refactor: replace SOC system with LangGraph.js gen-ui — full cleanup

## Removed (old SOC system)
- backend/ — Python FastAPI + LangGraph Python ReAct agent
- frontend/ — Next.js Gemini-style UI
- config/, doc/ — old documentation
- .github/workflows/deploy-backend.yml
- .github/workflows/deploy-frontend.yml

## Added (new LangGraph.js system)
- langgraph/src/agent/enterprise/ — enterprise agent with 6 tools
  - kb_search → KnowledgeResultCard
  - ticket_list/detail → TicketSummaryCard / TicketDetailCard
  - web_search (Jina Search+Reader) → SearchResultCard
  - sandbox_run (Daytona REST) → SandboxResultCard
- langgraph/src/agent-uis/enterprise/ — UI card components
- .github/workflows/deploy-langgraph-ui.yml — Vite SPA → Azure Static Web App

## Azure Resources
- soc-backend webapp: DELETED
- soc-frontend Static Web App (eastasia): DELETED
- soc-langgraph-ui Static Web App (eastasia): CREATED
  URL: salmon-mushroom-0d8872e00.7.azurestaticapps.net

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-10 04:23:51 +08:00
co-authored by Claude Sonnet 4.6
parent 645f1ecaae
commit 7a3cc140b0
12 changed files with 2191 additions and 0 deletions
+187
View File
@@ -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` 写入开发日志
+139
View File
@@ -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` 写入部署日志
+111
View File
@@ -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 />`
- 核心组件: `GeminiChat.tsx`(拥有所有状态)
- Mock 函数: `simulateAIResponse()` 在 `GeminiChat.tsx` 中
## 前端改动授权范围
已明确授权的改动:
1. `GeminiInput.tsx` 的 `onSubmit` 扩展参数,将 `activeTools`(Set\<string\>)和 `selectedModel`("flash"|"pro")传给后端
2. `GeminiChat.tsx` 的 `handleSend` 接收 tools/model,传入 `/api/chat/stream`
3. 替换 mock conversations 为真实 `GET /api/conversations`
4. 替换 mock tickets 为真实 `GET /api/tickets`
## SSE 对接方式
后端 SSE 事件格式:
```
data: {"type": "token", "content": "..."}
data: {"type": "tool_start", "tool": "kb_search"}
data: {"type": "tool_end", "tool": "kb_search"}
data: {"type": "done"}
```
前端需要用 `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
+97
View File
@@ -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 确认
+144
View File
@@ -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 汇总结果
+45
View File
@@ -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
@@ -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<string | null>(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 (
<div className="w-full max-w-3xl bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200">
<div className="bg-gradient-to-r from-indigo-700 to-indigo-500 px-6 py-4">
<div className="flex justify-between items-center">
<h2 className="text-white font-bold text-xl tracking-tight flex items-center">
<svg
className="w-6 h-6 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
</svg>
Portfolio Summary
</h2>
<div className="bg-indigo-800/50 text-white px-3 py-1 rounded-md text-sm backdrop-blur-sm border border-indigo-400/30 flex items-center">
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clipRule="evenodd"
></path>
</svg>
Updated: {new Date().toLocaleString()}
</div>
</div>
</div>
<div className="p-6 bg-gradient-to-b from-indigo-50 to-white">
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="flex justify-between">
<p className="text-gray-500 text-sm font-medium">Total Value</p>
<svg
className="w-5 h-5 text-indigo-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z"
clipRule="evenodd"
></path>
</svg>
</div>
<p className="text-2xl font-bold text-gray-900 mt-1">
{formatCurrency(portfolio.totalValue)}
</p>
<p
className={`text-xs mt-1 flex items-center ${totalPercentChange >= 0 ? "text-green-600" : "text-red-600"}`}
>
{totalPercentChange >= 0 ? (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(totalPercentChange)} All Time
</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="flex justify-between">
<p className="text-gray-500 text-sm font-medium">Cash Balance</p>
<svg
className="w-5 h-5 text-indigo-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z"></path>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z"
clipRule="evenodd"
></path>
</svg>
</div>
<p className="text-2xl font-bold text-gray-900 mt-1">
{formatCurrency(portfolio.cashBalance)}
</p>
<p className="text-xs mt-1 text-gray-500">
{((portfolio.cashBalance / portfolio.totalValue) * 100).toFixed(
1,
)}
% of portfolio
</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="flex justify-between">
<p className="text-gray-500 text-sm font-medium">Daily Change</p>
<svg
className="w-5 h-5 text-indigo-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
</div>
<p
className={`text-2xl font-bold mt-1 ${portfolio.performance.daily >= 0 ? "text-green-600" : "text-red-600"}`}
>
{formatPercent(portfolio.performance.daily)}
</p>
<p
className={`text-xs mt-1 ${portfolio.performance.daily >= 0 ? "text-green-600" : "text-red-600"}`}
>
{formatCurrency(
(portfolio.totalValue * portfolio.performance.daily) / 100,
)}
</p>
</div>
</div>
<div className="border-b border-gray-200 mb-4">
<div className="flex space-x-4">
<button
onClick={() => {
setActiveTab("holdings");
setSelectedHolding(null);
}}
className={`px-4 py-2 font-medium text-sm focus:outline-none ${
activeTab === "holdings"
? "text-indigo-600 border-b-2 border-indigo-600 font-semibold"
: "text-gray-500 hover:text-gray-700"
}`}
>
Holdings
</button>
<button
onClick={() => {
setActiveTab("performance");
setSelectedHolding(null);
}}
className={`px-4 py-2 font-medium text-sm focus:outline-none ${
activeTab === "performance"
? "text-indigo-600 border-b-2 border-indigo-600 font-semibold"
: "text-gray-500 hover:text-gray-700"
}`}
>
Performance
</button>
</div>
</div>
{activeTab === "holdings" && !selectedHolding && (
<div className="overflow-x-auto rounded-lg border border-gray-200 shadow-sm">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
onClick={() => 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"
>
<div className="flex items-center">
<span>Symbol</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "symbol"
? sortConfig.direction === "asc"
? "\u2191"
: "\u2193"
: "\u2195"}
</span>
</div>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Company
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Shares</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "shares"
? sortConfig.direction === "asc"
? "\u2191"
: "\u2193"
: "\u2195"}
</span>
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Price</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "price"
? sortConfig.direction === "asc"
? "\u2191"
: "\u2193"
: "\u2195"}
</span>
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Change</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "change"
? sortConfig.direction === "asc"
? "\u2191"
: "\u2193"
: "\u2195"}
</span>
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Value</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "value"
? sortConfig.direction === "asc"
? "\u2191"
: "\u2193"
: "\u2195"}
</span>
</div>
</th>
<th
onClick={() => 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"
>
<div className="flex items-center justify-end">
<span>Allocation</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "allocation"
? sortConfig.direction === "asc"
? "\u2191"
: "\u2193"
: "\u2195"}
</span>
</div>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedHoldings.map((holding) => (
<tr
key={holding.symbol}
className="hover:bg-indigo-50 cursor-pointer transition-colors"
onClick={() => setSelectedHolding(holding.symbol)}
>
<td className="px-4 py-4 text-sm font-medium text-indigo-600">
{holding.symbol}
</td>
<td className="px-4 py-4 text-sm text-gray-900">
{holding.name}
</td>
<td className="px-4 py-4 text-sm text-gray-900 text-right">
{holding.shares.toLocaleString()}
</td>
<td className="px-4 py-4 text-sm text-gray-900 text-right">
{formatCurrency(holding.price)}
</td>
<td
className={`px-4 py-4 text-sm text-right font-medium flex items-center justify-end ${holding.change >= 0 ? "text-green-600" : "text-red-600"}`}
>
{holding.change >= 0 ? (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(holding.change)}
</td>
<td className="px-4 py-4 text-sm text-gray-900 text-right font-medium">
{formatCurrency(holding.value)}
</td>
<td className="px-4 py-4 text-right">
<div className="flex items-center justify-end">
<div className="w-16 bg-gray-200 h-2 rounded-full overflow-hidden mr-2">
<div
className={`h-2 ${holding.change >= 0 ? "bg-green-500" : "bg-red-500"}`}
style={{
width: `${Math.min(100, holding.allocation * 3)}%`,
}}
></div>
</div>
<span className="text-sm text-gray-900">
{holding.allocation.toFixed(1)}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === "holdings" && selectedHolding && selectedStock && (
<div className="rounded-lg border border-gray-200 shadow-sm bg-white">
<div className="p-4 flex justify-between items-start">
<div>
<div className="flex items-center">
<h3 className="text-xl font-bold text-gray-900">
{selectedStock.symbol}
</h3>
<span className="ml-2 text-gray-600">
{selectedStock.name}
</span>
</div>
<div className="flex items-center mt-1">
<span className="text-2xl font-bold text-gray-900">
{formatCurrency(selectedStock.price)}
</span>
<span
className={`ml-2 text-sm font-medium ${selectedStock.change >= 0 ? "text-green-600" : "text-red-600"}`}
>
{selectedStock.change >= 0 ? "\u25B2" : "\u25BC"}{" "}
{formatPercent(selectedStock.change)}
</span>
</div>
</div>
<button
onClick={() => setSelectedHolding(null)}
className="bg-gray-100 hover:bg-gray-200 p-1 rounded-md"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
<div className="border-t border-gray-200 p-4">
<div className="h-40 bg-white">
<div className="flex items-end h-full space-x-1">
{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 (
<div
key={index}
className="flex flex-col items-center flex-1"
>
<div
className={`w-full rounded-sm ${point.price >= chartData[Math.max(0, index - 1)].price ? "bg-green-500" : "bg-red-500"}`}
style={{ height: `${heightPercent}%` }}
></div>
{index % 5 === 0 && (
<span className="text-xs text-gray-500 mt-1">
{point.date}
</span>
)}
</div>
);
})}
</div>
</div>
</div>
<div className="border-t border-gray-200 p-4">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-gray-500">Shares Owned</p>
<p className="text-sm font-medium">
{selectedStock.shares.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Market Value</p>
<p className="text-sm font-medium">
{formatCurrency(selectedStock.value)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Avg. Cost</p>
<p className="text-sm font-medium">
{formatCurrency(selectedStock.avgCost)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Cost Basis</p>
<p className="text-sm font-medium">
{formatCurrency(
selectedStock.avgCost * selectedStock.shares,
)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Gain/Loss</p>
<p
className={`text-sm font-medium ${selectedStock.price - selectedStock.avgCost >= 0 ? "text-green-600" : "text-red-600"}`}
>
{formatCurrency(
(selectedStock.price - selectedStock.avgCost) *
selectedStock.shares,
)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Allocation</p>
<p className="text-sm font-medium">
{selectedStock.allocation.toFixed(2)}%
</p>
</div>
</div>
</div>
<div className="border-t border-gray-200 p-4 flex space-x-2">
<button className="flex-1 bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded-md transition-colors text-sm">
Buy More
</button>
<button className="flex-1 bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md transition-colors text-sm">
Sell
</button>
<button className="flex items-center justify-center w-10 h-10 border border-gray-300 rounded-md hover:bg-gray-100 transition-colors">
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
</button>
</div>
</div>
)}
{activeTab === "performance" && (
<div className="space-y-6">
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg
className="w-5 h-5 mr-2 text-indigo-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"></path>
</svg>
Performance Overview
</h3>
<div className="grid grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Daily</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.daily >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.daily >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.daily)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Weekly</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.weekly >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.weekly >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.weekly)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Monthly</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.monthly >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.monthly >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.monthly)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Yearly</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.yearly >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.yearly >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.yearly)}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg
className="w-5 h-5 mr-2 text-indigo-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
</svg>
Portfolio Allocation
</h3>
<div className="space-y-3">
{sortedHoldings.map((holding) => (
<div
key={holding.symbol}
className="flex items-center group hover:bg-indigo-50 p-2 rounded-lg transition-colors"
>
<div className="w-24 text-sm font-medium text-indigo-600 flex items-center">
<div
className={`w-3 h-3 rounded-full mr-2 ${holding.change >= 0 ? "bg-green-500" : "bg-red-500"}`}
></div>
{holding.symbol}
</div>
<div className="flex-grow">
<div className="bg-gray-200 h-4 rounded-full overflow-hidden shadow-inner">
<div
className="h-4 bg-gradient-to-r from-indigo-500 to-indigo-600"
style={{ width: `${holding.allocation}%` }}
></div>
</div>
</div>
<div className="w-16 text-sm font-medium text-gray-900 text-right ml-3">
{holding.allocation.toFixed(1)}%
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity ml-2">
<button className="p-1 text-gray-400 hover:text-indigo-600">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path
fillRule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
</div>
))}
</div>
<div className="mt-6 bg-gray-50 p-3 rounded-lg">
<h4 className="text-sm font-medium text-gray-700 mb-2">
Portfolio Diversification
</h4>
<div className="flex h-4 rounded-full overflow-hidden">
{[
"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 (
<div
key={sector}
className={`${colors[index]} h-full`}
style={{ width: `${widths[index]}%` }}
title={`${sector}: ${widths[index]}%`}
></div>
);
})}
</div>
<div className="flex flex-wrap mt-2 text-xs">
{[
"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 (
<div key={sector} className="mr-3 flex items-center">
<div
className={`w-2 h-2 rounded-full ${colors[index].replace("text", "bg")} mr-1`}
></div>
<span className={`${colors[index]} font-medium`}>
{sector} {widths[index]}%
</span>
</div>
);
})}
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<button className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 shadow-sm flex items-center">
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
></path>
</svg>
Export Data
</button>
<button className="px-4 py-2 bg-indigo-600 border border-indigo-600 rounded-md text-sm font-medium text-white hover:bg-indigo-700 shadow-sm flex items-center">
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
></path>
</svg>
View Full Report
</button>
</div>
</div>
)}
</div>
</div>
);
}
+10
View File
@@ -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";
@@ -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<EnterpriseUpdate> {
const ui = typedUi<typeof ComponentMap>(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")<typeof kbSearchSchema>,
);
const ticketListToolCall = message.tool_calls?.find(
findToolCall("ticket_list")<typeof ticketListSchema>,
);
const ticketDetailToolCall = message.tool_calls?.find(
findToolCall("ticket_detail")<typeof ticketDetailSchema>,
);
const webSearchToolCall = message.tool_calls?.find(
findToolCall("web_search")<typeof webSearchSchema>,
);
const sandboxToolCall = message.tool_calls?.find(
findToolCall("sandbox_run")<typeof sandboxRunSchema>,
);
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<string, number> = {};
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(),
};
}
@@ -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<Record<string, unknown>> {
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<string, string> = {
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<Record<string, unknown>> = 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<string, string> = {
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(
() => {},
);
}
}
+11
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB