feat: Phase 1 — ReAct loop + Markdown rendering + Docker deployment
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 27s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 1m20s

Backend:
- Enterprise Agent refactored from single-round to ReAct multi-turn loop
- New agent.ts (LLM decision node) + tool-executor.ts (tool execution + Gen-UI)
- tool-defs.ts extracted for shared tool schemas
- MAX_ITERATIONS=6 safeguard against infinite loops

Frontend:
- MessageBubble: Markdown + code highlighting + LaTeX + tables
- ThemeToggle: light/dark/system theme cycling
- chart-result Gen-UI card: recharts bar/line/pie/area charts

Infrastructure:
- Docker Compose (lightweight): only LangGraph + Frontend, Azure cloud for PG/Redis/Blob
- Dockerfiles for dev (hot reload) and prod
- Makefile with dev/prod/down/logs commands
- Updated CLAUDE.md and agent definitions for LangGraph.js architecture

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-11 01:29:56 +08:00
co-authored by Claude Opus 4.6
parent 3263b15554
commit 251c6586f4
81 changed files with 4759 additions and 9008 deletions
Vendored
BIN
View File
Binary file not shown.
+22 -28
View File
@@ -20,29 +20,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: |
cd langgraph
pnpm install --frozen-lockfile
- name: Package for deployment
run: |
cd langgraph
touch .env
mkdir -p .langgraph_api
zip -r ../deploy-langgraph.zip . \
-x ".git/*" "dist/*" ".DS_Store"
- name: Login to Azure
uses: azure/login@v2
with:
@@ -50,8 +27,25 @@ jobs:
tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_62D66D4E65AC4A6C872064AD668AC691 }}
subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_5D7B0564A55F4209A91149F2642D3F69 }}
- name: Deploy to Azure Web App
uses: azure/webapps-deploy@v3
with:
app-name: soc-langgraph
package: deploy-langgraph.zip
- name: Build and push to ACR
run: |
az acr build \
--registry socsocacr \
--resource-group Operation \
--image soc-langgraph:${{ github.sha }} \
--image soc-langgraph:latest \
--file langgraph/Dockerfile \
langgraph/
- name: Deploy to Web App
run: |
az webapp config container set \
--resource-group Operation \
--name soc-langgraph \
--container-image-name socsocacr.azurecr.io/soc-langgraph:${{ github.sha }} \
--container-registry-url https://socsocacr.azurecr.io \
--container-registry-user socsocacr \
--container-registry-password $(az acr credential show --name socsocacr --resource-group Operation --query "passwords[0].value" -o tsv)
az webapp restart \
--resource-group Operation \
--name soc-langgraph
+136 -68
View File
@@ -4,102 +4,170 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
so-c-chat-clone — 企业级 Gemini 风格对话系统,前后端分离。基于 LangGraph ReAct Agent 编排,支持知识库检索、工单查询、外部搜索、文档生成、沙盒执行。
so-c-chat-clone — 企业级对话系统,基于 LangGraph.js Gen-UI 架构。Supervisor Agent 路由 + Enterprise Agent 工具调用,支持知识库检索、工单查询、网络搜索、代码沙盒执行,前端通过 useStream 实时渲染 Gen-UI 卡片。
## Commands
### Backend
### 后端(LangGraph Server)
```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --port 8000 --reload # Dev server
cd langgraph
pnpm install
pnpm run agent # langgraphjs dev --no-browser (port 2024)
```
### Frontend (read-only unless explicitly authorized)
### 前端(Vite SPA)
```bash
cd frontend
npm install && npm run dev # localhost:3000
npm run build
cd langgraph
pnpm install
pnpm run build # tsc -b && vite build
```
### Deployment
```bash
# Manual deploy (Oryx builds dependencies on Azure)
az webapp up --name soc-backend --resource-group Operation --runtime "PYTHON:3.12"
# Or push to main branch — GitHub Actions auto-deploys via .github/workflows/deploy-backend.yml
# Push to main branch — GitHub Actions auto-deploys:
# - deploy-langgraph.yml: ACR build → Web App container update
# - deploy-langgraph-ui.yml: pnpm vite build → Azure Static Web Apps
git push origin main
```
## Architecture
### System Overview
```
用户浏览器
↓
[Azure Static Web App] soc-langgraph-ui (eastasia)
salmon-mushroom-0d8872e00.7.azurestaticapps.net
Vite SPA + @langchain/langgraph-sdk/react useStream
↓ SSE
[Azure Web App] soc-langgraph (southeastasia)
soc-langgraph.azurewebsites.net
Node.js 20 LTS + langgraphjs dev (port 2024)
↓ HTTP
[外部服务]
├── Azure OpenAI (gpt-5.4)
├── KB Agent (Azure AI Search)
├── Gongdan 工单 API
├── Jina Search/Reader
├── Serper Google Search
└── Daytona Sandbox
```
### Agent Graph
```
Supervisor (Gemini 2.0 Flash) → router
├── enterprise → Enterprise Agent (Azure OpenAI gpt-5.4, 6 tools, Gen-UI cards)
└── generalInput → 通用对话
```
### Enterprise Agent Tools (src/agent/enterprise/nodes/tools.ts)
| Tool Name | External Service | UI Component |
|-----------|-----------------|--------------|
| `kb_search` | KB Agent (Azure AI Search) | `knowledge-result` |
| `ticket_list` | Gongdan API | `ticket-summary` |
| `ticket_detail` | Gongdan API | `ticket-detail` |
| `web_search` | Jina Search + Reader | `search-result` |
| `google_search` | Serper API | `search-result` |
| `sandbox_run` | Daytona REST API | `sandbox-result` |
### Model Mode Filtering
- `flash`: 排除 `web_search`(太慢),保留 `google_search`
- `pro`: 排除 `google_search`,使用深度 `web_search`
- `auto`: 保留全部工具
### Request Flow
```
Frontend POST /api/chat/stream
→ api/chat.py: resolve_tools() → get_chat_graph() → astream_events()
→ graph/builder.py: create_react_agent (with tools) OR plain StateGraph (no tools)
→ tools/*.py: LangChain @tool functions call external services
→ SSE events: token / tool_start / tool_end / done
Frontend useStream → LangGraph SSE
→ Supervisor router (Gemini Flash) → route to enterprise or generalInput
→ Enterprise tools node: LLM bindTools → call external APIs → ui.push() Gen-UI cards → LLM final answer
→ SSE stream with messages + UI components
```
### Graph Strategy (graph/builder.py)
- **With tools**: `create_react_agent(llm, tools, checkpointer)` — ReAct pattern, LLM decides tool calls
- **Without tools**: Simple `StateGraph(ChatState)` with single `call_model` node
- Graphs cached by `(model, frozenset(tool_names))` to avoid re-compilation
- Model presets: `flash` (max_tokens=500, temp=0.2), `pro` (max_tokens=4096, temp=0.3)
### Tool System (tools/__init__.py)
Frontend sends tool keys → `resolve_tools()` maps to LangChain @tool objects → bound to ReAct agent.
| Frontend Key | Tools | External Service |
|-------------|-------|-----------------|
| `"knowledge"` | `kb_search` | KB Agent (Azure AI Search) |
| `"tickets"` | `ticket_list`, `ticket_detail` | Gongdan API |
| `"search"` | `web_search` | Jina Search/Reader/Rerank |
| `"document"` | `generate_document` | Doc Creator Agent |
| `"sandbox"` | `sandbox_run` | Daytona API |
### Data Layer
- **PostgreSQL** (`store/postgres.py`): SQLAlchemy async ORM — `Conversation` and `Message` models, auto-creates tables on startup
- **LangGraph Checkpointer** (`store/memory.py`): `AsyncPostgresSaver` via psycopg (separate connection from SQLAlchemy, uses `sslmode=require` not `ssl=require`)
- **Redis** (`cache/redis.py`): Search result caching, key=`search:{hash}:{model}`, TTL=300s, graceful degradation on failure
- **Azure Blob Storage** (`storage/blob.py`): File uploads/downloads for attachments and generated docs
- **Azure Service Bus** (`tasks/bus.py`): Async task dispatch for long-running operations
### SSE Event Protocol
```json
{"type": "token", "content": "..."} // Streamed text chunk
{"type": "tool_start", "tool": "kb_search"}
{"type": "tool_end", "tool": "kb_search"}
{"type": "done"} // End of stream
## Repository Structure
```
so-c-chat-clone/
├── langgraph/ ← 唯一代码目录
│ ├── src/agent/supervisor/ ← Supervisor Agent(路由)
│ │ ├── index.ts ← StateGraph + checkpointer
│ │ ├── nodes/router.ts ← Gemini Flash 意图路由
│ │ └── nodes/general-input.ts ← 通用对话节点
│ ├── src/agent/enterprise/ ← Enterprise Agent
│ │ ├── index.ts ← StateGraph (START → tools)
│ │ ├── nodes/tools.ts ← 6 tools + LLM + ui.push()
│ │ ├── tools/soc-client.ts ← 外部 API 客户端
│ │ └── types.ts ← EnterpriseAnnotation
│ ├── src/agent/chat-agent/index.ts ← 简单聊天 Agent
│ ├── src/agent/utils/ ← checkpointer, format-messages
│ ├── src/agent-uis/enterprise/ ← 5 个 Gen-UI 卡片组件
│ │ ├── knowledge-result/
│ │ ├── ticket-summary/
│ │ ├── ticket-detail/
│ │ ├── search-result/
│ │ └── sandbox-result/
│ ├── src/main.tsx ← Chat UI 入口(useStream)
│ ├── langgraph.json ← LangGraph 配置
│ ├── startup.sh ← Azure Web App 启动脚本
│ ├── Dockerfile ← 后端容器
│ ├── Dockerfile.frontend ← 前端容器(未使用)
│ └── package.json ← pnpm, Node.js 20
├── .github/workflows/
│ ├── deploy-langgraph.yml ← ACR build → Web App
│ └── deploy-langgraph-ui.yml ← Vite build → Static Web App
├── doc/ ← 文档
└── CLAUDE.md
```
### Lifespan (main.py)
Startup creates DB tables. Shutdown closes: checkpointer → Redis → Blob client → Service Bus → SQLAlchemy engine.
## Configuration
All config via `pydantic-settings` in `app/config.py`, reads from `.env` file. Full credentials reference in `EXTERNAL_SERVICES.md`.
环境变量通过 Azure Web App 应用设置配置,本地通过 `langgraph/.env`:
Key env vars: `AZURE_OPENAI_*`, `DATABASE_URL`, `KB_AGENT_*`, `GONGDAN_*`, `JINA_API_KEY`, `REDIS_URL`, `DOC_AGENT_*`, `DAYTONA_*`, `AZURE_STORAGE_CONNECTION_STRING`, `AZURE_SERVICE_BUS_CONNECTION_STRING`.
```
# Azure OpenAI
AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT
# Google (Supervisor router)
GOOGLE_API_KEY
# KB Agent
KB_AGENT_URL, KB_AGENT_API_KEY, KB_AGENT_SEARCH_PATH
# Gongdan
GONGDAN_API_BASE, GONGDAN_API_KEY
# Jina
JINA_API_KEY
# Serper
SERPER_API_KEY
# Daytona
DAYTONA_API_KEY, DAYTONA_API_URL
# Frontend (Vite build-time)
VITE_LANGGRAPH_URL=https://soc-langgraph.azurewebsites.net
```
## Deployment
- **Azure Web App**: `soc-backend` in `Operation` resource group, Python 3.12, B1 Linux
- **Startup**: `gunicorn -w 2 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 120 app.main:app`
- **Critical setting**: `WEBSITES_PORT=8000` (Azure defaults to 8080)
- **CI/CD**: GitHub Actions with OIDC auth (`azure/login@v2`) → zip deploy, triggers on `backend/**` changes to main
- **Always On**: Enabled to avoid cold starts
### 后端 (soc-langgraph)
- **Azure Web App**: Node.js 20, B1 Linux, Southeast Asia, Operation 资源组
- **App Service Plan**: soc-langgraph-plan
- **启动命令**: `bash startup.sh`(pnpm install + langgraphjs dev --port $PORT)
- **WEBSITES_PORT**: 2024
- **CI/CD**: ACR cloud build → container update → restart
### 前端 (soc-langgraph-ui)
- **Azure Static Web App**: East Asia
- **URL**: salmon-mushroom-0d8872e00.7.azurestaticapps.net
- **CI/CD**: pnpm vite build → SWA upload
### CI/CD 认证
- OIDC: azure/login@v2 + Federated Identity (oidc-msi-8ac6)
- SWA Token: secrets.SWA_LANGGRAPH_TOKEN
## Constraints
- **Frontend is read-only** unless explicitly authorized
- **Azure resources**: `Operation` and `AuthData` resource groups only — all `az` commands must include `--resource-group`
- **Tool invocation policy**: User-selected tools are passed to ReAct Agent as available. Agent decides whether to call them. If it decides not to, it must explain why.
- **Search depth**: `flash` = quick (top 3, no Reader/Rerank), `pro` = deep (top 10, concurrent Reader, Rerank top 5)
## Known Issues
- `GET /api/tickets/{id}` returns 404 — upstream Gongdan API issue, not backend bug
- `config.py` has hardcoded DB/Redis passwords as defaults — should be empty strings (security risk if repo goes public)
- **Azure 资源组**: 仅允许操作 `Operation` 和 `AuthData`,所有 `az` 命令必须带 `--resource-group`
- **禁止删除已存在的 Azure 资源**
- **GitHub**: Fasthei/so-c-chat-clone,main 分支
- **已废弃(已删除)**: backend/ (Python), frontend/ (Next.js), soc-backend Web App, soc-frontend Static Web App, Container App
+84
View File
@@ -0,0 +1,84 @@
# SOC Chat Clone - Local Docker Commands (lightweight)
# 基础设施用 Azure 云服务,本地只运行 LangGraph Server + Frontend
# ====================================================================
# make dev - 开发模式(热重载)
# make prod - 生产模式
# make down - 停止
# make logs - 查看日志
.PHONY: dev prod down logs clean status setup check-env build-dev build-prod restart-backend restart-frontend
# ─── Environment ─────────────────────────────────────────────────
check-env:
@if [ ! -f langgraph/.env ]; then \
echo "ERROR: langgraph/.env not found."; \
echo " Run: make setup"; \
exit 1; \
fi
setup:
@if [ ! -f langgraph/.env ]; then \
cp langgraph/.env.docker.example langgraph/.env; \
echo "Created langgraph/.env — 请检查并填入 GOOGLE_API_KEY 等缺失值"; \
else \
echo "langgraph/.env already exists, skipping."; \
fi
# ─── Development ─────────────────────────────────────────────────
dev: check-env
docker compose --profile dev up --build
dev-d: check-env
docker compose --profile dev up --build -d
build-dev:
docker compose --profile dev build
# ─── Production ──────────────────────────────────────────────────
prod: check-env
docker compose --profile prod up --build
prod-d: check-env
docker compose --profile prod up --build -d
build-prod:
docker compose --profile prod build
# ─── Lifecycle ───────────────────────────────────────────────────
down:
docker compose --profile dev --profile prod down
clean:
docker compose --profile dev --profile prod down --rmi local
@echo "All containers stopped and images removed."
restart-backend:
docker compose restart langgraph-dev 2>/dev/null || docker compose restart langgraph-prod 2>/dev/null
restart-frontend:
docker compose restart frontend-dev 2>/dev/null || docker compose restart frontend-prod 2>/dev/null
# ─── Observability ───────────────────────────────────────────────
logs:
docker compose --profile dev --profile prod logs -f
logs-backend:
docker compose logs -f langgraph-dev langgraph-prod 2>/dev/null
logs-frontend:
docker compose logs -f frontend-dev frontend-prod 2>/dev/null
status:
@echo "=== Container Status ==="
@docker compose --profile dev --profile prod ps
@echo ""
@echo "=== Endpoints ==="
@echo " Frontend (dev): http://localhost:5173"
@echo " Frontend (prod): http://localhost:3000"
@echo " LangGraph API: http://localhost:2024"
@echo ""
@echo "=== Azure Cloud Services ==="
@echo " PostgreSQL: dataope.postgres.database.azure.com"
@echo " Redis: oper.redis.cache.windows.net:6380"
@echo " Blob: authdatablol.blob.core.windows.net"
@echo " Service Bus: databus.servicebus.windows.net"
+294
View File
@@ -0,0 +1,294 @@
# SOC 企业级 ChatGPT 系统 — 多 Agent 联合规划方案
> 2026-04-10 | 后端 Agent + 前端 Agent + LLM 工程师 + 部署 Agent 联合讨论产出
---
## 一、目标
基于 langgraphjs-gen-ui-examples 框架,打造无限接近 ChatGPT Enterprise 的企业对话系统,本地 Docker 部署。
## 二、系统架构(目标态)
```
用户浏览器 (localhost:3000)
↓ nginx 反代
[Vite SPA] React 19 + useStream + Gen-UI
↓ SSE
[LangGraph.js Server] (localhost:2024)
↓
[Supervisor] (gpt-5.4, temperature=0)
├── enterprise → 知识库 + 工单(保留改造)
├── analyst → 数据分析 + 图表生成(新增)
├── coder → 代码解释器(新增)
├── writer → Canvas 文档编辑(新增)
├── searcher → 深度搜索(新增)
├── fileProcessor → 文件解析 + 多模态(新增)
├── imageGen → 图像生成(新增)
├── memory → 用户记忆(后台服务,新增)
└── generalInput → 通用对话(保留)
↓
[基础设施 Docker]
├── Azure PostgreSQL → checkpointer + 用户数据 + 记忆 (dataope.postgres.database.azure.com)
├── Azure Redis → 缓存 + session + 限流 (oper.redis.cache.windows.net)
├── Azure Blob → 文件存储 (authdatablol)
└── Azure Service Bus → 异步任务 (databus.servicebus.windows.net)
↓
[外部 API]
├── Azure OpenAI (gpt-5.4)
├── Google Gemini Flash (router 备选)
├── KB Agent / Gongdan API
├── Jina Search + Reader / Serper
├── Daytona Sandbox
├── DALL-E 3 / Replicate FLUX
└── Doc Creator Agent
```
---
## 三、后端架构(后端 Agent 方案)
### 3.1 新增 Sub-Agent 一览
| Agent | 对标 ChatGPT 功能 | 工具数 | Gen-UI 卡片 |
|-------|------------------|--------|------------|
| enterprise (改造) | 企业知识+工单 | 5 | knowledge-result, ticket-summary, ticket-detail |
| analyst (新增) | Advanced Data Analysis | 5 | data-preview, chart-result, stats-summary |
| coder (新增) | Code Interpreter | 5 | code-execution, code-approval |
| writer (新增) | Canvas | 7 | document-editor, document-export |
| searcher (新增) | Deep Research | 5 | search-progress, source-list, search-result |
| fileProcessor (新增) | File Upload | 6 | file-preview, image-analysis |
| imageGen (新增) | DALL-E | 3 | image-gallery, image-generation-progress |
| memory (新增) | Memory | 4 | (后台,无UI) |
### 3.2 目录结构
```
langgraph/src/agent/
├── supervisor/ ← 路由到 8 个 Agent
├── enterprise/ ← 知识库 + 工单 (改为 ReAct 循环)
├── analyst/ ← 数据分析 (新增)
│ ├── nodes/call-model.ts, execute-tools.ts
│ └── tools/csv-parse.ts, data-query.ts, chart-generate.ts
├── coder/ ← 代码解释器 (新增)
│ ├── nodes/call-model.ts, execute-code.ts
│ └── tools/sandbox-manager.ts (会话级 sandbox 复用)
├── writer/ ← Canvas 文档 (新增)
│ └── nodes/call-model.ts, write-document.ts
├── searcher/ ← 深度搜索 (新增)
│ └── nodes/plan-search.ts, execute-searches.ts, synthesize.ts
├── file-processor/ ← 文件处理 (新增)
│ └── nodes/detect-type.ts, extract-text.ts, vision-analyze.ts
├── image-gen/ ← 图像生成 (新增)
│ └── nodes/generate.ts
├── memory/ ← 用户记忆 (新增)
│ ├── recall.ts, extract.ts
└── utils/
├── tool-loop.ts ← 通用 ReAct 循环抽象 (新增)
├── redis-client.ts ← Redis 客户端 (新增)
└── minio-client.ts ← MinIO 客户端 (新增)
```
### 3.3 数据库 Schema
```sql
-- users, conversations, files, user_memories, artifacts, tool_invocations
-- 详见后端 Agent 完整方案
```
---
## 四、LLM 工程方案(LLM 工程师方案)
### 4.1 ReAct 循环(最关键改造)
当前 Enterprise Agent 是**单轮**:LLM 调一次 → 执行工具 → LLM 再调一次总结。
改造为**多轮 ReAct**:
```
START → agent (LLM 思考) → route?
├── 有 tool_calls → tools (执行) → agent (继续思考)
└── 无 tool_calls → END
```
关键:MAX_ITERATIONS=6 防止无限循环。
### 4.2 Code Interpreter 自动修正循环
```
START → agent → generate_code → sandbox_execute → check_result
├── 成功 → agent → END
└── 报错 → agent (看错误) → generate_code (修正)
```
- sandbox 会话级复用(30min TTL),不再每次创建/销毁
- matplotlib/plotly 图表输出为 base64 PNG → 嵌入 Gen-UI chart-result 卡片
### 4.3 记忆系统
```
对话开始 → memory_recall(user_id) → 注入 system prompt 尾部
对话结束 → memory_extract(conversation) → 持久化到 PostgreSQL
```
记忆分类:preference / fact / instruction / context
### 4.4 Supervisor 路由优化
路由 prompt 使用**结构化 tool descriptions**:
```
- enterprise: 企业内部助手:知识库查询、工单管理
- analyst: 数据分析:上传 CSV/Excel 后数据探索、统计、图表
- coder: 代码解释器:编写和执行代码、调试
- writer: 文档编辑器:创建和编辑文档(Canvas 模式)
- searcher: 深度搜索:多步互联网搜索、新闻查询
- fileProcessor: 文件处理:解析 PDF/Word/图片
- imageGen: 图像生成:根据描述生成/编辑图片
- generalInput: 通用对话
```
路由用 structured output (z.enum) 而非自由文本。
### 4.5 模型策略
| 模式 | 路由模型 | Agent 模型 | 工具过滤 |
|------|---------|-----------|---------|
| flash | gpt-5.4 (temp=0, maxTokens=50) | gpt-5.4 (temp=0.2, maxTokens=2048) | 排除 web_search |
| pro | 同上 | gpt-5.4 (temp=0.3, maxTokens=8192) | 排除 google_search |
| auto | 同上 | gpt-5.4 (temp=0.3, maxTokens=4096) | 全部 |
---
## 五、前端 UI 方案(前端 Agent 方案)
### 5.1 新增 Gen-UI 卡片
| 组件名 | Props | 场景 |
|--------|-------|------|
| `chart-result` | title, chart_type, data[], x_key, y_keys, colors, unit | 数据分析图表 |
| `canvas-doc` | doc_id, title, content, language, type | 触发 Canvas 侧面板 |
| `file-preview` | filename, file_type, size_kb, summary, row_count, preview_url | 文件解析结果 |
### 5.2 新增核心组件
| 组件 | 功能 |
|------|------|
| `MessageBubble.tsx` | AI 消息 Markdown 渲染 + 代码高亮 + LaTeX(**最高优先级**) |
| `CanvasPanel.tsx` | 右侧文档/代码编辑侧面板 (w-[480px]) |
| `FileUploadZone.tsx` | 拖拽上传 + 进度条 + 附件展示 |
| `ThemeToggle.tsx` | 暗色/亮色/系统主题切换 |
| `ToolStatusIndicator.tsx` | 工具执行动态指示器 |
### 5.3 侧边栏增强
- 搜索框(过滤对话)
- 日期分组(今天/昨天/本周/更早)
- 移动端折叠(汉堡菜单 + 滑入动画)
### 5.4 响应式布局
| 断点 | 布局 |
|------|------|
| < 768px | 侧边栏隐藏,Canvas 从底部弹出 |
| 768px | 侧边栏 w-56,折叠模式 |
| 1024px | 侧边栏 w-64,Canvas w-[480px] |
| 1280px+ | 消息区域 max-w-4xl |
---
## 六、Docker 部署方案(部署 Agent 方案)
### 6.1 设计原则
**基础设施用 Azure 云服务,本地 Docker 只运行应用**:
- PostgreSQL → Azure (dataope.postgres.database.azure.com)
- Redis → Azure (oper.redis.cache.windows.net:6380)
- Blob Storage → Azure (authdatablol)
- Service Bus → Azure (databus.servicebus.windows.net)
- LangGraph Server + Frontend → 本地 Docker
### 6.2 一键启动
```bash
cd /Users/gongzhiyong/go/SOC
make setup # 创建 .env(已预填 Azure 服务凭据)
make dev # 开发模式(热重载)
make prod # 生产模式
```
### 6.3 服务矩阵
| 服务 | 位置 | 端口 | 用途 |
|------|------|------|------|
| langgraph | 本地 Docker | 2024 | LangGraph Server |
| frontend | 本地 Docker | 5173(dev)/3000(prod) | 前端 |
| PostgreSQL | Azure 云 | 5432 | checkpointer + 数据 |
| Redis | Azure 云 | 6380 (SSL) | 缓存 |
| Blob Storage | Azure 云 | — | 文件存储 |
| Service Bus | Azure 云 | — | 异步任务 |
### 6.4 开发模式特性
- 后端:src/ 目录挂载到容器,langgraphjs dev 自动监听变更
- 前端:src/ + index.html + vite.config.ts 挂载,Vite HMR 即时生效
- 无本地数据卷,所有持久化在 Azure 云端
### 6.5 已创建的文件
- `docker-compose.yml` — 轻量版,只有 langgraph + frontend
- `Dockerfile.dev` / `Dockerfile.prod` — 后端
- `Dockerfile.frontend.dev` / `Dockerfile.frontend.prod` — 前端
- `.env.docker.example` — 已预填 Azure 云服务凭据
- `Makefile` — 便捷命令
---
## 七、实施路线图
### Phase 0: 基础设施(1天)
- [x] Docker Compose(postgres + redis + minio + nginx)
- [ ] PostgreSQL schema 初始化
- [ ] MinIO buckets 初始化
- [ ] 验证 `make dev` 一键启动
### Phase 1: 核心升级(3天)
- [ ] **ReAct 循环改造** — Enterprise Agent 从单轮改为多轮 tool-calling loop
- [ ] **tool-loop 通用抽象** — 所有后续 Agent 复用
- [ ] **MessageBubble** — AI 消息 Markdown + 代码高亮渲染
- [ ] **ThemeProvider** — 暗色/亮色主题
- [ ] **chart-result 卡片** — recharts 图表渲染
### Phase 2: Agent 扩展(5天)
- [ ] **Deep Search Agent** — 从 Enterprise 剥离搜索,增加多步搜索
- [ ] **Code Interpreter Agent** — sandbox 复用 + 自动修正循环
- [ ] **Writer Agent (Canvas)** — 侧面板文档编辑 + 流式写入
- [ ] **CanvasPanel 前端组件** — 右侧抽屉 + Markdown/Code 渲染
### Phase 3: 高级功能(4天)
- [ ] **Data Analyst Agent** — CSV/Excel 分析 + pandas + 图表
- [ ] **File Processor Agent** — 文件上传 + PDF/图片解析
- [ ] **FileUploadZone 前端组件** — 拖拽上传 + 预览
- [ ] **Image Generator Agent** — DALL-E / Replicate
- [ ] **Memory Agent** — 跨会话记忆
### Phase 4: 体验打磨(2天)
- [ ] 侧边栏搜索 + 日期分组
- [ ] 移动端响应式布局
- [ ] ToolStatusIndicator 动态指示器
- [ ] 来源引用编号 [1][2]
- [ ] 自定义 GPTs UI(规划/占位)
**总计约 15 个工作日**
---
## 八、关键技术决策
| 决策 | 选择 | 原因 |
|------|------|------|
| 工具循环 | 自定义 tool-executor + ui.push | LangGraph ToolNode 不支持 typedUi.push() |
| 文件存储 | MinIO (S3 兼容) | 本地 Docker 部署,未来可无缝迁移 Azure Blob S3 兼容层 |
| 路由模型 | gpt-5.4 (temp=0, maxTokens=50) | 中文语义准确率高,成本极低 |
| Canvas 通信 | CustomEvent | Gen-UI 卡片无法接收父组件回调,CustomEvent 是唯一跨边界方案 |
| 记忆系统 | PostgreSQL + LLM extract | 透明记忆,用户无需主动说"记住",系统自动提取 |
| sandbox 复用 | 会话级 Map + 30min TTL | 避免每次创建/销毁的 10s+ 开销 |
+156
View File
@@ -0,0 +1,156 @@
# 外部服务接入配置
> **使用说明**:此文档用于记录外部服务的接入方式、环境变量和调用示例,便于开发、联调与排障。
>
> 当前服务按“代码已支持 + 部署环境变量由 Azure Web App 提供”的口径记录为已接入;实际运行效果仍以部署环境变量是否正确配置为准。
>
> 已接入的服务会标注 ✅。
---
## 1. LLM 大语言模型
> 当前使用 Azure OpenAI,已在后端 graph.py / main.py 中集成。
### 环境变量(已配置)
```
AZURE_OPENAI_ENDPOINT=https://ai-gzy0016231ai975636166896.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview/
AZURE_OPENAI_API_KEY=DlsBBFJ0RgMGdKxsdBWnlYj6IRdULzflGsKFCXnMBzqs4ZVHMtqZJQQJ99CCACHYHv6XJ3w3AAAAACOG45do
AZURE_OPENAI_API_VERSION=2025-04-01-preview
AZURE_OPENAI_DEPLOYMENT=gpt-5.4
```
### 请求示例
```bash
curl -X POST "${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}" \
-H "Content-Type: application/json" \
-H "api-key: ${AZURE_OPENAI_API_KEY}" \
-d '{
"messages": [{"role": "user", "content": "你好"}],
"max_tokens": 1000
}'
```
---
## 2. 内部知识库检索
> 当前通过 agnetdoc Function App 调用 Azure AI Search。
### 环境变量(已配置)
```
KB_AGENT_URL=https://agnetdoc-cve0guf5h8eggmej.southeastasia-01.azurewebsites.net
KB_AGENT_API_KEY=LdyzZlS3Nn1xFejqPsHn1nW-zsj9FLpC5KCbopCkQWKCAzFuLEUU4w==
KB_AGENT_SEARCH_PATH=/api/v1/search
KB_AGENT_SEARCH_TIMEOUT_SEC=15
```
### 请求示例
```bash
curl -X POST "${KB_AGENT_URL}/api/v1/search" \
-H "Content-Type: application/json" \
-H "api-key: ${KB_AGENT_API_KEY}" \
-d '{
"query": "Taiji Agent 产品规划",
"top": 8,
"search_mode": "hybrid"
}'
```
### 响应格式
```json
{
"results": [
{
"id": "xxx",
"title": "文档标题",
"content": "文档内容...",
"category": "分类",
"score": 0.85,
"url": "https://...",
"tags": ["tag1"],
"project": "项目名"
}
]
}
```
---
## 3. 外部 AI 搜索
目前外部搜索采用https://mcp.jina.ai/sse 或者 /v1 可优先测试
jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI
要求使用搜索和读取两个工具,并且要结合重排模型使用。
满足企业级的搜索准确度,包括不限于图片和视频
按照深度和快速来定义搜索内容和搜索的质量,还需要满足前端的展示。
支持MCP
---
## 4. 沙盒代码执行
沙盒采用现成的解决方案。https://docs.langchain.com/oss/python/integrations/sandboxes/daytona
https://app.daytona.io/api
dtn_066b83f57f0337c96fae2ef1f5c8456477a39dfbd5fc615456263fd4947108c2
依然要满足前端输出要求。
## 5. 文档生成 Agent
http://doc-creator-agent-b0d02105-a557fe.taijiagnet.com
sk-t5R8jkEp6IA7_ghJ6Hy1rQ
http://agnetdoc.taijiaicloud.com/node/019cd223-9d13-7566-a2ea-52ee67645463
## 6. 工单系统
> gongdan 工单系统,只读集成。
### 环境变量(已配置)
```
GONGDAN_API_BASE=https://gongdan-b5fzbtgteqd5gzfb.eastasia-01.azurewebsites.net
GONGDAN_API_KEY=gd_live_a28b3db84385be75d1d3b6b6023784c27200d045
```
### 请求示例
```bash
# 工单列表
curl -X GET "${GONGDAN_API_BASE}/api/tickets?page=1&pageSize=20" \
-H "X-Api-Key: ${GONGDAN_API_KEY}"
# 工单详情
curl -X GET "${GONGDAN_API_BASE}/api/tickets/{ticketId}" \
-H "X-Api-Key: ${GONGDAN_API_KEY}"
```
---
## 7. Pgsql数据库
```
DATABASE_URL=postgresql://USER:PASSWORD@<host>:5432/yydn?sslmode=require
```
```
dataope.postgres.database.azure.com
azuredb:h13nYoFJX6QrfLzB8bdipEUCjsZq2P7W
```
---
### 8.Redis
```
oper.redis.cache.windows.net:6380,password=bY8ZNwyJX60UwN5NPqnl6HRODfTV0efkDAzCaF1PrOU=,ssl=True,abortConnect=False
```
---
### 9.存储账户
```
DefaultEndpointsProtocol=https;AccountName=authdatablol;AccountKey=sm3ysR0zAmS9OLtiHVau3Wj122YWQJTuMHAyHO4ReIrpe6+3r1K7oGfFLGCZSZh+1n72gbK1q/+C+AStgrZ7fw==;EndpointSuffix=core.windows.net
```
---
### 10.service bus
```
Endpoint=sb://databus.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=+b7+0KMW1UQt5mbJEkA7uRxds4h0h4VNK+ASbOH5q3E=
```
---
### 11.serper.dev
499940576bc8a7211ac98a3f3b83a4826bb8105b
+110
View File
@@ -0,0 +1,110 @@
# SOC Chat Clone - Local Docker (lightweight)
# 基础设施用 Azure 云服务,本地只运行 LangGraph Server + Frontend
#
# Usage:
# make dev - 开发模式(热重载)
# make prod - 生产模式
# make down - 停止
services:
# ============================================================
# Backend - Development (hot reload, source mount)
# ============================================================
langgraph-dev:
container_name: soc-langgraph-dev
profiles: ["dev"]
build:
context: ./langgraph
dockerfile: Dockerfile.dev
env_file:
- ./langgraph/.env
ports:
- "2024:2024"
volumes:
- ./langgraph/src:/app/src:cached
- ./langgraph/langgraph.json:/app/langgraph.json:ro
- ./langgraph/index.html:/app/index.html:ro
- ./langgraph/vite.config.ts:/app/vite.config.ts:ro
- ./langgraph/tsconfig.json:/app/tsconfig.json:ro
- ./langgraph/tsconfig.app.json:/app/tsconfig.app.json:ro
- ./langgraph/tsconfig.node.json:/app/tsconfig.node.json:ro
- ./langgraph/tailwind.config.js:/app/tailwind.config.js:ro
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:2024/ok || exit 1"]
interval: 10s
timeout: 5s
retries: 15
start_period: 30s
# ============================================================
# Backend - Production (optimized image)
# ============================================================
langgraph-prod:
container_name: soc-langgraph-prod
profiles: ["prod"]
build:
context: ./langgraph
dockerfile: Dockerfile.prod
env_file:
- ./langgraph/.env
ports:
- "2024:2024"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:2024/ok || exit 1"]
interval: 10s
timeout: 5s
retries: 15
start_period: 30s
restart: unless-stopped
# ============================================================
# Frontend - Development (Vite dev server with HMR)
# ============================================================
frontend-dev:
container_name: soc-frontend-dev
profiles: ["dev"]
build:
context: ./langgraph
dockerfile: Dockerfile.frontend.dev
environment:
- VITE_LANGGRAPH_URL=http://localhost:2024
ports:
- "5173:5173"
volumes:
- ./langgraph/src:/app/src:cached
- ./langgraph/index.html:/app/index.html:ro
- ./langgraph/vite.config.ts:/app/vite.config.ts:ro
- ./langgraph/tailwind.config.js:/app/tailwind.config.js:ro
- ./langgraph/tsconfig.json:/app/tsconfig.json:ro
- ./langgraph/tsconfig.app.json:/app/tsconfig.app.json:ro
- ./langgraph/tsconfig.node.json:/app/tsconfig.node.json:ro
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:5173 || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
# ============================================================
# Frontend - Production (nginx + vite build)
# ============================================================
frontend-prod:
container_name: soc-frontend-prod
profiles: ["prod"]
build:
context: ./langgraph
dockerfile: Dockerfile.frontend.prod
args:
VITE_LANGGRAPH_URL: http://localhost:2024
ports:
- "3000:3000"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
+12
View File
@@ -0,0 +1,12 @@
node_modules
.git
dist
*.log
.DS_Store
.env
.env.*
!.env.example
!.env.docker.example
.langgraph_api
.claude
*.md
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-slim
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@10
# Copy package files first for layer caching
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# LangGraph dev prerequisites
RUN touch .env && mkdir -p .langgraph_api
# Expose port 8080 for Azure auto-detection
EXPOSE 8080
# Start LangGraph.js dev server on port 8080
CMD ["./node_modules/.bin/langgraphjs", "dev", "--host", "0.0.0.0", "--port", "8080", "--no-browser"]
+27
View File
@@ -0,0 +1,27 @@
# LangGraph Server - Development
# Hot reload via source volume mounts (see docker-compose.yml)
FROM node:20-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@10
# Copy package files for dependency caching
COPY package.json pnpm-lock.yaml .npmrc ./
# Install all dependencies (including devDependencies)
RUN pnpm install --frozen-lockfile
# Copy source code (will be overridden by volume mounts in dev)
COPY . .
# LangGraph dev prerequisites
RUN touch .env && mkdir -p .langgraph_api
EXPOSE 2024
# Start LangGraph.js dev server with watch mode
CMD ["./node_modules/.bin/langgraphjs", "dev", "--host", "0.0.0.0", "--port", "2024", "--no-browser"]
+35
View File
@@ -0,0 +1,35 @@
# Stage 1: Build the Vite SPA
FROM node:20-slim AS builder
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@10
# Copy package files first for layer caching
COPY package.json pnpm-lock.yaml ./
# Install all dependencies (including devDependencies for build)
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the frontend with the backend URL pointing to localhost:8080
ARG VITE_LANGGRAPH_URL=http://localhost:8080
ENV VITE_LANGGRAPH_URL=${VITE_LANGGRAPH_URL}
RUN pnpm build
# Stage 2: Serve with nginx
FROM nginx:alpine
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config for SPA routing
COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]
+20
View File
@@ -0,0 +1,20 @@
# Frontend - Development (Vite dev server with HMR)
FROM node:20-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN npm install -g pnpm@10
COPY package.json pnpm-lock.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
# Copy source (overridden by volume mounts in dev mode)
COPY . .
EXPOSE 5173
# Vite dev server with HMR, accessible from host
CMD ["./node_modules/.bin/vite", "--host", "0.0.0.0", "--port", "5173"]
+30
View File
@@ -0,0 +1,30 @@
# Frontend - Production (multi-stage: vite build + nginx)
FROM node:20-slim AS builder
WORKDIR /app
RUN npm install -g pnpm@10
COPY package.json pnpm-lock.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
COPY . .
# Build-time variable: where the frontend should call the LangGraph API
ARG VITE_LANGGRAPH_URL=http://localhost:2024
ENV VITE_LANGGRAPH_URL=${VITE_LANGGRAPH_URL}
RUN pnpm build
# ---- Serve with nginx ----
FROM nginx:alpine
RUN apk add --no-cache curl
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]
+35
View File
@@ -0,0 +1,35 @@
# LangGraph Server - Production
# Multi-stage build for smaller image
FROM node:20-slim AS deps
WORKDIR /app
RUN npm install -g pnpm@10
COPY package.json pnpm-lock.yaml .npmrc ./
# Install production + dev dependencies (langgraphjs CLI is in dependencies)
RUN pnpm install --frozen-lockfile
# ---- Production image ----
FROM node:20-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# LangGraph dev prerequisites
RUN touch .env && mkdir -p .langgraph_api
# Run as non-root
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 langgraph && \
chown -R langgraph:nodejs /app
USER langgraph
EXPOSE 2024
CMD ["./node_modules/.bin/langgraphjs", "dev", "--host", "0.0.0.0", "--port", "2024", "--no-browser"]
-1
View File
@@ -2,7 +2,6 @@
"node_version": "20",
"graphs": {
"agent": "./src/agent/supervisor/index.ts:graph",
"email_agent": "./src/agent/email-agent/index.ts:agent",
"chat": "./src/agent/chat-agent/index.ts:agent"
},
"ui": {
+23
View File
@@ -0,0 +1,23 @@
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback: all routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Disable caching for index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}
+1
View File
@@ -25,6 +25,7 @@
"@langchain/google-genai": "^0.1.10",
"@langchain/langgraph": "^0.2.64",
"@langchain/langgraph-checkpoint": "^0.0.17",
"@langchain/langgraph-checkpoint-postgres": "^1.0.1",
"@langchain/langgraph-cli": "^0.0.30",
"@langchain/langgraph-sdk": "^0.0.73",
"@langchain/openai": "^0.5.5",
+2253 -4300
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
import {
ResponsiveContainer,
BarChart,
Bar,
LineChart,
Line,
AreaChart,
Area,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
import { BarChart2 } from "lucide-react";
interface ChartResultProps {
title: string;
chart_type: "bar" | "line" | "pie" | "area";
data: Array<{ label: string; value: number; [key: string]: unknown }>;
x_key: string;
y_keys: string[];
colors?: string[];
unit?: string;
}
// Default color palette — uses CSS variables to respect dark/light theme
const DEFAULT_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
function makeTooltipFormatter(unit?: string) {
return (value: number) =>
unit ? [`${value} ${unit}`, ""] : [String(value), ""];
}
export default function ChartResult({
title,
chart_type,
data,
x_key,
y_keys,
colors,
unit,
}: ChartResultProps) {
const palette = colors?.length ? colors : DEFAULT_COLORS;
const tooltipFormatter = makeTooltipFormatter(unit);
const commonProps = {
data,
margin: { top: 4, right: 16, bottom: 4, left: 0 },
};
const axisStyle = {
fontSize: 11,
fill: "var(--color-muted-foreground)",
};
const gridStyle = { stroke: "var(--color-border)", strokeDasharray: "3 3" };
function renderChart() {
if (chart_type === "bar") {
return (
<BarChart {...commonProps}>
<CartesianGrid {...gridStyle} />
<XAxis dataKey={x_key} tick={axisStyle} />
<YAxis tick={axisStyle} unit={unit} />
<Tooltip formatter={tooltipFormatter} />
{y_keys.length > 1 && <Legend />}
{y_keys.map((key, i) => (
<Bar
key={key}
dataKey={key}
fill={palette[i % palette.length]}
radius={[3, 3, 0, 0]}
/>
))}
</BarChart>
);
}
if (chart_type === "line") {
return (
<LineChart {...commonProps}>
<CartesianGrid {...gridStyle} />
<XAxis dataKey={x_key} tick={axisStyle} />
<YAxis tick={axisStyle} unit={unit} />
<Tooltip formatter={tooltipFormatter} />
{y_keys.length > 1 && <Legend />}
{y_keys.map((key, i) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={palette[i % palette.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
))}
</LineChart>
);
}
if (chart_type === "area") {
return (
<AreaChart {...commonProps}>
<CartesianGrid {...gridStyle} />
<XAxis dataKey={x_key} tick={axisStyle} />
<YAxis tick={axisStyle} unit={unit} />
<Tooltip formatter={tooltipFormatter} />
{y_keys.length > 1 && <Legend />}
{y_keys.map((key, i) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={palette[i % palette.length]}
fill={palette[i % palette.length]}
fillOpacity={0.15}
strokeWidth={2}
/>
))}
</AreaChart>
);
}
// pie
const pieData = data.map((d) => ({
name: String(d[x_key] ?? d.label),
value: Number(d[y_keys[0]] ?? d.value),
}));
return (
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={90}
label={({ name, percent }) =>
`${name} ${(percent * 100).toFixed(0)}%`
}
labelLine={false}
>
{pieData.map((_, i) => (
<Cell key={i} fill={palette[i % palette.length]} />
))}
</Pie>
<Tooltip formatter={tooltipFormatter} />
<Legend />
</PieChart>
);
}
return (
<div className="w-full max-w-2xl rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<BarChart2 className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm text-foreground">{title}</span>
</div>
{/* Chart area */}
<div className="px-4 py-4">
<ResponsiveContainer width="100%" height={240}>
{renderChart()}
</ResponsiveContainer>
</div>
</div>
);
}
+2 -16
View File
@@ -1,30 +1,16 @@
import StockPrice from "./stockbroker/stock-price";
import PortfolioView from "./stockbroker/portfolio-view";
import AccommodationsList from "./trip-planner/accommodations-list";
import RestaurantsList from "./trip-planner/restaurants-list";
import BuyStock from "./stockbroker/buy-stock";
import Plan from "./open-code/plan";
import ProposedChange from "./open-code/proposed-change";
import { Writer } from "./writer";
import KnowledgeResult from "./enterprise/knowledge-result";
import TicketSummary from "./enterprise/ticket-summary";
import TicketDetail from "./enterprise/ticket-detail";
import SearchResult from "./enterprise/search-result";
import SandboxResult from "./enterprise/sandbox-result";
import ChartResult from "./enterprise/chart-result";
const ComponentMap = {
"stock-price": StockPrice,
portfolio: PortfolioView,
"accommodations-list": AccommodationsList,
"restaurants-list": RestaurantsList,
"buy-stock": BuyStock,
"code-plan": Plan,
"proposed-change": ProposedChange,
writer: Writer,
"knowledge-result": KnowledgeResult,
"ticket-summary": TicketSummary,
"ticket-detail": TicketDetail,
"search-result": SearchResult,
"sandbox-result": SandboxResult,
"chart-result": ChartResult,
} as const;
export default ComponentMap;
@@ -1 +0,0 @@
@import "tailwindcss";
@@ -1,79 +0,0 @@
import "./index.css";
import { motion } from "framer-motion";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
interface PlanProps {
toolCallId: string;
executedPlans: string[];
rejectedPlans: string[];
remainingPlans: string[];
}
export default function Plan(props: PlanProps) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="flex flex-col w-full max-w-4xl border-[1px] rounded-xl border-slate-200 overflow-hidden">
<div className="p-6">
<h2 className="text-2xl font-semibold text-left">Code Plan</h2>
</div>
<motion.div
className="relative overflow-hidden"
animate={{
height: isExpanded ? "auto" : "200px",
opacity: isExpanded ? 1 : 0.7,
}}
transition={{
height: { duration: 0.3, ease: [0.4, 0, 0.2, 1] },
opacity: { duration: 0.2 },
}}
initial={false}
>
<div className="grid grid-cols-3 divide-x divide-slate-300 w-full border-t border-slate-200 px-6 pt-4 pb-4">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-medium mb-4 text-slate-700">
Remaining Plans
</h3>
{props.remainingPlans.map((step, index) => (
<p key={index} className="font-mono text-sm">
{index + 1}. {step}
</p>
))}
</div>
<div className="flex flex-col gap-2 px-6">
<h3 className="text-lg font-medium mb-4 text-slate-700">
Executed Plans
</h3>
{props.executedPlans.map((step, index) => (
<p key={index} className="font-mono text-sm">
{step}
</p>
))}
</div>
<div className="flex flex-col gap-2 px-6">
<h3 className="text-lg font-medium mb-4 text-slate-700">
Rejected Plans
</h3>
{props.rejectedPlans.map((step, index) => (
<p key={index} className="font-mono text-sm">
{step}
</p>
))}
</div>
</div>
</motion.div>
<motion.button
className="w-full py-2 border-t border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<motion.span
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
>
<ChevronDown className="w-5 h-5 text-slate-600" />
</motion.span>
</motion.button>
</div>
);
}
@@ -1,122 +0,0 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@@ -1,208 +0,0 @@
import "./index.css";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@/components/ui/button";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { UIMessage, useStreamContext } from "@langchain/langgraph-sdk/react-ui";
import { Message } from "@langchain/langgraph-sdk";
import { useEffect, useState } from "react";
import { getToolResponse } from "../../utils/get-tool-response";
import { useArtifact } from "../../utils/use-artifact";
import { cn } from "@/lib/utils";
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
interface ProposedChangeProps {
toolCallId: string;
change: string;
planItem: string;
/**
* Whether or not to show the "Accept"/"Reject" buttons
* If true, this means the user selected the "Accept, don't ask again"
* button for this session.
*/
fullWriteAccess: boolean;
}
const ACCEPTED_CHANGE_CONTENT =
"User accepted the proposed change. Please continue.";
const REJECTED_CHANGE_CONTENT =
"User rejected the proposed change. Please continue.";
export default function ProposedChange(props: ProposedChangeProps) {
const [isAccepted, setIsAccepted] = useState(false);
const [isRejected, setIsRejected] = useState(false);
const thread = useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { ui: UIMessage | undefined } }
>();
const [Artifact, { open, setOpen }] = useArtifact();
const handleReject = () => {
thread.submit({
messages: [
{
type: "tool",
tool_call_id: props.toolCallId,
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: "update_file",
content: REJECTED_CHANGE_CONTENT,
},
{
type: "human",
content: `Rejected change.`,
},
],
});
setIsRejected(true);
};
const handleAccept = (shouldGrantFullWriteAccess = false) => {
const humanMessageContent = `Accepted change. ${shouldGrantFullWriteAccess ? "Granted full write access." : ""}`;
thread.submit(
{
messages: [
{
type: "tool",
tool_call_id: props.toolCallId,
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: "update_file",
content: ACCEPTED_CHANGE_CONTENT,
},
{
type: "human",
content: humanMessageContent,
},
],
},
{
config: {
configurable: {
permissions: {
full_write_access: shouldGrantFullWriteAccess,
},
},
},
},
);
setIsAccepted(true);
};
useEffect(() => {
if (typeof window === "undefined" || isAccepted) return;
const toolResponse = getToolResponse(props.toolCallId, thread);
if (toolResponse) {
if (toolResponse.content === ACCEPTED_CHANGE_CONTENT) {
setIsAccepted(true);
} else if (toolResponse.content === REJECTED_CHANGE_CONTENT) {
setIsRejected(true);
}
}
}, []);
if (isAccepted || isRejected) {
return (
<>
<div
className={cn(
"flex flex-col gap-4 w-full max-w-4xl p-4 border-[1px] rounded-xl",
isAccepted ? "border-green-300" : "border-red-300",
)}
onClick={() => setOpen((open) => !open)}
>
<div className="flex flex-col items-start justify-start gap-2">
<p className="text-lg font-medium">
{isAccepted ? "Accepted" : "Rejected"} Change
</p>
<p className="text-sm font-mono">{props.planItem}</p>
</div>
</div>
<Artifact title={props.planItem}>
<ReactMarkdown
children={props.change}
components={{
code(props) {
const { children, className, node: _node } = props;
const match = /language-(\w+)/.exec(className || "");
return match ? (
<SyntaxHighlighter
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={coldarkDark}
customStyle={{ margin: 0 }}
/>
) : (
<code className={className}>{children}</code>
);
},
}}
/>
</Artifact>
</>
);
}
return (
<>
<div
className={cn(
"flex flex-col gap-4 w-full max-w-4xl p-4 border-[1px] rounded-xl border-slate-200 transition-all cursor-pointer",
open && "border-blue-400",
)}
onClick={() => setOpen((open) => !open)}
>
<div className="flex flex-col items-start justify-start gap-2">
<p className="text-lg font-medium">Proposed Change</p>
<p className="text-sm font-mono">{props.planItem}</p>
</div>
</div>
<Artifact title={props.planItem}>
<ReactMarkdown
children={props.change}
components={{
code(props) {
const { children, className, node: _node } = props;
const match = /language-(\w+)/.exec(className || "");
return match ? (
<SyntaxHighlighter
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={coldarkDark}
customStyle={{ margin: 0 }}
/>
) : (
<code className={className}>{children}</code>
);
},
}}
/>
{!props.fullWriteAccess && (
<div className="flex gap-2 items-center w-full">
<Button
className="cursor-pointer w-full"
variant="destructive"
onClick={handleReject}
>
Reject
</Button>
<Button
className="cursor-pointer w-full"
onClick={() => handleAccept()}
>
Accept
</Button>
<Button
className="cursor-pointer w-full bg-blue-500 hover:bg-blue-500/90"
onClick={() => handleAccept(true)}
>
Accept, don&apos;t ask again
</Button>
</div>
)}
</Artifact>
</>
);
}
@@ -1 +0,0 @@
@import "tailwindcss";
@@ -1,147 +0,0 @@
import "./index.css";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { UIMessage, useStreamContext } from "@langchain/langgraph-sdk/react-ui";
import { Message } from "@langchain/langgraph-sdk";
import { Snapshot } from "@/agent/types";
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
import { getToolResponse } from "@/agent-uis/utils/get-tool-response";
function Purchased({
ticker,
quantity,
price,
}: {
ticker: string;
quantity: number;
price: number;
}) {
return (
<div className="w-full md:w-lg rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<h1 className="text-xl font-medium mb-2">Purchase Executed - {ticker}</h1>
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div className="flex flex-col gap-2">
<p>Number of Shares</p>
<p>Market Price</p>
<p>Total Cost</p>
</div>
<div className="flex flex-col gap-2 items-end justify-end">
<p>{quantity}</p>
<p>${price}</p>
<p>${(quantity * price).toFixed(2)}</p>
</div>
</div>
</div>
);
}
export default function BuyStock(props: {
toolCallId: string;
snapshot: Snapshot;
quantity: number;
}) {
const { snapshot, toolCallId } = props;
const [quantity, setQuantity] = useState(props.quantity);
const [finalPurchase, setFinalPurchase] = useState<{
ticker: string;
quantity: number;
price: number;
}>();
const thread = useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { ui: UIMessage | undefined } }
>();
useEffect(() => {
if (typeof window === "undefined" || finalPurchase) return;
const toolResponse = getToolResponse(toolCallId, thread);
if (toolResponse) {
try {
const parsedContent: {
purchaseDetails: {
ticker: string;
quantity: number;
price: number;
};
} = JSON.parse(toolResponse.content as string);
setFinalPurchase(parsedContent.purchaseDetails);
} catch {
console.error("Failed to parse tool response content.");
}
}
}, []);
function handleBuyStock() {
const orderDetails = {
message: "Successfully purchased stock",
purchaseDetails: {
ticker: snapshot.ticker,
quantity: quantity,
price: snapshot.price,
},
};
thread.submit(
{},
{
command: {
update: {
messages: [
{
type: "tool",
tool_call_id: toolCallId,
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: "buy-stock",
content: JSON.stringify(orderDetails),
},
{
type: "human",
content: `Purchased ${quantity} shares of ${snapshot.ticker} at ${snapshot.price} per share`,
},
],
},
goto: "generalInput",
},
},
);
setFinalPurchase(orderDetails.purchaseDetails);
}
if (finalPurchase) {
return <Purchased {...finalPurchase} />;
}
return (
<div className="w-full md:w-lg rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<h1 className="text-xl font-medium mb-2">Buy {snapshot.ticker}</h1>
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div className="flex flex-col gap-2">
<p>Number of Shares</p>
<p>Market Price</p>
<p>Total Cost</p>
</div>
<div className="flex flex-col gap-2 items-end justify-end">
<Input
type="number"
className="max-w-[100px] border-0 border-b focus:border-b-2 rounded-none shadow-none focus:ring-0"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min={1}
/>
<p>${snapshot.price}</p>
<p>${(quantity * snapshot.price).toFixed(2)}</p>
</div>
</div>
<Button
className="w-full bg-green-600 hover:bg-green-700 transition-colors ease-in-out duration-200 cursor-pointer text-white"
onClick={handleBuyStock}
>
Buy
</Button>
</div>
);
}
@@ -1 +0,0 @@
@import "tailwindcss";
@@ -1,959 +0,0 @@
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>
);
}
@@ -1 +0,0 @@
@import "tailwindcss";
@@ -1,217 +0,0 @@
import "./index.css";
import { useState, useMemo } from "react";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Price } from "@/agent/types";
const chartConfig = {
price: {
label: "Price",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
type DisplayRange = "1d" | "5d" | "1m";
function DisplayRangeSelector({
displayRange,
setDisplayRange,
}: {
displayRange: DisplayRange;
setDisplayRange: (range: DisplayRange) => void;
}) {
const sharedClass =
" bg-transparent text-gray-500 hover:bg-gray-50 transition-colors ease-in-out duration-200 p-2 cursor-pointer";
const selectedClass = `text-black bg-gray-100 hover:bg-gray-50`;
return (
<div className="flex flex-row items-center justify-start gap-2">
<Button
className={cn(sharedClass, displayRange === "1d" && selectedClass)}
variant={displayRange === "1d" ? "default" : "ghost"}
onClick={() => setDisplayRange("1d")}
>
1D
</Button>
<p className="text-gray-300">|</p>
<Button
className={cn(sharedClass, displayRange === "5d" && selectedClass)}
variant={displayRange === "5d" ? "default" : "ghost"}
onClick={() => setDisplayRange("5d")}
>
5D
</Button>
<p className="text-gray-300">|</p>
<Button
className={cn(sharedClass, displayRange === "1m" && selectedClass)}
variant={displayRange === "1m" ? "default" : "ghost"}
onClick={() => setDisplayRange("1m")}
>
1M
</Button>
</div>
);
}
function getPropsForDisplayRange(
displayRange: DisplayRange,
oneDayPrices: Price[],
thirtyDayPrices: Price[],
) {
const now = new Date();
const fiveDays = 5 * 24 * 60 * 60 * 1000; // 5 days in milliseconds
switch (displayRange) {
case "1d":
return oneDayPrices;
case "5d":
return thirtyDayPrices.filter(
(p) => new Date(p.time).getTime() >= now.getTime() - fiveDays,
);
case "1m":
return thirtyDayPrices;
default:
return [];
}
}
export default function StockPrice(props: {
ticker: string;
oneDayPrices: Price[];
thirtyDayPrices: Price[];
}) {
const { ticker } = props;
const { oneDayPrices, thirtyDayPrices } = props;
const [displayRange, setDisplayRange] = useState<DisplayRange>("1d");
const {
currentPrice,
openPrice,
dollarChange,
percentChange,
highPrice,
lowPrice,
chartData,
change,
} = useMemo(() => {
const prices = getPropsForDisplayRange(
displayRange,
oneDayPrices,
thirtyDayPrices,
);
const firstPrice = prices[0];
const lastPrice = prices[prices.length - 1];
const currentPrice = lastPrice?.close;
const openPrice = firstPrice?.open;
const dollarChange = currentPrice - openPrice;
const percentChange = ((currentPrice - openPrice) / openPrice) * 100;
const highPrice = prices.reduce(
(acc, p) => Math.max(acc, p.high),
-Infinity,
);
const lowPrice = prices.reduce((acc, p) => Math.min(acc, p.low), Infinity);
const chartData = prices.map((p) => ({
time: p.time,
price: p.close,
}));
const change: "up" | "down" = dollarChange > 0 ? "up" : "down";
return {
currentPrice,
openPrice,
dollarChange,
percentChange,
highPrice,
lowPrice,
chartData,
change,
};
}, [oneDayPrices, thirtyDayPrices, displayRange]);
const formatDateByDisplayRange = (value: string, isTooltip?: boolean) => {
if (displayRange === "1d") {
return format(value, "h:mm a");
}
if (isTooltip) {
return format(value, "LLL do h:mm a");
}
return format(value, "LLL do");
};
return (
<div className="w-full max-w-3xl rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<div className="flex items-center justify-start gap-4 mb-2 text-lg font-medium text-gray-700">
<p>{ticker}</p>
<p>${currentPrice}</p>
</div>
<div className="flex flex-col gap-2">
<p className={change === "up" ? "text-green-500" : "text-red-500"}>
${dollarChange.toFixed(2)} (${percentChange.toFixed(2)}%)
</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex flex-col gap-2">
<p>Open</p>
<p>High</p>
<p>Low</p>
</div>
<div className="flex flex-col gap-2">
<p>${openPrice}</p>
<p>${highPrice}</p>
<p>${lowPrice}</p>
</div>
</div>
<DisplayRangeSelector
displayRange={displayRange}
setDisplayRange={setDisplayRange}
/>
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(v) => formatDateByDisplayRange(v)}
/>
<YAxis
domain={[lowPrice - 2, highPrice + 2]}
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => `${value.toFixed(2)}`}
/>
<ChartTooltip
cursor={false}
wrapperStyle={{ backgroundColor: "white" }}
content={
<ChartTooltipContent
hideLabel={false}
labelFormatter={(v) => formatDateByDisplayRange(v, true)}
/>
}
/>
<Line dataKey="price" type="natural" strokeWidth={2} dot={false} />
</LineChart>
</ChartContainer>
</div>
);
}
@@ -1 +0,0 @@
@import "tailwindcss";
@@ -1,349 +0,0 @@
import "./index.css";
import { v4 as uuidv4 } from "uuid";
import {
useStreamContext,
type UIMessage,
} from "@langchain/langgraph-sdk/react-ui";
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { format } from "date-fns";
import { Message } from "@langchain/langgraph-sdk";
import { getToolResponse } from "../../utils/get-tool-response";
import { capitalizeSentence } from "@/agent/utils/capitalize";
import { TripDetails } from "@/agent/trip-planner/types";
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
import { Accommodation } from "@/agent/types";
const StarSVG = ({ fill = "white" }: { fill?: string }) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.73158 0.80127L6.26121 3.40923L9.23158 4.04798L7.20658 6.29854L7.51273 9.30127L4.73158 8.08423L1.95043 9.30127L2.25658 6.29854L0.23158 4.04798L3.20195 3.40923L4.73158 0.80127Z"
fill={fill}
/>
</svg>
);
function AccommodationCard({
accommodation,
}: {
accommodation: Accommodation;
}) {
return (
<div
className="relative w-[161px] h-[256px] rounded-2xl shadow-md overflow-hidden"
style={{
backgroundImage: `url(${accommodation.image})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<div className="absolute bottom-0 left-0 right-0 flex flex-col gap-1 p-3 text-white bg-gradient-to-t from-black/70 to-transparent">
<p className="text-sm font-semibold">{accommodation.name}</p>
<div className="flex items-center gap-1 text-xs">
<p className="flex items-center justify-center">
<StarSVG />
{accommodation.rating}
</p>
<p>·</p>
<p>{accommodation.price}</p>
</div>
<p className="text-sm">{capitalizeSentence(accommodation.city)}</p>
</div>
</div>
);
}
function SelectedAccommodation({
accommodation,
onHide,
tripDetails,
onBook,
}: {
accommodation: Accommodation;
onHide: () => void;
tripDetails: TripDetails;
onBook: (accommodation: Accommodation) => void;
}) {
const startDate = new Date(tripDetails.startDate);
const endDate = new Date(tripDetails.endDate);
const totalTripDurationDays = Math.max(
startDate.getDate() - endDate.getDate(),
1,
);
const totalPrice = totalTripDurationDays * accommodation.price;
return (
<div className="w-full flex gap-6 rounded-2xl overflow-hidden bg-white shadow-lg">
<div className="w-2/3 h-[400px]">
<img
src={accommodation.image}
alt={accommodation.name}
className="w-full h-full object-cover"
/>
</div>
<div className="w-1/3 p-4 flex flex-col">
<div className="flex justify-between items-center mb-4 gap-3">
<h3 className="text-xl font-semibold">{accommodation.name}</h3>
<Button
variant="ghost"
size="sm"
onClick={onHide}
className="cursor-pointer hover:bg-gray-50 transition-colors ease-in-out duration-200 text-gray-500 w-5 h-5"
>
<X className="w-3 h-3" />
</Button>
</div>
<div className="flex-1 space-y-4">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1">
<StarSVG fill="black" />
{accommodation.rating}
</span>
<p className="text-gray-600">
{capitalizeSentence(accommodation.city)}
</p>
</div>
<div className="space-y-2 text-sm text-gray-600">
<div className="flex justify-between">
<span>Check-in</span>
<span>{format(startDate, "MMM d, yyyy")}</span>
</div>
<div className="flex justify-between">
<span>Check-out</span>
<span>{format(endDate, "MMM d, yyyy")}</span>
</div>
<div className="flex justify-between">
<span>Guests</span>
<span>{tripDetails.numberOfGuests}</span>
</div>
<div className="flex justify-between font-semibold text-black">
<span>Total Price</span>
<span>${totalPrice.toLocaleString()}</span>
</div>
</div>
</div>
<Button
onClick={() => onBook(accommodation)}
variant="secondary"
className="w-full bg-gray-800 text-white hover:bg-gray-900 cursor-pointer transition-colors ease-in-out duration-200"
>
Book
</Button>
</div>
</div>
);
}
function BookedAccommodation({
accommodation,
tripDetails,
}: {
accommodation: Accommodation;
tripDetails: TripDetails;
}) {
const startDate = new Date(tripDetails.startDate);
const endDate = new Date(tripDetails.endDate);
const totalTripDurationDays = Math.max(
startDate.getDate() - endDate.getDate(),
1,
);
const totalPrice = totalTripDurationDays * accommodation.price;
return (
<div
className="relative w-full h-[400px] rounded-2xl shadow-md overflow-hidden"
style={{
backgroundImage: `url(${accommodation.image})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<div className="absolute bottom-0 left-0 right-0 flex flex-col gap-2 p-6 text-white bg-gradient-to-t from-black/90 via-black/70 to-transparent">
<p className="text-lg font-medium">Booked Accommodation</p>
<div className="flex justify-between items-baseline">
<h3 className="text-xl font-semibold"></h3>
</div>
<div className="grid grid-cols-2 gap-x-12 gap-y-2 text-sm">
<div className="flex justify-between">
<span>Address:</span>
</div>
<div className="flex justify-between">
<span>
{accommodation.name}, {capitalizeSentence(accommodation.city)}
</span>
</div>
<div className="flex justify-between">
<span>Rating:</span>
</div>
<div className="flex justify-between">
<span className="flex items-center gap-1">
<StarSVG />
{accommodation.rating}
</span>
</div>
<div className="flex justify-between">
<span>Dates:</span>
</div>
<div className="flex justify-between">
<span>
{format(startDate, "MMM d, yyyy")} -{" "}
{format(endDate, "MMM d, yyyy")}
</span>
</div>
<div className="flex justify-between">
<span>Guests:</span>
</div>
<div className="flex justify-between">
<span>{tripDetails.numberOfGuests}</span>
</div>
<div className="flex justify-between font-semibold">
<span>Total Price:</span>
</div>
<div className="flex justify-between font-semibold">
<span>${totalPrice.toLocaleString()}</span>
</div>
</div>
</div>
</div>
);
}
export default function AccommodationsList({
toolCallId,
tripDetails,
accommodations,
}: {
toolCallId: string;
tripDetails: TripDetails;
accommodations: Accommodation[];
}) {
const thread = useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { ui: UIMessage | undefined } }
>();
const [selectedAccommodation, setSelectedAccommodation] = useState<
Accommodation | undefined
>();
const [accommodationBooked, setAccommodationBooked] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || accommodationBooked) return;
const toolResponse = getToolResponse(toolCallId, thread);
if (toolResponse) {
setAccommodationBooked(true);
try {
const parsedContent: {
accommodation: Accommodation;
tripDetails: TripDetails;
} = JSON.parse(toolResponse.content as string);
setSelectedAccommodation(parsedContent.accommodation);
} catch {
console.error("Failed to parse tool response content.");
}
}
}, []);
function handleBookAccommodation(accommodation: Accommodation) {
const orderDetails = {
accommodation,
tripDetails,
};
thread.submit(
{},
{
command: {
update: {
messages: [
{
type: "tool",
tool_call_id: toolCallId,
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: "book-accommodation",
content: JSON.stringify(orderDetails),
},
{
type: "human",
content: `Booked ${accommodation.name} for ${tripDetails.numberOfGuests}.`,
},
],
},
goto: "generalInput",
},
},
);
setAccommodationBooked(true);
if (selectedAccommodation?.id !== accommodation.id) {
setSelectedAccommodation(accommodation);
}
}
if (accommodationBooked && selectedAccommodation) {
return (
<BookedAccommodation
tripDetails={tripDetails}
accommodation={selectedAccommodation}
/>
);
} else if (accommodationBooked) {
return <div>Successfully booked accommodation!</div>;
}
if (selectedAccommodation) {
return (
<SelectedAccommodation
tripDetails={tripDetails}
onHide={() => setSelectedAccommodation(undefined)}
accommodation={selectedAccommodation}
onBook={handleBookAccommodation}
/>
);
}
return (
<div className="space-y-8">
<Carousel
opts={{
align: "start",
loop: true,
}}
className="w-full sm:max-w-sm md:max-w-3xl lg:max-w-3xl"
>
<CarouselContent>
{accommodations.map((accommodation) => (
<CarouselItem
key={accommodation.id}
className="basis-1/2 md:basis-1/4"
onClick={() => setSelectedAccommodation(accommodation)}
>
<AccommodationCard accommodation={accommodation} />
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
);
}
@@ -1 +0,0 @@
@import "tailwindcss";
@@ -1,250 +0,0 @@
import { TripDetails } from "@/agent/trip-planner/types";
import "./index.css";
import { useState } from "react";
export default function RestaurantsList({
tripDetails,
}: {
tripDetails: TripDetails;
}) {
// Placeholder data - ideally would come from props
const [restaurants] = useState([
{
id: "1",
name: "The Local Grill",
cuisine: "Steakhouse",
priceRange: "$$",
rating: 4.7,
distance: "0.5 miles from center",
image: "https://placehold.co/300x200?text=Restaurant1",
openingHours: "5:00 PM - 10:00 PM",
popular: true,
},
{
id: "2",
name: "Ocean Breeze",
cuisine: "Seafood",
priceRange: "$$$",
rating: 4.9,
distance: "0.8 miles from center",
image: "https://placehold.co/300x200?text=Restaurant2",
openingHours: "12:00 PM - 11:00 PM",
popular: true,
},
{
id: "3",
name: "Pasta Paradise",
cuisine: "Italian",
priceRange: "$$",
rating: 4.5,
distance: "1.2 miles from center",
image: "https://placehold.co/300x200?text=Restaurant3",
openingHours: "11:30 AM - 9:30 PM",
popular: false,
},
{
id: "4",
name: "Spice Garden",
cuisine: "Indian",
priceRange: "$$",
rating: 4.6,
distance: "0.7 miles from center",
image: "https://placehold.co/300x200?text=Restaurant4",
openingHours: "12:00 PM - 10:00 PM",
popular: false,
},
]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = useState<string | null>(null);
const selectedRestaurant = restaurants.find((r) => r.id === selectedId);
const filteredRestaurants = filter
? restaurants.filter((r) => r.cuisine === filter)
: restaurants;
const cuisines = Array.from(new Set(restaurants.map((r) => r.cuisine)));
return (
<div className="w-full max-w-md bg-white rounded-lg shadow-md overflow-hidden">
<div className="bg-orange-600 px-4 py-3">
<div className="flex justify-between items-center">
<h3 className="text-white font-medium">
Restaurants in {tripDetails.location}
</h3>
{selectedId && (
<button
onClick={() => setSelectedId(null)}
className="text-white text-sm bg-orange-700 hover:bg-orange-800 px-2 py-1 rounded"
>
Back to list
</button>
)}
</div>
<p className="text-orange-100 text-xs">
For your trip {new Date(tripDetails.startDate).toLocaleDateString()} -{" "}
{new Date(tripDetails.endDate).toLocaleDateString()}
</p>
</div>
{!selectedId ? (
<div className="p-4">
<div className="mb-3">
<div className="flex flex-wrap gap-1 mb-1">
<button
onClick={() => setFilter(null)}
className={`px-2 py-1 text-xs rounded-full ${
filter === null
? "bg-orange-600 text-white"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
All
</button>
{cuisines.map((cuisine) => (
<button
key={cuisine}
onClick={() => setFilter(cuisine)}
className={`px-2 py-1 text-xs rounded-full ${
filter === cuisine
? "bg-orange-600 text-white"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
{cuisine}
</button>
))}
</div>
<p className="text-xs text-gray-500">
Showing {filteredRestaurants.length} restaurants{" "}
{filter ? `in ${filter}` : ""}
</p>
</div>
<div className="space-y-3">
{filteredRestaurants.map((restaurant) => (
<div
key={restaurant.id}
onClick={() => setSelectedId(restaurant.id)}
className="border rounded-lg p-3 cursor-pointer hover:border-orange-300 hover:shadow-md transition-all"
>
<div className="flex">
<div className="w-20 h-20 bg-gray-200 rounded-md flex-shrink-0 overflow-hidden">
<img
src={restaurant.image}
alt={restaurant.name}
className="w-full h-full object-cover"
/>
</div>
<div className="ml-3 flex-1">
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium text-gray-900">
{restaurant.name}
</h4>
<p className="text-sm text-gray-500">
{restaurant.cuisine}
</p>
</div>
<span className="text-sm text-gray-700">
{restaurant.priceRange}
</span>
</div>
<div className="flex items-center mt-1">
<svg
className="w-4 h-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span className="text-xs text-gray-500 ml-1">
{restaurant.rating}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-xs text-gray-500">
{restaurant.distance}
</span>
{restaurant.popular && (
<span className="text-xs bg-orange-100 text-orange-800 px-1.5 py-0.5 rounded-sm">
Popular
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="p-4">
{selectedRestaurant && (
<div className="space-y-4">
<div className="w-full h-40 bg-gray-200 rounded-lg overflow-hidden">
<img
src={selectedRestaurant.image}
alt={selectedRestaurant.name}
className="w-full h-full object-cover"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-lg text-gray-900">
{selectedRestaurant.name}
</h3>
<p className="text-sm text-gray-600">
{selectedRestaurant.cuisine}
</p>
</div>
<span className="text-gray-700 font-medium">
{selectedRestaurant.priceRange}
</span>
</div>
<div className="flex items-center">
<svg
className="w-4 h-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span className="text-sm text-gray-600 ml-1">
{selectedRestaurant.rating} rating
</span>
</div>
<div className="flex items-center text-sm text-gray-600 space-x-4">
<span>{selectedRestaurant.distance}</span>
<span>•</span>
<span>{selectedRestaurant.openingHours}</span>
</div>
<p className="text-sm text-gray-600 pt-2 border-t">
{selectedRestaurant.name} offers a wonderful dining experience
in {tripDetails.location}. Perfect for a group of{" "}
{tripDetails.numberOfGuests} guests. Enjoy authentic{" "}
{selectedRestaurant.cuisine} cuisine in a relaxed atmosphere.
</p>
<div className="pt-3 flex flex-col space-y-2">
<button className="w-full bg-orange-600 hover:bg-orange-700 text-white font-medium py-2 px-4 rounded-md transition-colors">
Reserve a Table
</button>
<button className="w-full bg-white border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded-md hover:bg-gray-50 transition-colors">
View Menu
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
-63
View File
@@ -1,63 +0,0 @@
import { useArtifact } from "../utils/use-artifact";
import { useEffect, useRef, useState } from "react";
import { LoaderIcon } from "lucide-react";
export function Writer(props: {
title?: string;
content?: string;
description?: string;
isGenerating: boolean;
}) {
const [Artifact, { open, setOpen, setContext }] = useArtifact<{
writer?: { selected?: string };
}>();
const [content, setContent] = useState(props.content ?? "");
useEffect(() => setContent(props.content ?? ""), [props.content]);
const prevOpened = useRef(false);
const shouldAutoOpen = !open && content.length > 0 && props.isGenerating;
useEffect(() => {
if (shouldAutoOpen && !prevOpened.current) {
prevOpened.current = true;
setOpen(true);
}
}, [shouldAutoOpen, setOpen]);
return (
<>
<div
onClick={() => setOpen(!open)}
className="border p-4 rounded-lg cursor-pointer"
>
<p className="font-medium">{props.title}</p>
<p className="text-sm text-gray-500">{props.description}</p>
{props.isGenerating && (
<p className="flex items-center gap-2">
<LoaderIcon className="animate-spin" />
<span>Generating...</span>
</p>
)}
</div>
<Artifact title={props.title}>
<textarea
className="absolute inset-0 w-full h-full p-4 outline-none"
value={content}
onChange={(e) => setContent(e.target.value)}
onSelect={(e) => {
const selectedText = e.currentTarget.value.substring(
e.currentTarget.selectionStart,
e.currentTarget.selectionEnd,
);
setContext((prevContext) => ({
...prevContext,
writer: { ...prevContext?.writer, selected: selectedText },
}));
}}
/>
</Artifact>
</>
);
}
+2 -4
View File
@@ -4,7 +4,7 @@ import {
START,
StateGraph,
} from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { createLlm } from "@/agent/utils/create-llm";
const ChatAgentAnnotation = Annotation.Root({
messages: MessagesAnnotation.spec["messages"],
@@ -12,9 +12,7 @@ const ChatAgentAnnotation = Annotation.Root({
const graph = new StateGraph(ChatAgentAnnotation)
.addNode("chat", async (state) => {
const model = new ChatOpenAI({
model: "gpt-4o-mini",
});
const model = createLlm();
const response = await model.invoke([
{ role: "system", content: "You are a helpful assistant." },
-47
View File
@@ -1,47 +0,0 @@
import { END, START, StateGraph } from "@langchain/langgraph";
import { EmailAgentAnnotation, EmailAgentState } from "./types";
import { writeEmail } from "./nodes/write-email";
import { interruptNode } from "./nodes/interrupt";
import { sendEmail } from "./nodes/send-email";
import { rewriteEmail } from "./nodes/rewrite-email";
function routeAfterInterrupt(
state: EmailAgentState,
): typeof END | "sendEmail" | "rewriteEmail" {
const responseType = state.humanResponse?.type;
if (!responseType || responseType === "ignore") {
return END;
}
if (responseType === "response") {
return "rewriteEmail";
}
return "sendEmail";
}
function routeAfterWritingEmail(
state: EmailAgentState,
): typeof END | "interrupt" {
if (!state.email) {
return END;
}
return "interrupt";
}
const graph = new StateGraph(EmailAgentAnnotation)
.addNode("writeEmail", writeEmail)
.addNode("interrupt", interruptNode)
.addNode("sendEmail", sendEmail)
.addNode("rewriteEmail", rewriteEmail)
.addEdge(START, "writeEmail")
.addConditionalEdges("writeEmail", routeAfterWritingEmail, [END, "interrupt"])
.addConditionalEdges("interrupt", routeAfterInterrupt, [
"sendEmail",
"rewriteEmail",
END,
])
.addEdge("rewriteEmail", "interrupt")
.addEdge("sendEmail", END);
export const agent = graph.compile();
agent.name = "Email Assistant Agent";
@@ -1,77 +0,0 @@
import { Email, EmailAgentState, EmailAgentUpdate } from "../types";
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
import { interrupt } from "@langchain/langgraph";
export async function interruptNode(
state: EmailAgentState,
): Promise<EmailAgentUpdate> {
if (!state.email) {
throw new Error("Can not interrupt if email is undefined.");
}
const description = `# New Email
## Subject
${state.email.subject}
## To
${state.email.to}
## Body
${state.email.body}
## Response Instructions
- **Response**: Any response submitted will be passed to an LLM to rewrite the email. It can rewrite the email body, subject, or recipient.
- **Edit or Accept**: Editing/Accepting the email will send the email.
- **Ignore**: Ignoring the email will end the conversation, and the email will not be sent.`;
const res = interrupt<HumanInterrupt[], HumanResponse[]>([
{
action_request: {
action: "New Email Draft",
args: {
...state.email,
},
},
description,
config: {
allow_ignore: true,
allow_respond: true,
allow_edit: true,
allow_accept: true,
},
},
])[0];
if (["ignore", "response", "accept"].includes(res.type)) {
return {
humanResponse: res,
};
}
if (
typeof res.args !== "object" ||
!res.args ||
!("subject" in res.args) ||
!("body" in res.args) ||
!("to" in res.args)
) {
throw new Error(
"If response type is edit, args must be an object with 'subject', 'body', and 'to' fields.",
);
}
const { subject, body, to } = res.args as Email;
return {
email: {
subject,
body,
to,
},
humanResponse: res,
};
}
@@ -1,81 +0,0 @@
import { z } from "zod";
import { EmailAgentState, EmailAgentUpdate } from "../types";
import { ChatOpenAI } from "@langchain/openai";
const REWRITE_EMAIL_PROMPT = `You're an AI email assistant, tasked with rewriting an email for the user.
Here is the current state of the email for the user:
<email>
<subject>
{SUBJECT}
</subject>
<body>
{BODY}
</body>
<to>
{TO}
</to>
</email>
Here is the user's response, which should contain some request for changes to the email:
<user-response>
{USER_RESPONSE}
</user-response>
Given that, please rewrite the email. Do NOT modify anything the user does not request to be changed.`;
const sendEmailSchema = z.object({
subject: z.string().describe("The subject of the email"),
body: z.string().describe("The body of the email"),
to: z.string().describe("The recipient of the email"),
});
export async function rewriteEmail(
state: EmailAgentState,
): Promise<EmailAgentUpdate> {
if (
!state.humanResponse?.args ||
typeof state.humanResponse.args !== "string"
) {
throw new Error(
"Can not rewrite email if human response args is not defined, or type string.",
);
}
if (!state.email) {
throw new Error("Can not rewrite email if email is undefined.");
}
const model = new ChatOpenAI({
model: "gpt-4o",
temperature: 0,
}).bindTools(
[
{
name: "write_email",
description: "Write an email based on the conversation history",
schema: sendEmailSchema,
},
],
{
tool_choice: "write_email",
},
);
const prompt = REWRITE_EMAIL_PROMPT.replace("{SUBJECT}", state.email.subject)
.replace("{BODY}", state.email.body)
.replace("{TO}", state.email.to)
.replace("{USER_RESPONSE}", state.humanResponse.args);
const response = await model.invoke([{ role: "user", content: prompt }]);
const toolCall = response.tool_calls?.[0]?.args as
| z.infer<typeof sendEmailSchema>
| undefined;
if (!toolCall) {
throw new Error("Failed to generate email");
}
return {
email: toolCall,
messages: [response],
};
}
@@ -1,17 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { AIMessage } from "@langchain/langgraph-sdk";
import { EmailAgentState, EmailAgentUpdate } from "../types";
export async function sendEmail(
_state: EmailAgentState,
): Promise<EmailAgentUpdate> {
// Should yield a gen ui component rendering a 'sent' email.
const tmpAiMessage: AIMessage = {
type: "ai",
id: uuidv4(),
content: "Successfully sent email.",
};
return {
messages: [tmpAiMessage],
};
}
@@ -1,59 +0,0 @@
import { z } from "zod";
import { EmailAgentState, EmailAgentUpdate } from "../types";
import { ChatOpenAI } from "@langchain/openai";
import { formatMessages } from "@/agent/utils/format-messages";
const SEND_EMAIL_PROMPT = `You're an AI email assistant, tasked with writing an email for the user.
Use the entire conversation history between you, and the user to craft the email for them.
<conversation>
{CONVERSATION}
</conversation>
If there is NOT enough information to send an email, respond to the user requesting the missing information.
Required fields:
- subject - The subject of the email
- body - The body of the email
- to - The recipient of the email`;
const sendEmailSchema = z.object({
subject: z.string().describe("The subject of the email"),
body: z.string().describe("The body of the email"),
to: z.string().describe("The recipient of the email"),
});
export async function writeEmail(
state: EmailAgentState,
): Promise<EmailAgentUpdate> {
const model = new ChatOpenAI({
model: "gpt-4o",
temperature: 0,
}).bindTools([
{
name: "write_email",
description: "Write an email based on the conversation history",
schema: sendEmailSchema,
},
]);
const prompt = SEND_EMAIL_PROMPT.replace(
"{CONVERSATION}",
formatMessages(state.messages),
);
const response = await model.invoke([{ role: "user", content: prompt }]);
const toolCall = response.tool_calls?.[0]?.args as
| z.infer<typeof sendEmailSchema>
| undefined;
if (!toolCall) {
return {
messages: [response],
};
}
return {
email: toolCall,
messages: [response],
};
}
-18
View File
@@ -1,18 +0,0 @@
import { Annotation } from "@langchain/langgraph";
import { GenerativeUIAnnotation } from "../types";
import { HumanResponse } from "@langchain/langgraph/prebuilt";
export type Email = {
subject: string;
body: string;
to: string;
};
export const EmailAgentAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
email: Annotation<Email | undefined>(),
humanResponse: Annotation<HumanResponse | undefined>(),
});
export type EmailAgentState = typeof EmailAgentAnnotation.State;
export type EmailAgentUpdate = typeof EmailAgentAnnotation.Update;
+41 -5
View File
@@ -1,10 +1,46 @@
import { StateGraph, START } from "@langchain/langgraph";
import { EnterpriseAnnotation } from "./types.js";
import { enterpriseToolsNode } from "./nodes/tools.js";
import { StateGraph, START, END } from "@langchain/langgraph";
import { AIMessage } from "@langchain/core/messages";
import { EnterpriseAnnotation, EnterpriseState } from "./types.js";
import { agentNode } from "./nodes/agent.js";
import { toolExecutorNode } from "./nodes/tool-executor.js";
const MAX_ITERATIONS = 6;
/**
* Route after agent node:
* - If the last message has tool_calls and we haven't exceeded MAX_ITERATIONS, go to tool-executor.
* - Otherwise, end the graph.
*/
function routeAfterAgent(
state: EnterpriseState,
): "tool-executor" | typeof END {
const lastMsg = state.messages[state.messages.length - 1];
// Check if the last message is an AI message with tool calls
const aiMsg = lastMsg as AIMessage | undefined;
if (aiMsg?.tool_calls && aiMsg.tool_calls.length > 0) {
// Count how many tool-calling rounds have occurred so far
const toolRounds = state.messages.filter(
(m) =>
(m as AIMessage).tool_calls !== undefined &&
((m as AIMessage).tool_calls?.length ?? 0) > 0,
).length;
if (toolRounds >= MAX_ITERATIONS) {
return END;
}
return "tool-executor";
}
return END;
}
const builder = new StateGraph(EnterpriseAnnotation)
.addNode("tools", enterpriseToolsNode)
.addEdge(START, "tools");
.addNode("agent", agentNode)
.addNode("tool-executor", toolExecutorNode)
.addEdge(START, "agent")
.addConditionalEdges("agent", routeAfterAgent, ["tool-executor", END])
.addEdge("tool-executor", "agent");
export const enterpriseGraph = builder.compile();
enterpriseGraph.name = "Enterprise";
@@ -0,0 +1,47 @@
/**
* Agent node: LLM thinks and decides whether to call tools.
* Does NOT execute tools — only returns the AI message (possibly with tool_calls).
*/
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { EnterpriseState, EnterpriseUpdate } from "../types.js";
import { filterTools } from "./tool-defs.js";
const SYSTEM_PROMPT = `你是企业智能助手,能够查询内部知识库、工单系统、互联网信息,以及执行代码。
## 工具使用指南
- 根据用户问题选择合适的工具。你可以在一轮中同时调用多个工具。
- 如果一个工具的结果不够完整,你可以在下一轮继续调用工具补充信息。
- 工具结果会以可视化卡片展示给用户,无需在文字中重复数据细节,只需提供简洁的分析和洞察。
- 当你已经收集到足够的信息来回答用户问题时,直接给出最终回答,不要再调用工具。
- 如果用户的问题不需要任何工具,直接回答即可。
## 回答规范
- 始终用中文回复。
- 基于工具返回的实际数据进行分析,不要编造数据。
- 如果工具调用失败,告知用户并建议替代方案。`;
export async function agentNode(
state: EnterpriseState,
config: LangGraphRunnableConfig,
): Promise<EnterpriseUpdate> {
const modelMode = ((config.configurable?.modelMode as string) ?? "auto") as ModelMode;
const enabledTools = config.configurable?.enabledTools as string[] | undefined;
const llm = createLlm({ modelMode });
const tools = filterTools(modelMode, enabledTools);
const messagesWithSystem = [
{ role: "system" as const, content: SYSTEM_PROMPT },
...state.messages,
];
// If no tools available after filtering, invoke LLM without tool binding
if (tools.length === 0) {
const message = await llm.invoke(messagesWithSystem);
return { messages: [message], timestamp: Date.now() };
}
const message = await llm.bindTools(tools).invoke(messagesWithSystem);
return { messages: [message], timestamp: Date.now() };
}
@@ -0,0 +1,97 @@
/**
* Enterprise tool definitions shared by agent and tool-executor nodes.
*/
import { z } from "zod";
import type { ModelMode } from "@/agent/utils/create-llm";
// --- Tool schemas ---
export const kbSearchSchema = z.object({
query: z.string().describe("The search query for the knowledge base"),
});
export const ticketListSchema = z.object({
page: z.number().optional().describe("Page number, defaults to 1"),
});
export const ticketDetailSchema = z.object({
ticket_id: z.string().describe("The ticket number / ID"),
});
export const webSearchSchema = z.object({
query: z.string().describe("The web search query"),
});
export const googleSearchSchema = z.object({
query: z.string().describe("The Google search query"),
});
export 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"),
});
export const ALL_ENTERPRISE_TOOLS = [
{
name: "kb_search",
description:
"搜索内部知识库,查询公司内部文档、产品信息、技术资料",
schema: kbSearchSchema,
},
{
name: "ticket_list",
description:
"查询工单列表,获取当前工单状态、优先级、客户信息",
schema: ticketListSchema,
},
{
name: "ticket_detail",
description: "查询单个工单详情",
schema: ticketDetailSchema,
},
{
name: "web_search",
description:
"深度搜索互联网,通过 Jina 获取网页全文内容,适合需要详细阅读原文的场景",
schema: webSearchSchema,
},
{
name: "google_search",
description:
"快速 Google 搜索,获取结构化摘要结果,适合查找最新新闻、快速事实核查、获取概览信息",
schema: googleSearchSchema,
},
{
name: "sandbox_run",
description:
"在安全沙盒中执行代码,支持 Python、JavaScript、Bash",
schema: sandboxRunSchema,
},
] as const;
export type EnterpriseToolDef = (typeof ALL_ENTERPRISE_TOOLS)[number];
/**
* Filter tools based on modelMode and enabledTools from config.configurable.
* - enabledTools: if non-empty array, only keep tools whose name is in the list
* - modelMode "flash": remove web_search (too slow for flash mode)
* - modelMode "pro": remove google_search (use deeper Jina search instead)
* - modelMode "auto": keep all
*/
export function filterTools(
modelMode: ModelMode,
enabledTools?: string[],
): EnterpriseToolDef[] {
let tools: EnterpriseToolDef[] = [...ALL_ENTERPRISE_TOOLS];
// Filter by user-selected tools
if (enabledTools && enabledTools.length > 0) {
tools = tools.filter((t) => enabledTools.includes(t.name));
}
// Filter by model mode
if (modelMode === "flash") {
tools = tools.filter((t) => t.name !== "web_search");
} else if (modelMode === "pro") {
tools = tools.filter((t) => t.name !== "google_search");
}
return tools;
}
@@ -0,0 +1,255 @@
/**
* Tool executor node: executes tool calls from the last AI message,
* pushes Gen-UI cards, and returns ToolMessages.
* Does NOT call LLM — that is the agent node's job.
*/
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import type ComponentMap from "../../../agent-uis/index.js";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { AIMessage } from "@langchain/core/messages";
import { EnterpriseState, EnterpriseUpdate } from "../types.js";
import {
kbSearch,
ticketList,
ticketDetail,
webSearch,
googleSearch,
sandboxRun,
} from "../tools/soc-client.js";
import {
kbSearchSchema,
ticketListSchema,
ticketDetailSchema,
webSearchSchema,
googleSearchSchema,
sandboxRunSchema,
} from "./tool-defs.js";
import { findToolCall } from "../../find-tool-call.js";
export async function toolExecutorNode(
state: EnterpriseState,
config: LangGraphRunnableConfig,
): Promise<EnterpriseUpdate> {
const ui = typedUi<typeof ComponentMap>(config);
// Find the last AI message with tool_calls
const lastAiMessage = [...state.messages]
.reverse()
.find(
(m): m is AIMessage =>
(m as AIMessage).tool_calls !== undefined &&
((m as AIMessage).tool_calls?.length ?? 0) > 0,
) as AIMessage | undefined;
if (!lastAiMessage?.tool_calls?.length) {
return { ui: ui.items, timestamp: Date.now() };
}
const toolCalls = lastAiMessage.tool_calls!;
const toolMessages: Array<{
role: "tool";
tool_call_id: string;
content: string;
}> = [];
// Execute all tool calls in parallel
const executions = toolCalls.map(async (tc) => {
const name = tc.name;
const args = tc.args;
const id = tc.id ?? "";
try {
switch (name) {
case "kb_search": {
const parsed = kbSearchSchema.parse(args);
const data = await kbSearch(parsed.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: parsed.query,
total: results.length,
results,
},
},
{ message: lastAiMessage },
);
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({ total: results.length, results }),
};
}
case "ticket_list": {
const parsed = ticketListSchema.parse(args);
const data = await ticketList(parsed.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: lastAiMessage },
);
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({ total: tickets.length, tickets, stats }),
};
}
case "ticket_detail": {
const parsed = ticketDetailSchema.parse(args);
const t = await ticketDetail(parsed.ticket_id);
ui.push(
{
name: "ticket-detail",
props: {
id: String(t.ticketNumber ?? parsed.ticket_id),
title: String(t.description ?? "").slice(0, 80),
status: String(t.status ?? ""),
priority: String(t.priority ?? ""),
customer: String(
(t.customer as { name?: string })?.name ?? "",
),
engineer: String(
(t.assignedEngineer as { username?: string })?.username ??
"未分配",
),
created: String(t.createdAt ?? "").slice(0, 10),
description: String(t.description ?? "").slice(0, 500),
},
},
{ message: lastAiMessage },
);
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify(t),
};
}
case "web_search": {
const parsed = webSearchSchema.parse(args);
const data = await webSearch(parsed.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: parsed.query,
total: results.length,
results,
},
},
{ message: lastAiMessage },
);
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({ total: results.length, results }),
};
}
case "google_search": {
const parsed = googleSearchSchema.parse(args);
const data = await googleSearch(parsed.query);
const results = data.results.map((r) => ({
title: r.title,
url: r.url,
snippet: r.snippet,
}));
ui.push(
{
name: "search-result",
props: {
query: parsed.query,
total: results.length,
results,
},
},
{ message: lastAiMessage },
);
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({
total: results.length,
results,
knowledgeGraph: data.knowledgeGraph,
}),
};
}
case "sandbox_run": {
const parsed = sandboxRunSchema.parse(args);
const result = await sandboxRun(
parsed.code,
parsed.language ?? "python",
);
ui.push(
{
name: "sandbox-result",
props: {
language: parsed.language ?? "python",
exit_code: result.exit_code,
stdout: result.stdout,
has_more: result.stdout.length >= 2000,
duration_ms: result.duration_ms,
},
},
{ message: lastAiMessage },
);
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify(result),
};
}
default:
return {
role: "tool" as const,
tool_call_id: id,
content: `Unknown tool: ${name}`,
};
}
} catch (e) {
return {
role: "tool" as const,
tool_call_id: id,
content: `工具执行失败 (${name}): ${e}`,
};
}
});
const results = await Promise.all(executions);
toolMessages.push(...results);
return {
messages: toolMessages,
ui: ui.items,
timestamp: Date.now(),
};
}
@@ -1,317 +0,0 @@
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(),
};
}
@@ -116,6 +116,47 @@ export async function webSearch(query: string): Promise<{
return { results: enriched };
}
// --- Serper Google Search (fast, structured) ---
export async function googleSearch(query: string): Promise<{
results: Array<{
title: string;
url: string;
snippet: string;
}>;
knowledgeGraph?: {
title: string;
description: string;
};
}> {
const resp = await fetch("https://google.serper.dev/search", {
method: "POST",
headers: {
"X-API-KEY": process.env.SERPER_API_KEY ?? "",
"Content-Type": "application/json",
},
body: JSON.stringify({ q: query, num: 10, gl: "cn", hl: "zh-cn" }),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) throw new Error(`Serper search failed: ${resp.status}`);
const data = await resp.json();
const organic: Array<Record<string, string>> = data.organic ?? [];
const results = organic.slice(0, 8).map((r) => ({
title: r.title ?? "",
url: r.link ?? "",
snippet: r.snippet ?? "",
}));
const kg = data.knowledgeGraph
? {
title: String(data.knowledgeGraph.title ?? ""),
description: String(data.knowledgeGraph.description ?? ""),
}
: undefined;
return { results, knowledgeGraph: kg };
}
// --- Daytona Sandbox Execution ---
export async function sandboxRun(
code: string,
-45
View File
@@ -1,45 +0,0 @@
import {
END,
LangGraphRunnableConfig,
START,
StateGraph,
} from "@langchain/langgraph";
import { OpenCodeAnnotation, OpenCodeState } from "./types";
import { planner } from "./nodes/planner";
import {
executor,
SUCCESSFULLY_COMPLETED_STEPS_CONTENT,
} from "./nodes/executor";
import { AIMessage } from "@langchain/langgraph-sdk";
function conditionallyEnd(
state: OpenCodeState,
config: LangGraphRunnableConfig,
): typeof END | "planner" {
const fullWriteAccess = !!config.configurable?.permissions?.full_write_access;
const lastAiMessage = state.messages.findLast(
(m) => m.getType() === "ai",
) as unknown as AIMessage;
// If the user did not grant full write access, or the last AI message is the success message, end
// otherwise, loop back to the start.
if (
(typeof lastAiMessage.content === "string" &&
lastAiMessage.content === SUCCESSFULLY_COMPLETED_STEPS_CONTENT) ||
!fullWriteAccess
) {
return END;
}
return "planner";
}
const workflow = new StateGraph(OpenCodeAnnotation)
.addNode("planner", planner)
.addNode("executor", executor)
.addEdge(START, "planner")
.addEdge("planner", "executor")
.addConditionalEdges("executor", conditionallyEnd, ["planner", END]);
export const graph = workflow.compile();
graph.name = "Open Code Graph";
@@ -1,127 +0,0 @@
import fs from "fs/promises";
import { v4 as uuidv4 } from "uuid";
import { AIMessage } from "@langchain/langgraph-sdk";
import { OpenCodeState, OpenCodeUpdate } from "../types";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import type ComponentMap from "../../../agent-uis/index";
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
export const SUCCESSFULLY_COMPLETED_STEPS_CONTENT =
"Successfully completed all the steps in the plan. Please let me know if you need anything else!";
export async function executor(
state: OpenCodeState,
config: LangGraphRunnableConfig,
): Promise<OpenCodeUpdate> {
const ui = typedUi<typeof ComponentMap>(config);
const lastPlanToolCall = state.messages.findLast(
(m) =>
m.getType() === "ai" &&
(m as unknown as AIMessage).tool_calls?.some((tc) => tc.name === "plan"),
) as AIMessage | undefined;
const planToolCallArgs = lastPlanToolCall?.tool_calls?.[0]?.args;
const nextPlanItem = planToolCallArgs?.remainingPlans?.[0] as
| string
| undefined;
const numSeenPlans =
[
...(planToolCallArgs?.executedPlans ?? []),
...(planToolCallArgs?.rejectedPlans ?? []),
]?.length ?? 0;
if (!nextPlanItem) {
// All plans have been executed
const successfullyFinishedMsg: AIMessage = {
type: "ai",
id: uuidv4(),
content: SUCCESSFULLY_COMPLETED_STEPS_CONTENT,
};
return { messages: [successfullyFinishedMsg] };
}
let updateFileContents = "";
switch (numSeenPlans) {
case 0:
updateFileContents = await fs.readFile(
"src/agent/open-code/nodes/plan-code/step-1.txt",
"utf-8",
);
break;
case 1:
updateFileContents = await fs.readFile(
"src/agent/open-code/nodes/plan-code/step-2.txt",
"utf-8",
);
break;
case 2:
updateFileContents = await fs.readFile(
"src/agent/open-code/nodes/plan-code/step-3.txt",
"utf-8",
);
break;
case 3:
updateFileContents = await fs.readFile(
"src/agent/open-code/nodes/plan-code/step-4.txt",
"utf-8",
);
break;
case 4:
updateFileContents = await fs.readFile(
"src/agent/open-code/nodes/plan-code/step-5.txt",
"utf-8",
);
break;
case 5:
updateFileContents = await fs.readFile(
"src/agent/open-code/nodes/plan-code/step-6.txt",
"utf-8",
);
break;
default:
updateFileContents = "";
}
if (!updateFileContents) {
throw new Error("No file updates found!");
}
const toolCallId = uuidv4();
const aiMessage: AIMessage = {
type: "ai",
id: uuidv4(),
content: "",
tool_calls: [
{
name: "update_file",
args: {
new_file_content: updateFileContents,
executed_plan_item: nextPlanItem,
},
id: toolCallId,
type: "tool_call",
},
],
};
const fullWriteAccess = !!config.configurable?.permissions?.full_write_access;
ui.push(
{
name: "proposed-change",
props: {
toolCallId,
change: updateFileContents,
planItem: nextPlanItem,
fullWriteAccess,
},
},
{ message: aiMessage },
);
return {
messages: [aiMessage],
ui: ui.items,
timestamp: Date.now(),
};
}
@@ -1,5 +0,0 @@
```bash
npx create-react-app todo-app --template typescript
cd todo-app
mkdir -p src/{components,styles,utils}
```
@@ -1,21 +0,0 @@
```tsx
// src/components/TodoItem.tsx
import React from 'react';
import styles from '../styles/TodoItem.module.css';
interface TodoItemProps {
id: string;
text: string;
completed: boolean;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
export const TodoItem: React.FC<TodoItemProps> = ({ id, text, completed, onToggle, onDelete }) => (
<div className={styles.todoItem}>
<input type='checkbox' checked={completed} onChange={() => onToggle(id)} />
<span className={completed ? styles.completed : ''}>{text}</span>
<button onClick={() => onDelete(id)}>Delete</button>
</div>
);
```
@@ -1,22 +0,0 @@
```tsx
// src/context/TodoContext.tsx
import React, { createContext, useContext, useReducer } from 'react';
type Todo = { id: string; text: string; completed: boolean; };
type TodoState = { todos: Todo[]; };
type TodoAction =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: string }
| { type: 'DELETE_TODO'; payload: string };
const TodoContext = createContext<{
state: TodoState;
dispatch: React.Dispatch<TodoAction>;
} | undefined>(undefined);
export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(todoReducer, { todos: [] });
return <TodoContext.Provider value={{ state, dispatch }}>{children}</TodoContext.Provider>;
};
```
@@ -1,33 +0,0 @@
```tsx
// src/components/AddTodo.tsx
import React, { useState } from 'react';
import styles from '../styles/AddTodo.module.css';
export const AddTodo: React.FC<{ onAdd: (text: string) => void }> = ({ onAdd }) => {
const [text, setText] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) {
setError('Todo text cannot be empty');
return;
}
onAdd(text.trim());
setText('');
setError('');
};
return (
<form onSubmit={handleSubmit} className={styles.form}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder='Add a new todo'
/>
{error && <div className={styles.error}>{error}</div>}
<button type='submit'>Add Todo</button>
</form>
);
};
```
@@ -1,22 +0,0 @@
```tsx
// src/components/TodoFilters.tsx
import React from 'react';
type FilterType = 'all' | 'active' | 'completed';
export const TodoFilters: React.FC<{
currentFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
onSortChange: (ascending: boolean) => void;
}> = ({ currentFilter, onFilterChange, onSortChange }) => (
<div>
<select value={currentFilter} onChange={(e) => onFilterChange(e.target.value as FilterType)}>
<option value='all'>All</option>
<option value='active'>Active</option>
<option value='completed'>Completed</option>
</select>
<button onClick={() => onSortChange(true)}>Sort A-Z</button>
<button onClick={() => onSortChange(false)}>Sort Z-A</button>
</div>
);
```
@@ -1,13 +0,0 @@
```tsx
// src/utils/storage.ts
const STORAGE_KEY = 'todos';
export const saveTodos = (todos: Todo[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
};
export const loadTodos = (): Todo[] => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
};
```
@@ -1,114 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { AIMessage, ToolMessage } from "@langchain/langgraph-sdk";
import { OpenCodeState, OpenCodeUpdate } from "../types";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import type ComponentMap from "../../../agent-uis/index";
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
const PLAN = [
"Set up project scaffolding using Create React App and implement basic folder structure for components, styles, and utilities.",
"Create reusable UI components for TodoItem, including styling with CSS modules.",
"Implement state management using React Context to handle todo items, including actions for adding, updating, and deleting todos.",
"Add form functionality for creating new todos with input validation and error handling.",
"Create filtering and sorting capabilities to allow users to view completed, active, or all todos.",
"Implement local storage integration to persist todo items between page refreshes.",
];
export async function planner(
state: OpenCodeState,
config: LangGraphRunnableConfig,
): Promise<OpenCodeUpdate> {
const ui = typedUi<typeof ComponentMap>(config);
const lastUpdateCodeToolCall = state.messages.findLast(
(m) =>
m.getType() === "ai" &&
(m as unknown as AIMessage).tool_calls?.some(
(tc) => tc.name === "update_file",
),
) as AIMessage | undefined;
const lastUpdateToolCallResponse = state.messages.findLast(
(m) =>
m.getType() === "tool" &&
(m as unknown as ToolMessage).tool_call_id ===
lastUpdateCodeToolCall?.tool_calls?.[0]?.id,
) as ToolMessage | undefined;
const lastPlanToolCall = state.messages.findLast(
(m) =>
m.getType() === "ai" &&
(m as unknown as AIMessage).tool_calls?.some((tc) => tc.name === "plan"),
) as AIMessage | undefined;
const wasPlanRejected = (
lastUpdateToolCallResponse?.content as string | undefined
)
?.toLowerCase()
.includes("rejected");
const planToolCallArgs = lastPlanToolCall?.tool_calls?.[0]?.args;
const executedPlans: string[] = planToolCallArgs?.executedPlans ?? [];
const rejectedPlans: string[] = planToolCallArgs?.rejectedPlans ?? [];
let remainingPlans: string[] = planToolCallArgs?.remainingPlans ?? PLAN;
const proposedChangePlanItem: string | undefined =
lastUpdateCodeToolCall?.tool_calls?.[0]?.args?.executed_plan_item;
if (proposedChangePlanItem) {
if (wasPlanRejected) {
rejectedPlans.push(proposedChangePlanItem);
} else {
executedPlans.push(proposedChangePlanItem);
}
remainingPlans = remainingPlans.filter((p) => p !== proposedChangePlanItem);
}
const content = proposedChangePlanItem
? `I've updated the plan list based on the last proposed change.`
: `I've come up with a detailed plan for building the todo app.`;
const toolCallId = uuidv4();
const aiMessage: AIMessage = {
type: "ai",
id: uuidv4(),
content,
tool_calls: [
{
name: "plan",
args: {
executedPlans,
rejectedPlans,
remainingPlans,
},
id: toolCallId,
type: "tool_call",
},
],
};
ui.push(
{
name: "code-plan",
props: {
toolCallId,
executedPlans,
rejectedPlans,
remainingPlans,
},
},
{ message: aiMessage },
);
const toolMessage: ToolMessage = {
type: "tool",
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
tool_call_id: toolCallId,
content: "User has approved the plan.",
};
return {
messages: [aiMessage, toolMessage],
ui: ui.items,
timestamp: Date.now(),
};
}
-11
View File
@@ -1,11 +0,0 @@
import { Annotation } from "@langchain/langgraph";
import { GenerativeUIAnnotation } from "../types";
export const OpenCodeAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui,
timestamp: GenerativeUIAnnotation.spec.timestamp,
});
export type OpenCodeState = typeof OpenCodeAnnotation.State;
export type OpenCodeUpdate = typeof OpenCodeAnnotation.Update;
-113
View File
@@ -1,113 +0,0 @@
import { ChatAnthropic } from "@langchain/anthropic";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { GenerativeUIAnnotation } from "../types";
import { z } from "zod";
import { AIMessage, ToolMessage } from "@langchain/langgraph-sdk";
import { v4 as uuidv4 } from "uuid";
const PizzaOrdererAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
});
async function sleep(ms = 5000) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const workflow = new StateGraph(PizzaOrdererAnnotation)
.addNode("findStore", async (state) => {
const findShopSchema = z
.object({
location: z
.string()
.describe(
"The location the user is in. E.g. 'San Francisco' or 'New York'",
),
pizza_company: z
.string()
.optional()
.describe(
"The name of the pizza company. E.g. 'Dominos' or 'Papa John's'. Optional, if not defined it will search for all pizza shops",
),
})
.describe("The schema for finding a pizza shop for the user");
const model = new ChatAnthropic({
model: "claude-3-5-sonnet-latest",
temperature: 0,
}).withStructuredOutput(findShopSchema, {
name: "find_pizza_shop",
includeRaw: true,
});
const response = await model.invoke([
{
role: "system",
content:
"You are a helpful AI assistant, tasked with extracting information from the conversation between you, and the user, in order to find a pizza shop for them.",
},
...state.messages,
]);
await sleep();
const toolResponse: ToolMessage = {
type: "tool",
id: uuidv4(),
content:
"I've found a pizza shop at 1119 19th St, San Francisco, CA 94107. The phone number for the shop is 415-555-1234.",
tool_call_id:
(response.raw as unknown as AIMessage).tool_calls?.[0].id ?? "",
};
return {
messages: [response.raw, toolResponse],
};
})
.addNode("orderPizza", async (state) => {
await sleep(1500);
const placeOrderSchema = z
.object({
address: z
.string()
.describe("The address of the store to order the pizza from"),
phone_number: z
.string()
.describe("The phone number of the store to order the pizza from"),
order: z.string().describe("The full pizza order for the user"),
})
.describe("The schema for ordering a pizza for the user");
const model = new ChatAnthropic({
model: "claude-3-5-sonnet-latest",
temperature: 0,
}).withStructuredOutput(placeOrderSchema, {
name: "place_pizza_order",
includeRaw: true,
});
const response = await model.invoke([
{
role: "system",
content:
"You are a helpful AI assistant, tasked with placing an order for a pizza for the user.",
},
...state.messages,
]);
const toolResponse: ToolMessage = {
type: "tool",
id: uuidv4(),
content: "Pizza order successfully placed.",
tool_call_id:
(response.raw as unknown as AIMessage).tool_calls?.[0].id ?? "",
};
return {
messages: [response.raw, toolResponse],
};
})
.addEdge(START, "findStore")
.addEdge("findStore", "orderPizza")
.addEdge("orderPizza", END);
export const graph = workflow.compile();
graph.name = "Order Pizza Graph";
-10
View File
@@ -1,10 +0,0 @@
import { StateGraph, START } from "@langchain/langgraph";
import { StockbrokerAnnotation } from "./types";
import { callTools } from "./nodes/tools";
const builder = new StateGraph(StockbrokerAnnotation)
.addNode("agent", callTools)
.addEdge(START, "agent");
export const stockbrokerGraph = builder.compile();
stockbrokerGraph.name = "Stockbroker";
@@ -1,221 +0,0 @@
import { StockbrokerState, StockbrokerUpdate } from "../types";
import { ChatOpenAI } from "@langchain/openai";
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import type ComponentMap from "../../../agent-uis/index";
import { z } from "zod";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { findToolCall } from "../../find-tool-call";
import { format, subDays } from "date-fns";
import { Price, Snapshot } from "../../types";
async function getNextPageData(url: string) {
if (!process.env.FINANCIAL_DATASETS_API_KEY) {
throw new Error("Financial datasets API key not set");
}
const options = {
method: "GET",
headers: { "X-API-KEY": process.env.FINANCIAL_DATASETS_API_KEY },
};
const response = await fetch(url, options);
if (!response.ok) {
const status = response.status;
const statusText = response.statusText;
throw new Error(
`Failed to next page data prices.\nURL: ${url}\nStatus: ${status} ${statusText}`,
);
}
return await response.json();
}
async function getPricesForTicker(ticker: string): Promise<{
oneDayPrices: Price[];
thirtyDayPrices: Price[];
}> {
if (!process.env.FINANCIAL_DATASETS_API_KEY) {
throw new Error("Financial datasets API key not set");
}
const options = {
method: "GET",
headers: { "X-API-KEY": process.env.FINANCIAL_DATASETS_API_KEY },
};
const url = "https://api.financialdatasets.ai/prices";
const oneMonthAgo = format(subDays(new Date(), 30), "yyyy-MM-dd");
const now = format(new Date(), "yyyy-MM-dd");
const queryParamsOneDay = new URLSearchParams({
ticker,
interval: "minute",
interval_multiplier: "5",
start_date: now,
end_date: now,
limit: "5000",
});
const queryParamsThirtyDays = new URLSearchParams({
ticker,
interval: "minute",
interval_multiplier: "30",
start_date: oneMonthAgo,
end_date: now,
limit: "5000",
});
const [resOneDay, resThirtyDays] = await Promise.all([
fetch(`${url}?${queryParamsOneDay.toString()}`, options),
fetch(`${url}?${queryParamsThirtyDays.toString()}`, options),
]);
if (!resOneDay.ok || !resThirtyDays.ok) {
throw new Error("Failed to fetch prices");
}
const { prices: pricesOneDay } = await resOneDay.json();
const { prices: pricesThirtyDays, next_page_url } =
await resThirtyDays.json();
let nextPageUrlThirtyDays = next_page_url;
let iters = 0;
while (nextPageUrlThirtyDays) {
if (iters > 10) {
throw new Error("MAX ITERS REACHED");
}
try {
const nextPageData = await getNextPageData(nextPageUrlThirtyDays);
pricesThirtyDays.push(...nextPageData.prices);
nextPageUrlThirtyDays = nextPageData.next_page_url;
iters += 1;
} catch (e) {
console.error(e);
break;
}
}
return {
oneDayPrices: pricesOneDay,
thirtyDayPrices: pricesThirtyDays,
};
}
async function getPriceSnapshotForTicker(ticker: string): Promise<Snapshot> {
if (!process.env.FINANCIAL_DATASETS_API_KEY) {
throw new Error("Financial datasets API key not set");
}
const options = {
method: "GET",
headers: { "X-API-KEY": process.env.FINANCIAL_DATASETS_API_KEY },
};
const url = "https://api.financialdatasets.ai/prices/snapshot";
const queryParams = new URLSearchParams({
ticker,
});
const response = await fetch(`${url}?${queryParams.toString()}`, options);
if (!response.ok) {
throw new Error("Failed to fetch price snapshot");
}
const { snapshot } = await response.json();
return snapshot;
}
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const getStockPriceSchema = z.object({
ticker: z.string().describe("The ticker symbol of the company"),
});
const getPortfolioSchema = z.object({
get_portfolio: z.boolean().describe("Should be true."),
});
const buyStockSchema = z.object({
ticker: z.string().describe("The ticker symbol of the company"),
quantity: z.number().describe("The quantity of the stock to buy"),
});
const STOCKBROKER_TOOLS = [
{
name: "stock-price",
description: "A tool to get the stock price of a company",
schema: getStockPriceSchema,
},
{
name: "portfolio",
description:
"A tool to get the user's portfolio details. Only call this tool if the user requests their portfolio details.",
schema: getPortfolioSchema,
},
{
name: "buy-stock",
description: "A tool to buy a stock",
schema: buyStockSchema,
},
];
export async function callTools(
state: StockbrokerState,
config: LangGraphRunnableConfig,
): Promise<StockbrokerUpdate> {
const ui = typedUi<typeof ComponentMap>(config);
const message = await llm.bindTools(STOCKBROKER_TOOLS).invoke([
{
role: "system",
content:
"You are a stockbroker agent that uses tools to get the stock price of a company",
},
...state.messages,
]);
const stockbrokerToolCall = message.tool_calls?.find(
findToolCall("stock-price")<typeof getStockPriceSchema>,
);
const portfolioToolCall = message.tool_calls?.find(
findToolCall("portfolio")<typeof getPortfolioSchema>,
);
const buyStockToolCall = message.tool_calls?.find(
findToolCall("buy-stock")<typeof buyStockSchema>,
);
if (stockbrokerToolCall) {
const prices = await getPricesForTicker(stockbrokerToolCall.args.ticker);
ui.push(
{
name: "stock-price",
props: { ticker: stockbrokerToolCall.args.ticker, ...prices },
},
{ message },
);
}
if (portfolioToolCall) {
ui.push({ name: "portfolio", props: {} }, { message });
}
if (buyStockToolCall) {
const snapshot = await getPriceSnapshotForTicker(
buyStockToolCall.args.ticker,
);
ui.push(
{
name: "buy-stock",
props: {
toolCallId: buyStockToolCall.id ?? "",
snapshot,
quantity: buyStockToolCall.args.quantity,
},
},
{ message },
);
}
return {
messages: [message],
ui: ui.items,
timestamp: Date.now(),
};
}
-11
View File
@@ -1,11 +0,0 @@
import { Annotation } from "@langchain/langgraph";
import { GenerativeUIAnnotation } from "../types";
export const StockbrokerAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui,
timestamp: GenerativeUIAnnotation.spec.timestamp,
});
export type StockbrokerState = typeof StockbrokerAnnotation.State;
export type StockbrokerUpdate = typeof StockbrokerAnnotation.Update;
+8 -36
View File
@@ -1,8 +1,4 @@
import { StateGraph, START, END } from "@langchain/langgraph";
import { stockbrokerGraph } from "../stockbroker";
import { tripPlannerGraph } from "../trip-planner";
import { graph as openCodeGraph } from "../open-code";
import { graph as orderPizzaGraph } from "../pizza-orderer";
import {
SupervisorAnnotation,
SupervisorState,
@@ -10,55 +6,31 @@ import {
} from "./types";
import { generalInput } from "./nodes/general-input";
import { router } from "./nodes/router";
import { graph as writerAgentGraph } from "../writer-agent";
import { enterpriseGraph } from "../enterprise";
import { getCheckpointer } from "../utils/checkpointer.js";
export const ALL_TOOL_DESCRIPTIONS = `- stockbroker: can fetch the price of a ticker, purchase/sell a ticker, or get the user's portfolio
- tripPlanner: helps the user plan their trip. it can suggest restaurants, and places to stay in any given location.
- openCode: can write a React TODO app for the user. Only call this tool if they request a TODO app.
- orderPizza: can order a pizza for the user
- writerAgent: can write a text document for the user. Only call this tool if they request a text document.
- enterprise: 企业内部助手:知识库查询、工单管理、网络搜索、代码执行`;
export const ALL_TOOL_DESCRIPTIONS = `- enterprise: 企业内部助手:知识库查询、工单管理、网络搜索、代码执行
- generalInput: handles all other cases where the above tools don't apply`;
function handleRoute(
state: SupervisorState,
):
| "stockbroker"
| "tripPlanner"
| "openCode"
| "orderPizza"
| "generalInput"
| "writerAgent"
| "enterprise" {
): "generalInput" | "enterprise" {
return state.next;
}
const builder = new StateGraph(SupervisorAnnotation, SupervisorZodConfiguration)
.addNode("router", router)
.addNode("stockbroker", stockbrokerGraph)
.addNode("tripPlanner", tripPlannerGraph)
.addNode("openCode", openCodeGraph)
.addNode("orderPizza", orderPizzaGraph)
.addNode("generalInput", generalInput)
.addNode("writerAgent", writerAgentGraph)
.addNode("enterprise", enterpriseGraph)
.addConditionalEdges("router", handleRoute, [
"stockbroker",
"tripPlanner",
"openCode",
"orderPizza",
"generalInput",
"writerAgent",
"enterprise",
])
.addEdge(START, "router")
.addEdge("stockbroker", END)
.addEdge("tripPlanner", END)
.addEdge("openCode", END)
.addEdge("orderPizza", END)
.addEdge("generalInput", END)
.addEdge("writerAgent", END)
.addEdge("enterprise", END);
export const graph = builder.compile();
graph.name = "Generative UI Agent";
// Use checkpointer for conversation history persistence
const checkpointer = await getCheckpointer();
export const graph = builder.compile({ checkpointer });
graph.name = "Enterprise Agent";
@@ -1,9 +1,11 @@
import { SupervisorState, SupervisorUpdate } from "../types";
import { ALL_TOOL_DESCRIPTIONS } from "../index";
import { ChatOpenAI } from "@langchain/openai";
import { createLlm, type ModelMode } from "@/agent/utils/create-llm";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
export async function generalInput(
state: SupervisorState,
config: LangGraphRunnableConfig,
): Promise<SupervisorUpdate> {
const GENERAL_INPUT_SYSTEM_PROMPT = `You are an AI assistant.
If the user asks what you can do, describe these tools.
@@ -13,7 +15,8 @@ If the last message is a tool result, describe what the action was, congratulate
Otherwise, just answer as normal.`;
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const modelMode = ((config.configurable?.modelMode as string) ?? "auto") as ModelMode;
const llm = createLlm({ modelMode });
const response = await llm.invoke([
{
role: "system",
+2 -11
View File
@@ -1,25 +1,19 @@
import { z } from "zod";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { ALL_TOOL_DESCRIPTIONS } from "../index";
import { SupervisorState, SupervisorUpdate } from "../types";
import { formatMessages } from "@/agent/utils/format-messages";
import { createLlm } from "@/agent/utils/create-llm";
export async function router(
state: SupervisorState,
): Promise<Partial<SupervisorUpdate>> {
const routerDescription = `The route to take based on the user's input.
${ALL_TOOL_DESCRIPTIONS}
- generalInput: handles all other cases where the above tools don't apply
`;
const routerSchema = z.object({
route: z
.enum([
"stockbroker",
"tripPlanner",
"openCode",
"orderPizza",
"generalInput",
"writerAgent",
"enterprise",
])
.describe(routerDescription),
@@ -30,10 +24,7 @@ ${ALL_TOOL_DESCRIPTIONS}
schema: routerSchema,
};
const llm = new ChatGoogleGenerativeAI({
model: "gemini-2.0-flash",
temperature: 0,
})
const llm = createLlm()
.bindTools([routerTool], { tool_choice: "router" })
.withConfig({ tags: ["langsmith:nostream"] });
+35
View File
@@ -54,6 +54,41 @@ export const SupervisorZodConfiguration = z.object({
},
],
}),
/**
* Model mode preset: flash (fast), pro (detailed), auto (balanced).
*/
modelMode: z
.enum(["flash", "pro", "auto"])
.optional()
.langgraph.metadata({
type: "select",
default: "auto",
description: "Model mode preset",
options: [
{ label: "Flash", value: "flash" },
{ label: "Pro", value: "pro" },
{ label: "Auto", value: "auto" },
],
}),
/**
* Enabled tool names. Empty array or undefined means all tools are enabled.
*/
enabledTools: z
.array(z.string())
.optional()
.langgraph.metadata({
type: "multi-select",
default: [],
description: "Enabled tools (empty = all enabled)",
options: [
{ label: "Knowledge Base", value: "kb_search" },
{ label: "Ticket List", value: "ticket_list" },
{ label: "Ticket Detail", value: "ticket_detail" },
{ label: "Web Search (Jina)", value: "web_search" },
{ label: "Google Search", value: "google_search" },
{ label: "Sandbox", value: "sandbox_run" },
],
}),
/**
* The temperature to use for the reflection generation.
* Defaults to `0.7`.
-51
View File
@@ -1,51 +0,0 @@
import { StateGraph, START, END } from "@langchain/langgraph";
import { TripPlannerAnnotation, TripPlannerState } from "./types";
import { extraction } from "./nodes/extraction";
import { callTools } from "./nodes/tools";
import { classify } from "./nodes/classify";
function routeStart(state: TripPlannerState): "classify" | "extraction" {
if (!state.tripDetails) {
return "extraction";
}
return "classify";
}
function routeAfterClassifying(
state: TripPlannerState,
): "callTools" | "extraction" {
// if `tripDetails` is undefined, this means they are not relevant to the conversation
if (!state.tripDetails) {
return "extraction";
}
// otherwise, they are relevant, and we should route to callTools
return "callTools";
}
function routeAfterExtraction(
state: TripPlannerState,
): "callTools" | typeof END {
// if `tripDetails` is undefined, this means they're missing some fields.
if (!state.tripDetails) {
return END;
}
return "callTools";
}
const builder = new StateGraph(TripPlannerAnnotation)
.addNode("classify", classify)
.addNode("extraction", extraction)
.addNode("callTools", callTools)
.addConditionalEdges(START, routeStart, ["classify", "extraction"])
.addConditionalEdges("classify", routeAfterClassifying, [
"callTools",
"extraction",
])
.addConditionalEdges("extraction", routeAfterExtraction, ["callTools", END])
.addEdge("callTools", END);
export const tripPlannerGraph = builder.compile();
tripPlannerGraph.name = "Trip Planner";
@@ -1,73 +0,0 @@
import { ChatOpenAI } from "@langchain/openai";
import { TripPlannerState, TripPlannerUpdate } from "../types";
import { z } from "zod";
import { formatMessages } from "@/agent/utils/format-messages";
export async function classify(
state: TripPlannerState,
): Promise<TripPlannerUpdate> {
if (!state.tripDetails) {
// Can not classify if tripDetails are undefined
return {};
}
const schema = z.object({
isRelevant: z
.boolean()
.describe(
"Whether the trip details are still relevant to the user's request.",
),
});
const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools(
[
{
name: "classify",
description:
"A tool to classify whether or not the trip details are still relevant to the user's request.",
schema,
},
],
{
tool_choice: "classify",
},
);
const prompt = `You're an AI assistant for planning trips. The user has already specified the following details for their trip:
- location - ${state.tripDetails.location}
- startDate - ${state.tripDetails.startDate}
- endDate - ${state.tripDetails.endDate}
- numberOfGuests - ${state.tripDetails.numberOfGuests}
Your task is to carefully read over the user's conversation, and determine if their trip details are still relevant to their most recent request.
You should set is relevant to false if they are now asking about a new location, trip duration, or number of guests.
If they do NOT change their request details (or they never specified them), please set is relevant to true.
`;
const humanMessage = `Here is the entire conversation so far:\n${formatMessages(state.messages)}`;
const response = await model.invoke(
[
{ role: "system", content: prompt },
{ role: "human", content: humanMessage },
],
{ tags: ["langsmith:nostream"] },
);
const classificationDetails = response.tool_calls?.[0]?.args as
| z.infer<typeof schema>
| undefined;
if (!classificationDetails) {
throw new Error("Could not classify trip details");
}
if (!classificationDetails.isRelevant) {
return {
tripDetails: undefined,
};
}
// If it is relevant, return the state unchanged
return {};
}
@@ -1,136 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { ChatOpenAI } from "@langchain/openai";
import { TripDetails, TripPlannerState, TripPlannerUpdate } from "../types";
import { z } from "zod";
import { ToolMessage } from "@langchain/langgraph-sdk";
import { formatMessages } from "@/agent/utils/format-messages";
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
function calculateDates(
startDate: string | undefined,
endDate: string | undefined,
): { startDate: Date; endDate: Date } {
const now = new Date();
if (!startDate && !endDate) {
// Both undefined: 4 and 5 weeks in future
const start = new Date(now);
start.setDate(start.getDate() + 28); // 4 weeks
const end = new Date(now);
end.setDate(end.getDate() + 35); // 5 weeks
return { startDate: start, endDate: end };
}
if (startDate && !endDate) {
// Only start defined: end is 1 week after
const start = new Date(startDate);
const end = new Date(start);
end.setDate(end.getDate() + 7);
return { startDate: start, endDate: end };
}
if (!startDate && endDate) {
// Only end defined: start is 1 week before
const end = new Date(endDate);
const start = new Date(end);
start.setDate(start.getDate() - 7);
return { startDate: start, endDate: end };
}
// Both defined: use as is
return {
startDate: new Date(startDate!),
endDate: new Date(endDate!),
};
}
export async function extraction(
state: TripPlannerState,
): Promise<TripPlannerUpdate> {
const schema = z.object({
location: z
.string()
.describe(
"The location to plan the trip for. Can be a city, state, or country.",
),
startDate: z
.string()
.optional()
.describe("The start date of the trip. Should be in YYYY-MM-DD format"),
endDate: z
.string()
.optional()
.describe("The end date of the trip. Should be in YYYY-MM-DD format"),
numberOfGuests: z
.number()
.describe(
"The number of guests for the trip. Should default to 2 if not specified",
),
});
const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools([
{
name: "extract",
description: "A tool to extract information from a user's request.",
schema: schema,
},
]);
const prompt = `You're an AI assistant for planning trips. The user has requested information about a trip they want to go on.
Before you can help them, you need to extract the following information from their request:
- location - The location to plan the trip for. Can be a city, state, or country.
- startDate - The start date of the trip. Should be in YYYY-MM-DD format. Optional
- endDate - The end date of the trip. Should be in YYYY-MM-DD format. Optional
- numberOfGuests - The number of guests for the trip. Optional
You are provided with the ENTIRE conversation history between you, and the user. Use these messages to extract the necessary information.
Do NOT guess, or make up any information. If the user did NOT specify a location, please respond with a request for them to specify the location.
You should ONLY send a clarification message if the user did not provide the location. You do NOT need any of the other fields, so if they're missing, proceed without them.
It should be a single sentence, along the lines of "Please specify the location for the trip you want to go on".
Extract only what is specified by the user. It is okay to leave fields blank if the user did not specify them.
`;
const humanMessage = `Here is the entire conversation so far:\n${formatMessages(state.messages)}`;
const response = await model.invoke([
{ role: "system", content: prompt },
{ role: "human", content: humanMessage },
]);
const toolCall = response.tool_calls?.[0];
if (!toolCall) {
return {
messages: [response],
};
}
const extractedDetails = toolCall.args as z.infer<typeof schema>;
const { startDate, endDate } = calculateDates(
extractedDetails.startDate,
extractedDetails.endDate,
);
const extractionDetailsWithDefaults: TripDetails = {
startDate,
endDate,
numberOfGuests:
extractedDetails.numberOfGuests && extractedDetails.numberOfGuests > 0
? extractedDetails.numberOfGuests
: 2,
location: extractedDetails.location,
};
const extractToolResponse: ToolMessage = {
type: "tool",
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
tool_call_id: toolCall.id ?? "",
content: "Successfully extracted trip details",
};
return {
tripDetails: extractionDetailsWithDefaults,
messages: [response, extractToolResponse],
};
}
@@ -1,92 +0,0 @@
import { TripPlannerState, TripPlannerUpdate } from "../types";
import { ChatOpenAI } from "@langchain/openai";
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import type ComponentMap from "../../../agent-uis/index";
import { z } from "zod";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { getAccommodationsListProps } from "../utils/get-accommodations";
import { findToolCall } from "../../find-tool-call";
const listAccommodationsSchema = z
.object({})
.describe("A tool to list accommodations for the user");
const listRestaurantsSchema = z
.object({})
.describe("A tool to list restaurants for the user");
const ACCOMMODATIONS_TOOLS = [
{
name: "list-accommodations",
description: "A tool to list accommodations for the user",
schema: listAccommodationsSchema,
},
{
name: "list-restaurants",
description: "A tool to list restaurants for the user",
schema: listRestaurantsSchema,
},
];
export async function callTools(
state: TripPlannerState,
config: LangGraphRunnableConfig,
): Promise<TripPlannerUpdate> {
if (!state.tripDetails) {
throw new Error("No trip details found");
}
const ui = typedUi<typeof ComponentMap>(config);
const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools(
ACCOMMODATIONS_TOOLS,
);
const response = await llm.invoke([
{
role: "system",
content:
"You are an AI assistant who helps users book trips. Use the user's most recent message(s) to contextually generate a response.",
},
...state.messages,
]);
const listAccommodationsToolCall = response.tool_calls?.find(
findToolCall("list-accommodations")<typeof listAccommodationsSchema>,
);
const listRestaurantsToolCall = response.tool_calls?.find(
findToolCall("list-restaurants")<typeof listRestaurantsSchema>,
);
if (!listAccommodationsToolCall && !listRestaurantsToolCall) {
throw new Error("No tool calls found");
}
if (listAccommodationsToolCall) {
ui.push(
{
name: "accommodations-list",
props: {
toolCallId: listAccommodationsToolCall.id ?? "",
...getAccommodationsListProps(state.tripDetails),
},
},
{ message: response },
);
}
if (listRestaurantsToolCall) {
ui.push(
{
name: "restaurants-list",
props: { tripDetails: state.tripDetails },
},
{ message: response },
);
}
return {
messages: [response],
ui: ui.items,
timestamp: Date.now(),
};
}
-19
View File
@@ -1,19 +0,0 @@
import { Annotation } from "@langchain/langgraph";
import { GenerativeUIAnnotation } from "../types";
export type TripDetails = {
location: string;
startDate: Date;
endDate: Date;
numberOfGuests: number;
};
export const TripPlannerAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui,
timestamp: GenerativeUIAnnotation.spec.timestamp,
tripDetails: Annotation<TripDetails | undefined>(),
});
export type TripPlannerState = typeof TripPlannerAnnotation.State;
export type TripPlannerUpdate = typeof TripPlannerAnnotation.Update;
@@ -1,66 +0,0 @@
import { faker } from "@faker-js/faker";
import { Accommodation } from "../../types";
import { TripDetails } from "../types";
export function getAccommodationsListProps(tripDetails: TripDetails) {
const IMAGE_URLS = [
"https://a0.muscache.com/im/pictures/c88d4356-9e33-4277-83fd-3053e5695333.jpg?im_w=1200&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-999231834211657440/original/fa140513-cc51-48a6-83c9-ef4e11e69bc2.jpeg?im_w=1200&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-5264493/original/10d2c21f-84c2-46c5-b20b-b51d1c2c971a.jpeg?im_w=1200&im_format=avif",
"https://a0.muscache.com/im/pictures/d0e3bb05-a96a-45cf-af92-980269168096.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-50597302/original/eb1bb383-4b70-45ae-b3ce-596f83436e6f.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-900891950206269231/original/7cc71402-9430-48b4-b4f1-e8cac69fd7d3.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/460efdcd-1286-431d-b4e5-e316d6427707.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-51234810/original/5231025a-4c39-4a96-ac9c-b088fceb5531.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-14886949/original/a9d72542-cd1f-418d-b070-a73035f94fe4.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/2011683a-c045-4b5a-97a8-37bca4b98079.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/11bcbeec-749c-4897-8593-1ec6f6dc04ad.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-18327626/original/fba2e4e8-9d68-47a8-838e-dab5353e5209.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-813949239894880001/original/b2abe806-b60f-4c0b-b4e6-46808024e5b6.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-894877242638354447/original/29e50d48-1733-4c5b-9068-da4443dd7757.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/hosting/Hosting-1079897686805296552/original/b24bd803-52f2-4ca7-9389-f73c9d9b3c64.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-43730011/original/29f90186-4f83-408a-89ce-a82e520b4e36.png?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/300ae0e1-fc7e-4a05-93a4-26809311ef19.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/0c7b03c9-8907-437f-8874-628e89e00679.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-1040593515802997386/original/0c910b31-03d3-450f-8dc3-2d7f7902b93e.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/d336587a-a4bf-44c9-b4a6-68b71c359be0.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-50345540/original/f8e911bb-8021-4edd-aca4-913d6f41fc6f.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-46122096/original/1bd27f94-cf00-4864-8ad9-bc1cd6c5e10d.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/574424e1-4935-45f5-a5f0-e960b16a3fcc.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/181d4be2-6cb2-4306-94bf-89aa45c5de66.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-50545526/original/af14ce0b-481e-41be-88d1-b84758f578e5.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/10d8309a-8ae6-492b-b1d5-20a543242c68.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-813727499556203528/original/12c1b750-4bea-40d9-9a10-66804df0530a.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/83e4c0a0-65ce-4c5d-967e-d378ed1bfe15.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/852f2d4d-6786-47b5-a3ca-ff7f21bcac2d.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/92534e36-d67a-4346-b3cf-7371b1985aca.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/ecbfed18-29d0-4f86-b6aa-4325b076dfb3.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-52443635/original/05f084c6-60d0-4945-81ff-d23dfb89c3ca.jpeg?im_w=720&im_format=avif",
];
const getAccommodations = (city: string): Accommodation[] => {
// Shuffle the image URLs array and take the first 6
const shuffledImages = [...IMAGE_URLS]
.sort(() => Math.random() - 0.5)
.slice(0, 6)
.filter((i): i is string => typeof i === "string");
return Array.from({ length: 6 }, (_, index) => ({
id: faker.string.uuid(),
name: faker.location.streetAddress(),
price: faker.number.int({ min: 100, max: 1000 }),
rating: Number(
faker.number
.float({ min: 4.0, max: 5.0, fractionDigits: 2 })
.toFixed(2),
),
city: city,
image: shuffledImages[index],
}));
};
return {
tripDetails,
accommodations: getAccommodations(tripDetails.location),
};
}
+1 -37
View File
@@ -13,43 +13,7 @@ export const GenerativeUIAnnotation = Annotation.Root({
>({ default: () => [], reducer: uiMessageReducer }),
context: Annotation<Record<string, unknown> | undefined>,
timestamp: Annotation<number>,
next: Annotation<
| "stockbroker"
| "tripPlanner"
| "openCode"
| "orderPizza"
| "writerAgent"
| "enterprise"
| "generalInput"
>(),
next: Annotation<"enterprise" | "generalInput">(),
});
export type GenerativeUIState = typeof GenerativeUIAnnotation.State;
export type Accommodation = {
id: string;
name: string;
price: number;
rating: number;
city: string;
image: string;
};
export type Price = {
ticker: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
time: string;
};
export type Snapshot = {
price: number;
ticker: string;
day_change: number;
day_change_percent: number;
market_cap: number;
time: string;
};
+16
View File
@@ -0,0 +1,16 @@
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
let _checkpointer: PostgresSaver | undefined;
export async function getCheckpointer(): Promise<PostgresSaver> {
if (_checkpointer) return _checkpointer;
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
throw new Error("DATABASE_URL is required for persistent checkpointing");
}
_checkpointer = PostgresSaver.fromConnString(dbUrl);
await _checkpointer.setup();
return _checkpointer;
}
+42
View File
@@ -0,0 +1,42 @@
import { AzureChatOpenAI } from "@langchain/openai";
export type ModelMode = "flash" | "pro" | "auto";
const MODEL_PRESETS: Record<
ModelMode,
{ temperature: number; maxTokens: number }
> = {
flash: { temperature: 0.2, maxTokens: 500 },
pro: { temperature: 0.3, maxTokens: 4096 },
auto: { temperature: 0.2, maxTokens: 2048 },
};
/**
* Create an AzureChatOpenAI instance using environment variables.
* All agents should use this instead of `new ChatOpenAI(...)`.
*
* @param options.modelMode - "flash" (fast/cheap), "pro" (detailed), "auto" (balanced)
* @param options.temperature - Override preset temperature
* @param options.maxTokens - Override preset maxTokens
*/
export function createLlm(options?: {
temperature?: number;
maxTokens?: number;
modelMode?: ModelMode;
}) {
const mode = options?.modelMode ?? "auto";
const preset = MODEL_PRESETS[mode];
const temperature = options?.temperature ?? preset.temperature;
const maxTokens = options?.maxTokens ?? preset.maxTokens;
return new AzureChatOpenAI({
azureOpenAIApiKey: process.env.AZURE_OPENAI_API_KEY,
azureOpenAIEndpoint: process.env.AZURE_OPENAI_ENDPOINT,
azureOpenAIApiDeploymentName:
process.env.AZURE_OPENAI_DEPLOYMENT ?? "gpt-5.4",
azureOpenAIApiVersion:
process.env.AZURE_OPENAI_API_VERSION ?? "2025-04-01-preview",
temperature,
modelKwargs: { max_completion_tokens: maxTokens },
});
}
-167
View File
@@ -1,167 +0,0 @@
import {
Annotation,
START,
StateGraph,
type LangGraphRunnableConfig,
} from "@langchain/langgraph";
import { ChatAnthropic } from "@langchain/anthropic";
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import {
isAIMessage,
isBaseMessage,
type AIMessageChunk,
type BaseMessageLike,
} from "@langchain/core/messages";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { findToolCall } from "../find-tool-call";
import { GenerativeUIAnnotation } from "../types";
import type ComponentMap from "../../agent-uis/index";
const MODEL_NAME = "claude-3-5-sonnet-latest";
const WriterAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui,
context: Annotation<{ writer?: { selected?: string } } | undefined>(),
});
type WriterState = typeof WriterAnnotation.State;
type WriterUpdate = Promise<typeof WriterAnnotation.Update>;
async function prepare(
state: WriterState,
config: LangGraphRunnableConfig,
): WriterUpdate {
const ui = typedUi<typeof ComponentMap>(config);
const model = new ChatAnthropic({ model: MODEL_NAME });
// create an initial draft of the document
const CreateTextDocumentTool = z.object({
title: z.string(),
description: z.string(),
});
const initStream = await model
.bindTools([
{
name: "draft_text_document",
description:
"Prepare a text document for the user with a short title and short description for browsing purposes. " +
"Can be also used when creating a new version of the document.",
schema: CreateTextDocumentTool,
} as const,
])
.stream([
...(state.context?.writer?.selected
? [
{
type: "system" as const,
content: state.context.writer?.selected
? `Selected text in question: ${state.context.writer?.selected}`
: "",
},
]
: []),
...state.messages,
]);
const id = uuidv4();
let message: AIMessageChunk | undefined;
for await (const chunk of initStream) {
message = message?.concat(chunk) ?? chunk;
const tool = message.tool_calls?.find(
findToolCall("draft_text_document")<typeof CreateTextDocumentTool>,
)?.args;
if (tool) {
ui.push(
{ id, name: "writer", props: { ...tool, isGenerating: true } },
{ message, merge: true },
);
}
}
return { messages: message ? [message] : [] };
}
async function writer(
state: WriterState,
config: LangGraphRunnableConfig,
): WriterUpdate {
const ui = typedUi<typeof ComponentMap>(config);
const lastMessage = state.messages.at(-1);
const lastUi = state.ui.findLast(
(i) => i.name === "writer" && i.metadata.message_id === lastMessage?.id,
);
if (!lastUi || !lastMessage) return {};
const { id } = lastUi;
const contentStream = await new ChatAnthropic({ model: MODEL_NAME })
.withConfig({ tags: ["nostream"] }) // do not stream to the UI
.stream([
{
role: "system",
content:
"Write a text document based on the user's request. " +
"Only output the content, do not ask any additional questions." +
(state.context?.writer?.selected
? `\n\nSelected text in question: ${state.context.writer?.selected}`
: ""),
},
...state.messages.slice(0, -1),
]);
let contentMessage: AIMessageChunk | undefined;
for await (const chunk of contentStream) {
contentMessage = contentMessage?.concat(chunk) ?? chunk;
const content = contentMessage?.text ?? "";
ui.push(
{ id, name: "writer", props: { content, isGenerating: true } },
{ message: lastMessage, merge: true },
);
}
ui.push(
{ id, name: "writer", props: { isGenerating: false } },
{ message: lastMessage, merge: true },
);
return { messages: [] };
}
async function suggestions(state: WriterState): WriterUpdate {
const messages: BaseMessageLike[] = state.messages.slice();
const lastMessage = messages.at(-1);
if (!isBaseMessage(lastMessage) || !isAIMessage(lastMessage)) {
return {};
}
for (const tool of lastMessage.tool_calls ?? []) {
if (!tool.id) continue;
messages.push({ type: "tool", content: "Finished", tool_call_id: tool.id });
}
const model = new ChatAnthropic({ model: MODEL_NAME });
const finish = await model.invoke(messages);
messages.push(finish);
return { messages: messages };
}
export const graph = new StateGraph(WriterAnnotation)
.addNode("prepare", prepare)
.addNode("writer", writer)
.addNode("suggestions", suggestions)
.addEdge(START, "prepare")
.addEdge("prepare", "writer")
.addEdge("writer", "suggestions")
.compile();
+220
View File
@@ -0,0 +1,220 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
interface MessageBubbleProps {
content: string;
role: "human" | "ai";
}
function CodeBlock({
language,
children,
}: {
language: string;
children: string;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(children).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
// Detect dark mode via document class
const isDark =
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark");
return (
<div className="relative group my-2 rounded-lg overflow-hidden border border-border">
{/* Language label + copy button */}
<div className="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
<span className="text-xs font-mono text-muted-foreground">
{language || "text"}
</span>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
{copied ? "已复制" : "复制"}
</button>
</div>
<SyntaxHighlighter
language={language || "text"}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: "0.75rem",
background: "transparent",
}}
PreTag="div"
>
{children}
</SyntaxHighlighter>
</div>
);
}
export default function MessageBubble({ content }: MessageBubbleProps) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// Code blocks
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className ?? "");
const isInline = !match && !className;
const codeStr = String(children).replace(/\n$/, "");
if (isInline) {
return (
<code
className="bg-muted px-1 rounded text-xs font-mono"
{...props}
>
{children}
</code>
);
}
return (
<CodeBlock language={match ? match[1] : ""}>{codeStr}</CodeBlock>
);
},
// Tables
table({ children }) {
return (
<div className="overflow-x-auto my-2">
<table className="min-w-full text-xs border-collapse border border-border">
{children}
</table>
</div>
);
},
th({ children }) {
return (
<th className="border border-border px-3 py-1.5 bg-muted text-left font-medium text-foreground">
{children}
</th>
);
},
td({ children }) {
return (
<td className="border border-border px-3 py-1.5 text-foreground">
{children}
</td>
);
},
// Headings
h1({ children }) {
return (
<h1 className="text-base font-bold mt-3 mb-1 text-foreground">
{children}
</h1>
);
},
h2({ children }) {
return (
<h2 className="text-sm font-semibold mt-3 mb-1 text-foreground">
{children}
</h2>
);
},
h3({ children }) {
return (
<h3 className="text-sm font-medium mt-2 mb-1 text-foreground">
{children}
</h3>
);
},
// Lists
ul({ children }) {
return (
<ul className="list-disc list-inside space-y-0.5 my-1 text-foreground">
{children}
</ul>
);
},
ol({ children }) {
return (
<ol className="list-decimal list-inside space-y-0.5 my-1 text-foreground">
{children}
</ol>
);
},
// Paragraphs
p({ children }) {
return <p className="my-1 leading-relaxed text-foreground">{children}</p>;
},
// Links
a({ href, children }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"underline underline-offset-2 text-foreground",
"hover:opacity-80 transition-opacity",
)}
>
{children}
</a>
);
},
// Blockquote
blockquote({ children }) {
return (
<blockquote className="border-l-2 border-border pl-3 my-2 text-muted-foreground italic">
{children}
</blockquote>
);
},
// Horizontal rule
hr() {
return <hr className="my-3 border-border" />;
},
// Strong / em
strong({ children }) {
return (
<strong className="font-semibold text-foreground">{children}</strong>
);
},
em({ children }) {
return <em className="italic text-foreground">{children}</em>;
},
}}
>
{content}
</ReactMarkdown>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useTheme } from "next-themes";
import { Sun, Moon, Monitor } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
type ThemeState = "light" | "dark" | "system";
const CYCLE: ThemeState[] = ["light", "dark", "system"];
const ICONS: Record<ThemeState, React.FC<{ className?: string }>> = {
light: Sun,
dark: Moon,
system: Monitor,
};
const LABELS: Record<ThemeState, string> = {
light: "亮色",
dark: "暗色",
system: "跟随系统",
};
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
// Avoid hydration mismatch — render only after mount
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className="size-7" />;
}
const current = (theme as ThemeState) ?? "system";
const Icon = ICONS[current] ?? Monitor;
function handleClick() {
const idx = CYCLE.indexOf(current);
const next = CYCLE[(idx + 1) % CYCLE.length];
setTheme(next);
}
return (
<button
type="button"
onClick={handleClick}
title={LABELS[current]}
className={cn(
"inline-flex items-center justify-center size-7 rounded-md",
"text-muted-foreground hover:text-foreground hover:bg-accent",
"transition-colors",
)}
>
<Icon className="size-4" />
</button>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { Plus, MessageSquare, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
export type ThreadItem = {
thread_id: string;
created_at: string;
metadata?: Record<string, unknown>;
};
type Props = {
threads: ThreadItem[];
currentThreadId: string | null;
onNewThread: () => void;
onSelectThread: (threadId: string) => void;
onDeleteThread: (threadId: string) => void;
};
function formatTime(iso: string) {
try {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffHrs = diffMs / (1000 * 60 * 60);
if (diffHrs < 24) {
return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
return d.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
} catch {
return "";
}
}
export function ThreadSidebar({
threads,
currentThreadId,
onNewThread,
onSelectThread,
onDeleteThread,
}: Props) {
return (
<div className="w-64 shrink-0 border-r border-border flex flex-col bg-muted/30">
{/* New chat button */}
<div className="p-3 border-b border-border">
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2"
onClick={onNewThread}
>
<Plus className="size-4" />
新建对话
</Button>
</div>
{/* Thread list */}
<div className="flex-1 overflow-y-auto py-2">
{threads.length === 0 && (
<p className="text-xs text-muted-foreground text-center mt-8 px-4">
暂无历史对话
</p>
)}
{threads.map((t) => {
const isActive = t.thread_id === currentThreadId;
const label =
(t.metadata?.title as string) ??
(t.metadata?.firstMessage as string) ??
t.thread_id.slice(0, 8) + "…";
return (
<div
key={t.thread_id}
className={cn(
"group flex items-center gap-2 px-3 py-2 mx-1 rounded-lg cursor-pointer transition-colors",
isActive
? "bg-primary/10 text-foreground"
: "hover:bg-accent text-muted-foreground hover:text-foreground",
)}
onClick={() => onSelectThread(t.thread_id)}
>
<MessageSquare className="size-4 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{label}</p>
<p className="text-[10px] text-muted-foreground">
{formatTime(t.created_at)}
</p>
</div>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteThread(t.thread_id);
}}
title="删除对话"
>
<Trash2 className="size-3.5" />
</button>
</div>
);
})}
</div>
</div>
);
}
+277 -112
View File
@@ -1,25 +1,62 @@
import { createRoot } from "react-dom/client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import { Client } from "@langchain/langgraph-sdk";
import type { Message } from "@langchain/langgraph-sdk";
// UIMessage is not exported directly — use a local shape
type UIMsgLocal = { id: string; type: string; name: string; props: Record<string, unknown>; metadata?: { message_id?: string } };
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import ComponentMap from "./agent-uis/index.tsx";
import "./index.css";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ThreadSidebar, type ThreadItem } from "@/components/ThreadSidebar.tsx";
import { ThemeProvider } from "next-themes";
import ThemeToggle from "@/components/ThemeToggle.tsx";
import MessageBubble from "@/components/MessageBubble.tsx";
const LANGGRAPH_URL =
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
// ─── Tool groups ────────────────────────────────────────────────────────────
const TOOL_GROUPS = [
{ key: "knowledge", label: "知识库", icon: BookOpen, tools: ["kb_search"] },
{ key: "tickets", label: "工单", icon: Ticket, tools: ["ticket_list", "ticket_detail"] },
{ key: "search", label: "搜索", icon: Search, tools: ["web_search", "google_search"] },
{ key: "sandbox", label: "代码", icon: Terminal, tools: ["sandbox_run"] },
] as const;
type ToolKey = (typeof TOOL_GROUPS)[number]["key"];
type ModelMode = "flash" | "auto" | "pro";
const MODEL_OPTIONS: { value: ModelMode; label: string; icon: React.FC<{ className?: string }> }[] = [
{ value: "flash", label: "Flash", icon: Zap },
{ value: "auto", label: "Auto", icon: Bot },
{ value: "pro", label: "Pro", icon: Cpu },
];
// ─── LangGraph Client (for thread management) ───────────────────────────────
const client = new Client({ apiUrl: LANGGRAPH_URL });
function App() {
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
// Tool & model state
const [activeTools, setActiveTools] = useState<Set<ToolKey>>(new Set());
const [modelMode, setModelMode] = useState<ModelMode>("auto");
// Thread sidebar state
const [threads, setThreads] = useState<ThreadItem[]>([]);
const [currentThreadId, setCurrentThreadId] = useState<string | null>(null);
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
apiUrl: LANGGRAPH_URL,
assistantId: "agent",
messagesKey: "messages",
threadId: currentThreadId ?? undefined,
});
// Auto-scroll to bottom when messages update
@@ -27,136 +64,264 @@ function App() {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [thread.messages]);
// Load threads on mount
useEffect(() => {
client.threads
.search({ limit: 50 })
.then((list: any) => setThreads(list as ThreadItem[]))
.catch(() => {/* graceful degradation — sidebar stays empty */});
}, []);
// ── Thread actions ──────────────────────────────────────────────────────
const handleNewThread = useCallback(async () => {
try {
const t = await client.threads.create();
setThreads((prev) => [t as ThreadItem, ...prev]);
setCurrentThreadId(t.thread_id);
} catch {
// If create fails, just clear threadId so useStream creates one implicitly
setCurrentThreadId(null);
}
}, []);
const handleSelectThread = useCallback((threadId: string) => {
setCurrentThreadId(threadId);
}, []);
const handleDeleteThread = useCallback(async (threadId: string) => {
try {
await client.threads.delete(threadId);
} catch {
// ignore errors
}
setThreads((prev) => prev.filter((t) => t.thread_id !== threadId));
if (currentThreadId === threadId) {
setCurrentThreadId(null);
}
}, [currentThreadId]);
// ── Tool toggle ─────────────────────────────────────────────────────────
function toggleTool(key: ToolKey) {
setActiveTools((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}
// ── Submit ──────────────────────────────────────────────────────────────
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || thread.isLoading) return;
setInput("");
thread.submit({ messages: [{ type: "human", content: text }] });
const enabledTools =
activeTools.size > 0
? TOOL_GROUPS.filter((g) => activeTools.has(g.key)).flatMap((g) => [...g.tools])
: [];
thread.submit(
{ messages: [{ type: "human", content: text }] },
{
config: {
configurable: {
enabledTools,
modelMode,
},
},
},
);
}
return (
<div className="h-screen flex flex-col bg-background text-foreground">
{/* Header */}
<header className="shrink-0 border-b border-border px-6 py-3 flex items-center gap-3">
<span className="font-semibold text-foreground">运营大脑</span>
{thread.isLoading && (
<span className="text-xs text-muted-foreground animate-pulse">
思考中…
</span>
)}
</header>
<div className="h-screen flex flex-row bg-background text-foreground overflow-hidden">
{/* ── Left sidebar ── */}
<ThreadSidebar
threads={threads}
currentThreadId={currentThreadId}
onNewThread={handleNewThread}
onSelectThread={handleSelectThread}
onDeleteThread={handleDeleteThread}
/>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-6">
{thread.messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground select-none">
<p className="text-lg font-medium">你好,有什么可以帮你的?</p>
<p className="text-sm">可以查询知识库、工单、搜索网络或执行代码。</p>
{/* ── Right main area ── */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="shrink-0 border-b border-border px-6 py-3 flex items-center gap-3">
<span className="font-semibold text-foreground">运营大脑</span>
{thread.isLoading && (
<span className="text-xs text-muted-foreground animate-pulse">
思考中…
</span>
)}
<div className="ml-auto">
<ThemeToggle />
</div>
)}
</header>
{thread.messages.map((message, idx) => {
// Render UI cards attached to this message
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uiItems = ((thread.values as any)?.ui ?? []).filter(
(ui: UIMsgLocal) => ui.metadata?.message_id === message.id,
) as UIMsgLocal[];
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-6">
{thread.messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground select-none">
<p className="text-lg font-medium">你好,有什么可以帮你的?</p>
<p className="text-sm">可以查询知识库、工单、搜索网络或执行代码。</p>
</div>
)}
if (message.type === "human") {
return (
<div key={message.id ?? idx} className="flex justify-end">
<div className="max-w-[75%] rounded-2xl rounded-br-sm bg-primary text-primary-foreground px-4 py-2.5 text-sm whitespace-pre-wrap">
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
</div>
</div>
);
}
{thread.messages.map((message, idx) => {
// Render UI cards attached to this message
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uiItems = ((thread.values as any)?.ui ?? []).filter(
(ui: UIMsgLocal) => ui.metadata?.message_id === message.id,
) as UIMsgLocal[];
if (message.type === "ai") {
const textContent =
typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? message.content
.filter((c) => c.type === "text")
.map((c) => ("text" in c ? c.text : ""))
.join("")
: "";
return (
<div key={message.id ?? idx} className="flex flex-col gap-3">
{/* Text reply */}
{textContent && (
<div className="max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm whitespace-pre-wrap">
{textContent}
if (message.type === "human") {
return (
<div key={message.id ?? idx} className="flex justify-end">
<div className="max-w-[75%] rounded-2xl rounded-br-sm bg-primary text-primary-foreground px-4 py-2.5 text-sm whitespace-pre-wrap">
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content)}
</div>
)}
{/* UI cards */}
{uiItems.map((ui: UIMsgLocal) => (
<LoadExternalComponent
key={ui.id}
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
message={ui as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components={ComponentMap as any}
/>
))}
</div>
);
}
return null;
})}
{/* Streaming UI cards not yet attached to a completed message */}
{thread.isLoading &&
(thread.values?.ui ?? [])
.filter((ui: UIMsgLocal) => {
const attachedToExisting = thread.messages.some(
(m) => m.id === ui.metadata?.message_id,
</div>
);
return !attachedToExisting;
})
.map((ui: UIMsgLocal) => (
<LoadExternalComponent
key={ui.id}
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
message={ui as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components={ComponentMap as any}
/>
}
if (message.type === "ai") {
const textContent =
typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? message.content
.filter((c) => c.type === "text")
.map((c) => ("text" in c ? c.text : ""))
.join("")
: "";
return (
<div key={message.id ?? idx} className="flex flex-col gap-3">
{/* Text reply */}
{textContent && (
<div className="max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm">
<MessageBubble content={textContent} role="ai" />
</div>
)}
{/* UI cards */}
{uiItems.map((ui: UIMsgLocal) => (
<LoadExternalComponent
key={ui.id}
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
message={ui as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components={ComponentMap as any}
/>
))}
</div>
);
}
return null;
})}
{/* Streaming UI cards not yet attached to a completed message */}
{thread.isLoading &&
(thread.values?.ui ?? [])
.filter((ui: UIMsgLocal) => {
const attachedToExisting = thread.messages.some(
(m) => m.id === ui.metadata?.message_id,
);
return !attachedToExisting;
})
.map((ui: UIMsgLocal) => (
<LoadExternalComponent
key={ui.id}
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
message={ui as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components={ComponentMap as any}
/>
))}
<div ref={bottomRef} />
</div>
{/* Control bar: tools (left) + model selector (right) */}
<div className="shrink-0 border-t border-border px-4 pt-3 pb-0 flex items-center justify-between max-w-3xl mx-auto w-full">
{/* Tool toggles */}
<div className="flex items-center gap-1.5">
{TOOL_GROUPS.map(({ key, label, icon: Icon }) => {
const isOn = activeTools.has(key);
return (
<button
key={key}
type="button"
onClick={() => toggleTool(key)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
isOn
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="size-3.5" />
{label}
</button>
);
})}
</div>
{/* Model selector */}
<div className="flex items-center gap-0.5">
{MODEL_OPTIONS.map(({ value, label, icon: Icon }) => (
<Button
key={value}
type="button"
variant={modelMode === value ? "default" : "ghost"}
size="sm"
className="h-7 px-2.5 text-xs gap-1"
onClick={() => setModelMode(value)}
>
<Icon className="size-3.5" />
{label}
</Button>
))}
</div>
</div>
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="shrink-0 border-t border-border px-4 py-4">
<form onSubmit={handleSubmit} className="flex gap-2 max-w-3xl mx-auto">
<input
className="flex-1 rounded-xl border border-input bg-background px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring placeholder:text-muted-foreground disabled:opacity-50"
placeholder="输入消息…"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={thread.isLoading}
autoFocus
/>
<button
type="submit"
disabled={thread.isLoading || !input.trim()}
className="rounded-xl bg-primary text-primary-foreground px-4 py-2.5 text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
>
发送
</button>
</form>
{/* Input */}
<div className="shrink-0 px-4 py-3">
<form onSubmit={handleSubmit} className="flex gap-2 max-w-3xl mx-auto">
<input
className="flex-1 rounded-xl border border-input bg-background px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring placeholder:text-muted-foreground disabled:opacity-50"
placeholder="输入消息…"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={thread.isLoading}
autoFocus
/>
<button
type="submit"
disabled={thread.isLoading || !input.trim()}
className="rounded-xl bg-primary text-primary-foreground px-4 py-2.5 text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
>
发送
</button>
</form>
</div>
</div>
</div>
);
}
createRoot(document.getElementById("root")!).render(<App />);
createRoot(document.getElementById("root")!).render(
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<App />
</ThemeProvider>,
);
+67
View File
@@ -0,0 +1,67 @@
# SOC LangGraph.js 本地 Docker 部署复测报告
## 测试环境
- 后端: http://localhost:8080 (容器 soc-langgraph)
- ��端: http://localhost:3002 (容器 soc-langgraph-ui)
- 测试时间: 2026-04-10
- Docker 镜像: soc-langgraph:local
## 测试结果汇总
| # | 测试项 | 端点 | 结果 | 备注 |
|---|--------|------|------|------|
| 1 | 后端健康检查 | GET /info | PASS | 返回 `{"flags":{"assistants":true,"crons":false}}` |
| 2 | 创建 Thread | POST /threads | PASS | 返回 thread_id, status=idle |
| 3 | Agent 流式对话 (generalInput) | POST /threads/{id}/runs/stream | PASS | Router -> generalInput, AI 正常回复中文 |
| 4 | Agent 非流式对话 | POST /threads/{id}/runs/wait | PASS | 返回 2 条消息, AI 回复正确 |
| 5 | Chat Graph | POST /threads/{id}/runs/wait (chat) | PASS | 独立 chat graph 正常工作 |
| 6 | Enterprise - KB 搜索 | POST /threads/{id}/runs/wait (agent) | PASS | Router -> enterprise, 调用 kb_search, 检索到 5 条结果, UI 组件 knowledge-result 正常 |
| 7 | Enterprise - 工单列表 | POST /threads/{id}/runs/wait (agent) | PASS | 调用 ticket_list, 返回 3 条工单, UI 组件 ticket-summary 正常 |
| 8 | Enterprise - 网络搜索 | POST /threads/{id}/runs/wait (agent) | PASS | 调用 web_search, 找到 5 条结果, UI 组件 search-result 正常 |
| 9 | Thread 历史记录 | POST /threads/{id}/history | PASS | 返回 4 条历史条目 |
| 10 | Thread 状态 | GET /threads/{id}/state | PASS | 返回消息列表及��态 |
| 11 | 助手列表 | POST /assistants/search | PASS | 3 个 graph: agent, chat, email_agent |
| 12 | 前端 HTML | GET http://localhost:3002/ | PASS | 返回 200, 完整 HTML |
| 13 | 前端 API 地址 | JS bundle 检查 | PASS | 指向 http://localhost:8080 |
| 14 | 后端日志 | docker logs | PASS | 最近运行无 error 日志 |
## 修复的问题
### 问题 1: AZURE_OPENAI_ENDPOINT 格式错误 (已修复)
- **原值**: `https://ai-gzy0016231ai975636166896.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview/`
- **修正**: `https://ai-gzy0016231ai975636166896.cognitiveservices.azure.com`
- **原因**: LangChain AzureChatOpenAI 会自动拼接 `/openai/deployments/{name}/chat/completions` 路径,endpoint 只需基础 URL
- **文���**: `/Users/gongzhiyong/go/SOC/langgraph/.env` (第 2 行)
### 问题 2: 多个 Agent 节点使用 ChatOpenAI 而非 AzureChatOpenAI (已修复)
- **现象**: Router 节点成功路由后, generalInput/chat 等节点报 `OPENAI_API_KEY environment variable is missing`
- **原因**: 这些节点使用 `new ChatOpenAI({model: "gpt-4o-mini"})`, 需要普通 OpenAI API Key, 但项目用的是 Azure OpenAI
- **修复方案**: 创建共用工厂函数 `createLlm()`, 统一使用 AzureChatOpenAI
- **修改的文件**:
- `src/agent/utils/create-llm.ts` -- 新建, 共用 LLM 工厂函数
- `src/agent/supervisor/nodes/router.ts` -- 改用 createLlm()
- `src/agent/supervisor/nodes/general-input.ts` -- ChatOpenAI -> createLlm()
- `src/agent/chat-agent/index.ts` -- ChatOpenAI -> createLlm()
- `src/agent/enterprise/nodes/tools.ts` -- 内联 createLlm -> 共用 createLlm
- `src/agent/trip-planner/nodes/classify.ts` -- ChatOpenAI -> createLlm()
- `src/agent/trip-planner/nodes/tools.ts` -- ChatOpenAI -> createLlm()
- `src/agent/trip-planner/nodes/extraction.ts` -- ChatOpenAI -> createLlm()
- `src/agent/email-agent/nodes/write-email.ts` -- ChatOpenAI -> createLlm()
- `src/agent/email-agent/nodes/rewrite-email.ts` -- ChatOpenAI -> createLlm()
- `src/agent/stockbroker/nodes/tools.ts` -- ChatOpenAI -> createLlm()
## 已知的外部依赖状态
| 外部服务 | 状态 | 备注 |
|----------|------|------|
| Azure OpenAI (gpt-5.4) | 正常 | 所有 LLM 调用成功 |
| KB Agent (知识库) | 正常 | 检索到 5 条结果 |
| Gongdan (工单) | 正常 | 返回 3 条工单 |
| Jina (网络搜索) | 正常 | 搜索到 5 条结果 |
| Daytona (沙盒) | 未测试 | 需要特定代码执行场景 |
## 通过率
**14/14 通过 (100%)**
所有后端 API 端点和前端页面均正常工作。两个核心 Bug 已修复并验证。