From f8fab0bcace8533f9472c5ee2555b59a71ab6d5f Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 30 May 2026 12:08:37 +0800 Subject: [PATCH 01/77] feat(webui): add resumable webchat jobs Add WebUI chat job endpoints with event replay, cancellation, and active job lookup so streamed chats can continue after refresh or disconnect. Bridge the legacy stream=true chat endpoint to the new job event stream while keeping stream=false synchronous behavior unchanged. Add paged WebUI chat history loading and protected clear-history behavior for virtual private chat system#42 without touching long-term memory, cognitive memory, or profiles. Update the management proxy, WebUI chat workspace, token/tool/agent progress rendering, docs, OpenAPI metadata, and focused runtime/WebUI tests. Verification: - uv run pytest tests/test_runtime_api_chat_stream.py tests/test_runtime_api_chat_history.py tests/test_runtime_api_chat_jobs.py tests/test_webui_management_api.py -q - uv run ruff check . - uv run mypy . - npm run check (apps/undefined-console) Co-authored-by: GPT-5 Codex --- docs/management-api.md | 6 + docs/openapi.md | 45 +- docs/webui-guide.md | 5 +- src/Undefined/ai/client/ask_loop.py | 61 ++ src/Undefined/ai/client/queue.py | 7 +- src/Undefined/ai/client/setup.py | 3 + src/Undefined/ai/llm/requester.py | 127 +++- src/Undefined/api/_helpers.py | 7 +- src/Undefined/api/_openapi.py | 19 +- src/Undefined/api/app.py | 47 +- src/Undefined/api/routes/chat.py | 644 +++++++++++++++--- src/Undefined/services/ai_coordinator.py | 1 + src/Undefined/utils/history.py | 40 ++ src/Undefined/webui/routes/_runtime.py | 78 +++ src/Undefined/webui/static/css/app.css | 30 +- src/Undefined/webui/static/css/components.css | 109 ++- src/Undefined/webui/static/css/responsive.css | 8 +- src/Undefined/webui/static/js/i18n.js | 26 + src/Undefined/webui/static/js/runtime.js | 505 ++++++++++++-- src/Undefined/webui/templates/index.html | 12 +- tests/test_runtime_api_chat_history.py | 166 ++++- tests/test_runtime_api_chat_jobs.py | 228 +++++++ tests/test_webui_management_api.py | 6 + 23 files changed, 1962 insertions(+), 218 deletions(-) create mode 100644 tests/test_runtime_api_chat_jobs.py diff --git a/docs/management-api.md b/docs/management-api.md index 346ae86b..d9e460b3 100644 --- a/docs/management-api.md +++ b/docs/management-api.md @@ -173,6 +173,12 @@ Management API 会把运行态相关能力统一代理到主进程 Runtime API - `GET /api/v1/management/runtime/cognitive/profile/{entity_type}/{entity_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` +- `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` 除此之外,Management API 还额外代理了表情包库管理接口: diff --git a/docs/openapi.md b/docs/openapi.md index ea5973e0..ce032ffb 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -231,11 +231,18 @@ curl http://127.0.0.1:8788/openapi.json } ``` -- 当 `stream = true` 时,返回 `text/event-stream`(SSE): +- `stream = false` 保持同步响应。 +- 当 `stream = true` 时,Runtime 会创建 WebChat job,并返回 `text/event-stream`(SSE): - `event: meta`:会话元信息。 - - `event: message`:AI/命令输出片段。 + - `event: token_delta`:模型文本增量。 + - `event: tool_delta`:工具参数增量预览。 + - `event: tool_start` / `tool_end`:主对话工具开始与结束。 + - `event: agent_start` / `agent_end`:主对话调用 Agent 开始与结束。 + - `event: message`:AI/命令最终输出片段。 - `event: done`:最终汇总(与非流式 JSON 结构一致)。 + - `event: error`:任务失败或取消。 - 在长时间无内容时会发送 `: keep-alive` 注释帧,防止中间层空闲断连。 + - SSE 帧包含 `id: `,客户端可用 `after=` 续接 job 事件。 行为约定: @@ -245,9 +252,21 @@ curl http://127.0.0.1:8788/openapi.json ### WebUI AI Chat 历史记录 -- `GET /api/v1/chat/history?limit=200` -- 用于读取虚拟私聊 `system#42` 的历史记录(只读)。 -- 返回中包含 `role/content/timestamp`,用于 WebUI 自动恢复会话视图。 +- `GET /api/v1/chat/history?limit=50&before=` +- 用于分页读取虚拟私聊 `system#42` 的历史记录。默认返回最新一页,响应包含 `items/has_more/next_before/total`。 +- `DELETE /api/v1/chat/history` +- 仅清空 `system#42` 聊天历史 JSON 和内存历史,不删除长期记忆、认知记忆或 profile。 +- 如果存在运行中的 WebChat job,返回 `409`,避免旧任务继续写回已清空的历史。 + +### WebUI AI Chat Jobs + +- `POST /api/v1/chat/jobs`:创建后台 job,Body 为 `{"message":"..."}`。 +- `GET /api/v1/chat/jobs/active`:返回当前运行中的 WebChat job(没有则为 `null`)。 +- `GET /api/v1/chat/jobs/{job_id}`:查询 job 状态、最后事件序号和已汇总输出。 +- `GET /api/v1/chat/jobs/{job_id}/events?after=`:订阅或续接 job SSE 事件。 +- `POST /api/v1/chat/jobs/{job_id}/cancel`:取消运行中的 job。 + +Runtime API 进程重启后不会恢复未完成 job;已落盘的聊天历史仍可通过 history 接口读取。 ### 工具调用 API @@ -422,6 +441,14 @@ 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 -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" @@ -450,12 +477,18 @@ WebUI 不直接在前端暴露 `auth_key`,而是通过后端代理访问主进 - `GET /api/runtime/cognitive/profile/{entity_type}/{entity_id}` - `POST /api/runtime/chat` - `GET /api/runtime/chat/history` +- `DELETE /api/runtime/chat/history` +- `POST /api/runtime/chat/jobs` +- `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。 +`/api/runtime/chat` 与 `/api/runtime/chat/jobs/{job_id}/events` 会透传 SSE keep-alive,聊天代理超时按当前聊天模型队列预算计算。 ## 7. 故障排查 diff --git a/docs/webui-guide.md b/docs/webui-guide.md index 42fdc361..6f00fce7 100644 --- a/docs/webui-guide.md +++ b/docs/webui-guide.md @@ -121,7 +121,10 @@ WebUI 内置的对话界面,直接与 Bot 的 AI 进行交互: - 支持文本和图片消息。 - AI 回复支持 Markdown 渲染。 -- 消息历史分页浏览。 +- 默认加载最新消息并滚动到底部;向上滚动到顶部会懒加载更早历史,并保持滚动位置。 +- 流式对话由 WebChat job 执行。刷新页面、关闭页面或网络短暂中断时,后端任务继续运行,前端会按 `job_id + seq` 自动续接事件。 +- 流式输出会更新同一个 AI 气泡;主对话工具和 Agent 调用会显示为可折叠进度块,展示状态、参数预览和结果摘要。 +- 清空历史只删除 WebUI 虚拟私聊 `system#42` 的聊天历史,不影响长期记忆、认知记忆或 profile。若仍有运行中的 WebChat job,清空会被拒绝。 - 发出的消息会经过与 QQ 侧相同的处理流程(安全检查、工具调用等)。 ### 关于(About) diff --git a/src/Undefined/ai/client/ask_loop.py b/src/Undefined/ai/client/ask_loop.py index bd75f01b..660c8098 100644 --- a/src/Undefined/ai/client/ask_loop.py +++ b/src/Undefined/ai/client/ask_loop.py @@ -77,6 +77,9 @@ async def ask( pre_context["request_id"] = ctx.request_id if extra_context: pre_context.update(extra_context) + stream_event_callback = pre_context.get("stream_event_callback") + if not callable(stream_event_callback): + stream_event_callback = None # ===== 阶段二:构建 LLM messages 与 OpenAI tools schema ===== messages = await self._prompt_builder.build_messages( @@ -205,6 +208,10 @@ async def fetch_session_messages_callback( missing_tool_call_count = 0 last_missing_tool_call_content = "" runtime_config = self._get_runtime_config() + agent_tool_names = { + str(schema.get("function", {}).get("name") or "") + for schema in self.agent_registry.get_agents_schema() + } max_pre_tool_retries = max( 0, int(getattr(runtime_config, "ai_request_max_retries", 0) or 0), @@ -223,6 +230,13 @@ async def fetch_session_messages_callback( tool_execution_started = False try: + pending_stream_events: list[tuple[str, dict[str, Any]]] = [] + + async def _collect_stream_event( + event: str, payload: dict[str, Any] + ) -> None: + pending_stream_events.append((event, payload)) + result = await self.submit_queued_llm_call( model_config=effective_chat_config, messages=messages, @@ -232,6 +246,11 @@ async def fetch_session_messages_callback( tool_choice="auto", transport_state=transport_state, queue_lane=queue_lane, + stream_event_callback=( + _collect_stream_event + if stream_event_callback is not None + else None + ), ) tool_name_map = ( @@ -310,6 +329,9 @@ async def fetch_session_messages_callback( # 无 tool_calls 与有 tool_calls 走不同分支 if not tool_calls: + if stream_event_callback is not None: + for event, payload in pending_stream_events: + await stream_event_callback(event, payload) if conversation_ended: logger.info( "[AI回复] 会话结束,返回最终内容: length=%s", @@ -371,6 +393,10 @@ async def fetch_session_messages_callback( "content": content, "tool_calls": tool_calls, } + if stream_event_callback is not None: + for event, payload in pending_stream_events: + if event != "token_delta": + await stream_event_callback(event, payload) missing_tool_call_count = 0 last_missing_tool_call_content = "" phase = message.get("phase") @@ -446,6 +472,17 @@ async def fetch_session_messages_callback( if not isinstance(function_args, dict): function_args = {} + if stream_event_callback is not None: + await stream_event_callback( + "tool_start", + { + "tool_call_id": call_id, + "name": internal_function_name, + "api_name": api_function_name, + "arguments": function_args, + "is_agent": internal_function_name in agent_tool_names, + }, + ) # 检测 end 工具,暂存后统一处理 if internal_function_name == "end": @@ -457,6 +494,18 @@ async def fetch_session_messages_callback( ) end_tool_call = tool_call end_tool_args = function_args + if stream_event_callback is not None: + await stream_event_callback( + "tool_end", + { + "tool_call_id": call_id, + "name": internal_function_name, + "api_name": api_function_name, + "ok": True, + "result": "会话结束请求已接收", + "is_agent": False, + }, + ) continue tool_call_ids.append(call_id) @@ -507,6 +556,18 @@ async def fetch_session_messages_callback( f"[工具响应体] {internal_fname} (ID={call_id})", content_str, ) + if stream_event_callback is not None: + await stream_event_callback( + "tool_end", + { + "tool_call_id": call_id, + "name": internal_fname, + "api_name": api_fname, + "ok": not isinstance(tool_result, Exception), + "result": content_str, + "is_agent": internal_fname in agent_tool_names, + }, + ) messages.append( { diff --git a/src/Undefined/ai/client/queue.py b/src/Undefined/ai/client/queue.py index 9a683a9d..1a31a942 100644 --- a/src/Undefined/ai/client/queue.py +++ b/src/Undefined/ai/client/queue.py @@ -4,7 +4,7 @@ import asyncio import logging -from typing import Any +from typing import Any, Awaitable, Callable from uuid import uuid4 from Undefined.ai.parsing import extract_choices_content @@ -83,6 +83,8 @@ async def submit_queued_llm_call( max_tokens: int | None = None, transport_state: dict[str, Any] | None = None, queue_lane: str | None = None, + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, ) -> dict[str, Any]: """将 LLM 调用投递到统一队列,走统一发车间隔和重试逻辑。 无 queue_manager 时降级为直接调用。""" @@ -102,6 +104,7 @@ async def submit_queued_llm_call( call_type=call_type, max_tokens=effective_max_tokens, transport_state=transport_state, + stream_event_callback=stream_event_callback, ) request_id = uuid4().hex event: asyncio.Event = asyncio.Event() @@ -119,6 +122,8 @@ async def submit_queued_llm_call( "max_tokens": effective_max_tokens, "transport_state": transport_state, } + if stream_event_callback is not None: + request["stream_event_callback"] = stream_event_callback ctx = RequestContext.current() if ctx is not None: if ctx.group_id is not None: diff --git a/src/Undefined/ai/client/setup.py b/src/Undefined/ai/client/setup.py index f89a2abd..9429f2bc 100644 --- a/src/Undefined/ai/client/setup.py +++ b/src/Undefined/ai/client/setup.py @@ -771,6 +771,8 @@ async def request_model( tools: list[dict[str, Any]] | None = None, tool_choice: str = "auto", transport_state: dict[str, Any] | None = None, + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, **kwargs: Any, ) -> dict[str, Any]: tools = self.tool_manager.maybe_merge_agent_tools(call_type, tools) @@ -797,6 +799,7 @@ async def request_model( tool_choice=tool_choice, transport_state=transport_state, message_count_for_transport=message_count_for_transport, + stream_event_callback=stream_event_callback, **kwargs, ) diff --git a/src/Undefined/ai/llm/requester.py b/src/Undefined/ai/llm/requester.py index 10329d0e..47845116 100644 --- a/src/Undefined/ai/llm/requester.py +++ b/src/Undefined/ai/llm/requester.py @@ -13,7 +13,7 @@ import re import time from datetime import datetime -from typing import Any +from typing import Any, Awaitable, Callable from urllib.parse import parse_qsl, urlsplit, urlunsplit import httpx @@ -320,6 +320,8 @@ async def request( tool_choice: str = "auto", transport_state: dict[str, Any] | None = None, message_count_for_transport: int | None = None, + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, **kwargs: Any, ) -> dict[str, Any]: """发送请求到模型 API。""" @@ -472,7 +474,11 @@ 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, + stream_event_callback=stream_event_callback, + ) except APIStatusError as exc: # Responses 续轮失败:自动切换 stateless replay 重发全量 input if ( @@ -507,7 +513,9 @@ async def request( logger, "[API请求体][stateless replay]", request_body ) raw_result = await self._request_with_openai( - model_config, request_body + model_config, + request_body, + stream_event_callback=stream_event_callback, ) else: raise @@ -666,7 +674,12 @@ 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], + *, + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, ) -> dict[str, Any]: client = self._get_openai_client_for_model(model_config) if bool(getattr(model_config, "stream_enabled", False)): @@ -676,6 +689,7 @@ async def _request_with_openai( client, model_config, request_body, + stream_event_callback=stream_event_callback, ) except Exception as exc: # 上游不支持流式时,剥离 stream 字段后降级为非流式重试 @@ -705,18 +719,26 @@ async def _request_with_openai_streaming( client: AsyncOpenAI, model_config: ModelConfig, request_body: dict[str, Any], + *, + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, ) -> dict[str, Any]: api_mode = get_api_mode(model_config) 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, + stream_event_callback=stream_event_callback, + ) ensure_chat_stream_usage_options(stream_body) return await self._stream_chat_completions_request( # client, stream_body, model_config client, stream_body, model_config, + stream_event_callback=stream_event_callback, ) async def _stream_chat_completions_request( @@ -724,6 +746,9 @@ async def _stream_chat_completions_request( client: AsyncOpenAI, request_body: dict[str, Any], model_config: ModelConfig, + *, + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, ) -> dict[str, Any]: params, extra_body = split_chat_completion_params(request_body) if extra_body: @@ -735,14 +760,25 @@ 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) + if stream_event_callback is not None: + await self._emit_chat_stream_events( + chunk_dict, + stream_event_callback=stream_event_callback, + ) 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], + *, + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, ) -> dict[str, Any]: params, extra_body = split_responses_params(request_body) if extra_body: @@ -751,9 +787,84 @@ 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) + if stream_event_callback is not None: + await self._emit_responses_stream_events( + event_dict, + stream_event_callback=stream_event_callback, + ) return aggregate_responses_stream(events) + async def _emit_chat_stream_events( + self, + chunk: dict[str, Any], + *, + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]], + ) -> None: + choices = chunk.get("choices") + if not isinstance(choices, list): + return + for choice in choices: + if not isinstance(choice, dict): + continue + delta = choice.get("delta") + if not isinstance(delta, dict): + continue + content_delta = delta.get("content") + if content_delta: + await stream_event_callback( + "token_delta", + {"delta": str(content_delta)}, + ) + raw_tool_calls = delta.get("tool_calls") + if not isinstance(raw_tool_calls, list): + continue + for tool_delta in raw_tool_calls: + if not isinstance(tool_delta, dict): + continue + function = tool_delta.get("function") + payload: dict[str, Any] = { + "index": tool_delta.get("index"), + "id": str(tool_delta.get("id") or ""), + "name": "", + "arguments_delta": "", + } + if isinstance(function, dict): + payload["name"] = str(function.get("name") or "") + payload["arguments_delta"] = str(function.get("arguments") or "") + if payload["name"] or payload["arguments_delta"] or payload["id"]: + await stream_event_callback("tool_delta", payload) + + async def _emit_responses_stream_events( + self, + event: dict[str, Any], + *, + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]], + ) -> None: + event_type = str(event.get("type") or "").strip() + if event_type == "response.output_text.delta": + delta = event.get("delta") + if delta: + await stream_event_callback("token_delta", {"delta": str(delta)}) + return + lowered = event_type.lower() + if "function_call" not in lowered: + return + delta = event.get("delta") or event.get("arguments_delta") + item = event.get("item") or event.get("output_item") + payload: dict[str, Any] = { + "index": event.get("output_index") or event.get("item_index"), + "id": "", + "name": "", + "arguments_delta": str(delta or ""), + } + if isinstance(item, dict): + payload["id"] = str(item.get("id") or item.get("call_id") or "") + payload["name"] = str(item.get("name") or "") + if payload["name"] or payload["arguments_delta"] or payload["id"]: + await stream_event_callback("tool_delta", payload) + async def embed( self, model_config: EmbeddingModelConfig, 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..d54cfd8c 100644 --- a/src/Undefined/api/_openapi.py +++ b/src/Undefined/api/_openapi.py @@ -90,12 +90,27 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st "summary": "WebUI special private chat", "description": ( "POST JSON {message, stream?}. " - "When stream=true, response is SSE with keep-alive comments." + "stream=false runs synchronously; stream=true creates a " + "WebChat job and bridges its events as SSE." ), } }, "/api/v1/chat/history": { - "get": {"summary": "Get virtual private chat history for WebUI"} + "get": {"summary": "Get paged virtual private chat history for WebUI"}, + "delete": {"summary": "Clear WebUI virtual private chat 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 WebUI chat job SSE events"} + }, + "/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..54bcd200 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -43,6 +43,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 @@ -134,6 +135,18 @@ async def _auth_middleware( ), 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), ] @@ -253,8 +266,40 @@ async def _run_webui_chat( async def _chat_history_handler(self, request: web.Request) -> Response: return await chat.chat_history_handler(self._ctx, 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..dc823f78 100644 --- a/src/Undefined/api/routes/chat.py +++ b/src/Undefined/api/routes/chat.py @@ -3,10 +3,14 @@ from __future__ import annotations import asyncio +import inspect import logging from contextlib import suppress +from dataclasses import dataclass, field from datetime import datetime +import time from typing import Any, Awaitable, Callable +from uuid import uuid4 from aiohttp import web from aiohttp.web_response import Response @@ -37,6 +41,331 @@ _VIRTUAL_USER_NAME = "system" _CHAT_SSE_KEEPALIVE_SECONDS = 10.0 +_CHAT_JOB_EVENT_BUFFER_LIMIT = 1000 +_PREVIEW_LIMIT = 800 + + +@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 + status: str = "queued" + mode: str = "chat" + outputs: list[str] = field(default_factory=list) + events: list[ChatJobEvent] = field(default_factory=list) + next_seq: int = 1 + task: asyncio.Task[None] | None = None + error: str = "" + done: asyncio.Event = field(default_factory=asyncio.Event) + changed: asyncio.Condition = field(default_factory=asyncio.Condition) + + def snapshot(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "status": self.status, + "mode": self.mode, + "created_at": self.created_at, + "updated_at": self.updated_at, + "last_seq": self.next_seq - 1, + "error": self.error or None, + "reply": "\n\n".join(self.outputs).strip(), + "messages": list(self.outputs), + } + + +class ChatJobManager: + def __init__(self, ctx: RuntimeAPIContext) -> None: + self._ctx = ctx + self._jobs: dict[str, ChatJob] = {} + self._lock = asyncio.Lock() + + async def create_job(self, text: str) -> ChatJob: + now = time.time() + job = ChatJob( + job_id=uuid4().hex, + text=text, + created_at=now, + updated_at=now, + ) + async with self._lock: + self._jobs[job.job_id] = job + await self._append_event( + job, + "meta", + { + "job_id": job.job_id, + "virtual_user_id": _VIRTUAL_USER_ID, + "permission": "superadmin", + }, + ) + 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) -> ChatJob | None: + async with self._lock: + candidates = [ + job + for job in self._jobs.values() + if job.status in {"queued", "running"} + ] + if not candidates: + return None + return max(candidates, key=lambda item: item.created_at) + + async def has_running_job(self) -> bool: + return await self.get_active_job() is not None + + 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 + job.status = "cancelled" + job.updated_at = time.time() + if job.task is not None and not job.task.done(): + job.task.cancel() + 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}, + ) + job.done.set() + return job + + 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 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() + 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 + 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) + await self._ctx.history_manager.add_private_message( + user_id=_VIRTUAL_USER_ID, + text_content=rendered.history_text, + display_name="Bot", + user_name="Bot", + attachments=rendered.attachments, + ) + await self._append_event( + job, + "message", + {"content": rendered.delivery_text, "job_id": job.job_id}, + ) + + async def _stream_event_callback(event: str, payload: dict[str, Any]) -> None: + await self._append_event( + job, + event, + {**_sanitize_stream_payload(event, payload), "job_id": job.job_id}, + ) + + try: + run_kwargs: dict[str, Any] = { + "text": job.text, + "send_output": _capture_private_message, + } + if "stream_event_callback" in inspect.signature(run_webui_chat).parameters: + run_kwargs["stream_event_callback"] = _stream_event_callback + mode = await run_webui_chat(self._ctx, **run_kwargs) + job.mode = mode + job.status = "done" + job.updated_at = time.time() + done_payload = _build_chat_response_payload(mode, outputs) + done_payload.update({"job_id": job.job_id, "status": job.status}) + await self._append_event( + job, + "done", + done_payload, + ) + except asyncio.CancelledError: + job.status = "cancelled" + job.updated_at = time.time() + 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}, + ) + except Exception as exc: + logger.exception("[RuntimeAPI] chat job failed: %s", exc) + job.status = "error" + job.error = str(exc) + job.updated_at = time.time() + await self._append_event( + job, + "error", + {"error": str(exc), "job_id": job.job_id}, + ) + finally: + job.done.set() + + async def _append_event( + self, job: ChatJob, event: str, payload: dict[str, Any] + ) -> None: + async with job.changed: + normalized_event = str(payload.pop("_event", event) or event) + item = ChatJobEvent( + seq=job.next_seq, event=normalized_event, payload=payload + ) + 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:] + job.changed.notify_all() + + +def _preview(value: Any, limit: int = _PREVIEW_LIMIT) -> str: + text = str(value or "") + compact = " ".join(text.split()) + if len(compact) <= limit: + return compact + return compact[:limit] + "..." + + +def _sanitize_stream_payload(event: str, payload: dict[str, Any]) -> dict[str, Any]: + if event == "token_delta": + return {"delta": str(payload.get("delta") or "")} + if event == "tool_delta": + return { + "index": payload.get("index"), + "tool_call_id": str(payload.get("id") or ""), + "name": str(payload.get("name") or ""), + "arguments_delta": _preview(payload.get("arguments_delta") or "", 300), + } + 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" + return { + "_event": output_event, + "tool_call_id": str(payload.get("tool_call_id") or ""), + "name": str(payload.get("name") or ""), + "api_name": str(payload.get("api_name") or ""), + "arguments_preview": _preview(payload.get("arguments")), + "is_agent": is_agent, + } + 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" + return { + "_event": output_event, + "tool_call_id": str(payload.get("tool_call_id") or ""), + "name": str(payload.get("name") or ""), + "api_name": str(payload.get("api_name") or ""), + "ok": bool(payload.get("ok", True)), + "result_preview": _preview(payload.get("result")), + "is_agent": is_agent, + } + return {key: value for key, value in payload.items() if key != "arguments"} + + +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 _history_record_to_item(item: dict[str, Any]) -> dict[str, Any] | None: + content = str(item.get("message", "")).strip() + if not content: + return None + display_name = str(item.get("display_name", "")).strip().lower() + role = "bot" if display_name == "bot" else "user" + return { + "role": role, + "content": content, + "timestamp": str(item.get("timestamp", "") or "").strip(), + } async def run_webui_chat( @@ -44,6 +373,8 @@ async def run_webui_chat( *, text: str, send_output: Callable[[int, str], Awaitable[None]], + stream_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, ) -> str: """Execute a single WebUI chat turn (command dispatch or AI ask).""" @@ -164,6 +495,7 @@ def send_message_callback( "sender_name": _VIRTUAL_USER_NAME, "webui_session": True, "webui_permission": "superadmin", + "stream_event_callback": stream_event_callback, }, ) @@ -179,34 +511,33 @@ async def chat_history_handler( ) -> Response: """Return recent WebUI chat history.""" - limit_raw = str(request.query.get("limit", "200") or "200").strip() - try: - limit = int(limit_raw) - except ValueError: - limit = 200 - limit = max(1, min(limit, 500)) + limit = _parse_limit(request, default=50, maximum=500) + before = _parse_before(request) - getter = getattr(ctx.history_manager, "get_recent_private", None) - if not callable(getter): + page_getter = getattr(ctx.history_manager, "get_private_page", None) + recent_getter = getattr(ctx.history_manager, "get_recent_private", None) + if not callable(page_getter) and not callable(recent_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(), - } + if callable(page_getter): + records, has_more, next_before, total = page_getter( + _VIRTUAL_USER_ID, + limit=limit, + before=before, ) + elif callable(recent_getter): + records = recent_getter(_VIRTUAL_USER_ID, limit) + has_more = False + next_before = None + total = len(records) + else: + return _json_error("History manager not ready", status=503) + items: list[dict[str, Any]] = [] + for record in records: + if isinstance(record, dict): + mapped = _history_record_to_item(record) + if mapped is not None: + items.append(mapped) return web.json_response( { @@ -214,12 +545,42 @@ async def chat_history_handler( "permission": "superadmin", "count": len(items), "items": items, + "limit": limit, + "before": before, + "has_more": has_more, + "next_before": next_before, + "total": total, + } + ) + + +async def chat_history_clear_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + """Clear WebUI virtual private chat history only.""" + + _ = request + if await job_manager.has_running_job(): + return _json_error("Chat job is still running", status=409) + clearer = getattr(ctx.history_manager, "clear_private_history", None) + if not callable(clearer): + return _json_error("History manager not ready", status=503) + cleared = await clearer(_VIRTUAL_USER_ID) + return web.json_response( + { + "success": True, + "virtual_user_id": _VIRTUAL_USER_ID, + "cleared": int(cleared or 0), } ) 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).""" @@ -233,36 +594,36 @@ async def chat_handler( return _json_error("message is required", status=400) 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, - ) - - 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 ctx.history_manager.add_private_message( + user_id=_VIRTUAL_USER_ID, + 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 @@ -282,74 +643,33 @@ 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 + job = await job_manager.create_job(text) + 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=_CHAT_SSE_KEEPALIVE_SECONDS, + ) + if not events: 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 +677,107 @@ 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: + _ = ctx + 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) + job = await job_manager.create_job(text) + return web.json_response(job.snapshot(), status=202) + + +async def chat_job_active_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + _ = ctx, request + job = await job_manager.get_active_job() + return web.json_response({"job": job.snapshot() if job is not None else None}) + + +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(job.snapshot()) + + +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) + return web.json_response(job.snapshot()) + + +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) + after = _parse_after(request) + + 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=_CHAT_SSE_KEEPALIVE_SECONDS, + ) + if not events: + 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/services/ai_coordinator.py b/src/Undefined/services/ai_coordinator.py index c61b6017..942956b9 100644 --- a/src/Undefined/services/ai_coordinator.py +++ b/src/Undefined/services/ai_coordinator.py @@ -668,6 +668,7 @@ async def _execute_queued_llm_call(self, request: dict[str, Any]) -> None: max_tokens=request.get("max_tokens") or getattr(request["model_config"], "max_tokens", 4096), transport_state=request.get("transport_state"), + stream_event_callback=request.get("stream_event_callback"), ) self.ai.set_llm_call_result(request_id, result) if retry_count > 0: diff --git a/src/Undefined/utils/history.py b/src/Undefined/utils/history.py index 76cebae2..ac2f646e 100644 --- a/src/Undefined/utils/history.py +++ b/src/Undefined/utils/history.py @@ -514,6 +514,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/webui/routes/_runtime.py b/src/Undefined/webui/routes/_runtime.py index fa26c0d8..c9c950fc 100644 --- a/src/Undefined/webui/routes/_runtime.py +++ b/src/Undefined/webui/routes/_runtime.py @@ -173,6 +173,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 +194,7 @@ async def _proxy_runtime_stream( async with session.request( method=method, url=url, + params=params, json=payload, headers=headers, ) as upstream: @@ -425,6 +427,82 @@ 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", + ) + + +@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) + return await _proxy_runtime( + method="POST", + path="/api/v1/chat/jobs", + payload={"message": message}, + 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") + + +@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="") + 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: diff --git a/src/Undefined/webui/static/css/app.css b/src/Undefined/webui/static/css/app.css index 083ebb39..fd2ebd5b 100644 --- a/src/Undefined/webui/static/css/app.css +++ b/src/Undefined/webui/static/css/app.css @@ -159,24 +159,40 @@ body.is-mobile-drawer-open { width: 100%; } .main-content.chat-layout { max-width: none; } -.main-content.chat-layout #tab-chat .runtime-card { max-width: 100%; } .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: clamp(620px, calc(100vh - 170px), 980px); + max-width: 100%; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; } .main-content.chat-layout #tab-chat .runtime-chat-log { min-height: 0; max-height: none; + margin-bottom: 0; + padding: 18px; } .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: sticky; + bottom: 0; + padding-top: 12px; + background: var(--bg-main); +} +.main-content.chat-layout #tab-chat .runtime-chat-content { + font-size: 15.5px; +} .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..52f3643a 100644 --- a/src/Undefined/webui/static/css/components.css +++ b/src/Undefined/webui/static/css/components.css @@ -587,11 +587,29 @@ display: grid; gap: 10px; } +.chat-workspace-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} +.chat-workspace-head .muted-sm { + margin-bottom: 0; +} +.runtime-chat-load-more { + min-height: 20px; + color: var(--text-tertiary); + font-size: 12px; + text-align: center; +} .runtime-chat-item { 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,6 +617,16 @@ .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; @@ -607,7 +635,7 @@ margin-bottom: 6px; } .runtime-chat-content { - font-size: 14px; + font-size: 15px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; @@ -778,6 +806,83 @@ resize: vertical; line-height: 1.5; } +.runtime-chat-tools { + display: grid; + gap: 8px; + margin-top: 10px; +} +.runtime-tool-block { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-app); + overflow: hidden; +} +.runtime-tool-block summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 8px 10px; + font-size: 12px; + color: var(--text-secondary); +} +.runtime-tool-block summary code { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border: 0; + background: transparent; + padding: 0; +} +.runtime-tool-block summary em { + font-style: normal; + color: var(--text-tertiary); +} +.runtime-tool-block.done summary em { + color: var(--success); +} +.runtime-tool-block.error summary em { + color: var(--danger); +} +.runtime-tool-block pre, +.runtime-tool-result { + margin: 0; + padding: 9px 10px; + border-top: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} +@keyframes runtime-chat-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: 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 { + animation: none; + } +} .sr-only { position: absolute; width: 1px; diff --git a/src/Undefined/webui/static/css/responsive.css b/src/Undefined/webui/static/css/responsive.css index b6feeba1..2ed9a425 100644 --- a/src/Undefined/webui/static/css/responsive.css +++ b/src/Undefined/webui/static/css/responsive.css @@ -12,7 +12,7 @@ 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 #tab-chat .chat-runtime-card { height: clamp(560px, calc(100vh - 170px), 860px); } } @media (max-width: 768px) { @@ -256,7 +256,11 @@ align-items: stretch; } .runtime-chat-actions { justify-content: flex-start; } - .main-content.chat-layout #tab-chat .chat-runtime-card { height: clamp(480px, calc(100vh - 180px), 720px); } + .chat-workspace-head { + flex-direction: column; + align-items: stretch; + } + .main-content.chat-layout #tab-chat .chat-runtime-card { height: clamp(540px, calc(100vh - 140px), 760px); } .main-content.chat-layout #tab-chat .runtime-chat-input { height: auto; min-height: 88px; diff --git a/src/Undefined/webui/static/js/i18n.js b/src/Undefined/webui/static/js/i18n.js index 7be72b9f..95ce9504 100644 --- a/src/Undefined/webui/static/js/i18n.js +++ b/src/Undefined/webui/static/js/i18n.js @@ -266,6 +266,19 @@ const I18N = { "runtime.chat_hint": "该会话由 WebUI 发起,权限为 superadmin;私聊里可直接使用 /命令。", "runtime.chat_placeholder": "输入消息,或直接 /help 这样的命令", + "runtime.chat_clear": "清空历史", + "runtime.chat_clear_confirm": + "确定清空 WebUI 虚拟私聊 system#42 的聊天历史吗?长期记忆和认知记忆不会受影响。", + "runtime.chat_cleared": "聊天历史已清空", + "runtime.chat_loading_more": "正在加载更早消息...", + "runtime.chat_streaming": "正在生成", + "runtime.chat_reconnecting": "正在恢复连接...", + "runtime.chat_running": "已有对话正在运行,请稍候。", + "runtime.tool": "工具", + "runtime.agent": "智能体", + "runtime.running": "运行中", + "runtime.done": "完成", + "runtime.error": "错误", "runtime.image": "图片", "runtime.image_added": "已插入图片", "runtime.download": "下载", @@ -589,6 +602,19 @@ const I18N = { "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_clear": "Clear History", + "runtime.chat_clear_confirm": + "Clear the WebUI virtual private chat history for system#42? Long-term and cognitive memory are not affected.", + "runtime.chat_cleared": "Chat history cleared", + "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.tool": "Tool", + "runtime.agent": "Agent", + "runtime.running": "Running", + "runtime.done": "Done", + "runtime.error": "Error", "runtime.image": "Image", "runtime.image_added": "Image inserted", "runtime.download": "Download", diff --git a/src/Undefined/webui/static/js/runtime.js b/src/Undefined/webui/static/js/runtime.js index c2f8c3f0..dde332ba 100644 --- a/src/Undefined/webui/static/js/runtime.js +++ b/src/Undefined/webui/static/js/runtime.js @@ -7,6 +7,14 @@ runtimeEnabled: true, chatBusy: false, chatHistoryLoaded: false, + activeJobId: null, + lastEventSeq: 0, + chatHistoryCursor: null, + chatHistoryHasMore: false, + chatHistoryLoading: false, + streamingMessageId: null, + chatReconnectTimer: null, + toolBlocks: new Map(), probeTimer: null, queryBusy: { memory: false, @@ -448,24 +456,156 @@ ); } - function appendChatMessage(role, content) { + function appendChatMessage(role, content, options = {}) { const log = get("runtimeChatLog"); - if (!log) return; + if (!log) return null; const isBot = role !== "user"; const contentClass = isBot ? "runtime-chat-content markdown" : "runtime-chat-content"; const item = document.createElement("div"); item.className = `runtime-chat-item ${role}`; + if (options.id) item.dataset.messageId = options.id; item.innerHTML = `
${role === "user" ? "You" : "AI"}
${renderChatContent(content, isBot)}
`; - log.appendChild(item); + if (options.prepend) { + log.insertBefore(item, log.firstChild); + } else { + log.appendChild(item); + if (options.scroll !== false) scrollChatToBottom(); + } + return item; + } + + function scrollChatToBottom() { + const log = get("runtimeChatLog"); + if (!log) return; log.scrollTop = log.scrollHeight; } + function updateChatMessage(item, content, role = "bot") { + if (!item) return; + const contentEl = item.querySelector(".runtime-chat-content"); + if (!contentEl) return; + const isBot = role !== "user"; + contentEl.innerHTML = renderChatContent(content, isBot); + } + + function ensureStreamingMessage() { + if (runtimeState.streamingMessageId) { + const existing = document.querySelector( + `[data-message-id="${runtimeState.streamingMessageId}"]`, + ); + if (existing) return existing; + } + const id = `stream-${Date.now()}`; + runtimeState.streamingMessageId = id; + const item = appendChatMessage("bot", "", { id }); + if (item) item.classList.add("streaming"); + return item; + } + + function appendTokenDelta(delta) { + const item = ensureStreamingMessage(); + if (!item) return; + const current = item.dataset.rawContent || ""; + const next = current + String(delta || ""); + item.dataset.rawContent = next; + updateChatMessage(item, next, "bot"); + scrollChatToBottom(); + } + + function finishStreamingMessage() { + if (!runtimeState.streamingMessageId) return; + const item = document.querySelector( + `[data-message-id="${runtimeState.streamingMessageId}"]`, + ); + if (item) item.classList.remove("streaming"); + runtimeState.streamingMessageId = null; + } + + function renderToolBlock(block) { + const label = block.isAgent ? t("runtime.agent") : t("runtime.tool"); + const statusLabel = + block.status === "done" + ? t("runtime.done") + : block.status === "error" + ? t("runtime.error") + : t("runtime.running"); + const args = block.argumentsPreview + ? `
${escapeHtml(block.argumentsPreview)}
` + : ""; + const result = block.resultPreview + ? `
${escapeHtml(block.resultPreview)}
` + : ""; + const openAttr = block.status === "running" ? " open" : ""; + return ( + `
` + + `${escapeHtml(label)}${escapeHtml(block.name || "--")}${escapeHtml(statusLabel)}` + + args + + result + + `
` + ); + } + + function upsertToolBlock(payload, status) { + const key = + String( + payload && payload.tool_call_id ? payload.tool_call_id : "", + ) || + String(payload && payload.name ? payload.name : "") || + `tool-${runtimeState.toolBlocks.size + 1}`; + const previous = runtimeState.toolBlocks.get(key) || {}; + const block = { + ...previous, + name: String((payload && payload.name) || previous.name || ""), + isAgent: !!( + (payload && payload.is_agent) || + previous.isAgent || + status === "agent_start" || + status === "agent_end" + ), + status: + status === "tool_end" || status === "agent_end" + ? payload && payload.ok === false + ? "error" + : "done" + : "running", + argumentsPreview: String( + (payload && payload.arguments_preview) || + previous.argumentsPreview || + "", + ), + resultPreview: String( + (payload && payload.result_preview) || + previous.resultPreview || + "", + ), + }; + runtimeState.toolBlocks.set(key, block); + const item = ensureStreamingMessage(); + if (!item) return; + const toolsEl = item.querySelector(".runtime-chat-tools"); + if (toolsEl) { + toolsEl.innerHTML = Array.from(runtimeState.toolBlocks.values()) + .map(renderToolBlock) + .join(""); + return; + } + const container = document.createElement("div"); + container.className = "runtime-chat-tools"; + container.innerHTML = Array.from(runtimeState.toolBlocks.values()) + .map(renderToolBlock) + .join(""); + item.appendChild(container); + scrollChatToBottom(); + } + function clearChatMessages() { const log = get("runtimeChatLog"); if (!log) return; log.innerHTML = ""; + runtimeState.streamingMessageId = null; + runtimeState.toolBlocks.clear(); } function parseCqAttributes(raw) { @@ -657,9 +797,15 @@ const block = String(rawBlock || "").trim(); if (!block) return; let event = "message"; + let seq = 0; const dataLines = []; block.split("\n").forEach((line) => { if (line.startsWith(":")) return; + if (line.startsWith("id:")) { + const parsed = Number(line.slice(3).trim()); + seq = Number.isFinite(parsed) ? parsed : 0; + return; + } if (line.startsWith("event:")) { event = line.slice(6).trim() || "message"; return; @@ -676,7 +822,7 @@ } catch (_error) { payload = { raw: rawData }; } - onEvent(event, payload); + onEvent(event, payload, seq); } while (true) { @@ -1207,9 +1353,11 @@ async function loadChatHistory(force = false) { if (runtimeState.chatHistoryLoaded && !force) return; - const res = await api("/api/runtime/chat/history?limit=200"); + runtimeState.chatHistoryLoading = true; + const res = await api("/api/runtime/chat/history?limit=50"); const data = await parseJsonSafe(res); if (!res.ok || (data && data.error)) { + runtimeState.chatHistoryLoading = false; throw new Error(buildRequestError(res, data)); } @@ -1219,9 +1367,265 @@ const role = item && item.role === "bot" ? "bot" : "user"; const content = String((item && item.content) || "").trim(); if (!content) return; - appendChatMessage(role, content); + appendChatMessage(role, content, { scroll: false }); }); + runtimeState.chatHistoryCursor = + data && data.next_before !== undefined ? data.next_before : null; + runtimeState.chatHistoryHasMore = !!(data && data.has_more); runtimeState.chatHistoryLoaded = true; + runtimeState.chatHistoryLoading = false; + scrollChatToBottom(); + await resumeActiveChatJob(); + } + + async function loadOlderChatHistory() { + const log = get("runtimeChatLog"); + if ( + !log || + runtimeState.chatHistoryLoading || + !runtimeState.chatHistoryHasMore + ) + return; + runtimeState.chatHistoryLoading = true; + const loader = get("runtimeChatLoadMore"); + if (loader) loader.textContent = t("runtime.chat_loading_more"); + const previousHeight = log.scrollHeight; + const before = runtimeState.chatHistoryCursor; + try { + const params = new URLSearchParams({ limit: "50" }); + if (before !== null && before !== undefined) { + params.set("before", String(before)); + } + const res = await api( + `/api/runtime/chat/history?${params.toString()}`, + ); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + throw new Error(buildRequestError(res, data)); + } + const items = data && Array.isArray(data.items) ? data.items : []; + for (let idx = items.length - 1; idx >= 0; idx -= 1) { + const item = items[idx]; + const role = item && item.role === "bot" ? "bot" : "user"; + const content = String((item && item.content) || "").trim(); + if (!content) continue; + appendChatMessage(role, content, { + prepend: true, + scroll: false, + }); + } + runtimeState.chatHistoryCursor = + data && data.next_before !== undefined + ? data.next_before + : null; + runtimeState.chatHistoryHasMore = !!(data && data.has_more); + log.scrollTop = log.scrollHeight - previousHeight; + } catch (error) { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + } finally { + runtimeState.chatHistoryLoading = false; + if (loader) loader.textContent = ""; + } + } + + function applyChatEvent(event, payload, seq = 0) { + if (seq) + runtimeState.lastEventSeq = Math.max( + runtimeState.lastEventSeq, + seq, + ); + if (event === "meta") { + if (payload && payload.job_id) { + runtimeState.activeJobId = String(payload.job_id); + } + return; + } + if (event === "token_delta") { + appendTokenDelta(payload && payload.delta ? payload.delta : ""); + return; + } + if ( + event === "tool_start" || + event === "tool_end" || + event === "agent_start" || + event === "agent_end" + ) { + upsertToolBlock(payload || {}, event); + return; + } + if (event === "message") { + const content = String( + payload && (payload.content ?? payload.message) + ? (payload.content ?? payload.message) + : "", + ).trim(); + if (!content) return; + const streaming = runtimeState.streamingMessageId + ? document.querySelector( + `[data-message-id="${runtimeState.streamingMessageId}"]`, + ) + : null; + if (streaming && !(streaming.dataset.rawContent || "").trim()) { + streaming.dataset.rawContent = content; + updateChatMessage(streaming, content, "bot"); + finishStreamingMessage(); + } else { + finishStreamingMessage(); + appendChatMessage("bot", content); + } + return; + } + if (event === "done") { + if ( + payload && + payload.reply && + runtimeState.streamingMessageId && + !( + document.querySelector( + `[data-message-id="${runtimeState.streamingMessageId}"]`, + )?.dataset.rawContent || "" + ).trim() + ) { + appendTokenDelta(String(payload.reply)); + } + finishStreamingMessage(); + runtimeState.activeJobId = null; + runtimeState.chatBusy = false; + runtimeState.chatHistoryLoaded = true; + setButtonLoading(get("btnRuntimeChatSend"), false); + return; + } + if (event === "error") { + finishStreamingMessage(); + runtimeState.activeJobId = null; + runtimeState.chatBusy = false; + setButtonLoading(get("btnRuntimeChatSend"), false); + const message = String( + payload && (payload.error || payload.message) + ? payload.error || payload.message + : "stream error", + ); + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(message)}`, + "error", + 5000, + ); + } + } + + async function attachChatJob(jobId, after = 0) { + runtimeState.activeJobId = jobId; + runtimeState.lastEventSeq = Number(after || 0); + runtimeState.chatBusy = true; + setButtonLoading(get("btnRuntimeChatSend"), true); + try { + const params = new URLSearchParams({ + after: String(runtimeState.lastEventSeq), + }); + const res = await api( + `/api/runtime/chat/jobs/${encodeURIComponent(jobId)}/events?${params.toString()}`, + { headers: { Accept: "text/event-stream" } }, + ); + const contentType = ( + res.headers.get("Content-Type") || "" + ).toLowerCase(); + if ( + !res.ok || + !contentType.includes("text/event-stream") || + !res.body + ) { + const data = await parseJsonSafe(res); + throw new Error(buildRequestError(res, data)); + } + await consumeSse(res, applyChatEvent); + if (runtimeState.activeJobId === jobId && runtimeState.chatBusy) { + const detail = await fetchJsonOrThrow( + `/api/runtime/chat/jobs/${encodeURIComponent(jobId)}`, + ); + if ( + detail && + ["done", "error", "cancelled"].includes(detail.status) + ) { + applyChatEvent( + detail.status === "done" ? "done" : "error", + detail.status === "done" + ? detail + : { error: detail.error || detail.status }, + detail.last_seq || runtimeState.lastEventSeq, + ); + } + } + } catch (error) { + if (runtimeState.activeJobId === jobId) { + showToast(t("runtime.chat_reconnecting"), "warning", 1800); + clearTimeout(runtimeState.chatReconnectTimer); + runtimeState.chatReconnectTimer = setTimeout(() => { + attachChatJob(jobId, runtimeState.lastEventSeq).catch( + () => {}, + ); + }, 1200); + } else { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + } + } + } + + async function resumeActiveChatJob() { + if (runtimeState.activeJobId) return; + try { + const data = await fetchJsonOrThrow( + "/api/runtime/chat/jobs/active", + ); + const job = data && data.job ? data.job : null; + if (!job || !job.job_id) return; + runtimeState.activeJobId = String(job.job_id); + attachChatJob( + runtimeState.activeJobId, + runtimeState.lastEventSeq, + ).catch(() => {}); + } catch (_error) { + // Runtime disabled/unreachable is surfaced by normal chat actions. + } + } + + async function clearChatHistory() { + if (runtimeState.chatBusy || runtimeState.activeJobId) { + showToast(t("runtime.chat_running"), "warning", 3000); + return; + } + if (!window.confirm(t("runtime.chat_clear_confirm"))) return; + const button = get("btnRuntimeChatClear"); + setButtonLoading(button, true); + try { + const res = await api("/api/runtime/chat/history", { + method: "DELETE", + }); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + throw new Error(buildRequestError(res, data)); + } + clearChatMessages(); + runtimeState.chatHistoryLoaded = true; + runtimeState.chatHistoryCursor = null; + runtimeState.chatHistoryHasMore = false; + showToast(t("runtime.chat_cleared"), "success", 2200); + } catch (error) { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + } finally { + setButtonLoading(button, false); + } } async function sendChatMessage() { @@ -1234,87 +1638,35 @@ runtimeState.chatBusy = true; setButtonLoading(button, true); + runtimeState.toolBlocks.clear(); + runtimeState.streamingMessageId = null; + runtimeState.lastEventSeq = 0; appendChatMessage("user", message); input.value = ""; try { - const res = await api("/api/runtime/chat", { + const res = await api("/api/runtime/chat/jobs", { method: "POST", - headers: { Accept: "text/event-stream" }, - body: JSON.stringify({ message, stream: true }), + body: JSON.stringify({ message }), }); - - const contentType = ( - res.headers.get("Content-Type") || "" - ).toLowerCase(); - if (contentType.includes("text/event-stream") && res.body) { - let replied = false; - let streamError = ""; - let donePayload = null; - await consumeSse(res, (event, payload) => { - if (event === "message") { - const content = String( - payload && (payload.content ?? payload.message) - ? (payload.content ?? payload.message) - : "", - ).trim(); - if (!content) return; - appendChatMessage("bot", content); - replied = true; - return; - } - if (event === "error") { - streamError = String( - payload && (payload.error || payload.message) - ? payload.error || payload.message - : "stream error", - ); - return; - } - if (event === "done") { - donePayload = payload; - } - }); - if (streamError) { - throw new Error(streamError); - } - if (!replied && donePayload && donePayload.reply) { - appendChatMessage("bot", String(donePayload.reply)); - replied = true; - } - if (!replied) { - appendChatMessage("bot", t("runtime.empty")); - } - runtimeState.chatHistoryLoaded = true; - return; - } - const data = await parseJsonSafe(res); if (!res.ok || (data && data.error)) { throw new Error(buildRequestError(res, data)); } - - const messages = - data && Array.isArray(data.messages) ? data.messages : []; - if (messages.length > 0) { - messages.forEach((msg) => - appendChatMessage("bot", String(msg || "")), - ); - } else if (data && data.reply) { - appendChatMessage("bot", String(data.reply)); - } else { - appendChatMessage("bot", t("runtime.empty")); + const jobId = data && data.job_id ? String(data.job_id) : ""; + if (!jobId) { + throw new Error("missing job_id"); } - runtimeState.chatHistoryLoaded = true; + ensureStreamingMessage(); + await attachChatJob(jobId, 0); } catch (error) { + runtimeState.chatBusy = false; + setButtonLoading(button, false); showToast( `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, "error", 5000, ); - } finally { - runtimeState.chatBusy = false; - setButtonLoading(button, false); } } @@ -1481,6 +1833,19 @@ const sendBtn = get("btnRuntimeChatSend"); if (sendBtn) sendBtn.addEventListener("click", sendChatMessage); + const clearChatBtn = get("btnRuntimeChatClear"); + if (clearChatBtn) + clearChatBtn.addEventListener("click", clearChatHistory); + + const chatLog = get("runtimeChatLog"); + if (chatLog) { + chatLog.addEventListener("scroll", () => { + if (chatLog.scrollTop <= 32) { + loadOlderChatHistory(); + } + }); + } + const imageBtn = get("btnRuntimeChatImage"); const imageInput = get("runtimeChatImageInput"); if (imageBtn && imageInput) { diff --git a/src/Undefined/webui/templates/index.html b/src/Undefined/webui/templates/index.html index 841ff956..94708e1f 100644 --- a/src/Undefined/webui/templates/index.html +++ b/src/Undefined/webui/templates/index.html @@ -763,9 +763,15 @@

智能对话

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

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

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

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

+
+ +
+
- +
", + 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 + attachments_block = css.split(".runtime-chat-attachments {", 1)[1].split( + ".runtime-chat-attachments[hidden]", + 1, + )[0] + assert "height: 54px;" in attachments_block + assert "overflow-y: auto;" in attachments_block + assert "flex-direction: column;" in attachments_block + 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 From d6574095f3037b646e4a7e8120f03d110f39212e Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 31 May 2026 12:43:50 +0800 Subject: [PATCH 38/77] fix(webui): animate chat attachment preview sizing Co-authored-by: GPT-5 Codex --- docs/webui-guide.md | 2 +- src/Undefined/webui/static/css/components.css | 134 ++++++++++++++++-- src/Undefined/webui/static/css/responsive.css | 19 ++- src/Undefined/webui/static/js/runtime.js | 62 ++++++++ tests/test_webui_runtime_chat_frontend.py | 63 +++++++- 5 files changed, 263 insertions(+), 17 deletions(-) diff --git a/docs/webui-guide.md b/docs/webui-guide.md index ba679bc0..19e81cb9 100644 --- a/docs/webui-guide.md +++ b/docs/webui-guide.md @@ -119,7 +119,7 @@ AI 的置顶备忘录(自我约束、待办事项等),支持完整 CRUD: WebUI 内置的对话界面,直接与 Bot 的 AI 进行交互: -- 支持文本、图片和文件消息。图片或文件可通过 `+` 选择,也可直接粘贴到输入框;粘贴只会加入待发送附件条,不会立即发送,点击发送或按 Enter 时才随同当前文本进入同一条 WebChat 消息。 +- 支持文本、图片和文件消息。图片或文件可通过 `+` 选择,也可直接粘贴到输入框;粘贴只会加入待发送附件条,不会立即发送,点击发送或按 Enter 时才随同当前文本进入同一条 WebChat 消息。无待发送附件时输入框会占满可用宽度;添加附件后右侧预览轨道随数量平滑展开,图片显示缩略图,附件较多时输入框保持最小可用宽度并压缩预览卡片,避免输入区跳动。 - AI 回复支持 Markdown 渲染,Markdown 内的常见 HTML 片段会经过 WebUI 白名单净化后自动渲染;完整 HTML 文档或独立块级 HTML 片段会先净化再直渲染,避免被 Markdown 缩进规则误判成代码块。脚本、事件属性、危险协议、危险样式以及 `head` / `style` 等文档元信息会被剥离。代码块使用本地随包的 highlight.js 做多语言语法高亮,不依赖外部 CDN;显式语言优先,未知语言会自动检测,库不可用时回退为安全转义文本。 - 默认加载最新 50 条消息并滚动到底部;向上滚动到顶部会按页懒加载更早历史,并保持滚动位置,避免一次性恢复大量工具块造成卡顿。“自动滚动到底部”开关默认开启并保存在浏览器本地,关闭后 AI 回复、工具 / Agent 状态和 AI 阶段刷新不会打断当前位置;首次加载和主动发送新消息仍会定位到底部。系统开启减少动态效果时,聊天滚动会改为即时跳转。 - 对话由 WebChat job 执行。刷新页面、关闭页面或网络短暂中断时,后端任务继续运行,前端会用 `job_id + seq` 每 0.5 秒轮询查询增量事件、当前阶段快照和运行中工具 / Agent 快照自动续接;如果刷新后首次查询运行中 job 失败,WebUI 会退避重试并在网络恢复时再次尝试。SSE 仅作为兼容方式保留,WebUI 不依赖长连接。 diff --git a/src/Undefined/webui/static/css/components.css b/src/Undefined/webui/static/css/components.css index e10f9065..4ffbe733 100644 --- a/src/Undefined/webui/static/css/components.css +++ b/src/Undefined/webui/static/css/components.css @@ -947,38 +947,114 @@ color: #fff; } .runtime-chat-input-row { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(156px, 240px) auto; + --chat-attachment-rail-width: 0px; + --chat-attachment-card-width: 132px; + --chat-attachment-gap: 8px; + display: flex; align-items: center; - gap: 8px; + gap: 0; + min-width: 0; +} +.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 { - align-self: stretch; + flex: 0 0 var(--chat-attachment-rail-width); display: flex; - flex-direction: column; + align-self: stretch; + flex-direction: row; gap: 6px; + width: var(--chat-attachment-rail-width); height: 54px; min-width: 0; - overflow-y: auto; + max-width: var(--chat-attachment-rail-width); + overflow-x: auto; + overflow-y: hidden; overscroll-behavior: contain; - scrollbar-gutter: stable; + 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; - visibility: hidden; + 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: 4px; } .runtime-chat-attachment-preview { display: inline-flex; @@ -990,6 +1066,10 @@ 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%; @@ -1009,6 +1089,9 @@ display: grid; min-width: 0; gap: 1px; + transition: + opacity 0.18s ease, + max-width 0.18s ease; } .runtime-chat-attachment-name { overflow: hidden; @@ -1036,10 +1119,45 @@ 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: 30px; + height: 30px; +} +.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: 1px; + right: 1px; + width: 16px; + height: 16px; + font-size: 12px; + background: color-mix(in srgb, var(--bg-card) 88%, transparent); + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.16); +} +@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; diff --git a/src/Undefined/webui/static/css/responsive.css b/src/Undefined/webui/static/css/responsive.css index e6d053a1..6c700fa1 100644 --- a/src/Undefined/webui/static/css/responsive.css +++ b/src/Undefined/webui/static/css/responsive.css @@ -293,16 +293,23 @@ white-space: nowrap; } .runtime-chat-input-row { - grid-template-columns: 1fr; - align-items: stretch; + --chat-attachment-gap: 6px; + align-items: center; + min-width: 0; + } + .runtime-chat-input-row > .runtime-chat-input { + min-width: 92px; } .runtime-chat-attachments { - height: 72px; - max-height: 72px; - overflow-y: auto; + flex-basis: min(var(--chat-attachment-rail-width), 36vw); + width: min(var(--chat-attachment-rail-width), 36vw); + max-width: min(var(--chat-attachment-rail-width), 36vw); } .runtime-chat-attachment { - max-width: 100%; + min-width: 34px; + } + .runtime-chat-input-row.is-attachment-rail-full .runtime-chat-attachment { + min-width: 30px; } .runtime-chat-actions { justify-content: flex-start; } .main-content.chat-layout #tab-chat .runtime-chat-log { diff --git a/src/Undefined/webui/static/js/runtime.js b/src/Undefined/webui/static/js/runtime.js index a65bda5a..7252537b 100644 --- a/src/Undefined/webui/static/js/runtime.js +++ b/src/Undefined/webui/static/js/runtime.js @@ -39,6 +39,14 @@ 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; function prefersReducedMotion() { return ( @@ -1800,12 +1808,66 @@ 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", + ); + 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 = diff --git a/tests/test_webui_runtime_chat_frontend.py b/tests/test_webui_runtime_chat_frontend.py index 8e81d210..5e00c3e9 100644 --- a/tests/test_webui_runtime_chat_frontend.py +++ b/tests/test_webui_runtime_chat_frontend.py @@ -513,6 +513,18 @@ def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: assert "URL.createObjectURL(file)" in source assert "URL.revokeObjectURL" in source assert "runtime-chat-attachment-thumb" 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 @@ -533,13 +545,60 @@ def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: 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] + responsive_attachments = ( + RESPONSIVE_CSS.read_text(encoding="utf-8") + .split( + ".runtime-chat-attachments", + 1, + )[1] + .split(".runtime-chat-attachment", 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 "overflow-y: auto;" in attachments_block - assert "flex-direction: column;" 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 "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: min(var(--chat-attachment-rail-width), 36vw);" in responsive_attachments + ) assert ".runtime-chat-attachment-thumb" in css assert ".runtime-chat-attachment-preview" in css assert ".runtime-chat-attachment-remove" in css From db1362c7c03fbd4614e1f81ff1b68ded81ab95bf Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 31 May 2026 13:00:49 +0800 Subject: [PATCH 39/77] feat(webchat): describe markdown html output context Co-authored-by: GPT-5 Codex --- res/prompts/undefined.xml | 8 ++++ res/prompts/undefined_nagaagent.xml | 8 ++++ src/Undefined/api/routes/chat.py | 1 + tests/test_runtime_api_chat_jobs.py | 49 +++++++++++++++++++++++++ tests/test_system_prompt_constraints.py | 16 ++++++++ 5 files changed, 82 insertions(+) diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 383d3c3d..deed8580 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -777,6 +777,14 @@ 如果确实需要用户补信息,直接问缺什么;如果不用补,就自然结束 + + WebUI Markdown 与 HTML 输出 + 当当前消息明确标注为【WebUI 会话】或 location="WebUI私聊" 时,用户正在 WebUI 中阅读回复。 + WebUI 支持完整 Markdown 渲染,也支持简单安全 HTML;可以用标题、列表、表格、引用、代码块、链接等 Markdown 语法组织内容。 + 复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放入 fenced code block,不要直接散落在正文里。 + 完整 HTML 页面优先使用 ```html 代码框输出,方便 WebUI 右上角运行按钮预览。 + + diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 6cc7cf00..bbab7042 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -825,6 +825,14 @@ 如果确实需要用户补信息,直接问缺什么;如果不用补,就自然结束 + + WebUI Markdown 与 HTML 输出 + 当当前消息明确标注为【WebUI 会话】或 location="WebUI私聊" 时,用户正在 WebUI 中阅读回复。 + WebUI 支持完整 Markdown 渲染,也支持简单安全 HTML;可以用标题、列表、表格、引用、代码块、链接等 Markdown 语法组织内容。 + 复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放入 fenced code block,不要直接散落在正文里。 + 完整 HTML 页面优先使用 ```html 代码框输出,方便 WebUI 右上角运行按钮预览。 + + diff --git a/src/Undefined/api/routes/chat.py b/src/Undefined/api/routes/chat.py index 7096e504..365e0df4 100644 --- a/src/Undefined/api/routes/chat.py +++ b/src/Undefined/api/routes/chat.py @@ -1575,6 +1575,7 @@ async def emit_stage(stage: str, detail: Any | None = None) -> None: 这是一条来自 WebUI 控制台的会话请求。 会话身份:虚拟用户 system(42)。 权限等级:superadmin(你可按最高管理权限处理)。 +WebUI 支持完整 Markdown 渲染和简单安全 HTML。复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放进 fenced code block;完整 HTML 页面请优先使用 ```html 代码框,方便 WebUI 的运行按钮预览。 请正常进行私聊对话;如果需要结束会话,调用 end 工具。""" virtual_sender = _WebUIVirtualSender( _VIRTUAL_USER_ID, send_output, onebot=ctx.onebot diff --git a/tests/test_runtime_api_chat_jobs.py b/tests/test_runtime_api_chat_jobs.py index e1853906..f053c4c8 100644 --- a/tests/test_runtime_api_chat_jobs.py +++ b/tests/test_runtime_api_chat_jobs.py @@ -109,6 +109,55 @@ def _decode_sse(writes: list[bytes]) -> list[dict[str, Any]]: 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 + + @pytest.mark.asyncio async def test_chat_job_events_after_reconnect_and_disconnect_does_not_cancel( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index f81b9393..df6e603d 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -39,6 +39,22 @@ def test_naga_prompt_requires_scope_before_naga_analysis() -> None: ) +@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 支持完整 Markdown 渲染", + "简单安全 HTML", + "复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放入 fenced code block", + "完整 HTML 页面优先使用 ```html 代码框输出", + ] + for snippet in required_snippets: + assert snippet in text + + @pytest.mark.parametrize("path", PROMPT_PATHS) def test_system_prompts_define_persona_nicknames_and_ownership_bounds( path: Path, From 5996c109e8b995e32b45e97cb744a2df4eadb1f9 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 31 May 2026 13:03:30 +0800 Subject: [PATCH 40/77] fix(webui): improve chat attachment previews Co-authored-by: GPT-5 Codex --- src/Undefined/webui/static/css/components.css | 37 ++++++++++++++----- src/Undefined/webui/static/js/runtime.js | 4 +- tests/test_webui_runtime_chat_frontend.py | 19 ++++++++++ 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/Undefined/webui/static/css/components.css b/src/Undefined/webui/static/css/components.css index 4ffbe733..2857ffe5 100644 --- a/src/Undefined/webui/static/css/components.css +++ b/src/Undefined/webui/static/css/components.css @@ -1054,7 +1054,7 @@ grid-template-columns: minmax(24px, 1fr); justify-items: center; gap: 0; - padding: 4px; + padding: 3px; } .runtime-chat-attachment-preview { display: inline-flex; @@ -1077,6 +1077,12 @@ 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; @@ -1130,8 +1136,9 @@ transform: scale(1.04); } .runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-preview { - width: 30px; - height: 30px; + width: 100%; + height: 38px; + border-radius: calc(var(--radius-sm) - 1px); } .runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-main { width: 0; @@ -1141,13 +1148,23 @@ } .runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-remove { position: absolute; - top: 1px; - right: 1px; - width: 16px; - height: 16px; - font-size: 12px; - background: color-mix(in srgb, var(--bg-card) 88%, transparent); - box-shadow: 0 1px 4px rgba(15, 23, 42, 0.16); + 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 { diff --git a/src/Undefined/webui/static/js/runtime.js b/src/Undefined/webui/static/js/runtime.js index 7252537b..89392ab9 100644 --- a/src/Undefined/webui/static/js/runtime.js +++ b/src/Undefined/webui/static/js/runtime.js @@ -1875,8 +1875,8 @@ ? t("runtime.attachment_kind_image") : t("runtime.attachment_kind_file"); const preview = item.previewUrl - ? `` - : ``; + ? `` + : ``; return ( `
` + `${preview}` + diff --git a/tests/test_webui_runtime_chat_frontend.py b/tests/test_webui_runtime_chat_frontend.py index 5e00c3e9..aecff2d4 100644 --- a/tests/test_webui_runtime_chat_frontend.py +++ b/tests/test_webui_runtime_chat_frontend.py @@ -513,6 +513,8 @@ def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: 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 @@ -565,6 +567,17 @@ def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: ".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 = ( RESPONSIVE_CSS.read_text(encoding="utf-8") .split( @@ -596,6 +609,12 @@ def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: 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 ( "width: min(var(--chat-attachment-rail-width), 36vw);" in responsive_attachments ) From 97038cfae2eeff90c2995c95a0bf30b21c3f805e Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 31 May 2026 13:08:08 +0800 Subject: [PATCH 41/77] feat(webui): add chat code block actions Co-authored-by: GPT-5 Codex --- src/Undefined/webui/static/css/components.css | 71 ++++++++++- src/Undefined/webui/static/js/i18n.js | 8 ++ src/Undefined/webui/static/js/runtime.js | 114 +++++++++++++++++- tests/test_webui_runtime_chat_frontend.py | 16 +++ 4 files changed, 205 insertions(+), 4 deletions(-) diff --git a/src/Undefined/webui/static/css/components.css b/src/Undefined/webui/static/css/components.css index 2857ffe5..efd4f300 100644 --- a/src/Undefined/webui/static/css/components.css +++ b/src/Undefined/webui/static/css/components.css @@ -749,12 +749,79 @@ color: var(--accent); text-decoration: underline; } -.runtime-chat-content.markdown pre { +.runtime-code-block { margin: 0.5em 0; - padding: 10px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background: color-mix(in srgb, var(--bg-app) 88%, var(--bg-deep)); + overflow: hidden; +} +.runtime-code-toolbar { + 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-chat-content.markdown pre { + margin: 0; + padding: 10px 12px; + border: 0; + border-radius: 0; + background: transparent; overflow-x: auto; } .runtime-chat-content.markdown pre code { diff --git a/src/Undefined/webui/static/js/i18n.js b/src/Undefined/webui/static/js/i18n.js index 76f33b4e..decef2ae 100644 --- a/src/Undefined/webui/static/js/i18n.js +++ b/src/Undefined/webui/static/js/i18n.js @@ -312,6 +312,10 @@ const I18N = { "runtime.attachment_kind_image": "图片", "runtime.attachment_kind_file": "文件", "runtime.remove_attachment": "移除附件", + "runtime.copy_code": "复制", + "runtime.run_html": "运行", + "runtime.code_copied": "已复制代码", + "runtime.copy_failed": "复制失败", "runtime.download": "下载", "runtime.send": "发送", "runtime.total": "共 {count} 条", @@ -680,6 +684,10 @@ const I18N = { "runtime.attachment_kind_image": "Image", "runtime.attachment_kind_file": "File", "runtime.remove_attachment": "Remove attachment", + "runtime.copy_code": "Copy", + "runtime.run_html": "Run", + "runtime.code_copied": "Code copied", + "runtime.copy_failed": "Copy failed", "runtime.download": "Download", "runtime.send": "Send", "runtime.total": "{count} items", diff --git a/src/Undefined/webui/static/js/runtime.js b/src/Undefined/webui/static/js/runtime.js index 89392ab9..aafc3198 100644 --- a/src/Undefined/webui/static/js/runtime.js +++ b/src/Undefined/webui/static/js/runtime.js @@ -2237,6 +2237,23 @@ } } + 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 createSafeMarkedRenderer() { if (typeof marked === "undefined" || !marked.Renderer) return null; const renderer = new marked.Renderer(); @@ -2251,15 +2268,28 @@ ? token.lang : legacyLanguage; const normalizedLanguage = normalizeCodeLanguage(language); + const encodedCode = encodeURIComponent(codeText); + const canRunHtml = isRunnableHtmlCode(codeText, normalizedLanguage); const languageClass = normalizedLanguage && normalizedLanguage !== "text" ? ` language-${escapeHtml(normalizedLanguage)}` : ""; return ( - `
` +
+                `
` + + `
` + + `${escapeHtml(codeBlockLanguageLabel(normalizedLanguage))}` + + `` + + `` + + (canRunHtml + ? `` + : "") + + `` + + `
` + + `
` +
                 `` +
                 `${highlightCodeBlock(codeText, normalizedLanguage)}` +
-                `
` + `
` + + `
` ); }; renderer.link = ({ href, title, tokens }) => { @@ -2361,6 +2391,71 @@ return html || escapeHtml(text); } + 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 readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -3566,6 +3661,21 @@ loadOlderChatHistory(); } }); + chatLog.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const copyButton = target.closest("[data-code-copy]"); + if (copyButton) { + const block = copyButton.closest(".runtime-code-block"); + if (block) copyCodeBlock(block); + return; + } + const runButton = target.closest("[data-code-run-html]"); + if (runButton) { + const block = runButton.closest(".runtime-code-block"); + if (block) runHtmlCodeBlock(block); + } + }); } const attachBtn = get("btnRuntimeChatImage"); diff --git a/tests/test_webui_runtime_chat_frontend.py b/tests/test_webui_runtime_chat_frontend.py index aecff2d4..6cf4da1e 100644 --- a/tests/test_webui_runtime_chat_frontend.py +++ b/tests/test_webui_runtime_chat_frontend.py @@ -425,14 +425,30 @@ def test_webchat_frontend_highlights_markdown_code_blocks() -> None: 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 "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 "highlightCodeBlock(codeText, normalizedLanguage)" in source assert "language-${escapeHtml(normalizedLanguage)}" in source + assert 'runtime.copy_code": "复制"' in I18N_JS.read_text(encoding="utf-8") + assert 'runtime.run_html": "运行"' in I18N_JS.read_text(encoding="utf-8") 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 ".runtime-code-language" in css + assert ".runtime-code-action" in css + assert ".runtime-code-action.primary" in 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 From 7a2f51aff0e29c49b3f6f95f3e6941e1b4a85cfe Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sun, 31 May 2026 13:17:08 +0800 Subject: [PATCH 42/77] feat(webui): add chat html runner Co-authored-by: GPT-5 Codex --- src/Undefined/webui/static/css/components.css | 97 +++++++++++- src/Undefined/webui/static/css/responsive.css | 26 ++++ src/Undefined/webui/static/js/i18n.js | 12 ++ src/Undefined/webui/static/js/runtime.js | 146 ++++++++++++++++++ src/Undefined/webui/templates/index.html | 15 ++ tests/test_webui_runtime_chat_frontend.py | 45 ++++++ 6 files changed, 340 insertions(+), 1 deletion(-) diff --git a/src/Undefined/webui/static/css/components.css b/src/Undefined/webui/static/css/components.css index efd4f300..2e23559a 100644 --- a/src/Undefined/webui/static/css/components.css +++ b/src/Undefined/webui/static/css/components.css @@ -1541,6 +1541,90 @@ .runtime-tool-message > *:last-child { margin-bottom: 0; } +.runtime-html-runner { + position: fixed; + right: clamp(16px, 3vw, 36px); + bottom: calc(16px + env(safe-area-inset-bottom)); + z-index: 118; + width: min(760px, calc(100vw - 32px)); + pointer-events: none; +} +.runtime-html-runner[hidden] { + display: none; +} +.runtime-html-runner-panel { + display: grid; + grid-template-rows: auto minmax(220px, 46vh); + 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); + pointer-events: auto; + animation: runtime-html-runner-in 0.2s ease-out; +} +.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)); +} +.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.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; @@ -1561,6 +1645,16 @@ 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-cursor { 0%, 45% { @@ -1576,7 +1670,8 @@ .runtime-chat-item.streaming .runtime-chat-content::after, .runtime-tool-preview, .runtime-tool-children, - .runtime-tool-message { + .runtime-tool-message, + .runtime-html-runner-panel { animation: none; } .runtime-tool-block, diff --git a/src/Undefined/webui/static/css/responsive.css b/src/Undefined/webui/static/css/responsive.css index 6c700fa1..d97cdae0 100644 --- a/src/Undefined/webui/static/css/responsive.css +++ b/src/Undefined/webui/static/css/responsive.css @@ -359,6 +359,32 @@ padding-left: 8px; padding-right: 6px; } + .runtime-html-runner { + right: 12px; + bottom: calc(12px + env(safe-area-inset-bottom)); + width: calc(100vw - 24px); + } + .runtime-html-runner-panel { + grid-template-rows: auto minmax(220px, 52vh); + border-radius: var(--radius-sm); + } + .runtime-html-runner-toolbar { + align-items: flex-start; + padding: 8px 9px 8px 11px; + } + .runtime-html-runner-title { + display: grid; + gap: 3px; + } + .runtime-html-runner-meta { + max-width: 36vw; + } + .runtime-html-runner-actions { + gap: 4px; + } + .runtime-html-runner-btn { + padding-inline: 8px; + } } @media (max-width: 480px) { diff --git a/src/Undefined/webui/static/js/i18n.js b/src/Undefined/webui/static/js/i18n.js index decef2ae..3c21014b 100644 --- a/src/Undefined/webui/static/js/i18n.js +++ b/src/Undefined/webui/static/js/i18n.js @@ -316,6 +316,12 @@ const I18N = { "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.close": "关闭", "runtime.download": "下载", "runtime.send": "发送", "runtime.total": "共 {count} 条", @@ -688,6 +694,12 @@ const I18N = { "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": "Pick an element in the preview", + "runtime.close": "Close", "runtime.download": "Download", "runtime.send": "Send", "runtime.total": "{count} items", diff --git a/src/Undefined/webui/static/js/runtime.js b/src/Undefined/webui/static/js/runtime.js index aafc3198..bb6957fb 100644 --- a/src/Undefined/webui/static/js/runtime.js +++ b/src/Undefined/webui/static/js/runtime.js @@ -24,6 +24,8 @@ toolCollapseTimers: new Map(), chatAttachments: [], chatAttachmentSeq: 0, + htmlRunnerSource: "", + htmlRunnerPickMode: false, probeTimer: null, queryBusy: { memory: false, @@ -2456,6 +2458,132 @@ showToast(t("runtime.run_html"), "info", 1200); } + function buildHtmlRunnerDocument(source) { + const raw = String(source || "").trim(); + if (!raw) return ""; + if (/^` + + `` + + `${raw}` + ); + } + + function htmlRunnerPickerScript() { + return ` - + + @@ -781,6 +781,7 @@

class="runtime-chat-sidebar-tab" type="button" aria-expanded="false" + aria-controls="runtimeChatConversationDrawerPanel" data-i18n-aria-label="runtime.chat_conversations" aria-label="对话" > @@ -833,7 +834,7 @@

- +
diff --git a/tests/test_ai_coordinator_queue_routing.py b/tests/test_ai_coordinator_queue_routing.py index 2f72a3c4..e09e4590 100644 --- a/tests/test_ai_coordinator_queue_routing.py +++ b/tests/test_ai_coordinator_queue_routing.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock @@ -229,11 +230,16 @@ async def test_execute_auto_reply_send_msg_cb_passes_history_message( captured_resources: dict[str, Any] = {} async def _fake_ask(*_args: Any, **kwargs: Any) -> str: - captured_extra_context.update(kwargs.get("extra_context", {})) + 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()) - await kwargs["send_message_callback"]("hello group") + 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) diff --git a/tests/test_cognitive_vector_store_metadata.py b/tests/test_cognitive_vector_store_metadata.py index 5518a138..3edc0a35 100644 --- a/tests/test_cognitive_vector_store_metadata.py +++ b/tests/test_cognitive_vector_store_metadata.py @@ -90,20 +90,24 @@ def query(self, **_kwargs: object) -> dict[str, list[list[object]]]: } store = CognitiveVectorStore.__new__(CognitiveVectorStore) - store._chroma_scheduler = ChromaOperationScheduler() + 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_file_analysis_attachment_uid.py b/tests/test_file_analysis_attachment_uid.py index a4645094..445851d8 100644 --- a/tests/test_file_analysis_attachment_uid.py +++ b/tests/test_file_analysis_attachment_uid.py @@ -1,13 +1,29 @@ 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.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, + } @pytest.mark.asyncio @@ -28,11 +44,7 @@ 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) @@ -60,8 +72,6 @@ async def test_download_file_redownloads_url_backed_attachment_uid( ) async def _fake_ensure_local_file(record: object) -> object: - cached = tmp_path / "downloaded.txt" - cached.write_bytes(b"https://example.com/demo.txt") return type( "AttachmentLike", (), @@ -70,19 +80,35 @@ async def _fake_ensure_local_file(record: object) -> object: "kind": getattr(record, "kind"), "media_type": getattr(record, "media_type"), "display_name": getattr(record, "display_name"), - "local_path": str(cached), + "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, + ) -> str: + _ = max_size_mb, task_uuid + 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", + _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) @@ -90,6 +116,7 @@ async def _fake_ensure_local_file(record: object) -> object: 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 @@ -111,11 +138,7 @@ async def test_download_file_uses_random_name_for_unsafe_attachment_name( 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) 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 482c2a7c..67836230 100644 --- a/tests/test_runtime_api_chat_history.py +++ b/tests/test_runtime_api_chat_history.py @@ -5,7 +5,6 @@ from pathlib import Path from types import SimpleNamespace from typing import Any, cast -from unittest.mock import AsyncMock import pytest from aiohttp import web @@ -431,7 +430,11 @@ async def test_runtime_chat_history_clear_clears_only_when_no_active_job() -> No assert payload["success"] is True assert payload["cleared"] == 2 - assert history.records + 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 @@ -463,10 +466,7 @@ async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str ), command_dispatcher=SimpleNamespace(), queue_manager=SimpleNamespace(snapshot=lambda: {}), - history_manager=SimpleNamespace( - add_private_message=AsyncMock(), - clear_private_history=history.clear_private_history, - ), + 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) @@ -507,10 +507,7 @@ async def test_runtime_chat_history_clear_returns_409_until_history_finalized() ), command_dispatcher=SimpleNamespace(), queue_manager=SimpleNamespace(snapshot=lambda: {}), - history_manager=SimpleNamespace( - add_private_message=AsyncMock(), - clear_private_history=history.clear_private_history, - ), + history_manager=history, ) manager = runtime_api_chat.ChatJobManager(context) job = runtime_api_chat.ChatJob( @@ -544,4 +541,10 @@ async def test_runtime_chat_history_clear_returns_409_until_history_finalized() payload = json.loads(response.text or "{}") assert response.status == 200 - assert payload["cleared"] == 0 + 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_send_message_tool.py b/tests/test_send_message_tool.py index aac1fa3b..01bf901f 100644 --- a/tests/test_send_message_tool.py +++ b/tests/test_send_message_tool.py @@ -11,6 +11,7 @@ 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: @@ -20,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 @@ -28,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( { @@ -62,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( { @@ -87,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( { @@ -114,14 +119,14 @@ async def test_send_message_private_callback_passes_reply_to() -> None: @pytest.mark.asyncio async def test_send_message_marks_request_context_when_context_is_copied() -> None: send_message_callback = AsyncMock() - context: dict[str, Any] = { - "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, - } + 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", @@ -142,15 +147,15 @@ async def test_send_message_does_not_implicitly_use_trigger_message_id() -> None 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( { @@ -174,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( { @@ -210,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( { @@ -263,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( { @@ -334,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 ab181cfc..7e67bb44 100644 --- a/tests/test_send_private_message_tool.py +++ b/tests/test_send_private_message_tool.py @@ -9,6 +9,7 @@ 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: @@ -17,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( { @@ -47,12 +52,12 @@ async def test_send_private_message_marks_request_context_when_context_is_copied None ): send_private_message_callback = AsyncMock() - context: dict[str, Any] = { - "user_id": 12345, - "request_id": "req-private-context", - "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-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)) @@ -68,12 +73,12 @@ async def test_send_private_message_returns_sent_message_id_when_available() -> 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_webchat_conversations.py b/tests/test_webchat_conversations.py index 5abf460c..5de8052e 100644 --- a/tests/test_webchat_conversations.py +++ b/tests/test_webchat_conversations.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -48,7 +49,11 @@ def _context(history: Any | None = None) -> RuntimeAPIContext: superadmin_qq=10001, bot_qq=20002, ), - onebot=SimpleNamespace(connection_status=lambda: {}), + 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), @@ -59,22 +64,81 @@ def _context(history: Any | None = None) -> RuntimeAPIContext: ) -def test_webchat_runtime_has_detailed_flow_logs() -> None: - route_source = Path("src/Undefined/api/routes/chat.py").read_text(encoding="utf-8") - store_source = Path("src/Undefined/api/webchat_store.py").read_text( - encoding="utf-8" +@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) - assert "[RuntimeAPI][WebChat] 创建 job" in route_source - assert "[RuntimeAPI][WebChat] job 开始" in route_source - assert "[RuntimeAPI][WebChat] 输入附件注册完成" in route_source - assert "[RuntimeAPI][WebChat] 调用 AI" in route_source - assert "[RuntimeAPI][WebChat] job 历史落盘" in route_source - assert "[RuntimeAPI][WebChat] 查询 job 事件" in route_source - assert "[RuntimeAPI][WebChat] 调度标题生成" in route_source - assert "[WebChat] 追加消息" in store_source - assert "[WebChat] 会话存储加载完成" in store_source - assert "[WebChat] 生成会话标题" in store_source + 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 diff --git a/tests/test_webui_runtime_chat_frontend.py b/tests/test_webui_runtime_chat_frontend.py index 878dc22e..7bbba15c 100644 --- a/tests/test_webui_runtime_chat_frontend.py +++ b/tests/test_webui_runtime_chat_frontend.py @@ -1,22 +1,32 @@ from __future__ import annotations +import asyncio from pathlib import Path +from typing import Final +from Undefined.utils import io as async_io -RUNTIME_JS = Path("src/Undefined/webui/static/js/runtime.js") -RUNTIME_CSS = Path("src/Undefined/webui/static/css/components.css") -WEBUI_TEMPLATE = Path("src/Undefined/webui/templates/index.html") -MAIN_JS = Path("src/Undefined/webui/static/js/main.js") -API_JS = Path("src/Undefined/webui/static/js/api.js") -APP_CSS = Path("src/Undefined/webui/static/css/app.css") -RESPONSIVE_CSS = Path("src/Undefined/webui/static/css/responsive.css") -I18N_JS = Path("src/Undefined/webui/static/js/i18n.js") -WEBUI_APP_PY = Path("src/Undefined/webui/app.py") -TAURI_CONF = Path("apps/undefined-console/src-tauri/tauri.conf.json") + +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 = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) assert "activeChatMessageId" in source assert 'if (event === "message")' in source @@ -29,17 +39,21 @@ def test_webchat_frontend_reuses_job_message_for_final_message() -> None: def test_webchat_html_preview_csp_allows_inline_scripts_without_eval() -> None: - webui_app = WEBUI_APP_PY.read_text(encoding="utf-8") - tauri_conf = TAURI_CONF.read_text(encoding="utf-8") - - assert "\"script-src 'self' 'unsafe-inline'; \"" in webui_app - assert "script-src 'self' 'unsafe-inline';" in tauri_conf + 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 = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) assert 'event === "token_delta"' not in source assert 'event === "tool_delta"' not in source @@ -59,9 +73,9 @@ def test_webchat_frontend_handles_tool_lifecycle_and_webchat_hints() -> None: def test_webchat_frontend_renders_live_stage_after_ai_label() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") - i18n = I18N_JS.read_text(encoding="utf-8") + 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 @@ -81,11 +95,11 @@ def test_webchat_frontend_renders_live_stage_after_ai_label() -> None: def test_webchat_frontend_has_conversation_sidebar() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - template = WEBUI_TEMPLATE.read_text(encoding="utf-8") - app_css = APP_CSS.read_text(encoding="utf-8") - responsive_css = RESPONSIVE_CSS.read_text(encoding="utf-8") - i18n = I18N_JS.read_text(encoding="utf-8") + 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 @@ -143,11 +157,11 @@ def test_webchat_frontend_has_conversation_sidebar() -> None: def test_webchat_frontend_has_slash_command_palette() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - template = WEBUI_TEMPLATE.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") - responsive_css = RESPONSIVE_CSS.read_text(encoding="utf-8") - i18n = I18N_JS.read_text(encoding="utf-8") + 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( @@ -225,7 +239,7 @@ def test_webchat_frontend_has_slash_command_palette() -> None: def test_webchat_frontend_sends_conversation_id_with_history_and_jobs() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) assert "currentChatConversationId" in source assert 'chatUrl("/api/runtime/chat/history"' in source @@ -241,7 +255,7 @@ def test_webchat_frontend_sends_conversation_id_with_history_and_jobs() -> None: def test_webchat_frontend_resumes_backend_job_after_refresh_or_reconnect() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) history_helper = source.split("async function loadChatHistory", 1)[1].split( "async function loadOlderChatHistory", 1, @@ -274,7 +288,7 @@ def test_webchat_frontend_resumes_backend_job_after_refresh_or_reconnect() -> No def test_webchat_frontend_lazy_load_preserves_scroll_offset() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) older_helper = source.split("async function loadOlderChatHistory", 1)[1].split( "function applyChatEvent", 1, @@ -291,7 +305,7 @@ def test_webchat_frontend_lazy_load_preserves_scroll_offset() -> None: def test_webchat_frontend_keeps_final_duration_after_done() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) done_branch = source.split('if (event === "done")', 1)[1].split( 'if (event === "error")', 1 @@ -312,7 +326,7 @@ def test_webchat_frontend_keeps_final_duration_after_done() -> None: def test_webchat_frontend_restores_history_tool_blocks_without_stream_state() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) assert "function appendHistoryChatItem" in source assert "function renderHistoryTimeline" in source @@ -336,7 +350,7 @@ def test_webchat_frontend_restores_history_tool_blocks_without_stream_state() -> def test_webchat_frontend_renders_chat_as_event_timeline() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) message_branch = source.split('if (event === "message")', 1)[1].split( 'if (event === "done")', 1 )[0] @@ -355,7 +369,7 @@ def test_webchat_frontend_renders_chat_as_event_timeline() -> None: in timeline_helper ) assert "topLevelToolKey(blocks, parentKey)" in timeline_helper - assert "runtime-tool-children" in RUNTIME_CSS.read_text(encoding="utf-8") + 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 @@ -363,7 +377,7 @@ def test_webchat_frontend_renders_chat_as_event_timeline() -> None: def test_webchat_frontend_prefers_backend_history_timeline() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) history_timeline_branch = source.split("if (timelineItems.length)", 1)[1].split( "if (calls.length)", 1 )[0] @@ -375,8 +389,8 @@ def test_webchat_frontend_prefers_backend_history_timeline() -> None: def test_webchat_frontend_renders_nested_tool_timeline() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) assert "function renderToolTimelineItem" in source assert "function appendNestedTimelineMessage" in source @@ -396,7 +410,7 @@ def test_webchat_frontend_renders_nested_tool_timeline() -> None: def test_webchat_tool_snapshots_do_not_rerender_unchanged_blocks() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) live_update_helper = source.split("function upsertTimelineToolBlock", 1)[1].split( "function appendNestedTimelineMessage", 1 )[0] @@ -423,8 +437,8 @@ def test_webchat_tool_snapshots_do_not_rerender_unchanged_blocks() -> None: def test_webchat_frontend_updates_agent_stage_summary_without_timeline_noise() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) assert 'event === "agent_stage"' in source assert "function upsertAgentStageBlock" in source @@ -449,7 +463,7 @@ def test_webchat_frontend_updates_agent_stage_summary_without_timeline_noise() - def test_webchat_frontend_polls_job_events_incrementally() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) assert "function pollChatJob" in source assert "CHAT_POLL_INTERVAL_MS = 500" in source @@ -467,7 +481,7 @@ def test_webchat_frontend_polls_job_events_incrementally() -> None: def test_webchat_frontend_retries_active_job_resume_after_refresh_failure() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) resume_helper = source.split("async function resumeActiveChatJob", 1)[1].split( "async function clearChatHistory", 1 )[0] @@ -481,8 +495,8 @@ def test_webchat_frontend_retries_active_job_resume_after_refresh_failure() -> N def test_webchat_tool_summary_uses_compact_single_line_order() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) render_helper = source.split("function renderToolBlock", 1)[1].split( "function renderToolTimelineItem", 1 )[0] @@ -516,7 +530,7 @@ def test_webchat_tool_summary_uses_compact_single_line_order() -> None: def test_webchat_tool_blocks_auto_collapse_after_minimum_visible_time() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + 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 @@ -547,11 +561,9 @@ def test_webchat_tool_blocks_auto_collapse_after_minimum_visible_time() -> None: def test_webchat_auto_scroll_toggle_controls_stream_scroll() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - template = Path("src/Undefined/webui/templates/index.html").read_text( - encoding="utf-8" - ) - css = RUNTIME_CSS.read_text(encoding="utf-8") + 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 @@ -568,7 +580,7 @@ def test_webchat_auto_scroll_toggle_controls_stream_scroll() -> None: def test_webchat_tab_activation_forces_bottom_scroll_after_history_load() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) load_helper = source.split("async function loadChatHistory", 1)[1].split( "async function loadOlderChatHistory", 1 )[0] @@ -592,7 +604,7 @@ def test_webchat_tab_activation_forces_bottom_scroll_after_history_load() -> Non def test_webchat_frontend_renders_tool_duration() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) assert "block.durationMs" in source assert "payload.duration_ms" in source @@ -608,9 +620,9 @@ def test_webchat_frontend_renders_tool_duration() -> None: def test_webchat_tool_previews_render_structured_input_output() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") - i18n = I18N_JS.read_text(encoding="utf-8") + 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 @@ -635,7 +647,7 @@ def test_webchat_tool_previews_render_structured_input_output() -> None: def test_webchat_frontend_sanitizes_markdown_html_and_unsafe_links() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) render_helper = source.split("function createSafeMarkedRenderer", 1)[1].split( "function renderChatContent", 1 )[0] @@ -660,11 +672,11 @@ def test_webchat_frontend_sanitizes_markdown_html_and_unsafe_links() -> None: def test_webchat_frontend_has_clickable_image_viewer() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - template = WEBUI_TEMPLATE.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") - responsive_css = RESPONSIVE_CSS.read_text(encoding="utf-8") - i18n = I18N_JS.read_text(encoding="utf-8") + 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 @@ -688,8 +700,8 @@ def test_webchat_frontend_has_clickable_image_viewer() -> None: def test_webchat_markdown_quotes_render_as_collapsible_scroll_blocks() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) renderer_helper = source.split("function createSafeMarkedRenderer", 1)[1].split( "renderer.link", 1, @@ -724,7 +736,7 @@ def test_webchat_markdown_quotes_render_as_collapsible_scroll_blocks() -> None: def test_webchat_frontend_renders_standalone_html_without_markdown_code_blocks() -> ( None ): - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) render_helper = source.split("function renderChatContent", 1)[1].split( "function readFileAsDataUrl", 1 )[0] @@ -742,9 +754,9 @@ def test_webchat_frontend_renders_standalone_html_without_markdown_code_blocks() def test_webchat_frontend_highlights_markdown_code_blocks() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") - template = WEBUI_TEMPLATE.read_text(encoding="utf-8") + 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 @@ -772,10 +784,10 @@ def test_webchat_frontend_highlights_markdown_code_blocks() -> None: 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 I18N_JS.read_text(encoding="utf-8") - assert 'runtime.run_html": "运行"' in I18N_JS.read_text(encoding="utf-8") - assert 'runtime.expand_code": "展开"' in I18N_JS.read_text(encoding="utf-8") - assert 'runtime.collapse_code": "折叠"' in I18N_JS.read_text(encoding="utf-8") + 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() @@ -844,15 +856,17 @@ def test_webchat_frontend_highlights_markdown_code_blocks() -> None: def test_webchat_html_runner_runs_code_in_sandboxed_preview() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") - responsive_css = RESPONSIVE_CSS.read_text(encoding="utf-8") - template = WEBUI_TEMPLATE.read_text(encoding="utf-8") - i18n = I18N_JS.read_text(encoding="utf-8") + 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 allow-forms allow-modals"' 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 @@ -871,7 +885,7 @@ def test_webchat_html_runner_runs_code_in_sandboxed_preview() -> None: ) assert "function buildHtmlRunnerDocument" in source assert "function htmlRunnerPickerScript" in source - assert "function injectHtmlRunnerPicker" in source + assert "function injectHtmlRunnerSecurity" in source assert "function syncHtmlRunnerPickModeToFrame" in source assert "function setHtmlRunnerPickMode" in source assert "function clampHtmlRunnerPosition" in source @@ -903,7 +917,7 @@ def test_webchat_html_runner_runs_code_in_sandboxed_preview() -> None: assert "return;\n }\n const target = locked;" in source assert "clearHtmlRunnerInteraction()" in source assert "ensureHtmlRunnerInitialRect(runner)" in source - assert "frame.srcdoc = injectHtmlRunnerPicker(html)" in source + assert "frame.srcdoc = injectHtmlRunnerSecurity(html)" in source assert ( "sanitizeHtmlSnippet" not in source.split( @@ -1024,11 +1038,11 @@ def test_webchat_html_runner_runs_code_in_sandboxed_preview() -> None: def test_webchat_references_are_prepended_as_markdown_quotes() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") - responsive_css = RESPONSIVE_CSS.read_text(encoding="utf-8") - template = WEBUI_TEMPLATE.read_text(encoding="utf-8") - i18n = I18N_JS.read_text(encoding="utf-8") + 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 @@ -1083,7 +1097,7 @@ def test_webchat_references_are_prepended_as_markdown_quotes() -> None: def test_webchat_tool_status_colors_drive_left_bar_and_status_text() -> None: - css = RUNTIME_CSS.read_text(encoding="utf-8") + css = _read_source(RUNTIME_CSS) running_block = css.split(".runtime-tool-block.running {", 1)[1].split( ".runtime-tool-block.done", 1 )[0] @@ -1112,7 +1126,7 @@ def test_webchat_tool_status_colors_drive_left_bar_and_status_text() -> None: def test_webchat_send_scrolls_to_bottom_after_layout_updates() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") + source = _read_source(RUNTIME_JS) force_helper = source.split("function forceScrollChatToBottomSoon", 1)[1].split( "function scrollChatToBottomSoon", 1 )[0] @@ -1137,11 +1151,11 @@ def test_webchat_send_scrolls_to_bottom_after_layout_updates() -> None: def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: - source = RUNTIME_JS.read_text(encoding="utf-8") - css = RUNTIME_CSS.read_text(encoding="utf-8") - template = WEBUI_TEMPLATE.read_text(encoding="utf-8") - i18n = I18N_JS.read_text(encoding="utf-8") - api_source = API_JS.read_text(encoding="utf-8") + 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 @@ -1218,7 +1232,7 @@ def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: 1, )[1].split("@keyframes runtime-chat-attachment-in", 1)[0] responsive_attachments = ( - RESPONSIVE_CSS.read_text(encoding="utf-8") + _read_source(RESPONSIVE_CSS) .split( ".runtime-chat-attachments", 1, @@ -1226,7 +1240,7 @@ def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: .split(".runtime-chat-attachment", 1)[0] ) mobile_input_row_block = ( - RESPONSIVE_CSS.read_text(encoding="utf-8") + _read_source(RESPONSIVE_CSS) .split( ".runtime-chat-input-row", 1, @@ -1282,12 +1296,10 @@ def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: def test_webchat_layout_keeps_input_at_bottom_and_log_scrollable() -> None: - app_css = APP_CSS.read_text(encoding="utf-8") - responsive_css = RESPONSIVE_CSS.read_text(encoding="utf-8") - main_js = MAIN_JS.read_text(encoding="utf-8") - template = Path("src/Undefined/webui/templates/index.html").read_text( - encoding="utf-8" - ) + 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 @@ -1346,8 +1358,8 @@ def test_webchat_layout_keeps_input_at_bottom_and_log_scrollable() -> None: def test_webchat_mobile_tool_rows_have_overflow_guards() -> None: - css = RUNTIME_CSS.read_text(encoding="utf-8") - responsive_css = RESPONSIVE_CSS.read_text(encoding="utf-8") + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) status_css = css.split(".runtime-tool-block summary .runtime-tool-status", 1)[ 1 @@ -1369,8 +1381,8 @@ def test_webchat_mobile_tool_rows_have_overflow_guards() -> None: def test_webchat_content_wraps_long_code_and_markdown_without_horizontal_scroll() -> ( None ): - css = RUNTIME_CSS.read_text(encoding="utf-8") - responsive_css = RESPONSIVE_CSS.read_text(encoding="utf-8") + 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", From 485bf21b06d8def0651ac2e9d253d7f4f55cd9e4 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 6 Jun 2026 17:03:38 +0800 Subject: [PATCH 74/77] fix(webchat): use atomic download writes Co-authored-by: GPT-5 Codex --- src/Undefined/ai/client/ask_loop.py | 2 + src/Undefined/ai/tooling.py | 2 + .../services/coordinator/batching.py | 6 +- .../tools/download_file/handler.py | 85 ++++++++++++++----- tests/test_file_analysis_attachment_uid.py | 5 +- 5 files changed, 71 insertions(+), 29 deletions(-) diff --git a/src/Undefined/ai/client/ask_loop.py b/src/Undefined/ai/client/ask_loop.py index cf545ee8..52a3f5de 100644 --- a/src/Undefined/ai/client/ask_loop.py +++ b/src/Undefined/ai/client/ask_loop.py @@ -18,6 +18,7 @@ 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 @@ -225,6 +226,7 @@ async def fetch_session_messages_callback( 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, diff --git a/src/Undefined/ai/tooling.py b/src/Undefined/ai/tooling.py index ac101c8e..58fcaaa0 100644 --- a/src/Undefined/ai/tooling.py +++ b/src/Undefined/ai/tooling.py @@ -13,6 +13,7 @@ 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 @@ -214,6 +215,7 @@ async def execute_tool( 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() diff --git a/src/Undefined/services/coordinator/batching.py b/src/Undefined/services/coordinator/batching.py index b55bdfee..0b5a1196 100644 --- a/src/Undefined/services/coordinator/batching.py +++ b/src/Undefined/services/coordinator/batching.py @@ -86,10 +86,6 @@ 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 并入队。 @@ -100,7 +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 = self._collect_message_ids(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) 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 ae51984d..ba939b97 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,10 +1,11 @@ -import uuid import asyncio -from pathlib import Path -from typing import Any, Callable, Dict, cast import logging -import httpx +import uuid +from pathlib import Path +from typing import Any, Callable, Dict, Protocol, cast + import aiofiles +import httpx logger = logging.getLogger(__name__) @@ -25,6 +26,12 @@ _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, @@ -84,14 +91,11 @@ def _can_treat_as_local_path(value: str) -> bool: async def _copy_file_to_temp( source: Path, target: Path, + write_bytes_fn: WriteBytesFn, ) -> None: async with aiofiles.open(source, "rb") as src: - async with aiofiles.open(target, "wb") as dst: - while True: - chunk = await src.read(1024 * 1024) - if not chunk: - break - await dst.write(chunk) + 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: @@ -116,6 +120,10 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: 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)( @@ -149,18 +157,35 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: temp_dir=temp_dir, max_size_mb=max_size_mb, task_uuid=task_uuid, + write_bytes_fn=write_bytes, ) 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) @@ -190,8 +215,7 @@ async def _download_from_url( task_uuid=task_uuid, ) file_path = temp_dir / filename - async with aiofiles.open(file_path, "wb") as f: - await f.write(response.content) + await write_bytes_fn(file_path, response.content, use_lock=False) logger.info(f"文件已保存到: {file_path}") return str(file_path) @@ -206,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") @@ -236,8 +264,7 @@ async def _download_from_file_id( task_uuid=task_uuid, ) file_path = temp_dir / filename - async with aiofiles.open(file_path, "wb") as f: - await f.write(response.content) + await write_bytes_fn(file_path, response.content, use_lock=False) logger.info(f"文件已保存到: {file_path}") return str(file_path) @@ -259,8 +286,7 @@ async def _download_from_file_id( task_uuid=task_uuid, ) file_path = temp_dir / filename - async with aiofiles.open(file_path, "wb") as f: - await f.write(content) + await write_bytes_fn(file_path, content, use_lock=False) logger.info(f"本地文件已复制到: {file_path}") return str(file_path) @@ -284,6 +310,7 @@ async def _download_from_attachment_record( 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) try: @@ -296,7 +323,11 @@ async def _download_from_attachment_record( 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 + source_ref, + temp_dir, + max_size_mb, + task_uuid, + write_bytes_fn, ) return f"错误:无法从附件 UID {getattr(record, 'uid', '')} 解析到可下载文件" @@ -306,7 +337,11 @@ async def _download_from_attachment_record( 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 + source_ref, + temp_dir, + max_size_mb, + task_uuid, + write_bytes_fn, ) return f"错误:附件 UID 本地文件不存在:{getattr(record, 'uid', '')}" @@ -325,7 +360,11 @@ async def _download_from_attachment_record( task_uuid=task_uuid, ) target = temp_dir / filename - await _copy_file_to_temp(local_path, target) + await _copy_file_to_temp( + local_path, + target, + write_bytes_fn, + ) logger.info("附件 UID 已通过注册表复制到: %s", target) return str(target) except OSError as exc: diff --git a/tests/test_file_analysis_attachment_uid.py b/tests/test_file_analysis_attachment_uid.py index 445851d8..06da1334 100644 --- a/tests/test_file_analysis_attachment_uid.py +++ b/tests/test_file_analysis_attachment_uid.py @@ -9,6 +9,7 @@ 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 @@ -23,6 +24,7 @@ def _download_context( "get_scope_from_context": scope_from_context, "download_cache_dir": tmp_path / "downloads", "ensure_dir_fn": ensure_dir, + "write_bytes_fn": write_bytes, } @@ -92,8 +94,9 @@ async def _fake_download_from_url( temp_dir: Path, max_size_mb: float, task_uuid: str, + write_bytes_fn: object, ) -> str: - _ = max_size_mb, task_uuid + _ = 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") From 5c7fe61a33df6d546f0d6f25b28b6a12150cd5ba Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 6 Jun 2026 17:19:11 +0800 Subject: [PATCH 75/77] fix(webchat): remove unreachable attachment fallback Co-authored-by: GPT-5 Codex --- .../agents/file_analysis_agent/tools/download_file/handler.py | 2 -- 1 file changed, 2 deletions(-) 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 ba939b97..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 @@ -374,5 +374,3 @@ async def _download_from_attachment_record( exc, ) return "错误:附件文件读取失败" - - return f"错误:无法从附件 UID {getattr(record, 'uid', '')} 解析到可下载文件" From 72f86c6cb0114292072a9975d8d62c92b2d5f12f Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 6 Jun 2026 18:48:58 +0800 Subject: [PATCH 76/77] ci: run quality checks on develop Co-authored-by: GPT-5 Codex --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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: From f7a616901a3954adfcaf6b38f83f698e9f494870 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 6 Jun 2026 19:10:43 +0800 Subject: [PATCH 77/77] fix(webchat): align stream history and nonce tests Co-authored-by: GPT-5 Codex --- src/Undefined/webui/routes/_index.py | 15 ++++++++++++++- tests/test_runtime_api_chat_stream.py | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Undefined/webui/routes/_index.py b/src/Undefined/webui/routes/_index.py index e22fe2ce..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,7 +75,12 @@ async def index_handler(request: web.Request) -> Response: initial_state_json = json.dumps(initial_state).replace(" None: + monkeypatch.chdir(tmp_path) + + class _DummyTransport: def is_closing(self) -> bool: return False @@ -268,7 +274,14 @@ 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_awaited_once() + 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