From 8cadd34a7e633ddbe4611cbbf23cf3d3a14c5832 Mon Sep 17 00:00:00 2001 From: Luhaozhu Date: Fri, 14 Aug 2026 02:58:22 -0400 Subject: [PATCH] feat: chat mode enhancements, turn status indicator, and composer state persistence - Chat modes: restricted modes can pin sub-agents and optionally keep sandbox code execution; the selected mode now persists per conversation instead of falling back to Standard on refresh or chat switch. - Turn status indicator: the wait before the first token shows a branded shimmer indicator instead of a premature empty tool-call card. - Composer state: thinking effort, queued message and quoted follow-up survive refresh, persisted per conversation. - Self-built tool docstrings slimmed; delivery-path notes consolidated into the tools' own documentation. - Dev-convention skill references updated to match the current code. --- .../references/architecture.md | 94 +++++++---- .../references/css-variables.md | 45 +++-- .../references/sse-events.md | 87 +++++++++- .../references/ui-design-spec.md | 62 +++---- src/backend/api/routes/v1/chat_modes.py | 3 + src/backend/core/db/models/chat_mode.py | 5 + src/backend/core/llm/agent_factory.py | 33 +++- src/backend/core/llm/tools/sandbox_tool.py | 8 +- .../core/services/chat_mode_service.py | 10 ++ .../10_tools_and_capabilities.system.md | 25 +-- .../tests/services/test_chat_mode_service.py | 19 +++ src/frontend/src/api.ts | 1 + src/frontend/src/components/chat/ChatArea.tsx | 4 +- .../src/components/chat/MessageBubble.tsx | 18 +- .../components/chat/TurnStatusIndicator.tsx | 38 +++++ .../components/settings/ChatModesPanel.tsx | 10 ++ src/frontend/src/hooks/useStreaming.ts | 13 +- src/frontend/src/i18n/en/chat.ts | 1 + src/frontend/src/i18n/en/settings.ts | 4 + src/frontend/src/stores/chatStore.ts | 155 +++++++++++++++--- src/frontend/src/styles/chat.css | 40 +++++ src/frontend/src/types.ts | 10 ++ src/frontend/src/utils/chatMode.ts | 5 + 23 files changed, 538 insertions(+), 152 deletions(-) create mode 100644 src/frontend/src/components/chat/TurnStatusIndicator.tsx diff --git a/.claude/skills/hugagent-backend-dev/references/architecture.md b/.claude/skills/hugagent-backend-dev/references/architecture.md index 4844a97..ba7ea66 100644 --- a/.claude/skills/hugagent-backend-dev/references/architecture.md +++ b/.claude/skills/hugagent-backend-dev/references/architecture.md @@ -4,8 +4,8 @@ ``` Browser → Nginx (:3000 /api/ proxy) → FastAPI (api/app.py) - → api/middleware/ (CORS → Logging → Error Handler) - → api/routes/v1/*.py (路由层;注册表见 api/routes/v1/__init__.py) + → api/middleware/ (CORS → Logging → Error Handler → license_gate) + → api/routes/v1/*.py (路由层;CE 注册表 api/routes/v1/__init__.py,EE 注册表 edition_ee/routes/registry.py) → core/services/*.py (服务层 — 业务逻辑) → core/db/repository/ (仓库层 — 数据访问,按领域分文件) → core/db/models/ (ORM 包 → PostgreSQL/SQLite) @@ -27,16 +27,16 @@ Browser → Nginx (:3000 /api/ proxy) → FastAPI (api/app.py) │ - 幂等操作 (ensure_xxx) │ │ - 抛 AppException │ ├─────────────────────────────────────────┤ -│ Repository (core/db/repository/) │ ← 数据访问抽象(包,按领域分文件) +│ Repository (core/db/repository/) │ ← 数据访问抽象(包,10 个领域文件) │ - CRUD 操作 │ │ - 分页查询 │ │ - 软删除过滤 │ │ - 不含业务逻辑 │ ├─────────────────────────────────────────┤ -│ Models (core/db/models/) │ ← ORM 定义(包,11 个领域文件) +│ Models (core/db/models/) │ ← ORM 定义(包,17 个领域文件) │ - SQLAlchemy declarative │ │ - 索引、约束、关系 │ -│ - EE 专属表登记 edition_tables.py │ +│ - EE 专属表登记 edition_ee/db/edition_tables.py │ │ - Alembic 迁移 │ └─────────────────────────────────────────┘ ``` @@ -48,58 +48,82 @@ POST /v1/chats/stream → api/routes/v1/chats.py → orchestration/chat_run_executor.py # ChatRun + Redis Stream(后台 run,SSE 跟随,断线续播) → orchestration/workflow.py # 流式编排主入口 + → core/services/chat_mode_service.py # 解析对话模式 ChatModeSpec(standard/turbo/市场模式;装配契约) → orchestration/memory_integration.py # 检索分层记忆并注入(user-role 冻结块,600ms 预算) - → orchestration/strategy.py # 路由策略(ROUTER_STRATEGY,默认 main_only) - → core/llm/agent_factory.py # 构建 AgentScope 2.0 ReActAgent + → core/llm/agent_factory.py # 构建 AgentScope 2.0 ReActAgent(按 ChatModeSpec 收窄工具面) → core/llm/mcp_manager.py # MCP 客户端池(streamable-http → mcp 容器) - → core/config/mcp_config.py # MCP server 定义 - → core/llm/middlewares.py # AS2 中间件(动态模型、文件上下文等) + → core/config/mcp_config.py # MCP server 定义(端口真源 mcp_servers/_ports.py) + → core/llm/middlewares.py # AS2 中间件(动态模型、文件上下文、CitationAnchorMiddleware、OntologyGateMiddleware) → prompts/prompt_runtime.py # 装配系统提示词(DB 版本池优先,prompt_text/ 兜底) - → orchestration/citations.py # 提取 [ref:tool-N] 引用标记 + → orchestration/streaming.py # StreamingAgent:把 AS2 事件流映射为内部块事件 + → orchestration/citation_anchor.py # 引用锚点唯一发号(AnchorAllocator,e1/e2/…,经中间件回注工具结果) → core/memory/service.py # 流结束后后台保存记忆(L1/L2/L3) + → orchestration/followups.py # 流结束后后台生成追问建议(写入消息 extra_data) ``` -SSE 事件:`run_started`(首帧,携带续播 run_id)、`content`(delta 增量)、`thinking`、 -`tool_call`、`tool_result`、`tool_pending`、`batch_confirm`、`file_confirm`、`meta`、`error`; -流以 `data: [DONE]` 终止,心跳为 SSE 注释行(15s)。**没有 `text` / `done` 事件。** -断线续播:`GET /v1/chats/stream/{run_id}`。 +> `orchestration/strategy.py` 仍在但已退化为占位符(两个分支都返回 MainOnlyStrategy),不再是主链路环节。 + +**平级编排入口**(不走普通 chat run 的独立驱动): + +| 入口 | 文件 | 说明 | +|------|------|------| +| 自主循环 | `orchestration/autonomous_loop.py` + `loop_planner.py` / `loop_evaluator.py` + `subagents/loop_reviewer.py` | driver 持有需求台账 feature_list.json,逐条注入 + 只读 reviewer 验收;路由 `v1/loops.py` | +| 批量执行 | `orchestration/batch_orchestrator.py` | batch_confirm 确认后逐行执行 | +| 计划模式 | `orchestration/subagents/plan_mode.py` | plan_generate / plan_execute run | +| 渠道入站 | `core/channels/`(protocol/registry/manager/inbound/outbound) | IM 消息归一为 InboundMsg 后复用聊天编排,回复经 markdown.py 降级回发 | +| 定时任务 | `orchestration/schedulers/`(automation / distillation_cron / evolution / memory_ttl) | croniter 调度 | + +SSE 事件(前端解析真源 `src/frontend/src/hooks/chatStream.ts`): + +- 生命周期:`run_started`(首帧,携带续播 run_id)、`meta`、`error`、`end` / `data: [DONE]`(终止)、心跳为 SSE 注释行 +- 正文/思考:`content`(delta 增量;别名 ai_message/text/delta)、`content_replace`、`thinking`、`compaction_notice`、`steer_applied` +- 工具:`tool_call_start`、`tool_call_delta`(参数流式)、`tool_call`、`tool_result`、`tool_pending`、`model_progress`(活性信号) +- 交互确认:`batch_confirm`、`file_confirm`、`design_pick` +- 计划/子智能体:`plan_update`、`subagent_event` +- 本体治理:`ontology_activation` / `ontology_gate` / `ontology_review` / `ontology_repair` / `ontology_revision` / `ontology_revision_thinking` + +断线续播:`GET /v1/chats/stream/{run_id}`。追问建议(follow_up_questions)流结束后后台生成,也可能以 `follow_up` 事件流内直送。 ## 模块索引 | 模块 | 路径 | 职责 | |------|------|------| | App 入口 | `api/app.py` | FastAPI 实例、中间件;路由按注册表自动注册 | -| 路由注册表 | `api/routes/v1/__init__.py` | **CE_ROUTERS / EE_ROUTERS 单一真源**(EE 项带 license 能力位) | -| 依赖注入 | `api/deps.py` | require_admin(ADMIN_TOKEN)、require_admin_or_config、require_super_admin、team 权限 | -| 用户认证 | `core/auth/backend.py` | get_current_user / UserContext(AUTH_MODE: mock/session/remote) | +| CE 路由注册表 | `api/routes/v1/__init__.py` | **CE_ROUTERS 单一真源**(二元组);re-export EE_ROUTERS | +| EE 路由注册表 | `edition_ee/routes/registry.py` | **EE_ROUTERS 单一真源**(三元组,带 license 能力位) | +| 依赖注入 | `api/deps.py` | require_admin(ADMIN_TOKEN)、require_config(CONFIG_TOKEN)、require_admin_or_config、require_system_settings、require_super_admin | +| 用户认证 | `core/auth/backend.py` | get_current_user / UserContext(桌面桥接 → session → API-Key → mock/remote) | | 健康检查 | `api/health.py` | /health, /ready, /live | | Schema | `api/schemas.py` | 请求/响应 Pydantic 模型 | -| 中间件 | `api/middleware/` | CORS, logging, error_handler | -| 路由 | `api/routes/v1/` | 50+ 路由文件 | +| 路由 | `api/routes/v1/` | 66 个路由文件 | | 技能引擎 | `core/agent_skills/` | SKILL.md 解析、多源加载、{dir} 沙箱路径注入 | | 生成物 | `core/artifacts/` | 注册与下载(store.py,local/oss 双模) | | 认证 | `core/auth/` | backend.py, session.py, sso.py, permissions_iface.py(CE/EE 接缝) | +| 渠道 | `core/channels/` | 入站机器人框架:钉钉/飞书/企微/微信 adapter;加渠道 = 写 adapter + registry.py 注册 | | 聊天 | `core/chat/` | context.py, tool_log.py | | 配置 | `core/config/` | settings.py + catalog 五件套(catalog.json/catalog.py/loader/resolver/migration)+ mcp_config.py | | 内容 | `core/content/` | 内容块、file_parser.py | -| 数据库 | `core/db/` | engine.py, models/(包), repository/(包), edition_tables.py | -| 基础设施 | `core/infra/` | exceptions, responses, logging, metrics, 限流, Redis | -| 知识库 | `core/kb/` | 分块、向量化、混合检索 | +| 数据库 | `core/db/` | engine.py, models/(17 领域文件), repository/(10 领域文件), model_extensions.py, edition_tables.py(垫片) | +| 进化 | `core/evolution/` | GCE 共享契约(Episode/候选/结算);控制面在 edition_ee/evolution | +| 基础设施 | `core/infra/` | exceptions, responses, logging, metrics, 限流, Redis, crypto | +| 知识库 | `core/kb/` | 分块、向量化、混合检索 + wiki/(LLM-Wiki 管线)+ wiki_router.py + external_provider.py(外接接缝) | | License | `core/licensing/` | features.py(能力位+402)、manager.py(状态机) | -| LLM | `core/llm/` | agent_factory, chat_models, middlewares, mcp_manager/mcp_pool, offloader, tools/ | -| 记忆 | `core/memory/` | service.py(mem0)、pipeline.py、profile.py(L1)、sanitizer.py | -| 沙箱 | `core/sandbox/` | protocol.py + provider 实现 | -| 服务 | `core/services/` | 30+ 业务服务(user/chat/catalog/kb/artifact/plan/automation/prompt_version/marketplace…) | +| LLM | `core/llm/` | agent_factory(含 turbo 参数), chat_models, middlewares, mcp_manager/mcp_pool, offloader, subagent_tool, tools/ | +| 记忆 | `core/memory/` | service.py(mem0)、pipeline.py、profile.py(L1)、sanitizer.py、audit.py | +| 本体 | `core/ontology/` | Domain Pack 校验、运行时选取、工具闸(schemas/validator/revision/toolkit) | +| 沙箱 | `core/sandbox/` | protocol.py + script_runner / opensandbox / cube 三实现 | +| 服务 | `core/services/` | 70+ 业务服务(user/chat/chat_mode/channel/loop/kb/plugin/prompt_version/marketplace…) | | 存储 | `core/storage/` | protocol, local, s3, oss | -| 编排 | `orchestration/` | workflow, chat_run_executor, strategy, citations, memory_integration, schedulers/, subagents/ | -| 提示词 | `prompts/` | prompt_runtime + provider + prompt_text/{default,code_exec,distillation,plan_mode}/ | -| MCP 服务器 | `mcp_servers/` | 8 个内置 server,streamable-http 常驻 mcp 容器(端口 9100–9107,真源 _ports.py) | -| 技能资产 | `skill_bundles/` | default/(内置)+ marketplace/(可安装) | +| 编排 | `orchestration/` | workflow, chat_run_executor, streaming, citation_anchor, autonomous_loop, followups, batch_orchestrator, schedulers/, subagents/ | +| 提示词 | `prompts/` | prompt_runtime + provider + prompt_text/{default,code_exec,distillation,plan_mode,subagents,turbo}/ | +| MCP 服务器 | `mcp_servers/` | 12 个 server 目录、11 个在役(端口 9100–9114,真源 _ports.py;9105/9109-9111 为退役保留位) | +| 技能资产 | `skill_bundles/` | default/(5 内置)+ marketplace/(40+ 可安装) | +| EE 树 | `edition_ee/` | EE 专属路由/模型/KB provider(dify/fastgpt/weknora)/evolution 控制面;CE 派生树物理删除 | ## 新路由注册步骤 -1. 创建 `api/routes/v1/my_feature.py`,文件内定义 `router = APIRouter(prefix="/v1/xxx", tags=["Xxx"])` -2. 在 `api/routes/v1/__init__.py` 的注册表中加一项: - - 通用功能 → `CE_ROUTERS` 加 `("my_feature", "router")` - - 企业版功能 → `EE_ROUTERS` 加 `("my_feature", "router", "")`(能力位 `None` 表示显式豁免 feature 守卫) -3. **不要**在 `api/app.py` 手工 `include_router()` —— app 启动时按表自动注册,模块缺失(CE 派生树物理删除了 EE 文件)会被静默跳过 +1. 创建 `api/routes/v1/my_feature.py`(EE 专属则放 `edition_ee/routes/`),文件内定义 `router = APIRouter(prefix="/v1/xxx", tags=["Xxx"])` +2. 注册进对应注册表: + - 通用功能 → `api/routes/v1/__init__.py` 的 `CE_ROUTERS` 加 `("my_feature", "router")` + - 企业版功能 → `edition_ee/routes/registry.py` 的 `EE_ROUTERS` 加 `("edition_ee.routes.my_feature", "router", "")`(能力位 `None` 表示显式豁免 feature 守卫) +3. **不要**在 `api/app.py` 手工 `include_router()` —— app 启动时经 `iter_edition_routers()` 按表自动注册,模块缺失(CE 派生树物理删除了 EE 文件)会被静默跳过 diff --git a/.claude/skills/hugagent-frontend-dev/references/css-variables.md b/.claude/skills/hugagent-frontend-dev/references/css-variables.md index 70392f9..afe227d 100644 --- a/.claude/skills/hugagent-frontend-dev/references/css-variables.md +++ b/.claude/skills/hugagent-frontend-dev/references/css-variables.md @@ -25,10 +25,10 @@ --color-bg-gray: #F5F6F7; /* 文字 */ - --color-text: #262626; - --color-text-secondary: #4D4D4D; - --color-text-tertiary: #808080; - --color-text-placeholder: #B3B3B3; + --color-text: #101828; + --color-text-secondary: #475467; + --color-text-tertiary: #667085; + --color-text-placeholder: #98A2B3; --color-text-white: #FFFFFF; /* 语义色 */ @@ -37,10 +37,10 @@ --color-error: #FC5D5D; /* 圆角 */ - --radius-xs: 4px; /* 标签等小面积 */ - --radius-sm: 8px; /* 按钮、输入框、下拉框 */ - --radius-md: 12px; /* 卡片视图、背景卡片 */ - --radius-lg: 20px; /* 弹窗、大输入框 */ + --radius-xs: 6px; /* 标签等小面积 */ + --radius-sm: 10px; /* 按钮、输入框、下拉框 */ + --radius-md: 16px; /* 卡片视图、背景卡片 */ + --radius-lg: 24px; /* 弹窗、大输入框 */ /* 间距 (4px 基准) */ --space-xs: 4px; @@ -49,12 +49,23 @@ --space-lg: 20px; --space-xl: 40px; - /* 字体 */ - --font-family: "PingFang SC", "Microsoft YaHei", "微软雅黑", sans-serif; + /* 字体(后一条定义生效:系统栈优先,中文回退 PingFang/雅黑) */ + --font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; --font-family-number: "DingTalk Sans", "PingFang SC", "Microsoft YaHei", sans-serif; + + /* Motion 令牌(TS 侧镜像在 utils/motionTokens.ts) */ + --motion-duration-instant: 100ms; + --motion-duration-fast: 160ms; + --motion-duration-normal: 240ms; + --motion-duration-slow: 320ms; + /* easing: --motion-ease-brand-out cubic-bezier(.16,1,.3,1) / --motion-ease-standard cubic-bezier(.4,0,.2,1) */ } ``` +> 阴影令牌:`--shadow-card` / `--shadow-card-hover` / `--shadow-dropdown`(双层 ambient + key-light)。 +> 底部还有 legacy 别名(`--primary` / `--bg` / `--text` / `--muted` / `--border` 等),新代码不要使用。 + ## CSS 类名前缀 所有自定义类使用 `.jx-` 前缀,避免与 Ant Design 冲突。 @@ -77,10 +88,10 @@ | 消息最大宽度 | 840px | | 面板内边距 | 20px (桌面) / 16px (移动) | | 卡片间距 | 8px - 16px | -| 卡片圆角 | 12px (`--radius-md`) | -| 输入框圆角 | 8px (`--radius-sm`) | -| 按钮圆角 | 8px (`--radius-sm`) | -| 弹窗圆角 | 20px (`--radius-lg`) | +| 卡片圆角 | 16px (`--radius-md`) | +| 输入框圆角 | 10px (`--radius-sm`) | +| 按钮圆角 | 10px (`--radius-sm`) | +| 弹窗圆角 | 24px (`--radius-lg`) | | 按钮最小高度 | 36px | ## 常用样式模式 @@ -90,7 +101,7 @@ ```css .jx-card { padding: var(--space-md) var(--space-lg); /* 16px 20px */ - border-radius: var(--radius-md); /* 12px */ + border-radius: var(--radius-md); /* 16px */ border: 1px solid var(--color-border); background: #fff; transition: box-shadow 0.2s, border-color 0.2s; @@ -109,7 +120,7 @@ align-items: center; gap: 6px; padding: 8px 18px; - border-radius: var(--radius-sm); /* 8px */ + border-radius: var(--radius-sm); /* 10px */ font-size: 14px; cursor: pointer; transition: all 0.2s; @@ -124,7 +135,7 @@ width: 100%; max-width: 840px; margin: 0 auto; - border-radius: var(--radius-lg); /* 20px */ + border-radius: var(--radius-lg); /* 24px */ border: 1.5px solid var(--color-border); background: #fff; } diff --git a/.claude/skills/hugagent-frontend-dev/references/sse-events.md b/.claude/skills/hugagent-frontend-dev/references/sse-events.md index b08649f..b80faa7 100644 --- a/.claude/skills/hugagent-frontend-dev/references/sse-events.md +++ b/.claude/skills/hugagent-frontend-dev/references/sse-events.md @@ -2,17 +2,22 @@ 流式聊天端点 `POST /v1/chats/stream` 返回 Server-Sent Events。后端把 run 跑在后台 (ChatRun + Redis Stream),SSE 只是"跟随"——断线后可用 `GET /v1/chats/stream/{run_id}` -重新接上同一条流续播。前端解析在 `hooks/useStreaming.ts`。 +重新接上同一条流续播。 + +**前端解析单一真源是 `hooks/chatStream.ts`(`processChatStream()`)**;`hooks/useStreaming.ts` +只做发送/中止/续播/排队编排;通用分帧器在 `utils/sse.ts`(`parseSSE`)。后端事件产出主体在 +`orchestration/streaming.py`(StreamingAgent)与 `orchestration/chat_run_executor.py`。 ## 流格式 ``` data: {"type": "", ...payload} # JSON 事件 data: [DONE] # 流终止标记 -: heartbeat # SSE 注释行心跳(15s 一次,直接忽略) +: heartbeat # SSE 注释行心跳(直接忽略) ``` -> **没有 `text` / `done` 事件**(旧版概念)。文本增量是 `content`,结束是 `end` 事件或 `[DONE]`。 +> 没有独立的 `done` 事件——结束是 `end` 事件或 `[DONE]`。正文事件规范名是 `content`, +> 但 `ai_message` / `text` / `delta` 都会被当作正文别名接受。 ## 事件类型 @@ -45,7 +50,25 @@ data: [DONE] # 流终止标记 - 收到带 `delta` 的 thinking 即标记 structuredReasoning(思考走独立通道,如 reasoning_content),关闭 `` 内嵌解析 - 兼容别名:`thought` -### `tool_call` — 工具调用开始 +### `content_replace` — 整段替换正文 + +```json +{"type": "content_replace", "content": "重写后的完整正文"} +``` + +- 本体治理评审后重写答案时使用:**整体替换**当前消息正文,而不是追加 + +### `tool_call_start` / `tool_call_delta` — 工具参数流式展示 + +```json +{"type": "tool_call_start", "id": "call_abc123", "name": "internet_search"} +{"type": "tool_call_delta", "id": "call_abc123", "delta": "{\"query\": \"北京"} +``` + +- 参数尚未生成完时先出卡片(status='running'),delta 增量拼接参数 JSON 实时展示 +- 随后的 `tool_call` 带完整参数,按 id 更新同一张卡片 + +### `tool_call` — 工具调用(完整参数) ```json {"type": "tool_call", "id": "call_abc123", "name": "internet_search", "input": {"query": "北京 天气"}} @@ -101,12 +124,54 @@ data: [DONE] # 流终止标记 - 入队 `useUIStore.enqueuePendingConfirm(chatId, info)`;`expired: true` 表示该项超时回收, 调 `resolvePendingConfirm` 只摘掉这一个 confirm_id +### `design_pick` — 建站方案三选一 + +- 与 `file_confirm` 同机制:流不结束,用户选择走带外接口,工具协程原地续跑 + +### `plan_update` — 计划清单更新 + +```json +{"type": "plan_update", "plan": [{"content": "...", "status": "completed"}, ...]} +``` + +- `update_plan` 工具产生的轻量计划清单;run 正常结束时后端自动补发全部 completed 的收尾帧 + +### `subagent_event` — 子智能体嵌套事件 + +```json +{"type": "subagent_event", "subType": "tool_call", ...} +``` + +- `subType` 取值:`tool_call` / `tool_call_delta` / `tool_result` / `thinking` / `content` / `error` +- 渲染为父工具卡片的 `subSteps` 子步骤 + +### `steer_applied` — 运行中插话生效 + +- 用户在 run 进行中追加的指令(steering)被后端采纳的边界标记 + +### `compaction_notice` — 上下文压缩提示 + +- 长会话触发上下文压缩时的提示帧 + +### `model_progress` — 模型活性信号 + +- 表示"模型还在飞行中",用于喂 run 卡死看门狗/停滞检测,UI 不直接渲染 + +### `ontology_*` — 本体治理事件族 + +`ontology_activation` / `ontology_gate` / `ontology_review` / `ontology_repair` / +`ontology_revision` / `ontology_revision_thinking`——本体激活、工具闸、输出评审、修复与重写过程 +(重写结果经 `content_replace` 落地)。另有 `eventObj.scope === 'ontology_revision'` 的分流。 + ### `follow_up` — 追问建议 ```json {"type": "follow_up", "follow_up_questions": ["追问1", "追问2"]} ``` +- 追问也可能不走 SSE:流结束后由后台任务生成,写进消息 `extra_data.follow_up_questions`, + 前端轮询/刷新历史时取到 + ### `meta` — 元信息 ```json @@ -139,8 +204,9 @@ data: [DONE] # 流终止标记 每个 segment 类型对应不同的 UI 组件: - `thinking` → ThinkingBlock (可折叠) -- `tool` → ToolCall (可展开,显示名称/参数/结果) -- `text` → Markdown 渲染 + 引用标记 +- `tool` → ToolCall (可展开,显示名称/参数/结果;子智能体带 subSteps) +- `text` → Markdown 渲染 + 引用锚点胶囊(`[锚文本](cite:eN)`) +- `plan` → 计划清单条 ## 中断流 @@ -169,3 +235,12 @@ const { runId } = useChatStore.getState().activeRuns[chatId]; const r = await authFetch(`${apiUrl}/v1/chats/stream/${runId}`); // 事件格式与 POST /v1/chats/stream 完全一致 ``` + +实际编排在 `useStreaming.ts::resumeRunIfAny`: + +- `getActiveChatRun` 探测活跃 run;已终态则清僵尸状态(`cleanupZombieRunState`) +- 普通对话从 `last_event_offset` **断点续传**;`plan_generate` / `plan_execute` / `autonomous_loop` + 从 offset 0 **全量重放**(loop 会先截掉尾部 assistant 占位再重建) +- 跨标签页互斥:Web Locks `hugagent_run_follow_${run_id}`(`ifAvailable: true`), + 只允许一个标签页跟随同一 run +- 运行中插话:`steerChatRun` / `withdrawChatRunSteer`,失败时降级为下一轮普通消息 diff --git a/.claude/skills/hugagent-frontend-dev/references/ui-design-spec.md b/.claude/skills/hugagent-frontend-dev/references/ui-design-spec.md index e047eae..8acca71 100644 --- a/.claude/skills/hugagent-frontend-dev/references/ui-design-spec.md +++ b/.claude/skills/hugagent-frontend-dev/references/ui-design-spec.md @@ -32,10 +32,10 @@ | 色值 | 用途 | |------|------| -| `#262626` | 强调文字、正文标题 | -| `#4D4D4D` | 次强调、大段正文内容 | -| `#808080` | 次要信息 | -| `#B3B3B3` | 置灰/说明文字 | +| `#101828` | 强调文字、正文标题 | +| `#475467` | 次强调、大段正文内容 | +| `#667085` | 次要信息 | +| `#98A2B3` | 置灰/说明文字 | | `#FFFFFF` | 纯白文字(深色背景上) | ### 1.4 语义色 — 成功 (Success Green) @@ -95,9 +95,10 @@ | Windows | 微软雅黑 | 正常 (400) / 加粗 (700) | | 特殊数字/汉字 | 钉钉进步体 / DingTalk Sans | 正常 (400) | -**CSS font-family 推荐写法:** +**CSS font-family 推荐写法(与 variables.css 实现一致,系统栈优先):** ```css -font-family: "PingFang SC", "Microsoft YaHei", "微软雅黑", "DingTalk Sans", sans-serif; +font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; ``` ### 2.2 字号层级 @@ -162,12 +163,12 @@ font-family: "PingFang SC", "Microsoft YaHei", "微软雅黑", "DingTalk Sans", 系统使用**大圆角**表达 AI、时尚、亲近的品牌感。 -| 圆角值 | 用途 | 示例 | -|--------|------|------| -| 4px | 小面积元素 | 标签、小卡片 | -| 8px | 常用组件 | 按钮、输入框、下拉框、菜单 | -| 12px | 分割模块 | 卡片视图、背景卡片 | -| 20px | 大容器 | 弹窗、大输入框 | +| 圆角值 | 变量 | 用途 | 示例 | +|--------|------|------|------| +| 6px | `--radius-xs` | 小面积元素 | 标签、小卡片 | +| 10px | `--radius-sm` | 常用组件 | 按钮、输入框、下拉框、菜单 | +| 16px | `--radius-md` | 分割模块 | 卡片视图、背景卡片 | +| 24px | `--radius-lg` | 大容器 | 弹窗、大输入框 | --- @@ -348,10 +349,10 @@ font-family: "PingFang SC", "Microsoft YaHei", "微软雅黑", "DingTalk Sans", --color-bg-gray: #F5F6F7; /* 文字 */ - --color-text: #262626; - --color-text-secondary: #4D4D4D; - --color-text-tertiary: #808080; - --color-text-placeholder: #B3B3B3; + --color-text: #101828; + --color-text-secondary: #475467; + --color-text-tertiary: #667085; + --color-text-placeholder: #98A2B3; --color-text-white: #FFFFFF; /* 成功 */ @@ -386,10 +387,10 @@ font-family: "PingFang SC", "Microsoft YaHei", "微软雅黑", "DingTalk Sans", --color-icon-orange: #F8AB42; /* 圆角 */ - --radius-xs: 4px; - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 20px; + --radius-xs: 6px; + --radius-sm: 10px; + --radius-md: 16px; + --radius-lg: 24px; /* 间距 */ --space-xs: 4px; @@ -398,8 +399,9 @@ font-family: "PingFang SC", "Microsoft YaHei", "微软雅黑", "DingTalk Sans", --space-lg: 20px; --space-xl: 40px; - /* 字体 */ - --font-family: "PingFang SC", "Microsoft YaHei", "微软雅黑", sans-serif; + /* 字体(系统栈优先) */ + --font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; --font-family-number: "DingTalk Sans", "PingFang SC", "Microsoft YaHei", sans-serif; /* 字号 */ @@ -426,18 +428,18 @@ const theme = { colorWarning: '#F8AB42', colorError: '#FC5D5D', colorInfo: '#126DFF', - colorText: '#262626', - colorTextSecondary: '#4D4D4D', - colorTextTertiary: '#808080', - colorTextQuaternary: '#B3B3B3', + colorText: '#101828', + colorTextSecondary: '#475467', + colorTextTertiary: '#667085', + colorTextQuaternary: '#98A2B3', colorBorder: '#E3E6EA', colorBorderSecondary: '#D8DBE2', colorBgContainer: '#FFFFFF', colorBgLayout: '#F5F6F7', - borderRadius: 8, - borderRadiusLG: 12, - borderRadiusSM: 4, - fontFamily: '"PingFang SC", "Microsoft YaHei", "微软雅黑", sans-serif', + borderRadius: 10, + borderRadiusLG: 16, + borderRadiusSM: 6, + fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif', fontSize: 14, fontSizeSM: 12, fontSizeLG: 16, diff --git a/src/backend/api/routes/v1/chat_modes.py b/src/backend/api/routes/v1/chat_modes.py index 947d85c..d40b58a 100644 --- a/src/backend/api/routes/v1/chat_modes.py +++ b/src/backend/api/routes/v1/chat_modes.py @@ -52,6 +52,8 @@ class ChatModeIn(BaseModel): plugin_ids: Optional[List[str]] = None agent_ids: Optional[List[str]] = Field(None, description="这个模式下可入场的子智能体") manual_invoke_enabled: Optional[bool] = None + #: 收窄模式下是否保留沙箱代码执行与文件工具;all 作用域不看这个位。 + code_exec_enabled: Optional[bool] = None max_iters: Optional[int] = None default_effort: Optional[str] = Field(None, description="fast / medium / high / max") effort_locked: Optional[bool] = None @@ -77,6 +79,7 @@ def _mode_to_dict(row: ChatMode) -> Dict[str, Any]: "plugin_ids": row.plugin_ids or [], "agent_ids": row.agent_ids or [], "manual_invoke_enabled": bool(row.manual_invoke_enabled), + "code_exec_enabled": bool(getattr(row, "code_exec_enabled", False)), "max_iters": row.max_iters, "default_effort": row.default_effort, "effort_locked": bool(row.effort_locked), diff --git a/src/backend/core/db/models/chat_mode.py b/src/backend/core/db/models/chat_mode.py index a2e681d..2b78188 100644 --- a/src/backend/core/db/models/chat_mode.py +++ b/src/backend/core/db/models/chat_mode.py @@ -77,6 +77,11 @@ class ChatMode(Base): agent_ids = Column(JSONType, nullable=False, default=list) #: 是否允许用户本轮显式呼唤(/技能、@子智能体、插件)临时装配到这个模式里。 manual_invoke_enabled = Column(Boolean, nullable=False, default=True) + #: 收窄模式(restricted)下是否保留沙箱代码执行与文件工具(bash / 读写文件 / + #: 产物进出等原生内置工具)。历史上"收窄=一律无代码"是极速模式的私货,泛化成 + #: 模式表后要让建模式的人自己选。``all`` 作用域不看这个位(本就全量装配,仍受 + #: 全局 ``sandbox.code_capability_enable`` 总开关约束)。 + code_exec_enabled = Column(Boolean, nullable=False, default=False) #: ReAct 迭代硬上限;NULL = 不额外收紧,走全局默认。 max_iters = Column(Integer, nullable=True) diff --git a/src/backend/core/llm/agent_factory.py b/src/backend/core/llm/agent_factory.py index 581fe04..58ea2c1 100644 --- a/src/backend/core/llm/agent_factory.py +++ b/src/backend/core/llm/agent_factory.py @@ -578,13 +578,19 @@ def _elapsed(): # Normalized before any capability resolution so every downstream gate # (skill-bound MCP expansion, subagent merge, update_plan opt-in, file # tools) sees the narrowed grants rather than needing its own turbo check. + # + # _turbo_code_exec: 收窄模式保留代码执行(chat_modes.code_exec_enabled)。 + # 收窄的是 MCP/技能/插件面,沙箱与文件工具照常注册——"收窄=无代码"只是 + # 内置极速模式的契约,不是所有收窄模式的。 + _turbo_code_exec = False if turbo_mode: if mode_spec is not None: - # 模式表是真源:这四个取值全部来自 chat_modes 那一行。 + # 模式表是真源:这几个取值全部来自 chat_modes 那一行。 _mode_manual_invoke = bool(getattr(mode_spec, "manual_invoke_enabled", True)) _mode_mcp_ids = list(getattr(mode_spec, "mcp_server_ids", ()) or ()) _mode_skill_ids = list(getattr(mode_spec, "skill_ids", ()) or ()) _mode_plugin_ids = list(getattr(mode_spec, "plugin_ids", ()) or ()) + _turbo_code_exec = bool(getattr(mode_spec, "code_exec_enabled", False)) else: from core.services.system_config import ( turbo_manual_invoke_enabled, @@ -628,8 +634,9 @@ def _elapsed(): ) ) top_level_chat = False - read_only = True - allow_bash = False + if not _turbo_code_exec: + read_only = True + allow_bash = False # The turbo tool surface comes from admin config alone — deliberately # NOT intersected with catalog/user enable-disable state. Explicitly # summoned plugin MCPs are appended on top; MCPs bound to a summoned @@ -1093,7 +1100,7 @@ async def _connect_http(key: str, cfg: dict): # human in the loop → non-interactive, and §13 rejects /myspace writes # outright. _interactive: bool = not (isolated or batch_mode) - if not disable_tools and turbo_mode: + if not disable_tools and turbo_mode and not _turbo_code_exec: # Turbo keeps only cross-turn attachment access (the file-context hook # references this tool for historical attachments); every other native # tool — sandbox/bash/file ops — is out of scope for quick lookup. @@ -1110,7 +1117,9 @@ async def _connect_http(key: str, cfg: dict): loader, loaded_skill_ids=loaded_skill_ids, ) - if not disable_tools and not turbo_mode: + # 收窄模式开了代码执行位(_turbo_code_exec)就走完整原生工具注册:MCP/技能面 + # 仍按模式收窄(上面已经归一),但沙箱/文件/产物工具与标准模式同一套。 + if not disable_tools and (not turbo_mode or _turbo_code_exec): register_sandboxed_view_text_file( toolkit, allowed_skill_dirs, @@ -1393,10 +1402,13 @@ def _build_toolkit() -> Toolkit: elif _mode_prompt_kind and _mode_prompt_kind != "turbo": _mode_prompt = _pvs_mode.render_system_prompt_of_kind(_mode_prompt_kind) - if turbo_mode: + if turbo_mode and not _turbo_code_exec: # Turbo swaps in the standalone prompt (DB "turbo" active version → # fs fallback): the default prompt's tool/workflow sections describe # capabilities this assembly deliberately does not carry. + # (开了代码执行位的收窄模式不进这条:装配确实带沙箱/文件工具, + # turbo 正文"秒级检索、不执行代码"的叙事反而是错的——没配专属 + # 提示词时走默认装配,工具段按实际 toolkit 动态生成。) from core.services import prompt_version_service as _pvs_turbo # 收窄模式没配专属提示词时退回历史的 turbo 正文——极速模式绑的就是它。 @@ -1443,7 +1455,7 @@ def _build_toolkit() -> Toolkit: # ── Inject code-capability system prompt ── # Gating: CODE_CAPABILITY_ENABLED=true injects in all modes. # Single source of truth render_code_capability_segment (same source as the Config console preview). - if code_capability_enabled() and not turbo_mode: + if code_capability_enabled() and (not turbo_mode or _turbo_code_exec): try: from core.services import prompt_version_service as _pvs @@ -1889,7 +1901,12 @@ def _build_toolkit() -> Toolkit: from core.services.system_config import turbo_max_iters _mode_iters = getattr(mode_spec, "max_iters", None) if mode_spec else None - _max_iters = min(_max_iters, int(_mode_iters) if _mode_iters else turbo_max_iters()) + if _mode_iters: + _max_iters = min(_max_iters, int(_mode_iters)) + elif not _turbo_code_exec: + _max_iters = min(_max_iters, turbo_max_iters()) + # else: 开了代码执行位且模式没配上限 → 不套极速的检索档硬顶(默认 4 轮 + # 会把跑代码的任务掐死),按 profile/env 的常规上限走。 # ── Create the Agent (AgentScope 2.0) ── # Note: long_term_memory is not passed — mem0 is fully stripped from the SSE diff --git a/src/backend/core/llm/tools/sandbox_tool.py b/src/backend/core/llm/tools/sandbox_tool.py index 4075138..6b304c0 100644 --- a/src/backend/core/llm/tools/sandbox_tool.py +++ b/src/backend/core/llm/tools/sandbox_tool.py @@ -594,8 +594,12 @@ async def sandbox_get_artifact(src_path: str, name: str = "") -> ToolResponse: sandbox_get_artifact.__doc__ = ( "把沙盒文件登记为持久 artifact 并返回 file_id。\n\n" - "⚠️ **拿到 file_id ≠ 已交付**——还要再调 `pin_to_workspace` 文件才对用户可见;\n" - "交付链路的完整规则见系统提示词「文件产物与『我的空间』操作」。\n\n" + "⚠️ **拿到 file_id ≠ 已交付**:本工具只做登记,返回的 url 默认对用户隐藏,\n" + "必须再调 `pin_to_workspace(file_ids=[...])` 文件才作为附件出现在对话区。\n" + "**禁止**把 file_id 或 url 写进正文当下载链接——那对用户不可见。\n\n" + "沙盒产物交付是**严格三步、顺序不可颠倒**:\n" + " 1) bash 跑命令生成文件 → 2) sandbox_get_artifact 登记拿 file_id\n" + " → 3) pin_to_workspace 交付。跳过第 2 步直接 pin 路径或文件名必然失败。\n\n" "Args:\n" " src_path (`str`): 沙盒里的源文件绝对路径,必须以 /workspace/ 开头。\n" " name (`str`, 可选): 用户面向的文件名。不传则取 src_path 的 basename。\n\n" diff --git a/src/backend/core/services/chat_mode_service.py b/src/backend/core/services/chat_mode_service.py index 2a92a5d..e525f50 100644 --- a/src/backend/core/services/chat_mode_service.py +++ b/src/backend/core/services/chat_mode_service.py @@ -54,6 +54,8 @@ class ChatModeSpec: plugin_ids: Tuple[str, ...] = () agent_ids: Tuple[str, ...] = () manual_invoke_enabled: bool = True + #: 收窄模式下是否保留沙箱代码执行与文件工具;``all`` 作用域不看这个位。 + code_exec_enabled: bool = False max_iters: Optional[int] = None default_effort: str = "fast" effort_locked: bool = False @@ -71,6 +73,7 @@ def restricted(self) -> bool: slug=SLUG_STANDARD, name="标准模式", tool_scope=TOOL_SCOPE_ALL, + code_exec_enabled=True, default_effort="fast", ) @@ -181,6 +184,7 @@ def _spec_of(row: Optional[ChatMode]) -> Optional[ChatModeSpec]: plugin_ids=tuple(_as_id_list(row.plugin_ids)), agent_ids=tuple(_as_id_list(row.agent_ids)), manual_invoke_enabled=bool(row.manual_invoke_enabled), + code_exec_enabled=bool(getattr(row, "code_exec_enabled", False)), max_iters=row.max_iters, default_effort=row.default_effort or "fast", effort_locked=bool(row.effort_locked), @@ -288,6 +292,8 @@ def take(key: str, value: Any) -> None: out[key] = _as_id_list(payload.get(key)) if creating or "manual_invoke_enabled" in payload: out["manual_invoke_enabled"] = bool(payload.get("manual_invoke_enabled", True)) + if creating or "code_exec_enabled" in payload: + out["code_exec_enabled"] = bool(payload.get("code_exec_enabled", False)) if creating or "max_iters" in payload: raw = payload.get("max_iters") try: @@ -625,6 +631,8 @@ def ensure_builtins(self) -> None: plugin_ids=[], agent_ids=[], manual_invoke_enabled=True, + # all 作用域不看这个位,置 True 只为语义自洽:标准模式本就带代码能力。 + code_exec_enabled=True, max_iters=None, default_effort="fast", effort_locked=False, @@ -679,6 +687,8 @@ def _turbo_seed_fields() -> Dict[str, Any]: "plugin_ids": plugin_ids, "agent_ids": [], "manual_invoke_enabled": manual, + # 极速的产品契约就是"检索直达、不执行代码",保持关。 + "code_exec_enabled": False, "max_iters": iters, # 极速那条链路本就不思考,强度锁死——不是"默认不思考但可以改"。 "default_effort": "fast", diff --git a/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md b/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md index 63f48f2..1cafee1 100644 --- a/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md +++ b/src/backend/prompts/prompt_text/code_exec/system/10_tools_and_capabilities.system.md @@ -33,26 +33,11 @@ ### 文件产物与「我的空间」操作(关键,照做别绕路) -文件**默认对用户隐藏**,必须显式 `pin_to_workspace` 才在对话区/Canvas 展示。 - -**`file_id` 的三种来源**: -- 用户上传的、或已在我的空间里的 → `list_myspace_files` 返回的 `file_id` -- **沙盒里 bash/脚本现场生成的**(`word-cli` 产出的 .docx、matplotlib 画的图等)→ **必须先**调 `sandbox_get_artifact(name="<显示名>.docx", src_path="<沙盒路径>")` 登记入库,取其返回的 `file_id`(也叫 `artifact_id`) -- 专用工具/CLI 直接返回的(`generate_chart_tool`,以及 `word-cli` / `ppt-cli` / `excel-cli` / `pdf-cli` 的 create / edit / build / convert 等)→ 用它返回的 `file_id` - -后两类的 `file_id` 是 **artifact 句柄,不是磁盘路径**——不在 `/workspace`、`/tmp`、`/myspace` 任何路径下。**禁止**用 `Glob` / `bash find` / `sandbox_get_artifact` 去文件系统里"找"它(永远找不到,纯浪费步骤);一律拿返回的原值往下串,不要从磁盘重新定位、不要传文件名或臆造路径。 - -**沙盒产物交付:严格三步,顺序不可颠倒** -``` -1) bash → 跑命令(word-cli create / matplotlib savefig),文件落在沙盒某路径 -2) sandbox_get_artifact(name=..., src_path=...) → 返回里的 file_id 才是真 file_id -3) pin_to_workspace(file_ids=["abc123..."]) ← 用上一步返回的 file_id -``` -❌ 传沙盒路径 `["/workspace/report.docx"]` ❌ 传文件名 `["report.docx"]` ❌ 先 pin 再登记(顺序反了,pin 的不存在) ❌ 跳过 `sandbox_get_artifact` 直接 pin(没登记,pin 不到) - -**形态校验**:file_id 是 32 位十六进制串或 `fid_` 开头的短 id,**不带斜杠、不带扩展名**。要传给 pin 的字符串里若含 `/` 或 `.docx`/`.pptx`/`.xlsx` 等扩展名,**就是错的**——回到第 2 步重新登记。 - -**多份产物**:每份各自登记拿到 file_id,**最后一次 `pin_to_workspace` 把所有 file_id 塞进同一个列表**,不要分多次调;单个文件也用列表。中间过程文件(Word 编辑链里的 `edited.docx`、调试草图、临时数据集)**不要** pin,也不必登记。默认交付方式是 pin 到对话区,**不是默默写进 `/myspace/`**。 +交付链路(登记 → pin)的规则写在 `sandbox_get_artifact` 与 `pin_to_workspace` 各自的 +工具说明里,按那里执行即可。这里只讲跨工具的一条:**工具/CLI 返回的 `file_id` 是 +artifact 句柄,不是磁盘路径**——它不在 `/workspace`、`/tmp`、`/myspace` 下,**禁止**用 +`Glob` / `bash find` 去文件系统里"找"它(永远找不到,纯浪费步骤),一律拿返回的原值往 +下串。默认交付方式是 pin 到对话区,**不是默默写进 `/myspace/`**。 **用户「我的空间」文件增删改查(仅在用户明确要求时):** diff --git a/src/backend/tests/services/test_chat_mode_service.py b/src/backend/tests/services/test_chat_mode_service.py index 4f6e3ab..7e9c12c 100644 --- a/src/backend/tests/services/test_chat_mode_service.py +++ b/src/backend/tests/services/test_chat_mode_service.py @@ -192,3 +192,22 @@ def test_private_mode_cannot_bind_prompt_kind(db): # 更新也一样 row2 = svc.update(row.id, {"prompt_kind": "turbo"}, owner_user_id="u1") assert row2.prompt_kind is None + + +def test_code_exec_flag_roundtrip(db): + """收窄模式的代码执行位:默认关(与历史"收窄=无代码"行为一致),显式开则 + 进 spec——agent 工厂据此保留沙箱/文件工具。""" + svc = ChatModeService(db) + # 默认关 + row = svc.create({"name": "纯检索", "slug": "retrieval-only"}, owner_user_id="u1") + assert bool(row.code_exec_enabled) is False + assert svc.resolve("retrieval-only", "u1").code_exec_enabled is False + # 显式开 + 更新可关回 + row2 = svc.create( + {"name": "带代码", "slug": "with-code", "code_exec_enabled": True}, + owner_user_id="u1", + ) + assert bool(row2.code_exec_enabled) is True + assert svc.resolve("with-code", "u1").code_exec_enabled is True + row3 = svc.update(row2.id, {"code_exec_enabled": False}, owner_user_id="u1") + assert bool(row3.code_exec_enabled) is False diff --git a/src/frontend/src/api.ts b/src/frontend/src/api.ts index 12343f0..2dbdfe6 100644 --- a/src/frontend/src/api.ts +++ b/src/frontend/src/api.ts @@ -370,6 +370,7 @@ export interface ChatModeDetail extends ChatModeOption { plugin_ids: string[]; agent_ids: string[]; manual_invoke_enabled: boolean; + code_exec_enabled: boolean; max_iters: number | null; prompt_kind: string | null; prompt_text: string | null; diff --git a/src/frontend/src/components/chat/ChatArea.tsx b/src/frontend/src/components/chat/ChatArea.tsx index 67c9037..dc5acfa 100644 --- a/src/frontend/src/components/chat/ChatArea.tsx +++ b/src/frontend/src/components/chat/ChatArea.tsx @@ -120,7 +120,6 @@ export function ChatArea({ store, currentChatId, setInput, planMode, shareSelectionMode, selectedShareMessageTs, pendingScrollMessageTs, setPendingScrollMessageTs, - setQuotedFollowUp, clearShareSelection, chatsLoading, backendSessionIds, loadedMsgIds, @@ -175,7 +174,8 @@ export function ChatArea({ useEffect(() => { useChatStore.getState().clearShareSelection(); - setQuotedFollowUp(null); + // 引用追问不再在这里清空:它已随对话记录持久化(ChatItem.pendingQuote), + // 切对话时由 chatStore 的各切换路径恢复/清空。 }, [currentChatId]); // Stable identity on purpose: ChatShareBanner rebuilds its loader whenever this diff --git a/src/frontend/src/components/chat/MessageBubble.tsx b/src/frontend/src/components/chat/MessageBubble.tsx index 2f828ca..be7789b 100644 --- a/src/frontend/src/components/chat/MessageBubble.tsx +++ b/src/frontend/src/components/chat/MessageBubble.tsx @@ -23,6 +23,7 @@ import { ToolProgressInline } from '../tool/ToolProgressInline'; import { anyToolRunning } from '../tool/renderers/utils'; import { ThinkingInline } from './ThinkingInline'; import { StreamWaitIndicator } from './StreamWaitIndicator'; +import { TurnStatusIndicator } from './TurnStatusIndicator'; import { EvolutionCard } from './EvolutionCard'; import { OntologyReviewTrigger } from './OntologyReviewTrigger'; import { useStallDetector } from '../../hooks'; @@ -896,13 +897,12 @@ export function MessageBubble({ m, messageIndex, currentChatId, send, exportChat }); if (virtualPending) { + // No real tool run to attach to (turn start, or the model went + // silent again after finishing a run + text) — a light turn-level + // shimmer label beats a hollow "执行中" shell card here: we don't + // even know yet whether a tool will be called. rendered.push( - , + , ); } @@ -934,11 +934,7 @@ export function MessageBubble({ m, messageIndex, currentChatId, send, exportChat )} {m.isStreaming && !m.content ? ( dispatchProcessVisible ? ( - + ) : ( ) diff --git a/src/frontend/src/components/chat/TurnStatusIndicator.tsx b/src/frontend/src/components/chat/TurnStatusIndicator.tsx new file mode 100644 index 0000000..94280bf --- /dev/null +++ b/src/frontend/src/components/chat/TurnStatusIndicator.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import { BrandLoader, ElapsedTimer } from '../common'; +import { t } from '../../i18n'; + +interface TurnStatusIndicatorProps { + /** Wall-clock ms the current wait began — anchors the elapsed clock. */ + startTs: number; +} + +/** Short waits keep the bare label; the clock only appears once the turn has clearly been running a while. */ +const CLOCK_AFTER_MS = 15_000; + +/** + * Turn-level "model at work" signal shown before anything visible has + * streamed in (no text, no tool run yet). A single line of brand-gradient + * shimmer text instead of a full ToolRunShell card — at this point we don't + * yet know whether the model will call a tool at all, so "执行中 / 正在准备 + * 调用工具" both over-promises and reads heavy. Once a real tool run starts, + * the shell (and its in-shell pending row) takes over. + */ +export function TurnStatusIndicator({ startTs }: TurnStatusIndicatorProps) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + + const showClock = now - startTs >= CLOCK_AFTER_MS; + return ( +
+ + {/* jx-anim-keep: "system at work" indicator; under reduced-motion the shimmer slows down but is preserved */} + {t('深度拥抱中…')} + {showClock && } +
+ ); +} diff --git a/src/frontend/src/components/settings/ChatModesPanel.tsx b/src/frontend/src/components/settings/ChatModesPanel.tsx index fca6a9c..bc35451 100644 --- a/src/frontend/src/components/settings/ChatModesPanel.tsx +++ b/src/frontend/src/components/settings/ChatModesPanel.tsx @@ -116,6 +116,7 @@ export function ChatModesPanel() { plugin_ids: [], agent_ids: [], manual_invoke_enabled: true, + code_exec_enabled: false, max_iters: null, default_effort: 'fast', effort_locked: false, @@ -220,6 +221,7 @@ export function ChatModesPanel() { {row.tool_scope === 'all' ? t('不收窄') : t('MCP {n}', { n: row.mcp_server_ids.length })} {row.tool_scope === 'restricted' && {t('技能 {n}', { n: row.skill_ids.length })}} {row.tool_scope === 'restricted' && {t('插件 {n}', { n: row.plugin_ids.length })}} + {row.tool_scope === 'restricted' && row.code_exec_enabled && {t('代码执行')}} {t(EFFORT_OPTIONS.find(o => o.value === row.default_effort)?.label || row.default_effort)} @@ -328,6 +330,14 @@ export function ChatModesPanel() { + + + diff --git a/src/frontend/src/hooks/useStreaming.ts b/src/frontend/src/hooks/useStreaming.ts index 999fe56..b854925 100644 --- a/src/frontend/src/hooks/useStreaming.ts +++ b/src/frontend/src/hooks/useStreaming.ts @@ -6,7 +6,7 @@ import { processPlanExecuteStream, processPlanGenerateStream } from './usePlanMo import { uploadFileToOSS } from '../utils/fileParser'; import { inferBusinessTopic } from '../utils/history'; import { resolveBatchModeActive } from '../utils/chatMode'; -import { useChatStore, useAuthStore, useCatalogStore, useFileStore, useUIStore, useBatchStore, useModelCapabilitiesStore } from '../stores'; +import { useChatStore, useAuthStore, useCatalogStore, useChatModeStore, useFileStore, useUIStore, useBatchStore, useModelCapabilitiesStore } from '../stores'; import { useProjectStore } from '../stores/projectStore'; import { isThinkingMode } from '../stores/chatStore'; import { processChatStream } from './chatStream'; @@ -489,6 +489,10 @@ export function useStreaming( updatedAt: Date.now(), title: c?.title && c.title !== '新对话' ? c.title : msg.slice(0, 18) || '新对话', businessTopic: inferredTopic, + // 发送即落当前模式与思考强度:首条消息前 setModeSlug/setChatMode 没有记录 + // 可写,这里补上,刷新/切对话后模式位和强度档才恢复得回来。 + modeSlug: useChatStore.getState().modeSlug, + thinkingEffort: useChatStore.getState().chatMode, }; return { chats: { ...prev.chats, [currentChatId]: nextChat }, @@ -527,6 +531,11 @@ export function useStreaming( ?.runTarget === 'local'; if (isLocalProject(effectiveProjectId) || runTargetLocal) registerLocalChat(currentChatId); + // 锁死强度的模式按模式默认档上行:切回历史对话恢复模式时,chatMode 可能 + // 还停在别的对话选的档位,不能把它带进锁档模式(显式选模式时 ChatModeSwitch + // 也是切到默认档,这里保持同一语义)。 + const activeModeSpec = useChatModeStore.getState().modeOf(useChatStore.getState().modeSlug); + const wireChatMode = activeModeSpec.effort_locked ? activeModeSpec.default_effort : chatMode; const r = await authFetch(`${effectiveApiUrl}/v1/chats/stream`, { method: 'POST', headers: { @@ -539,7 +548,7 @@ export function useStreaming( message: wireMsg, model_name: 'qwen', ...(selectedModelProviderId ? { model_provider_id: selectedModelProviderId } : {}), - chat_mode: chatMode, + chat_mode: wireChatMode, mode_slug: useChatStore.getState().modeSlug, attachments: attachments.map(({ name, mime_type, file_id }) => ({ name, diff --git a/src/frontend/src/i18n/en/chat.ts b/src/frontend/src/i18n/en/chat.ts index cf34931..8557e89 100644 --- a/src/frontend/src/i18n/en/chat.ts +++ b/src/frontend/src/i18n/en/chat.ts @@ -255,6 +255,7 @@ export const CHAT_DICT: Record = { '暂无提示词,请在管理后台添加': 'No prompts yet. Add some in the admin console.', '正在准备调用工具': 'Preparing tool call', '正在准备调用工具…': 'Preparing tool call…', + '深度拥抱中…': 'Deep hugging…', 'Mermaid 渲染失败': 'Mermaid render failed', '加载图表中...': 'Loading diagram...', '用时 {sec}秒': 'Took {sec}s', diff --git a/src/frontend/src/i18n/en/settings.ts b/src/frontend/src/i18n/en/settings.ts index 3303697..902f98c 100644 --- a/src/frontend/src/i18n/en/settings.ts +++ b/src/frontend/src/i18n/en/settings.ts @@ -778,6 +778,10 @@ export const SETTINGS_DICT: Record = { '模式名称': 'Mode name', '请填写模式名称': 'Please enter a mode name', '允许显式呼唤': 'Allow explicit summoning', + '保留代码执行': 'Keep code execution', + '代码执行': 'Code execution', + '开启后这个模式仍带沙箱代码执行与文件读写工具(受系统配置的代码能力总开关约束);关闭则纯检索问答,不执行代码——内置极速模式即如此。': 'When on, this mode keeps the sandbox code-execution and file tools (still subject to the global code-capability switch in system config); when off, it is retrieval-only Q&A with no code execution — the built-in Turbo mode works this way.', + '开启后这个模式仍能执行代码、读写文件;关闭则纯检索问答,不执行代码。': 'When on, this mode can still execute code and read/write files; when off, it is retrieval-only Q&A with no code execution.', '只装配下面列出的能力': 'Assemble only the capabilities listed below', '选择这个模式装配的 MCP 工具': 'Select the MCP tools this mode assembles', '选择这个模式装配的技能': 'Select the skills this mode assembles', diff --git a/src/frontend/src/stores/chatStore.ts b/src/frontend/src/stores/chatStore.ts index c0c9bf5..6b83610 100644 --- a/src/frontend/src/stores/chatStore.ts +++ b/src/frontend/src/stores/chatStore.ts @@ -4,7 +4,7 @@ import { loadChatStore, saveChatStoreDebounced, flushChatStore, nowId, userScope import { usePageConfigStore } from './pageConfigStore'; import { usePluginStore } from './pluginStore'; import { t } from '../i18n'; -import { resolvePlanModeActive } from '../utils/chatMode'; +import { resolveModeSlug, resolvePlanModeActive } from '../utils/chatMode'; /** Fixed slug of the site-building plugin (plugin_bundles/marketplace/sites). Site-building * capability (publish_site tool + site-builder guidance skill) is provided by it — removed from @@ -103,6 +103,55 @@ function savePendingScrollMessageTs(userId: string | null | undefined, ts: numbe window.localStorage.setItem(key, String(ts)); } +const QUEUED_MESSAGES_KEY = 'hugagent_queued_messages'; + +/** 排队消息持久化:**只存 status==='queued' 的**。steering/applied 的指令已经交给 + * 后端 run(steerChatRun),刷新后恢复成待发送会重复下发;queued 的恢复成待发送 + * 卡片即可——有续播 run 就等它结束自动接管,没有就留给用户点发送/删除。 */ +function loadQueuedMessages(userId: string | null | undefined): Record { + if (typeof window === 'undefined') return {}; + const key = userScopedKey(QUEUED_MESSAGES_KEY, userId); + if (!key) return {}; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + const out: Record = {}; + for (const [chatId, q] of Object.entries(parsed || {})) { + if (q && q.status === 'queued' && typeof q.content === 'string' && q.content) out[chatId] = q; + } + return out; + } catch { + return {}; + } +} + +function saveQueuedMessages( + userId: string | null | undefined, + map: Record, +) { + if (typeof window === 'undefined') return; + const key = userScopedKey(QUEUED_MESSAGES_KEY, userId); + if (!key) return; + const persistable = Object.entries(map).filter(([, q]) => q?.status === 'queued'); + try { + if (persistable.length === 0) window.localStorage.removeItem(key); + else window.localStorage.setItem(key, JSON.stringify(Object.fromEntries(persistable))); + } catch { /* localStorage 不可用时放弃持久化,不影响内存态 */ } +} + +/** 恢复对话记录上的思考强度档。没记录(老数据 / 没显式选过)返回 {}——沿用当前 + * 会话档位,避免切一次对话就把用户刚选的档位吞掉。 */ +function restoredEffort( + chat?: ChatItem, +): Partial<{ chatMode: ChatMode; lastStandardMode: ThinkingEffort }> { + const saved = chat?.thinkingEffort; + if (!saved || !(VALID_CHAT_MODES as readonly string[]).includes(saved)) return {}; + return saved === 'turbo' + ? { chatMode: saved } + : { chatMode: saved, lastStandardMode: saved }; +} + interface ChatState { /** ID of the currently authenticated user. Null until `hydrateForUser` is * called after login. All localStorage reads/writes are scoped by this id @@ -396,6 +445,11 @@ export const useChatStore = create((set, get) => { currentChatId: id, sending: get().sendingChatIds.has(id), planMode: resolvePlanModeActive(chat), + // 模式跟着对话走:切到哪段对话就恢复它记录上的模式,没记录的按标准模式。 + modeSlug: resolveModeSlug(chat), + // 思考强度与待发送引用同理随对话记录恢复;强度没记录时沿用当前档位。 + ...restoredEffort(chat), + quotedFollowUp: chat?.pendingQuote || null, // Autonomous loop is a "one-shot composer intent" and does not persist with the chat — // switching to any chat resets to a normal conversation, so a loop mode left on in the // previous chat doesn't carry over into a new/other chat. @@ -422,8 +476,41 @@ export const useChatStore = create((set, get) => { if (next.has(id)) next.delete(id); else next.add(id); set({ expandedThinking: next }); }, - setChatMode: (v) => set(v === 'turbo' ? { chatMode: v } : { chatMode: v, lastStandardMode: v }), - setModeSlug: (v) => set({ modeSlug: v || 'standard' }), + setChatMode: (v) => { + const ui = v === 'turbo' + ? { chatMode: v } + : { chatMode: v, lastStandardMode: v as ThinkingEffort }; + const { currentChatId, currentUserId, store } = get(); + const chat = store.chats[currentChatId]; + if (!chat) { + // 记录还没建(首条消息前):先记 UI 态,useStreaming 建记录时补落。 + set(ui); + return; + } + const next: ChatStoreData = { + ...store, + chats: { ...store.chats, [currentChatId]: { ...chat, thinkingEffort: v } }, + }; + set({ ...ui, store: next, storeRef: next }); + saveChatStoreDebounced(currentUserId, next); + }, + setModeSlug: (v) => { + const slug = v || 'standard'; + const { currentChatId, currentUserId, store } = get(); + const chat = store.chats[currentChatId]; + if (!chat) { + // 对话记录还没建(首条消息前):先记 UI 态,useStreaming 建记录时会把 + // 当前 modeSlug 落进去。 + set({ modeSlug: slug }); + return; + } + const next: ChatStoreData = { + ...store, + chats: { ...store.chats, [currentChatId]: { ...chat, modeSlug: slug } }, + }; + set({ modeSlug: slug, store: next, storeRef: next }); + saveChatStoreDebounced(currentUserId, next); + }, setToolResultPanel: (panel) => set({ toolResultPanel: panel }), setCopiedMsg: (ts) => set({ copiedMsg: ts }), setChatsLoading: (v) => set({ chatsLoading: v }), @@ -472,7 +559,23 @@ export const useChatStore = create((set, get) => { savePendingScrollMessageTs(get().currentUserId, ts); set({ pendingScrollMessageTs: ts }); }, - setQuotedFollowUp: (quote) => set({ quotedFollowUp: quote }), + setQuotedFollowUp: (quote) => { + const { currentChatId, currentUserId, store } = get(); + const chat = store.chats[currentChatId]; + if (!chat) { + set({ quotedFollowUp: quote }); + return; + } + const nextChat: ChatItem = { ...chat }; + if (quote) nextChat.pendingQuote = quote; + else delete nextChat.pendingQuote; + const next: ChatStoreData = { + ...store, + chats: { ...store.chats, [currentChatId]: nextChat }, + }; + set({ quotedFollowUp: quote, store: next, storeRef: next }); + saveChatStoreDebounced(currentUserId, next); + }, setActiveSkill: (skill) => set({ activeSkill: skill }), setActivePlugin: (plugin) => set({ activePlugin: plugin }), setActiveMention: (mention) => set({ activeMention: mention }), @@ -536,17 +639,23 @@ export const useChatStore = create((set, get) => { delete next[chatId]; return { activeRuns: next }; }), - setQueuedMessage: (chatId, queued) => set((s) => { - const next = { ...s.queuedMessages }; - if (queued) next[chatId] = queued; - else delete next[chatId]; - return { queuedMessages: next }; - }), - updateQueuedMessage: (chatId, updater) => set((s) => { - const current = s.queuedMessages[chatId]; - if (!current) return { queuedMessages: s.queuedMessages }; - return { queuedMessages: { ...s.queuedMessages, [chatId]: updater(current) } }; - }), + setQueuedMessage: (chatId, queued) => { + set((s) => { + const next = { ...s.queuedMessages }; + if (queued) next[chatId] = queued; + else delete next[chatId]; + return { queuedMessages: next }; + }); + saveQueuedMessages(get().currentUserId, get().queuedMessages); + }, + updateQueuedMessage: (chatId, updater) => { + set((s) => { + const current = s.queuedMessages[chatId]; + if (!current) return { queuedMessages: s.queuedMessages }; + return { queuedMessages: { ...s.queuedMessages, [chatId]: updater(current) } }; + }); + saveQueuedMessages(get().currentUserId, get().queuedMessages); + }, setCompactionNotice: (chatId) => set((s) => ({ compactionNotices: { ...s.compactionNotices, [chatId]: true }, })), @@ -699,6 +808,7 @@ export const useChatStore = create((set, get) => { loopMode: false, currentPlanId: null, toolResultPanel: null, + quotedFollowUp: nextChat.pendingQuote || null, sending: sendingChatIds.has(targetId), }); }, @@ -779,6 +889,7 @@ export const useChatStore = create((set, get) => { currentPlanId: null, toolResultPanel: null, input: '', + quotedFollowUp: nextChat.pendingQuote || null, activeSkill: null, // "Sites" plugin installed → activate it automatically (site-builder skill + site_publish tool delivered with this turn). activePlugin: sitesActivePlugin, @@ -834,6 +945,7 @@ export const useChatStore = create((set, get) => { queuedMessages: nextQueued, }); saveChatStoreDebounced(currentUserId, next); + saveQueuedMessages(currentUserId, nextQueued); if (currentChatId === id) { const newId = next.order[0] || nowId('chat'); const nextChat = next.chats[newId]; @@ -841,11 +953,13 @@ export const useChatStore = create((set, get) => { set({ currentChatId: newId, planMode: resolvePlanModeActive(nextChat), + modeSlug: resolveModeSlug(nextChat), + ...restoredEffort(nextChat), loopMode: false, currentPlanId: null, shareSelectionMode: false, selectedShareMessageTs: new Set(), - quotedFollowUp: null, + quotedFollowUp: nextChat?.pendingQuote || null, activeSkill: null, activePlugin: null, activeMention: null, @@ -894,7 +1008,7 @@ export const useChatStore = create((set, get) => { loadedMsgIds: new Set(), shareSelectionMode: false, selectedShareMessageTs: new Set(), - quotedFollowUp: null, + quotedFollowUp: store.chats[currentChatId]?.pendingQuote || null, activeSkill: null, activePlugin: null, activeMention: null, @@ -903,11 +1017,14 @@ export const useChatStore = create((set, get) => { currentPlanId: null, editingMessageTs: null, activeRuns: {}, - queuedMessages: {}, + // 只恢复 queued 状态的排队消息;steering/applied 的已交给后端 run,不重放。 + queuedMessages: loadQueuedMessages(userId), compactionNotices: {}, contextCompactions: {}, ...applyDefaultChatMode(), - modeSlug: 'standard', + // 刷新/重新进入时恢复当前对话记录上的模式与思考强度,不再一律掉回默认。 + modeSlug: resolveModeSlug(store.chats[currentChatId]), + ...restoredEffort(store.chats[currentChatId]), }); }, diff --git a/src/frontend/src/styles/chat.css b/src/frontend/src/styles/chat.css index 4ebf50b..036cf27 100644 --- a/src/frontend/src/styles/chat.css +++ b/src/frontend/src/styles/chat.css @@ -566,6 +566,46 @@ 40% { transform:translateY(-2px); opacity:1; } } +/* ── 轮级运行状态"深度拥抱中…"——首字/首个工具前的 logo 动图 + 灰阶流光文字, + 代替过早出现的"执行中 / 正在准备调用工具"空壳卡片。 + 灰阶渐变与 .jx-tcr-prefix--shimmer 同一套;字号加大加粗突出轮级地位, + 与既有"系统在工作"指示器视觉同源 ── */ +.jx-turnStatus{ + display:inline-flex; + align-items:center; + gap:6px; + align-self:flex-start; + min-height:26px; + padding:2px 0; + font-size:14px; + font-weight:600; + white-space:nowrap; +} +/* logo 动图与运行中的工具壳同款轻呼吸 */ +.jx-turnStatus .jx-brandLoader{ + animation:jx-trs-breathe 1.8s ease-in-out infinite; +} +.jx-turnStatus-label{ + /* 中灰而非近黑:slate-400 打底、slate-600 做流光高亮,加粗后仍显轻盈 */ + background:linear-gradient(90deg, + rgba(148, 163, 184, 0.70) 0%, + rgba(71, 85, 105, 0.90) 50%, + rgba(148, 163, 184, 0.70) 100%); + background-size:200% 100%; + -webkit-background-clip:text; + background-clip:text; + -webkit-text-fill-color:transparent; + color:transparent; + animation:jx-kf-shimmer 1.6s linear infinite; +} +.jx-turnStatus-clock{ + margin-left:2px; + font-size:12px; + font-weight:400; + font-variant-numeric:tabular-nums; + color:var(--color-text-placeholder); +} + /* ── 上传文件预览卡片(输入区 & 用户消息) ──────── */ .jx-fileCard{ diff --git a/src/frontend/src/types.ts b/src/frontend/src/types.ts index 1c24def..9fbbd98 100755 --- a/src/frontend/src/types.ts +++ b/src/frontend/src/types.ts @@ -456,6 +456,16 @@ export interface ChatItem { * (plan chats start with plan mode enabled); false records that the user switched back * to ordinary conversation while retaining the historical plan cards. */ planModeActive?: boolean; + /** 这段对话选中的模式 slug(chat_modes.slug)。undefined = 标准模式。随对话记录 + * 持久化(localStorage),刷新/切对话时由 resolveModeSlug 恢复,不然模式位每次 + * 都掉回标准模式。 */ + modeSlug?: string; + /** 这段对话选中的思考强度档(chatMode)。undefined = 没显式选过,恢复时沿用当前 + * 会话档位(刷新则回管理端默认)。与 modeSlug 同一套持久化机制。 */ + thinkingEffort?: 'turbo' | 'fast' | 'medium' | 'high' | 'max'; + /** 输入区待发送的引用追问块(还没随消息发出去的那个)。随对话记录持久化, + * 刷新/切对话时恢复;发送即清。消息上已发出的引用在 ChatMessage.quotedFollowUp。 */ + pendingQuote?: { text: string; ts: number }; /** Historical classification: this chat was created/used via the batch-execution ("批量执行") * entry. Like planChat it survives the user leaving the mode, so batch history stays * recognisable; it does not by itself decide how the next message is sent. */ diff --git a/src/frontend/src/utils/chatMode.ts b/src/frontend/src/utils/chatMode.ts index 494148a..99a1731 100644 --- a/src/frontend/src/utils/chatMode.ts +++ b/src/frontend/src/utils/chatMode.ts @@ -22,3 +22,8 @@ export function resolveBatchModeActive(chat?: BatchModeChat): boolean { export function shouldRestorePlanModeFromHistory(chat?: PlanModeChat): boolean { return chat?.planModeActive !== false; } + +/** 这段对话的模式 slug:记录上没有(老数据 / 没显式选过)= 标准模式。 */ +export function resolveModeSlug(chat?: Pick): string { + return chat?.modeSlug || 'standard'; +}