feat: enterprise product sprint — log protocol, tool rules, UX polish
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:
co-authored by
Claude Sonnet 4.6
parent
183794dc42
commit
9c96913b4d
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 确认
|
||||
|
||||
@@ -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` 写入测试记录
|
||||
|
||||
@@ -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)
|
||||
│ ├── langgraph.json ← LangGraph 配置
|
||||
│ ├── startup.sh ← Azure Web App 启动脚本
|
||||
│ ├── Dockerfile ← 后端容器
|
||||
│ ├── Dockerfile.frontend ← 前端容器(未使用)
|
||||
│ └── package.json ← pnpm, Node.js 20
|
||||
├── langgraph/ ← 唯一代码目录
|
||||
│ ├── 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 配置
|
||||
│ ├── Dockerfile ← 后端容器
|
||||
│ └── startup.sh ← Azure Web App 启动脚本
|
||||
├── .github/workflows/
|
||||
│ ├── deploy-langgraph.yml ← ACR build → Web App
|
||||
│ └── deploy-langgraph-ui.yml ← Vite build → Static Web App
|
||||
├── doc/ ← 文档
|
||||
│ ├── deploy-langgraph.yml ← ACR build → Web App
|
||||
│ └── deploy-langgraph-ui.yml ← Vite build → Static Web App
|
||||
└── 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,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">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
{renderChart()}
|
||||
</ResponsiveContainer>
|
||||
{!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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
() => kbSearch(parsed.query),
|
||||
3,
|
||||
{ backoffMs: 1000, exponential: true },
|
||||
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: "未找到该工单",
|
||||
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({
|
||||
error: formatToolError(name, e),
|
||||
fallback_hint: "工具执行失败。请用中文向用户解释错误原因,并提供替代建议。",
|
||||
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`;
|
||||
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);
|
||||
// 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();
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${base}/api/tickets/${resolvedId}`;
|
||||
const resp = await fetch(url, {
|
||||
// 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`, {
|
||||
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}`);
|
||||
let createResp: Response;
|
||||
try {
|
||||
createResp = await fetch(`${apiUrl}/sandbox`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ autoStopInterval: 5, autoDeleteInterval: 0 }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
} 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(
|
||||
`https://proxy.app.daytona.io/toolbox/${sbId}/process/execute`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
},
|
||||
);
|
||||
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(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 {
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import "@/agent/utils/config";
|
||||
import { StateGraph, START, END } from "@langchain/langgraph";
|
||||
import {
|
||||
SupervisorAnnotation,
|
||||
|
||||
@@ -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]) : "";
|
||||
|
||||
@@ -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)",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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" },
|
||||
|
||||
@@ -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,26 +114,61 @@ 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">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={onNewThread}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
新建对话
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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 */}
|
||||
<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
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
@@ -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();
|
||||
onDeleteThread(t.thread_id);
|
||||
if (window.confirm("确定要删除这个对话吗?")) {
|
||||
onDeleteThread(t.thread_id);
|
||||
}
|
||||
}}
|
||||
title="删除对话"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
+214
-31
@@ -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 }>;
|
||||
setInput(ce.detail.text);
|
||||
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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user