diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82c53ca0..c05d0c24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,11 @@ name: CI Code Quality on: + workflow_dispatch: push: - branches: [ "main" ] + branches: [ "main", "develop" ] pull_request: - branches: [ "main" ] + branches: [ "main", "develop" ] jobs: quality-check: diff --git a/config.toml.example b/config.toml.example index 423bb847..2925ff8c 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1286,6 +1286,9 @@ bot_name = "Undefined" # zh: ChromaDB 向量数据库存储路径。 # en: ChromaDB vector store path. path = "data/cognitive/chromadb" +# zh: ChromaDB 前台连续处理上限;达到后若有后台/维护任务,会让出一次执行机会。 +# en: Max consecutive foreground Chroma operations before one maintenance/background slot is allowed. +scheduler_foreground_burst = 8 [cognitive.query] # zh: 自动注入上下文时的召回条数。 diff --git a/docs/callable.md b/docs/callable.md index a671570a..ec489922 100644 --- a/docs/callable.md +++ b/docs/callable.md @@ -64,7 +64,7 @@ skills/agents/web_agent/callable.json - **自调用保护**:Agent 不会将自己注册为可调用工具 - **上下文隔离**:每次调用有独立上下文,历史记录按 Agent 分组保存 -- **迭代限制**:受 `max_iterations`(默认 20)约束,防止无限递归 +- **迭代限制**:受 `max_iterations`(默认 1000)约束,防止无限递归 ## 主工具共享(tools/) diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index b48a568e..8fbe90a2 100644 --- a/docs/cognitive-memory.md +++ b/docs/cognitive-memory.md @@ -5,7 +5,7 @@ 认知记忆系统是 Undefined 的三层分层记忆架构,模拟人类记忆机制: - **短期记忆**(`end.memo`):每轮对话结束自动记录便签备忘,最近 N 条始终注入,保持短期连续性,零配置开箱即用。若本轮由 MessageBatcher 合并多条消息,memo 应概括整个当前输入批次的处理结果。 -- **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中主动观察当前输入批次,提取用户/群聊事实及有价值的自身行为(`observations`),经后台史官异步改写为绝对化事件并存入 ChromaDB 向量库,支持语义检索;当对话中出现新信息(偏好、身份、习惯等)时,史官自动合并更新 Markdown 侧写文件,下次对话时注入 prompt。 +- **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中只观察当前输入批次,提取有价值的新观察(用户/群聊/第三方事实及有价值的自身行为)。`observations` 不要求与 bot 相关,也不要求长期稳定;历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。后台史官会异步改写为绝对化事件并存入 ChromaDB 向量库,支持语义检索;当对话中出现可沉淀为稳定画像的新信息(偏好、身份、习惯等)时,史官自动合并更新 Markdown 侧写文件,下次对话时注入 prompt。 - **置顶备忘录**(`memory.*`):AI 自身的置顶提醒(自我约束、待办事项,如"用户要求以后用英文回复"),每轮固定注入,支持增删改查。注意:用户事实(偏好、身份、习惯等)不应写入此层,一律通过 `end.observations` 写入认知记忆。 与旧 `end_summaries` 的区别: @@ -64,7 +64,7 @@ AI 调用 `end` 工具结束对话时,只做一次文件落盘(p95 < 5ms) `end` 字段语义: - `memo`:本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里),可空。当前输入批次包含多条连续消息时,memo 应概括整批处理结果。 -- `observations`:本轮值得长期留存的观察列表(0..N 条),包括用户/群聊事实和有价值的自身行为(帮谁解决了什么),严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息时,必须覆盖整批消息中值得留存的信息,不能只记录最后一条。 +- `observations`:本轮从当前输入批次提取的有价值新观察列表(0..N 条),包括用户/群聊/第三方事实和有价值的自身行为(帮谁解决了什么),不要求与 bot 相关,也不要求长期稳定,严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息时,必须覆盖整批消息中有价值的信息,不能只记录最后一条。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 - 两字段都为空时,仅结束会话,不写认知队列。 ### 后台史官流水线 @@ -175,6 +175,19 @@ MMR_score = λ × relevance(doc, query) − (1 − λ) × max_similarity(doc, se 史官合并侧写时,会在 merge LLM 调用前用当前 observations 作为 query 从 ChromaDB 检索该实体的 top-8 历史事件,注入 merge prompt。这让史官拥有更丰富的上下文来判断哪些特征应保留,避免因本轮未提及而误删长期稳定特征。 +### ChromaDB 前后台调度 + +`cognitive_events` / `cognitive_profiles` 的 ChromaDB `query/upsert` 由进程内单 worker 串行执行,避免多群聊、WebChat 与史官后台同时访问 Chroma collection 时互相踩踏。调度只覆盖真正的 Chroma 读写;embedding 与 rerank 仍走各自模型队列,避免后台向量化长尾占住 Chroma worker。 + +优先级: + +- `foreground_critical`:显式用户工具/API 检索(如 `cognitive.search_events` / `cognitive.search_profiles`)。 +- `foreground`:自动上下文注入、用户触发的侧写展示名同步。 +- `maintenance`:史官合并侧写前的历史查询。 +- `background`:史官事件/侧写向量写入。 + +前台请求优先;连续处理 `scheduler_foreground_burst` 个前台操作后,如果维护/后台队列中有等待任务,会让出一次执行机会,防止史官长期饥饿。日志中的 `chroma_wait` 表示在调度器里等待的时间,`chroma_exec` 表示真正执行 Chroma 调用的时间。 + ### 自动注入场景的 Query 构造 每轮对话自动注入认知记忆(`PromptBuilder -> cognitive.build_context`)时,检索 `query` 的构造规则如下: @@ -266,6 +279,7 @@ data/cognitive/ | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `path` | str | `data/cognitive/chromadb` | ChromaDB 存储路径 | +| `scheduler_foreground_burst` | int | `8` | Chroma 前台连续处理上限;达到后若有维护/后台任务,会让出一次执行机会(需重启) | ### [cognitive.query] @@ -340,7 +354,7 @@ data/cognitive/ ### 热更新说明 - **支持热更新**:`cognitive.query.*`、`cognitive.historian.poll_interval_seconds`、`cognitive.historian.rewrite_max_retry`、`cognitive.historian.recent_messages_inject_k`、`cognitive.historian.recent_message_line_max_len`、`cognitive.historian.source_message_max_len` -- **需重启**:`cognitive.enabled`、`models.embedding.*`、`models.rerank.*` +- **需重启**:`cognitive.enabled`、`cognitive.vector_store.*`、`models.embedding.*`、`models.rerank.*` 说明: - `knowledge.enable_rerank` 仅控制知识库检索重排。 diff --git a/docs/configuration.md b/docs/configuration.md index cefbcc04..dceb10c0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -385,6 +385,7 @@ Prompt caching 补充: 用途: - 仅供 `web_agent` 内的 `grok_search` 子工具使用。 +- 工具调用该模型时会注入专用 system prompt:以服务端当前时间作为“今天 / 最新 / 最近”的基准,要求先搜索、使用多组搜索查询或多个搜索工具、禁止编造,并在结果中给出来源。 默认: - `max_tokens=8192` @@ -880,6 +881,7 @@ Prompt caching 补充: | 字段 | 默认值 | 说明 | |---|---:|---| | `path` | `data/cognitive/chromadb` | Chroma 存储目录 | +| `scheduler_foreground_burst` | `8` | Chroma 前台连续处理上限;达到后若有维护/后台任务,会让出一次执行机会。需重启 | ### 4.24.3 `[cognitive.query]` diff --git a/docs/management-api.md b/docs/management-api.md index 346ae86b..89355aca 100644 --- a/docs/management-api.md +++ b/docs/management-api.md @@ -171,8 +171,163 @@ Management API 会把运行态相关能力统一代理到主进程 Runtime API - `GET /api/v1/management/runtime/cognitive/events` - `GET /api/v1/management/runtime/cognitive/profiles` - `GET /api/v1/management/runtime/cognitive/profile/{entity_type}/{entity_id}` +- `GET /api/v1/management/runtime/commands` +- `GET /api/v1/management/runtime/commands/{command_name}` +- `GET /api/v1/management/runtime/chat/conversations` +- `POST /api/v1/management/runtime/chat/conversations` +- `PATCH /api/v1/management/runtime/chat/conversations/{conversation_id}` +- `DELETE /api/v1/management/runtime/chat/conversations/{conversation_id}` - `POST /api/v1/management/runtime/chat` - `GET /api/v1/management/runtime/chat/history` +- `DELETE /api/v1/management/runtime/chat/history` +- `POST /api/v1/management/runtime/chat/jobs` +- `POST /api/v1/management/runtime/chat/files` +- `GET /api/v1/management/runtime/chat/jobs/active` +- `GET /api/v1/management/runtime/chat/jobs/{job_id}` +- `GET /api/v1/management/runtime/chat/jobs/{job_id}/events` +- `POST /api/v1/management/runtime/chat/jobs/{job_id}/cancel` + +所有 Runtime 代理端点都会先校验 Management session / access token,再由 WebUI 后端注入 `X-Undefined-API-Key`;浏览器不会接触 Runtime `[api].auth_key`。 + +### `runtime/commands` + +代理 Runtime 斜杠命令 REST 资源,供 WebChat `/` 补全面板和管理端命令浏览使用。 + +- 参数: + - `scope`:默认 `webui`;也可传 `private` / `group`。 + - `q`、`include_hidden`、`include_unavailable`、`sender_id`、`user_id`、`group_id`:原样透传给 Runtime。 +- 校验: + - Management 登录态或 access token 必须有效。 + - 后端注入 `X-Undefined-API-Key`。 +- 响应: + - `200`:命令、别名、子命令、用法、权限和当前 scope 可用性。 + - Runtime 鉴权或配置错误会透传对应错误状态。 + +```http +GET /api/v1/management/runtime/commands?scope=webui&q=help +``` + +### `runtime/chat/conversations` + +管理 WebChat 多对话,支持查询、新建、重命名和删除。 + +- 参数 / Body: + - `GET` 无必填参数。 + - `POST` 可传 `{"title":"..."}`。 + - `PATCH` 传 `{"title":"..."}`。 + - `DELETE` 使用路径参数 `conversation_id`。 +- 校验: + - 删除会话时 Runtime 会检查是否存在运行中或收尾落盘中的 WebChat job。 +- 响应: + - `200` / `201`:会话列表或会话对象。 + - `404`:会话不存在。 + - `409`:仍有 WebChat job 阻塞会话删除。 + +```json +{ + "conversation": { + "id": "legacy-system-42", + "title": "新对话", + "virtual_user_id": 42 + } +} +``` + +### `runtime/chat`、`runtime/chat/history`、`runtime/chat/jobs`、`runtime/chat/jobs/active` + +代理 WebChat 发送、历史分页、后台 job 创建和 active job 查询;`conversation_id` 会在 body 或 query 中原样透传。 + +- 参数 / Body: + - `conversation_id`:可选;不传时使用 Runtime 默认兼容会话。 + - `POST runtime/chat`:`message` 必填,`stream` 可选。 + - `GET runtime/chat/history`:`limit`、`before`、`conversation_id`。 + - `DELETE runtime/chat/history`:`conversation_id`。 + - `POST runtime/chat/jobs`:`message` 必填,`conversation_id` 可选。 + - `GET runtime/chat/jobs/active`:`conversation_id` 可选。 +- 校验: + - Runtime 会检查 `conversation_id` 是否存在。 + - 删除历史时,如果仍有运行中或收尾落盘中的 job,会透传 `409`。 +- 响应: + - `200` / `202`:聊天结果、历史页、job 快照或 active job。 + - `404`:会话不存在。 + - `409`:job 正在运行或历史尚未完成落盘。 +- 元数据语义: + - `webchat.duration_ms`、`webchat.events`、`webchat.timeline`、`current_tool_calls`、`stage` / `agent_stage` 是 **display-only**,用于刷新后恢复工具 / Agent 展示块、阶段和耗时。 + - 这些 WebChat 展示元数据不是 **AI-context**,不会作为后续 AI 对话上下文注入。 + - 工具 / Agent 输入输出预览由 Runtime 统一脱敏和截断。 + +```json +{ + "message": "你好", + "conversation_id": "legacy-system-42" +} +``` + +### `runtime/chat/files` + +缓存 WebChat 待发送文件,前端随后把返回的 `id` 合并为 `CQ:file` 随当前消息提交。 + +- 参数 / Body: + - `multipart/form-data` 字段 `file` 必填。 +- 校验: + - Management 登录态或 access token 必须有效。 + - Runtime / WebUI 文件大小限制会返回 `413`。 +- 响应: + - `200`:`{ "id": "...", "name": "...", "size": 123 }`。 + - `400`:缺少 `file` 字段或 multipart body 无效。 + - `413`:文件超过限制。 + +```http +POST /api/v1/management/runtime/chat/files +Content-Type: multipart/form-data +``` + +```json +{ "id": "abc123", "name": "report.pdf", "size": 2048 } +``` + +前端合并为: + +```text +CQ:file,id=abc123,name=report.pdf,size=2048 +``` + +### `runtime/chat/jobs/{job_id}/events` + +按 `conversation_id + job_id + seq` 续接 WebChat job 事件,支持 JSON 增量查询和兼容 SSE。 + +- 参数: + - `conversation_id`:可选;传入时必须与 job 所属会话一致。 + - `after`:返回大于该 `seq` 的事件。 + - `format=json`:显式 JSON 查询。 + - `Accept: text/event-stream`:透传 Runtime WebChat SSE。 +- 校验: + - `conversation_id` 不一致时返回 `404`,避免跨会话误续接。 +- 响应: + - 默认 JSON:持久事件、当前顶层 `stage` 快照、当前 `agent_stage` 快照、`current_tool_calls` 和耗时字段。 + - SSE:事件帧和 keep-alive 由 Runtime 透传。 + +```json +{ + "job": { + "job_id": "9c1...", + "status": "running", + "current_stage": "waiting_tools", + "current_tool_calls": [] + }, + "after": 4, + "last_seq": 5, + "events": [ + { "seq": 5, "event": "stage", "payload": { "stage": "waiting_tools" } } + ] +} +``` + +```text +id: 5 +event: stage +data: {"stage":"waiting_tools"} +``` 除此之外,Management API 还额外代理了表情包库管理接口: diff --git a/docs/message-batching.md b/docs/message-batching.md index fb913fc2..ad410ecb 100644 --- a/docs/message-batching.md +++ b/docs/message-batching.md @@ -30,7 +30,9 @@ `res/prompts/undefined.xml`、`res/prompts/undefined_nagaagent.xml` 与 `res/IMPORTANT/each.md` 均按"当前输入批次"适配:有【连续消息说明】时整批当前 `` 都属于本轮输入;没有连续说明时,当前输入批次退化为最后一条消息。防幽灵任务规则仍然生效,但它只隔离当前输入批次之外的历史消息;「催促/在吗」不等于新任务,历史同类或语义等价操作不得自动重跑(与 each.md 硬性熔断一致)。 -`end.memo` / `end.observations` 也按同一语义适配:当前输入批次包含多条连续消息时,短期 memo 要概括整批处理结果,认知 observations 要覆盖整批消息中值得留存的信息;后台史官收到的 `source_message` 会按时间顺序列出本批所有 ``,不会只取最后一条。 +Prompt 构建顺序按缓存命中友好设计:固定系统提示词、运行环境配置、Skills 元数据和强制规则尽量放在前面;会频繁变化的 memory / cognitive / end 摘要 / history / 当前时间 / 当前输入批次放在后面。`system_prompt_as_user=true` 时,系统块会合并进首条 user,但合并后的文本仍保留这个顺序,且当前输入批次仍在最后。 + +`end.memo` / `end.observations` 也按同一语义适配:当前输入批次包含多条连续消息时,短期 memo 要概括整批处理结果,认知 observations 要覆盖整批消息中有价值的新观察;这些观察不要求与 bot 相关,也不要求长期稳定,但只能来自当前输入批次。历史消息、认知记忆、侧写和最近消息参考只用于消歧,不能作为 observations 的新事实来源。后台史官收到的 `source_message` 会按时间顺序列出本批所有 ``,不会只取最后一条。 > **重要**:当前主提示词按 MessageBatcher 默认开启设计。`[message_batcher].enabled = true` 是推荐和默认配置;如果关闭 batcher,连续补充/修正会退化为逐条独立 AI 调用,提示词中的"当前输入批次"语义可能不再覆盖这些连续消息,需要单独调整提示词或接受旧版逐条触发行为。 diff --git a/docs/openapi.md b/docs/openapi.md index ea5973e0..d6e60eb6 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -219,6 +219,30 @@ curl http://127.0.0.1:8788/openapi.json 说明:这些接口仅在 `cognitive.enabled = true` 时可用,否则返回错误。 +### 斜杠命令元数据 + +- `GET /api/v1/commands` +- `GET /api/v1/commands/{command_name}` + +查询参数: + +- `scope`:`webui` / `private` / `group`,默认 `webui`。`webui` 会按 WebChat 虚拟私聊的实际执行路径过滤:身份仍是 `system#42`,权限主体使用配置中的 `superadmin_qq`。 +- `q`:按命令名、别名、描述、用法和子命令过滤(可选)。 +- `include_hidden`:是否包含 `show_in_help=false` 的命令,默认 `false`。 +- `include_unavailable`:是否返回当前 scope / 权限下不可用的命令,并在 `unavailable_reason` 标明原因,默认 `false`。 +- `sender_id` / `user_id` / `group_id`:当 `scope=private` 或 `scope=group` 时可指定用于权限和可见性策略判断的身份。 + +响应包含 `commands[]`。命令项提供 `name`、`trigger`、`description`、`usage`、`example`、`permission`、`allow_in_private`、`aliases`、`alias_triggers`、`subcommands[]`、`inference`、`available` 和 `unavailable_reason`;子命令项提供 `name`、`trigger`、`description`、`args`、`usage`、`permission`、`allow_in_private`、`available` 和 `unavailable_reason`。WebUI 的 `/` 补全面板使用 `GET /api/v1/commands?scope=webui`,因此展示结果与 WebChat 实际命令分发保持一致。 + +### WebUI AI Chat 导览 + +- [WebUI AI Chat(特殊私聊)](#webui-ai-chat特殊私聊) +- [Event types](#event-types) +- [WebUI AI Chat Conversations](#webui-ai-chat-conversations) +- [WebUI AI Chat 历史记录](#webui-ai-chat-历史记录) +- [WebUI AI Chat Jobs](#webui-ai-chat-jobs) +- [Schemas / Appendix](#schemas--appendix) + ### WebUI AI Chat(特殊私聊) - `POST /api/v1/chat` @@ -227,27 +251,309 @@ curl http://127.0.0.1:8788/openapi.json ```json { "message": "你好", + "conversation_id": "legacy-system-42", "stream": false } ``` -- 当 `stream = true` 时,返回 `text/event-stream`(SSE): - - `event: meta`:会话元信息。 - - `event: message`:AI/命令输出片段。 - - `event: done`:最终汇总(与非流式 JSON 结构一致)。 - - 在长时间无内容时会发送 `: keep-alive` 注释帧,防止中间层空闲断连。 +- `stream = false` 保持同步响应。 +- 当 `stream = true` 时,Runtime 会创建 WebChat job。旧接口仍可返回 SSE,但 WebUI 默认使用 job 查询接口续接事件。 +- `conversation_id` 可选;不传时使用兼容默认会话 `legacy-system-42`,传入不存在的会话 ID 时返回 `404`。 +#### Event types + +- `meta`:会话元信息。 +- `stage`:顶层 AI 当前处理阶段,用于 WebUI 在 `AI` 标签后实时显示状态和总已用时;payload 形如 `{"job_id":"...","stage":"waiting_model","elapsed_ms":1234,"detail":"..."}`。 +- `agent_stage`:某个 Agent 内部当前阶段,payload 包含 `webchat_call_id`、`stage`、`stage_elapsed_ms`、`elapsed_ms`、`agent_name`。运行中查询可能返回 `transient=true` 的当前快照;这类快照不写入历史。 +- `tool_start` / `tool_end`:工具开始与结束。 +- `agent_start` / `agent_end`:Agent 调用开始与结束。 +- `message`:AI/命令最终输出片段。 +- `done`:最终汇总(与非流式 JSON 结构一致)。 +- `error`:任务失败或取消。 + +#### Lifecycle / Display Conventions + +- WebChat 不发布模型 token 级文本增量,也不发布工具参数增量;正文以 `message` 事件展示,工具只按生命周期事件展示。 +- `stage`、`agent_stage`、`webchat.calls`、`webchat.timeline`、`current_tool_calls` 和 `duration_ms` 是 display-only 展示元数据,不作为 AI-context 注入后续对话。 +- 工具结束事件 payload 会尽量带 `duration_ms`。运行中的 job 快照会在 `current_tool_calls` 返回仍在执行的工具 / Agent 及其后端计算的 `duration_ms`。 +- WebUI 每 0.5 秒查询一次;查询间隙只用本地时间临时递增显示,下一次查询后以 Runtime 返回值校准。 +- 并发工具按实际完成时间发布结束事件,LLM tool message 回填仍保持模型要求的原始顺序。 +- 工具 / Agent 事件 payload 由后端补齐调用链字段:`webchat_call_id`、`parent_webchat_call_id`、`depth`、`agent_path`。 +- 工具 / Agent 事件 payload 由后端补齐 `status`,取值通常为 `running`、`done`、`error`、`cancelled`。如果 job 失败或取消时仍有未闭合调用,历史 metadata 会在统一落盘阶段补齐失败 / 取消终态。 +- WebUI 展开工具 / Agent 调用块时,会按输入 / 输出分区展示由 Runtime 生成的 `arguments_preview` 和 `result_preview`。预览会递归遮蔽常见敏感字段并按长度截断;预览不是权限边界,工具实现仍应避免把完整凭证写入结果正文。 +- 工具事件 payload 可能带 `ui_hint`:`webchat_private_send` 表示同一 WebChat 私聊回复已通过 `message` 事件展示;`webchat_end` 表示 `end` 成功结束,工具块可隐藏重复的成功结果。 行为约定: -- 会话固定虚拟用户:`system`(`id = 42`)。 +- AI 视角固定虚拟私聊身份:`system`(`id = 42`)。多对话只隔离 WebChat 历史文件和前端列表,不改变 `RequestContext`、`sender_id`、`user_id`、权限或 AI 看到的用户身份。 - 权限视角:`superadmin`。 - 如果输入以 `/` 开头,按私聊命令分发执行(遵循命令 `allow_in_private` 开放策略)。 +- WebChat 会话持久化在 `data/webchat/conversations/.json`,一个会话一个 JSON 文件。删除会话会删除对应 JSON;不会写入单个全局 conversations JSON。 +- 首次加载会自动把旧版 `data/history/private_42.json` 或运行中的旧历史管理器记录迁移到 `legacy-system-42`,并写入 `data/webchat/legacy_private_42_migrated.json` 迁移标记。只要标记存在就不会重复迁移;即使删除迁移出的会话,也不会再次从旧文件恢复。 + +### WebUI AI Chat Conversations + +- `GET /api/v1/chat/conversations`:列出 WebChat 会话,响应包含 `conversations`、`active_job`、`default_conversation_id` 和 `virtual_user_id`。 +- `POST /api/v1/chat/conversations`:新建会话,Body 可选 `{"title":"..."}`。不传标题时先使用临时标题。 +- `PATCH /api/v1/chat/conversations/{conversation_id}`:重命名会话,Body 为 `{"title":"..."}`。手动标题会标记为 `manual`,后续不会被自动标题覆盖。 +- `DELETE /api/v1/chat/conversations/{conversation_id}`:删除会话 JSON。若任意 WebChat job 仍在运行或收尾落盘,返回 `409`。 + +会话标题由后端维护。第一条用户消息写入后会先用首问前若干字符作为临时标题;当该会话同时具备首问和首答时,后端会调标题生成模型用“首问 + 首答”生成正式标题。标题生成带状态和内容哈希校验,避免并发回复、手动重命名或历史变化时把旧标题写回新内容。 ### WebUI AI Chat 历史记录 -- `GET /api/v1/chat/history?limit=200` -- 用于读取虚拟私聊 `system#42` 的历史记录(只读)。 -- 返回中包含 `role/content/timestamp`,用于 WebUI 自动恢复会话视图。 +- `GET /api/v1/chat/history?conversation_id=&limit=50&before=` +- 用于分页读取指定 WebChat 会话的虚拟私聊 `system#42` 历史记录。默认返回最新一页,响应包含 `conversation_id/items/has_more/next_before/total`;客户端继续加载更早历史时把上次返回的 `next_before` 作为 `before` 传回。不传 `conversation_id` 时兼容读取默认会话。 +- 对于由 WebChat job 产生的回复,Bot 历史项可能包含 `webchat` 展示元数据。完整示例见下方折叠块,字段 schema 见 [Schemas / Appendix](#schemas--appendix)。 + +
+展开完整 history 示例 + +```json +{ + "role": "bot", + "content": "最终回复文本,可为空", + "timestamp": "2026-05-30 12:00:00", + "webchat": { + "display_only": true, + "job_id": "9c1...", + "mode": "chat", + "status": "done", + "created_at": 1780123200.0, + "finished_at": 1780123201.5, + "duration_ms": 1500, + "timeline": [ + { + "type": "call", + "seq": 2, + "call": { + "webchat_call_id": "call_agent", + "name": "web_agent", + "is_agent": true, + "status": "done", + "duration_ms": 900, + "children": [ + { + "webchat_call_id": "call_agent/call_search", + "parent_webchat_call_id": "call_agent", + "name": "search", + "is_agent": false, + "status": "done", + "result_preview": "摘要", + "duration_ms": 420, + "children": [] + } + ] + } + }, + { + "type": "message", + "seq": 4, + "content": "中间回复文本", + "elapsed_ms": 860 + } + ], + "calls": [ + { + "webchat_call_id": "call_agent", + "parent_webchat_call_id": "", + "tool_call_id": "call_agent", + "name": "web_agent", + "is_agent": true, + "status": "done", + "duration_ms": 900, + "children": [ + { + "webchat_call_id": "call_agent/call_search", + "parent_webchat_call_id": "call_agent", + "tool_call_id": "call_search", + "name": "search", + "is_agent": false, + "status": "done", + "result_preview": "摘要", + "duration_ms": 420, + "children": [] + } + ] + } + ], + "events": [ + { + "seq": 2, + "event": "tool_start", + "payload": { + "job_id": "9c1...", + "tool_call_id": "call_1", + "name": "search", + "arguments_preview": "{\"q\":\"test\"}", + "is_agent": false + } + }, + { + "seq": 4, + "event": "message", + "payload": { + "job_id": "9c1...", + "content": "中间回复文本" + } + }, + { + "seq": 5, + "event": "tool_end", + "payload": { + "job_id": "9c1...", + "tool_call_id": "call_1", + "name": "search", + "ok": true, + "duration_ms": 420, + "result_preview": "摘要", + "is_agent": false + } + } + ] + } +} +``` + +
+ +`webchat.timeline` 是后端生成的权威历史展示序列,按 `seq` 混排顶层工具 / Agent 调用节点与正文消息,前端刷新后优先按它忠实渲染同一 AI 气泡。`webchat.calls` 是后端由生命周期事件汇总出的调用树,包含每个工具 / Agent 的输入预览、输出预览、状态、耗时、`children` 和节点内 `timeline`;节点内 `timeline` 用于恢复 Agent 内部“子工具 / 子 Agent / 正文”的真实时序,Agent 阶段只恢复为摘要行状态。`webchat.events` 保留原始生命周期 / 正文事件,供兼容旧历史与诊断使用,不作为 AI 后续对话上下文注入。若一次 job 没有正文但有工具事件,历史 API 仍会返回该 Bot 项,`content` 为空字符串。 +- `DELETE /api/v1/chat/history?conversation_id=` +- 仅清空指定 WebChat 会话的 `system#42` 聊天历史,不删除长期记忆、认知记忆、profile 或其他 WebChat 会话。 +- 如果存在运行中或正在收尾落盘的 WebChat job,返回 `409`,避免旧任务继续写回已清空的历史。 + +### WebUI AI Chat Jobs + +- `POST /api/v1/chat/jobs`:创建后台 job,Body 为 `{"message":"...","conversation_id":"..."}`,`conversation_id` 可选。 +- WebChat 前端粘贴或选择的附件会先被合并进 `message`:小图片使用 `CQ:image,file=base64://...`,普通文件使用 WebUI 管理代理的 `/api/runtime/chat/files` 缓存后生成 `CQ:file,id=...`;Runtime 侧沿用 `register_message_attachments()` 注册到 `webui` 附件作用域。 +- WebChat 前端引用 AI 消息、选中文本或 HTML 预览中点选的元素时,不新增后端端点,也不写入单独附件;发送前会把待引用内容转换成 Markdown blockquote 并拼接到 `message` 前面,例如 `> 引用 AI:` / `> 引用 HTML 片段:`。后端只接收最终 `message` 字符串。 +- `GET /api/v1/chat/jobs/active?conversation_id=`:返回当前运行中的 WebChat job(没有则为 `null`)。不传时返回任意当前 WebChat job;传入时只在该 job 属于对应会话时返回。 +- `GET /api/v1/chat/jobs/{job_id}`:查询 job 状态、最后事件序号和已汇总输出。 +- `GET /api/v1/chat/jobs/{job_id}/events?after=&conversation_id=`:查询 `seq` 之后的增量事件,默认返回 JSON。`conversation_id` 可选;传入时必须与 job 所属会话一致,否则返回 `404`,用于刷新、断线或换客户端后避免跨会话误续接。 +- `GET /api/v1/chat/jobs/{job_id}/events?after=&format=json` 或请求头 `Accept: application/json`:显式查询 JSON。响应包含: + +```json +{ + "job": { + "job_id": "9c1...", + "status": "running", + "last_seq": 5, + "elapsed_ms": 2400, + "duration_ms": null, + "current_stage": "waiting_tools", + "current_stage_elapsed_ms": 1200, + "current_agent_stages": [ + { + "job_id": "9c1...", + "webchat_call_id": "call_agent", + "agent_name": "web_agent", + "stage": "waiting_model", + "stage_elapsed_ms": 900, + "elapsed_ms": 2400, + "transient": true + } + ], + "current_tool_calls": [ + { + "job_id": "9c1...", + "webchat_call_id": "call_agent", + "name": "web_agent", + "status": "running", + "is_agent": true, + "started_at": 1760000000.0, + "duration_ms": 2400, + "current_stage": "waiting_model", + "current_stage_elapsed_ms": 900 + } + ] + }, + "after": 5, + "last_seq": 5, + "events": [ + { + "seq": 5, + "event": "agent_stage", + "payload": { + "webchat_call_id": "call_agent", + "stage": "waiting_model", + "stage_elapsed_ms": 900, + "transient": true + } + } + ] +} +``` + +`events` 只包含 `after` 之后的持久事件以及当前运行阶段快照。快照使用当前 `last_seq`,便于刷新或断线后以 `job_id + seq` 轮询续接,但不会推进序号或重复写入历史。 + +Runtime 在同一个 job 条件锁内维护事件、顶层阶段、Agent 阶段和耗时快照,因此 JSON 查询和兼容 SSE 都应看到一致的 job 状态。 +- `GET /api/v1/chat/jobs/{job_id}/events?after=` 加请求头 `Accept: text/event-stream`:兼容 SSE 订阅;SSE 帧包含 `id: `,长时间无事件时会发送 keep-alive 注释帧。 +- `POST /api/v1/chat/jobs/{job_id}/cancel`:取消运行中的 job。 + +Runtime API 进程重启后不会恢复未完成 job;已落盘的聊天历史仍可通过 history 接口读取。 + +### Schemas / Appendix + +#### `webchat.events` + +```json +[ + { + "seq": 5, + "event": "tool_end", + "payload": { + "job_id": "9c1...", + "tool_call_id": "call_1", + "webchat_call_id": "call_1", + "parent_webchat_call_id": "", + "name": "search", + "status": "done", + "duration_ms": 420, + "arguments_preview": "{\"q\":\"test\"}", + "result_preview": "摘要" + } + } +] +``` + +#### `webchat.calls` + +```json +[ + { + "webchat_call_id": "call_agent", + "parent_webchat_call_id": "", + "name": "web_agent", + "is_agent": true, + "status": "done", + "duration_ms": 900, + "children": [] + } +] +``` + +#### History Response + +```json +{ + "conversation_id": "legacy-system-42", + "items": [ + { + "role": "bot", + "content": "最终回复文本", + "webchat": { + "display_only": true, + "duration_ms": 1500, + "timeline": [], + "calls": [], + "events": [] + } + } + ], + "has_more": false, + "next_before": null, + "total": 1 +} +``` ### 工具调用 API @@ -422,6 +728,17 @@ curl -N -H "X-Undefined-API-Key: $KEY" \ -d '{"message":"你好","stream":true}' \ "$API/api/v1/chat" +JOB_ID="$(curl -s -H "X-Undefined-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{"message":"你好"}' \ + "$API/api/v1/chat/jobs" | jq -r .job_id)" +curl -H "X-Undefined-API-Key: $KEY" \ + "$API/api/v1/chat/jobs/$JOB_ID/events?after=0&format=json" + +curl -N -H "X-Undefined-API-Key: $KEY" \ + -H "Accept: text/event-stream" \ + "$API/api/v1/chat/jobs/$JOB_ID/events?after=0" + # 列出可用工具(需 tool_invoke_enabled = true) curl -H "X-Undefined-API-Key: $KEY" "$API/api/v1/tools" @@ -448,14 +765,72 @@ WebUI 不直接在前端暴露 `auth_key`,而是通过后端代理访问主进 - `GET /api/runtime/cognitive/events` - `GET /api/runtime/cognitive/profiles` - `GET /api/runtime/cognitive/profile/{entity_type}/{entity_id}` +- `GET /api/runtime/commands` +- `GET /api/runtime/commands/{command_name}` +- `GET /api/runtime/chat/conversations` +- `POST /api/runtime/chat/conversations` +- `PATCH /api/runtime/chat/conversations/{conversation_id}` +- `DELETE /api/runtime/chat/conversations/{conversation_id}` - `POST /api/runtime/chat` - `GET /api/runtime/chat/history` +- `DELETE /api/runtime/chat/history` +- `POST /api/runtime/chat/jobs` +- `POST /api/runtime/chat/files` +- `GET /api/runtime/chat/jobs/active` +- `GET /api/runtime/chat/jobs/{job_id}` +- `GET /api/runtime/chat/jobs/{job_id}/events` +- `POST /api/runtime/chat/jobs/{job_id}/cancel` - `GET /api/runtime/openapi` - `GET /api/runtime/tools` - `POST /api/runtime/tools/invoke` -WebUI 后端会自动从 `config.toml` 读取 `[api].auth_key` 并注入 Header。 -`/api/runtime/chat` 代理超时为 `480s`,并透传 SSE keep-alive。 +### Auth / Header 注入 + +WebUI 后端会自动从 `config.toml` 读取 `[api].auth_key` 并注入 `X-Undefined-API-Key`,前端只持有 WebUI 登录态,不直接暴露 Runtime API 密钥。 + +### Command Proxy + +`/api/runtime/commands` 代理斜杠命令 REST 资源。 + +- WebChat 输入框的 `/` 补全默认请求 `scope=webui`。 +- `q`、`include_hidden`、`include_unavailable` 等查询参数原样透传。 + +### Conversation Handling + +`/api/runtime/chat/conversations` 代理 WebChat 多对话管理。 + +- `GET/POST/PATCH/DELETE /api/runtime/chat/conversations...` 管理会话 JSON。 +- `/api/runtime/chat`、`/api/runtime/chat/history`、`/api/runtime/chat/jobs` 和 `/api/runtime/chat/jobs/active` 会透传 `conversation_id`。 +- 不传 `conversation_id` 时使用 Runtime 默认兼容会话。 +- 删除会话或清空历史时,运行中或收尾落盘中的 WebChat job 会导致 `409`。 + +### File Uploads + +`/api/runtime/chat/files` 接收已登录 WebUI 发起的文件上传。 + +- Content-Type:`multipart/form-data` +- 字段名:`file` +- 行为:缓存到 WebUI 文件缓存目录。 +- 响应:`{ "id": "...", "name": "...", "size": 123 }` + +前端随后把返回值合并进同一条 WebChat job 消息: + +```js +const uploaded = { id: "abc123", name: "report.pdf", size: 2048 }; +const cq = `CQ:file,id=${uploaded.id},name=${uploaded.name},size=${uploaded.size}`; +``` + +WebChat 引用功能只在前端把引用内容格式化为 Markdown `>` 引用块并合并进 `message`,不经过文件缓存代理。 + +### Event / Query Behavior + +`/api/runtime/chat/jobs/{job_id}/events` 代理 WebChat job 事件续接。 + +- 默认返回 JSON 增量查询。 +- `Accept: text/event-stream` 时透传 Runtime SSE 和 keep-alive。 +- `conversation_id` 传入时必须与 job 所属会话一致。 +- `after` 用于按 seq 增量续接。 +- 聊天代理超时按当前聊天模型队列预算计算。 ## 7. 故障排查 diff --git a/docs/usage.md b/docs/usage.md index 8cb658fc..e96cc2d1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -60,6 +60,8 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 **子工具**:`grok_search`(Grok 搜索)、`web_search`(通用搜索)、`crawl_webpage`(网页内容提取) +启用 `grok_search` 后,工具会在调用 Grok 模型时注入检索约束:以服务端提供的当前时间为准,先调用搜索能力,使用多组搜索查询或多个搜索工具进行交叉检索,禁止编造,并在输出中给出来源。 + **示例:** > *"请搜索最近三天关于 DeepSeek 的最新动态并生成摘要。"* > *"帮我爬取这个网页的主要内容并整理成结构化笔记。"* diff --git a/docs/webui-guide.md b/docs/webui-guide.md index 42fdc361..13d873a4 100644 --- a/docs/webui-guide.md +++ b/docs/webui-guide.md @@ -119,9 +119,23 @@ AI 的置顶备忘录(自我约束、待办事项等),支持完整 CRUD: WebUI 内置的对话界面,直接与 Bot 的 AI 进行交互: -- 支持文本和图片消息。 -- AI 回复支持 Markdown 渲染。 -- 消息历史分页浏览。 +- 右侧会话抽屉支持多对话管理:新建对话、切换对话、重命名对话和删除对话。桌面端默认折叠,鼠标移到右侧触发区会自动展开;移动端默认只显示“对话”按钮,点击后展开会话列表,切换会话后自动收起。新建成功后会显示提示,并短暂高亮新会话。多对话只作用于 WebChat,不影响 QQ 私聊 / 群聊历史。每个会话在后端保存为 `data/webchat/conversations/.json`,删除对话会删除对应 JSON 文件;如果仍有 WebChat job 运行或正在收尾落盘,删除和清空会被拒绝。 +- WebChat 的 AI 视角始终是同一个虚拟私聊用户 `system#42`,权限仍为 `superadmin`。切换 WebUI 会话只切换后端提供给 AI 的当前 WebChat 历史,不改变 `user_id`、`sender_id` 或身份提示,因此 AI 不会把多个 WebUI 会话看成不同真实用户。 +- 输入框开头输入 `/` 时会从后端 `/api/v1/commands?scope=webui` 获取当前可用斜杠命令并在输入框上方展开补全面板。面板按命令名、别名、说明和用法即时筛选,支持点击选择,也支持方向键选择、`Enter` / `Tab` 填入;直接手打 `/faq `、`/changelog ` 或 alias `/cl ` 这类复合命令后,会切换为子命令补全并显示具体用法。命令数据尚未返回时面板显示“正在加载可用命令”,不会提前显示“未找到匹配命令”;输入 `/h ` 这类已命中 alias 但命令本身没有声明子命令的内容时,会显示命令帮助块,包含说明、用法、示例和别名,例如 `/h [命令名] [-t]`,便于继续补参数;只有命令或子命令确实没有匹配项时才显示无匹配提示。命令清单按 WebChat 的虚拟私聊执行身份过滤,因此不会提示当前 WebUI 会话实际不可用的命令。 +- 旧版 WebChat 历史会在首次打开时自动迁移到默认会话。迁移完成后会写入标记文件,之后不会重复迁移;即使删除该默认会话,也不会再从旧历史文件恢复。未选择会话的旧接口调用会按需创建一个空的默认会话以保持向后兼容。 +- 会话标题先使用第一条用户消息的前若干字符作为临时标题;当会话已有首问和首答后,后端会使用 chat model 根据首问 + 首答生成正式标题。手动重命名的标题不会被自动生成覆盖;临时或生成失败的标题会在后续能处理时继续尝试。 +- 支持文本、图片和文件消息。图片或文件可通过 `+` 选择,也可直接粘贴到输入框;粘贴只会加入待发送附件条,不会立即发送,点击发送或按 Enter 时才随同当前文本进入同一条 WebChat 消息。无待发送附件时输入框会占满可用宽度;添加附件后右侧预览轨道随数量平滑展开,图片显示缩略图,附件较多时输入框保持最小可用宽度并压缩预览卡片,避免输入区跳动。移动端会把引用和附件预览轨道放到输入框上方,保证正文输入和发送按钮不被挤压。 +- AI 回复支持 Markdown 渲染,Markdown 内的常见 HTML 片段会经过 WebUI 白名单净化后自动渲染;完整 HTML 文档或独立块级 HTML 片段会先净化再直渲染,避免被 Markdown 缩进规则误判成代码块。脚本、事件属性、危险协议、危险样式以及 `head` / `style` 等文档元信息会被剥离。聊天中的图片可点击放大查看,支持点击遮罩、关闭按钮或按 `Esc` 退出。代码块使用本地随包的 highlight.js 做多语言语法高亮,不依赖外部 CDN;显式语言优先,未知语言会自动检测,库不可用时回退为安全转义文本。较长代码块默认以固定高度折叠,代码区内部可滚动,可用常驻工具栏展开 / 折叠,复制和运行按钮始终可见。可运行的 HTML 代码块会额外显示“运行”,在前端沙箱 iframe 中本地预览完整 HTML/CSS/JS,不调用后端执行;预览环境允许 inline script 以支持完整 HTML 交互,但不允许 `unsafe-eval`,安全边界依赖 WebUI CSP 和 iframe sandbox。预览小窗可拖动位置并调整大小,窗口尺寸变化时会自动保持在可见区域。HTML 预览面板可关闭,也可进入选择模式:第一次点击 iframe 内元素只预览并锁定引用范围,第二次点击确认后才把对应 HTML 片段加入待引用区。 +- WebUI 场景下,代码优先直接作为聊天回复输出,不要默认转成文件附件;只有用户明确要求文件交付、代码过长不适合聊天展示,或确需附件工作流时才使用文件。所有代码块都应显式标注语言,例如 ` ```python `、` ```javascript `、` ```html `、` ```bash `,不确定语言时用 ` ```text `。 +- AI 消息支持引用:可引用整条 AI 消息、选中的某段文字,或 HTML 预览中点选的元素。引用会显示在输入框右侧的待发送引用条中,可在发送前移除;实际发送时不会新增接口或附件,而是自动在用户消息前拼接为 Markdown `>` 引用块,例如 `> 引用 AI:`,随后和文本、图片、文件一起进入同一条 WebChat job 消息。发送后的引用块会像代码块一样折叠显示,展开后内部固定高度并可滚动。发送失败时待引用内容会保留,发送成功后清空。 +- 默认加载最新 50 条消息并滚动到底部;向上滚动到顶部会按后端返回的 `next_before` 游标懒加载更早历史,并保持原视口偏移,避免一次性恢复大量工具块造成卡顿。“自动滚动到底部”开关默认开启并保存在浏览器本地,关闭后 AI 回复、工具 / Agent 状态和 AI 阶段刷新不会打断当前位置;首次加载和主动发送新消息仍会定位到底部。系统开启减少动态效果时,聊天滚动会改为即时跳转。 +- 对话由 WebChat job 执行。刷新页面、关闭页面、网络短暂中断或换另一个客户端重新访问 WebUI 时,后端任务继续运行,前端会从后端查询会话列表、当前运行 job,并用 `conversation_id + job_id + seq` 每 0.5 秒轮询增量事件、当前阶段快照和运行中工具 / Agent 快照自动续接;前端不会把关键任务状态只存放在浏览器本地,历史、会话、运行中 job 和事件游标都以 Runtime API 返回值为准。如果刷新后首次查询运行中 job 失败,WebUI 会退避重试并在网络恢复时再次尝试;如果后端已完成 job 并落盘,前端会刷新历史并解除发送锁。SSE 仅作为兼容方式保留,WebUI 不依赖长连接。 +- 运行中的 AI 气泡会在 `AI` 标签后实时显示当前阶段和后端计算的总已用时,例如构建上下文、查长期记忆、查认知记忆、等待模型、等待工具、发送消息;任务完成后该位置显示本轮回复总用时。工具 / Agent 摘要行会在名称旁显示调用耗时,运行中先显示后端快照时间,轮询间隙用本地时间临时递增,下一次查询后自动校准;结束后固定显示后端结束事件的总耗时。轮询刷新只更新已有状态和计时节点,结构未变化时不会重建工具 / Agent 块,避免运行中闪烁。状态条区分运行中、成功和失败;整轮回复总耗时会写入 WebChat 历史 metadata。 +- WebChat job 事件会更新同一个 AI 气泡;AI 正文和主对话工具 / Agent 调用按事件时序显示,例如工具调用、正文回复、结束工具会依次出现在同一气泡内。 +- 工具 / Agent 调用块展开后会分区展示由后端脱敏截断后的输入和输出预览;Agent 内部工具、子 Agent 和发送出的正文会按后端提供的 timeline 嵌套显示,并保留各自状态、输出和耗时。Agent 内部阶段只作为对应 Agent 摘要行的当前状态展示,不单独占用一行。并发工具结束事件按实际完成时间发布,结构化预览会渲染为带颜色的键值字段。输出内容继续支持 Markdown、安全 HTML、图片和文件卡片渲染,并保留适合移动端的单行工具摘要。 +- WebChat 内由 `send_message` / `send_private_message` 发送给当前虚拟私聊的正文会作为 AI 消息展示,工具块只显示紧凑发送状态,避免重复展示参数和“已发送”结果;`end` 成功结束时同样只显示紧凑状态。 +- WebChat 的工具 / Agent 展示块、嵌套调用树、权威展示 timeline 和正文事件会随 Bot 回复一起写入虚拟私聊历史,用于刷新页面后恢复同一个聊天块的时序;若任务失败或取消,后端会在落盘时补齐未闭合工具的错误 / 取消状态。这部分展示元数据不会注入给 AI 作为后续上下文。 +- 清空历史接口保留为兼容能力,只删除当前 WebChat 会话的虚拟私聊 `system#42` 聊天历史,不影响其他 WebChat 会话、长期记忆、认知记忆或 profile。WebUI 主界面不提供清空按钮,推荐通过新建对话开始新的上下文;若仍有运行中或正在收尾落盘的 WebChat job,清空会被拒绝。 - 发出的消息会经过与 QQ 侧相同的处理流程(安全检查、工具调用等)。 ### 关于(About) diff --git a/res/IMPORTANT/each.md b/res/IMPORTANT/each.md index 3962d381..fbb406cd 100644 --- a/res/IMPORTANT/each.md +++ b/res/IMPORTANT/each.md @@ -12,6 +12,13 @@ - 历史消息存档、旧上下文、上轮未完成请求不属于当前输入批次;除非当前输入批次明确延续或修正它们,否则不得回溯执行。 + + **身份与对话对象识别(防误插话):** + - 先看 sender_id、@/reply、前后文对话对象和当前环境,再判断当前输入批次是不是在对你说。 + - 不要先入为主把「你」「AI」「bot」「机器人」当作在叫 Undefined;这些词只有在上下文明显指向 Undefined 时才算触发。 + - 如果是在讨论其他 AI/bot/机器人、泛泛评价技术,或无法确定话头指向 Undefined,默认不回复并调用 end。 + + **发信息前或调用任何工具前的必须判断(每次操作前强制执行):** 1. 明确本次操作的目标:将发送的消息内容 / 将调用的工具及参数 diff --git a/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index 9b966231..7db0be27 100644 --- a/res/prompts/historian_profile_merge.md +++ b/res/prompts/historian_profile_merge.md @@ -3,8 +3,9 @@ 必须遵守的硬约束: 1. 本次只允许更新目标实体:`{target_entity_type}:{target_entity_id}`。 2. `target_entity_id` 必须保持为该实体的稳定 ID,不得替换成昵称、备注名或其他文本。 -3. 当新信息不稳定、一次性、无法确认长期性时,必须跳过更新(`skip=true`)。 -4. 不得输出或暗示其他实体侧写内容。 +3. 新事件与认知观察只能来自当前输入批次;最近消息参考和历史事件只能用于消歧、判断稳定性与保留旧特征,禁止作为本轮新事实来源。 +4. 当新信息不稳定、一次性、无法确认长期性时,必须跳过更新(`skip=true`)。注意:observations 本身不要求长期稳定,但侧写只接收能沉淀为稳定画像的部分。 +5. 不得输出或暗示其他实体侧写内容。 工具使用规则(严格执行): - **修改任何侧写前,必须先调用 `read_profile` 查看其当前内容**,确认已读取后再决定是否调用 `update_profile`。 diff --git a/res/prompts/historian_rewrite.md b/res/prompts/historian_rewrite.md index 4b718171..7946d95c 100644 --- a/res/prompts/historian_rewrite.md +++ b/res/prompts/historian_rewrite.md @@ -6,9 +6,9 @@ 3. 消灭所有相对地点(这里、那边),替换为具体地点 4. 保持简洁,一两句话概括 5. `memo` 可能为空;为空时以 `observations` 和上下文为主 -6. `observations` 代表当前输入批次提取到的一条新记忆(可能是多条中的一条);若本轮包含 MessageBatcher 合并的多条消息,必须结合整批消息保证可追溯性 +6. `observations` 代表当前输入批次提取到的一条有价值新观察(可能是多条中的一条);不要求与 bot 相关,也不要求长期稳定。若本轮包含 MessageBatcher 合并的多条消息,必须结合整批消息保证可追溯性 7. 若原文已显式出现实体标识(如 `昵称(数字ID)`、`用户123456`、`QQ:123456`),必须保留该数字ID;禁止擅自替换成 `sender_id` 或其他ID -8. 可参考”当前输入批次原文”和”最近消息参考”做实体消歧;当 `observations` 与参考上下文冲突时,以可验证且更具体的信息为准 +8. 可参考”当前输入批次原文”和”最近消息参考”做实体消歧;最近消息参考只能消歧,禁止作为新事实来源。当 `observations` 与参考上下文冲突时,以当前输入批次可验证且更具体的信息为准 9. 当 `force=true` 且命中的“相对表达”属于专有名词本体(如用户名“你是谁”、片名《后天》、书名/歌名等)时,不得改写该专有名词,可保留原词直接提交;但实体 ID 一律不得漂移 称呼规则: diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 383d3c3d..37879d1d 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -1,5 +1,5 @@ - + @@ -244,15 +244,21 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - - observations:字符串数组,本轮值得长期留存的观察(写入认知记忆,不是 memory.add) + - observations:字符串数组,本轮从【当前输入批次】提取的有价值新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定 - 若存在【连续消息说明】或多段当前 ``,memo / observations 必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。 observations 应该记录两类内容: - 1. **用户/群聊事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) - 2. **有价值的自身行为**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等)——这些可以帮助未来回忆”上次帮TA做了什么” - 每条一个要点,可以多条。宁可多提取,不要遗漏。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 + 1. **当前批次直接出现的用户/群聊/第三方事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) + 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) + 每条一个要点,可以多条。当前批次中有价值即可记录,但不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。 - **群聊场景下的积极观察**:即使你决定不回复,也应积极观察群聊动态,提取有价值的信息写入 observations。注意观察:话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等。宁可多记几条,也不要遗漏有潜在价值的信息。 - 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。记录 observations 时必须包含 QQ 号,格式如:"QQ号12345678(昵称张三)做了某事"——昵称会变但 QQ 号不变。 + 历史消息、认知记忆、侧写、最近消息参考只能用于实体/时间/地点消歧,不能作为 observations 的新事实来源。 + **群聊场景下的当前批次观察**:即使你决定不回复,也只观察【当前输入批次】。如果当前批次直接出现有价值的群聊动态(话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等),可写入 observations;不要从历史或参考上下文里补写旧动态。 + 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。写入 observations / end.observations 时必须按实体类型使用稳定标识: + - 用户中心观察(sender_id 是 QQ 用户,或事实明确属于某个 QQ 用户):格式为 "QQ号12345678(昵称张三)做了某事";昵称会变但 QQ 号不变。 + - 群聊实体观察(事实属于群整体、群规、群氛围、群事件,而不是某个用户):格式为 "group:群号123456(群名技术群)发生了某事";没有群名时只写群号。 + - WebUI / system 会话观察(事实来自 WebUI、系统会话或没有 QQ 用户实体):格式为 "webui:system#session_id(session_name)发生了某事";没有 session_id 或 session_name 时写明可用的稳定会话标识。 + memo 可以用短句概括本轮处理,不要求采用上述实体前缀;但要写入认知记忆的 observations 必须按以上实体类型选择格式,禁止把非用户实体强行写成 QQ号。 + 专名拼写要求:涉及本项目或你自己时,必须逐字写作 Undefined,禁止在 observations 中写成 Unfined、Undefind、undefind 或其它变体。 若当前消息在转述第三方人物/群成员的信息,必须按原文实体记录(昵称/QQ号);禁止默认改写成当前 sender。 如果同一条内容已写入 observations,不要重复写入 memory.add。 @@ -300,6 +306,7 @@ undf, udf, und 心理委员、ud酱(偏玩笑或亲昵称呼) 常见称呼包括 Undefined、undf、udf、und、心理委员、ud酱;上下文明显是在叫你时,可以宽松理解,不必纠正称呼 + 项目名和你的主名必须逐字拼写为 Undefined。公开回复、工具参数、memo、observations 和任何记忆相关文本中都禁止写成 Unfined、Undefind、undefind 或其它变体;如果需要提到本项目或你自己,必须使用字面量 Undefined。 @@ -370,9 +377,9 @@ - 明确提到"bot"、"机器人"且语境指向你 + 明确提到"bot"、"机器人"、"AI"等泛称,且结合 sender_id、@、reply、上下文对象后能确认语境指向 Undefined 必须回复 - 注意区分:如果在讨论其他bot或@其他bot,不要回复 + 注意区分:泛称不是触发词;如果在讨论其他 bot / 其他 AI / @其他bot / 泛泛评价技术,不要回复。无法确认指向 Undefined 时默认不回复。 @@ -507,6 +514,7 @@ 在回复前,理解对话的连贯性和流向 识别和称呼用户时以 QQ 号(sender_id)为准,昵称可能随时变动。需要称呼用户时使用当前最新昵称(群名片优先,其次 QQ 昵称)。不确定最新昵称时,可调用 group.get_member_info 并设置 brief=true 快速查询。 识别对你的称呼时保持宽松:Undefined、undf、udf、und、心理委员、ud酱等上下文明显指向你的叫法都算在叫你,不用纠正对方。 + 不要先入为主把「你」「AI」「bot」「机器人」当作在叫 Undefined。必须先看 sender_id、@/reply、前后文对话对象和当前环境;只有明确指向 Undefined 时才回复,泛指或无法确定时默认沉默并调用 end。 看清发言者名字/QQ号与对话对象,确认对方在明确和你讲话才回复 **人称与对话归属(防误插话):** @@ -777,6 +785,17 @@ 如果确实需要用户补信息,直接问缺什么;如果不用补,就自然结束 + + WebUI Markdown 与 HTML 输出 + 当当前消息明确标注为【WebUI 会话】或 location="WebUI私聊" 时,用户正在 WebUI 中阅读回复。 + WebUI 私聊的身份视角固定为系统虚拟用户 system#42,权限视角固定为 superadmin;不要因为它不是 QQ 群聊或普通私聊就降低可用能力判断。 + WebUI 支持完整 Markdown 渲染,也支持简单安全 HTML;可以用标题、列表、表格、引用、代码块、链接等 Markdown 语法组织内容。 + 在 WebUI 会话中,凡是需要输出代码,优先直接在聊天回复里给出,不要默认转成文件或附件;只有用户明确要求文件交付、代码长到不适合聊天展示,或确需附件工作流时才发送文件。 + 复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放入 fenced code block,不要直接散落在正文里。 + 所有代码块都必须标明语言或类型,例如 ```python、```javascript、```html、```bash、```text;不确定语言时使用 ```text。 + 完整 HTML 页面优先使用 ```html 代码框输出,方便 WebUI 右上角运行按钮预览。 + + @@ -919,8 +938,9 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前输入批次提取值得留存的新观察(用户事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点) - 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中值得留存的信息;禁止只记录最后一条。 + 对当前输入批次提取有价值的新观察(用户/群聊/第三方事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点);不要求与 bot 相关,也不要求长期稳定 + 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中有价值的信息;禁止只记录最后一条。 + 历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 纯流水账动作(调了什么工具、决定不回复等)只写 memo,不写 end.observations 一次性闲聊、无后续价值的信息,不写入任何记忆 当你”不明白/信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围 @@ -930,10 +950,10 @@ ”memory.add: 用户A要求以后在本群用英文回复”(自我约束置顶) ”memory.add: 下周三前帮用户B完成数据迁移”(待办置顶) - ”end.observations: [“Null 喜欢用 Rust 写底层代码”]”(用户偏好 → 认知记忆) - ”end.observations: [“2026-02-20 晚上在开发群里,用户A说他下周三要发版”]”(一条一个要点) - ”end.observations: [“用户A最近在用 Rust 重写后端”]”(拆开写,不要合并) - ”end.observations: [“2026-02-24 帮用户B排查了 Redis 连接超时问题,最终定位到是连接池配置过小”]”(有价值的行为) + ”end.observations: [“QQ号1708213363(昵称Null)喜欢用 Rust 写底层代码”]”(用户偏好 → 认知记忆) + ”end.observations: [“2026-02-20 晚上在开发群里,QQ号10001(昵称用户A)说下周三要发版”]”(一条一个要点) + ”end.observations: [“QQ号10001(昵称用户A)最近在用 Rust 重写后端”]”(拆开写,不要合并) + ”end.observations: [“2026-02-24 帮QQ号10002(昵称用户B)排查了 Redis 连接超时问题,最终定位到是连接池配置过小”]”(有价值的行为) ”memo: 查了下认知记忆,没找到相关记录”(纯流水账写 memo) ”memory.add: Null 喜欢用 Rust 写底层代码”(用户偏好不该写 memory.add,应写 observations) ”end.observations: [“用户A说他下周三要发版,而且最近在用 Rust 重写后端”]”(一条塞了两个要点——应拆成两条) @@ -1327,6 +1347,7 @@ 不回复自己,不重复发言 尊重对话边界,不凑热闹 看清名字/QQ号与对话对象,只在明确被直接对话时回复 + 「你」「AI」「bot」「机器人」不是自动触发;必须结合 sender_id、@/reply 和上下文确认指向 Undefined,拿不准就沉默 认可并接受自身人设,不随对话随意改设定、不自贬为通用 AI、不临时扮演他人 无法判断是否在对你说话时,假设不在和你讲话;别人之间的对话不参与,不因「你/我」等人称误插话 对Null保持克制,不要频繁回复他的每条消息 diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 6cc7cf00..53882245 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -1,5 +1,5 @@ - + @@ -242,15 +242,17 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - - observations:字符串数组,本轮值得长期留存的观察(写入认知记忆,不是 memory.add) + - observations:字符串数组,本轮从【当前输入批次】提取的有价值新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定 - 若存在【连续消息说明】或多段当前 ``,memo / observations 必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。 observations 应该记录两类内容: - 1. **用户/群聊事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) - 2. **有价值的自身行为**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等)——这些可以帮助未来回忆”上次帮TA做了什么” - 每条一个要点,可以多条。宁可多提取,不要遗漏。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 + 1. **当前批次直接出现的用户/群聊/第三方事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) + 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) + 每条一个要点,可以多条。当前批次中有价值即可记录,但不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。 - **群聊场景下的积极观察**:即使你决定不回复,也应积极观察群聊动态,提取有价值的信息写入 observations。注意观察:话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等。宁可多记几条,也不要遗漏有潜在价值的信息。 + 历史消息、认知记忆、侧写、最近消息参考只能用于实体/时间/地点消歧,不能作为 observations 的新事实来源。 + **群聊场景下的当前批次观察**:即使你决定不回复,也只观察【当前输入批次】。如果当前批次直接出现有价值的群聊动态(话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等),可写入 observations;不要从历史或参考上下文里补写旧动态。 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。 + 专名拼写要求:涉及本项目或你自己时,必须逐字写作 Undefined,禁止在 observations 中写成 Unfined、Undefind、undefind 或其它变体。 若当前消息在转述第三方人物/群成员的信息,必须按原文实体记录(昵称/QQ号);禁止默认改写成当前 sender。 如果同一条内容已写入 observations,不要重复写入 memory.add。 @@ -298,6 +300,7 @@ undf, udf, und 心理委员、ud酱(偏玩笑或亲昵称呼) 常见称呼包括 Undefined、undf、udf、und、心理委员、ud酱;上下文明显是在叫你时,可以宽松理解,不必纠正称呼 + 项目名和你的主名必须逐字拼写为 Undefined。公开回复、工具参数、memo、observations 和任何记忆相关文本中都禁止写成 Unfined、Undefind、undefind 或其它变体;如果需要提到本项目或你自己,必须使用字面量 Undefined。 @@ -369,9 +372,9 @@ - 明确提到"bot"、"机器人"且语境指向你 + 明确提到"bot"、"机器人"、"AI"等泛称,且结合 sender_id、@、reply、上下文对象后能确认语境指向 Undefined 必须回复 - 注意区分:如果在讨论其他bot或@其他bot,不要回复 + 注意区分:泛称不是触发词;如果在讨论其他 bot / 其他 AI / @其他bot / 泛泛评价技术,不要回复。无法确认指向 Undefined 时默认不回复。 @@ -528,7 +531,13 @@ - 对于任何涉及 NagaAgent 的技术问题,**必须先调用 naga_code_analysis_agent 获取准确信息后再回复**。 + + 对于当前输入批次中任何明确涉及 NagaAgent 项目的技术问题,**必须先调用 naga_code_analysis_agent 获取准确信息后再回复**。 + 这是一条强制路由规则:不得凭自身记忆、历史印象、常识、旧上下文或用户提供的片段直接回答 NagaAgent 技术问题。 + 必须调用的工具/Agent 名称就是 `naga_code_analysis_agent`;不要改用 web_agent、file_analysis_agent、普通搜索、直接读文件工具或你自己的推测替代。 + 如果问题已经明确到模块、功能、报错、配置、部署、API、OpenClaw、前端、后端、技能、干员、任务调度、记忆、集成方式等任一技术对象,就先调用 `naga_code_analysis_agent`。 + 如果问题过于宽泛且缺少关键对象(例如只说“帮我看下 NagaAgent 为什么不对”),不要把模糊问题硬丢给 agent;先用 send_message 追问具体模块 / 报错 / 现象 / 目标,待范围收窄后再调用 `naga_code_analysis_agent`。 + 不要依赖自身记忆或猜测来回答 NagaAgent 相关问题——该项目代码频繁更新,只有通过 agent 实时查阅才能保证准确。 该 Agent 内部拥有自己的工具集(read_naga_intro、read_file、search_file_content 等), 这些内部工具你无法直接调用,你只需要调用 naga_code_analysis_agent 即可。 @@ -540,7 +549,7 @@ - 用户想了解 NagaAgent 的架构、代码逻辑、技能系统等 - 用户提到 NagaAgent 的任何技术细节(API、openclaw、干员、技能等) - 讨论涉及 NagaAgent 与其他系统的集成或对比 - 只有纯闲聊式提及(如"naga好用吗"这类不需要技术细节的对话)才可以不调用。 + 只有纯闲聊式提及且不需要事实/技术细节(如“naga这个名字挺可爱”)才可以不调用;只要需要回答事实、实现、使用方法、排错或判断,就必须调用。 @@ -553,6 +562,7 @@ 在回复前,理解对话的连贯性和流向 识别和称呼用户时以 QQ 号(sender_id)为准,昵称可能随时变动。需要称呼用户时使用当前最新昵称(群名片优先,其次 QQ 昵称)。不确定最新昵称时,可调用 group.get_member_info 并设置 brief=true 快速查询。 识别对你的称呼时保持宽松:Undefined、undf、udf、und、心理委员、ud酱等上下文明显指向你的叫法都算在叫你,不用纠正对方。 + 不要先入为主把「你」「AI」「bot」「机器人」当作在叫 Undefined。必须先看 sender_id、@/reply、前后文对话对象和当前环境;只有明确指向 Undefined 时才回复,泛指或无法确定时默认沉默并调用 end。 看清发言者名字/QQ号与对话对象,确认对方在明确和你讲话才回复 **人称与对话归属(防误插话):** @@ -825,6 +835,17 @@ 如果确实需要用户补信息,直接问缺什么;如果不用补,就自然结束 + + WebUI Markdown 与 HTML 输出 + 当当前消息明确标注为【WebUI 会话】或 location="WebUI私聊" 时,用户正在 WebUI 中阅读回复。 + WebUI 私聊的身份视角固定为系统虚拟用户 system#42,权限视角固定为 superadmin;不要因为它不是 QQ 群聊或普通私聊就降低可用能力判断。 + WebUI 支持完整 Markdown 渲染,也支持简单安全 HTML;可以用标题、列表、表格、引用、代码块、链接等 Markdown 语法组织内容。 + 在 WebUI 会话中,凡是需要输出代码,优先直接在聊天回复里给出,不要默认转成文件或附件;只有用户明确要求文件交付、代码长到不适合聊天展示,或确需附件工作流时才发送文件。 + 复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放入 fenced code block,不要直接散落在正文里。 + 所有代码块都必须标明语言或类型,例如 ```python、```javascript、```html、```bash、```text;不确定语言时使用 ```text。 + 完整 HTML 页面优先使用 ```html 代码框输出,方便 WebUI 右上角运行按钮预览。 + + @@ -968,8 +989,9 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前输入批次提取值得留存的新观察(用户事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点) - 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中值得留存的信息;禁止只记录最后一条。 + 对当前输入批次提取有价值的新观察(用户/群聊/第三方事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点);不要求与 bot 相关,也不要求长期稳定 + 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中有价值的信息;禁止只记录最后一条。 + 历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 纯流水账动作(调了什么工具、决定不回复等)只写 memo,不写 end.observations 一次性闲聊、无后续价值的信息,不写入任何记忆 当你”不明白/信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围 @@ -979,10 +1001,10 @@ ”memory.add: 用户A要求以后在本群用英文回复”(自我约束置顶) ”memory.add: 下周三前帮用户B完成数据迁移”(待办置顶) - ”end.observations: [“Null 喜欢用 Rust 写底层代码”]”(用户偏好 → 认知记忆) - ”end.observations: [“2026-02-20 晚上在开发群里,用户A说他下周三要发版”]”(一条一个要点) - ”end.observations: [“用户A最近在用 Rust 重写后端”]”(拆开写,不要合并) - ”end.observations: [“2026-02-24 帮用户B排查了 Redis 连接超时问题,最终定位到是连接池配置过小”]”(有价值的行为) + ”end.observations: [“QQ号1708213363(昵称Null)喜欢用 Rust 写底层代码”]”(用户偏好 → 认知记忆) + ”end.observations: [“2026-02-20 晚上在开发群里,QQ号10001(昵称用户A)说下周三要发版”]”(一条一个要点) + ”end.observations: [“QQ号10001(昵称用户A)最近在用 Rust 重写后端”]”(拆开写,不要合并) + ”end.observations: [“2026-02-24 帮QQ号10002(昵称用户B)排查了 Redis 连接超时问题,最终定位到是连接池配置过小”]”(有价值的行为) ”memo: 查了下认知记忆,没找到相关记录”(纯流水账写 memo) ”memory.add: Null 喜欢用 Rust 写底层代码”(用户偏好不该写 memory.add,应写 observations) ”end.observations: [“用户A说他下周三要发版,而且最近在用 Rust 重写后端”]”(一条塞了两个要点——应拆成两条) @@ -1389,6 +1411,7 @@ 不回复自己,不重复发言 尊重对话边界,不凑热闹 看清名字/QQ号与对话对象,只在明确被直接对话时回复 + 「你」「AI」「bot」「机器人」不是自动触发;必须结合 sender_id、@/reply 和上下文确认指向 Undefined,拿不准就沉默 认可并接受自身人设,不随对话随意改设定、不自贬为通用 AI、不临时扮演他人 无法判断是否在对你说话时,假设不在和你讲话;别人之间的对话不参与,不因「你/我」等人称误插话 对Null保持克制,不要频繁回复他的每条消息 diff --git a/src/Undefined/ai/client/ask_loop.py b/src/Undefined/ai/client/ask_loop.py index bd75f01b..52a3f5de 100644 --- a/src/Undefined/ai/client/ask_loop.py +++ b/src/Undefined/ai/client/ask_loop.py @@ -15,13 +15,49 @@ from Undefined.ai.transports.openai_transport import RESPONSES_OUTPUT_ITEMS_KEY from Undefined.ai.tooling import END_CO_CALL_REJECT_CONTENT from Undefined.context import RequestContext +from Undefined.render import render_html_to_image, render_markdown_to_html from Undefined.services.message_summary_fetch import fetch_session_messages +from Undefined.attachments import scope_from_context +from Undefined.utils.io import write_bytes from Undefined.utils.logging import log_debug_json, redact_string +from Undefined.utils.message_turn import mark_message_sent_this_turn +from Undefined.utils.paths import DOWNLOAD_CACHE_DIR, ensure_dir from Undefined.utils.tool_calls import parse_tool_arguments logger = logging.getLogger(__name__) +def _webchat_agent_path(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + +def _webchat_depth(value: Any) -> int: + try: + return max(0, int(value)) + except (TypeError, ValueError): + return 0 + + +def _webchat_call_id(parent_call_id: str, call_id: str, fallback: str) -> str: + local_id = str(call_id or fallback or "tool").strip() or "tool" + return f"{parent_call_id}/{local_id}" if parent_call_id else local_id + + +async def _emit_webchat_event_safely( + callback: Callable[[str, dict[str, Any]], Awaitable[None]] | None, + event: str, + payload: dict[str, Any], +) -> None: + if callback is None: + return + try: + await callback(event, payload) + except Exception: + logger.exception("[WebChat事件] 回调发送失败: event=%s", event) + + class ClientAskLoopMixin(ClientQueueMixin): """``ask()`` 多轮工具调用主循环。""" @@ -77,13 +113,28 @@ async def ask( pre_context["request_id"] = ctx.request_id if extra_context: pre_context.update(extra_context) + webchat_event_callback = pre_context.get("webchat_event_callback") + if not callable(webchat_event_callback): + webchat_event_callback = None + + async def emit_webchat_stage(stage: str, detail: Any | None = None) -> None: + payload: dict[str, Any] = {"stage": stage} + if detail is not None: + payload["detail"] = detail + await _emit_webchat_event_safely( + webchat_event_callback, + "stage", + payload, + ) # ===== 阶段二:构建 LLM messages 与 OpenAI tools schema ===== + await emit_webchat_stage("building_context") messages = await self._prompt_builder.build_messages( question, get_recent_messages_callback=get_recent_messages_callback, extra_context=extra_context, ) + await emit_webchat_stage("context_ready") tools = self.tool_manager.get_openai_tools() tools = self._filter_tools_for_runtime_config(tools) @@ -128,6 +179,9 @@ async def ask( ) tool_context.setdefault("end_summary_storage", self._end_summary_storage) tool_context.setdefault("end_summaries", self._prompt_builder.end_summaries) + tool_context.setdefault("webchat_parent_call_id", "") + tool_context.setdefault("webchat_depth", 0) + tool_context.setdefault("webchat_agent_path", []) tool_context.setdefault( "send_private_message_callback", self._send_private_message_callback ) @@ -162,11 +216,21 @@ async def fetch_session_messages_callback( tool_context.setdefault("history_manager", history_manager) tool_context.setdefault("onebot_client", onebot_client) tool_context.setdefault("scheduler", scheduler) + tool_context.setdefault("render_html_to_image", render_html_to_image) + tool_context.setdefault("render_markdown_to_html", render_markdown_to_html) tool_context.setdefault("send_image_callback", self._send_image_callback) tool_context.setdefault( "attachment_registry", getattr(self, "attachment_registry", None), ) + tool_context.setdefault("get_scope_from_context", scope_from_context) + tool_context.setdefault("download_cache_dir", DOWNLOAD_CACHE_DIR) + tool_context.setdefault("ensure_dir_fn", ensure_dir) + tool_context.setdefault("write_bytes_fn", write_bytes) + tool_context.setdefault( + "mark_message_sent_this_turn", + mark_message_sent_this_turn, + ) tool_context.setdefault("memory_storage", self.memory_storage) tool_context.setdefault("knowledge_manager", self._knowledge_manager) tool_context.setdefault("cognitive_service", self._cognitive_service) @@ -183,6 +247,7 @@ async def fetch_session_messages_callback( message_ids.append(trigger_message_id_text) # ===== 阶段四:模型选择、思维链/重试参数与主循环状态初始化 ===== + await emit_webchat_stage("selecting_model") await self.model_selector.wait_ready() selected_model_name = pre_context.get("selected_model_name") if selected_model_name: @@ -205,6 +270,14 @@ async def fetch_session_messages_callback( missing_tool_call_count = 0 last_missing_tool_call_content = "" runtime_config = self._get_runtime_config() + agent_registry = getattr(self, "agent_registry", None) + get_agent_schemas = getattr(agent_registry, "get_agents_schema", None) + raw_agent_schemas = get_agent_schemas() if callable(get_agent_schemas) else [] + agent_tool_names = { + str(schema.get("function", {}).get("name") or "") + for schema in raw_agent_schemas + if isinstance(schema, dict) + } max_pre_tool_retries = max( 0, int(getattr(runtime_config, "ai_request_max_retries", 0) or 0), @@ -223,6 +296,10 @@ async def fetch_session_messages_callback( tool_execution_started = False try: + await emit_webchat_stage( + "waiting_model", + f"iteration={iteration} model={effective_chat_config.model_name}", + ) result = await self.submit_queued_llm_call( model_config=effective_chat_config, messages=messages, @@ -311,6 +388,7 @@ async def fetch_session_messages_callback( # 无 tool_calls 与有 tool_calls 走不同分支 if not tool_calls: if conversation_ended: + await emit_webchat_stage("finalizing") logger.info( "[AI回复] 会话结束,返回最终内容: length=%s", len(content), @@ -332,6 +410,7 @@ async def fetch_session_messages_callback( fallback_content = last_missing_tool_call_content if fallback_content and send_message_callback is not None: try: + await emit_webchat_stage("sending_message") await send_message_callback(fallback_content) tool_context["message_sent_this_turn"] = True current_ctx = RequestContext.current() @@ -366,6 +445,7 @@ async def fetch_session_messages_callback( ) continue + await emit_webchat_stage("preparing_tools", len(tool_calls)) assistant_message: dict[str, Any] = { "role": "assistant", "content": content, @@ -383,12 +463,13 @@ async def fetch_session_messages_callback( assistant_message["reasoning_content"] = reasoning_content messages.append(assistant_message) - tool_tasks = [] + tool_tasks: list[asyncio.Task[Any]] = [] tool_call_ids = [] tool_api_names: list[str] = [] tool_internal_names: list[str] = [] end_tool_call: dict[str, Any] | None = None end_tool_args: dict[str, Any] = {} + end_webchat_event_base: dict[str, Any] = {} tool_results: list[Any] = [] # 逐个处理模型返回的 tool_call @@ -446,6 +527,37 @@ async def fetch_session_messages_callback( if not isinstance(function_args, dict): function_args = {} + is_agent_call = internal_function_name in agent_tool_names + webchat_parent_call_id = str( + tool_context.get("webchat_parent_call_id") or "" + ).strip() + webchat_depth = _webchat_depth(tool_context.get("webchat_depth")) + webchat_agent_path = _webchat_agent_path( + tool_context.get("webchat_agent_path") + ) + webchat_call_id = _webchat_call_id( + webchat_parent_call_id, + call_id, + internal_function_name, + ) + webchat_event_base: dict[str, Any] = { + "webchat_call_id": webchat_call_id, + "parent_webchat_call_id": webchat_parent_call_id, + "depth": webchat_depth, + "agent_path": webchat_agent_path, + } + await _emit_webchat_event_safely( + webchat_event_callback, + "tool_start", + { + "tool_call_id": call_id, + "name": internal_function_name, + "api_name": api_function_name, + "arguments": function_args, + "is_agent": is_agent_call, + **webchat_event_base, + }, + ) # 检测 end 工具,暂存后统一处理 if internal_function_name == "end": @@ -457,14 +569,77 @@ async def fetch_session_messages_callback( ) end_tool_call = tool_call end_tool_args = function_args + end_webchat_event_base = webchat_event_base continue tool_call_ids.append(call_id) tool_api_names.append(str(api_function_name)) tool_internal_names.append(str(internal_function_name)) + call_context = tool_context.copy() + if is_agent_call: + call_context["webchat_parent_call_id"] = webchat_call_id + call_context["webchat_call_parent_id"] = webchat_parent_call_id + call_context["webchat_depth"] = webchat_depth + 1 + call_context["webchat_agent_path"] = [ + *webchat_agent_path, + internal_function_name, + ] + + async def _execute_tool_with_webchat_event( + *, + call_id: str, + api_name: str, + internal_name: str, + args: dict[str, Any], + context: dict[str, Any], + webchat_event_base: dict[str, Any], + is_agent_call: bool, + ) -> Any: + try: + result = await self.tool_manager.execute_tool( + internal_name, args, context + ) + except Exception as exc: + await _emit_webchat_event_safely( + webchat_event_callback, + "tool_end", + { + "tool_call_id": call_id, + "name": internal_name, + "api_name": api_name, + "ok": False, + "result": f"执行失败: {str(exc)}", + "is_agent": is_agent_call, + **webchat_event_base, + }, + ) + raise + await _emit_webchat_event_safely( + webchat_event_callback, + "tool_end", + { + "tool_call_id": call_id, + "name": internal_name, + "api_name": api_name, + "ok": True, + "result": str(result), + "is_agent": is_agent_call, + **webchat_event_base, + }, + ) + return result + tool_tasks.append( - self.tool_manager.execute_tool( - str(internal_function_name), function_args, tool_context + asyncio.create_task( + _execute_tool_with_webchat_event( + call_id=call_id, + api_name=str(api_function_name), + internal_name=str(internal_function_name), + args=function_args, + context=call_context, + webchat_event_base=webchat_event_base, + is_agent_call=is_agent_call, + ) ) ) @@ -475,6 +650,9 @@ async def fetch_session_messages_callback( len(tool_tasks), ", ".join(tool_internal_names), ) + await emit_webchat_stage( + "waiting_tools", ", ".join(tool_internal_names) + ) tool_results = await asyncio.gather( *tool_tasks, return_exceptions=True, @@ -507,7 +685,6 @@ async def fetch_session_messages_callback( f"[工具响应体] {internal_fname} (ID={call_id})", content_str, ) - messages.append( { "role": "tool", @@ -544,6 +721,19 @@ async def fetch_session_messages_callback( end_call_id = end_tool_call.get("id", "") end_api_name = end_tool_call.get("function", {}).get("name", "end") if tool_tasks: + if webchat_event_callback is not None: + await webchat_event_callback( + "tool_end", + { + "tool_call_id": end_call_id, + "name": "end", + "api_name": end_api_name, + "ok": False, + "result": END_CO_CALL_REJECT_CONTENT, + "is_agent": False, + **end_webchat_event_base, + }, + ) messages.append( { "role": "tool", @@ -559,15 +749,36 @@ async def fetch_session_messages_callback( else: # end 单独调用,正常执行(参数已在循环中解析) tool_execution_started = True - end_result = await self.tool_manager.execute_tool( - "end", end_tool_args, tool_context - ) + await emit_webchat_stage("waiting_tools", "end") + try: + end_result_raw = await self.tool_manager.execute_tool( + "end", end_tool_args, tool_context + ) + end_result = str(end_result_raw) + end_ok = True + except Exception as exc: + logger.exception("[工具异常] end 执行抛出异常: %s", exc) + end_result = f"执行失败: {str(exc)}" + end_ok = False + if webchat_event_callback is not None: + await webchat_event_callback( + "tool_end", + { + "tool_call_id": end_call_id, + "name": "end", + "api_name": end_api_name, + "ok": end_ok, + "result": end_result, + "is_agent": False, + **end_webchat_event_base, + }, + ) messages.append( { "role": "tool", "tool_call_id": end_call_id, "name": end_api_name, - "content": str(end_result), + "content": end_result, } ) # 会话是否已由 end 工具标记结束 @@ -577,6 +788,7 @@ async def fetch_session_messages_callback( # 会话是否已由 end 工具标记结束 if conversation_ended: + await emit_webchat_stage("finalizing") logger.info("[会话状态] 对话已结束(调用 end 工具)") return "" pre_tool_failure_count = 0 @@ -598,6 +810,7 @@ async def fetch_session_messages_callback( iteration, exc, ) + await emit_webchat_stage("retrying_model", str(exc)) continue logger.exception( "[chat.suppressed_error] model=%s lane=%s iteration=%s error=%s", diff --git a/src/Undefined/ai/llm/requester.py b/src/Undefined/ai/llm/requester.py index 10329d0e..90ad9725 100644 --- a/src/Undefined/ai/llm/requester.py +++ b/src/Undefined/ai/llm/requester.py @@ -472,7 +472,10 @@ async def request( log_debug_json(logger, "[API请求体]", request_body) try: - raw_result = await self._request_with_openai(model_config, request_body) + raw_result = await self._request_with_openai( + model_config, + request_body, + ) except APIStatusError as exc: # Responses 续轮失败:自动切换 stateless replay 重发全量 input if ( @@ -507,7 +510,8 @@ async def request( logger, "[API请求体][stateless replay]", request_body ) raw_result = await self._request_with_openai( - model_config, request_body + model_config, + request_body, ) else: raise @@ -666,7 +670,9 @@ def _maybe_log_thinking( ) async def _request_with_openai( - self, model_config: ModelConfig, request_body: dict[str, Any] + self, + model_config: ModelConfig, + request_body: dict[str, Any], ) -> dict[str, Any]: client = self._get_openai_client_for_model(model_config) if bool(getattr(model_config, "stream_enabled", False)): @@ -710,7 +716,10 @@ async def _request_with_openai_streaming( stream_body = dict(request_body) stream_body["stream"] = True if api_mode == API_MODE_RESPONSES: - return await self._stream_responses_request(client, stream_body) + return await self._stream_responses_request( + client, + stream_body, + ) ensure_chat_stream_usage_options(stream_body) return await self._stream_chat_completions_request( # client, stream_body, model_config @@ -735,14 +744,17 @@ async def _stream_chat_completions_request( ) chunks: list[dict[str, Any]] = [] async for chunk in response: - chunks.append(self._response_to_dict(chunk)) + chunk_dict = self._response_to_dict(chunk) + chunks.append(chunk_dict) return aggregate_chat_completions_stream( chunks, reasoning_replay=reasoning_replay, ) async def _stream_responses_request( - self, client: AsyncOpenAI, request_body: dict[str, Any] + self, + client: AsyncOpenAI, + request_body: dict[str, Any], ) -> dict[str, Any]: params, extra_body = split_responses_params(request_body) if extra_body: @@ -751,7 +763,8 @@ async def _stream_responses_request( events: list[dict[str, Any]] = [] async for event in stream: - events.append(self._response_to_dict(event)) + event_dict = self._response_to_dict(event) + events.append(event_dict) return aggregate_responses_stream(events) async def embed( diff --git a/src/Undefined/ai/prompts/builder.py b/src/Undefined/ai/prompts/builder.py index 57984071..7ddca320 100644 --- a/src/Undefined/ai/prompts/builder.py +++ b/src/Undefined/ai/prompts/builder.py @@ -33,6 +33,18 @@ logger = logging.getLogger(__name__) +def _is_display_only_history_record(msg: dict[str, Any]) -> bool: + if str(msg.get("message", "") or "").strip(): + return False + webchat = msg.get("webchat") + if not isinstance(webchat, dict): + return False + events = webchat.get("events") + return ( + bool(webchat.get("display_only")) and isinstance(events, list) and bool(events) + ) + + class PromptBuilder: """Prompt 构建器。 @@ -122,6 +134,19 @@ async def _load_system_prompt(self) -> str: async with aiofiles.open(system_prompt_path, "r", encoding="utf-8") as f: return await f.read() + @staticmethod + def _format_current_input_batch(question: str) -> str: + """Format the only live user input block for this turn.""" + return ( + "【当前输入批次】\n" + "\n" + f"{question}\n" + "\n\n" + "注意:以上才是本轮正在发生、允许你回应和写入 end.observations 的当前输入。" + "历史消息、认知记忆、侧写、短期行动记录和系统说明都只是只读背景," + "只能用于消歧、防重复和理解上下文,不能作为 end.observations 的新事实来源。" + ) + async def build_messages( self, question: str, @@ -141,6 +166,22 @@ async def build_messages( 返回: 构建好的消息列表 (role/content 结构) """ + webchat_event_callback = ( + extra_context.get("webchat_event_callback") + if isinstance(extra_context, dict) + else None + ) + if not callable(webchat_event_callback): + webchat_event_callback = None + + async def emit_webchat_stage(stage: str, detail: Any | None = None) -> None: + if webchat_event_callback is None: + return + payload: dict[str, Any] = {"stage": stage} + if detail is not None: + payload["detail"] = detail + await webchat_event_callback("stage", payload) + system_prompt = await self._load_system_prompt() logger.debug( "[Prompt] system_prompt_len=%s path=%s", @@ -277,9 +318,10 @@ async def build_messages( ) deferred_messages: list[dict[str, Any]] = [] - # 长期记忆 / 认知 / end 摘要 / 历史等延迟注入块(排在主 system 之后) + # 缓存友好:固定/低频系统块排在前面;按轮变化的记忆、认知、摘要、历史延迟注入。 if self._memory_storage: + await emit_webchat_stage("checking_long_term_memory") memories = self._memory_storage.get_all() if memories: memory_lines = [f"- {mem.fact}" for mem in memories] @@ -367,6 +409,7 @@ async def build_messages( resolved_user_id or "", resolved_sender_id or "", ) + await emit_webchat_stage("searching_cognitive_memory") cognitive_context = await self._cognitive_service.build_context( query=cognitive_query, group_id=resolved_group_id, @@ -462,6 +505,7 @@ async def build_messages( ) if get_recent_messages_callback: + await emit_webchat_stage("loading_chat_history") await self._inject_recent_messages( deferred_messages, get_recent_messages_callback, extra_context, question ) @@ -477,7 +521,9 @@ async def build_messages( } ) - messages.append({"role": "user", "content": f"【当前消息】\n{question}"}) + messages.append( + {"role": "user", "content": self._format_current_input_batch(question)} + ) logger.debug( "[Prompt] messages_ready=%s question_len=%s", len(messages), @@ -570,6 +616,9 @@ async def _inject_recent_messages( recent_limit, ) recent_msgs = drop_current_message_if_duplicated(recent_msgs, question) + recent_msgs = [ + msg for msg in recent_msgs if not _is_display_only_history_record(msg) + ] context_lines: list[str] = [format_message_xml(msg) for msg in recent_msgs] formatted_context = "\n---\n".join(context_lines) @@ -577,11 +626,15 @@ async def _inject_recent_messages( if formatted_context: messages.append( { - "role": "user", + "role": "system", "content": ( - "【历史消息存档】\n" - f"{formatted_context}\n\n" - "注意:以上是之前的聊天记录,用于提供背景信息。每个消息之间使用 --- 分隔。接下来的用户消息才是当前正在发生的对话。" + "【历史消息存档】(只读上下文)\n" + "以下是之前的聊天记录,仅用于背景理解、实体消歧和防重复检查。" + "它们不属于当前输入批次,不是新请求,也不能作为 end.observations 的新事实来源。\n" + '\n' + f"{formatted_context}\n" + "\n\n" + "注意:每个历史消息之间使用 --- 分隔;后续单独的当前输入块才是本轮正在发生的对话。" ), } ) diff --git a/src/Undefined/ai/prompts/cognitive.py b/src/Undefined/ai/prompts/cognitive.py index bd799ee0..8159f9dd 100644 --- a/src/Undefined/ai/prompts/cognitive.py +++ b/src/Undefined/ai/prompts/cognitive.py @@ -2,15 +2,18 @@ from __future__ import annotations -import html import logging from typing import Any from Undefined.ai.prompts.constants import ( COGNITIVE_CONTEXT_VALUE_MAX_LEN, COGNITIVE_QUERY_SHORT_THRESHOLD, - CURRENT_MESSAGE_RE, - XML_ATTR_RE, +) +from Undefined.ai.prompts.current_input import ( + build_current_input_query_text, + drop_current_input_batch_if_duplicated, + extract_current_message_signature, + extract_current_message_signatures, ) logger = logging.getLogger(__name__) @@ -24,40 +27,16 @@ def normalize_cognitive_context_value(value: Any) -> str: return text[: COGNITIVE_CONTEXT_VALUE_MAX_LEN - 3].rstrip() + "..." -def extract_current_message_signature(question: str) -> dict[str, str]: - """从当前消息 XML 中提取 sender/time/content 签名。""" - matched = CURRENT_MESSAGE_RE.search(str(question or "")) - if not matched: - return {} - - attrs_text = str(matched.group("attrs") or "") - attrs: dict[str, str] = {} - for attr_match in XML_ATTR_RE.finditer(attrs_text): - key = str(attr_match.group("key") or "").strip() - if not key: - continue - attrs[key] = html.unescape(str(attr_match.group("value") or "")).strip() - - content = html.unescape(str(matched.group("content") or "")).strip() - return { - "sender_id": attrs.get("sender_id", ""), - "timestamp": attrs.get("time", ""), - "content": content, - } - - def build_cognitive_query( question: str, extra_context: dict[str, Any] | None = None ) -> tuple[str, bool]: """构建认知记忆检索 query,短消息时追加少量会话语境。""" question_text = str(question or "").strip() - signature = extract_current_message_signature(question_text) - current_content = str(signature.get("content", "")).strip() - base_query = current_content or question_text + base_query, from_current_messages = build_current_input_query_text(question_text) if not base_query: return "", False - if not current_content or len(current_content) > COGNITIVE_QUERY_SHORT_THRESHOLD: + if not from_current_messages or len(base_query) > COGNITIVE_QUERY_SHORT_THRESHOLD: return base_query, False # 短消息检索质量差,追加轻量会话语境提升向量召回 @@ -90,48 +69,20 @@ def build_cognitive_query( def drop_current_message_if_duplicated( recent_msgs: list[dict[str, Any]], question: str ) -> list[dict[str, Any]]: - """若历史末尾与当前帧重复,则剔除最后一条避免双重注入。""" - if not recent_msgs: - return recent_msgs - - signature = extract_current_message_signature(question) - if not signature: - return recent_msgs - - last_msg = recent_msgs[-1] - last_sender_id = str(last_msg.get("user_id", "")).strip() - last_timestamp = str(last_msg.get("timestamp", "")).strip() - last_content = str(last_msg.get("message", "")).strip() - - sig_sender_id = str(signature.get("sender_id", "")).strip() - sig_timestamp = str(signature.get("timestamp", "")).strip() - sig_content = str(signature.get("content", "")).strip() - if not sig_sender_id or not sig_content: - return recent_msgs - - if last_sender_id != sig_sender_id: - return recent_msgs - if last_content != sig_content: - return recent_msgs - - if sig_timestamp and last_timestamp and sig_timestamp != last_timestamp: - # 秒级时间戳不一致时,比较到分钟粒度,避免格式差异误杀 - if sig_timestamp[:16] != last_timestamp[:16]: - return recent_msgs - - logger.info( - "[Prompt] 历史注入剔除当前帧: sender=%s sig_time=%s history_time=%s content_preview=%s", - sig_sender_id, - sig_timestamp, - last_timestamp, - sig_content[:60], - ) - return recent_msgs[:-1] + """若历史末尾与当前输入批次重复,则整批剔除避免双重注入。""" + filtered, dropped = drop_current_input_batch_if_duplicated(recent_msgs, question) + if dropped: + logger.info( + "[Prompt] 历史注入剔除当前输入批次重复消息: count=%s", + dropped, + ) + return filtered __all__ = [ "build_cognitive_query", "drop_current_message_if_duplicated", "extract_current_message_signature", + "extract_current_message_signatures", "normalize_cognitive_context_value", ] diff --git a/src/Undefined/ai/prompts/current_input.py b/src/Undefined/ai/prompts/current_input.py new file mode 100644 index 00000000..26ae6ea5 --- /dev/null +++ b/src/Undefined/ai/prompts/current_input.py @@ -0,0 +1,129 @@ +"""Helpers for parsing the current input batch from prompt XML.""" + +from __future__ import annotations + +from dataclasses import dataclass +import html +from typing import Any + +from Undefined.ai.prompts.constants import CURRENT_MESSAGE_RE, XML_ATTR_RE + + +@dataclass(frozen=True) +class CurrentMessageSignature: + """Stable identity for one current ```` block.""" + + sender_id: str + timestamp: str + content: str + message_id: str = "" + + +def _parse_attrs(attrs_text: str) -> dict[str, str]: + attrs: dict[str, str] = {} + for attr_match in XML_ATTR_RE.finditer(attrs_text): + key = str(attr_match.group("key") or "").strip() + if not key: + continue + attrs[key] = html.unescape(str(attr_match.group("value") or "")).strip() + return attrs + + +def extract_current_message_signatures( + question: str, +) -> list[CurrentMessageSignature]: + """Extract all current ```` signatures from prompt text.""" + signatures: list[CurrentMessageSignature] = [] + for matched in CURRENT_MESSAGE_RE.finditer(str(question or "")): + attrs = _parse_attrs(str(matched.group("attrs") or "")) + content = html.unescape(str(matched.group("content") or "")).strip() + signatures.append( + CurrentMessageSignature( + sender_id=attrs.get("sender_id", ""), + timestamp=attrs.get("time", ""), + content=content, + message_id=attrs.get("message_id", ""), + ) + ) + return signatures + + +def extract_current_message_signature(question: str) -> dict[str, str]: + """Compatibility helper returning the first current message signature.""" + signatures = extract_current_message_signatures(question) + if not signatures: + return {} + first = signatures[0] + return { + "sender_id": first.sender_id, + "timestamp": first.timestamp, + "content": first.content, + "message_id": first.message_id, + } + + +def build_current_input_query_text(question: str) -> tuple[str, bool]: + """Return query text from the full current input batch. + + The boolean indicates whether the query came from explicit ```` + content instead of falling back to the raw question text. + """ + signatures = extract_current_message_signatures(question) + contents = [sig.content for sig in signatures if sig.content] + if contents: + return "\n".join(contents), True + return str(question or "").strip(), False + + +def _history_msg_matches_signature( + msg: dict[str, Any], signature: CurrentMessageSignature +) -> bool: + sig_sender_id = signature.sender_id.strip() + sig_content = signature.content.strip() + if not sig_sender_id or not sig_content: + return False + + history_message_id = str(msg.get("message_id", "") or "").strip() + if signature.message_id and history_message_id: + return history_message_id == signature.message_id + + last_sender_id = str(msg.get("user_id", "") or "").strip() + last_content = str(msg.get("message", "") or "").strip() + if last_sender_id != sig_sender_id or last_content != sig_content: + return False + + sig_timestamp = signature.timestamp.strip() + last_timestamp = str(msg.get("timestamp", "") or "").strip() + if sig_timestamp and last_timestamp and sig_timestamp != last_timestamp: + # 秒级时间戳不一致时,比较到分钟粒度,避免格式差异误杀。 + return sig_timestamp[:16] == last_timestamp[:16] + return True + + +def drop_current_input_batch_if_duplicated( + recent_msgs: list[dict[str, Any]], question: str +) -> tuple[list[dict[str, Any]], int]: + """Drop trailing history records that duplicate the whole current batch.""" + signatures = extract_current_message_signatures(question) + if not recent_msgs or not signatures: + return recent_msgs, 0 + + remaining = list(recent_msgs) + dropped = 0 + cursor = len(signatures) - 1 + while remaining and cursor >= 0: + if not _history_msg_matches_signature(remaining[-1], signatures[cursor]): + break + remaining.pop() + dropped += 1 + cursor -= 1 + return remaining, dropped + + +__all__ = [ + "CurrentMessageSignature", + "build_current_input_query_text", + "drop_current_input_batch_if_duplicated", + "extract_current_message_signature", + "extract_current_message_signatures", +] diff --git a/src/Undefined/ai/tooling.py b/src/Undefined/ai/tooling.py index bcc88cce..58fcaaa0 100644 --- a/src/Undefined/ai/tooling.py +++ b/src/Undefined/ai/tooling.py @@ -9,10 +9,14 @@ from typing import Any from Undefined.context import RequestContext +from Undefined.attachments import scope_from_context from Undefined.skills.agents import AgentRegistry from Undefined.skills.anthropic_skills import AnthropicSkillRegistry from Undefined.skills.tools import ToolRegistry +from Undefined.utils.io import write_bytes from Undefined.utils.logging import log_debug_json, redact_string +from Undefined.utils.message_turn import mark_message_sent_this_turn +from Undefined.utils.paths import DOWNLOAD_CACHE_DIR, ensure_dir logger = logging.getLogger(__name__) @@ -208,6 +212,12 @@ async def execute_tool( context.setdefault("request_type", ctx.request_type) context.setdefault("request_id", ctx.request_id) + context.setdefault("get_scope_from_context", scope_from_context) + context.setdefault("download_cache_dir", DOWNLOAD_CACHE_DIR) + context.setdefault("ensure_dir_fn", ensure_dir) + context.setdefault("write_bytes_fn", write_bytes) + context.setdefault("mark_message_sent_this_turn", mark_message_sent_this_turn) + agents_schema = self.agent_registry.get_agents_schema() agent_names = [s.get("function", {}).get("name") for s in agents_schema] is_agent = function_name in agent_names diff --git a/src/Undefined/api/_helpers.py b/src/Undefined/api/_helpers.py index 0ae579a5..6cc736ce 100644 --- a/src/Undefined/api/_helpers.py +++ b/src/Undefined/api/_helpers.py @@ -205,9 +205,12 @@ def _build_chat_response_payload(mode: str, outputs: list[str]) -> dict[str, Any } -def _sse_event(event: str, payload: dict[str, Any]) -> bytes: +def _sse_event( + event: str, payload: dict[str, Any], event_id: int | str | None = None +) -> bytes: data = json.dumps(payload, ensure_ascii=False) - return f"event: {event}\ndata: {data}\n\n".encode("utf-8") + id_line = f"id: {event_id}\n" if event_id is not None else "" + return f"{id_line}event: {event}\ndata: {data}\n\n".encode("utf-8") def _mask_url(url: str) -> str: diff --git a/src/Undefined/api/_openapi.py b/src/Undefined/api/_openapi.py index a6c5134c..4e127d44 100644 --- a/src/Undefined/api/_openapi.py +++ b/src/Undefined/api/_openapi.py @@ -85,17 +85,65 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st "/api/v1/cognitive/profile/{entity_type}/{entity_id}": { "get": {"summary": "Get a profile by entity type/id"} }, + "/api/v1/commands": { + "get": { + "summary": "List slash command metadata", + "description": ( + "Returns slash commands, aliases, subcommands, usage, " + "permission and WebUI/private/group availability. " + "Use scope=webui for the WebChat virtual private session." + ), + } + }, + "/api/v1/commands/{command_name}": { + "get": { + "summary": "Get slash command metadata by name or alias", + "description": ( + "Returns one canonical slash command with aliases, " + "subcommands, usage and availability metadata." + ), + } + }, "/api/v1/chat": { "post": { "summary": "WebUI special private chat", "description": ( - "POST JSON {message, stream?}. " - "When stream=true, response is SSE with keep-alive comments." + "POST JSON {message, stream?, conversation_id?}. " + "stream=false runs synchronously; stream=true creates a " + "WebChat job and streams lifecycle events as SSE." ), } }, + "/api/v1/chat/conversations": { + "get": {"summary": "List WebChat conversations"}, + "post": {"summary": "Create a WebChat conversation"}, + }, + "/api/v1/chat/conversations/{conversation_id}": { + "patch": {"summary": "Rename a WebChat conversation"}, + "delete": {"summary": "Delete a WebChat conversation"}, + }, "/api/v1/chat/history": { - "get": {"summary": "Get virtual private chat history for WebUI"} + "get": {"summary": "Get paged WebChat conversation history"}, + "delete": {"summary": "Clear a WebChat conversation history"}, + }, + "/api/v1/chat/jobs": {"post": {"summary": "Create a WebUI chat job"}}, + "/api/v1/chat/jobs/active": { + "get": {"summary": "Get the active WebUI chat job"} + }, + "/api/v1/chat/jobs/{job_id}": { + "get": {"summary": "Get a WebUI chat job by id"} + }, + "/api/v1/chat/jobs/{job_id}/events": { + "get": { + "summary": "Subscribe to or query WebUI chat job events", + "description": ( + "Returns SSE by default. With Accept: application/json or " + "format=json, returns a JSON snapshot with events after seq." + ), + } + }, + "/api/v1/chat/jobs/{job_id}/cancel": { + "post": {"summary": "Cancel a WebUI chat job"} }, "/api/v1/tools": { "get": { diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 7ad5e56c..17e9fe13 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -24,7 +24,17 @@ _AUTH_HEADER, ) from ._naga_state import NagaState -from .routes import chat, cognitive, health, memes, memory, naga, system, tools +from .routes import ( + chat, + cognitive, + commands, + health, + memes, + memory, + naga, + system, + tools, +) logger = logging.getLogger(__name__) @@ -43,6 +53,7 @@ def __init__( self._sites: list[web.TCPSite] = [] self._background_tasks: set[asyncio.Task[Any]] = set() self._naga_state = NagaState() + self._chat_job_manager = chat.ChatJobManager(context) async def start(self) -> None: from Undefined.config.models import resolve_bind_hosts @@ -132,8 +143,41 @@ async def _auth_middleware( "/api/v1/cognitive/profile/{entity_type}/{entity_id}", self._cognitive_profile_handler, ), + web.get("/api/v1/commands", self._commands_list_handler), + web.get( + "/api/v1/commands/{command_name}", + self._command_detail_handler, + ), + web.get( + "/api/v1/chat/conversations", + self._chat_conversations_handler, + ), + web.post( + "/api/v1/chat/conversations", + self._chat_conversation_create_handler, + ), + web.patch( + "/api/v1/chat/conversations/{conversation_id}", + self._chat_conversation_update_handler, + ), + web.delete( + "/api/v1/chat/conversations/{conversation_id}", + self._chat_conversation_delete_handler, + ), web.post("/api/v1/chat", self._chat_handler), web.get("/api/v1/chat/history", self._chat_history_handler), + web.delete("/api/v1/chat/history", self._chat_history_clear_handler), + web.post("/api/v1/chat/jobs", self._chat_job_create_handler), + web.get("/api/v1/chat/jobs/active", self._chat_job_active_handler), + web.get("/api/v1/chat/jobs/{job_id}", self._chat_job_detail_handler), + web.get( + "/api/v1/chat/jobs/{job_id}/events", + self._chat_job_events_handler, + ), + web.post( + "/api/v1/chat/jobs/{job_id}/cancel", + self._chat_job_cancel_handler, + ), web.get("/api/v1/tools", self._tools_list_handler), web.post("/api/v1/tools/invoke", self._tools_invoke_handler), ] @@ -241,6 +285,13 @@ async def _cognitive_profiles_handler(self, request: web.Request) -> Response: async def _cognitive_profile_handler(self, request: web.Request) -> Response: return await cognitive.cognitive_profile_handler(self._ctx, request) + # Commands + async def _commands_list_handler(self, request: web.Request) -> Response: + return await commands.commands_list_handler(self._ctx, request) + + async def _command_detail_handler(self, request: web.Request) -> Response: + return await commands.command_detail_handler(self._ctx, request) + # Chat async def _run_webui_chat( self, @@ -250,11 +301,65 @@ async def _run_webui_chat( ) -> str: return await chat.run_webui_chat(self._ctx, text=text, send_output=send_output) + async def _chat_conversations_handler(self, request: web.Request) -> Response: + return await chat.chat_conversations_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_conversation_create_handler(self, request: web.Request) -> Response: + return await chat.chat_conversation_create_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_conversation_update_handler(self, request: web.Request) -> Response: + return await chat.chat_conversation_update_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_conversation_delete_handler(self, request: web.Request) -> Response: + return await chat.chat_conversation_delete_handler( + self._ctx, self._chat_job_manager, request + ) + async def _chat_history_handler(self, request: web.Request) -> Response: - return await chat.chat_history_handler(self._ctx, request) + return await chat.chat_history_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_history_clear_handler(self, request: web.Request) -> Response: + return await chat.chat_history_clear_handler( + self._ctx, self._chat_job_manager, request + ) async def _chat_handler(self, request: web.Request) -> web.StreamResponse: - return await chat.chat_handler(self._ctx, request) + return await chat.chat_handler(self._ctx, self._chat_job_manager, request) + + async def _chat_job_create_handler(self, request: web.Request) -> Response: + return await chat.chat_job_create_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_job_active_handler(self, request: web.Request) -> Response: + return await chat.chat_job_active_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_job_detail_handler(self, request: web.Request) -> Response: + return await chat.chat_job_detail_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_job_events_handler( + self, request: web.Request + ) -> web.StreamResponse: + return await chat.chat_job_events_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_job_cancel_handler(self, request: web.Request) -> Response: + return await chat.chat_job_cancel_handler( + self._ctx, self._chat_job_manager, request + ) # Tools def _get_filtered_tools(self) -> list[dict[str, Any]]: diff --git a/src/Undefined/api/routes/chat.py b/src/Undefined/api/routes/chat.py index 536435ca..bd5e1757 100644 --- a/src/Undefined/api/routes/chat.py +++ b/src/Undefined/api/routes/chat.py @@ -3,10 +3,17 @@ from __future__ import annotations import asyncio +import inspect +import json import logging +import re from contextlib import suppress +from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path +import time from typing import Any, Awaitable, Callable +from uuid import uuid4 from aiohttp import web from aiohttp.web_response import Response @@ -20,6 +27,13 @@ _sse_event, _to_bool, ) +from Undefined.api.webchat_store import ( + DEFAULT_WEBCHAT_CONVERSATION_ID, + WebChatConversationStore, + format_webchat_message_xml, + generate_webchat_title, + webchat_title_basis_hash, +) from Undefined.attachments import ( attachment_refs_to_xml, build_attachment_scope, @@ -30,13 +44,1777 @@ from Undefined.context_resource_registry import collect_context_resources from Undefined.services.queue_manager import QUEUE_LANE_SUPERADMIN from Undefined.utils.common import message_to_segments +from Undefined.utils import io as async_io from Undefined.utils.recent_messages import get_recent_messages_prefer_local -from Undefined.utils.xml import escape_xml_attr, escape_xml_text logger = logging.getLogger(__name__) _VIRTUAL_USER_NAME = "system" +_DEFAULT_CONVERSATION_ID = DEFAULT_WEBCHAT_CONVERSATION_ID _CHAT_SSE_KEEPALIVE_SECONDS = 10.0 +_CHAT_STAGE_REFRESH_SECONDS = 1.0 +_CHAT_JOB_EVENT_BUFFER_LIMIT = 1000 +_PREVIEW_LIMIT = 800 +_WEBCHAT_SEND_MESSAGE_TOOLS = frozenset( + { + "messages.send_message", + "send_message", + "messages.send_private_message", + "send_private_message", + } +) +_WEBCHAT_LIFECYCLE_EVENTS = frozenset( + {"tool_start", "tool_end", "agent_start", "agent_end"} +) +_WEBCHAT_AGENT_STAGE_EVENTS = frozenset({"agent_stage"}) +_WEBCHAT_HISTORY_EVENTS = ( + _WEBCHAT_LIFECYCLE_EVENTS | _WEBCHAT_AGENT_STAGE_EVENTS | frozenset({"message"}) +) +_WEBCHAT_STAGE_EVENTS = frozenset({"stage"}) +_REDACTED_PREVIEW_VALUE = "[redacted]" +_SENSITIVE_KEY_EXACT = frozenset( + { + "apikey", + "authorization", + "authtoken", + "bearertoken", + "clientsecret", + "cookie", + "credentials", + "idtoken", + "password", + "passwd", + "privatekey", + "refreshtoken", + "secret", + "secretkey", + "sessioncookie", + "sessionid", + "sessiontoken", + "setcookie", + "token", + } +) +_SENSITIVE_KEY_SUFFIXES = ( + "apikey", + "authtoken", + "bearertoken", + "clientsecret", + "idtoken", + "privatekey", + "refreshtoken", + "secretkey", + "sessioncookie", + "sessionid", + "sessiontoken", +) +_SECRET_TEXT_PATTERNS = ( + re.compile( + r"(?i)\b(authorization)\s*[:=]\s*(bearer\s+)?([^\s,;]+)", + ), + re.compile( + r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|" + r"client[_-]?secret|password|passwd|secret|private[_-]?key|session[_-]?id|" + r"session[_-]?token|cookie|set-cookie)\s*[:=]\s*(['\"]?)([^,\s;&\n'\"]+)", + ), + re.compile(r"(?i)\b(bearer)\s+([A-Za-z0-9._~+/=-]{16,})"), +) + + +@dataclass +class ChatJobEvent: + seq: int + event: str + payload: dict[str, Any] + + +@dataclass +class ChatJob: + job_id: str + text: str + created_at: float + updated_at: float + conversation_id: str = _DEFAULT_CONVERSATION_ID + status: str = "queued" + mode: str = "chat" + finished_at: float | None = None + duration_ms: int | None = None + current_stage: str = "queued" + current_stage_detail: str = "" + current_stage_started_at: float = 0.0 + outputs: list[str] = field(default_factory=list) + history_outputs: list[str] = field(default_factory=list) + history_attachments: list[dict[str, str]] = field(default_factory=list) + webchat_events: list[ChatJobEvent] = field(default_factory=list) + events: list[ChatJobEvent] = field(default_factory=list) + next_seq: int = 1 + task: asyncio.Task[None] | None = None + error: str = "" + history_finalized: bool = False + cancel_finalizer_scheduled: bool = False + history_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + done: asyncio.Event = field(default_factory=asyncio.Event) + changed: asyncio.Condition = field(default_factory=asyncio.Condition) + tool_started_at: dict[str, float] = field(default_factory=dict) + tool_start_payloads: dict[str, dict[str, Any]] = field(default_factory=dict) + agent_current_stage: dict[str, str] = field(default_factory=dict) + agent_stage_started_at: dict[str, float] = field(default_factory=dict) + agent_stage_payloads: dict[str, dict[str, Any]] = field(default_factory=dict) + + def snapshot(self) -> dict[str, Any]: + now = time.time() + elapsed_ms = _job_elapsed_ms(self, now) + stage_elapsed_ms = _stage_elapsed_ms(self, now) + return { + "job_id": self.job_id, + "conversation_id": self.conversation_id, + "status": self.status, + "mode": self.mode, + "created_at": self.created_at, + "updated_at": self.updated_at, + "finished_at": self.finished_at, + "elapsed_ms": elapsed_ms, + "duration_ms": self.duration_ms, + "current_stage": self.current_stage, + "current_stage_detail": self.current_stage_detail or None, + "current_stage_started_at": self.current_stage_started_at or None, + "current_stage_elapsed_ms": stage_elapsed_ms, + "last_seq": self.next_seq - 1, + "error": self.error or None, + "reply": "\n\n".join(self.outputs).strip(), + "messages": list(self.outputs), + "current_agent_stages": self.current_agent_stage_snapshots(now), + "current_tool_calls": self.current_tool_call_snapshots(now), + "history_finalized": self.history_finalized, + } + + def current_stage_event(self) -> ChatJobEvent | None: + if self.done.is_set() or not self.current_stage: + return None + now = time.time() + payload: dict[str, Any] = { + "job_id": self.job_id, + "stage": self.current_stage, + "elapsed_ms": _job_elapsed_ms(self, now), + } + if self.current_stage_started_at > 0: + payload["started_at"] = self.current_stage_started_at + payload["stage_elapsed_ms"] = _stage_elapsed_ms(self, now) + if self.current_stage_detail: + payload["detail"] = self.current_stage_detail + return ChatJobEvent(seq=self.next_seq - 1, event="stage", payload=payload) + + def current_agent_stage_events(self) -> list[ChatJobEvent]: + if self.done.is_set(): + return [] + now = time.time() + payloads = self.current_agent_stage_snapshots(now) + return [ + ChatJobEvent(seq=self.next_seq - 1, event="agent_stage", payload=payload) + for payload in payloads + ] + + def current_agent_stage_snapshots( + self, now: float | None = None + ) -> list[dict[str, Any]]: + if self.done.is_set(): + return [] + measured_at = time.time() if now is None else now + payloads: list[dict[str, Any]] = [] + for call_id, stage in self.agent_current_stage.items(): + if not stage: + continue + payload = dict(self.agent_stage_payloads.get(call_id, {})) + started_at = self.agent_stage_started_at.get(call_id, measured_at) + payload.update( + { + "job_id": self.job_id, + "webchat_call_id": call_id, + "stage": stage, + "transient": True, + "started_at": started_at, + "stage_elapsed_ms": max(0, int((measured_at - started_at) * 1000)), + "elapsed_ms": _job_elapsed_ms(self, measured_at), + } + ) + payloads.append(payload) + return payloads + + def current_tool_call_snapshots( + self, now: float | None = None + ) -> list[dict[str, Any]]: + if self.done.is_set(): + return [] + measured_at = time.time() if now is None else now + payloads: list[dict[str, Any]] = [] + for call_id, started_at in self.tool_started_at.items(): + payload = dict(self.tool_start_payloads.get(call_id, {})) + if not payload: + continue + payload.update( + { + "job_id": self.job_id, + "webchat_call_id": call_id, + "status": "running", + "started_at": started_at, + "duration_ms": max(0, int((measured_at - started_at) * 1000)), + "elapsed_ms": _job_elapsed_ms(self, measured_at), + } + ) + if bool(payload.get("is_agent")): + stage_payload = self.agent_stage_payloads.get(call_id, {}) + stage_started_at = self.agent_stage_started_at.get(call_id, measured_at) + current_stage = self.agent_current_stage.get(call_id, "") + if current_stage: + payload.update( + { + "current_stage": current_stage, + "current_stage_detail": str( + stage_payload.get("detail") or "" + ).strip(), + "current_stage_elapsed_ms": max( + 0, + int((measured_at - stage_started_at) * 1000), + ), + } + ) + payloads.append(payload) + return payloads + + +class ChatJobManager: + def __init__(self, ctx: RuntimeAPIContext) -> None: + self._ctx = ctx + self._jobs: dict[str, ChatJob] = {} + self._lock = asyncio.Lock() + self._title_schedule_lock = asyncio.Lock() + self.conversation_store = WebChatConversationStore() + + async def create_job( + self, text: str, conversation_id: str | None = None + ) -> ChatJob: + await self.conversation_store.ensure_ready(self._ctx.history_manager) + requested_conversation_id = str(conversation_id or "").strip() + resolved_conversation_id = requested_conversation_id or _DEFAULT_CONVERSATION_ID + conversation = await self.conversation_store.get_conversation( + resolved_conversation_id + ) + if conversation is None: + if requested_conversation_id: + raise KeyError(resolved_conversation_id) + conversation = await self.conversation_store.ensure_default_conversation() + resolved_conversation_id = str(conversation["id"]) + now = time.time() + job = ChatJob( + job_id=uuid4().hex, + text=text, + created_at=now, + updated_at=now, + conversation_id=resolved_conversation_id, + ) + async with self._lock: + if any( + self._job_blocks_history_mutation(existing) + for existing in self._jobs.values() + ): + raise RuntimeError("Chat job is still running") + self._jobs[job.job_id] = job + logger.info( + "[RuntimeAPI][WebChat] 创建 job: job_id=%s conversation_id=%s text_len=%s", + job.job_id, + job.conversation_id, + len(text), + ) + await self._append_event( + job, + "meta", + { + "job_id": job.job_id, + "conversation_id": job.conversation_id, + "virtual_user_id": _VIRTUAL_USER_ID, + "permission": "superadmin", + }, + ) + await self._append_stage(job, "received") + job.task = asyncio.create_task(self._run_job(job), name=f"webchat:{job.job_id}") + return job + + async def get_job(self, job_id: str) -> ChatJob | None: + async with self._lock: + return self._jobs.get(job_id) + + async def get_active_job( + self, conversation_id: str | None = None + ) -> ChatJob | None: + async with self._lock: + candidates = [ + job + for job in self._jobs.values() + if self._job_blocks_history_mutation(job) + and ( + not conversation_id + or job.conversation_id == str(conversation_id).strip() + ) + ] + if not candidates: + return None + return max(candidates, key=lambda item: item.created_at) + + async def snapshot(self, job: ChatJob) -> dict[str, Any]: + async with job.changed: + return job.snapshot() + + async def has_running_job(self) -> bool: + async with self._lock: + return any( + self._job_blocks_history_mutation(job) for job in self._jobs.values() + ) + + def _job_blocks_history_mutation(self, job: ChatJob) -> bool: + if job.status in {"queued", "running"}: + return True + return not job.done.is_set() or not job.history_finalized + + async def clear_history_when_idle( + self, conversation_id: str | None = None + ) -> int | None: + await self.conversation_store.ensure_ready(self._ctx.history_manager) + resolved_conversation_id = ( + str(conversation_id or _DEFAULT_CONVERSATION_ID).strip() + or _DEFAULT_CONVERSATION_ID + ) + async with self._lock: + if any( + self._job_blocks_history_mutation(job) for job in self._jobs.values() + ): + logger.info( + "[RuntimeAPI][WebChat] 清空历史被拒绝,存在运行中 job: conversation_id=%s", + resolved_conversation_id, + ) + return None + return int( + await self.conversation_store.clear_conversation( + resolved_conversation_id + ) + or 0 + ) + + async def cancel_job(self, job_id: str) -> ChatJob | None: + job = await self.get_job(job_id) + if job is None: + return None + if job.status in {"done", "error", "cancelled"}: + return job + logger.info( + "[RuntimeAPI][WebChat] 取消 job: job_id=%s conversation_id=%s status=%s", + job.job_id, + job.conversation_id, + job.status, + ) + job.status = "cancelled" + self._mark_job_finished(job) + if job.task is not None and not job.task.done(): + job.task.cancel() + self._schedule_cancel_finalizer(job) + if not any( + event.event == "error" and event.payload.get("error") == "cancelled" + for event in job.events + ): + await self._append_event( + job, + "error", + { + "error": "cancelled", + "job_id": job.job_id, + "duration_ms": job.duration_ms, + }, + ) + if job.task is None or job.task.done(): + job.history_finalized = True + job.done.set() + return job + + def _schedule_cancel_finalizer(self, job: ChatJob) -> None: + if job.cancel_finalizer_scheduled: + return + if job.task is None: + return + job.cancel_finalizer_scheduled = True + loop = asyncio.get_running_loop() + + def _on_done(_task: asyncio.Task[None]) -> None: + loop.create_task( + self._complete_cancelled_job(job), + name=f"webchat-cancel-finalize:{job.job_id}", + ) + + job.task.add_done_callback(_on_done) + + async def _complete_cancelled_job(self, job: ChatJob) -> None: + try: + await self._finalize_job_history(job) + except Exception as exc: + logger.exception( + "[RuntimeAPI] cancelled chat job history finalize failed: %s", exc + ) + job.history_finalized = True + finally: + job.done.set() + + async def events_after(self, job: ChatJob, after: int) -> list[ChatJobEvent]: + async with job.changed: + return [event for event in job.events if event.seq > after] + + async def events_after_with_snapshot( + self, + job: ChatJob, + after: int, + ) -> tuple[list[ChatJobEvent], dict[str, Any], list[ChatJobEvent]]: + async with job.changed: + events = [event for event in job.events if event.seq > after] + snapshot = job.snapshot() + live_events = _current_webchat_live_events(job, after, events) + return events, snapshot, live_events + + async def update_agent_stage( + self, job: ChatJob, payload: dict[str, Any] + ) -> ChatJobEvent | None: + event_payload = _sanitize_webchat_event_payload("agent_stage", payload) + event_time = time.time() + call_id = _webchat_tool_event_key(event_payload) + stage_key = str(event_payload.get("stage") or "").strip() + if not stage_key: + return None + async with job.changed: + previous_stage = job.agent_current_stage.get(call_id) + if previous_stage != stage_key: + job.agent_current_stage[call_id] = stage_key + job.agent_stage_started_at[call_id] = event_time + started_at = job.agent_stage_started_at.get(call_id, event_time) + event_payload["started_at"] = started_at + event_payload["stage_elapsed_ms"] = max( + 0, int((event_time - started_at) * 1000) + ) + event_payload["elapsed_ms"] = _job_elapsed_ms(job, event_time) + event_payload["job_id"] = job.job_id + job.agent_stage_payloads[call_id] = dict(event_payload) + return self._append_event_locked(job, "agent_stage", event_payload) + + async def append_lifecycle_event( + self, job: ChatJob, event: str, payload: dict[str, Any] + ) -> ChatJobEvent | None: + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + return None + event_payload = _sanitize_webchat_event_payload(event, payload) + event_time = time.time() + output_event = str(event_payload.get("_event", event) or event) + tool_key = _webchat_tool_event_key(event_payload) + logger.debug( + "[RuntimeAPI][WebChat] 生命周期事件: job_id=%s conversation_id=%s event=%s tool_key=%s", + job.job_id, + job.conversation_id, + output_event, + tool_key, + ) + async with job.changed: + if output_event in {"tool_start", "agent_start"}: + job.tool_started_at[tool_key] = event_time + event_payload["started_at"] = event_time + job.tool_start_payloads[tool_key] = dict(event_payload) + elif output_event in {"tool_end", "agent_end"}: + lifecycle_started_at = job.tool_started_at.get(tool_key) + job.tool_started_at.pop(tool_key, 0.0) + job.tool_start_payloads.pop(tool_key, None) + if lifecycle_started_at is not None: + event_payload["duration_ms"] = max( + 0, int((event_time - lifecycle_started_at) * 1000) + ) + if output_event == "agent_end": + job.agent_current_stage.pop(tool_key, None) + job.agent_stage_started_at.pop(tool_key, None) + job.agent_stage_payloads.pop(tool_key, None) + event_payload["elapsed_ms"] = _job_elapsed_ms(job, event_time) + event_payload["job_id"] = job.job_id + return self._append_event_locked(job, event, event_payload) + + async def wait_for_events_after( + self, + job: ChatJob, + after: int, + *, + timeout: float, + ) -> list[ChatJobEvent]: + async with job.changed: + current = [event for event in job.events if event.seq > after] + if current: + return current + try: + await asyncio.wait_for(job.changed.wait(), timeout=timeout) + except asyncio.TimeoutError: + return [] + return [event for event in job.events if event.seq > after] + + async def _run_job(self, job: ChatJob) -> None: + job.status = "running" + job.updated_at = time.time() + logger.info( + "[RuntimeAPI][WebChat] job 开始: job_id=%s conversation_id=%s text_len=%s", + job.job_id, + job.conversation_id, + len(job.text), + ) + outputs: list[str] = [] + webui_scope_key = build_attachment_scope( + user_id=_VIRTUAL_USER_ID, + request_type="private", + webui_session=True, + ) + + async def _capture_private_message(user_id: int, message: str) -> None: + _ = user_id + content = str(message or "").strip() + if not content: + return + await self._append_stage(job, "sending_message") + rendered = await render_message_with_pic_placeholders( + content, + registry=self._ctx.ai.attachment_registry, + scope_key=webui_scope_key, + strict=False, + ) + if not rendered.delivery_text.strip(): + return + outputs.append(rendered.delivery_text) + job.outputs.append(rendered.delivery_text) + job.history_outputs.append(rendered.history_text) + job.history_attachments.extend(rendered.attachments) + logger.info( + "[RuntimeAPI][WebChat] job 输出消息: job_id=%s conversation_id=%s delivery_len=%s attachments=%s", + job.job_id, + job.conversation_id, + len(rendered.delivery_text), + len(rendered.attachments), + ) + now = time.time() + await self._append_event( + job, + "message", + { + "content": rendered.delivery_text, + "job_id": job.job_id, + "elapsed_ms": _job_elapsed_ms(job, now), + "parent_webchat_call_id": _current_webchat_agent_call_id(job), + }, + ) + + async def _webchat_event_callback(event: str, payload: dict[str, Any]) -> None: + if event in _WEBCHAT_STAGE_EVENTS: + await self._append_stage( + job, + str(payload.get("stage") or payload.get("key") or ""), + detail=payload.get("detail"), + ) + return + if event in _WEBCHAT_AGENT_STAGE_EVENTS: + await self.update_agent_stage(job, payload) + return + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + return + await self.append_lifecycle_event(job, event, payload) + + try: + run_kwargs: dict[str, Any] = { + "text": job.text, + "send_output": _capture_private_message, + } + if "webchat_event_callback" in inspect.signature(run_webui_chat).parameters: + run_kwargs["webchat_event_callback"] = _webchat_event_callback + if "conversation_store" in inspect.signature(run_webui_chat).parameters: + run_kwargs["conversation_store"] = self.conversation_store + if "conversation_id" in inspect.signature(run_webui_chat).parameters: + run_kwargs["conversation_id"] = job.conversation_id + await self._append_stage(job, "processing") + mode = await run_webui_chat(self._ctx, **run_kwargs) + job.mode = mode + job.status = "done" + self._mark_job_finished(job) + logger.info( + "[RuntimeAPI][WebChat] job 完成: job_id=%s conversation_id=%s mode=%s duration_ms=%s outputs=%s", + job.job_id, + job.conversation_id, + mode, + job.duration_ms, + len(outputs), + ) + done_payload = _build_chat_response_payload(mode, outputs) + done_payload.update( + { + "job_id": job.job_id, + "status": job.status, + "duration_ms": job.duration_ms, + } + ) + await self._append_stage(job, "done") + await self._append_event( + job, + "done", + done_payload, + ) + except asyncio.CancelledError: + job.status = "cancelled" + self._mark_job_finished(job) + logger.info( + "[RuntimeAPI][WebChat] job 已取消: job_id=%s conversation_id=%s duration_ms=%s", + job.job_id, + job.conversation_id, + job.duration_ms, + ) + if not any( + event.event == "error" and event.payload.get("error") == "cancelled" + for event in job.events + ): + await self._append_event( + job, + "error", + { + "error": "cancelled", + "job_id": job.job_id, + "duration_ms": job.duration_ms, + }, + ) + except Exception as exc: + logger.exception("[RuntimeAPI] chat job failed: %s", exc) + job.status = "error" + job.error = str(exc) + self._mark_job_finished(job) + await self._append_event( + job, + "error", + { + "error": str(exc), + "job_id": job.job_id, + "duration_ms": job.duration_ms, + }, + ) + finally: + try: + await self._finalize_job_history(job) + await self.maybe_schedule_title_generation(job.conversation_id) + except Exception as exc: + logger.exception( + "[RuntimeAPI] chat job history finalize failed: %s", exc + ) + job.history_finalized = True + job.done.set() + + async def _append_event( + self, job: ChatJob, event: str, payload: dict[str, Any] + ) -> ChatJobEvent: + async with job.changed: + return self._append_event_locked(job, event, payload) + + def _append_event_locked( + self, job: ChatJob, event: str, payload: dict[str, Any] + ) -> ChatJobEvent: + payload_copy = dict(payload) + normalized_event = str(payload_copy.pop("_event", event) or event) + payload_copy.setdefault("conversation_id", job.conversation_id) + item = ChatJobEvent( + seq=job.next_seq, event=normalized_event, payload=payload_copy + ) + job.next_seq += 1 + job.updated_at = time.time() + job.events.append(item) + if len(job.events) > _CHAT_JOB_EVENT_BUFFER_LIMIT: + job.events = job.events[-_CHAT_JOB_EVENT_BUFFER_LIMIT:] + if item.event in _WEBCHAT_HISTORY_EVENTS: + job.webchat_events.append(item) + if len(job.webchat_events) > _CHAT_JOB_EVENT_BUFFER_LIMIT: + job.webchat_events = job.webchat_events[-_CHAT_JOB_EVENT_BUFFER_LIMIT:] + job.changed.notify_all() + return item + + async def _append_stage( + self, + job: ChatJob, + stage: str, + *, + detail: Any | None = None, + ) -> ChatJobEvent | None: + stage_key = str(stage or "").strip() + if not stage_key: + return None + now = time.time() + payload: dict[str, Any] = { + "job_id": job.job_id, + "conversation_id": job.conversation_id, + "stage": stage_key, + "started_at": now, + "elapsed_ms": _job_elapsed_ms(job, now), + } + detail_text = _preview(detail, 120) + if detail_text: + payload["detail"] = detail_text + async with job.changed: + job.current_stage = stage_key + job.current_stage_detail = detail_text + job.current_stage_started_at = now + return self._append_event_locked(job, "stage", payload) + + def _mark_job_finished(self, job: ChatJob) -> None: + now = time.time() + job.finished_at = now + job.duration_ms = _job_elapsed_ms(job, now) + job.updated_at = now + + async def _finalize_job_history(self, job: ChatJob) -> None: + async with job.history_lock: + if job.history_finalized: + logger.debug( + "[RuntimeAPI][WebChat] job 历史已落盘,跳过: job_id=%s conversation_id=%s", + job.job_id, + job.conversation_id, + ) + return + text_content = "\n\n".join(job.history_outputs).strip() + webchat = _build_webchat_history_payload(job) + if text_content or webchat["events"]: + await self.conversation_store.append_message( + job.conversation_id, + role="bot", + text_content=text_content, + display_name="Bot", + user_name="Bot", + attachments=job.history_attachments or None, + webchat=webchat, + ) + logger.info( + "[RuntimeAPI][WebChat] job 历史落盘: job_id=%s conversation_id=%s text_len=%s events=%s attachments=%s", + job.job_id, + job.conversation_id, + len(text_content), + len(webchat["events"]), + len(job.history_attachments), + ) + else: + logger.info( + "[RuntimeAPI][WebChat] job 无需落盘 bot 历史: job_id=%s conversation_id=%s", + job.job_id, + job.conversation_id, + ) + job.history_finalized = True + + async def maybe_schedule_title_generation(self, conversation_id: str) -> None: + async with self._title_schedule_lock: + if self.conversation_store.title_task_running(conversation_id): + logger.debug( + "[RuntimeAPI][WebChat] 标题生成任务已存在: conversation_id=%s", + conversation_id, + ) + return + first_pair = await self.conversation_store.first_question_answer( + conversation_id + ) + if first_pair is None: + logger.debug( + "[RuntimeAPI][WebChat] 标题生成跳过,缺少首问首答: conversation_id=%s", + conversation_id, + ) + return + if not await self.conversation_store.mark_title_pending(conversation_id): + logger.debug( + "[RuntimeAPI][WebChat] 标题生成跳过,状态不允许: conversation_id=%s", + conversation_id, + ) + return + question, answer = first_pair + basis_hash = webchat_title_basis_hash(question, answer) + logger.info( + "[RuntimeAPI][WebChat] 调度标题生成: conversation_id=%s question_len=%s answer_len=%s", + conversation_id, + len(question), + len(answer), + ) + + async def _run_title() -> None: + try: + title = await generate_webchat_title(self._ctx.ai, question, answer) + if title: + await self.conversation_store.apply_generated_title( + conversation_id, + title=title, + basis_hash=basis_hash, + ) + logger.info( + "[RuntimeAPI][WebChat] 标题生成完成: conversation_id=%s title_len=%s", + conversation_id, + len(title), + ) + return + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning("[RuntimeAPI] webchat title generation failed: %s", exc) + await self.conversation_store.mark_title_failed(conversation_id, basis_hash) + + task = asyncio.create_task( + _run_title(), name=f"webchat-title:{conversation_id}" + ) + self.conversation_store.register_title_task(conversation_id, task) + + +def _job_elapsed_ms(job: ChatJob, now: float | None = None) -> int: + measured_at = time.time() if now is None else now + return max(0, int((measured_at - job.created_at) * 1000)) + + +def _stage_elapsed_ms(job: ChatJob, now: float | None = None) -> int: + if job.current_stage_started_at <= 0: + return 0 + measured_at = time.time() if now is None else now + return max(0, int((measured_at - job.current_stage_started_at) * 1000)) + + +def _current_webchat_live_events( + job: ChatJob, + after: int, + events: list[ChatJobEvent], +) -> list[ChatJobEvent]: + live_events: list[ChatJobEvent] = [] + current_stage_event = job.current_stage_event() + if ( + current_stage_event is not None + and current_stage_event.seq >= after + and not any( + existing.event == current_stage_event.event + and existing.payload.get("stage") + == current_stage_event.payload.get("stage") + for existing in events + ) + ): + live_events.append(current_stage_event) + live_events.extend( + event + for event in job.current_agent_stage_events() + if event.seq >= after + and not any( + existing.event == event.event + and existing.payload.get("webchat_call_id") + == event.payload.get("webchat_call_id") + and existing.payload.get("stage") == event.payload.get("stage") + for existing in events + ) + ) + return live_events + + +def _current_webchat_agent_call_id(job: ChatJob) -> str: + open_calls: list[tuple[str, bool]] = [] + for item in job.webchat_events: + if item.event not in _WEBCHAT_LIFECYCLE_EVENTS: + continue + payload = item.payload + call_id = _webchat_tool_event_key(payload) + if not call_id: + continue + if item.event in {"tool_start", "agent_start"}: + open_calls.append((call_id, bool(payload.get("is_agent")))) + continue + if item.event in {"tool_end", "agent_end"}: + for index in range(len(open_calls) - 1, -1, -1): + if open_calls[index][0] == call_id: + open_calls.pop(index) + break + for call_id, is_agent in reversed(open_calls): + if is_agent: + return call_id + return "" + + +def _webchat_tool_event_key(payload: dict[str, Any]) -> str: + return ( + str(payload.get("webchat_call_id") or "").strip() + or str(payload.get("tool_call_id") or "").strip() + or str(payload.get("name") or "").strip() + or str(payload.get("api_name") or "").strip() + or "tool" + ) + + +def _webchat_payload_lineage(payload: dict[str, Any]) -> dict[str, Any]: + call_id = ( + str(payload.get("webchat_call_id") or "").strip() + or str(payload.get("tool_call_id") or "").strip() + or str(payload.get("name") or "").strip() + or "tool" + ) + parent_call_id = str(payload.get("parent_webchat_call_id") or "").strip() + try: + depth = max(0, int(payload.get("depth", 0) or 0)) + except (TypeError, ValueError): + depth = 0 + raw_path = payload.get("agent_path") + agent_path = ( + [str(item) for item in raw_path if str(item).strip()] + if isinstance(raw_path, list) + else [] + ) + return { + "webchat_call_id": call_id, + "parent_webchat_call_id": parent_call_id, + "depth": depth, + "agent_path": agent_path, + } + + +def _legacy_webchat_tool_event_key(payload: dict[str, Any]) -> str: + return ( + str(payload.get("tool_call_id") or "").strip() + or str(payload.get("name") or "").strip() + or str(payload.get("api_name") or "").strip() + or "tool" + ) + + +def _preview(value: Any, limit: int = _PREVIEW_LIMIT) -> str: + redacted = _redact_preview_value(value) + if isinstance(redacted, dict | list): + text = json.dumps(redacted, ensure_ascii=False, separators=(",", ":")) + else: + text = _redact_secret_text(str(redacted or "")) + compact = " ".join(text.split()) + if len(compact) <= limit: + return compact + return compact[:limit] + "..." + + +def _preview_existing_text(raw: Any, limit: int = _PREVIEW_LIMIT) -> str: + text = str(raw or "").strip() + if not text: + return "" + with suppress(json.JSONDecodeError, TypeError, ValueError): + return _preview(json.loads(text), limit) + return _preview(text, limit) + + +def _normalize_sensitive_key(key: Any) -> str: + return re.sub(r"[^a-z0-9]", "", str(key or "").lower()) + + +def _is_sensitive_preview_key(key: Any) -> bool: + normalized = _normalize_sensitive_key(key) + if not normalized: + return False + if normalized in _SENSITIVE_KEY_EXACT: + return True + return any(normalized.endswith(suffix) for suffix in _SENSITIVE_KEY_SUFFIXES) + + +def _redact_secret_text(text: str) -> str: + redacted = str(text or "") + redacted = _SECRET_TEXT_PATTERNS[0].sub( + lambda match: ( + f"{match.group(1)}: {match.group(2) or ''}{_REDACTED_PREVIEW_VALUE}" + ), + redacted, + ) + redacted = _SECRET_TEXT_PATTERNS[1].sub( + lambda match: ( + f"{match.group(1)}={match.group(2)}" + f"{_REDACTED_PREVIEW_VALUE}{match.group(2) or ''}" + ), + redacted, + ) + redacted = _SECRET_TEXT_PATTERNS[2].sub( + lambda match: f"{match.group(1)} {_REDACTED_PREVIEW_VALUE}", + redacted, + ) + return redacted + + +def _redact_preview_value(value: Any) -> Any: + if isinstance(value, dict): + redacted_dict: dict[str, Any] = {} + for key, item in value.items(): + text_key = str(key) + redacted_dict[text_key] = ( + _REDACTED_PREVIEW_VALUE + if _is_sensitive_preview_key(text_key) + else _redact_preview_value(item) + ) + return redacted_dict + if isinstance(value, list): + return [_redact_preview_value(item) for item in value] + if isinstance(value, tuple): + return [_redact_preview_value(item) for item in value] + if isinstance(value, str): + return _redact_secret_text(value) + return value + + +def _redact_webchat_display_payload(payload: dict[str, Any]) -> dict[str, Any]: + result = dict(payload) + for key in ("arguments_preview", "result_preview", "current_stage_detail"): + if key in result: + result[key] = _preview_existing_text(result.get(key)) + if "detail" in result: + result["detail"] = _preview_existing_text(result.get("detail"), 160) + return result + + +def _redact_webchat_display_tree(value: Any) -> Any: + if isinstance(value, list): + return [_redact_webchat_display_tree(item) for item in value] + if not isinstance(value, dict): + return value + result = { + str(key): _redact_webchat_display_tree(item) for key, item in value.items() + } + return _redact_webchat_display_payload(result) + + +def _webchat_tool_ui_hint( + event: str, + *, + name: str, + api_name: str, + arguments: Any | None = None, + result: Any | None = None, + is_agent: bool = False, +) -> str | None: + if is_agent: + return None + tool_names = {name, api_name} + if tool_names & _WEBCHAT_SEND_MESSAGE_TOOLS: + if tool_names & {"messages.send_private_message", "send_private_message"}: + return "webchat_private_send" + if not isinstance(arguments, dict): + return None + target_type = str(arguments.get("target_type") or "").strip().lower() + if target_type in {"", "private"}: + return "webchat_private_send" + return None + if "end" in tool_names and event in {"tool_end", "agent_end"}: + result_text = str(result or "").strip() + if result_text == "对话已结束": + return "webchat_end" + return None + + +def _sanitize_webchat_event_payload( + event: str, payload: dict[str, Any] +) -> dict[str, Any]: + if event in {"tool_start", "agent_start"}: + is_agent = bool(payload.get("is_agent")) or event == "agent_start" + output_event = "agent_start" if is_agent else "tool_start" + name = str(payload.get("name") or "") + api_name = str(payload.get("api_name") or "") + arguments = payload.get("arguments") + ui_hint = _webchat_tool_ui_hint( + output_event, + name=name, + api_name=api_name, + arguments=arguments, + is_agent=is_agent, + ) + return { + "_event": output_event, + "tool_call_id": str(payload.get("tool_call_id") or ""), + "name": name, + "api_name": api_name, + "status": "running", + "arguments_preview": "" + if ui_hint == "webchat_private_send" + else _preview(arguments), + "is_agent": is_agent, + **_webchat_payload_lineage(payload), + **({"ui_hint": ui_hint} if ui_hint else {}), + } + if event in {"tool_end", "agent_end"}: + is_agent = bool(payload.get("is_agent")) or event == "agent_end" + output_event = "agent_end" if is_agent else "tool_end" + name = str(payload.get("name") or "") + api_name = str(payload.get("api_name") or "") + result = payload.get("result") + ui_hint = _webchat_tool_ui_hint( + output_event, + name=name, + api_name=api_name, + result=result, + is_agent=is_agent, + ) + return { + "_event": output_event, + "tool_call_id": str(payload.get("tool_call_id") or ""), + "name": name, + "api_name": api_name, + "ok": bool(payload.get("ok", True)), + "status": "error" if payload.get("ok") is False else "done", + "result_preview": _preview(result), + "is_agent": is_agent, + **_webchat_payload_lineage(payload), + **({"ui_hint": ui_hint} if ui_hint else {}), + } + if event == "agent_stage": + stage = str(payload.get("stage") or payload.get("key") or "").strip() + agent_name = str(payload.get("agent_name") or payload.get("name") or "") + call_id = str(payload.get("webchat_call_id") or "").strip() + parent_call_id = str(payload.get("parent_webchat_call_id") or "").strip() + if not call_id: + call_id = parent_call_id or agent_name or "agent" + payload = {**payload, "webchat_call_id": call_id} + return { + "stage": stage, + "detail": _preview(payload.get("detail"), 160), + "status": str(payload.get("status") or "running"), + "name": agent_name, + "agent_name": agent_name, + "is_agent": True, + **_webchat_payload_lineage(payload), + } + return {key: value for key, value in payload.items() if key != "arguments"} + + +def _build_webchat_history_payload(job: ChatJob) -> dict[str, Any]: + events = _finalize_webchat_history_events(job) + return { + "display_only": True, + "job_id": job.job_id, + "conversation_id": job.conversation_id, + "mode": job.mode, + "status": job.status, + "created_at": job.created_at, + "finished_at": job.finished_at, + "duration_ms": job.duration_ms, + "events": events, + "calls": _build_webchat_call_tree(events), + "timeline": _build_webchat_timeline(events), + } + + +def _finalize_webchat_history_events(job: ChatJob) -> list[dict[str, Any]]: + events = [ + { + "seq": item.seq, + "event": item.event, + "payload": dict(item.payload), + } + for item in job.webchat_events + ] + if job.status == "done": + return events + started: dict[str, dict[str, Any]] = {} + closed: set[str] = set() + for item in events: + event = str(item.get("event") or "") + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + continue + call_id = _webchat_event_call_id(item) + if not call_id: + continue + payload = item.get("payload") + payload_dict = payload if isinstance(payload, dict) else {} + if event in {"tool_start", "agent_start"}: + started[call_id] = dict(payload_dict) + continue + if event in {"tool_end", "agent_end"}: + closed.add(call_id) + unfinished = [call_id for call_id in started if call_id not in closed] + if not unfinished: + return events + reason = "cancelled" if job.status == "cancelled" else "interrupted" + finished_at = job.finished_at or time.time() + max_seq = 0 + for item in events: + seq_raw = item.get("seq", 0) + if not isinstance(seq_raw, str | bytes | int | float): + continue + try: + max_seq = max(max_seq, int(seq_raw)) + except (TypeError, ValueError): + continue + next_seq = max_seq + 1 + for call_id in unfinished: + start_payload = started[call_id] + started_at = start_payload.get("started_at") + duration_ms = None + if isinstance(started_at, int | float): + duration_ms = max(0, int((finished_at - float(started_at)) * 1000)) + events.append( + { + "seq": next_seq, + "event": "agent_end" if start_payload.get("is_agent") else "tool_end", + "payload": { + "tool_call_id": str(start_payload.get("tool_call_id") or ""), + "name": str(start_payload.get("name") or ""), + "api_name": str(start_payload.get("api_name") or ""), + "ok": False, + "status": "cancelled" if reason == "cancelled" else "error", + "result_preview": reason, + "is_agent": bool(start_payload.get("is_agent")), + "webchat_call_id": call_id, + "parent_webchat_call_id": str( + start_payload.get("parent_webchat_call_id") or "" + ), + "depth": start_payload.get("depth", 0), + "agent_path": start_payload.get("agent_path") + if isinstance(start_payload.get("agent_path"), list) + else [], + "duration_ms": duration_ms, + "elapsed_ms": _job_elapsed_ms(job, finished_at), + "job_id": job.job_id, + }, + } + ) + next_seq += 1 + return events + + +def _call_preview_node(payload: dict[str, Any]) -> dict[str, Any]: + return { + "webchat_call_id": str(payload.get("webchat_call_id") or "").strip(), + "parent_webchat_call_id": str( + payload.get("parent_webchat_call_id") or "" + ).strip(), + "tool_call_id": str(payload.get("tool_call_id") or "").strip(), + "name": str(payload.get("name") or "").strip(), + "api_name": str(payload.get("api_name") or "").strip(), + "is_agent": bool(payload.get("is_agent")), + "status": str(payload.get("status") or "running"), + "ok": None, + "arguments_preview": str(payload.get("arguments_preview") or ""), + "result_preview": "", + "ui_hint": str(payload.get("ui_hint") or "").strip(), + "duration_ms": payload.get("duration_ms"), + "elapsed_ms": payload.get("elapsed_ms"), + "started_at": payload.get("started_at"), + "current_stage": str(payload.get("current_stage") or "").strip(), + "current_stage_detail": str(payload.get("current_stage_detail") or "").strip(), + "current_stage_elapsed_ms": payload.get("current_stage_elapsed_ms"), + "depth": payload.get("depth", 0), + "agent_path": payload.get("agent_path") + if isinstance(payload.get("agent_path"), list) + else [], + "children": [], + "timeline": [], + } + + +def _webchat_event_call_id(event: dict[str, Any]) -> str: + payload = event.get("payload") + if not isinstance(payload, dict): + return "" + call_id = str(payload.get("webchat_call_id") or "").strip() + return call_id or _legacy_webchat_tool_event_key(payload) + + +def _history_agent_stage_seq(event: dict[str, Any]) -> int: + if str(event.get("event") or "") != "agent_stage": + return _webchat_event_seq(event) + seq = _webchat_event_seq(event) + return max(0, seq - 1) + + +def _build_webchat_call_graph( + events: list[dict[str, Any]], +) -> tuple[dict[str, dict[str, Any]], list[str], list[dict[str, Any]]]: + nodes: dict[str, dict[str, Any]] = {} + order: list[str] = [] + for item in events: + event = str(item.get("event") or "") + if event not in _WEBCHAT_LIFECYCLE_EVENTS and event != "agent_stage": + continue + payload = item.get("payload") + if not isinstance(payload, dict): + payload = {} + call_id = _webchat_event_call_id(item) + if not call_id: + continue + node = nodes.get(call_id) + if node is None: + node = _call_preview_node({**payload, "webchat_call_id": call_id}) + nodes[call_id] = node + if event == "agent_stage": + insert_after = _history_agent_stage_seq(item) + insert_at = len(order) + for index, existing_call_id in enumerate(order): + existing = nodes.get(existing_call_id) + if existing is None: + continue + started_seq = int(existing.get("_started_seq", 0) or 0) + if started_seq > insert_after: + insert_at = index + break + order.insert(insert_at, call_id) + else: + order.append(call_id) + if event == "agent_stage": + node.update( + { + "current_stage": str(payload.get("stage") or "").strip(), + "current_stage_detail": str(payload.get("detail") or "").strip(), + "current_stage_elapsed_ms": payload.get("stage_elapsed_ms"), + "elapsed_ms": payload.get("elapsed_ms"), + "is_agent": True, + "name": str( + payload.get("agent_name") + or payload.get("name") + or node.get("name") + or "" + ).strip(), + } + ) + continue + if event in {"tool_start", "agent_start"}: + node.update(_call_preview_node({**payload, "webchat_call_id": call_id})) + node["status"] = "running" + node["_started_seq"] = _webchat_event_seq(item) + continue + if event in {"tool_end", "agent_end"}: + node.update( + { + "status": str( + payload.get("status") + or ("error" if payload.get("ok") is False else "done") + ), + "ok": bool(payload.get("ok", True)), + "result_preview": str(payload.get("result_preview") or ""), + "duration_ms": payload.get("duration_ms"), + "elapsed_ms": payload.get("elapsed_ms"), + "ui_hint": str(payload.get("ui_hint") or node.get("ui_hint") or ""), + "is_agent": bool(payload.get("is_agent") or node.get("is_agent")), + } + ) + + for call_id in order: + nodes[call_id]["children"] = [] + roots: list[dict[str, Any]] = [] + for call_id in order: + node = nodes[call_id] + parent_id = str(node.get("parent_webchat_call_id") or "").strip() + parent = nodes.get(parent_id) + if parent is not None and parent is not node: + parent.setdefault("children", []).append(node) + else: + roots.append(node) + _populate_webchat_node_timelines(nodes, events) + for node in nodes.values(): + node.pop("_started_seq", None) + return nodes, order, roots + + +def _build_webchat_call_tree(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + _nodes, _order, roots = _build_webchat_call_graph(events) + return roots + + +def _webchat_event_seq(event: dict[str, Any]) -> int: + seq_raw = event.get("seq", 0) + try: + return max(0, int(seq_raw)) + except (TypeError, ValueError): + return 0 + + +def _webchat_message_timeline_item( + *, + event: dict[str, Any], + payload: dict[str, Any], +) -> dict[str, Any] | None: + content = str(payload.get("content") or payload.get("message") or "") + if not content: + return None + return { + "type": "message", + "seq": _webchat_event_seq(event), + "content": content, + "elapsed_ms": payload.get("elapsed_ms"), + } + + +def _webchat_agent_stage_timeline_item( + *, + event: dict[str, Any], + payload: dict[str, Any], +) -> dict[str, Any] | None: + stage = str(payload.get("stage") or "").strip() + if not stage: + return None + detail = str(payload.get("detail") or "").strip() + return { + "type": "stage", + "seq": _webchat_event_seq(event), + "stage": stage, + "detail": detail, + "elapsed_ms": payload.get("elapsed_ms"), + "stage_elapsed_ms": payload.get("stage_elapsed_ms"), + } + + +def _populate_webchat_node_timelines( + nodes: dict[str, dict[str, Any]], + events: list[dict[str, Any]], +) -> None: + emitted_child_calls: set[str] = set() + for node in nodes.values(): + node["timeline"] = [] + for item in events: + event = str(item.get("event") or "") + payload = item.get("payload") + if not isinstance(payload, dict): + payload = {} + if event == "message": + parent_id = str(payload.get("parent_webchat_call_id") or "").strip() + parent = nodes.get(parent_id) + if parent is None: + continue + message_item = _webchat_message_timeline_item(event=item, payload=payload) + if message_item is not None: + parent.setdefault("timeline", []).append(message_item) + continue + if event == "agent_stage": + call_id = _webchat_event_call_id(item) + parent = nodes.get(call_id) + if parent is None: + continue + stage_item = _webchat_agent_stage_timeline_item(event=item, payload=payload) + if stage_item is not None: + parent.setdefault("timeline", []).append(stage_item) + continue + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + continue + call_id = _webchat_event_call_id(item) + if not call_id or call_id in emitted_child_calls: + continue + call_node = nodes.get(call_id) + if call_node is None: + continue + parent_id = str(call_node.get("parent_webchat_call_id") or "").strip() + call_parent = nodes.get(parent_id) + if call_parent is None: + continue + emitted_child_calls.add(call_id) + call_parent.setdefault("timeline", []).append( + {"type": "call", "seq": _webchat_event_seq(item), "call": call_node} + ) + for node in nodes.values(): + timeline = node.get("timeline") + if isinstance(timeline, list): + timeline.sort(key=lambda entry: int(entry.get("seq", 0) or 0)) + + +def _build_webchat_timeline(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + nodes, _order, _roots = _build_webchat_call_graph(events) + emitted_calls: set[str] = set() + timeline: list[dict[str, Any]] = [] + for item in events: + event = str(item.get("event") or "") + payload = item.get("payload") + if not isinstance(payload, dict): + payload = {} + if event == "message": + if str(payload.get("parent_webchat_call_id") or "").strip(): + continue + message_item = _webchat_message_timeline_item(event=item, payload=payload) + if message_item is not None: + timeline.append(message_item) + continue + if event == "agent_stage": + call_id = _webchat_event_call_id(item) + if call_id and call_id in nodes: + continue + stage_item = _webchat_agent_stage_timeline_item(event=item, payload=payload) + if stage_item is not None: + timeline.append(stage_item) + continue + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + continue + call_id = _webchat_event_call_id(item) + if not call_id or call_id in emitted_calls: + continue + node = nodes.get(call_id) + if node is None: + continue + parent_id = str(node.get("parent_webchat_call_id") or "").strip() + if parent_id and parent_id in nodes: + continue + emitted_calls.add(call_id) + timeline.append({"type": "call", "seq": _webchat_event_seq(item), "call": node}) + return timeline + + +def _webchat_history_events(webchat: Any) -> list[dict[str, Any]]: + if not isinstance(webchat, dict): + return [] + raw_events = webchat.get("events") + if not isinstance(raw_events, list): + return [] + events: list[dict[str, Any]] = [] + for item in raw_events: + if not isinstance(item, dict): + continue + event = str(item.get("event", "") or "").strip() + if event not in _WEBCHAT_HISTORY_EVENTS: + continue + payload = item.get("payload") + if not isinstance(payload, dict): + payload = {} + seq_raw = item.get("seq", 0) + try: + seq = max(0, int(seq_raw)) + except (TypeError, ValueError): + seq = 0 + events.append( + { + "seq": seq, + "event": event, + "payload": _redact_webchat_display_payload(payload), + } + ) + return events + + +def _webchat_history_calls(webchat: Any) -> list[dict[str, Any]]: + if not isinstance(webchat, dict): + return [] + raw_calls = webchat.get("calls") + if isinstance(raw_calls, list): + return [ + _redact_webchat_display_tree(item) + for item in raw_calls + if isinstance(item, dict) + ] + return _build_webchat_call_tree(_webchat_history_events(webchat)) + + +def _webchat_history_timeline(webchat: Any) -> list[dict[str, Any]]: + if not isinstance(webchat, dict): + return [] + raw_timeline = webchat.get("timeline") + if isinstance(raw_timeline, list): + return [ + _redact_webchat_display_tree(item) + for item in raw_timeline + if isinstance(item, dict) + ] + return _build_webchat_timeline(_webchat_history_events(webchat)) + + +def _is_webchat_display_only_record(item: dict[str, Any]) -> bool: + if str(item.get("message", "") or "").strip(): + return False + webchat = item.get("webchat") + if not isinstance(webchat, dict): + return False + return bool(webchat.get("display_only")) and bool(_webchat_history_events(webchat)) + + +def _filter_webchat_display_only_records( + records: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [item for item in records if not _is_webchat_display_only_record(item)] + + +async def _write_sse_event(response: web.StreamResponse, item: ChatJobEvent) -> None: + await response.write(_sse_event(item.event, item.payload, item.seq)) + + +def _parse_limit(request: web.Request, default: int = 50, maximum: int = 500) -> int: + limit_raw = str(request.query.get("limit", str(default)) or str(default)).strip() + try: + limit = int(limit_raw) + except ValueError: + limit = default + return max(1, min(limit, maximum)) + + +def _parse_before(request: web.Request) -> int | None: + raw = request.query.get("before") + if raw is None: + return None + text = str(raw or "").strip() + if not text: + return None + try: + return max(0, int(text)) + except ValueError: + return None + + +def _parse_after(request: web.Request) -> int: + raw = request.query.get("after") + if raw is None: + raw = request.headers.get("Last-Event-ID") + try: + return max(0, int(str(raw or "0").strip())) + except ValueError: + return 0 + + +def _query_conversation_id(request: web.Request) -> str: + return str(request.query.get("conversation_id", "") or "").strip() + + +def _body_conversation_id(body: dict[str, Any]) -> str: + return str(body.get("conversation_id", "") or "").strip() + + +async def _resolve_conversation_id( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + *, + raw_conversation_id: str = "", + create_default: bool = True, +) -> str: + await job_manager.conversation_store.ensure_ready(ctx.history_manager) + conversation_id = str(raw_conversation_id or "").strip() + if conversation_id: + conversation = await job_manager.conversation_store.get_conversation( + conversation_id + ) + if conversation is None: + raise KeyError(conversation_id) + return str(conversation["id"]) + if not create_default: + return "" + conversation = await job_manager.conversation_store.get_conversation( + _DEFAULT_CONVERSATION_ID + ) + if conversation is None: + conversation = ( + await job_manager.conversation_store.ensure_default_conversation() + ) + return str(conversation["id"]) + + +async def _history_record_to_item( + item: dict[str, Any], + *, + attachment_registry: Any | None = None, + scope_key: str | None = None, +) -> dict[str, Any] | None: + content = str(item.get("message", "")).strip() + webchat = item.get("webchat") + webchat_events = _webchat_history_events(webchat) + attachments = await _history_attachments( + item.get("attachments"), + attachment_registry=attachment_registry, + scope_key=scope_key, + ) + if not content and not webchat_events and not attachments: + return None + display_name = str(item.get("display_name", "")).strip().lower() + role = "bot" if display_name == "bot" else "user" + mapped: dict[str, Any] = { + "role": role, + "content": content, + "timestamp": str(item.get("timestamp", "") or "").strip(), + } + if attachments: + mapped["attachments"] = attachments + if isinstance(webchat, dict) and webchat_events: + webchat_calls = _webchat_history_calls(webchat) + webchat_timeline = _webchat_history_timeline(webchat) + mapped["webchat"] = { + "display_only": bool(webchat.get("display_only")), + "job_id": str(webchat.get("job_id", "") or "").strip(), + "mode": str(webchat.get("mode", "") or "").strip(), + "status": str(webchat.get("status", "") or "").strip(), + "created_at": webchat.get("created_at"), + "finished_at": webchat.get("finished_at"), + "duration_ms": webchat.get("duration_ms"), + "events": webchat_events, + "calls": webchat_calls, + "timeline": webchat_timeline, + } + return mapped + + +async def _history_attachments( + raw: Any, + *, + attachment_registry: Any | None = None, + scope_key: str | None = None, +) -> list[dict[str, str]]: + if not isinstance(raw, list): + return [] + attachments: list[dict[str, str]] = [] + if attachment_registry is not None: + load = getattr(attachment_registry, "load", None) + if callable(load): + with suppress(Exception): + await load() + for item in raw: + if not isinstance(item, dict): + continue + uid = str(item.get("uid", "") or "").strip() + if not uid: + continue + media_type = str(item.get("media_type") or item.get("kind") or "file").strip() + kind = str(item.get("kind") or media_type or "file").strip() + ref: dict[str, str] = { + "uid": uid, + "kind": kind or "file", + "media_type": media_type or kind or "file", + "display_name": str(item.get("display_name", "") or ""), + } + for key in ("source_kind", "source_ref", "semantic_kind", "description"): + value = str(item.get(key, "") or "").strip() + if value: + ref[key] = value + resolved = await _resolve_history_attachment( + uid, + attachment_registry=attachment_registry, + scope_key=scope_key, + ) + if resolved is not None: + ref.update(await _history_attachment_render_fields(resolved)) + attachments.append(ref) + return attachments + + +async def _resolve_history_attachment( + uid: str, + *, + attachment_registry: Any | None, + scope_key: str | None, +) -> Any | None: + if attachment_registry is None: + return None + try: + resolve_async = getattr(attachment_registry, "resolve_async", None) + if callable(resolve_async): + return await resolve_async(uid, scope_key) + resolve = getattr(attachment_registry, "resolve", None) + if callable(resolve): + return resolve(uid, scope_key) + except Exception as exc: + logger.debug( + "[RuntimeAPI] resolve history attachment failed uid=%s err=%s", uid, exc + ) + return None + + +async def _history_attachment_render_fields(record: Any) -> dict[str, str]: + fields: dict[str, str] = {} + source_ref = str(getattr(record, "source_ref", "") or "").strip() + if source_ref: + fields["source_ref"] = source_ref + local_path = str(getattr(record, "local_path", "") or "").strip() + media_type = str(getattr(record, "media_type", "") or "").strip().lower() + if media_type == "image": + if local_path: + try: + path = Path(local_path) + if await async_io.is_file(path): + fields["render_source"] = path.resolve().as_uri() + except OSError: + pass + if "render_source" not in fields and source_ref: + fields["render_source"] = source_ref + if source_ref.isalnum(): + fields["file_id"] = source_ref + return fields async def run_webui_chat( @@ -44,11 +1822,41 @@ async def run_webui_chat( *, text: str, send_output: Callable[[int, str], Awaitable[None]], + webchat_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, + conversation_store: WebChatConversationStore | None = None, + conversation_id: str | None = None, ) -> str: """Execute a single WebUI chat turn (command dispatch or AI ask).""" + async def emit_stage(stage: str, detail: Any | None = None) -> None: + if webchat_event_callback is None: + return + await webchat_event_callback( + "stage", + {"stage": stage, **({"detail": detail} if detail is not None else {})}, + ) + cfg = ctx.config_getter() permission_sender_id = int(cfg.superadmin_qq) + resolved_conversation_id = ( + str(conversation_id or _DEFAULT_CONVERSATION_ID).strip() + or _DEFAULT_CONVERSATION_ID + ) + store = conversation_store or WebChatConversationStore() + await store.ensure_ready(ctx.history_manager) + logger.info( + "[RuntimeAPI][WebChat] 开始处理输入: conversation_id=%s text_len=%s", + resolved_conversation_id, + len(text), + ) + if conversation_id: + existing_conversation = await store.get_conversation(resolved_conversation_id) + if existing_conversation is None: + raise KeyError(resolved_conversation_id) + elif resolved_conversation_id == _DEFAULT_CONVERSATION_ID: + await store.ensure_default_conversation() + history_adapter = store.adapter(resolved_conversation_id) webui_scope_key = build_attachment_scope( user_id=_VIRTUAL_USER_ID, request_type="private", @@ -63,8 +1871,16 @@ async def run_webui_chat( get_forward_messages=ctx.onebot.get_forward_msg, ) normalized_text = registered_input.normalized_text or text - await ctx.history_manager.add_private_message( - user_id=_VIRTUAL_USER_ID, + logger.info( + "[RuntimeAPI][WebChat] 输入附件注册完成: conversation_id=%s normalized_len=%s attachments=%s", + resolved_conversation_id, + len(normalized_text), + len(registered_input.attachments), + ) + await emit_stage("recording_history") + await store.append_message( + resolved_conversation_id, + role="user", text_content=normalized_text, display_name=_VIRTUAL_USER_NAME, user_name=_VIRTUAL_USER_NAME, @@ -73,6 +1889,12 @@ async def run_webui_chat( command = ctx.command_dispatcher.parse_command(normalized_text) if command: + logger.info( + "[RuntimeAPI][WebChat] 分发私聊命令: conversation_id=%s command=%s", + resolved_conversation_id, + getattr(command, "name", ""), + ) + await emit_stage("running_command") await ctx.command_dispatcher.dispatch_private( user_id=_VIRTUAL_USER_ID, sender_id=permission_sender_id, @@ -80,6 +1902,11 @@ async def run_webui_chat( send_private_callback=send_output, is_webui_session=True, ) + await emit_stage("command_done") + logger.info( + "[RuntimeAPI][WebChat] 私聊命令完成: conversation_id=%s", + resolved_conversation_id, + ) return "command" current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -88,14 +1915,17 @@ async def run_webui_chat( if registered_input.attachments else "" ) - full_question = f""" - {escape_xml_text(normalized_text)}{attachment_xml} - + message_xml = format_webchat_message_xml( + normalized_text, attachment_xml, current_time + ) + full_question = f"""{message_xml} 【WebUI 会话】 这是一条来自 WebUI 控制台的会话请求。 会话身份:虚拟用户 system(42)。 权限等级:superadmin(你可按最高管理权限处理)。 +WebUI 支持完整 Markdown 渲染和简单安全 HTML。复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放进 fenced code block;完整 HTML 页面请优先使用 ```html 代码框,方便 WebUI 的运行按钮预览。 +需要输出代码时,优先在当前聊天消息中直接给出,不要为了普通代码片段调用文件生成或文件发送工具;只有用户明确要求文件交付、内容长到不适合聊天展示,或确需附件工作流时才使用文件。所有代码都必须使用 fenced code block,并始终标明语言或类型,例如 ```python、```javascript、```html、```bash、```text;不确定语言时使用 ```text。 请正常进行私聊对话;如果需要结束会话,调用 end 工具。""" virtual_sender = _WebUIVirtualSender( _VIRTUAL_USER_ID, send_output, onebot=ctx.onebot @@ -104,16 +1934,17 @@ async def run_webui_chat( async def _get_recent_cb( chat_id: str, msg_type: str, start: int, end: int ) -> list[dict[str, Any]]: - return await get_recent_messages_prefer_local( + recent_messages = await get_recent_messages_prefer_local( chat_id=chat_id, msg_type=msg_type, start=start, end=end, onebot_client=ctx.onebot, - history_manager=ctx.history_manager, + history_manager=history_adapter, bot_qq=cfg.bot_qq, attachment_registry=getattr(ctx.ai, "attachment_registry", None), ) + return _filter_webchat_display_only_records(recent_messages) async with RequestContext( request_type="private", @@ -124,7 +1955,7 @@ async def _get_recent_cb( memory_storage = ctx.ai.memory_storage # noqa: F841 runtime_config = ctx.ai.runtime_config # noqa: F841 sender = virtual_sender # noqa: F841 - history_manager = ctx.history_manager # noqa: F841 + history_manager = history_adapter # noqa: F841 onebot_client = ctx.onebot # noqa: F841 scheduler = ctx.scheduler # noqa: F841 @@ -147,6 +1978,12 @@ def send_message_callback( rctx.set_resource("webui_session", True) rctx.set_resource("webui_permission", "superadmin") + await emit_stage("asking_ai") + logger.info( + "[RuntimeAPI][WebChat] 调用 AI: conversation_id=%s prompt_len=%s", + resolved_conversation_id, + len(full_question), + ) result = await ctx.ai.ask( full_question, send_message_callback=send_message_callback, @@ -164,6 +2001,8 @@ def send_message_callback( "sender_name": _VIRTUAL_USER_NAME, "webui_session": True, "webui_permission": "superadmin", + "webchat_conversation_id": resolved_conversation_id, + "webchat_event_callback": webchat_event_callback, }, ) @@ -171,55 +2010,217 @@ def send_message_callback( if final_reply: await send_output(_VIRTUAL_USER_ID, final_reply) + logger.info( + "[RuntimeAPI][WebChat] AI 调用结束: conversation_id=%s final_reply_len=%s", + resolved_conversation_id, + len(final_reply), + ) return "chat" -async def chat_history_handler( - ctx: RuntimeAPIContext, request: web.Request +async def chat_conversations_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, ) -> Response: - """Return recent WebUI chat history.""" + _ = request + await job_manager.conversation_store.ensure_ready(ctx.history_manager) + conversations = await job_manager.conversation_store.list_conversations() + active_job = await job_manager.get_active_job() + active_snapshot = ( + await job_manager.snapshot(active_job) if active_job is not None else None + ) + for item in conversations: + conversation_id = str(item.get("id") or "") + if conversation_id: + await job_manager.maybe_schedule_title_generation(conversation_id) + item["is_running"] = bool( + active_job is not None and active_job.conversation_id == conversation_id + ) + logger.info( + "[RuntimeAPI][WebChat] 查询会话列表: count=%s active_job=%s", + len(conversations), + active_job.job_id if active_job is not None else "", + ) + return web.json_response( + { + "conversations": conversations, + "active_job": active_snapshot, + "default_conversation_id": _DEFAULT_CONVERSATION_ID, + "virtual_user_id": _VIRTUAL_USER_ID, + } + ) + - limit_raw = str(request.query.get("limit", "200") or "200").strip() +async def chat_conversation_create_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + await job_manager.conversation_store.ensure_ready(ctx.history_manager) try: - limit = int(limit_raw) - except ValueError: - limit = 200 - limit = max(1, min(limit, 500)) + body = await request.json() + except Exception: + body = {} + title = str(body.get("title", "") or "").strip() + conversation = await job_manager.conversation_store.create_conversation( + title=title or None, + ) + logger.info( + "[RuntimeAPI][WebChat] API 新建会话: conversation_id=%s title_len=%s", + conversation.get("id", ""), + len(str(conversation.get("title", "") or "")), + ) + return web.json_response({"conversation": conversation}, status=201) - getter = getattr(ctx.history_manager, "get_recent_private", None) - if not callable(getter): - return _json_error("History manager not ready", status=503) - records = getter(_VIRTUAL_USER_ID, limit) - items: list[dict[str, Any]] = [] - for item in records: - if not isinstance(item, dict): - continue - content = str(item.get("message", "")).strip() - if not content: - continue - display_name = str(item.get("display_name", "")).strip().lower() - role = "bot" if display_name == "bot" else "user" - items.append( - { - "role": role, - "content": content, - "timestamp": str(item.get("timestamp", "") or "").strip(), - } +async def chat_conversation_update_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + await job_manager.conversation_store.ensure_ready(ctx.history_manager) + conversation_id = str(request.match_info.get("conversation_id", "") or "").strip() + try: + body = await request.json() + except Exception: + return _json_error("Invalid JSON", status=400) + title = str(body.get("title", "") or "").strip() + try: + conversation = await job_manager.conversation_store.rename_conversation( + conversation_id, + title, + ) + except KeyError: + return _json_error("Conversation not found", status=404) + except ValueError as exc: + return _json_error(str(exc), status=400) + logger.info( + "[RuntimeAPI][WebChat] API 重命名会话: conversation_id=%s title_len=%s", + conversation_id, + len(title), + ) + return web.json_response({"conversation": conversation}) + + +async def chat_conversation_delete_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + await job_manager.conversation_store.ensure_ready(ctx.history_manager) + conversation_id = str(request.match_info.get("conversation_id", "") or "").strip() + if await job_manager.has_running_job(): + return _json_error("Chat job is still running", status=409) + existed = await job_manager.conversation_store.delete_conversation(conversation_id) + if not existed: + return _json_error("Conversation not found", status=404) + logger.info( + "[RuntimeAPI][WebChat] API 删除会话: conversation_id=%s", + conversation_id, + ) + return web.json_response({"success": True, "conversation_id": conversation_id}) + + +async def chat_history_handler( + ctx: RuntimeAPIContext, job_manager: ChatJobManager, request: web.Request +) -> Response: + """Return recent WebUI chat history.""" + + limit = _parse_limit(request, default=50, maximum=500) + before = _parse_before(request) + try: + conversation_id = await _resolve_conversation_id( + ctx, + job_manager, + raw_conversation_id=_query_conversation_id(request), + ) + page = await job_manager.conversation_store.get_history_page( + conversation_id, + limit=limit, + before=before, ) + except KeyError: + return _json_error("Conversation not found", status=404) + items: list[dict[str, Any]] = [] + for record in page.records: + if isinstance(record, dict): + mapped = await _history_record_to_item( + record, + attachment_registry=getattr(ctx.ai, "attachment_registry", None), + scope_key=build_attachment_scope( + user_id=_VIRTUAL_USER_ID, + request_type="private", + webui_session=True, + ), + ) + if mapped is not None: + items.append(mapped) + await job_manager.maybe_schedule_title_generation(conversation_id) + logger.info( + "[RuntimeAPI][WebChat] 查询历史: conversation_id=%s returned=%s total=%s has_more=%s before=%s", + conversation_id, + len(items), + page.total, + page.has_more, + before, + ) return web.json_response( { + "conversation_id": conversation_id, "virtual_user_id": _VIRTUAL_USER_ID, "permission": "superadmin", "count": len(items), "items": items, + "limit": limit, + "before": before, + "has_more": page.has_more, + "next_before": page.next_before, + "total": page.total, + } + ) + + +async def chat_history_clear_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + """Clear WebUI virtual private chat history only.""" + + try: + conversation_id = await _resolve_conversation_id( + ctx, + job_manager, + raw_conversation_id=_query_conversation_id(request), + ) + cleared = await job_manager.clear_history_when_idle(conversation_id) + except KeyError: + return _json_error("Conversation not found", status=404) + except RuntimeError: + return _json_error("History manager not ready", status=503) + if cleared is None: + return _json_error("Chat job is still running", status=409) + logger.info( + "[RuntimeAPI][WebChat] API 清空历史: conversation_id=%s cleared=%s", + conversation_id, + cleared, + ) + return web.json_response( + { + "success": True, + "conversation_id": conversation_id, + "virtual_user_id": _VIRTUAL_USER_ID, + "cleared": cleared, } ) async def chat_handler( - ctx: RuntimeAPIContext, request: web.Request + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, ) -> web.StreamResponse: """Handle a WebUI chat request (non-streaming or SSE streaming).""" @@ -231,47 +2232,86 @@ async def chat_handler( text = str(body.get("message", "") or "").strip() if not text: return _json_error("message is required", status=400) + try: + conversation_id = await _resolve_conversation_id( + ctx, + job_manager, + raw_conversation_id=_body_conversation_id(body), + ) + except KeyError: + return _json_error("Conversation not found", status=404) stream = _to_bool(body.get("stream")) - outputs: list[str] = [] - webui_scope_key = build_attachment_scope( - user_id=_VIRTUAL_USER_ID, - request_type="private", - webui_session=True, + logger.info( + "[RuntimeAPI][WebChat] 收到聊天请求: conversation_id=%s stream=%s text_len=%s", + conversation_id, + stream, + len(text), ) - - async def _capture_private_message(user_id: int, message: str) -> None: - _ = user_id - content = str(message or "").strip() - if not content: - return - rendered = await render_message_with_pic_placeholders( - content, - registry=ctx.ai.attachment_registry, - scope_key=webui_scope_key, - strict=False, - ) - if not rendered.delivery_text.strip(): - return - outputs.append(rendered.delivery_text) - await ctx.history_manager.add_private_message( + if not stream: + outputs: list[str] = [] + webui_scope_key = build_attachment_scope( user_id=_VIRTUAL_USER_ID, - text_content=rendered.history_text, - display_name="Bot", - user_name="Bot", - attachments=rendered.attachments, + request_type="private", + webui_session=True, ) - if not stream: + async def _capture_private_message(user_id: int, message: str) -> None: + _ = user_id + content = str(message or "").strip() + if not content: + return + rendered = await render_message_with_pic_placeholders( + content, + registry=ctx.ai.attachment_registry, + scope_key=webui_scope_key, + strict=False, + ) + if not rendered.delivery_text.strip(): + return + outputs.append(rendered.delivery_text) + await job_manager.conversation_store.append_message( + conversation_id, + role="bot", + text_content=rendered.history_text, + display_name="Bot", + user_name="Bot", + attachments=rendered.attachments, + ) + try: mode = await run_webui_chat( - ctx, text=text, send_output=_capture_private_message + ctx, + text=text, + send_output=_capture_private_message, + conversation_store=job_manager.conversation_store, + conversation_id=conversation_id, ) + await job_manager.maybe_schedule_title_generation(conversation_id) except Exception as exc: logger.exception("[RuntimeAPI] chat failed: %s", exc) return _json_error("Chat failed", status=502) - return web.json_response(_build_chat_response_payload(mode, outputs)) + payload = _build_chat_response_payload(mode, outputs) + payload["conversation_id"] = conversation_id + logger.info( + "[RuntimeAPI][WebChat] 非流式聊天完成: conversation_id=%s mode=%s outputs=%s", + conversation_id, + mode, + len(outputs), + ) + return web.json_response(payload) + try: + job = await job_manager.create_job(text, conversation_id) + except KeyError: + return _json_error("Conversation not found", status=404) + except RuntimeError: + return _json_error("Chat job is still running", status=409) + logger.info( + "[RuntimeAPI][WebChat] SSE 聊天 job 已创建: job_id=%s conversation_id=%s", + job.job_id, + conversation_id, + ) response = web.StreamResponse( status=200, reason="OK", @@ -282,74 +2322,47 @@ async def _capture_private_message(user_id: int, message: str) -> None: }, ) await response.prepare(request) - - message_queue: asyncio.Queue[str] = asyncio.Queue() - - async def _capture_private_message_stream(user_id: int, message: str) -> None: - output_count = len(outputs) - await _capture_private_message(user_id, message) - if len(outputs) <= output_count: - return - content = outputs[-1].strip() - if content: - await message_queue.put(content) - - task = asyncio.create_task( - run_webui_chat(ctx, text=text, send_output=_capture_private_message_stream) - ) - mode = "chat" - client_disconnected = False + after = 0 try: - await response.write( - _sse_event( - "meta", - { - "virtual_user_id": _VIRTUAL_USER_ID, - "permission": "superadmin", - }, - ) - ) - while True: if request.transport is None or request.transport.is_closing(): - client_disconnected = True - break - if task.done() and message_queue.empty(): break - try: - message = await asyncio.wait_for( - message_queue.get(), - timeout=_CHAT_SSE_KEEPALIVE_SECONDS, - ) - await response.write(_sse_event("message", {"content": message})) - except asyncio.TimeoutError: + events = await job_manager.wait_for_events_after( + job, + after, + timeout=min(_CHAT_STAGE_REFRESH_SECONDS, _CHAT_SSE_KEEPALIVE_SECONDS), + ) + if not events: + ( + events, + _snapshot, + live_events, + ) = await job_manager.events_after_with_snapshot(job, after) + for item in events: + await _write_sse_event(response, item) + after = item.seq + for live_event in live_events: + await _write_sse_event(response, live_event) + after = max(after, live_event.seq) + if events or live_events: + if job.done.is_set(): + break + continue await response.write(b": keep-alive\n\n") - - if client_disconnected: - task.cancel() - with suppress(asyncio.CancelledError): - await task - return response - - mode = await task - await response.write( - _sse_event("done", _build_chat_response_payload(mode, outputs)) - ) + if job.done.is_set(): + break + continue + for item in events: + await _write_sse_event(response, item) + after = item.seq + if job.done.is_set() and after >= job.next_seq - 1: + break except asyncio.CancelledError: - task.cancel() - with suppress(asyncio.CancelledError): - await task raise except (ConnectionResetError, RuntimeError): - task.cancel() - with suppress(asyncio.CancelledError): - await task + pass except Exception as exc: logger.exception("[RuntimeAPI] chat stream failed: %s", exc) - if not task.done(): - task.cancel() - with suppress(asyncio.CancelledError): - await task with suppress(Exception): await response.write(_sse_event("error", {"error": str(exc)})) finally: @@ -357,3 +2370,187 @@ async def _capture_private_message_stream(user_id: int, message: str) -> None: await response.write_eof() return response + + +async def chat_job_create_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + try: + body = await request.json() + except Exception: + return _json_error("Invalid JSON", status=400) + text = str(body.get("message", "") or "").strip() + if not text: + return _json_error("message is required", status=400) + try: + conversation_id = await _resolve_conversation_id( + ctx, + job_manager, + raw_conversation_id=_body_conversation_id(body), + ) + job = await job_manager.create_job(text, conversation_id) + except KeyError: + return _json_error("Conversation not found", status=404) + except RuntimeError: + return _json_error("Chat job is still running", status=409) + logger.info( + "[RuntimeAPI][WebChat] API 创建后台 job: job_id=%s conversation_id=%s text_len=%s", + job.job_id, + conversation_id, + len(text), + ) + return web.json_response(await job_manager.snapshot(job), status=202) + + +async def chat_job_active_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + _ = ctx + raw_conversation_id = _query_conversation_id(request) + job = await job_manager.get_active_job(raw_conversation_id or None) + snapshot = await job_manager.snapshot(job) if job is not None else None + logger.debug( + "[RuntimeAPI][WebChat] 查询 active job: conversation_id=%s job_id=%s", + raw_conversation_id, + job.job_id if job is not None else "", + ) + return web.json_response({"job": snapshot}) + + +async def chat_job_detail_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + _ = ctx + job_id = str(request.match_info.get("job_id", "") or "").strip() + job = await job_manager.get_job(job_id) + if job is None: + return _json_error("Job not found", status=404) + return web.json_response(await job_manager.snapshot(job)) + + +async def chat_job_cancel_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + _ = ctx, request + job_id = str(request.match_info.get("job_id", "") or "").strip() + job = await job_manager.cancel_job(job_id) + if job is None: + return _json_error("Job not found", status=404) + logger.info( + "[RuntimeAPI][WebChat] API 取消 job: job_id=%s conversation_id=%s status=%s", + job.job_id, + job.conversation_id, + job.status, + ) + return web.json_response(await job_manager.snapshot(job)) + + +async def chat_job_events_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> web.StreamResponse: + _ = ctx + job_id = str(request.match_info.get("job_id", "") or "").strip() + job = await job_manager.get_job(job_id) + if job is None: + return _json_error("Job not found", status=404) + requested_conversation_id = _query_conversation_id(request) + if requested_conversation_id and requested_conversation_id != job.conversation_id: + return _json_error("Job not found", status=404) + after = _parse_after(request) + accept_header = str(request.headers.get("Accept", "") or "").strip().lower() + wants_sse = "text/event-stream" in accept_header + wants_json = not wants_sse or ( + str(request.query.get("format", "") or "").strip().lower() == "json" + or "application/json" in accept_header + ) + if wants_json: + events, snapshot, live_events = await job_manager.events_after_with_snapshot( + job, after + ) + logger.debug( + "[RuntimeAPI][WebChat] 查询 job 事件: job_id=%s conversation_id=%s after=%s events=%s live_events=%s status=%s", + job.job_id, + job.conversation_id, + after, + len(events), + len(live_events), + job.status, + ) + return web.json_response( + { + "job": snapshot, + "after": after, + "last_seq": job.next_seq - 1, + "events": [ + { + "seq": event.seq, + "event": event.event, + "payload": dict(event.payload), + } + for event in [*events, *live_events] + ], + } + ) + + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + await response.prepare(request) + try: + while True: + if request.transport is None or request.transport.is_closing(): + break + events = await job_manager.wait_for_events_after( + job, + after, + timeout=min(_CHAT_STAGE_REFRESH_SECONDS, _CHAT_SSE_KEEPALIVE_SECONDS), + ) + if not events: + ( + events, + _snapshot, + live_events, + ) = await job_manager.events_after_with_snapshot(job, after) + for item in events: + await _write_sse_event(response, item) + after = item.seq + for live_event in live_events: + await _write_sse_event(response, live_event) + after = max(after, live_event.seq) + if events or live_events: + if job.done.is_set(): + break + continue + await response.write(b": keep-alive\n\n") + if job.done.is_set(): + break + continue + for item in events: + await _write_sse_event(response, item) + after = item.seq + if job.done.is_set() and after >= job.next_seq - 1: + break + except asyncio.CancelledError: + raise + except (ConnectionResetError, RuntimeError): + pass + finally: + with suppress(Exception): + await response.write_eof() + return response diff --git a/src/Undefined/api/routes/commands.py b/src/Undefined/api/routes/commands.py new file mode 100644 index 00000000..aebb2f6e --- /dev/null +++ b/src/Undefined/api/routes/commands.py @@ -0,0 +1,393 @@ +"""Slash command metadata route handlers for the Runtime API.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, cast + +from aiohttp import web +from aiohttp.web_response import Response + +from Undefined.api._context import RuntimeAPIContext +from Undefined.api._helpers import _VIRTUAL_USER_ID, _json_error, _to_bool +from Undefined.services.commands.context import CommandContext +from Undefined.services.commands.registry import CommandMeta, SubcommandMeta + +logger = logging.getLogger(__name__) + +_DEFAULT_COMMAND_SCOPE = "webui" +_VALID_COMMAND_SCOPES = frozenset({"webui", "private", "group"}) + + +@dataclass(frozen=True) +class _CommandRequestContext: + command_context: CommandContext + api_scope: str + execution_scope: str + sender_id: int + user_id: int | None + group_id: int + + +def _coerce_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _config_is_superadmin(config: Any, sender_id: int) -> bool: + checker = getattr(config, "is_superadmin", None) + if callable(checker): + return bool(checker(sender_id)) + return sender_id == _coerce_int(getattr(config, "superadmin_qq", 0), 0) + + +def _config_is_admin(config: Any, sender_id: int) -> bool: + checker = getattr(config, "is_admin", None) + if callable(checker): + return bool(checker(sender_id)) + admin_qqs = getattr(config, "admin_qqs", []) or [] + return sender_id in {_coerce_int(item, 0) for item in admin_qqs} + + +def _check_permission(config: Any, permission: str, sender_id: int) -> bool: + normalized = str(permission or "public").strip().lower() + if normalized == "superadmin": + return _config_is_superadmin(config, sender_id) + if normalized == "admin": + return _config_is_admin(config, sender_id) or _config_is_superadmin( + config, sender_id + ) + return True + + +def _permission_label(permission: str) -> str: + normalized = str(permission or "public").strip().lower() + if normalized == "superadmin": + return "superadmin" + if normalized == "admin": + return "admin" + return "public" + + +def _availability_reason( + *, + scope: str, + allow_in_private: bool, + permission: str, + permission_allowed: bool, + policy_visible: bool, +) -> str | None: + if not policy_visible: + return "policy_hidden" + if scope == "private" and not allow_in_private: + return "private_not_allowed" + if not permission_allowed: + return f"requires_{_permission_label(permission)}" + return None + + +def _build_command_request_context( + ctx: RuntimeAPIContext, request: web.Request +) -> _CommandRequestContext: + cfg = ctx.config_getter() + raw_scope = str(request.query.get("scope", _DEFAULT_COMMAND_SCOPE) or "").lower() + api_scope = ( + raw_scope if raw_scope in _VALID_COMMAND_SCOPES else _DEFAULT_COMMAND_SCOPE + ) + + if api_scope == "webui": + execution_scope = "private" + sender_id = _coerce_int(getattr(cfg, "superadmin_qq", 0), 0) + user_id: int | None = _VIRTUAL_USER_ID + group_id = 0 + is_webui_session = True + elif api_scope == "private": + execution_scope = "private" + sender_id = _coerce_int( + request.query.get("sender_id"), + _coerce_int(getattr(cfg, "superadmin_qq", 0), 0), + ) + user_id = _coerce_int(request.query.get("user_id"), sender_id) + group_id = 0 + is_webui_session = False + else: + execution_scope = "group" + sender_id = _coerce_int( + request.query.get("sender_id"), + _coerce_int(getattr(cfg, "superadmin_qq", 0), 0), + ) + user_id = None + group_id = _coerce_int(request.query.get("group_id"), 0) + is_webui_session = False + + dispatcher = ctx.command_dispatcher + command_registry = getattr(dispatcher, "command_registry", None) + if command_registry is None: + raise RuntimeError("command registry is unavailable") + command_context = CommandContext( + group_id=group_id, + sender_id=sender_id, + config=cfg, + sender=cast(Any, getattr(dispatcher, "sender", ctx.sender)), + ai=getattr(dispatcher, "ai", ctx.ai), + faq_storage=cast(Any, getattr(dispatcher, "faq_storage", None)), + onebot=cast(Any, getattr(dispatcher, "onebot", ctx.onebot)), + security=cast(Any, getattr(dispatcher, "security", None)), + queue_manager=getattr(dispatcher, "queue_manager", ctx.queue_manager), + rate_limiter=getattr(dispatcher, "rate_limiter", None), + dispatcher=dispatcher, + registry=command_registry, + scope=execution_scope, + user_id=user_id, + is_webui_session=is_webui_session, + cognitive_service=getattr(ctx, "cognitive_service", None), + history_manager=ctx.history_manager, + ) + return _CommandRequestContext( + command_context=command_context, + api_scope=api_scope, + execution_scope=execution_scope, + sender_id=sender_id, + user_id=user_id, + group_id=group_id, + ) + + +def _subcommand_usage(command: CommandMeta, subcommand: SubcommandMeta) -> str: + args = str(subcommand.args or "").strip() + return f"/{command.name} {subcommand.name}{f' {args}' if args else ''}" + + +def _serialize_subcommand( + command: CommandMeta, + subcommand: SubcommandMeta, + *, + request_context: _CommandRequestContext, + policy_visible: bool, +) -> dict[str, Any]: + scope = request_context.execution_scope + permission_allowed = _check_permission( + request_context.command_context.config, + subcommand.permission, + request_context.sender_id, + ) + unavailable_reason = _availability_reason( + scope=scope, + allow_in_private=subcommand.allow_in_private, + permission=subcommand.permission, + permission_allowed=permission_allowed, + policy_visible=policy_visible, + ) + return { + "name": subcommand.name, + "trigger": f"/{command.name} {subcommand.name}", + "description": subcommand.description, + "args": subcommand.args, + "usage": _subcommand_usage(command, subcommand), + "permission": subcommand.permission, + "allow_in_private": subcommand.allow_in_private, + "available": unavailable_reason is None, + "unavailable_reason": unavailable_reason, + } + + +def _serialize_inference(command: CommandMeta) -> dict[str, Any] | None: + inference = command.inference + if inference is None: + return None + return { + "default": inference.default, + "fallback": inference.fallback, + "rules": [ + {"pattern": rule.pattern.pattern, "subcommand": rule.subcommand} + for rule in inference.rules + ], + } + + +def _serialize_command( + command: CommandMeta, + *, + request_context: _CommandRequestContext, + include_unavailable: bool, +) -> dict[str, Any]: + registry = request_context.command_context.registry + policy_visible = True + if registry is not None: + policy_visible = bool( + registry.is_visible(command, request_context.command_context) + ) + + permission_allowed = _check_permission( + request_context.command_context.config, + command.permission, + request_context.sender_id, + ) + unavailable_reason = _availability_reason( + scope=request_context.execution_scope, + allow_in_private=command.allow_in_private, + permission=command.permission, + permission_allowed=permission_allowed, + policy_visible=policy_visible, + ) + subcommands = [ + _serialize_subcommand( + command, + subcommand, + request_context=request_context, + policy_visible=policy_visible, + ) + for subcommand in sorted( + command.subcommands.values(), key=lambda item: item.name + ) + ] + if not include_unavailable: + subcommands = [item for item in subcommands if bool(item.get("available"))] + return { + "name": command.name, + "trigger": f"/{command.name}", + "description": command.description, + "usage": command.usage, + "example": command.example, + "permission": command.permission, + "allow_in_private": command.allow_in_private, + "show_in_help": command.show_in_help, + "order": command.order, + "aliases": list(command.aliases), + "alias_triggers": [f"/{alias}" for alias in command.aliases], + "subcommands": subcommands, + "inference": _serialize_inference(command), + "available": unavailable_reason is None, + "unavailable_reason": unavailable_reason, + } + + +def _matches_query(command: dict[str, Any], query: str) -> bool: + if not query: + return True + haystacks = [ + command.get("name"), + command.get("description"), + command.get("usage"), + *(command.get("aliases") or []), + ] + for subcommand in command.get("subcommands") or []: + haystacks.extend( + [ + subcommand.get("name"), + subcommand.get("description"), + subcommand.get("args"), + subcommand.get("usage"), + ] + ) + return any(query in str(item or "").lower() for item in haystacks) + + +def _build_commands_payload( + ctx: RuntimeAPIContext, + request: web.Request, + *, + command_name: str | None = None, +) -> dict[str, Any] | None: + dispatcher = ctx.command_dispatcher + registry = getattr(dispatcher, "command_registry", None) + if registry is None: + return { + "scope": _DEFAULT_COMMAND_SCOPE, + "commands": [], + "count": 0, + "total": 0, + } + + include_hidden = _to_bool(request.query.get("include_hidden")) + include_unavailable = _to_bool(request.query.get("include_unavailable")) + request_context = _build_command_request_context(ctx, request) + + if command_name is not None: + command = registry.resolve(command_name) + if command is None: + return None + commands = [command] + else: + commands = registry.list_commands(include_hidden=include_hidden) + + serialized: list[dict[str, Any]] = [] + for command in commands: + if not include_hidden and not command.show_in_help: + continue + item = _serialize_command( + command, + request_context=request_context, + include_unavailable=include_unavailable, + ) + has_available_subcommand = any( + bool(subcommand.get("available")) + for subcommand in item.get("subcommands") or [] + ) + if ( + not include_unavailable + and not item["available"] + and not has_available_subcommand + ): + continue + serialized.append(item) + + query = str(request.query.get("q", "") or "").strip().lower() + if query: + serialized = [item for item in serialized if _matches_query(item, query)] + + total_aliases = sum(len(item.get("aliases") or []) for item in serialized) + total_subcommands = sum(len(item.get("subcommands") or []) for item in serialized) + payload = { + "scope": request_context.api_scope, + "execution_scope": request_context.execution_scope, + "sender_id": request_context.sender_id, + "user_id": request_context.user_id, + "group_id": request_context.group_id, + "commands": serialized, + "count": len(serialized), + "total": len(serialized), + "aliases": total_aliases, + "subcommands": total_subcommands, + } + if command_name is not None: + if not serialized: + return None + payload["command"] = serialized[0] + payload["requested_name"] = command_name + return payload + + +async def commands_list_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + payload = _build_commands_payload(ctx, request) + if payload is None: + return _json_error("Missing or invalid commands payload", status=400) + logger.info( + "[RuntimeAPI][Commands] 列出命令: scope=%s count=%s", + payload.get("scope"), + payload.get("count"), + ) + return web.json_response(payload) + + +async def command_detail_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + command_name = str(request.match_info.get("command_name", "") or "").strip().lower() + if not command_name: + return _json_error("command_name is required", status=400) + payload = _build_commands_payload(ctx, request, command_name=command_name) + if payload is None: + return _json_error("Command not found", status=404) + logger.info( + "[RuntimeAPI][Commands] 命令详情: requested=%s canonical=%s scope=%s", + command_name, + payload["command"].get("name"), + payload.get("scope"), + ) + return web.json_response(payload) diff --git a/src/Undefined/api/webchat_store.py b/src/Undefined/api/webchat_store.py new file mode 100644 index 00000000..5f483840 --- /dev/null +++ b/src/Undefined/api/webchat_store.py @@ -0,0 +1,781 @@ +"""Persistent WebChat conversation storage.""" + +from __future__ import annotations + +import asyncio +import copy +import hashlib +import inspect +import logging +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, TypeVar +from uuid import uuid4 + +from Undefined.utils import io +from Undefined.utils.paths import ( + HISTORY_DIR, + WEBCHAT_CONVERSATIONS_DIR, + WEBCHAT_MIGRATION_MARKER_FILE, + ensure_dir, +) +from Undefined.utils.xml import escape_xml_attr, escape_xml_text + +logger = logging.getLogger(__name__) + +WEBCHAT_VIRTUAL_USER_ID: int = 42 +WEBCHAT_VIRTUAL_USER_NAME: str = "system" +DEFAULT_WEBCHAT_CONVERSATION_ID: str = "legacy-system-42" +_DEFAULT_TITLE: str = "新对话" +_TEMP_TITLE_CHARS: int = 18 +_MIGRATION_VERSION: int = 1 +_TITLE_STATUS_GENERATED: str = "generated" +_TITLE_STATUS_MANUAL: str = "manual" +_TITLE_STATUS_PENDING: str = "pending" +_TITLE_STATUS_TEMPORARY: str = "temporary" +_TITLE_STATUS_FAILED: str = "failed" +_JsonT = TypeVar("_JsonT") + + +@dataclass(frozen=True) +class WebChatHistoryPage: + records: list[dict[str, Any]] + has_more: bool + next_before: int | None + total: int + + +class WebChatHistoryAdapter: + """Expose one WebChat conversation through MessageHistoryManager-like APIs.""" + + def __init__(self, store: WebChatConversationStore, conversation_id: str) -> None: + self._store = store + self._conversation_id = conversation_id + + def get_recent( + self, + chat_id: str, + msg_type: str, + start: int, + end: int, + ) -> list[dict[str, Any]]: + if msg_type != "private" or str(chat_id) != str(WEBCHAT_VIRTUAL_USER_ID): + return [] + return self._store.get_recent_sync(self._conversation_id, start, end) + + async def add_private_message( + self, + user_id: int, + text_content: str, + display_name: str = "", + user_name: str = "", + message_id: int | None = None, + attachments: list[dict[str, str]] | None = None, + webchat: dict[str, Any] | None = None, + ) -> None: + _ = message_id + if int(user_id) != WEBCHAT_VIRTUAL_USER_ID: + return + role = "bot" if str(display_name or "").strip().lower() == "bot" else "user" + await self._store.append_message( + self._conversation_id, + role=role, + text_content=text_content, + display_name=display_name or user_name or str(user_id), + user_name=user_name or display_name or str(user_id), + attachments=attachments, + webchat=webchat, + ) + + async def flush_pending_saves(self) -> None: + return None + + +class WebChatConversationStore: + """Store WebChat conversations as one JSON file per conversation.""" + + def __init__(self) -> None: + self._global_lock = asyncio.Lock() + self._migration_lock = asyncio.Lock() + self._locks: dict[str, asyncio.Lock] = {} + self._cache: dict[str, dict[str, Any]] = {} + self._loaded = False + self._title_tasks: dict[str, asyncio.Task[None]] = {} + ensure_dir(WEBCHAT_CONVERSATIONS_DIR) + + def adapter(self, conversation_id: str) -> WebChatHistoryAdapter: + return WebChatHistoryAdapter(self, conversation_id) + + async def ensure_ready(self, legacy_history_manager: Any | None = None) -> None: + async with self._global_lock: + if not self._loaded: + await self._load_conversations_locked() + self._loaded = True + await self._migrate_legacy_once(legacy_history_manager) + + async def ensure_default_conversation(self) -> dict[str, Any]: + existing = await self.get_conversation(DEFAULT_WEBCHAT_CONVERSATION_ID) + if existing is not None: + return existing + return await self.create_conversation( + conversation_id=DEFAULT_WEBCHAT_CONVERSATION_ID, + title=_DEFAULT_TITLE, + title_source="system", + ) + + async def list_conversations(self) -> list[dict[str, Any]]: + await self.ensure_ready() + async with self._global_lock: + items = [self._conversation_summary(conv) for conv in self._cache.values()] + return sorted( + items, + key=lambda item: str( + item.get("updated_at") or item.get("created_at") or "" + ), + reverse=True, + ) + + async def get_conversation(self, conversation_id: str) -> dict[str, Any] | None: + await self._ensure_loaded_only() + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + return _copy_json(conv) if conv is not None else None + + async def create_conversation( + self, + *, + conversation_id: str | None = None, + title: str | None = None, + title_source: str = "temporary", + ) -> dict[str, Any]: + await self._ensure_loaded_only() + conv_id = _normalize_conversation_id(conversation_id or uuid4().hex) + now = _now_iso() + conv: dict[str, Any] = { + "id": conv_id, + "title": _sanitize_title(title or _DEFAULT_TITLE) or _DEFAULT_TITLE, + "title_source": str(title_source or "temporary"), + "title_status": _TITLE_STATUS_TEMPORARY, + "created_at": now, + "updated_at": now, + "virtual_user_id": WEBCHAT_VIRTUAL_USER_ID, + "virtual_user_name": WEBCHAT_VIRTUAL_USER_NAME, + "messages": [], + } + async with self._get_lock(conv_id): + existing = self._cache.get(conv_id) + if existing is not None: + return _copy_json(existing) + self._cache[conv_id] = conv + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 创建会话: conversation_id=%s title_source=%s", + conv_id, + conv["title_source"], + ) + return _copy_json(conv) + + async def rename_conversation( + self, conversation_id: str, title: str + ) -> dict[str, Any]: + conv_id = _normalize_conversation_id(conversation_id) + clean_title = _sanitize_title(title) + if not clean_title: + raise ValueError("title is required") + async with self._get_lock(conv_id): + conv = self._require_conversation_locked(conv_id) + conv["title"] = clean_title + conv["title_source"] = "manual" + conv["title_status"] = _TITLE_STATUS_MANUAL + conv["updated_at"] = _now_iso() + conv["title_updated_at"] = conv["updated_at"] + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 重命名会话: conversation_id=%s title_len=%s", + conv_id, + len(clean_title), + ) + return _copy_json(conv) + + async def delete_conversation(self, conversation_id: str) -> bool: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + existed = conv_id in self._cache or self._path_for(conv_id).exists() + self._cache.pop(conv_id, None) + path = self._path_for(conv_id) + if path.exists(): + await asyncio.to_thread(path.unlink) + task = self._title_tasks.pop(conv_id, None) + if task is not None: + task.cancel() + logger.info( + "[WebChat] 删除会话: conversation_id=%s existed=%s", + conv_id, + existed, + ) + return existed + + async def clear_conversation(self, conversation_id: str) -> int: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._require_conversation_locked(conv_id) + previous = len(_messages(conv)) + conv["messages"] = [] + conv["updated_at"] = _now_iso() + conv["title"] = _DEFAULT_TITLE + conv["title_source"] = "temporary" + conv["title_status"] = _TITLE_STATUS_TEMPORARY + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 清空会话: conversation_id=%s previous_messages=%s", + conv_id, + previous, + ) + return previous + + async def append_message( + self, + conversation_id: str, + *, + role: str, + text_content: str, + display_name: str, + user_name: str, + attachments: list[dict[str, str]] | None = None, + webchat: dict[str, Any] | None = None, + ) -> dict[str, Any]: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._require_conversation_locked(conv_id) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + normalized_role = "bot" if role == "bot" else "user" + record: dict[str, Any] = { + "type": "private", + "chat_id": str(WEBCHAT_VIRTUAL_USER_ID), + "chat_name": WEBCHAT_VIRTUAL_USER_NAME, + "user_id": str(WEBCHAT_VIRTUAL_USER_ID), + "display_name": display_name or user_name or WEBCHAT_VIRTUAL_USER_NAME, + "timestamp": timestamp, + "message": str(text_content or ""), + } + if normalized_role == "bot": + record["display_name"] = "Bot" + record["chat_name"] = "Bot" + if attachments: + record["attachments"] = attachments + if isinstance(webchat, dict): + record["webchat"] = webchat + _messages(conv).append(record) + conv["updated_at"] = _now_iso() + if normalized_role == "user": + self._maybe_apply_temporary_title_locked(conv, record["message"]) + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 追加消息: conversation_id=%s role=%s text_len=%s attachments=%s webchat_events=%s total_messages=%s", + conv_id, + normalized_role, + len(record["message"]), + len(attachments or []), + len(webchat.get("events", []) if isinstance(webchat, dict) else []), + len(_messages(conv)), + ) + return _copy_json(record) + + async def get_history_page( + self, + conversation_id: str, + *, + limit: int, + before: int | None, + ) -> WebChatHistoryPage: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._require_conversation_locked(conv_id) + history = _messages(conv) + total = len(history) + if total == 0 or limit <= 0: + return WebChatHistoryPage([], False, None, total) + end = total if before is None else max(0, min(before, total)) + start = max(0, end - limit) + items = _copy_json(history[start:end]) + has_more = start > 0 + next_before = start if has_more else None + return WebChatHistoryPage(items, has_more, next_before, total) + + def get_recent_sync( + self, + conversation_id: str, + start: int, + end: int, + ) -> list[dict[str, Any]]: + conv_id = _normalize_conversation_id(conversation_id) + conv = self._cache.get(conv_id) + if conv is None: + return [] + history = _messages(conv) + total = len(history) + if total == 0: + return [] + actual_start = max(0, total - end) + actual_end = min(total, total - start) + if actual_start >= actual_end: + return [] + return _copy_json(history[actual_start:actual_end]) + + async def first_question_answer( + self, conversation_id: str + ) -> tuple[str, str] | None: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + if conv is None: + return None + question = "" + answer = "" + for record in _messages(conv): + role = _record_role(record) + text = str(record.get("message", "") or "").strip() + if not text: + continue + if role == "user" and not question: + question = text + continue + if question and role == "bot": + answer = text + break + if question and answer: + return question, answer + return None + + async def mark_title_pending(self, conversation_id: str) -> bool: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + if conv is None: + return False + if str(conv.get("title_status") or "") in { + _TITLE_STATUS_MANUAL, + _TITLE_STATUS_GENERATED, + }: + return False + first_pair = _first_question_answer_from_conv(conv) + if first_pair is None: + return False + conv["title_status"] = _TITLE_STATUS_PENDING + conv["title_basis_hash"] = _title_basis_hash(*first_pair) + conv["title_requested_at"] = _now_iso() + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 标题生成排队: conversation_id=%s question_len=%s answer_len=%s", + conv_id, + len(first_pair[0]), + len(first_pair[1]), + ) + return True + + async def apply_generated_title( + self, + conversation_id: str, + *, + title: str, + basis_hash: str, + ) -> bool: + conv_id = _normalize_conversation_id(conversation_id) + clean_title = _sanitize_title(title) + if not clean_title: + return False + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + if conv is None: + return False + if str(conv.get("title_status") or "") == _TITLE_STATUS_MANUAL: + return False + first_pair = _first_question_answer_from_conv(conv) + if first_pair is None or _title_basis_hash(*first_pair) != basis_hash: + return False + conv["title"] = clean_title + conv["title_source"] = "model" + conv["title_status"] = _TITLE_STATUS_GENERATED + conv["title_updated_at"] = _now_iso() + conv["updated_at"] = conv["title_updated_at"] + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 应用生成标题: conversation_id=%s title_len=%s", + conv_id, + len(clean_title), + ) + return True + + async def mark_title_failed(self, conversation_id: str, basis_hash: str) -> None: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + if conv is None: + return + if str(conv.get("title_status") or "") == _TITLE_STATUS_MANUAL: + return + if str(conv.get("title_basis_hash") or "") != basis_hash: + return + conv["title_status"] = _TITLE_STATUS_FAILED + conv["title_failed_at"] = _now_iso() + await self._save_conversation_locked(conv) + logger.info("[WebChat] 标题生成失败: conversation_id=%s", conv_id) + + def register_title_task( + self, conversation_id: str, task: asyncio.Task[None] + ) -> None: + conv_id = _normalize_conversation_id(conversation_id) + previous = self._title_tasks.get(conv_id) + if previous is not None and not previous.done(): + return + self._title_tasks[conv_id] = task + + def _cleanup(done_task: asyncio.Task[None]) -> None: + if self._title_tasks.get(conv_id) is done_task: + self._title_tasks.pop(conv_id, None) + + task.add_done_callback(_cleanup) + + def title_task_running(self, conversation_id: str) -> bool: + task = self._title_tasks.get(_normalize_conversation_id(conversation_id)) + return task is not None and not task.done() + + async def _ensure_loaded_only(self) -> None: + async with self._global_lock: + if not self._loaded: + await self._load_conversations_locked() + self._loaded = True + + async def _load_conversations_locked(self) -> None: + ensure_dir(WEBCHAT_CONVERSATIONS_DIR) + self._cache.clear() + paths = await asyncio.to_thread( + lambda: sorted(WEBCHAT_CONVERSATIONS_DIR.glob("*.json")) + ) + for path in paths: + raw = await io.read_json(path, use_lock=True) + if not isinstance(raw, dict): + continue + conv = _normalize_conversation(raw, path.stem) + self._cache[str(conv["id"])] = conv + logger.info( + "[WebChat] 会话存储加载完成: count=%s dir=%s", + len(self._cache), + WEBCHAT_CONVERSATIONS_DIR, + ) + + async def _migrate_legacy_once(self, legacy_history_manager: Any | None) -> None: + if WEBCHAT_MIGRATION_MARKER_FILE.exists(): + return + async with self._migration_lock: + if WEBCHAT_MIGRATION_MARKER_FILE.exists(): + return + legacy_path = HISTORY_DIR / f"private_{WEBCHAT_VIRTUAL_USER_ID}.json" + legacy_records = _legacy_records_from_manager(legacy_history_manager) + if not legacy_records: + raw = await io.read_json(legacy_path, use_lock=True) + legacy_records = raw if isinstance(raw, list) else [] + migrated_count = 0 + if legacy_records: + conv = await self.create_conversation( + conversation_id=DEFAULT_WEBCHAT_CONVERSATION_ID, + title=_DEFAULT_TITLE, + title_source="migration", + ) + conv_id = str(conv["id"]) + async with self._get_lock(conv_id): + cached = self._require_conversation_locked(conv_id) + cached["messages"] = [ + _normalize_history_record(item) + for item in legacy_records + if isinstance(item, dict) + ] + migrated_count = len(cached["messages"]) + first_question = _first_question_from_conv(cached) + if first_question: + cached["title"] = _temporary_title(first_question) + cached["title_source"] = "temporary" + cached["title_status"] = _TITLE_STATUS_TEMPORARY + cached["legacy_source"] = str(legacy_path) + cached["migrated_at"] = _now_iso() + cached["updated_at"] = cached["migrated_at"] + await self._save_conversation_locked(cached) + await io.write_json( + WEBCHAT_MIGRATION_MARKER_FILE, + { + "version": _MIGRATION_VERSION, + "migrated_at": _now_iso(), + "source": str(legacy_path), + "count": migrated_count, + }, + use_lock=True, + ) + logger.info( + "[WebChat] 旧历史迁移完成: migrated_count=%s marker=%s", + migrated_count, + WEBCHAT_MIGRATION_MARKER_FILE, + ) + + def _get_lock(self, conversation_id: str) -> asyncio.Lock: + lock = self._locks.get(conversation_id) + if lock is None: + lock = asyncio.Lock() + self._locks[conversation_id] = lock + return lock + + def _path_for(self, conversation_id: str) -> Path: + return WEBCHAT_CONVERSATIONS_DIR / f"{conversation_id}.json" + + def _require_conversation_locked(self, conversation_id: str) -> dict[str, Any]: + conv = self._cache.get(conversation_id) + if conv is None: + raise KeyError(conversation_id) + return conv + + async def _save_conversation_locked(self, conv: dict[str, Any]) -> None: + normalized = _normalize_conversation(conv, str(conv.get("id") or uuid4().hex)) + self._cache[str(normalized["id"])] = normalized + await io.write_json( + self._path_for(str(normalized["id"])), normalized, use_lock=True + ) + + def _conversation_summary(self, conv: dict[str, Any]) -> dict[str, Any]: + messages = _messages(conv) + return { + "id": str(conv.get("id") or ""), + "title": str(conv.get("title") or _DEFAULT_TITLE), + "title_source": str(conv.get("title_source") or ""), + "title_status": str(conv.get("title_status") or ""), + "created_at": str(conv.get("created_at") or ""), + "updated_at": str(conv.get("updated_at") or ""), + "virtual_user_id": WEBCHAT_VIRTUAL_USER_ID, + "message_count": len(messages), + } + + def _maybe_apply_temporary_title_locked( + self, conv: dict[str, Any], first_message: str + ) -> None: + status = str(conv.get("title_status") or "") + if status in { + _TITLE_STATUS_MANUAL, + _TITLE_STATUS_GENERATED, + _TITLE_STATUS_PENDING, + }: + return + title = _temporary_title(first_message) + if not title: + return + conv["title"] = title + conv["title_source"] = "temporary" + conv["title_status"] = _TITLE_STATUS_TEMPORARY + conv["title_updated_at"] = _now_iso() + + +def _normalize_conversation_id(value: str) -> str: + text = str(value or "").strip() + if not text: + return uuid4().hex + allowed = "".join(ch for ch in text if ch.isalnum() or ch in {"-", "_"}) + return allowed[:80] or uuid4().hex + + +def _now_iso() -> str: + return datetime.now().isoformat(timespec="seconds") + + +def _copy_json(value: _JsonT) -> _JsonT: + return copy.deepcopy(value) + + +def _messages(conv: dict[str, Any]) -> list[dict[str, Any]]: + messages = conv.get("messages") + if not isinstance(messages, list): + messages = [] + conv["messages"] = messages + return messages + + +def _normalize_conversation(raw: dict[str, Any], fallback_id: str) -> dict[str, Any]: + conv = dict(raw) + conv["id"] = _normalize_conversation_id(str(conv.get("id") or fallback_id)) + conv["title"] = ( + _sanitize_title(conv.get("title") or _DEFAULT_TITLE) or _DEFAULT_TITLE + ) + conv["title_source"] = str(conv.get("title_source") or "temporary") + conv["title_status"] = str(conv.get("title_status") or _TITLE_STATUS_TEMPORARY) + conv["created_at"] = str(conv.get("created_at") or _now_iso()) + conv["updated_at"] = str(conv.get("updated_at") or conv["created_at"]) + conv["virtual_user_id"] = WEBCHAT_VIRTUAL_USER_ID + conv["virtual_user_name"] = WEBCHAT_VIRTUAL_USER_NAME + conv["messages"] = [ + _normalize_history_record(item) + for item in conv.get("messages", []) + if isinstance(item, dict) + ] + return conv + + +def _normalize_history_record(record: dict[str, Any]) -> dict[str, Any]: + item = dict(record) + item["type"] = "private" + item["chat_id"] = str(WEBCHAT_VIRTUAL_USER_ID) + item["user_id"] = str(WEBCHAT_VIRTUAL_USER_ID) + item["chat_name"] = str(item.get("chat_name") or WEBCHAT_VIRTUAL_USER_NAME) + item["display_name"] = str(item.get("display_name") or WEBCHAT_VIRTUAL_USER_NAME) + item["timestamp"] = str(item.get("timestamp") or "") + item["message"] = str(item.get("message", item.get("content", "")) or "") + attachments = item.get("attachments") + item["attachments"] = attachments if isinstance(attachments, list) else [] + return item + + +def _legacy_records_from_manager(history_manager: Any | None) -> list[dict[str, Any]]: + if history_manager is None: + return [] + recent_getter = getattr(history_manager, "get_recent_private", None) + if callable(recent_getter): + try: + records = recent_getter(WEBCHAT_VIRTUAL_USER_ID, 1000000) + if isinstance(records, list): + return [item for item in records if isinstance(item, dict)] + except Exception: + return [] + return [] + + +def _record_role(record: dict[str, Any]) -> str: + display_name = str(record.get("display_name", "") or "").strip().lower() + return "bot" if display_name == "bot" else "user" + + +def _first_question_from_conv(conv: dict[str, Any]) -> str: + for record in _messages(conv): + if _record_role(record) != "user": + continue + text = str(record.get("message", "") or "").strip() + if text: + return text + return "" + + +def _first_question_answer_from_conv(conv: dict[str, Any]) -> tuple[str, str] | None: + question = "" + for record in _messages(conv): + text = str(record.get("message", "") or "").strip() + if not text: + continue + role = _record_role(record) + if role == "user" and not question: + question = text + continue + if question and role == "bot": + return question, text + return None + + +def _temporary_title(text: str) -> str: + normalized = " ".join(str(text or "").strip().split()) + if not normalized: + return _DEFAULT_TITLE + return normalized[:_TEMP_TITLE_CHARS] + + +def _sanitize_title(value: Any) -> str: + text = " ".join(str(value or "").strip().split()) + text = text.strip(" \t\r\n\"'`“”‘’") + return text[:40] + + +def _title_basis_hash(question: str, answer: str) -> str: + basis = f"{question}\n\n{answer}" + return hashlib.sha256(basis.encode("utf-8")).hexdigest() + + +def webchat_title_basis_hash(question: str, answer: str) -> str: + return _title_basis_hash(question, answer) + + +def build_webchat_title_prompt(question: str, answer: str) -> list[dict[str, str]]: + content = ( + "请为一段 WebChat 对话生成一个简短标题。\n" + "要求:只返回标题文本;不要引号、编号或前缀;中文不超过 18 个字,英文不超过 6 个词;" + "标题应概括用户首问和 AI 首答的实际主题。\n\n" + f"{escape_xml_text(question)}\n" + f"{escape_xml_text(answer)}" + ) + return [{"role": "user", "content": content}] + + +def _resolve_webchat_title_chat_model(ai: Any) -> Any | None: + chat_config = getattr(ai, "chat_config", None) + if chat_config is None: + runtime_config = getattr(ai, "runtime_config", None) + chat_config = getattr(runtime_config, "chat_model", None) + if chat_config is None: + return None + selector = getattr(ai, "model_selector", None) + select_chat_config = getattr(selector, "select_chat_config", None) + if callable(select_chat_config): + runtime_config = getattr(ai, "runtime_config", None) + global_enabled = bool( + getattr(runtime_config, "model_pool_enabled", True) + if runtime_config is not None + else True + ) + return select_chat_config( + chat_config, + group_id=0, + user_id=WEBCHAT_VIRTUAL_USER_ID, + global_enabled=global_enabled, + ) + return chat_config + + +async def generate_webchat_title(ai: Any, question: str, answer: str) -> str: + messages = build_webchat_title_prompt(question, answer) + model_config = _resolve_webchat_title_chat_model(ai) + logger.info( + "[WebChat] 生成会话标题: model=%s question_len=%s answer_len=%s", + getattr(model_config, "model_name", ""), + len(question), + len(answer), + ) + submit = getattr(ai, "submit_background_llm_call", None) + if callable(submit) and model_config is not None: + result = await submit( + model_config=model_config, + messages=messages, + tools=None, + call_type="webchat_title", + ) + from Undefined.ai.parsing import extract_choices_content + + return _sanitize_title(extract_choices_content(result)) + request_model = getattr(ai, "request_model", None) + if callable(request_model) and model_config is not None: + result = await request_model( + model_config=model_config, + messages=messages, + tools=None, + call_type="webchat_title", + ) + from Undefined.ai.parsing import extract_choices_content + + return _sanitize_title(extract_choices_content(result)) + generate_title = getattr(ai, "generate_title", None) + if callable(generate_title): + logger.info("[WebChat] 会话标题生成回退到 generate_title") + maybe = generate_title(f"用户首问:{question}\nAI首答:{answer}") + result_text = await maybe if inspect.isawaitable(maybe) else maybe + return _sanitize_title(result_text) + return "" + + +def format_webchat_message_xml( + content: str, attachment_xml: str, current_time: str +) -> str: + return f""" + {escape_xml_text(content)}{attachment_xml} + """ diff --git a/src/Undefined/attachments/segments.py b/src/Undefined/attachments/segments.py index d78b458f..0acfefb1 100644 --- a/src/Undefined/attachments/segments.py +++ b/src/Undefined/attachments/segments.py @@ -369,11 +369,8 @@ async def _collect_from_segments( try: if type_ == "image": raw_source = str(data.get("file") or data.get("url") or "").strip() - display_name = display_name_from_source( - raw_source, - f"image_{index + 1}.png", - ) if raw_source.startswith("base64://"): + display_name = f"image_{index + 1}.png" payload = raw_source[len("base64://") :].strip() content = base64.b64decode(payload) record = await registry.register_bytes( @@ -390,6 +387,7 @@ async def _collect_from_segments( ) ref = record.prompt_ref() elif is_data_url(raw_source): + display_name = f"image_{index + 1}.png" record = await registry.register_data_url( scope_key, raw_source, @@ -404,6 +402,10 @@ async def _collect_from_segments( ) ref = record.prompt_ref() else: + display_name = display_name_from_source( + raw_source, + f"image_{index + 1}.png", + ) resolved_source = raw_source if raw_source and resolve_image_url is not None: try: diff --git a/src/Undefined/cognitive/chroma_scheduler.py b/src/Undefined/cognitive/chroma_scheduler.py new file mode 100644 index 00000000..b67ff00f --- /dev/null +++ b/src/Undefined/cognitive/chroma_scheduler.py @@ -0,0 +1,296 @@ +"""ChromaDB operation scheduler for cognitive vector stores.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, TypeVar + +from Undefined.context import RequestContext + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +CHROMA_PRIORITY_FOREGROUND_CRITICAL = "foreground_critical" +CHROMA_PRIORITY_FOREGROUND = "foreground" +CHROMA_PRIORITY_MAINTENANCE = "maintenance" +CHROMA_PRIORITY_BACKGROUND = "background" + +CHROMA_PRIORITY_DISPLAY_NAMES = { + CHROMA_PRIORITY_FOREGROUND_CRITICAL: "前台关键", + CHROMA_PRIORITY_FOREGROUND: "前台", + CHROMA_PRIORITY_MAINTENANCE: "维护", + CHROMA_PRIORITY_BACKGROUND: "后台", +} + +_PRIORITY_ORDER = ( + CHROMA_PRIORITY_FOREGROUND_CRITICAL, + CHROMA_PRIORITY_FOREGROUND, + CHROMA_PRIORITY_MAINTENANCE, + CHROMA_PRIORITY_BACKGROUND, +) +_FOREGROUND_PRIORITIES = ( + CHROMA_PRIORITY_FOREGROUND_CRITICAL, + CHROMA_PRIORITY_FOREGROUND, +) +_BACKGROUND_PRIORITIES = ( + CHROMA_PRIORITY_MAINTENANCE, + CHROMA_PRIORITY_BACKGROUND, +) + + +def normalize_chroma_priority(value: str | None, default: str) -> str: + """Normalize external priority values to a known scheduler lane.""" + raw = str(value or "").strip() + if raw in CHROMA_PRIORITY_DISPLAY_NAMES: + return raw + return default + + +@dataclass +class ChromaOperationReceipt: + """Execution timing for one Chroma operation.""" + + priority: str + operation: str + collection: str + request_id: str + queue_wait_seconds: float + exec_seconds: float + pending_before: int + + +@dataclass +class _ChromaOperation: + priority: str + operation: str + collection: str + request_id: str + callback: Callable[[], Any] + created_at: float + pending_before: int + future: asyncio.Future[tuple[Any, ChromaOperationReceipt]] + + +@dataclass +class ChromaSchedulerSnapshot: + running: bool + stopped: bool + foreground_burst: int + active: bool + pending: dict[str, int] = field(default_factory=dict) + + +class ChromaOperationScheduler: + """Single-worker priority scheduler for Chroma collection operations.""" + + def __init__(self, *, foreground_burst: int = 8) -> None: + self._foreground_burst = max(1, int(foreground_burst)) + self._queues: dict[str, deque[_ChromaOperation]] = { + priority: deque() for priority in _PRIORITY_ORDER + } + self._condition = asyncio.Condition() + self._worker: asyncio.Task[None] | None = None + self._stopped = False + self._foreground_since_background = 0 + self._active_operation: _ChromaOperation | None = None + + @property + def foreground_burst(self) -> int: + return self._foreground_burst + + def snapshot(self) -> ChromaSchedulerSnapshot: + return ChromaSchedulerSnapshot( + running=self._worker is not None and not self._worker.done(), + stopped=self._stopped, + foreground_burst=self._foreground_burst, + active=self._active_operation is not None, + pending={priority: len(queue) for priority, queue in self._queues.items()}, + ) + + async def run( + self, + *, + priority: str, + operation: str, + collection: str, + callback: Callable[[], T], + ) -> tuple[T, ChromaOperationReceipt]: + normalized_priority = normalize_chroma_priority( + priority, + CHROMA_PRIORITY_FOREGROUND, + ) + loop = asyncio.get_running_loop() + future: asyncio.Future[tuple[Any, ChromaOperationReceipt]] = ( + loop.create_future() + ) + request_id = self._current_request_id() + created_at = time.perf_counter() + async with self._condition: + if self._stopped: + raise RuntimeError("Chroma operation scheduler has stopped") + self._ensure_worker_locked() + pending_before = self._pending_count_locked() + job = _ChromaOperation( + priority=normalized_priority, + operation=operation, + collection=collection, + request_id=request_id, + callback=callback, + created_at=created_at, + pending_before=pending_before, + future=future, + ) + self._queues[normalized_priority].append(job) + self._condition.notify() + + try: + result, receipt = await future + except asyncio.CancelledError: + if not future.done(): + future.cancel() + raise + return result, receipt + + async def stop(self) -> None: + worker: asyncio.Task[None] | None + async with self._condition: + self._stopped = True + self._cancel_pending_locked() + self._condition.notify_all() + worker = self._worker + if worker is not None: + await worker + self._worker = None + + def _ensure_worker_locked(self) -> None: + if self._worker is None or self._worker.done(): + self._worker = asyncio.create_task(self._worker_loop()) + + async def _worker_loop(self) -> None: + while True: + async with self._condition: + while True: + if self._stopped and self._pending_count_locked() == 0: + return + job = self._pop_next_locked() + if job is not None: + self._active_operation = job + break + await self._condition.wait() + + try: + await self._execute(job) + finally: + async with self._condition: + self._active_operation = None + self._condition.notify_all() + + async def _execute(self, job: _ChromaOperation) -> None: + if job.future.cancelled(): + return + wait_seconds = time.perf_counter() - job.created_at + exec_started = time.perf_counter() + logger.info( + "[认知向量库] Chroma 操作开始: priority=%s operation=%s collection=%s request_id=%s wait=%.3fs pending_before=%s", + job.priority, + job.operation, + job.collection, + job.request_id, + wait_seconds, + job.pending_before, + ) + try: + result = await asyncio.to_thread(job.callback) + except Exception as exc: + exec_seconds = time.perf_counter() - exec_started + if not job.future.done(): + job.future.set_exception(exc) + logger.warning( + "[认知向量库] Chroma 操作失败: priority=%s operation=%s collection=%s request_id=%s wait=%.3fs exec=%.3fs err=%s", + job.priority, + job.operation, + job.collection, + job.request_id, + wait_seconds, + exec_seconds, + exc, + ) + return + + exec_seconds = time.perf_counter() - exec_started + receipt = ChromaOperationReceipt( + priority=job.priority, + operation=job.operation, + collection=job.collection, + request_id=job.request_id, + queue_wait_seconds=wait_seconds, + exec_seconds=exec_seconds, + pending_before=job.pending_before, + ) + if not job.future.done(): + job.future.set_result((result, receipt)) + logger.info( + "[认知向量库] Chroma 操作完成: priority=%s operation=%s collection=%s request_id=%s wait=%.3fs exec=%.3fs", + job.priority, + job.operation, + job.collection, + job.request_id, + wait_seconds, + exec_seconds, + ) + + def _pop_next_locked(self) -> _ChromaOperation | None: + if ( + self._foreground_since_background >= self._foreground_burst + and self._has_pending_locked(_BACKGROUND_PRIORITIES) + ): + job = self._pop_first_locked(_BACKGROUND_PRIORITIES) + if job is not None: + self._foreground_since_background = 0 + return job + + job = self._pop_first_locked(_FOREGROUND_PRIORITIES) + if job is not None: + self._foreground_since_background += 1 + return job + + job = self._pop_first_locked(_BACKGROUND_PRIORITIES) + if job is not None: + self._foreground_since_background = 0 + return job + return None + + def _pop_first_locked(self, priorities: tuple[str, ...]) -> _ChromaOperation | None: + for priority in priorities: + queue = self._queues[priority] + while queue: + job = queue.popleft() + if not job.future.cancelled(): + return job + return None + + def _has_pending_locked(self, priorities: tuple[str, ...]) -> bool: + return any(self._queues[priority] for priority in priorities) + + def _pending_count_locked(self) -> int: + return sum(len(queue) for queue in self._queues.values()) + + def _cancel_pending_locked(self) -> None: + for queue in self._queues.values(): + while queue: + job = queue.popleft() + if not job.future.done(): + job.future.cancel() + + @staticmethod + def _current_request_id() -> str: + ctx = RequestContext.current() + if ctx is None: + return "" + return str(getattr(ctx, "request_id", "") or "") diff --git a/src/Undefined/cognitive/historian/worker.py b/src/Undefined/cognitive/historian/worker.py index 30ecf43c..6d200f28 100644 --- a/src/Undefined/cognitive/historian/worker.py +++ b/src/Undefined/cognitive/historian/worker.py @@ -9,6 +9,11 @@ from typing import Any, Callable from Undefined.ai.transports.openai_transport import RESPONSES_OUTPUT_ITEMS_KEY +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_BACKGROUND, + CHROMA_PRIORITY_MAINTENANCE, +) +from Undefined.cognitive.vector_store_compat import call_vector_store_method from Undefined.utils.tool_calls import extract_required_tool_call_arguments from Undefined.cognitive.historian.helpers import ( @@ -212,7 +217,13 @@ async def _process_job(self, job_id: str, job: dict[str, Any]) -> None: **base_metadata, "has_observations": True, } - await self._vector_store.upsert_event(event_id, canonical, meta) + await call_vector_store_method( + self._vector_store.upsert_event, + event_id, + canonical, + meta, + priority=CHROMA_PRIORITY_BACKGROUND, + ) canonicals.append(canonical) logger.info( "[史官] 任务 %s 事件入库完成(%s/%s): len=%s", @@ -516,10 +527,12 @@ async def _write_profile( profile_metadata["group_name"] = effective_name profile_metadata["group_id"] = entity_id - await self._vector_store.upsert_profile( + await call_vector_store_method( + self._vector_store.upsert_profile, f"{entity_type}:{entity_id}", profile_doc, profile_metadata, + priority=CHROMA_PRIORITY_BACKGROUND, ) logger.info( "[史官] 任务 %s 侧写向量入库完成: profile_id=%s perspective=%s", @@ -560,15 +573,19 @@ async def _query_user_history_events_for_profile_merge( query_embedding_value = query_embedding if query_embedding_value is None: query_embedding_value = await self._prepare_query_embedding(query_text) - sender_query = self._vector_store.query_events( + sender_query = call_vector_store_method( + self._vector_store.query_events, query_text, + priority=CHROMA_PRIORITY_MAINTENANCE, top_k=safe_top_k, where={"sender_id": entity_id}, apply_mmr=True, query_embedding=query_embedding_value, ) - user_query = self._vector_store.query_events( + user_query = call_vector_store_method( + self._vector_store.query_events, query_text, + priority=CHROMA_PRIORITY_MAINTENANCE, top_k=safe_top_k, where={"user_id": entity_id}, apply_mmr=True, @@ -631,8 +648,10 @@ async def _merge_profile_target( ) query_embedding = await self._prepare_query_embedding(observations_text) if entity_type == "group": - historical_events = await self._vector_store.query_events( + historical_events = await call_vector_store_method( + self._vector_store.query_events, observations_text, + priority=CHROMA_PRIORITY_MAINTENANCE, top_k=8, where={"group_id": entity_id}, apply_mmr=True, diff --git a/src/Undefined/cognitive/service/service.py b/src/Undefined/cognitive/service/service.py index c7a69e9d..ac2550c7 100644 --- a/src/Undefined/cognitive/service/service.py +++ b/src/Undefined/cognitive/service/service.py @@ -9,6 +9,10 @@ from typing import TYPE_CHECKING, Any, Callable, cast from Undefined.context import RequestContext +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_FOREGROUND, + CHROMA_PRIORITY_FOREGROUND_CRITICAL, +) from Undefined.utils.coerce import safe_float from Undefined.cognitive.service.helpers import ( _build_profile_vector_payload, @@ -23,6 +27,7 @@ _resolve_auto_request_type, _serialize_profile_markdown, ) +from Undefined.cognitive.vector_store_compat import call_vector_store_method if TYPE_CHECKING: from Undefined.knowledge.runtime import RetrievalRuntime @@ -47,6 +52,11 @@ def __init__( self._reranker = reranker self._retrieval_runtime = retrieval_runtime + async def stop(self) -> None: + stop = getattr(self._vector_store, "stop", None) + if callable(stop): + await stop() + def _base_reranker(self) -> Any: if self._retrieval_runtime is not None: return self._retrieval_runtime.ensure_reranker() @@ -138,10 +148,12 @@ async def sync_profile_display_name( tags=_normalize_profile_tags(frontmatter.get("tags")), summary=summary, ) - await self._vector_store.upsert_profile( + await call_vector_store_method( + self._vector_store.upsert_profile, f"{normalized_entity_type}:{normalized_entity_id}", profile_doc, profile_metadata, + priority=CHROMA_PRIORITY_FOREGROUND, ) logger.info( "[认知服务] 已刷新侧写展示名: entity_type=%s entity_id=%s old=%s new=%s", @@ -270,8 +282,10 @@ async def _query_events_for_auto_context( uid_values = self._uid_candidates(user_id, sender_id) if request_type == "group": - group_events: list[dict[str, Any]] = await self._vector_store.query_events( + group_events = await call_vector_store_method( + self._vector_store.query_events, query, + priority=CHROMA_PRIORITY_FOREGROUND, top_k=scoped_top_k, where={"request_type": "group"}, **common_kwargs, @@ -296,8 +310,10 @@ async def _query_events_for_auto_context( return merged if request_type == "private": - group_task = self._vector_store.query_events( + group_task = call_vector_store_method( + self._vector_store.query_events, query, + priority=CHROMA_PRIORITY_FOREGROUND, top_k=scoped_top_k, where={"request_type": "group"}, **common_kwargs, @@ -312,8 +328,10 @@ async def _query_events_for_auto_context( {"$or": uid_clauses}, ] } - private_task = self._vector_store.query_events( + private_task = call_vector_store_method( + self._vector_store.query_events, query, + priority=CHROMA_PRIORITY_FOREGROUND, top_k=scoped_top_k, where=private_where, **common_kwargs, @@ -356,8 +374,10 @@ async def _query_events_for_auto_context( "$or": [{"user_id": value} for value in uid_values] + [{"sender_id": value} for value in uid_values] } - events: list[dict[str, Any]] = await self._vector_store.query_events( + events = await call_vector_store_method( + self._vector_store.query_events, query, + priority=CHROMA_PRIORITY_FOREGROUND, top_k=safe_top_k, where=where, **common_kwargs, @@ -369,7 +389,7 @@ async def _query_events_for_auto_context( len(events), safe_top_k, ) - return events + return cast(list[dict[str, Any]], events) async def enqueue_job( self, @@ -678,8 +698,10 @@ async def search_events(self, query: str, **kwargs: Any) -> list[dict[str, Any]] time_from_epoch, time_to_epoch, ) - results: list[dict[str, Any]] = await self._vector_store.query_events( + results = await call_vector_store_method( + self._vector_store.query_events, query, + priority=CHROMA_PRIORITY_FOREGROUND_CRITICAL, top_k=top_k, where=where or None, reranker=self._current_reranker(), @@ -696,7 +718,7 @@ async def search_events(self, query: str, **kwargs: Any) -> list[dict[str, Any]] query_embedding=await self._prepare_query_embedding(query), ) logger.info("[认知服务] 搜索事件完成: count=%s", len(results)) - return results + return cast(list[dict[str, Any]], results) async def get_profile(self, entity_type: str, entity_id: str) -> str | None: logger.info( @@ -739,8 +761,10 @@ async def search_profiles(self, query: str, **kwargs: Any) -> list[dict[str, Any top_k, where or {}, ) - results: list[dict[str, Any]] = await self._vector_store.query_profiles( + results = await call_vector_store_method( + self._vector_store.query_profiles, query, + priority=CHROMA_PRIORITY_FOREGROUND_CRITICAL, top_k=top_k, where=where, reranker=self._current_reranker(), @@ -748,4 +772,4 @@ async def search_profiles(self, query: str, **kwargs: Any) -> list[dict[str, Any query_embedding=await self._prepare_query_embedding(query), ) logger.info("[认知服务] 搜索侧写完成: count=%s", len(results)) - return results + return cast(list[dict[str, Any]], results) diff --git a/src/Undefined/cognitive/vector_store.py b/src/Undefined/cognitive/vector_store.py index 076b8a9f..a15a90e8 100644 --- a/src/Undefined/cognitive/vector_store.py +++ b/src/Undefined/cognitive/vector_store.py @@ -11,13 +11,20 @@ from typing import Any import chromadb - -from Undefined.utils.coerce import safe_float from chromadb.errors import InternalError as ChromaInternalError import numpy as np from numba import njit from numpy.typing import NDArray +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_BACKGROUND, + CHROMA_PRIORITY_FOREGROUND, + ChromaOperationReceipt, + ChromaOperationScheduler, + normalize_chroma_priority, +) +from Undefined.utils.coerce import safe_float + logger = logging.getLogger(__name__) _QUERY_EMBEDDING_CACHE_TTL_SECONDS = 60.0 @@ -173,7 +180,13 @@ def _mmr_select( class CognitiveVectorStore: - def __init__(self, path: str | Path, embedder: Any) -> None: + def __init__( + self, + path: str | Path, + embedder: Any, + *, + scheduler_foreground_burst: int = 8, + ) -> None: client = chromadb.PersistentClient(path=str(path)) self._client = client self._events = client.get_or_create_collection( @@ -183,19 +196,21 @@ def __init__(self, path: str | Path, embedder: Any) -> None: "cognitive_profiles", metadata={"hnsw:space": "cosine"} ) self._embedder = embedder - self._events_lock = asyncio.Lock() - self._profiles_lock = asyncio.Lock() + self._chroma_scheduler = ChromaOperationScheduler( + foreground_burst=scheduler_foreground_burst + ) self._query_embedding_cache: OrderedDict[ tuple[str, str, str, str], tuple[float, list[float]] ] = OrderedDict() self._query_embedding_cache_lock = asyncio.Lock() logger.info( - "[认知向量库] 初始化完成: path=%s events=%s profiles=%s query_cache_ttl=%ss query_cache_size=%s", + "[认知向量库] 初始化完成: path=%s events=%s profiles=%s query_cache_ttl=%ss query_cache_size=%s scheduler_foreground_burst=%s", str(path), getattr(self._events, "name", "cognitive_events"), getattr(self._profiles, "name", "cognitive_profiles"), _QUERY_EMBEDDING_CACHE_TTL_SECONDS, _QUERY_EMBEDDING_CACHE_MAX_SIZE, + self._chroma_scheduler.foreground_burst, ) async def _embed(self, text: str) -> list[float]: @@ -290,33 +305,62 @@ def _is_transient_query_error(exc: Exception) -> bool: text = str(exc).lower() return "error finding id" in text or "error executing plan" in text - def _collection_lock(self, col: Any) -> asyncio.Lock: - if col is self._events: - return self._events_lock - return self._profiles_lock + async def stop(self) -> None: + await self._chroma_scheduler.stop() + + async def _run_chroma_operation( + self, + *, + priority: str, + operation: str, + collection: str, + callback: Any, + ) -> tuple[Any, ChromaOperationReceipt]: + return await self._chroma_scheduler.run( + priority=priority, + operation=operation, + collection=collection, + callback=callback, + ) async def upsert_event( - self, event_id: str, document: str, metadata: dict[str, Any] + self, + event_id: str, + document: str, + metadata: dict[str, Any], + *, + priority: str = CHROMA_PRIORITY_BACKGROUND, ) -> None: safe_metadata = _sanitize_metadata(metadata) + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_BACKGROUND) logger.info( - "[认知向量库] 写入事件: event_id=%s doc_len=%s metadata_keys=%s", + "[认知向量库] 写入事件: event_id=%s doc_len=%s metadata_keys=%s priority=%s", event_id, len(document or ""), sorted(safe_metadata.keys()), + safe_priority, ) emb = await self._embed(document) col = self._events - async with self._events_lock: - await asyncio.to_thread( - lambda: col.upsert( - ids=[event_id], - documents=[document], - embeddings=[emb], # type: ignore[arg-type] - metadatas=[safe_metadata], - ) - ) - logger.info("[认知向量库] 事件写入完成: event_id=%s", event_id) + col_name = getattr(col, "name", "cognitive_events") + _, receipt = await self._run_chroma_operation( + priority=safe_priority, + operation="upsert_event", + collection=col_name, + callback=lambda: col.upsert( + ids=[event_id], + documents=[document], + embeddings=[emb], # type: ignore[arg-type] + metadatas=[safe_metadata], + ), + ) + logger.info( + "[认知向量库] 事件写入完成: event_id=%s priority=%s chroma_wait=%.3fs chroma_exec=%.3fs", + event_id, + safe_priority, + receipt.queue_wait_seconds, + receipt.exec_seconds, + ) async def query_events( self, @@ -331,9 +375,11 @@ async def query_events( time_decay_min_similarity: float = 0.35, apply_mmr: bool = False, query_embedding: list[float] | None = None, + priority: str = CHROMA_PRIORITY_FOREGROUND, ) -> list[dict[str, Any]]: + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_FOREGROUND) logger.info( - "[认知向量库] 查询事件: query_len=%s top_k=%s where=%s reranker=%s multiplier=%s decay_enabled=%s half_life_days=%s boost=%s min_sim=%s mmr=%s", + "[认知向量库] 查询事件: query_len=%s top_k=%s where=%s reranker=%s multiplier=%s decay_enabled=%s half_life_days=%s boost=%s min_sim=%s mmr=%s priority=%s", len(query_text or ""), top_k, where or {}, @@ -344,6 +390,7 @@ async def query_events( time_decay_boost, time_decay_min_similarity, apply_mmr, + safe_priority, ) return await self._query( self._events, @@ -358,30 +405,47 @@ async def query_events( time_decay_min_similarity=time_decay_min_similarity, apply_mmr=apply_mmr, query_embedding=query_embedding, + priority=safe_priority, ) async def upsert_profile( - self, profile_id: str, document: str, metadata: dict[str, Any] + self, + profile_id: str, + document: str, + metadata: dict[str, Any], + *, + priority: str = CHROMA_PRIORITY_BACKGROUND, ) -> None: safe_metadata = _sanitize_metadata(metadata) + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_BACKGROUND) logger.info( - "[认知向量库] 写入侧写向量: profile_id=%s doc_len=%s metadata_keys=%s", + "[认知向量库] 写入侧写向量: profile_id=%s doc_len=%s metadata_keys=%s priority=%s", profile_id, len(document or ""), sorted(safe_metadata.keys()), + safe_priority, ) emb = await self._embed(document) col = self._profiles - async with self._profiles_lock: - await asyncio.to_thread( - lambda: col.upsert( - ids=[profile_id], - documents=[document], - embeddings=[emb], # type: ignore[arg-type] - metadatas=[safe_metadata], - ) - ) - logger.info("[认知向量库] 侧写向量写入完成: profile_id=%s", profile_id) + col_name = getattr(col, "name", "cognitive_profiles") + _, receipt = await self._run_chroma_operation( + priority=safe_priority, + operation="upsert_profile", + collection=col_name, + callback=lambda: col.upsert( + ids=[profile_id], + documents=[document], + embeddings=[emb], # type: ignore[arg-type] + metadatas=[safe_metadata], + ), + ) + logger.info( + "[认知向量库] 侧写向量写入完成: profile_id=%s priority=%s chroma_wait=%.3fs chroma_exec=%.3fs", + profile_id, + safe_priority, + receipt.queue_wait_seconds, + receipt.exec_seconds, + ) async def query_profiles( self, @@ -391,14 +455,17 @@ async def query_profiles( reranker: Any = None, candidate_multiplier: int = 3, query_embedding: list[float] | None = None, + priority: str = CHROMA_PRIORITY_FOREGROUND, ) -> list[dict[str, Any]]: + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_FOREGROUND) logger.info( - "[认知向量库] 查询侧写: query_len=%s top_k=%s where=%s reranker=%s multiplier=%s", + "[认知向量库] 查询侧写: query_len=%s top_k=%s where=%s reranker=%s multiplier=%s priority=%s", len(query_text or ""), top_k, where or {}, bool(reranker), candidate_multiplier, + safe_priority, ) return await self._query( self._profiles, @@ -408,6 +475,7 @@ async def query_profiles( reranker, candidate_multiplier, query_embedding=query_embedding, + priority=safe_priority, ) async def _query( @@ -425,18 +493,21 @@ async def _query( time_decay_min_similarity: float = 0.35, apply_mmr: bool = False, query_embedding: list[float] | None = None, + priority: str = CHROMA_PRIORITY_FOREGROUND, ) -> list[dict[str, Any]]: col_name = getattr(col, "name", "unknown") + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_FOREGROUND) safe_top_k = _safe_positive_int(top_k, default=1, maximum=500) safe_multiplier = _safe_positive_int(candidate_multiplier, default=1) total_started = time.perf_counter() logger.debug( - "[认知向量库] 开始查询 collection=%s top_k=%s where=%s decay=%s mmr=%s", + "[认知向量库] 开始查询 collection=%s top_k=%s where=%s decay=%s mmr=%s priority=%s", col_name, safe_top_k, where or {}, apply_time_decay, apply_mmr, + safe_priority, ) embed_started = time.perf_counter() emb, embedding_source = await self._resolve_query_embedding( @@ -469,14 +540,21 @@ async def _query( def _q() -> Any: return col.query(**kwargs) - query_lock = self._collection_lock(col) - chroma_started = time.perf_counter() + chroma_wait_duration = 0.0 + chroma_exec_duration = 0.0 last_exc: Exception | None = None raw: dict[str, Any] | None = None for attempt in range(1, _CHROMA_TRANSIENT_QUERY_RETRIES + 1): try: - async with query_lock: - raw = await asyncio.to_thread(_q) + raw_result, receipt = await self._run_chroma_operation( + priority=safe_priority, + operation="query", + collection=col_name, + callback=_q, + ) + raw = raw_result + chroma_wait_duration += receipt.queue_wait_seconds + chroma_exec_duration += receipt.exec_seconds last_exc = None break except Exception as exc: @@ -499,7 +577,6 @@ def _q() -> Any: if last_exc is not None: raise last_exc raise RuntimeError(f"query returned no result for collection={col_name}") - chroma_duration = time.perf_counter() - chroma_started docs: list[str] = (raw.get("documents") or [[]])[0] metas: list[dict[str, Any]] = (raw.get("metadatas") or [[]])[0] dists: list[float] = (raw.get("distances") or [[]])[0] @@ -606,14 +683,16 @@ def _q() -> Any: len(final), ) logger.info( - "[认知向量库] 查询阶段耗时: collection=%s embed=%.3fs chroma_query=%.3fs rerank=%.3fs post_rank=%.3fs total=%.3fs embedding_source=%s", + "[认知向量库] 查询阶段耗时: collection=%s embed=%.3fs chroma_wait=%.3fs chroma_exec=%.3fs rerank=%.3fs post_rank=%.3fs total=%.3fs embedding_source=%s priority=%s", col_name, embed_duration, - chroma_duration, + chroma_wait_duration, + chroma_exec_duration, rerank_duration, post_rank_duration, total_duration, embedding_source, + safe_priority, ) return final diff --git a/src/Undefined/cognitive/vector_store_compat.py b/src/Undefined/cognitive/vector_store_compat.py new file mode 100644 index 00000000..2e18364b --- /dev/null +++ b/src/Undefined/cognitive/vector_store_compat.py @@ -0,0 +1,38 @@ +"""Compatibility helpers for cognitive vector store calls.""" + +from __future__ import annotations + +import inspect +from typing import Any + + +def _accepts_keyword(method: Any, keyword: str) -> bool: + try: + signature = inspect.signature(method) + except (TypeError, ValueError): + return True + for parameter in signature.parameters.values(): + if parameter.kind is inspect.Parameter.VAR_KEYWORD: + return True + if parameter.name == keyword and parameter.kind in { + inspect.Parameter.KEYWORD_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + }: + return True + return False + + +async def call_vector_store_method( + method: Any, + *args: Any, + priority: str, + **kwargs: Any, +) -> Any: + """Call a vector-store method with priority when the method supports it.""" + call_kwargs = dict(kwargs) + if _accepts_keyword(method, "priority"): + call_kwargs["priority"] = priority + result = method(*args, **call_kwargs) + if inspect.isawaitable(result): + return await result + return result diff --git a/src/Undefined/config/domain_parsers.py b/src/Undefined/config/domain_parsers.py index c0de5fa0..9d8b35e5 100644 --- a/src/Undefined/config/domain_parsers.py +++ b/src/Undefined/config/domain_parsers.py @@ -46,6 +46,17 @@ def _parse_cognitive_config(data: dict[str, Any]) -> CognitiveConfig: vs.get("path") if isinstance(vs, dict) else None, "data/cognitive/chromadb", ), + vector_store_scheduler_foreground_burst=max( + 1, + _coerce_int( + ( + vs.get("scheduler_foreground_burst") + if isinstance(vs, dict) + else None + ), + 8, + ), + ), queue_path=_coerce_str( que.get("path") if isinstance(que, dict) else None, "data/cognitive/queues", diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index f0011f1c..e97494a7 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -304,6 +304,7 @@ class CognitiveConfig: # 史官改写时 bot 自身的称呼(仅影响认知记忆事件文本,不影响主提示词) bot_name: str = "Undefined" vector_store_path: str = "data/cognitive/chromadb" + vector_store_scheduler_foreground_burst: int = 8 queue_path: str = "data/cognitive/queues" profiles_path: str = "data/cognitive/profiles" auto_top_k: int = 3 diff --git a/src/Undefined/main.py b/src/Undefined/main.py index df95c086..8dd9ead7 100644 --- a/src/Undefined/main.py +++ b/src/Undefined/main.py @@ -313,6 +313,7 @@ async def main() -> None: vector_store = CognitiveVectorStore( str(_cog_chroma), retrieval_runtime, + scheduler_foreground_burst=config.cognitive.vector_store_scheduler_foreground_burst, ) job_queue = JobQueue(str(_cog_queues)) profile_storage = ProfileStorage( diff --git a/src/Undefined/services/ai_coordinator.py b/src/Undefined/services/ai_coordinator.py index c61b6017..599d2b14 100644 --- a/src/Undefined/services/ai_coordinator.py +++ b/src/Undefined/services/ai_coordinator.py @@ -22,6 +22,7 @@ MessageBatcher, make_scope, ) +from Undefined.services.coordinator.message_ids import collect_message_ids from Undefined.utils.history import MessageHistoryManager from Undefined.utils.sender import MessageSender from Undefined.utils.scheduler import TaskScheduler @@ -50,6 +51,8 @@ 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】 4. 其他技术问题 → 【酌情回复,直接按用户提到的对象回答,不要引入无关的项目名/工具名作背景】 5. 先判断当前输入批次(无连续消息说明时就是最后一条消息)是不是在对你说: + - 先看 sender_id、@/reply、前后文对话对象和当前群聊环境;不要先入为主把"你"、"AI"、"bot"、"机器人"当作在叫 Undefined + - 泛称或讨论其他 AI/bot/机器人时不算叫你;无法确认指向 Undefined 时默认不回复 - 如果明显是在和别人说话 → 【不要回复】 - 如果你不能确定是不是在和你说话 → 【默认不回复】 - 只有明确在和你说,或多人公开讨论且对话明显开放时,才进入下一步 @@ -302,6 +305,11 @@ async def _execute_auto_reply(self, request: dict[str, Any]) -> None: group_name = str(request.get("group_name") or "未知群聊") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + message_ids = [ + str(item).strip() + for item in request.get("message_ids", []) + if str(item).strip() + ] # 用于向 batcher 注册 inflight 任务(仅当本请求源自合并桶时生效) batcher_scope: str | None = make_scope(group_id=group_id) if group_id else None @@ -368,6 +376,8 @@ async def send_like_cb(uid: int, times: int = 1) -> None: ctx.set_resource(key, value) if trigger_message_id is not None: ctx.set_resource("trigger_message_id", trigger_message_id) + if message_ids: + ctx.set_resource("message_ids", list(message_ids)) if request.get("_queue_lane"): ctx.set_resource("queue_lane", request.get("_queue_lane")) logger.debug( @@ -410,6 +420,12 @@ async def send_like_cb(uid: int, times: int = 1) -> None: "is_at_bot": bool(request.get("is_at_bot", False)), "sender_name": sender_name, "group_name": group_name, + "message_ids": list(message_ids), + "batched_count": int(request.get("batched_count", 1) or 1), + "current_input_is_batched": int( + request.get("batched_count", 1) or 1 + ) + > 1, }, ) finally: @@ -438,6 +454,11 @@ async def _execute_private_reply(self, request: dict[str, Any]) -> None: sender_name = str(request.get("sender_name") or "未知用户") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + message_ids = [ + str(item).strip() + for item in request.get("message_ids", []) + if str(item).strip() + ] batcher_scope: str | None = make_scope(user_id=user_id) async with RequestContext( @@ -498,6 +519,8 @@ async def send_private_cb( ctx.set_resource(key, value) if trigger_message_id is not None: ctx.set_resource("trigger_message_id", trigger_message_id) + if message_ids: + ctx.set_resource("message_ids", list(message_ids)) if request.get("_queue_lane"): ctx.set_resource("queue_lane", request.get("_queue_lane")) logger.debug( @@ -536,6 +559,12 @@ async def send_private_cb( "is_private_chat": True, "sender_name": sender_name, "selected_model_name": request.get("selected_model_name"), + "message_ids": list(message_ids), + "batched_count": int(request.get("batched_count", 1) or 1), + "current_input_is_batched": int( + request.get("batched_count", 1) or 1 + ) + > 1, }, ) finally: @@ -895,6 +924,10 @@ def _build_grouped_prompt(self, items: list[BufferedMessage]) -> str: body += _GROUP_STRATEGY_FOOTER if not is_private else _PRIVATE_STRATEGY_FOOTER return body + @staticmethod + def _collect_message_ids(items: list[BufferedMessage]) -> list[str]: + return collect_message_ids(items) + async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: """根据一组 BufferedMessage 决定优先级、构造 prompt 并入队。 @@ -905,6 +938,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: first = items[0] last = items[-1] full_question = self._build_grouped_prompt(items) + message_ids = self._collect_message_ids(items) any_poke = any(it.is_poke for it in items) any_at_bot = any(it.is_at_bot for it in items) @@ -917,6 +951,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: "text": last.text, "full_question": full_question, "trigger_message_id": last.trigger_message_id, + "message_ids": message_ids, "batched_count": len(items), } if first.batch_token is not None: @@ -954,6 +989,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: "full_question": full_question, "is_at_bot": any_at_bot, "trigger_message_id": last.trigger_message_id, + "message_ids": message_ids, "batched_count": len(items), } if first.batch_token is not None: @@ -1021,32 +1057,7 @@ def _build_prompt( return f"""{prefix} {safe_text}{attachment_xml} - - 【回复策略 - 更克制,纯表情包才前置检索】 - 1. 如果用户 @ 了你或拍了拍你 → 【必须回复】 - 2. 如果消息中明确提到了你(根据上下文判断用户是否在叫你或维持对话流) → 【必须回复】 - 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】 - 4. 其他技术问题 → 【酌情回复,直接按用户提到的对象回答,不要引入无关的项目名/工具名作背景】 - 5. 先判断当前输入批次(无连续消息说明时就是最后一条消息)是不是在对你说: - - 如果明显是在和别人说话 → 【不要回复】 - - 如果你不能确定是不是在和你说话 → 【默认不回复】 - - 只有明确在和你说,或多人公开讨论且对话明显开放时,才进入下一步 - 6. 群聊里的主动参与只保留给公开、开放的技术或项目讨论: - - 只在多人公开讨论代码、AI、开发工具、项目进展、技术 bug 等,且不是别人之间定向交流时,才可以【极低频参与】 - - 默认更倾向不参与;不要长篇大论,一两句点到为止;如果别人已经在深入讨论且不需要你,保持沉默 - - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复,且本轮明确是纯表情包/纯反应图时,才优先考虑表情包表达 - 7. 对于已经决定要回复的场景(包括被@、被拍一拍、轻量答疑,以及少量符合条件的主动参与): - - 只有明确纯表情包回复才先检索表情包,再用 memes.send_meme_by_uid 单独发一条图片消息 - - 其他需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 send_message - - 如果确实还想补表情包,把 memes.search_memes 和 memes.send_meme_by_uid 放到文字发送后的后续响应轮次,不要阻塞首条文字回复 - - 不要发送任何敷衍消息(如'懒得掺和'、'哦'等);不想回复就直接调用 end - - 严肃、任务型、高信息密度场景少发表情包,避免打断信息传递 - - 绝不要刷屏、绝不要每条都回 - 8. 对于本来就会回复的场景(私聊、被拍一拍、被@、轻量答疑): - - 如果表情包能自然增强语气、缓和语气或让表达更像真人,也只能作为后续可选补充 - - 但不要为了发表情包而牺牲信息传递;信息密度优先时仍以文字为主 - - 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,除非明确是纯表情包回复,否则先把文字回复做好,表情包最后再搜。""" +{_GROUP_STRATEGY_FOOTER}""" async def _send_image(self, tid: int, mtype: str, path: str) -> None: """发送图片或语音消息到群聊或私聊""" diff --git a/src/Undefined/services/coordinator/batching.py b/src/Undefined/services/coordinator/batching.py index d3da2c63..0b5a1196 100644 --- a/src/Undefined/services/coordinator/batching.py +++ b/src/Undefined/services/coordinator/batching.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any from Undefined.services.coordinator.group import _GROUP_STRATEGY_FOOTER +from Undefined.services.coordinator.message_ids import collect_message_ids from Undefined.services.coordinator.private import _PRIVATE_STRATEGY_FOOTER from Undefined.services.message_batcher import BufferedMessage @@ -95,6 +96,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: first = items[0] last = items[-1] full_question = self._build_grouped_prompt(items) + message_ids = collect_message_ids(items) any_poke = any(it.is_poke for it in items) any_at_bot = any(it.is_at_bot for it in items) @@ -107,6 +109,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: "text": last.text, "full_question": full_question, "trigger_message_id": last.trigger_message_id, + "message_ids": message_ids, "batched_count": len(items), } if first.batch_token is not None: @@ -145,6 +148,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: "full_question": full_question, "is_at_bot": any_at_bot, "trigger_message_id": last.trigger_message_id, + "message_ids": message_ids, "batched_count": len(items), } if first.batch_token is not None: diff --git a/src/Undefined/services/coordinator/group.py b/src/Undefined/services/coordinator/group.py index 2a63d9a3..4ebe25bd 100644 --- a/src/Undefined/services/coordinator/group.py +++ b/src/Undefined/services/coordinator/group.py @@ -36,6 +36,8 @@ 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】 4. 其他技术问题 → 【酌情回复,直接按用户提到的对象回答,不要引入无关的项目名/工具名作背景】 5. 先判断当前输入批次(无连续消息说明时就是最后一条消息)是不是在对你说: + - 先看 sender_id、@/reply、前后文对话对象和当前群聊环境;不要先入为主把"你"、"AI"、"bot"、"机器人"当作在叫 Undefined + - 泛称或讨论其他 AI/bot/机器人时不算叫你;无法确认指向 Undefined 时默认不回复 - 如果明显是在和别人说话 → 【不要回复】 - 如果你不能确定是不是在和你说话 → 【默认不回复】 - 只有明确在和你说,或多人公开讨论且对话明显开放时,才进入下一步 @@ -165,6 +167,11 @@ async def _execute_auto_reply(self, request: dict[str, Any]) -> None: group_name = str(request.get("group_name") or "未知群聊") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + message_ids = [ + str(item).strip() + for item in request.get("message_ids", []) + if str(item).strip() + ] # 用于向 batcher 注册 inflight 任务(仅当本请求源自合并桶时生效) batcher_scope: str | None = make_scope(group_id=group_id) if group_id else None @@ -231,6 +238,8 @@ async def send_like_cb(uid: int, times: int = 1) -> None: ctx.set_resource(key, value) if trigger_message_id is not None: ctx.set_resource("trigger_message_id", trigger_message_id) + if message_ids: + ctx.set_resource("message_ids", list(message_ids)) if request.get("_queue_lane"): ctx.set_resource("queue_lane", request.get("_queue_lane")) logger.debug( @@ -273,6 +282,12 @@ async def send_like_cb(uid: int, times: int = 1) -> None: "is_at_bot": bool(request.get("is_at_bot", False)), "sender_name": sender_name, "group_name": group_name, + "message_ids": list(message_ids), + "batched_count": int(request.get("batched_count", 1) or 1), + "current_input_is_batched": int( + request.get("batched_count", 1) or 1 + ) + > 1, }, ) finally: diff --git a/src/Undefined/services/coordinator/message_ids.py b/src/Undefined/services/coordinator/message_ids.py new file mode 100644 index 00000000..480b2635 --- /dev/null +++ b/src/Undefined/services/coordinator/message_ids.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from Undefined.services.message_batcher import BufferedMessage + + +def collect_message_ids(items: list[BufferedMessage]) -> list[str]: + """Collect all known message IDs from a grouped request.""" + message_ids: list[str] = [] + seen: set[str] = set() + for item in items: + if item.trigger_message_id is None: + continue + message_id = str(item.trigger_message_id).strip() + if not message_id or message_id in seen: + continue + seen.add(message_id) + message_ids.append(message_id) + return message_ids diff --git a/src/Undefined/services/coordinator/private.py b/src/Undefined/services/coordinator/private.py index e3a6ec74..ebc3ac34 100644 --- a/src/Undefined/services/coordinator/private.py +++ b/src/Undefined/services/coordinator/private.py @@ -118,6 +118,11 @@ async def _execute_private_reply(self, request: dict[str, Any]) -> None: sender_name = str(request.get("sender_name") or "未知用户") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + message_ids = [ + str(item).strip() + for item in request.get("message_ids", []) + if str(item).strip() + ] batcher_scope: str | None = make_scope(user_id=user_id) async with RequestContext( @@ -178,6 +183,8 @@ async def send_private_cb( ctx.set_resource(key, value) if trigger_message_id is not None: ctx.set_resource("trigger_message_id", trigger_message_id) + if message_ids: + ctx.set_resource("message_ids", list(message_ids)) if request.get("_queue_lane"): ctx.set_resource("queue_lane", request.get("_queue_lane")) logger.debug( @@ -216,6 +223,12 @@ async def send_private_cb( "is_private_chat": True, "sender_name": sender_name, "selected_model_name": request.get("selected_model_name"), + "message_ids": list(message_ids), + "batched_count": int(request.get("batched_count", 1) or 1), + "current_input_is_batched": int( + request.get("batched_count", 1) or 1 + ) + > 1, }, ) finally: diff --git a/src/Undefined/skills/agents/README.md b/src/Undefined/skills/agents/README.md index 20da6b9e..f1acc322 100644 --- a/src/Undefined/skills/agents/README.md +++ b/src/Undefined/skills/agents/README.md @@ -263,6 +263,7 @@ mv skills/tools/my_tool skills/agents/my_agent/tools/ - **功能**:网页搜索和网页内容获取 - **适用场景**:获取互联网最新信息、搜索新闻、爬取网页内容 - **子工具**:`grok_search`, `web_search`, `crawl_webpage` +- **grok_search 参数**:优先使用 `search_request`,用自然语言完整叙述搜索要求,不要只传关键词。 ### file_analysis_agent(文件分析助手) - **功能**:分析代码、PDF、Docx、Xlsx 等多种格式文件 diff --git a/src/Undefined/skills/agents/agent_tool_registry.py b/src/Undefined/skills/agents/agent_tool_registry.py index a7c2cbf9..36775211 100644 --- a/src/Undefined/skills/agents/agent_tool_registry.py +++ b/src/Undefined/skills/agents/agent_tool_registry.py @@ -417,6 +417,22 @@ async def handler(args: dict[str, Any], context: dict[str, Any]) -> str: # 构造被调用方上下文,避免复用调用方身份与历史。 callee_context = context.copy() callee_context["agent_name"] = target_agent_name + parent_call_id = str(context.get("webchat_parent_call_id") or "") + if parent_call_id: + callee_context["webchat_parent_call_id"] = parent_call_id + call_parent_id = str(context.get("webchat_call_parent_id") or "") + if call_parent_id: + callee_context["webchat_call_parent_id"] = call_parent_id + try: + callee_context["webchat_depth"] = max( + 0, int(context.get("webchat_depth", 0) or 0) + ) + except (TypeError, ValueError): + callee_context["webchat_depth"] = 0 + agent_path = context.get("webchat_agent_path") + callee_context["webchat_agent_path"] = ( + list(agent_path) if isinstance(agent_path, list) else [] + ) agent_histories = context.get("agent_histories") if not isinstance(agent_histories, dict): diff --git a/src/Undefined/skills/agents/arxiv_analysis_agent/handler.py b/src/Undefined/skills/agents/arxiv_analysis_agent/handler.py index 7b6d6052..024a9d7f 100644 --- a/src/Undefined/skills/agents/arxiv_analysis_agent/handler.py +++ b/src/Undefined/skills/agents/arxiv_analysis_agent/handler.py @@ -5,7 +5,10 @@ from typing import Any from Undefined.arxiv.parser import normalize_arxiv_id -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -43,6 +46,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=15, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/code_delivery_agent/handler.py b/src/Undefined/skills/agents/code_delivery_agent/handler.py index 9627be45..d5062e9f 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/handler.py @@ -99,7 +99,10 @@ async def _run_agent_with_retry( agent_dir: Path, ) -> str: """执行 agent。""" - from Undefined.skills.agents.runner import run_agent_with_tools + from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, + ) return await run_agent_with_tools( agent_name="code_delivery_agent", @@ -110,7 +113,7 @@ async def _run_agent_with_retry( context=context, agent_dir=agent_dir, logger=logger, - max_iterations=50, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/entertainment_agent/handler.py b/src/Undefined/skills/agents/entertainment_agent/handler.py index 3408af04..e9f0f14f 100644 --- a/src/Undefined/skills/agents/entertainment_agent/handler.py +++ b/src/Undefined/skills/agents/entertainment_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -21,6 +24,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=20, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/file_analysis_agent/handler.py b/src/Undefined/skills/agents/file_analysis_agent/handler.py index 7db9cbe0..0b1ddb33 100644 --- a/src/Undefined/skills/agents/file_analysis_agent/handler.py +++ b/src/Undefined/skills/agents/file_analysis_agent/handler.py @@ -5,7 +5,10 @@ from typing import Any from Undefined.attachments import scope_from_context -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -55,6 +58,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=30, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/file_analysis_agent/tools/download_file/handler.py b/src/Undefined/skills/agents/file_analysis_agent/tools/download_file/handler.py index f12b6654..acafe3d0 100644 --- a/src/Undefined/skills/agents/file_analysis_agent/tools/download_file/handler.py +++ b/src/Undefined/skills/agents/file_analysis_agent/tools/download_file/handler.py @@ -1,11 +1,11 @@ +import asyncio +import logging import uuid from pathlib import Path -from typing import Any, Dict -import logging -import httpx -import aiofiles +from typing import Any, Callable, Dict, Protocol, cast -from Undefined.attachments import scope_from_context +import aiofiles +import httpx logger = logging.getLogger(__name__) @@ -23,6 +23,79 @@ } DEFAULT_SIZE_LIMIT = 100 * 1024 * 1024 +_MAX_PATH_SOURCE_LENGTH = 4096 + + +class WriteBytesFn(Protocol): + async def __call__( + self, file_path: str | Path, content: bytes, use_lock: bool = True + ) -> None: ... + + +def _safe_download_filename( + *, + preferred_name: str, + fallback_name: str = "", + fallback_prefix: str, + task_uuid: str, +) -> str: + name = str(preferred_name or "").strip() + suffix = _safe_suffix(name) or _safe_suffix(str(fallback_name or "").strip()) + if suffix: + return f"{fallback_prefix}_{task_uuid}{suffix}" + return f"{fallback_prefix}_{task_uuid}" + + +def _safe_suffix(name: str) -> str: + if not name or len(name) > 255: + return "" + basename = name.replace("\\", "/").rsplit("/", 1)[-1].split("?", 1)[0] + basename = basename.split("#", 1)[0] + suffixes = Path(basename).suffixes[-2:] + suffix = "".join(suffixes).lower() + if len(suffix) > 16: + suffix = Path(suffix).suffix.lower() + if not suffix or len(suffix) > 16: + return "" + if any(ch not in ".abcdefghijklmnopqrstuvwxyz0123456789_-" for ch in suffix): + return "" + return suffix + + +def _download_prefix(record: Any | None = None) -> str: + if record is None: + return "file" + kind = str(getattr(record, "media_type", "") or getattr(record, "kind", "")) + return "image" if kind.strip().lower() == "image" else "file" + + +def _is_http_url(value: str) -> bool: + return value.startswith("http://") or value.startswith("https://") + + +def _is_file_uri(value: str) -> bool: + return value.startswith("file://") + + +def _can_treat_as_local_path(value: str) -> bool: + if not value or len(value) > _MAX_PATH_SOURCE_LENGTH: + return False + lowered = value.lower() + if lowered.startswith(("base64://", "data:")): + return False + if "://" in value and not _is_file_uri(value): + return False + return True + + +async def _copy_file_to_temp( + source: Path, + target: Path, + write_bytes_fn: WriteBytesFn, +) -> None: + async with aiofiles.open(source, "rb") as src: + content = await src.read() + await write_bytes_fn(target, content, use_lock=False) async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: @@ -42,37 +115,77 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: return "错误:文件源不能为空" task_uuid: str = uuid.uuid4().hex[:16] - from Undefined.utils.paths import DOWNLOAD_CACHE_DIR, ensure_dir - temp_dir: Path = ensure_dir(DOWNLOAD_CACHE_DIR / task_uuid) + download_cache_dir_raw = context.get("download_cache_dir") + ensure_dir_fn = context.get("ensure_dir_fn") + if download_cache_dir_raw is None or not callable(ensure_dir_fn): + return "错误:download_file 缺少下载缓存目录上下文依赖" + write_bytes_fn = context.get("write_bytes_fn") + if not callable(write_bytes_fn): + return "错误:download_file 缺少原子文件写入上下文依赖" + write_bytes = cast(WriteBytesFn, write_bytes_fn) + + download_cache_dir = Path(download_cache_dir_raw) + temp_dir: Path = cast(Callable[[Path], Path], ensure_dir_fn)( + download_cache_dir / task_uuid + ) attachment_registry = context.get("attachment_registry") - scope_key = scope_from_context(context) + scope_key = str(context.get("scope_key") or "").strip() or None + if scope_key is None: + get_scope_from_context = context.get("get_scope_from_context") + if callable(get_scope_from_context): + scope_key_raw = get_scope_from_context(context) + scope_key = str(scope_key_raw or "").strip() or None if attachment_registry and scope_key: try: - record = attachment_registry.resolve(file_source, scope_key) + load = getattr(attachment_registry, "load", None) + if load is not None: + await load() + resolve_async = getattr(attachment_registry, "resolve_async", None) + if resolve_async is not None: + record = await resolve_async(file_source, scope_key) + else: + record = attachment_registry.resolve(file_source, scope_key) except Exception: + logger.exception("附件 UID 解析失败: %s", file_source) record = None if record is not None: return await _download_from_attachment_record( record, + registry=attachment_registry, temp_dir=temp_dir, max_size_mb=max_size_mb, task_uuid=task_uuid, + write_bytes_fn=write_bytes, ) - is_url: bool = file_source.startswith("http://") or file_source.startswith( - "https://" - ) + is_url: bool = _is_http_url(file_source) if is_url: - return await _download_from_url(file_source, temp_dir, max_size_mb, task_uuid) + return await _download_from_url( + file_source, + temp_dir, + max_size_mb, + task_uuid, + write_bytes, + ) else: - return await _download_from_file_id(file_source, temp_dir, context, task_uuid) + return await _download_from_file_id( + file_source, + temp_dir, + context, + task_uuid, + write_bytes, + ) async def _download_from_url( - url: str, temp_dir: Path, max_size_mb: float, task_uuid: str + url: str, + temp_dir: Path, + max_size_mb: float, + task_uuid: str, + write_bytes_fn: WriteBytesFn, ) -> str: """从 Web URL 进行下载,包含大小预检""" max_size_bytes: int = int(max_size_mb * 1024 * 1024) @@ -96,12 +209,13 @@ async def _download_from_url( response = await client.get(url, timeout=120.0) response.raise_for_status() - filename = _extract_filename_from_url(url) - if not filename or "." not in filename: - filename = f"downloaded_{task_uuid}" - + filename = _safe_download_filename( + preferred_name=_extract_filename_from_url(url), + fallback_prefix="file", + task_uuid=task_uuid, + ) file_path = temp_dir / filename - file_path.write_bytes(response.content) + await write_bytes_fn(file_path, response.content, use_lock=False) logger.info(f"文件已保存到: {file_path}") return str(file_path) @@ -116,7 +230,11 @@ async def _download_from_url( async def _download_from_file_id( - file_id: str, temp_dir: Path, context: Dict[str, Any], task_uuid: str + file_id: str, + temp_dir: Path, + context: Dict[str, Any], + task_uuid: str, + write_bytes_fn: WriteBytesFn, ) -> str: """从 OneBot file_id 进行下载或解析""" get_image_url_callback = context.get("get_image_url_callback") @@ -132,7 +250,7 @@ async def _download_from_file_id( logger.info(f"获取到 URL: {url}") # 检查是否为 HTTP/HTTPS URL - is_http_url = url.startswith("http://") or url.startswith("https://") + is_http_url = _is_http_url(url) if is_http_url: # 使用 httpx 下载远程文件 @@ -140,15 +258,21 @@ async def _download_from_file_id( response = await client.get(url, timeout=120.0) response.raise_for_status() - filename = f"file_{file_id}" + filename = _safe_download_filename( + preferred_name=file_id, + fallback_prefix="file", + task_uuid=task_uuid, + ) file_path = temp_dir / filename - file_path.write_bytes(response.content) + await write_bytes_fn(file_path, response.content, use_lock=False) logger.info(f"文件已保存到: {file_path}") return str(file_path) else: + if not _can_treat_as_local_path(url): + return "错误:解析结果不是可访问的本地文件路径或 HTTP URL" # 处理本地文件路径 - local_path = Path(url) + local_path = Path(url[7:] if _is_file_uri(url) else url) if not local_path.exists(): return f"错误:本地文件不存在: {url}" @@ -156,9 +280,13 @@ async def _download_from_file_id( async with aiofiles.open(local_path, "rb") as f: content = await f.read() - filename = local_path.name + filename = _safe_download_filename( + preferred_name=local_path.name, + fallback_prefix="file", + task_uuid=task_uuid, + ) file_path = temp_dir / filename - file_path.write_bytes(content) + await write_bytes_fn(file_path, content, use_lock=False) logger.info(f"本地文件已复制到: {file_path}") return str(file_path) @@ -178,44 +306,71 @@ def _extract_filename_from_url(url: str) -> str: async def _download_from_attachment_record( record: Any, *, + registry: Any, temp_dir: Path, max_size_mb: float, task_uuid: str, + write_bytes_fn: WriteBytesFn, ) -> str: max_size_bytes: int = int(max_size_mb * 1024 * 1024) - local_path_raw = getattr(record, "local_path", None) - if local_path_raw: - local_path = Path(str(local_path_raw)) - if local_path.is_file(): - size = local_path.stat().st_size - if size > max_size_bytes: - return ( - f"错误:文件大小 ({size / 1024 / 1024:.2f}MB) " - f"超过限制 ({max_size_mb}MB)" + try: + ensure_local_file = getattr(registry, "ensure_local_file", None) + if ensure_local_file is not None: + record = await ensure_local_file(record) + + source_ref = str(getattr(record, "source_ref", "") or "").strip() + local_path_raw = str(getattr(record, "local_path", "") or "").strip() + if not _can_treat_as_local_path(local_path_raw): + if _is_http_url(source_ref): + return await _download_from_url( + source_ref, + temp_dir, + max_size_mb, + task_uuid, + write_bytes_fn, + ) + return f"错误:无法从附件 UID {getattr(record, 'uid', '')} 解析到可下载文件" + + local_path = Path( + local_path_raw[7:] if _is_file_uri(local_path_raw) else local_path_raw + ) + if not await asyncio.to_thread(local_path.is_file): + if _is_http_url(source_ref): + return await _download_from_url( + source_ref, + temp_dir, + max_size_mb, + task_uuid, + write_bytes_fn, ) - display_name = str(getattr(record, "display_name", "") or "").strip() - filename = display_name or local_path.name or f"downloaded_{task_uuid}" - target = temp_dir / filename - async with aiofiles.open(local_path, "rb") as src: - content = await src.read() - target.write_bytes(content) - logger.info("附件 UID 已复制到: %s", target) - return str(target) - - source_ref = str(getattr(record, "source_ref", "") or "").strip() - if source_ref.startswith("http://") or source_ref.startswith("https://"): - return await _download_from_url(source_ref, temp_dir, max_size_mb, task_uuid) - - if source_ref: - candidate = Path(source_ref) - if candidate.exists() and candidate.is_file(): - display_name = str(getattr(record, "display_name", "") or "").strip() - filename = display_name or candidate.name or f"downloaded_{task_uuid}" - target = temp_dir / filename - async with aiofiles.open(candidate, "rb") as src: - content = await src.read() - target.write_bytes(content) - logger.info("附件 UID 源文件已复制到: %s", target) - return str(target) - - return f"错误:无法从附件 UID {getattr(record, 'uid', '')} 解析到可下载文件" + return f"错误:附件 UID 本地文件不存在:{getattr(record, 'uid', '')}" + + size = await asyncio.to_thread(lambda: local_path.stat().st_size) + if size > max_size_bytes: + return ( + f"错误:文件大小 ({size / 1024 / 1024:.2f}MB) " + f"超过限制 ({max_size_mb}MB)" + ) + + display_name = str(getattr(record, "display_name", "") or "").strip() + filename = _safe_download_filename( + preferred_name=display_name, + fallback_name=local_path.name, + fallback_prefix=_download_prefix(record), + task_uuid=task_uuid, + ) + target = temp_dir / filename + await _copy_file_to_temp( + local_path, + target, + write_bytes_fn, + ) + logger.info("附件 UID 已通过注册表复制到: %s", target) + return str(target) + except OSError as exc: + logger.warning( + "附件 UID 本地化复制失败 uid=%s err=%s", + getattr(record, "uid", ""), + exc, + ) + return "错误:附件文件读取失败" diff --git a/src/Undefined/skills/agents/info_agent/handler.py b/src/Undefined/skills/agents/info_agent/handler.py index 910971e6..6332597e 100644 --- a/src/Undefined/skills/agents/info_agent/handler.py +++ b/src/Undefined/skills/agents/info_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -21,6 +24,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=20, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py b/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py index e9dca11f..eb4b4fd0 100644 --- a/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py +++ b/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -21,6 +24,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=20, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/runner/__init__.py b/src/Undefined/skills/agents/runner/__init__.py index 09676932..5ac28cf6 100644 --- a/src/Undefined/skills/agents/runner/__init__.py +++ b/src/Undefined/skills/agents/runner/__init__.py @@ -2,10 +2,14 @@ # 对外 re-export,兼容 `from Undefined.skills.agents.runner import run_agent_with_tools` from Undefined.skills.agents.runner.context import load_prompt_text -from Undefined.skills.agents.runner.loop import run_agent_with_tools +from Undefined.skills.agents.runner.loop import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) from Undefined.skills.agents.runner.tools import _filter_tools_for_runtime_config __all__ = [ + "DEFAULT_AGENT_MAX_ITERATIONS", "load_prompt_text", "run_agent_with_tools", "_filter_tools_for_runtime_config", diff --git a/src/Undefined/skills/agents/runner/loop.py b/src/Undefined/skills/agents/runner/loop.py index bc215e8b..6de77a70 100644 --- a/src/Undefined/skills/agents/runner/loop.py +++ b/src/Undefined/skills/agents/runner/loop.py @@ -8,6 +8,40 @@ from Undefined.ai.transports.openai_transport import RESPONSES_OUTPUT_ITEMS_KEY from Undefined.skills.agents.runner.context import prepare_agent_run from Undefined.skills.agents.runner.tools import execute_assistant_tool_calls +from Undefined.skills.agents.runner.webchat_utils import ( + webchat_agent_path, + webchat_depth, +) + + +DEFAULT_AGENT_MAX_ITERATIONS = 1000 + + +async def _emit_webchat_agent_stage( + context: dict[str, Any], + agent_name: str, + stage: str, + detail: Any | None = None, +) -> None: + callback = context.get("webchat_event_callback") + if not callable(callback): + return + call_id = str(context.get("webchat_parent_call_id") or "").strip() + if not call_id: + return + parent_call_id = str(context.get("webchat_call_parent_id") or "").strip() + payload: dict[str, Any] = { + "webchat_call_id": call_id, + "parent_webchat_call_id": parent_call_id, + "agent_name": agent_name, + "name": agent_name, + "stage": stage, + "depth": webchat_depth(context.get("webchat_depth")), + "agent_path": webchat_agent_path(context.get("webchat_agent_path")), + } + if detail is not None: + payload["detail"] = detail + await callback("agent_stage", payload) # Agent 主循环:LLM 决策 → 工具执行 → 结果回填 @@ -21,7 +55,7 @@ async def run_agent_with_tools( context: dict[str, Any], agent_dir: Path, logger: logging.Logger, - max_iterations: int = 20, + max_iterations: int = DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix: str = "错误", ) -> str: """执行通用 Agent 循环。 @@ -50,6 +84,7 @@ async def run_agent_with_tools( if isinstance(prepared, str): return prepared + await _emit_webchat_agent_stage(context, agent_name, "context_ready") messages = prepared.messages transport_state: dict[str, Any] | None = None pre_tool_failure_count = 0 @@ -62,6 +97,12 @@ async def run_agent_with_tools( message_checkpoint_len = len(messages) transport_state_checkpoint = transport_state try: + await _emit_webchat_agent_stage( + context, + agent_name, + "waiting_model", + f"iteration={iteration} model={prepared.agent_config.model_name}", + ) # 通过队列提交 LLM 请求(含 tools 与 transport 多轮状态) result = await prepared.ai_client.submit_queued_llm_call( model_config=prepared.agent_config, @@ -118,8 +159,12 @@ async def run_agent_with_tools( # 无工具调用即视为最终回复 if not tool_calls: + await _emit_webchat_agent_stage(context, agent_name, "done") return content + await _emit_webchat_agent_stage( + context, agent_name, "preparing_tools", len(tool_calls) + ) # 将 assistant 消息(含 tool_calls)追加到对话历史 assistant_message: dict[str, Any] = { "role": "assistant", @@ -138,6 +183,21 @@ async def run_agent_with_tools( messages.append(assistant_message) # 并发执行 tool_calls,结果以 role=tool 消息回填 + tool_names = [ + str( + (tool_call.get("function") or {}).get("name") + if isinstance(tool_call, dict) + and isinstance(tool_call.get("function"), dict) + else "" + ) + for tool_call in tool_calls + ] + await _emit_webchat_agent_stage( + context, + agent_name, + "waiting_tools", + ", ".join(name for name in tool_names if name), + ) tool_execution_started = await execute_assistant_tool_calls( agent_name=agent_name, tool_calls=tool_calls, @@ -169,6 +229,9 @@ async def run_agent_with_tools( iteration, exc, ) + await _emit_webchat_agent_stage( + context, agent_name, "retrying_model", str(exc) + ) continue logger.exception( "[Agent:%s] 执行失败,已静默抑制: lane=%s iteration=%s error=%s", @@ -179,4 +242,5 @@ async def run_agent_with_tools( ) return "" + await _emit_webchat_agent_stage(context, agent_name, "done") return "达到最大迭代次数" diff --git a/src/Undefined/skills/agents/runner/tools.py b/src/Undefined/skills/agents/runner/tools.py index 31d79177..e7a01b0f 100644 --- a/src/Undefined/skills/agents/runner/tools.py +++ b/src/Undefined/skills/agents/runner/tools.py @@ -8,9 +8,29 @@ from Undefined.ai.tooling import END_CO_CALL_REJECT_CONTENT from Undefined.skills.anthropic_skills import AnthropicSkillRegistry from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry +from Undefined.skills.agents.runner.webchat_utils import ( + webchat_agent_path, + webchat_depth, +) from Undefined.utils.tool_calls import parse_tool_arguments +def _webchat_call_id(parent_call_id: str, call_id: str, fallback: str) -> str: + local_id = str(call_id or fallback or "tool").strip() or "tool" + return f"{parent_call_id}/{local_id}" if parent_call_id else local_id + + +async def _emit_webchat_tool_event( + context: dict[str, Any], + event: str, + payload: dict[str, Any], +) -> None: + callback = context.get("webchat_event_callback") + if not callable(callback): + return + await callback(event, payload) + + # 按运行时配置过滤不可用工具 schema def _filter_tools_for_runtime_config( agent_name: str, @@ -49,12 +69,20 @@ async def execute_assistant_tool_calls( ) -> bool: """并发执行 assistant 的 tool_calls,回填 tool 消息。返回是否已开始工具执行。""" - tool_tasks: list[asyncio.Future[Any]] = [] + tool_tasks: list[asyncio.Task[Any]] = [] tool_call_ids: list[str] = [] tool_api_names: list[str] = [] + tool_internal_names: list[str] = [] end_tool_call: dict[str, Any] | None = None end_tool_args: dict[str, Any] = {} + end_webchat_event_base: dict[str, Any] = {} tool_execution_started = False + parent_call_id = str(context.get("webchat_parent_call_id") or "").strip() + depth = webchat_depth(context.get("webchat_depth")) + agent_path = webchat_agent_path(context.get("webchat_agent_path")) + callable_agent_names: set[str] = getattr( + tool_registry, "_callable_agent_tool_names", set() + ) for tool_call in tool_calls: call_id = str(tool_call.get("id", "")) @@ -79,6 +107,30 @@ async def execute_assistant_tool_calls( if not isinstance(function_args, dict): function_args = {} + is_nested_agent = internal_function_name in callable_agent_names + webchat_call_id = _webchat_call_id( + parent_call_id, + call_id, + internal_function_name, + ) + webchat_event_base: dict[str, Any] = { + "webchat_call_id": webchat_call_id, + "parent_webchat_call_id": parent_call_id, + "depth": depth, + "agent_path": agent_path, + } + await _emit_webchat_tool_event( + context, + "tool_start", + { + "tool_call_id": call_id, + "name": internal_function_name, + "api_name": api_function_name, + "arguments": function_args, + "is_agent": is_nested_agent, + **webchat_event_base, + }, + ) # end 工具延后处理:若与其他工具同批调用则返回拒绝 if internal_function_name == "end": @@ -90,36 +142,96 @@ async def execute_assistant_tool_calls( ) end_tool_call = tool_call end_tool_args = function_args + end_webchat_event_base = webchat_event_base continue tool_call_ids.append(call_id) tool_api_names.append(api_function_name) + tool_internal_names.append(internal_function_name) + tool_context = context.copy() + if is_nested_agent: + tool_context["webchat_parent_call_id"] = webchat_call_id + tool_context["webchat_call_parent_id"] = parent_call_id + tool_context["webchat_depth"] = depth + 1 + tool_context["webchat_agent_path"] = [ + *agent_path, + internal_function_name, + ] skill_delimiter = ( agent_skill_registry.dot_delimiter if agent_skill_registry else "-_-" ) # Anthropic Skill 走独立 registry,其余走 AgentToolRegistry is_agent_skill = internal_function_name.startswith(f"skills{skill_delimiter}") - if is_agent_skill and agent_skill_registry: - tool_tasks.append( - asyncio.ensure_future( - agent_skill_registry.execute_skill_tool( - internal_function_name, - function_args, + + async def _execute_tool_with_webchat_event( + *, + call_id: str, + api_name: str, + internal_name: str, + args: dict[str, Any], + context: dict[str, Any], + webchat_event_base: dict[str, Any], + is_nested_agent: bool, + is_agent_skill: bool, + ) -> Any: + try: + if is_agent_skill and agent_skill_registry: + result = await agent_skill_registry.execute_skill_tool( + internal_name, + args, context, ) - ) - ) - else: - tool_tasks.append( - asyncio.ensure_future( - tool_registry.execute_tool( - internal_function_name, - function_args, + else: + result = await tool_registry.execute_tool( + internal_name, + args, context, ) + except Exception as exc: + await _emit_webchat_tool_event( + context, + "tool_end", + { + "tool_call_id": call_id, + "name": internal_name, + "api_name": api_name, + "ok": False, + "result": f"{tool_error_prefix}: {exc}", + "is_agent": is_nested_agent, + **webchat_event_base, + }, + ) + raise + await _emit_webchat_tool_event( + context, + "tool_end", + { + "tool_call_id": call_id, + "name": internal_name, + "api_name": api_name, + "ok": True, + "result": str(result), + "is_agent": is_nested_agent, + **webchat_event_base, + }, + ) + return result + + tool_tasks.append( + asyncio.create_task( + _execute_tool_with_webchat_event( + call_id=call_id, + api_name=api_function_name, + internal_name=internal_function_name, + args=function_args, + context=tool_context, + webchat_event_base=webchat_event_base, + is_nested_agent=is_nested_agent, + is_agent_skill=is_agent_skill, ) ) + ) if tool_tasks: tool_execution_started = True @@ -152,6 +264,19 @@ async def execute_assistant_tool_calls( end_call_id = str(end_tool_call.get("id", "")) end_api_name = end_tool_call.get("function", {}).get("name", "end") if tool_tasks: + await _emit_webchat_tool_event( + context, + "tool_end", + { + "tool_call_id": end_call_id, + "name": "end", + "api_name": end_api_name, + "ok": False, + "result": END_CO_CALL_REJECT_CONTENT, + "is_agent": False, + **end_webchat_event_base, + }, + ) messages.append( { "role": "tool", @@ -166,7 +291,27 @@ async def execute_assistant_tool_calls( ) else: tool_execution_started = True - end_result = await tool_registry.execute_tool("end", end_tool_args, context) + try: + end_result = await tool_registry.execute_tool( + "end", end_tool_args, context + ) + end_ok = True + except Exception as exc: + end_result = f"{tool_error_prefix}: {exc}" + end_ok = False + await _emit_webchat_tool_event( + context, + "tool_end", + { + "tool_call_id": end_call_id, + "name": "end", + "api_name": end_api_name, + "ok": end_ok, + "result": str(end_result), + "is_agent": False, + **end_webchat_event_base, + }, + ) messages.append( { "role": "tool", diff --git a/src/Undefined/skills/agents/runner/webchat_utils.py b/src/Undefined/skills/agents/runner/webchat_utils.py new file mode 100644 index 00000000..9395e934 --- /dev/null +++ b/src/Undefined/skills/agents/runner/webchat_utils.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Any + + +def webchat_agent_path(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + +def webchat_depth(value: Any) -> int: + try: + return max(0, int(value)) + except (TypeError, ValueError): + return 0 diff --git a/src/Undefined/skills/agents/summary_agent/handler.py b/src/Undefined/skills/agents/summary_agent/handler.py index f64aa4fc..94224657 100644 --- a/src/Undefined/skills/agents/summary_agent/handler.py +++ b/src/Undefined/skills/agents/summary_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -69,6 +72,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=10, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/web_agent/README.md b/src/Undefined/skills/agents/web_agent/README.md index 9a985c21..d8c04c82 100644 --- a/src/Undefined/skills/agents/web_agent/README.md +++ b/src/Undefined/skills/agents/web_agent/README.md @@ -2,7 +2,7 @@ 用于网络搜索与网页抓取,支持结合 MCP 的浏览器能力。 默认子工具包括: -- `grok_search`:优先级最高的联网搜索工具(需显式启用) +- `grok_search`:优先级最高的联网搜索工具(需显式启用),调用时使用 `search_request` 自然语言完整叙述搜索要求;工具会向 Grok 模型注入当前服务端时间、必须先搜索、交叉检索、禁止编造和必须给来源的约束 - `web_search`:基于 SearXNG 的后备搜索工具 - `crawl_webpage`:读取网页正文 diff --git a/src/Undefined/skills/agents/web_agent/handler.py b/src/Undefined/skills/agents/web_agent/handler.py index 3595c77e..f4e0358d 100644 --- a/src/Undefined/skills/agents/web_agent/handler.py +++ b/src/Undefined/skills/agents/web_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -22,6 +25,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=agent_dir, logger=logger, - max_iterations=20, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="Error", ) diff --git a/src/Undefined/skills/agents/web_agent/prompt.md b/src/Undefined/skills/agents/web_agent/prompt.md index 3944ebf5..54f18db8 100644 --- a/src/Undefined/skills/agents/web_agent/prompt.md +++ b/src/Undefined/skills/agents/web_agent/prompt.md @@ -2,7 +2,7 @@ 工作原则: - 先判断是“搜索”还是“读取 URL”,必要时追问范围或关键词。 -- 若 `grok_search` 可用,优先调用它;提问时要使用详细自然语言,尽量写清对象、时间范围、限定条件和想要的结果。 +- 若 `grok_search` 可用,优先调用它;调用时使用 `search_request`,用完整自然语言详细说明搜索内容和回答要求,不要只给关键词,也不要主动把范围限定到用户未要求的时间、地区、站点或排除项。若用户明确给出这些约束,再一并写入。 - 只有在 `grok_search` 不可用或明显不适合时,才改用 `web_search`。 - 优先给出权威来源或一手材料的要点。 - 结果要点化,避免堆砌原文。 diff --git a/src/Undefined/skills/agents/web_agent/tools/grok_search/config.json b/src/Undefined/skills/agents/web_agent/tools/grok_search/config.json index 8a23bb8e..129b4937 100644 --- a/src/Undefined/skills/agents/web_agent/tools/grok_search/config.json +++ b/src/Undefined/skills/agents/web_agent/tools/grok_search/config.json @@ -2,16 +2,16 @@ "type": "function", "function": { "name": "grok_search", - "description": "最优先使用的联网搜索工具,适用于获取最新信息、开放式互联网检索和高质量综合答案。提问时请使用详细自然语言,尽量带上时间范围、对象、限定条件和想要的结果。", + "description": "最优先使用的联网搜索工具,适用于获取最新信息、开放式互联网检索和高质量综合答案。调用时必须使用 search_request,用自然语言详细说明要搜索的内容和回答要求;不要只给关键词,也不要主动把范围写死到用户未要求的限制里。若用户明确给出时间、地区、站点、排除项、输出格式或比较维度等约束,再一并写入。", "parameters": { "type": "object", "properties": { - "query": { + "search_request": { "type": "string", - "description": "请用详细自然语言完整描述搜索问题,而不是只给几个关键词。" + "description": "用自然语言详细说明搜索内容和回答要求。把用户真正想查的主题、背景、重点、需要核实或比较的问题说清楚;不要只写关键词,也不要主动添加用户未要求的硬性范围。若用户给了时间、地区、来源、排除项或格式等要求,再一并写入。" } }, - "required": ["query"] + "required": ["search_request"] } } } diff --git a/src/Undefined/skills/agents/web_agent/tools/grok_search/handler.py b/src/Undefined/skills/agents/web_agent/tools/grok_search/handler.py index 8533f3e6..0aa81cc5 100644 --- a/src/Undefined/skills/agents/web_agent/tools/grok_search/handler.py +++ b/src/Undefined/skills/agents/web_agent/tools/grok_search/handler.py @@ -1,15 +1,59 @@ from __future__ import annotations +from datetime import datetime +import json import logging from typing import Any logger = logging.getLogger(__name__) +def _extract_grok_content(result: Any) -> str: + payload = result + if isinstance(result, str): + text = result.strip() + if not text: + return result + try: + payload = json.loads(text) + except json.JSONDecodeError: + return result + if not isinstance(payload, dict): + return str(result) + + choices = payload.get("choices") + if not isinstance(choices, list) or not choices: + return str(result) + first = choices[0] + if not isinstance(first, dict): + return str(result) + message = first.get("message") + if not isinstance(message, dict): + return str(result) + content = message.get("content") + if isinstance(content, str): + return content + return str(result) + + +def _build_grok_search_system_prompt(now: datetime | None = None) -> str: + current_time = (now or datetime.now().astimezone()).isoformat(timespec="seconds") + return "\n".join( + [ + "你是联网搜索执行器,必须严格遵守以下规则:", + f"- 当前基准时间是 {current_time},判断“今天”“最新”“最近”等相对时间时必须以这个时间为准,不要以模型内部时间为准。", + "- 必须先调用搜索能力获取外部信息,再组织回答;不要只依赖已有知识。", + "- 总是调用多个搜索工具或多组搜索查询,从不同角度全方位、深度检索以满足用户要求。", + "- 不可胡编乱造;无法确认的信息要明确说明不确定或未找到。", + "- 最终回答必须给出来源,优先包含标题、发布时间或访问时间、URL。", + ] + ) + + async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: - query = str(args.get("query") or "").strip() - if not query: - return "请提供详细的自然语言搜索问题。" + search_request = str(args.get("search_request") or "").strip() + if not search_request: + return "请用 search_request 提供完整的自然语言搜索要求。" runtime_config = context.get("runtime_config") if runtime_config is None: @@ -39,7 +83,10 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: if ai_client is None: return "Grok 搜索功能不可用(缺少 AI client)" - messages = [{"role": "user", "content": query}] + messages = [ + {"role": "system", "content": _build_grok_search_system_prompt()}, + {"role": "user", "content": search_request}, + ] try: result = await ai_client.submit_queued_llm_call( @@ -52,4 +99,4 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: logger.exception("[grok_search] 搜索失败: %s", exc) return "Grok 搜索失败,请稍后重试" - return str(result) + return _extract_grok_content(result) diff --git a/src/Undefined/skills/commands/changelog/config.json b/src/Undefined/skills/commands/changelog/config.json index 9477d560..51957445 100644 --- a/src/Undefined/skills/commands/changelog/config.json +++ b/src/Undefined/skills/commands/changelog/config.json @@ -12,5 +12,25 @@ "show_in_help": true, "order": 15, "allow_in_private": true, - "aliases": ["cl"] + "aliases": ["cl"], + "subcommands": { + "list": { + "description": "按新到旧列出版本号与标题", + "args": "[数量]" + }, + "show": { + "description": "展示指定版本的完整变更详情", + "args": "<版本号>" + }, + "latest": { + "description": "展示最新版本的完整变更详情" + } + }, + "inference": { + "default": "list", + "rules": [ + { "pattern": "^\\d+$", "subcommand": "list" }, + { "pattern": "^[vV]?\\d+(?:\\.\\d+){1,3}(?:[-+][A-Za-z0-9._-]+)?$", "subcommand": "show" } + ] + } } diff --git a/src/Undefined/skills/tools/end/README.md b/src/Undefined/skills/tools/end/README.md index 1a85d70a..239ea2d1 100644 --- a/src/Undefined/skills/tools/end/README.md +++ b/src/Undefined/skills/tools/end/README.md @@ -4,7 +4,8 @@ 关键信息: - `memo`(可选):本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里,如调用工具、决定不回复等)。若当前输入批次包含多条消息,应概括整批处理结果。 -- `observations`(可选):字符串数组,本轮值得长期留存的观察(严格一条一个要点,可多条)——包括用户/群聊事实和有价值的自身行为(帮谁解决了什么问题);纯流水账写 memo 而非此处。若当前输入批次包含 MessageBatcher 合并的多条消息,必须覆盖整批消息内容,不能只记录最后一条。 +- `observations`(可选):字符串数组,本轮有价值的新观察(严格一条一个要点,可多条)——只允许来自当前输入批次直接出现的新事实,或本轮回复行为产生的有价值事实(帮谁解决了什么问题)。不要求与 bot 相关,也不要求长期稳定;当前批次中有价值即可记录。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。纯流水账写 memo 而非此处。若当前输入批次包含 MessageBatcher 合并的多条消息,必须覆盖整批消息内容,不能只记录最后一条。 +- 专名拼写:涉及本项目或 bot 主名时必须写作 `Undefined`。工具会在入队认知记忆前把已知错拼 `Unfined`、`Undefind`、`undefind` 规范为 `Undefined`,避免污染长期观察。 - `force`(可选):`true` 时可跳过"本轮未发送消息"的结束检查;同时在认知史官绝对化正则闸门失败时允许强制入库 - 两者都可为空;为空时仅结束会话,不写认知队列 diff --git a/src/Undefined/skills/tools/end/config.json b/src/Undefined/skills/tools/end/config.json index bb782b8f..703b2e91 100644 --- a/src/Undefined/skills/tools/end/config.json +++ b/src/Undefined/skills/tools/end/config.json @@ -2,18 +2,18 @@ "type": "function", "function": { "name": "end", - "description": "结束当前对话。memo 是本轮便签纸(短句);observations 从当前输入批次提取本轮值得长期留存的观察——包括用户/群聊事实和有回忆价值的自身行为(帮谁解决了什么),纯流水账动作写 memo。若当前输入批次包含 MessageBatcher 合并的多条消息,记忆记录必须覆盖整批,不要只看最后一条。", + "description": "结束当前对话。memo 是本轮便签纸(短句);observations 只能从当前输入批次提取本轮有价值的新观察,不要求与 bot 相关,也不要求长期稳定。可记录当前批次直接出现的用户/群聊/第三方事实,以及本轮回复行为产生的有价值事实(帮谁解决了什么)。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。纯流水账动作写 memo。若当前输入批次包含 MessageBatcher 合并的多条消息,记忆记录必须覆盖整批,不要只看最后一条。项目名/主名必须逐字写作 Undefined,禁止写成 Unfined、Undefind、undefind 或其它变体。", "parameters": { "type": "object", "properties": { "memo": { "type": "string", - "description": "本轮行动备忘(可空,建议短句)。若当前输入批次包含多条消息,memo 应概括整批处理结果。" + "description": "本轮行动备忘(可空,建议短句)。若当前输入批次包含多条消息,memo 应概括整批处理结果。涉及本项目或你自己时必须写作 Undefined。" }, "observations": { "type": "array", "items": {"type": "string"}, - "description": "从当前输入批次提取认知观察列表;存在【连续消息说明】或多段当前 时,必须覆盖整批消息内容,不能只记录最后一条。记录用户/群聊事实(偏好、计划、状态、关系、观点、人物事实、群聊事实)以及有回忆价值的自身行为(帮谁解决了什么问题、给了什么建议)。每条一个要点,宁多勿漏。纯流水账动作(调了什么工具、决定不回复)写 memo 而非此处。格式:具体、绝对化,写明谁/何时/何地。" + "description": "从当前输入批次提取认知观察列表;只记录当前批次直接出现的新事实,或本轮回复行为产生的有价值事实。不要求与 bot 相关,也不要求长期稳定;当前批次中有价值即可记录。历史消息、认知记忆、侧写和最近消息参考只能用于实体/时间/地点消歧,禁止从其中摘取新事实写入 observations。存在【连续消息说明】或多段当前 时,必须覆盖整批消息内容,不能只记录最后一条。记录用户/群聊/第三方事实(偏好、计划、状态、关系、观点、人物事实、群聊事实)以及有价值的自身行为(帮谁解决了什么问题、给了什么建议)。每条一个要点;纯流水账动作(调了什么工具、决定不回复)写 memo 而非此处。格式:具体、绝对化,写明谁/何时/何地。涉及本项目或你自己时必须逐字写作 Undefined,禁止写成 Unfined、Undefind、undefind 或其它变体。" }, "perspective": { "type": "string", diff --git a/src/Undefined/skills/tools/end/handler.py b/src/Undefined/skills/tools/end/handler.py index 84e3a643..e15b8cfb 100644 --- a/src/Undefined/skills/tools/end/handler.py +++ b/src/Undefined/skills/tools/end/handler.py @@ -7,6 +7,7 @@ import re from Undefined.context import RequestContext +from Undefined.ai.prompts.current_input import drop_current_input_batch_if_duplicated from Undefined.utils.coerce import coerce_truthy, is_truthy, safe_int, was_message_sent from Undefined.utils.xml import format_message_xml @@ -33,6 +34,8 @@ _MAX_HISTORIAN_LINES = 50 _MIN_HISTORIAN_LINE_LEN = 16 _MAX_HISTORIAN_LINE_LEN = 1000 +_CANONICAL_PROJECT_NAME = "Undefined" +_PROJECT_NAME_MISSPELLINGS = ("Unfined", "Undefind", "undefind") def _parse_force_flag(value: Any) -> tuple[bool, bool]: @@ -54,6 +57,13 @@ def _clip_text(value: Any, max_len: int) -> str: return text[: max_len - 3].rstrip() + "..." +def _normalize_project_name_spelling(text: str) -> str: + normalized = str(text or "") + for misspelling in _PROJECT_NAME_MISSPELLINGS: + normalized = normalized.replace(misspelling, _CANONICAL_PROJECT_NAME) + return normalized + + def _clamp_int(value: int, min_value: int, max_value: int) -> int: if value < min_value: return min_value @@ -151,7 +161,7 @@ def _extract_current_input_batch_from_question(question: str, *, max_len: int) - def _build_historian_recent_messages( - context: Dict[str, Any], *, recent_k: int + context: Dict[str, Any], *, recent_k: int, current_question: str ) -> list[str]: """Build XML-formatted recent messages for historian context. @@ -188,6 +198,15 @@ def _build_historian_recent_messages( if not isinstance(recent, list): return [] + recent, dropped = drop_current_input_batch_if_duplicated( + recent, + current_question, + ) + if dropped: + logger.info( + "[end工具] 史官最近消息剔除当前输入批次重复消息: count=%s", + dropped, + ) lines: list[str] = [] for msg in recent: @@ -212,7 +231,11 @@ def _inject_historian_reference_context(context: Dict[str, Any]) -> None: current_question, max_source_len ) - recent_lines = _build_historian_recent_messages(context, recent_k=recent_k) + recent_lines = _build_historian_recent_messages( + context, + recent_k=recent_k, + current_question=current_question, + ) if recent_lines: context["historian_recent_messages"] = recent_lines @@ -243,13 +266,23 @@ def _build_location(context: Dict[str, Any]) -> EndSummaryLocation | None: async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: memo_raw = args.get("memo", "") - memo = memo_raw.strip() if isinstance(memo_raw, str) else "" + memo = ( + _normalize_project_name_spelling(memo_raw.strip()) + if isinstance(memo_raw, str) + else "" + ) observations_raw = args.get("observations", []) if isinstance(observations_raw, str): - observations = [observations_raw.strip()] if observations_raw.strip() else [] + observations = ( + [_normalize_project_name_spelling(observations_raw.strip())] + if observations_raw.strip() + else [] + ) elif isinstance(observations_raw, list): observations = [ - str(item).strip() for item in observations_raw if str(item).strip() + _normalize_project_name_spelling(str(item).strip()) + for item in observations_raw + if str(item).strip() ] else: observations = [] diff --git a/src/Undefined/skills/toolsets/messages/context_utils.py b/src/Undefined/skills/toolsets/messages/context_utils.py new file mode 100644 index 00000000..34b49e31 --- /dev/null +++ b/src/Undefined/skills/toolsets/messages/context_utils.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def mark_message_sent(context: dict[str, Any]) -> None: + marker = context.get("mark_message_sent_this_turn") + if not callable(marker): + logger.warning("缺少 mark_message_sent_this_turn 上下文依赖") + return + marker(context) diff --git a/src/Undefined/skills/toolsets/messages/react_message_emoji/handler.py b/src/Undefined/skills/toolsets/messages/react_message_emoji/handler.py index 9157a41f..5d1bef0a 100644 --- a/src/Undefined/skills/toolsets/messages/react_message_emoji/handler.py +++ b/src/Undefined/skills/toolsets/messages/react_message_emoji/handler.py @@ -7,6 +7,7 @@ from Undefined.context import RequestContext from Undefined.utils.qq_emoji import resolve_emoji_id_by_alias, search_emoji_aliases +from Undefined.skills.toolsets.messages.context_utils import mark_message_sent logger = logging.getLogger(__name__) @@ -156,10 +157,7 @@ def _resolve_onebot_client(context: Dict[str, Any]) -> Any | None: def _mark_action_sent(context: Dict[str, Any]) -> None: - context["message_sent_this_turn"] = True - ctx = RequestContext.current() - if ctx is not None: - ctx.set_resource("message_sent_this_turn", True) + mark_message_sent(context) def _get_seen_ops(context: Dict[str, Any]) -> set[str]: diff --git a/src/Undefined/skills/toolsets/messages/send_message/handler.py b/src/Undefined/skills/toolsets/messages/send_message/handler.py index 709c2c92..507faefa 100644 --- a/src/Undefined/skills/toolsets/messages/send_message/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_message/handler.py @@ -8,6 +8,7 @@ ) from Undefined.utils.message_targets import TargetType, parse_positive_int from Undefined.utils.message_targets import resolve_message_target +from Undefined.skills.toolsets.messages.context_utils import mark_message_sent logger = logging.getLogger(__name__) @@ -168,7 +169,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: message, **send_kwargs, ) - context["message_sent_this_turn"] = True + mark_message_sent(context) await dispatch_pending_file_sends( rendered, sender=sender, @@ -192,7 +193,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if send_message_callback and _is_current_group_target(context, target_id): try: await send_message_callback(message, reply_to=reply_to_id) - context["message_sent_this_turn"] = True + mark_message_sent(context) return "消息已发送" except Exception as e: logger.exception( @@ -215,7 +216,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: await send_private_message_callback( target_id, message, reply_to=reply_to_id ) - context["message_sent_this_turn"] = True + mark_message_sent(context) return "消息已发送" except Exception as e: logger.exception( @@ -229,7 +230,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if send_message_callback and _is_current_private_target(context, target_id): try: await send_message_callback(message, reply_to=reply_to_id) - context["message_sent_this_turn"] = True + mark_message_sent(context) return "消息已发送" except Exception as e: logger.exception( diff --git a/src/Undefined/skills/toolsets/messages/send_poke/handler.py b/src/Undefined/skills/toolsets/messages/send_poke/handler.py index e26590d9..9100c236 100644 --- a/src/Undefined/skills/toolsets/messages/send_poke/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_poke/handler.py @@ -5,6 +5,7 @@ from typing import Any, Dict, Literal, cast from Undefined.context import RequestContext +from Undefined.skills.toolsets.messages.context_utils import mark_message_sent logger = logging.getLogger(__name__) @@ -210,10 +211,7 @@ def _resolve_onebot_client(context: Dict[str, Any]) -> Any | None: def _mark_action_sent(context: Dict[str, Any]) -> None: - context["message_sent_this_turn"] = True - ctx = RequestContext.current() - if ctx is not None: - ctx.set_resource("message_sent_this_turn", True) + mark_message_sent(context) def _resolve_bot_qq(context: Dict[str, Any]) -> int: diff --git a/src/Undefined/skills/toolsets/messages/send_private_message/handler.py b/src/Undefined/skills/toolsets/messages/send_private_message/handler.py index 74a8bbed..e224f2f8 100644 --- a/src/Undefined/skills/toolsets/messages/send_private_message/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_private_message/handler.py @@ -6,6 +6,7 @@ render_message_with_pic_placeholders, scope_from_context, ) +from Undefined.skills.toolsets.messages.context_utils import mark_message_sent logger = logging.getLogger(__name__) @@ -115,7 +116,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: message, **send_kwargs, ) - context["message_sent_this_turn"] = True + mark_message_sent(context) await dispatch_pending_file_sends( rendered, sender=sender, @@ -136,7 +137,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if send_private_message_callback: try: await send_private_message_callback(user_id, message, reply_to=reply_to_id) - context["message_sent_this_turn"] = True + mark_message_sent(context) return f"私聊消息已发送给用户 {user_id}" except Exception as e: logger.exception( diff --git a/src/Undefined/skills/toolsets/messages/send_text_file/handler.py b/src/Undefined/skills/toolsets/messages/send_text_file/handler.py index ee65cb40..7ba457a9 100644 --- a/src/Undefined/skills/toolsets/messages/send_text_file/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_text_file/handler.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Any, Dict, Literal, cast +from Undefined.utils.message_turn import mark_message_sent_this_turn + logger = logging.getLogger(__name__) TargetType = Literal["group", "private"] @@ -442,7 +444,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: file_size=file_size, ) - context["message_sent_this_turn"] = True + mark_message_sent_this_turn(context) logger.info( "[发送文本文件] 成功: request_id=%s target_type=%s target_id=%s file=%s size=%sB", request_id, diff --git a/src/Undefined/skills/toolsets/messages/send_url_file/handler.py b/src/Undefined/skills/toolsets/messages/send_url_file/handler.py index c57db73d..e05ef29d 100644 --- a/src/Undefined/skills/toolsets/messages/send_url_file/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_url_file/handler.py @@ -16,6 +16,7 @@ parse_content_length, probe_remote_file, ) +from Undefined.utils.message_turn import mark_message_sent_this_turn logger = logging.getLogger(__name__) @@ -495,7 +496,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: file_size=downloaded_size, ) - context["message_sent_this_turn"] = True + mark_message_sent_this_turn(context) logger.info( "[URL文件发送] 成功: request_id=%s target_type=%s target_id=%s file=%s size=%sB url=%s", request_id, diff --git a/src/Undefined/utils/history.py b/src/Undefined/utils/history.py index 76cebae2..7ee33882 100644 --- a/src/Undefined/utils/history.py +++ b/src/Undefined/utils/history.py @@ -428,6 +428,7 @@ async def add_private_message( user_name: str = "", message_id: int | None = None, attachments: list[dict[str, str]] | None = None, + webchat: dict[str, Any] | None = None, ) -> None: """异步保存私聊消息到历史记录""" await self._ensure_initialized() @@ -456,6 +457,8 @@ async def add_private_message( record["message_id"] = message_id if attachments: record["attachments"] = attachments + if isinstance(webchat, dict): + record["webchat"] = webchat self._private_message_history[user_id_str].append(record) @@ -514,6 +517,46 @@ def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: return [] return self._private_message_history[user_id_str][-count:] if count > 0 else [] + def get_private_page( + self, + user_id: int, + *, + limit: int, + before: int | None = None, + ) -> tuple[list[dict[str, Any]], bool, int | None, int]: + """按时间倒序游标分页读取私聊历史,返回结果保持正序。 + + ``before`` 是完整历史数组里的结束下标(exclusive)。不传时从最新 + 消息开始读取;返回的 ``next_before`` 可用于继续向更早历史翻页。 + """ + user_id_str = str(user_id) + history = self._private_message_history.get(user_id_str, []) + total = len(history) + if total == 0 or limit <= 0: + return [], False, None, total + + end = total if before is None else max(0, min(before, total)) + start = max(0, end - limit) + items = history[start:end] + has_more = start > 0 + next_before = start if has_more else None + return items, has_more, next_before, total + + async def clear_private_history(self, user_id: int) -> int: + """清空指定私聊会话的内存与落盘历史,返回清空前记录数。""" + await self._ensure_initialized() + + user_id_str = str(user_id) + path = self._get_private_history_path(user_id) + async with self._get_private_lock(user_id_str): + previous_count = len(self._private_message_history.get(user_id_str, [])) + self._private_message_history[user_id_str] = [] + self._queue_history_save([], path) + + # 等待空数组写入,避免正在运行的旧保存任务最终把旧历史恢复到文件。 + await self.flush_pending_saves() + return previous_count + async def modify_last_group_message( self, group_id: int, diff --git a/src/Undefined/utils/io.py b/src/Undefined/utils/io.py index 738a681a..8e77733d 100644 --- a/src/Undefined/utils/io.py +++ b/src/Undefined/utils/io.py @@ -4,6 +4,7 @@ import json import logging import os +import shutil import tempfile import time from pathlib import Path @@ -174,6 +175,16 @@ async def exists(file_path: Path | str) -> bool: return await asyncio.to_thread(Path(file_path).exists) +async def is_file(file_path: Path | str) -> bool: + """异步检查路径是否为普通文件。""" + return await asyncio.to_thread(Path(file_path).is_file) + + +async def is_dir(file_path: Path | str) -> bool: + """异步检查路径是否为目录。""" + return await asyncio.to_thread(Path(file_path).is_dir) + + async def delete_file(file_path: Path | str) -> bool: """异步删除指定文件 @@ -194,6 +205,19 @@ def sync_delete() -> bool: return await asyncio.to_thread(sync_delete) +async def delete_tree(dir_path: Path | str) -> bool: + """异步删除目录树,目录不存在则返回 False。""" + p = Path(dir_path) + + def sync_delete() -> bool: + if not p.exists(): + return False + shutil.rmtree(p) + return True + + return await asyncio.to_thread(sync_delete) + + def _write_text_sync(target: Path, content: str, use_lock: bool) -> None: target.parent.mkdir(parents=True, exist_ok=True) @@ -239,6 +263,33 @@ def _read_bytes_sync(target: Path, use_lock: bool) -> bytes: return target.read_bytes() +def _write_bytes_sync(target: Path, content: bytes, use_lock: bool) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + + def atomic_write() -> None: + tmp_path: Path | None = None + try: + fd, tmp_name = tempfile.mkstemp( + prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent) + ) + tmp_path = Path(tmp_name) + with os.fdopen(fd, "wb") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_name, target) + finally: + if tmp_path is not None and tmp_path.exists(): + tmp_path.unlink() + + if use_lock: + lock_path = target.with_name(f"{target.name}.lock") + with FileLock(lock_path, shared=False): + atomic_write() + else: + atomic_write() + + async def write_text( file_path: str | Path, content: str, use_lock: bool = True ) -> None: @@ -257,3 +308,11 @@ async def read_bytes(file_path: str | Path, use_lock: bool = False) -> bytes: """异步读取二进制文件""" target = Path(file_path) return await asyncio.to_thread(_read_bytes_sync, target, use_lock) + + +async def write_bytes( + file_path: str | Path, content: bytes, use_lock: bool = True +) -> None: + """原子写入二进制文件""" + target = Path(file_path) + await asyncio.to_thread(_write_bytes_sync, target, content, use_lock) diff --git a/src/Undefined/utils/message_turn.py b/src/Undefined/utils/message_turn.py new file mode 100644 index 00000000..b680722c --- /dev/null +++ b/src/Undefined/utils/message_turn.py @@ -0,0 +1,24 @@ +"""Helpers for tracking per-turn user-visible output.""" + +from __future__ import annotations + +from collections.abc import MutableMapping +from typing import Any + +from Undefined.context import RequestContext + + +def mark_message_sent_this_turn( + context: MutableMapping[str, Any] | None = None, +) -> None: + """Mark the current turn as having produced user-visible output. + + Tool runners may execute tools with copied context dictionaries. Writing + the flag to both the passed context and the active request context keeps + downstream tools such as ``end`` from missing a successful send. + """ + if context is not None: + context["message_sent_this_turn"] = True + request_context = RequestContext.current() + if request_context is not None: + request_context.set_resource("message_sent_this_turn", True) diff --git a/src/Undefined/utils/paths.py b/src/Undefined/utils/paths.py index 05254147..a077bed3 100644 --- a/src/Undefined/utils/paths.py +++ b/src/Undefined/utils/paths.py @@ -2,18 +2,22 @@ from pathlib import Path -PACKAGE_ROOT = Path(__file__).resolve().parent.parent - -DATA_DIR = Path("data") -CACHE_DIR = DATA_DIR / "cache" -RENDER_CACHE_DIR = CACHE_DIR / "render" -IMAGE_CACHE_DIR = CACHE_DIR / "images" -ATTACHMENT_CACHE_DIR = CACHE_DIR / "attachments" -DOWNLOAD_CACHE_DIR = CACHE_DIR / "downloads" -TEXT_FILE_CACHE_DIR = CACHE_DIR / "text_files" -URL_FILE_CACHE_DIR = CACHE_DIR / "url_files" -WEBUI_FILE_CACHE_DIR = CACHE_DIR / "webui_files" -ATTACHMENT_REGISTRY_FILE = DATA_DIR / "attachment_registry.json" +PACKAGE_ROOT: Path = Path(__file__).resolve().parent.parent + +DATA_DIR: Path = Path("data") +HISTORY_DIR: Path = DATA_DIR / "history" +CACHE_DIR: Path = DATA_DIR / "cache" +RENDER_CACHE_DIR: Path = CACHE_DIR / "render" +IMAGE_CACHE_DIR: Path = CACHE_DIR / "images" +ATTACHMENT_CACHE_DIR: Path = CACHE_DIR / "attachments" +DOWNLOAD_CACHE_DIR: Path = CACHE_DIR / "downloads" +TEXT_FILE_CACHE_DIR: Path = CACHE_DIR / "text_files" +URL_FILE_CACHE_DIR: Path = CACHE_DIR / "url_files" +WEBUI_FILE_CACHE_DIR: Path = CACHE_DIR / "webui_files" +ATTACHMENT_REGISTRY_FILE: Path = DATA_DIR / "attachment_registry.json" +WEBCHAT_DIR: Path = DATA_DIR / "webchat" +WEBCHAT_CONVERSATIONS_DIR: Path = WEBCHAT_DIR / "conversations" +WEBCHAT_MIGRATION_MARKER_FILE: Path = WEBCHAT_DIR / "legacy_private_42_migrated.json" def ensure_dir(path: Path) -> Path: @@ -23,24 +27,24 @@ def ensure_dir(path: Path) -> Path: # Cognitive Memory -COGNITIVE_DIR = DATA_DIR / "cognitive" -COGNITIVE_CHROMADB_DIR = COGNITIVE_DIR / "chromadb" -COGNITIVE_PROFILES_DIR = COGNITIVE_DIR / "profiles" -COGNITIVE_PROFILES_USERS_DIR = COGNITIVE_PROFILES_DIR / "users" -COGNITIVE_PROFILES_GROUPS_DIR = COGNITIVE_PROFILES_DIR / "groups" -COGNITIVE_PROFILES_HISTORY_DIR = COGNITIVE_PROFILES_DIR / "history" -COGNITIVE_QUEUES_DIR = COGNITIVE_DIR / "queues" -COGNITIVE_QUEUES_PENDING_DIR = COGNITIVE_QUEUES_DIR / "pending" -COGNITIVE_QUEUES_PROCESSING_DIR = COGNITIVE_QUEUES_DIR / "processing" -COGNITIVE_QUEUES_FAILED_DIR = COGNITIVE_QUEUES_DIR / "failed" +COGNITIVE_DIR: Path = DATA_DIR / "cognitive" +COGNITIVE_CHROMADB_DIR: Path = COGNITIVE_DIR / "chromadb" +COGNITIVE_PROFILES_DIR: Path = COGNITIVE_DIR / "profiles" +COGNITIVE_PROFILES_USERS_DIR: Path = COGNITIVE_PROFILES_DIR / "users" +COGNITIVE_PROFILES_GROUPS_DIR: Path = COGNITIVE_PROFILES_DIR / "groups" +COGNITIVE_PROFILES_HISTORY_DIR: Path = COGNITIVE_PROFILES_DIR / "history" +COGNITIVE_QUEUES_DIR: Path = COGNITIVE_DIR / "queues" +COGNITIVE_QUEUES_PENDING_DIR: Path = COGNITIVE_QUEUES_DIR / "pending" +COGNITIVE_QUEUES_PROCESSING_DIR: Path = COGNITIVE_QUEUES_DIR / "processing" +COGNITIVE_QUEUES_FAILED_DIR: Path = COGNITIVE_QUEUES_DIR / "failed" # Meme Library -MEMES_DIR = DATA_DIR / "memes" -MEMES_BLOBS_DIR = MEMES_DIR / "blobs" -MEMES_PREVIEWS_DIR = MEMES_DIR / "previews" -MEMES_DB_PATH = MEMES_DIR / "memes.sqlite3" -MEMES_CHROMADB_DIR = MEMES_DIR / "chromadb" -MEMES_QUEUES_DIR = MEMES_DIR / "queues" -MEMES_QUEUES_PENDING_DIR = MEMES_QUEUES_DIR / "pending" -MEMES_QUEUES_PROCESSING_DIR = MEMES_QUEUES_DIR / "processing" -MEMES_QUEUES_FAILED_DIR = MEMES_QUEUES_DIR / "failed" +MEMES_DIR: Path = DATA_DIR / "memes" +MEMES_BLOBS_DIR: Path = MEMES_DIR / "blobs" +MEMES_PREVIEWS_DIR: Path = MEMES_DIR / "previews" +MEMES_DB_PATH: Path = MEMES_DIR / "memes.sqlite3" +MEMES_CHROMADB_DIR: Path = MEMES_DIR / "chromadb" +MEMES_QUEUES_DIR: Path = MEMES_DIR / "queues" +MEMES_QUEUES_PENDING_DIR: Path = MEMES_QUEUES_DIR / "pending" +MEMES_QUEUES_PROCESSING_DIR: Path = MEMES_QUEUES_DIR / "processing" +MEMES_QUEUES_FAILED_DIR: Path = MEMES_QUEUES_DIR / "failed" diff --git a/src/Undefined/webui/app.py b/src/Undefined/webui/app.py index 7b7f61a4..53eecfbc 100644 --- a/src/Undefined/webui/app.py +++ b/src/Undefined/webui/app.py @@ -1,6 +1,7 @@ import asyncio import gzip as _gzip_mod import logging +import secrets from logging.handlers import RotatingFileHandler from pathlib import Path @@ -33,7 +34,7 @@ CSP_POLICY = ( "default-src 'self'; " - "script-src 'self'; " + "script-src 'self' 'nonce-{nonce}'; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "font-src 'self' https://fonts.gstatic.com data:; " "img-src 'self' data:; " @@ -42,6 +43,11 @@ "frame-ancestors 'none'" ) + +def _build_csp_policy(nonce: str) -> str: + return CSP_POLICY.format(nonce=nonce) + + # ── gzip 压缩 ── _GZIP_CONTENT_TYPES = frozenset( @@ -180,11 +186,13 @@ async def security_headers_middleware( request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]], ) -> web.StreamResponse: + csp_nonce = secrets.token_urlsafe(16) + request["csp_nonce"] = csp_nonce try: response = await handler(request) except web.HTTPException as exc: response = exc - response.headers.setdefault("Content-Security-Policy", CSP_POLICY) + response.headers.setdefault("Content-Security-Policy", _build_csp_policy(csp_nonce)) response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("Referrer-Policy", "no-referrer") diff --git a/src/Undefined/webui/routes/_index.py b/src/Undefined/webui/routes/_index.py index c48a2b5b..3dd254b1 100644 --- a/src/Undefined/webui/routes/_index.py +++ b/src/Undefined/webui/routes/_index.py @@ -1,4 +1,5 @@ import json +from typing import Any, cast from aiohttp import web from aiohttp.web_response import Response @@ -12,6 +13,13 @@ ) +def _request_mapping_value(request: web.Request, key: str) -> Any: + getter = getattr(request, "get", None) + if callable(getter): + return getter(key) + return cast(Any, request).__dict__.get(key) + + @routes.get("/") async def index_handler(request: web.Request) -> Response: settings = get_settings(request) @@ -67,6 +75,12 @@ async def index_handler(request: web.Request) -> Response: initial_state_json = json.dumps(initial_state).replace(" str: @@ -121,6 +124,21 @@ def _resolve_chat_image_path(raw_path: str) -> Path | None: return path +def _sanitize_upload_display_name(raw_name: str) -> str: + name = Path(str(raw_name or "").strip() or "attachment").name or "attachment" + if len(name) > _WEBUI_FILE_UPLOAD_NAME_MAX_LENGTH: + suffix = "".join(Path(name).suffixes[-2:]) or Path(name).suffix + suffix = suffix if len(suffix) <= 16 else "" + name = f"attachment{suffix}" + return name + + +def _random_upload_filename(display_name: str) -> str: + suffix = "".join(Path(display_name).suffixes[-2:]) or Path(display_name).suffix + suffix = suffix if len(suffix) <= 16 else "" + return f"file_{uuid.uuid4().hex[:16]}{suffix}" + + async def _proxy_runtime( *, method: str, @@ -173,6 +191,7 @@ async def _proxy_runtime_stream( method: str, path: str, payload: dict[str, Any] | None = None, + params: Mapping[str, str] | None = None, timeout_seconds: float | None = None, ) -> web.StreamResponse: cfg = get_config(strict=False) @@ -193,6 +212,7 @@ async def _proxy_runtime_stream( async with session.request( method=method, url=url, + params=params, json=payload, headers=headers, ) as upstream: @@ -379,6 +399,33 @@ async def runtime_cognitive_profile_handler(request: web.Request) -> Response: ) +@routes.get("/api/v1/management/runtime/commands") +@routes.get("/api/runtime/commands") +async def runtime_commands_list_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/commands", + params=request.query, + ) + + +@routes.get("/api/v1/management/runtime/commands/{command_name}") +@routes.get("/api/runtime/commands/{command_name}") +async def runtime_command_detail_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + command_name = _url_quote( + str(request.match_info.get("command_name", "")).strip(), safe="" + ) + return await _proxy_runtime( + method="GET", + path=f"/api/v1/commands/{command_name}", + params=request.query, + ) + + @routes.post("/api/v1/management/runtime/chat") @routes.post("/api/runtime/chat") async def runtime_chat_handler(request: web.Request) -> web.StreamResponse: @@ -392,9 +439,12 @@ async def runtime_chat_handler(request: web.Request) -> web.StreamResponse: message = str(body.get("message", "") or "").strip() if not message: return web.json_response({"error": "message is required"}, status=400) + conversation_id = str(body.get("conversation_id", "") or "").strip() stream = _to_bool(body.get("stream")) payload: dict[str, Any] = {"message": message} + if conversation_id: + payload["conversation_id"] = conversation_id if stream: payload["stream"] = True return await _proxy_runtime_stream( @@ -413,6 +463,71 @@ async def runtime_chat_handler(request: web.Request) -> web.StreamResponse: ) +@routes.get("/api/v1/management/runtime/chat/conversations") +@routes.get("/api/runtime/chat/conversations") +async def runtime_chat_conversations_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/chat/conversations", + params=request.query, + ) + + +@routes.post("/api/v1/management/runtime/chat/conversations") +@routes.post("/api/runtime/chat/conversations") +async def runtime_chat_conversation_create_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + try: + body = await request.json() + except Exception: + body = {} + payload: dict[str, Any] = {} + title = str(body.get("title", "") or "").strip() + if title: + payload["title"] = title + return await _proxy_runtime( + method="POST", + path="/api/v1/chat/conversations", + payload=payload, + ) + + +@routes.patch("/api/v1/management/runtime/chat/conversations/{conversation_id}") +@routes.patch("/api/runtime/chat/conversations/{conversation_id}") +async def runtime_chat_conversation_update_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + conversation_id = _url_quote( + str(request.match_info.get("conversation_id", "")).strip(), safe="" + ) + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + return await _proxy_runtime( + method="PATCH", + path=f"/api/v1/chat/conversations/{conversation_id}", + payload={"title": str(body.get("title", "") or "").strip()}, + ) + + +@routes.delete("/api/v1/management/runtime/chat/conversations/{conversation_id}") +@routes.delete("/api/runtime/chat/conversations/{conversation_id}") +async def runtime_chat_conversation_delete_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + conversation_id = _url_quote( + str(request.match_info.get("conversation_id", "")).strip(), safe="" + ) + return await _proxy_runtime( + method="DELETE", + path=f"/api/v1/chat/conversations/{conversation_id}", + ) + + @routes.get("/api/v1/management/runtime/chat/history") @routes.get("/api/runtime/chat/history") async def runtime_chat_history_handler(request: web.Request) -> Response: @@ -425,6 +540,103 @@ async def runtime_chat_history_handler(request: web.Request) -> Response: ) +@routes.delete("/api/v1/management/runtime/chat/history") +@routes.delete("/api/runtime/chat/history") +async def runtime_chat_history_clear_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="DELETE", + path="/api/v1/chat/history", + params=request.query, + ) + + +@routes.post("/api/v1/management/runtime/chat/jobs") +@routes.post("/api/runtime/chat/jobs") +async def runtime_chat_job_create_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + message = str(body.get("message", "") or "").strip() + if not message: + return web.json_response({"error": "message is required"}, status=400) + payload: dict[str, Any] = {"message": message} + conversation_id = str(body.get("conversation_id", "") or "").strip() + if conversation_id: + payload["conversation_id"] = conversation_id + return await _proxy_runtime( + method="POST", + path="/api/v1/chat/jobs", + payload=payload, + timeout_seconds=20.0, + ) + + +@routes.get("/api/v1/management/runtime/chat/jobs/active") +@routes.get("/api/runtime/chat/jobs/active") +async def runtime_chat_job_active_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/chat/jobs/active", + params=request.query, + ) + + +@routes.get("/api/v1/management/runtime/chat/jobs/{job_id}") +@routes.get("/api/runtime/chat/jobs/{job_id}") +async def runtime_chat_job_detail_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + job_id = _url_quote(str(request.match_info.get("job_id", "")).strip(), safe="") + return await _proxy_runtime(method="GET", path=f"/api/v1/chat/jobs/{job_id}") + + +@routes.get("/api/v1/management/runtime/chat/jobs/{job_id}/events") +@routes.get("/api/runtime/chat/jobs/{job_id}/events") +async def runtime_chat_job_events_handler(request: web.Request) -> web.StreamResponse: + if not check_auth(request): + return _unauthorized() + job_id = _url_quote(str(request.match_info.get("job_id", "")).strip(), safe="") + wants_json = ( + str(request.query.get("format", "") or "").strip().lower() == "json" + or "application/json" + in str(request.headers.get("Accept", "") or "").strip().lower() + ) + if wants_json: + return await _proxy_runtime( + method="GET", + path=f"/api/v1/chat/jobs/{job_id}/events", + params=request.query, + timeout_seconds=20.0, + ) + return await _proxy_runtime_stream( + request, + method="GET", + path=f"/api/v1/chat/jobs/{job_id}/events", + params=request.query, + timeout_seconds=_chat_proxy_timeout_seconds(), + ) + + +@routes.post("/api/v1/management/runtime/chat/jobs/{job_id}/cancel") +@routes.post("/api/runtime/chat/jobs/{job_id}/cancel") +async def runtime_chat_job_cancel_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + job_id = _url_quote(str(request.match_info.get("job_id", "")).strip(), safe="") + return await _proxy_runtime( + method="POST", + path=f"/api/v1/chat/jobs/{job_id}/cancel", + timeout_seconds=20.0, + ) + + @routes.get("/api/v1/management/runtime/chat/image") @routes.get("/api/runtime/chat/image") async def runtime_chat_image_handler(request: web.Request) -> web.StreamResponse: @@ -480,6 +692,65 @@ async def runtime_chat_file_handler(request: web.Request) -> web.StreamResponse: ) +@routes.post("/api/v1/management/runtime/chat/files") +@routes.post("/api/runtime/chat/files") +async def runtime_chat_file_upload_handler(request: web.Request) -> Response: + """缓存 WebChat 待发送附件,发送时通过 CQ:file id 引用。""" + if not check_auth(request): + return _unauthorized() + + try: + reader = await request.multipart() + field = await reader.next() + except Exception: + return web.json_response({"error": "Invalid multipart body"}, status=400) + + field_any = cast(Any, field) + if field is None or getattr(field_any, "name", None) != "file": + return web.json_response({"error": "file field is required"}, status=400) + + raw_name = _sanitize_upload_display_name( + str(getattr(field_any, "filename", "") or "attachment") + ) + file_id = uuid.uuid4().hex + dest_dir = (Path.cwd() / WEBUI_FILE_CACHE_DIR / file_id).resolve() + cache_root = (Path.cwd() / WEBUI_FILE_CACHE_DIR).resolve() + if cache_root not in dest_dir.parents and dest_dir != cache_root: + return web.json_response({"error": "Invalid file path"}, status=400) + dest = dest_dir / _random_upload_filename(raw_name) + cfg = get_config(strict=False) + max_size_mb = max(1, int(getattr(cfg, "messages_send_url_file_max_size_mb", 100))) + max_size_bytes = max_size_mb * 1024 * 1024 + + size = 0 + chunks = bytearray() + try: + while True: + chunk = await field_any.read_chunk() + if not chunk: + break + size += len(chunk) + if size > max_size_bytes: + await async_io.delete_tree(dest_dir) + return web.json_response( + {"error": "file too large", "max_size": max_size_bytes}, + status=413, + ) + chunks.extend(chunk) + await async_io.write_bytes(dest, bytes(chunks), use_lock=False) + except Exception: + await async_io.delete_tree(dest_dir) + raise + + return web.json_response( + { + "id": file_id, + "name": raw_name, + "size": size, + } + ) + + # ------------------------------------------------------------------ # Tool Invoke API proxy # ------------------------------------------------------------------ diff --git a/src/Undefined/webui/static/css/app.css b/src/Undefined/webui/static/css/app.css index 083ebb39..3f51ddc9 100644 --- a/src/Undefined/webui/static/css/app.css +++ b/src/Undefined/webui/static/css/app.css @@ -158,25 +158,329 @@ body.is-mobile-drawer-open { max-width: 1400px; width: 100%; } -.main-content.chat-layout { max-width: none; } -.main-content.chat-layout #tab-chat .runtime-card { max-width: 100%; } +.main-content.chat-layout { + display: flex; + flex-direction: column; + height: 100dvh; + min-height: 0; + max-width: none; + overflow: hidden; + padding-bottom: max(16px, env(safe-area-inset-bottom)); +} +.main-content.chat-layout #appContent { + flex: 1 1 auto; + grid-template-rows: auto minmax(0, 1fr); + height: auto; + min-height: 0; +} +.main-content.chat-layout #appContent > .mobile-shell { + min-height: 0; +} +.main-content.chat-layout #tab-chat.active { + display: grid; + grid-row: 2; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + min-height: 0; + overflow: hidden; +} +.main-content.chat-layout #tab-chat > .header { + margin-bottom: 10px; +} +.runtime-chat-header { + align-items: center; + gap: 16px; +} +.runtime-chat-title { + min-width: 0; +} +.runtime-chat-page-title { + display: flex; + align-items: baseline; + gap: 12px; + min-width: 0; + margin-bottom: 0; +} +.runtime-chat-title-meta { + min-width: 0; + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 400; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.runtime-chat-shell { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + min-height: 0; + padding-right: 42px; + overflow: hidden; +} +.runtime-chat-sidebar { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 40; + width: min(340px, calc(100% - 44px)); + min-height: 0; + overflow: visible; + transform: translateX(calc(100% - 36px)); + transition: + transform 0.24s cubic-bezier(0.2, 0.8, 0.2, 1), + filter 0.24s ease; + will-change: transform; +} +.runtime-chat-sidebar:hover, +.runtime-chat-sidebar:focus-within, +.runtime-chat-sidebar.is-open { + transform: translateX(0); +} +.runtime-chat-sidebar-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + padding: 14px 12px 14px 46px; + border: 1px solid var(--border-color); + border-right: 0; + border-radius: 10px 0 0 10px; + background: color-mix(in srgb, var(--bg-card) 94%, var(--bg-app)); + box-shadow: -18px 0 40px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(12px); + overflow: hidden; +} +.runtime-chat-sidebar-head { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 30px; + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; +} +.runtime-chat-sidebar-tab { + position: absolute; + top: 18px; + left: 0; + z-index: 1; + display: grid; + place-items: center; + width: 36px; + min-height: 112px; + border: 1px solid var(--border-color); + border-left: 0; + border-radius: 0 8px 8px 0; + background: color-mix(in srgb, var(--bg-card) 88%, var(--accent-subtle)); + color: var(--accent-color); + box-shadow: 8px 0 18px rgba(0, 0, 0, 0.08); + cursor: pointer; + font: inherit; + padding: 0; +} +.runtime-chat-sidebar-tab::after { + content: ""; + position: absolute; + right: 7px; + bottom: 10px; + width: 7px; + height: 7px; + border-top: 1px solid currentColor; + border-left: 1px solid currentColor; + opacity: 0.72; + transform: rotate(-45deg); + transition: transform 0.24s ease; +} +.runtime-chat-sidebar:hover .runtime-chat-sidebar-tab::after, +.runtime-chat-sidebar:focus-within .runtime-chat-sidebar-tab::after, +.runtime-chat-sidebar.is-open .runtime-chat-sidebar-tab::after { + transform: rotate(135deg); +} +.runtime-chat-sidebar-tab:focus-visible { + outline: 2px solid rgba(217, 119, 87, 0.65); + outline-offset: 2px; +} +.runtime-chat-sidebar-tab-label { + writing-mode: vertical-rl; + text-orientation: mixed; + font-size: 12px; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} +.runtime-chat-conversations { + display: flex; + flex-direction: column; + gap: 6px; + height: 100%; + overflow-y: auto; + padding: 0 4px 4px 0; + scrollbar-gutter: stable; +} +.runtime-chat-conversation { + display: grid; + grid-template-columns: minmax(0, 1fr) 28px 28px; + align-items: center; + gap: 2px; + border-radius: 8px; + color: var(--text-secondary); + border: 1px solid transparent; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + color 0.18s ease; +} +.runtime-chat-conversation.active { + background: var(--bg-card); + color: var(--text-primary); + border-color: var(--border-color); +} +.runtime-chat-conversation:hover { + background: color-mix(in srgb, var(--bg-card) 72%, transparent); + color: var(--text-primary); +} +.runtime-chat-conversation.is-new { + animation: runtime-chat-conversation-created 1.2s ease-out; +} +.runtime-chat-conversation.running .runtime-chat-conversation-title::after { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + margin-left: 6px; + border-radius: 50%; + background: var(--success); + vertical-align: middle; +} +.runtime-chat-conversation-main { + min-width: 0; + padding: 9px 8px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.runtime-chat-conversation-main:hover, +.runtime-chat-conversation-rename:hover, +.runtime-chat-conversation-delete:hover { + color: var(--text-primary); +} +.runtime-chat-conversation-title, +.runtime-chat-conversation-meta { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.runtime-chat-conversation-title { + font-size: 13px; + font-weight: 600; +} +.runtime-chat-conversation-meta { + margin-top: 2px; + color: var(--text-tertiary); + font-size: 11px; +} +.runtime-chat-conversation-rename, +.runtime-chat-conversation-delete { + width: 28px; + height: 28px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; +} +.runtime-chat-conversation-empty { + padding: 12px 8px; + color: var(--text-tertiary); + font-size: 13px; +} +.runtime-chat-conversation-head { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; + padding: 8px 18px; + border-bottom: 1px solid var(--border-color); +} +.runtime-chat-current-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + font-weight: 700; +} +.runtime-chat-current-meta { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-size: 12px; +} .main-content.chat-layout #tab-chat .chat-runtime-card { display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - height: clamp(520px, calc(100vh - 230px), 840px); + grid-template-rows: auto auto minmax(0, 1fr) auto; + height: auto; + min-height: 0; + max-width: 100%; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + overflow: hidden; } .main-content.chat-layout #tab-chat .runtime-chat-log { min-height: 0; max-height: none; + margin-bottom: 0; + padding: 18px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; } .main-content.chat-layout #tab-chat .runtime-chat-input { - height: 44px; - min-height: 44px; - max-height: 44px; + height: 54px; + min-height: 54px; + max-height: 120px; resize: none; - line-height: 22px; + line-height: 1.45; overflow-y: auto; } +.main-content.chat-layout #tab-chat .runtime-chat-input-row { + position: relative; + padding: 12px 18px 0; + background: var(--bg-app); +} +.main-content.chat-layout #tab-chat .runtime-chat-content { + font-size: 15.5px; +} + +@keyframes runtime-chat-conversation-created { + 0% { + background: color-mix(in srgb, var(--accent-subtle) 78%, var(--bg-card)); + border-color: rgba(217, 119, 87, 0.56); + } + 60% { + background: color-mix(in srgb, var(--accent-subtle) 38%, var(--bg-card)); + border-color: rgba(217, 119, 87, 0.34); + } + 100% { + background: transparent; + border-color: transparent; + } +} .header { margin-bottom: 32px; display: flex; justify-content: space-between; align-items: flex-end; } .toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } diff --git a/src/Undefined/webui/static/css/components.css b/src/Undefined/webui/static/css/components.css index aed5932b..e80ffad4 100644 --- a/src/Undefined/webui/static/css/components.css +++ b/src/Undefined/webui/static/css/components.css @@ -103,11 +103,21 @@ /* Toggle */ .toggle-wrapper { display: flex; align-items: center; gap: 8px; cursor: pointer; } -.toggle-input { display: none; } +.toggle-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} .toggle-track { width: 40px; height: 22px; background: var(--border-color); border-radius: 99px; position: relative; transition: 0.2s; } .toggle-handle { width: 18px; height: 18px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: 0.2s; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .toggle-input:checked + .toggle-track { background: var(--accent-color); } .toggle-input:checked + .toggle-track .toggle-handle { transform: translateX(18px); } +.toggle-input:focus-visible + .toggle-track { + outline: 2px solid color-mix(in srgb, var(--accent-color) 68%, transparent); + outline-offset: 3px; +} /* Utility */ .w-full { width: 100%; } @@ -575,23 +585,51 @@ color: var(--text-secondary); font-family: var(--font-mono); } +.runtime-chat-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} +.runtime-chat-auto-scroll-toggle { + min-height: 30px; + padding: 4px 8px 4px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 80%, var(--bg-app)); + color: var(--text-secondary); + font-size: 12px; +} .runtime-chat-log { border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-app); + min-width: 0; min-height: 220px; max-height: 420px; + overflow-x: hidden; overflow-y: auto; padding: 12px; margin-bottom: 12px; display: grid; + align-content: start; gap: 10px; } +.runtime-chat-load-more { + min-height: 20px; + color: var(--text-tertiary); + font-size: 12px; + text-align: center; +} .runtime-chat-item { + min-width: 0; + max-width: 100%; border-radius: var(--radius-sm); border: 1px solid var(--border-color); - padding: 10px 12px; + padding: 12px 14px; background: var(--bg-card); + animation: runtime-chat-enter 0.18s ease-out; } .runtime-chat-item.user { border-color: rgba(217, 119, 87, 0.35); @@ -599,22 +637,118 @@ .runtime-chat-item.bot { border-color: var(--border-color); } +.runtime-chat-item.streaming .runtime-chat-content::after { + content: ""; + display: inline-block; + width: 7px; + height: 1.1em; + margin-left: 4px; + vertical-align: -0.15em; + background: var(--accent); + animation: runtime-chat-cursor 0.9s steps(2, start) infinite; +} .runtime-chat-role { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--text-tertiary); margin-bottom: 6px; + display: flex; + align-items: center; + gap: 8px; + min-height: 18px; + flex-wrap: wrap; +} +.runtime-chat-quote-btn { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 1px 7px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-tertiary); + font-size: 11px; + line-height: 1; + letter-spacing: 0; + text-transform: none; + cursor: pointer; + opacity: 0; + transform: translateY(-1px); + transition: + opacity 0.16s ease, + color 0.16s ease, + background 0.16s ease, + border-color 0.16s ease; +} +.runtime-chat-item:hover .runtime-chat-quote-btn, +.runtime-chat-quote-btn:focus-visible { + opacity: 1; +} +.runtime-chat-quote-btn:hover, +.runtime-chat-quote-btn:focus-visible { + border-color: color-mix(in srgb, var(--accent) 28%, var(--border-color)); + background: color-mix(in srgb, var(--accent) 9%, transparent); + color: var(--accent-color); +} +.runtime-chat-stage { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: min(260px, 100%); + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font-size: 11px; + line-height: 1.25; + letter-spacing: 0; + text-transform: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.runtime-chat-stage[hidden] { + display: none; +} +.runtime-chat-stage.is-final { + background: color-mix(in srgb, var(--success) 12%, transparent); + color: var(--success); +} +.runtime-chat-stage::before { + content: ""; + width: 5px; + height: 5px; + flex: 0 0 auto; + border-radius: 999px; + background: currentColor; + opacity: 0.85; } .runtime-chat-content { - font-size: 14px; + min-width: 0; + max-width: 100%; + font-size: 15px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; + overflow-wrap: anywhere; +} +.runtime-chat-timeline { + display: grid; + gap: 10px; + min-width: 0; + max-width: 100%; +} +.runtime-chat-item.tool-only .runtime-chat-content { + display: none; } .runtime-chat-content.markdown { white-space: normal; } +.runtime-chat-content.markdown > * { + min-width: 0; + max-width: 100%; +} .runtime-chat-content.markdown > *:first-child { margin-top: 0; } .runtime-chat-content.markdown > *:last-child { margin-bottom: 0; } .runtime-chat-content.markdown p { @@ -642,7 +776,69 @@ border-left: 3px solid var(--border-color); color: var(--text-secondary); } +.runtime-quote-block { + min-width: 0; + max-width: 100%; + margin: 0.5em 0; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-app) 86%, var(--bg-card)); + overflow: hidden; +} +.runtime-quote-block summary { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 7px; + min-height: 32px; + padding: 4px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + color: var(--text-secondary); + font-size: 12px; + font-weight: 650; + cursor: pointer; + list-style: none; + background: color-mix(in srgb, var(--bg-card) 62%, transparent); +} +.runtime-quote-block summary::-webkit-details-marker { + display: none; +} +.runtime-quote-block summary::before { + content: ""; + width: 6px; + height: 6px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.16s ease; + opacity: 0.72; +} +.runtime-quote-block[open] summary::before { + transform: rotate(45deg); +} +.runtime-quote-body { + max-height: min(28vh, 220px); + padding: 8px 10px; + overflow: auto; + scrollbar-gutter: stable; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.runtime-quote-body > *:first-child { + margin-top: 0; +} +.runtime-quote-body > *:last-child { + margin-bottom: 0; +} .runtime-chat-content.markdown table { + width: 100%; + max-width: 100%; + table-layout: fixed; border-collapse: collapse; margin: 0.5em 0; font-size: 13px; @@ -651,6 +847,8 @@ .runtime-chat-content.markdown td { border: 1px solid var(--border-color); padding: 4px 8px; + overflow-wrap: anywhere; + word-break: break-word; } .runtime-chat-content.markdown th { background: var(--bg-app); @@ -660,32 +858,238 @@ color: var(--accent); text-decoration: underline; } -.runtime-chat-content.markdown pre { +.runtime-code-block { + min-width: 0; + max-width: 100%; margin: 0.5em 0; - padding: 10px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-color); - background: var(--bg-app); - overflow-x: auto; + background: color-mix(in srgb, var(--bg-app) 88%, var(--bg-deep)); + overflow: hidden; +} +.runtime-code-toolbar { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 34px; + padding: 5px 8px 5px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + background: color-mix(in srgb, var(--bg-card) 58%, transparent); +} +.runtime-code-language { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; +} +.runtime-code-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.runtime-code-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: + background 0.16s ease, + border-color 0.16s ease, + color 0.16s ease, + transform 0.16s ease; +} +.runtime-code-action:hover, +.runtime-code-action:focus-visible { + border-color: color-mix(in srgb, var(--accent) 28%, var(--border-color)); + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--text-primary); +} +.runtime-code-action.primary { + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); +} +.runtime-code-action.primary:hover, +.runtime-code-action.primary:focus-visible { + background: var(--accent); + color: #fff; +} +.runtime-code-block.is-collapsed .runtime-code-body { + height: 9.2em; + overflow: auto; + position: relative; + scrollbar-gutter: stable; +} +.runtime-code-block.is-collapsed .runtime-code-body::after { + display: none; +} +.runtime-chat-content.markdown pre { + max-width: 100%; + margin: 0; + padding: 10px 12px; + border: 0; + border-radius: 0; + background: transparent; + overflow-x: hidden; + white-space: pre-wrap; + overflow-wrap: anywhere; } .runtime-chat-content.markdown pre code { display: block; + min-width: 0; + max-width: 100%; padding: 0; border: none; background: none; border-radius: 0; font-size: 12.5px; line-height: 1.5; - white-space: pre; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} +.runtime-chat-content.markdown pre code.hljs, +.runtime-chat-content.markdown code.hljs { + padding: 0; + background: transparent; + color: var(--text-primary); +} +.runtime-code-block .hljs-keyword, +.runtime-code-block .hljs-doctag, +.runtime-code-block .hljs-template-tag, +.runtime-code-block .hljs-type { + color: #9b3fb5; + font-weight: 600; +} +.runtime-code-block .hljs-string, +.runtime-code-block .hljs-regexp, +.runtime-code-block .hljs-meta .hljs-string { + color: #246f4f; +} +.runtime-code-block .hljs-comment, +.runtime-code-block .hljs-quote { + color: var(--text-tertiary); + font-style: italic; +} +.runtime-code-block .hljs-number, +.runtime-code-block .hljs-literal, +.runtime-code-block .hljs-variable, +.runtime-code-block .hljs-attribute, +.runtime-code-block .hljs-symbol { + color: #b45f2b; +} +.runtime-code-block .hljs-title, +.runtime-code-block .hljs-title.function_, +.runtime-code-block .hljs-title.class_, +.runtime-code-block .hljs-section { + color: #1f6fb2; + font-weight: 600; +} +.runtime-code-block .hljs-operator, +.runtime-code-block .hljs-punctuation, +.runtime-code-block .hljs-meta { + color: #8a6350; +} +.runtime-code-block .hljs-property, +.runtime-code-block .hljs-attr, +.runtime-code-block .hljs-selector-attr, +.runtime-code-block .hljs-selector-class, +.runtime-code-block .hljs-selector-id { + color: #b25577; +} +.runtime-code-block .hljs-name, +.runtime-code-block .hljs-selector-tag, +.runtime-code-block .hljs-built_in { + color: #22735a; +} +.runtime-code-block .hljs-addition { + color: #1d7f45; + background: color-mix(in srgb, #1d7f45 12%, transparent); +} +.runtime-code-block .hljs-deletion { + color: #b31d28; + background: color-mix(in srgb, #b31d28 10%, transparent); +} +[data-theme="dark"] .runtime-code-block .hljs-keyword, +[data-theme="dark"] .runtime-code-block .hljs-doctag, +[data-theme="dark"] .runtime-code-block .hljs-template-tag, +[data-theme="dark"] .runtime-code-block .hljs-type { + color: #c792ea; +} +[data-theme="dark"] .runtime-code-block .hljs-string, +[data-theme="dark"] .runtime-code-block .hljs-regexp, +[data-theme="dark"] .runtime-code-block .hljs-meta .hljs-string { + color: #89d39a; +} +[data-theme="dark"] .runtime-code-block .hljs-comment, +[data-theme="dark"] .runtime-code-block .hljs-quote { + color: #7f8a91; +} +[data-theme="dark"] .runtime-code-block .hljs-number, +[data-theme="dark"] .runtime-code-block .hljs-literal, +[data-theme="dark"] .runtime-code-block .hljs-variable, +[data-theme="dark"] .runtime-code-block .hljs-attribute, +[data-theme="dark"] .runtime-code-block .hljs-symbol { + color: #f2b86d; +} +[data-theme="dark"] .runtime-code-block .hljs-title, +[data-theme="dark"] .runtime-code-block .hljs-title.function_, +[data-theme="dark"] .runtime-code-block .hljs-title.class_, +[data-theme="dark"] .runtime-code-block .hljs-section { + color: #82b8ff; +} +[data-theme="dark"] .runtime-code-block .hljs-operator, +[data-theme="dark"] .runtime-code-block .hljs-punctuation, +[data-theme="dark"] .runtime-code-block .hljs-meta { + color: #c6a58d; +} +[data-theme="dark"] .runtime-code-block .hljs-property, +[data-theme="dark"] .runtime-code-block .hljs-attr, +[data-theme="dark"] .runtime-code-block .hljs-selector-attr, +[data-theme="dark"] .runtime-code-block .hljs-selector-class, +[data-theme="dark"] .runtime-code-block .hljs-selector-id { + color: #f08bad; +} +[data-theme="dark"] .runtime-code-block .hljs-name, +[data-theme="dark"] .runtime-code-block .hljs-selector-tag, +[data-theme="dark"] .runtime-code-block .hljs-built_in { + color: #7fd6b4; +} +[data-theme="dark"] .runtime-code-block .hljs-addition { + color: #89d39a; + background: color-mix(in srgb, #89d39a 13%, transparent); +} +[data-theme="dark"] .runtime-code-block .hljs-deletion { + color: #ff8d8d; + background: color-mix(in srgb, #ff8d8d 12%, transparent); } .runtime-chat-content code { display: inline-block; + max-width: 100%; padding: 2px 6px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-app); font-family: var(--font-mono); font-size: 12px; + white-space: normal; + overflow-wrap: anywhere; } .runtime-chat-image { display: block; @@ -693,6 +1097,16 @@ border-radius: var(--radius-sm); border: 1px solid var(--border-color); margin: 6px 0; + cursor: zoom-in; + transition: + border-color 0.16s ease, + box-shadow 0.16s ease, + transform 0.16s ease; +} +.runtime-chat-image:hover { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border-color)); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16); + transform: translateY(-1px); } .runtime-chat-file-card { display: flex; @@ -743,40 +1157,1124 @@ color: #fff; } .runtime-chat-input-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; -} -.runtime-chat-actions { + --chat-attachment-rail-width: 0px; + --chat-attachment-card-width: 132px; + --chat-attachment-gap: 8px; display: flex; align-items: center; - justify-content: flex-end; - white-space: nowrap; - gap: 8px; + gap: 0; + min-width: 0; + position: relative; } -.runtime-chat-action-btn { - width: 44px; - height: 44px; - min-width: 44px; - padding: 0; - border-radius: 999px; - display: inline-flex; +.runtime-chat-command-palette { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 10px); + z-index: 180; + display: grid; + gap: 4px; + max-height: min(360px, 46vh); + padding: 8px; + border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--border-color)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 96%, var(--bg-app)); + box-shadow: 0 16px 42px rgba(15, 23, 42, 0.2); + overflow-y: auto; + opacity: 0; + transform: translateY(8px) scale(0.99); + transform-origin: bottom center; + pointer-events: none; + transition: + opacity 0.16s ease, + transform 0.18s ease; +} +.runtime-chat-command-palette.is-open { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; +} +.runtime-chat-command-palette[hidden] { + display: none; +} +.runtime-chat-command-head { + padding: 2px 6px 6px; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 650; +} +.runtime-chat-command-empty { + padding: 12px; + color: var(--text-tertiary); + font-size: 13px; +} +.runtime-chat-command-item { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(160px, 36%); align-items: center; - justify-content: center; + gap: 12px; + width: 100%; + min-height: 58px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition: + border-color 0.14s ease, + background 0.14s ease, + transform 0.14s ease; } -.runtime-chat-action-icon { - font-size: 20px; - line-height: 1; +.runtime-chat-command-item:hover, +.runtime-chat-command-item.active { + border-color: color-mix(in srgb, var(--accent) 26%, var(--border-color)); + background: color-mix(in srgb, var(--accent) 8%, transparent); +} +.runtime-chat-command-item.active { + transform: translateX(2px); +} +.runtime-chat-command-main, +.runtime-chat-command-side { + display: grid; + min-width: 0; + gap: 3px; +} +.runtime-chat-command-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent-color); + font-family: var(--font-mono); + font-size: 13px; font-weight: 700; } -.runtime-chat-action-btn-send .runtime-chat-action-icon { - transform: translateY(-1px); +.runtime-chat-command-desc { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + font-size: 12px; } -.runtime-chat-input { - min-height: 88px; - resize: vertical; - line-height: 1.5; +.runtime-chat-command-side { + justify-items: end; + color: var(--text-tertiary); + font-size: 11px; +} +.runtime-chat-command-side code { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 2px 6px; + border-radius: calc(var(--radius-sm) - 1px); + background: color-mix(in srgb, var(--bg-app) 78%, transparent); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; +} +.runtime-chat-command-help { + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border-color)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 6%, var(--bg-card)); + color: var(--text-primary); +} +.runtime-chat-command-help-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} +.runtime-chat-command-help-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent-color); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 700; +} +.runtime-chat-command-help-kicker { + flex: 0 0 auto; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 650; +} +.runtime-chat-command-help-desc { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} +.runtime-chat-command-help-grid { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 5px 10px; + color: var(--text-secondary); + font-size: 12px; +} +.runtime-chat-command-help-grid > span:nth-child(odd) { + color: var(--text-tertiary); +} +.runtime-chat-command-help-grid code { + max-width: 100%; + overflow-wrap: anywhere; + padding: 2px 6px; + border-radius: calc(var(--radius-sm) - 1px); + background: color-mix(in srgb, var(--bg-app) 78%, transparent); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 11px; +} +.runtime-chat-command-help-note { + color: var(--text-tertiary); + font-size: 11px; + line-height: 1.45; +} +.runtime-chat-references { + flex: 0 0 min(260px, 28%); + display: flex; + align-self: stretch; + gap: 6px; + min-width: 0; + max-width: min(260px, 28%); + height: 54px; + margin-right: var(--chat-attachment-gap); + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior: contain; + scrollbar-width: none; + opacity: 1; + transform: translateX(0); + transition: + flex-basis 0.2s ease, + max-width 0.2s ease, + margin-right 0.2s ease, + opacity 0.16s ease, + transform 0.18s ease; +} +.runtime-chat-references::-webkit-scrollbar { + display: none; +} +.runtime-chat-references[hidden] { + display: flex; + flex-basis: 0; + max-width: 0; + margin-right: 0; + opacity: 0; + pointer-events: none; + transform: translateX(8px); + visibility: hidden; +} +.runtime-chat-reference { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 24px; + align-items: center; + gap: 6px; + flex: 0 0 min(220px, 100%); + min-width: 160px; + padding: 5px 6px; + border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border-color)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); + color: var(--text-primary); + overflow: hidden; + animation: runtime-chat-attachment-in 0.2s ease both; +} +.runtime-chat-reference-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 34px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent-color); + font-family: var(--font-serif); + font-size: 22px; + line-height: 1; +} +.runtime-chat-reference-main { + display: grid; + gap: 1px; + min-width: 0; +} +.runtime-chat-reference-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent-color); + font-size: 11px; + font-weight: 650; +} +.runtime-chat-reference-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + font-size: 11px; +} +.runtime-chat-reference-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: 15px; + font-weight: 700; + transition: + background 0.16s ease, + color 0.16s ease, + transform 0.16s ease; +} +.runtime-chat-reference-remove:hover, +.runtime-chat-reference-remove:focus-visible { + background: color-mix(in srgb, var(--error) 12%, transparent); + color: var(--error); + transform: scale(1.04); +} +.runtime-chat-input-row > .runtime-chat-input { + flex: 1 1 auto; + min-width: min(100%, 260px); + margin-right: var(--chat-attachment-gap); + transition: + flex 0.24s ease, + flex-basis 0.22s ease, + width 0.22s ease, + margin-right 0.22s ease; +} +.runtime-chat-attachments { + flex: 0 0 var(--chat-attachment-rail-width); + display: flex; + align-self: stretch; + flex-direction: row; + gap: 6px; + width: var(--chat-attachment-rail-width); + height: 54px; + min-width: 0; + max-width: var(--chat-attachment-rail-width); + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior: contain; + opacity: 1; + scrollbar-width: none; + transform: translateX(0); + transition: + flex-basis 0.22s ease, + width 0.22s ease, + max-width 0.22s ease, + margin-right 0.22s ease, + opacity 0.18s ease, + transform 0.22s ease; +} +.runtime-chat-attachments::-webkit-scrollbar { + display: none; +} +.runtime-chat-input-row.has-attachments .runtime-chat-attachments { + margin-right: var(--chat-attachment-gap); +} +.runtime-chat-attachments[hidden] { + display: flex; + flex-basis: 0; + width: 0; + max-width: 0; + margin-right: 0; + opacity: 0; + pointer-events: none; + transform: translateX(8px); + visibility: hidden; +} +.runtime-chat-attachment { + display: inline-grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + flex: 0 0 var(--chat-attachment-card-width); + min-width: 42px; + max-width: var(--chat-attachment-card-width); + position: relative; + min-height: 44px; + padding: 5px 7px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 72%, var(--bg-app)); + color: var(--text-primary); + overflow: hidden; + animation: runtime-chat-attachment-in 0.2s ease both; + transition: + flex-basis 0.2s ease, + grid-template-columns 0.18s ease, + max-width 0.2s ease, + min-width 0.2s ease, + padding 0.18s ease, + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} +.runtime-chat-attachment:hover { + border-color: color-mix(in srgb, var(--accent) 32%, var(--border-color)); + background: color-mix(in srgb, var(--bg-card) 84%, var(--accent-subtle)); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); +} +.runtime-chat-input-row.has-attachments .runtime-chat-attachment { + min-width: 0; +} +.runtime-chat-input-row.is-attachment-rail-full .runtime-chat-attachment { + grid-template-columns: 30px minmax(0, 1fr) auto; + padding-inline: 6px; +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachments { + gap: 4px; +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment { + min-width: 32px; + grid-template-columns: minmax(24px, 1fr); + justify-items: center; + gap: 0; + padding: 3px; +} +.runtime-chat-attachment-preview { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + overflow: hidden; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); + transition: + width 0.18s ease, + height 0.18s ease, + border-radius 0.18s ease; +} +.runtime-chat-attachment-thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.runtime-chat-attachment-preview.is-missing-thumb::before { + content: "IMG"; + font-size: 10px; + font-weight: 700; + letter-spacing: 0; +} +.runtime-chat-attachment-file { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + letter-spacing: 0; +} +.runtime-chat-attachment-main { + display: grid; + min-width: 0; + gap: 1px; + transition: + opacity 0.18s ease, + max-width 0.18s ease; +} +.runtime-chat-attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; +} +.runtime-chat-attachment-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-size: 11px; +} +.runtime-chat-attachment-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + transition: + background 0.16s ease, + color 0.16s ease, + transform 0.16s ease; +} +.runtime-chat-attachment-remove:hover { + background: color-mix(in srgb, var(--error) 12%, transparent); + color: var(--error); + transform: scale(1.04); +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-preview { + width: 100%; + height: 38px; + border-radius: calc(var(--radius-sm) - 1px); +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-main { + width: 0; + max-width: 0; + opacity: 0; + overflow: hidden; +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-remove { + position: absolute; + top: 3px; + right: 3px; + width: 22px; + height: 22px; + border: 1px solid color-mix(in srgb, var(--bg-card) 88%, var(--border-color)); + font-size: 15px; + font-weight: 700; + background: color-mix(in srgb, var(--bg-card) 92%, transparent); + color: var(--text-primary); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.22); + opacity: 0.92; +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-remove:hover, +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-remove:focus-visible { + background: color-mix(in srgb, var(--error) 16%, var(--bg-card)); + color: var(--error); + opacity: 1; +} +@keyframes runtime-chat-attachment-in { + from { + opacity: 0; + transform: translateX(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} +.runtime-chat-actions { + display: flex; + align-items: center; + justify-content: flex-end; + white-space: nowrap; + gap: 8px; +} +.runtime-chat-action-btn { + width: 44px; + height: 44px; + min-width: 44px; + padding: 0; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.runtime-chat-action-icon { + font-size: 20px; + line-height: 1; + font-weight: 700; +} +.runtime-chat-action-btn-send .runtime-chat-action-icon { + transform: translateY(-1px); +} +.runtime-chat-input { + min-height: 88px; + resize: vertical; + line-height: 1.5; +} +.runtime-chat-selection-quote { + position: fixed; + z-index: 220; + transform: translateX(-50%); + min-height: 28px; + padding: 4px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border-color)); + border-radius: 999px; + background: var(--bg-card); + color: var(--accent-color); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.18); + font-size: 12px; + font-weight: 600; + cursor: pointer; + animation: runtime-chat-selection-quote-in 0.14s ease-out; +} +.runtime-chat-selection-quote[hidden] { + display: none; +} +.runtime-chat-tools { + display: grid; + gap: 8px; +} +.runtime-tool-block { + --tool-accent: var(--accent-color); + min-width: 0; + max-width: 100%; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-app) 92%, var(--bg-card)); + overflow: hidden; + position: relative; + transition: + border-color 0.18s ease, + background 0.18s ease, + transform 0.18s ease, + box-shadow 0.18s ease; +} +.runtime-tool-block::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--tool-accent); + opacity: 0.65; +} +.runtime-tool-block.is-agent { + --tool-accent: var(--accent-color); + background: color-mix(in srgb, var(--bg-card) 60%, var(--bg-app)); +} +.runtime-tool-block.is-tool { + --tool-accent: color-mix(in srgb, var(--success) 76%, var(--accent-color)); +} +.runtime-tool-block.running { + --tool-accent: color-mix(in srgb, var(--warning) 82%, var(--accent-color)); +} +.runtime-tool-block.done { + --tool-accent: var(--success); +} +.runtime-tool-block.error { + --tool-accent: var(--error); +} +.runtime-tool-block.cancelled { + --tool-accent: var(--warning); +} +.runtime-tool-block:hover { + border-color: color-mix(in srgb, var(--tool-accent) 38%, var(--border-color)); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); +} +.runtime-tool-block summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + cursor: pointer; + min-height: 32px; + padding: 3px 10px 3px 13px; + font-size: 12px; + line-height: 1.2; + color: var(--text-secondary); + list-style: none; +} +.runtime-tool-block summary::-webkit-details-marker { + display: none; +} +.runtime-tool-block summary::before { + content: ""; + width: 6px; + height: 6px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.18s ease; + opacity: 0.7; +} +.runtime-tool-block[open] summary::before { + transform: rotate(45deg); +} +.runtime-tool-block summary .runtime-tool-summary-main { + min-width: 0; + overflow: hidden; +} +.runtime-tool-block summary .runtime-tool-title { + display: inline-flex; + align-items: baseline; + gap: 7px; + min-width: 0; + max-width: 100%; + vertical-align: middle; +} +.runtime-tool-block summary .runtime-tool-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border: 0; + background: transparent; + padding: 0; + color: var(--text-primary); + font-size: 12px; + font-weight: 650; +} +.runtime-tool-block summary .runtime-tool-duration { + flex: 0 0 auto; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--tool-accent) 10%, transparent); + color: color-mix(in srgb, var(--tool-accent) 84%, var(--text-primary)); + font-family: var(--font-mono); + font-size: 10.5px; + line-height: 1.4; + white-space: nowrap; +} +.runtime-tool-block summary .runtime-tool-duration[hidden] { + display: none; +} +.runtime-tool-block summary .runtime-tool-status { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-style: normal; + white-space: nowrap; + color: var(--text-tertiary); +} +.runtime-tool-block summary .runtime-tool-kind { + min-width: 44px; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; + white-space: nowrap; + color: var(--text-secondary); +} +.runtime-tool-block.webchat-private-send, +.runtime-tool-block.webchat-end { + background: color-mix(in srgb, var(--bg-app) 72%, var(--bg-card)); +} +.runtime-tool-block.webchat-private-send summary, +.runtime-tool-block.webchat-end summary { + min-height: 32px; + padding-block: 3px; +} +.runtime-tool-block.running summary .runtime-tool-status { + color: var(--warning); +} +.runtime-tool-block.done summary .runtime-tool-status { + color: var(--success); +} +.runtime-tool-block.error summary .runtime-tool-status { + color: var(--error); +} +.runtime-tool-block.cancelled summary .runtime-tool-status { + color: var(--warning); +} +.runtime-tool-preview { + min-width: 0; + max-width: 100%; + border-top: 1px solid var(--border-color); + padding: 8px 10px 10px; + animation: runtime-tool-reveal 0.18s ease-out; +} +.runtime-tool-preview + .runtime-tool-preview { + border-top-style: dashed; +} +.runtime-tool-preview-label { + margin-bottom: 6px; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; +} +.runtime-tool-preview-body { + min-width: 0; + max-width: 100%; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: min(34vh, 260px); + overflow: auto; +} +.runtime-tool-preview-body > *:first-child { + margin-top: 0; +} +.runtime-tool-preview-body > *:last-child { + margin-bottom: 0; +} +.runtime-tool-preview-body.is-structured { + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-app)); + white-space: normal; +} +.runtime-tool-structured-list { + display: grid; + gap: 6px; + min-width: 0; +} +.runtime-tool-structured-list .runtime-tool-structured-list { + margin-top: 5px; + padding-left: 10px; + border-left: 1px solid var(--border-color); +} +.runtime-tool-structured-row { + display: grid; + grid-template-columns: minmax(64px, min(34%, 180px)) minmax(0, 1fr); + align-items: start; + gap: 8px; + min-width: 0; +} +.runtime-tool-key { + min-width: 0; + color: var(--accent-color); + font-family: var(--font-mono); + font-size: 11px; + overflow-wrap: anywhere; +} +.runtime-tool-value { + min-width: 0; + color: var(--text-secondary); + overflow-wrap: anywhere; +} +.runtime-tool-value.string { + color: var(--text-primary); +} +.runtime-tool-value.number { + color: var(--warning); + font-family: var(--font-mono); +} +.runtime-tool-value.boolean { + color: var(--success); + font-family: var(--font-mono); +} +.runtime-tool-value.muted { + color: var(--text-tertiary); + font-family: var(--font-mono); +} +.runtime-tool-preview-body .runtime-chat-image { + max-width: min(420px, 100%); +} +.runtime-tool-preview-body .runtime-chat-file-card { + max-width: min(340px, 100%); +} +.runtime-chat-image-viewer { + position: fixed; + inset: 0; + z-index: 260; + display: grid; + place-items: center; + padding: 28px; + background: color-mix(in srgb, #020617 78%, transparent); + backdrop-filter: blur(7px); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; +} +.runtime-chat-image-viewer.is-open { + opacity: 1; + pointer-events: auto; +} +.runtime-chat-image-viewer[hidden] { + display: none; +} +.runtime-chat-image-viewer-figure { + display: grid; + gap: 10px; + justify-items: center; + max-width: min(1120px, calc(100vw - 56px)); + max-height: calc(100dvh - 56px); + margin: 0; +} +.runtime-chat-image-viewer-image { + display: block; + max-width: min(1120px, calc(100vw - 56px)); + max-height: calc(100dvh - 104px); + object-fit: contain; + border-radius: var(--radius-sm); + background: #020617; + box-shadow: 0 24px 70px rgba(2, 6, 23, 0.46); + animation: runtime-chat-image-viewer-in 0.18s ease-out; +} +.runtime-chat-image-viewer-caption { + max-width: min(720px, calc(100vw - 56px)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: rgba(255, 255, 255, 0.78); + font-size: 12px; +} +.runtime-chat-image-viewer-close { + position: fixed; + top: max(16px, env(safe-area-inset-top)); + right: max(16px, env(safe-area-inset-right)); + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: var(--radius-sm); + background: rgba(15, 23, 42, 0.68); + color: #fff; + cursor: pointer; + font-size: 24px; + line-height: 1; + transition: + background 0.16s ease, + border-color 0.16s ease, + transform 0.16s ease; +} +.runtime-chat-image-viewer-close:hover, +.runtime-chat-image-viewer-close:focus-visible { + border-color: rgba(255, 255, 255, 0.4); + background: rgba(15, 23, 42, 0.9); + transform: translateY(-1px); +} +.runtime-tool-children { + display: grid; + gap: 8px; + margin-left: 10px; + padding: 8px 10px 10px 12px; + border-top: 1px dashed var(--border-color); + border-left: 1px solid color-mix(in srgb, var(--tool-accent) 30%, var(--border-color)); + background: color-mix(in srgb, var(--bg-app) 84%, var(--bg-card)); + animation: runtime-tool-reveal 0.18s ease-out; +} +.runtime-tool-children .runtime-tool-block { + background: var(--bg-card); +} +.runtime-tool-message { + min-width: 0; + max-width: 100%; + border: 1px solid color-mix(in srgb, var(--tool-accent) 18%, var(--border-color)); + border-left: 3px solid color-mix(in srgb, var(--tool-accent) 48%, var(--border-color)); + border-radius: var(--radius-sm); + padding: 8px 10px; + background: color-mix(in srgb, var(--bg-card) 72%, var(--bg-app)); + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.5; + animation: runtime-tool-reveal 0.18s ease-out; +} +.runtime-tool-message > *:first-child { + margin-top: 0; +} +.runtime-tool-message > *:last-child { + margin-bottom: 0; +} +.runtime-html-runner { + position: fixed; + z-index: 118; + width: min(760px, calc(100vw - 32px)); + height: 360px; + max-width: calc(100vw - 32px); + max-height: calc(100dvh - 32px - env(safe-area-inset-bottom)); + min-width: 360px; + min-height: 280px; + overflow: visible; + pointer-events: auto; +} +.runtime-html-runner[hidden] { + display: none; +} +.runtime-html-runner-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: 100%; + height: 100%; + min-height: 280px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + border-radius: var(--radius-md); + background: var(--bg-card); + box-shadow: 0 18px 56px rgba(15, 23, 42, 0.2); + position: relative; + animation: runtime-html-runner-in 0.2s ease-out; +} +.runtime-html-runner.is-resizing, +.runtime-html-runner.is-resizing * { + cursor: nwse-resize !important; + user-select: none; +} +.runtime-html-runner.is-resizing .runtime-html-runner-frame { + pointer-events: none; +} +.runtime-html-runner.is-dragging, +.runtime-html-runner.is-dragging * { + cursor: move !important; + user-select: none; +} +.runtime-html-runner.is-dragging .runtime-html-runner-frame { + pointer-events: none; +} +.runtime-html-runner-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + min-height: 44px; + padding: 8px 10px 8px 14px; + border-bottom: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-app) 64%, var(--bg-card)); + cursor: move; + touch-action: none; +} +.runtime-html-runner-actions, +.runtime-html-runner-actions * { + cursor: auto; +} +.runtime-html-runner-actions button { + cursor: pointer; +} +.runtime-html-runner-title { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--text-primary); + font-size: 13px; + font-weight: 650; +} +.runtime-html-runner-meta { + max-width: 28ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 999px; + padding: 2px 7px; + background: color-mix(in srgb, var(--accent) 11%, transparent); + color: var(--accent); + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 600; +} +.runtime-html-runner-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.runtime-html-runner-btn { + min-height: 28px; + padding: 4px 10px; + font-size: 12px; +} +.runtime-html-runner-btn.is-active { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border-color)); + background: var(--accent-subtle); + color: var(--accent-color); +} +.runtime-html-runner-frame { + width: 100%; + height: 100%; + min-height: 220px; + border: 0; + background: #fff; +} +.runtime-html-runner-resize { + position: absolute; + right: 5px; + bottom: 5px; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent); + border-radius: 6px; + background: + linear-gradient(135deg, transparent 45%, color-mix(in srgb, var(--text-tertiary) 65%, transparent) 46%, transparent 52%), + linear-gradient(135deg, transparent 62%, color-mix(in srgb, var(--text-tertiary) 65%, transparent) 63%, transparent 70%), + color-mix(in srgb, var(--bg-card) 88%, transparent); + box-shadow: 0 3px 10px rgba(15, 23, 42, 0.18); + cursor: nwse-resize; + opacity: 0.86; +} +.runtime-html-runner-resize:hover, +.runtime-html-runner-resize:focus-visible { + border-color: color-mix(in srgb, var(--accent) 44%, var(--border-color)); + opacity: 1; +} +.runtime-html-runner.is-picking .runtime-html-runner-panel { + border-color: color-mix(in srgb, var(--accent) 54%, var(--border-color)); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent), + 0 18px 56px rgba(15, 23, 42, 0.22); +} +@keyframes runtime-chat-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes runtime-tool-reveal { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes runtime-html-runner-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +@keyframes runtime-chat-image-viewer-in { + from { + opacity: 0; + transform: scale(0.985); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes runtime-chat-selection-quote-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} +@keyframes runtime-chat-cursor { + 0%, + 45% { + opacity: 1; + } + 46%, + 100% { + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + .runtime-chat-item, + .runtime-chat-item.streaming .runtime-chat-content::after, + .runtime-tool-preview, + .runtime-tool-children, + .runtime-tool-message, + .runtime-html-runner-panel, + .runtime-chat-selection-quote { + animation: none; + } + .runtime-tool-block, + .runtime-tool-block summary::before { + transition: none; + } } .sr-only { position: absolute; diff --git a/src/Undefined/webui/static/css/highlight-github.min.css b/src/Undefined/webui/static/css/highlight-github.min.css new file mode 100644 index 00000000..275239a7 --- /dev/null +++ b/src/Undefined/webui/static/css/highlight-github.min.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub + Description: Light theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-light + Current colors taken from GitHub's CSS +*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} \ No newline at end of file diff --git a/src/Undefined/webui/static/css/responsive.css b/src/Undefined/webui/static/css/responsive.css index b6feeba1..00dcea51 100644 --- a/src/Undefined/webui/static/css/responsive.css +++ b/src/Undefined/webui/static/css/responsive.css @@ -12,7 +12,10 @@ padding-right: 30px; } .search-group .form-control { min-width: 0; } - .main-content.chat-layout #tab-chat .chat-runtime-card { height: clamp(460px, calc(100vh - 220px), 760px); } + .main-content.chat-layout { + height: 100dvh; + padding-bottom: max(14px, env(safe-area-inset-bottom)); + } } @media (max-width: 768px) { @@ -54,6 +57,117 @@ min-height: 100vh; padding: 20px 16px 28px; } + .main-content.chat-layout { + height: 100dvh; + min-height: 0; + padding: 14px 16px max(12px, env(safe-area-inset-bottom)); + } + .main-content.chat-layout #tab-chat > .header { + margin-bottom: 8px; + } + .main-content.chat-layout #tab-chat .runtime-chat-header { + gap: 10px; + } + .runtime-chat-shell { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + padding-right: 0; + } + .runtime-chat-sidebar { + position: static; + width: auto; + transform: none; + transition: none; + padding: 0 0 8px; + border-right: 0; + border-bottom: 1px solid var(--border-color); + } + .runtime-chat-sidebar-panel { + display: none; + height: auto; + padding: 8px 0 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; + } + .runtime-chat-sidebar.is-open .runtime-chat-sidebar-panel { + display: block; + } + .runtime-chat-sidebar-head { + display: flex; + min-height: 0; + margin-bottom: 7px; + padding-inline: 2px; + } + .runtime-chat-sidebar-tab { + position: static; + display: flex; + place-items: initial; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-card); + box-shadow: none; + } + .runtime-chat-sidebar-tab::after { + position: static; + right: auto; + bottom: auto; + flex: 0 0 auto; + transform: rotate(-135deg); + } + .runtime-chat-sidebar.is-open .runtime-chat-sidebar-tab::after { + transform: rotate(45deg); + } + .runtime-chat-sidebar-tab-label { + writing-mode: horizontal-tb; + font-size: 13px; + } + .runtime-chat-sidebar:not(.is-open) { + padding-bottom: 8px; + } + .runtime-chat-conversations { + flex-direction: row; + height: auto; + overflow-x: auto; + overflow-y: hidden; + padding: 0 0 6px; + } + .runtime-chat-conversation { + flex: 0 0 min(220px, 72vw); + } + .runtime-chat-conversation-head { + padding: 7px 12px; + } + .runtime-chat-page-title { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + .runtime-chat-title-meta { + max-width: 100%; + font-size: 12px; + line-height: 1.35; + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .runtime-chat-header-actions { + width: 100%; + justify-content: space-between; + flex-wrap: nowrap; + } + .runtime-chat-auto-scroll-toggle { + flex: 1 1 auto; + min-width: 0; + } .header { margin-bottom: 24px; gap: 14px; @@ -247,16 +361,137 @@ .runtime-chat-file-card { max-width: 100%; } .runtime-chat-content.markdown table { - display: block; - overflow-x: auto; - white-space: nowrap; + display: table; + overflow-x: visible; + white-space: normal; + } + .runtime-code-toolbar { + gap: 6px; + min-height: 32px; + padding: 4px 6px 4px 9px; + } + .runtime-code-actions { + gap: 4px; + } + .runtime-code-action { + min-height: 24px; + padding-inline: 7px; + font-size: 11px; } .runtime-chat-input-row { - grid-template-columns: 1fr; - align-items: stretch; + --chat-attachment-gap: 6px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "references references" + "attachments attachments" + "input actions"; + align-items: end; + column-gap: 7px; + row-gap: 0; + min-width: 0; + } + .runtime-chat-command-palette { + max-height: min(310px, 42vh); + padding: 6px; + } + .runtime-chat-command-item { + grid-template-columns: minmax(0, 1fr); + gap: 5px; + min-height: 64px; + padding: 9px; + } + .runtime-chat-command-side { + justify-items: start; + } + .runtime-chat-command-help { + padding: 9px; + } + .runtime-chat-command-help-head { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + .runtime-chat-command-help-grid { + grid-template-columns: minmax(0, 1fr); + gap: 3px; + } + .runtime-chat-command-help-grid > span:nth-child(even) { + margin-bottom: 5px; + } + .runtime-chat-image-viewer { + padding: 14px; + } + .runtime-chat-image-viewer-figure, + .runtime-chat-image-viewer-image, + .runtime-chat-image-viewer-caption { + max-width: calc(100vw - 28px); + } + .runtime-chat-image-viewer-image { + max-height: calc(100dvh - 86px); + } + .runtime-chat-image-viewer-close { + top: max(10px, env(safe-area-inset-top)); + right: max(10px, env(safe-area-inset-right)); + } + .runtime-chat-references { + grid-area: references; + align-self: stretch; + width: 100%; + max-width: 100%; + height: 46px; + margin-right: 0; + margin-bottom: 7px; + } + .runtime-chat-references[hidden] { display: none; } + .runtime-chat-reference { + flex-basis: min(220px, 78vw); + min-width: 132px; + grid-template-columns: 20px minmax(0, 1fr) 24px; + padding-inline: 5px; + } + .runtime-chat-reference-mark { + width: 20px; + height: 34px; + font-size: 19px; + } + .runtime-chat-input-row > .runtime-chat-input { + grid-area: input; + min-width: 0; + width: 100%; + margin-right: 0; + } + .runtime-chat-attachments { + grid-area: attachments; + align-self: stretch; + width: 100%; + max-width: 100%; + height: 48px; + margin-right: 0; + margin-bottom: 7px; + } + .runtime-chat-attachments[hidden] { display: none; } + .runtime-chat-input-row.has-attachments .runtime-chat-attachments { + margin-right: 0; + } + .runtime-chat-attachment { + flex-basis: min(var(--chat-attachment-card-width), 72vw); + min-width: 34px; + } + .runtime-chat-input-row.is-attachment-rail-full .runtime-chat-attachment { + min-width: 30px; + } + .runtime-chat-actions { + grid-area: actions; + align-self: end; + justify-content: flex-end; + } + .main-content.chat-layout #tab-chat .runtime-chat-log { + padding: 12px; + } + .main-content.chat-layout #tab-chat .runtime-chat-input-row { + padding: 10px 12px 0; } - .runtime-chat-actions { justify-content: flex-start; } - .main-content.chat-layout #tab-chat .chat-runtime-card { height: clamp(480px, calc(100vh - 180px), 720px); } .main-content.chat-layout #tab-chat .runtime-chat-input { height: auto; min-height: 88px; @@ -271,6 +506,65 @@ .main-content.chat-layout #tab-chat .runtime-chat-action-icon { font-size: 18px; } + .runtime-tool-block summary { + grid-template-columns: auto minmax(0, 1fr) minmax(46px, auto); + gap: 6px; + } + .runtime-tool-block summary .runtime-tool-title { + gap: 5px; + } + .runtime-tool-block summary .runtime-tool-duration { + padding-inline: 5px; + font-size: 10px; + } + .runtime-tool-block summary .runtime-tool-status { + justify-self: end; + max-width: 30vw; + } + .runtime-tool-block summary .runtime-tool-kind { + display: none; + } + .runtime-tool-structured-row { + grid-template-columns: 1fr; + gap: 3px; + } + .runtime-tool-children { + margin-left: 6px; + padding-left: 8px; + padding-right: 6px; + } + .runtime-html-runner { + width: calc(100vw - 24px); + min-width: 0; + min-height: 260px; + max-width: calc(100vw - 24px); + max-height: calc(100dvh - 24px - env(safe-area-inset-bottom)); + } + .runtime-html-runner-panel { + grid-template-rows: auto minmax(0, 1fr); + min-height: 260px; + border-radius: var(--radius-sm); + } + .runtime-html-runner-toolbar { + align-items: flex-start; + flex-wrap: wrap; + padding: 8px 9px 8px 11px; + } + .runtime-html-runner-title { + display: grid; + gap: 3px; + flex: 1 1 min(160px, 100%); + } + .runtime-html-runner-meta { + max-width: min(62vw, 260px); + } + .runtime-html-runner-actions { + margin-left: auto; + gap: 4px; + } + .runtime-html-runner-btn { + padding-inline: 8px; + } } @media (max-width: 480px) { @@ -293,6 +587,24 @@ padding-left: 16px; padding-right: 16px; } + .runtime-chat-header-actions { + gap: 6px; + } + .runtime-chat-auto-scroll-toggle { + font-size: 11px; + padding-inline: 8px; + } + .runtime-chat-auto-scroll-toggle .toggle-track { + width: 34px; + height: 20px; + } + .runtime-chat-auto-scroll-toggle .toggle-handle { + width: 16px; + height: 16px; + } + .runtime-chat-auto-scroll-toggle .toggle-input:checked + .toggle-track .toggle-handle { + transform: translateX(14px); + } .search-group { flex-direction: column; } diff --git a/src/Undefined/webui/static/js/api.js b/src/Undefined/webui/static/js/api.js index ed528e10..ed8287e5 100644 --- a/src/Undefined/webui/static/js/api.js +++ b/src/Undefined/webui/static/js/api.js @@ -59,8 +59,15 @@ function shouldRetryCandidate(res) { async function requestOnce(path, options = {}) { const headers = { ...(options.headers || {}) }; + const body = options.body; + const isNativeBody = + (typeof FormData !== "undefined" && body instanceof FormData) || + (typeof Blob !== "undefined" && body instanceof Blob) || + (typeof URLSearchParams !== "undefined" && + body instanceof URLSearchParams); const needsJson = - options.body && + body && + !isNativeBody && !headers["Content-Type"] && ["POST", "PATCH", "PUT", "DELETE"].includes( String(options.method || "").toUpperCase(), diff --git a/src/Undefined/webui/static/js/i18n.js b/src/Undefined/webui/static/js/i18n.js index 7be72b9f..99a59e34 100644 --- a/src/Undefined/webui/static/js/i18n.js +++ b/src/Undefined/webui/static/js/i18n.js @@ -262,12 +262,102 @@ const I18N = { "runtime.profiles_placeholder": "输入侧写关键词...", "runtime.cognitive_profile_get": "按实体查看侧写", "runtime.fetch": "获取", - "runtime.chat_title": "AI Chat(虚拟私聊 system#42)", - "runtime.chat_hint": - "该会话由 WebUI 发起,权限为 superadmin;私聊里可直接使用 /命令。", "runtime.chat_placeholder": "输入消息,或直接 /help 这样的命令", + "runtime.chat_log_label": "聊天消息", + "runtime.chat_auto_scroll": "自动滚动到底部", + "runtime.chat_clear": "清空历史", + "runtime.chat_clear_confirm": + "确定清空当前 WebChat 对话的聊天历史吗?长期记忆和认知记忆不会受影响。", + "runtime.chat_cleared": "聊天历史已清空", + "runtime.chat_conversations": "对话", + "runtime.chat_new_conversation": "新建对话", + "runtime.chat_conversation_created": "已新建对话", + "runtime.chat_no_conversations": "暂无对话", + "runtime.chat_rename_conversation": "重命名对话", + "runtime.chat_delete_conversation": "删除对话", + "runtime.chat_delete_confirm": + "确定删除当前 WebChat 对话吗?该对话历史会被删除。", + "runtime.chat_message_count": "{count} 条消息", + "runtime.chat_title_pending": "标题生成中", + "runtime.chat_loading_more": "正在加载更早消息...", + "runtime.chat_streaming": "正在生成", + "runtime.chat_reconnecting": "正在恢复连接...", + "runtime.chat_running": "已有对话正在运行,请稍候。", + "runtime.chat_command_hint": "选择命令,Enter/Tab 填入", + "runtime.chat_command_hint_subcommand": "选择子命令,Enter/Tab 填入", + "runtime.chat_command_loading": "正在加载可用命令...", + "runtime.chat_command_empty": "未找到匹配命令", + "runtime.chat_command_unknown_command": + "继续输入完整命令,或从候选命令中选择", + "runtime.chat_command_subcommand_empty": "未找到匹配子命令", + "runtime.chat_command_subcommands": "{count} 个子命令", + "runtime.command_help": "命令帮助", + "runtime.command_usage": "用法", + "runtime.command_example": "示例", + "runtime.command_aliases": "别名", + "runtime.command_no_subcommands_note": + "该命令没有子命令,可按上方用法补充参数后发送。", + "runtime.chat_stage_received": "已接收", + "runtime.chat_stage_processing": "处理中", + "runtime.chat_stage_recording_history": "记录历史", + "runtime.chat_stage_running_command": "执行命令", + "runtime.chat_stage_command_done": "命令完成", + "runtime.chat_stage_asking_ai": "进入 AI", + "runtime.chat_stage_building_context": "构建上下文", + "runtime.chat_stage_checking_long_term_memory": "查长期记忆", + "runtime.chat_stage_searching_cognitive_memory": "查认知记忆", + "runtime.chat_stage_loading_chat_history": "加载历史", + "runtime.chat_stage_context_ready": "上下文就绪", + "runtime.chat_stage_selecting_model": "选择模型", + "runtime.chat_stage_waiting_model": "等待模型", + "runtime.chat_stage_preparing_tools": "准备工具", + "runtime.chat_stage_waiting_tools": "等待工具", + "runtime.chat_stage_sending_message": "发送消息", + "runtime.chat_stage_retrying_model": "重试模型", + "runtime.chat_stage_finalizing": "收尾", + "runtime.chat_stage_done": "完成", + "runtime.tool": "工具", + "runtime.agent": "智能体", + "runtime.tool_input": "输入", + "runtime.tool_output": "输出", + "runtime.message": "消息", + "runtime.end": "结束", + "runtime.running": "运行中", + "runtime.done": "完成", + "runtime.sending": "发送中", + "runtime.sent": "已发送", + "runtime.ended": "已结束", + "runtime.error": "错误", + "runtime.cancelled": "已取消", "runtime.image": "图片", - "runtime.image_added": "已插入图片", + "runtime.attach_file": "附加文件", + "runtime.attachment_added": "已附加 1 个文件", + "runtime.attachments_added": "已附加 {count} 个文件", + "runtime.attachment_kind_image": "图片", + "runtime.attachment_kind_file": "文件", + "runtime.remove_attachment": "移除附件", + "runtime.quote": "引用", + "runtime.quote_selection": "引用所选", + "runtime.reference_added": "已加入引用", + "runtime.remove_reference": "移除引用", + "runtime.reference_message": "引用 AI", + "runtime.reference_selection": "引用所选内容", + "runtime.reference_html": "引用 HTML 片段", + "runtime.expand_code": "展开", + "runtime.collapse_code": "折叠", + "runtime.copy_code": "复制", + "runtime.run_html": "运行", + "runtime.code_copied": "已复制代码", + "runtime.copy_failed": "复制失败", + "runtime.html_runner": "HTML 预览", + "runtime.pick_html": "选择", + "runtime.picking_html": "选择元素", + "runtime.html_ready": "HTML 已运行", + "runtime.html_pick_hint": "第一次点击预览范围,第二次点击确认", + "runtime.html_pick_confirm_hint": "再次点击确认", + "runtime.close": "关闭", + "runtime.image_preview": "图片预览", + "runtime.open_image_preview": "点击放大查看", "runtime.download": "下载", "runtime.send": "发送", "runtime.total": "共 {count} 条", @@ -278,7 +368,8 @@ const I18N = { "runtime.not_found": "未命中", "runtime.api_start_hint": "请先在 WebUI 中启动机器人进程。", "chat.title": "智能对话", - "chat.subtitle": "虚拟私聊 system#42。", + "chat.subtitle": + "虚拟私聊 system#42。该会话由 WebUI 发起,权限为 superadmin;私聊里可直接使用 /命令。", "about.title": "项目信息", "about.subtitle": "关于 Undefined 项目的作者及许可协议。", "about.author": "作者", @@ -585,12 +676,104 @@ const I18N = { "runtime.profiles_placeholder": "Search profile keyword...", "runtime.cognitive_profile_get": "Get Profile by Entity", "runtime.fetch": "Fetch", - "runtime.chat_title": "AI Chat (virtual private chat system#42)", - "runtime.chat_hint": - "This WebUI session runs as superadmin; slash commands work directly in private chat.", "runtime.chat_placeholder": "Type a message, or run /help directly", + "runtime.chat_log_label": "Chat messages", + "runtime.chat_auto_scroll": "Auto-scroll", + "runtime.chat_clear": "Clear History", + "runtime.chat_clear_confirm": + "Clear the current WebChat conversation history? Long-term and cognitive memory are not affected.", + "runtime.chat_cleared": "Chat history cleared", + "runtime.chat_conversations": "Conversations", + "runtime.chat_new_conversation": "New Chat", + "runtime.chat_conversation_created": "New chat created", + "runtime.chat_no_conversations": "No conversations", + "runtime.chat_rename_conversation": "Rename conversation", + "runtime.chat_delete_conversation": "Delete conversation", + "runtime.chat_delete_confirm": + "Delete this WebChat conversation and its history?", + "runtime.chat_message_count": "{count} messages", + "runtime.chat_title_pending": "Generating title", + "runtime.chat_loading_more": "Loading earlier messages...", + "runtime.chat_streaming": "Generating", + "runtime.chat_reconnecting": "Restoring connection...", + "runtime.chat_running": "A chat job is already running.", + "runtime.chat_command_hint": "Choose a command, Enter/Tab to insert", + "runtime.chat_command_hint_subcommand": + "Choose a subcommand, Enter/Tab to insert", + "runtime.chat_command_loading": "Loading available commands...", + "runtime.chat_command_empty": "No matching command", + "runtime.chat_command_unknown_command": + "Keep typing the full command, or choose from command matches", + "runtime.chat_command_subcommand_empty": "No matching subcommand", + "runtime.chat_command_subcommands": "{count} subcommands", + "runtime.command_help": "Command help", + "runtime.command_usage": "Usage", + "runtime.command_example": "Example", + "runtime.command_aliases": "Aliases", + "runtime.command_no_subcommands_note": + "This command has no subcommands. Add arguments using the usage above, then send.", + "runtime.chat_stage_received": "Received", + "runtime.chat_stage_processing": "Processing", + "runtime.chat_stage_recording_history": "Recording history", + "runtime.chat_stage_running_command": "Running command", + "runtime.chat_stage_command_done": "Command done", + "runtime.chat_stage_asking_ai": "Entering AI", + "runtime.chat_stage_building_context": "Building context", + "runtime.chat_stage_checking_long_term_memory": "Checking memory", + "runtime.chat_stage_searching_cognitive_memory": "Searching memory", + "runtime.chat_stage_loading_chat_history": "Loading history", + "runtime.chat_stage_context_ready": "Context ready", + "runtime.chat_stage_selecting_model": "Selecting model", + "runtime.chat_stage_waiting_model": "Waiting for model", + "runtime.chat_stage_preparing_tools": "Preparing tools", + "runtime.chat_stage_waiting_tools": "Waiting for tools", + "runtime.chat_stage_sending_message": "Sending message", + "runtime.chat_stage_retrying_model": "Retrying model", + "runtime.chat_stage_finalizing": "Finalizing", + "runtime.chat_stage_done": "Done", + "runtime.tool": "Tool", + "runtime.agent": "Agent", + "runtime.tool_input": "Input", + "runtime.tool_output": "Output", + "runtime.message": "Message", + "runtime.end": "End", + "runtime.running": "Running", + "runtime.done": "Done", + "runtime.sending": "Sending", + "runtime.sent": "Sent", + "runtime.ended": "Ended", + "runtime.error": "Error", + "runtime.cancelled": "Cancelled", "runtime.image": "Image", - "runtime.image_added": "Image inserted", + "runtime.attach_file": "Attach file", + "runtime.attachment_added": "Attached 1 file", + "runtime.attachments_added": "Attached {count} files", + "runtime.attachment_kind_image": "Image", + "runtime.attachment_kind_file": "File", + "runtime.remove_attachment": "Remove attachment", + "runtime.quote": "Quote", + "runtime.quote_selection": "Quote selection", + "runtime.reference_added": "Reference added", + "runtime.remove_reference": "Remove reference", + "runtime.reference_message": "Quoted AI", + "runtime.reference_selection": "Quoted selection", + "runtime.reference_html": "Quoted HTML snippet", + "runtime.expand_code": "Expand", + "runtime.collapse_code": "Collapse", + "runtime.copy_code": "Copy", + "runtime.run_html": "Run", + "runtime.code_copied": "Code copied", + "runtime.copy_failed": "Copy failed", + "runtime.html_runner": "HTML preview", + "runtime.pick_html": "Pick", + "runtime.picking_html": "Picking", + "runtime.html_ready": "HTML is running", + "runtime.html_pick_hint": + "Click once to preview, click again to confirm", + "runtime.html_pick_confirm_hint": "Click again to confirm", + "runtime.close": "Close", + "runtime.image_preview": "Image preview", + "runtime.open_image_preview": "Click to enlarge", "runtime.download": "Download", "runtime.send": "Send", "runtime.total": "{count} items", @@ -602,7 +785,8 @@ const I18N = { "runtime.api_start_hint": "Please start the bot process in WebUI first.", "chat.title": "AI Dialog", - "chat.subtitle": "Virtual private session system#42.", + "chat.subtitle": + "Virtual private session system#42. This WebUI session runs as superadmin; slash commands work directly in private chat.", "about.title": "About Project", "about.subtitle": "Information about authors and open source licenses.", "about.author": "Author", diff --git a/src/Undefined/webui/static/js/main.js b/src/Undefined/webui/static/js/main.js index a9b495ce..d88d01e3 100644 --- a/src/Undefined/webui/static/js/main.js +++ b/src/Undefined/webui/static/js/main.js @@ -45,6 +45,22 @@ function renderAboutChangelogEntry(entry) { container.appendChild(list); } +function syncMainContentLayout() { + const mainContent = document.querySelector(".main-content"); + if (mainContent) { + mainContent.classList.toggle("chat-layout", state.tab === "chat"); + } + + const appContent = get("appContent"); + if (appContent && state.authenticated) { + if (state.view === "app") { + appContent.style.display = state.tab === "chat" ? "grid" : "block"; + } else { + appContent.style.display = "none"; + } + } +} + function renderAboutChangelog(payload) { aboutChangelogPayload = payload; const select = get("about-changelog-select"); @@ -119,7 +135,6 @@ function refreshUI() { if (state.view === "app") { if (state.authenticated) { - get("appContent").style.display = "block"; if (!state.configLoaded) loadConfig(); if ( window.RuntimeController && @@ -143,10 +158,7 @@ function refreshUI() { if (!state.authenticated) state.mobileDrawerOpen = false; - const mainContent = document.querySelector(".main-content"); - if (mainContent) { - mainContent.classList.toggle("chat-layout", state.tab === "chat"); - } + syncMainContentLayout(); if (initialState && initialState.version) get("about-version-display").innerText = initialState.version; @@ -172,10 +184,7 @@ function switchTab(tab) { abortPendingRequests(); // Cancel pending requests from previous tab state.tab = tab; state.mobileDrawerOpen = false; - const mainContent = document.querySelector(".main-content"); - if (mainContent) { - mainContent.classList.toggle("chat-layout", tab === "chat"); - } + syncMainContentLayout(); document.querySelectorAll(".nav-item").forEach((el) => { el.classList.toggle("active", el.getAttribute("data-tab") === tab); }); diff --git a/src/Undefined/webui/static/js/runtime.js b/src/Undefined/webui/static/js/runtime.js index c2f8c3f0..31dbc4e8 100644 --- a/src/Undefined/webui/static/js/runtime.js +++ b/src/Undefined/webui/static/js/runtime.js @@ -6,7 +6,50 @@ runtimeMetaLoaded: false, runtimeEnabled: true, chatBusy: false, + chatConversationsLoaded: false, + chatConversationsLoading: false, + chatConversations: [], + currentChatConversationId: "", + activeJobConversationId: "", + recentlyCreatedConversationId: "", + chatCommandsLoaded: false, + chatCommandsLoading: false, + chatCommandsLoadedAt: 0, + chatCommands: [], + chatCommandsError: "", + chatCommandPaletteOpen: false, + chatCommandMatches: [], + chatCommandActiveIndex: 0, + chatCommandContext: null, chatHistoryLoaded: false, + activeJobId: null, + lastEventSeq: 0, + chatHistoryCursor: null, + chatHistoryHasMore: false, + chatHistoryLoading: false, + chatTopLoadSuppressedUntil: 0, + chatAutoScroll: true, + streamingMessageId: null, + activeChatMessageId: null, + chatPollTimer: null, + chatPollBackoffMs: 500, + chatClockTimer: null, + activeJobResumeTimer: null, + activeJobResumeAttempts: 0, + toolBlocks: new Map(), + toolCollapseTimers: new Map(), + chatAttachments: [], + chatAttachmentSeq: 0, + chatReferences: [], + chatReferenceSeq: 0, + pendingSelectionReference: null, + selectionQuoteButton: null, + imageViewerPreviousFocus: null, + chatConversationDrawerOpen: false, + htmlRunnerSource: "", + htmlRunnerPickMode: false, + htmlRunnerResize: null, + htmlRunnerDrag: null, probeTimer: null, queryBusy: { memory: false, @@ -16,6 +59,40 @@ }, }; const RUNTIME_DISABLED_ERROR = "Runtime API disabled"; + const CHAT_AUTO_SCROLL_STORAGE_KEY = "undefined_webchat_auto_scroll"; + const CHAT_POLL_INTERVAL_MS = 500; + const CHAT_CLOCK_INTERVAL_MS = 500; + const CHAT_TOP_LOAD_SUPPRESS_MS = 900; + const TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS = 2000; + const ACTIVE_JOB_RESUME_MAX_ATTEMPTS = 20; + const CHAT_INLINE_IMAGE_MAX_BYTES = 12 * 1024 * 1024; + const CHAT_ATTACHMENT_RAIL_BASE_WIDTH = 72; + const CHAT_ATTACHMENT_RAIL_STEP_WIDTH = 56; + const CHAT_ATTACHMENT_RAIL_MAX_WIDTH = 240; + const CHAT_ATTACHMENT_CARD_MAX_WIDTH = 132; + const CHAT_ATTACHMENT_CARD_MIN_WIDTH = 36; + const CHAT_ATTACHMENT_GAP_WIDTH = 6; + const CHAT_ATTACHMENT_COMPRESSED_GAP_WIDTH = 4; + const CHAT_ATTACHMENT_COMPRESSED_COUNT = 5; + const CHAT_REFERENCE_MAX_CHARS = 4000; + const CHAT_REFERENCE_PREVIEW_CHARS = 180; + const CHAT_COMMAND_CACHE_MS = 30000; + const CHAT_COMMAND_MAX_MATCHES = 8; + const CODE_COLLAPSE_LINE_THRESHOLD = 8; + const HTML_RUNNER_MIN_WIDTH = 360; + const HTML_RUNNER_MIN_HEIGHT = 280; + const HTML_RUNNER_VIEWPORT_MARGIN = 12; + + function prefersReducedMotion() { + return ( + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); + } + + function chatScrollBehavior() { + return prefersReducedMotion() ? "auto" : "smooth"; + } function i18nFormat(key, params = {}) { let text = t(key); @@ -25,6 +102,39 @@ return text; } + function currentChatConversationId() { + return String(runtimeState.currentChatConversationId || "").trim(); + } + + function chatUrl(path, params = {}) { + const query = new URLSearchParams(); + const conversationId = currentChatConversationId(); + if (conversationId) query.set("conversation_id", conversationId); + Object.entries(params || {}).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") return; + query.set(key, String(value)); + }); + const suffix = query.toString(); + return suffix ? `${path}?${suffix}` : path; + } + + function runtimeChatJobEventsUrls(jobId, params) { + const encoded = encodeURIComponent(jobId); + const query = new URLSearchParams(); + const conversationId = + runtimeState.activeJobConversationId || currentChatConversationId(); + if (conversationId) query.set("conversation_id", conversationId); + Object.entries(params || {}).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") return; + query.set(key, String(value)); + }); + const suffix = query.toString(); + return [ + `/api/v1/management/runtime/chat/jobs/${encoded}/events?${suffix}`, + `/api/runtime/chat/jobs/${encoded}/events?${suffix}`, + ]; + } + function setJsonBlock(id, payload) { const el = get(id); if (!el) return; @@ -448,780 +558,4765 @@ ); } - function appendChatMessage(role, content) { + function hasMarkdownBlockquote(content) { + return String(content || "") + .split(/\r?\n/) + .some((line) => /^\s*>/.test(line)); + } + + function shouldRenderChatMarkdown(role, content) { + return role !== "user" || hasMarkdownBlockquote(content); + } + + function appendChatMessage(role, content, options = {}) { const log = get("runtimeChatLog"); - if (!log) return; + if (!log) return null; const isBot = role !== "user"; - const contentClass = isBot + const useMarkdown = shouldRenderChatMarkdown(role, content); + const contentClass = useMarkdown ? "runtime-chat-content markdown" : "runtime-chat-content"; const item = document.createElement("div"); item.className = `runtime-chat-item ${role}`; - item.innerHTML = `
${role === "user" ? "You" : "AI"}
${renderChatContent(content, isBot)}
`; - log.appendChild(item); - log.scrollTop = log.scrollHeight; + if (options.id) item.dataset.messageId = options.id; + if (options.jobId) item.dataset.jobId = options.jobId; + const roleHtml = isBot + ? `AI` + : `You`; + item.innerHTML = `
${roleHtml}
${renderChatContent(content, useMarkdown)}
`; + if (isBot) { + const roleEl = item.querySelector(".runtime-chat-role"); + if (roleEl) { + const quoteButton = document.createElement("button"); + quoteButton.className = "runtime-chat-quote-btn"; + quoteButton.type = "button"; + quoteButton.dataset.quoteMessage = "1"; + quoteButton.textContent = t("runtime.quote"); + roleEl.appendChild(quoteButton); + } + } + if (options.prepend) { + log.insertBefore(item, log.firstChild); + } else { + log.appendChild(item); + if (options.scroll !== false) scrollChatToBottom(); + } + return item; } - function clearChatMessages() { + function formatDurationMs(value) { + const ms = Number(value); + if (!Number.isFinite(ms) || ms <= 0) return ""; + if (ms < 1000) return `${Math.max(1, Math.round(ms))}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = Math.floor(seconds % 60); + return `${minutes}m ${remainder}s`; + } + + function messageQuoteSourceLabel(type) { + if (type === "html") return t("runtime.reference_html"); + if (type === "selection") return t("runtime.reference_selection"); + return t("runtime.reference_message"); + } + + function scrollChatToBottom() { + if (!runtimeState.chatAutoScroll) return; const log = get("runtimeChatLog"); if (!log) return; - log.innerHTML = ""; + suppressChatTopHistoryLoad(); + log.scrollTo({ + top: log.scrollHeight, + behavior: chatScrollBehavior(), + }); } - function parseCqAttributes(raw) { - const attrs = {}; - String(raw || "") - .split(",") - .forEach((part) => { - const idx = part.indexOf("="); - if (idx <= 0) return; - const key = part.slice(0, idx).trim(); - const value = part.slice(idx + 1).trim(); - if (!key) return; - attrs[key] = value; + function forceScrollChatToBottom() { + const log = get("runtimeChatLog"); + if (!log) return; + suppressChatTopHistoryLoad(); + log.scrollTo({ + top: log.scrollHeight, + behavior: chatScrollBehavior(), + }); + } + + function suppressChatTopHistoryLoad() { + runtimeState.chatTopLoadSuppressedUntil = Math.max( + runtimeState.chatTopLoadSuppressedUntil || 0, + Date.now() + CHAT_TOP_LOAD_SUPPRESS_MS, + ); + } + + function isChatTopHistoryLoadSuppressed() { + return Date.now() < (runtimeState.chatTopLoadSuppressedUntil || 0); + } + + function forceScrollChatToBottomSoon() { + suppressChatTopHistoryLoad(); + forceScrollChatToBottom(); + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => { + forceScrollChatToBottom(); + requestAnimationFrame(forceScrollChatToBottom); }); - return attrs; + } else { + setTimeout(forceScrollChatToBottom, 0); + } + setTimeout(forceScrollChatToBottom, 80); + setTimeout(forceScrollChatToBottom, 260); + setTimeout(forceScrollChatToBottom, 700); } - function resolveCqImageSource(attrs) { - const raw = String((attrs && (attrs.url || attrs.file)) || "").trim(); - if (!raw) return ""; - if (raw.startsWith("base64://")) { - const payload = raw.slice("base64://".length).trim(); - return payload ? `data:image/png;base64,${payload}` : ""; + function scrollChatToBottomSoon() { + if (!runtimeState.chatAutoScroll) return; + scrollChatToBottom(); + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(scrollChatToBottom); + return; } - if (raw.startsWith("file://")) { - const localPath = raw.slice("file://".length).trim(); - return localPath - ? `/api/runtime/chat/image?path=${encodeURIComponent(localPath)}` - : ""; + setTimeout(scrollChatToBottom, 0); + } + + function updateChatMessage(item, content, role = "bot") { + if (!item) return; + const contentEl = item.querySelector(".runtime-chat-content"); + if (!contentEl) return; + const useMarkdown = shouldRenderChatMarkdown(role, content); + contentEl.classList.toggle("markdown", useMarkdown); + contentEl.innerHTML = renderChatContent(content, useMarkdown); + } + + function currentChatJobId() { + return runtimeState.activeJobId ? String(runtimeState.activeJobId) : ""; + } + + function findActiveChatMessage(jobId = "") { + const byJob = String(jobId || "").trim(); + if (byJob) { + const existingForJob = document.querySelector( + `[data-job-id="${CSS.escape(byJob)}"]`, + ); + if (existingForJob) return existingForJob; } - if (raw.startsWith("/") || /^[A-Za-z]:[\\/]/.test(raw)) { - return `/api/runtime/chat/image?path=${encodeURIComponent(raw)}`; + if (runtimeState.activeChatMessageId) { + const existing = document.querySelector( + `[data-message-id="${CSS.escape(runtimeState.activeChatMessageId)}"]`, + ); + if (existing) return existing; } - if ( - raw.startsWith("http://") || - raw.startsWith("https://") || - raw.startsWith("data:image/") - ) { - return raw; + if (runtimeState.streamingMessageId) { + const existing = document.querySelector( + `[data-message-id="${CSS.escape(runtimeState.streamingMessageId)}"]`, + ); + if (existing) return existing; } - return ""; + return null; } - function formatFileSize(bytes) { - const n = Number(bytes); - if (!Number.isFinite(n) || n <= 0) return ""; - if (n < 1024) return n + "B"; - if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB"; - return (n / 1024 / 1024).toFixed(2) + "MB"; + function ensureStreamingMessage(jobId = "") { + const resolvedJobId = String(jobId || currentChatJobId()).trim(); + const existing = findActiveChatMessage(resolvedJobId); + if (existing) { + if (resolvedJobId) existing.dataset.jobId = resolvedJobId; + runtimeState.activeChatMessageId = + existing.dataset.messageId || null; + return existing; + } + const id = `stream-${Date.now()}`; + runtimeState.streamingMessageId = id; + runtimeState.activeChatMessageId = id; + const item = appendChatMessage("bot", "", { + id, + jobId: resolvedJobId || null, + }); + if (item) item.classList.add("streaming"); + return item; } - function renderFileCard(attrs) { - const fileId = escapeHtml(String(attrs.id || "").trim()); - const name = escapeHtml(String(attrs.name || "file").trim()); - const size = formatFileSize(attrs.size); - if (!fileId) return `[file]`; - const href = `/api/runtime/chat/file?id=${encodeURIComponent(fileId)}`; - return ( - `
` + - `
📄
` + - `
` + - `
${name}
` + - (size ? `
${size}
` : "") + - `
` + - `${t("runtime.download") || "Download"}` + - `
` - ); + function ensureTimelineNodeContainer(item) { + if (!item) return null; + let container = item.querySelector(".runtime-chat-timeline"); + if (!container) { + container = document.createElement("div"); + container.className = "runtime-chat-timeline"; + const contentEl = item.querySelector(".runtime-chat-content"); + if (contentEl) contentEl.remove(); + item.appendChild(container); + } + return container; } - function renderChatContent(content, useMarkdown) { - const text = String(content || ""); + function appendRawChatContent(item, content) { + const text = String(content || "").trim(); + if (!item || !text) return; + item.dataset.rawContent = [item.dataset.rawContent || "", text] + .filter(Boolean) + .join("\n\n"); + } - // Extract CQ file codes into placeholders - const filePattern = /\[CQ:file,([^\]]+)\]/g; - const filePlaceholders = []; - const step1 = text.replace(filePattern, (match, attrStr) => { - const attrs = parseCqAttributes(attrStr); - const idx = filePlaceholders.length; - filePlaceholders.push(renderFileCard(attrs)); - return `CQFILEPH${idx}CQFILEPH`; - }); + function appendTimelineMessage(item, content, role = "bot") { + const text = String(content || "").trim(); + if (!item || !text) return null; + const timeline = ensureTimelineNodeContainer(item); + if (!timeline) return null; + const node = document.createElement("div"); + const useMarkdown = shouldRenderChatMarkdown(role, text); + node.className = useMarkdown + ? "runtime-chat-content markdown" + : "runtime-chat-content"; + node.innerHTML = renderChatContent(text, useMarkdown); + timeline.appendChild(node); + appendRawChatContent(item, text); + return node; + } - // Extract CQ image codes into placeholders before markdown parsing - const imagePattern = /\[CQ:image,([^\]]+)\]/g; - const images = []; - const processed = step1.replace(imagePattern, (match, attrStr) => { - const attrs = parseCqAttributes(attrStr); - const src = resolveCqImageSource(attrs); - if (src) { - const idx = images.length; - images.push( - `image`, - ); - return `CQIMGPH${idx}CQIMGPH`; - } - return match; + function renderHistoryAttachment(item) { + if (!item || typeof item !== "object") return ""; + const mediaType = String(item.media_type || item.kind || "").trim(); + if (mediaType === "image") { + const source = String( + item.render_source || item.source_ref || "", + ).trim(); + if (!source) return ""; + return chatImageMarkup( + source, + item.display_name || item.name || "", + ); + } + const fileId = String( + item.file_id || item.source_ref || item.uid || "", + ).trim(); + if (!fileId) return ""; + return renderFileCard({ + id: fileId, + name: item.display_name || item.name || fileId, + size: item.size, }); + } - let html; - if (useMarkdown && typeof marked !== "undefined" && marked.parse) { - try { - html = marked.parse(processed, { breaks: true, gfm: true }); - } catch (_e) { - html = escapeHtml(processed); - } - } else { - html = escapeHtml(processed); - } + function buildAttachmentMarkup(attachments) { + const items = Array.isArray(attachments) ? attachments : []; + return items + .map((item) => renderHistoryAttachment(item)) + .filter(Boolean) + .join(""); + } - // Restore placeholders - for (let i = 0; i < images.length; i++) { - html = html.replace( - new RegExp(`CQIMGPH${i}CQIMGPH`, "g"), - images[i], + function readChatAutoScrollPreference() { + try { + const value = window.localStorage.getItem( + CHAT_AUTO_SCROLL_STORAGE_KEY, ); + return value === null ? true : value !== "false"; + } catch (_error) { + return true; } - for (let i = 0; i < filePlaceholders.length; i++) { - // marked may wrap placeholder in

, strip it for block-level card - html = html.replace( - new RegExp(`

\\s*CQFILEPH${i}CQFILEPH\\s*

`, "g"), - filePlaceholders[i], - ); - html = html.replace( - new RegExp(`CQFILEPH${i}CQFILEPH`, "g"), - filePlaceholders[i], + } + + function writeChatAutoScrollPreference(enabled) { + try { + window.localStorage.setItem( + CHAT_AUTO_SCROLL_STORAGE_KEY, + enabled ? "true" : "false", ); + } catch (_error) { + // ignore storage failures in hardened browsers/private mode } + } - return html || escapeHtml(text); + function syncChatAutoScrollToggle() { + const input = get("runtimeChatAutoScroll"); + if (!input) return; + input.checked = runtimeState.chatAutoScroll; } - function readFileAsDataUrl(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result || "")); - reader.onerror = () => reject(new Error("File read failed")); - reader.readAsDataURL(file); + function setChatAutoScroll(enabled, { persist = true } = {}) { + runtimeState.chatAutoScroll = !!enabled; + syncChatAutoScrollToggle(); + if (persist) writeChatAutoScrollPreference(runtimeState.chatAutoScroll); + if (runtimeState.chatAutoScroll) scrollChatToBottomSoon(); + } + + function clearToolCollapseTimers() { + runtimeState.toolCollapseTimers.forEach((timer) => { + clearTimeout(timer); }); + runtimeState.toolCollapseTimers.clear(); } - async function parseJsonSafe(res) { - try { - return await res.json(); - } catch (_error) { - return null; + function refreshActiveChatTimers() { + const item = findActiveChatMessage(); + if (item) { + item.querySelectorAll(".runtime-chat-stage").forEach((stageEl) => { + updateChatStageDisplay(stageEl); + }); } + if (!runtimeState.toolBlocks.size) return; + runtimeState.toolBlocks.forEach((block) => { + if (!["done", "error", "cancelled"].includes(block.status)) { + updateToolDurationDisplay(block); + } + }); } - async function fetchJsonOrThrow(path) { - const res = await api(path); - const data = await parseJsonSafe(res); - if (!res.ok || (data && data.error)) { - throw new Error(buildRequestError(res, data)); - } - return data || {}; + function stopChatPolling() { + clearTimeout(runtimeState.chatPollTimer); + runtimeState.chatPollTimer = null; } - function buildRequestError(res, payload) { - const fallback = - `${res.status} ${res.statusText || "Request failed"}`.trim(); - if (!payload || typeof payload !== "object") return fallback; - const base = payload.error ? String(payload.error) : fallback; - return payload.detail ? `${base}: ${payload.detail}` : base; + function stopChatClock() { + clearInterval(runtimeState.chatClockTimer); + runtimeState.chatClockTimer = null; } - function appendRuntimeApiHint(message) { - const text = String(message || "").trim(); - if (!text) return text; - const normalized = text.toLowerCase(); - const unreachable = - normalized.includes("runtime api unreachable") || - normalized.includes("failed to fetch") || - normalized.includes("networkerror") || - normalized.includes(" 502 ") || - normalized.startsWith("502 "); - if (!unreachable) return text; - const hint = t("runtime.api_start_hint"); - if (!hint || text.includes(hint)) return text; - return `${text} ${hint}`; + function startChatClock() { + if (runtimeState.chatClockTimer) return; + runtimeState.chatClockTimer = setInterval(() => { + refreshActiveChatTimers(); + }, CHAT_CLOCK_INTERVAL_MS); } - async function consumeSse(res, onEvent) { - if (!res.body) return; - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - function emitBlock(rawBlock) { - const block = String(rawBlock || "").trim(); - if (!block) return; - let event = "message"; - const dataLines = []; - block.split("\n").forEach((line) => { - if (line.startsWith(":")) return; - if (line.startsWith("event:")) { - event = line.slice(6).trim() || "message"; - return; - } - if (line.startsWith("data:")) { - dataLines.push(line.slice(5).trimStart()); - } - }); - if (dataLines.length === 0) return; - const rawData = dataLines.join("\n"); - let payload = {}; - try { - payload = JSON.parse(rawData); - } catch (_error) { - payload = { raw: rawData }; - } - onEvent(event, payload); - } + function stopActiveJobResumeTimer() { + clearTimeout(runtimeState.activeJobResumeTimer); + runtimeState.activeJobResumeTimer = null; + } + + function finishStreamingMessage() { + if (!runtimeState.streamingMessageId) return; + const item = document.querySelector( + `[data-message-id="${CSS.escape(runtimeState.streamingMessageId)}"]`, + ); + if (item) item.classList.remove("streaming"); + runtimeState.streamingMessageId = null; + } - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - buffer = buffer.replace(/\r\n/g, "\n"); - let boundary = buffer.indexOf("\n\n"); - while (boundary !== -1) { - const block = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); - emitBlock(block); - boundary = buffer.indexOf("\n\n"); + function finalizeActiveChatMessage(payload = null) { + const item = findActiveChatMessage(); + if (item) { + const durationMs = Number(payload && payload.duration_ms); + if (Number.isFinite(durationMs) && durationMs >= 0) { + setChatStage(item, { + stage: "done", + elapsed_ms: durationMs, + final: true, + }); + } else { + setChatStage(item, null); } } - buffer += decoder.decode(); - if (buffer.trim()) emitBlock(buffer); + finishStreamingMessage(); + runtimeState.activeChatMessageId = null; + stopChatClock(); } - let _memoryMutating = false; + function chatStageLabel(stage) { + const key = `runtime.chat_stage_${String(stage || "").trim()}`; + const label = t(key); + if (label !== key) return label; + return String(stage || "").replace(/_/g, " "); + } - function renderMemoryItems(payload) { - const container = get("runtimeMemoryList"); - const meta = get("runtimeMemoryMeta"); - if (!container || !meta) return; - const items = - payload && Array.isArray(payload.items) ? payload.items : []; - const queryInfo = - payload && payload.query && typeof payload.query === "object" - ? payload.query - : {}; - if (!Array.isArray(items) || items.length === 0) { - meta.textContent = i18nFormat("runtime.total", { count: 0 }); - container.innerHTML = `
${t("runtime.empty")}
`; + function setChatStage(item, payload) { + if (!item) return; + const stageEl = item.querySelector(".runtime-chat-stage"); + if (!stageEl) return; + const stage = payload && payload.stage ? String(payload.stage) : ""; + if (!stage) { + stageEl.hidden = true; + stageEl.textContent = ""; + stageEl.removeAttribute("title"); + delete stageEl.dataset.stageLabel; + delete stageEl.dataset.stageDetail; + delete stageEl.dataset.stageBaseMs; + delete stageEl.dataset.stageReceivedAtMs; + stageEl.classList.remove("is-final"); return; } - const parts = [i18nFormat("runtime.total", { count: items.length })]; - const queryText = String(queryInfo.q || "").trim(); - if (queryText) parts.push(`q=${queryText}`); - const topK = String(queryInfo.top_k || "").trim(); - if (topK) parts.push(`top_k=${topK}`); - const timeFrom = String(queryInfo.time_from || "").trim(); - if (timeFrom) parts.push(`from=${timeFrom}`); - const timeTo = String(queryInfo.time_to || "").trim(); - if (timeTo) parts.push(`to=${timeTo}`); - meta.textContent = parts.join(" · "); - container.innerHTML = items - .map((item) => { - const uuid = escapeHtml(item.uuid || ""); - const fact = escapeHtml(item.fact || ""); - const created = escapeHtml(item.created_at || ""); - return `
${uuid}
${created}
${fact}
`; - }) - .join(""); + const label = chatStageLabel(stage); + const detail = String((payload && payload.detail) || "").trim(); + const elapsedMs = Number(payload && payload.elapsed_ms); + const duration = Number.isFinite(elapsedMs) ? elapsedMs : 0; + stageEl.hidden = false; + stageEl.classList.toggle("is-final", !!(payload && payload.final)); + stageEl.dataset.stageLabel = label; + stageEl.dataset.stageDetail = detail; + stageEl.dataset.stageBaseMs = String(duration); + stageEl.dataset.stageReceivedAtMs = String(monotonicNowMs()); + stageEl.title = detail ? `${label} · ${detail}` : label; + updateChatStageDisplay(stageEl); + } - container.querySelectorAll(".memory-btn-edit").forEach((btn) => { - btn.addEventListener("click", () => - startEditMemory(btn.dataset.uuid), + function updateChatStageDisplay(stageEl) { + if (!stageEl || stageEl.hidden) return; + const label = String(stageEl.dataset.stageLabel || "").trim(); + if (!label) return; + const baseMs = Number(stageEl.dataset.stageBaseMs); + const receivedAtMs = Number(stageEl.dataset.stageReceivedAtMs); + const elapsedMs = stageEl.classList.contains("is-final") + ? baseMs + : baseMs + Math.max(0, monotonicNowMs() - receivedAtMs); + const duration = formatDurationMs(elapsedMs); + const nextText = duration ? `${label} · ${duration}` : label; + if (stageEl.textContent !== nextText) { + stageEl.textContent = nextText; + } + } + + function toolStatusLabel(block) { + if (block.uiHint === "webchat_private_send") { + return block.status === "done" + ? t("runtime.sent") + : block.status === "error" + ? t("runtime.error") + : t("runtime.sending"); + } + if (block.uiHint === "webchat_end" && block.status === "done") { + return t("runtime.ended"); + } + if (block.status === "done") return t("runtime.done"); + if (block.status === "error") return t("runtime.error"); + if (block.status === "cancelled") return t("runtime.cancelled"); + return t("runtime.running"); + } + + function toolDisplayLabel(block) { + if (block.uiHint === "webchat_private_send") { + return t("runtime.message"); + } + if (block.uiHint === "webchat_end") { + return t("runtime.end"); + } + return block.isAgent ? t("runtime.agent") : t("runtime.tool"); + } + + function formatToolPreview(raw) { + const text = String(raw || "").trim(); + if (!text) return { text: "", isStructured: false, value: null }; + try { + const parsed = JSON.parse(text); + return { + text, + isStructured: parsed !== null && typeof parsed === "object", + value: parsed, + }; + } catch (_error) { + try { + const normalized = text + .replace( + /([{,]\s*)'([^'\\]*(?:\\.[^'\\]*)*)'\s*:/g, + '$1"$2":', + ) + .replace( + /:\s*'([^'\\]*(?:\\.[^'\\]*)*)'(?=\s*[,}])/g, + ':"$1"', + ) + .replace( + /([\[,]\s*)'([^'\\]*(?:\\.[^'\\]*)*)'(?=\s*[\],])/g, + '$1"$2"', + ) + .replace(/\bNone\b/g, "null") + .replace(/\bTrue\b/g, "true") + .replace(/\bFalse\b/g, "false"); + const parsed = JSON.parse(normalized); + return { + text, + isStructured: parsed !== null && typeof parsed === "object", + value: parsed, + }; + } catch (_compatError) { + return { text, isStructured: false, value: null }; + } + } + } + + function renderStructuredToolValue(value) { + if (Array.isArray(value)) { + if (!value.length) { + return `[]`; + } + return ( + `
` + + value + .map( + (item, index) => + `
` + + `${index}` + + `
${renderStructuredToolValue(item)}
` + + `
`, + ) + .join("") + + `
` ); - }); - container.querySelectorAll(".memory-btn-delete").forEach((btn) => { - btn.addEventListener("click", () => deleteMemory(btn.dataset.uuid)); - }); + } + if (value && typeof value === "object") { + const entries = Object.entries(value); + if (!entries.length) { + return `{}`; + } + return ( + `
` + + entries + .map( + ([key, item]) => + `
` + + `${escapeHtml(key)}` + + `
${renderStructuredToolValue(item)}
` + + `
`, + ) + .join("") + + `
` + ); + } + if (typeof value === "boolean") { + return `${value ? "true" : "false"}`; + } + if (typeof value === "number") { + return `${escapeHtml(value)}`; + } + if (value === null || value === undefined) { + return `null`; + } + return `${renderChatContent(String(value), false)}`; } - function startEditMemory(uuid) { - const container = get("runtimeMemoryList"); - if (!container) return; - const itemEl = container.querySelector( - `.runtime-list-item[data-uuid="${CSS.escape(uuid)}"]`, + function renderToolPreviewSection(labelKey, raw, options = {}) { + const preview = formatToolPreview(raw); + if (!preview.text) return ""; + const label = t(labelKey); + const bodyClass = preview.isStructured + ? "runtime-tool-preview-body is-structured" + : "runtime-tool-preview-body"; + const body = preview.isStructured + ? `
${renderStructuredToolValue(preview.value)}
` + : `
${renderChatContent(preview.text, !!options.markdown)}
`; + return ( + `
` + + `
${escapeHtml(label)}
` + + body + + `
` ); - if (!itemEl) return; - const factEl = itemEl.querySelector(".runtime-list-fact"); - if (!factEl || factEl.dataset.editing === "true") return; + } - const currentText = factEl.textContent || ""; - factEl.dataset.editing = "true"; - factEl.innerHTML = ""; + function renderToolBlock(block) { + const label = toolDisplayLabel(block); + const statusLabel = toolStatusLabel(block); + const durationLabel = formatDurationMs(runningDurationMs(block)); + const callId = toolCallIdentity(block); + const stageLabel = block.currentStage + ? chatStageLabel(block.currentStage) + : ""; + const showLiveAgentStage = + block.isAgent && + stageLabel && + !["done", "error", "cancelled"].includes(block.status); + const metaLabel = showLiveAgentStage ? stageLabel : statusLabel; + const titleHtml = + `` + + `${escapeHtml(block.name || "--")}` + + `${escapeHtml(durationLabel)}` + + ``; + const args = renderToolPreviewSection( + "runtime.tool_input", + block.argumentsPreview, + { markdown: false }, + ); + const result = renderToolPreviewSection( + "runtime.tool_output", + block.resultPreview, + { markdown: true }, + ); + const timeline = Array.isArray(block.timeline) + ? block.timeline.map(renderToolTimelineItem).join("") + : ""; + const children = + !timeline && Array.isArray(block.children) + ? block.children.map((child) => renderToolBlock(child)).join("") + : ""; + const childContent = timeline || children; + const childHtml = childContent + ? `
${childContent}
` + : ""; + const openAttr = block.autoOpen ? " open" : ""; + const hintClass = block.uiHint + ? ` ${escapeHtml(String(block.uiHint).replace(/_/g, "-"))}` + : ""; + const kindClass = block.isAgent ? " is-agent" : " is-tool"; + return ( + `
` + + `${titleHtml}${escapeHtml(metaLabel)}${escapeHtml(label)}` + + args + + childHtml + + result + + `
` + ); + } - const textarea = document.createElement("textarea"); - textarea.className = "form-control memory-edit-area"; - textarea.value = currentText; + function renderToolTimelineItem(entry) { + if (!entry || typeof entry !== "object") return ""; + if (entry.type === "message") { + const content = String(entry.content || "").trim(); + if (!content) return ""; + return `
${renderChatContent(content, true)}
`; + } + if (entry.type === "stage") { + return ""; + } + if (entry.type === "call" && entry.call) { + return renderToolBlock(entry.call); + } + return ""; + } - const actions = document.createElement("div"); - actions.className = "memory-edit-actions"; - const saveBtn = document.createElement("button"); - saveBtn.className = "btn btn-sm"; - saveBtn.textContent = "保存"; - const cancelBtn = document.createElement("button"); - cancelBtn.className = "btn btn-sm"; - cancelBtn.textContent = "取消"; - actions.append(saveBtn, cancelBtn); - factEl.append(textarea, actions); - textarea.focus(); + function toolBlockKey(payload, blocks) { + return ( + String( + payload && payload.webchat_call_id + ? payload.webchat_call_id + : "", + ) || + String( + payload && payload.tool_call_id ? payload.tool_call_id : "", + ) || + String(payload && payload.name ? payload.name : "") || + `tool-${blocks.size + 1}` + ); + } - cancelBtn.addEventListener("click", () => { - delete factEl.dataset.editing; - factEl.innerHTML = ""; - factEl.textContent = currentText; - }); + function normalizeToolCallNode(node) { + if (!node || typeof node !== "object") return null; + const children = Array.isArray(node.children) + ? node.children.map(normalizeToolCallNode).filter(Boolean) + : []; + const timeline = Array.isArray(node.timeline) + ? node.timeline.map(normalizeHistoryTimelineNode).filter(Boolean) + : []; + return { + name: String(node.name || ""), + isAgent: !!node.is_agent, + status: String(node.status || "done"), + argumentsPreview: String(node.arguments_preview || ""), + resultPreview: String(node.result_preview || ""), + uiHint: String(node.ui_hint || ""), + durationMs: + node.duration_ms !== undefined + ? Number(node.duration_ms) + : undefined, + currentStage: String(node.current_stage || ""), + currentStageDetail: String(node.current_stage_detail || ""), + currentStageElapsedMs: + node.current_stage_elapsed_ms !== undefined + ? Number(node.current_stage_elapsed_ms) + : undefined, + children, + timeline, + autoOpen: false, + }; + } - saveBtn.addEventListener("click", () => - updateMemory(uuid, textarea.value), - ); + function normalizeHistoryTimelineNode(node) { + if (!node || typeof node !== "object") return null; + const type = String(node.type || "").trim(); + if (type === "message") { + return { + type, + content: String(node.content || ""), + }; + } + if (type === "stage") { + return { + type, + stage: String(node.stage || ""), + detail: String(node.detail || ""), + elapsedMs: + node.elapsed_ms !== undefined + ? Number(node.elapsed_ms) + : undefined, + stageElapsedMs: + node.stage_elapsed_ms !== undefined + ? Number(node.stage_elapsed_ms) + : undefined, + }; + } + if (type === "call") { + const call = normalizeToolCallNode(node.call); + return call ? { type, call } : null; + } + return null; + } - textarea.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - e.preventDefault(); - cancelBtn.click(); + function monotonicNowMs() { + return typeof performance !== "undefined" && + typeof performance.now === "function" + ? performance.now() + : Date.now(); + } + + function backendDurationClock(payload, field = "duration_ms") { + const durationMs = Number(payload && payload[field]); + if (!Number.isFinite(durationMs) || durationMs < 0) return null; + return { + baseMs: durationMs, + receivedAtMs: monotonicNowMs(), + }; + } + + function runningDurationMs(block) { + const baseMs = Number(block && block.durationBaseMs); + const receivedAtMs = Number(block && block.durationReceivedAtMs); + if (!Number.isFinite(baseMs) || baseMs < 0) { + return Number(block && block.durationMs); + } + if ( + ["done", "error", "cancelled"].includes(String(block.status || "")) + ) { + return baseMs; + } + if (!Number.isFinite(receivedAtMs) || receivedAtMs <= 0) { + return baseMs; + } + return Math.max(0, baseMs + monotonicNowMs() - receivedAtMs); + } + + function updateToolDurationDisplay(block) { + const identity = toolCallIdentity(block); + if (!identity) return; + const durationLabel = formatDurationMs(runningDurationMs(block)); + const selector = `[data-tool-duration-for="${CSS.escape(identity)}"]`; + document.querySelectorAll(selector).forEach((node) => { + if (node.textContent !== durationLabel) { + node.textContent = durationLabel; } - if (e.key === "Enter" && e.ctrlKey) { - e.preventDefault(); - saveBtn.click(); + const nextHidden = !durationLabel; + if (node.hidden !== nextHidden) { + node.hidden = nextHidden; } }); } - async function createMemory() { - if (_memoryMutating) return; - const input = get("memoryCreateInput"); - if (!input) return; - const fact = String(input.value || "").trim(); - if (!fact) { - showToast("记忆内容不能为空", "warning"); - return; - } - _memoryMutating = true; - const btn = get("btnMemoryCreate"); - if (btn) btn.disabled = true; - try { - const res = await api("/api/runtime/memory", { - method: "POST", - body: JSON.stringify({ fact }), - }); - const data = await parseJsonSafe(res); - if (!res.ok || (data && data.error)) { - throw new Error(buildRequestError(res, data)); + function isToolLifecycleStart(status) { + return status === "tool_start" || status === "agent_start"; + } + + function isToolLifecycleEnd(status) { + return status === "tool_end" || status === "agent_end"; + } + + function reduceToolBlock(blocks, payload, status) { + const key = toolBlockKey(payload, blocks); + if (!blocks.has(key) && payload && payload.tool_call_id) { + const nameKey = String(payload.name || ""); + if (nameKey && blocks.has(nameKey)) { + blocks.set(key, blocks.get(nameKey)); + blocks.delete(nameKey); } - showToast("记忆已添加", "success"); - input.value = ""; - await searchMemory(); - } catch (err) { - showToast(`添加失败: ${err.message || err}`, "error"); - } finally { - _memoryMutating = false; - if (btn) btn.disabled = false; } + const previous = blocks.get(key) || {}; + const isStart = isToolLifecycleStart(status); + const isEnd = isToolLifecycleEnd(status); + const isSnapshot = status === "tool_snapshot"; + const durationClock = backendDurationClock(payload); + const previousUiHint = String(previous.uiHint || ""); + const nextUiHint = String( + (payload && payload.ui_hint) || previousUiHint, + ); + const nextStatus = String((payload && payload.status) || "").trim(); + const nextArguments = String( + (payload && payload.arguments_preview) || + previous.argumentsPreview || + "", + ); + const block = { + ...previous, + webchatCallId: key, + name: String((payload && payload.name) || previous.name || ""), + isAgent: !!( + (payload && payload.is_agent) || + previous.isAgent || + status === "agent_start" || + status === "agent_end" + ), + status: + nextStatus || + (status === "tool_end" || status === "agent_end" + ? payload && payload.ok === false + ? "error" + : "done" + : "running"), + argumentsPreview: nextArguments, + resultPreview: String( + (payload && payload.result_preview) || + previous.resultPreview || + "", + ), + uiHint: nextUiHint, + durationMs: + durationClock && isEnd + ? durationClock.baseMs + : payload && payload.duration_ms !== undefined + ? Number(payload.duration_ms) + : previous.durationMs, + durationBaseMs: + durationClock && (isSnapshot || isEnd) + ? durationClock.baseMs + : isStart + ? 0 + : previous.durationBaseMs, + durationReceivedAtMs: + durationClock && (isSnapshot || isEnd) + ? durationClock.receivedAtMs + : isStart + ? monotonicNowMs() + : previous.durationReceivedAtMs, + backendStartedAt: Number( + (payload && payload.started_at) || + previous.backendStartedAt || + 0, + ), + currentStage: + isEnd && !(payload && payload.current_stage) + ? "" + : String( + (payload && payload.current_stage) || + previous.currentStage || + "", + ), + currentStageDetail: + isEnd && !(payload && payload.current_stage_detail) + ? "" + : String( + (payload && payload.current_stage_detail) || + previous.currentStageDetail || + "", + ), + currentStageElapsedMs: + isEnd && !(payload && payload.current_stage_elapsed_ms) + ? undefined + : payload && payload.current_stage_elapsed_ms !== undefined + ? Number(payload.current_stage_elapsed_ms) + : previous.currentStageElapsedMs, + autoOpen: isStart || isSnapshot ? true : !!previous.autoOpen, + localStartedAtMs: isStart + ? monotonicNowMs() + : previous.localStartedAtMs, + finishedAtMs: isEnd ? monotonicNowMs() : previous.finishedAtMs, + parentWebchatCallId: String( + (payload && payload.parent_webchat_call_id) || + previous.parentWebchatCallId || + "", + ), + children: Array.isArray(previous.children) ? previous.children : [], + timeline: Array.isArray(previous.timeline) ? previous.timeline : [], + }; + blocks.set(key, block); + return block; } - async function updateMemory(uuid, newFact) { - const fact = String(newFact || "").trim(); - if (!fact) { - showToast("记忆内容不能为空", "warning"); - return; + function topLevelToolKey(blocks, key) { + let currentKey = String(key || "").trim(); + const seen = new Set(); + while (currentKey && blocks.has(currentKey) && !seen.has(currentKey)) { + seen.add(currentKey); + const block = blocks.get(currentKey); + const parentKey = String(block.parentWebchatCallId || "").trim(); + if (!parentKey || !blocks.has(parentKey)) return currentKey; + currentKey = parentKey; } - if (_memoryMutating) return; - _memoryMutating = true; - try { - const res = await api( - `/api/runtime/memory/${encodeURIComponent(uuid)}`, - { - method: "PATCH", - body: JSON.stringify({ fact }), - }, - ); - const data = await parseJsonSafe(res); - if (!res.ok || (data && data.error)) { - throw new Error(buildRequestError(res, data)); + return currentKey || String(key || ""); + } + + function timelineToolKey(payload, blocks) { + return toolBlockKey(payload, blocks); + } + + function toolCallIdentity(block) { + if (!block) return ""; + return String(block.webchatCallId || block.name || "").trim(); + } + + function toolRenderSignature(block) { + if (!block) return ""; + const childSignature = Array.isArray(block.children) + ? block.children.map(toolRenderSignature).join("\u001e") + : ""; + const timelineSignature = Array.isArray(block.timeline) + ? block.timeline + .map((entry) => { + if (!entry || typeof entry !== "object") return ""; + if (entry.type === "call") { + return `call:${toolRenderSignature(entry.call)}`; + } + if (entry.type === "message") { + return `message:${String(entry.content || "")}`; + } + if (entry.type === "stage") { + return ["stage", entry.seq, entry.stage, entry.detail] + .map((value) => String(value || "")) + .join(":"); + } + return String(entry.type || ""); + }) + .join("\u001e") + : ""; + return [ + block.webchatCallId, + block.parentWebchatCallId, + block.name, + block.isAgent, + block.status, + block.autoOpen, + block.argumentsPreview, + block.resultPreview, + block.uiHint, + block.currentStage, + block.currentStageDetail, + childSignature, + timelineSignature, + ] + .map((value) => String(value || "")) + .join("\u001f"); + } + + function updateToolMetaDisplay(block) { + if (!block) return; + const identity = toolCallIdentity(block); + if (!identity) return; + updateToolDurationDisplay(block); + const statusLabel = toolStatusLabel(block); + const stageLabel = block.currentStage + ? chatStageLabel(block.currentStage) + : ""; + const showLiveAgentStage = + block.isAgent && + stageLabel && + !["done", "error", "cancelled"].includes(block.status); + const metaLabel = showLiveAgentStage ? stageLabel : statusLabel; + const selector = `[data-tool-status-for="${CSS.escape(identity)}"]`; + document.querySelectorAll(selector).forEach((node) => { + if (node.textContent !== metaLabel) { + node.textContent = metaLabel; } - showToast("记忆已更新", "success"); - await searchMemory(); - } catch (err) { - showToast(`更新失败: ${err.message || err}`, "error"); - } finally { - _memoryMutating = false; + }); + } + + function renderToolNodeIfChanged(node, block) { + if (!node || !block) return null; + const nextSignature = toolRenderSignature(block); + if (node.dataset.renderSignature === nextSignature) { + updateToolMetaDisplay(block); + return node; } + node.innerHTML = renderToolBlock(block); + node.dataset.renderSignature = nextSignature; + return node; } - async function deleteMemory(uuid) { - if (_memoryMutating) return; - if (!confirm(`确认删除记忆 ${uuid.slice(0, 8)}…?`)) return; - _memoryMutating = true; - try { - const res = await api( - `/api/runtime/memory/${encodeURIComponent(uuid)}`, - { - method: "DELETE", - }, + function appendToolTimelineEntry(parent, entry) { + if (!parent || !entry) return; + const timeline = Array.isArray(parent.timeline) ? parent.timeline : []; + if (entry.type === "call" && entry.call) { + const identity = toolCallIdentity(entry.call); + const existingIndex = timeline.findIndex( + (item) => + item.type === "call" && + toolCallIdentity(item.call) === identity, ); - const data = await parseJsonSafe(res); - if (!res.ok || (data && data.error)) { - throw new Error(buildRequestError(res, data)); + if (existingIndex >= 0) { + timeline[existingIndex] = entry; + } else { + timeline.push(entry); } - showToast("记忆已删除", "success"); - await searchMemory(); - } catch (err) { - showToast(`删除失败: ${err.message || err}`, "error"); - } finally { - _memoryMutating = false; + parent.timeline = timeline; + return; + } + if (entry.type === "stage") { + const entrySeq = Number(entry.seq); + const existingIndex = timeline.findIndex( + (item) => + item.type === "stage" && + Number(item.seq) === entrySeq && + String(item.stage || "") === String(entry.stage || ""), + ); + if (existingIndex >= 0) { + timeline[existingIndex] = entry; + } else { + timeline.push(entry); + } + parent.timeline = timeline; + return; } + timeline.push(entry); + parent.timeline = timeline; } - function setListMessage(metaId, listId, message) { - const meta = get(metaId); - const list = get(listId); - const msg = String(message || "").trim() || t("runtime.empty"); - if (meta) meta.textContent = msg; - if (list) { - list.innerHTML = `
${escapeHtml(msg)}
`; - } + function reduceAgentStageBlock(blocks, payload, seq = 0) { + const key = toolBlockKey(payload, blocks); + const previous = blocks.get(key) || {}; + const parentCandidate = String( + (payload && payload.parent_webchat_call_id) || + previous.parentWebchatCallId || + "", + ).trim(); + const parentKey = parentCandidate === key ? "" : parentCandidate; + const stage = String((payload && payload.stage) || "").trim(); + const block = { + ...previous, + webchatCallId: key, + name: String( + (payload && (payload.agent_name || payload.name)) || + previous.name || + "", + ), + isAgent: true, + status: String( + (payload && payload.status) || previous.status || "running", + ), + argumentsPreview: previous.argumentsPreview || "", + resultPreview: previous.resultPreview || "", + uiHint: previous.uiHint || "", + currentStage: stage || previous.currentStage || "", + currentStageDetail: String( + (payload && payload.detail) || + previous.currentStageDetail || + "", + ), + currentStageElapsedMs: + payload && payload.stage_elapsed_ms !== undefined + ? Number(payload.stage_elapsed_ms) + : previous.currentStageElapsedMs, + durationMs: previous.durationMs, + durationBaseMs: previous.durationBaseMs, + durationReceivedAtMs: previous.durationReceivedAtMs, + backendStartedAt: previous.backendStartedAt, + autoOpen: !!previous.autoOpen, + parentWebchatCallId: parentKey, + children: Array.isArray(previous.children) ? previous.children : [], + timeline: Array.isArray(previous.timeline) ? previous.timeline : [], + }; + blocks.set(key, block); + return block; } - function renderCognitiveItems(metaId, listId, payload) { - const meta = get(metaId); - const list = get(listId); - if (!meta || !list) return; - const items = - payload && Array.isArray(payload.items) ? payload.items : []; - const count = Number.isFinite(Number(payload && payload.count)) - ? Number(payload.count) - : items.length; - meta.textContent = i18nFormat("runtime.total", { count }); - if (!items.length) { - list.innerHTML = `
${t("runtime.empty")}
`; - return; + function agentStageRenderSignature(block) { + if (!block) return ""; + return [ + block.webchatCallId, + block.parentWebchatCallId, + block.name, + block.status, + block.currentStage, + block.currentStageDetail, + ] + .map((value) => String(value || "")) + .join("\u001f"); + } + + function redrawToolTimelineNode(item, blocks, key) { + const timeline = ensureTimelineNodeContainer(item); + if (!timeline) return null; + const rootKey = topLevelToolKey(blocks, key); + const root = blocks.get(rootKey); + if (!root) return null; + let node = timeline.querySelector( + `[data-tool-key="${CSS.escape(rootKey)}"]`, + ); + if (!node) { + node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = rootKey; + timeline.appendChild(node); } + return renderToolNodeIfChanged(node, root); + } - const preferredMetaKeys = [ - "timestamp_local", - "request_type", - "group_id", - "user_id", - "sender_id", - "entity_type", - "entity_id", - "request_id", - ]; + function scheduleToolAutoCollapse(item, blocks, key, block) { + if (!item || !block || block.status === "running") return; + const timerKey = String(key || "").trim(); + if (!timerKey) return; + if (runtimeState.toolCollapseTimers.has(timerKey)) { + clearTimeout(runtimeState.toolCollapseTimers.get(timerKey)); + runtimeState.toolCollapseTimers.delete(timerKey); + } + const collapse = () => { + runtimeState.toolCollapseTimers.delete(timerKey); + const latest = blocks.get(timerKey); + if (!latest) return; + latest.autoOpen = false; + redrawToolTimelineNode(item, blocks, timerKey); + }; + runtimeState.toolCollapseTimers.set( + timerKey, + setTimeout(collapse, TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS), + ); + } - list.innerHTML = items - .map((item, index) => { - const doc = escapeHtml( - String((item && item.document) || "").trim(), + function upsertTimelineToolBlock(item, blocks, payload, status) { + if (!item) return null; + const key = timelineToolKey(payload, blocks); + const previousRootKey = topLevelToolKey(blocks, key); + const previousRoot = blocks.get(previousRootKey); + const previousSignature = toolRenderSignature(previousRoot); + const block = reduceToolBlock(blocks, payload, status); + const timeline = ensureTimelineNodeContainer(item); + if (!timeline) return null; + const parentKey = String( + (payload && payload.parent_webchat_call_id) || "", + ).trim(); + if (parentKey && blocks.has(parentKey)) { + const parent = blocks.get(parentKey); + const previousParentSignature = toolRenderSignature(parent); + const blockIdentity = toolCallIdentity(block); + const siblings = Array.isArray(parent.children) + ? parent.children.filter( + (child) => toolCallIdentity(child) !== blockIdentity, + ) + : []; + parent.children = [...siblings, block]; + appendToolTimelineEntry(parent, { type: "call", call: block }); + const nextParentSignature = toolRenderSignature(parent); + const parentNode = timeline.querySelector( + `[data-tool-key="${CSS.escape(parentKey)}"]`, + ); + if (parentNode) { + if ( + status === "tool_snapshot" && + previousParentSignature === nextParentSignature + ) { + updateToolMetaDisplay(block); + updateToolMetaDisplay(parent); + return parentNode; + } + renderToolNodeIfChanged(parentNode, parent); + if (isToolLifecycleEnd(status)) { + scheduleToolAutoCollapse(item, blocks, key, block); + } + return parentNode; + } + const rootKey = topLevelToolKey(blocks, parentKey); + const root = blocks.get(rootKey); + const rootNode = timeline.querySelector( + `[data-tool-key="${CSS.escape(rootKey)}"]`, + ); + if (root && rootNode) { + const previousRootSignature = + rootNode.dataset.renderSignature || + toolRenderSignature(root); + const nextRootSignature = toolRenderSignature(root); + if ( + status === "tool_snapshot" && + previousRootSignature === nextRootSignature + ) { + updateToolMetaDisplay(block); + updateToolMetaDisplay(root); + return rootNode; + } + renderToolNodeIfChanged(rootNode, root); + if (isToolLifecycleEnd(status)) { + scheduleToolAutoCollapse(item, blocks, key, block); + } + return rootNode; + } + } + let node = timeline.querySelector( + `[data-tool-key="${CSS.escape(key)}"]`, + ); + if (!node) { + node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = key; + timeline.appendChild(node); + } + if (status === "tool_snapshot" && previousSignature) { + const nextSignature = toolRenderSignature(block); + if (previousSignature === nextSignature) { + updateToolMetaDisplay(block); + return node; + } + } + renderToolNodeIfChanged(node, block); + if (isToolLifecycleEnd(status)) { + scheduleToolAutoCollapse(item, blocks, key, block); + } + return node; + } + + function appendNestedTimelineMessage(item, blocks, payload, content) { + const parentKey = String( + (payload && payload.parent_webchat_call_id) || "", + ).trim(); + if (!parentKey || !blocks.has(parentKey)) return false; + const parent = blocks.get(parentKey); + appendToolTimelineEntry(parent, { + type: "message", + content, + }); + redrawToolTimelineNode(item, blocks, parentKey); + appendRawChatContent(item, content); + return true; + } + + function upsertToolBlock(payload, status, jobId = "") { + const item = ensureStreamingMessage(jobId); + if (!item) return; + upsertTimelineToolBlock(item, runtimeState.toolBlocks, payload, status); + scrollChatToBottomSoon(); + } + + function upsertToolSnapshot(payload, jobId = "") { + const item = ensureStreamingMessage(jobId); + if (!item) return; + upsertTimelineToolBlock( + item, + runtimeState.toolBlocks, + payload, + "tool_snapshot", + ); + } + + function upsertAgentStageBlock(payload, jobId = "", seq = 0) { + const item = ensureStreamingMessage(jobId); + if (!item) return; + const blocks = runtimeState.toolBlocks; + const key = timelineToolKey(payload, blocks); + const previousSignature = agentStageRenderSignature(blocks.get(key)); + const block = reduceAgentStageBlock(blocks, payload, seq); + if ( + previousSignature && + previousSignature === agentStageRenderSignature(block) + ) { + return; + } + const parentKey = String(block.parentWebchatCallId || "").trim(); + const timeline = ensureTimelineNodeContainer(item); + if (!timeline) return; + if (parentKey && blocks.has(parentKey)) { + const parent = blocks.get(parentKey); + const previousParentSignature = toolRenderSignature(parent); + const blockIdentity = toolCallIdentity(block); + const siblings = Array.isArray(parent.children) + ? parent.children.filter( + (child) => toolCallIdentity(child) !== blockIdentity, + ) + : []; + parent.children = [...siblings, block]; + appendToolTimelineEntry(parent, { type: "call", call: block }); + const nextParentSignature = toolRenderSignature(parent); + if (previousParentSignature === nextParentSignature) { + updateToolMetaDisplay(block); + updateToolMetaDisplay(parent); + } else { + redrawToolTimelineNode(item, blocks, parentKey); + } + } else { + let node = timeline.querySelector( + `[data-tool-key="${CSS.escape(key)}"]`, + ); + if (!node) { + node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = key; + timeline.appendChild(node); + } + renderToolNodeIfChanged(node, block); + } + scrollChatToBottomSoon(); + } + + function historyWebchatEvents(item) { + const webchat = item && item.webchat; + const events = + webchat && Array.isArray(webchat.events) ? webchat.events : []; + return events.filter((entry) => { + const event = entry && String(entry.event || ""); + return ( + event === "tool_start" || + event === "tool_end" || + event === "agent_start" || + event === "agent_end" || + event === "agent_stage" || + event === "message" + ); + }); + } + + function renderHistoryTimeline(item, message) { + const events = historyWebchatEvents(item); + if (!message || !events.length) return false; + const calls = + item && item.webchat && Array.isArray(item.webchat.calls) + ? item.webchat.calls + : []; + const timelineItems = + item && item.webchat && Array.isArray(item.webchat.timeline) + ? item.webchat.timeline + : []; + if (timelineItems.length) { + const timeline = ensureTimelineNodeContainer(message); + if (timeline) { + timelineItems + .map(normalizeHistoryTimelineNode) + .filter(Boolean) + .forEach((entry, index) => { + if (entry.type === "message") { + appendTimelineMessage( + message, + entry.content, + "bot", + ); + return; + } + if (entry.type !== "call" || !entry.call) return; + const node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = `history-call-${index}`; + node.innerHTML = renderToolBlock(entry.call); + timeline.appendChild(node); + }); + } + return true; + } + if (calls.length) { + const timeline = ensureTimelineNodeContainer(message); + if (timeline) { + calls + .map(normalizeToolCallNode) + .filter(Boolean) + .forEach((block, index) => { + const node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = `history-call-${index}`; + node.innerHTML = renderToolBlock(block); + timeline.appendChild(node); + }); + } + events + .filter((entry) => entry.event === "message") + .forEach((entry) => { + appendTimelineMessage( + message, + entry.payload && + (entry.payload.content ?? entry.payload.message), + "bot", + ); + }); + return true; + } + const blocks = new Map(); + events.forEach((entry) => { + if (entry.event === "message") { + appendTimelineMessage( + message, + entry.payload && + (entry.payload.content ?? entry.payload.message), + "bot", ); - const md = - item && typeof item.metadata === "object" && item.metadata - ? item.metadata - : {}; - const dist = formatNumeric(item && item.distance); - const rerank = formatNumeric(item && item.rerank_score); - const timestamp = escapeHtml( - String(md.timestamp_local || "").trim(), + return; + } + upsertTimelineToolBlock( + message, + blocks, + entry.payload || {}, + entry.event, + ); + }); + return true; + } + + function appendHistoryChatItem(item, options = {}) { + const role = item && item.role === "bot" ? "bot" : "user"; + const content = String((item && item.content) || "").trim(); + const attachmentMarkup = buildAttachmentMarkup( + item && item.attachments, + ); + const hasTimeline = + role === "bot" && historyWebchatEvents(item).length > 0; + if (!content && !hasTimeline && !attachmentMarkup) return null; + const message = appendChatMessage(role, content, options); + if (!message) return null; + if (hasTimeline) { + const contentEl = message.querySelector(".runtime-chat-content"); + if (contentEl) contentEl.innerHTML = ""; + renderHistoryTimeline(item, message); + if (!message.dataset.rawContent && content) { + appendTimelineMessage(message, content, role); + } + } + if (attachmentMarkup) { + const contentEl = message.querySelector(".runtime-chat-content"); + if (contentEl) { + contentEl.insertAdjacentHTML("beforeend", attachmentMarkup); + } + } + const webchat = item && item.webchat; + const durationMs = Number(webchat && webchat.duration_ms); + if (role === "bot" && Number.isFinite(durationMs) && durationMs >= 0) { + setChatStage(message, { + stage: "done", + elapsed_ms: durationMs, + final: true, + }); + } + if (!content && !attachmentMarkup && !message.dataset.rawContent) { + message.classList.add("tool-only"); + } + return message; + } + + function clearChatMessages() { + const log = get("runtimeChatLog"); + if (!log) return; + clearToolCollapseTimers(); + log.innerHTML = ""; + runtimeState.streamingMessageId = null; + runtimeState.activeChatMessageId = null; + runtimeState.toolBlocks.clear(); + stopChatClock(); + } + + function parseCqAttributes(raw) { + const attrs = {}; + String(raw || "") + .split(",") + .forEach((part) => { + const idx = part.indexOf("="); + if (idx <= 0) return; + const key = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim(); + if (!key) return; + attrs[key] = value; + }); + return attrs; + } + + function resolveCqImageSource(attrs) { + const raw = String((attrs && (attrs.url || attrs.file)) || "").trim(); + if (!raw) return ""; + if (raw.startsWith("base64://")) { + const payload = raw.slice("base64://".length).trim(); + return payload ? `data:image/png;base64,${payload}` : ""; + } + if (raw.startsWith("file://")) { + const localPath = raw.slice("file://".length).trim(); + return localPath + ? `/api/runtime/chat/image?path=${encodeURIComponent(localPath)}` + : ""; + } + if (raw.startsWith("/") || /^[A-Za-z]:[\\/]/.test(raw)) { + return `/api/runtime/chat/image?path=${encodeURIComponent(raw)}`; + } + if ( + raw.startsWith("http://") || + raw.startsWith("https://") || + raw.startsWith("data:image/") + ) { + return raw; + } + return ""; + } + + function chatImageMarkup(source, alt = "") { + const src = String(source || "").trim(); + if (!src) return ""; + const label = + String(alt || "").trim() || t("runtime.image_preview") || "image"; + return `${escapeHtml(label)}`; + } + + function formatFileSize(bytes) { + const n = Number(bytes); + if (!Number.isFinite(n) || n <= 0) return ""; + if (n < 1024) return n + "B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB"; + return (n / 1024 / 1024).toFixed(2) + "MB"; + } + + function fileKind(file) { + const type = String((file && file.type) || "").toLowerCase(); + return type.startsWith("image/") ? "image" : "file"; + } + + function formatAttachmentName(file) { + return ( + String((file && file.name) || "attachment").trim() || "attachment" + ); + } + + function renderPendingChatAttachments() { + const container = get("runtimeChatAttachments"); + if (!container) return; + const inputRow = container.closest(".runtime-chat-input-row"); + if (!runtimeState.chatAttachments.length) { + container.hidden = true; + container.innerHTML = ""; + if (inputRow) { + inputRow.classList.remove( + "has-attachments", + "is-attachment-rail-full", + "is-attachment-compressed", ); - const headLabel = timestamp || `#${index + 1}`; - const tags = []; - if (dist) - tags.push( - `distance ${dist}`, + inputRow.style.setProperty( + "--chat-attachment-rail-width", + "0px", + ); + inputRow.style.setProperty( + "--chat-attachment-card-width", + `${CHAT_ATTACHMENT_CARD_MAX_WIDTH}px`, + ); + } + return; + } + container.hidden = false; + if (inputRow) { + const count = runtimeState.chatAttachments.length; + const width = Math.min( + CHAT_ATTACHMENT_RAIL_MAX_WIDTH, + CHAT_ATTACHMENT_RAIL_BASE_WIDTH + + count * CHAT_ATTACHMENT_RAIL_STEP_WIDTH, + ); + const gapWidth = + count >= CHAT_ATTACHMENT_COMPRESSED_COUNT + ? CHAT_ATTACHMENT_COMPRESSED_GAP_WIDTH + : CHAT_ATTACHMENT_GAP_WIDTH; + const cardWidth = Math.max( + CHAT_ATTACHMENT_CARD_MIN_WIDTH, + Math.min( + CHAT_ATTACHMENT_CARD_MAX_WIDTH, + Math.floor( + (width - Math.max(0, count - 1) * gapWidth) / count, + ), + ), + ); + inputRow.classList.toggle("has-attachments", count > 0); + inputRow.classList.toggle( + "is-attachment-rail-full", + width >= CHAT_ATTACHMENT_RAIL_MAX_WIDTH, + ); + inputRow.classList.toggle( + "is-attachment-compressed", + count >= CHAT_ATTACHMENT_COMPRESSED_COUNT, + ); + inputRow.style.setProperty( + "--chat-attachment-rail-width", + `${width}px`, + ); + inputRow.style.setProperty( + "--chat-attachment-card-width", + `${cardWidth}px`, + ); + } + container.innerHTML = runtimeState.chatAttachments + .map((item) => { + const kindLabel = + item.kind === "image" + ? t("runtime.attachment_kind_image") + : t("runtime.attachment_kind_file"); + const preview = item.previewUrl + ? `` + : ``; + return ( + `
` + + `${preview}` + + `` + + `${escapeHtml(item.name)}` + + `${escapeHtml(kindLabel)}${item.sizeLabel ? ` · ${escapeHtml(item.sizeLabel)}` : ""}` + + `` + + `` + + `
` + ); + }) + .join(""); + container + .querySelectorAll("[data-attachment-remove]") + .forEach((button) => { + button.addEventListener("click", () => { + const id = String( + button.getAttribute("data-attachment-remove") || "", ); - if (rerank) - tags.push( - `rerank ${rerank}`, + const removed = runtimeState.chatAttachments.find( + (item) => item.id === id, ); + if (removed && removed.previewUrl) { + URL.revokeObjectURL(removed.previewUrl); + } + runtimeState.chatAttachments = + runtimeState.chatAttachments.filter( + (item) => item.id !== id, + ); + renderPendingChatAttachments(); + }); + }); + } - const metaRows = preferredMetaKeys - .filter( - (key) => - md[key] !== undefined && - md[key] !== null && - String(md[key]).trim() !== "", - ) - .map((key) => { - const raw = md[key]; - const text = - raw && typeof raw === "object" - ? JSON.stringify(raw) - : String(raw); - return `${escapeHtml(key)}${escapeHtml(text)}`; - }) - .join(""); + function addChatFiles(files, { source = "picker" } = {}) { + const selected = Array.from(files || []).filter(Boolean); + if (!selected.length) return 0; + const added = []; + for (const file of selected) { + const name = formatAttachmentName(file); + const size = Number(file.size || 0); + const kind = fileKind(file); + added.push({ + id: `att-${Date.now()}-${runtimeState.chatAttachmentSeq++}`, + file, + kind, + name, + previewUrl: kind === "image" ? URL.createObjectURL(file) : "", + size, + sizeLabel: formatFileSize(size), + source, + }); + } + runtimeState.chatAttachments.push(...added); + renderPendingChatAttachments(); + const messageKey = + added.length === 1 + ? "runtime.attachment_added" + : "runtime.attachments_added"; + showToast( + i18nFormat(messageKey, { count: added.length }), + "success", + 1800, + ); + return added.length; + } - return `
-
${headLabel}
${tags.join("")}
-
${doc || "--"}
- ${metaRows ? `
${metaRows}
` : ""} -
`; + function clearChatAttachments() { + runtimeState.chatAttachments.forEach((item) => { + if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); + }); + runtimeState.chatAttachments = []; + renderPendingChatAttachments(); + } + + function normalizeReferenceText(text) { + return String(text || "") + .replace(/\r\n?/g, "\n") + .replace(/\n{4,}/g, "\n\n\n") + .trim(); + } + + function truncateReferenceText(text, maxChars = CHAT_REFERENCE_MAX_CHARS) { + const value = normalizeReferenceText(text); + if (value.length <= maxChars) return value; + return `${value.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; + } + + function referencePreview(text) { + const value = normalizeReferenceText(text).replace(/\s+/g, " "); + if (value.length <= CHAT_REFERENCE_PREVIEW_CHARS) return value; + return `${value.slice(0, CHAT_REFERENCE_PREVIEW_CHARS - 1).trimEnd()}…`; + } + + function renderPendingChatReferences() { + const container = get("runtimeChatReferences"); + if (!container) return; + if (!runtimeState.chatReferences.length) { + container.hidden = true; + container.innerHTML = ""; + return; + } + container.hidden = false; + container.innerHTML = runtimeState.chatReferences + .map((item) => { + const label = messageQuoteSourceLabel(item.type); + const preview = referencePreview(item.text); + return ( + `
` + + `` + + `` + + `${escapeHtml(label)}` + + `${escapeHtml(preview)}` + + `` + + `` + + `
` + ); + }) + .join(""); + container + .querySelectorAll("[data-reference-remove]") + .forEach((button) => { + button.addEventListener("click", () => { + const id = String( + button.getAttribute("data-reference-remove") || "", + ); + runtimeState.chatReferences = + runtimeState.chatReferences.filter( + (item) => item.id !== id, + ); + renderPendingChatReferences(); + }); + }); + } + + function addChatReference({ type = "message", text = "" } = {}) { + const value = truncateReferenceText(text); + if (!value) return false; + runtimeState.chatReferences.push({ + id: `ref-${Date.now()}-${runtimeState.chatReferenceSeq++}`, + type, + text: value, + }); + renderPendingChatReferences(); + showToast(t("runtime.reference_added"), "success", 1600); + const input = get("runtimeChatInput"); + if (input) input.focus(); + return true; + } + + function clearChatReferences() { + runtimeState.chatReferences = []; + renderPendingChatReferences(); + } + + function formatChatReferencesAsMarkdown(references) { + const items = Array.isArray(references) ? references : []; + if (!items.length) return ""; + return items + .map((item) => { + const label = messageQuoteSourceLabel(item.type); + const lines = normalizeReferenceText(item.text).split("\n"); + return [`> ${label}:`, ...lines.map((line) => `> ${line}`)] + .join("\n") + .trim(); + }) + .filter(Boolean) + .join("\n\n"); + } + + function buildChatMessageWithReferences(message, references) { + const quote = formatChatReferencesAsMarkdown(references); + const body = String(message || "").trim(); + return [quote, body].filter(Boolean).join("\n\n").trim(); + } + + function chatMessageTextForQuote(item) { + if (!item) return ""; + const raw = String(item.dataset.rawContent || "").trim(); + if (raw) return raw; + const content = item.querySelector(".runtime-chat-content"); + if (content) return normalizeReferenceText(content.innerText || ""); + const timeline = item.querySelector(".runtime-chat-timeline"); + return timeline ? normalizeReferenceText(timeline.innerText || "") : ""; + } + + function hideSelectionQuoteButton() { + if (runtimeState.selectionQuoteButton) { + runtimeState.selectionQuoteButton.hidden = true; + } + runtimeState.pendingSelectionReference = null; + } + + function ensureSelectionQuoteButton() { + if (runtimeState.selectionQuoteButton) { + return runtimeState.selectionQuoteButton; + } + const button = document.createElement("button"); + button.className = "runtime-chat-selection-quote"; + button.type = "button"; + button.textContent = t("runtime.quote_selection"); + button.hidden = true; + button.addEventListener("click", () => { + const text = runtimeState.pendingSelectionReference; + if (text) addChatReference({ type: "selection", text }); + hideSelectionQuoteButton(); + }); + document.body.appendChild(button); + runtimeState.selectionQuoteButton = button; + return button; + } + + function maybeShowSelectionQuoteButton() { + const selection = window.getSelection ? window.getSelection() : null; + const text = normalizeReferenceText( + selection ? selection.toString() : "", + ); + if (!selection || !text) { + hideSelectionQuoteButton(); + return; + } + const log = get("runtimeChatLog"); + const anchorNode = selection.anchorNode; + const focusNode = selection.focusNode; + const anchorElement = + anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE + ? anchorNode + : anchorNode && anchorNode.parentElement; + const focusElement = + focusNode && focusNode.nodeType === Node.ELEMENT_NODE + ? focusNode + : focusNode && focusNode.parentElement; + const anchorMessage = + anchorElement && anchorElement.closest(".runtime-chat-item.bot"); + const focusMessage = + focusElement && focusElement.closest(".runtime-chat-item.bot"); + if (!log || !anchorMessage || anchorMessage !== focusMessage) { + hideSelectionQuoteButton(); + return; + } + const range = selection.rangeCount ? selection.getRangeAt(0) : null; + if (!range) { + hideSelectionQuoteButton(); + return; + } + const rect = range.getBoundingClientRect(); + const button = ensureSelectionQuoteButton(); + runtimeState.pendingSelectionReference = text; + button.textContent = t("runtime.quote_selection"); + button.hidden = false; + button.style.left = `${Math.max(12, Math.min(rect.left + rect.width / 2, window.innerWidth - 12))}px`; + button.style.top = `${Math.max(12, rect.top - 38)}px`; + } + + function renderFileCard(attrs) { + const fileId = escapeHtml(String(attrs.id || "").trim()); + const name = escapeHtml(String(attrs.name || "file").trim()); + const size = formatFileSize(attrs.size); + if (!fileId) return `[file]`; + const href = `/api/runtime/chat/file?id=${encodeURIComponent(fileId)}`; + return ( + `
` + + `
📄
` + + `
` + + `
${name}
` + + (size ? `
${size}
` : "") + + `
` + + `${t("runtime.download") || "Download"}` + + `
` + ); + } + + function isSafeRenderedUrl(url) { + const text = String(url || "").trim(); + if (!text) return false; + try { + const parsed = new URL(text, window.location.origin); + return ["http:", "https:", "mailto:"].includes(parsed.protocol); + } catch (_error) { + return false; + } + } + + function isSafeRenderedImageUrl(url) { + const text = String(url || "").trim(); + if (!text) return false; + try { + const parsed = new URL(text, window.location.origin); + return ["http:", "https:"].includes(parsed.protocol); + } catch (_error) { + return false; + } + } + + const SAFE_HTML_TAGS = new Set([ + "a", + "article", + "aside", + "b", + "blockquote", + "br", + "caption", + "code", + "del", + "details", + "div", + "em", + "footer", + "header", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "img", + "kbd", + "li", + "main", + "nav", + "mark", + "ol", + "p", + "pre", + "s", + "section", + "small", + "span", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "u", + "ul", + ]); + const DROP_HTML_TAGS = new Set([ + "canvas", + "embed", + "form", + "head", + "iframe", + "input", + "link", + "math", + "meta", + "object", + "script", + "style", + "svg", + "template", + "title", + "video", + ]); + const STANDALONE_HTML_ROOT_TAGS = new Set([ + "article", + "aside", + "blockquote", + "body", + "details", + "div", + "footer", + "header", + "html", + "main", + "nav", + "ol", + "p", + "section", + "table", + "ul", + ]); + + function sanitizeIntegerAttribute(element, name, min, max) { + const value = Number.parseInt(element.getAttribute(name) || "", 10); + if (!Number.isFinite(value) || value < min || value > max) { + element.removeAttribute(name); + return; + } + element.setAttribute(name, String(value)); + } + + function sanitizeHtmlElement(element) { + const tag = element.tagName.toLowerCase(); + [...element.attributes].forEach((attr) => { + const name = attr.name.toLowerCase(); + if (name.startsWith("on") || name === "style") { + element.removeAttribute(attr.name); + return; + } + if (name === "href" && tag === "a") { + if (!isSafeRenderedUrl(attr.value)) { + element.removeAttribute(attr.name); + return; + } + element.setAttribute("rel", "noreferrer"); + return; + } + if (name === "src" && tag === "img") { + if (!isSafeRenderedImageUrl(attr.value)) { + element.remove(); + return; + } + element.classList.add("runtime-chat-image"); + element.setAttribute("loading", "lazy"); + element.setAttribute("data-chat-image-preview", "1"); + element.setAttribute("title", t("runtime.open_image_preview")); + return; + } + if (["alt", "title"].includes(name)) return; + if ( + ["th", "td"].includes(tag) && + ["colspan", "rowspan"].includes(name) + ) { + sanitizeIntegerAttribute(element, name, 1, 20); + return; + } + if (tag === "ol" && name === "start") { + sanitizeIntegerAttribute(element, name, 1, 9999); + return; + } + element.removeAttribute(attr.name); + }); + } + + function sanitizeHtmlNode(node) { + if (node.nodeType === Node.TEXT_NODE) return; + if (node.nodeType !== Node.ELEMENT_NODE) { + node.remove(); + return; + } + const element = node; + const tag = element.tagName.toLowerCase(); + if (DROP_HTML_TAGS.has(tag)) { + element.remove(); + return; + } + if (!SAFE_HTML_TAGS.has(tag)) { + [...element.childNodes].forEach(sanitizeHtmlNode); + const parent = element.parentNode; + if (!parent) { + element.remove(); + return; + } + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + element.remove(); + return; + } + sanitizeHtmlElement(element); + [...element.childNodes].forEach(sanitizeHtmlNode); + } + + function sanitizeHtmlSnippet(html) { + const raw = String(html || ""); + if (!raw.trim() || typeof document === "undefined") + return escapeHtml(raw); + const template = document.createElement("template"); + template.innerHTML = raw; + [...template.content.childNodes].forEach(sanitizeHtmlNode); + return template.innerHTML; + } + + function looksLikeStandaloneHtml(text) { + const raw = String(text || "").trim(); + if (!raw || !raw.includes("<") || !raw.includes(">")) return false; + if (/^```/.test(raw)) return false; + if (/^]*>/i); + if (!firstTag) return false; + const tag = firstTag[1].toLowerCase(); + if (tag === "html" || tag === "body") return true; + if (!STANDALONE_HTML_ROOT_TAGS.has(tag)) return false; + return new RegExp(`\\s*$`, "i").test(raw); + } + + const CODE_LANGUAGE_ALIASES = { + c: "c", + cc: "cpp", + cjs: "javascript", + cs: "csharp", + htm: "xml", + html: "xml", + js: "javascript", + jsonc: "json", + jsx: "javascript", + md: "markdown", + mjs: "javascript", + plaintext: "plaintext", + plain: "plaintext", + py: "python", + sh: "bash", + shell: "bash", + ts: "typescript", + tsx: "typescript", + txt: "plaintext", + vue: "xml", + xhtml: "xml", + yml: "yaml", + }; + + function normalizeCodeLanguage(language) { + const raw = String(language || "") + .trim() + .toLowerCase() + .replace(/^language-/, "") + .split(/\s+/)[0] + .replace(/[^a-z0-9_+#.-]/g, ""); + return CODE_LANGUAGE_ALIASES[raw] || raw || "text"; + } + + function highlightCodeBlock(code, language) { + const lang = normalizeCodeLanguage(language); + if (lang === "text" || lang === "plaintext") { + return escapeHtml(code); + } + if (typeof hljs === "undefined") { + return escapeHtml(code); + } + try { + if (hljs.getLanguage && hljs.getLanguage(lang)) { + return hljs.highlight(code, { + ignoreIllegals: true, + language: lang, + }).value; + } + return hljs.highlightAuto(code).value; + } catch (_e) { + return escapeHtml(code); + } + } + + function isRunnableHtmlCode(code, language) { + const lang = normalizeCodeLanguage(language); + if (["html", "xml", "xhtml"].includes(lang)) return true; + const raw = String(code || "").trim(); + if (!raw) return false; + return ( + /^/i.test(raw)) + ); + } + + function codeBlockLanguageLabel(language) { + const lang = normalizeCodeLanguage(language); + return lang === "text" ? "code" : lang; + } + + function shouldCollapseCodeBlock(code) { + const lines = String(code || "").split(/\r?\n/).length; + return lines > CODE_COLLAPSE_LINE_THRESHOLD; + } + + function createSafeMarkedRenderer() { + if (typeof marked === "undefined" || !marked.Renderer) return null; + const renderer = new marked.Renderer(); + renderer.html = ({ text }) => sanitizeHtmlSnippet(text || ""); + renderer.code = (token, legacyLanguage) => { + const codeText = + token && typeof token === "object" + ? String(token.text || "") + : String(token || ""); + const language = + token && typeof token === "object" + ? token.lang + : legacyLanguage; + const normalizedLanguage = normalizeCodeLanguage(language); + const encodedCode = encodeURIComponent(codeText); + const canRunHtml = isRunnableHtmlCode(codeText, normalizedLanguage); + const languageClass = + normalizedLanguage && normalizedLanguage !== "text" + ? ` language-${escapeHtml(normalizedLanguage)}` + : ""; + const isCollapsible = shouldCollapseCodeBlock(codeText); + const collapsedClass = isCollapsible ? " is-collapsed" : ""; + return ( + `
` + + `
` + + `${escapeHtml(codeBlockLanguageLabel(normalizedLanguage))}` + + `` + + (isCollapsible + ? `` + : "") + + `` + + (canRunHtml + ? `` + : "") + + `` + + `
` + + `
` +
+                `` +
+                `${highlightCodeBlock(codeText, normalizedLanguage)}` +
+                `
` + + `
` + ); + }; + renderer.blockquote = ({ tokens }) => { + const parser = renderer.parser || marked.Parser; + const body = + parser && typeof parser.parse === "function" + ? parser.parse(tokens || []) + : ""; + return ( + `
` + + `${escapeHtml(t("runtime.quote"))}` + + `
${body}
` + + `
` + ); + }; + renderer.link = ({ href, title, tokens }) => { + const parser = renderer.parser || marked.Parser; + const label = + parser && typeof parser.parseInline === "function" + ? parser.parseInline(tokens || []) + : escapeHtml(href || ""); + if (!isSafeRenderedUrl(href)) return label; + const rawHref = String(href || "").trim(); + const parsed = new URL(rawHref, window.location.origin); + const safeHref = escapeHtml( + parsed.origin === window.location.origin && + !rawHref.match(/^[a-z][a-z0-9+.-]*:/i) + ? `${parsed.pathname}${parsed.search}${parsed.hash}` + : parsed.toString(), + ); + const safeTitle = title + ? ` title="${escapeHtml(String(title))}"` + : ""; + return ( + `` + + `${label}` + ); + }; + renderer.image = ({ text }) => escapeHtml(text || ""); + return renderer; + } + + function renderChatContent(content, useMarkdown) { + const text = String(content || ""); + + // Extract CQ file codes into placeholders + const filePattern = /\[CQ:file,([^\]]+)\]/g; + const filePlaceholders = []; + const step1 = text.replace(filePattern, (match, attrStr) => { + const attrs = parseCqAttributes(attrStr); + const idx = filePlaceholders.length; + filePlaceholders.push(renderFileCard(attrs)); + return `CQFILEPH${idx}CQFILEPH`; + }); + + // Extract CQ image codes into placeholders before markdown parsing + const imagePattern = /\[CQ:image,([^\]]+)\]/g; + const images = []; + const processed = step1.replace(imagePattern, (match, attrStr) => { + const attrs = parseCqAttributes(attrStr); + const src = resolveCqImageSource(attrs); + if (src) { + const idx = images.length; + images.push(chatImageMarkup(src)); + return `CQIMGPH${idx}CQIMGPH`; + } + return match; + }); + + let html; + if (useMarkdown && looksLikeStandaloneHtml(processed)) { + html = sanitizeHtmlSnippet(processed); + } else if ( + useMarkdown && + typeof marked !== "undefined" && + marked.parse + ) { + try { + html = marked.parse(processed, { + breaks: true, + gfm: true, + renderer: createSafeMarkedRenderer(), + }); + } catch (_e) { + html = escapeHtml(processed); + } + } else { + html = escapeHtml(processed); + } + + // Restore placeholders + for (let i = 0; i < images.length; i++) { + html = html.replace( + new RegExp(`CQIMGPH${i}CQIMGPH`, "g"), + images[i], + ); + } + for (let i = 0; i < filePlaceholders.length; i++) { + // marked may wrap placeholder in

, strip it for block-level card + html = html.replace( + new RegExp(`

\\s*CQFILEPH${i}CQFILEPH\\s*

`, "g"), + filePlaceholders[i], + ); + html = html.replace( + new RegExp(`CQFILEPH${i}CQFILEPH`, "g"), + filePlaceholders[i], + ); + } + + return html || escapeHtml(text); + } + + function openChatImageViewer(image) { + if (!image) return; + const source = String( + image.currentSrc || image.getAttribute("src") || "", + ).trim(); + if (!source) return; + const viewer = get("runtimeChatImageViewer"); + const viewerImage = get("runtimeChatImageViewerImage"); + const caption = get("runtimeChatImageViewerCaption"); + const closeButton = get("btnRuntimeChatImageViewerClose"); + if (!viewer || !viewerImage) return; + const alt = String(image.getAttribute("alt") || "").trim(); + runtimeState.imageViewerPreviousFocus = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + viewerImage.src = source; + viewerImage.alt = alt || t("runtime.image_preview"); + if (caption) { + caption.textContent = + alt && alt !== "image" ? alt : t("runtime.image_preview"); + } + viewer.hidden = false; + viewer.classList.add("is-open"); + viewer.setAttribute("aria-hidden", "false"); + if (closeButton) closeButton.focus({ preventScroll: true }); + } + + function closeChatImageViewer() { + const viewer = get("runtimeChatImageViewer"); + const viewerImage = get("runtimeChatImageViewerImage"); + if (!viewer) return; + viewer.classList.remove("is-open"); + viewer.hidden = true; + viewer.setAttribute("aria-hidden", "true"); + if (viewerImage) { + viewerImage.removeAttribute("src"); + viewerImage.alt = ""; + } + const previousFocus = runtimeState.imageViewerPreviousFocus; + runtimeState.imageViewerPreviousFocus = null; + if ( + previousFocus && + typeof previousFocus.focus === "function" && + document.contains(previousFocus) + ) { + previousFocus.focus({ preventScroll: true }); + } + } + + function decodeCodeBlockPayload(block) { + const encoded = String((block && block.dataset.code) || ""); + if (!encoded) return ""; + try { + return decodeURIComponent(encoded); + } catch (_error) { + return ""; + } + } + + async function copyTextToClipboard(text) { + const value = String(text || ""); + if (!value) return false; + if ( + navigator.clipboard && + typeof navigator.clipboard.writeText === "function" + ) { + try { + await navigator.clipboard.writeText(value); + return true; + } catch (_error) { + // fall through to textarea fallback + } + } + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch (_error) { + ok = false; + } finally { + textarea.remove(); + } + return ok; + } + + async function copyCodeBlock(block) { + const text = decodeCodeBlockPayload(block); + const ok = await copyTextToClipboard(text); + showToast( + ok ? t("runtime.code_copied") : t("runtime.copy_failed"), + ok ? "success" : "error", + 1800, + ); + } + + function runHtmlCodeBlock(block) { + const code = decodeCodeBlockPayload(block); + if (!code) return; + if (typeof openHtmlRunner === "function") { + openHtmlRunner(code, { + language: String((block && block.dataset.language) || "html"), + }); + return; + } + showToast(t("runtime.run_html"), "info", 1200); + } + + function toggleCodeBlock(block) { + if (!block) return; + const nextCollapsed = !block.classList.contains("is-collapsed"); + block.classList.toggle("is-collapsed", nextCollapsed); + const button = block.querySelector("[data-code-toggle]"); + if (button) { + button.textContent = nextCollapsed + ? button.getAttribute("data-collapsed-label") || + t("runtime.expand_code") + : button.getAttribute("data-expanded-label") || + t("runtime.collapse_code"); + button.setAttribute( + "aria-expanded", + nextCollapsed ? "false" : "true", + ); + } + } + + function buildHtmlRunnerDocument(source) { + const raw = String(source || "").trim(); + if (!raw) return ""; + if (/^` + + `` + + `${raw}` + ); + } + + function createHtmlRunnerNonce() { + if ( + window.crypto && + typeof window.crypto.getRandomValues === "function" + ) { + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + } + return String(Date.now()) + Math.random().toString(16).slice(2); + } + + function htmlRunnerCspMeta(nonce) { + const safeNonce = escapeHtml(String(nonce || "")); + return ( + `` + ); + } + + function htmlRunnerPickerScript(nonce) { + const confirmHint = JSON.stringify(t("runtime.html_pick_confirm_hint")); + const nonceAttr = escapeHtml(String(nonce || "")); + return ` - + + @@ -756,32 +757,85 @@

表情包库

-
-
-

智能对话

-

虚拟私聊 system#42。

+
+
+

+ 智能对话 + 虚拟私聊 system#42。该会话由 WebUI 发起,权限为 superadmin;私聊里可直接使用 /命令。 +

+
+
+ +
-
-
AI Chat(虚拟私聊 system#42)
-

该会话由 WebUI 发起,权限为 superadmin;私聊里可直接使用 /命令。

-
-
- -
- - - +
+ +
+
+
新对话
+
+
+
+
+ + + + +
+ + + +
+
+
+
+
@@ -834,10 +888,18 @@

MIT License

+ + diff --git a/tests/test_ai_coordinator_queue_routing.py b/tests/test_ai_coordinator_queue_routing.py index 7a78a73f..e09e4590 100644 --- a/tests/test_ai_coordinator_queue_routing.py +++ b/tests/test_ai_coordinator_queue_routing.py @@ -1,11 +1,13 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock import pytest +from Undefined.context import RequestContext from Undefined.services.ai_coordinator import AICoordinator from Undefined.services.coordinator import group as coordinator_group_module @@ -224,9 +226,20 @@ async def test_execute_auto_reply_send_msg_cb_passes_history_message( ) -> None: coordinator: Any = object.__new__(AICoordinator) sender = SimpleNamespace(send_group_message=AsyncMock()) + captured_extra_context: dict[str, Any] = {} + captured_resources: dict[str, Any] = {} async def _fake_ask(*_args: Any, **kwargs: Any) -> str: - await kwargs["send_message_callback"]("hello group") + extra_context = cast(dict[str, Any], kwargs.get("extra_context", {})) + captured_extra_context.update(extra_context) + current_context = RequestContext.current() + assert current_context is not None + captured_resources.update(current_context.get_resources()) + send_message_callback = cast( + Callable[[str], Awaitable[None]], + kwargs["send_message_callback"], + ) + await send_message_callback("hello group") return "" coordinator.config = SimpleNamespace(bot_qq=10000) @@ -257,6 +270,8 @@ async def _fake_ask(*_args: Any, **kwargs: Any) -> str: "sender_name": "member", "group_name": "测试群", "full_question": "prompt", + "message_ids": ["101", "102"], + "batched_count": 2, } ) @@ -266,3 +281,7 @@ async def _fake_ask(*_args: Any, **kwargs: Any) -> str: reply_to=None, history_message="hello group", ) + assert captured_extra_context["message_ids"] == ["101", "102"] + assert captured_extra_context["batched_count"] == 2 + assert captured_extra_context["current_input_is_batched"] is True + assert captured_resources["message_ids"] == ["101", "102"] diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 2dced7b0..8209500b 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -547,11 +547,45 @@ async def test_register_message_attachments_normalizes_webui_base64_image( assert len(result.attachments) == 1 uid = result.attachments[0]["uid"] + record = registry.resolve(uid, "webui") assert uid.startswith("pic_") + assert record is not None + assert record.display_name == "image_2.png" + assert len(record.display_name) < 64 assert uid in result.normalized_text assert "这张图" in result.normalized_text +@pytest.mark.asyncio +async def test_register_message_attachments_uses_short_data_url_image_name( + tmp_path: Path, +) -> None: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + ) + payload = base64.b64encode(_PNG_BYTES).decode("ascii") + + result = await register_message_attachments( + registry=registry, + segments=[ + { + "type": "image", + "data": {"file": f"data:image/png;base64,{payload}"}, + } + ], + scope_key="webui", + ) + + uid = result.attachments[0]["uid"] + record = registry.resolve(uid, "webui") + + assert record is not None + assert record.display_name == "image_1.png" + assert record.source_kind == "data_url_image" + assert len(record.display_name) < 64 + + @pytest.mark.asyncio async def test_register_message_attachments_recurses_into_forward_images( tmp_path: Path, diff --git a/tests/test_cognitive_chroma_scheduler.py b/tests/test_cognitive_chroma_scheduler.py new file mode 100644 index 00000000..d870575a --- /dev/null +++ b/tests/test_cognitive_chroma_scheduler.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import asyncio +import threading +from typing import Any + +import pytest + +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_BACKGROUND, + CHROMA_PRIORITY_FOREGROUND, + CHROMA_PRIORITY_MAINTENANCE, + ChromaOperationScheduler, +) + + +@pytest.mark.asyncio +async def test_chroma_scheduler_runs_one_operation_at_a_time() -> None: + scheduler = ChromaOperationScheduler() + active = 0 + max_active = 0 + + async def _submit(index: int) -> int: + def _work() -> int: + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + try: + return index + finally: + active -= 1 + + result, _receipt = await scheduler.run( + priority=CHROMA_PRIORITY_FOREGROUND, + operation="query", + collection="cognitive_events", + callback=_work, + ) + return int(result) + + results = await asyncio.gather(*[_submit(index) for index in range(8)]) + await scheduler.stop() + + assert results == list(range(8)) + assert max_active == 1 + + +@pytest.mark.asyncio +async def test_chroma_scheduler_prefers_foreground_over_background() -> None: + scheduler = ChromaOperationScheduler(foreground_burst=8) + release_first = threading.Event() + order: list[str] = [] + + def _blocking_first() -> str: + release_first.wait() + order.append("first") + return "first" + + def _record(name: str) -> str: + order.append(name) + return name + + async def _submit(name: str, priority: str, callback: Any | None = None) -> str: + result, _receipt = await scheduler.run( + priority=priority, + operation=name, + collection="cognitive_events", + callback=callback or (lambda: _record(name)), + ) + return str(result) + + first_task = asyncio.create_task( + _submit("first", CHROMA_PRIORITY_BACKGROUND, _blocking_first) + ) + while scheduler.snapshot().active is False: + await asyncio.sleep(0) + background_task = asyncio.create_task( + _submit("background", CHROMA_PRIORITY_BACKGROUND) + ) + foreground_task = asyncio.create_task( + _submit("foreground", CHROMA_PRIORITY_FOREGROUND) + ) + while sum(scheduler.snapshot().pending.values()) < 2: + await asyncio.sleep(0) + + release_first.set() + + assert await first_task == "first" + assert await foreground_task == "foreground" + assert await background_task == "background" + await scheduler.stop() + + assert order == ["first", "foreground", "background"] + + +@pytest.mark.asyncio +async def test_chroma_scheduler_gives_background_a_fairness_slot() -> None: + scheduler = ChromaOperationScheduler(foreground_burst=2) + release_first = threading.Event() + order: list[str] = [] + + def _blocking_first() -> str: + release_first.wait() + order.append("first") + return "first" + + def _record(name: str) -> str: + order.append(name) + return name + + async def _submit(name: str, priority: str, callback: Any | None = None) -> str: + result, _receipt = await scheduler.run( + priority=priority, + operation=name, + collection="cognitive_events", + callback=callback or (lambda: _record(name)), + ) + return str(result) + + tasks = [ + asyncio.create_task( + _submit("first", CHROMA_PRIORITY_FOREGROUND, _blocking_first) + ), + asyncio.create_task(_submit("fg1", CHROMA_PRIORITY_FOREGROUND)), + asyncio.create_task(_submit("fg2", CHROMA_PRIORITY_FOREGROUND)), + asyncio.create_task(_submit("fg3", CHROMA_PRIORITY_FOREGROUND)), + asyncio.create_task(_submit("maintenance", CHROMA_PRIORITY_MAINTENANCE)), + ] + while scheduler.snapshot().active is False: + await asyncio.sleep(0) + while sum(scheduler.snapshot().pending.values()) < 4: + await asyncio.sleep(0) + release_first.set() + + await asyncio.gather(*tasks) + await scheduler.stop() + + assert order == ["first", "fg1", "maintenance", "fg2", "fg3"] + + +@pytest.mark.asyncio +async def test_chroma_scheduler_cancelled_pending_operation_is_skipped() -> None: + scheduler = ChromaOperationScheduler() + release_first = threading.Event() + ran_cancelled = False + + def _blocking_first() -> str: + release_first.wait() + return "first" + + def _mark_cancelled() -> str: + nonlocal ran_cancelled + ran_cancelled = True + return "pending" + + first_task = asyncio.create_task( + scheduler.run( + priority=CHROMA_PRIORITY_FOREGROUND, + operation="first", + collection="cognitive_events", + callback=_blocking_first, + ) + ) + while scheduler.snapshot().active is False: + await asyncio.sleep(0) + + pending_task = asyncio.create_task( + scheduler.run( + priority=CHROMA_PRIORITY_BACKGROUND, + operation="pending", + collection="cognitive_events", + callback=lambda: _mark_cancelled(), + ) + ) + await asyncio.sleep(0) + pending_task.cancel() + with pytest.raises(asyncio.CancelledError): + await pending_task + + release_first.set() + await first_task + await scheduler.stop() + + assert ran_cancelled is False + + +@pytest.mark.asyncio +async def test_chroma_scheduler_propagates_operation_errors() -> None: + scheduler = ChromaOperationScheduler() + + def _raise() -> str: + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + await scheduler.run( + priority=CHROMA_PRIORITY_FOREGROUND, + operation="query", + collection="cognitive_events", + callback=_raise, + ) + await scheduler.stop() diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index 00a331ee..b3b54cc6 100644 --- a/tests/test_cognitive_historian.py +++ b/tests/test_cognitive_historian.py @@ -7,6 +7,7 @@ import pytest +from Undefined.cognitive.chroma_scheduler import CHROMA_PRIORITY_MAINTENANCE from Undefined.cognitive.historian import HistorianWorker @@ -117,6 +118,7 @@ async def test_merge_profile_target_user_queries_history_with_sender_or_user_id( class _FakeVectorStore: def __init__(self) -> None: self.where_calls: list[dict[str, Any]] = [] + self.priority_calls: list[str] = [] self.embed_query_calls = 0 async def embed_query(self, _query: str) -> list[float]: @@ -129,6 +131,7 @@ async def query_events( where = kwargs.get("where") if isinstance(where, dict): self.where_calls.append(where) + self.priority_calls.append(str(kwargs.get("priority", ""))) return [] class _FakeAIClient: @@ -182,6 +185,10 @@ async def submit_background_llm_call(self, **kwargs: Any) -> dict[str, Any]: assert vector_store.embed_query_calls == 1 assert {"sender_id": "123456"} in vector_store.where_calls assert {"user_id": "123456"} in vector_store.where_calls + assert vector_store.priority_calls == [ + CHROMA_PRIORITY_MAINTENANCE, + CHROMA_PRIORITY_MAINTENANCE, + ] @pytest.mark.asyncio diff --git a/tests/test_cognitive_service.py b/tests/test_cognitive_service.py index 16ef906a..07ed9548 100644 --- a/tests/test_cognitive_service.py +++ b/tests/test_cognitive_service.py @@ -5,6 +5,10 @@ import pytest +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_FOREGROUND, + CHROMA_PRIORITY_FOREGROUND_CRITICAL, +) from Undefined.cognitive.service import CognitiveService @@ -21,7 +25,9 @@ class _FakeVectorStore: def __init__(self) -> None: self.last_event_kwargs: dict[str, Any] | None = None self.last_profile_kwargs: dict[str, Any] | None = None - self.last_upsert_profile: tuple[str, str, dict[str, Any]] | None = None + self.last_upsert_profile: ( + tuple[str, str, dict[str, Any], dict[str, Any]] | None + ) = None self.event_calls: list[dict[str, Any]] = [] self.event_resolver: Callable[[dict[str, Any]], list[dict[str, Any]]] | None = ( None @@ -51,8 +57,9 @@ async def upsert_profile( profile_id: str, document: str, metadata: dict[str, Any], + **kwargs: Any, ) -> None: - self.last_upsert_profile = (profile_id, document, metadata) + self.last_upsert_profile = (profile_id, document, metadata, dict(kwargs)) class _FakeProfileStorage: @@ -179,6 +186,10 @@ async def test_search_events_uses_reranker_when_cognitive_rerank_enabled() -> No assert vector_store.last_event_kwargs is not None assert vector_store.last_event_kwargs.get("reranker") is reranker + assert ( + vector_store.last_event_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -205,6 +216,10 @@ async def test_search_events_skips_reranker_when_cognitive_rerank_disabled() -> assert vector_store.last_event_kwargs is not None assert vector_store.last_event_kwargs.get("reranker") is None + assert ( + vector_store.last_event_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -226,6 +241,10 @@ async def test_search_profiles_skips_reranker_when_cognitive_rerank_disabled() - assert vector_store.last_profile_kwargs is not None assert vector_store.last_profile_kwargs.get("reranker") is None + assert ( + vector_store.last_profile_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -249,6 +268,10 @@ async def test_search_profiles_handles_none_top_k_and_empty_entity_type() -> Non assert vector_store.last_profile_kwargs is not None assert vector_store.last_profile_kwargs.get("top_k") == 8 assert vector_store.last_profile_kwargs.get("where") is None + assert ( + vector_store.last_profile_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -277,6 +300,10 @@ async def test_search_events_uses_runtime_reranker_when_enabled() -> None: assert runtime.ensure_reranker_calls == 1 assert vector_store.last_event_kwargs is not None assert vector_store.last_event_kwargs.get("reranker") is runtime._reranker + assert ( + vector_store.last_event_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -305,6 +332,10 @@ async def test_search_events_does_not_touch_runtime_reranker_when_disabled() -> assert runtime.ensure_reranker_calls == 0 assert vector_store.last_event_kwargs is not None assert vector_store.last_event_kwargs.get("reranker") is None + assert ( + vector_store.last_event_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -364,6 +395,7 @@ async def test_build_context_group_mode_uses_group_scope_with_boost() -> None: assert len(vector_store.event_calls) == 1 assert vector_store.event_calls[0].get("where") == {"request_type": "group"} assert vector_store.event_calls[0].get("top_k") == 4 + assert vector_store.event_calls[0].get("priority") == CHROMA_PRIORITY_FOREGROUND assert "当前群事件" in context assert "跨群事件" in context assert context.index("当前群事件") < context.index("跨群事件") @@ -433,6 +465,10 @@ def _resolve_events(kwargs: dict[str, Any]) -> list[dict[str, Any]]: assert len(vector_store.event_calls) == 2 where_clauses = [call.get("where") for call in vector_store.event_calls] assert {"request_type": "group"} in where_clauses + assert all( + call.get("priority") == CHROMA_PRIORITY_FOREGROUND + for call in vector_store.event_calls + ) assert any( isinstance(where, dict) and isinstance(where.get("$and"), list) @@ -518,6 +554,10 @@ def _resolve_events(kwargs: dict[str, Any]) -> list[dict[str, Any]]: assert len(vector_store.event_calls) == 2 assert vector_store.event_calls[0].get("query_embedding") == [0.12, 0.34] assert vector_store.event_calls[1].get("query_embedding") == [0.12, 0.34] + assert all( + call.get("priority") == CHROMA_PRIORITY_FOREGROUND + for call in vector_store.event_calls + ) @pytest.mark.asyncio @@ -621,11 +661,12 @@ async def test_sync_profile_display_name_updates_existing_profile_and_vector() - assert "name: 新昵称" in profile_storage.last_write[2] assert "nickname: 新昵称" in profile_storage.last_write[2] assert vector_store.last_upsert_profile is not None - profile_id, document, metadata = vector_store.last_upsert_profile + profile_id, document, metadata, kwargs = vector_store.last_upsert_profile assert profile_id == "user:12345" assert "昵称: 新昵称" in document assert metadata["name"] == "新昵称" assert metadata["nickname"] == "新昵称" + assert kwargs.get("priority") == CHROMA_PRIORITY_FOREGROUND @pytest.mark.asyncio diff --git a/tests/test_cognitive_vector_store_compat.py b/tests/test_cognitive_vector_store_compat.py new file mode 100644 index 00000000..85372c03 --- /dev/null +++ b/tests/test_cognitive_vector_store_compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from Undefined.cognitive.vector_store_compat import call_vector_store_method + + +@pytest.mark.asyncio +async def test_call_vector_store_method_omits_priority_for_legacy_method() -> None: + calls: list[dict[str, Any]] = [] + + async def _legacy_method(value: str, *, top_k: int) -> str: + calls.append({"value": value, "top_k": top_k}) + return "ok" + + result = await call_vector_store_method( + _legacy_method, + "query", + priority="foreground", + top_k=3, + ) + + assert result == "ok" + assert calls == [{"value": "query", "top_k": 3}] + + +@pytest.mark.asyncio +async def test_call_vector_store_method_passes_priority_when_supported() -> None: + calls: list[dict[str, Any]] = [] + + async def _new_method(value: str, *, top_k: int, priority: str) -> str: + calls.append({"value": value, "top_k": top_k, "priority": priority}) + return "ok" + + result = await call_vector_store_method( + _new_method, + "query", + priority="foreground_critical", + top_k=3, + ) + + assert result == "ok" + assert calls == [{"value": "query", "top_k": 3, "priority": "foreground_critical"}] diff --git a/tests/test_cognitive_vector_store_metadata.py b/tests/test_cognitive_vector_store_metadata.py index d512b020..3edc0a35 100644 --- a/tests/test_cognitive_vector_store_metadata.py +++ b/tests/test_cognitive_vector_store_metadata.py @@ -8,6 +8,7 @@ import pytest from chromadb.errors import InternalError as ChromaInternalError +from Undefined.cognitive.chroma_scheduler import ChromaOperationScheduler from Undefined.cognitive.vector_store import _sanitize_metadata from Undefined.cognitive.vector_store import CognitiveVectorStore @@ -89,21 +90,24 @@ def query(self, **_kwargs: object) -> dict[str, list[list[object]]]: } store = CognitiveVectorStore.__new__(CognitiveVectorStore) - store._events_lock = asyncio.Lock() - store._profiles_lock = asyncio.Lock() + scheduler = ChromaOperationScheduler() + store._chroma_scheduler = scheduler fake_collection = _FakeCollection() store._events = cast(Any, fake_collection) store._profiles = cast(Any, object()) - results = await store._query( - fake_collection, - "测试查询", - 1, - None, - None, - 1, - query_embedding=[0.11, 0.22, 0.33], - ) + try: + results = await store._query( + fake_collection, + "测试查询", + 1, + None, + None, + 1, + query_embedding=[0.11, 0.22, 0.33], + ) + finally: + await scheduler.stop() assert fake_collection.calls == 3 assert results == [ diff --git a/tests/test_config_cognitive_historian_limits.py b/tests/test_config_cognitive_historian_limits.py index bb58f56b..36f98f60 100644 --- a/tests/test_config_cognitive_historian_limits.py +++ b/tests/test_config_cognitive_historian_limits.py @@ -13,6 +13,9 @@ def test_parse_cognitive_historian_reference_limits() -> None: "auto_current_group_boost": 1.3, "auto_current_private_boost": 1.6, }, + "vector_store": { + "scheduler_foreground_burst": 5, + }, "historian": { "recent_messages_inject_k": 21, "recent_message_line_max_len": 333, @@ -29,6 +32,7 @@ def test_parse_cognitive_historian_reference_limits() -> None: assert cfg.auto_current_group_boost == 1.3 assert cfg.auto_current_private_boost == 1.6 assert cfg.enable_rerank is False + assert cfg.vector_store_scheduler_foreground_burst == 5 def test_parse_cognitive_historian_reference_limits_defaults() -> None: @@ -41,3 +45,12 @@ def test_parse_cognitive_historian_reference_limits_defaults() -> None: assert cfg.auto_current_group_boost == 1.15 assert cfg.auto_current_private_boost == 1.25 assert cfg.enable_rerank is True + assert cfg.vector_store_scheduler_foreground_burst == 8 + + +def test_parse_cognitive_vector_store_scheduler_burst_clamps_to_positive() -> None: + cfg = _parse_cognitive_config( + {"cognitive": {"vector_store": {"scheduler_foreground_burst": 0}}} + ) + + assert cfg.vector_store_scheduler_foreground_burst == 1 diff --git a/tests/test_context_recent_messages_limit.py b/tests/test_context_recent_messages_limit.py index 987ea3d8..c3a4a620 100644 --- a/tests/test_context_recent_messages_limit.py +++ b/tests/test_context_recent_messages_limit.py @@ -84,3 +84,83 @@ async def _fake_recent( ) assert captured == [750] + + +@pytest.mark.asyncio +async def test_prompt_builder_filters_webchat_display_only_history() -> None: + class _Runtime: + def get_context_recent_messages_limit(self) -> int: + return 10 + + builder = PromptBuilder( + bot_qq=123456, + memory_storage=None, + end_summary_storage=_FakeEndSummaryStorage(), # type: ignore[arg-type] + runtime_config_getter=lambda: _Runtime(), + ) + messages: list[dict[str, Any]] = [] + + async def _fake_recent( + chat_id: str, + msg_type: str, + start: int, + limit: int, + ) -> list[dict[str, Any]]: + _ = (chat_id, msg_type, start, limit) + return [ + { + "type": "private", + "display_name": "Bot", + "user_id": "42", + "chat_id": "42", + "chat_name": "QQ用户42", + "timestamp": "2026-05-30 12:00:00", + "message": "", + "webchat": { + "display_only": True, + "events": [ + { + "seq": 2, + "event": "tool_end", + "payload": {"result_preview": "secret tool result"}, + } + ], + }, + }, + { + "type": "private", + "display_name": "Bot", + "user_id": "42", + "chat_id": "42", + "chat_name": "QQ用户42", + "timestamp": "2026-05-30 12:00:01", + "message": "可见回复", + "webchat": { + "display_only": True, + "events": [ + { + "seq": 3, + "event": "tool_end", + "payload": {"result_preview": "visible metadata"}, + } + ], + }, + }, + ] + + async with RequestContext(request_type="private", user_id=42, sender_id=10001): + await builder._inject_recent_messages( + messages, + _fake_recent, + None, + "hello", + ) + + history_message = next( + str(msg.get("content", "")) + for msg in messages + if "【历史消息存档】" in str(msg.get("content", "")) + ) + assert "secret tool result" not in history_message + assert "可见回复" in history_message + assert "visible metadata" not in history_message diff --git a/tests/test_end_tool.py b/tests/test_end_tool.py index 92be8277..b1eac426 100644 --- a/tests/test_end_tool.py +++ b/tests/test_end_tool.py @@ -7,6 +7,7 @@ from Undefined.context import RequestContext from Undefined.skills.tools.end.handler import execute +from Undefined.utils.message_turn import mark_message_sent_this_turn @pytest.mark.asyncio @@ -60,6 +61,21 @@ async def test_end_accepts_message_sent_flag_from_request_context_string_true() assert context["conversation_ended"] is True +@pytest.mark.asyncio +async def test_end_accepts_message_sent_flag_from_copied_tool_context() -> None: + send_context: dict[str, Any] = {"request_id": "req-send-copy"} + end_context: dict[str, Any] = {"request_id": "req-end-copy"} + + async with RequestContext(request_type="private", user_id=42): + mark_message_sent_this_turn(send_context) + result = await execute({"memo": "已发送消息"}, end_context) + + assert send_context["message_sent_this_turn"] is True + assert "message_sent_this_turn" not in end_context + assert result == "对话已结束" + assert end_context["conversation_ended"] is True + + class _FakeHistoryManager: def get_recent( self, chat_id: str, msg_type: str, start: int, end: int @@ -80,6 +96,8 @@ class _FakeCognitiveService: def __init__(self) -> None: self.last_context: dict[str, Any] | None = None self.last_force: bool | None = None + self.last_memo = "" + self.last_observations: list[str] = [] async def enqueue_job( self, @@ -91,6 +109,8 @@ async def enqueue_job( ) -> str: self.last_context = dict(context) self.last_force = bool(force) + self.last_memo = memo + self.last_observations = list(observations) return "job-test" @@ -117,6 +137,36 @@ async def test_end_ignores_removed_legacy_param_names() -> None: assert cognitive_service.last_context is None +@pytest.mark.asyncio +async def test_end_normalizes_undefined_project_name_misspellings() -> None: + cognitive_service = _FakeCognitiveService() + context: dict[str, Any] = { + "request_id": "req-normalize-project-name", + "cognitive_service": cognitive_service, + } + + result = await execute( + { + "memo": "已解释 Unfined 的记忆架构", + "observations": [ + "QQ号42(昵称system)在 WebUI 询问 Unfined 是否了解自身记忆架构", + "QQ号42(昵称system)提到 Undefind 的分层架构", + "QQ号42(昵称system)继续讨论 undefind", + ], + "force": True, + }, + context, + ) + + assert result == "对话已结束" + assert cognitive_service.last_memo == "已解释 Undefined 的记忆架构" + assert cognitive_service.last_observations == [ + "QQ号42(昵称system)在 WebUI 询问 Undefined 是否了解自身记忆架构", + "QQ号42(昵称system)提到 Undefined 的分层架构", + "QQ号42(昵称system)继续讨论 Undefined", + ] + + @pytest.mark.asyncio async def test_end_enriches_historian_reference_context() -> None: cognitive_service = _FakeCognitiveService() @@ -208,6 +258,85 @@ def get_recent( ] +class _DuplicateCurrentBatchHistoryManager: + def get_recent( + self, chat_id: str, msg_type: str, start: int, end: int + ) -> list[dict[str, Any]]: + _ = chat_id, msg_type, start, end + return [ + { + "type": "group", + "message_id": "100", + "timestamp": "2026-02-23 19:01:00", + "display_name": "旁观者", + "user_id": "99999", + "chat_id": "1082837821", + "chat_name": "bot测试群", + "message": "保留的旧历史", + }, + { + "type": "group", + "message_id": "101", + "timestamp": "2026-02-23 19:02:12", + "display_name": "洛泫", + "user_id": "120218451", + "chat_id": "1082837821", + "chat_name": "bot测试群", + "message": "我周三要发版", + }, + { + "type": "group", + "message_id": "102", + "timestamp": "2026-02-23 19:02:14", + "display_name": "洛泫", + "user_id": "120218451", + "chat_id": "1082837821", + "chat_name": "bot测试群", + "message": "补充:是后端服务发版", + }, + ] + + +@pytest.mark.asyncio +async def test_end_historian_recent_messages_drops_current_batch_duplicates() -> None: + cognitive_service = _FakeCognitiveService() + context: dict[str, Any] = { + "request_id": "req-historian-drop-current-batch", + "request_type": "group", + "group_id": "1082837821", + "user_id": "120218451", + "sender_id": "120218451", + "history_manager": _DuplicateCurrentBatchHistoryManager(), + "cognitive_service": cognitive_service, + "current_question": ( + '' + "我周三要发版" + '' + "补充:是后端服务发版" + "\n\n 【连续消息说明】以上 2 条 共同构成【当前输入批次】" + ), + } + + result = await execute( + {"observations": ["洛泫周三要进行后端服务发版"], "force": True}, + context, + ) + + assert result == "对话已结束" + recent = context.get("historian_recent_messages", []) + assert isinstance(recent, list) + recent_text = "\n".join(str(item) for item in recent) + assert "保留的旧历史" in recent_text + assert "我周三要发版" not in recent_text + assert "补充:是后端服务发版" not in recent_text + assert cognitive_service.last_context is not None + assert cognitive_service.last_context.get("historian_recent_messages") == recent + + @pytest.mark.asyncio async def test_end_uses_runtime_config_for_historian_reference_limits() -> None: cognitive_service = _FakeCognitiveService() diff --git a/tests/test_file_analysis_attachment_uid.py b/tests/test_file_analysis_attachment_uid.py index 715d5702..06da1334 100644 --- a/tests/test_file_analysis_attachment_uid.py +++ b/tests/test_file_analysis_attachment_uid.py @@ -1,13 +1,31 @@ from __future__ import annotations from pathlib import Path +from typing import Any import pytest -from Undefined.attachments import AttachmentRegistry +from Undefined.attachments import AttachmentRegistry, scope_from_context from Undefined.skills.agents.file_analysis_agent.tools.download_file import ( handler as download_file_handler, ) +from Undefined.utils.io import write_bytes +from Undefined.utils.paths import ensure_dir + + +def _download_context( + tmp_path: Path, + registry: AttachmentRegistry, +) -> dict[str, Any]: + return { + "attachment_registry": registry, + "request_type": "private", + "user_id": 12345, + "get_scope_from_context": scope_from_context, + "download_cache_dir": tmp_path / "downloads", + "ensure_dir_fn": ensure_dir, + "write_bytes_fn": write_bytes, + } @pytest.mark.asyncio @@ -28,16 +46,13 @@ async def test_download_file_supports_internal_attachment_uid( result = await download_file_handler.execute( {"file_source": record.uid}, - { - "attachment_registry": registry, - "request_type": "private", - "user_id": 12345, - }, + _download_context(tmp_path, registry), ) downloaded = Path(result) assert downloaded.is_file() - assert downloaded.name == "demo.txt" + assert downloaded.name.startswith("file_") + assert downloaded.suffix == ".txt" assert downloaded.read_bytes() == b"hello attachment" @@ -58,16 +73,36 @@ async def test_download_file_redownloads_url_backed_attachment_uid( display_name="demo.txt", ) + async def _fake_ensure_local_file(record: object) -> object: + return type( + "AttachmentLike", + (), + { + "uid": getattr(record, "uid"), + "kind": getattr(record, "kind"), + "media_type": getattr(record, "media_type"), + "display_name": getattr(record, "display_name"), + "source_ref": getattr(record, "source_ref"), + "local_path": "", + }, + )() + + captured_url: dict[str, str] = {} + async def _fake_download_from_url( url: str, temp_dir: Path, max_size_mb: float, task_uuid: str, + write_bytes_fn: object, ) -> str: - target = temp_dir / "demo.txt" - target.write_bytes(url.encode("utf-8")) + _ = max_size_mb, task_uuid, write_bytes_fn + captured_url["url"] = url + target = temp_dir / "file_from_source_ref.txt" + target.write_bytes(b"https://example.com/demo.txt") return str(target) + monkeypatch.setattr(registry, "ensure_local_file", _fake_ensure_local_file) monkeypatch.setattr( download_file_handler, "_download_from_url", @@ -76,14 +111,41 @@ async def _fake_download_from_url( result = await download_file_handler.execute( {"file_source": record.uid}, - { - "attachment_registry": registry, - "request_type": "private", - "user_id": 12345, - }, + _download_context(tmp_path, registry), ) downloaded = Path(result) assert downloaded.is_file() - assert downloaded.name == "demo.txt" + assert downloaded.name.startswith("file_") + assert downloaded.suffix == ".txt" assert downloaded.read_bytes() == b"https://example.com/demo.txt" + assert captured_url["url"] == "https://example.com/demo.txt" + + +@pytest.mark.asyncio +async def test_download_file_uses_random_name_for_unsafe_attachment_name( + tmp_path: Path, +) -> None: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + ) + record = await registry.register_bytes( + "private:12345", + b"image bytes", + kind="image", + display_name=f"base64://{'a' * 5000}.png", + source_kind="base64_image", + source_ref="segment:0", + ) + + result = await download_file_handler.execute( + {"file_source": record.uid}, + _download_context(tmp_path, registry), + ) + + downloaded = Path(result) + assert downloaded.is_file() + assert downloaded.name.startswith("image_") + assert len(downloaded.name) < 64 + assert downloaded.read_bytes() == b"image bytes" diff --git a/tests/test_grok_search_tool.py b/tests/test_grok_search_tool.py index ce3dc955..b7870a0b 100644 --- a/tests/test_grok_search_tool.py +++ b/tests/test_grok_search_tool.py @@ -1,5 +1,8 @@ from __future__ import annotations +from datetime import datetime, timezone +import json +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock @@ -9,12 +12,77 @@ from Undefined.skills.agents.web_agent.tools.grok_search import handler as grok_handler +def test_grok_search_system_prompt_uses_provided_time_and_search_rules() -> None: + prompt = grok_handler._build_grok_search_system_prompt( + datetime(2026, 5, 30, 12, 34, 56, tzinfo=timezone.utc) + ) + + assert "2026-05-30T12:34:56+00:00" in prompt + assert "不要以模型内部时间为准" in prompt + assert "必须先调用搜索" in prompt + assert "多个搜索工具" in prompt + assert "不可胡编乱造" in prompt + assert "必须给出来源" in prompt + + +def test_grok_search_schema_requires_natural_language_search_request() -> None: + config_path = ( + Path("src") + / "Undefined" + / "skills" + / "agents" + / "web_agent" + / "tools" + / "grok_search" + / "config.json" + ) + schema = json.loads(config_path.read_text(encoding="utf-8")) + parameters = schema["function"]["parameters"] + + assert parameters["required"] == ["search_request"] + assert "search_request" in parameters["properties"] + assert "query" not in parameters["properties"] + assert ( + "自然语言详细说明搜索内容和回答要求" + in parameters["properties"]["search_request"]["description"] + ) + assert "不要只给关键词" in schema["function"]["description"] + assert "不要主动把范围写死" in schema["function"]["description"] + assert ( + "不要主动添加用户未要求的硬性范围" + in parameters["properties"]["search_request"]["description"] + ) + + +@pytest.mark.asyncio +async def test_grok_search_requires_search_request() -> None: + ai_client = SimpleNamespace(submit_queued_llm_call=AsyncMock()) + + result = await grok_handler.execute( + {}, + { + "runtime_config": SimpleNamespace( + grok_search_enabled=True, + grok_model=SimpleNamespace( + api_url="https://grok.example/v1", + api_key="sk-grok", + model_name="grok-4-search", + ), + ), + "ai_client": ai_client, + }, + ) + + assert result == "请用 search_request 提供完整的自然语言搜索要求。" + ai_client.submit_queued_llm_call.assert_not_awaited() + + @pytest.mark.asyncio async def test_grok_search_returns_disabled_when_switch_is_off() -> None: ai_client = SimpleNamespace(submit_queued_llm_call=AsyncMock()) result = await grok_handler.execute( - {"query": "latest inference model releases"}, + {"search_request": "latest inference model releases"}, { "runtime_config": SimpleNamespace( grok_search_enabled=False, @@ -29,7 +97,7 @@ async def test_grok_search_returns_disabled_when_switch_is_off() -> None: @pytest.mark.asyncio -async def test_grok_search_returns_raw_result() -> None: +async def test_grok_search_returns_message_content_from_dict_response() -> None: ai_client = SimpleNamespace( submit_queued_llm_call=AsyncMock( return_value={ @@ -57,7 +125,12 @@ async def test_grok_search_returns_raw_result() -> None: ) result = await grok_handler.execute( - {"query": "请详细搜索 2026 年最新 AI 芯片发布信息"}, + { + "search_request": ( + "请搜索 2026 年最新 AI 芯片发布信息,重点比较发布时间、" + "供应商、面向推理还是训练、公开性能指标和权威来源。" + ) + }, { "runtime_config": SimpleNamespace( grok_search_enabled=True, @@ -68,11 +141,90 @@ async def test_grok_search_returns_raw_result() -> None: ) assert "这里是搜索结果摘要。" in result + assert "choices" not in result assert "参考链接:" not in result ai_client.submit_queued_llm_call.assert_awaited_once() kwargs = ai_client.submit_queued_llm_call.await_args.kwargs assert kwargs["model_config"] is grok_model assert kwargs["call_type"] == "agent_tool:grok_search" + assert kwargs["messages"][0]["role"] == "system" + assert "不要以模型内部时间为准" in kwargs["messages"][0]["content"] + assert "必须先调用搜索" in kwargs["messages"][0]["content"] + assert "多个搜索工具" in kwargs["messages"][0]["content"] + assert "必须给出来源" in kwargs["messages"][0]["content"] + assert kwargs["messages"][1] == { + "role": "user", + "content": ( + "请搜索 2026 年最新 AI 芯片发布信息,重点比较发布时间、" + "供应商、面向推理还是训练、公开性能指标和权威来源。" + ), + } + + +@pytest.mark.asyncio +async def test_grok_search_returns_message_content_from_json_string_response() -> None: + ai_client = SimpleNamespace( + submit_queued_llm_call=AsyncMock( + return_value=json.dumps( + { + "id": "chatcmpl-test", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": {"content": "JSON 字符串里的搜索摘要。"}, + } + ], + }, + ensure_ascii=False, + ) + ) + ) + grok_model = SimpleNamespace( + api_url="https://grok.example/v1", + api_key="sk-grok", + model_name="grok-4-search", + max_tokens=4096, + ) + + result = await grok_handler.execute( + {"search_request": "请搜索一个测试主题并返回摘要。"}, + { + "runtime_config": SimpleNamespace( + grok_search_enabled=True, + grok_model=grok_model, + ), + "ai_client": ai_client, + }, + ) + + assert result == "JSON 字符串里的搜索摘要。" + + +@pytest.mark.asyncio +async def test_grok_search_returns_original_text_when_json_parse_fails() -> None: + ai_client = SimpleNamespace( + submit_queued_llm_call=AsyncMock(return_value="{not valid json") + ) + grok_model = SimpleNamespace( + api_url="https://grok.example/v1", + api_key="sk-grok", + model_name="grok-4-search", + max_tokens=4096, + ) + + result = await grok_handler.execute( + {"search_request": "请搜索一个测试主题并返回摘要。"}, + { + "runtime_config": SimpleNamespace( + grok_search_enabled=True, + grok_model=grok_model, + ), + "ai_client": ai_client, + }, + ) + + assert result == "{not valid json" def test_runner_filters_grok_search_for_web_agent_when_disabled() -> None: diff --git a/tests/test_history_level.py b/tests/test_history_level.py index a8a4f4a6..b96d14a5 100644 --- a/tests/test_history_level.py +++ b/tests/test_history_level.py @@ -87,6 +87,48 @@ async def fake_save(data: list[dict[str, object]], path: str) -> None: assert record["level"] == "" +@pytest.mark.asyncio +async def test_add_private_message_stores_webchat_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = MessageHistoryManager.__new__(MessageHistoryManager) + manager._private_message_history = {} + manager._max_records = 10000 + manager._initialized = asyncio.Event() + manager._initialized.set() + manager._private_locks = {} + + saved_data: dict[str, list[dict[str, object]]] = {} + + async def fake_save(data: list[dict[str, object]], path: str) -> None: + saved_data[path] = data + + monkeypatch.setattr(manager, "_save_history_to_file", fake_save) + + webchat: dict[str, object] = { + "display_only": True, + "job_id": "job_1", + "events": [ + { + "seq": 2, + "event": "tool_end", + "payload": {"tool_call_id": "call_1"}, + } + ], + } + await manager.add_private_message( + user_id=42, + text_content="", + display_name="Bot", + webchat=webchat, + ) + await manager.flush_pending_saves() + + record = manager._private_message_history["42"][0] + assert record["message"] == "" + assert record["webchat"] == webchat + + @pytest.mark.asyncio async def test_history_save_failure_keeps_pending_snapshot( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_llm_retry_suppression.py b/tests/test_llm_retry_suppression.py index c58a1746..67b6a9e1 100644 --- a/tests/test_llm_retry_suppression.py +++ b/tests/test_llm_retry_suppression.py @@ -150,6 +150,150 @@ async def _execute_tool( assert cast(AsyncMock, client.submit_queued_llm_call).await_count == 2 +@pytest.mark.asyncio +async def test_ai_ask_webchat_events_include_stage_and_tool_lifecycle() -> None: + client: Any = object.__new__(AIClient) + client.runtime_config = cast( + Any, + SimpleNamespace( + log_thinking=False, + ai_request_max_retries=0, + missing_tool_call_retries=0, + ), + ) + client._prompt_builder = cast( + Any, + SimpleNamespace( + build_messages=AsyncMock( + return_value=[{"role": "user", "content": "hello"}] + ), + end_summaries=[], + ), + ) + + seen_tool_context: dict[str, Any] = {} + + async def _execute_tool( + name: str, args: dict[str, Any], ctx: dict[str, Any] + ) -> str: + _ = args + seen_tool_context.update(ctx) + if name == "end": + ctx["conversation_ended"] = True + return "对话已结束" + return "tool result" + + client.tool_manager = cast( + Any, + SimpleNamespace( + get_openai_tools=lambda: [], + execute_tool=_execute_tool, + ), + ) + client._filter_tools_for_runtime_config = lambda tools: tools + client._get_runtime_config = cast(Any, lambda: client.runtime_config) + client.model_selector = cast(Any, SimpleNamespace(wait_ready=AsyncMock())) + client.chat_config = ChatModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="chat-model", + max_tokens=1024, + ) + client._find_chat_config_by_name = lambda _name: client.chat_config + + llm_results = [ + { + "choices": [ + { + "message": { + "content": "", + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "lookup", + "arguments": '{"q":"weather"}', + }, + } + ], + } + } + ] + }, + { + "choices": [ + { + "message": { + "content": "", + "tool_calls": [ + { + "id": "call_end", + "function": {"name": "end", "arguments": "{}"}, + } + ], + } + } + ] + }, + ] + + submit_index = 0 + + async def _submit_queued_llm_call(**kwargs: Any) -> dict[str, Any]: + nonlocal submit_index + assert "stream_event_callback" not in kwargs + result = llm_results[submit_index] + submit_index += 1 + return result + + client.submit_queued_llm_call = AsyncMock(side_effect=_submit_queued_llm_call) + client._search_wrapper = None + client._end_summary_storage = cast(Any, None) + client._send_private_message_callback = None + client._send_image_callback = None + client.memory_storage = None + client._knowledge_manager = None + client._cognitive_service = None + client._meme_service = None + client._crawl4ai_capabilities = SimpleNamespace( + available=False, + error=None, + proxy_config_available=False, + ) + events: list[tuple[str, dict[str, Any]]] = [] + + async def _webchat_event_callback(event: str, payload: dict[str, Any]) -> None: + events.append((event, dict(payload))) + + await AIClient.ask( + client, + "hello", + extra_context={"webchat_event_callback": _webchat_event_callback}, + ) + + event_names = [event for event, _payload in events] + assert "stage" in event_names + assert [event for event, _payload in events if event != "stage"] == [ + "tool_start", + "tool_end", + "tool_start", + "tool_end", + ] + stage_names = [ + str(payload.get("stage") or "") for event, payload in events if event == "stage" + ] + assert "building_context" in stage_names + assert "waiting_model" in stage_names + assert "waiting_tools" in stage_names + lifecycle_payloads = [payload for event, payload in events if event != "stage"] + assert lifecycle_payloads[0]["name"] == "lookup" + assert lifecycle_payloads[1]["result"] == "tool result" + assert lifecycle_payloads[2]["name"] == "end" + assert lifecycle_payloads[3]["result"] == "对话已结束" + assert callable(seen_tool_context.get("render_html_to_image")) + assert callable(seen_tool_context.get("render_markdown_to_html")) + + @pytest.mark.asyncio async def test_ai_ask_limits_missing_tool_call_retries() -> None: client: Any = object.__new__(AIClient) @@ -271,6 +415,78 @@ async def test_agent_runner_reraises_queued_llm_error(tmp_path: Path) -> None: ) +@pytest.mark.asyncio +async def test_agent_runner_emits_nested_webchat_agent_stage( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + agent_dir = tmp_path / "demo_agent" + (agent_dir / "tools").mkdir(parents=True) + + agent_config = AgentModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="agent-model", + max_tokens=512, + ) + ai_client = SimpleNamespace( + agent_config=agent_config, + model_selector=SimpleNamespace( + select_agent_config=lambda config, **_kwargs: config + ), + submit_queued_llm_call=AsyncMock( + return_value={"choices": [{"message": {"content": "done"}}]} + ), + ) + events: list[tuple[str, dict[str, Any]]] = [] + + async def _webchat_event_callback(event: str, payload: dict[str, Any]) -> None: + events.append((event, dict(payload))) + + monkeypatch.setattr( + "Undefined.skills.agents.runner.context.AgentToolRegistry", + lambda *_args, **_kwargs: SimpleNamespace(get_tools_schema=lambda: []), + ) + + result = await run_agent_with_tools( + agent_name="demo_agent", + user_content="用户需求:测试", + empty_user_content_message="empty", + default_prompt="你是一个测试助手。", + context={ + "ai_client": cast(Any, ai_client), + "runtime_config": SimpleNamespace( + model_pool_enabled=False, + ai_request_max_retries=0, + ), + "queue_lane": "private", + "webchat_event_callback": _webchat_event_callback, + "webchat_parent_call_id": "call_agent", + "webchat_call_parent_id": "root_agent", + "webchat_depth": 1, + "webchat_agent_path": ["web_agent"], + }, + agent_dir=agent_dir, + logger=logging.getLogger("test_agent_runner_emits_webchat_agent_stage"), + max_iterations=3, + ) + + assert result == "done" + agent_stage_payloads = [ + payload for event, payload in events if event == "agent_stage" + ] + assert [str(payload.get("stage") or "") for payload in agent_stage_payloads] == [ + "context_ready", + "waiting_model", + "done", + ] + assert agent_stage_payloads[0]["webchat_call_id"] == "call_agent" + assert agent_stage_payloads[0]["parent_webchat_call_id"] == "root_agent" + assert agent_stage_payloads[0]["depth"] == 1 + assert agent_stage_payloads[0]["agent_path"] == ["web_agent"] + assert "model=agent-model" in str(agent_stage_payloads[1]["detail"]) + + @pytest.mark.asyncio async def test_submit_queued_llm_call_enqueues_requested_lane() -> None: client: Any = object.__new__(AIClient) diff --git a/tests/test_message_batcher_integration.py b/tests/test_message_batcher_integration.py index d4469e6c..f13f4cc3 100644 --- a/tests/test_message_batcher_integration.py +++ b/tests/test_message_batcher_integration.py @@ -99,6 +99,8 @@ async def test_two_group_messages_merge_into_single_request() -> None: request_data = await_args.args[0] assert request_data["batched_count"] == 2 assert request_data["text"] == "改成狗" # last 文本 + assert request_data["trigger_message_id"] == 2 + assert request_data["message_ids"] == ["1", "2"] assert "帮我画一只猫" in request_data["full_question"] assert "改成狗" in request_data["full_question"] assert "【连续消息说明】" in request_data["full_question"] @@ -139,6 +141,7 @@ async def test_first_at_bot_routes_batch_to_mention_lane() -> None: req = await_args.args[0] assert req["batched_count"] == 2 assert req["is_at_bot"] is True + assert req["message_ids"] == [] assert "(用户 @ 了你)" in req["full_question"] @@ -178,6 +181,7 @@ def _is_at(content: list[dict[str, Any]]) -> bool: assert mention_await is not None mention_req = mention_await.args[0] assert mention_req["batched_count"] == 1 + assert mention_req["message_ids"] == [] # 普通桶仍未发车 cast(AsyncMock, qm.add_group_normal_request).assert_not_called() @@ -226,6 +230,8 @@ async def test_private_consecutive_merge() -> None: assert await_args is not None req = await_args.args[0] assert req["batched_count"] == 2 + assert req["trigger_message_id"] == 11 + assert req["message_ids"] == ["10", "11"] assert "第一条" in req["full_question"] assert "第二条" in req["full_question"] @@ -273,6 +279,7 @@ async def test_superadmin_batched_routes_to_superadmin_lane() -> None: assert await_args is not None req = await_args.args[0] assert req["batched_count"] == 2 + assert req["message_ids"] == [] @pytest.mark.asyncio diff --git a/tests/test_prompt_builder_cognitive_query.py b/tests/test_prompt_builder_cognitive_query.py index bf3304ed..a702b2b3 100644 --- a/tests/test_prompt_builder_cognitive_query.py +++ b/tests/test_prompt_builder_cognitive_query.py @@ -1,5 +1,6 @@ from typing import Any, cast +from Undefined.ai.prompts.cognitive import drop_current_message_if_duplicated from Undefined.ai.prompts import PromptBuilder @@ -38,6 +39,37 @@ def test_build_cognitive_query_uses_current_frame_raw_content() -> None: assert enhanced is False +def test_build_cognitive_query_uses_all_messages_in_current_batch() -> None: + builder = _make_builder() + question = """ +我周三要发版 + + +补充:是后端服务发版 + + +【连续消息说明】以上 2 条 是同一用户连续发送的消息 +【回复策略】 +你可以选择不回复""" + query, enhanced = builder._build_cognitive_query( + question, + extra_context={ + "group_id": 20001, + "sender_name": "测试用户", + "group_name": "研发讨论群", + "is_at_bot": False, + }, + ) + + assert query.startswith("我周三要发版\n补充:是后端服务发版\n语境: ") + assert "会话:群聊" in query + assert "发送者:测试用户" in query + assert "群:研发讨论群" in query + assert "连续消息说明" not in query + assert "回复策略" not in query + assert enhanced is True + + def test_build_cognitive_query_adds_light_context_for_short_content() -> None: builder = _make_builder() question = """ @@ -68,3 +100,45 @@ def test_build_cognitive_query_falls_back_to_plain_question() -> None: query, enhanced = builder._build_cognitive_query("直接提问:今天安排啥?") assert query == "直接提问:今天安排啥?" assert enhanced is False + + +def test_drop_current_message_if_duplicated_removes_whole_current_batch_tail() -> None: + recent_messages = [ + { + "type": "group", + "message_id": "100", + "display_name": "其他用户", + "user_id": "99999", + "chat_id": "20001", + "timestamp": "2026-02-24 11:59:00", + "message": "保留的历史消息", + }, + { + "type": "group", + "message_id": "101", + "display_name": "测试用户", + "user_id": "10001", + "chat_id": "20001", + "timestamp": "2026-02-24 12:00:00", + "message": "我周三要发版", + }, + { + "type": "group", + "message_id": "102", + "display_name": "测试用户", + "user_id": "10001", + "chat_id": "20001", + "timestamp": "2026-02-24 12:00:02", + "message": "补充:是后端服务发版", + }, + ] + question = """ +我周三要发版 + + +补充:是后端服务发版 +""" + + filtered = drop_current_message_if_duplicated(recent_messages, question) + + assert [msg["message"] for msg in filtered] == ["保留的历史消息"] diff --git a/tests/test_prompt_builder_message_order.py b/tests/test_prompt_builder_message_order.py index be4e49ff..3e702cbd 100644 --- a/tests/test_prompt_builder_message_order.py +++ b/tests/test_prompt_builder_message_order.py @@ -6,6 +6,7 @@ import pytest +from Undefined.ai.llm.sanitize import prepare_chat_completion_messages from Undefined.ai.prompts import PromptBuilder from Undefined.end_summary_storage import EndSummaryRecord from Undefined.memory import Memory @@ -144,7 +145,7 @@ async def _fake_recent_messages( "summary": "【短期行动记录(最近 1 条,带时间)】", "history": "【历史消息存档】", "time": "【当前时间】", - "current": "【当前消息】", + "current": "【当前输入批次】", } positions = { name: next( @@ -174,7 +175,81 @@ async def _fake_recent_messages( @pytest.mark.asyncio -async def test_build_messages_keeps_current_message_as_last_item( +async def test_build_messages_keeps_cache_friendly_static_before_dynamic_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + builder = _make_builder() + + async def _fake_load_system_prompt() -> str: + return "系统提示词" + + async def _fake_load_each_rules() -> str: + return "固定规则" + + monkeypatch.setattr(builder, "_load_system_prompt", _fake_load_system_prompt) + monkeypatch.setattr(builder, "_load_each_rules", _fake_load_each_rules) + + async def _fake_recent_messages( + chat_id: str, msg_type: str, start: int, end: int + ) -> list[dict[str, Any]]: + _ = chat_id, msg_type, start, end + return [ + { + "type": "group", + "display_name": "测试用户", + "user_id": "10001", + "chat_id": "20001", + "chat_name": "研发群", + "timestamp": "2026-04-03 10:01:00", + "message": "上一条消息", + "attachments": [], + "role": "member", + "title": "", + } + ] + + messages = await builder.build_messages( + '\n继续看缓存问题\n', + get_recent_messages_callback=_fake_recent_messages, + extra_context={ + "group_id": 20001, + "sender_id": 10001, + "sender_name": "测试用户", + "group_name": "研发群", + "request_type": "group", + }, + ) + + labels = [ + "系统提示词", + "【当前运行环境配置】", + "【可用的 Anthropic Skills】", + "【强制规则 - 必须在进行任何操作前仔细阅读并严格遵守】", + "【memory.* 手动长期记忆(可编辑)】", + "【认知记忆上下文】", + "【短期行动记录(最近 1 条,带时间)】", + "【历史消息存档】", + "【当前时间】", + "【当前输入批次】", + ] + positions = [ + next( + idx + for idx, message in enumerate(messages) + if label in str(message.get("content", "")) + ) + for label in labels + ] + + assert positions == sorted(positions) + assert messages[-2]["role"] == "system" + assert "【当前时间】" in str(messages[-2].get("content", "")) + assert messages[-1]["role"] == "user" + assert "【当前输入批次】" in str(messages[-1].get("content", "")) + + +@pytest.mark.asyncio +async def test_build_messages_keeps_current_input_batch_as_last_item( monkeypatch: pytest.MonkeyPatch, ) -> None: builder = PromptBuilder( @@ -194,7 +269,84 @@ async def _fake_load_each_rules() -> str: messages = await builder.build_messages("直接提问:缓存是否命中?") - assert messages[-1] == { - "role": "user", - "content": "【当前消息】\n直接提问:缓存是否命中?", - } + assert messages[-1]["role"] == "user" + current_content = str(messages[-1].get("content", "")) + assert current_content.startswith("【当前输入批次】\n\n") + assert "直接提问:缓存是否命中?" in current_content + assert "" in current_content + assert "允许你回应和写入 end.observations 的当前输入" in current_content + assert "不能作为 end.observations 的新事实来源" in current_content + + +@pytest.mark.asyncio +async def test_system_prompt_as_user_keeps_current_batch_and_readonly_history_markers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + builder = PromptBuilder( + bot_qq=0, + memory_storage=None, + end_summary_storage=cast(Any, _FakeEndSummaryStorage()), + ) + + async def _fake_load_system_prompt() -> str: + return "系统提示词" + + async def _fake_load_each_rules() -> str: + return "固定规则" + + monkeypatch.setattr(builder, "_load_system_prompt", _fake_load_system_prompt) + monkeypatch.setattr(builder, "_load_each_rules", _fake_load_each_rules) + + async def _fake_recent_messages( + chat_id: str, msg_type: str, start: int, end: int + ) -> list[dict[str, Any]]: + _ = chat_id, msg_type, start, end + return [ + { + "type": "group", + "display_name": "测试用户", + "user_id": "10001", + "chat_id": "20001", + "chat_name": "研发群", + "timestamp": "2026-04-03 10:01:00", + "message": "只读历史消息", + "attachments": [], + "role": "member", + "title": "", + } + ] + + messages = await builder.build_messages( + '\n这次缓存为什么没命中?\n', + get_recent_messages_callback=_fake_recent_messages, + extra_context={ + "group_id": 20001, + "sender_id": 10001, + "sender_name": "测试用户", + "group_name": "研发群", + "request_type": "group", + }, + ) + + cfg: Any = SimpleNamespace( + reasoning_content_replay=False, + system_prompt_as_user=True, + ) + outbound = prepare_chat_completion_messages(cfg, messages) + + assert outbound + assert all( + str(message.get("role", "")).lower() not in {"system", "developer"} + for message in outbound + ) + assert outbound[0]["role"] == "user" + merged_content = str(outbound[0].get("content", "")) + assert "【历史消息存档】(只读上下文)" in merged_content + assert '' in merged_content + assert "【当前输入批次】" in merged_content + assert "" in merged_content + assert "这次缓存为什么没命中?" in merged_content + assert "不能作为 end.observations 的新事实来源" in merged_content + assert merged_content.index("【历史消息存档】") < merged_content.index( + "【当前输入批次】" + ) diff --git a/tests/test_react_message_emoji_tools.py b/tests/test_react_message_emoji_tools.py index 9b553457..05619c43 100644 --- a/tests/test_react_message_emoji_tools.py +++ b/tests/test_react_message_emoji_tools.py @@ -16,6 +16,7 @@ from Undefined.skills.toolsets.messages.react_message_emoji.handler import ( execute as react_message_emoji_execute, ) +from Undefined.utils.message_turn import mark_message_sent_this_turn def _runtime_config() -> Any: @@ -25,6 +26,10 @@ def _runtime_config() -> Any: ) +def _tool_context(**values: Any) -> dict[str, Any]: + return {"mark_message_sent_this_turn": mark_message_sent_this_turn, **values} + + @pytest.mark.asyncio async def test_react_message_emoji_uses_trigger_message_id_and_alias() -> None: onebot_client = SimpleNamespace( @@ -32,15 +37,15 @@ async def test_react_message_emoji_uses_trigger_message_id_and_alias() -> None: fetch_emoji_like=AsyncMock(return_value={"emoji_likes": []}), set_msg_emoji_like=AsyncMock(return_value={}), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 1001, - "sender_id": 2002, - "request_id": "req-react-1", - "trigger_message_id": 5555, - "runtime_config": _runtime_config(), - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=1001, + sender_id=2002, + request_id="req-react-1", + trigger_message_id=5555, + runtime_config=_runtime_config(), + onebot_client=onebot_client, + ) result = await react_message_emoji_execute({"emoji": "👍"}, context) @@ -58,14 +63,14 @@ async def test_react_message_emoji_skip_when_already_set() -> None: ), set_msg_emoji_like=AsyncMock(return_value={}), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 1001, - "request_id": "req-react-2", - "trigger_message_id": 6666, - "runtime_config": _runtime_config(), - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=1001, + request_id="req-react-2", + trigger_message_id=6666, + runtime_config=_runtime_config(), + onebot_client=onebot_client, + ) result = await react_message_emoji_execute({"emoji_id": 76}, context) @@ -80,14 +85,14 @@ async def test_react_message_emoji_reject_cross_session_by_default() -> None: fetch_emoji_like=AsyncMock(return_value={}), set_msg_emoji_like=AsyncMock(return_value={}), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 1001, - "request_id": "req-react-3", - "trigger_message_id": 7777, - "runtime_config": _runtime_config(), - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=1001, + request_id="req-react-3", + trigger_message_id=7777, + runtime_config=_runtime_config(), + onebot_client=onebot_client, + ) result = await react_message_emoji_execute({"emoji_id": 76}, context) @@ -106,14 +111,14 @@ async def delayed_set(*args: Any, **kwargs: Any) -> dict[str, Any]: fetch_emoji_like=AsyncMock(return_value={"emoji_likes": []}), set_msg_emoji_like=AsyncMock(side_effect=delayed_set), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 1001, - "request_id": "req-react-4", - "trigger_message_id": 8888, - "runtime_config": _runtime_config(), - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=1001, + request_id="req-react-4", + trigger_message_id=8888, + runtime_config=_runtime_config(), + onebot_client=onebot_client, + ) result_1, result_2 = await asyncio.gather( react_message_emoji_execute({"emoji_id": 76}, context), diff --git a/tests/test_runtime_api_chat_history.py b/tests/test_runtime_api_chat_history.py index 2219e159..67836230 100644 --- a/tests/test_runtime_api_chat_history.py +++ b/tests/test_runtime_api_chat_history.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import json +from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -8,12 +10,17 @@ from aiohttp import web from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.api.routes import chat as runtime_api_chat + + +@pytest.fixture(autouse=True) +def _isolate_webchat_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) class _DummyHistoryManager: - def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: - _ = user_id, count - return [ + def __init__(self) -> None: + self.records: list[dict[str, Any]] = [ { "display_name": "system", "message": "你好", @@ -26,6 +33,38 @@ def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: }, ] + def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: + _ = user_id, count + return self.records[-count:] + + def get_private_page( + self, + user_id: int, + *, + limit: int, + before: int | None = None, + ) -> tuple[list[dict[str, Any]], bool, int | None, int]: + _ = user_id + end = len(self.records) if before is None else before + start = max(0, end - limit) + return ( + self.records[start:end], + start > 0, + start if start > 0 else None, + len(self.records), + ) + + async def clear_private_history(self, user_id: int) -> int: + _ = user_id + count = len(self.records) + self.records = [] + return count + + +class _JsonRequest(SimpleNamespace): + async def json(self) -> dict[str, object]: + return dict(getattr(self, "_json", {})) + @pytest.mark.asyncio async def test_runtime_chat_history_endpoint_returns_role_mapped_items() -> None: @@ -68,3 +107,444 @@ async def test_runtime_chat_history_endpoint_returns_role_mapped_items() -> None assert payload["items"][0]["content"] == "你好" assert payload["items"][1]["role"] == "bot" assert payload["items"][1]["content"] == "你好,我在。" + + +@pytest.mark.asyncio +async def test_runtime_chat_history_endpoint_returns_attachment_refs() -> None: + history = _DummyHistoryManager() + history.records = [ + { + "display_name": "Bot", + "message": "[图片 uid=pic_abc123 name=image_1.png]", + "timestamp": "2026-02-25 22:00:02", + "attachments": [ + { + "uid": "pic_abc123", + "kind": "image", + "media_type": "image", + "display_name": "image_1.png", + "source_kind": "base64_image", + } + ], + } + ] + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace( + memory_storage=SimpleNamespace(count=lambda: 0), + attachment_registry=None, + ), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_history_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={"limit": "1"}))) + ) + payload = json.loads(response.text or "{}") + + attachment = payload["items"][0]["attachments"][0] + assert attachment["uid"] == "pic_abc123" + assert attachment["media_type"] == "image" + assert attachment["display_name"] == "image_1.png" + + +@pytest.mark.asyncio +async def test_runtime_chat_history_endpoint_returns_webchat_metadata_only_item() -> ( + None +): + history = _DummyHistoryManager() + history.records = [ + { + "display_name": "Bot", + "message": "", + "timestamp": "2026-02-25 22:00:02", + "webchat": { + "display_only": True, + "job_id": "job_1", + "mode": "chat", + "status": "done", + "calls": [ + { + "webchat_call_id": "call_1", + "name": "search", + "is_agent": False, + "status": "done", + "result_preview": "ok", + "children": [], + } + ], + "timeline": [ + { + "type": "call", + "seq": 2, + "call": { + "webchat_call_id": "call_1", + "name": "search", + "is_agent": False, + "status": "done", + "result_preview": "ok", + "children": [], + }, + } + ], + "events": [ + { + "seq": 2, + "event": "tool_start", + "payload": { + "job_id": "job_1", + "tool_call_id": "call_1", + "name": "search", + "arguments_preview": '{"q":"test"}', + "is_agent": False, + }, + }, + { + "seq": 3, + "event": "tool_end", + "payload": { + "job_id": "job_1", + "tool_call_id": "call_1", + "name": "search", + "ok": True, + "result_preview": "ok", + "is_agent": False, + }, + }, + ], + }, + } + ] + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_history_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={"limit": "1"}))) + ) + payload = json.loads(response.text or "{}") + + assert payload["count"] == 1 + item = payload["items"][0] + assert item["role"] == "bot" + assert item["content"] == "" + assert item["webchat"]["job_id"] == "job_1" + assert [event["event"] for event in item["webchat"]["events"]] == [ + "tool_start", + "tool_end", + ] + assert item["webchat"]["calls"][0]["webchat_call_id"] == "call_1" + assert item["webchat"]["calls"][0]["result_preview"] == "ok" + assert item["webchat"]["timeline"][0]["type"] == "call" + assert item["webchat"]["timeline"][0]["call"]["webchat_call_id"] == "call_1" + + +@pytest.mark.asyncio +async def test_runtime_chat_history_endpoint_redacts_legacy_webchat_metadata() -> None: + history = _DummyHistoryManager() + history.records = [ + { + "display_name": "Bot", + "message": "", + "timestamp": "2026-02-25 22:00:02", + "webchat": { + "display_only": True, + "job_id": "job_1", + "mode": "chat", + "status": "done", + "calls": [ + { + "webchat_call_id": "call_1", + "name": "search", + "is_agent": False, + "status": "done", + "arguments_preview": ('{"api_key":"sk-legacy","q":"test"}'), + "result_preview": ("Authorization: Bearer legacy-token"), + "children": [], + } + ], + "timeline": [ + { + "type": "call", + "seq": 2, + "call": { + "webchat_call_id": "call_1", + "name": "search", + "is_agent": False, + "status": "done", + "result_preview": "password=legacy-password", + "children": [], + }, + } + ], + "events": [ + { + "seq": 2, + "event": "tool_start", + "payload": { + "job_id": "job_1", + "tool_call_id": "call_1", + "name": "search", + "arguments_preview": ( + '{"cookie":"sid=legacy-cookie","q":"test"}' + ), + "is_agent": False, + }, + }, + ], + }, + } + ] + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_history_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={"limit": "1"}))) + ) + payload = json.loads(response.text or "{}") + dumped = json.dumps(payload["items"][0]["webchat"], ensure_ascii=False) + + assert "sk-legacy" not in dumped + assert "legacy-token" not in dumped + assert "legacy-password" not in dumped + assert "legacy-cookie" not in dumped + assert "[redacted]" in dumped + + +@pytest.mark.asyncio +async def test_runtime_chat_history_endpoint_supports_before_pagination() -> None: + history = _DummyHistoryManager() + history.records = [ + {"display_name": "system", "message": f"user {idx}", "timestamp": str(idx)} + for idx in range(5) + ] + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + request = cast( + web.Request, + cast(Any, SimpleNamespace(query={"limit": "2", "before": "3"})), + ) + response = await server._chat_history_handler(request) + payload = json.loads(response.text or "{}") + + assert [item["content"] for item in payload["items"]] == ["user 1", "user 2"] + assert payload["has_more"] is True + assert payload["next_before"] == 1 + assert payload["total"] == 5 + + +@pytest.mark.asyncio +async def test_runtime_chat_history_clear_clears_only_when_no_active_job() -> None: + history = _DummyHistoryManager() + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + request = cast(web.Request, cast(Any, SimpleNamespace(query={}))) + + response = await server._chat_history_clear_handler(request) + payload = json.loads(response.text or "{}") + + assert payload["success"] is True + assert payload["cleared"] == 2 + conversation = await server._chat_job_manager.conversation_store.get_conversation( + str(payload["conversation_id"]) + ) + assert conversation is not None + assert conversation["messages"] == [] + + +@pytest.mark.asyncio +async def test_runtime_chat_history_clear_returns_409_for_running_job( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str: + _ = text, send_output + await asyncio.Event().wait() + return "chat" + + history = _DummyHistoryManager() + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + create_request = cast( + web.Request, + cast(Any, _JsonRequest(query={}, _json={"message": "hello"})), + ) + await server._chat_job_create_handler(create_request) + + response = await server._chat_history_clear_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={}))) + ) + payload = json.loads(response.text or "{}") + + assert response.status == 409 + assert payload["error"] == "Chat job is still running" + + +@pytest.mark.asyncio +async def test_runtime_chat_history_clear_returns_409_until_history_finalized() -> None: + history = _DummyHistoryManager() + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + manager = runtime_api_chat.ChatJobManager(context) + job = runtime_api_chat.ChatJob( + job_id="job_finalizing", + text="hello", + created_at=1.0, + updated_at=1.0, + status="done", + history_finalized=False, + ) + manager._jobs[job.job_id] = job + + response = await runtime_api_chat.chat_history_clear_handler( + context, + manager, + cast(web.Request, cast(Any, SimpleNamespace(query={}))), + ) + payload = json.loads(response.text or "{}") + + assert response.status == 409 + assert payload["error"] == "Chat job is still running" + assert history.records + + job.history_finalized = True + job.done.set() + response = await runtime_api_chat.chat_history_clear_handler( + context, + manager, + cast(web.Request, cast(Any, SimpleNamespace(query={}))), + ) + payload = json.loads(response.text or "{}") + + assert response.status == 200 + assert payload["success"] is True + assert payload["cleared"] == 2 + conversation = await manager.conversation_store.get_conversation( + str(payload["conversation_id"]) + ) + assert conversation is not None + assert conversation["messages"] == [] diff --git a/tests/test_runtime_api_chat_jobs.py b/tests/test_runtime_api_chat_jobs.py new file mode 100644 index 00000000..caf3a148 --- /dev/null +++ b/tests/test_runtime_api_chat_jobs.py @@ -0,0 +1,880 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import pytest +from aiohttp import web + +from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.api.routes import chat as runtime_api_chat + + +@pytest.fixture(autouse=True) +def _isolate_webchat_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + + +class _DummyTransport: + def __init__(self, *, closing_after_writes: int | None = None) -> None: + self._closing_after_writes = closing_after_writes + self.write_count = 0 + + def is_closing(self) -> bool: + if self._closing_after_writes is None: + return False + return self.write_count >= self._closing_after_writes + + +class _DummyRequest(SimpleNamespace): + async def json(self) -> dict[str, object]: + return dict(getattr(self, "_json", {})) + + +class _DummyStreamResponse: + def __init__( + self, + *, + status: int, + reason: str, + headers: dict[str, str], + ) -> None: + self.status = status + self.reason = reason + self.headers = dict(headers) + self.writes: list[bytes] = [] + self.eof_written = False + self._request: Any = None + + async def prepare(self, request: web.Request) -> _DummyStreamResponse: + self._request = request + return self + + async def write(self, data: bytes) -> None: + self.writes.append(data) + transport = getattr(self._request, "transport", None) + if isinstance(transport, _DummyTransport): + transport.write_count += 1 + + async def write_eof(self) -> None: + self.eof_written = True + + +def _context() -> RuntimeAPIContext: + return RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(add_private_message=AsyncMock()), + ) + + +async def _last_webchat_record(server: RuntimeAPIServer) -> dict[str, Any]: + conversation = await server._chat_job_manager.conversation_store.get_conversation( + "legacy-system-42" + ) + assert conversation is not None + messages = conversation.get("messages") + assert isinstance(messages, list) + assert messages + return cast(dict[str, Any], messages[-1]) + + +def _decode_sse(writes: list[bytes]) -> list[dict[str, Any]]: + payload = b"".join(writes).decode("utf-8") + events: list[dict[str, Any]] = [] + for block in payload.split("\n\n"): + if not block.strip() or block.startswith(":"): + continue + event = "message" + seq = 0 + data_lines: list[str] = [] + for line in block.splitlines(): + if line.startswith("id:"): + seq = int(line[3:].strip()) + elif line.startswith("event:"): + event = line[6:].strip() + elif line.startswith("data:"): + data_lines.append(line[5:].strip()) + if data_lines: + events.append( + { + "seq": seq, + "event": event, + "payload": json.loads("\n".join(data_lines)), + } + ) + return events + + +@pytest.mark.asyncio +async def test_run_webui_chat_prompt_describes_webui_markdown_html_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, str] = {} + + class _AI: + attachment_registry: object = object() + memory_storage: Any = SimpleNamespace(count=lambda: 0) + runtime_config: Any = SimpleNamespace() + + async def ask(self, question: str, **_kwargs: Any) -> str: + captured["question"] = question + return "" + + context = _context() + context.ai = _AI() + context.onebot = SimpleNamespace( + get_image=AsyncMock(return_value=None), + get_forward_msg=AsyncMock(return_value=[]), + ) + context.command_dispatcher = SimpleNamespace(parse_command=lambda _text: None) + + async def _fake_register_message_attachments(**_kwargs: Any) -> Any: + return SimpleNamespace(normalized_text="hello", attachments=[]) + + monkeypatch.setattr( + runtime_api_chat, + "register_message_attachments", + _fake_register_message_attachments, + ) + + mode = await runtime_api_chat.run_webui_chat( + context, + text="hello", + send_output=AsyncMock(), + ) + + assert mode == "chat" + prompt = captured["question"] + assert "【WebUI 会话】" in prompt + assert "WebUI 支持完整 Markdown 渲染和简单安全 HTML" in prompt + assert ( + "复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放进 fenced code block" + in prompt + ) + assert "完整 HTML 页面请优先使用 ```html 代码框" in prompt + assert "优先在当前聊天消息中直接给出" in prompt + assert "不要为了普通代码片段调用文件生成或文件发送工具" in prompt + assert "始终标明语言或类型" in prompt + assert "不确定语言时使用 ```text" in prompt + + +@pytest.mark.asyncio +async def test_chat_job_events_after_reconnect_and_disconnect_does_not_cancel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + started = asyncio.Event() + release = asyncio.Event() + cancelled = False + + async def _fake_render_message_with_pic_placeholders( + message: str, + *, + registry: Any, + scope_key: str, + strict: bool, + ) -> Any: + _ = registry, scope_key, strict + return SimpleNamespace( + delivery_text=f"rendered {message}", + history_text=f"history {message}", + attachments=[], + ) + + async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str: + nonlocal cancelled + assert text == "hello" + try: + await send_output(42, "first") + started.set() + await release.wait() + await send_output(42, "second") + return "chat" + except asyncio.CancelledError: + cancelled = True + raise + + monkeypatch.setattr( + runtime_api_chat, + "render_message_with_pic_placeholders", + _fake_render_message_with_pic_placeholders, + ) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + monkeypatch.setattr(web, "StreamResponse", _DummyStreamResponse) + + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + create_request = cast( + web.Request, + cast(Any, _DummyRequest(_json={"message": "hello"}, query={})), + ) + create_response = await server._chat_job_create_handler(create_request) + create_payload = json.loads(create_response.text or "{}") + job_id = str(create_payload["job_id"]) + + await asyncio.wait_for(started.wait(), timeout=1) + + first_request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job_id}, + query={"after": "0"}, + headers={"Accept": "text/event-stream"}, + transport=_DummyTransport(closing_after_writes=1), + ), + ), + ) + first_response = await server._chat_job_events_handler(first_request) + first_events = _decode_sse(cast(_DummyStreamResponse, first_response).writes) + assert first_events[0]["event"] == "meta" + first_last_seq = first_events[-1]["seq"] + assert cancelled is False + + release.set() + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": job_id}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["status"] == "done": + break + await asyncio.sleep(0.01) + assert isinstance(detail_payload["duration_ms"], int) + assert detail_payload["finished_at"] is not None + + second_request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job_id}, + query={"after": str(first_last_seq)}, + headers={"Accept": "text/event-stream"}, + transport=_DummyTransport(), + ), + ), + ) + second_response = await server._chat_job_events_handler(second_request) + second_events = _decode_sse(cast(_DummyStreamResponse, second_response).writes) + + assert cancelled is False + assert "stage" in [event["event"] for event in second_events] + assert [event["event"] for event in second_events if event["event"] != "stage"] == [ + "message", + "done", + ] + message_events = [event for event in second_events if event["event"] == "message"] + assert message_events[0]["payload"]["content"] == "rendered second" + + +@pytest.mark.asyncio +async def test_chat_job_cancel_unknown_returns_404() -> None: + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": "missing"}, query={})), + ) + + response = await server._chat_job_cancel_handler(request) + payload = json.loads(response.text or "{}") + + assert response.status == 404 + assert payload["error"] == "Job not found" + + +@pytest.mark.asyncio +async def test_chat_job_events_refreshes_stage_without_advancing_seq( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: + await release.wait() + return "chat" + + context = _context() + monkeypatch.setattr(web, "StreamResponse", _DummyStreamResponse) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + job = await server._chat_job_manager.create_job("hello") + await asyncio.sleep(0.01) + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job.job_id}, + query={"after": str(job.next_seq - 1)}, + headers={"Accept": "text/event-stream"}, + transport=_DummyTransport(closing_after_writes=1), + ), + ), + ) + + response = await server._chat_job_events_handler(request) + events = _decode_sse(cast(_DummyStreamResponse, response).writes) + + assert events[0]["event"] == "stage" + assert events[0]["seq"] == job.next_seq - 1 + assert events[0]["payload"]["stage"] == job.current_stage + assert isinstance(events[0]["payload"]["elapsed_ms"], int) + release.set() + await server._chat_job_manager.cancel_job(job.job_id) + + +@pytest.mark.asyncio +async def test_chat_job_events_refreshes_agent_stage_without_advancing_seq( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat( + _ctx: Any, + *, + webchat_event_callback: Any = None, + **_kwargs: Any, + ) -> str: + assert webchat_event_callback is not None + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_agent", + "webchat_call_id": "call_agent", + "name": "web_agent", + "api_name": "web_agent", + "arguments": {"prompt": "search"}, + "is_agent": True, + }, + ) + await webchat_event_callback( + "agent_stage", + { + "webchat_call_id": "call_agent", + "agent_name": "web_agent", + "stage": "waiting_model", + "detail": "iteration=1", + }, + ) + await release.wait() + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_agent", + "webchat_call_id": "call_agent", + "name": "web_agent", + "api_name": "web_agent", + "ok": True, + "result": "ok", + "is_agent": True, + }, + ) + return "chat" + + monkeypatch.setattr(web, "StreamResponse", _DummyStreamResponse) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + job = await server._chat_job_manager.create_job("hello") + + for _ in range(20): + if any(event.event == "agent_stage" for event in job.events): + break + await asyncio.sleep(0.01) + after = job.next_seq - 1 + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job.job_id}, + query={"after": str(after)}, + headers={"Accept": "text/event-stream"}, + transport=_DummyTransport(closing_after_writes=2), + ), + ), + ) + + response = await server._chat_job_events_handler(request) + events = _decode_sse(cast(_DummyStreamResponse, response).writes) + + agent_stage_events = [event for event in events if event["event"] == "agent_stage"] + assert agent_stage_events + assert agent_stage_events[0]["seq"] == after + assert agent_stage_events[0]["payload"]["webchat_call_id"] == "call_agent" + assert agent_stage_events[0]["payload"]["stage"] == "waiting_model" + assert isinstance(agent_stage_events[0]["payload"]["stage_elapsed_ms"], int) + release.set() + await server._chat_job_manager.cancel_job(job.job_id) + + +@pytest.mark.asyncio +async def test_chat_job_events_json_returns_incremental_events_and_live_stage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat( + _ctx: Any, + *, + webchat_event_callback: Any = None, + **_kwargs: Any, + ) -> str: + assert webchat_event_callback is not None + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_agent", + "webchat_call_id": "call_agent", + "name": "web_agent", + "api_name": "web_agent", + "arguments": {"prompt": "search"}, + "is_agent": True, + }, + ) + await webchat_event_callback( + "agent_stage", + { + "webchat_call_id": "call_agent", + "agent_name": "web_agent", + "stage": "waiting_model", + }, + ) + await release.wait() + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_agent", + "webchat_call_id": "call_agent", + "name": "web_agent", + "api_name": "web_agent", + "ok": True, + "result": "ok", + "is_agent": True, + }, + ) + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + job = await server._chat_job_manager.create_job("hello") + + for _ in range(20): + if any(event.event == "agent_stage" for event in job.events): + break + await asyncio.sleep(0.01) + after = job.next_seq - 1 + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job.job_id}, + query={"after": str(after), "format": "json"}, + headers={"Accept": "application/json"}, + transport=_DummyTransport(), + ), + ), + ) + + response = cast(web.Response, await server._chat_job_events_handler(request)) + payload = json.loads(response.text or "{}") + + assert payload["after"] == after + assert payload["last_seq"] == after + assert payload["job"]["current_agent_stages"][0]["stage"] == "waiting_model" + assert payload["job"]["current_tool_calls"][0]["webchat_call_id"] == "call_agent" + assert payload["job"]["current_tool_calls"][0]["status"] == "running" + assert payload["job"]["current_tool_calls"][0]["is_agent"] is True + assert isinstance(payload["job"]["current_tool_calls"][0]["duration_ms"], int) + assert isinstance(payload["job"]["current_tool_calls"][0]["started_at"], float) + assert payload["job"]["current_tool_calls"][0]["current_stage"] == "waiting_model" + assert payload["events"][0]["event"] == "stage" + assert payload["events"][1]["event"] == "agent_stage" + assert payload["events"][1]["seq"] == after + assert payload["events"][1]["payload"]["transient"] is True + release.set() + await server._chat_job_manager.cancel_job(job.job_id) + + +@pytest.mark.asyncio +async def test_chat_job_events_reject_wrong_conversation_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: + await release.wait() + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + conversation = ( + await server._chat_job_manager.conversation_store.create_conversation() + ) + conversation_id = str(conversation["id"]) + job = await server._chat_job_manager.create_job("hello", conversation_id) + + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job.job_id}, + query={"after": "0", "format": "json", "conversation_id": "other"}, + headers={"Accept": "application/json"}, + transport=_DummyTransport(), + ), + ), + ) + + response = cast(web.Response, await server._chat_job_events_handler(request)) + payload = json.loads(response.text or "{}") + + assert response.status == 404 + assert payload["error"] == "Job not found" + release.set() + await server._chat_job_manager.cancel_job(job.job_id) + + +@pytest.mark.asyncio +async def test_chat_job_persists_webchat_lifecycle_history( + monkeypatch: pytest.MonkeyPatch, +) -> None: + history_calls: list[dict[str, Any]] = [] + + class _History: + async def add_private_message(self, **kwargs: Any) -> None: + history_calls.append(dict(kwargs)) + + async def flush_pending_saves(self) -> None: + return None + + async def _fake_render_message_with_pic_placeholders( + message: str, + *, + registry: Any, + scope_key: str, + strict: bool, + ) -> Any: + _ = registry, scope_key, strict + return SimpleNamespace( + delivery_text=f"rendered {message}", + history_text=f"history {message}", + attachments=[], + ) + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + send_output: Any, + webchat_event_callback: Any = None, + ) -> str: + assert text == "hello" + assert webchat_event_callback is not None + await webchat_event_callback("stage", {"stage": "waiting_model"}) + await webchat_event_callback("token_delta", {"delta": "ignored"}) + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_1", + "webchat_call_id": "agent_1", + "name": "web_agent", + "api_name": "web_agent", + "arguments": {"prompt": "search"}, + "is_agent": True, + }, + ) + await webchat_event_callback( + "agent_stage", + { + "webchat_call_id": "agent_1", + "name": "web_agent", + "agent_name": "web_agent", + "stage": "waiting_model", + "detail": "iteration=1", + "is_agent": True, + }, + ) + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_1_1", + "webchat_call_id": "agent_1/search_1", + "parent_webchat_call_id": "agent_1", + "name": "search", + "api_name": "search", + "arguments": {"q": "test"}, + "is_agent": False, + "depth": 1, + "agent_path": ["web_agent"], + }, + ) + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_1_1", + "webchat_call_id": "agent_1/search_1", + "parent_webchat_call_id": "agent_1", + "name": "search", + "api_name": "search", + "ok": True, + "result": "nested result", + "is_agent": False, + "depth": 1, + "agent_path": ["web_agent"], + }, + ) + await send_output(42, "final") + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_1", + "webchat_call_id": "agent_1", + "name": "web_agent", + "api_name": "web_agent", + "ok": True, + "result": "agent result", + "is_agent": True, + }, + ) + return "chat" + + context = _context() + context.history_manager = _History() + monkeypatch.setattr( + runtime_api_chat, + "render_message_with_pic_placeholders", + _fake_render_message_with_pic_placeholders, + ) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_job_create_handler( + cast( + web.Request, cast(Any, _DummyRequest(_json={"message": "hello"}, query={})) + ) + ) + job_id = str(json.loads(response.text or "{}")["job_id"]) + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": job_id}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["history_finalized"] is True: + break + await asyncio.sleep(0.01) + + assert history_calls == [] + call = await _last_webchat_record(server) + assert call["user_id"] == "42" + assert call["message"] == "history final" + webchat = call["webchat"] + assert webchat["display_only"] is True + assert webchat["job_id"] == job_id + assert isinstance(webchat["duration_ms"], int) + assert webchat["finished_at"] is not None + assert [event["event"] for event in webchat["events"]] == [ + "agent_start", + "agent_stage", + "tool_start", + "tool_end", + "message", + "agent_end", + ] + assert webchat["events"][0]["payload"]["webchat_call_id"] == "agent_1" + assert webchat["events"][1]["payload"]["stage"] == "waiting_model" + assert webchat["events"][1]["payload"]["job_id"] == job_id + assert isinstance(webchat["events"][1]["payload"]["stage_elapsed_ms"], int) + assert webchat["events"][2]["payload"]["parent_webchat_call_id"] == "agent_1" + assert webchat["events"][3]["payload"]["result_preview"] == "nested result" + assert "duration_ms" in webchat["events"][3]["payload"] + assert webchat["events"][4]["payload"]["content"] == "rendered final" + assert webchat["events"][4]["payload"]["parent_webchat_call_id"] == "agent_1" + assert webchat["events"][5]["payload"]["result_preview"] == "agent result" + assert len(webchat["calls"]) == 1 + assert webchat["calls"][0]["webchat_call_id"] == "agent_1" + assert webchat["calls"][0]["is_agent"] is True + assert webchat["calls"][0]["current_stage"] == "waiting_model" + assert webchat["calls"][0]["children"][0]["webchat_call_id"] == "agent_1/search_1" + assert webchat["calls"][0]["children"][0]["result_preview"] == "nested result" + assert [item["type"] for item in webchat["timeline"]] == ["call"] + assert webchat["timeline"][0]["call"]["webchat_call_id"] == "agent_1" + assert webchat["timeline"][0]["call"]["children"][0]["name"] == "search" + assert [item["type"] for item in webchat["calls"][0]["timeline"]] == [ + "stage", + "call", + "message", + ] + assert webchat["calls"][0]["timeline"][0]["stage"] == "waiting_model" + assert webchat["calls"][0]["timeline"][1]["call"]["name"] == "search" + assert webchat["calls"][0]["timeline"][2]["content"] == "rendered final" + + +@pytest.mark.asyncio +async def test_chat_job_finalizes_unclosed_webchat_calls_as_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + history_calls: list[dict[str, Any]] = [] + + class _History: + async def add_private_message(self, **kwargs: Any) -> None: + history_calls.append(dict(kwargs)) + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + send_output: Any, + webchat_event_callback: Any = None, + ) -> str: + _ = send_output + assert text == "hello" + assert webchat_event_callback is not None + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_1", + "webchat_call_id": "call_1", + "name": "search", + "api_name": "search", + "arguments": {"q": "test"}, + "is_agent": False, + }, + ) + raise RuntimeError("boom") + + context = _context() + context.history_manager = _History() + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_job_create_handler( + cast( + web.Request, cast(Any, _DummyRequest(_json={"message": "hello"}, query={})) + ) + ) + job_id = str(json.loads(response.text or "{}")["job_id"]) + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": job_id}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["history_finalized"] is True: + break + await asyncio.sleep(0.01) + + assert history_calls == [] + webchat = (await _last_webchat_record(server))["webchat"] + assert [event["event"] for event in webchat["events"]] == [ + "tool_start", + "tool_end", + ] + assert webchat["events"][1]["payload"]["ok"] is False + assert webchat["events"][1]["payload"]["status"] == "error" + assert webchat["calls"][0]["status"] == "error" + assert webchat["timeline"][0]["call"]["status"] == "error" + + +@pytest.mark.asyncio +async def test_chat_job_history_persists_redacted_webchat_previews( + monkeypatch: pytest.MonkeyPatch, +) -> None: + history_calls: list[dict[str, Any]] = [] + + class _History: + async def add_private_message(self, **kwargs: Any) -> None: + history_calls.append(dict(kwargs)) + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + send_output: Any, + webchat_event_callback: Any = None, + ) -> str: + _ = text, send_output + assert webchat_event_callback is not None + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_secret", + "webchat_call_id": "call_secret", + "name": "external.search", + "arguments": { + "q": "test", + "api_key": "sk-history-secret", + "headers": {"Authorization": "Bearer auth-history-secret"}, + }, + }, + ) + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_secret", + "webchat_call_id": "call_secret", + "name": "external.search", + "ok": True, + "result": {"password": "history-password", "summary": "ok"}, + }, + ) + return "chat" + + context = _context() + context.history_manager = _History() + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_job_create_handler( + cast( + web.Request, cast(Any, _DummyRequest(_json={"message": "hello"}, query={})) + ) + ) + job_id = str(json.loads(response.text or "{}")["job_id"]) + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": job_id}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["history_finalized"] is True: + break + await asyncio.sleep(0.01) + + assert history_calls == [] + webchat = (await _last_webchat_record(server))["webchat"] + dumped = json.dumps(webchat, ensure_ascii=False) + assert "sk-history-secret" not in dumped + assert "auth-history-secret" not in dumped + assert "history-password" not in dumped + assert "[redacted]" in dumped + assert webchat["calls"][0]["result_preview"] == ( + '{"password":"[redacted]","summary":"ok"}' + ) diff --git a/tests/test_runtime_api_chat_stream.py b/tests/test_runtime_api_chat_stream.py index 09738cbf..2e986e68 100644 --- a/tests/test_runtime_api_chat_stream.py +++ b/tests/test_runtime_api_chat_stream.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +from pathlib import Path from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock @@ -11,6 +13,11 @@ from Undefined.api.routes import chat as runtime_api_chat +@pytest.fixture(autouse=True) +def _isolate_webchat_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + + class _DummyTransport: def is_closing(self) -> bool: return False @@ -46,6 +53,148 @@ async def write_eof(self) -> None: self.eof_written = True +def test_sanitize_webchat_event_payload_compacts_webchat_private_send_tool() -> None: + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_start", + { + "tool_call_id": "call_1", + "name": "messages.send_message", + "api_name": "messages-_-send_message", + "arguments": { + "target_type": "private", + "target_id": 42, + "message": "这段正文会作为 message 事件展示", + }, + }, + ) + + assert payload["ui_hint"] == "webchat_private_send" + assert payload["arguments_preview"] == "" + + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_end", + { + "tool_call_id": "call_1", + "name": "messages.send_message", + "api_name": "messages-_-send_message", + "ok": True, + "result": "消息已发送(message_id=123)", + }, + ) + + assert payload["result_preview"] == "消息已发送(message_id=123)" + + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_end", + { + "tool_call_id": "call_2", + "name": "messages.send_private_message", + "api_name": "messages-_-send_private_message", + "ok": True, + "result": "私聊消息已发送给用户 42(message_id=456)", + }, + ) + + assert payload["ui_hint"] == "webchat_private_send" + assert payload["result_preview"] == "私聊消息已发送给用户 42(message_id=456)" + + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_start", + { + "tool_call_id": "call_2", + "name": "messages.send_private_message", + "api_name": "messages-_-send_private_message", + "arguments": { + "target_id": 42, + "message": "私聊正文", + }, + }, + ) + + assert payload["ui_hint"] == "webchat_private_send" + assert payload["arguments_preview"] == "" + + +def test_sanitize_webchat_event_payload_keeps_group_send_message_details() -> None: + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_start", + { + "tool_call_id": "call_1", + "name": "messages.send_message", + "api_name": "messages-_-send_message", + "arguments": { + "target_type": "group", + "target_id": 10001, + "message": "群聊消息", + }, + }, + ) + + assert "ui_hint" not in payload + assert "群聊消息" in payload["arguments_preview"] + assert json.loads(payload["arguments_preview"]) == { + "target_type": "group", + "target_id": 10001, + "message": "群聊消息", + } + + +def test_sanitize_webchat_event_payload_compacts_successful_end_tool() -> None: + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_end", + { + "tool_call_id": "call_end", + "name": "end", + "api_name": "end", + "ok": True, + "result": "对话已结束", + }, + ) + + assert payload["ui_hint"] == "webchat_end" + assert payload["result_preview"] == "对话已结束" + + +def test_sanitize_webchat_event_payload_redacts_secret_previews() -> None: + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_start", + { + "tool_call_id": "call_secret", + "name": "external.search", + "arguments": { + "q": "weather", + "api_key": "sk-live-secret", + "headers": { + "Authorization": "Bearer token-secret", + "Cookie": "sid=session-secret", + }, + }, + }, + ) + + preview = payload["arguments_preview"] + assert "weather" in preview + assert "sk-live-secret" not in preview + assert "token-secret" not in preview + assert "session-secret" not in preview + assert "[redacted]" in preview + + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_end", + { + "tool_call_id": "call_secret", + "name": "external.search", + "ok": True, + "result": "Authorization: Bearer result-secret password=plain-secret", + }, + ) + + result_preview = payload["result_preview"] + assert "result-secret" not in result_preview + assert "plain-secret" not in result_preview + assert "[redacted]" in result_preview + + @pytest.mark.asyncio async def test_runtime_chat_stream_renders_each_message_once( monkeypatch: pytest.MonkeyPatch, @@ -86,7 +235,10 @@ async def _fake_render_message_with_pic_placeholders( ), command_dispatcher=SimpleNamespace(), queue_manager=SimpleNamespace(snapshot=lambda: {}), - history_manager=SimpleNamespace(add_private_message=AsyncMock()), + history_manager=SimpleNamespace( + add_private_message=AsyncMock(), + flush_pending_saves=AsyncMock(), + ), ) server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) @@ -122,6 +274,129 @@ async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str assert "rendered stream reply" in payload assert "event: done" in payload assert response.eof_written is True + context.history_manager.add_private_message.assert_not_awaited() + conversation = await server._chat_job_manager.conversation_store.get_conversation( + "legacy-system-42" + ) + assert conversation is not None + messages = conversation.get("messages") + assert isinstance(messages, list) + assert [item["message"] for item in messages] == ["rendered history reply"] + + +@pytest.mark.asyncio +async def test_runtime_chat_stream_uses_webchat_lifecycle_events_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_render_message_with_pic_placeholders( + message: str, + *, + registry: Any, + scope_key: str, + strict: bool, + ) -> Any: + _ = registry, scope_key, strict + return SimpleNamespace( + delivery_text=message, + history_text=message, + attachments=[], + ) + + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace( + add_private_message=AsyncMock(), + flush_pending_saves=AsyncMock(), + ), + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + send_output: Any, + webchat_event_callback: Any = None, + ) -> str: + assert text == "hello" + assert webchat_event_callback is not None + await webchat_event_callback("token_delta", {"delta": "ignored"}) + await webchat_event_callback( + "tool_delta", + {"id": "call_1", "arguments_delta": '{"q"'}, + ) + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_1", + "name": "search", + "api_name": "search", + "arguments": {"q": "weather"}, + "is_agent": False, + }, + ) + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_1", + "name": "search", + "api_name": "search", + "ok": True, + "result": "sunny", + "is_agent": False, + }, + ) + await send_output(42, "final") + return "chat" + + monkeypatch.setattr( + runtime_api_chat, + "render_message_with_pic_placeholders", + _fake_render_message_with_pic_placeholders, + ) + monkeypatch.setattr(web, "StreamResponse", _DummyStreamResponse) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + transport=_DummyTransport(), + ), + ), + ) + + response = await server._chat_handler(request) + + assert isinstance(response, _DummyStreamResponse) + payload = b"".join(response.writes).decode("utf-8") + assert "event: token_delta" not in payload + assert "event: tool_delta" not in payload + assert "event: stage" in payload + assert '"stage": "received"' in payload + assert '"elapsed_ms":' in payload + assert '"duration_ms":' in payload + assert "event: tool_start" in payload + assert "event: tool_end" in payload + assert "event: message" in payload @pytest.mark.asyncio diff --git a/tests/test_runtime_api_commands.py b/tests/test_runtime_api_commands.py new file mode 100644 index 00000000..631b1d65 --- /dev/null +++ b/tests/test_runtime_api_commands.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from aiohttp import web + +from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.services.commands.registry import CommandRegistry + + +def _write_command( + commands_dir: Path, + directory: str, + config: dict[str, Any], +) -> None: + command_dir = commands_dir / directory + command_dir.mkdir(parents=True) + (command_dir / "config.json").write_text( + json.dumps(config, ensure_ascii=False), + encoding="utf-8", + ) + (command_dir / "handler.py").write_text( + "from Undefined.services.commands.context import CommandContext\n\n" + "async def execute(args: list[str], context: CommandContext) -> None:\n" + " _ = args, context\n", + encoding="utf-8", + ) + + +def _config() -> Any: + config = cast( + Any, + SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + admin_qqs=[10001], + ), + ) + config.is_superadmin = lambda user_id: int(user_id) == 10001 + config.is_admin = lambda user_id: int(user_id) in {10001} + return config + + +def _context(registry: CommandRegistry) -> RuntimeAPIContext: + dispatcher = SimpleNamespace( + command_registry=registry, + sender=SimpleNamespace(), + ai=SimpleNamespace(), + faq_storage=SimpleNamespace(), + onebot=SimpleNamespace(), + security=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + rate_limiter=None, + ) + return RuntimeAPIContext( + config_getter=_config, + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), + command_dispatcher=dispatcher, + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(), + ) + + +@pytest.mark.asyncio +async def test_commands_api_exposes_changelog_alias_subcommands() -> None: + commands_dir = Path("src/Undefined/skills/commands") + registry = CommandRegistry(commands_dir) + registry.load_commands() + server = RuntimeAPIServer(_context(registry), host="127.0.0.1", port=8788) + + response = await server._command_detail_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={"scope": "webui"}, + match_info={"command_name": "cl"}, + ), + ), + ) + ) + payload = json.loads(response.text or "{}") + command = payload["command"] + + assert command["name"] == "changelog" + assert command["aliases"] == ["cl"] + subcommands = {item["name"]: item for item in command["subcommands"]} + assert set(subcommands) == {"latest", "list", "show"} + assert subcommands["list"]["usage"] == "/changelog list [数量]" + assert subcommands["show"]["usage"] == "/changelog show <版本号>" + assert subcommands["latest"]["usage"] == "/changelog latest" + + +@pytest.mark.asyncio +async def test_commands_api_lists_webui_available_commands_and_subcommands( + tmp_path: Path, +) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir() + _write_command( + commands_dir, + "public_cmd", + { + "name": "faq", + "description": "FAQ 管理", + "usage": "/faq [ls|view|del]", + "example": "/faq ls", + "permission": "public", + "allow_in_private": True, + "aliases": ["f"], + "order": 10, + "subcommands": { + "ls": {"description": "列出 FAQ"}, + "del": { + "description": "删除 FAQ", + "permission": "admin", + "args": "", + }, + }, + }, + ) + _write_command( + commands_dir, + "group_only", + { + "name": "bugfix", + "description": "群聊修复报告", + "usage": "/bugfix <开始> <结束>", + "permission": "admin", + "allow_in_private": False, + "order": 20, + }, + ) + registry = CommandRegistry(commands_dir) + registry.load_commands() + server = RuntimeAPIServer(_context(registry), host="127.0.0.1", port=8788) + + response = await server._commands_list_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={"scope": "webui"}))) + ) + payload = json.loads(response.text or "{}") + + assert payload["scope"] == "webui" + assert payload["execution_scope"] == "private" + assert payload["sender_id"] == 10001 + assert [item["name"] for item in payload["commands"]] == ["faq"] + command = payload["commands"][0] + assert command["trigger"] == "/faq" + assert command["aliases"] == ["f"] + assert command["alias_triggers"] == ["/f"] + assert command["available"] is True + assert [item["name"] for item in command["subcommands"]] == ["del", "ls"] + delete_subcommand = command["subcommands"][0] + assert delete_subcommand["trigger"] == "/faq del" + assert delete_subcommand["usage"] == "/faq del " + assert delete_subcommand["available"] is True + + +@pytest.mark.asyncio +async def test_command_detail_accepts_alias_and_can_include_unavailable( + tmp_path: Path, +) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir() + _write_command( + commands_dir, + "faq", + { + "name": "faq", + "description": "FAQ 管理", + "usage": "/faq [ls]", + "permission": "public", + "allow_in_private": False, + "aliases": ["f"], + "show_in_help": True, + }, + ) + registry = CommandRegistry(commands_dir) + registry.load_commands() + server = RuntimeAPIServer(_context(registry), host="127.0.0.1", port=8788) + + response = await server._command_detail_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={"scope": "webui", "include_unavailable": "true"}, + match_info={"command_name": "f"}, + ), + ), + ) + ) + payload = json.loads(response.text or "{}") + + assert payload["requested_name"] == "f" + assert payload["command"]["name"] == "faq" + assert payload["command"]["available"] is False + assert payload["command"]["unavailable_reason"] == "private_not_allowed" diff --git a/tests/test_send_message_tool.py b/tests/test_send_message_tool.py index 74de2253..01bf901f 100644 --- a/tests/test_send_message_tool.py +++ b/tests/test_send_message_tool.py @@ -8,7 +8,10 @@ import pytest from Undefined.attachments import AttachmentRecord, AttachmentRegistry +from Undefined.context import RequestContext from Undefined.skills.toolsets.messages.send_message.handler import execute +from Undefined.utils.coerce import was_message_sent +from Undefined.utils.message_turn import mark_message_sent_this_turn def _build_runtime_config() -> Any: @@ -18,6 +21,10 @@ def _build_runtime_config() -> Any: ) +def _tool_context(**values: Any) -> dict[str, Any]: + return {"mark_message_sent_this_turn": mark_message_sent_this_turn, **values} + + @pytest.mark.asyncio async def test_send_message_private_passes_context_group_as_preferred_temp_group() -> ( None @@ -26,15 +33,15 @@ async def test_send_message_private_passes_context_group_as_preferred_temp_group send_group_message=AsyncMock(), send_private_message=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "user_id": 20002, - "sender_id": 20002, - "request_id": "req-1", - "runtime_config": _build_runtime_config(), - "sender": sender, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + user_id=20002, + sender_id=20002, + request_id="req-1", + runtime_config=_build_runtime_config(), + sender=sender, + ) result = await execute( { @@ -60,14 +67,14 @@ async def test_send_message_private_passes_context_group_as_preferred_temp_group @pytest.mark.asyncio async def test_send_message_group_callback_passes_reply_to() -> None: send_message_callback = AsyncMock() - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-2", - "runtime_config": _build_runtime_config(), - "send_message_callback": send_message_callback, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-2", + runtime_config=_build_runtime_config(), + send_message_callback=send_message_callback, + ) result = await execute( { @@ -85,14 +92,14 @@ async def test_send_message_group_callback_passes_reply_to() -> None: @pytest.mark.asyncio async def test_send_message_private_callback_passes_reply_to() -> None: send_private_message_callback = AsyncMock() - context: dict[str, Any] = { - "request_type": "private", - "user_id": 30003, - "sender_id": 30003, - "request_id": "req-3", - "runtime_config": _build_runtime_config(), - "send_private_message_callback": send_private_message_callback, - } + context: dict[str, Any] = _tool_context( + request_type="private", + user_id=30003, + sender_id=30003, + request_id="req-3", + runtime_config=_build_runtime_config(), + send_private_message_callback=send_private_message_callback, + ) result = await execute( { @@ -109,21 +116,46 @@ async def test_send_message_private_callback_passes_reply_to() -> None: assert context["message_sent_this_turn"] is True +@pytest.mark.asyncio +async def test_send_message_marks_request_context_when_context_is_copied() -> None: + send_message_callback = AsyncMock() + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-request-context", + runtime_config=_build_runtime_config(), + send_message_callback=send_message_callback, + ) + + async with RequestContext( + request_type="group", + group_id=10001, + sender_id=20002, + ) as req_ctx: + result = await execute({"message": "hello"}, dict(context)) + + assert result == "消息已发送" + assert was_message_sent(req_ctx) is True + + assert "message_sent_this_turn" not in context + + @pytest.mark.asyncio async def test_send_message_does_not_implicitly_use_trigger_message_id() -> None: sender = SimpleNamespace( send_group_message=AsyncMock(), send_private_message=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "trigger_message_id": 99999, - "request_id": "req-4", - "runtime_config": _build_runtime_config(), - "sender": sender, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + trigger_message_id=99999, + request_id="req-4", + runtime_config=_build_runtime_config(), + sender=sender, + ) result = await execute( { @@ -147,14 +179,14 @@ async def test_send_message_returns_sent_message_id_when_available() -> None: send_group_message=AsyncMock(return_value=77777), send_private_message=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-5", - "runtime_config": _build_runtime_config(), - "sender": sender, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-5", + runtime_config=_build_runtime_config(), + sender=sender, + ) result = await execute( { @@ -183,15 +215,15 @@ async def test_send_message_renders_pic_uid_before_sending(tmp_path: Path) -> No display_name="demo.png", source_kind="test", ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-6", - "runtime_config": _build_runtime_config(), - "sender": sender, - "attachment_registry": registry, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-6", + runtime_config=_build_runtime_config(), + sender=sender, + attachment_registry=registry, + ) result = await execute( { @@ -236,16 +268,16 @@ async def test_send_message_renders_webui_scoped_pic_uid_before_sending( display_name="webui.png", source_kind="test", ) - context: dict[str, Any] = { - "request_type": "private", - "user_id": 42, - "sender_id": 10001, - "request_id": "req-webui-1", - "runtime_config": _build_runtime_config(), - "sender": sender, - "attachment_registry": registry, - "webui_session": True, - } + context: dict[str, Any] = _tool_context( + request_type="private", + user_id=42, + sender_id=10001, + request_id="req-webui-1", + runtime_config=_build_runtime_config(), + sender=sender, + attachment_registry=registry, + webui_session=True, + ) result = await execute( { @@ -307,15 +339,15 @@ async def test_send_message_passes_meme_attachments_for_global_meme_uid( else None ) ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-meme-1", - "runtime_config": _build_runtime_config(), - "sender": sender, - "attachment_registry": registry, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-meme-1", + runtime_config=_build_runtime_config(), + sender=sender, + attachment_registry=registry, + ) result = await execute( { diff --git a/tests/test_send_poke_tool.py b/tests/test_send_poke_tool.py index 7d4af4c2..65e5d79b 100644 --- a/tests/test_send_poke_tool.py +++ b/tests/test_send_poke_tool.py @@ -8,6 +8,7 @@ from Undefined.context import RequestContext from Undefined.skills.toolsets.messages.send_poke.handler import execute +from Undefined.utils.message_turn import mark_message_sent_this_turn def _build_runtime_config() -> Any: @@ -18,6 +19,10 @@ def _build_runtime_config() -> Any: ) +def _tool_context(**values: Any) -> dict[str, Any]: + return {"mark_message_sent_this_turn": mark_message_sent_this_turn, **values} + + @pytest.mark.asyncio async def test_send_poke_group_default_target_writes_group_history() -> None: history_manager = SimpleNamespace( @@ -28,16 +33,16 @@ async def test_send_poke_group_default_target_writes_group_history() -> None: send_group_poke=AsyncMock(), send_private_poke=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "user_id": 20002, - "sender_id": 20002, - "request_id": "req-1", - "runtime_config": _build_runtime_config(), - "history_manager": history_manager, - "sender": sender, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + user_id=20002, + sender_id=20002, + request_id="req-1", + runtime_config=_build_runtime_config(), + history_manager=history_manager, + sender=sender, + ) result = await execute({}, context) @@ -61,15 +66,15 @@ async def test_send_poke_private_default_target_writes_private_history() -> None add_private_message=AsyncMock(), ) onebot_client = SimpleNamespace(send_private_poke=AsyncMock()) - context: dict[str, Any] = { - "request_type": "private", - "user_id": 30003, - "sender_id": 30003, - "request_id": "req-2", - "runtime_config": _build_runtime_config(), - "history_manager": history_manager, - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="private", + user_id=30003, + sender_id=30003, + request_id="req-2", + runtime_config=_build_runtime_config(), + history_manager=history_manager, + onebot_client=onebot_client, + ) result = await execute({}, context) @@ -92,15 +97,15 @@ async def test_send_poke_explicit_group_and_target_user() -> None: add_private_message=AsyncMock(), ) onebot_client = SimpleNamespace(send_group_poke=AsyncMock()) - context: dict[str, Any] = { - "request_type": "private", - "user_id": 40004, - "sender_id": 40004, - "request_id": "req-3", - "runtime_config": _build_runtime_config(), - "history_manager": history_manager, - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="private", + user_id=40004, + sender_id=40004, + request_id="req-3", + runtime_config=_build_runtime_config(), + history_manager=history_manager, + onebot_client=onebot_client, + ) result = await execute( {"target_type": "group", "target_id": 88888, "target_user_id": 99999}, @@ -123,10 +128,10 @@ async def test_send_poke_infers_from_request_context_when_context_missing() -> N send_group_poke=AsyncMock(), send_private_poke=AsyncMock(), ) - context: dict[str, Any] = { - "sender": sender, - "runtime_config": _build_runtime_config(), - } + context: dict[str, Any] = _tool_context( + sender=sender, + runtime_config=_build_runtime_config(), + ) async with RequestContext( request_type="group", @@ -147,19 +152,19 @@ async def test_send_poke_group_blacklist_message() -> None: send_group_poke=AsyncMock(), send_private_poke=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-blacklist-1", - "runtime_config": SimpleNamespace( + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-blacklist-1", + runtime_config=SimpleNamespace( bot_qq=123456, is_group_allowed=lambda _gid: False, is_private_allowed=lambda _uid: True, group_access_denied_reason=lambda _gid: "blacklist", ), - "sender": sender, - } + sender=sender, + ) result = await execute({}, context) diff --git a/tests/test_send_private_message_tool.py b/tests/test_send_private_message_tool.py index 0cd79d00..7e67bb44 100644 --- a/tests/test_send_private_message_tool.py +++ b/tests/test_send_private_message_tool.py @@ -6,7 +6,10 @@ import pytest +from Undefined.context import RequestContext from Undefined.skills.toolsets.messages.send_private_message.handler import execute +from Undefined.utils.coerce import was_message_sent +from Undefined.utils.message_turn import mark_message_sent_this_turn def _build_runtime_config() -> Any: @@ -15,15 +18,19 @@ def _build_runtime_config() -> Any: ) +def _tool_context(**values: Any) -> dict[str, Any]: + return {"mark_message_sent_this_turn": mark_message_sent_this_turn, **values} + + @pytest.mark.asyncio async def test_send_private_message_callback_passes_reply_to() -> None: send_private_message_callback = AsyncMock() - context: dict[str, Any] = { - "user_id": 12345, - "request_id": "req-private-1", - "runtime_config": _build_runtime_config(), - "send_private_message_callback": send_private_message_callback, - } + context: dict[str, Any] = _tool_context( + user_id=12345, + request_id="req-private-1", + runtime_config=_build_runtime_config(), + send_private_message_callback=send_private_message_callback, + ) result = await execute( { @@ -40,17 +47,38 @@ async def test_send_private_message_callback_passes_reply_to() -> None: assert context["message_sent_this_turn"] is True +@pytest.mark.asyncio +async def test_send_private_message_marks_request_context_when_context_is_copied() -> ( + None +): + send_private_message_callback = AsyncMock() + context: dict[str, Any] = _tool_context( + user_id=12345, + request_id="req-private-context", + runtime_config=_build_runtime_config(), + send_private_message_callback=send_private_message_callback, + ) + + async with RequestContext(request_type="private", user_id=12345) as req_ctx: + result = await execute({"message": "hello direct private"}, dict(context)) + + assert result == "私聊消息已发送给用户 12345" + assert was_message_sent(req_ctx) is True + + assert "message_sent_this_turn" not in context + + @pytest.mark.asyncio async def test_send_private_message_returns_sent_message_id_when_available() -> None: sender = SimpleNamespace( send_private_message=AsyncMock(return_value=99999), ) - context: dict[str, Any] = { - "user_id": 12345, - "request_id": "req-private-2", - "runtime_config": _build_runtime_config(), - "sender": sender, - } + context: dict[str, Any] = _tool_context( + user_id=12345, + request_id="req-private-2", + runtime_config=_build_runtime_config(), + sender=sender, + ) result = await execute( { diff --git a/tests/test_summary_agent.py b/tests/test_summary_agent.py index 1b4f72cf..b935607e 100644 --- a/tests/test_summary_agent.py +++ b/tests/test_summary_agent.py @@ -7,6 +7,7 @@ import pytest from Undefined.config.models import AgentModelConfig +from Undefined.skills.agents.runner import DEFAULT_AGENT_MAX_ITERATIONS from Undefined.skills.agents.summary_agent.handler import ( _build_user_content, execute as summary_agent_execute, @@ -47,7 +48,7 @@ async def test_summary_agent_normal_execution() -> None: assert "消息总结助手" in call_kwargs["default_prompt"] assert call_kwargs["context"] is context assert isinstance(call_kwargs["agent_dir"], Path) - assert call_kwargs["max_iterations"] == 10 + assert call_kwargs["max_iterations"] == DEFAULT_AGENT_MAX_ITERATIONS assert call_kwargs["tool_error_prefix"] == "错误" diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index f81b9393..7c9d8e6a 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -32,11 +32,43 @@ def test_system_prompts_include_info_gate_and_style_constraints(path: Path) -> N def test_naga_prompt_requires_scope_before_naga_analysis() -> None: text = Path("res/prompts/undefined_nagaagent.xml").read_text(encoding="utf-8") + assert '' in text + assert "强制路由规则" in text + assert "必须调用的工具/Agent 名称就是 `naga_code_analysis_agent`" in text + assert ( + "不得凭自身记忆、历史印象、常识、旧上下文或用户提供的片段直接回答 NagaAgent 技术问题" + in text + ) + assert ( + "不要改用 web_agent、file_analysis_agent、普通搜索、直接读文件工具或你自己的推测替代" + in text + ) assert "直接把宽泛问题丢给 naga_code_analysis_agent" in text assert ( "先追问具体模块 / 报错 / 现象;只有范围收窄后再调用 naga_code_analysis_agent" in text ) + assert "待范围收窄后再调用 `naga_code_analysis_agent`" in text + + +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_describe_webui_markdown_and_html_output(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + required_snippets = [ + "WebUI Markdown 与 HTML 输出", + 'location="WebUI私聊"', + "WebUI 私聊的身份视角固定为系统虚拟用户 system#42", + "权限视角固定为 superadmin", + "WebUI 支持完整 Markdown 渲染", + "简单安全 HTML", + "在 WebUI 会话中,凡是需要输出代码,优先直接在聊天回复里给出", + "复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放入 fenced code block", + "所有代码块都必须标明语言或类型", + "完整 HTML 页面优先使用 ```html 代码框输出", + ] + for snippet in required_snippets: + assert snippet in text @pytest.mark.parametrize("path", PROMPT_PATHS) @@ -60,6 +92,16 @@ def test_system_prompts_define_persona_nicknames_and_ownership_bounds( assert "资深开发者" not in text +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_pin_undefined_literal_spelling(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + assert "必须逐字拼写为 Undefined" in text + assert "必须使用字面量 Undefined" in text + assert "公开回复、工具参数、memo、observations" in text + assert "禁止在 observations 中写成 Unfined、Undefind、undefind" in text + + def test_naga_prompt_keeps_relationship_contextual_and_non_claiming() -> None: text = Path("res/prompts/undefined_nagaagent.xml").read_text(encoding="utf-8") @@ -122,7 +164,10 @@ def test_system_prompts_tell_end_to_record_whole_current_input_batch( assert "memo / observations 必须覆盖整个【当前输入批次】" in text assert "不要只根据最后一条消息记录" in text - assert "end.observations 必须覆盖整批消息中值得留存的信息" in text + assert "end.observations 必须覆盖整批消息中有价值的信息" in text + assert "不要求与 bot 相关,也不要求长期稳定" in text + assert "当前批次中有价值即可记录" in text + assert "不能作为 observations 的新事实来源" in text assert "系统会围绕当前输入批次自动检索相关内容" in text assert "何时应该填写 memo" in text assert "何时应该填写 summary" not in text @@ -138,8 +183,14 @@ def test_end_tool_schema_mentions_current_input_batch() -> None: observations = properties["observations"] assert "当前输入批次" in function["description"] + assert "不要求与 bot 相关" in function["description"] + assert "不要求长期稳定" in function["description"] + assert "项目名/主名必须逐字写作 Undefined" in function["description"] assert "必须覆盖整批消息内容" in observations["description"] assert "不能只记录最后一条" in observations["description"] + assert "当前批次中有价值即可记录" in observations["description"] + assert "禁止从其中摘取新事实写入 observations" in observations["description"] + assert "禁止写成 Unfined、Undefind、undefind" in observations["description"] assert "summary" not in properties assert "action_summary" not in properties assert "new_info" not in properties @@ -149,9 +200,23 @@ def test_historian_prompts_reference_current_input_batch_source() -> None: rewrite = Path("res/prompts/historian_rewrite.md").read_text(encoding="utf-8") merge = Path("res/prompts/historian_profile_merge.md").read_text(encoding="utf-8") - assert "当前输入批次提取到的一条新记忆" in rewrite + assert "当前输入批次提取到的一条有价值新观察" in rewrite + assert "最近消息参考只能消歧,禁止作为新事实来源" in rewrite assert "当前输入批次原文(触发本轮;连续消息会按时间顺序列出多条)" in rewrite assert "当前输入批次原文" in merge + assert "禁止作为本轮新事实来源" in merge + + +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_do_not_treat_you_ai_bot_as_automatic_mention( + path: Path, +) -> None: + text = path.read_text(encoding="utf-8") + + assert "不要先入为主把「你」「AI」「bot」「机器人」当作在叫 Undefined" in text + assert "泛称不是触发词" in text + assert "无法确认指向 Undefined 时默认不回复" in text + assert "「你」「AI」「bot」「机器人」不是自动触发" in text @pytest.mark.parametrize("path", PROMPT_PATHS) diff --git a/tests/test_webchat_conversations.py b/tests/test_webchat_conversations.py new file mode 100644 index 00000000..5de8052e --- /dev/null +++ b/tests/test_webchat_conversations.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import pytest +from aiohttp import web + +from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.api.routes import chat as runtime_api_chat +from Undefined.api.webchat_store import ( + DEFAULT_WEBCHAT_CONVERSATION_ID, + WEBCHAT_VIRTUAL_USER_ID, + generate_webchat_title, +) + + +class _JsonRequest(SimpleNamespace): + async def json(self) -> dict[str, object]: + return dict(getattr(self, "_json", {})) + + +class _History: + def __init__(self) -> None: + self.records: list[dict[str, Any]] = [ + {"display_name": "system", "message": "旧问题是什么", "timestamp": "1"}, + {"display_name": "Bot", "message": "旧答案是这个", "timestamp": "2"}, + ] + + def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: + _ = user_id + return self.records[-count:] + + +def _context(history: Any | None = None) -> RuntimeAPIContext: + return RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=AsyncMock(return_value=None), + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history or _History(), + ) + + +@pytest.mark.asyncio +async def test_webchat_runtime_has_detailed_flow_logs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.chdir(tmp_path) + + async def _fake_ask( + *_args: Any, + send_message_callback: Any, + **_kwargs: Any, + ) -> str: + await send_message_callback("AI 已处理") + return "" + + async def _fake_generate_title(_ai: Any, question: str, answer: str) -> str: + _ = question, answer + return "生成标题" + + ai = SimpleNamespace( + ask=_fake_ask, + attachment_registry=None, + memory_storage=SimpleNamespace(count=lambda: 0), + runtime_config=SimpleNamespace(), + ) + context = _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])) + context.ai = ai + monkeypatch.setattr( + runtime_api_chat, "generate_webchat_title", _fake_generate_title + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + with caplog.at_level(logging.INFO): + create_response = await server._chat_job_create_handler( + cast( + web.Request, + cast(Any, _JsonRequest(query={}, _json={"message": "请回答"})), + ) + ) + create_payload = json.loads(create_response.text or "{}") + job_id = str(create_payload["job_id"]) + job = await server._chat_job_manager.get_job(job_id) + assert job is not None + await job.done.wait() + + events_response = await server._chat_job_events_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={}, + headers={}, + match_info={"job_id": job_id}, + ), + ), + ) + ) + assert events_response.status == 200 + title_task = server._chat_job_manager.conversation_store._title_tasks[ + job.conversation_id + ] + await title_task + + log_text = caplog.text + assert "[RuntimeAPI][WebChat] 创建 job" in log_text + assert "[RuntimeAPI][WebChat] job 开始" in log_text + assert "[RuntimeAPI][WebChat] 输入附件注册完成" in log_text + assert "[RuntimeAPI][WebChat] 调用 AI" in log_text + assert "[RuntimeAPI][WebChat] job 历史落盘" in log_text + assert "[RuntimeAPI][WebChat] 调度标题生成" in log_text + assert "[RuntimeAPI][WebChat] 标题生成完成" in log_text + assert "[WebChat] 会话存储加载完成" in log_text + assert "[WebChat] 追加消息" in log_text + + +@pytest.mark.asyncio +async def test_webchat_legacy_history_migrates_once_and_delete_does_not_remigrate( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + request = cast(web.Request, cast(Any, SimpleNamespace(query={}))) + + first = await server._chat_conversations_handler(request) + payload = json.loads(first.text or "{}") + assert [item["id"] for item in payload["conversations"]] == [ + DEFAULT_WEBCHAT_CONVERSATION_ID + ] + assert payload["conversations"][0]["title"].startswith("旧问题") + + delete = await server._chat_conversation_delete_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={}, + match_info={"conversation_id": DEFAULT_WEBCHAT_CONVERSATION_ID}, + ), + ), + ) + ) + assert delete.status == 200 + + second = await server._chat_conversations_handler(request) + payload = json.loads(second.text or "{}") + assert payload["conversations"] == [] + assert (tmp_path / "data" / "webchat" / "legacy_private_42_migrated.json").exists() + + +@pytest.mark.asyncio +async def test_webchat_title_generation_uses_first_question_and_answer( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + context = _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])) + captured: dict[str, str] = {} + + async def _fake_generate_title(_ai: Any, question: str, answer: str) -> str: + captured["question"] = question + captured["answer"] = answer + return "首问首答标题" + + monkeypatch.setattr( + runtime_api_chat, "generate_webchat_title", _fake_generate_title + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + create_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + conversation = json.loads(create_response.text or "{}")["conversation"] + conversation_id = str(conversation["id"]) + + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="user", + text_content="请解释缓存命中", + display_name="system", + user_name="system", + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="bot", + text_content="缓存命中依赖稳定前缀。", + display_name="Bot", + user_name="Bot", + ) + await server._chat_job_manager.maybe_schedule_title_generation(conversation_id) + task = server._chat_job_manager.conversation_store._title_tasks[conversation_id] + await task + + updated = await server._chat_job_manager.conversation_store.get_conversation( + conversation_id + ) + assert captured == { + "question": "请解释缓存命中", + "answer": "缓存命中依赖稳定前缀。", + } + assert updated is not None + assert updated["title"] == "首问首答标题" + assert updated["title_status"] == "generated" + + +@pytest.mark.asyncio +async def test_webchat_title_generation_uses_chat_model_not_summary_model() -> None: + chat_config = SimpleNamespace(model_name="chat-model") + selected_config = SimpleNamespace(model_name="selected-chat-model") + captured: dict[str, Any] = {} + + def _select_chat_config( + primary: Any, + *, + group_id: int, + user_id: int, + global_enabled: bool, + ) -> Any: + captured["primary"] = primary + captured["group_id"] = group_id + captured["user_id"] = user_id + captured["global_enabled"] = global_enabled + return selected_config + + async def _submit_background_llm_call(**kwargs: Any) -> dict[str, Any]: + captured["submit_kwargs"] = kwargs + return {"choices": [{"message": {"content": " 聊天模型标题 "}}]} + + def _summary_resolver() -> Any: + raise AssertionError("summary model resolver should not be used") + + ai = SimpleNamespace( + chat_config=chat_config, + runtime_config=SimpleNamespace(model_pool_enabled=True), + model_selector=SimpleNamespace(select_chat_config=_select_chat_config), + submit_background_llm_call=_submit_background_llm_call, + _resolve_summary_model_for_requests=_summary_resolver, + ) + + title = await generate_webchat_title(ai, "首问", "首答") + + assert title == "聊天模型标题" + assert captured["primary"] is chat_config + assert captured["group_id"] == 0 + assert captured["user_id"] == WEBCHAT_VIRTUAL_USER_ID + assert captured["global_enabled"] is True + submit_kwargs = captured["submit_kwargs"] + assert submit_kwargs["model_config"] is selected_config + assert submit_kwargs["call_type"] == "webchat_title" + assert "max_tokens" not in submit_kwargs + + +@pytest.mark.asyncio +async def test_webchat_manual_title_blocks_generated_overwrite( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + server = RuntimeAPIServer( + _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])), + host="127.0.0.1", + port=8788, + ) + create_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + conversation_id = str( + json.loads(create_response.text or "{}")["conversation"]["id"] + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="user", + text_content="第一个问题", + display_name="system", + user_name="system", + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="bot", + text_content="第一个回答", + display_name="Bot", + user_name="Bot", + ) + await server._chat_job_manager.conversation_store.rename_conversation( + conversation_id, + "手动标题", + ) + + await server._chat_job_manager.maybe_schedule_title_generation(conversation_id) + updated = await server._chat_job_manager.conversation_store.get_conversation( + conversation_id + ) + + assert updated is not None + assert updated["title"] == "手动标题" + assert updated["title_status"] == "manual" + + +@pytest.mark.asyncio +async def test_webchat_history_isolated_by_conversation_id( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + server = RuntimeAPIServer( + _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])), + host="127.0.0.1", + port=8788, + ) + first_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + second_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + first_id = str(json.loads(first_response.text or "{}")["conversation"]["id"]) + second_id = str(json.loads(second_response.text or "{}")["conversation"]["id"]) + + await server._chat_job_manager.conversation_store.append_message( + first_id, + role="user", + text_content="第一会话消息", + display_name="system", + user_name="system", + ) + await server._chat_job_manager.conversation_store.append_message( + second_id, + role="user", + text_content="第二会话消息", + display_name="system", + user_name="system", + ) + + first_history = await server._chat_history_handler( + cast( + web.Request, + cast(Any, SimpleNamespace(query={"conversation_id": first_id})), + ) + ) + first_payload = json.loads(first_history.text or "{}") + second_history = await server._chat_history_handler( + cast( + web.Request, + cast(Any, SimpleNamespace(query={"conversation_id": second_id})), + ) + ) + second_payload = json.loads(second_history.text or "{}") + + assert first_payload["conversation_id"] == first_id + assert [item["content"] for item in first_payload["items"]] == ["第一会话消息"] + assert second_payload["conversation_id"] == second_id + assert [item["content"] for item in second_payload["items"]] == ["第二会话消息"] + + +@pytest.mark.asyncio +async def test_webchat_delete_and_clear_reject_while_job_running( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + server = RuntimeAPIServer( + _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])), + host="127.0.0.1", + port=8788, + ) + create_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + conversation_id = str( + json.loads(create_response.text or "{}")["conversation"]["id"] + ) + job = await server._chat_job_manager.create_job("hello", conversation_id) + job.task = AsyncMock() + + delete_response = await server._chat_conversation_delete_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={}, match_info={"conversation_id": conversation_id} + ), + ), + ) + ) + clear_response = await server._chat_history_clear_handler( + cast( + web.Request, + cast(Any, SimpleNamespace(query={"conversation_id": conversation_id})), + ) + ) + + assert delete_response.status == 409 + assert clear_response.status == 409 + assert ( + await server._chat_job_manager.conversation_store.get_conversation( + conversation_id + ) + is not None + ) diff --git a/tests/test_webui_management_api.py b/tests/test_webui_management_api.py index 2b82f168..765dd279 100644 --- a/tests/test_webui_management_api.py +++ b/tests/test_webui_management_api.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -11,13 +12,22 @@ from Undefined.webui import app as webui_app from Undefined.webui.app import create_app from Undefined.webui.core import SessionStore -from Undefined.webui.routes import _auth, _config, _index, _memes, _shared, _system +from Undefined.webui.routes import ( + _auth, + _config, + _index, + _memes, + _runtime, + _shared, + _system, +) from Undefined.webui.routes._shared import ( REDIRECT_TO_CONFIG_ONCE_APP_KEY, SESSION_COOKIE, SESSION_STORE_APP_KEY, SETTINGS_APP_KEY, ) +from Undefined.utils.paths import WEBUI_FILE_CACHE_DIR class DummyRequest(SimpleNamespace): @@ -25,6 +35,33 @@ async def json(self) -> dict[str, object]: return dict(getattr(self, "_json", {})) +class DummyMultipartField: + def __init__(self, chunks: list[bytes], *, filename: str = "file.bin") -> None: + self.name = "file" + self.filename = filename + self._chunks = list(chunks) + + async def read_chunk(self) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +class DummyMultipartRequest(DummyRequest): + def __init__(self, field: DummyMultipartField | None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._field = field + + async def multipart(self) -> object: + field = self._field + + class _Reader: + async def next(self) -> DummyMultipartField | None: + return field + + return _Reader() + + def _request( *, json_body: dict[str, object] | None = None, @@ -346,6 +383,13 @@ def test_create_app_registers_management_routes() -> None: assert ("GET", "/api/v1/management/runtime/meta") in routes assert ("POST", "/api/v1/management/config/validate") in routes assert ("POST", "/api/v1/management/bot/start") in routes + assert ("POST", "/api/v1/management/runtime/chat/jobs") in routes + assert ("GET", "/api/v1/management/runtime/chat/jobs/active") in routes + assert ("GET", "/api/v1/management/runtime/chat/jobs/{job_id}") in routes + assert ("GET", "/api/v1/management/runtime/chat/jobs/{job_id}/events") in routes + assert ("POST", "/api/v1/management/runtime/chat/jobs/{job_id}/cancel") in routes + assert ("DELETE", "/api/v1/management/runtime/chat/history") in routes + assert ("POST", "/api/v1/management/runtime/chat/files") in routes async def test_index_handler_applies_launcher_mode_and_initial_view() -> None: @@ -387,6 +431,64 @@ async def test_index_handler_renders_mobile_shell_and_action_toggles() -> None: assert 'id="logsMobileActionsToggle"' in payload_text +async def test_runtime_chat_file_upload_handler_caches_authenticated_file( + monkeypatch: Any, tmp_path: Path +) -> None: + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + monkeypatch.chdir(tmp_path) + field = DummyMultipartField([b"hello", b" world"], filename="../note.txt") + request = DummyMultipartRequest( + field, + headers={}, + cookies={}, + query={}, + app={}, + remote="127.0.0.1", + scheme="http", + host="127.0.0.1:8787", + transport=None, + ) + + response = await _runtime.runtime_chat_file_upload_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _json_payload(response) + + assert cast(web.Response, response).status == 200 + assert isinstance(payload["id"], str) + assert str(payload["id"]).isalnum() + assert payload["name"] == "note.txt" + assert payload["size"] == 11 + cached_dir = tmp_path / WEBUI_FILE_CACHE_DIR / str(payload["id"]) + cached_files = list(cached_dir.iterdir()) + assert len(cached_files) == 1 + cached_file = cached_files[0] + assert cached_file.name != "note.txt" + assert cached_file.name.startswith("file_") + assert cached_file.read_bytes() == b"hello world" + + +async def test_runtime_chat_file_upload_handler_requires_auth(monkeypatch: Any) -> None: + monkeypatch.setattr(_runtime, "check_auth", lambda _request: False) + request = DummyMultipartRequest( + None, + headers={}, + cookies={}, + query={}, + app={}, + remote="127.0.0.1", + scheme="http", + host="127.0.0.1:8787", + transport=None, + ) + + response = await _runtime.runtime_chat_file_upload_handler( + cast(web.Request, cast(Any, request)) + ) + + assert cast(web.Response, response).status == 401 + + def test_webui_cors_only_allows_trusted_origins(monkeypatch: Any) -> None: monkeypatch.setattr( webui_app, @@ -530,3 +632,163 @@ async def _fake_proxy_binary(request: web.Request, path: str) -> web.Response: assert payload["ok"] is True assert captured["path"] == "/api/v1/memes/pic%20a%2Fb%3F/blob" + + +async def test_runtime_chat_job_proxy_routes_require_management_auth() -> None: + request = cast( + web.Request, + cast( + Any, + SimpleNamespace( + headers={}, + cookies={}, + query={}, + match_info={"job_id": "job_1"}, + app=_request().app, + ), + ), + ) + + handlers = [ + _runtime.runtime_chat_conversations_handler, + _runtime.runtime_chat_conversation_create_handler, + _runtime.runtime_chat_conversation_update_handler, + _runtime.runtime_chat_conversation_delete_handler, + _runtime.runtime_chat_history_clear_handler, + _runtime.runtime_chat_job_create_handler, + _runtime.runtime_chat_job_active_handler, + _runtime.runtime_chat_job_detail_handler, + _runtime.runtime_chat_job_events_handler, + _runtime.runtime_chat_job_cancel_handler, + ] + for handler in handlers: + response = await handler(request) + assert cast(web.Response, response).status == 401 + + +async def test_runtime_chat_job_proxy_json_injects_runtime_api_key( + monkeypatch: Any, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_proxy_runtime(**kwargs: Any) -> web.Response: + captured.update(kwargs) + return web.json_response({"ok": True}) + + monkeypatch.setattr(_runtime, "_proxy_runtime", _fake_proxy_runtime) + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + request = cast( + web.Request, + cast( + Any, + SimpleNamespace( + headers={"Accept": "application/json"}, + cookies={}, + query={"after": "7", "format": "json"}, + match_info={"job_id": "job /secret"}, + app=_request().app, + ), + ), + ) + + response = await _runtime.runtime_chat_job_events_handler(request) + payload = _json_payload(response) + + assert payload["ok"] is True + assert captured["method"] == "GET" + assert captured["path"] == "/api/v1/chat/jobs/job%20%2Fsecret/events" + assert captured["params"]["after"] == "7" + assert captured["timeout_seconds"] == 20.0 + + +async def test_runtime_chat_job_proxy_sse_uses_stream_proxy( + monkeypatch: Any, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_proxy_runtime_stream( + request: web.Request, + **kwargs: Any, + ) -> web.Response: + captured["accept"] = request.headers.get("Accept") + captured.update(kwargs) + return web.json_response({"stream": True}) + + monkeypatch.setattr(_runtime, "_proxy_runtime_stream", _fake_proxy_runtime_stream) + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + monkeypatch.setattr(_runtime, "_chat_proxy_timeout_seconds", lambda: 123.0) + request = cast( + web.Request, + cast( + Any, + SimpleNamespace( + headers={"Accept": "text/event-stream"}, + cookies={}, + query={"after": "0"}, + match_info={"job_id": "job_1"}, + app=_request().app, + ), + ), + ) + + response = await _runtime.runtime_chat_job_events_handler(request) + payload = _json_payload(response) + + assert payload["stream"] is True + assert captured["method"] == "GET" + assert captured["path"] == "/api/v1/chat/jobs/job_1/events" + assert captured["params"]["after"] == "0" + assert captured["timeout_seconds"] == 123.0 + assert captured["accept"] == "text/event-stream" + + +async def test_proxy_runtime_injects_runtime_api_key(monkeypatch: Any) -> None: + captured: dict[str, Any] = {} + + class _FakeResponse: + status = 200 + headers = {"Content-Type": "application/json"} + content_type = "application/json" + charset = "utf-8" + + async def __aenter__(self) -> _FakeResponse: + return self + + async def __aexit__(self, *_args: Any) -> None: + return None + + async def text(self) -> str: + return '{"ok": true}' + + class _FakeSession: + def __init__(self, *args: Any, **kwargs: Any) -> None: + _ = args, kwargs + + async def __aenter__(self) -> _FakeSession: + return self + + async def __aexit__(self, *_args: Any) -> None: + return None + + def request(self, **kwargs: Any) -> _FakeResponse: + captured.update(kwargs) + return _FakeResponse() + + monkeypatch.setattr( + _runtime, + "get_config", + lambda strict=False: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + loopback_url="http://127.0.0.1:8788", + auth_key="runtime-secret", + ) + ), + ) + monkeypatch.setattr(_runtime, "ClientSession", _FakeSession) + + response = await _runtime._proxy_runtime(method="GET", path="/api/v1/chat/jobs") + payload = _json_payload(response) + + assert payload["ok"] is True + assert captured["headers"] == {"X-Undefined-API-Key": "runtime-secret"} diff --git a/tests/test_webui_runtime_chat_frontend.py b/tests/test_webui_runtime_chat_frontend.py new file mode 100644 index 00000000..7bbba15c --- /dev/null +++ b/tests/test_webui_runtime_chat_frontend.py @@ -0,0 +1,1432 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Final + +from Undefined.utils import io as async_io + + +RUNTIME_JS: Final[Path] = Path("src/Undefined/webui/static/js/runtime.js") +RUNTIME_CSS: Final[Path] = Path("src/Undefined/webui/static/css/components.css") +WEBUI_TEMPLATE: Final[Path] = Path("src/Undefined/webui/templates/index.html") +MAIN_JS: Final[Path] = Path("src/Undefined/webui/static/js/main.js") +API_JS: Final[Path] = Path("src/Undefined/webui/static/js/api.js") +APP_CSS: Final[Path] = Path("src/Undefined/webui/static/css/app.css") +RESPONSIVE_CSS: Final[Path] = Path("src/Undefined/webui/static/css/responsive.css") +I18N_JS: Final[Path] = Path("src/Undefined/webui/static/js/i18n.js") +WEBUI_APP_PY: Final[Path] = Path("src/Undefined/webui/app.py") +TAURI_CONF: Final[Path] = Path("apps/undefined-console/src-tauri/tauri.conf.json") + + +def _read_source(path: Path) -> str: + text = asyncio.run(async_io.read_text(path)) + assert text is not None + return text + + +def test_webchat_frontend_reuses_job_message_for_final_message() -> None: + source = _read_source(RUNTIME_JS) + + assert "activeChatMessageId" in source + assert 'if (event === "message")' in source + message_branch = source.split('if (event === "message")', 1)[1].split( + 'if (event === "done")', 1 + )[0] + + assert "ensureStreamingMessage(eventJobId)" in message_branch + assert 'appendChatMessage("bot", content)' not in message_branch + + +def test_webchat_html_preview_csp_allows_inline_scripts_without_eval() -> None: + webui_app = _read_source(WEBUI_APP_PY) + tauri_conf = _read_source(TAURI_CONF) + + assert "\"script-src 'self' 'nonce-{nonce}'; \"" in webui_app + assert "script-src 'self';" in tauri_conf + assert "script-src 'self' 'unsafe-inline'" not in webui_app + assert "script-src 'self' 'unsafe-inline'" not in tauri_conf + assert "__CSP_NONCE__" in _read_source(WEBUI_TEMPLATE) + assert "htmlRunnerCspMeta" in _read_source(RUNTIME_JS) + assert "unsafe-eval" not in webui_app + assert "unsafe-eval" not in tauri_conf + + +def test_webchat_frontend_handles_tool_lifecycle_and_webchat_hints() -> None: + source = _read_source(RUNTIME_JS) + + assert 'event === "token_delta"' not in source + assert 'event === "tool_delta"' not in source + assert "pendingToolDeltas" not in source + assert "appendTokenDelta" not in source + assert "consumeSse" not in source + assert "attachChatJobSse" not in source + assert "text/event-stream" not in source + assert 'event === "tool_start"' in source + assert 'event === "tool_end"' in source + assert 'event === "agent_start"' in source + assert 'event === "agent_end"' in source + assert 'block.uiHint === "webchat_private_send"' in source + assert 'block.uiHint === "webchat_end"' in source + assert "payload && payload.result_preview" in source + assert 'nextUiHint === "webchat_end"' not in source + + +def test_webchat_frontend_renders_live_stage_after_ai_label() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + i18n = _read_source(I18N_JS) + + assert 'runtime-chat-role-label">AI' in source + assert "runtime-chat-stage" in source + assert 'if (event === "stage")' in source + assert "setChatStage(item, payload || {})" in source + assert "setChatStage(item, null)" in source + assert "function updateChatStageDisplay" in source + assert "function refreshActiveChatTimers" in source + assert "updateToolDurationDisplay(block)" in source + assert "formatDurationMs" in source + assert "payload && payload.elapsed_ms" in source + assert "Date.now() - runtimeState.activeStageStartedAt" not in source + assert "runtime.chat_stage_waiting_model" in i18n + assert "runtime.chat_stage_searching_cognitive_memory" in i18n + assert ".runtime-chat-stage" in css + assert "runtime-chat-stage-pulse" not in css + + +def test_webchat_frontend_has_conversation_sidebar() -> None: + source = _read_source(RUNTIME_JS) + template = _read_source(WEBUI_TEMPLATE) + app_css = _read_source(APP_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + i18n = _read_source(I18N_JS) + + assert "runtimeChatConversations" in template + assert "btnRuntimeChatNew" in template + assert "btnRuntimeChatClear" not in template + assert "runtimeChatCurrentTitle" in template + assert 'id="runtimeChatConversationDrawerToggle"' in template + assert "runtime-chat-sidebar-tab" in template + assert "runtime-chat-sidebar-panel" in template + assert "loadChatConversations" in source + assert "switchChatConversation" in source + assert "renameChatConversation" in source + assert "deleteChatConversation" in source + assert "/api/runtime/chat/conversations" in source + assert ".runtime-chat-sidebar" in app_css + sidebar_block = app_css.split(".runtime-chat-sidebar {", 1)[1].split( + ".runtime-chat-sidebar:hover", 1 + )[0] + assert "position: absolute;" in sidebar_block + assert "right: 0;" in sidebar_block + assert "transform: translateX(calc(100% - 36px));" in sidebar_block + assert "transition:" in sidebar_block + assert ".runtime-chat-sidebar:hover" in app_css + assert ".runtime-chat-sidebar:focus-within" in app_css + assert "transform: translateX(0);" in app_css + assert ".runtime-chat-sidebar-tab" in app_css + assert "runtime-chat-conversation-created" in app_css + assert ".runtime-chat-conversation.is-new" in app_css + assert "recentlyCreatedConversationId" in source + assert 'showToast(t("runtime.chat_conversation_created")' in source + assert '"runtime.chat_conversation_created"' in i18n + assert 'get("btnRuntimeChatClear")' not in source + assert "chatConversationDrawerOpen: false" in source + assert "function setChatConversationDrawerOpen" in source + assert "function canToggleChatConversationDrawer" in source + assert "window.innerWidth <= 768" in source + assert "runtimeChatConversationDrawerToggle" in source + assert 'toggle.setAttribute(\n "aria-expanded",' in source + mobile_sidebar_block = responsive_css.split(".runtime-chat-sidebar {", 1)[1].split( + ".runtime-chat-sidebar-panel", 1 + )[0] + assert "position: static;" in mobile_sidebar_block + assert "transform: none;" in mobile_sidebar_block + mobile_panel_block = responsive_css.split(".runtime-chat-sidebar-panel {", 1)[ + 1 + ].split(".runtime-chat-sidebar.is-open .runtime-chat-sidebar-panel", 1)[0] + assert "display: none;" in mobile_panel_block + assert ".runtime-chat-sidebar.is-open .runtime-chat-sidebar-panel" in responsive_css + assert "display: block;" in responsive_css + mobile_tab_block = responsive_css.split(".runtime-chat-sidebar-tab {", 1)[1].split( + ".runtime-chat-sidebar-tab::after", 1 + )[0] + assert "display: flex;" in mobile_tab_block + assert "width: 100%;" in mobile_tab_block + assert "runtime.chat_new_conversation" in i18n + + +def test_webchat_frontend_has_slash_command_palette() -> None: + source = _read_source(RUNTIME_JS) + template = _read_source(WEBUI_TEMPLATE) + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + i18n = _read_source(I18N_JS) + + assert 'id="runtimeChatCommandPalette"' in template + input_row = template.split('class="runtime-chat-input-row"', 1)[1].split( + 'id="runtimeChatReferences"', + 1, + )[0] + assert input_row.index('id="runtimeChatCommandPalette"') < input_row.index( + 'id="runtimeChatInput"' + ) + + assert "chatCommandsLoaded" in source + assert "CHAT_COMMAND_CACHE_MS" in source + assert "CHAT_COMMAND_MAX_MATCHES" in source + assert '"/api/runtime/commands?scope=webui"' in source + assert "function buildChatCommandContext" in source + assert 'if (!beforeCursor.startsWith("/")) return null' in source + assert "if (tokenCount > 2) return null" in source + assert 'mode: hasCommandBoundary ? "subcommand" : "command"' in source + assert "function currentChatCommandMatches" in source + assert "findChatCommandByNameOrAlias(context.commandQuery)" in source + assert "commandMatchesForQuery(context.commandQuery)" in source + assert "function chatCommandDisplayName" in source + assert "typedCommandName: chatCommandDisplayName(" in source + assert "const commandName = match.typedCommandName || match.command.name" in source + assert ( + "if (!command) {\n return commandMatchesForQuery(context.commandQuery);" + in source + ) + assert "function renderChatCommandNoSubcommandsHelp" in source + assert "function chatCommandPaletteEmptyHtml" in source + assert "commandTextWithTypedTrigger" in source + assert "commandAliasText" in source + assert "!runtimeState.chatCommandsLoaded" in source + assert "runtime.chat_command_loading" in source + assert "runtime.chat_command_unknown_command" in source + assert "runtime.chat_command_subcommand_empty" in source + assert "runtime.command_no_subcommands_note" in source + assert "runtime.command_usage" in source + assert "runtime.command_example" in source + assert "runtime.command_aliases" in source + assert "runtime-chat-command-help" in source + assert "function replaceChatCommandInput" in source + assert "chooseActiveChatCommandMatch()" in source + assert 'event.key === "ArrowDown"' in source + assert 'event.key === "ArrowUp"' in source + assert 'event.key === "Tab"' in source + assert 'event.key === "Escape"' in source + assert "data-command-match-index" in source + assert "closeChatCommandPalette()" in source + + assert ".runtime-chat-command-palette" in css + palette_block = css.split(".runtime-chat-command-palette {", 1)[1].split( + ".runtime-chat-command-palette.is-open", + 1, + )[0] + assert "position: absolute;" in palette_block + assert "bottom: calc(100% + 10px);" in palette_block + assert "max-height: min(360px, 46vh);" in palette_block + assert ".runtime-chat-command-item" in css + assert ".runtime-chat-command-side code" in css + assert ".runtime-chat-command-palette" in responsive_css + assert "grid-template-columns: minmax(0, 1fr);" in responsive_css + assert "runtime.chat_command_hint" in i18n + assert "runtime.chat_command_hint_subcommand" in i18n + assert "runtime.chat_command_loading" in i18n + assert "runtime.chat_command_empty" in i18n + assert "runtime.chat_command_unknown_command" in i18n + assert "runtime.chat_command_subcommand_empty" in i18n + assert "runtime.chat_command_subcommands" in i18n + assert "runtime.command_help" in i18n + assert "runtime.command_usage" in i18n + assert "runtime.command_example" in i18n + assert "runtime.command_aliases" in i18n + assert "runtime.command_no_subcommands_note" in i18n + + +def test_webchat_frontend_sends_conversation_id_with_history_and_jobs() -> None: + source = _read_source(RUNTIME_JS) + + assert "currentChatConversationId" in source + assert 'chatUrl("/api/runtime/chat/history"' in source + assert 'chatUrl("/api/runtime/chat/jobs/active"' in source + assert "runtimeChatJobEventsUrls" in source + assert "conversation_id: currentChatConversationId()" in source + assert "activeJobConversationId" in source + assert ( + "runtimeState.activeJobConversationId || currentChatConversationId()" in source + ) + assert "eventConversationId === currentChatConversationId()" in source + assert "jobConversationId !== currentChatConversationId()" in source + + +def test_webchat_frontend_resumes_backend_job_after_refresh_or_reconnect() -> None: + source = _read_source(RUNTIME_JS) + history_helper = source.split("async function loadChatHistory", 1)[1].split( + "async function loadOlderChatHistory", + 1, + )[0] + conversation_helper = source.split( + "async function loadChatConversations", + 1, + )[1].split("async function createChatConversation", 1)[0] + resume_helper = source.split("async function resumeActiveChatJob", 1)[1].split( + "async function clearChatHistory", + 1, + )[0] + + assert "{ resumeActiveJob = true }" in history_helper + assert "if (runtimeState.chatHistoryLoaded && !force)" in history_helper + assert "await resumeActiveChatJob();" in history_helper + assert "const localJobId = runtimeState.activeJobId" in resume_helper + assert 'chatUrl("/api/runtime/chat/jobs/active")' in resume_helper + assert "attachChatJob(jobId, runtimeState.lastEventSeq)" in resume_helper + assert "runtimeState.activeJobId) return" not in resume_helper + assert "await loadChatHistory(true, { resumeActiveJob: false })" in resume_helper + assert "runtimeState.chatBusy = false" in resume_helper + assert "const previousJobId = runtimeState.activeJobId" in conversation_helper + assert 'const nextJobId = String(activeJob.job_id || "")' in conversation_helper + assert "previousJobId !== nextJobId" in conversation_helper + assert "localJobId !== jobId" in resume_helper + assert "runtimeState.lastEventSeq = 0" in conversation_helper + assert "clearToolCollapseTimers()" in conversation_helper + assert 'window.addEventListener(\n "online"' in source + + +def test_webchat_frontend_lazy_load_preserves_scroll_offset() -> None: + source = _read_source(RUNTIME_JS) + older_helper = source.split("async function loadOlderChatHistory", 1)[1].split( + "function applyChatEvent", + 1, + )[0] + + assert "const previousHeight = log.scrollHeight" in older_helper + assert "const previousTop = log.scrollTop" in older_helper + assert "appendHistoryChatItem(items[idx], {" in older_helper + assert "prepend: true" in older_helper + assert ( + "log.scrollTop = previousTop + (log.scrollHeight - previousHeight)" + in older_helper + ) + + +def test_webchat_frontend_keeps_final_duration_after_done() -> None: + source = _read_source(RUNTIME_JS) + + done_branch = source.split('if (event === "done")', 1)[1].split( + 'if (event === "error")', 1 + )[0] + finalize_helper = source.split("function finalizeActiveChatMessage", 1)[1].split( + "function chatStageLabel", 1 + )[0] + history_helper = source.split("function appendHistoryChatItem", 1)[1].split( + "function clearChatMessages", 1 + )[0] + + assert "finalizeActiveChatMessage(payload || {})" in done_branch + assert "payload && payload.duration_ms" in finalize_helper + assert 'stage: "done"' in finalize_helper + assert "final: true" in finalize_helper + assert "webchat.duration_ms" in history_helper + assert "setChatStage(message, {" in history_helper + + +def test_webchat_frontend_restores_history_tool_blocks_without_stream_state() -> None: + source = _read_source(RUNTIME_JS) + + assert "function appendHistoryChatItem" in source + assert "function renderHistoryTimeline" in source + assert "function reduceToolBlock" in source + assert "function normalizeToolCallNode" in source + assert "function normalizeHistoryTimelineNode" in source + assert 'entry.event === "message"' in source + assert "item.webchat.calls" in source + assert "item.webchat.timeline" in source + assert 'message.classList.add("tool-only")' in source + assert "appendHistoryChatItem(item, { scroll: false })" in source + assert "appendHistoryChatItem(items[idx], {" in source + + history_helper = source.split("function appendHistoryChatItem", 1)[1].split( + "function clearChatMessages", 1 + )[0] + assert "applyChatEvent(" not in history_helper + assert "upsertToolBlock(" not in history_helper + assert "ensureStreamingMessage(" not in history_helper + assert "data-job-id" not in history_helper + + +def test_webchat_frontend_renders_chat_as_event_timeline() -> None: + source = _read_source(RUNTIME_JS) + message_branch = source.split('if (event === "message")', 1)[1].split( + 'if (event === "done")', 1 + )[0] + timeline_helper = source.split("function upsertTimelineToolBlock", 1)[1].split( + "function upsertToolBlock", 1 + )[0] + + assert 'appendTimelineMessage(item, content, "bot")' in message_branch + assert "appendNestedTimelineMessage(" in message_branch + assert 'updateChatMessage(item, content, "bot")' not in message_branch + assert "timeline.appendChild(node)" in timeline_helper + assert "parent_webchat_call_id" in timeline_helper + assert "parent.children" in timeline_helper + assert ( + 'appendToolTimelineEntry(parent, { type: "call", call: block })' + in timeline_helper + ) + assert "topLevelToolKey(blocks, parentKey)" in timeline_helper + assert "runtime-tool-children" in _read_source(RUNTIME_CSS) + assert "function renderToolNodeIfChanged" in source + assert "node.dataset.renderSignature === nextSignature" in source + assert "updateToolMetaDisplay(block)" in source + assert "data-tool-status-for" in source + + +def test_webchat_frontend_prefers_backend_history_timeline() -> None: + source = _read_source(RUNTIME_JS) + history_timeline_branch = source.split("if (timelineItems.length)", 1)[1].split( + "if (calls.length)", 1 + )[0] + + assert 'entry.type === "message"' in history_timeline_branch + assert 'entry.type !== "call"' in history_timeline_branch + assert "renderToolBlock(entry.call)" in history_timeline_branch + assert "reduceToolBlock(" not in history_timeline_branch + + +def test_webchat_frontend_renders_nested_tool_timeline() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + + assert "function renderToolTimelineItem" in source + assert "function appendNestedTimelineMessage" in source + assert "function appendToolTimelineEntry" in source + assert "block.timeline" in source + assert "renderToolTimelineItem" in source + assert "runtime-tool-message" in source + nested_message_helper = source.split("function appendNestedTimelineMessage", 1)[ + 1 + ].split("function upsertToolBlock", 1)[0] + assert "payload.parent_webchat_call_id" in nested_message_helper + assert 'type: "message"' in nested_message_helper + assert "redrawToolTimelineNode(item, blocks, parentKey)" in nested_message_helper + assert "runtime-tool-reveal" in css + assert ".runtime-tool-block::before" in css + assert ".runtime-tool-block summary::before" in css + + +def test_webchat_tool_snapshots_do_not_rerender_unchanged_blocks() -> None: + source = _read_source(RUNTIME_JS) + live_update_helper = source.split("function upsertTimelineToolBlock", 1)[1].split( + "function appendNestedTimelineMessage", 1 + )[0] + agent_stage_helper = source.split("function upsertAgentStageBlock", 1)[1].split( + "function historyWebchatEvents", + 1, + )[0] + history_helper = source.split("function renderHistoryTimeline", 1)[1].split( + "function appendHistoryChatItem", + 1, + )[0] + + assert "previousParentSignature === nextParentSignature" in live_update_helper + assert "previousRootSignature === nextRootSignature" in live_update_helper + assert 'status === "tool_snapshot"' in live_update_helper + assert "renderToolNodeIfChanged(parentNode, parent)" in live_update_helper + assert "renderToolNodeIfChanged(rootNode, root)" in live_update_helper + assert "renderToolNodeIfChanged(node, block)" in live_update_helper + assert "previousParentSignature === nextParentSignature" in agent_stage_helper + assert "renderToolNodeIfChanged(node, block)" in agent_stage_helper + assert "node.innerHTML = renderToolBlock" not in live_update_helper + assert "node.innerHTML = renderToolBlock" not in agent_stage_helper + assert "node.innerHTML = renderToolBlock" in history_helper + + +def test_webchat_frontend_updates_agent_stage_summary_without_timeline_noise() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + + assert 'event === "agent_stage"' in source + assert "function upsertAgentStageBlock" in source + assert "function reduceAgentStageBlock" in source + assert "currentStage" in source + assert "current_stage_elapsed_ms" in source + render_helper = source.split("function renderToolTimelineItem", 1)[1].split( + "function toolBlockKey", 1 + )[0] + reduce_helper = source.split("function reduceAgentStageBlock", 1)[1].split( + "function agentStageRenderSignature", 1 + )[0] + + assert 'entry.type === "stage"' in render_helper + assert 'return "";' in render_helper + assert 'type: "stage"' not in reduce_helper + assert "function agentStageRenderSignature" in source + assert "previousSignature === agentStageRenderSignature(block)" in source + assert "currentStage: stage || previous.currentStage" in source + assert "runtime-tool-stage" not in source + assert ".runtime-tool-stage" not in css + + +def test_webchat_frontend_polls_job_events_incrementally() -> None: + source = _read_source(RUNTIME_JS) + + assert "function pollChatJob" in source + assert "CHAT_POLL_INTERVAL_MS = 500" in source + assert "CHAT_CLOCK_INTERVAL_MS = 500" in source + assert 'format: "json"' in source + assert "after: String(runtimeState.lastEventSeq)" in source + assert "function applyChatEventsPayload" in source + assert "function applyChatJobSnapshot" in source + assert "job.current_tool_calls" in source + assert "upsertToolSnapshot" in source + assert "runtimeState.chatPollTimer" in source + assert "runtimeState.chatPollBackoffMs" in source + assert "pollChatJob(jobId).catch" in source + assert 'Accept: "text/event-stream"' not in source + + +def test_webchat_frontend_retries_active_job_resume_after_refresh_failure() -> None: + source = _read_source(RUNTIME_JS) + resume_helper = source.split("async function resumeActiveChatJob", 1)[1].split( + "async function clearChatHistory", 1 + )[0] + + assert "activeJobResumeTimer" in source + assert "ACTIVE_JOB_RESUME_MAX_ATTEMPTS = 20" in source + assert "runtimeState.activeJobResumeAttempts += 1" in resume_helper + assert "setTimeout(() => {" in resume_helper + assert "resumeActiveChatJob().catch" in resume_helper + assert 'window.addEventListener(\n "online"' in source + + +def test_webchat_tool_summary_uses_compact_single_line_order() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + render_helper = source.split("function renderToolBlock", 1)[1].split( + "function renderToolTimelineItem", 1 + )[0] + summary_css = css.split(".runtime-tool-block summary {", 1)[1].split( + ".runtime-tool-block summary::-webkit-details-marker", 1 + )[0] + + assert "runtime-tool-name" in render_helper + assert "runtime-tool-duration" in render_helper + assert "runtime-tool-status" in render_helper + assert "runtime-tool-kind" in render_helper + assert ( + render_helper.index("runtime-tool-name") + < render_helper.index("runtime-tool-duration") + < render_helper.index("runtime-tool-status") + < render_helper.index("runtime-tool-kind") + ) + assert "grid-template-columns: auto minmax(0, 1fr) auto auto;" in summary_css + assert "min-height: 32px;" in summary_css + assert "padding: 3px 10px 3px 13px;" in summary_css + assert "line-height: 1.2;" in summary_css + name_css = css.split(".runtime-tool-block summary .runtime-tool-name", 1)[1].split( + ".runtime-tool-block summary .runtime-tool-duration", 1 + )[0] + duration_css = css.split(".runtime-tool-block summary .runtime-tool-duration", 1)[ + 1 + ].split(".runtime-tool-block summary .runtime-tool-status", 1)[0] + assert "font-weight: 650;" in name_css + assert "font-family: var(--font-mono);" in duration_css + assert "white-space: nowrap;" in duration_css + + +def test_webchat_tool_blocks_auto_collapse_after_minimum_visible_time() -> None: + source = _read_source(RUNTIME_JS) + assert "TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS = 2000" in source + assert "runtimeState.toolCollapseTimers" in source + assert "function scheduleToolAutoCollapse" in source + assert 'block.autoOpen ? " open" : ""' in source + assert "autoOpen: isStart || isSnapshot ? true : !!previous.autoOpen" in source + assert "localStartedAtMs: isStart" in source + assert "finishedAtMs: isEnd" in source + signature_helper = source.split("function toolRenderSignature", 1)[1].split( + "function updateToolMetaDisplay", + 1, + )[0] + assert "block.autoOpen" in signature_helper + assert "const childSignature" in signature_helper + assert "block.children.map(toolRenderSignature)" in signature_helper + assert "const timelineSignature" in signature_helper + assert "`call:${toolRenderSignature(entry.call)}`" in signature_helper + collapse_helper = source.split("function scheduleToolAutoCollapse", 1)[1].split( + "function upsertTimelineToolBlock", 1 + )[0] + assert "latest.autoOpen = false" in collapse_helper + assert "redrawToolTimelineNode(item, blocks, timerKey)" in collapse_helper + assert "setTimeout(collapse, TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS)" in collapse_helper + assert "TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS -" not in collapse_helper + clear_helper = source.split("function clearToolCollapseTimers", 1)[1].split( + "function finishStreamingMessage", 1 + )[0] + assert "clearTimeout(timer)" in clear_helper + + +def test_webchat_auto_scroll_toggle_controls_stream_scroll() -> None: + source = _read_source(RUNTIME_JS) + template = _read_source(WEBUI_TEMPLATE) + css = _read_source(RUNTIME_CSS) + + assert "runtimeChatAutoScroll" in template + assert "runtime.chat_auto_scroll" in template + assert "CHAT_AUTO_SCROLL_STORAGE_KEY" in source + assert "readChatAutoScrollPreference()" in source + assert "setChatAutoScroll(autoScrollToggle.checked)" in source + assert "if (!runtimeState.chatAutoScroll) return;" in source + assert "forceScrollChatToBottom()" in source + assert "prefersReducedMotion()" in source + assert "chatScrollBehavior()" in source + assert "behavior: chatScrollBehavior()" in source + assert ".toggle-input:focus-visible + .toggle-track" in css + assert ".toggle-input { display: none;" not in css + + +def test_webchat_tab_activation_forces_bottom_scroll_after_history_load() -> None: + source = _read_source(RUNTIME_JS) + load_helper = source.split("async function loadChatHistory", 1)[1].split( + "async function loadOlderChatHistory", 1 + )[0] + tab_helper = source.split("function onTabActivated", 1)[1].split( + "window.RuntimeController", 1 + )[0] + chat_branch = tab_helper.split('if (tab === "chat")', 1)[1].split( + "return;", + 1, + )[0] + + assert "forceScrollChatToBottomSoon()" in load_helper + assert "forceScrollChatToBottom();" not in load_helper + assert "loadChatConversations()" in chat_branch + assert ".then(() => loadChatHistory())" in chat_branch + assert "forceScrollChatToBottomSoon()" in chat_branch + assert "CHAT_TOP_LOAD_SUPPRESS_MS = 900" in source + assert "suppressChatTopHistoryLoad()" in source + assert "isChatTopHistoryLoadSuppressed()" in source + assert "chatTopLoadSuppressedUntil" in source + + +def test_webchat_frontend_renders_tool_duration() -> None: + source = _read_source(RUNTIME_JS) + + assert "block.durationMs" in source + assert "payload.duration_ms" in source + assert "runtime-tool-duration" in source + assert "formatDurationMs(runningDurationMs(block))" in source + assert "function runningDurationMs" in source + assert "function backendDurationClock" in source + assert "function updateToolDurationDisplay" in source + assert "function toolRenderSignature" in source + assert "durationBaseMs" in source + assert "durationReceivedAtMs" in source + assert "statusLabel} · ${durationLabel}" not in source + + +def test_webchat_tool_previews_render_structured_input_output() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + i18n = _read_source(I18N_JS) + + assert "function formatToolPreview" in source + assert "JSON.parse(text)" in source + assert "function renderStructuredToolValue" in source + assert "function renderToolPreviewSection" in source + assert '"runtime.tool_input"' in source + assert '"runtime.tool_output"' in source + assert "runtime-tool-structured-row" in source + assert "runtime-tool-key" in source + assert "runtime-tool-value" in source + assert "renderChatContent(preview.text, !!options.markdown)" in source + + assert ".runtime-tool-preview" in css + assert ".runtime-tool-preview-label" in css + assert ".runtime-tool-preview-body.is-structured" in css + assert ".runtime-tool-key" in css + assert ".runtime-tool-value.string" in css + assert ".runtime-tool-value.number" in css + assert ".runtime-tool-value.boolean" in css + assert "runtime.tool_input" in i18n + assert "runtime.tool_output" in i18n + + +def test_webchat_frontend_sanitizes_markdown_html_and_unsafe_links() -> None: + source = _read_source(RUNTIME_JS) + render_helper = source.split("function createSafeMarkedRenderer", 1)[1].split( + "function renderChatContent", 1 + )[0] + sanitizer_helper = source.split("function sanitizeHtmlSnippet", 1)[0].split( + "function isSafeRenderedImageUrl", 1 + )[1] + + assert "renderer.html" in render_helper + assert 'sanitizeHtmlSnippet(text || "")' in render_helper + assert "SAFE_HTML_TAGS" in sanitizer_helper + assert "DROP_HTML_TAGS" in sanitizer_helper + assert 'name.startsWith("on")' in sanitizer_helper + assert 'name === "style"' in sanitizer_helper + assert "isSafeRenderedUrl(attr.value)" in sanitizer_helper + assert "isSafeRenderedImageUrl(attr.value)" in sanitizer_helper + assert 'element.setAttribute("rel", "noreferrer")' in sanitizer_helper + assert 'element.setAttribute("loading", "lazy")' in sanitizer_helper + assert "isSafeRenderedUrl(href)" in render_helper + assert 'rel="noreferrer"' in render_helper + assert "renderer.image" in render_helper + assert "renderer: createSafeMarkedRenderer()" in source + + +def test_webchat_frontend_has_clickable_image_viewer() -> None: + source = _read_source(RUNTIME_JS) + template = _read_source(WEBUI_TEMPLATE) + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + i18n = _read_source(I18N_JS) + + assert 'id="runtimeChatImageViewer"' in template + assert 'id="runtimeChatImageViewerImage"' in template + assert "data-chat-image-viewer-close" in template + assert "function chatImageMarkup" in source + assert 'data-chat-image-preview="1"' in source + assert "function openChatImageViewer" in source + assert "function closeChatImageViewer" in source + assert "runtimeState.imageViewerPreviousFocus" in source + assert '".runtime-chat-image[data-chat-image-preview]"' in source + assert 'event.key === "Escape"' in source + assert 'target.closest(".runtime-chat-image-viewer-figure")' in source + assert "runtime.open_image_preview" in i18n + assert "runtime.image_preview" in i18n + assert ".runtime-chat-image-viewer" in css + assert ".runtime-chat-image-viewer.is-open" in css + assert ".runtime-chat-image-viewer-close" in css + assert "cursor: zoom-in;" in css + assert "@keyframes runtime-chat-image-viewer-in" in css + assert ".runtime-chat-image-viewer" in responsive_css + + +def test_webchat_markdown_quotes_render_as_collapsible_scroll_blocks() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + renderer_helper = source.split("function createSafeMarkedRenderer", 1)[1].split( + "renderer.link", + 1, + )[0] + append_helper = source.split("function appendChatMessage", 1)[1].split( + "function formatDurationMs", + 1, + )[0] + update_helper = source.split("function updateChatMessage", 1)[1].split( + "function currentChatJobId", + 1, + )[0] + quote_css = css.split(".runtime-quote-block", 1)[1].split( + ".runtime-chat-content.markdown table", + 1, + )[0] + + assert "function hasMarkdownBlockquote" in source + assert "function shouldRenderChatMarkdown" in source + assert 'role !== "user" || hasMarkdownBlockquote(content)' in source + assert "renderer.blockquote = ({ tokens }) =>" in renderer_helper + assert '
' in renderer_helper + assert '
' in renderer_helper + assert "shouldRenderChatMarkdown(role, content)" in append_helper + assert "renderChatContent(content, useMarkdown)" in append_helper + assert 'contentEl.classList.toggle("markdown", useMarkdown)' in update_helper + assert "max-height: min(28vh, 220px);" in quote_css + assert "overflow: auto;" in quote_css + assert ".runtime-quote-block[open] summary::before" in css + + +def test_webchat_frontend_renders_standalone_html_without_markdown_code_blocks() -> ( + None +): + source = _read_source(RUNTIME_JS) + render_helper = source.split("function renderChatContent", 1)[1].split( + "function readFileAsDataUrl", 1 + )[0] + sanitizer_section = source.split("const SAFE_HTML_TAGS", 1)[1].split( + "const CODE_LANGUAGE_ALIASES", 1 + )[0] + + assert "function looksLikeStandaloneHtml" in source + assert "STANDALONE_HTML_ROOT_TAGS" in source + assert "looksLikeStandaloneHtml(processed)" in render_helper + assert "html = sanitizeHtmlSnippet(processed)" in render_helper + assert '"head"' in sanitizer_section + assert '"title"' in sanitizer_section + assert '"style"' in sanitizer_section + + +def test_webchat_frontend_highlights_markdown_code_blocks() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + template = _read_source(WEBUI_TEMPLATE) + + assert "function highlightCodeBlock" in source + assert 'typeof hljs === "undefined"' in source + assert "hljs.getLanguage(lang)" in source + assert "hljs.highlight(code, {" in source + assert "hljs.highlightAuto(code).value" in source + assert "renderer.code" in source + assert "runtime-code-block" in source + assert "runtime-code-toolbar" in source + assert "runtime-code-action" in source + assert "CODE_COLLAPSE_LINE_THRESHOLD = 8" in source + assert "function shouldCollapseCodeBlock" in source + assert "function toggleCodeBlock" in source + assert "data-code-toggle" in source + assert "runtime.expand_code" in source + assert "runtime.collapse_code" in source + assert "data-code-copy" in source + assert "data-code-run-html" in source + assert "function isRunnableHtmlCode" in source + assert "function copyCodeBlock" in source + assert "function runHtmlCodeBlock" in source + assert "navigator.clipboard.writeText" in source + assert 'document.execCommand("copy")' in source + assert 'chatLog.addEventListener("click"' in source + assert 'target.closest("[data-code-toggle]")' in source + assert "highlightCodeBlock(codeText, normalizedLanguage)" in source + assert "language-${escapeHtml(normalizedLanguage)}" in source + assert 'runtime.copy_code": "复制"' in _read_source(I18N_JS) + assert 'runtime.run_html": "运行"' in _read_source(I18N_JS) + assert 'runtime.expand_code": "展开"' in _read_source(I18N_JS) + assert 'runtime.collapse_code": "折叠"' in _read_source(I18N_JS) + assert "/static/js/vendor/highlight.min.js" in template + assert "/static/css/highlight-github.min.css" in template + assert Path("src/Undefined/webui/static/js/vendor/highlight.min.js").is_file() + assert Path("src/Undefined/webui/static/js/vendor/highlightjs.LICENSE").is_file() + assert Path("src/Undefined/webui/static/css/highlight-github.min.css").is_file() + + assert ".runtime-code-toolbar" in css + assert ( + "position: sticky;" + in css.split(".runtime-code-toolbar", 1)[1].split( + ".runtime-code-language", + 1, + )[0] + ) + assert ".runtime-code-language" in css + assert ".runtime-code-action" in css + assert ".runtime-code-action.primary" in css + assert ".runtime-code-block.is-collapsed .runtime-code-body" in css + assert ".runtime-code-block.is-collapsed .runtime-code-body::after" in css + content_css = css.split(".runtime-chat-content {", 1)[1].split( + ".runtime-chat-timeline", + 1, + )[0] + table_css = css.split(".runtime-chat-content.markdown table", 1)[1].split( + ".runtime-chat-content.markdown th", + 1, + )[0] + pre_css = css.split(".runtime-chat-content.markdown pre {", 1)[1].split( + ".runtime-chat-content.markdown pre code", + 1, + )[0] + code_css = css.split(".runtime-chat-content.markdown pre code {", 1)[1].split( + ".runtime-chat-content.markdown pre code.hljs", + 1, + )[0] + assert "max-width: 100%;" in content_css + assert "overflow-wrap: anywhere;" in content_css + assert "table-layout: fixed;" in table_css + assert "overflow-x: hidden;" in pre_css + assert "white-space: pre-wrap;" in pre_css + assert "white-space: pre-wrap;" in code_css + assert "overflow-wrap: anywhere;" in code_css + collapsed_css = css.split( + ".runtime-code-block.is-collapsed .runtime-code-body", + 1, + )[1].split( + ".runtime-code-block.is-collapsed .runtime-code-body::after", + 1, + )[0] + collapsed_after_css = css.split( + ".runtime-code-block.is-collapsed .runtime-code-body::after", + 1, + )[1].split(".runtime-chat-content.markdown pre", 1)[0] + assert "height: 9.2em;" in collapsed_css + assert "overflow: auto;" in collapsed_css + assert "scrollbar-gutter: stable;" in collapsed_css + assert "display: none;" in collapsed_after_css + assert ".runtime-chat-content.markdown pre code.hljs" in css + assert ".runtime-code-block .hljs-keyword" in css + assert ".runtime-code-block .hljs-string" in css + assert ".runtime-code-block .hljs-comment" in css + assert ".runtime-code-block .hljs-number" in css + assert ".runtime-code-block .hljs-title.function_" in css + assert ".runtime-code-block .hljs-property" in css + assert '[data-theme="dark"] .runtime-code-block' in css + + +def test_webchat_html_runner_runs_code_in_sandboxed_preview() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + template = _read_source(WEBUI_TEMPLATE) + i18n = _read_source(I18N_JS) + + assert 'id="runtimeHtmlRunner"' in template + assert 'id="runtimeHtmlRunnerFrame"' in template + assert 'sandbox="allow-scripts"' in template + assert "allow-forms" not in template + assert "allow-modals" not in template + assert "allow-same-origin" not in template + assert 'id="btnRuntimeHtmlPick"' in template + assert 'id="btnRuntimeHtmlClose"' in template + assert 'id="runtimeHtmlRunnerResize"' in template + assert "runtime.html_runner" in template + + assert "htmlRunnerSource" in source + assert "htmlRunnerPickMode" in source + assert "htmlRunnerResize" in source + assert "htmlRunnerDrag" in source + assert "HTML_RUNNER_MIN_WIDTH = 360" in source + assert "HTML_RUNNER_MIN_HEIGHT = 280" in source + assert "const minWidth = Math.min(HTML_RUNNER_MIN_WIDTH, viewportWidth)" in source + assert ( + "const minHeight = Math.min(HTML_RUNNER_MIN_HEIGHT, viewportHeight)" in source + ) + assert "function buildHtmlRunnerDocument" in source + assert "function htmlRunnerPickerScript" in source + assert "function injectHtmlRunnerSecurity" in source + assert "function syncHtmlRunnerPickModeToFrame" in source + assert "function setHtmlRunnerPickMode" in source + assert "function clampHtmlRunnerPosition" in source + assert "function setHtmlRunnerRect" in source + assert "function setHtmlRunnerSize" in source + assert "function clearHtmlRunnerInteraction" in source + assert "function ensureHtmlRunnerInitialRect" in source + assert "function startHtmlRunnerResize" in source + assert "function moveHtmlRunnerResize" in source + assert "function stopHtmlRunnerResize" in source + assert "function startHtmlRunnerDrag" in source + assert "function moveHtmlRunnerDrag" in source + assert "function stopHtmlRunnerDrag" in source + assert "function clampVisibleHtmlRunner" in source + assert "function openHtmlRunner" in source + assert "function closeHtmlRunner" in source + assert "function handleHtmlRunnerPicked" in source + assert ( + 'const confirmHint = JSON.stringify(t("runtime.html_pick_confirm_hint"))' + in source + ) + assert "let locked = null;" in source + assert "if (locked) return;" in source + assert "if (!locked) {" in source + assert ( + "locked = selected || candidateFromPoint(event.clientX, event.clientY)" + in source + ) + assert "return;\n }\n const target = locked;" in source + assert "clearHtmlRunnerInteraction()" in source + assert "ensureHtmlRunnerInitialRect(runner)" in source + assert "frame.srcdoc = injectHtmlRunnerSecurity(html)" in source + assert ( + "sanitizeHtmlSnippet" + not in source.split( + "function buildHtmlRunnerDocument", + 1, + )[1].split("function htmlRunnerPickerScript", 1)[0] + ) + assert 'parent.postMessage({ type: "webui-html-picked", html }, "*")' in source + assert "data-webui-html-picker-overlay" in source + assert "data-webui-html-picker-label" in source + assert "data-webui-html-picking" in source + assert "document.elementsFromPoint" in source + assert "candidateFromPoint(event.clientX, event.clientY)" in source + assert 'document.addEventListener("pointerdown"' in source + assert 'parent.postMessage({ type: "webui-html-picker-ready" }, "*")' in source + assert "requestAnimationFrame(() =>" in source + assert "elementLabel(element)" in source + assert "event.source !== frame.contentWindow" in source + assert 'data.type === "webui-html-picker-ready"' in source + assert 'data.type !== "webui-html-picked"' in source + assert "btnRuntimeHtmlClose" in source + assert "btnRuntimeHtmlPick" in source + assert "runtimeHtmlRunnerResize" in source + assert ".runtime-html-runner-toolbar" in source + assert "setHtmlRunnerPickMode(!runtimeState.htmlRunnerPickMode)" in source + assert "syncHtmlRunnerPickModeToFrame()" in source + assert "startHtmlRunnerResize" in source + assert "moveHtmlRunnerResize" in source + assert "stopHtmlRunnerResize" in source + assert "startHtmlRunnerDrag" in source + assert "moveHtmlRunnerDrag" in source + assert "stopHtmlRunnerDrag" in source + assert '"lostpointercapture"' in source + assert 'window.addEventListener("pointerup"' in source + assert 'window.addEventListener("pointercancel"' in source + assert 'window.addEventListener("blur"' in source + assert "setHtmlRunnerRect(rect.left, rect.top, rect.width, rect.height)" in source + assert 'window.addEventListener("resize", clampVisibleHtmlRunner)' in source + assert "setPointerCapture(pointerId)" in source + assert "releasePointerCapture(state.pointerId)" in source + assert 'button.setAttribute("aria-pressed", active ? "true" : "false")' in source + + assert ".runtime-html-runner" in css + assert ".runtime-html-runner-panel" in css + assert ".runtime-html-runner-toolbar" in css + assert ".runtime-html-runner-frame" in css + runner_css = css.split(".runtime-html-runner {", 1)[1].split( + ".runtime-html-runner[hidden]", + 1, + )[0] + runner_panel_css = css.split(".runtime-html-runner-panel {", 1)[1].split( + ".runtime-html-runner-toolbar", + 1, + )[0] + assert "resize: both;" not in runner_css + assert "right:" not in runner_css + assert "bottom:" not in runner_css + assert "overflow: visible;" in runner_css + assert "pointer-events: auto;" in runner_css + assert "height: 360px;" in runner_css + assert "grid-template-rows: auto minmax(0, 1fr);" in runner_panel_css + assert "width: 100%;" in runner_panel_css + assert "height: 100%;" in runner_panel_css + assert ".runtime-html-runner-resize" in css + assert ".runtime-html-runner.is-resizing" in css + assert ".runtime-html-runner.is-dragging" in css + assert ( + "pointer-events: none;" + in css.split( + ".runtime-html-runner.is-resizing .runtime-html-runner-frame", + 1, + )[1].split(".runtime-html-runner-toolbar", 1)[0] + ) + assert ( + "pointer-events: none;" + in css.split( + ".runtime-html-runner.is-dragging .runtime-html-runner-frame", + 1, + )[1].split(".runtime-html-runner-toolbar", 1)[0] + ) + toolbar_css = css.split(".runtime-html-runner-toolbar {", 1)[1].split( + ".runtime-html-runner-actions", + 1, + )[0] + assert "cursor: move;" in toolbar_css + assert "touch-action: none;" in toolbar_css + assert ".runtime-html-runner-actions,\n.runtime-html-runner-actions *" in css + assert ".runtime-html-runner-actions button" in css + assert ".runtime-html-runner-btn.is-active" in css + assert ".runtime-html-runner.is-picking .runtime-html-runner-panel" in css + assert "@keyframes runtime-html-runner-in" in css + assert ".runtime-html-runner" in responsive_css + responsive_runner_css = responsive_css.split(".runtime-html-runner {", 1)[1].split( + ".runtime-html-runner-panel", 1 + )[0] + assert "right:" not in responsive_runner_css + assert "bottom:" not in responsive_runner_css + assert ( + "max-height: calc(100dvh - 24px - env(safe-area-inset-bottom));" + in responsive_css + ) + responsive_toolbar_css = responsive_css.split( + ".runtime-html-runner-toolbar", + 1, + )[1].split(".runtime-html-runner-title", 1)[0] + responsive_title_css = responsive_css.split(".runtime-html-runner-title", 1)[ + 1 + ].split(".runtime-html-runner-meta", 1)[0] + responsive_meta_css = responsive_css.split(".runtime-html-runner-meta", 1)[1].split( + ".runtime-html-runner-actions", 1 + )[0] + assert "flex-wrap: wrap;" in responsive_toolbar_css + assert "flex: 1 1 min(160px, 100%);" in responsive_title_css + assert "max-width: min(62vw, 260px);" in responsive_meta_css + assert "runtime.html_ready" in i18n + assert "runtime.pick_html" in i18n + assert "runtime.html_pick_confirm_hint" in i18n + + +def test_webchat_references_are_prepended_as_markdown_quotes() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + template = _read_source(WEBUI_TEMPLATE) + i18n = _read_source(I18N_JS) + + assert "chatReferences: []" in source + assert "chatReferenceSeq" in source + assert "function addChatReference" in source + assert "function renderPendingChatReferences" in source + assert "function formatChatReferencesAsMarkdown" in source + assert "function buildChatMessageWithReferences" in source + assert "function chatMessageTextForQuote" in source + assert "[`> ${label}:`, ...lines.map((line) => `> ${line}`)]" in source + assert ( + "buildChatMessageWithReferences(\n message,\n references" + in source + ) + assert "clearChatReferences()" in source + assert 'addChatReference({ type: "html", text: picked })' in source + assert 'addChatReference({ type: "message", text })' in source + assert 'addChatReference({ type: "selection", text })' in source + assert "runtimeState.chatReferences =" in source + assert "runtimeState.chatReferences.filter" in source + assert 'api("/api/runtime/chat/files"' in source + + send_helper = source.split("async function sendChatMessage", 1)[1].split( + "function handleChatFilesPicked", + 1, + )[0] + assert "const references = [...runtimeState.chatReferences]" in send_helper + assert ( + "if (!message && !attachments.length && !references.length) return" + in send_helper + ) + assert "clearChatReferences()" in send_helper + + assert 'id="runtimeChatReferences"' in template + input_row = template.split('class="runtime-chat-input-row"', 1)[1].split( + 'class="runtime-chat-actions"', + 1, + )[0] + assert input_row.index('id="runtimeChatInput"') < input_row.index( + 'id="runtimeChatReferences"' + ) + + assert ".runtime-chat-references" in css + assert ".runtime-chat-reference" in css + assert ".runtime-chat-reference-remove" in css + assert ".runtime-chat-quote-btn" in css + assert ".runtime-chat-selection-quote" in css + assert "@keyframes runtime-chat-selection-quote-in" in css + assert ".runtime-chat-references" in responsive_css + assert "runtime.reference_added" in i18n + assert "runtime.reference_html" in i18n + assert "runtime.quote_selection" in i18n + + +def test_webchat_tool_status_colors_drive_left_bar_and_status_text() -> None: + css = _read_source(RUNTIME_CSS) + running_block = css.split(".runtime-tool-block.running {", 1)[1].split( + ".runtime-tool-block.done", 1 + )[0] + done_block = css.split(".runtime-tool-block.done {", 1)[1].split( + ".runtime-tool-block.error", 1 + )[0] + error_accent_block = css.split(".runtime-tool-block.error {", 1)[1].split( + ".runtime-tool-block.cancelled", 1 + )[0] + pseudo_block = css.split(".runtime-tool-block::before", 1)[1].split( + ".runtime-tool-block.is-agent", 1 + )[0] + status_block = css.split( + ".runtime-tool-block.error summary .runtime-tool-status", 1 + )[1].split(".runtime-tool-preview", 1)[0] + + assert "--tool-accent: color-mix(in srgb, var(--warning)" in running_block + assert "--tool-accent: var(--success);" in done_block + assert "--tool-accent: var(--error);" in error_accent_block + assert "background: var(--tool-accent);" in pseudo_block + assert ".runtime-tool-block.running summary .runtime-tool-status" in css + assert ".runtime-tool-block.done summary .runtime-tool-status" in css + assert "color: var(--error);" in status_block + assert ".runtime-tool-block.cancelled summary .runtime-tool-status" in status_block + assert "var(--danger)" not in status_block + + +def test_webchat_send_scrolls_to_bottom_after_layout_updates() -> None: + source = _read_source(RUNTIME_JS) + force_helper = source.split("function forceScrollChatToBottomSoon", 1)[1].split( + "function scrollChatToBottomSoon", 1 + )[0] + helper = source.split("function scrollChatToBottomSoon", 1)[1].split( + "function updateChatMessage", 1 + )[0] + send_helper = source.split("async function sendChatMessage", 1)[1].split( + "function handleChatFilesPicked", 1 + )[0] + + assert "requestAnimationFrame(() =>" in force_helper + assert "requestAnimationFrame(forceScrollChatToBottom)" in force_helper + assert "setTimeout(forceScrollChatToBottom, 80)" in force_helper + assert "requestAnimationFrame(scrollChatToBottom)" in helper + assert "setTimeout(scrollChatToBottom, 0)" in helper + assert "buildChatMessageWithAttachments(" in send_helper + assert 'appendChatMessage("user", outboundMessage)' in send_helper + assert 'input.value = ""' in send_helper + assert "clearChatAttachments()" in send_helper + assert "forceScrollChatToBottomSoon()" in send_helper + assert "ensureStreamingMessage()" in send_helper + + +def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + template = _read_source(WEBUI_TEMPLATE) + i18n = _read_source(I18N_JS) + api_source = _read_source(API_JS) + + assert "chatAttachments: []" in source + assert "function addChatFiles" in source + assert "function renderPendingChatAttachments" in source + assert "async function uploadChatFile" in source + assert "async function buildChatMessageWithAttachments" in source + assert "CHAT_INLINE_IMAGE_MAX_BYTES" in source + assert "URL.createObjectURL(file)" in source + assert "URL.revokeObjectURL" in source + assert "runtime-chat-attachment-thumb" in source + assert "is-missing-thumb" in source + assert 'item.kind === "image" ? "IMG" : "FILE"' in source + assert "CHAT_ATTACHMENT_RAIL_BASE_WIDTH" in source + assert "CHAT_ATTACHMENT_RAIL_STEP_WIDTH" in source + assert "CHAT_ATTACHMENT_RAIL_MAX_WIDTH" in source + assert "CHAT_ATTACHMENT_CARD_MAX_WIDTH" in source + assert "CHAT_ATTACHMENT_CARD_MIN_WIDTH" in source + assert "CHAT_ATTACHMENT_COMPRESSED_COUNT" in source + assert "Math.min(\n CHAT_ATTACHMENT_RAIL_MAX_WIDTH" in source + assert '"--chat-attachment-rail-width"' in source + assert '"--chat-attachment-card-width"' in source + assert '"is-attachment-rail-full"' in source + assert '"is-attachment-compressed"' in source + assert "Math.floor(\n (width - Math.max" in source + assert 'api("/api/runtime/chat/files"' in source + assert "event.clipboardData && event.clipboardData.files" in source + assert 'addChatFiles(files, { source: "paste" })' in source + assert ( + "sendChatMessage()" + not in source.split('chatInput.addEventListener("paste"', 1)[1].split("});", 1)[ + 0 + ] + ) + assert 'id="runtimeChatAttachments"' in template + input_row = template.split('class="runtime-chat-input-row"', 1)[1].split( + 'class="runtime-chat-actions"', + 1, + )[0] + assert input_row.index('id="runtimeChatInput"') < input_row.index( + 'id="runtimeChatAttachments"' + ) + assert 'id="runtimeChatFileInput" type="file" multiple hidden' in template + assert 'data-i18n="runtime.attach_file"' in template + assert ".runtime-chat-attachments" in css + input_row_block = css.split(".runtime-chat-input-row {", 1)[1].split( + ".runtime-chat-input-row > .runtime-chat-input", + 1, + )[0] + input_block = css.split( + ".runtime-chat-input-row > .runtime-chat-input", + 1, + )[1].split(".runtime-chat-attachments", 1)[0] + attachments_block = css.split(".runtime-chat-attachments {", 1)[1].split( + ".runtime-chat-attachments[hidden]", + 1, + )[0] + hidden_block = css.split(".runtime-chat-attachments[hidden]", 1)[1].split( + ".runtime-chat-attachment {", + 1, + )[0] + compressed_block = css.split( + ".runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment", + 1, + )[1].split(".runtime-chat-attachment-preview", 1)[0] + compressed_preview_block = css.split( + ".runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-preview", + 1, + )[1].split( + ".runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-main", + 1, + )[0] + compressed_remove_block = css.split( + ".runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-remove", + 1, + )[1].split("@keyframes runtime-chat-attachment-in", 1)[0] + responsive_attachments = ( + _read_source(RESPONSIVE_CSS) + .split( + ".runtime-chat-attachments", + 1, + )[1] + .split(".runtime-chat-attachment", 1)[0] + ) + mobile_input_row_block = ( + _read_source(RESPONSIVE_CSS) + .split( + ".runtime-chat-input-row", + 1, + )[1] + .split(".runtime-chat-references", 1)[0] + ) + assert "--chat-attachment-rail-width: 0px;" in input_row_block + assert "--chat-attachment-card-width: 132px;" in input_row_block + assert "--chat-attachment-gap: 8px;" in input_row_block + assert "display: flex;" in input_row_block + assert "flex: 1 1 auto;" in input_block + assert "min-width: min(100%, 260px);" in input_block + assert "height: 54px;" in attachments_block + assert "flex: 0 0 var(--chat-attachment-rail-width);" in attachments_block + assert "width: var(--chat-attachment-rail-width);" in attachments_block + assert "max-width: var(--chat-attachment-rail-width);" in attachments_block + assert "overflow-x: auto;" in attachments_block + assert "overflow-y: hidden;" in attachments_block + assert "scrollbar-width: none;" in attachments_block + assert "display: grid;" in mobile_input_row_block + assert ( + 'grid-template-areas:\n "references references"\n "attachments attachments"\n "input actions";' + in mobile_input_row_block + ) + assert "column-gap: 7px;" in mobile_input_row_block + assert "row-gap: 0;" in mobile_input_row_block + assert "flex-basis: 0;" in hidden_block + assert "width: 0;" in hidden_block + assert "max-width: 0;" in hidden_block + attachment_block = css.split(".runtime-chat-attachment {", 1)[1].split( + ".runtime-chat-attachment:hover", + 1, + )[0] + assert "flex: 0 0 var(--chat-attachment-card-width);" in attachment_block + assert "max-width: var(--chat-attachment-card-width);" in attachment_block + assert "grid-template-columns: minmax(24px, 1fr);" in compressed_block + assert "width: 100%;" in compressed_preview_block + assert "height: 38px;" in compressed_preview_block + assert "width: 22px;" in compressed_remove_block + assert "height: 22px;" in compressed_remove_block + assert "font-weight: 700;" in compressed_remove_block + assert ".runtime-chat-attachment-preview.is-missing-thumb::before" in css + assert "grid-area: attachments;" in responsive_attachments + assert "width: 100%;" in responsive_attachments + assert "max-width: 100%;" in responsive_attachments + assert ".runtime-chat-attachment-thumb" in css + assert ".runtime-chat-attachment-preview" in css + assert ".runtime-chat-attachment-remove" in css + assert "runtime.attach_file" in i18n + assert "runtime.attachment_added" in i18n + assert "body instanceof FormData" in api_source + assert "!isNativeBody" in api_source + + +def test_webchat_layout_keeps_input_at_bottom_and_log_scrollable() -> None: + app_css = _read_source(APP_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + main_js = _read_source(MAIN_JS) + template = _read_source(WEBUI_TEMPLATE) + + assert ".main-content.chat-layout {" in app_css + assert "display: flex;" in app_css + assert "height: 100dvh;" in app_css + assert "overflow: hidden;" in app_css + assert "#appContent" in app_css + assert "grid-template-rows: auto minmax(0, 1fr);" in app_css + assert "#tab-chat.active" in app_css + assert "grid-template-rows: auto minmax(0, 1fr);" in app_css + + chat_card_block = app_css.split( + ".main-content.chat-layout #tab-chat .chat-runtime-card", 1 + )[1].split(".main-content.chat-layout #tab-chat .runtime-chat-log", 1)[0] + assert "grid-template-rows: auto auto minmax(0, 1fr) auto;" in chat_card_block + assert "min-height: 0;" in chat_card_block + + log_block = app_css.split( + ".main-content.chat-layout #tab-chat .runtime-chat-log", 1 + )[1].split(".main-content.chat-layout #tab-chat .runtime-chat-input", 1)[0] + assert "overflow-y: auto;" in log_block + assert "overscroll-behavior: contain;" in log_block + + input_row_block = app_css.split( + ".main-content.chat-layout #tab-chat .runtime-chat-input-row", 1 + )[1].split(".main-content.chat-layout #tab-chat .runtime-chat-content", 1)[0] + assert "position: relative;" in input_row_block + assert "position: sticky;" not in input_row_block + assert "position: fixed;" not in input_row_block + assert "var(--bg-main)" not in input_row_block + + chat_header_block = app_css.split(".runtime-chat-header {", 1)[1].split( + ".runtime-chat-title", 1 + )[0] + title_meta_block = app_css.split(".runtime-chat-title-meta", 1)[1].split( + ".main-content.chat-layout #tab-chat .chat-runtime-card", 1 + )[0] + mobile_header_block = responsive_css.split(".runtime-chat-header-actions", 1)[ + 1 + ].split(".runtime-chat-auto-scroll-toggle", 1)[0] + + assert "align-items: center;" in chat_header_block + assert "white-space: nowrap;" in title_meta_block + assert "justify-content: space-between;" in mobile_header_block + assert ".main-content.chat-layout" in responsive_css + assert "height: 100dvh;" in responsive_css + assert "function syncMainContentLayout()" in main_js + assert ( + 'appContent.style.display = state.tab === "chat" ? "grid" : "block";' in main_js + ) + assert 'role="log"' in template + assert 'aria-live="polite"' in template + assert 'data-i18n-aria-label="runtime.chat_log_label"' in template + assert 'class="header runtime-chat-header"' in template + assert "runtime-chat-title-meta" in template + assert "该会话由 WebUI 发起" in template + + +def test_webchat_mobile_tool_rows_have_overflow_guards() -> None: + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + + status_css = css.split(".runtime-tool-block summary .runtime-tool-status", 1)[ + 1 + ].split(".runtime-tool-block summary .runtime-tool-kind", 1)[0] + kind_css = css.split(".runtime-tool-block summary .runtime-tool-kind", 1)[1].split( + ".runtime-tool-block.webchat-private-send", 1 + )[0] + structured_css = css.split(".runtime-tool-structured-row", 1)[1].split( + ".runtime-tool-key", 1 + )[0] + + assert "min-width: 0;" in status_css + assert "text-overflow: ellipsis;" in status_css + assert "overflow: hidden;" in kind_css + assert "grid-template-columns: minmax(64px, min(34%, 180px))" in structured_css + assert ".runtime-tool-block summary .runtime-tool-duration" in responsive_css + + +def test_webchat_content_wraps_long_code_and_markdown_without_horizontal_scroll() -> ( + None +): + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + + log_css = css.split(".runtime-chat-log {", 1)[1].split( + ".runtime-chat-load-more", + 1, + )[0] + item_css = css.split(".runtime-chat-item {", 1)[1].split( + ".runtime-chat-item.user", + 1, + )[0] + code_block_css = css.split(".runtime-code-block {", 1)[1].split( + ".runtime-code-toolbar", + 1, + )[0] + inline_code_css = css.split(".runtime-chat-content code {", 1)[1].split( + ".runtime-chat-image", + 1, + )[0] + mobile_table_css = responsive_css.split( + ".runtime-chat-content.markdown table", + 1, + )[1].split(".runtime-chat-input-row", 1)[0] + + assert "min-width: 0;" in log_css + assert "overflow-x: hidden;" in log_css + assert "min-width: 0;" in item_css + assert "max-width: 100%;" in item_css + assert "min-width: 0;" in code_block_css + assert "max-width: 100%;" in code_block_css + assert "white-space: normal;" in inline_code_css + assert "overflow-wrap: anywhere;" in inline_code_css + assert "display: table;" in mobile_table_css + assert "overflow-x: visible;" in mobile_table_css + assert "white-space: normal;" in mobile_table_css + mobile_code_toolbar_css = responsive_css.split(".runtime-code-toolbar", 1)[1].split( + ".runtime-code-actions", 1 + )[0] + mobile_code_action_css = responsive_css.split(".runtime-code-action", 1)[1].split( + ".runtime-chat-input-row", + 1, + )[0] + assert "min-height: 32px;" in mobile_code_toolbar_css + assert "padding: 4px 6px 4px 9px;" in mobile_code_toolbar_css + assert "min-height: 24px;" in mobile_code_action_css + assert "font-size: 11px;" in mobile_code_action_css + assert ".runtime-tool-block summary .runtime-tool-kind" in responsive_css + assert "display: none;" in responsive_css + assert "max-width: 30vw;" in responsive_css