diff --git a/.claude/agents/soc-deploy-agent.md b/.claude/agents/soc-deploy-agent.md index 8a49cea..5cff659 100644 --- a/.claude/agents/soc-deploy-agent.md +++ b/.claude/agents/soc-deploy-agent.md @@ -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`) diff --git a/.claude/agents/soc-frontend-agent.md b/.claude/agents/soc-frontend-agent.md index f99d663..e9a222c 100644 --- a/.claude/agents/soc-frontend-agent.md +++ b/.claude/agents/soc-frontend-agent.md @@ -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.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\)和 `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 diff --git a/.claude/agents/soc-llm-engineer-agent.md b/.claude/agents/soc-llm-engineer-agent.md index 6fd7447..7830e34 100644 --- a/.claude/agents/soc-llm-engineer-agent.md +++ b/.claude/agents/soc-llm-engineer-agent.md @@ -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 确认 diff --git a/.claude/agents/soc-tester-agent.md b/.claude/agents/soc-tester-agent.md index d63f33c..5021d18 100644 --- a/.claude/agents/soc-tester-agent.md +++ b/.claude/agents/soc-tester-agent.md @@ -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` 写入测试记录 diff --git a/CLAUDE.md b/CLAUDE.md index 53f8833..35178c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/langgraph/langgraph.json b/langgraph/langgraph.json index 05f1ab3..a4c1e86 100644 --- a/langgraph/langgraph.json +++ b/langgraph/langgraph.json @@ -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" diff --git a/langgraph/src/agent-uis/enterprise/canvas-doc/index.tsx b/langgraph/src/agent-uis/enterprise/canvas-doc/index.tsx index 62fb503..1f57ade 100644 --- a/langgraph/src/agent-uis/enterprise/canvas-doc/index.tsx +++ b/langgraph/src/agent-uis/enterprise/canvas-doc/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; } /** diff --git a/langgraph/src/agent-uis/enterprise/chart-result/index.tsx b/langgraph/src/agent-uis/enterprise/chart-result/index.tsx index 894466b..db1b527 100644 --- a/langgraph/src/agent-uis/enterprise/chart-result/index.tsx +++ b/langgraph/src/agent-uis/enterprise/chart-result/index.tsx @@ -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 */}
- {title} + {title} + {/* View toggle */} + {hasData && ( +
+ + +
+ )}
- {/* Chart area */} + {/* Content area */}
- - {renderChart()} - + {!hasData ? ( +
+ 暂无数据 +
+ ) : viewMode === "chart" ? ( + + {renderChart()} + + ) : ( +
+ + + + + {y_keys.map((k) => ( + + ))} + + + + {data.map((row, i) => ( + + + {y_keys.map((k) => ( + + ))} + + ))} + +
+ {x_key} + + {k}{unit ? ` (${unit})` : ""} +
+ {String(row[x_key] ?? row.label ?? "")} + + {String(row[k] ?? "")} +
+
+ )}
); diff --git a/langgraph/src/agent-uis/enterprise/error-result/index.tsx b/langgraph/src/agent-uis/enterprise/error-result/index.tsx new file mode 100644 index 0000000..3ebb9c9 --- /dev/null +++ b/langgraph/src/agent-uis/enterprise/error-result/index.tsx @@ -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 = { + kb_search: "知识库检索", + ticket_list: "工单列表", + ticket_detail: "工单详情", + google_search: "网络搜索", + web_search_deep: "深度搜索", + sandbox_run: "代码沙盒", + chart_generate: "图表生成", +}; + +/** Possible reasons per suggestion type */ +const REASON_MAP: Record = { + 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 ( +
+ {/* Part 1: Error header + friendly description */} +
+ + + {label} - 执行异常 + +
+
+

{message}

+
+ + {/* Part 2: Possible reasons */} + {reasons.length > 0 && ( +
+

+ 可能原因: +

+
    + {reasons.map((r) => ( +
  • {r}
  • + ))} +
+
+ )} + + {/* Part 3: Action buttons */} + {actions.length > 0 && ( +
+ {actions.map((a) => ( + + ))} +
+ )} +
+ ); +} diff --git a/langgraph/src/agent-uis/enterprise/knowledge-result/index.tsx b/langgraph/src/agent-uis/enterprise/knowledge-result/index.tsx index 5559945..cecbec2 100644 --- a/langgraph/src/agent-uis/enterprise/knowledge-result/index.tsx +++ b/langgraph/src/agent-uis/enterprise/knowledge-result/index.tsx @@ -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(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 (
{/* Header */} @@ -77,8 +96,14 @@ export default function KnowledgeResult({ {citations && citations.length > 0 && (
{citations.map(c => ( -
- [{c.index}] +
+ [{c.index}] {c.url ? {c.title} : {c.title}} {c.source && {c.source}} @@ -86,11 +111,30 @@ export default function KnowledgeResult({ ))}
)} -
+
+ {/* Dedicated quick actions for this card */} +
+ + +
diff --git a/langgraph/src/agent-uis/enterprise/next-actions/index.tsx b/langgraph/src/agent-uis/enterprise/next-actions/index.tsx index a8fa320..44b7890 100644 --- a/langgraph/src/agent-uis/enterprise/next-actions/index.tsx +++ b/langgraph/src/agent-uis/enterprise/next-actions/index.tsx @@ -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) => { diff --git a/langgraph/src/agent-uis/enterprise/ticket-detail/index.tsx b/langgraph/src/agent-uis/enterprise/ticket-detail/index.tsx index 25e6f0d..7ab1b58 100644 --- a/langgraph/src/agent-uis/enterprise/ticket-detail/index.tsx +++ b/langgraph/src/agent-uis/enterprise/ticket-detail/index.tsx @@ -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 { diff --git a/langgraph/src/agent-uis/enterprise/ticket-summary/index.tsx b/langgraph/src/agent-uis/enterprise/ticket-summary/index.tsx index 8047f0e..c55c819 100644 --- a/langgraph/src/agent-uis/enterprise/ticket-summary/index.tsx +++ b/langgraph/src/agent-uis/enterprise/ticket-summary/index.tsx @@ -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({ )) )} -
+
+ {/* Dedicated quick actions for this card */} +
+ + +
diff --git a/langgraph/src/agent-uis/index.tsx b/langgraph/src/agent-uis/index.tsx index b1e16cd..6f5d3e7 100644 --- a/langgraph/src/agent-uis/index.tsx +++ b/langgraph/src/agent-uis/index.tsx @@ -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; diff --git a/langgraph/src/agent/chat-agent/index.ts b/langgraph/src/agent/chat-agent/index.ts deleted file mode 100644 index 28d1737..0000000 --- a/langgraph/src/agent/chat-agent/index.ts +++ /dev/null @@ -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"; diff --git a/langgraph/src/agent/coder/nodes/tool-executor.ts b/langgraph/src/agent/coder/nodes/tool-executor.ts index fdaf367..f32b61d 100644 --- a/langgraph/src/agent/coder/nodes/tool-executor.ts +++ b/langgraph/src/agent/coder/nodes/tool-executor.ts @@ -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, diff --git a/langgraph/src/agent/enterprise/nodes/agent.ts b/langgraph/src/agent/enterprise/nodes/agent.ts index 4a881c5..94895dd 100644 --- a/langgraph/src/agent/enterprise/nodes/agent.ts +++ b/langgraph/src/agent/enterprise/nodes/agent.ts @@ -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, diff --git a/langgraph/src/agent/enterprise/nodes/tool-defs.ts b/langgraph/src/agent/enterprise/nodes/tool-defs.ts index 3362b4f..936db3c 100644 --- a/langgraph/src/agent/enterprise/nodes/tool-defs.ts +++ b/langgraph/src/agent/enterprise/nodes/tool-defs.ts @@ -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]; diff --git a/langgraph/src/agent/enterprise/nodes/tool-executor.ts b/langgraph/src/agent/enterprise/nodes/tool-executor.ts index f745290..6c3e79b 100644 --- a/langgraph/src/agent/enterprise/nodes/tool-executor.ts +++ b/langgraph/src/agent/enterprise/nodes/tool-executor.ts @@ -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 = { @@ -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; + 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).props as + | Record + | 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( + fn: (signal: AbortSignal) => Promise, + timeoutMs: number, +): Promise { + 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, + 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, 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> | 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 = {}; + 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 = { 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 = {}; @@ -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 = {}; + 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, }; } diff --git a/langgraph/src/agent/enterprise/tools/soc-client.ts b/langgraph/src/agent/enterprise/tools/soc-client.ts index 9c47396..c0f8097 100644 --- a/langgraph/src/agent/enterprise/tools/soc-client.ts +++ b/langgraph/src/agent/enterprise/tools/soc-client.ts @@ -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> { - 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) => 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[] = + 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[] = + 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 = { - 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 = { 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 = { + 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 { diff --git a/langgraph/src/agent/enterprise/types.ts b/langgraph/src/agent/enterprise/types.ts index 5dc2109..ecc89ea 100644 --- a/langgraph/src/agent/enterprise/types.ts +++ b/langgraph/src/agent/enterprise/types.ts @@ -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; diff --git a/langgraph/src/agent/searcher/nodes/tool-executor.ts b/langgraph/src/agent/searcher/nodes/tool-executor.ts index 2acded7..f28b09c 100644 --- a/langgraph/src/agent/searcher/nodes/tool-executor.ts +++ b/langgraph/src/agent/searcher/nodes/tool-executor.ts @@ -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 }), }; } }); diff --git a/langgraph/src/agent/supervisor/index.ts b/langgraph/src/agent/supervisor/index.ts index 3800314..b557863 100644 --- a/langgraph/src/agent/supervisor/index.ts +++ b/langgraph/src/agent/supervisor/index.ts @@ -1,3 +1,4 @@ +import "@/agent/utils/config"; import { StateGraph, START, END } from "@langchain/langgraph"; import { SupervisorAnnotation, diff --git a/langgraph/src/agent/supervisor/nodes/router.ts b/langgraph/src/agent/supervisor/nodes/router.ts index 39f18e6..3289d7a 100644 --- a/langgraph/src/agent/supervisor/nodes/router.ts +++ b/langgraph/src/agent/supervisor/nodes/router.ts @@ -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> { @@ -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]) : ""; diff --git a/langgraph/src/agent/supervisor/types.ts b/langgraph/src/agent/supervisor/types.ts index 48a8299..0cec40f 100644 --- a/langgraph/src/agent/supervisor/types.ts +++ b/langgraph/src/agent/supervisor/types.ts @@ -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)", + }), }); diff --git a/langgraph/src/agent/types.ts b/langgraph/src/agent/types.ts index 0a9281f..8f00cb5 100644 --- a/langgraph/src/agent/types.ts +++ b/langgraph/src/agent/types.ts @@ -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; }; diff --git a/langgraph/src/agent/utils/checkpointer.ts b/langgraph/src/agent/utils/checkpointer.ts index 96368e8..1cf8a16 100644 --- a/langgraph/src/agent/utils/checkpointer.ts +++ b/langgraph/src/agent/utils/checkpointer.ts @@ -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 { 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; } diff --git a/langgraph/src/agent/utils/config.ts b/langgraph/src/agent/utils/config.ts new file mode 100644 index 0000000..17bfbe0 --- /dev/null +++ b/langgraph/src/agent/utils/config.ts @@ -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 = { + 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; diff --git a/langgraph/src/agent/utils/create-llm.ts b/langgraph/src/agent/utils/create-llm.ts index cf882c4..cbb2387 100644 --- a/langgraph/src/agent/utils/create-llm.ts +++ b/langgraph/src/agent/utils/create-llm.ts @@ -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 }, }); diff --git a/langgraph/src/agent/utils/file-service.ts b/langgraph/src/agent/utils/file-service.ts index 6c1f206..7f1258a 100644 --- a/langgraph/src/agent/utils/file-service.ts +++ b/langgraph/src/agent/utils/file-service.ts @@ -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"; diff --git a/langgraph/src/agent/utils/retry.ts b/langgraph/src/agent/utils/retry.ts index 7392d10..6ade6a3 100644 --- a/langgraph/src/agent/utils/retry.ts +++ b/langgraph/src/agent/utils/retry.ts @@ -32,13 +32,15 @@ export async function executeWithRetry( /** * 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 = { const TOOL_ERROR_MAP: Record, 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 { - window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text } })); + window.dispatchEvent( + new CustomEvent("soc:prefill-input", { + detail: { + text, + taskType, + sourceCardId, + sourceLabel: cardTitle, + }, + }), + ); }; return ( diff --git a/langgraph/src/components/CanvasPanel.tsx b/langgraph/src/components/CanvasPanel.tsx index c7a873b..e992db0 100644 --- a/langgraph/src/components/CanvasPanel.tsx +++ b/langgraph/src/components/CanvasPanel.tsx @@ -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 { diff --git a/langgraph/src/components/MessageBubble.tsx b/langgraph/src/components/MessageBubble.tsx index 210b7ec..17b9e18 100644 --- a/langgraph/src/components/MessageBubble.tsx +++ b/langgraph/src/components/MessageBubble.tsx @@ -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 {tag.label}; + } + // Citation chip? Match [N] exactly + const citMatch = /^\[(\d+)\]$/.exec(part); + if (citMatch) { + const num = Number(citMatch[1]); + return ( + + ); + } + 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 (
{/* Language label + copy button */} @@ -69,8 +128,22 @@ function CodeBlock({ }} PreTag="div" > - {children} + {displayedCode} + {isLongCode && ( +
+ + {codeExpanded ? `共 ${lines.length} 行` : `已折叠,共 ${lines.length} 行`} + + +
+ )}
); } @@ -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

{children}

; + const processedChildren = Array.isArray(children) + ? children.flatMap((child, idx) => + typeof child === "string" + ? renderWithSourceBadges(child).map((node, ni) => + typeof node === "string" ? node : {node} + ) + : [child] + ) + : typeof children === "string" + ? renderWithSourceBadges(children) + : children; + return

{processedChildren}

; }, // Links diff --git a/langgraph/src/components/SourceBadge.tsx b/langgraph/src/components/SourceBadge.tsx index 93e3cfd..72cd577 100644 --- a/langgraph/src/components/SourceBadge.tsx +++ b/langgraph/src/components/SourceBadge.tsx @@ -1,4 +1,3 @@ -import React from "react"; const SOURCE_CONFIG: Record = { internal_kb: { label: "内部知识库", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" }, diff --git a/langgraph/src/components/ThreadSidebar.tsx b/langgraph/src/components/ThreadSidebar.tsx index fe665e0..e16fef9 100644 --- a/langgraph/src/components/ThreadSidebar.tsx +++ b/langgraph/src/components/ThreadSidebar.tsx @@ -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 = {}; - 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(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({
{/* New chat button */}
- +
+ + {/* Search toggle icon */} + +
- {/* Search input */} -
- - 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 */} +
+
+ + 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" + /> +
@@ -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 (
-

{itemLabel}

+

16 ? itemLabel : undefined} + > + {displayLabel} +

{formatTime(t.created_at)}

@@ -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="删除对话" > diff --git a/langgraph/src/components/ToolCallStatus.tsx b/langgraph/src/components/ToolCallStatus.tsx index 49c634c..f4fbc4d 100644 --- a/langgraph/src/components/ToolCallStatus.tsx +++ b/langgraph/src/components/ToolCallStatus.tsx @@ -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 = { @@ -30,6 +30,7 @@ const TOOL_LOADING_MAP: Record = { code_execute: "正在执行代码...", code_install: "正在安装依赖...", sandbox_run: "正在运行沙盒...", + chart_generate: "正在生成图表...", doc_create: "正在创建文档...", doc_edit: "正在编辑文档...", doc_translate: "正在翻译文档...", @@ -58,6 +59,17 @@ interface ToolCallStatusProps { components?: Parameters[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 (
@@ -103,7 +116,14 @@ function ToolCallRow({ )} {isFailed ? `${label} · 失败` : isDone ? (canExpand ? `${label} · ${expanded ? "收起" : "查看结果"}` : `${label} · 已完成`) : (tc.name ? (TOOL_LOADING_MAP[tc.name] ?? `正在${label}...`) : "思考中...")} - {expanded && uiItem && stream && components && ( + {expanded && isFailed && ( +
+

+ {(uiItem?.props?.errorMessage as string) ?? "工具执行失败,请稍后重试。"} +

+
+ )} + {expanded && !isFailed && uiItem && stream && components && (
)} - {expanded && !uiItem && ( + {expanded && !isFailed && !uiItem && (
@@ -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(undefined); + const UI_NAME_MAP: Record = { 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 = {}; + const showGlobalToggle = toolCalls.length >= 2; + + function handleGlobalToggle() { + setGlobalExpanded((prev) => { + // If currently collapsed (false) → expand all; otherwise → collapse all + return prev === false ? true : false; + }); + } + return (
+ {/* Global collapse/expand button — only shown when >= 2 tool calls */} + {showGlobalToggle && ( +
+ +
+ )} {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} /> ); })} diff --git a/langgraph/src/index.css b/langgraph/src/index.css index 02db610..bc5c382 100644 --- a/langgraph/src/index.css +++ b/langgraph/src/index.css @@ -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; } +} diff --git a/langgraph/src/main.tsx b/langgraph/src/main.tsx index 1db36d8..a9efd88 100644 --- a/langgraph/src/main.tsx +++ b/langgraph/src/main.tsx @@ -9,10 +9,10 @@ type UIMsgLocal = { id: string; type: string; name: string; props: Record = new Set()): string { + const counts: Record = {}; + 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(null); const scrollContainerRef = useRef(null); const [showScrollBtn, setShowScrollBtn] = useState(false); + const isComposingRef = useRef(false); // Tool & model state const [activeTools, setActiveTools] = useState>(new Set()); @@ -117,6 +139,12 @@ function App() { // File attachment state const [attachedFile, setAttachedFile] = useState(null); + // Source label from card action buttons (e.g. "来自 知识库检索") + const [sourceLabel, setSourceLabel] = useState(null); + + // Pending retry tool name — set by soc:retry-tool, consumed on next submit + const pendingRetryToolRef = useRef(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 (
+ {/* Text reply — rendered first so user sees conclusion before evidence */} + {textContent && ( +
+ + {thread.isLoading && isLastAi && ( +
+ )} + {/* Tool call status (with inline expand/collapse for UI cards) */} {toolCalls.length > 0 && ( !toolCalls.some((tc) => { + {(() => { + const standaloneUiItems = uiItems.filter((ui) => !toolCalls.some((tc) => { const nameMap: Record = { 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) => ( -
+ })); + if (standaloneUiItems.length === 0) return null; + const cards = standaloneUiItems.map((ui: UIMsgLocal) => ( +
- ))} + )); + if (standaloneUiItems.length >= 2) { + return ( +
+ 综合分析 · {standaloneUiItems.length} 项结果 + {cards} +
+ ); + } + return <>{cards}; + })()} - {/* Text reply */} - {textContent && ( -
- - {thread.isLoading && isLastAi && ( -
+

)} {/* 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 ? ( +
+ + + +
+ ) : null; + })()} + {/* Streaming UI cards not yet attached to a completed message */} {thread.isLoading && deduplicateUiItems( @@ -594,7 +722,7 @@ function App() { }) ) .map((ui: UIMsgLocal) => ( -
+
{/* Tool toggles */}
+ {/* Auto chip — active when no tools are selected */} + {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)} > {label} + {value === "auto" && modelMode !== value && ( + 推荐 + )} ))}
@@ -671,6 +821,22 @@ function App() { {attachedFile && ( setAttachedFile(null)} /> )} + {/* Source label: shown when user clicks a card action button */} + {sourceLabel && ( +
+ + 来自 {sourceLabel} + + +
+ )}