fp-webui 0.1.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fp-webui might be problematic. Click here for more details.
- fp_webui/__init__.py +3 -0
- fp_webui/main.py +1234 -0
- fp_webui/static/.gitkeep +0 -0
- fp_webui/static/background.svg +115 -0
- fp_webui/static/favicon.png +0 -0
- fp_webui/static/index.html +2291 -0
- fp_webui-0.1.1.dev0.dist-info/METADATA +183 -0
- fp_webui-0.1.1.dev0.dist-info/RECORD +11 -0
- fp_webui-0.1.1.dev0.dist-info/WHEEL +5 -0
- fp_webui-0.1.1.dev0.dist-info/entry_points.txt +2 -0
- fp_webui-0.1.1.dev0.dist-info/top_level.txt +1 -0
fp_webui/main.py
ADDED
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Five Pebbles — WebUI 服务器
|
|
3
|
+
================================
|
|
4
|
+
|
|
5
|
+
插件模式的 Web 用户界面,不修改 core/ 中的任何代码。
|
|
6
|
+
|
|
7
|
+
架构:
|
|
8
|
+
┌─────────────┐ 生命周期钩子 ┌───────────┐ WebSocket ┌──────────┐
|
|
9
|
+
│ Agent 核心 │ ──────────────→ │ EventBus │ ────────────→ │ 前端 UI │
|
|
10
|
+
│ │ │ (pub/sub) │ │ (浏览器) │
|
|
11
|
+
└─────────────┘ └───────────┘ └──────────┘
|
|
12
|
+
|
|
13
|
+
用法:
|
|
14
|
+
cd /media/zpb/data/codes/AI/agent
|
|
15
|
+
python3 -m app.webui.main
|
|
16
|
+
|
|
17
|
+
或:
|
|
18
|
+
python3 app/webui/main.py
|
|
19
|
+
|
|
20
|
+
然后打开浏览器访问 http://localhost:8765
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import secrets
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
from contextlib import asynccontextmanager, suppress
|
|
31
|
+
|
|
32
|
+
# ── FastAPI / WebSocket ─────────────────────────────────
|
|
33
|
+
try:
|
|
34
|
+
import uvicorn
|
|
35
|
+
from fastapi import FastAPI, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
|
36
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
37
|
+
from fastapi.staticfiles import StaticFiles
|
|
38
|
+
except ImportError as e:
|
|
39
|
+
print(f"[WebUI] 缺少依赖: {e}")
|
|
40
|
+
print(" → 请安装: pip install -r app/webui/requirements.txt")
|
|
41
|
+
print(" → 或: pip install .[webui]")
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
# ── Agent 核心导入 ──────────────────────────────────────
|
|
45
|
+
from fp_core import display
|
|
46
|
+
from fp_core.core.agent import Agent
|
|
47
|
+
from fp_core.core.io import RestIO, WebSocketIO
|
|
48
|
+
from fp_core.core.lifecycle import HookContext, LifecycleHook
|
|
49
|
+
from fp_core.plugins.base.plugin import Plugin
|
|
50
|
+
|
|
51
|
+
# ════════════════════════════════════════════════════════════
|
|
52
|
+
# 1. EventBus — 异步发布/订阅
|
|
53
|
+
# ════════════════════════════════════════════════════════════
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EventBus:
|
|
57
|
+
"""
|
|
58
|
+
异步事件总线,用于 Agent 生命周期事件 → WebSocket 的桥梁。
|
|
59
|
+
|
|
60
|
+
支持多个订阅者(多个 WebSocket 连接),自动清理断开连接。
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self):
|
|
64
|
+
self._subscribers: dict[str, asyncio.Queue] = {}
|
|
65
|
+
self._next_id = 0
|
|
66
|
+
|
|
67
|
+
def subscribe(self) -> tuple[str, asyncio.Queue]:
|
|
68
|
+
"""订阅事件流,返回 (subscriber_id, queue)"""
|
|
69
|
+
sub_id = f"sub_{self._next_id}"
|
|
70
|
+
self._next_id += 1
|
|
71
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=256)
|
|
72
|
+
self._subscribers[sub_id] = q
|
|
73
|
+
return sub_id, q
|
|
74
|
+
|
|
75
|
+
def unsubscribe(self, sub_id: str):
|
|
76
|
+
"""取消订阅"""
|
|
77
|
+
self._subscribers.pop(sub_id, None)
|
|
78
|
+
|
|
79
|
+
async def publish(self, event: dict):
|
|
80
|
+
"""向所有订阅者推送事件"""
|
|
81
|
+
dead_subs: list[str] = []
|
|
82
|
+
for sub_id, q in self._subscribers.items():
|
|
83
|
+
try:
|
|
84
|
+
q.put_nowait(event)
|
|
85
|
+
except asyncio.QueueFull:
|
|
86
|
+
dead_subs.append(sub_id) # 消费太慢,断开
|
|
87
|
+
for sub_id in dead_subs:
|
|
88
|
+
self._subscribers.pop(sub_id, None)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def subscriber_count(self) -> int:
|
|
92
|
+
return len(self._subscribers)
|
|
93
|
+
|
|
94
|
+
async def shutdown(self):
|
|
95
|
+
"""关闭所有订阅者"""
|
|
96
|
+
dead_subs = list(self._subscribers.keys())
|
|
97
|
+
for sub_id in dead_subs:
|
|
98
|
+
self._subscribers.pop(sub_id, None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# 全局事件总线实例
|
|
102
|
+
event_bus = EventBus()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ════════════════════════════════════════════════════════════
|
|
106
|
+
# 1b. 认证 — 自生成启动 Token(幂等)
|
|
107
|
+
# ════════════════════════════════════════════════════════════
|
|
108
|
+
|
|
109
|
+
_TOKEN_DIR: str = os.path.join(
|
|
110
|
+
os.environ.get("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")),
|
|
111
|
+
"fp",
|
|
112
|
+
)
|
|
113
|
+
_TOKENS_DIR: str = os.path.join(_TOKEN_DIR, "tokens")
|
|
114
|
+
os.makedirs(_TOKENS_DIR, exist_ok=True)
|
|
115
|
+
_TOKEN_FILE: str = os.path.join(_TOKENS_DIR, ".webui_token")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _load_or_create_token() -> str:
|
|
119
|
+
"""
|
|
120
|
+
读取已有 token 文件,或生成新 token 写入文件。
|
|
121
|
+
幂等设计:无论模块被 import 多少次,都返回同一 token。
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
if os.path.exists(_TOKEN_FILE):
|
|
125
|
+
with open(_TOKEN_FILE) as f:
|
|
126
|
+
stored = f.read().strip()
|
|
127
|
+
if stored and len(stored) >= 32:
|
|
128
|
+
return stored
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
# 文件不存在或内容无效 → 生成新 token
|
|
132
|
+
new_token = secrets.token_urlsafe(32)
|
|
133
|
+
try:
|
|
134
|
+
with open(_TOKEN_FILE, "w") as f:
|
|
135
|
+
f.write(new_token)
|
|
136
|
+
os.chmod(_TOKEN_FILE, 0o600)
|
|
137
|
+
except OSError:
|
|
138
|
+
pass
|
|
139
|
+
return new_token
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_WEBUI_TOKEN: str = _load_or_create_token()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ════════════════════════════════════════════════════════════
|
|
146
|
+
# 2. WebUIPlugin — 生命周期桥接
|
|
147
|
+
# ════════════════════════════════════════════════════════════
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class WebUIPlugin(Plugin):
|
|
151
|
+
"""
|
|
152
|
+
WebUI 桥接插件
|
|
153
|
+
|
|
154
|
+
监听 Agent 的关键生命周期钩子,将中间状态(思考、工具调用、错误等)
|
|
155
|
+
通过 EventBus 实时推送到前端。
|
|
156
|
+
|
|
157
|
+
不修改 Agent 核心代码,以插件形式运行时自动激活。
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
name = "webui_bridge"
|
|
161
|
+
version = "1.0.0"
|
|
162
|
+
|
|
163
|
+
def on_register(self, lifecycle):
|
|
164
|
+
"""注册所有需要监听的生命周期钩子"""
|
|
165
|
+
lifecycle.register(
|
|
166
|
+
LifecycleHook.ON_BEFORE_LLM_CALL,
|
|
167
|
+
self._on_before_llm,
|
|
168
|
+
priority=5,
|
|
169
|
+
name="webui_before_llm",
|
|
170
|
+
)
|
|
171
|
+
lifecycle.register(
|
|
172
|
+
LifecycleHook.ON_AFTER_LLM_CALL,
|
|
173
|
+
self._on_after_llm,
|
|
174
|
+
priority=5,
|
|
175
|
+
name="webui_after_llm",
|
|
176
|
+
)
|
|
177
|
+
lifecycle.register(
|
|
178
|
+
LifecycleHook.ON_TOOL_SELECT,
|
|
179
|
+
self._on_tool_select,
|
|
180
|
+
priority=5,
|
|
181
|
+
name="webui_tool_select",
|
|
182
|
+
)
|
|
183
|
+
lifecycle.register(
|
|
184
|
+
LifecycleHook.ON_TOOL_CALL,
|
|
185
|
+
self._on_tool_call,
|
|
186
|
+
priority=5,
|
|
187
|
+
name="webui_tool_call",
|
|
188
|
+
)
|
|
189
|
+
lifecycle.register(
|
|
190
|
+
LifecycleHook.ON_TOOL_RESULT,
|
|
191
|
+
self._on_tool_result,
|
|
192
|
+
priority=5,
|
|
193
|
+
name="webui_tool_result",
|
|
194
|
+
)
|
|
195
|
+
lifecycle.register(
|
|
196
|
+
LifecycleHook.ON_ERROR,
|
|
197
|
+
self._on_error,
|
|
198
|
+
priority=5,
|
|
199
|
+
name="webui_error",
|
|
200
|
+
)
|
|
201
|
+
lifecycle.register(
|
|
202
|
+
LifecycleHook.ON_BEFORE_RESPONSE,
|
|
203
|
+
self._on_response,
|
|
204
|
+
priority=5,
|
|
205
|
+
name="webui_response",
|
|
206
|
+
)
|
|
207
|
+
lifecycle.register(
|
|
208
|
+
LifecycleHook.ON_SHUTDOWN,
|
|
209
|
+
self._on_shutdown,
|
|
210
|
+
priority=5,
|
|
211
|
+
name="webui_shutdown",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
async def _emit(self, event_type: str, **data):
|
|
215
|
+
"""向 EventBus 发布事件"""
|
|
216
|
+
await event_bus.publish({"type": event_type, "ts": time.time(), **data})
|
|
217
|
+
|
|
218
|
+
async def _on_before_llm(self, ctx: HookContext, **kwargs):
|
|
219
|
+
"""LLM 调用开始 → 前端显示"思考中"状态"""
|
|
220
|
+
await self._emit("llm_start")
|
|
221
|
+
|
|
222
|
+
async def _on_after_llm(self, ctx: HookContext, **kwargs):
|
|
223
|
+
"""LLM 调用完成 → 前端显示回复内容"""
|
|
224
|
+
await self._emit(
|
|
225
|
+
"llm_end",
|
|
226
|
+
content=kwargs.get("content", ""),
|
|
227
|
+
has_tool_calls=kwargs.get("has_tool_calls", False),
|
|
228
|
+
tool_names=kwargs.get("tool_names", []),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
async def _on_tool_select(self, ctx: HookContext, **kwargs):
|
|
232
|
+
"""工具选择 → 前端显示即将调用的工具列表"""
|
|
233
|
+
tools = kwargs.get("tools", [])
|
|
234
|
+
await self._emit("tool_select", tools=tools)
|
|
235
|
+
|
|
236
|
+
async def _on_tool_call(self, ctx: HookContext, **kwargs):
|
|
237
|
+
"""工具调用开始 → 前端显示工具名称和参数"""
|
|
238
|
+
await self._emit(
|
|
239
|
+
"tool_call",
|
|
240
|
+
name=kwargs.get("tool_name", ""),
|
|
241
|
+
args=kwargs.get("tool_args", ""),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def _on_tool_result(self, ctx: HookContext, **kwargs):
|
|
245
|
+
"""工具调用完成 → 前端显示结果摘要"""
|
|
246
|
+
result = kwargs.get("result", "")
|
|
247
|
+
await self._emit(
|
|
248
|
+
"tool_result",
|
|
249
|
+
name=kwargs.get("tool_name", ""),
|
|
250
|
+
result=(result[:200] + "...") if len(result) > 200 else result,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
async def _on_error(self, ctx: HookContext, **kwargs):
|
|
254
|
+
"""错误发生 → 前端显示错误信息"""
|
|
255
|
+
await self._emit("error", error=str(kwargs.get("error", "")))
|
|
256
|
+
|
|
257
|
+
async def _on_response(self, ctx: HookContext, **kwargs):
|
|
258
|
+
"""最终回复生成 → 前端显示完整回复"""
|
|
259
|
+
await self._emit(
|
|
260
|
+
"response",
|
|
261
|
+
content=kwargs.get("content", ""),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def _on_shutdown(self, ctx: HookContext, **kwargs):
|
|
265
|
+
"""Agent 关闭 → 前端显示关闭通知"""
|
|
266
|
+
await self._emit("shutdown")
|
|
267
|
+
|
|
268
|
+
def on_unregister(self):
|
|
269
|
+
"""卸载插件时清理资源"""
|
|
270
|
+
# WebUIPlugin 是桥接插件,随 Agent 生命周期自动管理,
|
|
271
|
+
# EventBus 由 WebUI 服务器全局管理,此处无需额外清理
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ════════════════════════════════════════════════════════════
|
|
276
|
+
# 3. FastAPI 应用
|
|
277
|
+
# ════════════════════════════════════════════════════════════
|
|
278
|
+
|
|
279
|
+
# ── 全局 Agent 实例 ──────────────────────────────────────
|
|
280
|
+
_agent: Agent | None = None
|
|
281
|
+
_agent_lock = asyncio.Lock()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def get_agent() -> Agent:
|
|
285
|
+
"""获取或创建全局 Agent 实例(延迟初始化)"""
|
|
286
|
+
global _agent
|
|
287
|
+
if _agent is None:
|
|
288
|
+
async with _agent_lock:
|
|
289
|
+
if _agent is None:
|
|
290
|
+
_agent = Agent(enable_log=False)
|
|
291
|
+
# 注册 WebUI 桥接插件
|
|
292
|
+
webui_plugin = WebUIPlugin()
|
|
293
|
+
_agent.plugins.register(webui_plugin)
|
|
294
|
+
await _agent.ensure_initialized()
|
|
295
|
+
display.info(f"[WebUI] Agent 已初始化 (model={_agent.model})")
|
|
296
|
+
return _agent
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ── 生命周期管理 ─────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@asynccontextmanager
|
|
303
|
+
async def lifespan(app: FastAPI):
|
|
304
|
+
"""FastAPI 生命周期:启动时初始化 Agent,关闭时清理"""
|
|
305
|
+
display.info("[WebUI] 🚀 Five Pebbles WebUI 启动中...")
|
|
306
|
+
|
|
307
|
+
# Agent 延迟初始化,第一次请求时创建
|
|
308
|
+
yield
|
|
309
|
+
|
|
310
|
+
# 关闭
|
|
311
|
+
display.info("[WebUI] 🛑 正在关闭...")
|
|
312
|
+
global _agent
|
|
313
|
+
if _agent is not None:
|
|
314
|
+
await _agent.shutdown()
|
|
315
|
+
await event_bus.shutdown()
|
|
316
|
+
display.info("[WebUI] ✅ 已关闭")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ── FastAPI 实例 ─────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
app = FastAPI(
|
|
322
|
+
title="Five Pebbles WebUI",
|
|
323
|
+
description="五块卵石 AI Agent 的 Web 界面",
|
|
324
|
+
version="1.0.0",
|
|
325
|
+
lifespan=lifespan,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ── 认证中间件 ──────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
_AUTH_WHITELIST = {"/api/auth", "/api/health"}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.middleware("http")
|
|
335
|
+
async def auth_middleware(request: Request, call_next):
|
|
336
|
+
"""拦截 /api/* 请求,验证 Bearer Token(白名单除外)"""
|
|
337
|
+
path = request.url.path
|
|
338
|
+
if path.startswith("/api/") and path not in _AUTH_WHITELIST:
|
|
339
|
+
auth = request.headers.get("authorization", "")
|
|
340
|
+
expected = f"Bearer {_WEBUI_TOKEN}"
|
|
341
|
+
if not auth or auth != expected:
|
|
342
|
+
return JSONResponse(status_code=401, content={"detail": "未授权,请先登录"})
|
|
343
|
+
return await call_next(request)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ════════════════════════════════════════════════════════════
|
|
347
|
+
# 4. REST API 端点
|
|
348
|
+
# ════════════════════════════════════════════════════════════
|
|
349
|
+
|
|
350
|
+
# ── 简单限流:每 IP 每 10 秒最多 5 次尝试 ──────────
|
|
351
|
+
_AUTH_LIMIT_WINDOW = 10 # 窗口秒数
|
|
352
|
+
_AUTH_LIMIT_MAX = 5 # 窗口内最大尝试次数
|
|
353
|
+
_auth_attempts: dict[str, list[float]] = {} # ip → [时间戳列表]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _check_auth_rate_limit(client_ip: str) -> None:
|
|
357
|
+
"""检查客户端认证频率,超限则抛 429"""
|
|
358
|
+
now = time.time()
|
|
359
|
+
window_start = now - _AUTH_LIMIT_WINDOW
|
|
360
|
+
records = _auth_attempts.get(client_ip, [])
|
|
361
|
+
# 清理过期记录
|
|
362
|
+
records = [t for t in records if t > window_start]
|
|
363
|
+
if len(records) >= _AUTH_LIMIT_MAX:
|
|
364
|
+
raise HTTPException(
|
|
365
|
+
status_code=429,
|
|
366
|
+
detail="认证尝试过于频繁,请稍后再试",
|
|
367
|
+
)
|
|
368
|
+
records.append(now)
|
|
369
|
+
_auth_attempts[client_ip] = records
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@app.post("/api/auth")
|
|
373
|
+
async def auth_login(request: Request, body: dict):
|
|
374
|
+
"""验证 Token 并登录(每 IP 限频)"""
|
|
375
|
+
client_ip = request.client.host if request.client else request.headers.get("x-forwarded-for", "unknown")
|
|
376
|
+
_check_auth_rate_limit(client_ip)
|
|
377
|
+
token = body.get("token", "").strip()
|
|
378
|
+
if secrets.compare_digest(token, _WEBUI_TOKEN):
|
|
379
|
+
return {"status": "ok", "message": "验证通过"}
|
|
380
|
+
raise HTTPException(status_code=401, detail="Token 无效")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@app.get("/api/health")
|
|
384
|
+
async def health_check():
|
|
385
|
+
"""健康检查端点"""
|
|
386
|
+
agent = await get_agent()
|
|
387
|
+
return {
|
|
388
|
+
"status": "ok",
|
|
389
|
+
"agent": agent.model,
|
|
390
|
+
"session": agent.session.session_id,
|
|
391
|
+
"subscribers": event_bus.subscriber_count,
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@app.post("/api/chat")
|
|
396
|
+
async def send_message(body: dict):
|
|
397
|
+
"""
|
|
398
|
+
发送消息并获取回复(非流式)
|
|
399
|
+
|
|
400
|
+
请求体:
|
|
401
|
+
{"message": "你好"}
|
|
402
|
+
|
|
403
|
+
返回:
|
|
404
|
+
{"response": "...", "session_id": "..."}
|
|
405
|
+
"""
|
|
406
|
+
message = body.get("message", "").strip()
|
|
407
|
+
if not message:
|
|
408
|
+
raise HTTPException(status_code=400, detail="消息不能为空")
|
|
409
|
+
|
|
410
|
+
agent = await get_agent()
|
|
411
|
+
|
|
412
|
+
# 发送消息时启动一个独立的后台任务来处理
|
|
413
|
+
# 前端通过 WebSocket 接收实时更新
|
|
414
|
+
# 使用 RestIO 避免交互式命令(如 /back 无参数)阻塞
|
|
415
|
+
response = await agent.process(message, io=RestIO())
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
"response": response.content,
|
|
419
|
+
"session_id": agent.session.session_id,
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@app.get("/api/sessions")
|
|
424
|
+
async def list_sessions():
|
|
425
|
+
"""列出所有历史会话"""
|
|
426
|
+
agent = await get_agent()
|
|
427
|
+
sessions = agent.session.list_sessions()
|
|
428
|
+
|
|
429
|
+
result = []
|
|
430
|
+
for sid, info in sorted(sessions.items(), key=lambda x: x[1].get("created", ""), reverse=True):
|
|
431
|
+
result.append({
|
|
432
|
+
"id": sid,
|
|
433
|
+
"message_count": info.get("message_count", 0),
|
|
434
|
+
"created": info.get("created", ""),
|
|
435
|
+
"summary": info.get("summary", ""),
|
|
436
|
+
"is_current": sid == agent.session.session_id,
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
return {"sessions": result}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ════════════════════════════════════════════════════════════
|
|
443
|
+
# 4a. 新建 Agent(shutdown 旧实例,创建全新实例)
|
|
444
|
+
# ════════════════════════════════════════════════════════════
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@app.post("/api/agent/new")
|
|
448
|
+
async def new_agent():
|
|
449
|
+
"""
|
|
450
|
+
创建全新 Agent 实例。
|
|
451
|
+
|
|
452
|
+
流程:
|
|
453
|
+
1. shutdown 旧 Agent(保存当前会话后优雅退出)
|
|
454
|
+
2. 创建新 Agent 实例
|
|
455
|
+
3. 注册 WebUIPlugin 桥接插件
|
|
456
|
+
4. 创建全新的会话(清空上下文)
|
|
457
|
+
5. 通过 EventBus 通知前端刷新
|
|
458
|
+
|
|
459
|
+
这是真正的"重置"——所有内存状态被清空,所有模块被重新加载,
|
|
460
|
+
相当于 Agent 刚启动时的状态。
|
|
461
|
+
"""
|
|
462
|
+
global _agent
|
|
463
|
+
|
|
464
|
+
# ── 检查是否正在处理 ──
|
|
465
|
+
if _agent is not None and _agent.is_processing:
|
|
466
|
+
raise HTTPException(status_code=409, detail="Agent 正在处理请求,请稍后重试")
|
|
467
|
+
|
|
468
|
+
async with _agent_lock:
|
|
469
|
+
# ── 保存旧会话并 shutdown 旧 Agent ──
|
|
470
|
+
if _agent is not None:
|
|
471
|
+
try:
|
|
472
|
+
_agent.save_context()
|
|
473
|
+
await _agent.shutdown()
|
|
474
|
+
except Exception as e:
|
|
475
|
+
display.warning(f"[WebUI] ⚠️ 旧 Agent shutdown 时发生异常: {e}")
|
|
476
|
+
_agent = None
|
|
477
|
+
|
|
478
|
+
# ── 通知前端准备重连 ──
|
|
479
|
+
await event_bus.publish({
|
|
480
|
+
"type": "reload",
|
|
481
|
+
"message": "🔄 新建 Agent 中,连接即将断开",
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
# ── 重新导入 Agent 类(确保获取最新代码) ──
|
|
485
|
+
from fp_core.core.agent import Agent as NewAgent
|
|
486
|
+
|
|
487
|
+
# ── 创建新 Agent ──
|
|
488
|
+
try:
|
|
489
|
+
_agent = NewAgent(enable_log=False)
|
|
490
|
+
webui_plugin = WebUIPlugin()
|
|
491
|
+
_agent.plugins.register(webui_plugin)
|
|
492
|
+
await _agent.ensure_initialized()
|
|
493
|
+
except Exception as e:
|
|
494
|
+
display.error(f"[WebUI] ❌ 新 Agent 创建失败: {e}")
|
|
495
|
+
_agent = None
|
|
496
|
+
raise HTTPException(status_code=500, detail=f"新 Agent 创建失败: {e}") from e
|
|
497
|
+
|
|
498
|
+
# ── 使用 Agent 构造函数已创建的新会话 ──
|
|
499
|
+
# NewAgent() 的 SessionManager(resume=None) 中已调用 _init_session()
|
|
500
|
+
# 生成了全新会话,此处只需重建 context 即可
|
|
501
|
+
try:
|
|
502
|
+
_agent.rebuild_context()
|
|
503
|
+
new_sid = _agent.session.session_id
|
|
504
|
+
display.info(f"[WebUI] 🆕 已使用新会话: {new_sid}")
|
|
505
|
+
except Exception as e:
|
|
506
|
+
display.error(f"[WebUI] ❌ 新会话初始化失败: {e}")
|
|
507
|
+
raise HTTPException(status_code=500, detail=f"新会话初始化失败: {e}") from e
|
|
508
|
+
|
|
509
|
+
display.info(f"[WebUI] 🆕 Agent 新建完成 (model={_agent.model}, session={_agent.session.session_id})")
|
|
510
|
+
|
|
511
|
+
# ── 稍等片刻,让前端收到 reload 事件后再推送 done ──
|
|
512
|
+
await asyncio.sleep(0.3)
|
|
513
|
+
await event_bus.publish({
|
|
514
|
+
"type": "reload_done",
|
|
515
|
+
"session_id": _agent.session.session_id,
|
|
516
|
+
"model": _agent.model,
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
"status": "ok",
|
|
521
|
+
"session_id": _agent.session.session_id,
|
|
522
|
+
"model": _agent.model,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@app.post("/api/sessions")
|
|
527
|
+
async def create_new_session():
|
|
528
|
+
"""创建新会话并切换到它"""
|
|
529
|
+
agent = await get_agent()
|
|
530
|
+
|
|
531
|
+
# 记录旧会话,用于后台生成摘要
|
|
532
|
+
old_sid = agent.session.session_id
|
|
533
|
+
old_context = agent.get_messages() # 浅拷贝
|
|
534
|
+
|
|
535
|
+
# 保存当前会话上下文
|
|
536
|
+
agent.save_context()
|
|
537
|
+
|
|
538
|
+
# 创建新会话(自动切换到新会话)
|
|
539
|
+
new_sid = agent.session.create_session()
|
|
540
|
+
|
|
541
|
+
# 重建 agent 上下文(加载 system prompt 到新会话)
|
|
542
|
+
agent.rebuild_context()
|
|
543
|
+
|
|
544
|
+
# 同步生成旧会话摘要(不传 tools,确保 LLM 返回纯文本标题)
|
|
545
|
+
history_msgs = [m for m in old_context if m["role"] != "system"]
|
|
546
|
+
if len(history_msgs) >= 2:
|
|
547
|
+
try:
|
|
548
|
+
summary_msgs = old_context + [
|
|
549
|
+
{"role": "user", "content": "请总结一下,给这次对话起一个5到10个汉字的名字。不要添加任何多余的文字。"}
|
|
550
|
+
]
|
|
551
|
+
response = await agent.client.chat.completions.create(
|
|
552
|
+
model=agent.model,
|
|
553
|
+
messages=summary_msgs,
|
|
554
|
+
temperature=0.3,
|
|
555
|
+
max_tokens=32,
|
|
556
|
+
extra_body={"enable_thinking": False},
|
|
557
|
+
)
|
|
558
|
+
summary = response.choices[0].message.content or ""
|
|
559
|
+
summary = summary.strip().strip('"').strip("'").strip("「」『』")
|
|
560
|
+
if not summary or len(summary) > 50:
|
|
561
|
+
# 回退:取首条用户消息
|
|
562
|
+
for m in history_msgs:
|
|
563
|
+
if m["role"] == "user":
|
|
564
|
+
text = m.get("content", "").strip()
|
|
565
|
+
if text:
|
|
566
|
+
summary = text.split("\n")[0].strip()[:50]
|
|
567
|
+
break
|
|
568
|
+
if not summary:
|
|
569
|
+
summary = "empty_session"
|
|
570
|
+
agent.session.update_meta(old_sid, summary=summary)
|
|
571
|
+
except Exception:
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
return {"session_id": new_sid, "status": "created"}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@app.delete("/api/sessions/{session_id}")
|
|
578
|
+
async def delete_session_endpoint(session_id: str):
|
|
579
|
+
"""删除指定会话(不能是当前会话)"""
|
|
580
|
+
agent = await get_agent()
|
|
581
|
+
|
|
582
|
+
# 检查是不是当前会话
|
|
583
|
+
if session_id == agent.session.session_id:
|
|
584
|
+
raise HTTPException(status_code=400, detail="不能删除当前正在使用的会话")
|
|
585
|
+
|
|
586
|
+
if not agent.delete_session(session_id):
|
|
587
|
+
raise HTTPException(status_code=404, detail=f"会话 {session_id} 不存在或删除失败")
|
|
588
|
+
|
|
589
|
+
return {"status": "deleted", "session_id": session_id}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@app.get("/api/sessions/{session_id}/messages")
|
|
593
|
+
async def get_session_messages(session_id: str):
|
|
594
|
+
"""
|
|
595
|
+
获取指定会话的完整消息列表(直接读文件,不修改 Agent 状态)。
|
|
596
|
+
|
|
597
|
+
⚠️ index = 非 system 消息的 1-based 索引(与 /back 命令的索引体系一致)。
|
|
598
|
+
跳过 role=system 的消息(如 compact 产生的摘要),因为 /back 命令
|
|
599
|
+
使用的是 get_non_system_messages(),两类 system 消息不计入:
|
|
600
|
+
1. 原始 system prompt
|
|
601
|
+
2. compact 产生的摘要 system 消息
|
|
602
|
+
3. repair_tool_ordering 转化的孤儿 tool 消息
|
|
603
|
+
"""
|
|
604
|
+
from fp_core.core.session import _session_path
|
|
605
|
+
|
|
606
|
+
path = _session_path(session_id)
|
|
607
|
+
if not os.path.exists(path):
|
|
608
|
+
raise HTTPException(status_code=404, detail=f"会话 {session_id} 不存在")
|
|
609
|
+
|
|
610
|
+
messages = []
|
|
611
|
+
with open(path, encoding="utf-8") as f:
|
|
612
|
+
for line in f:
|
|
613
|
+
line = line.strip()
|
|
614
|
+
if not line:
|
|
615
|
+
continue
|
|
616
|
+
try:
|
|
617
|
+
msg = json.loads(line)
|
|
618
|
+
except json.JSONDecodeError:
|
|
619
|
+
continue
|
|
620
|
+
if msg.get("__meta__"):
|
|
621
|
+
continue
|
|
622
|
+
messages.append(msg)
|
|
623
|
+
|
|
624
|
+
# ── 只对非 system 消息编号(与 ConversationState.back() 的索引规则一致) ──
|
|
625
|
+
# ConversationState.get_non_system_messages() 只返回 role != "system" 的消息,
|
|
626
|
+
# 所以 compact 后产生的 system(摘要) 消息不计入索引。
|
|
627
|
+
# 如果按文件全部消息编号,compact/resume 后前端 data-index 与后端索引会错位。
|
|
628
|
+
result = []
|
|
629
|
+
non_system_idx = 0 # 只对非 system 消息的 1-based 索引
|
|
630
|
+
for msg in messages:
|
|
631
|
+
role = msg.get("role", "")
|
|
632
|
+
entry = {
|
|
633
|
+
"index": None, # system 消息 index 为 None
|
|
634
|
+
"role": role,
|
|
635
|
+
"content": msg.get("content", ""),
|
|
636
|
+
"tool_calls": msg.get("tool_calls"),
|
|
637
|
+
"tool_call_id": msg.get("tool_call_id"),
|
|
638
|
+
}
|
|
639
|
+
if role != "system":
|
|
640
|
+
non_system_idx += 1
|
|
641
|
+
entry["index"] = non_system_idx
|
|
642
|
+
result.append(entry)
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
"session_id": session_id,
|
|
646
|
+
"total": len(result),
|
|
647
|
+
"non_system_count": non_system_idx,
|
|
648
|
+
"messages": result,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ════════════════════════════════════════════════════════════
|
|
653
|
+
# 4a. 文本搜索接口 — 通过内容片段定位消息
|
|
654
|
+
# ════════════════════════════════════════════════════════════
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@app.post("/api/sessions/{session_id}/search")
|
|
658
|
+
async def search_session_messages(session_id: str, body: dict):
|
|
659
|
+
"""
|
|
660
|
+
通过文本内容片段搜索消息,返回消息的真实文件行号和非 system 索引。
|
|
661
|
+
|
|
662
|
+
请求体:
|
|
663
|
+
{"query": "搜索关键词"} ← 简单文本片段匹配
|
|
664
|
+
{"query": "...", "regex": true} ← 正则表达式匹配
|
|
665
|
+
{"query": "...", "limit": 10} ← 最多返回条数(默认 20)
|
|
666
|
+
|
|
667
|
+
返回:
|
|
668
|
+
{
|
|
669
|
+
"session_id": "...",
|
|
670
|
+
"total_matches": 3,
|
|
671
|
+
"results": [
|
|
672
|
+
{
|
|
673
|
+
"index": 5, # 非 system 索引(与 /back 一致)
|
|
674
|
+
"line_number": 7, # 文件行号(从 1 开始,含 meta 行)
|
|
675
|
+
"role": "assistant",
|
|
676
|
+
"content_preview": "前 200 字符...",
|
|
677
|
+
"tool_calls": [...],
|
|
678
|
+
"tool_call_id": "..."
|
|
679
|
+
}
|
|
680
|
+
]
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
说明:
|
|
684
|
+
- index=null 表示 system 消息,不可用 /back 回溯
|
|
685
|
+
- line_number 可用于文件定位
|
|
686
|
+
- 匹配方式:简单子串匹配(默认)或正则表达式
|
|
687
|
+
"""
|
|
688
|
+
from fp_core.core.session import _session_path
|
|
689
|
+
|
|
690
|
+
path = _session_path(session_id)
|
|
691
|
+
if not os.path.exists(path):
|
|
692
|
+
raise HTTPException(status_code=404, detail=f"会话 {session_id} 不存在")
|
|
693
|
+
|
|
694
|
+
query = body.get("query", "").strip()
|
|
695
|
+
if not query:
|
|
696
|
+
raise HTTPException(status_code=400, detail="查询内容不能为空")
|
|
697
|
+
|
|
698
|
+
import re
|
|
699
|
+
|
|
700
|
+
use_regex = body.get("regex", False)
|
|
701
|
+
limit = min(body.get("limit", 20), 100)
|
|
702
|
+
|
|
703
|
+
# ── 编译匹配模式 ──
|
|
704
|
+
pattern: re.Pattern | None = None
|
|
705
|
+
query_lower: str | None = None
|
|
706
|
+
if use_regex:
|
|
707
|
+
try:
|
|
708
|
+
pattern = re.compile(query)
|
|
709
|
+
except re.error as e:
|
|
710
|
+
raise HTTPException(status_code=400, detail=f"正则表达式无效: {e}") from e
|
|
711
|
+
else:
|
|
712
|
+
query_lower = query.lower()
|
|
713
|
+
|
|
714
|
+
# ── 逐行扫描文件 ──
|
|
715
|
+
results = []
|
|
716
|
+
try:
|
|
717
|
+
with open(path, encoding="utf-8") as f:
|
|
718
|
+
lines = f.readlines()
|
|
719
|
+
except Exception as e:
|
|
720
|
+
raise HTTPException(status_code=500, detail=f"读取会话文件失败: {e}") from e
|
|
721
|
+
|
|
722
|
+
non_system_idx = 0
|
|
723
|
+
for line_no, line in enumerate(lines, 1):
|
|
724
|
+
line = line.strip()
|
|
725
|
+
if not line:
|
|
726
|
+
continue
|
|
727
|
+
try:
|
|
728
|
+
msg = json.loads(line)
|
|
729
|
+
except json.JSONDecodeError:
|
|
730
|
+
continue
|
|
731
|
+
if msg.get("__meta__"):
|
|
732
|
+
continue
|
|
733
|
+
|
|
734
|
+
role = msg.get("role", "")
|
|
735
|
+
content = msg.get("content", "")
|
|
736
|
+
|
|
737
|
+
# 计算非 system 索引(与 /back 一致)
|
|
738
|
+
is_system = role == "system"
|
|
739
|
+
if not is_system:
|
|
740
|
+
non_system_idx += 1
|
|
741
|
+
|
|
742
|
+
# ── 匹配检测 ──
|
|
743
|
+
matched = (use_regex and pattern is not None and pattern.search(content)) or (
|
|
744
|
+
not use_regex and query_lower is not None and query_lower in content.lower()
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
if matched:
|
|
748
|
+
preview = content[:200]
|
|
749
|
+
if len(content) > 200:
|
|
750
|
+
preview += "..."
|
|
751
|
+
|
|
752
|
+
results.append({
|
|
753
|
+
"index": non_system_idx if not is_system else None,
|
|
754
|
+
"line_number": line_no,
|
|
755
|
+
"role": role,
|
|
756
|
+
"content_preview": preview,
|
|
757
|
+
"content_length": len(content),
|
|
758
|
+
"tool_calls": msg.get("tool_calls"),
|
|
759
|
+
"tool_call_id": msg.get("tool_call_id"),
|
|
760
|
+
"file_line": line_no,
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
if len(results) >= limit:
|
|
764
|
+
break
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
"session_id": session_id,
|
|
768
|
+
"query": query,
|
|
769
|
+
"total_matches": len(results),
|
|
770
|
+
"results": results,
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
@app.post("/api/sessions/{session_id}/switch")
|
|
775
|
+
async def switch_session_endpoint(session_id: str):
|
|
776
|
+
"""切换到指定会话"""
|
|
777
|
+
agent = await get_agent()
|
|
778
|
+
|
|
779
|
+
# 记录旧会话,用于后台生成摘要
|
|
780
|
+
old_sid = agent.session.session_id
|
|
781
|
+
old_context = agent.get_messages() # 浅拷贝
|
|
782
|
+
|
|
783
|
+
# 保存当前会话
|
|
784
|
+
agent.save_context()
|
|
785
|
+
|
|
786
|
+
if not agent.switch_session(session_id):
|
|
787
|
+
raise HTTPException(status_code=404, detail=f"会话 {session_id} 不存在")
|
|
788
|
+
|
|
789
|
+
# 同步生成旧会话摘要(不传 tools)
|
|
790
|
+
history_msgs = [m for m in old_context if m["role"] != "system"]
|
|
791
|
+
if len(history_msgs) >= 2:
|
|
792
|
+
try:
|
|
793
|
+
summary_msgs = old_context + [
|
|
794
|
+
{"role": "user", "content": "请总结一下,给这次对话起一个5到10个汉字的名字。不要添加任何多余的文字。"}
|
|
795
|
+
]
|
|
796
|
+
response = await agent.client.chat.completions.create(
|
|
797
|
+
model=agent.model,
|
|
798
|
+
messages=summary_msgs,
|
|
799
|
+
temperature=0.3,
|
|
800
|
+
max_tokens=32,
|
|
801
|
+
extra_body={"enable_thinking": False},
|
|
802
|
+
)
|
|
803
|
+
summary = response.choices[0].message.content or ""
|
|
804
|
+
summary = summary.strip().strip('"').strip("'").strip("「」『』")
|
|
805
|
+
if not summary or len(summary) > 50:
|
|
806
|
+
for m in history_msgs:
|
|
807
|
+
if m["role"] == "user":
|
|
808
|
+
text = m.get("content", "").strip()
|
|
809
|
+
if text:
|
|
810
|
+
summary = text.split("\n")[0].strip()[:50]
|
|
811
|
+
break
|
|
812
|
+
if not summary:
|
|
813
|
+
summary = "empty_session"
|
|
814
|
+
agent.session.update_meta(old_sid, summary=summary)
|
|
815
|
+
except Exception:
|
|
816
|
+
pass
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
"session_id": session_id,
|
|
820
|
+
"status": "switched",
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
@app.post("/api/sessions/clear")
|
|
825
|
+
async def clear_current_session():
|
|
826
|
+
"""清空当前会话"""
|
|
827
|
+
agent = await get_agent()
|
|
828
|
+
agent.clear_session()
|
|
829
|
+
return {"status": "cleared"}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# ════════════════════════════════════════════════════════════
|
|
833
|
+
# 4b. 热重载 Agent
|
|
834
|
+
# ════════════════════════════════════════════════════════════
|
|
835
|
+
|
|
836
|
+
# ── 需要热重载的模块列表(按依赖顺序,子模块由父模块自动重新导入)─
|
|
837
|
+
_RELOAD_MODULES = [
|
|
838
|
+
# 第 1 层:无项目内部依赖
|
|
839
|
+
"fp_core.config",
|
|
840
|
+
"fp_core.display",
|
|
841
|
+
# 第 2 层:依赖 config
|
|
842
|
+
"fp_core.core.io",
|
|
843
|
+
"fp_core.core.lifecycle",
|
|
844
|
+
"fp_core.core.session",
|
|
845
|
+
"fp_core.core.llm_client",
|
|
846
|
+
# 第 3 层:依赖 core.*
|
|
847
|
+
"fp_core.plugins.base.plugin",
|
|
848
|
+
"fp_core.prompts.agent",
|
|
849
|
+
"fp_core.skills.loader",
|
|
850
|
+
# 第 4 层:工具和命令(含全局注册表状态)
|
|
851
|
+
"fp_core.commands", # _discover_commands() 重新扫描
|
|
852
|
+
"fp_core.tools", # ToolRegistry 全局实例重建
|
|
853
|
+
# 第 5 层:Agent 主干(依赖以上所有)
|
|
854
|
+
"fp_core.core.agent",
|
|
855
|
+
]
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _reload_modules():
|
|
859
|
+
"""按顺序 reload 所有核心模块,返回是否成功。"""
|
|
860
|
+
import importlib
|
|
861
|
+
|
|
862
|
+
# ── 先 reload tools 和 commands 的子模块 ──
|
|
863
|
+
# tools/commands 的父模块 reload 时会重新扫描子模块,
|
|
864
|
+
# 但子模块本身的代码可能被用户修改,所以需要先 reload 子模块
|
|
865
|
+
for prefix in ("fp_core.tools.", "fp_core.commands.", "fp_core.core."):
|
|
866
|
+
for mod_name in list(sys.modules.keys()):
|
|
867
|
+
if (
|
|
868
|
+
mod_name.startswith(prefix) and mod_name in sys.modules and mod_name not in _RELOAD_MODULES
|
|
869
|
+
): # 父模块由主列表处理
|
|
870
|
+
importlib.reload(sys.modules[mod_name])
|
|
871
|
+
|
|
872
|
+
# ── 按依赖顺序 reload 主模块 ──
|
|
873
|
+
for mod_name in _RELOAD_MODULES:
|
|
874
|
+
if mod_name in sys.modules:
|
|
875
|
+
try:
|
|
876
|
+
importlib.reload(sys.modules[mod_name])
|
|
877
|
+
except Exception as e:
|
|
878
|
+
raise RuntimeError(f"重载模块 {mod_name} 失败: {e}") from e
|
|
879
|
+
|
|
880
|
+
importlib.invalidate_caches()
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@app.post("/api/reload")
|
|
884
|
+
async def reload_agent():
|
|
885
|
+
"""
|
|
886
|
+
热重载 Agent:在不重启服务器的前提下,刷新所有核心代码并创建新 Agent。
|
|
887
|
+
|
|
888
|
+
流程:
|
|
889
|
+
1. 保存当前会话上下文到文件
|
|
890
|
+
2. 关闭旧 Agent(释放 LLM 客户端连接)
|
|
891
|
+
3. importlib.reload 所有关键模块(按依赖顺序)
|
|
892
|
+
4. 创建新 Agent 实例
|
|
893
|
+
5. 注册 WebUIPlugin 桥接插件
|
|
894
|
+
6. 恢复原会话上下文
|
|
895
|
+
7. 替换全局 _agent 引用
|
|
896
|
+
8. 通过 EventBus 通知各 WebSocket 客户端重连
|
|
897
|
+
|
|
898
|
+
安全保证:
|
|
899
|
+
- 如果 Agent 正在处理请求,返回 409 拒绝重载
|
|
900
|
+
- 重载期间 _agent 被设为 None,get_agent() 会等待锁
|
|
901
|
+
- 如果重载过程中任何模块 reload 失败,_agent 保持为 None,get_agent() 自动创建新实例
|
|
902
|
+
- 活跃的 WebSocket 连接保有旧的 agent 对象引用,仍可继续工作
|
|
903
|
+
"""
|
|
904
|
+
global _agent
|
|
905
|
+
|
|
906
|
+
# ── 检查是否正在处理 ──
|
|
907
|
+
if _agent is not None and _agent.is_processing:
|
|
908
|
+
raise HTTPException(status_code=409, detail="Agent 正在处理请求,请稍后重试")
|
|
909
|
+
|
|
910
|
+
async with _agent_lock:
|
|
911
|
+
# ── 保存旧会话并关闭旧 Agent ──
|
|
912
|
+
old_sid: str | None = None
|
|
913
|
+
if _agent is not None:
|
|
914
|
+
_agent.save_context()
|
|
915
|
+
old_sid = _agent.session.session_id
|
|
916
|
+
# 静默容错
|
|
917
|
+
with suppress(Exception):
|
|
918
|
+
await _agent.shutdown()
|
|
919
|
+
_agent = None
|
|
920
|
+
|
|
921
|
+
# ── 通知前端准备重连 ──
|
|
922
|
+
await event_bus.publish({
|
|
923
|
+
"type": "reload",
|
|
924
|
+
"message": "🔄 Agent 正在重载,连接即将断开",
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
# ── 热重载所有模块 ──
|
|
928
|
+
try:
|
|
929
|
+
_reload_modules()
|
|
930
|
+
except RuntimeError as e:
|
|
931
|
+
display.error(f"[WebUI] ❌ 模块重载失败: {e}")
|
|
932
|
+
# _agent 保持 None,后续请求会通过 get_agent() 自动创建
|
|
933
|
+
raise HTTPException(status_code=500, detail=f"模块重载失败: {e}") from e
|
|
934
|
+
|
|
935
|
+
# ── 重新导入 Agent 类 ──
|
|
936
|
+
# 注意:main.py 顶部 from fp_core.core.agent import Agent 是旧引用,
|
|
937
|
+
# 必须重新 import 才能获得重载后的类
|
|
938
|
+
from fp_core.core.agent import Agent as NewAgent
|
|
939
|
+
|
|
940
|
+
# ── 创建新 Agent ──
|
|
941
|
+
try:
|
|
942
|
+
_agent = NewAgent(enable_log=False)
|
|
943
|
+
webui_plugin = WebUIPlugin()
|
|
944
|
+
_agent.plugins.register(webui_plugin)
|
|
945
|
+
await _agent.ensure_initialized()
|
|
946
|
+
except Exception as e:
|
|
947
|
+
display.error(f"[WebUI] ❌ 新 Agent 创建失败: {e}")
|
|
948
|
+
_agent = None
|
|
949
|
+
raise HTTPException(status_code=500, detail=f"新 Agent 创建失败: {e}") from e
|
|
950
|
+
|
|
951
|
+
# ── 恢复旧会话 ──
|
|
952
|
+
if old_sid:
|
|
953
|
+
try:
|
|
954
|
+
_agent.session.switch_session(old_sid)
|
|
955
|
+
_agent.rebuild_context()
|
|
956
|
+
display.info(f"[WebUI] 🔄 已恢复会话: {old_sid}")
|
|
957
|
+
except Exception as e:
|
|
958
|
+
display.warning(f"[WebUI] ⚠️ 会话恢复失败: {e}")
|
|
959
|
+
|
|
960
|
+
display.info(f"[WebUI] 🔄 Agent 重载完成 (model={_agent.model}, session={_agent.session.session_id})")
|
|
961
|
+
|
|
962
|
+
# ── 稍等片刻,让前端收到 reload 事件后再推送 done ──
|
|
963
|
+
await asyncio.sleep(0.3)
|
|
964
|
+
await event_bus.publish({
|
|
965
|
+
"type": "reload_done",
|
|
966
|
+
"session_id": _agent.session.session_id,
|
|
967
|
+
"model": _agent.model,
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
return {
|
|
971
|
+
"status": "ok",
|
|
972
|
+
"session_id": _agent.session.session_id,
|
|
973
|
+
"model": _agent.model,
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
# ════════════════════════════════════════════════════════════
|
|
978
|
+
# 5. WebSocket 端点 — 流式聊天
|
|
979
|
+
# ════════════════════════════════════════════════════════════
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
@app.websocket("/ws/chat")
|
|
983
|
+
async def websocket_chat(websocket: WebSocket, token: str | None = Query(None)):
|
|
984
|
+
"""
|
|
985
|
+
WebSocket 流式聊天
|
|
986
|
+
|
|
987
|
+
连接后,前端可发送 JSON 消息:
|
|
988
|
+
{"type": "message", "content": "你好"}
|
|
989
|
+
|
|
990
|
+
服务器通过 WebSocket 推送实时事件:
|
|
991
|
+
{"type": "llm_start", "ts": ...}
|
|
992
|
+
{"type": "llm_end", "content": "...", "has_tool_calls": ..., "tool_names": [...]}
|
|
993
|
+
{"type": "tool_call", "name": "...", "args": "..."}
|
|
994
|
+
{"type": "tool_result", "name": "...", "result": "..."}
|
|
995
|
+
{"type": "response", "content": "..."}
|
|
996
|
+
{"type": "ask", "prompt": "选择: "} ← 命令等待用户输入
|
|
997
|
+
{"type": "info", "content": "..."} ← IO 通道输出
|
|
998
|
+
{"type": "hint", "content": "..."}
|
|
999
|
+
{"type": "error", "error": "..."}
|
|
1000
|
+
{"type": "item", "content": "..."}
|
|
1001
|
+
{"type": "done", "session_id": "...", "final_content": "..."}
|
|
1002
|
+
"""
|
|
1003
|
+
await websocket.accept()
|
|
1004
|
+
|
|
1005
|
+
# ── 验证 Token ──
|
|
1006
|
+
if not token or not secrets.compare_digest(token, _WEBUI_TOKEN):
|
|
1007
|
+
await websocket.send_json({"type": "error", "error": "未授权,请先登录"})
|
|
1008
|
+
await websocket.close(code=4001)
|
|
1009
|
+
return
|
|
1010
|
+
|
|
1011
|
+
# 订阅事件总线
|
|
1012
|
+
sub_id, event_queue = event_bus.subscribe()
|
|
1013
|
+
|
|
1014
|
+
# 当前连接的 IO 通道(用于交互式命令)
|
|
1015
|
+
current_io: WebSocketIO | None = None
|
|
1016
|
+
|
|
1017
|
+
# 后台任务跟踪(初始化后供 finally 安全清理)
|
|
1018
|
+
push_task: asyncio.Task | None = None
|
|
1019
|
+
process_tasks: list[asyncio.Task] = []
|
|
1020
|
+
|
|
1021
|
+
try:
|
|
1022
|
+
# 发送连接确认
|
|
1023
|
+
await websocket.send_json({"type": "connected", "sub_id": sub_id})
|
|
1024
|
+
|
|
1025
|
+
# 后台任务:读取 EventBus 并推送至 WebSocket
|
|
1026
|
+
async def push_events():
|
|
1027
|
+
while True:
|
|
1028
|
+
try:
|
|
1029
|
+
event = await asyncio.wait_for(event_queue.get(), timeout=30)
|
|
1030
|
+
await websocket.send_json(event)
|
|
1031
|
+
except TimeoutError:
|
|
1032
|
+
# 心跳保活
|
|
1033
|
+
try:
|
|
1034
|
+
await websocket.send_json({"type": "ping"})
|
|
1035
|
+
except Exception:
|
|
1036
|
+
break
|
|
1037
|
+
except Exception:
|
|
1038
|
+
break
|
|
1039
|
+
|
|
1040
|
+
push_task = asyncio.create_task(push_events())
|
|
1041
|
+
|
|
1042
|
+
# 主循环:接收客户端消息
|
|
1043
|
+
# 首次获取 Agent 引用(后续每次消息前检查是否已被重载)
|
|
1044
|
+
agent = await get_agent()
|
|
1045
|
+
# process_tasks 已在函数顶部初始化
|
|
1046
|
+
|
|
1047
|
+
while True:
|
|
1048
|
+
raw = await websocket.receive_text()
|
|
1049
|
+
data = json.loads(raw)
|
|
1050
|
+
|
|
1051
|
+
# ── 检测 Agent 是否已被重载(热替换)──
|
|
1052
|
+
# 如果 get_agent() 返回了不同的对象,说明发生了 reload/new_agent
|
|
1053
|
+
# 旧 WS 连接透明切换到新 Agent 引用,避免断开重连导致消息丢失。
|
|
1054
|
+
# push_events 任务已通过 EventBus 收到 reload/reload_done 事件,
|
|
1055
|
+
# 前端此时已显示"已重载"状态,无需再发额外通知。
|
|
1056
|
+
current_agent = await get_agent()
|
|
1057
|
+
if current_agent is not agent:
|
|
1058
|
+
display.info("[WebUI] ↻ 旧 WS 透明切换到新 Agent(reload 后无缝续传)")
|
|
1059
|
+
agent = current_agent
|
|
1060
|
+
|
|
1061
|
+
if data.get("type") == "message":
|
|
1062
|
+
content = data.get("content", "").strip()
|
|
1063
|
+
if not content:
|
|
1064
|
+
await websocket.send_json({"type": "error", "error": "消息不能为空"})
|
|
1065
|
+
continue
|
|
1066
|
+
|
|
1067
|
+
# ── 如果 IO 通道正在等待用户输入,直接注入回复 ──
|
|
1068
|
+
if current_io and current_io.feed_reply(content):
|
|
1069
|
+
continue
|
|
1070
|
+
|
|
1071
|
+
# ── 如果 IO 通道还在运行(非 ask 状态),拒绝 ──
|
|
1072
|
+
if current_io and current_io.is_running:
|
|
1073
|
+
await websocket.send_json({
|
|
1074
|
+
"type": "error",
|
|
1075
|
+
"error": "正在处理中,请等待当前操作完成",
|
|
1076
|
+
})
|
|
1077
|
+
continue
|
|
1078
|
+
|
|
1079
|
+
# ── 正常处理:创建新 IO 通道并启动处理任务 ──
|
|
1080
|
+
ws_io = WebSocketIO(event_bus)
|
|
1081
|
+
ws_io.is_running = True
|
|
1082
|
+
current_io = ws_io
|
|
1083
|
+
|
|
1084
|
+
async def process_and_notify(msg: str, io: WebSocketIO, agent=agent):
|
|
1085
|
+
"""处理消息并通过 EventBus 推送结果"""
|
|
1086
|
+
try:
|
|
1087
|
+
response = await agent.process(msg, io=io)
|
|
1088
|
+
# 检查是否被用户主动中断(工具执行中 task.cancel())
|
|
1089
|
+
# agent._cancelled_by_user 在 agent._process_inner 的
|
|
1090
|
+
# except 块中被设为 True,process() 返回后检查此标记。
|
|
1091
|
+
# 用这种方式而非重新抛出 CancelledError,是为了不破坏
|
|
1092
|
+
# CLI 模式——CLI 的 except CancelledError: break 会退出程序。
|
|
1093
|
+
if agent.cancelled_by_user:
|
|
1094
|
+
agent.reset_cancelled()
|
|
1095
|
+
await event_bus.publish({"type": "cancelled"})
|
|
1096
|
+
else:
|
|
1097
|
+
# 从后端获取权威的非 system 消息计数,传递给前端
|
|
1098
|
+
# 前端据此校准 liveMsgIndex,消除前端自增计数器漂移
|
|
1099
|
+
all_msgs = agent.get_messages()
|
|
1100
|
+
non_sys_count = sum(1 for m in all_msgs if m.get("role") != "system")
|
|
1101
|
+
await event_bus.publish({
|
|
1102
|
+
"type": "done",
|
|
1103
|
+
"session_id": agent.session.session_id,
|
|
1104
|
+
"final_content": response.content,
|
|
1105
|
+
"non_system_count": non_sys_count,
|
|
1106
|
+
})
|
|
1107
|
+
except asyncio.CancelledError:
|
|
1108
|
+
await event_bus.publish({"type": "cancelled"})
|
|
1109
|
+
except Exception as e:
|
|
1110
|
+
await event_bus.publish({"type": "error", "error": str(e)})
|
|
1111
|
+
await event_bus.publish({"type": "done", "error": str(e)})
|
|
1112
|
+
finally:
|
|
1113
|
+
io.is_running = False
|
|
1114
|
+
|
|
1115
|
+
task = asyncio.create_task(process_and_notify(content, ws_io))
|
|
1116
|
+
process_tasks.append(task)
|
|
1117
|
+
|
|
1118
|
+
elif data.get("type") == "cancel":
|
|
1119
|
+
# 用户请求中断 → 取消最近的处理任务
|
|
1120
|
+
# task.cancel() 注入 CancelledError → agent._process_inner 的
|
|
1121
|
+
# tool 执行 except 块捕获 → 标记 _cancelled_by_user = True
|
|
1122
|
+
# → process() 正常返回 → process_and_notify 检查标记 → 发布 cancelled。
|
|
1123
|
+
# 不重新抛出异常,避免 CLI 的 except CancelledError: break 误退出。
|
|
1124
|
+
while process_tasks:
|
|
1125
|
+
task = process_tasks.pop()
|
|
1126
|
+
if not task.done():
|
|
1127
|
+
task.cancel()
|
|
1128
|
+
break
|
|
1129
|
+
|
|
1130
|
+
elif data.get("type") == "ping":
|
|
1131
|
+
await websocket.send_json({"type": "pong"})
|
|
1132
|
+
|
|
1133
|
+
except WebSocketDisconnect:
|
|
1134
|
+
pass
|
|
1135
|
+
except Exception as e:
|
|
1136
|
+
with suppress(Exception):
|
|
1137
|
+
await websocket.send_json({"type": "error", "error": str(e)})
|
|
1138
|
+
finally:
|
|
1139
|
+
# ── 清理后台任务:取消事件推送和处理任务 ──
|
|
1140
|
+
# 防止在 Agent 重载后孤立任务继续使用已关闭的客户端
|
|
1141
|
+
if push_task is not None:
|
|
1142
|
+
push_task.cancel()
|
|
1143
|
+
for t in process_tasks:
|
|
1144
|
+
t.cancel()
|
|
1145
|
+
event_bus.unsubscribe(sub_id)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
# ════════════════════════════════════════════════════════════
|
|
1149
|
+
# 6. 静态文件服务 + 前端路由
|
|
1150
|
+
# ════════════════════════════════════════════════════════════
|
|
1151
|
+
|
|
1152
|
+
# 挂载静态文件
|
|
1153
|
+
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
|
1154
|
+
os.makedirs(_static_dir, exist_ok=True)
|
|
1155
|
+
|
|
1156
|
+
if os.path.isdir(_static_dir):
|
|
1157
|
+
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
# ── 主页 ──────────────────────────────────────────────────
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
@app.get("/")
|
|
1164
|
+
async def index():
|
|
1165
|
+
"""返回聊天界面 HTML"""
|
|
1166
|
+
index_path = os.path.join(_static_dir, "index.html")
|
|
1167
|
+
if os.path.exists(index_path):
|
|
1168
|
+
with open(index_path, encoding="utf-8") as f:
|
|
1169
|
+
return HTMLResponse(f.read())
|
|
1170
|
+
# 如果前端文件不存在,返回说明页面
|
|
1171
|
+
return HTMLResponse("""
|
|
1172
|
+
<!DOCTYPE html>
|
|
1173
|
+
<html>
|
|
1174
|
+
<head><meta charset="utf-8"><title>Five Pebbles WebUI</title></head>
|
|
1175
|
+
<body style="background:#1a1a2e;color:#e0e0e0;font-family:sans-serif;
|
|
1176
|
+
display:flex;align-items:center;justify-content:center;height:100vh;">
|
|
1177
|
+
<div style="text-align:center">
|
|
1178
|
+
<h1> Five Pebbles WebUI</h1>
|
|
1179
|
+
<p>API 服务器已启动。</p>
|
|
1180
|
+
<p>访问 <a href="/api/health" style="color:#00bcd4">/api/health</a> 检查状态</p>
|
|
1181
|
+
<p>前端文件位于: <code>app/static/index.html</code></p>
|
|
1182
|
+
<hr style="border-color:#333;width:50%">
|
|
1183
|
+
<p style="color:#888">使用 WebSocket 连接: <code>ws://localhost:8765/ws/chat</code></p>
|
|
1184
|
+
</div>
|
|
1185
|
+
</body>
|
|
1186
|
+
</html>
|
|
1187
|
+
""")
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
# ════════════════════════════════════════════════════════════
|
|
1191
|
+
# 7. 启动入口
|
|
1192
|
+
# ════════════════════════════════════════════════════════════
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def main():
|
|
1196
|
+
"""启动 WebUI 服务器"""
|
|
1197
|
+
parser = argparse.ArgumentParser(description="Five Pebbles WebUI")
|
|
1198
|
+
parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)")
|
|
1199
|
+
parser.add_argument("--port", type=int, default=8765, help="监听端口(默认 8765)")
|
|
1200
|
+
parser.add_argument("--reload", action="store_true", help="启用热重载(开发用)")
|
|
1201
|
+
parser.add_argument("--expose", action="store_true", help="监听 0.0.0.0,允许局域网设备访问")
|
|
1202
|
+
args = parser.parse_args()
|
|
1203
|
+
|
|
1204
|
+
if args.expose:
|
|
1205
|
+
args.host = "0.0.0.0"
|
|
1206
|
+
|
|
1207
|
+
print()
|
|
1208
|
+
display.print_logo()
|
|
1209
|
+
print()
|
|
1210
|
+
if args.host == "0.0.0.0":
|
|
1211
|
+
display.warning(" ⚠️ 已监听 0.0.0.0,局域网设备可访问此服务")
|
|
1212
|
+
display.warning(" ⚠️ 请妥善保管 Token,建议使用 HTTPS 反向代理")
|
|
1213
|
+
print()
|
|
1214
|
+
display.info(f" 🌐 WebUI: http://{args.host}:{args.port}")
|
|
1215
|
+
display.info(f" 🔌 WS: ws://{args.host}:{args.port}/ws/chat")
|
|
1216
|
+
display.info(f" 📡 API: http://{args.host}:{args.port}/api/health")
|
|
1217
|
+
print()
|
|
1218
|
+
# 显示 Token(从文件读,确保与文件一致)
|
|
1219
|
+
display_token = _load_or_create_token()
|
|
1220
|
+
display.info(f" 🔑 启动 Token: ...{display_token[-4:]}")
|
|
1221
|
+
display.info(f" 📄 Token 文件: {_TOKEN_FILE} (cat 查看完整 Token)")
|
|
1222
|
+
print()
|
|
1223
|
+
|
|
1224
|
+
uvicorn.run(
|
|
1225
|
+
"fp_webui.main:app",
|
|
1226
|
+
host=args.host,
|
|
1227
|
+
port=args.port,
|
|
1228
|
+
reload=args.reload,
|
|
1229
|
+
log_level="info",
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
if __name__ == "__main__":
|
|
1234
|
+
main()
|