Merge origin/master into PR #5271

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frank SongandPaperclip committed 2026-07-01 08:07:51 +08:00
commit 4643d3520e
6 files changed
+457 -214

No files matched your search

+4
View File
@@ -35,6 +35,10 @@
### Fixed
- **More complete Simplified Chinese (zhCN) localization.** Previously-unlocalized UI strings now have zhCN translations. Thanks @Loukky. (#5279)
- **A reader who scrolled up no longer gets yanked to the bottom when the SSE stream drops and recovers.** The four SSE-recovery follow-guards now capture explicit follow-intent (sticky-aware: a manually scrolled-up reader counts as unpinned even within 1200px of the bottom) before the terminal-error marker mutates the transcript, so a reconnect re-follows a genuine follower but spares a reader who scrolled up to read history. Thanks @allenliang2022. (#5217)
- **Deep-link composer prefill via `?q=` no longer loses the draft or starts a session early.** Opening the WebUI with a `?q=...` (and related prefill params) pre-fills the composer with that text; the draft now survives a login redirect (params are consumed after auth/profile bootstrap, and the logged-out `?q=` is carried through `next`), and a prefill boot leaves the session uncreated until you actually send — it no longer silently binds a fresh default-workspace session. Thanks @rodboev. (#4969, #4961)
- **The Run dispatcher now works on the default Kanban board.** The default board is stored as `null`, and the dispatcher's board check treated that as "no board," so the Run button silently no-opped on the default board. It now resolves the default board correctly and dispatches. Thanks @rodboev. (#5289, fixes #5231)
+213 -213
View File
@@ -9775,7 +9775,7 @@ const LOCALES = {
cancelling: '正在取消...',
cancel_failed: '取消失败:',
mic_denied: '麦克风访问被拒绝,请检查浏览器权限。',
mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate
mic_insecure_origin: '语音输入需要安全连接。请通过HTTPS 协议或从本地主机打开Hermes,以便使用麦克风。',
mic_no_speech: '没有检测到语音,请再试一次。',
mic_network: '语音识别当前不可用。',
mic_error: '语音输入出错:',
@@ -9889,7 +9889,7 @@ const LOCALES = {
approval_btn_deny: '拒绝',
approval_btn_deny_title: '拒绝 — 不执行此命令',
approval_responding: '处理中…',
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
approval_gateway_unsupported: '批准操作需要更新版本的网关。请升级已连接的Hermes网关以实现此功能。',
approval_gateway_unsupported_label: '不支持批准',
clarify_heading: '需要澄清',
clarify_hint: '请选择一个选项,或在下方输入你自己的回答。',
@@ -9922,19 +9922,19 @@ const LOCALES = {
provider_mismatch_warning: (m,p)=>`\"${m}\" 可能无法在当前配置的提供商 (${p}) 下工作。直接发送,或在终端运行 \`hermes model\` 切换。`,
provider_mismatch_label: '提供商不匹配',
gateway_auth_label: 'Gateway 身份验证失败',
gateway_start: 'Start',
gateway_stop: 'Stop',
gateway_restart: 'Restart',
gateway_start_success: 'Gateway start completed.',
gateway_stop_success: 'Gateway stop completed.',
gateway_restart_success: 'Gateway restart completed.',
gateway_start_failed: 'Gateway start failed',
gateway_stop_failed: 'Gateway stop failed',
gateway_restart_failed: 'Gateway restart failed',
gateway_running: 'Running',
gateway_session: 'session',
gateway_sessions: 'sessions',
gateway_status_load_failed: 'Failed to load gateway status',
gateway_start: '启动',
gateway_stop: '停止',
gateway_restart: '重启',
gateway_start_success: '网关启动完成。',
gateway_stop_success: '网关停止完成。',
gateway_restart_success: '网关重启完成。',
gateway_start_failed: '网关启动失败',
gateway_stop_failed: '网关停止失败',
gateway_restart_failed: '网关重启失败',
gateway_running: '运行中',
gateway_session: '会话',
gateway_sessions: '会话',
gateway_status_load_failed: '加载网关状态失败',
model_not_found_label: '未找到模型',
model_custom_label: '自定义模型 ID',
model_custom_placeholder: '例如 openai/gpt-5.4',
@@ -10017,21 +10017,21 @@ const LOCALES = {
no_active_session: '当前没有活动会话',
cmd_queue: '将消息加入下一轮的队列',
cmd_goal: '设置或查看持久目标',
goal_evaluating_progress: 'Evaluating goal progress…',
goal_working_toward: 'Working toward goal…',
goal_continuing_toast: 'Continuing toward goal…',
goal_status_none: 'No active goal. Set one with /goal <text>.',
goal_status_active: (turns, max_turns, goal) => `⊙ Goal (active, ${turns}/${max_turns} turns): ${goal}`,
goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Goal (paused, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`,
goal_status_done: (turns, max_turns, goal) => `✓ Goal done (${turns}/${max_turns}): ${goal}`,
goal_set: (turns, goal) => `⊙ Goal set (${turns}-turn budget): ${goal}`,
goal_paused: (goal) => `⏸ Goal paused: ${goal}`,
goal_resumed: (goal) => `▶ Goal resumed: ${goal}`,
goal_cleared: 'Goal cleared.',
goal_no_goal: 'No active goal.',
goal_achieved: (reason) => `✓ Goal achieved: ${reason}`,
goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Goal paused — ${turns}/${max_turns} turns used. Use /goal resume to keep going, or /goal clear to stop.`,
goal_continuing: (turns, max_turns, reason) => `↻ Continuing toward goal (${turns}/${max_turns}): ${reason}`,
goal_evaluating_progress: '正在评估目标进展情况…',
goal_working_toward: '正朝着目标努力推进…',
goal_continuing_toast: '继续朝着目标前进…',
goal_status_none: '无当前活动目标。请使用 /goal <文本> 设置一个目标。',
goal_status_active: (turns, max_turns, goal) => `⊙ 目标 (进行中, ${turns}/${max_turns} 回合): ${goal}`,
goal_status_paused: (turns, max_turns, reason, goal) => `⏸ 目标 (已暂停, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`,
goal_status_done: (turns, max_turns, goal) => `✓ 目标完成 (${turns}/${max_turns}): ${goal}`,
goal_set: (turns, goal) => `⊙ 目标设置 (${turns}-回合预算): ${goal}`,
goal_paused: (goal) => `⏸ 目标暂停: ${goal}`,
goal_resumed: (goal) => `▶ 目标恢复: ${goal}`,
goal_cleared: '目标已清除。',
goal_no_goal: '无激活目标。',
goal_achieved: (reason) => `✓ 目标已达成: ${reason}`,
goal_paused_budget_exhausted: (turns, max_turns) => `⏸ 目标已暂停 — ${turns}/${max_turns} 回合已使用。使用 /goal resume 以继续, 或者 /goal clear 以停止。`,
goal_continuing: (turns, max_turns, reason) => `↻ 继续完成目标 (${turns}/${max_turns}): ${reason}`,
cmd_interrupt: '取消当前回合并发送新消息',
cmd_steer: '用纠正信息引导代理(回退为中断)',
cmd_queue_no_msg: '用法:/queue <消息>',
@@ -10044,16 +10044,16 @@ const LOCALES = {
cmd_steer_delivered: 'Steer 已交付 — 代理将在下一个工具结果中看到',
steer_leftover_queued: 'Steer 已加入下轮队列',
busy_steer_fallback: 'Steer 不可用 — 草稿已恢复',
steer_fail_no_cached_agent: 'No agent cached for this session — try refreshing',
steer_fail_agent_lacks_steer: 'This agent version does not support steer',
steer_fail_session_not_found: 'Session not found — it may have expired',
steer_fail_not_running: 'Agent is not currently running',
steer_fail_stream_dead: 'The agent stream has ended',
steer_fail_steer_error: 'Steer delivery failed — the agent may have finished',
steer_fail_network_error: 'Network error — check your connection',
steer_fail_unknown: 'Steer unavailable',
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
steer_fail_no_cached_agent: '此会话无缓存代理 — 请尝试刷新',
steer_fail_agent_lacks_steer: '此代理版本不支持引导功能',
steer_fail_session_not_found: '会话未找到 — 可能已过期',
steer_fail_not_running: '代理当前未运行',
steer_fail_stream_dead: '代理流已结束',
steer_fail_steer_error: '引导交付失败 — 代理可能已结束运行',
steer_fail_network_error: '网络错误 — 请检查您的网络连接',
steer_fail_unknown: '引导功能不可用',
steer_recovery_retry: '重试',
steer_recovery_dismiss: '忽略',
busy_interrupt_confirm: '已中断 — 正在发送新消息',
settings_label_busy_input_mode: '忙碌输入模式',
settings_desc_busy_input_mode: '控制当代理正在运行时你发送消息会发生什么。队列会等待;中断会取消并重新开始;引导会在不中断的情况下注入中间修正(当流不可用时回退到队列)。',
@@ -10134,11 +10134,11 @@ const LOCALES = {
file_open_failed: '无法打开文件',
downloading: (name) => `正在下载 ${name}...`,
double_click_rename: '双击重命名',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
symlink_link_to: '符号链接 → {target}',
external_link_open_confirm: '此符号链接指向工作区外部。是否在本次浏览器会话的只读外部视图中打开它?',
external_link_read_only: '此外部视图为只读。此处创建、重命名、移动、删除、上传、显示及编辑操作均处于禁用状态。',
external_link_grant_expired: '此外部视图已过期。重新打开转义目标符号链接以再次授权。',
session_rename_failed_no_row: '无法开始重命名操作 — 未找到相应行。',
renamed_to: '已重命名为 ',
rename_failed: '重命名失败:',
moved_to: '已移动到 ',
@@ -10199,15 +10199,15 @@ const LOCALES = {
settings_save_btn: '保存设置',
settings_label_model: '默认模型',
settings_desc_model: '用于新对话。现有对话保持各自选定的模型。',
settings_main_advanced_button_aria: 'Advanced options for main model',
settings_main_advanced_title: 'Main model options',
settings_main_advanced_subtitle: 'Advanced config for the default chat model.',
settings_main_advanced_saved: 'Main model options saved',
settings_main_advanced_save_failed: 'Failed to save main model options',
settings_main_advanced_service_tier: 'Service tier',
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
settings_main_advanced_service_tier_default: 'Default / off',
settings_main_advanced_service_tier_priority: 'Priority (fast)',
settings_main_advanced_button_aria: '主模型的高级选项',
settings_main_advanced_title: '主模型选项',
settings_main_advanced_subtitle: '默认聊天模型的高级配置。',
settings_main_advanced_saved: '主模型选项已保存',
settings_main_advanced_save_failed: '主模型选项保存失败',
settings_main_advanced_service_tier: '服务层级',
settings_main_advanced_service_tier_desc: '针对OpenAI系列供应商的可选请求设置。',
settings_main_advanced_service_tier_default: '默认/关闭',
settings_main_advanced_service_tier_priority: '优先(快速)',
settings_label_auxiliary_models: '辅助模型',
settings_desc_auxiliary_models: '视觉分析、上下文压缩、标题生成等辅助任务的路由。"自动"表示使用主聊天模型。',
settings_btn_reset_aux_models: '全部重置为自动',
@@ -10224,53 +10224,53 @@ const LOCALES = {
settings_aux_save_failed: '辅助模型保存失败',
settings_aux_saved: '辅助模型已更新',
settings_aux_no_changes: '没有需要应用的更改',
settings_aux_task_vision: 'Vision',
settings_aux_task_vision_desc: 'image/screenshot analysis',
settings_aux_task_compression: 'Compression',
settings_aux_task_compression_desc: 'context summarization',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'web page summarization',
settings_aux_task_session_search: 'Session search',
settings_aux_task_session_search_desc: 'past-conversation recall',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'smart command approval',
settings_aux_task_vision: '视觉功能',
settings_aux_task_vision_desc: '图像/屏幕截图分析',
settings_aux_task_compression: '压缩',
settings_aux_task_compression_desc: '上下文摘要',
settings_aux_task_web_extract: '网页提取',
settings_aux_task_web_extract_desc: '网页内容摘要',
settings_aux_task_session_search: '会话搜索',
settings_aux_task_session_search_desc: '历史对话召回',
settings_aux_task_approval: '批准',
settings_aux_task_approval_desc: '智能指令批准',
settings_aux_task_mcp: 'MCP',
settings_aux_task_mcp_desc: 'MCP tool reasoning',
settings_aux_task_title_generation: 'Title generation',
settings_aux_task_title_generation_desc: 'session titles',
settings_aux_task_skills_hub: 'Skills hub',
settings_aux_task_skills_hub_desc: 'skills search/install',
settings_aux_task_curator: 'Curator',
settings_aux_task_curator_desc: 'skill-usage review pass',
settings_aux_task_kanban_decomposer: 'Kanban decomposer',
settings_aux_task_kanban_decomposer_desc: 'task decomposition',
settings_aux_task_profile_describer: 'Profile describer',
settings_aux_task_profile_describer_desc: 'profile summaries',
settings_aux_task_triage_specifier: 'Triage specifier',
settings_aux_task_triage_specifier_desc: 'issue/task triage specs',
settings_aux_advanced_button_title: 'Advanced options',
settings_aux_advanced_button_aria: 'Advanced options for {task}',
settings_aux_advanced_title: '{task} options',
settings_aux_advanced_subtitle: 'Advanced config for auxiliary.',
settings_aux_advanced_save: 'Save options',
settings_aux_task_mcp_desc: 'MCP 工具推理',
settings_aux_task_title_generation: '标题生成',
settings_aux_task_title_generation_desc: '会话标题',
settings_aux_task_skills_hub: '技能中心',
settings_aux_task_skills_hub_desc: '技能搜索/安装',
settings_aux_task_curator: '策展人',
settings_aux_task_curator_desc: '技能-用途审查通过',
settings_aux_task_kanban_decomposer: '看板分解器',
settings_aux_task_kanban_decomposer_desc: '任务分解',
settings_aux_task_profile_describer: '个人资料描述器',
settings_aux_task_profile_describer_desc: '个人资料摘要',
settings_aux_task_triage_specifier: '分类指定器',
settings_aux_task_triage_specifier_desc: '问题 / 任务分类规范',
settings_aux_advanced_button_title: '高级选项',
settings_aux_advanced_button_aria: '{task}的高级选项',
settings_aux_advanced_title: '{task} 选项',
settings_aux_advanced_subtitle: '辅助程序的高级配置。',
settings_aux_advanced_save: '保存选项',
settings_aux_advanced_base_url: 'Base URL',
settings_aux_advanced_base_url_desc: 'Optional provider endpoint override.',
settings_aux_advanced_timeout: 'Timeout seconds',
settings_aux_advanced_timeout_desc: 'Request timeout for this auxiliary task. Blank uses Hermes default.',
settings_aux_advanced_download_timeout: 'Download timeout seconds',
settings_aux_advanced_download_timeout_desc: 'Only relevant for tasks that download media/content, e.g. vision. Blank uses default.',
settings_aux_advanced_max_concurrency: 'Max concurrency',
settings_aux_advanced_max_concurrency_desc: 'Optional per-task concurrency limit. Blank uses default.',
settings_aux_advanced_extra_body: 'Extra body JSON',
settings_aux_advanced_extra_body_desc: 'Optional JSON object merged into the model request body.',
settings_aux_advanced_api_key: 'API key override',
settings_aux_advanced_api_key_set_hint: 'API key is set. Leave blank to keep it, or use clear to remove it.',
settings_aux_advanced_api_key_empty_hint: 'Leave blank to use provider/default credentials.',
settings_aux_advanced_api_key_clear: 'Clear existing API key override',
settings_aux_advanced_extra_body_invalid_json: 'Extra body must be valid JSON',
settings_aux_advanced_extra_body_object_required: 'Extra body must be a JSON object',
settings_aux_advanced_saved: 'Auxiliary options saved',
settings_aux_advanced_save_failed: 'Failed to save auxiliary options',
settings_aux_advanced_base_url_desc: '可选的供应商端点覆盖设置。',
settings_aux_advanced_timeout: '超时秒数',
settings_aux_advanced_timeout_desc: '此辅助任务的请求超时时间。留空则使用Hermes默认设置。',
settings_aux_advanced_download_timeout: '下载超时秒数',
settings_aux_advanced_download_timeout_desc: '仅适用于下载媒体/内容的任务,如视觉相关任务。留空则使用默认设置。',
settings_aux_advanced_max_concurrency: '最大并发数',
settings_aux_advanced_max_concurrency_desc: '可选的每个任务并发限制。留空则使用默认值。',
settings_aux_advanced_extra_body: '额外的主体 JSON',
settings_aux_advanced_extra_body_desc: '可选的JSON对象,会合并到模型请求主体中。',
settings_aux_advanced_api_key: 'API密钥覆盖',
settings_aux_advanced_api_key_set_hint: 'API密钥已设置。留空则保留,或者使用 clear 以移除。',
settings_aux_advanced_api_key_empty_hint: '留空以使用供应商/默认凭证。',
settings_aux_advanced_api_key_clear: '清除现有的API密钥覆盖',
settings_aux_advanced_extra_body_invalid_json: '额外主体必须是有效的JSON格式',
settings_aux_advanced_extra_body_object_required: '额外主体必须是一个JSON对象',
settings_aux_advanced_saved: '辅助选项已保存',
settings_aux_advanced_save_failed: '保存辅助选项失败',
settings_label_send_key: '发送快捷键',
settings_label_theme: '主题',
settings_label_skin: '皮肤',
@@ -10288,23 +10288,23 @@ const LOCALES = {
settings_desc_quota_chip: '在编辑器底部显示剩余配额指示器(如 OpenRouter 信用余额)。默认关闭。启用时仅在宽屏(≥1400px)显示,以保持笔记本和标准桌面屏幕上编辑器的整洁。',
settings_label_conversation_outline: '显示会话大纲',
settings_desc_conversation_outline: '显示仅限桌面的跳转列表,用于跳转到当前会话中的用户问题。默认关闭。',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_virtualize_transcript: 'Virtualize long transcripts',
settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.",
settings_label_hide_suggestions: '隐藏新聊天建议',
settings_desc_hide_suggestions: '隐藏新聊天空白界面上的三个默认建议按钮,以避免误触。',
settings_label_virtualize_transcript: '对长篇聊天记录进行虚拟处理',
settings_desc_virtualize_transcript: "对于非常长的聊天记录(超过80条消息),仅渲染可见部分,以实现更流畅的滚动。如果遇到任何渲染或滚动问题,或者希望浏览器的查找(Ctrl + F)匹配整个对话,请关闭此功能 \u2014 这样完整的聊天记录将一次性渲染出来。",
settings_label_token_usage: '显示 token 用量',
settings_label_token_speed: '显示 token 速度 (TPS)',
settings_desc_token_speed: '在流式传输期间和响应完成后,在助手消息标题中显示每秒 token 数。默认关闭。',
settings_label_sidebar_density: '侧边栏密度',
cmd_reasoning: '切换思维可见性(显示/隐藏)、设置工作强度或查看当前状态',
settings_label_external_sessions: '显示外部会话',
settings_label_cron_sessions: 'Show cron sessions',
settings_label_webhook_sessions: 'Show webhook sessions',
settings_label_cron_sessions: '显示定时任务会话',
settings_label_webhook_sessions: '显示Webhook会话',
settings_label_previous_messaging_sessions: '显示以前的消息会话',
settings_label_sync_insights: '同步到 insights',
settings_label_check_updates: '检查更新',
settings_label_ignore_agent_updates: 'Ignore Agent updates',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_ignore_agent_updates: '忽略智能体更新',
settings_label_whats_new_summary: "用AI总结新功能",
settings_label_bot_name: '默认助手名称',
settings_label_password: '访问密码',
settings_label_pinned_limit: '置顶对话数量限制',
@@ -10352,8 +10352,8 @@ const LOCALES = {
kanban_no_matching_tasks: '没有匹配的任务',
kanban_no_data: '无看板数据',
kanban_work_queue_hint: '这是 Hermes Agent 的工作队列。创建或分类任务,分配任务,将其移至”就绪”状态,然后让调度员认领任务。',
kanban_tasks_hidden_by_filters: 'Tasks hidden by filters',
kanban_clear_filters: 'Clear filters',
kanban_tasks_hidden_by_filters: '被筛选器隐藏的任务',
kanban_clear_filters: '清除筛选条件',
kanban_unavailable: '看板不可用',
kanban_read_only: '只读视图',
kanban_empty: '空',
@@ -10411,13 +10411,13 @@ const LOCALES = {
kanban_view_consolidated_saved: 'Kanban 视图:已合并',
kanban_view_update_failed: '无法更新 Kanban 视图:',
kanban_add_comment: '添加评论',
kanban_workspace_kind: 'Workspace kind',
kanban_workspace_path: 'Workspace path',
kanban_workspace_path_placeholder: 'Path to workspace',
kanban_workspace_scratch: 'Scratch',
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_workspace_kind: '工作区类型',
kanban_workspace_path: '工作区路径',
kanban_workspace_path_placeholder: '工作区路径',
kanban_workspace_scratch: '暂存区',
kanban_workspace_worktree: '工作树',
kanban_workspace_dir: '目录',
kanban_workspace_path_required: '非暂存工作区需要设置工作区路径。',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
@@ -10425,10 +10425,10 @@ const LOCALES = {
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
kanban_dependency_self: 'A task cannot depend on itself',
kanban_add_dependency: '添加依赖项',
kanban_remove_dependency: '移除依赖项',
kanban_dependency_placeholder: '要链接的任务ID',
kanban_dependency_self: '任务不能依赖自身',
kanban_only_mine: '仅我的',
kanban_bulk_action: '批量操作',
kanban_nudge_dispatcher: '提醒调度器',
@@ -10462,7 +10462,7 @@ const LOCALES = {
tab_profiles: '配置',
tab_logs: '日志',
tab_settings: '设置',
close_menu: 'Close menu', // TODO: translate
close_menu: '关闭菜单',
logs_title: '日志',
logs_file: '文件',
@@ -10553,9 +10553,9 @@ const LOCALES = {
providers_oauth_config_yaml_hint: '通过 config.yaml 配置的令牌。如需更新,请编辑 config.yaml 中的 providers 部分或运行 hermes auth。',
providers_oauth_not_configured_hint: '未认证。在终端中运行 hermes auth 以配置此提供商。',
providers_save: '保存',
providers_refresh_models: 'Refresh models',
providers_refreshing: 'Refreshing…',
providers_models_refreshed: 'Models refreshed',
providers_refresh_models: '刷新模型',
providers_refreshing: '正在刷新…',
providers_models_refreshed: '模型已刷新',
providers_remove: '移除',
providers_saving: '保存中…',
providers_removing: '移除中…',
@@ -10617,12 +10617,12 @@ const LOCALES = {
settings_label_notifications: '浏览器通知',
settings_desc_sound: '助手完成回复时播放提示音。',
settings_desc_notifications: '当标签页在后台时,回复完成后显示系统通知。',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
notifications_enable_btn: '启用通知',
notifications_test_btn: '发送测试',
notifications_permission_status: (status) => `权限: ${status}`,
notifications_enabled_toast: '通知已启用。',
notifications_denied: '此浏览器中通知功能被阻止。请在浏览器设置中启用。',
notifications_unsupported: '此浏览器不支持通知功能。',
settings_desc_token_usage: '在助手每次回复下方显示输入/输出 token 数量。也可以用 /usage 切换。',
settings_sidebar_density_compact: '紧凑',
settings_sidebar_density_detailed: '详细',
@@ -10634,13 +10634,13 @@ const LOCALES = {
settings_auto_title_refresh_20: '每 20 轮对话',
settings_desc_auto_title_refresh: '基于最新对话自动重新生成会话标题,使其随对话发展保持相关。需要配置 LLM 标题生成模型。',
settings_desc_external_sessions: '在会话列表中显示来自 CLI、Telegram、Discord、Slack 等渠道的对话。点击可导入并继续对话。',
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.',
settings_desc_cron_sessions: '将定时任务输出以对话形式显示在侧边栏。仅在启用非 WebUI 会话时此功能才会生效。默认关闭;高频任务可能会使侧边栏信息泛滥。',
settings_desc_webhook_sessions: '将Webhook运行情况以对话形式显示在侧边栏。仅在启用非 WebUI 会话时此功能才会生效。默认关闭;大量的路由信息可能会使侧边栏信息泛滥。',
settings_desc_previous_messaging_sessions: '显示被 reset 或 compression 替换的较旧的 Discord、Telegram、Slack 和 Weixin 会话。',
settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。',
settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。',
settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.',
settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.",
settings_desc_ignore_agent_updates: '保持开启Web用户界面更新检查,但隐藏智能体更新通知并跳过智能体更新获取。',
settings_desc_whats_new_summary: "将“新功能”动作从首先打开原始差异改为生成简短且易读的摘要。在生成摘要后,仍可进行常规的差异比较。",
settings_desc_bot_name: '仅用于默认个人资料。其他个人资料会使用各自的名称。',
settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。',
// onboarding
@@ -10752,8 +10752,8 @@ const LOCALES = {
// panel/runtime i18n
error_prefix: '错误:',
default: 'default',
search: 'Search',
default: '默认',
search: '搜索',
not_available: '无',
never: '从未',
add: '添加',
@@ -10802,7 +10802,7 @@ const LOCALES = {
cron_script_path_hint: '除非是绝对路径,否则在 ~/.hermes/scripts/ 下解析。请在服务器上编辑脚本文件以更改行为。',
cron_script_badge_title: '脚本任务(无代理)',
cron_workdir_label: '工作目录',
cron_view_full_output: 'View full output',
cron_view_full_output: '查看完整输出',
cron_all_runs: '全部运行记录',
cron_hide_runs: '隐藏记录',
cron_no_runs_yet: '(暂无运行记录)',
@@ -10816,7 +10816,7 @@ const LOCALES = {
cron_job_updated: '任务已更新',
cron_model_label: '模型覆盖',
cron_model_hint: '覆盖此任务的默认模型。',
cron_model_no_agent_hint: 'No-agent jobs run the configured script directly; model is unused.',
cron_model_no_agent_hint: '无智能体任务直接运行已配置的脚本,模型未使用。',
cron_model_use_default: '默认(使用配置/系统默认)',
cron_delete_confirm_title: '删除定时任务',
cron_delete_confirm_message: '此操作无法撤销。',
@@ -10853,8 +10853,8 @@ const LOCALES = {
external_notes_recent_ai_reason: '自动召回',
external_notes_search_placeholder: '搜索笔记…',
external_notes_search_empty: '搜索已配置的笔记来源后,可在这里预览笔记。',
source_active: 'active',
source_configured: 'configured',
source_active: '激活',
source_configured: '已配置',
no_notes_yet: '暂无备注。',
no_profile_yet: '暂无用户画像。',
agent_soul: '智能体灵魂',
@@ -11085,56 +11085,56 @@ const LOCALES = {
settings_desc_session_endless_scroll: '启用后,向上滚动时会自动加载更早的消息。禁用时请使用加载更早消息按钮。',
settings_label_auto_scroll_follow: '自动跟随新内容',
settings_desc_auto_scroll_follow: '启用后,流式输出时页面会自动滚动到底部。禁用后,你可以自由控制滚动位置,阅读答案不会被拽到底部。',
settings_label_render_user_markdown: 'Render markdown in user messages',
settings_label_show_titlebar_profile: 'Show profile switcher in titlebar', // TODO: translate
settings_desc_show_titlebar_profile: 'When enabled, a profile switcher button appears in the top-left app titlebar so you can change profiles from any tab. Off by default; the composer footer always has a profile switcher regardless of this setting.', // TODO: translate
settings_desc_render_user_markdown: 'When enabled, bold, italic, links, and other markdown in your own messages are rendered. Off by default; fenced code blocks and math always render regardless of this setting.',
settings_label_large_text_paste_as_attachment: 'Attach large pasted text as file',
settings_desc_large_text_paste_as_attachment: 'When enabled, long pasted text becomes a .md attachment instead of filling the composer.',
settings_label_render_user_markdown: '在用户消息中渲染Markdown格式文本',
settings_label_large_text_paste_as_attachment: '将粘贴的大段文本作为文件附加。',
settings_desc_large_text_paste_as_attachment: '启用此功能后,粘贴的长文本将作为.md附件,而非直接填充到编辑器中。 ',
settings_label_show_titlebar_profile: '在标题栏中显示配置文件切换器',
settings_desc_show_titlebar_profile: '启用后,应用程序左上角的标题栏会出现一个配置文件切换器按钮,这样您就可以在任意标签页中切换配置文件。默认情况下此功能关闭;无论此设置如何,撰写框底部始终有一个配置文件切换器。 ',
settings_desc_render_user_markdown: '启用后,您自己消息中的加粗、斜体、链接及其他Markdown格式会被渲染。默认关闭;无论此设置如何,代码块和数学公式始终会被渲染。 ',
settings_label_project_quick_create: 'Show per-project new-conversation buttons',
settings_desc_project_quick_create: 'When enabled, each project chip in the sidebar gets a + button to start a conversation in that project.',
settings_label_structured_code: 'JSON/YAML code blocks',
settings_option_structured_code_auto: 'Auto: Tree for long blocks',
settings_option_structured_code_on: 'Tree by default',
settings_option_structured_code_off: 'Raw by default',
settings_label_structured_code_auto_lines: 'Auto threshold lines',
settings_desc_structured_code: 'When Auto is selected, JSON/YAML blocks with at least this many lines open in Tree view. Tree always opens the structured viewer; Raw keeps the syntax-highlighted text. You can still switch Tree/Raw on each block.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
settings_label_chat_activity_display_mode: 'Activity display',
settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
first_token_time: 'Time to first token',
settings_label_structured_code: 'JSON/YAML代码块',
settings_option_structured_code_auto: '自动:长代码块以树形展示',
settings_option_structured_code_on: '默认以树形',
settings_option_structured_code_off: '默认以原始格式',
settings_label_structured_code_auto_lines: '自动阈值行数',
settings_desc_structured_code: '选择“自动”时,至少有此设定行数的JSON/YAML代码块将以树形视图打开。树形视图始终打开结构化查看器;原始视图则保留带语法高亮的文本。您仍可针对每个代码块在树形/原始视图间切换。 ',
settings_label_worklog_details_expanded_default: '自动打开工作日志详情',
settings_desc_worklog_details_expanded_default: '当启用此功能时,新的工作日志详情会自动展开,因此无需额外点击即可查看工具、思路和进度卡片。当禁用此功能时,工作日志详情默认保持折叠状态;不过每次仍可手动选择折叠或展开。',
settings_label_chat_activity_display_mode: '活动显示',
settings_desc_chat_activity_display_mode: '紧凑的工作日志将支持活动归纳为简洁摘要。透明流模式则将每个思考/工具使用事件按时间顺序单独列为可展开的行。',
first_token_time: '首个令牌生成时间',
processed_elapsed: _i18nProcessedElapsedZh,
worklog_thinking: '正在思考',
tool_target_skill_suffix: '技能',
tool_action_label: _i18nToolActionLabelZh,
tool_worklog_summary: _i18nToolWorklogSummaryZh,
tool_summary_join: _i18nToolSummaryJoinCjk,
done: 'Done',
settings_option_compact_worklog: 'Compact Worklog',
settings_option_transparent_stream: 'Transparent Stream',
done: '完成',
settings_option_compact_worklog: '紧凑工作日志',
settings_option_transparent_stream: '透明流模式',
settings_label_tab_visibility: '侧边栏标签',
settings_desc_tab_visibility: '选择在侧边栏和导航栏中显示哪些标签。聊天和设置始终可见。',
settings_label_composer_controls: 'Chat footer controls',
settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.',
settings_label_composer_situational_controls: 'Situational footer controls',
settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.',
composer_control_attach: 'Attach',
composer_control_saved_prompts: 'Saved prompts',
settings_label_composer_controls: '聊天底部控件',
settings_desc_composer_controls: '切换显示在聊天底部的控件。不支持重新排序。',
settings_label_composer_situational_controls: '情境化底部控件',
settings_desc_composer_situational_controls: '这些控件并非始终显示,具体取决于视口、模式或运行时状态。如有需要,你仍可将其关闭。',
composer_control_attach: '附加',
composer_control_saved_prompts: '已保存的提示词',
composer_control_mic: 'Mic',
composer_control_profile: 'Profile',
composer_control_workspace: 'Workspace',
composer_control_model: 'Model',
composer_control_reasoning: 'Reasoning',
composer_control_context: 'Context',
composer_control_voice_mode: 'Voice mode',
composer_control_profile: '配置',
composer_control_workspace: '工作区',
composer_control_model: '模型',
composer_control_reasoning: '推理',
composer_control_context: '上下文',
composer_control_voice_mode: '语音模式',
composer_control_yolo: 'YOLO',
composer_control_bg_badge: 'Background badge',
composer_control_mobile_config: 'Mobile config',
composer_control_quota_chip: 'Quota chip',
composer_control_toolsets: 'Toolsets',
composer_control_status: 'Status',
composer_control_bg_badge: '背景徽章',
composer_control_mobile_config: '移动设备配置',
composer_control_quota_chip: '配额标识',
composer_control_toolsets: '工具集',
composer_control_status: '状态',
open_in_browser: '在浏览器中打开',
settings_section_system_title: '系统',
settings_tab_appearance: '外观',
@@ -11142,24 +11142,24 @@ const LOCALES = {
settings_tab_preferences: '偏好',
settings_tab_plugins: '插件',
settings_tab_extensions: '扩展',
settings_extensions_gallery_tab: 'Gallery',
settings_extensions_installed_tab: 'Installed',
settings_extensions_diagnostics_tab: 'Diagnostics',
settings_extensions_gallery_loading: 'Loading gallery…',
settings_extensions_gallery_empty: 'No extensions found in the registry.',
settings_extensions_gallery_error: 'Failed to load gallery.',
ext_gallery_install: 'Install',
ext_gallery_installing: 'Installing…',
ext_gallery_installed: 'Installed',
ext_gallery_uninstall: 'Uninstall',
ext_gallery_uninstalling: 'Uninstalling…',
ext_gallery_restart_required: 'Restart required',
ext_gallery_permissions_show: 'Permissions',
ext_gallery_permissions_hide: 'Hide permissions',
settings_extensions_gallery_tab: '图库',
settings_extensions_installed_tab: '已安装',
settings_extensions_diagnostics_tab: '诊断',
settings_extensions_gallery_loading: '加载图库中…',
settings_extensions_gallery_empty: '注册表中未找到扩展项。',
settings_extensions_gallery_error: '加载图库失败。',
ext_gallery_install: '安装',
ext_gallery_installing: '安装中…',
ext_gallery_installed: '已安装',
ext_gallery_uninstall: '卸载',
ext_gallery_uninstalling: '卸载中…',
ext_gallery_restart_required: '需要重启',
ext_gallery_permissions_show: '权限',
ext_gallery_permissions_hide: '隐藏权限',
ext_gallery_permissions_empty: '未声明额外权限。',
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_version: '版本',
ext_gallery_author: '作者',
ext_gallery_capabilities: '能力',
ext_gallery_next_step: '下一步',
ext_gallery_after_install: '安装后',
ext_gallery_local_component_required: '此扩展包含本地组件。安装后请启动所需的本地应用或 sidecar。',
@@ -11170,17 +11170,17 @@ const LOCALES = {
ext_gallery_open_setup_guide: '打开设置指南',
ext_gallery_install_restart_required: '扩展已安装。需要重启后生效。',
ext_gallery_install_followup: '扩展已安装。请查看卡片中的后续步骤。',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
ext_gallery_install_ok: '扩展安装成功。',
ext_gallery_uninstall_ok: '扩展已卸载。',
settings_tab_system: '系统',
settings_tab_help: 'Help',
settings_help_meta: 'Resources and support for Hermes WebUI.',
settings_help_docs_label: 'Documentation',
settings_help_docs_desc: 'Guides, configuration reference, and the full Hermes WebUI README.',
settings_help_docs_link: 'Open Documentation',
settings_help_issue_label: 'Having an issue?',
settings_help_issue_desc: 'Search existing reports or open a new one on GitHub.',
settings_help_issue_link: 'Open GitHub Issues',
settings_tab_help: '帮助',
settings_help_meta: 'Hermes WebUI的资源与支持 ',
settings_help_docs_label: '文档',
settings_help_docs_desc: '指南、配置参考以及完整的Hermes WebUI自述文件。',
settings_help_docs_link: '打开文档',
settings_help_issue_label: '遇到问题了?',
settings_help_issue_desc: '在 GitHub 上搜索现有报告或提交新报告。',
settings_help_issue_link: '提交 GitHub 问题',
status_updated: '已更新',
status_ephemeral: '临时快照 — 不会保存到对话记录。',
status_no_tokens: '无令牌数据',
@@ -11224,7 +11224,7 @@ const LOCALES = {
pdf_no_pages: '无法渲染 PDF 预览',
pdf_error: 'PDF 加载失败',
pdf_download: '下载 PDF',
pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document',
pdf_truncated: '显示前 {0} 页,共 {1} 页 — 下载查看完整文档',
html_loading: '正在加载 HTML 预览…',
html_too_large: 'HTML 文件过大,无法内联预览',
html_error: 'HTML 预览加载失败',
@@ -11294,10 +11294,10 @@ const LOCALES = {
insights_model_cache: '缓存',
insights_cache_hit: '提示缓存命中率',
insights_model_share: '占比',
insights_model_health_title: 'Model health comparison',
insights_model_health_provider: 'Provider',
insights_model_health_replacement: 'Replacement guide',
insights_model_health_cost_per_m: 'Cost / 1M',
insights_model_health_title: '模型健康状况比较',
insights_model_health_provider: '供应商',
insights_model_health_replacement: '替换指南',
insights_model_health_cost_per_m: '花费 / 1M',
insights_no_usage_data: '暂无使用数据',
insights_footer: '显示最近 {days} 天的数据',
insights_skill_usage_title: '技能使用统计',
@@ -11341,10 +11341,10 @@ const LOCALES = {
outline_title: '大纲',
outline_empty: '暂无问题。',
outline_loading: '加载中…',
wiki_browse: 'Browse wiki',
wiki_search_placeholder: 'Search pages...',
wiki_no_pages: 'No wiki pages found',
wiki_not_configured: 'Wiki not configured',
wiki_browse: '浏览维基',
wiki_search_placeholder: '搜索页面...',
wiki_no_pages: '未找到维基页面',
wiki_not_configured: '维基未配置',
},
// Traditional Chinese (zh-Hant)
+59
View File
@@ -5657,6 +5657,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// The GET path guarantees it via the URL; the embedded path via the stream→session
// binding — but reject a mismatched id so a stray payload can't overwrite the view.
if(sessionPayload.session_id&&sessionPayload.session_id!==activeSid) return false;
// Capture follow-intent BEFORE replacing S.messages: a reader who was
// following the live stream when it got cancelled/reconnected must land at
// the bottom (where the cancellation notice renders), not be stranded at a
// stale mid-stream scrollTop by preserveScroll's restore path. Same
// jump-on-recovery class as the Connection-interrupted path below.
const _wasFollowingAtCancel=((typeof _isMessagePaneNearBottom==='function')
? _isMessagePaneNearBottom(1200)
: true)
&& !((typeof _isMessageReaderUnpinned==='function')
? _isMessageReaderUnpinned()
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
S.session=sessionPayload;
const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
@@ -5665,6 +5676,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
clearLiveToolCards();if(!assistantText)removeThinking();
_markSessionViewed(activeSid, sessionPayload.message_count ?? S.messages.length);
renderMessages({preserveScroll:true});
if(_wasFollowingAtCancel && typeof scrollToBottom==='function') scrollToBottom();
return true;
};
// Prefer the canonical session snapshot embedded in the terminal cancel event.
@@ -5683,11 +5695,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}catch(_){
// Fallback to local cancel message if API fails
if(S.session&&S.session.session_id===activeSid){
const _wasFollowingAtCancelFb=((typeof _isMessagePaneNearBottom==='function')
? _isMessagePaneNearBottom(1200)
: true)
&& !((typeof _isMessageReaderUnpinned==='function')
? _isMessageReaderUnpinned()
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
clearLiveToolCards();if(!assistantText)removeThinking();
const cancelAgentName=(assistantDisplayName()+'').trim()||'Hermes';
S.messages.push({role:'assistant',content:`**Task cancelled:** Task cancelled.\n\n*The run was cancelled by the user before ${cancelAgentName} finished. No provider failure occurred.*`,provider_details:'Task cancelled.',provider_details_label:'Cancellation details',_error:true});
_attachProjectedAnchorSceneToLastAssistant(S.messages);
renderMessages({preserveScroll:true});
if(_wasFollowingAtCancelFb && typeof scrollToBottom==='function') scrollToBottom();
_markSessionViewed(activeSid, S.messages.length);
}
}
@@ -5863,6 +5882,29 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_clearClarifyForOwner('terminal');
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
// Capture the reader's follow-intent BEFORE mutating S.messages. If the SSE
// dropped while they were following the live stream (pinned / near the
// bottom), the disconnect-recovery render must land them at the bottom where
// the "Connection interrupted" notice appears — NOT restore a stale
// mid-stream scrollTop. preserveScroll's restore path keys on the pre-render
// snapshot's bottom-distance, which during a live stream can read large
// (content was still growing under a followed viewport), so it would yank a
// following reader up to a historical position on a process restart / SSE
// drop. This is the "restart/disconnect jump-back" report: the jump is the
// recovery render restoring an old position into a DOM whose height changed.
// Follow-intent must be STICKY-aware: a reader who manually scrolled up
// (sets _messageUserUnpinned, ui.js scroll listener) but stayed within
// 1200px of the bottom would read _isMessagePaneNearBottom(1200)===true,
// so a proximity-only check would re-follow them on recovery and clobber
// their position (maintainer-reproduced bounce). Require near-bottom AND
// not-unpinned. scrollToBottom() clears _messageUserUnpinned, so a genuine
// follower stays pinned; only a manually-unpinned reader is spared.
const _wasFollowingAtDisconnect=((typeof _isMessagePaneNearBottom==='function')
? _isMessagePaneNearBottom(1200)
: true)
&& !((typeof _isMessageReaderUnpinned==='function')
? _isMessageReaderUnpinned()
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
_flushReasoningToAnchor();
_applyToAnchor('error',{
status:'connection_lost',
@@ -5873,6 +5915,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_ensureSingleTerminalStreamErrorMarker(S.messages);
_attachProjectedAnchorSceneToLastAssistant(S.messages);
renderMessages({preserveScroll:true});
// If they were following the stream, force the viewport to the bottom after
// the recovery render so they see the interruption notice in place instead
// of being thrown back into the transcript. Readers who had scrolled up to
// read history are left where they were (the near-bottom guard above is
// false for them).
if(_wasFollowingAtDisconnect && typeof scrollToBottom==='function') scrollToBottom();
_markSessionViewed(activeSid, S.messages.length);
}else{
if(typeof trackBackgroundError==='function'){
@@ -5897,12 +5945,23 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_clearApprovalForOwner();
_clearClarifyForOwner('terminal');
if(S.session&&S.session.session_id===activeSid){
// Follow-intent BEFORE removing live placeholders: a reader following
// the (now-dead) stream should stay pinned to the bottom as the
// thinking/tool placeholders are cleared, not be stranded mid-transcript
// by preserveScroll restoring a stale scrollTop after the height shrinks.
const _wasFollowingAtReconnectDead=((typeof _isMessagePaneNearBottom==='function')
? _isMessagePaneNearBottom(1200)
: true)
&& !((typeof _isMessageReaderUnpinned==='function')
? _isMessageReaderUnpinned()
: (typeof _messageUserUnpinned!=='undefined' && _messageUserUnpinned));
S.activeStreamId=null;
clearLiveToolCards();
removeThinking();
if(_isActiveSession()) _queueDrainSid=activeSid;
_setActivePaneIdleIfOwner();
renderMessages({preserveScroll:true});
if(_wasFollowingAtReconnectDead && typeof scrollToBottom==='function') scrollToBottom();
renderSessionList();
}
_scheduleAnchorRegistryCleanup(120000);
+13
View File
@@ -4700,6 +4700,19 @@ if(typeof window!=='undefined'){
if(nearBottom){
_nearBottomCount=_nearBottomCount+1;
if(_nearBottomCount>=2){_scrollPinned=true;_nearBottomCount=0;}
}else if(!movedUp && _autoScrollFollow && _scrollPinned){
// Content-grew-beneath-a-pinned-viewport case (NOT a user scroll-away).
// During streaming on a tall transcript (esp. mobile, where chunks land
// fast), new content increases scrollHeight under a stationary viewport,
// so bottomDistance crosses the nearBottom threshold even though the
// reader never scrolled (top did NOT move up, _messageUserUnpinned is
// false). Previously this fell through to `_scrollPinned=false`, killing
// auto-follow mid-stream: the follow writer and this listener then fought
// frame-by-frame, the viewport stalled while content kept growing, and it
// was progressively stranded mid-transcript (the "jump back" report).
// Keep the pin and re-snap to the true bottom instead of unpinning.
_nearBottomCount=0;
if(typeof _setMessageScrollToBottom==='function') _setMessageScrollToBottom();
}else{
_nearBottomCount=0;
_scrollPinned=false;
+5 -1
View File
@@ -213,7 +213,11 @@ process.stdout.write(JSON.stringify({{
def test_reattach_path_uses_replay_when_status_reports_journal():
reattach_pos = MESSAGES_SRC.index("let replayOnly=false;")
block = MESSAGES_SRC[reattach_pos : reattach_pos + 1200]
# Window widened to 2200: the SSE-recovery follow-restore fix (the
# _wasFollowingAtReconnectDead guard + its sticky-unpin check) inserted lines
# into the reconnect-dead cleanup block between this anchor and the
# replay-params assertion below, pushing the target string past the old slice.
block = MESSAGES_SRC[reattach_pos : reattach_pos + 2200]
assert "st.replay_available" in block
assert "replayOnly=true" in block
+163
View File
@@ -0,0 +1,163 @@
"""Regression locks: SSE-recovery / streaming scroll-stranding fixes.
Two distinct "jump back" classes, both verified live this session:
1. Content-grew-beneath-a-pinned-viewport (static/ui.js scroll listener):
while streaming on a tall transcript, new content increases scrollHeight under
a stationary viewport. The reader never scrolled (top did not move up,
_messageUserUnpinned is false), but bottomDistance crosses the nearBottom
threshold, so the old code fell through to `_scrollPinned=false`, killing
auto-follow mid-stream. The follow writer and the scroll listener then fought
frame-by-frame; the viewport stalled while content kept growing and was
progressively stranded mid-transcript. Fix: in the `!_messageUserUnpinned`
branch, when the viewport did NOT move up and auto-follow is on, keep the pin
and re-snap to the true bottom instead of unpinning.
2. SSE-recovery follow-restore (static/messages.js): _handleStreamError (SSE
drop), the Task-cancelled apply/fallback paths, and the reconnect-stream-dead
cleanup all push/replace S.messages then renderMessages({preserveScroll:true}).
preserveScroll's restore path keys on the pre-render snapshot's bottom-distance,
which during a live stream can read large (content grew under a followed
viewport), so it yanked a following reader up to a stale historical position on
a process restart / SSE drop / cancel. Fix: capture follow-intent
(_isMessagePaneNearBottom) BEFORE mutating S.messages, and after the recovery
render, scrollToBottom() if the reader was following — so they see the
interruption/cancellation notice in place instead of being thrown back into the
transcript. Readers who had scrolled up to read history are left where they were.
These are structural source-locks (the behavioral A/B was verified live via
Playwright: OLD stranded the reader 470-580px from bottom, FIX landed at 1px).
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
def _compact(s: str) -> str:
return "".join(s.split())
def test_content_grew_keeps_pin_and_resnaps_not_unpin():
# The scroll listener's !_messageUserUnpinned branch must, when the viewport
# did NOT move up and auto-follow is on, keep the pin and re-snap to bottom
# rather than unpinning. Assert the new guard + re-snap call are present.
c = _compact(UI_JS)
assert _compact("}else if(!movedUp && _autoScrollFollow && _scrollPinned){") in c, (
"content-grew-beneath-pinned guard missing from the scroll listener"
)
# The guard body must re-snap to bottom, not set _scrollPinned=false.
idx = c.find(_compact("else if(!movedUp && _autoScrollFollow && _scrollPinned){"))
assert idx != -1
# The re-snap call must be the FIRST scroll action after the guard opens,
# before the chain reaches any `else{ _scrollPinned=false }` fallthrough.
after = c[idx:]
resnap = after.find(_compact("_setMessageScrollToBottom()"))
fallthrough = after.find(_compact("}else{_nearBottomCount=0;_scrollPinned=false;}"))
assert resnap != -1, "content-grew guard must re-snap via _setMessageScrollToBottom()"
assert fallthrough != -1, "the original unpin fallthrough should still exist for the real scroll-away case"
assert resnap < fallthrough, (
"the re-snap must be inside the content-grew guard body, BEFORE the unpin fallthrough"
)
def test_sse_recovery_paths_capture_follow_intent_and_refollow():
# All four SSE-recovery mutation points must capture follow-intent before
# mutating S.messages and re-follow after the recovery render.
c = _compact(MESSAGES_JS)
for guard in (
"_wasFollowingAtDisconnect", # _handleStreamError (SSE drop)
"_wasFollowingAtCancel", # Task cancelled (embedded payload)
"_wasFollowingAtCancelFb", # Task cancelled (fallback)
"_wasFollowingAtReconnectDead", # reconnect-stream-dead cleanup
):
assert guard in MESSAGES_JS, f"SSE-recovery follow-intent guard '{guard}' missing"
# each guard is computed via _isMessagePaneNearBottom AND a sticky-unpin
# check, then consumed by a scrollToBottom() re-follow.
assert _compact("_isMessagePaneNearBottom(1200)") in c, (
"follow-intent must be computed via _isMessagePaneNearBottom(1200)"
)
# STICKY-FOLLOW INVARIANT (maintainer must-fix): proximity alone is NOT
# enough — a reader who manually scrolled up but stayed within 1200px sets
# _messageUserUnpinned and must NOT be re-followed on recovery. Each guard
# must AND in the sticky-unpin state via _isMessageReaderUnpinned (with a
# _messageUserUnpinned fallback). Assert the guard is gated on NOT-unpinned.
assert _compact("_isMessageReaderUnpinned") in c, (
"follow-intent must consult the sticky _isMessageReaderUnpinned state"
)
assert _compact("_messageUserUnpinned") in c, (
"follow-intent must fall back to _messageUserUnpinned when the helper is absent"
)
# Each guard must drive a scrollToBottom re-follow.
for guard in ("_wasFollowingAtDisconnect", "_wasFollowingAtCancel",
"_wasFollowingAtCancelFb", "_wasFollowingAtReconnectDead"):
assert _compact(f"if({guard} && typeof scrollToBottom==='function') scrollToBottom()") in c, (
f"guard '{guard}' must re-follow via scrollToBottom() when the reader was following"
)
def test_follow_intent_captured_before_mutation():
# Ordering invariant: inside _handleStreamError, the _wasFollowingAtDisconnect
# capture must appear BEFORE the terminal-error marker is inserted into
# S.messages (capturing after the mutation would read a post-insert
# bottom-distance and defeat the fix). Scope the search to the
# _handleStreamError function body — messages.js has more than one
# "Connection interrupted" push and more than one _handleStreamError
# reference, so a global str.find() would compare across unrelated paths.
src = MESSAGES_JS
fn = src.find("function _handleStreamError")
assert fn != -1, "could not locate _handleStreamError"
body = src[fn:fn + 8000]
cap = body.find("_wasFollowingAtDisconnect=")
mutation = body.find("_ensureSingleTerminalStreamErrorMarker(S.messages)")
assert cap != -1, "follow-intent capture not found in _handleStreamError"
assert mutation != -1, "terminal-error marker insertion not found in _handleStreamError"
assert cap < mutation, (
"follow-intent must be captured BEFORE the terminal-error marker is "
"inserted into S.messages"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
def test_sticky_follow_invariant_unpinned_within_1200_is_not_refollowed():
"""Behavioral: the sticky-aware guard re-follows a genuine follower but spares
a reader who manually scrolled up (unpinned) even while still within 1200px.
Extracts the real _wasFollowingAtDisconnect expression from messages.js and
evaluates it in Node under the four (nearBottom x unpinned) states, stubbing
_isMessagePaneNearBottom / _isMessageReaderUnpinned to the scenario values.
"""
src = MESSAGES_JS
start = src.index("const _wasFollowingAtDisconnect=")
# capture through the terminating semicolon of the const declaration
end = src.index(";", src.index("_messageUserUnpinned", start))
expr = src[start:end + 1]
def run(near_bottom: bool, unpinned: bool) -> bool:
harness = textwrap.dedent(f"""
let _messageUserUnpinned = {str(unpinned).lower()};
function _isMessagePaneNearBottom(px){{ return {str(near_bottom).lower()}; }}
function _isMessageReaderUnpinned(){{ return {str(unpinned).lower()}; }}
{expr}
console.log(JSON.stringify(_wasFollowingAtDisconnect));
""")
res = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=30)
assert res.returncode == 0, res.stderr
return json.loads(res.stdout.strip())
# Genuine follower (pinned, at/near bottom) → re-follow.
assert run(near_bottom=True, unpinned=False) is True
# MAINTAINER MUST-FIX: manually-unpinned reader STILL within 1200px → NOT re-followed.
assert run(near_bottom=True, unpinned=True) is False, (
"a reader who scrolled up (unpinned) within 1200px must NOT be re-followed on recovery"
)
# Scrolled far away (not near bottom), pinned flag irrelevant → not re-followed.
assert run(near_bottom=False, unpinned=False) is False
assert run(near_bottom=False, unpinned=True) is False