chore: rebuild CentOS7 release package
This commit is contained in:
34
AGENTS.md
34
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 候选。
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
status,step
|
||||
DONE,定位会话切换与运行中恢复逻辑
|
||||
DONE,核对本次改动是否影响该链路
|
||||
DONE,查看相关运行日志和当前会话状态
|
||||
DONE,修复缺失 streaming 气泡的恢复逻辑
|
||||
IN_PROGRESS,验证并汇总影响范围
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-06-21T15:06:34.507Z",
|
||||
"updatedAt": "2026-06-26T15:00:37.081Z",
|
||||
"replies": []
|
||||
}
|
||||
Binary file not shown.
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
660
public/app.js
660
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) {
|
||||
|
||||
@@ -112,6 +112,10 @@
|
||||
<button id="user-outline-btn" class="user-outline-btn" type="button" aria-expanded="false" aria-controls="user-outline-panel" title="定位用户消息">定位</button>
|
||||
<div id="user-outline-panel" class="user-outline-panel" hidden></div>
|
||||
</div>
|
||||
<div class="ccweb-prompt-outline-anchor" hidden>
|
||||
<button id="ccweb-prompt-outline-btn" class="user-outline-btn ccweb-prompt-outline-btn" type="button" aria-expanded="false" aria-controls="ccweb-prompt-outline-panel" title="定位待处理表单">表单</button>
|
||||
<div id="ccweb-prompt-outline-panel" class="user-outline-panel ccweb-prompt-outline-panel" hidden></div>
|
||||
</div>
|
||||
<button id="reload-mcp-btn" class="reload-mcp-btn" type="button" title="重载 Codex App MCP 配置" hidden>重载 MCP</button>
|
||||
<span id="cost-display" class="cost-display" hidden></span>
|
||||
</div>
|
||||
|
||||
@@ -6,398 +6,476 @@
|
||||
<title>RAG 入门:原理、流程与使用</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f6f8fb;
|
||||
--paper:#ffffff;
|
||||
--ink:#0a2540;
|
||||
--muted:#486176;
|
||||
--soft:#eef3f8;
|
||||
--line:#d9e2ec;
|
||||
--blue:#1d4ed8;
|
||||
--mint:#0f9f8f;
|
||||
--orange:#d97706;
|
||||
--rose:#e11d48;
|
||||
--shadow:0 14px 34px rgba(10,37,64,.10),0 2px 7px rgba(10,37,64,.05);
|
||||
--blue:#0b63f6;
|
||||
--blue2:#0f8bff;
|
||||
--cyan:#15c8c2;
|
||||
--green:#12a57d;
|
||||
--navy:#061b4e;
|
||||
--ink:#102047;
|
||||
--muted:#51627f;
|
||||
--line:#c8dcff;
|
||||
--soft:#f3f8ff;
|
||||
--glass:rgba(255,255,255,.86);
|
||||
--shadow:0 20px 46px rgba(16,45,100,.16),0 2px 10px rgba(16,45,100,.08);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC","Microsoft YaHei",Arial,sans-serif}
|
||||
.deck{position:relative;width:100vw;height:100vh;overflow:hidden;background:linear-gradient(135deg,#f8fafc,#eef3f8)}
|
||||
.slide{position:absolute;inset:0;display:none;padding:72px 88px;background:radial-gradient(circle at 85% 12%,rgba(29,78,216,.08),transparent 28%),var(--paper);align-items:center;justify-content:center}
|
||||
.slide.active{display:flex}
|
||||
.slide-inner{width:100%;max-height:calc(100vh - 150px)}
|
||||
.slide::before{content:"";position:absolute;left:0;top:0;bottom:0;width:8px;background:linear-gradient(180deg,var(--blue),var(--mint))}
|
||||
.topline{position:absolute;left:88px;right:88px;top:28px;display:flex;justify-content:space-between;align-items:center;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#8aa0b5}
|
||||
.footer{position:absolute;left:88px;right:88px;bottom:24px;display:flex;justify-content:space-between;color:#8aa0b5;font-size:12px}
|
||||
.progress{position:fixed;left:0;right:0;bottom:0;height:4px;background:#dbe4ee;z-index:20}
|
||||
.progress span{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--blue),var(--mint));transition:width .2s ease}
|
||||
.nav-controls{position:fixed;right:28px;bottom:26px;z-index:30;display:flex;align-items:center;gap:8px;padding:8px;border:1px solid rgba(10,37,64,.12);border-radius:999px;background:rgba(255,255,255,.92);box-shadow:0 8px 26px rgba(10,37,64,.14);backdrop-filter:blur(10px)}
|
||||
.nav-btn{width:38px;height:38px;border:0;border-radius:50%;background:#0a2540;color:#fff;font-size:22px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.nav-btn:disabled{opacity:.32;cursor:not-allowed}
|
||||
.nav-index{min-width:58px;text-align:center;font-size:13px;font-weight:800;color:var(--muted)}
|
||||
.kicker{margin:0 0 12px;color:var(--blue);font-size:15px;font-weight:700;letter-spacing:.1em;text-transform:uppercase}
|
||||
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#eef3fb;color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC","Microsoft YaHei",Arial,sans-serif}
|
||||
body{display:grid;place-items:center}
|
||||
h1,h2,h3,h4,p{margin:0}
|
||||
h1{font-size:68px;line-height:1.05;letter-spacing:0;font-weight:800;max-width:980px}
|
||||
h2{font-size:44px;line-height:1.15;letter-spacing:0;font-weight:760;max-width:1040px}
|
||||
h3{font-size:24px;line-height:1.25;font-weight:740}
|
||||
h4{font-size:18px;line-height:1.3;font-weight:730}
|
||||
.lead{font-size:23px;line-height:1.55;color:var(--muted);max-width:900px;margin-top:22px}
|
||||
.muted{color:var(--muted)}
|
||||
.accent{background:linear-gradient(135deg,var(--blue),var(--mint));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.row{display:flex;gap:16px;align-items:stretch}
|
||||
.grid2{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}
|
||||
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
||||
.mt{margin-top:30px}.mt-sm{margin-top:16px}.mt-lg{margin-top:42px}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:22px}
|
||||
.card.soft{background:var(--soft);box-shadow:none}
|
||||
.tag{display:inline-flex;align-items:center;justify-content:center;border-radius:999px;padding:5px 12px;background:#edf4ff;color:var(--blue);font-size:12px;font-weight:800}
|
||||
.tag.mint{background:#e8faf7;color:var(--mint)}
|
||||
.tag.orange{background:#fff4df;color:var(--orange)}
|
||||
.tag.rose{background:#fff0f4;color:var(--rose)}
|
||||
.num{width:40px;height:40px;border-radius:9px;background:var(--blue);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;margin-bottom:14px}
|
||||
.num.mint{background:var(--mint)}.num.orange{background:var(--orange)}.num.rose{background:var(--rose)}
|
||||
.pillbar{display:flex;flex-wrap:wrap;gap:10px;margin-top:32px}
|
||||
.pill{border:1px solid var(--line);background:#fff;border-radius:999px;padding:8px 14px;font-size:14px;color:var(--muted);font-weight:650}
|
||||
.stage{display:grid;grid-template-columns:1fr 52px 1fr 52px 1fr;gap:10px;align-items:center;margin-top:38px}
|
||||
.stage .box{text-align:center;padding:28px 18px;border:1px solid var(--line);border-radius:12px;background:#fff;box-shadow:var(--shadow)}
|
||||
.stage .box.main{background:var(--blue);color:#fff}
|
||||
.stage .box.main p{color:#dbeafe}
|
||||
.arrow{font-size:34px;color:#93a4b5;text-align:center;font-weight:900}
|
||||
.compare{display:grid;grid-template-columns:1fr 88px 1fr;gap:22px;align-items:center;margin-top:34px}
|
||||
.compare .mid{text-align:center;font-size:42px;color:#9aacbd;font-weight:900}
|
||||
ul{margin:14px 0 0;padding-left:22px;color:var(--muted);line-height:1.85;font-size:18px}
|
||||
.small{font-size:14px;line-height:1.55;color:var(--muted)}
|
||||
.flow{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-top:30px}
|
||||
.flow .step{position:relative;background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:18px;min-height:150px}
|
||||
.flow .step h4{margin:8px 0 6px}.flow .step p{font-size:13px;line-height:1.5;color:var(--muted)}
|
||||
.flow .step.hot{border-color:rgba(29,78,216,.45);box-shadow:0 0 0 3px rgba(29,78,216,.10),var(--shadow)}
|
||||
.chunks{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:24px}
|
||||
.chunk{border:1px dashed #9fb0c2;background:#fff;border-radius:9px;padding:17px;min-height:122px}
|
||||
.chunk b{color:var(--blue);font-size:14px}.chunk p{font-size:13px;line-height:1.6;color:var(--muted);margin-top:8px}
|
||||
.vector{display:grid;grid-template-columns:repeat(10,1fr);gap:6px;margin-top:14px}
|
||||
.bar{height:44px;border-radius:5px;background:var(--blue);opacity:.28}.bar:nth-child(2n){opacity:.55}.bar:nth-child(3n){opacity:.82}.bar:nth-child(5n){opacity:.42}
|
||||
.search{display:grid;grid-template-columns:1.02fr .98fr;gap:20px;margin-top:26px}
|
||||
.result{display:flex;gap:12px;border:1px solid var(--line);border-radius:9px;background:#fff;padding:14px;margin-top:10px}
|
||||
.rank{width:34px;height:34px;border-radius:8px;background:#edf4ff;color:var(--blue);display:flex;align-items:center;justify-content:center;font-weight:900;flex-shrink:0}
|
||||
.prompt{background:#09213a;color:#e8f1ff;border-radius:12px;box-shadow:var(--shadow);padding:22px 24px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:15px;line-height:1.85}
|
||||
.prompt .dim{color:#8fb1d6}.prompt .mark{color:#fde68a}
|
||||
.agent-map{display:grid;grid-template-columns:1fr 46px 1fr 46px 1fr;gap:10px;align-items:center;margin-top:28px}
|
||||
.agent{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:20px;text-align:center;min-height:170px}
|
||||
.agent p{font-size:13px;line-height:1.55;color:var(--muted);margin-top:8px}
|
||||
.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-top:34px}
|
||||
.summary .card{min-height:150px}
|
||||
.learning-map{display:grid;grid-template-columns:1fr 38px 1fr 38px 1fr 38px 1fr 38px 1fr;gap:10px;align-items:stretch;margin-top:34px}
|
||||
.map-card{background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);padding:20px 16px;min-height:270px}
|
||||
.map-card .num{margin-bottom:16px}
|
||||
.map-card h3{font-size:21px}
|
||||
.map-list{margin:14px 0 0;padding-left:18px;color:var(--muted);font-size:14px;line-height:1.75}
|
||||
.map-arrow{display:flex;align-items:center;justify-content:center;font-size:28px;color:#9aacbd;font-weight:900}
|
||||
.mini-flow{display:grid;grid-template-columns:1fr 44px 1fr 44px 1fr;gap:10px;align-items:center;margin-top:32px}
|
||||
.mini-flow .node{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:18px;text-align:center;min-height:126px}
|
||||
.mini-flow .node.main{background:var(--blue);color:#fff}
|
||||
.mini-flow .node.main p{color:#dbeafe}
|
||||
.mini-flow p{font-size:13px;line-height:1.5;color:var(--muted);margin-top:6px}
|
||||
.mcp-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-top:30px}
|
||||
.mcp-card{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:22px;min-height:205px}
|
||||
.tool-list{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:24px}
|
||||
.tool-list span{display:flex;align-items:center;justify-content:center;min-height:58px;border-radius:10px;background:var(--soft);border:1px solid var(--line);font-size:14px;font-weight:800;color:var(--muted)}
|
||||
.deck{position:relative;width:100vw;height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 8% 15%,rgba(11,99,246,.13),transparent 24%),radial-gradient(circle at 90% 80%,rgba(21,200,194,.16),transparent 28%),#edf3fb}
|
||||
.slide{position:relative;display:none;width:min(100vw,calc(100vh * 16 / 9));aspect-ratio:16/9;max-height:100vh;background:#fff;overflow:hidden;box-shadow:var(--shadow);padding:46px 56px 42px}
|
||||
.slide.active{display:block}
|
||||
.slide::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(12,97,246,.05) 1px,transparent 1px),linear-gradient(rgba(12,97,246,.05) 1px,transparent 1px);background-size:36px 36px;mask-image:radial-gradient(ellipse at 45% 40%,black 18%,transparent 82%);pointer-events:none}
|
||||
.slide::after{content:"";position:absolute;left:-120px;bottom:-70px;width:580px;height:180px;background:repeating-linear-gradient(155deg,rgba(11,99,246,.11) 0 2px,transparent 2px 12px);transform:skewX(-18deg);opacity:.55;pointer-events:none}
|
||||
.slide > *{position:relative;z-index:1}
|
||||
.slide-inner{height:100%;display:flex;flex-direction:column;gap:18px}
|
||||
.top{height:66px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:22px;border-bottom:2px solid #dbe8ff;padding-bottom:14px}
|
||||
.chapter{height:56px;min-width:122px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:30px;font-weight:900;letter-spacing:.03em;background:linear-gradient(135deg,#0054e6,#0d82ff);clip-path:polygon(0 0,82% 0,100% 50%,82% 100%,0 100%);box-shadow:0 10px 22px rgba(11,99,246,.24)}
|
||||
.head h2{font-size:34px;line-height:1.05;font-weight:900;letter-spacing:0;color:#061b4e}
|
||||
.head p{font-size:15px;color:var(--muted);margin-top:6px}
|
||||
.brand{display:flex;align-items:center;gap:10px;color:var(--blue);font-weight:900}
|
||||
.brand-mark{width:44px;height:44px;border:2px solid var(--blue);border-radius:12px;display:grid;place-items:center;background:#fff;font-weight:900}
|
||||
.content{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:center}
|
||||
.footer{height:42px;display:flex;align-items:flex-end;justify-content:space-between;color:#7d91b5;font-size:11px;letter-spacing:.14em;text-transform:uppercase;border-top:1px solid #dbe8ff;padding-top:12px}
|
||||
.page-corner{position:absolute;right:0;bottom:0;width:86px;height:52px;background:linear-gradient(135deg,#0b63f6,#0050ca);clip-path:polygon(38% 0,100% 0,100% 100%,0 100%);color:#fff;font-size:18px;font-weight:900;display:flex;align-items:flex-end;justify-content:flex-end;padding:0 18px 12px;z-index:3}
|
||||
.kicker{font-size:13px;font-weight:900;letter-spacing:.18em;color:var(--blue);text-transform:uppercase;margin-bottom:8px}
|
||||
.big-title{font-size:58px;line-height:1.08;font-weight:950;color:#061b4e;letter-spacing:0}
|
||||
.sub{font-size:20px;line-height:1.55;color:var(--muted);max-width:760px;margin-top:14px}
|
||||
.accent{background:linear-gradient(135deg,var(--blue),var(--cyan));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
|
||||
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
|
||||
.card{background:var(--glass);border:1.5px solid var(--line);border-radius:14px;box-shadow:0 12px 26px rgba(38,91,160,.09);padding:18px}
|
||||
.card h3{font-size:20px;color:#08205d;font-weight:900}
|
||||
.card h4{font-size:17px;color:#08205d;font-weight:900}
|
||||
.card p,.card li{font-size:13px;line-height:1.55;color:var(--muted)}
|
||||
.tag{display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 12px;border-radius:999px;background:#eaf3ff;color:var(--blue);font-size:12px;font-weight:900}
|
||||
.tag.green{background:#e7fbf5;color:var(--green)}
|
||||
.tag.cyan{background:#e7fbff;color:#0796a5}
|
||||
.tag.warn{background:#fff4de;color:#d27a00}
|
||||
.tag.red{background:#fff0f4;color:#d81948}
|
||||
.icon{width:56px;height:56px;border-radius:50%;display:grid;place-items:center;background:#fff;border:1px solid #c8dcff;box-shadow:0 8px 18px rgba(11,99,246,.12);color:var(--blue);font-size:23px;font-weight:950;margin-bottom:10px}
|
||||
.ribbon-row{display:grid;grid-template-columns:175px 1fr;gap:18px;align-items:center;margin:14px 0}
|
||||
.ribbon{height:78px;display:flex;align-items:center;gap:14px;color:#fff;background:linear-gradient(135deg,var(--blue),#0050d8);clip-path:polygon(0 0,88% 0,100% 50%,88% 100%,0 100%);border-radius:10px 0 0 10px;padding:0 22px;font-size:22px;font-weight:950}
|
||||
.ribbon.green{background:linear-gradient(135deg,#08a88f,#06c1b8)}
|
||||
.ribbon.navy{background:linear-gradient(135deg,#12327a,#0b63f6)}
|
||||
.ribbon-line{min-height:78px;border:1.5px solid var(--line);border-radius:12px;background:rgba(255,255,255,.78);display:grid;grid-template-columns:90px 1fr;align-items:center;padding:14px 22px}
|
||||
.dot-list{display:grid;gap:7px}
|
||||
.dot-list span{position:relative;padding-left:16px;color:#1b2b58;font-size:14px;line-height:1.4}
|
||||
.dot-list span::before{content:"";position:absolute;left:0;top:.62em;width:6px;height:6px;border-radius:50%;background:var(--blue)}
|
||||
.diagram-title{height:34px;display:inline-flex;align-items:center;padding:0 18px;border-radius:10px;background:linear-gradient(135deg,var(--blue),#0053df);color:#fff;font-weight:950;box-shadow:0 10px 18px rgba(11,99,246,.22)}
|
||||
.stack{display:grid;gap:12px}
|
||||
.stack-layer{position:relative;border:1.5px solid #a7c8ff;border-radius:20px;background:linear-gradient(180deg,#fff,rgba(237,247,255,.92));box-shadow:0 14px 26px rgba(11,99,246,.12);padding:18px 20px}
|
||||
.stack-layer::after{content:"";position:absolute;left:42px;right:42px;bottom:-11px;height:18px;border-radius:0 0 22px 22px;background:linear-gradient(90deg,rgba(11,99,246,.18),rgba(21,200,194,.18));z-index:-1}
|
||||
.stack-items{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:12px}
|
||||
.mini{background:#fff;border:1px solid #d7e6ff;border-radius:12px;padding:13px;text-align:center;min-height:92px}
|
||||
.mini strong{display:block;font-size:16px;color:#08205d;margin-bottom:4px}
|
||||
.mini span{font-size:12px;line-height:1.35;color:var(--muted)}
|
||||
.side-notes{display:grid;gap:20px}
|
||||
.side-note{display:grid;grid-template-columns:56px 1fr;gap:12px;align-items:center}
|
||||
.side-note .round{width:52px;height:52px;border-radius:50%;border:2px solid #d7e6ff;background:#fff;display:grid;place-items:center;color:var(--blue);font-weight:950;font-size:20px}
|
||||
.side-note strong{display:block;color:var(--blue);font-size:16px}
|
||||
.side-note span{font-size:12px;color:var(--muted)}
|
||||
.value-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:18px;border:1px solid #d8e6ff;border-radius:14px;background:rgba(255,255,255,.76);padding:12px 14px}
|
||||
.value{display:grid;grid-template-columns:42px 1fr;gap:10px;align-items:center;border-right:1px solid #d8e6ff}
|
||||
.value:last-child{border-right:0}
|
||||
.value .vicon{width:38px;height:38px;border-radius:10px;background:#eaf3ff;color:var(--blue);display:grid;place-items:center;font-weight:950}
|
||||
.value strong{display:block;font-size:15px;color:var(--blue)}
|
||||
.value span{font-size:11px;color:var(--muted)}
|
||||
.flow{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;align-items:stretch}
|
||||
.step{position:relative;min-height:142px;padding:16px;border:1.5px solid #bdd7ff;border-radius:14px;background:#fff;box-shadow:0 10px 22px rgba(11,99,246,.08)}
|
||||
.step::after{content:"";position:absolute;right:-13px;top:50%;width:14px;height:2px;background:#8db9ff}
|
||||
.step:last-child::after{display:none}
|
||||
.step .num{width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,var(--blue),var(--cyan));color:#fff;display:grid;place-items:center;font-weight:950;margin-bottom:10px}
|
||||
.step h3{font-size:18px;color:#08205d;font-weight:950}
|
||||
.step p{font-size:12px;line-height:1.45;color:var(--muted);margin-top:6px}
|
||||
.step.hot{border-color:var(--cyan);box-shadow:0 0 0 4px rgba(21,200,194,.12),0 10px 22px rgba(11,99,246,.08)}
|
||||
.arrow{font-size:24px;color:#75a7ff;font-weight:950;text-align:center}
|
||||
.compare{display:grid;grid-template-columns:1fr 82px 1fr;gap:16px;align-items:center}
|
||||
.compare .mid{height:82px;border-radius:50%;display:grid;place-items:center;background:linear-gradient(135deg,var(--blue),var(--cyan));color:#fff;font-size:26px;font-weight:950;box-shadow:0 12px 24px rgba(11,99,246,.24)}
|
||||
.center-badge{width:150px;height:150px;border-radius:36px;background:linear-gradient(135deg,#0b63f6,#12c8c2);color:#fff;display:grid;place-items:center;text-align:center;font-size:28px;font-weight:950;box-shadow:0 20px 38px rgba(11,99,246,.28);margin:auto}
|
||||
.limit-grid{display:grid;grid-template-columns:1fr 170px 1fr;gap:18px;align-items:center}
|
||||
.limit-col{display:grid;gap:14px}
|
||||
.limit{display:grid;grid-template-columns:50px 1fr;gap:12px;align-items:center;border:1.5px solid #cfe0ff;border-radius:14px;background:#fff;padding:14px}
|
||||
.limit .n{width:46px;height:46px;border-radius:12px;background:#eaf3ff;color:var(--blue);display:grid;place-items:center;font-weight:950}
|
||||
.pipeline{display:grid;grid-template-columns:1.1fr 1.2fr 1.1fr;gap:18px;align-items:center}
|
||||
.pipe-card{border:1.5px solid #cfe0ff;border-radius:18px;background:#fff;padding:18px;min-height:260px}
|
||||
.bad-list{display:grid;gap:10px;margin-top:14px}
|
||||
.bad-list span{border:1px dashed #ffb4c4;border-radius:10px;background:#fff7fa;color:#a6193c;padding:10px;font-size:13px}
|
||||
.filter{display:grid;place-items:center;min-height:280px}
|
||||
.filter .funnel{width:190px;height:230px;background:linear-gradient(180deg,#e9f3ff,#fff);clip-path:polygon(8% 0,92% 0,62% 48%,62% 100%,38% 100%,38% 48%);border:2px solid #9fc3ff;filter:drop-shadow(0 12px 20px rgba(11,99,246,.16));display:grid;place-items:center;color:var(--blue);font-size:28px;font-weight:950}
|
||||
.chunks{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:center}
|
||||
.doc{border:1.5px solid #cfe0ff;border-radius:18px;background:#fff;padding:18px}
|
||||
.slice{border-left:5px solid var(--blue);border-radius:10px;background:#f6faff;padding:12px;margin-top:10px}
|
||||
.vector-bars{display:grid;grid-template-columns:repeat(12,1fr);gap:6px;margin-top:16px}
|
||||
.bar{height:56px;border-radius:7px;background:var(--blue);opacity:.25}
|
||||
.bar:nth-child(2n){opacity:.45}.bar:nth-child(3n){opacity:.75}.bar:nth-child(5n){opacity:.9}
|
||||
.rank-list{display:grid;gap:10px}
|
||||
.rank{display:grid;grid-template-columns:42px 1fr auto;gap:12px;align-items:center;border:1.5px solid #cfe0ff;border-radius:12px;background:#fff;padding:12px}
|
||||
.rank .rnum{width:38px;height:38px;border-radius:10px;background:#eaf3ff;color:var(--blue);display:grid;place-items:center;font-weight:950}
|
||||
.score{font-size:20px;font-weight:950;color:var(--green)}
|
||||
.prompt-box{background:#071d48;color:#e7f0ff;border-radius:18px;padding:18px 20px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:13px;line-height:1.65;box-shadow:0 18px 34px rgba(7,29,72,.24)}
|
||||
.prompt-box b{color:#7dd3fc}.prompt-box mark{background:rgba(250,204,21,.18);color:#fde68a;padding:1px 4px;border-radius:4px}
|
||||
.agent-map{display:grid;grid-template-columns:1fr 70px 1fr 70px 1fr;gap:10px;align-items:center}
|
||||
.agent-card{border:1.5px solid #cfe0ff;border-radius:18px;background:#fff;padding:18px;text-align:center;min-height:190px}
|
||||
.agent-card .icon{margin:0 auto 12px}
|
||||
.tool-bus{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:18px;align-items:center}
|
||||
.bus{height:96px;border-radius:22px;background:linear-gradient(135deg,var(--blue),var(--cyan));color:#fff;display:grid;place-items:center;text-align:center;font-size:26px;font-weight:950;box-shadow:0 16px 30px rgba(11,99,246,.24)}
|
||||
.tools{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
|
||||
.tool{border:1.5px solid #cfe0ff;background:#fff;border-radius:14px;padding:15px;text-align:center;font-weight:900;color:#08205d}
|
||||
.tool span{display:block;font-size:12px;color:var(--muted);font-weight:500;margin-top:4px}
|
||||
.cover{padding:58px 70px 52px}
|
||||
.cover .slide-inner{justify-content:center}
|
||||
.cover-grid{display:grid;grid-template-columns:1.05fr .95fr;gap:34px;align-items:center}
|
||||
.cover-panel{border:1.5px solid #cfe0ff;background:rgba(255,255,255,.82);border-radius:24px;padding:26px;box-shadow:0 14px 30px rgba(11,99,246,.12)}
|
||||
.cover-visual{position:relative;height:440px}
|
||||
.orb{position:absolute;border-radius:50%;background:linear-gradient(135deg,var(--blue),var(--cyan));box-shadow:0 24px 44px rgba(11,99,246,.24)}
|
||||
.orb.one{width:205px;height:205px;left:160px;top:64px;display:grid;place-items:center;color:#fff;font-size:54px;font-weight:950}
|
||||
.ring{position:absolute;border:2px dashed #9ac1ff;border-radius:50%;inset:22px;animation:spin 18s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.orbit-card{position:absolute;width:150px;border:1px solid #cfe0ff;border-radius:16px;background:#fff;padding:14px;text-align:center;box-shadow:0 12px 22px rgba(11,99,246,.13)}
|
||||
.orbit-card.a{left:16px;top:36px}.orbit-card.b{right:18px;top:56px}.orbit-card.c{left:40px;bottom:52px}.orbit-card.d{right:42px;bottom:28px}
|
||||
.orbit-card strong{display:block;color:var(--blue);font-size:18px}.orbit-card span{font-size:12px;color:var(--muted)}
|
||||
.nav-controls{position:fixed;left:50%;bottom:10px;transform:translateX(-50%);z-index:30;display:flex;align-items:center;gap:6px;padding:6px;border:1px solid rgba(16,32,71,.12);border-radius:999px;background:rgba(255,255,255,.74);box-shadow:0 8px 24px rgba(16,32,71,.12);backdrop-filter:blur(10px);opacity:.72;transition:opacity .18s ease,background .18s ease}
|
||||
.nav-controls:hover,.nav-controls:focus-within{opacity:1;background:rgba(255,255,255,.94)}
|
||||
.nav-btn{width:32px;height:32px;border:0;border-radius:50%;background:#061b4e;color:#fff;font-size:19px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.nav-btn:disabled{opacity:.32;cursor:not-allowed}
|
||||
.nav-index{min-width:58px;text-align:center;font-size:13px;font-weight:900;color:var(--muted)}
|
||||
.progress{position:fixed;left:0;right:0;bottom:0;height:4px;background:#dbe6f6;z-index:25}
|
||||
.progress span{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--blue),var(--cyan));transition:width .2s ease}
|
||||
.notes{display:none}
|
||||
@media (max-aspect-ratio:16/9){
|
||||
.slide{width:calc(100vh * 16 / 9);height:100vh}
|
||||
}
|
||||
@media (max-width:900px){
|
||||
.slide{padding:62px 28px 54px;overflow:auto;align-items:flex-start}
|
||||
.slide-inner{max-height:none}
|
||||
.topline,.footer{left:28px;right:28px}
|
||||
h1{font-size:42px}h2{font-size:32px}.lead{font-size:18px}
|
||||
.grid2,.grid3,.grid4,.flow,.chunks,.search,.summary,.learning-map,.mcp-grid,.tool-list{grid-template-columns:1fr}
|
||||
.stage,.compare,.agent-map,.mini-flow{grid-template-columns:1fr}
|
||||
.arrow,.map-arrow,.compare .mid{display:none}
|
||||
body{overflow:auto;display:block}
|
||||
html,body{height:auto;min-height:100%;overflow:auto}
|
||||
.deck{display:block;height:auto;min-height:100vh;padding:0;background:#fff}
|
||||
.slide{width:100%;height:auto;aspect-ratio:auto;min-height:100vh;box-shadow:none;padding:28px 22px 34px}
|
||||
.slide.active{display:block}
|
||||
.top{height:auto;grid-template-columns:1fr;gap:10px}
|
||||
.chapter{width:112px}
|
||||
.head h2{font-size:28px}
|
||||
.brand{display:none}
|
||||
.big-title{font-size:40px}
|
||||
.content{justify-content:flex-start;padding-top:24px}
|
||||
.cover-grid,.grid2,.grid3,.grid4,.pipeline,.chunks,.tool-bus,.limit-grid,.agent-map{grid-template-columns:1fr}
|
||||
.flow{grid-template-columns:1fr}
|
||||
.step::after,.arrow{display:none}
|
||||
.ribbon-row{grid-template-columns:1fr}
|
||||
.ribbon{clip-path:none;border-radius:12px}
|
||||
.ribbon-line{grid-template-columns:1fr}
|
||||
.value-strip{grid-template-columns:1fr}
|
||||
.value{border-right:0;border-bottom:1px solid #d8e6ff;padding-bottom:8px}
|
||||
.page-corner{display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="deck" id="deck">
|
||||
|
||||
<section class="slide active" data-title="封面">
|
||||
<section class="slide cover active" data-title="封面">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>RAG Primer</span><span>原理和使用</span></div>
|
||||
<p class="kicker">RAG · LLM · Retrieval · Prompt · Agent · MCP</p>
|
||||
<h1>RAG 入门:让 AI <span class="accent">先查资料</span>再回答</h1>
|
||||
<p class="lead">从 LLM 的对话限制出发,理解 RAG 的建库、检索、排序、提示词和 Agent 扩展。</p>
|
||||
<div class="pillbar">
|
||||
<span class="pill">RAG解释</span><span class="pill">LLM解释</span><span class="pill">上下文限制</span><span class="pill">幻觉</span><span class="pill">注意力顺序</span><span class="pill">切片</span><span class="pill">向量化</span><span class="pill">排序</span><span class="pill">语义检索</span><span class="pill">提示词工程</span><span class="pill">AGENTS</span><span class="pill">MCP</span>
|
||||
</div>
|
||||
<div class="footer"><span>RAG 原理和使用</span><span class="page"></span></div>
|
||||
<div class="notes">开场不用讲算法,先讲主线:大模型会说话,但它不是企业知识库。因为它有上下文、幻觉、注意力和顺序方面的限制,所以需要 RAG 这套“先查资料,再组织答案”的机制。</div>
|
||||
|
||||
<div class="cover-grid">
|
||||
<div>
|
||||
<div class="kicker">RAG · LLM · Prompt · Agent · MCP</div>
|
||||
<h1 class="big-title">RAG 入门:让 AI <span class="accent">先查资料</span> 再回答</h1>
|
||||
<p class="sub">从 LLM 的限制讲起,串起知识库建设、语义检索、排序、提示词工程、Agent 和 MCP。</p>
|
||||
<div class="value-strip" style="grid-template-columns:repeat(3,1fr);margin-top:28px">
|
||||
<div class="value"><div class="vicon">01</div><div><strong>先懂限制</strong><span>上下文、幻觉、注意力</span></div></div>
|
||||
<div class="value"><div class="vicon">02</div><div><strong>再懂流程</strong><span>建库、检索、生成</span></div></div>
|
||||
<div class="value"><div class="vicon">03</div><div><strong>最后扩展</strong><span>Agent、MCP、工具</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cover-panel">
|
||||
<div class="cover-visual">
|
||||
<div class="orb one"><div class="ring"></div>RAG</div>
|
||||
<div class="orbit-card a"><strong>LLM</strong><span>理解和生成</span></div>
|
||||
<div class="orbit-card b"><strong>知识库</strong><span>资料与来源</span></div>
|
||||
<div class="orbit-card c"><strong>检索</strong><span>找相关片段</span></div>
|
||||
<div class="orbit-card d"><strong>提示词</strong><span>约束回答</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG Primer</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="目录">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Roadmap</span><span>学习路径</span></div>
|
||||
<p class="kicker">由浅入深</p>
|
||||
<h2>从“会生成”到“会查资料、会调用工具”</h2>
|
||||
<div class="learning-map">
|
||||
<div class="map-card"><div class="num">1</div><h3>基础概念</h3><ul class="map-list"><li>RAG 是什么</li><li>LLM 是什么</li></ul></div>
|
||||
<div class="map-arrow">→</div>
|
||||
<div class="map-card"><div class="num orange">2</div><h3>LLM 限制</h3><ul class="map-list"><li>上下文</li><li>幻觉</li><li>注意力与顺序</li></ul></div>
|
||||
<div class="map-arrow">→</div>
|
||||
<div class="map-card"><div class="num mint">3</div><h3>离线建库</h3><ul class="map-list"><li>资料清洗</li><li>切片</li><li>向量化入库</li></ul></div>
|
||||
<div class="map-arrow">→</div>
|
||||
<div class="map-card"><div class="num">4</div><h3>在线问答</h3><ul class="map-list"><li>语义检索</li><li>排序 / 重排</li><li>提示词工程</li></ul></div>
|
||||
<div class="map-arrow">→</div>
|
||||
<div class="map-card"><div class="num rose">5</div><h3>能力扩展</h3><ul class="map-list"><li>Agents 分工</li><li>MCP 连接工具</li></ul></div>
|
||||
</div>
|
||||
<div class="footer"><span>目录</span><span class="page"></span></div>
|
||||
<div class="notes">这页把路线说清楚。后面每一页都围绕 LLM 限制到 RAG 方案,再到提示词工程和 Agent 的路径推进。</div>
|
||||
|
||||
<div class="top"><div class="chapter">01</div><div class="head"><h2>学习路径</h2><p>由浅入深:先理解问题,再理解方案,最后理解扩展能力。</p></div><div class="brand"><div class="brand-mark">AI</div><span>RAG 入门</span></div></div>
|
||||
<div class="content">
|
||||
<div class="flow">
|
||||
<div class="step hot"><div class="num">1</div><h3>基础概念</h3><p>RAG 是什么;LLM 是什么;两者分别负责什么。</p></div>
|
||||
<div class="step"><div class="num">2</div><h3>LLM 限制</h3><p>上下文有限、会幻觉、长内容专注度下降、顺序不稳定。</p></div>
|
||||
<div class="step"><div class="num">3</div><h3>离线建库</h3><p>资料准备、清洗、切片、元数据、向量化入库。</p></div>
|
||||
<div class="step"><div class="num">4</div><h3>在线问答</h3><p>用户提问、语义检索、候选排序、拼上下文、生成答案。</p></div>
|
||||
<div class="step"><div class="num">5</div><h3>能力扩展</h3><p>提示词工程、多个 LLM 分工、Agent 编排、MCP 连接工具。</p></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">懂</div><div><strong>概念</strong><span>知道每个词在干什么</span></div></div>
|
||||
<div class="value"><div class="vicon">看</div><div><strong>流程</strong><span>知道一次问答怎么跑</span></div></div>
|
||||
<div class="value"><div class="vicon">抓</div><div><strong>关键</strong><span>切片、排序、提示词</span></div></div>
|
||||
<div class="value"><div class="vicon">扩</div><div><strong>边界</strong><span>Agent 和工具调用</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Roadmap</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="RAG解释">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>RAG</span><span>检索增强生成</span></div>
|
||||
<p class="kicker">RAG 解释</p>
|
||||
<h2>RAG = 先检索资料,再增强上下文,最后生成答案</h2>
|
||||
<div class="mini-flow">
|
||||
<div class="node"><span class="tag">Retrieval</span><h3 class="mt-sm">检索</h3><p>从知识库找到相关资料。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node main"><span class="tag" style="background:rgba(255,255,255,.18);color:#fff">Augmented</span><h3 class="mt-sm">增强</h3><p>把资料放进上下文。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node"><span class="tag mint">Generation</span><h3 class="mt-sm">生成</h3><p>LLM 基于资料回答。</p></div>
|
||||
</div>
|
||||
<div class="grid2 mt">
|
||||
<div class="card"><h3>不是训练模型</h3><p class="lead" style="font-size:19px;margin-top:8px">知识不写进模型参数,而是每次回答前动态查资料。</p></div>
|
||||
<div class="card soft"><h3>不是全量塞资料</h3><p class="lead" style="font-size:19px;margin-top:8px">只取与问题相关的片段,降低上下文压力和噪声。</p></div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG = Retrieval-Augmented Generation</span><span class="page"></span></div>
|
||||
<div class="notes">这页讲 RAG 的基本定义。先给出完整定义,再强调 RAG 不是训练模型,也不是把全部资料塞给模型。</div>
|
||||
|
||||
<div class="top"><div class="chapter">02</div><div class="head"><h2>RAG 是什么</h2><p>RAG = Retrieval-Augmented Generation,检索增强生成。</p></div><div class="brand"><div class="brand-mark">R</div><span>先查再答</span></div></div>
|
||||
<div class="content">
|
||||
<div class="pipeline">
|
||||
<div class="pipe-card"><span class="tag">Retrieval</span><div class="icon">查</div><h3>检索</h3><p>先从知识库中找到和问题相关的资料片段。</p></div>
|
||||
<div class="filter"><div class="funnel">RAG<br><span style="font-size:14px">筛选相关资料</span></div></div>
|
||||
<div class="pipe-card"><span class="tag green">Generation</span><div class="icon">答</div><h3>生成</h3><p>LLM 只基于筛出来的资料组织自然语言答案。</p></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">≠</div><div><strong>不是训练模型</strong><span>知识不写进模型参数</span></div></div>
|
||||
<div class="value"><div class="vicon">≠</div><div><strong>不是全量塞资料</strong><span>只取相关片段</span></div></div>
|
||||
<div class="value"><div class="vicon">=</div><div><strong>动态查资料</strong><span>每次提问实时检索</span></div></div>
|
||||
<div class="value"><div class="vicon">✓</div><div><strong>降低幻觉</strong><span>让答案有依据</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG = Retrieval + Augmented + Generation</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="LLM 是什么">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>LLM</span><span>大语言模型</span></div>
|
||||
<p class="kicker">定义</p>
|
||||
<h2>LLM:理解上下文,生成自然语言</h2>
|
||||
<div class="compare">
|
||||
<div class="card">
|
||||
<span class="tag">能力</span>
|
||||
<h3 class="mt-sm">理解上下文,生成答案</h3>
|
||||
<ul>
|
||||
<li>读懂用户问题的大意</li>
|
||||
<li>把零散信息组织成自然语言</li>
|
||||
<li>按要求改写、总结、解释、翻译</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mid">≠</div>
|
||||
<div class="card">
|
||||
<span class="tag rose">边界</span>
|
||||
<h3 class="mt-sm">企业资料库或事实系统</h3>
|
||||
<ul>
|
||||
<li>不会天然知道最新制度</li>
|
||||
<li>不知道内部文档和私有数据</li>
|
||||
<li>不能保证每句话都有来源</li>
|
||||
</ul>
|
||||
<div class="top"><div class="chapter">03</div><div class="head"><h2>LLM 是什么</h2><p>大语言模型擅长理解上下文和生成表达,但不是企业事实库。</p></div><div class="brand"><div class="brand-mark">LLM</div><span>语言引擎</span></div></div>
|
||||
<div class="content">
|
||||
<div class="compare">
|
||||
<div class="card" style="min-height:300px"><span class="tag">能力</span><div class="icon">AI</div><h3>理解与表达</h3><div class="dot-list" style="margin-top:14px"><span>读懂问题意图</span><span>总结、改写、解释、翻译</span><span>把零散资料组织成答案</span></div></div>
|
||||
<div class="mid">≠</div>
|
||||
<div class="card" style="min-height:300px"><span class="tag red">边界</span><div class="icon">DB</div><h3>企业事实系统</h3><div class="dot-list" style="margin-top:14px"><span>不会天然知道内部文档</span><span>不知道最新政策和数据</span><span>不能保证每句话都有来源</span></div></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">读</div><div><strong>读懂上下文</strong><span>理解语义,不是简单关键词</span></div></div>
|
||||
<div class="value"><div class="vicon">写</div><div><strong>生成答案</strong><span>组织语言和结构</span></div></div>
|
||||
<div class="value"><div class="vicon">缺</div><div><strong>缺事实来源</strong><span>私有知识需要外部供给</span></div></div>
|
||||
<div class="value"><div class="vicon">接</div><div><strong>接 RAG</strong><span>把资料交给 LLM 使用</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>LLM handles language, not source-of-truth</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="card soft mt"><h3>定位</h3><p class="lead" style="font-size:20px;margin-top:8px">LLM 负责读懂和表达,事实依据来自外部资料。</p></div>
|
||||
<div class="footer"><span>LLM 不是知识库</span><span class="page"></span></div>
|
||||
<div class="notes">这里不要把 LLM 讲成搜索引擎。LLM 最强的是语言理解和生成,事实来源要靠外部知识、数据库或工具补充。</div>
|
||||
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="LLM 的限制">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Limits</span><span>对话限制</span></div>
|
||||
<p class="kicker">对话限制</p>
|
||||
<h2>LLM 的 4 个对话限制</h2>
|
||||
<div class="grid4 mt">
|
||||
<div class="card"><div class="num">1</div><h3>上下文有限</h3><p class="small mt-sm">输入窗口有限;资料过多会变慢、变贵、变乱。</p></div>
|
||||
<div class="card"><div class="num orange">2</div><h3>会有幻觉</h3><p class="small mt-sm">资料不足或指令不清时,会生成看似合理的错误内容。</p></div>
|
||||
<div class="card"><div class="num mint">3</div><h3>专注度下降</h3><p class="small mt-sm">长资料和噪声会稀释重点,关键信息可能被忽略。</p></div>
|
||||
<div class="card"><div class="num rose">4</div><h3>顺序不稳定</h3><p class="small mt-sm">位置、相似内容、前后冲突都会影响答案。</p></div>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:20px;margin-top:0">处理策略:每次只给最相关、最可信的少量资料。</p></div>
|
||||
<div class="footer"><span>上下文 · 幻觉 · 注意力 · 顺序</span><span class="page"></span></div>
|
||||
<div class="notes">这一页要明确回应用户大纲:上下文限制、幻觉、专注度、不会稳定关注内容顺序。它们共同解释了为什么不能简单粗暴地把所有文档塞进去。</div>
|
||||
|
||||
<div class="top"><div class="chapter">04</div><div class="head"><h2>LLM 的对话限制</h2><p>正因为有这些限制,不能把所有知识一次性丢给 LLM。</p></div><div class="brand"><div class="brand-mark">!</div><span>为什么需要 RAG</span></div></div>
|
||||
<div class="content">
|
||||
<div class="limit-grid">
|
||||
<div class="limit-col">
|
||||
<div class="limit"><div class="n">01</div><div><h4>上下文有限</h4><p>输入窗口有限;资料过多会变慢、变贵、变乱。</p></div></div>
|
||||
<div class="limit"><div class="n">02</div><div><h4>会有幻觉</h4><p>资料不足或指令不清时,会编出看似合理的内容。</p></div></div>
|
||||
</div>
|
||||
<div class="center-badge">不能<br>全量塞<br>资料</div>
|
||||
<div class="limit-col">
|
||||
<div class="limit"><div class="n">03</div><div><h4>专注度下降</h4><p>长资料和噪声会稀释重点,关键信息被忽略。</p></div></div>
|
||||
<div class="limit"><div class="n">04</div><div><h4>顺序不稳定</h4><p>内容位置、相似片段、前后冲突都会影响答案。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">少</div><div><strong>少量</strong><span>只给必要资料</span></div></div>
|
||||
<div class="value"><div class="vicon">准</div><div><strong>准确</strong><span>优先高相关来源</span></div></div>
|
||||
<div class="value"><div class="vicon">新</div><div><strong>新鲜</strong><span>版本和时间可控</span></div></div>
|
||||
<div class="value"><div class="vicon">规</div><div><strong>规则</strong><span>提示词约束回答边界</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Context · Hallucination · Attention · Order</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="为什么需要 RAG">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Why RAG</span><span>限制带来方案</span></div>
|
||||
<p class="kicker">从限制到方案</p>
|
||||
<h2>RAG 的核心:不是让模型记住全部知识,而是让它按需查资料</h2>
|
||||
<div class="stage">
|
||||
<div class="box"><span class="tag rose">全量输入</span><h3 class="mt-sm">全部塞给 LLM</h3><p class="small mt-sm">长、乱、贵,容易混入过期和无权限资料。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box main"><span class="tag" style="background:rgba(255,255,255,.18);color:#fff">RAG</span><h3 class="mt-sm">先查,再答</h3><p class="small mt-sm">每次只取跟问题最相关的资料片段。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box"><span class="tag mint">生成</span><h3 class="mt-sm">基于资料回答</h3><p class="small mt-sm">LLM 根据资料生成,必要时引用来源。</p></div>
|
||||
</div>
|
||||
<div class="card soft mt"><h3>RAG = 检索增强生成</h3><p class="lead" style="font-size:20px;margin-top:8px">检索相关资料 → 放入上下文 → LLM 生成答案。</p></div>
|
||||
<div class="footer"><span>先查资料,再生成</span><span class="page"></span></div>
|
||||
<div class="notes">这页把 RAG 的必要性讲出来:不是因为向量数据库酷,而是因为 LLM 的上下文和注意力有限,必须把资料筛小、筛准,再交给模型。</div>
|
||||
|
||||
<div class="top"><div class="chapter">05</div><div class="head"><h2>从限制到方案</h2><p>RAG 的核心不是让模型记住全部知识,而是让模型按需查资料。</p></div><div class="brand"><div class="brand-mark">→</div><span>方案转化</span></div></div>
|
||||
<div class="content">
|
||||
<div class="pipeline">
|
||||
<div class="pipe-card"><span class="tag red">错误做法</span><h3 style="margin-top:16px">全部塞给 LLM</h3><div class="bad-list"><span>长文档堆叠,重点被稀释</span><span>过期资料混入,结果不可信</span><span>无权限内容可能泄露</span><span>成本高,速度慢</span></div></div>
|
||||
<div class="filter"><div class="funnel">检索<br>过滤<br>排序</div></div>
|
||||
<div class="pipe-card"><span class="tag green">RAG 做法</span><h3 style="margin-top:16px">只给相关资料</h3><div class="bad-list"><span style="border-color:#8fe0cf;background:#f1fffb;color:#0b8066">按问题查资料</span><span style="border-color:#8fe0cf;background:#f1fffb;color:#0b8066">按相关性排序</span><span style="border-color:#8fe0cf;background:#f1fffb;color:#0b8066">拼成小上下文</span><span style="border-color:#8fe0cf;background:#f1fffb;color:#0b8066">让 LLM 基于依据回答</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG turns big documents into precise context</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="后台建库">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Offline Pipeline</span><span>后台整理资料</span></div>
|
||||
<p class="kicker">离线流程</p>
|
||||
<h2>资料整理成可检索的知识库</h2>
|
||||
<div class="flow">
|
||||
<div class="step"><span class="tag">01</span><h4>收集资料</h4><p>产品手册、FAQ、制度、接口文档、案例、流程说明。</p></div>
|
||||
<div class="step"><span class="tag orange">02</span><h4>清洗资料</h4><p>去掉重复、过期、广告、目录噪声和格式错误。</p></div>
|
||||
<div class="step hot"><span class="tag mint">03</span><h4>切片</h4><p>把长文档拆成能独立表达意思的小片段。</p></div>
|
||||
<div class="step"><span class="tag">04</span><h4>加元数据</h4><p>来源、版本、时间、部门、权限、适用范围。</p></div>
|
||||
<div class="step"><span class="tag">05</span><h4>向量化入库</h4><p>把每个片段变成语义向量,写入向量库。</p></div>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:20px;margin-top:0">资料质量决定检索质量。</p></div>
|
||||
<div class="footer"><span>资料准备 → 切片 → 向量库</span><span class="page"></span></div>
|
||||
<div class="notes">这里是“前期准备”。强调 RAG 不只是问答界面,后台知识库准备很关键。元数据也很重要,因为后面排序和权限过滤会用到。</div>
|
||||
|
||||
<div class="top"><div class="chapter">06</div><div class="head"><h2>后台建库:把资料整理成可检索系统</h2><p>RAG 不是只有一个问答框,前期知识库整理决定最终效果。</p></div><div class="brand"><div class="brand-mark">KB</div><span>离线流程</span></div></div>
|
||||
<div class="content">
|
||||
<div class="ribbon-row">
|
||||
<div class="ribbon">资料层</div>
|
||||
<div class="ribbon-line"><div class="icon">文</div><div class="dot-list"><span>产品手册、FAQ、制度、接口文档、案例、流程说明</span><span>确认来源、版本、时间、权限和适用范围</span></div></div>
|
||||
</div>
|
||||
<div class="ribbon-row">
|
||||
<div class="ribbon green">处理层</div>
|
||||
<div class="ribbon-line"><div class="icon">洗</div><div class="dot-list"><span>清洗重复、过期、广告、目录噪声和格式错误</span><span>长文档拆成能独立表达意思的小片段</span></div></div>
|
||||
</div>
|
||||
<div class="ribbon-row">
|
||||
<div class="ribbon navy">索引层</div>
|
||||
<div class="ribbon-line"><div class="icon">库</div><div class="dot-list"><span>每个切片转成语义向量,写入向量库</span><span>同时保留元数据,用于过滤、排序和引用来源</span></div></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">质</div><div><strong>资料质量</strong><span>决定检索上限</span></div></div>
|
||||
<div class="value"><div class="vicon">粒</div><div><strong>切片粒度</strong><span>决定召回精度</span></div></div>
|
||||
<div class="value"><div class="vicon">源</div><div><strong>元数据</strong><span>决定过滤和引用</span></div></div>
|
||||
<div class="value"><div class="vicon">库</div><div><strong>向量库</strong><span>支持语义检索</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Prepare · Clean · Chunk · Embed · Index</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="切片和向量库">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Chunk & Embedding</span><span>切片和向量化</span></div>
|
||||
<p class="kicker">索引构建</p>
|
||||
<h2>切片 + 向量化:支持语义检索</h2>
|
||||
<div class="chunks">
|
||||
<div class="chunk"><b>切片 A:退费条件</b><p>购买后 7 天内,且未使用核心服务,可以申请全额退款。</p></div>
|
||||
<div class="chunk"><b>切片 B:不可退场景</b><p>已开具发票、已交付定制服务、超过合同期限,不支持自动退款。</p></div>
|
||||
<div class="chunk"><b>切片 C:审批路径</b><p>超过 5 万元的退款申请,需要客户成功经理和财务双审批。</p></div>
|
||||
</div>
|
||||
<div class="grid2 mt">
|
||||
<div class="card"><span class="tag">切片</span><h3 class="mt-sm">粒度适中</h3><p class="small mt-sm">过大噪声多,过小语义断;每片覆盖一个局部问题。</p></div>
|
||||
<div class="card"><span class="tag mint">向量库</span><h3 class="mt-sm">语义坐标</h3><p class="small mt-sm">文字转换成数字向量;语义相近,距离更近。</p><div class="vector"><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i></div></div>
|
||||
</div>
|
||||
<div class="footer"><span>Chunking + Embedding</span><span class="page"></span></div>
|
||||
<div class="notes">用卡片和地图类比:切片是把书拆成卡片,向量化是给卡片标语义坐标。用户问法不一样,也能找到意思接近的片段。</div>
|
||||
|
||||
<div class="top"><div class="chapter">07</div><div class="head"><h2>切片 + 向量化</h2><p>把长文档拆成小卡片,再给每张卡片标上“语义坐标”。</p></div><div class="brand"><div class="brand-mark">V</div><span>语义检索</span></div></div>
|
||||
<div class="content">
|
||||
<div class="chunks">
|
||||
<div class="doc">
|
||||
<span class="tag">原始资料:退款政策</span>
|
||||
<div class="slice"><h4>切片 A:退费条件</h4><p>购买后 7 天内,且未使用核心服务,可以申请全额退款。</p></div>
|
||||
<div class="slice"><h4>切片 B:不可退场景</h4><p>已开具发票、已交付定制服务、超过合同期限,不支持自动退款。</p></div>
|
||||
<div class="slice"><h4>切片 C:审批路径</h4><p>超过 5 万元的退款申请,需要客户成功经理和财务双审批。</p></div>
|
||||
</div>
|
||||
<div class="doc">
|
||||
<span class="tag green">向量化结果</span>
|
||||
<h3 style="margin-top:14px">语义相近,距离更近</h3>
|
||||
<p style="font-size:13px;line-height:1.65;color:var(--muted);margin-top:10px">用户问“买了 3 天没用能退吗”,即使没有出现“退费条件”这几个字,也能找到切片 A。</p>
|
||||
<div class="vector-bars"><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Chunking + Embedding</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="用户提问时怎么查">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Online Retrieval</span><span>检索和排序</span></div>
|
||||
<p class="kicker">在线流程</p>
|
||||
<h2>用户提问后:检索候选,排序后交给 LLM</h2>
|
||||
<div class="flow">
|
||||
<div class="step"><span class="tag">01</span><h4>问题改写</h4><p>把口语问题改成更适合检索的查询。</p></div>
|
||||
<div class="step"><span class="tag">02</span><h4>问题向量化</h4><p>把用户问题也转成语义向量。</p></div>
|
||||
<div class="step"><span class="tag mint">03</span><h4>召回候选</h4><p>从向量库里找语义距离近的片段。</p></div>
|
||||
<div class="step hot"><span class="tag orange">04</span><h4>排序 / 重排</h4><p>按相关性、时效、权限、来源可信度重新排序。</p></div>
|
||||
<div class="step"><span class="tag">05</span><h4>拼上下文</h4><p>只把最有用的几段资料交给 LLM。</p></div>
|
||||
</div>
|
||||
<div class="search">
|
||||
<div class="card"><h3>排序后的候选资料</h3><div class="result"><div class="rank">1</div><div><h4>退款政策 v2026Q2</h4><p class="small">最相关,且版本最新。</p></div></div><div class="result"><div class="rank">2</div><div><h4>大客户审批流程</h4><p class="small">相关,但只在金额超过 5 万时使用。</p></div></div></div>
|
||||
<div class="card soft"><h3>排序作用</h3><ul><li>过期资料降权</li><li>无权限资料过滤</li><li>可信来源优先</li><li>减少噪声,保留重点</li></ul></div>
|
||||
</div>
|
||||
<div class="footer"><span>Retrieve + Rerank</span><span class="page"></span></div>
|
||||
<div class="notes">这里必须引出“排序”概念。召回只是先捞一批候选,排序/重排才决定哪些片段真正进入上下文。排序质量直接影响最终答案。</div>
|
||||
|
||||
<div class="top"><div class="chapter">08</div><div class="head"><h2>用户提问时怎么查</h2><p>召回只是先捞候选,排序 / 重排决定哪些资料真正进入上下文。</p></div><div class="brand"><div class="brand-mark">S</div><span>在线检索</span></div></div>
|
||||
<div class="content">
|
||||
<div class="flow">
|
||||
<div class="step"><div class="num">1</div><h3>问题改写</h3><p>把口语问题改成适合检索的查询。</p></div>
|
||||
<div class="step"><div class="num">2</div><h3>问题向量化</h3><p>用户问题也转成语义向量。</p></div>
|
||||
<div class="step"><div class="num">3</div><h3>召回候选</h3><p>从向量库找语义距离近的片段。</p></div>
|
||||
<div class="step hot"><div class="num">4</div><h3>排序 / 重排</h3><p>按相关性、时效、权限、来源可信度重新排序。</p></div>
|
||||
<div class="step"><div class="num">5</div><h3>拼上下文</h3><p>只把最有用的几段交给 LLM。</p></div>
|
||||
</div>
|
||||
<div class="grid2" style="margin-top:18px">
|
||||
<div class="card"><h3>排序后的候选资料</h3><div class="rank-list" style="margin-top:12px"><div class="rank"><div class="rnum">1</div><div><h4>退款政策 v2026Q2</h4><p>最相关,版本最新</p></div><div class="score">96</div></div><div class="rank"><div class="rnum">2</div><div><h4>大客户审批流程</h4><p>金额超过 5 万时使用</p></div><div class="score">78</div></div></div></div>
|
||||
<div class="card"><h3>排序看什么</h3><div class="dot-list" style="margin-top:12px"><span>相关性:是否真的回答这个问题</span><span>时效性:新版本优先,过期资料降权</span><span>权限:用户不能看的资料先过滤</span><span>可信度:制度、合同、权威来源优先</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Retrieve · Rerank · Context Assembly</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="提示词工程">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Prompt Engineering</span><span>系统提示词</span></div>
|
||||
<p class="kicker">生成约束</p>
|
||||
<h2>系统提示词规定 LLM 的资料使用规则</h2>
|
||||
<div class="search">
|
||||
<div class="prompt">
|
||||
<div class="dim">SYSTEM:</div>
|
||||
<div>你是客服助手。只能基于 CONTEXT 回答;资料不足时说“不确定”,不要编造。</div>
|
||||
<br>
|
||||
<div class="dim">CONTEXT:</div>
|
||||
<div class="mark">[退款政策 v2026Q2] 购买后 7 天内且未使用核心服务,可全额退款。</div>
|
||||
<br>
|
||||
<div class="dim">USER:</div>
|
||||
<div>客户买了 3 天,还没使用,可以退吗?</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="tag">提示词工程</span>
|
||||
<h3 class="mt-sm">规则</h3>
|
||||
<ul>
|
||||
<li>角色:你是谁</li>
|
||||
<li>边界:只能基于资料回答</li>
|
||||
<li>格式:分点、引用来源、给结论</li>
|
||||
<li>兜底:不知道就说不知道</li>
|
||||
<li>安全:不要泄露无权限信息</li>
|
||||
</ul>
|
||||
<div class="top"><div class="chapter">09</div><div class="head"><h2>提示词工程:规定资料怎么用</h2><p>RAG 找资料,系统提示词规定 LLM 的角色、边界、格式和兜底方式。</p></div><div class="brand"><div class="brand-mark">P</div><span>生成约束</span></div></div>
|
||||
<div class="content">
|
||||
<div class="grid2">
|
||||
<div class="prompt-box">
|
||||
<b>SYSTEM</b><br>
|
||||
你是客服助手。只能基于 CONTEXT 回答;资料不足时说“不确定”,不要编造。<br><br>
|
||||
<b>CONTEXT</b><br>
|
||||
<mark>[退款政策 v2026Q2]</mark> 购买后 7 天内且未使用核心服务,可全额退款。<br><br>
|
||||
<b>USER</b><br>
|
||||
客户买了 3 天,还没使用,可以退吗?
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div class="stack-layer"><div class="diagram-title">提示词工程</div><div class="stack-items"><div class="mini"><strong>角色</strong><span>你是谁</span></div><div class="mini"><strong>边界</strong><span>只能基于资料</span></div><div class="mini"><strong>格式</strong><span>结论、理由、来源</span></div></div></div>
|
||||
<div class="stack-layer"><div class="diagram-title" style="background:linear-gradient(135deg,#0aa58f,#14c8bf)">安全兜底</div><div class="stack-items"><div class="mini"><strong>不知道</strong><span>就说不确定</span></div><div class="mini"><strong>冲突</strong><span>说明版本差异</span></div><div class="mini"><strong>权限</strong><span>不泄露资料</span></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>System Prompt + Context + User Question</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:20px;margin-top:0">RAG 找资料;提示词规定资料的使用方式。</p></div>
|
||||
<div class="footer"><span>System Prompt + Context + User Question</span><span class="page"></span></div>
|
||||
<div class="notes">这一页承接用户大纲:给到 LLM 的时候需要系统提示词,由此引出提示词工程。提示词工程重点不是花哨话术,而是角色、边界、格式、引用和兜底。</div>
|
||||
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="Agent">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Agent</span><span>复杂任务的分工协作</span></div>
|
||||
<p class="kicker">复杂任务</p>
|
||||
<h2>复杂任务:多步骤、多角色、多 LLM 协作</h2>
|
||||
<div class="agent-map">
|
||||
<div class="agent"><span class="tag">规划者</span><h3 class="mt-sm">拆任务</h3><p>规划检索、工具调用和结果组织。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="agent"><span class="tag mint">检索者</span><h3 class="mt-sm">查资料</h3><p>使用 RAG 从知识库里找依据,必要时多轮检索。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="agent"><span class="tag orange">执行者</span><h3 class="mt-sm">调用工具</h3><p>查订单、建工单、读数据库、调用业务系统接口。</p></div>
|
||||
</div>
|
||||
<div class="grid2 mt">
|
||||
<div class="card"><h3>Agent</h3><p class="lead" style="font-size:19px;margin-top:8px">目标驱动流程:规划步骤、选择工具、读取结果、继续推进。</p></div>
|
||||
<div class="card soft"><h3>和 RAG 的关系</h3><p class="lead" style="font-size:19px;margin-top:8px">RAG 提供知识入口;Agent 负责任务编排。</p></div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG 是知识入口,Agent 是任务编排</span><span class="page"></span></div>
|
||||
<div class="notes">不要把 Agent 讲玄。它就是更复杂任务里的规划和编排:可能一个 LLM 规划,一个 LLM 检索,一个 LLM 写答案,也可能调用外部工具。</div>
|
||||
|
||||
<div class="top"><div class="chapter">10</div><div class="head"><h2>复杂任务需要 Agent</h2><p>当任务不只是“答一句话”,就需要规划、检索、执行、校验等分工。</p></div><div class="brand"><div class="brand-mark">A</div><span>任务编排</span></div></div>
|
||||
<div class="content">
|
||||
<div class="agent-map">
|
||||
<div class="agent-card"><div class="icon">规</div><h3>规划者</h3><p>拆步骤,决定先查什么、再调用什么工具。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="agent-card"><div class="icon">查</div><h3>检索者</h3><p>使用 RAG 找依据,必要时多轮检索。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="agent-card"><div class="icon">执</div><h3>执行者</h3><p>查订单、建工单、读数据库、调用接口。</p></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">R</div><div><strong>RAG</strong><span>提供知识入口</span></div></div>
|
||||
<div class="value"><div class="vicon">P</div><div><strong>Prompt</strong><span>规定回答规则</span></div></div>
|
||||
<div class="value"><div class="vicon">A</div><div><strong>Agent</strong><span>负责任务编排</span></div></div>
|
||||
<div class="value"><div class="vicon">T</div><div><strong>Tools</strong><span>执行外部动作</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Agent = Plan + Retrieve + Act + Check</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="MCP">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>MCP</span><span>工具连接</span></div>
|
||||
<p class="kicker">MCP</p>
|
||||
<h2>MCP:让 Agent 稳定连接外部工具和业务系统</h2>
|
||||
<div class="mcp-grid">
|
||||
<div class="mcp-card"><span class="tag">统一接口</span><h3 class="mt-sm">工具接入规范</h3><p class="small mt-sm">把不同系统的能力包装成模型可调用的工具。</p></div>
|
||||
<div class="mcp-card"><span class="tag mint">上下文供给</span><h3 class="mt-sm">读取外部信息</h3><p class="small mt-sm">查数据库、读文件、取工单、访问知识系统。</p></div>
|
||||
<div class="mcp-card"><span class="tag orange">动作执行</span><h3 class="mt-sm">调用业务能力</h3><p class="small mt-sm">创建工单、查询订单、发送通知、写入结果。</p></div>
|
||||
</div>
|
||||
<div class="tool-list">
|
||||
<span>CRM</span><span>工单</span><span>数据库</span><span>搜索</span><span>文件</span>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:20px;margin-top:0">RAG 负责查知识;Agent 负责编排任务;MCP 负责连接工具。</p></div>
|
||||
<div class="footer"><span>RAG · Agent · MCP</span><span class="page"></span></div>
|
||||
<div class="notes">这页讲 MCP。它不需要展开协议细节,只要说明 MCP 是让模型/Agent 稳定连接外部工具和系统的接口层。</div>
|
||||
|
||||
<div class="top"><div class="chapter">11</div><div class="head"><h2>MCP:让 Agent 连接工具</h2><p>MCP 可以理解为模型/Agent 调用外部系统的一套统一连接方式。</p></div><div class="brand"><div class="brand-mark">M</div><span>工具连接</span></div></div>
|
||||
<div class="content">
|
||||
<div class="tool-bus">
|
||||
<div class="card"><span class="tag">Agent</span><h3 style="margin-top:14px">会规划任务</h3><p style="margin-top:10px">但真正查数据、改状态、发通知,需要连接业务系统。</p></div>
|
||||
<div class="bus">MCP<br><span style="font-size:14px;font-weight:700">统一工具接口 / 上下文供给 / 动作执行</span></div>
|
||||
<div class="tools">
|
||||
<div class="tool">CRM<span>客户资料</span></div>
|
||||
<div class="tool">工单<span>创建 / 查询</span></div>
|
||||
<div class="tool">数据库<span>读数据</span></div>
|
||||
<div class="tool">文件<span>读文档</span></div>
|
||||
<div class="tool">搜索<span>查外部信息</span></div>
|
||||
<div class="tool">消息<span>发送通知</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">知</div><div><strong>RAG</strong><span>查知识</span></div></div>
|
||||
<div class="value"><div class="vicon">编</div><div><strong>Agent</strong><span>编排任务</span></div></div>
|
||||
<div class="value"><div class="vicon">接</div><div><strong>MCP</strong><span>连接工具</span></div></div>
|
||||
<div class="value"><div class="vicon">做</div><div><strong>业务系统</strong><span>完成动作</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Model Context Protocol</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="总结">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Takeaway</span><span>关键链路</span></div>
|
||||
<p class="kicker">核心链路</p>
|
||||
<h2>RAG → LLM 限制 → 切片 → 向量化 → 语义检索 → 排序 → 提示词 → Agent → MCP</h2>
|
||||
<div class="summary">
|
||||
<div class="card"><span class="tag rose">限制</span><h3 class="mt-sm">LLM 不能吃下全部知识</h3><p class="small mt-sm">上下文有限、会幻觉、专注度和顺序都不稳定。</p></div>
|
||||
<div class="card"><span class="tag mint">建库</span><h3 class="mt-sm">资料要先整理成片段</h3><p class="small mt-sm">清洗、切片、向量化、加元数据,再放入向量库。</p></div>
|
||||
<div class="card"><span class="tag orange">检索</span><h3 class="mt-sm">提问时先找资料</h3><p class="small mt-sm">召回候选,再排序过滤,把最相关内容放进上下文。</p></div>
|
||||
<div class="card"><span class="tag">扩展</span><h3 class="mt-sm">Agent + MCP</h3><p class="small mt-sm">Agent 编排多步骤任务;MCP 连接外部工具和系统。</p></div>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:21px;margin-top:0">RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。</p></div>
|
||||
<div class="footer"><span>End</span><span class="page"></span></div>
|
||||
<div class="notes">收尾不要再加新概念。重复主线:LLM 有限制,所以需要 RAG;RAG 后台建库,前台检索排序,再通过提示词交给 LLM;复杂任务用 Agent。</div>
|
||||
|
||||
<div class="top"><div class="chapter">12</div><div class="head"><h2>入门之后,要抓住这条主线</h2><p>LLM 有限制,所以需要 RAG;RAG 把资料找准,再交给 LLM 生成。</p></div><div class="brand"><div class="brand-mark">✓</div><span>核心链路</span></div></div>
|
||||
<div class="content">
|
||||
<div class="flow">
|
||||
<div class="step"><div class="num">1</div><h3>LLM</h3><p>负责理解和表达,但不是企业知识库。</p></div>
|
||||
<div class="step"><div class="num">2</div><h3>限制</h3><p>上下文、幻觉、专注度、顺序带来风险。</p></div>
|
||||
<div class="step"><div class="num">3</div><h3>RAG</h3><p>后台建库;前台检索;排序后拼上下文。</p></div>
|
||||
<div class="step"><div class="num">4</div><h3>Prompt</h3><p>规定角色、边界、格式、兜底和安全。</p></div>
|
||||
<div class="step"><div class="num">5</div><h3>Agent + MCP</h3><p>复杂任务用 Agent 编排,用 MCP 连接工具。</p></div>
|
||||
</div>
|
||||
<div class="stack-layer" style="margin-top:24px;text-align:center">
|
||||
<div class="diagram-title">一句话总结</div>
|
||||
<h3 style="font-size:30px;margin-top:18px;color:#061b4e">RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>End</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -6,398 +6,476 @@
|
||||
<title>RAG 入门:原理、流程与使用</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f6f8fb;
|
||||
--paper:#ffffff;
|
||||
--ink:#0a2540;
|
||||
--muted:#486176;
|
||||
--soft:#eef3f8;
|
||||
--line:#d9e2ec;
|
||||
--blue:#1d4ed8;
|
||||
--mint:#0f9f8f;
|
||||
--orange:#d97706;
|
||||
--rose:#e11d48;
|
||||
--shadow:0 14px 34px rgba(10,37,64,.10),0 2px 7px rgba(10,37,64,.05);
|
||||
--blue:#0b63f6;
|
||||
--blue2:#0f8bff;
|
||||
--cyan:#15c8c2;
|
||||
--green:#12a57d;
|
||||
--navy:#061b4e;
|
||||
--ink:#102047;
|
||||
--muted:#51627f;
|
||||
--line:#c8dcff;
|
||||
--soft:#f3f8ff;
|
||||
--glass:rgba(255,255,255,.86);
|
||||
--shadow:0 20px 46px rgba(16,45,100,.16),0 2px 10px rgba(16,45,100,.08);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC","Microsoft YaHei",Arial,sans-serif}
|
||||
.deck{position:relative;width:100vw;height:100vh;overflow:hidden;background:linear-gradient(135deg,#f8fafc,#eef3f8)}
|
||||
.slide{position:absolute;inset:0;display:none;padding:72px 88px;background:radial-gradient(circle at 85% 12%,rgba(29,78,216,.08),transparent 28%),var(--paper);align-items:center;justify-content:center}
|
||||
.slide.active{display:flex}
|
||||
.slide-inner{width:100%;max-height:calc(100vh - 150px)}
|
||||
.slide::before{content:"";position:absolute;left:0;top:0;bottom:0;width:8px;background:linear-gradient(180deg,var(--blue),var(--mint))}
|
||||
.topline{position:absolute;left:88px;right:88px;top:28px;display:flex;justify-content:space-between;align-items:center;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#8aa0b5}
|
||||
.footer{position:absolute;left:88px;right:88px;bottom:24px;display:flex;justify-content:space-between;color:#8aa0b5;font-size:12px}
|
||||
.progress{position:fixed;left:0;right:0;bottom:0;height:4px;background:#dbe4ee;z-index:20}
|
||||
.progress span{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--blue),var(--mint));transition:width .2s ease}
|
||||
.nav-controls{position:fixed;right:28px;bottom:26px;z-index:30;display:flex;align-items:center;gap:8px;padding:8px;border:1px solid rgba(10,37,64,.12);border-radius:999px;background:rgba(255,255,255,.92);box-shadow:0 8px 26px rgba(10,37,64,.14);backdrop-filter:blur(10px)}
|
||||
.nav-btn{width:38px;height:38px;border:0;border-radius:50%;background:#0a2540;color:#fff;font-size:22px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.nav-btn:disabled{opacity:.32;cursor:not-allowed}
|
||||
.nav-index{min-width:58px;text-align:center;font-size:13px;font-weight:800;color:var(--muted)}
|
||||
.kicker{margin:0 0 12px;color:var(--blue);font-size:15px;font-weight:700;letter-spacing:.1em;text-transform:uppercase}
|
||||
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#eef3fb;color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC","Microsoft YaHei",Arial,sans-serif}
|
||||
body{display:grid;place-items:center}
|
||||
h1,h2,h3,h4,p{margin:0}
|
||||
h1{font-size:68px;line-height:1.05;letter-spacing:0;font-weight:800;max-width:980px}
|
||||
h2{font-size:44px;line-height:1.15;letter-spacing:0;font-weight:760;max-width:1040px}
|
||||
h3{font-size:24px;line-height:1.25;font-weight:740}
|
||||
h4{font-size:18px;line-height:1.3;font-weight:730}
|
||||
.lead{font-size:23px;line-height:1.55;color:var(--muted);max-width:900px;margin-top:22px}
|
||||
.muted{color:var(--muted)}
|
||||
.accent{background:linear-gradient(135deg,var(--blue),var(--mint));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.row{display:flex;gap:16px;align-items:stretch}
|
||||
.grid2{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}
|
||||
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
||||
.mt{margin-top:30px}.mt-sm{margin-top:16px}.mt-lg{margin-top:42px}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:22px}
|
||||
.card.soft{background:var(--soft);box-shadow:none}
|
||||
.tag{display:inline-flex;align-items:center;justify-content:center;border-radius:999px;padding:5px 12px;background:#edf4ff;color:var(--blue);font-size:12px;font-weight:800}
|
||||
.tag.mint{background:#e8faf7;color:var(--mint)}
|
||||
.tag.orange{background:#fff4df;color:var(--orange)}
|
||||
.tag.rose{background:#fff0f4;color:var(--rose)}
|
||||
.num{width:40px;height:40px;border-radius:9px;background:var(--blue);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;margin-bottom:14px}
|
||||
.num.mint{background:var(--mint)}.num.orange{background:var(--orange)}.num.rose{background:var(--rose)}
|
||||
.pillbar{display:flex;flex-wrap:wrap;gap:10px;margin-top:32px}
|
||||
.pill{border:1px solid var(--line);background:#fff;border-radius:999px;padding:8px 14px;font-size:14px;color:var(--muted);font-weight:650}
|
||||
.stage{display:grid;grid-template-columns:1fr 52px 1fr 52px 1fr;gap:10px;align-items:center;margin-top:38px}
|
||||
.stage .box{text-align:center;padding:28px 18px;border:1px solid var(--line);border-radius:12px;background:#fff;box-shadow:var(--shadow)}
|
||||
.stage .box.main{background:var(--blue);color:#fff}
|
||||
.stage .box.main p{color:#dbeafe}
|
||||
.arrow{font-size:34px;color:#93a4b5;text-align:center;font-weight:900}
|
||||
.compare{display:grid;grid-template-columns:1fr 88px 1fr;gap:22px;align-items:center;margin-top:34px}
|
||||
.compare .mid{text-align:center;font-size:42px;color:#9aacbd;font-weight:900}
|
||||
ul{margin:14px 0 0;padding-left:22px;color:var(--muted);line-height:1.85;font-size:18px}
|
||||
.small{font-size:14px;line-height:1.55;color:var(--muted)}
|
||||
.flow{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-top:30px}
|
||||
.flow .step{position:relative;background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:18px;min-height:150px}
|
||||
.flow .step h4{margin:8px 0 6px}.flow .step p{font-size:13px;line-height:1.5;color:var(--muted)}
|
||||
.flow .step.hot{border-color:rgba(29,78,216,.45);box-shadow:0 0 0 3px rgba(29,78,216,.10),var(--shadow)}
|
||||
.chunks{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:24px}
|
||||
.chunk{border:1px dashed #9fb0c2;background:#fff;border-radius:9px;padding:17px;min-height:122px}
|
||||
.chunk b{color:var(--blue);font-size:14px}.chunk p{font-size:13px;line-height:1.6;color:var(--muted);margin-top:8px}
|
||||
.vector{display:grid;grid-template-columns:repeat(10,1fr);gap:6px;margin-top:14px}
|
||||
.bar{height:44px;border-radius:5px;background:var(--blue);opacity:.28}.bar:nth-child(2n){opacity:.55}.bar:nth-child(3n){opacity:.82}.bar:nth-child(5n){opacity:.42}
|
||||
.search{display:grid;grid-template-columns:1.02fr .98fr;gap:20px;margin-top:26px}
|
||||
.result{display:flex;gap:12px;border:1px solid var(--line);border-radius:9px;background:#fff;padding:14px;margin-top:10px}
|
||||
.rank{width:34px;height:34px;border-radius:8px;background:#edf4ff;color:var(--blue);display:flex;align-items:center;justify-content:center;font-weight:900;flex-shrink:0}
|
||||
.prompt{background:#09213a;color:#e8f1ff;border-radius:12px;box-shadow:var(--shadow);padding:22px 24px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:15px;line-height:1.85}
|
||||
.prompt .dim{color:#8fb1d6}.prompt .mark{color:#fde68a}
|
||||
.agent-map{display:grid;grid-template-columns:1fr 46px 1fr 46px 1fr;gap:10px;align-items:center;margin-top:28px}
|
||||
.agent{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:20px;text-align:center;min-height:170px}
|
||||
.agent p{font-size:13px;line-height:1.55;color:var(--muted);margin-top:8px}
|
||||
.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-top:34px}
|
||||
.summary .card{min-height:150px}
|
||||
.learning-map{display:grid;grid-template-columns:1fr 38px 1fr 38px 1fr 38px 1fr 38px 1fr;gap:10px;align-items:stretch;margin-top:34px}
|
||||
.map-card{background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);padding:20px 16px;min-height:270px}
|
||||
.map-card .num{margin-bottom:16px}
|
||||
.map-card h3{font-size:21px}
|
||||
.map-list{margin:14px 0 0;padding-left:18px;color:var(--muted);font-size:14px;line-height:1.75}
|
||||
.map-arrow{display:flex;align-items:center;justify-content:center;font-size:28px;color:#9aacbd;font-weight:900}
|
||||
.mini-flow{display:grid;grid-template-columns:1fr 44px 1fr 44px 1fr;gap:10px;align-items:center;margin-top:32px}
|
||||
.mini-flow .node{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:18px;text-align:center;min-height:126px}
|
||||
.mini-flow .node.main{background:var(--blue);color:#fff}
|
||||
.mini-flow .node.main p{color:#dbeafe}
|
||||
.mini-flow p{font-size:13px;line-height:1.5;color:var(--muted);margin-top:6px}
|
||||
.mcp-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-top:30px}
|
||||
.mcp-card{background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:22px;min-height:205px}
|
||||
.tool-list{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:24px}
|
||||
.tool-list span{display:flex;align-items:center;justify-content:center;min-height:58px;border-radius:10px;background:var(--soft);border:1px solid var(--line);font-size:14px;font-weight:800;color:var(--muted)}
|
||||
.deck{position:relative;width:100vw;height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 8% 15%,rgba(11,99,246,.13),transparent 24%),radial-gradient(circle at 90% 80%,rgba(21,200,194,.16),transparent 28%),#edf3fb}
|
||||
.slide{position:relative;display:none;width:min(100vw,calc(100vh * 16 / 9));aspect-ratio:16/9;max-height:100vh;background:#fff;overflow:hidden;box-shadow:var(--shadow);padding:46px 56px 42px}
|
||||
.slide.active{display:block}
|
||||
.slide::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(12,97,246,.05) 1px,transparent 1px),linear-gradient(rgba(12,97,246,.05) 1px,transparent 1px);background-size:36px 36px;mask-image:radial-gradient(ellipse at 45% 40%,black 18%,transparent 82%);pointer-events:none}
|
||||
.slide::after{content:"";position:absolute;left:-120px;bottom:-70px;width:580px;height:180px;background:repeating-linear-gradient(155deg,rgba(11,99,246,.11) 0 2px,transparent 2px 12px);transform:skewX(-18deg);opacity:.55;pointer-events:none}
|
||||
.slide > *{position:relative;z-index:1}
|
||||
.slide-inner{height:100%;display:flex;flex-direction:column;gap:18px}
|
||||
.top{height:66px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:22px;border-bottom:2px solid #dbe8ff;padding-bottom:14px}
|
||||
.chapter{height:56px;min-width:122px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:30px;font-weight:900;letter-spacing:.03em;background:linear-gradient(135deg,#0054e6,#0d82ff);clip-path:polygon(0 0,82% 0,100% 50%,82% 100%,0 100%);box-shadow:0 10px 22px rgba(11,99,246,.24)}
|
||||
.head h2{font-size:34px;line-height:1.05;font-weight:900;letter-spacing:0;color:#061b4e}
|
||||
.head p{font-size:15px;color:var(--muted);margin-top:6px}
|
||||
.brand{display:flex;align-items:center;gap:10px;color:var(--blue);font-weight:900}
|
||||
.brand-mark{width:44px;height:44px;border:2px solid var(--blue);border-radius:12px;display:grid;place-items:center;background:#fff;font-weight:900}
|
||||
.content{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:center}
|
||||
.footer{height:42px;display:flex;align-items:flex-end;justify-content:space-between;color:#7d91b5;font-size:11px;letter-spacing:.14em;text-transform:uppercase;border-top:1px solid #dbe8ff;padding-top:12px}
|
||||
.page-corner{position:absolute;right:0;bottom:0;width:86px;height:52px;background:linear-gradient(135deg,#0b63f6,#0050ca);clip-path:polygon(38% 0,100% 0,100% 100%,0 100%);color:#fff;font-size:18px;font-weight:900;display:flex;align-items:flex-end;justify-content:flex-end;padding:0 18px 12px;z-index:3}
|
||||
.kicker{font-size:13px;font-weight:900;letter-spacing:.18em;color:var(--blue);text-transform:uppercase;margin-bottom:8px}
|
||||
.big-title{font-size:58px;line-height:1.08;font-weight:950;color:#061b4e;letter-spacing:0}
|
||||
.sub{font-size:20px;line-height:1.55;color:var(--muted);max-width:760px;margin-top:14px}
|
||||
.accent{background:linear-gradient(135deg,var(--blue),var(--cyan));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
|
||||
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
|
||||
.card{background:var(--glass);border:1.5px solid var(--line);border-radius:14px;box-shadow:0 12px 26px rgba(38,91,160,.09);padding:18px}
|
||||
.card h3{font-size:20px;color:#08205d;font-weight:900}
|
||||
.card h4{font-size:17px;color:#08205d;font-weight:900}
|
||||
.card p,.card li{font-size:13px;line-height:1.55;color:var(--muted)}
|
||||
.tag{display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 12px;border-radius:999px;background:#eaf3ff;color:var(--blue);font-size:12px;font-weight:900}
|
||||
.tag.green{background:#e7fbf5;color:var(--green)}
|
||||
.tag.cyan{background:#e7fbff;color:#0796a5}
|
||||
.tag.warn{background:#fff4de;color:#d27a00}
|
||||
.tag.red{background:#fff0f4;color:#d81948}
|
||||
.icon{width:56px;height:56px;border-radius:50%;display:grid;place-items:center;background:#fff;border:1px solid #c8dcff;box-shadow:0 8px 18px rgba(11,99,246,.12);color:var(--blue);font-size:23px;font-weight:950;margin-bottom:10px}
|
||||
.ribbon-row{display:grid;grid-template-columns:175px 1fr;gap:18px;align-items:center;margin:14px 0}
|
||||
.ribbon{height:78px;display:flex;align-items:center;gap:14px;color:#fff;background:linear-gradient(135deg,var(--blue),#0050d8);clip-path:polygon(0 0,88% 0,100% 50%,88% 100%,0 100%);border-radius:10px 0 0 10px;padding:0 22px;font-size:22px;font-weight:950}
|
||||
.ribbon.green{background:linear-gradient(135deg,#08a88f,#06c1b8)}
|
||||
.ribbon.navy{background:linear-gradient(135deg,#12327a,#0b63f6)}
|
||||
.ribbon-line{min-height:78px;border:1.5px solid var(--line);border-radius:12px;background:rgba(255,255,255,.78);display:grid;grid-template-columns:90px 1fr;align-items:center;padding:14px 22px}
|
||||
.dot-list{display:grid;gap:7px}
|
||||
.dot-list span{position:relative;padding-left:16px;color:#1b2b58;font-size:14px;line-height:1.4}
|
||||
.dot-list span::before{content:"";position:absolute;left:0;top:.62em;width:6px;height:6px;border-radius:50%;background:var(--blue)}
|
||||
.diagram-title{height:34px;display:inline-flex;align-items:center;padding:0 18px;border-radius:10px;background:linear-gradient(135deg,var(--blue),#0053df);color:#fff;font-weight:950;box-shadow:0 10px 18px rgba(11,99,246,.22)}
|
||||
.stack{display:grid;gap:12px}
|
||||
.stack-layer{position:relative;border:1.5px solid #a7c8ff;border-radius:20px;background:linear-gradient(180deg,#fff,rgba(237,247,255,.92));box-shadow:0 14px 26px rgba(11,99,246,.12);padding:18px 20px}
|
||||
.stack-layer::after{content:"";position:absolute;left:42px;right:42px;bottom:-11px;height:18px;border-radius:0 0 22px 22px;background:linear-gradient(90deg,rgba(11,99,246,.18),rgba(21,200,194,.18));z-index:-1}
|
||||
.stack-items{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:12px}
|
||||
.mini{background:#fff;border:1px solid #d7e6ff;border-radius:12px;padding:13px;text-align:center;min-height:92px}
|
||||
.mini strong{display:block;font-size:16px;color:#08205d;margin-bottom:4px}
|
||||
.mini span{font-size:12px;line-height:1.35;color:var(--muted)}
|
||||
.side-notes{display:grid;gap:20px}
|
||||
.side-note{display:grid;grid-template-columns:56px 1fr;gap:12px;align-items:center}
|
||||
.side-note .round{width:52px;height:52px;border-radius:50%;border:2px solid #d7e6ff;background:#fff;display:grid;place-items:center;color:var(--blue);font-weight:950;font-size:20px}
|
||||
.side-note strong{display:block;color:var(--blue);font-size:16px}
|
||||
.side-note span{font-size:12px;color:var(--muted)}
|
||||
.value-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:18px;border:1px solid #d8e6ff;border-radius:14px;background:rgba(255,255,255,.76);padding:12px 14px}
|
||||
.value{display:grid;grid-template-columns:42px 1fr;gap:10px;align-items:center;border-right:1px solid #d8e6ff}
|
||||
.value:last-child{border-right:0}
|
||||
.value .vicon{width:38px;height:38px;border-radius:10px;background:#eaf3ff;color:var(--blue);display:grid;place-items:center;font-weight:950}
|
||||
.value strong{display:block;font-size:15px;color:var(--blue)}
|
||||
.value span{font-size:11px;color:var(--muted)}
|
||||
.flow{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;align-items:stretch}
|
||||
.step{position:relative;min-height:142px;padding:16px;border:1.5px solid #bdd7ff;border-radius:14px;background:#fff;box-shadow:0 10px 22px rgba(11,99,246,.08)}
|
||||
.step::after{content:"";position:absolute;right:-13px;top:50%;width:14px;height:2px;background:#8db9ff}
|
||||
.step:last-child::after{display:none}
|
||||
.step .num{width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,var(--blue),var(--cyan));color:#fff;display:grid;place-items:center;font-weight:950;margin-bottom:10px}
|
||||
.step h3{font-size:18px;color:#08205d;font-weight:950}
|
||||
.step p{font-size:12px;line-height:1.45;color:var(--muted);margin-top:6px}
|
||||
.step.hot{border-color:var(--cyan);box-shadow:0 0 0 4px rgba(21,200,194,.12),0 10px 22px rgba(11,99,246,.08)}
|
||||
.arrow{font-size:24px;color:#75a7ff;font-weight:950;text-align:center}
|
||||
.compare{display:grid;grid-template-columns:1fr 82px 1fr;gap:16px;align-items:center}
|
||||
.compare .mid{height:82px;border-radius:50%;display:grid;place-items:center;background:linear-gradient(135deg,var(--blue),var(--cyan));color:#fff;font-size:26px;font-weight:950;box-shadow:0 12px 24px rgba(11,99,246,.24)}
|
||||
.center-badge{width:150px;height:150px;border-radius:36px;background:linear-gradient(135deg,#0b63f6,#12c8c2);color:#fff;display:grid;place-items:center;text-align:center;font-size:28px;font-weight:950;box-shadow:0 20px 38px rgba(11,99,246,.28);margin:auto}
|
||||
.limit-grid{display:grid;grid-template-columns:1fr 170px 1fr;gap:18px;align-items:center}
|
||||
.limit-col{display:grid;gap:14px}
|
||||
.limit{display:grid;grid-template-columns:50px 1fr;gap:12px;align-items:center;border:1.5px solid #cfe0ff;border-radius:14px;background:#fff;padding:14px}
|
||||
.limit .n{width:46px;height:46px;border-radius:12px;background:#eaf3ff;color:var(--blue);display:grid;place-items:center;font-weight:950}
|
||||
.pipeline{display:grid;grid-template-columns:1.1fr 1.2fr 1.1fr;gap:18px;align-items:center}
|
||||
.pipe-card{border:1.5px solid #cfe0ff;border-radius:18px;background:#fff;padding:18px;min-height:260px}
|
||||
.bad-list{display:grid;gap:10px;margin-top:14px}
|
||||
.bad-list span{border:1px dashed #ffb4c4;border-radius:10px;background:#fff7fa;color:#a6193c;padding:10px;font-size:13px}
|
||||
.filter{display:grid;place-items:center;min-height:280px}
|
||||
.filter .funnel{width:190px;height:230px;background:linear-gradient(180deg,#e9f3ff,#fff);clip-path:polygon(8% 0,92% 0,62% 48%,62% 100%,38% 100%,38% 48%);border:2px solid #9fc3ff;filter:drop-shadow(0 12px 20px rgba(11,99,246,.16));display:grid;place-items:center;color:var(--blue);font-size:28px;font-weight:950}
|
||||
.chunks{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:center}
|
||||
.doc{border:1.5px solid #cfe0ff;border-radius:18px;background:#fff;padding:18px}
|
||||
.slice{border-left:5px solid var(--blue);border-radius:10px;background:#f6faff;padding:12px;margin-top:10px}
|
||||
.vector-bars{display:grid;grid-template-columns:repeat(12,1fr);gap:6px;margin-top:16px}
|
||||
.bar{height:56px;border-radius:7px;background:var(--blue);opacity:.25}
|
||||
.bar:nth-child(2n){opacity:.45}.bar:nth-child(3n){opacity:.75}.bar:nth-child(5n){opacity:.9}
|
||||
.rank-list{display:grid;gap:10px}
|
||||
.rank{display:grid;grid-template-columns:42px 1fr auto;gap:12px;align-items:center;border:1.5px solid #cfe0ff;border-radius:12px;background:#fff;padding:12px}
|
||||
.rank .rnum{width:38px;height:38px;border-radius:10px;background:#eaf3ff;color:var(--blue);display:grid;place-items:center;font-weight:950}
|
||||
.score{font-size:20px;font-weight:950;color:var(--green)}
|
||||
.prompt-box{background:#071d48;color:#e7f0ff;border-radius:18px;padding:18px 20px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:13px;line-height:1.65;box-shadow:0 18px 34px rgba(7,29,72,.24)}
|
||||
.prompt-box b{color:#7dd3fc}.prompt-box mark{background:rgba(250,204,21,.18);color:#fde68a;padding:1px 4px;border-radius:4px}
|
||||
.agent-map{display:grid;grid-template-columns:1fr 70px 1fr 70px 1fr;gap:10px;align-items:center}
|
||||
.agent-card{border:1.5px solid #cfe0ff;border-radius:18px;background:#fff;padding:18px;text-align:center;min-height:190px}
|
||||
.agent-card .icon{margin:0 auto 12px}
|
||||
.tool-bus{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:18px;align-items:center}
|
||||
.bus{height:96px;border-radius:22px;background:linear-gradient(135deg,var(--blue),var(--cyan));color:#fff;display:grid;place-items:center;text-align:center;font-size:26px;font-weight:950;box-shadow:0 16px 30px rgba(11,99,246,.24)}
|
||||
.tools{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
|
||||
.tool{border:1.5px solid #cfe0ff;background:#fff;border-radius:14px;padding:15px;text-align:center;font-weight:900;color:#08205d}
|
||||
.tool span{display:block;font-size:12px;color:var(--muted);font-weight:500;margin-top:4px}
|
||||
.cover{padding:58px 70px 52px}
|
||||
.cover .slide-inner{justify-content:center}
|
||||
.cover-grid{display:grid;grid-template-columns:1.05fr .95fr;gap:34px;align-items:center}
|
||||
.cover-panel{border:1.5px solid #cfe0ff;background:rgba(255,255,255,.82);border-radius:24px;padding:26px;box-shadow:0 14px 30px rgba(11,99,246,.12)}
|
||||
.cover-visual{position:relative;height:440px}
|
||||
.orb{position:absolute;border-radius:50%;background:linear-gradient(135deg,var(--blue),var(--cyan));box-shadow:0 24px 44px rgba(11,99,246,.24)}
|
||||
.orb.one{width:205px;height:205px;left:160px;top:64px;display:grid;place-items:center;color:#fff;font-size:54px;font-weight:950}
|
||||
.ring{position:absolute;border:2px dashed #9ac1ff;border-radius:50%;inset:22px;animation:spin 18s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.orbit-card{position:absolute;width:150px;border:1px solid #cfe0ff;border-radius:16px;background:#fff;padding:14px;text-align:center;box-shadow:0 12px 22px rgba(11,99,246,.13)}
|
||||
.orbit-card.a{left:16px;top:36px}.orbit-card.b{right:18px;top:56px}.orbit-card.c{left:40px;bottom:52px}.orbit-card.d{right:42px;bottom:28px}
|
||||
.orbit-card strong{display:block;color:var(--blue);font-size:18px}.orbit-card span{font-size:12px;color:var(--muted)}
|
||||
.nav-controls{position:fixed;left:50%;bottom:10px;transform:translateX(-50%);z-index:30;display:flex;align-items:center;gap:6px;padding:6px;border:1px solid rgba(16,32,71,.12);border-radius:999px;background:rgba(255,255,255,.74);box-shadow:0 8px 24px rgba(16,32,71,.12);backdrop-filter:blur(10px);opacity:.72;transition:opacity .18s ease,background .18s ease}
|
||||
.nav-controls:hover,.nav-controls:focus-within{opacity:1;background:rgba(255,255,255,.94)}
|
||||
.nav-btn{width:32px;height:32px;border:0;border-radius:50%;background:#061b4e;color:#fff;font-size:19px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.nav-btn:disabled{opacity:.32;cursor:not-allowed}
|
||||
.nav-index{min-width:58px;text-align:center;font-size:13px;font-weight:900;color:var(--muted)}
|
||||
.progress{position:fixed;left:0;right:0;bottom:0;height:4px;background:#dbe6f6;z-index:25}
|
||||
.progress span{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--blue),var(--cyan));transition:width .2s ease}
|
||||
.notes{display:none}
|
||||
@media (max-aspect-ratio:16/9){
|
||||
.slide{width:calc(100vh * 16 / 9);height:100vh}
|
||||
}
|
||||
@media (max-width:900px){
|
||||
.slide{padding:62px 28px 54px;overflow:auto;align-items:flex-start}
|
||||
.slide-inner{max-height:none}
|
||||
.topline,.footer{left:28px;right:28px}
|
||||
h1{font-size:42px}h2{font-size:32px}.lead{font-size:18px}
|
||||
.grid2,.grid3,.grid4,.flow,.chunks,.search,.summary,.learning-map,.mcp-grid,.tool-list{grid-template-columns:1fr}
|
||||
.stage,.compare,.agent-map,.mini-flow{grid-template-columns:1fr}
|
||||
.arrow,.map-arrow,.compare .mid{display:none}
|
||||
body{overflow:auto;display:block}
|
||||
html,body{height:auto;min-height:100%;overflow:auto}
|
||||
.deck{display:block;height:auto;min-height:100vh;padding:0;background:#fff}
|
||||
.slide{width:100%;height:auto;aspect-ratio:auto;min-height:100vh;box-shadow:none;padding:28px 22px 34px}
|
||||
.slide.active{display:block}
|
||||
.top{height:auto;grid-template-columns:1fr;gap:10px}
|
||||
.chapter{width:112px}
|
||||
.head h2{font-size:28px}
|
||||
.brand{display:none}
|
||||
.big-title{font-size:40px}
|
||||
.content{justify-content:flex-start;padding-top:24px}
|
||||
.cover-grid,.grid2,.grid3,.grid4,.pipeline,.chunks,.tool-bus,.limit-grid,.agent-map{grid-template-columns:1fr}
|
||||
.flow{grid-template-columns:1fr}
|
||||
.step::after,.arrow{display:none}
|
||||
.ribbon-row{grid-template-columns:1fr}
|
||||
.ribbon{clip-path:none;border-radius:12px}
|
||||
.ribbon-line{grid-template-columns:1fr}
|
||||
.value-strip{grid-template-columns:1fr}
|
||||
.value{border-right:0;border-bottom:1px solid #d8e6ff;padding-bottom:8px}
|
||||
.page-corner{display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="deck" id="deck">
|
||||
|
||||
<section class="slide active" data-title="封面">
|
||||
<section class="slide cover active" data-title="封面">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>RAG Primer</span><span>原理和使用</span></div>
|
||||
<p class="kicker">RAG · LLM · Retrieval · Prompt · Agent · MCP</p>
|
||||
<h1>RAG 入门:让 AI <span class="accent">先查资料</span>再回答</h1>
|
||||
<p class="lead">从 LLM 的对话限制出发,理解 RAG 的建库、检索、排序、提示词和 Agent 扩展。</p>
|
||||
<div class="pillbar">
|
||||
<span class="pill">RAG解释</span><span class="pill">LLM解释</span><span class="pill">上下文限制</span><span class="pill">幻觉</span><span class="pill">注意力顺序</span><span class="pill">切片</span><span class="pill">向量化</span><span class="pill">排序</span><span class="pill">语义检索</span><span class="pill">提示词工程</span><span class="pill">AGENTS</span><span class="pill">MCP</span>
|
||||
</div>
|
||||
<div class="footer"><span>RAG 原理和使用</span><span class="page"></span></div>
|
||||
<div class="notes">开场不用讲算法,先讲主线:大模型会说话,但它不是企业知识库。因为它有上下文、幻觉、注意力和顺序方面的限制,所以需要 RAG 这套“先查资料,再组织答案”的机制。</div>
|
||||
|
||||
<div class="cover-grid">
|
||||
<div>
|
||||
<div class="kicker">RAG · LLM · Prompt · Agent · MCP</div>
|
||||
<h1 class="big-title">RAG 入门:让 AI <span class="accent">先查资料</span> 再回答</h1>
|
||||
<p class="sub">从 LLM 的限制讲起,串起知识库建设、语义检索、排序、提示词工程、Agent 和 MCP。</p>
|
||||
<div class="value-strip" style="grid-template-columns:repeat(3,1fr);margin-top:28px">
|
||||
<div class="value"><div class="vicon">01</div><div><strong>先懂限制</strong><span>上下文、幻觉、注意力</span></div></div>
|
||||
<div class="value"><div class="vicon">02</div><div><strong>再懂流程</strong><span>建库、检索、生成</span></div></div>
|
||||
<div class="value"><div class="vicon">03</div><div><strong>最后扩展</strong><span>Agent、MCP、工具</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cover-panel">
|
||||
<div class="cover-visual">
|
||||
<div class="orb one"><div class="ring"></div>RAG</div>
|
||||
<div class="orbit-card a"><strong>LLM</strong><span>理解和生成</span></div>
|
||||
<div class="orbit-card b"><strong>知识库</strong><span>资料与来源</span></div>
|
||||
<div class="orbit-card c"><strong>检索</strong><span>找相关片段</span></div>
|
||||
<div class="orbit-card d"><strong>提示词</strong><span>约束回答</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG Primer</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="目录">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Roadmap</span><span>学习路径</span></div>
|
||||
<p class="kicker">由浅入深</p>
|
||||
<h2>从“会生成”到“会查资料、会调用工具”</h2>
|
||||
<div class="learning-map">
|
||||
<div class="map-card"><div class="num">1</div><h3>基础概念</h3><ul class="map-list"><li>RAG 是什么</li><li>LLM 是什么</li></ul></div>
|
||||
<div class="map-arrow">→</div>
|
||||
<div class="map-card"><div class="num orange">2</div><h3>LLM 限制</h3><ul class="map-list"><li>上下文</li><li>幻觉</li><li>注意力与顺序</li></ul></div>
|
||||
<div class="map-arrow">→</div>
|
||||
<div class="map-card"><div class="num mint">3</div><h3>离线建库</h3><ul class="map-list"><li>资料清洗</li><li>切片</li><li>向量化入库</li></ul></div>
|
||||
<div class="map-arrow">→</div>
|
||||
<div class="map-card"><div class="num">4</div><h3>在线问答</h3><ul class="map-list"><li>语义检索</li><li>排序 / 重排</li><li>提示词工程</li></ul></div>
|
||||
<div class="map-arrow">→</div>
|
||||
<div class="map-card"><div class="num rose">5</div><h3>能力扩展</h3><ul class="map-list"><li>Agents 分工</li><li>MCP 连接工具</li></ul></div>
|
||||
</div>
|
||||
<div class="footer"><span>目录</span><span class="page"></span></div>
|
||||
<div class="notes">这页把路线说清楚。后面每一页都围绕 LLM 限制到 RAG 方案,再到提示词工程和 Agent 的路径推进。</div>
|
||||
|
||||
<div class="top"><div class="chapter">01</div><div class="head"><h2>学习路径</h2><p>由浅入深:先理解问题,再理解方案,最后理解扩展能力。</p></div><div class="brand"><div class="brand-mark">AI</div><span>RAG 入门</span></div></div>
|
||||
<div class="content">
|
||||
<div class="flow">
|
||||
<div class="step hot"><div class="num">1</div><h3>基础概念</h3><p>RAG 是什么;LLM 是什么;两者分别负责什么。</p></div>
|
||||
<div class="step"><div class="num">2</div><h3>LLM 限制</h3><p>上下文有限、会幻觉、长内容专注度下降、顺序不稳定。</p></div>
|
||||
<div class="step"><div class="num">3</div><h3>离线建库</h3><p>资料准备、清洗、切片、元数据、向量化入库。</p></div>
|
||||
<div class="step"><div class="num">4</div><h3>在线问答</h3><p>用户提问、语义检索、候选排序、拼上下文、生成答案。</p></div>
|
||||
<div class="step"><div class="num">5</div><h3>能力扩展</h3><p>提示词工程、多个 LLM 分工、Agent 编排、MCP 连接工具。</p></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">懂</div><div><strong>概念</strong><span>知道每个词在干什么</span></div></div>
|
||||
<div class="value"><div class="vicon">看</div><div><strong>流程</strong><span>知道一次问答怎么跑</span></div></div>
|
||||
<div class="value"><div class="vicon">抓</div><div><strong>关键</strong><span>切片、排序、提示词</span></div></div>
|
||||
<div class="value"><div class="vicon">扩</div><div><strong>边界</strong><span>Agent 和工具调用</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Roadmap</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="RAG解释">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>RAG</span><span>检索增强生成</span></div>
|
||||
<p class="kicker">RAG 解释</p>
|
||||
<h2>RAG = 先检索资料,再增强上下文,最后生成答案</h2>
|
||||
<div class="mini-flow">
|
||||
<div class="node"><span class="tag">Retrieval</span><h3 class="mt-sm">检索</h3><p>从知识库找到相关资料。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node main"><span class="tag" style="background:rgba(255,255,255,.18);color:#fff">Augmented</span><h3 class="mt-sm">增强</h3><p>把资料放进上下文。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node"><span class="tag mint">Generation</span><h3 class="mt-sm">生成</h3><p>LLM 基于资料回答。</p></div>
|
||||
</div>
|
||||
<div class="grid2 mt">
|
||||
<div class="card"><h3>不是训练模型</h3><p class="lead" style="font-size:19px;margin-top:8px">知识不写进模型参数,而是每次回答前动态查资料。</p></div>
|
||||
<div class="card soft"><h3>不是全量塞资料</h3><p class="lead" style="font-size:19px;margin-top:8px">只取与问题相关的片段,降低上下文压力和噪声。</p></div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG = Retrieval-Augmented Generation</span><span class="page"></span></div>
|
||||
<div class="notes">这页讲 RAG 的基本定义。先给出完整定义,再强调 RAG 不是训练模型,也不是把全部资料塞给模型。</div>
|
||||
|
||||
<div class="top"><div class="chapter">02</div><div class="head"><h2>RAG 是什么</h2><p>RAG = Retrieval-Augmented Generation,检索增强生成。</p></div><div class="brand"><div class="brand-mark">R</div><span>先查再答</span></div></div>
|
||||
<div class="content">
|
||||
<div class="pipeline">
|
||||
<div class="pipe-card"><span class="tag">Retrieval</span><div class="icon">查</div><h3>检索</h3><p>先从知识库中找到和问题相关的资料片段。</p></div>
|
||||
<div class="filter"><div class="funnel">RAG<br><span style="font-size:14px">筛选相关资料</span></div></div>
|
||||
<div class="pipe-card"><span class="tag green">Generation</span><div class="icon">答</div><h3>生成</h3><p>LLM 只基于筛出来的资料组织自然语言答案。</p></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">≠</div><div><strong>不是训练模型</strong><span>知识不写进模型参数</span></div></div>
|
||||
<div class="value"><div class="vicon">≠</div><div><strong>不是全量塞资料</strong><span>只取相关片段</span></div></div>
|
||||
<div class="value"><div class="vicon">=</div><div><strong>动态查资料</strong><span>每次提问实时检索</span></div></div>
|
||||
<div class="value"><div class="vicon">✓</div><div><strong>降低幻觉</strong><span>让答案有依据</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG = Retrieval + Augmented + Generation</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="LLM 是什么">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>LLM</span><span>大语言模型</span></div>
|
||||
<p class="kicker">定义</p>
|
||||
<h2>LLM:理解上下文,生成自然语言</h2>
|
||||
<div class="compare">
|
||||
<div class="card">
|
||||
<span class="tag">能力</span>
|
||||
<h3 class="mt-sm">理解上下文,生成答案</h3>
|
||||
<ul>
|
||||
<li>读懂用户问题的大意</li>
|
||||
<li>把零散信息组织成自然语言</li>
|
||||
<li>按要求改写、总结、解释、翻译</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mid">≠</div>
|
||||
<div class="card">
|
||||
<span class="tag rose">边界</span>
|
||||
<h3 class="mt-sm">企业资料库或事实系统</h3>
|
||||
<ul>
|
||||
<li>不会天然知道最新制度</li>
|
||||
<li>不知道内部文档和私有数据</li>
|
||||
<li>不能保证每句话都有来源</li>
|
||||
</ul>
|
||||
<div class="top"><div class="chapter">03</div><div class="head"><h2>LLM 是什么</h2><p>大语言模型擅长理解上下文和生成表达,但不是企业事实库。</p></div><div class="brand"><div class="brand-mark">LLM</div><span>语言引擎</span></div></div>
|
||||
<div class="content">
|
||||
<div class="compare">
|
||||
<div class="card" style="min-height:300px"><span class="tag">能力</span><div class="icon">AI</div><h3>理解与表达</h3><div class="dot-list" style="margin-top:14px"><span>读懂问题意图</span><span>总结、改写、解释、翻译</span><span>把零散资料组织成答案</span></div></div>
|
||||
<div class="mid">≠</div>
|
||||
<div class="card" style="min-height:300px"><span class="tag red">边界</span><div class="icon">DB</div><h3>企业事实系统</h3><div class="dot-list" style="margin-top:14px"><span>不会天然知道内部文档</span><span>不知道最新政策和数据</span><span>不能保证每句话都有来源</span></div></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">读</div><div><strong>读懂上下文</strong><span>理解语义,不是简单关键词</span></div></div>
|
||||
<div class="value"><div class="vicon">写</div><div><strong>生成答案</strong><span>组织语言和结构</span></div></div>
|
||||
<div class="value"><div class="vicon">缺</div><div><strong>缺事实来源</strong><span>私有知识需要外部供给</span></div></div>
|
||||
<div class="value"><div class="vicon">接</div><div><strong>接 RAG</strong><span>把资料交给 LLM 使用</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>LLM handles language, not source-of-truth</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="card soft mt"><h3>定位</h3><p class="lead" style="font-size:20px;margin-top:8px">LLM 负责读懂和表达,事实依据来自外部资料。</p></div>
|
||||
<div class="footer"><span>LLM 不是知识库</span><span class="page"></span></div>
|
||||
<div class="notes">这里不要把 LLM 讲成搜索引擎。LLM 最强的是语言理解和生成,事实来源要靠外部知识、数据库或工具补充。</div>
|
||||
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="LLM 的限制">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Limits</span><span>对话限制</span></div>
|
||||
<p class="kicker">对话限制</p>
|
||||
<h2>LLM 的 4 个对话限制</h2>
|
||||
<div class="grid4 mt">
|
||||
<div class="card"><div class="num">1</div><h3>上下文有限</h3><p class="small mt-sm">输入窗口有限;资料过多会变慢、变贵、变乱。</p></div>
|
||||
<div class="card"><div class="num orange">2</div><h3>会有幻觉</h3><p class="small mt-sm">资料不足或指令不清时,会生成看似合理的错误内容。</p></div>
|
||||
<div class="card"><div class="num mint">3</div><h3>专注度下降</h3><p class="small mt-sm">长资料和噪声会稀释重点,关键信息可能被忽略。</p></div>
|
||||
<div class="card"><div class="num rose">4</div><h3>顺序不稳定</h3><p class="small mt-sm">位置、相似内容、前后冲突都会影响答案。</p></div>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:20px;margin-top:0">处理策略:每次只给最相关、最可信的少量资料。</p></div>
|
||||
<div class="footer"><span>上下文 · 幻觉 · 注意力 · 顺序</span><span class="page"></span></div>
|
||||
<div class="notes">这一页要明确回应用户大纲:上下文限制、幻觉、专注度、不会稳定关注内容顺序。它们共同解释了为什么不能简单粗暴地把所有文档塞进去。</div>
|
||||
|
||||
<div class="top"><div class="chapter">04</div><div class="head"><h2>LLM 的对话限制</h2><p>正因为有这些限制,不能把所有知识一次性丢给 LLM。</p></div><div class="brand"><div class="brand-mark">!</div><span>为什么需要 RAG</span></div></div>
|
||||
<div class="content">
|
||||
<div class="limit-grid">
|
||||
<div class="limit-col">
|
||||
<div class="limit"><div class="n">01</div><div><h4>上下文有限</h4><p>输入窗口有限;资料过多会变慢、变贵、变乱。</p></div></div>
|
||||
<div class="limit"><div class="n">02</div><div><h4>会有幻觉</h4><p>资料不足或指令不清时,会编出看似合理的内容。</p></div></div>
|
||||
</div>
|
||||
<div class="center-badge">不能<br>全量塞<br>资料</div>
|
||||
<div class="limit-col">
|
||||
<div class="limit"><div class="n">03</div><div><h4>专注度下降</h4><p>长资料和噪声会稀释重点,关键信息被忽略。</p></div></div>
|
||||
<div class="limit"><div class="n">04</div><div><h4>顺序不稳定</h4><p>内容位置、相似片段、前后冲突都会影响答案。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">少</div><div><strong>少量</strong><span>只给必要资料</span></div></div>
|
||||
<div class="value"><div class="vicon">准</div><div><strong>准确</strong><span>优先高相关来源</span></div></div>
|
||||
<div class="value"><div class="vicon">新</div><div><strong>新鲜</strong><span>版本和时间可控</span></div></div>
|
||||
<div class="value"><div class="vicon">规</div><div><strong>规则</strong><span>提示词约束回答边界</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Context · Hallucination · Attention · Order</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="为什么需要 RAG">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Why RAG</span><span>限制带来方案</span></div>
|
||||
<p class="kicker">从限制到方案</p>
|
||||
<h2>RAG 的核心:不是让模型记住全部知识,而是让它按需查资料</h2>
|
||||
<div class="stage">
|
||||
<div class="box"><span class="tag rose">全量输入</span><h3 class="mt-sm">全部塞给 LLM</h3><p class="small mt-sm">长、乱、贵,容易混入过期和无权限资料。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box main"><span class="tag" style="background:rgba(255,255,255,.18);color:#fff">RAG</span><h3 class="mt-sm">先查,再答</h3><p class="small mt-sm">每次只取跟问题最相关的资料片段。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box"><span class="tag mint">生成</span><h3 class="mt-sm">基于资料回答</h3><p class="small mt-sm">LLM 根据资料生成,必要时引用来源。</p></div>
|
||||
</div>
|
||||
<div class="card soft mt"><h3>RAG = 检索增强生成</h3><p class="lead" style="font-size:20px;margin-top:8px">检索相关资料 → 放入上下文 → LLM 生成答案。</p></div>
|
||||
<div class="footer"><span>先查资料,再生成</span><span class="page"></span></div>
|
||||
<div class="notes">这页把 RAG 的必要性讲出来:不是因为向量数据库酷,而是因为 LLM 的上下文和注意力有限,必须把资料筛小、筛准,再交给模型。</div>
|
||||
|
||||
<div class="top"><div class="chapter">05</div><div class="head"><h2>从限制到方案</h2><p>RAG 的核心不是让模型记住全部知识,而是让模型按需查资料。</p></div><div class="brand"><div class="brand-mark">→</div><span>方案转化</span></div></div>
|
||||
<div class="content">
|
||||
<div class="pipeline">
|
||||
<div class="pipe-card"><span class="tag red">错误做法</span><h3 style="margin-top:16px">全部塞给 LLM</h3><div class="bad-list"><span>长文档堆叠,重点被稀释</span><span>过期资料混入,结果不可信</span><span>无权限内容可能泄露</span><span>成本高,速度慢</span></div></div>
|
||||
<div class="filter"><div class="funnel">检索<br>过滤<br>排序</div></div>
|
||||
<div class="pipe-card"><span class="tag green">RAG 做法</span><h3 style="margin-top:16px">只给相关资料</h3><div class="bad-list"><span style="border-color:#8fe0cf;background:#f1fffb;color:#0b8066">按问题查资料</span><span style="border-color:#8fe0cf;background:#f1fffb;color:#0b8066">按相关性排序</span><span style="border-color:#8fe0cf;background:#f1fffb;color:#0b8066">拼成小上下文</span><span style="border-color:#8fe0cf;background:#f1fffb;color:#0b8066">让 LLM 基于依据回答</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG turns big documents into precise context</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="后台建库">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Offline Pipeline</span><span>后台整理资料</span></div>
|
||||
<p class="kicker">离线流程</p>
|
||||
<h2>资料整理成可检索的知识库</h2>
|
||||
<div class="flow">
|
||||
<div class="step"><span class="tag">01</span><h4>收集资料</h4><p>产品手册、FAQ、制度、接口文档、案例、流程说明。</p></div>
|
||||
<div class="step"><span class="tag orange">02</span><h4>清洗资料</h4><p>去掉重复、过期、广告、目录噪声和格式错误。</p></div>
|
||||
<div class="step hot"><span class="tag mint">03</span><h4>切片</h4><p>把长文档拆成能独立表达意思的小片段。</p></div>
|
||||
<div class="step"><span class="tag">04</span><h4>加元数据</h4><p>来源、版本、时间、部门、权限、适用范围。</p></div>
|
||||
<div class="step"><span class="tag">05</span><h4>向量化入库</h4><p>把每个片段变成语义向量,写入向量库。</p></div>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:20px;margin-top:0">资料质量决定检索质量。</p></div>
|
||||
<div class="footer"><span>资料准备 → 切片 → 向量库</span><span class="page"></span></div>
|
||||
<div class="notes">这里是“前期准备”。强调 RAG 不只是问答界面,后台知识库准备很关键。元数据也很重要,因为后面排序和权限过滤会用到。</div>
|
||||
|
||||
<div class="top"><div class="chapter">06</div><div class="head"><h2>后台建库:把资料整理成可检索系统</h2><p>RAG 不是只有一个问答框,前期知识库整理决定最终效果。</p></div><div class="brand"><div class="brand-mark">KB</div><span>离线流程</span></div></div>
|
||||
<div class="content">
|
||||
<div class="ribbon-row">
|
||||
<div class="ribbon">资料层</div>
|
||||
<div class="ribbon-line"><div class="icon">文</div><div class="dot-list"><span>产品手册、FAQ、制度、接口文档、案例、流程说明</span><span>确认来源、版本、时间、权限和适用范围</span></div></div>
|
||||
</div>
|
||||
<div class="ribbon-row">
|
||||
<div class="ribbon green">处理层</div>
|
||||
<div class="ribbon-line"><div class="icon">洗</div><div class="dot-list"><span>清洗重复、过期、广告、目录噪声和格式错误</span><span>长文档拆成能独立表达意思的小片段</span></div></div>
|
||||
</div>
|
||||
<div class="ribbon-row">
|
||||
<div class="ribbon navy">索引层</div>
|
||||
<div class="ribbon-line"><div class="icon">库</div><div class="dot-list"><span>每个切片转成语义向量,写入向量库</span><span>同时保留元数据,用于过滤、排序和引用来源</span></div></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">质</div><div><strong>资料质量</strong><span>决定检索上限</span></div></div>
|
||||
<div class="value"><div class="vicon">粒</div><div><strong>切片粒度</strong><span>决定召回精度</span></div></div>
|
||||
<div class="value"><div class="vicon">源</div><div><strong>元数据</strong><span>决定过滤和引用</span></div></div>
|
||||
<div class="value"><div class="vicon">库</div><div><strong>向量库</strong><span>支持语义检索</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Prepare · Clean · Chunk · Embed · Index</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="切片和向量库">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Chunk & Embedding</span><span>切片和向量化</span></div>
|
||||
<p class="kicker">索引构建</p>
|
||||
<h2>切片 + 向量化:支持语义检索</h2>
|
||||
<div class="chunks">
|
||||
<div class="chunk"><b>切片 A:退费条件</b><p>购买后 7 天内,且未使用核心服务,可以申请全额退款。</p></div>
|
||||
<div class="chunk"><b>切片 B:不可退场景</b><p>已开具发票、已交付定制服务、超过合同期限,不支持自动退款。</p></div>
|
||||
<div class="chunk"><b>切片 C:审批路径</b><p>超过 5 万元的退款申请,需要客户成功经理和财务双审批。</p></div>
|
||||
</div>
|
||||
<div class="grid2 mt">
|
||||
<div class="card"><span class="tag">切片</span><h3 class="mt-sm">粒度适中</h3><p class="small mt-sm">过大噪声多,过小语义断;每片覆盖一个局部问题。</p></div>
|
||||
<div class="card"><span class="tag mint">向量库</span><h3 class="mt-sm">语义坐标</h3><p class="small mt-sm">文字转换成数字向量;语义相近,距离更近。</p><div class="vector"><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i></div></div>
|
||||
</div>
|
||||
<div class="footer"><span>Chunking + Embedding</span><span class="page"></span></div>
|
||||
<div class="notes">用卡片和地图类比:切片是把书拆成卡片,向量化是给卡片标语义坐标。用户问法不一样,也能找到意思接近的片段。</div>
|
||||
|
||||
<div class="top"><div class="chapter">07</div><div class="head"><h2>切片 + 向量化</h2><p>把长文档拆成小卡片,再给每张卡片标上“语义坐标”。</p></div><div class="brand"><div class="brand-mark">V</div><span>语义检索</span></div></div>
|
||||
<div class="content">
|
||||
<div class="chunks">
|
||||
<div class="doc">
|
||||
<span class="tag">原始资料:退款政策</span>
|
||||
<div class="slice"><h4>切片 A:退费条件</h4><p>购买后 7 天内,且未使用核心服务,可以申请全额退款。</p></div>
|
||||
<div class="slice"><h4>切片 B:不可退场景</h4><p>已开具发票、已交付定制服务、超过合同期限,不支持自动退款。</p></div>
|
||||
<div class="slice"><h4>切片 C:审批路径</h4><p>超过 5 万元的退款申请,需要客户成功经理和财务双审批。</p></div>
|
||||
</div>
|
||||
<div class="doc">
|
||||
<span class="tag green">向量化结果</span>
|
||||
<h3 style="margin-top:14px">语义相近,距离更近</h3>
|
||||
<p style="font-size:13px;line-height:1.65;color:var(--muted);margin-top:10px">用户问“买了 3 天没用能退吗”,即使没有出现“退费条件”这几个字,也能找到切片 A。</p>
|
||||
<div class="vector-bars"><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i><i class="bar"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Chunking + Embedding</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="用户提问时怎么查">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Online Retrieval</span><span>检索和排序</span></div>
|
||||
<p class="kicker">在线流程</p>
|
||||
<h2>用户提问后:检索候选,排序后交给 LLM</h2>
|
||||
<div class="flow">
|
||||
<div class="step"><span class="tag">01</span><h4>问题改写</h4><p>把口语问题改成更适合检索的查询。</p></div>
|
||||
<div class="step"><span class="tag">02</span><h4>问题向量化</h4><p>把用户问题也转成语义向量。</p></div>
|
||||
<div class="step"><span class="tag mint">03</span><h4>召回候选</h4><p>从向量库里找语义距离近的片段。</p></div>
|
||||
<div class="step hot"><span class="tag orange">04</span><h4>排序 / 重排</h4><p>按相关性、时效、权限、来源可信度重新排序。</p></div>
|
||||
<div class="step"><span class="tag">05</span><h4>拼上下文</h4><p>只把最有用的几段资料交给 LLM。</p></div>
|
||||
</div>
|
||||
<div class="search">
|
||||
<div class="card"><h3>排序后的候选资料</h3><div class="result"><div class="rank">1</div><div><h4>退款政策 v2026Q2</h4><p class="small">最相关,且版本最新。</p></div></div><div class="result"><div class="rank">2</div><div><h4>大客户审批流程</h4><p class="small">相关,但只在金额超过 5 万时使用。</p></div></div></div>
|
||||
<div class="card soft"><h3>排序作用</h3><ul><li>过期资料降权</li><li>无权限资料过滤</li><li>可信来源优先</li><li>减少噪声,保留重点</li></ul></div>
|
||||
</div>
|
||||
<div class="footer"><span>Retrieve + Rerank</span><span class="page"></span></div>
|
||||
<div class="notes">这里必须引出“排序”概念。召回只是先捞一批候选,排序/重排才决定哪些片段真正进入上下文。排序质量直接影响最终答案。</div>
|
||||
|
||||
<div class="top"><div class="chapter">08</div><div class="head"><h2>用户提问时怎么查</h2><p>召回只是先捞候选,排序 / 重排决定哪些资料真正进入上下文。</p></div><div class="brand"><div class="brand-mark">S</div><span>在线检索</span></div></div>
|
||||
<div class="content">
|
||||
<div class="flow">
|
||||
<div class="step"><div class="num">1</div><h3>问题改写</h3><p>把口语问题改成适合检索的查询。</p></div>
|
||||
<div class="step"><div class="num">2</div><h3>问题向量化</h3><p>用户问题也转成语义向量。</p></div>
|
||||
<div class="step"><div class="num">3</div><h3>召回候选</h3><p>从向量库找语义距离近的片段。</p></div>
|
||||
<div class="step hot"><div class="num">4</div><h3>排序 / 重排</h3><p>按相关性、时效、权限、来源可信度重新排序。</p></div>
|
||||
<div class="step"><div class="num">5</div><h3>拼上下文</h3><p>只把最有用的几段交给 LLM。</p></div>
|
||||
</div>
|
||||
<div class="grid2" style="margin-top:18px">
|
||||
<div class="card"><h3>排序后的候选资料</h3><div class="rank-list" style="margin-top:12px"><div class="rank"><div class="rnum">1</div><div><h4>退款政策 v2026Q2</h4><p>最相关,版本最新</p></div><div class="score">96</div></div><div class="rank"><div class="rnum">2</div><div><h4>大客户审批流程</h4><p>金额超过 5 万时使用</p></div><div class="score">78</div></div></div></div>
|
||||
<div class="card"><h3>排序看什么</h3><div class="dot-list" style="margin-top:12px"><span>相关性:是否真的回答这个问题</span><span>时效性:新版本优先,过期资料降权</span><span>权限:用户不能看的资料先过滤</span><span>可信度:制度、合同、权威来源优先</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Retrieve · Rerank · Context Assembly</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="提示词工程">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Prompt Engineering</span><span>系统提示词</span></div>
|
||||
<p class="kicker">生成约束</p>
|
||||
<h2>系统提示词规定 LLM 的资料使用规则</h2>
|
||||
<div class="search">
|
||||
<div class="prompt">
|
||||
<div class="dim">SYSTEM:</div>
|
||||
<div>你是客服助手。只能基于 CONTEXT 回答;资料不足时说“不确定”,不要编造。</div>
|
||||
<br>
|
||||
<div class="dim">CONTEXT:</div>
|
||||
<div class="mark">[退款政策 v2026Q2] 购买后 7 天内且未使用核心服务,可全额退款。</div>
|
||||
<br>
|
||||
<div class="dim">USER:</div>
|
||||
<div>客户买了 3 天,还没使用,可以退吗?</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="tag">提示词工程</span>
|
||||
<h3 class="mt-sm">规则</h3>
|
||||
<ul>
|
||||
<li>角色:你是谁</li>
|
||||
<li>边界:只能基于资料回答</li>
|
||||
<li>格式:分点、引用来源、给结论</li>
|
||||
<li>兜底:不知道就说不知道</li>
|
||||
<li>安全:不要泄露无权限信息</li>
|
||||
</ul>
|
||||
<div class="top"><div class="chapter">09</div><div class="head"><h2>提示词工程:规定资料怎么用</h2><p>RAG 找资料,系统提示词规定 LLM 的角色、边界、格式和兜底方式。</p></div><div class="brand"><div class="brand-mark">P</div><span>生成约束</span></div></div>
|
||||
<div class="content">
|
||||
<div class="grid2">
|
||||
<div class="prompt-box">
|
||||
<b>SYSTEM</b><br>
|
||||
你是客服助手。只能基于 CONTEXT 回答;资料不足时说“不确定”,不要编造。<br><br>
|
||||
<b>CONTEXT</b><br>
|
||||
<mark>[退款政策 v2026Q2]</mark> 购买后 7 天内且未使用核心服务,可全额退款。<br><br>
|
||||
<b>USER</b><br>
|
||||
客户买了 3 天,还没使用,可以退吗?
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div class="stack-layer"><div class="diagram-title">提示词工程</div><div class="stack-items"><div class="mini"><strong>角色</strong><span>你是谁</span></div><div class="mini"><strong>边界</strong><span>只能基于资料</span></div><div class="mini"><strong>格式</strong><span>结论、理由、来源</span></div></div></div>
|
||||
<div class="stack-layer"><div class="diagram-title" style="background:linear-gradient(135deg,#0aa58f,#14c8bf)">安全兜底</div><div class="stack-items"><div class="mini"><strong>不知道</strong><span>就说不确定</span></div><div class="mini"><strong>冲突</strong><span>说明版本差异</span></div><div class="mini"><strong>权限</strong><span>不泄露资料</span></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>System Prompt + Context + User Question</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:20px;margin-top:0">RAG 找资料;提示词规定资料的使用方式。</p></div>
|
||||
<div class="footer"><span>System Prompt + Context + User Question</span><span class="page"></span></div>
|
||||
<div class="notes">这一页承接用户大纲:给到 LLM 的时候需要系统提示词,由此引出提示词工程。提示词工程重点不是花哨话术,而是角色、边界、格式、引用和兜底。</div>
|
||||
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="Agent">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Agent</span><span>复杂任务的分工协作</span></div>
|
||||
<p class="kicker">复杂任务</p>
|
||||
<h2>复杂任务:多步骤、多角色、多 LLM 协作</h2>
|
||||
<div class="agent-map">
|
||||
<div class="agent"><span class="tag">规划者</span><h3 class="mt-sm">拆任务</h3><p>规划检索、工具调用和结果组织。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="agent"><span class="tag mint">检索者</span><h3 class="mt-sm">查资料</h3><p>使用 RAG 从知识库里找依据,必要时多轮检索。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="agent"><span class="tag orange">执行者</span><h3 class="mt-sm">调用工具</h3><p>查订单、建工单、读数据库、调用业务系统接口。</p></div>
|
||||
</div>
|
||||
<div class="grid2 mt">
|
||||
<div class="card"><h3>Agent</h3><p class="lead" style="font-size:19px;margin-top:8px">目标驱动流程:规划步骤、选择工具、读取结果、继续推进。</p></div>
|
||||
<div class="card soft"><h3>和 RAG 的关系</h3><p class="lead" style="font-size:19px;margin-top:8px">RAG 提供知识入口;Agent 负责任务编排。</p></div>
|
||||
</div>
|
||||
<div class="footer"><span>RAG 是知识入口,Agent 是任务编排</span><span class="page"></span></div>
|
||||
<div class="notes">不要把 Agent 讲玄。它就是更复杂任务里的规划和编排:可能一个 LLM 规划,一个 LLM 检索,一个 LLM 写答案,也可能调用外部工具。</div>
|
||||
|
||||
<div class="top"><div class="chapter">10</div><div class="head"><h2>复杂任务需要 Agent</h2><p>当任务不只是“答一句话”,就需要规划、检索、执行、校验等分工。</p></div><div class="brand"><div class="brand-mark">A</div><span>任务编排</span></div></div>
|
||||
<div class="content">
|
||||
<div class="agent-map">
|
||||
<div class="agent-card"><div class="icon">规</div><h3>规划者</h3><p>拆步骤,决定先查什么、再调用什么工具。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="agent-card"><div class="icon">查</div><h3>检索者</h3><p>使用 RAG 找依据,必要时多轮检索。</p></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="agent-card"><div class="icon">执</div><h3>执行者</h3><p>查订单、建工单、读数据库、调用接口。</p></div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">R</div><div><strong>RAG</strong><span>提供知识入口</span></div></div>
|
||||
<div class="value"><div class="vicon">P</div><div><strong>Prompt</strong><span>规定回答规则</span></div></div>
|
||||
<div class="value"><div class="vicon">A</div><div><strong>Agent</strong><span>负责任务编排</span></div></div>
|
||||
<div class="value"><div class="vicon">T</div><div><strong>Tools</strong><span>执行外部动作</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Agent = Plan + Retrieve + Act + Check</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="MCP">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>MCP</span><span>工具连接</span></div>
|
||||
<p class="kicker">MCP</p>
|
||||
<h2>MCP:让 Agent 稳定连接外部工具和业务系统</h2>
|
||||
<div class="mcp-grid">
|
||||
<div class="mcp-card"><span class="tag">统一接口</span><h3 class="mt-sm">工具接入规范</h3><p class="small mt-sm">把不同系统的能力包装成模型可调用的工具。</p></div>
|
||||
<div class="mcp-card"><span class="tag mint">上下文供给</span><h3 class="mt-sm">读取外部信息</h3><p class="small mt-sm">查数据库、读文件、取工单、访问知识系统。</p></div>
|
||||
<div class="mcp-card"><span class="tag orange">动作执行</span><h3 class="mt-sm">调用业务能力</h3><p class="small mt-sm">创建工单、查询订单、发送通知、写入结果。</p></div>
|
||||
</div>
|
||||
<div class="tool-list">
|
||||
<span>CRM</span><span>工单</span><span>数据库</span><span>搜索</span><span>文件</span>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:20px;margin-top:0">RAG 负责查知识;Agent 负责编排任务;MCP 负责连接工具。</p></div>
|
||||
<div class="footer"><span>RAG · Agent · MCP</span><span class="page"></span></div>
|
||||
<div class="notes">这页讲 MCP。它不需要展开协议细节,只要说明 MCP 是让模型/Agent 稳定连接外部工具和系统的接口层。</div>
|
||||
|
||||
<div class="top"><div class="chapter">11</div><div class="head"><h2>MCP:让 Agent 连接工具</h2><p>MCP 可以理解为模型/Agent 调用外部系统的一套统一连接方式。</p></div><div class="brand"><div class="brand-mark">M</div><span>工具连接</span></div></div>
|
||||
<div class="content">
|
||||
<div class="tool-bus">
|
||||
<div class="card"><span class="tag">Agent</span><h3 style="margin-top:14px">会规划任务</h3><p style="margin-top:10px">但真正查数据、改状态、发通知,需要连接业务系统。</p></div>
|
||||
<div class="bus">MCP<br><span style="font-size:14px;font-weight:700">统一工具接口 / 上下文供给 / 动作执行</span></div>
|
||||
<div class="tools">
|
||||
<div class="tool">CRM<span>客户资料</span></div>
|
||||
<div class="tool">工单<span>创建 / 查询</span></div>
|
||||
<div class="tool">数据库<span>读数据</span></div>
|
||||
<div class="tool">文件<span>读文档</span></div>
|
||||
<div class="tool">搜索<span>查外部信息</span></div>
|
||||
<div class="tool">消息<span>发送通知</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value-strip">
|
||||
<div class="value"><div class="vicon">知</div><div><strong>RAG</strong><span>查知识</span></div></div>
|
||||
<div class="value"><div class="vicon">编</div><div><strong>Agent</strong><span>编排任务</span></div></div>
|
||||
<div class="value"><div class="vicon">接</div><div><strong>MCP</strong><span>连接工具</span></div></div>
|
||||
<div class="value"><div class="vicon">做</div><div><strong>业务系统</strong><span>完成动作</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>Model Context Protocol</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-title="总结">
|
||||
<div class="slide-inner">
|
||||
<div class="topline"><span>Takeaway</span><span>关键链路</span></div>
|
||||
<p class="kicker">核心链路</p>
|
||||
<h2>RAG → LLM 限制 → 切片 → 向量化 → 语义检索 → 排序 → 提示词 → Agent → MCP</h2>
|
||||
<div class="summary">
|
||||
<div class="card"><span class="tag rose">限制</span><h3 class="mt-sm">LLM 不能吃下全部知识</h3><p class="small mt-sm">上下文有限、会幻觉、专注度和顺序都不稳定。</p></div>
|
||||
<div class="card"><span class="tag mint">建库</span><h3 class="mt-sm">资料要先整理成片段</h3><p class="small mt-sm">清洗、切片、向量化、加元数据,再放入向量库。</p></div>
|
||||
<div class="card"><span class="tag orange">检索</span><h3 class="mt-sm">提问时先找资料</h3><p class="small mt-sm">召回候选,再排序过滤,把最相关内容放进上下文。</p></div>
|
||||
<div class="card"><span class="tag">扩展</span><h3 class="mt-sm">Agent + MCP</h3><p class="small mt-sm">Agent 编排多步骤任务;MCP 连接外部工具和系统。</p></div>
|
||||
</div>
|
||||
<div class="card soft mt"><p class="lead" style="font-size:21px;margin-top:0">RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。</p></div>
|
||||
<div class="footer"><span>End</span><span class="page"></span></div>
|
||||
<div class="notes">收尾不要再加新概念。重复主线:LLM 有限制,所以需要 RAG;RAG 后台建库,前台检索排序,再通过提示词交给 LLM;复杂任务用 Agent。</div>
|
||||
|
||||
<div class="top"><div class="chapter">12</div><div class="head"><h2>入门之后,要抓住这条主线</h2><p>LLM 有限制,所以需要 RAG;RAG 把资料找准,再交给 LLM 生成。</p></div><div class="brand"><div class="brand-mark">✓</div><span>核心链路</span></div></div>
|
||||
<div class="content">
|
||||
<div class="flow">
|
||||
<div class="step"><div class="num">1</div><h3>LLM</h3><p>负责理解和表达,但不是企业知识库。</p></div>
|
||||
<div class="step"><div class="num">2</div><h3>限制</h3><p>上下文、幻觉、专注度、顺序带来风险。</p></div>
|
||||
<div class="step"><div class="num">3</div><h3>RAG</h3><p>后台建库;前台检索;排序后拼上下文。</p></div>
|
||||
<div class="step"><div class="num">4</div><h3>Prompt</h3><p>规定角色、边界、格式、兜底和安全。</p></div>
|
||||
<div class="step"><div class="num">5</div><h3>Agent + MCP</h3><p>复杂任务用 Agent 编排,用 MCP 连接工具。</p></div>
|
||||
</div>
|
||||
<div class="stack-layer" style="margin-top:24px;text-align:center">
|
||||
<div class="diagram-title">一句话总结</div>
|
||||
<h3 style="font-size:30px;margin-top:18px;color:#061b4e">RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><span>End</span><span class="page"></span></div>
|
||||
</div>
|
||||
<div class="page-corner"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
573
public/style.css
573
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
737
server.js
737
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 : {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user