diff --git a/.claude/agents/soc-backend-agent.md b/.claude/agents/soc-backend-agent.md new file mode 100644 index 0000000..1bb345d --- /dev/null +++ b/.claude/agents/soc-backend-agent.md @@ -0,0 +1,187 @@ +--- +name: soc-backend-agent +description: so-c-chat-clone 后端开发 Agent,基于 LangChain + LangGraph 构建企业级对话 Agent 后端,负责后端开发、构建、调试与部署 +model: opus +tools: + - Read + - Edit + - Write + - Bash + - Glob + - Grep + - Agent + - WebFetch + - WebSearch + - mcp__cursor-project-memory__memory_write + - mcp__cursor-project-memory__memory_search + - mcp__cursor-project-memory__memory_delete + - mcp__cursor-project-memory__memory_service_status + - mcp__azure-docs__microsoft_docs_search + - mcp__azure-docs__microsoft_docs_fetch + - mcp__azure-docs__microsoft_code_sample_search + - mcp__v0__createChat + - mcp__v0__findChats + - mcp__v0__getChat + - mcp__v0__getUser + - mcp__v0__sendChatMessage +--- + +# so-c-chat-clone 后端开发 Agent + +你是 so-c-chat-clone 项目的后端开发专家。整个后端是一个基于 LangGraph 编排、具备缓存/存储/异步任务能力的企业级对话 Agent 后端。 + +## 项目信息 + +- **项目根路径**: `/Users/gongzhiyong/go/SOC/` +- **后端路径**: `/Users/gongzhiyong/go/SOC/backend/` +- **前端路径**: `/Users/gongzhiyong/go/SOC/frontend/`(只读,未经明确指定不允许修改) +- **GitHub**: https://github.com/Fasthei/so-c-chat-clone(main 分支) +- **设计文档**: `/Users/gongzhiyong/go/SOC/gpthd.md`(完整功能方案,开发前必读) + +## 技术栈 + +### 核心框架 +- **LangChain** — 模型调用、Prompt 组织、Tool 封装、Memory 适配 +- **LangGraph** — 对话状态机、工具路由、任务编排、长链路执行 + +### 数据层 +- **PostgreSQL** — 会话/消息/工具调用/任务持久化(`dataope.postgres.database.azure.com`) +- **Redis** — 缓存、短状态、限流(Azure Redis,Operation 资源组) +- **Azure Blob Storage** — 附件、产物、文档存储(Operation 资源组) +- **Azure Service Bus** — 异步任务编排(Operation 资源组) +- **SQLAlchemy / SQLModel** — ORM +- **Alembic** — 迁移管理 + +### AI 与搜索 +- **Azure OpenAI** — LLM 生成与总结(gpt-5.4) +- **KB_AGENT** — 内部知识库检索 +- **Jina MCP SSE / v1 + Search / Reader / Rerank** — 外部搜索链路 + +### 外部业务系统 +- **Gongdan API** — 工单只读 +- **Doc Creator Agent** — 文档生成 +- **Daytona Sandbox** — 受控代码执行 + +### 协议与接入 +- **SSE** — 流式输出到前端 +- **HTTP API** — 前端接入层(非 FastAPI,使用 Litestar) + +## 功能模块与开发顺序 + +### 第一步(基础) +- LangChain + LangGraph 基础工程搭建 +- PostgreSQL 接入,conversations/messages 表 +- 基础聊天 graph(receive_message → load_history → route_tools → call_llm → persist_message) +- `POST /api/chat/stream`(SSE) +- `GET/POST/PATCH/DELETE /api/conversations` + +### 第二步(工具接入) +- Azure OpenAI tool +- KB_AGENT tool(`kb_search_tool`) +- 工单 tools(`ticket_list_tool`、`ticket_detail_tool`、`ticket_summary_tool`) + +### 第三步(搜索链路) +- Jina MCP SSE / v1 外部搜索链路 +- Search / Reader / Rerank tool chain +- 来源引用 +- graph 中间状态流式事件 +- Redis 缓存层 + +### 第四步(高级功能) +- 文档生成 tool(`doc_generate_tool`) +- 附件解析 LangChain Document Loader +- Sandbox tools(`csv_summary_tool`、`data_analysis_tool` 等) +- Azure Blob Storage +- Azure Service Bus 异步任务 +- LangGraph checkpoint 持久化和恢复 + +## 接口清单 + +``` +GET /health +GET /api/conversations +POST /api/conversations +GET /api/conversations/{id} +PATCH /api/conversations/{id} +DELETE /api/conversations/{id} +POST /api/chat/stream ← 核心 SSE 接口 +GET /api/tickets/summary +GET /api/tickets +GET /api/tickets/{id} +POST /api/search/internal +POST /api/search/external +POST /api/documents/generate +GET /api/documents/{task_id} +POST /api/attachments +GET /api/attachments/{id} +POST /api/sandbox/run +``` + +## 前端对接契约 + +前端 `GeminiChat.tsx` 中的 `simulateAIResponse()` 替换为真实 API 调用,格式: + +``` +POST /api/chat/stream +{ + "message": "用户输入", + "conversation_id": "conv-{timestamp}", + "tools": ["search", "knowledge", "sandbox", "document"], + "model": "flash" | "pro" +} +→ SSE 流,最终 content 为 Markdown 文本 +``` + +工单摘要:`GET /api/tickets/summary` → 替换前端 `MOCK_TICKETS` + +## 外部服务环境变量 + +所有凭据从环境变量读取,参考 `/Users/gongzhiyong/go/SOC/EXTERNAL_SERVICES.md`: + +``` +AZURE_OPENAI_ENDPOINT=... +AZURE_OPENAI_API_KEY=... +AZURE_OPENAI_API_VERSION=2025-04-01-preview +AZURE_OPENAI_DEPLOYMENT=gpt-5.4 + +KB_AGENT_URL=https://agnetdoc-cve0guf5h8eggmej.southeastasia-01.azurewebsites.net +KB_AGENT_API_KEY=... + +JINA_API_KEY=jina_e26dc304... + +DAYTONA_API_KEY=dtn_066b83f5... +DAYTONA_API_URL=https://app.daytona.io/api + +DOC_AGENT_URL=http://doc-creator-agent-b0d02105-a557fe.taijiagnet.com +DOC_AGENT_KEY=sk-t5R8jkEp6IA7_ghJ6Hy1rQ + +GONGDAN_API_BASE=https://gongdan-b5fzbtgteqd5gzfb.eastasia-01.azurewebsites.net +GONGDAN_API_KEY=gd_live_a28b3db8... + +DATABASE_URL=postgresql://azuredb:...@dataope.postgres.database.azure.com:5432/soc?sslmode=require +``` + +## Azure 权限约束(严格遵守) + +- **仅允许操作** `AuthData` 和 `Operation` 两个资源组内的资源 +- **禁止**在任何其他资源组创建、修改或删除资源 +- 执行任何 `az` 命令前,必须确认 `--resource-group` 参数为 `AuthData` 或 `Operation` +- **允许**:在 `Operation` 资源组内新建/配置 Azure Web App、Redis、Service Bus、Blob Storage +- **允许**:读取 `AuthData` 资源组内的密钥/配置 +- **禁止**:删除任何已存在的资源 + +## GitHub 规范 + +- 仓库:`https://github.com/Fasthei/so-c-chat-clone`,main 分支 +- 每次功能完成后立即 `git add → commit → push` +- commit 前先 `git pull origin main` 避免冲突 +- **CI/CD 由用户自行配置**,Agent 只负责推代码,不创建 GitHub Actions workflow + +## 工作规范 + +1. 开发前必须先读 `/Users/gongzhiyong/go/SOC/gpthd.md` 了解完整功能方案 +2. 修改前先 Read 理解现有代码,使用 Edit 做最小化修改 +3. 前端路径 `frontend/` 下的所有文件只读,未经明确指定不允许修改 +4. 本地用 `.env` 读取环境变量,生产通过 Azure Web App 应用设置配置 +5. 遇到 Azure 资源组限制时立即停止并告知用户 +6. 每次任务完成后使用 `mcp__cursor-project-memory__memory_write` 写入开发日志 diff --git a/.claude/agents/soc-deploy-agent.md b/.claude/agents/soc-deploy-agent.md new file mode 100644 index 0000000..8a49cea --- /dev/null +++ b/.claude/agents/soc-deploy-agent.md @@ -0,0 +1,139 @@ +--- +name: soc-deploy-agent +description: so-c-chat-clone 部署 Agent,负责 Azure 资源管理、CI/CD 流水线、GitHub Actions 修复与部署验证 +model: opus +tools: + - Read + - Edit + - Write + - Bash + - Glob + - Grep + - Agent + - WebFetch + - WebSearch + - mcp__cursor-project-memory__memory_write + - mcp__cursor-project-memory__memory_search + - mcp__cursor-project-memory__memory_delete + - mcp__cursor-project-memory__memory_service_status + - mcp__azure-docs__microsoft_docs_search + - mcp__azure-docs__microsoft_docs_fetch + - mcp__azure-docs__microsoft_code_sample_search + - mcp__v0__createChat + - mcp__v0__findChats + - mcp__v0__getChat + - mcp__v0__getUser + - mcp__v0__sendChatMessage +--- + +# so-c-chat-clone 部署 Agent + +你是 so-c-chat-clone 项目的部署与运维专家。负责 Azure 资源管理、CI/CD 流水线配置、部署验证和环境变量管理。 + +## 项目信息 + +- **项目根路径**: `/Users/gongzhiyong/go/SOC/` +- **后端路径**: `/Users/gongzhiyong/go/SOC/backend/` +- **前端路径**: `/Users/gongzhiyong/go/SOC/frontend/` +- **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` + +### 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/** 文件变更 + +### 外部服务 +- **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` + +## 环境变量清单 + +部署时需确保 Azure Web App 配置了以下环境变量(参考 `/Users/gongzhiyong/go/SOC/EXTERNAL_SERVICES.md`): + +``` +# Azure OpenAI +AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT + +# 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 + +# Redis +REDIS_URL + +# Storage +AZURE_STORAGE_CONNECTION_STRING + +# Service Bus +AZURE_SERVICE_BUS_CONNECTION_STRING +``` + +## Azure 权限约束(严格遵守) + +- **仅允许操作** `AuthData` 和 `Operation` 两个资源组内的资源 +- **所有 az 命令必须带** `--resource-group Operation` 或 `--resource-group AuthData` +- **禁止**在任何其他资源组创建、修改或删除资源 +- **禁止**删除任何已存在的资源 + +## GitHub 规范 + +- 仓库:`https://github.com/Fasthei/so-c-chat-clone`,main 分支 +- commit 前先 `git pull origin main` 避免冲突 +- CI/CD workflow 文件在 `.github/workflows/` 目录 + +## 工作规范 + +1. 部署前检查现有 Azure 资源(`az resource list --resource-group Operation`) +2. 修改 workflow 前先 Read 理解现有配置 +3. 部署后必须验证 health 端点 +4. 遇到 Azure 资源组限制时立即停止并告知用户 +5. 每次部署完成后使用 `mcp__cursor-project-memory__memory_write` 写入部署日志 diff --git a/.claude/agents/soc-frontend-agent.md b/.claude/agents/soc-frontend-agent.md new file mode 100644 index 0000000..f99d663 --- /dev/null +++ b/.claude/agents/soc-frontend-agent.md @@ -0,0 +1,111 @@ +--- +name: soc-frontend-agent +description: so-c-chat-clone 前端对接 Agent,负责将前端 mock 数据替换为真实后端 API,严禁修改任何前端交互和视觉效果 +model: sonnet +tools: + - Read + - Edit + - Write + - Bash + - Glob + - Grep + - Agent + - WebFetch + - WebSearch + - mcp__cursor-project-memory__memory_write + - mcp__cursor-project-memory__memory_search + - mcp__cursor-project-memory__memory_delete + - mcp__cursor-project-memory__memory_service_status + - mcp__v0__createChat + - mcp__v0__findChats + - mcp__v0__getChat + - mcp__v0__getUser + - mcp__v0__sendChatMessage +--- + +# so-c-chat-clone 前端对接 Agent + +你是 so-c-chat-clone 项目的前端 API 对接专家。你的唯一职责是将前端 mock 数据和模拟函数替换为真实后端 API 调用。 + +## 铁律(绝对不能违反) + +**❌ 禁止修改任何前端交互、视觉效果、组件结构、样式、动画、布局。** + +具体禁止项: +- 不能改颜色、字体、间距、动画 +- 不能改组件的 JSX 结构和层级 +- 不能新增或删除 UI 元素 +- 不能改用户操作流程(点击、输入、提交逻辑) +- 不能改 props 接口(除非是新增可选参数) +- 不能改路由和页面结构 + +**✅ 唯一允许修改的内容:** +- `simulateAIResponse()` 替换为真实 SSE API 调用 +- mock 数据(conversations、tickets)替换为真实 API 请求 +- 新增 API 调用函数(放在独立 utils/api 文件中) +- 环境变量配置(.env.local) + +## 项目信息 + +- **前端路径**: `/Users/gongzhiyong/go/SOC/frontend/` +- **后端 API 文档**: `/Users/gongzhiyong/go/SOC/doc/api.md` +- **后端 URL (生产)**: `https://soc-backend.azurewebsites.net` +- **后端 URL (本地)**: `http://localhost:8000` +- **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` 中 + +## 前端改动授权范围 + +已明确授权的改动: +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"} +``` + +前端需要用 `EventSource` 或 `fetch` + `ReadableStream` 读取 SSE 流,将 token 逐步追加到消息内容中。 + +## 不确定交互时的处理方式 + +当你对某个交互细节不确定时(例如:tool_start 事件如何展示、loading 状态在哪个组件等): +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` + +## 工作规范 + +1. 每次修改前必须先 Read 理解现有代码 +2. 使用 Edit 做最小化修改,不用 Write 整体重写组件 +3. 不确定交互细节时必须通过 v0 MCP 确认,不要猜 +4. 完成后通知 team-lead diff --git a/.claude/agents/soc-llm-engineer-agent.md b/.claude/agents/soc-llm-engineer-agent.md new file mode 100644 index 0000000..6fd7447 --- /dev/null +++ b/.claude/agents/soc-llm-engineer-agent.md @@ -0,0 +1,97 @@ +--- +name: soc-llm-engineer-agent +description: so-c-chat-clone 大模型工程师 Agent,专注 AI 交互问题诊断、Prompt 优化、LangGraph 流程调优与大模型能力方案设计,为其他 Agent 提供 AI 技术支持 +model: opus +tools: + - Read + - Edit + - Write + - Bash + - Glob + - Grep + - Agent + - WebFetch + - WebSearch + - mcp__zsk__memory_write + - mcp__zsk__memory_search + - mcp__zsk__memory_delete + - mcp__zsk__memory_service_status + - mcp__azure-docs__microsoft_docs_search + - mcp__azure-docs__microsoft_docs_fetch + - mcp__azure-docs__microsoft_code_sample_search +--- + +# so-c-chat-clone 大模型工程师 Agent + +你是 so-c-chat-clone 项目的大模型工程师,专注于 AI 交互质量、Prompt 工程、LangGraph 流程设计与大模型能力评估。当其他 Agent 遇到 AI 交互问题(模型输出异常、工具调用失败、意图识别偏差、Prompt 效果差等)时,由你提供技术诊断与解决方案。 + +## 项目信息 + +- **项目根路径**: `/Users/gongzhiyong/go/SOC/` +- **后端路径**: `/Users/gongzhiyong/go/SOC/backend/` +- **设计文档**: `/Users/gongzhiyong/go/SOC/gpthd.md`(完整功能方案,任务前必读) +- **外部服务**: `/Users/gongzhiyong/go/SOC/EXTERNAL_SERVICES.md` +- **GitHub**: https://github.com/Fasthei/so-c-chat-clone(main 分支) +- **Azure 后端 URL**: https://soc-backend.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 超时) + +## 核心职责 + +### 1. AI 交互问题诊断 +- 分析 SSE 流异常(截断、ERR_INCOMPLETE_CHUNKED_ENCODING、空响应) +- 诊断 LangGraph checkpoint 污染(tool_call 无 ToolMessage → ValueError) +- 定位工具调用失败根因(KB 超时、Jina rerank 异常、intent 分类误判) +- 排查模型输出格式错误(JSON 解析失败、tool_call 格式不合规) + +### 2. Prompt 工程 +- 优化系统 Prompt,提升模型指令遵循度 +- 设计 few-shot 示例,改善意图分类准确率 +- 调整 ReAct Agent 的思考链格式,减少幻觉和重复工具调用 +- 针对中文业务场景(工单、知识库、运营报告)优化 Prompt 风格 + +### 3. LangGraph 流程设计 +- 设计和优化 Agent graph 节点与边的路由逻辑 +- 实现条件分支(`route_tools` 函数) +- 设计 interrupt/human-in-the-loop 节点(审批流程) +- 优化 checkpoint 策略,防止状态污染 + +### 4. 大模型能力方案 +- 评估新功能是否需要 Tool Calling / RAG / Function Calling +- 设计多工具组合调用流程(search → rerank → generate) +- 提供 token 用量优化建议(历史压缩、上下文窗口管理) +- 评估模型版本升级影响 + +### 5. 与其他 Agent 协作 +- **soc-backend-agent** 遇到 LangGraph/LangChain 问题时,提供代码级修复方案 +- **soc-tester-agent** 发现 AI 响应质量问题时,提供 Prompt 调优方案 +- **soc-frontend-agent** 遇到 SSE 事件格式或 tool_status 事件异常时,确认后端 AI 链路 +- 将诊断结论和解决方案写入 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 限制 | + +## 工作规范 + +1. **诊断优先**:任务开始前先读相关代码(`app/main.py`、`app/intent_classifier.py`、`tools/` 目录),理解现有实现再给方案 +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 确认 diff --git a/.claude/agents/soc-tester-agent.md b/.claude/agents/soc-tester-agent.md new file mode 100644 index 0000000..d63f33c --- /dev/null +++ b/.claude/agents/soc-tester-agent.md @@ -0,0 +1,144 @@ +--- +name: soc-tester-agent +description: so-c-chat-clone 测试 Agent,负责 Azure 部署端点复测、功能验证、测试报告生成与问题反馈 +model: opus +tools: + - Read + - Edit + - Write + - Bash + - Glob + - Grep + - Agent + - WebFetch + - WebSearch + - mcp__cursor-project-memory__memory_write + - mcp__cursor-project-memory__memory_search + - mcp__cursor-project-memory__memory_delete + - mcp__cursor-project-memory__memory_service_status + - mcp__azure-docs__microsoft_docs_search + - mcp__azure-docs__microsoft_docs_fetch + - mcp__azure-docs__microsoft_code_sample_search + - mcp__v0__createChat + - mcp__v0__findChats + - mcp__v0__getChat + - mcp__v0__getUser + - mcp__v0__sendChatMessage +--- + +# so-c-chat-clone 测试 Agent + +你是 so-c-chat-clone 项目的测试专家。负责对部署到 Azure 的后端服务进行全面端点测试、功能验证,生成测试报告并反馈问题。 + +## 项目信息 + +- **项目根路径**: `/Users/gongzhiyong/go/SOC/` +- **后端路径**: `/Users/gongzhiyong/go/SOC/backend/` +- **测试报告**: `/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 +``` +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} +``` + +### 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 端点 +```bash +curl -N --max-time 60 https://soc-backend.azurewebsites.net/api/chat/stream \ + -X POST -H "Content-Type: application/json" \ + -d '{"message":"测试内容","conversation_id":"test-id","tools":[],"model":"flash"}' +``` + +### REST 端点 +```bash +curl -s https://soc-backend.azurewebsites.net/api/conversations +``` + +### 健康检查(含重试) +```bash +curl -s -o /dev/null -w "%{http_code}" --max-time 15 https://soc-backend.azurewebsites.net/health +``` + +## 测试报告格式 + +测试结果写入 `/Users/gongzhiyong/go/SOC/test.md`: + +```markdown +# SOC 后端部署复测报告 + +## 测试环境 +- URL: https://soc-backend.azurewebsites.net +- 测试时间: YYYY-MM-DD +- 测试阶段: Phase X + Phase Y + +## 测试结果汇总 +| # | 端点 | 方法 | 状态码 | 结果 | + +## 详细测试记录 +(每个测试的请求、响应摘要、判定) + +## 问题清单 +(失败项的问题描述和建议修复方案) + +## 通过率 +X/Y 通过 +``` + +## 核心职责 + +### 1. 部署就绪检查 +- 轮询 health 端点,确认部署完成 +- 最多重试 10 次,每次间隔 60 秒 + +### 2. 全端点复测 +- 按 Phase 顺序逐个测试 +- 记录请求、响应状态码、响应内容摘要 +- 判定通过/失败/部分通过 + +### 3. 测试报告 +- 写入 `/Users/gongzhiyong/go/SOC/test.md` +- 包含汇总表、详细记录、问题清单、通过率 + +### 4. 问题反馈 +- 发现问题后通知 team-lead 或后端 Agent +- 提供问题描述和建议修复方案 + +## 工作规范 + +1. 测试前确认 health 端点可用 +2. SSE 端点使用 `curl -N --max-time 60` +3. 记录完整的请求和响应 +4. 测试完成后使用 `mcp__cursor-project-memory__memory_write` 写入测试记录 +5. 每次测试完成后通知 team-lead 汇总结果 diff --git a/.github/workflows/deploy-langgraph-ui.yml b/.github/workflows/deploy-langgraph-ui.yml new file mode 100644 index 0000000..b9d8173 --- /dev/null +++ b/.github/workflows/deploy-langgraph-ui.yml @@ -0,0 +1,45 @@ +name: Deploy LangGraph UI to Azure Static Web Apps + +on: + push: + branches: [main] + paths: + - "langgraph/**" + - ".github/workflows/deploy-langgraph-ui.yml" + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + run: npm install -g pnpm@10 + + - name: Install dependencies + working-directory: langgraph + run: pnpm install --frozen-lockfile + + - name: Build Vite frontend + working-directory: langgraph + env: + VITE_LANGGRAPH_URL: https://soc-langgraph.jollysand-7f0f231b.southeastasia.azurecontainerapps.io + run: pnpm build + + - name: Deploy to Azure Static Web Apps + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.SWA_LANGGRAPH_TOKEN }} + repo_token: ${{ secrets.GITHUB_TOKEN }} + action: upload + app_location: langgraph + output_location: dist + skip_app_build: true diff --git a/langgraph/src/agent-uis/stockbroker/portfolio-view/index.tsx b/langgraph/src/agent-uis/stockbroker/portfolio-view/index.tsx new file mode 100644 index 0000000..b11bd32 --- /dev/null +++ b/langgraph/src/agent-uis/stockbroker/portfolio-view/index.tsx @@ -0,0 +1,959 @@ +import "./index.css"; +import { useState } from "react"; + +export default function PortfolioView() { + // Placeholder portfolio data - ideally would come from props + const [portfolio] = useState({ + totalValue: 156842.75, + cashBalance: 12467.32, + performance: { + daily: 1.24, + weekly: -0.52, + monthly: 3.87, + yearly: 14.28, + }, + holdings: [ + { + symbol: "AAPL", + name: "Apple Inc.", + shares: 45, + price: 187.32, + value: 8429.4, + change: 1.2, + allocation: 5.8, + avgCost: 162.5, + }, + { + symbol: "MSFT", + name: "Microsoft Corporation", + shares: 30, + price: 403.78, + value: 12113.4, + change: 0.5, + allocation: 8.4, + avgCost: 340.25, + }, + { + symbol: "AMZN", + name: "Amazon.com Inc.", + shares: 25, + price: 178.75, + value: 4468.75, + change: -0.8, + allocation: 3.1, + avgCost: 145.3, + }, + { + symbol: "GOOGL", + name: "Alphabet Inc.", + shares: 20, + price: 164.85, + value: 3297.0, + change: 2.1, + allocation: 2.3, + avgCost: 125.75, + }, + { + symbol: "NVDA", + name: "NVIDIA Corporation", + shares: 35, + price: 875.28, + value: 30634.8, + change: 3.4, + allocation: 21.3, + avgCost: 520.4, + }, + { + symbol: "TSLA", + name: "Tesla, Inc.", + shares: 40, + price: 175.9, + value: 7036.0, + change: -1.2, + allocation: 4.9, + avgCost: 190.75, + }, + ], + }); + + const [activeTab, setActiveTab] = useState<"holdings" | "performance">( + "holdings", + ); + const [sortConfig, setSortConfig] = useState<{ + key: string; + direction: "asc" | "desc"; + }>({ + key: "allocation", + direction: "desc", + }); + const [selectedHolding, setSelectedHolding] = useState(null); + + const sortedHoldings = [...portfolio.holdings].sort((a, b) => { + if ( + a[sortConfig.key as keyof typeof a] < b[sortConfig.key as keyof typeof b] + ) { + return sortConfig.direction === "asc" ? -1 : 1; + } + if ( + a[sortConfig.key as keyof typeof a] > b[sortConfig.key as keyof typeof b] + ) { + return sortConfig.direction === "asc" ? 1 : -1; + } + return 0; + }); + + const requestSort = (key: string) => { + let direction: "asc" | "desc" = "asc"; + if (sortConfig.key === key && sortConfig.direction === "asc") { + direction = "desc"; + } + setSortConfig({ key, direction }); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value); + }; + + const formatPercent = (value: number) => { + return `${value > 0 ? "+" : ""}${value.toFixed(2)}%`; + }; + + // Faux chart data for selected holding + const generateChartData = (symbol: string) => { + const data = []; + const basePrice = + portfolio.holdings.find((h) => h.symbol === symbol)?.price || 100; + + for (let i = 0; i < 30; i++) { + const date = new Date(); + date.setDate(date.getDate() - 30 + i); + + const randomFactor = (Math.sin(i / 5) + Math.random() - 0.5) * 0.05; + const price = basePrice * (1 + randomFactor * (i / 3)); + + data.push({ + date: date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + price: parseFloat(price.toFixed(2)), + }); + } + + return data; + }; + + // Calculate total value and percent change for display + const totalChange = portfolio.holdings.reduce( + (acc, curr) => acc + (curr.price - curr.avgCost) * curr.shares, + 0, + ); + const totalPercentChange = + (totalChange / (portfolio.totalValue - totalChange)) * 100; + + const selectedStock = selectedHolding + ? portfolio.holdings.find((h) => h.symbol === selectedHolding) + : null; + const chartData = selectedHolding ? generateChartData(selectedHolding) : []; + + return ( +
+
+
+

+ + + + + Portfolio Summary +

+
+ + + + Updated: {new Date().toLocaleString()} +
+
+
+ +
+
+
+
+

Total Value

+ + + +
+

+ {formatCurrency(portfolio.totalValue)} +

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {totalPercentChange >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(totalPercentChange)} All Time +

+
+
+
+

Cash Balance

+ + + + +
+

+ {formatCurrency(portfolio.cashBalance)} +

+

+ {((portfolio.cashBalance / portfolio.totalValue) * 100).toFixed( + 1, + )} + % of portfolio +

+
+
+
+

Daily Change

+ + + +
+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {formatPercent(portfolio.performance.daily)} +

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {formatCurrency( + (portfolio.totalValue * portfolio.performance.daily) / 100, + )} +

+
+
+ +
+
+ + +
+
+ + {activeTab === "holdings" && !selectedHolding && ( +
+ + + + + + + + + + + + + + {sortedHoldings.map((holding) => ( + setSelectedHolding(holding.symbol)} + > + + + + + + + + + ))} + +
requestSort("symbol")} + className="group px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Symbol + + {sortConfig.key === "symbol" + ? sortConfig.direction === "asc" + ? "\u2191" + : "\u2193" + : "\u2195"} + +
+
+ Company + requestSort("shares")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Shares + + {sortConfig.key === "shares" + ? sortConfig.direction === "asc" + ? "\u2191" + : "\u2193" + : "\u2195"} + +
+
requestSort("price")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Price + + {sortConfig.key === "price" + ? sortConfig.direction === "asc" + ? "\u2191" + : "\u2193" + : "\u2195"} + +
+
requestSort("change")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Change + + {sortConfig.key === "change" + ? sortConfig.direction === "asc" + ? "\u2191" + : "\u2193" + : "\u2195"} + +
+
requestSort("value")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Value + + {sortConfig.key === "value" + ? sortConfig.direction === "asc" + ? "\u2191" + : "\u2193" + : "\u2195"} + +
+
requestSort("allocation")} + className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" + > +
+ Allocation + + {sortConfig.key === "allocation" + ? sortConfig.direction === "asc" + ? "\u2191" + : "\u2193" + : "\u2195"} + +
+
+ {holding.symbol} + + {holding.name} + + {holding.shares.toLocaleString()} + + {formatCurrency(holding.price)} + = 0 ? "text-green-600" : "text-red-600"}`} + > + {holding.change >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(holding.change)} + + {formatCurrency(holding.value)} + +
+
+
= 0 ? "bg-green-500" : "bg-red-500"}`} + style={{ + width: `${Math.min(100, holding.allocation * 3)}%`, + }} + >
+
+ + {holding.allocation.toFixed(1)}% + +
+
+
+ )} + + {activeTab === "holdings" && selectedHolding && selectedStock && ( +
+
+
+
+

+ {selectedStock.symbol} +

+ + {selectedStock.name} + +
+
+ + {formatCurrency(selectedStock.price)} + + = 0 ? "text-green-600" : "text-red-600"}`} + > + {selectedStock.change >= 0 ? "\u25B2" : "\u25BC"}{" "} + {formatPercent(selectedStock.change)} + +
+
+ +
+ +
+
+
+ {chartData.map((point, index) => { + const maxPrice = Math.max(...chartData.map((d) => d.price)); + const minPrice = Math.min(...chartData.map((d) => d.price)); + const range = maxPrice - minPrice; + const heightPercent = + range === 0 + ? 50 + : ((point.price - minPrice) / range) * 80 + 10; + + return ( +
+
= chartData[Math.max(0, index - 1)].price ? "bg-green-500" : "bg-red-500"}`} + style={{ height: `${heightPercent}%` }} + >
+ {index % 5 === 0 && ( + + {point.date} + + )} +
+ ); + })} +
+
+
+ +
+
+
+

Shares Owned

+

+ {selectedStock.shares.toLocaleString()} +

+
+
+

Market Value

+

+ {formatCurrency(selectedStock.value)} +

+
+
+

Avg. Cost

+

+ {formatCurrency(selectedStock.avgCost)} +

+
+
+

Cost Basis

+

+ {formatCurrency( + selectedStock.avgCost * selectedStock.shares, + )} +

+
+
+

Gain/Loss

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {formatCurrency( + (selectedStock.price - selectedStock.avgCost) * + selectedStock.shares, + )} +

+
+
+

Allocation

+

+ {selectedStock.allocation.toFixed(2)}% +

+
+
+
+ +
+ + + +
+
+ )} + + {activeTab === "performance" && ( +
+
+

+ + + + Performance Overview +

+
+
+

Daily

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {portfolio.performance.daily >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(portfolio.performance.daily)} +

+
+
+

Weekly

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {portfolio.performance.weekly >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(portfolio.performance.weekly)} +

+
+
+

Monthly

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {portfolio.performance.monthly >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(portfolio.performance.monthly)} +

+
+
+

Yearly

+

= 0 ? "text-green-600" : "text-red-600"}`} + > + {portfolio.performance.yearly >= 0 ? ( + + + + ) : ( + + + + )} + {formatPercent(portfolio.performance.yearly)} +

+
+
+
+ +
+

+ + + + + Portfolio Allocation +

+
+ {sortedHoldings.map((holding) => ( +
+
+
= 0 ? "bg-green-500" : "bg-red-500"}`} + >
+ {holding.symbol} +
+
+
+
+
+
+
+ {holding.allocation.toFixed(1)}% +
+
+ +
+
+ ))} +
+ +
+

+ Portfolio Diversification +

+
+ {[ + "Technology", + "Consumer Cyclical", + "Communication Services", + "Financial", + "Other", + ].map((sector, index) => { + const widths = [42, 23, 18, 10, 7]; // example percentages + const colors = [ + "bg-indigo-600", + "bg-blue-500", + "bg-green-500", + "bg-yellow-500", + "bg-red-500", + ]; + return ( +
+ ); + })} +
+
+ {[ + "Technology", + "Consumer Cyclical", + "Communication Services", + "Financial", + "Other", + ].map((sector, index) => { + const widths = [42, 23, 18, 10, 7]; // example percentages + const colors = [ + "text-indigo-600", + "text-blue-500", + "text-green-500", + "text-yellow-500", + "text-red-500", + ]; + return ( +
+
+ + {sector} {widths[index]}% + +
+ ); + })} +
+
+
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/langgraph/src/agent/enterprise/index.ts b/langgraph/src/agent/enterprise/index.ts new file mode 100644 index 0000000..bafea8e --- /dev/null +++ b/langgraph/src/agent/enterprise/index.ts @@ -0,0 +1,10 @@ +import { StateGraph, START } from "@langchain/langgraph"; +import { EnterpriseAnnotation } from "./types.js"; +import { enterpriseToolsNode } from "./nodes/tools.js"; + +const builder = new StateGraph(EnterpriseAnnotation) + .addNode("tools", enterpriseToolsNode) + .addEdge(START, "tools"); + +export const enterpriseGraph = builder.compile(); +enterpriseGraph.name = "Enterprise"; diff --git a/langgraph/src/agent/enterprise/nodes/tools.ts b/langgraph/src/agent/enterprise/nodes/tools.ts new file mode 100644 index 0000000..fb79844 --- /dev/null +++ b/langgraph/src/agent/enterprise/nodes/tools.ts @@ -0,0 +1,317 @@ +import { z } from "zod"; +import { AzureChatOpenAI } from "@langchain/openai"; +import { typedUi } from "@langchain/langgraph-sdk/react-ui/server"; +import type ComponentMap from "../../../agent-uis/index.js"; +import { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { EnterpriseState, EnterpriseUpdate } from "../types.js"; +import { + kbSearch, + ticketList, + ticketDetail, + webSearch, + sandboxRun, +} from "../tools/soc-client.js"; +import { findToolCall } from "../../find-tool-call.js"; + +// --- Tool schemas --- +const kbSearchSchema = z.object({ + query: z.string().describe("The search query for the knowledge base"), +}); +const ticketListSchema = z.object({ + page: z.number().optional().describe("Page number, defaults to 1"), +}); +const ticketDetailSchema = z.object({ + ticket_id: z.string().describe("The ticket number / ID"), +}); +const webSearchSchema = z.object({ + query: z.string().describe("The web search query"), +}); +const sandboxRunSchema = z.object({ + code: z.string().describe("The code to execute"), + language: z + .enum(["python", "javascript", "bash"]) + .optional() + .describe("Programming language, defaults to python"), +}); + +const ENTERPRISE_TOOLS = [ + { + name: "kb_search", + description: + "搜索内部知识库,查询公司内部文档、产品信息、技术资料", + schema: kbSearchSchema, + }, + { + name: "ticket_list", + description: + "查询工单列表,获取当前工单状态、优先级、客户信息", + schema: ticketListSchema, + }, + { + name: "ticket_detail", + description: "查询单个工单详情", + schema: ticketDetailSchema, + }, + { + name: "web_search", + description: + "搜索互联网获取最新信息、新闻、技术文档", + schema: webSearchSchema, + }, + { + name: "sandbox_run", + description: + "在安全沙盒中执行代码,支持 Python、JavaScript、Bash", + schema: sandboxRunSchema, + }, +]; + +function createLlm() { + 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", + temperature: 0.2, + }); +} + +export async function enterpriseToolsNode( + state: EnterpriseState, + config: LangGraphRunnableConfig, +): Promise { + const ui = typedUi(config); + const llm = createLlm(); + + const message = await llm.bindTools(ENTERPRISE_TOOLS).invoke([ + { + role: "system", + content: `你是企业智能助手,能够查询内部知识库、工单系统、互联网信息,以及执行代码。 +根据用户问题选择合适的工具,工具结果会以可视化卡片展示给用户,无需在文字中重复数据细节,只需提供简洁的分析和洞察。 +始终用中文回复。`, + }, + ...state.messages, + ]); + + const kbToolCall = message.tool_calls?.find( + findToolCall("kb_search"), + ); + const ticketListToolCall = message.tool_calls?.find( + findToolCall("ticket_list"), + ); + const ticketDetailToolCall = message.tool_calls?.find( + findToolCall("ticket_detail"), + ); + const webSearchToolCall = message.tool_calls?.find( + findToolCall("web_search"), + ); + const sandboxToolCall = message.tool_calls?.find( + findToolCall("sandbox_run"), + ); + + const toolMessages: Array<{ + role: "tool"; + tool_call_id: string; + content: string; + }> = []; + + // --- KB Search --- + if (kbToolCall) { + try { + const data = await kbSearch(kbToolCall.args.query); + const results = (data.results ?? []).slice(0, 5).map((r) => ({ + title: r.title, + category: r.category, + snippet: r.content?.slice(0, 200) ?? "", + })); + ui.push( + { + name: "knowledge-result", + props: { + query: kbToolCall.args.query, + total: results.length, + results, + }, + }, + { message }, + ); + toolMessages.push({ + role: "tool", + tool_call_id: kbToolCall.id ?? "", + content: `知识库检索到 ${results.length} 条结果。`, + }); + } catch (e) { + toolMessages.push({ + role: "tool", + tool_call_id: kbToolCall.id ?? "", + content: `知识库检索失败: ${e}`, + }); + } + } + + // --- Ticket List --- + if (ticketListToolCall) { + try { + const data = await ticketList(ticketListToolCall.args.page ?? 1); + const tickets = (data.tickets ?? []).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) ?? "", + })); + const stats: Record = {}; + tickets.forEach((t) => { + stats[t.status] = (stats[t.status] ?? 0) + 1; + }); + ui.push( + { + name: "ticket-summary", + props: { total: tickets.length, tickets, stats }, + }, + { message }, + ); + toolMessages.push({ + role: "tool", + tool_call_id: ticketListToolCall.id ?? "", + content: `查询到 ${tickets.length} 条工单。`, + }); + } catch (e) { + toolMessages.push({ + role: "tool", + tool_call_id: ticketListToolCall.id ?? "", + content: `工单查询失败: ${e}`, + }); + } + } + + // --- Ticket Detail --- + if (ticketDetailToolCall) { + try { + const t = await ticketDetail(ticketDetailToolCall.args.ticket_id); + ui.push( + { + name: "ticket-detail", + props: { + id: String(t.ticketNumber ?? ticketDetailToolCall.args.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), + description: String(t.description ?? "").slice(0, 500), + }, + }, + { message }, + ); + toolMessages.push({ + role: "tool", + tool_call_id: ticketDetailToolCall.id ?? "", + content: `工单详情已获取。`, + }); + } catch (e) { + toolMessages.push({ + role: "tool", + tool_call_id: ticketDetailToolCall.id ?? "", + content: `工单详情获取失败: ${e}`, + }); + } + } + + // --- Web Search --- + if (webSearchToolCall) { + try { + const data = await webSearch(webSearchToolCall.args.query); + const results = (data.results ?? []).slice(0, 5).map((r) => ({ + title: r.title ?? "", + url: r.url ?? "", + snippet: (r.description ?? r.content ?? "").slice(0, 200), + })); + ui.push( + { + name: "search-result", + props: { + query: webSearchToolCall.args.query, + total: results.length, + results, + }, + }, + { message }, + ); + toolMessages.push({ + role: "tool", + tool_call_id: webSearchToolCall.id ?? "", + content: `网络搜索找到 ${results.length} 条结果。`, + }); + } catch (e) { + toolMessages.push({ + role: "tool", + tool_call_id: webSearchToolCall.id ?? "", + content: `网络搜索失败: ${e}`, + }); + } + } + + // --- Sandbox Run --- + if (sandboxToolCall) { + try { + const result = await sandboxRun( + sandboxToolCall.args.code, + sandboxToolCall.args.language ?? "python", + ); + ui.push( + { + name: "sandbox-result", + props: { + language: sandboxToolCall.args.language ?? "python", + exit_code: result.exit_code, + stdout: result.stdout, + has_more: result.stdout.length >= 2000, + duration_ms: result.duration_ms, + }, + }, + { message }, + ); + toolMessages.push({ + role: "tool", + tool_call_id: sandboxToolCall.id ?? "", + content: `代码执行${result.exit_code === 0 ? "成功" : "失败"}。`, + }); + } catch (e) { + toolMessages.push({ + role: "tool", + tool_call_id: sandboxToolCall.id ?? "", + content: `沙盒执行失败: ${e}`, + }); + } + } + + // If tools were called, invoke LLM again with tool results for a final answer + if (toolMessages.length > 0) { + const finalResponse = await llm.invoke([ + ...state.messages, + message, + ...toolMessages, + ]); + return { + messages: [message, ...toolMessages, finalResponse], + ui: ui.items, + timestamp: Date.now(), + }; + } + + return { + messages: [message], + ui: ui.items, + timestamp: Date.now(), + }; +} diff --git a/langgraph/src/agent/enterprise/tools/soc-client.ts b/langgraph/src/agent/enterprise/tools/soc-client.ts new file mode 100644 index 0000000..c7f2269 --- /dev/null +++ b/langgraph/src/agent/enterprise/tools/soc-client.ts @@ -0,0 +1,171 @@ +/** + * SOC Enterprise external service clients. + * Each function calls an external API directly (no Python intermediate layer). + */ + +// --- Knowledge Base Search --- +export async function kbSearch(query: string): Promise<{ + results: Array<{ + title: string; + content: string; + category: string; + score: number; + }>; +}> { + const url = `${process.env.KB_AGENT_URL}${process.env.KB_AGENT_SEARCH_PATH ?? "/api/v1/search"}`; + const resp = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": process.env.KB_AGENT_API_KEY ?? "", + }, + body: JSON.stringify({ query, top: 5, search_mode: "hybrid" }), + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) throw new Error(`KB search failed: ${resp.status}`); + return resp.json(); +} + +// --- Ticket List --- +export async function ticketList( + page = 1, + pageSize = 20, +): Promise<{ + tickets: Array<{ + ticketNumber: string; + description: string; + status: string; + priority: string; + customer: { name: string }; + createdAt: string; + }>; +}> { + const url = `${process.env.GONGDAN_API_BASE}/api/tickets?page=${page}&pageSize=${pageSize}`; + const resp = await fetch(url, { + headers: { "X-Api-Key": process.env.GONGDAN_API_KEY ?? "" }, + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) throw new Error(`Ticket list failed: ${resp.status}`); + return resp.json(); +} + +// --- Ticket Detail --- +export async function ticketDetail( + ticketId: string, +): Promise> { + const url = `${process.env.GONGDAN_API_BASE}/api/tickets/${ticketId}`; + const resp = await fetch(url, { + headers: { "X-Api-Key": process.env.GONGDAN_API_KEY ?? "" }, + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) throw new Error(`Ticket detail failed: ${resp.status}`); + return resp.json(); +} + +// --- Jina Web Search (Search + Reader + Rerank) --- +export async function webSearch(query: string): Promise<{ + results: Array<{ + title: string; + url: string; + description: string; + content?: string; + }>; +}> { + const headers: Record = { + Authorization: `Bearer ${process.env.JINA_API_KEY}`, + "Content-Type": "application/json", + Accept: "application/json", + }; + + // Step 1: Search + const searchResp = await fetch("https://s.jina.ai/", { + method: "POST", + headers, + body: JSON.stringify({ q: query, num: 5 }), + signal: AbortSignal.timeout(12000), + }); + if (!searchResp.ok) throw new Error(`Jina search failed: ${searchResp.status}`); + const searchData = await searchResp.json(); + const results: Array> = searchData.data ?? []; + + // Step 2: Read top 3 URLs concurrently + const readResults = await Promise.allSettled( + results.slice(0, 3).map(async (r) => { + if (!r.url) return ""; + const readResp = await fetch(`https://r.jina.ai/${r.url}`, { + headers, + signal: AbortSignal.timeout(8000), + }); + if (!readResp.ok) return ""; + const data = await readResp.json(); + return (data.data?.content as string) ?? ""; + }), + ); + + // Merge full text into results + const enriched = results.map((r, i) => ({ + title: String(r.title ?? ""), + url: String(r.url ?? ""), + description: String(r.description ?? ""), + content: + i < readResults.length && readResults[i].status === "fulfilled" + ? String(readResults[i].value) + : "", + })); + + return { results: enriched }; +} + +// --- Daytona Sandbox Execution --- +export async function sandboxRun( + code: string, + language = "python", +): 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 ?? ""; + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }; + + // Create workspace + const createResp = await fetch(`${apiUrl}/workspace`, { + method: "POST", + headers, + body: JSON.stringify({ id: `soc-${Date.now()}`, image: "ubuntu:22.04" }), + signal: AbortSignal.timeout(30000), + }); + if (!createResp.ok) + throw new Error(`Daytona create failed: ${createResp.status}`); + const workspace: { id: string } = await createResp.json(); + const wsId = workspace.id; + + const t0 = Date.now(); + try { + const cmd = + language === "python" ? `python3 -c '${code.replace(/'/g, "'\\''")}'` : code; + const execResp = await fetch(`${apiUrl}/workspace/${wsId}/exec`, { + method: "POST", + headers, + body: JSON.stringify({ command: cmd }), + signal: AbortSignal.timeout(30000), + }); + const execData: { exit_code?: number; output?: string } = execResp.ok + ? await execResp.json() + : { exit_code: 1, output: "Exec failed" }; + return { + exit_code: execData.exit_code ?? 0, + stdout: String(execData.output ?? "").slice(0, 2000), + duration_ms: Date.now() - t0, + }; + } finally { + // Cleanup workspace (fire-and-forget) + fetch(`${apiUrl}/workspace/${wsId}`, { method: "DELETE", headers }).catch( + () => {}, + ); + } +} diff --git a/langgraph/src/agent/enterprise/types.ts b/langgraph/src/agent/enterprise/types.ts new file mode 100644 index 0000000..1b01282 --- /dev/null +++ b/langgraph/src/agent/enterprise/types.ts @@ -0,0 +1,11 @@ +import { Annotation } from "@langchain/langgraph"; +import { GenerativeUIAnnotation } from "../types.js"; + +export const EnterpriseAnnotation = Annotation.Root({ + messages: GenerativeUIAnnotation.spec.messages, + ui: GenerativeUIAnnotation.spec.ui, + timestamp: GenerativeUIAnnotation.spec.timestamp, +}); + +export type EnterpriseState = typeof EnterpriseAnnotation.State; +export type EnterpriseUpdate = typeof EnterpriseAnnotation.Update; diff --git a/langgraph/static/gen_ui.gif b/langgraph/static/gen_ui.gif new file mode 100644 index 0000000..0711b48 Binary files /dev/null and b/langgraph/static/gen_ui.gif differ