Skip to content

Commit b91d11d

Browse files
committed
feat: Agent 能力升级(SSE、MCP/PAT、Failover、Planning、OTel) (#101)
1 parent 55a9da2 commit b91d11d

55 files changed

Lines changed: 6416 additions & 291 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
24.14.0

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
# 多阶段构建,减小最终镜像大小
33

44
# ===== Stage 1: 前端构建 =====
5-
FROM node:20-alpine AS frontend-builder
5+
FROM node:24.14.0-alpine AS frontend-builder
66

77
WORKDIR /app/frontend
88

9-
# 安装 pnpm
10-
RUN npm install -g pnpm
9+
# 启用并固定 pnpm,避免镜像构建时随 npm 全局安装漂移
10+
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
1111

1212
# 复制依赖文件
1313
COPY frontend/package.json frontend/pnpm-lock.yaml ./

Makefile

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,109 @@
1-
.PHONY: help setup-backend dev-api dev-web build test test-notify doctor install-hooks clean-venv
1+
.PHONY: help setup-backend dev-api dev-web build test test-notify eval doctor install-hooks clean-venv
22

33
# 端口约定:
44
# - 后端::8000(Docker / 本地 dev 统一,避免存量用户升级困惑)
55
# - 前端::5183(与 BeeCount-Cloud 的 :5173 错开避免冲突)
66

7+
ifneq ($(filter Windows_NT,$(OS)),)
8+
WINDOWS := 1
9+
else ifneq (,$(findstring cmd.exe,$(ComSpec)))
10+
WINDOWS := 1
11+
endif
12+
13+
ifeq ($(WINDOWS),1)
14+
SHELL := cmd.exe
15+
.SHELLFLAGS := /C
16+
PYTHON := python
17+
VENV_PYTHON := .venv\Scripts\python.exe
18+
else
19+
PYTHON := python3
20+
VENV_PYTHON := .venv/bin/python
21+
endif
22+
723
help:
824
@echo "PanWatch 开发命令:"
925
@echo " make setup-backend 创建 venv 并安装后端依赖"
1026
@echo " make dev-api 启动后端(:8000,自动 setup-backend)"
1127
@echo " make dev-web 启动前端(:5183,自动 pnpm install)"
1228
@echo " make test 跑全部单测(默认不发通知)"
1329
@echo " make test-notify 跑全部单测(实际发送通知)"
30+
@echo " make eval 跑 Agent 过程评测集(chat 用例需 EVAL_AI_* 环境变量)"
1431
@echo " make doctor 系统自检(数据源/AI/通知/DB/磁盘/调度)"
1532
@echo " make build VERSION=x 构建前端 + Docker 镜像"
1633
@echo " make install-hooks 安装 git pre-push hook"
1734
@echo " make clean-venv 删除本地 venv"
1835

1936
setup-backend:
37+
ifeq ($(WINDOWS),1)
38+
@if not exist .venv ( echo [setup] 创建 venv & $(PYTHON) -m venv .venv )
39+
@$(VENV_PYTHON) -m pip install -q -r requirements.txt
40+
@if not exist .env if exist .env.example copy /Y .env.example .env >nul
41+
else
2042
@if [ ! -d .venv ]; then \
2143
echo ">>> 创建 venv"; \
2244
python3 -m venv .venv; \
2345
fi
24-
@. .venv/bin/activate && pip install -q -r requirements.txt
46+
@$(VENV_PYTHON) -m pip install -q -r requirements.txt
2547
@if [ ! -f .env ] && [ -f .env.example ]; then cp .env.example .env; fi
48+
endif
2649

2750
# server.py 内部已经用 uvicorn.run(host=0.0.0.0, port=8000, reload=True) 启动。
2851
dev-api: setup-backend
29-
. .venv/bin/activate && python server.py
52+
ifeq ($(WINDOWS),1)
53+
@set "DEV_RELOAD=1" && $(VENV_PYTHON) server.py
54+
else
55+
@DEV_RELOAD=1 $(VENV_PYTHON) server.py
56+
endif
3057

3158
dev-web:
59+
ifeq ($(WINDOWS),1)
60+
@where pnpm >nul 2>&1 || ( echo pnpm 未安装,请先 npm install -g pnpm & exit /b 1 )
61+
@cd frontend && pnpm install --no-frozen-lockfile && pnpm dev
62+
else
3263
@if ! command -v pnpm >/dev/null 2>&1; then \
3364
echo "pnpm 未安装,请先 npm install -g pnpm"; \
3465
exit 1; \
3566
fi
3667
cd frontend && pnpm install --no-frozen-lockfile && pnpm dev
68+
endif
3769

3870
test:
39-
. .venv/bin/activate && python -m pytest tests/ -v
71+
@$(VENV_PYTHON) -m pytest tests/ -v
4072

4173
test-notify:
42-
. .venv/bin/activate && python -m pytest tests/ -v --notify
74+
@$(VENV_PYTHON) -m pytest tests/ -v --notify
75+
76+
# Agent 过程评测(工具选择/参数/有据性/结构化输出/动作白名单):
77+
# - 结构化解析用例纯规则,直接跑
78+
# - chat 工具循环用例需被测模型: EVAL_AI_BASE_URL / EVAL_AI_API_KEY / EVAL_AI_MODEL
79+
# - 追加 LLM-as-judge: EVAL_JUDGE_* 环境变量 + EVAL_ARGS=--judge
80+
# - 建议在改动 prompts/*.txt 或工具 schema 后运行,低于阈值(EVAL_PASS_THRESHOLD)退出码非 0
81+
eval:
82+
@$(VENV_PYTHON) tests/eval/run_eval.py $(EVAL_ARGS)
4383

4484
# 命令行系统自检:跑一遍数据源/AI/通知 + DB/磁盘/调度,打印结果与修复建议
4585
doctor:
46-
. .venv/bin/activate && python -m src.core.doctor
86+
@$(VENV_PYTHON) -m src.core.doctor
4787

4888
# 用法: make build VERSION=0.3.0
4989
build:
90+
ifeq ($(WINDOWS),1)
91+
@if "$(VERSION)"=="" ( echo Usage: make build VERSION=^<version^> & exit /b 1 )
92+
@powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\build.ps1 -Version "$(VERSION)"
93+
else
5094
@if [ -z "$(VERSION)" ]; then \
5195
echo "Usage: make build VERSION=<version>"; \
5296
exit 1; \
5397
fi
5498
./build.sh $(VERSION)
99+
endif
55100

56101
install-hooks:
57102
bash scripts/install-hooks.sh
58103

59104
clean-venv:
105+
ifeq ($(WINDOWS),1)
106+
@if exist .venv rmdir /S /Q .venv
107+
else
60108
rm -rf .venv
109+
endif

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ docker-compose up -d
152152
| `PLAYWRIGHT_SKIP_BROWSER_INSTALL` | 跳过首次 Chromium 安装(不需要截图时可用) | 未设置 |
153153
| `LOG_LEVEL` | 控制台日志级别。默认 `INFO`(只输出业务事件 + 错误);排查问题时设 `DEBUG` 可看到调度心跳、采集过程等底层日志。UI 日志板始终保留完整记录,不受影响 | `INFO` |
154154
| `HTTP_PROXY` / `HTTPS_PROXY` / `http_proxy` | 出站 HTTP 代理。三种配置方式任选其一: ① 启动前 `export HTTP_PROXY=...`;② `.env` 里写 `http_proxy=http://host:port`;③ UI「设置 → 全局 HTTP 代理」。三者优先级:外部环境变量 > UI > `.env`。生效后所有 httpx 客户端走代理。`NO_PROXY` 默认包含 `localhost,127.0.0.1` | 未设置 |
155+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry OTLP 导出端点(如 `http://jaeger:4318`)。**配置后**才启用 OTel trace 导出;留空则完全关闭(零副作用)。还需安装可选依赖 `requirements-otel.txt`。详见下方「OTel 导出」 | 未设置(关闭) |
155156

156157
</details>
157158

@@ -197,6 +198,45 @@ cd frontend && pnpm install && pnpm dev # 前端 :5183
197198

198199
</details>
199200

201+
<details>
202+
<summary><b>OTel 导出(可选,默认关闭)</b></summary>
203+
204+
PanWatch 内建一套自建可观测体系(结构化日志 `trace_id` 贯穿 / `agent_runs` 运行表 / TradingAgents 节点级进度与成本),开箱即用、无需任何外部组件。
205+
206+
在此之上,可**可选地**再挂一层标准 [OpenTelemetry](https://opentelemetry.io/) 导出,把 trace 送到 Jaeger / Tempo / Langfuse 等标准 APM。三类 span 映射:
207+
208+
- **Agent 一次运行** → root span(复用 `agent_runs``trace_id` 关联)
209+
- **单次 LLM 调用**`gen_ai` 子 span,遵循 [OpenTelemetry GenAI 语义约定](https://opentelemetry.io/docs/specs/semconv/gen-ai/)(`gen_ai.system` / `gen_ai.request.model` / `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens` / `gen_ai.operation.name`),可被标准 APM 识别为一次模型调用
210+
- **TradingAgents 节点** → 子 span(复用节点级进度回调)
211+
212+
**默认完全关闭**:不装依赖、不配 endpoint 时,导出层全程 no-op,不改变任何现有行为。
213+
214+
**开启三步**:
215+
216+
```bash
217+
# 1. 安装可选依赖
218+
pip install -r requirements-otel.txt
219+
220+
# 2. 配置 OTLP 端点(指向你的 collector / APM)
221+
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
222+
export OTEL_SERVICE_NAME=panwatch # 可选,默认 panwatch
223+
224+
# 3. 正常启动;启动日志出现 "OTel 导出已启用" 即生效
225+
python server.py
226+
```
227+
228+
**本地起一个 Jaeger 验证**:
229+
230+
```bash
231+
docker run -d --name jaeger -p 16686:16686 -p 4318:4318 \
232+
jaegertracing/all-in-one:latest
233+
# 触发任意 Agent 运行后,打开 http://localhost:16686 选 service=panwatch 查看 trace
234+
```
235+
236+
Langfuse / Tempo 同理,把 `OTEL_EXPORTER_OTLP_ENDPOINT` 指向对应 OTLP 入口即可。
237+
238+
</details>
239+
200240
<details>
201241
<summary><b>发布(Docker 镜像)</b></summary>
202242

frontend/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
"private": true,
44
"version": "0.1.0",
55
"type": "module",
6+
"packageManager": "pnpm@9.15.9",
7+
"engines": {
8+
"node": "24.14.0",
9+
"pnpm": "9.15.9"
10+
},
611
"scripts": {
712
"dev": "vite",
813
"build": "tsc -b && vite build",

frontend/packages/api/src/chat.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { fetchAPI } from './client'
2+
import { readSSE, type SSEEvent } from './sse'
23

34
export interface ChatConversation {
45
id: number
@@ -49,4 +50,105 @@ export const chatApi = {
4950
fetchAPI<{ questions: string[] }>(
5051
`/chat/suggested-questions?symbol=${encodeURIComponent(symbol)}&market=${encodeURIComponent(market)}`
5152
),
53+
54+
sendMessageStream,
55+
}
56+
57+
export interface ChatStreamCallbacks {
58+
/** token 增量文本 */
59+
onToken?: (text: string) => void
60+
/** 模型开始调用工具(前端应清空当前 token 缓冲并展示"正在查询…") */
61+
onToolCallStart?: (info: { name: string; arguments: Record<string, unknown> }) => void
62+
/** 工具执行完成 */
63+
onToolResult?: (info: { name: string; ok: boolean; preview: string }) => void
64+
/** 计划驱动(全面诊断持仓):计划生成/步骤推进/完成 */
65+
onPlan?: (info: {
66+
status: string
67+
steps: { id: number; title: string; status: string }[]
68+
current?: number
69+
}) => void
70+
/** 最终回答(已落库) */
71+
onDone?: (msg: { message_id: number; content: string; created_at: string }) => void
72+
/** AI 服务异常(服务端已把错误文案落库) */
73+
onError?: (message: string) => void
74+
}
75+
76+
const CHAT_STREAM_MAX_RECONNECTS = 3
77+
78+
/**
79+
* 流式发送消息(SSE)。
80+
*
81+
* - 首次连接 POST /chat/conversations/{id}/messages/stream;
82+
* - meta 事件携带 stream_id,之后若连接中断(生成仍在服务端继续),
83+
* 自动经 GET /chat/streams/{stream_id} + Last-Event-ID 续推;
84+
* - 若首次连接直接失败(未收到任何事件),抛异常,调用方降级到非流式 sendMessage。
85+
*/
86+
async function sendMessageStream(
87+
conversationId: number,
88+
content: string,
89+
callbacks: ChatStreamCallbacks,
90+
signal?: AbortSignal
91+
): Promise<void> {
92+
let streamId = ''
93+
let lastEventId = 0
94+
let finished = false
95+
96+
const handleEvent = (ev: SSEEvent) => {
97+
if (ev.id > 0) lastEventId = ev.id
98+
const d = ev.data || {}
99+
switch (ev.event) {
100+
case 'meta':
101+
streamId = d.stream_id || ''
102+
break
103+
case 'token':
104+
callbacks.onToken?.(d.text || '')
105+
break
106+
case 'tool_call_start':
107+
callbacks.onToolCallStart?.({ name: d.name || '', arguments: d.arguments || {} })
108+
break
109+
case 'tool_result':
110+
callbacks.onToolResult?.({ name: d.name || '', ok: !!d.ok, preview: d.preview || '' })
111+
break
112+
case 'plan':
113+
callbacks.onPlan?.({ status: d.status || '', steps: d.steps || [], current: d.current })
114+
break
115+
case 'done':
116+
finished = true
117+
callbacks.onDone?.({
118+
message_id: d.message_id || 0,
119+
content: d.content || '',
120+
created_at: d.created_at || '',
121+
})
122+
break
123+
case 'error':
124+
callbacks.onError?.(d.message || '未知错误')
125+
break
126+
}
127+
}
128+
129+
await readSSE(`/chat/conversations/${conversationId}/messages/stream`, {
130+
method: 'POST',
131+
body: { content },
132+
signal,
133+
onEvent: handleEvent,
134+
})
135+
136+
// 连接被中断但生成未结束 → 经续推端点接回(服务端缓冲全量事件)
137+
let reconnects = 0
138+
while (!finished && streamId && reconnects < CHAT_STREAM_MAX_RECONNECTS) {
139+
if (signal?.aborted) return
140+
reconnects += 1
141+
try {
142+
await readSSE(`/chat/streams/${streamId}`, {
143+
signal,
144+
lastEventId,
145+
onEvent: handleEvent,
146+
})
147+
} catch {
148+
// 退避后再试
149+
await new Promise((r) => setTimeout(r, 1000 * reconnects))
150+
}
151+
}
152+
153+
if (!finished) throw new Error('流式回复未完成')
52154
}

frontend/packages/api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './client'
2+
export * from './sse'
23
export * from './types'
34
export * from './stocks'
45
export * from './insight'
@@ -15,3 +16,4 @@ export * from './paper-trading'
1516
export * from './chat'
1617
export * from './tradingagents'
1718
export * from './datasources'
19+
export * from './pats'

frontend/packages/api/src/pats.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { fetchAPI } from './client'
2+
3+
/** 个人访问令牌(PAT)—— MCP 端点专用凭据 */
4+
export interface PatItem {
5+
id: number
6+
name: string
7+
prefix: string
8+
scopes: string[]
9+
expires_at: string | null
10+
last_used_at: string | null
11+
revoked_at: string | null
12+
created_at: string | null
13+
revoked: boolean
14+
}
15+
16+
/** 创建响应:额外带一次性明文 token */
17+
export interface PatCreated extends PatItem {
18+
token: string
19+
}
20+
21+
export interface CreatePatBody {
22+
name?: string
23+
scopes?: string[]
24+
/** 过期天数;null = 永不过期 */
25+
expires_in_days?: number | null
26+
}
27+
28+
export const patsApi = {
29+
list: () => fetchAPI<{ items: PatItem[] }>('/pats'),
30+
31+
create: (body: CreatePatBody) =>
32+
fetchAPI<PatCreated>('/pats', {
33+
method: 'POST',
34+
body: JSON.stringify(body),
35+
}),
36+
37+
revoke: (id: number) =>
38+
fetchAPI<{ ok: boolean; id: number }>(`/pats/${encodeURIComponent(String(id))}`, {
39+
method: 'DELETE',
40+
}),
41+
}

0 commit comments

Comments
 (0)