chore: rebuild CentOS7 release package

This commit is contained in:
shiyue
2026-06-27 19:47:52 +08:00
parent 911dd84c35
commit cd37ecf10b
14 changed files with 3128 additions and 653 deletions

View File

@@ -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 候选。

View File

@@ -1,6 +0,0 @@
status,step
DONE,定位会话切换与运行中恢复逻辑
DONE,核对本次改动是否影响该链路
DONE,查看相关运行日志和当前会话状态
DONE,修复缺失 streaming 气泡的恢复逻辑
IN_PROGRESS,验证并汇总影响范围
1 status step
2 DONE 定位会话切换与运行中恢复逻辑
3 DONE 核对本次改动是否影响该链路
4 DONE 查看相关运行日志和当前会话状态
5 DONE 修复缺失 streaming 气泡的恢复逻辑
6 IN_PROGRESS 验证并汇总影响范围

View File

@@ -1,5 +1,5 @@
{
"version": 1,
"updatedAt": "2026-06-21T15:06:34.507Z",
"updatedAt": "2026-06-26T15:00:37.081Z",
"replies": []
}

View File

@@ -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'

View File

@@ -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: '候选项选择模式,默认 singlenone 表示只输入答案。',
},
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) {

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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 有限制,所以需要 RAGRAG 后台建库,前台检索排序,再通过提示词交给 LLM复杂任务用 Agent</div>
<div class="top"><div class="chapter">12</div><div class="head"><h2>入门之后,要抓住这条主线</h2><p>LLM 有限制,所以需要 RAGRAG 把资料找准,再交给 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;

View File

@@ -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 有限制,所以需要 RAGRAG 后台建库,前台检索排序,再通过提示词交给 LLM复杂任务用 Agent</div>
<div class="top"><div class="chapter">12</div><div class="head"><h2>入门之后,要抓住这条主线</h2><p>LLM 有限制,所以需要 RAGRAG 把资料找准,再交给 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;

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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
View File

@@ -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 : {};
}