feat: Phase 1 — ReAct loop + Markdown rendering + Docker deployment
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:
co-authored by
Claude Opus 4.6
parent
3263b15554
commit
251c6586f4
@@ -20,29 +20,6 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Login to Azure
|
||||||
uses: azure/login@v2
|
uses: azure/login@v2
|
||||||
with:
|
with:
|
||||||
@@ -50,8 +27,25 @@ jobs:
|
|||||||
tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_62D66D4E65AC4A6C872064AD668AC691 }}
|
tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_62D66D4E65AC4A6C872064AD668AC691 }}
|
||||||
subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_5D7B0564A55F4209A91149F2642D3F69 }}
|
subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_5D7B0564A55F4209A91149F2642D3F69 }}
|
||||||
|
|
||||||
- name: Deploy to Azure Web App
|
- name: Build and push to ACR
|
||||||
uses: azure/webapps-deploy@v3
|
run: |
|
||||||
with:
|
az acr build \
|
||||||
app-name: soc-langgraph
|
--registry socsocacr \
|
||||||
package: deploy-langgraph.zip
|
--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
|
||||||
|
|||||||
@@ -4,102 +4,170 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Overview
|
## 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
|
## Commands
|
||||||
|
|
||||||
### Backend
|
### 后端(LangGraph Server)
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd langgraph
|
||||||
python -m venv .venv && source .venv/bin/activate
|
pnpm install
|
||||||
pip install -r requirements.txt
|
pnpm run agent # langgraphjs dev --no-browser (port 2024)
|
||||||
uvicorn app.main:app --port 8000 --reload # Dev server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (read-only unless explicitly authorized)
|
### 前端(Vite SPA)
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd langgraph
|
||||||
npm install && npm run dev # localhost:3000
|
pnpm install
|
||||||
npm run build
|
pnpm run build # tsc -b && vite build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
```bash
|
```bash
|
||||||
# Manual deploy (Oryx builds dependencies on Azure)
|
# Push to main branch — GitHub Actions auto-deploys:
|
||||||
az webapp up --name soc-backend --resource-group Operation --runtime "PYTHON:3.12"
|
# - deploy-langgraph.yml: ACR build → Web App container update
|
||||||
|
# - deploy-langgraph-ui.yml: pnpm vite build → Azure Static Web Apps
|
||||||
# Or push to main branch — GitHub Actions auto-deploys via .github/workflows/deploy-backend.yml
|
|
||||||
git push origin main
|
git push origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## 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
|
### Request Flow
|
||||||
```
|
```
|
||||||
Frontend POST /api/chat/stream
|
Frontend useStream → LangGraph SSE
|
||||||
→ api/chat.py: resolve_tools() → get_chat_graph() → astream_events()
|
→ Supervisor router (Gemini Flash) → route to enterprise or generalInput
|
||||||
→ graph/builder.py: create_react_agent (with tools) OR plain StateGraph (no tools)
|
→ Enterprise tools node: LLM bindTools → call external APIs → ui.push() Gen-UI cards → LLM final answer
|
||||||
→ tools/*.py: LangChain @tool functions call external services
|
→ SSE stream with messages + UI components
|
||||||
→ SSE events: token / tool_start / tool_end / done
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Graph Strategy (graph/builder.py)
|
## Repository Structure
|
||||||
- **With tools**: `create_react_agent(llm, tools, checkpointer)` — ReAct pattern, LLM decides tool calls
|
```
|
||||||
- **Without tools**: Simple `StateGraph(ChatState)` with single `call_model` node
|
so-c-chat-clone/
|
||||||
- Graphs cached by `(model, frozenset(tool_names))` to avoid re-compilation
|
├── langgraph/ ← 唯一代码目录
|
||||||
- Model presets: `flash` (max_tokens=500, temp=0.2), `pro` (max_tokens=4096, temp=0.3)
|
│ ├── src/agent/supervisor/ ← Supervisor Agent(路由)
|
||||||
|
│ │ ├── index.ts ← StateGraph + checkpointer
|
||||||
### Tool System (tools/__init__.py)
|
│ │ ├── nodes/router.ts ← Gemini Flash 意图路由
|
||||||
Frontend sends tool keys → `resolve_tools()` maps to LangChain @tool objects → bound to ReAct agent.
|
│ │ └── nodes/general-input.ts ← 通用对话节点
|
||||||
|
│ ├── src/agent/enterprise/ ← Enterprise Agent
|
||||||
| Frontend Key | Tools | External Service |
|
│ │ ├── index.ts ← StateGraph (START → tools)
|
||||||
|-------------|-------|-----------------|
|
│ │ ├── nodes/tools.ts ← 6 tools + LLM + ui.push()
|
||||||
| `"knowledge"` | `kb_search` | KB Agent (Azure AI Search) |
|
│ │ ├── tools/soc-client.ts ← 外部 API 客户端
|
||||||
| `"tickets"` | `ticket_list`, `ticket_detail` | Gongdan API |
|
│ │ └── types.ts ← EnterpriseAnnotation
|
||||||
| `"search"` | `web_search` | Jina Search/Reader/Rerank |
|
│ ├── src/agent/chat-agent/index.ts ← 简单聊天 Agent
|
||||||
| `"document"` | `generate_document` | Doc Creator Agent |
|
│ ├── src/agent/utils/ ← checkpointer, format-messages
|
||||||
| `"sandbox"` | `sandbox_run` | Daytona API |
|
│ ├── src/agent-uis/enterprise/ ← 5 个 Gen-UI 卡片组件
|
||||||
|
│ │ ├── knowledge-result/
|
||||||
### Data Layer
|
│ │ ├── ticket-summary/
|
||||||
- **PostgreSQL** (`store/postgres.py`): SQLAlchemy async ORM — `Conversation` and `Message` models, auto-creates tables on startup
|
│ │ ├── ticket-detail/
|
||||||
- **LangGraph Checkpointer** (`store/memory.py`): `AsyncPostgresSaver` via psycopg (separate connection from SQLAlchemy, uses `sslmode=require` not `ssl=require`)
|
│ │ ├── search-result/
|
||||||
- **Redis** (`cache/redis.py`): Search result caching, key=`search:{hash}:{model}`, TTL=300s, graceful degradation on failure
|
│ │ └── sandbox-result/
|
||||||
- **Azure Blob Storage** (`storage/blob.py`): File uploads/downloads for attachments and generated docs
|
│ ├── src/main.tsx ← Chat UI 入口(useStream)
|
||||||
- **Azure Service Bus** (`tasks/bus.py`): Async task dispatch for long-running operations
|
│ ├── langgraph.json ← LangGraph 配置
|
||||||
|
│ ├── startup.sh ← Azure Web App 启动脚本
|
||||||
### SSE Event Protocol
|
│ ├── Dockerfile ← 后端容器
|
||||||
```json
|
│ ├── Dockerfile.frontend ← 前端容器(未使用)
|
||||||
{"type": "token", "content": "..."} // Streamed text chunk
|
│ └── package.json ← pnpm, Node.js 20
|
||||||
{"type": "tool_start", "tool": "kb_search"}
|
├── .github/workflows/
|
||||||
{"type": "tool_end", "tool": "kb_search"}
|
│ ├── deploy-langgraph.yml ← ACR build → Web App
|
||||||
{"type": "done"} // End of stream
|
│ └── 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
|
## 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
|
## Deployment
|
||||||
|
|
||||||
- **Azure Web App**: `soc-backend` in `Operation` resource group, Python 3.12, B1 Linux
|
### 后端 (soc-langgraph)
|
||||||
- **Startup**: `gunicorn -w 2 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 120 app.main:app`
|
- **Azure Web App**: Node.js 20, B1 Linux, Southeast Asia, Operation 资源组
|
||||||
- **Critical setting**: `WEBSITES_PORT=8000` (Azure defaults to 8080)
|
- **App Service Plan**: soc-langgraph-plan
|
||||||
- **CI/CD**: GitHub Actions with OIDC auth (`azure/login@v2`) → zip deploy, triggers on `backend/**` changes to main
|
- **启动命令**: `bash startup.sh`(pnpm install + langgraphjs dev --port $PORT)
|
||||||
- **Always On**: Enabled to avoid cold starts
|
- **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
|
## Constraints
|
||||||
|
|
||||||
- **Frontend is read-only** unless explicitly authorized
|
- **Azure 资源组**: 仅允许操作 `Operation` 和 `AuthData`,所有 `az` 命令必须带 `--resource-group`
|
||||||
- **Azure resources**: `Operation` and `AuthData` resource groups only — all `az` commands must include `--resource-group`
|
- **禁止删除已存在的 Azure 资源**
|
||||||
- **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.
|
- **GitHub**: Fasthei/so-c-chat-clone,main 分支
|
||||||
- **Search depth**: `flash` = quick (top 3, no Reader/Rerank), `pro` = deep (top 10, concurrent Reader, Rerank top 5)
|
- **已废弃(已删除)**: backend/ (Python), frontend/ (Next.js), soc-backend Web App, soc-frontend Static Web App, Container App
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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+ 开销 |
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.docker.example
|
||||||
|
.langgraph_api
|
||||||
|
.claude
|
||||||
|
*.md
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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;"]
|
||||||
@@ -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"]
|
||||||
@@ -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;"]
|
||||||
@@ -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"]
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
"node_version": "20",
|
"node_version": "20",
|
||||||
"graphs": {
|
"graphs": {
|
||||||
"agent": "./src/agent/supervisor/index.ts:graph",
|
"agent": "./src/agent/supervisor/index.ts:graph",
|
||||||
"email_agent": "./src/agent/email-agent/index.ts:agent",
|
|
||||||
"chat": "./src/agent/chat-agent/index.ts:agent"
|
"chat": "./src/agent/chat-agent/index.ts:agent"
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@langchain/google-genai": "^0.1.10",
|
"@langchain/google-genai": "^0.1.10",
|
||||||
"@langchain/langgraph": "^0.2.64",
|
"@langchain/langgraph": "^0.2.64",
|
||||||
"@langchain/langgraph-checkpoint": "^0.0.17",
|
"@langchain/langgraph-checkpoint": "^0.0.17",
|
||||||
|
"@langchain/langgraph-checkpoint-postgres": "^1.0.1",
|
||||||
"@langchain/langgraph-cli": "^0.0.30",
|
"@langchain/langgraph-cli": "^0.0.30",
|
||||||
"@langchain/langgraph-sdk": "^0.0.73",
|
"@langchain/langgraph-sdk": "^0.0.73",
|
||||||
"@langchain/openai": "^0.5.5",
|
"@langchain/openai": "^0.5.5",
|
||||||
|
|||||||
Generated
+2253
-4300
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 KnowledgeResult from "./enterprise/knowledge-result";
|
||||||
import TicketSummary from "./enterprise/ticket-summary";
|
import TicketSummary from "./enterprise/ticket-summary";
|
||||||
import TicketDetail from "./enterprise/ticket-detail";
|
import TicketDetail from "./enterprise/ticket-detail";
|
||||||
import SearchResult from "./enterprise/search-result";
|
import SearchResult from "./enterprise/search-result";
|
||||||
import SandboxResult from "./enterprise/sandbox-result";
|
import SandboxResult from "./enterprise/sandbox-result";
|
||||||
|
import ChartResult from "./enterprise/chart-result";
|
||||||
|
|
||||||
const ComponentMap = {
|
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,
|
"knowledge-result": KnowledgeResult,
|
||||||
"ticket-summary": TicketSummary,
|
"ticket-summary": TicketSummary,
|
||||||
"ticket-detail": TicketDetail,
|
"ticket-detail": TicketDetail,
|
||||||
"search-result": SearchResult,
|
"search-result": SearchResult,
|
||||||
"sandbox-result": SandboxResult,
|
"sandbox-result": SandboxResult,
|
||||||
|
"chart-result": ChartResult,
|
||||||
} as const;
|
} as const;
|
||||||
export default ComponentMap;
|
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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
START,
|
START,
|
||||||
StateGraph,
|
StateGraph,
|
||||||
} from "@langchain/langgraph";
|
} from "@langchain/langgraph";
|
||||||
import { ChatOpenAI } from "@langchain/openai";
|
import { createLlm } from "@/agent/utils/create-llm";
|
||||||
|
|
||||||
const ChatAgentAnnotation = Annotation.Root({
|
const ChatAgentAnnotation = Annotation.Root({
|
||||||
messages: MessagesAnnotation.spec["messages"],
|
messages: MessagesAnnotation.spec["messages"],
|
||||||
@@ -12,9 +12,7 @@ const ChatAgentAnnotation = Annotation.Root({
|
|||||||
|
|
||||||
const graph = new StateGraph(ChatAgentAnnotation)
|
const graph = new StateGraph(ChatAgentAnnotation)
|
||||||
.addNode("chat", async (state) => {
|
.addNode("chat", async (state) => {
|
||||||
const model = new ChatOpenAI({
|
const model = createLlm();
|
||||||
model: "gpt-4o-mini",
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await model.invoke([
|
const response = await model.invoke([
|
||||||
{ role: "system", content: "You are a helpful assistant." },
|
{ role: "system", content: "You are a helpful assistant." },
|
||||||
|
|||||||
@@ -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],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,10 +1,46 @@
|
|||||||
import { StateGraph, START } from "@langchain/langgraph";
|
import { StateGraph, START, END } from "@langchain/langgraph";
|
||||||
import { EnterpriseAnnotation } from "./types.js";
|
import { AIMessage } from "@langchain/core/messages";
|
||||||
import { enterpriseToolsNode } from "./nodes/tools.js";
|
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)
|
const builder = new StateGraph(EnterpriseAnnotation)
|
||||||
.addNode("tools", enterpriseToolsNode)
|
.addNode("agent", agentNode)
|
||||||
.addEdge(START, "tools");
|
.addNode("tool-executor", toolExecutorNode)
|
||||||
|
.addEdge(START, "agent")
|
||||||
|
.addConditionalEdges("agent", routeAfterAgent, ["tool-executor", END])
|
||||||
|
.addEdge("tool-executor", "agent");
|
||||||
|
|
||||||
export const enterpriseGraph = builder.compile();
|
export const enterpriseGraph = builder.compile();
|
||||||
enterpriseGraph.name = "Enterprise";
|
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 };
|
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 ---
|
// --- Daytona Sandbox Execution ---
|
||||||
export async function sandboxRun(
|
export async function sandboxRun(
|
||||||
code: string,
|
code: string,
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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";
|
|
||||||
@@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import { StateGraph, START, END } from "@langchain/langgraph";
|
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 {
|
import {
|
||||||
SupervisorAnnotation,
|
SupervisorAnnotation,
|
||||||
SupervisorState,
|
SupervisorState,
|
||||||
@@ -10,55 +6,31 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
import { generalInput } from "./nodes/general-input";
|
import { generalInput } from "./nodes/general-input";
|
||||||
import { router } from "./nodes/router";
|
import { router } from "./nodes/router";
|
||||||
import { graph as writerAgentGraph } from "../writer-agent";
|
|
||||||
import { enterpriseGraph } from "../enterprise";
|
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
|
export const ALL_TOOL_DESCRIPTIONS = `- enterprise: 企业内部助手:知识库查询、工单管理、网络搜索、代码执行
|
||||||
- tripPlanner: helps the user plan their trip. it can suggest restaurants, and places to stay in any given location.
|
- generalInput: handles all other cases where the above tools don't apply`;
|
||||||
- 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: 企业内部助手:知识库查询、工单管理、网络搜索、代码执行`;
|
|
||||||
|
|
||||||
function handleRoute(
|
function handleRoute(
|
||||||
state: SupervisorState,
|
state: SupervisorState,
|
||||||
):
|
): "generalInput" | "enterprise" {
|
||||||
| "stockbroker"
|
|
||||||
| "tripPlanner"
|
|
||||||
| "openCode"
|
|
||||||
| "orderPizza"
|
|
||||||
| "generalInput"
|
|
||||||
| "writerAgent"
|
|
||||||
| "enterprise" {
|
|
||||||
return state.next;
|
return state.next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const builder = new StateGraph(SupervisorAnnotation, SupervisorZodConfiguration)
|
const builder = new StateGraph(SupervisorAnnotation, SupervisorZodConfiguration)
|
||||||
.addNode("router", router)
|
.addNode("router", router)
|
||||||
.addNode("stockbroker", stockbrokerGraph)
|
|
||||||
.addNode("tripPlanner", tripPlannerGraph)
|
|
||||||
.addNode("openCode", openCodeGraph)
|
|
||||||
.addNode("orderPizza", orderPizzaGraph)
|
|
||||||
.addNode("generalInput", generalInput)
|
.addNode("generalInput", generalInput)
|
||||||
.addNode("writerAgent", writerAgentGraph)
|
|
||||||
.addNode("enterprise", enterpriseGraph)
|
.addNode("enterprise", enterpriseGraph)
|
||||||
.addConditionalEdges("router", handleRoute, [
|
.addConditionalEdges("router", handleRoute, [
|
||||||
"stockbroker",
|
|
||||||
"tripPlanner",
|
|
||||||
"openCode",
|
|
||||||
"orderPizza",
|
|
||||||
"generalInput",
|
"generalInput",
|
||||||
"writerAgent",
|
|
||||||
"enterprise",
|
"enterprise",
|
||||||
])
|
])
|
||||||
.addEdge(START, "router")
|
.addEdge(START, "router")
|
||||||
.addEdge("stockbroker", END)
|
|
||||||
.addEdge("tripPlanner", END)
|
|
||||||
.addEdge("openCode", END)
|
|
||||||
.addEdge("orderPizza", END)
|
|
||||||
.addEdge("generalInput", END)
|
.addEdge("generalInput", END)
|
||||||
.addEdge("writerAgent", END)
|
|
||||||
.addEdge("enterprise", END);
|
.addEdge("enterprise", END);
|
||||||
|
|
||||||
export const graph = builder.compile();
|
// Use checkpointer for conversation history persistence
|
||||||
graph.name = "Generative UI Agent";
|
const checkpointer = await getCheckpointer();
|
||||||
|
export const graph = builder.compile({ checkpointer });
|
||||||
|
graph.name = "Enterprise Agent";
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { SupervisorState, SupervisorUpdate } from "../types";
|
import { SupervisorState, SupervisorUpdate } from "../types";
|
||||||
import { ALL_TOOL_DESCRIPTIONS } from "../index";
|
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(
|
export async function generalInput(
|
||||||
state: SupervisorState,
|
state: SupervisorState,
|
||||||
|
config: LangGraphRunnableConfig,
|
||||||
): Promise<SupervisorUpdate> {
|
): Promise<SupervisorUpdate> {
|
||||||
const GENERAL_INPUT_SYSTEM_PROMPT = `You are an AI assistant.
|
const GENERAL_INPUT_SYSTEM_PROMPT = `You are an AI assistant.
|
||||||
If the user asks what you can do, describe these tools.
|
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.`;
|
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([
|
const response = await llm.invoke([
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
|
|||||||
@@ -1,25 +1,19 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
|
||||||
import { ALL_TOOL_DESCRIPTIONS } from "../index";
|
import { ALL_TOOL_DESCRIPTIONS } from "../index";
|
||||||
import { SupervisorState, SupervisorUpdate } from "../types";
|
import { SupervisorState, SupervisorUpdate } from "../types";
|
||||||
import { formatMessages } from "@/agent/utils/format-messages";
|
import { formatMessages } from "@/agent/utils/format-messages";
|
||||||
|
import { createLlm } from "@/agent/utils/create-llm";
|
||||||
|
|
||||||
export async function router(
|
export async function router(
|
||||||
state: SupervisorState,
|
state: SupervisorState,
|
||||||
): Promise<Partial<SupervisorUpdate>> {
|
): Promise<Partial<SupervisorUpdate>> {
|
||||||
const routerDescription = `The route to take based on the user's input.
|
const routerDescription = `The route to take based on the user's input.
|
||||||
${ALL_TOOL_DESCRIPTIONS}
|
${ALL_TOOL_DESCRIPTIONS}
|
||||||
- generalInput: handles all other cases where the above tools don't apply
|
|
||||||
`;
|
`;
|
||||||
const routerSchema = z.object({
|
const routerSchema = z.object({
|
||||||
route: z
|
route: z
|
||||||
.enum([
|
.enum([
|
||||||
"stockbroker",
|
|
||||||
"tripPlanner",
|
|
||||||
"openCode",
|
|
||||||
"orderPizza",
|
|
||||||
"generalInput",
|
"generalInput",
|
||||||
"writerAgent",
|
|
||||||
"enterprise",
|
"enterprise",
|
||||||
])
|
])
|
||||||
.describe(routerDescription),
|
.describe(routerDescription),
|
||||||
@@ -30,10 +24,7 @@ ${ALL_TOOL_DESCRIPTIONS}
|
|||||||
schema: routerSchema,
|
schema: routerSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
const llm = new ChatGoogleGenerativeAI({
|
const llm = createLlm()
|
||||||
model: "gemini-2.0-flash",
|
|
||||||
temperature: 0,
|
|
||||||
})
|
|
||||||
.bindTools([routerTool], { tool_choice: "router" })
|
.bindTools([routerTool], { tool_choice: "router" })
|
||||||
.withConfig({ tags: ["langsmith:nostream"] });
|
.withConfig({ tags: ["langsmith:nostream"] });
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* The temperature to use for the reflection generation.
|
||||||
* Defaults to `0.7`.
|
* Defaults to `0.7`.
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -13,43 +13,7 @@ export const GenerativeUIAnnotation = Annotation.Root({
|
|||||||
>({ default: () => [], reducer: uiMessageReducer }),
|
>({ default: () => [], reducer: uiMessageReducer }),
|
||||||
context: Annotation<Record<string, unknown> | undefined>,
|
context: Annotation<Record<string, unknown> | undefined>,
|
||||||
timestamp: Annotation<number>,
|
timestamp: Annotation<number>,
|
||||||
next: Annotation<
|
next: Annotation<"enterprise" | "generalInput">(),
|
||||||
| "stockbroker"
|
|
||||||
| "tripPlanner"
|
|
||||||
| "openCode"
|
|
||||||
| "orderPizza"
|
|
||||||
| "writerAgent"
|
|
||||||
| "enterprise"
|
|
||||||
| "generalInput"
|
|
||||||
>(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GenerativeUIState = typeof GenerativeUIAnnotation.State;
|
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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,25 +1,62 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||||
|
import { Client } from "@langchain/langgraph-sdk";
|
||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
// UIMessage is not exported directly — use a local shape
|
// 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 } };
|
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 ComponentMap from "./agent-uis/index.tsx";
|
||||||
import "./index.css";
|
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 =
|
const LANGGRAPH_URL =
|
||||||
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
|
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() {
|
function App() {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
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[] }>({
|
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
|
||||||
apiUrl: LANGGRAPH_URL,
|
apiUrl: LANGGRAPH_URL,
|
||||||
assistantId: "agent",
|
assistantId: "agent",
|
||||||
messagesKey: "messages",
|
messagesKey: "messages",
|
||||||
|
threadId: currentThreadId ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-scroll to bottom when messages update
|
// Auto-scroll to bottom when messages update
|
||||||
@@ -27,136 +64,264 @@ function App() {
|
|||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [thread.messages]);
|
}, [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) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (!text || thread.isLoading) return;
|
if (!text || thread.isLoading) return;
|
||||||
setInput("");
|
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 (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-background text-foreground">
|
<div className="h-screen flex flex-row bg-background text-foreground overflow-hidden">
|
||||||
{/* Header */}
|
{/* ── Left sidebar ── */}
|
||||||
<header className="shrink-0 border-b border-border px-6 py-3 flex items-center gap-3">
|
<ThreadSidebar
|
||||||
<span className="font-semibold text-foreground">运营大脑</span>
|
threads={threads}
|
||||||
{thread.isLoading && (
|
currentThreadId={currentThreadId}
|
||||||
<span className="text-xs text-muted-foreground animate-pulse">
|
onNewThread={handleNewThread}
|
||||||
思考中…
|
onSelectThread={handleSelectThread}
|
||||||
</span>
|
onDeleteThread={handleDeleteThread}
|
||||||
)}
|
/>
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Messages */}
|
{/* ── Right main area ── */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-6">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{thread.messages.length === 0 && (
|
{/* Header */}
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground select-none">
|
<header className="shrink-0 border-b border-border px-6 py-3 flex items-center gap-3">
|
||||||
<p className="text-lg font-medium">你好,有什么可以帮你的?</p>
|
<span className="font-semibold text-foreground">运营大脑</span>
|
||||||
<p className="text-sm">可以查询知识库、工单、搜索网络或执行代码。</p>
|
{thread.isLoading && (
|
||||||
|
<span className="text-xs text-muted-foreground animate-pulse">
|
||||||
|
思考中…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
)}
|
</header>
|
||||||
|
|
||||||
{thread.messages.map((message, idx) => {
|
{/* Messages */}
|
||||||
// Render UI cards attached to this message
|
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-6">
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
{thread.messages.length === 0 && (
|
||||||
const uiItems = ((thread.values as any)?.ui ?? []).filter(
|
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground select-none">
|
||||||
(ui: UIMsgLocal) => ui.metadata?.message_id === message.id,
|
<p className="text-lg font-medium">你好,有什么可以帮你的?</p>
|
||||||
) as UIMsgLocal[];
|
<p className="text-sm">可以查询知识库、工单、搜索网络或执行代码。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
if (message.type === "human") {
|
{thread.messages.map((message, idx) => {
|
||||||
return (
|
// Render UI cards attached to this message
|
||||||
<div key={message.id ?? idx} className="flex justify-end">
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
<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">
|
const uiItems = ((thread.values as any)?.ui ?? []).filter(
|
||||||
{typeof message.content === "string"
|
(ui: UIMsgLocal) => ui.metadata?.message_id === message.id,
|
||||||
? message.content
|
) as UIMsgLocal[];
|
||||||
: JSON.stringify(message.content)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === "ai") {
|
if (message.type === "human") {
|
||||||
const textContent =
|
return (
|
||||||
typeof message.content === "string"
|
<div key={message.id ?? idx} className="flex justify-end">
|
||||||
? message.content
|
<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">
|
||||||
: Array.isArray(message.content)
|
{typeof message.content === "string"
|
||||||
? message.content
|
? message.content
|
||||||
.filter((c) => c.type === "text")
|
: JSON.stringify(message.content)}
|
||||||
.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}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</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) => (
|
if (message.type === "ai") {
|
||||||
<LoadExternalComponent
|
const textContent =
|
||||||
key={ui.id}
|
typeof message.content === "string"
|
||||||
stream={thread}
|
? message.content
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
: Array.isArray(message.content)
|
||||||
message={ui as any}
|
? message.content
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
.filter((c) => c.type === "text")
|
||||||
components={ComponentMap as any}
|
.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} />
|
{/* Input */}
|
||||||
</div>
|
<div className="shrink-0 px-4 py-3">
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2 max-w-3xl mx-auto">
|
||||||
{/* Input */}
|
<input
|
||||||
<div className="shrink-0 border-t border-border px-4 py-4">
|
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"
|
||||||
<form onSubmit={handleSubmit} className="flex gap-2 max-w-3xl mx-auto">
|
placeholder="输入消息…"
|
||||||
<input
|
value={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"
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="输入消息…"
|
disabled={thread.isLoading}
|
||||||
value={input}
|
autoFocus
|
||||||
onChange={(e) => setInput(e.target.value)}
|
/>
|
||||||
disabled={thread.isLoading}
|
<button
|
||||||
autoFocus
|
type="submit"
|
||||||
/>
|
disabled={thread.isLoading || !input.trim()}
|
||||||
<button
|
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"
|
||||||
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>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 已修复并验证。
|
||||||
Reference in New Issue
Block a user