diff --git a/design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md b/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md similarity index 87% rename from design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md rename to design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md index 2074a35b..4915cea7 100644 --- a/design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md +++ b/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md @@ -1,8 +1,8 @@ -# 7-7 [process] 页面 AI Mini Hermes 控制面设计与执行 checklist v1 +# 7-7 [done] 页面 AI Mini Hermes 控制面设计与执行 checklist v1 > 更新时间:2026-05-14 > -> 当前状态:`PROCESS`。当前实现已经可以通过 Hermes 返回回复,并已完成 `7-4` 的 session/run/tool/writeback/audit 主链;本稿只承接下一阶段体验与 agent / skill / 设置面的收口。当前已补上 `agent/profile` 选择、`SOUL.md` / `USER.md` / `MEMORY.md` 轻编辑、skill 启停和对应 smoke,但 stop/retry、完整 settings 跳转、tool audit 完整面还未收口。 +> 当前状态:`DONE`。当前实现已经可以通过 Hermes 返回回复,并已完成 `7-4` 的 session/run/tool/writeback/audit 主链;本稿承接的 Mini Hermes 控制面已完成:`agent/profile` 选择、`SOUL.md` / `USER.md` / `MEMORY.md` 轻编辑、skill 启停、Hermes settings 深链、run stop、context scope、mnote tool 可用性和最近 tool call / trace / audit 摘要均已有代码与 smoke 验证。retry、session rename/delete、profile create/rename、skill 上传继续作为 P2,不在本稿 done gate 内。 > > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` @@ -329,21 +329,21 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi ### I. 自动化验收 -- [ ] I1. 新增或扩展 browser smoke:session 状态条。 +- [x] I1. 新增或扩展 browser smoke:session 状态条。 - 验收标准:断言 session title/model/run status 可见。 -- [ ] I2. 新增或扩展 browser smoke:agent/profile 选择。 +- [x] I2. 新增或扩展 browser smoke:agent/profile 选择。 - 验收标准:断言 profile 列表来自 Hermes;切换 profile 后新 run/session 使用目标 profile。 -- [ ] I3. 新增或扩展 browser smoke:agent 人格文件编辑。 +- [x] I3. 新增或扩展 browser smoke:agent 人格文件编辑。 - 验收标准:保存 `SOUL.md` 或 `USER.md` 后通过 Hermes API 读回一致;运行中保存行为符合禁用或提示规则。 -- [ ] I4. 新增或扩展 browser smoke:skill 启停。 +- [x] I4. 新增或扩展 browser smoke:skill 启停。 - 验收标准:toggle 后刷新仍保持 Hermes 返回状态;失败时 UI 回滚。 -- [ ] I5. 新增或扩展 browser smoke:run stop/retry。 +- [x] I5. 新增或扩展 browser smoke:run stop/retry。 - 验收标准:至少覆盖 stop;retry 若未实现则断言按钮不存在或禁用。 -- [ ] I6. 新增或扩展 browser smoke:context scope。 +- [x] I6. 新增或扩展 browser smoke:context scope。 - 验收标准:选区/当前页上下文能进入 run payload 或 tool audit。 -- [ ] I7. 新增或扩展 browser smoke:tool 可用性与 audit。 +- [x] I7. 新增或扩展 browser smoke:tool 可用性与 audit。 - 验收标准:能看到 mnote tool 列表、一次 tool call、对应 audit id。 -- [ ] I8. 移动端 smoke。 +- [x] I8. 移动端 smoke。 - 验收标准:状态条、tool 折叠、输入区在窄屏不重叠。 --- @@ -352,29 +352,47 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi 本稿移入 `done/` 前必须同时满足: -- [ ] 页面 AI 抽屉具备 Mini Hermes 状态条,用户能看见当前 session、model/agent profile、run 状态。 -- [ ] 页面 AI 抽屉能选择 agent/profile;切换 agent 不篡改已有 Hermes session profile。 -- [ ] 页面 AI 抽屉能轻编辑当前 agent 的 `SOUL.md`、`USER.md`、`MEMORY.md`,保存真相在 Hermes。 -- [ ] 页面 AI 抽屉能搜索、查看并启停 Hermes skill,刷新后状态仍以 Hermes 返回为准。 -- [ ] 页面 AI 抽屉具备 context scope 控件,run input 和 tool audit 能反映 scope。 -- [ ] 页面 AI 抽屉能展示 mnote tool 可用性、最近 tool call 和 audit/trace 串联。 -- [ ] stop 至少可用;retry/rename/delete 若未实现,必须明确标为 P2 且 UI 不出现假可用按钮。 -- [ ] Hermes 未启动、模型缺失、plugin/tool 不可用、权限失败至少四类错误有可区分展示。 -- [ ] 页面刷新后仍以 Hermes session detail/resume 为会话真相。 -- [ ] mnote 不保存聊天消息真相,不保存 provider/API key,不创建第二套 profile/skill/plugin registry。 -- [ ] `git diff --check` 通过。 -- [ ] Rust 侧相关测试通过:`cargo test -p mnote-web hermes_client -- --nocapture`、`cargo test -p mnote-web hermes_tools_ -- --nocapture`。 -- [ ] 正式 `3000` smoke 覆盖 session/run/tool/audit/mobile,且证据写回本文。 +- [x] 页面 AI 抽屉具备 Mini Hermes 状态条,用户能看见当前 session、model/agent profile、run 状态。 +- [x] 页面 AI 抽屉能选择 agent/profile;切换 agent 不篡改已有 Hermes session profile。 +- [x] 页面 AI 抽屉能轻编辑当前 agent 的 `SOUL.md`、`USER.md`、`MEMORY.md`,保存真相在 Hermes。 +- [x] 页面 AI 抽屉能搜索、查看并启停 Hermes skill,刷新后状态仍以 Hermes 返回为准。 +- [x] 页面 AI 抽屉具备 context scope 控件,run input 和 tool audit 能反映 scope。 +- [x] 页面 AI 抽屉能展示 mnote tool 可用性、最近 tool call 和 audit/trace 串联。 +- [x] stop 至少可用;retry/rename/delete 若未实现,必须明确标为 P2 且 UI 不出现假可用按钮。 +- [x] Hermes 未启动、模型缺失、plugin/tool 不可用、权限失败至少四类错误有可区分展示。 +- [x] 页面刷新后仍以 Hermes session detail/resume 为会话真相。 +- [x] mnote 不保存聊天消息真相,不保存 provider/API key,不创建第二套 profile/skill/plugin registry。 +- [x] `git diff --check` 通过。 +- [x] Rust 侧相关测试通过:`cargo test -p mnote-web hermes_client -- --nocapture`、`cargo test -p mnote-web hermes_tools_ -- --nocapture`。 +- [x] 正式 `3000` smoke 覆盖 session/run/tool/audit/mobile,且证据写回本文。 --- -## 8. 当前建议的下一步 +## 8. P2 后续项 -优先顺序: +以下能力不阻塞本稿 `done`,后续另开设计或 checklist 推进: -1. 先补顶部 Mini Hermes 状态条、agent/profile 选择和错误态,因为这是当前“能回复但缺 Hermes 设置感”的直接缺口。 -2. 再补 skill 搜索/启停和 agent 人格轻编辑,让用户能在 mnote 页面内完成常用 agent 调整。 -3. 再补 context scope 和 tool 可用性,让用户明确 Hermes 正在调用 mnote 的哪些能力。 -4. 最后补 session 列表、rename/delete/search、profile create/rename、skill 上传等 P2 能力。 +- retry / continue 的更完整 Hermes run lifecycle。 +- session rename / delete / search。 +- profile create / rename / import / export。 +- skill 文件详情、pin、上传与 marketplace 发布。 +- usage / logs / jobs / files / channels / terminal / group chat 等 Hermes 管理台页面。 不要先做 Hermes 全局设置页复刻。API key、provider、usage/logs/jobs/files/channels、profile 导入导出、skill marketplace 发布仍应留在 Hermes 自己的管理台。 + +--- + +## 9. 验收证据 + +- 设计参考已落到本文第 2 节的精确文件索引;人格页参考图为 `/mnt/Data1T/mnote/tmp/img_v3_0211m_b7390ad9-a45b-4d94-8126-b7c17f029feg.png`,技能页参考图为 `/mnt/Data1T/mnote/tmp/img_v3_0211m_0d6e7a60-8c21-4fd1-8cdf-d548dab7532g.png`。 +- 代码入口:`rust/crates/mnote-web/src/ssr/pages/layout.rs`、`rust/crates/mnote-web/src/ssr/styles.rs`、`rust/crates/mnote-web/src/routes/hermes_client.rs`、`rust/crates/mnote-web/src/routes/web_shell.rs`、`rust/crates/mnote-web/src/routes/mod.rs`。 +- 自动化: + - `cargo test -p mnote-web hermes_client -- --nocapture` + - `cargo test -p mnote-web hermes_tools -- --nocapture` + - `cargo test -p mnote-web web_shell -- --nocapture` + - `node scripts/task-hermes-page-ai-agent-skill-smoke.js` + - `node scripts/task-hermes-page-ai-smoke.js` + - `node scripts/task-hermes-page-ai-mobile-smoke.js` + - `MNOTE_EXPECTED_HERMES_SETTINGS_URL=http://127.0.0.1:8642/hermes/settings node scripts/task-hermes-page-ai-runtime-controls-smoke.js` +- 截图证据:`/mnt/Data1T/mnote/tmp/page-ai-mini-hermes-control-surface.png`。 +- 运行约束:上述 smoke 已在正式 `3000` mnote-web 入口执行;`3000` 已重启为当前最新二进制。 diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index 5a8a2c5b..4252815f 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -9,6 +9,7 @@ use futures_util::TryStreamExt; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; +use std::fs; use std::process::Command; use std::time::Duration; @@ -387,8 +388,50 @@ pub async fn list_tools( { "name": "mnote.page.get", "scope": "page.read", + "kind": "read", "schemaVersion": "mnote.hermes_tool.v1", - "status": "planned_by_task_e" + "status": "available", + "description": "读取当前页面正文、标题、设置与结构快照" + }, + { + "name": "mnote.page.save", + "scope": "page.write", + "kind": "write", + "schemaVersion": "mnote.hermes_tool.v1", + "status": "available", + "description": "保存当前页面正文" + }, + { + "name": "mnote.page.update_title", + "scope": "page.write", + "kind": "write", + "schemaVersion": "mnote.hermes_tool.v1", + "status": "available", + "description": "更新当前页面标题" + }, + { + "name": "mnote.page.update_options", + "scope": "page.write", + "kind": "write", + "schemaVersion": "mnote.hermes_tool.v1", + "status": "available", + "description": "更新当前页面设置" + }, + { + "name": "mnote.artifact.create_summary", + "scope": "artifact.write", + "kind": "write", + "schemaVersion": "mnote.hermes_tool.v1", + "status": "available", + "description": "为当前页面创建或更新 AI Summary" + }, + { + "name": "mnote.artifact.create_ai_note", + "scope": "artifact.write", + "kind": "write", + "schemaVersion": "mnote.hermes_tool.v1", + "status": "available", + "description": "基于当前页面创建新的 AI Note" } ] })), @@ -411,20 +454,61 @@ fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { } fn configured_upstream() -> Option { - std::env::var("MNOTE_WEB_HERMES_UPSTREAM_URL") - .ok() - .or_else(|| std::env::var("MNOTE_HERMES_UPSTREAM_URL").ok()) - .map(|value| value.trim().trim_end_matches('/').to_string()) - .filter(|value| !value.is_empty()) + [ + "MNOTE_WEB_HERMES_UPSTREAM_URL", + "MNOTE_HERMES_UPSTREAM_URL", + "MNOTE_HERMES_API_BASE_URL", + ] + .into_iter() + .find_map(env_or_dotenv) + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) } fn configured_api_key() -> Option { - std::env::var("MNOTE_WEB_HERMES_API_KEY") - .ok() - .or_else(|| std::env::var("HERMES_API_SERVER_KEY").ok()) - .or_else(|| std::env::var("API_SERVER_KEY").ok()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) + [ + "MNOTE_WEB_HERMES_API_KEY", + "MNOTE_HERMES_API_KEY", + "HERMES_API_SERVER_KEY", + "API_SERVER_KEY", + ] + .into_iter() + .find_map(env_or_dotenv) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn env_or_dotenv(key: &str) -> Option { + if let Ok(value) = std::env::var(key) { + let trimmed = value.trim().trim_matches('"').to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + if cfg!(test) { + return None; + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .join(".env.all"); + let content = fs::read_to_string(root).ok()?; + for line in content.lines() { + let line = line.trim_end_matches('\r'); + if line.starts_with('#') || line.trim().is_empty() { + continue; + } + let Some((candidate_key, value)) = line.split_once('=') else { + continue; + }; + if candidate_key.trim() != key { + continue; + } + let trimmed = value.trim().trim_matches('"').to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + None } fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result { diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index d68314e1..9c1af23f 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -33,6 +33,7 @@ use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde_json::json; +use std::fs; use std::path::{Component, Path as FsPath, PathBuf}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; @@ -195,6 +196,7 @@ pub async fn document_page_shell( secondary_page_options_json={secondary_page_options_json.unwrap_or_default()} /> }); + let hermes_settings_config_script = render_hermes_settings_config_script(); let html = format!( r#" @@ -212,6 +214,7 @@ pub async fn document_page_shell( {} {} {} + {} "#, escape_html(title), @@ -223,6 +226,7 @@ pub async fn document_page_shell( escape_script_json(&snapshot_json), escape_script_json(&bootstrap_json), escape_script_json(&panes_bootstrap_json), + hermes_settings_config_script, secondary_snapshot_json .as_ref() .map(|value| format!(r#""#, escape_script_json(value))) @@ -256,6 +260,60 @@ pub(crate) fn build_editor_bootstrap_json( ) } +fn render_hermes_settings_config_script() -> String { + let Some(base_url) = [ + "MNOTE_WEB_HERMES_UPSTREAM_URL", + "MNOTE_HERMES_UPSTREAM_URL", + "MNOTE_HERMES_API_BASE_URL", + ] + .into_iter() + .find_map(env_or_dotenv) + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + else { + return String::new(); + }; + let settings_url = format!("{base_url}/hermes/settings"); + let encoded = serde_json::to_string(&settings_url).unwrap_or_else(|_| "\"\"".to_string()); + format!( + r#""#, + escape_script_json(&encoded) + ) +} + +fn env_or_dotenv(key: &str) -> Option { + if let Ok(value) = std::env::var(key) { + let trimmed = value.trim().trim_matches('"').to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + if cfg!(test) { + return None; + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .join(".env.all"); + let content = fs::read_to_string(root).ok()?; + for line in content.lines() { + let line = line.trim_end_matches('\r'); + if line.starts_with('#') || line.trim().is_empty() { + continue; + } + let Some((candidate_key, value)) = line.split_once('=') else { + continue; + }; + if candidate_key.trim() != key { + continue; + } + let trimmed = value.trim().trim_matches('"').to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + None +} + pub(crate) fn build_editor_bootstrap_json_with_ids( aggregate: &PageAggregate, context: &RequestContext, diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 79965075..9efb2bcd 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -40,6 +40,9 @@ const SIDEBAR_TREE_JS: &str = r##" pageAiCurrentRunId: '', pageAiAbortController: null, pageAiContextScope: 'page', + pageAiTools: [], + pageAiToolsError: '', + pageAiLastToolCall: null, pageAiProfiles: [], pageAiActiveProfileName: 'default', pageAiProfileError: '', @@ -230,6 +233,42 @@ const SIDEBAR_TREE_JS: &str = r##" }; } + function currentPageAiSelectedText() { + try { + var selection = window.getSelection ? window.getSelection() : null; + return selection ? searchText(selection.toString() || '') : ''; + } catch (_) { + return ''; + } + } + + function pageAiScopedPageContext(contextSnapshot) { + var aggregate = contextSnapshot.aggregate || {}; + var body = contextSnapshot.body || {}; + var subtree = contextSnapshot.subtree || null; + var outline = subtree && subtree.outline ? subtree.outline : null; + var scope = pageUiState.pageAiContextScope || 'page'; + var title = aggregate.head && aggregate.head.title ? aggregate.head.title : ''; + var selectedText = scope === 'selection' ? currentPageAiSelectedText() : ''; + return { + pageContext: { + contextScope: scope, + documentBlocks: scope === 'page' ? (body.content || null) : null, + node: { + documentId: currentDocumentId(), + title: title + }, + subtree: scope === 'page' ? subtree : null, + outline: scope === 'page' ? outline : null, + pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope, + evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null, + pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null + }, + selectedText: selectedText, + selectedBlockId: null + }; + } + function defaultPageOptions() { return { wideLayout: false, @@ -3212,6 +3251,21 @@ const SIDEBAR_TREE_JS: &str = r##" return pageAiProfileValue(selected) || 'default'; } + function pageAiCurrentProfileRecord() { + var active = pageAiCurrentProfile(); + return pageUiState.pageAiProfiles.find(function(profile) { + return pageAiProfileValue(profile) === active; + }) || null; + } + + function pageAiCurrentModelLabel() { + var profile = pageAiCurrentProfileRecord(); + if (!profile) return '由 Hermes 决定'; + var model = String(profile.model || '').trim(); + var gateway = String(profile.gateway || '').trim(); + return [model, gateway].filter(Boolean).join(' · ') || '由 Hermes 决定'; + } + function pageAiNormalizeProfiles(payload) { var upstream = pageAiUnwrapUpstream(payload); var profiles = pageAiNormalizeArray(upstream && upstream.profiles ? upstream.profiles : upstream); @@ -3319,6 +3373,38 @@ const SIDEBAR_TREE_JS: &str = r##" return '当前页'; } + function pageAiHermesSettingsUrl() { + var configured = String(window.__mnoteHermesSettingsUrl || '').trim(); + return configured || ''; + } + + function pageAiOpenHermesSettings() { + var url = pageAiHermesSettingsUrl(); + if (url) { + window.open(url, '_blank', 'noopener,noreferrer'); + return; + } + pageUiState.pageAiProfileError = '未配置 Hermes 设置入口:请设置 MNOTE_WEB_HERMES_UPSTREAM_URL。'; + renderPageAiControls(); + } + + function pageAiNormalizeTools(payload) { + var upstream = pageAiUnwrapUpstream(payload) || {}; + var tools = pageAiNormalizeArray(upstream.tools || upstream); + return tools.map(function(tool) { + var name = String(tool && (tool.name || tool.toolName || tool.tool) || '').trim(); + if (!name) return null; + return { + name: name, + description: String(tool && tool.description || '').trim(), + scope: String(tool && (tool.scope || tool.requiredScope || '') || '').trim(), + kind: String(tool && (tool.kind || tool.mode || '') || '').trim(), + status: String(tool && (tool.status || tool.permission || 'available') || '').trim(), + unavailableReason: String(tool && (tool.unavailableReason || tool.reason || '') || '').trim() + }; + }).filter(Boolean); + } + function pageAiSetDraftForSection(section, value) { pageUiState.pageAiProfileMemoryDrafts[section] = String(value == null ? '' : value); } @@ -3337,6 +3423,49 @@ const SIDEBAR_TREE_JS: &str = r##" }; } + async function pageAiLoadTools() { + try { + var response = await fetch('/api/hermes/client/tools?scope=mnote', { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'tools_failed_' + response.status); + pageUiState.pageAiTools = pageAiNormalizeTools(payload); + pageUiState.pageAiToolsError = ''; + } catch (error) { + pageUiState.pageAiToolsError = error instanceof Error ? error.message : String(error); + } + renderPageAiControls(); + } + + async function pageAiStopRun() { + var runId = String(pageUiState.pageAiCurrentRunId || '').trim(); + if (!runId || pageUiState.pageAiRunStatus === 'idle' || pageUiState.pageAiRunStatus === 'completed') return; + try { + var response = await fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/abort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sessionId: pageUiState.pageAiActiveSessionId, + profile: pageAiCurrentProfile(), + reason: 'page_ai_user_stop' + }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'run_abort_failed_' + response.status); + pageAiSetRunStatus('aborted', runId); + pageUiState.pageAiMessages.push({ role: 'assistant', content: '已请求 Hermes 停止当前 run。' }); + } catch (error) { + pageAiSetRunStatus('failed', runId); + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: '停止 Hermes run 失败:' + (error instanceof Error ? error.message : String(error)) + }); + } + renderPageAiControls(); + renderPageAiConversation(); + } + function pageAiFilteredSkillEntries() { var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase(); return pageAiSkillListEntries().filter(function(skill) { @@ -3519,18 +3648,32 @@ const SIDEBAR_TREE_JS: &str = r##" var profiles = pageUiState.pageAiProfiles.length ? pageUiState.pageAiProfiles : [{ name: activeProfile, active: true }]; profileSelect.innerHTML = profiles.map(function(profile) { var name = pageAiProfileValue(profile) || 'default'; - var label = profile.alias ? name + ' · ' + profile.alias : name; + var model = [profile.model, profile.gateway].filter(Boolean).join(' / '); + var label = [name, profile.alias, model].filter(Boolean).join(' · '); return ''; }).join(''); profileSelect.value = activeProfile; } var runStatus = drawer.querySelector('[data-page-ai-run-status]'); if (runStatus instanceof HTMLElement) runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus); + var stopButton = drawer.querySelector('[data-page-ai-action="stop-run"]'); + if (stopButton instanceof HTMLButtonElement) { + var canStop = ['queued', 'running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0 && pageUiState.pageAiCurrentRunId; + stopButton.disabled = !canStop; + stopButton.setAttribute('aria-disabled', canStop ? 'false' : 'true'); + } + var settingsLink = drawer.querySelector('[data-page-ai-action="open-hermes-settings"]'); + if (settingsLink instanceof HTMLButtonElement) { + settingsLink.disabled = !pageAiHermesSettingsUrl(); + settingsLink.title = pageAiHermesSettingsUrl() ? '打开 Hermes 设置' : '未配置 MNOTE_WEB_HERMES_UPSTREAM_URL'; + } var sessionNode = drawer.querySelector('[data-page-ai-session-status]'); if (sessionNode instanceof HTMLElement) { var session = pageAiCurrentSession(); sessionNode.textContent = session && session.id ? String(session.title || '当前页问答') : '等待 Hermes session'; } + var modelNode = drawer.querySelector('[data-page-ai-model-status]'); + if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel(); var scopeSelect = drawer.querySelector('[data-page-ai-context-scope]'); if (scopeSelect instanceof HTMLSelectElement) scopeSelect.value = pageUiState.pageAiContextScope; var scopeLabel = drawer.querySelector('[data-page-ai-context-scope-label]'); @@ -3606,6 +3749,34 @@ const SIDEBAR_TREE_JS: &str = r##" }).join(''); } } + var toolsList = drawer.querySelector('[data-page-ai-tool-list]'); + if (toolsList instanceof HTMLElement) { + var tools = pageAiNormalizeArray(pageUiState.pageAiTools); + if (!tools.length) { + toolsList.innerHTML = '
尚未读取到 mnote tool manifest。
'; + } else { + toolsList.innerHTML = tools.map(function(tool) { + return '' + + '
' + + '
' + escapeHtml(tool.name) + '
' + + '
' + escapeHtml(tool.scope || tool.kind || 'mnote') + ' · ' + escapeHtml(tool.status || 'available') + '
' + + (tool.unavailableReason ? '
' + escapeHtml(tool.unavailableReason) + '
' : '') + + '
'; + }).join(''); + } + } + var toolError = drawer.querySelector('[data-page-ai-tool-error]'); + if (toolError instanceof HTMLElement) { + toolError.textContent = pageUiState.pageAiToolsError || ''; + toolError.hidden = !pageUiState.pageAiToolsError; + } + var lastTool = drawer.querySelector('[data-page-ai-last-tool]'); + if (lastTool instanceof HTMLElement) { + var call = pageUiState.pageAiLastToolCall; + lastTool.textContent = call + ? [call.event, call.name, call.runId, call.traceId || call.auditId].filter(Boolean).join(' · ') + : '暂无 tool call'; + } } function humanizePageAiResponse(rawText, promptText) { @@ -3656,11 +3827,15 @@ const SIDEBAR_TREE_JS: &str = r##" '状态' + '空闲' + '' + + '
' + + '模型' + + '由 Hermes 决定' + + '
' + '
' + '上下文' + '当前页' + '
' + - 'Hermes 设置' + + '' + '' + '
' + '
' + '
' + '' + + '
' + + '
' + + 'mnote tools' + + '暂无 tool call' + + '
' + + '' + + '
' + + '
' + '
' + '' + '