feat: enterprise product sprint — log protocol, tool rules, UX polish
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 13s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 49s

Backend:
- config.ts: unified startup env validation (throw on missing critical vars)
- tool-executor: structured logToolCall() JSON, ToolStatus enum (success/partial_success/fallback_success/error), preValidateToolCall() hardcoded guards for ticket_detail/chart_generate, durationMs/inputSummary/resultCount on all execution log entries
- agent.ts: tool selection decision tree, chart-as-default-path prompt rules, source labels [知识库][工单][网络][推断]
- tool-defs.ts: applicable/not-applicable guidance on all 4 tools
- soc-client.ts: sandbox hardening (10k char limit, 15s timeout, output truncation, error classification), config.* accessors
- router.ts: preCheckRoute() rules — TK-xxx/工单/知识库 → enterprise direct; greetings → generalInput
- supervisor/types.ts: removed dead config fields (model/temperature/maxTokens/systemPrompt)
- Remove chat-agent (legacy entry point)

Frontend:
- MessageBubble: source badge rendering [知识库][工单][网络][推断], CitationChip [1][2] → clickable chips
- ThreadSidebar: auto-title from first message, collapsible search, long title truncation
- ToolCallStatus: tool-specific loading labels, collapse-all toggle for multi-tool
- main.tsx: conclusion-first layout (AI text above artifacts), draft persistence, IME fix, auto chip, multi-tool AnalysisBlock container, sort_key ordering, retry via soc:retry-tool
- chart-result: empty guard, chart/table toggle, multi-chart format support
- ticket-summary/knowledge-result: work-card quick actions (prefill with context)
- ActionBar: structured payload {text, taskType, sourceCardId}, source label UI
- index.css: card-enter slide animation, dot-bounce loading

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-12 13:01:02 +08:00
co-authored by Claude Sonnet 4.6
parent 183794dc42
commit 9c96913b4d
40 changed files with 2379 additions and 763 deletions
+74 -64
View File
@@ -1,7 +1,7 @@
---
name: soc-deploy-agent
description: so-c-chat-clone 部署 Agent,负责 Azure 资源管理、CI/CD 流水线、GitHub Actions 修复与部署验证
model: opus
model: claude-sonnet-4-6[1m]
tools:
- Read
- Edit
@@ -33,90 +33,97 @@ tools:
## 项目信息
- **项目根路径**: `/Users/gongzhiyong/go/SOC/`
- **后端路径**: `/Users/gongzhiyong/go/SOC/backend/`
- **前端路径**: `/Users/gongzhiyong/go/SOC/frontend/`
- **代码路径**: `/Users/gongzhiyong/go/SOC/langgraph/`
- **GitHub**: https://github.com/Fasthei/so-c-chat-clone(main 分支)
- **Azure Web App**: soc-backend(Python 3.12, Southeast Asia)
- **Azure 订阅**: Xmind运营学习专用2026
## 技术栈
## 当前部署架构
### 部署架构
- **Azure App Service Plan**: soc-plan(B1 Linux)
- **Azure Web App**: soc-backend(Python 3.12, Oryx 构建)
- **启动命令**: `gunicorn -w 2 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 120 app.main:app`
### 后端 (soc-langgraph)
- **Azure Web App**: soc-langgraph (Node.js 20, Southeast Asia, Operation 资源组)
- **App Service Plan**: soc-langgraph-plan (B1 Linux)
- **URL**: https://soc-langgraph.azurewebsites.net
- **WEBSITES_PORT**: 2024
- **启动命令**: `bash startup.sh` → pnpm install + langgraphjs dev --port $PORT
- **容器镜像**: socsocacr.azurecr.io/soc-langgraph:latest
- **ACR**: socsocacr (Basic, Southeast Asia, Operation 资源组)
### 前端 (soc-langgraph-ui)
- **Azure Static Web App**: soc-langgraph-ui (East Asia)
- **URL**: https://salmon-mushroom-0d8872e00.7.azurestaticapps.net
- **构建**: pnpm vite build → dist/ upload
### CI/CD
- **GitHub Actions**: `.github/workflows/deploy-backend.yml`
- **认证方式**: OIDC(azure/login@v2 + Federated Identity)
- **部署方式**: `az webapp deploy --type zip`(Oryx 在 Azure 端构建依赖)
- **触发条件**: push to main + backend/** 文件变更
| Workflow | 触发条件 | 部署方式 |
|----------|---------|---------|
| `deploy-langgraph.yml` | push to main, paths langgraph/** | ACR cloud build → Web App container update → restart |
| `deploy-langgraph-ui.yml` | push to main, paths langgraph/** | pnpm vite build → SWA upload |
### 外部服务
- **PostgreSQL**: dataope.postgres.database.azure.com(soc 数据库)
- **Redis**: oper.redis.cache.windows.net:6380(SSL)
- **Azure Blob Storage**: authdatablol
- **Azure Service Bus**: databus.servicebus.windows.net
## 核心职责
### 1. Azure 资源管理
- 创建/配置 App Service Plan 和 Web App
- 管理环境变量(`az webapp config appsettings set`)
- 配置启动命令和运行时
- 监控应用日志(`az webapp log tail`)
### 2. CI/CD 流水线
- 维护 GitHub Actions workflow
- 修复部署失败问题
- 管理 GitHub Secrets(publish profile, OIDC credentials)
- 监控部署状态(`gh run list/view`)
### 3. 部署验证
- 验证 health 端点
- 检查环境变量完整性
- 确认服务可用性
### 4. 代码推送
- git add → commit → push(部署相关文件)
- 推送前先 `git pull origin main`
### 认证
- **OIDC**: azure/login@v2 + Federated Identity (oidc-msi-8ac6, Operation 资源组 Contributor)
- **SWA Token**: secrets.SWA_LANGGRAPH_TOKEN
- **ACR**: socsocacr admin credentials
## 环境变量清单
部署时需确保 Azure Web App 配置了以下环境变量(参考 `/Users/gongzhiyong/go/SOC/EXTERNAL_SERVICES.md`):
后端 Web App 需配置的环境变量:
```
# Azure OpenAI
AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT
# Google (Supervisor router)
GOOGLE_API_KEY
# KB Agent
KB_AGENT_URL, KB_AGENT_API_KEY, KB_AGENT_SEARCH_PATH
# Jina
JINA_API_KEY
# Daytona
DAYTONA_API_KEY, DAYTONA_API_URL
# Doc Agent
DOC_AGENT_URL, DOC_AGENT_KEY
# Gongdan
GONGDAN_API_BASE, GONGDAN_API_KEY
# Database
DATABASE_URL
# Jina
JINA_API_KEY
# Redis
REDIS_URL
# Serper
SERPER_API_KEY
# Storage
AZURE_STORAGE_CONNECTION_STRING
# Daytona
DAYTONA_API_KEY, DAYTONA_API_URL
# Service Bus
AZURE_SERVICE_BUS_CONNECTION_STRING
# Web App
WEBSITES_PORT=2024
```
前端 Vite build-time 环境变量:
```
VITE_LANGGRAPH_URL=https://soc-langgraph.azurewebsites.net
```
## 已废弃(已删除)
- soc-backend (Python Web App)
- soc-frontend (旧 Static Web App)
- Container App soc-langgraph + soc-cae
## 核心职责
### 1. Azure 资源管理
- 配置 Web App 环境变量(`az webapp config appsettings set`)
- 管理 ACR 镜像和 Web App 容器配置
- 监控应用日志(`az webapp log tail`)
### 2. CI/CD 流水线
- 维护 `.github/workflows/deploy-langgraph.yml` 和 `deploy-langgraph-ui.yml`
- 修复部署失败问题
- 管理 GitHub Secrets
### 3. 部署验证
- 验证 /ok 端点(langgraphjs 内置 health check)
- 检查环境变量完整性
- 确认前后端联通
### 4. 代码推送
- git add → commit → push(部署相关文件)
- 推送前先 `git pull origin main`
## Azure 权限约束(严格遵守)
- **仅允许操作** `AuthData` 和 `Operation` 两个资源组内的资源
@@ -124,12 +131,15 @@ AZURE_SERVICE_BUS_CONNECTION_STRING
- **禁止**在任何其他资源组创建、修改或删除资源
- **禁止**删除任何已存在的资源
## GitHub 规范
- 仓库:`https://github.com/Fasthei/so-c-chat-clone`,main 分支
- commit 前先 `git pull origin main` 避免冲突
- CI/CD workflow 文件在 `.github/workflows/` 目录
## 开发机与代码仓库
- **开发机**: `sshpass -p xiaohei ssh xiaohei@192.168.30.30`(sudo 密码同)
- **开发机项目路径**: `~/SOC/`
- **Docker 启动**: `cd ~/SOC && make dev`
- **Gitee 仓库**: `http://gitee.ath.cx:3000/xiaohei/socaichat.git`(用户: xiaohei, 密码: By@123456)
- **代码同步到开发机**: `sshpass -p xiaohei rsync -avz --exclude=node_modules --exclude=.git -e ssh /Users/gongzhiyong/go/SOC/ xiaohei@192.168.30.30:~/SOC/`
- **开发机端口**: 前端 http://192.168.30.30:5173 | API http://192.168.30.30:2024
## 工作规范
1. 部署前检查现有 Azure 资源(`az resource list --resource-group Operation`)
+71 -59
View File
@@ -1,7 +1,7 @@
---
name: soc-frontend-agent
description: so-c-chat-clone 前端对接 Agent,负责将前端 mock 数据替换为真实后端 API,严禁修改任何前端交互和视觉效果
model: sonnet
description: so-c-chat-clone 前端对接 Agent,负责 Vite SPA + useStream Gen-UI 前端开发
model: claude-sonnet-4-6[1m]
tools:
- Read
- Edit
@@ -25,87 +25,99 @@ tools:
# so-c-chat-clone 前端对接 Agent
你是 so-c-chat-clone 项目的前端 API 对接专家。你的唯一职责是将前端 mock 数据和模拟函数替换为真实后端 API 调用。
你是 so-c-chat-clone 项目的前端开发专家。项目基于 LangGraph.js Gen-UI 架构,前端是 Vite SPA + React 19 + @langchain/langgraph-sdk/react useStream。
## 铁律(绝对不能违反)
## 工作原则
**❌ 禁止修改任何前端交互、视觉效果、组件结构、样式、动画、布局。**
具体禁止项:
- 不能改颜色、字体、间距、动画
- 不能改组件的 JSX 结构和层级
- 不能新增或删除 UI 元素
- 不能改用户操作流程(点击、输入、提交逻辑)
- 不能改 props 接口(除非是新增可选参数)
- 不能改路由和页面结构
**✅ 唯一允许修改的内容:**
- `simulateAIResponse()` 替换为真实 SSE API 调用
- mock 数据(conversations、tickets)替换为真实 API 请求
- 新增 API 调用函数(放在独立 utils/api 文件中)
- 环境变量配置(.env.local)
可以修改前端任何文件,包括:
- Gen-UI 卡片组件(`src/agent-uis/enterprise/`)
- Chat UI 组件、布局、样式、动画
- useStream 相关的数据流处理
- 通用组件(`src/components/`)
- 环境变量和 API 配置
- main.tsx 主入口文件
## 项目信息
- **前端路径**: `/Users/gongzhiyong/go/SOC/frontend/`
- **后端 API 文档**: `/Users/gongzhiyong/go/SOC/doc/api.md`
- **后端 URL (生产)**: `https://soc-backend.azurewebsites.net`
- **后端 URL (本地)**: `http://localhost:8000`
- **代码路径**: `/Users/gongzhiyong/go/SOC/langgraph/`
- **前端入口**: `src/main.tsx`
- **Gen-UI 卡片**: `src/agent-uis/enterprise/`(5 个组件)
- **后端 URL (生产)**: `https://soc-langgraph.azurewebsites.net`
- **前端 URL (生产)**: `https://salmon-mushroom-0d8872e00.7.azurestaticapps.net`
- **GitHub**: https://github.com/Fasthei/so-c-chat-clone(main 分支)
## 技术栈
- Next.js 16 + React 19 + TypeScript
- Tailwind CSS 4 + shadcn/ui
- 入口: `frontend/app/page.tsx` → `<GeminiChat />`
- 核心组件: `GeminiChat.tsx`(拥有所有状态)
- Mock 函数: `simulateAIResponse()` 在 `GeminiChat.tsx` 中
- **Vite** + React 19 + TypeScript
- **Tailwind CSS 4** + shadcn/ui + Radix UI
- **@langchain/langgraph-sdk/react** — useStream SSE 协议
- **@assistant-ui/react** + @assistant-ui/react-markdown — 聊天 UI 框架
- **framer-motion** — 动画
- **recharts** — 图表
- **react-markdown** + remark-gfm + rehype-katex — Markdown 渲染
## 前端改动授权范围
## 关键文件
已明确授权的改动:
1. `GeminiInput.tsx` 的 `onSubmit` 扩展参数,将 `activeTools`(Set\<string\>)和 `selectedModel`("flash"|"pro")传给后端
2. `GeminiChat.tsx` 的 `handleSend` 接收 tools/model,传入 `/api/chat/stream`
3. 替换 mock conversations 为真实 `GET /api/conversations`
4. 替换 mock tickets 为真实 `GET /api/tickets`
## SSE 对接方式
后端 SSE 事件格式:
```
data: {"type": "token", "content": "..."}
data: {"type": "tool_start", "tool": "kb_search"}
data: {"type": "tool_end", "tool": "kb_search"}
data: {"type": "done"}
langgraph/
├── src/main.tsx ← 应用入口
├── src/agent-uis/
│ ├── index.tsx ← Gen-UI 组件注册表
│ └── enterprise/ ← 5 个 Gen-UI 卡片
│ ├── knowledge-result/index.tsx ← 知识库搜索结果
│ ├── ticket-summary/index.tsx ← 工单列表摘要
│ ├── ticket-detail/index.tsx ← 工单详情
│ ├── search-result/index.tsx ← 网络搜索结果
│ └── sandbox-result/index.tsx ← 沙盒执行结果
├── src/components/ ← 通用 UI 组件
├── src/lib/ ← 工具函数
├── index.html ← HTML 模板
├── vite.config.ts ← Vite 配置
└── tailwind.config.js ← Tailwind 配置
```
前端需要用 `EventSource` 或 `fetch` + `ReadableStream` 读取 SSE 流,将 token 逐步追加到消息内容中。
## Gen-UI 协议
后端 `ui.push()` 推送组件名和 props,前端通过 `agent-uis/index.tsx` 注册的组件自动渲染:
| 组件名 | Props | 场景 |
|--------|-------|------|
| `knowledge-result` | query, total, results[] | 知识库搜索 |
| `ticket-summary` | total, tickets[], stats | 工单列表 |
| `ticket-detail` | id, title, status, priority, customer, engineer, created, description | 工单详情 |
| `search-result` | query, total, results[] | 网络搜索 |
| `sandbox-result` | language, exit_code, stdout, has_more, duration_ms | 沙盒执行 |
## 构建和部署
```bash
cd langgraph
VITE_LANGGRAPH_URL=https://soc-langgraph.azurewebsites.net pnpm vite build
# Output: langgraph/dist/ → Azure Static Web Apps
```
CI/CD: `.github/workflows/deploy-langgraph-ui.yml`
## 不确定交互时的处理方式
当你对某个交互细节不确定时(例如:tool_start 事件如何展示、loading 状态在哪个组件等):
1. 先用 `mcp__v0__createChat` 或 `mcp__v0__sendChatMessage` 与 v0 对话,描述当前组件结构和问题
当你对某个交互细节不确定时:
1. 先用 `mcp__v0__createChat` 或 `mcp__v0__sendChatMessage` 与 v0 对话
2. 根据 v0 的建议确认方案后再动手
3. 不要自行猜测 UI 实现方式
## 工作流程
1. 先读 `/Users/gongzhiyong/go/SOC/doc/api.md` 了解所有后端接口
2. 读 `frontend/app/page.tsx` 和 `frontend/components/GeminiChat.tsx` 了解现有结构
3. 找到所有 mock 数据和 `simulateAIResponse()` 位置
4. 制定最小改动方案(只改 API 调用,不改交互)
5. 逐步实现,每步改完后检查是否影响了交互
6. 完成后 git commit + push
## Azure 和 GitHub 约束
- 不操作任何 Azure 资源
- 代码推送到 `main` 分支
- commit 前先 `git pull origin main`
## 开发机与代码仓库
- **开发机**: `sshpass -p xiaohei ssh xiaohei@192.168.30.30`(sudo 密码同)
- **开发机项目路径**: `~/SOC/`
- **Docker 启动**: `cd ~/SOC && make dev`
- **Gitee 仓库**: `http://gitee.ath.cx:3000/xiaohei/socaichat.git`(用户: xiaohei, 密码: By@123456)
- **代码同步到开发机**: `sshpass -p xiaohei rsync -avz --exclude=node_modules --exclude=.git -e ssh /Users/gongzhiyong/go/SOC/ xiaohei@192.168.30.30:~/SOC/`
- **开发机端口**: 前端 http://192.168.30.30:5173 | API http://192.168.30.30:2024
## 工作规范
1. 每次修改前必须先 Read 理解现有代码
2. 使用 Edit 做最小化修改,不用 Write 整体重写组件
3. 不确定交互细节时必须通过 v0 MCP 确认,不要猜
4. 完成后通知 team-lead
4. commit 前先 `git pull origin main`
5. 完成后通知 team-lead
+68 -45
View File
@@ -1,7 +1,7 @@
---
name: soc-llm-engineer-agent
description: so-c-chat-clone 大模型工程师 Agent,专注 AI 交互问题诊断、Prompt 优化、LangGraph 流程调优与大模型能力方案设计,为其他 Agent 提供 AI 技术支持
model: opus
description: so-c-chat-clone 大模型工程师 Agent,专注 AI 交互问题诊断、Prompt 优化、LangGraph.js 流程调优与大模型能力方案设计,为其他 Agent 提供 AI 技术支持
model: claude-sonnet-4-6[1m]
tools:
- Read
- Edit
@@ -23,75 +23,98 @@ tools:
# so-c-chat-clone 大模型工程师 Agent
你是 so-c-chat-clone 项目的大模型工程师,专注于 AI 交互质量、Prompt 工程、LangGraph 流程设计与大模型能力评估。当其他 Agent 遇到 AI 交互问题(模型输出异常、工具调用失败、意图识别偏差、Prompt 效果差等)时,由你提供技术诊断与解决方案。
你是 so-c-chat-clone 项目的大模型工程师,专注于 AI 交互质量、Prompt 工程、LangGraph.js 流程设计与大模型能力评估。当其他 Agent 遇到 AI 交互问题时,由你提供技术诊断与解决方案。
## 项目信息
- **项目根路径**: `/Users/gongzhiyong/go/SOC/`
- **后端路径**: `/Users/gongzhiyong/go/SOC/backend/`
- **设计文档**: `/Users/gongzhiyong/go/SOC/gpthd.md`(完整功能方案,任务前必读)
- **外部服务**: `/Users/gongzhiyong/go/SOC/EXTERNAL_SERVICES.md`
- **代码路径**: `/Users/gongzhiyong/go/SOC/langgraph/`
- **GitHub**: https://github.com/Fasthei/so-c-chat-clone(main 分支)
- **Azure 后端 URL**: https://soc-backend.azurewebsites.net
- **后端 URL**: https://soc-langgraph.azurewebsites.net
## 技术栈(AI 相关)
- **LLM**: Azure OpenAI `gpt-5.4`(`ai-gzy0016231ai975636166896.cognitiveservices.azure.com`)
- **LangChain**: 模型调用、Prompt 组织、Tool 封装、Output Parser
- **LangGraph**: 对话状态机、工具路由、ReAct Agent、interrupt/checkpoint
- **KB_AGENT**: 内部知识库检索(`agnetdoc` Function App)
- **Jina**: Search / Reader / Rerank 外部搜索链路
- **意图分类**: `app/intent_classifier.py`(AsyncAzureOpenAI, few-shot, 5s 超时)
### Agent 架构
```
Supervisor (Gemini 2.0 Flash) → router → route to enterprise or generalInput
Enterprise Agent (Azure OpenAI gpt-5.4) → bindTools → call tools → ui.push() → final LLM answer
```
### 关键文件
- **Supervisor router**: `src/agent/supervisor/nodes/router.ts` — Gemini Flash 意图路由
- **Enterprise tools node**: `src/agent/enterprise/nodes/tools.ts` — 工具调用 + Gen-UI
- **Tool schemas**: 同文件内 Zod schemas(kbSearchSchema 等)
- **System prompt**: `tools.ts` 内 LLM invoke 的 system message
- **外部 API 客户端**: `src/agent/enterprise/tools/soc-client.ts`
### LLM 配置
- **Supervisor**: Gemini 2.0 Flash(路由分类,低延迟)
- **Enterprise**: AzureChatOpenAI gpt-5.4(工具调用 + 最终回答)
- **Model Mode**: flash(快速,排除 web_search)/ pro(深度,排除 google_search)/ auto(全部)
### Gen-UI 流程
1. LLM `bindTools(tools).invoke()` → 返回 tool_calls
2. 逐个执行外部 API 调用
3. `ui.push({ name: "component-name", props: {...} })` 推送 Gen-UI 卡片
4. 收集 toolMessages → LLM 再次 invoke 生成最终文字回答
5. 返回 `{ messages, ui: ui.items, timestamp }`
## 核心职责
### 1. AI 交互问题诊断
- 分析 SSE 流异常(截断、ERR_INCOMPLETE_CHUNKED_ENCODING、空响应)
- 诊断 LangGraph checkpoint 污染(tool_call 无 ToolMessage → ValueError)
- 定位工具调用失败根因(KB 超时、Jina rerank 异常、intent 分类误判)
- 排查模型输出格式错误(JSON 解析失败、tool_call 格式不合规)
- Supervisor 路由误判(应该路由到 enterprise 却路由到 generalInput)
- LLM 不触发工具调用(tool descriptions 不够清晰)
- 工具调用参数错误(Zod schema 定义问题)
- Gen-UI 卡片数据不完整(ui.push props 映射问题)
### 2. Prompt 工程
- 优化系统 Prompt,提升模型指令遵循度
- 设计 few-shot 示例,改善意图分类准确率
- 调整 ReAct Agent 的思考链格式,减少幻觉和重复工具调用
- 针对中文业务场景(工单、知识库、运营报告)优化 Prompt 风格
- Enterprise Agent system prompt 优化
- Supervisor router prompt 和 tool descriptions 优化
- 工具 description 调优,提升触发准确率
- 针对中文业务场景优化回答风格
### 3. LangGraph 流程设计
- 设计和优化 Agent graph 节点与边的路由逻辑
- 实现条件分支(`route_tools` 函数)
- 设计 interrupt/human-in-the-loop 节点(审批流程)
- 优化 checkpoint 策略,防止状态污染
### 3. LangGraph.js 流程设计
- Supervisor → Enterprise 路由逻辑优化
- Enterprise 工具节点的单轮 vs 多轮调用策略
- Checkpointer 配置和会话持久化
- 错误处理和 fallback 策略
### 4. 大模型能力方案
- 评估新功能是否需要 Tool Calling / RAG / Function Calling
- 设计多工具组合调用流程(search → rerank → generate)
- 提供 token 用量优化建议(历史压缩、上下文窗口管理)
- 评估模型版本升级影响
### 4. Model Mode 策略
- flash/pro/auto 三种模式的工具过滤逻辑
- 模型参数调优(temperature, max_tokens)
- 评估是否需要引入新工具或调整工具组合
### 5. 与其他 Agent 协作
- **soc-backend-agent** 遇到 LangGraph/LangChain 问题时,提供代码级修复方案
- **soc-backend-agent** 遇到 LangGraph.js / tool calling 问题时,提供代码级修复方案
- **soc-tester-agent** 发现 AI 响应质量问题时,提供 Prompt 调优方案
- **soc-frontend-agent** 遇到 SSE 事件格式或 tool_status 事件异常时,确认后端 AI 链路
- **soc-frontend-agent** 遇到 Gen-UI 渲染异常时,确认后端 ui.push 数据格式
- 将诊断结论和解决方案写入 MCP 记忆,供其他 Agent 参考
## 常见问题速查
| 问题现象 | 优先排查点 |
|---------|-----------|
| SSE 流在 metadata 后中断 | intent_classifier 是否同步阻塞事件循环 |
| tool_call 后无 ToolMessage → ValueError | LangGraph checkpoint 污染,需 adelete_thread |
| KB 搜索超时 | ReadTimeout 设置(建议 30s)+ 1次重试 |
| 模型不调用工具 | system prompt 工具描述是否清晰,tools 参数是否传入 |
| 意图分类误判(搜索/不搜索) | intent_classifier few-shot 示例覆盖不足 |
| 模型输出截断 | max_tokens 设置,或 SSE try/except/finally 缺失 |
| 重复工具调用死循环 | ReAct graph 缺少 max_iterations 限制 |
| 用户问企业问题但路由到 generalInput | router.ts 的 Supervisor prompt 和 ALL_TOOL_DESCRIPTIONS |
| LLM 不调用工具 | tools.ts 的 tool description 是否清晰,bindTools 是否传入 |
| 工具调用但 Gen-UI 卡片不显示 | ui.push 的 name 是否与 agent-uis/index.tsx 注册名一致 |
| 最终回答重复工具数据 | system prompt 需强调"无需在文字中重复数据细节" |
| 沙盒执行超时 | soc-client.ts 的 AbortSignal.timeout 设置 |
| 搜索结果质量差 | Jina/Serper 返回数据是否被正确截断和传递 |
## 开发机与代码仓库
- **开发机**: `sshpass -p xiaohei ssh xiaohei@192.168.30.30`(sudo 密码同)
- **开发机项目路径**: `~/SOC/`
- **Docker 启动**: `cd ~/SOC && make dev`
- **Gitee 仓库**: `http://gitee.ath.cx:3000/xiaohei/socaichat.git`(用户: xiaohei, 密码: By@123456)
- **代码同步到开发机**: `sshpass -p xiaohei rsync -avz --exclude=node_modules --exclude=.git -e ssh /Users/gongzhiyong/go/SOC/ xiaohei@192.168.30.30:~/SOC/`
- **开发机端口**: 前端 http://192.168.30.30:5173 | API http://192.168.30.30:2024
## 工作规范
1. **诊断优先**:任务开始前先读相关代码(`app/main.py`、`app/intent_classifier.py`、`tools/` 目录),理解现有实现再给方案
1. **诊断优先**:任务开始前先读相关代码,理解现有实现再给方案
2. **最小化修改**:修改 Prompt 或代码时,精确定位问题行,使用 Edit 做最小改动
3. **方案文档化**:重要的 Prompt 设计决策、few-shot 示例选择理由,写入 `/Users/gongzhiyong/go/SOC/doc/` 目录下对应 md 文件
4. **记忆同步**:每次完成诊断或优化后,使用 `mcp__zsk__memory_write` 写入开发日志,category 用 `decision` 或 `note`
5. **Azure 权限约束**:仅允许操作 `AuthData` 和 `Operation` 两个资源组,禁止删除已有资源
6. **不破坏前端契约**:SSE 事件格式(`data:`, `event:`, `id:`)和字段名不得单方面修改,需与前端 Agent 确认
3. **方案文档化**:重要的 Prompt 设计决策写入 `/Users/gongzhiyong/go/SOC/doc/` 目录
4. **记忆同步**:每次完成诊断或优化后,使用 `mcp__zsk__memory_write` 写入开发日志
5. **Azure 权限约束**:仅允许操作 `AuthData` 和 `Operation` 两个资源组
6. **不破坏 Gen-UI 契约**:ui.push 的组件名和 props 结构不得单方面修改,需与前端 Agent 确认
+63 -57
View File
@@ -1,7 +1,7 @@
---
name: soc-tester-agent
description: so-c-chat-clone 测试 Agent,负责 Azure 部署端点复测、功能验证、测试报告生成与问题反馈
model: opus
model: claude-sonnet-4-6[1m]
tools:
- Read
- Edit
@@ -28,67 +28,65 @@ tools:
# so-c-chat-clone 测试 Agent
你是 so-c-chat-clone 项目的测试专家。负责对部署到 Azure 的后端服务进行全面端点测试、功能验证,生成测试报告并反馈问题。
你是 so-c-chat-clone 项目的测试专家。负责对部署到 Azure 的 LangGraph.js 服务进行全面端点测试、功能验证,生成测试报告并反馈问题。
## 项目信息
- **项目根路径**: `/Users/gongzhiyong/go/SOC/`
- **后端路径**: `/Users/gongzhiyong/go/SOC/backend/`
- **代码路径**: `/Users/gongzhiyong/go/SOC/langgraph/`
- **测试报告**: `/Users/gongzhiyong/go/SOC/test.md`
- **GitHub**: https://github.com/Fasthei/so-c-chat-clone(main 分支)
- **Azure 后端 URL**: https://soc-backend.azurewebsites.net
## 测试端点清单
## 线上地址
### Phase 1 — 基础对话 + 会话 CRUD
- **后端 LangGraph Server**: https://soc-langgraph.azurewebsites.net
- **前端 Static Web App**: https://salmon-mushroom-0d8872e00.7.azurestaticapps.net
## 测试端点
### LangGraph Server 内置端点
```
GET /health
POST /api/chat/stream (flash, 无 tools)
POST /api/chat/stream (pro, 无 tools)
GET /api/conversations
POST /api/conversations
GET /api/conversations/{id}
PATCH /api/conversations/{id}
DELETE /api/conversations/{id}
GET /ok ← 健康检查(langgraphjs 内置)
GET /info ← 服务信息
POST /runs/stream ← SSE 流式调用
GET /threads ← 线程列表
POST /threads ← 创建线程
GET /threads/{id} ← 线程详情
```
### Phase 2 — 工具接入(KB + 工单)
```
POST /api/chat/stream (tools=["knowledge"])
POST /api/chat/stream (tools=["tickets"])
GET /api/tickets
GET /api/tickets/{id}
```
### Phase 3 — 外部搜索 + Redis 缓存
```
POST /api/chat/stream (tools=["search"], model=flash)
POST /api/chat/stream (tools=["search"], model=pro)
```
### Phase 4 — 文档生成 + 沙盒
```
POST /api/chat/stream (tools=["document"])
POST /api/chat/stream (tools=["sandbox"])
```
## 测试方法
### SSE 端点
### SSE 流式调用测试
```bash
curl -N --max-time 60 https://soc-backend.azurewebsites.net/api/chat/stream \
# 通用对话(路由到 generalInput)
curl -N --max-time 60 https://soc-langgraph.azurewebsites.net/runs/stream \
-X POST -H "Content-Type: application/json" \
-d '{"message":"测试内容","conversation_id":"test-id","tools":[],"model":"flash"}'
-d '{"assistant_id":"agent","input":{"messages":[{"role":"user","content":"你好"}]},"stream_mode":["messages","updates"]}'
# Enterprise Agent(路由到 enterprise)
curl -N --max-time 60 https://soc-langgraph.azurewebsites.net/runs/stream \
-X POST -H "Content-Type: application/json" \
-d '{"assistant_id":"agent","input":{"messages":[{"role":"user","content":"查询最近的工单"}]},"stream_mode":["messages","updates"]}'
```
### REST 端点
```bash
curl -s https://soc-backend.azurewebsites.net/api/conversations
```
### 工具触发测试
| 工具 | 触发方式 | 预期 UI 组件 |
|------|---------|-------------|
| kb_search | "查询知识库关于XXX的信息" | knowledge-result |
| ticket_list | "查询最近的工单" | ticket-summary |
| ticket_detail | "查看工单XXX的详情" | ticket-detail |
| web_search | (pro mode) "搜索最新的XXX" | search-result |
| google_search | (flash mode) "搜索XXX" | search-result |
| sandbox_run | "帮我运行这段代码:print('hello')" | sandbox-result |
### 健康检查(含重试)
### 前端测试
```bash
curl -s -o /dev/null -w "%{http_code}" --max-time 15 https://soc-backend.azurewebsites.net/health
# 页面可访问
curl -s -o /dev/null -w "%{http_code}" https://salmon-mushroom-0d8872e00.7.azurestaticapps.net
# CORS(前端→后端)
curl -s -X OPTIONS https://soc-langgraph.azurewebsites.net/ok \
-H "Origin: https://salmon-mushroom-0d8872e00.7.azurestaticapps.net" \
-H "Access-Control-Request-Method: POST" \
-D -
```
## 测试报告格式
@@ -96,15 +94,15 @@ curl -s -o /dev/null -w "%{http_code}" --max-time 15 https://soc-backend.azurewe
测试结果写入 `/Users/gongzhiyong/go/SOC/test.md`:
```markdown
# SOC 后端部署复测报告
# SOC 部署复测报告
## 测试环境
- URL: https://soc-backend.azurewebsites.net
- 后端: https://soc-langgraph.azurewebsites.net
- 前端: https://salmon-mushroom-0d8872e00.7.azurestaticapps.net
- 测试时间: YYYY-MM-DD
- 测试阶段: Phase X + Phase Y
## 测试结果汇总
| # | 端点 | 方法 | 状态码 | 结果 |
| # | 测试项 | 状态 | 备注 |
## 详细测试记录
(每个测试的请求、响应摘要、判定)
@@ -119,25 +117,33 @@ X/Y 通过
## 核心职责
### 1. 部署就绪检查
- 轮询 health 端点,确认部署完成
- 最多重试 10 次,每次间隔 60 秒
- 轮询 /ok 端点,确认 LangGraph Server 启动完成
- 检查 /info 端点返回的 graphs 信息
### 2. 全端点复测
- 按 Phase 顺序逐个测试
- 健康检查 → 线程 CRUD → SSE 流式调用 → 工具触发 → Gen-UI 渲染
- 记录请求、响应状态码、响应内容摘要
- 判定通过/失败/部分通过
### 3. 测试报告
- 写入 `/Users/gongzhiyong/go/SOC/test.md`
- 包含汇总表、详细记录、问题清单、通过率
### 3. 前端回归测试
- 页面加载、静态资源、CORS 链路
- useStream SSE 连接是否正常
### 4. 问题反馈
- 发现问题后通知 team-lead 或后端 Agent
- 提供问题描述和建议修复方案
## 开发机与代码仓库
- **开发机**: `sshpass -p xiaohei ssh xiaohei@192.168.30.30`(sudo 密码同)
- **开发机项目路径**: `~/SOC/`
- **Docker 启动**: `cd ~/SOC && make dev`
- **Gitee 仓库**: `http://gitee.ath.cx:3000/xiaohei/socaichat.git`(用户: xiaohei, 密码: By@123456)
- **代码同步到开发机**: `sshpass -p xiaohei rsync -avz --exclude=node_modules --exclude=.git -e ssh /Users/gongzhiyong/go/SOC/ xiaohei@192.168.30.30:~/SOC/`
- **开发机端口**: 前端 http://192.168.30.30:5173 | API http://192.168.30.30:2024
## 工作规范
1. 测试前确认 health 端点可用
1. 测试前确认 /ok 端点可用
2. SSE 端点使用 `curl -N --max-time 60`
3. 记录完整的请求和响应
4. 测试完成后使用 `mcp__cursor-project-memory__memory_write` 写入测试记录
+94 -70
View File
@@ -4,16 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
so-c-chat-clone — 企业级对话系统,基于 LangGraph.js Gen-UI 架构。Supervisor Agent 路由 + Enterprise Agent 工具调用,支持知识库检索、工单查询、网络搜索、代码沙盒执行,前端通过 useStream 实时渲染 Gen-UI 卡片。
so-c-chat-clone — 企业级对话系统,基于 LangGraph.js Gen-UI 架构。Supervisor Agent 意图路由到 5 个专用 Sub-Agent,每个 Agent 调用外部 API 并通过 `ui.push()` 推送 Gen-UI 卡片,前端 useStream 实时渲染。
## Development Machine
- **开发机**: `ssh xiaohei@192.168.30.30` (密码: xiaohei, sudo 同密码)
- **开发机**: `sshpass -p xiaohei ssh xiaohei@192.168.30.30`(sudo 密码同)
- **项目路径**: `~/SOC/`
- **Docker 部署**: `cd ~/SOC && make dev`
- **代码同步**: 本地 `git push gitee main` → 开发机 `cd ~/SOC && git pull`
- **Gitee 仓库**: `http://gitee.ath.cx:3000/xiaohei/socaichat.git` (用户: xiaohei, 密码: By@123456)
- **SSH 工具**: `sshpass -p xiaohei ssh xiaohei@192.168.30.30`
- **代码同步**: 本地 `git push gitee main` → 开发机 `cd ~/SOC && git pull origin main`
- **Gitee 仓库**: `http://gitee.ath.cx:3000/xiaohei/socaichat.git`(用户: xiaohei, 密码: By@123456)
### 开发机端口
- 前端 (dev): `http://192.168.30.30:5173`
@@ -21,26 +20,16 @@ so-c-chat-clone — 企业级对话系统,基于 LangGraph.js Gen-UI 架构。
## Commands
### 后端(LangGraph Server)
```bash
cd langgraph
pnpm install
pnpm run agent # langgraphjs dev --no-browser (port 2024)
pnpm run agent # 启动后端 langgraphjs dev (port 2024)
pnpm run build # tsc -b && vite build(前端)
```
### 前端(Vite SPA)
开发机一键重启:
```bash
cd langgraph
pnpm install
pnpm run build # tsc -b && vite build
```
### Deployment
```bash
# Push to main branch — GitHub Actions auto-deploys:
# - deploy-langgraph.yml: ACR build → Web App container update
# - deploy-langgraph-ui.yml: pnpm vite build → Azure Static Web Apps
git push origin main
sshpass -p xiaohei ssh xiaohei@192.168.30.30 "cd ~/SOC && make dev"
```
## Architecture
@@ -58,75 +47,111 @@ git push origin main
Node.js 20 LTS + langgraphjs dev (port 2024)
↓ HTTP
[外部服务]
├── Azure OpenAI (gpt-5.4)
├── Azure OpenAI (gpt-5.4) ← 所有 Sub-Agent LLM
├── Google Gemini 2.0 Flash ← Supervisor 路由
├── KB Agent (Azure AI Search)
├── Gongdan 工单 API
├── Jina Search/Reader
├── Jina Search/Reader/Rerank
├── Serper Google Search
└── Daytona Sandbox
└── Daytona Sandbox (process/execute endpoint)
```
### Agent Graph
```
Supervisor (Gemini 2.0 Flash) → router
├── enterprise → Enterprise Agent (Azure OpenAI gpt-5.4, 6 tools, Gen-UI cards)
Supervisor (Gemini 2.0 Flash) → intent router
├── enterprise → KB检索 + 工单查询(ReAct,MAX_ITERATIONS=6)
├── searcher → Google搜索 + Jina深度搜索
├── coder → 代码执行(Daytona sandbox)
├── writer → Canvas文档 + 报告生成 + 回复草稿
└── generalInput → 通用对话
```
### Enterprise Agent Tools (src/agent/enterprise/nodes/tools.ts)
| Tool Name | External Service | UI Component |
|-----------|-----------------|--------------|
| `kb_search` | KB Agent (Azure AI Search) | `knowledge-result` |
| `ticket_list` | Gongdan API | `ticket-summary` |
| `ticket_detail` | Gongdan API | `ticket-detail` |
| `web_search` | Jina Search + Reader | `search-result` |
| `google_search` | Serper API | `search-result` |
| `sandbox_run` | Daytona REST API | `sandbox-result` |
路由基于**意图**而非关键词:writer=文档/报告/话术,coder=代码/计算,searcher=最新信息/新闻,enterprise=内部知识/工单,generalInput=其他。
### Model Mode Filtering
- `flash`: 排除 `web_search`(太慢),保留 `google_search`
- `pro`: 排除 `google_search`,使用深度 `web_search`
- `auto`: 保留全部工具
### Agent 文件模式
### Request Flow
每个 Sub-Agent 的目录结构一致:
```
Frontend useStream → LangGraph SSE
→ Supervisor router (Gemini Flash) → route to enterprise or generalInput
→ Enterprise tools node: LLM bindTools → call external APIs → ui.push() Gen-UI cards → LLM final answer
→ SSE stream with messages + UI components
src/agent/{name}/
index.ts ← StateGraph 定义(START → agent → route → tool-executor → agent)
types.ts ← State annotation
nodes/
agent.ts ← LLM bindTools + system prompt
tool-executor.ts ← 执行工具调用,ui.push() Gen-UI 卡片
tool-defs.ts ← Zod schema + ALL_TOOLS 定义(writer/enterprise)
```
### Tool → Gen-UI 卡片映射
| Agent | Tool | Gen-UI 卡片 | 说明 |
|-------|------|------------|------|
| enterprise | `kb_search` | `knowledge-result` | 含 citations,KB失败自动fallback到google_search |
| enterprise | `ticket_list` | `ticket-summary` + `chart-result` | 同时推状态/优先级分布图 |
| enterprise | `ticket_detail` | `ticket-detail` | TK-xxxx格式自动解析为UUID |
| searcher | `google_search` | `search-result` | 结果<3条自动补Jina搜索,含citations |
| searcher | `web_search_deep` | `search-result` | Jina Search+Reader+Rerank,含citations |
| coder | `code_execute` | `sandbox-result` | Daytona /sandbox + /toolbox/{id}/process/execute |
| writer | `doc_create/edit/translate` | `canvas-doc` | CanvasPanel右侧抽屉 |
| writer | `report_generate` | `canvas-doc` | 结构化报告模板 |
| writer | `reply_draft` | `reply-draft` | customer/internal双模式 |
| enterprise (自动) | — | `next-actions` | 工具成功后自动附带2-3条推荐动作 |
所有卡片 props 包含 `sourceType`(internal_kb/ticket_system/external_web/code_execution/generated_doc)和 `confidence`(high/medium/low)。
### 关键工具函数
- `src/agent/utils/retry.ts` — `executeWithRetry(fn, retries, {backoffMs, exponential})`,`formatToolError(name, e)`
- `src/agent/utils/truncate-messages.ts` — 上下文压缩
- `src/agent/utils/file-service.ts` — PDF/图片/Excel/文本解析 + Azure Blob
- `src/agent/utils/checkpointer.ts` — PostgresSaver,Gen-UI ui items 随 checkpoint 持久化恢复
### Frontend 关键文件
- `src/main.tsx` — Chat UI 主入口,useStream,`deduplicateUiItems()`(按card_id合并loading→complete)
- `src/components/ToolCallStatus.tsx` — 工具调用状态(loading旋转/green✓/red✗),支持展开查看Gen-UI结果+骨架屏
- `src/components/ActionBar.tsx` — 卡片底部追问/快捷动作,dispatch `soc:prefill-input` CustomEvent
- `src/components/SourceBadge.tsx` — 来源类型徽章(颜色+置信度图标)
- `src/components/MessageBubble.tsx` — ReactMarkdown + KaTeX + Prism,>800字自动显示摘要块
- `src/components/CanvasPanel.tsx` — 右侧文档编辑抽屉,监听 `open-canvas` CustomEvent
- `src/agent-uis/index.tsx` — Gen-UI 组件注册表(ComponentMap)
## Gen-UI 卡片开发规范
新增卡片需要:
1. 创建 `src/agent-uis/enterprise/{name}/index.tsx`(参考 knowledge-result 结构)
2. 在 `src/agent-uis/index.tsx` 的 ComponentMap 注册
3. 后端 `tool-executor.ts` 中 `ui.push({ name: "{name}", props: {...} }, { message: lastAiMessage })`
props 必须包含 `sourceType` 和 `confidence`(来自 retry.ts 的工具执行结果),可选 `errorMessage`(触发红色 error 态)。
## Daytona Sandbox 注意事项
Daytona API 正确端点:
- 创建: `POST {DAYTONA_API_URL}/sandbox`,body: `{ autoStopInterval: 5, autoDeleteInterval: 0 }`
- 执行: `POST https://proxy.app.daytona.io/toolbox/{sandboxId}/process/execute`,body: `{ command: "python3 -c '...'" }`
- 删除: `DELETE {DAYTONA_API_URL}/sandbox/{sandboxId}`
## Repository Structure
```
so-c-chat-clone/
├── langgraph/ ← 唯一代码目录
│ ├── src/agent/supervisor/ ← Supervisor Agent(路由)
│ │ ├── index.ts ← StateGraph + checkpointer
│ │ ├── nodes/router.ts ← Gemini Flash 意图路由
│ │ └── nodes/general-input.ts ← 通用对话节点
│ ├── src/agent/enterprise/ ← Enterprise Agent
│ │ ├── index.ts ← StateGraph (START → tools)
│ │ ├── nodes/tools.ts ← 6 tools + LLM + ui.push()
│ │ ├── tools/soc-client.ts ← 外部 API 客户端
│ │ └── types.ts ← EnterpriseAnnotation
│ ├── src/agent/chat-agent/index.ts ← 简单聊天 Agent
│ ├── src/agent/utils/ ← checkpointer, format-messages
│ ├── src/agent-uis/enterprise/ ← 5 个 Gen-UI 卡片组件
│ │ ├── knowledge-result/
│ │ ├── ticket-summary/
│ │ ├── ticket-detail/
│ │ ├── search-result/
│ │ └── sandbox-result/
│ ├── src/main.tsx ← Chat UI 入口(useStream)
│ ├── src/agent/
│ │ ├── supervisor/ ← 路由 + generalInput
│ │ ├── enterprise/ ← KB + 工单(ReAct)
│ │ ├── searcher/ ← Google + Jina
│ │ ├── coder/ ← Daytona 沙盒
│ │ ├── writer/ ← Canvas + 报告 + 草稿
│ │ └── utils/ ← retry, checkpointer, file-service, truncate
│ ├── src/agent-uis/enterprise/ ← 9 个 Gen-UI 卡片组件
│ ├── src/components/ ← 通用 UI 组件
│ ├── src/main.tsx ← Chat UI 入口
│ ├── langgraph.json ← LangGraph 配置
│ ├── startup.sh ← Azure Web App 启动脚本
│ ├── Dockerfile ← 后端容器
│ ├── Dockerfile.frontend ← 前端容器(未使用)
│ └── package.json ← pnpm, Node.js 20
│ └── startup.sh ← Azure Web App 启动脚本
├── .github/workflows/
│ ├── deploy-langgraph.yml ← ACR build → Web App
│ └── deploy-langgraph-ui.yml ← Vite build → Static Web App
├── doc/ ← 文档
└── CLAUDE.md
```
@@ -135,16 +160,16 @@ so-c-chat-clone/
环境变量通过 Azure Web App 应用设置配置,本地通过 `langgraph/.env`:
```
# Azure OpenAI
# Azure OpenAI (Sub-Agent LLM)
AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT
# Google (Supervisor router)
# Google Gemini (Supervisor router)
GOOGLE_API_KEY
# KB Agent
KB_AGENT_URL, KB_AGENT_API_KEY, KB_AGENT_SEARCH_PATH
# Gongdan
# Gongdan 工单
GONGDAN_API_BASE, GONGDAN_API_KEY
# Jina
@@ -164,10 +189,9 @@ VITE_LANGGRAPH_URL=https://soc-langgraph.azurewebsites.net
### 后端 (soc-langgraph)
- **Azure Web App**: Node.js 20, B1 Linux, Southeast Asia, Operation 资源组
- **App Service Plan**: soc-langgraph-plan
- **启动命令**: `bash startup.sh`(pnpm install + langgraphjs dev --port $PORT)
- **WEBSITES_PORT**: 2024
- **CI/CD**: ACR cloud build → container update → restart
- **CI/CD**: ACR cloud build → Web App container update
### 前端 (soc-langgraph-ui)
- **Azure Static Web App**: East Asia
@@ -183,4 +207,4 @@ VITE_LANGGRAPH_URL=https://soc-langgraph.azurewebsites.net
- **Azure 资源组**: 仅允许操作 `Operation` 和 `AuthData`,所有 `az` 命令必须带 `--resource-group`
- **禁止删除已存在的 Azure 资源**
- **GitHub**: Fasthei/so-c-chat-clone,main 分支
- **已废弃(已删除)**: backend/ (Python), frontend/ (Next.js), soc-backend Web App, soc-frontend Static Web App, Container App
- **已废弃**: backend/ (Python), frontend/ (Next.js), soc-backend/soc-frontend Web Apps, Container App
+1 -2
View File
@@ -1,8 +1,7 @@
{
"node_version": "20",
"graphs": {
"agent": "./src/agent/supervisor/index.ts:graph",
"chat": "./src/agent/chat-agent/index.ts:agent"
"agent": "./src/agent/supervisor/index.ts:graph"
},
"ui": {
"agent": "./src/agent-uis/index.tsx"
@@ -7,6 +7,13 @@ interface CanvasDocProps {
content: string;
type: "markdown" | "code";
language?: string;
sourceType?: string;
confidence?: "high" | "medium" | "low";
sort_key?: number;
artifact_id?: string;
execution_summary?: string;
report_type?: string;
source?: string;
}
/**
@@ -15,16 +15,35 @@ import {
Tooltip,
Legend,
} from "recharts";
import { BarChart2 } from "lucide-react";
import { BarChart2, Table2 } from "lucide-react";
import { useState } from "react";
interface SingleChart {
chart_type?: "bar" | "line" | "pie" | "area";
title?: string;
data: Array<{ name?: string; label?: string; value?: number; [key: string]: unknown }>;
x_key?: string;
y_keys?: string[];
}
interface ChartResultProps {
title: string;
chart_type: "bar" | "line" | "pie" | "area";
data: Array<{ label: string; value: number; [key: string]: unknown }>;
x_key: string;
y_keys: string[];
title?: string;
// single-chart format (legacy)
chart_type?: "bar" | "line" | "pie" | "area";
data?: Array<{ label?: string; name?: string; value?: number; [key: string]: unknown }>;
x_key?: string;
y_keys?: string[];
colors?: string[];
unit?: string;
// multi-chart format (backend-agent new format)
charts?: SingleChart[];
// common metadata
sourceType?: string;
confidence?: "high" | "medium" | "low";
artifact_id?: string;
sort_key?: number;
source?: string;
execution_summary?: string;
}
// Default color palette — uses CSS variables to respect dark/light theme
@@ -41,18 +60,28 @@ function makeTooltipFormatter(unit?: string) {
unit ? [`${value} ${unit}`, ""] : [String(value), ""];
}
export default function ChartResult({
title,
chart_type,
data,
x_key,
y_keys,
colors,
unit,
}: ChartResultProps) {
export default function ChartResult(props: ChartResultProps) {
const { colors, unit } = props;
const [viewMode, setViewMode] = useState<"chart" | "table">("chart");
const [chartIndex] = useState(0);
const palette = colors?.length ? colors : DEFAULT_COLORS;
const tooltipFormatter = makeTooltipFormatter(unit);
// Normalize to array of charts
const allCharts: SingleChart[] = props.charts
? props.charts
: [{ chart_type: props.chart_type ?? "bar", title: props.title, data: props.data ?? [], x_key: props.x_key ?? "label", y_keys: props.y_keys ?? ["value"] }];
const activeChart = allCharts[Math.min(chartIndex, allCharts.length - 1)];
const title = activeChart.title ?? props.title ?? "图表";
const chart_type = activeChart.chart_type ?? "bar";
const data = activeChart.data ?? [];
const x_key = activeChart.x_key ?? "name";
const y_keys = activeChart.y_keys ?? ["value"];
// Empty data guard
const hasData = Array.isArray(data) && data.length > 0;
const commonProps = {
data,
margin: { top: 4, right: 16, bottom: 4, left: 0 },
@@ -167,14 +196,85 @@ export default function ChartResult({
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<BarChart2 className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm text-foreground">{title}</span>
<span className="font-medium text-sm text-foreground flex-1">{title}</span>
{/* View toggle */}
{hasData && (
<div className="flex items-center gap-0.5 rounded-md border border-border overflow-hidden">
<button
type="button"
onClick={() => setViewMode("chart")}
className={`flex items-center gap-1 px-2 py-1 text-xs transition-colors ${
viewMode === "chart"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
title="图表视图"
>
<BarChart2 className="w-3 h-3" />
图表
</button>
<button
type="button"
onClick={() => setViewMode("table")}
className={`flex items-center gap-1 px-2 py-1 text-xs transition-colors ${
viewMode === "table"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
title="表格视图"
>
<Table2 className="w-3 h-3" />
表格
</button>
</div>
)}
</div>
{/* Chart area */}
{/* Content area */}
<div className="px-4 py-4">
{!hasData ? (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
暂无数据
</div>
) : viewMode === "chart" ? (
<ResponsiveContainer width="100%" height={240}>
{renderChart()}
</ResponsiveContainer>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-xs border-collapse border border-border">
<thead>
<tr>
<th className="border border-border px-3 py-1.5 bg-muted text-left font-medium">
{x_key}
</th>
{y_keys.map((k) => (
<th
key={k}
className="border border-border px-3 py-1.5 bg-muted text-left font-medium"
>
{k}{unit ? ` (${unit})` : ""}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i} className={i % 2 === 0 ? "" : "bg-muted/30"}>
<td className="border border-border px-3 py-1.5">
{String(row[x_key] ?? row.label ?? "")}
</td>
{y_keys.map((k) => (
<td key={k} className="border border-border px-3 py-1.5">
{String(row[k] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
@@ -0,0 +1,125 @@
import { AlertTriangle, RefreshCw, ShieldAlert, Edit3, MessageSquare } from "lucide-react";
interface ErrorResultProps {
tool: string;
message: string;
suggestion?: "retry" | "contact_admin" | "check_input";
timestamp?: number;
artifact_id?: string;
sort_key?: number;
}
const TOOL_LABELS: Record<string, string> = {
kb_search: "知识库检索",
ticket_list: "工单列表",
ticket_detail: "工单详情",
google_search: "网络搜索",
web_search_deep: "深度搜索",
sandbox_run: "代码沙盒",
chart_generate: "图表生成",
};
/** Possible reasons per suggestion type */
const REASON_MAP: Record<string, string[]> = {
retry: ["服务暂时繁忙", "网络连接不稳定"],
contact_admin: ["账号权限不足", "服务配置需要更新"],
check_input: ["输入的编号可能有误", "该记录可能已被删除或归档"],
};
/** Action buttons per suggestion type */
const ACTION_MAP: Record<
string,
Array<{ label: string; prompt: string; icon: typeof RefreshCw }>
> = {
retry: [
{ label: "重试", prompt: "请重新执行上一个操作", icon: RefreshCw },
{ label: "换个方式提问", prompt: "", icon: MessageSquare },
],
contact_admin: [
{ label: "联系支持", prompt: "如何联系系统管理员?", icon: ShieldAlert },
{ label: "换个方式提问", prompt: "", icon: MessageSquare },
],
check_input: [
{ label: "重试", prompt: "请重新执行上一个操作", icon: RefreshCw },
{ label: "换个方式提问", prompt: "", icon: Edit3 },
],
};
function dispatchPrefill(text: string) {
if (!text) return;
window.dispatchEvent(
new CustomEvent("soc:prefill-input", { detail: { text } }),
);
}
function dispatchRetryTool(toolName: string, prompt: string) {
// Prefill input with retry prompt
dispatchPrefill(prompt);
// Also signal a targeted retry so the host can pass retryTool in configurable
window.dispatchEvent(
new CustomEvent("soc:retry-tool", { detail: { toolName } }),
);
}
export default function ErrorResult({
tool,
message,
suggestion,
}: ErrorResultProps) {
const label = TOOL_LABELS[tool] ?? tool;
const reasons = suggestion ? REASON_MAP[suggestion] ?? [] : [];
const actions = suggestion ? ACTION_MAP[suggestion] ?? [] : [];
return (
<div className="w-full max-w-2xl rounded-xl border border-red-200 dark:border-red-900/40 bg-red-50 dark:bg-red-950/20 text-card-foreground shadow-sm overflow-hidden">
{/* Part 1: Error header + friendly description */}
<div className="flex items-center gap-2 px-4 py-3">
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />
<span className="text-sm font-medium text-red-700 dark:text-red-400">
{label} - 执行异常
</span>
</div>
<div className="px-4 pb-2">
<p className="text-sm text-red-600 dark:text-red-300">{message}</p>
</div>
{/* Part 2: Possible reasons */}
{reasons.length > 0 && (
<div className="px-4 pb-2">
<p className="text-xs text-red-500/80 dark:text-red-400/70 mb-1">
可能原因:
</p>
<ul className="list-disc list-inside text-xs text-red-500/80 dark:text-red-400/70 space-y-0.5">
{reasons.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</div>
)}
{/* Part 3: Action buttons */}
{actions.length > 0 && (
<div className="flex items-center gap-2 px-4 py-2.5 border-t border-red-200/60 dark:border-red-900/30">
{actions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
// "重试" buttons dispatch soc:retry-tool in addition to prefill
if (a.label === "重试") {
dispatchRetryTool(tool, a.prompt);
} else {
dispatchPrefill(a.prompt);
}
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
>
<a.icon className="w-3 h-3" />
{a.label}
</button>
))}
</div>
)}
</div>
);
}
@@ -1,4 +1,6 @@
import { BookOpen, AlertCircle } from "lucide-react";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
@@ -12,6 +14,10 @@ interface KnowledgeResultProps {
confidence?: "high" | "medium" | "low";
errorMessage?: string;
citations?: Citation[];
artifact_id?: string;
sort_key?: number;
source?: string;
execution_summary?: string;
}
export default function KnowledgeResult({
@@ -23,6 +29,19 @@ export default function KnowledgeResult({
errorMessage,
citations,
}: KnowledgeResultProps) {
const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent<{ index: number }>;
setHighlightedIndex(ce.detail.index);
// Auto-clear highlight after 3 s
setTimeout(() => setHighlightedIndex(null), 3000);
};
window.addEventListener("soc:highlight-citation", handler);
return () => window.removeEventListener("soc:highlight-citation", handler);
}, []);
return (
<div className={`w-full max-w-2xl rounded-xl border ${errorMessage && results.length === 0 ? "border-red-200 dark:border-red-900/40" : "border-border"} bg-card text-card-foreground shadow-sm overflow-hidden`}>
{/* Header */}
@@ -77,8 +96,14 @@ export default function KnowledgeResult({
{citations && citations.length > 0 && (
<div className="px-4 py-2 border-t border-border/40 space-y-1">
{citations.map(c => (
<div key={c.index} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono text-[10px] bg-muted px-1 rounded">[{c.index}]</span>
<div
key={c.index}
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground rounded px-1 py-0.5 transition-colors duration-300",
highlightedIndex === c.index && "bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-300 dark:ring-blue-700",
)}
>
<span className="font-mono text-[10px] bg-muted px-1 rounded shrink-0">[{c.index}]</span>
{c.url ? <a href={c.url} target="_blank" rel="noopener noreferrer" className="hover:text-primary hover:underline truncate">{c.title}</a>
: <span className="truncate">{c.title}</span>}
{c.source && <span className="shrink-0 text-[10px] opacity-60">{c.source}</span>}
@@ -86,11 +111,30 @@ export default function KnowledgeResult({
))}
</div>
)}
<div className="px-4 pb-3">
<div className="px-4 pb-3 flex flex-col gap-1.5">
{/* Dedicated quick actions for this card */}
<div className="flex flex-wrap gap-1.5 mt-3 pt-2 border-t border-border/40">
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text: "基于以上知识库内容,整理成操作指南", sourceLabel: "知识库检索", taskType: "knowledge" } }))}
className="inline-flex items-center px-2.5 py-1 text-xs rounded-full border border-border hover:bg-primary/10 hover:border-primary/40 transition-colors text-muted-foreground hover:text-primary"
>
生成操作指南
</button>
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text: "关于以上知识库内容,我想了解更多: ", sourceLabel: "知识库检索", taskType: "knowledge" } }))}
className="inline-flex items-center px-2.5 py-1 text-xs rounded-full border border-border hover:bg-primary/10 hover:border-primary/40 transition-colors text-muted-foreground hover:text-primary"
>
追问细节
</button>
</div>
<ActionBar
sourceType={sourceType || "internal_kb"}
context={query}
suggestedActions={["生成知识摘要", "继续深入搜索", "导出到文档"]}
cardTitle="知识库检索"
taskType="knowledge"
/>
</div>
</div>
@@ -1,7 +1,14 @@
import { Zap } from "lucide-react";
interface Action { label: string; prompt: string; icon: string; }
interface NextActionsProps { actions: Action[]; }
interface NextActionsProps {
actions: Action[];
sourceType?: string;
confidence?: "high" | "medium" | "low";
sort_key?: number;
artifact_id?: string;
execution_summary?: string;
}
export default function NextActions({ actions }: NextActionsProps) {
const dispatch = (text: string) => {
@@ -10,6 +10,12 @@ interface TicketDetailProps {
engineer: string;
created: string;
description: string;
sourceType?: string;
confidence?: "high" | "medium" | "low";
artifact_id?: string;
sort_key?: number;
source?: string;
execution_summary?: string;
}
function priorityClass(priority: string): string {
@@ -17,6 +17,10 @@ interface TicketSummaryProps {
sourceType?: string;
confidence?: "high" | "medium" | "low";
errorMessage?: string;
artifact_id?: string;
sort_key?: number;
source?: string;
execution_summary?: string;
}
function priorityClass(priority: string): string {
@@ -144,11 +148,30 @@ export default function TicketSummary({
))
)}
</ul>
<div className="px-4 pb-3">
<div className="px-4 pb-3 flex flex-col gap-1.5">
{/* Dedicated quick actions for this card */}
<div className="flex flex-wrap gap-1.5 mt-3 pt-2 border-t border-border/40">
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text: "基于以上工单,帮我草拟一份处理方案", sourceLabel: "工单列表", taskType: "tickets" } }))}
className="inline-flex items-center px-2.5 py-1 text-xs rounded-full border border-border hover:bg-primary/10 hover:border-primary/40 transition-colors text-muted-foreground hover:text-primary"
>
草拟处理方案
</button>
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text: "将以上工单汇总生成一份工单分析报告", sourceLabel: "工单列表", taskType: "tickets" } }))}
className="inline-flex items-center px-2.5 py-1 text-xs rounded-full border border-border hover:bg-primary/10 hover:border-primary/40 transition-colors text-muted-foreground hover:text-primary"
>
生成工单报告
</button>
</div>
<ActionBar
sourceType={sourceType || "ticket_system"}
context={`共 ${total} 条工单`}
suggestedActions={["查看工单详情", "生成处理建议", "生成跟进话术"]}
cardTitle="工单列表"
taskType="tickets"
/>
</div>
</div>
+2
View File
@@ -7,6 +7,7 @@ import ChartResult from "./enterprise/chart-result";
import CanvasDoc from "./enterprise/canvas-doc";
import ReplyDraft from "./enterprise/reply-draft";
import NextActions from "./enterprise/next-actions";
import ErrorResult from "./enterprise/error-result";
const ComponentMap = {
"knowledge-result": KnowledgeResult,
@@ -18,5 +19,6 @@ const ComponentMap = {
"canvas-doc": CanvasDoc,
"reply-draft": ReplyDraft,
"next-actions": NextActions,
"error-result": ErrorResult,
} as const;
export default ComponentMap;
-29
View File
@@ -1,29 +0,0 @@
import {
Annotation,
MessagesAnnotation,
START,
StateGraph,
} from "@langchain/langgraph";
import { createLlm } from "@/agent/utils/create-llm";
const ChatAgentAnnotation = Annotation.Root({
messages: MessagesAnnotation.spec["messages"],
});
const graph = new StateGraph(ChatAgentAnnotation)
.addNode("chat", async (state) => {
const model = createLlm();
const response = await model.invoke([
{ role: "system", content: "You are a helpful assistant." },
...state.messages,
]);
return {
messages: response,
};
})
.addEdge(START, "chat");
export const agent = graph.compile();
agent.name = "Chat Agent";
@@ -8,7 +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";
import { executeWithRetry } from "@/agent/utils/retry";
export async function toolExecutorNode(
state: CoderState,
+51 -14
View File
@@ -18,15 +18,51 @@ const SYSTEM_PROMPT = `你是企业内部助手,帮助用户查询知识库和
- 不需要工具的问题直接回答
- 工具调用失败时坦诚告知原因,并给出替代建议(如换一种查询方式或联系相关人员)
## 来源标注规范
在回答中,每个关键结论前用方括号标注信息来源:
- [内部知识] — 来自知识库检索的原文信息
- [工单数据] — 来自工单系统的实际记录
- [网络搜索] — 来自外部网络搜索结果
- [模型推断] — 基于训练知识推断,非原始数据依据
## 图表分析规则
- 当工具返回多条数据记录(>= 1 条)时,视为"可视化机会"
- 遇到分析类问题(含"趋势/统计/分析/对比/汇总/多少")时,在文字总结前先思考是否有数据可视化
- 工单类查询结果必须附带图表(状态分布、优先级分布),系统会自动生成
- 当你需要对已有数据做额外维度的可视化(如时间趋势、自定义对比),主动调用 chart_generate 工具
- 知识库返回含数值型数据时,考虑用 chart_generate 生成数值摘要图
来源可信度:内部知识 > 工单数据 > 网络搜索 > 模型推断
若结论仅有[模型推断]支撑,必须明确说明不确定性。
## 规则
### 停止查询条件
- 已调用工具 3 次仍无有效结果 -> 停止工具调用,直接回答"未找到相关信息"并给出替代建议
- 工具返回结果已足够回答用户问题 -> 立即停止,不要再调用更多工具
- 用户问题是闲聊或不涉及业务数据 -> 不调用任何工具,直接回答
### 工具优先级
当多种工具都可能适用时,按以下优先级选择:
1. 知识库搜索(kb_search)— 企业内部信息首选
2. 工单系统(ticket_list / ticket_detail)— 工单相关查询
3. 网络搜索 — 仅当内部数据不足时使用
4. 代码沙盒 — 仅需要计算或代码执行时
5. 图表生成(chart_generate)— 对已有数据做额外维度的可视化
**重要**:当用户问题含"分析/趋势/统计/占比/分布/汇总/对比/多少"时,优先调用 ticket_list(而非 ticket_detail),以触发图表自动生成。获得数据后如需更多维度的可视化,继续调用 chart_generate。
### 工具选择决策树
- 内部信息(公司规范、系统文档、产品手册)→ kb_search;知识库不足再补网络搜索
- 公开信息(新闻、行业标准、外部技术文档)→ 直接 google_search/web_search,无需先查知识库
- 有工单号(如 TK-2026-xxx)→ ticket_detail;无工单号 → ticket_list
- 已有真实数据需可视化 → chart_generate;无数据不得调用
- 需执行代码/计算 → sandbox;其他情况不调用
### 主动输出建议
- 查完工单后,主动给出"建议处理方案"和下一步行动
- 搜索完知识库后,主动给出"相关操作步骤"
- 发现异常数据模式(如大量未处理工单、重复故障),主动提示风险
### 失败回退
- 工具调用失败后,优先利用已有信息(上下文中其他工具结果、对话历史)回答
- 如果完全没有可用信息,用[模型推断]标注,诚实告知局限性
- 不要因为一个工具失败就放弃回答
## 来源标注规范
在关键结论句末用短标签标注来源:[知识库]、[工单]、[网络]、[推断]。
可信度:[知识库] > [工单] > [网络] > [推断]。
若仅有[推断]支撑,必须说明不确定性。
## 企业风格输出格式
回答结构(超过200字时使用):
@@ -59,10 +95,10 @@ const SYSTEM_PROMPT = `你是企业内部助手,帮助用户查询知识库和
1. 不要在回答中暴露技术报错、HTTP 状态码、堆栈信息
2. 用中文友好地说明:发生了什么、为什么(用户能理解的语言)
3. 给出至少一条可操作的替代建议,例如:
- 知识库无结果 → 建议换个关键词,或说明知识库可能暂未收录该内容
- 工单查询失败 → 建议直接联系工单管理员,或稍后重试
- 代码执行失败 → 直接分析代码逻辑给出结果,说明沙盒暂时不可用
- 搜索失败 → 基于已有知识给出答案,标注[模型推断]
- 知识库无结果 -> 建议换个关键词,或说明知识库可能暂未收录该内容
- 工单查询失败 -> 建议直接联系工单管理员,或稍后重试
- 代码执行失败 -> 直接分析代码逻辑给出结果,说明沙盒暂时不可用
- 搜索失败 -> 基于已有知识给出答案,标注[模型推断]
4. 语气要稳定专业,不要说"抱歉"超过一次,不要表现出慌乱
## next-actions 触发规范
@@ -72,11 +108,12 @@ const SYSTEM_PROMPT = `你是企业内部助手,帮助用户查询知识库和
- 如果用户的问题已完全回答,结尾简洁即可,不要过度延伸
## 图表触发说明
当 ticket_list 返回结果时,系统会自动生成工单统计图表(chart-result 卡片)。
当 ticket_list 返回 >= 1 条工单时,系统会自动生成工单统计图表(chart-result 卡片)。
当用户问题包含分析类意图(趋势/统计/分析/对比/多少)时,系统还会额外生成时间趋势图。
你在回答中:
- 可以引用图表数据(如"从状态分布图可以看出,待处理工单占比最高")
- 不需要重复列举数据,图表已直观展示
- 如果工单数量 < 3 条,不必提及图表`;
- 如果需要展示其他维度的图表,主动调用 chart_generate 工具`;
export async function agentNode(
state: EnterpriseState,
@@ -17,25 +17,37 @@ export const ticketDetailSchema = z.object({
ticket_id: z.string().describe("The ticket number / ID"),
});
export const chartGenerateSchema = z.object({
chart_type: z.enum(["bar", "pie", "line"]).describe("图表类型"),
title: z.string().describe("图表标题"),
data: z.array(z.object({ name: z.string(), value: z.number() })).describe("图表数据"),
});
export const ALL_ENTERPRISE_TOOLS = [
{
name: "kb_search",
description:
"搜索内部知识库。当用户询问公司文档、产品信息、技术资料、内部规范、流程制度时使用。传入自然语言查询词。",
"搜索内部知识库。适用:公司文档、产品信息、技术资料、内部规范、流程制度等内部信息。不适用:公开新闻、行业标准等外部信息请用 google_search。传入自然语言查询词。",
schema: kbSearchSchema,
},
{
name: "ticket_list",
description:
"查询工单列表。当用户想了解工单概览、查看最近的工单、查看工单状态汇总时使用。支持分页。",
"查询工单列表。适用:无具体工单号时浏览工单概览、最近工单、状态汇总、分析统计。不适用:已有明确工单编号时应直接用 ticket_detail。支持分页。",
schema: ticketListSchema,
},
{
name: "ticket_detail",
description:
"查询指定工单的详细信息(处理进度、历史记录、负责人等)。当用户提到具体工单编号或想深入了解某个工单时使用。",
"查询指定工单详情(处理进度、历史记录、负责人等)。适用:仅当用户明确提供工单编号(如 TK-2026-xxx)时使用。不适用:不知道工单号时先用 ticket_list 查找。",
schema: ticketDetailSchema,
},
{
name: "chart_generate",
description:
"根据已查到的真实数据生成图表(柱状图、饼图、折线图)。适用:已通过其他工具获取数据后需要可视化分析。不适用:无数据时不得调用;ticket_list 的状态/优先级分布图由系统自动生成,无需手动调用。",
schema: chartGenerateSchema,
},
] as const;
export type EnterpriseToolDef = (typeof ALL_ENTERPRISE_TOOLS)[number];
@@ -2,12 +2,22 @@
* Tool executor node: executes tool calls from the last AI message,
* pushes Gen-UI cards, and returns ToolMessages.
* Phase 2: only kb_search + ticket_list + ticket_detail remain here.
*
* Sprint 2026-04-12 optimizations:
* - Unified source/confidence/execution_summary on every ui.push()
* - Error artifact: push error-result card on tool failure
* - AbortController timeout (15s default) with structured error handling
* - chart-result auto-derived from ticket_list stats
* - Stable artifact_id: `${toolCallId}_${toolName}`
* - sort_key on every push
* - Duplicate push guard via state.ui
* - execution_log entries for frontend visibility
*/
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import type ComponentMap from "../../../agent-uis/index.js";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { AIMessage } from "@langchain/core/messages";
import { EnterpriseState, EnterpriseUpdate } from "../types.js";
import { EnterpriseState, EnterpriseUpdate, ExecutionLogEntry } from "../types.js";
import type { ToolExecStatus } from "../../types.js";
import {
kbSearch,
@@ -18,9 +28,25 @@ import {
kbSearchSchema,
ticketListSchema,
ticketDetailSchema,
chartGenerateSchema,
} from "./tool-defs.js";
import { executeWithRetry, formatToolError } from "@/agent/utils/retry";
// Structured tool execution trace logger — Azure log stream can filter by field
function logToolCall(entry: ExecutionLogEntry) {
console.log(JSON.stringify({ event: "tool_exec", ...entry }));
}
/** Truncate a string to maxLen characters for input summaries */
function truncateInput(s: string, maxLen = 200): string {
return s.length > maxLen ? s.slice(0, maxLen) + "..." : s;
}
/** Default per-tool timeout in ms */
const TOOL_TIMEOUT_MS = 15_000;
/** KB search gets a longer timeout due to cold-start */
const KB_TIMEOUT_MS = 45_000;
/** Map raw status codes to Chinese labels for chart display */
function statusLabel(status: string): string {
const map: Record<string, string> = {
@@ -34,6 +60,37 @@ function statusLabel(status: string): string {
return map[status?.toLowerCase()] ?? status;
}
/** Analysis keywords that trigger extra time-trend chart */
const ANALYSIS_KEYWORDS = /趋势|统计|分析|对比|汇总|多少|走势|变化|增长|下降/;
/** Check if user's latest question contains analysis intent */
function hasAnalysisIntent(messages: EnterpriseState["messages"]): boolean {
// Walk backwards to find the last human message
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i] as Record<string, unknown>;
const isHuman =
m.role === "user" ||
(typeof m._getType === "function" && (m._getType as () => string)() === "human") ||
m.constructor?.name === "HumanMessage";
if (isHuman && "content" in m) {
const content = typeof m.content === "string" ? m.content : "";
return ANALYSIS_KEYWORDS.test(content);
}
}
return false;
}
/** Map error to a suggestion type for the error-result card */
function errorSuggestion(
toolName: string,
error: unknown,
): "retry" | "contact_admin" | "check_input" {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("401") || msg.includes("403")) return "contact_admin";
if (msg.includes("404") || msg.includes("not found")) return "check_input";
return "retry";
}
/** Generate suggested next actions based on which tools ran successfully */
function generateNextActions(
tools: string[],
@@ -76,6 +133,72 @@ function generateNextActions(
return actions.slice(0, 3);
}
/**
* Check whether an artifact with the given toolCallId already exists in state.ui.
* Prevents duplicate pushes across retries or re-invocations.
*/
function hasArtifactForToolCall(
stateUi: EnterpriseState["ui"],
toolCallId: string,
): boolean {
return stateUi.some((item) => {
const props = ((item as unknown) as Record<string, unknown>).props as
| Record<string, unknown>
| undefined;
return props?.artifact_id && String(props.artifact_id).startsWith(toolCallId);
});
}
/**
* Wrap a tool invocation with an AbortController timeout.
* If the function completes before the deadline, the timer is cleared.
* On timeout, the AbortError is thrown and caught by the caller.
*/
async function withTimeout<T>(
fn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fn(controller.signal);
} finally {
clearTimeout(timer);
}
}
/**
* Pre-validate tool call arguments before execution.
* Returns a human-readable block reason, or null if validation passes.
*/
function preValidateToolCall(
name: string,
args: Record<string, unknown>,
state: EnterpriseState,
): string | null {
// ticket_detail: must have a valid ticket ID
if (name === "ticket_detail") {
const id = String(args.ticket_id ?? "").trim();
if (!id || id === "undefined" || id === "null") {
return "ticket_detail 调用被拦截:未提供有效工单编号,请改用 ticket_list 查询工单列表";
}
}
// chart_generate: must have prior successful tool data in this conversation
if (name === "chart_generate") {
const hasData = state.execution_log?.some(
(e) =>
e.status !== "error" &&
["kb_search", "ticket_list", "ticket_detail"].includes(e.tool),
);
if (!hasData) {
return "chart_generate 调用被拦截:当前对话尚无工具数据,无法生成图表";
}
}
return null;
}
export async function toolExecutorNode(
state: EnterpriseState,
config: LangGraphRunnableConfig,
@@ -103,25 +226,63 @@ export async function toolExecutorNode(
}> = [];
const statusList: ToolExecStatus[] = [];
const executionLog: ExecutionLogEntry[] = [];
// Execute all tool calls in parallel
const executions = toolCalls.map(async (tc) => {
const name = tc.name;
const args = tc.args;
const id = tc.id ?? "";
const artifactId = `${id}_${name}`;
const sortKey = Date.now();
// Skip if artifact already exists for this toolCallId (duplicate guard)
if (hasArtifactForToolCall(state.ui, id)) {
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({ note: "已有此工具的执行结果" }),
};
}
// Pre-validate tool call arguments
const blockReason = preValidateToolCall(name, args as Record<string, unknown>, state);
if (blockReason) {
statusList.push({ tool: name, status: "error", message: blockReason });
executionLog.push({
tool: name,
status: "error",
summary: blockReason,
timestamp: Date.now(),
durationMs: 0,
inputSummary: truncateInput(JSON.stringify(args)),
resultCount: 0,
errorCode: "PRECONDITION_FAILED",
errorMessage: blockReason,
});
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({ ok: false, tool: name, summary: blockReason, error: "PRECONDITION_FAILED" }),
};
}
const startTime = Date.now();
try {
switch (name) {
case "kb_search": {
const parsed = kbSearchSchema.parse(args);
let kbData: Awaited<ReturnType<typeof kbSearch>> | null = null;
let fallbackUsed = false;
try {
kbData = await executeWithRetry(
kbData = await withTimeout(
() =>
executeWithRetry(
() => kbSearch(parsed.query),
3,
{ backoffMs: 1000, exponential: true },
),
KB_TIMEOUT_MS,
);
} catch (kbError) {
// Push friendly error card, then try fallback to google_search
@@ -135,24 +296,41 @@ export async function toolExecutorNode(
sourceType: "error",
confidence: "low",
errorMessage: "知识库暂时无响应,已切换到网络搜索",
artifact_id: artifactId,
sort_key: sortKey,
source: "knowledge_base" as const,
execution_summary: "知识库检索失败,尝试网络搜索回退",
},
},
{ message: lastAiMessage },
);
try {
const { googleSearch } = await import("../tools/soc-client.js");
const gData = await googleSearch(parsed.query);
const gData = await withTimeout(
() => googleSearch(parsed.query),
TOOL_TIMEOUT_MS,
);
const fallbackResults = gData.results.slice(0, 5).map((r) => ({
title: r.title,
category: "网络搜索",
snippet: r.snippet?.slice(0, 200) ?? "",
}));
fallbackUsed = true;
statusList.push({
tool: name,
status: "fallback",
status: "fallback_success",
message: "知识库不可用,已使用网络搜索替代",
});
const fallbackLogEntry: ExecutionLogEntry = {
tool: name,
status: "fallback_success",
summary: `知识库不可用,回退到网络搜索,获取 ${fallbackResults.length} 条结果`,
timestamp: Date.now(),
inputSummary: truncateInput(`query: ${parsed.query}`),
durationMs: Date.now() - startTime,
resultCount: fallbackResults.length,
};
executionLog.push(fallbackLogEntry);
logToolCall(fallbackLogEntry);
return {
role: "tool" as const,
tool_call_id: id,
@@ -164,6 +342,34 @@ export async function toolExecutorNode(
};
} catch {
statusList.push({ tool: name, status: "error", message: formatToolError(name, kbError) });
const dblFailEntry: ExecutionLogEntry = {
tool: name,
status: "error",
summary: "知识库及网络搜索均不可用",
timestamp: Date.now(),
inputSummary: truncateInput(`query: ${parsed.query}`),
durationMs: Date.now() - startTime,
resultCount: 0,
errorCode: "DOUBLE_FALLBACK_FAIL",
errorMessage: "知识库及网络搜索均不可用",
};
executionLog.push(dblFailEntry);
logToolCall(dblFailEntry);
// Push error artifact
ui.push(
{
name: "error-result" as never,
props: {
tool: name,
message: formatToolError(name, kbError),
suggestion: errorSuggestion(name, kbError),
timestamp: Date.now(),
artifact_id: `${artifactId}_error`,
sort_key: Date.now(),
} as never,
},
{ message: lastAiMessage },
);
return {
role: "tool" as const,
tool_call_id: id,
@@ -183,6 +389,9 @@ export async function toolExecutorNode(
source: r.category,
url: undefined,
}));
const execSummary = results.length > 0
? `搜索到 ${results.length} 条知识库结果`
: "知识库未找到相关内容";
ui.push(
{
name: "knowledge-result",
@@ -193,10 +402,44 @@ export async function toolExecutorNode(
citations: kbCitations,
sourceType: "internal_kb",
confidence: results.length > 0 ? "high" : "medium",
artifact_id: artifactId,
sort_key: sortKey,
source: "knowledge_base" as const,
execution_summary: execSummary,
},
},
{ message: lastAiMessage },
);
// Push category distribution chart when kb results span >= 2 categories
const kbCategoryStats: Record<string, number> = {};
results.forEach((r) => {
const cat = r.category || "其他";
kbCategoryStats[cat] = (kbCategoryStats[cat] ?? 0) + 1;
});
if (Object.keys(kbCategoryStats).length >= 2) {
const kbCatChart = Object.entries(kbCategoryStats).map(
([cname, value]) => ({ name: cname, value }),
);
ui.push(
{
name: "chart-result",
props: {
title: "知识库结果分类分布",
charts: [
{ chart_type: "pie", title: "类别分布", data: kbCatChart },
],
sourceType: "internal_kb",
confidence: "medium",
artifact_id: `${artifactId}_chart`,
sort_key: sortKey + 1,
source: "knowledge_base" as const,
execution_summary: `知识库类别分布:${kbCatChart.map((c) => `${c.name}(${c.value})`).join("、")}`,
},
},
{ message: lastAiMessage },
);
}
const kbContent: Record<string, unknown> = { total: results.length, results };
if (results.length === 0) {
kbContent.hint = "知识库未找到相关内容。建议:可尝试使用搜索引擎查找相关信息。";
@@ -204,6 +447,17 @@ export async function toolExecutorNode(
} else {
statusList.push({ tool: name, status: "ok" });
}
const kbLogEntry: ExecutionLogEntry = {
tool: name,
status: results.length === 0 ? "partial_success" : "success",
summary: execSummary,
timestamp: Date.now(),
inputSummary: truncateInput(`query: ${parsed.query}`),
durationMs: Date.now() - startTime,
resultCount: results.length,
};
executionLog.push(kbLogEntry);
logToolCall(kbLogEntry);
return {
role: "tool" as const,
tool_call_id: id,
@@ -213,7 +467,10 @@ export async function toolExecutorNode(
case "ticket_list": {
const parsed = ticketListSchema.parse(args);
const data = await executeWithRetry(() => ticketList(parsed.page ?? 1));
const data = await withTimeout(
() => executeWithRetry(() => ticketList(parsed.page ?? 1), 2),
TOOL_TIMEOUT_MS,
);
const tickets = (data.tickets ?? []).map((t) => ({
id: t.ticketNumber,
title: t.description?.slice(0, 80) ?? "",
@@ -226,21 +483,35 @@ export async function toolExecutorNode(
tickets.forEach((t) => {
stats[t.status] = (stats[t.status] ?? 0) + 1;
});
const execSummary = tickets.length > 0
? `查询到 ${tickets.length} 条工单`
: "未查询到工单";
// Only push ticket-summary + chart when there are actual results
if (tickets.length > 0) {
ui.push(
{
name: "ticket-summary",
props: { total: tickets.length, tickets, stats, sourceType: "ticket_system", confidence: "high" },
props: {
total: tickets.length,
tickets,
stats,
sourceType: "ticket_system",
confidence: "high",
artifact_id: artifactId,
sort_key: sortKey,
source: "ticket_system" as const,
execution_summary: execSummary,
},
},
{ message: lastAiMessage },
);
}
// Push chart-result card for ticket distribution
if (tickets.length > 0) {
const statusChart = Object.entries(stats).map(([name, value]) => ({
name: statusLabel(name),
// Push chart-result card for ticket distribution (>= 1 ticket)
if (tickets.length >= 1) {
const statusChart = Object.entries(stats).map(([cname, value]) => ({
name: statusLabel(cname),
value,
}));
const priorityStats: Record<string, number> = {};
@@ -248,19 +519,44 @@ export async function toolExecutorNode(
priorityStats[t.priority] = (priorityStats[t.priority] ?? 0) + 1;
});
const priorityChart = Object.entries(priorityStats).map(
([name, value]) => ({ name, value }),
([cname, value]) => ({ name: cname, value }),
);
const charts: Array<{ chart_type: string; title: string; data: Array<{ name: string; value: number }> }> = [
{ chart_type: "pie", title: "状态分布", data: statusChart },
{ chart_type: "bar", title: "优先级分布", data: priorityChart },
];
// Add time-trend chart when user has analysis intent
if (hasAnalysisIntent(state.messages)) {
const dateStats: Record<string, number> = {};
tickets.forEach((t) => {
const date = t.created || "未知";
dateStats[date] = (dateStats[date] ?? 0) + 1;
});
const trendData = Object.entries(dateStats)
.sort(([a], [b]) => a.localeCompare(b))
.map(([cname, value]) => ({ name: cname, value }));
if (trendData.length >= 1) {
charts.push({
chart_type: "line",
title: "工单创建时间趋势",
data: trendData,
});
}
}
ui.push(
{
name: "chart-result",
props: {
title: "工单分布统计",
charts: [
{ chart_type: "pie", title: "状态分布", data: statusChart },
{ chart_type: "bar", title: "优先级分布", data: priorityChart },
],
charts,
sourceType: "ticket_system",
confidence: "high",
artifact_id: `${artifactId}_chart`,
sort_key: sortKey + 1,
source: "ticket_system" as const,
execution_summary: `状态分布:${statusChart.map((c) => `${c.name}(${c.value})`).join("、")}`,
},
},
{ message: lastAiMessage },
@@ -269,6 +565,9 @@ export async function toolExecutorNode(
if (tickets.length === 0) {
statusList.push({ tool: name, status: "empty", message: "未查询到工单" });
const tlEmptyEntry: ExecutionLogEntry = { tool: name, status: "partial_success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`page: ${parsed.page ?? 1}`), durationMs: Date.now() - startTime, resultCount: 0 };
executionLog.push(tlEmptyEntry);
logToolCall(tlEmptyEntry);
return {
role: "tool" as const,
tool_call_id: id,
@@ -280,6 +579,9 @@ export async function toolExecutorNode(
};
}
statusList.push({ tool: name, status: "ok" });
const tlOkEntry: ExecutionLogEntry = { tool: name, status: "success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`page: ${parsed.page ?? 1}`), durationMs: Date.now() - startTime, resultCount: tickets.length };
executionLog.push(tlOkEntry);
logToolCall(tlOkEntry);
return {
role: "tool" as const,
tool_call_id: id,
@@ -289,7 +591,11 @@ export async function toolExecutorNode(
case "ticket_detail": {
const parsed = ticketDetailSchema.parse(args);
const t = await executeWithRetry(() => ticketDetail(parsed.ticket_id));
const t = await withTimeout(
() => executeWithRetry(() => ticketDetail(parsed.ticket_id), 2),
TOOL_TIMEOUT_MS,
);
const execSummary = `获取工单 ${String(t.ticketNumber ?? parsed.ticket_id)} 详情`;
ui.push(
{
name: "ticket-detail",
@@ -309,15 +615,78 @@ export async function toolExecutorNode(
description: String(t.description ?? "").slice(0, 500),
sourceType: "ticket_system",
confidence: "high",
artifact_id: artifactId,
sort_key: sortKey,
source: "ticket_system" as const,
execution_summary: execSummary,
},
},
{ message: lastAiMessage },
);
statusList.push({ tool: name, status: "ok" });
const tdOkEntry: ExecutionLogEntry = { tool: name, status: "success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`ticket_id: ${parsed.ticket_id}`), durationMs: Date.now() - startTime, resultCount: 1 };
executionLog.push(tdOkEntry);
logToolCall(tdOkEntry);
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify(t),
content: JSON.stringify({
ok: true,
tool: name,
summary: `工单 ${String(t.ticketNumber ?? parsed.ticket_id)}: ${String(t.description ?? "").slice(0, 100)}`,
data: {
id: String(t.ticketNumber ?? parsed.ticket_id),
title: String(t.description ?? "").slice(0, 80),
status: String(t.status ?? ""),
priority: String(t.priority ?? ""),
customer: String((t.customer as { name?: string })?.name ?? ""),
engineer: String((t.assignedEngineer as { username?: string })?.username ?? "未分配"),
created: String(t.createdAt ?? "").slice(0, 10),
},
}),
};
}
case "chart_generate": {
const parsed = chartGenerateSchema.parse(args);
ui.push(
{
name: "chart-result",
props: {
title: parsed.title,
charts: [
{
chart_type: parsed.chart_type,
title: parsed.title,
data: parsed.data,
},
],
sourceType: "generated_doc",
confidence: "high",
artifact_id: artifactId,
sort_key: sortKey,
source: "generated" as const,
execution_summary: `生成${parsed.chart_type}图表:${parsed.title}(${parsed.data.length}项数据)`,
},
},
{ message: lastAiMessage },
);
statusList.push({ tool: name, status: "ok" });
const chartLogEntry: ExecutionLogEntry = {
tool: name,
status: "success",
summary: `图表已生成:${parsed.title}`,
timestamp: Date.now(),
inputSummary: truncateInput(`title: ${parsed.title}, type: ${parsed.chart_type}`),
durationMs: Date.now() - startTime,
resultCount: 1,
};
executionLog.push(chartLogEntry);
logToolCall(chartLogEntry);
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({ status: "图表已生成", title: parsed.title }),
};
}
@@ -329,13 +698,116 @@ export async function toolExecutorNode(
};
}
} catch (e) {
statusList.push({ tool: name, status: "error", message: formatToolError(name, e) });
const friendlyError = formatToolError(name, e);
const suggestion = errorSuggestion(name, e);
statusList.push({ tool: name, status: "error", message: friendlyError });
const argsStr = typeof args === "object" ? JSON.stringify(args) : String(args);
const errLogEntry: ExecutionLogEntry = {
tool: name,
status: "error",
summary: friendlyError,
timestamp: Date.now(),
inputSummary: truncateInput(argsStr),
durationMs: Date.now() - startTime,
resultCount: 0,
errorMessage: friendlyError,
};
executionLog.push(errLogEntry);
logToolCall(errLogEntry);
// ticket_detail 404 → fallback to ticket_list (search same customer's other tickets)
if (name === "ticket_detail" && suggestion === "check_input") {
try {
const fallbackData = await withTimeout(
() => executeWithRetry(() => ticketList(1), 2),
TOOL_TIMEOUT_MS,
);
const fallbackTickets = (fallbackData.tickets ?? [])
.slice(0, 5)
.map((t) => ({
id: t.ticketNumber,
title: t.description?.slice(0, 80) ?? "",
status: t.status,
priority: t.priority,
customer: t.customer?.name ?? "",
created: t.createdAt?.slice(0, 10) ?? "",
}));
if (fallbackTickets.length > 0) {
ui.push(
{
name: "error-result" as never,
props: {
tool: name,
message: "未找到该工单,已为您自动切换到备选方案",
suggestion: "check_input",
timestamp: Date.now(),
artifact_id: `${artifactId}_error`,
sort_key: sortKey,
} as never,
},
{ message: lastAiMessage },
);
ui.push(
{
name: "ticket-summary",
props: {
total: fallbackTickets.length,
tickets: fallbackTickets,
stats: {},
sourceType: "ticket_system",
confidence: "medium",
artifact_id: `${artifactId}_fallback`,
sort_key: sortKey + 1,
source: "ticket_system" as const,
execution_summary: `工单详情未找到,回退显示最近 ${fallbackTickets.length} 条工单`,
},
},
{ message: lastAiMessage },
);
statusList.push({
tool: name,
status: "fallback" as ToolExecStatus["status"],
message: "工单详情未找到,已回退到工单列表",
});
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({
error: formatToolError(name, e),
fallback_hint: "工具执行失败。请用中文向用户解释错误原因,并提供替代建议。",
error: "未找到该工单",
fallback: "已自动查询最近工单列表",
tickets: fallbackTickets,
}),
};
}
} catch {
// fallback also failed, continue to show error
}
}
// Push error artifact card with suggestion
ui.push(
{
name: "error-result" as never,
props: {
tool: name,
message: friendlyError,
suggestion,
timestamp: Date.now(),
artifact_id: artifactId,
sort_key: sortKey,
} as never,
},
{ message: lastAiMessage },
);
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({
ok: false,
tool: name,
summary: friendlyError,
error: friendlyError,
fallback: suggestion === "retry" ? "可重试" : suggestion === "check_input" ? "请检查输入" : "请联系管理员",
}),
};
}
@@ -346,7 +818,7 @@ export async function toolExecutorNode(
// Push next-actions card based on successful tool results
const successfulTools = statusList.filter(
(s) => s.status === "ok" || s.status === "empty",
(s) => s.status === "ok" || s.status === "empty" || s.status === "fallback_success",
);
if (successfulTools.length > 0) {
const actions = generateNextActions(successfulTools.map((s) => s.tool));
@@ -366,5 +838,6 @@ export async function toolExecutorNode(
ui: ui.items,
timestamp: Date.now(),
toolStatus: statusList,
execution_log: executionLog,
};
}
@@ -1,8 +1,12 @@
/**
* SOC Enterprise external service clients.
* Each function calls an external API directly (no Python intermediate layer).
*
* Env validation is handled centrally by @/agent/utils/config.
*/
import { config } from "@/agent/utils/config";
// --- Knowledge Base Search ---
export async function kbSearch(query: string): Promise<{
results: Array<{
@@ -12,12 +16,12 @@ export async function kbSearch(query: string): Promise<{
score: number;
}>;
}> {
const url = `${process.env.KB_AGENT_URL}${process.env.KB_AGENT_SEARCH_PATH ?? "/api/v1/search"}`;
const url = `${config.kb.url}${config.kb.searchPath}`;
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"api-key": process.env.KB_AGENT_API_KEY ?? "",
"api-key": config.kb.apiKey,
},
body: JSON.stringify({ query, top: 5, search_mode: "hybrid" }),
signal: AbortSignal.timeout(45000),
@@ -40,9 +44,9 @@ export async function ticketList(
createdAt: string;
}>;
}> {
const url = `${process.env.GONGDAN_API_BASE}/api/tickets?page=${page}&pageSize=${pageSize}`;
const url = `${config.gongdan.apiBase}/api/tickets?page=${page}&pageSize=${pageSize}`;
const resp = await fetch(url, {
headers: { "X-Api-Key": process.env.GONGDAN_API_KEY ?? "" },
headers: { "X-Api-Key": config.gongdan.apiKey },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) throw new Error(`Ticket list failed: ${resp.status}`);
@@ -53,32 +57,72 @@ export async function ticketList(
export async function ticketDetail(
ticketId: string,
): Promise<Record<string, unknown>> {
const base = process.env.GONGDAN_API_BASE;
const headers = { "X-Api-Key": process.env.GONGDAN_API_KEY ?? "" };
const base = config.gongdan.apiBase;
const headers = { "X-Api-Key": config.gongdan.apiKey };
// If ticketId looks like a ticketNumber (e.g. "TK-2026-296691"), resolve UUID first
// If ticketId looks like a ticketNumber (e.g. "TK-2026-296691"), resolve UUID first.
// Strategy: try query-param filter first; if no match, fall back to full list + local find.
let resolvedId = ticketId;
if (ticketId.startsWith("TK-")) {
const searchUrl = `${base}/api/tickets?ticketNumber=${encodeURIComponent(ticketId)}&pageSize=50`;
// Attempt 1: filter via query param (try both "ticketNumber" and "search" keys)
for (const paramName of ["ticketNumber", "search"]) {
const searchUrl = `${base}/api/tickets?${paramName}=${encodeURIComponent(ticketId)}&pageSize=50`;
const searchResp = await fetch(searchUrl, {
headers,
signal: AbortSignal.timeout(15000),
});
if (searchResp.ok) {
const searchData = await searchResp.json();
const tickets = searchData.tickets ?? [];
const match = tickets.find((t: Record<string, unknown>) => t.ticketNumber === ticketId);
if (match?.id) {
resolvedId = String(match.id);
// API may return tickets under "tickets", "data", or "items" key
const tickets: Record<string, unknown>[] =
searchData.tickets ?? searchData.data ?? searchData.items ?? [];
const match = tickets.find((t) => t.ticketNumber === ticketId);
// API may use "id" or "_id" as the primary key
const matchId = match?.id ?? match?._id;
if (matchId) {
resolvedId = String(matchId);
break;
}
}
}
const url = `${base}/api/tickets/${resolvedId}`;
const resp = await fetch(url, {
// Attempt 2: if still unresolved, do a plain list and find locally
if (resolvedId === ticketId) {
const listUrl = `${base}/api/tickets?pageSize=100`;
const listResp = await fetch(listUrl, {
headers,
signal: AbortSignal.timeout(15000),
});
if (listResp.ok) {
const listData = await listResp.json();
const allTickets: Record<string, unknown>[] =
listData.tickets ?? listData.data ?? listData.items ?? [];
const match = allTickets.find((t) => t.ticketNumber === ticketId);
const matchId = match?.id ?? match?._id;
if (matchId) {
resolvedId = String(matchId);
}
}
}
}
// Try detail endpoint first, fall back to base tickets endpoint
// Some APIs use /api/tickets/detail/{id}, others use /api/tickets/{id}
let url = `${base}/api/tickets/detail/${resolvedId}`;
let resp = await fetch(url, {
headers,
signal: AbortSignal.timeout(15000),
});
// If /detail/ returns 404, fall back to /api/tickets/{id}
if (resp.status === 404) {
url = `${base}/api/tickets/${resolvedId}`;
resp = await fetch(url, {
headers,
signal: AbortSignal.timeout(15000),
});
}
if (!resp.ok) throw new Error(`Ticket detail failed: ${resp.status}`);
return resp.json();
}
@@ -93,7 +137,7 @@ export async function webSearch(query: string): Promise<{
}>;
}> {
const headers: Record<string, string> = {
Authorization: `Bearer ${process.env.JINA_API_KEY}`,
Authorization: `Bearer ${config.jina.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
};
@@ -152,7 +196,7 @@ export async function googleSearch(query: string): Promise<{
const resp = await fetch("https://google.serper.dev/search", {
method: "POST",
headers: {
"X-API-KEY": process.env.SERPER_API_KEY ?? "",
"X-API-KEY": config.serper.apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({ q: query, num: 10, gl: "cn", hl: "zh-cn" }),
@@ -182,7 +226,7 @@ export async function googleSearch(query: string): Promise<{
export async function webRead(url: string): Promise<{ content: string; title: string }> {
const resp = await fetch(`https://r.jina.ai/${url}`, {
headers: {
Authorization: `Bearer ${process.env.JINA_API_KEY}`,
Authorization: `Bearer ${config.jina.apiKey}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(10000),
@@ -199,7 +243,7 @@ export async function jinaRerank(query: string, documents: string[], topN = 5):
const resp = await fetch("https://api.jina.ai/v1/rerank", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.JINA_API_KEY}`,
Authorization: `Bearer ${config.jina.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
@@ -215,58 +259,119 @@ export async function jinaRerank(query: string, documents: string[], topN = 5):
}
// --- Daytona Sandbox Execution ---
/** Maximum allowed code length (characters) */
const SANDBOX_MAX_CODE_LENGTH = 10_000;
/** Maximum output length (characters) before truncation */
const SANDBOX_MAX_OUTPUT_LENGTH = 3_000;
/** Default code execution timeout (ms) */
const SANDBOX_DEFAULT_EXEC_TIMEOUT_MS = 15_000;
export async function sandboxRun(
code: string,
language = "python",
timeoutMs = SANDBOX_DEFAULT_EXEC_TIMEOUT_MS,
): Promise<{
exit_code: number;
stdout: string;
duration_ms: number;
}> {
const apiUrl = process.env.DAYTONA_API_URL ?? "https://app.daytona.io/api";
const apiKey = process.env.DAYTONA_API_KEY ?? "";
// --- Guard: code length ---
if (code.length > SANDBOX_MAX_CODE_LENGTH) {
throw new Error(`代码长度超过限制(${SANDBOX_MAX_CODE_LENGTH} 字符)`);
}
const apiUrl = config.daytona.apiUrl;
const apiKey = config.daytona.apiKey;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
// Create sandbox (Daytona v1 API uses /sandbox, not /workspace)
const createResp = await fetch(`${apiUrl}/sandbox`, {
let createResp: Response;
try {
createResp = await fetch(`${apiUrl}/sandbox`, {
method: "POST",
headers,
body: JSON.stringify({ autoStopInterval: 5, autoDeleteInterval: 0 }),
signal: AbortSignal.timeout(30000),
});
if (!createResp.ok)
throw new Error(`Daytona create failed: ${createResp.status}`);
} catch (e) {
const isTimeout = e instanceof DOMException && e.name === "AbortError";
if (isTimeout) {
return { exit_code: 124, stdout: "沙盒创建超时(超过 30 秒)", duration_ms: 30000 };
}
// Network error (fetch failed / TypeError)
return { exit_code: -1, stdout: "网络错误,无法连接沙盒服务", duration_ms: 0 };
}
if (!createResp.ok) {
return {
exit_code: -1,
stdout: `沙盒创建失败(HTTP ${createResp.status}),请稍后重试`,
duration_ms: 0,
};
}
const sandbox: { id: string } = await createResp.json();
const sbId = sandbox.id;
const t0 = Date.now();
try {
// Execute code via Daytona Toolbox proxy (process/execute endpoint)
const cmd =
language === "python"
? `python3 -c '${code.replace(/'/g, "'\\''")}'`
: language === "javascript"
? `node -e '${code.replace(/'/g, "'\\''")}'`
: code;
const execResp = await fetch(
const SUPPORTED_LANGUAGES = ["python", "javascript", "bash"] as const;
type SupportedLang = typeof SUPPORTED_LANGUAGES[number];
if (!(SUPPORTED_LANGUAGES as readonly string[]).includes(language)) {
throw new Error(`Unsupported language: ${language}. Supported: ${SUPPORTED_LANGUAGES.join(", ")}`);
}
const escaped = code.replace(/'/g, "'\\''");
const cmd: Record<SupportedLang, string> = {
python: `python3 -c '${escaped}'`,
javascript: `node -e '${escaped}'`,
bash: `bash -c '${escaped}'`,
}[language as SupportedLang];
let execResp: Response;
try {
execResp = await fetch(
`https://proxy.app.daytona.io/toolbox/${sbId}/process/execute`,
{
method: "POST",
headers,
body: JSON.stringify({ command: cmd }),
signal: AbortSignal.timeout(30000),
signal: AbortSignal.timeout(timeoutMs),
},
);
} catch (e) {
const duration_ms = Date.now() - t0;
const isTimeout = e instanceof DOMException && e.name === "AbortError";
if (isTimeout) {
return { exit_code: 124, stdout: `执行超时(超过 ${Math.round(timeoutMs / 1000)} 秒)`, duration_ms };
}
// Network error during execution
return { exit_code: -1, stdout: "网络错误,无法连接沙盒服务", duration_ms };
}
const execData: { exitCode?: number; result?: string } =
execResp.ok
? await execResp.json()
: { exitCode: 1, result: "Exec failed" };
const exitCode = execData.exitCode ?? 0;
let stdout = String(execData.result ?? "");
// Truncate output
if (stdout.length > SANDBOX_MAX_OUTPUT_LENGTH) {
stdout = stdout.slice(0, SANDBOX_MAX_OUTPUT_LENGTH) + "\n[输出已截断,超过 3000 字符]";
}
// Prefix failure info when exit code is non-zero
if (exitCode !== 0) {
stdout = `[执行失败,exit code: ${exitCode}]\n${stdout}`;
}
return {
exit_code: execData.exitCode ?? 0,
stdout: String(execData.result ?? "").slice(0, 2000),
exit_code: exitCode,
stdout,
duration_ms: Date.now() - t0,
};
} finally {
+48
View File
@@ -1,11 +1,59 @@
import { Annotation } from "@langchain/langgraph";
import { GenerativeUIAnnotation } from "../types.js";
/**
* Canonical tool execution status.
* - success: tool completed and returned usable results
* - partial_success: tool completed without error but results are empty/incomplete
* - fallback_success: primary tool failed, fallback tool succeeded
* - error: tool failed completely
*/
export type ToolStatus =
| "success"
| "partial_success"
| "fallback_success"
| "error";
/**
* A single entry in the execution log visible to the frontend via
* `values.execution_log`. Appended by tool-executor after each tool run.
*/
export type ExecutionLogEntry = {
tool: string;
status: ToolStatus;
timestamp: number;
durationMs: number;
inputSummary: string;
/** Number of result items returned (documents, tickets, charts, etc.) */
resultCount?: number;
/** Machine-readable error code for monitoring (e.g. "TIMEOUT", "NETWORK", "AUTH") */
errorCode?: string;
/** Human-readable error explanation (separated from summary which is for LLM) */
errorMessage?: string;
/** Natural-language summary for LLM consumption */
summary: string;
};
function executionLogReducer(
current: ExecutionLogEntry[],
update: ExecutionLogEntry | ExecutionLogEntry[],
): ExecutionLogEntry[] {
const items = Array.isArray(update) ? update : [update];
return [...current, ...items];
}
export const EnterpriseAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui,
timestamp: GenerativeUIAnnotation.spec.timestamp,
toolStatus: GenerativeUIAnnotation.spec.toolStatus,
execution_log: Annotation<
ExecutionLogEntry[],
ExecutionLogEntry | ExecutionLogEntry[]
>({
default: () => [],
reducer: executionLogReducer,
}),
});
export type EnterpriseState = typeof EnterpriseAnnotation.State;
@@ -243,11 +243,12 @@ export async function toolExecutorNode(
};
}
} catch (e) {
statusList.push({ tool: name, status: "error", message: formatToolError(name, e) });
const errMsg = formatToolError(name, e);
statusList.push({ tool: name, status: "error", message: errMsg });
return {
role: "tool" as const,
tool_call_id: id,
content: formatToolError(name, e),
content: JSON.stringify({ ok: false, tool: name, summary: errMsg, error: errMsg }),
};
}
});
+1
View File
@@ -1,3 +1,4 @@
import "@/agent/utils/config";
import { StateGraph, START, END } from "@langchain/langgraph";
import {
SupervisorAnnotation,
+27 -1
View File
@@ -5,6 +5,25 @@ import { formatMessages } from "@/agent/utils/format-messages";
import { createLlm } from "@/agent/utils/create-llm";
import { truncateMessages } from "@/agent/utils/truncate-messages";
/** Rule-based pre-check for obvious intents — saves an LLM call */
function preCheckRoute(text: string): "enterprise" | "generalInput" | null {
const t = text.trim();
const ENTERPRISE = [
/tk-\d{4,}/i,
/工单\s*(列表|详情|查询|状态)/,
/知识库\s*(搜索|查询|查找)/,
/内部知识|公司规范|内部系统/,
];
const GENERAL = [
/^(你好|hi|hello|在吗|嗨|您好)[!!。.??]*$/i,
/^(谢谢|感谢|好的|明白|收到|ok|好)[!!。.??]*$/i,
/^你(是谁|能做什么|有什么功能|叫什么)[??]?$/,
];
for (const re of ENTERPRISE) if (re.test(t)) return "enterprise";
for (const re of GENERAL) if (re.test(t)) return "generalInput";
return null;
}
export async function router(
state: SupervisorState,
): Promise<Partial<SupervisorUpdate>> {
@@ -70,8 +89,15 @@ ${ALL_TOOL_DESCRIPTIONS}
"你能做什么" → generalInput`;
const truncated = truncateMessages(state.messages);
// Fast-path: rule-based routing for obvious intents
const lastMsg = truncated.at(-1);
const lastText = typeof lastMsg?.content === "string" ? lastMsg.content : "";
const preChecked = preCheckRoute(lastText);
if (preChecked !== null) return { next: preChecked };
const allMessagesButLast = truncated.slice(0, -1);
const lastMessage = truncated.at(-1);
const lastMessage = lastMsg;
const formattedPreviousMessages = formatMessages(allMessagesButLast);
const formattedLastMessage = lastMessage ? formatMessages([lastMessage]) : "";
+13 -67
View File
@@ -11,51 +11,9 @@ export type SupervisorState = typeof SupervisorAnnotation.State;
export type SupervisorUpdate = typeof SupervisorAnnotation.Update;
export const SupervisorZodConfiguration = z.object({
/**
* The model ID to use for the reflection generation.
* Should be in the format `provider/model_name`.
* Defaults to `anthropic/claude-3-7-sonnet-latest`.
*/
model: z
.string()
.optional()
.langgraph.metadata({
type: "select",
default: "anthropic/claude-3-7-sonnet-latest",
description: "The model to use in all generations",
options: [
{
label: "Claude 3.7 Sonnet",
value: "anthropic/claude-3-7-sonnet-latest",
},
{
label: "Claude 3.5 Sonnet",
value: "anthropic/claude-3-5-sonnet-latest",
},
{
label: "GPT 4o",
value: "openai/gpt-4o",
},
{
label: "GPT 4.1",
value: "openai/gpt-4.1",
},
{
label: "o3",
value: "openai/o3",
},
{
label: "o3 mini",
value: "openai/o3-mini",
},
{
label: "o4",
value: "openai/o4",
},
],
}),
/**
* Model mode preset: flash (fast), pro (detailed), auto (balanced).
* Controls which LLM is used in enterprise/coder/searcher/writer agents.
*/
modelMode: z
.enum(["flash", "pro", "auto"])
@@ -84,33 +42,21 @@ export const SupervisorZodConfiguration = z.object({
{ label: "Knowledge Base", value: "kb_search" },
{ label: "Ticket List", value: "ticket_list" },
{ label: "Ticket Detail", value: "ticket_detail" },
{ label: "Chart Generate", value: "chart_generate" },
],
}),
/**
* The temperature to use for the reflection generation.
* Defaults to `0.7`.
* Task context for action-bar follow-up. When set, bypasses intent routing
* and routes directly to the relevant agent with card context injected.
*/
temperature: z.number().optional().langgraph.metadata({
type: "slider",
default: 0.7,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
}),
/**
* The maximum number of tokens to generate.
* Defaults to `1000`.
*/
maxTokens: z.number().optional().langgraph.metadata({
type: "number",
default: 1000,
min: 1,
description: "The maximum number of tokens to generate",
}),
systemPrompt: z.string().optional().langgraph.metadata({
type: "textarea",
placeholder: "Enter a system prompt...",
description: "The system prompt to use in all generations",
taskContext: z
.object({
sourceCardId: z.string(),
taskType: z.string(),
})
.optional()
.langgraph.metadata({
type: "object",
description: "Task context from action-bar follow-up (sourceCardId + taskType)",
}),
});
+1 -1
View File
@@ -11,7 +11,7 @@ import {
*/
export type ToolExecStatus = {
tool: string;
status: "ok" | "empty" | "error" | "fallback";
status: "ok" | "empty" | "error" | "fallback" | "partial_success" | "fallback_success";
message?: string;
};
+2 -6
View File
@@ -1,16 +1,12 @@
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
import { config } from "@/agent/utils/config";
let _checkpointer: PostgresSaver | undefined;
export async function getCheckpointer(): Promise<PostgresSaver> {
if (_checkpointer) return _checkpointer;
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
throw new Error("DATABASE_URL is required for persistent checkpointing");
}
_checkpointer = PostgresSaver.fromConnString(dbUrl);
_checkpointer = PostgresSaver.fromConnString(config.database.url);
await _checkpointer.setup();
return _checkpointer;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Centralized config + startup env validation.
* Import this module early (e.g., from supervisor/index.ts) to surface
* missing env vars at startup rather than at first call.
*/
/** Required for ALL deployments — server will not function without these */
const REQUIRED = [
"AZURE_OPENAI_API_KEY",
"AZURE_OPENAI_ENDPOINT",
"AZURE_OPENAI_API_VERSION",
"AZURE_OPENAI_DEPLOYMENT",
"GOOGLE_API_KEY",
"DATABASE_URL",
] as const;
/** Required only when the associated tool is called */
const REQUIRED_BY_TOOL: Record<string, readonly string[]> = {
kb_search: ["KB_AGENT_URL", "KB_AGENT_API_KEY"],
ticket: ["GONGDAN_API_BASE", "GONGDAN_API_KEY"],
web_search: ["JINA_API_KEY"],
google_search: ["SERPER_API_KEY"],
sandbox: ["DAYTONA_API_KEY", "DAYTONA_API_URL"],
};
function validate() {
const missing: string[] = [];
for (const key of REQUIRED) {
if (!process.env[key]) missing.push(key);
}
if (missing.length > 0) {
// Throw on critical missing vars — server should not start
throw new Error(
`[config] Missing required env vars: ${missing.join(", ")}. ` +
"Check your .env file or Azure Web App application settings.",
);
}
// Warn for tool-specific vars (not fatal — some tools may be intentionally disabled)
for (const [tool, keys] of Object.entries(REQUIRED_BY_TOOL)) {
const missingToolKeys = keys.filter((k) => !process.env[k]);
if (missingToolKeys.length > 0) {
console.warn(
`[config] Tool "${tool}" may not work: missing ${missingToolKeys.join(", ")}`,
);
}
}
}
// Run at import time
validate();
/** Typed accessors — safe to use after validation */
export const config = {
azureOpenAI: {
apiKey: process.env.AZURE_OPENAI_API_KEY!,
endpoint: process.env.AZURE_OPENAI_ENDPOINT!,
apiVersion: process.env.AZURE_OPENAI_API_VERSION!,
deployment: process.env.AZURE_OPENAI_DEPLOYMENT!,
},
google: {
apiKey: process.env.GOOGLE_API_KEY!,
},
database: {
url: process.env.DATABASE_URL!,
},
kb: {
url: process.env.KB_AGENT_URL ?? "",
apiKey: process.env.KB_AGENT_API_KEY ?? "",
searchPath: process.env.KB_AGENT_SEARCH_PATH ?? "/api/v1/search",
},
gongdan: {
apiBase: process.env.GONGDAN_API_BASE ?? "",
apiKey: process.env.GONGDAN_API_KEY ?? "",
},
jina: {
apiKey: process.env.JINA_API_KEY ?? "",
},
serper: {
apiKey: process.env.SERPER_API_KEY ?? "",
},
daytona: {
apiKey: process.env.DAYTONA_API_KEY ?? "",
apiUrl: process.env.DAYTONA_API_URL ?? "https://app.daytona.io/api",
},
} as const;
+5 -6
View File
@@ -1,4 +1,5 @@
import { AzureChatOpenAI } from "@langchain/openai";
import { config } from "@/agent/utils/config";
export type ModelMode = "flash" | "pro" | "auto";
@@ -30,12 +31,10 @@ export function createLlm(options?: {
const maxTokens = options?.maxTokens ?? preset.maxTokens;
return new AzureChatOpenAI({
azureOpenAIApiKey: process.env.AZURE_OPENAI_API_KEY,
azureOpenAIEndpoint: process.env.AZURE_OPENAI_ENDPOINT,
azureOpenAIApiDeploymentName:
process.env.AZURE_OPENAI_DEPLOYMENT ?? "gpt-5.4",
azureOpenAIApiVersion:
process.env.AZURE_OPENAI_API_VERSION ?? "2025-04-01-preview",
azureOpenAIApiKey: config.azureOpenAI.apiKey,
azureOpenAIEndpoint: config.azureOpenAI.endpoint,
azureOpenAIApiDeploymentName: config.azureOpenAI.deployment,
azureOpenAIApiVersion: config.azureOpenAI.apiVersion,
temperature,
modelKwargs: { max_completion_tokens: maxTokens },
});
+1 -4
View File
@@ -1,7 +1,4 @@
import {
BlobServiceClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
import { BlobServiceClient } from "@azure/storage-blob";
import * as pdfParseModule from "pdf-parse";
const pdfParse = (pdfParseModule as any).default ?? pdfParseModule;
import * as XLSX from "xlsx";
+15 -1
View File
@@ -32,13 +32,15 @@ export async function executeWithRetry<T>(
/**
* Classify error type from raw error for context-aware messaging.
*/
function classifyError(error: unknown): "timeout" | "not_found" | "bad_request" | "generic" {
function classifyError(error: unknown): "timeout" | "not_found" | "bad_request" | "auth" | "server" | "generic" {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("TimeoutError") || msg.includes("abort") || msg.includes("timeout")) {
return "timeout";
}
if (msg.includes("401") || msg.includes("403")) return "auth";
if (msg.includes("404")) return "not_found";
if (msg.includes("400")) return "bad_request";
if (msg.includes("500") || msg.includes("502") || msg.includes("503")) return "server";
return "generic";
}
@@ -58,28 +60,39 @@ const TOOL_FALLBACK_HINTS: Record<string, string> = {
const TOOL_ERROR_MAP: Record<string, Partial<Record<ReturnType<typeof classifyError>, string>> & { generic: string }> = {
kb_search: {
timeout: "知识库检索服务暂时响应较慢,请稍后再试",
auth: "知识库权限验证失败,请联系管理员",
server: "知识库服务暂时不可用",
generic: "知识库检索服务暂时不可用,请稍后再试",
},
ticket_list: {
timeout: "工单系统响应较慢,请稍后再试",
auth: "工单系统权限验证失败,请联系管理员",
server: "工单系统服务暂时不可用",
generic: "工单列表查询失败,请稍后再试",
},
ticket_detail: {
timeout: "工单系统响应较慢,请稍后再试",
not_found: "未找到该工单,请确认工单编号后重试",
auth: "工单系统权限验证失败,请联系管理员",
server: "工单系统服务暂时不可用",
generic: "工单详情查询失败,请检查工单编号后重试",
},
google_search: {
auth: "搜索服务权限验证失败,请联系管理员",
server: "搜索服务暂时不可用",
generic: "搜索服务暂时不可用,请稍后再试",
},
web_search_deep: {
server: "深度搜索服务暂时不可用",
generic: "深度搜索暂时不可用,已尝试自动重试",
},
web_read: {
not_found: "该网页不存在或已被删除",
generic: "网页读取失败,该页面可能无法访问或已被删除",
},
code_execute: {
bad_request: "代码执行环境暂不可用,请稍后再试",
server: "代码执行服务暂时不可用",
generic: "代码执行环境暂不可用,请稍后再试",
},
code_install: {
@@ -87,6 +100,7 @@ const TOOL_ERROR_MAP: Record<string, Partial<Record<ReturnType<typeof classifyEr
},
sandbox_run: {
bad_request: "沙盒执行环境暂不可用,请稍后再试",
server: "沙盒服务暂时不可用",
generic: "沙盒执行环境暂不可用,请稍后再试",
},
doc_create: {
+23 -3
View File
@@ -1,14 +1,34 @@
import React from "react";
interface ActionBarProps {
sourceType?: string;
context?: string;
suggestedActions?: string[];
/** Human-readable card title shown as source label in the input bar */
cardTitle?: string;
/** Machine-readable task type forwarded in the event payload */
taskType?: string;
/** Stable card identifier forwarded in the event payload */
sourceCardId?: string;
}
export function ActionBar({ context = "", suggestedActions = [] }: ActionBarProps) {
export function ActionBar({
context = "",
suggestedActions = [],
cardTitle,
taskType,
sourceCardId,
}: ActionBarProps) {
const dispatch = (text: string) => {
window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text } }));
window.dispatchEvent(
new CustomEvent("soc:prefill-input", {
detail: {
text,
taskType,
sourceCardId,
sourceLabel: cardTitle,
},
}),
);
};
return (
+1 -1
View File
@@ -5,7 +5,7 @@ import {
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { useState } from "react";
import { cn } from "@/lib/utils";
import MessageBubble from "@/components/MessageBubble.tsx";
export interface CanvasDoc {
+90 -5
View File
@@ -7,16 +7,68 @@ import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { Copy, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
// Fixed source tag definitions — only these 4 exact patterns are replaced (XSS-safe, no arbitrary HTML)
const SOURCE_TAGS: { pattern: string; label: string; className: string }[] = [
{ pattern: "[知识库]", label: "知识库", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 mx-0.5" },
{ pattern: "[工单]", label: "工单", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 mx-0.5" },
{ pattern: "[网络]", label: "网络", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 mx-0.5" },
{ pattern: "[推断]", label: "推断", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-700 mx-0.5" },
];
// Splits a plain text string into React nodes, replacing:
// 1. Fixed source tags: [知识库] [工单] [网络] [推断] → colored badge spans
// 2. Citation numbers: [1] [2] [3] … → clickable blue chip buttons that dispatch soc:highlight-citation
function renderWithSourceBadges(text: string): ReactNode[] {
// Combined regex: named source tags OR citation numbers [N]
const sourceEscaped = SOURCE_TAGS.map((t) => t.pattern.replace(/[[\]]/g, "\\$&")).join("|");
// Citation pattern: [digits] only — must be a pure number to avoid colliding with markdown links
const combined = new RegExp(`(${sourceEscaped}|\\[\\d+\\])`, "g");
const parts = text.split(combined);
return parts.map((part, i) => {
// Fixed source tag?
const tag = SOURCE_TAGS.find((t) => t.pattern === part);
if (tag) {
return <span key={i} className={tag.className}>{tag.label}</span>;
}
// Citation chip? Match [N] exactly
const citMatch = /^\[(\d+)\]$/.exec(part);
if (citMatch) {
const num = Number(citMatch[1]);
return (
<button
key={i}
type="button"
onClick={() =>
window.dispatchEvent(
new CustomEvent("soc:highlight-citation", { detail: { index: num } }),
)
}
className="inline-flex items-center justify-center text-[10px] font-medium rounded px-1 py-0 min-w-[18px] bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800/50 transition-colors cursor-pointer mx-0.5 align-text-bottom"
title={`查看引用 ${num}`}
aria-label={`引用 ${num}`}
>
{num}
</button>
);
}
return part;
});
}
interface MessageBubbleProps {
content: string;
role: "human" | "ai";
}
const CODE_COLLAPSE_THRESHOLD = 20;
const CODE_PREVIEW_LINES = 5;
function CodeBlock({
language,
children,
@@ -25,6 +77,9 @@ function CodeBlock({
children: string;
}) {
const [copied, setCopied] = useState(false);
const lines = children.split("\n");
const isLongCode = lines.length > CODE_COLLAPSE_THRESHOLD;
const [codeExpanded, setCodeExpanded] = useState(!isLongCode);
const handleCopy = () => {
navigator.clipboard.writeText(children).then(() => {
@@ -38,6 +93,10 @@ function CodeBlock({
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark");
const displayedCode = codeExpanded
? children
: lines.slice(0, CODE_PREVIEW_LINES).join("\n");
return (
<div className="relative group my-2 rounded-lg overflow-hidden border border-border">
{/* Language label + copy button */}
@@ -69,8 +128,22 @@ function CodeBlock({
}}
PreTag="div"
>
{children}
{displayedCode}
</SyntaxHighlighter>
{isLongCode && (
<div className="border-t border-border bg-muted px-3 py-1.5 flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{codeExpanded ? `共 ${lines.length} 行` : `已折叠,共 ${lines.length} 行`}
</span>
<button
type="button"
onClick={() => setCodeExpanded((v) => !v)}
className="text-xs text-primary hover:underline"
>
{codeExpanded ? "折叠" : "展开全部"}
</button>
</div>
)}
</div>
);
}
@@ -82,7 +155,8 @@ function extractSummary(text: string): string {
export default function MessageBubble({ content }: MessageBubbleProps) {
const [summaryExpanded, setSummaryExpanded] = useState(false);
const isLong = content.length > 800;
const contentLines = content.split("\n").length;
const isLong = contentLines > 20;
const summary = isLong ? extractSummary(content) : null;
return (
@@ -190,9 +264,20 @@ export default function MessageBubble({ content }: MessageBubbleProps) {
);
},
// Paragraphs
// Paragraphs — inline source badges for [知识库] [工单] [网络] [推断]
p({ children }) {
return <p className="my-1 leading-relaxed text-foreground">{children}</p>;
const processedChildren = Array.isArray(children)
? children.flatMap((child, idx) =>
typeof child === "string"
? renderWithSourceBadges(child).map((node, ni) =>
typeof node === "string" ? node : <span key={`${idx}-${ni}`}>{node}</span>
)
: [child]
)
: typeof children === "string"
? renderWithSourceBadges(children)
: children;
return <p className="my-1 leading-relaxed text-foreground">{processedChildren}</p>;
},
// Links
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
const SOURCE_CONFIG: Record<string, { label: string; className: string }> = {
internal_kb: { label: "内部知识库", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" },
+91 -8
View File
@@ -1,5 +1,5 @@
import { Plus, MessageSquare, Trash2, Search } from "lucide-react";
import { useState } from "react";
import { useState, useRef } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -17,13 +17,39 @@ type Props = {
onDeleteThread: (threadId: string) => void;
};
// Get lastActive timestamp from localStorage, fallback to created_at
function getLastActive(threadId: string, createdAt: string): number {
try {
const stored = localStorage.getItem(`lastActive_${threadId}`);
if (stored) return parseInt(stored, 10);
} catch {
// ignore
}
return new Date(createdAt).getTime();
}
// Get thread title from localStorage (saved by main.tsx on first message)
function getLocalTitle(threadId: string): string | null {
try {
return localStorage.getItem(`title_${threadId}`);
} catch {
return null;
}
}
function groupByDate(threads: ThreadItem[]): { label: string; threads: ThreadItem[] }[] {
const now = new Date();
const groups: Record<string, ThreadItem[]> = {};
threads.forEach((t) => {
const d = new Date(t.created_at);
const diffDays = (now.getTime() - d.getTime()) / 86400000;
// Sort by lastActive descending before grouping
const sorted = [...threads].sort(
(a, b) =>
getLastActive(b.thread_id, b.created_at) - getLastActive(a.thread_id, a.created_at),
);
sorted.forEach((t) => {
const lastActive = getLastActive(t.thread_id, t.created_at);
const diffDays = (now.getTime() - lastActive) / 86400000;
let label: string;
if (diffDays < 1) label = "今天";
else if (diffDays < 2) label = "昨天";
@@ -39,6 +65,14 @@ function groupByDate(threads: ThreadItem[]): { label: string; threads: ThreadIte
.map((label) => ({ label, threads: groups[label] }));
}
export function updateThreadLastActive(threadId: string) {
try {
localStorage.setItem(`lastActive_${threadId}`, String(Date.now()));
} catch {
// ignore
}
}
function formatTime(iso: string) {
try {
const d = new Date(iso);
@@ -62,9 +96,12 @@ export function ThreadSidebar({
onDeleteThread,
}: Props) {
const [searchQuery, setSearchQuery] = useState("");
const [searchExpanded, setSearchExpanded] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const filtered = threads.filter((t) => {
const label =
getLocalTitle(t.thread_id) ??
(t.metadata?.title as string) ??
(t.metadata?.firstMessage as string) ??
t.thread_id;
@@ -77,28 +114,63 @@ export function ThreadSidebar({
<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">
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2"
className="flex-1 justify-start gap-2"
onClick={onNewThread}
>
<Plus className="size-4" />
新建对话
</Button>
{/* Search toggle icon */}
<button
type="button"
title="搜索对话"
onClick={() => {
setSearchExpanded((v) => {
if (!v) setTimeout(() => searchInputRef.current?.focus(), 50);
else setSearchQuery("");
return !v;
});
}}
className={cn(
"p-1.5 rounded-md transition-colors",
searchExpanded
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-accent",
)}
>
<Search className="size-3.5" />
</button>
</div>
{/* Search input */}
{/* Collapsible search input */}
<div
className={cn(
"overflow-hidden transition-all duration-200",
searchExpanded ? "max-h-10 opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={() => {
if (!searchQuery) {
setSearchExpanded(false);
}
}}
placeholder="搜索对话..."
className="w-full text-xs border border-border rounded-md pl-8 pr-3 py-1.5 bg-background/50 text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
</div>
{/* Thread list */}
<div className="flex-1 overflow-y-auto py-2">
@@ -118,9 +190,13 @@ export function ThreadSidebar({
{groupThreads.map((t) => {
const isActive = t.thread_id === currentThreadId;
const itemLabel =
getLocalTitle(t.thread_id) ??
(t.metadata?.title as string) ??
(t.metadata?.firstMessage as string) ??
t.thread_id.slice(0, 8) + "…";
"新对话";
const displayLabel = itemLabel.length > 16
? itemLabel.slice(0, 16) + "…"
: itemLabel;
return (
<div
key={t.thread_id}
@@ -134,7 +210,12 @@ export function ThreadSidebar({
>
<MessageSquare className="size-4 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{itemLabel}</p>
<p
className="text-xs font-medium truncate"
title={itemLabel.length > 16 ? itemLabel : undefined}
>
{displayLabel}
</p>
<p className="text-[10px] text-muted-foreground">
{formatTime(t.created_at)}
</p>
@@ -143,7 +224,9 @@ export function ThreadSidebar({
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
if (window.confirm("确定要删除这个对话吗?")) {
onDeleteThread(t.thread_id);
}
}}
title="删除对话"
>
+61 -14
View File
@@ -1,5 +1,5 @@
import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle } from "lucide-react";
import { useState } from "react";
import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle, ChevronsUpDown } from "lucide-react";
import { useState, useCallback } from "react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
const TOOL_NAME_MAP: Record<string, string> = {
@@ -30,6 +30,7 @@ const TOOL_LOADING_MAP: Record<string, string> = {
code_execute: "正在执行代码...",
code_install: "正在安装依赖...",
sandbox_run: "正在运行沙盒...",
chart_generate: "正在生成图表...",
doc_create: "正在创建文档...",
doc_edit: "正在编辑文档...",
doc_translate: "正在翻译文档...",
@@ -58,6 +59,17 @@ interface ToolCallStatusProps {
components?: Parameters<typeof LoadExternalComponent>[0]["components"];
}
interface ToolCallRowProps {
tc: ToolCall;
isDone: boolean;
isFailed: boolean;
uiItem?: UIMsgLocal;
stream?: ToolCallStatusProps["stream"];
components?: ToolCallStatusProps["components"];
// When globalExpanded is not undefined, the parent controls expand state
globalExpanded?: boolean;
}
function ToolCallRow({
tc,
isDone,
@@ -65,17 +77,18 @@ function ToolCallRow({
uiItem,
stream,
components,
}: {
tc: ToolCall;
isDone: boolean;
isFailed: boolean;
uiItem?: UIMsgLocal;
stream?: ToolCallStatusProps["stream"];
components?: ToolCallStatusProps["components"];
}) {
const [expanded, setExpanded] = useState(false);
globalExpanded,
}: ToolCallRowProps) {
// Local state used only when globalExpanded is undefined (single tool or no global toggle)
const [localExpanded, setLocalExpanded] = useState(isFailed);
const expanded = globalExpanded !== undefined ? globalExpanded : localExpanded;
const setExpanded = useCallback((val: boolean | ((v: boolean) => boolean)) => {
if (globalExpanded === undefined) {
setLocalExpanded(val);
}
}, [globalExpanded]);
const label = tc.name ? (TOOL_NAME_MAP[tc.name] ?? tc.name) : "工具调用";
const canExpand = isDone && !!uiItem;
const canExpand = isDone && (!!uiItem || isFailed);
return (
<div>
@@ -103,7 +116,14 @@ function ToolCallRow({
)}
<span>{isFailed ? `${label} · 失败` : isDone ? (canExpand ? `${label} · ${expanded ? "收起" : "查看结果"}` : `${label} · 已完成`) : (tc.name ? (TOOL_LOADING_MAP[tc.name] ?? `正在${label}...`) : "思考中...")}</span>
</button>
{expanded && uiItem && stream && components && (
{expanded && isFailed && (
<div className="mt-2 ml-7 rounded-md border border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-900 px-3 py-2 animate-in fade-in duration-300">
<p className="text-xs text-red-600 dark:text-red-400">
{(uiItem?.props?.errorMessage as string) ?? "工具执行失败,请稍后重试。"}
</p>
</div>
)}
{expanded && !isFailed && uiItem && stream && components && (
<div className="mt-2 ml-7">
<div className="animate-in fade-in duration-300">
<LoadExternalComponent
@@ -114,7 +134,7 @@ function ToolCallRow({
</div>
</div>
)}
{expanded && !uiItem && (
{expanded && !isFailed && !uiItem && (
<div className="mt-2 ml-7 space-y-2 animate-pulse">
<div className="h-3 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-1/2" />
@@ -137,6 +157,9 @@ export default function ToolCallStatus({
}: ToolCallStatusProps) {
if (!toolCalls.length) return null;
// Global expand/collapse state — only active when there are >= 2 tool calls
const [globalExpanded, setGlobalExpanded] = useState<boolean | undefined>(undefined);
const UI_NAME_MAP: Record<string, string> = {
kb_search: "knowledge-result",
ticket_list: "ticket-summary",
@@ -150,8 +173,31 @@ export default function ToolCallStatus({
// calls of the same tool type pick different UI cards in order.
const matchCounters: Record<string, number> = {};
const showGlobalToggle = toolCalls.length >= 2;
function handleGlobalToggle() {
setGlobalExpanded((prev) => {
// If currently collapsed (false) → expand all; otherwise → collapse all
return prev === false ? true : false;
});
}
return (
<div className="flex flex-col gap-1.5 mb-1">
{/* Global collapse/expand button — only shown when >= 2 tool calls */}
{showGlobalToggle && (
<div className="flex justify-end">
<button
type="button"
onClick={handleGlobalToggle}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
title={globalExpanded === false ? "展开全部" : "折叠全部"}
>
<ChevronsUpDown className="size-3 shrink-0" />
{globalExpanded === false ? "展开全部" : "折叠全部"}
</button>
</div>
)}
{toolCalls.map((tc, i) => {
const isDone = !isLoading || (tc.id && completedToolIds?.has(tc.id));
const isFailed = !!(tc.id && failedToolIds?.has(tc.id));
@@ -173,6 +219,7 @@ export default function ToolCallStatus({
uiItem={uiItem}
stream={stream}
components={components}
globalExpanded={showGlobalToggle ? globalExpanded : undefined}
/>
);
})}
+33
View File
@@ -163,3 +163,36 @@
@keyframes blink {
50% { opacity: 0; }
}
/* Card slide-in-from-bottom animation */
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-enter {
animation: card-enter 100ms ease-out both;
}
/* 3-dot loading bounce */
.dot-bounce {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: dot-bounce 1.2s ease-in-out infinite;
}
.dot-bounce:nth-child(2) { animation-delay: 0.2s; }
.dot-bounce:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
+213 -30
View File
@@ -9,10 +9,10 @@ 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, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown } from "lucide-react";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ThreadSidebar, type ThreadItem } from "@/components/ThreadSidebar.tsx";
import { ThreadSidebar, type ThreadItem, updateThreadLastActive } from "@/components/ThreadSidebar.tsx";
import { ThemeProvider } from "next-themes";
import ThemeToggle from "@/components/ThemeToggle.tsx";
import MessageBubble from "@/components/MessageBubble.tsx";
@@ -41,6 +41,27 @@ function deduplicateUiItems(items: UIMsgLocal[]): UIMsgLocal[] {
});
}
// ─── Execution summary from UI items ─────────────────────────────────────────
function buildExecutionSummary(uiItems: UIMsgLocal[], failedToolNames: Set<string> = new Set()): string {
const counts: Record<string, number> = {};
for (const ui of uiItems) {
counts[ui.name] = (counts[ui.name] ?? 0) + 1;
}
const parts: string[] = [];
const failSuffix = (name: string) => failedToolNames.has(name) ? "(失败)" : "";
if (counts["knowledge-result"]) parts.push(`查了知识库${failSuffix("knowledge-result")}`);
if (counts["ticket-summary"] || counts["ticket-detail"]) {
const n = (counts["ticket-summary"] ?? 0) + (counts["ticket-detail"] ?? 0);
const failed = failedToolNames.has("ticket-summary") || failedToolNames.has("ticket-detail");
parts.push(`看了 ${n} 个工单${failed ? "(失败)" : ""}`);
}
if (counts["search-result"]) parts.push(`搜索了网络${failSuffix("search-result")}`);
if (counts["sandbox-result"]) parts.push(`执行了代码${failSuffix("sandbox-result")}`);
if (counts["chart-result"]) parts.push(`生成了图表${failSuffix("chart-result")}`);
if (counts["canvas-doc"]) parts.push(`生成了文档${failSuffix("canvas-doc")}`);
return parts.join(" · ");
}
// ─── Tool groups ────────────────────────────────────────────────────────────
const TOOL_GROUPS = [
{ key: "knowledge", label: "知识库", icon: BookOpen, tools: ["kb_search"] },
@@ -101,6 +122,7 @@ function App() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const isComposingRef = useRef(false);
// Tool & model state
const [activeTools, setActiveTools] = useState<Set<ToolKey>>(new Set());
@@ -117,6 +139,12 @@ function App() {
// File attachment state
const [attachedFile, setAttachedFile] = useState<SelectedFile | null>(null);
// Source label from card action buttons (e.g. "来自 知识库检索")
const [sourceLabel, setSourceLabel] = useState<string | null>(null);
// Pending retry tool name — set by soc:retry-tool, consumed on next submit
const pendingRetryToolRef = useRef<string | null>(null);
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
apiUrl: LANGGRAPH_URL,
assistantId: "agent",
@@ -141,11 +169,35 @@ function App() {
return () => window.removeEventListener("open-canvas", handler);
}, []);
// Listen for retry-tool events from error-result cards
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent<{ toolName: string }>;
if (ce.detail.toolName) {
pendingRetryToolRef.current = ce.detail.toolName;
}
};
window.addEventListener("soc:retry-tool", handler);
return () => window.removeEventListener("soc:retry-tool", handler);
}, []);
// Listen for prefill-input events from ActionBar
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent<{ text: string }>;
const ce = e as CustomEvent<{ text?: string; prefix?: string; sourceLabel?: string; taskType?: string; sourceCardId?: string }>;
if (ce.detail.prefix) {
// Prefix mode: prepend context label to current input
setInput((prev) => {
const base = prev.trim();
return base ? `${ce.detail.prefix}${base}` : ce.detail.prefix!;
});
} else if (ce.detail.text !== undefined) {
setInput(ce.detail.text);
}
// Show source label tag above textarea if provided
if (ce.detail.sourceLabel) {
setSourceLabel(ce.detail.sourceLabel);
}
setTimeout(() => textareaRef.current?.focus(), 50);
};
window.addEventListener("soc:prefill-input", handler);
@@ -162,6 +214,11 @@ function App() {
// ── Thread actions ──────────────────────────────────────────────────────
const handleNewThread = useCallback(async () => {
// Save draft for current thread before switching
if (currentThreadId) {
try { localStorage.setItem(`draft_${currentThreadId}`, input); } catch { /* ignore */ }
}
setInput("");
try {
const t = await client.threads.create();
setThreads((prev) => [t as ThreadItem, ...prev]);
@@ -171,12 +228,23 @@ function App() {
setCurrentThreadId(null);
}
setSidebarOpen(false);
}, []);
}, [currentThreadId, input]);
const handleSelectThread = useCallback((threadId: string) => {
// Save draft for current thread
if (currentThreadId) {
try { localStorage.setItem(`draft_${currentThreadId}`, input); } catch { /* ignore */ }
}
// Restore draft for the new thread
try {
const saved = localStorage.getItem(`draft_${threadId}`) ?? "";
setInput(saved);
} catch {
setInput("");
}
setCurrentThreadId(threadId);
setSidebarOpen(false);
}, []);
}, [currentThreadId, input]);
const handleDeleteThread = useCallback(async (threadId: string) => {
try {
@@ -217,6 +285,12 @@ function App() {
const text = input.trim();
if ((!text && !attachedFile) || thread.isLoading) return;
setInput("");
setSourceLabel(null);
// Clear draft and update lastActive for this thread
if (currentThreadId) {
try { localStorage.removeItem(`draft_${currentThreadId}`); } catch { /* ignore */ }
updateThreadLastActive(currentThreadId);
}
const enabledTools =
activeTools.size > 0
@@ -227,7 +301,8 @@ function App() {
setAttachedFile(null);
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
let messageContent: unknown;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let messageContent: any;
if (file && IMAGE_TYPES.includes(file.mimeType)) {
messageContent = [
@@ -245,6 +320,10 @@ function App() {
// Auto-name thread on first message
const isFirstMessage = thread.messages.length === 0;
// Consume pending retry tool hint (set by soc:retry-tool event)
const retryTool = pendingRetryToolRef.current;
pendingRetryToolRef.current = null;
thread.submit(
{ messages: [{ type: "human", content: messageContent }] },
{
@@ -252,6 +331,7 @@ function App() {
configurable: {
enabledTools,
modelMode,
...(retryTool ? { retryTool } : {}),
...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}),
},
},
@@ -259,12 +339,15 @@ function App() {
);
if (isFirstMessage && currentThreadId) {
client.threads.update(currentThreadId, { metadata: { title: text.slice(0, 30) } }).catch(() => {});
const titleText = text.slice(0, 20);
client.threads.update(currentThreadId, { metadata: { title: titleText } }).catch(() => {});
// Persist title to localStorage for instant display
try { localStorage.setItem(`title_${currentThreadId}`, titleText); } catch { /* ignore */ }
// Optimistically update local thread title
setThreads((prev) =>
prev.map((t) =>
t.thread_id === currentThreadId
? { ...t, metadata: { ...t.metadata, title: text.slice(0, 30) } }
? { ...t, metadata: { ...t.metadata, title: titleText } }
: t,
),
);
@@ -436,11 +519,17 @@ function App() {
{thread.messages.map((message, idx) => {
// Render UI cards attached to this message
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uiItems = deduplicateUiItems(
const uiItemsRaw = deduplicateUiItems(
((thread.values as any)?.ui ?? []).filter(
(ui: UIMsgLocal) => ui.metadata?.message_id === message.id,
) as UIMsgLocal[]
);
// Sort by sort_key so multi-tool results appear in deterministic order
const uiItems = [...uiItemsRaw].sort((a, b) => {
const sa = (a.props?.sort_key as number) ?? 0;
const sb = (b.props?.sort_key as number) ?? 0;
return sa - sb;
});
if (message.type === "human") {
const humanText = typeof message.content === "string"
@@ -511,6 +600,20 @@ function App() {
return (
<div key={message.id ?? idx} className="flex flex-col gap-3">
{/* Text reply — rendered first so user sees conclusion before evidence */}
{textContent && (
<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>
)}
{/* Tool call status (with inline expand/collapse for UI cards) */}
{toolCalls.length > 0 && (
<ToolCallStatus
@@ -525,8 +628,8 @@ function App() {
)}
{/* UI cards not matched to any tool call (standalone) */}
{uiItems
.filter((ui) => !toolCalls.some((tc) => {
{(() => {
const standaloneUiItems = uiItems.filter((ui) => !toolCalls.some((tc) => {
const nameMap: Record<string, string> = {
kb_search: "knowledge-result",
ticket_list: "ticket-summary",
@@ -536,9 +639,10 @@ function App() {
sandbox_run: "sandbox-result",
};
return tc.name && ui.name === nameMap[tc.name];
}))
.map((ui: UIMsgLocal) => (
<div key={ui.id} className="animate-in fade-in duration-300">
}));
if (standaloneUiItems.length === 0) return null;
const cards = standaloneUiItems.map((ui: UIMsgLocal) => (
<div key={ui.id} className="card-enter">
<LoadExternalComponent
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -547,20 +651,31 @@ function App() {
components={ComponentMap as any}
/>
</div>
))}
));
if (standaloneUiItems.length >= 2) {
return (
<div className="flex flex-col gap-2 border-l-2 border-primary/20 pl-3 mt-2">
<span className="text-xs text-muted-foreground font-medium">综合分析 · {standaloneUiItems.length} 项结果</span>
{cards}
</div>
);
}
return <>{cards}</>;
})()}
{/* Text reply */}
{textContent && (
<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" />
{/* Execution summary */}
{uiItems.length > 0 && !thread.isLoading && (
<p className="text-[10px] text-muted-foreground px-1">
{buildExecutionSummary(
uiItems,
new Set(
uiItems
.filter((ui) => ui.type === "error-result")
.map((ui) => ui.props?.tool as string)
.filter(Boolean),
),
)}
{/* Copy button */}
<div className="flex justify-end mt-1">
<CopyButton text={textContent} />
</div>
</div>
</p>
)}
{/* Regenerate button — only on last AI message, only when not loading */}
@@ -583,6 +698,19 @@ function App() {
return null;
})}
{/* Loading placeholder: show 3-dot bounce when waiting for first AI tokens after a human message */}
{(() => {
const lastMsg = thread.messages[thread.messages.length - 1];
const showLoadingDots = thread.isLoading && lastMsg?.type === "human";
return showLoadingDots ? (
<div className="flex items-center gap-1.5 px-4 py-3 rounded-2xl rounded-bl-sm bg-muted text-muted-foreground w-fit">
<span className="dot-bounce" />
<span className="dot-bounce" />
<span className="dot-bounce" />
</div>
) : null;
})()}
{/* Streaming UI cards not yet attached to a completed message */}
{thread.isLoading &&
deduplicateUiItems(
@@ -594,7 +722,7 @@ function App() {
})
)
.map((ui: UIMsgLocal) => (
<div key={ui.id} className="animate-in fade-in duration-300">
<div key={ui.id} className="card-enter">
<LoadExternalComponent
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -626,6 +754,21 @@ function App() {
<div className="shrink-0 border-t border-border px-4 pt-3 pb-0 flex items-center justify-between max-w-3xl mx-auto w-full">
{/* Tool toggles */}
<div className="flex items-center gap-1.5">
{/* Auto chip — active when no tools are selected */}
<button
type="button"
onClick={() => setActiveTools(new Set())}
title="自动模式:由 AI 决定使用哪些工具"
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
activeTools.size === 0
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Sparkles className="size-3.5" />
自动
</button>
{TOOL_GROUPS.map(({ key, label, icon: Icon }) => {
const isOn = activeTools.has(key);
return (
@@ -633,6 +776,7 @@ function App() {
key={key}
type="button"
onClick={() => toggleTool(key)}
title={isOn ? `${label}:已启用` : `${label}:已禁用`}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
isOn
@@ -655,11 +799,17 @@ function App() {
type="button"
variant={modelMode === value ? "default" : "ghost"}
size="sm"
className="h-7 px-2.5 text-xs gap-1"
className={cn(
"h-7 px-2.5 text-xs gap-1",
value === "auto" && modelMode !== value && "font-semibold text-primary",
)}
onClick={() => setModelMode(value)}
>
<Icon className="size-3.5" />
{label}
{value === "auto" && modelMode !== value && (
<span className="ml-0.5 text-[9px] text-primary/70">推荐</span>
)}
</Button>
))}
</div>
@@ -671,6 +821,22 @@ function App() {
{attachedFile && (
<FileAttachmentPreview file={attachedFile} onRemove={() => setAttachedFile(null)} />
)}
{/* Source label: shown when user clicks a card action button */}
{sourceLabel && (
<div className="flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
来自 {sourceLabel}
</span>
<button
type="button"
onClick={() => setSourceLabel(null)}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
title="清除来源标签"
>
×
</button>
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<FileUploadButton onFileSelect={setAttachedFile} disabled={thread.isLoading} />
<textarea
@@ -681,8 +847,10 @@ function App() {
value={input}
rows={1}
onChange={(e) => setInput(e.target.value)}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => { isComposingRef.current = false; }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
handleSubmit(e as unknown as React.FormEvent);
}
@@ -696,8 +864,9 @@ function App() {
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"
title="点击停止生成"
>
<Square className="size-4" />
<Square className="size-4 fill-current" />
停止
</button>
) : (
@@ -710,6 +879,20 @@ function App() {
</button>
)}
</form>
{/* Input hint: dynamically changes based on active tools */}
<p className="text-[10px] text-muted-foreground/70 pl-1">
{activeTools.size === 0
? "可分析工单、查知识库、搜索网络"
: activeTools.size === 1 && activeTools.has("knowledge")
? "将在内部知识库中检索"
: activeTools.size === 1 && activeTools.has("tickets")
? "将查询工单系统"
: activeTools.size === 1 && activeTools.has("search")
? "将使用网络搜索"
: activeTools.size === 1 && activeTools.has("sandbox")
? "将执行代码沙盒"
: `已启用 ${activeTools.size} 个工具`}
</p>
</div>
</div>
</div>
+208 -142
View File
@@ -1,178 +1,244 @@
# SOC 端到端功能测试报告
# SOC 8项优化 端到端验收测试报告
## 测试环境
- **开发机**: 192.168.30.30
- **后端**: http://localhost:2024 (LangGraph Server)
- **前端**: http://localhost:5173 (Vite dev server)
- **测试时间**: 2026-04-11 (第三轮 -- 产品验收测试)
- **测试范围**: 全端点功能测试 + 产品验收标准检查
- **后端**: http://192.168.30.30:2024
- **前端**: http://192.168.30.30:5173
- **代码版本**: commit 4ad169e (main)
- **测试时间**: 2026-04-11
## 基础端点测试
| # | 端点 | 方法 | 状态码 | 响应 | 状态 |
|---|------|------|--------|------|------|
| 1 | /ok | GET | 200 | `{"ok":true}` | PASS |
| 2 | /info | GET | 200 | `{"flags":{"assistants":true,"crons":false}}` | PASS |
| 3 | /threads | POST | 200 | 返回 thread_id, status: idle | PASS |
| 4 | 前端首页 | GET | 200 | 页面正常加载 | PASS |
---
## 1. 端点测试结果
## 第一轮:8项优化验收测试
| # | 端点 / 功能 | 方法 | 状态 | 结果 |
|---|------------|------|------|------|
| 1 | `/ok` 健康检查 | GET | 200 | PASS - 返回 `{"ok":true}` |
| 2 | `/info` 服务信息 | GET | 200 | PASS - 返回 `{"flags":{"assistants":true,"crons":false}}` |
| 3 | 前端 `localhost:5173` | GET | 200 | PASS - 返回 Vite dev HTML |
| 4 | `/threads` 创建 Thread | POST | 200 | PASS - 返回 thread_id |
| 5 | Vite Proxy `/threads` | POST | 200 | PASS - 前端端口代理到后端 API |
| 6 | Supervisor 路由 → generalInput | POST stream | 200 | PASS - "你好" 正确路由到通用对话,生成流畅回复 |
| 7 | Supervisor 路由 → enterprise (kb_search) | POST stream | 200 | PARTIAL - 路由正确,触发 kb_search 工具,但 KB Agent 超时 |
| 8 | Supervisor 路由 → enterprise (ticket_list) | POST stream | 200 | PASS - 返回 3 条工单 + ticket-summary Gen-UI 卡片 |
| 9 | Supervisor 路由 → enterprise (ticket_detail) | POST stream | 200 | PARTIAL - 路由正确,参数解析正确,但 Gongdan API 返回 404 |
| 10 | Supervisor 路由 → searcher (google_search) | POST stream | 200 | PASS - 返回丰富搜索结果 (8 条新闻),内容质量高 |
| 11 | Supervisor 路由 → coder (sandbox_run) | POST stream | 200 | PARTIAL - 路由正确,LLM 生成代码正确,Daytona sandbox 执行失败 (400) |
| 12 | Supervisor 路由 → writer (canvas-doc) | POST stream | 200 | PASS - 生成 canvas-doc Gen-UI 卡片,含完整 markdown 文档 (833字) |
### 端点通过率: 9/12 通过 (75%), 3 个部分通过 (路由均正确,外部服务异常)
| # | 测试项 | 状态 | 备注 |
|---|--------|------|------|
| 1 | P0 Daytona sandbox 修复 | PASS | code_execute 工具成功调用,exit_code=0,stdout="5050",耗时1462ms |
| 2 | KB Agent retry 验证 | PASS | retry.ts 实现完整(3次重试,指数退避1s基准),已在 enterprise/tool-executor 中正确包裹 kb_search;本次测试 KB 正常响应无需重试 |
| 3 | ticket_detail 修复 | PASS | TK-xxxx 格式工单 ID 可正常查询详情,返回完整工单数据 |
| 4 | sourceType 字段 | PASS | 所有 ui items 均包含 sourceType 和 confidence 字段:sandbox-result(code_execution/high), ticket-summary(ticket_system/high), ticket-detail(ticket_system/high), canvas-doc(generated_doc/high), knowledge-result(internal_kb), search-result(external_web) |
| 5 | 自动工具路由准确性 | PASS (5/5) | "写报告"->writer, "网络安全新闻"->searcher, "Python排序算法"->coder, "公司网络安全规范"->enterprise(kb_search), "你好介绍自己"->generalInput |
| 6 | 企业风格输出 | PASS | 回复包含 [工单数据] 来源标注,结论/依据/建议三层结构完整,有风险提示和下一步建议 |
| 7 | 会话恢复 | PASS | 新 thread 发送工具触发消息后,GET /threads/{id}/state 返回 values.ui 数组包含 ticket-summary 卡片(含 sourceType/confidence),UI 状态正确持久化到 checkpoint |
---
## 2. 产品验收测试结果
## 第二轮:迭代修复验收测试
### 一、已实现功能 (回归测试) -- 代码审计
| # | 功能 | 状态 | 验证方式 | 备注 |
|---|------|------|---------|------|
| 1 | 停止生成 | PASS | 代码审计 | main.tsx:652-660 `thread.stop()` + Square 图标,isLoading 时显示红色"停止"按钮,否则显示"发送" |
| 2 | 重新生成 | PASS | 代码审计 | main.tsx:247-266 `handleRegenerate()` 取最后一条 human 消息重新 submit,仅最后一条 AI 消息显示 |
| 3 | 复制消息 | PASS | 代码审计 | main.tsx:56-79 `CopyButton` 组件,hover AI 消息时通过 `group-hover:opacity-100` 显示,使用 `navigator.clipboard` |
| 4 | 代码块 | PASS | 代码审计 | MessageBubble.tsx:20-76 `CodeBlock` 组件,Prism 语法高亮 + 语言标签 + 复制按钮,dark/light 跟随主题 (oneDark/oneLight) |
| 5 | 文件上传 | PASS | 代码审计 | 三种方式均实现:FileUploadButton (main.tsx:633)、拖拽 (onDrop main.tsx:273)、Ctrl+V 粘贴 (onPaste main.tsx:287) |
| 6 | 主题切换 | PASS | 代码审计 | ThemeProvider + ThemeToggle 组件,代码块高亮跟随 (MessageBubble.tsx:37-39 检测 `.dark` class) |
| 7 | 移动端 | PASS | 代码审计 | main.tsx:325-330 `md:hidden`/`md:flex` 响应式,hamburger Menu 按钮 (main.tsx:348-354),点击切换 sidebarOpen |
| 8 | 对话管理 | PASS | 代码审计 + API | 新建 (handleNewThread)、切换 (handleSelectThread)、删除 (handleDeleteThread)、搜索 (ThreadSidebar 搜索框) |
| 9 | 模型切换 | PASS | 代码审计 | main.tsx:38-42 Flash/Auto/Pro 三模式,609-623 模型选择器 UI |
| 10 | 工具开关 | PASS | 代码审计 | main.tsx:28-33 四组工具独立 toggle,586-606 工具开关 UI (pill 形态,选中变 primary 色) |
**已实现功能回归: 10/10 通过**
### 二、新增功能验证
| # | 功能 | 状态 | 验证方式 | 备注 |
|---|------|------|---------|------|
| 1 | Textarea 多行输入 | PASS | 代码审计 | main.tsx:634-650 `<textarea>` 替代 input;Enter 发送 (onKeyDown:642-646);Shift+Enter 换行(默认行为);auto-resize (useEffect:166-171 max 200px);resize-none + overflow-y-auto |
| 2 | 流式打字光标 | PASS | 代码审计 + CSS | main.tsx:516-518 `<span className="typing-cursor">` 仅在 `isLoading && isLastAi` 时渲染;index.css:153-165 `blink` 动画 1s step-end;颜色用 `currentColor` 跟随主题 |
| 3 | 编辑用户消息 | PASS | 代码审计 | main.tsx:416-428 Pencil 图标按钮,`group-hover:opacity-100` 显示;点击后 `setInput(humanText)` 回填到输入框并 focus;仅非 loading 时可编辑 |
**新增功能: 3/3 通过**
### 三、体验质量标准
| # | 指标 | 状态 | 验证方式 | 结果 |
|---|------|------|---------|------|
| 1 | 首 token 延迟 < 3s | PASS | API 测量 | 实测 ~2094ms (含 Supervisor 路由 + generalInput LLM 调用) |
| 2 | 工具调用状态 | PASS | 代码审计 | ToolCallStatus.tsx: Loader2 旋转 + "执行中...",完成后 CheckCircle2 绿色 + "已完成",可展开查看 Gen-UI 结果 |
| 3 | Gen-UI 卡片渲染 | PASS | API 测试 | ticket-summary 和 canvas-doc 确认在 SSE ui 数组中正确输出 props |
| 4 | 空状态 | PASS | 代码审计 | main.tsx:377-398 欢迎语"你好,有什么可以帮你的?" + 4 个快速提示按钮 (搜索知识库/查看工单/搜索网络/运行代码) |
| 5 | 错误恢复 | PARTIAL | 代码审计 | useStream 内置重连机制;但无显式的断网提示 UI 或手动重连按钮 |
**体验质量: 4.5/5 (1 项部分通过)**
| # | 测试项 | 状态 | 备注 |
|---|--------|------|------|
| R1 | ticket_detail 不同工单返回不同数据 | PASS | 查询 TK-2026-AE4DEA 和 TK-2026-9FA15E 分别返回正确的不同工单数据(P1-1 已修复) |
| R2 | sandbox 失败时友好提示 | PASS | 执行 `import nonexistent_module_xyz` 时 AI 给出中文分析(模块不存在、建议替代方案),未暴露原始技术栈错误 |
| R3 | 空工单列表友好提示 | PASS (代码验证) | ticket-summary 组件 tickets.length===0 时显示 "暂无工单" 居中提示文案 |
| R4 | Gen-UI 卡片 error 状态渲染 | PASS (代码验证) | 4个卡片组件均支持 errorMessage prop:有错误时边框变红(border-red-200),显示红色错误文案(text-red-700) |
| R5 | ToolCallStatus 失败状态红色X | PASS (代码验证) | 使用 XCircle + text-red-500 图标,失败时显示 "失败 . {工具名}" |
| R6 | ToolCallStatus 骨架屏过渡 | PASS (代码验证) | expanded && !uiItem 时渲染 animate-pulse 骨架屏(4行灰色占位条) |
---
## 3. 启动问题修复记录
## 详细测试记录
### pdf-parse ESM 导入错误
- **文件**: `src/agent/utils/file-service.ts`
- **错误**: `SyntaxError: The requested module 'pdf-parse' does not provide an export named 'default'`
- **原因**: pdf-parse v2.x 在 ESM 环境下没有 default export
- **修复**: 改为 `import * as pdfParseModule from "pdf-parse"` + 运行时 fallback
- **状态**: 已修复,服务正常启动
### 测试1:Daytona sandbox
- **请求**: "帮我用Python计算1到100的和"
- **路由**: supervisor -> coder
- **工具调用**: code_execute, language=python, code=`sum(range(1, 101))`
- **响应**: exit_code=0, stdout="5050\n", duration_ms=1462
- **UI 卡片**: sandbox-result, props 含 sourceType="code_execution", confidence="high"
- **判定**: PASS
### 测试2:KB Agent retry
- **代码验证**: `src/agent/utils/retry.ts` 实现 executeWithRetry,支持指数退避
- **集成验证**: enterprise/tool-executor.ts 中 kb_search 调用已包裹 `executeWithRetry(() => kbSearch(query), 3, { backoffMs: 1000, exponential: true })`
- **运行时验证**: 发送 "我们公司的网络安全规范是什么",路由到 enterprise,调用 kb_search 成功返回 knowledge-result 卡片
- **日志检查**: 未触发 retry 日志(KB 服务正常响应,未超时)
- **判定**: PASS(代码实现正确,运行时无异常)
### 测试3:ticket_detail
- **请求**: "查看最新工单的详情"
- **工具链**: ticket_list(page=1) -> ticket_detail("TK-2026-296691")
- **ticket_list 响应**: 3条工单,TK-2026-296691/TK-2026-AE4DEA/TK-2026-9FA15E
- **ticket_detail 响应**: 返回完整工单详情(含 customer/engineer/urges/sla 等字段)
- **UI 卡片**: ticket-summary + ticket-detail,均含 sourceType="ticket_system"
- **判定**: PASS
### 测试4:sourceType 字段
验证所有 ui 卡片 props 中的 sourceType 和 confidence 字段:
| 卡片类型 | sourceType | confidence |
|----------|-----------|------------|
| sandbox-result | code_execution | high |
| ticket-summary | ticket_system | high |
| ticket-detail | ticket_system | high |
| knowledge-result | internal_kb | (已确认存在) |
| search-result | external_web | (已确认存在) |
| canvas-doc | generated_doc | high |
- **判定**: PASS
### 测试5:自动工具路由
| 输入 | 预期路由 | 实际路由 | 触发工具 | 状态 |
|------|---------|---------|----------|------|
| "帮我写一个关于本季度运营情况的报告" | writer | writer | doc_create | PASS |
| "查一下最近的网络安全新闻" | searcher | searcher | google_search | PASS |
| "用Python写个排序算法" | coder | coder | (code_execute) | PASS |
| "我们公司的网络安全规范是什么" | enterprise | enterprise | kb_search | PASS |
| "你好,介绍一下自己" | generalInput | generalInput | (无工具) | PASS |
- **判定**: PASS (5/5)
### 测试6:企业风格输出
- **请求**: "分析最近3个工单的共同问题"
- **路由**: enterprise
- **工具调用**: ticket_list + 3x ticket_detail
- **输出结构验证**:
- [工单数据] 来源标注: 出现多处,标注在每个关键结论前
- 结论段: 有,以"**结论**"开头
- 依据段: 有,以"**依据**"开头,按来源分组
- 建议段: 有,以"**建议**"开头,含建议行动/风险提示/下一步
- **判定**: PASS
### 测试7:会话恢复
- **流程**: 创建 thread -> 发送"查询最近的工单" -> 等待完成 -> GET /threads/{id}/state
- **Thread ID**: 8be68055-38a5-4c22-b614-601a2eaf206e
- **验证结果**: values.ui 数组包含 1 个 ticket-summary 卡片,props 含 sourceType="ticket_system" 和 confidence="high"
- **判定**: PASS(UI 状态正确持久化到 checkpoint,切换会话可恢复)
### R1:ticket_detail UUID 映射修复验证
- **请求**: "分别查看工单TK-2026-AE4DEA和TK-2026-9FA15E的详情"
- **路由**: enterprise
- **结果**: 2个 ticket-detail 卡片,id 分别为 TK-2026-AE4DEA (title="12e1") 和 TK-2026-9FA15E (title="123")
- **toolStatus**: 2x ticket_detail status=ok
- **判定**: PASS(第一轮 P1-1 问题已修复,不同 TK-xxxx 正确返回不同工单)
### R2:sandbox 失败友好提示
- **请求**: "帮我运行这段Python代码:import nonexistent_module_xyz; print(nonexistent_module_xyz.hello())"
- **工具结果**: exit_code=1, ModuleNotFoundError
- **AI 回复**: 中文分析 -- "模块 nonexistent_module_xyz 并不存在",给出表格化失败原因,提供修复建议(替换为真实模块名/自定义示例),未暴露原始 traceback
- **UI 卡片**: sandbox-result, exit_code=1
- **判定**: PASS
### R3:空工单列表友好提示
- **代码验证**: ticket-summary/index.tsx 中 `tickets.length === 0` 分支渲染 `<li className="px-4 py-6 text-center text-sm text-muted-foreground">暂无工单</li>`
- **判定**: PASS(组件正确处理空状态)
### R4:Gen-UI 卡片 error 状态
- **代码验证**: 4个卡片组件均实现 errorMessage prop:
- knowledge-result: errorMessage 时 border-red-200/dark:border-red-900/40,显示红色文案
- ticket-summary: 同上
- search-result: 同上
- sandbox-result: 同上 + 失败时显示 "失败 (exit {code})"
- **后端集成**: enterprise/tool-executor.ts 中 kb_search 失败时推送 `sourceType: "error", confidence: "low", errorMessage: "知识库暂时无响应,已切换到网络搜索"` 并触发 google_search fallback
- **判定**: PASS
### R5:ToolCallStatus 失败状态
- **代码验证**: ToolCallStatus.tsx 中 isFailed 时渲染 `<XCircle className="size-3.5 text-red-500 shrink-0" />`,文案显示 "失败 . {工具中文名}"
- **判定**: PASS
### R6:ToolCallStatus 骨架屏
- **代码验证**: ToolCallStatus.tsx 中 `expanded && !uiItem` 时渲染:
```html
<div className="mt-2 ml-7 space-y-2 animate-pulse">
<div className="h-3 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-1/2" />
<div className="h-3 bg-muted rounded w-5/6" />
<div className="h-8 bg-muted rounded w-full mt-3" />
</div>
```
- **判定**: PASS(展开时 UI 卡片未加载前显示骨架屏过渡动画)
---
## 4. Gen-UI 卡片验证
## 第三轮:新功能验收测试
| 组件 | 对应工具/Agent | 是否触发 | SSE 中 ui 数组 |
|------|--------------|---------|---------------|
| ticket-summary | ticket_list | 是 | 有 props (total, tickets) |
| canvas-doc | writer agent | 是 | 有 props (doc_id, title, _doc_content) |
| search-result | google_search | 待验证 | 工具返回数据正确 |
| knowledge-result | kb_search | 未触发 | KB Agent 超时 |
| ticket-detail | ticket_detail | 未触发 | Gongdan API 404 |
| sandbox-result | sandbox_run | 未触发 | Daytona 400 |
| # | 测试项 | 状态 | 备注 |
|---|--------|------|------|
| R3-1 | chart-result 自动出图 | PASS | ticket-summary 后自动附带 chart-result 卡片,含2个图表:状态分布(已关闭:2, IN_PROGRESS:1) + 优先级分布(PRIORITY:2, NORMAL:1) |
| R3-2 | reply-draft 卡片 | PASS | 路由到 writer,调用 reply_draft 工具,返回 reply-draft 卡片(mode="customer",含专业客户回复内容) |
| R3-3 | next-actions 卡片 | PASS | 工单查询后自动附带 next-actions 卡片,含2条推荐动作;知识库查询后含"生成知识摘要"、"导出到文档" |
| R3-4 | citations 来源列表 | PASS | knowledge-result 含5条 citations(带 title/source);search-result 含8条 citations(带 title/source/url) |
| R3-5 | 卡片合并 deduplication | PASS (代码验证) | main.tsx 中 deduplicateUiItems() 按 card_id 去重,同 card_id 只保留最新态 |
---
## 5. 新增后端/前端改动验证
### R3-1:chart-result 自动出图
- **请求**: "查询最近的工单"
- **路由**: enterprise
- **UI 卡片**: 3个 -- ticket-summary + chart-result + next-actions
- **chart-result 详情**:
- title: "工单分布统计"
- charts[0]: title="状态分布", data=[{name:"已关闭", value:2}, {name:"IN_PROGRESS", value:1}]
- charts[1]: title="优先级分布", data=[{name:"PRIORITY", value:2}, {name:"NORMAL", value:1}]
- **判定**: PASS
### 后端改动
| 改动项 | 状态 | 备注 |
|--------|------|------|
| truncateMessages 上下文压缩 | PASS | TypeScript 编译通过,各 Agent 集成正确 |
| router.ts 路由改进 | PASS | 路由到正确的 Agent (enterprise/generalInput/searcher/coder/writer) |
| process-attachment.ts 附件处理 | PASS | 新文件已创建,TypeScript 编译通过 |
| retry.ts 工具重试 | PASS | 新文件已创建,TypeScript 编译通过 |
| pdf-parse ESM 修复 | PASS | 修复后服务正常启动 |
### R3-2:reply-draft 卡片
- **请求**: "帮我生成一份客户回复草稿,关于工单TK-2026-296691中LLM请求被拒绝的问题"
- **路由**: writer
- **工具调用**: reply_draft + doc_create
- **UI 卡片**: reply-draft (mode="customer") + canvas-doc
- **reply-draft 内容**: 专业客户回复,包含问题确认、排查方向(参数规范/鉴权/频率限制/额度)、建议步骤
- **判定**: PASS
### 前端改动
| 改动项 | 状态 | 备注 |
|--------|------|------|
| ToolCallStatus.tsx 新组件 | PASS | 完整实现:中文工具名映射、加载动画、完成状态、可展开 Gen-UI 结果 |
| ThreadSidebar.tsx 修改 | PASS | 按日期分组 (今天/昨天/本周/更早)、搜索过滤 |
| main.tsx UX 改进 | PASS | textarea 多行、打字光标、编辑消息、复制按钮、快速提示 |
### R3-3:next-actions 卡片
- **工单查询场景**: actions = ["生成处理建议", "生成客户回复"],每条含 prompt 和 icon
- **知识库查询场景**: actions = ["生成知识摘要", "导出到文档"]
- **判定**: PASS
### R3-4:citations 来源列表
- **knowledge-result**: 5条 citations,格式 {index, title, source}
- 示例: {index:1, title:"Taiji Agent 文档中心 - Openclaw相关配置说明", source:null}
- **search-result**: 8条 citations,格式 {index, title, source, url}
- 示例: {index:1, title:"推出OpenAI 安全研究员计划", source:"openai.com", url:"https://openai.com/..."}
- **前端渲染**: knowledge-result/index.tsx 和 search-result/index.tsx 均实现 Citation 接口渲染来源列表
- **判定**: PASS
### R3-5:卡片合并 deduplication
- **代码验证**: main.tsx:30 `deduplicateUiItems()` 函数:
- 遍历 items 按 `props.card_id` 建立 Map,记录每个 card_id 最后出现的 index
- filter 时只保留 card_id 对应最后 index 的项,无 card_id 的项全部保留
- 在消息渲染和会话恢复两处调用(L439, L588)
- **判定**: PASS
---
## 6. 问题清单
## 问题清单
### P0 - Daytona Sandbox 执行失败
- **端点**: coder agent → code_execute → Daytona API
- **错误**: `Daytona create failed: 400`
- **影响**: 代码解释器功能完全不可用
- **建议**: 检查 DAYTONA_API_KEY 和 DAYTONA_API_URL 环境变量配置
### 已修复
- **P1-1 (R2已修复)**: ticket_detail UUID 映射异常
### P1 - KB Agent 超时
- **端点**: enterprise agent → kb_search → KB Agent API
- **错误**: `TimeoutError: The operation was aborted due to timeout`
- **影响**: 知识库检索功能间歇性不可用
- **建议**: 增加超时时间或检查 KB Agent 服务状态,已有 retry 工具可集成
### 遗留问题
无 P0/P1 遗留问题。
### P2 - Gongdan API ticket_detail 404
- **端点**: enterprise agent → ticket_detail → Gongdan API
- **错误**: `Ticket detail failed: 404`
- **影响**: 工单详情查看功能不可用
- **建议**: 检查 Gongdan API 的 ticket_detail 端点路径和参数格式
### P3 - 错误恢复无显式 UI
- **影响**: 网络断开时用户无明确提示
- **建议**: 添加断网检测 toast 提示 + 手动重连按钮
### P2 建议
- KB retry 机制在正常情况下无法触发运行时验证,建议后续添加集成测试模拟超时场景
- 骨架屏和 error 态为代码验证(非运行时触发),建议前端 Storybook 添加对应 story
- knowledge-result citations 中 source 字段部分为 null,建议后端 kb_search 返回时补充来源文档名
- chart-result 中 charts 子项缺少 type 字段(pie/bar),前端需靠 fallback 推断图表类型
---
## 7. 产品验收总结
## 通过率
| 类别 | 通过项 | 总数 | 通过率 |
|------|--------|------|--------|
| 已实现功能回归 | 10 | 10 | 100% |
| 新增功能 | 3 | 3 | 100% |
| 体验质量标准 | 4.5 | 5 | 90% |
| **合计** | **17.5** | **18** | **97.2%** |
**第一轮: 7/7 通过** | **第二轮: 6/6 通过** | **第三轮: 5/5 通过** | **总计: 18/18**
---
## 整体质量评分
## 8. 整体质量评分
**9.2 / 10**
### 8.0 / 10
**加分项:**
- 架构设计成熟 -- Supervisor + 多子 Agent 路由,所有路由 100% 正确
- 前端功能完整 -- 停止/重新生成/复制/编辑/多行输入/主题切换/移动端/工具开关全部实现
- ToolCallStatus 组件体验好 -- 中文工具名、加载动画、可展开查看结果
- 打字光标和空状态提升了产品质感
- 首 token 延迟 ~2s,满足 3s 标准
- TypeScript 编译 0 错误
- Gen-UI 卡片系统正常工作 (ticket-summary, canvas-doc)
**减分项:**
- Daytona sandbox 不可用 (-1)
- KB Agent 超时不稳定 (-0.5)
- Gongdan ticket_detail 404 (-0.5)
- 缺少断网恢复提示 UI (-0.2)
---
*测试人: SOC Test Agent | 生成时间: 2026-04-11 (第三轮 -- 产品验收测试)*
- 核心功能(sandbox、工单、知识库、搜索、路由)全部正常
- sourceType/confidence 字段在所有卡片中正确注入
- 企业风格输出格式规范,来源标注完整
- 会话恢复 checkpoint UI 持久化正常
- KB retry + fallback 机制代码实现完善
- ticket_detail UUID 映射问题已修复
- Gen-UI error 态、ToolCallStatus 失败/骨架屏均已实现
- chart-result 自动出图(状态分布+优先级分布)
- reply-draft 卡片含 customer/internal 模式
- next-actions 推荐动作按场景智能生成
- citations 来源列表完整(知识库+搜索均有)
- deduplicateUiItems 卡片去重逻辑正确
- 扣分项:chart-result 缺少 type 字段(-0.3);knowledge citations source 部分为 null(-0.2);部分前端状态为代码验证(-0.3)