diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1defce0..31a18f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,6 +116,7 @@ codex exec --json -m {model} -C {cwd} --skip-git-repo-check \ codex exec resume {sessionId} --json -m {model} --skip-git-repo-check "{prompt}" ``` (`exec resume` does not use `-C` or `-s`.) +Image attachments are passed via `-i /path/to/image`; non-image attachments are appended to the prompt as readable file paths. **Ollama Provider:** Stateless HTTP requests to Ollama API: - POST to `/api/chat` with full message history @@ -241,6 +242,8 @@ Conversations default to sandboxed mode for safety. Sandbox configuration: | `GET` | `/api/files/content` | Get structured file content (standalone cwd) | | `GET` | `/api/files/download` | Download file | | `POST` | `/api/files/upload` | Upload file | +| `POST` | `/api/conversations/:id/upload` | Upload local file as conversation attachment | +| `POST` | `/api/conversations/:id/attachments/from-files` | Copy existing cwd file(s) into conversation attachments | | `GET` | `/api/conversations/:id/files` | List files in cwd | | `GET` | `/api/conversations/:id/files/content` | Get file content | | `GET` | `/api/conversations/:id/files/search` | Git grep search | @@ -371,7 +374,7 @@ public/js/ Five mutually exclusive views with CSS transform transitions: 1. **List View** — Conversation browser grouped by cwd, search (keyword + semantic), archive toggle -2. **Chat View** — Messages, input bar, file panel with preview +2. **Chat View** — Messages, input bar, file panel with preview, and direct file-to-chat attachment from file tree/viewer actions 3. **Stats View** — Analytics dashboard with cost tracking, activity charts 4. **Branches View** — Fork tree visualization with parent/child navigation 5. **Memory View** — Memory management (global + project-scoped) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7a49916..ed7b7f3 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -126,7 +126,7 @@ public/css/ role: 'user', text: string, timestamp: number, - attachments: [{ filename, url }] // optional + attachments: [{ path, filename, url }] // optional } ``` @@ -139,8 +139,13 @@ public/css/ cost: number, // USD duration: number, // ms sessionId: string, - inputTokens: number, - outputTokens: number + inputTokens: number, // Provider-reported input token count (raw when available) + outputTokens: number, + netInputTokens?: number, // rawInputTokens - cachedInputTokens + typedInputTokens?: number, // rough estimate of user-typed prompt tokens + rawInputTokens?: number, // full provider-reported prompt/context tokens + cachedInputTokens?: number, // provider cache hit tokens (if available) + reasoningTokens?: number // provider-reported reasoning/thinking tokens } ``` @@ -235,7 +240,7 @@ public/css/ | `loadConversations()` | conversations.js | Fetch + render list | | `openConversation(id)` | conversations.js | Load + display conversation | | `forkConversation(idx)` | conversations.js | Fork from message | -| `sendMessage(text)` | ui.js | Send with attachments | +| `sendMessage(text)` | ui.js | Send with local uploads or pre-attached server files | | `renderMarkdown(text)` | markdown.js | Markdown → HTML | --- diff --git a/lib/providers/claude.js b/lib/providers/claude.js index 5cf4822..b9d7545 100644 --- a/lib/providers/claude.js +++ b/lib/providers/claude.js @@ -117,6 +117,10 @@ function calculateMessageCost(inputTokens, outputTokens, modelId) { return inputCost + outputCost; } +function estimateTypedInputTokens(text) { + return text ? Math.max(1, Math.ceil(String(text).length / 4)) : 0; +} + // Active Claude processes per conversation const activeProcesses = new Map(); const PROCESS_TIMEOUT = 5 * 60 * 1000; // 5 minutes @@ -190,6 +194,7 @@ function buildInlineHistoryContext(messages = [], latestUserText = '', options = let totalChars = 0; for (let i = priorMessages.length - 1; i >= 0; i--) { const msg = priorMessages[i]; + if (msg?.summarized) continue; let text = String(msg?.text || '').trim(); for (const replacement of pathReplacements) { text = text.split(replacement.from).join(replacement.to); @@ -376,8 +381,20 @@ function handleResultEvent( conv.claudeForkSessionId = null; } - const inputTokens = event.total_input_tokens ?? event.input_tokens ?? event.usage?.input_tokens ?? 0; + const rawInputTokens = event.total_input_tokens ?? event.input_tokens ?? event.usage?.input_tokens ?? 0; + const cachedInputTokens = + event.cache_read_input_tokens ?? + event.usage?.cache_read_input_tokens ?? + event.usage?.input_tokens_details?.cached_tokens ?? + 0; + const netInputTokens = Math.max(0, rawInputTokens - cachedInputTokens); + const inputTokens = rawInputTokens; + const typedInputTokens = conv._typedInputTokens ?? null; const outputTokens = event.total_output_tokens ?? event.output_tokens ?? event.usage?.output_tokens ?? 0; + const reasoningTokens = + event.usage?.output_tokens_details?.reasoning_tokens ?? + event.usage?.completion_tokens_details?.reasoning_tokens ?? + 0; if (!resultText.trim() && inputTokens === 0 && outputTokens === 0) { if (canRetryWithFreshSession) { // Resumed sessions can occasionally become invalid and return empty 0/0. @@ -417,7 +434,12 @@ function handleResultEvent( duration: event.duration_ms, sessionId: event.session_id, inputTokens, + netInputTokens, + typedInputTokens, outputTokens, + reasoningTokens, + rawInputTokens, + cachedInputTokens, }); conv.status = 'idle'; conv.thinkingStartTime = null; @@ -437,8 +459,14 @@ function handleResultEvent( duration: event.duration_ms, sessionId: event.session_id, inputTokens, + netInputTokens, + typedInputTokens, outputTokens, + reasoningTokens, + rawInputTokens, + cachedInputTokens, }); + delete conv._typedInputTokens; broadcastStatus(conversationId, 'idle'); } @@ -542,6 +570,7 @@ class ClaudeProvider extends LLMProvider { const promptText = inlineHistory ? `${inlineHistory}\n\n[New user message]\n${text}` : text; + conv._typedInputTokens = estimateTypedInputTokens(text); const args = [ '-p', promptText, @@ -741,6 +770,7 @@ Please continue the conversation naturally, using the above context as backgroun ) { retryScheduled = true; delete conv._retryAfterEmptyResultMode; + delete conv._typedInputTokens; void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, { ...runtime, retried: true, @@ -776,6 +806,7 @@ Please continue the conversation naturally, using the above context as backgroun isSlashOnlyPrompt, }); delete conv._retryAfterEmptyResultMode; + delete conv._typedInputTokens; }); proc.on('error', (err) => { @@ -784,6 +815,7 @@ Please continue the conversation naturally, using the above context as backgroun if (!retryScheduled && conv.status === 'thinking' && canRetryWithCompactHistory && isContextOverflowError(err?.message || err?.code)) { retryScheduled = true; + delete conv._typedInputTokens; void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, { ...runtime, retried: true, @@ -801,6 +833,7 @@ Please continue the conversation naturally, using the above context as backgroun }); broadcastStatus(conversationId, 'idle'); delete conv._retryAfterEmptyResultMode; + delete conv._typedInputTokens; }); } diff --git a/lib/providers/codex.js b/lib/providers/codex.js index 49450f8..a1963f4 100644 --- a/lib/providers/codex.js +++ b/lib/providers/codex.js @@ -54,6 +54,10 @@ function calculateMessageCost(inputTokens, outputTokens, modelId) { return inputCost + outputCost; } +function estimateTypedInputTokens(text) { + return text ? Math.max(1, Math.ceil(String(text).length / 4)) : 0; +} + // Active Codex processes per conversation const activeProcesses = new Map(); const PROCESS_TIMEOUT = 5 * 60 * 1000; // 5 minutes @@ -112,6 +116,30 @@ function formatMemoriesForPrompt(memories) { return `\n\n[User memories]\n${sections.join('\n\n')}\n[/User memories]`; } +function partitionAttachments(attachments = []) { + const imageAttachments = []; + const fileAttachments = []; + + for (const attachment of attachments || []) { + const filePath = attachment?.path; + if (!filePath || typeof filePath !== 'string') continue; + + if (/\.(png|jpg|jpeg|gif|webp)$/i.test(filePath)) { + imageAttachments.push(attachment); + } else { + fileAttachments.push(attachment); + } + } + + return { imageAttachments, fileAttachments }; +} + +function buildFileAttachmentPrompt(fileAttachments = []) { + if (!Array.isArray(fileAttachments) || fileAttachments.length === 0) return ''; + const paths = fileAttachments.map((a) => a.path).join('\n'); + return `\n\n[Attached file${fileAttachments.length > 1 ? 's' : ''} — read for context:]\n${paths}`; +} + function buildInlineHistoryContext(messages = [], latestUserText = '', options = {}) { if (!Array.isArray(messages) || messages.length === 0) return ''; const maxChars = Number.isFinite(Number(options.maxChars)) && Number(options.maxChars) > 0 @@ -128,6 +156,7 @@ function buildInlineHistoryContext(messages = [], latestUserText = '', options = let totalChars = 0; for (let i = priorMessages.length - 1; i >= 0; i--) { const msg = priorMessages[i]; + if (msg?.summarized) continue; const text = String(msg?.text || '').trim(); if (!text) continue; @@ -496,8 +525,14 @@ function processCodexEvent( 0; // For UX, show net new input tokens instead of full cached prompt tokens. const inputTokens = Math.max(0, rawInputTokens - cachedInputTokens); + const netInputTokens = inputTokens; const displayInputTokens = conv._codexDisplayInputTokens ?? inputTokens; + const typedInputTokens = conv._typedInputTokens ?? displayInputTokens; const outputTokens = usage.output_tokens || 0; + const reasoningTokens = + usage.output_tokens_details?.reasoning_tokens ?? + usage.completion_tokens_details?.reasoning_tokens ?? + 0; if (!assistantText.trim() && inputTokens === 0 && outputTokens === 0) { if (canRetryWithFreshSession) { // Resumed sessions can occasionally become invalid and return empty 0/0. @@ -522,6 +557,7 @@ function processCodexEvent( error: 'Model returned an empty response. Please retry.', }); delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; broadcastStatus(conversationId, 'idle'); break; } @@ -538,8 +574,11 @@ function processCodexEvent( duration: event.duration_ms, sessionId: conv.codexSessionId, inputTokens, + netInputTokens, displayInputTokens, + typedInputTokens, outputTokens, + reasoningTokens, rawInputTokens, cachedInputTokens, }); @@ -555,12 +594,16 @@ function processCodexEvent( duration: event.duration_ms, sessionId: conv.codexSessionId, inputTokens, + netInputTokens, displayInputTokens, + typedInputTokens, outputTokens, + reasoningTokens, rawInputTokens, cachedInputTokens, }); delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; broadcastStatus(conversationId, 'idle'); break; } @@ -623,14 +666,15 @@ class CodexProvider extends LLMProvider { args.push('-s', allowWrites ? 'workspace-write' : 'read-only'); } + const { imageAttachments, fileAttachments } = partitionAttachments(attachments); + // Image attachments - const images = (attachments || []).filter(a => /\.(png|jpg|jpeg|gif|webp)$/i.test(a.path)); - for (const img of images) { + for (const img of imageAttachments) { args.push('-i', img.path); } // Grant access to uploads directory - if (!isResume && attachments && attachments.length > 0) { + if (!isResume && (imageAttachments.length > 0 || fileAttachments.length > 0)) { args.push('--add-dir', path.join(uploadDir, conversationId)); } @@ -651,8 +695,11 @@ class CodexProvider extends LLMProvider { const promptBase = inlineHistory ? `${inlineHistory}\n\n[New user message]\n${text}` : text; - const prompt = promptBase + formatMemoriesForPrompt(enabledMemories); - conv._codexDisplayInputTokens = text ? Math.max(1, Math.ceil(text.length / 4)) : 0; + const prompt = promptBase + + buildFileAttachmentPrompt(fileAttachments) + + formatMemoriesForPrompt(enabledMemories); + conv._typedInputTokens = estimateTypedInputTokens(text); + conv._codexDisplayInputTokens = conv._typedInputTokens; // Prompt must be last args.push(prompt); @@ -758,6 +805,7 @@ class CodexProvider extends LLMProvider { delete conv._codexOpenToolIds; delete conv._codexOpenTraceCount; delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, { ...runtime, retried: true, @@ -797,6 +845,7 @@ class CodexProvider extends LLMProvider { delete conv._codexOpenToolIds; delete conv._codexOpenTraceCount; delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; }); proc.on('error', (err) => { @@ -808,6 +857,7 @@ class CodexProvider extends LLMProvider { delete conv._codexOpenToolIds; delete conv._codexOpenTraceCount; delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; void this.chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories, { ...runtime, retried: true, @@ -828,6 +878,7 @@ class CodexProvider extends LLMProvider { delete conv._codexOpenToolIds; delete conv._codexOpenTraceCount; delete conv._codexDisplayInputTokens; + delete conv._typedInputTokens; }); } @@ -951,3 +1002,8 @@ module.exports.MODELS = MODELS; module.exports.activeProcesses = activeProcesses; module.exports.processCodexEvent = processCodexEvent; module.exports.handleNoOutputClose = handleNoOutputClose; +module.exports.partitionAttachments = partitionAttachments; +module.exports.buildFileAttachmentPrompt = buildFileAttachmentPrompt; +module.exports._private = { + buildInlineHistoryContext, +}; diff --git a/lib/providers/ollama.js b/lib/providers/ollama.js index a736454..5f3be81 100644 --- a/lib/providers/ollama.js +++ b/lib/providers/ollama.js @@ -11,6 +11,10 @@ const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://localhost:11434'; // Active requests per conversation (for cancellation) const activeRequests = new Map(); +function estimateTypedInputTokens(text) { + return text ? Math.max(1, Math.ceil(String(text).length / 4)) : 0; +} + // Default models if Ollama is not available const DEFAULT_MODELS = [ { id: 'llama3.2', name: 'Llama 3.2', context: 128000 }, @@ -122,6 +126,7 @@ ${summaryMsg.text} */ async chat(ws, conversationId, conv, text, attachments, uploadDir, callbacks, memories = []) { const { onSave, broadcastStatus } = callbacks; + conv._typedInputTokens = estimateTypedInputTokens(text); // Note: Ollama doesn't support file attachments natively // We could read file contents and append to the message, but skip for now @@ -198,7 +203,11 @@ ${summaryMsg.text} cost: 0, // Ollama is free/local duration, inputTokens: json.prompt_eval_count || 0, + netInputTokens: json.prompt_eval_count || 0, + typedInputTokens: conv._typedInputTokens ?? null, outputTokens: json.eval_count || 0, + rawInputTokens: json.prompt_eval_count || 0, + cachedInputTokens: 0, }); conv.status = 'idle'; conv.thinkingStartTime = null; @@ -211,7 +220,11 @@ ${summaryMsg.text} cost: 0, duration, inputTokens: json.prompt_eval_count || 0, + netInputTokens: json.prompt_eval_count || 0, + typedInputTokens: conv._typedInputTokens ?? null, outputTokens: json.eval_count || 0, + rawInputTokens: json.prompt_eval_count || 0, + cachedInputTokens: 0, }); broadcastStatus(conversationId, 'idle'); } @@ -249,6 +262,7 @@ ${summaryMsg.text} broadcastStatus(conversationId, 'idle'); } finally { activeRequests.delete(conversationId); + delete conv._typedInputTokens; } } diff --git a/lib/routes/files.js b/lib/routes/files.js index e52b440..651c2af 100644 --- a/lib/routes/files.js +++ b/lib/routes/files.js @@ -7,6 +7,7 @@ const fsp = require('fs').promises; const readline = require('readline'); const { UPLOAD_DIR } = require('../data'); +const { MAX_UPLOAD_SIZE } = require('../constants'); // Parquet support (lazy loaded to avoid startup cost if not used) let parquet = null; @@ -311,6 +312,34 @@ function detectDelimiter(firstLine) { return tabCount > commaCount ? '\t' : ','; } +function splitFilename(filename) { + const ext = path.extname(filename); + const base = ext ? filename.slice(0, -ext.length) : filename; + return { base, ext }; +} + +async function fileExists(filePath) { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + +async function getUniqueUploadFilename(uploadDir, filename) { + const safeName = sanitizeFilename(filename || `upload-${Date.now()}`); + const { base, ext } = splitFilename(safeName); + + let candidate = safeName; + let counter = 1; + while (await fileExists(path.join(uploadDir, candidate))) { + counter += 1; + candidate = `${base}-${counter}${ext}`; + } + return candidate; +} + /** * Parse Jupyter notebook file * Returns { cells, metadata } @@ -749,6 +778,84 @@ function setupFileRoutes(app) { })); })); + // Attach existing cwd files into conversation uploads + app.post('/api/conversations/:id/attachments/from-files', withConversation(async (req, res, conv) => { + const requestedPaths = Array.isArray(req.body?.paths) ? req.body.paths : null; + if (!requestedPaths || requestedPaths.length === 0) { + return res.status(400).json({ error: 'paths array required' }); + } + + const baseCwd = conv.cwd || process.env.HOME; + const convId = conv.id; + const uploadDir = path.join(UPLOAD_DIR, convId); + await fsp.mkdir(uploadDir, { recursive: true }); + + const attachments = []; + const failed = []; + + for (const rawPath of requestedPaths) { + const originalPath = typeof rawPath === 'string' ? rawPath : ''; + const requestPath = originalPath.trim(); + if (!requestPath) { + failed.push({ path: originalPath, error: 'Invalid path' }); + continue; + } + + const sourcePath = path.resolve(baseCwd, requestPath); + if (!isPathWithinCwd(baseCwd, sourcePath)) { + failed.push({ path: requestPath, error: 'Access denied' }); + continue; + } + + let sourceStat; + try { + sourceStat = await fsp.stat(sourcePath); + } catch (err) { + if (err?.code === 'ENOENT') { + failed.push({ path: requestPath, error: 'File not found' }); + } else { + failed.push({ path: requestPath, error: err.message || 'Unable to read file' }); + } + continue; + } + + if (!sourceStat.isFile()) { + failed.push({ path: requestPath, error: 'Path is not a file' }); + continue; + } + + if (sourceStat.size > MAX_UPLOAD_SIZE) { + const maxMB = Math.round(MAX_UPLOAD_SIZE / (1024 * 1024)); + failed.push({ path: requestPath, error: `File too large. Maximum size is ${maxMB}MB.` }); + continue; + } + + try { + const sourceName = path.basename(sourcePath); + const targetName = await getUniqueUploadFilename(uploadDir, sourceName); + const targetPath = path.join(uploadDir, targetName); + await fsp.copyFile(sourcePath, targetPath); + attachments.push({ + path: targetPath, + filename: targetName, + url: `/uploads/${convId}/${targetName}`, + }); + } catch (err) { + failed.push({ path: requestPath, error: err.message || 'Failed to attach file' }); + } + } + + if (attachments.length === 0) { + return res.status(400).json({ + error: 'No files were attached', + attachments: [], + failed, + }); + } + + res.json({ attachments, failed }); + })); + // List files in conversation's working directory app.get('/api/conversations/:id/files', withConversation(async (req, res, conv) => { const subpath = req.query.path || ''; diff --git a/public/css/file-panel.css b/public/css/file-panel.css index a802b52..6aaf1f5 100644 --- a/public/css/file-panel.css +++ b/public/css/file-panel.css @@ -416,11 +416,11 @@ } /* Load to Data tab button */ -.file-tree-load-data-btn { +.file-tree-load-data-btn, +.file-tree-attach-btn { display: none; background: none; border: none; - color: var(--accent); min-width: 32px; min-height: 32px; padding: 0; @@ -432,26 +432,48 @@ border-radius: 6px; } +.file-tree-load-data-btn { + color: var(--accent); +} + +.file-tree-attach-btn { + color: var(--text-secondary); +} + .file-tree-load-data-btn:hover { opacity: 1; background: var(--accent-bg); } +.file-tree-attach-btn:hover { + opacity: 1; + background: var(--hover-bg); + color: var(--text); +} + .file-tree-load-data-btn:active { opacity: 1; background: var(--accent); color: var(--bg); } +.file-tree-attach-btn:active { + opacity: 1; + background: var(--active-bg, var(--hover-bg)); +} + .file-tree-item:hover .file-tree-load-data-btn, -.file-tree-item:focus-within .file-tree-load-data-btn { +.file-tree-item:focus-within .file-tree-load-data-btn, +.file-tree-item:hover .file-tree-attach-btn, +.file-tree-item:focus-within .file-tree-attach-btn { display: flex; align-items: center; justify-content: center; } @media (hover: none) { - .file-tree-load-data-btn { + .file-tree-load-data-btn, + .file-tree-attach-btn { display: flex; align-items: center; justify-content: center; @@ -700,17 +722,40 @@ } .file-viewer-fullsize-btn:hover, -.file-viewer-open-tab-btn:hover { +.file-viewer-open-tab-btn:hover, +.file-viewer-attach-tab-btn:hover, +.file-viewer-attach-float-btn:hover { background: var(--hover-bg); color: var(--text); } .file-viewer-fullsize-btn svg, -.file-viewer-open-tab-btn svg { +.file-viewer-open-tab-btn svg, +.file-viewer-attach-tab-btn svg, +.file-viewer-attach-float-btn svg { width: 18px; height: 18px; } +.file-viewer-attach-float-btn { + position: absolute; + bottom: 24px; + right: 68px; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.15)); + transition: background var(--transition-fast), color var(--transition-fast); + z-index: 10; +} + /* Open in new tab button (for text files) */ .file-viewer-open-tab-btn { position: fixed; @@ -731,6 +776,38 @@ z-index: 10; } +.file-viewer-attach-tab-btn { + position: fixed; + bottom: calc(24px + env(safe-area-inset-bottom)); + right: 68px; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.15)); + transition: background var(--transition-fast), color var(--transition-fast); + z-index: 10; +} + +.file-viewer-error-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.file-viewer-attach-inline-btn { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); +} + /* Dragging state */ .file-panel.dragging { transition: none !important; diff --git a/public/css/messages.css b/public/css/messages.css index 695690e..4d357a7 100644 --- a/public/css/messages.css +++ b/public/css/messages.css @@ -962,9 +962,10 @@ html[data-theme="light"] .code-block::before { /* Compressed messages section */ .compressed-section { display: flex; + flex-direction: column; align-items: center; justify-content: center; - gap: 8px; + gap: 4px; padding: 10px 16px; margin: 0 auto 12px; background: var(--surface); @@ -987,6 +988,12 @@ html[data-theme="light"] .code-block::before { transition: transform 0.2s ease; } +.compressed-section-main { + display: inline-flex; + align-items: center; + gap: 8px; +} + .compressed-section[aria-expanded="true"] svg { transform: rotate(180deg); } @@ -997,6 +1004,12 @@ html[data-theme="light"] .code-block::before { font-weight: 500; } +.compressed-section-hint { + font-size: 11px; + color: var(--text-secondary); + opacity: 0.8; +} + /* Summarized/compressed messages (collapsed by default) */ .message.summarized { opacity: 0.5; @@ -1030,6 +1043,74 @@ html[data-theme="light"] .code-block::before { display: block; } +.message.summarized.show-compressed { + opacity: 0.96; + max-height: none; + overflow: visible; +} + +.message.summarized.show-compressed::after { + display: none; +} + +.message.assistant.summarized.show-compressed { + border-left: 3px dashed var(--accent-alpha-50); + background: + repeating-linear-gradient( + -45deg, + color-mix(in srgb, var(--assistant-bubble) 90%, var(--accent) 10%), + color-mix(in srgb, var(--assistant-bubble) 90%, var(--accent) 10%) 10px, + var(--assistant-bubble) 10px, + var(--assistant-bubble) 20px + ); +} + +.message.user.summarized.show-compressed { + border-right: 3px dashed var(--accent-alpha-50); + background: + repeating-linear-gradient( + -45deg, + rgba(255, 255, 255, 0.08), + rgba(255, 255, 255, 0.08) 10px, + rgba(0, 0, 0, 0.04) 10px, + rgba(0, 0, 0, 0.04) 20px + ), + linear-gradient(145deg, var(--user-bubble) 0%, var(--user-bubble-end) 100%); +} + +.compressed-context-badge { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + padding: 3px 8px; + width: fit-content; + border-radius: 999px; + background: var(--accent-alpha-12); + border: 1px solid var(--accent-alpha-30); + color: var(--accent-light); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.compressed-context-badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-light); +} + +.message.user.summarized.show-compressed .compressed-context-badge { + background: rgba(0, 0, 0, 0.24); + border-color: rgba(255, 255, 255, 0.4); + color: rgba(255, 255, 255, 0.95); +} + +.message.user.summarized.show-compressed .compressed-context-badge-dot { + background: rgba(255, 255, 255, 0.95); +} + /* Compression summary message */ .message.compression-summary { background: var(--bg-tertiary); diff --git a/public/js/explorer/context.js b/public/js/explorer/context.js index 33f4b37..f59f47d 100644 --- a/public/js/explorer/context.js +++ b/public/js/explorer/context.js @@ -67,6 +67,12 @@ export function createConversationContext(conversationIdOrGetter) { return withQuery(`/api/conversations/${conversationId}/upload`, { filename }); }, + getAttachExistingFilesUrl() { + const conversationId = getConversationId(); + if (!conversationId) return null; + return `/api/conversations/${conversationId}/attachments/from-files`; + }, + getGitUrl(endpoint) { const conversationId = getConversationId(); if (!conversationId) return null; diff --git a/public/js/explorer/file-tree-ui.js b/public/js/explorer/file-tree-ui.js index edb38d4..b371528 100644 --- a/public/js/explorer/file-tree-ui.js +++ b/public/js/explorer/file-tree-ui.js @@ -95,7 +95,7 @@ export function renderFileTreeView({ const index = parseInt(item.dataset.index, 10); const entry = entries[index]; if (!entry) return; - await onExtra(entry); + await onExtra(entry, btn); }); }); } diff --git a/public/js/explorer/file-viewer-content.js b/public/js/explorer/file-viewer-content.js index f93693b..8bb5d4e 100644 --- a/public/js/explorer/file-viewer-content.js +++ b/public/js/explorer/file-viewer-content.js @@ -17,6 +17,7 @@ const GEO_PREVIEW_EXTS = new Set(['geojson', 'json', 'topojson', 'jsonl', 'ndjso const JSON_PREVIEW_EXTS = new Set(['json', 'geojson', 'topojson']); const DEFAULT_OPEN_EXTERNAL_ICON = ''; +const DEFAULT_ATTACH_ICON = ''; function toSafeString(value) { return value === null || value === undefined ? '' : String(value); @@ -39,6 +40,16 @@ function attachOpenButton(container, selector, url) { } } +function attachActionButton(container, selector, onAction) { + if (typeof onAction !== 'function') return; + const button = container.querySelector(selector); + if (button) { + button.addEventListener('click', () => { + void onAction(); + }); + } +} + function renderDataPreview(data, { escapeHtml, enableCopyCells }) { const columns = data.columns || []; const rows = data.rows || []; @@ -191,12 +202,15 @@ function renderGeoPreview(container, { geoResult, fileUrl, openExternalIcon, + attachIcon, + canAttach = false, escapeHtml, rawContent, rawDisabled = false, rawPreviewSize = null, formatFileSize, onRefresh = null, + onAttach = null, }) { const summary = escapeHtml(geoResult.summary || 'GeoJSON map preview'); const rawText = escapeHtml(toSafeString(rawContent)); @@ -288,10 +302,12 @@ function renderGeoPreview(container, { ${rawPanelHtml} + ${canAttach ? `` : ''} `; attachOpenButton(container, '.file-viewer-open-tab-btn', fileUrl); + attachActionButton(container, '.file-viewer-attach-tab-btn', onAttach); if (rawDisabled) { attachOpenButton(container, '.geo-preview-open-raw-btn', fileUrl); } @@ -504,6 +520,7 @@ export function renderFileViewerContent({ previewableExts = DEFAULT_PREVIEWABLE_EXTS, enableCopyCells = true, onRefresh = null, + onAttach = null, }) { if (!container || !data || !context) return false; @@ -514,6 +531,8 @@ export function renderFileViewerContent({ const downloadUrl = context.getFileDownloadUrl(filePath); const fileLikeIcon = icons.document || icons.file || ''; const openExternalIcon = icons.openExternal || DEFAULT_OPEN_EXTERNAL_ICON; + const attachIcon = icons.attach || DEFAULT_ATTACH_ICON; + const canAttach = context.kind === 'conversation' && typeof onAttach === 'function'; if (data.csv || data.parquet) { container.innerHTML = renderDataPreview(data, { escapeHtml, enableCopyCells }); @@ -534,6 +553,7 @@ export function renderFileViewerContent({ container.innerHTML = `
${escapeHtml(toSafeString(data.name))}
${formatFileSize(data.size)}
- +Binary file cannot be previewed
${formatFileSize(data.size)}
- +${sizeErrorTitle}
${formatFileSize(data.size)} (max ${maxPreview})
+ ${canAttach ? `` : ''} `; + attachActionButton(container, '.file-viewer-attach-inline-btn', onAttach); return true; } @@ -596,12 +627,15 @@ export function renderFileViewerContent({ geoResult, fileUrl, openExternalIcon, + attachIcon, + canAttach, escapeHtml, rawContent: data.rawTruncated ? '' : content, rawDisabled: !!data.rawTruncated, rawPreviewSize: data.rawPreviewSize, formatFileSize, onRefresh, + onAttach, }); return true; } @@ -613,8 +647,10 @@ export function renderFileViewerContent({${escapeHtml(content)}
+ ${canAttach ? `` : ''}
`;
highlightCodeBlocks(container);
+ attachActionButton(container, '.file-viewer-attach-tab-btn', onAttach);
attachOpenButton(container, '.file-viewer-open-tab-btn', fileUrl);
return true;
}
diff --git a/public/js/explorer/shell.js b/public/js/explorer/shell.js
index e2efc77..80e56ba 100644
--- a/public/js/explorer/shell.js
+++ b/public/js/explorer/shell.js
@@ -55,6 +55,7 @@ export function createExplorerShell({
onViewerWillOpen = noop,
onViewerWillClose = noop,
onViewerDidClose = noop,
+ onAttachFromViewer = null,
onNavigateHaptic = noop,
isNavigationBlocked = () => false,
resolveUploadTargetPath = (path) => path,
@@ -212,6 +213,7 @@ export function createExplorerShell({
imageExts,
enableCopyCells: true,
onRefresh: () => viewFile(filePath),
+ onAttach: onAttachFromViewer ? () => onAttachFromViewer(filePath) : null,
});
return true;
diff --git a/public/js/file-panel/file-browser.js b/public/js/file-panel/file-browser.js
index cffbac6..4b8c243 100644
--- a/public/js/file-panel/file-browser.js
+++ b/public/js/file-panel/file-browser.js
@@ -67,6 +67,52 @@ async function loadFileToDataTab(filePath) {
}
}
+async function attachExistingFileToChat(filePath) {
+ if (!fileBrowserContext.isAvailable()) {
+ showToast('No conversation selected', { variant: 'error' });
+ return;
+ }
+
+ const url = fileBrowserContext.getAttachExistingFilesUrl?.();
+ if (!url) {
+ showToast('Unable to attach file', { variant: 'error' });
+ return;
+ }
+
+ const res = await apiFetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paths: [filePath] }),
+ });
+ if (!res) return;
+
+ const data = await res.json();
+ const attachments = Array.isArray(data.attachments) ? data.attachments : [];
+ const failed = Array.isArray(data.failed) ? data.failed : [];
+
+ if (attachments.length === 0) {
+ showToast(data.error || failed[0]?.error || 'Failed to attach file', { variant: 'error' });
+ return;
+ }
+
+ for (const attachment of attachments) {
+ if (!attachment?.path || !attachment?.filename) continue;
+ const ext = (attachment.filename.split('.').pop() || '').toLowerCase();
+ state.addPendingAttachment({
+ kind: 'server',
+ attachment,
+ name: attachment.filename,
+ previewUrl: IMAGE_EXTS.has(ext) ? attachment.url : undefined,
+ });
+ }
+
+ const fileCount = attachments.length;
+ showToast(`Attached ${fileCount} file${fileCount > 1 ? 's' : ''} to chat`);
+ if (failed.length > 0) {
+ showToast(`Skipped ${failed.length} file${failed.length > 1 ? 's' : ''}: ${failed[0].error}`, { variant: 'error' });
+ }
+}
+
// DOM elements (set by init)
let filePanelUp = null;
let filePanelPath = null;
@@ -135,16 +181,30 @@ export function initFileBrowser(elements) {
return baseCwd ? `${baseCwd}/${entry.path}` : entry.path;
},
getExtraButtonHtml: (entry) => {
+ const isFile = entry.type === 'file';
const isDataFile = entry.type === 'file' && DATA_FILE_EXTS.has(entry.ext);
- if (!isDataFile) return '';
- return `