files cant't be seen by LLM, but everything else works.
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# Strapi secrets - change these in production!
|
||||||
|
JWT_SECRET=your-jwt-secret-here
|
||||||
|
ADMIN_JWT_SECRET=your-admin-jwt-secret-here
|
||||||
|
APP_KEYS=your-app-key-1,your-app-key-2
|
||||||
|
API_TOKEN_SALT=your-api-token-salt
|
||||||
|
TRANSFER_TOKEN_SALT=your-transfer-token-salt
|
||||||
|
|
||||||
|
# Optional: Strapi API token for backend integration
|
||||||
|
STRAPI_API_TOKEN=
|
||||||
+5
-46
@@ -1,48 +1,7 @@
|
|||||||
# Dependencies
|
node_modules
|
||||||
node_modules/
|
dist
|
||||||
backend/node_modules/
|
|
||||||
frontend/node_modules/
|
|
||||||
.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
frontend/dist/
|
|
||||||
frontend/dist-ssr/
|
|
||||||
backend/dist/
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs/
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
data/
|
||||||
yarn-debug.log*
|
strapi/
|
||||||
yarn-error.log*
|
!.env.example
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea/
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
coverage/
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
*.tmp
|
|
||||||
*.temp
|
|
||||||
.cache/
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
data
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Create data directories
|
||||||
|
RUN mkdir -p /app/data/uploads /app/data/chromadb
|
||||||
|
|
||||||
|
EXPOSE 3002
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3002
|
||||||
|
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
+110
-2
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3002;
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -31,6 +31,42 @@ const knowledgeBases = new Map();
|
|||||||
const knowledgeFiles = new Map();
|
const knowledgeFiles = new Map();
|
||||||
const uploadedFiles = new Map();
|
const uploadedFiles = new Map();
|
||||||
|
|
||||||
|
// Token usage tracking
|
||||||
|
const tokenUsageStats = new Map(); // conversationId -> aggregated token stats
|
||||||
|
|
||||||
|
// In-memory storage for token usage events
|
||||||
|
const tokenUsageLog = [];
|
||||||
|
|
||||||
|
// Extract token usage from SSE response
|
||||||
|
function extractTokenUsage(fullResponse) {
|
||||||
|
try {
|
||||||
|
// Parse the last SSE data block which contains usage info
|
||||||
|
const lines = fullResponse.split('\n');
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6).trim();
|
||||||
|
if (data === '[DONE]') continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed.usage) {
|
||||||
|
return {
|
||||||
|
promptTokens: parsed.usage.prompt_tokens || 0,
|
||||||
|
completionTokens: parsed.usage.completion_tokens || 0,
|
||||||
|
totalTokens: parsed.usage.total_tokens || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Global ChromaDB client (recreated per request when path changes)
|
// Global ChromaDB client (recreated per request when path changes)
|
||||||
function getChromaClient(persistDir = './data/chromadb') {
|
function getChromaClient(persistDir = './data/chromadb') {
|
||||||
return new ChromaClient({
|
return new ChromaClient({
|
||||||
@@ -78,13 +114,31 @@ app.post('/api/v1/chat/completions', async (req, res) => {
|
|||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
let fullResponse = '';
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
res.write(decoder.decode(value, { stream: true }));
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
fullResponse += chunk;
|
||||||
|
res.write(chunk);
|
||||||
}
|
}
|
||||||
res.end();
|
res.end();
|
||||||
|
|
||||||
|
// Parse token usage from the final SSE message
|
||||||
|
const tokenUsage = extractTokenUsage(fullResponse);
|
||||||
|
if (tokenUsage) {
|
||||||
|
const usageEntry = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
conversationId: req.body.conversationId || 'unknown',
|
||||||
|
model,
|
||||||
|
promptTokens: tokenUsage.promptTokens,
|
||||||
|
completionTokens: tokenUsage.completionTokens,
|
||||||
|
totalTokens: tokenUsage.totalTokens,
|
||||||
|
};
|
||||||
|
tokenUsageLog.push(usageEntry);
|
||||||
|
console.log(`Token usage: ${usageEntry.totalTokens} tokens (${usageEntry.promptTokens} prompt, ${usageEntry.completionTokens} completion) for model ${model}`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Proxy error:', error);
|
console.error('Proxy error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -427,6 +481,60 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Token usage tracking endpoints
|
||||||
|
app.get('/api/token-usage', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { period = 'all', conversationId } = req.query;
|
||||||
|
|
||||||
|
let filtered = [...tokenUsageLog];
|
||||||
|
|
||||||
|
if (conversationId) {
|
||||||
|
filtered = filtered.filter((e) => e.conversationId === conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period === 'today') {
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
filtered = filtered.filter((e) => e.timestamp >= todayStart.getTime());
|
||||||
|
} else if (period === 'week') {
|
||||||
|
const weekStart = new Date();
|
||||||
|
weekStart.setDate(weekStart.getDate() - 7);
|
||||||
|
filtered = filtered.filter((e) => e.timestamp >= weekStart.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptTokens = filtered.reduce((sum, e) => sum + e.promptTokens, 0);
|
||||||
|
const completionTokens = filtered.reduce((sum, e) => sum + e.completionTokens, 0);
|
||||||
|
const totalTokens = filtered.reduce((sum, e) => sum + e.totalTokens, 0);
|
||||||
|
|
||||||
|
// Per-model breakdown
|
||||||
|
const modelBreakdown = {};
|
||||||
|
filtered.forEach((entry) => {
|
||||||
|
if (!modelBreakdown[entry.model]) {
|
||||||
|
modelBreakdown[entry.model] = { promptTokens: 0, completionTokens: 0, totalTokens: 0, requestCount: 0 };
|
||||||
|
}
|
||||||
|
modelBreakdown[entry.model].promptTokens += entry.promptTokens;
|
||||||
|
modelBreakdown[entry.model].completionTokens += entry.completionTokens;
|
||||||
|
modelBreakdown[entry.model].totalTokens += entry.totalTokens;
|
||||||
|
modelBreakdown[entry.model].requestCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
summary: { promptTokens, completionTokens, totalTokens, requestCount: filtered.length },
|
||||||
|
modelBreakdown,
|
||||||
|
history: filtered.slice(-100), // last 100 entries
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token usage error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/token-usage', (req, res) => {
|
||||||
|
tokenUsageLog.length = 0;
|
||||||
|
tokenUsageStats.clear();
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`AIUI Backend running on port ${PORT}`);
|
console.log(`AIUI Backend running on port ${PORT}`);
|
||||||
console.log(`Upload directory: ${uploadDir}`);
|
console.log(`Upload directory: ${uploadDir}`);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Nginx reverse proxy
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
- strapi
|
||||||
|
networks:
|
||||||
|
- aiui-network
|
||||||
|
|
||||||
|
# Frontend (React + Vite built to static files)
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
networks:
|
||||||
|
- aiui-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Backend (Express + ChromaDB)
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
expose:
|
||||||
|
- "3002"
|
||||||
|
volumes:
|
||||||
|
- ./data/backend:/app/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3002
|
||||||
|
- STRAPI_URL=http://strapi:1337
|
||||||
|
- STRAPI_API_TOKEN=${STRAPI_API_TOKEN:-}
|
||||||
|
networks:
|
||||||
|
- aiui-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Strapi CMS (SQLite)
|
||||||
|
strapi:
|
||||||
|
image: strapi/strapi:5
|
||||||
|
expose:
|
||||||
|
- "1337"
|
||||||
|
volumes:
|
||||||
|
- ./data/strapi:/srv/app
|
||||||
|
environment:
|
||||||
|
- DATABASE_CLIENT=sqlite
|
||||||
|
- DATABASE_FILENAME=.tmp/data.db
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-changemejwt}
|
||||||
|
- ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET:-changemeadmin}
|
||||||
|
- APP_KEYS=${APP_KEYS:-changeme1,changeme2}
|
||||||
|
- API_TOKEN_SALT=${API_TOKEN_SALT:-changemesalt}
|
||||||
|
- TRANSFER_TOKEN_SALT=${TRANSFER_TOKEN_SALT:-changemetoken}
|
||||||
|
- NODE_ENV=production
|
||||||
|
networks:
|
||||||
|
- aiui-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
aiui-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend-data:
|
||||||
|
strapi-data:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage - serve with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:3002/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# SSE support
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strapi admin/proxy (optional)
|
||||||
|
location /strapi/ {
|
||||||
|
proxy_pass http://strapi:1337/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,7 +105,8 @@ export default function ChatArea({ conversationId }: ChatAreaProps) {
|
|||||||
settings.temperature,
|
settings.temperature,
|
||||||
settings.maxTokens,
|
settings.maxTokens,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
abortControllerRef.current.signal
|
abortControllerRef.current.signal,
|
||||||
|
conversationId
|
||||||
);
|
);
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { X, RefreshCw, Eye, EyeOff } from 'lucide-react';
|
import { X, RefreshCw, Eye, EyeOff, BarChart3 } from 'lucide-react';
|
||||||
import { useChatStore } from '@/stores/chatStore';
|
import { useChatStore } from '@/stores/chatStore';
|
||||||
import { fetchModels } from '@/services/api';
|
import { fetchModels } from '@/services/api';
|
||||||
|
import TokenUsagePanel from './TokenUsagePanel';
|
||||||
|
|
||||||
export default function SettingsModal() {
|
export default function SettingsModal() {
|
||||||
const { settings, updateSettings, toggleSettings, closeSettings } = useChatStore();
|
const { settings, updateSettings, toggleSettings, closeSettings } = useChatStore();
|
||||||
|
const [activeTab, setActiveTab] = useState<'settings' | 'tokens'>('settings');
|
||||||
const [localSettings, setLocalSettings] = useState(settings);
|
const [localSettings, setLocalSettings] = useState(settings);
|
||||||
const [models, setModels] = useState<string[]>([]);
|
const [models, setModels] = useState<string[]>([]);
|
||||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||||
@@ -52,20 +54,51 @@ export default function SettingsModal() {
|
|||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={toggleSettings}
|
onClick={toggleSettings}
|
||||||
/>
|
/>
|
||||||
<div className="relative bg-[var(--color-surface)] border border-[var(--color-border)] rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-2xl">
|
<div className="relative bg-[var(--color-surface)] border border-[var(--color-border)] rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-2xl flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-[var(--color-border)]">
|
<div className="flex-shrink-0">
|
||||||
<h2 className="text-xl font-semibold text-[var(--color-text)]">Settings</h2>
|
<div className="flex items-center justify-between p-6 border-b border-[var(--color-border)]">
|
||||||
<button
|
<h2 className="text-xl font-semibold text-[var(--color-text)]">Settings</h2>
|
||||||
onClick={toggleSettings}
|
<button
|
||||||
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
|
onClick={toggleSettings}
|
||||||
>
|
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors"
|
||||||
<X size={20} />
|
>
|
||||||
</button>
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 px-6 pb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('settings')}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-t-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'settings'
|
||||||
|
? 'bg-[var(--color-bg)] text-[var(--color-text)] border-t-2 border-[var(--color-accent)]'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Configuration
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tokens')}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-t-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'tokens'
|
||||||
|
? 'bg-[var(--color-bg)] text-[var(--color-text)] border-t-2 border-[var(--color-accent)]'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
|
<BarChart3 size={14} />
|
||||||
|
<span>Token Usage</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 space-y-5">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{activeTab === 'settings' ? (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||||
{error}
|
{error}
|
||||||
@@ -217,59 +250,67 @@ export default function SettingsModal() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="border-[var(--color-border)]" />
|
<hr className="border-[var(--color-border)]" />
|
||||||
|
|
||||||
{/* Embedding Model */}
|
{/* Embedding Model */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
||||||
Embedding Model
|
Embedding Model
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={localSettings.embeddingModel}
|
value={localSettings.embeddingModel}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setLocalSettings((prev) => ({ ...prev, embeddingModel: e.target.value }))
|
setLocalSettings((prev) => ({ ...prev, embeddingModel: e.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="text-embedding-3-small"
|
placeholder="text-embedding-3-small"
|
||||||
className="input-field"
|
className="input-field"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
Model used for knowledge base embeddings
|
Model used for knowledge base embeddings
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ChromaDB Directory */}
|
{/* ChromaDB Directory */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
||||||
ChromaDB Directory
|
ChromaDB Directory
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={localSettings.chromaDir}
|
value={localSettings.chromaDir}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setLocalSettings((prev) => ({ ...prev, chromaDir: e.target.value }))
|
setLocalSettings((prev) => ({ ...prev, chromaDir: e.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="./data/chromadb"
|
placeholder="./data/chromadb"
|
||||||
className="input-field"
|
className="input-field"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
Path to ChromaDB persistent storage on the server
|
Path to ChromaDB persistent storage on the server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6">
|
||||||
|
<TokenUsagePanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-[var(--color-border)]">
|
{activeTab === 'settings' && (
|
||||||
<button
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-[var(--color-border)] shrink-0">
|
||||||
onClick={toggleSettings}
|
<button
|
||||||
className="px-4 py-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
onClick={toggleSettings}
|
||||||
>
|
className="px-4 py-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
<button onClick={handleSave} className="btn-primary">
|
</button>
|
||||||
Save Settings
|
<button onClick={handleSave} className="btn-primary">
|
||||||
</button>
|
Save Settings
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { RefreshCw, Trash2, BarChart3 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TokenUsageSummary {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
requestCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelBreakdown {
|
||||||
|
[key: string]: {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
requestCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenUsageHistory {
|
||||||
|
timestamp: number;
|
||||||
|
conversationId: string;
|
||||||
|
model: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenUsageData {
|
||||||
|
summary: TokenUsageSummary;
|
||||||
|
modelBreakdown: ModelBreakdown;
|
||||||
|
history: TokenUsageHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimePeriod = 'all' | 'today' | 'week';
|
||||||
|
|
||||||
|
export default function TokenUsagePanel() {
|
||||||
|
const [data, setData] = useState<TokenUsageData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [period, setPeriod] = useState<TimePeriod>('all');
|
||||||
|
const [expandedModel, setExpandedModel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/token-usage?period=${period}`);
|
||||||
|
const result = await res.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch token usage:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
if (!confirm('Clear all token usage data?')) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/token-usage', { method: 'DELETE' });
|
||||||
|
setData(null);
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clear token usage:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (n: number) => n.toLocaleString();
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--color-text)]">
|
||||||
|
<BarChart3 size={20} className="text-[var(--color-accent)]" />
|
||||||
|
<h3 className="text-sm font-medium">Token Usage</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={!data || data.summary.requestCount === 0}
|
||||||
|
className="p-2 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:text-red-400 transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period filter */}
|
||||||
|
<div className="flex gap-1 p-1 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||||
|
{(['all', 'today', 'week'] as TimePeriod[]).map((p) => {
|
||||||
|
const labels = { all: 'All', today: 'Today', week: 'Week' };
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
period === p
|
||||||
|
? 'bg-[var(--color-accent)] text-white'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{labels[p]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{data && data.summary.requestCount > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-1">Total Tokens</p>
|
||||||
|
<p className="text-lg font-semibold text-[var(--color-text)]">
|
||||||
|
{formatNumber(data.summary.totalTokens)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-1">Requests</p>
|
||||||
|
<p className="text-lg font-semibold text-[var(--color-text)]">
|
||||||
|
{data.summary.requestCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-1">Prompt Tokens</p>
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text)]">
|
||||||
|
{formatNumber(data.summary.promptTokens)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-1">Completion Tokens</p>
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text)]">
|
||||||
|
{formatNumber(data.summary.completionTokens)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-[var(--color-text-muted)]">
|
||||||
|
{loading ? 'Loading...' : 'No token usage data yet'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model breakdown */}
|
||||||
|
{data && Object.keys(data.modelBreakdown).length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text)]">By Model</p>
|
||||||
|
{Object.entries(data.modelBreakdown)
|
||||||
|
.sort((a, b) => b[1].totalTokens - a[1].totalTokens)
|
||||||
|
.map(([model, stats]) => (
|
||||||
|
<div
|
||||||
|
key={model}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedModel(expandedModel === model ? null : model)}
|
||||||
|
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text)] truncate">
|
||||||
|
{model}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{formatNumber(stats.totalTokens)} tokens · {stats.requestCount} requests
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 ml-4">
|
||||||
|
<p className="text-xs text-[var(--color-text-secondary)]">
|
||||||
|
{formatNumber(stats.promptTokens)} / {formatNumber(stats.completionTokens)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedModel === model && (
|
||||||
|
<div className="px-4 pb-3 border-t border-[var(--color-border)] pt-3">
|
||||||
|
{/* Simple bar chart */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)] w-16">Prompt</span>
|
||||||
|
<div className="flex-1 h-3 rounded bg-[var(--color-surface)] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded bg-[var(--color-accent)] transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
stats.totalTokens > 0
|
||||||
|
? (stats.promptTokens / stats.totalTokens) * 100
|
||||||
|
: 0
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--color-text)] w-16 text-right">
|
||||||
|
{formatNumber(stats.promptTokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)] w-16">Completion</span>
|
||||||
|
<div className="flex-1 h-3 rounded bg-[var(--color-surface)] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded bg-green-500 transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
stats.totalTokens > 0
|
||||||
|
? (stats.completionTokens / stats.totalTokens) * 100
|
||||||
|
: 0
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--color-text)] w-16 text-right">
|
||||||
|
{formatNumber(stats.completionTokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show recent history for this model */}
|
||||||
|
{data.history.filter((h) => h.model === model).length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-2">Recent requests</p>
|
||||||
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{data.history
|
||||||
|
.filter((h) => h.model === model)
|
||||||
|
.slice(-5)
|
||||||
|
.reverse()
|
||||||
|
.map((entry, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between text-xs px-2 py-1 rounded bg-[var(--color-surface)]"
|
||||||
|
>
|
||||||
|
<span className="text-[var(--color-text-muted)]">{formatTime(entry.timestamp)}</span>
|
||||||
|
<span className="text-[var(--color-text)]">
|
||||||
|
{formatNumber(entry.totalTokens)} tokens
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History timeline */}
|
||||||
|
{data && data.history.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text)]">Recent History</p>
|
||||||
|
{data.history.slice(-10).reverse().map((entry, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 text-xs px-3 py-2 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)]"
|
||||||
|
>
|
||||||
|
<span className="text-[var(--color-text-muted)] w-12 shrink-0">
|
||||||
|
{formatTime(entry.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--color-text)] truncate flex-1">{entry.model}</span>
|
||||||
|
<span className="text-[var(--color-text-secondary)] shrink-0">
|
||||||
|
{formatNumber(entry.totalTokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp } from 'lucide-react';
|
import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp, FolderOpen, AlertCircle } from 'lucide-react';
|
||||||
import { useChatStore } from '@/stores/chatStore';
|
import { useChatStore } from '@/stores/chatStore';
|
||||||
|
|
||||||
|
interface FileResult {
|
||||||
|
id: string;
|
||||||
|
originalName: string;
|
||||||
|
size: number;
|
||||||
|
chunkCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function KnowledgePanel() {
|
export default function KnowledgePanel() {
|
||||||
const { settings, toggleKnowledge, isKnowledgeOpen, setKnowledgeBases, knowledgeBases, removeKnowledgeBase } = useChatStore();
|
const { settings, toggleKnowledge, isKnowledgeOpen, setKnowledgeBases, knowledgeBases, removeKnowledgeBase } = useChatStore();
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
@@ -10,6 +18,10 @@ export default function KnowledgePanel() {
|
|||||||
const [expandedBase, setExpandedBase] = useState<string | null>(null);
|
const [expandedBase, setExpandedBase] = useState<string | null>(null);
|
||||||
const [uploadingBase, setUploadingBase] = useState<string | null>(null);
|
const [uploadingBase, setUploadingBase] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [folderLoading, setFolderLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isKnowledgeOpen) loadBases();
|
if (isKnowledgeOpen) loadBases();
|
||||||
@@ -17,11 +29,15 @@ export default function KnowledgePanel() {
|
|||||||
|
|
||||||
const loadBases = async () => {
|
const loadBases = async () => {
|
||||||
try {
|
try {
|
||||||
|
setError(null);
|
||||||
const res = await fetch('/api/knowledge');
|
const res = await fetch('/api/knowledge');
|
||||||
|
if (!res.ok) throw new Error('Failed to load');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setKnowledgeBases(data.bases || []);
|
setKnowledgeBases(data.bases || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load knowledge bases:', err);
|
console.error('Failed to load knowledge bases:', err);
|
||||||
|
setError('Could not connect to knowledge base service. Is the backend running?');
|
||||||
|
setKnowledgeBases([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,12 +51,13 @@ export default function KnowledgePanel() {
|
|||||||
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
|
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }),
|
||||||
});
|
});
|
||||||
const base = await res.json();
|
const base = await res.json();
|
||||||
|
if (base.error) throw new Error(base.error);
|
||||||
setKnowledgeBases([...knowledgeBases, base]);
|
setKnowledgeBases([...knowledgeBases, base]);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
setNewDesc('');
|
setNewDesc('');
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
alert('Failed to create knowledge base');
|
setError(err.message || 'Failed to create knowledge base');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -52,7 +69,7 @@ export default function KnowledgePanel() {
|
|||||||
await fetch(`/api/knowledge/${id}`, { method: 'DELETE' });
|
await fetch(`/api/knowledge/${id}`, { method: 'DELETE' });
|
||||||
removeKnowledgeBase(id);
|
removeKnowledgeBase(id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete knowledge base');
|
setError('Failed to delete knowledge base');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +77,7 @@ export default function KnowledgePanel() {
|
|||||||
if (!e.target.files?.[0]) return;
|
if (!e.target.files?.[0]) return;
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
setUploadingBase(baseId);
|
setUploadingBase(baseId);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -76,7 +94,6 @@ export default function KnowledgePanel() {
|
|||||||
if (!res.ok) throw new Error('Upload failed');
|
if (!res.ok) throw new Error('Upload failed');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Update local state
|
|
||||||
const updated = knowledgeBases.map((b) =>
|
const updated = knowledgeBases.map((b) =>
|
||||||
b.id === baseId
|
b.id === baseId
|
||||||
? { ...b, files: [...b.files, data.file], documentCount: (b.documentCount || 0) + data.chunks }
|
? { ...b, files: [...b.files, data.file], documentCount: (b.documentCount || 0) + data.chunks }
|
||||||
@@ -84,13 +101,68 @@ export default function KnowledgePanel() {
|
|||||||
);
|
);
|
||||||
setKnowledgeBases(updated);
|
setKnowledgeBases(updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to upload file. Make sure API settings are configured.');
|
setError('Failed to upload file. Make sure API settings are configured.');
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingBase(null);
|
setUploadingBase(null);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUploadFolder = async (baseId: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
setFolderLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// For browser folder upload, we can't get the actual folder path.
|
||||||
|
// Instead, upload all files from the file picker using webkitdirectory.
|
||||||
|
const formData = new FormData();
|
||||||
|
Array.from(files).forEach((f) => formData.append('files', f));
|
||||||
|
formData.append('apiUrl', settings.apiUrl);
|
||||||
|
formData.append('apiKey', settings.apiKey);
|
||||||
|
formData.append('embeddingModel', settings.embeddingModel);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use a batch upload approach — upload files one by one
|
||||||
|
const uploaded: FileResult[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const singleForm = new FormData();
|
||||||
|
singleForm.append('file', file);
|
||||||
|
singleForm.append('apiUrl', settings.apiUrl);
|
||||||
|
singleForm.append('apiKey', settings.apiKey);
|
||||||
|
singleForm.append('embeddingModel', settings.embeddingModel);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/knowledge/${baseId}/files`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: singleForm,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed');
|
||||||
|
const data = await res.json();
|
||||||
|
uploaded.push(data.file);
|
||||||
|
} catch {
|
||||||
|
errors.push(file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploaded.length > 0) {
|
||||||
|
await loadBases();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
setError(`${errors.length} files failed to upload out of ${files.length}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Folder upload failed');
|
||||||
|
} finally {
|
||||||
|
setFolderLoading(false);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteFile = async (baseId: string, fileId: string) => {
|
const handleDeleteFile = async (baseId: string, fileId: string) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/knowledge/${baseId}/files/${fileId}`, { method: 'DELETE' });
|
await fetch(`/api/knowledge/${baseId}/files/${fileId}`, { method: 'DELETE' });
|
||||||
@@ -105,7 +177,7 @@ export default function KnowledgePanel() {
|
|||||||
);
|
);
|
||||||
setKnowledgeBases(updated);
|
setKnowledgeBases(updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete file');
|
setError('Failed to delete file');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,6 +203,17 @@ export default function KnowledgePanel() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className="ml-auto text-red-400 hover:text-red-300">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create new */}
|
{/* Create new */}
|
||||||
{!isCreating ? (
|
{!isCreating ? (
|
||||||
<button
|
<button
|
||||||
@@ -203,7 +286,7 @@ export default function KnowledgePanel() {
|
|||||||
{base.name}
|
{base.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
{base.documentCount || 0} chunks · {base.files.length} files
|
{base.documentCount || 0} chunks · {base.files?.length || 0} files
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -222,7 +305,7 @@ export default function KnowledgePanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Files */}
|
{/* Files */}
|
||||||
{base.files.length > 0 && (
|
{base.files && base.files.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{base.files.map((file) => (
|
{base.files.map((file) => (
|
||||||
<div
|
<div
|
||||||
@@ -235,7 +318,7 @@ export default function KnowledgePanel() {
|
|||||||
{file.originalName}
|
{file.originalName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[var(--color-text-muted)] shrink-0">
|
<span className="text-[var(--color-text-muted)] shrink-0">
|
||||||
({file.chunkCount} chunks)
|
({file.chunkCount || 0} chunks)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -249,25 +332,47 @@ export default function KnowledgePanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upload */}
|
{/* Upload buttons */}
|
||||||
<div className="relative">
|
<div className="flex gap-2">
|
||||||
<label className={`flex items-center justify-center gap-2 w-full px-3 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] transition-colors cursor-pointer ${uploadingBase === base.id ? 'opacity-50' : ''}`}>
|
<label className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] transition-colors cursor-pointer ${uploadingBase === base.id ? 'opacity-50' : ''}`}>
|
||||||
{uploadingBase === base.id ? (
|
{uploadingBase === base.id ? (
|
||||||
<span className="animate-pulse">Processing...</span>
|
<span className="animate-pulse">Processing...</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
<span>Upload document</span>
|
<span>Upload file</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept=".txt,.md,.pdf,.docx,.json,.csv,.js,.ts,.html,.css"
|
||||||
|
onChange={(e) => handleUploadFile(base.id, e)}
|
||||||
|
disabled={uploadingBase === base.id || folderLoading}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-dashed border-[var(--color-border)] text-xs text-[var(--color-text-muted)] hover:border-green-500 hover:text-green-500 transition-colors cursor-pointer ${folderLoading ? 'opacity-50' : ''}`}>
|
||||||
|
{folderLoading ? (
|
||||||
|
<span className="animate-pulse">Scanning folder...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
<span>Upload folder</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={folderInputRef}
|
||||||
|
// @ts-ignore — webkitdirectory is non-standard but widely supported
|
||||||
|
webkitdirectory=""
|
||||||
|
directory=""
|
||||||
|
multiple
|
||||||
|
onChange={(e) => handleUploadFolder(base.id, e)}
|
||||||
|
disabled={uploadingBase === base.id || folderLoading}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".txt,.md,.pdf,.docx"
|
|
||||||
onChange={(e) => handleUploadFile(base.id, e)}
|
|
||||||
disabled={uploadingBase === base.id}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export async function* streamChatCompletion(
|
|||||||
temperature: number,
|
temperature: number,
|
||||||
maxTokens: number,
|
maxTokens: number,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
abortSignal: AbortSignal
|
abortSignal: AbortSignal,
|
||||||
|
conversationId?: string
|
||||||
): AsyncGenerator<OpenAIStreamChunk, void, unknown> {
|
): AsyncGenerator<OpenAIStreamChunk, void, unknown> {
|
||||||
const formattedMessages: OpenAIMessage[] = [
|
const formattedMessages: OpenAIMessage[] = [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -28,6 +29,7 @@ export async function* streamChatCompletion(
|
|||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
messages: formattedMessages,
|
messages: formattedMessages,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
conversationId,
|
||||||
}),
|
}),
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => ({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -13,9 +13,13 @@ export default defineConfig({
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: mode === 'development' ? 'http://localhost:3002' : 'http://backend:3002',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: mode === 'development',
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||||
|
|
||||||
|
# Frontend (static files)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:80/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:3002/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# SSE support for streaming
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strapi
|
||||||
|
location /strapi/ {
|
||||||
|
proxy_pass http://strapi:1337/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user