v0.2.0
This commit is contained in:
1 parent
731768deb9
commit
863abe7c76
18 files changed
+2740
-114
No files matched your search
+1862
File diff suppressed because it is too large.
Load diff
@@ -71,7 +71,7 @@
|
||||
| **GUI 操作粒度** | 一步一调用,N 个动作 = 2N+1 次 LLM 调用 | **动作序列合并**:同页多操作打包为一次 `gui` 调用,N 个动作仅 2 次 LLM 调用 |
|
||||
| **视觉反馈** | 仅返回坐标文本 | **截图作为视觉闭环**注入上下文,多模态 LLM 看到真实屏幕状态 |
|
||||
| **坐标定位** | 逐元素请求 | **批量定位** `_batch_ground_elements`:N 个元素 1 次子模型调用 |
|
||||
| **循环失控** | 无防护,token 烧穿 | **两级循环检测**:连续 3 次同工具警告反思 → 连续 5 次强制终止 |
|
||||
| **循环失控** | 无防护,token 烧穿 | **循环检测**:连续同工具同参(默认 3 次)注入反思警告,引导模型自我纠偏 |
|
||||
| **上下文膨胀** | 简单截断 | **图内分级压缩**:token 超 `窗口×COMPRESS_RATE` 触发,压缩历史工具调用为累积摘要,每步 LLM 调用后检查 |
|
||||
| **部署形态** | 仅 Web / 仅 API | **同一套源码双模式**:`uvicorn` Web 服务 ↔ Electron 桌面应用 |
|
||||
| **文件操作** | HTTP 上传下载 | 桌面端走 **IPC 直读磁盘**,零延迟;Web 端走 `/api/fs/*` |
|
||||
@@ -211,9 +211,9 @@ Agent/
|
||||
│ │ │ ├── graph.py # LangGraph 构建(单 Agent + 多 Agent)
|
||||
│ │ │ ├── nodes.py # ReAct 节点:call_model / process_tool_artifact / 压缩
|
||||
│ │ │ ├── runtime.py # 图执行器:消息拼装、历史加载、轨迹落盘
|
||||
│ │ │ ├── routing.py # should_continue 路由 + 强制终止
|
||||
│ │ │ ├── routing.py # should_continue 路由
|
||||
│ │ │ ├── state.py # Graph 状态定义(messages / agent_mode)
|
||||
│ │ │ ├── loop_detector.py # 循环检测:重复工具调用,两级响应
|
||||
│ │ │ ├── loop_detector.py # 循环检测:重复工具调用,反思警告
|
||||
│ │ │ ├── human_request.py # ★ 人工请求注册表:交互类工具的通用阻塞通道(ask_human)
|
||||
│ │ │ ├── turn_runner.py # ★ 后台线程轮次执行器(解耦 HTTP 与图执行生命周期)
|
||||
│ │ │ ├── llm.py # ChatQwen 多模态封装(图像/附件/非标准 tool_call)
|
||||
@@ -384,8 +384,7 @@ cloudflared tunnel --url http://localhost:8765
|
||||
| `THINKING_LEVEL` | `"low"` | 深度思考档位:low / high / xhigh / max / ultra(xhigh 及以上启用深度思考) |
|
||||
| `IMG_SIZE` | `768` | 截图短边尺寸 |
|
||||
| `GROUNDING_WIDTH/HEIGHT` | `1000` | 视觉定位输入分辨率 |
|
||||
| `LOOP_DETECT_REPEATED_TOOL_WARN` | `3` | 连续同工具同参多少次警告 |
|
||||
| `LOOP_DETECT_REPEATED_TOOL_END` | `5` | 连续同工具同参多少次强制终止 |
|
||||
| `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) |
|
||||
@@ -469,12 +468,13 @@ cloudflared tunnel --url http://localhost:8765
|
||||
- **执行**:压缩发生在图内 `compress` 节点(`process_tool_artifact` 之后),只压缩注入的历史工具上下文,系统提示词与对话历史保留,摘要按压缩游标累积
|
||||
- **子 Agent**:不做压缩,超限时整理任务进度直接返回主 Agent,避免上下文继续膨胀
|
||||
|
||||
### 3. 循环检测两级响应
|
||||
### 3. 循环检测(反思警告)
|
||||
|
||||
| 级别 | 触发条件 | 响应 |
|
||||
|------|----------|------|
|
||||
| ⚠️ 警告 | 连续 3 次同工具同参 | 注入反思提示引导模型自我纠偏 |
|
||||
| 🛑 终止 | 连续 5 次同工具同参 | `should_force_end()` 返回 END,向前端输出错误报告 |
|
||||
在 ReAct 循环的 agent 节点调用 LLM 前,检测连续相同工具调用(仅保留警告响应,强制终止已移除):
|
||||
|
||||
| 触发条件 | 响应 |
|
||||
|----------|------|
|
||||
| 连续同工具同参 ≥ `LOOP_DETECT_REPEATED_TOOL_WARN`(默认 3) | 注入反思提示引导模型自我纠偏:更换工具或策略、检查之前的工具返回结果、无法推进时向用户说明困难并请求帮助 |
|
||||
|
||||
### 4. 阻塞式人机交互(交互类 / 展示类工具的统一模式)
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
# 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 <MODEL_DIR>`:`--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...` 说明 + `<tools>...</tools>` 内逐条 `tool | tojson` 序列化工具签名;工具调用格式约束为 `<tool_call>\n{"name": ..., "arguments": ...}\n</tool_call>`。
|
||||
- 无工具时:首条 system 按 content 类型输出多模态占位符或文本。
|
||||
- **multi_step_tool 检测**(第 47-54 行):从后往前找第一个"非 `<tool_response>` 包裹"的 user 消息,记为 `last_query_index`,用于判断后续 assistant 是否需要显式输出 `<think>` 块。
|
||||
- assistant 消息:优先用 `message.reasoning_content`(字符串);否则若 content 含 `</think>`,用 `split('<think>')`/`split('</think>')` 拆出 reasoning 与正文。`loop.index0 > last_query_index` 且(末条或含 reasoning)时,输出 `<think>...</think>` 包裹的思考段。`tool_calls` 逐个渲染为 `<tool_call>...</tool_call>`。
|
||||
- tool(函数返回)消息:连续的 tool 消息被**合并进同一个 `user` 角色块**,每条包在 `<tool_response>\n...\n</tool_response>` 中,首条前加 `<|im_start|>user`、末条后加 `<|im_end|>`。
|
||||
- `add_generation_prompt` 时输出 `<|im_start|>assistant\n`;若显式传 `enable_thinking=false` 则先输出空 `<think>\n\n</think>\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 <file> [--url http://127.0.0.1:8901/v1/chat/completions] [--base64]`。核心函数 `caption_audio(audio_path, server_url, use_base64)`:
|
||||
- 音频转 URL:本地直传用 `file://` + 绝对路径(`/` 分隔);远程场景 `--base64` 用 `data:<mime>;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`(`<Instruct>: ...\n<Query>: ...\n<Document>: ...`,默认 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 中提取 `<image>N</image>` 占位与图片字节,表格转 `<table>`,支持"表1/图1"短标题缓存合并);`process_pdf`(pypdf,逐页文字 + `<image>N</image>` 占位 + 图片字节);`process_pptx`(python-pptx 提文字 + `ppt_utils.convert_pptx_to_svg` 提布局,SVG 内 base64 data URL 替换为 `#[内嵌图片 N]` 占位,输出 `<slide>N</slide>[文字][布局]` 结构);`process_tabular`(csv → `<table>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)`:按**最后一个** `</think>` 拆分(兼容 `"思考\n</think>\n\n回复"` 与 `<think>思考</think>回复`),返回 (thinking, reply);无标记 → ("", text)。
|
||||
- `extract_reasoning_text(msg)`:思考开启(xhigh/max/ultra)优先 `additional_kwargs.reasoning_content`,其次 content 内联 `<think>` 块;思考关闭(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` 的 `<image>` 元素(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` → 组装 `<svg viewBox>`。`_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。
|
||||
+67
-22
@@ -30,11 +30,31 @@ const SRC_DIR = isPackaged
|
||||
// 配置文件路径 (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');
|
||||
|
||||
const HOST = '127.0.0.1';
|
||||
const PORT = 8765;
|
||||
const BASE_URL = `http://${HOST}:${PORT}`;
|
||||
|
||||
// ==================== 主题检测(启动玻璃层随主题色变化) ====================
|
||||
// 与前端 themes.js 的 dark 标记保持一致
|
||||
const DARK_THEMES = new Set(['vscode-dark', 'cyber-neon', 'deep-space-indigo', 'pixel-gold']);
|
||||
|
||||
function getThemeMode() {
|
||||
try {
|
||||
if (fs.existsSync(THEME_CONFIG_PATH)) {
|
||||
const cfg = JSON.parse(fs.readFileSync(THEME_CONFIG_PATH, 'utf-8'));
|
||||
const id = cfg.theme || '';
|
||||
if (DARK_THEMES.has(id)) return 'dark';
|
||||
if (id) {
|
||||
// 未知主题按 id 推断(与 themes.js 的回退逻辑一致)
|
||||
return (!id.includes('light') && !id.includes('solarized')) ? 'dark' : 'light';
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return 'dark'; // 默认深色,与窗口默认底色一致
|
||||
}
|
||||
|
||||
// ==================== 全局状态 ====================
|
||||
let mainWindow = null;
|
||||
let pythonProcess = null;
|
||||
@@ -166,11 +186,13 @@ function findSystemPython() {
|
||||
}
|
||||
|
||||
// ==================== FastAPI 生命周期 ====================
|
||||
let _fastapiStderr = ''; // 累积 stderr 用于超时诊断
|
||||
let _fastapiStderr = ''; // 累积 stderr 用于超时诊断
|
||||
let _fastapiReadySettled = false; // 健康检查已结束(成功或超时),停止轮询
|
||||
|
||||
function startFastAPI() {
|
||||
return new Promise((resolve, reject) => {
|
||||
killPortProcess(PORT);
|
||||
_fastapiReadySettled = false;
|
||||
|
||||
const pythonExe = findSystemPython();
|
||||
|
||||
@@ -234,16 +256,22 @@ function startFastAPI() {
|
||||
}
|
||||
|
||||
function waitForReady(resolve, reject, retriesLeft) {
|
||||
if (_fastapiReadySettled) return;
|
||||
if (retriesLeft <= 0) {
|
||||
_fastapiReadySettled = true;
|
||||
reject(new Error(
|
||||
`FastAPI 启动超时\n\nPython: ${findSystemPython()}\n工作目录: ${SRC_DIR}\n\n最后 stderr 输出:\n${_fastapiStderr || '(无输出)'}`
|
||||
));
|
||||
return;
|
||||
}
|
||||
const req = http.get(`${BASE_URL}/api/health`, (res) => {
|
||||
res.resume(); // 及时排空响应体,避免 keep-alive 悬挂
|
||||
if (res.statusCode === 200) {
|
||||
console.log('[Electron] FastAPI Ready');
|
||||
resolve();
|
||||
if (!_fastapiReadySettled) {
|
||||
_fastapiReadySettled = true;
|
||||
console.log('[Electron] FastAPI Ready');
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
retry();
|
||||
}
|
||||
@@ -251,7 +279,10 @@ function waitForReady(resolve, reject, retriesLeft) {
|
||||
req.on('error', () => retry());
|
||||
req.setTimeout(2000, () => { req.destroy(); retry(); });
|
||||
function retry() {
|
||||
setTimeout(() => waitForReady(resolve, reject, retriesLeft - 1), 1000);
|
||||
// 已就绪/已超时后不再轮询,防止反复打印日志
|
||||
setTimeout(() => {
|
||||
if (!_fastapiReadySettled) waitForReady(resolve, reject, retriesLeft - 1);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,14 +304,16 @@ function stopFastAPI() {
|
||||
}
|
||||
|
||||
// ==================== 窗口管理 ====================
|
||||
async function createMainWindow() {
|
||||
async function createMainWindow(themeMode) {
|
||||
themeMode = themeMode || getThemeMode();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: 'minor Agent',
|
||||
backgroundColor: '#1a1a2e',
|
||||
backgroundColor: themeMode === 'dark' ? '#1a1a2e' : '#eef1f7',
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
@@ -294,7 +327,9 @@ async function createMainWindow() {
|
||||
await mainWindow.webContents.session.clearCache();
|
||||
await mainWindow.webContents.session.clearStorageData({ storages: ['caches', 'serviceworkers'] });
|
||||
|
||||
mainWindow.loadURL(BASE_URL);
|
||||
// 先显示本地过渡页:毛玻璃 + 中央图标(无任何按钮),主题随 theme_config.json 变化;
|
||||
// FastAPI 就绪后再切到应用页;应用页自带的同款玻璃覆盖层会无缝接手
|
||||
mainWindow.loadFile(path.join(__dirname, 'splash.html'), { query: { theme: themeMode } });
|
||||
|
||||
mainWindow.once('ready-to-show', () => mainWindow.show());
|
||||
|
||||
@@ -304,6 +339,26 @@ async function createMainWindow() {
|
||||
require('electron').shell.openExternal(url).catch(() => {});
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
// 启动主流程:立即弹出毛玻璃窗口 → 后台启动 FastAPI → 就绪后加载应用页
|
||||
async function launchMainFlow() {
|
||||
const themeMode = getThemeMode();
|
||||
try {
|
||||
// 1. 创建并显示主窗口(本地过渡页,秒开,无按钮),主题随 theme_config.json
|
||||
await createMainWindow(themeMode);
|
||||
// 2. 后台启动 FastAPI(窗口已先显示,用户不会盯着空白等待)
|
||||
await startFastAPI();
|
||||
if (!mainWindow) return; // 启动期间用户已关闭窗口(window-all-closed 会走退出流程)
|
||||
// 3. 后端就绪后加载应用页;带上主题参数,保证应用页首帧即为当前主题(避免深色闪屏),
|
||||
// 页面加载完成后由前端播放玻璃层淡出动画
|
||||
mainWindow.loadURL(`${BASE_URL}?theme=${themeMode}`);
|
||||
} catch (err) {
|
||||
dialog.showErrorBox('启动失败', `无法启动 minor Agent:\n${err.message}`);
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 应用生命周期 ====================
|
||||
@@ -342,14 +397,8 @@ app.whenReady().then(async () => {
|
||||
// 首次运行:打开配置窗口(Python 环境安装 + LLM 配置)
|
||||
createSetupWindow();
|
||||
} else {
|
||||
// 非首次:直接启动 FastAPI 和主窗口
|
||||
try {
|
||||
await startFastAPI();
|
||||
await createMainWindow();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox('启动失败', `无法启动 minor Agent:\n${err.message}`);
|
||||
app.quit();
|
||||
}
|
||||
// 非首次:立即显示毛玻璃启动窗口,同时后台启动 FastAPI 和主流程
|
||||
await launchMainFlow();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -376,13 +425,9 @@ ipcMain.on('launch-app', async () => {
|
||||
setupWindow = null;
|
||||
}
|
||||
|
||||
// 启动 FastAPI 并创建主窗口
|
||||
// 立即显示毛玻璃启动窗口,后台启动 FastAPI 并加载应用页
|
||||
try {
|
||||
await startFastAPI();
|
||||
await createMainWindow();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox('启动失败', `无法启动 minor Agent:\n${err.message}`);
|
||||
app.quit();
|
||||
await launchMainFlow();
|
||||
} finally {
|
||||
isLaunching = false;
|
||||
}
|
||||
@@ -888,7 +933,7 @@ app.on('before-quit', () => {
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) {
|
||||
createMainWindow();
|
||||
launchMainFlow();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>minor Agent</title>
|
||||
<!-- 与 index.html 内 #appSplash 覆盖层共用同一套玻璃层样式,切换时视觉无缝 -->
|
||||
<link rel="stylesheet" href="../src/web/css/splash.css" />
|
||||
<script>
|
||||
// 主题随动:main.js 读取 theme_config.json 后通过 ?theme=dark|light 传入,
|
||||
// 这里在 <html> 上挂 theme-dark / theme-light 类(与 index.html 的 body 类名一致)
|
||||
(function () {
|
||||
var m = new URLSearchParams(location.search).get("theme");
|
||||
if (m === "light") document.documentElement.classList.add("theme-light");
|
||||
else document.documentElement.classList.add("theme-dark");
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="splash-page">
|
||||
<!-- 毛玻璃层 + 居中图标(无边框、无任何按钮);后端就绪切到应用页后,由应用页的同款覆盖层接手 -->
|
||||
<div class="app-splash">
|
||||
<svg class="app-splash-icon" version="1.0" xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 800 800" preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,800.000000) scale(0.100000,-0.100000)" fill="#0f172a" stroke="none">
|
||||
<path d="M2465 5660 c-595 -6 -552 7 -574 -176 -14 -111 -14 -269 -1 -294 5 -10 38 -28 73 -39 81 -27 136 -63 167 -109 l25 -36 -3 -1008 c-2 -1095 0 -1047 -57 -1101 -29 -27 -71 -45 -167 -71 -86 -24 -91 -38 -86 -245 4 -180 11 -208 61 -245 28 -21 40 -21 510 -24 595 -4 657 3 702 71 17 26 55 270 55 353 0 40 -5 59 -20 74 -24 24 -40 27 -151 34 -106 6 -157 27 -182 76 -17 32 -18 55 -13 275 4 132 11 344 16 470 6 127 10 436 9 688 -1 252 1 460 4 463 2 3 11 -23 19 -58 28 -117 169 -645 233 -873 28 -97 69 -243 169 -605 124 -445 247 -872 262 -910 19 -48 41 -59 137 -71 90 -12 332 -4 384 12 67 21 69 24 184 444 105 383 226 810 352 1245 30 102 78 266 106 365 29 99 65 225 81 280 16 55 39 138 52 185 20 75 23 82 29 55 10 -46 10 -515 -1 -755 -5 -118 -14 -381 -20 -585 -12 -405 -23 -573 -40 -622 -14 -40 -59 -69 -131 -84 -79 -17 -104 -33 -129 -85 -21 -42 -22 -56 -17 -154 11 -185 30 -240 95 -267 49 -20 388 -32 945 -33 463 0 466 0 492 63 7 18 19 102 25 188 17 220 3 247 -135 269 -166 26 -196 73 -216 340 -17 221 -5 1727 14 1798 24 90 71 123 209 147 46 8 84 15 86 15 2 0 10 17 19 37 21 52 30 294 13 391 -17 103 -25 107 -228 116 -152 6 -1174 -9 -1299 -20 -33 -3 -69 -11 -78 -18 -10 -7 -45 -108 -91 -261 -93 -314 -88 -297 -179 -625 -138 -493 -163 -583 -211 -752 -26 -93 -51 -168 -55 -168 -5 0 -9 7 -9 15 0 14 -85 313 -155 545 -146 484 -296 985 -335 1123 -30 108 -54 141 -109 157 -20 5 -108 9 -196 8 -88 -1 -376 -5 -640 -8z m-135 -123 c-69 -18 -135 -38 -147 -45 -24 -13 -38 -67 -49 -194 -4 -47 -11 -79 -19 -82 -19 -7 -74 31 -85 58 -17 45 -23 171 -11 209 24 72 69 86 276 86 l160 0 -125 -32z m2356 7 c-14 -15 -26 -32 -26 -39 0 -6 15 -29 33 -51 32 -37 57 -76 57 -89 0 -3 -10 -1 -21 6 -37 19 -138 7 -199 -25 -30 -16 -60 -36 -67 -45 -11 -15 -12 -15 -13 0 0 23 56 158 78 189 23 32 116 80 155 80 l30 0 -27 -26z m1165 -5 c20 -5 43 -17 51 -26 25 -30 48 -101 48 -149 0 -40 -2 -45 -15 -34 -8 7 -15 18 -15 25 0 7 -18 32 -39 56 -31 36 -53 49 -107 67 -83 27 -190 48 -304 59 l-85 8 85 6 c108 7 334 0 381 -12z m-2587 -22 c14 -10 146 -380 146 -411 0 -19 -113 135 -175 238 -67 113 -79 157 -47 175 24 14 55 13 76 -2z m2590 -212 l6 -51 -60 1 -59 0 38 35 c21 19 41 46 46 59 10 32 21 15 29 -44z m-3594 -245 c0 -22 -4 -28 -15 -24 -15 6 -20 35 -8 47 14 15 23 6 23 -23z m3311 -103 c-16 -16 -26 40 -10 60 13 15 14 13 17 -17 2 -18 -1 -37 -7 -43z m-24 -4 c-3 -10 -13 -53 -22 -95 -9 -43 -19 -78 -22 -78 -8 0 5 134 17 164 11 29 34 36 27 9z m-1960 -2228 c40 -77 68 -103 134 -120 74 -20 123 -19 169 5 42 21 108 98 141 162 11 21 22 38 25 38 11 0 -6 -101 -28 -157 -21 -58 -96 -176 -130 -205 -16 -15 -39 -18 -122 -18 -55 0 -106 4 -111 8 -13 9 -57 77 -82 127 -44 87 -100 292 -117 430 -5 42 2 28 42 -80 27 -71 63 -157 79 -190z m2173 73 c0 -13 -23 -5 -28 10 -2 7 2 10 12 6 9 -3 16 -11 16 -16z m-3741 -130 c-1 -19 11 -56 27 -88 l29 -55 60 -5 c46 -4 54 -7 35 -12 -13 -4 -36 -5 -50 -1 l-25 5 20 -20 c11 -12 47 -33 80 -46 l60 -25 -70 -1 c-79 0 -194 16 -211 31 -8 6 -8 20 -1 47 7 27 6 50 -5 86 -13 43 -13 53 1 82 21 46 52 47 50 2z m2677 14 c6 -4 19 -32 27 -62 21 -71 59 -108 167 -160 l85 -41 -65 6 c-227 22 -244 24 -269 43 -29 21 -59 110 -60 180 l-1 42 53 0 c28 0 57 -4 63 -8z m1204 -254 c-1 -34 -3 -39 -11 -26 -8 15 -3 68 7 68 2 0 4 -19 4 -42z m-86 2 c-16 -26 -44 -48 -44 -34 0 5 52 63 58 64 2 0 -5 -13 -14 -30z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"current": "",
|
||||
"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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}}}
|
||||
{"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}}}}
|
||||
@@ -1018,6 +1018,18 @@ if (window.electronAPI?.minimizeWindow) {
|
||||
initTheme();
|
||||
initI18n();
|
||||
|
||||
// ===== 启动玻璃层过渡(毛玻璃 + 中央图标) =====
|
||||
// 加载完成后:图标向下滑出、毛玻璃渐变消失,露出完整应用界面
|
||||
function hideAppSplash() {
|
||||
const splash = document.getElementById("appSplash");
|
||||
if (!splash || splash.classList.contains("app-splash--out")) return;
|
||||
splash.classList.add("app-splash--out");
|
||||
// 动画结束后移除覆盖层(放行对底层界面的交互)
|
||||
setTimeout(() => {
|
||||
if (splash.parentNode) splash.parentNode.removeChild(splash);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// ===== 启动 =====
|
||||
bootstrap().then(() => {
|
||||
initTokenRing();
|
||||
@@ -1028,10 +1040,15 @@ bootstrap().then(() => {
|
||||
initThinkLevel();
|
||||
loadNickname();
|
||||
initServicesStatus();
|
||||
hideAppSplash();
|
||||
}).catch((e) => {
|
||||
const chatPlaceholder = $("chatPlaceholder");
|
||||
if (chatPlaceholder) {
|
||||
chatPlaceholder.hidden = false;
|
||||
chatPlaceholder.innerHTML = `<h3>无法连接服务</h3><p>${escapeHtml(e.message)}</p><p class="muted">请从 Agent 目录执行:<code>uvicorn web.server:app --reload --host 127.0.0.1 --port 8765</code>,并保证 PYTHONPATH 包含 <code>src</code>。</p>`;
|
||||
}
|
||||
hideAppSplash();
|
||||
});
|
||||
|
||||
// 兜底:若前端初始化异常导致 bootstrap 永不完成,超时后强制淡出玻璃层
|
||||
setTimeout(hideAppSplash, 12000);
|
||||
@@ -14,6 +14,7 @@
|
||||
border-radius: var(--radius, 12px);
|
||||
box-shadow: 0 8px 32px var(--overlay-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 2px solid var(--accent);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text);
|
||||
@@ -88,6 +89,7 @@
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-hover);
|
||||
box-shadow: 0 1px 4px var(--shadow);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
@@ -96,6 +98,11 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pending-overlay-card--resolved .pending-overlay-btn,
|
||||
.pending-overlay-card--resolved .pending-overlay-send-btn {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pending-overlay-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -149,9 +156,9 @@
|
||||
/* 工作空间越界审批提示(访问策略 policy_note) */
|
||||
.pending-overlay-note {
|
||||
font-size: 0.78rem;
|
||||
color: #b45309;
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
|
||||
color: var(--warning, #b45309);
|
||||
background: var(--warning-bg, color-mix(in srgb, #f59e0b 12%, transparent));
|
||||
border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 45%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -160,7 +167,7 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ===== tool_call 卡片:参数只读展示 ===== */
|
||||
/* ===== tool_call 卡片:参数摘要只读展示 ===== */
|
||||
.pending-overlay-tool-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
@@ -169,18 +176,19 @@
|
||||
}
|
||||
|
||||
.pending-overlay-tool-args {
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
margin-bottom: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 160px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-family, inherit);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ===== 按钮行(同意 / 拒绝 / 跳过)===== */
|
||||
@@ -206,6 +214,16 @@
|
||||
.pending-overlay-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.pending-overlay-btn:focus-visible,
|
||||
.pending-overlay-send-btn:focus-visible,
|
||||
.pending-overlay-submit-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.pending-overlay-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pending-overlay-btn--approve {
|
||||
background: var(--accent);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/* splash.css — 启动玻璃层(毛玻璃 + 中央图标)
|
||||
*
|
||||
* 被两个页面共用:
|
||||
* - electron/splash.html(本地过渡页,FastAPI 就绪前显示,file:// 加载)
|
||||
* - index.html 的 #appSplash 覆盖层(应用页加载完成后淡出,露出完整界面)
|
||||
*
|
||||
* 主题随动:
|
||||
* - splash.html 由 main.js 读取 theme_config.json 后,通过 query 注入
|
||||
* theme=dark/light,页面在 <html> 上挂 theme-dark / theme-light 类
|
||||
* - index.html 由 themes.js 在 <body> 上挂 theme-dark / theme-light 类
|
||||
* 两类名都通过 --splash-* 自定义属性切换毛玻璃底色与图标颜色。
|
||||
*
|
||||
* 退出动画(通过 .app-splash--out 触发):
|
||||
* - 图标向下滑出消失
|
||||
* - 毛玻璃层透明度、模糊度渐变消失,底层内容由模糊变清晰
|
||||
*/
|
||||
|
||||
/* 默认按深色主题(与应用默认 vscode-dark、窗口默认底色一致) */
|
||||
:root {
|
||||
--splash-tint: rgba(8, 10, 20, 0.62);
|
||||
--splash-icon-filter: invert(1) drop-shadow(0 6px 16px rgba(0, 0, 0, 0.5));
|
||||
--splash-page-bg: radial-gradient(1200px 800px at 30% 20%, #2b2f57 0%, #1a1a2e 45%, #10101f 100%);
|
||||
}
|
||||
.theme-light {
|
||||
--splash-tint: rgba(236, 240, 248, 0.55);
|
||||
--splash-icon-filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.25));
|
||||
--splash-page-bg: radial-gradient(1200px 800px at 30% 20%, #dfe5f2 0%, #eef1f7 45%, #ffffff 100%);
|
||||
}
|
||||
|
||||
/* 本地过渡页(electron/splash.html)的底色,供玻璃层模糊 */
|
||||
.splash-page {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
min-height: 100dvh;
|
||||
background: var(--splash-page-bg);
|
||||
}
|
||||
|
||||
/* 全屏毛玻璃层 */
|
||||
.app-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000; /* 盖住自绘标题栏(z-index: 9999)与全部界面,加载期间无任何按钮 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--splash-tint);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(130%);
|
||||
backdrop-filter: blur(24px) saturate(130%);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: drag; /* 无边框窗口:玻璃层可拖动 */
|
||||
transition:
|
||||
opacity 0.65s ease,
|
||||
-webkit-backdrop-filter 0.65s ease,
|
||||
backdrop-filter 0.65s ease;
|
||||
will-change: opacity, backdrop-filter;
|
||||
}
|
||||
|
||||
/* 斜向光带扫过(“刷新”质感) */
|
||||
.app-splash::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -60%;
|
||||
left: -30%;
|
||||
width: 60%;
|
||||
height: 220%;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 42%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 58%
|
||||
);
|
||||
transform: rotate(12deg) translateX(-160%);
|
||||
animation: splash-sweep 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes splash-sweep {
|
||||
0% {
|
||||
transform: rotate(12deg) translateX(-160%);
|
||||
}
|
||||
55%,
|
||||
100% {
|
||||
transform: rotate(12deg) translateX(320%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 中央图标(无边框/无卡片,直接浮在毛玻璃上;颜色随主题反色) */
|
||||
.app-splash-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
filter: var(--splash-icon-filter);
|
||||
user-select: none;
|
||||
animation: splash-breathe 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes splash-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* 退出动画:玻璃层淡出 + 图标向下滑出 */
|
||||
.app-splash--out {
|
||||
opacity: 0;
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
backdrop-filter: blur(0px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-splash--out .app-splash-icon {
|
||||
animation: splash-icon-out 0.7s cubic-bezier(0.55, 0, 0.85, 0.36) forwards;
|
||||
}
|
||||
@keyframes splash-icon-out {
|
||||
to {
|
||||
transform: translateY(170px) scale(0.96);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 跟随系统“减少动态效果”偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-splash,
|
||||
.app-splash::before,
|
||||
.app-splash-icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,29 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<script>
|
||||
// 首帧主题同步:main.js 启动时通过 ?theme=dark|light 传入当前主题,
|
||||
// 这里在页面渲染前挂好类与底色变量,避免从本地过渡页切到应用页时出现深色闪屏
|
||||
(function () {
|
||||
var m = new URLSearchParams(location.search).get("theme");
|
||||
if (m === "light" || m === "dark") {
|
||||
var el = document.documentElement;
|
||||
el.classList.add(m === "light" ? "theme-light" : "theme-dark");
|
||||
el.style.setProperty("--edit-bg", m === "light" ? "#ffffff" : "#1a1a2e");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- 本地字体(离线可用,不再依赖 Google Fonts CDN) -->
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="icon" href="/image/favicon.svg" sizes="any" />
|
||||
<link rel="stylesheet" href="/app.css?v=6" />
|
||||
<link rel="stylesheet" href="/css/splash.css?v=1" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- 启动玻璃层:毛玻璃 + 中央图标(无边框、无任何按钮),加载完成后由 app.js 播放淡出动画 -->
|
||||
<div class="app-splash" id="appSplash" aria-hidden="true">
|
||||
<img class="app-splash-icon" src="/image/icon.svg" alt="" draggable="false" />
|
||||
</div>
|
||||
<div class="bg-scene" id="bgScene" aria-hidden="true">
|
||||
<div class="bg-image" id="bgImageLayer"></div>
|
||||
<div class="bg-vignette" aria-hidden="true"></div>
|
||||
|
||||
@@ -710,6 +710,8 @@ function renderUserFolderRefCard(folderPath) {
|
||||
function renderUserFileRefCard(filePath) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "user-file-card";
|
||||
card.draggable = true;
|
||||
card.title = filePath;
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.className = "user-file-card-icon";
|
||||
@@ -734,9 +736,77 @@ function renderUserFileRefCard(filePath) {
|
||||
info.appendChild(extEl);
|
||||
card.appendChild(icon);
|
||||
card.appendChild(info);
|
||||
|
||||
// 点击卡片 → 浮窗预览内容
|
||||
card.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
previewRefFile(filePath);
|
||||
});
|
||||
|
||||
// 拖拽:支持从聊天区拖拽整张文件引用卡片到输入区(与文件夹引用一致)
|
||||
card.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.setData("application/doc-path", filePath);
|
||||
e.dataTransfer.setData("text/plain", safeName);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/* ===== 浮窗预览本地路径文件(@file: 引用卡片点击) ===== */
|
||||
async function previewRefFile(filePath) {
|
||||
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
||||
|
||||
// 有工作区时复用 edit 模式的文档预览(图片/HTML/表格/二进制/文本统一处理)
|
||||
if (state._docRootPath) {
|
||||
try {
|
||||
const { loadDocContent } = await import('./edit-mode.js');
|
||||
await loadDocContent(filePath);
|
||||
return;
|
||||
} catch { /* 回退到直接读取 */ }
|
||||
}
|
||||
|
||||
const { readTextFile, readBinaryFile } = await import('./electron-api.js');
|
||||
const { openFilePreview } = await import('./file-preview.js');
|
||||
|
||||
// 图片
|
||||
if (/\.(png|jpe?g|gif|webp|bmp|ico)$/i.test(fileName)) {
|
||||
try {
|
||||
const res = await readBinaryFile(filePath, state._docRootPath);
|
||||
if (res && res.status === 'ok' && res.data) {
|
||||
showImagePreview(`data:${res.mime || 'image/png'};base64,${res.data}`);
|
||||
return;
|
||||
}
|
||||
} catch { /* 继续 */ }
|
||||
openFilePreview(filePath, fileName, '(无法读取文件)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 表格(xlsx/xls):后端提取为 TSV 后按列着色预览
|
||||
if (/\.(xlsx|xls)$/i.test(fileName)) {
|
||||
try {
|
||||
const { api } = await import('./api.js');
|
||||
const tsv = await api('/api/table/text?path=' + encodeURIComponent(filePath));
|
||||
openFilePreview(filePath, fileName, (tsv && tsv.content) || '');
|
||||
} catch (err) {
|
||||
openFilePreview(filePath, fileName, '(加载失败: ' + (err.message || err) + ')');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 文本/代码
|
||||
try {
|
||||
const res = await readTextFile(filePath, state._docRootPath);
|
||||
if (res && res.status === 'ok') {
|
||||
openFilePreview(filePath, fileName, res.content || '');
|
||||
} else {
|
||||
openFilePreview(filePath, fileName, '(无法读取文件)');
|
||||
}
|
||||
} catch (err) {
|
||||
openFilePreview(filePath, fileName, '(加载失败: ' + (err.message || err) + ')');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 从工具文件 URL 中提取文件名 ===== */
|
||||
function extractToolFileName(url) {
|
||||
if (!url) return "文件";
|
||||
|
||||
+4
-31
@@ -92,11 +92,8 @@ export function switchToMode(mode) {
|
||||
_modeSwitchCooldown = true;
|
||||
setTimeout(() => { _modeSwitchCooldown = false; }, 300);
|
||||
|
||||
// Agent 运行中禁止切换到 cron/edit 模式,避免 chat 区域被替换导致工具调用和思考历史消失
|
||||
if (state.sending && mode !== "chat") {
|
||||
showToast("Agent 正在运行中,请等待完成后再切换模式");
|
||||
return;
|
||||
}
|
||||
// 注:edit/chat/cron 模式均只影响左侧栏(文件树/会话列表/定时任务),
|
||||
// 右侧聊天区保持不变,因此 Agent 运行中也可以自由切换,无需等待
|
||||
state.mode = mode;
|
||||
if (mode === "edit") {
|
||||
state.editMode = true;
|
||||
@@ -1099,21 +1096,6 @@ export function clearRefChips() {
|
||||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||||
}
|
||||
|
||||
/** 将 pendingRefs 转为发送给 AI 的文本引用 */
|
||||
function _refsToText() {
|
||||
if (!state.pendingRefs.length) return '';
|
||||
const root = state._docRootPath ? state._docRootPath.replace(/\\/g, '/').replace(/\/$/, '') : '';
|
||||
return state.pendingRefs.map((ref) => {
|
||||
// 外部引用路径已是绝对路径(统一为正斜杠),工作区引用需拼接 root
|
||||
const absPath = ref.isExternal
|
||||
? String(ref.path || '').replace(/\\/g, '/')
|
||||
: (root + '/' + ref.path);
|
||||
const prefix = ref.isFolder ? '@folder:' : '@file:';
|
||||
const linePart = ref.startLine ? ` L${ref.startLine}-L${ref.endLine}` : '';
|
||||
return `${prefix}${absPath}${linePart}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
// ===== 清除状态 =====
|
||||
export function clearEditState() {
|
||||
// 清理该工作空间对应的快照子目录
|
||||
@@ -1142,24 +1124,15 @@ export async function sendEditChat() {
|
||||
if (!state.sessionId || state.sending) return;
|
||||
const textInput = $('textInput');
|
||||
const trimmed = textInput ? textInput.value.trim() : "";
|
||||
const refsText = _refsToText();
|
||||
const hasInput = trimmed || state.pendingFiles.length > 0 || state.pendingRefs.length > 0;
|
||||
if (!hasInput) return;
|
||||
|
||||
// 保存原始文本,以便失败时恢复
|
||||
const originalVal = textInput ? textInput.value : '';
|
||||
|
||||
// 将文档引用注入 textInput
|
||||
if (refsText) {
|
||||
textInput.value = originalVal ? originalVal + '\n\n' + refsText : refsText;
|
||||
}
|
||||
|
||||
// 文档/文件夹引用(@file:/@folder:)由 sendChat 统一拼接为文本发送,
|
||||
// 此处无需再注入 textInput,避免 chat/edit 双份引用
|
||||
try {
|
||||
const { sendChat } = await import('./send.js');
|
||||
await sendChat();
|
||||
} catch (err) {
|
||||
// 发送失败:恢复原始文本,避免下次发送时重复注入 refs
|
||||
if (textInput) textInput.value = originalVal;
|
||||
throw err;
|
||||
} finally {
|
||||
// 无论成功失败都清除引用 chip,防止重复注入
|
||||
|
||||
@@ -95,6 +95,53 @@ function buildPendingOverlay(pendingActions) {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 tool_call 的 args 规整为紧凑参数摘要。
|
||||
* 后端包装为 {name, args:{...}},解包后:
|
||||
* - 权限审查(oneLine=true)时只展示一条关键参数(如 file_path / command);
|
||||
* - 普通确认时逐行展示 key: value(长值截断),避免整段 JSON 重复显示。
|
||||
* @param {*} args - pending.args
|
||||
* @param {boolean} oneLine - 是否只取关键参数一条(权限审查)
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildArgsSummary(args, oneLine) {
|
||||
let obj = args;
|
||||
if (typeof obj === "string") {
|
||||
try { obj = JSON.parse(obj); } catch { /* 非 JSON 原样返回 */ }
|
||||
}
|
||||
if (obj === null || obj === undefined) return "";
|
||||
if (typeof obj !== "object") return String(obj);
|
||||
|
||||
// 解包 {name, args} 包装(tool_call_utils 构造)
|
||||
let inner = obj;
|
||||
if (typeof obj.name === "string" && obj.args && typeof obj.args === "object" && !Array.isArray(obj.args)) {
|
||||
inner = obj.args;
|
||||
}
|
||||
|
||||
const fmt = (v) => {
|
||||
if (v === null || v === undefined) return "";
|
||||
const s = typeof v === "string" ? v : JSON.stringify(v);
|
||||
return s.length > 160 ? s.slice(0, 157) + "..." : s;
|
||||
};
|
||||
|
||||
const entries = Object.entries(inner || {}).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
||||
if (entries.length === 0) return "";
|
||||
|
||||
// 关键参数优先(权限审查时只显示这一条)
|
||||
const PRIORITY = ["file_path", "path", "command", "cmd", "url", "dir", "folder", "query", "name", "action"];
|
||||
const keyOf = (k) => {
|
||||
const i = PRIORITY.indexOf(k);
|
||||
return i === -1 ? 99 : i;
|
||||
};
|
||||
entries.sort((a, b) => keyOf(a[0]) - keyOf(b[0]));
|
||||
|
||||
if (oneLine) {
|
||||
const [k, v] = entries[0];
|
||||
return `${k}: ${fmt(v)}`;
|
||||
}
|
||||
return entries.map(([k, v]) => `${k}: ${fmt(v)}`).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建决策按钮
|
||||
* @param {string} label
|
||||
@@ -143,7 +190,7 @@ function buildPendingCard(pending, isSubAgent) {
|
||||
cardBody.appendChild(promptEl);
|
||||
}
|
||||
|
||||
// ===== tool_call 类型:展示参数(只读)+ 决策按钮 =====
|
||||
// ===== tool_call 类型:展示参数摘要(只读)+ 决策按钮 =====
|
||||
if (pendingType === "tool_call") {
|
||||
// 工作空间越界审批提示(访问策略注入的 policy_note)
|
||||
if (pending.policy_note) {
|
||||
@@ -152,24 +199,19 @@ function buildPendingCard(pending, isSubAgent) {
|
||||
noteEl.textContent = "⚠️ 越界操作待审批:" + unescapeNewlines(pending.policy_note);
|
||||
cardBody.appendChild(noteEl);
|
||||
}
|
||||
// 参数 JSON(只读)
|
||||
if (pending.args !== undefined && pending.args !== null) {
|
||||
let argsStr;
|
||||
try {
|
||||
argsStr = typeof pending.args === "string" ? pending.args : JSON.stringify(pending.args, null, 2);
|
||||
} catch (_) {
|
||||
argsStr = String(pending.args);
|
||||
}
|
||||
// 参数摘要:权限审查只展示一条关键参数;普通确认展示紧凑 key: value,
|
||||
// 不再整段 JSON 重复(标题已含工具名)
|
||||
const argsSummary = buildArgsSummary(pending.args, !!pending.policy_note);
|
||||
if (argsSummary) {
|
||||
const argsEl = document.createElement("div");
|
||||
argsEl.className = "pending-overlay-tool-args";
|
||||
argsEl.textContent = argsStr;
|
||||
argsEl.textContent = argsSummary;
|
||||
cardBody.appendChild(argsEl);
|
||||
}
|
||||
// 指令输入(可选,用于 approve 时附带说明)
|
||||
// 指令输入(可选,用于 approve 时附带说明;不预填 JSON)
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "pending-overlay-textarea";
|
||||
textarea.placeholder = "可选:补充指令...";
|
||||
textarea.value = pending.instruction || "";
|
||||
textarea.rows = 2;
|
||||
textarea.setAttribute("data-field", "instruction");
|
||||
cardBody.appendChild(textarea);
|
||||
@@ -212,7 +254,12 @@ function buildPendingCard(pending, isSubAgent) {
|
||||
sendBtn.textContent = "确认";
|
||||
sendBtn.addEventListener("click", () => {
|
||||
const val = textarea.value.trim();
|
||||
if (!val) return;
|
||||
if (!val) {
|
||||
// 空输入时给出明确反馈,避免误以为按钮无响应
|
||||
showToast("请先输入内容再确认");
|
||||
textarea.focus();
|
||||
return;
|
||||
}
|
||||
submitSingle(pending.id, "approve", val, card);
|
||||
});
|
||||
inputRow.appendChild(sendBtn);
|
||||
|
||||
+19
-16
@@ -1,8 +1,9 @@
|
||||
// pending.js -- 人工请求决策提交(卡片渲染在 pending-overlay.js,数据源为 live snapshot)
|
||||
|
||||
import { $ } from './utils.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
// 防重入:同一 approval_id 同时只允许一个提交请求(连点保护)
|
||||
const _inFlight = new Set();
|
||||
|
||||
/**
|
||||
* 提交一个等待中的人工请求的决策。
|
||||
@@ -12,22 +13,24 @@ import { state } from './state.js';
|
||||
* 浮窗由下一次 live snapshot 自动移除,最终结果由
|
||||
* /api/chat/complete 或 /api/chat/stream 返回——无需重渲染消息、
|
||||
* 无需重启 live 流、更不会清空已积累的工具调用列表。
|
||||
*
|
||||
* 注意:Agent 运行期间 state.sending 恒为 true(本轮仍在后台执行),
|
||||
* 因此这里绝不能以 state.sending 作为拦截条件,否则浮窗按钮全部失效。
|
||||
*/
|
||||
async function handleHumanAction(requestId, decision, instruction, card) {
|
||||
if (!requestId || state.sending) return;
|
||||
state.sending = true; // 本轮仍在后台运行,保持发送按钮禁用
|
||||
const sendBtn = $("sendBtn");
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
if (card) card.classList.add("pending-action-card--resolved");
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
fd.append("approval_id", requestId);
|
||||
fd.append("decision", decision);
|
||||
fd.append("instruction", instruction || "");
|
||||
|
||||
await api("/api/human-action", { method: "POST", body: fd });
|
||||
// 注意:不恢复 state.sending——本轮任务尚未结束,按钮状态由 sendChat 完成时统一恢复
|
||||
if (!requestId || _inFlight.has(requestId)) return;
|
||||
_inFlight.add(requestId);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
fd.append("approval_id", requestId);
|
||||
fd.append("decision", decision);
|
||||
fd.append("instruction", instruction || "");
|
||||
await api("/api/human-action", { method: "POST", body: fd });
|
||||
} finally {
|
||||
_inFlight.delete(requestId);
|
||||
}
|
||||
// 注意:不修改 state.sending——本轮任务尚未结束,按钮状态由 sendChat 完成时统一恢复
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
+39
-1
@@ -151,20 +151,45 @@ function buildQuotesText() {
|
||||
return quotes.map((q) => "【用户引用】\n" + q.text.trim()).join("\n\n");
|
||||
}
|
||||
|
||||
/** 将待发送的文件/文件夹引用(拖拽产生的 @file:/@folder:)拼接为注入给 agent 的绝对路径文本 */
|
||||
function buildRefsText() {
|
||||
const refs = (state.pendingRefs || []).filter((r) => r && r.type !== "quote" && (r.path || r.isFolder));
|
||||
if (refs.length === 0) return "";
|
||||
const root = state._docRootPath ? state._docRootPath.replace(/\\/g, "/").replace(/\/$/, "") : "";
|
||||
return refs.map((ref) => {
|
||||
// 外部引用路径已是绝对路径(统一为正斜杠),工作区引用需拼接 root
|
||||
const absPath = ref.isExternal
|
||||
? String(ref.path || "").replace(/\\/g, "/")
|
||||
: (root + "/" + ref.path);
|
||||
const prefix = ref.isFolder ? "@folder:" : "@file:";
|
||||
const linePart = ref.startLine ? ` L${ref.startLine}-L${ref.endLine}` : "";
|
||||
return `${prefix}${absPath}${linePart}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
/** 清除已发送的引用(pendingRefs 与 pendingFiles 中的 ref/quote 条目) */
|
||||
function clearQuoteRefs() {
|
||||
state.pendingRefs = (state.pendingRefs || []).filter((r) => r && r.type !== "quote");
|
||||
state.pendingFiles = state.pendingFiles.filter((f) => !(f && f.__isRef && f.type === "ref/quote"));
|
||||
}
|
||||
|
||||
/** 清除已发送的文件/文件夹引用(仅清理 @file:/@folder:,保留用户引用) */
|
||||
function clearFileRefs() {
|
||||
state.pendingRefs = (state.pendingRefs || []).filter((r) => r && r.type === "quote");
|
||||
state.pendingFiles = state.pendingFiles.filter((f) => !(f && f.__isRef && f.type !== "ref/quote"));
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
if (!state.sessionId || state.sending) return;
|
||||
const myGen = ++_sendGeneration; // 标记本次 sendChat,防止旧实例的 finally/catch 污染状态
|
||||
const trimmed = (textInput || {}).value ? textInput.value.trim() : "";
|
||||
// 拖拽的文件/文件夹引用:以 @file:/@folder: 绝对路径形式并入发送文本(agent 收到路径本身)
|
||||
const refsText = buildRefsText();
|
||||
// 聊天区引用:以「用户引用」形式并入发送文本(agent 收到的内容为具体文字并注明来源)
|
||||
const quotesText = buildQuotesText();
|
||||
const effectiveText = quotesText ? (trimmed ? trimmed + "\n\n" + quotesText : quotesText) : trimmed;
|
||||
const effectiveText = [trimmed, refsText, quotesText].filter(Boolean).join("\n\n");
|
||||
const savedQuotes = (state.pendingRefs || []).filter((r) => r && r.type === "quote");
|
||||
const savedFileRefs = (state.pendingRefs || []).filter((r) => r && r.type !== "quote");
|
||||
if (!effectiveText && state.pendingFiles.length === 0) return;
|
||||
// 首次发送时,输入框从居中移至底部
|
||||
if (onSendStartFn) onSendStartFn();
|
||||
@@ -238,6 +263,7 @@ async function sendChat() {
|
||||
if (textInput) textInput.value = "";
|
||||
state.pendingFiles.length = 0;
|
||||
clearQuoteRefs();
|
||||
clearFileRefs();
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
} else if (effectiveText) {
|
||||
@@ -247,6 +273,7 @@ async function sendChat() {
|
||||
}
|
||||
if (textInput) textInput.value = "";
|
||||
clearQuoteRefs();
|
||||
clearFileRefs();
|
||||
}
|
||||
|
||||
state.abortController = new AbortController();
|
||||
@@ -269,6 +296,7 @@ async function sendChat() {
|
||||
if (textInput) textInput.value = "";
|
||||
state.pendingFiles.length = 0;
|
||||
clearQuoteRefs();
|
||||
clearFileRefs();
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
}
|
||||
@@ -466,6 +494,16 @@ async function sendChat() {
|
||||
state.pendingFiles.push(f);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (savedFileRefs.length > 0) {
|
||||
// 补回文件/文件夹引用(@file:/@folder:),避免失败后引用丢失
|
||||
const haveFileRefs = new Set(state.pendingFiles.filter((f) => f && f.__isRef && f.type !== "ref/quote").map((f) => f.refPath));
|
||||
savedFileRefs.forEach((f) => {
|
||||
if (!haveFileRefs.has(f.refPath)) state.pendingFiles.push(f);
|
||||
});
|
||||
state.pendingRefs.push(...savedFileRefs);
|
||||
}
|
||||
if (savedQuotes.length > 0 || savedFileRefs.length > 0) {
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
}
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
|
||||
@@ -723,10 +723,13 @@ export function applyTheme(themeId) {
|
||||
document.dispatchEvent(new CustomEvent("theme-changed"));
|
||||
}
|
||||
|
||||
/** 初始化:先用默认主题渲染,再从服务端 theme_config.json 恢复全部设置 */
|
||||
/** 初始化:先用与首帧一致的主题渲染,再从服务端 theme_config.json 恢复全部设置 */
|
||||
export function initTheme() {
|
||||
_loadingTheme = true;
|
||||
applyTheme(DEFAULT_THEME);
|
||||
// 主进程启动时通过 ?theme= 指定了首帧主题(避免过渡闪屏),初始应用与之匹配的主题;
|
||||
// 否则回退默认深色主题,随后 loadThemeConfig 从服务端恢复用户保存的主题
|
||||
const firstPaintLight = document.documentElement.classList.contains("theme-light");
|
||||
applyTheme(firstPaintLight ? "vscode-light" : DEFAULT_THEME);
|
||||
applyBgImage("");
|
||||
applyFontSetting("family", "");
|
||||
applyFontSetting("size", "");
|
||||
|
||||
+11
-9
@@ -381,10 +381,12 @@ def _inject_sub_tool_calls(
|
||||
tc["sub_tool_calls"] = sub_tc
|
||||
|
||||
|
||||
def _bootstrap() -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||
"""初始化默认会话:取最近会话或新建时间戳 ID 及其消息列表。
|
||||
def _bootstrap(force_new: bool = False) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||
"""初始化默认会话:默认新建空会话;force_new=False 时回退到最近会话。
|
||||
|
||||
输入: 无。
|
||||
输入:
|
||||
force_new: 为 True 时始终新建空会话(应用每次打开默认新建会话);
|
||||
为 False 时取最近会话(删除会话后的回退逻辑)。
|
||||
|
||||
输出:
|
||||
(session_id, sessions列表, messages列表)
|
||||
@@ -396,13 +398,13 @@ def _bootstrap() -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||
可增加用户偏好默认会话逻辑。
|
||||
"""
|
||||
ids = ui.list_session_ids_ordered()
|
||||
if ids:
|
||||
sid = ids[0]
|
||||
hist = ui.load_ui_messages_for_session(sid)
|
||||
else:
|
||||
if force_new or not ids:
|
||||
sid = ui.new_timestamp_session_id()
|
||||
hist = []
|
||||
ids = [sid]
|
||||
ids = [sid] + [x for x in ids if x != sid]
|
||||
else:
|
||||
sid = ids[0]
|
||||
hist = ui.load_ui_messages_for_session(sid)
|
||||
return sid, ids, hist
|
||||
|
||||
|
||||
@@ -473,7 +475,7 @@ def api_bootstrap() -> dict[str, Any]:
|
||||
可扩展性:
|
||||
可返回用户配置、主题等元信息。
|
||||
"""
|
||||
sid, ids, hist = _bootstrap()
|
||||
sid, ids, hist = _bootstrap(force_new=True)
|
||||
from agent.history.tool_call_recorder import get_session_tokens
|
||||
return {
|
||||
"sessionId": sid,
|
||||
|
||||
Reference in new issue
Block a user