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:
co-authored by
Claude Sonnet 4.6
parent
645f1ecaae
commit
7a3cc140b0
@@ -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` 写入开发日志
|
||||
@@ -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` 写入部署日志
|
||||
@@ -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
|
||||
@@ -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 确认
|
||||
@@ -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 汇总结果
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user