From f117cd84a22aa8d7f9d7fca793f39cc3d6651601 Mon Sep 17 00:00:00 2001 From: Luhaozhu Date: Sat, 31 Oct 2026 09:38:32 -0700 Subject: [PATCH] Sync task numbers, run monitor cards, and context replay fixes --- .claude/skills/hugagent-frontend-dev/SKILL.md | 2 +- document/en/modules/automation.md | 4 +- document/en/modules/projects-myspace.md | 2 +- document/zh-CN/modules/automation.md | 4 +- document/zh-CN/modules/projects-myspace.md | 2 +- scripts/refactor/import_contract.py | 1 + .../alembic/versions/ce_0036_task_numbers.py | 28 + src/backend/api/routes/v1/models.py | 4 +- src/backend/core/chat/compaction/history.py | 14 +- src/backend/core/chat/runtime/chunks.py | 33 +- src/backend/core/chat/steer.py | 2 + src/backend/core/chat/tool_log.py | 25 +- src/backend/core/db/models/__init__.py | 3 +- src/backend/core/db/models/chat_runs.py | 58 +- src/backend/core/db/models/logs.py | 5 + src/backend/core/db/task_number_migration.py | 125 ++++ src/backend/core/files/artifacts/reader.py | 6 - .../files/content/content_blocks_constants.py | 9 +- src/backend/core/harness/log_records.py | 43 +- src/backend/core/harness/log_writer.py | 3 +- src/backend/core/harness/request_links.py | 61 ++ src/backend/core/infra/logging.py | 16 +- .../core/llm/context/attachment_hook.py | 2 +- .../llm/runtime/agentscope_hook_adapter.py | 6 +- .../core/llm/runtime/context_hook_adapter.py | 38 +- src/backend/core/llm/runtime/file_context.py | 185 +----- .../core/llm/runtime/interaction_store.py | 6 +- src/backend/core/llm/runtime/model_steps.py | 72 ++- .../core/llm/runtime/tool_media_store.py | 10 +- .../core/llm/tools/confirmation/gate.py | 4 - .../core/llm/tools/confirmation/pending.py | 7 +- src/backend/core/runs/binding.py | 4 + src/backend/core/runs/runtime.py | 18 +- src/backend/orchestration/plans/mode.py | 23 +- .../personal_agent/conversation/front.py | 4 +- src/backend/tests/api/chat_fork_fixtures.py | 3 +- .../api/test_agent_response_admission.py | 6 +- .../tests/api/test_chat_stream_sequencer.py | 4 +- .../tests/chat/test_stream_event_builders.py | 12 +- .../tests/core/runs/test_request_links.py | 118 ++++ .../tests/core/runs/test_task_numbers.py | 153 +++++ .../tests/llm/test_context_step_replay.py | 159 +++++ .../llm/test_execution_surface_limits.py | 2 +- ...st_file_context_offloads_blocking_parse.py | 48 +- .../tests/llm/test_file_context_preview.py | 80 +-- .../llm/test_live_approval_integration.py | 55 +- .../orchestration/test_agent_api_recovery.py | 4 +- .../test_project_api_recovery.py | 4 +- .../tests/services/test_chat_sequencer.py | 4 +- src/frontend/scripts/dark-mode-baseline.json | 3 - .../scripts/test-live-approval-browser.mjs | 13 +- .../scripts/test-module-pages-browser.mjs | 16 +- .../scripts/test-reference-motion-browser.mjs | 20 +- src/frontend/src/ApiDocApp.tsx | 2 + src/frontend/src/components/AppWorkspace.tsx | 8 +- .../automation/RunTimelinePanel.tsx | 370 ----------- .../components/automation/automationUtils.ts | 15 + .../src/components/common/RollingText.tsx | 14 +- .../src/components/common/ShimmerText.tsx | 41 ++ src/frontend/src/components/common/index.ts | 1 + .../src/components/monitor/MonitorCard.tsx | 25 + .../src/components/monitor/RunMonitorRail.css | 36 ++ .../src/components/monitor/RunMonitorRail.tsx | 33 +- .../components/monitor/ScheduledTaskCard.tsx | 112 ++++ .../components/sidebar/ChatSidebarItem.tsx | 11 +- .../src/components/tool/CompactionStepRow.tsx | 4 +- .../src/components/tool/SubagentCallRow.tsx | 3 +- .../src/components/tool/ThinkingStepRow.tsx | 10 +- .../src/components/tool/ToolCallRow.tsx | 4 +- src/frontend/src/hooks/chatStream/controls.ts | 16 +- src/frontend/src/hooks/useAppBootstrap.ts | 32 +- src/frontend/src/hooks/useDocumentFavicon.ts | 25 + src/frontend/src/i18n/en/hooks.ts | 1 - src/frontend/src/i18n/en/lab.ts | 13 +- .../src/stores/automationChatStore.ts | 2 +- src/frontend/src/stores/chatSupport.ts | 19 +- .../src/stores/modelCapabilitiesStore.ts | 20 +- .../src/styles/automation-timeline.css | 609 ------------------ src/frontend/src/styles/chat/turn-status.css | 69 +- src/frontend/src/styles/index.ts | 1 + src/frontend/src/styles/mobile/touch.css | 2 +- src/frontend/src/styles/motion.css | 4 - src/frontend/src/styles/task-monitor.css | 273 ++++++++ src/frontend/src/styles/tool.css | 14 - src/frontend/src/utils/pageConfigDefaults.ts | 15 +- src/frontend/src/utils/reasoningEffort.ts | 6 + 86 files changed, 1765 insertions(+), 1578 deletions(-) create mode 100644 src/backend/alembic/versions/ce_0036_task_numbers.py create mode 100644 src/backend/core/db/task_number_migration.py create mode 100644 src/backend/core/harness/request_links.py create mode 100644 src/backend/tests/core/runs/test_request_links.py create mode 100644 src/backend/tests/core/runs/test_task_numbers.py create mode 100644 src/backend/tests/llm/test_context_step_replay.py delete mode 100644 src/frontend/src/components/automation/RunTimelinePanel.tsx create mode 100644 src/frontend/src/components/common/ShimmerText.tsx create mode 100644 src/frontend/src/components/monitor/MonitorCard.tsx create mode 100644 src/frontend/src/components/monitor/ScheduledTaskCard.tsx create mode 100644 src/frontend/src/hooks/useDocumentFavicon.ts delete mode 100644 src/frontend/src/styles/automation-timeline.css create mode 100644 src/frontend/src/styles/task-monitor.css diff --git a/.claude/skills/hugagent-frontend-dev/SKILL.md b/.claude/skills/hugagent-frontend-dev/SKILL.md index d0743241..5f2c624b 100644 --- a/.claude/skills/hugagent-frontend-dev/SKILL.md +++ b/.claude/skills/hugagent-frontend-dev/SKILL.md @@ -124,7 +124,7 @@ src/frontend/src/ │ ├── variables.css # CSS 自定义属性(颜色、阴影) │ ├── common.css # 全局工具类 │ ├── chat.css / sidebar.css / catalog.css / tool.css -│ ├── automation.css / automation-timeline.css / batch(并入相关文件) +│ ├── automation.css / batch(并入相关文件) │ ├── canvas.css / config.css / mcp.css / myspace.css / plan.css │ └── projects.css / search-modal.css / settings.css / skill-distill.css / team-folder.css ``` diff --git a/document/en/modules/automation.md b/document/en/modules/automation.md index d2c375f6..1f88aa8a 100644 --- a/document/en/modules/automation.md +++ b/document/en/modules/automation.md @@ -58,7 +58,7 @@ Scheduled prompts and ordinary conversations share admission, capability resolut Scheduled Tasks is a dedicated module under src/frontend/src/components/automation/. Its sidebar groups conversations by task ID and shows one entry per scheduled task, keeping distinct tasks separate even when their names match. New executions update the entry's latest activity time; clicking it opens the newest execution. The right control panel retains individual executions, and choosing a historical run keeps the same task selected in the sidebar. Setup conversations remain separate, and empty setup conversations are hidden. The oldest conversation anchors each entry, preserving its ordering, pin, favorite, and rename actions. Export and delete apply to the selected execution, or the newest execution when the task is not open; deleting one conversation does not delete the scheduled task. All Tasks opens configuration, status, and run history in the content area. Creating through chat first persists a conversation owned by Scheduled Tasks, references the installed task-management plugin, and prefills an editable template. Task conversations are excluded from the ordinary chat sidebar. -Execution conversations use /automation/:taskId/conversations/:chatId, with new as the task segment for setup conversations. Reloads, browser history, search hits, and notifications preserve task ownership. Search opens the exact matching conversation instead of the latest run. RunTimelinePanel groups executions by date. automationStore owns the single task list; automationChatStore owns the current run group and task pin/favorite preferences. +Execution conversations use /automation/:taskId/conversations/:chatId, with new as the task segment for setup conversations. Reloads, browser history, search hits, and notifications preserve task ownership. Search opens the exact matching conversation instead of the latest run. The run monitor rail beside an execution conversation shows a scheduled-task card listing every run, newest first. automationStore owns the single task list; automationChatStore owns the current run group and task pin/favorite preferences. Opening the right-side Canvas in an execution conversation temporarily hides the run timeline to make room for the conversation and Canvas. Collapsing Canvas restores the timeline and preserves the current task, selected execution, and conversation. @@ -106,7 +106,7 @@ Every morning an Industry morning brief execution conversation is created. The s | Plan REST API | `src/backend/api/routes/v1/plans.py` | | Plan generation / execution orchestration | `src/backend/orchestration/plans/`, `src/backend/core/chat/plans.py` | | Plan / step models | `src/backend/core/db/models/agent.py` | -| Frontend automation UI | `src/frontend/src/components/automation/AutomationPanel.tsx` etc., `src/frontend/src/components/automation/RunTimelinePanel.tsx` | +| Frontend automation UI | `src/frontend/src/components/automation/AutomationPanel.tsx` etc., `src/frontend/src/components/monitor/ScheduledTaskCard.tsx` | | Frontend state | `src/frontend/src/stores/automationStore.ts`, `automationChatStore.ts` | Further reading: [Chat System](chat.md) · [MCP Tools](mcp-tools.md) · [Canvas & Artifacts](canvas-artifacts.md) diff --git a/document/en/modules/projects-myspace.md b/document/en/modules/projects-myspace.md index ad5faf7d..7fd1cdbc 100644 --- a/document/en/modules/projects-myspace.md +++ b/document/en/modules/projects-myspace.md @@ -167,7 +167,7 @@ Permission model: `TeamMember.role` (owner/admin/member) + `file_permission` (vi Three complementary paths: -1. **Attachment injection (hooks)**: frontend requests send only `file_id`, `name`, and `mime_type` in each `attachments[]` item, never the file body. `core/harness/hooks.py` lazily downloads and parses by `file_id`, caching `parsed_text` and its summary; the first turn injects only metadata and a bounded preview of at most 5,000 characters, while xlsx previews also include total dimensions, headers, and a few data rows. The model reads content beyond the preview on demand through `read_artifact`; images still use multimodal injection. Every read validates `user_id` ownership to block forged cross-user access; +1. **Attachment injection (hooks)**: frontend requests send only `file_id`, `name`, and `mime_type` in each `attachments[]` item, never the file body. The attachment hook (`core/llm/context/attachment_hook.py`) neither downloads nor parses files: it injects only each file's name, MIME type, and `file_id` plus reading instructions, so attachments add nothing to time-to-first-token. When the model needs the content it calls `read_artifact`, which downloads and parses by `file_id`, caches `parsed_text` and its summary, and returns paginated text (xlsx by sheet, pptx by slide); images still use multimodal injection. Every read validates `user_id` ownership to block forged cross-user access; 2. **Project file access**: a project conversation's system prompt supplies project instructions and directory scope without an automatic file inventory or count. The agent discovers files on demand through directory, search and read tools; 3. **Sandbox virtual filesystem**: with code execution enabled, the `/myspace/...` path maps MySpace into the sandbox (lazy loading + reverse sync); team projects map the team folder — see [Sandbox](./sandbox.md). diff --git a/document/zh-CN/modules/automation.md b/document/zh-CN/modules/automation.md index 2c53f631..dd4f24ea 100644 --- a/document/zh-CN/modules/automation.md +++ b/document/zh-CN/modules/automation.md @@ -58,7 +58,7 @@ HugAgentOS 内置两种「把一句话变成可重复的生产力」机制,两 定时任务是独立模块,相关组件位于 src/frontend/src/components/automation/。左侧按任务 ID 合并会话,每个定时任务只显示一条记录;同名的不同任务分别显示。新运行更新入口的最近活动时间,点击入口打开最近一次运行。右侧控制面板保留各次执行记录,选择历史运行后左侧仍选中同一个任务。创建中的会话分别显示,空白创建会话隐藏。侧边栏沿用最早会话作为稳定入口,保留该会话的排序、置顶、收藏和重命名操作。导出和删除作用于当前选中的执行会话,未打开该任务时作用于最近一次执行;删除单次会话不删除定时任务。点击“全部任务”进入内容区管理任务配置、状态和执行记录。通过对话创建时,会先保存归属于定时任务模块的会话,引用已安装的定时任务管理插件并填入可编辑模板。普通对话列表不展示任务会话。 -执行会话使用 /automation/:taskId/conversations/:chatId;创建会话使用任务段 new。刷新、前进后退、搜索命中及通知入口均保留任务归属;搜索执行结果直接打开命中的会话,避免误跳到最新执行。RunTimelinePanel 提供按日期分组的执行记录。automationStore 管理唯一任务列表,automationChatStore 仅管理当前执行组和任务置顶、收藏偏好。 +执行会话使用 /automation/:taskId/conversations/:chatId;创建会话使用任务段 new。刷新、前进后退、搜索命中及通知入口均保留任务归属;搜索执行结果直接打开命中的会话,避免误跳到最新执行。执行会话右侧的运行监控栏提供「定时任务」卡片,按时间倒序列出全部执行记录。automationStore 管理唯一任务列表,automationChatStore 仅管理当前执行组和任务置顶、收藏偏好。 执行会话打开右侧 Canvas 时,执行记录面板暂时隐藏,为对话和 Canvas 留出空间。收起 Canvas 后恢复执行记录面板,保留当前任务、选中的执行和会话。 @@ -106,7 +106,7 @@ POST /v1/automations | 计划 REST API | `src/backend/api/routes/v1/plans.py` | | 计划生成 / 执行编排 | `src/backend/orchestration/plans/`、`src/backend/core/chat/plans.py` | | 计划 / 步骤模型 | `src/backend/core/db/models/agent.py` | -| 前端自动化 UI | `src/frontend/src/components/automation/AutomationPanel.tsx` 等、`src/frontend/src/components/automation/RunTimelinePanel.tsx` | +| 前端自动化 UI | `src/frontend/src/components/automation/AutomationPanel.tsx` 等、`src/frontend/src/components/monitor/ScheduledTaskCard.tsx` | | 前端状态 | `src/frontend/src/stores/automationStore.ts`、`automationChatStore.ts` | 延伸阅读:[对话系统](chat.md) · [MCP 工具](mcp-tools.md) · [数据画布与产物](canvas-artifacts.md) diff --git a/document/zh-CN/modules/projects-myspace.md b/document/zh-CN/modules/projects-myspace.md index 7f009df6..a52b990c 100644 --- a/document/zh-CN/modules/projects-myspace.md +++ b/document/zh-CN/modules/projects-myspace.md @@ -158,7 +158,7 @@ teams ─────────┬── team_members(role: owner/admin/memb 三条路径,互为补充: -1. **附件注入(hooks)**:前端请求的 `attachments[]` 只携带 `file_id`、`name`、`mime_type`,不携带文件正文。`core/harness/hooks.py` 按 `file_id` 延迟下载、解析并缓存 `parsed_text` 与摘要;首轮只注入文件元数据和最多 5,000 字符的有界预览,xlsx 额外提供总行列数、表头和少量数据行。模型需要预览外的正文时通过 `read_artifact` 按需读取;图片仍走多模态注入。所有读取都校验 `user_id` 归属,防止伪造 `file_id` 跨用户访问; +1. **附件注入(hooks)**:前端请求的 `attachments[]` 只携带 `file_id`、`name`、`mime_type`,不携带文件正文。附件钩子(`core/llm/context/attachment_hook.py`)不下载也不解析文件,只注入文件名、MIME 和 `file_id` 以及读取指引,因此附件不会拖慢首字响应。模型需要正文时调用 `read_artifact`,由它按 `file_id` 下载、解析并缓存 `parsed_text` 与摘要,再分页返回(xlsx 可按 sheet、pptx 可按页读取);图片仍走多模态注入。所有读取都校验 `user_id` 归属,防止伪造 `file_id` 跨用户访问; 2. **项目文件访问**:项目对话的 system prompt 提供项目指令和目录范围,不自动携带文件列表或文件总数。agent 按需使用目录、搜索和读取工具获取文件信息; 3. **沙箱虚拟文件系统**:开启代码执行时,`/myspace/...` 路径把我的空间映射进沙箱(懒加载 + 文件系统实时登记),团队项目映射团队文件夹——见 [沙箱](./sandbox.md)。 diff --git a/scripts/refactor/import_contract.py b/scripts/refactor/import_contract.py index abf1c1ca..ab576048 100644 --- a/scripts/refactor/import_contract.py +++ b/scripts/refactor/import_contract.py @@ -28,6 +28,7 @@ ("automation", "desktop"), # Authorized local task dispatch; see module README. ("distillation", "marketplace"), # Explicit draft publication; see module README. ("security_console", "sandbox_ops"), # Read-only rebuild status; see module README. + ("task_monitor", "security_console"), # Read-only health snapshot; see module README. } OUTER = ("features", "integrations", "personal_agent", "orchestration", "api") diff --git a/src/backend/alembic/versions/ce_0036_task_numbers.py b/src/backend/alembic/versions/ce_0036_task_numbers.py new file mode 100644 index 00000000..e6d9f8cf --- /dev/null +++ b/src/backend/alembic/versions/ce_0036_task_numbers.py @@ -0,0 +1,28 @@ +"""Task numbers: RW-YYYYMMDD-NNNN on every run, RW-…-NN on every sub-agent call. + +Same schema and backfill as the EE revision ``tasknumber01`` (``core.db.task_number_migration``). + +Revision ID: ce_0036 +Revises: ce_0035 +""" + +import sqlalchemy as sa +from alembic import op + +from core.db.task_number_migration import downgrade_task_numbers, upgrade_task_numbers + +revision = "ce_0036" +down_revision = "ce_0035" +branch_labels = None +depends_on = None + + +def upgrade(): + # CE's initial create_all may already contain the current model tables. + if "task_number_counters" in sa.inspect(op.get_bind()).get_table_names(): + return + upgrade_task_numbers(op) + + +def downgrade(): + downgrade_task_numbers(op) diff --git a/src/backend/api/routes/v1/models.py b/src/backend/api/routes/v1/models.py index d259055d..17376977 100644 --- a/src/backend/api/routes/v1/models.py +++ b/src/backend/api/routes/v1/models.py @@ -532,7 +532,7 @@ def get_main_capabilities( # context-usage gauge reflects the model's true window instead of a guess. # 0 when unconfigured — the frontend falls back to a heuristic/default. main_context_length = int(cfg.context_length) if cfg and cfg.context_length else 0 - from core.files.artifacts.reader import ATTACHMENT_PREVIEW_MAX_CHARS + from core.llm.runtime.file_context import ATTACHMENT_HANDLE_EST_CHARS # Image reading: either the main model is natively multimodal, or a `vision` role # model is assigned and the vision bridge transcribes images into text evidence. # The frontend uses `can_read_image` alone to decide whether to offer image upload; @@ -547,7 +547,7 @@ def get_main_capabilities( **effort_caps, "supports_vision": main_native_vision, "context_length": main_context_length, - "attachment_preview_chars": ATTACHMENT_PREVIEW_MAX_CHARS, + "attachment_preview_chars": ATTACHMENT_HANDLE_EST_CHARS, }, "vision": { "can_read_image": vision_cfg is not None, diff --git a/src/backend/core/chat/compaction/history.py b/src/backend/core/chat/compaction/history.py index 6ab148eb..d1630548 100644 --- a/src/backend/core/chat/compaction/history.py +++ b/src/backend/core/chat/compaction/history.py @@ -8,7 +8,11 @@ from core.chat.context import build_effective_user_message from core.llm.context import compaction as C from core.llm.context.ir_constants import SESSION_CONTEXT_META_KEY -from core.llm.runtime.model_steps import assistant_row_replay, record_assistant_step +from core.llm.runtime.model_steps import ( + assistant_row_replay, + record_assistant_step, + split_before_input, +) from core.chat.references import render_reference_block @@ -56,6 +60,9 @@ def normalize_rows(rows: List[Any], live_run_ids: frozenset = frozenset()) -> Li definition and would show the model a truncated version of its own reply. """ out: List[Dict[str, Any]] = [] + # Where the latest user row starts: blocks the run injected ahead of the + # user's message (attachment previews) go back in front of it. + turn_start = 0 for msg in rows: role = getattr(msg, "role", None) extra = getattr(msg, "extra_data", None) or {} @@ -79,9 +86,10 @@ def normalize_rows(rows: List[Any], live_run_ids: frozenset = frozenset()) -> Li } if isinstance(extra.get(SESSION_CONTEXT_META_KEY), dict): row[SESSION_CONTEXT_META_KEY] = dict(extra[SESSION_CONTEXT_META_KEY]) + turn_start = len(out) out.append(row) elif role == "assistant": - out.extend( + before, after = split_before_input( assistant_row_replay( content=content or "", model_steps=( @@ -92,6 +100,8 @@ def normalize_rows(rows: List[Any], live_run_ids: frozenset = frozenset()) -> Li cancelled=bool(extra.get("cancelled")), ) ) + out[turn_start:turn_start] = before + out.extend(after) else: row = {"role": role or "user", "content": content} if isinstance(extra.get(SESSION_CONTEXT_META_KEY), dict): diff --git a/src/backend/core/chat/runtime/chunks.py b/src/backend/core/chat/runtime/chunks.py index 544b62a5..b575a03d 100644 --- a/src/backend/core/chat/runtime/chunks.py +++ b/src/backend/core/chat/runtime/chunks.py @@ -6,13 +6,12 @@ from core.infra.text_bounds import bound_result_for_display from core.chat.tool_log import ( attach_subagent_step, + build_interaction_event, build_thinking_event, build_tool_call_delta_event, build_tool_call_event, build_tool_call_start_event, build_tool_result_event, - build_user_question_event, - build_user_question_resolved_event, ) from core.infra.logging import get_logger @@ -131,34 +130,8 @@ async def consume_chunk(turn, chunk): logger.debug("attach_subagent_step failed (ignored)", exc_info=True) chunk["chat_id"] = turn.chat_id await turn.emit(bound_result_for_display(chunk)[0]) - elif chunk_type == "file_confirm": - await turn.emit( - { - "type": "file_confirm", - "chat_id": turn.chat_id, - "confirm_id": chunk.get("confirm_id"), - "op": chunk.get("op"), - "logical_path": chunk.get("logical_path"), - "message": chunk.get("message"), - "expired": chunk.get("expired", False), - } - ) - elif chunk_type == "design_pick": - await turn.emit( - { - "type": "design_pick", - "chat_id": turn.chat_id, - "confirm_id": chunk.get("confirm_id"), - "question": chunk.get("question", ""), - "options": chunk.get("options", []), - "message": chunk.get("message"), - "expired": chunk.get("expired", False), - } - ) - elif chunk_type == "user_question": - await turn.emit(build_user_question_event(chunk, turn.chat_id)) - elif chunk_type == "user_question_resolved": - await turn.emit(build_user_question_resolved_event(chunk, turn.chat_id)) + elif chunk_type in ("file_confirm", "design_pick", "user_question", "user_question_resolved"): + await turn.emit(build_interaction_event(chunk_type, chunk, turn.chat_id)) elif chunk_type == "plan_update": await turn.emit( { diff --git a/src/backend/core/chat/steer.py b/src/backend/core/chat/steer.py index 5dc37396..991f3ab6 100644 --- a/src/backend/core/chat/steer.py +++ b/src/backend/core/chat/steer.py @@ -15,6 +15,7 @@ from core.db.engine import SessionLocal from core.db.models import ChatRun +from core.harness.request_links import link_request from core.infra.ephemeral import get_ephemeral_state from core.chat.steer_queue import SteerQueue, SteerQueueConflict @@ -65,6 +66,7 @@ async def put_pending_steer(run_id: str, payload: Dict[str, Any]) -> Dict[str, A delivery_mode=str(payload.get("delivery_mode") or "steer"), replace_latest=bool(payload.get("replace_latest", True)), ) + await link_request(run_id, f"steer:{record.delivery_mode}") await _notify( run_id, { diff --git a/src/backend/core/chat/tool_log.py b/src/backend/core/chat/tool_log.py index 0f368009..6d1ee3f1 100644 --- a/src/backend/core/chat/tool_log.py +++ b/src/backend/core/chat/tool_log.py @@ -164,28 +164,11 @@ def build_tool_call_event(chunk: dict, chat_id: str, tool_calls_log: list) -> Di return {"type": "tool_call", **tc, "chat_id": chat_id} -def build_user_question_event(chunk: dict, chat_id: str) -> Dict[str, Any]: - """Build the authoritative pending-question request sent to the composer.""" +def build_interaction_event(event_type: str, chunk: dict, chat_id: str) -> Dict[str, Any]: + """Relay a confirm / design-pick / question signal; its producer owns the fields.""" - return { - "type": "user_question", - "chat_id": chat_id, - "request_id": chunk.get("request_id"), - "questions": chunk.get("questions", []), - "created_at": chunk.get("created_at"), - "expires_at": chunk.get("expires_at"), - } - - -def build_user_question_resolved_event(chunk: dict, chat_id: str) -> Dict[str, Any]: - """Build the server-owned event that removes a composer request.""" - - return { - "type": "user_question_resolved", - "chat_id": chat_id, - "request_id": chunk.get("request_id"), - "outcome": chunk.get("outcome"), - } + public = {k: v for k, v in chunk.items() if not k.startswith("_") and k != "event"} + return bound_result_for_display({**public, "type": event_type, "chat_id": chat_id})[0] def _payload_carries_error(res: Any) -> bool: diff --git a/src/backend/core/db/models/__init__.py b/src/backend/core/db/models/__init__.py index beb8116f..2ba29242 100644 --- a/src/backend/core/db/models/__init__.py +++ b/src/backend/core/db/models/__init__.py @@ -9,7 +9,7 @@ from core.db.edition import EDITION_MODEL_EXPORTS from core.db.models.chat import ChatCompactionState, ChatMessage, ChatSession -from core.db.models.chat_runs import ChatRun, ChatRunOperation, ChatSteerQueueItem +from core.db.models.chat_runs import ChatRun, ChatRunOperation, ChatSteerQueueItem, TaskNumberCounter from core.db.models.chat_resources import ChatSandboxSnapshot, ToolMediaBlob, MessageFeedback from core.db.models.identity import LocalUser, UserApiKey, UserFolder, UserShadow from core.db.models.project import Project, ProjectFavorite @@ -88,6 +88,7 @@ def __getattr__(name): 'ChatMessage', 'ChatCompactionState', 'ChatRun', + 'TaskNumberCounter', 'CommandTask', 'ChatRunOperation', 'ChatSteerQueueItem', diff --git a/src/backend/core/db/models/chat_runs.py b/src/backend/core/db/models/chat_runs.py index e0afa2f3..c77cf1c8 100644 --- a/src/backend/core/db/models/chat_runs.py +++ b/src/backend/core/db/models/chat_runs.py @@ -3,7 +3,9 @@ from core.db.utc_datetime import UTCDateTime from datetime import datetime, timezone from core.db.engine import Base -from sqlalchemy import BigInteger, CheckConstraint, Column, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy import BigInteger, CheckConstraint, Column, ForeignKey, Index, Integer, String, Text, UniqueConstraint, event +from sqlalchemy.dialects.postgresql import insert as postgresql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.orm import deferred from core.db.models.column_types import BigIntPK, JSONType @@ -18,6 +20,11 @@ class ChatRun(Base): __tablename__ = "chat_runs" run_id = Column(String(64), primary_key=True) + # The task number people see and quote: RW-YYYYMMDD-NNNN, assigned on insert (see below). + # run_id stays the internal key every table joins on. + task_no = Column(String(32), nullable=False) + # Last sub-task number handed out under this task (RW-…-NNNN-01, -02, …). + subtask_seq = Column(Integer, nullable=False, default=0, server_default="0") chat_id = Column( String(64), ForeignKey("chat_sessions.chat_id", ondelete="CASCADE"), @@ -77,6 +84,7 @@ class ChatRun(Base): name="chat_runs_writer_slot_check", ), UniqueConstraint("chat_id", "writer_slot", name="uq_chat_runs_writer_slot"), + UniqueConstraint("task_no", name="uq_chat_runs_task_no"), Index("idx_chat_runs_chat_status", "chat_id", "status"), Index("idx_chat_runs_status_lease", "status", "lease_expires_at"), Index("idx_chat_runs_user_id", "user_id"), @@ -84,6 +92,54 @@ class ChatRun(Base): ) +class TaskNumberCounter(Base): + """The last task serial number handed out on each day (server local time).""" + + __tablename__ = "task_number_counters" + + day = Column(String(8), primary_key=True) # YYYYMMDD + last_seq = Column(Integer, nullable=False) + + +TASK_NUMBER_PREFIX = "RW" + + +def task_number(day: str, seq: int) -> str: + return f"{TASK_NUMBER_PREFIX}-{day}-{seq:04d}" + + +def subtask_number(parent_no: str, seq: int) -> str: + return f"{parent_no}-{seq:02d}" + + +def reserve_task_number(connection, at: datetime) -> str: + """Hand out the next task number of ``at``'s day in one atomic upsert. + + The counter row stays locked until the caller's transaction ends, so numbers are + gap-free and never collide, on PostgreSQL and SQLite alike. + """ + day = f"{at.astimezone():%Y%m%d}" + table = TaskNumberCounter.__table__ + insert = postgresql_insert if connection.dialect.name == "postgresql" else sqlite_insert + statement = ( + insert(table) + .values(day=day, last_seq=1) + .on_conflict_do_update(index_elements=[table.c.day], set_={"last_seq": table.c.last_seq + 1}) + .returning(table.c.last_seq) + ) + return task_number(day, connection.execute(statement).scalar_one()) + + +@event.listens_for(ChatRun, "before_insert") +def assign_task_number(_mapper, connection, target: ChatRun) -> None: + """Every run gets its task number where the row is written, whichever path admits it.""" + if target.task_no is None: + at = target.created_at or datetime.now(timezone.utc) + if at.tzinfo is None: + at = at.replace(tzinfo=timezone.utc) + target.task_no = reserve_task_number(connection, at) + + class ChatRunOperation(Base): """Append-only, per-run operation journal used for recovery decisions.""" diff --git a/src/backend/core/db/models/logs.py b/src/backend/core/db/models/logs.py index f88dcf87..0d486b42 100644 --- a/src/backend/core/db/models/logs.py +++ b/src/backend/core/db/models/logs.py @@ -287,6 +287,11 @@ class SubAgentCallLog(Base): error_message = Column(Text) duration_ms = Column(Integer) parent_subagent_log_id = Column(String(64), index=True) + # Sub-task number: the parent's number (the run's task number, or the calling sub-agent's + # own sub-task number) plus "-NN". Agents that are the run itself carry the run's number. + task_no = Column(String(64), index=True) + # Last sub-task number handed out to sub-agents this one called. + child_seq = Column(Integer, nullable=False, default=0, server_default="0") started_at = Column(UTCDateTime(timezone=True)) completed_at = Column(UTCDateTime(timezone=True)) created_at = Column(UTCDateTime(timezone=True), default=utc_now, nullable=False) diff --git a/src/backend/core/db/task_number_migration.py b/src/backend/core/db/task_number_migration.py new file mode 100644 index 00000000..33d1d779 --- /dev/null +++ b/src/backend/core/db/task_number_migration.py @@ -0,0 +1,125 @@ +"""Schema and backfill for task numbers, shared by the EE (tasknumber01) and CE (ce_0036) chains. + +Adds ``chat_runs.task_no`` / ``subtask_seq``, ``subagent_call_logs.task_no`` / ``child_seq`` and +the per-day ``task_number_counters`` table, then numbers existing rows by the rules new rows +follow (``core.db.models.chat_runs`` and ``core.harness.log_records``): runs in creation order by +local day; sub-agent calls under whoever dispatched them; the run's own agent keeps its number. +""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from datetime import timezone + +import sqlalchemy as sa + +from core.db.utc_datetime import UTCDateTime + +_RUN_AGENT_TYPES = {"plan_mode", "user_agent_direct"} +_BATCH = 1000 + +_runs = sa.table( + "chat_runs", + sa.column("run_id", sa.String), + sa.column("message_id", sa.String), + sa.column("created_at", UTCDateTime(timezone=True)), + sa.column("task_no", sa.String), + sa.column("subtask_seq", sa.Integer), +) +_logs = sa.table( + "subagent_call_logs", + sa.column("id", sa.String), + sa.column("message_id", sa.String), + sa.column("subagent_type", sa.String), + sa.column("parent_subagent_log_id", sa.String), + sa.column("created_at", UTCDateTime(timezone=True)), + sa.column("task_no", sa.String), + sa.column("child_seq", sa.Integer), +) +_counters = sa.table("task_number_counters", sa.column("day", sa.String), sa.column("last_seq", sa.Integer)) + + +def _local_day(at) -> str: + if at.tzinfo is None: + at = at.replace(tzinfo=timezone.utc) + return f"{at.astimezone():%Y%m%d}" + + +def _update(bind, table, key, values_by_key, **columns) -> None: + statement = table.update().where(table.c[key] == sa.bindparam("_key")).values( + **{name: sa.bindparam(f"_{name}") for name in columns} + ) + batch = [ + {"_key": row_key, **{f"_{name}": values[row_key] for name, values in columns.items()}} + for row_key in values_by_key + ] + for start in range(0, len(batch), _BATCH): + bind.execute(statement, batch[start:start + _BATCH]) + + +def _backfill(bind) -> None: + run_no, run_by_message, per_day = {}, {}, Counter() + for run_id, message_id, created_at in bind.execute( + sa.select(_runs.c.run_id, _runs.c.message_id, _runs.c.created_at) + .order_by(_runs.c.created_at, _runs.c.run_id) + ): + day = _local_day(created_at) + per_day[day] += 1 + run_no[run_id] = f"RW-{day}-{per_day[day]:04d}" + run_by_message[message_id] = run_id + if per_day: + bind.execute(_counters.insert(), [{"day": d, "last_seq": n} for d, n in per_day.items()]) + + log_no, log_seq, run_seq = {}, defaultdict(int), defaultdict(int) + for log_id, message_id, kind, parent_id in bind.execute( + sa.select(_logs.c.id, _logs.c.message_id, _logs.c.subagent_type, _logs.c.parent_subagent_log_id) + .where(_logs.c.message_id.isnot(None)) + .order_by(_logs.c.created_at, _logs.c.id) + ): + run_id = run_by_message.get(message_id) + if run_id is None: + continue + if kind in _RUN_AGENT_TYPES: + log_no[log_id] = run_no[run_id] + elif parent_id in log_no: + log_seq[parent_id] += 1 + log_no[log_id] = f"{log_no[parent_id]}-{log_seq[parent_id]:02d}" + else: + run_seq[run_id] += 1 + log_no[log_id] = f"{run_no[run_id]}-{run_seq[run_id]:02d}" + + _update(bind, _runs, "run_id", run_no, task_no=run_no, subtask_seq=defaultdict(int, run_seq)) + _update(bind, _logs, "id", log_no, task_no=log_no, child_seq=defaultdict(int, log_seq)) + + +def upgrade_task_numbers(op) -> None: + op.create_table( + "task_number_counters", + sa.Column("day", sa.String(8), primary_key=True), + sa.Column("last_seq", sa.Integer(), nullable=False), + ) + with op.batch_alter_table("chat_runs") as batch: + batch.add_column(sa.Column("task_no", sa.String(32), nullable=True)) + batch.add_column(sa.Column("subtask_seq", sa.Integer(), nullable=False, server_default="0")) + with op.batch_alter_table("subagent_call_logs") as batch: + batch.add_column(sa.Column("task_no", sa.String(64), nullable=True)) + batch.add_column(sa.Column("child_seq", sa.Integer(), nullable=False, server_default="0")) + + _backfill(op.get_bind()) + + with op.batch_alter_table("chat_runs") as batch: + batch.alter_column("task_no", existing_type=sa.String(32), nullable=False) + batch.create_unique_constraint("uq_chat_runs_task_no", ["task_no"]) + op.create_index("ix_subagent_call_logs_task_no", "subagent_call_logs", ["task_no"]) + + +def downgrade_task_numbers(op) -> None: + op.drop_index("ix_subagent_call_logs_task_no", table_name="subagent_call_logs") + with op.batch_alter_table("subagent_call_logs") as batch: + batch.drop_column("child_seq") + batch.drop_column("task_no") + with op.batch_alter_table("chat_runs") as batch: + batch.drop_constraint("uq_chat_runs_task_no", type_="unique") + batch.drop_column("subtask_seq") + batch.drop_column("task_no") + op.drop_table("task_number_counters") diff --git a/src/backend/core/files/artifacts/reader.py b/src/backend/core/files/artifacts/reader.py index 7c04def2..5107d38e 100644 --- a/src/backend/core/files/artifacts/reader.py +++ b/src/backend/core/files/artifacts/reader.py @@ -13,12 +13,6 @@ logger = logging.getLogger(__name__) -# Maximum parsed-text preview injected automatically for a newly attached file. -# Full content remains available through read_artifact and is never placed in -# the request body by the frontend. -ATTACHMENT_PREVIEW_MAX_CHARS = 5_000 - - # Canonical source values stored in Artifact.extra_data["source"]. SOURCE_USER_UPLOAD = "user_upload" SOURCE_AI_GENERATED = "ai_generated" diff --git a/src/backend/core/files/content/content_blocks_constants.py b/src/backend/core/files/content/content_blocks_constants.py index c46a3a41..60288760 100644 --- a/src/backend/core/files/content/content_blocks_constants.py +++ b/src/backend/core/files/content/content_blocks_constants.py @@ -103,6 +103,8 @@ "content_label": "内容管理", "config_label": "系统配置", "apidoc_label": "接口文档", + # Browser tab icon of the admin pages; empty = follow branding.favicon_url. + "favicon_url": "", }, # 与前端 utils/pageConfigDefaults.ts 的 DEFAULT_SIDEBAR_ITEMS / DEFAULT_MENU_ITEMS 保持一致。 # 这只是新装环境的出厂值——已存在的部署以 DB 现有取值为准,由 backfill_navigation_entries @@ -124,13 +126,6 @@ "dialog_logout_confirm_ok": "退出登录", "recommend_banner_text": "", }, - "defaults": { - # Initial mode on every user login / new chat: - # - chat_mode: 'turbo' / 'fast' / 'medium' / 'high' / 'max' (recommended) - # - thinking_mode: bool (legacy field, fallback compat; true→medium, false→fast) - "chat_mode": "fast", - "thinking_mode": False, - }, "auth": { # Whether the login page shows the "register" entry (tab + register subpage). # When off, the login page keeps only login, and backend register submissions diff --git a/src/backend/core/harness/log_records.py b/src/backend/core/harness/log_records.py index 66dc0eda..2fe60128 100644 --- a/src/backend/core/harness/log_records.py +++ b/src/backend/core/harness/log_records.py @@ -1,9 +1,11 @@ """Synchronous writers for tool, sub-agent and skill call logs.""" from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, Optional +from sqlalchemy import select, update from sqlalchemy.orm import Session from core.db.engine import SessionLocal -from core.db.models import SkillCallLog, SubAgentCallLog, ToolCallLog, ToolEffectLedger +from core.db.models import ChatRun, SkillCallLog, SubAgentCallLog, ToolCallLog, ToolEffectLedger +from core.db.models.chat_runs import subtask_number from core.infra.data_masking import mask_sensitive_data from core.infra.logging import get_logger from core.harness import log_context @@ -87,6 +89,42 @@ def write_tool_call_sync(record: Dict[str, Any]) -> None: db.close() +# Agents that are the run itself (a plan's coordinator, a dedicated agent chat), not a sub-task. +RUN_AGENT_TYPES = frozenset({"plan_mode", "user_agent_direct"}) + + +def _reserve_subtask_no(db: Session, record: Dict[str, Any]) -> Optional[str]: + """Number a sub-agent call under whoever dispatched it, in one atomic UPDATE. + + A sub-agent called by another sub-agent is numbered under that sub-agent; otherwise it is + numbered under the run. Agents that are the run itself carry the run's own number. + """ + run_id = record.get("run_id") + if record.get("subagent_type") in RUN_AGENT_TYPES: + if not run_id: + return None + return db.execute(select(ChatRun.task_no).where(ChatRun.run_id == run_id)).scalar_one_or_none() + row = None + parent_id = record.get("parent_subagent_log_id") + if parent_id: + logs = SubAgentCallLog.__table__ + row = db.execute( + update(logs) + .where(logs.c.id == parent_id, logs.c.task_no.is_not(None)) + .values(child_seq=logs.c.child_seq + 1) + .returning(logs.c.task_no, logs.c.child_seq) + ).first() + if row is None and run_id: + runs = ChatRun.__table__ + row = db.execute( + update(runs) + .where(runs.c.run_id == run_id) + .values(subtask_seq=runs.c.subtask_seq + 1) + .returning(runs.c.task_no, runs.c.subtask_seq) + ).first() + return subtask_number(row[0], row[1]) if row else None + + def write_subagent_create_sync(record: Dict[str, Any]) -> None: if not log_writer_constants.SUBAGENT_LOG_ENABLED: return @@ -112,6 +150,7 @@ def write_subagent_create_sync(record: Dict[str, Any]) -> None: input_messages=input_safe, status="running", parent_subagent_log_id=record.get("parent_subagent_log_id"), + task_no=_reserve_subtask_no(db, record), started_at=record.get("started_at") or log_context.now_utc(), created_at=log_context.now_utc(), ) diff --git a/src/backend/core/harness/log_writer.py b/src/backend/core/harness/log_writer.py index a4ccb852..08effa2d 100644 --- a/src/backend/core/harness/log_writer.py +++ b/src/backend/core/harness/log_writer.py @@ -3,7 +3,7 @@ import asyncio from typing import Any, Dict, List, Optional from core.infra.background import spawn -from core.infra.logging import get_logger +from core.infra.logging import get_logger, run_id_var from core.harness import log_audit from core.harness import log_context from core.harness import log_records @@ -51,6 +51,7 @@ async def start_subagent_log(record: Dict[str, Any]) -> str: record.setdefault("chat_id", ctx["chat_id"]) record.setdefault("message_id", log_context.current_message_id.get()) record.setdefault("parent_subagent_log_id", log_context.current_subagent_log_id()) + record.setdefault("run_id", run_id_var.get()) # numbers the call under its run's task number record.setdefault("started_at", log_context.now_utc()) try: await asyncio.to_thread(log_records.write_subagent_create_sync, record) diff --git a/src/backend/core/harness/request_links.py b/src/backend/core/harness/request_links.py new file mode 100644 index 00000000..43529b91 --- /dev/null +++ b/src/backend/core/harness/request_links.py @@ -0,0 +1,61 @@ +"""Which HTTP requests acted on a run: the bridge from a run_id to request trace_ids. + +A run outlives the request that started it and is later steered, answered or cancelled by +other requests, each with its own trace_id. Every such request appends one ``request`` event +to the run's append-only event log, so a run's execution trace can list the trace_ids to look +up in the application log. Code running inside a run's own worker task is not a request and +never records a link, even though the task inherited the starting request's context. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from sqlalchemy.orm import Session + +from core.db.models import HarnessEventLog +from core.db.threads import run_db +from core.harness.events import Event +from core.harness.ledger import DurableEventStore +from core.infra.logging import get_logger, run_id_var, trace_id_var + +logger = get_logger(__name__) + +REQUEST_EVENT = "request" + + +def current_request_trace() -> str: + """The trace_id of the HTTP request being served; empty inside a run's worker task.""" + return "" if run_id_var.get() else trace_id_var.get() + + +async def link_request(run_id: str, action: str, *, trace_id: Optional[str] = None) -> None: + """Record that the current request (or ``trace_id``) performed ``action`` on ``run_id``. + + Best effort: a missing link only costs the operator a log lookup, so it never fails the + request that acted on the run. + """ + trace_id = current_request_trace() if trace_id is None else trace_id + if not run_id or not trace_id: + return + event = Event.create( + run_id=run_id, event_type=REQUEST_EVENT, phase="request", + payload={"trace_id": trace_id, "action": action}, + ) + try: + await run_db(DurableEventStore(), event) + except Exception as exc: # noqa: BLE001 + logger.warning("run_request_link_failed", run_id=run_id, action=action, error=str(exc)) + + +def list_request_links(db: Session, run_id: str) -> List[Dict[str, Any]]: + rows = ( + db.query(HarnessEventLog.payload, HarnessEventLog.created_at) + .filter(HarnessEventLog.run_id == run_id, HarnessEventLog.event_type == REQUEST_EVENT) + .order_by(HarnessEventLog.event_seq) + .all() + ) + return [ + {"trace_id": payload.get("trace_id"), "action": payload.get("action"), "at": created_at} + for payload, created_at in rows + ] diff --git a/src/backend/core/infra/logging.py b/src/backend/core/infra/logging.py index 391e76ee..b6106c9c 100644 --- a/src/backend/core/infra/logging.py +++ b/src/backend/core/infra/logging.py @@ -44,6 +44,9 @@ def mask_log_data(data): trace_id_var: ContextVar[str] = ContextVar("trace_id", default="") user_id_var: ContextVar[str] = ContextVar("user_id", default="") chat_id_var: ContextVar[str] = ContextVar("chat_id", default="") +# Set for the lifetime of a run's worker task (core.runs.runtime.register_run_task), so every log +# line a run produces can be found by its run_id; trace_id stays the id of one HTTP request. +run_id_var: ContextVar[str] = ContextVar("run_id", default="") _QUIET_ACCESS_LOG = "__quiet_access_log__" @@ -77,14 +80,11 @@ def add_trace_id(logger: Any, method_name: str, event_dict: EventDict) -> EventD def add_user_context(logger: Any, method_name: str, event_dict: EventDict) -> EventDict: - """Add user_id and chat_id to log event if available.""" - user_id = user_id_var.get() - chat_id = chat_id_var.get() - - if user_id: - event_dict["user_id"] = user_id - if chat_id: - event_dict["chat_id"] = chat_id + """Add user_id, chat_id and run_id to log event if available.""" + for key, var in (("user_id", user_id_var), ("chat_id", chat_id_var), ("run_id", run_id_var)): + value = var.get() + if value: + event_dict.setdefault(key, value) return event_dict diff --git a/src/backend/core/llm/context/attachment_hook.py b/src/backend/core/llm/context/attachment_hook.py index b9c71d19..09714812 100644 --- a/src/backend/core/llm/context/attachment_hook.py +++ b/src/backend/core/llm/context/attachment_hook.py @@ -59,7 +59,7 @@ async def __call__(self, invocation: Invocation) -> Decision: text = file_context.build_historical_files_context(historical) if text: items.append(payload(text, kind="project_material", origin="workspace:historical_files", trust="workspace")) - text = await asyncio.to_thread(file_context.build_file_context, uploaded, user_id=user_id) if uploaded else "" + text = file_context.build_file_context(uploaded) if uploaded else "" if text: items.append(payload(text, kind="attachment", origin="user:uploaded_files", trust="user")) images = [file for file in uploaded if artifact_bytes.is_image(file)] diff --git a/src/backend/core/llm/runtime/agentscope_hook_adapter.py b/src/backend/core/llm/runtime/agentscope_hook_adapter.py index 25576bac..32820cb8 100644 --- a/src/backend/core/llm/runtime/agentscope_hook_adapter.py +++ b/src/backend/core/llm/runtime/agentscope_hook_adapter.py @@ -11,7 +11,7 @@ from core.harness.hooks import HookBus, HookStage, Invocation from core.llm.runtime.model_steps import record_assistant_step from core.models.reasoning_replay import stamp_reasoning_origin -from core.llm.runtime.context_hook_adapter import attachment_input, begin_context_turn, append_hook_context, apply_reasoning_hooks +from core.llm.runtime.context_hook_adapter import attachment_input, begin_context_turn, append_hook_context, apply_reasoning_hooks, record_hook_context from core.llm.runtime.model_selection_adapter import apply_model_selection, model_selection_input from core.harness.ledger import DurableEventStore, HarnessUsageLedger @@ -241,7 +241,9 @@ async def on_reply(self, agent: Any, input_kwargs: dict, next_handler): state.context = _restore_context( getattr(state, "context", None), transformed.data["context"] ) - append_hook_context(agent, transformed) + await record_hook_context( + agent, append_hook_context(agent, transformed), before_input=True + ) from core.llm.runtime.context_hook_adapter import finish_context_hooks while True: final_output = None diff --git a/src/backend/core/llm/runtime/context_hook_adapter.py b/src/backend/core/llm/runtime/context_hook_adapter.py index 9989a268..4f782cfb 100644 --- a/src/backend/core/llm/runtime/context_hook_adapter.py +++ b/src/backend/core/llm/runtime/context_hook_adapter.py @@ -20,17 +20,49 @@ def begin_context_turn(): def append_hook_context(agent, invocation): + appended = [] for item in invocation.data.get("context_items", ()): item = thaw_value(item) sequence = next_context_sequence(agent.state.context) origin = item["origin"] - append_state_context_item(agent.state, ContextItem.create( + context_item = ContextItem.create( item_id=f"{origin}:{sequence}", kind=item["kind"], origin=origin, trust=item["trust"], visibility="model", priority=item["priority"], token_budget=item["token_budget"], truncation_policy=item.get("truncation_policy", POLICY_HEAD_TAIL), content=item["content"], cache_class="dynamic", created_seq=sequence, render_role="user", message_group=f"{origin}:{sequence}", - )) + ) + append_state_context_item(agent.state, context_item) + appended.append(context_item) + return appended + + +# A steer is persisted as its own user row by the chat runtime; recording the +# injection as well would replay the instruction twice. +_NOT_RECORDED_KINDS = frozenset({"steer"}) + + +async def record_hook_context(agent, items, *, before_input): + """Keep injected blocks in the turn's step record so the next turn replays them. + + Only the reply and reasoning stages call this: their blocks sit at a clear + position (before the user's message / before a model call). Tool-stage + injections can land between a call and its result and are not replayed. + """ + import asyncio + from core.llm.runtime.model_steps import record_context_step + + ledger = getattr(getattr(agent, "state", None), "pending_model_steps", None) + if ledger is None: + return + for item in items: + if item.kind in _NOT_RECORDED_KINDS: + continue + if isinstance(item.content, (list, tuple)): # may carry media → blob table write + step = await asyncio.to_thread(record_context_step, item, before_input=before_input) + else: + step = record_context_step(item, before_input=before_input) + ledger.append(step) async def apply_reasoning_hooks(adapter, agent, input_kwargs, plain, restore_context): @@ -49,7 +81,7 @@ async def apply_reasoning_hooks(adapter, agent, input_kwargs, plain, restore_con ) if transformed.data.get("context") is not None: state.context = restore_context(state.context, transformed.data["context"]) - append_hook_context(agent, transformed) + await record_hook_context(agent, append_hook_context(agent, transformed), before_input=False) await adapter._stage(HookStage.AFTER_CONTEXT, agent, operation_name="agent_reasoning", data={"steer_delivery": transformed.data.get("steer_delivery")}) if transformed.data.get("steer_delivery") is not None: diff --git a/src/backend/core/llm/runtime/file_context.py b/src/backend/core/llm/runtime/file_context.py index 8f0c26fe..fd54c819 100644 --- a/src/backend/core/llm/runtime/file_context.py +++ b/src/backend/core/llm/runtime/file_context.py @@ -1,4 +1,4 @@ -"""Turn file attachments into context: text, images and spreadsheet previews.""" +"""Turn file attachments into context: file handles for documents, base64 for images.""" from __future__ import annotations from core.files import artifact_bytes import base64 @@ -26,181 +26,42 @@ def fetch_image_base64( return base64.b64encode(raw).decode("utf-8"), mime_type -def is_xlsx(f: Dict[str, Any]) -> bool: - """Detect xlsx by mime_type or filename extension. +# Rough size of one attachment handle block, reported to the frontend so its +# context gauge estimates what the backend actually injects for a staged file. +ATTACHMENT_HANDLE_EST_CHARS = 300 - We only special-case xlsx (not xls/csv) because: - - xlsx is the dominant batch-execution upload format - - parse_xlsx_preview uses openpyxl, same engine as the batch path - - xls would need a separate xlrd-based preview helper (out of scope) - """ - mime = (f.get("mime_type") or "").lower() - if "spreadsheetml.sheet" in mime: - return True - name = (f.get("name") or f.get("filename") or "").lower() - return name.endswith(".xlsx") +def build_file_context(uploaded_files: List[Dict[str, Any]]) -> str: + """Describe the turn's non-image attachments as handles: name, MIME and ``file_id``. -def build_xlsx_preview_block( - f: Dict[str, Any], - user_id: Optional[str] = None, -) -> Optional[str]: - """Build the bounded preview + metadata + directive block for a single xlsx. - - Returns None if the file can't be read (caller falls back to the regular - parsed_text path, so the agent at least sees *something*). - """ - from core.files.content.spreadsheet_parser import parse_xlsx_preview - - file_id = f.get("file_id") or "" - name = f.get("name") or f.get("filename") or "未命名表格" - if not file_id: - return None - - file_bytes = artifact_bytes.download_artifact_bytes( - file_id, - name, - "xlsx preview", - user_id=user_id, - ) - if file_bytes is None: - return None - - try: - from core.files.artifacts.reader import ATTACHMENT_PREVIEW_MAX_CHARS, fetch_parsed_text - - # Reuse the bytes already downloaded for the structural preview to - # populate parsed_text + summary without a second storage round-trip. - fetch_parsed_text(file_id, user_id=user_id, prefetched_bytes=file_bytes) - info = parse_xlsx_preview(file_bytes, char_budget=ATTACHMENT_PREVIEW_MAX_CHARS) - except RuntimeError as e: - logger.warning(f"xlsx preview: parse failed for {file_id}: {e}") - return None - except Exception as e: - logger.warning(f"xlsx preview: unexpected parse error for {file_id}: {e}") - return None - - total_rows = info["total_rows"] - total_cols = info["total_columns"] - preview_rows = info["preview_rows"] - omitted = max(0, total_rows - preview_rows) - cols_str = ", ".join(info["header"]) if info["header"] else "(无列名)" - - lines: List[str] = [ - f"[文件: {name}]", - f" file_id: {file_id}", - f" 总规模: {total_rows} 行 × {total_cols} 列", - f" 列名: [{cols_str}]", - f" 已展示: 前 {preview_rows} 行(约 {len(info['preview_md'])} 字符,达到预览预算)", - ] - if info.get("truncated_columns"): - lines.append(f" 注意: 共 {total_cols} 列,预览仅含前 {len(info['header'])} 列") - if info.get("truncated_cells"): - lines.append(f" 注意: {info['truncated_cells']} 个超长单元格已截断到 500 字以内") - lines.append("") - lines.append(info["preview_md"]) - if omitted: - lines.append(f"... (剩余 {omitted} 行未展示) ...") - lines.append("") - lines.append("[操作指引]") - lines.append( - f"- 需要预览之外的正文时,调用 read_artifact(file_id='{file_id}', " - "offset=N, limit=4000) 按需读取" - ) - lines.append("- 以上只是有界预览,不要把未展示的行当作不存在,也不要推断未读取内容") - return "\n".join(lines) - - -def build_file_context( - uploaded_files: List[Dict[str, Any]], - user_id: Optional[str] = None, -) -> str: - """Assemble the text-attachment list into the context text injected into the model (non-image files only). - - Files are resolved exclusively by ``file_id``. The backend lazily parses and - caches the artifact, then injects only metadata plus a bounded preview. The - request body never supplies parsed content. - - ``user_id`` is used as the ownership check when fetching ``fetch_parsed_text`` / - the xlsx preview, preventing a forged request-body ``attachments[].file_id`` - from reading across users — see the :func:`artifact_bytes.download_artifact_bytes` docstring - for the core defense point. + No file is downloaded or parsed here, so attaching a large PDF adds nothing + to time-to-first-token. The model reads content on demand through + ``read_artifact``, which owns parsing, the ``parsed_text`` cache and the + ``user_id`` ownership check. """ text_files = [f for f in uploaded_files if not artifact_bytes.is_image(f)] if not text_files: return "" - file_descriptions = [] - for f in text_files: - name = f.get("name", "未知文件") - fid = f.get("file_id", "") - desc = f"- {name}" - if fid: - desc += f" (file_id: {fid})" - file_descriptions.append(desc) - from core.files.artifacts.reader import ATTACHMENT_PREVIEW_MAX_CHARS, fetch_parsed_text - - file_content_parts: List[str] = [] + lines: List[str] = [] for f in text_files: - # ── xlsx: bounded preview + metadata + directive (no full content) ── - if is_xlsx(f): - block = build_xlsx_preview_block(f, user_id=user_id) - if block is not None: - file_content_parts.append(block) - continue - # Fall through to regular path on preview failure so the agent - # at least sees the parsed markdown (degraded but non-fatal). - + name = f.get("name") or f.get("filename") or "未知文件" fid = (f.get("file_id") or "").strip() - mime_type = f.get("mime_type") or "" + mime_type = f.get("mime_type") or "(未知)" if not fid: - file_content_parts.append( - f"[文件: {f.get('name', '未知文件')}]\n" - " 状态: 缺少 file_id,无法读取正文" - ) - continue - - logger.info("Document hook: loading preview for %s (%s)", f.get("name"), fid) - content = fetch_parsed_text(fid, user_id=user_id) - if not content: - file_content_parts.append( - f"[文件: {f.get('name', '未知文件')}]\n" - f" file_id: {fid}\n" - f" MIME: {mime_type or '(未知)'}\n" - " 状态: 文件不可访问或解析失败" - ) + lines.append(f"- {name} (MIME: {mime_type}) [缺少 file_id,无法读取]") continue - - total_chars = len(content) - preview = content[:ATTACHMENT_PREVIEW_MAX_CHARS] - truncated = total_chars > len(preview) - block = [ - f"[文件: {f.get('name', '未知文件')}]", - f" file_id: {fid}", - f" MIME: {mime_type or '(未知)'}", - f" 解析文本: 共 {total_chars} 字符,本轮自动展示 {len(preview)} 字符", - "", - preview, - ] - if truncated: - block.append(f"... (其余 {total_chars - len(preview)} 字符未展示) ...") - block.extend( - [ - "", - "[读取指引]", - f"- 需要未展示的正文时,调用 read_artifact(file_id='{fid}', offset=N, limit=4000)", - "- 不要推断尚未读取的内容", - ] - ) - file_content_parts.append("\n".join(block)) - file_content = "\n\n---\n\n".join(file_content_parts) + lines.append(f"- {name} (file_id: {fid}, MIME: {mime_type})") return ( - f"[file name]: {chr(10).join(file_descriptions)}\n" - f"[file content begin]\n" - f"{file_content}\n" - f"[file content end]\n" - "以上是用户附件的元数据和有界预览;需要预览之外的正文时必须按需调用 read_artifact。" + "[用户上传的附件](本轮只提供文件元数据,正文尚未读取)\n" + + "\n".join(lines) + + "\n\n[读取指引]\n" + "- 需要文件内容时调用 read_artifact(file_id, offset=0, limit=4000) 读取解析后的文本," + "按返回的 next_offset 继续分页\n" + "- xlsx 先看返回的 sheet_names 再用 sheet_name 读单表;pptx 用 slide_index 读单页\n" + "- 调用 read_artifact 之前不要声称已读过附件,也不要推断其内容;" + "与附件无关的问题可以不读" ) diff --git a/src/backend/core/llm/runtime/interaction_store.py b/src/backend/core/llm/runtime/interaction_store.py index de3911c9..4264fb56 100644 --- a/src/backend/core/llm/runtime/interaction_store.py +++ b/src/backend/core/llm/runtime/interaction_store.py @@ -14,7 +14,9 @@ import time from urllib.parse import quote +from core.harness.request_links import link_request from core.infra.ephemeral import get_ephemeral_state +from core.infra.logging import run_id_var _PREFIX = "human-interaction:v1:" _POLL = 0.2 @@ -37,7 +39,8 @@ def _live(record): async def register(kind, chat_id, request_id, info, timeout): now = time.time() record = dict( - info=info, created_at=now, expires_at=now + timeout, lease_until=now + _LEASE, decision=None + info=info, created_at=now, expires_at=now + timeout, lease_until=now + _LEASE, decision=None, + run_id=run_id_var.get(), # registered by the waiting tool, inside its run's worker task ) await get_ephemeral_state().put( _key(kind, chat_id, request_id), json.dumps(record), ttl=_ttl(record) @@ -86,6 +89,7 @@ async def decide(kind, chat_id, request_id, decision): return False record["decision"] = decision if await store.compare_exchange(key, raw, json.dumps(record), ttl=_ttl(record)): + await link_request(record.get("run_id") or "", f"answer:{kind}") return True diff --git a/src/backend/core/llm/runtime/model_steps.py b/src/backend/core/llm/runtime/model_steps.py index f220198e..e9a2a3a8 100644 --- a/src/backend/core/llm/runtime/model_steps.py +++ b/src/backend/core/llm/runtime/model_steps.py @@ -4,7 +4,8 @@ columns (``content`` / ``thinking`` / ``tool_calls`` / ``metadata.segments``) serve the chat UI. ``model_steps`` is the record the model's own history is rebuilt from: one entry per model response with the exact blocks it returned, -and one entry per tool result as it entered the context. Replaying it yields +one entry per tool result as it entered the context, and one entry per block a +hook injected into the context (attachment previews, reminders). Replaying it yields the same message sequence the agent held in memory, so a later turn — or a resumed one — sends the model what it actually said, step by step, instead of a reassembly guessed from three separate columns. @@ -20,7 +21,10 @@ STEP_SCHEMA = "harness.steps.v1" STEP_ASSISTANT = "assistant" STEP_TOOL_RESULT = "tool_result" +STEP_CONTEXT = "context" STATE_INTERRUPTED = "interrupted" +# Replay-only marker on a context row that preceded the user's message live. +BEFORE_INPUT_KEY = "_before_input" _CANCELLED_TURN_MARKER = "[本轮回答被用户中断]" _INTERRUPTED_RESULT = "[tool call was interrupted before it returned a result]" @@ -148,6 +152,61 @@ def record_tool_result_step(block: Any) -> Dict[str, Any]: } +def record_context_step(item: Any, *, before_input: bool) -> Dict[str, Any]: + """One hook-injected context item, as the model saw it. + + Without this record the next turn rebuilds history without the block, so its + request diverges from the cached prefix right there and everything after it + is billed again. ``before_input`` marks items added in ``on_reply``, which + AgentScope places ahead of the user's message. Media goes to the blob table + like tool media, so the caller runs this off the event loop when it has any. + """ + from core.infra.immutable import thaw_json + from core.llm.context.items import session_context_metadata + + content = thaw_json(item.content) + if isinstance(content, Mapping): + content = [content] + blocks = ( + [{"type": "text", "text": str(content or "")}] + if not isinstance(content, list) + else _persistable_output(content) + ) + return { + "schema": STEP_SCHEMA, + "kind": STEP_CONTEXT, + "before_input": bool(before_input), + "context_item": session_context_metadata(item), + "blocks": blocks, + } + + +def _context_row(step: Mapping[str, Any], blocks: List[Dict[str, Any]]) -> Dict[str, Any]: + from core.llm.context.ir_constants import SESSION_CONTEXT_META_KEY + + texts = [b for b in blocks if b.get("type") == "text"] + # A text-only item rendered live as one text block; a plain string replays to the same. + content: Any = texts[0]["text"] if len(blocks) == 1 and texts else blocks + row: Dict[str, Any] = {"role": "user", "content": content} + if isinstance(step.get("context_item"), Mapping): + row[SESSION_CONTEXT_META_KEY] = dict(step["context_item"]) + if step.get("before_input"): + row[BEFORE_INPUT_KEY] = True + return row + + +def split_before_input( + rows: Sequence[Mapping[str, Any]], +) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Separate the rows that preceded the user's message live from the rest.""" + before: List[Dict[str, Any]] = [] + after: List[Dict[str, Any]] = [] + for row in rows: + row = dict(row) + (before if row.pop(BEFORE_INPUT_KEY, False) else after).append(row) + return before, after + + def _ordered_result_steps( steps: Sequence[Mapping[str, Any]], *, state: str, missing_output: str ) -> List[Dict[str, Any]]: @@ -287,6 +346,7 @@ def replay_rows( steps: Sequence[Mapping[str, Any]], *, cancelled: bool = False, + include_context: bool = True, ) -> List[Dict[str, Any]]: """Rebuild the model-facing rows of one assistant message from its record. @@ -321,6 +381,9 @@ def replay_rows( rows[-1]["content"].extend(blocks) else: rows.append({"role": "tool", "content": blocks}) + elif kind == STEP_CONTEXT: + if include_context: + rows.append(_context_row(step, blocks)) else: raise ValueError(f"unknown model step kind {kind!r}") if cancelled: @@ -335,10 +398,11 @@ def assistant_row_replay( content: str, model_steps: Any, cancelled: bool = False, + include_context: bool = True, ) -> List[Dict[str, Any]]: """Replay canonical steps, or the body of an unstructured text message.""" if isinstance(model_steps, list) and model_steps: - return replay_rows(model_steps, cancelled=cancelled) + return replay_rows(model_steps, cancelled=cancelled, include_context=include_context) blocks = [{"type": "text", "text": content}] if content else [] if cancelled: _append_cancel_marker(blocks) @@ -387,15 +451,19 @@ def explode_history_rows(history: Sequence[Mapping[str, Any]]) -> List[Dict[str, __all__ = [ + "BEFORE_INPUT_KEY", "STATE_INTERRUPTED", "STEP_ASSISTANT", + "STEP_CONTEXT", "STEP_SCHEMA", "STEP_TOOL_RESULT", "assistant_row_replay", "close_dangling_calls", "explode_history_rows", "record_assistant_step", + "record_context_step", "record_tool_result_step", "replace_final_text", "replay_rows", + "split_before_input", ] diff --git a/src/backend/core/llm/runtime/tool_media_store.py b/src/backend/core/llm/runtime/tool_media_store.py index a1f12fc3..3974914b 100644 --- a/src/backend/core/llm/runtime/tool_media_store.py +++ b/src/backend/core/llm/runtime/tool_media_store.py @@ -169,9 +169,13 @@ def _media_blocks(rows: Iterable[Any]) -> List[Mapping[str, Any]]: """历史行里所有的媒体引用块。""" found: List[Mapping[str, Any]] = [] for row in rows: - if not isinstance(row, Mapping): + if not isinstance(row, Mapping) or not isinstance(row.get("content"), list): continue for block in row.get("content") or []: + # 钩子注入在用户消息前的图片(原生视觉附件),直接挂在行内容上。 + if is_stored(block): + found.append(block) + continue if not isinstance(block, Mapping) or block.get("type") != "tool_result": continue output = block.get("output") @@ -221,7 +225,9 @@ def resolved(block: Mapping[str, Any]) -> Dict[str, Any]: continue new_blocks = [] for block in blocks: - if isinstance(block, Mapping) and block.get("type") == "tool_result": + if is_stored(block): + block = resolved(block) + elif isinstance(block, Mapping) and block.get("type") == "tool_result": output = block.get("output") if isinstance(output, list): block = { diff --git a/src/backend/core/llm/tools/confirmation/gate.py b/src/backend/core/llm/tools/confirmation/gate.py index afeda91e..8256c7f8 100644 --- a/src/backend/core/llm/tools/confirmation/gate.py +++ b/src/backend/core/llm/tools/confirmation/gate.py @@ -115,8 +115,6 @@ def _claim_first(pd: state._Pending, decision: str) -> bool: cid, p, timeout=timeout, - expire_message="等待确认超时,已自动取消。如仍需要请重新发起。", - cancel_message="本次运行已取消,该确认已关闭。", in_lock_extract=_claim_first, ) if decision in (state.DECISION_ALLOW, state.DECISION_ALLOW_SESSION): @@ -217,8 +215,6 @@ def _reject_concurrent(st: state._ChatConfirm): cid, p, timeout=timeout, - expire_message="设计方案选择已超时,助手将自行选择方案继续。", - cancel_message="本次运行已取消,设计方案选择已关闭。", in_lock_extract=lambda pd, _d: pd.choice, ) if decision == state.DECISION_CHOICE and choice: diff --git a/src/backend/core/llm/tools/confirmation/pending.py b/src/backend/core/llm/tools/confirmation/pending.py index 3069093f..490f137c 100644 --- a/src/backend/core/llm/tools/confirmation/pending.py +++ b/src/backend/core/llm/tools/confirmation/pending.py @@ -84,8 +84,6 @@ async def _wait_pending( p: state._Pending, *, timeout: float, - expire_message: str, - cancel_message: str, in_lock_extract=None, ) -> Tuple[str, Any]: """Resolve, claim execution and release the last waiter under one state lock. @@ -145,8 +143,9 @@ async def _wait_pending( "logical_path": p.logical_path, "kind": p.kind, "expired": True, - "resolved": not cancelled and decision != state._DECISION_TIMEOUT, - "message": cancel_message if cancelled else expire_message, + "outcome": "cancelled" if cancelled else ( + "timeout" if decision == state._DECISION_TIMEOUT else "decided" + ), } if expire_q is not None and expire_payload is not None: try: diff --git a/src/backend/core/runs/binding.py b/src/backend/core/runs/binding.py index 53455534..b1095f5c 100644 --- a/src/backend/core/runs/binding.py +++ b/src/backend/core/runs/binding.py @@ -10,6 +10,7 @@ from core.db.engine import SessionLocal from core.db.models import ChatRun, ChatSession from core.db.threads import run_db +from core.infra.logging import run_id_var from core.runs.journal_domain import DurableRunBinding, RunAlreadyExists, RunJournalError, RunLeaseLost from sqlalchemy.orm import sessionmaker @@ -139,6 +140,8 @@ async def _heartbeat() -> None: heartbeat = asyncio.create_task(_heartbeat()) from core.llm.context.safety import request_admission_scope + # The bound work runs in this very task: scope it to the run like a registered run task. + run_scope = run_id_var.set(run_id) with request_admission_scope( context={"run_id": run_id, "journal_owner": owner}, ) as admission: @@ -189,6 +192,7 @@ async def _heartbeat() -> None: else: await run_db(journal.complete, run_id, owner=owner, status="completed") finally: + run_id_var.reset(run_scope) heartbeat.cancel() with suppress(asyncio.CancelledError): await heartbeat diff --git a/src/backend/core/runs/runtime.py b/src/backend/core/runs/runtime.py index 76790c07..b7beaa44 100644 --- a/src/backend/core/runs/runtime.py +++ b/src/backend/core/runs/runtime.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextvars import datetime as _datetime import typing as _typing from typing import Any, Awaitable, Callable, List, Optional, Tuple @@ -17,7 +18,9 @@ import core.runs.tasks as tasks from core.db.models import ChatRun from core.db.threads import run_db -from core.infra.logging import get_logger +from core.harness.request_links import current_request_trace, link_request +from core.infra.background import spawn +from core.infra.logging import get_logger, run_id_var logger = get_logger(__name__) @@ -31,12 +34,20 @@ class ChatRunPermissionDenied(Exception): def register_run_task(run_id: str, coro: Awaitable[None], *, name: str) -> None: - """Spawn the worker coroutine, register it in _active_runs, auto-cleanup on done.""" + """Spawn the worker coroutine, register it in _active_runs, auto-cleanup on done. + + The task runs with ``run_id`` bound for logging. The request that started it is linked + off the run's path, so the link never delays the first model call. + """ active = tasks.process_state().active_runs if run_id in active and not active[run_id].done(): coro.close() return - task = asyncio.create_task(coro, name=name) + context = contextvars.copy_context() + context.run(run_id_var.set, run_id) + task = asyncio.create_task(coro, name=name, context=context) + if current_request_trace(): + spawn(link_request(run_id, "start"), name=f"run_request_link:{run_id}") tasks.process_state().active_runs[run_id] = task def finished(worker): @@ -166,6 +177,7 @@ async def cancel_run( ) if run is None: return False + await link_request(run_id, "cancel") payload = run.request_payload if isinstance(run.request_payload, dict) else {} cooperative_only = payload.get("kind") in settings._COOPERATIVE_KINDS diff --git a/src/backend/orchestration/plans/mode.py b/src/backend/orchestration/plans/mode.py index ffb885c9..1bc7a476 100644 --- a/src/backend/orchestration/plans/mode.py +++ b/src/backend/orchestration/plans/mode.py @@ -370,14 +370,11 @@ def _build_plan_generation_messages( return messages -def _build_file_context( - uploaded_files: List[Dict[str, Any]], - user_id: Optional[str] = None, -) -> str: - """Reuse the main-chat file_id-based lazy preview pipeline.""" - from core.llm.runtime.file_context import build_file_context as build_lazy_file_context +def _build_file_context(uploaded_files: List[Dict[str, Any]]) -> str: + """Reuse the main-chat file_id handle pipeline; content is read via read_artifact.""" + from core.llm.runtime.file_context import build_file_context as build_handle_context - return build_lazy_file_context(uploaded_files, user_id=user_id) + return build_handle_context(uploaded_files) def _generation_inputs( @@ -466,12 +463,7 @@ async def astream_generate_plan( ontology_runtime=ontology_runtime, ) - # Worker thread: building the context parses attachments, which on a cache miss - # is a blocking POST to the external file-parser service. Inline it would pin the - # event loop and silence every open SSE stream on the instance. - file_context = await asyncio.to_thread( - _build_file_context, uploaded_files or [], user_id=user_id - ) + file_context = _build_file_context(uploaded_files or []) # Prepare history from chat session. Plan rules, attachment evidence, # and the actual user task remain separate canonical candidates. history = await _prepare_history(session_messages or [], model_name) @@ -944,10 +936,7 @@ def _cancellation_requested() -> bool: agent.state.context.extend(session_to_msgs(prepared_history)) # Inject file context (content must be a list of blocks). - # Worker thread for the same reason as the plan-generation path above. - file_context = await asyncio.to_thread( - _build_file_context, uploaded_files or [], user_id=user_id - ) + file_context = _build_file_context(uploaded_files or []) if file_context: append_context_text( agent, diff --git a/src/backend/personal_agent/conversation/front.py b/src/backend/personal_agent/conversation/front.py index e4bafc4b..22a88f00 100644 --- a/src/backend/personal_agent/conversation/front.py +++ b/src/backend/personal_agent/conversation/front.py @@ -196,8 +196,10 @@ def history(db, ident): elif row.role == "assistant": if extra.get("model_history_display_only"): continue + # This window truncates and merges rows, so it never matched the live + # request byte for byte; injected context rows would only add user turns. out.extend(assistant_row_replay( - content=row.content or "", model_steps=row.model_steps, + content=row.content or "", model_steps=row.model_steps, include_context=False, )) continue else: diff --git a/src/backend/tests/api/chat_fork_fixtures.py b/src/backend/tests/api/chat_fork_fixtures.py index 1f759cea..414c5f87 100644 --- a/src/backend/tests/api/chat_fork_fixtures.py +++ b/src/backend/tests/api/chat_fork_fixtures.py @@ -3,7 +3,7 @@ import pytest from core.auth.backend import UserContext, get_current_user from core.db.engine import Base, get_db -from core.db.models import ChatCompactionState, ChatMessage, ChatRun, ChatSession, Project +from core.db.models import ChatCompactionState, ChatMessage, ChatRun, ChatSession, Project, TaskNumberCounter from core.db.models.command import CommandTask from fastapi import FastAPI from fastapi.testclient import TestClient @@ -27,6 +27,7 @@ def fork_env(tmp_path, monkeypatch): ChatSession.__table__, ChatMessage.__table__, ChatRun.__table__, + TaskNumberCounter.__table__, ChatCompactionState.__table__, Project.__table__, CommandTask.__table__, diff --git a/src/backend/tests/api/test_agent_response_admission.py b/src/backend/tests/api/test_agent_response_admission.py index 78afac30..450f271a 100644 --- a/src/backend/tests/api/test_agent_response_admission.py +++ b/src/backend/tests/api/test_agent_response_admission.py @@ -22,7 +22,7 @@ from core.auth.backend import UserContext from core.chat.schemas import ChatRequest from core.db.engine import Base -from core.db.models import ChatMessage, ChatRun, ChatSession +from core.db.models import ChatMessage, ChatRun, ChatSession, TaskNumberCounter from core.chat.sequencer import ChatSequencer from fastapi import HTTPException from sqlalchemy import create_engine @@ -36,7 +36,7 @@ def _route_database(tmp_path, name="route.db"): ) Base.metadata.create_all( engine, - tables=[ChatSession.__table__, ChatMessage.__table__, ChatRun.__table__], + tables=[ChatSession.__table__, ChatMessage.__table__, ChatRun.__table__, TaskNumberCounter.__table__], ) Session = sessionmaker(bind=engine) with Session() as db: @@ -93,7 +93,7 @@ def test_concurrent_requests_have_one_durable_winner(monkeypatch, tmp_path, tran ) Base.metadata.create_all( engine, - tables=[ChatSession.__table__, ChatMessage.__table__, ChatRun.__table__], + tables=[ChatSession.__table__, ChatMessage.__table__, ChatRun.__table__, TaskNumberCounter.__table__], ) Session = sessionmaker(bind=engine) with Session() as db: diff --git a/src/backend/tests/api/test_chat_stream_sequencer.py b/src/backend/tests/api/test_chat_stream_sequencer.py index 3589d2e8..1b339c8a 100644 --- a/src/backend/tests/api/test_chat_stream_sequencer.py +++ b/src/backend/tests/api/test_chat_stream_sequencer.py @@ -19,7 +19,7 @@ import pytest from core.auth.backend import UserContext from core.db.engine import Base -from core.db.models import ChatMessage, ChatRun, ChatSession +from core.db.models import ChatMessage, ChatRun, ChatSession, TaskNumberCounter from core.chat.sequencer import ChatSequencer from fastapi import HTTPException from sqlalchemy import create_engine @@ -33,7 +33,7 @@ def _route_database(tmp_path, name="route.db"): ) Base.metadata.create_all( engine, - tables=[ChatSession.__table__, ChatMessage.__table__, ChatRun.__table__], + tables=[ChatSession.__table__, ChatMessage.__table__, ChatRun.__table__, TaskNumberCounter.__table__], ) Session = sessionmaker(bind=engine) with Session() as db: diff --git a/src/backend/tests/chat/test_stream_event_builders.py b/src/backend/tests/chat/test_stream_event_builders.py index 2cca620c..83b69b9a 100644 --- a/src/backend/tests/chat/test_stream_event_builders.py +++ b/src/backend/tests/chat/test_stream_event_builders.py @@ -13,8 +13,7 @@ build_tool_call_event, build_tool_call_start_event, build_tool_result_event, - build_user_question_event, - build_user_question_resolved_event, + build_interaction_event, ) @@ -161,8 +160,10 @@ def test_interrupted_tool_result_keeps_neutral_status(): def test_user_question_requested_and_resolved_wire_events(): - requested = build_user_question_event( + requested = build_interaction_event( + "user_question", { + "event": "requested", "request_id": "req-1", "questions": [{"id": "scope", "question": "范围?"}], "created_at": 10.0, @@ -179,8 +180,9 @@ def test_user_question_requested_and_resolved_wire_events(): "expires_at": 20.0, } - resolved = build_user_question_resolved_event( - {"request_id": "req-1", "outcome": "answered"}, + resolved = build_interaction_event( + "user_question_resolved", + {"event": "resolved", "request_id": "req-1", "outcome": "answered"}, "chat-1", ) assert resolved == { diff --git a/src/backend/tests/core/runs/test_request_links.py b/src/backend/tests/core/runs/test_request_links.py new file mode 100644 index 00000000..6eac028b --- /dev/null +++ b/src/backend/tests/core/runs/test_request_links.py @@ -0,0 +1,118 @@ +"""A run's log scope (run_id) and the HTTP requests (trace_ids) that acted on it.""" + +from __future__ import annotations + +import asyncio + +import core.harness.request_links as request_links +import core.runs.runtime as runtime +import pytest +from core.db.engine import Base +from core.harness.ledger import DurableEventStore +from core.infra.logging import add_user_context, run_id_var, trace_id_var +from core.llm.runtime import interaction_store +from core.runs.journal import RunJournal +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +RUN = "run-links" + + +@pytest.fixture() +def sessions(tmp_path, monkeypatch): + engine = create_engine( + f"sqlite:///{tmp_path / 'request-links.sqlite'}", connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + RunJournal(factory).accept( + run_id=RUN, message_id="msg-links", chat_id="chat-links", user_id="user-links", + request_payload={"kind": "chat"}, recovery_snapshot={"kind": "chat", "worker_args": {}}, + ) + monkeypatch.setattr(request_links, "DurableEventStore", lambda: DurableEventStore(factory)) + yield factory + engine.dispose() + + +def _links(factory): + with factory() as db: + return [(link["action"], link["trace_id"]) for link in request_links.list_request_links(db, RUN)] + + +async def _settle_links(): + pending = [t for t in asyncio.all_tasks() if t.get_name().startswith("run_request_link:")] + await asyncio.gather(*pending) + + +@pytest.mark.asyncio +async def test_run_task_logs_under_its_run_id_and_links_the_starting_request(sessions): + seen = {} + + async def worker(): + seen["run_id"] = run_id_var.get() + seen["request"] = request_links.current_request_trace() + + token = trace_id_var.set("trace-start") + try: + runtime.register_run_task(RUN, worker(), name=f"test:{RUN}") + await runtime.tasks.process_state().active_runs[RUN] + finally: + trace_id_var.reset(token) + await _settle_links() + + assert seen == {"run_id": RUN, "request": ""} # inside the run, nothing counts as a request + assert run_id_var.get() == "" # the caller's context is untouched + assert _links(sessions) == [("start", "trace-start")] + + +@pytest.mark.asyncio +async def test_run_started_without_a_request_links_nothing(sessions): + async def worker(): + return None + + runtime.register_run_task(RUN, worker(), name=f"test:{RUN}") # e.g. startup recovery + await runtime.tasks.process_state().active_runs[RUN] + await _settle_links() + assert _links(sessions) == [] + + +@pytest.mark.asyncio +async def test_answer_links_the_answering_request_to_the_run_that_asked(sessions): + async def ask(): + await interaction_store.register("question", "chat-links", "q1", {"questions": []}, 60) + + run_token = run_id_var.set(RUN) + try: + await asyncio.create_task(ask()) # registered from inside the run's worker task + finally: + run_id_var.reset(run_token) + + trace_token = trace_id_var.set("trace-answer") + try: + assert await interaction_store.decide("question", "chat-links", "q1", {"outcome": "answered"}) + finally: + trace_id_var.reset(trace_token) + await interaction_store.remove("question", "chat-links", "q1") + + assert _links(sessions) == [("answer:question", "trace-answer")] + + +@pytest.mark.asyncio +async def test_actions_taken_inside_a_run_are_not_linked(sessions): + run_token = run_id_var.set("some-other-run") + trace_token = trace_id_var.set("inherited-trace") + try: + await request_links.link_request(RUN, "cancel") + finally: + trace_id_var.reset(trace_token) + run_id_var.reset(run_token) + assert _links(sessions) == [] + + +def test_log_lines_carry_the_run_id_without_overriding_an_explicit_one(): + token = run_id_var.set(RUN) + try: + assert add_user_context(None, "info", {"event": "x"})["run_id"] == RUN + assert add_user_context(None, "info", {"event": "x", "run_id": "other"})["run_id"] == "other" + finally: + run_id_var.reset(token) diff --git a/src/backend/tests/core/runs/test_task_numbers.py b/src/backend/tests/core/runs/test_task_numbers.py new file mode 100644 index 00000000..98e77e36 --- /dev/null +++ b/src/backend/tests/core/runs/test_task_numbers.py @@ -0,0 +1,153 @@ +"""Task numbers: RW-YYYYMMDD-NNNN per run, RW-…-NN per sub-agent call, backfilled on upgrade.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import core.harness.log_records as log_records +import pytest +from core.db.engine import Base +from core.db.models import ChatRun, ChatSession, SubAgentCallLog, TaskNumberCounter +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +NOON = datetime(2026, 10, 8, 4, 0, tzinfo=timezone.utc) + + +def _day(at: datetime) -> str: + return f"{at.astimezone():%Y%m%d}" + + +@pytest.fixture() +def factory(tmp_path, monkeypatch): + engine = create_engine( + f"sqlite:///{tmp_path / 'task-numbers.sqlite'}", connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(engine) + sessions = sessionmaker(bind=engine, expire_on_commit=False) + with sessions() as db: + db.add(ChatSession(chat_id="chat-1", user_id="u1", title="t")) + db.commit() + monkeypatch.setattr(log_records, "SessionLocal", sessions) + monkeypatch.setattr(log_records.log_writer_constants, "SUBAGENT_LOG_ENABLED", True) + yield sessions + engine.dispose() + + +def _add_run(sessions, run_id: str, at: datetime = NOON) -> str: + with sessions() as db: + run = ChatRun(run_id=run_id, chat_id="chat-1", user_id="u1", message_id=f"msg-{run_id}", created_at=at) + db.add(run) + db.commit() + return run.task_no + + +def _sub(sessions, log_id: str, run_id: str, *, kind: str = "user_agent", parent: str | None = None) -> str: + log_records.write_subagent_create_sync({ + "id": log_id, "run_id": run_id, "subagent_name": log_id, "subagent_type": kind, + "parent_subagent_log_id": parent, "message_id": f"msg-{run_id}", + }) + with sessions() as db: + return db.get(SubAgentCallLog, log_id).task_no + + +def test_runs_are_numbered_per_day_in_order(factory): + next_day = NOON + timedelta(days=1) + numbers = [_add_run(factory, "r1"), _add_run(factory, "r2"), _add_run(factory, "r3", next_day)] + assert numbers == [f"RW-{_day(NOON)}-0001", f"RW-{_day(NOON)}-0002", f"RW-{_day(next_day)}-0001"] + + +def test_concurrent_admissions_never_share_a_number(factory): + with ThreadPoolExecutor(max_workers=8) as pool: + numbers = list(pool.map(lambda i: _add_run(factory, f"c{i}"), range(24))) + assert sorted(numbers) == [f"RW-{_day(NOON)}-{n:04d}" for n in range(1, 25)] + with factory() as db: + assert db.get(TaskNumberCounter, _day(NOON)).last_seq == 24 + + +def test_subtasks_are_numbered_under_whoever_dispatched_them(factory): + main = _add_run(factory, "r1") + assert _sub(factory, "a", "r1") == f"{main}-01" + assert _sub(factory, "b", "r1") == f"{main}-02" + assert _sub(factory, "a1", "r1", parent="a") == f"{main}-01-01" # called by sub-agent a + assert _sub(factory, "c", "r1") == f"{main}-03" + + +def test_plan_steps_number_under_the_run_and_the_coordinator_keeps_its_number(factory): + main = _add_run(factory, "r1") + assert _sub(factory, "plan", "r1", kind="plan_mode") == main + assert _sub(factory, "s1", "r1", kind="plan_step", parent="plan") == f"{main}-01" + assert _sub(factory, "s2", "r1", kind="plan_step", parent="plan") == f"{main}-02" + + +def test_subagent_outside_any_run_has_no_number(factory): + assert _sub(factory, "x", "") is None + + +# ── Upgrade backfill ─────────────────────────────────────────────────────── + + +def _alembic(repo_root: Path, url: str, *args: str) -> None: + result = subprocess.run( + [sys.executable, "-m", "alembic", *args], cwd=repo_root, + env={**os.environ, "DATABASE_URL": url}, capture_output=True, text=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_upgrade_numbers_existing_runs_and_subagent_calls(tmp_path): + repo_root = Path(__file__).resolve().parents[5] + revision, previous = ( + ("ce_0036", "ce_0035") + if (repo_root / ".hugagent-edition").is_file() + else ("tasknumber01", "taskmonitor02") + ) + url = f"sqlite:///{tmp_path / 'backfill.sqlite'}" + engine = create_engine(url) + # The full chain does not run from scratch on SQLite; start from the current schema and + # step back over this revision only. + Base.metadata.create_all(engine) + _alembic(repo_root, url, "stamp", revision) + _alembic(repo_root, url, "downgrade", previous) + day1, day2 = NOON, NOON + timedelta(days=1) + with engine.begin() as db: + db.execute(text( + "INSERT INTO chat_sessions (chat_id, user_id, title, created_at, updated_at) " + "VALUES ('c', 'u', 't', :at, :at)" + ), {"at": day1}) + for run_id, at in (("late", day2), ("first", day1), ("second", day1 + timedelta(minutes=1))): + db.execute(text( + "INSERT INTO chat_runs (run_id, chat_id, user_id, message_id, status, last_event_offset, " + "run_phase, operation_seq, snapshot_version, last_operation_safety, created_at, updated_at) " + "VALUES (:r, 'c', 'u', :m, 'completed', 0, 'completed', 0, 0, 'replayable', :at, :at)" + ), {"r": run_id, "m": f"m-{run_id}", "at": at}) + for i, (log_id, kind, parent) in enumerate(( + ("plan", "plan_mode", None), ("s1", "plan_step", "plan"), ("s2", "plan_step", "plan"), + ("helper", "user_agent", "s1"), + )): + db.execute(text( + "INSERT INTO subagent_call_logs (id, message_id, subagent_name, subagent_type, " + "parent_subagent_log_id, status, created_at) VALUES (:i, 'm-first', :i, :k, :p, 'success', :at)" + ), {"i": log_id, "k": kind, "p": parent, "at": day1 + timedelta(seconds=i)}) + _alembic(repo_root, url, "upgrade", revision) + + with engine.connect() as db: + runs = dict(db.execute(text("SELECT run_id, task_no FROM chat_runs")).all()) + logs = dict(db.execute(text("SELECT id, task_no FROM subagent_call_logs")).all()) + counters = dict(db.execute(text("SELECT day, last_seq FROM task_number_counters")).all()) + first = f"RW-{_day(day1)}-0001" + assert runs == {"first": first, "second": f"RW-{_day(day1)}-0002", "late": f"RW-{_day(day2)}-0001"} + assert logs == {"plan": first, "s1": f"{first}-01", "s2": f"{first}-02", "helper": f"{first}-01-01"} + assert counters == {_day(day1): 2, _day(day2): 1} + + with sessionmaker(bind=engine)() as db: # new runs continue the day's sequence + db.add(ChatRun(run_id="new", chat_id="c", user_id="u", message_id="m-new", created_at=day1)) + db.commit() + assert db.get(ChatRun, "new").task_no == f"RW-{_day(day1)}-0003" + _alembic(repo_root, url, "downgrade", previous) + engine.dispose() diff --git a/src/backend/tests/llm/test_context_step_replay.py b/src/backend/tests/llm/test_context_step_replay.py new file mode 100644 index 00000000..5f236ff4 --- /dev/null +++ b/src/backend/tests/llm/test_context_step_replay.py @@ -0,0 +1,159 @@ +"""Hook-injected context is part of the model's own history. + +Attachment previews, the historical-file listing and reasoning reminders enter +the live context through hooks. The next turn rebuilds history from the stored +rows; if those blocks are missing there, the request stops matching the one the +provider cached at the first such block and everything after it is billed again. +These tests pin the live request and the replayed one to the same bytes. +""" + +import json +from types import SimpleNamespace + +import pytest +from agentscope.message import Msg, TextBlock, ThinkingBlock, ToolCallBlock, ToolResultBlock + +from core.chat.compaction.history import normalize_rows +from core.llm.context.ir_constants import KIND_USER_INPUT, POLICY_NEVER +from core.llm.context.items import ContextItem +from core.llm.context.rendering import next_request_sequence, render_context_item +from core.llm.runtime.context_hook_adapter import append_hook_context, record_hook_context +from core.llm.runtime.message_compat import session_to_msgs +from core.llm.runtime.model_steps import record_assistant_step, record_tool_result_step +from core.models.chat_models import ReasoningEchoChatFormatter + +ATTACHMENT = "[file name]: - sales.csv (file_id: ua_1)\n[file content begin]\n| 区域 | 销售额 |\n[file content end]" + + +@pytest.fixture +def media_db(tmp_path, monkeypatch): + from core.db.models import ToolMediaBlob + from core.llm.runtime import tool_media_store + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + engine = create_engine(f"sqlite:///{tmp_path}/media.db") + ToolMediaBlob.__table__.create(engine, checkfirst=True) + monkeypatch.setattr(tool_media_store, "SessionLocal", sessionmaker(bind=engine)) + tool_media_store.reset_cache() + yield engine + tool_media_store.reset_cache() + + +def _agent(context=()): + return SimpleNamespace(state=SimpleNamespace(context=list(context), pending_model_steps=[])) + + +def _item(content, kind="attachment", origin="user:uploaded_files", trust="user"): + return dict(content=content, kind=kind, origin=origin, trust=trust, + priority=650, token_budget=8000) + + +async def _inject(agent, *items, before_input): + appended = append_hook_context(agent, SimpleNamespace(data={"context_items": list(items)})) + await record_hook_context(agent, appended, before_input=before_input) + + +def _request(context, text): + seq = next_request_sequence(context) + return render_context_item(ContextItem.create( + item_id=f"request:{seq}", kind=KIND_USER_INPUT, origin="user:chat", trust="user", + visibility="model", priority=1_000, token_budget=100_000, + truncation_policy=POLICY_NEVER, content=text, cache_class="dynamic", + created_seq=seq, render_role="user", render_name="user", + message_group=f"request:{seq}", + )) + + +def _assistant(*blocks): + return record_assistant_step(blocks, provider="deepseek", model="v4", protocol="openai_chat") + + +def _row(role, content, model_steps=None, **extra): + return SimpleNamespace(role=role, content=content, extra_data=extra, model_steps=model_steps) + + +async def _wire(messages): + return await ReasoningEchoChatFormatter().format(messages) + + +def _drain(agent): + steps, agent.state.pending_model_steps = agent.state.pending_model_steps, [] + return steps + + +@pytest.mark.asyncio +async def test_attachment_turn_replays_to_the_request_the_model_was_sent(): + history = [Msg(name="user", role="user", content=[TextBlock(type="text", text="上一问")]), + Msg(name="assistant", role="assistant", content=[TextBlock(type="text", text="上一答")])] + agent = _agent(history) + # on_reply: the attachment preview is appended before AgentScope adds the user's message. + await _inject(agent, _item(ATTACHMENT), before_input=True) + agent.state.context.append(_request(agent.state.context, "按区域汇总")) + answer = TextBlock(type="text", text="汇总完成") + agent.state.pending_model_steps.append(_assistant(answer)) + agent.state.context.append(Msg(name="assistant", role="assistant", content=[answer])) + + replayed = normalize_rows([ + _row("user", "上一问"), + _row("assistant", "上一答"), + _row("user", "按区域汇总"), + _row("assistant", "汇总完成", model_steps=_drain(agent)), + ]) + + assert await _wire(session_to_msgs(replayed)) == await _wire(agent.state.context) + + +@pytest.mark.asyncio +async def test_reasoning_reminder_replays_between_the_steps_it_was_injected_into(): + agent = _agent() + agent.state.context.append(_request(agent.state.context, "查一下")) + call = ToolCallBlock(type="tool_call", id="c1", name="search", input="{}") + result = ToolResultBlock(type="tool_result", id="c1", name="search", output="结果") + agent.state.context.append(Msg(name="assistant", role="assistant", + content=[ThinkingBlock(type="thinking", thinking="先查"), call, result])) + steps = [_assistant(ThinkingBlock(type="thinking", thinking="先查"), call), + record_tool_result_step(result)] + # on_reasoning: a reminder lands after the tool result, before the next model call. + await _inject(agent, _item("\n剩 1 轮\n", kind="reminder", + origin="harness:iteration_budget", trust="system"), + before_input=False) + answer = TextBlock(type="text", text="答案") + agent.state.pending_model_steps.append(_assistant(answer)) + agent.state.context.append(Msg(name="assistant", role="assistant", content=[answer])) + + replayed = normalize_rows([_row("user", "查一下"), + _row("assistant", "答案", model_steps=[*steps, *_drain(agent)])]) + + assert await _wire(session_to_msgs(replayed)) == await _wire(agent.state.context) + + +@pytest.mark.asyncio +async def test_steering_is_not_recorded_twice(): + """A steer already has its own user row; recording the injection would duplicate it.""" + agent = _agent() + await _inject(agent, _item("[用户在当前执行中追加的新指令]\n改成表格", kind="steer", + origin="user:steer"), before_input=False) + + assert agent.state.pending_model_steps == [] + assert len(agent.state.context) == 1 + + +@pytest.mark.asyncio +async def test_native_image_attachment_is_kept_by_reference_and_restored(media_db): + from core.llm.runtime.tool_media_store import hydrate_rows + + image = [{"type": "text", "text": "[用户上传了 1 张图片:a.png]"}, + {"type": "data", "source": {"type": "base64", "media_type": "image/png", + "data": "iVBORw0KGgo="}}] + agent = _agent() + await _inject(agent, _item(image, origin="user:uploaded_images"), before_input=True) + agent.state.pending_model_steps.append(_assistant(TextBlock(type="text", text="看到了"))) + steps = _drain(agent) + + assert "iVBORw0KGgo=" not in json.dumps(steps) + replayed = normalize_rows([_row("user", "这是什么"), + _row("assistant", "看到了", model_steps=steps)]) + assert "iVBORw0KGgo=" not in json.dumps(replayed) + media = hydrate_rows(replayed)[0]["content"][1] + assert media["source"] == {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="} diff --git a/src/backend/tests/llm/test_execution_surface_limits.py b/src/backend/tests/llm/test_execution_surface_limits.py index 5719e2d3..34848051 100644 --- a/src/backend/tests/llm/test_execution_surface_limits.py +++ b/src/backend/tests/llm/test_execution_surface_limits.py @@ -53,7 +53,7 @@ async def test_public_multimodal_request_keeps_or_drops_each_image_whole(monkeyp monkeypatch.setattr( mw.file_context, "build_file_context", - lambda files, user_id=None: "TEXT-FILE-EVIDENCE", + lambda files: "TEXT-FILE-EVIDENCE", ) monkeypatch.setattr(mw, "supports_native_images", lambda state: True) monkeypatch.setattr( diff --git a/src/backend/tests/llm/test_file_context_offloads_blocking_parse.py b/src/backend/tests/llm/test_file_context_offloads_blocking_parse.py index bb34a306..a36a6c25 100644 --- a/src/backend/tests/llm/test_file_context_offloads_blocking_parse.py +++ b/src/backend/tests/llm/test_file_context_offloads_blocking_parse.py @@ -1,16 +1,11 @@ -"""FileContextMiddleware must not parse attachments on the event loop. +"""Attachment injection must not block the event loop. Regression pin for the "streaming stopped working" incident: attaching an 8MB PDF -froze the entire backend for 79 seconds. ``FileContextMiddleware._inject`` is -``async``, but it called ``_build_file_context`` inline — and on a parse-cache miss -that helper POSTs the document to the external file-parser service with blocking -``requests``. The single event loop that also drives every open SSE stream was -pinned for the whole parse: no chat produced a token, queued requests all landed in -the same millisecond once it returned, and even ``/health`` stalled. - -The fix hands that work to a worker thread. These tests pin the *behaviour* (the -loop keeps running) rather than the call syntax, so they stay meaningful if the -offload is reimplemented some other way. +froze the entire backend for 79 seconds because the attachment hook parsed it +inline. Document attachments are now injected as file_id handles without any +I/O (``read_artifact`` parses on demand in a worker thread); images still need a +download per file, so that work must stay off the loop. These tests pin the +*behaviour* (the loop keeps running) rather than the call syntax. """ import asyncio @@ -69,35 +64,6 @@ async def heartbeat(): return ticks -@pytest.mark.asyncio -async def test_text_attachment_parse_does_not_block_the_event_loop(monkeypatch): - """The blocking parse of a text/PDF attachment runs off the loop.""" - called = {} - - def fake_build_file_context(uploaded_files, user_id=None): - # Stands in for the blocking requests.post to the file-parser service. - called["user_id"] = user_id - called["n"] = len(uploaded_files) - time.sleep(BLOCKING_SECONDS) - return "[file content begin]parsed[file content end]" - - monkeypatch.setattr(mw.file_context, "build_file_context", fake_build_file_context) - - agent = _make_agent([{"file_id": "ua_x", "name": "a.pdf", "mime_type": "application/pdf"}]) - ticks = await _count_heartbeats_during(inject_attachments(agent)) - - # The parse actually ran, with ownership still propagated. - assert called["n"] == 1 - assert called["user_id"] == "user_test" - # ...and its result reached the model context. - assert any("parsed" in str(getattr(m, "content", "")) for m in agent.state.context) - # ...without the loop going dark. Inline, ticks would be 0. - assert ticks >= MIN_EXPECTED_TICKS, ( - f"event loop stalled during attachment parse: only {ticks} heartbeat(s) in " - f"{BLOCKING_SECONDS}s — the blocking parse is back on the loop" - ) - - @pytest.mark.asyncio async def test_native_image_injection_does_not_block_the_event_loop(monkeypatch): """Image download + base64 encode also runs off the loop. @@ -115,7 +81,7 @@ def fake_inject_native_images(image_files, user_id): "native_image_content", fake_inject_native_images, ) - monkeypatch.setattr(mw.file_context, "build_file_context", lambda files, user_id=None: "") + monkeypatch.setattr(mw.file_context, "build_file_context", lambda files: "") agent = _make_agent([{"file_id": "ua_img", "name": "a.png", "mime_type": "image/png"}]) ticks = await _count_heartbeats_during(inject_attachments(agent)) diff --git a/src/backend/tests/llm/test_file_context_preview.py b/src/backend/tests/llm/test_file_context_preview.py index 416700fa..d32cbaf5 100644 --- a/src/backend/tests/llm/test_file_context_preview.py +++ b/src/backend/tests/llm/test_file_context_preview.py @@ -24,66 +24,48 @@ def test_attachment_schema_discards_legacy_content_and_download_url(): } -def test_regular_file_context_uses_bounded_lazy_preview(monkeypatch): +def test_file_context_injects_handles_without_reading_files(monkeypatch): from core.files.artifacts import reader as artifact_reader - from core.llm.runtime.file_context import build_file_context as _build_file_context + from core.files.content import file_parser + from core.llm.runtime.file_context import build_file_context + + def must_not_run(*args, **kwargs): + raise AssertionError("attachment context must not download or parse files") - parsed = "A" * artifact_reader.ATTACHMENT_PREVIEW_MAX_CHARS + "UNSEEN_TAIL" - monkeypatch.setattr(artifact_reader, "fetch_parsed_text", lambda *args, **kwargs: parsed) + monkeypatch.setattr(artifact_reader, "fetch_parsed_text", must_not_run) + monkeypatch.setattr(shared_artifact_bytes, "download_artifact_bytes", must_not_run) + monkeypatch.setattr(file_parser, "parse_file", must_not_run) + monkeypatch.setattr(spreadsheet_parser, "parse_xlsx_preview", must_not_run) - context = _build_file_context( + context = build_file_context( [ + {"name": "report.pdf", "mime_type": "application/pdf", "file_id": "ua_pdf"}, { - "name": "large.txt", - "mime_type": "text/plain", - "file_id": "ua_123", - } - ], - user_id="user_1", + "name": "large.xlsx", + "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "file_id": "ua_sheet", + }, + {"name": "photo.png", "mime_type": "image/png", "file_id": "ua_img"}, + ] ) - assert "file_id: ua_123" in context - assert f"本轮自动展示 {artifact_reader.ATTACHMENT_PREVIEW_MAX_CHARS} 字符" in context + assert "- report.pdf (file_id: ua_pdf, MIME: application/pdf)" in context + assert "file_id: ua_sheet" in context + assert "ua_img" not in context assert "read_artifact" in context - assert "UNSEEN_TAIL" not in context - assert "batch_plan" not in context + assert "正文尚未读取" in context -def test_xlsx_context_uses_preview_budget_without_batch_hint(monkeypatch): - from core.files.artifacts import reader as artifact_reader - from core.files.content import file_parser +def test_file_context_flags_attachment_without_file_id(): + from core.llm.runtime.file_context import build_file_context - seen = {} - - monkeypatch.setattr(shared_artifact_bytes, 'download_artifact_bytes', lambda *args, **kwargs: b"xlsx") - monkeypatch.setattr(artifact_reader, "fetch_parsed_text", lambda *args, **kwargs: "cached") - - def fake_preview(_file_bytes, char_budget): - seen["char_budget"] = char_budget - return { - "total_rows": 1000, - "total_columns": 3, - "preview_rows": 12, - "header": ["姓名", "部门", "备注"], - "preview_md": "| 姓名 | 部门 | 备注 |\n| --- | --- | --- |", - "truncated_columns": False, - "truncated_cells": 0, - } + context = build_file_context([{"name": "orphan.txt", "mime_type": "text/plain"}]) - monkeypatch.setattr(spreadsheet_parser, 'parse_xlsx_preview', fake_preview) + assert "orphan.txt" in context + assert "缺少 file_id" in context - block = file_context_module.build_xlsx_preview_block( - { - "name": "large.xlsx", - "mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "file_id": "ua_sheet", - }, - user_id="user_1", - ) - assert seen["char_budget"] == artifact_reader.ATTACHMENT_PREVIEW_MAX_CHARS - assert block is not None - assert "总规模: 1000 行 × 3 列" in block - assert "列名: [姓名, 部门, 备注]" in block - assert "read_artifact" in block - assert "batch_plan" not in block +def test_file_context_is_empty_for_images_only(): + from core.llm.runtime.file_context import build_file_context + + assert build_file_context([{"name": "a.png", "mime_type": "image/png", "file_id": "ua_a"}]) == "" diff --git a/src/backend/tests/llm/test_live_approval_integration.py b/src/backend/tests/llm/test_live_approval_integration.py index 4f57b778..cf4b02d5 100644 --- a/src/backend/tests/llm/test_live_approval_integration.py +++ b/src/backend/tests/llm/test_live_approval_integration.py @@ -4,6 +4,7 @@ import uuid import pytest from core.llm.tools import confirmation as confirms +from core.chat.tool_log import build_interaction_event from core.auth.users import UserService from tests.llm.test_shared_human_interactions import shared_store, pending_on from tests.storage.project_fixtures import team @@ -76,7 +77,7 @@ async def test_full_never_publishes_new_permission_dialog(team, monkeypatch): signals = [] while queue is not None and not queue.empty(): signals.append(queue.get_nowait()) - assert all(signal.get("resolved") for signal in signals) + assert all(signal.get("outcome") != "timeout" for signal in signals) @pytest.mark.asyncio @@ -190,3 +191,55 @@ async def ask(): first.cancel() second.cancel() await asyncio.gather(first, second, return_exceptions=True) + + +def _drain_relayed(chat_id): + queue = confirms.get_ui_queue(chat_id) + signals = [] + while queue is not None and not queue.empty(): + signals.append(build_interaction_event("file_confirm", queue.get_nowait(), chat_id)) + return signals + + +@pytest.mark.asyncio +async def test_relayed_close_reports_decision_not_timeout(team, monkeypatch): + """An approved write must not reach the browser as a timeout toast.""" + monkeypatch.setattr(confirms.state, "_confirm_enabled", lambda kind: True) + waiting = asyncio.create_task( + confirms.gate( + chat_id="alice", + op="write", + logical_path="/myspace/approved.txt", + interactive=True, + timeout=5, + approval_user_id="alice", + ) + ) + try: + pending = await pending_on(confirms, "alice") + await confirms.set_decision_shared("alice", pending[0]["confirm_id"], "allow") + assert await asyncio.wait_for(waiting, 2) is None + finally: + waiting.cancel() + await asyncio.gather(waiting, return_exceptions=True) + opened, closed = _drain_relayed("alice") + assert opened["kind"] == "myspace" and not opened.get("expired") + assert closed["expired"] and closed["outcome"] == "decided" + assert closed["confirm_id"] == opened["confirm_id"] + assert not any(key.startswith("_") for key in opened) + + +@pytest.mark.asyncio +async def test_relayed_close_reports_real_timeout(team, monkeypatch): + monkeypatch.setattr(confirms.state, "_confirm_enabled", lambda kind: True) + result = await confirms.gate( + chat_id="alice", + op="write", + logical_path="/myspace/late.txt", + interactive=True, + timeout=0.3, + approval_user_id="alice", + ) + assert result["status"] == "confirm_timeout" + closed = _drain_relayed("alice")[-1] + assert closed["expired"] and closed["outcome"] == "timeout" diff --git a/src/backend/tests/orchestration/test_agent_api_recovery.py b/src/backend/tests/orchestration/test_agent_api_recovery.py index 756112db..29e764e7 100644 --- a/src/backend/tests/orchestration/test_agent_api_recovery.py +++ b/src/backend/tests/orchestration/test_agent_api_recovery.py @@ -12,7 +12,7 @@ import pytest from core.auth.backend import UserContext from core.db.engine import Base -from core.db.models import ChatRun, ChatSession +from core.db.models import ChatRun, ChatSession, TaskNumberCounter from core.runs.api_recovery import validate_recovery_context from core.auth.agent_api import make_agent_api_scope from sqlalchemy import create_engine @@ -22,7 +22,7 @@ @pytest.fixture def recovery(tmp_path, monkeypatch): engine = create_engine(f"sqlite:///{tmp_path / 'scope-recovery.db'}") - Base.metadata.create_all(engine, tables=[ChatSession.__table__, ChatRun.__table__]) + Base.metadata.create_all(engine, tables=[ChatSession.__table__, ChatRun.__table__, TaskNumberCounter.__table__]) sessions = sessionmaker(bind=engine, expire_on_commit=False) monkeypatch.setattr(db_engine, "SessionLocal", sessions) scope = make_agent_api_scope( diff --git a/src/backend/tests/orchestration/test_project_api_recovery.py b/src/backend/tests/orchestration/test_project_api_recovery.py index 56d1587d..576ff898 100644 --- a/src/backend/tests/orchestration/test_project_api_recovery.py +++ b/src/backend/tests/orchestration/test_project_api_recovery.py @@ -10,7 +10,7 @@ import core.runs.runtime as runtime import pytest from core.db.engine import Base -from core.db.models import ChatRun, ChatSession +from core.db.models import ChatRun, ChatSession, TaskNumberCounter from core.runs.api_recovery import validate_recovery_context from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -19,7 +19,7 @@ @pytest.fixture def recovery(tmp_path, monkeypatch): engine = create_engine(f"sqlite:///{tmp_path / 'project-recovery.db'}") - Base.metadata.create_all(engine, tables=[ChatSession.__table__, ChatRun.__table__]) + Base.metadata.create_all(engine, tables=[ChatSession.__table__, ChatRun.__table__, TaskNumberCounter.__table__]) sessions = sessionmaker(bind=engine, expire_on_commit=False) monkeypatch.setattr(db_engine, "SessionLocal", sessions) scope = { diff --git a/src/backend/tests/services/test_chat_sequencer.py b/src/backend/tests/services/test_chat_sequencer.py index 9435a660..8efa477b 100644 --- a/src/backend/tests/services/test_chat_sequencer.py +++ b/src/backend/tests/services/test_chat_sequencer.py @@ -18,7 +18,7 @@ import pytest from core.chat import inflight from core.db.engine import Base -from core.db.models import ChatMessage, ChatRun, ChatSession +from core.db.models import ChatMessage, ChatRun, ChatSession, TaskNumberCounter from core.db.models.chat import reserve_chat_sequences from core.chat.sequencer import ChatBusyError, ChatSequencer from core.chat.service import ChatService @@ -554,7 +554,7 @@ def _concurrent_database(tmp_path): ) Base.metadata.create_all( engine, - tables=[ChatSession.__table__, ChatMessage.__table__, ChatRun.__table__], + tables=[ChatSession.__table__, ChatMessage.__table__, ChatRun.__table__, TaskNumberCounter.__table__], ) Session = sessionmaker(bind=engine) with Session() as db: diff --git a/src/frontend/scripts/dark-mode-baseline.json b/src/frontend/scripts/dark-mode-baseline.json index e2db045e..32f7ff46 100644 --- a/src/frontend/scripts/dark-mode-baseline.json +++ b/src/frontend/scripts/dark-mode-baseline.json @@ -191,9 +191,6 @@ "src/styles/admin.css": { "css-literal-color": 1 }, - "src/styles/automation-timeline.css": { - "css-literal-color": 6 - }, "src/styles/automation/cards.css": { "css-literal-color": 1 }, diff --git a/src/frontend/scripts/test-live-approval-browser.mjs b/src/frontend/scripts/test-live-approval-browser.mjs index 80073031..9a8f76b6 100644 --- a/src/frontend/scripts/test-live-approval-browser.mjs +++ b/src/frontend/scripts/test-live-approval-browser.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { build } from 'esbuild'; import { chromium } from 'playwright'; +const TIMEOUT_TOAST = '一项「我的空间」写确认已超时取消,如仍需要请重新发起。'; + const result = await build({ stdin: { contents: ` import React from 'react'; @@ -12,7 +14,7 @@ import {useUIStore, useChatStore} from './src/stores'; useChatStore.setState({currentChatId:'chat'}); window.enqueue = id => useUIStore.getState().enqueuePendingConfirm('chat', { confirmId:id, op:'delete', logicalPath:'/myspace/a.txt', kind:'myspace', message:'Delete'}); -window.resolve = id => reduceControls({chatId:'chat'}, {confirm_id:id,expired:true,resolved:true},{},'file_confirm'); +window.resolve = (id, outcome='decided') => reduceControls({chatId:'chat'}, {confirm_id:id,expired:true,outcome},{},'file_confirm'); window.pending = () => useUIStore.getState().pendingConfirm.chat ?? []; createRoot(document.getElementById('root')).render(); `, resolveDir: process.cwd(), loader: 'tsx' }, @@ -46,13 +48,20 @@ try { await page.evaluate(()=>window.resolve('module-prompt')); await page.waitForFunction(()=>window.pending().length===0); await page.getByRole('alertdialog').waitFor({state:'detached'}); - assert.equal(await page.getByText('一项「我的空间」写确认已超时取消,如仍需要请重新发起。').count(),0); + assert.equal(await page.getByText(TIMEOUT_TOAST).count(),0); assert.equal(decisions.length,0,'server resolution needs no browser approval POST'); await page.evaluate(()=>window.enqueue('human-prompt')); await page.getByRole('alertdialog').waitFor(); await page.getByRole('button',{name:/拒绝|Deny/}).click(); await page.waitForFunction(()=>window.pending().length===0); assert.equal(decisions.length,1,'only a human choice sends an approval decision'); + await page.evaluate(()=>window.resolve('cancelled-prompt','cancelled')); + await page.evaluate(()=>window.enqueue('late-prompt')); + await page.evaluate(()=>window.resolve('late-prompt','timeout')); + const toast = page.getByText(TIMEOUT_TOAST); + await toast.waitFor(); + assert.equal(await toast.count(),1, + 'only the real timeout toasts; a cancelled run closes silently'); assert.deepEqual(errors,[]); console.log('approval browser: scoped prompt waits; server resolution clears silently; human choice posts'); } finally { await browser.close(); } diff --git a/src/frontend/scripts/test-module-pages-browser.mjs b/src/frontend/scripts/test-module-pages-browser.mjs index 20eb730b..ece15d87 100644 --- a/src/frontend/scripts/test-module-pages-browser.mjs +++ b/src/frontend/scripts/test-module-pages-browser.mjs @@ -113,8 +113,10 @@ try { await page.goto(origin + '/'); await page.locator('.jx-composerEditor').waitFor(); await page.getByText('Ordinary saved chat', {exact:true}).waitFor(); + // The scheduled-task card lives in the run monitor rail, which only shows on a wide column. + await page.setViewportSize({width:1920, height:900}); await page.evaluate(() => window.__workbench.open('/automation/task-one/conversations/task-chat')); - await page.locator('.jx-runTimeline').waitFor(); + await page.locator('.jx-runMonitor-schedule').waitFor(); const selection = {taskId:'task-one',runId:'run-one',chatId:'task-chat'}; assert.deepEqual(await page.evaluate(() => window.__workbench.automationSelection()), selection); for (const width of [1920, 1280]) { @@ -122,7 +124,7 @@ try { for (const fullscreen of [false, true]) { await page.evaluate(() => window.__workbench.preview()); await page.locator('.jx-canvas').waitFor(); - assert.equal(await page.locator('.jx-runTimeline').count(), 0, 'Canvas must remove the timeline from layout immediately'); + assert.equal(await page.locator('.jx-runMonitor-schedule').isVisible(), false, 'Canvas narrows the column, so the scheduled-task card leaves with the rail'); if (fullscreen) { await page.getByRole('button',{name:'全屏',exact:true}).click(); await page.getByRole('button',{name:'退出全屏',exact:true}).waitFor(); @@ -133,7 +135,7 @@ try { await page.screenshot({path:resolve(output,'automation-canvas-'+width+'-'+fullscreen+'.png'),animations:'disabled'}); await page.getByRole('button',{name:'收起右侧面板',exact:true}).click(); await page.locator('.jx-canvas').waitFor({state:'detached'}); - await page.locator('.jx-runTimeline').waitFor(); + await page.locator('.jx-runMonitor-schedule').waitFor({state:width >= 1920 ? 'visible' : 'attached'}); assert.deepEqual(await page.evaluate(() => window.__workbench.automationSelection()), selection); } } @@ -141,12 +143,14 @@ try { await page.locator('.jx-canvas').waitFor(); await page.evaluate(() => window.__workbench.open('/c/ordinary')); await page.locator('.jx-canvas').waitFor({state:'detached'}); - await page.locator('.jx-runTimeline').waitFor({state:'detached'}); + await page.locator('.jx-runMonitor-schedule').waitFor({state:'detached'}); assert.deepEqual(await page.evaluate(() => window.__workbench.automationSelection()), {taskId:undefined,runId:null,chatId:'ordinary'}); + await page.setViewportSize({width:1920, height:900}); await page.evaluate(() => window.__workbench.open('/automation/task-one/conversations/task-chat')); - await page.locator('.jx-runTimeline').waitFor(); + await page.locator('.jx-runMonitor-schedule').waitFor(); assert.deepEqual(await page.evaluate(() => window.__workbench.automationSelection()), selection); - console.log('automation Canvas: timeline hides during preview and fullscreen, then returns with selection preserved at two desktop widths'); + await page.locator('.jx-runMonitor-schedule').screenshot({path:resolve(output,'automation-schedule-card.png'),animations:'disabled'}); + console.log('automation Canvas: scheduled-task card hides during preview and fullscreen, then returns with selection preserved at two desktop widths'); if (!process.argv.includes('--scroll-only')) { await page.evaluate(() => window.__workbench.open('/app-center')); const center = page.locator('.jx-agentPage'); diff --git a/src/frontend/scripts/test-reference-motion-browser.mjs b/src/frontend/scripts/test-reference-motion-browser.mjs index 82fc35b7..b436f356 100644 --- a/src/frontend/scripts/test-reference-motion-browser.mjs +++ b/src/frontend/scripts/test-reference-motion-browser.mjs @@ -68,10 +68,10 @@ try{ if(assistant)assert.equal(assistant.frames[0].transform,'translateY(10px)'); await page.locator('.jx-trs-head').click(); const labels=page.locator('.jx-rollText-text.is-shimmer,.jx-tcr-prefix--shimmer'); - const live=await labels.evaluateAll(els=>els.map(el=>{const s=getComputedStyle(el);return {name:s.animationName,duration:s.animationDuration,size:s.backgroundSize,clip:s.backgroundClip,gradient:s.backgroundImage};})); + const live=await labels.evaluateAll(els=>els.map(el=>{const band=el.querySelector(':scope > .jx-shimmer-band'),glint=band?.querySelector('.jx-shimmer-glint');const b=band&&getComputedStyle(band),g=glint&&getComputedStyle(glint);return {band:b?.animationName,glint:g?.animationName,duration:b?.animationDuration,same:glint?.textContent===el.firstChild?.textContent,hidden:band?.getAttribute('aria-hidden'),color:g?.color,clip:getComputedStyle(el).backgroundClip};})); assert.ok(live.length>=4,'startup, execution title, tool and thinking step labels participate'); - assert.ok(live.every(s=>s.name==='jxTurnStatusShine'&&s.duration==='2.8s'&&s.size==='250% 100%'&&s.clip==='text'),'all running labels use the same reference scan'); - assert.equal(new Set(live.map(s=>s.gradient)).size,1,'tool.css must not override the shared gradient'); + assert.ok(live.every(s=>s.band==='jxShimmerBand'&&s.glint==='jxShimmerGlint'&&s.duration==='2.8s'&&s.same&&s.hidden==='true'&&s.clip!=='text'),'all running labels use the same compositor-only scan'); + assert.equal(new Set(live.map(s=>s.color)).size,1,'tool.css must not override the shared glint colour'); const summary=page.locator('.jx-trs-summary'); assert.equal(await summary.locator('.jx-trs-dur').count(),1); assert.equal(await page.locator('.jx-trs-steps').count(),0,'the tool-kind chips replace the step count'); @@ -81,18 +81,18 @@ try{ assert.equal(await page.locator('.jx-trs-head .jx-agentSlot').count(),0,'a run with tools leaves no mark slot'); assert.equal(await page.locator('.jx-trs-head .jx-kindStack .jx-kindChip.is-running').count(),1,'the lit chip carries the running state'); const title=summary.locator('.jx-rollText-text.is-shimmer'); - const scan=await title.evaluate(el=>{const animation=el.getAnimations()[0];window.summaryScan=animation;return {start:animation.startTime};}); + const scan=await title.evaluate(el=>{const animation=el.querySelector('.jx-shimmer-band').getAnimations()[0];window.summaryScan=animation;return {start:animation.startTime};}); const before=await summary.locator('.jx-trs-dur').textContent(); await page.waitForTimeout(1100); - const tick=await title.evaluate(el=>({same:el.getAnimations()[0]===window.summaryScan,start:el.getAnimations()[0].startTime,fill:getComputedStyle(el).webkitTextFillColor})); + const tick=await title.evaluate(el=>{const a=el.querySelector('.jx-shimmer-band').getAnimations()[0];return {same:a===window.summaryScan,start:a.startTime};}); assert.equal(tick.same,true,'timer ticks do not restart the title scan'); assert.equal(tick.start,scan.start); assert.notEqual(await summary.locator('.jx-trs-dur').textContent(),before,'elapsed seconds continue to update'); - assert.equal(tick.fill,'rgba(0, 0, 0, 0)','the running title is the scanned surface'); - assert.notEqual(await summary.locator('.jx-trs-dur').evaluate(el=>getComputedStyle(el).webkitTextFillColor),'rgba(0, 0, 0, 0)','only the title scans; the clock stays plain'); - const positions=await page.locator('.jx-turnStatus-label .jx-rollText-text').evaluate(el=>{const a=el.getAnimations()[0];a.pause();const d=a.effect.getTiming().delay;return [0,280,1400,2500].map(t=>{a.currentTime=t+d+2800;return getComputedStyle(el).backgroundPosition;});}); + assert.equal(await summary.locator('.jx-shimmer-band').count(),1,'only the title scans; the clock stays plain'); + const positions=await page.locator('.jx-turnStatus-label .jx-rollText-text').evaluate(el=>{const band=el.querySelector('.jx-shimmer-band'),glint=band.querySelector('.jx-shimmer-glint');const[a,b]=[band,glint].map(n=>n.getAnimations()[0]);a.pause();b.pause();const d=a.effect.getTiming().delay;return [0,280,1400,2500].map(t=>{a.currentTime=b.currentTime=t+d+2800;const x=new DOMMatrix(getComputedStyle(band).transform).m41,y=new DOMMatrix(getComputedStyle(glint).transform).m41;return {x,net:Math.abs(x+y)};});}); // Labels phase-lock to a shared clock through a negative delay; sample one full cycle in. - assert.equal(positions[0],positions[1],'scan holds for the first 15%');assert.notEqual(positions[1],positions[2]); + assert.equal(positions[0].x,positions[1].x,'scan holds for the first 15%');assert.notEqual(positions[1].x,positions[2].x); + assert.ok(positions.every(p=>p.net<0.01),'the glint counter-moves so the glyphs stay put'); const skeleton=await page.locator('.jx-skeletonBlock').first().evaluate(el=>{const s=getComputedStyle(el,'::after');return {name:s.animationName,duration:s.animationDuration,gradient:s.backgroundImage,pointer:s.pointerEvents};}); assert.equal(skeleton.name,'jx-skeletonSweep');assert.equal(skeleton.duration,'1.6s');assert.ok(skeleton.gradient.includes('110deg'));assert.equal(skeleton.pointer,'none'); await page.screenshot({path:resolve(evidence,theme+'-running.png'),fullPage:true}); @@ -105,7 +105,7 @@ try{ await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;},theme); const reduced=await page.locator('section').evaluate(el=>[...el.querySelectorAll('.jx-msg.assistant,.jx-bubble.user')].map(n=>({opacity:getComputedStyle(n).opacity,transform:getComputedStyle(n).transform}))); assert.ok(reduced.every(s=>s.opacity==='1'&&['none','matrix(1, 0, 0, 1, 0, 0)'].includes(s.transform)),'reduced motion shows both message types immediately'); - assert.equal(await page.locator('.jx-turnStatus-label .jx-rollText-text').evaluate(el=>getComputedStyle(el).animationName),'jxTurnStatusShine'); + assert.equal(await page.locator('.jx-turnStatus-label .jx-shimmer-band').evaluate(el=>getComputedStyle(el).animationName),'jxShimmerBand'); await page.setViewportSize({width:390,height:844}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'no narrow-screen overflow'); await page.screenshot({path:resolve(evidence,theme+'-mobile-reduced.png'),fullPage:true}); diff --git a/src/frontend/src/ApiDocApp.tsx b/src/frontend/src/ApiDocApp.tsx index 376d5818..1597f488 100644 --- a/src/frontend/src/ApiDocApp.tsx +++ b/src/frontend/src/ApiDocApp.tsx @@ -5,6 +5,7 @@ import { } from '@ant-design/icons'; import { ApiDocPanel } from './components/apidoc'; import { usePageConfig, usePageConfigPolling } from './hooks/usePageConfig'; +import { useAdminPlatformFavicon } from './hooks/useDocumentFavicon'; import { t } from './i18n'; const { Title, Text } = Typography; @@ -18,6 +19,7 @@ export default function ApiDocApp() { const docTitle = `${platformName} — ${apidocLabel}`; // The browser tab title follows the admin-platform branding (index.html's static is only a first-frame fallback). useEffect(() => { if (typeof document !== 'undefined') document.title = docTitle; }, [docTitle]); + useAdminPlatformFavicon(); const reloadRef = useRef<(() => void) | null>(null); return ( diff --git a/src/frontend/src/components/AppWorkspace.tsx b/src/frontend/src/components/AppWorkspace.tsx index efea088c..1e9f81dc 100644 --- a/src/frontend/src/components/AppWorkspace.tsx +++ b/src/frontend/src/components/AppWorkspace.tsx @@ -47,12 +47,11 @@ import { SlidePanel } from './common/SlidePanel'; import { usePaneOcclusion } from '../hooks/usePaneOcclusion'; import PanelLoadingSkeleton from './common/PanelLoadingSkeleton'; import { ProjectHeaderSkeleton, ProjectsSkeleton, ProjectWorkspaceRailSkeleton } from './projects/ProjectSkeletons'; -const RunTimelinePanel = lazy(() => import('./automation/RunTimelinePanel').then(m => ({ default: m.RunTimelinePanel }))); const ProjectHeader = lazy(() => import('./projects/ProjectHeader')); const ProjectWorkspaceRail = lazy(() => import('./projects/ProjectWorkspaceRail')); const { Header, Content } = Layout; export function AppWorkspace({ state }: { state: ReturnType<typeof useAppController> }) { - const { handleNewChat, handleNewProjectChat, deleteChat, toggleChatPinned, toggleChatFavorite, startRenameChat, commitRenameChat, exportChatRecord, handleSelectChat, handleSetPanel, siderCollapsed, setSiderCollapsed, handleSelectSearchResult, canvasFullscreen, canvasPanelWidth, canvasOpen, chatSurface, showChatHeader, openMobileSidebar, isEmptyChat, handleRightSidebarToggle, showHeader, title, hint, chat, chatProjectName, recommendBarVisible, recommendBannerText, handleCapabilityClick, setRecommendBarVisible, panel, overviewProjectId, toolResultPanel, promptHubOpen, isCE, automationActiveGroup, rightSidebarView, detailModal, setDetailModal, refreshCatalog } = state; + const { handleNewChat, handleNewProjectChat, deleteChat, toggleChatPinned, toggleChatFavorite, startRenameChat, commitRenameChat, exportChatRecord, handleSelectChat, handleSetPanel, siderCollapsed, setSiderCollapsed, handleSelectSearchResult, canvasFullscreen, canvasPanelWidth, canvasOpen, chatSurface, showChatHeader, openMobileSidebar, isEmptyChat, handleRightSidebarToggle, showHeader, title, hint, chat, chatProjectName, recommendBarVisible, recommendBannerText, handleCapabilityClick, setRecommendBarVisible, panel, overviewProjectId, toolResultPanel, promptHubOpen, isCE, rightSidebarView, detailModal, setDetailModal, refreshCatalog } = state; const personalAgentAvailable = usePersonalAgentAvailable(); const authUserLoaded = useAuthStore(s => s.authUser != null); useEffect(() => { @@ -267,11 +266,6 @@ export function AppWorkspace({ state }: { state: ReturnType<typeof useAppControl <SlidePanel show={!isCE && promptHubOpen && !canvasOpen && chatSurface} panelKey="prompt-hub"> <PromptHubPanel draftKey={chatDraftKey(useChatStore.getState().currentChatId)} /> </SlidePanel> - {!canvasOpen && ( - <SlidePanel show={!!automationActiveGroup && panel === 'automation' && chatSurface && !showProjectOverview} panelKey="run-timeline" x={24} duration={0.24}> - <Suspense fallback={<PanelLoadingSkeleton />}><RunTimelinePanel /></Suspense> - </SlidePanel> - )} </div> </div> diff --git a/src/frontend/src/components/automation/RunTimelinePanel.tsx b/src/frontend/src/components/automation/RunTimelinePanel.tsx deleted file mode 100644 index ac633b74..00000000 --- a/src/frontend/src/components/automation/RunTimelinePanel.tsx +++ /dev/null @@ -1,370 +0,0 @@ -import { -CheckCircleOutlined, -CloseCircleOutlined, -CloseOutlined, -LoadingOutlined, -MessageOutlined, -RightOutlined, -} from '@ant-design/icons'; -import { Tooltip } from 'antd'; -import { AnimatePresence,motion } from 'motion/react'; -import { useEffect,useMemo,useState } from 'react'; -import { getAutomationRuns } from '../../api'; -import { useStatusFlash } from '../../hooks/useFlash'; -import { t } from '../../i18n'; -import { useAutomationChatStore,useCatalogStore } from '../../stores'; -import '../../styles/automation-timeline.css'; -import type { AutomationChatGroup,AutomationRun,AutomationRunStatus } from '../../types'; -import { formatDateKey,formatFullDateTime,formatMonthDay,formatMonthDayTime } from '../../utils/date'; -import { DUR,EASE,staggerStyle } from '../../utils/motionTokens'; -import { RUN_STATUS_LABEL,formatRunDuration,isRunActive } from './automationUtils'; - -/* Stat number y6→0 crossfade (shared config between top stats and mini stats) */ -const STAT_NUM_MOTION = { - initial: { opacity: 0, y: 6 }, - animate: { opacity: 1, y: 0 }, - exit: { opacity: 0, y: -6 }, - transition: { duration: DUR.fast, ease: EASE.standard }, -}; - -/* 有执行中的 run 时刷新列表的间隔:状态流转、chat_id 关联都靠它同步到面板 */ -const RUN_POLL_INTERVAL_MS = 5_000; - -interface DateGroup { - date: string; - fullDate: string; - runs: (AutomationRun & { runNo: number })[]; -} - -// First, second … tenth; above 10 use Arabic numerals -const CN_DIGITS = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; -function formatRunOrdinal(n: number): string { - return `第${CN_DIGITS[n] || n}次`; -} - -function summarizeRun(run: AutomationRun): string { - if (isRunActive(run)) return t('任务正在执行,结果生成后会自动更新到对话区。'); - if (run.status === 'failed') { - return String(run.error_message || run.result_summary || t('执行失败,请进入详情查看错误信息。')).replace(/\s+/g, ' ').trim(); - } - return String(run.result_summary || t('执行完成,可进入对话查看完整输出与附件结果。')).replace(/\s+/g, ' ').trim(); -} - -export function RunTimelinePanel() { - const { activeGroup, selectedRunId, selectRun, setRuns, exitAutomationChat } = useAutomationChatStore(); - const { setPanel } = useCatalogStore(); - - // activeGroup 只是进入时的快照:有执行中的 run 就轮询,否则状态与 chat_id 永远停在进入那一刻 - const pollTaskId = activeGroup?.runs.some(isRunActive) ? activeGroup.taskId : null; - useEffect(() => { - if (!pollTaskId) return; - let cancelled = false; - const refresh = async () => { - if (document.hidden) return; - try { - const runs = await getAutomationRuns(pollTaskId, 50); - // 请求期间可能已退出或切到别的任务,旧响应不能写回 - if (!cancelled && useAutomationChatStore.getState().activeGroup?.taskId === pollTaskId) setRuns(runs); - } catch { - /* 下一轮再试 */ - } - }; - const timer = window.setInterval(() => void refresh(), RUN_POLL_INTERVAL_MS); - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, [pollTaskId, setRuns]); - - // Exit snapshot: when App.tsx's AnimatePresence plays the exit animation, store.activeGroup is already null, - // so during render we derive state that caches the last non-empty group to render the final frame as a fallback - // (the React-endorsed derived-state pattern), avoiding a blank panel during exit. - const [snapshot, setSnapshot] = useState(activeGroup); - if (activeGroup && activeGroup !== snapshot) setSnapshot(activeGroup); - const group: AutomationChatGroup | null = activeGroup ?? snapshot; - - const numberedRuns = useMemo<(AutomationRun & { runNo: number })[]>(() => { - if (!group) return []; - // Runs are desc by started_at; number them ascending (oldest = #1) - return group.runs.map((run, idx) => ({ ...run, runNo: group.runs.length - idx })); - }, [group]); - - // run_id → flat index (used for enter stagger, following render order consistent with groupedRuns) - const runIndexMap = useMemo(() => { - const map = new Map<string, number>(); - numberedRuns.forEach((run, idx) => map.set(run.run_id, idx)); - return map; - }, [numberedRuns]); - - // The running → finished flip frame gives the card a one-time border flash (auto-cleared after 800ms). - const justFinished = useStatusFlash( - numberedRuns, - (run) => run.run_id, - (run) => run.status, - (prev) => prev === 'running', - 800, - ); - - const groupedRuns = useMemo<DateGroup[]>(() => { - // Group by date (runs are already desc by started_at) - const map = new Map<string, DateGroup>(); - for (const run of numberedRuns) { - const key = formatDateKey(run.started_at); - if (!map.has(key)) { - map.set(key, { - date: formatMonthDay(run.started_at), - fullDate: key, - runs: [], - }); - } - map.get(key)!.runs.push(run); - } - return Array.from(map.values()); - }, [numberedRuns]); - - // Accumulate all status counts + the most recent success record in a single pass, avoiding the O(kn) cost of multiple filter/find calls. - const stats = useMemo(() => { - let running = 0; - let success = 0; - let failed = 0; - let latestSuccessRun: AutomationRun | undefined; - let latestFinishedRun: AutomationRun | undefined; - for (const run of numberedRuns) { - if (isRunActive(run)) running += 1; - else if (run.status === 'success') { - success += 1; - if (!latestSuccessRun) latestSuccessRun = run; - if (!latestFinishedRun) latestFinishedRun = run; - } else if (run.status === 'failed') { - failed += 1; - if (!latestFinishedRun) latestFinishedRun = run; - } - } - const total = numberedRuns.length; - const completed = total - running; - const successRate = completed > 0 ? Math.round((success / completed) * 1000) / 10 : 0; - return { total, running, success, failed, completed, successRate, latestSuccessRun, latestFinishedRun }; - }, [numberedRuns]); - - if (!group) return null; - - const { total: totalCount, running: runningCount, success: successCount, failed: failedCount, - completed: completedCount, successRate, latestSuccessRun, latestFinishedRun } = stats; - const latestRun = numberedRuns[0]; - - const navigateBackToDetail = () => { - // 定时任务已从应用中心拆成一级面板;任务 id 要随这一次跳转一起给出,否则会被冲回任务列表 - setPanel('automation', group.taskId); - exitAutomationChat(); - }; - - const latestLabel = latestRun ? t('最近一次{status}', { status: RUN_STATUS_LABEL[latestRun.status] }) : t('暂无执行'); - const latestIndicatorRun = latestSuccessRun || latestRun; - const latestIndicatorStatus: AutomationRunStatus | 'idle' = - latestIndicatorRun ? latestIndicatorRun.status : 'idle'; - - const topStats: { label: string; value: string | number; primary?: boolean }[] = [ - { label: t('执行次数'), value: totalCount, primary: true }, - { label: t('成功率'), value: `${successRate}%`, primary: true }, - { label: t('最近完成时间'), value: latestFinishedRun ? formatMonthDay(latestFinishedRun.started_at) : '—' }, - ]; - const miniStats: { label: string; value: number }[] = [ - { label: t('全部'), value: totalCount }, - { label: t('成功'), value: successCount }, - { label: t('失败'), value: failedCount }, - { label: t('执行中'), value: runningCount }, - ]; - - return ( - <div className="jx-runTimeline"> - {/* Top bar: Automation task > X */} - <div className="jx-runTimeline-topBar"> - <button - type="button" - className="jx-runTimeline-crumb" - onClick={navigateBackToDetail} - title={t('返回定时任务详情')} - > - <span>{t('定时任务')}</span> - <RightOutlined className="jx-runTimeline-crumbIcon" /> - </button> - <button - type="button" - className="jx-runTimeline-closeBtn" - onClick={exitAutomationChat} - aria-label={t('关闭')} - title={t('关闭面板')} - > - <CloseOutlined /> - </button> - </div> - - {/* Scrollable body */} - <div className="jx-runTimeline-body"> - {/* Title + subtitle */} - <div className="jx-runTimeline-titleBlock"> - <div className="jx-runTimeline-title" title={group.taskName}> - {group.taskName} - </div> - <div className="jx-runTimeline-subtitle"> - {t('运行舱记录最近执行状态、摘要与异常线索')} - </div> - </div> - - {/* Top 3-column stats */} - <div className="jx-runTimeline-statRow"> - {topStats.map((s) => ( - <div key={s.label} className="jx-runTimeline-statCard"> - <span className="jx-runTimeline-statLabel">{s.label}</span> - <strong className={`jx-runTimeline-statValue${s.primary ? ' is-primary' : ''}`}> - {/* Number-change y6→0 crossfade: key bound to the value itself, so polling with the same value re-renders without replaying */} - <AnimatePresence mode="popLayout" initial={false}> - <motion.span key={String(s.value)} {...STAT_NUM_MOTION}> - {s.value} - </motion.span> - </AnimatePresence> - </strong> - </div> - ))} - </div> - - {/* Most recent status */} - <div className={`jx-runTimeline-latestRow is-${latestIndicatorStatus}`}> - <span className="jx-runTimeline-latestIcon"> - {latestIndicatorStatus === 'success' && <CheckCircleOutlined />} - {latestIndicatorStatus === 'failed' && <CloseCircleOutlined />} - {latestIndicatorStatus === 'running' && <LoadingOutlined spin />} - {latestIndicatorStatus === 'idle' && <CheckCircleOutlined />} - </span> - <span className="jx-runTimeline-latestLabel"> - {latestLabel}: - </span> - <span className="jx-runTimeline-latestTime"> - {latestIndicatorRun ? formatFullDateTime(latestIndicatorRun.started_at) : '—'} - </span> - </div> - - {/* Execution records */} - <section className="jx-runTimeline-section"> - <div className="jx-runTimeline-sectionHeading"> - <span className="jx-runTimeline-sectionBar" /> - <span>{t('执行记录')}</span> - </div> - <div className="jx-runTimeline-sectionMeta"> - {t('已完成 {done} 次,失败 {failed} 次', { done: completedCount, failed: failedCount })} - </div> - <div className="jx-runTimeline-miniStatRow"> - {miniStats.map((s) => ( - <div key={s.label} className="jx-runTimeline-miniStat"> - <span className="jx-runTimeline-miniStatLabel">{s.label}</span> - <strong className="jx-runTimeline-miniStatValue"> - <AnimatePresence mode="popLayout" initial={false}> - <motion.span key={String(s.value)} {...STAT_NUM_MOTION}> - {s.value} - </motion.span> - </AnimatePresence> - </strong> - </div> - ))} - </div> - </section> - - {/* Detail timeline */} - <section className="jx-runTimeline-section"> - <div className="jx-runTimeline-sectionHeading"> - <span className="jx-runTimeline-sectionBar" /> - <span>{t('详情')}</span> - </div> - - {groupedRuns.length === 0 ? ( - <div className="jx-runTimeline-empty"> - <div className="jx-runTimeline-emptyTitle">{t('暂无运行记录')}</div> - <div className="jx-runTimeline-emptyHint">{t('等待下一次自动执行或手动触发任务')}</div> - </div> - ) : ( - <div className="jx-runTimeline-timeline"> - {groupedRuns.map((group) => ( - <div key={group.fullDate} className="jx-runTimeline-dateGroup"> - <div className="jx-runTimeline-dateHeader"> - <span className="jx-runTimeline-dateLabel">{group.date}</span> - <span className="jx-runTimeline-dateCount">{t('{n} 次', { n: group.runs.length })}</span> - </div> - <div className="jx-runTimeline-dateBody"> - {group.runs.map((run) => { - const isActive = run.run_id === selectedRunId; - const isRunning = isRunActive(run); - // 执行中的 run 一旦关联了对话就能进入看实时进度;还没关联的(排队/刚触发)才不可点 - const isClickable = !!run.chat_id; - const summary = summarizeRun(run); - const tooltip = !isRunning - ? RUN_STATUS_LABEL[run.status] - : (isClickable ? t('执行中,点击查看实时进度') : t('准备中,暂不可查看')); - - return ( - <Tooltip - key={run.run_id} - title={tooltip} - placement="left" - > - <div - className={[ - 'jx-runTimeline-item', - isActive && 'is-active', - `is-${run.status}`, - !isClickable && 'is-disabled', - justFinished.has(run.run_id) && 'is-justFinished', - ].filter(Boolean).join(' ')} - style={staggerStyle(runIndexMap.get(run.run_id) ?? 0)} - role={isClickable ? 'button' : undefined} - tabIndex={isClickable ? 0 : -1} - onClick={() => isClickable && selectRun(run.run_id)} - onKeyDown={(event) => { - if (!isClickable) return; - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - selectRun(run.run_id); - } - }} - > - <div className="jx-runTimeline-itemTop"> - <div className="jx-runTimeline-itemIdentity"> - <span className="jx-runTimeline-runNo">{formatRunOrdinal(run.runNo)}</span> - <span className={`jx-runTimeline-statusTag is-${run.status}${isRunning ? ' jx-anim-ripple' : ''}`}> - {RUN_STATUS_LABEL[run.status]} - </span> - </div> - <span className="jx-runTimeline-itemTime"> - {formatMonthDayTime(run.started_at)} - </span> - </div> - <div className="jx-runTimeline-itemSummary">{summary}</div> - <div className="jx-runTimeline-itemBottom"> - <span className="jx-runTimeline-itemDuration"> - {run.duration_ms - ? t('耗时 {dur}', { dur: formatRunDuration(run.duration_ms) }) - : (isRunning ? t('执行中') : t('耗时 —'))} - </span> - <span - className={[ - 'jx-runTimeline-itemEnter', - !isClickable && 'is-disabled', - ].filter(Boolean).join(' ')} - > - <MessageOutlined /> - <span>{!isClickable ? t('生成中') : (isRunning ? t('查看进度') : t('进入对话'))}</span> - </span> - </div> - </div> - </Tooltip> - ); - })} - </div> - </div> - ))} - </div> - )} - </section> - </div> - </div> - ); -} diff --git a/src/frontend/src/components/automation/automationUtils.ts b/src/frontend/src/components/automation/automationUtils.ts index 75a9a4af..b0079a2f 100644 --- a/src/frontend/src/components/automation/automationUtils.ts +++ b/src/frontend/src/components/automation/automationUtils.ts @@ -23,6 +23,21 @@ export function isRunActive(run: Pick<AutomationRun, 'status'>): boolean { return run.status === 'running' || run.status === 'pending'; } +// First, second … tenth; above 10 use Arabic numerals +const CN_DIGITS = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; +export function formatRunOrdinal(n: number): string { + return `第${CN_DIGITS[n] || n}次`; +} + +/** One-line result of a run: its summary, its error, or what it is waiting for. */ +export function summarizeRun(run: AutomationRun): string { + if (isRunActive(run)) return t('任务正在执行,结果生成后会自动更新到对话区。'); + if (run.status === 'failed') { + return String(run.error_message || run.result_summary || t('执行失败,请进入详情查看错误信息。')).replace(/\s+/g, ' ').trim(); + } + return String(run.result_summary || t('执行完成,可进入对话查看完整输出与附件结果。')).replace(/\s+/g, ' ').trim(); +} + /** Status modifier classes for the run dot (`.jx-automation-runDot`), shared by the task * detail page and the aggregated run list. The running state additionally carries the * `.jx-anim-ripple` motion primitive; colour/spread live in automation.css. */ diff --git a/src/frontend/src/components/common/RollingText.tsx b/src/frontend/src/components/common/RollingText.tsx index dd1cf7fb..4638b14e 100644 --- a/src/frontend/src/components/common/RollingText.tsx +++ b/src/frontend/src/components/common/RollingText.tsx @@ -1,9 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { Ref } from 'react'; import { DUR } from '../../utils/motionTokens'; - -/** Matches `jxTurnStatusShine` in styles/chat/turn-status.css. */ -const SHIMMER_MS = 2800; +import { ShimmerText } from './ShimmerText'; /** The outgoing label is dropped once its exit (--motion-duration-fast) has played. */ const EXIT_MS = DUR.fast * 1000; @@ -87,19 +85,11 @@ function RollItem({ text, shimmer, entering, ref }: { entering: boolean; ref: Ref<HTMLSpanElement>; }) { - // Phase-lock every scan to one wall clock, so a label that mounts mid-scan - // continues the sweep instead of restarting it on every hand-over. - const [delay] = useState(() => `-${Math.round(performance.now() % SHIMMER_MS)}ms`); // The roll moves the outer span and the scan paints the inner one: one // element per animation, so neither overrides the other's `animation`. return ( <span ref={ref} className={`jx-rollText-item${entering ? ' jx-rollText-item--in' : ''}`}> - <span - className={`jx-rollText-text${shimmer ? ' is-shimmer' : ''}`} - style={shimmer ? { animationDelay: delay } : undefined} - > - {text} - </span> + <ShimmerText text={text} active={shimmer} className="jx-rollText-text" activeClassName="is-shimmer" /> </span> ); } diff --git a/src/frontend/src/components/common/ShimmerText.tsx b/src/frontend/src/components/common/ShimmerText.tsx new file mode 100644 index 00000000..8487a8d2 --- /dev/null +++ b/src/frontend/src/components/common/ShimmerText.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import type { CSSProperties, Ref } from 'react'; + +/** Matches `jxShimmerBand` / `jxShimmerGlint` in styles/chat/turn-status.css. */ +const SHIMMER_MS = 2800; + +interface ShimmerTextProps { + text: string; + /** Scan the label while the step it names is running. */ + active: boolean; + className: string; + /** Modifier added while scanning (positions the band, see turn-status.css). */ + activeClassName: string; + ref?: Ref<HTMLSpanElement>; +} + +/** + * A label with a light band sweeping across it. The band is a masked copy of + * the text that slides one way while the copy inside slides back, so the glyphs + * stay put and only `transform` animates — it runs on the compositor and stays + * smooth while the stream keeps the main thread busy. (Animating a + * `background-clip: text` gradient repaints every frame and stutters.) + */ +export function ShimmerText({ text, active, className, activeClassName, ref }: ShimmerTextProps) { + // Phase-lock every scan to one wall clock, so a label that mounts mid-scan + // continues the sweep instead of restarting it on every hand-over. + const [delay] = useState(() => `-${Math.round(performance.now() % SHIMMER_MS)}ms`); + if (!active) return <span ref={ref} className={className}>{text}</span>; + return ( + <span + ref={ref} + className={`${className} ${activeClassName}`} + style={{ '--jx-shimmer-delay': delay } as CSSProperties} + > + {text} + <span className="jx-shimmer-band" aria-hidden="true"> + <span className="jx-shimmer-glint">{text}</span> + </span> + </span> + ); +} diff --git a/src/frontend/src/components/common/index.ts b/src/frontend/src/components/common/index.ts index 40c649c2..58317102 100644 --- a/src/frontend/src/components/common/index.ts +++ b/src/frontend/src/components/common/index.ts @@ -1,6 +1,7 @@ export { default as ImagePreview } from './ImagePreview'; export { default as AuthExpiredModal } from './AuthExpiredModal'; export { ElapsedTimer } from './ElapsedTimer'; +export { ShimmerText } from './ShimmerText'; export { default as AppLoadingSkeleton } from './AppLoadingSkeleton'; export { default as AppErrorBoundary } from './AppErrorBoundary'; export { default as ContentErrorBoundary } from './ContentErrorBoundary'; diff --git a/src/frontend/src/components/monitor/MonitorCard.tsx b/src/frontend/src/components/monitor/MonitorCard.tsx new file mode 100644 index 00000000..d6322c85 --- /dev/null +++ b/src/frontend/src/components/monitor/MonitorCard.tsx @@ -0,0 +1,25 @@ +import { useState, type ReactNode } from 'react'; +import { CaretDownFilled } from '@ant-design/icons'; + +interface MonitorCardProps { + title: string; + /** Right side of the header: a count, a percentage, a short state. */ + summary?: ReactNode; + defaultOpen?: boolean; + className?: string; + children: ReactNode; +} + +export function MonitorCard({ title, summary, defaultOpen = true, className, children }: MonitorCardProps) { + const [open, setOpen] = useState(defaultOpen); + return ( + <section className={`jx-runMonitor-card${className ? ' ' + className : ''}`} data-open={open || undefined}> + <button type="button" className="jx-runMonitor-cardHead" aria-expanded={open} onClick={() => setOpen(!open)}> + <span className="jx-runMonitor-cardTitle">{title}</span> + <CaretDownFilled className="jx-runMonitor-caret" aria-hidden /> + {summary != null && <span className="jx-runMonitor-summary">{summary}</span>} + </button> + {open && <div className="jx-runMonitor-cardBody">{children}</div>} + </section> + ); +} diff --git a/src/frontend/src/components/monitor/RunMonitorRail.css b/src/frontend/src/components/monitor/RunMonitorRail.css index d566b91b..077dd099 100644 --- a/src/frontend/src/components/monitor/RunMonitorRail.css +++ b/src/frontend/src/components/monitor/RunMonitorRail.css @@ -271,3 +271,39 @@ button.jx-runMonitor-row:disabled { cursor: default; } } .jx-runMonitor-failed { color: var(--color-error); } + +/* ── Scheduled task ── */ +.jx-runMonitor-scheduleLive { color: var(--color-primary); } +.jx-runMonitor-scheduleTask { + font-weight: 500; +} +.jx-runMonitor-scheduleRuns { + display: flex; + max-height: 320px; + margin-top: 4px; + padding-top: 4px; + flex-direction: column; + border-top: 1px solid var(--color-border); + overflow-y: auto; + scrollbar-width: thin; +} +.jx-runMonitor-scheduleRuns > .jx-runMonitor-row { flex: none; } +.jx-runMonitor-scheduleEmpty { + padding: 8px; + color: var(--color-text-tertiary); + font-size: 12px; +} +.jx-runMonitor-runSummary { color: var(--color-text-tertiary); } +.jx-runMonitor-runDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-success); +} +.jx-runMonitor-runDot[data-status='failed'] { background: var(--color-error); } +.jx-runMonitor-runDot[data-status='running'], +.jx-runMonitor-runDot[data-status='pending'] { + background: var(--color-primary); + --ripple-color: var(--color-primary); + --ripple-scale: 2.5; +} diff --git a/src/frontend/src/components/monitor/RunMonitorRail.tsx b/src/frontend/src/components/monitor/RunMonitorRail.tsx index fc4d8b34..554fcaa0 100644 --- a/src/frontend/src/components/monitor/RunMonitorRail.tsx +++ b/src/frontend/src/components/monitor/RunMonitorRail.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState, type ReactNode } from 'react'; -import { CaretDownFilled, CloudOutlined, CodeOutlined, FolderOutlined, LaptopOutlined, LoadingOutlined, SettingOutlined, UnorderedListOutlined } from '@ant-design/icons'; +import { useMemo, useState } from 'react'; +import { CloudOutlined, CodeOutlined, FolderOutlined, LaptopOutlined, LoadingOutlined, SettingOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { message, Tooltip } from 'antd'; import { t } from '../../i18n'; import { useChatStore } from '../../stores/chatStore'; @@ -16,6 +16,8 @@ import { CONTEXT_CATEGORIES, useContextUsageView } from '../chat/useContextUsage import { DeliveryFileIcon } from '../chat/DeliveryFileIcon'; import { SubagentStatus } from '../tool/SubagentCallRow'; import { openSubagentTab } from '../tool/openSubagentTab'; +import { MonitorCard } from './MonitorCard'; +import { ScheduledTaskCard } from './ScheduledTaskCard'; import { collectFiles, collectSubagents, countToolCalls, type MonitorSubagent } from './runMonitorData'; import { isLiveCommand, isLiveJob, useBackgroundTasks } from './useBackgroundTasks'; import { useChatProject } from './useChatProject'; @@ -26,28 +28,6 @@ import { useMySpaceStore } from '../../stores/mySpaceStore'; import '../chat/DeliveredFiles.css'; import './RunMonitorRail.css'; -interface MonitorCardProps { - title: string; - /** Right side of the header: a count, a percentage, a short state. */ - summary?: ReactNode; - defaultOpen?: boolean; - children: ReactNode; -} - -function MonitorCard({ title, summary, defaultOpen = true, children }: MonitorCardProps) { - const [open, setOpen] = useState(defaultOpen); - return ( - <section className="jx-runMonitor-card" data-open={open || undefined}> - <button type="button" className="jx-runMonitor-cardHead" aria-expanded={open} onClick={() => setOpen(!open)}> - <span className="jx-runMonitor-cardTitle">{title}</span> - <CaretDownFilled className="jx-runMonitor-caret" aria-hidden /> - {summary != null && <span className="jx-runMonitor-summary">{summary}</span>} - </button> - {open && <div className="jx-runMonitor-cardBody">{children}</div>} - </section> - ); -} - function SubagentRow({ item, chatId, active }: { item: MonitorSubagent; chatId: string; active: boolean }) { const { tool, messageUid, isStreaming } = item; const agent = useSubagentIdentity(tool); @@ -271,7 +251,7 @@ function EnvironmentCard({ projectId }: { projectId: string }) { /** * Run monitor inside the conversation pane, right of the messages: one card per - * concern (project environment, context, sub-agents, output and input files, + * concern (scheduled task runs, project environment, context, sub-agents, output and input files, * background tasks). * Cards with nothing to show stay hidden. Rows open their sub-agent session or * file in the canvas; the rail hides when the pane is too narrow for both. @@ -280,6 +260,7 @@ export function RunMonitorRail() { const chatId = useChatStore((s) => s.currentChatId); const projectId = useChatStore((s) => (s.currentChatId ? s.store.chats[s.currentChatId]?.projectId : undefined)); const messages = useChatStore((s) => (s.currentChatId ? s.store.chats[s.currentChatId]?.messages : undefined)); + const automationTaskId = useChatStore((s) => (s.currentChatId ? s.store.chats[s.currentChatId]?.automationTaskId : undefined)); const activeTabId = useCanvasStore((s) => (s.isOpen ? s.activeTabId : null)); const subagents = useMemo(() => collectSubagents(messages), [messages]); @@ -290,6 +271,8 @@ export function RunMonitorRail() { return ( <aside className="jx-runMonitor" aria-label={t('运行监控')}> + {automationTaskId && <ScheduledTaskCard key={automationTaskId} taskId={automationTaskId} chatId={chatId} />} + {projectId && <EnvironmentCard key={projectId} projectId={projectId} />} <ContextCard toolCalls={toolStats.calls} toolFailed={toolStats.failed} /> diff --git a/src/frontend/src/components/monitor/ScheduledTaskCard.tsx b/src/frontend/src/components/monitor/ScheduledTaskCard.tsx new file mode 100644 index 00000000..3db90d80 --- /dev/null +++ b/src/frontend/src/components/monitor/ScheduledTaskCard.tsx @@ -0,0 +1,112 @@ +import { useEffect, useMemo, useState } from 'react'; +import { RightOutlined } from '@ant-design/icons'; +import { getAutomation, getAutomationRuns } from '../../api'; +import { t } from '../../i18n'; +import { useAutomationChatStore, useCatalogStore } from '../../stores'; +import type { AutomationRun } from '../../types'; +import { formatMonthDayTime } from '../../utils/date'; +import { RUN_STATUS_LABEL, formatRunOrdinal, isRunActive, summarizeRun } from '../automation/automationUtils'; +import { MonitorCard } from './MonitorCard'; + +/* Refresh while a run is active: status flips and chat_id links reach the card through it. */ +const RUN_POLL_INTERVAL_MS = 5_000; + +/** + * The scheduled task this run conversation belongs to: every run, newest first, + * each one opening its own conversation. Runs come from the active automation + * group when the route set one, otherwise they are fetched here. + */ +export function ScheduledTaskCard({ taskId, chatId }: { taskId: string; chatId: string }) { + const activeGroup = useAutomationChatStore((s) => s.activeGroup); + const setRuns = useAutomationChatStore((s) => s.setRuns); + const group = activeGroup?.taskId === taskId ? activeGroup : null; + const [fallback, setFallback] = useState<{ taskName: string; runs: AutomationRun[] } | null>(null); + const taskName = group?.taskName ?? fallback?.taskName ?? ''; + const runs = useMemo(() => group?.runs ?? fallback?.runs ?? [], [group, fallback]); + + useEffect(() => { + if (group || taskId === 'new') return; + let cancelled = false; + void Promise.all([getAutomation(taskId), getAutomationRuns(taskId, 50)]) + .then(([task, list]) => { if (!cancelled) setFallback({ taskName: task.name || taskId, runs: list }); }) + .catch(() => { /* The card stays hidden; the conversation itself still works. */ }); + return () => { cancelled = true; }; + }, [group, taskId]); + + const polling = runs.some(isRunActive); + useEffect(() => { + if (!polling) return; + let cancelled = false; + const timer = window.setInterval(async () => { + if (document.hidden) return; + try { + const list = await getAutomationRuns(taskId, 50); + if (cancelled) return; + // A response that lands after the user moved to another task must not write back. + if (useAutomationChatStore.getState().activeGroup?.taskId === taskId) setRuns(list); + else setFallback((prev) => (prev ? { ...prev, runs: list } : prev)); + } catch { /* Next tick retries. */ } + }, RUN_POLL_INTERVAL_MS); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [polling, taskId, setRuns]); + + if (taskId === 'new' || (!group && !fallback)) return null; + + const running = runs.filter(isRunActive).length; + const completed = runs.length - running; + const success = runs.filter((run) => run.status === 'success').length; + const rate = completed > 0 ? Math.round((success / completed) * 100) : 0; + + const openDetail = () => { + // The task id goes along with the jump, otherwise the panel falls back to the task list. + useCatalogStore.getState().setPanel('automation', taskId); + useAutomationChatStore.getState().exitAutomationChat(); + }; + const openRun = (run: AutomationRun) => { + const store = useAutomationChatStore.getState(); + if (store.activeGroup?.taskId === taskId) store.selectRun(run.run_id); + else store.enterAutomationChat(taskId, taskName, runs, run.run_id); + }; + + return ( + <MonitorCard + title={t('定时任务')} + className="jx-runMonitor-schedule" + summary={running > 0 + ? <span className="jx-runMonitor-scheduleLive">{t('{n} 运行中', { n: running })}</span> + : completed > 0 && <span className="jx-runMonitor-num">{t('{n} 次', { n: runs.length })} · {rate}%</span>} + > + <button type="button" className="jx-runMonitor-row jx-runMonitor-scheduleTask" title={t('返回定时任务详情')} onClick={openDetail}> + <span className="jx-runMonitor-name">{taskName}</span> + <RightOutlined className="jx-runMonitor-glyph" aria-hidden /> + </button> + {runs.length === 0 + ? <div className="jx-runMonitor-scheduleEmpty">{t('暂无运行记录')}</div> + : <div className="jx-runMonitor-scheduleRuns"> + {runs.map((run, index) => { + const summary = summarizeRun(run); + return ( + <button + key={run.run_id} + type="button" + className="jx-runMonitor-row" + data-active={run.chat_id === chatId || undefined} + disabled={!run.chat_id} + title={run.chat_id ? summary : t('准备中,暂不可查看')} + onClick={() => openRun(run)} + > + <span className="jx-runMonitor-lead"> + <span className={`jx-runMonitor-runDot${run.status === 'running' ? ' jx-anim-ripple' : ''}`} data-status={run.status} aria-label={RUN_STATUS_LABEL[run.status]} role="img" /> + </span> + <span className="jx-runMonitor-name"> + {formatRunOrdinal(runs.length - index)} + <span className="jx-runMonitor-runSummary"> {summary}</span> + </span> + <span className="jx-runMonitor-meta">{formatMonthDayTime(run.started_at)}</span> + </button> + ); + })} + </div>} + </MonitorCard> + ); +} diff --git a/src/frontend/src/components/sidebar/ChatSidebarItem.tsx b/src/frontend/src/components/sidebar/ChatSidebarItem.tsx index 692b7212..0ef2f46b 100644 --- a/src/frontend/src/components/sidebar/ChatSidebarItem.tsx +++ b/src/frontend/src/components/sidebar/ChatSidebarItem.tsx @@ -52,14 +52,21 @@ export function ChatSidebarItem({ item, groupItems, scopeKey, dragScopeRef, acti const waitingForAnswer = statusIds.some(id => (pendingUserQuestions[id]?.length ?? 0) > 0); const running = statusIds.some(id => sendingChatIds.has(id) || remoteRunningChatIds.has(id)); const waitingForConfirmation = statusIds.some(id => (pendingConfirm[id]?.length ?? 0) > 0 || !!pendingDesignPick[id]); - const indicator = taskIndicator(statusIds.flatMap(id => { + const groupNotices = statusIds.flatMap(id => { const notice = notices['chat:' + id]; // A journal pause can outlive its interaction. Only the actionable UI // queues above may light the conversation's yellow reminder. if (notice?.status === 'waiting') return []; // A newly admitted run supersedes a previous verdict before the next poll arrives. return notice && (!(sendingChatIds.has(id) || remoteRunningChatIds.has(id)) || notice.status === 'running') ? [notice] : []; - }), running, waitingForAnswer || waitingForConfirmation); + }); + // A scheduled task groups many runs: only the latest day's verdicts stay unread, + // so reading today's runs clears the dot without opening every older one. + const latestDay = statusChatIds && groupNotices.length + ? new Date(Math.max(...groupNotices.map(n => n.updatedAt))).toDateString() : null; + const indicator = taskIndicator(latestDay + ? groupNotices.map(n => n.seen || new Date(n.updatedAt).toDateString() === latestDay ? n : { ...n, seen: true }) + : groupNotices, running, waitingForAnswer || waitingForConfirmation); const handleClick = async () => { diff --git a/src/frontend/src/components/tool/CompactionStepRow.tsx b/src/frontend/src/components/tool/CompactionStepRow.tsx index 574f59ea..31ae5a7e 100644 --- a/src/frontend/src/components/tool/CompactionStepRow.tsx +++ b/src/frontend/src/components/tool/CompactionStepRow.tsx @@ -1,6 +1,6 @@ import { CloseOutlined, MinusOutlined } from '@ant-design/icons'; import { ToolIcon } from './toolIcons'; -import { ElapsedTimer } from '../common'; +import { ElapsedTimer, ShimmerText } from '../common'; import { t } from '../../i18n'; import { compactionLabel, type CompactionProgress } from '../../utils/compactionProgress'; @@ -16,7 +16,7 @@ export function CompactionStepRow({ progress }: { progress: CompactionProgress } {settledIcon ?? <ToolIcon kind="compress" className={`jx-tcr-kind${running ? ' jx-tcr-kind--running' : ''}`} />} </span> <span className="jx-tcr-label"> - <span className={`jx-tcr-prefix${running ? ' jx-tcr-prefix--shimmer' : ''}`}>{t(compactionLabel(progress))}</span> + <ShimmerText text={t(compactionLabel(progress))} active={running} className="jx-tcr-prefix" activeClassName="jx-tcr-prefix--shimmer" /> </span> {running && <ElapsedTimer startTs={progress.started_at} className="jx-tcr-timer" />} </div> diff --git a/src/frontend/src/components/tool/SubagentCallRow.tsx b/src/frontend/src/components/tool/SubagentCallRow.tsx index d0350da1..286680ae 100644 --- a/src/frontend/src/components/tool/SubagentCallRow.tsx +++ b/src/frontend/src/components/tool/SubagentCallRow.tsx @@ -1,5 +1,6 @@ import { ExclamationCircleOutlined, LoadingOutlined, PauseCircleOutlined } from '@ant-design/icons'; import { t } from '../../i18n'; +import { ShimmerText } from '../common/ShimmerText'; import { useSubagentIdentity } from '../../hooks/useSubagentIdentity'; import type { ToolCall } from '../../types'; import { subagentStatus } from '../../utils/subagentView'; @@ -29,7 +30,7 @@ export function SubagentCallRow({ tool, isStreaming, identity }: { aria-label={t('查看智能体「{name}」的执行过程', { name: agent.name })}> <span className="jx-tcr-status"><SubagentStatus tool={tool} isStreaming={isStreaming} /></span> <AgentIcon agent={agent} size={22} /> - <span className="jx-tcr-label"><span className={running ? 'jx-tcr-prefix jx-tcr-prefix--shimmer' : 'jx-tcr-prefix'}>{agent.name}</span></span> + <span className="jx-tcr-label"><ShimmerText text={agent.name} active={running} className="jx-tcr-prefix" activeClassName="jx-tcr-prefix--shimmer" /></span> <span className="jx-tcr-arrow" aria-hidden="true" /> </button> </div> diff --git a/src/frontend/src/components/tool/ThinkingStepRow.tsx b/src/frontend/src/components/tool/ThinkingStepRow.tsx index 5520789b..e6211ccb 100644 --- a/src/frontend/src/components/tool/ThinkingStepRow.tsx +++ b/src/frontend/src/components/tool/ThinkingStepRow.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { ToolIcon } from './toolIcons'; +import { ShimmerText } from '../common/ShimmerText'; import { t } from '../../i18n'; import { useCollapseHeight } from '../../hooks/useCollapseHeight'; @@ -43,9 +44,12 @@ export function ThinkingStepRow({ content, active }: ThinkingStepRowProps) { <ToolIcon kind="thinking" className={`jx-tcr-kind${active ? ' jx-tcr-kind--running' : ''}`} /> </span> <span className="jx-tcr-label"> - <span className={`jx-tcr-prefix${active ? ' jx-tcr-prefix--shimmer' : ''}`}> - {active ? t('正在思考…') : t('思考')} - </span> + <ShimmerText + text={active ? t('正在思考…') : t('思考')} + active={active} + className="jx-tcr-prefix" + activeClassName="jx-tcr-prefix--shimmer" + /> </span> {canExpand && ( <span className={`jx-tcr-arrow${expanded ? ' jx-tcr-arrow--open' : ''}`} /> diff --git a/src/frontend/src/components/tool/ToolCallRow.tsx b/src/frontend/src/components/tool/ToolCallRow.tsx index d619f963..ecd4a3a4 100644 --- a/src/frontend/src/components/tool/ToolCallRow.tsx +++ b/src/frontend/src/components/tool/ToolCallRow.tsx @@ -7,7 +7,7 @@ import type { ComponentType } from 'react'; import { PauseCircleOutlined } from '@ant-design/icons'; import type { ToolCall } from '../../types'; import { useChatStore, useUIStore } from '../../stores'; -import { ElapsedTimer } from '../common'; +import { ElapsedTimer, ShimmerText } from '../common'; import { renderToolOutputBody } from './ToolOutputRenderer'; import { ThinkingStepRow } from './ThinkingStepRow'; import { extractCodeFromInput, extractCodeFromStreamingArgs } from '../../utils/codeExecParser'; @@ -326,7 +326,7 @@ function StandardToolCallRow({ tool, isStreaming }: ToolCallRowProps) { : <StepIcon name={tool.name} running={running} />} </span> <span className="jx-tcr-label"> - <span className={`jx-tcr-prefix${running ? ' jx-tcr-prefix--shimmer' : ''}`}>{prefix}</span> + <ShimmerText text={prefix} active={running} className="jx-tcr-prefix" activeClassName="jx-tcr-prefix--shimmer" /> {value && <span className="jx-tcr-value">{value}</span>} {count != null && <span className="jx-tcr-count"> ({count})</span>} </span> diff --git a/src/frontend/src/hooks/chatStream/controls.ts b/src/frontend/src/hooks/chatStream/controls.ts index ff538c96..edb33e8f 100644 --- a/src/frontend/src/hooks/chatStream/controls.ts +++ b/src/frontend/src/hooks/chatStream/controls.ts @@ -76,18 +76,13 @@ if (eventType === 'meta') { return true; } if (eventType === 'file_confirm') { - // §13: some tool coroutine has suspended awaiting the user's confirmation of a - // /myspace write. Show the confirm bar; this SSE stream does **not** end — the user's - // allow/deny goes via an out-of-band POST /file-confirm, the suspended tool resumes in - // place, and subsequent tool_result/meta still arrive on this same stream. + // A suspended /myspace write awaits an out-of-band POST /file-confirm; the stream stays open. if (eventObj.expired) { - // The backend's confirmation-wait timeout reclaimed **that** pending item: remove - // only this one confirm_id from the queue (leave the other queued items alone), - // otherwise a user clicking much later would inevitably hit a dangling confirm_id error. + // The backend closed this one confirmation; drop only it from the queue. const _cid = String(eventObj.confirm_id ?? ''); if (_cid) { useUIStore.getState().resolvePendingConfirm(s.chatId, _cid); - if (!eventObj.resolved) message.info(t('一项「我的空间」写确认已超时取消,如仍需要请重新发起。')); + if (eventObj.outcome === 'timeout') message.info(t('一项「我的空间」写确认已超时取消,如仍需要请重新发起。')); } return true; } @@ -97,12 +92,7 @@ if (eventType === 'file_confirm') { return true; } if (eventType === 'design_pick') { - // Site-design pick-one-of-three: the choose_design tool coroutine suspends awaiting - // the user's pick. Same mechanism as file_confirm (suspend – out-of-band POST – resume - // on the original stream); the UI uses a separate pick card. applyDesignPickEvent(s.chatId, eventObj); - if (eventObj.expired) - message.info(t('设计方案选择已超时,助手将自行选择方案继续。')); return true; } if (eventType === 'user_question') { diff --git a/src/frontend/src/hooks/useAppBootstrap.ts b/src/frontend/src/hooks/useAppBootstrap.ts index e27acecf..ad5d3b49 100644 --- a/src/frontend/src/hooks/useAppBootstrap.ts +++ b/src/frontend/src/hooks/useAppBootstrap.ts @@ -5,12 +5,11 @@ import { useAuthStore, useChatStore, useEditionStore, useModelCapabilitiesStore, import { useAutomationChatStore } from '../stores/automationChatStore'; import { useAutomationStore } from '../stores/automationStore'; import { useCanvasStore } from '../stores/canvasStore'; -import type { ChatMode } from '../stores/chatStore'; import { useDeploymentModeStore } from '../stores/deploymentModeStore'; import { useMySpaceStore } from '../stores/mySpaceStore'; -import { usePageConfigStore } from '../stores/pageConfigStore'; import { useAutomationConversations } from './useAutomationConversations'; import { usePageConfigAll } from './usePageConfig'; +import { useDocumentFavicon } from './useDocumentFavicon'; export function useAppBootstrap(chatSurface: boolean) { const { pathname } = useLocation(); const pageConfig = usePageConfigAll(); @@ -63,34 +62,7 @@ export function useAppBootstrap(chatSurface: boolean) { if (pt && typeof document !== 'undefined') document.title = pt; }, [pageConfig.branding.page_title]); - useEffect(() => { - const fav = pageConfig.branding.favicon_url; - if (!fav || typeof document === 'undefined') return; - let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']"); - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - if (link.href !== fav) link.href = fav; - }, [pageConfig.branding.favicon_url]); - - // Once pageConfig finishes its first load, sync chatStore.chatMode to the admin-side - // "default chat mode". Runs only once, when loaded first flips, so remote config changes - // during the subsequent 15s polling never override the user's manual switch. - const pageConfigLoaded = usePageConfigStore((s) => s.loaded); - const setChatMode = useChatStore((s) => s.setChatMode); - const defaultChatModeApplied = useRef(false); - useEffect(() => { - if (!pageConfigLoaded || defaultChatModeApplied.current) return; - defaultChatModeApplied.current = true; - const VALID: readonly ChatMode[] = ['turbo', 'fast', 'low', 'medium', 'high', 'xhigh', 'max']; - const raw = pageConfig.defaults?.chat_mode as string | undefined; - const next: ChatMode = (raw && (VALID as readonly string[]).includes(raw)) - ? (raw as ChatMode) - : (pageConfig.defaults?.thinking_mode ? 'medium' : 'fast'); - setChatMode(next); - }, [pageConfigLoaded, pageConfig.defaults?.chat_mode, pageConfig.defaults?.thinking_mode, setChatMode]); + useDocumentFavicon(pageConfig.branding.favicon_url); // Fetch main-model capabilities at startup (decides whether the dropdown shows "Thinking: high/max") const fetchCapabilities = useModelCapabilitiesStore((s) => s.fetchCapabilities); diff --git a/src/frontend/src/hooks/useDocumentFavicon.ts b/src/frontend/src/hooks/useDocumentFavicon.ts new file mode 100644 index 00000000..8a3d0e6c --- /dev/null +++ b/src/frontend/src/hooks/useDocumentFavicon.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { usePageConfig } from './usePageConfig'; + +/** Point the browser tab icon at `url` (index.html's static <link rel="icon"> is only a first-frame fallback). */ +export function useDocumentFavicon(url: string | undefined): void { + useEffect(() => { + if (!url || typeof document === 'undefined') return; + let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + // The static tag declares type="image/png"; drop it so uploaded SVG / ICO icons are not misread. + link.removeAttribute('type'); + if (link.getAttribute('href') !== url) link.href = url; + }, [url]); +} + +/** Admin platform pages (/admin, /config, /api-docs) use their own tab icon, falling back to the main-site favicon. */ +export function useAdminPlatformFavicon(): void { + const adminFavicon = usePageConfig('navigation.admin_platform.favicon_url', ''); + const siteFavicon = usePageConfig('branding.favicon_url', '/icon.png'); + useDocumentFavicon(adminFavicon.trim() || siteFavicon.trim() || '/icon.png'); +} diff --git a/src/frontend/src/i18n/en/hooks.ts b/src/frontend/src/i18n/en/hooks.ts index 278a5696..361603d9 100644 --- a/src/frontend/src/i18n/en/hooks.ts +++ b/src/frontend/src/i18n/en/hooks.ts @@ -17,7 +17,6 @@ export const HOOKS_DICT: Record<string, string> = { // useStreaming — file confirm timeout '一项「我的空间」写确认已超时取消,如仍需要请重新发起。': 'A My Space write confirmation has timed out and been cancelled. Please resend if needed.', // useStreaming — design pick timeout - '设计方案选择已超时,助手将自行选择方案继续。': 'The design picker timed out; the assistant will pick an option and continue.', '等待回答已超时,助手将采用稳妥的默认方案继续。': 'The answer wait timed out; the assistant will continue with the safest default.', '连接已中断,任务状态尚未同步': 'The connection was interrupted; task status has not been synchronized.', // useStreaming — network / send errors diff --git a/src/frontend/src/i18n/en/lab.ts b/src/frontend/src/i18n/en/lab.ts index 3e9283af..3991b4f5 100644 --- a/src/frontend/src/i18n/en/lab.ts +++ b/src/frontend/src/i18n/en/lab.ts @@ -154,25 +154,14 @@ export const LAB_DICT: Record<string, string> = { '{n} 小时后': 'In {n} hours', '{n} 天后': 'In {n} days', - // ── RunTimelinePanel ────────────────────────────────────────── + // ── Scheduled-task run card ─────────────────────────────────── '返回定时任务详情': 'Back to scheduled task details', - '运行舱记录最近执行状态、摘要与异常线索': 'Run log showing latest status, summaries, and error clues', - '执行次数': 'Total Runs', '成功率': 'Success Rate', - '最近完成时间': 'Last Completed', - '最近一次{status}': 'Latest {status}', - '暂无执行': 'No runs yet', - '已完成 {done} 次,失败 {failed} 次': '{done} completed, {failed} failed', '详情': 'Details', '暂无运行记录': 'No run records', - '等待下一次自动执行或手动触发任务': 'Waiting for the next scheduled or manual trigger', '{n} 次': '{n} runs', - '执行中,点击查看实时进度': 'Running — click to view live progress', '准备中,暂不可查看': 'Preparing — not viewable yet', '查看进度': 'View Progress', - '耗时 {dur}': 'Duration: {dur}', - '耗时 —': 'Duration: —', - '进入对话': 'Open Chat', '生成中': 'Generating', '任务正在执行,结果生成后会自动更新到对话区。': 'Task is running. Results will appear in the chat when complete.', '执行失败,请进入详情查看错误信息。': 'Execution failed. Open details to view the error.', diff --git a/src/frontend/src/stores/automationChatStore.ts b/src/frontend/src/stores/automationChatStore.ts index 6d868900..1fa4242a 100644 --- a/src/frontend/src/stores/automationChatStore.ts +++ b/src/frontend/src/stores/automationChatStore.ts @@ -38,7 +38,7 @@ interface AutomationChatState { /** Owner of the in-memory state. Null until hydrated by login. */ currentUserId: string | null; /** Currently active automation chat group (null = normal chat mode). - * The "last frame" render during the exit animation is backed by derived state inside the RunTimelinePanel component; the store keeps no snapshot. */ + * Drives the scheduled-task card in the run monitor rail. */ activeGroup: AutomationChatGroup | null; /** Currently selected run ID within the group */ selectedRunId: string | null; diff --git a/src/frontend/src/stores/chatSupport.ts b/src/frontend/src/stores/chatSupport.ts index 00d2d6ce..df824e78 100644 --- a/src/frontend/src/stores/chatSupport.ts +++ b/src/frontend/src/stores/chatSupport.ts @@ -1,7 +1,8 @@ import { removeLocal, userScopedKey, writeLocal } from '../storage'; import type { ChatItem } from '../types'; import { normalizeChatInvocation } from '../utils/chatInvocation'; -import { usePageConfigStore } from './pageConfigStore'; +import { defaultThinkingEffort, selectedReasoningPolicy } from '../utils/reasoningEffort'; +import { useModelCapabilitiesStore } from './modelCapabilitiesStore'; import type { StoreApi } from 'zustand'; import type { ChatState, ChatMode, QueuedChatMessage, ThinkingEffort } from './chatState'; @@ -9,19 +10,15 @@ export const SITES_PLUGIN_SLUG = 'sites'; export const VALID_CHAT_MODES: readonly ChatMode[] = ['turbo', 'fast', 'low', 'medium', 'high', 'xhigh', 'max']; -export function adminDefaultChatMode(): ChatMode { - const defaults = usePageConfigStore.getState().config.defaults; - const raw = defaults?.chat_mode as string | undefined; - if (raw && (VALID_CHAT_MODES as readonly string[]).includes(raw)) { - return raw as ChatMode; - } - // Fallback for the legacy field - return defaults?.thinking_mode ? 'medium' : 'fast'; +/** New chats start at the selected model's default thinking level (Config → models → default level). */ +export function modelDefaultChatMode(): ThinkingEffort { + const { capabilities, selectedModelProviderId } = useModelCapabilitiesStore.getState(); + return defaultThinkingEffort(selectedReasoningPolicy(capabilities, selectedModelProviderId)); } export function applyDefaultChatMode(): { chatMode: ChatMode; lastStandardMode: ThinkingEffort } { - const mode = adminDefaultChatMode(); - return { chatMode: mode, lastStandardMode: mode === 'turbo' ? 'fast' : mode }; + const mode = modelDefaultChatMode(); + return { chatMode: mode, lastStandardMode: mode }; } export function isThinkingMode(mode: ChatMode): boolean { diff --git a/src/frontend/src/stores/modelCapabilitiesStore.ts b/src/frontend/src/stores/modelCapabilitiesStore.ts index 435f5263..d18e2fc9 100644 --- a/src/frontend/src/stores/modelCapabilitiesStore.ts +++ b/src/frontend/src/stores/modelCapabilitiesStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { getMainModelCapabilities, type ModelCapabilities } from '../api'; import { writeLocal, removeLocal } from '../storage'; import { useChatStore } from './chatStore'; -import { selectedReasoningPolicy, normalizeThinkingEffort } from '../utils/reasoningEffort'; +import { selectedReasoningPolicy, normalizeThinkingEffort, defaultThinkingEffort } from '../utils/reasoningEffort'; function normalizeSelectedEffort(caps: ModelCapabilities, id: string | null) { const chat = useChatStore.getState(); @@ -10,6 +10,14 @@ function normalizeSelectedEffort(caps: ModelCapabilities, id: string | null) { if (next !== chat.chatMode) chat.setChatMode(next); } +/** Jump to the model's default thinking level; locked modes (turbo) keep their own effort. */ +function applyModelDefaultEffort(caps: ModelCapabilities, id: string | null) { + const chat = useChatStore.getState(); + if (chat.chatMode === 'turbo') return; + const next = defaultThinkingEffort(selectedReasoningPolicy(caps, id)); + if (next !== chat.chatMode) chat.setChatMode(next); +} + const SELECTED_MODEL_PROVIDER_KEY = 'hugagent_selected_model_provider_id'; function loadSelectedProviderId(): string | null { @@ -48,7 +56,8 @@ export const useModelCapabilitiesStore = create<ModelCapabilitiesState>((set, ge setSelectedModelProviderId: (providerId) => { saveSelectedProviderId(providerId); set({ selectedModelProviderId: providerId }); - normalizeSelectedEffort(get().capabilities, providerId); + // Switching model starts from that model's own default thinking level. + applyModelDefaultEffort(get().capabilities, providerId); }, fetchCapabilities: async () => { if (get().fetching) return; @@ -66,7 +75,12 @@ export const useModelCapabilitiesStore = create<ModelCapabilitiesState>((set, ge ? (stillAvailable ? current : defaultModel?.provider_id || null) : null; if (nextSelected !== current) saveSelectedProviderId(nextSelected); - normalizeSelectedEffort(caps, nextSelected); + // First load: the chat opened before capabilities arrived, so it started from the placeholder + // policy — move it to the model's default unless the chat already recorded its own level. + const chat = useChatStore.getState(); + const savedEffort = chat.store.chats[chat.currentChatId]?.thinkingEffort; + if (!get().loaded && !savedEffort) applyModelDefaultEffort(caps, nextSelected); + else normalizeSelectedEffort(caps, nextSelected); set({ capabilities: caps, selectedModelProviderId: nextSelected, diff --git a/src/frontend/src/styles/automation-timeline.css b/src/frontend/src/styles/automation-timeline.css deleted file mode 100644 index 1d1d1295..00000000 --- a/src/frontend/src/styles/automation-timeline.css +++ /dev/null @@ -1,609 +0,0 @@ -/* ══════════════════════════════════════════ - RunTimelinePanel — 自动化任务运行舱 - Figma 参考设计:干净扁平,灰底卡片 + 主色强调 + 时间线 - ══════════════════════════════════════════ */ - -.jx-runTimeline { - width: 384px; - min-width: 340px; - flex-shrink: 0; - height: 100%; - display: flex; - flex-direction: column; - position: relative; - overflow: hidden; - color: var(--color-text); - background: var(--color-bg-container); - border-left: 1px solid var(--color-border); - /* 入场/退场由 App.tsx 的 AnimatePresence + motion.div 接管,CSS 不再做 slideIn(避免双播) */ - font-family: var(--font-family); -} - -/* ── 顶部 bar ───────────────────────────────────────────────────────────── */ - -.jx-runTimeline-topBar { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 0 20px; - height: 56px; - border-bottom: 1px solid var(--color-border); - background: var(--color-bg-container); -} - -.jx-runTimeline-crumb { - appearance: none; - border: none; - background: transparent; - padding: 4px 0; - display: inline-flex; - align-items: center; - gap: 6px; - font-family: inherit; - font-size: 18px; - font-weight: 700; - color: var(--color-text); - cursor: pointer; - transition: color 0.15s ease; -} - -.jx-runTimeline-crumb:hover { - color: var(--color-primary); -} - -.jx-runTimeline-crumbIcon { - font-size: 12px; - color: var(--color-text-tertiary); - transition: color 0.15s ease; -} - -.jx-runTimeline-crumb:hover .jx-runTimeline-crumbIcon { - color: var(--color-primary); -} - -.jx-runTimeline-closeBtn { - appearance: none; - border: none; - background: transparent; - padding: 0; - width: 28px; - height: 28px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-xs); - cursor: pointer; - color: var(--color-text-tertiary); - font-size: 16px; - transition: background-color 0.15s ease, color 0.15s ease; -} - -.jx-runTimeline-closeBtn:hover { - background: var(--color-bg-gray); - color: var(--color-text); -} - -/* ── 可滚动主体 ─────────────────────────────────────────────────────────── */ - -.jx-runTimeline-body { - flex: 1; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; - padding: 16px 20px 24px; - display: flex; - flex-direction: column; - gap: 20px; -} - -.jx-runTimeline-body::-webkit-scrollbar { - width: 4px; -} - -.jx-runTimeline-body::-webkit-scrollbar-thumb { - background: rgba(100, 116, 139, 0.16); - border-radius: 999px; -} - -/* ── 任务标题 ───────────────────────────────────────────────────────────── */ - -.jx-runTimeline-titleBlock { - display: flex; - flex-direction: column; - gap: 8px; -} - -.jx-runTimeline-title { - font-size: 20px; - line-height: 1.35; - font-weight: 700; - color: var(--color-text); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; -} - -.jx-runTimeline-subtitle { - font-size: var(--font-size-sm); - line-height: 1.6; - color: var(--color-text-tertiary); - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; -} - -/* ── 顶部 3 列统计 ──────────────────────────────────────────────────────── */ - -.jx-runTimeline-statRow { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; -} - -.jx-runTimeline-statCard { - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 12px; - border-radius: var(--radius-md); - background: var(--color-bg-gray); -} - -.jx-runTimeline-statLabel { - font-size: var(--font-size-sm); - color: var(--color-text-tertiary); - line-height: 1.4; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.jx-runTimeline-statValue { - font-size: 20px; - line-height: 1.25; - font-weight: 700; - color: var(--color-text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - /* 数字与字母等宽对齐,但保持 PingFang SC 字形与中文标签一致 */ - font-feature-settings: "tnum"; -} - -.jx-runTimeline-statValue.is-primary { - color: var(--color-primary); -} - -/* ── 最近一次状态 ───────────────────────────────────────────────────────── */ - -.jx-runTimeline-latestRow { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: var(--font-size-sm); - color: var(--color-text-tertiary); - line-height: 1.4; - flex-wrap: wrap; -} - -.jx-runTimeline-latestIcon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - font-size: 16px; - color: var(--color-success); -} - -.jx-runTimeline-latestRow.is-failed .jx-runTimeline-latestIcon { - color: var(--color-error); -} - -.jx-runTimeline-latestRow.is-running .jx-runTimeline-latestIcon { - color: var(--color-primary); -} - -.jx-runTimeline-latestRow.is-idle .jx-runTimeline-latestIcon { - color: var(--color-text-placeholder); -} - -.jx-runTimeline-latestLabel { - color: var(--color-text-tertiary); -} - -.jx-runTimeline-latestTime { - color: var(--color-text-tertiary); - font-feature-settings: "tnum"; -} - -/* ── Section 通用结构 ───────────────────────────────────────────────────── */ - -.jx-runTimeline-section { - display: flex; - flex-direction: column; - gap: 12px; -} - -.jx-runTimeline-sectionHeading { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 15px; - font-weight: 700; - color: var(--color-text); - line-height: 1.2; -} - -.jx-runTimeline-sectionBar { - display: inline-block; - width: 2px; - height: 12px; - background: var(--color-primary); - border-radius: 1px; -} - -.jx-runTimeline-sectionMeta { - font-size: var(--font-size-sm); - color: var(--color-text-tertiary); - line-height: 1.4; - margin-top: -4px; -} - -/* ── 小 4 列统计(全部/成功/失败/执行中) ───────────────────────────────── */ - -.jx-runTimeline-miniStatRow { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; -} - -.jx-runTimeline-miniStat { - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - padding: 10px 12px; - border-radius: var(--radius-md); - background: var(--color-bg-gray); -} - -.jx-runTimeline-miniStatLabel { - font-size: var(--font-size-sm); - color: var(--color-text-tertiary); - line-height: 1.4; -} - -.jx-runTimeline-miniStatValue { - font-size: var(--font-size-md); - font-weight: 700; - color: var(--color-text); - line-height: 1.25; - font-feature-settings: "tnum"; -} - -/* ── 空状态 ─────────────────────────────────────────────────────────────── */ - -.jx-runTimeline-empty { - padding: 32px 20px; - text-align: center; - border-radius: var(--radius-md); - border: 1px dashed var(--color-border); - color: var(--color-text-tertiary); - background: var(--color-bg-container); -} - -.jx-runTimeline-emptyTitle { - font-size: var(--font-size-sm); - font-weight: 500; - color: var(--color-text); -} - -.jx-runTimeline-emptyHint { - margin-top: 6px; - font-size: var(--font-size-xs); - line-height: 1.6; - color: var(--color-text-tertiary); -} - -/* ── 时间线 ─────────────────────────────────────────────────────────────── */ - -.jx-runTimeline-timeline { - display: flex; - flex-direction: column; - position: relative; - padding-left: 0; -} - -.jx-runTimeline-dateGroup { - position: relative; - padding-left: 20px; - padding-bottom: 20px; -} - -.jx-runTimeline-dateGroup:last-child { - padding-bottom: 0; -} - -/* 蓝色圆点 — 日期组头部左侧 */ -.jx-runTimeline-dateGroup::before { - content: ''; - position: absolute; - left: 0; - top: 6px; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--color-primary); - z-index: 1; -} - -/* 竖直连接线 — 圆点下方延伸,跨越卡片到下一个组 */ -.jx-runTimeline-dateGroup::after { - content: ''; - position: absolute; - left: 3px; - top: 18px; - bottom: 0; - width: 1px; - background: var(--color-border); -} - -.jx-runTimeline-dateGroup:last-child::after { - display: none; -} - -.jx-runTimeline-dateHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding-bottom: 10px; -} - -.jx-runTimeline-dateLabel { - font-size: var(--font-size-sm); - font-weight: 700; - color: var(--color-text); - line-height: 1.2; - font-feature-settings: "tnum"; -} - -.jx-runTimeline-dateCount { - font-size: var(--font-size-sm); - font-weight: 500; - color: var(--color-text-tertiary); - line-height: 1.2; - font-feature-settings: "tnum"; -} - -.jx-runTimeline-dateBody { - display: flex; - flex-direction: column; - gap: 12px; -} - -/* ── 单次执行卡片 ───────────────────────────────────────────────────────── */ - -.jx-runTimeline-item { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px; - border-radius: var(--radius-sm); - background: var(--color-bg-container); - border: 1px solid var(--color-border); - cursor: pointer; - transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; - /* 列表项入场 stagger:--stagger-i 由组件按扁平序号注入(封顶 8); - keyed by run_id,轮询整组替换不重挂、不重播;新 run 顶插自然走入场 */ - animation: jx-kf-fadeInUp 0.3s var(--motion-ease-brand-out) both; - animation-delay: calc(var(--stagger-i, 0) * 35ms); -} - -/* 刚完成(running→success/failed 翻转帧)的一次性边框闪光。 - 必须连同入场动画一起声明:单独覆盖 animation 会丢掉 fadeInUp 的 both 终态, - 且移除该类时 animation-name 列表回缩不会重启 fadeInUp。 */ -.jx-runTimeline-item.is-justFinished { - animation: - jx-kf-fadeInUp 0.3s var(--motion-ease-brand-out) both, - jx-runTimeline-finishFlash 0.2s var(--motion-ease-standard) 1; - animation-delay: calc(var(--stagger-i, 0) * 35ms), 0ms; -} - -.jx-runTimeline-item.is-justFinished.is-failed { - animation-name: jx-kf-fadeInUp, jx-runTimeline-finishFlashFail; -} - -@keyframes jx-runTimeline-finishFlash { - 0% { border-color: var(--color-success); box-shadow: 0 0 0 3px rgba(2, 181, 137, 0.18); } - 100% { border-color: var(--color-border); box-shadow: 0 0 0 0 rgba(2, 181, 137, 0); } -} -/* motion-stop: 减弱动效下定格在终态(同名关键帧后定义者胜,用到它的规则一起生效) */ -@media (prefers-reduced-motion: reduce) { - @keyframes jx-runTimeline-finishFlash { from, to { border-color: var(--color-border) } } -} - - -@keyframes jx-runTimeline-finishFlashFail { - 0% { border-color: var(--color-error); box-shadow: 0 0 0 3px rgba(252, 93, 93, 0.16); } - 100% { border-color: var(--color-border); box-shadow: 0 0 0 0 rgba(252, 93, 93, 0); } -} -/* motion-stop: 减弱动效下定格在终态(同名关键帧后定义者胜,用到它的规则一起生效) */ -@media (prefers-reduced-motion: reduce) { - @keyframes jx-runTimeline-finishFlashFail { from, to { border-color: var(--color-border) } } -} - - -.jx-runTimeline-item:hover:not(.is-disabled) { - border-color: var(--color-primary); - box-shadow: 0 4px 12px rgba(18, 109, 255, 0.08); - /* 与 transition 中已声明的 transform 配套(原为死代码 B4) */ - transform: translateY(-1px); -} - -.jx-runTimeline-item.is-active { - border-color: var(--color-primary); - box-shadow: 0 0 0 2px var(--color-primary-light); -} - -.jx-runTimeline-item.is-disabled { - cursor: not-allowed; - opacity: 0.72; -} - -.jx-runTimeline-itemTop { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.jx-runTimeline-itemIdentity { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -.jx-runTimeline-runNo { - font-size: var(--font-size-sm); - font-weight: 700; - color: var(--color-text); - line-height: 1.2; - white-space: nowrap; -} - -.jx-runTimeline-statusTag { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 1px 6px; - border-radius: var(--radius-xs); - background: var(--color-bg-container); - border: 1px solid var(--color-border); - color: var(--color-text-tertiary); - font-size: var(--font-size-xs); - line-height: 1.4; - white-space: nowrap; - transition: color .25s var(--motion-ease-standard), border-color .25s var(--motion-ease-standard); -} - -.jx-runTimeline-statusTag.is-success { - border-color: var(--color-success-disabled); - color: var(--color-success); -} - -.jx-runTimeline-statusTag.is-failed { - border-color: var(--color-error-disabled); - color: var(--color-error); -} - -/* 运行中扩散涟漪:组件层挂 .jx-anim-ripple(motion.css 原语,compositor-only), - 小倍数扩散 ≈ 原 6px box-shadow 呼吸幅度 */ -.jx-runTimeline-statusTag.is-running { - border-color: var(--color-primary-disabled); - color: var(--color-primary); - --ripple-color: var(--color-primary); - --ripple-scale: 1.25; -} - -.jx-runTimeline-itemTime { - flex-shrink: 0; - font-size: var(--font-size-sm); - color: var(--color-text-tertiary); - line-height: 1.2; - text-align: right; - font-feature-settings: "tnum"; -} - -.jx-runTimeline-itemSummary { - font-size: var(--font-size-sm); - line-height: 1.55; - color: var(--color-text-tertiary); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; -} - -.jx-runTimeline-itemBottom { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.jx-runTimeline-itemDuration { - font-size: var(--font-size-sm); - color: var(--color-text-placeholder); - line-height: 1.2; - font-feature-settings: "tnum"; -} - -.jx-runTimeline-itemEnter { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 6px; - border-radius: var(--radius-sm); - color: var(--color-text-tertiary); - font-size: var(--font-size-sm); - line-height: 1.2; - transition: background-color 0.15s ease, color 0.15s ease; -} - -.jx-runTimeline-itemEnter .anticon { - font-size: 14px; -} - -.jx-runTimeline-item:hover:not(.is-disabled) .jx-runTimeline-itemEnter { - background: var(--color-primary-light); - color: var(--color-primary); -} - -.jx-runTimeline-itemEnter.is-disabled { - color: var(--color-text-placeholder); -} - -/* ── 响应式 ─────────────────────────────────────────────────────────────── */ - -@media (max-width: 1440px) { - .jx-runTimeline { - width: 340px; - min-width: 320px; - } -} - -@media (max-width: 1180px) { - .jx-runTimeline { - width: 308px; - min-width: 288px; - } - - .jx-runTimeline-body { - padding: 14px 16px 20px; - } - - .jx-runTimeline-topBar { - padding: 0 16px; - } - - .jx-runTimeline-statValue { - font-size: 18px; - } -} - -@media (max-width: 980px) { - .jx-runTimeline { - display: none; - } -} - diff --git a/src/frontend/src/styles/chat/turn-status.css b/src/frontend/src/styles/chat/turn-status.css index 86547fd4..d9b7df7b 100644 --- a/src/frontend/src/styles/chat/turn-status.css +++ b/src/frontend/src/styles/chat/turn-status.css @@ -56,27 +56,45 @@ } /* Shared reference shimmer for startup, thinking, tool calls and execution. - Match the actual label classes with enough specificity to beat tool.css's - historical entrance/static rules; completed labels lose their live class. - Rolling labels (RollingText) scan their inner text span, so the roll's own - transform animation never competes with the scan for `animation`. */ -/* motion-keep: Text scanning is an activity signal while a request is running. */ + ShimmerText lays a masked copy of the label over it: the band slides right + while the copy inside slides left by the same amount, so the glyphs stay put + and only `transform` animates (compositor-only — a `background-clip:text` + gradient repaints every frame and stutters while the stream is rendering). + The modifier classes beat tool.css's own prefix colour. */ .jx-rollText-text.is-shimmer, .jx-tcr .jx-tcr-prefix.jx-tcr-prefix--shimmer{ - background-image:linear-gradient(90deg, - color-mix(in srgb, var(--color-text-secondary) 80%, var(--color-primary)) 0%, - var(--color-text-secondary) 35%, - color-mix(in srgb, var(--color-text-secondary) 55%, var(--color-bg-chat)) 50%, - var(--color-text-secondary) 65%, - color-mix(in srgb, var(--color-text-secondary) 80%, var(--color-primary)) 100%); - background-size:250% 100%; - -webkit-background-clip:text; - background-clip:text; - -webkit-text-fill-color:transparent; - color:transparent; - animation:jxTurnStatusShine 2.8s linear infinite; + position:relative; + display:inline-block; + color:var(--color-text-secondary); +} +.jx-shimmer-band, +.jx-shimmer-glint{ + animation-duration:2.8s; + animation-timing-function:linear; + animation-delay:var(--jx-shimmer-delay, 0s); + animation-iteration-count:infinite; + will-change:transform; +} +.jx-shimmer-band{ + position:absolute; + inset:0; + pointer-events:none; + /* A flat middle, not a single peak: a peak-only band blends to half strength + and the sweep becomes invisible. */ + -webkit-mask-image:linear-gradient(90deg, transparent, #000 35%, #000 65%, transparent); + mask-image:linear-gradient(90deg, transparent, #000 35%, #000 65%, transparent); + /* motion-keep: Text scanning is an activity signal while a request is running. */ + animation-name:jxShimmerBand; +} +.jx-shimmer-glint{ + position:absolute; + top:0; + left:0; + white-space:nowrap; + color:color-mix(in srgb, var(--color-text-secondary) 25%, var(--color-bg-chat)); + /* motion-keep: Text scanning is an activity signal while a request is running. */ + animation-name:jxShimmerGlint; } - .jx-turnStatus-clock{ flex-shrink:0; margin-left:2px; @@ -86,10 +104,13 @@ color:var(--color-text-secondary); } -@keyframes jxTurnStatusShine{ - 0%,15%{ - background-position:83.333% 0; - animation-timing-function:cubic-bezier(.714,.121,.211,.888); - } - 85%,100%{background-position:16.667% 0;} +/* Constant speed across the label: an eased sweep crawls in from the left + and then rushes, which reads as a stall. The pause sits off-label. */ +@keyframes jxShimmerBand{ + 0%,15%{transform:translateX(-100%);} + 85%,100%{transform:translateX(100%);} +} +@keyframes jxShimmerGlint{ + 0%,15%{transform:translateX(100%);} + 85%,100%{transform:translateX(-100%);} } diff --git a/src/frontend/src/styles/index.ts b/src/frontend/src/styles/index.ts index b16978e8..0802a02e 100644 --- a/src/frontend/src/styles/index.ts +++ b/src/frontend/src/styles/index.ts @@ -22,6 +22,7 @@ import './canvas.css'; import '../plugin-ui/styles.css'; import './plan.css'; import './config.css'; +import './task-monitor.css'; import './projects.css'; import './admin.css'; import './evolution.css'; diff --git a/src/frontend/src/styles/mobile/touch.css b/src/frontend/src/styles/mobile/touch.css index 21989a92..7f1c0f40 100644 --- a/src/frontend/src/styles/mobile/touch.css +++ b/src/frontend/src/styles/mobile/touch.css @@ -305,6 +305,6 @@ 由 `components/lab/AutomationPanel.tsx` 自己 import,打包后它排在 mobile.css **之后**。实测现象是页头的两级选择器规则生效、单类规则(如 presetsNav 的 display:none)被 automation.css 原样盖回。`body ` 把特异性抬到 0,1,1 即可稳赢。 - 同样由组件自行 import 的还有 sites.css / skill-distill.css / automation-timeline.css, + 同样由组件自行 import 的还有 sites.css / skill-distill.css, 以后给这几页写移动端规则要一并注意。 ══════════════════════════════════════════════════════════════════════ */ diff --git a/src/frontend/src/styles/motion.css b/src/frontend/src/styles/motion.css index fa0231a1..45416ee6 100644 --- a/src/frontend/src/styles/motion.css +++ b/src/frontend/src/styles/motion.css @@ -71,10 +71,6 @@ .jx-anim-pulse { animation: jx-kf-pulse 1.6s var(--motion-ease-standard) infinite; } /* 6. shimmer:骨架屏流光 */ -@keyframes jx-kf-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} /* Skeleton surfaces and their transform sweep live in skeleton.css. */ /* 7. flash:跳转定位 / 保存成功的一次性高亮。 diff --git a/src/frontend/src/styles/task-monitor.css b/src/frontend/src/styles/task-monitor.css new file mode 100644 index 00000000..b75a1890 --- /dev/null +++ b/src/frontend/src/styles/task-monitor.css @@ -0,0 +1,273 @@ +/* ── TaskMonitorPanel:任务监控(/config → 数据监控 → 任务监控) ── */ + +.jx-tm { display: flex; flex-direction: column; gap: 16px; } +.jx-tmMono { font-family: var(--font-family-mono); font-size: 0.75rem; } +.jx-tmMuted { color: var(--color-text-tertiary); font-size: 0.75rem; } +.jx-tmErrText { color: var(--color-error-text); } +.jx-tmOkText { color: var(--color-success-text); font-size: 0.75rem; } +.jx-tmNowrap { white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; } +.jx-tmNowrap .ant-tag { margin: 0; } +.jx-tmEllipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.jx-tmGrow { flex: 1; } +.jx-tmCenter { display: flex; justify-content: center; padding: 48px 0; } +.jx-tmEmpty { padding: 24px 0; text-align: center; color: var(--color-text-tertiary); font-size: 0.8125rem; } + +/* Toolbar */ +.jx-tmToolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; } +.jx-tmToolbar-l { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.jx-tmH1 { margin: 0; font-size: 1.375rem; font-weight: 600; color: var(--color-text); } +.jx-tmLive { display: inline-flex; align-items: center; gap: 8px; font-size: 0.8125rem; color: var(--color-text-secondary); } +.jx-tmLive i { width: 8px; height: 8px; border-radius: 50%; background: var(--color-success); } +.jx-tmTypeSelect { min-width: 160px; } +.jx-tmTaskSearch { width: 288px; max-width: 100%; } + +/* Cards */ +.jx-tmCard { border-radius: var(--radius-md); } +.jx-tmCard--fill { height: 100%; } +.jx-tmTitle { display: inline-flex; align-items: baseline; gap: 8px; } +.jx-tmTitle-q { font-size: 0.75rem; font-weight: 400; color: var(--color-text-tertiary); } +.jx-tmExtra { display: inline-flex; flex-wrap: wrap; gap: 8px; } +.jx-tmGrid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +@media (max-width: 1100px) { .jx-tmGrid2 { grid-template-columns: minmax(0, 1fr); } } + +/* Semantic fills shared by charts and legends */ +.jx-tmFill--ok { background: var(--color-success); } +.jx-tmFill--err { background: var(--color-error); } +.jx-tmFill--warn { background: var(--color-warning); } +.jx-tmFill--muted { background: var(--color-fill-heavy); } +.jx-tmFill--primary { background: var(--color-primary); } + +/* Cockpit */ +.jx-tmCockpit { display: grid; grid-template-columns: minmax(0, 3fr) minmax(260px, 1fr); gap: 16px; align-items: center; } +@media (max-width: 1200px) { .jx-tmCockpit { grid-template-columns: minmax(0, 1fr); } } +.jx-tmGauges { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; } +@media (max-width: 1100px) { .jx-tmGauges { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +.jx-tmGauge { display: flex; flex-direction: column; align-items: center; min-width: 0; text-align: center; } +.jx-tmGauge .ant-progress-circle-trail { stroke: var(--color-fill); } +.jx-tmGauge-value { font-family: var(--font-family-number); font-variant-numeric: tabular-nums; font-size: 1.25rem; font-weight: 600; color: var(--color-text); } +.jx-tmGauge--none .jx-tmGauge-value { color: var(--color-text-tertiary); } +.jx-tmGauge-label { margin-top: -8px; font-size: 0.8125rem; font-weight: 500; color: var(--color-text); } +.jx-tmGauge-caption { font-size: 0.75rem; color: var(--color-text-tertiary); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.jx-tmSide { display: flex; flex-direction: column; gap: 8px; } +.jx-tmCounters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.jx-tmCounter { padding: 12px; border-radius: var(--radius-sm); background: var(--color-bg-layout); } +.jx-tmCounter-l { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: var(--color-text-secondary); } +.jx-tmCounter-l i { width: 8px; height: 8px; border-radius: 50%; } +.jx-tmCounter-v { display: block; font-family: var(--font-family-number); font-variant-numeric: tabular-nums; font-size: 1.5rem; font-weight: 600; line-height: 1.3; color: var(--color-text); } +.jx-tmAlertBox { + display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; + padding: 12px; border-radius: var(--radius-sm); border: 1px solid var(--color-border); + background: var(--color-bg-layout); color: var(--color-text); text-align: left; cursor: pointer; font: inherit; +} +.jx-tmAlertBox:hover { background: var(--color-fill); } +.jx-tmAlertBox--hot { border-color: var(--color-error); background: var(--color-error-bg); } +.jx-tmAlertBox--hot:hover { background: var(--color-error-bg); } +.jx-tmAlertBox--hot .jx-tmCounter-v { color: var(--color-error-text); } +.jx-tmSevRow { display: inline-flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; } + +.jx-tmSev { display: inline-flex; padding: 0 6px; border-radius: var(--radius-xs); font-family: var(--font-family-mono); font-size: 0.6875rem; font-weight: 600; line-height: 20px; } +.jx-tmSev--P0 { background: var(--color-error-bg); color: var(--color-error-text); } +.jx-tmSev--P1 { background: var(--color-warning-bg); color: var(--color-warning-text); } +.jx-tmSev--P2 { background: var(--color-fill); color: var(--color-text-secondary); font-weight: 500; } + +/* Charts */ +.jx-tmCharts { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); gap: 24px; margin-top: 16px; } +@media (max-width: 1100px) { .jx-tmCharts { grid-template-columns: minmax(0, 1fr); } } +.jx-tmChartTitle { margin-bottom: 8px; font-size: 0.8125rem; font-weight: 600; color: var(--color-text); } +.jx-tmLegend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 8px; font-size: 0.75rem; color: var(--color-text-secondary); } +.jx-tmLegend span { display: inline-flex; align-items: center; gap: 4px; } +.jx-tmLegend i { width: 8px; height: 8px; border-radius: 2px; } +.jx-tmLegend b { color: var(--color-text); font-weight: 600; font-variant-numeric: tabular-nums; } +.jx-tmBars-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; justify-content: flex-end; font-size: 0.75rem; color: var(--color-text-secondary); } +.jx-tmBars-legend span { display: inline-flex; align-items: center; gap: 4px; } +.jx-tmBars-legend i { width: 8px; height: 8px; border-radius: 2px; } +.jx-tmBars-plot { display: flex; gap: 8px; height: 168px; margin-top: 8px; } +.jx-tmBars-axis { display: flex; flex-direction: column; justify-content: space-between; padding-bottom: 18px; font-size: 0.6875rem; color: var(--color-text-tertiary); font-variant-numeric: tabular-nums; text-align: right; } +.jx-tmBars-cols { flex: 1; display: flex; gap: 3px; align-items: stretch; border-left: 1px solid var(--color-border); padding-left: 4px; min-width: 0; } +.jx-tmBars-col { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: flex-end; } +.jx-tmBars-stack { display: flex; flex-direction: column-reverse; gap: 1px; border-radius: 3px 3px 0 0; overflow: hidden; min-height: 0; } +.jx-tmBars-stack span { flex-basis: 0; min-height: 2px; } +.jx-tmBars-x { height: 18px; padding-top: 4px; font-size: 0.6875rem; color: var(--color-text-tertiary); white-space: nowrap; overflow: visible; } +.jx-tmDonut { display: flex; flex-direction: column; align-items: center; } +.jx-tmDonut svg { width: 132px; height: 132px; transform: rotate(-90deg); } +.jx-tmDonut circle { fill: none; stroke-width: 14; } +.jx-tmDonut-track { stroke: var(--color-fill); } +.jx-tmDonut-total { transform: rotate(90deg); transform-origin: 50px 50px; fill: var(--color-text); font-size: 16px; font-weight: 600; } +.jx-tmStroke--ok { stroke: var(--color-success); } +.jx-tmStroke--err { stroke: var(--color-error); } +.jx-tmStroke--warn { stroke: var(--color-warning); } +.jx-tmStroke--muted { stroke: var(--color-fill-heavy); } +.jx-tmDonut .jx-tmLegend i.jx-tmStroke--ok { background: var(--color-success); } +.jx-tmDonut .jx-tmLegend i.jx-tmStroke--err { background: var(--color-error); } +.jx-tmDonut .jx-tmLegend i.jx-tmStroke--warn { background: var(--color-warning); } +.jx-tmDonut .jx-tmLegend i.jx-tmStroke--muted { background: var(--color-fill-heavy); } +.jx-tmHbars { display: flex; flex-direction: column; gap: 8px; } +.jx-tmHbars-row { display: grid; grid-template-columns: 64px minmax(0, 1fr) 32px; align-items: center; gap: 8px; font-size: 0.75rem; color: var(--color-text-secondary); } +.jx-tmHbars-label { text-align: right; white-space: nowrap; } +.jx-tmHbars-track { height: 10px; border-radius: 0 4px 4px 0; overflow: hidden; } +.jx-tmHbars-track i { display: block; height: 100%; background: var(--color-primary); border-radius: 0 4px 4px 0; } +.jx-tmHbars-value { font-variant-numeric: tabular-nums; color: var(--color-text); } + +/* Run table */ +.jx-tmRow { cursor: pointer; } +.jx-tmRow--abnormal > td:first-child { box-shadow: inset 3px 0 0 var(--color-error); } +.jx-tmLevel { display: inline-block; padding: 0 8px; border-radius: var(--radius-xs); font-size: 0.75rem; line-height: 22px; } +.jx-tmLevel--low { background: var(--color-tint-teal-bg); color: var(--color-tint-teal); } +.jx-tmLevel--medium { background: var(--color-tint-blue-bg); color: var(--color-tint-blue); } +.jx-tmLevel--high { background: var(--color-tint-purple-bg); color: var(--color-tint-purple); } +.jx-tmDur { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; font-variant-numeric: tabular-nums; } +.jx-tmMiniBar { width: 40px; height: 4px; border-radius: 2px; background: var(--color-fill); overflow: hidden; } +.jx-tmMiniBar i { display: block; height: 100%; background: var(--color-primary); } +.jx-tmMiniBar--over i { background: var(--color-error); } + +/* Alerts and tickets */ +.jx-tmList { display: flex; flex-direction: column; gap: 8px; max-height: 560px; overflow-y: auto; } +.jx-tmAlert { display: flex; flex-direction: column; gap: 4px; padding: 12px; border-radius: var(--radius-sm); background: var(--color-bg-layout); border-left: 3px solid var(--color-fill-heavy); } +.jx-tmAlert--P0 { border-left-color: var(--color-error); } +.jx-tmAlert--P1 { border-left-color: var(--color-warning); } +.jx-tmAlert--done { opacity: .64; } +.jx-tmAlert-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.jx-tmAlert-kind { font-weight: 600; color: var(--color-text); } +.jx-tmAlert-time { margin-left: auto; font-family: var(--font-family-mono); font-size: 0.75rem; color: var(--color-text-tertiary); } +.jx-tmAlert-detail { font-size: 0.8125rem; color: var(--color-text-secondary); word-break: break-word; } +.jx-tmAlert-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.jx-tmLinkBtn.ant-btn { padding: 0; height: auto; } + +/* Quality */ +.jx-tmMetrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } +@media (max-width: 640px) { .jx-tmMetrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.jx-tmMetric, .jx-tmResBox { padding: 12px; border-radius: var(--radius-sm); background: var(--color-bg-layout); min-width: 0; } +.jx-tmMetric-l { font-size: 0.75rem; color: var(--color-text-secondary); } +.jx-tmMetric-v { font-family: var(--font-family-number); font-variant-numeric: tabular-nums; font-size: 1.25rem; font-weight: 600; color: var(--color-text); } +.jx-tmMetric-v small { margin-left: 2px; font-size: 0.75rem; font-weight: 500; color: var(--color-text-secondary); } +.jx-tmMetric-c { font-size: 0.75rem; color: var(--color-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.jx-tmSub { margin-top: 16px; } +.jx-tmSub-h { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 0.8125rem; font-weight: 600; color: var(--color-text); } +.jx-tmStackbar { display: flex; gap: 2px; height: 12px; border-radius: 999px; overflow: hidden; background: var(--color-fill); } +.jx-tmStackbar span { flex-basis: 0; } +.jx-tmRowLine { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 0.8125rem; min-width: 0; } +.jx-tmRowLine .ant-tag { margin: 0; flex-shrink: 0; } +.jx-tmRowLine .ant-btn { flex-shrink: 0; } +.jx-tmRowLine-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-secondary); } + +/* Resources */ +.jx-tmResTop { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } +@media (max-width: 900px) { .jx-tmResTop { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.jx-tmBar { height: 6px; margin-top: 6px; border-radius: 3px; background: var(--color-fill); overflow: hidden; } +.jx-tmBar i { display: block; height: 100%; background: var(--color-primary); } +.jx-tmBar--ok i { background: var(--color-success); } +.jx-tmBar--warn i { background: var(--color-warning); } +.jx-tmBar--bad i { background: var(--color-error); } +.jx-tmBar--inline { width: 64px; height: 4px; margin: 0; flex-shrink: 0; } +.jx-tmMemCell { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; font-variant-numeric: tabular-nums; } +.jx-tmResGrid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 24px; } +@media (max-width: 1100px) { .jx-tmResGrid { grid-template-columns: minmax(0, 1fr); } } +.jx-tmDot { display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 50%; background: var(--color-success); vertical-align: middle; flex-shrink: 0; } +.jx-tmDot--warn { background: var(--color-warning); border-radius: 2px; } +.jx-tmDot--bad, .jx-tmDot--fail { background: var(--color-error); border-radius: 1px; transform: rotate(45deg); } +.jx-tmDot--none { background: var(--color-fill-heavy); } +.jx-tmDeps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } +.jx-tmDep { display: flex; align-items: center; gap: 2px; padding: 7px 10px; border-radius: var(--radius-xs); background: var(--color-bg-layout); font-size: 0.8125rem; min-width: 0; } +.jx-tmDep span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text); } +.jx-tmDep small { color: var(--color-text-tertiary); font-size: 0.75rem; } +.jx-tmDep--bad { background: var(--color-error-bg); } +.jx-tmDep--warn { background: var(--color-warning-bg); } + +/* Trace drawer */ +.jx-tmMeta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px 16px; margin: 0; font-size: 0.8125rem; } +@media (max-width: 640px) { .jx-tmMeta { grid-template-columns: minmax(0, 1fr); } } +.jx-tmMeta-full { grid-column: 1 / -1; } +.jx-tmMeta dt { display: inline; color: var(--color-text-tertiary); } +.jx-tmMeta dt::after { content: ":"; } +.jx-tmMeta dd { display: inline; margin: 0; color: var(--color-text); word-break: break-word; } +.jx-tmSummary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 16px; padding: 12px; border-radius: var(--radius-sm); background: var(--color-bg-layout); } +@media (max-width: 640px) { .jx-tmSummary { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.jx-tmSummary span { display: block; font-size: 0.75rem; color: var(--color-text-secondary); } +.jx-tmSummary b { font-family: var(--font-family-number); font-variant-numeric: tabular-nums; font-size: 1.125rem; font-weight: 600; color: var(--color-text); } +.jx-tmSummary small { margin-left: 4px; font-size: 0.75rem; font-weight: 500; color: var(--color-error-text); } +.jx-tmSec { margin: 24px 0 8px; font-weight: 600; color: var(--color-text); } +.jx-tmPlan { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--color-bg-layout); font-size: 0.8125rem; color: var(--color-text); } +.jx-tmPlan > div { display: flex; align-items: center; gap: 8px; } +.jx-tmPlanBox { display: grid; place-items: center; width: 14px; height: 14px; flex-shrink: 0; border-radius: 4px; border: 1.5px solid var(--color-fill-heavy); font-size: 10px; color: var(--color-text-white); } +.jx-tmPlanBox--completed { background: var(--color-success); border-color: var(--color-success); } +.jx-tmPlanBox--in_progress { background: var(--color-primary-bg); border-color: var(--color-primary); } +.jx-tmSteps { border: 1px solid var(--color-border); border-radius: var(--radius-sm); overflow: hidden; } +.jx-tmStep { padding: 6px 12px; border-top: 1px solid var(--color-border); } +.jx-tmSteps > div:first-child > .jx-tmStep:first-child { border-top: none; } +.jx-tmStep--child { padding-left: 44px; background: var(--color-bg-layout); } +.jx-tmStep-h { display: flex; align-items: center; gap: 8px; } +.jx-tmToggle.ant-btn { width: 22px; min-width: 22px; height: 22px; font-size: 10px; color: var(--color-text-secondary); } +.jx-tmToggle .anticon { transition: transform var(--motion-duration-fast) var(--motion-ease-standard); } +.jx-tmToggle--open .anticon { transform: rotate(90deg); } +.jx-tmStepDot { width: 8px; height: 8px; flex-shrink: 0; border-radius: 50%; background: var(--color-success); } +.jx-tmStepDot--running { background: var(--color-primary); } +.jx-tmStepDot--failed { background: var(--color-error); border-radius: 1px; transform: rotate(45deg); } +.jx-tmStepDot--cancelled { background: var(--color-fill-heavy); } +.jx-tmStep-m { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.jx-tmStep-m .ant-tag { margin: 0; } +.jx-tmStep-name { font-weight: 500; color: var(--color-text); } +.jx-tmSubNo { font-size: 0.75rem; color: var(--color-text-secondary); font-variant-numeric: tabular-nums; } +.jx-tmSrc { display: inline-flex; padding: 0 6px; border-radius: var(--radius-xs); font-size: 0.6875rem; line-height: 18px; background: var(--color-fill); color: var(--color-text-secondary); } +.jx-tmSrc--kb { background: var(--color-tint-blue-bg); color: var(--color-tint-blue); } +.jx-tmSrc--db { background: var(--color-tint-amber-bg); color: var(--color-tint-amber); } +.jx-tmSrc--web { background: var(--color-tint-teal-bg); color: var(--color-tint-teal); } +.jx-tmSrc--skill { background: var(--color-tint-purple-bg); color: var(--color-tint-purple); } +.jx-tmSrc--sub, .jx-tmSrc--plan { background: var(--color-primary-bg); color: var(--color-primary-text); } +.jx-tmSrc--mem { background: var(--color-success-bg); color: var(--color-success-text); } +.jx-tmTiming { flex-shrink: 0; width: 240px; display: flex; align-items: center; gap: 8px; } +@media (max-width: 640px) { .jx-tmTiming { width: 88px; } .jx-tmGantt { display: none; } } +.jx-tmGantt { position: relative; flex: 1; height: 8px; border-radius: 4px; background: var(--color-fill); } +.jx-tmGantt i { position: absolute; top: 0; bottom: 0; min-width: 2px; border-radius: 4px; background: var(--color-success); } +.jx-tmGantt i.jx-tmGantt-running { background: var(--color-primary); } +.jx-tmGantt i.jx-tmGantt-failed { background: var(--color-error); } +.jx-tmGantt i.jx-tmGantt-cancelled { background: var(--color-fill-heavy); } +.jx-tmTiming-d { width: 64px; text-align: right; font-size: 0.75rem; font-variant-numeric: tabular-nums; color: var(--color-text-secondary); } +.jx-tmIo { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 4px 8px; margin: 6px 0 2px 46px; padding: 8px 12px; border-radius: var(--radius-xs); background: var(--color-fill); font-size: 0.8125rem; } +.jx-tmIo dt { color: var(--color-text-tertiary); } +.jx-tmIo dd { margin: 0; color: var(--color-text); word-break: break-word; } +.jx-tmQgrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--color-border); border-radius: var(--radius-sm); overflow: hidden; font-size: 0.8125rem; } +.jx-tmQgrid > div { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--color-border); } +.jx-tmQgrid > div:nth-child(-n+2) { border-top: none; } +.jx-tmQgrid .ant-tag { margin: 0; } +.jx-tmQgrid-full { grid-column: 1 / -1; } +.jx-tmQgrid-k { width: 64px; flex-shrink: 0; color: var(--color-text-tertiary); } +.jx-tmSignals { flex: 1; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 6px; } +.jx-tmSignal { display: flex; align-items: center; gap: 2px; padding: 4px 10px; border-radius: var(--radius-xs); background: var(--color-bg-layout); font-size: 0.75rem; color: var(--color-text-secondary); } +.jx-tmSignal b { margin-left: auto; padding-left: 8px; font-weight: 600; color: var(--color-text); } + +/* Modals */ +.jx-tmCarried { margin-bottom: 16px; padding: 12px; border-radius: var(--radius-sm); background: var(--color-bg-layout); font-size: 0.8125rem; } +.jx-tmCarried-h { margin-bottom: 8px; font-size: 0.75rem; font-weight: 600; color: var(--color-text-secondary); } +.jx-tmCarried-r { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; margin-bottom: 4px; color: var(--color-text); } +.jx-tmCarried-r > span:first-child { width: 64px; flex-shrink: 0; color: var(--color-text-tertiary); } +.jx-tmCarried-r b { font-weight: 500; } +.jx-tmTrail { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0 0 72px; padding: 0; list-style: none; } +@media (max-width: 560px) { .jx-tmTrail { margin-left: 0; } } +.jx-tmTrail li { padding: 0 8px; border-radius: var(--radius-xs); background: var(--color-fill); font-size: 0.75rem; line-height: 22px; color: var(--color-text-secondary); } +.jx-tmTrail li.jx-tmTrail--failed { background: var(--color-error-bg); color: var(--color-error-text); } +.jx-tmTrail li.jx-tmTrail--running { background: var(--color-primary-bg); color: var(--color-primary-text); } +.jx-tmFormGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px; } +@media (max-width: 560px) { .jx-tmFormGrid { grid-template-columns: minmax(0, 1fr); } } +.jx-tmSpot { display: flex; flex-direction: column; gap: 8px; } +.jx-tmSpot-label { font-size: 0.75rem; color: var(--color-text-secondary); } + +@media (prefers-reduced-motion: reduce) { + .jx-tmToggle .anticon { transition: none; } +} + +/* 内容管理 → 问题处置 → 纠错工单 */ +.jx-ticketAdmin-filters { margin-bottom: 16px; } +.jx-ticketAdmin-search { width: 320px; max-width: 100%; } +.jx-ticketAdmin-select { min-width: 128px; } +.jx-ticketAdmin-sec { margin: 24px 0 8px; font-weight: 600; color: var(--color-text); } +.jx-ticketAdmin-text { white-space: pre-wrap; margin-bottom: 0; } +.jx-ticketAdmin-steps { margin: 0; padding-left: 24px; display: grid; gap: 4px; } +.jx-ticketAdmin-steps li { display: flex; align-items: center; gap: 8px; min-width: 0; } +.jx-ticketAdmin-steps .ant-tag { margin: 0; } +.jx-ticketAdmin-stepName { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.jx-ticketAdmin-step--suspect .jx-ticketAdmin-stepName { color: var(--color-error-text); font-weight: 600; } +.jx-ticketAdmin-mono { font-family: var(--font-family-number); font-variant-numeric: tabular-nums; white-space: nowrap; } +.jx-ticketAdmin-gone { margin-left: 8px; } +.jx-ticketAdmin-form { display: grid; grid-template-columns: 80px 1fr; gap: 12px 16px; align-items: center; } +.jx-ticketAdmin-form label { color: var(--color-text-secondary); } +.jx-ticketAdmin-form label[for="ticket-note"] { align-self: start; padding-top: 4px; } diff --git a/src/frontend/src/styles/tool.css b/src/frontend/src/styles/tool.css index 0df92282..084f7bb4 100644 --- a/src/frontend/src/styles/tool.css +++ b/src/frontend/src/styles/tool.css @@ -94,20 +94,6 @@ color: color-mix(in srgb, var(--color-text) 65%, transparent); flex-shrink: 0; } -/* 运行中工具行的文字流光 — 单色灰阶。 */ -/* motion-keep: 工具运行中的文字流光,定格会变成一段忽深忽浅的灰字 */ -.jx-tcr-prefix--shimmer { - background: linear-gradient(90deg, - color-mix(in srgb, var(--color-text) 40%, transparent) 0%, - color-mix(in srgb, var(--color-text) 88%, transparent) 50%, - color-mix(in srgb, var(--color-text) 40%, transparent) 100%); - background-size: 200% 100%; - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - color: transparent; - animation: jx-kf-shimmer 2.2s linear infinite; -} /* Argument after the action: plain tertiary text, no chip — the row stays one quiet line */ .jx-tcr-value { display: inline-block; diff --git a/src/frontend/src/utils/pageConfigDefaults.ts b/src/frontend/src/utils/pageConfigDefaults.ts index 0381a764..1f6838da 100644 --- a/src/frontend/src/utils/pageConfigDefaults.ts +++ b/src/frontend/src/utils/pageConfigDefaults.ts @@ -39,17 +39,13 @@ export interface PageConfig { content_label: string; config_label: string; apidoc_label: string; + /** Browser tab icon of the three admin pages; empty = follow branding.favicon_url. */ + favicon_url: string; }; sidebar_items: string[]; menu_items: string[]; }; texts: Record<string, string>; - defaults: { - /** Initial mode on each user login / new conversation: turbo / fast / medium / high / max */ - chat_mode: 'turbo' | 'fast' | 'medium' | 'high' | 'max'; - /** Legacy field, dual-written with chat_mode for old-frontend compatibility; true=thinking mode, false=fast mode */ - thinking_mode: boolean; - }; auth: { /** Whether the login page shows the "register" entry (Tab + register sub-page); when disabled, only login remains */ allow_register: boolean; @@ -133,6 +129,7 @@ export const DEFAULT_PAGE_CONFIG: PageConfig = { content_label: '内容管理', config_label: '系统配置', apidoc_label: '接口文档', + favicon_url: '', }, sidebar_items: [...DEFAULT_SIDEBAR_ITEMS], menu_items: [...DEFAULT_MENU_ITEMS], @@ -151,10 +148,6 @@ export const DEFAULT_PAGE_CONFIG: PageConfig = { dialog_logout_confirm_ok: '退出登录', recommend_banner_text: '', }, - defaults: { - chat_mode: 'fast', - thinking_mode: false, - }, auth: { allow_register: true, }, @@ -199,7 +192,6 @@ export function mergePageConfig(remote: Partial<PageConfig> | null | undefined): ), }, texts: { ...DEFAULT_PAGE_CONFIG.texts, ...(remote.texts || {}) }, - defaults: { ...DEFAULT_PAGE_CONFIG.defaults, ...(remote.defaults || {}) }, auth: { ...DEFAULT_PAGE_CONFIG.auth, ...(remote.auth || {}) }, }; } @@ -241,6 +233,7 @@ function mergeAdminPlatform( content_label: pick(r.content_label, def.content_label), config_label: pick(r.config_label, def.config_label), apidoc_label: pick(r.apidoc_label, def.apidoc_label), + favicon_url: pick(r.favicon_url, def.favicon_url), }; } diff --git a/src/frontend/src/utils/reasoningEffort.ts b/src/frontend/src/utils/reasoningEffort.ts index 42301919..c2b8bed1 100644 --- a/src/frontend/src/utils/reasoningEffort.ts +++ b/src/frontend/src/utils/reasoningEffort.ts @@ -40,6 +40,12 @@ export function normalizeThinkingEffort(effort: ThinkingEffort | 'turbo', policy return levels.find((level) => level.key === policy.default_reasoning_effort)?.key ?? levels[0]?.key ?? 'medium'; } +/** Starting level for a new chat: the model's configured default level; models without levels start at fast. */ +export function defaultThinkingEffort(policy: ReasoningPolicy): ThinkingEffort { + if (!policy.supports_reasoning_effort) return 'fast'; + return normalizeThinkingEffort(policy.default_reasoning_effort ?? 'medium', policy) as ThinkingEffort; +} + export function selectedReasoningPolicy( caps: ReasoningPolicy & { user_model_switch_enabled: boolean; user_selectable_models: (ReasoningPolicy & { provider_id: string; is_default: boolean })[] }, providerId: string | null,