feat: complete page ai hermes control checklist
This commit is contained in:
+47
-29
@@ -1,8 +1,8 @@
|
|||||||
# 7-7 [process] 页面 AI Mini Hermes 控制面设计与执行 checklist v1
|
# 7-7 [done] 页面 AI Mini Hermes 控制面设计与执行 checklist v1
|
||||||
|
|
||||||
> 更新时间:2026-05-14
|
> 更新时间: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`
|
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||||
@@ -329,21 +329,21 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
|
|||||||
|
|
||||||
### I. 自动化验收
|
### I. 自动化验收
|
||||||
|
|
||||||
- [ ] I1. 新增或扩展 browser smoke:session 状态条。
|
- [x] I1. 新增或扩展 browser smoke:session 状态条。
|
||||||
- 验收标准:断言 session title/model/run status 可见。
|
- 验收标准:断言 session title/model/run status 可见。
|
||||||
- [ ] I2. 新增或扩展 browser smoke:agent/profile 选择。
|
- [x] I2. 新增或扩展 browser smoke:agent/profile 选择。
|
||||||
- 验收标准:断言 profile 列表来自 Hermes;切换 profile 后新 run/session 使用目标 profile。
|
- 验收标准:断言 profile 列表来自 Hermes;切换 profile 后新 run/session 使用目标 profile。
|
||||||
- [ ] I3. 新增或扩展 browser smoke:agent 人格文件编辑。
|
- [x] I3. 新增或扩展 browser smoke:agent 人格文件编辑。
|
||||||
- 验收标准:保存 `SOUL.md` 或 `USER.md` 后通过 Hermes API 读回一致;运行中保存行为符合禁用或提示规则。
|
- 验收标准:保存 `SOUL.md` 或 `USER.md` 后通过 Hermes API 读回一致;运行中保存行为符合禁用或提示规则。
|
||||||
- [ ] I4. 新增或扩展 browser smoke:skill 启停。
|
- [x] I4. 新增或扩展 browser smoke:skill 启停。
|
||||||
- 验收标准:toggle 后刷新仍保持 Hermes 返回状态;失败时 UI 回滚。
|
- 验收标准:toggle 后刷新仍保持 Hermes 返回状态;失败时 UI 回滚。
|
||||||
- [ ] I5. 新增或扩展 browser smoke:run stop/retry。
|
- [x] I5. 新增或扩展 browser smoke:run stop/retry。
|
||||||
- 验收标准:至少覆盖 stop;retry 若未实现则断言按钮不存在或禁用。
|
- 验收标准:至少覆盖 stop;retry 若未实现则断言按钮不存在或禁用。
|
||||||
- [ ] I6. 新增或扩展 browser smoke:context scope。
|
- [x] I6. 新增或扩展 browser smoke:context scope。
|
||||||
- 验收标准:选区/当前页上下文能进入 run payload 或 tool audit。
|
- 验收标准:选区/当前页上下文能进入 run payload 或 tool audit。
|
||||||
- [ ] I7. 新增或扩展 browser smoke:tool 可用性与 audit。
|
- [x] I7. 新增或扩展 browser smoke:tool 可用性与 audit。
|
||||||
- 验收标准:能看到 mnote tool 列表、一次 tool call、对应 audit id。
|
- 验收标准:能看到 mnote tool 列表、一次 tool call、对应 audit id。
|
||||||
- [ ] I8. 移动端 smoke。
|
- [x] I8. 移动端 smoke。
|
||||||
- 验收标准:状态条、tool 折叠、输入区在窄屏不重叠。
|
- 验收标准:状态条、tool 折叠、输入区在窄屏不重叠。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -352,29 +352,47 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
|
|||||||
|
|
||||||
本稿移入 `done/` 前必须同时满足:
|
本稿移入 `done/` 前必须同时满足:
|
||||||
|
|
||||||
- [ ] 页面 AI 抽屉具备 Mini Hermes 状态条,用户能看见当前 session、model/agent profile、run 状态。
|
- [x] 页面 AI 抽屉具备 Mini Hermes 状态条,用户能看见当前 session、model/agent profile、run 状态。
|
||||||
- [ ] 页面 AI 抽屉能选择 agent/profile;切换 agent 不篡改已有 Hermes session profile。
|
- [x] 页面 AI 抽屉能选择 agent/profile;切换 agent 不篡改已有 Hermes session profile。
|
||||||
- [ ] 页面 AI 抽屉能轻编辑当前 agent 的 `SOUL.md`、`USER.md`、`MEMORY.md`,保存真相在 Hermes。
|
- [x] 页面 AI 抽屉能轻编辑当前 agent 的 `SOUL.md`、`USER.md`、`MEMORY.md`,保存真相在 Hermes。
|
||||||
- [ ] 页面 AI 抽屉能搜索、查看并启停 Hermes skill,刷新后状态仍以 Hermes 返回为准。
|
- [x] 页面 AI 抽屉能搜索、查看并启停 Hermes skill,刷新后状态仍以 Hermes 返回为准。
|
||||||
- [ ] 页面 AI 抽屉具备 context scope 控件,run input 和 tool audit 能反映 scope。
|
- [x] 页面 AI 抽屉具备 context scope 控件,run input 和 tool audit 能反映 scope。
|
||||||
- [ ] 页面 AI 抽屉能展示 mnote tool 可用性、最近 tool call 和 audit/trace 串联。
|
- [x] 页面 AI 抽屉能展示 mnote tool 可用性、最近 tool call 和 audit/trace 串联。
|
||||||
- [ ] stop 至少可用;retry/rename/delete 若未实现,必须明确标为 P2 且 UI 不出现假可用按钮。
|
- [x] stop 至少可用;retry/rename/delete 若未实现,必须明确标为 P2 且 UI 不出现假可用按钮。
|
||||||
- [ ] Hermes 未启动、模型缺失、plugin/tool 不可用、权限失败至少四类错误有可区分展示。
|
- [x] Hermes 未启动、模型缺失、plugin/tool 不可用、权限失败至少四类错误有可区分展示。
|
||||||
- [ ] 页面刷新后仍以 Hermes session detail/resume 为会话真相。
|
- [x] 页面刷新后仍以 Hermes session detail/resume 为会话真相。
|
||||||
- [ ] mnote 不保存聊天消息真相,不保存 provider/API key,不创建第二套 profile/skill/plugin registry。
|
- [x] mnote 不保存聊天消息真相,不保存 provider/API key,不创建第二套 profile/skill/plugin registry。
|
||||||
- [ ] `git diff --check` 通过。
|
- [x] `git diff --check` 通过。
|
||||||
- [ ] Rust 侧相关测试通过:`cargo test -p mnote-web hermes_client -- --nocapture`、`cargo test -p mnote-web hermes_tools_ -- --nocapture`。
|
- [x] 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] 正式 `3000` smoke 覆盖 session/run/tool/audit/mobile,且证据写回本文。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 当前建议的下一步
|
## 8. P2 后续项
|
||||||
|
|
||||||
优先顺序:
|
以下能力不阻塞本稿 `done`,后续另开设计或 checklist 推进:
|
||||||
|
|
||||||
1. 先补顶部 Mini Hermes 状态条、agent/profile 选择和错误态,因为这是当前“能回复但缺 Hermes 设置感”的直接缺口。
|
- retry / continue 的更完整 Hermes run lifecycle。
|
||||||
2. 再补 skill 搜索/启停和 agent 人格轻编辑,让用户能在 mnote 页面内完成常用 agent 调整。
|
- session rename / delete / search。
|
||||||
3. 再补 context scope 和 tool 可用性,让用户明确 Hermes 正在调用 mnote 的哪些能力。
|
- profile create / rename / import / export。
|
||||||
4. 最后补 session 列表、rename/delete/search、profile create/rename、skill 上传等 P2 能力。
|
- 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 自己的管理台。
|
不要先做 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` 已重启为当前最新二进制。
|
||||||
@@ -9,6 +9,7 @@ use futures_util::TryStreamExt;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -387,8 +388,50 @@ pub async fn list_tools(
|
|||||||
{
|
{
|
||||||
"name": "mnote.page.get",
|
"name": "mnote.page.get",
|
||||||
"scope": "page.read",
|
"scope": "page.read",
|
||||||
|
"kind": "read",
|
||||||
"schemaVersion": "mnote.hermes_tool.v1",
|
"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,22 +454,63 @@ fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn configured_upstream() -> Option<String> {
|
fn configured_upstream() -> Option<String> {
|
||||||
std::env::var("MNOTE_WEB_HERMES_UPSTREAM_URL")
|
[
|
||||||
.ok()
|
"MNOTE_WEB_HERMES_UPSTREAM_URL",
|
||||||
.or_else(|| std::env::var("MNOTE_HERMES_UPSTREAM_URL").ok())
|
"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())
|
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn configured_api_key() -> Option<String> {
|
fn configured_api_key() -> Option<String> {
|
||||||
std::env::var("MNOTE_WEB_HERMES_API_KEY")
|
[
|
||||||
.ok()
|
"MNOTE_WEB_HERMES_API_KEY",
|
||||||
.or_else(|| std::env::var("HERMES_API_SERVER_KEY").ok())
|
"MNOTE_HERMES_API_KEY",
|
||||||
.or_else(|| std::env::var("API_SERVER_KEY").ok())
|
"HERMES_API_SERVER_KEY",
|
||||||
|
"API_SERVER_KEY",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find_map(env_or_dotenv)
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_or_dotenv(key: &str) -> Option<String> {
|
||||||
|
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<Value, WebError> {
|
fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<Value, WebError> {
|
||||||
let message = payload
|
let message = payload
|
||||||
.get("message")
|
.get("message")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use bridge_runtime::RuntimeQueryEnvelopeWire;
|
|||||||
use core_protocol::KernelProjectionKind;
|
use core_protocol::KernelProjectionKind;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::fs;
|
||||||
use std::path::{Component, Path as FsPath, PathBuf};
|
use std::path::{Component, Path as FsPath, PathBuf};
|
||||||
|
|
||||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
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()}
|
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
|
||||||
/>
|
/>
|
||||||
});
|
});
|
||||||
|
let hermes_settings_config_script = render_hermes_settings_config_script();
|
||||||
let html = format!(
|
let html = format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@@ -212,6 +214,7 @@ pub async fn document_page_shell(
|
|||||||
{}
|
{}
|
||||||
{}
|
{}
|
||||||
{}
|
{}
|
||||||
|
{}
|
||||||
</body>
|
</body>
|
||||||
</html>"#,
|
</html>"#,
|
||||||
escape_html(title),
|
escape_html(title),
|
||||||
@@ -223,6 +226,7 @@ pub async fn document_page_shell(
|
|||||||
escape_script_json(&snapshot_json),
|
escape_script_json(&snapshot_json),
|
||||||
escape_script_json(&bootstrap_json),
|
escape_script_json(&bootstrap_json),
|
||||||
escape_script_json(&panes_bootstrap_json),
|
escape_script_json(&panes_bootstrap_json),
|
||||||
|
hermes_settings_config_script,
|
||||||
secondary_snapshot_json
|
secondary_snapshot_json
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
|
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, 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#"<script>window.__mnoteHermesSettingsUrl = {};</script>"#,
|
||||||
|
escape_script_json(&encoded)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_or_dotenv(key: &str) -> Option<String> {
|
||||||
|
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(
|
pub(crate) fn build_editor_bootstrap_json_with_ids(
|
||||||
aggregate: &PageAggregate,
|
aggregate: &PageAggregate,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
pageAiCurrentRunId: '',
|
pageAiCurrentRunId: '',
|
||||||
pageAiAbortController: null,
|
pageAiAbortController: null,
|
||||||
pageAiContextScope: 'page',
|
pageAiContextScope: 'page',
|
||||||
|
pageAiTools: [],
|
||||||
|
pageAiToolsError: '',
|
||||||
|
pageAiLastToolCall: null,
|
||||||
pageAiProfiles: [],
|
pageAiProfiles: [],
|
||||||
pageAiActiveProfileName: 'default',
|
pageAiActiveProfileName: 'default',
|
||||||
pageAiProfileError: '',
|
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() {
|
function defaultPageOptions() {
|
||||||
return {
|
return {
|
||||||
wideLayout: false,
|
wideLayout: false,
|
||||||
@@ -3212,6 +3251,21 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
return pageAiProfileValue(selected) || 'default';
|
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) {
|
function pageAiNormalizeProfiles(payload) {
|
||||||
var upstream = pageAiUnwrapUpstream(payload);
|
var upstream = pageAiUnwrapUpstream(payload);
|
||||||
var profiles = pageAiNormalizeArray(upstream && upstream.profiles ? upstream.profiles : upstream);
|
var profiles = pageAiNormalizeArray(upstream && upstream.profiles ? upstream.profiles : upstream);
|
||||||
@@ -3319,6 +3373,38 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
return '当前页';
|
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) {
|
function pageAiSetDraftForSection(section, value) {
|
||||||
pageUiState.pageAiProfileMemoryDrafts[section] = String(value == null ? '' : 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() {
|
function pageAiFilteredSkillEntries() {
|
||||||
var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase();
|
var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase();
|
||||||
return pageAiSkillListEntries().filter(function(skill) {
|
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 }];
|
var profiles = pageUiState.pageAiProfiles.length ? pageUiState.pageAiProfiles : [{ name: activeProfile, active: true }];
|
||||||
profileSelect.innerHTML = profiles.map(function(profile) {
|
profileSelect.innerHTML = profiles.map(function(profile) {
|
||||||
var name = pageAiProfileValue(profile) || 'default';
|
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 '<option value="' + escapeHtml(name) + '"' + (name === activeProfile ? ' selected' : '') + '>' + escapeHtml(label) + '</option>';
|
return '<option value="' + escapeHtml(name) + '"' + (name === activeProfile ? ' selected' : '') + '>' + escapeHtml(label) + '</option>';
|
||||||
}).join('');
|
}).join('');
|
||||||
profileSelect.value = activeProfile;
|
profileSelect.value = activeProfile;
|
||||||
}
|
}
|
||||||
var runStatus = drawer.querySelector('[data-page-ai-run-status]');
|
var runStatus = drawer.querySelector('[data-page-ai-run-status]');
|
||||||
if (runStatus instanceof HTMLElement) runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus);
|
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]');
|
var sessionNode = drawer.querySelector('[data-page-ai-session-status]');
|
||||||
if (sessionNode instanceof HTMLElement) {
|
if (sessionNode instanceof HTMLElement) {
|
||||||
var session = pageAiCurrentSession();
|
var session = pageAiCurrentSession();
|
||||||
sessionNode.textContent = session && session.id ? String(session.title || '当前页问答') : '等待 Hermes session';
|
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]');
|
var scopeSelect = drawer.querySelector('[data-page-ai-context-scope]');
|
||||||
if (scopeSelect instanceof HTMLSelectElement) scopeSelect.value = pageUiState.pageAiContextScope;
|
if (scopeSelect instanceof HTMLSelectElement) scopeSelect.value = pageUiState.pageAiContextScope;
|
||||||
var scopeLabel = drawer.querySelector('[data-page-ai-context-scope-label]');
|
var scopeLabel = drawer.querySelector('[data-page-ai-context-scope-label]');
|
||||||
@@ -3606,6 +3749,34 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var toolsList = drawer.querySelector('[data-page-ai-tool-list]');
|
||||||
|
if (toolsList instanceof HTMLElement) {
|
||||||
|
var tools = pageAiNormalizeArray(pageUiState.pageAiTools);
|
||||||
|
if (!tools.length) {
|
||||||
|
toolsList.innerHTML = '<div class="wolai-page-ai-empty">尚未读取到 mnote tool manifest。</div>';
|
||||||
|
} else {
|
||||||
|
toolsList.innerHTML = tools.map(function(tool) {
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-tool-row">' +
|
||||||
|
'<div class="wolai-page-ai-tool-name">' + escapeHtml(tool.name) + '</div>' +
|
||||||
|
'<div class="wolai-page-ai-tool-meta">' + escapeHtml(tool.scope || tool.kind || 'mnote') + ' · ' + escapeHtml(tool.status || 'available') + '</div>' +
|
||||||
|
(tool.unavailableReason ? '<div class="wolai-page-ai-tool-meta">' + escapeHtml(tool.unavailableReason) + '</div>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}).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) {
|
function humanizePageAiResponse(rawText, promptText) {
|
||||||
@@ -3656,11 +3827,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
'<span class="wolai-page-ai-statuslabel">状态</span>' +
|
'<span class="wolai-page-ai-statuslabel">状态</span>' +
|
||||||
'<span class="wolai-page-ai-statusvalue" data-page-ai-run-status>空闲</span>' +
|
'<span class="wolai-page-ai-statusvalue" data-page-ai-run-status>空闲</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="wolai-page-ai-statusitem">' +
|
||||||
|
'<span class="wolai-page-ai-statuslabel">模型</span>' +
|
||||||
|
'<span class="wolai-page-ai-statusvalue" data-page-ai-model-status>由 Hermes 决定</span>' +
|
||||||
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-statusitem">' +
|
'<div class="wolai-page-ai-statusitem">' +
|
||||||
'<span class="wolai-page-ai-statuslabel">上下文</span>' +
|
'<span class="wolai-page-ai-statuslabel">上下文</span>' +
|
||||||
'<span class="wolai-page-ai-statusvalue" data-page-ai-context-scope-label>当前页</span>' +
|
'<span class="wolai-page-ai-statusvalue" data-page-ai-context-scope-label>当前页</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<a class="wolai-page-ai-settings-link" href="/auth" target="_blank" rel="noreferrer">Hermes 设置</a>' +
|
'<button type="button" class="wolai-page-ai-settings-link" data-page-ai-action="open-hermes-settings">Hermes 设置</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-topbar">' +
|
'<div class="wolai-page-ai-topbar">' +
|
||||||
'<label class="wolai-page-ai-profile-select">' +
|
'<label class="wolai-page-ai-profile-select">' +
|
||||||
@@ -3697,6 +3872,14 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
|
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="wolai-page-ai-tools-panel">' +
|
||||||
|
'<div class="wolai-page-ai-tools-head">' +
|
||||||
|
'<span>mnote tools</span>' +
|
||||||
|
'<span data-page-ai-last-tool>暂无 tool call</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="wolai-page-ai-inline-error" data-page-ai-tool-error hidden></div>' +
|
||||||
|
'<div class="wolai-page-ai-tool-list" data-page-ai-tool-list></div>' +
|
||||||
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
|
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
|
||||||
'</section>' +
|
'</section>' +
|
||||||
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="agent" hidden>' +
|
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="agent" hidden>' +
|
||||||
@@ -3721,6 +3904,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
'<div class="wolai-page-ai-toolbar">' +
|
'<div class="wolai-page-ai-toolbar">' +
|
||||||
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-action="new-session" aria-label="当前已是新会话">新会话</button>' +
|
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-action="new-session" aria-label="当前已是新会话">新会话</button>' +
|
||||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history">历史会话</button>' +
|
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history">历史会话</button>' +
|
||||||
|
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="stop-run" disabled>停止</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-input-row">' +
|
'<div class="wolai-page-ai-input-row">' +
|
||||||
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="问我你想知道的"></textarea>' +
|
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="问我你想知道的"></textarea>' +
|
||||||
@@ -3794,7 +3978,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
pageAiLoadProfiles(),
|
pageAiLoadProfiles(),
|
||||||
pageAiLoadProfileMemory(),
|
pageAiLoadProfileMemory(),
|
||||||
pageAiLoadSkills()
|
pageAiLoadSkills(),
|
||||||
|
pageAiLoadTools()
|
||||||
]).then(function() {
|
]).then(function() {
|
||||||
renderPageAiControls();
|
renderPageAiControls();
|
||||||
}).catch(function() {}).then(function() {
|
}).catch(function() {}).then(function() {
|
||||||
@@ -3864,11 +4049,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
try {
|
try {
|
||||||
await pageAiEnsureHermesSession();
|
await pageAiEnsureHermesSession();
|
||||||
pageAiSetRunStatus('queued');
|
pageAiSetRunStatus('queued');
|
||||||
|
renderPageAiControls();
|
||||||
var contextSnapshot = currentPageAiContextSnapshot();
|
var contextSnapshot = currentPageAiContextSnapshot();
|
||||||
var aggregate = contextSnapshot.aggregate || {};
|
var scopedContext = pageAiScopedPageContext(contextSnapshot);
|
||||||
var body = contextSnapshot.body || {};
|
|
||||||
var subtree = contextSnapshot.subtree || null;
|
|
||||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
|
||||||
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
|
||||||
currentSession = pageAiCurrentSession();
|
currentSession = pageAiCurrentSession();
|
||||||
if (currentSession) {
|
if (currentSession) {
|
||||||
@@ -3887,22 +4070,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
documentId: currentDocumentId(),
|
documentId: currentDocumentId(),
|
||||||
sessionId: pageUiState.pageAiActiveSessionId,
|
sessionId: pageUiState.pageAiActiveSessionId,
|
||||||
profile: pageAiCurrentProfile(),
|
profile: pageAiCurrentProfile(),
|
||||||
|
contextScope: pageUiState.pageAiContextScope,
|
||||||
message: prompt,
|
message: prompt,
|
||||||
model: 'hermes-agent',
|
model: 'hermes-agent',
|
||||||
pageContext: {
|
pageContext: scopedContext.pageContext,
|
||||||
documentBlocks: body.content || null,
|
selectedBlockId: scopedContext.selectedBlockId,
|
||||||
node: {
|
selectedText: scopedContext.selectedText,
|
||||||
documentId: currentDocumentId(),
|
|
||||||
title: aggregate.head && aggregate.head.title ? aggregate.head.title : ''
|
|
||||||
},
|
|
||||||
subtree: subtree,
|
|
||||||
outline: outline,
|
|
||||||
pageSubtreeSource: contextSnapshot.pageSubtreeSource || 'none',
|
|
||||||
evidence: null,
|
|
||||||
pageOptions: currentPageOptions()
|
|
||||||
},
|
|
||||||
selectedBlockId: null,
|
|
||||||
selectedText: null,
|
|
||||||
traceId: 'page-ai-run-' + Date.now().toString(36)
|
traceId: 'page-ai-run-' + Date.now().toString(36)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -3914,7 +4087,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload;
|
var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload;
|
||||||
var runId = upstream && (upstream.run_id || upstream.runId);
|
var runId = upstream && (upstream.run_id || upstream.runId);
|
||||||
if (!runId) throw new Error('hermes_run_missing_run_id');
|
if (!runId) throw new Error('hermes_run_missing_run_id');
|
||||||
|
var runTraceId = String((upstream && (upstream.trace_id || upstream.traceId)) || (runPayload && (runPayload.trace_id || runPayload.traceId)) || '');
|
||||||
pageAiSetRunStatus('running', runId);
|
pageAiSetRunStatus('running', runId);
|
||||||
|
renderPageAiControls();
|
||||||
var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), {
|
var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), {
|
||||||
headers: { 'accept': 'text/event-stream' }
|
headers: { 'accept': 'text/event-stream' }
|
||||||
});
|
});
|
||||||
@@ -3942,15 +4117,30 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
|
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
|
||||||
try {
|
try {
|
||||||
var toolEvent = JSON.parse(payloadText || 'null');
|
var toolEvent = JSON.parse(payloadText || 'null');
|
||||||
|
pageUiState.pageAiLastToolCall = {
|
||||||
|
event: eventName,
|
||||||
|
name: String(toolEvent && (toolEvent.name || toolEvent.tool || eventName) || eventName),
|
||||||
|
runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId),
|
||||||
|
traceId: String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId),
|
||||||
|
auditId: String(toolEvent && (toolEvent.audit_id || toolEvent.auditId) || '')
|
||||||
|
};
|
||||||
pageUiState.pageAiMessages.push({
|
pageUiState.pageAiMessages.push({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
content: String(toolEvent && (toolEvent.name || toolEvent.tool || eventName) || eventName)
|
content: pageUiState.pageAiLastToolCall.name
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
pageUiState.pageAiLastToolCall = {
|
||||||
|
event: eventName,
|
||||||
|
name: eventName,
|
||||||
|
runId: runId,
|
||||||
|
traceId: runTraceId,
|
||||||
|
auditId: ''
|
||||||
|
};
|
||||||
pageUiState.pageAiMessages.push({ role: 'tool', content: eventName });
|
pageUiState.pageAiMessages.push({ role: 'tool', content: eventName });
|
||||||
}
|
}
|
||||||
renderPageAiConversation();
|
renderPageAiConversation();
|
||||||
pageAiSetRunStatus('tool_calling', runId);
|
pageAiSetRunStatus('tool_calling', runId);
|
||||||
|
renderPageAiControls();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
pageUiState.pageAiMessages.push({
|
pageUiState.pageAiMessages.push({
|
||||||
@@ -4521,6 +4711,20 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pageAiSettings = closestAction(e.target, '[data-page-ai-action="open-hermes-settings"]');
|
||||||
|
if (pageAiSettings) {
|
||||||
|
e.preventDefault();
|
||||||
|
pageAiOpenHermesSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageAiStop = closestAction(e.target, '[data-page-ai-action="stop-run"]');
|
||||||
|
if (pageAiStop) {
|
||||||
|
e.preventDefault();
|
||||||
|
void pageAiStopRun();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var pageAiRotate = closestAction(e.target, '[data-page-ai-action="rotate"]');
|
var pageAiRotate = closestAction(e.target, '[data-page-ai-action="rotate"]');
|
||||||
if (pageAiRotate) {
|
if (pageAiRotate) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -2670,7 +2670,7 @@ body {
|
|||||||
|
|
||||||
.wolai-page-ai-statusbar {
|
.wolai-page-ai-statusbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(68px, auto) minmax(72px, auto) auto;
|
grid-template-columns: minmax(0, 1fr) minmax(68px, auto) minmax(82px, auto) minmax(72px, auto) auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -2714,9 +2714,16 @@ body {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
background: #FFF;
|
||||||
color: #1B1C1C;
|
color: #1B1C1C;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-settings-link:disabled {
|
||||||
|
color: #AAA6A0;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-page-ai-topbar {
|
.wolai-page-ai-topbar {
|
||||||
@@ -2811,6 +2818,67 @@ body {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tools-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #FAFAFA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tools-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tools-head span:last-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tool-list {
|
||||||
|
display: grid;
|
||||||
|
max-height: 96px;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tool-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tool-name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1B1C1C;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tool-meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.wolai-page-ai-provider-group {
|
.wolai-page-ai-provider-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
BASE_URL,
|
||||||
|
UI_TIMEOUT_MS,
|
||||||
|
cleanupDocuments,
|
||||||
|
createTempDocument,
|
||||||
|
ensureAuthenticated,
|
||||||
|
renameDocument,
|
||||||
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
|
const EXPECTED_SETTINGS_URL =
|
||||||
|
process.env.MNOTE_EXPECTED_HERMES_SETTINGS_URL || "http://127.0.0.1:3999/hermes/settings";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
const title = `TEST-HERMES-AI-runtime-${suffix}`;
|
||||||
|
const sessionId = `mnote_runtime_${suffix}`;
|
||||||
|
const runId = `run_runtime_${suffix}`;
|
||||||
|
const createdIds = [];
|
||||||
|
const sessionBodies = [];
|
||||||
|
const runBodies = [];
|
||||||
|
const abortBodies = [];
|
||||||
|
const eventsRelease = deferred();
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.route("**/api/ai-agent/run", async (route) => {
|
||||||
|
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/profiles", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, profiles: [{ name: "default", active: true, model: "gpt-5" }] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/profile-memory**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, memory: "", user: "", soul: "" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/skills**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||||
|
sessionBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
sessionId,
|
||||||
|
title: "运行控制测试",
|
||||||
|
profile: "default",
|
||||||
|
traceId: `trace_session_${suffix}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, session: { sessionId, profile: "default", messages: [] } }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||||
|
runBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runId, sessionId, traceId: `trace_run_${suffix}` }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
|
||||||
|
await eventsRelease.promise;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||||
|
body: `data: ${JSON.stringify({ event: "run.aborted", run_id: runId, session_id: sessionId })}\n\n`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/hermes/client/runs/${runId}/abort`, async (route) => {
|
||||||
|
abortBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||||
|
eventsRelease.resolve();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runId, status: "aborted" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureAuthenticated(page, context.request);
|
||||||
|
const target = await createTempDocument(context.request);
|
||||||
|
createdIds.push(target.documentId);
|
||||||
|
await renameDocument(context.request, target.workspaceId, target.documentId, title);
|
||||||
|
await page.goto(
|
||||||
|
`${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
|
||||||
|
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) => window.__mnoteHermesSettingsUrl === expected,
|
||||||
|
EXPECTED_SETTINGS_URL,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector("[data-page-ai-tool-list]")?.textContent || "").includes("mnote.page.save"),
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const settingsDisabled = await page.locator('[data-page-ai-action="open-hermes-settings"]').isDisabled();
|
||||||
|
assert.equal(settingsDisabled, false, "配置 Hermes upstream 后设置入口不应禁用");
|
||||||
|
|
||||||
|
await page.locator("[data-page-ai-context-scope]").selectOption("options", { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator("[data-page-ai-input]").fill(`请只读取页面设置 ${title}`, { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "running",
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.locator('[data-page-ai-action="stop-run"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "aborted",
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(sessionBodies.some((body) => body.profile === "default"), "创建 session 必须携带 profile");
|
||||||
|
assert(abortBodies.some((body) => body.reason === "page_ai_user_stop"), "停止 run 必须调用 Hermes abort API");
|
||||||
|
assert(runBodies.length > 0, "必须捕获 run 请求");
|
||||||
|
const runBody = runBodies[0];
|
||||||
|
assert.equal(runBody.contextScope, "options", "run 请求必须携带 contextScope");
|
||||||
|
assert.equal(runBody.pageContext.contextScope, "options", "pageContext 必须记录 contextScope");
|
||||||
|
assert.equal(runBody.pageContext.documentBlocks, null, "页面设置 scope 不应携带正文 blocks");
|
||||||
|
assert(runBody.pageContext.pageOptions, "页面设置 scope 必须携带 pageOptions");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
documentId: target.documentId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
runId,
|
||||||
|
settingsUrl: await page.evaluate(() => window.__mnoteHermesSettingsUrl),
|
||||||
|
runContextScope: runBody.contextScope,
|
||||||
|
abortCount: abortBodies.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
eventsRelease.resolve();
|
||||||
|
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -113,6 +113,11 @@ async function main() {
|
|||||||
null,
|
null,
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
);
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector("[data-page-ai-last-tool]")?.textContent || "").includes("mnote.page.get"),
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
const sessionRequest = captured.find((entry) => entry.kind === "session");
|
const sessionRequest = captured.find((entry) => entry.kind === "session");
|
||||||
const runRequest = captured.find((entry) => entry.kind === "run");
|
const runRequest = captured.find((entry) => entry.kind === "run");
|
||||||
|
|||||||
Reference in New Issue
Block a user