diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index e70e81e..bc848c2 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -266,9 +266,9 @@ function createAgentRuntime(deps) { const currentText = entry.fullText || ''; const hasExistingText = /\S/.test(currentText); - const hasParagraphBoundary = /\n\s*\n\s*$/.test(currentText) || /^\s*\n\s*\n/.test(nextText); - const separator = hasExistingText && !hasParagraphBoundary - ? (/\n\s*$/.test(currentText) ? '\n' : '\n\n') + const hasVisualBoundary = /\n\s*(?:---|\*\*\*|___)\s*$/.test(currentText) || /^\s*(?:---|\*\*\*|___)\s*\n/.test(nextText); + const separator = hasExistingText && !hasVisualBoundary + ? (/\n\s*$/.test(currentText) ? '\n---\n\n' : '\n\n---\n\n') : ''; const chunk = separator + nextText; entry.fullText += chunk; diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index 8946805..59625f9 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -18,7 +18,7 @@ const TOOLS = [ properties: { agent: { type: 'string', - enum: ['claude', 'codex'], + enum: ['claude', 'codex', 'codexapp'], description: '可选。只返回指定 Agent 的对话。', }, status: { @@ -55,6 +55,25 @@ const TOOLS = [ additionalProperties: false, }, }, + { + name: 'ccweb_request_reply', + description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后自动把回复发回当前对话。', + inputSchema: { + type: 'object', + properties: { + targetConversationId: { + type: 'string', + description: '目标对话 ID。', + }, + content: { + type: 'string', + description: '要发送到目标对话的纯文本消息。', + }, + }, + required: ['targetConversationId', 'content'], + additionalProperties: false, + }, + }, ]; function writeMessage(message) { diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js new file mode 100644 index 0000000..2084f2d --- /dev/null +++ b/lib/codex-app-runtime.js @@ -0,0 +1,472 @@ +'use strict'; + +function createCodexAppRuntime(deps = {}) { + const { + wsSend, + loadSession, + saveSession, + truncateObj, + } = deps; + + function truncate(value, maxLen) { + if (typeof truncateObj === 'function') return truncateObj(value, maxLen); + const text = typeof value === 'string' ? value : JSON.stringify(value); + return text.length > maxLen ? `${text.slice(0, maxLen)}...` : value; + } + + function sendRuntime(entry, sessionId, payload) { + wsSend(entry.ws, { ...payload, sessionId }); + } + + function itemKind(item) { + switch (item?.type) { + case 'commandExecution': + return 'command_execution'; + case 'mcpToolCall': + return 'mcp_tool_call'; + case 'fileChange': + return 'file_change'; + case 'reasoning': + return 'reasoning'; + case 'dynamicToolCall': + return 'dynamic_tool_call'; + case 'collabAgentToolCall': + return 'collab_agent_tool_call'; + case 'webSearch': + return 'web_search'; + case 'imageView': + return 'image_view'; + case 'imageGeneration': + return 'image_generation'; + default: + return item?.type || 'codex_app_item'; + } + } + + function itemName(item) { + switch (item?.type) { + case 'commandExecution': + return 'CommandExecution'; + case 'mcpToolCall': + return 'McpToolCall'; + case 'fileChange': + return 'FileChange'; + case 'reasoning': + return 'Reasoning'; + case 'dynamicToolCall': + return item.tool || 'DynamicToolCall'; + case 'collabAgentToolCall': + return item.tool || 'CollabAgentToolCall'; + case 'webSearch': + return 'WebSearch'; + case 'imageGeneration': + return 'ImageGeneration'; + default: + return item?.type || 'CodexAppItem'; + } + } + + function itemInput(item) { + if (!item) return null; + switch (item.type) { + case 'commandExecution': + return { command: item.command || '' }; + case 'mcpToolCall': + return { + server: item.server || '', + tool: item.tool || '', + arguments: item.arguments ?? null, + }; + case 'fileChange': + return { changes: item.changes || [] }; + case 'reasoning': + return { content: item.content || [], summary: item.summary || [] }; + case 'dynamicToolCall': + return { tool: item.tool || '', namespace: item.namespace || null, arguments: item.arguments ?? null }; + case 'collabAgentToolCall': + return { + tool: item.tool || '', + prompt: item.prompt || null, + receiverThreadIds: item.receiverThreadIds || [], + agentsStates: item.agentsStates || {}, + }; + default: + return truncate(item, 500); + } + } + + function reasoningTextFromItem(item) { + const parts = [...(item?.summary || []), ...(item?.content || [])]; + return parts.map((part) => { + if (typeof part === 'string') return part; + if (typeof part?.text === 'string') return part.text; + return ''; + }).filter(Boolean).join('\n\n'); + } + + function hasReasoningContent(item) { + return Boolean(reasoningTextFromItem(item).trim()); + } + + function itemMeta(item) { + if (!item) return null; + switch (item.type) { + case 'commandExecution': + return { + kind: 'command_execution', + title: 'Shell Command', + subtitle: item.command || '', + exitCode: typeof item.exitCode === 'number' ? item.exitCode : null, + status: item.status || null, + }; + case 'mcpToolCall': + return { + kind: 'mcp_tool_call', + title: 'MCP Tool', + subtitle: [item.server, item.tool].filter(Boolean).join('.'), + status: item.status || null, + }; + case 'fileChange': + return { + kind: 'file_change', + title: 'File Change', + subtitle: (item.changes || []).map((change) => change.path).filter(Boolean).join(', '), + status: item.status || null, + }; + case 'reasoning': + return { + kind: 'reasoning', + title: 'Reasoning', + subtitle: reasoningTextFromItem(item).replace(/\s+/g, ' ').slice(0, 120), + status: item.status || null, + }; + default: + return { + kind: itemKind(item), + title: itemName(item), + subtitle: item.tool || item.query || '', + status: item.status || null, + }; + } + } + + function stringifyMcpResult(result) { + if (!result) return ''; + if (Array.isArray(result.content)) { + const text = result.content.map((part) => { + if (typeof part?.text === 'string') return part.text; + try { + return JSON.stringify(part); + } catch { + return String(part); + } + }).filter(Boolean).join('\n'); + if (text) return text; + } + try { + return JSON.stringify(result, null, 2); + } catch { + return String(result); + } + } + + function itemResult(item) { + if (!item) return ''; + switch (item.type) { + case 'commandExecution': + return item.aggregatedOutput || ''; + case 'mcpToolCall': + return item.error?.message || stringifyMcpResult(item.result); + case 'fileChange': + return JSON.stringify(item.changes || [], null, 2); + case 'reasoning': + return reasoningTextFromItem(item); + case 'dynamicToolCall': + return JSON.stringify({ + success: item.success ?? null, + contentItems: item.contentItems || null, + }, null, 2); + case 'collabAgentToolCall': + return JSON.stringify({ + status: item.status || null, + receiverThreadIds: item.receiverThreadIds || [], + agentsStates: item.agentsStates || {}, + }, null, 2); + default: + if (typeof item.text === 'string') return item.text; + return JSON.stringify(truncate(item, 1200)); + } + } + + function ensureToolCall(entry, item, sessionId) { + if (!item?.id) return null; + const kind = itemKind(item); + let toolCall = entry.toolCalls.find((tool) => tool.id === item.id); + if (toolCall) { + toolCall.name = itemName(item); + toolCall.kind = kind; + toolCall.meta = itemMeta(item) || toolCall.meta || null; + if (toolCall.input == null) toolCall.input = itemInput(item); + return toolCall; + } + + toolCall = { + name: itemName(item), + id: item.id, + kind, + meta: itemMeta(item), + input: itemInput(item), + done: false, + }; + entry.toolCalls.push(toolCall); + sendRuntime(entry, sessionId, { + type: 'tool_start', + name: toolCall.name, + toolUseId: toolCall.id, + input: toolCall.input, + kind: toolCall.kind, + meta: toolCall.meta, + }); + return toolCall; + } + + function appendAgentText(entry, itemId, text) { + const nextText = String(text || ''); + if (!nextText) return ''; + if (!entry.agentMessageItems) entry.agentMessageItems = new Map(); + const currentItemText = entry.agentMessageItems.get(itemId) || ''; + entry.agentMessageItems.set(itemId, currentItemText + nextText); + entry.fullText = (entry.fullText || '') + nextText; + return nextText; + } + + function appendAgentCompletedText(entry, item) { + const text = String(item?.text || ''); + if (!text) return ''; + if (!entry.agentMessageItems) entry.agentMessageItems = new Map(); + const currentItemText = entry.agentMessageItems.get(item.id) || ''; + if (currentItemText && text.startsWith(currentItemText)) { + const remainder = text.slice(currentItemText.length); + entry.agentMessageItems.set(item.id, text); + entry.fullText = (entry.fullText || '') + remainder; + return remainder; + } + if (currentItemText === text) return ''; + entry.agentMessageItems.set(item.id, text); + entry.fullText = (entry.fullText || '') + text; + return text; + } + + function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) { + if (!itemId) return; + let toolCall = entry.toolCalls.find((tool) => tool.id === itemId); + if (!toolCall) { + toolCall = { + name: patch.name || 'CodexAppItem', + id: itemId, + kind: patch.kind || 'codex_app_item', + meta: patch.meta || null, + input: patch.input || null, + done: false, + }; + entry.toolCalls.push(toolCall); + sendRuntime(entry, sessionId, { + type: 'tool_start', + name: toolCall.name, + toolUseId: toolCall.id, + input: toolCall.input, + kind: toolCall.kind, + meta: toolCall.meta, + }); + } + if (patch.name) toolCall.name = patch.name; + if (patch.kind) toolCall.kind = patch.kind; + if (patch.meta) toolCall.meta = patch.meta; + if (patch.input !== undefined) toolCall.input = patch.input; + toolCall.done = done; + toolCall.result = result; + sendRuntime(entry, sessionId, { + type: done ? 'tool_end' : 'tool_update', + toolUseId: itemId, + name: toolCall.name, + input: toolCall.input, + result, + kind: toolCall.kind, + meta: toolCall.meta, + }); + } + + function updateUsage(sessionId, entry, usage) { + const total = usage?.total || usage || null; + if (!total) return; + const session = loadSession(sessionId); + if (!session) return; + session.totalUsage = { + inputTokens: total.inputTokens || 0, + cachedInputTokens: total.cachedInputTokens || 0, + outputTokens: total.outputTokens || 0, + }; + entry.lastUsage = session.totalUsage; + saveSession(session); + sendRuntime(entry, sessionId, { type: 'usage', totalUsage: session.totalUsage }); + } + + function processCodexAppNotification(entry, notification, sessionId) { + if (!entry || !notification?.method) return { done: false }; + const method = notification.method; + const params = notification.params || {}; + + if (params.threadId && !entry.threadId) entry.threadId = params.threadId; + if (params.turnId && !entry.turnId) entry.turnId = params.turnId; + + switch (method) { + case 'turn/started': + if (params.turn?.id) entry.turnId = params.turn.id; + return { done: false }; + + case 'item/started': { + const item = params.item; + if (!item || !item.id || item.type === 'agentMessage' || item.type === 'userMessage') return { done: false }; + if (item.type === 'reasoning' && !hasReasoningContent(item)) return { done: false }; + ensureToolCall(entry, item, sessionId); + return { done: false }; + } + + case 'item/agentMessage/delta': { + const chunk = appendAgentText(entry, params.itemId || 'agent-message', params.delta || ''); + if (chunk) sendRuntime(entry, sessionId, { type: 'text_delta', text: chunk }); + return { done: false }; + } + + case 'item/commandExecution/outputDelta': { + const itemId = params.itemId; + const current = entry.toolOutputDeltas?.get(itemId) || ''; + const next = current + String(params.delta || ''); + if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map(); + entry.toolOutputDeltas.set(itemId, next); + updateToolResult(entry, sessionId, itemId, next, false, { + name: 'CommandExecution', + kind: 'command_execution', + }); + return { done: false }; + } + + case 'item/fileChange/patchUpdated': { + updateToolResult(entry, sessionId, params.itemId, JSON.stringify(params.changes || [], null, 2), false, { + name: 'FileChange', + kind: 'file_change', + input: { changes: params.changes || [] }, + meta: { + kind: 'file_change', + title: 'File Change', + subtitle: (params.changes || []).map((change) => change.path).filter(Boolean).join(', '), + status: 'inProgress', + }, + }); + return { done: false }; + } + + case 'item/mcpToolCall/progress': { + updateToolResult(entry, sessionId, params.itemId, params.message || '', false, { + name: 'McpToolCall', + kind: 'mcp_tool_call', + }); + return { done: false }; + } + + case 'item/reasoning/summaryTextDelta': + case 'item/reasoning/textDelta': { + const itemId = params.itemId; + const delta = String(params.delta || ''); + if (!itemId || !delta) return { done: false }; + const current = entry.toolOutputDeltas?.get(itemId) || ''; + const next = current + delta; + if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map(); + entry.toolOutputDeltas.set(itemId, next); + updateToolResult(entry, sessionId, itemId, next, false, { + name: 'Reasoning', + kind: 'reasoning', + }); + return { done: false }; + } + + case 'item/completed': { + const item = params.item; + if (!item || !item.id) return { done: false }; + if (item.type === 'agentMessage') { + const chunk = appendAgentCompletedText(entry, item); + if (chunk) sendRuntime(entry, sessionId, { type: 'text_delta', text: chunk }); + return { done: false }; + } + if (item.type === 'userMessage') return { done: false }; + if (item.type === 'reasoning') { + const result = (itemResult(item) || entry.toolOutputDeltas?.get(item.id) || '').slice(0, 4000); + if (!result.trim()) return { done: false }; + const toolCall = ensureToolCall(entry, item, sessionId); + if (!toolCall) return { done: false }; + toolCall.done = true; + toolCall.result = result; + toolCall.meta = itemMeta(item) || toolCall.meta; + sendRuntime(entry, sessionId, { + type: 'tool_end', + toolUseId: toolCall.id, + result, + kind: toolCall.kind, + meta: toolCall.meta, + }); + return { done: false }; + } + const toolCall = ensureToolCall(entry, item, sessionId); + if (!toolCall) return { done: false }; + const result = itemResult(item).slice(0, 4000); + toolCall.done = true; + toolCall.result = result; + toolCall.meta = itemMeta(item) || toolCall.meta; + sendRuntime(entry, sessionId, { + type: 'tool_end', + toolUseId: toolCall.id, + result, + kind: toolCall.kind, + meta: toolCall.meta, + }); + return { done: false }; + } + + case 'thread/tokenUsage/updated': + updateUsage(sessionId, entry, params.tokenUsage); + return { done: false }; + + case 'turn/completed': { + if (params.turn?.id) entry.turnId = params.turn.id; + entry.turnStatus = params.turn?.status || 'completed'; + if (params.turn?.status === 'failed') { + entry.lastError = params.turn?.error?.message || 'Codex App 任务失败'; + } + return { done: true }; + } + + case 'error': + case 'warning': + case 'guardianWarning': + case 'configWarning': + case 'deprecationNotice': { + const message = params.message || params.title || ''; + if (message) { + if (method === 'error') entry.lastError = message; + sendRuntime(entry, sessionId, { type: 'system_message', message }); + } + return { done: false }; + } + + default: + return { done: false }; + } + } + + return { + processCodexAppNotification, + updateUsage, + }; +} + +module.exports = { createCodexAppRuntime }; diff --git a/lib/codex-app-server-client.js b/lib/codex-app-server-client.js new file mode 100644 index 0000000..16bb2d6 --- /dev/null +++ b/lib/codex-app-server-client.js @@ -0,0 +1,220 @@ +'use strict'; + +const readline = require('readline'); +const { spawn } = require('child_process'); + +function createCodexAppServerClient(options = {}) { + const command = options.command || 'codex'; + const args = Array.isArray(options.args) && options.args.length > 0 + ? options.args.slice() + : ['app-server', '--stdio']; + const env = options.env || process.env; + const cwd = options.cwd || process.cwd(); + const clientInfo = options.clientInfo || { + name: 'ccweb_codexapp', + title: 'CC-Web Codex App', + version: '0.1.0', + }; + const onNotification = typeof options.onNotification === 'function' ? options.onNotification : () => {}; + const onServerRequest = typeof options.onServerRequest === 'function' ? options.onServerRequest : null; + const onExit = typeof options.onExit === 'function' ? options.onExit : () => {}; + const onLog = typeof options.onLog === 'function' ? options.onLog : () => {}; + const postInitialize = typeof options.postInitialize === 'function' ? options.postInitialize : null; + + let proc = null; + let rl = null; + let nextId = 1; + let initPromise = null; + let exited = false; + const pending = new Map(); + + function rejectAllPending(err) { + for (const [, pendingRequest] of pending) { + clearTimeout(pendingRequest.timer); + pendingRequest.reject(err); + } + pending.clear(); + } + + function sendRaw(message) { + if (!proc || !proc.stdin || proc.stdin.destroyed) { + throw new Error('Codex app-server 未启动。'); + } + proc.stdin.write(`${JSON.stringify(message)}\n`); + } + + function respondToServerRequest(id, result, error) { + try { + if (error) { + sendRaw({ id, error }); + } else { + sendRaw({ id, result: result || {} }); + } + } catch (err) { + onLog('WARN', 'codex_app_server_response_failed', { error: err.message }); + } + } + + function handleServerRequest(message) { + const id = message.id; + const method = message.method; + const params = message.params || {}; + if (onServerRequest) { + Promise.resolve() + .then(() => onServerRequest({ method, params, id })) + .then((result) => respondToServerRequest(id, result || {})) + .catch((err) => respondToServerRequest(id, null, { + code: -32603, + message: err?.message || 'cc-web 无法处理 Codex app-server 请求。', + })); + return; + } + respondToServerRequest(id, null, { + code: -32601, + message: `cc-web 暂不支持 Codex app-server 请求: ${method}`, + }); + } + + function handleMessage(line) { + let message; + try { + message = JSON.parse(line); + } catch { + onLog('WARN', 'codex_app_server_invalid_json', { line: String(line || '').slice(0, 200) }); + return; + } + + if (Object.prototype.hasOwnProperty.call(message, 'id')) { + const pendingRequest = pending.get(message.id); + if (pendingRequest) { + pending.delete(message.id); + clearTimeout(pendingRequest.timer); + if (message.error) { + const err = new Error(message.error.message || 'Codex app-server 请求失败。'); + err.code = message.error.code; + err.data = message.error.data; + pendingRequest.reject(err); + } else { + pendingRequest.resolve(message.result || {}); + } + return; + } + if (message.method) { + handleServerRequest(message); + return; + } + } + + if (message.method) { + onNotification(message); + } + } + + function request(method, params = {}, timeoutMs = 300000) { + const id = nextId++; + const message = { id, method, params }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Codex app-server 请求超时: ${method}`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timer, method }); + try { + sendRaw(message); + } catch (err) { + clearTimeout(timer); + pending.delete(id); + reject(err); + } + }); + } + + function notification(method, params = {}) { + sendRaw({ method, params }); + } + + function start() { + if (initPromise) return initPromise; + exited = false; + proc = spawn(command, args, { + env, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + + let stderr = ''; + proc.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + if (stderr.length > 4000) stderr = stderr.slice(-4000); + }); + + rl = readline.createInterface({ input: proc.stdout }); + rl.on('line', handleMessage); + + proc.on('exit', (code, signal) => { + exited = true; + if (rl) rl.close(); + const err = new Error(`Codex app-server 已退出: code=${code ?? 'null'} signal=${signal || 'null'}`); + err.exitCode = code; + err.signal = signal; + err.stderr = stderr; + rejectAllPending(err); + onExit({ code, signal, stderr }); + }); + + proc.on('error', (err) => { + rejectAllPending(err); + onExit({ code: null, signal: null, stderr: err.message }); + }); + + initPromise = request('initialize', { + clientInfo, + capabilities: { experimentalApi: true }, + }, 30000) + .then(async (result) => { + notification('initialized', {}); + if (postInitialize) await postInitialize({ request, notification, onLog }); + return result; + }) + .catch((err) => { + stop(); + throw err; + }); + + return initPromise; + } + + function stop() { + initPromise = null; + if (rl) { + try { rl.close(); } catch {} + rl = null; + } + if (proc && !exited) { + try { proc.kill('SIGTERM'); } catch {} + setTimeout(() => { + try { + if (proc && !proc.killed) proc.kill('SIGKILL'); + } catch {} + }, 3000); + } + proc = null; + rejectAllPending(new Error('Codex app-server 已停止。')); + } + + function isRunning() { + return !!proc && !exited; + } + + return { + start, + stop, + request, + notification, + isRunning, + pid: () => proc?.pid || null, + }; +} + +module.exports = { createCodexAppServerClient }; diff --git a/public/app.js b/public/app.js index 36f47fc..89ea712 100644 --- a/public/app.js +++ b/public/app.js @@ -2,8 +2,10 @@ (function () { 'use strict'; + const ASSET_VERSION = '20260613-codexapp-tools2'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; + const COMPOSER_SUGGESTION_DEBOUNCE = 120; const SLASH_COMMANDS = [ { cmd: '/clear', desc: '清除当前会话' }, @@ -24,6 +26,7 @@ const AGENT_LABELS = { claude: 'Claude', codex: 'Codex', + codexapp: 'Codex App', }; const DEFAULT_AGENT = 'claude'; @@ -99,6 +102,10 @@ let loadedHistorySessionId = null; let activeSessionLoad = null; let sidebarSwipe = null; + let activeComposerToken = null; + let composerSuggestionTimer = null; + let composerRequestSeq = 0; + let latestComposerRequestId = ''; let pendingAttachments = []; let uploadingAttachments = []; let attachmentPreviewModal = null; @@ -108,6 +115,7 @@ let currentSessionRunning = false; let fileBrowserState = null; let directoryPickerState = null; + let codexAppUserInputModal = null; let pendingNewSessionRequest = null; let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; let pendingInitialSessionLoad = false; @@ -143,6 +151,7 @@ const chatCwd = $('#chat-cwd'); const costDisplay = $('#cost-display'); const attachmentTray = $('#attachment-tray'); + const pendingNotesTray = $('#pending-notes-tray'); const imageUploadInput = $('#image-upload-input'); const attachBtn = $('#attach-btn'); const messagesDiv = $('#messages'); @@ -172,6 +181,15 @@ return AGENT_LABELS[agent] ? agent : DEFAULT_AGENT; } + function isCodexLikeAgent(agent) { + const normalized = normalizeAgent(agent); + return normalized === 'codex' || normalized === 'codexapp'; + } + + function isCodexAppAgent(agent) { + return normalizeAgent(agent) === 'codexapp'; + } + function getDraftNoteKey(agent = currentAgent) { return `draft:${normalizeAgent(agent)}`; } @@ -218,9 +236,10 @@ } if (inputWrapper) inputWrapper.classList.toggle('note-mode-active', active); if (sendBtn) { + const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !active; sendBtn.classList.toggle('note-send', active); - sendBtn.title = active ? '记录笔记' : '发送'; - sendBtn.hidden = isGenerating ? !active : false; + sendBtn.title = active ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送'); + sendBtn.hidden = isGenerating ? (!active && !allowRuntimeInsert) : false; } if (msgInput) { msgInput.placeholder = active ? '记录笔记,稍后从气泡发送…' : defaultMsgInputPlaceholder; @@ -240,15 +259,15 @@ function createPendingNoteElement(note) { const div = document.createElement('div'); - div.className = 'msg note'; + div.className = 'pending-note'; div.dataset.noteId = note.id; const avatar = document.createElement('div'); - avatar.className = 'msg-avatar note-avatar'; + avatar.className = 'note-avatar'; avatar.textContent = 'N'; const bubble = document.createElement('div'); - bubble.className = 'msg-bubble note-bubble'; + bubble.className = 'note-bubble'; const meta = document.createElement('div'); meta.className = 'note-meta'; @@ -274,24 +293,23 @@ } function renderPendingNotes(options = {}) { - messagesDiv.querySelectorAll('.msg.note').forEach((node) => node.remove()); + if (!pendingNotesTray) return; + pendingNotesTray.innerHTML = ''; const notes = getCurrentNotes(false); if (!notes || notes.length === 0) { - updateScrollbar(); + pendingNotesTray.hidden = true; + if (options.updateScrollbar !== false) updateScrollbar(); return; } - const welcome = messagesDiv.querySelector('.welcome-msg'); - if (welcome) welcome.remove(); - const frag = document.createDocumentFragment(); notes.forEach((note) => frag.appendChild(createPendingNoteElement(note))); - messagesDiv.appendChild(frag); - if (options.scroll !== false) { - scrollToBottom(); - } else { - updateScrollbar(); + pendingNotesTray.appendChild(frag); + pendingNotesTray.hidden = false; + if (options.scrollIntoView !== false && options.scroll !== false) { + pendingNotesTray.scrollTop = pendingNotesTray.scrollHeight; } + if (options.updateScrollbar !== false) updateScrollbar(); } function findPendingNote(noteId) { @@ -336,7 +354,7 @@ function beginEditPendingNote(noteId) { const found = findPendingNote(noteId); if (!found) return; - const noteEl = messagesDiv.querySelector(`.msg.note[data-note-id="${noteId}"]`); + const noteEl = pendingNotesTray?.querySelector(`.pending-note[data-note-id="${noteId}"]`); const bubble = noteEl?.querySelector('.note-bubble'); if (!bubble) return; @@ -650,6 +668,15 @@ return sessions.find((s) => s.id === sessionId) || null; } + function compareSessionUpdatedDesc(a, b) { + return new Date(b?.updated || 0) - new Date(a?.updated || 0); + } + + function compareSessionPinnedDesc(a, b) { + const pinnedDiff = new Date(b?.pinnedAt || 0) - new Date(a?.pinnedAt || 0); + return pinnedDiff || compareSessionUpdatedDesc(a, b); + } + function shortSessionId(sessionId) { const value = String(sessionId || ''); return value ? value.slice(0, 8) : ''; @@ -730,6 +757,7 @@ mode: payload.mode || 'yolo', model: payload.model || '', agent: normalizeAgent(payload.agent), + pinnedAt: payload.pinnedAt || null, hasUnread: !!payload.hasUnread, cwd: payload.cwd || null, totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0, @@ -832,6 +860,7 @@ snapshot.agent = normalizeAgent(meta.agent || snapshot.agent); snapshot.hasUnread = !!meta.hasUnread; snapshot.updated = meta.updated || snapshot.updated; + snapshot.pinnedAt = meta.pinnedAt || null; snapshot.isRunning = !!meta.isRunning; } return snapshot; @@ -898,6 +927,154 @@ return data || {}; } + function closeCodexAppUserInputModal(sendCancel = false) { + if (!codexAppUserInputModal) return; + const { overlay, escapeHandler, requestId, sessionId } = codexAppUserInputModal; + if (escapeHandler) document.removeEventListener('keydown', escapeHandler); + if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); + codexAppUserInputModal = null; + if (sendCancel && requestId) { + send({ + type: 'codex_app_user_input_response', + sessionId, + requestId, + answers: {}, + }); + } + } + + function cssEscape(value) { + if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || '')); + return String(value || '').replace(/["\\]/g, '\\$&'); + } + + function collectCodexAppUserInputAnswers(panel, questions) { + const answers = {}; + for (const question of questions) { + const id = String(question?.id || '').trim(); + if (!id) continue; + const escapedId = cssEscape(id); + const checked = panel.querySelector(`input[name="codex-ui-${escapedId}"]:checked`); + const values = []; + if (checked) { + if (checked.value === '__other__') { + const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`); + const text = String(input?.value || '').trim(); + if (text) values.push(text); + } else { + values.push(checked.value); + } + } else { + const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`); + const text = String(input?.value || '').trim(); + if (text) values.push(text); + } + answers[id] = { answers: values }; + } + return answers; + } + + function renderCodexAppQuestion(question, index) { + const id = String(question?.id || `q${index}`); + const options = Array.isArray(question?.options) ? question.options : []; + const hasOther = !!question?.isOther || options.length === 0; + const inputType = question?.isSecret ? 'password' : 'text'; + const optionHtml = options.map((option, optionIndex) => { + const value = String(option?.label || `选项 ${optionIndex + 1}`); + return ` + + `; + }).join(''); + const otherHtml = hasOther ? ` + + ` : ''; + + return ` +
+
${escapeHtml(question?.header || `问题 ${index + 1}`)}
+
${escapeHtml(question?.question || '请选择一个答案。')}
+
+ ${optionHtml} + ${otherHtml} +
+
+ `; + } + + function showCodexAppUserInputModal(msg) { + closeCodexAppUserInputModal(true); + const questions = Array.isArray(msg.questions) ? msg.questions : []; + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay codex-user-input-overlay'; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + const panel = overlay.querySelector('.codex-user-input-panel'); + const escapeHandler = (e) => { + if (e.key === 'Escape') closeCodexAppUserInputModal(true); + }; + document.addEventListener('keydown', escapeHandler); + + codexAppUserInputModal = { + overlay, + requestId: msg.requestId || '', + sessionId: msg.sessionId || '', + escapeHandler, + }; + + overlay.querySelectorAll('[data-codex-ui-cancel]').forEach((button) => { + button.addEventListener('click', () => closeCodexAppUserInputModal(true)); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) closeCodexAppUserInputModal(true); + }); + overlay.querySelector('[data-codex-ui-submit]')?.addEventListener('click', () => { + send({ + type: 'codex_app_user_input_response', + sessionId: msg.sessionId, + requestId: msg.requestId, + answers: collectCodexAppUserInputAnswers(panel, questions), + }); + closeCodexAppUserInputModal(false); + }); + + panel.querySelectorAll('.codex-user-input-text').forEach((input) => { + input.addEventListener('focus', () => { + const radio = input.closest('.codex-user-input-option')?.querySelector('input[type="radio"]'); + if (radio) radio.checked = true; + }); + }); + panel.querySelector('input, button')?.focus(); + } + function closeDirectoryPicker() { if (!directoryPickerState) return; const { overlay, escapeHandler } = directoryPickerState; @@ -1824,26 +2001,66 @@ group.cwd = getSessionEffectiveCwd(session) || group.cwd; } } + for (const group of groups) { + group.sessions.sort(compareSessionUpdatedDesc); + } + ungroupedSessions.sort(compareSessionUpdatedDesc); return { groups: groups.sort((a, b) => new Date(b.latestUpdated || 0) - new Date(a.latestUpdated || 0)), ungroupedSessions, }; } + function splitPinnedSessions(sessionItems) { + const pinnedSessions = []; + const regularSessions = []; + for (const session of sessionItems) { + if (session.pinnedAt) { + pinnedSessions.push(session); + } else { + regularSessions.push(session); + } + } + pinnedSessions.sort(compareSessionPinnedDesc); + regularSessions.sort(compareSessionUpdatedDesc); + return { pinnedSessions, regularSessions }; + } + + function applySessionPinnedState(sessionId, pinnedAt) { + sessions = sessions.map((session) => ( + session.id === sessionId ? { ...session, pinnedAt: pinnedAt || null } : session + )); + updateCachedSession(sessionId, (snapshot) => { + snapshot.pinnedAt = pinnedAt || null; + }); + renderSessionList(); + } + + function toggleSessionPinned(session) { + if (!session?.id) return; + const nextPinned = !session.pinnedAt; + const pinnedAt = nextPinned ? new Date().toISOString() : null; + applySessionPinnedState(session.id, pinnedAt); + send({ type: 'set_session_pinned', sessionId: session.id, pinned: nextPinned }); + } + function createSessionListItem(session) { const item = document.createElement('div'); - item.className = `session-item${session.id === currentSessionId ? ' active' : ''}`; + const isPinned = !!session.pinnedAt; + item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}`; item.dataset.id = session.id; const sessionCwd = getSessionEffectiveCwd(session); if (sessionCwd) item.title = sessionCwd; item.innerHTML = `
${escapeHtml(session.title || 'Untitled')} + ${isPinned ? '' : ''} ${session.isRunning ? '运行中' : ''}
${session.hasUnread ? '' : ''} ${timeAgo(session.updated)}
+ @@ -1857,6 +2074,11 @@ copyTextToClipboard(session.id, '会话 ID 已复制'); return; } + if (target.classList.contains('pin')) { + e.stopPropagation(); + toggleSessionPinned(session); + return; + } if (target.classList.contains('delete')) { e.stopPropagation(); const doDelete = () => { @@ -1928,7 +2150,13 @@ }); } if (importSessionBtn) { - importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话'; + if (isCodexAppAgent(currentAgent)) { + importSessionBtn.textContent = 'Codex App 暂不支持导入'; + importSessionBtn.disabled = true; + } else { + importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话'; + importSessionBtn.disabled = false; + } } } @@ -1961,7 +2189,7 @@ clearSessionLoading(); setCurrentSessionRunningState(false); currentCwd = null; - currentModel = currentAgent === 'claude' ? 'opus' : ''; + currentModel = isCodexLikeAgent(currentAgent) ? '' : 'opus'; isGenerating = false; generatingSessionId = null; pendingText = ''; @@ -2152,7 +2380,7 @@ } function setStatsDisplay(msg) { - if (currentAgent === 'codex' && msg && msg.totalUsage) { + if (isCodexLikeAgent(currentAgent) && msg && msg.totalUsage) { const usage = msg.totalUsage; if ((usage.inputTokens || 0) > 0 || (usage.outputTokens || 0) > 0) { const cacheText = usage.cachedInputTokens ? ` · cache ${usage.cachedInputTokens}` : ''; @@ -2204,7 +2432,7 @@ DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addBaseOption(opt.value, opt.label, opt.desc)); addBaseOption(currentModel, currentModel, '当前会话模型'); sessions - .filter((s) => normalizeAgent(s.agent) === 'codex' && s.id === currentSessionId) + .filter((s) => isCodexLikeAgent(s.agent) && s.id === currentSessionId) .forEach((s) => addBaseOption(s.model, s.model, '当前会话已保存模型')); return options; @@ -2409,6 +2637,7 @@ cwd: snapshot.cwd || session.cwd || '', projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '', title: snapshot.title || session.title, + pinnedAt: snapshot.pinnedAt || session.pinnedAt || null, } : session )); @@ -2471,6 +2700,10 @@ renderSessionList(); break; + case 'session_pinned': + applySessionPinnedState(msg.sessionId, msg.pinnedAt || null); + break; + case 'text_delta': if (!ensureGeneratingForEvent(msg)) break; pendingText += msg.text; @@ -2488,6 +2721,7 @@ case 'tool_start': if (!ensureGeneratingForEvent(msg)) break; + if (isEmptyReasoningTool({ name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false })) break; activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false }); appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null); break; @@ -2500,9 +2734,10 @@ input: msg.input, kind: msg.kind || null, meta: msg.meta || null, + result: msg.result, done: false, }); - appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null); + appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null, msg.result); } activeToolCalls.get(msg.toolUseId).done = false; if (msg.name) activeToolCalls.get(msg.toolUseId).name = msg.name; @@ -2515,6 +2750,17 @@ case 'tool_end': if (!isCurrentSessionEvent(msg)) break; + if (!activeToolCalls.has(msg.toolUseId) && !isEmptyReasoningTool({ name: msg.name, input: msg.input, result: msg.result, kind: msg.kind || null, meta: msg.meta || null, done: true })) { + activeToolCalls.set(msg.toolUseId, { + name: msg.name, + input: msg.input, + kind: msg.kind || null, + meta: msg.meta || null, + result: msg.result, + done: true, + }); + appendToolCall(msg.toolUseId, msg.name, msg.input, true, msg.kind || null, msg.meta || null, msg.result); + } if (activeToolCalls.has(msg.toolUseId)) { activeToolCalls.get(msg.toolUseId).done = true; if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind; @@ -2558,6 +2804,13 @@ appendSystemMessage(msg.message); break; + case 'codex_app_user_input_request': + if (msg.sessionId && msg.sessionId !== currentSessionId) { + showToast('Codex App 需要输入', msg.sessionId); + } + showCodexAppUserInputModal(msg); + break; + case 'mode_changed': if (msg.mode && MODE_LABELS[msg.mode]) { currentMode = msg.mode; @@ -2714,6 +2967,10 @@ if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg); break; + case 'composer_suggestions': + handleComposerSuggestions(msg); + break; + case 'update_info': if (typeof window._ccOnUpdateInfo === 'function') window._ccOnUpdateInfo(msg); break; @@ -2853,7 +3110,8 @@ function createMsgElement(role, content, attachments = [], meta = {}) { const div = document.createElement('div'); const isCrossConversation = role === 'user' && !!meta.crossConversation; - div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}`; + const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId); + div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`; if (role === 'system') { const bubble = document.createElement('div'); @@ -2866,11 +3124,11 @@ const avatar = document.createElement('div'); avatar.className = 'msg-avatar'; if (isCrossConversation) { - avatar.textContent = '↗'; + avatar.textContent = isCrossConversationReply ? '↩' : '↗'; } else if (role === 'user') { avatar.textContent = 'U'; - } else if (currentAgent === 'codex') { - avatar.innerHTML = `Codex`; + } else if (isCodexLikeAgent(currentAgent)) { + avatar.innerHTML = `${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'}`; } else { avatar.innerHTML = `Claude`; } @@ -2888,7 +3146,9 @@ const label = document.createElement('span'); label.className = 'cross-conversation-label'; - label.textContent = `来自「${sourceTitle}」的对话`; + label.textContent = isCrossConversationReply + ? `来自「${sourceTitle}」的回复` + : `来自「${sourceTitle}」的对话`; sourceMeta.appendChild(label); if (sourceId) { @@ -3090,6 +3350,26 @@ } } + function reasoningPartText(parts) { + if (!Array.isArray(parts)) return ''; + return parts.map((part) => { + if (typeof part === 'string') return part; + if (typeof part?.text === 'string') return part.text; + return ''; + }).filter(Boolean).join('\n'); + } + + function isEmptyReasoningTool(tool) { + if (toolKind(tool) !== 'reasoning') return false; + const resultText = stringifyToolValue(tool?.result).trim(); + const input = tool?.input || {}; + const inputText = [ + reasoningPartText(input.content), + reasoningPartText(input.summary), + ].filter(Boolean).join('\n').trim(); + return !resultText && !inputText; + } + function toolStateLabel(tool, done) { if (!done) return 'Running'; if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number') { @@ -3183,6 +3463,7 @@ const FOLD_AT = 3; let grouped = false; for (const tc of m.toolCalls) { + if (isEmptyReasoningTool(tc)) continue; const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true); // 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group @@ -3548,7 +3829,7 @@ const kind = toolKind(tool); if (tool.name === 'AskUserQuestion') { details.open = true; - } else if (agent !== 'codex' && !done && kind === 'command_execution') { + } else if (!isCodexLikeAgent(agent) && !done && kind === 'command_execution') { details.open = true; } @@ -3559,7 +3840,7 @@ return details; } - function appendToolCall(toolUseId, name, input, done, kind = null, meta = null) { + function appendToolCall(toolUseId, name, input, done, kind = null, meta = null, result = undefined) { const streamEl = document.getElementById('streaming-msg'); if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); @@ -3568,6 +3849,8 @@ if (!toolsDiv) { toolsDiv = bubble; } const tool = { id: toolUseId, name, input, kind, meta, done }; + if (result !== undefined) tool.result = result; + if (isEmptyReasoningTool(tool)) return; // 如果是 todo_list,检查是否已存在相同 id 的 todo_list if (kind === 'todo_list' && input?.id) { @@ -3672,6 +3955,11 @@ }; nextTool.done = done; if (result !== undefined) nextTool.result = result; + if (isEmptyReasoningTool(nextTool)) { + activeToolCalls.delete(toolUseId); + el.remove(); + return; + } rememberToolCallTarget(toolUseId, nextTool, el); const summary = el.querySelector('summary'); if (summary) applyToolSummary(summary, nextTool, done); @@ -3690,6 +3978,9 @@ if (normalized === 'codex') { return '删除本会话将同步删去本地 Codex rollout 历史与线程记录,不可恢复。确认删除?'; } + if (normalized === 'codexapp') { + return '删除本会话只会删除 cc-web 中的 Codex App 会话记录,不会清理本地 Codex App 线程历史。确认删除?'; + } return '删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?'; } @@ -3868,7 +4159,26 @@ return; } - const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleSessions); + const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions); + if (pinnedSessions.length > 0) { + const pinnedGroupEl = document.createElement('section'); + pinnedGroupEl.className = 'session-project-group session-pinned-group'; + const pinnedHeader = document.createElement('div'); + pinnedHeader.className = 'session-project-header session-pinned-header'; + pinnedHeader.innerHTML = ` + 置顶 + + ${pinnedSessions.length} + + `; + pinnedGroupEl.appendChild(pinnedHeader); + for (const session of pinnedSessions) { + pinnedGroupEl.appendChild(createSessionListItem(session)); + } + sessionList.appendChild(pinnedGroupEl); + } + + const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions); for (const group of projectGroups) { const groupEl = document.createElement('section'); groupEl.className = 'session-project-group'; @@ -4093,52 +4403,137 @@ } } - // --- Slash Command Menu --- - function showCmdMenu(filter) { - const filtered = SLASH_COMMANDS.filter(c => - c.cmd.startsWith(filter) || c.desc.includes(filter.slice(1)) - ); - // Exact match first (fixes /mode vs /model ambiguity) - filtered.sort((a, b) => (b.cmd === filter ? 1 : 0) - (a.cmd === filter ? 1 : 0)); - if (filtered.length === 0) { + // --- Composer Modifier Menu --- + function getLocalSlashSuggestions(query) { + const normalized = String(query || '').replace(/^\//, '').toLowerCase(); + const filtered = SLASH_COMMANDS + .filter((item) => { + const cmd = item.cmd.toLowerCase(); + const desc = item.desc.toLowerCase(); + return !normalized || cmd.includes(normalized) || desc.includes(normalized); + }) + .sort((a, b) => (b.cmd === `/${normalized}` ? 1 : 0) - (a.cmd === `/${normalized}` ? 1 : 0)); + return filtered.map((item) => ({ + kind: 'command', + name: item.cmd, + label: item.cmd, + description: item.desc, + insertion: `${item.cmd} `, + appendSpace: false, + })); + } + + function findActiveComposerToken() { + if (!msgInput) return null; + const value = msgInput.value || ''; + const cursor = typeof msgInput.selectionStart === 'number' ? msgInput.selectionStart : value.length; + const before = value.slice(0, cursor); + + if (!before.includes('\n') && value.startsWith('/') && cursor > 0) { + const nextWhitespace = value.search(/\s/); + const end = nextWhitespace >= 0 ? Math.min(cursor, nextWhitespace) : cursor; + if (cursor <= end || nextWhitespace < 0) { + return { trigger: '/', query: value.slice(1, cursor), start: 0, end: cursor }; + } + } + + const lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1; + const line = before.slice(lineStart); + const match = line.match(/(^|\s)([@$])([^\s]*)$/); + if (!match) return null; + const prefixLength = match[1] ? match[1].length : 0; + const start = lineStart + match.index + prefixLength; + return { + trigger: match[2], + query: match[3] || '', + start, + end: cursor, + }; + } + + function showCmdMenu(token, items) { + const safeItems = Array.isArray(items) ? items : []; + if (!token || safeItems.length === 0) { hideCmdMenu(); return; } + activeComposerToken = token; cmdMenuIndex = 0; - cmdMenu.innerHTML = filtered.map((c, i) => - `
- ${c.cmd} - ${c.desc} -
` - ).join(''); + cmdMenu.innerHTML = safeItems.map((item, i) => { + const kindLabel = item.kind === 'skill' + ? 'Skill' + : item.kind === 'prompt' + ? 'Prompt' + : item.kind === 'file' + ? (item.itemType === 'directory' ? 'Dir' : 'File') + : 'Cmd'; + return `
+ ${kindLabel} + + ${escapeHtml(item.label || item.name || item.insertion || '')} + ${escapeHtml(item.description || item.title || '')} + +
`; + }).join(''); + cmdMenu._items = safeItems; cmdMenu.hidden = false; - // Click handlers - cmdMenu.querySelectorAll('.cmd-item').forEach(el => { + cmdMenu.querySelectorAll('.cmd-item').forEach((el) => { el.addEventListener('click', () => { - const cmd = el.dataset.cmd; - if (cmd === '/model') { - hideCmdMenu(); - msgInput.value = ''; - showModelPicker(); - return; - } - if (cmd === '/mode') { - hideCmdMenu(); - msgInput.value = ''; - showModePicker(); - return; - } - msgInput.value = cmd + ' '; - hideCmdMenu(); - msgInput.focus(); + const index = Number.parseInt(el.dataset.index || '-1', 10); + selectComposerItemByIndex(index); }); }); } + function requestComposerSuggestions() { + const token = findActiveComposerToken(); + if (!token || noteMode) { + hideCmdMenu(); + return; + } + + if (token.trigger === '/') { + showCmdMenu(token, getLocalSlashSuggestions(token.query)); + return; + } + + clearTimeout(composerSuggestionTimer); + composerSuggestionTimer = setTimeout(() => { + const liveToken = findActiveComposerToken(); + if (!liveToken || liveToken.trigger !== token.trigger || liveToken.start !== token.start) { + hideCmdMenu(); + return; + } + const requestId = `composer-${Date.now()}-${++composerRequestSeq}`; + latestComposerRequestId = requestId; + activeComposerToken = liveToken; + send({ + type: 'composer_suggestions', + requestId, + trigger: liveToken.trigger, + query: liveToken.query, + sessionId: currentSessionId, + agent: currentAgent, + }); + }, COMPOSER_SUGGESTION_DEBOUNCE); + } + + function handleComposerSuggestions(msg) { + if (!msg || msg.requestId !== latestComposerRequestId) return; + const token = findActiveComposerToken(); + if (!token || token.trigger !== msg.trigger) { + hideCmdMenu(); + return; + } + showCmdMenu(token, msg.items || []); + } + function hideCmdMenu() { cmdMenu.hidden = true; cmdMenuIndex = -1; + cmdMenu._items = []; + activeComposerToken = null; } function navigateCmdMenu(direction) { @@ -4147,12 +4542,16 @@ items[cmdMenuIndex]?.classList.remove('active'); cmdMenuIndex = (cmdMenuIndex + direction + items.length) % items.length; items[cmdMenuIndex]?.classList.add('active'); + items[cmdMenuIndex]?.scrollIntoView({ block: 'nearest' }); } - function selectCmdMenuItem() { - const items = cmdMenu.querySelectorAll('.cmd-item'); - if (cmdMenuIndex >= 0 && items[cmdMenuIndex]) { - const cmd = items[cmdMenuIndex].dataset.cmd; + function selectComposerItemByIndex(index) { + const items = Array.isArray(cmdMenu._items) ? cmdMenu._items : []; + const item = items[index]; + if (!item) return; + + if (item.kind === 'command') { + const cmd = item.name || item.label || ''; if (cmd === '/model') { hideCmdMenu(); msgInput.value = ''; @@ -4165,10 +4564,30 @@ showModePicker(); return; } - msgInput.value = cmd + ' '; + msgInput.value = item.insertion || `${cmd} `; hideCmdMenu(); msgInput.focus(); + autoResize(); + return; } + + const token = activeComposerToken || findActiveComposerToken(); + if (!token) return; + const value = msgInput.value || ''; + const insertion = String(item.insertion || item.label || item.name || ''); + const appendSpace = item.appendSpace !== false; + const suffix = appendSpace ? ' ' : ''; + msgInput.value = value.slice(0, token.start) + insertion + suffix + value.slice(token.end); + const nextCursor = token.start + insertion.length + suffix.length; + msgInput.setSelectionRange(nextCursor, nextCursor); + hideCmdMenu(); + msgInput.focus(); + autoResize(); + if (!appendSpace) requestComposerSuggestions(); + } + + function selectCmdMenuItem() { + if (cmdMenuIndex >= 0) selectComposerItemByIndex(cmdMenuIndex); } // --- Option Picker (generic) --- @@ -4232,10 +4651,10 @@ } function showModelPicker() { - if (currentAgent === 'codex') { + if (isCodexLikeAgent(currentAgent)) { const current = _splitCodexThinkingModel(currentModel || ''); const baseOptions = getCodexBaseModelOptions(); - showOptionPicker('选择 Codex 模型', baseOptions, current.base || '', (baseValue) => { + showOptionPicker(`选择 ${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'} 模型`, baseOptions, current.base || '', (baseValue) => { const base = String(baseValue || '').trim(); const thinkingOptions = [ { value: '', label: '无 (默认)', desc: '不附加 (low/medium/high/xhigh) 后缀' }, @@ -4296,10 +4715,29 @@ return; } - if ((!text && pendingAttachments.length === 0) || isGenerating || isBlockingSessionLoad()) return; + const runtimeInsert = isGenerating && isCodexAppAgent(currentAgent); + if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return; + if (isGenerating && !runtimeInsert) return; hideCmdMenu(); hideOptionPicker(); + if (runtimeInsert) { + if (pendingAttachments.length > 0) { + appendError('Codex App 运行中插入暂不支持图片附件,请先移除图片。'); + return; + } + if (text.startsWith('/')) { + appendError('Codex App 运行中暂不支持 slash 指令插入。'); + return; + } + messagesDiv.appendChild(createMsgElement('user', text, [])); + scrollToBottom(); + send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); + msgInput.value = ''; + autoResize(); + return; + } + // Slash commands: don't show as user bubble if (text.startsWith('/')) { if (pendingAttachments.length > 0) { @@ -4397,7 +4835,9 @@ }); importSessionBtn.addEventListener('click', () => { newChatDropdown.hidden = true; - if (currentAgent === 'codex') { + if (isCodexAppAgent(currentAgent)) { + appendError('Codex App 模式暂不支持导入本地会话。'); + } else if (currentAgent === 'codex') { showImportCodexSessionModal(); } else { showImportSessionModal(); @@ -4461,13 +4901,7 @@ msgInput.addEventListener('input', () => { autoResize(); - const val = msgInput.value; - // Show slash command menu - if (!noteMode && val.startsWith('/') && !val.includes('\n')) { - showCmdMenu(val); - } else { - hideCmdMenu(); - } + requestComposerSuggestions(); }); msgInput.addEventListener('keydown', (e) => { @@ -5116,7 +5550,7 @@ } function showSettingsPanel() { - if (currentAgent === 'codex') { + if (isCodexLikeAgent(currentAgent)) { showCodexSettingsPanel(); return; } @@ -6085,7 +6519,7 @@ // Register Service Worker for mobile push notifications if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js').catch(() => {}); + navigator.serviceWorker.register(`/sw.js?v=${ASSET_VERSION}`).catch(() => {}); } // Restore remembered password diff --git a/public/index.html b/public/index.html index 1f93970..765f8f9 100644 --- a/public/index.html +++ b/public/index.html @@ -12,7 +12,7 @@ document.documentElement.dataset.theme = theme; })(); - + @@ -65,6 +65,7 @@ @@ -94,6 +95,7 @@
+