From 5887462f7d3044b3e8d9d39fcb36e428c2dfa67c Mon Sep 17 00:00:00 2001 From: ekko Date: Sun, 12 Apr 2026 23:23:50 +0800 Subject: [PATCH] feat: add model selector, skills/memory pages, and config management - Add model selector in sidebar that discovers models from auth.json credential pool - Add per-session model display (badge in chat header and session list) - Add skills browser page and memory editor page - Add BFF routes for skills, memory, and config model management - Model switching updates config.yaml provider field to bypass env auto-detection - Refactor Settings page, simplify ChatInput with file upload - Add attachment upload support via BFF /upload endpoint Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 +- README.md | 21 +- package.json | 2 +- server/src/index.ts | 2 + server/src/routes/filesystem.ts | 511 ++++++++++++++++++++++++ src/App.vue | 1 + src/api/chat.ts | 1 + src/api/skills.ts | 55 +++ src/api/system.ts | 68 ++++ src/components/chat/ChatInput.vue | 135 +++++-- src/components/chat/ChatPanel.vue | 46 ++- src/components/layout/AppSidebar.vue | 29 ++ src/components/layout/ModelSelector.vue | 55 +++ src/components/skills/SkillDetail.vue | 247 ++++++++++++ src/components/skills/SkillList.vue | 193 +++++++++ src/router/index.ts | 12 +- src/stores/app.ts | 32 +- src/stores/chat.ts | 46 ++- src/views/MemoryView.vue | 322 +++++++++++++++ src/views/SettingsView.vue | 113 +++--- src/views/SkillsView.vue | 154 +++++++ 21 files changed, 1941 insertions(+), 106 deletions(-) create mode 100644 server/src/routes/filesystem.ts create mode 100644 src/api/skills.ts create mode 100644 src/components/layout/ModelSelector.vue create mode 100644 src/components/skills/SkillDetail.vue create mode 100644 src/components/skills/SkillList.vue create mode 100644 src/views/MemoryView.vue create mode 100644 src/views/SkillsView.vue diff --git a/.gitignore b/.gitignore index 6b4e9311..5d42b0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ dist dist-ssr server/dist *.local - +ROADMAP.md # Server data server/data/ server/node_modules/ diff --git a/README.md b/README.md index 1fbc9e77..19198e9b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ hermes-web-ui/ │ │ ├── proxy.ts # API proxy to Hermes (/api/*, /v1/*) │ │ ├── upload.ts # File upload (POST /upload) │ │ ├── sessions.ts # Session management via Hermes CLI +│ │ ├── filesystem.ts # Skills, memory, config model management │ │ ├── webhook.ts # Webhook receiver │ │ └── logs.ts # Log file listing and reading │ └── services/ @@ -80,13 +81,16 @@ hermes-web-ui/ │ ├── api/ # Frontend API layer │ ├── stores/ # Pinia state management │ ├── components/ -│ │ ├── layout/AppSidebar.vue # Sidebar navigation +│ │ ├── layout/ +│ │ │ ├── AppSidebar.vue # Sidebar navigation +│ │ │ └── ModelSelector.vue # Global model selector │ │ ├── chat/ # Chat components │ │ └── jobs/ # Job components │ ├── views/ │ │ ├── ChatView.vue # Chat page │ │ ├── JobsView.vue # Jobs page -│ │ └── LogsView.vue # Logs page +│ │ ├── LogsView.vue # Logs page +│ │ └── SettingsView.vue # Settings (model management) │ └── router/index.ts # Router configuration └── dist/ # Build output (published to npm) ├── server/index.js # Compiled BFF @@ -102,6 +106,16 @@ hermes-web-ui/ - Multi-session switching with message history - Markdown rendering with syntax highlighting and code copy - File upload support (saved to temp, path passed to API) +- Model selector — automatically discovers available models from `~/.hermes/auth.json` credential pool +- Global model switching (updates `~/.hermes/config.yaml`) +- Per-session model display (badge in chat header and session list) + +### Model Management +- Automatically reads credential pool from `~/.hermes/auth.json` +- Fetches available models from each provider endpoint (`/v1/models`) +- Groups models by provider (e.g. zai, subrouter.ai) +- Switching model updates `model.provider` in config.yaml to bypass env auto-detection +- Error handling: parallel fetching, per-provider timeout, fallback to config.yaml parsing ### Scheduled Jobs - Job list view (including paused/disabled jobs) @@ -133,6 +147,9 @@ The BFF layer handles: - SSE streaming passthrough - File upload to temp directory - Session CRUD via Hermes CLI +- Model discovery from `~/.hermes/auth.json` credential pool +- Config.yaml model switching (reads/writes `~/.hermes/config.yaml`) +- Skills, memory, and custom provider management - Log file reading and parsing - Static file serving (SPA fallback) diff --git a/package.json b/package.json index 93e3e12a..2ea9661b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.1.3", + "version": "0.1.4", "description": "Hermes Agent Web UI - Chat and Job Management Dashboard", "repository": { "type": "git", diff --git a/server/src/index.ts b/server/src/index.ts index 767d65cc..f7e8ba94 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -11,6 +11,7 @@ import { uploadRoutes } from './routes/upload' import { sessionRoutes } from './routes/sessions' import { webhookRoutes } from './routes/webhook' import { logRoutes } from './routes/logs' +import { fsRoutes } from './routes/filesystem' import * as hermesCli from './services/hermes-cli' const { restartGateway } = hermesCli @@ -28,6 +29,7 @@ export async function bootstrap() { app.use(logRoutes.routes()) app.use(uploadRoutes.routes()) app.use(sessionRoutes.routes()) + app.use(fsRoutes.routes()) // Health endpoint with version app.use(async (ctx, next) => { diff --git a/server/src/routes/filesystem.ts b/server/src/routes/filesystem.ts new file mode 100644 index 00000000..980bca7f --- /dev/null +++ b/server/src/routes/filesystem.ts @@ -0,0 +1,511 @@ +import Router from '@koa/router' +import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises' +import { join, resolve } from 'path' +import { homedir } from 'os' + +// --- Auth / Credential Pool --- + +interface CredentialPoolEntry { + id: string + label: string + base_url: string + access_token: string + last_status?: string | null +} + +interface AuthJson { + credential_pool?: Record +} + +const authPath = resolve(homedir(), '.hermes', 'auth.json') + +async function loadAuthJson(): Promise { + try { + const raw = await readFile(authPath, 'utf-8') + return JSON.parse(raw) as AuthJson + } catch { + return null + } +} + +async function fetchProviderModels(baseUrl: string, apiKey: string): Promise { + try { + const url = baseUrl.replace(/\/+$/, '') + '/models' + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(8000), + }) + if (!res.ok) { + console.error(`[available-models] ${baseUrl} returned ${res.status}`) + return [] + } + const data = await res.json() as { data?: Array<{ id: string }> } + if (!Array.isArray(data.data)) { + console.error(`[available-models] ${baseUrl} returned unexpected format`) + return [] + } + return data.data.map(m => m.id).sort() + } catch (err: any) { + console.error(`[available-models] ${baseUrl} failed: ${err.message}`) + return [] + } +} + +export const fsRoutes = new Router() + +const hermesDir = resolve(homedir(), '.hermes') + +// --- Types --- + +interface SkillInfo { + name: string + description: string +} + +interface SkillCategory { + name: string + description: string + skills: SkillInfo[] +} + +// --- Helpers --- + +function extractDescription(content: string): string { + // SKILL.md format: YAML frontmatter between --- delimiters, then markdown body + // Extract first non-empty, non-frontmatter, non-heading line as description + const lines = content.split('\n') + let inFrontmatter = false + let bodyStarted = false + + for (const line of lines) { + if (!bodyStarted && line.trim() === '---') { + if (!inFrontmatter) { + inFrontmatter = true + continue + } else { + inFrontmatter = false + bodyStarted = true + continue + } + } + if (inFrontmatter) continue + if (line.trim() === '') continue + if (line.startsWith('#')) continue + // Return first meaningful line, truncated + return line.trim().slice(0, 80) + } + return '' +} + +async function safeReadFile(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8') + } catch { + return null + } +} + +async function safeStat(filePath: string): Promise<{ mtime: number } | null> { + try { + const s = await stat(filePath) + return { mtime: Math.round(s.mtimeMs) } + } catch { + return null + } +} + +// --- Skills Routes --- + +// List all skills grouped by category +fsRoutes.get('/api/skills', async (ctx) => { + const skillsDir = join(hermesDir, 'skills') + + try { + const entries = await readdir(skillsDir, { withFileTypes: true }) + const categories: SkillCategory[] = [] + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + + const catDir = join(skillsDir, entry.name) + const catDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md')) + const catDescription = catDesc ? catDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : '' + + const skillEntries = await readdir(catDir, { withFileTypes: true }) + const skills: SkillInfo[] = [] + + for (const se of skillEntries) { + if (!se.isDirectory()) continue + const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md')) + if (skillMd) { + skills.push({ + name: se.name, + description: extractDescription(skillMd), + }) + } + } + + if (skills.length > 0) { + categories.push({ name: entry.name, description: catDescription, skills }) + } + } + + // Sort categories alphabetically + categories.sort((a, b) => a.name.localeCompare(b.name)) + for (const cat of categories) { + cat.skills.sort((a, b) => a.name.localeCompare(b.name)) + } + + ctx.body = { categories } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: `Failed to read skills directory: ${err.message}` } + } +}) + +// List files in a skill directory (for references/templates/scripts) +// Must be registered before the wildcard route +async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> { + const result: { path: string; name: string }[] = [] + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return result + } + for (const entry of entries) { + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + result.push(...await listFilesRecursive(join(dir, entry.name), relPath)) + } else { + result.push({ path: relPath, name: entry.name }) + } + } + return result +} + +fsRoutes.get('/api/skills/:category/:skill/files', async (ctx) => { + const { category, skill } = ctx.params + const skillDir = join(hermesDir, 'skills', category, skill) + + try { + const allFiles = await listFilesRecursive(skillDir, '') + const files = allFiles.filter(f => f.path !== 'SKILL.md') + ctx.body = { files } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// Read a specific file under skills/ +fsRoutes.get('/api/skills/:path(.+)', async (ctx) => { + const filePath = ctx.params.path + const fullPath = resolve(join(hermesDir, 'skills', filePath)) + + // Security: ensure path stays within skills directory + if (!fullPath.startsWith(join(hermesDir, 'skills'))) { + ctx.status = 403 + ctx.body = { error: 'Access denied' } + return + } + + const content = await safeReadFile(fullPath) + if (content === null) { + ctx.status = 404 + ctx.body = { error: 'File not found' } + return + } + + ctx.body = { content } +}) + +// --- Memory Routes --- + +// Read MEMORY.md and USER.md +fsRoutes.get('/api/memory', async (ctx) => { + const memoryPath = join(hermesDir, 'memories', 'MEMORY.md') + const userPath = join(hermesDir, 'memories', 'USER.md') + + const [memory, user, memoryStat, userStat] = await Promise.all([ + safeReadFile(memoryPath), + safeReadFile(userPath), + safeStat(memoryPath), + safeStat(userPath), + ]) + + ctx.body = { + memory: memory || '', + user: user || '', + memory_mtime: memoryStat?.mtime || null, + user_mtime: userStat?.mtime || null, + } +}) + +// Write MEMORY.md or USER.md +fsRoutes.post('/api/memory', async (ctx) => { + const { section, content } = ctx.request.body as { section: string; content: string } + + if (!section || !content) { + ctx.status = 400 + ctx.body = { error: 'Missing section or content' } + return + } + + if (section !== 'memory' && section !== 'user') { + ctx.status = 400 + ctx.body = { error: 'Section must be "memory" or "user"' } + return + } + + const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md' + const filePath = join(hermesDir, 'memories', fileName) + + try { + await writeFile(filePath, content, 'utf-8') + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// --- Config Model Routes --- + +const configPath = resolve(homedir(), '.hermes/config.yaml') + +interface ModelInfo { + id: string + label: string +} + +interface ModelGroup { + provider: string + models: ModelInfo[] +} + +// Build model list from user's actual config.yaml configuration +// Only shows models the user has explicitly configured, not entire provider catalogs +function buildModelGroups(yaml: string): { default: string; groups: ModelGroup[] } { + let defaultModel = '' + let defaultProvider = '' + const groups: ModelGroup[] = [] + const allModelIds = new Set() + + // 1. Extract current model from `model:` section + const defaultMatch = yaml.match(/^model:\s*\n\s+default:\s*(.+)/m) + if (defaultMatch) defaultModel = defaultMatch[1].trim() + const providerMatch = yaml.match(/^model:\s*\n(?:.*\n)*?\s+provider:\s*(.+)/m) + if (providerMatch) defaultProvider = providerMatch[1].trim() + + // 2. Extract providers: section (user-defined endpoints) + const providersSection = yaml.match(/^providers:\s*\n((?: .+\n(?: .+\n)*)*)/m) + if (providersSection) { + const entries = providersSection[1].match(/^ (\S+):\s*\n((?: .+\n)*)/gm) + if (entries) { + for (const entry of entries) { + const nameMatch = entry.match(/^ (\S+):/) + const baseUrlMatch = entry.match(/base_url:\s*(.+)/) + const name = nameMatch?.[1]?.trim() + if (name) { + // Provider entry itself — mark as available but don't add model yet + // (it's an endpoint the user can switch to, models are fetched at runtime) + } + } + } + } + + // 3. Extract custom_providers: section + const customSection = yaml.match(/^custom_providers:\s*\n((?:\s*- .+\n(?: .+\n)*)*)/m) + if (customSection) { + const entryBlocks = customSection[1].match(/\s*- name:\s*(.+)\n((?: .+\n)*)/g) + if (entryBlocks) { + const customModels: ModelInfo[] = [] + for (const block of entryBlocks) { + const cName = block.match(/name:\s*(.+)/)?.[1]?.trim() + const cModel = block.match(/model:\s*(.+)/)?.[1]?.trim() + if (cName && cModel) { + customModels.push({ id: cModel, label: `${cName}: ${cModel}` }) + allModelIds.add(cModel) + } + } + if (customModels.length > 0) { + groups.push({ provider: 'Custom', models: customModels }) + } + } + } + + // 4. Add current default model (if not already in custom_providers) + if (defaultModel && !allModelIds.has(defaultModel)) { + groups.unshift({ provider: 'Current', models: [{ id: defaultModel, label: defaultModel }] }) + } + + return { default: defaultModel, groups } +} + +// GET /api/available-models — fetch models from all credential pool endpoints +fsRoutes.get('/api/available-models', async (ctx) => { + try { + const auth = await loadAuthJson() + const pool = auth?.credential_pool || {} + + // Read current default model from config.yaml + const yaml = await safeReadFile(configPath) || '' + const defaultMatch = yaml.match(/^model:\s*\n\s+default:\s*(.+)/m) + const currentDefault = defaultMatch?.[1]?.trim() || '' + + // Collect unique endpoints from credential pool + const endpoints: Array<{ key: string; label: string; base_url: string; token: string }> = [] + const seenUrls = new Set() + + for (const [providerKey, entries] of Object.entries(pool)) { + if (!Array.isArray(entries) || entries.length === 0) continue + const entry = entries.find(e => e.last_status !== 'exhausted') || entries[0] + if (!entry?.base_url || !entry?.access_token) continue + const baseUrl = entry.base_url.replace(/\/+$/, '') + if (seenUrls.has(baseUrl)) continue + seenUrls.add(baseUrl) + endpoints.push({ + key: providerKey, + label: providerKey.replace(/^custom:/, '') || entry.label || baseUrl, + base_url: baseUrl, + token: entry.access_token, + }) + } + + // Fetch all provider models in parallel + const results = await Promise.allSettled( + endpoints.map(async ep => { + const models = await fetchProviderModels(ep.base_url, ep.token) + return { ...ep, models } + }), + ) + + const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = [] + for (const result of results) { + if (result.status === 'fulfilled' && result.value.models.length > 0) { + const { key, label, base_url, models } = result.value + groups.push({ provider: key, label, base_url, models }) + } else if (result.status === 'rejected') { + console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`) + } + } + + // Fallback: if no providers returned models, fall back to config.yaml parsing + if (groups.length === 0) { + const fallback = buildModelGroups(yaml) + ctx.body = fallback + return + } + + ctx.body = { default: currentDefault, groups } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// GET /api/config/models +fsRoutes.get('/api/config/models', async (ctx) => { + try { + const yaml = await safeReadFile(configPath) + ctx.body = yaml ? buildModelGroups(yaml) : { default: '', groups: [] } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// PUT /api/config/model +fsRoutes.put('/api/config/model', async (ctx) => { + const { default: defaultModel, provider: reqProvider } = ctx.request.body as { + default: string + provider?: string + } + + if (!defaultModel) { + ctx.status = 400 + ctx.body = { error: 'Missing default model' } + return + } + + try { + await copyFile(configPath, configPath + '.bak') + let yaml = await safeReadFile(configPath) || '' + + // Rebuild the model: block + const modelBlockMatch = yaml.match(/^(model:\s*\n(?: .+\n)*)/m) + if (modelBlockMatch) { + const lines = [`model:`, ` default: ${defaultModel}`] + + if (reqProvider) { + // Provider from credential pool key (e.g. "zai" or "custom:subrouter.ai") + // Hermes resolves base_url/api_key from auth.json automatically + lines.push(` provider: ${reqProvider}`) + } + + yaml = yaml.replace(modelBlockMatch[1], lines.join('\n') + '\n') + } + + await writeFile(configPath, yaml, 'utf-8') + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// POST /api/config/providers +fsRoutes.post('/api/config/providers', async (ctx) => { + const { name, base_url, api_key, model } = ctx.request.body as { + name: string + base_url: string + api_key: string + model: string + } + + if (!name || !base_url || !model) { + ctx.status = 400 + ctx.body = { error: 'Missing name, base_url, or model' } + return + } + + try { + await copyFile(configPath, configPath + '.bak') + let yaml = await safeReadFile(configPath) || '' + + const newEntry = `- name: ${name}\n base_url: ${base_url}\n api_key: ${api_key || ''}\n model: ${model}\n` + + if (/^custom_providers:/m.test(yaml)) { + yaml = yaml.replace(/^(custom_providers:)/m, `$1\n${newEntry}`) + } else { + yaml = yaml.trimEnd() + `\n\ncustom_providers:\n${newEntry}\n` + } + + await writeFile(configPath, yaml, 'utf-8') + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// DELETE /api/config/providers/:name +fsRoutes.delete('/api/config/providers/:name', async (ctx) => { + const name = ctx.params.name + + try { + await copyFile(configPath, configPath + '.bak') + let yaml = await safeReadFile(configPath) || '' + + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const blockRegex = new RegExp(` - name:\\s*${escaped}\\s*\\n(?: .+\\n)*`, 'g') + yaml = yaml.replace(blockRegex, '') + + await writeFile(configPath, yaml, 'utf-8') + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 6a89bbfe..f978ebfc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,7 @@ import { useAppStore } from '@/stores/app' const appStore = useAppStore() onMounted(() => { + appStore.loadModels() appStore.startHealthPolling() }) diff --git a/src/api/chat.ts b/src/api/chat.ts index 4e5476cc..b19660da 100644 --- a/src/api/chat.ts +++ b/src/api/chat.ts @@ -10,6 +10,7 @@ export interface StartRunRequest { instructions?: string conversation_history?: ChatMessage[] session_id?: string + model?: string } export interface StartRunResponse { diff --git a/src/api/skills.ts b/src/api/skills.ts new file mode 100644 index 00000000..5210b800 --- /dev/null +++ b/src/api/skills.ts @@ -0,0 +1,55 @@ +import { request } from './client' + +export interface SkillInfo { + name: string + description: string +} + +export interface SkillCategory { + name: string + description: string + skills: SkillInfo[] +} + +export interface SkillListResponse { + categories: SkillCategory[] +} + +export interface SkillFileEntry { + path: string + name: string + isDir: boolean +} + +export interface MemoryData { + memory: string + user: string + memory_mtime: number | null + user_mtime: number | null +} + +export async function fetchSkills(): Promise { + const res = await request('/api/skills') + return res.categories +} + +export async function fetchSkillContent(skillPath: string): Promise { + const res = await request<{ content: string }>(`/api/skills/${skillPath}`) + return res.content +} + +export async function fetchSkillFiles(category: string, skill: string): Promise { + const res = await request<{ files: SkillFileEntry[] }>(`/api/skills/${category}/${skill}/files`) + return res.files +} + +export async function fetchMemory(): Promise { + return request('/api/memory') +} + +export async function saveMemory(section: 'memory' | 'user', content: string): Promise { + await request('/api/memory', { + method: 'POST', + body: JSON.stringify({ section, content }), + }) +} diff --git a/src/api/system.ts b/src/api/system.ts index 342b0779..e6b881fa 100644 --- a/src/api/system.ts +++ b/src/api/system.ts @@ -16,6 +16,41 @@ export interface ModelsResponse { data: Model[] } +// Config-based model types +export interface ModelInfo { + id: string + label: string +} + +export interface ModelGroup { + provider: string + models: ModelInfo[] +} + +export interface ConfigModelsResponse { + default: string + groups: ModelGroup[] +} + +export interface AvailableModelGroup { + provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai") + label: string // display name (e.g. "zai", "subrouter.ai") + base_url: string + models: string[] +} + +export interface AvailableModelsResponse { + default: string + groups: AvailableModelGroup[] +} + +export interface CustomProvider { + name: string + base_url: string + api_key: string + model: string +} + export async function checkHealth(): Promise { return request('/health') } @@ -23,3 +58,36 @@ export async function checkHealth(): Promise { export async function fetchModels(): Promise { return request('/v1/models') } + +export async function fetchConfigModels(): Promise { + return request('/api/config/models') +} + +export async function fetchAvailableModels(): Promise { + return request('/api/available-models') +} + +export async function updateDefaultModel(data: { + default: string + provider?: string + base_url?: string + api_key?: string +}): Promise { + await request('/api/config/model', { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function addCustomProvider(data: CustomProvider): Promise { + await request('/api/config/providers', { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function removeCustomProvider(name: string): Promise { + await request(`/api/config/providers/${encodeURIComponent(name)}`, { + method: 'DELETE', + }) +} diff --git a/src/components/chat/ChatInput.vue b/src/components/chat/ChatInput.vue index f3efed9e..5fddf36c 100644 --- a/src/components/chat/ChatInput.vue +++ b/src/components/chat/ChatInput.vue @@ -9,13 +9,108 @@ const inputText = ref('') const textareaRef = ref() const fileInputRef = ref() const attachments = ref([]) +const isDragging = ref(false) +const dragCounter = ref(0) const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0) +// --- Voice input (Web Speech API) --- +// TODO: re-enable when needed — browser-native speech-to-text +// const hasSpeechRecognition = ref(false) +// let recognition: SpeechRecognition | null = null +// let finalTranscript = '' +// let prefixText = '' +// onMounted(() => { +// const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition +// if (!SR) return +// recognition = new SR() +// recognition.continuous = false +// recognition.interimResults = true +// recognition.lang = 'en-US' +// hasSpeechRecognition.value = true +// recognition.onresult = (event: SpeechRecognitionEvent) => { ... } +// recognition.onend = () => { ... } +// recognition.onerror = (event: SpeechRecognitionErrorEvent) => { ... } +// }) +// onUnmounted(() => { if (recognition && isRecording.value) recognition.stop() }) + +// --- File attachment helpers --- + +function addFile(file: File) { + if (attachments.value.find(a => a.name === file.name)) return + const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + const url = URL.createObjectURL(file) + attachments.value.push({ + id, + name: file.name, + type: file.type, + size: file.size, + url, + file, + }) +} + function handleAttachClick() { fileInputRef.value?.click() } +function handleFileChange(e: Event) { + const input = e.target as HTMLInputElement + if (!input.files) return + for (const file of input.files) addFile(file) + input.value = '' +} + +// --- Paste image --- + +function handlePaste(e: ClipboardEvent) { + const items = Array.from(e.clipboardData?.items || []) + const imageItems = items.filter(i => i.type.startsWith('image/')) + if (!imageItems.length) return + e.preventDefault() + for (const item of imageItems) { + const blob = item.getAsFile() + if (!blob) continue + const ext = item.type.split('/')[1] || 'png' + const file = new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type }) + addFile(file) + } +} + +// --- Drag and drop --- + +function handleDragOver(e: DragEvent) { + e.preventDefault() +} + +function handleDragEnter(e: DragEvent) { + e.preventDefault() + if (e.dataTransfer?.types.includes('Files')) { + dragCounter.value++ + isDragging.value = true + } +} + +function handleDragLeave() { + dragCounter.value-- + if (dragCounter.value <= 0) { + dragCounter.value = 0 + isDragging.value = false + } +} + +function handleDrop(e: DragEvent) { + e.preventDefault() + dragCounter.value = 0 + isDragging.value = false + const files = Array.from(e.dataTransfer?.files || []) + if (!files.length) return + for (const file of files) addFile(file) + textareaRef.value?.focus() +} + +// --- Send --- + function handleSend() { const text = inputText.value.trim() if (!text && attachments.value.length === 0) return @@ -24,7 +119,6 @@ function handleSend() { inputText.value = '' attachments.value = [] - // Reset textarea height if (textareaRef.value) { textareaRef.value.style.height = 'auto' } @@ -43,28 +137,6 @@ function handleInput(e: Event) { el.style.height = Math.min(el.scrollHeight, 100) + 'px' } -function handleFileChange(e: Event) { - const input = e.target as HTMLInputElement - const files = input.files - if (!files) return - - for (const file of files) { - const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8) - const url = URL.createObjectURL(file) - attachments.value.push({ - id, - name: file.name, - type: file.type, - size: file.size, - url, - file, - }) - } - - // Reset input so the same file can be re-selected - input.value = '' -} - function removeAttachment(id: string) { const idx = attachments.value.findIndex(a => a.id === id) if (idx !== -1) { @@ -110,7 +182,14 @@ function isImage(type: string): boolean { -
+
@@ -288,4 +368,11 @@ function isImage(type: string): boolean { flex-shrink: 0; align-items: center; } + +// Drag-over state +.input-wrapper.drag-over { + border-color: #4a90d9; + border-style: dashed; + background-color: rgba(74, 144, 217, 0.04); +} diff --git a/src/components/chat/ChatPanel.vue b/src/components/chat/ChatPanel.vue index c5dfab90..d421cefd 100644 --- a/src/components/chat/ChatPanel.vue +++ b/src/components/chat/ChatPanel.vue @@ -20,6 +20,10 @@ const activeSessionLabel = computed(() => chatStore.activeSession?.id || 'New Chat', ) +const sessionModelLabel = computed(() => + chatStore.activeSession?.model || appStore.selectedModel || '', +) + function handleNewChat() { chatStore.newChat() } @@ -58,7 +62,7 @@ function formatTime(ts: number) {
-
Loading...
+
Loading...
No sessions
+
+ {{ sessionModelLabel }}
@@ -224,12 +233,31 @@ function formatTime(ts: number) { } .session-item-time { - display: block; font-size: 11px; color: $text-muted; +} + +.session-item-meta { + display: flex; + align-items: center; + gap: 6px; margin-top: 2px; } +.session-item-model { + font-size: 10px; + color: $accent-primary; + background: rgba($accent-primary, 0.08); + padding: 0 5px; + border-radius: 3px; + line-height: 16px; + flex-shrink: 0; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .session-item-delete { flex-shrink: 0; opacity: 0; @@ -269,6 +297,14 @@ function formatTime(ts: number) { align-items: center; gap: 8px; overflow: hidden; + flex: 1; + min-width: 0; +} + +.header-center { + flex-shrink: 0; + max-width: 240px; + min-width: 140px; } .header-session-title { diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index 7e12fc29..63cfa074 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -2,6 +2,7 @@ import { computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAppStore } from '@/stores/app' +import ModelSelector from './ModelSelector.vue' const route = useRoute() const router = useRouter() @@ -47,6 +48,32 @@ function handleNav(key: string) { Jobs + + + +