diff --git a/AGENTS.md b/AGENTS.md index 30e18ac..846c88d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,3 +158,37 @@ home-cc-web - 顶层是否没有重复 `effort` - `ccweb` 能力是否走 `mcpToolCall`,而不是再次退回 `dynamicToolCall` - mock / regression 中是否覆盖了上述断言 + +## Composer 快捷指令设计基线 + +cc-web 的输入框快捷指令是“本轮消息装饰器 / mention”系统,不是 Codex 原生工具面板,也不是手动填写 MCP tool 参数的执行面板。后续维护 `/`、`$`、`@` 时默认遵守以下语义,避免再次出现 `ccweb_prompt_user` 这类“真实可调用但快捷入口消失或语义跑偏”的问题。 + +### 1. 三类触发符的职责 + +- `/`:展示 cc-web 命令和当前会话可用的 MCP server/tool mention。选择 MCP tool 后,应插入类似 `mcp:ccweb/ccweb_prompt_user` 的标记,表示“本轮优先/明确使用这个 MCP”。真正的 tool 参数由模型在 MCP tool call 时生成。 +- `$`:展示 Codex skills。选择后插入 skill mention,语义同样是给本轮消息增加上下文/偏好,不应直接执行 skill 内声明的 MCP。 +- `@`:展示文件/目录和 `.codex/prompts` prompt 模板。不要把 MCP 塞进 `@`,否则会破坏“选文件/选 prompt”的用户预期。 + +### 2. MCP 候选显示原则 + +- `/` 中的 MCP 候选必须来自当前会话可运行的配置源: + - 内置 `ccweb` MCP:来自线程级 `mcp_servers.ccweb` + - 项目 MCP:来自当前会话 `cwd` 下可用的项目级 Codex MCP 配置 +- 不要从历史消息、运行态 tool 名称、skill 的 `agents/*.yaml` 依赖声明里反推出“可用 MCP”。这些只能作为元数据,不代表当前 runtime 真能调用。 +- 如果同一个 MCP server/tool 已经能被当前会话注入并调用,不要在 composer 层对个别工具做硬编码过滤。`ccweb_prompt_user` 这类需要结构化参数的工具,也应作为普通 MCP mention 显示,由模型生成参数。 + +### 3. MCP mention 不是参数构造 UI + +- 选择 `mcp:server/tool` 后,默认只插入 mention 文本,不打开自定义参数表单,也不直接调用 `/api/internal/mcp`。 +- 不要为某个 MCP tool 单独新增 `composer_mcp_tool_submit`、`tool_form`、`open_argument_form` 这类前端直调路径,除非产品明确新增“手动执行 MCP 工具”的独立能力。 +- 如果未来确实要做“手动执行工具”功能,应和 `/` mention 分开设计,不能混进快捷补全语义里。 + +### 4. 回归覆盖要求 + +涉及 composer 快捷指令时,至少补这些断言: + +- `/` 下应能看到当前会话可用的 MCP server/tool,包括 `mcp:ccweb/ccweb_prompt_user`。 +- `mcp:ccweb/ccweb_prompt_user` 应作为普通 `itemType: 'tool'` 候选插入 mention 文本,而不是参数表单入口。 +- `$` 不展示 MCP tool;`@` 不展示 MCP tool。 +- 不从运行态工具名或历史 `mcp:*` 文本反推 MCP 候选。 +- Codex App 侧仍要另行验证 `thread/start.config.mcp_servers.*` 注入和真实 MCP tool call 链路,不能只测 UI 候选。 diff --git a/Fix running session streaming bubble restore TO DO list.csv b/Fix running session streaming bubble restore TO DO list.csv deleted file mode 100644 index 3d1208b..0000000 --- a/Fix running session streaming bubble restore TO DO list.csv +++ /dev/null @@ -1,6 +0,0 @@ -status,step -DONE,定位会话切换与运行中恢复逻辑 -DONE,核对本次改动是否影响该链路 -DONE,查看相关运行日志和当前会话状态 -DONE,修复缺失 streaming 气泡的恢复逻辑 -IN_PROGRESS,验证并汇总影响范围 diff --git a/config/cross-conversation-replies.json b/config/cross-conversation-replies.json index b48f43f..6f3e556 100644 --- a/config/cross-conversation-replies.json +++ b/config/cross-conversation-replies.json @@ -1,5 +1,5 @@ { "version": 1, - "updatedAt": "2026-06-21T15:06:34.507Z", + "updatedAt": "2026-06-26T15:00:37.081Z", "replies": [] } \ No newline at end of file diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 95de5b3..6ee74df 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 9b0c0cb..51ea4ec 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -10,6 +10,7 @@ function createAgentRuntime(deps) { loadCodexConfig, prepareCodexCustomRuntime, ccwebMcpServerArg, + ccwebMcpServerArgs, internalMcpUrl, internalMcpToken, nodePath, @@ -63,10 +64,13 @@ function createAgentRuntime(deps) { function appendCcwebMcpConfig(args, mcpEnv) { if (!mcpEnv) return; const envVars = Object.keys(mcpEnv); + const serverArgs = Array.isArray(ccwebMcpServerArgs) && ccwebMcpServerArgs.length > 0 + ? ccwebMcpServerArgs + : [ccwebMcpServerArg]; args.push( '-c', 'mcp_servers.ccweb.type="stdio"', '-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`, - '-c', `mcp_servers.ccweb.args=${tomlStringArray([ccwebMcpServerArg])}`, + '-c', `mcp_servers.ccweb.args=${tomlStringArray(serverArgs)}`, '-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`, '-c', 'mcp_servers.ccweb.startup_timeout_sec=10', '-c', 'mcp_servers.ccweb.tool_timeout_sec=60' diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index f451ce0..eea3c79 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -136,6 +136,109 @@ const TOOLS = [ additionalProperties: false, }, }, + { + name: 'ccweb_prompt_user', + description: '在当前来源 ccweb 对话前台渲染一个多问题表单。工具会立即返回,不等待用户;用户提交后,ccweb 会把问题、选择和答案作为一条普通用户消息发回当前对话。', + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + maxLength: 160, + description: '表单标题。', + }, + description: { + type: 'string', + maxLength: 2000, + description: '可选。表单整体说明。', + }, + questions: { + type: 'array', + minItems: 1, + maxItems: 10, + description: '问题数组。每个问题都会渲染候选项和可编辑答案输入区。', + items: { + type: 'object', + properties: { + id: { + type: 'string', + maxLength: 80, + description: '问题 ID。建议稳定唯一;缺失时 ccweb 会按顺序生成。', + }, + title: { + type: 'string', + maxLength: 160, + description: '问题标题。', + }, + question: { + type: 'string', + maxLength: 4000, + description: '问题正文。', + }, + required: { + type: 'boolean', + description: '是否必答,默认 true。', + }, + selectionMode: { + type: 'string', + enum: ['single', 'multi', 'none'], + description: '候选项选择模式,默认 single;none 表示只输入答案。', + }, + answerPlaceholder: { + type: 'string', + maxLength: 240, + description: '答案输入区占位文案。', + }, + defaultAnswer: { + type: 'string', + maxLength: 4000, + description: '答案输入区默认值。', + }, + options: { + type: 'array', + maxItems: 8, + description: '候选/推荐选项。点击选项会把 answerText 写入该问题的答案输入区。', + items: { + type: 'object', + properties: { + id: { + type: 'string', + maxLength: 80, + description: '选项 ID。建议稳定唯一;缺失时 ccweb 会按顺序生成。', + }, + label: { + type: 'string', + maxLength: 240, + description: '选项展示文本。', + }, + description: { + type: 'string', + maxLength: 1000, + description: '选项说明。', + }, + answerText: { + type: 'string', + maxLength: 4000, + description: '点击该选项后预填到答案输入区的文本。', + }, + recommended: { + type: 'boolean', + description: '是否推荐选项。', + }, + }, + additionalProperties: false, + }, + }, + }, + required: ['question'], + additionalProperties: false, + }, + }, + }, + required: ['questions'], + additionalProperties: false, + }, + }, ]; function writeMessage(message) { diff --git a/public/app.js b/public/app.js index a37df2c..bff58a9 100644 --- a/public/app.js +++ b/public/app.js @@ -2,11 +2,12 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260625-branch-bubble'; + const ASSET_VERSION = '20260626-ccweb-prompt-compact-ui'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; const DIVIDER_TIME_STORAGE_KEY = 'cc-web-show-divider-time'; + const CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY = 'cc-web-ccweb-prompt-view-mode'; const PROJECT_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-projects'; const CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-cross-replies'; const CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT = 500; @@ -210,6 +211,7 @@ let queuedMessageSeq = 0; let queuedMessageDrainTimer = null; let isReloadingMcp = false; + const mcpStartupToastKeys = new Map(); let sessionSearchQuery = ''; const collapsedProjectKeys = (() => { try { @@ -263,6 +265,8 @@ const chatCwd = $('#chat-cwd'); const userOutlineBtn = $('#user-outline-btn'); const userOutlinePanel = $('#user-outline-panel'); + const ccwebPromptOutlineBtn = $('#ccweb-prompt-outline-btn'); + const ccwebPromptOutlinePanel = $('#ccweb-prompt-outline-panel'); const reloadMcpBtn = $('#reload-mcp-btn'); const costDisplay = $('#cost-display'); const attachmentTray = $('#attachment-tray'); @@ -525,6 +529,7 @@ pendingNotesTray.innerHTML = ''; const notes = getCurrentNotes(false); const queuedMessages = getCurrentQueue(false); + renderPendingCcwebPrompts({ scroll: false, updateScrollbar: false }); if ((!notes || notes.length === 0) && (!queuedMessages || queuedMessages.length === 0)) { pendingNotesTray.hidden = true; if (options.updateScrollbar !== false) updateScrollbar(); @@ -544,6 +549,140 @@ if (options.updateScrollbar !== false) updateScrollbar(); } + function collectCurrentPendingCcwebPrompts() { + if (!currentSessionId) return []; + const prompts = []; + const seen = new Set(); + const entry = sessionCache.get(currentSessionId); + const messages = Array.isArray(entry?.snapshot?.messages) ? entry.snapshot.messages : []; + messages.forEach((message) => { + const prompt = message?.ccwebPrompt; + if (!prompt?.id || seen.has(prompt.id) || (prompt.status || 'pending') !== 'pending') return; + seen.add(prompt.id); + prompts.push(prompt); + }); + messagesDiv?.querySelectorAll?.('.ccweb-prompt-card[data-status="pending"]').forEach((card) => { + const promptId = card.dataset.promptId || ''; + if (!promptId || seen.has(promptId)) return; + seen.add(promptId); + prompts.push({ + id: promptId, + title: card.querySelector('.ccweb-prompt-title')?.textContent || '需要用户确认', + questions: Array.from(card.querySelectorAll('.ccweb-prompt-question')).map((questionEl) => ({ + id: questionEl.dataset.questionId || '', + })), + }); + }); + return prompts; + } + + function scrollToCcwebPrompt(promptId) { + if (!promptId || !messagesDiv) return false; + const card = messagesDiv.querySelector(`.ccweb-prompt-card[data-prompt-id="${cssEscape(promptId)}"]`); + if (!card) { + showToast('未找到待提交表单', '可能在未加载的历史消息中'); + return false; + } + const target = card.closest('.msg') || card; + const containerRect = messagesDiv.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const targetTop = messagesDiv.scrollTop + targetRect.top - containerRect.top - 72; + messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }); + card.classList.remove('ccweb-prompt-focus'); + requestAnimationFrame(() => { + card.classList.add('ccweb-prompt-focus'); + window.setTimeout(() => card.classList.remove('ccweb-prompt-focus'), 1400); + }); + updateScrollbar(); + return true; + } + + function dismissCcwebPrompt(promptId) { + if (!promptId || !currentSessionId) return; + send({ + type: 'ccweb_prompt_user_dismiss', + sessionId: currentSessionId, + promptId, + }); + } + + function createPendingCcwebPromptElement(prompt) { + const item = document.createElement('div'); + item.className = 'pending-ccweb-prompt'; + item.dataset.promptId = prompt.id || ''; + + const badge = document.createElement('span'); + badge.className = 'pending-ccweb-prompt-badge'; + badge.setAttribute('aria-label', '未提交'); + + const title = document.createElement('div'); + title.className = 'pending-ccweb-prompt-title'; + const questionCount = Array.isArray(prompt.questions) ? prompt.questions.length : 0; + title.textContent = `${prompt.title || '需要用户确认'} · ${questionCount || 1} 题`; + + const action = document.createElement('button'); + action.type = 'button'; + action.className = 'pending-ccweb-prompt-action'; + action.textContent = '定位'; + action.addEventListener('click', () => { + closeCcwebPromptOutlinePanel(); + scrollToCcwebPrompt(prompt.id); + }); + + const dismiss = document.createElement('button'); + dismiss.type = 'button'; + dismiss.className = 'pending-ccweb-prompt-dismiss'; + dismiss.textContent = '忽略'; + dismiss.title = '忽略并删除这个未提交表单'; + dismiss.addEventListener('click', () => { + dismiss.disabled = true; + dismiss.textContent = '删除中'; + dismissCcwebPrompt(prompt.id); + }); + + item.append(badge, title, action, dismiss); + return item; + } + + function renderPendingCcwebPrompts(options = {}) { + if (!ccwebPromptOutlineBtn || !ccwebPromptOutlinePanel) return; + const prompts = collectCurrentPendingCcwebPrompts(); + const anchor = ccwebPromptOutlineBtn.closest('.ccweb-prompt-outline-anchor'); + if (prompts.length === 0) { + if (anchor) anchor.hidden = true; + closeCcwebPromptOutlinePanel(); + delete ccwebPromptOutlineBtn.dataset.count; + ccwebPromptOutlinePanel.innerHTML = ''; + if (options.updateScrollbar !== false) updateScrollbar(); + return; + } + if (anchor) anchor.hidden = false; + ccwebPromptOutlineBtn.disabled = false; + ccwebPromptOutlineBtn.dataset.count = String(prompts.length); + ccwebPromptOutlinePanel.replaceChildren(...prompts.map((prompt) => createPendingCcwebPromptElement(prompt))); + if (options.updateScrollbar !== false) updateScrollbar(); + } + + function closeCcwebPromptOutlinePanel() { + if (!ccwebPromptOutlinePanel || !ccwebPromptOutlineBtn) return; + ccwebPromptOutlinePanel.hidden = true; + ccwebPromptOutlineBtn.setAttribute('aria-expanded', 'false'); + } + + function toggleCcwebPromptOutlinePanel() { + if (!ccwebPromptOutlinePanel || !ccwebPromptOutlineBtn) return; + if (ccwebPromptOutlinePanel.hidden) { + renderPendingCcwebPrompts({ scroll: false, updateScrollbar: false }); + const anchor = ccwebPromptOutlineBtn.closest('.ccweb-prompt-outline-anchor'); + if (anchor?.hidden || !ccwebPromptOutlinePanel.children.length) return; + closeUserOutlinePanel(); + ccwebPromptOutlinePanel.hidden = false; + ccwebPromptOutlineBtn.setAttribute('aria-expanded', 'true'); + } else { + closeCcwebPromptOutlinePanel(); + } + } + function findPendingNote(noteId) { const key = getCurrentNoteKey(); const notes = getNotesForKey(key, false); @@ -1246,6 +1385,7 @@ if (!userOutlinePanel || !userOutlineBtn) return; if (userOutlinePanel.hidden) { updateUserOutlinePanel(); + closeCcwebPromptOutlinePanel(); userOutlinePanel.hidden = false; userOutlineBtn.setAttribute('aria-expanded', 'true'); } else { @@ -1843,15 +1983,65 @@ reloadMcpBtn.setAttribute('aria-busy', isReloadingMcp ? 'true' : 'false'); } + function normalizeMcpStartupStatusPayload(payload) { + if (!payload || typeof payload !== 'object') return null; + if (payload.mcpStatus && typeof payload.mcpStatus === 'object') return payload.mcpStatus; + if (payload.status && typeof payload.status === 'object') return payload.status; + return payload; + } + + function mcpStartupStatusToastText(status) { + const summary = normalizeMcpStartupStatusPayload(status); + if (!summary) return '已请求重载,等待状态'; + const server = String(summary.server || summary.name || 'ccweb').trim() || 'ccweb'; + const state = String(summary.status || 'unknown').trim().toLowerCase(); + const message = String(summary.message || '').trim(); + if (state === 'ready') return `${server} MCP 已启动`; + if (state === 'failed') return `${server} MCP 启动失败${message ? `:${message}` : ''}`; + if (state === 'cancelled' || state === 'canceled') return `${server} MCP 启动已取消${message ? `:${message}` : ''}`; + if (state === 'starting') return `${server} MCP 正在启动`; + if (state === 'pending' || state === 'unknown') return '已请求重载,等待状态'; + return `${server} MCP 状态:${state}`; + } + + function rememberMcpStartupStatus(sessionId, status) { + const summary = normalizeMcpStartupStatusPayload(status); + if (!sessionId || !summary) return; + updateCachedSession(sessionId, (snapshot) => { + snapshot.codexAppMcpStartupStatus = deepClone(summary); + }); + } + + function showMcpStartupStatusToast(status, sessionId = currentSessionId, options = {}) { + const summary = normalizeMcpStartupStatusPayload(status); + const text = mcpStartupStatusToastText(summary); + const server = String(summary?.server || summary?.name || 'ccweb').trim() || 'ccweb'; + const state = String(summary?.status || 'pending').trim().toLowerCase() || 'pending'; + if (state === 'ready' && !options.notifyReady) return; + if ((state === 'starting' || state === 'pending' || state === 'unknown') && !options.notifyPending) return; + const stamp = state === 'failed' || state === 'cancelled' || state === 'canceled' + ? String(summary?.message || text || '') + : ''; + const cacheKey = sessionId || currentSessionId || 'global'; + const nextKey = `${server}|${state}|${stamp}`; + if (mcpStartupToastKeys.get(cacheKey) === nextKey) return; + mcpStartupToastKeys.set(cacheKey, nextKey); + showToast(text, sessionId); + } + async function reloadCurrentMcpServers() { if (!currentSessionId || !isCodexAppAgent(currentAgent) || isReloadingMcp) return; isReloadingMcp = true; updateReloadMcpButtonUI(); try { - await fetchAuthJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/reload-mcp`, { + const data = await fetchAuthJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/reload-mcp`, { method: 'POST', }); - showToast('已请求重载 MCP'); + rememberMcpStartupStatus(currentSessionId, data.mcpStatus); + showMcpStartupStatusToast(data.mcpStatus || { status: 'pending' }, currentSessionId, { + notifyReady: true, + notifyPending: true, + }); } catch (err) { showToast(err?.message || '重载 MCP 失败'); } finally { @@ -2102,6 +2292,432 @@ panel.querySelector('input, button')?.focus(); } + function ccwebPromptStatusLabel(status) { + if (status === 'submitted') return '已提交'; + if (status === 'cancelled') return '已取消'; + return '待回答'; + } + + function ccwebPromptRecommendedOption(question) { + const options = Array.isArray(question?.options) ? question.options : []; + return options.find((option) => option?.recommended) || options[0] || null; + } + + function getCcwebPromptViewMode() { + return localStorage.getItem(CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY) === 'tabs' ? 'tabs' : 'cards'; + } + + function setCcwebPromptActiveQuestion(card, index) { + if (!card) return; + const questions = Array.from(card.querySelectorAll('.ccweb-prompt-question')); + if (questions.length === 0) return; + const activeIndex = Math.min(Math.max(Number(index) || 0, 0), questions.length - 1); + card.dataset.activeQuestionIndex = String(activeIndex); + questions.forEach((questionEl, questionIndex) => { + const isActive = questionIndex === activeIndex; + questionEl.classList.toggle('is-active', isActive); + questionEl.setAttribute('aria-hidden', isActive ? 'false' : 'true'); + }); + card.querySelectorAll('.ccweb-prompt-tab').forEach((tab, tabIndex) => { + const isActive = tabIndex === activeIndex; + tab.classList.toggle('is-active', isActive); + tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); + tab.tabIndex = isActive ? 0 : -1; + }); + const counter = card.querySelector('.ccweb-prompt-tab-counter'); + if (counter) counter.textContent = `问题 ${activeIndex + 1} / ${questions.length}`; + const prev = card.querySelector('[data-ccweb-prompt-prev]'); + const next = card.querySelector('[data-ccweb-prompt-next]'); + if (prev) prev.disabled = activeIndex <= 0; + if (next) next.disabled = activeIndex >= questions.length - 1; + } + + function setCcwebPromptViewMode(card, mode) { + if (!card) return; + const normalized = mode === 'tabs' ? 'tabs' : 'cards'; + card.dataset.viewMode = normalized; + card.querySelectorAll('.ccweb-prompt-view-btn').forEach((button) => { + const isActive = button.dataset.viewMode === normalized; + button.classList.toggle('is-active', isActive); + button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + if (normalized === 'tabs') { + setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0)); + } else { + card.querySelectorAll('.ccweb-prompt-question').forEach((questionEl) => { + questionEl.setAttribute('aria-hidden', 'false'); + }); + } + } + + function createCcwebPromptViewControls(card, questions) { + const switcher = document.createElement('div'); + switcher.className = 'ccweb-prompt-view-switcher'; + switcher.setAttribute('aria-label', '表单显示方式'); + [ + { mode: 'cards', label: '▦', title: '卡片视图' }, + { mode: 'tabs', label: '▤', title: '页签视图' }, + ].forEach((item) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'ccweb-prompt-view-btn'; + button.dataset.viewMode = item.mode; + button.textContent = item.label; + button.title = item.title; + button.setAttribute('aria-label', item.title); + button.addEventListener('click', () => { + localStorage.setItem(CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY, item.mode); + setCcwebPromptViewMode(card, item.mode); + }); + switcher.appendChild(button); + }); + return switcher; + } + + function createCcwebPromptTabs(card, questions) { + const controls = document.createElement('div'); + controls.className = 'ccweb-prompt-view-controls'; + + const tabs = document.createElement('div'); + tabs.className = 'ccweb-prompt-tabs'; + tabs.setAttribute('role', 'tablist'); + questions.forEach((question, index) => { + const tab = document.createElement('button'); + tab.type = 'button'; + tab.className = 'ccweb-prompt-tab'; + tab.setAttribute('role', 'tab'); + tab.textContent = question.title || `问题 ${index + 1}`; + tab.addEventListener('click', () => setCcwebPromptActiveQuestion(card, index)); + tabs.appendChild(tab); + }); + + controls.append(tabs); + return controls; + } + + function createCcwebPromptTabNav(card, questions) { + const nav = document.createElement('div'); + nav.className = 'ccweb-prompt-tab-nav'; + if (!Array.isArray(questions) || questions.length <= 1) return nav; + const prev = document.createElement('button'); + prev.type = 'button'; + prev.className = 'ccweb-prompt-tab-nav-btn'; + prev.dataset.ccwebPromptPrev = '1'; + prev.textContent = '上一个'; + prev.addEventListener('click', () => setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0) - 1)); + const counter = document.createElement('span'); + counter.className = 'ccweb-prompt-tab-counter'; + const next = document.createElement('button'); + next.type = 'button'; + next.className = 'ccweb-prompt-tab-nav-btn'; + next.dataset.ccwebPromptNext = '1'; + next.textContent = '下一个'; + next.addEventListener('click', () => setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0) + 1)); + nav.append(prev, counter, next); + return nav; + } + + function setCcwebPromptError(card, message) { + const error = card.querySelector('.ccweb-prompt-error'); + if (!error) return; + error.textContent = message || ''; + error.hidden = !message; + } + + function updateCcwebPromptAnswerFromSelection(questionEl, question) { + const textarea = questionEl.querySelector('.ccweb-prompt-answer'); + if (!textarea) return; + const selectedIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected')) + .map((button) => button.dataset.optionId || '') + .filter(Boolean); + const selectedOptions = (question.options || []).filter((option) => selectedIds.includes(option.id)); + const answerText = selectedOptions.map((option) => option.answerText || option.label || '').filter(Boolean).join('\n'); + if (answerText) textarea.value = answerText; + } + + function selectCcwebPromptOption(questionEl, question, optionId) { + const buttons = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option')); + if (question.selectionMode === 'multi') { + buttons.forEach((button) => { + if (button.dataset.optionId === optionId) button.classList.toggle('is-selected'); + }); + } else { + buttons.forEach((button) => { + button.classList.toggle('is-selected', button.dataset.optionId === optionId); + }); + } + updateCcwebPromptAnswerFromSelection(questionEl, question); + } + + function createCcwebPromptQuestionElement(question, index, prompt) { + const questionEl = document.createElement('section'); + questionEl.className = 'ccweb-prompt-question'; + questionEl.dataset.questionId = question.id || `question_${index + 1}`; + + const head = document.createElement('div'); + head.className = 'ccweb-prompt-question-head'; + const title = document.createElement('div'); + title.className = 'ccweb-prompt-question-title'; + title.textContent = question.title || `问题 ${index + 1}`; + head.appendChild(title); + if (question.required !== false && prompt.status !== 'submitted') { + const required = document.createElement('span'); + required.className = 'ccweb-prompt-required'; + required.textContent = '必答'; + head.appendChild(required); + } + questionEl.appendChild(head); + + if (question.question) { + const body = document.createElement('div'); + body.className = 'ccweb-prompt-question-body'; + body.textContent = question.question; + questionEl.appendChild(body); + } + + if (prompt.status === 'submitted') { + const answer = prompt.answers?.[question.id] || {}; + if (Array.isArray(answer.selectedOptionLabels) && answer.selectedOptionLabels.length > 0) { + const selected = document.createElement('div'); + selected.className = 'ccweb-prompt-selected-readonly'; + selected.textContent = `选择:${answer.selectedOptionLabels.join(',')}`; + questionEl.appendChild(selected); + } + const answerText = document.createElement('div'); + answerText.className = 'ccweb-prompt-answer-readonly'; + answerText.textContent = answer.answerText || '(未填写答案)'; + questionEl.appendChild(answerText); + return questionEl; + } + + const options = Array.isArray(question.options) ? question.options : []; + if (options.length > 0 && question.selectionMode !== 'none') { + const optionList = document.createElement('div'); + optionList.className = 'ccweb-prompt-options'; + options.forEach((option) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'ccweb-prompt-option'; + button.dataset.optionId = option.id || ''; + const label = document.createElement('span'); + label.className = 'ccweb-prompt-option-label'; + label.textContent = option.label || option.id || '选项'; + button.appendChild(label); + if (option.recommended) { + const badge = document.createElement('span'); + badge.className = 'ccweb-prompt-option-badge'; + badge.textContent = '推荐'; + button.appendChild(badge); + } + if (option.description) { + const desc = document.createElement('span'); + desc.className = 'ccweb-prompt-option-desc'; + desc.textContent = option.description; + button.appendChild(desc); + } + button.addEventListener('click', () => selectCcwebPromptOption(questionEl, question, option.id)); + optionList.appendChild(button); + }); + questionEl.appendChild(optionList); + } + + const answer = document.createElement('textarea'); + answer.className = 'ccweb-prompt-answer'; + answer.rows = 4; + answer.placeholder = question.answerPlaceholder || '填写你的答案...'; + answer.value = question.defaultAnswer || ''; + questionEl.appendChild(answer); + + const recommended = ccwebPromptRecommendedOption(question); + if (recommended?.recommended) { + selectCcwebPromptOption(questionEl, question, recommended.id); + } + + return questionEl; + } + + function collectCcwebPromptAnswers(card, prompt) { + const answers = {}; + for (const question of prompt.questions || []) { + const escapedId = cssEscape(question.id || ''); + const questionEl = card.querySelector(`.ccweb-prompt-question[data-question-id="${escapedId}"]`); + if (!questionEl) continue; + const selectedOptionIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected')) + .map((button) => button.dataset.optionId || '') + .filter(Boolean); + const answerText = String(questionEl.querySelector('.ccweb-prompt-answer')?.value || '').trim(); + if (question.required !== false && !answerText) { + return { ok: false, message: `请填写「${question.title || question.id}」的答案。` }; + } + answers[question.id] = { selectedOptionIds, answerText }; + } + return { ok: true, answers }; + } + + function createCcwebPromptElement(prompt, meta = {}) { + const card = document.createElement('section'); + const promptStatus = prompt?.status || 'pending'; + const questions = Array.isArray(prompt?.questions) ? prompt.questions : []; + card.className = 'ccweb-prompt-card'; + card.dataset.promptId = prompt?.id || ''; + card.dataset.status = promptStatus; + card.dataset.viewMode = 'cards'; + + const header = document.createElement('div'); + header.className = 'ccweb-prompt-header'; + const titleWrap = document.createElement('div'); + titleWrap.className = 'ccweb-prompt-title-wrap'; + const title = document.createElement('div'); + title.className = 'ccweb-prompt-title'; + title.textContent = prompt?.title || '需要用户确认'; + titleWrap.appendChild(title); + header.appendChild(titleWrap); + const headerActions = document.createElement('div'); + headerActions.className = 'ccweb-prompt-header-actions'; + const status = document.createElement('span'); + status.className = 'ccweb-prompt-status'; + status.textContent = promptStatus === 'pending' ? '●' : ccwebPromptStatusLabel(prompt?.status || 'pending'); + status.title = ccwebPromptStatusLabel(prompt?.status || 'pending'); + status.setAttribute('aria-label', ccwebPromptStatusLabel(prompt?.status || 'pending')); + headerActions.appendChild(status); + if (promptStatus === 'pending' && questions.length > 1) { + headerActions.appendChild(createCcwebPromptViewControls(card, questions)); + } + header.appendChild(headerActions); + card.appendChild(header); + + if (prompt?.description) { + const desc = document.createElement('div'); + desc.className = 'ccweb-prompt-desc'; + desc.textContent = prompt.description; + card.appendChild(desc); + } + + if (promptStatus === 'pending' && questions.length > 1) { + card.appendChild(createCcwebPromptTabs(card, questions)); + } + + const questionsWrap = document.createElement('div'); + questionsWrap.className = 'ccweb-prompt-questions'; + questions.forEach((question, index) => { + questionsWrap.appendChild(createCcwebPromptQuestionElement(question, index, prompt)); + }); + card.appendChild(questionsWrap); + + const error = document.createElement('div'); + error.className = 'ccweb-prompt-error'; + error.hidden = true; + card.appendChild(error); + + if ((prompt?.status || 'pending') === 'pending') { + const footer = document.createElement('div'); + footer.className = 'ccweb-prompt-footer'; + if (questions.length > 1) { + footer.appendChild(createCcwebPromptTabNav(card, questions)); + } + const footerActions = document.createElement('div'); + footerActions.className = 'ccweb-prompt-footer-actions'; + const fillRecommended = document.createElement('button'); + fillRecommended.type = 'button'; + fillRecommended.className = 'ccweb-prompt-secondary'; + fillRecommended.textContent = '填入推荐'; + fillRecommended.addEventListener('click', () => { + questions.forEach((question) => { + const option = ccwebPromptRecommendedOption(question); + if (!option) return; + const questionEl = card.querySelector(`.ccweb-prompt-question[data-question-id="${cssEscape(question.id || '')}"]`); + if (questionEl) selectCcwebPromptOption(questionEl, question, option.id); + }); + }); + footerActions.appendChild(fillRecommended); + + const submit = document.createElement('button'); + submit.type = 'button'; + submit.className = 'ccweb-prompt-submit'; + submit.textContent = '提交全部'; + submit.addEventListener('click', () => { + const collected = collectCcwebPromptAnswers(card, prompt); + if (!collected.ok) { + setCcwebPromptError(card, collected.message); + return; + } + setCcwebPromptError(card, ''); + submit.disabled = true; + submit.textContent = '提交中'; + send({ + type: 'ccweb_prompt_user_response', + sessionId: meta.sessionId || currentSessionId, + promptId: prompt.id, + answers: collected.answers, + }); + }); + footerActions.appendChild(submit); + footer.appendChild(footerActions); + card.appendChild(footer); + } + + if (promptStatus === 'pending' && questions.length > 1) { + setCcwebPromptActiveQuestion(card, 0); + setCcwebPromptViewMode(card, getCcwebPromptViewMode()); + } + + return card; + } + + function updateCcwebPromptMessageInSnapshot(snapshot, prompt) { + if (!snapshot || !Array.isArray(snapshot.messages) || !prompt?.id) return; + for (const message of snapshot.messages) { + if (message?.ccwebPrompt?.id === prompt.id) { + message.ccwebPrompt = deepClone(prompt); + } + } + } + + function removeCcwebPromptMessageFromSnapshot(snapshot, promptId) { + if (!snapshot || !Array.isArray(snapshot.messages) || !promptId) return; + snapshot.messages = snapshot.messages.filter((message) => message?.ccwebPrompt?.id !== promptId); + } + + function removeCcwebPromptMessageFromDom(promptId) { + if (!promptId) return 0; + let removed = 0; + document.querySelectorAll(`.ccweb-prompt-card[data-prompt-id="${cssEscape(promptId)}"]`).forEach((card) => { + const messageEl = card.closest('.msg'); + if (messageEl?.parentNode) { + messageEl.remove(); + } else { + card.remove(); + } + removed += 1; + }); + if (removed > 0) { + updateUserOutlinePanel(); + updateScrollbar(); + } + return removed; + } + + function applyCcwebPromptUserUpdate(msg) { + if (msg.sessionId && msg.prompt) { + updateCachedSession(msg.sessionId, (snapshot) => updateCcwebPromptMessageInSnapshot(snapshot, msg.prompt)); + } + if (msg.sessionId !== currentSessionId || !msg.prompt?.id) return; + document.querySelectorAll(`.ccweb-prompt-card[data-prompt-id="${cssEscape(msg.prompt.id)}"]`).forEach((card) => { + card.replaceWith(createCcwebPromptElement(msg.prompt, { sessionId: msg.sessionId })); + }); + renderPendingCcwebPrompts({ scroll: false }); + } + + function applyCcwebPromptUserRemove(msg) { + const promptId = msg.promptId || msg.prompt?.id || ''; + if (msg.sessionId && promptId) { + updateCachedSession(msg.sessionId, (snapshot) => removeCcwebPromptMessageFromSnapshot(snapshot, promptId)); + } + if (msg.sessionId !== currentSessionId || !promptId) return; + removeCcwebPromptMessageFromDom(promptId); + renderPendingCcwebPrompts({ scroll: false }); + } + function closeDirectoryPicker() { if (!directoryPickerState) return; const { overlay, escapeHandler } = directoryPickerState; @@ -3399,6 +4015,7 @@ function resetChatView(agent) { setCurrentAgent(agent); closeUserOutlinePanel(); + closeCcwebPromptOutlinePanel(); closeFileBrowser(); currentSessionId = null; loadedHistorySessionId = null; @@ -3460,6 +4077,7 @@ setCurrentSessionRunningState(snapshot.isRunning); setStatsDisplay(snapshot); closeUserOutlinePanel(); + closeCcwebPromptOutlinePanel(); currentCwd = snapshot.cwd || null; updateCwdBadge(); if (snapshot.mode && MODE_LABELS[snapshot.mode]) { @@ -3492,6 +4110,7 @@ const { preserveCurrent = true, loadLast = true } = options; setCurrentAgent(targetAgent); closeUserOutlinePanel(); + closeCcwebPromptOutlinePanel(); renderSessionList(); const currentMeta = currentSessionId ? getSessionMeta(currentSessionId) : null; @@ -3651,6 +4270,7 @@ send({ type: 'detach_view' }); } closeUserOutlinePanel(); + closeCcwebPromptOutlinePanel(); clearSessionLoading(); touchSessionCache(sessionId); applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true }); @@ -3660,6 +4280,7 @@ function openSession(sessionId, options = {}) { if (!sessionId) return; closeUserOutlinePanel(); + closeCcwebPromptOutlinePanel(); if (options.forceSync) { beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: true, label: options.label }); return; @@ -4355,6 +4976,7 @@ messagesDiv.appendChild(buildMsgElement(msg.message, messageIndex)); followOutputIfNeeded(shouldFollow); setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); + renderPendingCcwebPrompts({ scroll: false }); } renderSessionList(); break; @@ -4499,6 +5121,19 @@ applyCcwebMcpChildAgentUpdate(msg); break; + case 'ccweb_prompt_user_update': + applyCcwebPromptUserUpdate(msg); + break; + + case 'ccweb_prompt_user_remove': + applyCcwebPromptUserRemove(msg); + break; + + case 'mcp_startup_status': + rememberMcpStartupStatus(msg.sessionId, msg.mcpStatus || msg.status); + showMcpStartupStatusToast(msg.mcpStatus || msg.status, msg.sessionId); + break; + case 'mode_changed': if (msg.mode && MODE_LABELS[msg.mode]) { currentMode = msg.mode; @@ -5870,6 +6505,10 @@ function buildMsgElement(m, messageIndex = null) { const el = createMsgElement(m.role, m.content, m.attachments || [], m); + if (m.ccwebPrompt) { + const bubble = el.querySelector('.msg-bubble'); + if (bubble) bubble.appendChild(createCcwebPromptElement(m.ccwebPrompt, { sessionId: currentSessionId })); + } if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { const bubble = el.querySelector('.msg-bubble'); const toolMount = bubble.querySelector(':scope > .cross-conversation-reply-body') || bubble; @@ -7489,6 +8128,16 @@ }); } + if (ccwebPromptOutlineBtn && ccwebPromptOutlinePanel) { + ccwebPromptOutlineBtn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleCcwebPromptOutlinePanel(); + }); + ccwebPromptOutlinePanel.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + if (reloadMcpBtn) { reloadMcpBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -7551,6 +8200,11 @@ e.target !== userOutlineBtn) { closeUserOutlinePanel(); } + if (ccwebPromptOutlinePanel && !ccwebPromptOutlinePanel.hidden && + !ccwebPromptOutlinePanel.contains(e.target) && + e.target !== ccwebPromptOutlineBtn) { + closeCcwebPromptOutlinePanel(); + } }); sendBtn.addEventListener('click', sendMessage); if (queueSendBtn) { diff --git a/public/index.html b/public/index.html index 5108e27..6e17a21 100644 --- a/public/index.html +++ b/public/index.html @@ -112,6 +112,10 @@ + diff --git a/public/rag-for-pm-v2.html b/public/rag-for-pm-v2.html index f09388e..601e589 100644 --- a/public/rag-for-pm-v2.html +++ b/public/rag-for-pm-v2.html @@ -6,398 +6,476 @@ RAG 入门:原理、流程与使用
-
+
-
RAG Primer原理和使用
-

RAG · LLM · Retrieval · Prompt · Agent · MCP

-

RAG 入门:让 AI 先查资料再回答

-

从 LLM 的对话限制出发,理解 RAG 的建库、检索、排序、提示词和 Agent 扩展。

-
- RAG解释LLM解释上下文限制幻觉注意力顺序切片向量化排序语义检索提示词工程AGENTSMCP -
- -
开场不用讲算法,先讲主线:大模型会说话,但它不是企业知识库。因为它有上下文、幻觉、注意力和顺序方面的限制,所以需要 RAG 这套“先查资料,再组织答案”的机制。
- +
+
+
RAG · LLM · Prompt · Agent · MCP
+

RAG 入门:让 AI 先查资料 再回答

+

从 LLM 的限制讲起,串起知识库建设、语义检索、排序、提示词工程、Agent 和 MCP。

+
+
01
先懂限制上下文、幻觉、注意力
+
02
再懂流程建库、检索、生成
+
03
最后扩展Agent、MCP、工具
+
+
+
+
+
RAG
+
LLM理解和生成
+
知识库资料与来源
+
检索找相关片段
+
提示词约束回答
+
+
+
+
+
-
Roadmap学习路径
-

由浅入深

-

从“会生成”到“会查资料、会调用工具”

-
-
1

基础概念

  • RAG 是什么
  • LLM 是什么
-
-
2

LLM 限制

  • 上下文
  • 幻觉
  • 注意力与顺序
-
-
3

离线建库

  • 资料清洗
  • 切片
  • 向量化入库
-
-
4

在线问答

  • 语义检索
  • 排序 / 重排
  • 提示词工程
-
-
5

能力扩展

  • Agents 分工
  • MCP 连接工具
-
- -
这页把路线说清楚。后面每一页都围绕 LLM 限制到 RAG 方案,再到提示词工程和 Agent 的路径推进。
- +
01

学习路径

由浅入深:先理解问题,再理解方案,最后理解扩展能力。

AI
RAG 入门
+
+
+
1

基础概念

RAG 是什么;LLM 是什么;两者分别负责什么。

+
2

LLM 限制

上下文有限、会幻觉、长内容专注度下降、顺序不稳定。

+
3

离线建库

资料准备、清洗、切片、元数据、向量化入库。

+
4

在线问答

用户提问、语义检索、候选排序、拼上下文、生成答案。

+
5

能力扩展

提示词工程、多个 LLM 分工、Agent 编排、MCP 连接工具。

+
+
+
概念知道每个词在干什么
+
流程知道一次问答怎么跑
+
关键切片、排序、提示词
+
边界Agent 和工具调用
+
+
+
+
-
RAG检索增强生成
-

RAG 解释

-

RAG = 先检索资料,再增强上下文,最后生成答案

-
-
Retrieval

检索

从知识库找到相关资料。

-
-
Augmented

增强

把资料放进上下文。

-
-
Generation

生成

LLM 基于资料回答。

-
-
-

不是训练模型

知识不写进模型参数,而是每次回答前动态查资料。

-

不是全量塞资料

只取与问题相关的片段,降低上下文压力和噪声。

-
- -
这页讲 RAG 的基本定义。先给出完整定义,再强调 RAG 不是训练模型,也不是把全部资料塞给模型。
- +
02

RAG 是什么

RAG = Retrieval-Augmented Generation,检索增强生成。

R
先查再答
+
+
+
Retrieval

检索

先从知识库中找到和问题相关的资料片段。

+
RAG
筛选相关资料
+
Generation

生成

LLM 只基于筛出来的资料组织自然语言答案。

+
+
+
不是训练模型知识不写进模型参数
+
不是全量塞资料只取相关片段
+
=
动态查资料每次提问实时检索
+
降低幻觉让答案有依据
+
+
+
+
-
LLM大语言模型
-

定义

-

LLM:理解上下文,生成自然语言

-
-
- 能力 -

理解上下文,生成答案

-
    -
  • 读懂用户问题的大意
  • -
  • 把零散信息组织成自然语言
  • -
  • 按要求改写、总结、解释、翻译
  • -
-
-
-
- 边界 -

企业资料库或事实系统

-
    -
  • 不会天然知道最新制度
  • -
  • 不知道内部文档和私有数据
  • -
  • 不能保证每句话都有来源
  • -
+
03

LLM 是什么

大语言模型擅长理解上下文和生成表达,但不是企业事实库。

LLM
语言引擎
+
+
+
能力
AI

理解与表达

读懂问题意图总结、改写、解释、翻译把零散资料组织成答案
+
+
边界
DB

企业事实系统

不会天然知道内部文档不知道最新政策和数据不能保证每句话都有来源
+
+
+
读懂上下文理解语义,不是简单关键词
+
生成答案组织语言和结构
+
缺事实来源私有知识需要外部供给
+
接 RAG把资料交给 LLM 使用
+
+
-

定位

LLM 负责读懂和表达,事实依据来自外部资料。

- -
这里不要把 LLM 讲成搜索引擎。LLM 最强的是语言理解和生成,事实来源要靠外部知识、数据库或工具补充。
- -
+
-
Limits对话限制
-

对话限制

-

LLM 的 4 个对话限制

-
-
1

上下文有限

输入窗口有限;资料过多会变慢、变贵、变乱。

-
2

会有幻觉

资料不足或指令不清时,会生成看似合理的错误内容。

-
3

专注度下降

长资料和噪声会稀释重点,关键信息可能被忽略。

-
4

顺序不稳定

位置、相似内容、前后冲突都会影响答案。

-
-

处理策略:每次只给最相关、最可信的少量资料。

- -
这一页要明确回应用户大纲:上下文限制、幻觉、专注度、不会稳定关注内容顺序。它们共同解释了为什么不能简单粗暴地把所有文档塞进去。
- +
04

LLM 的对话限制

正因为有这些限制,不能把所有知识一次性丢给 LLM。

!
为什么需要 RAG
+
+
+
+
01

上下文有限

输入窗口有限;资料过多会变慢、变贵、变乱。

+
02

会有幻觉

资料不足或指令不清时,会编出看似合理的内容。

+
+
不能
全量塞
资料
+
+
03

专注度下降

长资料和噪声会稀释重点,关键信息被忽略。

+
04

顺序不稳定

内容位置、相似片段、前后冲突都会影响答案。

+
+
+
+
少量只给必要资料
+
准确优先高相关来源
+
新鲜版本和时间可控
+
规则提示词约束回答边界
+
+
+
+
-
Why RAG限制带来方案
-

从限制到方案

-

RAG 的核心:不是让模型记住全部知识,而是让它按需查资料

-
-
全量输入

全部塞给 LLM

长、乱、贵,容易混入过期和无权限资料。

-
-
RAG

先查,再答

每次只取跟问题最相关的资料片段。

-
-
生成

基于资料回答

LLM 根据资料生成,必要时引用来源。

-
-

RAG = 检索增强生成

检索相关资料 → 放入上下文 → LLM 生成答案。

- -
这页把 RAG 的必要性讲出来:不是因为向量数据库酷,而是因为 LLM 的上下文和注意力有限,必须把资料筛小、筛准,再交给模型。
- +
05

从限制到方案

RAG 的核心不是让模型记住全部知识,而是让模型按需查资料。

方案转化
+
+
+
错误做法

全部塞给 LLM

长文档堆叠,重点被稀释过期资料混入,结果不可信无权限内容可能泄露成本高,速度慢
+
检索
过滤
排序
+
RAG 做法

只给相关资料

按问题查资料按相关性排序拼成小上下文让 LLM 基于依据回答
+
+
+
+
-
Offline Pipeline后台整理资料
-

离线流程

-

资料整理成可检索的知识库

-
-
01

收集资料

产品手册、FAQ、制度、接口文档、案例、流程说明。

-
02

清洗资料

去掉重复、过期、广告、目录噪声和格式错误。

-
03

切片

把长文档拆成能独立表达意思的小片段。

-
04

加元数据

来源、版本、时间、部门、权限、适用范围。

-
05

向量化入库

把每个片段变成语义向量,写入向量库。

-
-

资料质量决定检索质量。

- -
这里是“前期准备”。强调 RAG 不只是问答界面,后台知识库准备很关键。元数据也很重要,因为后面排序和权限过滤会用到。
- +
06

后台建库:把资料整理成可检索系统

RAG 不是只有一个问答框,前期知识库整理决定最终效果。

KB
离线流程
+
+
+
资料层
+
产品手册、FAQ、制度、接口文档、案例、流程说明确认来源、版本、时间、权限和适用范围
+
+
+
处理层
+
清洗重复、过期、广告、目录噪声和格式错误长文档拆成能独立表达意思的小片段
+
+
+ +
每个切片转成语义向量,写入向量库同时保留元数据,用于过滤、排序和引用来源
+
+
+
资料质量决定检索上限
+
切片粒度决定召回精度
+
元数据决定过滤和引用
+
向量库支持语义检索
+
+
+
+
-
Chunk & Embedding切片和向量化
-

索引构建

-

切片 + 向量化:支持语义检索

-
-
切片 A:退费条件

购买后 7 天内,且未使用核心服务,可以申请全额退款。

-
切片 B:不可退场景

已开具发票、已交付定制服务、超过合同期限,不支持自动退款。

-
切片 C:审批路径

超过 5 万元的退款申请,需要客户成功经理和财务双审批。

-
-
-
切片

粒度适中

过大噪声多,过小语义断;每片覆盖一个局部问题。

-
向量库

语义坐标

文字转换成数字向量;语义相近,距离更近。

-
- -
用卡片和地图类比:切片是把书拆成卡片,向量化是给卡片标语义坐标。用户问法不一样,也能找到意思接近的片段。
- +
07

切片 + 向量化

把长文档拆成小卡片,再给每张卡片标上“语义坐标”。

V
语义检索
+
+
+
+ 原始资料:退款政策 +

切片 A:退费条件

购买后 7 天内,且未使用核心服务,可以申请全额退款。

+

切片 B:不可退场景

已开具发票、已交付定制服务、超过合同期限,不支持自动退款。

+

切片 C:审批路径

超过 5 万元的退款申请,需要客户成功经理和财务双审批。

+
+
+ 向量化结果 +

语义相近,距离更近

+

用户问“买了 3 天没用能退吗”,即使没有出现“退费条件”这几个字,也能找到切片 A。

+
+
+
+
+
+
-
Online Retrieval检索和排序
-

在线流程

-

用户提问后:检索候选,排序后交给 LLM

-
-
01

问题改写

把口语问题改成更适合检索的查询。

-
02

问题向量化

把用户问题也转成语义向量。

-
03

召回候选

从向量库里找语义距离近的片段。

-
04

排序 / 重排

按相关性、时效、权限、来源可信度重新排序。

-
05

拼上下文

只把最有用的几段资料交给 LLM。

-
- - -
这里必须引出“排序”概念。召回只是先捞一批候选,排序/重排才决定哪些片段真正进入上下文。排序质量直接影响最终答案。
- +
08

用户提问时怎么查

召回只是先捞候选,排序 / 重排决定哪些资料真正进入上下文。

S
在线检索
+
+
+
1

问题改写

把口语问题改成适合检索的查询。

+
2

问题向量化

用户问题也转成语义向量。

+
3

召回候选

从向量库找语义距离近的片段。

+
4

排序 / 重排

按相关性、时效、权限、来源可信度重新排序。

+
5

拼上下文

只把最有用的几段交给 LLM。

+
+
+

排序后的候选资料

1

退款政策 v2026Q2

最相关,版本最新

96
2

大客户审批流程

金额超过 5 万时使用

78
+

排序看什么

相关性:是否真的回答这个问题时效性:新版本优先,过期资料降权权限:用户不能看的资料先过滤可信度:制度、合同、权威来源优先
+
+
+
+
-
Prompt Engineering系统提示词
-

生成约束

-

系统提示词规定 LLM 的资料使用规则

- +
-
Agent复杂任务的分工协作
-

复杂任务

-

复杂任务:多步骤、多角色、多 LLM 协作

-
-
规划者

拆任务

规划检索、工具调用和结果组织。

-
-
检索者

查资料

使用 RAG 从知识库里找依据,必要时多轮检索。

-
-
执行者

调用工具

查订单、建工单、读数据库、调用业务系统接口。

-
-
-

Agent

目标驱动流程:规划步骤、选择工具、读取结果、继续推进。

-

和 RAG 的关系

RAG 提供知识入口;Agent 负责任务编排。

-
- -
不要把 Agent 讲玄。它就是更复杂任务里的规划和编排:可能一个 LLM 规划,一个 LLM 检索,一个 LLM 写答案,也可能调用外部工具。
- +
10

复杂任务需要 Agent

当任务不只是“答一句话”,就需要规划、检索、执行、校验等分工。

A
任务编排
+
+
+

规划者

拆步骤,决定先查什么、再调用什么工具。

+
+

检索者

使用 RAG 找依据,必要时多轮检索。

+
+

执行者

查订单、建工单、读数据库、调用接口。

+
+
+
R
RAG提供知识入口
+
P
Prompt规定回答规则
+
A
Agent负责任务编排
+
T
Tools执行外部动作
+
+
+
+
-
MCP工具连接
-

MCP

-

MCP:让 Agent 稳定连接外部工具和业务系统

-
-
统一接口

工具接入规范

把不同系统的能力包装成模型可调用的工具。

-
上下文供给

读取外部信息

查数据库、读文件、取工单、访问知识系统。

-
动作执行

调用业务能力

创建工单、查询订单、发送通知、写入结果。

-
-
- CRM工单数据库搜索文件 -
-

RAG 负责查知识;Agent 负责编排任务;MCP 负责连接工具。

- -
这页讲 MCP。它不需要展开协议细节,只要说明 MCP 是让模型/Agent 稳定连接外部工具和系统的接口层。
- +
11

MCP:让 Agent 连接工具

MCP 可以理解为模型/Agent 调用外部系统的一套统一连接方式。

M
工具连接
+
+
+
Agent

会规划任务

但真正查数据、改状态、发通知,需要连接业务系统。

+
MCP
统一工具接口 / 上下文供给 / 动作执行
+
+
CRM客户资料
+
工单创建 / 查询
+
数据库读数据
+
文件读文档
+
搜索查外部信息
+
消息发送通知
+
+
+
+
RAG查知识
+
Agent编排任务
+
MCP连接工具
+
业务系统完成动作
+
+
+
+
-
Takeaway关键链路
-

核心链路

-

RAG → LLM 限制 → 切片 → 向量化 → 语义检索 → 排序 → 提示词 → Agent → MCP

-
-
限制

LLM 不能吃下全部知识

上下文有限、会幻觉、专注度和顺序都不稳定。

-
建库

资料要先整理成片段

清洗、切片、向量化、加元数据,再放入向量库。

-
检索

提问时先找资料

召回候选,再排序过滤,把最相关内容放进上下文。

-
扩展

Agent + MCP

Agent 编排多步骤任务;MCP 连接外部工具和系统。

-
-

RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。

- -
收尾不要再加新概念。重复主线:LLM 有限制,所以需要 RAG;RAG 后台建库,前台检索排序,再通过提示词交给 LLM;复杂任务用 Agent。
- +
12

入门之后,要抓住这条主线

LLM 有限制,所以需要 RAG;RAG 把资料找准,再交给 LLM 生成。

核心链路
+
+
+
1

LLM

负责理解和表达,但不是企业知识库。

+
2

限制

上下文、幻觉、专注度、顺序带来风险。

+
3

RAG

后台建库;前台检索;排序后拼上下文。

+
4

Prompt

规定角色、边界、格式、兜底和安全。

+
5

Agent + MCP

复杂任务用 Agent 编排,用 MCP 连接工具。

+
+
+
一句话总结
+

RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。

+
+
+
+
@@ -418,6 +496,7 @@ ul{margin:14px 0 0;padding-left:22px;color:var(--muted);line-height:1.85;font-si function render(){ slides.forEach((s,i)=>s.classList.toggle('active',i===idx)); document.querySelectorAll('.page').forEach(el=>{el.textContent=(idx+1)+' / '+slides.length}); + document.querySelectorAll('.page-corner').forEach(el=>{el.textContent=String(idx+1).padStart(2,'0')}); navIndex.textContent = (idx+1)+' / '+slides.length; prevBtn.disabled = idx === 0; nextBtn.disabled = idx === slides.length - 1; diff --git a/public/rag-for-pm.html b/public/rag-for-pm.html index f09388e..601e589 100644 --- a/public/rag-for-pm.html +++ b/public/rag-for-pm.html @@ -6,398 +6,476 @@ RAG 入门:原理、流程与使用
-
+
-
RAG Primer原理和使用
-

RAG · LLM · Retrieval · Prompt · Agent · MCP

-

RAG 入门:让 AI 先查资料再回答

-

从 LLM 的对话限制出发,理解 RAG 的建库、检索、排序、提示词和 Agent 扩展。

-
- RAG解释LLM解释上下文限制幻觉注意力顺序切片向量化排序语义检索提示词工程AGENTSMCP -
- -
开场不用讲算法,先讲主线:大模型会说话,但它不是企业知识库。因为它有上下文、幻觉、注意力和顺序方面的限制,所以需要 RAG 这套“先查资料,再组织答案”的机制。
- +
+
+
RAG · LLM · Prompt · Agent · MCP
+

RAG 入门:让 AI 先查资料 再回答

+

从 LLM 的限制讲起,串起知识库建设、语义检索、排序、提示词工程、Agent 和 MCP。

+
+
01
先懂限制上下文、幻觉、注意力
+
02
再懂流程建库、检索、生成
+
03
最后扩展Agent、MCP、工具
+
+
+
+
+
RAG
+
LLM理解和生成
+
知识库资料与来源
+
检索找相关片段
+
提示词约束回答
+
+
+
+
+
-
Roadmap学习路径
-

由浅入深

-

从“会生成”到“会查资料、会调用工具”

-
-
1

基础概念

  • RAG 是什么
  • LLM 是什么
-
-
2

LLM 限制

  • 上下文
  • 幻觉
  • 注意力与顺序
-
-
3

离线建库

  • 资料清洗
  • 切片
  • 向量化入库
-
-
4

在线问答

  • 语义检索
  • 排序 / 重排
  • 提示词工程
-
-
5

能力扩展

  • Agents 分工
  • MCP 连接工具
-
- -
这页把路线说清楚。后面每一页都围绕 LLM 限制到 RAG 方案,再到提示词工程和 Agent 的路径推进。
- +
01

学习路径

由浅入深:先理解问题,再理解方案,最后理解扩展能力。

AI
RAG 入门
+
+
+
1

基础概念

RAG 是什么;LLM 是什么;两者分别负责什么。

+
2

LLM 限制

上下文有限、会幻觉、长内容专注度下降、顺序不稳定。

+
3

离线建库

资料准备、清洗、切片、元数据、向量化入库。

+
4

在线问答

用户提问、语义检索、候选排序、拼上下文、生成答案。

+
5

能力扩展

提示词工程、多个 LLM 分工、Agent 编排、MCP 连接工具。

+
+
+
概念知道每个词在干什么
+
流程知道一次问答怎么跑
+
关键切片、排序、提示词
+
边界Agent 和工具调用
+
+
+
+
-
RAG检索增强生成
-

RAG 解释

-

RAG = 先检索资料,再增强上下文,最后生成答案

-
-
Retrieval

检索

从知识库找到相关资料。

-
-
Augmented

增强

把资料放进上下文。

-
-
Generation

生成

LLM 基于资料回答。

-
-
-

不是训练模型

知识不写进模型参数,而是每次回答前动态查资料。

-

不是全量塞资料

只取与问题相关的片段,降低上下文压力和噪声。

-
- -
这页讲 RAG 的基本定义。先给出完整定义,再强调 RAG 不是训练模型,也不是把全部资料塞给模型。
- +
02

RAG 是什么

RAG = Retrieval-Augmented Generation,检索增强生成。

R
先查再答
+
+
+
Retrieval

检索

先从知识库中找到和问题相关的资料片段。

+
RAG
筛选相关资料
+
Generation

生成

LLM 只基于筛出来的资料组织自然语言答案。

+
+
+
不是训练模型知识不写进模型参数
+
不是全量塞资料只取相关片段
+
=
动态查资料每次提问实时检索
+
降低幻觉让答案有依据
+
+
+
+
-
LLM大语言模型
-

定义

-

LLM:理解上下文,生成自然语言

-
-
- 能力 -

理解上下文,生成答案

-
    -
  • 读懂用户问题的大意
  • -
  • 把零散信息组织成自然语言
  • -
  • 按要求改写、总结、解释、翻译
  • -
-
-
-
- 边界 -

企业资料库或事实系统

-
    -
  • 不会天然知道最新制度
  • -
  • 不知道内部文档和私有数据
  • -
  • 不能保证每句话都有来源
  • -
+
03

LLM 是什么

大语言模型擅长理解上下文和生成表达,但不是企业事实库。

LLM
语言引擎
+
+
+
能力
AI

理解与表达

读懂问题意图总结、改写、解释、翻译把零散资料组织成答案
+
+
边界
DB

企业事实系统

不会天然知道内部文档不知道最新政策和数据不能保证每句话都有来源
+
+
+
读懂上下文理解语义,不是简单关键词
+
生成答案组织语言和结构
+
缺事实来源私有知识需要外部供给
+
接 RAG把资料交给 LLM 使用
+
+
-

定位

LLM 负责读懂和表达,事实依据来自外部资料。

- -
这里不要把 LLM 讲成搜索引擎。LLM 最强的是语言理解和生成,事实来源要靠外部知识、数据库或工具补充。
- -
+
-
Limits对话限制
-

对话限制

-

LLM 的 4 个对话限制

-
-
1

上下文有限

输入窗口有限;资料过多会变慢、变贵、变乱。

-
2

会有幻觉

资料不足或指令不清时,会生成看似合理的错误内容。

-
3

专注度下降

长资料和噪声会稀释重点,关键信息可能被忽略。

-
4

顺序不稳定

位置、相似内容、前后冲突都会影响答案。

-
-

处理策略:每次只给最相关、最可信的少量资料。

- -
这一页要明确回应用户大纲:上下文限制、幻觉、专注度、不会稳定关注内容顺序。它们共同解释了为什么不能简单粗暴地把所有文档塞进去。
- +
04

LLM 的对话限制

正因为有这些限制,不能把所有知识一次性丢给 LLM。

!
为什么需要 RAG
+
+
+
+
01

上下文有限

输入窗口有限;资料过多会变慢、变贵、变乱。

+
02

会有幻觉

资料不足或指令不清时,会编出看似合理的内容。

+
+
不能
全量塞
资料
+
+
03

专注度下降

长资料和噪声会稀释重点,关键信息被忽略。

+
04

顺序不稳定

内容位置、相似片段、前后冲突都会影响答案。

+
+
+
+
少量只给必要资料
+
准确优先高相关来源
+
新鲜版本和时间可控
+
规则提示词约束回答边界
+
+
+
+
-
Why RAG限制带来方案
-

从限制到方案

-

RAG 的核心:不是让模型记住全部知识,而是让它按需查资料

-
-
全量输入

全部塞给 LLM

长、乱、贵,容易混入过期和无权限资料。

-
-
RAG

先查,再答

每次只取跟问题最相关的资料片段。

-
-
生成

基于资料回答

LLM 根据资料生成,必要时引用来源。

-
-

RAG = 检索增强生成

检索相关资料 → 放入上下文 → LLM 生成答案。

- -
这页把 RAG 的必要性讲出来:不是因为向量数据库酷,而是因为 LLM 的上下文和注意力有限,必须把资料筛小、筛准,再交给模型。
- +
05

从限制到方案

RAG 的核心不是让模型记住全部知识,而是让模型按需查资料。

方案转化
+
+
+
错误做法

全部塞给 LLM

长文档堆叠,重点被稀释过期资料混入,结果不可信无权限内容可能泄露成本高,速度慢
+
检索
过滤
排序
+
RAG 做法

只给相关资料

按问题查资料按相关性排序拼成小上下文让 LLM 基于依据回答
+
+
+
+
-
Offline Pipeline后台整理资料
-

离线流程

-

资料整理成可检索的知识库

-
-
01

收集资料

产品手册、FAQ、制度、接口文档、案例、流程说明。

-
02

清洗资料

去掉重复、过期、广告、目录噪声和格式错误。

-
03

切片

把长文档拆成能独立表达意思的小片段。

-
04

加元数据

来源、版本、时间、部门、权限、适用范围。

-
05

向量化入库

把每个片段变成语义向量,写入向量库。

-
-

资料质量决定检索质量。

- -
这里是“前期准备”。强调 RAG 不只是问答界面,后台知识库准备很关键。元数据也很重要,因为后面排序和权限过滤会用到。
- +
06

后台建库:把资料整理成可检索系统

RAG 不是只有一个问答框,前期知识库整理决定最终效果。

KB
离线流程
+
+
+
资料层
+
产品手册、FAQ、制度、接口文档、案例、流程说明确认来源、版本、时间、权限和适用范围
+
+
+
处理层
+
清洗重复、过期、广告、目录噪声和格式错误长文档拆成能独立表达意思的小片段
+
+
+ +
每个切片转成语义向量,写入向量库同时保留元数据,用于过滤、排序和引用来源
+
+
+
资料质量决定检索上限
+
切片粒度决定召回精度
+
元数据决定过滤和引用
+
向量库支持语义检索
+
+
+
+
-
Chunk & Embedding切片和向量化
-

索引构建

-

切片 + 向量化:支持语义检索

-
-
切片 A:退费条件

购买后 7 天内,且未使用核心服务,可以申请全额退款。

-
切片 B:不可退场景

已开具发票、已交付定制服务、超过合同期限,不支持自动退款。

-
切片 C:审批路径

超过 5 万元的退款申请,需要客户成功经理和财务双审批。

-
-
-
切片

粒度适中

过大噪声多,过小语义断;每片覆盖一个局部问题。

-
向量库

语义坐标

文字转换成数字向量;语义相近,距离更近。

-
- -
用卡片和地图类比:切片是把书拆成卡片,向量化是给卡片标语义坐标。用户问法不一样,也能找到意思接近的片段。
- +
07

切片 + 向量化

把长文档拆成小卡片,再给每张卡片标上“语义坐标”。

V
语义检索
+
+
+
+ 原始资料:退款政策 +

切片 A:退费条件

购买后 7 天内,且未使用核心服务,可以申请全额退款。

+

切片 B:不可退场景

已开具发票、已交付定制服务、超过合同期限,不支持自动退款。

+

切片 C:审批路径

超过 5 万元的退款申请,需要客户成功经理和财务双审批。

+
+
+ 向量化结果 +

语义相近,距离更近

+

用户问“买了 3 天没用能退吗”,即使没有出现“退费条件”这几个字,也能找到切片 A。

+
+
+
+
+
+
-
Online Retrieval检索和排序
-

在线流程

-

用户提问后:检索候选,排序后交给 LLM

-
-
01

问题改写

把口语问题改成更适合检索的查询。

-
02

问题向量化

把用户问题也转成语义向量。

-
03

召回候选

从向量库里找语义距离近的片段。

-
04

排序 / 重排

按相关性、时效、权限、来源可信度重新排序。

-
05

拼上下文

只把最有用的几段资料交给 LLM。

-
- - -
这里必须引出“排序”概念。召回只是先捞一批候选,排序/重排才决定哪些片段真正进入上下文。排序质量直接影响最终答案。
- +
08

用户提问时怎么查

召回只是先捞候选,排序 / 重排决定哪些资料真正进入上下文。

S
在线检索
+
+
+
1

问题改写

把口语问题改成适合检索的查询。

+
2

问题向量化

用户问题也转成语义向量。

+
3

召回候选

从向量库找语义距离近的片段。

+
4

排序 / 重排

按相关性、时效、权限、来源可信度重新排序。

+
5

拼上下文

只把最有用的几段交给 LLM。

+
+
+

排序后的候选资料

1

退款政策 v2026Q2

最相关,版本最新

96
2

大客户审批流程

金额超过 5 万时使用

78
+

排序看什么

相关性:是否真的回答这个问题时效性:新版本优先,过期资料降权权限:用户不能看的资料先过滤可信度:制度、合同、权威来源优先
+
+
+
+
-
Prompt Engineering系统提示词
-

生成约束

-

系统提示词规定 LLM 的资料使用规则

- +
-
Agent复杂任务的分工协作
-

复杂任务

-

复杂任务:多步骤、多角色、多 LLM 协作

-
-
规划者

拆任务

规划检索、工具调用和结果组织。

-
-
检索者

查资料

使用 RAG 从知识库里找依据,必要时多轮检索。

-
-
执行者

调用工具

查订单、建工单、读数据库、调用业务系统接口。

-
-
-

Agent

目标驱动流程:规划步骤、选择工具、读取结果、继续推进。

-

和 RAG 的关系

RAG 提供知识入口;Agent 负责任务编排。

-
- -
不要把 Agent 讲玄。它就是更复杂任务里的规划和编排:可能一个 LLM 规划,一个 LLM 检索,一个 LLM 写答案,也可能调用外部工具。
- +
10

复杂任务需要 Agent

当任务不只是“答一句话”,就需要规划、检索、执行、校验等分工。

A
任务编排
+
+
+

规划者

拆步骤,决定先查什么、再调用什么工具。

+
+

检索者

使用 RAG 找依据,必要时多轮检索。

+
+

执行者

查订单、建工单、读数据库、调用接口。

+
+
+
R
RAG提供知识入口
+
P
Prompt规定回答规则
+
A
Agent负责任务编排
+
T
Tools执行外部动作
+
+
+
+
-
MCP工具连接
-

MCP

-

MCP:让 Agent 稳定连接外部工具和业务系统

-
-
统一接口

工具接入规范

把不同系统的能力包装成模型可调用的工具。

-
上下文供给

读取外部信息

查数据库、读文件、取工单、访问知识系统。

-
动作执行

调用业务能力

创建工单、查询订单、发送通知、写入结果。

-
-
- CRM工单数据库搜索文件 -
-

RAG 负责查知识;Agent 负责编排任务;MCP 负责连接工具。

- -
这页讲 MCP。它不需要展开协议细节,只要说明 MCP 是让模型/Agent 稳定连接外部工具和系统的接口层。
- +
11

MCP:让 Agent 连接工具

MCP 可以理解为模型/Agent 调用外部系统的一套统一连接方式。

M
工具连接
+
+
+
Agent

会规划任务

但真正查数据、改状态、发通知,需要连接业务系统。

+
MCP
统一工具接口 / 上下文供给 / 动作执行
+
+
CRM客户资料
+
工单创建 / 查询
+
数据库读数据
+
文件读文档
+
搜索查外部信息
+
消息发送通知
+
+
+
+
RAG查知识
+
Agent编排任务
+
MCP连接工具
+
业务系统完成动作
+
+
+
+
-
Takeaway关键链路
-

核心链路

-

RAG → LLM 限制 → 切片 → 向量化 → 语义检索 → 排序 → 提示词 → Agent → MCP

-
-
限制

LLM 不能吃下全部知识

上下文有限、会幻觉、专注度和顺序都不稳定。

-
建库

资料要先整理成片段

清洗、切片、向量化、加元数据,再放入向量库。

-
检索

提问时先找资料

召回候选,再排序过滤,把最相关内容放进上下文。

-
扩展

Agent + MCP

Agent 编排多步骤任务;MCP 连接外部工具和系统。

-
-

RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。

- -
收尾不要再加新概念。重复主线:LLM 有限制,所以需要 RAG;RAG 后台建库,前台检索排序,再通过提示词交给 LLM;复杂任务用 Agent。
- +
12

入门之后,要抓住这条主线

LLM 有限制,所以需要 RAG;RAG 把资料找准,再交给 LLM 生成。

核心链路
+
+
+
1

LLM

负责理解和表达,但不是企业知识库。

+
2

限制

上下文、幻觉、专注度、顺序带来风险。

+
3

RAG

后台建库;前台检索;排序后拼上下文。

+
4

Prompt

规定角色、边界、格式、兜底和安全。

+
5

Agent + MCP

复杂任务用 Agent 编排,用 MCP 连接工具。

+
+
+
一句话总结
+

RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。

+
+
+
+
@@ -418,6 +496,7 @@ ul{margin:14px 0 0;padding-left:22px;color:var(--muted);line-height:1.85;font-si function render(){ slides.forEach((s,i)=>s.classList.toggle('active',i===idx)); document.querySelectorAll('.page').forEach(el=>{el.textContent=(idx+1)+' / '+slides.length}); + document.querySelectorAll('.page-corner').forEach(el=>{el.textContent=String(idx+1).padStart(2,'0')}); navIndex.textContent = (idx+1)+' / '+slides.length; prevBtn.disabled = idx === 0; nextBtn.disabled = idx === slides.length - 1; diff --git a/public/style.css b/public/style.css index ce309fa..ce1cac1 100644 --- a/public/style.css +++ b/public/style.css @@ -1736,6 +1736,31 @@ body.session-loading-active { display: inline-flex; flex-shrink: 0; } +.ccweb-prompt-outline-anchor { + position: relative; + display: inline-flex; + flex-shrink: 0; +} +.ccweb-prompt-outline-btn { + position: relative; +} +.ccweb-prompt-outline-btn[data-count]::after { + content: attr(data-count); + position: absolute; + top: -6px; + right: -7px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: var(--accent); + color: var(--accent-ink); + font-size: 10px; + font-weight: 900; + line-height: 16px; + text-align: center; + box-shadow: 0 0 0 2px var(--bg-primary); +} .user-outline-btn:hover, .reload-mcp-btn:hover:not(:disabled) { background: rgba(91, 126, 161, 0.16); @@ -3387,6 +3412,76 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { background: var(--note-border); border-radius: 999px; } +.pending-ccweb-prompt { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + width: 100%; + min-height: 36px; + padding: 6px 8px; + border: none; + border-radius: 10px; + background: transparent; + animation: fadeIn 0.2s ease; +} +.pending-ccweb-prompt:hover { + background: var(--accent-light); +} +.pending-ccweb-prompt-badge { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px rgba(192, 85, 58, 0.12); +} +.pending-ccweb-prompt-title { + color: var(--text-primary); + font-size: 13px; + font-weight: 800; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pending-ccweb-prompt-action { + min-height: 26px; + padding: 3px 9px; + border: 1px solid rgba(192, 85, 58, 0.24); + border-radius: 999px; + background: rgba(255, 249, 242, 0.9); + color: var(--accent); + font: inherit; + font-size: 12px; + font-weight: 900; + cursor: pointer; + transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease; +} +.pending-ccweb-prompt-action:hover { + border-color: var(--accent); + background: var(--bg-primary); + transform: translateY(-1px); +} +.pending-ccweb-prompt-dismiss { + min-height: 26px; + padding: 3px 8px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 12px; + font-weight: 900; + cursor: pointer; +} +.pending-ccweb-prompt-dismiss:hover { + background: rgba(139, 100, 32, 0.1); + color: var(--text-primary); +} +.pending-ccweb-prompt-dismiss:disabled { + opacity: 0.55; + cursor: wait; +} .pending-note { display: grid; grid-template-columns: 28px minmax(0, 1fr); @@ -3704,6 +3799,7 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { font-size: 11px; } .user-outline-btn, + .ccweb-prompt-outline-btn, .reload-mcp-btn { padding: 4px 8px; font-size: 10px; @@ -3747,9 +3843,13 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; } + .ccweb-prompt-outline-anchor { + width: 100%; + } .chat-cwd, .mode-select, .user-outline-btn, + .ccweb-prompt-outline-btn, .reload-mcp-btn, .chat-runtime-state { width: 100%; @@ -4719,6 +4819,474 @@ html[data-theme='coolvibe'] .settings-back:hover { .codex-user-input-text:focus { border-color: var(--accent); } +.ccweb-prompt-card { + width: min(100%, 720px); + margin-top: 14px; + padding: 0; + border: 1px solid rgba(192, 85, 58, 0.18); + border-left: 4px solid var(--accent); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 249, 242, 0.98), rgba(250, 246, 240, 0.94)); + color: var(--text-primary); + box-shadow: 0 14px 34px rgba(45, 31, 20, 0.08); + overflow: hidden; +} +.ccweb-prompt-card.ccweb-prompt-focus { + animation: ccwebPromptFocus 1.4s ease; +} +@keyframes ccwebPromptFocus { + 0% { + box-shadow: 0 0 0 0 rgba(192, 85, 58, 0.34), 0 14px 34px rgba(45, 31, 20, 0.08); + } + 45% { + box-shadow: 0 0 0 5px rgba(192, 85, 58, 0.18), 0 18px 38px rgba(45, 31, 20, 0.12); + } + 100% { + box-shadow: 0 0 0 0 rgba(192, 85, 58, 0), 0 14px 34px rgba(45, 31, 20, 0.08); + } +} +.ccweb-prompt-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 46px; + padding: 9px 12px; + border-bottom: 1px solid rgba(221, 208, 192, 0.72); +} +.ccweb-prompt-title-wrap { + flex: 1 1 auto; + min-width: 0; +} +.ccweb-prompt-kicker { + color: var(--text-muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.ccweb-prompt-title { + color: var(--text-primary); + font-size: 15px; + font-weight: 800; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ccweb-prompt-header-actions { + display: inline-flex; + align-items: center; + gap: 7px; + flex: 0 0 auto; +} +.ccweb-prompt-status { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid rgba(192, 85, 58, 0.18); + border-radius: 999px; + background: rgba(245, 221, 212, 0.6); + color: var(--accent); + font-size: 10px; + font-weight: 800; +} +.ccweb-prompt-card[data-status='submitted'] .ccweb-prompt-status { + width: auto; + padding: 0 7px; + border-color: rgba(93, 138, 84, 0.28); + background: rgba(93, 138, 84, 0.12); + color: var(--success); + font-size: 11px; +} +.ccweb-prompt-desc { + margin: 0; + padding: 10px 12px 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.ccweb-prompt-view-controls { + padding: 8px 12px 0; +} +.ccweb-prompt-view-switcher { + display: inline-flex; + width: max-content; + max-width: 100%; + padding: 1px; + border: 1px solid rgba(221, 208, 192, 0.94); + border-radius: 8px; + background: rgba(255, 255, 255, 0.48); +} +.ccweb-prompt-view-btn { + width: 26px; + height: 26px; + min-height: 26px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 14px; + font-weight: 900; + line-height: 1; + cursor: pointer; +} +.ccweb-prompt-view-btn.is-active { + background: var(--accent); + color: var(--accent-ink); +} +.ccweb-prompt-tabs { + display: none; + min-width: 0; + gap: 6px; + overflow-x: auto; + scrollbar-width: thin; +} +.ccweb-prompt-tab { + flex: 0 0 auto; + max-width: 168px; + min-height: 30px; + padding: 5px 10px; + border: 1px solid rgba(221, 208, 192, 0.94); + border-radius: 8px; + background: rgba(255, 249, 242, 0.72); + color: var(--text-secondary); + font: inherit; + font-size: 12px; + font-weight: 800; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ccweb-prompt-tab.is-active { + border-color: var(--accent); + background: rgba(245, 221, 212, 0.72); + color: var(--accent); +} +.ccweb-prompt-tab-nav { + display: none; + align-items: center; + justify-content: flex-start; + gap: 6px; + min-width: 0; +} +.ccweb-prompt-tab-nav-btn { + min-height: 30px; + padding: 5px 9px; + border: 1px solid rgba(221, 208, 192, 0.98); + border-radius: 8px; + background: rgba(255, 249, 242, 0.75); + color: var(--text-primary); + font: inherit; + font-size: 12px; + font-weight: 900; + cursor: pointer; +} +.ccweb-prompt-tab-nav-btn:disabled { + opacity: 0.45; + cursor: default; +} +.ccweb-prompt-tab-counter { + min-width: 64px; + color: var(--text-muted); + font-size: 12px; + font-weight: 800; + text-align: center; +} +.ccweb-prompt-card[data-view-mode='tabs'] .ccweb-prompt-tabs { + display: flex; +} +.ccweb-prompt-card[data-view-mode='cards'] .ccweb-prompt-view-controls { + display: none; +} +.ccweb-prompt-card[data-view-mode='tabs'] .ccweb-prompt-tab-nav { + display: flex; +} +.ccweb-prompt-card[data-view-mode='tabs'] .ccweb-prompt-question:not(.is-active) { + display: none; +} +.ccweb-prompt-questions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 0; + padding: 10px 12px 4px; +} +.ccweb-prompt-question { + display: flex; + flex-direction: column; + gap: 10px; + padding: 13px 14px; + border: 1px solid rgba(221, 208, 192, 0.92); + border-radius: 8px; + background: rgba(255, 255, 255, 0.46); +} +.ccweb-prompt-question-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.ccweb-prompt-question-title { + color: var(--text-primary); + font-size: 14px; + font-weight: 800; + line-height: 1.35; + overflow-wrap: anywhere; +} +.ccweb-prompt-required { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 999px; + background: rgba(192, 85, 58, 0.1); + color: var(--danger); + font-size: 12px; + font-weight: 800; +} +.ccweb-prompt-question-body { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.ccweb-prompt-options { + display: grid; + grid-template-columns: 1fr; + gap: 7px; +} +.ccweb-prompt-option { + position: relative; + display: grid; + grid-template-columns: auto 1fr auto; + column-gap: 9px; + row-gap: 4px; + min-width: 0; + align-items: start; + width: 100%; + padding: 9px 11px; + border: 1px solid rgba(221, 208, 192, 0.96); + border-radius: 8px; + background: rgba(255, 249, 242, 0.82); + color: var(--text-primary); + font: inherit; + font-size: 13px; + line-height: 1.35; + text-align: left; + cursor: pointer; + transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; +} +.ccweb-prompt-option::before { + content: ''; + width: 15px; + height: 15px; + margin-top: 1px; + border: 1.5px solid var(--border-color); + border-radius: 4px; + background: rgba(255, 255, 255, 0.75); + box-sizing: border-box; +} +.ccweb-prompt-option:hover { + border-color: var(--accent); + background: rgba(255, 249, 242, 0.98); + box-shadow: 0 8px 18px rgba(45, 31, 20, 0.06); + transform: translateY(-1px); +} +.ccweb-prompt-option.is-selected { + border-color: var(--accent); + background: rgba(245, 221, 212, 0.72); + color: var(--text-primary); + box-shadow: inset 0 0 0 1px rgba(192, 85, 58, 0.16); +} +.ccweb-prompt-option.is-selected::before { + content: '✓'; + display: grid; + place-items: center; + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-size: 10px; + font-weight: 900; +} +.ccweb-prompt-option-label { + min-width: 0; + overflow-wrap: anywhere; + font-weight: 800; +} +.ccweb-prompt-option-badge { + flex: 0 0 auto; + align-self: start; + padding: 2px 6px; + border-radius: 999px; + background: rgba(192, 85, 58, 0.12); + color: var(--accent); + font-size: 11px; + font-weight: 800; +} +.ccweb-prompt-option-desc { + grid-column: 2 / -1; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; + text-align: left; +} +.ccweb-prompt-answer { + width: 100%; + min-height: 84px; + resize: vertical; + padding: 10px 11px; + border: 1px solid rgba(221, 208, 192, 0.98); + border-radius: 8px; + background: rgba(255, 255, 255, 0.62); + color: var(--text-primary); + font: inherit; + font-size: 13px; + line-height: 1.5; + outline: none; + transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease; +} +.ccweb-prompt-answer:focus { + border-color: var(--accent); + background: var(--bg-primary); + box-shadow: 0 0 0 3px rgba(192, 85, 58, 0.1); +} +.ccweb-prompt-selected-readonly, +.ccweb-prompt-answer-readonly { + padding: 10px 11px; + border: 1px solid rgba(221, 208, 192, 0.92); + border-radius: 8px; + background: rgba(255, 255, 255, 0.56); + color: var(--text-primary); + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.ccweb-prompt-selected-readonly { + color: var(--text-secondary); +} +.ccweb-prompt-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 0; + padding: 9px 12px 11px; + border-top: 1px solid rgba(221, 208, 192, 0.72); + background: rgba(242, 235, 226, 0.38); +} +.ccweb-prompt-footer-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} +.ccweb-prompt-submit, +.ccweb-prompt-secondary { + min-height: 32px; + padding: 6px 12px; + border-radius: 8px; + font: inherit; + font-size: 13px; + font-weight: 800; + cursor: pointer; + transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; +} +.ccweb-prompt-submit { + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-ink); +} +.ccweb-prompt-submit:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); + box-shadow: 0 8px 18px rgba(192, 85, 58, 0.18); + transform: translateY(-1px); +} +.ccweb-prompt-submit:disabled { + opacity: 0.65; + cursor: wait; +} +.ccweb-prompt-secondary { + border: 1px solid rgba(221, 208, 192, 0.98); + background: rgba(255, 249, 242, 0.75); + color: var(--text-primary); +} +.ccweb-prompt-secondary:hover { + border-color: var(--accent); + background: var(--bg-primary); +} +.ccweb-prompt-error { + margin: 2px 18px 0; + color: var(--danger); + font-size: 13px; + font-weight: 800; +} +@media (max-width: 640px) { + .pending-ccweb-prompt { + grid-template-columns: 10px minmax(72px, 1fr) auto auto; + } + .pending-ccweb-prompt-title { + max-width: none; + } + .ccweb-prompt-card { + width: 100%; + } + .ccweb-prompt-header { + padding: 9px 10px; + } + .ccweb-prompt-header-actions { + gap: 5px; + } + .ccweb-prompt-desc { + padding: 9px 10px 0; + } + .ccweb-prompt-view-controls { + padding: 8px 10px 0; + } + .ccweb-prompt-view-switcher { + width: auto; + } + .ccweb-prompt-question-head { + align-items: flex-start; + } + .ccweb-prompt-tab-nav { + flex: 0 1 auto; + } + .ccweb-prompt-tab-nav-btn { + flex: 0 0 auto; + } + .ccweb-prompt-questions { + padding: 10px 10px 4px; + } + .ccweb-prompt-footer { + flex-wrap: wrap; + padding: 9px 10px 10px; + } + .ccweb-prompt-footer-actions { + flex: 1 1 auto; + margin-left: 0; + } + .ccweb-prompt-error { + margin-right: 10px; + margin-left: 10px; + } + .ccweb-prompt-submit, + .ccweb-prompt-secondary { + width: auto; + } +} .codex-approval-panel { max-width: 560px; } @@ -5460,8 +6028,10 @@ html[data-theme='coolvibe'] .settings-back:hover { :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .mode-select, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .chat-cwd, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-btn, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-outline-btn, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .reload-mcp-btn, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-item, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-back, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-nav-card, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-toggle-row, @@ -5551,7 +6121,8 @@ html[data-theme='coolvibe'] .settings-back:hover { :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .cmd-item:hover, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .cmd-item.active, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .file-browser-item:hover, -:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-item:hover { +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-item:hover, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt:hover { background: var(--accent-light); } diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 06a6d33..2e09f15 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -431,6 +431,8 @@ function completeMcpToolTurn(thread, turnId) { sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || null, hasCcwebMcpConfig: Boolean(ccwebConfig), hasProjectMcpConfig: Boolean(projectConfig), + ccwebCommand: ccwebConfig?.command || null, + ccwebArgs: ccwebConfig?.args || null, }; const itemBase = { id: itemId, @@ -798,6 +800,24 @@ function handleRequest(message) { } if (method === 'config/mcpServer/reload') { mcpReloadCount += 1; + send({ + method: 'mcpServer/startupStatus/updated', + params: { + server: 'ccweb', + state: 'starting', + message: 'ccweb MCP starting', + threadId: null, + }, + }); + send({ + method: 'mcpServer/startupStatus/updated', + params: { + name: 'ccweb', + status: 'ready', + message: 'ccweb MCP ready CC_WEB_MCP_TOKEN=mock-secret-token', + threadId: null, + }, + }); send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } }); return; } diff --git a/scripts/regression.js b/scripts/regression.js index 502dcae..93b8339 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -10,6 +10,7 @@ const WebSocket = require('ws'); const REPO_DIR = path.resolve(__dirname, '..'); const SERVER_PATH = path.join(REPO_DIR, 'server.js'); const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js'); +const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html'); const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js'); const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js'); const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js'); @@ -503,6 +504,7 @@ function assertFrontendGenerationControlsContract() { function assertFrontendComposerMcpContract() { const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); const requestStart = source.indexOf('function requestComposerSuggestions()'); const requestEnd = source.indexOf('\n function handleComposerSuggestions', requestStart); assert(requestStart >= 0 && requestEnd > requestStart, 'Frontend should define requestComposerSuggestions before handleComposerSuggestions'); @@ -520,13 +522,68 @@ function assertFrontendComposerMcpContract() { assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions'); const menuBlock = source.slice(menuStart, menuEnd); assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels'); + const selectStart = source.indexOf('function selectComposerItemByIndex(index)'); + const selectEnd = source.indexOf('\n function selectCmdMenuItem()', selectStart); + assert(selectStart >= 0 && selectEnd > selectStart, 'Frontend should define selectComposerItemByIndex before selectCmdMenuItem'); + const selectBlock = source.slice(selectStart, selectEnd); + assert(selectBlock.includes('const insertion = String(item.insertion || item.label || item.name || \'\');'), 'Composer should insert selected item text through the generic insertion path'); + assert(selectBlock.includes('const appendSpace = item.appendSpace !== false;'), 'Composer should honor appendSpace for generic MCP insertion'); + assert(!source.includes('function showCcwebPromptUserComposerModal'), 'Composer should not open a parameter builder for ccweb_prompt_user'); + assert(!source.includes('composer_mcp_tool_submit'), 'Frontend should not submit ccweb_prompt_user from slash composer as structured MCP args'); + assert(!serverSource.includes('composer_mcp_tool_submit'), 'Server should not accept slash-composer structured MCP tool submissions'); + assert(!source.includes('data-composer-mcp-questions'), 'Frontend should not render a slash-composer MCP argument builder'); + assert(!source.includes('data-option-field="recommended"'), 'Frontend should not render slash-composer MCP option editors'); assert(source.includes('function renderComposerMentionsStrip(meta)'), 'Frontend should define composer mention strip renderer'); assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container'); } +function assertMockCodexAppPromptUserNotTextTriggered() { + const source = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8'); + assert(!source.includes('codexapp runtime prompt mcp'), 'Mock Codex App should not expose a text-triggered ccweb_prompt_user path'); + assert(!source.includes('mcp-ccweb-prompt-user'), 'Regression should not depend on a mock ccweb_prompt_user tool call id'); +} + +function assertFrontendCcwebPromptContract() { + const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8'); + assert(source.includes('function createCcwebPromptElement(prompt, meta = {})'), 'Frontend should define ccweb prompt renderer'); + assert(source.includes("type: 'ccweb_prompt_user_response'"), 'Frontend should send ccweb prompt answers over WebSocket'); + assert(source.includes("type: 'ccweb_prompt_user_dismiss'"), 'Frontend should send ccweb prompt dismiss requests over WebSocket'); + assert(source.includes("case 'ccweb_prompt_user_update':"), 'Frontend should handle ccweb prompt status updates'); + assert(source.includes("case 'ccweb_prompt_user_remove':"), 'Frontend should remove submitted ccweb prompt bubbles'); + assert(source.includes('applyCcwebPromptUserUpdate(msg);'), 'Frontend should apply ccweb prompt updates to cached messages and DOM'); + assert(source.includes('removeCcwebPromptMessageFromSnapshot'), 'Frontend should remove submitted prompt messages from cached snapshots'); + assert(source.includes('renderPendingCcwebPrompts'), 'Frontend should render pending ccweb prompt reminders'); + assert(indexSource.includes('id="ccweb-prompt-outline-btn"') && indexSource.includes('class="ccweb-prompt-outline-anchor" hidden'), 'Frontend should expose a hidden ccweb prompt outline button'); + assert(source.includes('toggleCcwebPromptOutlinePanel') && source.includes('ccwebPromptOutlineBtn.dataset.count'), 'Frontend should render pending forms behind a compact outline button'); + assert(source.includes('dismissCcwebPrompt'), 'Frontend should allow users to ignore pending ccweb prompts'); + assert(source.includes('CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY'), 'Frontend should persist ccweb prompt view mode'); + assert(source.includes("className = 'ccweb-prompt-tabs'"), 'Frontend should render ccweb prompt tabs for multi-question forms'); + assert(source.includes("className = 'pending-ccweb-prompt-dismiss'"), 'Frontend should render a compact dismiss action for pending prompts'); + assert(source.includes('card.dataset.viewMode = normalized'), 'Frontend should switch ccweb prompt card view mode'); + assert(source.includes('m.ccwebPrompt'), 'Message rebuild should render persisted ccweb prompt messages'); + assert(source.includes("className = 'ccweb-prompt-answer'"), 'Each ccweb prompt question should expose an editable answer textarea'); +} + +function assertFrontendMcpReloadContract() { + const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + assert(source.includes('function mcpStartupStatusToastText(status)'), 'Frontend should format MCP startup status toast text'); + assert(source.includes("payload.status && typeof payload.status === 'object'"), 'Frontend should preserve plain MCP status summary objects'); + assert(source.includes('data.mcpStatus'), 'Frontend reload button should consume reload-mcp mcpStatus payload'); + assert(source.includes("case 'mcp_startup_status':"), 'Frontend should handle pushed MCP startup status updates'); + assert(source.includes('showMcpStartupStatusToast'), 'Frontend should show explicit MCP startup status toasts'); + assert(source.includes('notifyReady: true'), 'Frontend should only show ready MCP startup toasts for explicit reload actions'); + assert(source.includes("state === 'ready' && !options.notifyReady"), 'Frontend should suppress background ready MCP startup toasts'); + assert(source.includes('MCP 已启动'), 'Frontend should expose a ready toast for ccweb MCP'); + assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast'); +} + async function main() { assertFrontendGenerationControlsContract(); assertFrontendComposerMcpContract(); + assertFrontendCcwebPromptContract(); + assertMockCodexAppPromptUserNotTextTriggered(); + assertFrontendMcpReloadContract(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); const configDir = path.join(tempRoot, 'config'); @@ -1125,6 +1182,7 @@ async function main() { .split('\n') .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(crossTargetSession.sessionId.slice(0, 8))); assert(mcpSpawnLine && mcpSpawnLine.includes('mcp_servers.ccweb.command') && mcpSpawnLine.includes('mcp_servers.ccweb.env_vars'), 'Codex spawn should inject ccweb MCP config'); + assert(mcpSpawnLine.includes('server.js') && mcpSpawnLine.includes('--ccweb-mcp-server'), 'Codex spawn should launch ccweb MCP through server.js in Node mode'); assert(!mcpSpawnLine.includes(internalMcpToken), 'Codex spawn log should not expose internal MCP token'); const projectMcpSpawnLine = processLogAfterMcp .trim() @@ -1442,6 +1500,15 @@ async function main() { const reloadMcpResult = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`); assert(reloadMcpResult.sessionId === codexAppSession.sessionId, 'Codex App MCP reload should return the target session id'); assert(reloadMcpResult.result?.reloaded === true, 'Codex App MCP reload should call app-server config/mcpServer/reload'); + assert(reloadMcpResult.mcpStatus?.server === 'ccweb', 'Codex App MCP reload should return ccweb server startup status'); + assert(reloadMcpResult.mcpStatus?.status === 'ready', 'Codex App MCP reload should surface ready startup status from app-server notification'); + assert(reloadMcpResult.mcpStatus?.hasStartupStatus === true, 'Codex App MCP reload should distinguish real startupStatus from pending fallback'); + const reloadMcpStatusText = JSON.stringify(reloadMcpResult.mcpStatus); + assert(/CC_WEB_MCP_TOKEN=\[redacted\]/.test(reloadMcpStatusText), 'Codex App MCP reload status should redact token-looking values'); + assert(!reloadMcpStatusText.includes('mock-secret-token'), 'Codex App MCP reload status should not leak raw token-looking values'); + storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); + assert(storedCodexApp.codexAppMcpStartupStatus?.servers?.ccweb?.status === 'ready', 'Codex App MCP startup status should be persisted on the session'); + assert(!JSON.stringify(storedCodexApp.codexAppMcpStartupStatus).includes('mock-secret-token'), 'Persisted MCP startup status should not leak raw token-looking values'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list'); @@ -1449,8 +1516,149 @@ async function main() { assert(/currentConversationId/.test(codexAppDynamicTool.result || ''), 'Codex App MCP tool should return ccweb conversation data'); assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config'); assert(/"hasProjectMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass project MCP config from session cwd'); + assert(/server\.js/.test(codexAppDynamicTool.result || '') && /--ccweb-mcp-server/.test(codexAppDynamicTool.result || ''), 'Codex App ccweb MCP config should launch through server.js in Node mode'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-empty-slash-prompt-user-mcp', trigger: '/', query: '', sessionId: codexAppSession.sessionId, agent: 'codexapp' })); + const codexAppEmptySlashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-empty-slash-prompt-user-mcp'); + const emptySlashPromptUserIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user'); + const firstOtherCcwebMcpIndex = codexAppEmptySlashMcpComposer.items.findIndex((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name !== 'ccweb_prompt_user'); + assert(emptySlashPromptUserIndex >= 0, 'Codex App empty slash composer should include ccweb_prompt_user'); + assert(firstOtherCcwebMcpIndex < 0 || emptySlashPromptUserIndex < firstOtherCcwebMcpIndex, 'ccweb_prompt_user should be pinned before other ccweb MCP tools'); + + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-prompt-user-mcp', trigger: '/', query: 'prompt_user', sessionId: codexAppSession.sessionId, agent: 'codexapp' })); + const codexAppPromptUserMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-prompt-user-mcp'); + const promptUserComposerItem = codexAppPromptUserMcpComposer.items.find((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user'); + assert(promptUserComposerItem, 'Codex App composer should show ccweb_prompt_user when ccweb MCP is runtime-configured'); + assert(promptUserComposerItem.itemType === 'tool', 'ccweb_prompt_user composer item should be a normal MCP tool suggestion'); + assert(!promptUserComposerItem.action, 'ccweb_prompt_user composer item should not declare a form action'); + assert(promptUserComposerItem.insertion === 'mcp:ccweb/ccweb_prompt_user', 'ccweb_prompt_user composer item should insert the MCP mention text'); + assert(promptUserComposerItem.appendSpace === true, 'ccweb_prompt_user composer item should append a space like other MCP suggestions'); + + const promptUserResult = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_prompt_user', + sourceSessionId: codexAppSession.sessionId, + sourceHopCount: 0, + args: { + title: '确认实现方案', + description: '回归测试多问题表单', + questions: [ + { + id: 'ui_choice', + title: '交互方式', + question: '用哪种交互方式?', + required: true, + options: [ + { id: 'fineui', label: 'FineUI 弹窗', recommended: true, answerText: '使用 FineUI 弹窗。' }, + { id: 'prompt', label: '浏览器 prompt', answerText: '使用浏览器 prompt。' }, + ], + answerPlaceholder: '填写方案', + }, + { + id: 'button_id', + title: '按钮 ID', + question: '确认按钮 ID 是什么?', + required: true, + options: [ + { id: 'confirm', label: 'btnShortageReleaseConfirm', recommended: true, answerText: '按钮 ID 固定为 btnShortageReleaseConfirm。' }, + ], + }, + ], + }, + }); + assert(promptUserResult.status === 200 && promptUserResult.body?.ok, `MCP prompt user should render: ${JSON.stringify(promptUserResult.body)}`); + assert(promptUserResult.body.status === 'rendered', 'MCP prompt user should return rendered status without waiting for user input'); + assert(promptUserResult.body.questionCount === 2, 'MCP prompt user should preserve multiple questions'); + const promptRendered = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_message' && + msg.sessionId === codexAppSession.sessionId && + msg.message?.ccwebPrompt?.id === promptUserResult.body.promptId + )); + assert(promptRendered.message.ccwebPrompt.status === 'pending', 'Prompt message should start pending'); + assert(promptRendered.message.ccwebPrompt.questions?.length === 2, 'Prompt message should carry all questions to the UI'); + assert(promptRendered.message.ccwebPrompt.questions[0]?.options?.some((option) => option.id === 'fineui' && option.recommended === true), 'Prompt message should preserve recommended options'); + + const promptDismissResult = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_prompt_user', + sourceSessionId: codexAppSession.sessionId, + sourceHopCount: 0, + args: { + title: '可忽略表单', + questions: [ + { + id: 'dismiss_choice', + title: '是否忽略', + question: '这个表单会被忽略删除。', + required: false, + options: [ + { id: 'skip', label: '忽略', answerText: '忽略这个表单。' }, + ], + }, + ], + }, + }); + assert(promptDismissResult.status === 200 && promptDismissResult.body?.ok, 'Dismissable MCP prompt should render'); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_message' && + msg.sessionId === codexAppSession.sessionId && + msg.message?.ccwebPrompt?.id === promptDismissResult.body.promptId + )); + ws.send(JSON.stringify({ + type: 'ccweb_prompt_user_dismiss', + sessionId: codexAppSession.sessionId, + promptId: promptDismissResult.body.promptId, + })); + const promptDismissed = await nextMessage(messages, ws, (msg) => ( + msg.type === 'ccweb_prompt_user_remove' && + msg.sessionId === codexAppSession.sessionId && + msg.promptId === promptDismissResult.body.promptId + )); + assert(promptDismissed.reason === 'dismissed', 'Dismissed prompt remove event should carry dismissed reason'); + storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); + assert(!storedCodexApp.messages.some((message) => message.ccwebPrompt?.id === promptDismissResult.body.promptId), 'Dismissed prompt message should be removed from session history'); + + ws.send(JSON.stringify({ + type: 'ccweb_prompt_user_response', + sessionId: codexAppSession.sessionId, + promptId: promptUserResult.body.promptId, + answers: { + ui_choice: { + selectedOptionIds: ['fineui'], + answerText: '使用 FineUI 弹窗,方便固定按钮 ID。', + }, + button_id: { + selectedOptionIds: ['confirm'], + answerText: '按钮 ID 固定为 btnShortageReleaseConfirm。', + }, + }, + })); + const promptSubmitted = await nextMessage(messages, ws, (msg) => ( + msg.type === 'ccweb_prompt_user_remove' && + msg.sessionId === codexAppSession.sessionId && + msg.promptId === promptUserResult.body.promptId + )); + assert(promptSubmitted.prompt?.status === 'submitted', 'Prompt remove event should carry submitted status'); + assert(promptSubmitted.prompt?.answers?.ui_choice?.selectedOptionLabels?.[0] === 'FineUI 弹窗', 'Prompt remove event should include selected option labels'); + const promptAnswerMessage = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_message' && + msg.sessionId === codexAppSession.sessionId && + msg.message?.role === 'user' && + /我已回答 ccweb 提示的问题/.test(msg.message.content || '') && + /btnShortageReleaseConfirm/.test(msg.message.content || '') + )); + assert(/使用 FineUI 弹窗,方便固定按钮 ID。/.test(promptAnswerMessage.message.content || ''), 'Prompt submission should become a normal user message with the free-form answer'); + const promptAnswerDelta = await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && + msg.sessionId === codexAppSession.sessionId && + /btnShortageReleaseConfirm/.test(msg.text || '') + )); + assert(/我已回答 ccweb 提示的问题/.test(promptAnswerDelta.text || ''), 'Prompt submission should trigger a Codex App turn with the answer text'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); + const storedPromptMessage = storedCodexApp.messages.find((message) => message.ccwebPrompt?.id === promptUserResult.body.promptId); + assert(!storedPromptMessage, 'Submitted prompt message should be removed from session history'); + assert(storedCodexApp.messages.some((message) => message.role === 'user' && /btnShortageReleaseConfirm/.test(String(message.content || ''))), 'Prompt response user message should persist in session history'); + ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const ccwebMcpChildRunning = await nextMessage(messages, ws, (msg) => msg.type === 'ccweb_mcp_child_agent_update' && @@ -1542,7 +1750,7 @@ async function main() { ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning)); - await sleep(150); + await sleep(500); ws.send(JSON.stringify({ type: 'message', text: 'runtime steer insert', diff --git a/server.js b/server.js index 9b42e4e..dbb5b8b 100644 --- a/server.js +++ b/server.js @@ -41,6 +41,16 @@ const IS_BUN_SINGLE_EXECUTABLE = !!process.versions?.bun const CCWEB_MCP_SERVER_ARG = '--ccweb-mcp-server'; const CODEX_APP_WORKER_ARG = '--codex-app-worker'; +function ccwebMcpServerCommandSpec() { + if (IS_BUN_SINGLE_EXECUTABLE) { + return { command: process.execPath, args: [CCWEB_MCP_SERVER_ARG] }; + } + return { + command: process.execPath, + args: [path.join(APP_DIR, 'server.js'), CCWEB_MCP_SERVER_ARG], + }; +} + // Load .env const envPath = path.join(APP_DIR, '.env'); if (fs.existsSync(envPath)) { @@ -645,6 +655,15 @@ const activeCodexAppTurns = new Map(); // ccweb MCP child agents tracked from Codex App native collaboration mode: // childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state } const ccwebMcpChildThreads = new Map(); +const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated'; +const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb'; +const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200; +const CODEX_APP_MCP_RELOAD_TRACK_MS = 15000; +const codexAppMcpStartupStatusByServer = new Map(); +// sessionId -> { threadId, requestedAt, expiresAt, reloadRequestId } +const pendingCodexAppMcpReloads = new Map(); +// sessionId -> Set<{ requestedAt, timer, resolve }> +const codexAppMcpStatusWaiters = new Map(); // 等待目标对话完成后回传给来源对话的跨对话请求:requestId -> metadata const pendingCrossConversationReplies = new Map(); // Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer } @@ -670,6 +689,14 @@ let MODEL_MAP = { const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']); const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']); const MCP_CONVERSATION_TITLE_MAX_CHARS = 120; +const MCP_PROMPT_TITLE_MAX_CHARS = 160; +const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000; +const MCP_PROMPT_QUESTION_MAX_COUNT = 10; +const MCP_PROMPT_OPTION_MAX_COUNT = 8; +const MCP_PROMPT_QUESTION_MAX_CHARS = 4000; +const MCP_PROMPT_OPTION_MAX_CHARS = 1000; +const MCP_PROMPT_ANSWER_MAX_CHARS = 4000; +const MCP_PROMPT_RESPONSE_MAX_CHARS = 20000; // Codex 默认模型优先读取 ~/.codex/config.toml,缺失时再回退到旧默认值。 const FALLBACK_CODEX_MODEL = 'gpt-5.4'; @@ -2272,6 +2299,7 @@ function summarizeSkillForMention(skill, configuredMcpNames = new Set()) { function buildCcwebMcpRuntimeConfig(session, options = {}) { const env = codexAppCcwebMcpEnv(session, options); if (!env) return null; + const commandSpec = ccwebMcpServerCommandSpec(); return { server: 'ccweb', name: 'ccweb', @@ -2279,8 +2307,8 @@ function buildCcwebMcpRuntimeConfig(session, options = {}) { type: 'stdio', description: 'ccweb 内置 MCP server,可用于跨会话协作。', config: { - command: process.execPath, - args: [CCWEB_MCP_SERVER_ARG], + command: commandSpec.command, + args: commandSpec.args, env, startup_timeout_sec: 10, tool_timeout_sec: 60, @@ -2340,6 +2368,7 @@ function listComposerMcpItems(options = {}) { server: 'ccweb', source: 'mcp:ccweb', itemType: 'tool', + action: '', }); } } @@ -2418,6 +2447,8 @@ function listComposerSuggestions(trigger, query, sessionId, agent, session = nul const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : []; if (trigger === '/') { const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, session, agent }), query); + const promptUserMcpItems = mcpItems.filter((item) => item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user'); + const otherMcpItems = mcpItems.filter((item) => !(item.kind === 'mcp' && item.server === 'ccweb' && item.name === 'ccweb_prompt_user')); const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({ kind: 'command', name: cmd.name, @@ -2425,7 +2456,7 @@ function listComposerSuggestions(trigger, query, sessionId, agent, session = nul description: cmd.description, insertion: cmd.insertion, })), query.replace(/^\//, '')); - return mergeComposerSuggestionGroups(commands, mcpItems); + return mergeComposerSuggestionGroups(commands, promptUserMcpItems, otherMcpItems); } if (trigger === '$') { const skills = filterComposerItems(skillItems, query); @@ -2850,6 +2881,344 @@ function getRuntimeSessionId(session) { return session.claudeSessionId || null; } +function mcpStatusObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : null; +} + +function firstPresentValue(...values) { + for (const value of values) { + if (value === undefined || value === null) continue; + if (typeof value === 'string' && !value.trim()) continue; + return value; + } + return null; +} + +function redactMcpStatusText(text) { + return String(text || '') + .replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]') + .replace(/\b((?:[A-Z0-9_]*_)?(?:TOKEN|API_KEY|SECRET|PASSWORD|AUTHORIZATION))\b\s*[:=]\s*["']?[^"',\s}]+/gi, '$1=[redacted]') + .replace(/("(?:[^"]*(?:token|api[_-]?key|secret|password|authorization)[^"]*)"\s*:\s*)"[^"]*"/gi, '$1"[redacted]"'); +} + +function safeMcpStatusString(value, maxChars = 500) { + if (value === undefined || value === null) return ''; + let text = ''; + if (typeof value === 'string') { + text = value; + } else if (typeof value === 'number' || typeof value === 'boolean') { + text = String(value); + } else if (mcpStatusObject(value)) { + text = firstPresentValue(value.message, value.error, value.reason, value.detail); + if (text === null) { + try { + text = JSON.stringify(value); + } catch { + text = String(value); + } + } + } else { + text = String(value); + } + const redacted = redactMcpStatusText(text).trim(); + if (!redacted) return ''; + return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}...` : redacted; +} + +function normalizeCodexAppMcpStartupStatus(value) { + const raw = safeMcpStatusString(value, 80).toLowerCase(); + if (!raw) return 'unknown'; + if (/^(ready|running|ok|success|succeeded|started|available)$/.test(raw)) return 'ready'; + if (/^(starting|pending|loading|initializing|launching|connecting)$/.test(raw)) return 'starting'; + if (/^(failed|failure|error|errored|crashed)$/.test(raw)) return 'failed'; + if (/^(cancelled|canceled|disabled|stopped)$/.test(raw)) return 'cancelled'; + return raw; +} + +function codexAppMcpStatusKey(name) { + return safeMcpStatusString(name || CODEX_APP_MCP_DEFAULT_SERVER, 120).toLowerCase() || CODEX_APP_MCP_DEFAULT_SERVER; +} + +function publicCodexAppMcpStatusRecord(record = {}) { + const name = safeMcpStatusString(record.name || record.server || CODEX_APP_MCP_DEFAULT_SERVER, 120) || CODEX_APP_MCP_DEFAULT_SERVER; + const status = normalizeCodexAppMcpStartupStatus(record.status || record.state || 'unknown'); + return { + server: name, + name, + status, + rawStatus: safeMcpStatusString(record.rawStatus || record.status || status, 80) || status, + message: safeMcpStatusString(record.message || record.error || '', 500), + threadId: safeMcpStatusString(record.threadId || '', 160) || null, + updatedAt: safeMcpStatusString(record.updatedAt || new Date().toISOString(), 80), + source: safeMcpStatusString(record.source || 'notification', 40) || 'notification', + }; +} + +function parseCodexAppMcpStartupStatus(params = {}) { + const direct = mcpStatusObject(params) || {}; + const statusObject = mcpStatusObject(direct.status) || mcpStatusObject(direct.startupStatus) || mcpStatusObject(direct.serverStatus) || {}; + const serverObject = mcpStatusObject(direct.server) || mcpStatusObject(direct.mcpServer) || mcpStatusObject(statusObject.server) || {}; + const name = safeMcpStatusString(firstPresentValue( + direct.name, + direct.serverName, + direct.mcpServerName, + typeof direct.server === 'string' ? direct.server : null, + typeof direct.mcpServer === 'string' ? direct.mcpServer : null, + serverObject.name, + serverObject.server, + statusObject.name, + statusObject.server, + statusObject.serverName + ), 120); + const rawStatus = firstPresentValue( + typeof direct.status === 'string' ? direct.status : null, + direct.state, + direct.startupState, + statusObject.status, + statusObject.state, + statusObject.startupState, + direct.ready === true ? 'ready' : null, + direct.ok === false ? 'failed' : null + ); + const threadId = safeMcpStatusString(firstPresentValue( + direct.threadId, + direct.thread?.id, + statusObject.threadId, + statusObject.thread?.id + ), 160) || null; + if (!name && rawStatus === null && !threadId) return null; + const status = normalizeCodexAppMcpStartupStatus(rawStatus); + return publicCodexAppMcpStatusRecord({ + name: name || CODEX_APP_MCP_DEFAULT_SERVER, + status, + rawStatus: rawStatus || status, + message: firstPresentValue( + direct.message, + direct.error, + direct.reason, + direct.detail, + statusObject.message, + statusObject.error, + statusObject.reason, + statusObject.detail + ), + threadId, + updatedAt: new Date().toISOString(), + source: 'notification', + }); +} + +function ensureCodexAppMcpStartupState(session) { + if (!session || typeof session !== 'object') return null; + if (!mcpStatusObject(session.codexAppMcpStartupStatus)) { + session.codexAppMcpStartupStatus = {}; + } + const state = session.codexAppMcpStartupStatus; + if (!mcpStatusObject(state.servers)) state.servers = {}; + return state; +} + +function findCodexAppMcpStatusRecord(state, serverName = CODEX_APP_MCP_DEFAULT_SERVER) { + const servers = mcpStatusObject(state?.servers) || {}; + const key = codexAppMcpStatusKey(serverName); + if (servers[key]) return publicCodexAppMcpStatusRecord(servers[key]); + return Object.values(servers) + .map((record) => publicCodexAppMcpStatusRecord(record)) + .find((record) => codexAppMcpStatusKey(record.name) === key) || null; +} + +function buildCodexAppMcpStatusSummary(session, options = {}) { + const state = mcpStatusObject(session?.codexAppMcpStartupStatus) || {}; + const serversObject = mcpStatusObject(state.servers) || {}; + const servers = Object.values(serversObject).map((record) => publicCodexAppMcpStatusRecord(record)); + let current = findCodexAppMcpStatusRecord(state, options.serverName || CODEX_APP_MCP_DEFAULT_SERVER); + const reloadRequestedAt = safeMcpStatusString(options.reloadRequestedAt || state.reloadRequestedAt || '', 80) || null; + if (!current) { + const threadId = safeMcpStatusString(state.threadId || getRuntimeSessionId(session) || '', 160) || null; + current = publicCodexAppMcpStatusRecord({ + name: options.serverName || CODEX_APP_MCP_DEFAULT_SERVER, + status: reloadRequestedAt ? 'pending' : 'unknown', + rawStatus: reloadRequestedAt ? 'pending' : 'unknown', + message: reloadRequestedAt ? '已请求重载,等待 app-server 上报启动状态' : '尚未收到 app-server 启动状态', + threadId, + updatedAt: reloadRequestedAt || new Date().toISOString(), + source: reloadRequestedAt ? 'pending' : 'unknown', + }); + } + return { + ...current, + reloadRequestedAt, + reloadRequestId: safeMcpStatusString(state.reloadRequestId || '', 80) || null, + hasStartupStatus: current.source === 'notification', + servers, + }; +} + +function markCodexAppMcpReloadPending(session, sessionId) { + const requestedAt = new Date().toISOString(); + const threadId = getRuntimeSessionId(session) || null; + const reloadRequestId = crypto.randomUUID(); + const state = ensureCodexAppMcpStartupState(session); + if (!state) return { requestedAt, summary: null }; + state.reloadRequestedAt = requestedAt; + state.reloadRequestId = reloadRequestId; + state.updatedAt = requestedAt; + state.threadId = threadId; + state.servers[codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER)] = publicCodexAppMcpStatusRecord({ + name: CODEX_APP_MCP_DEFAULT_SERVER, + status: 'pending', + rawStatus: 'pending', + message: '已请求重载,等待 app-server 上报启动状态', + threadId, + updatedAt: requestedAt, + source: 'pending', + }); + pendingCodexAppMcpReloads.set(sessionId, { + threadId, + requestedAt, + expiresAt: Date.now() + CODEX_APP_MCP_RELOAD_TRACK_MS, + reloadRequestId, + }); + saveSession(session); + return { + requestedAt, + summary: buildCodexAppMcpStatusSummary(session, { reloadRequestedAt: requestedAt }), + }; +} + +function cleanupExpiredCodexAppMcpReloads() { + const now = Date.now(); + for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) { + if ((pending.expiresAt || 0) <= now) pendingCodexAppMcpReloads.delete(sessionId); + } +} + +function isFinalCodexAppMcpStatus(status) { + return status === 'ready' || status === 'failed' || status === 'cancelled'; +} + +function isFreshCodexAppMcpSummary(summary, requestedAt) { + if (!summary || summary.source !== 'notification') return false; + if (!requestedAt) return true; + const updated = Date.parse(summary.updatedAt || ''); + const requested = Date.parse(requestedAt || ''); + if (!Number.isFinite(updated) || !Number.isFinite(requested)) return true; + return updated >= requested; +} + +function resolveCodexAppMcpStatusWaiters(sessionId, summary) { + const waiters = codexAppMcpStatusWaiters.get(sessionId); + if (!waiters || waiters.size === 0) return; + for (const waiter of Array.from(waiters)) { + if (!isFreshCodexAppMcpSummary(summary, waiter.requestedAt) || !isFinalCodexAppMcpStatus(summary.status)) continue; + clearTimeout(waiter.timer); + waiters.delete(waiter); + waiter.resolve(summary); + } + if (waiters.size === 0) codexAppMcpStatusWaiters.delete(sessionId); +} + +function waitForCodexAppMcpStatusAfterReload(sessionId, requestedAt, timeoutMs = CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS) { + const current = buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt: requestedAt }); + if (isFreshCodexAppMcpSummary(current, requestedAt) && isFinalCodexAppMcpStatus(current.status)) { + return Promise.resolve(current); + } + return new Promise((resolve) => { + const waiter = { + requestedAt, + resolve, + timer: setTimeout(() => { + const waiters = codexAppMcpStatusWaiters.get(sessionId); + if (waiters) { + waiters.delete(waiter); + if (waiters.size === 0) codexAppMcpStatusWaiters.delete(sessionId); + } + resolve(buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt: requestedAt })); + }, timeoutMs), + }; + let waiters = codexAppMcpStatusWaiters.get(sessionId); + if (!waiters) { + waiters = new Set(); + codexAppMcpStatusWaiters.set(sessionId, waiters); + } + waiters.add(waiter); + }); +} + +function updateCodexAppSessionMcpStatus(sessionId, statusRecord) { + const normalizedId = sanitizeId(sessionId || ''); + if (!normalizedId) return null; + const session = loadSession(normalizedId); + if (!session || !isCodexAppSession(session)) return null; + const state = ensureCodexAppMcpStartupState(session); + if (!state) return null; + const record = publicCodexAppMcpStatusRecord({ + ...statusRecord, + threadId: statusRecord.threadId || state.threadId || getRuntimeSessionId(session) || null, + source: 'notification', + }); + const key = codexAppMcpStatusKey(record.name); + state.servers[key] = record; + state.updatedAt = record.updatedAt; + state.threadId = record.threadId || state.threadId || null; + if (key === codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER) && isFinalCodexAppMcpStatus(record.status)) { + pendingCodexAppMcpReloads.delete(normalizedId); + } + saveSession(session); + const summary = buildCodexAppMcpStatusSummary(session); + resolveCodexAppMcpStatusWaiters(normalizedId, summary); + sendSessionEventToViewers(normalizedId, { + type: 'mcp_startup_status', + sessionId: normalizedId, + status: summary, + mcpStatus: summary, + }); + return summary; +} + +function markCodexAppMcpReloadFailed(sessionId, message) { + return updateCodexAppSessionMcpStatus(sessionId, { + name: CODEX_APP_MCP_DEFAULT_SERVER, + status: 'failed', + rawStatus: 'failed', + message, + updatedAt: new Date().toISOString(), + source: 'notification', + }); +} + +function codexAppMcpStatusTargetSessionIds(statusRecord, routed) { + cleanupExpiredCodexAppMcpReloads(); + const targetSessionIds = new Set(); + if (routed?.sessionId) targetSessionIds.add(routed.sessionId); + for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) { + if (statusRecord.threadId && pending.threadId && statusRecord.threadId !== pending.threadId) continue; + targetSessionIds.add(sessionId); + } + return targetSessionIds; +} + +function handleCodexAppMcpStartupStatusNotification(notification, routed) { + if (notification?.method !== CODEX_APP_MCP_STARTUP_STATUS_METHOD) return false; + const statusRecord = parseCodexAppMcpStartupStatus(notification.params || {}); + if (!statusRecord) { + plog('WARN', 'codex_app_mcp_startup_status_unparsed', { method: notification.method }); + return true; + } + codexAppMcpStartupStatusByServer.set(codexAppMcpStatusKey(statusRecord.name), statusRecord); + const targetSessionIds = codexAppMcpStatusTargetSessionIds(statusRecord, routed); + for (const sessionId of targetSessionIds) { + updateCodexAppSessionMcpStatus(sessionId, statusRecord); + } + plog('INFO', 'codex_app_mcp_startup_status_updated', { + server: statusRecord.name, + status: statusRecord.status, + threadId: statusRecord.threadId, + targetSessions: targetSessionIds.size, + }); + return true; +} + async function handleReloadMcpApi(req, res, rawSessionId) { const token = extractBearerToken(req); if (!token || !activeTokens.has(token)) { @@ -2874,6 +3243,7 @@ async function handleReloadMcpApi(req, res, rawSessionId) { }); } + let reloadRequestedAt = null; try { const clientResult = getCodexAppClient(); if (clientResult.error) { @@ -2886,13 +3256,17 @@ async function handleReloadMcpApi(req, res, rawSessionId) { const client = clientResult.client; await client.start(); + const pendingMcp = markCodexAppMcpReloadPending(session, sessionId); + reloadRequestedAt = pendingMcp.requestedAt; const result = typeof client.reloadMcpServers === 'function' ? await client.reloadMcpServers() : await client.request('config/mcpServer/reload', {}, 30000); + const mcpStatus = await waitForCodexAppMcpStatusAfterReload(sessionId, reloadRequestedAt); plog('INFO', 'codex_app_mcp_reload_requested', { sessionId: sessionId.slice(0, 8), threadId: getRuntimeSessionId(session) || null, + status: mcpStatus?.status || pendingMcp.summary?.status || 'pending', }); return jsonResponse(res, 200, { @@ -2900,15 +3274,21 @@ async function handleReloadMcpApi(req, res, rawSessionId) { sessionId, threadId: getRuntimeSessionId(session) || null, result: result || {}, + mcpStatus: mcpStatus || pendingMcp.summary || buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt }), }); } catch (err) { const unsupported = err?.code === -32601 || /not found|unknown|unsupported|method/i.test(String(err?.message || '')); + const message = unsupported + ? `当前 Codex app-server 不支持重载 MCP,请重启 Codex App。${err?.message ? `(${err.message})` : ''}` + : `重载 MCP 失败: ${err?.message || err}`; + const mcpStatus = reloadRequestedAt + ? markCodexAppMcpReloadFailed(sessionId, message) + : buildCodexAppMcpStatusSummary(session); return jsonResponse(res, unsupported ? 501 : 500, { ok: false, code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed', - message: unsupported - ? `当前 Codex app-server 不支持重载 MCP,请重启 Codex App。${err?.message ? `(${err.message})` : ''}` - : `重载 MCP 失败: ${err?.message || err}`, + message, + mcpStatus, }); } } @@ -3938,6 +4318,18 @@ function findViewingSessionWs(sessionId) { return null; } +function sendSessionEventToViewers(sessionId, payload) { + const normalizedId = sanitizeId(sessionId || ''); + if (!normalizedId) return 0; + let sent = 0; + for (const [client, viewedSessionId] of wsSessionMap.entries()) { + if (viewedSessionId !== normalizedId || client?.readyState !== 1) continue; + wsSend(client, payload); + sent += 1; + } + return sent; +} + function getInternalMcpRequestToken(req) { return String(req.headers['x-cc-web-mcp-token'] || '').trim() || extractBearerToken(req); } @@ -4003,6 +4395,228 @@ function listConversationSummaries(args = {}, sourceSessionId = '') { }; } +function normalizeMcpPromptText(value, maxChars) { + if (value === null || value === undefined) return ''; + return truncateTextValue(String(value).trim(), maxChars, '...'); +} + +function uniqueMcpPromptId(value, fallback, used) { + const base = normalizeMcpPromptText(value, 80) + .replace(/\s+/g, '_') + .replace(/[^\w.-]/g, '') + || fallback; + let id = base; + let index = 2; + while (used.has(id)) { + id = `${base}_${index}`; + index += 1; + } + used.add(id); + return id; +} + +function normalizeMcpPromptOption(rawOption, index, usedIds) { + const raw = rawOption && typeof rawOption === 'object' ? rawOption : {}; + const label = normalizeMcpPromptText(raw.label ?? raw.title ?? raw.value ?? raw.answerText, 240); + const answerText = normalizeMcpPromptText(raw.answerText ?? raw.answer ?? label, MCP_PROMPT_ANSWER_MAX_CHARS); + if (!label && !answerText) return null; + const id = uniqueMcpPromptId(raw.id ?? raw.value ?? label, `option_${index + 1}`, usedIds); + return { + id, + label: label || answerText.slice(0, 80) || `选项 ${index + 1}`, + description: normalizeMcpPromptText(raw.description ?? raw.desc, MCP_PROMPT_OPTION_MAX_CHARS), + answerText, + recommended: raw.recommended === true, + }; +} + +function normalizeMcpPromptQuestion(rawQuestion, index, usedIds) { + const raw = rawQuestion && typeof rawQuestion === 'object' ? rawQuestion : {}; + const title = normalizeMcpPromptText(raw.title ?? raw.header, MCP_PROMPT_TITLE_MAX_CHARS); + const question = normalizeMcpPromptText(raw.question ?? raw.prompt ?? raw.text, MCP_PROMPT_QUESTION_MAX_CHARS); + if (!title && !question) return null; + const id = uniqueMcpPromptId(raw.id, `question_${index + 1}`, usedIds); + const rawOptions = Array.isArray(raw.options) ? raw.options : []; + const optionIds = new Set(); + const options = rawOptions + .slice(0, MCP_PROMPT_OPTION_MAX_COUNT) + .map((option, optionIndex) => normalizeMcpPromptOption(option, optionIndex, optionIds)) + .filter(Boolean); + const mode = String(raw.selectionMode || raw.mode || '').trim(); + const selectionMode = mode === 'multi' || mode === 'multiple' + ? 'multi' + : mode === 'none' || options.length === 0 + ? 'none' + : 'single'; + return { + id, + title: title || `问题 ${index + 1}`, + question, + required: raw.required !== false, + selectionMode, + answerPlaceholder: normalizeMcpPromptText(raw.answerPlaceholder ?? raw.placeholder, 240), + defaultAnswer: normalizeMcpPromptText(raw.defaultAnswer ?? raw.answerText, MCP_PROMPT_ANSWER_MAX_CHARS), + options, + }; +} + +function normalizeCcwebPromptUserArgs(args = {}) { + const rawQuestions = Array.isArray(args.questions) ? args.questions : []; + const usedQuestionIds = new Set(); + const questions = rawQuestions + .slice(0, MCP_PROMPT_QUESTION_MAX_COUNT) + .map((question, index) => normalizeMcpPromptQuestion(question, index, usedQuestionIds)) + .filter(Boolean); + if (questions.length === 0) { + return mcpToolError('missing_questions', 'ccweb_prompt_user 需要至少一个有效问题。'); + } + return { + ok: true, + title: normalizeMcpPromptText(args.title, MCP_PROMPT_TITLE_MAX_CHARS) || '需要用户确认', + description: normalizeMcpPromptText(args.description ?? args.instructions, MCP_PROMPT_DESCRIPTION_MAX_CHARS), + questions, + }; +} + +function createCcwebPromptUser(args = {}, sourceSessionId = '') { + const sourceId = sanitizeId(sourceSessionId || ''); + if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。'); + const session = loadSession(sourceId); + if (!session) return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId }); + + const normalized = normalizeCcwebPromptUserArgs(args); + if (!normalized.ok) return normalized; + + const now = new Date().toISOString(); + const promptId = crypto.randomUUID(); + const prompt = { + id: promptId, + status: 'pending', + title: normalized.title, + description: normalized.description, + questions: normalized.questions, + answers: {}, + createdAt: now, + submittedAt: null, + }; + const message = { + role: 'assistant', + content: '', + timestamp: now, + ccwebPrompt: prompt, + }; + + session.messages = Array.isArray(session.messages) ? session.messages : []; + session.messages.push(message); + session.updated = now; + if (!findViewingSessionWs(sourceId)) session.hasUnread = true; + saveSession(session); + + sendSessionEventToViewers(sourceId, { + type: 'session_message', + sessionId: sourceId, + message, + }); + broadcastSessionList(); + + return { + ok: true, + promptId, + status: 'rendered', + sourceConversationId: sourceId, + questionCount: normalized.questions.length, + message: '已在 ccweb 前台展示问题,等待用户提交。', + }; +} + +function findCcwebPromptMessage(session, promptId) { + const normalizedPromptId = String(promptId || '').trim(); + if (!normalizedPromptId || !Array.isArray(session?.messages)) return null; + for (let index = session.messages.length - 1; index >= 0; index -= 1) { + const message = session.messages[index]; + if (message?.ccwebPrompt?.id === normalizedPromptId) { + return { message, index, prompt: message.ccwebPrompt }; + } + } + return null; +} + +function removeCcwebPromptMessage(session, promptId) { + const found = findCcwebPromptMessage(session, promptId); + if (!found || !Array.isArray(session?.messages)) return null; + session.messages.splice(found.index, 1); + return found; +} + +function selectedPromptOptionIds(rawAnswer, question) { + const raw = rawAnswer && typeof rawAnswer === 'object' ? rawAnswer : {}; + const source = Array.isArray(raw.selectedOptionIds) + ? raw.selectedOptionIds + : Array.isArray(raw.selectedOptions) + ? raw.selectedOptions + : Array.isArray(raw.optionIds) + ? raw.optionIds + : raw.selectedOptionId || raw.selectedOption || raw.optionId + ? [raw.selectedOptionId || raw.selectedOption || raw.optionId] + : []; + const valid = new Set((question.options || []).map((option) => option.id)); + const ids = source.map((item) => String(item || '').trim()).filter((item) => valid.has(item)); + if (question.selectionMode !== 'multi') return ids.slice(0, 1); + return [...new Set(ids)]; +} + +function normalizeCcwebPromptUserAnswers(prompt, rawAnswers = {}) { + const raw = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {}; + const answers = {}; + const answerList = []; + for (const question of prompt.questions || []) { + const rawAnswer = raw[question.id] && typeof raw[question.id] === 'object' ? raw[question.id] : {}; + const selectedOptionIds = question.selectionMode === 'none' ? [] : selectedPromptOptionIds(rawAnswer, question); + const selectedOptions = selectedOptionIds + .map((id) => (question.options || []).find((option) => option.id === id)) + .filter(Boolean); + let answerText = normalizeMcpPromptText( + rawAnswer.answerText ?? rawAnswer.answer ?? rawAnswer.text ?? '', + MCP_PROMPT_ANSWER_MAX_CHARS + ); + if (!answerText && selectedOptions.length > 0) { + answerText = selectedOptions.map((option) => option.answerText || option.label).filter(Boolean).join('\n'); + } + if (!answerText && question.defaultAnswer) answerText = question.defaultAnswer; + if (question.required && !answerText) { + return mcpToolError('missing_answer', `问题「${question.title || question.id}」需要填写答案。`, { + promptId: prompt.id, + questionId: question.id, + }); + } + const answer = { + questionId: question.id, + selectedOptionIds, + selectedOptionLabels: selectedOptions.map((option) => option.label), + answerText, + }; + answers[question.id] = answer; + answerList.push({ question, answer }); + } + return { ok: true, answers, answerList }; +} + +function buildCcwebPromptUserResponseText(prompt, answerList) { + const lines = ['我已回答 ccweb 提示的问题:']; + if (prompt.title) { + lines.push('', `表单:${prompt.title}`); + } + answerList.forEach(({ question, answer }, index) => { + lines.push('', `${index + 1}. ${question.title || question.id}`); + if (question.question) lines.push(`问题:${question.question}`); + if (answer.selectedOptionLabels.length > 0) { + lines.push(`选择:${answer.selectedOptionLabels.join(',')}`); + } + lines.push(`答案:${answer.answerText || '(空)'}`); + }); + return truncateTextValue(lines.join('\n'), MCP_PROMPT_RESPONSE_MAX_CHARS); +} + function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount = 0) { const sourceId = sanitizeId(sourceSessionId || ''); const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0); @@ -4455,6 +5069,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) { return getPendingCrossConversationReply(args, sourceSessionId); case 'ccweb_request_reply': return requestCrossConversationReply(args, sourceSessionId, sourceHopCount); + case 'ccweb_prompt_user': + return createCcwebPromptUser(args, sourceSessionId); default: return mcpToolError('unknown_tool', `未知 MCP 工具: ${tool}`); } @@ -5499,6 +6115,12 @@ wss.on('connection', (ws, req) => { case 'codex_app_user_input_response': handleCodexAppUserInputResponse(ws, msg); break; + case 'ccweb_prompt_user_response': + handleCcwebPromptUserResponse(ws, msg); + break; + case 'ccweb_prompt_user_dismiss': + handleCcwebPromptUserDismiss(ws, msg); + break; case 'codex_app_approval_response': handleCodexAppApprovalResponse(ws, msg); break; @@ -7168,6 +7790,7 @@ const { loadCodexConfig, prepareCodexCustomRuntime, ccwebMcpServerArg: CCWEB_MCP_SERVER_ARG, + ccwebMcpServerArgs: ccwebMcpServerCommandSpec().args, internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`, internalMcpToken: INTERNAL_MCP_TOKEN, nodePath: process.execPath, @@ -7559,6 +8182,7 @@ function findCodexAppRouteByRuntime(params = {}) { function handleCodexAppNotification(notification) { const routed = findCodexAppRouteByRuntime(notification?.params || {}); + if (handleCodexAppMcpStartupStatusNotification(notification, routed)) return; if (!routed) { plog('INFO', 'codex_app_notification_unrouted', { method: notification?.method || '', @@ -7836,6 +8460,107 @@ function resolvePendingCodexAppUserInputsForSession(sessionId) { } } +function handleCcwebPromptUserResponse(ws, msg = {}) { + const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || ''); + const promptId = String(msg.promptId || '').trim(); + const fail = (code, message, extra = {}) => { + wsSend(ws, { type: 'error', sessionId, code, message, ...extra }); + return { ok: false, code, message, ...extra }; + }; + + if (!sessionId) return fail('missing_session_id', '缺少会话 ID。'); + if (!promptId) return fail('missing_prompt_id', '缺少 promptId。'); + if (activeProcesses.has(sessionId) && !activeCodexAppTurns.has(sessionId)) { + return fail('session_running', '当前会话正在运行,暂不能提交 ccweb 表单答案。'); + } + + const session = loadSession(sessionId); + if (!session) return fail('session_not_found', '会话不存在。'); + + const found = findCcwebPromptMessage(session, promptId); + if (!found) return fail('prompt_not_found', 'ccweb 表单不存在或已过期。', { promptId }); + if (found.prompt.status && found.prompt.status !== 'pending') { + return fail('prompt_already_completed', 'ccweb 表单已经提交。', { promptId, status: found.prompt.status }); + } + + const normalized = normalizeCcwebPromptUserAnswers(found.prompt, msg.answers || {}); + if (!normalized.ok) return fail(normalized.code || 'bad_answers', normalized.message || '答案无效。', normalized); + + const now = new Date().toISOString(); + const submittedPrompt = { + ...found.prompt, + status: 'submitted', + answers: normalized.answers, + submittedAt: now, + submitMessageId: crypto.randomUUID(), + }; + removeCcwebPromptMessage(session, promptId); + session.updated = now; + saveSession(session); + + sendSessionEventToViewers(sessionId, { + type: 'ccweb_prompt_user_remove', + sessionId, + promptId, + prompt: submittedPrompt, + }); + + const responseText = buildCcwebPromptUserResponseText(submittedPrompt, normalized.answerList); + const result = handleMessage(ws, { + type: 'message', + text: responseText, + sessionId, + mode: session.permissionMode || 'yolo', + agent: getSessionAgent(session), + clientMessageId: submittedPrompt.submitMessageId, + }, { + emitUserMessage: true, + runtimeText: responseText, + skipPendingCrossConversationFlush: true, + }); + + if (!result?.ok) { + return fail(result?.code || 'submit_failed', result?.message || '提交 ccweb 表单答案失败。', { promptId }); + } + return { ok: true, sessionId, promptId }; +} + +function handleCcwebPromptUserDismiss(ws, msg = {}) { + const sessionId = sanitizeId(msg.sessionId || wsSessionMap.get(ws) || ''); + const promptId = String(msg.promptId || '').trim(); + const fail = (code, message, extra = {}) => { + wsSend(ws, { type: 'error', sessionId, code, message, ...extra }); + return { ok: false, code, message, ...extra }; + }; + + if (!sessionId) return fail('missing_session_id', '缺少会话 ID。'); + if (!promptId) return fail('missing_prompt_id', '缺少 promptId。'); + + const session = loadSession(sessionId); + if (!session) return fail('session_not_found', '会话不存在。'); + const found = removeCcwebPromptMessage(session, promptId); + if (!found) return fail('prompt_not_found', 'ccweb 表单不存在或已被清理。', { promptId }); + + const now = new Date().toISOString(); + const dismissedPrompt = { + ...found.prompt, + status: 'dismissed', + dismissedAt: now, + }; + session.updated = now; + saveSession(session); + + sendSessionEventToViewers(sessionId, { + type: 'ccweb_prompt_user_remove', + sessionId, + promptId, + prompt: dismissedPrompt, + reason: 'dismissed', + }); + broadcastSessionList(); + return { ok: true, promptId, status: 'dismissed' }; +} + function codexAppRecord(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }