feat: ChatGPT Enterprise 对标优化 — Prompt 重构 + 前端 P0 交互 + 错误处理

- 重构所有 Agent System Prompt,参考 ChatGPT 风格:自然对话、匹配用户沟通风格、避免翻译腔
- Router 路由规则结构化,新增 Decision Rules 和分组示例,提升路由准确率
- 前端 P0:textarea 多行输入 + Shift+Enter 换行、流式打字光标动画、编辑已发送消息
- 新增 ToolCallStatus 组件、process-attachment 文件处理节点、retry 重试工具
- 工具调用错误响应友好化(formatToolError 中文提示)
- Enterprise tool descriptions 增加使用场景说明
- 上下文截断提示改为中文
- 新增 soc-product-agent 产品经理 Agent 定义

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-11 14:35:45 +08:00
co-authored by Claude Opus 4.6
parent 6400d85027
commit 86cfa317b6
22 changed files with 850 additions and 195 deletions
+110
View File
@@ -0,0 +1,110 @@
---
name: soc-product-agent
description: so-c-chat-clone 产品经理 Agent,实时读取 MCP 产品文档和知识库进展,对标 ChatGPT Enterprise 定义产品方向,主盯前端交互体验
model: opus
tools:
- Read
- Edit
- Write
- Bash
- Glob
- Grep
- Agent
- WebFetch
- WebSearch
- mcp__zsk__memory_write
- mcp__zsk__memory_search
- mcp__zsk__memory_delete
- mcp__zsk__memory_service_status
- mcp__cursor-project-memory__memory_write
- mcp__cursor-project-memory__memory_search
- mcp__cursor-project-memory__memory_delete
- mcp__cursor-project-memory__memory_service_status
- mcp__v0__createChat
- mcp__v0__findChats
- mcp__v0__getChat
- mcp__v0__getUser
- mcp__v0__sendChatMessage
---
# so-c-chat-clone 产品经理 Agent
你是 so-c-chat-clone 项目的产品经理,负责定义产品方向、跟进实施进度、主盯前端交互体验。你的核心目标是**让产品无限接近 ChatGPT 的企业级运营大脑**。
## 核心职责
### 1. 产品文档管理(MCP 驱动)
- 使用 `mcp__zsk__memory_search` 实时读取知识库中的产品文档、需求规格、设计方案
- 使用 `mcp__cursor-project-memory__memory_search` 读取项目开发进展记录
- 基于文档定义产品迭代方向和优先级
- 将产品决策写入 `mcp__zsk__memory_write`,供其他 Agent 参考
### 2. ChatGPT Enterprise 对标
- 持续搜索 ChatGPT Enterprise 最新功能和交互模式
- 分析 SOC 与 ChatGPT 的功能差距
- 制定具体的追赶方案和优先级排序
- 重点关注企业级场景:多轮对话、工具调用、文件处理、团队协作
### 3. 前端交互主盯(核心)
- 读取前端代码(langgraph/src/main.tsx、components/)分析交互体验
- 对标 ChatGPT 的交互细节:消息气泡、流式输出动画、工具调用状态展示、错误提示
- 使用 v0 MCP(mcp__v0__createChat、mcp__v0__sendChatMessage)咨询 UI 方案
- 提出具体的前端改进需求,交给 frontend-agent 实施
- 验收前端实施结果是否符合产品预期
### 4. 进度跟进
- 读取 MCP 知识库中各 Agent 的开发日志
- 跟踪功能实现进度
- 识别阻塞项并协调解决
- 定期生成产品进度报告
## 产品对标清单(ChatGPT Enterprise)
### 交互体验(高优先级)
- [x] 消息输入:textarea 多行 + Shift+Enter 换行 + 自动增高(2026-04-11 完成)
- [x] 流式输出:停止生成按钮(已实现)
- [x] 流式输出:打字光标闪烁动画(2026-04-11 完成)
- [ ] 流式输出:思考过程可折叠展示(P1)
- [x] 工具调用:状态指示器 ToolCallStatus(已实现,Loader/CheckCircle)
- [ ] 工具调用:结果卡片展开/折叠动画(P1)
- [x] 对话管理:重新生成(已实现)
- [x] 对话管理:编辑已发送消息并重新发送(2026-04-11 完成)
- [ ] 对话管理:分支对话(P2)
- [x] 文件处理:拖拽上传、粘贴图片、文件预览(已实现)
- [x] 代码块:语法高亮 Prism + 一键复制(已实现)
- [ ] 代码块:运行按钮(P2,需配合 sandbox)
- [x] 响应式:移动端适配、侧边栏折叠(已实现)
- [ ] 滚动到底部浮动按钮(P2)
### 功能完整性(中优先级)
- [x] 对话历史搜索和筛选(已实现,ThreadSidebar 搜索框)
- [ ] 对话导出(Markdown/PDF)(P2)
- [x] 快捷指令/Prompt 模板(已实现,QuickPrompts)
- [x] 多模型切换(已实现 Flash/Auto/Pro)
- [ ] 上下文长度指示器(P2)
- [ ] 消息反馈点赞/点踩(P2)
- [ ] 键盘快捷键(P1)
### 企业级特性(低优先级)
- [ ] 用户认证 SSO
- [ ] 审计日志
- [ ] 数据保留策略
## 项目信息
- **项目根路径**: `/Users/gongzhiyong/go/SOC/`
- **前端代码**: `langgraph/src/main.tsx`(Chat UI 入口)
- **组件目录**: `langgraph/src/components/`
- **Gen-UI 组件**: `langgraph/src/agent-uis/`
- **测试报告**: `/Users/gongzhiyong/go/SOC/test.md`
- **GitHub**: https://github.com/Fasthei/so-c-chat-clone
## 工作规范
1. **每次任务开始前**先用 MCP 搜索产品文档和开发进展
2. **互联网搜索**对标 ChatGPT 最新功能,用 WebSearch + WebFetch
3. **前端交互评审**必须先 Read 完整代码再提改进建议
4. **UI 方案咨询**不确定的交互用 v0 MCP 确认
5. **产品决策记录**写入 mcp__zsk__memory_write
6. **不直接改前端代码**,提需求给 frontend-agent 实施
7. **Azure 权限约束**:仅允许操作 `AuthData` 和 `Operation` 资源组
+23 -15
View File
@@ -2,27 +2,34 @@
* Coder agent node: LLM decides whether to write/execute code.
*/
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
import { truncateMessages } from "@/agent/utils/truncate-messages";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { CoderState, CoderUpdate } from "../types.js";
import { z } from "zod";
const SYSTEM_PROMPT = `你是代码解释器。你必须编写代码并通过 code_execute 工具执行来回答用户问题。
const SYSTEM_PROMPT = `你是代码执行助手。通过编写和运行代码来解决用户问题。
重要:你必须始终调用 code_execute 工具来执行代码,绝对不要只返回代码文本而不执行。
## 能力
- Python / JavaScript / Bash 代码编写与执行
- 数据分析(pandas, numpy)、可视化(matplotlib)
- 文件处理(CSV, JSON, Excel)
- 数学计算
能力:
- 编写 Python/JavaScript/Bash 代码并立即执行
- 如果代码报错,自动分析错误并修正后重试
- 数据分析:pandas, matplotlib, numpy
- 文件处理:csv, json, excel
- 计算和可视化
## 工作流程
1. 理解用户需求,确定用什么语言和库
2. 如果需要额外依赖,先用 code_install 安装
3. 用 code_execute 执行代码
4. 根据执行结果给出分析和回答
规则:
- 任何涉及代码的请求,都必须调用 code_execute 执行,不要只展示代码
- 如果执行失败,分析错误原因,修正代码后重新执行
- 图表输出时打印 JSON 格式数据(供前端渲染)
- 始终用中文回复
- 当你已经成功执行代码并得到结果后,直接给出最终回答`;
## 错误恢复
- 执行报错时分析原因(语法 / 依赖 / 逻辑),修正后重试
- 缺少依赖 → code_install 安装后重新执行
- 最多重试 3 次,仍失败则说明原因并给替代方案
## 回答风格
- 用中文回复
- 代码执行成功后,直接给出结果分析,不要再多余地调用工具
- 数据结果用表格展示,图表输出 JSON 数据供前端渲染`;
export const codeExecuteSchema = z.object({
code: z.string().describe("The code to execute"),
@@ -60,9 +67,10 @@ export async function agentNode(
const modelMode = ((config.configurable?.modelMode as string) ?? "auto") as ModelMode;
const llm = createLlm({ modelMode });
const truncated = truncateMessages(state.messages);
const messagesWithSystem = [
{ role: "system" as const, content: SYSTEM_PROMPT },
...state.messages,
...truncated,
];
const message = await llm.bindTools([...CODER_TOOLS]).invoke(messagesWithSystem);
@@ -8,6 +8,7 @@ import { AIMessage } from "@langchain/core/messages";
import { CoderState, CoderUpdate } from "../types.js";
import { sandboxRun } from "../../enterprise/tools/soc-client.js";
import { codeExecuteSchema, codeInstallSchema } from "./agent.js";
import { executeWithRetry, formatToolError } from "@/agent/utils/retry";
export async function toolExecutorNode(
state: CoderState,
@@ -43,9 +44,8 @@ export async function toolExecutorNode(
switch (name) {
case "code_execute": {
const parsed = codeExecuteSchema.parse(args);
const result = await sandboxRun(
parsed.code,
parsed.language ?? "python",
const result = await executeWithRetry(() =>
sandboxRun(parsed.code, parsed.language ?? "python"),
);
ui.push(
{
@@ -94,7 +94,7 @@ export async function toolExecutorNode(
return {
role: "tool" as const,
tool_call_id: id,
content: `工具执行失败 (${name}): ${e}`,
content: formatToolError(name, e),
};
}
});
+19 -12
View File
@@ -3,23 +3,29 @@
* Does NOT execute tools — only returns the AI message (possibly with tool_calls).
*/
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
import { truncateMessages } from "@/agent/utils/truncate-messages";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { EnterpriseState, EnterpriseUpdate } from "../types.js";
import { filterTools } from "./tool-defs.js";
const SYSTEM_PROMPT = `你是企业内部助手,专注于查询内部知识库和工单系统。
const SYSTEM_PROMPT = `你是企业内部助手,帮助用户查询知识库和管理工单。你也能理解用户上传的文件和图片。
## 工具使用指南
- 根据用户问题选择合适的工具。你可以在一轮中同时调用多个工具。
- 如果一个工具的结果不够完整,你可以在下一轮继续调用工具补充信息。
- 工具结果会以可视化卡片展示给用户,无需在文字中重复数据细节,只需提供简洁的分析和洞察。
- 当你已经收集到足够的信息来回答用户问题时,直接给出最终回答,不要再调用工具。
- 如果用户的问题不需要任何工具,直接回答即可。
## 工具使用
- 根据用户问题选择合适的工具,可以一次调用多个工具
- 结果不够完整时,继续调用工具补充
- 工具结果已通过可视化卡片展示给用户,你的文字回复只需提供分析和洞察,不要复述卡片中的原始数据
- 信息足够时直接回答,不要多余地再调工具
- 不需要工具的问题直接回答
## 回答规范
- 始终用中文回复。
- 基于工具返回的实际数据进行分析,不要编造数据。
- 如果工具调用失败,告知用户并建议替代方案。`;
## 引用
- 知识库:标注来源文档,如「根据《XX文档》…」
- 工单:标注工单编号
## 回答风格
- 用中文回复,语气专业但不生硬
- 基于实际数据分析,绝不编造
- 工具调用失败时坦诚告知,并给出替代建议
- 回复简洁有条理,善用列表和加粗突出关键信息`;
export async function agentNode(
state: EnterpriseState,
@@ -31,9 +37,10 @@ export async function agentNode(
const llm = createLlm({ modelMode });
const tools = filterTools(modelMode, enabledTools);
const truncated = truncateMessages(state.messages);
const messagesWithSystem = [
{ role: "system" as const, content: SYSTEM_PROMPT },
...state.messages,
...truncated,
];
// If no tools available after filtering, invoke LLM without tool binding
@@ -21,18 +21,19 @@ export const ALL_ENTERPRISE_TOOLS = [
{
name: "kb_search",
description:
"搜索内部知识库,查询公司内部文档、产品信息、技术资料",
"搜索内部知识库。当用户询问公司文档、产品信息、技术资料、内部规范、流程制度时使用。传入自然语言查询词。",
schema: kbSearchSchema,
},
{
name: "ticket_list",
description:
"查询工单列表,获取当前工单状态、优先级、客户信息",
"查询工单列表。当用户想了解工单概览、查看最近的工单、查看工单状态汇总时使用。支持分页。",
schema: ticketListSchema,
},
{
name: "ticket_detail",
description: "查询单个工单详情",
description:
"查询指定工单的详细信息(处理进度、历史记录、负责人等)。当用户提到具体工单编号或想深入了解某个工单时使用。",
schema: ticketDetailSchema,
},
] as const;
@@ -18,6 +18,7 @@ import {
ticketListSchema,
ticketDetailSchema,
} from "./tool-defs.js";
import { executeWithRetry, formatToolError } from "@/agent/utils/retry";
export async function toolExecutorNode(
state: EnterpriseState,
@@ -55,7 +56,7 @@ export async function toolExecutorNode(
switch (name) {
case "kb_search": {
const parsed = kbSearchSchema.parse(args);
const data = await kbSearch(parsed.query);
const data = await executeWithRetry(() => kbSearch(parsed.query));
const results = (data.results ?? []).slice(0, 5).map((r) => ({
title: r.title,
category: r.category,
@@ -81,7 +82,7 @@ export async function toolExecutorNode(
case "ticket_list": {
const parsed = ticketListSchema.parse(args);
const data = await ticketList(parsed.page ?? 1);
const data = await executeWithRetry(() => ticketList(parsed.page ?? 1));
const tickets = (data.tickets ?? []).map((t) => ({
id: t.ticketNumber,
title: t.description?.slice(0, 80) ?? "",
@@ -110,7 +111,7 @@ export async function toolExecutorNode(
case "ticket_detail": {
const parsed = ticketDetailSchema.parse(args);
const t = await ticketDetail(parsed.ticket_id);
const t = await executeWithRetry(() => ticketDetail(parsed.ticket_id));
ui.push(
{
name: "ticket-detail",
@@ -150,7 +151,7 @@ export async function toolExecutorNode(
return {
role: "tool" as const,
tool_call_id: id,
content: `工具执行失败 (${name}): ${e}`,
content: formatToolError(name, e),
};
}
});
+20 -12
View File
@@ -2,23 +2,30 @@
* Searcher agent node: LLM plans search strategy and decides whether to continue searching.
*/
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
import { truncateMessages } from "@/agent/utils/truncate-messages";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { SearcherState, SearcherUpdate } from "../types.js";
import { z } from "zod";
const SYSTEM_PROMPT = `你是深度搜索助手。你的任务是通过多步搜索找到最准确、最全面的信息。
const SYSTEM_PROMPT = `你是深度搜索助手。通过多步搜索为用户找到准确、全面的信息。
策略:
1. 先用 google_search 快速获取概览
2. 如果需要详细内容,用 web_read 读取相关页面全文
3. 如果首次搜索结果不满意,换关键词再搜
4. 综合所有来源给出回答,标注来源编号 [1] [2]
## 搜索策略
1. 分析问题关键词和意图,确定搜索方向
2. 先用 google_search 获取概览
3. 需要详细内容时,用 web_read 读取页面全文
4. 首次结果不理想则调整关键词重新搜索
5. 复杂问题拆分为子问题分别搜索,最后综合
规则:
- 始终用中文回复。
- 基于搜索返回的实际数据进行分析,不要编造信息。
- 如果搜索工具调用失败,告知用户并建议替代方案。
- 当你已经收集到足够的信息来回答用户问题时,直接给出最终回答,不要再调用工具。`;
## 引用格式
- 回答中用 [1][2] 标注来源
- 回答末尾列出来源:[编号] 标题 - URL
## 回答风格
- 用中文回复
- 基于搜索到的实际内容回答,不编造信息
- 搜索结果已通过卡片展示给用户,文字回复侧重分析和总结
- 信息充足时直接回答,不要多余地继续搜索
- 搜索失败时坦诚告知并建议替代方案`;
export const googleSearchSchema = z.object({
query: z.string().describe("The Google search query"),
@@ -60,9 +67,10 @@ export async function agentNode(
const modelMode = ((config.configurable?.modelMode as string) ?? "auto") as ModelMode;
const llm = createLlm({ modelMode });
const truncated = truncateMessages(state.messages);
const messagesWithSystem = [
{ role: "system" as const, content: SYSTEM_PROMPT },
...state.messages,
...truncated,
];
const message = await llm.bindTools([...SEARCHER_TOOLS]).invoke(messagesWithSystem);
@@ -18,6 +18,8 @@ import {
webReadSchema,
} from "./agent.js";
import { executeWithRetry, formatToolError } from "@/agent/utils/retry";
export async function toolExecutorNode(
state: SearcherState,
config: LangGraphRunnableConfig,
@@ -52,7 +54,7 @@ export async function toolExecutorNode(
switch (name) {
case "google_search": {
const parsed = googleSearchSchema.parse(args);
const data = await googleSearch(parsed.query);
const data = await executeWithRetry(() => googleSearch(parsed.query));
const results = data.results.map((r) => ({
title: r.title,
url: r.url,
@@ -82,7 +84,7 @@ export async function toolExecutorNode(
case "web_search_deep": {
const parsed = webSearchDeepSchema.parse(args);
const data = await webSearch(parsed.query);
const data = await executeWithRetry(() => webSearch(parsed.query));
let enriched = (data.results ?? []).slice(0, 5).map((r) => ({
title: r.title ?? "",
url: r.url ?? "",
@@ -122,7 +124,7 @@ export async function toolExecutorNode(
case "web_read": {
const parsed = webReadSchema.parse(args);
const data = await webRead(parsed.url);
const data = await executeWithRetry(() => webRead(parsed.url));
// Truncate content to avoid token explosion
const truncated = data.content.slice(0, 4000);
return {
@@ -148,7 +150,7 @@ export async function toolExecutorNode(
return {
role: "tool" as const,
tool_call_id: id,
content: `工具执行失败 (${name}): ${e}`,
content: formatToolError(name, e),
};
}
});
+9 -6
View File
@@ -6,17 +6,18 @@ import {
} from "./types";
import { generalInput } from "./nodes/general-input";
import { router } from "./nodes/router";
import { processAttachment } from "./nodes/process-attachment";
import { enterpriseGraph } from "../enterprise";
import { searcherGraph } from "../searcher";
import { coderGraph } from "../coder";
import { writerGraph } from "../writer";
import { getCheckpointer } from "../utils/checkpointer.js";
export const ALL_TOOL_DESCRIPTIONS = `- enterprise: 企业内部助手:知识库查询、工单管理
- searcher: 深度搜索:多步互联网搜索、网页阅读、来源综合
- coder: 代码解释器:编写和执行代码、数据分析、图表生成
- writer: 文档编辑器:创建和编辑文档、翻译、Canvas 模式
- generalInput: 通用对话,处理以上工具不适用的情况`;
export const ALL_TOOL_DESCRIPTIONS = `- **知识库 & 工单**(enterprise):搜索内部知识库文档、查询和跟踪工单状态、理解上传的文件和图片
- **互联网搜索**(searcher):多步搜索互联网信息、阅读网页全文、综合多来源给出回答
- **代码执行**(coder):编写并运行 Python/JS/Bash 代码、数据分析与可视化、数学计算
- **文档编辑**(writer):在 Canvas 中撰写报告/方案/邮件、编辑和翻译文档
- **自由对话**(generalInput):闲聊、问答、以上功能都不涉及的一般对话`;
function handleRoute(
state: SupervisorState,
@@ -25,6 +26,7 @@ function handleRoute(
}
const builder = new StateGraph(SupervisorAnnotation, SupervisorZodConfiguration)
.addNode("processAttachment", processAttachment)
.addNode("router", router)
.addNode("generalInput", generalInput)
.addNode("enterprise", enterpriseGraph)
@@ -38,7 +40,8 @@ const builder = new StateGraph(SupervisorAnnotation, SupervisorZodConfiguration)
"coder",
"writer",
])
.addEdge(START, "router")
.addEdge(START, "processAttachment")
.addEdge("processAttachment", "router")
.addEdge("generalInput", END)
.addEdge("enterprise", END)
.addEdge("searcher", END)
@@ -8,13 +8,25 @@ export async function generalInput(
state: SupervisorState,
config: LangGraphRunnableConfig,
): Promise<SupervisorUpdate> {
const GENERAL_INPUT_SYSTEM_PROMPT = `You are an AI assistant.
If the user asks what you can do, describe these tools.
const GENERAL_INPUT_SYSTEM_PROMPT = `你是 SOC 智能助手,一个专业且友好的企业 AI 伙伴。用自然流畅的中文与用户对话,语气真诚、简洁,像一位靠谱的同事。
## 核心原则
- 匹配用户的沟通风格:用户简短你也简短,用户详细你也详细
- 回答直奔主题,不说空话套话
- 遇到不确定的问题坦诚说明,不编造信息
## 你的能力
当用户问「你能做什么」时,用自然的方式介绍:
${ALL_TOOL_DESCRIPTIONS}
If the last message is a tool result, describe what the action was, congratulate the user, or send a friendly followup in response to the tool action. Ensure this is a clear and concise message.
## 引导规范
如果用户的问题明显更适合专业功能处理,用自然的方式引导,而不是机械地推荐。例如:用户问「最近有什么新闻」→ 你可以说「我来帮你搜索一下」。不要说「建议你使用XX功能」这种生硬的措辞。
Otherwise, just answer as normal.`;
## 格式规范
- 用中文回复
- 短问题给短回答,不要过度展开
- 善用 Markdown 格式(标题、列表、加粗)让信息更易读
- 不要在回复末尾加多余的总结`;
const modelMode = ((config.configurable?.modelMode as string) ?? "auto") as ModelMode;
const llm = createLlm({ modelMode });
@@ -0,0 +1,68 @@
/**
* Process file attachments before routing.
* - Images: convert last human message content to multimodal array
* - Documents: inject parsed text into message content
*/
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { HumanMessage } from "@langchain/core/messages";
import { SupervisorState, SupervisorUpdate } from "../types";
import { parseFile } from "@/agent/utils/file-service";
export async function processAttachment(
state: SupervisorState,
config: LangGraphRunnableConfig,
): Promise<Partial<SupervisorUpdate>> {
const attachedFile = config.configurable?.attachedFile as
| { buffer: string; mimeType: string; filename: string }
| undefined;
if (!attachedFile) {
return {};
}
const buffer = Buffer.from(attachedFile.buffer, "base64");
const parsed = await parseFile(buffer, attachedFile.mimeType, attachedFile.filename);
// Find the last human message
const messages = [...state.messages];
let lastHumanIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]._getType() === "human") {
lastHumanIdx = i;
break;
}
}
if (lastHumanIdx === -1) {
return {};
}
const lastHuman = messages[lastHumanIdx] as HumanMessage;
const textContent =
typeof lastHuman.content === "string"
? lastHuman.content
: Array.isArray(lastHuman.content)
? lastHuman.content
.filter((c) => typeof c === "string" || (typeof c === "object" && c.type === "text"))
.map((c) => (typeof c === "string" ? c : (c as { text: string }).text))
.join("\n")
: "";
if (parsed.type === "image") {
// Multimodal: image_url + text
const newContent = [
{ type: "image_url" as const, image_url: { url: parsed.url } },
{ type: "text" as const, text: textContent || "请分析这张图片" },
];
const newMessage = new HumanMessage({ content: newContent, id: lastHuman.id });
return { messages: [newMessage] };
}
// Document: prepend parsed text
const docPrefix = `[附件: ${attachedFile.filename}]\n内容:\n${parsed.content.slice(0, 8000)}\n\n---\n`;
const newMessage = new HumanMessage({
content: docPrefix + textContent,
id: lastHuman.id,
});
return { messages: [newMessage] };
}
+33 -9
View File
@@ -21,6 +21,7 @@ ${ALL_TOOL_DESCRIPTIONS}
"writer",
])
.describe(routerDescription),
reason: z.string().optional().describe("简要说明选择这个路由的原因"),
});
const routerTool = {
name: "router",
@@ -32,17 +33,40 @@ ${ALL_TOOL_DESCRIPTIONS}
.bindTools([routerTool], { tool_choice: "router" })
.withConfig({ tags: ["langsmith:nostream"] });
const prompt = `You're a highly helpful AI assistant, tasked with routing the user's query to the appropriate tool.
You should analyze the user's input, and choose the appropriate tool to use.
const prompt = `You are an intent router. Analyze the user's latest message in conversation context and select exactly one route.
Routing rules:
- enterprise: questions about internal knowledge base (知识库), company documents, tickets/work orders (工单)
- searcher: questions that require internet search, looking up external information, news, facts
- coder: requests to EXECUTE code, run programs, data analysis with code, calculations, debug code. Keywords: 运行, 执行, 计算, 分析数据, 写代码并运行, Python/JavaScript/Bash 代码
- writer: requests to CREATE written documents (reports, articles, plans, emails) WITHOUT code execution. Keywords: 写报告, 写方案, 写周报, 写邮件, 翻译文档, 编辑文档
- generalInput: general conversation, greetings, or when no specific tool is needed
## Routes
IMPORTANT: "写代码" (write code to execute) → coder, NOT writer. "写文档/写报告" (write a document) → writer. The key distinction is whether the user wants code EXECUTED or a document CREATED.`;
enterprise — 企业内部数据:知识库文档检索、工单查询/跟踪、公司内部资料
searcher — 互联网搜索:新闻、外部事实、实时信息、公开资料查询
coder — 代码执行:运行代码、数据分析、数学计算、文件解析、生成图表
writer — 文档创作:撰写报告/方案/邮件/文章、翻译、编辑已有文档(在 Canvas 中展示)
generalInput — 闲聊、打招呼、问你是谁、问你能做什么、或以上路由都不匹配
## Decision Rules
1. 关键词匹配优先:「知识库」「工单」「内部」→ enterprise;「搜索」「查一下」「最新」「新闻」→ searcher
2. 写代码 vs 写文档:用户想让代码运行并得到结果 → coder;用户想生成一份文本文档 → writer
3. 复合意图取主意图:「搜索XX并写总结」→ searcher(搜索是主动作);「根据数据写报告」→ writer
4. 上传文件/图片且无明确指令 → enterprise(理解文件内容)
5. 不确定时 → generalInput(安全兜底)
## Examples
"知识库里有没有关于XX的资料" → enterprise
"我的工单处理到哪了" → enterprise
"帮我看看这张图片" → enterprise
"最近有什么科技新闻" → searcher
"帮我查一下天气" → searcher
"XX公司的股价是多少" → searcher
"用Python分析这个CSV" → coder
"计算这组数据的平均值" → coder
"画一个柱状图" → coder
"写一份周报" → writer
"帮我翻译这段话" → writer
"起草一封邮件" → writer
"你好" → generalInput
"你能做什么" → generalInput`;
const truncated = truncateMessages(state.messages);
const allMessagesButLast = truncated.slice(0, -1);
+2 -1
View File
@@ -2,7 +2,8 @@ import {
BlobServiceClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
import pdfParse from "pdf-parse";
import * as pdfParseModule from "pdf-parse";
const pdfParse = (pdfParseModule as any).default ?? pdfParseModule;
import * as XLSX from "xlsx";
import { randomUUID } from "crypto";
+43
View File
@@ -0,0 +1,43 @@
/**
* Execute a function with automatic retry on failure.
* Used to wrap external API calls in tool executors.
*/
export async function executeWithRetry<T>(
fn: () => Promise<T>,
retries = 1,
): Promise<T> {
try {
return await fn();
} catch (e) {
if (retries > 0) {
await new Promise((r) => setTimeout(r, 1000));
return executeWithRetry(fn, retries - 1);
}
throw e;
}
}
/**
* Map tool names to user-friendly Chinese error messages.
* Hides raw HTTP errors, stack traces, and technical details from the LLM/user.
*/
const TOOL_ERROR_MESSAGES: Record<string, string> = {
kb_search: "知识库服务暂时不可用,请稍后再试",
ticket_list: "工单列表查询失败,请稍后再试",
ticket_detail: "工单详情查询失败,请检查工单编号是否正确",
google_search: "搜索服务暂时不可用,请稍后再试",
web_search_deep: "深度搜索服务暂时不可用,请稍后再试",
web_read: "网页读取失败,该页面可能无法访问",
code_execute: "代码执行环境暂时不可用,请稍后再试",
code_install: "依赖安装失败,请稍后再试",
doc_create: "文档创建失败,请稍后再试",
doc_edit: "文档编辑失败,请稍后再试",
doc_translate: "文档翻译失败,请稍后再试",
};
export function formatToolError(toolName: string, error: unknown): string {
const friendly = TOOL_ERROR_MESSAGES[toolName] ?? `${toolName} 执行失败,请稍后再试`;
// Log raw error for debugging but return friendly message to LLM
console.error(`[tool-error] ${toolName}:`, error);
return friendly;
}
@@ -51,7 +51,7 @@ export function truncateMessages(
const droppedCount = nonSystemMessages.length - recentMessages.length;
if (droppedCount > 0) {
const summaryNote = new HumanMessage(
`[System note: ${droppedCount} earlier messages were truncated to save context space.]`,
`[系统提示:为保持对话质量,已省略前 ${droppedCount} 条较早的消息。如需回溯之前的内容,请告诉我。]`,
);
return [...systemMessages, summaryNote, ...recentMessages];
}
@@ -77,7 +77,7 @@ export function truncateMessages(
if (droppedCount > 0) {
result.push(
new HumanMessage(
`[System note: ${droppedCount} earlier messages were truncated to save context space.]`,
`[系统提示:为保持对话质量,已省略前 ${droppedCount} 条较早的消息。如需回溯之前的内容,请告诉我。]`,
),
);
}
+18 -14
View File
@@ -3,25 +3,28 @@
* Does NOT execute tools -- only returns the AI message (possibly with tool_calls).
*/
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
import { truncateMessages } from "@/agent/utils/truncate-messages";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { WriterState, WriterUpdate } from "../types.js";
import { ALL_WRITER_TOOLS } from "./tool-defs.js";
const SYSTEM_PROMPT = `你是文档编辑助手。你可以创建和编辑各种文档。
const SYSTEM_PROMPT = `你是文档编辑助手。通过工具在 Canvas 侧面板中创建和编辑文档。
能力:
- 创建 Markdown 文档、代码文件、技术方案、报告
- 根据指令修改现有文档(改写、扩展、精简、翻译)
- 调整文档风格(专业/简洁/学术)
## 工具选择
- 新文档(报告/方案/周报/邮件/文章)→ doc_create
- 修改已有文档 → doc_edit
- 翻译 → doc_translate
规则:
- 你必须始终使用 doc_create 工具来创建文档,不要直接输出文档内容作为纯文本
- 任何写报告、写方案、写周报、写邮件、写文章的请求,都必须调用 doc_create
- 编辑已有文档时,使用 doc_edit 工具
- 翻译文档时,使用 doc_translate 工具
- 文档会在用户的 Canvas 侧面板中展示,所以必须通过工具创建
- 始终用中文回复(除非用户要求其他语言)
- 绝对不要跳过工具直接回复文档内容`;
## 文档质量
- 结构清晰:用标题层级(# ## ###)组织
- 关键内容**加粗**标注
- 善用表格和列表呈现数据
- 超长文档分段生成,保持段落间逻辑衔接
## 回答风格
- 用中文回复(除非用户要求其他语言)
- 始终通过工具输出文档内容,不要直接回复纯文本
- 文字回复只需简要说明文档概要或修改要点`;
export async function writerAgentNode(
state: WriterState,
@@ -32,9 +35,10 @@ export async function writerAgentNode(
// Writer needs higher token limits for document generation
const llm = createLlm({ modelMode, maxTokens: 8192 });
const truncated = truncateMessages(state.messages);
const messagesWithSystem = [
{ role: "system" as const, content: SYSTEM_PROMPT },
...state.messages,
...truncated,
];
const message = await llm
@@ -13,6 +13,7 @@ import {
docEditSchema,
docTranslateSchema,
} from "./tool-defs.js";
import { formatToolError } from "@/agent/utils/retry";
/**
* Search backwards through messages to find the most recent canvas-doc content
@@ -233,7 +234,7 @@ export async function writerToolExecutorNode(
toolMessages.push({
role: "tool",
tool_call_id: id,
content: `工具执行失败 (${name}): ${e}`,
content: formatToolError(name, e),
});
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ export function ThreadSidebar({
const groups = groupByDate(filtered);
return (
<div className="w-64 shrink-0 border-r border-border flex flex-col bg-muted/30">
<div className="w-full h-full shrink-0 border-r border-border flex flex-col bg-muted/30">
{/* New chat button */}
<div className="p-3 border-b border-border flex flex-col gap-2">
<Button
@@ -0,0 +1,61 @@
import { Loader2, CheckCircle2 } from "lucide-react";
const TOOL_NAME_MAP: Record<string, string> = {
kb_search: "搜索知识库",
ticket_list: "查询工单列表",
ticket_detail: "查询工单详情",
web_search: "搜索网络",
google_search: "搜索网络",
web_search_deep: "深度搜索",
web_read: "阅读网页",
code_execute: "执行代码",
code_install: "安装依赖",
doc_create: "创建文档",
doc_edit: "编辑文档",
doc_translate: "翻译文档",
sandbox_run: "运行沙盒",
};
interface ToolCall {
name?: string;
id?: string;
}
interface ToolCallStatusProps {
toolCalls: ToolCall[];
isLoading: boolean;
// IDs of tool messages that exist (completed)
completedToolIds?: Set<string>;
}
export default function ToolCallStatus({
toolCalls,
isLoading,
completedToolIds,
}: ToolCallStatusProps) {
if (!toolCalls.length) return null;
return (
<div className="flex flex-col gap-1 mb-1">
{toolCalls.map((tc, i) => {
const label = tc.name ? (TOOL_NAME_MAP[tc.name] ?? tc.name) : "工具调用";
const isDone = !isLoading || (tc.id && completedToolIds?.has(tc.id));
return (
<div
key={tc.id ?? i}
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
{isDone ? (
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
) : (
<Loader2 className="size-3.5 animate-spin shrink-0" />
)}
<span>{label}</span>
<span>{isDone ? "已完成" : "执行中..."}</span>
</div>
);
})}
</div>
);
}
+14
View File
@@ -149,3 +149,17 @@
box-shadow: inset 9px 0 6px -1px rgb(0 0 0 / 0.02);
}
}
.typing-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: currentColor;
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
+262 -27
View File
@@ -9,7 +9,7 @@ type UIMsgLocal = { id: string; type: string; name: string; props: Record<string
import { useState, useRef, useEffect, useCallback } from "react";
import ComponentMap from "./agent-uis/index.tsx";
import "./index.css";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot } from "lucide-react";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ThreadSidebar, type ThreadItem } from "@/components/ThreadSidebar.tsx";
@@ -19,6 +19,7 @@ import MessageBubble from "@/components/MessageBubble.tsx";
import CanvasPanel, { type CanvasDoc } from "@/components/CanvasPanel.tsx";
import FileUploadButton, { type SelectedFile } from "@/components/FileUploadButton.tsx";
import FileAttachmentPreview from "@/components/FileAttachmentPreview.tsx";
import ToolCallStatus from "@/components/ToolCallStatus.tsx";
const LANGGRAPH_URL =
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
@@ -40,12 +41,49 @@ const MODEL_OPTIONS: { value: ModelMode; label: string; icon: React.FC<{ classNa
{ value: "pro", label: "Pro", icon: Cpu },
];
// ─── Quick prompts ───────────────────────────────────────────────────────────
const QUICK_PROMPTS = [
{ icon: BookOpen, label: "搜索知识库", prompt: "帮我搜索知识库中关于" },
{ icon: Ticket, label: "查看最新工单", prompt: "查看最新的工单列表" },
{ icon: Search, label: "搜索网络", prompt: "帮我搜索" },
{ icon: Terminal, label: "运行代码", prompt: "用 Python 写一段代码" },
];
// ─── LangGraph Client (for thread management) ───────────────────────────────
const client = new Client({ apiUrl: LANGGRAPH_URL });
// ─── Copy button for AI messages ────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<button
type="button"
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded hover:bg-accent"
title="复制"
>
{copied ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
{copied ? "已复制" : "复制"}
</button>
);
}
function App() {
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false);
// Tool & model state
const [activeTools, setActiveTools] = useState<Set<ToolKey>>(new Set());
@@ -54,6 +92,7 @@ function App() {
// Thread sidebar state
const [threads, setThreads] = useState<ThreadItem[]>([]);
const [currentThreadId, setCurrentThreadId] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
// Canvas panel state
const [canvasDoc, setCanvasDoc] = useState<CanvasDoc | null>(null);
@@ -98,10 +137,12 @@ function App() {
// If create fails, just clear threadId so useStream creates one implicitly
setCurrentThreadId(null);
}
setSidebarOpen(false);
}, []);
const handleSelectThread = useCallback((threadId: string) => {
setCurrentThreadId(threadId);
setSidebarOpen(false);
}, []);
const handleDeleteThread = useCallback(async (threadId: string) => {
@@ -116,6 +157,14 @@ function App() {
}
}, [currentThreadId]);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
}, [input]);
// ── Tool toggle ─────────────────────────────────────────────────────────
function toggleTool(key: ToolKey) {
setActiveTools((prev) => {
@@ -130,7 +179,7 @@ function App() {
}
// ── Submit ──────────────────────────────────────────────────────────────
function handleSubmit(e: React.FormEvent) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const text = input.trim();
if ((!text && !attachedFile) || thread.isLoading) return;
@@ -160,6 +209,9 @@ function App() {
messageContent = text;
}
// Auto-name thread on first message
const isFirstMessage = thread.messages.length === 0;
thread.submit(
{ messages: [{ type: "human", content: messageContent }] },
{
@@ -172,6 +224,40 @@ function App() {
},
},
);
if (isFirstMessage && currentThreadId) {
client.threads.update(currentThreadId, { metadata: { title: text.slice(0, 30) } }).catch(() => {});
// Optimistically update local thread title
setThreads((prev) =>
prev.map((t) =>
t.thread_id === currentThreadId
? { ...t, metadata: { ...t.metadata, title: text.slice(0, 30) } }
: t,
),
);
}
}
// ── Regenerate last AI message ───────────────────────────────────────────
function handleRegenerate() {
// Find last human message
const humanMsgs = thread.messages.filter((m) => m.type === "human");
const lastHuman = humanMsgs[humanMsgs.length - 1];
if (!lastHuman || thread.isLoading) return;
const enabledTools =
activeTools.size > 0
? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools])
: [];
thread.submit(
{ messages: [{ type: "human", content: lastHuman.content }] },
{
config: {
configurable: { enabledTools, modelMode },
},
},
);
}
// ── Drag and drop ────────────────────────────────────────────────────────
@@ -208,22 +294,60 @@ function App() {
reader.readAsDataURL(file);
}
// ── Collect completed tool call IDs ─────────────────────────────────────
const completedToolIds = new Set(
thread.messages
.filter((m) => m.type === "tool")
.map((m) => (m as any).tool_call_id as string)
.filter(Boolean),
);
// ── Find last AI message index ──────────────────────────────────────────
const lastAiIdx = thread.messages.reduce((last, m, i) => (m.type === "ai" ? i : last), -1);
return (
<div className="h-screen flex flex-row bg-background text-foreground overflow-hidden">
{/* ── Mobile sidebar overlay backdrop ── */}
{sidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* ── Left sidebar ── */}
<ThreadSidebar
threads={threads}
currentThreadId={currentThreadId}
onNewThread={handleNewThread}
onSelectThread={handleSelectThread}
onDeleteThread={handleDeleteThread}
/>
<div
className={cn(
"z-30 md:relative md:flex md:w-64",
sidebarOpen
? "fixed inset-y-0 left-0 flex w-64"
: "hidden md:flex",
)}
>
<ThreadSidebar
threads={threads}
currentThreadId={currentThreadId}
onNewThread={handleNewThread}
onSelectThread={handleSelectThread}
onDeleteThread={handleDeleteThread}
/>
</div>
{/* ── Right main area ── */}
<div className="flex-1 flex flex-row min-w-0 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0" onDragOver={handleDragOver} onDrop={handleDrop}>
{/* Header */}
<header className="shrink-0 border-b border-border px-6 py-3 flex items-center gap-3">
<header className="shrink-0 border-b border-border px-4 py-3 flex items-center gap-3">
{/* Hamburger for mobile */}
<button
type="button"
className="md:hidden p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
onClick={() => setSidebarOpen((o) => !o)}
aria-label="打开侧栏"
>
<Menu className="size-5" />
</button>
<span className="font-semibold text-foreground">运营大脑</span>
{thread.isLoading && (
<span className="text-xs text-muted-foreground animate-pulse">
@@ -238,9 +362,25 @@ function App() {
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-6">
{thread.messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground select-none">
<p className="text-lg font-medium">你好,有什么可以帮你的?</p>
<p className="text-sm">可以查询知识库、工单、搜索网络或执行代码。</p>
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground select-none">
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-medium">你好,有什么可以帮你的?</p>
<p className="text-sm">可以查询知识库、工单、搜索网络或执行代码。</p>
</div>
{/* Quick prompts */}
<div className="grid grid-cols-2 gap-2 w-full max-w-sm mt-2">
{QUICK_PROMPTS.map(({ icon: Icon, label, prompt }) => (
<button
key={label}
type="button"
onClick={() => setInput(prompt)}
className="border border-border rounded-lg p-3 hover:bg-accent cursor-pointer text-left transition-colors flex items-start gap-2"
>
<Icon className="size-4 shrink-0 mt-0.5 text-muted-foreground" />
<span className="text-xs font-medium text-foreground">{label}</span>
</button>
))}
</div>
</div>
)}
@@ -252,12 +392,53 @@ function App() {
) as UIMsgLocal[];
if (message.type === "human") {
const humanText = typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? (message.content as any[]).filter((p) => p.type === "text").map((p) => p.text).join("")
: "";
return (
<div key={message.id ?? idx} className="flex justify-end">
<div key={message.id ?? idx} className="group flex justify-end items-end gap-2">
{/* Edit button — appears on hover, refills input */}
{humanText && !thread.isLoading && (
<button
type="button"
onClick={() => {
setInput(humanText);
setTimeout(() => textareaRef.current?.focus(), 0);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity mb-1 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
title="编辑消息"
>
<Pencil className="size-3.5" />
</button>
)}
<div className="max-w-[75%] rounded-2xl rounded-br-sm bg-primary text-primary-foreground px-4 py-2.5 text-sm whitespace-pre-wrap">
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
{Array.isArray(message.content) ? (
<div className="flex flex-col gap-2">
{(message.content as any[]).map((part, pi) => {
if (part.type === "image_url") {
const url = part.image_url?.url ?? part.image_url;
return (
<img
key={pi}
src={url}
alt="附件图片"
className="max-h-48 rounded-lg object-contain"
/>
);
}
if (part.type === "text") {
return <span key={pi}>{part.text}</span>;
}
return null;
})}
</div>
) : (
typeof message.content === "string"
? message.content
: JSON.stringify(message.content)
)}
</div>
</div>
);
@@ -274,14 +455,34 @@ function App() {
.join("")
: "";
const toolCalls: { name?: string; id?: string }[] = (message as any).tool_calls ?? [];
const isLastAi = idx === lastAiIdx;
return (
<div key={message.id ?? idx} className="flex flex-col gap-3">
{/* Tool call status */}
{toolCalls.length > 0 && (
<ToolCallStatus
toolCalls={toolCalls}
isLoading={thread.isLoading}
completedToolIds={completedToolIds}
/>
)}
{/* Text reply */}
{textContent && (
<div className="max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm">
<div className="group relative max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm">
<MessageBubble content={textContent} role="ai" />
{thread.isLoading && isLastAi && (
<span className="typing-cursor" aria-hidden="true" />
)}
{/* Copy button */}
<div className="flex justify-end mt-1">
<CopyButton text={textContent} />
</div>
</div>
)}
{/* UI cards */}
{uiItems.map((ui: UIMsgLocal) => (
<LoadExternalComponent
@@ -293,6 +494,20 @@ function App() {
components={ComponentMap as any}
/>
))}
{/* Regenerate button — only on last AI message, only when not loading */}
{isLastAi && !thread.isLoading && (
<div className="flex">
<button
type="button"
onClick={handleRegenerate}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent"
>
<RefreshCw className="size-3.5" />
重新生成
</button>
</div>
)}
</div>
);
}
@@ -374,22 +589,42 @@ function App() {
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<FileUploadButton onFileSelect={setAttachedFile} disabled={thread.isLoading} />
<input
className="flex-1 rounded-xl border border-input bg-background px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring placeholder:text-muted-foreground disabled:opacity-50"
<textarea
ref={textareaRef}
className="flex-1 rounded-xl border border-input bg-background px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring placeholder:text-muted-foreground disabled:opacity-50 resize-none overflow-y-auto"
style={{ minHeight: "42px", maxHeight: "200px" }}
placeholder="输入消息…"
value={input}
rows={1}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as unknown as React.FormEvent);
}
}}
onPaste={handlePaste}
disabled={thread.isLoading}
autoFocus
/>
<button
type="submit"
disabled={thread.isLoading || (!input.trim() && !attachedFile)}
className="rounded-xl bg-primary text-primary-foreground px-4 py-2.5 text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
>
发送
</button>
{thread.isLoading ? (
<button
type="button"
onClick={() => thread.stop()}
className="rounded-xl bg-destructive text-destructive-foreground px-4 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity flex items-center gap-1.5"
>
<Square className="size-4" />
停止
</button>
) : (
<button
type="submit"
disabled={!input.trim() && !attachedFile}
className="rounded-xl bg-primary text-primary-foreground px-4 py-2.5 text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
>
发送
</button>
)}
</form>
</div>
</div>
+128 -76
View File
@@ -1,98 +1,150 @@
# SOC LangGraph.js Phase 2 回归测试报告
# SOC 端到端功能测试报告
## 测试环境
- LangGraph API: http://192.168.30.30:2024
- Frontend: http://192.168.30.30:5173
- 测试时间: 2026-04-10
- 部署版本: 8a5d1c0
- 修复项: Task #5 (Router coder/writer 误路由), Task #6 (Writer 未调用 doc_create)
- **开发机**: 192.168.30.30
- **后端**: http://localhost:2024 (LangGraph Server)
- **前端**: http://localhost:5173 (Vite dev server)
- **测试时间**: 2026-04-11
- **测试范围**: 全端点功能测试 + ChatGPT Enterprise 对标分析
## 测试结果汇总
---
| # | 测试项 | 输入 | 预期路由 | 实际路由 | 结果 | 备注 |
|---|--------|------|---------|---------|------|------|
| 1 | 健康检查 | GET /ok | - | - | PASS | `{"ok":true}` |
| 2 | 普通对话 | "你好" | generalInput | generalInput | PASS | 纯文本回复,无工具调用 |
| 3 | 知识库查询 | "查询知识库关于产品的信息" | enterprise | enterprise | PARTIAL | 路由正确,kb_search 仍超时 |
| 4 | 工单查询 | "查询最近的工单" | enterprise | enterprise | PASS | ticket-summary UI 卡片正常 |
| 5 | 网络搜索 | "搜索 LangGraph.js 最新版本" | searcher | searcher | PASS | search-result UI 卡片正常 |
| 6 | 代码生成 | "帮我写一段 Python 代码计算斐波那契数列" | coder | coder | PARTIAL | 路由已修复! 但 coder 未调用 sandbox,直接返回文本代码 |
| 7 | 文档撰写 | "帮我写一份项目周报" | writer | writer | PASS | 路由正确,调用 doc_create 工具生成 markdown 文档 (已修复!) |
| 8 | 前端页面 | GET http://localhost:5173/ | - | - | PASS | HTTP 200 |
## 1. 端点测试结果
## 修复验证
| # | 端点 / 功能 | 方法 | 状态 | 结果 |
|---|------------|------|------|------|
| 1 | `/ok` 健康检查 | GET | 200 | PASS - 返回 `{"ok":true}` |
| 2 | 前端 `localhost:5173` | GET | 200 | PASS - 返回 Vite dev HTML |
| 3 | Vite Proxy `/threads` | POST | 200 | PASS - 前端端口代理到后端 API,正确返回 thread JSON |
| 4 | `/threads` 创建 Thread | POST | 200 | PASS - 返回 thread_id |
| 5 | `/assistants/search` | POST | 200 | PASS - 返回 agent + chat 两个 assistant |
| 6 | Supervisor 路由 → generalInput | POST stream | 200 | PASS - "你好" 正确路由到通用对话 |
| 7 | Supervisor 路由 → enterprise (kb_search) | POST stream | 200 | PASS - 路由到 enterprise,触发 kb_search 工具 |
| 8 | Supervisor 路由 → enterprise (ticket_list) | POST stream | 200 | PASS - 返回 3 条工单 + ticket-summary Gen-UI 卡片 |
| 9 | Supervisor 路由 → searcher (google_search) | POST stream | 200 | PASS - 返回搜索结果 |
| 10 | Supervisor 路由 → coder (sandbox_run) | POST stream | 200 | PARTIAL - 路由正确,LLM 生成代码正确,但 Daytona sandbox 执行失败 (400) |
| 11 | Supervisor 路由 → writer | POST stream | 200 | PASS - 生成 canvas-doc Gen-UI 卡片,含完整 markdown 文档 |
### Task #5: Router coder/writer 误路由 -- 已修复
- **之前**: "帮我写代码" -> writer (错误)
- **现在**: "帮我写代码" -> coder (正确)
- **验证结果**: PASS
### 通过率: 10/11 通过 (90.9%), 1 个部分通过
### Task #6: Writer 未调用 doc_create 工具 -- 已修复
- **之前**: writer 直接返回纯文本
- **现在**: writer 调用 `doc_create({"title":"项目周报","type":"markdown","content":"..."})` 生成结构化文档
- **验证结果**: PASS
---
## 详细测试记录
## 2. Vite Proxy 状态
### Test 1: GET /ok
- 响应: `{"ok":true}`
- 判定: **PASS**
**结论: 正常工作**
### Test 2: generalInput - "你好"
- Router: `next: "generalInput"`
- 回复: "你好!有什么我可以帮你的吗?"
- 判定: **PASS** (无回归)
通过 `http://localhost:5173/threads` POST 请求成功代理到后端 `http://localhost:2024/threads`,返回正确的 thread JSON。前端 SPA 可以在同源策略下无缝访问后端 API。
### Test 3: enterprise KB - "查询知识库关于产品的信息"
- Router: `next: "enterprise"`
- 调用: `kb_search({"query":"产品 信息"})`
- 结果: `TimeoutError: The operation was aborted due to timeout`
- AI 优雅降级回复
- 判定: **PARTIAL** - 外部 KB 服务仍然超时,非本次修复范围
---
### Test 4: enterprise 工单 - "查询最近的工单"
- Router: `next: "enterprise"`
- ticket-summary UI 卡片正常
- 判定: **PASS** (无回归)
## 3. ChatGPT Enterprise 对标功能清单
### Test 5: searcher - "搜索 LangGraph.js 最新版本"
- Router: `next: "searcher"`
- search-result UI 卡片正常
- 判定: **PASS** (无回归)
### 已实现功能
### Test 6: coder - "帮我写一段 Python 代码计算斐波那契数列"
- Router: `next: "coder"` (修复成功!)
- coder 节点 (langgraph_node: "agent") 直接返回文本代码,未调用 sandbox 执行工具
- 输出包含正确的 Python 代码但无 sandbox-result UI 卡片
- 判定: **PARTIAL** - 路由修复成功,但 coder Agent 未使用 sandbox 执行代码
| 功能 | ChatGPT Enterprise | SOC 实现 | 对标程度 |
|------|-------------------|----------|---------|
| 多轮对话 | 有 | 有 (LangGraph checkpointer) | 完整 |
| 对话历史管理 | 有 (侧边栏) | 有 (ThreadSidebar + CRUD) | 完整 |
| 模型切换 | GPT-4/4o | Flash/Auto/Pro 三模式 | 完整 |
| 知识库检索 (RAG) | 有 (自定义 GPT + Files) | 有 (kb_search + Azure AI Search) | 完整 |
| 网络搜索 | 有 (Browse) | 有 (google_search + web_search) | 完整 |
| 代码解释器 | 有 (Code Interpreter) | 有 (coder agent + Daytona sandbox) | 部分 (sandbox 400 错误) |
| 文档编辑/Canvas | 有 (Canvas) | 有 (writer agent + CanvasPanel) | 完整 |
| Gen-UI 卡片渲染 | 无 (纯文本) | 有 (7 种卡片组件) | 超越 |
| 工单系统集成 | 无 | 有 (ticket_list/detail + Gen-UI) | 超越 (企业特色) |
| 深色/浅色主题 | 有 | 有 (ThemeToggle) | 完整 |
| 文件上传 | 有 | 有 (图片+文件,拖拽+粘贴) | 完整 |
| 图表生成 | 有 (Code Interpreter) | 有 (chart-result Gen-UI) | 完整 |
| Markdown 渲染 | 有 | 有 (MessageBubble) | 完整 |
| SSE 流式输出 | 有 | 有 (useStream) | 完整 |
| 多 Agent 协作 | 无 (单 Agent) | 有 (Supervisor + 5 子 Agent) | 超越 |
### Test 7: writer - "帮我写一份项目周报"
- Router: `next: "writer"`
- writer 调用 `doc_create({"title":"项目周报","type":"markdown","content":"..."})` (修复成功!)
- 判定: **PASS**
### 尚未实现
### Test 8: 前端页面
- HTTP 200
- 判定: **PASS**
| 功能 | ChatGPT Enterprise | SOC 状态 |
|------|-------------------|---------|
| 用户认证/SSO | 有 (SAML/OIDC) | 未实现 |
| 用户权限管理 | 有 (Admin Console) | 未实现 |
| 审计日志 | 有 | 未实现 |
| 数据保留策略 | 有 (30 天) | 未实现 |
| 自定义 GPT 创建 | 有 | 未实现 |
| DALL-E 图片生成 | 有 | 未实现 |
| 语音对话 | 有 | 未实现 |
| 插件/Action 市场 | 有 | 未实现 (但 Agent 架构可扩展) |
## 遗留问题
---
### 问题 1: kb_search 外部服务超时 [中等]
- enterprise 调用 kb_search 仍返回 TimeoutError
- 非本次修复范围,需检查 KB Agent 外部服务
## 4. 已实现的 Gen-UI 组件
### 问题 2: coder Agent 未调用 sandbox 执行代码 [中等]
- Router 已正确路由到 coder,但 coder Agent 直接返回文本代码而非调用 sandbox 工具执行
- 建议: 检查 coder Agent 的 prompt 或工具绑定,确保代码请求会调用 sandbox 执行工具
| 组件 | 对应工具/Agent |
|------|--------------|
| knowledge-result | kb_search |
| ticket-summary | ticket_list |
| ticket-detail | ticket_detail |
| search-result | google_search / web_search |
| sandbox-result | sandbox_run / code_execute |
| canvas-doc | writer agent |
| chart-result | coder agent |
## 通过率
---
**6/8 通过, 2 部分通过 (75%)**
## 5. 问题清单
### 与上一轮对比
| 指标 | 上一轮 | 本轮 | 变化 |
|------|--------|------|------|
| 通过率 | 67% (6/9) | 75% (6/8) | +8% |
| Router 路由正确率 | 5/6 | 6/6 | 100% |
| UI 卡片 | 2/4 | 3/4 | +1 (doc_create) |
| 修复验证 | - | 2/2 PASS | Task #5, #6 确认修复 |
### P0 - Daytona Sandbox 执行失败
- **端点**: coder agent → code_execute → Daytona API
- **错误**: `Daytona create failed: 400`
- **影响**: 代码解释器功能完全不可用
- **建议**: 检查 DAYTONA_API_KEY 和 DAYTONA_API_URL 环境变量配置,验证 Daytona 服务可用性
---
## 6. 最大的 3 个质量风险
### 风险 1: Daytona Sandbox 不可用 (严重)
代码解释器是核心差异化功能之一。当前 Daytona sandbox 返回 400 错误,整个 coder agent 链路虽然路由和代码生成正常,但执行环节断裂。用户体验直接受损。
### 风险 2: 无用户认证体系 (高)
当前系统无任何认证机制,所有 API 完全开放。对于企业级产品,这意味着:
- 任何人都可以访问所有对话历史
- 无法区分用户身份
- 无法满足企业合规要求
- 部署到公网后存在安全风险
### 风险 3: 错误处理和降级策略不够健壮 (中)
- 工具调用失败时(如 Daytona 400),错误信息直接暴露给用户("工具执行失败")
- 外部服务(KB Agent, Gongdan API, Jina, Serper)任一不可用时,缺乏优雅降级
- SSE 流中断后无自动重连机制
---
## 7. 整体质量评分
### 7.5 / 10
**加分项:**
- 架构设计成熟 -- Supervisor + 5 子 Agent 路由,可扩展性强
- Gen-UI 卡片系统是亮点,超越 ChatGPT 的纯文本回复
- 工具链丰富 (知识库、工单、搜索、代码、文档) 且均正确路由
- 前端完成度高 (主题切换、文件上传、拖拽、Canvas)
- SSE 流式输出 + useStream 集成流畅
**减分项:**
- Daytona sandbox 不可用 (-1)
- 无认证体系 (-1)
- 缺少自动化测试覆盖 (-0.5)
---
## 与上一次测试 (2026-04-10) 对比
| 项目 | 上次 | 本次 | 变化 |
|------|------|------|------|
| 通过率 | 6/8 (75%) | 10/11 (90.9%) | 提升 |
| Router 路由正确率 | 6/6 (100%) | 6/6 (100%) | 保持 |
| kb_search | PARTIAL (超时) | PASS | 修复 |
| Daytona sandbox | PARTIAL (400) | PARTIAL (400) | 未修复 |
| 新增测试 | - | Vite Proxy, assistants API, writer Gen-UI | +3 项 |
---
*测试人: SOC Test Agent | 生成时间: 2026-04-11*