From 6131c2563e37f80dbe6050afaf32280c1b12e954 Mon Sep 17 00:00:00 2001 From: minorQAQ <202200120184@mail.sdu.edu.cn> Date: Wed, 19 Aug 2026 15:11:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=BC=80=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E6=9C=BA=E5=88=B6=E5=92=8CUI=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=BC=95=E5=85=A5=E4=B8=80=E4=BA=9B=E6=96=B0=E5=B7=A5=E5=85=B7?= =?UTF-8?q?.preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- README.html | 6 +- README.md | 6 +- analysis_report.md | 370 ------------ electron/main.js | 155 ++++- electron/preload.js | 19 + llm_server/README.md | 45 +- llm_server/image_gen_server.py | 47 +- llm_server/rag_server.py | 62 +- llm_server/start_asr.sh | 34 +- llm_server/start_image_gen.sh | 9 +- llm_server/start_llm.sh | 45 +- llm_server/start_rag_server.sh | 13 +- llm_server/start_streaming_tts.sh | 11 +- llm_server/streaming_tts_server.py | 51 +- requirements.txt | 3 + src/agent/agents/config_dist.json | 49 ++ src/agent/config/agent_config_dist.json | 46 -- src/agent/config/env_config_dist.json | 33 -- src/agent/config/gui_config.json | 23 - src/agent/config/theme_config.json | 8 - src/agent/config/tool_config.json | 244 -------- src/agent/config/workspace_config.json | 9 - src/agent/core/config_manager.py | 197 +++++-- src/agent/core/db.py | 302 +++++++++- src/agent/core/llm.py | 24 +- src/agent/core/nodes.py | 82 ++- src/agent/core/storage.py | 742 ++++++++++++++++++++++-- src/agent/core/workspace_policy.py | 24 +- src/agent/cron/models.py | 4 + src/agent/cron/runner.py | 29 + src/agent/cron/scheduler.py | 1 + src/agent/history/tool_call_recorder.py | 167 ++++-- src/agent/history/usage_stats.json | 2 +- src/agent/tools/__init__.py | 2 + src/agent/tools/browser_control.py | 689 +++++++++++++++++----- src/agent/tools/doc.py | 2 +- src/agent/tools/excel2sql.py | 188 ++++-- src/agent/tools/human_interaction.py | 114 +--- src/agent/tools/image_gen.py | 24 +- src/agent/tools/rag.py | 16 +- src/agent/tools/terminal_execute.py | 8 +- src/agent/tools/text2sql.py | 177 ++++-- src/agent/tools/web_page.py | 406 +++++++++++++ src/agent/utils/agent_utils.py | 125 +++- src/agent/utils/env_utils.py | 115 +++- src/agent/utils/git_utils.py | 206 +++++++ src/agent/utils/tool_call_utils.py | 55 +- src/web/app.css | 3 +- src/web/app.js | 218 +++---- src/web/css/animations.css | 2 - src/web/css/base.css | 106 +++- src/web/css/chat.css | 66 +++ src/web/css/composer.css | 437 ++++++++------ src/web/css/cron.css | 43 ++ src/web/css/edit-mode.css | 21 +- src/web/css/layout.css | 533 ++++++++++++++--- src/web/css/notify-strip.css | 449 ++++++++++++++ src/web/css/settings.css | 598 ++++++++++--------- src/web/css/toolcalls.css | 5 + src/web/css/variables.css | 1 - src/web/image/cron.svg | 1 + src/web/image/minorharness.svg | 34 ++ src/web/image/pdf.svg | 1 + src/web/image/todostate.svg | 1 + src/web/image/vedio.svg | 1 + src/web/image/word.svg | 1 + src/web/image/zip.svg | 1 + src/web/image/右栏_right-bar.svg | 1 + src/web/image/左栏_left-bar.svg | 1 + src/web/image/文件树.svg | 1 + src/web/image/文本文件.svg | 1 + src/web/image/设置.svg | 1 + src/web/index.html | 693 +++++++++++++++------- src/web/js/action-bar.js | 87 +-- src/web/js/boot.js | 11 + src/web/js/chat-render.js | 268 +++++++-- src/web/js/cron.js | 325 +++++++++-- src/web/js/doc-mod-panel.js | 235 -------- src/web/js/doc-tree.js | 50 +- src/web/js/edit-mode.js | 502 +++------------- src/web/js/i18n.js | 10 - src/web/js/layout.js | 2 - src/web/js/notify-strip.js | 740 +++++++++++++++++++++++ src/web/js/pending-overlay.js | 509 +--------------- src/web/js/pending.js | 8 +- src/web/js/rollback.js | 50 +- src/web/js/send.js | 52 +- src/web/js/sessions.js | 302 ++++++++-- src/web/js/settings.js | 587 +++++++++---------- src/web/js/state.js | 5 + src/web/js/terminal.js | 389 +++++++++++++ src/web/js/themes.js | 35 ++ src/web/js/think-level.js | 27 +- src/web/js/todo.js | 161 +---- src/web/js/toolcalls.js | 104 +++- src/web/js/turn-nav.js | 121 ++++ src/web/js/user-area.js | 52 ++ src/web/js/utils.js | 57 ++ src/web/js/workspace.js | 137 ++++- src/web/server.py | 354 ++++++++--- src/web/ui_session.py | 44 ++ 102 files changed, 9087 insertions(+), 4351 deletions(-) delete mode 100644 analysis_report.md create mode 100644 src/agent/agents/config_dist.json delete mode 100644 src/agent/config/agent_config_dist.json delete mode 100644 src/agent/config/env_config_dist.json delete mode 100644 src/agent/config/gui_config.json delete mode 100644 src/agent/config/theme_config.json delete mode 100644 src/agent/config/tool_config.json delete mode 100644 src/agent/config/workspace_config.json create mode 100644 src/agent/tools/web_page.py create mode 100644 src/agent/utils/git_utils.py create mode 100644 src/web/css/notify-strip.css create mode 100644 src/web/image/cron.svg create mode 100644 src/web/image/minorharness.svg create mode 100644 src/web/image/pdf.svg create mode 100644 src/web/image/todostate.svg create mode 100644 src/web/image/vedio.svg create mode 100644 src/web/image/word.svg create mode 100644 src/web/image/zip.svg create mode 100644 src/web/image/右栏_right-bar.svg create mode 100644 src/web/image/左栏_left-bar.svg create mode 100644 src/web/image/文件树.svg create mode 100644 src/web/image/文本文件.svg create mode 100644 src/web/image/设置.svg create mode 100644 src/web/js/boot.js delete mode 100644 src/web/js/doc-mod-panel.js create mode 100644 src/web/js/notify-strip.js create mode 100644 src/web/js/terminal.js create mode 100644 src/web/js/turn-nav.js create mode 100644 src/web/js/user-area.js diff --git a/.gitignore b/.gitignore index 8f3c7a0..fcc22c5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ package-lock.json dist/ electron-builder-cache/ -src/agent/config/env_config.json -src/agent/config/agent_config.json +src/agent/agents/config.json +src/agent/data/browser_profile/ + llm_server_bac/ diff --git a/README.html b/README.html index 4258e42..0b63a26 100644 --- a/README.html +++ b/README.html @@ -1181,7 +1181,7 @@ cloudflared tunnel --url http://localhost:8765 - + @@ -1194,7 +1194,6 @@ cloudflared tunnel --url http://localhost:8765 - @@ -1202,9 +1201,6 @@ cloudflared tunnel --url http://localhost:8765 - - -
字段默认值说明
models[]SETUP_REQUIREDLLM 列表:{id, name, model, api_key, base_url, timeout, max_retries}
models[]SETUP_REQUIREDLLM 列表:{id, name, model, api_key, base_url, timeout, max_retries};ASR / TTS / RAG / ImageGen 四个本地服务也在此注册(按 model 字段匹配,api_key 留空)
gui_model_id""GUI 视觉定位专用模型(留空则回退主模型)
WORKING_DIR项目根目录工作根目录(桌面端自动设为安装目录)
WORKSPACE_DIR""工作空间目录(相对 WORKING_DIR),留空则使用 WORKING_DIR
LOOP_DETECT_REPEATED_TOOL_WARN3连续同工具同参多少次警告
LOOP_DETECT_REPEATED_TOOL_END5连续同工具同参多少次强制终止
RAG_CHUNK_SIZE / OVERLAP500 / 50RAG 分块大小与重叠
IMAGE_GEN_URLhttp://localhost:8904图像生成服务地址
SEND_FILE_SIZE_LIMIT30发送文件大小上限(MB)
WEB_SEARCH_API_KEY""联网搜索 API Key
WEB_SEARCH_ENGINE"tavily"搜索引擎选择(tavily / firecrawl / volcengine)
MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE""MySQL 连接参数(STORAGE_BACKEND=mysql 时生效)
TOOL_TIMEOUT300工具执行超时时间(秒)
CRON_TIME_PERIOD_MINUTES30定时任务时段长度(分钟),同一时段全局并发=1
ASR_BASE_URL""语音识别服务地址
STREAMING_TTS_URL""流式 TTS 服务地址
RAG_BASE_URL""RAG 嵌入/重排序服务地址
EMAIL_ADDRESS / EMAIL_AUTH_CODE""邮箱凭证(IMAP/SMTP)
diff --git a/README.md b/README.md index fc8d6bc..9987b2e 100644 --- a/README.md +++ b/README.md @@ -374,7 +374,7 @@ cloudflared tunnel --url http://localhost:8765 | 字段 | 默认值 | 说明 | |------|--------|------| -| `models[]` | `SETUP_REQUIRED` | LLM 列表:`{id, name, model, api_key, base_url, timeout, max_retries}` | +| `models[]` | `SETUP_REQUIRED` | LLM 列表:`{id, name, model, api_key, base_url, timeout, max_retries}`;ASR / TTS / RAG / ImageGen 四个本地服务也在此注册(按 `model` 字段匹配,`api_key` 留空) | | `gui_model_id` | `""` | GUI 视觉定位专用模型(留空则回退主模型) | | `WORKING_DIR` | 项目根目录 | 工作根目录(桌面端自动设为安装目录) | | `WORKSPACE_DIR` | `""` | 工作空间目录(相对 WORKING_DIR),留空则使用 WORKING_DIR | @@ -386,7 +386,6 @@ cloudflared tunnel --url http://localhost:8765 | `GROUNDING_WIDTH/HEIGHT` | `1000` | 视觉定位输入分辨率 | | `LOOP_DETECT_REPEATED_TOOL_WARN` | `3` | 连续同工具同参多少次注入反思警告 | | `RAG_CHUNK_SIZE / OVERLAP` | `500 / 50` | RAG 分块大小与重叠 | -| `IMAGE_GEN_URL` | `http://localhost:8904` | 图像生成服务地址 | | `SEND_FILE_SIZE_LIMIT` | `30` | 发送文件大小上限(MB) | | `WEB_SEARCH_API_KEY` | `""` | 联网搜索 API Key | | `WEB_SEARCH_ENGINE` | `"tavily"` | 搜索引擎选择(tavily / firecrawl / volcengine) | @@ -394,9 +393,6 @@ cloudflared tunnel --url http://localhost:8765 | `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE` | `""` | MySQL 连接参数(STORAGE_BACKEND=mysql 时生效) | | `TOOL_TIMEOUT` | `300` | 工具执行超时时间(秒) | | `CRON_TIME_PERIOD_MINUTES` | `30` | 定时任务时段长度(分钟),同一时段全局并发=1 | -| `ASR_BASE_URL` | `""` | 语音识别服务地址 | -| `STREAMING_TTS_URL` | `""` | 流式 TTS 服务地址 | -| `RAG_BASE_URL` | `""` | RAG 嵌入/重排序服务地址 | | `EMAIL_ADDRESS / EMAIL_AUTH_CODE` | `""` | 邮箱凭证(IMAP/SMTP) | > ⚙️ 以上参数均可在主界面「设置」面板热修改,或通过 `/api/config/*` 接口程序化调整。 diff --git a/analysis_report.md b/analysis_report.md deleted file mode 100644 index d599d97..0000000 --- a/analysis_report.md +++ /dev/null @@ -1,370 +0,0 @@ -# minor Agent 代码深度分析报告 - -> 分析范围:`Agent/llm_server/`、`Agent/src/agent/cron/`、`Agent/src/agent/tts/streaming_client.py`、`Agent/src/agent/utils/`、`Agent/src/agent/memory/system_prompt.py`、`Agent/src/agent/agents/agent_runtime.py`、`Agent/src/agent/history/`、`Agent/src/web/ui_session.py`。全部路径精确到文件与关键函数/类/变量。 - ---- - -## 一、`C:\Users\86166\Desktop\Agent_Learning_minor\Agent\llm_server\`(本地推理后端,6 个模型/5 个服务端口) - -### 1. README.md —— 部署与总览 - -- 定位:为 minor Agent 提供**本地化推理后端**,全部跑在 GPU 服务器上,以 OpenAI 兼容 / 自定义 HTTP API 暴露;可整体部署或按需启用。 -- 服务总览表(README 自称 6 个独立服务,实际为 **5 个服务、6 个模型** —— RAG 一个服务内装 Embedding+Reranker 两个模型): - -| 服务 | 端口 | 模型 | 启动脚本 | Python 服务端 | 关键端点 | -|---|---|---|---|---|---| -| LLM | 8900 | Qwen3.6-35B-A3B-FP8 | `start_llm.sh` | vLLM 内置 | `/v1/chat/completions` | -| ASR | 8901 | Qwen3-ASR-1.7B | `start_asr.sh` | vLLM 内置 | `/v1/chat/completions` | -| TTS | 8902 | VoxCPM 1.5 / 2 | `start_streaming_tts.sh` | `streaming_tts_server.py` | `/stream_tts` | -| RAG | 8903 | Qwen3-Embedding-0.6B + Qwen3-Reranker-4B | `start_rag_server.sh` | `rag_server.py` | `/embed` `/rerank` | -| Image Gen | 8904 | Z-Image-Turbo | `start_image_gen.sh` | `image_gen_server.py` | `/generate` | - -- 所有脚本/服务端通过 `os.path.dirname(__file__)` 与 `${SCRIPT_DIR}` 动态定位模型路径,**目录整体迁移无需改代码**。 -- 部署流程要点: - 1. `scp -r Agent/llm_server/*` 上传 → 服务器上 `chmod +x *.sh`。 - 2. 环境准备:`export HF_ENDPOINT=https://hf-mirror.com`、`HF_HUB_ENABLE_XET=0`(国内必需);`python3 -m venv myenv`;一次性安装 `vllm modelscope transformers fastapi uvicorn torch numpy torchaudio soundfile bitsandbytes huggingface_hub voxcpm diffusers`;`apt` 安装 ffmpeg 全家桶。 - 3. 模型统一存 `~/llm_server/models/`,用 `modelscope download --model ... --local_dir ./models/...` 下载(注意 modelscope 目录名中 `/` 转 `___`、`.` 转 `_`,如 `Qwen3___6-35B-A3B-FP8`、`Qwen3-Embedding-0___6B`、`VoxCPM1__5`)。 - 4. 每个 `start_*.sh` 支持环境变量覆盖:`HOST_PORT`(端口)、`LOCAL_MODEL_DIR`/`MODEL_PATH`/`VOXCPM_MODEL_PATH`(模型目录)、`CUDA_VISIBLE_DEVICES`/`SPECIFIC_GPU`(GPU 指定)。 -- 本地调试:`ssh -fN -L 8900:0.0.0.0:8900 ...` 一次性映射 5 个端口;`curl http://localhost:$p/health` 验证。 -- 停止服务:`for p in 8900..8904; do kill $(lsof -t -i:$p); done`。 -- 接入 Agent(配置向导或 `env_config.json`):`models[].base_url=http://localhost:8900/v1`、`ASR_BASE_URL=http://localhost:8901/v1`、`STREAMING_TTS_URL=http://localhost:8902`、`RAG_BASE_URL=http://localhost:8903`、图像生成由 `image_gen` 工具读取 `http://localhost:8904`。也支持任意 OpenAI 兼容云端 API。 - -### 2. start_llm.sh —— LLM 启动参数(vLLM 推理引擎) - -固定 `CUDA_VISIBLE_DEVICES=3`,`VLLM_USE_MODELSCOPE=true`、`MODELSCOPE_DOWNLOAD_MODE=local`、`HF_HUB_OFFLINE=1` 等离线环境变量。vLLM 关键参数: - -- `vllm serve `:`--tensor-parallel-size 1`、`--max-model-len 262144`(26 万超长上下文)、`--reasoning-parser qwen3`、`--enable-auto-tool-choice` + `--tool-call-parser qwen3_coder`(自动工具调用)、`--speculative-config '{"method":"qwen3_next_mtp","num_speculative_tokens":2}'`(MTP 投机解码,2 个投机 token)、`--enable-prefix-caching`、`--max_num_batched_tokens 4096`、`--max_num_seqs 8`、`--kv-cache-dtype fp8_e4m3`(KV 缓存 FP8)、`--chat-template chat_template.jinja`(自定义模板)、`--safetensors-load-strategy eager`、`--served-model-name Qwen/Qwen3.6-35B-A3B-FP8`。 - -### 3. chat_template.jinja —— ChatML 风格自定义模板 - -- `enable_thinking` 默认 `false`(`|default(false)`)。 -- 有工具时:system 段 = 首条 system 消息(content 为 list 时把 image/audio/video part 替换为 `<|vision_start|><|image_pad|><|vision_end|>`、`<|audio_start|><|audio_pad|><|audio_end|>`、`<|vision_start|><|video_pad|><|vision_end|>` 占位符)+ 固定的 `# Tools\n...` 说明 + `...` 内逐条 `tool | tojson` 序列化工具签名;工具调用格式约束为 `\n{"name": ..., "arguments": ...}\n`。 -- 无工具时:首条 system 按 content 类型输出多模态占位符或文本。 -- **multi_step_tool 检测**(第 47-54 行):从后往前找第一个"非 `` 包裹"的 user 消息,记为 `last_query_index`,用于判断后续 assistant 是否需要显式输出 `` 块。 -- assistant 消息:优先用 `message.reasoning_content`(字符串);否则若 content 含 ``,用 `split('')`/`split('')` 拆出 reasoning 与正文。`loop.index0 > last_query_index` 且(末条或含 reasoning)时,输出 `...` 包裹的思考段。`tool_calls` 逐个渲染为 `...`。 -- tool(函数返回)消息:连续的 tool 消息被**合并进同一个 `user` 角色块**,每条包在 `\n...\n` 中,首条前加 `<|im_start|>user`、末条后加 `<|im_end|>`。 -- `add_generation_prompt` 时输出 `<|im_start|>assistant\n`;若显式传 `enable_thinking=false` 则先输出空 `\n\n\n\n`(禁用思考)。 - -### 4. test_llm.py —— 测试方式 - -- `python test_llm.py [--url http://127.0.0.1:8900] [--model Qwen/Qwen3.6-35B-A3B-FP8] [--stream]`。 -- 非流式 `test_non_stream`:POST `/v1/chat/completions`,打印完整响应 + 提取 model/finish_reason/content/tool_calls/usage(prompt/completion/total tokens)。 -- 流式 `test_stream`:`stream=True`,逐行解析 `data: ` 前缀的 SSE,`[DONE]` 结束,累加 `delta.content` 输出。 - -### 5. start_asr.sh + test_asr.py —— ASR 服务 - -- **引擎 = vLLM**,模型 **Qwen3-ASR-1.7B**(README 标注;test_asr.py 文档字符串仍引用旧模型 Qwen3-Omni-30B-A3B-Captioner,说明测试脚本是从旧版继承)。启动参数:`--quantization fp8`、`--dtype auto`、`--kv-cache-dtype fp8`、`--max-model-len 16384`、`--allowed-local-media-path /`(允许读取本地媒体路径)、`--tensor-parallel-size 1`、`--gpu-memory-utilization 0.15`(只占 15% 显存)、`--max-num-batched-tokens 4096`;`CUDA_VISIBLE_DEVICES=3`。 -- **调用方式**:`test_asr.py --audio [--url http://127.0.0.1:8901/v1/chat/completions] [--base64]`。核心函数 `caption_audio(audio_path, server_url, use_base64)`: - - 音频转 URL:本地直传用 `file://` + 绝对路径(`/` 分隔);远程场景 `--base64` 用 `data:;base64,...`(`file_to_url`)。 - - **不带任何文本提示**,payload 仅一条 user 消息、content 为 `[{"type": "audio_url", "audio_url": {"url": audio_url}}]`(音频描述式 ASR,模型输出整段音频内容的描述/转写)。 - - 用 `soundfile`(回退 librosa)检查时长,>30s 打警告(建议裁剪到 ≤30s)。`timeout=120`,取 `result["choices"][0]["message"]["content"]`。 - -### 6. streaming_tts_server.py —— 流式 TTS 服务器(FastAPI + VoxCPM) - -- **引擎 = OpenBMB VoxCPM**(`from voxcpm import VoxCPM`,README 注释"Whispera 同款")。`DEFAULT_MODEL_PATH=models/VoxCPM1__5`,`DEFAULT_PORT=8902`。 -- 音色克隆:`DEFAULT_PROMPT_WAV=reference_audio.wav` + `DEFAULT_PROMPT_TEXT="日子缓缓前行,不必急于追赶…"`,每次合成都克隆该音色;音频质量参数 `DEFAULT_CFG_VALUE=1.5`(音色贴合度)、`DEFAULT_INFERENCE_STEPS=15`(推理步数)。 -- 单例与并发控制:`_get_model(model_path)` 线程安全惰性加载(`_singleton_lock`,`enable_denoiser=False, optimize=True`,采样率取 `model.tts_model.sample_rate`,默认 24000Hz);`_model_lock` 全局模型锁(VoxCPM 只允许单线程);`cancel_current_and_set(new_request_id)` 在**新请求到达时自动取消旧请求**(设置 cancel Event,并强制释放被旧请求持有的模型锁);`set_cancel`/`is_cancelled` 请求级取消标志。 -- 端点: - - `POST /stream_tts`:body `{"text": ..., "request_id": ...}`(缺省 request_id 自动生成 `tts-{ms}`)。返回 `StreamingResponse(media_type="text/event-stream")`,头带 `Cache-Control: no-cache`、`X-Accel-Buffering: no`。空文本直接返回 `audio.error`。 - - `POST /cancel_tts`:`{"request_id": ...}` 取消指定合成。 - - `GET /health`。 -- **流式音频块格式(SSE,`_build_sse` 为 `data: {json}\n\n`)**: - - `audio.start`:`{type, request_id, sample_rate, audio_format: "pcm_f32le"}`。 - - `audio.chunk`:`{type, request_id, index, num_samples, sample_rate, data: base64(PCM float32 LE 字节)}` —— `np.asarray(chunk, float32).ravel()` → `tobytes()` → base64;生成循环中检查 `is_cancelled`,取消则发 `audio.cancelled` 并 return。 - - `audio.done`:`{type, request_id, total_samples, interrupted: false}`。 - - `audio.error`:`{type, request_id, message}`(模型加载失败、等锁超时 3 次等)。 - - 模型调用:`model.generate_streaming(text=text, prompt_wav_path=..., prompt_text=..., cfg_value=..., inference_timesteps=..., retry_badcase=False)`。 -- **关于"分句"**:本文件**没有显式分句逻辑**——整段 text 一次性交给 `generate_streaming`,由 VoxCPM 内部流式产出音频块(docstring 说"按句分块合成"是沿用旧设计的注释;`test_tts.py` 里读取的 `num_segments`/`total_segments` 字段服务器实际并不发送,客户端读到默认值)。真实分句若需要,应在客户端或未来版本做文本切分。 -- 启动:`uvicorn.run(app, host="0.0.0.0", port=args.port)`;lifespan 启动时预加载模型。 - -### 7. start_streaming_tts.sh + test_tts.py - -- 启动脚本:`VOXCPM_MODEL_PATH` 可覆盖(默认 `models/VoxCPM1__5`);依次尝试 `../myenv`、`../../myenv` 激活虚拟环境;`CUDA_VISIBLE_DEVICES` 默认 3;`exec python streaming_tts_server.py --port --model-path`。 -- 测试脚本:健康检查 → `POST /stream_tts`(`stream=True, timeout=300`)→ 逐行解析 `data: ` SSE → 按事件类型处理:`audio.start` 取 sample_rate;`audio.chunk` 累积 base64 解码的 PCM、进度打印(idx%5==0);`audio.done` 汇总;`audio.error` 退出;`audio.cancelled` break。结束后计算 RTF(耗时/音频时长,<1 即比实时快)。`_save_wav`:f32 → `(arr*32767).clip(-32768,32767).astype(int16)` 写 16-bit 单声道 WAV;`_play_pcm` 用 pyaudio 播放。实测产物 `test_output.wav` 已存在。 - -### 8. rag_server.py —— RAG 服务器(FastAPI + Transformers) - -- **框架**:FastAPI + `modelscope.AutoTokenizer/AutoModel/AutoModelForCausalLM`(无 LangChain/RAG 框架,纯手写检索基座)。 -- 模型路径:`EMBED_MODEL_PATH=models/Qwen/Qwen3-Embedding-0___6B`、`RERANK_MODEL_PATH=models/Qwen/Qwen3-Reranker-4B`。 -- 加载(`@app.on_event("startup")` `load_models`):Embedding 用 `AutoModel.from_pretrained(..., device_map="auto", dtype=torch.float16)`(fp16,未量化,注释里 8-bit 被禁用);Reranker 用 `AutoModelForCausalLM.from_pretrained(..., quantization_config=BitsAndBytesConfig(load_in_8bit=True), device_map="auto", dtype=torch.float16)`。 -- 工具函数:`last_token_pool`(取每条样本最后有效 token 的 hidden state,兼容左/右 padding)、`get_detailed_instruct`(query 拼 `Instruct: {task}\nQuery:{query}`,**document 不加指令**)、`format_rerank_instruction`(`: ...\n: ...\n: ...`,默认 instruct 文本 "Given a web search query, retrieve relevant passages that answer the query")。 -- Reranker 技巧:预编码固定 prefix/suffix(ChatML system+user 头、assistant 尾带空 think),`max_rerank_length=32768`;取 `logits[:, -1, :]` 中 `"yes"`/`"no"` 两个 token id 的 logit,`log_softmax([false, true])` 后取 true 的 `exp` 作为得分(生成式 rerank)。 -- **API 端点**: - - `POST /embed`:`{task_description, queries: [...], documents: [...]}` → `{query_embeddings, doc_embeddings}`(L2 归一化,queries 在前 documents 在后拼 batch)。 - - `POST /rerank`:`{task_description, query, documents}` → `{scores: [...]}`。 - - `GET /health`。 -- 启动:`start_rag_server.sh` 用 `uvicorn rag_server:app --host 0.0.0.0 --port 8903 --workers 1`(离线环境变量 + `CUDA_VISIBLE_DEVICES=3`)。测试:`test_rag.py` 依次测 health/embed/rerank,打印向量维度与得分。 - -### 9. image_gen_server.py —— 图像生成服务器(Diffusers) - -- **模型 = Tongyi-MAI/Z-Image-Turbo**(README:基于 Diffusers,9 步推理出 1024×1024;代码默认 `num_inference_steps=11`)。`LOCAL_MODEL_DIR` 环境变量优先本地路径,否则回退远程 model id;`SPECIFIC_GPU` 直接写 `CUDA_VISIBLE_DEVICES`。 -- 加载:`DiffusionPipeline.from_pretrained(MODEL_PATH, torch_dtype=torch.bfloat16(if cuda), low_cpu_mem_usage=False, trust_remote_code=True)`;本地路径时 `local_files_only=True`;惰性单例 `_get_pipe()` + `_pipe_lock`。 -- **`POST /generate`**:入参 `prompt`(必填)、`negative_prompt`、`height/width`(默认 1024,**须为 64 的整数倍**且 ∈[256,2048])、`num_inference_steps`(默认 11)、`guidance_scale`(默认 0.0)、`seed`(-1 随机,≥0 用 `torch.Generator(device).manual_seed`)、`return_format`(`base64` 默认 | `url`)。响应 `{success, image: base64-PNG 或原始 PNG 流, format:"png", width, height, elapsed_seconds}`。`GET /health`。启动时 lifespan 预加载模型。 - ---- - -## 二、`C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src\agent\cron\`(定时任务子系统) - -### models.py —— 数据模型(3 个 dataclass + 类型别名) - -- `TriggerType = Literal["cron","interval","once"]`;`TaskStatus = Literal["pending","running","completed","failed","expired","disabled"]`;`TriggerSource = Literal["schedule","manual"]`。 -- `Trigger`(触发配置):`type`(默认 "cron")、`cron`(标准 5 段 cron 表达式如 `"0 9 * * *"`)、`interval_seconds`、`run_at`(once 的目标本地时间 ISO)。有 `to_dict`/`from_dict`。 -- `ExecutionLog`(单次执行记录):`started_at`、`ended_at`、`status`、`error`、`trigger`(schedule/manual)、`turn_id`(关联工具调用记录)。 -- `CronTask`(任务完整定义):`task_id`(时间戳风格,与 session_id 同构)、`name`、`prompt`(触发时投递给 Agent 的提示词)、`trigger`、`enabled`、`timeout_seconds`(默认 300)、`created_at`、`next_run_at`、`last_run_at`、`last_status`、`last_error`、`executions: list[ExecutionLog]`。`summary()` 返回不含 executions 全量的精简 dict(附 `executions_count`)供前端列表。 -- `now_iso()`:`datetime.now().strftime("%Y-%m-%dT%H:%M:%S")`(无时区,与前端一致)。 - -### scheduler.py —— 调度器(CronScheduler 单例) - -- 常量:`_SCAN_INTERVAL = 5.0`(扫描周期秒)、`_CRON_TIME_PERIOD_MINUTES = 30.0`(时段长度,同一时间段只允许一个任务运行,全局并发=1;由 `env_config.json` 的 `CRON_TIME_PERIOD_MINUTES` 配置)。 -- `_load_time_period_minutes()` / `get_cron_time_period()` / `set_cron_time_period(minutes)`:读取/热更新时段长度并持久化到 env_config.json(`save_env_config`)。 -- `_parse_iso(dt_str)`:解析 `%Y-%m-%dT%H:%M:%S`,失败 None。 -- `_compute_next_run(trigger, now)`:cron → `croniter(trigger.cron, now).get_next(datetime)`(异常返回 "");interval → `now + timedelta(interval_seconds)`;once → `run_at` 若未来,否则 ""(不再调度)。`compute_next_fire` 包装为 datetime。 -- `detect_time_period_conflict(trigger, exclude_task_id=None)`:计算候选任务下次触发时刻,与所有 enabled 任务(排除自身)的下次触发时刻求**绝对差 < 时段长度 → conflict=True**,返回 `{"conflict", "conflict_with": [name...], "period_minutes", "next_fire"}`。供前端 check-conflict API 与 cron_manager 工具复用。 -- `CronScheduler`(`get_instance()`/模块级 `get_scheduler()` 单例): - - 状态:`_thread`、`_stop_event`、`_running_procs: dict[task_id, Popen]`、`_procs_lock`、`_app_start_time`、`_started`、`_period_start_ts`(当前占用时段起点,None 空闲)。 - - `start()`:启动 daemon 线程 `cron-scheduler` 跑 `_run_loop`;`shutdown()`:set stop、terminate 所有子进程、join 3s。 - - `_run_loop`:先 `_on_startup()`(启动扫描),再每 5s `_scan_once()`。 - - `_on_startup`/`_handle_startup_task`(错失/过期处理):once 目标已过且从未成功 → 追加 `expired` 执行记录并 `update_status(...,"expired", error="App was not running, missed execution time", next_run_at="")` + `enabled=False` 禁用;cron 若 `next_run_at < app 启动时间` → 记 expired 执行记录并推进 `_compute_next_run(trigger, now)`;interval 直接推进到 now+interval。 - - `_scan_once`:遍历 enabled 任务,`next_run_at <= now` 且不在 `_running_procs` → `_spawn(task, trigger_source="schedule", started_iso=now_iso_str)`。 - - `_get_python_and_cwd`:Python 解释器优先 `env_config.json` 的 `USER_PYTHON_PATH`(文件存在才用),否则 `sys.executable`;cwd = `agent/cron/storage.py` 的 `parents[2]`(即 src 目录)。 - - `_spawn(task, trigger_source, started_iso, prompt=None)`:**时段 gate** —— 锁内检查 `_running_procs` 非空或 `now - _period_start_ts < 时段秒数` 则跳过;否则占用新时段 `_period_start_ts = now_ts`。命令:`[py, "-m", "agent.cron.runner", "--task-id", ..., "--prompt", use_prompt, "--trigger", trigger_source, "--timeout", str(task.timeout_seconds)]`;env 注入 `PYTHONPATH=cwd`;Windows 用 `CREATE_NEW_PROCESS_GROUP`。spawn 失败 → `update_status(...,"failed", error="Failed to spawn subprocess: ...")` 并释放时段。成功后 `update_status(...,"running", last_run_at=started_iso)`,起 daemon 线程 `_monitor_proc`。 - - `_monitor_proc`:`proc.wait()` 回收;**子进程自身会 POST `/api/cron/internal/finished` 回传终态,本线程仅兜底**——若 `last_status` 已是 completed/failed/expired 则不覆盖;否则按 exit code 推断(0→completed,非 0→failed `Subprocess exited abnormally (exit code N)`),并补记 execution。 - - `spawn_now(task_id, prompt=None)`:手动立即运行/追问(trigger=manual,prompt 可覆盖原任务提示词);`is_running(task_id)`;`kill_task(task_id)`(terminate);`reload_task(task_id)`(配置变更后重算 next_run_at,供 cron_manager 工具调用)。 - -### runner.py —— 子进程执行器(`python -m agent.cron.runner`) - -- 回传地址常量:`_MAIN_HOST="127.0.0.1"`、`_MAIN_PORT=8765`、`_LIVE_URL=http://127.0.0.1:8765/api/cron/internal/live`、`_FINISHED_URL=http://127.0.0.1:8765/api/cron/internal/finished`。 -- `_redirect_storage_roots(task_id)`(**必须在构建 runtime 前调用**):把三个模块的全局根目录显式覆盖到 cron 目录,并把统一存储作用域切到 "cron": - - `agent.utils.agent_utils.SESSIONS_ROOT = CRON_ROOT`(turn 文件/session_dir/get_history) - - `agent.history.tool_call_recorder.TOOL_CALLING_ROOT = Path(CRON_ROOT)`(tool_*.json、token) - - `web.ui_session.SESSIONS_ROOT = CRON_ROOT`(turn relpath 计算) - - `agent.core.storage.set_storage_scope("cron")`(消息/工具调用记录直写 cron 作用域:CRON_ROOT 文件或 cron_* 表,**无需执行后的文件→DB 桥接**) - - 任务目录 `task_dir = CRON_ROOT/{task_id}` 并 mkdir。 -- `_start_live_forwarder(task_id)`:后台线程 `cron-live-{task_id}` 订阅 `subscribe_live(task_id)`,把每个快照 `POST {"task_id": task_id, "snapshot": snapshot}` 到 `_LIVE_URL`(timeout=5,失败不阻塞),返回 stop Event。 -- `_post_finished(task_id, status, error, turn_id)`:POST `{"task_id","status","error","turn_id"}` 到 `_FINISHED_URL`(timeout=10,失败仅打印)。 -- `_run_with_timeout(func, timeout)`:daemon 工作线程执行,`done.wait(timeout)` 判超时;超时返回 `(None, None, True)`(Python 无法杀线程,靠子进程随后 `sys.exit` 一起销毁);异常 `(None, exc, False)`;成功 `(value, None, False)`。 -- `_classify_runner_error`:复用 `agent.core.runtime._classify_error`。 -- `main()` 流程: - 1. 解析 `--task-id/--prompt/--trigger/--timeout`(timeout 下限 10s)→ `_redirect_storage_roots(task_id)`,失败则 `_post_finished(failed, "存储初始化失败...")` 返回 1。 - 2. `agent.core.workspace_policy.mark_headless()`:无人值守标记,approval 模式退化为拦截(待审批项随进程退出丢失)。 - 3. `build_all_agent_runtimes()` + `get_main_agent_runtime()`,无主 Agent → failed。 - 4. `_start_live_forwarder(task_id)`。 - 5. `execute_runtime_agent(runtime=runtime, chat_history=[{"role":"user","content":prompt}], session_id=task_id, agent_mode="cron")`(直接走 core.runtime,绕开 {"agent","plan"} 归一化),包 `_run_with_timeout`;超时→failed"任务超时";异常→`_classify_runner_error`;正常完成时检查最后一条 assistant content 是否以错误前缀开头(`"OOM:"、"执行过程中出现错误"、"API Key"、"无法连接"、"指定的模型"、"模型服务端"、"对话内容超过"、"API 调用频率"、"Agent 执行轮数超过限制"`)→ 判定 failed。 - 6. `stop_forwarder.set()` → `get_last_turn_id(task_id)` 取 turn_id → `_post_finished(task_id, status, error_msg, turn_id)` → `sleep(0.3)` 给转发线程发最后快照 → 返回 0/1。 - -### storage.py —— 转发层 - -纯转发到 `agent.core.storage`:导出 `CRON_ROOT`、`TaskConfigRepository`、`ConversationRecordRepository`、`get_task_repository`、`new_task_id`、`set_storage_scope`、`get_storage_scope`;并自定义 `get_record_repository()` = `_get_core_record_repository(scope="cron")`(固定返回 cron 作用域记录仓库:`cron_messages`/`cron_tool_calls` 表或 `history/cron` 下 JSON 文件,按 `STORAGE_BACKEND` 选择 json/mysql)。 - -### live.py —— 主进程侧实时流中继 - -- 子进程无法直接与前端建 SSE,故经 HTTP 回传主进程后由本模块按 task_id 分发。 -- `_cron_live_subs: dict[task_id, list[Queue]]` + `_subscribers_lock`。 -- `subscribe(task_id)`/`unsubscribe(task_id, q)`:每任务订阅者队列(maxsize=64)。 -- `push_snapshot(task_id, snapshot)`:向所有订阅者 `put_nowait({"type":"update","data":snapshot})`(队列满丢最旧)。 -- `push_finished(task_id, payload)`:推 `{"type":"finished","data":payload}`,前端据此重载消息并刷新状态。 -- 快照格式与 `tool_call_recorder._build_live_snapshot` 一致:`{tool_calls, reflections, started_at}`(前端复用 toolcalls.js 渲染)。 - -### __init__.py - -包说明:Cron 模式与 Edit/Chat 模式并列,通过子进程隔离执行;子模块 roles:models/storage/scheduler/runner/live。 - -### ★ cron 与主进程的通信机制(总结) - -1. **调度**:主进程 FastAPI lifespan 启动 `CronScheduler`(daemon 线程)→ 5s 扫描 → `subprocess.Popen([py, "-m", "agent.cron.runner", ...])` 拉起子进程。 -2. **子进程 → 主进程**(HTTP 回传,主进程固定监听 127.0.0.1:8765): - - `POST /api/cron/internal/live`:`{task_id, snapshot}` → 主进程 `cron.live.push_snapshot` → 分发给前端 SSE 订阅者。 - - `POST /api/cron/internal/finished`:`{task_id, status, error, turn_id}` → 主进程 finished handler 更新任务状态(scheduler `_monitor_proc` 以此为兜底依据,不重复覆盖终态)。 -3. **主进程 → 前端**:SSE(cron live 订阅队列),前端收 `update`/`finished` 事件。 -4. 存储天然隔离:子进程把 `SESSIONS_ROOT`/`TOOL_CALLING_ROOT`/`ui_session.SESSIONS_ROOT` 重定向到 `history/cron/{task_id}/`,且统一存储 scope="cron",无需桥接导入。 - ---- - -## 三、`C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src\agent\tts\streaming_client.py`(流式 TTS 客户端) - -- `TTSEvent` dataclass:`type`(audio.start | audio.chunk | audio.done | audio.error | audio.cancelled)、`request_id`、`index=0`、`num_samples=0`、`sample_rate=24000`、`data: bytes|None`(**PCM f32le 原始字节**,已由 base64 解码)、`message=""`。 -- `StreamingTTSClient(base_url="http://localhost:8902")`: - - `stream_tts(text, request_id="")` → **Generator[TTSEvent]**:`requests.post(f"{base_url}/stream_tts", json={"text","request_id"}, stream=True, timeout=300)`;`resp.raise_for_status()`;`resp.iter_lines(decode_unicode=True)` 逐行,跳过非 `data: ` 行;`json.loads(line[len("data: "):])`,解码失败 continue;按 payload 字段构造 `TTSEvent`,`data=_decode_pcm_b64(payload.get("data"))`;`yield evt`。 - - `cancel_tts(request_id)` → bool:POST `{base_url}/cancel_tts`(timeout=5),200 即成功。 -- `_decode_pcm_b64(b64_data)`:base64 解码失败返回 None。 - ---- - -## 四、`C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src\agent\utils\`(工具函数集) - -### env_utils.py —— 环境变量加载与全局配置 - -- `_get_env(name, default, cast, required)`:读 os.environ,支持必填校验(缺失抛 `EnvironmentError`)与类型转换(失败抛 `ValueError`)。 -- 配置加载链:`_load_config()` 优先 `agent.core.config_manager.load_env_config()`(单一数据源),把非 `models` 键写入 `os.environ`,并从 `models[0]` 提取 `api_key/base_url/model/timeout` 写入 `LLM_API_KEY/LLM_BASE_URL/LLM_MODEL/LLM_TIMEOUT`;config_manager 不可用时回退直接读 `src/agent/config/env_config.json`;最后 `_refresh_globals()`。模块导入时执行 `_load_config(); _refresh_globals()`。 -- `_refresh_globals()` 定义的模块级常量(均可用环境变量覆盖): - - 路径:`WORKING_DIR`(相对路径转绝对)、`WORKSPACE_DIR`、`USER_PYTHON_PATH`。 - - LLM:`LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL`(**required=True**)、`LLM_TIMEOUT`(默认 60.0)、`LLM_CONTEXT_WINDOW`(默认 262144)。 - - 多模态服务:`STREAMING_TTS_URL`、`ASR_BASE_URL`、`RAG_BASE_URL`(三者均为可选,对应 llm_server 的 8902/8901/v1、8903)。 - - 压缩/图像:`COMPRESS_RATE`(0.6)、`IMG_SIZE`(768)、`RAG_CHUNK_SIZE`(500)、`RAG_CHUNK_OVERLAP`(50)、`GROUNDING_WIDTH/HEIGHT`(1000)、`SEND_FILE_SIZE_LIMIT`(30MB)。 -- **THINKING_LEVELS 各档位**(`("low","high","xhigh","max","ultra")`,默认 `"low"`;非法值回退 low): - - `low` = agent 模式 + 不思考;`high` = plan 模式 + 不思考;`xhigh` = agent 模式 + 思考;`max` = plan 模式 + 思考;`ultra` 为未来 react→审批图预留占位,暂按 "agent+思考" 处理。 - - `_THINKING_ENABLED_LEVELS = {"xhigh","max","ultra"}`。 -- `thinking_enabled(level=None)`:档位是否启用深度思考。 -- `get_thinking_extra_body(level=None)`:思考开启返回 `{}`(恢复模型原生行为=启用思考);否则返回 `None`(禁用思考,默认)。该 extra_body 注入 `Multimodel_LLM`。 -- `reload_config()`:热重载并刷新全局(调用方须 `import agent.utils.env_utils as env_utils` 访问属性,`from ... import` 会绑定旧值)。 -- `BROWSER_MAP`:edge/chrome/firefox/safari/opera/brave → (可执行名, 显示名)。 -- `get_workspace_dir()`:WORKSPACE_DIR 绝对化(相对则相对 WORKING_DIR),缺省回退 WORKING_DIR,确保目录存在。 -- `get_venv_dir()`:由 `USER_PYTHON_PATH`(`.../Scripts/python.exe`)取上级目录。 - -### agent_utils.py —— 会话/历史/附件/文档核心工具 - -- 常量:`AUDIO_FILE_EXTENSIONS`(wav/mp3/flac/ogg/m4a/aac/wma/opus/aiff/ape/amr/au)、`DOC_EXT_MAP`(txt/json/md/cpp/c/java/m/html/svg→plain_text;docx→docx;pptx→pptx;pdf→pdf;csv/xlsx/xls→tabular)、**路径**:`HISTORY_ROOT = src/agent/history`、`SESSIONS_ROOT = history/sessions`(启动即 mkdir)。 -- `JsonChatMessageHistory(session_id)`(替代 SQLite):基于 turn 文件读写 LangChain 消息,`_load()` 用 `load_all_turn_messages`,`_dict_to_langchain` 把 user/assistant 条目转 HumanMessage/AIMessage;`get_history(session_id)` 返回实例。 -- session_meta:`_load_session_meta`/`_save_session_meta`(经 `session_storage.load_session_extra/save_session_extra` 的 `"agent_meta"` 键);`get_session_meta(session_id, agent_name)` → `{cursor_index, compressed_content}`;`update_session_meta` 写入。 -- **动态尾部长期记忆**:`update_session_dynamic_tail(session_id, agent_key, text, max_entries=100)` —— 存 `dynamic_tail_history` 列表,连续相同内容去重,超出上限丢最旧;`get_session_dynamic_tail_history` 读取(时间正序)。用于 TodoList 状态/循环提醒长期记忆,与 compressed_content 合并保存不覆盖(前缀缓存友好:变化部分在消息末尾)。 -- `build_tool_history_messages(session_id, agent_key="main")`:读 cursor_index,把压缩游标**之后**所有轮次工具调用构造成 synthetic HumanMessage(`additional_kwargs={"synthetic":True,"tool_ctx":True}`,`id=f"tool_ctx_{idx}"` 供压缩节点 RemoveMessage 精确移除),内容 `[历史工具调用] {name}({args[:300]}) → {result[:500]}`。 -- Turn 存储:`TURN_JSON_PREFIX="turn_"`;`turn_files_dir(session_id, turn_id)` = `sessions/{sid}/{turn_id}/files`;`save_turn_messages`(→ `session_storage.save_turn`,后端无关,附件二进制由调用方落盘);`load_all_turn_messages`;`delete_turns_after(session_id, keep_until_turn_id)`(按目录名排序删除 > keep 的 turn 目录 + 存储记录,**keep 自身保留**);`get_last_turn_id`;`session_dir(session_id)` = `sessions/{sid}`。 -- pending turn_id:`set_pending_turn_id`/`take_pending_turn_id`(跨请求共享,`_pending_turn_ids` dict + 锁,仅取一次)。 -- `summarize_chat_text(llm, chat_text)`:`ChatPromptTemplate(SUMMARY_SYSTEM_PROMPT + 压缩指令) | llm`,`config={"metadata":{"output_type":"text"}}`,供 in-loop 压缩节点复用。 -- `assistant_text(value)`:str/list/dict 多形态归一为纯文本(dict 依次尝试 text/content/input/output/query/answer/result 键,失败 json.dumps)。 -- `make_attachment_text(category, file_path, extra="")`:生成附件描述文本——图片(含尺寸 `[用户提供了图片,文件路径: ...,尺寸: ...]`)、音频(路径 + extra)、有 extra 时直接返回 extra(如 ASR 结果/文档内容)、附件文件/通用格式。 -- `Documents_process`(静态方法类):`doc_classify(file_path)` 按扩展名+MIME 返回 `plain_text|docx|pdf|image|audio|tabular`(不支持抛 ValueError);`process_plain_text`(UTF-8 读取);`process_docx`(python-docx,按文档顺序遍历段落/表格,run 中提取 `N` 占位与图片字节,表格转 ``,支持"表1/图1"短标题缓存合并);`process_pdf`(pypdf,逐页文字 + `N` 占位 + 图片字节);`process_pptx`(python-pptx 提文字 + `ppt_utils.convert_pptx_to_svg` 提布局,SVG 内 base64 data URL 替换为 `#[内嵌图片 N]` 占位,输出 `N[文字][布局]` 结构);`process_tabular`(csv → `
Sheet1...`;xlsx openpyxl 多 sheet;xls xlrd 日期转可读);`process_image`/`process_audio`(读字节,文本为空);`process(file_path)` 统一分发入口(供 `core/llm._preprocess_messages` 调用)。 -- `audio_file_to_text(audio_path)`:**ASR 调用链** —— 非 wav 先用 pydub `AudioSegment.from_file` 转临时 wav(`tempfile.mkstemp(prefix="asr_", suffix=".wav")`);读字节 → base64 → `data:audio/wav;base64,...`;payload `messages=[{role:user, content:[{"type":"audio_url","audio_url":{"url": data_url}}]}]`;POST `{ASR_BASE_URL.rstrip('/')}/v1/chat/completions`(timeout=120);返回 `choices[0].message.content.strip()`;finally 删除临时 wav。 -- **附件存储规则(归纳)**: - - 用户上传附件:`web/ui_session._rewrite_user_files_to_dir` 把外部文件 `shutil.copy2` 复制到 `SESSIONS_ROOT/{session_id}/`(会话根目录),命名 `{清洗后文件名主干}_{session_id}_{递增序号}{ext}`(`_next_session_copy_name`);已在该目录内的跳过。 - - 单 turn 附件目录:`turn_files_dir` = `sessions/{sid}/{turn_id}/files`。 - - 工具产物(tool_call_recorder):直接写 `TOOL_CALLING_ROOT/{session_id}/`,命名 `_artifact_{工具名}_{序号}.png`(图片)、`_file_{工具名}{序号}{ext}`(send_file 文件),音频不落盘只存路径元信息。 - -### image_utils.py —— 图片多模态适配 - -- `IMAGE_FILE_EXTENSIONS = {.png,.jpg,.jpeg,.bmp,.gif,.webp,.ico}`。 -- `resize_image_if_needed(image_path_or_bytes, max_size=IMG_SIZE=768)`:超长边等比缩放(LANCZOS);RGBA/LA 透明图贴白底后转 JPEG;JPEG quality=70 + optimize + progressive,PNG 走 optimize。 -- `image_to_base64`:路径/字节 → base64(失败 None)。 -- `_mime_from_magic`:按魔数判 jpeg(`FFD8`)/png(`\x89PNG`)/gif,默认 jpeg。 -- `image_bytes_to_openai_image_url_part(image_bytes, detail=None)` / `image_path_to_openai_image_url_part(file_path, detail=None)`:统一返回 `{"type":"image_url","image_url":{"url": "data:{mime};base64,...", "detail": 可选}}`。供 `core/nodes.process_tool_artifact`(工具截图注入对话)与 `core/llm`(用户上传图片)使用。 - -### tool_call_utils.py —— 工具执行钳点 - -- `_invoke_with_timeout(tool, tool_call_input, timeout)`:daemon 线程执行 `tool.invoke`;**用 `contextvars.copy_context()` 把调用方上下文(session/turn、agent 名称、sub_agent_context、tool_call_id 等 ContextVar)复制进线程**(保证并行工具/子 Agent 隔离);`t.join(timeout)`,超时返回 None;工具异常在主线程 re-raise(保留 ABORTED_BY_USER 冒泡)。 -- `invoke_tool_and_build_message(tool, tool_name, tool_args, tool_call_id="", timeout=None)` → `(content, artifact)`。**工具执行前的唯一钳点,顺序**: - 1. **工作空间越界拦截**:`workspace_policy.decision(tool_name, tool_args) == "block"` → 直接返回 `build_block_message(tool_name, tool_args)`(策略模块异常不阻断执行)。 - 2. **人工确认钳点**(human_interaction 除外):`classify_tool_execution(...)=="confirm"` 或 workspace decision=="approve"(附 `check_violation` 说明)→ `ask_human(session_id, meta)`,meta 含 `{type:"tool_call", title:"待确认执行工具:{name}", args, instruction, options:["approve","reject","skip"], policy_note, agent_name, is_sub_agent, resolved:false}`;decision reject/skip → 返回 `"用户拒绝/已跳过此工具调用({name}):{hint}"`;timeout → `"等待用户确认超时,未执行工具"`;approve 继续。`RuntimeError`(ABORTED_BY_USER)向上冒泡。 - 3. 超时取值:显式 timeout > `config_manager.get_tool_timeout(tool_name)`(缺省 `DEFAULT_TOOL_TIMEOUT` 300)。 - 4. `human_interaction` 为阻塞工具**绕过超时包装直连 invoke**(内部 ask_human 的 ABORTED_BY_USER 直接冒泡 runtime 按暂停处理);其余走 `_invoke_with_timeout`,`None` → `"工具执行超时({n}s)"`。 - 5. 返回归一化:ToolMessage → (content, artifact);tuple → (str(t0), t1);否则 str(result)。 -- `json_safe(value)`:递归转 JSON 可序列化(UUID→str、datetime→isoformat、dict/list/tuple 递归、其余 str)。 -- `normalize_tool_call(tool_call)`:兼容 dict 或对象,args 为 JSON 字符串时解析(失败 `{"raw": args}`),输出固定 `{"id","name","args"}`。供 `core/routing` 判断工具执行策略/提取待确认项。 -- `split_inline_thinking(text)`:按**最后一个** `` 拆分(兼容 `"思考\n\n\n回复"` 与 `思考回复`),返回 (thinking, reply);无标记 → ("", text)。 -- `extract_reasoning_text(msg)`:思考开启(xhigh/max/ultra)优先 `additional_kwargs.reasoning_content`,其次 content 内联 `` 块;思考关闭(low/high)content 即一句话反思直接返回。供 nodes 与 tool_call_recorder 提取思维链。 -- `format_args_summary(args, max_len=80)`:`json.dumps(default=str)` 截断 + "...",供死循环检测与记录展示。 - -### ppt_utils.py —— PPT 读取/预览(仅 pptx→SVG,不生成 PPT) - -- 模块头明确:**原先 svg→pptx 制作路线已移除**,仅保留 PPT 读取/解析与预览渲染(自研 PPTX 解析,不依赖外部 ppt-master 项目)。 -- 常量:`EMU_PER_INCH=914400`、`EMU_PER_PX=9525`、`HUNDREDTHS_PT_PER_PX=75`、`ANGLE_UNIT=60000`;XML 命名空间 NS_A/NS_P/NS_R/NS_REL/NS_SVG;`_ns`/`_etree_fromstring`。 -- 单位换算:`emu_to_px`、`px_to_emu`、`fmt_num`(去尾零)。 -- 颜色:`_resolve_srgb`(`srgbClr`→`#RRGGBB`)、`_find_color_elem`、`_resolve_color`(srgbClr/schemeClr/sysClr/prstClr + alpha)、`_parse_color_simple`。 -- `OoxmlPackage(pptx_path)`:zipfile 打开,读 `_rels/.rels` → presentation.xml(`sldSz` 取画布尺寸 px)→ `sldIdLst` 按顺序列出 slide → 每 slide 解析 layout/master 链(`_resolve_related`);`resolve_theme(master)` 从 master→theme 的 `clrScheme` 取主题色映射;`iter_slides`/`read_media`/`close`(支持 with)。 -- `Xfrm`(坐标变换):x/y/w/h/rot/flip_h/flip_v/ch_*(group),`to_svg_transform` 生成 rotate/translate+scale 字符串;`parse_xfrm`。 -- 预设几何 `_convert_prst_geom`:rect/roundRect(rx=min*0.05)/ellipse/line。 -- `_resolve_fill_svg`:noFill→`fill:none`,solidFill→hex,默认 `#CCCCCC`。 -- `_convert_picture`:媒体按扩展名映射 MIME,内联 `data:...;base64` 的 `` 元素(preserveAspectRatio="none")。 -- `_convert_txbody`:bodyPr 内边距/锚点/换行;段落对齐(l/ctr/r→start/middle/end);run 级 sz(/100 得 px)、b/i、latin/ea typeface、颜色;行高 1.25、y = inner_y + font_size + idx*1.25*font_size;超出形状高度截断;wrap none 加 `white-space:pre`。 -- `ShapeNode`(kind: shape/picture/group/graphic)+ `walk_sp_tree`(递归遍历 sp/cxnSp、pic、grpSp、graphicFrame,读 hidden)。 -- 主入口 `convert_pptx_to_svg(pptx_path)` → `PptxToSvgResult{slides:[SlideSvgResult{index, svg, media_files}], canvas_px, theme_colors}`:每页画背景 rect → 遍历形状树 `_convert_node` → 组装 ``。`_convert_shape_to_svg`/`_convert_pic_to_svg`/`_convert_group_to_svg` 递归实现。 - ---- - -## 五、`C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src\agent\memory\system_prompt.py`(提示词体系) - -- `_BASE_PROMPT`(基础主提示词,未命名 MAIN_SYSTEM_PROMPT,实际入口为 `get_main_system_prompt()`):多模态桌面操作助手。要点:理解意图高效完成任务;图片/音频/文件直接多模态分析,**不为此调用截图等无关工具**;只有信息确实无法获取才调工具;拿到工具结果必须观察实际返回继续推进、严禁编造;**同一页面 GUI 连续操作合并为一次 gui_tool 调用(actions 列表),严禁逐个单步**;复杂任务才用 todo_list 拆解并 done_step 标记进度,简单任务直接执行;完成时总结。 -- `_WS_TAIL`:`{workspace_dir}` 占位的工作空间配置(代码/脚本/图片默认存工作空间;terminal_execute 默认 cwd;doc_tool 用绝对路径;越界访问拦截或审批)。 -- `get_main_system_prompt(workspace_dir=None)` = `_BASE_PROMPT + _reflection_section() + _WS_TAIL`;`set_current_workspace(dir)` 热改 `_current_workspace_dir`。 -- `get_cron_system_prompt(workspace_dir=None)` = `CRON_MODE_PROMPT.format(workspace_dir=...) + _reflection_section()`(.format 失败时替换占位符兜底)。 -- `SUMMARY_SYSTEM_PROMPT`:Summary Agent 摘要助手(客观准确、抓要点、简洁、保原意;默认压到 20%~30% 或 3~5 句;对话记录总结主题/立场/共识/分歧/行动项;过短回复"内容过短,无法生成有效摘要";多语言同语言输出)。 -- `VISION_SYSTEM_PROMPT`:专业图像理解助手(客观中文描述、命名品牌/时间/地点/人物、多图编号「图1」「图2」、看不清如实说明、结合图片回答问题)。 -- `DB_SYSTEM_PROMT`(注意拼写为 PROMT):PostgreSQL 数据分析助手(只读 SELECT/WITH、先展示表/查结构/验证后执行、结果归纳成人话、安全底线、非 DB 问题直接回复)。 -- `_PLAN_CORE_RULES`(Plan 模式核心规则):任务模糊先 `human_interaction` 确认;计划写入工作空间 `.minorAgent/任务名.md`(目标/步骤/产出物);`send_file` 发给用户确认,**确认前不得执行**;每步完成 doc_tool 更新 `[x]` 进度;完成总结并告知计划位置。 -- `PLAN_MODE_PROMPT = _PLAN_CORE_RULES + "\n\n" + _BASE_PROMPT`;`get_plan_system_prompt` = PLAN_MODE_PROMPT + 反思段 + WS_TAIL。 -- `CRON_MODE_PROMPT`:无人值守自主完成;**慎用 human_interaction**(用户大概率不在场);复杂任务 todo_list;时效性判断(滞后说明/已过时效跳过);执行汇报(做了什么/关键结果/是否有异常);**【强制规则】PPT 制作、代码项目等复杂产出或系统操作类任务必须先调 skill_router 查询 Skill 并严格遵循**;可自用 cron_manager 管理定时任务;无人值守越界访问直接拦截。 -- `REFLECTION_PROMPT`(固定反思引导):不需要工具直接回复不输出反思;需要工具先一句话反思再调用;GUI 操作未命中按最新截图重估坐标重试;成功则继续。**gui_tool 批量调用规则**同上(一次 actions 传全部动作)。 -- `_reflection_section()`:**思考关闭档位(low/high)注入 REFLECTION_PROMPT 到系统提示词前缀(前缀缓存友好),思考开启档位(xhigh/max/ultra)返回空**(保持模型原生思考语义)。惰性导入 env_utils 防循环依赖。 -- `SUB_AGENT_OOM_PROMPT`:`{total_tokens}/{threshold}` 占位,子 Agent 上下文超限时注入——立即停止调用工具、整理进度(已完成/关键结果/问题原因)、直接文字回复主 Agent。 -- Skill Router 相关:`SKILL_ROUTER_PROMPT`(从 Skill 列表选 1~3 个,每行一个 name,无匹配输出 NONE)、`SKILL_ROUTER_NO_MATCH_MSG`/`SKILL_ROUTER_EMPTY_MSG`/`SKILL_ROUTER_NO_QUERY_MSG`/`SKILL_ROUTER_NO_SKILLS_MSG`。 -- `DEFAULT_SUB_AGENT_PROMPT`(子 Agent 默认提示词):主 Agent 委派的专业执行助手;专注任务不越权;工作流程(接收→工具逐步完成→清晰摘要→复杂先 todo_list);关键准则(聚焦/高效/如实/按需用工具/结果导向);输出格式(简洁概括、分点编号、`[完成]` 标记)。 -- `get_default_sub_agent_prompt()` = `DEFAULT_SUB_AGENT_PROMPT + _reflection_section()`。 - ---- - -## 六、`C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src\agent\agents\agent_runtime.py`(动态 Agent 工厂 + Web 入口) - -- `AgentMode = Literal["agent","plan","cron"]`;缓存 `_all_agent_runtimes: dict[str, AgentRuntime]`、`_main_agent_name`。 -- `_build_llm_for_agent(cfg)`:从 `load_models()`(env_config 的 models 列表)按 `cfg.llm_model_id` 找模型配置,构造 `Multimodel_LLM(model, api_key, base_url, timeout, max_retries, extra_body=get_thinking_extra_body())`——深度思考由全局 THINKING_LEVEL 档位控制,取代 per-model enable_thinking 开关;未匹配回退 `build_default_llm()`(优先主 Agent 配置模型)。 -- `_build_agent_runtime(cfg)`:① 构建 LLM;② `get_tools()` 全量按 `cfg.tools` 过滤;**主 Agent(role=="main")强制加载 doc_tool 与 todo_list**(不可移除);③ `agent_llm.bind_tools(agent_tools, tool_choice="auto")`;④ `build_agent_graph(tools, model_with_tools, trajectory_rounds=cfg.trajectory_rounds)`;⑤ 组装 `AgentRuntime(name, description, graph, llm, tools, tools_by_name, system_prompt, max_iterations, trajectory_rounds)`。 -- `_refresh_subagent_tool_description()`:遍历非 main 的 runtime 收集 `sub_names`/`sub_descriptions`,调用主 Agent 中 `CallSubAgentTool._refresh_description(sub_names, sub_descriptions)`,让主 Agent 知晓可委派的子 Agent。 -- `build_all_agent_runtimes()`:`load_agent_configs()` 只构建 enabled;第一个 `role=="main"` 记为 `_main_agent_name`;无 main 时自动把第一个设为主;**先刷新 call_subagent 描述,再重建主 Agent**(bind_tools 携带最新子 Agent 列表)。 -- 取值函数:`get_main_agent_runtime()`/`get_all_agent_runtimes()`/`get_agent_runtime(name)`(缓存空则自动构建)。 -- `reload_all_agent_runtimes()`:清空重建;同步模块级引用 `main_agent_runtime/agent_graph/tools/TOOLS_BY_NAME/llm`;再刷新子 Agent 描述;返回 `{"agents":[{name,description,tools_count,max_iterations}], "main_agent"}`。 -- `execute_agent(chat_history, session_id="test", agent_mode="agent")`:**Web/API 层入口**,直接委托 `execute_runtime_agent(runtime=main_agent_runtime, ...)`。 -- 模块导入时(底部)即执行 `build_all_agent_runtimes()` 并缓存 `main_agent_runtime/agent_graph/tools/TOOLS_BY_NAME/llm`。 - ---- - -## 七、`C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src\agent\history\`(会话/工具调用记录) - -### session_storage.py —— 转发层 - -纯转发 `agent.core.storage`:`ConversationRecordRepository`/`JsonRecordRepository`/`MysqlRecordRepository`(Protocol+实现)、便捷函数 `save_turn`/`load_messages`/`load_turn_messages`/`list_turn_ids`/`save_tool_calls`/`get_turn_record`/`get_latest_turn_record`/`list_turn_records`/`persist_live_records`/`load_session_extra`/`save_session_extra`/`list_session_ids`/`get_last_turn_id`/`delete_turns_after`/`delete_tool_records`/`delete_session`,作用域 `set_storage_scope`/`get_storage_scope`/`get_record_repository`。消费方无需感知 json/mysql 后端。 - -### tool_call_recorder.py —— 工具调用记录器(live 实时推送 + 持久化) - -- 全局:`TOOL_CALLING_ROOT = Path(SESSIONS_ROOT)`(默认 `history/sessions`;cron 子进程改指 CRON_ROOT)。 -- 上下文 ContextVar:`_current_turn_id`/`_current_session_id`(带线程安全 fallback `_current_turn_fallback`/`_current_session_fallback`)、`_sub_agent_context`(子 Agent 执行标记)、`_current_tool_call_id`(并行工具隔离)。配套 `set_current_turn`/`get_current_turn`/`set_current_session`/`get_current_session`/`set_current_tool_call_id`/`get_current_tool_call_id`/`is_sub_agent_context`。 -- `start_live_turn(session_id)`:turn_id = `datetime.now().strftime("%Y%m%d_%H%M%S")`;`init_live_turn_with_id(session_id, turn_id)`:清空该 session 的 `_live_records`、记 `_turn_start_times`、加 `_active_sessions`、清 pending thinking。 -- `record_tool_call_live(session_id, name, args, tool_call_id=None)`:条目 `{id, name, args(JSON安全), args_summary(format_args_summary 40 字符), status:"running", result_text:"", 可选 thinking}`;**消费 pending thinking**(上一条反思附加到本条);子 Agent 上下文路由到 `_sub_live_records[(session, tool_call_id)]`(隔离 key 取父级 call_subagent 的 tool_call_id,并行/同名子 Agent 互不串扰);结尾 `_notify_live(session_id)`。 -- `record_tool_result_live(session_id, name, result_text, artifact=None)`:从后往前找同名 running 条目 → status="done"、result_text 截 500 字、`result_images`(`_artifact_image_data_urls`:artifact 内 bytes/image_bytes/images 列表/data_url/http URL → data URL 列表)、`result_audio`(`_artifact_audio_info`,仅路径元数据)、`result_file_info`(`_artifact_file_infos`:file/image/folder);json 后端额外 `_safe_persist_live_records` 增量落盘防崩溃丢失。 -- `record_reflection_live(session_id, content)`:写 `_think_store`(去重,summary=content[:60] 换行转空格)并置 pending thinking;子 Agent 上下文只写 `_sub_pending_thinking`(不独立显示,嵌套在各子工具条目的 thinking 中)。`get_live_reflections`/`clear_live_reflections`。 -- **订阅推送**:`subscribe_live(session_id)` → Queue(maxsize=64);`unsubscribe_live`;`_build_live_snapshot(session_id)` = `{tool_calls: get_live_tool_calls(), reflections, started_at, human_requests(惰性导入 human_request)}`;`_notify_live` 向所有订阅者 `put_nowait(snapshot)`(满则丢最旧放最新)。cron 子进程正是复用该 subscribe_live 转发到主进程。 -- `get_live_tool_calls(session_id)`:内存优先;空且 session 活跃 → 回退磁盘最近 `tool_*.json`;session 已结束直接返回空(避免跨 turn 残留);并把 `_sub_live_records` 按 tool_call_id 精确注入 call_subagent/agent_call 条目的 `sub_tool_calls`(无 id 回退"最后一条 running")。 -- `get_turn_started_at(session_id)`:轮次开始时间戳(内存 → 磁盘回退)。 -- `end_live_turn(session_id)`:清 turn/反思/pending、清理该 session 所有子 Agent 槽、`_active_sessions.discard`、返回并弹出 `_live_records`。 -- `save_aborted_turn(session_id, turn_id)`:Abort 时把内存 live 记录转 save_turn 兼容格式落盘(meta 含 `aborted: True`)。 -- `extract_tool_calls_from_messages(messages)`:从图执行结果消息提取完整工具链——AIMessage.tool_calls(首个附加 thinking,`extract_reasoning_text`)→ pending;ToolMessage 配对 → result_text + artifact 落盘(`_save_artifact_files` → `_artifact_{name}_{idx}.png` / `_file_{name}{ext}`)+ result_images/audio/file_info;未配对 pending 标记 done。 -- `save_turn(session_id, turn_id, user_message, messages)`:工具调用持久化唯一入口。① extract;② 产物字节直接写 `session_tool_dir(session_id)`;③ 从存储读 live 记录(`get_turn_record`)合并(`_merge_extracted_with_live`:按索引优先、name+顺序回退,从 live 补 thinking/args_summary);④ `_inject_sub_traces_to_record` 注入子 Agent 轨迹;⑤ `session_storage.save_tool_calls(..., meta={started_at, ended_at, duration})`(轮次级计时,单条工具计时已移除)。 -- 读取 API:`list_turns(session_id, limit=5)`(倒序)、`get_latest_turn`、`get_tool_calls_for_latest_turn`、`get_tool_calls_for_turn`、`get_turn_meta`(started_at/ended_at/duration)、`get_thinking_for_turn`(从 tool_calls 各条 thinking 提取链)。 -- `_tool_calls_from_turn_record(record, session_id)`:为每条工具调用补 `result_files_full`(`session_tool_dir(sid)/fname` 绝对路径)、`result_images`(`_artifact_*.png` → `/api/tool-image?path=...&session={turn_id}`;仅当 live 无 data URL 时回退)、`result_download_files`(`_file_*` → `/api/tool-file?path=...&session={turn_id}`)、`result_audio.api_url`、`turn_id`。 -- 子 Agent 轨迹:`_sub_trace_key = f"{session_id}/{turn_id}/{agent_name}[/{tool_call_id}]"`;`record_sub_agent_trace`/`get_sub_agent_traces_for_turn`(内存 `_sub_traces_store`,并行按 tool_call_id 精确隔离)。 -- Token 统计:`set_session_tokens(session_id, total_tokens, breakdown)`(内存 + 经 session extra 持久化;breakdown={messages, tools, system} 估算占比由 `nodes._estimate_token_breakdown` 算)、`get_session_tokens`(内存→磁盘→0)、`get_session_token_breakdown`、`clear_session_tokens`。 -- **目录结构(session_tool_dir)**:`history/sessions/{session_id}/` 下:`turn_*.json`(对话消息,由 ui_session 写)、`tool_*.json`(每 turn 工具调用记录 + started_at/ended_at/duration)、`_artifact_*.png`/`_file_*`(工具产物字节)、session extra(`agent_meta` 键含 cursor/compressed_content/dynamic_tail_history,`tokens`/`token_breakdown`);turn 附件在 `{session_id}/{turn_id}/files/`。cron 场景整体换到 `history/cron/{task_id}/`。 - ---- - -## 八、`C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src\web\ui_session.py`(会话 UI 状态管理) - -- `_AUDIO_EXTENSIONS = AUDIO_FILE_EXTENSIONS`(别名,向后兼容);`_memory_session_ids: set[str]`(内存会话 id 缓存防冲突)。 -- `session_dir(session_id)` = agent_utils 的 `sessions/{sid}`。 -- `list_session_ids_ordered()`:`session_storage.list_session_ids()`(mysql 按 sessions 表 updated_at 倒序;json 扫描目录含 turn_*.json 的按 mtime 倒序)。 -- `new_timestamp_session_id()`:`datetime.now().strftime("%Y%m%d_%H%M%S")`,与磁盘+内存已有 id 冲突时追加 `_1`、`_2` 后缀;新 id 加入 `_memory_session_ids`。 -- `prune_memory_session_ids()`:把已持久化的 id 从内存集合移除,防无限增长(每次会话操作后调用)。 -- `delete_session(session_id)`:删内存缓存 → `shutil.rmtree(session_dir)` + `session_tool_dir` → `session_storage.delete_session`(后端无关)。 -- `_build_user_chat_content(query)`:`query={text, files, file_names}` → 多模态 content:图片文件 → `{"type":"image_url","image_url":{"url": filepath}, "name": 原名}`;其余(含音频)→ `{"path": filepath, "name": 原名}`(**音频作为普通文件 part,不再生成 audio part**;麦克风录音由 `/api/chat/start` 阻塞 ASR 转 text,不走此函数);文本 → `{"type":"text","text": text}`;仅一段纯文本时直接返回 str。 -- `_sanitize_filename_stem(name)`:清洗 `<>:"/\|?*` 为 `_`、去尾部空格点、空回退 "file"。 -- `_next_session_copy_name(dest_dir, session_id, original_name, ext)`:`{stem}_{session_id}_{max_index+1}{ext}`(扫描目录现有同名前缀取最大序号递增)。 -- `_rewrite_user_files_to_dir(user_msg, dest_dir, session_id)`:**附件从临时目录复制到会话目录的核心**——遍历 content list,text 直接保留;对 path/image_url.url/audio_path 源文件:若已在 dest_dir 内跳过;否则 `_next_session_copy_name` 生成唯一名 → `shutil.copy2(src, dest)` → 原地更新 `piece["path"]`(image_url 同时更新 `image_url.url`,audio 更新 `audio_path`)。 -- `rewrite_user_message_files_to_session_dir(user_msg, session_id)`:向后兼容包装,dest = 会话根目录。 -- `_serialize_user_msg_for_turn(user_msg)`:序列化为 turn 文件 user 条目——str 拆 text part;list 中 text 保留、附件转 `{"type":"image_url"|"file", "relpath": 相对 SESSIONS_ROOT 的路径, "name"}`。 -- `_turn_user_entry_to_content(user_entry)`:还原 UI/LLM content——relpath 拼绝对路径并校验在 SESSIONS_ROOT 内;image_url part 文件存在给 `{"type":"image_url","image_url":{"url": abs}, "path", "name"}`,缺失累计 `any_image_missing`;历史 audio part 降级为普通文件;file part 缺失 → `[附件已丢失: name]`;有图丢失补 `[图片已丢失]`;纯文本单段直接返回 str。 -- `_serialize_assistant_entry`:`{text, output_type, turn_id}`;voice 输出额外 `autoplay`/`audio_duration_seconds`/`audio_relpath`(相对 SESSIONS_ROOT)。 -- `_serialize_message_for_turn(msg)`:按 role 分发 → `{"role":"user","user":...}` / `{"role":"assistant","assistant":...}`。 -- `sync_turn_from_chat_history(session_id, chat_history, latest_user_image_description)`:**turn 级消息落盘**——`get_last_turn_id` 得 turn_id;从最后一条 user 消息起截取本轮消息区间;逐条序列化并把 `turn_id` 注入 meta;`save_turn_messages(session_id, turn_id, messages_out)`(内部 → `session_storage.save_turn`)。 -- `rollback_turns_from_history(session_id, chat_history)`:从 chat_history 找最后一个 turn_id,`delete_turns_after(session_id, keep_turn_id)` 删除多余 turn 目录。 -- `_assistant_turn_entry_to_content`/`_assistant_turn_entry_to_message`:还原 assistant 消息(text/voice → `{"type":"text"}` + `{"type":"audio","audio_path","autoplay","duration_seconds"}`,meta 含 output_type/autoplay/pending_action/turn_id/audio{path,format:"wav",sample_rate:24000})。 -- `_load_ui_messages_from_turn(session_id)` / `load_ui_messages_for_session(session_id)`:从 turn 文件重建 UI 消息列表(无数据返回空列表)。 -- `rewrite_user_files_for_last_turn(chat_history, session_id)`:若末条为 user 消息,将其附件复制到会话目录(API 层添加用户消息后立即调用)。 -- `add_message(chat_history, query)`:`_build_user_chat_content` → 无有效内容返回 None,否则 append `{"role":"user","content": user_content}` 并返回新 chat_history(API 层构造用户消息核心)。 -- `clear_ui_and_turns(session_id)`:`delete_turns_after(session_id, "00000000_000000")` 全删,返回 `([], {"text":"","files":[]})`。 - ---- - -## 附:跨模块关键链路速查 - -- **语音输入**:`/api/chat/start`(阻塞 ASR)→ `agent_utils.audio_file_to_text` → POST `{ASR_BASE_URL}/v1/chat/completions`(audio_url data URL,无文本提示)→ `choices[0].message.content`。 -- **语音输出**:`StreamingTTSClient.stream_tts`(SSE 逐事件收 PCM f32le chunk)→ 前端 Web Audio 播放;服务端新请求自动打断旧请求。 -- **图片入模**:`image_utils.image_path_to_openai_image_url_part`(缩放→base64→data URL)或 `Documents_process.process`(docx/pdf/pptx 提取文本+图片字节)。 -- **工具执行**:`tool_call_utils.invoke_tool_and_build_message`(workspace block → confirm ask_human → 超时 → 归一化 ToolMessage)→ `tool_call_recorder` 实时/持久化记录 → SSE 推送前端。 -- **Cron**:`CronScheduler`(5s 扫描 + 时段互斥)→ 子进程 `agent.cron.runner`(重定向存储根 + headless + execute_agent(agent_mode="cron"))→ HTTP 回传 `:8765/api/cron/internal/live|finished` → 主进程 `cron.live` 分发 SSE。 diff --git a/electron/main.js b/electron/main.js index ae0dbcd..7af2cd0 100644 --- a/electron/main.js +++ b/electron/main.js @@ -27,10 +27,10 @@ const SRC_DIR = isPackaged ? path.join(process.resourcesPath, 'app', 'src') : path.join(AGENT_DIR, 'src'); -// 配置文件路径 (src/agent/config/env_config.json) -const CONFIG_PATH = path.join(SRC_DIR, 'agent', 'config', 'env_config.json'); -const CONFIG_DIST_PATH = path.join(SRC_DIR, 'agent', 'config', 'env_config_dist.json'); -const THEME_CONFIG_PATH = path.join(SRC_DIR, 'agent', 'config', 'theme_config.json'); +// 合并后的唯一配置文件 (src/agent/agents/config.json),分区见 agent/core/config_manager.py +const CONFIG_PATH = path.join(SRC_DIR, 'agent', 'agents', 'config.json'); +const CONFIG_DIST_PATH = path.join(SRC_DIR, 'agent', 'agents', 'config_dist.json'); +const THEME_CONFIG_PATH = CONFIG_PATH; const HOST = '127.0.0.1'; const PORT = 8765; @@ -44,7 +44,7 @@ function getThemeMode() { try { if (fs.existsSync(THEME_CONFIG_PATH)) { const cfg = JSON.parse(fs.readFileSync(THEME_CONFIG_PATH, 'utf-8')); - const id = cfg.theme || ''; + const id = (cfg.theme || {}).theme || ''; if (DARK_THEMES.has(id)) return 'dark'; if (id) { // 未知主题按 id 推断(与 themes.js 的回退逻辑一致) @@ -81,7 +81,8 @@ function isFirstRun() { return true; } try { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + const merged = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + const config = (merged && merged.env) || {}; const models = config.models || []; if (models.length === 0) return true; if (models[0].id === 'SETUP_REQUIRED') return true; @@ -94,26 +95,25 @@ function isFirstRun() { // ==================== 配置管理 ==================== function writeEnvConfig(userInput) { - let config; - const templatePath = CONFIG_DIST_PATH; - if (fs.existsSync(templatePath)) { - config = JSON.parse(fs.readFileSync(templatePath, 'utf-8')); - } else if (fs.existsSync(CONFIG_PATH)) { - config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); - } else { - config = {}; + // 读入现有 config.json(保留其他分区),仅覆写 env / gui 分区 + let merged = {}; + if (fs.existsSync(CONFIG_PATH)) { + try { merged = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch {} + } + const templatePath = CONFIG_DIST_PATH; + let config = {}; + if (fs.existsSync(templatePath)) { + const tpl = JSON.parse(fs.readFileSync(templatePath, 'utf-8')); + config = (tpl && tpl.env) || {}; + } else if (merged.env && typeof merged.env === 'object') { + config = merged.env; } - - config.WORKING_DIR = isPackaged ? process.resourcesPath : AGENT_DIR; config.USER_NICKNAME = userInput.nickname || (config.USER_NICKNAME || ''); config.USER_PYTHON_PATH = userInput.pythonPath || (config.USER_PYTHON_PATH || ''); config.EMAIL_ADDRESS = userInput.emailAddr || (config.EMAIL_ADDRESS || ''); config.EMAIL_AUTH_CODE = userInput.emailCode || (config.EMAIL_AUTH_CODE || ''); config.WEB_SEARCH_API_KEY = userInput.webSearchKey || (config.WEB_SEARCH_API_KEY || ''); - config.ASR_BASE_URL = userInput.asrUrl || (config.ASR_BASE_URL || ''); - config.STREAMING_TTS_URL = userInput.ttsUrl || (config.STREAMING_TTS_URL || ''); - config.RAG_BASE_URL = userInput.ragUrl || (config.RAG_BASE_URL || ''); config.LLM_CONTEXT_WINDOW = String(userInput.llmContextWindow || config.LLM_CONTEXT_WINDOW || 262144); config.MINI_COMPRESS_RATE = String(userInput.miniCompressRate || config.MINI_COMPRESS_RATE || 0.4); @@ -133,6 +133,10 @@ function writeEnvConfig(userInput) { // 向后兼容:优先读 STORAGE_BACKEND,回退旧的 CRON_STORAGE_BACKEND config.STORAGE_BACKEND = userInput.cronStorageBackend || config.STORAGE_BACKEND || config.CRON_STORAGE_BACKEND || 'json'; + // 保留本地服务模型(ASR/TTS/RAG/ImageGen):配置向导只覆盖用户主模型,不清除这四个注册项 + const LOCAL_SERVICE_MODELS = new Set(['Qwen3-ASR-1.7B', 'VoxCPM1.5', 'Qwen3-Embedding-0.6B', 'Z-Image-Turbo']); + const preservedModels = (config.models || []).filter((m) => LOCAL_SERVICE_MODELS.has(m && m.model)); + config.models = [{ id: 'user_configured', name: userInput.model, @@ -141,13 +145,16 @@ function writeEnvConfig(userInput) { base_url: userInput.baseUrl, timeout: userInput.timeout || 60, max_retries: 0, - }]; + }, ...preservedModels]; - config.gui_model_id = userInput.guiModelId || userInput.model || (config.gui_model_id || ''); + // GUI 模型选择归属 gui 分区 + merged.gui = merged.gui || {}; + merged.gui.gui_model_id = userInput.guiModelId || userInput.model || (merged.gui.gui_model_id || ''); - const configDir = path.dirname(CONFIG_PATH); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8'); + merged.env = config; + + fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); + fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), 'utf-8'); console.log('[Electron] CONFIG_PATH:', CONFIG_PATH); } @@ -157,7 +164,8 @@ function findSystemPython() { // 1. 优先使用用户配置的 Python 路径 try { if (fs.existsSync(CONFIG_PATH)) { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + const merged = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + const config = (merged && merged.env) || {}; if (config.USER_PYTHON_PATH && fs.existsSync(config.USER_PYTHON_PATH)) { return config.USER_PYTHON_PATH; } @@ -340,6 +348,21 @@ async function createMainWindow(themeMode) { return { action: 'deny' }; }); + // 阻止应用内主窗口导航到外部网页(聊天消息里的 默认会顶掉整个应用页, + // 且应用壳无地址栏/返回按钮,导致"跳走后回不来");外部链接一律交给系统浏览器打开。 + // 本地源放行:file://(splash/打包页)与 127.0.0.1/localhost(FastAPI 应用页自身) + mainWindow.webContents.on('will-navigate', (event, url) => { + try { + const u = new URL(url); + const isLocal = u.protocol === 'file:' || + u.hostname === '127.0.0.1' || u.hostname === 'localhost'; + if (!isLocal) { + event.preventDefault(); + require('electron').shell.openExternal(url).catch(() => {}); + } + } catch (e) { /* 非法 URL,忽略 */ } + }); + return mainWindow; } @@ -520,6 +543,21 @@ function readDirRecursive(dirPath, depth, currentDepth, rootDir) { return results; } +ipcMain.handle('shell:openInExplorer', (_event, filePath) => { + // 在系统资源管理器中打开路径(不自动最大化:用 Shell.Application.Open 以还原态打开) + try { + if (!filePath) return false; + const { execFile } = require('child_process'); + const escaped = String(filePath).replace(/'/g, "''"); + const script = `$shell = New-Object -ComObject Shell.Application; $shell.Open('${escaped}')`; + execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true }); + return true; + } catch (e) { + console.error('[Electron] openInExplorer failed:', e.message); + return false; + } +}); + ipcMain.handle('fs:readFile', async (_event, filePath) => { try { const content = fs.readFileSync(filePath, 'utf-8'); @@ -922,15 +960,82 @@ app.on('browser-window-created', (_, win) => { app.on('window-all-closed', () => { if (isLaunching) return; // setup → main 过渡中,不退出 isQuitting = true; + killAllTerminals(); stopFastAPI(); app.quit(); }); app.on('before-quit', () => { isQuitting = true; + killAllTerminals(); stopFastAPI(); }); +// ==================== 内置 PowerShell 终端 ==================== +// 每个标签对应一个 powershell 子进程;面板折叠不 kill,仅标签 x / 右上角 x / 退出时清理。 +const _terminals = new Map(); // id -> { proc, sender } +let _terminalSeq = 0; + +function forceKill(proc) { + try { proc.kill(); } catch (e) { /* ignore */ } + // Windows 兜底:taskkill 结束整个进程树(处理 Ctrl 信号不响应/子进程残留) + try { + execSync(`taskkill /PID ${proc.pid} /T /F`, { stdio: 'ignore' }); + } catch (e) { /* 进程已退出 */ } +} + +function killAllTerminals() { + for (const { proc } of _terminals.values()) { + forceKill(proc); + } + _terminals.clear(); +} + +ipcMain.handle('terminal:spawn', (event, { cwd } = {}) => { + const id = ++_terminalSeq; + let proc; + try { + proc = spawn('powershell.exe', ['-NoLogo'], { + cwd: (cwd && fs.existsSync(cwd)) ? cwd : undefined, + windowsHide: true, + }); + } catch (e) { + return { id, ok: false, error: String(e && e.message || e) }; + } + _terminals.set(id, { proc, sender: event.sender }); + proc.stdout.on('data', (chunk) => { + if (!event.sender.isDestroyed()) event.sender.send('terminal:data', { id, chunk: chunk.toString('utf8') }); + }); + proc.stderr.on('data', (chunk) => { + if (!event.sender.isDestroyed()) event.sender.send('terminal:data', { id, chunk: chunk.toString('utf8') }); + }); + proc.on('exit', (code) => { + _terminals.delete(id); + if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { id, code }); + }); + proc.on('error', (err) => { + if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { id, code: -1, error: String(err.message || err) }); + }); + return { id, ok: true }; +}); + +ipcMain.on('terminal:input', (_event, { id, data } = {}) => { + const t = _terminals.get(id); + if (!t || !data) return; + try { t.proc.stdin.write(data); } catch (e) { /* ignore */ } +}); + +ipcMain.on('terminal:kill', (_event, { id } = {}) => { + const t = _terminals.get(id); + if (!t) return; + forceKill(t.proc); + _terminals.delete(id); +}); + +ipcMain.on('terminal:killAll', () => { + killAllTerminals(); +}); + app.on('activate', () => { if (mainWindow === null) { launchMainFlow(); diff --git a/electron/preload.js b/electron/preload.js index e1258d7..cc7ab09 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -25,6 +25,25 @@ contextBridge.exposeInMainWorld('electronAPI', { openFile: () => ipcRenderer.invoke('dialog:openFile'), saveFile: (defaultPath) => ipcRenderer.invoke('dialog:saveFile', defaultPath), + // ===== 系统集成 ===== + openInExplorer: (filePath) => ipcRenderer.invoke('shell:openInExplorer', filePath), + + // ===== 内置 PowerShell 终端 ===== + spawnTerminal: (cwd) => ipcRenderer.invoke('terminal:spawn', { cwd }), + sendTerminalInput: (id, data) => ipcRenderer.send('terminal:input', { id, data }), + killTerminal: (id) => ipcRenderer.send('terminal:kill', { id }), + killAllTerminals: () => ipcRenderer.send('terminal:killAll'), + onTerminalData: (cb) => { + const handler = (_e, data) => cb(data); + ipcRenderer.on('terminal:data', handler); + return () => ipcRenderer.removeListener('terminal:data', handler); + }, + onTerminalExit: (cb) => { + const handler = (_e, data) => cb(data); + ipcRenderer.on('terminal:exit', handler); + return () => ipcRenderer.removeListener('terminal:exit', handler); + }, + // ===== 应用信息 ===== getAppPath: () => ipcRenderer.invoke('app:getPath'), diff --git a/llm_server/README.md b/llm_server/README.md index 5fbf573..e94cab1 100644 --- a/llm_server/README.md +++ b/llm_server/README.md @@ -85,42 +85,64 @@ sudo apt update && sudo apt install -y ffmpeg libavcodec-dev libavformat-dev lib | `LOCAL_MODEL_DIR` / `MODEL_PATH` / `VOXCPM_MODEL_PATH` | 模型目录 | 全部 | | `CUDA_VISIBLE_DEVICES` | GPU 设备号 | 全部 | | `SPECIFIC_GPU` | 单 GPU 指定(写 `CUDA_VISIBLE_DEVICES`) | image_gen / tts | +| `SERVER_API_KEY` | API Key 鉴权(空=不校验,任意值放行) | 全部 | +| `SERVER_MODELNAME` | 服务模型名(请求 body.model 须匹配;空=不校验) | 全部 | + +> **鉴权与模型名校验**:`SERVER_API_KEY` 为空时服务不校验 API Key(任意值放行); +> `SERVER_MODELNAME` 为空时服务不校验请求中的 `model` 字段。 +> 部署时如需启用,示例: +> ```bash +> SERVER_API_KEY=sk-xxx SERVER_MODELNAME=Qwen3-ASR-1.7B bash start_asr.sh +> ``` ### LLM · 8900 ```bash bash start_llm.sh +# 启用鉴权与自定义模型名 +# SERVER_API_KEY=sk-xxx SERVER_MODELNAME=Qwen/Qwen3.6-35B-A3B-FP8 bash start_llm.sh # 测试 python3 test_llm.py --url http://127.0.0.1:8900 # 非流式 python3 test_llm.py --url http://127.0.0.1:8900 --stream # 流式 ``` > vLLM 加载 Qwen3.6-35B-A3B-FP8,OpenAI 兼容 API,使用 `chat_template.jinja` 格式化对话。 +> `--served-model-name` 由 `SERVER_MODELNAME` 控制(默认 `Qwen/Qwen3.6-35B-A3B-FP8`); +> `SERVER_API_KEY` 非空时传给 vLLM `--api-key`,请求需携带 `Authorization: Bearer `。 ### ASR · 8901 ```bash bash start_asr.sh +# 启用鉴权与自定义模型名 +# SERVER_API_KEY=sk-xxx SERVER_MODELNAME=Qwen3-ASR-1.7B bash start_asr.sh # 测试 python3 test_asr.py --audio /path/to/audio.wav --url http://127.0.0.1:8901/v1/chat/completions ``` +> vLLM 加载 Qwen3-ASR-1.7B,`--served-model-name` 由 `SERVER_MODELNAME` 控制(默认 `Qwen3-ASR-1.7B`)。 + ### TTS · 8902(流式) ```bash bash start_streaming_tts.sh +# 启用鉴权与自定义模型名 +# SERVER_API_KEY=sk-xxx SERVER_MODELNAME=VoxCPM1.5 bash start_streaming_tts.sh # 测试(流式返回 wav 分块) curl -X POST http://127.0.0.1:8902/stream_tts \ -H "Content-Type: application/json" \ - -d '{"text": "你好,这是一个流式语音合成测试。", "request_id": "test-1"}' + -d '{"model": "VoxCPM1.5", "text": "你好,这是一个流式语音合成测试。", "request_id": "test-1"}' ``` > VoxCPM 流式合成,边生成边返回 wav chunk,配合 `reference_audio.wav` 控制音色。 +> 配置了 `SERVER_MODELNAME` 时请求 body 须带 `model` 字段;配置了 `SERVER_API_KEY` 时须带 `Authorization: Bearer ` 头。 ### RAG · 8903(Embedding + Reranker) ```bash bash start_rag_server.sh +# 启用鉴权与自定义模型名 +# SERVER_API_KEY=sk-xxx SERVER_MODELNAME=Qwen3-Embedding-0.6B bash start_rag_server.sh python3 test_rag.py ``` @@ -128,10 +150,12 @@ python3 test_rag.py ```bash LOCAL_MODEL_DIR=./models/Tongyi-MAI/Z-Image-Turbo SPECIFIC_GPU=3 bash start_image_gen.sh +# 启用鉴权与自定义模型名 +# SERVER_API_KEY=sk-xxx SERVER_MODELNAME=Z-Image-Turbo bash start_image_gen.sh # 测试 curl -X POST http://127.0.0.1:8904/generate \ -H "Content-Type: application/json" \ - -d '{"prompt": "一只橘猫坐在窗台上,阳光洒落", "width": 1024, "height": 1024, "return_format": "base64"}' \ + -d '{"model": "Z-Image-Turbo", "prompt": "一只橘猫坐在窗台上,阳光洒落", "width": 1024, "height": 1024, "return_format": "base64"}' \ | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'成功:{d.get(\"success\")} 尺寸:{d.get(\"width\")}x{d.get(\"height\")} 耗时:{d.get(\"elapsed_seconds\",\"?\")}s')" ``` @@ -171,14 +195,15 @@ for p in 8900 8901 8902 8903 8904; do kill $(lsof -t -i:$p); done ## 五、接入 Agent -在 minor Agent 配置向导(或 `env_config.json`)中填入对应地址即可启用各能力: +四个本地服务(ASR / TTS / RAG / ImageGen)在 Agent 的 `env_config.json` **`models` 列表中注册**(设置 → 环境变量 → LLM 模型列表,或直接编辑 `src/agent/config/env_config.json`),`base_url` 指向本地映射端口,`api_key` 留空(对应服务端未启用鉴权)。Agent 运行时按 `model` 字段匹配取得服务地址: -| Agent 能力 | 配置字段 | 示例值 | -|-----------|----------|--------| -| LLM 对话 | `models[].base_url` | `http://localhost:8900/v1` | -| 语音识别 | `ASR_BASE_URL` | `http://localhost:8901/v1` | -| 语音合成 | `STREAMING_TTS_URL` | `http://localhost:8902` | -| 知识库检索 | `RAG_BASE_URL` | `http://localhost:8903` | -| 图像生成 | (由 `image_gen` 工具读取) | `http://localhost:8904` | +| Agent 能力 | 模型列表条目 | 字段示例 | +|-----------|-------------|----------| +| 语音识别 | `name: "ASR 语音识别"` | `model: "Qwen3-ASR-1.7B"`, `base_url: "http://localhost:8901"`, `api_key: ""` | +| 语音合成 | `name: "TTS 语音合成"` | `model: "VoxCPM1.5"`, `base_url: "http://localhost:8902"`, `api_key: ""` | +| 知识库检索 | `name: "RAG 检索"` | `model: "Qwen3-Embedding-0.6B"`, `base_url: "http://localhost:8903"`, `api_key: ""` | +| 图像生成 | `name: "图像生成"` | `model: "Z-Image-Turbo"`, `base_url: "http://localhost:8904"`, `api_key: ""` | +> 💡 若服务端部署时设置了 `SERVER_API_KEY` / `SERVER_MODELNAME`,请把模型条目中的 `api_key` 与 `model` 改成与之一致,Agent 发出的请求会携带 `Authorization: Bearer ` 与 `model` 字段。 +> > 💡 不必自建本服务,Agent 也支持任何 **OpenAI 兼容 API**(云端 Qwen / DeepSeek / OpenAI 等),填入 `base_url` + `api_key` 即可。 diff --git a/llm_server/image_gen_server.py b/llm_server/image_gen_server.py index 6b96dcf..033642d 100644 --- a/llm_server/image_gen_server.py +++ b/llm_server/image_gen_server.py @@ -26,6 +26,8 @@ import torch BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DEFAULT_PORT = 8904 DEFAULT_MODEL_ID = "Tongyi-MAI/Z-Image-Turbo" +DEFAULT_MODEL_NAME = "Z-Image-Turbo" # 默认服务模型名(校验请求 body.model 用) +DEFAULT_API_KEY = "" # 默认空:不校验 API Key(任意值放行) # 本地模型路径:优先读环境变量 LOCAL_MODEL_DIR,未设置则回退到远程 ID LOCAL_MODEL_DIR = os.environ.get("LOCAL_MODEL_DIR", "") @@ -45,6 +47,39 @@ warnings.filterwarnings("ignore", category=FutureWarning) from fastapi import FastAPI, Request, HTTPException from fastapi.responses import JSONResponse, Response +# 服务参数(--api-key / --model-name): +# api_key 为空:不校验(任意值放行);model_name 为空:不校验请求 model 字段 +_model_args: dict = {"api_key": "", "model_name": ""} + + +def _check_api_key(request: Request): + """校验请求 API Key:服务未配置 key(空)时放行任意值;否则校验 Authorization / X-API-Key。 + + 校验失败抛出 HTTP 401。 + """ + api_key = _model_args.get("api_key", "") + if not api_key: + return + header = request.headers.get("Authorization", "") + if header.startswith("Bearer "): + provided = header[len("Bearer "):].strip() + else: + provided = request.headers.get("X-API-Key", "").strip() + if provided != api_key: + raise HTTPException(status_code=401, detail="API Key 无效或缺失") + + +def _check_model_name(body: dict): + """校验请求 modelname:服务未配置模型名(空)时放行任意值;否则要求 body.model 匹配。 + + 校验失败抛出 HTTP 400。 + """ + model_name = _model_args.get("model_name", "") + if not model_name: + return + if str(body.get("model") or "").strip() != model_name: + raise HTTPException(status_code=400, detail=f"模型名不匹配: 期望 '{model_name}',收到 '{body.get('model') or ''}'") + # Lazy-loaded pipeline singleton _pipe = None _pipe_lock = threading.Lock() @@ -129,6 +164,10 @@ async def generate_image(request: Request): except Exception: raise HTTPException(status_code=400, detail="请求体必须为 JSON") + # 鉴权与模型名校验 + _check_api_key(request) + _check_model_name(body) + prompt = (body.get("prompt") or "").strip() if not prompt: raise HTTPException(status_code=400, detail="prompt 不能为空") @@ -194,14 +233,18 @@ async def health(): # ======================== 启动入口 ======================== -_model_args: dict = {} - if __name__ == "__main__": parser = argparse.ArgumentParser(description="Z-Image-Turbo Image Generation Server") parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"服务端口(默认 {DEFAULT_PORT})") + parser.add_argument("--model-name", type=str, default=DEFAULT_MODEL_NAME, + help=f"服务模型名(校验请求 body.model;默认 {DEFAULT_MODEL_NAME},留空则不校验)") + parser.add_argument("--api-key", type=str, default=DEFAULT_API_KEY, + help="API Key(校验 Authorization: Bearer 或 X-API-Key;默认空=任意值放行)") args = parser.parse_args() _model_args["port"] = args.port + _model_args["model_name"] = args.model_name + _model_args["api_key"] = args.api_key import uvicorn uvicorn.run(app, host="0.0.0.0", port=args.port) diff --git a/llm_server/rag_server.py b/llm_server/rag_server.py index 6c4656e..bba2337 100644 --- a/llm_server/rag_server.py +++ b/llm_server/rag_server.py @@ -1,7 +1,9 @@ import os +import argparse import torch import torch.nn.functional as F -from fastapi import FastAPI +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import JSONResponse from pydantic import BaseModel from typing import List from modelscope import AutoTokenizer, AutoModel, AutoModelForCausalLM @@ -16,6 +18,37 @@ quantization_config = BitsAndBytesConfig(load_in_8bit=True) EMBED_MODEL_PATH = os.path.join(BASE_DIR, "models", "Qwen", "Qwen3-Embedding-0___6B") RERANK_MODEL_PATH = os.path.join(BASE_DIR, "models", "Qwen", "Qwen3-Reranker-4B") +# ===================== 服务参数(--api-key / --model-name)===================== +# api_key 为空:不校验(任意值放行);model_name 为空:不校验请求 model 字段 +_model_args: dict = {"api_key": "", "model_name": ""} + +def check_api_key(request: Request): + """校验请求 API Key:服务未配置 key(空)时放行任意值;否则校验 Authorization / X-API-Key。 + + 校验失败抛出 HTTP 401。 + """ + api_key = _model_args.get("api_key", "") + if not api_key: + return + header = request.headers.get("Authorization", "") + if header.startswith("Bearer "): + provided = header[len("Bearer "):].strip() + else: + provided = request.headers.get("X-API-Key", "").strip() + if provided != api_key: + raise HTTPException(status_code=401, detail="API Key 无效或缺失") + +def check_model_name(model: str | None): + """校验请求 modelname:服务未配置模型名(空)时放行任意值;否则要求请求 model 匹配。 + + 校验失败抛出 HTTP 400。 + """ + model_name = _model_args.get("model_name", "") + if not model_name: + return + if str(model or "").strip() != model_name: + raise HTTPException(status_code=400, detail=f"模型名不匹配: 期望 '{model_name}',收到 '{model or ''}'") + # ===================== 初始化 FastAPI ===================== app = FastAPI(title="RAG Backend with 8-bit Qwen3 Models") @@ -87,6 +120,7 @@ def load_models(): # ===================== API 请求体定义 ===================== class EmbedRequest(BaseModel): task_description: str = "Given a web search query, retrieve relevant passages that answer the query" + model: str | None = None # 可选,服务配置了 --model-name 时须匹配 queries: List[str] documents: List[str] # 可选,若只查询文本可只传 queries @@ -96,6 +130,7 @@ class EmbedResponse(BaseModel): class RerankRequest(BaseModel): task_description: str = "Given a web search query, retrieve relevant passages that answer the query" + model: str | None = None # 可选,服务配置了 --model-name 时须匹配 query: str documents: List[str] @@ -104,7 +139,9 @@ class RerankResponse(BaseModel): # ===================== Embedding 端点 ===================== @app.post("/embed", response_model=EmbedResponse) -async def get_embeddings(req: EmbedRequest): +async def get_embeddings(req: EmbedRequest, request: Request): + check_api_key(request) + check_model_name(req.model) # 为 query 加上指令,document 不加指令 instructed_queries = [get_detailed_instruct(req.task_description, q) for q in req.queries] # 所有文本拼成一个 batch:先是 queries,后是 documents(如果有) @@ -132,7 +169,9 @@ async def get_embeddings(req: EmbedRequest): # ===================== Reranker 端点 ===================== @app.post("/rerank", response_model=RerankResponse) -async def rerank_documents(req: RerankRequest): +async def rerank_documents(req: RerankRequest, request: Request): + check_api_key(request) + check_model_name(req.model) # 构造带格式的输入对 pairs = [ format_rerank_instruction(req.task_description, req.query, doc) @@ -166,3 +205,20 @@ async def rerank_documents(req: RerankRequest): @app.get("/health") def health(): return {"status": "ok"} + + +# ======================== 启动入口 ======================== +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Qwen3 RAG Server (Embedding + Reranker)") + parser.add_argument("--port", type=int, default=8903, help="服务端口(默认 8903)") + parser.add_argument("--model-name", type=str, default="Qwen3-Embedding-0.6B", + help="服务模型名(校验请求 body.model;默认 Qwen3-Embedding-0.6B,留空则不校验)") + parser.add_argument("--api-key", type=str, default="", + help="API Key(校验 Authorization: Bearer 或 X-API-Key;默认空=任意值放行)") + args = parser.parse_args() + + _model_args["api_key"] = args.api_key + _model_args["model_name"] = args.model_name + + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=args.port) diff --git a/llm_server/start_asr.sh b/llm_server/start_asr.sh index 141a877..cd6a2cf 100644 --- a/llm_server/start_asr.sh +++ b/llm_server/start_asr.sh @@ -6,6 +6,9 @@ SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VENV_DIR="${SCRIPT_DIR}/myenv" MODEL_DIR="${SCRIPT_DIR}/models/Qwen/Qwen3-ASR-1.7B" HOST_PORT="${HOST_PORT:-8901}" +# 鉴权与模型名校验:SERVER_API_KEY 为空时 vLLM 不校验 API Key(任意值放行) +SERVER_API_KEY="${SERVER_API_KEY:-}" +SERVER_MODELNAME="${SERVER_MODELNAME:-Qwen3-ASR-1.7B}" # 激活虚拟环境 source "${VENV_DIR}/bin/activate" @@ -18,6 +21,10 @@ fi echo "Starting vLLM server on port ${HOST_PORT} ..." echo "Model path: ${MODEL_DIR}" +echo "Served model name: ${SERVER_MODELNAME}" +if [ -n "${SERVER_API_KEY}" ]; then + echo "API Key 校验已启用" +fi # 进入脚本目录 cd "${SCRIPT_DIR}" @@ -34,14 +41,21 @@ export TRANSFORMERS_OFFLINE=1 export CUDA_VISIBLE_DEVICES=3 # 启动 vLLM 服务 -vllm serve "${MODEL_DIR}" \ - --port "${HOST_PORT}" \ - --host 0.0.0.0 \ - --quantization fp8 \ - --dtype auto \ - --kv-cache-dtype fp8 \ - --max-model-len 16384 \ - --allowed-local-media-path / \ - --tensor-parallel-size 1 \ - --gpu-memory-utilization 0.15 \ +ASR_ARGS=( + --port "${HOST_PORT}" + --host 0.0.0.0 + --quantization fp8 + --dtype auto + --kv-cache-dtype fp8 + --max-model-len 16384 + --allowed-local-media-path / + --tensor-parallel-size 1 + --gpu-memory-utilization 0.15 --max-num-batched-tokens 4096 + --served-model-name "${SERVER_MODELNAME}" +) +# API Key 为空时不传 --api-key(vLLM 默认放行任意值) +if [ -n "${SERVER_API_KEY}" ]; then + ASR_ARGS+=(--api-key "${SERVER_API_KEY}") +fi +vllm serve "${MODEL_DIR}" "${ASR_ARGS[@]}" diff --git a/llm_server/start_image_gen.sh b/llm_server/start_image_gen.sh index 33ce214..2d9df94 100644 --- a/llm_server/start_image_gen.sh +++ b/llm_server/start_image_gen.sh @@ -4,6 +4,9 @@ set -euo pipefail # ---------- 配置 ---------- HOST_PORT="${HOST_PORT:-8904}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# 鉴权与模型名校验:SERVER_API_KEY / SERVER_MODELNAME 为空时服务端不校验(任意值放行) +SERVER_API_KEY="${SERVER_API_KEY:-}" +SERVER_MODELNAME="${SERVER_MODELNAME:-Z-Image-Turbo}" # ---------- Python 虚拟环境 ---------- if [ -f "${SCRIPT_DIR}/../myenv/bin/activate" ]; then @@ -20,5 +23,9 @@ if [ -n "${CUDA_DEV}" ]; then fi echo "[Z-Image] 启动图像生成服务 (端口=${HOST_PORT})" +echo "[Z-Image] 服务模型名: ${SERVER_MODELNAME}" +if [ -n "${SERVER_API_KEY}" ]; then + echo "[Z-Image] API Key 校验已启用" +fi cd "${SCRIPT_DIR}" -exec python image_gen_server.py --port "${HOST_PORT}" +exec python image_gen_server.py --port "${HOST_PORT}" --model-name "${SERVER_MODELNAME}" --api-key "${SERVER_API_KEY}" diff --git a/llm_server/start_llm.sh b/llm_server/start_llm.sh index d304f20..467ca1d 100644 --- a/llm_server/start_llm.sh +++ b/llm_server/start_llm.sh @@ -5,6 +5,9 @@ SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VENV_DIR="${SCRIPT_DIR}/myenv" HOST_PORT="${HOST_PORT:-8900}" MODEL_DIR="${SCRIPT_DIR}/models/Qwen/Qwen3___6-35B-A3B-FP8" +# 鉴权与模型名校验:SERVER_API_KEY 为空时 vLLM 不校验 API Key(任意值放行) +SERVER_API_KEY="${SERVER_API_KEY:-}" +SERVER_MODELNAME="${SERVER_MODELNAME:-Qwen/Qwen3.6-35B-A3B-FP8}" # 激活虚拟环境 source "${VENV_DIR}/bin/activate" @@ -17,6 +20,10 @@ fi echo "Starting vLLM server on port ${HOST_PORT} ..." echo "Model path: ${MODEL_DIR}" +echo "Served model name: ${SERVER_MODELNAME}" +if [ -n "${SERVER_API_KEY}" ]; then + echo "API Key 校验已启用" +fi # 设置环境变量 export VLLM_USE_MODELSCOPE=true @@ -28,19 +35,25 @@ export TRANSFORMERS_OFFLINE=1 export CUDA_VISIBLE_DEVICES=3 # 启动 vLLM 服务 -vllm serve "${MODEL_DIR}" \ - --port "${HOST_PORT}" \ - --host 0.0.0.0 \ - --tensor-parallel-size 1 \ - --max-model-len 262144 \ - --reasoning-parser qwen3 \ - --enable-auto-tool-choice \ - --tool-call-parser qwen3_coder \ - --speculative-config '{"method":"qwen3_next_mtp","num_speculative_tokens":2}' \ - --enable-prefix-caching \ - --max_num_batched_tokens 4096 \ - --max_num_seqs 8 \ - --kv-cache-dtype fp8_e4m3 \ - --chat-template "${SCRIPT_DIR}/chat_template.jinja" \ - --safetensors-load-strategy eager \ - --served-model-name Qwen/Qwen3.6-35B-A3B-FP8 +VLLM_ARGS=( + --port "${HOST_PORT}" + --host 0.0.0.0 + --tensor-parallel-size 1 + --max-model-len 262144 + --reasoning-parser qwen3 + --enable-auto-tool-choice + --tool-call-parser qwen3_coder + --speculative-config '{"method":"qwen3_next_mtp","num_speculative_tokens":2}' + --enable-prefix-caching + --max_num_batched_tokens 4096 + --max_num_seqs 8 + --kv-cache-dtype fp8_e4m3 + --chat-template "${SCRIPT_DIR}/chat_template.jinja" + --safetensors-load-strategy eager + --served-model-name "${SERVER_MODELNAME}" +) +# API Key 为空时不传 --api-key(vLLM 默认放行任意值) +if [ -n "${SERVER_API_KEY}" ]; then + VLLM_ARGS+=(--api-key "${SERVER_API_KEY}") +fi +vllm serve "${MODEL_DIR}" "${VLLM_ARGS[@]}" diff --git a/llm_server/start_rag_server.sh b/llm_server/start_rag_server.sh index 4deb193..0037e2b 100644 --- a/llm_server/start_rag_server.sh +++ b/llm_server/start_rag_server.sh @@ -5,6 +5,9 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VENV_DIR="${VENV_DIR:-${SCRIPT_DIR}/myenv}" HOST_PORT="${HOST_PORT:-8903}" +# 鉴权与模型名校验:SERVER_API_KEY / SERVER_MODELNAME 为空时服务端不校验(任意值放行) +SERVER_API_KEY="${SERVER_API_KEY:-}" +SERVER_MODELNAME="${SERVER_MODELNAME:-Qwen3-Embedding-0.6B}" # 激活虚拟环境 source "${VENV_DIR}/bin/activate" @@ -16,6 +19,10 @@ if lsof -i "tcp:${HOST_PORT}" -t >/dev/null 2>&1; then fi echo "Starting Qwen3 RAG server on port ${HOST_PORT} ..." +echo "Served model name: ${SERVER_MODELNAME}" +if [ -n "${SERVER_API_KEY}" ]; then + echo "API Key 校验已启用" +fi # 进入脚本目录 cd "${SCRIPT_DIR}" @@ -34,7 +41,7 @@ export MODELSCOPE_CACHE="${MODELSCOPE_CACHE:-${SCRIPT_DIR}/models}" export CUDA_VISIBLE_DEVICES=3 # 启动 FastAPI 服务 -uvicorn rag_server:app \ - --host 0.0.0.0 \ +exec python rag_server.py \ --port "${HOST_PORT}" \ - --workers 1 + --model-name "${SERVER_MODELNAME}" \ + --api-key "${SERVER_API_KEY}" diff --git a/llm_server/start_streaming_tts.sh b/llm_server/start_streaming_tts.sh index 4f09bdc..e46c7ae 100644 --- a/llm_server/start_streaming_tts.sh +++ b/llm_server/start_streaming_tts.sh @@ -7,6 +7,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # VoxCPM 模型路径(可覆盖) VOXCPM_MODEL_PATH="${VOXCPM_MODEL_PATH:-${SCRIPT_DIR}/models/VoxCPM1__5}" +# 鉴权与模型名校验:SERVER_API_KEY / SERVER_MODELNAME 为空时服务端不校验(任意值放行) +SERVER_API_KEY="${SERVER_API_KEY:-}" +SERVER_MODELNAME="${SERVER_MODELNAME:-VoxCPM1.5}" # ---------- Python 虚拟环境 ---------- if [ -f "${SCRIPT_DIR}/../myenv/bin/activate" ]; then @@ -21,7 +24,13 @@ export CUDA_VISIBLE_DEVICES="${CUDA_DEV}" echo "[VoxCPM TTS] 启动流式 TTS 服务 (端口=${HOST_PORT}, CUDA=${CUDA_DEV})" echo "[VoxCPM TTS] 模型路径: ${VOXCPM_MODEL_PATH}" +echo "[VoxCPM TTS] 服务模型名: ${SERVER_MODELNAME}" +if [ -n "${SERVER_API_KEY}" ]; then + echo "[VoxCPM TTS] API Key 校验已启用" +fi cd "${SCRIPT_DIR}" exec python streaming_tts_server.py \ --port "${HOST_PORT}" \ - --model-path "${VOXCPM_MODEL_PATH}" + --model-path "${VOXCPM_MODEL_PATH}" \ + --model-name "${SERVER_MODELNAME}" \ + --api-key "${SERVER_API_KEY}" diff --git a/llm_server/streaming_tts_server.py b/llm_server/streaming_tts_server.py index a034540..ac4ff0f 100644 --- a/llm_server/streaming_tts_server.py +++ b/llm_server/streaming_tts_server.py @@ -39,6 +39,8 @@ except ImportError: # 默认参数(可通过命令行覆盖) DEFAULT_MODEL_PATH = os.path.join(BASE_DIR, "models", "VoxCPM1__5") DEFAULT_PORT = 8902 +DEFAULT_MODEL_NAME = "VoxCPM1.5" # 默认服务模型名(校验请求 body.model 用) +DEFAULT_API_KEY = "" # 默认空:不校验 API Key(任意值放行) # 参考音频(用于统一音色,同一人物说的一段话 + 对应文本) # 如果配置了这对参数,所有合成都会克隆该音色,各句音色保持一致 @@ -161,6 +163,37 @@ def _build_sse(data: dict) -> str: return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" +def _check_api_key(request: Request) -> str | None: + """校验请求 API Key:服务未配置 key(空)时放行任意值;否则校验 Authorization / X-API-Key。 + + 返回 None 表示通过,否则返回错误提示文本。 + """ + api_key = _model_args.get("api_key", "") + if not api_key: + return None + header = request.headers.get("Authorization", "") + if header.startswith("Bearer "): + provided = header[len("Bearer "):].strip() + else: + provided = request.headers.get("X-API-Key", "").strip() + if provided == api_key: + return None + return "API Key 无效或缺失" + + +def _check_model_name(body: dict) -> str | None: + """校验请求 modelname:服务未配置模型名(空)时放行任意值;否则要求 body.model 匹配。 + + 返回 None 表示通过,否则返回错误提示文本。 + """ + model_name = _model_args.get("model_name", "") + if not model_name: + return None + if str(body.get("model") or "").strip() == model_name: + return None + return f"模型名不匹配: 期望 '{model_name}',收到 '{body.get('model') or ''}'" + + def _stream_audio_events(text: str, request_id: str, model_path: str): """生成 SSE 音频流事件(同步,在独立线程中运行)。""" import numpy as np @@ -237,7 +270,7 @@ def _stream_audio_events(text: str, request_id: str, model_path: str): # ======================== FastAPI ======================== from fastapi import FastAPI, Request -from fastapi.responses import StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse @asynccontextmanager @@ -260,12 +293,22 @@ app = FastAPI(title="VoxCPM Streaming TTS", lifespan=lifespan) @app.post("/stream_tts") async def stream_tts(request: Request): """流式 TTS 端点:接收文本,SSE 流式返回 PCM f32le 音频块。""" + # 鉴权校验(服务配置了 API Key 时) + auth_error = _check_api_key(request) + if auth_error: + return JSONResponse(status_code=401, content={"detail": auth_error}) + try: body = await request.json() except Exception: form = await request.form() body = dict(form) + # 模型名校验(服务配置了模型名时) + model_error = _check_model_name(body) + if model_error: + return JSONResponse(status_code=400, content={"detail": model_error}) + text = (body.get("text") or "").strip() request_id = body.get("request_id") or f"tts-{int(time.time() * 1000)}" @@ -317,9 +360,15 @@ if __name__ == "__main__": parser = argparse.ArgumentParser(description="VoxCPM Streaming TTS Server") parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"服务端口(默认 {DEFAULT_PORT})") parser.add_argument("--model-path", type=str, default=DEFAULT_MODEL_PATH, help="VoxCPM 模型目录") + parser.add_argument("--model-name", type=str, default=DEFAULT_MODEL_NAME, + help=f"服务模型名(校验请求 body.model;默认 {DEFAULT_MODEL_NAME},留空则不校验)") + parser.add_argument("--api-key", type=str, default=DEFAULT_API_KEY, + help="API Key(校验 Authorization: Bearer 或 X-API-Key;默认空=任意值放行)") args = parser.parse_args() _model_args["model_path"] = args.model_path + _model_args["model_name"] = args.model_name + _model_args["api_key"] = args.api_key import uvicorn uvicorn.run(app, host="0.0.0.0", port=args.port) diff --git a/requirements.txt b/requirements.txt index 7310e18..47eae3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,9 @@ pywin32>=312 pyperclip>=1.8.0 screeninfo>=0.8.0 +# ===== Browser Automation (browser_control / web_page) ===== +playwright>=1.60.0 + # ===== Audio ===== pyaudio>=0.2.11 pydub>=0.25.1 diff --git a/src/agent/agents/config_dist.json b/src/agent/agents/config_dist.json new file mode 100644 index 0000000..10b51b7 --- /dev/null +++ b/src/agent/agents/config_dist.json @@ -0,0 +1,49 @@ +{ + "agents": [], + "tools": [], + "env": { + "USER_NICKNAME": "", + "USER_PYTHON_PATH": "", + "WEB_SEARCH_API_KEY": "", + "WEB_SEARCH_ENGINE": "tavily", + "LLM_CONTEXT_WINDOW": "262144", + "COMPRESS_RATE": "0.9", + "IMG_SIZE": "1000", + "RAG_CHUNK_SIZE": "500", + "RAG_CHUNK_OVERLAP": "50", + "GROUNDING_WIDTH": "1000", + "GROUNDING_HEIGHT": "1000", + "LOOP_DETECT_REPEATED_TOOL_WARN": "3", + "SEND_FILE_SIZE_LIMIT": "30", + "STORAGE_BACKEND": "json", + "CRON_TIME_PERIOD_MINUTES": 30, + "MYSQL_HOST": "localhost", + "MYSQL_PORT": "3306", + "MYSQL_USER": "root", + "MYSQL_PASSWORD": "", + "MYSQL_DATABASE": "agent_history", + "EMAIL_ADDRESS": "", + "EMAIL_AUTH_CODE": "", + "models": [] + }, + "gui": { + "gui_monitor_name": "", + "gui_monitor_index": 0, + "gui_model_id": "", + "monitors_snapshot": [] + }, + "workspace": { + "current": "", + "list": [], + "access_mode": "restricted" + }, + "theme": { + "theme": "solarized-light", + "bg_image": "", + "font_family": "\"ZCOOL KuaiLe\", \"Microsoft YaHei\", sans-serif", + "font_size": "14px", + "bg_blur": "", + "bg_vignette": "", + "avatar": "" + } +} \ No newline at end of file diff --git a/src/agent/config/agent_config_dist.json b/src/agent/config/agent_config_dist.json deleted file mode 100644 index 5e60346..0000000 --- a/src/agent/config/agent_config_dist.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "name": "main_agent", - "description": "", - "system_prompt": "你是一个具备多模态理解能力的桌面操作助手,能够直接理解用户上传的图片、语音和文件,并通过调用工具完成桌面自动化任务。\n【核心行为准则】\n- 你的首要职责是理解用户的意图,并高效地完成用户的任务。\n- 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图等无关工具。\n- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。例如:需要查看当前屏幕状态、需要执行 GUI 操作、需要搜索网络信息等。\n- **【强制规则】面对涉及 PPT 制作、代码项目等复杂产出类或系统操作类任务时,你必须先调用 skill_router 工具查询是否有对应的 Skill 操作指南。\n如果 skill_router 返回了匹配的 Skill,你必须将其视为最高优先级的执行规范,严格遵循 Skill 中定义的完整工作流,严禁跳过任何步骤,\n严禁用你自身的编程经验(如直接写 python-pptx 代码、用终端脚本批量生成等)替代 Skill 规范。Skill 规范的地位高于任何工具描述中的建议,当工具描述与 Skill 规范冲突时,以 Skill 规范为准。**\n- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。\n- 进行浏览器操作时,慎用刷新工具,避免刷新导致页面状态丢失。\n- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用,注意单次调用最多包含5个动作。\n- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。\n- 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。\n- 当任务完成时,向用户总结执行过程和结果。\n\n【工作空间配置】\n- 工作空间 (WORKSPACE_DIR): /app/src/workspace\n- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。\n- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。\n- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。\n", - "max_iterations": 200, - "llm_model_id": "affiaa7r", - "tools": [ - "doc_tool", - "call_subagent", - "date_query" - ], - "enabled": true, - "role": "main", - "trajectory_rounds": 3 - }, - { - "name": "websearch_agent", - "description": "网络搜索子 Agent,擅长搜索引擎查询与信息整理。", - "system_prompt": "你是一个专业的网络内容搜索助手,由主 Agent 委派执行特定子任务。\n\n【身份定位】\n- 你是多 Agent 体系中的一个**子 Agent**,接收主 Agent 委派的任务并独立完成。\n- 你的职责是**专注完成被委派的任务**,不越权处理超出任务范围的事务。\n\n【工作流程】\n1. 接收主 Agent 下发的任务描述。\n2. 调用可用工具逐步完成任务。\n3. 完成后返回清晰的结果摘要。\n\n【关键行为准则】\n- **聚焦任务**:只执行被委派的具体任务,不要做额外操作或发散思考。\n- **高效执行**:尽量在一次推理链路中完成,减少不必要的确认或反复。\n- **如实汇报**:基于实际执行结果输出,不编造信息。遇到错误时说明原因。\n- **工具使用**:优先利用内置工具完成任务,但仅在必要时才调用。\n- **结果导向**:返回的结果应直接回应主 Agent 的问题,避免冗长铺垫。\n\n【输出格式】\n- 直接输出任务执行结果,用简洁、清晰的语言概括关键信息。\n- 如果有多个结果,用分点或编号列出。\n- 以「[完成]」标记任务执行完毕。", - "max_iterations": 200, - "llm_model_id": "affiaa7r", - "tools": [ - "web_search", - "date_query" - ], - "enabled": false, - "role": "sub", - "trajectory_rounds": 3 - }, - { - "name": "SQL_agent", - "description": "数据库操作子 Agent,擅长数据库操作。", - "system_prompt": "你是一个专业的数据库操作助手,由主 Agent 委派执行特定子任务。\n\n【身份定位】\n- 你是多 Agent 体系中的一个**子 Agent**,接收主 Agent 委派的任务并独立完成。\n- 你的职责是**专注完成被委派的任务**,不越权处理超出任务范围的事务。\n\n【工作流程】\n1. 接收主 Agent 下发的任务描述。\n2. 调用可用工具逐步完成任务。\n3. 完成后返回清晰的结果摘要。\n\n【关键行为准则】\n- **聚焦任务**:只执行被委派的具体任务,不要做额外操作或发散思考。\n- **高效执行**:尽量在一次推理链路中完成,减少不必要的确认或反复。\n- **如实汇报**:基于实际执行结果输出,不编造信息。遇到错误时说明原因。\n- **工具使用**:优先利用内置工具完成任务,但仅在必要时才调用。\n- **结果导向**:返回的结果应直接回应主 Agent 的问题,避免冗长铺垫。\n\n【输出格式】\n- 直接输出任务执行结果,用简洁、清晰的语言概括关键信息。\n- 如果有多个结果,用分点或编号列出。\n- 以「[完成]」标记任务执行完毕。", - "max_iterations": 200, - "llm_model_id": "affiaa7r", - "tools": [ - "text2sql", - "excel2sql", - "hard_excel_read" - ], - "enabled": false, - "role": "sub", - "trajectory_rounds": 3 - } -] diff --git a/src/agent/config/env_config_dist.json b/src/agent/config/env_config_dist.json deleted file mode 100644 index 567e8b3..0000000 --- a/src/agent/config/env_config_dist.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "WORKING_DIR": ".", - "WORKSPACE_DIR": "workspace", - "USER_PYTHON_PATH": "", - "STREAMING_TTS_URL": "", - "ASR_BASE_URL": "", - "RAG_BASE_URL": "", - "WEB_SEARCH_API_KEY": "", - "WEB_SEARCH_ENGINE": "tavily", - "LLM_CONTEXT_WINDOW": "262144", - "COMPRESS_RATE": "0.6", - "IMG_SIZE": "768", - "RAG_CHUNK_SIZE": "500", - "RAG_CHUNK_OVERLAP": "50", - "GROUNDING_WIDTH": "1000", - "GROUNDING_HEIGHT": "1000", - "LOOP_DETECT_REPEATED_TOOL_WARN": "3", - "SEND_FILE_SIZE_LIMIT": "30", - "EMAIL_ADDRESS": "", - "EMAIL_AUTH_CODE": "", - "gui_model_id": "", - "models": [ - { - "id": "SETUP_REQUIRED", - "name": "请先完成首次配置", - "model": "", - "api_key": "", - "base_url": "", - "timeout": 60, - "max_retries": 0 - } - ] -} diff --git a/src/agent/config/gui_config.json b/src/agent/config/gui_config.json deleted file mode 100644 index 1a9af6e..0000000 --- a/src/agent/config/gui_config.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "gui_monitor_name": "\\\\.\\DISPLAY5", - "gui_monitor_index": 0, - "gui_model_id": "affiaa7r", - "monitors_snapshot": [ - { - "name": "\\\\.\\DISPLAY5", - "x": 0, - "y": 0, - "width": 2560, - "height": 1440, - "is_primary": true - }, - { - "name": "\\\\.\\DISPLAY1", - "x": -1350, - "y": 149, - "width": 1350, - "height": 2400, - "is_primary": false - } - ] -} \ No newline at end of file diff --git a/src/agent/config/theme_config.json b/src/agent/config/theme_config.json deleted file mode 100644 index d0548ee..0000000 --- a/src/agent/config/theme_config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "theme": "solarized-light", - "bg_image": "", - "font_family": "\"ZCOOL KuaiLe\", \"Microsoft YaHei\", sans-serif", - "font_size": "14px", - "bg_blur": "", - "bg_vignette": "" -} \ No newline at end of file diff --git a/src/agent/config/tool_config.json b/src/agent/config/tool_config.json deleted file mode 100644 index c2bbdb0..0000000 --- a/src/agent/config/tool_config.json +++ /dev/null @@ -1,244 +0,0 @@ -[ - { - "name": "web_search", - "display_name": "WebSearch", - "description": "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "date_query", - "display_name": "DateQuery", - "description": "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "browser_control", - "display_name": "BrowserControl", - "description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。", - "permission": "confirm", - "auto_execute_rules": [ - { - "parameter": "action", - "operator": "equals", - "value": "open" - } - ], - "enabled": true, - "timeout": 300 - }, - { - "name": "human_interaction", - "display_name": "HumanInteraction", - "description": "当任务缺少关键信息、需要人类选择路径、或需要人类审批后才能继续时调用。三种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑;approval=请求人类审批高影响决策或继续执行。工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。", - "permission": "confirm", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "software_control", - "display_name": "SoftwareControl", - "description": "操作本机已安装的软件。工具会自动扫描 Windows 开始菜单快捷方式、桌面快捷方式和 App Paths,建立软件索引后根据用户输入做模糊匹配。action=open 时打开软件;action=close 时仅做正常关闭;action=force_close 时才允许在正常关闭失败后强制结束进程。仅当用户明确要求打开、关闭或强制关闭某个本机软件时调用。", - "permission": "confirm", - "auto_execute_rules": [ - { - "parameter": "action", - "operator": "equals", - "value": "open" - } - ], - "enabled": true, - "timeout": 300 - }, - { - "name": "rag_tool", - "display_name": "RAGTool", - "description": "个人知识库管理工具,支持增删查及列出所有来源操作。", - "permission": "confirm", - "auto_execute_rules": [ - { - "parameter": "action_type", - "operator": "equals", - "value": "query" - } - ], - "enabled": true, - "timeout": 300 - }, - { - "name": "directory_listing", - "display_name": "DirectoryListing", - "description": "列出指定目录下的所有文件和子目录。可用于探索文件系统、查找文件位置。仅当需要获取目录内容或确认文件结构时调用,其余情况严禁调用。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "todo_list", - "display_name": "TodoList", - "description": "任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "gui_tool", - "display_name": "GUITool", - "description": "GUI 自动化工具。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、hold_and_press(按住组合键", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "call_subagent", - "display_name": "CallSubAgentTool", - "description": "调用一个已启用的子 Agent 执行指定任务。子 Agent 会独立运行并返回最终结果。适用于需要专业能力(如代码分析、文档处理等)进行辅助的场景。可用的子 Agent 名称取决于 agent_config.json 中已启用且 role='sub' 的配置。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 900 - }, - { - "name": "doc_tool", - "display_name": "DocTool", - "description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,以及图片(image)、音频(audio)的读取。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "skill_router", - "display_name": "SkillRouter", - "description": "Skill 路由工具。根据用户的 query(问题或任务描述),从已安装的 skill 中智能匹配最相关的操作指南,并返回指南全文。", - "permission": "direct", - "auto_execute_rules": [ - { - "parameter": "query", - "operator": "exists", - "value": "" - } - ], - "enabled": true, - "timeout": 300 - }, - { - "name": "send_file", - "display_name": "SendFileTool", - "description": "向用户发送文件的工具。根据文件类型自动处理:", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "terminal_execute", - "display_name": "TerminalExecute", - "description": "终端命令执行工具。在本地终端(PowerShell)中执行 shell 命令并返回执行结果。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "mail_tool", - "display_name": "MailTool", - "description": "QQ email tool. Supports sending and reading email without GUI.", - "permission": "direct", - "auto_execute_rules": [ - { - "parameter": "action", - "operator": "equals", - "value": "read" - } - ], - "enabled": true, - "timeout": 300 - }, - { - "name": "image_gen", - "display_name": "ImageGen", - "description": "AI 图像生成工具,将文字描述转换为高质量图片。支持详细的中英文提示词,可指定尺寸和排除元素。生成的图片自动保存到 workspace 目录。适用场景:用户要求画图、生成图片、制作海报、设计插图等。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "cron_manager", - "display_name": "CronManager", - "description": "定时任务管理工具。用于查看(含状态)、新增、修改、删除定时任务。支持三种触发方式:cron 表达式、固定间隔、一次性延时。适用场景:用户希望设置/查看/修改定时任务,或 Agent 在定时任务模式下需管理其他定时任务。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "text2sql", - "display_name": "Text2SQL", - "description": "数据库操作工具(text2sql)。支持查看数据库结构(列出表、查看表结构)、查询表内容(SELECT),对数据库内容做增删改(INSERT/UPDATE/DELETE/DDL),以及建库建表(create_database/create_table)。查类与建库建表操作(list_tables/describe/query/create_database/create_table)直接执行;增删改(execute)需用户确认。连接配置复用 env_config.json 的 MYSQL_*(host/port/user/password),database 参数必填(用户需告知要操作的数据库名)。", - "permission": "confirm", - "auto_execute_rules": [ - { - "parameter": "action", - "operator": "equals", - "value": "list_tables" - }, - { - "parameter": "action", - "operator": "equals", - "value": "describe" - }, - { - "parameter": "action", - "operator": "equals", - "value": "query" - }, - { - "parameter": "action", - "operator": "equals", - "value": "create_database" - }, - { - "parameter": "action", - "operator": "equals", - "value": "create_table" - } - ], - "enabled": true, - "timeout": 300 - }, - { - "name": "hard_excel_read", - "display_name": "HardExcelRead", - "description": "复杂Excel表结构读取工具。读取指定sheet(或所有sheet)前N行原始数据+辅助摘要(维度/合并区/列类型与样本/数据区范围建议)返回给大模型解析复杂表结构。不含数据区全量行。配合excel2sql完成解析→入库。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true, - "timeout": 300 - }, - { - "name": "excel2sql", - "display_name": "Excel2SQL", - "description": "按大模型给定的数据行列范围,自动建库建表(表COMMENT存表格描述)并把Excel数据区流式批量入库MySQL。不做重清洗DSL,按范围直接入库;值级清洗入库后用text2sql(execute)完成。dry_run=true时直接执行复核,false需确认。", - "permission": "direct", - "auto_execute_rules": [ - { - "parameter": "dry_run", - "operator": "equals", - "value": "true" - } - ], - "enabled": true, - "timeout": 300 - } -] \ No newline at end of file diff --git a/src/agent/config/workspace_config.json b/src/agent/config/workspace_config.json deleted file mode 100644 index ec64eee..0000000 --- a/src/agent/config/workspace_config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "current": "C:\\Users\\86166\\Desktop\\新建文件夹", - "list": [ - "C:\\Users\\86166\\Desktop\\新建文件夹", - "C:\\Users\\86166\\Desktop", - "C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace" - ], - "access_mode": "full" -} \ No newline at end of file diff --git a/src/agent/core/config_manager.py b/src/agent/core/config_manager.py index 9897f5f..1e9a34c 100644 --- a/src/agent/core/config_manager.py +++ b/src/agent/core/config_manager.py @@ -1,13 +1,17 @@ -"""Agent 配置管理:从 JSON 文件加载/保存 agent、tool、env、model 配置。 +"""Agent 配置管理:从合并后的 config.json 加载/保存 agent、tool、env、model、gui 配置。 系统定位: - 提供统一接口读写 ``agent/config/`` 下的 JSON 配置文件, + 提供统一接口读写 ``agent/agents/config.json`` 下的配置, 使前端手动配置的 JSON 可以替代 core 底下的相应 .py 硬编码。 -配置文件: - agent_config.json - Agent 运行时配置(名称、模型引用、工具列表、system_prompt) - tool_config.json - 工具权限与参数配置 - env_config.json - 环境变量与 LLM 模型列表 +配置文件(单一文件,按分区组织): + config.json - 合并后的唯一配置文件 + agents - Agent 运行时配置(名称、模型引用、工具列表、system_prompt) + tools - 工具权限与参数配置 + env - 环境变量与 LLM 模型列表 + gui - GUI 显示器配置 + workspace - 工作空间配置(current/list/access_mode) + theme - 主题/背景/头像配置 可扩展性: 可增加版本号、校验 schema、多环境配置切换。 @@ -23,10 +27,12 @@ from pathlib import Path from typing import Any # ---------- 配置文件路径 ---------- -_CONFIG_DIR = Path(__file__).resolve().parent.parent / "config" -AGENT_CONFIG_PATH = _CONFIG_DIR / "agent_config.json" -TOOL_CONFIG_PATH = _CONFIG_DIR / "tool_config.json" -ENV_CONFIG_PATH = _CONFIG_DIR / "env_config.json" +CONFIG_PATH = Path(__file__).resolve().parent.parent / "agents" / "config.json" +# 兼容旧路径引用(API 返回值、前端提示用) +AGENT_CONFIG_PATH = CONFIG_PATH +TOOL_CONFIG_PATH = CONFIG_PATH +ENV_CONFIG_PATH = CONFIG_PATH +GUI_CONFIG_PATH = CONFIG_PATH # ---------- 模型配置 ---------- @@ -47,7 +53,7 @@ class AgentConfig: description: str = "" # 简短能力描述,暴露给主Agent用于委派决策 system_prompt: str = "" max_iterations: int = 200 - llm_model_id: str = "" # 引用 env_config.json models 列表中的模型 ID + llm_model_id: str = "" # 引用 env 分区 models 列表中的模型 ID tools: list[str] = field(default_factory=list) enabled: bool = True role: str = "sub" # "main" | "sub" @@ -80,6 +86,7 @@ def _read_json(path: Path) -> dict | list | None: def _write_json(path: Path, data: Any) -> bool: """写入 JSON 文件,成功返回 True。""" try: + path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) return True @@ -87,6 +94,31 @@ def _write_json(path: Path, data: Any) -> bool: return False +def _read_config_file() -> dict: + """读取合并后的 config.json 全文({agents, tools, env, gui, workspace, theme})。""" + raw = _read_json(CONFIG_PATH) + if not isinstance(raw, dict): + return {} + return raw + + +def _write_config_file(data: dict) -> bool: + """整体写回 config.json。""" + return _write_json(CONFIG_PATH, data) + + +def _get_section(name: str) -> Any: + """读取 config.json 的某个分区(缺失返回 None)。""" + return _read_config_file().get(name) + + +def _set_section(name: str, value: Any) -> bool: + """读-改-写 config.json 的某个分区(保留其他分区)。""" + data = _read_config_file() + data[name] = value + return _write_config_file(data) + + # ---------- Agent 配置 ---------- def _default_agent_config() -> list[dict]: """生成默认 agent 配置(单 agent: main)。""" @@ -109,17 +141,17 @@ def _default_agent_config() -> list[dict]: def load_agent_configs() -> list[AgentConfig]: - """从 agent_config.json 加载 agent 配置列表,无文件时返回默认。 + """从 config.json 的 agents 分区加载 agent 配置列表,无文件时返回默认。 对于 role='main' 的 Agent,system_prompt 始终从 get_main_system_prompt() 动态获取 (忽略 JSON 中的静态值),确保提示词可热更新且包含正确的动态路径。 对于 role='sub' 的 Agent,若未指定 system_prompt 则自动使用默认子 Agent 提示词。 """ from agent.memory.system_prompt import get_default_sub_agent_prompt, get_main_system_prompt - raw = _read_json(AGENT_CONFIG_PATH) + raw = _get_section("agents") if not raw or not isinstance(raw, list): raw = _default_agent_config() - _write_json(AGENT_CONFIG_PATH, raw) + _set_section("agents", raw) configs = [] for item in raw: @@ -147,8 +179,8 @@ def load_agent_configs() -> list[AgentConfig]: def save_agent_configs(configs: list[dict]) -> bool: - """将 agent 配置列表写入 agent_config.json。""" - return _write_json(AGENT_CONFIG_PATH, configs) + """将 agent 配置列表写入 config.json 的 agents 分区。""" + return _set_section("agents", configs) # ---------- Tool 配置 ---------- @@ -157,6 +189,13 @@ def save_agent_configs(configs: list[dict]) -> bool: _TOOL_DEFAULT_AUTO_RULES: dict[str, list[dict]] = { "browser_control": [ {"parameter": "action", "operator": "equals", "value": "open"}, + {"parameter": "action", "operator": "equals", "value": "list_tabs"}, + {"parameter": "action", "operator": "equals", "value": "switch_tab"}, + ], + "web_page": [ + {"parameter": "action", "operator": "equals", "value": "snapshot"}, + {"parameter": "action", "operator": "equals", "value": "extract"}, + {"parameter": "action", "operator": "equals", "value": "screenshot"}, ], "rag_tool": [ {"parameter": "action_type", "operator": "equals", "value": "query"}, @@ -242,11 +281,11 @@ def _default_tool_config() -> list[dict]: def load_tool_configs() -> list[ToolConfig]: - """从 tool_config.json 加载工具配置,无文件时返回默认;有新工具时自动补入。""" - raw = _read_json(TOOL_CONFIG_PATH) + """从 config.json 的 tools 分区加载工具配置,无文件时返回默认;有新工具时自动补入。""" + raw = _get_section("tools") if not raw or not isinstance(raw, list): raw = _default_tool_config() - _write_json(TOOL_CONFIG_PATH, raw) + _set_section("tools", raw) # 补上代码中已注册但 JSON 中缺失的新工具 registered = get_registered_tool_names() @@ -257,7 +296,7 @@ def load_tool_configs() -> list[ToolConfig]: for name in missing: if name in defaults: raw.append(defaults[name]) - _write_json(TOOL_CONFIG_PATH, raw) + _set_section("tools", raw) configs = [] for item in raw: @@ -274,8 +313,8 @@ def load_tool_configs() -> list[ToolConfig]: def save_tool_configs(configs: list[dict]) -> bool: - """将 tool 配置列表写入 tool_config.json。""" - ok = _write_json(TOOL_CONFIG_PATH, configs) + """将 tool 配置列表写入 config.json 的 tools 分区。""" + ok = _set_section("tools", configs) invalidate_tool_timeout_cache() # 配置变更后清除超时缓存,确保新执行读到新超时 return ok @@ -351,35 +390,51 @@ def _default_models() -> list[dict]: def load_models() -> list[dict]: - """从 env_config.json 的 models 字段加载模型列表。""" - raw = _read_json(ENV_CONFIG_PATH) - if isinstance(raw, dict) and "models" in raw and isinstance(raw["models"], list) and raw["models"]: - return raw["models"] + """从 config.json 的 env 分区 models 字段加载模型列表。""" + env = load_env_config() + models = env.get("models") + if isinstance(models, list) and models: + return models return _default_models() def save_models(models: list[dict]) -> bool: - """将模型列表写入 env_config.json(合并其他字段)。""" + """将模型列表写入 config.json 的 env 分区(合并其他字段)。""" current = load_env_config() current["models"] = models - return _write_json(ENV_CONFIG_PATH, current) + return save_env_config(current) + + +def get_model_by_name(model_name: str) -> dict | None: + """按 model 字段(模型名)在模型列表中查找模型配置。 + + 用于 ASR / TTS / RAG / ImageGen 等本地服务模型:这些服务在模型列表中注册, + 运行时按 model 名匹配取得其 base_url / api_key。 + + 输入: + model_name: 模型名(如 "Qwen3-ASR-1.7B")。 + + 输出: + 匹配的模型配置 dict(含 id/name/model/api_key/base_url),未找到返回 None。 + """ + for m in load_models(): + if str(m.get("model", "")).strip() == model_name: + return dict(m) + return None # ---------- Env 配置 ---------- def _default_env_config() -> dict: - """生成默认 env 配置(不含 models,models 由 load_models 独立处理)。""" - import os as _os - _default_working = _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))) + """生成默认 env 配置(不含 models,models 由 load_models 独立处理)。 + + WORKING_DIR / WORKSPACE_DIR / THINKING_LEVEL 已移除: + 工作空间默认取启动目录(由 workspace 分区/工作空间 API 管理), + 思考档位改为会话级变量(存于各会话的 session_meta.json)。 + """ return { - "WORKING_DIR": _default_working, - "WORKSPACE_DIR": "", - "STREAMING_TTS_URL": "", - "ASR_BASE_URL": "", - "RAG_BASE_URL": "", - "IMAGE_GEN_URL": "http://localhost:8904", + "USER_PYTHON_PATH": "", "LLM_CONTEXT_WINDOW": "262144", "COMPRESS_RATE": "0.6", - "THINKING_LEVEL": "low", "IMG_SIZE": "768", "RAG_CHUNK_SIZE": "500", "RAG_CHUNK_OVERLAP": "50", @@ -393,32 +448,50 @@ def _default_env_config() -> dict: "MYSQL_USER": "root", "MYSQL_PASSWORD": "", "MYSQL_DATABASE": "agent_history", + # 存储数据库配置(STORAGE_DB_*,缺省回退 MYSQL_*;path 为空时 + # 由 db.load_storage_db_config 解析为 history/sqlite/storage.sqlite) + "STORAGE_DB_TYPE": "", + "STORAGE_DB_HOST": "", + "STORAGE_DB_PORT": "", + "STORAGE_DB_USER": "", + "STORAGE_DB_PASSWORD": "", + "STORAGE_DB_DATABASE": "", + "STORAGE_DB_PATH": "", + # 工具数据库配置(TOOL_DB_*,与存储后端完全隔离,不回退 MYSQL_*; + # path 为空时由 db.load_tool_db_config 解析为 history/sqlite/tool 目录) + "TOOL_DB_TYPE": "mysql", + "TOOL_DB_HOST": "localhost", + "TOOL_DB_PORT": "3306", + "TOOL_DB_USER": "root", + "TOOL_DB_PASSWORD": "", + "TOOL_DB_DATABASE": "", + "TOOL_DB_PATH": "", "EMAIL_ADDRESS": "", "EMAIL_AUTH_CODE": "", } def load_env_config() -> dict: - """从 env_config.json 加载环境变量配置,无文件时返回默认。""" - raw = _read_json(ENV_CONFIG_PATH) - if not raw or not isinstance(raw, dict): - raw = _default_env_config() - raw["models"] = _default_models() - _write_json(ENV_CONFIG_PATH, raw) - return raw + """从 config.json 的 env 分区加载环境变量配置,无文件时返回默认。""" + env = _get_section("env") + if not env or not isinstance(env, dict): + env = _default_env_config() + env["models"] = _default_models() + _set_section("env", env) + return env # 确保有 models 字段 - if "models" not in raw or not isinstance(raw.get("models"), list): - raw["models"] = _default_models() - return raw + if "models" not in env or not isinstance(env.get("models"), list): + env["models"] = _default_models() + return env def save_env_config(config: dict) -> bool: - """将 env 配置写入 env_config.json(保留已有 models)。""" + """将 env 配置写入 config.json 的 env 分区(保留已有 models)。""" # 保留已有的 models 列表(由 models API 单独管理) - existing = _read_json(ENV_CONFIG_PATH) - if isinstance(existing, dict) and "models" in existing: + existing = _get_section("env") + if isinstance(existing, dict) and "models" in existing and "models" not in config: config["models"] = existing["models"] - return _write_json(ENV_CONFIG_PATH, config) + return _set_section("env", config) # ---------- 获取所有已注册工具的基础信息 ---------- @@ -429,9 +502,6 @@ def get_registered_tool_names() -> list[str]: # ---------- GUI 显示器配置 ---------- -GUI_CONFIG_PATH = _CONFIG_DIR / "gui_config.json" - - def _default_gui_config() -> dict: """生成默认 GUI 显示器配置(主显示器)。""" try: @@ -492,23 +562,23 @@ def _get_live_monitors() -> list[dict]: def load_gui_config() -> dict: - """从 gui_config.json 加载 GUI 显示器配置,无文件时返回默认。""" - raw = _read_json(GUI_CONFIG_PATH) + """从 config.json 的 gui 分区加载 GUI 显示器配置,无文件时返回默认。""" + raw = _get_section("gui") if not raw or not isinstance(raw, dict): raw = _default_gui_config() - _write_json(GUI_CONFIG_PATH, raw) + _set_section("gui", raw) return raw def save_gui_config(config: dict) -> bool: - """将 GUI 显示器配置写入 gui_config.json。""" - return _write_json(GUI_CONFIG_PATH, config) + """将 GUI 显示器配置写入 config.json 的 gui 分区。""" + return _set_section("gui", config) def get_gui_model_config() -> dict | None: """获取 GUI 工具专用的模型配置。 - 从 gui_config.json 读取 gui_model_id,在 env_config.json 的 models 列表中查找对应模型。 + 从 config.json 的 gui 分区读取 gui_model_id,在 env 分区的 models 列表中查找对应模型。 若 gui_model_id 为空或找不到对应模型,返回 None(表示使用 agent 主模型)。 返回: @@ -528,7 +598,7 @@ def get_gui_model_config() -> dict | None: def get_gui_llm(): """获取 GUI 工具专用的 LLM 实例。 - 若 gui_config.json 中 gui_model_id 已配置且在模型列表中存在,返回对应的 Multimodel_LLM 实例; + 若 config.json 中 gui_model_id 已配置且在模型列表中存在,返回对应的 Multimodel_LLM 实例; 否则返回 None(调用方应回退到全局 llm 单例)。 返回: @@ -538,7 +608,8 @@ def get_gui_llm(): if model_cfg is None: return None from agent.core.llm import Multimodel_LLM - # 深度思考由全局 THINKING_LEVEL 档位控制(与主 Agent 一致),取代 per-model enable_thinking + # 深度思考由会话级思考档位动态控制(每轮按当前会话解析,见 llm.Multimodel_LLM._generate), + # 构建时使用全局默认档位作为兜底 from agent.utils.env_utils import get_thinking_extra_body return Multimodel_LLM( model=model_cfg["model"], diff --git a/src/agent/core/db.py b/src/agent/core/db.py index a1dcdc9..3e9f7d3 100644 --- a/src/agent/core/db.py +++ b/src/agent/core/db.py @@ -1,26 +1,39 @@ """共享数据库连接与建表工具。 系统定位: - 为 cron 任务存储与普通会话历史存储提供统一的 MySQL 连接、配置读取、 - 幂等建表与后端选择入口,避免两处存储后端配置漂移。 + 为 cron 任务存储与普通会话历史存储提供统一的数据库连接、配置读取、 + 幂等建表与后端选择入口,避免多处存储后端配置漂移。 + +配置模型(env_config.json): + - 存储数据库: ``STORAGE_DB_*``(host/port/user/password/database/path), + type 由 ``STORAGE_DB_TYPE`` 或 ``STORAGE_BACKEND`` 决定;缺省回退旧 + ``MYSQL_*``(同一角色,零迁移)。SQLite 文件默认 + ``/sqlite/storage.sqlite``。 + - 工具数据库: ``TOOL_DB_*``,与存储后端**完全隔离**(不回退 MYSQL_*), + 默认 mysql@localhost:3306/root。SQLite 默认 ``/sqlite/tool/`` + 目录(目录模式下每个 database 名 = 一个 ``<目录>/<库名>.sqlite`` 文件)。 可扩展性: - - 新增存储后端时复用 mysql_conn / ensure_tables / get_storage_backend。 + - 新增存储后端时复用 mysql_conn / ensure_tables / get_storage_backend / + sqlite_conn / ensure_sqlite_tables。 - ensure_tables 按表名细粒度幂等,cron 表与 session 表互不影响。 - - 连接配置统一读 MYSQL_*(cron 与 session 共用同一数据库)。 - 向后兼容:旧配置中的 CRON_MYSQL_* 仍可读取(逐步迁移)。 """ from __future__ import annotations +import os import re +import sqlite3 import threading from typing import Any +# ---------- 配置加载 ---------- def load_mysql_config() -> dict[str, Any]: - """从 env_config.json 读取 MySQL 连接配置(MYSQL_*,cron 与 session 共用)。 + """读取 env_config.json 的 MySQL 连接配置(MYSQL_*,存储后端旧键)。 向后兼容:优先读 MYSQL_*,回退到旧的 CRON_MYSQL_*。 + 存储后端新键见 load_storage_db_config;工具数据库见 load_tool_db_config。 """ try: from agent.core.config_manager import load_env_config @@ -36,28 +49,171 @@ def load_mysql_config() -> dict[str, Any]: } -def mysql_conn(): +def load_storage_db_config() -> dict[str, Any]: + """读取存储数据库连接配置(STORAGE_DB_*,缺省回退 MYSQL_*)。 + + 输出: + {type, host, port, user, password, database, path} + type: "mysql" | "sqlite"(由 STORAGE_DB_TYPE 显式指定,或按 + STORAGE_BACKEND 推导:sqlite 后端 → sqlite,否则 mysql) + path: sqlite 数据库文件路径(仅 sqlite 使用);缺省 + ``/sqlite/storage.sqlite``。 + """ + try: + from agent.core.config_manager import load_env_config + cfg = load_env_config() or {} + except Exception: + cfg = {} + backend = str(cfg.get("STORAGE_BACKEND") or cfg.get("CRON_STORAGE_BACKEND") or "json").lower() + db_type = str(cfg.get("STORAGE_DB_TYPE") or "").lower() + if db_type not in ("mysql", "sqlite"): + db_type = "sqlite" if backend == "sqlite" else "mysql" + return { + "type": db_type, + "host": str(cfg.get("STORAGE_DB_HOST") or cfg.get("MYSQL_HOST") + or cfg.get("CRON_MYSQL_HOST") or "localhost"), + "port": int(cfg.get("STORAGE_DB_PORT") or cfg.get("MYSQL_PORT") + or cfg.get("CRON_MYSQL_PORT") or 3306), + "user": str(cfg.get("STORAGE_DB_USER") or cfg.get("MYSQL_USER") + or cfg.get("CRON_MYSQL_USER") or "root"), + "password": str(cfg.get("STORAGE_DB_PASSWORD") or cfg.get("MYSQL_PASSWORD") + or cfg.get("CRON_MYSQL_PASSWORD") or ""), + "database": str(cfg.get("STORAGE_DB_DATABASE") or cfg.get("MYSQL_DATABASE") + or cfg.get("CRON_MYSQL_DATABASE") or "agent_history"), + "path": str(cfg.get("STORAGE_DB_PATH") or "") or _default_storage_sqlite_path(), + } + + +def load_tool_db_config() -> dict[str, Any]: + """读取工具数据库连接配置(TOOL_DB_*,与存储后端完全隔离,不回退 MYSQL_*)。 + + 输出: + {type, host, port, user, password, database, path} + type: "mysql" | "sqlite"(TOOL_DB_TYPE,缺省 mysql) + path: sqlite 数据库路径(仅 sqlite 使用);缺省 + ``/sqlite/tool``(目录:每个库名一个 .sqlite 文件)。 + """ + try: + from agent.core.config_manager import load_env_config + cfg = load_env_config() or {} + except Exception: + cfg = {} + db_type = str(cfg.get("TOOL_DB_TYPE") or "").lower() + if db_type not in ("mysql", "sqlite"): + db_type = "mysql" + return { + "type": db_type, + "host": str(cfg.get("TOOL_DB_HOST") or "localhost"), + "port": int(cfg.get("TOOL_DB_PORT") or 3306), + "user": str(cfg.get("TOOL_DB_USER") or "root"), + "password": str(cfg.get("TOOL_DB_PASSWORD") or ""), + "database": str(cfg.get("TOOL_DB_DATABASE") or ""), + "path": str(cfg.get("TOOL_DB_PATH") or "") or _default_tool_sqlite_dir(), + } + + +def _default_storage_sqlite_path() -> str: + """存储库默认 SQLite 文件:/sqlite/storage.sqlite。""" + from agent.utils.agent_utils import HISTORY_ROOT + return os.path.join(HISTORY_ROOT, "sqlite", "storage.sqlite") + + +def _default_tool_sqlite_dir() -> str: + """工具库默认 SQLite 目录:/sqlite/tool(每库一个文件)。""" + from agent.utils.agent_utils import HISTORY_ROOT + return os.path.join(HISTORY_ROOT, "sqlite", "tool") + + +# ---------- MySQL 连接 ---------- +def mysql_conn(cfg: dict[str, Any] | None = None, database: str | None = None): """创建一个 MySQL 连接(DictCursor,手动提交)。调用方负责 close。 + 输入: + cfg: 连接配置(默认读存储数据库配置 load_storage_db_config)。 + database: 覆盖目标库名(默认取 cfg 的 database)。 + 系统定位: - 所有 MySQL 仓库(cron / session)获取连接的统一入口。 + 存储仓库(cron / session)获取 MySQL 连接的统一入口; + 工具侧请使用 load_tool_db_config + connect_db。 """ import pymysql from pymysql.cursors import DictCursor - cfg = load_mysql_config() + cfg = cfg or load_storage_db_config() return pymysql.connect( host=cfg["host"], port=cfg["port"], user=cfg["user"], password=cfg["password"], - database=cfg["database"], + database=database or cfg["database"], charset="utf8mb4", cursorclass=DictCursor, autocommit=False, ) +# ---------- SQLite 连接 ---------- +def sqlite_conn(path: str) -> sqlite3.Connection: + """创建一个 SQLite 连接(row_factory=Row,WAL 模式,busy_timeout 5s)。 + + 输入: + path: 数据库文件路径(不存在则自动创建;父目录需已存在, + 可用 ensure_sqlite_dir 预先创建)。 + + 输出: + sqlite3.Connection。调用方负责 close。 + """ + conn = sqlite3.connect(path, timeout=5.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=5000") + try: + conn.execute("PRAGMA journal_mode=WAL") + except Exception: + pass + return conn + + +def sqlite_db_path(cfg: dict[str, Any], database: str | None = None) -> str: + """解析 SQLite 工具库的目标文件路径。 + + - cfg["path"] 为目录(或不存在且无 .sqlite 后缀)→ 每个库名一个文件: + ``<目录>/<库名>.sqlite``(database 必填)。 + - cfg["path"] 为 .sqlite 文件 → 所有操作使用该文件(database 忽略)。 + """ + base = str(cfg.get("path") or "") + if base.lower().endswith(".sqlite"): + return base + db = (database or "").strip() + if not db: + raise ValueError("SQLite 目录模式下 database 参数必填(库名 = 文件名)") + return os.path.join(base, f"{db}.sqlite") + + +def ensure_sqlite_dir(path: str) -> None: + """确保 SQLite 路径的父目录存在(幂等)。""" + parent = os.path.dirname(os.path.abspath(path)) + if parent: + os.makedirs(parent, exist_ok=True) + + +def connect_db(cfg: dict[str, Any], database: str | None = None): + """按数据库类型创建连接(统一分发入口)。 + + 输入: + cfg: load_tool_db_config() / load_storage_db_config() 的输出。 + database: 目标库名(mysql 覆盖 cfg.database;sqlite 目录模式为文件名)。 + + 输出: + pymysql 连接或 sqlite3.Connection。调用方负责 close。 + """ + if cfg.get("type") == "sqlite": + path = sqlite_db_path(cfg, database) + ensure_sqlite_dir(path) + return sqlite_conn(path) + return mysql_conn(cfg, database) + + +# ---------- MySQL 建表 ---------- _ensured_tables: set[str] = set() _database_ensured: bool = False _ensure_lock = threading.Lock() @@ -83,7 +239,7 @@ def ensure_database() -> None: import pymysql from pymysql.cursors import DictCursor - cfg = load_mysql_config() + cfg = load_storage_db_config() try: conn = pymysql.connect( host=cfg["host"], @@ -145,10 +301,134 @@ def ensure_tables(ddls: list[str]) -> None: conn.close() +# ---------- SQLite 建库建表 ---------- +def ensure_sqlite_database(path: str) -> None: + """幂等创建 SQLite 数据库(数据库文件,等价 MySQL 的 CREATE DATABASE IF NOT EXISTS)。 + + 输入: + path: 数据库文件路径(父目录自动创建)。 + + 输出: 无。 + + 系统定位: + 在 ensure_sqlite_tables 之前调用,确保数据库文件存在; + 不存在时创建空文件,已存在时为 no-op。 + """ + ensure_sqlite_dir(path) + if not os.path.isfile(path): + # sqlite3.connect 即创建空数据库文件(SQLite 无 CREATE DATABASE 语句) + conn = sqlite_conn(path) + conn.close() + + +def ensure_sqlite_tables(path: str, ddls: list[str]) -> None: + """幂等创建 SQLite 表(CREATE TABLE/INDEX IF NOT EXISTS)。 + + 输入: + path: 数据库文件路径(不存在则先建库,镜像 MySQL ensure_tables → + ensure_database 的顺序)。 + ddls: DDL 语句列表(含索引 DDL,全部 IF NOT EXISTS 幂等)。 + + 输出: 无。 + + 系统定位: + SqliteRecordRepository / SqliteTaskConfigRepository 初始化时调用, + 传入各自的建表 DDL。 + """ + # 先确保数据库文件存在,否则建表连接会创建空库后才有表结构 + ensure_sqlite_database(path) + conn = sqlite_conn(path) + try: + with conn: + for ddl in ddls: + conn.execute(ddl) + finally: + conn.close() + + +# SQLite 表 DDL(幂等,session / cron 互不影响)。 +# JSON 数据以 TEXT 列存储(sqlite 无原生 JSON 类型);sessions 直接含 extra 列。 +_SQLITE_SESSION_TABLE_DDLS = [ + """ + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_turn_id TEXT DEFAULT '', + extra TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS session_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL + ) + """, + "CREATE INDEX IF NOT EXISTS idx_sess_turn ON session_messages (session_id, turn_id)", + """ + CREATE TABLE IF NOT EXISTS session_tool_calls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + tool_calls TEXT NOT NULL, + meta TEXT + ) + """, + "CREATE INDEX IF NOT EXISTS idx_sess_tc_turn ON session_tool_calls (session_id, turn_id)", +] + +_SQLITE_CRON_TABLE_DDLS = [ + """ + CREATE TABLE IF NOT EXISTS cron_tasks ( + task_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + trigger_type TEXT NOT NULL, + cron_expr TEXT DEFAULT '', + interval_seconds INTEGER DEFAULT 0, + run_at TEXT DEFAULT '', + enabled INTEGER DEFAULT 1, + timeout_seconds INTEGER DEFAULT 300, + workspace TEXT DEFAULT '', + created_at TEXT NOT NULL, + next_run_at TEXT DEFAULT '', + last_run_at TEXT DEFAULT '', + last_status TEXT DEFAULT 'pending', + last_error TEXT, + executions TEXT + ) + """, + """ + CREATE TABLE IF NOT EXISTS cron_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + meta TEXT + ) + """, + "CREATE INDEX IF NOT EXISTS idx_task_turn ON cron_messages (task_id, turn_id)", + """ + CREATE TABLE IF NOT EXISTS cron_tool_calls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + tool_calls TEXT NOT NULL, + meta TEXT + ) + """, + "CREATE INDEX IF NOT EXISTS idx_task_tc_turn ON cron_tool_calls (task_id, turn_id)", +] + + def get_storage_backend() -> str: """读取 env_config.json 的 STORAGE_BACKEND,默认 json。 - cron 任务与普通会话历史共用此后端开关:mysql 时两者都入库。 + cron 任务与普通会话历史共用此后端开关:mysql / sqlite 时两者都入库。 向后兼容:优先读 STORAGE_BACKEND,回退到旧的 CRON_STORAGE_BACKEND。 """ try: diff --git a/src/agent/core/llm.py b/src/agent/core/llm.py index f3fd5b7..cc566d9 100644 --- a/src/agent/core/llm.py +++ b/src/agent/core/llm.py @@ -8,7 +8,7 @@ - Vision 描述接口 依赖: - env_utils 中的 LLM_* / ASR_BASE_URL + env_utils 中的 LLM_* 常量与 ASR 服务模型配置 可扩展性: - 可子类化覆盖 _preprocess_messages、_create_chat_result @@ -218,8 +218,30 @@ class Multimodel_LLM(ChatOpenAI): 可在此注入 token 统计回调、链路追踪 span。 """ messages = self._preprocess_messages(messages) + self._apply_session_extra_body() return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs) + def _apply_session_extra_body(self) -> None: + """按当前会话的思考档位动态设置 extra_body(会话级 THINKING_LEVEL)。 + + 档位存于该会话的 session_meta.json(见 agent_utils.set_session_thinking_level); + 每轮图执行前由 runtime 设置当前会话(tool_call_recorder.set_current_session), + 因此此处按线程上下文中的当前会话解析档位并覆盖构造时的全局默认值。 + 无会话上下文(cron 子进程、describe_image 等)保持构造时的 extra_body 不变。 + """ + try: + from agent.history.tool_call_recorder import get_current_session + from agent.utils.env_utils import get_session_thinking_level, get_thinking_extra_body + sid = get_current_session() + if not sid: + return + lv = get_session_thinking_level(sid) + if not lv: + return + self.extra_body = get_thinking_extra_body(session_id=sid) + except Exception: + pass + def _create_chat_result(self, response, generation_info=None) -> ChatResult: """解析 API 响应:深度思考归一化、XML/JSON tool_call 提取。 diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py index 51c7c4f..3b35ffe 100644 --- a/src/agent/core/nodes.py +++ b/src/agent/core/nodes.py @@ -23,7 +23,7 @@ from agent.core.loop_detector import build_loop_reflection_prompt from agent.core.state import AgentState from agent.memory.system_prompt import SUB_AGENT_OOM_PROMPT from agent.utils.image_utils import image_bytes_to_openai_image_url_part -from agent.utils.tool_call_utils import normalize_tool_call, extract_reasoning_text +from agent.utils.tool_call_utils import normalize_tool_call, extract_thought, extract_reflection from langchain_core.messages import SystemMessage def _has_image_content(msg: HumanMessage) -> bool: @@ -105,7 +105,8 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: # 剥离历史模型思考文本:上一/几轮的思考不进入本轮上下文。 # 带 tool_calls 的 AIMessage,其 content 仅是思维链,保留 tool_calls 即可 - # (思考内容已由 extract_reasoning_text 单独记录到前端 think 面板)。 + # (思考/反思已由 extract_thought/extract_reflection 单独记录到 think 面板, + # 并由 build_tool_history_messages 以 [历史思考]/[历史反思] 形式注入)。 cleaned: list = [] for m in result: if isinstance(m, AIMessage) and getattr(m, "tool_calls", None) and getattr(m, "content", None): @@ -411,6 +412,51 @@ def maybe_compress_context(state: AgentState) -> dict: return {"_need_compress": False} +def _inject_turn_thinking(messages: list[Any]) -> list[Any]: + """把本轮图消息中携带的思考/反思(reasoning_content / content)以合成消息插回上下文。 + + reasoning_content 不被 LLM API 回传(additional_kwargs 不在标准消息 + schema 内),跨步骤调用时模型会丢失自己的思考链;此处在其所属的 + 工具调用 AIMessage 之前插入 ``[思考]`` / ``[反思]`` 合成消息 + (因果交错),使模型在后续步骤中仍能看到本轮已产生的思考与反思。 + + 注意: + - 仅注入到本次调用的 messages_for_llm,不进图状态,无副作用; + - 思考与反思独立提取(extract_thought / extract_reflection), + 可同时存在(既有深度思考又有反思的通用情况),分别注入; + - 思考文本与位置确定性构建(同一图状态下结果恒定),id 稳定, + 不破坏 LLM 前缀缓存; + - trim_messages 已剥空 content 时反思自然跳过(思考存于 + additional_kwargs,不受影响)。 + """ + out: list[Any] = [] + think_seq = 0 + reflect_seq = 0 + for m in messages: + if isinstance(m, AIMessage) and getattr(m, "tool_calls", None): + try: + thought = extract_thought(m) + reflection = extract_reflection(m) + except Exception: + thought = reflection = "" + if thought: + out.append(HumanMessage( + content=f"[思考] {thought}", + additional_kwargs={"synthetic": True}, + id=f"turn_think_{think_seq}", + )) + think_seq += 1 + if reflection: + out.append(HumanMessage( + content=f"[反思] {reflection}", + additional_kwargs={"synthetic": True}, + id=f"turn_reflect_{reflect_seq}", + )) + reflect_seq += 1 + out.append(m) + return out + + def make_call_model_node( model_with_tools: Any, trajectory_rounds: int = 3, @@ -454,6 +500,9 @@ def make_call_model_node( # 固定反思引导已并入系统提示词前缀,此处仅注入变化部分: # TodoList 状态与循环提醒;无 TodoList 且无循环提醒时不注入(省 token)。 messages_for_llm = list(filtered_messages) + # 注入本轮已产生的思考(reasoning_content 不被 API 回传,跨步骤需回插; + # 因果交错:思考位于其工具调用 AIMessage 之前) + messages_for_llm = _inject_turn_thinking(messages_for_llm) tail = _build_dynamic_tail_prompt(filtered_messages, _sid) if tail: # 长期记忆:持久化本轮动态尾部,供后续轮次注入查看 @@ -495,21 +544,20 @@ def make_call_model_node( state["_need_compress"] = need_compress # 提取模型产出的思考/反思内容,记录到前端 think 面板与后端记录 - # - 思考关闭:模型在反思提示引导下输出的 content(一句话反思) - # - 思考开启:深度思考(additional_kwargs.reasoning_content 或内联 块, - # 由 _create_chat_result 归一化到 reasoning_content) - response_content = extract_reasoning_text(response) - if response_content and isinstance(response_content, str) and response_content.strip(): - has_tool_calls = getattr(response, "tool_calls", None) - if has_tool_calls: - # 有 tool_calls 时 content 是思考过程,记录到 think 面板 - try: - from agent.history.tool_call_recorder import record_reflection_live, get_current_session - sid = get_current_session() - if sid: - record_reflection_live(sid, response_content.strip()) - except Exception: - pass + # - 思考 = reasoning_content(深度思考档位) + # - 反思 = content(思考关闭档位为引导的一句话反思;思考开启时 + # 与思考可同时存在,独立存储、独立注入上下文) + thought = extract_thought(response) + reflection = extract_reflection(response) + if (thought or reflection) and getattr(response, "tool_calls", None): + # 有 tool_calls 时思考/反思是该轮工具调用的思维过程,记录到 think 面板 + try: + from agent.history.tool_call_recorder import record_reflection_live, get_current_session + sid = get_current_session() + if sid: + record_reflection_live(sid, thought, reflection) + except Exception: + pass if hasattr(response, "tool_calls") and response.tool_calls: for tc in response.tool_calls: diff --git a/src/agent/core/storage.py b/src/agent/core/storage.py index f90b176..90e161b 100644 --- a/src/agent/core/storage.py +++ b/src/agent/core/storage.py @@ -46,6 +46,11 @@ from agent.core.db import ( mysql_conn, ensure_tables, get_storage_backend, + sqlite_conn, + ensure_sqlite_tables, + load_storage_db_config, + _SQLITE_SESSION_TABLE_DDLS, + _SQLITE_CRON_TABLE_DDLS, ) # 定时任务根目录:history/cron @@ -80,7 +85,8 @@ class ConversationRecordRepository(Protocol): def load_turn_messages(self, session_id: str, turn_id: str) -> list[dict] | None: ... def list_turn_ids(self, session_id: str) -> list[str]: ... def save_tool_calls( - self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None + self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None, + reflections: list[dict] | None = None, ) -> None: ... def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None: ... def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None: ... @@ -160,6 +166,7 @@ _CRON_TABLE_DDLS = [ run_at VARCHAR(32) DEFAULT '', enabled TINYINT(1) DEFAULT 1, timeout_seconds INT DEFAULT 300, + workspace VARCHAR(512) DEFAULT '', created_at VARCHAR(32) NOT NULL, next_run_at VARCHAR(32) DEFAULT '', last_run_at VARCHAR(32) DEFAULT '', @@ -345,7 +352,8 @@ class JsonRecordRepository: # ---- 工具调用记录 ---- def save_tool_calls( - self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None + self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None, + reflections: list[dict] | None = None, ) -> None: if not session_id or not turn_id: return @@ -359,6 +367,10 @@ class JsonRecordRepository: } if meta: record.update(meta) # meta 全量透传,消费方新增字段无需改动存储层 + if reflections: + # 尾部思考记录(未被任何工具调用消费的反思),随 turn 一并持久化, + # 供后续轮次 build_tool_history_messages 注入上下文 + record["reflections"] = reflections with open(self._tool_path(session_id, turn_id), "w", encoding="utf-8") as f: json.dump(record, f, ensure_ascii=False, indent=2) @@ -416,17 +428,25 @@ class JsonRecordRepository: json.dump(record, f, ensure_ascii=False, indent=2) # ---- 会话元数据(extra = {tokens, token_breakdown, agent_meta}) ---- + # 统一存于 session_meta.json:{...agent_meta, tokens, breakdown} + # 旧版 _session_tokens.json 在读取/写入时自动合并进 session_meta.json 并删除(迁移)。 + def load_session_extra(self, session_id: str) -> dict[str, Any]: extra: dict[str, Any] = {} - # agent_meta ↔ session_meta.json + # agent_meta + tokens/breakdown ↔ session_meta.json(合并后的单一文件) mp = self._meta_path(session_id) if os.path.isfile(mp): try: with open(mp, "r", encoding="utf-8") as f: - extra["agent_meta"] = json.load(f) + data = json.load(f) + extra["agent_meta"] = data + if "tokens" in data: + extra["tokens"] = data.get("tokens", 0) + if "breakdown" in data and isinstance(data.get("breakdown"), dict): + extra["token_breakdown"] = data["breakdown"] except (json.JSONDecodeError, OSError): pass - # tokens / token_breakdown ↔ _session_tokens.json + # 兼容迁移:旧 _session_tokens.json 仍存在时合并(其数据优先)并删除 tf = self._token_path(session_id) if os.path.isfile(tf): try: @@ -436,8 +456,16 @@ class JsonRecordRepository: extra["tokens"] = data.get("tokens", 0) if "breakdown" in data and isinstance(data.get("breakdown"), dict): extra["token_breakdown"] = data["breakdown"] + if "workspace" in data: + meta = dict(extra.get("agent_meta") or {}) + meta["workspace"] = data.get("workspace") or "" + extra["agent_meta"] = meta except (json.JSONDecodeError, OSError): pass + try: + os.remove(tf) + except OSError: + pass return extra def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None: @@ -445,18 +473,35 @@ class JsonRecordRepository: return sess_dir = self._session_dir(session_id) os.makedirs(sess_dir, exist_ok=True) + # 与磁盘已有内容合并,避免单字段更新(如仅写 agent_meta)覆盖 tokens + merged: dict[str, Any] = {} + mp = self._meta_path(session_id) + if os.path.isfile(mp): + try: + with open(mp, "r", encoding="utf-8") as f: + merged = json.load(f) + except (json.JSONDecodeError, OSError): + merged = {} if "agent_meta" in extra: - with open(self._meta_path(session_id), "w", encoding="utf-8") as f: - json.dump(extra.get("agent_meta") or {}, f, ensure_ascii=False, indent=2) - if "tokens" in extra or "token_breakdown" in extra: - payload: dict[str, Any] = { - "session_id": session_id, - "tokens": extra.get("tokens", 0), - } - if "token_breakdown" in extra and isinstance(extra.get("token_breakdown"), dict): - payload["breakdown"] = extra["token_breakdown"] - with open(self._token_path(session_id), "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) + merged.update(extra.get("agent_meta") or {}) + if "tokens" in extra: + merged["tokens"] = extra.get("tokens", 0) + if "token_breakdown" in extra: + merged["breakdown"] = extra["token_breakdown"] + else: + # 显式传 tokens 但无 breakdown:视为清除旧的 breakdown + merged.pop("breakdown", None) + elif "token_breakdown" in extra: + merged["breakdown"] = extra["token_breakdown"] + with open(mp, "w", encoding="utf-8") as f: + json.dump(merged, f, ensure_ascii=False, indent=2) + # 迁移:不再使用 _session_tokens.json,删除旧文件 + tf = self._token_path(session_id) + if os.path.isfile(tf): + try: + os.remove(tf) + except OSError: + pass # ---- 会话生命周期 ---- def list_session_ids(self) -> list[str]: @@ -466,17 +511,14 @@ class JsonRecordRepository: entries: list[tuple[float, str]] = [] for name in os.listdir(root): dir_path = os.path.join(root, name) + # 会话根目录下存在目录即视为会话(含空会话——新建未发消息的会话 + # 也要出现在列表中;与 MySQL 后端按 sessions 表行列出对齐) if not os.path.isdir(dir_path): continue try: - has_turns = any( - f.startswith(TURN_JSON_PREFIX) and f.endswith(".json") - for f in os.listdir(dir_path) - ) - except OSError: - has_turns = False - if has_turns: entries.append((os.path.getmtime(dir_path), name)) + except OSError: + continue entries.sort(key=lambda x: x[0], reverse=True) return [name for _, name in entries] @@ -730,7 +772,8 @@ class MysqlRecordRepository: # ---- 工具调用记录 ---- def save_tool_calls( - self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None + self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None, + reflections: list[dict] | None = None, ) -> None: if not session_id or not turn_id: return @@ -743,13 +786,17 @@ class MysqlRecordRepository: (session_id, turn_id), ) if self.scope == "cron": - # cron_tool_calls 无 meta 列(与既有表结构一致) + # cron_tool_calls 无 meta 列(与既有表结构一致), + # 尾部思考记录无法入库;JSON 后端下完整持久化 cur.execute( f"INSERT INTO {self._tool_table()} ({sid_col}, turn_id, tool_calls) " "VALUES (%s,%s,%s)", (session_id, turn_id, json.dumps(tool_calls or [], ensure_ascii=False)), ) else: + # 尾部思考并入 meta(_row_to_record 全量透传,读取时合并回记录顶层) + if reflections: + meta = {**(meta or {}), "reflections": reflections} cur.execute( f"INSERT INTO {self._tool_table()} " "(session_id, turn_id, tool_calls, meta) VALUES (%s,%s,%s,%s)", @@ -1053,6 +1100,465 @@ class MysqlRecordRepository: conn.close() +# ---------- SQLite 实现 ---------- +class SqliteRecordRepository: + """基于 SQLite(内置 sqlite3)的记录仓库。 + + session 与 cron 双作用域共用同一个数据库文件(默认 + ``/sqlite/storage.sqlite``),表相互独立: + session 作用域: sessions / session_messages / session_tool_calls; + cron 作用域: cron_messages / cron_tool_calls。 + JSON 数据以 TEXT 列存储(sqlite 无原生 JSON 类型),读取时统一反序列化; + 消息与工具调用记录原样 JSON 入库,meta 全量透传(不白名单)。 + cron_tool_calls 含 meta 列(与 MySQL 表结构不同——SQLite 建表无迁移负担), + 尾部思考(reflections)可完整落库。 + """ + + def __init__(self, scope: str = "session", db_path: str | None = None) -> None: + self.scope = scope if scope == "cron" else "session" + self._path_override = db_path + if self.scope == "cron": + ensure_sqlite_tables(self._db_path(), _SQLITE_CRON_TABLE_DDLS) + else: + ensure_sqlite_tables(self._db_path(), _SQLITE_SESSION_TABLE_DDLS) + + # ---- 表名 / 连接 ---- + def _db_path(self) -> str: + if self._path_override: + return self._path_override + return load_storage_db_config()["path"] + + def _conn(self): + return sqlite_conn(self._db_path()) + + def _messages_table(self) -> str: + return "cron_messages" if self.scope == "cron" else "session_messages" + + def _tool_table(self) -> str: + return "cron_tool_calls" if self.scope == "cron" else "session_tool_calls" + + def _sessions_table(self) -> str | None: + return None if self.scope == "cron" else "sessions" + + def _sid_col(self) -> str: + return "task_id" if self.scope == "cron" else "session_id" + + @staticmethod + def _now_iso() -> str: + from datetime import datetime + return datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + + @staticmethod + def _loads(raw: Any) -> Any: + """TEXT 列统一反序列化(兼容已解析的 dict/list 输入)。""" + if isinstance(raw, str): + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {} + if isinstance(raw, (bytes, bytearray)): + try: + return json.loads(raw.decode("utf-8")) + except Exception: + return {} + return raw + + @staticmethod + def _dumps(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False) if obj is not None else None + + @staticmethod + def _row_to_dict(row: Any) -> dict[str, Any]: + """sqlite3.Row → dict。""" + return dict(row) if row is not None else {} + + # ---- 消息 ---- + def save_turn(self, session_id: str, turn_id: str, messages: list[dict]) -> None: + if not session_id or not turn_id: + return + sid_col = self._sid_col() + conn = self._conn() + try: + with conn: + conn.execute( + f"DELETE FROM {self._messages_table()} WHERE {sid_col}=? AND turn_id=?", + (session_id, turn_id), + ) + if self.scope == "cron": + # cron 表按 role/content/meta 分列存储,附件路径转相对 CRON_ROOT + messages = [_to_rel_paths(m, session_id) for m in messages or []] + for msg in messages: + content = msg.get("content") + meta = msg.get("meta") + conn.execute( + f"INSERT INTO {self._messages_table()} " + f"({sid_col}, turn_id, role, content, meta) VALUES (?,?,?,?,?)", + ( + session_id, turn_id, msg.get("role", "user"), + self._dumps(content), + self._dumps(meta) if meta is not None else None, + ), + ) + else: + for seq, msg in enumerate(messages or []): + conn.execute( + f"INSERT INTO {self._messages_table()} " + f"(session_id, turn_id, seq, message) VALUES (?,?,?,?)", + (session_id, turn_id, seq, self._dumps(msg)), + ) + now = self._now_iso() + conn.execute( + """ + INSERT INTO sessions (session_id, created_at, updated_at, last_turn_id) + VALUES (?,?,?,?) + ON CONFLICT(session_id) DO UPDATE SET + updated_at=excluded.updated_at, last_turn_id=excluded.last_turn_id + """, + (session_id, now, now, turn_id), + ) + finally: + conn.close() + + def load_messages(self, session_id: str) -> list[dict]: + sid_col = self._sid_col() + conn = self._conn() + try: + if self.scope == "cron": + rows = conn.execute( + f"SELECT turn_id, role, content, meta FROM {self._messages_table()} " + f"WHERE {sid_col}=? ORDER BY id", + (session_id,), + ).fetchall() or [] + msgs: list[dict] = [] + for r in rows: + row = self._row_to_dict(r) + content = self._loads(row.get("content")) + meta = self._loads(row.get("meta")) + msg: dict[str, Any] = {"role": row.get("role", "user"), "content": content} + if isinstance(meta, dict) and meta: + msg["meta"] = meta + msgs.append(_resolve_msg_paths(msg, session_id)) + return msgs + rows = conn.execute( + f"SELECT message FROM {self._messages_table()} " + f"WHERE session_id=? ORDER BY turn_id ASC, seq ASC", + (session_id,), + ).fetchall() or [] + return [self._loads(self._row_to_dict(r)["message"]) for r in rows] + finally: + conn.close() + + def load_turn_messages(self, session_id: str, turn_id: str) -> list[dict] | None: + sid_col = self._sid_col() + conn = self._conn() + try: + if self.scope == "cron": + rows = conn.execute( + f"SELECT turn_id, role, content, meta FROM {self._messages_table()} " + f"WHERE {sid_col}=? AND turn_id=? ORDER BY id", + (session_id, turn_id), + ).fetchall() or [] + if not rows: + return None + msgs: list[dict] = [] + for r in rows: + row = self._row_to_dict(r) + content = self._loads(row.get("content")) + meta = self._loads(row.get("meta")) + msg: dict[str, Any] = {"role": row.get("role", "user"), "content": content} + if isinstance(meta, dict) and meta: + msg["meta"] = meta + msgs.append(_resolve_msg_paths(msg, session_id)) + return msgs + rows = conn.execute( + f"SELECT message FROM {self._messages_table()} " + f"WHERE session_id=? AND turn_id=? ORDER BY seq ASC", + (session_id, turn_id), + ).fetchall() or [] + if not rows: + return None + return [self._loads(self._row_to_dict(r)["message"]) for r in rows] + finally: + conn.close() + + def list_turn_ids(self, session_id: str) -> list[str]: + sid_col = self._sid_col() + conn = self._conn() + try: + rows = conn.execute( + f"SELECT DISTINCT turn_id FROM {self._messages_table()} " + f"WHERE {sid_col}=? ORDER BY turn_id", + (session_id,), + ).fetchall() or [] + return [self._row_to_dict(r)["turn_id"] for r in rows] + finally: + conn.close() + + # ---- 工具调用记录 ---- + def save_tool_calls( + self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None, + reflections: list[dict] | None = None, + ) -> None: + if not session_id or not turn_id: + return + sid_col = self._sid_col() + # 尾部思考并入 meta(_row_to_record 全量透传,读取时合并回记录顶层) + if reflections: + meta = {**(meta or {}), "reflections": reflections} + conn = self._conn() + try: + with conn: + conn.execute( + f"DELETE FROM {self._tool_table()} WHERE {sid_col}=? AND turn_id=?", + (session_id, turn_id), + ) + conn.execute( + f"INSERT INTO {self._tool_table()} " + f"({sid_col}, turn_id, tool_calls, meta) VALUES (?,?,?,?)", + ( + session_id, + turn_id, + self._dumps(tool_calls or []), + self._dumps(meta) if meta else None, + ), + ) + finally: + conn.close() + + @classmethod + def _row_to_record(cls, row: dict[str, Any], session_id: str, turn_id: str, + meta_key: str | None = None) -> dict[str, Any]: + """把工具调用表行还原为与 tool_*.json 同构的 record dict。 + + meta 全量透传(不做字段白名单):消费方新增 meta 字段无需改动存储层。 + """ + tool_calls = cls._loads(row.get("tool_calls")) or [] + record: dict[str, Any] = { + "session_id": session_id, + "turn_id": turn_id, + "tool_calls": tool_calls, + "timestamp": turn_id, + } + if meta_key and row.get(meta_key) is not None: + meta = cls._loads(row.get(meta_key)) + if isinstance(meta, dict): + record.update(meta) + return record + + def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None: + sid_col = self._sid_col() + conn = self._conn() + try: + row = conn.execute( + f"SELECT turn_id, tool_calls, meta FROM {self._tool_table()} " + f"WHERE {sid_col}=? AND turn_id=?", + (session_id, turn_id), + ).fetchone() + row = self._row_to_dict(row) + if not row: + return None + return self._row_to_record(row, session_id, turn_id, meta_key="meta") + finally: + conn.close() + + def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None: + sid_col = self._sid_col() + conn = self._conn() + try: + row = conn.execute( + f"SELECT turn_id, tool_calls, meta FROM {self._tool_table()} " + f"WHERE {sid_col}=? ORDER BY turn_id DESC LIMIT 1", + (session_id,), + ).fetchone() + row = self._row_to_dict(row) + if not row: + return None + return self._row_to_record(row, session_id, row["turn_id"], meta_key="meta") + finally: + conn.close() + + def list_turn_records(self, session_id: str, limit: int = 5) -> list[dict[str, Any]]: + """按 turn_id 倒序返回最近 limit 条 turn 记录(与 tool_*.json 同构)。""" + sid_col = self._sid_col() + conn = self._conn() + try: + rows = conn.execute( + f"SELECT turn_id, tool_calls, meta FROM {self._tool_table()} " + f"WHERE {sid_col}=? ORDER BY turn_id DESC LIMIT ?", + (session_id, limit), + ).fetchall() or [] + return [ + self._row_to_record(self._row_to_dict(r), session_id, r["turn_id"], meta_key="meta") + for r in rows + ] + finally: + conn.close() + + def persist_live_records( + self, session_id: str, turn_id: str, records: list[dict], started_at: float | None = None + ) -> None: + """实时工具记录不增量入库(由 save_tool_calls 在 turn 结束时统一落库)。""" + + # ---- 会话元数据(extra) ---- + def load_session_extra(self, session_id: str) -> dict[str, Any]: + """读取 sessions.extra 列(含 tokens + agent_meta);cron 作用域无此表 → {}。""" + table = self._sessions_table() + if table is None or not session_id: + return {} + conn = self._conn() + try: + row = conn.execute("SELECT extra FROM sessions WHERE session_id=?", (session_id,)).fetchone() + row = self._row_to_dict(row) + raw = row.get("extra") if row else None + return self._loads(raw) if raw is not None else {} + finally: + conn.close() + + def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None: + """写入 sessions.extra 列(与既有 extra 增量合并,不覆盖未涉及的键)。 + + 与 json 实现按 key 拆分的语义一致:消费方既可 load-modify-save 全量写入, + 也可只传增量键。cron 作用域无此表 → no-op。 + """ + table = self._sessions_table() + if table is None or not session_id: + return + conn = self._conn() + try: + with conn: + row = conn.execute("SELECT extra FROM sessions WHERE session_id=?", (session_id,)).fetchone() + row = self._row_to_dict(row) + existing = self._loads(row.get("extra")) if row else {} + if not isinstance(existing, dict): + existing = {} + existing.update(extra or {}) + now = self._now_iso() + conn.execute( + """ + INSERT INTO sessions (session_id, created_at, updated_at, extra) + VALUES (?,?,?,?) + ON CONFLICT(session_id) DO UPDATE SET + updated_at=excluded.updated_at, extra=excluded.extra + """, + (session_id, now, now, self._dumps(existing)), + ) + finally: + conn.close() + + # ---- 会话生命周期 ---- + def list_session_ids(self) -> list[str]: + conn = self._conn() + try: + if self.scope == "cron": + rows = conn.execute( + "SELECT task_id AS sid FROM cron_messages " + "GROUP BY task_id ORDER BY MAX(id) DESC" + ).fetchall() or [] + else: + rows = conn.execute( + "SELECT session_id AS sid FROM sessions ORDER BY updated_at DESC" + ).fetchall() or [] + return [self._row_to_dict(r)["sid"] for r in rows] + finally: + conn.close() + + def get_last_turn_id(self, session_id: str) -> str: + sid_col = self._sid_col() + conn = self._conn() + try: + if self.scope == "cron": + row = conn.execute( + f"SELECT MAX(turn_id) AS m FROM {self._messages_table()} WHERE {sid_col}=?", + (session_id,), + ).fetchone() + row = self._row_to_dict(row) + return (row or {}).get("m") or "" + row = conn.execute( + "SELECT last_turn_id FROM sessions WHERE session_id=?", (session_id,) + ).fetchone() + row = self._row_to_dict(row) + if row and row.get("last_turn_id"): + return row["last_turn_id"] + row2 = conn.execute( + "SELECT MAX(turn_id) AS m FROM session_messages WHERE session_id=?", (session_id,) + ).fetchone() + row2 = self._row_to_dict(row2) + return (row2 or {}).get("m") or "" + finally: + conn.close() + + def delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> int: + if not session_id: + return 0 + sid_col = self._sid_col() + conn = self._conn() + try: + with conn: + conn.execute( + f"DELETE FROM {self._messages_table()} " + f"WHERE {sid_col}=? AND turn_id > ?", + (session_id, keep_until_turn_id), + ) + conn.execute( + f"DELETE FROM {self._tool_table()} " + f"WHERE {sid_col}=? AND turn_id > ?", + (session_id, keep_until_turn_id), + ) + if self.scope == "session": + row = conn.execute( + "SELECT MAX(turn_id) AS m FROM session_messages WHERE session_id=?", + (session_id,), + ).fetchone() + row = self._row_to_dict(row) + last = (row or {}).get("m") or "" + conn.execute( + "UPDATE sessions SET last_turn_id=?, updated_at=? WHERE session_id=?", + (last, self._now_iso(), session_id), + ) + finally: + conn.close() + return 0 + + def delete_tool_records(self, session_id: str, keep_turn_ids: set[str] | None = None) -> None: + if not session_id: + return + sid_col = self._sid_col() + conn = self._conn() + try: + with conn: + if keep_turn_ids: + placeholders = ",".join(["?"] * len(keep_turn_ids)) + conn.execute( + f"DELETE FROM {self._tool_table()} " + f"WHERE {sid_col}=? AND turn_id NOT IN ({placeholders})", + (session_id, *sorted(keep_turn_ids)), + ) + else: + conn.execute( + f"DELETE FROM {self._tool_table()} WHERE {sid_col}=?", (session_id,) + ) + finally: + conn.close() + + def delete_session(self, session_id: str) -> None: + if not session_id: + return + sid_col = self._sid_col() + conn = self._conn() + try: + with conn: + conn.execute( + f"DELETE FROM {self._messages_table()} WHERE {sid_col}=?", (session_id,) + ) + conn.execute( + f"DELETE FROM {self._tool_table()} WHERE {sid_col}=?", (session_id,) + ) + if self.scope == "session": + conn.execute("DELETE FROM sessions WHERE session_id=?", (session_id,)) + finally: + conn.close() + + # ---------- JSON 任务仓库 ---------- class JsonTaskConfigRepository: """基于 JSON 文件的任务仓库。 @@ -1167,6 +1673,7 @@ def _row_to_task(row: dict[str, Any]) -> CronTask: }, "enabled": bool(row.get("enabled", 1)), "timeout_seconds": int(row.get("timeout_seconds", 300) or 300), + "workspace": row.get("workspace", "") or "", "created_at": row.get("created_at", "") or "", "next_run_at": row.get("next_run_at", "") or "", "last_run_at": row.get("last_run_at", "") or "", @@ -1186,6 +1693,21 @@ class MysqlTaskConfigRepository: def __init__(self) -> None: _ensure_cron_tables() + self._ensure_workspace_column() + + def _ensure_workspace_column(self) -> None: + """幂等添加 cron_tasks.workspace 列(存量表迁移)。""" + conn = mysql_conn() + try: + with conn.cursor() as cur: + cur.execute( + "ALTER TABLE cron_tasks ADD COLUMN workspace VARCHAR(512) DEFAULT ''" + ) + conn.commit() + except Exception: + pass # 列已存在 → 忽略 + finally: + conn.close() def list_tasks(self) -> list[CronTask]: conn = mysql_conn() @@ -1219,21 +1741,22 @@ class MysqlTaskConfigRepository: """ INSERT INTO cron_tasks (task_id, name, prompt, trigger_type, cron_expr, interval_seconds, run_at, - enabled, timeout_seconds, created_at, next_run_at, last_run_at, + enabled, timeout_seconds, workspace, created_at, next_run_at, last_run_at, last_status, last_error, executions) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE name=VALUES(name), prompt=VALUES(prompt), trigger_type=VALUES(trigger_type), cron_expr=VALUES(cron_expr), interval_seconds=VALUES(interval_seconds), run_at=VALUES(run_at), enabled=VALUES(enabled), - timeout_seconds=VALUES(timeout_seconds), next_run_at=VALUES(next_run_at), + timeout_seconds=VALUES(timeout_seconds), workspace=VALUES(workspace), + next_run_at=VALUES(next_run_at), last_run_at=VALUES(last_run_at), last_status=VALUES(last_status), last_error=VALUES(last_error), executions=VALUES(executions) """, ( task.task_id, task.name, task.prompt, trig.type, trig.cron, trig.interval_seconds, trig.run_at, int(task.enabled), - task.timeout_seconds, task.created_at, task.next_run_at, + task.timeout_seconds, task.workspace, task.created_at, task.next_run_at, task.last_run_at, task.last_status, task.last_error, execs_json, ), ) @@ -1314,6 +1837,152 @@ class MysqlTaskConfigRepository: conn.close() +# ---------- SQLite 任务仓库 ---------- +class SqliteTaskConfigRepository: + """基于 SQLite(内置 sqlite3)的任务仓库。 + + 表: cron_tasks(与 SqliteRecordRepository 共用同一数据库文件)。 + trigger 拆为 trigger_type/cron_expr/interval_seconds/run_at 列, + executions 存为 TEXT 列(JSON)。 + """ + + def __init__(self, db_path: str | None = None) -> None: + self._path_override = db_path + ensure_sqlite_tables(self._db_path(), _SQLITE_CRON_TABLE_DDLS) + + def _db_path(self) -> str: + if self._path_override: + return self._path_override + return load_storage_db_config()["path"] + + def _conn(self): + return sqlite_conn(self._db_path()) + + @staticmethod + def _loads(raw: Any) -> Any: + if isinstance(raw, str): + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return raw + return raw + + def list_tasks(self) -> list[CronTask]: + conn = self._conn() + try: + rows = conn.execute("SELECT * FROM cron_tasks ORDER BY created_at DESC").fetchall() or [] + return [_row_to_task(dict(r)) for r in rows] + finally: + conn.close() + + def get_task(self, task_id: str) -> CronTask | None: + conn = self._conn() + try: + row = conn.execute("SELECT * FROM cron_tasks WHERE task_id=?", (task_id,)).fetchone() + return _row_to_task(dict(row)) if row else None + finally: + conn.close() + + def save_task(self, task: CronTask) -> None: + if not task.task_id: + return + trig = task.trigger + execs_json = json.dumps([e.to_dict() for e in task.executions], ensure_ascii=False) + conn = self._conn() + try: + with conn: + conn.execute( + """ + INSERT INTO cron_tasks + (task_id, name, prompt, trigger_type, cron_expr, interval_seconds, run_at, + enabled, timeout_seconds, workspace, created_at, next_run_at, last_run_at, + last_status, last_error, executions) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(task_id) DO UPDATE SET + name=excluded.name, prompt=excluded.prompt, + trigger_type=excluded.trigger_type, cron_expr=excluded.cron_expr, + interval_seconds=excluded.interval_seconds, run_at=excluded.run_at, + enabled=excluded.enabled, timeout_seconds=excluded.timeout_seconds, + workspace=excluded.workspace, next_run_at=excluded.next_run_at, + last_run_at=excluded.last_run_at, last_status=excluded.last_status, + last_error=excluded.last_error, executions=excluded.executions + """, + ( + task.task_id, task.name, task.prompt, trig.type, trig.cron, + trig.interval_seconds, trig.run_at, int(task.enabled), + task.timeout_seconds, task.workspace, task.created_at, task.next_run_at, + task.last_run_at, task.last_status, task.last_error, execs_json, + ), + ) + finally: + conn.close() + + def delete_task(self, task_id: str) -> None: + if not task_id: + return + conn = self._conn() + try: + with conn: + conn.execute("DELETE FROM cron_tasks WHERE task_id=?", (task_id,)) + conn.execute("DELETE FROM cron_messages WHERE task_id=?", (task_id,)) + conn.execute("DELETE FROM cron_tool_calls WHERE task_id=?", (task_id,)) + finally: + conn.close() + + def update_status( + self, + task_id: str, + status: str, + *, + error: str = "", + last_run_at: str = "", + next_run_at: str = "", + ) -> None: + sets: list[str] = ["last_status=?"] + vals: list[Any] = [status] + if error: + sets.append("last_error=?") + vals.append(error) + elif status != "failed": + sets.append("last_error=?") + vals.append("") + if last_run_at: + sets.append("last_run_at=?") + vals.append(last_run_at) + sets.append("next_run_at=?") + vals.append(next_run_at) + vals.append(task_id) + conn = self._conn() + try: + with conn: + conn.execute( + f"UPDATE cron_tasks SET {', '.join(sets)} WHERE task_id=?", + vals, + ) + finally: + conn.close() + + def append_execution(self, task_id: str, exec_log: dict[str, Any]) -> None: + conn = self._conn() + try: + with conn: + row = conn.execute( + "SELECT executions FROM cron_tasks WHERE task_id=?", (task_id,) + ).fetchone() + execs: list = [] + if row: + raw = self._loads(dict(row).get("executions")) + if isinstance(raw, list): + execs = raw + execs.append(exec_log) + conn.execute( + "UPDATE cron_tasks SET executions=? WHERE task_id=?", + (json.dumps(execs, ensure_ascii=False), task_id), + ) + finally: + conn.close() + + # ---------- 工厂 ---------- _repo_cache: dict[str, Any] = {} @@ -1329,18 +1998,24 @@ def get_record_repository(scope: str | None = None) -> ConversationRecordReposit if key not in _repo_cache: if backend == "mysql": _repo_cache[key] = MysqlRecordRepository(scope) + elif backend == "sqlite": + _repo_cache[key] = SqliteRecordRepository(scope) else: _repo_cache[key] = JsonRecordRepository(scope) return _repo_cache[key] # type: ignore[return-value] def get_task_repository() -> TaskConfigRepository: - """获取任务仓库实例(按配置选择 json / mysql)。""" + """获取任务仓库实例(按配置选择 json / mysql / sqlite)。""" backend = get_storage_backend() if backend == "mysql": if "task_mysql" not in _repo_cache: _repo_cache["task_mysql"] = MysqlTaskConfigRepository() return _repo_cache["task_mysql"] # type: ignore[return-value] + if backend == "sqlite": + if "task_sqlite" not in _repo_cache: + _repo_cache["task_sqlite"] = SqliteTaskConfigRepository() + return _repo_cache["task_sqlite"] # type: ignore[return-value] if "task_json" not in _repo_cache: _repo_cache["task_json"] = JsonTaskConfigRepository() return _repo_cache["task_json"] # type: ignore[return-value] @@ -1402,11 +2077,12 @@ def list_turn_ids(session_id: str) -> list[str]: def save_tool_calls( - session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None + session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None, + reflections: list[dict] | None = None, ) -> None: - """持久化一轮工具调用记录(meta 全量透传)。""" + """持久化一轮工具调用记录(meta 全量透传;reflections 为尾部思考记录)。""" try: - _repo().save_tool_calls(session_id, turn_id, tool_calls, meta) + _repo().save_tool_calls(session_id, turn_id, tool_calls, meta, reflections) except Exception as e: print(f"[storage] save_tool_calls 失败: {e}", file=sys.stderr) diff --git a/src/agent/core/workspace_policy.py b/src/agent/core/workspace_policy.py index 3dd7cb9..3a3f80c 100644 --- a/src/agent/core/workspace_policy.py +++ b/src/agent/core/workspace_policy.py @@ -5,7 +5,7 @@ (doc_tool 的 create/update/delete)与终端文件操作(terminal_execute 的 working_dir / 命令变更目标)做路径沙箱校验,把"提示词劝导"变为"工具层强制"。 - 访问模式 (access_mode,持久化于 workspace_config.json): + 访问模式 (access_mode,持久化于 config.json 的 workspace 分区): - restricted: 越界直接拦截,返回错误文案给 Agent 自行纠正。 - approval: 越界时暂停图,交由前端人工审批(复用 HITL 审批卡片)。 - full: 不做越界审查。 @@ -37,7 +37,7 @@ from typing import Any, Literal, Optional _ACCESS_MODES: tuple[str, ...] = ("restricted", "approval", "full") _DEFAULT_MODE: str = "restricted" -_WS_CONFIG_PATH = pathlib.Path(__file__).resolve().parents[1] / "config" / "workspace_config.json" +_WS_CONFIG_PATH = pathlib.Path(__file__).resolve().parents[1] / "agents" / "config.json" # 无人值守标记环境变量(cron 子进程设置) _HEADLESS_ENV = "AGENT_HEADLESS" @@ -48,21 +48,31 @@ _mode_cache: str | None = None def _read_ws_config() -> dict: - """读取 workspace_config.json;失败返回空字典。""" + """读取 config.json 的 workspace 分区;失败返回空字典。""" if _WS_CONFIG_PATH.is_file(): try: with open(_WS_CONFIG_PATH, "r", encoding="utf-8") as f: - return json.load(f) + merged = json.load(f) + cfg = merged.get("workspace") if isinstance(merged, dict) else None + if isinstance(cfg, dict): + return cfg except (json.JSONDecodeError, OSError): pass return {} def _write_ws_config(data: dict) -> bool: - """写入 workspace_config.json;失败返回 False。""" + """写入 config.json 的 workspace 分区(保留其他分区);失败返回 False。""" try: + merged = {} + if _WS_CONFIG_PATH.is_file(): + with open(_WS_CONFIG_PATH, "r", encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + merged = loaded + merged["workspace"] = data with open(_WS_CONFIG_PATH, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + json.dump(merged, f, ensure_ascii=False, indent=2) return True except OSError: return False @@ -86,7 +96,7 @@ def get_access_mode() -> str: def set_access_mode(mode: str) -> bool: - """设置访问模式并持久化到 workspace_config.json。 + """设置访问模式并持久化到 config.json 的 workspace 分区。 输入: mode: restricted | approval | full。 diff --git a/src/agent/cron/models.py b/src/agent/cron/models.py index 32ba16d..7bb61dc 100644 --- a/src/agent/cron/models.py +++ b/src/agent/cron/models.py @@ -109,6 +109,7 @@ class CronTask: trigger: 触发配置。 enabled: 是否启用。 timeout_seconds: 单次执行超时秒数。 + workspace: 任务执行时的工作区(文件操作沙箱)目录;空表示使用默认工作区。 created_at: 创建时间(ISO 字符串)。 next_run_at: 下次预计运行时间(ISO 字符串,空表示不再运行)。 last_run_at: 上次运行时间(ISO 字符串)。 @@ -123,6 +124,7 @@ class CronTask: trigger: Trigger = field(default_factory=Trigger) enabled: bool = True timeout_seconds: int = 300 + workspace: str = "" created_at: str = "" next_run_at: str = "" last_run_at: str = "" @@ -138,6 +140,7 @@ class CronTask: "trigger": self.trigger.to_dict(), "enabled": self.enabled, "timeout_seconds": self.timeout_seconds, + "workspace": self.workspace, "created_at": self.created_at, "next_run_at": self.next_run_at, "last_run_at": self.last_run_at, @@ -156,6 +159,7 @@ class CronTask: trigger=Trigger.from_dict(data.get("trigger")), enabled=bool(data.get("enabled", True)), timeout_seconds=int(data.get("timeout_seconds", 300) or 300), + workspace=data.get("workspace", ""), created_at=data.get("created_at", ""), next_run_at=data.get("next_run_at", ""), last_run_at=data.get("last_run_at", ""), diff --git a/src/agent/cron/runner.py b/src/agent/cron/runner.py index 7fe099c..a77523a 100644 --- a/src/agent/cron/runner.py +++ b/src/agent/cron/runner.py @@ -177,11 +177,13 @@ def main() -> int: parser.add_argument("--trigger", default="schedule", choices=["schedule", "manual"], help="触发来源:schedule(调度)| manual(手动/追问)") parser.add_argument("--timeout", type=int, default=300, help="单次执行超时秒数") + parser.add_argument("--workspace", default="", help="任务绑定的工作区目录(空则用默认工作区)") args = parser.parse_args() task_id = args.task_id prompt = args.prompt timeout = max(int(args.timeout or 300), 10) + workspace = str(args.workspace or "").strip() started_iso = "" try: @@ -207,6 +209,20 @@ def main() -> int: except Exception: pass + # 1.2 应用任务绑定的工作区(须在构建 runtime 前:系统提示词在构建时注入) + # 与主进程 POST /api/workspace/select 的联动保持一致。 + if workspace: + try: + from agent.memory.system_prompt import set_current_workspace + set_current_workspace(workspace) + except Exception: + pass + try: + import agent.utils.env_utils as env_utils + env_utils.WORKSPACE_DIR = workspace + except Exception: + pass + # 2. 构建 AgentRuntime try: from agent.agents.agent_runtime import build_all_agent_runtimes, get_main_agent_runtime @@ -224,6 +240,19 @@ def main() -> int: # 3. 启动 live 转发线程 stop_forwarder = _start_live_forwarder(task_id) + # 3.1 预持久化本轮 user 消息(与 _chat_start_core 对齐): + # 崩溃/超时时 user 消息不丢,turn 结构与 chat 完全同构。 + # turn_id 经 pending 机制交给 execute_agent 复用,避免重复生成。 + try: + from datetime import datetime as _dt + from agent.utils.agent_utils import set_pending_turn_id, save_turn_messages + _turn_id = _dt.now().strftime("%Y%m%d_%H%M%S") + set_pending_turn_id(task_id, _turn_id) + _user_item = {"role": "user", "user": {"content": [{"type": "text", "text": prompt}]}} + save_turn_messages(task_id, _turn_id, [_user_item]) + except Exception as e: + print(f"[cron.runner] 预持久化 user 消息失败(不阻断执行): {e}", file=sys.stderr) + # 4. 执行任务(agent_mode="cron",直接走 core.runtime 绕开 {"agent","plan"} 归一化) from agent.core.runtime import execute_agent as execute_runtime_agent from agent.utils.agent_utils import get_last_turn_id diff --git a/src/agent/cron/scheduler.py b/src/agent/cron/scheduler.py index 30d0875..9467c71 100644 --- a/src/agent/cron/scheduler.py +++ b/src/agent/cron/scheduler.py @@ -406,6 +406,7 @@ class CronScheduler: "--prompt", use_prompt, "--trigger", trigger_source, "--timeout", str(task.timeout_seconds), + "--workspace", task.workspace or "", ] env = dict(os.environ) # 确保 src 在 PYTHONPATH(与 cwd 配合,-m 已能定位 agent 包) diff --git a/src/agent/history/tool_call_recorder.py b/src/agent/history/tool_call_recorder.py index 76fd6b2..49d53ba 100644 --- a/src/agent/history/tool_call_recorder.py +++ b/src/agent/history/tool_call_recorder.py @@ -21,7 +21,7 @@ from typing import Any from langchain_core.messages import BaseMessage, AIMessage, HumanMessage, ToolMessage from agent.utils.agent_utils import SESSIONS_ROOT -from agent.utils.tool_call_utils import normalize_tool_call, format_args_summary, extract_reasoning_text +from agent.utils.tool_call_utils import normalize_tool_call, format_args_summary, extract_thought, extract_reflection TOOL_CALLING_ROOT = Path(SESSIONS_ROOT) @@ -95,6 +95,8 @@ def sub_agent_context(tool_call_id: str | None = None): _sub_live_records.pop(key, None) with _sub_pending_thinking_lock: _sub_pending_thinking.pop(key, None) + with _sub_pending_reflection_lock: + _sub_pending_reflection.pop(key, None) yield finally: _sub_agent_context.reset(token) @@ -109,10 +111,12 @@ _live_records: dict[str, list[dict[str, Any]]] = {} _sub_live_records: dict[tuple[str, str], list[dict[str, Any]]] = {} _sub_live_lock = threading.Lock() -# 子 Agent 待附加 thinking(与主 Agent 的 _pending_thinking 隔离), +# 子 Agent 待附加 thinking / reflection(与主 Agent 的 pending 隔离), # key 与 _sub_live_records 一致:(session_id, tool_call_id)。 _sub_pending_thinking: dict[tuple[str, str], str] = {} _sub_pending_thinking_lock = threading.Lock() +_sub_pending_reflection: dict[tuple[str, str], str] = {} +_sub_pending_reflection_lock = threading.Lock() # 轮次开始时间(key = session_id),用于计时 _turn_start_times: dict[str, float] = {} # key = session_id @@ -200,15 +204,22 @@ def clear_session_tokens(session_id: str) -> None: _session_token_breakdowns.pop(session_id, None) from agent.history import session_storage existing = session_storage.load_session_extra(session_id) or {} + if not existing: + # 会话存储已不存在(如会话已被删除):清内存即可,禁止回写—— + # save_session_extra 会 mkdir/建行,把刚删除的会话目录重建出来 + return existing["tokens"] = 0 existing.pop("token_breakdown", None) session_storage.save_session_extra(session_id, existing) -# ---------- 待附加的 thinking ---------- -# 当 record_reflection_live 被调用时,thinking 暂存于此; -# 随后的 record_tool_call_live 会将其附加到工具调用记录中。 +# ---------- 待附加的 thinking / reflection ---------- +# 当 record_reflection_live 被调用时,思考(thought)与反思(reflection) +# 分别暂存于此;随后的 record_tool_call_live 会将其附加到工具调用记录中 +# (思考 = reasoning_content,反思 = content,二者独立存储)。 _pending_thinking: dict[str, str] = {} _pending_thinking_lock = threading.Lock() +_pending_reflection: dict[str, str] = {} +_pending_reflection_lock = threading.Lock() def set_current_turn(turn_id: str) -> None: @@ -256,11 +267,13 @@ def init_live_turn_with_id(session_id: str, turn_id: str) -> str: _active_sessions.add(session_id) with _pending_thinking_lock: _pending_thinking.pop(session_id, None) + with _pending_reflection_lock: + _pending_reflection.pop(session_id, None) return turn_id def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool_call_id: str | None = None) -> None: - """图节点中调用:记录工具调用开始。同时消费 pending thinking。 + """图节点中调用:记录工具调用开始。同时消费 pending thinking/reflection。 输入: session_id: 会话 ID。 @@ -278,6 +291,8 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool key = _sub_key(session_id) with _sub_pending_thinking_lock: thinking = _sub_pending_thinking.pop(key, None) + with _sub_pending_reflection_lock: + reflection = _sub_pending_reflection.pop(key, None) with _sub_live_lock: if key not in _sub_live_records: _sub_live_records[key] = [] @@ -291,11 +306,15 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool } if thinking: entry["thinking"] = thinking + if reflection: + entry["reflection"] = reflection _sub_live_records[key].append(entry) _notify_live(session_id) return with _pending_thinking_lock: thinking = _pending_thinking.pop(session_id, None) + with _pending_reflection_lock: + reflection = _pending_reflection.pop(session_id, None) with _live_lock: if session_id not in _live_records: _live_records[session_id] = [] @@ -309,6 +328,8 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool } if thinking: entry["thinking"] = thinking + if reflection: + entry["reflection"] = reflection _live_records[session_id].append(entry) _notify_live(session_id) @@ -509,15 +530,25 @@ def _artifact_file_infos(artifact: Any | None) -> list[dict[str, Any]] | None: def end_live_turn(session_id: str) -> list[dict[str, Any]]: - """结束实时记录并返回该 turn 所有记录(同时清空反思与 pending thinking)。""" + """结束实时记录并返回该 turn 所有记录(同时清空反思与 pending thinking)。 + + 清空反思前先把本轮全部思考暂存(_persisted_reflections), + 供随后 save_turn 持久化为 turn 记录的 reflections 字段(尾部思考注入用)。 + """ + _stash_turn_reflections(session_id) set_current_turn("") clear_live_reflections(session_id) with _pending_thinking_lock: _pending_thinking.pop(session_id, None) + with _pending_reflection_lock: + _pending_reflection.pop(session_id, None) with _sub_pending_thinking_lock: # 清理该会话下所有子 Agent 隔离槽(并行子 Agent 按 (session, tc_id) 分槽) for k in [k for k in _sub_pending_thinking if k[0] == session_id]: _sub_pending_thinking.pop(k, None) + with _sub_pending_reflection_lock: + for k in [k for k in _sub_pending_reflection if k[0] == session_id]: + _sub_pending_reflection.pop(k, None) with _sub_live_lock: for k in [k for k in _sub_live_records if k[0] == session_id]: _sub_live_records.pop(k, None) @@ -603,6 +634,7 @@ def save_aborted_turn(session_id: str, turn_id: str) -> None: "status": r.get("status", "done" if r.get("result_text") else "running"), "result_text": r.get("result_text", ""), "thinking": r.get("thinking", ""), + "reflection": r.get("reflection", ""), "result_images": r.get("result_images", []), "result_audio": r.get("result_audio"), "result_file_info": r.get("result_file_info"), @@ -700,44 +732,81 @@ def get_turn_started_at(session_id: str) -> float | None: return None -# ---------- 反思内容共享存储 ---------- -# Agent 反思(think)内容通过此 store 实时推送到前端展示 +# 反思内容共享存储 _think_store: dict[str, list[dict[str, Any]]] = {} _think_lock = threading.Lock() +# 轮次结束暂存:end_live_turn 清空 _think_store 前把尾部思考暂存, +# 供随后执行的 save_turn 持久化(save_turn 在 end_live_turn 之后调用) +_persisted_reflections: dict[str, list[dict[str, Any]]] = {} +_persisted_reflections_lock = threading.Lock() -def record_reflection_live(session_id: str, content: str) -> None: - """记录一次反思内容到共享存储,同时设为 pending thinking 供后续工具调用附加。 + +def _stash_turn_reflections(session_id: str) -> None: + """把当前轮次全部思考记录暂存(end_live_turn 清空内存前调用)。 + + 无思考时清除旧暂存(防止上一轮 aborted 残留被下一轮 save_turn 误持久化)。 + """ + with _think_lock: + refs = list(_think_store.get(session_id, [])) + with _persisted_reflections_lock: + if refs: + _persisted_reflections[session_id] = refs + else: + _persisted_reflections.pop(session_id, None) + + +def _take_stashed_reflections(session_id: str) -> list[dict[str, Any]]: + """取出并清除暂存的思考记录(save_turn 持久化时调用)。""" + with _persisted_reflections_lock: + return _persisted_reflections.pop(session_id, []) + + +def record_reflection_live(session_id: str, thought: str = "", reflection: str = "") -> None: + """记录一次思考/反思到共享存储,同时设为 pending 供后续工具调用附加。 输入: session_id: 会话 ID。 - content: 反思提示文本。 + thought: 深度思考文本(reasoning_content;思考开启档位)。 + reflection: 反思文本(content;思考关闭档位为引导的一句话反思)。 + 二者可同时存在(既有思考又有反思的通用情况),独立存储。 输出: 无。 """ - if not session_id or not content: + if not session_id or not (thought or reflection): return - # 子 Agent 上下文:thinking 存入 _sub_pending_thinking(按 (session, tc_id) 隔离), + # 子 Agent 上下文:思考/反思存入 _sub_pending_*(按 (session, tc_id) 隔离), # 由 record_tool_call_live 消费后附加到子工具调用条目。 if is_sub_agent_context(): - with _sub_pending_thinking_lock: - _sub_pending_thinking[_sub_key(session_id)] = content + if thought: + with _sub_pending_thinking_lock: + _sub_pending_thinking[_sub_key(session_id)] = thought + if reflection: + with _sub_pending_reflection_lock: + _sub_pending_reflection[_sub_key(session_id)] = reflection # 不添加到 _think_store(子 Agent 思考在主 Agent 思考面板中不独立显示, # 而是嵌套在 call_subagent 展开的各个子工具调用条目的 thinking 字段中) return - # 设为 pending thinking:下一个 record_tool_call_live 会消费它 - with _pending_thinking_lock: - _pending_thinking[session_id] = content + # 设为 pending:下一个 record_tool_call_live 会消费它 + if thought: + with _pending_thinking_lock: + _pending_thinking[session_id] = thought + if reflection: + with _pending_reflection_lock: + _pending_reflection[session_id] = reflection with _think_lock: if session_id not in _think_store: _think_store[session_id] = [] - # 避免重复记录相同内容 + # 避免重复记录相同内容(思考与反思分别去重) + merged = (thought or reflection) or "" for entry in _think_store[session_id]: - if entry["content"] == content: + if entry.get("content") == merged: return entry = { - "content": content, - "summary": content[:60].replace("\n", " "), + "content": merged, # 前端展示用(思考优先,无思考用反思) + "thought": thought, # 深度思考(独立存储) + "reflection": reflection, # 反思(独立存储) + "summary": merged[:60].replace("\n", " "), "timestamp": time.time(), } _think_store[session_id].append(entry) @@ -773,10 +842,12 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s name_counter: dict[str, int] = {} for msg in messages: if isinstance(msg, AIMessage) and msg.tool_calls: - # 提取该 AIMessage 的 thinking,仅附加到第一个 tool_call - # 提取规则见 extract_reasoning_text:开启思考取深度思考(reasoning_content/ - # 内联 块),关闭思考取 content 中的一句话反思 - thinking = extract_reasoning_text(msg) + # 提取该 AIMessage 的思考与反思,仅附加到第一个 tool_call: + # thinking = reasoning_content(深度思考) + # reflection = content(反思) + # 二者独立存储,可同时存在;仅在带 tool_calls 的消息上提取 + thinking = extract_thought(msg) + reflection = extract_reflection(msg) first = True for tc in msg.tool_calls: d = normalize_tool_call(tc) @@ -790,8 +861,11 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s "result_files": [], "result_images": [], } - if thinking and first: - entry["thinking"] = thinking + if first: + if thinking: + entry["thinking"] = thinking + if reflection: + entry["reflection"] = reflection first = False pending.append(entry) elif isinstance(msg, ToolMessage): @@ -807,10 +881,10 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s result_files, bytes_map = _save_artifact_files(artifact, unique_name) entry["result_files"] = result_files entry["_all_bytes"] = bytes_map # 供 save_turn 落盘(图片 + 文件) - # 提取图片 data URL(供前端直接展示) - images = _artifact_image_data_urls(artifact) - if images: - entry["result_images"] = images + # 图片不内嵌 base64(避免 tool json 膨胀数 MB): + # PNG 统一落盘为 _artifact_*.png,回放时由 + # _tool_calls_from_turn_record 按 result_files 生成 /api/tool-image URL + # 实时展示仍走 live 记录中的 data URL(record_tool_result_live) # 提取音频信息(不含字节,仅路径元数据) audio_info = _artifact_audio_info(artifact) if audio_info: @@ -946,7 +1020,10 @@ def save_turn(session_id: str, turn_id: str, user_message: dict[str, Any], "ended_at": ended_at, "duration": (ended_at - started_at) if started_at else None, } - session_storage.save_tool_calls(session_id, turn_id, tool_calls, meta=_meta) + # 尾部思考(未被任何工具调用消费的反思):end_live_turn 暂存 → 此处持久化, + # 供后续轮次 build_tool_history_messages 注入上下文(历史思考)。 + _reflections = _take_stashed_reflections(session_id) + session_storage.save_tool_calls(session_id, turn_id, tool_calls, meta=_meta, reflections=_reflections) return None @@ -954,7 +1031,7 @@ def _merge_extracted_with_live( extracted: list[dict[str, Any]], live_calls: list[dict[str, Any]], ) -> list[dict[str, Any]]: - """将 extracted 的 artifact 数据合并到 live 记录上(保留 live 的 thinking/args_summary)。 + """将 extracted 的 artifact 数据合并到 live 记录上(live 的 thinking/reflection 优先)。 匹配策略:按索引位置匹配(同一轮次中工具调用顺序应一致)。 如果数量不一致,回退到按 name+顺序 匹配。 @@ -979,10 +1056,14 @@ def _merge_extracted_with_live( candidates = live_by_name.get(name, []) lc = candidates[idx] if idx < len(candidates) else {} merged = dict(ext) - # 从 live 记录补充 thinking / args_summary(单条工具计时已移除,不再合并 timestamp/ended_at) - for field in ("thinking", "args_summary"): - if lc.get(field) is not None and not merged.get(field): + # 思考/反思以 live 记录为准(调用时刻原样记录;extracted 为消息解析值, + # 压缩/裁剪后可能缺失或不完整);args_summary 仅在 extracted 缺失时补充 + # (单条工具计时已移除,不再合并 timestamp/ended_at) + for field in ("thinking", "reflection"): + if lc.get(field): merged[field] = lc[field] + if not merged.get("args_summary") and lc.get("args_summary") is not None: + merged["args_summary"] = lc["args_summary"] result.append(merged) return result @@ -1113,10 +1194,14 @@ def _tool_calls_from_turn_record(record: dict[str, Any], session_id: str) -> lis image_files.append(f"/api/tool-image?path={fp}&session={turn_id}") else: other_files.append(f"/api/tool-file?path={fp}&session={turn_id}") - # 保留 live 记录中已有的 result_images(data URLs 来自实时录制), - # 仅在无 data URL 时才用磁盘文件 URL 回退,避免覆盖导致前端无法渲染 + # result_images:落盘记录不再内嵌 base64(体积原因), + # 统一按 result_files 生成 /api/tool-image URL; + # 若历史数据仍带 data URL(旧版记录)则保留以兼容 existing_images = entry.get("result_images") - if (not existing_images or not isinstance(existing_images, list) or len(existing_images) == 0): + has_data_url = any( + isinstance(u, str) and u.startswith("data:") for u in (existing_images or []) + ) + if not has_data_url: entry["result_images"] = image_files if other_files: entry["result_download_files"] = other_files diff --git a/src/agent/history/usage_stats.json b/src/agent/history/usage_stats.json index 1820b38..c6b625c 100644 --- a/src/agent/history/usage_stats.json +++ b/src/agent/history/usage_stats.json @@ -1 +1 @@ -{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}}} \ No newline at end of file +{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}, "2026-08-16T19": {"total": 558037, "by_model": {"Qwen3.5-122B-A10B": 558037}}, "2026-08-16T20": {"total": 212587, "by_model": {"Qwen3.5-122B-A10B": 212587}}, "2026-08-16T22": {"total": 5894, "by_model": {"Qwen3.5-122B-A10B": 5894}}, "2026-08-17T07": {"total": 100546, "by_model": {"Qwen3.5-122B-A10B": 100546}}, "2026-08-17T08": {"total": 135785, "by_model": {"Qwen3.5-122B-A10B": 135785}}, "2026-08-17T17": {"total": 5753, "by_model": {"Qwen3.5-122B-A10B": 5753}}, "2026-08-18T14": {"total": 27472, "by_model": {"Qwen3.5-122B-A10B": 27472}}, "2026-08-18T15": {"total": 45565, "by_model": {"Qwen3.5-122B-A10B": 45565}}, "2026-08-19T08": {"total": 6141, "by_model": {"Qwen3.5-122B-A10B": 6141}}, "2026-08-19T11": {"total": 13137, "by_model": {"Qwen3.5-122B-A10B": 13137}}, "2026-08-19T12": {"total": 53109, "by_model": {"Qwen3.5-122B-A10B": 53109}}}} \ No newline at end of file diff --git a/src/agent/tools/__init__.py b/src/agent/tools/__init__.py index 7dcb44e..e1b3940 100644 --- a/src/agent/tools/__init__.py +++ b/src/agent/tools/__init__.py @@ -1,6 +1,7 @@ from .web_search import WebSearch from .date_query import DateQuery from .browser_control import BrowserControl +from .web_page import WebPage from .human_interaction import HumanInteraction from .directory_listing import DirectoryListing from .todo_list import TodoList @@ -23,6 +24,7 @@ _all_tools = [ WebSearch(), DateQuery(), BrowserControl(), + WebPage(), HumanInteraction(), SoftwareControl(), RAGTool(), diff --git a/src/agent/tools/browser_control.py b/src/agent/tools/browser_control.py index 19b3ee7..b3f5aa5 100644 --- a/src/agent/tools/browser_control.py +++ b/src/agent/tools/browser_control.py @@ -1,187 +1,588 @@ +"""浏览器控制工具与 Playwright 会话管理器。 + +本模块包含两部分: + 1. BrowserSession —— Playwright 会话管理器(非工具,供本工具与 web_page 共用) + 2. BrowserControl —— 浏览器控制 LangChain 工具(开/关浏览器、标签页管理) + +会话架构(分离进程 + CDP 连接): + - 浏览器以独立进程启动(--remote-debugging-port),Agent 退出后窗口保留 + - Agent 重启后通过 CDP 自动重连同一浏览器(登录态、标签页不丢) + - 专用后台线程运行 asyncio loop,所有 Playwright 异步操作在该线程串行执行, + 规避 sync API 的线程亲和问题 + +可扩展性: + - 端口与 profile 目录取环境变量 BROWSER_DEBUG_PORT / BROWSER_PROFILE_DIR 覆盖 + - 新的页面级操作经 BrowserSession.call_async 提交协程即可,无需改动线程模型 +""" from __future__ import annotations -import io -import sys -import time -import webbrowser import asyncio -from typing import Optional, Literal, Tuple +import atexit +import glob +import json +import os +import re +import subprocess +import sys +import threading +import time +import urllib.request +from concurrent.futures import TimeoutError as _FutureTimeoutError +from pathlib import Path +from typing import Any, Callable, Literal, Optional, Tuple -import pyautogui -from PIL import Image from langchain.tools import BaseTool from pydantic import BaseModel, Field, create_model -import agent.utils.env_utils as env_utils +# ==================== 第一部分:BrowserSession 会话管理器 ==================== + +# ---------- 配置常量 ---------- +_AGENT_DIR = Path(__file__).resolve().parents[1] +BROWSER_DEBUG_PORT = int(os.getenv("BROWSER_DEBUG_PORT", "9222")) +BROWSER_PROFILE_DIR = str(Path(os.getenv("BROWSER_PROFILE_DIR", str(_AGENT_DIR / "data" / "browser_profile")))) +_CDP_URL = f"http://127.0.0.1:{BROWSER_DEBUG_PORT}" +_LAUNCH_TIMEOUT = 15.0 # 分离启动后等待 CDP 就绪的秒数 +_DEFAULT_OP_TIMEOUT = 45.0 # 单次操作的默认外层超时 + +# Windows 分离进程标志:DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB +# 使浏览器进程独立于 Agent 进程存活(Agent 退出后浏览器窗口保留) +_DETACHED_FLAGS = 0x00000008 | 0x00000200 | 0x01000000 + + +_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:") + + +def normalize_url(url: str) -> str: + """补全 URL 协议头:无协议时默认 https://(about:blank、edge:// 等已有 scheme 的不处理)。""" + u = (url or "").strip() + if u and "://" not in u and not _SCHEME_RE.match(u): + u = "https://" + u + return u + + +def _candidate_paths(browser: Optional[str]) -> list[str]: + """按优先级返回浏览器可执行文件候选路径列表。""" + b = (browser or "edge").lower().strip() + if b == "chrome": + order = ["chrome", "edge", "chromium"] + elif b in ("chromium", "playwright"): + order = ["chromium", "edge", "chrome"] + else: # 默认 edge + order = ["edge", "chrome", "chromium"] + pf64 = os.getenv("ProgramFiles", r"C:\Program Files") + pf86 = os.getenv("ProgramFiles(x86)", r"C:\Program Files (x86)") + local = os.getenv("LOCALAPPDATA", "") + known: dict[str, list[str]] = { + "edge": [ + os.path.join(pf86, "Microsoft", "Edge", "Application", "msedge.exe"), + os.path.join(pf64, "Microsoft", "Edge", "Application", "msedge.exe"), + ], + "chrome": [ + os.path.join(pf64, "Google", "Chrome", "Application", "chrome.exe"), + os.path.join(pf86, "Google", "Chrome", "Application", "chrome.exe"), + os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "", + ], + "chromium": sorted( + glob.glob(os.path.join(local, "ms-playwright", "chromium-*", "chrome-win", "chrome.exe")), + reverse=True, + ) if local else [], + } + out: list[str] = [] + for name in order: + out.extend(p for p in known.get(name, []) if p) + return out + + +def _resolve_executable(browser: Optional[str]) -> str: + """解析第一个存在的浏览器可执行文件路径,找不到时抛出明确错误。""" + for p in _candidate_paths(browser): + if os.path.isfile(p): + return p + raise RuntimeError( + "未找到可用的浏览器(Edge / Chrome / Playwright 内置 Chromium)。" + "请安装 Edge/Chrome,或运行 `playwright install chromium`。" + ) + + +def _cdp_alive() -> Optional[dict]: + """探测 CDP 调试端口是否存活,存活返回 /json/version 内容。""" + try: + with urllib.request.urlopen(f"{_CDP_URL}/json/version", timeout=2) as resp: + data = json.loads(resp.read().decode("utf-8")) + return data if isinstance(data, dict) else None + except Exception: + return None + + +class BrowserSession: + """浏览器会话单例:持有 CDP 连接与当前标签页,对外提供同步门面方法。""" + + def __init__(self) -> None: + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._thread_lock = threading.Lock() + # 以下状态仅由会话线程读写 + self._playwright = None + self._browser = None # connect_over_cdp 得到的 Browser + self._context = None # 默认 BrowserContext(持久 profile) + self._page = None # 当前活动标签页 + self._proc: Optional[subprocess.Popen] = None + self._watched: set[int] = set() # 已注册 close 监听的 page id + # ref 注册表(snapshot 序号 -> CSS 选择器) + self._refs: dict[int, str] = {} + self._refs_page = None + self._launch_browser_pref: Optional[str] = None + atexit.register(self._atexit_shutdown) + + # ---------- 线程模型 ---------- + def _ensure_thread(self) -> None: + """惰性启动会话线程(daemon,随进程退出)。 + + loop 在调用线程创建后再交给线程运行,避免 _loop 赋值竞态。 + """ + if self._thread and self._thread.is_alive(): + return + with self._thread_lock: + if self._thread and self._thread.is_alive(): + return + loop = asyncio.new_event_loop() + self._loop = loop + self._thread = threading.Thread(target=self._thread_main, args=(loop,), name="browser-session", daemon=True) + self._thread.start() + + def _thread_main(self, loop: asyncio.AbstractEventLoop) -> None: + asyncio.set_event_loop(loop) + try: + loop.run_forever() + finally: + loop.close() + + def call_async(self, coro_fn: Callable[["BrowserSession"], Any], timeout: float = _DEFAULT_OP_TIMEOUT) -> Any: + """提交协程到会话线程执行并阻塞等待结果。 + + coro_fn: 接收 session 的函数,返回 coroutine(在会话线程的事件循环中运行)。 + """ + self._ensure_thread() + assert self._loop is not None + fut = asyncio.run_coroutine_threadsafe(coro_fn(self), self._loop) + try: + return fut.result(timeout=timeout) + except _FutureTimeoutError: + fut.cancel() + raise TimeoutError(f"浏览器操作超时(>{timeout:.0f}s)") from None + + # ---------- 内部异步实现(全部在会话线程运行) ---------- + async def _ensure_connected(self) -> None: + """确保 CDP 连接与当前页可用;浏览器未运行时分离启动。""" + if self._browser is not None and self._browser.is_connected() and self._context is not None: + await self._ensure_current_page() + return + await self._cleanup_connection() + if _cdp_alive() is None: + await self._launch_detached() + try: + from playwright.async_api import async_playwright + except ImportError as e: + raise RuntimeError("playwright 未安装,请先 `pip install playwright`") from e + self._playwright = await async_playwright().start() + self._browser = await self._playwright.chromium.connect_over_cdp(_CDP_URL) + contexts = self._browser.contexts + self._context = contexts[0] if contexts else await self._browser.new_context() + try: + self._context.set_default_timeout(10_000) # 动作超时 10s + self._context.set_default_navigation_timeout(30_000) # 导航超时 30s + except Exception: + pass + await self._ensure_current_page() + + async def _ensure_current_page(self) -> None: + """确保 self._page 指向一个存活标签页(优先沿用当前页,否则取最后一页/新建)。""" + pages = list(self._context.pages) + if self._page in pages: + page = self._page + elif pages: + page = pages[-1] + else: + page = await self._context.new_page() + self._page = page + self._watch_page(page) + try: + await page.bring_to_front() + except Exception: + pass + + def _watch_page(self, page) -> None: + """为标签页注册 close 监听(幂等),关闭后自动回退当前页并清理 ref。""" + if id(page) in self._watched: + return + self._watched.add(id(page)) + + def _on_close(p=page): + if self._page is p: + self._page = None + if self._refs_page is p: + self._refs.clear() + self._refs_page = None + + try: + page.on("close", _on_close) + except Exception: + pass + + async def _launch_detached(self) -> None: + """以分离进程启动浏览器并等待 CDP 就绪。""" + exe = _resolve_executable(self._launch_browser_pref) + Path(BROWSER_PROFILE_DIR).mkdir(parents=True, exist_ok=True) + cmd = [ + exe, + f"--remote-debugging-port={BROWSER_DEBUG_PORT}", + f"--user-data-dir={BROWSER_PROFILE_DIR}", + "--start-maximized", + "--no-first-run", + "--no-default-browser-check", + "--hide-crash-restore-bubble", + "about:blank", + ] + kwargs: dict[str, Any] = {} + if sys.platform == "win32": + kwargs["creationflags"] = _DETACHED_FLAGS + else: + kwargs["start_new_session"] = True + self._proc = subprocess.Popen( + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, **kwargs + ) + deadline = time.monotonic() + _LAUNCH_TIMEOUT + while time.monotonic() < deadline: + if _cdp_alive() is not None: + return + if self._proc.poll() is not None: + break + await asyncio.sleep(0.3) + raise RuntimeError( + "浏览器启动失败:CDP 调试端口未就绪" + f"(端口 {BROWSER_DEBUG_PORT},profile 可能被其他浏览器实例占用,可关闭残留浏览器进程后重试)" + ) + + async def _cleanup_connection(self) -> None: + """断开 CDP 连接(不关闭浏览器进程)。""" + self._page = None + self._context = None + self._refs.clear() + self._refs_page = None + if self._browser is not None: + try: + await self._browser.close() # CDP 模式下仅断开连接 + except Exception: + pass + self._browser = None + if self._playwright is not None: + try: + await self._playwright.stop() + except Exception: + pass + self._playwright = None + + # ---------- 页面信息 ---------- + async def _page_meta(self) -> dict[str, Any]: + page = self._page + if page is None: + return {"title": "", "url": ""} + try: + title = await page.title() + except Exception: + title = "" + return {"title": title, "url": page.url} + + # ---------- 会话级异步操作(供 call_async 调用) ---------- + async def ensure(self, browser: Optional[str] = None) -> dict[str, Any]: + """确保浏览器就绪并返回当前页信息(不新建标签页)。""" + if browser: + self._launch_browser_pref = browser + await self._ensure_connected() + return await self._page_meta() + + async def open_tab(self, url: Optional[str] = None, browser: Optional[str] = None) -> dict[str, Any]: + """新建标签页并可选导航到 url。""" + if browser: + self._launch_browser_pref = browser + await self._ensure_connected() + page = await self._context.new_page() + self._page = page + self._watch_page(page) + try: + await page.bring_to_front() + except Exception: + pass + if url: + await page.goto(normalize_url(url), wait_until="domcontentloaded") + return await self._page_meta() + + async def refresh(self) -> dict[str, Any]: + """刷新当前标签页。""" + await self._ensure_connected() + try: + await self._page.reload(wait_until="domcontentloaded") + except Exception: + await self._page.goto(self._page.url, wait_until="domcontentloaded") + return await self._page_meta() + + async def switch_tab(self, index: int) -> dict[str, Any]: + """切换到第 index 个标签页(从 0 开始)。""" + await self._ensure_connected() + pages = list(self._context.pages) + if not (0 <= index < len(pages)): + raise ValueError(f"标签页索引 {index} 越界,当前共 {len(pages)} 个标签页(0~{len(pages) - 1})") + self._page = pages[index] + self._watch_page(self._page) + try: + await self._page.bring_to_front() + except Exception: + pass + return await self._page_meta() + + async def close_tab(self) -> str: + """关闭当前标签页(若为最后一页则保留浏览器,自动补开空白页)。""" + await self._ensure_connected() + cur = self._page + others = [p for p in self._context.pages if p is not cur] + try: + await cur.close() + except Exception: + pass + if others: + self._page = others[-1] + else: + # 最后一个标签页关闭会导致浏览器退出,补开空白页保持会话存活 + try: + self._page = await self._context.new_page() + except Exception: + await self._cleanup_connection() + return "已关闭最后一个标签页,浏览器已退出" + try: + await self._page.bring_to_front() + except Exception: + pass + meta = await self._page_meta() + return f"已关闭标签页,当前页: {meta['title'] or meta['url'] or 'about:blank'}" + + async def close_browser(self) -> str: + """真正关闭浏览器进程(CDP Browser.close 命令)。""" + was_connected = self._browser is not None and self._browser.is_connected() + if was_connected: + try: + cdp = await self._browser.new_browser_cdp_session() + await cdp.send("Browser.close") + except Exception: + pass + await self._cleanup_connection() + proc, self._proc = self._proc, None + if proc is not None: + try: + proc.wait(timeout=5) + except Exception: + try: + proc.kill() + except Exception: + pass + return "已关闭浏览器" if was_connected else "浏览器未在运行(已清理本地连接状态)" + + async def list_tabs(self) -> list[dict[str, Any]]: + """列出全部标签页(索引、标题、URL),当前页带标记。""" + await self._ensure_connected() + tabs = [] + for i, p in enumerate(self._context.pages): + try: + title = await p.title() + except Exception: + title = "" + tabs.append({ + "index": i, + "title": title, + "url": p.url, + "current": p is self._page, + }) + return tabs + + async def _settle_current(self) -> None: + """等待当前页稳定(截图前调用),避免截到加载动画而非动作结果。 + + 三段式:主文档加载完成(4s 上限)→ 网络空闲(3s 上限,覆盖 AJAX; + 长轮询页面会超时直接放行)→ 0.3s 渲染缓冲。 + """ + page = self._page + if page is None: + return + try: + await page.wait_for_load_state("domcontentloaded", timeout=4000) + except Exception: + pass + try: + await page.wait_for_load_state("networkidle", timeout=3000) + except Exception: + pass + await asyncio.sleep(0.3) + + async def screenshot(self, full_page: bool = False) -> bytes: + """当前标签页截图(先等待页面稳定),返回 PNG 字节。""" + await self._ensure_connected() + await self._settle_current() + return await self._page.screenshot(full_page=full_page) + + # ---------- ref 注册表(供 web_page 在会话线程内使用) ---------- + def set_refs(self, refs: dict[int, str], page) -> None: + self._refs = refs + self._refs_page = page + + def get_ref_selector(self, ref: int) -> Optional[str]: + return self._refs.get(ref) + + # ---------- 同步门面(工具线程/主线程调用,内部转投会话线程) ---------- + def open(self, url: Optional[str] = None, browser: Optional[str] = None) -> dict[str, Any]: + return self.call_async(lambda s: s.ensure(browser) if url is None else s.open_tab(url, browser), + timeout=_DEFAULT_OP_TIMEOUT + _LAUNCH_TIMEOUT) + + def refresh_sync(self) -> dict[str, Any]: + return self.call_async(lambda s: s.refresh()) + + def switch_tab_sync(self, index: int) -> dict[str, Any]: + return self.call_async(lambda s: s.switch_tab(index)) + + def close_tab_sync(self) -> str: + return self.call_async(lambda s: s.close_tab()) + + def close_browser_sync(self) -> str: + return self.call_async(lambda s: s.close_browser()) + + def list_tabs_sync(self) -> list[dict[str, Any]]: + return self.call_async(lambda s: s.list_tabs()) + + def screenshot_sync(self, full_page: bool = False) -> bytes: + return self.call_async(lambda s: s.screenshot(full_page)) + + # ---------- 进程退出 ---------- + def _atexit_shutdown(self) -> None: + """Agent 退出时仅断开连接,浏览器窗口保留(独立进程可被下次重连)。""" + try: + if self._loop and self._thread and self._thread.is_alive(): + fut = asyncio.run_coroutine_threadsafe(self._cleanup_connection(), self._loop) + try: + fut.result(timeout=3) + except Exception: + fut.cancel() + except Exception: + pass + + +# ---------- 模块级单例 ---------- +_session: Optional[BrowserSession] = None +_session_lock = threading.Lock() + + +def get_browser_session() -> BrowserSession: + """获取浏览器会话单例(browser_control 与 web_page 共用)。""" + global _session + if _session is None: + with _session_lock: + if _session is None: + _session = BrowserSession() + return _session + + +# ==================== 第二部分:BrowserControl 工具 ==================== class BrowserControl(BaseTool): - """统一浏览器控制工具:支持打开/关闭浏览器、刷新/关闭标签页、打开指定 URL""" + """统一浏览器控制工具(Playwright 分离进程 + CDP 架构)。 + + 浏览器以独立进程运行(Agent 退出后窗口保留,重启自动重连), + 登录态通过持久化 profile 保存。 + """ args_schema: type[BaseModel] = create_model( "BrowserControlInput", - action=(Literal["open", "close"], Field( + action=(Literal["open", "close", "list_tabs", "switch_tab"], Field( default="open", - description="操作类型:'open' 打开/刷新,'close' 关闭" + description="操作类型:'open' 打开/刷新,'close' 关闭,'list_tabs' 列出标签页,'switch_tab' 切换标签页" )), url=(Optional[str], Field( default=None, - description="URL 地址、'current' 表示当前页,留空则操作整个浏览器窗口" + description="URL 地址、'current' 表示刷新当前页;action=open 且留空表示打开浏览器(新标签空白页)" )), browser=(Optional[str], Field( default=None, - description="浏览器名称,如 'edge', 'chrome', 'firefox',不指定则使用系统默认" + description="浏览器引擎:'edge'(默认)/ 'chrome' / 'chromium'(Playwright 内置)" + )), + tab_index=(Optional[int], Field( + default=None, + description="标签页索引(从 0 开始),switch_tab 时必填,可用 list_tabs 查询" )), ) name: str = "browser_control" description: str = ( - "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。\n" - "- action='open', url=None: 打开浏览器首页\n" + "控制浏览器(独立进程,跨会话存活):打开 URL、刷新、关闭浏览器/标签页、列出与切换标签页。\n" + "- action='open', url=None: 打开浏览器(若未运行则启动,聚焦当前页)\n" "- action='open', url='current': 刷新当前标签页\n" - "- action='open', url='https://...': 在新标签页打开指定 URL\n" - "- action='close', url=None: 关闭整个浏览器窗口\n" + "- action='open', url='https://...': 新标签页打开指定 URL\n" + "- action='close', url=None: 关闭整个浏览器\n" "- action='close', url='任意非空字符(例如current)': 关闭当前标签页\n" - "操作完成后会返回当前屏幕截图,帮助判断操作是否成功。" + "- action='list_tabs': 列出全部标签页(索引、标题、URL)\n" + "- action='switch_tab', tab_index=N: 切换到第 N 个标签页\n" + "操作完成后返回当前页面截图。打开页面后的元素级操作请使用 web_page 工具。\n" "仅用于进行浏览器操作时调用,其余任何情况严禁调用。" ) response_format: Literal["content", "content_and_artifact"] = "content_and_artifact" - # ---------- 辅助方法(与原版相同,无改动) ---------- - def _get_browser_info(self, browser_name: Optional[str]): - """解析浏览器名称,返回 webbrowser 控制器与窗口标题关键字。""" - if browser_name is None: - return webbrowser.get(), None - name_lower = browser_name.lower().strip() - if name_lower not in env_utils.BROWSER_MAP: - raise ValueError(f"不支持的浏览器: {browser_name},支持: {', '.join(env_utils.BROWSER_MAP.keys())}") - reg_name, title_keyword = env_utils.BROWSER_MAP[name_lower] - try: - controller = webbrowser.get(reg_name) - except webbrowser.Error: - controller = webbrowser.get(browser_name) - return controller, title_keyword - - def _activate_window(self, title_keyword: str) -> bool: - """在 Windows 上按标题关键字激活浏览器窗口(非 Win32 返回 False)。""" - if sys.platform != "win32": - return False - try: - time.sleep(0.3) - windows = pyautogui.getWindowsWithTitle(title_keyword) - if windows: - win = windows[0] - if win.isMinimized: - win.restore() - win.activate() - return True - except Exception: - pass - return False - - def _send_keys(self, *keys): - """通过 pyautogui 发送组合键。""" - pyautogui.hotkey(*keys) - - # ---------- 核心操作(返回操作结果描述) ---------- - def _try_activate_any_browser(self) -> bool: - """尝试激活任意已知浏览器窗口(不指定 browser 时的回退方案)。""" - for _reg_name, title_keyword in env_utils.BROWSER_MAP.values(): - if self._activate_window(title_keyword): - return True - return False - - def _open_action(self, url: Optional[str], browser: Optional[str]) -> str: - """执行打开首页、刷新当前页或打开指定 URL。""" - controller, title_keyword = self._get_browser_info(browser) - if url is None: - controller.open("about:blank") - if title_keyword: - self._activate_window(title_keyword) - else: - self._try_activate_any_browser() - browser_info = f" (使用 {browser})" if browser else "" - return f"已打开浏览器首页{browser_info}" - elif url.lower() == "current": - if title_keyword: - if not self._activate_window(title_keyword): - return f"未找到 {browser or '默认'} 浏览器窗口,无法刷新" - time.sleep(0.2) - if sys.platform == "darwin": - self._send_keys("command", "r") - else: - self._send_keys("f5") - return "已刷新当前标签页" - else: - controller.open(url) - if title_keyword: - self._activate_window(title_keyword) - else: - self._try_activate_any_browser() - # 等待浏览器窗口出现并开始加载 - time.sleep(1.0) - return f"已尝试打开 URL: {url}" - - def _close_action(self, url: Optional[str], browser: Optional[str]) -> str: - """执行关闭浏览器窗口或当前标签页。""" - _, title_keyword = self._get_browser_info(browser) - if url is None: - if title_keyword: - if not self._activate_window(title_keyword): - return f"未找到 {browser or '默认'} 浏览器窗口,无法关闭" - else: - self._try_activate_any_browser() - time.sleep(0.2) - if sys.platform == "darwin": - self._send_keys("command", "q") - else: - self._send_keys("alt", "f4") - return "已尝试关闭浏览器窗口" - else: - if title_keyword: - if not self._activate_window(title_keyword): - return f"未找到 {browser or '默认'} 浏览器窗口,无法关闭标签页" - else: - self._try_activate_any_browser() - time.sleep(0.2) - if sys.platform == "darwin": - self._send_keys("command", "w") - else: - self._send_keys("ctrl", "w") - return "已尝试关闭当前标签页" - # ---------- 同步主逻辑,返回 (描述, artifact) ---------- - def _run(self, action: str = "open", url: Optional[str] = None, browser: Optional[str] = None) -> Tuple[str, dict]: + def _run(self, action: str = "open", url: Optional[str] = None, + browser: Optional[str] = None, tab_index: Optional[int] = None) -> Tuple[str, dict]: + session = get_browser_session() try: if action == "open": - msg = self._open_action(url, browser) + if url is None: + meta = session.open(browser=browser) + msg = f"浏览器已就绪,当前页: {meta.get('title') or meta.get('url') or 'about:blank'}" + elif url.lower() == "current": + meta = session.refresh_sync() + msg = f"已刷新当前页: {meta.get('title') or meta.get('url') or 'about:blank'}" + else: + meta = session.open(url=url, browser=browser) + msg = f"已在新标签页打开: {meta.get('url') or url}({meta.get('title') or ''})" elif action == "close": - msg = self._close_action(url, browser) + if url is None: + msg = session.close_browser_sync() + else: + msg = session.close_tab_sync() + elif action == "list_tabs": + tabs: list[dict[str, Any]] = session.list_tabs_sync() + if not tabs: + msg = "当前没有打开的标签页" + else: + rows = [ + f"[{t['index']}]{'(当前)' if t['current'] else ''} {t['title'][:40]} | {t['url'][:80]}" + for t in tabs + ] + msg = f"共 {len(tabs)} 个标签页:\n" + "\n".join(rows) + elif action == "switch_tab": + if tab_index is None: + msg = "switch_tab 需要 tab_index 参数(可先用 list_tabs 查询索引)" + else: + meta = session.switch_tab_sync(tab_index) + msg = f"已切换到标签页[{tab_index}]: {meta.get('title') or meta.get('url') or 'about:blank'}" else: msg = f"不支持的操作类型: {action}" except Exception as e: - msg = f"执行浏览器操作时出错: {e}" + return f"执行浏览器操作时出错: {e}", {} - # 无论操作是否成功,都尝试截屏(等待窗口切换) - time.sleep(0.3) + # 关闭整个浏览器后无页面可截,其余情况返回当前页截图 + if action == "close" and url is None: + return msg, {} try: - # 先尝试获取 GUI 操作区域,限定截屏范围 - try: - from agent.core.config_manager import get_active_monitor_region - region = get_active_monitor_region() - except Exception: - region = None - if region: - pil_img = pyautogui.screenshot(region=(region["x"], region["y"], region["width"], region["height"])) - else: - pil_img = pyautogui.screenshot() - pil_img = pil_img.resize((env_utils.GROUNDING_WIDTH, env_utils.GROUNDING_HEIGHT), Image.Resampling.LANCZOS) - buf = io.BytesIO() - pil_img.save(buf, format="PNG") - raw_png = buf.getvalue() - return msg, {"image_bytes": raw_png} + png = session.screenshot_sync() + return msg, {"image_bytes": png} except Exception as e: return f"{msg}\n(截屏失败: {e})", {} # ---------- 异步入口 ---------- - async def _arun(self, action: str = "open", url: Optional[str] = None, browser: Optional[str] = None) -> Tuple[str, dict]: - return await asyncio.to_thread(self._run, action, url, browser) + async def _arun(self, action: str = "open", url: Optional[str] = None, + browser: Optional[str] = None, tab_index: Optional[int] = None) -> Tuple[str, dict]: + return await asyncio.to_thread(self._run, action, url, browser, tab_index) diff --git a/src/agent/tools/doc.py b/src/agent/tools/doc.py index 20e7d21..d1e381a 100644 --- a/src/agent/tools/doc.py +++ b/src/agent/tools/doc.py @@ -671,7 +671,7 @@ class DocTool(BaseTool): " * docx 插入图片: insert_paragraph_at + insert_image_path 在指定段落前插入图片。\n" "- delete: 删除文档。支持所有类型。\n\n" "【文档类型识别】\n" - " plain_text: .txt .json .md .cpp .c .py .m .java .html .svg 等\n" + " plain_text: 所有 UTF-8 文本/代码文件(.txt .md .json .py .js .ts .html .css .xml .yaml .sql .sh 等)\n" " docx: .docx\n" " pptx: .pptx(read 提取文本和图片;delete 删除)。" " pdf: .pdf\n" diff --git a/src/agent/tools/excel2sql.py b/src/agent/tools/excel2sql.py index 5173f2a..8b228e1 100644 --- a/src/agent/tools/excel2sql.py +++ b/src/agent/tools/excel2sql.py @@ -1,9 +1,12 @@ -"""excel2sql 工具:按大模型给定的数据区范围,自动建库建表并把 Excel 数据批量入库 MySQL。 +"""excel2sql 工具:按大模型给定的数据区范围,自动建库建表并把 Excel 数据批量入库。 系统定位: 配合 hard_excel_read 完成"解析 → 入库"链路。hard_excel_read 让大模型理解复杂表结构、 产出 data_region 与 table_description;本工具按该范围流式读取真正的数据区, - 自动建库建表(表 COMMENT 存表格描述)并批量入库。 + 自动建库建表(表 COMMENT 存表格描述,仅 MySQL;SQLite 无 COMMENT 概念)并批量入库。 + + 连接使用设置中的**工具数据库配置**(TOOL_DB_*,MySQL 或 SQLite), + 与存储后端配置完全隔离。SQLite 目录模式下库名 = 文件名(<目录>/<库名>.sqlite)。 不做重清洗 DSL,按范围直接入库(结构清洗已由大模型定范围完成)。 值级清洗入库后用 text2sql 的 execute(UPDATE/DELETE) 完成。 @@ -19,12 +22,30 @@ import datetime import json import os import re +from contextlib import contextmanager from typing import Any from langchain.tools import BaseTool from pydantic import BaseModel, Field +@contextmanager +def _cursor(conn: Any): + """统一游标上下文:pymysql 与 sqlite3 的 cursor 行为差异。 + + pymysql cursor 支持 with(自动 close),sqlite3 cursor 不支持 + context manager 协议,需手动 close。本助手对两者统一处理。 + """ + cur = conn.cursor() + try: + yield cur + finally: + try: + cur.close() + except Exception: + pass + + # data_region 必须包含的键 _REGION_KEYS = ("header_row", "data_start", "data_end", "col_start", "col_end") @@ -55,13 +76,32 @@ _DB_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _TABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +def _db_type() -> str: + """当前工具数据库类型:"mysql" | "sqlite"(读 TOOL_DB_*,与存储后端隔离)。""" + from agent.core.db import load_tool_db_config + return load_tool_db_config().get("type", "mysql") + + def _ensure_database_exists(db_name: str) -> tuple[bool, str]: - """幂等创建目标 MySQL 数据库(连服务器,不指定 database)。""" + """幂等创建目标数据库。 + + - mysql: 连服务器(不指定 database)执行 CREATE DATABASE IF NOT EXISTS; + - sqlite: 库 = 文件,幂等创建(复用 ensure_sqlite_database,不存在则建空文件)。 + """ + if _db_type() == "sqlite": + from agent.core.db import load_tool_db_config, sqlite_db_path, ensure_sqlite_database + try: + cfg = load_tool_db_config() + path = sqlite_db_path(cfg, db_name) + ensure_sqlite_database(path) + return True, "ok" + except Exception as e: + return False, str(e) import pymysql from pymysql.cursors import DictCursor - from agent.core.db import load_mysql_config + from agent.core.db import load_tool_db_config - cfg = load_mysql_config() + cfg = load_tool_db_config() try: conn = pymysql.connect( host=cfg["host"], port=cfg["port"], user=cfg["user"], @@ -69,7 +109,7 @@ def _ensure_database_exists(db_name: str) -> tuple[bool, str]: cursorclass=DictCursor, autocommit=True, ) try: - with conn.cursor() as cur: + with _cursor(conn) as cur: cur.execute( f"CREATE DATABASE IF NOT EXISTS `{db_name}` " f"CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci" @@ -82,17 +122,28 @@ def _ensure_database_exists(db_name: str) -> tuple[bool, str]: def _connect(db_name: str): - """连接目标库(复用 text2sql 的连接模式)。""" - import pymysql - from pymysql.cursors import DictCursor - from agent.core.db import load_mysql_config + """连接目标库(按工具数据库配置分发 mysql/sqlite)。""" + from agent.core.db import connect_db, load_tool_db_config - cfg = load_mysql_config() - return pymysql.connect( - host=cfg["host"], port=cfg["port"], user=cfg["user"], - password=cfg["password"], database=db_name, charset="utf8mb4", - cursorclass=DictCursor, autocommit=False, - ) + cfg = load_tool_db_config() + return connect_db(cfg, db_name) + + +def _table_exists(conn: Any, table: str) -> bool: + """判断目标表是否已存在(mysql: SHOW TABLES LIKE;sqlite: sqlite_master)。""" + if _db_type() == "sqlite": + cur = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,) + ) + return cur.fetchone() is not None + with _cursor(conn) as cur: + cur.execute(f"SHOW TABLES LIKE '{table}'") + return cur.fetchone() is not None + + +def _placeholder() -> str: + """SQL 占位符:mysql 用 %s,sqlite 用 ?。""" + return "?" if _db_type() == "sqlite" else "%s" def _normalize_value(v: Any) -> Any: @@ -213,8 +264,16 @@ def _infer_columns(header_values: list, sample_rows: list) -> list[dict]: def _build_ddl(table: str, col_defs: list[dict], description: str, - drop_if_exists: bool) -> str: - """生成建表 DDL(含列定义/主键/索引/表 COMMENT)。""" + drop_if_exists: bool, db_type: str | None = None) -> str: + """生成建表 DDL(按数据库类型区分语法)。 + + - mysql: 列 COMMENT / 表 COMMENT / ENGINE=InnoDB DEFAULT CHARSET / 行内 KEY 索引; + - sqlite: 无 COMMENT / ENGINE / 行内 KEY(索引改为建表后独立 + CREATE INDEX IF NOT EXISTS 语句)。 + 列类型(BIGINT/DECIMAL/DATETIME/DATE/TEXT/VARCHAR)两者通用(sqlite 亲和类型)。 + """ + db_type = db_type or _db_type() + is_sqlite = db_type == "sqlite" lines: list[str] = [] if drop_if_exists: lines.append(f"DROP TABLE IF EXISTS `{table}`;") @@ -225,7 +284,7 @@ def _build_ddl(table: str, col_defs: list[dict], description: str, parts = [f" `{c['target']}` {c['sql_type']}"] if not c.get("nullable", True): parts.append("NOT NULL") - if c.get("comment"): + if c.get("comment") and not is_sqlite: esc = str(c["comment"]).replace("'", "''")[:500] parts.append(f"COMMENT '{esc}'") col_lines.append(" ".join(parts)) @@ -235,21 +294,35 @@ def _build_ddl(table: str, col_defs: list[dict], description: str, col_lines.append( f" PRIMARY KEY ({', '.join(f'`{p}`' for p in pks)})" ) - for c in col_defs: - if c.get("index") and not c.get("primary_key"): - idx_name = f"idx_{c['target']}"[:64] - col_lines.append(f" KEY `{idx_name}` (`{c['target']}`)") + if not is_sqlite: + for c in col_defs: + if c.get("index") and not c.get("primary_key"): + idx_name = f"idx_{c['target']}"[:64] + col_lines.append(f" KEY `{idx_name}` (`{c['target']}`)") - desc_clause = "" - if description: - esc = description.replace("'", "''")[:1000] - desc_clause = f" COMMENT='{esc}'" - - ddl = ( - f"CREATE TABLE IF NOT EXISTS `{table}` (\n" - + ",\n".join(col_lines) - + f"\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4{desc_clause}" - ) + if is_sqlite: + ddl = ( + f"CREATE TABLE IF NOT EXISTS `{table}` (\n" + + ",\n".join(col_lines) + + "\n)" + ) + # sqlite 索引在表外单独建(IF NOT EXISTS 幂等) + for c in col_defs: + if c.get("index") and not c.get("primary_key"): + idx_name = f"idx_{c['target']}"[:64] + lines.append( + f"CREATE INDEX IF NOT EXISTS `{idx_name}` ON `{table}` (`{c['target']}`);" + ) + else: + desc_clause = "" + if description: + esc = description.replace("'", "''")[:1000] + desc_clause = f" COMMENT='{esc}'" + ddl = ( + f"CREATE TABLE IF NOT EXISTS `{table}` (\n" + + ",\n".join(col_lines) + + f"\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4{desc_clause}" + ) lines.append(ddl) return "\n".join(lines) @@ -276,7 +349,7 @@ class Excel2SQLInput(BaseModel): "", description="表格描述(数据区上方的标题/表单/说明等),存为表 COMMENT", ) - target_database: str = Field(..., description="目标 MySQL 库名(不存在自动建)") + target_database: str = Field(..., description="目标库名(不存在自动建;连接使用工具数据库配置,MySQL 或 SQLite)") target_table: str = Field(..., description="目标表名(不存在自动建)") columns: list[dict] | None = Field( None, @@ -301,8 +374,9 @@ class Excel2SQLTool(BaseTool): args_schema: type[BaseModel] = Excel2SQLInput name: str = "excel2sql" description: str = ( - "Excel 批量入库工具(excel2sql)。按大模型给定的数据行列范围,自动建库建表(表 COMMENT 存表格描述)," - "并把 Excel 数据区流式批量入库 MySQL。\n" + "Excel 批量入库工具(excel2sql)。按大模型给定的数据行列范围,自动建库建表" + "(表描述存为表 COMMENT,仅 MySQL;SQLite 无此概念)," + "并把 Excel 数据区流式批量入库(MySQL 或 SQLite,连接使用工具数据库配置,与存储库隔离)。\n" "适用场景:hard_excel_read 已解析出 data_region 与 table_description 后,把数据真正写入数据库。\n" "不做重清洗 DSL,按范围直接入库;值级清洗入库后用 text2sql(execute) 完成。\n" "参数说明:\n" @@ -310,7 +384,7 @@ class Excel2SQLTool(BaseTool): "- sheet: 要入库的 sheet 名\n" "- data_region: {header_row, data_start, data_end, col_start, col_end}(1-indexed)\n" "- table_description: 表格描述,存为表 COMMENT\n" - "- target_database: 目标 MySQL 库名(不存在自动建)\n" + "- target_database: 目标库名(不存在自动建)\n" "- target_table: 目标表名(不存在自动建)\n" "- columns: 列定义 [{source,target,sql_type,nullable,primary_key,index,comment}];None=表头+自动推断\n" "- batch_size: 批量入库大小,默认 2000\n" @@ -411,10 +485,8 @@ class Excel2SQLTool(BaseTool): if ok: conn = _connect(target_database) try: - with conn.cursor() as cur: - cur.execute(f"SHOW TABLES LIKE '{target_table}'") - if cur.fetchone(): - return json.dumps({ + if _table_exists(conn, target_table): + return json.dumps({ "error": ( f"表 `{target_database}`.`{target_table}` 已存在," f"而当前传入的 columns 定义了新的列 schema," @@ -465,7 +537,7 @@ class Excel2SQLTool(BaseTool): sample_dicts: list[dict] = [] batch: list = [] try: - with conn.cursor() as cur: + with _cursor(conn) as cur: # 执行 DDL(可能含 DROP + CREATE) for stmt in ddl.split(";"): s = stmt.strip() @@ -473,10 +545,10 @@ class Excel2SQLTool(BaseTool): cur.execute(s) # 入库 cols_sql = ", ".join(f"`{c['target']}`" for c in col_defs) - placeholders = ", ".join(["%s"] * len(col_defs)) + placeholders = ", ".join([_placeholder()] * len(col_defs)) insert_sql = f"INSERT INTO `{target_table}` ({cols_sql}) VALUES ({placeholders})" - with conn.cursor() as cur: + with _cursor(conn) as cur: for row in ws.iter_rows(min_row=data_start, max_row=data_end, min_col=col_start, max_col=col_end, values_only=True): # 整行空跳过 @@ -583,18 +655,16 @@ class Excel2SQLTool(BaseTool): if ok: conn = _connect(target_database) try: - with conn.cursor() as cur: - cur.execute(f"SHOW TABLES LIKE '{target_table}'") - if cur.fetchone(): - return json.dumps({ - "error": ( - f"表 `{target_database}`.`{target_table}` 已存在," - f"而当前传入的 columns 定义了新的列 schema," - f"CREATE TABLE IF NOT EXISTS 不会修改已有表结构。" - f"请设置 drop_if_exists: true 以删表重建。" - ), - "hint": "设置 drop_if_exists: true", - }, ensure_ascii=False) + if _table_exists(conn, target_table): + return json.dumps({ + "error": ( + f"表 `{target_database}`.`{target_table}` 已存在," + f"而当前传入的 columns 定义了新的列 schema," + f"CREATE TABLE IF NOT EXISTS 不会修改已有表结构。" + f"请设置 drop_if_exists: true 以删表重建。" + ), + "hint": "设置 drop_if_exists: true", + }, ensure_ascii=False) finally: conn.close() except Exception: @@ -633,16 +703,16 @@ class Excel2SQLTool(BaseTool): sample_dicts: list[dict] = [] batch: list = [] try: - with conn.cursor() as cur: + with _cursor(conn) as cur: for stmt in ddl.split(";"): s = stmt.strip() if s: cur.execute(s) cols_sql = ", ".join(f"`{c['target']}`" for c in col_defs) - placeholders = ", ".join(["%s"] * len(col_defs)) + placeholders = ", ".join([_placeholder()] * len(col_defs)) insert_sql = f"INSERT INTO `{target_table}` ({cols_sql}) VALUES ({placeholders})" - with conn.cursor() as cur: + with _cursor(conn) as cur: for r in range(data_start - 1, min(data_end, total_rows)): row_vals = tuple(_xlrd_cell_to_value(ws, r, c) for c in range(col_start - 1, min(col_end, ws.ncols))) diff --git a/src/agent/tools/human_interaction.py b/src/agent/tools/human_interaction.py index 8036f0c..2041e6c 100644 --- a/src/agent/tools/human_interaction.py +++ b/src/agent/tools/human_interaction.py @@ -2,81 +2,40 @@ from __future__ import annotations import json import asyncio -from typing import Optional, Literal +from typing import Optional from langchain.tools import BaseTool from pydantic import BaseModel, Field class HumanInteractionInput(BaseModel): - interaction_type: Literal["information", "selection"] = Field( - default="information", - description="交互类型:information=人工补充信息,selection=人工选择。", - ) title: str = Field(default="需要人工处理", description="展示给人类的交互标题。") - prompt: str = Field(default="请处理后继续。", description="需要人类理解并响应的具体问题或说明。") + prompt: str = Field(default="请处理后继续。", description="需要人类理解并响应的具体问题。") options: Optional[list[str] | str] = Field( default=None, - description="selection 可选项。优先传字符串数组;如果传 JSON 字符串,系统会自动解析。", - ) - suggested_response: Optional[str] = Field(default=None, description="可选的建议回复、默认选择或默认审批意见。") - questions: Optional[list[dict]] = Field( - default=None, - description="selection 类型时的问题列表。每项含 question(问题文本) 和 options(该问题的选项列表)。" - "支持多个问题,每个问题有独立选项及一个可编辑的'其他'输入框。" - "例:[{'question': '问题1', 'options': ['A', 'B']}, {'question': '问题2', 'options': ['是', '否']}]", + description="可选项。优先传字符串数组;JSON 字符串或逗号分隔会被自动解析。" + "可空——人类也可直接在补充输入框中输入任意信息。", ) def normalize_human_interaction_args(args: dict) -> dict: - """规范化 human_interaction 工具参数为固定 schema。 - - 输入: - args: 模型传入的原始参数字典。 - - 输出: - 含 interaction_type、title、prompt、options、suggested_response、questions 的字典; - interaction_type 限定为 information | selection。 - """ + """规范化 human_interaction 工具参数为固定 schema(title / prompt / options)。""" normalized = dict(args or {}) - normalized["interaction_type"] = str(normalized.get("interaction_type") or "information") - if normalized["interaction_type"] not in {"information", "selection"}: - normalized["interaction_type"] = "information" normalized["title"] = str(normalized.get("title") or "需要人工处理") normalized["prompt"] = str(normalized.get("prompt") or "请处理后继续。") normalized["options"] = HumanInteraction.normalize_options(normalized.get("options")) - normalized["suggested_response"] = str(normalized.get("suggested_response") or "") - # 规范化 questions 字段 - questions = normalized.get("questions") - if isinstance(questions, str): - try: - questions = json.loads(questions) - except (json.JSONDecodeError, TypeError): - questions = None - if isinstance(questions, list): - normalized_questions = [] - for q in questions: - if isinstance(q, dict): - nq = { - "question": str(q.get("question", "")), - "options": HumanInteraction.normalize_options(q.get("options")), - } - normalized_questions.append(nq) - normalized["questions"] = normalized_questions if normalized_questions else None - else: - normalized["questions"] = None return normalized class HumanInteraction(BaseTool): - """向人类请求补充信息或选择;调用时阻塞等待前端浮窗应答,答案作为普通工具返回值。""" + """向人类请求补充信息或选择;调用时阻塞等待前端通知条应答,答案作为普通工具返回值。""" args_schema: type[BaseModel] = HumanInteractionInput name: str = "human_interaction" description: str = ( "当任务缺少关键信息或需要人类选择路径时调用。" - "两种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑," - "可通过 questions 字段支持多个问题(每问题含 question 和 options)。" + "通过 options 提供可选项(可空),人类可以从选项中选择," + "也可在补充输入框中输入任意信息——输入内容将直接作为本工具的返回值。" "工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。" ) @@ -99,45 +58,15 @@ class HumanInteraction(BaseTool): return [str(item) for item in options] @staticmethod - def format_result(interaction_type: str, options: list[str] | None = None, response: str = "") -> str: - """根据交互类型与用户决策生成结果文本。 - - 输入: - interaction_type: information | selection。 - options: selection 的可选项(用于默认值)。 - response: 用户输入的文本(补充信息、选择项、建议回复等)。 - - 输出: - 供 ToolMessage 使用的文本内容。 - """ + def format_result(response: str) -> str: + """将用户输入(选中的选项或补充输入框内容)直接作为工具返回值。""" text = (response or "").strip() - if interaction_type == "selection": - # response 可能是 JSON 格式的多问题答案: {"问题1": "答案", ...} - if text: - try: - parsed = json.loads(text) - if isinstance(parsed, dict): - lines = [] - for q, a in parsed.items(): - lines.append(f" - {q}: {a}") - return "用户选择:\n" + "\n".join(lines) if lines else "用户选择:(无)" - except json.JSONDecodeError: - pass - selected = text or (options[0] if options else "") - return f"用户选择:{selected}" if selected else "用户选择:" - if text.startswith("用户拒绝") or text.startswith("用户已跳过"): - return text - return f"用户补充信息如下:{text}" if text else "用户没有补充信息" + return text or "用户未提供信息" @staticmethod def build_request_meta(args: dict) -> dict: - """将规范化后的工具参数构造成前端浮窗渲染数据(与旧 pending_action 形状一致)。""" + """将规范化后的工具参数构造成前端通知条渲染数据。""" args = normalize_human_interaction_args(args) - interaction_type = args["interaction_type"] - default_titles = { - "information": "请补充信息", - "selection": "请选择一个选项", - } agent_name = "主Agent" try: from agent.core.human_request import get_current_agent_name @@ -147,18 +76,14 @@ class HumanInteraction(BaseTool): pass return { "type": "human_interaction", - "interaction_type": interaction_type, - "title": args["title"] or default_titles.get(interaction_type, "需要人工处理"), + "title": args["title"] or "需要人工处理", "prompt": args["prompt"] or "请处理后继续。", "tool_name": "human_interaction", "args": { - "交互类型": interaction_type, "说明": args["prompt"] or "请处理后继续。", "可选项": args["options"] or [], }, - "instruction": args["suggested_response"] or "", "options": args["options"] or [], - "questions": args["questions"] or None, "agent_name": agent_name, "is_sub_agent": agent_name != "主Agent", "resolved": False, @@ -166,21 +91,15 @@ class HumanInteraction(BaseTool): def _run( self, - interaction_type: str = "information", title: str = "需要人工处理", prompt: str = "请处理后继续。", options: Optional[list[str] | str] = None, - suggested_response: Optional[str] = None, - questions: Optional[list[dict]] = None, ) -> str: try: args = { - "interaction_type": interaction_type, "title": title, "prompt": prompt, "options": options, - "suggested_response": suggested_response, - "questions": questions, } normalized = normalize_human_interaction_args(args) from agent.core.human_request import ask_human @@ -190,7 +109,7 @@ class HumanInteraction(BaseTool): # 拒绝/跳过:原样返回用户决策文本,让 Agent 自行调整,不结束整轮 if answer.get("decision") in ("reject", "skip"): return f"用户{('拒绝' if answer['decision'] == 'reject' else '已跳过')}此次交互:{answer.get('instruction') or ''}".strip() - return self.format_result(interaction_type, normalized.get("options") or [], answer.get("instruction") or "") + return self.format_result(answer.get("instruction") or "") except RuntimeError: raise # ABORTED_BY_USER 冒泡到 runtime 统一按暂停处理 except Exception as e: @@ -198,11 +117,8 @@ class HumanInteraction(BaseTool): async def _arun( self, - interaction_type: str = "information", title: str = "需要人工处理", prompt: str = "请处理后继续。", options: Optional[list[str] | str] = None, - suggested_response: Optional[str] = None, - questions: Optional[list[dict]] = None, ) -> str: - return await asyncio.to_thread(self._run, interaction_type, title, prompt, options, suggested_response, questions) + return await asyncio.to_thread(self._run, title, prompt, options) diff --git a/src/agent/tools/image_gen.py b/src/agent/tools/image_gen.py index dc839be..bb8f2d2 100644 --- a/src/agent/tools/image_gen.py +++ b/src/agent/tools/image_gen.py @@ -3,7 +3,7 @@ 依赖: - Z-Image-Turbo 服务(由 llm_server/image_gen_server.py 提供) - - env_config.json 中的 IMAGE_GEN_URL 配置 + - env_config.json 的 models 列表中注册的 "Z-Image-Turbo" 模型(base_url 配置) """ from __future__ import annotations @@ -18,18 +18,6 @@ from langchain.tools import BaseTool from pydantic import BaseModel, Field, create_model -def _read_config(key: str, default: str = "") -> str: - """从 env_config.json 读取配置项。""" - try: - from agent.core.config_manager import load_env_config - config = load_env_config() - if isinstance(config, dict): - return str(config.get(key, default)) - except Exception: - pass - return os.environ.get(key, default) - - def _get_workspace_dir() -> str: """获取 workspace 绝对路径。优先使用当前选中的工作空间,回退到 env_config 配置。""" try: @@ -89,7 +77,8 @@ class ImageGen(BaseTool): response_format: Literal["content", "content_and_artifact"] = "content_and_artifact" def _get_url(self) -> str: - return _read_config("IMAGE_GEN_URL", "http://localhost:8904") + from agent.utils.env_utils import get_service_model_config + return get_service_model_config("image_gen")["base_url"] def _generate_one(self, base_url: str, inp: ImageGenInput, idx: int) -> Tuple[str, dict[str, Any]]: """生成单张图片,返回 (文本结果, artifact_dict)。""" @@ -102,7 +91,10 @@ class ImageGen(BaseTool): if not (256 <= w <= 2048) or not (256 <= h <= 2048): return (f"[ERROR] 第{idx}张: 尺寸须在 [256, 2048] 之间", {}) + from agent.utils.env_utils import get_service_model_config + img_cfg = get_service_model_config("image_gen") payload: dict = { + "model": img_cfg["model"], "prompt": inp.prompt, "width": w, "height": h, @@ -111,11 +103,15 @@ class ImageGen(BaseTool): "seed": seed, "return_format": "base64", } + headers = {"Content-Type": "application/json"} + if img_cfg["api_key"]: + headers["Authorization"] = f"Bearer {img_cfg['api_key']}" try: resp = requests.post( f"{base_url}/generate", json=payload, + headers=headers, timeout=300, ) resp.raise_for_status() diff --git a/src/agent/tools/rag.py b/src/agent/tools/rag.py index d44a4bc..4d7e43c 100644 --- a/src/agent/tools/rag.py +++ b/src/agent/tools/rag.py @@ -15,7 +15,7 @@ from langchain.tools import BaseTool from pydantic import BaseModel, Field, create_model from langchain_text_splitters import RecursiveCharacterTextSplitter -from agent.utils.env_utils import RAG_BASE_URL, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP +from agent.utils.env_utils import RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, get_service_model_config from agent.utils.agent_utils import Documents_process from agent.core.llm import llm @@ -144,13 +144,18 @@ def get_global_collection(): def embed_texts(queries: List[str], documents: List[str] = None) -> tuple: """调用 RAG 嵌入服务,返回 (query_embeddings, doc_embeddings)。""" + rag_cfg = get_service_model_config("rag") payload = { "task_description": "Given a web search query, retrieve relevant passages that answer the query", + "model": rag_cfg["model"], "queries": queries, "documents": documents or [] } + headers = {"Content-Type": "application/json"} + if rag_cfg["api_key"]: + headers["Authorization"] = f"Bearer {rag_cfg['api_key']}" try: - resp = requests.post(f"{RAG_BASE_URL}/embed", json=payload, timeout=30) + resp = requests.post(f"{rag_cfg['base_url']}/embed", json=payload, headers=headers, timeout=30) resp.raise_for_status() data = resp.json() return data["query_embeddings"], data.get("doc_embeddings", []) @@ -160,13 +165,18 @@ def embed_texts(queries: List[str], documents: List[str] = None) -> tuple: def rerank_documents(query: str, documents: List[str]) -> List[float]: """调用 RAG 重排序服务,返回与 documents 对齐的相关度分数列表。""" + rag_cfg = get_service_model_config("rag") payload = { "task_description": "Given a web search query, retrieve relevant passages that answer the query", + "model": rag_cfg["model"], "query": query, "documents": documents } + headers = {"Content-Type": "application/json"} + if rag_cfg["api_key"]: + headers["Authorization"] = f"Bearer {rag_cfg['api_key']}" try: - resp = requests.post(f"{RAG_BASE_URL}/rerank", json=payload, timeout=30) + resp = requests.post(f"{rag_cfg['base_url']}/rerank", json=payload, headers=headers, timeout=30) resp.raise_for_status() return resp.json()["scores"] except Exception as e: diff --git a/src/agent/tools/terminal_execute.py b/src/agent/tools/terminal_execute.py index f317042..6d77df4 100644 --- a/src/agent/tools/terminal_execute.py +++ b/src/agent/tools/terminal_execute.py @@ -47,11 +47,11 @@ class TerminalExecute(BaseTool): "- command: 必填,要执行的命令字符串\n" "- working_dir: 可选,命令执行的工作目录\n" "- timeout: 超时时间(秒),默认 120 秒\n\n" - "【数据作图标准流程】(本工具替代 data_plot,用于基于已入库 MySQL 数据作图)\n" + "【数据作图标准流程】(本工具替代 data_plot,用于基于已入库数据库数据作图)\n" "当用户要求对已入库的数据画图/出图表时,按以下流程用本工具执行一个 Python 脚本:\n" - "1. 查库:脚本内用 pymysql 连接 MySQL。连接参数从 env_config.json 的\n" - " MYSQL_HOST/MYSQL_PORT/MYSQL_USER/MYSQL_PASSWORD 读取,或直接\n" - " `from agent.core.db import load_mysql_config` 获取;执行只读 SELECT 取数。\n" + "1. 查库:脚本内连接数据库(MySQL 或 SQLite)。连接参数从 env_config.json 的\n" + " 工具数据库配置(TOOL_DB_*)读取,或直接\n" + " `from agent.core.db import load_tool_db_config` 获取;执行只读 SELECT 取数。\n" "2. 画图:用 matplotlib。脚本开头必须 `import matplotlib; matplotlib.use('Agg')`\n" " (无头模式,避免弹窗阻塞);中文需设字体\n" " `plt.rcParams['font.sans-serif']=['SimHei','Microsoft YaHei','DejaVu Sans']`、\n" diff --git a/src/agent/tools/text2sql.py b/src/agent/tools/text2sql.py index 320d0d3..5828662 100644 --- a/src/agent/tools/text2sql.py +++ b/src/agent/tools/text2sql.py @@ -4,9 +4,11 @@ 让 Agent 用自然语言/SQL 操作数据库:查看库结构、查看表结构、查询表内容(查), 以及对库内容做增删改(INSERT/UPDATE/DELETE/DDL)。 - 连接配置复用 env_config.json 的 MYSQL_*(host/port/user/password), - 但 database 必须由调用方显式指定(用户需告知 Agent 要操作的数据库名), - 避免误操作系统存储库(agent_history)。 + 连接使用设置中的**工具数据库配置**(TOOL_DB_*,MySQL 或 SQLite), + 与存储后端配置完全隔离。MySQL 的 database 必须由调用方显式指定 + (用户需告知 Agent 要操作的数据库名),避免误操作系统存储库(agent_history); + SQLite 目录模式下库名 = 文件名(<工具库目录>/<库名>.sqlite), + 建库即幂等创建该文件。 权限策略(见 tool_config.json): - action=list_tables / describe / query → 查类,直接执行(auto_execute_rules 升级为 direct) @@ -67,6 +69,7 @@ class Text2SQLTool(BaseTool): description: str = ( "数据库操作工具(text2sql)。支持查看数据库结构、查询表内容、对数据库内容做增删改,以及建库建表。\n" "适用场景:用户希望用自然语言查询/操作数据库,或 Agent 需要读取/修改数据库内容,或需创建新库/新表。\n" + "连接使用设置中的工具数据库配置(MySQL 或 SQLite,与存储库隔离);SQLite 目录模式下每个库名对应一个 .sqlite 文件。\n" "参数说明:\n" "- action: list_tables(列出所有表)/ describe(查看表结构)/ query(只读查询)/ execute(增删改/DDL)/ create_database(建库,幂等)/ create_table(建表,先确保库存在)\n" "- database: 目标数据库名(必填,用户需告知要操作的数据库)\n" @@ -80,7 +83,7 @@ class Text2SQLTool(BaseTool): '- 查询数据: {"action": "query", "database": "my_agent_data", "sql": "SELECT * FROM users LIMIT 10"}\n' '- 新增数据: {"action": "execute", "database": "my_agent_data", "sql": "INSERT INTO users (name) VALUES (\'test\')"}\n' '- 建库: {"action": "create_database", "database": "my_agent_data"}\n' - '- 建表: {"action": "create_table", "database": "my_agent_data", "sql": "CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, name VARCHAR(50))"}\n' + '- 建表: {"action": "create_table", "database": "my_agent_data", "sql": "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"}\n' ) def _run( @@ -120,42 +123,54 @@ class Text2SQLTool(BaseTool): return json.dumps({"error": f"text2sql 执行失败: {str(e)}"}, ensure_ascii=False) # ---------- 连接 ---------- + @staticmethod + def _tool_cfg() -> dict: + """读取工具数据库配置(TOOL_DB_*,与存储后端完全隔离)。""" + from agent.core.db import load_tool_db_config + return load_tool_db_config() + + @classmethod + def _db_type(cls) -> str: + """当前工具数据库类型:"mysql" | "sqlite"。""" + return cls._tool_cfg().get("type", "mysql") + @staticmethod def _connect(database: str): - """创建 MySQL 连接(复用 MYSQL_* 的 host/port/user/password,database 由调用方指定)。 + """创建数据库连接(按工具数据库配置分发 mysql/sqlite)。 + + - mysql: 复用 TOOL_DB_* 的 host/port/user/password,database 由调用方指定; + - sqlite: 目录模式库名 = 文件名(<目录>/<库名>.sqlite),单文件模式用配置路径。 调用方负责 close。 """ - import pymysql - from pymysql.cursors import DictCursor - from agent.core.db import load_mysql_config + from agent.core.db import connect_db - cfg = load_mysql_config() + cfg = Text2SQLTool._tool_cfg() # database 由调用方指定,不使用配置中的默认库 - return pymysql.connect( - host=cfg["host"], - port=cfg["port"], - user=cfg["user"], - password=cfg["password"], - database=database, - charset="utf8mb4", - cursorclass=DictCursor, - autocommit=False, - ) + return connect_db(cfg, database) # ---------- 查类 ---------- def _list_tables(self, database: str) -> str: conn = self._connect(database) try: - with conn.cursor() as cur: - cur.execute("SHOW TABLES") - rows = cur.fetchall() - cur.execute("SELECT DATABASE() AS db") - db_name = (cur.fetchone() or {}).get("db") or "" - names: list[str] = [] - for r in rows: - for v in r.values(): - names.append(str(v)) + if self._db_type() == "sqlite": + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + rows = [dict(r) for r in cur.fetchall()] + names = [str(r.get("name", "")) for r in rows] + db_name = database + else: + with conn.cursor() as cur: + cur.execute("SHOW TABLES") + rows = cur.fetchall() + cur.execute("SELECT DATABASE() AS db") + db_name = (cur.fetchone() or {}).get("db") or "" + names: list[str] = [] + for r in rows: + for v in r.values(): + names.append(str(v)) return json.dumps( {"action": "list_tables", "database": db_name, "tables": names, "count": len(names)}, ensure_ascii=False, @@ -171,9 +186,24 @@ class Text2SQLTool(BaseTool): return json.dumps({"error": f"非法表名: {table}"}, ensure_ascii=False) conn = self._connect(database) try: - with conn.cursor() as cur: - cur.execute(f"SHOW FULL COLUMNS FROM `{table}`") - cols = cur.fetchall() + if self._db_type() == "sqlite": + # PRAGMA table_info → 与 SHOW FULL COLUMNS 同构的列结构 + cur = conn.execute(f"PRAGMA table_info(`{table}`)") + cols = [] + for r in cur.fetchall(): + d = dict(r) + cols.append({ + "Field": d.get("name", ""), + "Type": d.get("type", ""), + "Null": "NO" if d.get("notnull") else "YES", + "Key": "PRI" if d.get("pk") else "", + "Default": d.get("dflt_value"), + "Extra": "", + }) + else: + with conn.cursor() as cur: + cur.execute(f"SHOW FULL COLUMNS FROM `{table}`") + cols = cur.fetchall() return json.dumps( {"action": "describe", "database": database, "table": table, "columns": cols, "count": len(cols)}, ensure_ascii=False, @@ -195,9 +225,12 @@ class Text2SQLTool(BaseTool): row_limit = max(1, min(int(limit or 100), _MAX_ROWS)) conn = self._connect(database) try: - with conn.cursor() as cur: - cur.execute(stmt) - rows = cur.fetchmany(row_limit) + if self._db_type() == "sqlite": + rows = self._query_sqlite(conn, stmt, row_limit) + else: + with conn.cursor() as cur: + cur.execute(stmt) + rows = cur.fetchmany(row_limit) columns = list(rows[0].keys()) if rows else [] return json.dumps( { @@ -214,16 +247,50 @@ class Text2SQLTool(BaseTool): finally: conn.close() + @staticmethod + def _query_sqlite(conn: Any, stmt: str, row_limit: int) -> list[dict]: + """SQLite 只读查询:SHOW TABLES / DESCRIBE / DESC 翻译为 sqlite 等价语法。""" + up = stmt.lstrip("(").lstrip().upper() + if up.startswith("SHOW TABLES"): + cur = conn.execute( + "SELECT name AS `Tables_in_db` FROM sqlite_master " + "WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + return [dict(r) for r in cur.fetchmany(row_limit)] + if up.startswith(("DESCRIBE", "DESC")): + parts = stmt.split() + t = parts[1].strip('`"[]') if len(parts) > 1 else "" + if not t: + return [] + cur = conn.execute(f"PRAGMA table_info(`{t}`)") + cols = [] + for r in cur.fetchall(): + d = dict(r) + cols.append({ + "Field": d.get("name", ""), + "Type": d.get("type", ""), + "Null": "NO" if d.get("notnull") else "YES", + "Key": "PRI" if d.get("pk") else "", + "Default": d.get("dflt_value"), + "Extra": "", + }) + return cols[:row_limit] + cur = conn.execute(stmt) + return [dict(r) for r in cur.fetchmany(row_limit)] + # ---------- 增删改类 ---------- def _execute(self, sql: str | None, database: str) -> str: if not sql or not sql.strip(): return json.dumps({"error": "execute 操作需提供 sql"}, ensure_ascii=False) conn = self._connect(database) try: - with conn.cursor() as cur: - cur.execute(sql.strip()) - affected = cur.rowcount - last_id = cur.lastrowid + if self._db_type() == "sqlite": + cur = conn.execute(sql.strip()) + else: + with conn.cursor() as cur: + cur.execute(sql.strip()) + affected = cur.rowcount + last_id = cur.lastrowid conn.commit() return json.dumps( { @@ -247,17 +314,32 @@ class Text2SQLTool(BaseTool): # ---------- 建库建表 ---------- def _create_database(self, database: str) -> str: - """幂等创建 MySQL 数据库。 + """幂等创建数据库。 - 连接 MySQL 服务器(不指定 database,避免库不存在时连接失败), - 执行 CREATE DATABASE IF NOT EXISTS。逻辑与 db.ensure_database() 一致, - 但库名由调用方指定。 + - mysql: CREATE DATABASE IF NOT EXISTS(连服务器,不指定 database); + - sqlite: 库 = 文件,幂等创建(建父目录 + 空文件),无 CREATE DATABASE 语句。 """ + if self._db_type() == "sqlite": + from agent.core.db import load_tool_db_config, sqlite_db_path, ensure_sqlite_database + try: + cfg = load_tool_db_config() + path = sqlite_db_path(cfg, database) + ensure_sqlite_database(path) + except Exception as e: + return json.dumps( + {"error": f"create_database 失败: {str(e)}", "database": database}, + ensure_ascii=False, + ) + return json.dumps( + {"action": "create_database", "database": database, "success": True, + "note": "sqlite 库即文件,已幂等创建"}, + ensure_ascii=False, + ) import pymysql from pymysql.cursors import DictCursor - from agent.core.db import load_mysql_config + from agent.core.db import load_tool_db_config - cfg = load_mysql_config() + cfg = load_tool_db_config() try: conn = pymysql.connect( host=cfg["host"], @@ -300,8 +382,11 @@ class Text2SQLTool(BaseTool): pass conn = self._connect(database) try: - with conn.cursor() as cur: - cur.execute(sql.strip()) + if self._db_type() == "sqlite": + conn.execute(sql.strip()) + else: + with conn.cursor() as cur: + cur.execute(sql.strip()) conn.commit() return json.dumps( {"action": "create_table", "database": database, "success": True, "ddl": sql.strip()}, diff --git a/src/agent/tools/web_page.py b/src/agent/tools/web_page.py new file mode 100644 index 0000000..eebfd78 --- /dev/null +++ b/src/agent/tools/web_page.py @@ -0,0 +1,406 @@ +"""web_page 工具:对当前浏览器页面执行 DOM 级批量操作。 + +定位方式(无需视觉 grounding,毫秒级命中): + - ref: snapshot 返回的元素序号 + - selector: Playwright 选择器(CSS / text=登录 / role=button / xpath= 等, + 纯中文或含空格的字符串会自动按 text= 处理) + +批量语义: + actions 列表顺序执行,首错即停并报告失败步骤;每次调用结束返回页面截图 + artifact(复用 nodes.py 的 image_bytes 注入链路)。 +""" +from __future__ import annotations + +import asyncio +import re +from typing import Any, Literal, Optional, Tuple + +from langchain.tools import BaseTool +from pydantic import BaseModel, Field + +from .browser_control import get_browser_session, normalize_url + +# ---------- snapshot / extract 注入页面的 JS ---------- +_SNAPSHOT_JS = """() => { + const SELECTOR = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [onclick], [contenteditable="true"]'; + const els = Array.from(document.querySelectorAll(SELECTOR)); + const out = []; + const isVisible = (el) => { + const r = el.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return false; + const s = getComputedStyle(el); + return s.visibility !== 'hidden' && s.display !== 'none'; + }; + const cssPath = (el) => { + if (el.id) return '#' + CSS.escape(el.id); + const parts = []; + let cur = el, depth = 0; + while (cur && cur.nodeType === 1 && depth < 6) { + let seg = cur.tagName.toLowerCase(); + if (seg === 'html' || seg === 'body') break; + const parent = cur.parentElement; + if (parent) { + const same = Array.from(parent.children).filter(c => c.tagName === cur.tagName); + if (same.length > 1) seg += ':nth-of-type(' + (same.indexOf(cur) + 1) + ')'; + } + parts.unshift(seg); + cur = parent; + depth += 1; + if (cur && cur.id) { parts.unshift('#' + CSS.escape(cur.id)); break; } + } + return parts.join(' > '); + }; + for (const el of els) { + try { + if (!isVisible(el)) continue; + const text = (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40); + out.push({ + sel: cssPath(el), + tag: el.tagName.toLowerCase(), + text: text, + aria: (el.getAttribute('aria-label') || '').slice(0, 40), + ph: (el.getAttribute('placeholder') || '').slice(0, 40), + href: (el.getAttribute('href') || '').slice(0, 80), + type: el.getAttribute('type') || '' + }); + if (out.length >= 80) break; + } catch (e) {} + } + return { title: document.title, url: location.href, elements: out }; +}""" + +_EXTRACT_JS = """(sel) => { + const el = sel ? document.querySelector(sel) : document.body; + if (!el) return null; + return { title: document.title, url: location.href, text: el.innerText || '' }; +}""" + + +# 选择器结构字符:出现任意一个即视为"已是选择器语法",不做 text= 自动转换 +_SELECTOR_META_RE = re.compile(r"[\[\]#.:>~+*@=()]") + + +def _normalize_selector(sel: str) -> str: + """选择器归一化:仅对不含结构字符的纯中文短语自动加 text= 前缀。 + + 注意: + - input[placeholder="用户名"]、button:has-text("提交") 等是合法 CSS / + Playwright 引擎语法(中文出现在属性值/伪类参数里),必须原样透传, + 不能因"含中文"被误转成 text=xxx + - 含空格的 ASCII 串可能是 CSS 后代选择器(如 "h2 a"),同样不转换 + """ + s = (sel or "").strip() + if not s: + raise ValueError("selector 不能为空") + # 已带 Playwright 选择器引擎前缀 + if re.match(r"^(css|text|xpath|id|role|placeholder|label|alt|title|testid|data-testid|nth|visible)=|^(>>|internal:)", s): + return s + if s.startswith("//") or s.startswith(".."): + return "xpath=" + s + # 纯中文短语(无任何选择器结构字符)→ text= 引擎匹配 + if re.search(r"[\u4e00-\u9fff]", s) and not _SELECTOR_META_RE.search(s): + return "text=" + s + return s + + +# ---------- 动作定义 ---------- +WebPageAction = Literal[ + "goto", "snapshot", "click", "fill", "hover", "select", + "scroll", "press", "wait", "extract", "back", "forward", + "reload", "screenshot", "eval_js", +] + + +class WebPageActionItem(BaseModel): + """单个页面动作。""" + action: WebPageAction = Field(..., description="动作类型。") + url: str = Field(default="", description="目标 URL(goto 用,无协议头自动补 https://)。") + ref: Optional[int] = Field(default=None, description="snapshot 返回的元素序号(优先于 selector)。") + selector: Optional[str] = Field( + default=None, + description="Playwright 选择器,合法写法原样传入即可:CSS(#id、.cls、input[placeholder=\"用户名\"])、" + "text=登录、placeholder=用户名、role=button[name=\"提交\"]、xpath=//div。" + "仅纯中文短语会自动按 text= 匹配;不要给 CSS/引擎语法外再套 text= 前缀。", + ) + text: str = Field(default="", description="fill 要输入的文本。") + press_enter: bool = Field(default=False, description="fill 输入完成后按回车(触发表单提交/搜索)。") + button: Literal["left", "right"] = Field(default="left", description="鼠标按键(click 用)。") + double_click: bool = Field(default=False, description="是否双击(click 用)。") + key: str = Field(default="", description="按键(press 用),如 Enter、Control+a、ArrowDown。") + clicks: int = Field(default=0, description="滚轮量(scroll 用,与 gui_tool 一致:负数为向下)。") + scroll_to: Literal["", "top", "bottom"] = Field(default="", description="滚动到页面顶部/底部(scroll 用,优先于 clicks)。") + value: str = Field(default="", description="select 下拉选项的 value 或可见文本。") + wait_seconds: float = Field(default=1.0, description="等待秒数(wait 用)。") + wait_selector: str = Field(default="", description="等待该选择器元素出现(wait 用,优先于固定秒数)。") + full_page: bool = Field(default=False, description="是否整页截图(screenshot 用)。") + max_length: int = Field(default=3000, description="extract 文本最大返回长度。") + js: str = Field(default="", description="eval_js 要执行的 JS 表达式或箭头函数。") + + +def _short_url(url: str, limit: int = 100) -> str: + """结果文本中的 URL 截断(data: 或超长 query 的 URL 全量回显会污染上下文)。""" + u = url or "" + return u if len(u) <= limit else u[:limit] + "..." + + +class WebPageInput(BaseModel): + actions: list[WebPageActionItem] = Field( + default_factory=list, + description="有序页面动作列表,按顺序依次执行;空列表仅返回当前页面截图。", + ) + + +class WebPage(BaseTool): + """DOM 级网页操作工具:通过 snapshot 序号或选择器精准操作页面元素。""" + + args_schema: type[BaseModel] = WebPageInput + name: str = "web_page" + description: str = ( + "对浏览器当前页面执行 DOM 级精准操作(毫秒级,优于 gui_tool 视觉定位)。\n" + "典型流程:browser_control 打开 URL → web_page snapshot 获取元素清单 → 按 ref/selector 批量操作。\n" + "支持动作:goto/snapshot/click/fill/hover/select/scroll/press/wait/extract/back/forward/reload/screenshot/eval_js。\n" + "元素定位:优先用 snapshot 返回的 ref 序号;用 selector 时合法语法原样传入" + "(CSS:input[placeholder=\"用户名\"];引擎:text=登录 / placeholder=用户名 / role=button[name=\"提交\"] / xpath=//div)。\n" + "同一页面的连续操作必须合并为一次调用(actions 传多个动作),页面跳转后需重新 snapshot。\n" + "改变页面状态的操作会附操作后截图;纯观察批次(snapshot/extract)不附图。\n" + "仅用于已打开的浏览器页面操作,其余情况严禁调用。" + ) + response_format: Literal["content", "content_and_artifact"] = "content_and_artifact" + + # ---------- 单个动作执行(会话线程内运行) ---------- + async def _exec_action(self, session, item: WebPageActionItem) -> str: + page = session._page + + def _resolve() -> str: + """解析元素选择器:ref 优先,其次 selector(归一化)。""" + if item.ref is not None: + sel = session.get_ref_selector(item.ref) + if sel is None: + raise ValueError(f"ref={item.ref} 不存在(页面可能已跳转),请重新执行 snapshot") + return sel + if not item.selector: + raise ValueError("该动作需要提供 ref 或 selector 定位元素") + return _normalize_selector(item.selector) + + async def _loc_op(op, sel: str): + """执行 locator 操作,定位超时时给出 snapshot+ref 引导提示。""" + try: + return await op() + except Exception as e: + if type(e).__name__ == "TimeoutError": + raise ValueError( + f"元素定位超时(10s): {sel}。建议先执行 snapshot 获取元素清单,再用 ref 序号定位" + ) from e + raise + + if item.action == "goto": + if not item.url: + raise ValueError("goto 需要 url") + await page.goto(normalize_url(item.url), wait_until="domcontentloaded") + return f"已打开 {_short_url(page.url)}({await page.title()})" + + if item.action == "snapshot": + data = await page.evaluate(_SNAPSHOT_JS) + elements = data.get("elements", []) + refs = {i + 1: e["sel"] for i, e in enumerate(elements)} + session.set_refs(refs, page) + lines = [f"页面: {data.get('title', '')} | {data.get('url', '')}"] + lines.append(f"可交互元素共 {len(elements)} 个(click/fill 等用 ref 序号定位):") + for i, e in enumerate(elements, 1): + tag = e["tag"] + if tag == "input" and e.get("type"): + tag += f"[{e['type']}]" + parts = [f"[{i}] {tag}"] + if e.get("text"): + parts.append(f"\"{e['text']}\"") + if e.get("aria"): + parts.append(f"aria=\"{e['aria']}\"") + if e.get("ph"): + parts.append(f"placeholder=\"{e['ph']}\"") + if e.get("href"): + parts.append(f"-> {e['href']}") + lines.append(" ".join(parts)) + return "\n".join(lines) + + if item.action == "click": + sel = _resolve() + loc = page.locator(sel) + if item.double_click: + await _loc_op(lambda: loc.dblclick(button=item.button), sel) + else: + await _loc_op(lambda: loc.click(button=item.button), sel) + return f"已点击元素({item.ref and f'ref={item.ref}' or item.selector})" + + if item.action == "fill": + sel = _resolve() + loc = page.locator(sel) + await _loc_op(lambda: loc.fill(item.text), sel) + if item.press_enter: + await page.keyboard.press("Enter") + return f"已输入并回车: \"{item.text[:50]}\"" + return f"已输入: \"{item.text[:50]}\"" + + if item.action == "hover": + sel = _resolve() + loc = page.locator(sel) + await _loc_op(lambda: loc.hover(), sel) + return "已悬停元素" + + if item.action == "select": + if not item.value: + raise ValueError("select 需要 value(选项 value 或可见文本)") + sel = _resolve() + loc = page.locator(sel) + try: + await _loc_op(lambda: loc.select_option(value=item.value), sel) + except Exception: + await _loc_op(lambda: loc.select_option(label=item.value), sel) + return f"已选择: {item.value[:50]}" + + if item.action == "scroll": + if item.scroll_to == "top": + await page.evaluate("window.scrollTo(0, 0)") + return "已滚动到顶部" + if item.scroll_to == "bottom": + await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") + return "已滚动到底部" + # 与 gui_tool 一致:负数向下(Playwright wheel 正 deltaY 向下) + await page.mouse.wheel(0, -item.clicks * 100) + return f"已滚动(滚轮量 {item.clicks})" + + if item.action == "press": + if not item.key: + raise ValueError("press 需要 key(如 Enter、Control+a)") + await page.keyboard.press(item.key) + return f"已按键: {item.key}" + + if item.action == "wait": + if item.wait_selector: + await page.wait_for_selector( + _normalize_selector(item.wait_selector), + timeout=max(item.wait_seconds, 1.0) * 1000, + ) + return f"已等到元素出现: {item.wait_selector}" + await asyncio.sleep(max(item.wait_seconds, 0.0)) + return f"已等待 {item.wait_seconds}s" + + if item.action == "extract": + try: + title = await page.title() + except Exception: + title = "" + if item.selector: + # 走 locator(支持 CSS/text=/xpath= 等全部引擎;多匹配时拼接前 20 个) + sel = _normalize_selector(item.selector) + loc = page.locator(sel) + count = await loc.count() + if count == 0: + raise ValueError(f"未找到元素: {item.selector}") + texts = [] + for i in range(min(count, 20)): + try: + t = (await loc.nth(i).inner_text()).strip() + except Exception: + t = "" + if t: + texts.append(t) + text = "\n".join(texts) + else: + data = await page.evaluate(_EXTRACT_JS, None) + text = ((data or {}).get("text") or "").strip() + limit = min(max(item.max_length, 200), 10000) + head = f"页面: {title} | {_short_url(page.url)}\n" + body = text[:limit] + ("...(已截断)" if len(text) > limit else "") + return head + (body or "(无文本内容)") + + if item.action == "back": + await page.go_back(wait_until="domcontentloaded") + return f"已后退到 {page.url}" + + if item.action == "forward": + await page.go_forward(wait_until="domcontentloaded") + return f"已前进到 {page.url}" + + if item.action == "reload": + await page.reload(wait_until="domcontentloaded") + return f"已刷新({await page.title()})" + + if item.action == "screenshot": + return f"已请求截图({'整页' if item.full_page else '视口'})" + + if item.action == "eval_js": + if not item.js: + raise ValueError("eval_js 需要 js 表达式") + result = await page.evaluate(item.js) + return f"JS 执行结果: {str(result)[:2000]}" + + raise ValueError(f"不支持的动作类型: {item.action}") + + # ---------- 批量执行(一次 call_async 提交,会话线程内串行) ---------- + # 纯观察型动作:结果本身就是页面状态文本,无需再附截图(省视觉 token) + _OBSERVE_ONLY = {"snapshot", "extract", "eval_js", "wait"} + + async def _run_batch(self, session, actions: list[WebPageActionItem]) -> Tuple[str, Optional[bytes]]: + await session._ensure_connected() + lines: list[str] = [] + png: Optional[bytes] = None + want_full = False + screenshot_requested = False + prev_observe = False + for idx, item in enumerate(actions, 1): + if item.action == "screenshot": + want_full = item.full_page + screenshot_requested = True + # 观察型动作(snapshot/extract 等)执行前等待页面稳定, + # 避免 JS 未渲染完就读到旧状态;连续观察只等待一次 + is_observe = item.action in self._OBSERVE_ONLY + if is_observe and not prev_observe: + try: + await session._settle_current() + except Exception: + pass + try: + line = await self._exec_action(session, item) + lines.append(f"步骤{idx} [{item.action}] {line}") + prev_observe = is_observe + except Exception as e: + lines.append(f"步骤{idx} [{item.action}] 失败: {e}") + break + # 操作结束截图:空动作或含非观察型动作(点击/输入等改变页面状态的操作)时附加; + # 纯观察批次(snapshot/extract 等)文本结果已含页面信息,跳过截图。 + # 截图统一走 session.screenshot(内含页面稳定等待,避免截到加载动画) + need_shot = screenshot_requested or (not actions) or any( + a.action not in self._OBSERVE_ONLY for a in actions + ) + if need_shot: + try: + png = await session.screenshot(want_full) if session._page else None + except Exception: + png = None + if screenshot_requested: + lines.append("截图见随附图片") + msg = "\n".join(lines) if lines else "无动作(已返回当前页面截图)" + return msg, png + + # ---------- 同步主逻辑 ---------- + def _run(self, actions: list[WebPageActionItem] | list[dict] | None = None) -> Tuple[str, dict]: + session = get_browser_session() + # LangChain 调用路径传入 pydantic 模型实例;直连/测试路径可能传 dict,两者兼容 + items = [ + a if isinstance(a, WebPageActionItem) else WebPageActionItem(**a) + for a in (actions or []) + ] + try: + msg, png = session.call_async( + lambda s: self._run_batch(s, items), + timeout=180.0, + ) + except Exception as e: + return f"web_page 执行出错: {e}", {} + artifact: dict = {} + if png: + artifact["image_bytes"] = png + return msg, artifact + + async def _arun(self, actions: list[WebPageActionItem] | list[dict] | None = None) -> Tuple[str, dict]: + return await asyncio.to_thread(self._run, actions) diff --git a/src/agent/utils/agent_utils.py b/src/agent/utils/agent_utils.py index 50a15f9..01d6f7d 100644 --- a/src/agent/utils/agent_utils.py +++ b/src/agent/utils/agent_utils.py @@ -33,11 +33,23 @@ from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS AUDIO_FILE_EXTENSIONS = frozenset({".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac", ".wma", ".opus", ".aiff", ".ape", ".amr", ".au"}) # 扩展名到内部类型标签的映射 +# plain_text 覆盖所有 UTF-8 文本/代码文件(与前端附件分类保持一致) DOC_EXT_MAP = { - ".txt": "plain_text", ".json": "plain_text", ".md": "plain_text",".cpp": "plain_text", - ".c": "plain_text", ".py": "plain_text", ".m": "plain_text", ".java": "plain_text",".html": "plain_text", - ".svg": "plain_text", + ".txt": "plain_text", ".json": "plain_text", ".md": "plain_text", ".markdown": "plain_text", + ".js": "plain_text", ".mjs": "plain_text", ".cjs": "plain_text", ".ts": "plain_text", + ".tsx": "plain_text", ".jsx": "plain_text", ".vue": "plain_text", ".svelte": "plain_text", + ".py": "plain_text", ".pyw": "plain_text", ".html": "plain_text", ".htm": "plain_text", + ".css": "plain_text", ".scss": "plain_text", ".less": "plain_text", ".sass": "plain_text", + ".xml": "plain_text", ".yaml": "plain_text", ".yml": "plain_text", ".toml": "plain_text", + ".ini": "plain_text", ".cfg": "plain_text", ".conf": "plain_text", ".log": "plain_text", + ".env": "plain_text", ".sh": "plain_text", ".bat": "plain_text", ".cmd": "plain_text", + ".ps1": "plain_text", ".sql": "plain_text", ".r": "plain_text", ".go": "plain_text", + ".rs": "plain_text", ".rb": "plain_text", ".php": "plain_text", ".swift": "plain_text", + ".kt": "plain_text", ".scala": "plain_text", ".cpp": "plain_text", ".cc": "plain_text", + ".c": "plain_text", ".h": "plain_text", ".hpp": "plain_text", ".java": "plain_text", + ".m": "plain_text", ".svg": "plain_text", ".docx": "docx", + ".docm": "docx", ".pptx": "pptx", ".pdf": "pdf", ".csv": "tabular", @@ -182,6 +194,31 @@ def update_session_meta(session_id: str, agent_name: str, _save_session_meta(session_id, meta) +# ---------- 会话级思考档位(存于 session_meta.json 顶层) ---------- +def get_session_thinking_level(session_id: str) -> str: + """读取会话级思考档位(顶层 thinking_level 键)。 + + 输入: + session_id: 会话 ID。 + + 输出: + 档位字符串(low | high | xhigh | max | ultra);未配置返回 ""。 + """ + if not session_id: + return "" + meta = _load_session_meta(session_id) + return str(meta.get("thinking_level", "") or "") + + +def set_session_thinking_level(session_id: str, level: str) -> None: + """写入会话级思考档位(顶层 thinking_level 键,不覆盖 agent_meta / tokens)。""" + if not session_id: + return + meta = _load_session_meta(session_id) + meta["thinking_level"] = level + _save_session_meta(session_id, meta) + + # ---------- 动态尾部长期记忆(TodoList 状态 + 循环提醒) ---------- def update_session_dynamic_tail(session_id: str, agent_key: str, text: str, max_entries: int = 100) -> None: """持久化当前轮的动态尾部提示(TodoList 状态 + 循环提醒)为长期记忆。 @@ -226,7 +263,7 @@ def get_session_dynamic_tail_history(session_id: str, agent_key: str) -> list[st def build_tool_history_messages(session_id: str, agent_key: str = "main") -> list: - """构建压缩游标之后的"历史工具调用与返回值"合成消息列表。 + """构建压缩游标之后的"历史思考与工具调用"合成消息列表。 输入: session_id: 会话 ID。 @@ -236,10 +273,21 @@ def build_tool_history_messages(session_id: str, agent_key: str = "main") -> lis synthetic HumanMessage(带 ``tool_ctx`` 标记)列表;无历史或 游标之后无内容时为空列表。 + 上下文拼接格式(前缀缓存友好,确定性构建): + [历史思考] {thinking} ← 该工具调用前模型产出的深度思考(reasoning_content) + [历史反思] {reflection} ← 该工具调用前模型产出的反思(content) + [历史工具调用] {name}({args}) → {result} + (思考、反思、工具调用按因果顺序交错;思考/反思独立注入——工具无 + 结果文本(失败/无返回值)时仍保留;每轮末尾未被工具调用消费的 + 尾部思考/反思去重后追加在该轮工具调用之后) + 系统定位: runtime.execute_agent 构建上下文时调用,替代原"最近5轮"注入, - 改为注入压缩游标之后的所有轮次工具调用历史(游标之前的部分已包含在 - 压缩摘要中,由 ``compressed_content`` 承载)。 + 改为注入压缩游标之后的所有轮次思考+工具调用历史(游标之前的 + 部分已包含在压缩摘要中,由 ``compressed_content`` 承载)。 + 所有消息从磁盘确定性构建、id 稳定(think_ctx_{idx}/ + reflect_ctx_{idx}/tool_ctx_{idx}),满足 LLM 前缀缓存条件; + compress 节点按 id 精确移除。 """ if not session_id: return [] @@ -252,11 +300,35 @@ def build_tool_history_messages(session_id: str, agent_key: str = "main") -> lis turns.reverse() # 转为时间正序 idx = 0 for turn in turns: + # 本轮已被工具调用消费的思考/反思内容(用于尾部去重; + # 含游标之前的工具调用——被摘要覆盖的思考同样不得重复注入) + consumed_thinkings: set[str] = set() + consumed_reflections: set[str] = set() for tc in turn.get("tool_calls") or []: + _think = str(tc.get("thinking", "") or "").strip() + _reflect = str(tc.get("reflection", "") or "").strip() + if _think: + consumed_thinkings.add(_think) + if _reflect: + consumed_reflections.add(_reflect) if idx >= cursor: tn = tc.get("name", "unknown") ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300] tr = str(tc.get("result_text", "") or "")[:500] + # 思考/反思独立注入:即使工具无结果文本(失败/无返回值), + # 模型的思考/反思仍有上下文价值 + if _think: + result.append(HumanMessage( + content=f"[历史思考] {_think[:500]}", + additional_kwargs={"synthetic": True, "tool_ctx": True}, + id=f"think_ctx_{idx}", + )) + if _reflect: + result.append(HumanMessage( + content=f"[历史反思] {_reflect[:500]}", + additional_kwargs={"synthetic": True, "tool_ctx": True}, + id=f"reflect_ctx_{idx}", + )) if tn and tr: # 分配稳定 id(基于全局工具调用序号),供压缩节点 RemoveMessage 精确移除 result.append(HumanMessage( @@ -265,6 +337,38 @@ def build_tool_history_messages(session_id: str, agent_key: str = "main") -> lis id=f"tool_ctx_{idx}", )) idx += 1 + # 尾部思考/反思:该轮末尾未被任何工具调用消费的(分别去重后追加) + if idx > cursor: + think_tail_idx = 0 + reflect_tail_idx = 0 + for ref in turn.get("reflections") or []: + _thought = str(ref.get("thought", "") or "").strip() + _reflection = str(ref.get("reflection", "") or "").strip() + if _thought and _thought not in consumed_thinkings: + result.append(HumanMessage( + content=f"[历史思考] {_thought[:500]}", + additional_kwargs={"synthetic": True, "tool_ctx": True}, + id=f"think_tail_{turn.get('turn_id', idx)}_{think_tail_idx}", + )) + think_tail_idx += 1 + if _reflection and _reflection not in consumed_reflections: + result.append(HumanMessage( + content=f"[历史反思] {_reflection[:500]}", + additional_kwargs={"synthetic": True, "tool_ctx": True}, + id=f"reflect_tail_{turn.get('turn_id', idx)}_{reflect_tail_idx}", + )) + reflect_tail_idx += 1 + # 旧格式兼容(仅 content,无 thought/reflection 字段): + # 视为思考注入(与旧行为一致) + if not _thought and not _reflection: + rc = str(ref.get("content", "") or "").strip() + if rc and rc not in consumed_thinkings: + result.append(HumanMessage( + content=f"[历史思考] {rc[:500]}", + additional_kwargs={"synthetic": True, "tool_ctx": True}, + id=f"think_tail_{turn.get('turn_id', idx)}_{think_tail_idx}", + )) + think_tail_idx += 1 except Exception: pass return result @@ -913,21 +1017,26 @@ def audio_file_to_text(audio_path: str) -> str: except ImportError: raise RuntimeError("非 WAV 音频需要安装 pydub 和 ffmpeg: pip install pydub") + asr_cfg = _env_utils.get_service_model_config("asr") try: with open(wav_path, "rb") as f: audio_bytes = f.read() b64 = _base64.b64encode(audio_bytes).decode("ascii") data_url = f"data:audio/wav;base64,{b64}" payload = { + "model": asr_cfg["model"], "messages": [{ "role": "user", "content": [{"type": "audio_url", "audio_url": {"url": data_url}}] }] } - endpoint = f"{_env_utils.ASR_BASE_URL.rstrip('/')}/v1/chat/completions" + endpoint = f"{asr_cfg['base_url']}/v1/chat/completions" + headers = {"Content-Type": "application/json"} + if asr_cfg["api_key"]: + headers["Authorization"] = f"Bearer {asr_cfg['api_key']}" resp = _requests.post( endpoint, - headers={"Content-Type": "application/json"}, + headers=headers, json=payload, timeout=120, ) diff --git a/src/agent/utils/env_utils.py b/src/agent/utils/env_utils.py index aa88083..f235231 100644 --- a/src/agent/utils/env_utils.py +++ b/src/agent/utils/env_utils.py @@ -1,7 +1,7 @@ """环境变量加载与全局配置常量。 系统定位: - 项目配置入口,模块导入时从 ``config/env_config.json`` 加载配置, + 项目配置入口,模块导入时从 ``agents/config.json`` 的 env 分区加载配置, 并向 ``core/llm``、``utils/agent_utils``、``utils/image_utils`` 等模块 提供 LLM、压缩策略、RAG、浏览器等运行时参数。 @@ -48,27 +48,29 @@ def _get_env(name: str, default: str | None = None, cast: type | None = None, re def _refresh_globals(): - """刷新模块级常量(热重载用)。""" + """刷新模块级常量(热重载用)。 + + WORKING_DIR / WORKSPACE_DIR 已从环境变量移除:工作空间默认取启动目录 + (os.getcwd()),实际工作空间由 workspace 分区与工作空间 API 管理。 + THINKING_LEVEL 已改为会话级变量(存于各会话 session_meta.json), + 此处仅保留全局默认档位作为无会话上下文(cron、describe_image 等)的兜底。 + """ global WORKING_DIR, WORKSPACE_DIR, USER_PYTHON_PATH global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT - global STREAMING_TTS_URL, ASR_BASE_URL, RAG_BASE_URL global LLM_CONTEXT_WINDOW, COMPRESS_RATE, THINKING_LEVEL global IMG_SIZE, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, GROUNDING_WIDTH, GROUNDING_HEIGHT global SEND_FILE_SIZE_LIMIT - WORKING_DIR = _get_env("WORKING_DIR", default=os.getcwd()) + WORKING_DIR = os.getcwd() # 将相对路径解析为绝对路径,确保无论从哪个目录启动服务,WORKING_DIR 始终指向项目根目录 if WORKING_DIR and not os.path.isabs(WORKING_DIR): WORKING_DIR = os.path.abspath(WORKING_DIR) - WORKSPACE_DIR = _get_env("WORKSPACE_DIR", default="") + WORKSPACE_DIR = "" USER_PYTHON_PATH = _get_env("USER_PYTHON_PATH", default="") LLM_BASE_URL = _get_env("LLM_BASE_URL", required=True) LLM_API_KEY = _get_env("LLM_API_KEY", required=True) LLM_MODEL = _get_env("LLM_MODEL", required=True) LLM_TIMEOUT = _get_env("LLM_TIMEOUT", default="60", cast=float) - STREAMING_TTS_URL = _get_env("STREAMING_TTS_URL") - ASR_BASE_URL = _get_env("ASR_BASE_URL") - RAG_BASE_URL = _get_env("RAG_BASE_URL") LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 262144)) COMPRESS_RATE = float(os.getenv("COMPRESS_RATE", 0.6)) THINKING_LEVEL = _get_env("THINKING_LEVEL", default="low") @@ -87,27 +89,103 @@ def _refresh_globals(): # ultra 为未来 react→审批图预留占位,暂按"agent+思考"处理 THINKING_LEVELS: tuple[str, ...] = ("low", "high", "xhigh", "max", "ultra") _THINKING_ENABLED_LEVELS: frozenset[str] = frozenset({"xhigh", "max", "ultra"}) +# 全局默认档位:仅作为无会话上下文(cron 等)的兜底;会话级档位见 get_session_thinking_level THINKING_LEVEL: str = "low" -def thinking_enabled(level: str | None = None) -> bool: - """判断思考档位是否启用深度思考。""" - lv = (level or THINKING_LEVEL or "low").lower() - return lv in _THINKING_ENABLED_LEVELS +def get_session_thinking_level(session_id: str | None) -> str: + """读取会话级思考档位(存于该会话的 session_meta.json)。 + + 输入: + session_id: 会话 ID;为空或读取失败时返回全局默认档位。 + + 输出: + 合法档位字符串(low | high | xhigh | max | ultra)。 + """ + if session_id: + try: + from agent.utils.agent_utils import get_session_thinking_level as _get_session_level + lv = _get_session_level(session_id) + if lv in THINKING_LEVELS: + return lv + except Exception: + pass + return THINKING_LEVEL -def get_thinking_extra_body(level: str | None = None) -> dict | None: +def thinking_enabled(level: str | None = None, session_id: str | None = None) -> bool: + """判断思考档位是否启用深度思考(level 优先,其次按会话档位,最后全局默认)。""" + if level: + lv = level + elif session_id: + lv = get_session_thinking_level(session_id) + else: + lv = THINKING_LEVEL + return (lv or "low").lower() in _THINKING_ENABLED_LEVELS + + +def get_thinking_extra_body(level: str | None = None, session_id: str | None = None) -> dict | None: """根据思考档位返回 Multimodel_LLM 的 extra_body。 返回空 dict {} 表示恢复模型原生行为(启用思考);None 表示禁用思考(默认)。 + 传入 session_id 时按该会话的会话级档位解析(供运行时每轮动态应用)。 """ - return {} if thinking_enabled(level) else None + return {} if thinking_enabled(level, session_id) else None + + +# ---------- 本地服务模型(ASR / TTS / RAG / ImageGen) ---------- +# 这四个服务在 env_config.json 的 models 列表中注册(model 字段为模型名), +# 运行时按模型名匹配取得 base_url / api_key;未注册时回退到默认本地端口。 +SERVICE_MODELS: dict[str, str] = { + "asr": "Qwen3-ASR-1.7B", + "tts": "VoxCPM1.5", + "rag": "Qwen3-Embedding-0.6B", + "image_gen": "Z-Image-Turbo", +} +SERVICE_DEFAULT_URLS: dict[str, str] = { + "asr": "http://localhost:8901", + "tts": "http://localhost:8902", + "rag": "http://localhost:8903", + "image_gen": "http://localhost:8904", +} + + +def get_service_model_config(service: str) -> dict: + """获取本地服务(asr/tts/rag/image_gen)的模型配置。 + + 优先在 env_config.json 的 models 列表中按 model 名匹配(含 base_url / api_key / model), + 未注册时回退到默认本地端口(api_key 为空)。 + + 输入: + service: 服务标识,SERVICE_MODELS 中的键(asr/tts/rag/image_gen)。 + + 输出: + {"model": str, "base_url": str, "api_key": str} + """ + model_name = SERVICE_MODELS.get(service, "") + default_url = SERVICE_DEFAULT_URLS.get(service, "") + try: + from agent.core.config_manager import get_model_by_name + cfg = get_model_by_name(model_name) + if cfg and cfg.get("base_url"): + return { + "model": str(cfg.get("model", model_name)), + "base_url": str(cfg["base_url"]).rstrip("/"), + "api_key": str(cfg.get("api_key", "") or ""), + } + except Exception: + pass + return { + "model": model_name, + "base_url": default_url.rstrip("/"), + "api_key": "", + } def _load_config() -> dict: - """加载 env_config.json,不存在时回退到 .env 文件。 + """加载 config.json 的 env 分区,不存在时回退到 .env 文件。 - 优先级: env_config.json(通过 config_manager 统一读取) > .env > 默认值 + 优先级: agents/config.json(通过 config_manager 统一读取) > .env > 默认值 """ # 优先通过 config_manager 读取(单一数据源,避免重复解析同一文件) try: @@ -132,12 +210,13 @@ def _load_config() -> dict: pass # 回退:直接读取文件(config_manager 不可用时) - _config_path = Path(__file__).resolve().parents[1] / "config" / "env_config.json" + _config_path = Path(__file__).resolve().parents[1] / "agents" / "config.json" if _config_path.is_file(): try: with open(_config_path, "r", encoding="utf-8") as f: - config = json.load(f) + merged = json.load(f) + config = merged.get("env") if isinstance(merged, dict) else None if isinstance(config, dict): for key, value in config.items(): if key != "models" and value is not None: diff --git a/src/agent/utils/git_utils.py b/src/agent/utils/git_utils.py new file mode 100644 index 0000000..06831c8 --- /dev/null +++ b/src/agent/utils/git_utils.py @@ -0,0 +1,206 @@ +"""工作区 git 集成工具(元数据目录 .minorAgent,替代自研 snapshot 体系)。 + +系统定位: + - 为 Edit 文件树提供 git 状态(新增绿/修改橙),为会话回撤提供文件恢复 + - 元数据目录用工作区内的 ``.minorAgent``(GIT_DIR 环境变量方式,工作区不出现 .git) + - 所有操作 try/except 包裹:git 不可用/非仓库时静默跳过,不影响主流程 + +可扩展性: + - 提交时机由调用方决定(轮次结束落盘时 commit_turn,回撤时 reset_to_commit) + - 新的 git 子命令可复用 _run / _run_lines 封装 +""" +from __future__ import annotations + +import os +import re +import subprocess +from typing import Optional + +# 元数据目录名(工作区内) +GIT_META_DIR = ".minorAgent" +_INIT_MSG = "minor: init" +_TURN_MSG_PREFIX = "minor: " + +_SAFE_ENV = dict(os.environ) + + +def _ws_git_dir(workspace: str) -> str: + return os.path.join(workspace, GIT_META_DIR) + + +def _git_env(workspace: str) -> dict: + """为指定工作区构造 GIT_DIR/GIT_WORK_TREE 环境(元数据放 .minorAgent)。""" + env = dict(_SAFE_ENV) + env["GIT_DIR"] = _ws_git_dir(workspace) + env["GIT_WORK_TREE"] = workspace + return env + + +def _run(workspace: str, *args: str, timeout: float = 20.0) -> tuple[int, str]: + """在指定工作区执行 git 命令,返回 (returncode, stdout)。 + + 注意:不做 strip——porcelain 输出行首的空格是状态码的一部分 + (如 " D path" 表示 worktree 删除),剥离会破坏解析。 + """ + try: + proc = subprocess.run( + ["git", *args], + cwd=workspace, + env=_git_env(workspace), + capture_output=True, + text=True, + timeout=timeout, + ) + return proc.returncode, proc.stdout + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return -1, "" + + +def is_repo(workspace: str) -> bool: + """工作区是否已初始化为 git 仓库。""" + if not workspace or not os.path.isdir(workspace): + return False + return os.path.isdir(_ws_git_dir(workspace)) + + +def ensure_repo(workspace: str) -> bool: + """确保工作区为 git 仓库(首次自动 init + 初始 commit),失败返回 False。 + + 初始 commit 让回撤有最底层的恢复点;.minorAgent 写入 info/exclude, + 避免 git status 把元数据目录自身显示为未跟踪。 + """ + if not workspace or not os.path.isdir(workspace): + return False + if is_repo(workspace): + return True + try: + rc, _ = _run(workspace, "init", "--quiet") + if rc != 0: + return False + # 本地 user 配置(仅作用于该仓库) + _run(workspace, "config", "user.name", "minor-agent") + _run(workspace, "config", "user.email", "minor-agent@local") + # 排除元数据目录自身(append 模式句柄不可读,需先读后写) + exclude = os.path.join(_ws_git_dir(workspace), "info", "exclude") + try: + os.makedirs(os.path.dirname(exclude), exist_ok=True) + existing = "" + if os.path.isfile(exclude): + with open(exclude, "r", encoding="utf-8") as f: + existing = f.read() + if GIT_META_DIR not in existing: + with open(exclude, "a", encoding="utf-8") as f: + f.write(f"\n{GIT_META_DIR}/\n") + except OSError: + pass + # 初始 commit(空改动也提交,保证 HEAD 存在) + _run(workspace, "add", "-A") + rc, _ = _run(workspace, "commit", "-m", _INIT_MSG, "--allow-empty") + return rc == 0 + except Exception: + return False + + +def _porcelain_state(code: str) -> str: + """git status --porcelain 单字符状态码 → 业务状态。""" + if code.startswith("??"): + return "untracked" + # 第一列 X=index(暂存),第二列 Y=worktree:任一非空格即视为 modified/deleted + if len(code) >= 2: + x, y = code[0], code[1] + if "D" in (x, y): + return "deleted" + if x != " " or y != " ": + return "modified" + return "modified" + + +def status(workspace: str) -> list[dict]: + """返回工作区 git 状态列表:[{path, state}],非仓库/失败返回 []。 + + state: untracked(新增) | modified(修改/暂存) | deleted(删除) + """ + if not is_repo(workspace): + return [] + rc, out = _run(workspace, "status", "--porcelain", "-z") + if rc != 0 or not out: + return [] + result: list[dict] = [] + # -z 模式:每项为 "XY path\0",重命名/复制为 "XY old -> new\0" + for entry in out.split("\0"): + if not entry or len(entry) < 3: + continue + code, path = entry[:2], entry[3:] + if code.startswith("R") or code.startswith("C"): + path = path.split(" -> ")[-1] if " -> " in path else path + result.append({"path": path, "state": _porcelain_state(code)}) + return result + + +def commit_turn(workspace: str, session_id: str, turn_id: str) -> bool: + """提交轮次结束后的工作区快照,message 编码会话与回合(供回撤定位)。""" + if not workspace or not os.path.isdir(workspace): + return False + if not ensure_repo(workspace): + return False + try: + _run(workspace, "add", "-A") + rc, _ = _run(workspace, "commit", "-m", f"{_TURN_MSG_PREFIX}{session_id} {turn_id}") + return rc == 0 + except Exception: + return False + + +def find_restore_commit(workspace: str, session_id: str, keep_turn_id: str) -> Optional[str]: + """定位回撤恢复点 commit:该会话 < keep_turn_id 的最近一次轮次提交,无则初始提交。 + + 回撤到某条消息时,文件应恢复到"该消息之前"的状态 = + 上一个已完成轮次的提交(turn_id 严格小于 keep_turn_id)。 + """ + if not is_repo(workspace): + return None + rc, out = _run(workspace, "log", "--format=%H%x09%s") + if rc != 0 or not out: + return None + best: Optional[str] = None + for line in out.splitlines(): + parts = line.split("\t", 1) + if len(parts) != 2: + continue + commit_hash, msg = parts[0], parts[1] + if msg == _INIT_MSG: + best = best or commit_hash # 兜底:初始提交 + continue + if not msg.startswith(_TURN_MSG_PREFIX): + continue + m = re.match(rf"^{re.escape(_TURN_MSG_PREFIX)}(\S+)\s+(\S+)$", msg) + if not m or m.group(1) != session_id: + continue + tid = m.group(2) + if tid < keep_turn_id: # turn_id 为时间戳字符串,字典序即时间序 + best = commit_hash + return best + + +def reset_to_commit(workspace: str, commit: str) -> bool: + """将工作区与 git 状态恢复到指定 commit(丢弃该点之后的改动与未跟踪文件)。""" + if not is_repo(workspace) or not commit: + return False + try: + rc, _ = _run(workspace, "reset", "--hard", commit) + if rc != 0: + return False + _run(workspace, "clean", "-fd") + return True + except Exception: + return False + + +def rollback_workspace_to(workspace: str, session_id: str, keep_turn_id: str) -> bool: + """会话回撤时恢复工作区文件与 git 状态(无恢复点返回 False,调用方跳过)。""" + if not workspace: + return False + commit = find_restore_commit(workspace, session_id, keep_turn_id) + if not commit: + return False + return reset_to_commit(workspace, commit) diff --git a/src/agent/utils/tool_call_utils.py b/src/agent/utils/tool_call_utils.py index 4a9b259..066cbe4 100644 --- a/src/agent/utils/tool_call_utils.py +++ b/src/agent/utils/tool_call_utils.py @@ -274,43 +274,58 @@ def split_inline_thinking(text: str) -> tuple[str, str]: return thinking, reply -def extract_reasoning_text(msg: Any) -> str: - """提取模型的思考/反思内容,根据思考档位区分提取来源。 +def extract_thought(msg: Any) -> str: + """提取模型的深度思考(thought):``additional_kwargs.reasoning_content``。 - 功能描述: - - 开启思考(xhigh/max/ultra):优先 ``additional_kwargs.reasoning_content`` - (独立字段,或由 ``_create_chat_result`` 从内联 ```` 块归一化而来), - 其次 content 内联 ```` 块;无标记返回 ""。 - - 关闭思考(low/high):模型在反思提示引导下,content 即一句话反思, - 直接返回 content。 + 思考仅在深度思考开启时存在(独立字段,或由 ``_create_chat_result`` 从 + 内联 ```` 块归一化而来);无思考返回空字符串。 输入: - msg: AIMessage 对象(需有 content / additional_kwargs 属性)。 + msg: AIMessage 对象(需有 additional_kwargs 属性)。 输出: - 思考/反思内容文本(已 strip),无内容时返回空字符串。 - - 系统定位: - 供 ``core/nodes`` 提取反思内容、``history/tool_call_recorder`` 提取思维链展示文本。 + 思考文本(已 strip),无思考时返回 ""。 """ - from agent.utils.env_utils import thinking_enabled - extra = getattr(msg, "additional_kwargs", None) or {} rc = extra.get("reasoning_content") if isinstance(extra, dict) else None if rc and isinstance(rc, str) and rc.strip(): return rc.strip() + return "" + +def extract_reflection(msg: Any) -> str: + """提取模型的反思(reflection):``content``。 + + 反思 = 模型返回的 content(思考关闭档位下为引导输出的一句话反思; + 思考开启档位下若 content 非空同样保留为反思)。与思考独立存储, + 二者可同时存在。 + + 输入: + msg: AIMessage 对象(需有 content 属性)。 + + 输出: + 反思文本(已 strip),无内容时返回 ""。 + """ content = getattr(msg, "content", "") if content and isinstance(content, str) and content.strip(): - if thinking_enabled(): - # 开启思考:仅提取内联 块,无标记则无思考内容 - thinking, _ = split_inline_thinking(content) - return thinking if thinking else "" - # 关闭思考:content 即反思内容 return content.strip() return "" +def extract_reasoning_text(msg: Any) -> str: + """提取模型的思考/反思合并文本(兼容入口:有思考用思考,否则用反思)。 + + 规则: + 思考 = 深度思考开启时的 ``reasoning_content``; + 反思 = ``content``。 + 优先取思考,无思考时回退使用反思。 + + 注意: 需要同时保留思考与反思时请分别使用 extract_thought / + extract_reflection(存储与上下文注入按此区分)。 + """ + return extract_thought(msg) or extract_reflection(msg) + + def format_args_summary(args: dict, max_len: int = 80) -> str: """将工具参数 dict 序列化为截断的 JSON 字符串摘要。 diff --git a/src/web/app.css b/src/web/app.css index e9f68ab..4a97337 100644 --- a/src/web/app.css +++ b/src/web/app.css @@ -6,9 +6,8 @@ @import url("./css/composer.css"); @import url("./css/settings.css"); @import url("./css/pending.css"); -@import url("./css/pending-overlay.css"); @import url("./css/toolcalls.css"); -@import url("./css/todo.css"); +@import url("./css/notify-strip.css"); @import url("./css/skills.css"); @import url("./css/edit-mode.css"); @import url("./css/cron.css"); diff --git a/src/web/app.js b/src/web/app.js index c769cff..7fba346 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -1,7 +1,13 @@ // app.js -- 主入口:导入所有模块,绑定 DOM 事件 // ES 模块重构版:原单文件 3800+ 行已拆分为 js/ 目录下各司其职的小模块 +// +// !! 单实例约束:本文件必须全页面只被求值一次。js/ 下模块通过 import('../app.js') +// 取本文件的导出函数,入口脚本若带版本查询串(如 /app.js?v=N)会与无串路径形成 +// 两个模块实例 → 监听/初始化双份注册 → toggle 类按钮一次点击"开+关"互相抵消 +// (左栏/终端按钮曾因此表现为点击无反应)。入口已隔离为 js/boot.js(版本参数只加 +// 在它身上);今后切勿让任何 import 或 @@ -31,46 +31,22 @@
- -
- minor Agent -
- - - - -
-
- + +
@@ -96,18 +95,34 @@ > ☰ -
-

多模态Agent

+ + - + + + + +
+ + + +
@@ -118,6 +133,13 @@

+ + +
- -
- + +
+ + + - - - +
+ + + 任务执行时的文件操作沙箱目录(无人值守,建议选择已信任的工作区)。 +
@@ -369,49 +362,219 @@
- +