Improve local evidence search and AI capabilities
This commit is contained in:
@@ -896,6 +896,7 @@ LiteParseProvider + MinerUProvider
|
|||||||
- Hermes tool manifest 已暴露 `mnote.evidence.search/read/open`,运行时 dispatch 到 evidence route helper。
|
- Hermes tool manifest 已暴露 `mnote.evidence.search/read/open`,运行时 dispatch 到 evidence route helper。
|
||||||
- 2026-06-04 复核补强:`scripts/reasonix-acp-wrapper.mjs` 已注册 `mnote_evidence_search/read/open` 三个 Reasonix ACP 只读工具,并把它们转发到 Rust `mnote.evidence.search/read/open`;wrapper selftest 覆盖 evidence payload 继承 `rootUri` 与 evidence search 只读属性。
|
- 2026-06-04 复核补强:`scripts/reasonix-acp-wrapper.mjs` 已注册 `mnote_evidence_search/read/open` 三个 Reasonix ACP 只读工具,并把它们转发到 Rust `mnote.evidence.search/read/open`;wrapper selftest 覆盖 evidence payload 继承 `rootUri` 与 evidence search 只读属性。
|
||||||
- 2026-06-04 真实服务验证:登录测试账号后,`/api/hermes/client/tools?scope=mnote&profile=reasonix` 已返回 `mnote.evidence.search/read/open`;`/api/hermes/client/skills?runtime=mnote&agentId=reasonix` 已返回启用的 `mnote-document-evidence` skill。
|
- 2026-06-04 真实服务验证:登录测试账号后,`/api/hermes/client/tools?scope=mnote&profile=reasonix` 已返回 `mnote.evidence.search/read/open`;`/api/hermes/client/skills?runtime=mnote&agentId=reasonix` 已返回启用的 `mnote-document-evidence` skill。
|
||||||
|
- 2026-06-04 索引 skill 合并:公开 skill 改为 `mnote-local-index`,合并 evidence 检索与索引管理说明;`mnote-document-evidence` 仅保留为 `mnote.skill.read` 兼容别名,不再作为公开 skill 摘要展示。新增 `mnote.index.status/refresh/update_settings`,Reasonix ACP 对应 `mnote_index_status/refresh/update_settings`。
|
||||||
- 2026-06-04 真 Reasonix ACP 验证:带 `agentId=reasonix`、`contextRefs=[current_page, folder]`、local-folder `rootUri` 发起 `/api/hermes/client/runs`,SSE 中出现 `tool.started/tool.completed`,工具为 `mnote_evidence_search`,返回 `quote="Printer test page"`、`page=1`、`bbox`、`sourceMapPath` 和 `mnote.agent_run_receipt.evidence.v1`。
|
- 2026-06-04 真 Reasonix ACP 验证:带 `agentId=reasonix`、`contextRefs=[current_page, folder]`、local-folder `rootUri` 发起 `/api/hermes/client/runs`,SSE 中出现 `tool.started/tool.completed`,工具为 `mnote_evidence_search`,返回 `quote="Printer test page"`、`page=1`、`bbox`、`sourceMapPath` 和 `mnote.agent_run_receipt.evidence.v1`。
|
||||||
- 2026-06-04 修正:Reasonix wrapper 原先用字符串包含 `"error"` 判断工具失败,导致 `error:null` 的成功结果在 UI/SSE 中被标记成 `tool.failed`;已改为解析 JSON,仅 `ok:false` 或非空 `error` 才标记失败。
|
- 2026-06-04 修正:Reasonix wrapper 原先用字符串包含 `"error"` 判断工具失败,导致 `error:null` 的成功结果在 UI/SSE 中被标记成 `tool.failed`;已改为解析 JSON,仅 `ok:false` 或非空 `error` 才标记失败。
|
||||||
- Skill 约束明确要求回答保留 quote、source 和 openAction,禁止直接读取 `.mnote/index`、OCR sidecar 或自行拼接 URL。
|
- Skill 约束明确要求回答保留 quote、source 和 openAction,禁止直接读取 `.mnote/index`、OCR sidecar 或自行拼接 URL。
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
# 7-47 MNote 公共 capability/plugin 注册表设计 v1
|
||||||
|
|
||||||
|
> 状态:process
|
||||||
|
>
|
||||||
|
> 目标:把当前分裂的 MNote builtin skill、mnote tool manifest、Hermes plugin、Reasonix wrapper 和 Page AI UI 开关收口为同一个“AI 能力”模型。对用户来说 skill / plugin / tool 都是“授予 AI 的能力”,UI 不应暴露实现层分类;实现层再把一个能力映射到说明书、工具、runtime adapter 和权限策略。本轮只整理 MNote 公共能力;Reasonix / Hermes 自带的 skills/plugins 维持现状,不纳入统一注册表迁移范围。
|
||||||
|
|
||||||
|
## 0. 用户口径
|
||||||
|
|
||||||
|
用户不需要理解 skill、plugin、tool 的区别。Page AI 设置中统一展示为“AI 能力”:
|
||||||
|
|
||||||
|
- `当前页读取`
|
||||||
|
- `本地索引与证据检索`
|
||||||
|
- `本地文件编辑`
|
||||||
|
- `思维导图`
|
||||||
|
- `ONLYOFFICE 实时编辑`
|
||||||
|
- `纯聊天`
|
||||||
|
|
||||||
|
每个能力只有一个主开关。展开后可以显示该能力包含的工具、上下文要求和读写权限,但这些是高级详情,不是主概念。
|
||||||
|
|
||||||
|
实现层映射:
|
||||||
|
|
||||||
|
- `skill`:能力说明书,告诉 agent 什么时候用、怎么用。
|
||||||
|
- `tool`:能力的可执行函数,真正落到 Rust runtime / kernel。
|
||||||
|
- `plugin / adapter`:把同一套工具暴露给 Hermes ACP、Reasonix ACP 或其它 agent runtime。
|
||||||
|
- `policy`:决定该能力是否可见、是否启用、是否允许写入。
|
||||||
|
|
||||||
|
后续 UI 文案统一使用“能力”,不再把 MNote builtin skill、Hermes skill、Reasonix skill、mnote tool 作为并列用户入口。
|
||||||
|
|
||||||
|
## 1. 当前盘点
|
||||||
|
|
||||||
|
### 1.1 MNote builtin skills
|
||||||
|
|
||||||
|
当前入口:`rust/crates/mnote-web/src/hermes_tools/skill.rs`
|
||||||
|
|
||||||
|
现有内置 skill:
|
||||||
|
|
||||||
|
- `mnote-current-page`
|
||||||
|
- `mnote-local-index`
|
||||||
|
- `mnote-local-file`
|
||||||
|
- `mnote-onlyoffice-live`
|
||||||
|
- `mnote-mindmap`
|
||||||
|
- `mnote-chat-only`
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- skill 是 Rust 静态注册,正文来自 `skills/*/SKILL.md`。
|
||||||
|
- `/api/hermes/client/skills?runtime=mnote&agentId=...` 通过 `mnote_builtin_skills_payload()` 输出到 UI。
|
||||||
|
- 每个 skill 现在已经带 `toolNames` 和 `requiresContextRefs`,但这些只是弱引用,不是一个正式 capability/plugin 合同。
|
||||||
|
- `mnote.skill.read` 能懒加载正文;旧 `mnote-document-evidence` 已作为兼容别名映射到 `mnote-local-index`。
|
||||||
|
|
||||||
|
### 1.2 MNote tools
|
||||||
|
|
||||||
|
当前入口:`rust/crates/mnote-web/src/hermes_tools/manifest.rs`
|
||||||
|
|
||||||
|
现有工具大类:
|
||||||
|
|
||||||
|
- skill/context:`mnote.skill.read`、`mnote.context.*`
|
||||||
|
- evidence/index:`mnote.evidence.*`、`mnote.index.*`
|
||||||
|
- doc/block/page/artifact:`mnote.doc.*`、`mnote.block.*`、`mnote.page.*`、`mnote.artifact.*`
|
||||||
|
- mindmap:`mnote.mindmap.*`
|
||||||
|
- office/onlyoffice:`mnote.office.*`、`mnote.onlyoffice.*`
|
||||||
|
|
||||||
|
执行入口:
|
||||||
|
|
||||||
|
- `/api/hermes/tools/mnote/manifest`
|
||||||
|
- `/api/hermes/tools/mnote/call`
|
||||||
|
- `execute_mnote_tool_call()` 统一做认证、profile 禁用检查、workspace 校验、capabilityScope 校验、写入 guard、audit、idempotency。
|
||||||
|
|
||||||
|
问题:
|
||||||
|
|
||||||
|
- manifest 只有 plugin 总描述和扁平 tools,没有“哪个 tool 属于哪个公共能力包”的结构。
|
||||||
|
- `/api/hermes/client/tools?scope=mnote&profile=...` 只展示扁平工具列表。
|
||||||
|
- tool 开关写入 Hermes profile 的 `mnote.tools.disabled`,skill 开关写入 MNote SQLite user preference,两个开关域不同步。
|
||||||
|
|
||||||
|
### 1.3 Hermes plugin
|
||||||
|
|
||||||
|
当前本机存在 `/home/lix/.hermes/plugins/mnote/`,包括:
|
||||||
|
|
||||||
|
- `plugin.yaml`
|
||||||
|
- `__init__.py`
|
||||||
|
|
||||||
|
现状:
|
||||||
|
|
||||||
|
- 这是 Hermes 用户插件,不在 mnote repo 内。
|
||||||
|
- `plugin.yaml` 只列出旧批次工具:`mnote_page_get/save/update_title/update_options`、`mnote_doc_*`、`mnote_block_*`、artifact 等。
|
||||||
|
- 没有 `mnote_evidence_*`、`mnote_index_*`、mindmap、onlyoffice live 等新工具。
|
||||||
|
- `__init__.py` 手写 Python wrapper,把 Hermes tool call 转发到 `/api/hermes/tools/mnote/call`。
|
||||||
|
|
||||||
|
问题:
|
||||||
|
|
||||||
|
- Hermes 用户插件和 Rust manifest 已经漂移。
|
||||||
|
- 新增 Rust tool 后不会自动进入 Hermes plugin。
|
||||||
|
- 如果继续维护该插件,必须由 Rust manifest 生成 plugin.yaml / Python adapter,不能手写。
|
||||||
|
|
||||||
|
### 1.4 Reasonix wrapper
|
||||||
|
|
||||||
|
当前入口:`scripts/reasonix-acp-wrapper.mjs`
|
||||||
|
|
||||||
|
现状:
|
||||||
|
|
||||||
|
- 手写 Reasonix ACP 可见工具名,例如 `mnote_evidence_search`、`mnote_index_status`。
|
||||||
|
- 手写 `REASONIX_TOOL_TO_MNOTE_TOOL` 映射到 Rust `mnote.*` 工具。
|
||||||
|
- 手写每个工具的 parameters。
|
||||||
|
|
||||||
|
问题:
|
||||||
|
|
||||||
|
- 与 Rust manifest 重复。
|
||||||
|
- 与 Hermes plugin 重复。
|
||||||
|
- 新增/改名工具必须同步改 wrapper,否则 agent 看得到 skill 也无法调用工具。
|
||||||
|
|
||||||
|
### 1.5 Page AI UI
|
||||||
|
|
||||||
|
当前入口:
|
||||||
|
|
||||||
|
- skills panel:`rust/crates/mnote-web/browser/sidebar-page-ai-skill-runtime.js`
|
||||||
|
- runtime/tools panel:`rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js`
|
||||||
|
- data load/toggle:`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
|
||||||
|
|
||||||
|
现状:
|
||||||
|
|
||||||
|
- Skills 页能展示 MNote builtin skills、Reasonix skills、Hermes profile skills。
|
||||||
|
- Runtime/高级页展示扁平 `mnote tools` 列表。
|
||||||
|
- MNote skill 开关调用 `/api/hermes/client/skills/toggle`,写 SQLite user preference。
|
||||||
|
- Tool 开关调用 `/api/hermes/client/tools/toggle`,写 profile YAML `mnote.tools.disabled`。
|
||||||
|
|
||||||
|
问题:
|
||||||
|
|
||||||
|
- 用户想管理的是“索引能力包”,不是单独 skill 或一堆扁平工具。
|
||||||
|
- 现在 UI 上 skill 和 tool 分两个地方,不能表达“启用索引 skill,同时启用对应工具”。
|
||||||
|
- 无法像公共 skill 一样展示 plugin/capability 包的工具内容、权限、启停状态。
|
||||||
|
|
||||||
|
## 2. 设计判断
|
||||||
|
|
||||||
|
当前应该引入 `MnoteAiCapability` / `MnoteCapabilityPack`,而不是继续把 skill、tool、plugin 分开维护。代码中可叫 capability pack;UI 中只叫“AI 能力”。
|
||||||
|
|
||||||
|
定义:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MnoteCapabilityPack {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub title: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub agent_ids: &'static [&'static str],
|
||||||
|
pub read_only: bool,
|
||||||
|
pub requires_context_refs: &'static [&'static str],
|
||||||
|
pub skill_id: &'static str,
|
||||||
|
pub skill_content: &'static str,
|
||||||
|
pub tool_names: &'static [&'static str],
|
||||||
|
pub ui_kind: &'static str, // capability | chat | compat
|
||||||
|
pub category: &'static str, // mnote, office, resource, chat
|
||||||
|
pub public: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
原则:
|
||||||
|
|
||||||
|
- 一个公共能力包可以包含一个 skill 和多个 tools。
|
||||||
|
- UI 主要展示 capability pack,不再让用户先理解 skill/tool/plugin 三套概念。
|
||||||
|
- agent prompt 仍注入短 skill/capability 摘要,正文仍走 `mnote.skill.read` 懒加载。
|
||||||
|
- tool manifest 仍是执行真相,但每个 tool 必须能反查所属 capability pack。
|
||||||
|
- Reasonix wrapper / Hermes plugin 都从同一份 capability/tool manifest 生成或动态注册。
|
||||||
|
|
||||||
|
## 3. 目标 API
|
||||||
|
|
||||||
|
### 3.1 新增 capability 列表
|
||||||
|
|
||||||
|
`GET /api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=...`
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"runtime": "mnote",
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"name": "mnote",
|
||||||
|
"title": "MNote",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "mnote-local-index",
|
||||||
|
"title": "MNote local index",
|
||||||
|
"description": "Search local documents with evidence locators and manage local index scopes.",
|
||||||
|
"enabled": true,
|
||||||
|
"toggleable": true,
|
||||||
|
"readOnly": false,
|
||||||
|
"skillId": "mnote-local-index",
|
||||||
|
"toolNames": [
|
||||||
|
"mnote.evidence.search",
|
||||||
|
"mnote.evidence.read",
|
||||||
|
"mnote.evidence.open",
|
||||||
|
"mnote.index.status",
|
||||||
|
"mnote.index.refresh",
|
||||||
|
"mnote.index.update_settings"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "mnote.index.update_settings",
|
||||||
|
"kind": "write",
|
||||||
|
"status": "available",
|
||||||
|
"enabled": true,
|
||||||
|
"requiresWritePermission": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requiresContextRefs": ["folder"],
|
||||||
|
"configScope": "user_sqlite+profile_tool_policy"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 新增 capability toggle
|
||||||
|
|
||||||
|
`PUT /api/hermes/client/capabilities/toggle`
|
||||||
|
|
||||||
|
入参:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"runtime": "mnote",
|
||||||
|
"profile": "reasonix",
|
||||||
|
"id": "mnote-local-index",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
行为:
|
||||||
|
|
||||||
|
- 写入 MNote SQLite user preference:`ai.agent.mnote_builtin.skill.{id}.enabled`
|
||||||
|
- 对包内 tools 批量更新 profile `mnote.tools.disabled`
|
||||||
|
- 保留单 tool 开关作为高级功能,但 UI 默认展示包开关。
|
||||||
|
|
||||||
|
### 3.3 manifest 扩展
|
||||||
|
|
||||||
|
`/api/hermes/tools/mnote/manifest` 增加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "mnote-local-index",
|
||||||
|
"skillId": "mnote-local-index",
|
||||||
|
"toolNames": ["mnote.evidence.search", "mnote.index.status"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "mnote.index.status",
|
||||||
|
"capabilityId": "mnote-local-index",
|
||||||
|
"capabilityScope": ["index.read", "evidence.read"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. UI 设计
|
||||||
|
|
||||||
|
### 4.1 Skills 页改为 AI 能力页
|
||||||
|
|
||||||
|
在 Page AI 的 Skills 页中,用户看到的是统一“AI 能力”列表,不再分 skill / plugin / tool:
|
||||||
|
|
||||||
|
- 行标题:`MNote local index`
|
||||||
|
- 副标题:`索引 / 证据检索 · 6 tools · 需要 folder`
|
||||||
|
- 状态 chip:`只读` / `可写` / `部分工具关闭` / `只读上下文不可写`
|
||||||
|
- 主开关:启用/关闭整个能力包
|
||||||
|
- 展开项:列出 tools,显示 read/write、enabled、status
|
||||||
|
|
||||||
|
不新增单独 landing/settings 页;沿用当前 Skills 页即可,避免再散一处入口。
|
||||||
|
|
||||||
|
页面标题建议从 `Skills` 改为 `能力`;内部 source filter 可以保留 `MNote / Hermes / Reasonix`,但展示为能力来源,不作为用户要理解的能力类型。
|
||||||
|
|
||||||
|
### 4.2 Runtime 高级页保留扁平工具
|
||||||
|
|
||||||
|
Runtime 页继续保留 `mnote tools`,但作为高级调试面:
|
||||||
|
|
||||||
|
- 默认按 capability 分组,而不是纯扁平列表。
|
||||||
|
- 单 tool 开关仍保留,用于排查或临时禁用某个写工具。
|
||||||
|
- 若 capability 关闭,包内工具显示 `disabled_by_capability`。
|
||||||
|
|
||||||
|
### 4.3 索引面板与能力包关系
|
||||||
|
|
||||||
|
Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI;Page AI 的 `mnote-local-index` capability 是 agent 能力开关。
|
||||||
|
|
||||||
|
两者职责不同:
|
||||||
|
|
||||||
|
- 索引设置面板:用户手动新增/删除/刷新索引范围。
|
||||||
|
- MNote local index capability:允许 agent 使用工具帮用户查看、新增、刷新、删除索引范围。
|
||||||
|
|
||||||
|
## 5. Runtime 适配
|
||||||
|
|
||||||
|
### 5.1 Reasonix
|
||||||
|
|
||||||
|
短期:
|
||||||
|
|
||||||
|
- 保留 `scripts/reasonix-acp-wrapper.mjs`。
|
||||||
|
- 但 wrapper 启动时请求 `/api/hermes/tools/mnote/manifest`,按 manifest 自动注册工具。
|
||||||
|
- 保留手写 fallback,避免 mnote-web 未启动时 wrapper 不能初始化。
|
||||||
|
|
||||||
|
中期:
|
||||||
|
|
||||||
|
- 删除 `REASONIX_TOOL_TO_MNOTE_TOOL` 手写表。
|
||||||
|
- 工具名转换统一由函数生成:
|
||||||
|
- `mnote.index.status` -> `mnote_index_status`
|
||||||
|
- `mnote.evidence.search` -> `mnote_evidence_search`
|
||||||
|
|
||||||
|
### 5.2 Hermes ACP
|
||||||
|
|
||||||
|
短期:
|
||||||
|
|
||||||
|
- MNote 继续通过 `AcpMnoteToolContext` 把 `availableSkills` 和 capability policy 交给 Hermes ACP。
|
||||||
|
- 如果 Hermes ACP 原生不能消费 Rust manifest 注册工具,则仍依赖 `/home/lix/.hermes/plugins/mnote`。
|
||||||
|
|
||||||
|
中期:
|
||||||
|
|
||||||
|
- 用 Rust manifest 生成 `/home/lix/.hermes/plugins/mnote/plugin.yaml` 和 Python adapter。
|
||||||
|
- 生成内容覆盖当前手写旧插件,确保 Hermes plugin tools 与 Rust manifest 一致。
|
||||||
|
|
||||||
|
长期:
|
||||||
|
|
||||||
|
- Hermes ACP 若支持从 host 动态接收 tools,则不再需要本机 Python plugin,只保留 Rust manifest。
|
||||||
|
|
||||||
|
## 6. 迁移方案
|
||||||
|
|
||||||
|
### Phase A:只加 registry,不改 UI 行为
|
||||||
|
|
||||||
|
- 新增 `hermes_tools/capability.rs`。
|
||||||
|
- 将当前 `SKILLS` 迁到 `CAPABILITY_PACKS`,`skill_summaries_for_agent()` 从 pack 派生。
|
||||||
|
- `manifest()` 给每个 tool 增加 `capabilityId`,并输出 `capabilities`。
|
||||||
|
- 保持 `/client/skills`、`/client/tools` 响应兼容。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- `mnote-local-index` 在 `/client/skills?runtime=mnote` 可见。
|
||||||
|
- `/api/hermes/tools/mnote/manifest` 可看到 `capabilities[]`。
|
||||||
|
- 旧 `mnote.skill.read` 不破。
|
||||||
|
|
||||||
|
### Phase B:UI 展示 capability pack
|
||||||
|
|
||||||
|
- 新增 `/api/hermes/client/capabilities`。
|
||||||
|
- Skills 页对 `runtime=mnote` 优先消费 capabilities。
|
||||||
|
- 能力包行展示 tools 数、read/write、contextRefs。
|
||||||
|
- Runtime 页 tools 按 capability 分组。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Page AI Skills 页中 `MNote local index` 像公共 skill 一样可见。
|
||||||
|
- 展开能看到 `mnote.evidence.*` 和 `mnote.index.*`。
|
||||||
|
- 开关 capability 后,下一次 run 的 `skillPreferences.mnote` 同步变化。
|
||||||
|
|
||||||
|
### Phase C:能力包开关驱动工具开关
|
||||||
|
|
||||||
|
- 新增 `/api/hermes/client/capabilities/toggle`。
|
||||||
|
- 开关包时同步 skill preference 和 profile tool disabled 列表。
|
||||||
|
- `page_ai_capability_policy()` 过滤 disabled capability,且 `execute_mnote_tool_call()` 对 disabled tool 继续硬拒绝。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 关闭 `mnote-local-index` 后 agent 不再看到该 skill 摘要。
|
||||||
|
- 关闭后直接调用 `mnote.index.status` 返回 `mnote_tool_disabled` 或 capability disabled。
|
||||||
|
- 再打开后恢复。
|
||||||
|
|
||||||
|
### Phase D:生成 Reasonix/Hermes adapters
|
||||||
|
|
||||||
|
- Reasonix wrapper 从 manifest 自动注册工具。
|
||||||
|
- Hermes plugin 从 manifest 生成或在启动时同步。
|
||||||
|
- 删除手写工具表漂移。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 新增 Rust tool 后,只改 Rust manifest/capability pack,Reasonix/Hermes UI 与 runtime 均自动出现。
|
||||||
|
- `node scripts/reasonix-acp-wrapper.mjs` selftest 覆盖 manifest 动态注册。
|
||||||
|
- `hermes plugins list` / Hermes tool list 显示与 Rust manifest 一致。
|
||||||
|
|
||||||
|
## 7. 对 mnote-local-index 的落地形态
|
||||||
|
|
||||||
|
`mnote-local-index` 是第一批公共能力包试点:
|
||||||
|
|
||||||
|
- skill:`skills/mnote-local-index/SKILL.md`
|
||||||
|
- tools:
|
||||||
|
- `mnote.evidence.search`
|
||||||
|
- `mnote.evidence.read`
|
||||||
|
- `mnote.evidence.open`
|
||||||
|
- `mnote.index.status`
|
||||||
|
- `mnote.index.refresh`
|
||||||
|
- `mnote.index.update_settings`
|
||||||
|
- requiresContextRefs:`folder`
|
||||||
|
- readOnly:`false`
|
||||||
|
- write guard:只 `mnote.index.update_settings` 写设置;必须 `dryRun/idempotencyKey`;共享只读禁止写。
|
||||||
|
- UI 文案:`索引 / 证据检索 · 可管理索引范围`
|
||||||
|
|
||||||
|
## 8. 非目标
|
||||||
|
|
||||||
|
- 不把索引设置面板挪进 Page AI。
|
||||||
|
- 不让 agent 直接读写 `.mnote/index` 文件。
|
||||||
|
- 不让 Hermes plugin 直接写 Convex 或绕过 Rust runtime。
|
||||||
|
- 不在前端手写工具 schema。
|
||||||
|
- 不把 Reasonix wrapper 作为长期 tool registry 真相。
|
||||||
|
|
||||||
|
## 9. 风险
|
||||||
|
|
||||||
|
- Hermes Python plugin 当前在 `/home/lix/.hermes/plugins/mnote`,不在 repo,自动生成前仍会漂移。
|
||||||
|
- tool 开关现在是 profile 级,skill 开关是用户级;能力包 toggle 需要明确优先级。
|
||||||
|
- `mnote-local-file` 是 agent 原生文件能力指导,当前没有对应 MNote 写工具,放入 capability pack 时需要标记为 `native_agent_tools`,避免误以为 MNote 提供文件 patch tool。
|
||||||
|
- `mnote-chat-only` 不是业务能力包,应保留为 agent mode skill,不参与 tools。
|
||||||
|
|
||||||
|
## 10. 建议执行顺序
|
||||||
|
|
||||||
|
1. 先做 Phase A:Rust `MnoteCapabilityPack` registry,完全兼容现有接口。
|
||||||
|
2. 再做 Phase B:UI 展示 capability pack,保留旧 skills/tools fallback。
|
||||||
|
3. 再做 Phase C:能力包 toggle 批量控制 skill + tools。
|
||||||
|
4. 最后做 Phase D:Reasonix/Hermes adapter 从 manifest 自动生成。
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
# 7-48 [process] Paperless-ngx Reference: Resource Ingestion / Job Ledger / Evidence Index v1
|
||||||
|
|
||||||
|
> 创建时间:2026-06-05
|
||||||
|
>
|
||||||
|
> 当前状态:`PROCESS`
|
||||||
|
>
|
||||||
|
> Owner:07-ai / 03-rust-web / control-plane / 01-tree-first-graph-kernel
|
||||||
|
>
|
||||||
|
> 参考项目:`/mnt/Data1T/mnote/reference-code/paperless-ngx`
|
||||||
|
>
|
||||||
|
> 参考版本:`f56f29111`
|
||||||
|
>
|
||||||
|
> CodeGraph:已单独建立,`712 files / 16,952 nodes / 35,407 edges`
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||||
|
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
|
||||||
|
> - `/mnt/Data1T/mnote/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md`
|
||||||
|
> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md`
|
||||||
|
> - `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
|
||||||
|
|
||||||
|
## 1. 第一结论
|
||||||
|
|
||||||
|
Paperless-ngx 对 MNote 最有价值的不是 Django / Angular / Celery 技术栈,而是三套工程结构:
|
||||||
|
|
||||||
|
1. `PaperlessTask`:所有后台任务有统一账本,能记录来源、状态、耗时、输入、结果和用户是否已确认。
|
||||||
|
2. `consume_file` plugin pipeline:资源导入、预检、解析、OCR、存储、索引、通知按阶段推进,失败可以定位到阶段。
|
||||||
|
3. `Tantivy search backend + sanity checker`:索引有 schema 版本、权限字段、锁、延迟补偿和可重建性;文件与派生物有一致性检查。
|
||||||
|
|
||||||
|
MNote 应把这些模式收口成自己的 `Resource Work Kernel`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- local file / attachment / OCR source / parsed artifact
|
||||||
|
-> ResourceWorkJob control-plane ledger
|
||||||
|
-> ResourceIngestionPipeline stage runner
|
||||||
|
-> sidecar artifact + source-map
|
||||||
|
-> evidence.sqlite / local search projection
|
||||||
|
-> realtime job events
|
||||||
|
-> sanity check / recovery job
|
||||||
|
```
|
||||||
|
|
||||||
|
这不是要引入 paperless-ngx 的运行时。MNote 的数据真相仍然是 local Markdown、附件原文件、resource tree 和 control-plane;索引、OCR Markdown、parse Markdown、source-map 都是可删除重建的派生物。
|
||||||
|
|
||||||
|
## 2. Paperless-ngx 可借鉴点
|
||||||
|
|
||||||
|
### 2.1 统一后台任务账本
|
||||||
|
|
||||||
|
参考路径:
|
||||||
|
|
||||||
|
- `src/documents/models.py`:`PaperlessTask`
|
||||||
|
- `src/documents/signals/handlers.py`:Celery task publish / prerun / postrun / failure handlers
|
||||||
|
- `src-ui/src/app/components/admin/tasks/tasks.component.ts`
|
||||||
|
|
||||||
|
可借鉴点:
|
||||||
|
|
||||||
|
- 任务不是只存在于内存事件或前端状态,而是落库。
|
||||||
|
- 每个任务有 `task_type`、`trigger_source`、`status`、`date_created`、`date_started`、`date_done`、`duration_seconds`、`wait_time_seconds`、`input_data`、`result_data`、`acknowledged`。
|
||||||
|
- 系统任务和用户触发任务统一展示,但保留来源差异。
|
||||||
|
|
||||||
|
MNote 映射:
|
||||||
|
|
||||||
|
- 当前 `JobTicket`、OCR job、index refresh、agent run、OnlyOffice bridge 任务、recovery job 不应继续分散。
|
||||||
|
- 新增 control-plane 表 `resource_work_jobs`,先覆盖 local OCR / evidence parse / index refresh,后续再纳入 agent run 和 recovery job。
|
||||||
|
|
||||||
|
### 2.2 阶段化资源导入管线
|
||||||
|
|
||||||
|
参考路径:
|
||||||
|
|
||||||
|
- `src/documents/tasks.py`:`consume_file`
|
||||||
|
- `src/documents/plugins/base.py`:`ConsumeTaskPlugin`
|
||||||
|
- `src/documents/consumer.py`:解析、OCR、存储、索引、progress
|
||||||
|
- `src/documents/plugins/helpers.py`:`ProgressManager`
|
||||||
|
|
||||||
|
可借鉴点:
|
||||||
|
|
||||||
|
- 导入任务按插件链执行,每个 stage 有 `setup / run / cleanup`。
|
||||||
|
- 阶段状态通过 websocket 通知。
|
||||||
|
- 文件写入、数据库更新、索引更新分层处理,失败时能说明卡在预检、解析、写 sidecar 还是索引。
|
||||||
|
|
||||||
|
MNote 映射:
|
||||||
|
|
||||||
|
- 不引入插件框架泛化;先定义窄的 `ResourceIngestionPipeline`。
|
||||||
|
- stage 只覆盖当前真实需要:`preflight`、`parse_text`、`ocr`、`write_artifact`、`write_source_map`、`refresh_evidence_index`、`broadcast_done`。
|
||||||
|
- 当前 `local_ocr` 里的 stage 字符串和 sidecar 写入逻辑可以作为第一批迁移对象。
|
||||||
|
|
||||||
|
### 2.3 索引生命周期和自愈
|
||||||
|
|
||||||
|
参考路径:
|
||||||
|
|
||||||
|
- `src/documents/search/_backend.py`
|
||||||
|
- `src/documents/search/_schema.py`
|
||||||
|
- `src/documents/search/_query.py`
|
||||||
|
- `src/documents/tasks.py`:`index_document`、`remove_document_from_index`
|
||||||
|
|
||||||
|
可借鉴点:
|
||||||
|
|
||||||
|
- schema version sentinel 决定是否重建。
|
||||||
|
- 写索引用 file lock 和 retry,锁耗尽后排延迟任务,而不是让前台操作失败。
|
||||||
|
- 查询层有权限过滤、autocomplete、highlight、CJK bigram、simple search。
|
||||||
|
|
||||||
|
MNote 映射:
|
||||||
|
|
||||||
|
- `.mnote/index/evidence.sqlite` 当前已存在,但需要更明确的 schema sentinel 和 rebuild reason。
|
||||||
|
- `query_evidence_sqlite_results` 继续作为默认 evidence path;后续补充 autocomplete / CJK / highlight 时仍以 `EvidenceLocator` 为返回真相。
|
||||||
|
- 索引写失败不能悄悄丢失,应写入 `resource_work_jobs` 的 retry-scheduled 状态。
|
||||||
|
|
||||||
|
### 2.4 权限过滤的实时事件
|
||||||
|
|
||||||
|
参考路径:
|
||||||
|
|
||||||
|
- `src/paperless/consumers.py`
|
||||||
|
- `src/documents/plugins/helpers.py`
|
||||||
|
|
||||||
|
可借鉴点:
|
||||||
|
|
||||||
|
- websocket payload 带 owner / visible users / visible groups。
|
||||||
|
- server-side websocket consumer 根据当前用户过滤。
|
||||||
|
|
||||||
|
MNote 映射:
|
||||||
|
|
||||||
|
- local-only 阶段可以先只带 `workspaceId`、`actorId`、`rootUri`、`targetDocumentId`、`grantId`。
|
||||||
|
- 一旦进入 share / team workspace,OCR / index / agent job event 不能只按广播频道粗暴推送。
|
||||||
|
- `AiAccessScope` 和 share grants 应能映射成 job event 可见性字段。
|
||||||
|
|
||||||
|
### 2.5 Sanity checker
|
||||||
|
|
||||||
|
参考路径:
|
||||||
|
|
||||||
|
- `src/documents/sanity_checker.py`
|
||||||
|
- `src/documents/management/commands/document_sanity_checker.py`
|
||||||
|
|
||||||
|
可借鉴点:
|
||||||
|
|
||||||
|
- 独立检查原文件、派生文件、checksum、孤儿文件和空 OCR 内容。
|
||||||
|
- 输出按 error / warning / info 分级,既能 CLI 显示,也能作为后台任务结果。
|
||||||
|
|
||||||
|
MNote 映射:
|
||||||
|
|
||||||
|
- 新增 `workspace_sanity_check`,先检查 local-folder resource evidence:
|
||||||
|
- Markdown owner 是否存在。
|
||||||
|
- 附件路径是否存在且在 allowed root 内。
|
||||||
|
- `{pageStem}.ocr/` sidecar 是否能回到 owner Markdown。
|
||||||
|
- `*.source-map.json` 是否能解析为 `mnote.resource_source_map.v1`。
|
||||||
|
- `evidence.sqlite` 是否能由 sidecar 重建。
|
||||||
|
- evidence locator 的 `openAction` 是否能落回 document / resource tab。
|
||||||
|
|
||||||
|
## 3. 目标架构
|
||||||
|
|
||||||
|
### 3.1 ResourceWorkJob
|
||||||
|
|
||||||
|
新增 control-plane job ledger,不替代 domain event,也不替代 agent run receipt。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ResourceWorkJob {
|
||||||
|
pub job_id: String,
|
||||||
|
pub job_type: ResourceWorkJobType,
|
||||||
|
pub trigger_source: ResourceWorkTriggerSource,
|
||||||
|
pub status: ResourceWorkJobStatus,
|
||||||
|
pub stage: Option<String>,
|
||||||
|
pub progress_current: Option<u32>,
|
||||||
|
pub progress_total: Option<u32>,
|
||||||
|
pub stage_label: Option<String>,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub root_uri: String,
|
||||||
|
pub actor_id: Option<String>,
|
||||||
|
pub target_document_id: Option<String>,
|
||||||
|
pub source_root_relative_path: Option<String>,
|
||||||
|
pub artifact_root_relative_path: Option<String>,
|
||||||
|
pub source_map_root_relative_path: Option<String>,
|
||||||
|
pub input_json: serde_json::Value,
|
||||||
|
pub result_json: Option<serde_json::Value>,
|
||||||
|
pub error_code: Option<String>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub created_at_ms: u128,
|
||||||
|
pub started_at_ms: Option<u128>,
|
||||||
|
pub finished_at_ms: Option<u128>,
|
||||||
|
pub acknowledged_at_ms: Option<u128>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
首批枚举:
|
||||||
|
|
||||||
|
```text
|
||||||
|
job_type:
|
||||||
|
- local_ocr
|
||||||
|
- resource_parse
|
||||||
|
- evidence_index_refresh
|
||||||
|
- evidence_index_rebuild
|
||||||
|
- workspace_sanity_check
|
||||||
|
|
||||||
|
trigger_source:
|
||||||
|
- web_ui
|
||||||
|
- api
|
||||||
|
- watcher
|
||||||
|
- agent_tool
|
||||||
|
- system
|
||||||
|
- recovery
|
||||||
|
|
||||||
|
status:
|
||||||
|
- pending
|
||||||
|
- running
|
||||||
|
- succeeded
|
||||||
|
- failed
|
||||||
|
- retry_scheduled
|
||||||
|
- canceled
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `job_id` 由 MNote 生成,不复用外部 provider task id。
|
||||||
|
- MinerU task id、LiteParse request id、Reasonix run id 只进入 `input_json / result_json`。
|
||||||
|
- `result_json` 必须能存 evidence id、artifact path、source-map path、locator count、index row count。
|
||||||
|
- `stage / progress_current / progress_total / stage_label` 是前台任务中心的稳定合同;不能只塞在 provider 私有 payload 里。
|
||||||
|
- job ledger 是 control-plane 事实,不是用户正文真相。
|
||||||
|
|
||||||
|
### 3.2 ResourceIngestionPipeline
|
||||||
|
|
||||||
|
先实现窄接口,不做任意插件市场:
|
||||||
|
|
||||||
|
```text
|
||||||
|
preflight
|
||||||
|
-> detect_resource_kind
|
||||||
|
-> choose_provider
|
||||||
|
-> parse_or_ocr
|
||||||
|
-> write_artifact
|
||||||
|
-> write_source_map
|
||||||
|
-> refresh_evidence_index
|
||||||
|
-> broadcast_job_event
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider 策略:
|
||||||
|
|
||||||
|
- 文本型 PDF / Office parse 优先走 `LiteParseProvider` 或当前轻量 parser。
|
||||||
|
- 扫描件 / 图片走 `MinerUProvider`。
|
||||||
|
- mock provider 仅用于 smoke,不得在真实功能报告中冒充成功链路。
|
||||||
|
|
||||||
|
阶段状态:
|
||||||
|
|
||||||
|
```text
|
||||||
|
queued
|
||||||
|
preflight
|
||||||
|
parsing
|
||||||
|
ocr_uploading
|
||||||
|
ocr_processing
|
||||||
|
writing_artifact
|
||||||
|
writing_source_map
|
||||||
|
indexing
|
||||||
|
done
|
||||||
|
failed
|
||||||
|
retry_scheduled
|
||||||
|
stale
|
||||||
|
```
|
||||||
|
|
||||||
|
当前 `local_ocr.job.updated` 可兼容保留,但 payload 应逐步包含 `jobId / jobType / stage / workspaceId / rootUri / sourceRootRelativePath / targetDocumentId / artifactPath / sourceMapPath`。
|
||||||
|
|
||||||
|
### 3.3 Evidence Index Lifecycle
|
||||||
|
|
||||||
|
当前 `.mnote/index/evidence.sqlite` 继续作为默认 evidence index。升级点:
|
||||||
|
|
||||||
|
- 增加 `schema_version` 和 `build_settings` sentinel。
|
||||||
|
- 每次 write / refresh 记录 `job_id`。
|
||||||
|
- 索引 row 必须能通过 `source_map_root_relative_path` 回到 canonical artifact。
|
||||||
|
- lock 失败进入 `retry_scheduled` job,不直接吞掉。
|
||||||
|
- watcher 增量刷新失败时,不做前端轮询补偿;排 recovery job 并广播一次明确事件。
|
||||||
|
|
||||||
|
建议最小表:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS evidence_index_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value_json TEXT NOT NULL,
|
||||||
|
updated_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS evidence_index_jobs (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
last_error_code TEXT,
|
||||||
|
last_error_message TEXT,
|
||||||
|
updated_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 control-plane 已存 job,这里只保存 index-local rebuild metadata,不重复完整 job 账本。
|
||||||
|
|
||||||
|
### 3.4 Workspace Sanity Check
|
||||||
|
|
||||||
|
新增只读检查,不自动删除、不自动修复。
|
||||||
|
|
||||||
|
检查项:
|
||||||
|
|
||||||
|
| 级别 | 检查 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| error | owner Markdown 不存在 | evidence locator 无法打开 |
|
||||||
|
| error | resource file 不存在 | 附件或 OCR source 丢失 |
|
||||||
|
| error | source-map JSON 无法解析 | 定位真相损坏 |
|
||||||
|
| error | evidence.sqlite schema 不匹配 | 需要 rebuild |
|
||||||
|
| warning | sidecar 孤儿文件 | 有 OCR/parse artifact 但找不到 owner link |
|
||||||
|
| warning | source hash / mtime stale | 可重建,但不阻断 |
|
||||||
|
| info | parse/OCR 内容为空 | 允许,但要可见 |
|
||||||
|
|
||||||
|
输出既可作为 API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/workspaces/sanity/check
|
||||||
|
GET /api/work/jobs/:jobId
|
||||||
|
```
|
||||||
|
|
||||||
|
也可作为 smoke / CLI helper 的 JSON 结果。
|
||||||
|
|
||||||
|
### 3.5 Unified Task Foreground UI
|
||||||
|
|
||||||
|
统一后台任务必须有统一前台可见入口。否则用户仍然只能在 OCR 设置、索引设置、toast、局部状态灯之间猜系统是否还在运行。
|
||||||
|
|
||||||
|
当前已有的 `mnote-local-ocr-task-dock` 是迁移起点,不是长期终点。它应升级为全局 `Work Task Center`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Topbar task button
|
||||||
|
-> badge: active / failed / needs attention count
|
||||||
|
-> drawer: current jobs + recent completed + failed
|
||||||
|
-> row: icon + title + stage label + progress bar + actions
|
||||||
|
-> detail: input/result/error + related document/resource + trace/run receipt
|
||||||
|
```
|
||||||
|
|
||||||
|
入口位置:
|
||||||
|
|
||||||
|
- 顶栏保留一个任务中心图标,建议使用 `progress_activity` 或 `pending_actions`。
|
||||||
|
- OCR 和索引设置按钮仍可保留,但它们不再各自承载任务列表。
|
||||||
|
- 任务中心抽屉优先靠右打开,保持轻量;不做全屏管理台作为第一阶段。
|
||||||
|
|
||||||
|
任务行最小字段:
|
||||||
|
|
||||||
|
| 字段 | 来源 | UI 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `jobId` | job ledger | 稳定 row key 和详情查询 |
|
||||||
|
| `jobType` | job ledger | 图标、分类、筛选 |
|
||||||
|
| `status` | job ledger | 颜色、分组、是否需要确认 |
|
||||||
|
| `stageLabel` | job event | 当前阶段文案 |
|
||||||
|
| `progressCurrent / progressTotal` | job event | 确定性进度条 |
|
||||||
|
| `createdAtMs / startedAtMs / finishedAtMs` | job ledger | 排队/耗时/最近完成 |
|
||||||
|
| `targetDocumentId` | job ledger | 打开 owner 文档 |
|
||||||
|
| `sourceRootRelativePath` | job ledger | 打开附件或定位资源 |
|
||||||
|
| `artifactRootRelativePath / sourceMapRootRelativePath` | result | 打开 OCR/parse/source-map |
|
||||||
|
| `errorCode / errorMessage` | job ledger | 失败摘要和复现证据 |
|
||||||
|
|
||||||
|
进度规则:
|
||||||
|
|
||||||
|
- 有 `progressCurrent / progressTotal` 时显示确定性进度条。
|
||||||
|
- 没有总量但 status 为 running 时显示细条 indeterminate,不伪造百分比。
|
||||||
|
- `queued / retry_scheduled` 显示排队态,不显示假进度。
|
||||||
|
- `failed` 和 `retry_scheduled` 必须进入“需要处理”计数。
|
||||||
|
- `succeeded` 默认保留在最近完成列表,可由用户清除/ack。
|
||||||
|
|
||||||
|
任务动作:
|
||||||
|
|
||||||
|
| 状态 | 动作 |
|
||||||
|
| --- | --- |
|
||||||
|
| running | 打开目标、查看详情 |
|
||||||
|
| succeeded | 打开结果、打开目标、清除 |
|
||||||
|
| failed | 查看错误、重试、打开目标、复制错误摘要 |
|
||||||
|
| retry_scheduled | 查看重试原因、立即重试、取消重试 |
|
||||||
|
| stale | 重新生成、打开旧结果 |
|
||||||
|
|
||||||
|
事件与数据流:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/work/jobs?scope=currentWorkspace&active=true
|
||||||
|
GET /api/work/jobs?scope=currentWorkspace&recent=true
|
||||||
|
GET /api/work/jobs/:jobId
|
||||||
|
POST /api/work/jobs/:jobId/ack
|
||||||
|
POST /api/work/jobs/:jobId/retry
|
||||||
|
SSE/WS event: resource_work.job.updated
|
||||||
|
```
|
||||||
|
|
||||||
|
前端运行时:
|
||||||
|
|
||||||
|
- 新增 `browser/work-task-center-runtime.js`,由 `layout.rs` 统一注入。
|
||||||
|
- 现有 `document-resource-tab-runtime.js` 中 OCR task dock 的 state/render 逻辑迁入该 runtime。
|
||||||
|
- `mnote:local-ocr-job-updated` 作为兼容事件继续转发为 `resource_work.job.updated`,直到后端统一 payload 完成。
|
||||||
|
- 前端只在启动时拉一次 active/recent snapshot;后续靠 WS/SSE 事件更新,不新增周期轮询。
|
||||||
|
|
||||||
|
视觉约束:
|
||||||
|
|
||||||
|
- 顶栏只显示一个任务中心 badge,避免 OCR/索引/AI 各自占顶栏状态位。
|
||||||
|
- 抽屉行要密集、可扫描,不能做大卡片堆叠。
|
||||||
|
- 每行必须有可见进度或阶段文本,长路径截断但 title 保留完整路径 tooltip。
|
||||||
|
- 移动端抽屉宽度占满可用宽度,任务行按钮折到第二行,避免文字溢出。
|
||||||
|
|
||||||
|
## 4. 不做什么
|
||||||
|
|
||||||
|
- 不把 paperless-ngx 的 Django model / Angular UI / Celery worker 引入 MNote。
|
||||||
|
- 不把 OCR Markdown、parse Markdown 或 evidence.sqlite 变成正文真相。
|
||||||
|
- 不新增前端轮询来弥补 job 状态;状态更新走现有 WS / SSE / watcher event。
|
||||||
|
- 不把任务 UI 继续拆成 OCR 一套、索引一套、AI 一套;这些只能是任务中心里的分类或过滤。
|
||||||
|
- 不让 workflow UI 先行。先做 event-triggered job 和少量内置动作,再考虑可视化配置。
|
||||||
|
- 不在本轮实现向量 RAG。Paperless 的 FAISS append-only 方案只作为反例和参考,不作为 MNote 默认路径。
|
||||||
|
|
||||||
|
## 5. 实施分期
|
||||||
|
|
||||||
|
### Phase A:设计冻结和接口补齐
|
||||||
|
|
||||||
|
Owner:07-ai / 03-rust-web
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- [ ] 在 `core-protocol` 增加 `ResourceWorkJob` / `ResourceWorkJobStatus` / `ResourceWorkTriggerSource` 合同。
|
||||||
|
- [ ] 在 job 合同中加入 `stage / stageLabel / progressCurrent / progressTotal`,作为前台任务中心稳定字段。
|
||||||
|
- [ ] 明确 `local_ocr.job.updated` 与新 `resource_work.job.updated` 的兼容关系。
|
||||||
|
- [ ] 在 `7-46` 里引用本设计作为 job / index lifecycle 的执行补充。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- [ ] Rust unit 覆盖 job status 序列化。
|
||||||
|
- [ ] Rust unit 覆盖 progress 字段缺省、确定性进度和 indeterminate 语义。
|
||||||
|
- [ ] 不改变现有 OCR smoke 行为。
|
||||||
|
|
||||||
|
### Phase B:OCR job ledger 收口
|
||||||
|
|
||||||
|
Owner:03-rust-web / control-plane
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- [ ] 给 `local_ocr::create_job` 创建 control-plane job 记录。
|
||||||
|
- [ ] 每次 stage advance 同步 job ledger。
|
||||||
|
- [ ] `done / failed` 写入 `finished_at_ms / result_json / error_code`。
|
||||||
|
- [ ] 增加 `GET /api/work/jobs/:jobId`。
|
||||||
|
- [ ] 增加 `GET /api/work/jobs?active=true&recent=true`,供任务中心首屏 snapshot 使用。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- [ ] 现有 OCR sidecar smoke 仍通过。
|
||||||
|
- [ ] 新增 smoke 验证 OCR job 可查询、失败错误脱敏、done 后 result 包含 sidecar 和 source-map。
|
||||||
|
- [ ] 新增 smoke 验证 active snapshot 包含运行中 OCR job,完成后转入 recent。
|
||||||
|
|
||||||
|
### Phase B2:统一任务中心 UI
|
||||||
|
|
||||||
|
Owner:03-rust-web / 05-editor-mainline
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- [ ] 新增 `browser/work-task-center-runtime.js`。
|
||||||
|
- [ ] 将 `mnote-local-ocr-task-dock` 的状态聚合和 drawer 渲染迁移到 work task center。
|
||||||
|
- [ ] 顶栏新增统一任务中心按钮和 badge,OCR 按钮回归 OCR 设置入口或合并进设置面板。
|
||||||
|
- [ ] 支持 active / needs attention / recent 三个分组。
|
||||||
|
- [ ] 任务行支持确定性 progress bar、indeterminate running bar、失败重试、打开目标、打开结果、ack。
|
||||||
|
- [ ] 兼容接收 `mnote:local-ocr-job-updated`,并在后端统一事件上线后接 `resource_work.job.updated`。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- [ ] 浏览器 smoke:触发 OCR 后顶栏 badge 从 0 变 1,抽屉显示阶段和进度。
|
||||||
|
- [ ] 浏览器 smoke:OCR 完成后任务移入 recent,能打开 OCR sidecar。
|
||||||
|
- [ ] 浏览器 smoke:mock 失败任务进入 needs attention,能查看错误和重试。
|
||||||
|
- [ ] 截图验证桌面和移动端抽屉不溢出、不遮挡主编辑区关键内容。
|
||||||
|
|
||||||
|
### Phase C:Evidence index lifecycle
|
||||||
|
|
||||||
|
Owner:07-ai / 03-rust-web
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- [ ] 给 evidence.sqlite 增加 schema/settings sentinel。
|
||||||
|
- [ ] index refresh 写入 job id 或 index-local job metadata。
|
||||||
|
- [ ] lock / write / parse source-map 失败时进入 retry-scheduled 或 failed job。
|
||||||
|
- [ ] `mnote.index.status` 返回 schema、last build、last job、pending retry。
|
||||||
|
- [ ] index refresh / rebuild 通过任务中心显示阶段和结果摘要。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- [ ] `task528-document-evidence-liteparse-agent-smoke.js` 继续通过。
|
||||||
|
- [ ] 新增 index stale / rebuild smoke,验证删除 evidence.sqlite 后可通过 job 重建。
|
||||||
|
- [ ] 浏览器 smoke 验证 index rebuild 任务出现在任务中心。
|
||||||
|
|
||||||
|
### Phase D:Workspace sanity check
|
||||||
|
|
||||||
|
Owner:03-rust-web / 04-tree-domain
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- [ ] 实现只读 `workspace_sanity_check` job。
|
||||||
|
- [ ] 检查 owner Markdown、resource file、sidecar、source-map、evidence.sqlite。
|
||||||
|
- [ ] 结果按 error / warning / info 分级。
|
||||||
|
- [ ] 从任务中心和 index/OCR 设置 surface 都能启动检查;结果统一进入任务中心详情。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- [ ] smoke 构造缺失 source-map、孤儿 sidecar、损坏 sqlite,能得到稳定 JSON。
|
||||||
|
- [ ] 不删除、不移动任何用户文件。
|
||||||
|
- [ ] 浏览器 smoke 验证 sanity check 运行时有任务行,完成后详情展示 error / warning / info 摘要。
|
||||||
|
|
||||||
|
### Phase E:内置 workflow actions
|
||||||
|
|
||||||
|
Owner:01-tree-first-graph-kernel / 07-ai
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- [ ] 定义内置事件:`resource.created`、`resource.changed`、`ocr.done`、`evidence.index.failed`。
|
||||||
|
- [ ] 定义内置动作:`parse_resource`、`run_ocr`、`refresh_evidence_index`、`schedule_sanity_check`。
|
||||||
|
- [ ] 先用静态配置或 settings 控制,不做复杂 UI。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- [ ] 新增附件后能按 settings 自动排 parse/OCR/index job。
|
||||||
|
- [ ] 失败不会循环重试;必须有 retry budget 和可见错误。
|
||||||
|
|
||||||
|
## 6. 设计验收基线
|
||||||
|
|
||||||
|
本设计完成后,MNote 应具备以下能力:
|
||||||
|
|
||||||
|
- 用户能看到后台 OCR / parse / index / sanity job 的真实状态,而不是只看到散落的 toast 或局部状态灯。
|
||||||
|
- 顶栏任务中心能显示 active / failed / recent 任务,且所有任务进度来自 job ledger 或 job event,不伪造百分比。
|
||||||
|
- agent 调用 evidence 工具时,MNote 能把本次证据索引状态、evidence ids、source-map path 写入 run receipt。
|
||||||
|
- 删除或损坏 evidence.sqlite 不会让系统静默退化;会显示 rebuild required 或排 rebuild job。
|
||||||
|
- OCR sidecar、parse artifact、source-map 和 evidence locator 可以被 sanity check 证明互相可追溯。
|
||||||
|
- 多人 / share 场景下,job event 有明确可见性字段,不把本地单用户广播模型固化成长期事实。
|
||||||
|
|
||||||
|
## 7. 参考代码索引
|
||||||
|
|
||||||
|
Paperless-ngx:
|
||||||
|
|
||||||
|
- `src/documents/models.py`:`PaperlessTask`、workflow model、document version fields。
|
||||||
|
- `src/documents/tasks.py`:`consume_file`、index deferred tasks、bulk update。
|
||||||
|
- `src/documents/consumer.py`:resource consume main path。
|
||||||
|
- `src/documents/plugins/base.py`:plugin lifecycle contract。
|
||||||
|
- `src/documents/plugins/helpers.py`:progress websocket payload。
|
||||||
|
- `src/documents/search/_backend.py`:Tantivy backend、lock retry、autocomplete、highlight。
|
||||||
|
- `src/documents/search/_schema.py`:schema version sentinel。
|
||||||
|
- `src/documents/search/_query.py`:permission filter、date rewrite、CJK/simple query。
|
||||||
|
- `src/documents/sanity_checker.py`:archive consistency checker。
|
||||||
|
- `src/paperless/consumers.py`:permission-aware websocket consumer。
|
||||||
|
|
||||||
|
MNote 当前落点:
|
||||||
|
|
||||||
|
- `rust/crates/core-protocol/src/governance.rs`
|
||||||
|
- `rust/crates/core-protocol/src/evidence.rs`
|
||||||
|
- `rust/crates/core-protocol/src/tool.rs`
|
||||||
|
- `rust/crates/mnote-web/src/routes/local_ocr.rs`
|
||||||
|
- `rust/crates/mnote-web/src/routes/local_search_index.rs`
|
||||||
|
- `rust/crates/mnote-web/src/routes/evidence.rs`
|
||||||
|
- `rust/crates/mnote-web/src/routes/local_folder_events.rs`
|
||||||
|
- `rust/crates/mnote-web/src/routes/ws.rs`
|
||||||
|
- `scripts/task528-document-evidence-liteparse-agent-smoke.js`
|
||||||
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev-hot.js",
|
"dev": "node scripts/dev-hot.js",
|
||||||
"desktop:hot": "node scripts/desktop-hot.js",
|
"desktop:hot": "node scripts/desktop-hot.js",
|
||||||
"dev:hot": "node scripts/dev-hot.js"
|
"dev:hot": "node scripts/dev-hot.js",
|
||||||
|
"prod:start": "node scripts/prod-build-start.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
|
|||||||
@@ -234,6 +234,12 @@ pub struct EvidenceSearchResult {
|
|||||||
pub quote: String,
|
pub quote: String,
|
||||||
pub score: f64,
|
pub score: f64,
|
||||||
pub source: EvidenceLocator,
|
pub source: EvidenceLocator,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub citation_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub citation_label: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub citation_markdown: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
|||||||
@@ -143,7 +143,8 @@ pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
|||||||
pub const EVIDENCE_TOOL_SEARCH: ToolSpec = ToolSpec {
|
pub const EVIDENCE_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||||
name: "mnote.evidence.search",
|
name: "mnote.evidence.search",
|
||||||
display_name: "证据搜索",
|
display_name: "证据搜索",
|
||||||
description: "在工作区内搜索可回跳原文的证据块,返回 quote、locator 与 openAction。",
|
description:
|
||||||
|
"在工作区内搜索可回跳原文的证据块,返回 quote、locator、openAction 与 citationMarkdown。",
|
||||||
toolset_id: "toolset.evidence_read",
|
toolset_id: "toolset.evidence_read",
|
||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
@@ -154,7 +155,8 @@ pub const EVIDENCE_TOOL_SEARCH: ToolSpec = ToolSpec {
|
|||||||
pub const EVIDENCE_TOOL_READ: ToolSpec = ToolSpec {
|
pub const EVIDENCE_TOOL_READ: ToolSpec = ToolSpec {
|
||||||
name: "mnote.evidence.read",
|
name: "mnote.evidence.read",
|
||||||
display_name: "证据读回",
|
display_name: "证据读回",
|
||||||
description: "按 EvidenceLocator 读取原文证据及周边上下文,供 AI 回答引用。",
|
description:
|
||||||
|
"按 EvidenceLocator 读取原文证据及周边上下文,供 AI 回答引用,并返回可点击引用链接。",
|
||||||
toolset_id: "toolset.evidence_read",
|
toolset_id: "toolset.evidence_read",
|
||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
@@ -165,7 +167,7 @@ pub const EVIDENCE_TOOL_READ: ToolSpec = ToolSpec {
|
|||||||
pub const EVIDENCE_TOOL_OPEN: ToolSpec = ToolSpec {
|
pub const EVIDENCE_TOOL_OPEN: ToolSpec = ToolSpec {
|
||||||
name: "mnote.evidence.open",
|
name: "mnote.evidence.open",
|
||||||
display_name: "证据打开",
|
display_name: "证据打开",
|
||||||
description: "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。",
|
description: "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作,并返回 citationUrl/citationMarkdown。",
|
||||||
toolset_id: "toolset.evidence_read",
|
toolset_id: "toolset.evidence_read",
|
||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
|
|||||||
@@ -267,7 +267,15 @@ import {
|
|||||||
root.setAttribute('data-mnote-evidence-open', 'true');
|
root.setAttribute('data-mnote-evidence-open', 'true');
|
||||||
if (blockId) root.setAttribute('data-mnote-evidence-block-id', blockId);
|
if (blockId) root.setAttribute('data-mnote-evidence-block-id', blockId);
|
||||||
if (lineRange) root.setAttribute('data-mnote-evidence-line-range', lineRange);
|
if (lineRange) root.setAttribute('data-mnote-evidence-line-range', lineRange);
|
||||||
const target = blockId ? root.querySelector(`[data-block-id="${cssSafe(blockId)}"]`) : null;
|
let target = blockId ? root.querySelector(`[data-block-id="${cssSafe(blockId)}"]`) : null;
|
||||||
|
if (!(target instanceof HTMLElement) && lineRange) {
|
||||||
|
const start = Number(String(lineRange).split('-')[0] || 0);
|
||||||
|
if (Number.isFinite(start) && start > 0) {
|
||||||
|
const blocks = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]'))
|
||||||
|
.filter((node) => node instanceof HTMLElement);
|
||||||
|
target = blocks[Math.max(0, Math.min(blocks.length - 1, start - 1))] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (target instanceof HTMLElement) {
|
if (target instanceof HTMLElement) {
|
||||||
root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => {
|
root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => {
|
||||||
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight');
|
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight');
|
||||||
@@ -302,6 +310,12 @@ import {
|
|||||||
title,
|
title,
|
||||||
fileName: title,
|
fileName: title,
|
||||||
openTarget: 'active-tab',
|
openTarget: 'active-tab',
|
||||||
|
page: url.searchParams.get('page') || undefined,
|
||||||
|
bbox: url.searchParams.get('bbox') || undefined,
|
||||||
|
sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(),
|
||||||
|
blockId: String(url.searchParams.get('blockId') || '').trim(),
|
||||||
|
lineRange: url.searchParams.get('lineRange') || null,
|
||||||
|
charRange: url.searchParams.get('charRange') || null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -418,6 +418,21 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const officeOpenModeForEntry = (entry) => {
|
||||||
|
if (!entry || normalizeResourceTabKind(entry) !== 'office') return '';
|
||||||
|
const href = String(entry.passiveFrameSrc || entry.officeUrl || entry.href || '').trim()
|
||||||
|
|| String(entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame')?.getAttribute?.('src') || '').trim();
|
||||||
|
if (href) {
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.origin);
|
||||||
|
if (url.pathname === '/office-preview' || url.pathname === '/office-n') return 'preview';
|
||||||
|
if (url.pathname === '/onlyoffice' || url.pathname.startsWith('/office/')) return 'onlyoffice_live';
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (entry.onlyofficeSessionId || entry.bridgeSessionId) return 'onlyoffice_live';
|
||||||
|
return 'preview';
|
||||||
|
};
|
||||||
|
|
||||||
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
|
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
|
||||||
const active = entry?.tab instanceof HTMLElement
|
const active = entry?.tab instanceof HTMLElement
|
||||||
? entry.tab.getAttribute('aria-selected') === 'true'
|
? entry.tab.getAttribute('aria-selected') === 'true'
|
||||||
@@ -432,15 +447,23 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const kind = normalizeResourceTabKind(entry);
|
const kind = normalizeResourceTabKind(entry);
|
||||||
const dirtyState = resourceTabCloseGuardReason(entry?.session);
|
const dirtyState = resourceTabCloseGuardReason(entry?.session);
|
||||||
const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null;
|
const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null;
|
||||||
|
const officeOpenMode = kind === 'office' ? officeOpenModeForEntry(entry) : '';
|
||||||
const onlyofficeSessionId = String(
|
const onlyofficeSessionId = String(
|
||||||
officeBridgeDebug?.bridgeSessionId
|
officeBridgeDebug?.bridgeSessionId
|
||||||
|| entry?.onlyofficeSessionId
|
|| entry?.onlyofficeSessionId
|
||||||
|| entry?.bridgeSessionId
|
|| entry?.bridgeSessionId
|
||||||
|| '',
|
|| '',
|
||||||
).trim();
|
).trim();
|
||||||
|
const snapshotResourceKind = kind === 'office'
|
||||||
|
? (officeOpenMode === 'onlyoffice_live' || onlyofficeSessionId ? 'only_office' : 'attachment')
|
||||||
|
: kind;
|
||||||
|
const workspacePathSeed = entry?.workspacePath && typeof entry.workspacePath === 'object'
|
||||||
|
? { ...entry.workspacePath, resourceKind: snapshotResourceKind }
|
||||||
|
: entry?.workspacePath;
|
||||||
return {
|
return {
|
||||||
objectIdentity,
|
objectIdentity,
|
||||||
workspacePath: buildWorkspacePath({
|
workspacePath: buildWorkspacePath({
|
||||||
|
workspacePath: workspacePathSeed,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
sourceKind,
|
sourceKind,
|
||||||
rootUri,
|
rootUri,
|
||||||
@@ -448,8 +471,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
documentId,
|
documentId,
|
||||||
objectIdentity,
|
objectIdentity,
|
||||||
assetId,
|
assetId,
|
||||||
resourceKind: kind,
|
resourceKind: snapshotResourceKind,
|
||||||
workspacePath: entry?.workspacePath,
|
|
||||||
}),
|
}),
|
||||||
paneRole: normalizePaneRole(entry?.paneRole),
|
paneRole: normalizePaneRole(entry?.paneRole),
|
||||||
documentId,
|
documentId,
|
||||||
@@ -465,13 +487,14 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
dirtyGuard: dirtyState,
|
dirtyGuard: dirtyState,
|
||||||
assetId,
|
assetId,
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
|
officeOpenMode,
|
||||||
onlyofficeSessionId,
|
onlyofficeSessionId,
|
||||||
bridgeSessionId: onlyofficeSessionId,
|
bridgeSessionId: onlyofficeSessionId,
|
||||||
bridgeSessionReady: Boolean(onlyofficeSessionId),
|
bridgeSessionReady: Boolean(onlyofficeSessionId),
|
||||||
bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(),
|
bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(),
|
||||||
bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(),
|
bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(),
|
||||||
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
|
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
|
||||||
preview: false,
|
preview: officeOpenMode === 'preview',
|
||||||
pinned: false,
|
pinned: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -848,6 +871,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
panel.setAttribute('data-mnote-object-identity', objectIdentity);
|
panel.setAttribute('data-mnote-object-identity', objectIdentity);
|
||||||
panel.setAttribute('data-pane-role', paneRole);
|
panel.setAttribute('data-pane-role', paneRole);
|
||||||
panel.setAttribute('data-resource-kind', kind);
|
panel.setAttribute('data-resource-kind', kind);
|
||||||
|
panel.setAttribute('data-resource-path', String(input.path || '').trim());
|
||||||
panel.hidden = true;
|
panel.hidden = true;
|
||||||
nodes.strip.append(tab);
|
nodes.strip.append(tab);
|
||||||
nodes.panelRoot.append(panel);
|
nodes.panelRoot.append(panel);
|
||||||
@@ -989,6 +1013,16 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
if (bbox) url.searchParams.set('bbox', bbox);
|
if (bbox) url.searchParams.set('bbox', bbox);
|
||||||
if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath));
|
if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath));
|
||||||
if (locator.blockId) url.searchParams.set('blockId', String(locator.blockId));
|
if (locator.blockId) url.searchParams.set('blockId', String(locator.blockId));
|
||||||
|
if (url.pathname === '/office-preview' && frame.contentWindow) {
|
||||||
|
frame.contentWindow.postMessage({
|
||||||
|
type: 'mnote:office-evidence-locator',
|
||||||
|
page: locator.page,
|
||||||
|
bbox,
|
||||||
|
sourceMapPath: locator.sourceMapPath || '',
|
||||||
|
blockId: locator.blockId || '',
|
||||||
|
}, window.location.origin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
frame.src = url.toString();
|
frame.src = url.toString();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
@@ -1015,11 +1049,22 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyEvidenceLocatorToTextPanel = (entry, locator) => {
|
const applyEvidenceLocatorToTextPanel = (entry, locator) => {
|
||||||
if (!(entry?.panel instanceof HTMLElement) || !locator?.blockId) return;
|
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
|
||||||
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||||
if (!(root instanceof HTMLElement)) return;
|
if (!(root instanceof HTMLElement)) return;
|
||||||
const selector = `[data-block-id="${cssSafe(locator.blockId)}"]`;
|
const selector = locator.blockId ? `[data-block-id="${cssSafe(locator.blockId)}"]` : '';
|
||||||
const target = root.querySelector(selector);
|
let target = selector ? root.querySelector(selector) : null;
|
||||||
|
const query = String(locator?.openAction?.params?.query || locator?.query || '').trim();
|
||||||
|
if (!(target instanceof HTMLElement) && query) {
|
||||||
|
target = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]'))
|
||||||
|
.find((node) => node instanceof HTMLElement && String(node.textContent || '').includes(query)) || null;
|
||||||
|
}
|
||||||
|
const lineStart = Number(locator?.lineRange?.start ?? locator?.line_range?.start ?? 0);
|
||||||
|
if (!(target instanceof HTMLElement) && Number.isFinite(lineStart) && lineStart > 0) {
|
||||||
|
const blocks = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]'))
|
||||||
|
.filter((node) => node instanceof HTMLElement);
|
||||||
|
target = blocks[Math.max(0, Math.min(blocks.length - 1, lineStart - 1))] || null;
|
||||||
|
}
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => {
|
root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => {
|
||||||
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight');
|
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight');
|
||||||
@@ -1364,6 +1409,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
rootUri: '',
|
rootUri: '',
|
||||||
jobsBySource: new Map(),
|
jobsBySource: new Map(),
|
||||||
drawerOpen: false,
|
drawerOpen: false,
|
||||||
|
taskFilter: 'active',
|
||||||
eventSource: null,
|
eventSource: null,
|
||||||
fileTreeRefreshKeys: new Set(),
|
fileTreeRefreshKeys: new Set(),
|
||||||
};
|
};
|
||||||
@@ -1467,13 +1513,36 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
: status || '未知';
|
: status || '未知';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const localOcrTaskCategory = (job) => {
|
||||||
|
const status = String(job?.status || '').trim();
|
||||||
|
if (['failed', 'stale', 'retry_scheduled'].includes(status)) return 'attention';
|
||||||
|
if (['done', 'succeeded', 'success'].includes(status)) return 'completed';
|
||||||
|
return 'active';
|
||||||
|
};
|
||||||
|
|
||||||
|
const localOcrTaskProgress = (job) => {
|
||||||
|
const current = Number(job?.progressCurrent ?? job?.currentProgress);
|
||||||
|
const total = Number(job?.progressTotal ?? job?.maxProgress);
|
||||||
|
if (Number.isFinite(current) && Number.isFinite(total) && total > 0) {
|
||||||
|
return Math.max(0, Math.min(100, Math.round((current / total) * 100)));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const localOcrTaskFilterLabel = (filter) => {
|
||||||
|
return filter === 'active' ? '进行中'
|
||||||
|
: filter === 'completed' ? '已完成'
|
||||||
|
: filter === 'attention' ? '需处理'
|
||||||
|
: '全部';
|
||||||
|
};
|
||||||
|
|
||||||
const ensureLocalOcrTaskDock = () => {
|
const ensureLocalOcrTaskDock = () => {
|
||||||
let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]');
|
let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]');
|
||||||
if (!(dock instanceof HTMLElement)) {
|
if (!(dock instanceof HTMLElement)) {
|
||||||
dock = document.createElement('section');
|
dock = document.createElement('section');
|
||||||
dock.className = 'mnote-local-ocr-task-dock';
|
dock.className = 'mnote-local-ocr-task-dock';
|
||||||
dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock');
|
dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock');
|
||||||
dock.innerHTML = '<div class="mnote-local-ocr-task-drawer" data-testid="mnote-local-ocr-task-drawer" hidden><div class="mnote-local-ocr-task-head"><strong>OCR 任务</strong><button type="button" class="mnote-local-ocr-task-close" data-mnote-local-ocr-task-close aria-label="收起 OCR 任务">×</button></div><div class="mnote-local-ocr-task-list" data-testid="mnote-local-ocr-task-list"></div></div>';
|
dock.innerHTML = '<div class="mnote-local-ocr-task-drawer" data-testid="mnote-local-ocr-task-drawer" hidden><div class="mnote-local-ocr-task-panel"><div class="mnote-local-ocr-task-head"><div><strong>后台任务</strong><span data-mnote-local-ocr-task-summary>暂无任务</span></div><button type="button" class="mnote-local-ocr-task-close" data-mnote-local-ocr-task-close aria-label="收起后台任务">×</button></div><div class="mnote-local-ocr-task-tabs" data-mnote-local-ocr-task-tabs></div><div class="mnote-local-ocr-task-toolbar"><span data-mnote-local-ocr-task-filter-label>进行中</span><button type="button" data-mnote-local-ocr-task-clear-completed>清除已完成</button></div><div class="mnote-local-ocr-task-list" data-testid="mnote-local-ocr-task-list"></div></div></div>';
|
||||||
document.body.appendChild(dock);
|
document.body.appendChild(dock);
|
||||||
}
|
}
|
||||||
let toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
|
let toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
|
||||||
@@ -1509,6 +1578,20 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const tabButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-tab]') : null;
|
||||||
|
if (tabButton instanceof HTMLElement) {
|
||||||
|
localOcrTaskState.taskFilter = tabButton.getAttribute('data-mnote-local-ocr-task-tab') || 'active';
|
||||||
|
renderLocalOcrTaskDock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clearCompleted = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-clear-completed]') : null;
|
||||||
|
if (clearCompleted instanceof HTMLElement) {
|
||||||
|
Array.from(localOcrTaskState.jobsBySource.entries()).forEach(([key, job]) => {
|
||||||
|
if (localOcrTaskCategory(job) === 'completed') localOcrTaskState.jobsBySource.delete(key);
|
||||||
|
});
|
||||||
|
renderLocalOcrTaskDock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const deleteButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-delete]') : null;
|
const deleteButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-delete]') : null;
|
||||||
if (deleteButton instanceof HTMLElement) {
|
if (deleteButton instanceof HTMLElement) {
|
||||||
const sourcePath = deleteButton.getAttribute('data-mnote-local-ocr-task-delete') || '';
|
const sourcePath = deleteButton.getAttribute('data-mnote-local-ocr-task-delete') || '';
|
||||||
@@ -1568,12 +1651,18 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const dock = ensureLocalOcrTaskDock();
|
const dock = ensureLocalOcrTaskDock();
|
||||||
const jobs = Array.from(localOcrTaskState.jobsBySource.values())
|
const jobs = Array.from(localOcrTaskState.jobsBySource.values())
|
||||||
.sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0));
|
.sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0));
|
||||||
const runningCount = jobs.filter((job) => !['done', 'failed', 'stale'].includes(String(job?.status || ''))).length;
|
const activeJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'active');
|
||||||
const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
|
const attentionJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'attention');
|
||||||
if (toggle instanceof HTMLButtonElement) {
|
const completedJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'completed');
|
||||||
|
const runningCount = activeJobs.length;
|
||||||
|
const taskToggles = Array.from(document.querySelectorAll('[data-testid="mnote-local-ocr-task-toggle"], [data-testid="mnote-floating-task-toggle"]'))
|
||||||
|
.filter((node) => node instanceof HTMLButtonElement);
|
||||||
|
taskToggles.forEach((toggle) => {
|
||||||
|
const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-ocr-settings';
|
||||||
const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务,打开 OCR 设置` : 'OCR 设置');
|
const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务,打开 OCR 设置` : 'OCR 设置');
|
||||||
toggle.setAttribute('title', label);
|
const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务');
|
||||||
toggle.setAttribute('aria-label', label);
|
toggle.setAttribute('title', opensSettings ? label : taskLabel);
|
||||||
|
toggle.setAttribute('aria-label', opensSettings ? label : taskLabel);
|
||||||
toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false');
|
toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false');
|
||||||
toggle.classList.toggle('has-mnote-local-ocr-tasks', jobs.length > 0);
|
toggle.classList.toggle('has-mnote-local-ocr-tasks', jobs.length > 0);
|
||||||
const badge = toggle.querySelector('[data-mnote-local-ocr-task-count]');
|
const badge = toggle.querySelector('[data-mnote-local-ocr-task-count]');
|
||||||
@@ -1583,28 +1672,85 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
} else {
|
} else {
|
||||||
toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`;
|
toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]');
|
const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]');
|
||||||
if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen;
|
if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen;
|
||||||
|
const summary = dock.querySelector('[data-mnote-local-ocr-task-summary]');
|
||||||
|
if (summary instanceof HTMLElement) {
|
||||||
|
summary.textContent = `${runningCount} 进行中 · ${attentionJobs.length} 需处理 · ${completedJobs.length} 已完成`;
|
||||||
|
}
|
||||||
|
const tabs = dock.querySelector('[data-mnote-local-ocr-task-tabs]');
|
||||||
|
if (tabs instanceof HTMLElement) {
|
||||||
|
const tabItems = [
|
||||||
|
['active', '进行中', activeJobs.length],
|
||||||
|
['attention', '需处理', attentionJobs.length],
|
||||||
|
['completed', '已完成', completedJobs.length],
|
||||||
|
['all', '全部', jobs.length],
|
||||||
|
];
|
||||||
|
tabs.replaceChildren();
|
||||||
|
tabItems.forEach(([key, label, count]) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.setAttribute('data-mnote-local-ocr-task-tab', key);
|
||||||
|
button.setAttribute('aria-selected', localOcrTaskState.taskFilter === key ? 'true' : 'false');
|
||||||
|
button.textContent = `${label} ${count}`;
|
||||||
|
tabs.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const filterLabel = dock.querySelector('[data-mnote-local-ocr-task-filter-label]');
|
||||||
|
if (filterLabel instanceof HTMLElement) filterLabel.textContent = localOcrTaskFilterLabel(localOcrTaskState.taskFilter);
|
||||||
|
const clearCompleted = dock.querySelector('[data-mnote-local-ocr-task-clear-completed]');
|
||||||
|
if (clearCompleted instanceof HTMLButtonElement) clearCompleted.disabled = completedJobs.length === 0;
|
||||||
const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]');
|
const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]');
|
||||||
if (!(list instanceof HTMLElement)) return;
|
if (!(list instanceof HTMLElement)) return;
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
if (!jobs.length) {
|
const visibleJobs = jobs.filter((job) => {
|
||||||
|
return localOcrTaskState.taskFilter === 'all' || localOcrTaskCategory(job) === localOcrTaskState.taskFilter;
|
||||||
|
});
|
||||||
|
if (!visibleJobs.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'mnote-local-ocr-task-empty';
|
empty.className = 'mnote-local-ocr-task-empty';
|
||||||
empty.textContent = '暂无 OCR 任务';
|
empty.textContent = jobs.length ? `暂无${localOcrTaskFilterLabel(localOcrTaskState.taskFilter)}任务` : '暂无后台任务';
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
jobs.forEach((job) => {
|
visibleJobs.forEach((job) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'mnote-local-ocr-task-row';
|
row.className = 'mnote-local-ocr-task-row';
|
||||||
row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || ''));
|
row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || ''));
|
||||||
row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || ''));
|
row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || ''));
|
||||||
|
row.setAttribute('data-mnote-local-ocr-task-category', localOcrTaskCategory(job));
|
||||||
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR';
|
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR';
|
||||||
row.innerHTML = '<div class="mnote-local-ocr-task-main"><strong></strong><span></span></div><div class="mnote-local-ocr-task-actions"><button type="button" data-mnote-local-ocr-task-open>打开</button><button type="button" data-mnote-local-ocr-task-retry>重试</button><button type="button" data-mnote-local-ocr-task-clear>清除</button><button type="button" data-mnote-local-ocr-task-delete>删除</button></div>';
|
const category = localOcrTaskCategory(job);
|
||||||
row.querySelector('strong').textContent = title;
|
const progress = localOcrTaskProgress(job);
|
||||||
row.querySelector('span').textContent = statusTextForLocalOcrJob(job);
|
row.innerHTML = '<div class="mnote-local-ocr-task-main"><div class="mnote-local-ocr-task-title-line"><strong></strong><em></em></div><span></span><div class="mnote-local-ocr-task-progress" role="progressbar"><i></i></div></div><div class="mnote-local-ocr-task-actions"><button type="button" data-mnote-local-ocr-task-open>打开</button><button type="button" data-mnote-local-ocr-task-retry>重试</button><button type="button" data-mnote-local-ocr-task-clear>清除</button><button type="button" data-mnote-local-ocr-task-delete>删除</button></div>';
|
||||||
|
const titleNode = row.querySelector('strong');
|
||||||
|
if (titleNode instanceof HTMLElement) {
|
||||||
|
titleNode.textContent = title;
|
||||||
|
titleNode.setAttribute('title', String(job.sourceRootRelativePath || title));
|
||||||
|
}
|
||||||
|
const categoryNode = row.querySelector('em');
|
||||||
|
if (categoryNode instanceof HTMLElement) {
|
||||||
|
categoryNode.textContent = category === 'active' ? '进行中' : category === 'attention' ? '需处理' : '已完成';
|
||||||
|
}
|
||||||
|
const statusNode = row.querySelector('span');
|
||||||
|
if (statusNode instanceof HTMLElement) statusNode.textContent = statusTextForLocalOcrJob(job);
|
||||||
|
const progressBar = row.querySelector('.mnote-local-ocr-task-progress');
|
||||||
|
const progressValue = row.querySelector('.mnote-local-ocr-task-progress i');
|
||||||
|
if (progressBar instanceof HTMLElement && progressValue instanceof HTMLElement) {
|
||||||
|
progressBar.hidden = category !== 'active' && progress === null;
|
||||||
|
progressBar.setAttribute('aria-valuemin', '0');
|
||||||
|
progressBar.setAttribute('aria-valuemax', '100');
|
||||||
|
if (progress === null) {
|
||||||
|
progressBar.setAttribute('data-progress-mode', 'indeterminate');
|
||||||
|
progressBar.removeAttribute('aria-valuenow');
|
||||||
|
progressValue.style.width = '';
|
||||||
|
} else {
|
||||||
|
progressBar.setAttribute('data-progress-mode', 'determinate');
|
||||||
|
progressBar.setAttribute('aria-valuenow', String(progress));
|
||||||
|
progressValue.style.width = `${progress}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
const open = row.querySelector('[data-mnote-local-ocr-task-open]');
|
const open = row.querySelector('[data-mnote-local-ocr-task-open]');
|
||||||
if (open instanceof HTMLButtonElement) {
|
if (open instanceof HTMLButtonElement) {
|
||||||
open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || ''));
|
open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || ''));
|
||||||
@@ -2178,6 +2324,20 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const officePreviewBaseHref = (href) => {
|
||||||
|
const raw = String(href || '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(raw, window.location.origin);
|
||||||
|
['page', 'bbox', 'sourceMapPath', 'blockId', 'lineRange', 'charRange', 'mnoteResourceReload'].forEach((key) => {
|
||||||
|
url.searchParams.delete(key);
|
||||||
|
});
|
||||||
|
return url.pathname + '?' + url.searchParams.toString();
|
||||||
|
} catch (_) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshExistingOfficeResourceTab = (entry, input) => {
|
const refreshExistingOfficeResourceTab = (entry, input) => {
|
||||||
if (!entry || entry.kind !== 'office') return false;
|
if (!entry || entry.kind !== 'office') return false;
|
||||||
const nextHref = String(input.officeUrl || input.href || '').trim();
|
const nextHref = String(input.officeUrl || input.href || '').trim();
|
||||||
@@ -2186,7 +2346,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const currentHref = frame instanceof HTMLIFrameElement
|
const currentHref = frame instanceof HTMLIFrameElement
|
||||||
? String(frame.getAttribute('src') || frame.src || '').trim()
|
? String(frame.getAttribute('src') || frame.src || '').trim()
|
||||||
: '';
|
: '';
|
||||||
if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);
|
if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export function createSidebarPageAiMarkdownRuntime(context) {
|
|||||||
codeSpans.push('<code>' + code + '</code>');
|
codeSpans.push('<code>' + code + '</code>');
|
||||||
return key;
|
return key;
|
||||||
});
|
});
|
||||||
|
html = html.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, function(match, label, href) {
|
||||||
|
var normalizedHref = normalizePageAiMarkdownHref(href);
|
||||||
|
if (!normalizedHref) return match;
|
||||||
|
var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : '';
|
||||||
|
return '<a href="' + escapeHtml(normalizedHref) + '" target="_blank" rel="noopener noreferrer"' + citationAttr + '>' + label + '</a>';
|
||||||
|
});
|
||||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||||
codeSpans.forEach(function(value, index) {
|
codeSpans.forEach(function(value, index) {
|
||||||
@@ -32,6 +38,28 @@ export function createSidebarPageAiMarkdownRuntime(context) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePageAiMarkdownHref(value) {
|
||||||
|
var href = String(value || '').trim()
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
if (!href || /[\u0000-\u001f<>"']/.test(href)) return '';
|
||||||
|
var lower = href.toLowerCase();
|
||||||
|
if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) return '';
|
||||||
|
if (href.startsWith('/') || href.startsWith('#')) return href;
|
||||||
|
if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) return href;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMnoteCitationHref(href) {
|
||||||
|
try {
|
||||||
|
var url = new URL(href, window.location.origin);
|
||||||
|
return url.origin === window.location.origin && url.pathname.startsWith('/documents/');
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderPageAiMarkdown(content) {
|
function renderPageAiMarkdown(content) {
|
||||||
var lines = String(content || '').replace(/\r\n?/g, '\n').split('\n');
|
var lines = String(content || '').replace(/\r\n?/g, '\n').split('\n');
|
||||||
var blocks = [];
|
var blocks = [];
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function createSidebarPageAiProfileRuntime(context) {
|
|||||||
|
|
||||||
function pageAiSessionAgentFilterOptions(rows) {
|
function pageAiSessionAgentFilterOptions(rows) {
|
||||||
var byValue = { all: '全部 agent' };
|
var byValue = { all: '全部 agent' };
|
||||||
pageAiNormalizeArray(rows).forEach(function(session) {
|
pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession).forEach(function(session) {
|
||||||
var value = pageAiSessionAgentFilterValue(session);
|
var value = pageAiSessionAgentFilterValue(session);
|
||||||
byValue[value] = pageAiSessionAgentLabel(session);
|
byValue[value] = pageAiSessionAgentLabel(session);
|
||||||
});
|
});
|
||||||
@@ -163,9 +163,16 @@ export function createSidebarPageAiProfileRuntime(context) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageAiVisibleHistorySession(session) {
|
||||||
|
var source = String(session && session.source || '').trim();
|
||||||
|
var status = String(session && session.status || '').trim();
|
||||||
|
var messages = pageAiNormalizeArray(session && session.messages);
|
||||||
|
return !(source === 'draft' && status === 'draft' && messages.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
function pageAiFilteredHistoryRows(rows) {
|
function pageAiFilteredHistoryRows(rows) {
|
||||||
var filterValue = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all';
|
var filterValue = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all';
|
||||||
var normalized = pageAiNormalizeArray(rows);
|
var normalized = pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession);
|
||||||
if (filterValue === 'all') return normalized;
|
if (filterValue === 'all') return normalized;
|
||||||
return normalized.filter(function(session) {
|
return normalized.filter(function(session) {
|
||||||
return pageAiSessionAgentFilterValue(session) === filterValue;
|
return pageAiSessionAgentFilterValue(session) === filterValue;
|
||||||
|
|||||||
@@ -563,10 +563,13 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
var skillSourceSelect = drawer.querySelector('[data-page-ai-skill-source-select]');
|
var skillSourceSelect = drawer.querySelector('[data-page-ai-skill-source-select]');
|
||||||
if (skillSourceSelect instanceof HTMLSelectElement) {
|
if (skillSourceSelect instanceof HTMLSelectElement) {
|
||||||
var activeSkillSource = pageAiCurrentSkillSource();
|
var activeSkillSource = pageAiCurrentSkillSource();
|
||||||
skillSourceSelect.innerHTML = pageAiSkillSourceOptions().map(function(option) {
|
var sourceOptions = pageAiSkillSourceOptions();
|
||||||
|
skillSourceSelect.innerHTML = sourceOptions.map(function(option) {
|
||||||
return '<option value="' + escapeHtml(option.value) + '"' + (option.value === activeSkillSource ? ' selected' : '') + '>' + escapeHtml(option.label) + '</option>';
|
return '<option value="' + escapeHtml(option.value) + '"' + (option.value === activeSkillSource ? ' selected' : '') + '>' + escapeHtml(option.label) + '</option>';
|
||||||
}).join('');
|
}).join('');
|
||||||
skillSourceSelect.value = activeSkillSource;
|
skillSourceSelect.value = activeSkillSource;
|
||||||
|
var sourceControl = skillSourceSelect.closest('[data-page-ai-skill-source-control]');
|
||||||
|
if (sourceControl instanceof HTMLElement) sourceControl.hidden = sourceOptions.length <= 1;
|
||||||
}
|
}
|
||||||
var hermesBuiltinToggle = drawer.querySelector('[data-page-ai-hide-hermes-builtin]');
|
var hermesBuiltinToggle = drawer.querySelector('[data-page-ai-hide-hermes-builtin]');
|
||||||
if (hermesBuiltinToggle instanceof HTMLInputElement) {
|
if (hermesBuiltinToggle instanceof HTMLInputElement) {
|
||||||
@@ -580,7 +583,7 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
if (skillList instanceof HTMLElement) {
|
if (skillList instanceof HTMLElement) {
|
||||||
var skills = pageAiFilteredSkillEntries();
|
var skills = pageAiFilteredSkillEntries();
|
||||||
if (!skills.length) {
|
if (!skills.length) {
|
||||||
skillList.innerHTML = '<div class="wolai-page-ai-empty">没有匹配的技能。</div>';
|
skillList.innerHTML = '<div class="wolai-page-ai-empty">没有匹配的能力。</div>';
|
||||||
} else {
|
} else {
|
||||||
var sourceParts = pageAiSkillSourceParts(pageAiCurrentSkillSource());
|
var sourceParts = pageAiSkillSourceParts(pageAiCurrentSkillSource());
|
||||||
var group = sourceParts.group;
|
var group = sourceParts.group;
|
||||||
@@ -593,14 +596,33 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
'<span>' + escapeHtml(String(skills.length) + headingExtra) + '</span>' +
|
'<span>' + escapeHtml(String(skills.length) + headingExtra) + '</span>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
(collapsed ? '' : skills.map(function(skill) {
|
(collapsed ? '' : skills.map(function(skill) {
|
||||||
var sourceText = pageAiSkillOriginLabel(skill)
|
var toolCount = Number(skill.toolCount || pageAiNormalizeArray(skill.tools).length || 0);
|
||||||
+ (skill.configScope ? ' · ' + skill.configScope : '')
|
var disabledToolCount = Number(skill.disabledToolCount || 0);
|
||||||
+ (skill.readOnly ? ' · 只读' : '')
|
var capabilityMeta = [];
|
||||||
+ (skill.modified ? ' · modified' : '');
|
if (toolCount > 0) capabilityMeta.push(String(toolCount) + ' 工具');
|
||||||
|
if (disabledToolCount > 0) capabilityMeta.push(String(disabledToolCount) + ' 已关闭');
|
||||||
|
if (pageAiNormalizeArray(skill.requiresContextRefs).length > 0) capabilityMeta.push('需要上下文');
|
||||||
|
var categoryText = group === 'mnote' ? String(skill.categoryTitle || skill.category || '').trim() : '';
|
||||||
|
var sourceMetaParts = [];
|
||||||
|
if (group === 'mnote') {
|
||||||
|
sourceMetaParts.push(categoryText || 'MNote');
|
||||||
|
} else {
|
||||||
|
sourceMetaParts.push(pageAiSkillOriginLabel(skill));
|
||||||
|
sourceMetaParts.push('只读查看');
|
||||||
|
}
|
||||||
|
if (capabilityMeta.length) sourceMetaParts = sourceMetaParts.concat(capabilityMeta);
|
||||||
|
if (skill.readOnly && group === 'mnote') sourceMetaParts.push('只读');
|
||||||
|
if (skill.modified) sourceMetaParts.push('modified');
|
||||||
|
var sourceText = sourceMetaParts.filter(Boolean).join(' · ');
|
||||||
var description = String(skill.description || '').trim();
|
var description = String(skill.description || '').trim();
|
||||||
var hasDescription = description && description !== '---' && description !== '无描述';
|
var hasDescription = description && description !== '---' && description !== '无描述';
|
||||||
var displayName = String(skill.title || skill.name || skill.id || '').trim();
|
var displayName = String(skill.title || skill.name || skill.id || '').trim();
|
||||||
var disabled = skill.toggleable === false || skill.readOnly === true;
|
var disabled = skill.toggleable === false || skill.readOnly === true;
|
||||||
|
var switchControl = group === 'mnote'
|
||||||
|
? '<button type="button" class="wolai-page-ai-skill-switch' + (pageAiSkillEnabled(skill) ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.id || skill.name) + '" data-page-ai-skill-group="' + escapeHtml(group) + '" data-page-ai-skill-profile="' + escapeHtml(skill.profile || sourceParts.profile || '') + '" data-page-ai-skill-kind="' + escapeHtml(skill.skillKind || '') + '" aria-pressed="' + (pageAiSkillEnabled(skill) ? 'true' : 'false') + '"' + (disabled ? ' disabled' : '') + '>' +
|
||||||
|
'<span></span>' +
|
||||||
|
'</button>'
|
||||||
|
: '<span class="wolai-page-ai-skill-readonly-badge">查看</span>';
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="wolai-page-ai-skill-row">' +
|
'<div class="wolai-page-ai-skill-row">' +
|
||||||
'<div class="wolai-page-ai-skill-copy">' +
|
'<div class="wolai-page-ai-skill-copy">' +
|
||||||
@@ -610,9 +632,7 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
(hasDescription ? '<div class="wolai-page-ai-skill-desc">' + escapeHtml(description) + '</div>' : '') +
|
(hasDescription ? '<div class="wolai-page-ai-skill-desc">' + escapeHtml(description) + '</div>' : '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<button type="button" class="wolai-page-ai-skill-switch' + (pageAiSkillEnabled(skill) ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.id || skill.name) + '" data-page-ai-skill-group="' + escapeHtml(group) + '" data-page-ai-skill-profile="' + escapeHtml(skill.profile || sourceParts.profile || '') + '" data-page-ai-skill-kind="' + escapeHtml(skill.skillKind || '') + '" aria-pressed="' + (pageAiSkillEnabled(skill) ? 'true' : 'false') + '"' + (disabled ? ' disabled' : '') + '>' +
|
switchControl +
|
||||||
'<span></span>' +
|
|
||||||
'</button>' +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('')) +
|
}).join('')) +
|
||||||
'</section>';
|
'</section>';
|
||||||
@@ -751,7 +771,7 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
'<div class="wolai-page-ai-header-actions">' +
|
'<div class="wolai-page-ai-header-actions">' +
|
||||||
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="new-session" aria-label="新建 AI 会话" title="新建 AI 会话">+</button>' +
|
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="new-session" aria-label="新建 AI 会话" title="新建 AI 会话">+</button>' +
|
||||||
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="history" aria-label="历史会话" title="历史会话">⌕</button>' +
|
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="history" aria-label="历史会话" title="历史会话">⌕</button>' +
|
||||||
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="skills" aria-label="技能" title="技能">' +
|
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="skills" aria-label="能力" title="能力">' +
|
||||||
'<svg class="wolai-page-ai-icon-svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
'<svg class="wolai-page-ai-icon-svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
||||||
'<path d="M12 3l1.35 4.15L17.5 8.5l-4.15 1.35L12 14l-1.35-4.15L6.5 8.5l4.15-1.35L12 3z" />' +
|
'<path d="M12 3l1.35 4.15L17.5 8.5l-4.15 1.35L12 14l-1.35-4.15L6.5 8.5l4.15-1.35L12 3z" />' +
|
||||||
'<path d="M18.5 13l.8 2.4 2.2.8-2.2.8-.8 2.4-.8-2.4-2.2-.8 2.2-.8.8-2.4z" />' +
|
'<path d="M18.5 13l.8 2.4 2.2.8-2.2.8-.8 2.4-.8-2.4-2.2-.8 2.2-.8.8-2.4z" />' +
|
||||||
@@ -856,17 +876,17 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
|
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
|
||||||
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
|
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
|
||||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">Agent</button>' +
|
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">Agent</button>' +
|
||||||
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="skills" role="tab" aria-selected="true">Skills</button>' +
|
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="skills" role="tab" aria-selected="true">能力</button>' +
|
||||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="runtime" role="tab" aria-selected="false">Runtime</button>' +
|
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="runtime" role="tab" aria-selected="false">Runtime</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-skills-toolbar">' +
|
'<div class="wolai-page-ai-skills-toolbar">' +
|
||||||
'<label class="wolai-page-ai-skill-search">' +
|
'<label class="wolai-page-ai-skill-search">' +
|
||||||
'<span>搜索技能</span>' +
|
'<span>搜索能力</span>' +
|
||||||
'<input type="search" data-page-ai-skill-search placeholder="搜索技能…" />' +
|
'<input type="search" data-page-ai-skill-search placeholder="搜索能力…" />' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
'<label class="wolai-page-ai-profile-select" data-page-ai-skill-source-control>' +
|
'<label class="wolai-page-ai-profile-select" data-page-ai-skill-source-control>' +
|
||||||
'<span>技能来源</span>' +
|
'<span>能力来源</span>' +
|
||||||
'<select data-page-ai-skill-source-select></select>' +
|
'<select data-page-ai-skill-source-select></select>' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
'<label class="wolai-page-ai-skill-filter-toggle">' +
|
'<label class="wolai-page-ai-skill-filter-toggle">' +
|
||||||
@@ -882,7 +902,7 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
|
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
|
||||||
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
|
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
|
||||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">Agent</button>' +
|
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">Agent</button>' +
|
||||||
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="skills" role="tab" aria-selected="false">Skills</button>' +
|
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="skills" role="tab" aria-selected="false">能力</button>' +
|
||||||
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="runtime" role="tab" aria-selected="true">Runtime</button>' +
|
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="runtime" role="tab" aria-selected="true">Runtime</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -911,9 +931,9 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-tool-list" data-page-ai-queue-list></div>' +
|
'<div class="wolai-page-ai-tool-list" data-page-ai-queue-list></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-tools-panel">' +
|
'<div class="wolai-page-ai-tools-panel" hidden>' +
|
||||||
'<div class="wolai-page-ai-tools-head">' +
|
'<div class="wolai-page-ai-tools-head">' +
|
||||||
'<span>mnote tools</span>' +
|
'<span>MNote 工具调试</span>' +
|
||||||
'<span data-page-ai-last-tool>暂无 tool call</span>' +
|
'<span data-page-ai-last-tool>暂无 tool call</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-ai-inline-error" data-page-ai-tool-error hidden></div>' +
|
'<div class="wolai-page-ai-inline-error" data-page-ai-tool-error hidden></div>' +
|
||||||
@@ -982,6 +1002,231 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageAiConversationShouldStickToBottom(conversation) {
|
||||||
|
var distance = conversation.scrollHeight - conversation.scrollTop - conversation.clientHeight;
|
||||||
|
return distance <= 48;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiCaptureOpenToolDetails(conversation) {
|
||||||
|
var openToolDetails = {};
|
||||||
|
conversation.querySelectorAll('[data-page-ai-collapse-card], [data-page-ai-tool-card]').forEach(function(card) {
|
||||||
|
if (!(card instanceof HTMLElement)) return;
|
||||||
|
var key = String(card.getAttribute('data-page-ai-collapse-id') || card.getAttribute('data-page-ai-tool-group-id') || card.getAttribute('data-page-ai-tool-call-id') || '').trim();
|
||||||
|
var details = card.querySelector('[data-page-ai-collapse-details], .wolai-page-ai-tool-details');
|
||||||
|
if (key && details instanceof HTMLDetailsElement && details.open) {
|
||||||
|
openToolDetails[key] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return openToolDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiRestoreOpenToolDetails(conversation, openToolDetails) {
|
||||||
|
if (!openToolDetails || typeof openToolDetails !== 'object') return;
|
||||||
|
conversation.querySelectorAll('[data-page-ai-collapse-card], [data-page-ai-tool-card]').forEach(function(card) {
|
||||||
|
if (!(card instanceof HTMLElement)) return;
|
||||||
|
var key = String(card.getAttribute('data-page-ai-collapse-id') || card.getAttribute('data-page-ai-tool-group-id') || card.getAttribute('data-page-ai-tool-call-id') || '').trim();
|
||||||
|
if (!key || openToolDetails[key] !== true) return;
|
||||||
|
var details = card.querySelector('[data-page-ai-collapse-details], .wolai-page-ai-tool-details');
|
||||||
|
if (details instanceof HTMLDetailsElement) details.open = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiRenderToolDetailRows(item) {
|
||||||
|
var locationRows = Array.isArray(item.locations) && item.locations.length
|
||||||
|
? '<div class="wolai-page-ai-tool-meta">位置:' + item.locations.map(function(loc) {
|
||||||
|
return '<span style="display:inline-flex;align-items:center;gap:4px;margin-right:8px">' +
|
||||||
|
'<span>' + escapeHtml(loc) + '</span>' +
|
||||||
|
'<button type="button" class="wolai-page-ai-ghost" style="font-size:0.85em;padding:0 4px;min-width:unset" data-page-ai-open-location="' + escapeHtml(loc) + '" data-page-ai-tool-call-id="' + escapeHtml(item.toolCallId || '') + '" title="打开文件">打开</button>' +
|
||||||
|
'</span>';
|
||||||
|
}).join('') + '</div>'
|
||||||
|
: '';
|
||||||
|
return [
|
||||||
|
locationRows,
|
||||||
|
item.argsSummary ? '<div class="wolai-page-ai-tool-meta">参数 ' + escapeHtml(item.argsSummary) + '</div>' : '',
|
||||||
|
item.resultSummary ? '<div class="wolai-page-ai-tool-meta">结果 ' + escapeHtml(item.resultSummary) + '</div>' : '',
|
||||||
|
item.changedFiles && item.changedFiles.length ? '<pre class="wolai-page-ai-tool-meta wolai-page-ai-changed-files">' + escapeHtml(pageAiFormatChangedFiles(item.changedFiles)) + '</pre>' : '',
|
||||||
|
(item.traceId || item.auditId) ? '<div class="wolai-page-ai-tool-meta">' + escapeHtml([item.traceId, item.auditId].filter(Boolean).join(' · ')) + '</div>' : ''
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiToolStatusLabel(status) {
|
||||||
|
if (status === 'completed') return '完成';
|
||||||
|
if (status === 'failed') return '失败';
|
||||||
|
return '运行中';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiToolGroupStatus(items) {
|
||||||
|
if (items.some(function(item) { return item.status === 'failed'; })) return 'failed';
|
||||||
|
if (items.some(function(item) { return item.status !== 'completed'; })) return 'running';
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiRenderToolGroup(items, groupIndex) {
|
||||||
|
var tools = pageAiNormalizeArray(items);
|
||||||
|
if (!tools.length) return '';
|
||||||
|
var status = pageAiToolGroupStatus(tools);
|
||||||
|
var completedCount = tools.filter(function(item) { return item.status === 'completed'; }).length;
|
||||||
|
var failedCount = tools.filter(function(item) { return item.status === 'failed'; }).length;
|
||||||
|
var runningCount = tools.length - completedCount - failedCount;
|
||||||
|
var countParts = [String(tools.length) + ' 个'];
|
||||||
|
if (completedCount) countParts.push('完成 ' + String(completedCount));
|
||||||
|
if (failedCount) countParts.push('失败 ' + String(failedCount));
|
||||||
|
if (runningCount) countParts.push('运行中 ' + String(runningCount));
|
||||||
|
var firstToolId = String(tools[0] && tools[0].toolCallId || '').trim();
|
||||||
|
var groupId = 'tool-group-' + String(groupIndex) + '-' + (firstToolId || String(tools.length));
|
||||||
|
var rows = tools.map(function(item) {
|
||||||
|
var statusLabel = pageAiToolStatusLabel(item.status);
|
||||||
|
var meta = [statusLabel, item.toolKind, item.toolCallId].filter(Boolean).join(' · ');
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-tool-item" data-page-ai-tool-item data-page-ai-tool-call-id="' + escapeHtml(item.toolCallId || '') + '" data-page-ai-tool-status="' + escapeHtml(item.status || '') + '">' +
|
||||||
|
'<div class="wolai-page-ai-tool-item-head">' +
|
||||||
|
'<strong>' + escapeHtml(item.toolName || item.content || 'tool') + '</strong>' +
|
||||||
|
'<span>' + escapeHtml(meta) + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
(pageAiRenderToolDetailRows(item) || '<div class="wolai-page-ai-tool-meta">暂无参数或结果详情。</div>') +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-collapse-card data-page-ai-collapse-id="' + escapeHtml(groupId) + '" data-page-ai-tool-card data-page-ai-tool-group="true" data-page-ai-tool-group-id="' + escapeHtml(groupId) + '" data-page-ai-tool-status="' + escapeHtml(status) + '">' +
|
||||||
|
'<div class="wolai-page-ai-message-role">工具</div>' +
|
||||||
|
'<div class="wolai-page-ai-message-text">' +
|
||||||
|
'<details class="wolai-page-ai-tool-details wolai-page-ai-tool-group-details" data-page-ai-collapse-details>' +
|
||||||
|
'<summary>' +
|
||||||
|
'<strong>调用工具</strong>' +
|
||||||
|
'<span>' + escapeHtml(countParts.join(' · ')) + '</span>' +
|
||||||
|
'</summary>' +
|
||||||
|
'<div class="wolai-page-ai-tool-group-list">' + rows + '</div>' +
|
||||||
|
'</details>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiRenderThoughtGroup(items, groupIndex) {
|
||||||
|
var thoughts = pageAiNormalizeArray(items).filter(function(item) {
|
||||||
|
return item && item.kind === 'thought' && String(item.content || '').trim();
|
||||||
|
});
|
||||||
|
if (!thoughts.length) return '';
|
||||||
|
var firstText = String(thoughts[0] && thoughts[0].content || '').slice(0, 24).replace(/\s+/g, ' ').trim();
|
||||||
|
var groupId = 'thought-group-' + String(groupIndex) + '-' + String(thoughts.length) + '-' + (firstText || 'delta');
|
||||||
|
var content = thoughts.map(function(item) {
|
||||||
|
return String(item.content || '').trim();
|
||||||
|
}).filter(Boolean).join('\n\n');
|
||||||
|
var countLabel = thoughts.length > 1 ? String(thoughts.length) + ' 段' : '1 段';
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-collapse-card data-page-ai-collapse-id="' + escapeHtml(groupId) + '" data-page-ai-thought-card="true">' +
|
||||||
|
'<div class="wolai-page-ai-message-role">AI</div>' +
|
||||||
|
'<div class="wolai-page-ai-message-text">' +
|
||||||
|
'<details class="wolai-page-ai-tool-details wolai-page-ai-thought-group-details" data-page-ai-collapse-details data-page-ai-thought-delta="true">' +
|
||||||
|
'<summary>' +
|
||||||
|
'<strong>思考过程</strong>' +
|
||||||
|
'<span>' + escapeHtml(countLabel) + '</span>' +
|
||||||
|
'</summary>' +
|
||||||
|
'<div class="wolai-page-ai-thought-group-list">' + escapeHtml(content) + '</div>' +
|
||||||
|
'</details>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiRenderNonToolMessage(item) {
|
||||||
|
var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI');
|
||||||
|
if (item.kind === 'thought') {
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-collapse-card data-page-ai-collapse-id="thought-single">' +
|
||||||
|
'<div class="wolai-page-ai-message-role">AI</div>' +
|
||||||
|
'<div class="wolai-page-ai-message-text">' +
|
||||||
|
'<details class="wolai-page-ai-tool-details wolai-page-ai-thought-group-details" data-page-ai-collapse-details data-page-ai-thought-delta="true">' +
|
||||||
|
'<summary><strong>思考过程</strong><span>1 段</span></summary>' +
|
||||||
|
'<div class="wolai-page-ai-thought-group-list">' + escapeHtml(item.content || '') + '</div>' +
|
||||||
|
'</details>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
if (item.kind === 'permission') {
|
||||||
|
var permissionActions = item.resolved ? '' : (
|
||||||
|
'<div class="wolai-page-ai-message-actions">' +
|
||||||
|
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">允许</button>' +
|
||||||
|
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">拒绝</button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-permission-request="' + escapeHtml(item.permissionId || '') + '">' +
|
||||||
|
'<div class="wolai-page-ai-message-role">权限</div>' +
|
||||||
|
'<div class="wolai-page-ai-message-text">' +
|
||||||
|
'<strong>' + escapeHtml(item.toolName || 'session/request_permission') + '</strong><br />' +
|
||||||
|
'<span>' + escapeHtml(item.argsSummary || item.content || '') + '</span>' +
|
||||||
|
permissionActions +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
if (item.kind === 'plan') {
|
||||||
|
var planEntries = Array.isArray(item.entries) ? item.entries : [];
|
||||||
|
var listHtml = planEntries.map(function(entry) {
|
||||||
|
return '<li style="margin:2px 0">' + escapeHtml(String(entry || '')) + '</li>';
|
||||||
|
}).join('');
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-plan="true">' +
|
||||||
|
'<details class="wolai-page-ai-plan-details" open>' +
|
||||||
|
'<summary class="wolai-page-ai-message-role" style="cursor:pointer;user-select:none;opacity:0.8;font-size:0.85em">执行计划 · ' + planEntries.length + ' 步</summary>' +
|
||||||
|
'<div class="wolai-page-ai-message-text" style="margin-top:4px;opacity:0.75;font-size:0.9em;border-left:2px solid var(--wolai-border-color, #ddd);padding-left:8px">' +
|
||||||
|
'<ol style="margin:4px 0;padding-left:20px">' + listHtml + '</ol>' +
|
||||||
|
'</div>' +
|
||||||
|
'</details>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
|
||||||
|
'<div class="wolai-page-ai-message-role">' + escapeHtml(roleLabel) + '</div>' +
|
||||||
|
'<div class="wolai-page-ai-message-text">' + (item.role === 'assistant' ? renderPageAiMarkdown(item.content || '') : escapeHtml(item.content || '')) + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiConversationRenderHtml(messages) {
|
||||||
|
var turns = [];
|
||||||
|
var current = null;
|
||||||
|
function ensureTurn() {
|
||||||
|
if (!current) current = { user: null, tools: [], status: [], assistants: [], others: [] };
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
function flushTurn() {
|
||||||
|
if (!current) return;
|
||||||
|
turns.push(current);
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
pageAiNormalizeArray(messages).forEach(function(item) {
|
||||||
|
if (item && item.role === 'user') {
|
||||||
|
flushTurn();
|
||||||
|
current = { user: item, tools: [], status: [], assistants: [], others: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var turn = ensureTurn();
|
||||||
|
if (item && item.role === 'tool' && item.kind !== 'permission') {
|
||||||
|
turn.tools.push(item);
|
||||||
|
} else if (item && (item.kind === 'thought' || item.kind === 'plan' || item.kind === 'permission')) {
|
||||||
|
turn.status.push(item);
|
||||||
|
} else if (item && item.role === 'assistant') {
|
||||||
|
turn.assistants.push(item);
|
||||||
|
} else {
|
||||||
|
turn.others.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushTurn();
|
||||||
|
var toolGroupIndex = 0;
|
||||||
|
var thoughtGroupIndex = 0;
|
||||||
|
return turns.map(function(turn) {
|
||||||
|
var html = [];
|
||||||
|
if (turn.user) html.push(pageAiRenderNonToolMessage(turn.user));
|
||||||
|
if (turn.tools.length) html.push(pageAiRenderToolGroup(turn.tools, toolGroupIndex++));
|
||||||
|
var thoughts = turn.status.filter(function(item) { return item && item.kind === 'thought'; });
|
||||||
|
var otherStatus = turn.status.filter(function(item) { return !(item && item.kind === 'thought'); });
|
||||||
|
if (thoughts.length) html.push(pageAiRenderThoughtGroup(thoughts, thoughtGroupIndex++));
|
||||||
|
html = html.concat(otherStatus.map(pageAiRenderNonToolMessage));
|
||||||
|
html = html.concat(turn.assistants.map(pageAiRenderNonToolMessage));
|
||||||
|
html = html.concat(turn.others.map(pageAiRenderNonToolMessage));
|
||||||
|
return html.join('');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function renderPageAiConversation() {
|
function renderPageAiConversation() {
|
||||||
var drawer = ensurePageAiDrawer();
|
var drawer = ensurePageAiDrawer();
|
||||||
renderPageAiSuggestions();
|
renderPageAiSuggestions();
|
||||||
@@ -1022,86 +1267,17 @@ export function createSidebarPageAiRenderRuntime(context) {
|
|||||||
conversation.innerHTML = '<div class="wolai-page-ai-empty">围绕当前页面提问,我会优先使用当前页内容、页面结构和已保存设置。</div>';
|
conversation.innerHTML = '<div class="wolai-page-ai-empty">围绕当前页面提问,我会优先使用当前页内容、页面结构和已保存设置。</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) {
|
var shouldStickToBottom = pageAiConversationShouldStickToBottom(conversation);
|
||||||
var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI');
|
var previousScrollTop = conversation.scrollTop;
|
||||||
if (item.role === 'tool') {
|
var openToolDetails = pageAiCaptureOpenToolDetails(conversation);
|
||||||
var statusLabel = item.status === 'completed' ? '完成' : (item.status === 'failed' ? '失败' : '运行中');
|
conversation.innerHTML = pageAiConversationRenderHtml(pageUiState.pageAiMessages);
|
||||||
var locationRows = Array.isArray(item.locations) && item.locations.length
|
pageAiRestoreOpenToolDetails(conversation, openToolDetails);
|
||||||
? '<div class="wolai-page-ai-tool-meta">位置:' + item.locations.map(function(loc, idx) {
|
if (shouldStickToBottom) {
|
||||||
return '<span style="display:inline-flex;align-items:center;gap:4px;margin-right:8px">' +
|
conversation.scrollTop = conversation.scrollHeight;
|
||||||
'<span>' + escapeHtml(loc) + '</span>' +
|
} else {
|
||||||
'<button type="button" class="wolai-page-ai-ghost" style="font-size:0.85em;padding:0 4px;min-width:unset" data-page-ai-open-location="' + escapeHtml(loc) + '" data-page-ai-tool-call-id="' + escapeHtml(item.toolCallId || '') + '" title="打开文件">打开</button>' +
|
var maxScrollTop = Math.max(0, conversation.scrollHeight - conversation.clientHeight);
|
||||||
'</span>';
|
conversation.scrollTop = Math.min(previousScrollTop, maxScrollTop);
|
||||||
}).join('') + '</div>'
|
}
|
||||||
: '';
|
|
||||||
var detailRows = [
|
|
||||||
locationRows,
|
|
||||||
item.argsSummary ? '<div class="wolai-page-ai-tool-meta">参数 ' + escapeHtml(item.argsSummary) + '</div>' : '',
|
|
||||||
item.resultSummary ? '<div class="wolai-page-ai-tool-meta">结果 ' + escapeHtml(item.resultSummary) + '</div>' : '',
|
|
||||||
item.changedFiles && item.changedFiles.length ? '<pre class="wolai-page-ai-tool-meta wolai-page-ai-changed-files">' + escapeHtml(pageAiFormatChangedFiles(item.changedFiles)) + '</pre>' : '',
|
|
||||||
(item.traceId || item.auditId) ? '<div class="wolai-page-ai-tool-meta">' + escapeHtml([item.traceId, item.auditId].filter(Boolean).join(' · ')) + '</div>' : ''
|
|
||||||
].filter(Boolean).join('');
|
|
||||||
return '' +
|
|
||||||
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-tool-card data-page-ai-tool-call-id="' + escapeHtml(item.toolCallId || '') + '" data-page-ai-tool-status="' + escapeHtml(item.status || '') + '">' +
|
|
||||||
'<div class="wolai-page-ai-message-role">' + escapeHtml(roleLabel) + '</div>' +
|
|
||||||
'<div class="wolai-page-ai-message-text">' +
|
|
||||||
'<details class="wolai-page-ai-tool-details">' +
|
|
||||||
'<summary>' +
|
|
||||||
'<strong>' + escapeHtml(item.toolName || item.content || 'tool') + '</strong>' +
|
|
||||||
'<span>' + escapeHtml([statusLabel, item.toolKind, item.toolCallId].filter(Boolean).join(' · ')) + '</span>' +
|
|
||||||
'</summary>' +
|
|
||||||
(detailRows || '<div class="wolai-page-ai-tool-meta">暂无参数或结果详情。</div>') +
|
|
||||||
'</details>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
if (item.kind === 'thought') {
|
|
||||||
return '' +
|
|
||||||
'<details class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-thought-delta="true">' +
|
|
||||||
'<summary class="wolai-page-ai-message-role" style="cursor:pointer;user-select:none;opacity:0.7;font-size:0.85em">思考过程</summary>' +
|
|
||||||
'<div class="wolai-page-ai-message-text" style="margin-top:4px;opacity:0.6;font-size:0.9em;border-left:2px solid var(--wolai-border-color, #ddd);padding-left:8px">' + escapeHtml(item.content || '') + '</div>' +
|
|
||||||
'</details>';
|
|
||||||
}
|
|
||||||
if (item.kind === 'permission') {
|
|
||||||
var permissionActions = item.resolved ? '' : (
|
|
||||||
'<div class="wolai-page-ai-message-actions">' +
|
|
||||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">允许</button>' +
|
|
||||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">拒绝</button>' +
|
|
||||||
'</div>'
|
|
||||||
);
|
|
||||||
return '' +
|
|
||||||
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-permission-request="' + escapeHtml(item.permissionId || '') + '">' +
|
|
||||||
'<div class="wolai-page-ai-message-role">权限</div>' +
|
|
||||||
'<div class="wolai-page-ai-message-text">' +
|
|
||||||
'<strong>' + escapeHtml(item.toolName || 'session/request_permission') + '</strong><br />' +
|
|
||||||
'<span>' + escapeHtml(item.argsSummary || item.content || '') + '</span>' +
|
|
||||||
permissionActions +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
if (item.kind === 'plan') {
|
|
||||||
var planEntries = Array.isArray(item.entries) ? item.entries : [];
|
|
||||||
var listHtml = planEntries.map(function(entry, idx) {
|
|
||||||
return '<li style="margin:2px 0">' + escapeHtml(String(entry || '')) + '</li>';
|
|
||||||
}).join('');
|
|
||||||
return '' +
|
|
||||||
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-plan="true">' +
|
|
||||||
'<details class="wolai-page-ai-plan-details" open>' +
|
|
||||||
'<summary class="wolai-page-ai-message-role" style="cursor:pointer;user-select:none;opacity:0.8;font-size:0.85em">执行计划 · ' + planEntries.length + ' 步</summary>' +
|
|
||||||
'<div class="wolai-page-ai-message-text" style="margin-top:4px;opacity:0.75;font-size:0.9em;border-left:2px solid var(--wolai-border-color, #ddd);padding-left:8px">' +
|
|
||||||
'<ol style="margin:4px 0;padding-left:20px">' + listHtml + '</ol>' +
|
|
||||||
'</div>' +
|
|
||||||
'</details>' +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
|
|
||||||
return '' +
|
|
||||||
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
|
|
||||||
'<div class="wolai-page-ai-message-role">' + escapeHtml(roleLabel) + '</div>' +
|
|
||||||
'<div class="wolai-page-ai-message-text">' + (item.role === 'assistant' ? renderPageAiMarkdown(item.content || '') : escapeHtml(item.content || '')) + '</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
conversation.scrollTop = conversation.scrollHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -724,6 +724,89 @@ export function createSidebarPageAiRuntime(context) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageAiEvidenceRangeFromParam(value) {
|
||||||
|
var text = String(value || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
var match = text.match(/^(\d+)[-:,](\d+)$/);
|
||||||
|
if (!match) return text;
|
||||||
|
return { start: Number(match[1]), end: Number(match[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiEvidenceBboxFromParam(value) {
|
||||||
|
var text = String(value || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
var parts = text.split(',').map(function(part) { return Number(part.trim()); });
|
||||||
|
if (parts.length < 4 || parts.slice(0, 4).some(function(part) { return !Number.isFinite(part); })) return text;
|
||||||
|
return { x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiDocumentIdFromUrl(url) {
|
||||||
|
var prefix = '/documents/';
|
||||||
|
if (!url.pathname.startsWith(prefix)) return '';
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(url.pathname.slice(prefix.length).split('/')[0] || '');
|
||||||
|
} catch (_error) {
|
||||||
|
return url.pathname.slice(prefix.length).split('/')[0] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiCitationResourcePath(url, rootUri) {
|
||||||
|
var explicitPath = String(url.searchParams.get('resourcePath') || '').trim();
|
||||||
|
if (explicitPath) return explicitPath;
|
||||||
|
var raw = String(url.searchParams.get('resourceTab') || '').trim();
|
||||||
|
if (raw.indexOf('::') >= 0) raw = raw.slice(raw.indexOf('::') + 2);
|
||||||
|
if (!raw.startsWith('resource:file:')) return '';
|
||||||
|
var rest = raw.slice('resource:file:'.length);
|
||||||
|
var prefix = String(rootUri || '').trim() + ':';
|
||||||
|
if (!prefix.trim() || !rest.startsWith(prefix)) return '';
|
||||||
|
return rest.slice(prefix.length).replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAiOpenCitationUrl(href) {
|
||||||
|
var url;
|
||||||
|
try {
|
||||||
|
url = new URL(String(href || ''), window.location.origin);
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (url.origin !== window.location.origin || !url.pathname.startsWith('/documents/')) return false;
|
||||||
|
var rootUri = String(url.searchParams.get('rootUri') || currentRootUri() || '').trim();
|
||||||
|
var resourcePath = pageAiCitationResourcePath(url, rootUri);
|
||||||
|
var documentId = pageAiDocumentIdFromUrl(url) || currentDocumentId() || '';
|
||||||
|
var workspaceId = String(url.searchParams.get('workspaceId') || resolveWorkspaceId(document.body) || '').trim();
|
||||||
|
if (resourcePath) {
|
||||||
|
void openLocalResourceInActiveTab({
|
||||||
|
path: resourcePath,
|
||||||
|
rootUri: rootUri,
|
||||||
|
documentId: documentId,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
sourceKind: String(url.searchParams.get('sourceKind') || currentSourceKind() || 'local_folder').trim(),
|
||||||
|
page: url.searchParams.get('page') || undefined,
|
||||||
|
bbox: pageAiEvidenceBboxFromParam(url.searchParams.get('bbox')),
|
||||||
|
sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(),
|
||||||
|
blockId: String(url.searchParams.get('blockId') || '').trim(),
|
||||||
|
lineRange: pageAiEvidenceRangeFromParam(url.searchParams.get('lineRange')),
|
||||||
|
charRange: pageAiEvidenceRangeFromParam(url.searchParams.get('charRange')),
|
||||||
|
openTarget: 'active-tab',
|
||||||
|
paneRole: 'primary'
|
||||||
|
}).then(function(opened) {
|
||||||
|
if (!opened) window.location.assign(url.pathname + url.search + url.hash);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument === 'function') {
|
||||||
|
void window.__mnoteDocumentPaneRuntime.openPrimaryDocument({
|
||||||
|
documentId: documentId,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
sourceKind: String(url.searchParams.get('sourceKind') || currentSourceKind() || '').trim(),
|
||||||
|
rootUri: rootUri,
|
||||||
|
url: url
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function pageAiChatOnlyProfileEntries() {
|
function pageAiChatOnlyProfileEntries() {
|
||||||
var profiles = pageAiNormalizeArray(pageUiState.pageAiProfiles);
|
var profiles = pageAiNormalizeArray(pageUiState.pageAiProfiles);
|
||||||
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY.map(function(spec) {
|
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY.map(function(spec) {
|
||||||
@@ -786,10 +869,13 @@ export function createSidebarPageAiRuntime(context) {
|
|||||||
var archived = pageAiNormalizeArray(upstream && upstream.archived ? upstream.archived : []);
|
var archived = pageAiNormalizeArray(upstream && upstream.archived ? upstream.archived : []);
|
||||||
return {
|
return {
|
||||||
categories: categories.map(function(category) {
|
categories: categories.map(function(category) {
|
||||||
|
var skillRows = pageAiNormalizeArray(category && (category.skills || category.capabilities));
|
||||||
return {
|
return {
|
||||||
name: String(category && category.name || '').trim() || 'misc',
|
name: String(category && category.name || '').trim() || 'misc',
|
||||||
description: String(category && category.description || '').trim(),
|
description: String(category && category.description || '').trim(),
|
||||||
skills: pageAiNormalizeArray(category && category.skills).map(function(skill) {
|
title: String(category && category.title || '').trim(),
|
||||||
|
capabilities: pageAiNormalizeArray(category && category.capabilities),
|
||||||
|
skills: skillRows.map(function(skill) {
|
||||||
return {
|
return {
|
||||||
name: String(skill && skill.name || '').trim(),
|
name: String(skill && skill.name || '').trim(),
|
||||||
id: String(skill && skill.id || skill && skill.name || '').trim(),
|
id: String(skill && skill.id || skill && skill.name || '').trim(),
|
||||||
@@ -807,8 +893,18 @@ export function createSidebarPageAiRuntime(context) {
|
|||||||
configScope: String(skill && skill.configScope || '').trim(),
|
configScope: String(skill && skill.configScope || '').trim(),
|
||||||
skillKind: String(skill && skill.skillKind || '').trim(),
|
skillKind: String(skill && skill.skillKind || '').trim(),
|
||||||
profileId: String(skill && skill.profileId || '').trim(),
|
profileId: String(skill && skill.profileId || '').trim(),
|
||||||
|
toolNames: pageAiNormalizeArray(skill && skill.toolNames),
|
||||||
|
tools: pageAiNormalizeArray(skill && skill.tools),
|
||||||
|
toolCount: Number(skill && skill.toolCount || 0),
|
||||||
|
disabledToolCount: Number(skill && skill.disabledToolCount || 0),
|
||||||
|
status: String(skill && skill.status || '').trim(),
|
||||||
|
capabilityId: String(skill && skill.capabilityId || '').trim(),
|
||||||
|
capabilityKind: String(skill && skill.capabilityKind || '').trim(),
|
||||||
|
uiKind: String(skill && skill.uiKind || '').trim(),
|
||||||
|
requiresContextRefs: pageAiNormalizeArray(skill && skill.requiresContextRefs),
|
||||||
readOnly: Boolean(skill && (skill.readOnly || skill.readonly || skill.configurable === false)),
|
readOnly: Boolean(skill && (skill.readOnly || skill.readonly || skill.configurable === false)),
|
||||||
category: String(category && category.name || '').trim()
|
category: String(skill && skill.category || category && category.name || '').trim(),
|
||||||
|
categoryTitle: String(skill && skill.categoryTitle || category && category.title || category && category.name || '').trim()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -831,6 +927,16 @@ export function createSidebarPageAiRuntime(context) {
|
|||||||
configScope: String(skill && skill.configScope || '').trim(),
|
configScope: String(skill && skill.configScope || '').trim(),
|
||||||
skillKind: String(skill && skill.skillKind || '').trim(),
|
skillKind: String(skill && skill.skillKind || '').trim(),
|
||||||
profileId: String(skill && skill.profileId || '').trim(),
|
profileId: String(skill && skill.profileId || '').trim(),
|
||||||
|
toolNames: pageAiNormalizeArray(skill && skill.toolNames),
|
||||||
|
tools: pageAiNormalizeArray(skill && skill.tools),
|
||||||
|
toolCount: Number(skill && skill.toolCount || 0),
|
||||||
|
disabledToolCount: Number(skill && skill.disabledToolCount || 0),
|
||||||
|
status: String(skill && skill.status || '').trim(),
|
||||||
|
capabilityId: String(skill && skill.capabilityId || '').trim(),
|
||||||
|
capabilityKind: String(skill && skill.capabilityKind || '').trim(),
|
||||||
|
uiKind: String(skill && skill.uiKind || '').trim(),
|
||||||
|
requiresContextRefs: pageAiNormalizeArray(skill && skill.requiresContextRefs),
|
||||||
|
categoryTitle: String(skill && skill.categoryTitle || '').trim(),
|
||||||
readOnly: Boolean(skill && (skill.readOnly || skill.readonly || skill.configurable === false))
|
readOnly: Boolean(skill && (skill.readOnly || skill.readonly || skill.configurable === false))
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -859,6 +965,16 @@ export function createSidebarPageAiRuntime(context) {
|
|||||||
configScope: skill.configScope || '',
|
configScope: skill.configScope || '',
|
||||||
skillKind: skill.skillKind || '',
|
skillKind: skill.skillKind || '',
|
||||||
profileId: skill.profileId || '',
|
profileId: skill.profileId || '',
|
||||||
|
toolNames: skill.toolNames || [],
|
||||||
|
tools: skill.tools || [],
|
||||||
|
toolCount: Number(skill.toolCount || 0),
|
||||||
|
disabledToolCount: Number(skill.disabledToolCount || 0),
|
||||||
|
status: skill.status || '',
|
||||||
|
capabilityId: skill.capabilityId || '',
|
||||||
|
capabilityKind: skill.capabilityKind || '',
|
||||||
|
uiKind: skill.uiKind || '',
|
||||||
|
requiresContextRefs: skill.requiresContextRefs || [],
|
||||||
|
categoryTitle: skill.categoryTitle || category.title || category.name || '',
|
||||||
readOnly: Boolean(skill.readOnly || skill.readonly || skill.configurable === false)
|
readOnly: Boolean(skill.readOnly || skill.readonly || skill.configurable === false)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1310,18 +1426,21 @@ export function createSidebarPageAiRuntime(context) {
|
|||||||
function pageAiLoadSkillCatalog(runtime, profile) {
|
function pageAiLoadSkillCatalog(runtime, profile) {
|
||||||
var params = new URLSearchParams();
|
var params = new URLSearchParams();
|
||||||
params.set('runtime', runtime);
|
params.set('runtime', runtime);
|
||||||
|
var endpoint = '/api/hermes/client/skills';
|
||||||
if (runtime === 'mnote') {
|
if (runtime === 'mnote') {
|
||||||
params.set('agentId', pageUiState.pageAiAgentId || 'reasonix');
|
endpoint = '/api/hermes/client/capabilities';
|
||||||
|
params.set('agentId', 'reasonix');
|
||||||
|
if (profile) params.set('profile', profile);
|
||||||
} else if (runtime === 'reasonix') {
|
} else if (runtime === 'reasonix') {
|
||||||
params.set('runtime', 'reasonix');
|
params.set('runtime', 'reasonix');
|
||||||
} else if (runtime === 'hermes' && profile) {
|
} else if (runtime === 'hermes' && profile) {
|
||||||
params.set('profileId', profile);
|
params.set('profileId', profile);
|
||||||
}
|
}
|
||||||
return fetch('/api/hermes/client/skills?' + params.toString(), {
|
return fetch(endpoint + '?' + params.toString(), {
|
||||||
headers: { 'accept': 'application/json' }
|
headers: { 'accept': 'application/json' }
|
||||||
}).then(function(response) {
|
}).then(function(response) {
|
||||||
return response.json().catch(function(){ return null; }).then(function(payload) {
|
return response.json().catch(function(){ return null; }).then(function(payload) {
|
||||||
if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skills_failed_' + response.status));
|
if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'capabilities_failed_' + response.status));
|
||||||
return pageAiNormalizeSkills(payload);
|
return pageAiNormalizeSkills(payload);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1478,21 +1597,30 @@ export function createSidebarPageAiRuntime(context) {
|
|||||||
}
|
}
|
||||||
if (skillGroup === 'mnote') {
|
if (skillGroup === 'mnote') {
|
||||||
try {
|
try {
|
||||||
var mnoteResponse = await fetch('/api/hermes/client/skills/toggle', {
|
var mnoteResponse = await fetch('/api/hermes/client/capabilities/toggle', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
skillKind: 'mnote_builtin',
|
runtime: 'mnote',
|
||||||
name: name,
|
profile: skillProfile || pageAiCurrentProfile(),
|
||||||
|
id: name,
|
||||||
enabled: Boolean(enabled)
|
enabled: Boolean(enabled)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
var mnotePayload = await mnoteResponse.json().catch(function(){ return null; });
|
var mnotePayload = await mnoteResponse.json().catch(function(){ return null; });
|
||||||
if (!mnoteResponse.ok) throw new Error(pageAiErrorMessage(mnotePayload, 'mnote_skill_toggle_failed_' + mnoteResponse.status));
|
if (!mnoteResponse.ok) throw new Error(pageAiErrorMessage(mnotePayload, 'mnote_capability_toggle_failed_' + mnoteResponse.status));
|
||||||
pageAiSetSkillPreference(skillGroup, name, Boolean(enabled), skillProfile);
|
pageAiSetSkillPreference(skillGroup, name, Boolean(enabled), skillProfile);
|
||||||
pageAiNormalizeArray(pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs.mnote && pageUiState.pageAiSkillCatalogs.mnote.categories).forEach(function(category) {
|
pageAiNormalizeArray(pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs.mnote && pageUiState.pageAiSkillCatalogs.mnote.categories).forEach(function(category) {
|
||||||
pageAiNormalizeArray(category.skills).forEach(function(skill) {
|
pageAiNormalizeArray(category.skills).forEach(function(skill) {
|
||||||
if (skill.id === name || skill.name === name) skill.enabled = Boolean(enabled);
|
if (skill.id === name || skill.name === name) {
|
||||||
|
skill.enabled = Boolean(enabled);
|
||||||
|
skill.status = enabled ? 'available' : 'disabled';
|
||||||
|
pageAiNormalizeArray(skill.tools).forEach(function(tool) {
|
||||||
|
tool.enabled = Boolean(enabled);
|
||||||
|
tool.status = enabled ? 'available' : 'disabled';
|
||||||
|
});
|
||||||
|
skill.disabledToolCount = enabled ? 0 : Number(skill.toolCount || pageAiNormalizeArray(skill.tools).length || 0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', skillGroup + ':' + name);
|
document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', skillGroup + ':' + name);
|
||||||
@@ -1612,8 +1740,6 @@ export function createSidebarPageAiRuntime(context) {
|
|||||||
pageAiLoadBackendSessions()
|
pageAiLoadBackendSessions()
|
||||||
]).then(function() {
|
]).then(function() {
|
||||||
renderPageAiControls();
|
renderPageAiControls();
|
||||||
}).catch(function() {}).then(function() {
|
|
||||||
return pageAiEnsureHermesSession();
|
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
return pageAiRestoreHermesSession();
|
return pageAiRestoreHermesSession();
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
@@ -2119,6 +2245,14 @@ export function createSidebarPageAiRuntime(context) {
|
|||||||
function handlePageAiClick(event, helpers) {
|
function handlePageAiClick(event, helpers) {
|
||||||
var closestAction = helpers && helpers.closestAction;
|
var closestAction = helpers && helpers.closestAction;
|
||||||
if (typeof closestAction !== 'function') return false;
|
if (typeof closestAction !== 'function') return false;
|
||||||
|
var pageAiCitationLink = closestAction(event.target, 'a[data-page-ai-citation-link="true"]');
|
||||||
|
if (pageAiCitationLink && !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
|
||||||
|
var href = pageAiCitationLink.getAttribute('href') || '';
|
||||||
|
if (pageAiOpenCitationUrl(href)) {
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
var pageAiClose = closestAction(event.target, '[data-page-ai-action="close"]');
|
var pageAiClose = closestAction(event.target, '[data-page-ai-action="close"]');
|
||||||
if (pageAiClose) {
|
if (pageAiClose) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
source: 'local',
|
source: 'draft',
|
||||||
usage: null,
|
usage: null,
|
||||||
status: 'idle',
|
status: 'draft',
|
||||||
messages: []
|
messages: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,6 +117,10 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
if (title.length > 28) title = title.slice(0, 28) + '…';
|
if (title.length > 28) title = title.slice(0, 28) + '…';
|
||||||
var persistence = String(row.persistence || payload.persistence || '').trim();
|
var persistence = String(row.persistence || payload.persistence || '').trim();
|
||||||
var sessionStorage = String(row.sessionStorage || row.session_storage || payload.sessionStorage || '').trim();
|
var sessionStorage = String(row.sessionStorage || row.session_storage || payload.sessionStorage || '').trim();
|
||||||
|
var status = String(row.status || (row.runtime && row.runtime.status) || '').trim();
|
||||||
|
var hasConversationSignal = String(payload.message || payload.input || row.snippet || '').trim()
|
||||||
|
|| pageAiNormalizeArray(row.messages).length > 0;
|
||||||
|
if (status === 'session.created' && !hasConversationSignal) return null;
|
||||||
var agentId = pageAiNormalizeAgentId(row.agentId || row.agent_id || payload.agentId || payload.agent_id);
|
var agentId = pageAiNormalizeAgentId(row.agentId || row.agent_id || payload.agentId || payload.agent_id);
|
||||||
var profileId = String(row.profileId || row.profile_id || payload.profileId || payload.profile_id || '').trim();
|
var profileId = String(row.profileId || row.profile_id || payload.profileId || payload.profile_id || '').trim();
|
||||||
var profile = String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default';
|
var profile = String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default';
|
||||||
@@ -143,7 +147,7 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
shareId: String(row.shareId || row.share_id || payload.shareId || '').trim(),
|
shareId: String(row.shareId || row.share_id || payload.shareId || '').trim(),
|
||||||
acpSessionId: String(row.acpSessionId || row.acp_session_id || payload.acpSessionId || payload.acp_session_id || '').trim(),
|
acpSessionId: String(row.acpSessionId || row.acp_session_id || payload.acpSessionId || payload.acp_session_id || '').trim(),
|
||||||
runId: String(row.runId || row.run_id || '').trim(),
|
runId: String(row.runId || row.run_id || '').trim(),
|
||||||
status: String(row.status || (row.runtime && row.runtime.status) || '').trim(),
|
status: status,
|
||||||
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
|
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
|
||||||
preview: String(payload.message || row.snippet || '').trim(),
|
preview: String(payload.message || row.snippet || '').trim(),
|
||||||
messages: []
|
messages: []
|
||||||
@@ -164,11 +168,24 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
|
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageAiDedupeSessions(sessions) {
|
||||||
|
var byId = {};
|
||||||
|
var ordered = [];
|
||||||
|
pageAiNormalizeSessions(sessions).forEach(function(session) {
|
||||||
|
var id = String(session && session.id || '').trim();
|
||||||
|
if (!id || byId[id]) return;
|
||||||
|
byId[id] = true;
|
||||||
|
ordered.push(session);
|
||||||
|
});
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
function pageAiSessionStorageLabel(session) {
|
function pageAiSessionStorageLabel(session) {
|
||||||
var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
|
var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
|
||||||
var persistence = String(session && session.persistence || '').trim();
|
var persistence = String(session && session.persistence || '').trim();
|
||||||
if (storage === 'local_shared') return '共享会话';
|
if (storage === 'local_shared') return '共享会话';
|
||||||
if (storage === 'local_private') return '本地私有';
|
if (storage === 'local_private') return '本地私有';
|
||||||
|
if (storage === 'sqlite_control_plane' || persistence === 'sqlite_acp_runtime_store') return '账号会话';
|
||||||
if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话';
|
if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话';
|
||||||
if (persistence === 'local_ai_session_jsonl') return '本地私有';
|
if (persistence === 'local_ai_session_jsonl') return '本地私有';
|
||||||
return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有';
|
return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有';
|
||||||
@@ -179,30 +196,11 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
try {
|
try {
|
||||||
var raw = win.localStorage.getItem(pageAiStorageKey());
|
var raw = win.localStorage.getItem(pageAiStorageKey());
|
||||||
var parsed = raw ? JSON.parse(raw) : null;
|
var parsed = raw ? JSON.parse(raw) : null;
|
||||||
var activeId = String(parsed && parsed.activeSessionId || '').trim();
|
|
||||||
var activeProfile = String(parsed && parsed.activeProfileName || '').trim();
|
var activeProfile = String(parsed && parsed.activeProfileName || '').trim();
|
||||||
var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim();
|
var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim();
|
||||||
var sessions = pageAiNormalizeSessions(parsed && parsed.sessions);
|
|
||||||
var storageVersion = Number(parsed && parsed.version || 0);
|
var storageVersion = Number(parsed && parsed.version || 0);
|
||||||
if (storageVersion >= sessionStorageVersion && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
|
if (storageVersion >= sessionStorageVersion && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
|
||||||
if (activeProfile) pageAiSetActiveProfile(activeProfile);
|
if (activeProfile) pageAiSetActiveProfile(activeProfile);
|
||||||
if (sessions.length) {
|
|
||||||
pageUiState.pageAiSessions = sessions;
|
|
||||||
var activeSession = sessions.find(function(session) { return session.id === activeId; }) || sessions[0];
|
|
||||||
pageUiState.pageAiActiveSessionId = activeSession.id;
|
|
||||||
if (activeSession.profile) pageAiSetActiveProfile(activeSession.profile);
|
|
||||||
if (activeSession.agentId) pageUiState.pageAiAgentId = pageAiNormalizeAgentId(activeSession.agentId);
|
|
||||||
if (!pageUiState.pageAiAcpRuntime && activeSession.acpRuntime) pageUiState.pageAiAcpRuntime = activeSession.acpRuntime;
|
|
||||||
pageUiState.pageAiMessages = Array.isArray(activeSession.messages) ? activeSession.messages.slice() : [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activeId) {
|
|
||||||
pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')];
|
|
||||||
pageUiState.pageAiSessions[0].id = activeId;
|
|
||||||
pageUiState.pageAiActiveSessionId = activeId;
|
|
||||||
pageUiState.pageAiMessages = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
var fresh = pageAiNewSession();
|
var fresh = pageAiNewSession();
|
||||||
pageUiState.pageAiSessions = [fresh];
|
pageUiState.pageAiSessions = [fresh];
|
||||||
@@ -219,8 +217,24 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status));
|
throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status));
|
||||||
}
|
}
|
||||||
var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean);
|
var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean);
|
||||||
if (!backendSessions.length) return [];
|
var draftSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(session) {
|
||||||
pageUiState.pageAiSessions = pageAiMergeSessions(pageUiState.pageAiSessions, backendSessions);
|
var id = String(session && session.id || '').trim();
|
||||||
|
return id && !id.startsWith('mnote_')
|
||||||
|
&& (id === pageUiState.pageAiActiveSessionId || (Array.isArray(session.messages) && session.messages.length > 0));
|
||||||
|
});
|
||||||
|
if (!backendSessions.length) {
|
||||||
|
if (!draftSessions.length && !pageUiState.pageAiActiveSessionId) {
|
||||||
|
var fresh = pageAiNewSession();
|
||||||
|
pageUiState.pageAiSessions = [fresh];
|
||||||
|
pageUiState.pageAiActiveSessionId = fresh.id;
|
||||||
|
pageUiState.pageAiMessages = [];
|
||||||
|
}
|
||||||
|
pageUiState.pageAiSessionError = '';
|
||||||
|
renderPageAiConversation();
|
||||||
|
renderPageAiControls();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
pageUiState.pageAiSessions = pageAiDedupeSessions(backendSessions.concat(draftSessions));
|
||||||
if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) {
|
if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) {
|
||||||
pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id;
|
pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id;
|
||||||
}
|
}
|
||||||
@@ -242,7 +256,7 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
|
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
|
||||||
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
|
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
|
||||||
if (eventType === 'message.delta') {
|
if (eventType === 'message.delta') {
|
||||||
var delta = String(payload.delta || payload.text || payload.output_text || '').trim();
|
var delta = String(payload.delta || payload.text || payload.output_text || '');
|
||||||
return delta ? { role: 'assistant', content: delta } : null;
|
return delta ? { role: 'assistant', content: delta } : null;
|
||||||
}
|
}
|
||||||
if (eventType === 'thought.delta') {
|
if (eventType === 'thought.delta') {
|
||||||
@@ -301,10 +315,34 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
var userMessage = String(runPayload.message || runPayload.input || '').trim();
|
var userMessage = String(runPayload.message || runPayload.input || '').trim();
|
||||||
if (userMessage) messages.push({ role: 'user', content: userMessage });
|
if (userMessage) messages.push({ role: 'user', content: userMessage });
|
||||||
var runId = String(run && (run.runId || run.run_id) || '').trim();
|
var runId = String(run && (run.runId || run.run_id) || '').trim();
|
||||||
|
var assistantDelta = '';
|
||||||
|
var completedOutput = '';
|
||||||
|
function flushAssistantDelta() {
|
||||||
|
var content = assistantDelta.trim();
|
||||||
|
if (content) messages.push({ role: 'assistant', content: content });
|
||||||
|
assistantDelta = '';
|
||||||
|
}
|
||||||
pageAiNormalizeArray(eventsByRunId[runId]).forEach(function(event) {
|
pageAiNormalizeArray(eventsByRunId[runId]).forEach(function(event) {
|
||||||
var message = pageAiMessageFromRuntimeEvent(event);
|
var message = pageAiMessageFromRuntimeEvent(event);
|
||||||
if (message) messages.push(message);
|
if (!message) return;
|
||||||
|
if (message.role === 'assistant' && !message.kind) {
|
||||||
|
var eventType = String(event && (event.eventType || event.event_type || event.event) || '').trim();
|
||||||
|
if (eventType === 'run.completed') {
|
||||||
|
completedOutput = String(message.content || '').trim();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assistantDelta += String(message.content || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushAssistantDelta();
|
||||||
|
messages.push(message);
|
||||||
});
|
});
|
||||||
|
flushAssistantDelta();
|
||||||
|
if (completedOutput && !messages.some(function(message) {
|
||||||
|
return message.role === 'assistant' && String(message.content || '').trim() === completedOutput;
|
||||||
|
})) {
|
||||||
|
messages.push({ role: 'assistant', content: completedOutput });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!messages.length) messages = storedMessages;
|
if (!messages.length) messages = storedMessages;
|
||||||
@@ -381,8 +419,7 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
version: sessionStorageVersion,
|
version: sessionStorageVersion,
|
||||||
activeSessionId: pageUiState.pageAiActiveSessionId,
|
activeSessionId: pageUiState.pageAiActiveSessionId,
|
||||||
activeProfileName: pageAiCurrentProfile(),
|
activeProfileName: pageAiCurrentProfile(),
|
||||||
activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'
|
||||||
sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions)
|
|
||||||
}));
|
}));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -427,7 +464,12 @@ export function createSidebarPageAiSessionRuntime(context) {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
messages: pageUiState.pageAiMessages.slice()
|
messages: pageUiState.pageAiMessages.slice()
|
||||||
};
|
};
|
||||||
pageUiState.pageAiSessions = pageAiNormalizeSessions([session]);
|
var previousSessionId = String(current && current.id || '').trim();
|
||||||
|
var retainedSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(item) {
|
||||||
|
var itemId = String(item && item.id || '').trim();
|
||||||
|
return itemId && itemId !== session.id && itemId !== previousSessionId;
|
||||||
|
});
|
||||||
|
pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(retainedSessions));
|
||||||
pageUiState.pageAiActiveSessionId = session.id;
|
pageUiState.pageAiActiveSessionId = session.id;
|
||||||
pageAiPersistSessions();
|
pageAiPersistSessions();
|
||||||
renderPageAiConversation();
|
renderPageAiConversation();
|
||||||
|
|||||||
@@ -13,30 +13,39 @@ export function createSidebarPageAiSkillRuntime(context) {
|
|||||||
|
|
||||||
function pageAiSkillSourceOptions() {
|
function pageAiSkillSourceOptions() {
|
||||||
var options = [
|
var options = [
|
||||||
{ value: 'mnote', group: 'mnote', label: 'mnote', profile: '' },
|
{ value: 'mnote', group: 'mnote', label: 'MNote 公共能力', profile: '' },
|
||||||
{ value: 'reasonix', group: 'reasonix', label: 'reasonix', profile: '' }
|
{ value: 'reasonix', group: 'reasonix', label: 'Reasonix skill(查看)', profile: '', readonly: true }
|
||||||
];
|
];
|
||||||
pageAiNormalizeArray(pageUiState.pageAiProfiles).forEach(function(profile) {
|
pageAiNormalizeArray(pageUiState.pageAiProfiles).forEach(function(profile) {
|
||||||
var profileId = pageAiProfileValue(profile);
|
var profileId = pageAiProfileValue(profile);
|
||||||
if (!profileId) return;
|
if (!profileId || pageAiProfileIsChatOnlySkillSource(profile)) return;
|
||||||
var label = profile.kind === 'shared'
|
var label = profile.kind === 'shared' ? 'Hermes 共享 skill(查看)' : 'Hermes skill(查看)';
|
||||||
? (profile.baseProfile === 'lite' || profileId === 'shared_lite' ? 'hermes_lite' : 'Hermes_shared')
|
var alias = String(profile.alias || profile.displayName || '').trim();
|
||||||
: 'Hermes_user';
|
|
||||||
var alias = String(profile.alias || '').trim();
|
|
||||||
options.push({
|
options.push({
|
||||||
value: 'hermes:' + profileId,
|
value: 'hermes:' + profileId,
|
||||||
group: 'hermes',
|
group: 'hermes',
|
||||||
profile: profileId,
|
profile: profileId,
|
||||||
label: alias && alias !== label ? label + ' · ' + alias : label,
|
label: alias && alias !== label ? label + ' · ' + alias : label,
|
||||||
readonly: profile.readonly === true
|
readonly: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageAiProfileIsChatOnlySkillSource(profile) {
|
||||||
|
var profileId = String(pageAiProfileValue(profile) || '').trim().toLowerCase();
|
||||||
|
var baseProfile = String(profile && profile.baseProfile || '').trim().toLowerCase();
|
||||||
|
var label = String(profile && (profile.displayName || profile.alias || profile.name) || '').trim().toLowerCase();
|
||||||
|
var providerKind = String(profile && profile.providerKind || '').trim().toLowerCase();
|
||||||
|
return profileId.indexOf('chat') >= 0
|
||||||
|
|| baseProfile.indexOf('chat') >= 0
|
||||||
|
|| label.indexOf('chat') >= 0
|
||||||
|
|| providerKind.indexOf('chat') >= 0
|
||||||
|
|| profileId === 'shared_lite'
|
||||||
|
|| baseProfile === 'lite';
|
||||||
|
}
|
||||||
|
|
||||||
function pageAiDefaultSkillSource() {
|
function pageAiDefaultSkillSource() {
|
||||||
if (pageAiCurrentAgentId() === 'hermes') return 'hermes:' + pageAiCurrentProfile();
|
|
||||||
if (pageAiCurrentAgentId() === 'reasonix') return 'reasonix';
|
|
||||||
return 'mnote';
|
return 'mnote';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,21 +181,29 @@ export function createSidebarPageAiSkillRuntime(context) {
|
|||||||
group: group,
|
group: group,
|
||||||
profile: profile || '',
|
profile: profile || '',
|
||||||
category: category.name,
|
category: category.name,
|
||||||
|
categoryTitle: skill.categoryTitle || category.title || category.name || '',
|
||||||
id: id,
|
id: id,
|
||||||
name: skill.name || id,
|
name: skill.name || id,
|
||||||
title: skill.title || skill.name || id,
|
title: skill.title || skill.name || id,
|
||||||
description: skill.description || '',
|
description: skill.description || '',
|
||||||
enabled: group === 'hermes' ? skill.enabled !== false : (skill.enabled !== false && overrides[id] !== false),
|
enabled: group === 'mnote' ? (skill.enabled !== false && overrides[id] !== false) : skill.enabled !== false,
|
||||||
toggleable: skill.toggleable !== false,
|
toggleable: group === 'mnote' && skill.toggleable !== false,
|
||||||
source: skill.source || group,
|
source: skill.source || group,
|
||||||
origin: skill.origin || '',
|
origin: skill.origin || '',
|
||||||
readOnly: skill.readOnly === true || skill.readonly === true || skill.configurable === false,
|
readOnly: group !== 'mnote' || skill.readOnly === true || skill.readonly === true || skill.configurable === false,
|
||||||
builtin: pageAiSkillIsBuiltin(skill),
|
builtin: pageAiSkillIsBuiltin(skill),
|
||||||
configurable: skill.configurable !== false,
|
configurable: skill.configurable !== false,
|
||||||
configScope: skill.configScope || '',
|
configScope: skill.configScope || '',
|
||||||
skillKind: skill.skillKind || '',
|
skillKind: skill.skillKind || '',
|
||||||
profileId: skill.profileId || '',
|
profileId: skill.profileId || '',
|
||||||
toolNames: skill.toolNames || [],
|
toolNames: skill.toolNames || [],
|
||||||
|
tools: skill.tools || [],
|
||||||
|
toolCount: Number(skill.toolCount || 0),
|
||||||
|
disabledToolCount: Number(skill.disabledToolCount || 0),
|
||||||
|
status: skill.status || '',
|
||||||
|
capabilityId: skill.capabilityId || '',
|
||||||
|
capabilityKind: skill.capabilityKind || '',
|
||||||
|
uiKind: skill.uiKind || '',
|
||||||
requiresContextRefs: skill.requiresContextRefs || []
|
requiresContextRefs: skill.requiresContextRefs || []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -198,21 +215,29 @@ export function createSidebarPageAiSkillRuntime(context) {
|
|||||||
group: group,
|
group: group,
|
||||||
profile: profile || '',
|
profile: profile || '',
|
||||||
category: 'archived',
|
category: 'archived',
|
||||||
|
categoryTitle: skill.categoryTitle || 'archived',
|
||||||
id: id,
|
id: id,
|
||||||
name: skill.name || id,
|
name: skill.name || id,
|
||||||
title: skill.title || skill.name || id,
|
title: skill.title || skill.name || id,
|
||||||
description: skill.description || '',
|
description: skill.description || '',
|
||||||
enabled: group === 'hermes' ? skill.enabled !== false : (skill.enabled !== false && overrides[id] !== false),
|
enabled: group === 'mnote' ? (skill.enabled !== false && overrides[id] !== false) : skill.enabled !== false,
|
||||||
toggleable: skill.toggleable !== false,
|
toggleable: group === 'mnote' && skill.toggleable !== false,
|
||||||
source: skill.source || group,
|
source: skill.source || group,
|
||||||
origin: skill.origin || '',
|
origin: skill.origin || '',
|
||||||
readOnly: skill.readOnly === true || skill.readonly === true || skill.configurable === false,
|
readOnly: group !== 'mnote' || skill.readOnly === true || skill.readonly === true || skill.configurable === false,
|
||||||
builtin: pageAiSkillIsBuiltin(skill),
|
builtin: pageAiSkillIsBuiltin(skill),
|
||||||
configurable: skill.configurable !== false,
|
configurable: skill.configurable !== false,
|
||||||
configScope: skill.configScope || '',
|
configScope: skill.configScope || '',
|
||||||
skillKind: skill.skillKind || '',
|
skillKind: skill.skillKind || '',
|
||||||
profileId: skill.profileId || '',
|
profileId: skill.profileId || '',
|
||||||
toolNames: skill.toolNames || [],
|
toolNames: skill.toolNames || [],
|
||||||
|
tools: skill.tools || [],
|
||||||
|
toolCount: Number(skill.toolCount || 0),
|
||||||
|
disabledToolCount: Number(skill.disabledToolCount || 0),
|
||||||
|
status: skill.status || '',
|
||||||
|
capabilityId: skill.capabilityId || '',
|
||||||
|
capabilityKind: skill.capabilityKind || '',
|
||||||
|
uiKind: skill.uiKind || '',
|
||||||
requiresContextRefs: skill.requiresContextRefs || []
|
requiresContextRefs: skill.requiresContextRefs || []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,13 +59,26 @@ export function createSidebarPageAiTargetRuntime(context) {
|
|||||||
var kind = String(entry && (entry.editorKind || entry.kind) || '').trim().toLowerCase();
|
var kind = String(entry && (entry.editorKind || entry.kind) || '').trim().toLowerCase();
|
||||||
var assetId = String(entry && entry.assetId || '').trim();
|
var assetId = String(entry && entry.assetId || '').trim();
|
||||||
var path = String(entry && entry.path || '').trim().toLowerCase();
|
var path = String(entry && entry.path || '').trim().toLowerCase();
|
||||||
|
var workspacePath = entry && entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {};
|
||||||
|
var workspaceResourceKind = String(workspacePath.resourceKind || '').trim().toLowerCase();
|
||||||
|
var officeOpenMode = String(entry && entry.officeOpenMode || '').trim().toLowerCase();
|
||||||
|
var onlyofficeSessionId = String(entry && (entry.onlyofficeSessionId || entry.bridgeSessionId) || '').trim();
|
||||||
if (kind === 'page' || kind === 'markdown_page') return 'markdown_page';
|
if (kind === 'page' || kind === 'markdown_page') return 'markdown_page';
|
||||||
if (kind === 'mindmap' || path.endsWith('.mindmap.json')) return 'mindmap';
|
if (kind === 'mindmap' || path.endsWith('.mindmap.json')) return 'mindmap';
|
||||||
if (kind === 'office' || kind === 'onlyoffice' || kind === 'only_office' || /\.(doc|docx|ppt|pptx|xls|xlsx)$/.test(path)) return 'only_office';
|
if (kind === 'office' || kind === 'onlyoffice' || kind === 'only_office' || workspaceResourceKind === 'only_office' || /\.(doc|docx|ppt|pptx|xls|xlsx)$/.test(path)) {
|
||||||
|
return officeOpenMode === 'onlyoffice_live' || onlyofficeSessionId ? 'only_office' : 'attachment';
|
||||||
|
}
|
||||||
if (kind === 'resource' && assetId) return 'resource';
|
if (kind === 'resource' && assetId) return 'resource';
|
||||||
return kind || 'markdown_page';
|
return kind || 'markdown_page';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageAiIsOnlyOfficeLiveTarget(editorTarget) {
|
||||||
|
var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
|
||||||
|
var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim().toLowerCase();
|
||||||
|
var officeOpenMode = String(editorTarget && editorTarget.officeOpenMode || '').trim().toLowerCase();
|
||||||
|
return resourceKind === 'only_office' || resourceKind === 'onlyoffice' || officeOpenMode === 'onlyoffice_live';
|
||||||
|
}
|
||||||
|
|
||||||
function pageAiTargetId(entry) {
|
function pageAiTargetId(entry) {
|
||||||
if (!entry || typeof entry !== 'object') return '';
|
if (!entry || typeof entry !== 'object') return '';
|
||||||
var workspacePath = entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {};
|
var workspacePath = entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {};
|
||||||
@@ -113,6 +126,7 @@ export function createSidebarPageAiTargetRuntime(context) {
|
|||||||
lastActiveAt: entry.lastActiveAt || 0,
|
lastActiveAt: entry.lastActiveAt || 0,
|
||||||
assetId: entry.assetId || workspacePath.assetId || '',
|
assetId: entry.assetId || workspacePath.assetId || '',
|
||||||
path: entry.path || workspacePath.relativePath || '',
|
path: entry.path || workspacePath.relativePath || '',
|
||||||
|
officeOpenMode: entry.officeOpenMode || '',
|
||||||
onlyofficeSessionId: entry.onlyofficeSessionId || entry.bridgeSessionId || '',
|
onlyofficeSessionId: entry.onlyofficeSessionId || entry.bridgeSessionId || '',
|
||||||
bridgeSessionId: entry.bridgeSessionId || entry.onlyofficeSessionId || '',
|
bridgeSessionId: entry.bridgeSessionId || entry.onlyofficeSessionId || '',
|
||||||
bridgeSessionReady: entry.bridgeSessionReady === true
|
bridgeSessionReady: entry.bridgeSessionReady === true
|
||||||
@@ -137,6 +151,7 @@ export function createSidebarPageAiTargetRuntime(context) {
|
|||||||
lastActiveAt: Number(entry.lastActiveAt || 0) || 0,
|
lastActiveAt: Number(entry.lastActiveAt || 0) || 0,
|
||||||
assetId: String(entry.assetId || '').trim(),
|
assetId: String(entry.assetId || '').trim(),
|
||||||
path: String(entry.path || '').trim(),
|
path: String(entry.path || '').trim(),
|
||||||
|
officeOpenMode: String(entry.officeOpenMode || '').trim(),
|
||||||
onlyofficeSessionId: String(entry.onlyofficeSessionId || entry.bridgeSessionId || '').trim(),
|
onlyofficeSessionId: String(entry.onlyofficeSessionId || entry.bridgeSessionId || '').trim(),
|
||||||
bridgeSessionId: String(entry.bridgeSessionId || entry.onlyofficeSessionId || '').trim(),
|
bridgeSessionId: String(entry.bridgeSessionId || entry.onlyofficeSessionId || '').trim(),
|
||||||
bridgeSessionReady: entry.bridgeSessionReady === true
|
bridgeSessionReady: entry.bridgeSessionReady === true
|
||||||
@@ -661,7 +676,7 @@ export function createSidebarPageAiTargetRuntime(context) {
|
|||||||
var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
|
var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
|
||||||
var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim();
|
var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim();
|
||||||
var onlyofficeSessionId = String(editorTarget && (editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId) || '').trim();
|
var onlyofficeSessionId = String(editorTarget && (editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId) || '').trim();
|
||||||
if ((resourceKind === 'office' || resourceKind === 'only_office' || resourceKind === 'onlyoffice') && !onlyofficeSessionId) {
|
if (pageAiIsOnlyOfficeLiveTarget(editorTarget) && !onlyofficeSessionId) {
|
||||||
var sessionError = new Error('ONLYOFFICE 资源仍在连接 MNote bridge,请等待 Office 页面加载完成后再让 AI 操作。');
|
var sessionError = new Error('ONLYOFFICE 资源仍在连接 MNote bridge,请等待 Office 页面加载完成后再让 AI 操作。');
|
||||||
sessionError.code = 'page_ai_onlyoffice_bridge_not_ready';
|
sessionError.code = 'page_ai_onlyoffice_bridge_not_ready';
|
||||||
throw sessionError;
|
throw sessionError;
|
||||||
|
|||||||
@@ -740,8 +740,15 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
|
|
||||||
var status = summary.status || {};
|
var status = summary.status || {};
|
||||||
var settings = status.settings && typeof status.settings === 'object' ? status.settings : {};
|
var settings = status.settings && typeof status.settings === 'object' ? status.settings : {};
|
||||||
var includePaths = Array.isArray(settings.includePaths) ? settings.includePaths : ['.'];
|
var savedIncludePaths = Array.isArray(settings.includePaths) ? settings.includePaths : [];
|
||||||
statusNode.textContent = '索引 ' + Number(status.documentCount || 0) + ' 个页面 / ' + Number(status.resourceCount || 0) + ' 个资源 / ' + Number(status.evidenceBlockCount || 0) + ' 个证据块' + (status.scheduledDue ? ',已到计划时间' : '');
|
var indexedPaths = Array.isArray(status.indexedPaths) ? status.indexedPaths : [];
|
||||||
|
var includePaths = savedIncludePaths.length ? savedIncludePaths : indexedPaths;
|
||||||
|
var hasIndexCache = status.indexExists === true || status.evidenceIndexExists === true;
|
||||||
|
if (!savedIncludePaths.length && !hasIndexCache) {
|
||||||
|
statusNode.textContent = '未设置索引范围';
|
||||||
|
} else {
|
||||||
|
statusNode.textContent = '索引 ' + Number(status.documentCount || 0) + ' 个页面 / ' + Number(status.resourceCount || 0) + ' 个资源 / ' + Number(status.evidenceBlockCount || 0) + ' 个证据块' + (status.scheduledDue ? ',已到计划时间' : '');
|
||||||
|
}
|
||||||
if (!(rangesNode instanceof HTMLElement) || !rangesNode.contains(document.activeElement)) {
|
if (!(rangesNode instanceof HTMLElement) || !rangesNode.contains(document.activeElement)) {
|
||||||
renderLocalIndexRangeRows(popover, includePaths, localIndexStatusKind(status), false, status);
|
renderLocalIndexRangeRows(popover, includePaths, localIndexStatusKind(status), false, status);
|
||||||
}
|
}
|
||||||
@@ -762,6 +769,9 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
|
|
||||||
function localIndexStatusKind(status) {
|
function localIndexStatusKind(status) {
|
||||||
if (!status || typeof status !== 'object') return 'fault';
|
if (!status || typeof status !== 'object') return 'fault';
|
||||||
|
var settings = status.settings && typeof status.settings === 'object' ? status.settings : {};
|
||||||
|
var includePaths = Array.isArray(settings.includePaths) ? settings.includePaths : [];
|
||||||
|
if (!includePaths.length && status.indexExists !== true && status.evidenceIndexExists !== true) return 'indexed';
|
||||||
if (status.indexExists !== true) return 'fault';
|
if (status.indexExists !== true) return 'fault';
|
||||||
if (status.cacheMatchesSettings === true && status.scheduledDue !== true) return 'indexed';
|
if (status.cacheMatchesSettings === true && status.scheduledDue !== true) return 'indexed';
|
||||||
return 'indexing';
|
return 'indexing';
|
||||||
@@ -774,10 +784,10 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function localIndexPathStatusKind(path, status, fallbackKind) {
|
function localIndexPathStatusKind(path, status, fallbackKind) {
|
||||||
if (fallbackKind === 'fault' || fallbackKind === 'indexing') return fallbackKind;
|
|
||||||
var indexedPaths = Array.isArray(status && status.indexedPaths) ? status.indexedPaths : [];
|
var indexedPaths = Array.isArray(status && status.indexedPaths) ? status.indexedPaths : [];
|
||||||
var normalizedPath = String(path || '').trim() || '.';
|
var normalizedPath = String(path || '').trim() || '.';
|
||||||
if (indexedPaths.indexOf(normalizedPath) >= 0 && status.cacheMatchesSettings === true) return 'indexed';
|
if (indexedPaths.indexOf(normalizedPath) >= 0 && status.indexExists === true) return 'indexed';
|
||||||
|
if (fallbackKind === 'fault' || fallbackKind === 'indexing') return fallbackKind;
|
||||||
if (status.indexExists === true) return 'indexing';
|
if (status.indexExists === true) return 'indexing';
|
||||||
return 'fault';
|
return 'fault';
|
||||||
}
|
}
|
||||||
@@ -789,13 +799,26 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
}) : [];
|
}) : [];
|
||||||
var paths = disabled ? rawPaths.filter(Boolean) : rawPaths;
|
var paths = disabled ? rawPaths.filter(Boolean) : rawPaths;
|
||||||
if (!paths.length && !disabled) paths = ['.'];
|
var savedPaths = Array.isArray(status && status.settings && status.settings.includePaths)
|
||||||
|
? status.settings.includePaths.map(function(value) { return String(value || '').trim(); }).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
var indexedPaths = Array.isArray(status && status.indexedPaths)
|
||||||
|
? status.indexedPaths.map(function(value) { return String(value || '').trim(); }).filter(Boolean)
|
||||||
|
: [];
|
||||||
rangesNode.innerHTML = paths.map(function(path, index) {
|
rangesNode.innerHTML = paths.map(function(path, index) {
|
||||||
|
var isPersisted = savedPaths.indexOf(path) >= 0 || indexedPaths.indexOf(path) >= 0;
|
||||||
|
var inputDisabled = disabled || isPersisted;
|
||||||
var kind = localIndexPathStatusKind(path, status || {}, fallbackKind || 'fault');
|
var kind = localIndexPathStatusKind(path, status || {}, fallbackKind || 'fault');
|
||||||
|
var normalizedPath = String(path || '').trim() || '.';
|
||||||
|
var displayPath = inputDisabled && normalizedPath === '.' ? '工作区根目录(全部)' : path;
|
||||||
|
var rootScopeHint = normalizedPath === '.'
|
||||||
|
? '<span class="wolai-page-settings-index-root-hint">当前工作区全部</span>'
|
||||||
|
: '';
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="wolai-page-settings-index-range-row" data-local-index-range-row="true">' +
|
'<div class="wolai-page-settings-index-range-row" data-local-index-range-row="true">' +
|
||||||
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(localIndexStatusLabel(kind)) + '"></span>' +
|
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(localIndexStatusLabel(kind)) + '"></span>' +
|
||||||
'<input type="text" class="wolai-page-settings-index-path" data-local-index-range-input="true" value="' + escapeHtml(path) + '" spellcheck="false"' + (disabled ? ' disabled' : '') + ' />' +
|
'<input type="text" class="wolai-page-settings-index-path" data-local-index-range-input="true" data-local-index-value="' + escapeHtml(normalizedPath) + '" value="' + escapeHtml(displayPath) + '" spellcheck="false"' + (inputDisabled ? ' disabled' : '') + (isPersisted ? ' data-local-index-persisted="true" title="已保存的索引目录不能直接修改;删除后重新新增范围"' : '') + ' />' +
|
||||||
|
rootScopeHint +
|
||||||
'<button type="button" class="wolai-page-settings-index-remove" data-local-index-action="remove-path" data-local-index-path-index="' + String(index) + '"' + (disabled ? ' disabled' : '') + ' aria-label="删除索引范围">×</button>' +
|
'<button type="button" class="wolai-page-settings-index-remove" data-local-index-action="remove-path" data-local-index-path-index="' + String(index) + '"' + (disabled ? ' disabled' : '') + ' aria-label="删除索引范围">×</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -860,16 +883,17 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
|
|
||||||
function localIndexIncludePathsFromForm() {
|
function localIndexIncludePathsFromForm() {
|
||||||
var popover = ensureLocalIndexSettingsPopover();
|
var popover = ensureLocalIndexSettingsPopover();
|
||||||
var values = currentLocalIndexRangeValues(popover).map(function(value) {
|
return currentLocalIndexRangeValues(popover).map(function(value) {
|
||||||
return value.trim();
|
return value.trim();
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
return values.length ? values : ['.'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentLocalIndexRangeValues(popover) {
|
function currentLocalIndexRangeValues(popover) {
|
||||||
var root = popover || ensureLocalIndexSettingsPopover();
|
var root = popover || ensureLocalIndexSettingsPopover();
|
||||||
return Array.from(root.querySelectorAll('[data-local-index-range-input]')).map(function(input) {
|
return Array.from(root.querySelectorAll('[data-local-index-range-input]')).map(function(input) {
|
||||||
return input instanceof HTMLInputElement ? input.value : '';
|
if (!(input instanceof HTMLInputElement)) return '';
|
||||||
|
if (input.disabled && input.dataset.localIndexValue) return input.dataset.localIndexValue;
|
||||||
|
return input.value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,7 +925,6 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
var summary = pageUiState.localIndexSummary || {};
|
var summary = pageUiState.localIndexSummary || {};
|
||||||
var values = currentLocalIndexRangeValues(popover);
|
var values = currentLocalIndexRangeValues(popover);
|
||||||
values.splice(Number(index || 0), 1);
|
values.splice(Number(index || 0), 1);
|
||||||
if (!values.length) values = [''];
|
|
||||||
renderLocalIndexRangeRows(popover, values, localIndexStatusKind(summary.status || {}), false, summary.status || {});
|
renderLocalIndexRangeRows(popover, values, localIndexStatusKind(summary.status || {}), false, summary.status || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1162,6 +1162,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
var objectIdentity = fileObjectIdentity(item);
|
var objectIdentity = fileObjectIdentity(item);
|
||||||
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
|
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
|
||||||
var iconKind = iconKindOf(item);
|
var iconKind = iconKindOf(item);
|
||||||
|
var indexStatus = String((item && item.indexStatus) || (item && item.resourceMeta && item.resourceMeta.indexStatus) || '').trim();
|
||||||
|
if (indexStatus !== 'indexed' && indexStatus !== 'failed') indexStatus = '';
|
||||||
var cachedChildren = relativePath ? cachedFileTreeRows(relativePath) : [];
|
var cachedChildren = relativePath ? cachedFileTreeRows(relativePath) : [];
|
||||||
var title = isFileTreeProjectionPageRow(rowKind, assetId)
|
var title = isFileTreeProjectionPageRow(rowKind, assetId)
|
||||||
? fileTreePageTitle(rawTitle)
|
? fileTreePageTitle(rawTitle)
|
||||||
@@ -1185,7 +1187,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
|||||||
var childHtml = expandable && expanded && childRowsHtml
|
var childHtml = expandable && expanded && childRowsHtml
|
||||||
? '<ul class="tree-children">' + childRowsHtml + '</ul>'
|
? '<ul class="tree-children">' + childRowsHtml + '</ul>'
|
||||||
: '';
|
: '';
|
||||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '"' + (indexStatus ? ' data-index-status="' + escapeHtml(indexStatus) + '"' : '') + ' data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" title="' + escapeHtml(title) + '"><span class="tree-link-title" title="' + escapeHtml(title) + '">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -874,6 +874,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
const inferOnlyOfficeFileType = (...args) => sidebarFileTreeOpen.inferOnlyOfficeFileType(...args);
|
const inferOnlyOfficeFileType = (...args) => sidebarFileTreeOpen.inferOnlyOfficeFileType(...args);
|
||||||
const buildOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenUrl(...args);
|
const buildOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenUrl(...args);
|
||||||
const buildOnlyOfficeOpenPath = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenPath(...args);
|
const buildOnlyOfficeOpenPath = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenPath(...args);
|
||||||
|
const buildOfficePreviewOpenUrl = (...args) => sidebarFileTreeOpen.buildOfficePreviewOpenUrl(...args);
|
||||||
const buildPdfPreviewOpenUrl = (...args) => sidebarFileTreeOpen.buildPdfPreviewOpenUrl(...args);
|
const buildPdfPreviewOpenUrl = (...args) => sidebarFileTreeOpen.buildPdfPreviewOpenUrl(...args);
|
||||||
const buildLocalOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildLocalOnlyOfficeOpenUrl(...args);
|
const buildLocalOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildLocalOnlyOfficeOpenUrl(...args);
|
||||||
const openLocalOfficeFileInActiveTab = (...args) => sidebarFileTreeOpen.openLocalOfficeFileInActiveTab(...args);
|
const openLocalOfficeFileInActiveTab = (...args) => sidebarFileTreeOpen.openLocalOfficeFileInActiveTab(...args);
|
||||||
@@ -2198,26 +2199,57 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return searchText(shell && shell.getAttribute('data-document-id')) || searchText(bodyId);
|
return searchText(shell && shell.getAttribute('data-document-id')) || searchText(bodyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localResourceDocumentIdFromPath(path) {
|
||||||
|
var normalized = searchText(path).replace(/\\/g, '/');
|
||||||
|
return normalized ? 'local-resource:' + normalized.replace(/\//g, '~2F') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSearchDocumentId() {
|
||||||
|
var activePanel = document.querySelector('[data-mnote-resource-tab-panel][data-pane-role="primary"]:not([hidden])');
|
||||||
|
if (activePanel instanceof HTMLElement) {
|
||||||
|
var resourceKind = searchText(activePanel.getAttribute('data-resource-kind')).toLowerCase();
|
||||||
|
var resourcePath = searchText(activePanel.getAttribute('data-resource-path'));
|
||||||
|
if (resourceKind && resourceKind !== 'markdown' && resourcePath) return localResourceDocumentIdFromPath(resourcePath);
|
||||||
|
try {
|
||||||
|
var locator = JSON.parse(activePanel.getAttribute('data-mnote-evidence-locator') || 'null');
|
||||||
|
var locatorDoc = searchText(locator && (locator.ownerDocumentId || locator.owner_document_id));
|
||||||
|
if (locatorDoc) return locatorDoc;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return currentDocumentId();
|
||||||
|
}
|
||||||
|
|
||||||
function searchSwitchValue(overlay, name) {
|
function searchSwitchValue(overlay, name) {
|
||||||
var button = overlay.querySelector('[data-search-switch="' + name + '"]');
|
var button = overlay.querySelector('[data-search-switch="' + name + '"]');
|
||||||
return button instanceof HTMLElement && button.getAttribute('aria-checked') === 'true';
|
return button instanceof HTMLElement && button.getAttribute('aria-checked') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightedHtml(value) {
|
function highlightSearchText(value, query, exact) {
|
||||||
return escapeHtml(value)
|
var text = searchText(value);
|
||||||
.replace(/<mark>/g, '<mark>')
|
|
||||||
.replace(/<\/mark>/g, '</mark>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlightSearchTitle(title, query) {
|
|
||||||
var cleanTitle = searchText(title);
|
|
||||||
var cleanQuery = searchText(query);
|
var cleanQuery = searchText(query);
|
||||||
if (!cleanQuery) return escapeHtml(cleanTitle);
|
if (!text || !cleanQuery) return escapeHtml(text);
|
||||||
var index = cleanTitle.toLowerCase().indexOf(cleanQuery.toLowerCase());
|
var lower = text.toLowerCase();
|
||||||
if (index < 0) return escapeHtml(cleanTitle);
|
var lowerQuery = cleanQuery.toLowerCase();
|
||||||
return escapeHtml(cleanTitle.slice(0, index)) +
|
var exactIndex = lower.indexOf(lowerQuery);
|
||||||
'<mark>' + escapeHtml(cleanTitle.slice(index, index + cleanQuery.length)) + '</mark>' +
|
if (exact || exactIndex >= 0) {
|
||||||
escapeHtml(cleanTitle.slice(index + cleanQuery.length));
|
if (exactIndex < 0) return escapeHtml(text);
|
||||||
|
return escapeHtml(text.slice(0, exactIndex)) +
|
||||||
|
'<mark>' + escapeHtml(text.slice(exactIndex, exactIndex + cleanQuery.length)) + '</mark>' +
|
||||||
|
escapeHtml(text.slice(exactIndex + cleanQuery.length));
|
||||||
|
}
|
||||||
|
var chars = Array.from(cleanQuery.replace(/\s+/g, '').toLowerCase());
|
||||||
|
if (!chars.length) return escapeHtml(text);
|
||||||
|
var next = 0;
|
||||||
|
var html = '';
|
||||||
|
Array.from(text).forEach(function(ch) {
|
||||||
|
if (next < chars.length && ch.toLowerCase() === chars[next]) {
|
||||||
|
html += '<mark>' + escapeHtml(ch) + '</mark>';
|
||||||
|
next += 1;
|
||||||
|
} else {
|
||||||
|
html += escapeHtml(ch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next >= chars.length ? html : escapeHtml(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchResultEvidenceLocator(item) {
|
function searchResultEvidenceLocator(item) {
|
||||||
@@ -2243,6 +2275,38 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return searchText(action && action.url);
|
return searchText(action && action.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evidenceLocatorLineRange(locator) {
|
||||||
|
return locator && (locator.lineRange || locator.line_range) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceLocatorCharRange(locator) {
|
||||||
|
return locator && (locator.charRange || locator.char_range) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceLocatorBlockId(locator) {
|
||||||
|
return searchText(locator && (locator.blockId || locator.block_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceHrefForEvidenceLocator(resourceKind, resourcePath, fileName, ownerDocumentId, locator) {
|
||||||
|
var localFileUrl = buildLocalFileOpenUrl(resourcePath, false);
|
||||||
|
if (!localFileUrl) return '';
|
||||||
|
if (resourceKind === 'pdf') return buildPdfPreviewOpenUrl(localFileUrl, fileName);
|
||||||
|
if (resourceKind === 'office') {
|
||||||
|
var fileType = inferOnlyOfficeFileType(fileName, '') || (fileName.indexOf('.') >= 0 ? fileName.split('.').pop() : 'docx');
|
||||||
|
return buildOfficePreviewOpenUrl({
|
||||||
|
fileUrl: localFileUrl,
|
||||||
|
fileName: fileName,
|
||||||
|
fileType: fileType,
|
||||||
|
assetId: 'local-file:' + resourcePath,
|
||||||
|
documentId: ownerDocumentId || currentDocumentId() || '',
|
||||||
|
workspaceId: resolveWorkspaceId(document.body) || '',
|
||||||
|
sourceKind: currentSourceKind() || 'local_folder',
|
||||||
|
rootUri: searchText(locator && (locator.rootUri || locator.root_uri) || currentRootUri())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return localFileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
async function openEvidenceSearchResult(item, event) {
|
async function openEvidenceSearchResult(item, event) {
|
||||||
var locator = searchResultEvidenceLocator(item);
|
var locator = searchResultEvidenceLocator(item);
|
||||||
if (!locator) {
|
if (!locator) {
|
||||||
@@ -2254,18 +2318,20 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
var ownerDocumentId = searchText(locator.ownerDocumentId || locator.owner_document_id || item.documentId || '');
|
var ownerDocumentId = searchText(locator.ownerDocumentId || locator.owner_document_id || item.documentId || '');
|
||||||
var resourceKind = evidenceLocatorResourceKind(locator, item && item.resourceType);
|
var resourceKind = evidenceLocatorResourceKind(locator, item && item.resourceType);
|
||||||
var openTarget = event && event.altKey ? 'side' : 'active-tab';
|
var openTarget = event && event.altKey ? 'side' : 'active-tab';
|
||||||
if (resourcePath && resourceKind && resourceKind !== 'markdown') {
|
if (resourcePath && resourceKind) {
|
||||||
var fileName = resourcePath.split('/').filter(Boolean).pop() || resourcePath;
|
var fileName = resourcePath.split('/').filter(Boolean).pop() || resourcePath;
|
||||||
var localFileUrl = buildLocalFileOpenUrl(resourcePath, false);
|
var normalizedKind = resourceKind === 'raw_file' || resourceKind === 'resource' ? fileTreeIconKindForFileName(fileName) || 'file' : resourceKind;
|
||||||
var href = resourceKind === 'pdf' ? buildPdfPreviewOpenUrl(localFileUrl, fileName) : localFileUrl;
|
var hostDocumentId = currentDocumentId() || ownerDocumentId || '';
|
||||||
await openLocalResourceInActiveTab({
|
var href = resourceHrefForEvidenceLocator(normalizedKind, resourcePath, fileName, hostDocumentId, locator);
|
||||||
|
var openedInResourceTab = await openLocalResourceInActiveTab({
|
||||||
path: resourcePath,
|
path: resourcePath,
|
||||||
title: fileName,
|
title: fileName,
|
||||||
kind: resourceKind,
|
kind: normalizedKind,
|
||||||
href: href,
|
href: href,
|
||||||
|
officeUrl: normalizedKind === 'office' ? href : '',
|
||||||
assetId: 'local-file:' + resourcePath,
|
assetId: 'local-file:' + resourcePath,
|
||||||
documentId: ownerDocumentId || currentDocumentId() || '',
|
documentId: hostDocumentId,
|
||||||
ownerDocumentId: ownerDocumentId || currentDocumentId() || '',
|
ownerDocumentId: ownerDocumentId || hostDocumentId,
|
||||||
workspaceId: resolveWorkspaceId(document.body) || '',
|
workspaceId: resolveWorkspaceId(document.body) || '',
|
||||||
sourceKind: currentSourceKind() || 'local_folder',
|
sourceKind: currentSourceKind() || 'local_folder',
|
||||||
rootUri: searchText(locator.rootUri || locator.root_uri || currentRootUri()),
|
rootUri: searchText(locator.rootUri || locator.root_uri || currentRootUri()),
|
||||||
@@ -2274,10 +2340,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
page: locator.page,
|
page: locator.page,
|
||||||
bbox: locator.bbox,
|
bbox: locator.bbox,
|
||||||
sourceMapPath: searchText(locator.sourceMapPath || locator.source_map_path),
|
sourceMapPath: searchText(locator.sourceMapPath || locator.source_map_path),
|
||||||
blockId: searchText(locator.blockId || locator.block_id),
|
blockId: evidenceLocatorBlockId(locator),
|
||||||
lineRange: locator.lineRange || locator.line_range || null,
|
lineRange: evidenceLocatorLineRange(locator),
|
||||||
charRange: locator.charRange || locator.char_range || null
|
charRange: evidenceLocatorCharRange(locator)
|
||||||
});
|
});
|
||||||
|
if (!openedInResourceTab) console.warn('mnote evidence 搜索结果无法在当前页面资源标签打开', locator);
|
||||||
closeSearchModal();
|
closeSearchModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2339,7 +2406,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
workspaceId: resolveWorkspaceId(document.body),
|
workspaceId: resolveWorkspaceId(document.body),
|
||||||
sourceKind: currentSourceKind() || null,
|
sourceKind: currentSourceKind() || null,
|
||||||
rootUri: currentRootUri() || null,
|
rootUri: currentRootUri() || null,
|
||||||
documentId: currentDocumentId() || null,
|
documentId: searchSwitchValue(overlay, 'page') ? (currentSearchDocumentId() || null) : (currentDocumentId() || null),
|
||||||
query: query,
|
query: query,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
filters: {
|
filters: {
|
||||||
@@ -2368,10 +2435,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
var locator = searchResultEvidenceLocator(item);
|
var locator = searchResultEvidenceLocator(item);
|
||||||
var locatorAttr = locator ? escapeHtml(JSON.stringify(locator)) : '';
|
var locatorAttr = locator ? escapeHtml(JSON.stringify(locator)) : '';
|
||||||
var index = items.indexOf(item);
|
var index = items.indexOf(item);
|
||||||
|
var exact = searchSwitchValue(overlay, 'exact');
|
||||||
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="rust-kernel" data-search-result-index="' + String(index) + '" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '" data-resource-type="' + escapeHtml(resourceType) + '"' + (locatorAttr ? ' data-evidence-locator="' + locatorAttr + '"' : '') + '>' +
|
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="rust-kernel" data-search-result-index="' + String(index) + '" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '" data-resource-type="' + escapeHtml(resourceType) + '"' + (locatorAttr ? ' data-evidence-locator="' + locatorAttr + '"' : '') + '>' +
|
||||||
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
||||||
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchTitle(title, query) + '</span>' +
|
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchText(title, query, exact) + '</span>' +
|
||||||
'<span class="wolai-search-result-snippet">' + (snippet ? highlightedHtml(snippet) : escapeHtml(path)) + '</span>' +
|
'<span class="wolai-search-result-snippet">' + (snippet ? highlightSearchText(snippet, query, exact) : escapeHtml(path)) + '</span>' +
|
||||||
'<span class="wolai-search-result-path"><span>' + escapeHtml(path) + '</span><span class="wolai-search-result-type">' + escapeHtml(resourceType) + '</span></span></span>' +
|
'<span class="wolai-search-result-path"><span>' + escapeHtml(path) + '</span><span class="wolai-search-result-type">' + escapeHtml(resourceType) + '</span></span></span>' +
|
||||||
'</button>';
|
'</button>';
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -2583,7 +2651,16 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ocrSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-ocr-settings"], [data-mnote-action="toggle-ocr-tasks"]');
|
var ocrTaskTrigger = closestAction(e.target, '[data-mnote-action="toggle-ocr-tasks"]');
|
||||||
|
if (ocrTaskTrigger) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.dispatchEvent(new CustomEvent('mnote:local-ocr-settings-action', {
|
||||||
|
detail: { action: 'tasks' }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ocrSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-ocr-settings"]');
|
||||||
if (ocrSettingsTrigger) {
|
if (ocrSettingsTrigger) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openLocalOcrSettingsPopover();
|
openLocalOcrSettingsPopover();
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
use crate::app::AppState;
|
||||||
|
use crate::context::RequestContext;
|
||||||
|
use crate::error::WebError;
|
||||||
|
use crate::hermes_tools::{ensure_write_authorized, ToolCallInput};
|
||||||
|
use crate::routes;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct IndexSettingsArgs {
|
||||||
|
#[serde(default)]
|
||||||
|
include_paths: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
schedule_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
schedule_time: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
schedule_date: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
run_on_change: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn index_status(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let workspace_id = effective_workspace_id(context, input);
|
||||||
|
let root_uri = required_root_uri(
|
||||||
|
context,
|
||||||
|
input,
|
||||||
|
"mnote_index_root_required",
|
||||||
|
"本地索引状态缺少 rootUri",
|
||||||
|
)?;
|
||||||
|
let root_path =
|
||||||
|
routes::ensure_local_workspace_read_access_with_state(state, context, &root_uri)
|
||||||
|
.map_err(|error| error.with_context(context))?;
|
||||||
|
let user_settings = read_user_settings(state, context, &workspace_id, &root_path)?;
|
||||||
|
let effective_settings = routes::effective_local_index_settings_for_root(
|
||||||
|
state.control_plane(),
|
||||||
|
&workspace_id,
|
||||||
|
&root_path,
|
||||||
|
)?;
|
||||||
|
let status = routes::local_index_status_with_settings(
|
||||||
|
&root_path,
|
||||||
|
&root_uri,
|
||||||
|
&workspace_id,
|
||||||
|
&user_settings,
|
||||||
|
&effective_settings,
|
||||||
|
)?;
|
||||||
|
Ok(json!({
|
||||||
|
"ok": true,
|
||||||
|
"schema": "mnote.index.status_result.v1",
|
||||||
|
"result": status
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn index_refresh(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let workspace_id = effective_workspace_id(context, input);
|
||||||
|
let root_uri = required_root_uri(
|
||||||
|
context,
|
||||||
|
input,
|
||||||
|
"mnote_index_root_required",
|
||||||
|
"本地索引刷新缺少 rootUri",
|
||||||
|
)?;
|
||||||
|
let root_path =
|
||||||
|
routes::ensure_local_workspace_read_access_with_state(state, context, &root_uri)
|
||||||
|
.map_err(|error| error.with_context(context))?;
|
||||||
|
let effective_settings = routes::effective_local_index_settings_for_root(
|
||||||
|
state.control_plane(),
|
||||||
|
&workspace_id,
|
||||||
|
&root_path,
|
||||||
|
)?;
|
||||||
|
let refreshed = routes::refresh_local_search_index_with_settings(
|
||||||
|
&root_path,
|
||||||
|
&root_uri,
|
||||||
|
&workspace_id,
|
||||||
|
&effective_settings,
|
||||||
|
)?;
|
||||||
|
Ok(json!({
|
||||||
|
"ok": true,
|
||||||
|
"schema": "mnote.index.refresh_result.v1",
|
||||||
|
"index": refreshed
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn index_update_settings(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
ensure_write_authorized(context, input)?;
|
||||||
|
let workspace_id = effective_workspace_id(context, input);
|
||||||
|
let root_uri = required_root_uri(
|
||||||
|
context,
|
||||||
|
input,
|
||||||
|
"mnote_index_root_required",
|
||||||
|
"本地索引设置缺少 rootUri",
|
||||||
|
)?;
|
||||||
|
let args = parse_settings_args(context, input)?;
|
||||||
|
let root_path =
|
||||||
|
routes::ensure_local_workspace_write_access_with_state(state, context, &root_uri)
|
||||||
|
.map_err(|error| error.with_context(context))?;
|
||||||
|
let actor_id = routes::current_actor_id(state, context).ok_or_else(|| {
|
||||||
|
WebError::new(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"local_index_settings_auth_required",
|
||||||
|
"本地索引设置需要登录用户",
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
})?;
|
||||||
|
if input.dry_run == Some(true) {
|
||||||
|
let planned_settings = routes::preview_user_local_index_settings(
|
||||||
|
state.control_plane(),
|
||||||
|
&actor_id,
|
||||||
|
&workspace_id,
|
||||||
|
&root_path,
|
||||||
|
&args.include_paths,
|
||||||
|
args.schedule_mode.as_deref(),
|
||||||
|
args.schedule_time.as_deref(),
|
||||||
|
args.schedule_date.as_deref(),
|
||||||
|
args.run_on_change,
|
||||||
|
)?;
|
||||||
|
let current_status = index_status(state, context, input).await?;
|
||||||
|
let would_clear_index_files = planned_settings.include_paths.is_empty();
|
||||||
|
return Ok(json!({
|
||||||
|
"ok": true,
|
||||||
|
"schema": "mnote.index.update_settings_plan.v1",
|
||||||
|
"dryRun": true,
|
||||||
|
"plannedSettings": planned_settings,
|
||||||
|
"currentStatus": current_status.get("result").cloned().unwrap_or(Value::Null),
|
||||||
|
"wouldRefresh": true,
|
||||||
|
"wouldClearIndexFiles": would_clear_index_files
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let settings = routes::write_user_local_index_settings(
|
||||||
|
state.control_plane(),
|
||||||
|
&actor_id,
|
||||||
|
&workspace_id,
|
||||||
|
&root_path,
|
||||||
|
&args.include_paths,
|
||||||
|
args.schedule_mode.as_deref(),
|
||||||
|
args.schedule_time.as_deref(),
|
||||||
|
args.schedule_date.as_deref(),
|
||||||
|
args.run_on_change,
|
||||||
|
)?;
|
||||||
|
let effective_settings = routes::effective_local_index_settings_for_root(
|
||||||
|
state.control_plane(),
|
||||||
|
&workspace_id,
|
||||||
|
&root_path,
|
||||||
|
)?;
|
||||||
|
let refreshed = routes::refresh_local_search_index_with_settings(
|
||||||
|
&root_path,
|
||||||
|
&root_uri,
|
||||||
|
&workspace_id,
|
||||||
|
&effective_settings,
|
||||||
|
)?;
|
||||||
|
let status = routes::local_index_status_with_settings(
|
||||||
|
&root_path,
|
||||||
|
&root_uri,
|
||||||
|
&workspace_id,
|
||||||
|
&settings,
|
||||||
|
&effective_settings,
|
||||||
|
)?;
|
||||||
|
Ok(json!({
|
||||||
|
"ok": true,
|
||||||
|
"schema": "mnote.index.update_settings_result.v1",
|
||||||
|
"settings": settings,
|
||||||
|
"index": refreshed,
|
||||||
|
"result": status
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_settings_args(
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
) -> Result<IndexSettingsArgs, WebError> {
|
||||||
|
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||||
|
serde_json::from_value::<IndexSettingsArgs>(args).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"mnote_index_settings_payload_invalid",
|
||||||
|
format!("本地索引设置参数无效: {error}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_user_settings(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
workspace_id: &str,
|
||||||
|
root_path: &std::path::Path,
|
||||||
|
) -> Result<routes::LocalIndexSettings, WebError> {
|
||||||
|
if let Some(actor_id) = routes::current_actor_id(state, context) {
|
||||||
|
return routes::read_user_local_index_settings(
|
||||||
|
state.control_plane(),
|
||||||
|
&actor_id,
|
||||||
|
workspace_id,
|
||||||
|
root_path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
routes::read_local_index_settings_or_default(root_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_workspace_id(context: &RequestContext, input: &ToolCallInput) -> String {
|
||||||
|
input
|
||||||
|
.effective_workspace_id()
|
||||||
|
.or_else(|| context.workspace.workspace_id.clone())
|
||||||
|
.unwrap_or_else(|| "default".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_root_uri(
|
||||||
|
context: &RequestContext,
|
||||||
|
input: &ToolCallInput,
|
||||||
|
code: &'static str,
|
||||||
|
message: &'static str,
|
||||||
|
) -> Result<String, WebError> {
|
||||||
|
input
|
||||||
|
.effective_root_uri()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| WebError::bad_request_code(code, message).with_context(context))
|
||||||
|
}
|
||||||
@@ -1,9 +1,94 @@
|
|||||||
|
use super::skill;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.hermes_tool_manifest.v1";
|
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.hermes_tool_manifest.v1";
|
||||||
pub const TOOL_SCHEMA_VERSION: &str = "mnote.hermes_tool.v1";
|
pub const TOOL_SCHEMA_VERSION: &str = "mnote.hermes_tool.v1";
|
||||||
|
|
||||||
pub fn manifest() -> Value {
|
pub fn manifest() -> Value {
|
||||||
|
let tools = annotate_tools_with_capabilities(vec![
|
||||||
|
skill_read_tool(),
|
||||||
|
context_snapshot_tool(),
|
||||||
|
context_read_current_page_tool(),
|
||||||
|
context_resolve_target_tool(),
|
||||||
|
doc_fetch_tool(),
|
||||||
|
doc_find_tool(),
|
||||||
|
evidence_search_tool(),
|
||||||
|
evidence_read_tool(),
|
||||||
|
evidence_open_tool(),
|
||||||
|
index_status_tool(),
|
||||||
|
index_refresh_tool(),
|
||||||
|
index_update_settings_tool(),
|
||||||
|
block_fetch_tool(),
|
||||||
|
doc_plan_update_tool(),
|
||||||
|
block_replace_tool(),
|
||||||
|
block_insert_after_tool(),
|
||||||
|
block_delete_tool(),
|
||||||
|
block_move_after_tool(),
|
||||||
|
doc_apply_block_ops_tool(),
|
||||||
|
doc_markdown_edit_tool(),
|
||||||
|
page_get_tool(),
|
||||||
|
page_save_tool(),
|
||||||
|
available_tool(
|
||||||
|
"mnote.page.update_title",
|
||||||
|
"更新当前页面标题",
|
||||||
|
["page.write"],
|
||||||
|
),
|
||||||
|
available_tool(
|
||||||
|
"mnote.page.update_options",
|
||||||
|
"更新当前页面设置",
|
||||||
|
["page.write"],
|
||||||
|
),
|
||||||
|
mindmap_fetch_tool(),
|
||||||
|
mindmap_apply_ops_tool(),
|
||||||
|
mindmap_create_from_outline_tool(),
|
||||||
|
office_fetch_summary_tool(),
|
||||||
|
office_propose_changes_tool(),
|
||||||
|
onlyoffice_session_current_tool(),
|
||||||
|
onlyoffice_capabilities_tool(),
|
||||||
|
onlyoffice_selection_get_tool(),
|
||||||
|
onlyoffice_document_insert_text_tool(),
|
||||||
|
onlyoffice_document_replace_selection_tool(),
|
||||||
|
onlyoffice_document_insert_html_tool(),
|
||||||
|
onlyoffice_document_export_tool(),
|
||||||
|
onlyoffice_document_search_replace_tool(),
|
||||||
|
onlyoffice_document_insert_table_tool(),
|
||||||
|
onlyoffice_document_get_comments_tool(),
|
||||||
|
onlyoffice_document_add_comment_tool(),
|
||||||
|
onlyoffice_sheet_get_sheets_tool(),
|
||||||
|
onlyoffice_sheet_add_sheet_tool(),
|
||||||
|
onlyoffice_sheet_rename_sheet_tool(),
|
||||||
|
onlyoffice_sheet_get_range_tool(),
|
||||||
|
onlyoffice_sheet_get_range_values_tool(),
|
||||||
|
onlyoffice_sheet_get_values_tool(),
|
||||||
|
onlyoffice_sheet_set_value_tool(),
|
||||||
|
onlyoffice_sheet_set_formula_tool(),
|
||||||
|
onlyoffice_sheet_batch_set_values_tool(),
|
||||||
|
onlyoffice_sheet_set_range_values_tool(),
|
||||||
|
onlyoffice_sheet_format_range_tool(),
|
||||||
|
onlyoffice_sheet_set_dimensions_tool(),
|
||||||
|
onlyoffice_sheet_sort_range_tool(),
|
||||||
|
onlyoffice_sheet_add_chart_tool(),
|
||||||
|
onlyoffice_presentation_get_slides_tool(),
|
||||||
|
onlyoffice_presentation_get_slide_texts_tool(),
|
||||||
|
onlyoffice_presentation_get_shapes_tool(),
|
||||||
|
onlyoffice_presentation_add_text_slide_tool(),
|
||||||
|
onlyoffice_presentation_replace_text_tool(),
|
||||||
|
onlyoffice_presentation_set_shape_text_tool(),
|
||||||
|
onlyoffice_presentation_delete_slide_tool(),
|
||||||
|
onlyoffice_presentation_add_table_tool(),
|
||||||
|
onlyoffice_presentation_clear_slide_tool(),
|
||||||
|
onlyoffice_presentation_add_shape_tool(),
|
||||||
|
available_tool(
|
||||||
|
"mnote.artifact.create_summary",
|
||||||
|
"为当前页面创建或更新 AI Summary",
|
||||||
|
["artifact.write"],
|
||||||
|
),
|
||||||
|
available_tool(
|
||||||
|
"mnote.artifact.create_ai_note",
|
||||||
|
"基于当前页面创建新的 AI Note",
|
||||||
|
["artifact.write"],
|
||||||
|
),
|
||||||
|
]);
|
||||||
json!({
|
json!({
|
||||||
"schemaVersion": MANIFEST_SCHEMA_VERSION,
|
"schemaVersion": MANIFEST_SCHEMA_VERSION,
|
||||||
"plugin": {
|
"plugin": {
|
||||||
@@ -12,74 +97,30 @@ pub fn manifest() -> Value {
|
|||||||
"runtimeOwner": "mnote-web",
|
"runtimeOwner": "mnote-web",
|
||||||
"writeOwner": "rust-runtime-kernel"
|
"writeOwner": "rust-runtime-kernel"
|
||||||
},
|
},
|
||||||
"tools": [
|
"capabilities": skill::manifest_capabilities(),
|
||||||
skill_read_tool(),
|
"tools": tools
|
||||||
context_snapshot_tool(),
|
|
||||||
context_read_current_page_tool(),
|
|
||||||
context_resolve_target_tool(),
|
|
||||||
doc_fetch_tool(),
|
|
||||||
doc_find_tool(),
|
|
||||||
evidence_search_tool(),
|
|
||||||
evidence_read_tool(),
|
|
||||||
evidence_open_tool(),
|
|
||||||
block_fetch_tool(),
|
|
||||||
doc_plan_update_tool(),
|
|
||||||
block_replace_tool(),
|
|
||||||
block_insert_after_tool(),
|
|
||||||
block_delete_tool(),
|
|
||||||
block_move_after_tool(),
|
|
||||||
doc_apply_block_ops_tool(),
|
|
||||||
doc_markdown_edit_tool(),
|
|
||||||
page_get_tool(),
|
|
||||||
page_save_tool(),
|
|
||||||
available_tool("mnote.page.update_title", "更新当前页面标题", ["page.write"]),
|
|
||||||
available_tool("mnote.page.update_options", "更新当前页面设置", ["page.write"]),
|
|
||||||
mindmap_fetch_tool(),
|
|
||||||
mindmap_apply_ops_tool(),
|
|
||||||
mindmap_create_from_outline_tool(),
|
|
||||||
office_fetch_summary_tool(),
|
|
||||||
office_propose_changes_tool(),
|
|
||||||
onlyoffice_session_current_tool(),
|
|
||||||
onlyoffice_capabilities_tool(),
|
|
||||||
onlyoffice_selection_get_tool(),
|
|
||||||
onlyoffice_document_insert_text_tool(),
|
|
||||||
onlyoffice_document_replace_selection_tool(),
|
|
||||||
onlyoffice_document_insert_html_tool(),
|
|
||||||
onlyoffice_document_export_tool(),
|
|
||||||
onlyoffice_document_search_replace_tool(),
|
|
||||||
onlyoffice_document_insert_table_tool(),
|
|
||||||
onlyoffice_document_get_comments_tool(),
|
|
||||||
onlyoffice_document_add_comment_tool(),
|
|
||||||
onlyoffice_sheet_get_sheets_tool(),
|
|
||||||
onlyoffice_sheet_add_sheet_tool(),
|
|
||||||
onlyoffice_sheet_rename_sheet_tool(),
|
|
||||||
onlyoffice_sheet_get_range_tool(),
|
|
||||||
onlyoffice_sheet_get_range_values_tool(),
|
|
||||||
onlyoffice_sheet_get_values_tool(),
|
|
||||||
onlyoffice_sheet_set_value_tool(),
|
|
||||||
onlyoffice_sheet_set_formula_tool(),
|
|
||||||
onlyoffice_sheet_batch_set_values_tool(),
|
|
||||||
onlyoffice_sheet_set_range_values_tool(),
|
|
||||||
onlyoffice_sheet_format_range_tool(),
|
|
||||||
onlyoffice_sheet_set_dimensions_tool(),
|
|
||||||
onlyoffice_sheet_sort_range_tool(),
|
|
||||||
onlyoffice_sheet_add_chart_tool(),
|
|
||||||
onlyoffice_presentation_get_slides_tool(),
|
|
||||||
onlyoffice_presentation_get_slide_texts_tool(),
|
|
||||||
onlyoffice_presentation_get_shapes_tool(),
|
|
||||||
onlyoffice_presentation_add_text_slide_tool(),
|
|
||||||
onlyoffice_presentation_replace_text_tool(),
|
|
||||||
onlyoffice_presentation_set_shape_text_tool(),
|
|
||||||
onlyoffice_presentation_delete_slide_tool(),
|
|
||||||
onlyoffice_presentation_add_table_tool(),
|
|
||||||
onlyoffice_presentation_clear_slide_tool(),
|
|
||||||
onlyoffice_presentation_add_shape_tool(),
|
|
||||||
available_tool("mnote.artifact.create_summary", "为当前页面创建或更新 AI Summary", ["artifact.write"]),
|
|
||||||
available_tool("mnote.artifact.create_ai_note", "基于当前页面创建新的 AI Note", ["artifact.write"])
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn annotate_tools_with_capabilities(tools: Vec<Value>) -> Vec<Value> {
|
||||||
|
tools
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut tool| {
|
||||||
|
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
let capability_ids = skill::capability_ids_for_tool(name);
|
||||||
|
if let Value::Object(map) = &mut tool {
|
||||||
|
if let Some(first) = capability_ids.first() {
|
||||||
|
map.insert("capabilityId".into(), json!(first));
|
||||||
|
}
|
||||||
|
if !capability_ids.is_empty() {
|
||||||
|
map.insert("capabilityIds".into(), json!(capability_ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tool
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn skill_read_tool() -> Value {
|
fn skill_read_tool() -> Value {
|
||||||
let mut properties = base_identity_properties();
|
let mut properties = base_identity_properties();
|
||||||
if let Value::Object(map) = &mut properties {
|
if let Value::Object(map) = &mut properties {
|
||||||
@@ -286,7 +327,7 @@ fn evidence_search_tool() -> Value {
|
|||||||
}
|
}
|
||||||
json!({
|
json!({
|
||||||
"name": "mnote.evidence.search",
|
"name": "mnote.evidence.search",
|
||||||
"description": "搜索可回跳原文的文档证据,返回 quote、EvidenceLocator 与 openAction。",
|
"description": "搜索可回跳原文的文档证据,返回 quote、EvidenceLocator、openAction 与可直接放进最终回答的 citationMarkdown 链接。",
|
||||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||||
"capabilityScope": ["evidence.read", "page.read"],
|
"capabilityScope": ["evidence.read", "page.read"],
|
||||||
"status": "available",
|
"status": "available",
|
||||||
@@ -317,7 +358,7 @@ fn evidence_read_tool() -> Value {
|
|||||||
}
|
}
|
||||||
json!({
|
json!({
|
||||||
"name": "mnote.evidence.read",
|
"name": "mnote.evidence.read",
|
||||||
"description": "按 EvidenceLocator 读取原文证据及周边上下文,供回答引用。",
|
"description": "按 EvidenceLocator 读取原文证据及周边上下文,返回 quote/contextBlocks 与可点击引用链接供回答引用。",
|
||||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||||
"capabilityScope": ["evidence.read", "page.read"],
|
"capabilityScope": ["evidence.read", "page.read"],
|
||||||
"status": "available",
|
"status": "available",
|
||||||
@@ -337,7 +378,7 @@ fn evidence_open_tool() -> Value {
|
|||||||
}
|
}
|
||||||
json!({
|
json!({
|
||||||
"name": "mnote.evidence.open",
|
"name": "mnote.evidence.open",
|
||||||
"description": "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。",
|
"description": "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作,并返回 citationUrl/citationMarkdown。",
|
||||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||||
"capabilityScope": ["evidence.read", "page.read"],
|
"capabilityScope": ["evidence.read", "page.read"],
|
||||||
"status": "available",
|
"status": "available",
|
||||||
@@ -350,6 +391,79 @@ fn evidence_open_tool() -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn index_status_tool() -> Value {
|
||||||
|
let mut properties = base_identity_properties();
|
||||||
|
if let Value::Object(map) = &mut properties {
|
||||||
|
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"name": "mnote.index.status",
|
||||||
|
"description": "查看本地索引范围、缓存文件、构建时间、文档数和 evidence block 数。",
|
||||||
|
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||||
|
"capabilityScope": ["index.read", "evidence.read"],
|
||||||
|
"status": "available",
|
||||||
|
"annotations": tool_annotations(true, false, true, false),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["workspaceId", "rootUri"],
|
||||||
|
"properties": properties
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_refresh_tool() -> Value {
|
||||||
|
let mut properties = base_identity_properties();
|
||||||
|
if let Value::Object(map) = &mut properties {
|
||||||
|
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"name": "mnote.index.refresh",
|
||||||
|
"description": "按当前有效索引范围重建本地搜索/evidence 缓存,不修改 Markdown 正文。",
|
||||||
|
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||||
|
"capabilityScope": ["index.read", "evidence.read"],
|
||||||
|
"status": "available",
|
||||||
|
"annotations": tool_annotations(true, false, false, false),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["workspaceId", "rootUri"],
|
||||||
|
"properties": properties
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_update_settings_tool() -> Value {
|
||||||
|
let mut properties = base_identity_properties();
|
||||||
|
if let Value::Object(map) = &mut properties {
|
||||||
|
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||||
|
map.insert(
|
||||||
|
"includePaths".into(),
|
||||||
|
json!({ "type": "array", "items": { "type": "string" } }),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"scheduleMode".into(),
|
||||||
|
json!({ "type": "string", "enum": ["manual", "daily", "weekly", "monthly"] }),
|
||||||
|
);
|
||||||
|
map.insert("scheduleTime".into(), json!({ "type": "string" }));
|
||||||
|
map.insert("scheduleDate".into(), json!({ "type": "string" }));
|
||||||
|
map.insert("runOnChange".into(), json!({ "type": "boolean" }));
|
||||||
|
map.insert("dryRun".into(), json!({ "type": "boolean" }));
|
||||||
|
map.insert("idempotencyKey".into(), json!({ "type": "string" }));
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"name": "mnote.index.update_settings",
|
||||||
|
"description": "新增或删除本地索引范围;includePaths 为空表示删除当前用户索引范围并在无有效范围时清空索引文件。",
|
||||||
|
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||||
|
"capabilityScope": ["index.write", "evidence.read"],
|
||||||
|
"status": "available",
|
||||||
|
"annotations": tool_annotations(false, true, false, false),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["workspaceId", "rootUri", "includePaths", "dryRun", "idempotencyKey"],
|
||||||
|
"properties": properties
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn block_fetch_tool() -> Value {
|
fn block_fetch_tool() -> Value {
|
||||||
let mut properties = base_identity_properties();
|
let mut properties = base_identity_properties();
|
||||||
if let Value::Object(map) = &mut properties {
|
if let Value::Object(map) = &mut properties {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod block;
|
|||||||
pub mod context_tools;
|
pub mod context_tools;
|
||||||
pub mod doc;
|
pub mod doc;
|
||||||
pub mod evidence;
|
pub mod evidence;
|
||||||
|
pub mod index;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod onlyoffice_live;
|
pub mod onlyoffice_live;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ use axum::http::StatusCode;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct MnoteSkill {
|
pub struct MnoteCapabilityPack {
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
pub title: &'static str,
|
pub title: &'static str,
|
||||||
pub description: &'static str,
|
pub description: &'static str,
|
||||||
|
pub category: &'static str,
|
||||||
pub agent_ids: &'static [&'static str],
|
pub agent_ids: &'static [&'static str],
|
||||||
pub read_only: bool,
|
pub read_only: bool,
|
||||||
pub requires_context_refs: &'static [&'static str],
|
pub requires_context_refs: &'static [&'static str],
|
||||||
@@ -16,11 +17,14 @@ pub struct MnoteSkill {
|
|||||||
pub content: &'static str,
|
pub content: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SKILLS: &[MnoteSkill] = &[
|
pub type MnoteSkill = MnoteCapabilityPack;
|
||||||
MnoteSkill {
|
|
||||||
|
const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||||
|
MnoteCapabilityPack {
|
||||||
id: "mnote-current-page",
|
id: "mnote-current-page",
|
||||||
title: "MNote current page",
|
title: "当前页读取",
|
||||||
description: "Read the current MNote Markdown page only when the task needs page content.",
|
description: "仅在任务需要当前 MNote Markdown 页面内容时读取当前页。",
|
||||||
|
category: "mnote",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["hermes", "reasonix"],
|
||||||
read_only: true,
|
read_only: true,
|
||||||
requires_context_refs: &["current_page"],
|
requires_context_refs: &["current_page"],
|
||||||
@@ -31,12 +35,13 @@ const SKILLS: &[MnoteSkill] = &[
|
|||||||
],
|
],
|
||||||
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
|
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
|
||||||
},
|
},
|
||||||
MnoteSkill {
|
MnoteCapabilityPack {
|
||||||
id: "mnote-document-evidence",
|
id: "mnote-local-index",
|
||||||
title: "MNote document evidence",
|
title: "本地索引与证据检索",
|
||||||
description: "Search local documents and resources with clickable evidence locators.",
|
description: "检索本地文档证据,并管理本地索引范围、刷新和删除。",
|
||||||
|
category: "knowledge",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["hermes", "reasonix"],
|
||||||
read_only: true,
|
read_only: false,
|
||||||
requires_context_refs: &["folder"],
|
requires_context_refs: &["folder"],
|
||||||
tool_names: &[
|
tool_names: &[
|
||||||
"mnote.context.snapshot",
|
"mnote.context.snapshot",
|
||||||
@@ -44,23 +49,28 @@ const SKILLS: &[MnoteSkill] = &[
|
|||||||
"mnote.evidence.search",
|
"mnote.evidence.search",
|
||||||
"mnote.evidence.read",
|
"mnote.evidence.read",
|
||||||
"mnote.evidence.open",
|
"mnote.evidence.open",
|
||||||
|
"mnote.index.status",
|
||||||
|
"mnote.index.refresh",
|
||||||
|
"mnote.index.update_settings",
|
||||||
],
|
],
|
||||||
content: include_str!("../../../../../skills/mnote-document-evidence/SKILL.md"),
|
content: include_str!("../../../../../skills/mnote-local-index/SKILL.md"),
|
||||||
},
|
},
|
||||||
MnoteSkill {
|
MnoteCapabilityPack {
|
||||||
id: "mnote-local-file",
|
id: "mnote-local-file",
|
||||||
title: "MNote local file editing",
|
title: "本地文件编辑",
|
||||||
description: "Read and patch local Markdown files inside MNote allowed roots.",
|
description: "在 MNote 授权目录内读取和修改本地 Markdown 文件。",
|
||||||
|
category: "file",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["hermes", "reasonix"],
|
||||||
read_only: false,
|
read_only: false,
|
||||||
requires_context_refs: &["current_page", "file", "folder"],
|
requires_context_refs: &["current_page", "file", "folder"],
|
||||||
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
||||||
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
|
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
|
||||||
},
|
},
|
||||||
MnoteSkill {
|
MnoteCapabilityPack {
|
||||||
id: "mnote-onlyoffice-live",
|
id: "mnote-onlyoffice-live",
|
||||||
title: "MNote ONLYOFFICE live bridge",
|
title: "ONLYOFFICE 实时编辑",
|
||||||
description: "Operate the currently open ONLYOFFICE editor session for Word, Excel, and PPT.",
|
description: "操作当前已打开的 ONLYOFFICE Word、Excel、PPT 编辑会话。",
|
||||||
|
category: "office",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["hermes", "reasonix"],
|
||||||
read_only: false,
|
read_only: false,
|
||||||
requires_context_refs: &["onlyoffice"],
|
requires_context_refs: &["onlyoffice"],
|
||||||
@@ -103,10 +113,11 @@ const SKILLS: &[MnoteSkill] = &[
|
|||||||
],
|
],
|
||||||
content: include_str!("../../../../../skills/mnote-onlyoffice-live/SKILL.md"),
|
content: include_str!("../../../../../skills/mnote-onlyoffice-live/SKILL.md"),
|
||||||
},
|
},
|
||||||
MnoteSkill {
|
MnoteCapabilityPack {
|
||||||
id: "mnote-mindmap",
|
id: "mnote-mindmap",
|
||||||
title: "MNote mindmap editing",
|
title: "思维导图",
|
||||||
description: "Read, update, summarize, or create MNote mindmap resources, including generating a new mindmap from PDF or document outlines.",
|
description: "读取、更新、总结或创建 MNote 思维导图资源。",
|
||||||
|
category: "resource",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["hermes", "reasonix"],
|
||||||
read_only: false,
|
read_only: false,
|
||||||
requires_context_refs: &["current_page", "file", "folder", "resource"],
|
requires_context_refs: &["current_page", "file", "folder", "resource"],
|
||||||
@@ -119,10 +130,11 @@ const SKILLS: &[MnoteSkill] = &[
|
|||||||
],
|
],
|
||||||
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
|
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
|
||||||
},
|
},
|
||||||
MnoteSkill {
|
MnoteCapabilityPack {
|
||||||
id: "mnote-chat-only",
|
id: "mnote-chat-only",
|
||||||
title: "MNote chat only",
|
title: "纯聊天",
|
||||||
description: "Reply conversationally without reading or writing MNote page/file context.",
|
description: "只进行对话回复,不读取或写入 MNote 页面、文件上下文。",
|
||||||
|
category: "chat",
|
||||||
agent_ids: &["chat_only", "hermes", "reasonix"],
|
agent_ids: &["chat_only", "hermes", "reasonix"],
|
||||||
read_only: true,
|
read_only: true,
|
||||||
requires_context_refs: &[],
|
requires_context_refs: &[],
|
||||||
@@ -132,20 +144,75 @@ const SKILLS: &[MnoteSkill] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
pub fn skill_summaries_for_agent(agent_id: Option<&str>) -> Vec<Value> {
|
pub fn skill_summaries_for_agent(agent_id: Option<&str>) -> Vec<Value> {
|
||||||
SKILLS
|
capability_summaries_for_agent(agent_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capability_summaries_for_agent(agent_id: Option<&str>) -> Vec<Value> {
|
||||||
|
CAPABILITY_PACKS
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|skill| skill_matches_agent(skill, agent_id))
|
.filter(|skill| skill_matches_agent(skill, agent_id))
|
||||||
.map(skill_summary)
|
.map(skill_summary)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn public_capability_packs() -> &'static [MnoteCapabilityPack] {
|
||||||
|
CAPABILITY_PACKS
|
||||||
|
}
|
||||||
|
|
||||||
pub fn find_skill(skill_id: &str, agent_id: Option<&str>) -> Option<&'static MnoteSkill> {
|
pub fn find_skill(skill_id: &str, agent_id: Option<&str>) -> Option<&'static MnoteSkill> {
|
||||||
let requested = skill_id.trim();
|
find_capability_pack(skill_id, agent_id)
|
||||||
SKILLS
|
}
|
||||||
|
|
||||||
|
pub fn find_capability_pack(
|
||||||
|
capability_id: &str,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
) -> Option<&'static MnoteCapabilityPack> {
|
||||||
|
let requested = canonical_skill_id(capability_id.trim());
|
||||||
|
CAPABILITY_PACKS
|
||||||
.iter()
|
.iter()
|
||||||
.find(|skill| skill.id == requested && skill_matches_agent(skill, agent_id))
|
.find(|skill| skill.id == requested && skill_matches_agent(skill, agent_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn capability_ids_for_tool(tool_name: &str) -> Vec<&'static str> {
|
||||||
|
let name = tool_name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
CAPABILITY_PACKS
|
||||||
|
.iter()
|
||||||
|
.filter(|pack| pack.tool_names.iter().any(|tool| *tool == name))
|
||||||
|
.map(|pack| pack.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn manifest_capabilities() -> Vec<Value> {
|
||||||
|
CAPABILITY_PACKS
|
||||||
|
.iter()
|
||||||
|
.map(|pack| {
|
||||||
|
json!({
|
||||||
|
"id": pack.id,
|
||||||
|
"title": pack.title,
|
||||||
|
"description": pack.description,
|
||||||
|
"category": pack.category,
|
||||||
|
"agentIds": pack.agent_ids,
|
||||||
|
"readOnly": pack.read_only,
|
||||||
|
"requiresContextRefs": pack.requires_context_refs,
|
||||||
|
"skillId": pack.id,
|
||||||
|
"toolNames": pack.tool_names,
|
||||||
|
"uiKind": if pack.category == "chat" { "chat" } else { "ai_capability" },
|
||||||
|
"public": true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_skill_id(skill_id: &str) -> &str {
|
||||||
|
match skill_id {
|
||||||
|
"mnote-document-evidence" => "mnote-local-index",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn skill_read(
|
pub async fn skill_read(
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
input: &ToolCallInput,
|
input: &ToolCallInput,
|
||||||
@@ -196,6 +263,7 @@ fn skill_summary(skill: &MnoteSkill) -> Value {
|
|||||||
"id": skill.id,
|
"id": skill.id,
|
||||||
"title": skill.title,
|
"title": skill.title,
|
||||||
"description": skill.description,
|
"description": skill.description,
|
||||||
|
"category": skill.category,
|
||||||
"agentIds": skill.agent_ids,
|
"agentIds": skill.agent_ids,
|
||||||
"readOnly": skill.read_only,
|
"readOnly": skill.read_only,
|
||||||
"requiresContextRefs": skill.requires_context_refs,
|
"requiresContextRefs": skill.requires_context_refs,
|
||||||
@@ -296,18 +364,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skill_registry_exposes_document_evidence_skill_to_agents() {
|
fn skill_registry_exposes_local_index_skill_to_agents() {
|
||||||
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
|
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
|
||||||
let skill = hermes_skills
|
let skill = hermes_skills
|
||||||
.iter()
|
.iter()
|
||||||
.find(|skill| skill["id"] == "mnote-document-evidence")
|
.find(|skill| skill["id"] == "mnote-local-index")
|
||||||
.expect("hermes should see document evidence skill");
|
.expect("hermes should see local index skill");
|
||||||
assert_eq!(skill["readOnly"], true);
|
assert_eq!(skill["readOnly"], false);
|
||||||
assert!(skill["toolNames"]
|
assert!(skill["toolNames"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("tool names")
|
.expect("tool names")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|name| name == "mnote.evidence.search"));
|
.any(|name| name == "mnote.evidence.search"));
|
||||||
|
assert!(skill["toolNames"]
|
||||||
|
.as_array()
|
||||||
|
.expect("tool names")
|
||||||
|
.iter()
|
||||||
|
.any(|name| name == "mnote.index.update_settings"));
|
||||||
|
assert!(!hermes_skills
|
||||||
|
.iter()
|
||||||
|
.any(|skill| skill["id"] == "mnote-document-evidence"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_read_keeps_document_evidence_compat_alias() {
|
||||||
|
let skill = find_skill("mnote-document-evidence", Some("reasonix"))
|
||||||
|
.expect("compat alias should resolve");
|
||||||
|
assert_eq!(skill.id, "mnote-local-index");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ use axum::extract::{Extension, State};
|
|||||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use core_protocol::{
|
use core_protocol::{
|
||||||
EvidenceBBox, EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceReadRequest,
|
EvidenceBBox, EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceRange,
|
||||||
EvidenceResourceKind, EvidenceSearchMode, EvidenceSearchRequest, EvidenceSearchResponse,
|
EvidenceReadRequest, EvidenceResourceKind, EvidenceSearchMode, EvidenceSearchRequest,
|
||||||
EvidenceSearchResult, ResourceSourceMap, SourceMapBlock, SourceMapTextItem,
|
EvidenceSearchResponse, EvidenceSearchResult, ResourceSourceMap, SourceMapBlock,
|
||||||
EVIDENCE_LOCATOR_SCHEMA,
|
SourceMapTextItem, EVIDENCE_LOCATOR_SCHEMA,
|
||||||
};
|
};
|
||||||
use serde_json::Map;
|
use serde_json::Map;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -47,18 +47,31 @@ pub(crate) async fn search_payload(
|
|||||||
)
|
)
|
||||||
.map_err(|error| error.with_context(context))?;
|
.map_err(|error| error.with_context(context))?;
|
||||||
let page_id = body.scope.target_document_id.as_deref();
|
let page_id = body.scope.target_document_id.as_deref();
|
||||||
|
let evidence_owner_filter = if body.scope.include_resources || body.scope.include_ocr {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
page_id
|
||||||
|
};
|
||||||
let query = body.query.trim().to_string();
|
let query = body.query.trim().to_string();
|
||||||
if matches!(body.mode, EvidenceSearchMode::Graph) {
|
if matches!(body.mode, EvidenceSearchMode::Graph) {
|
||||||
if let Some(results) = local_search_index::query_evidence_graph_results(
|
if let Some(mut results) = local_search_index::query_evidence_graph_results(
|
||||||
&root_path, &query, page_id, body.top_k,
|
&root_path,
|
||||||
|
&query,
|
||||||
|
evidence_owner_filter,
|
||||||
|
body.top_k,
|
||||||
)? {
|
)? {
|
||||||
|
enrich_citation_links(&mut results);
|
||||||
return Ok(json!(EvidenceSearchResponse { ok: true, results }));
|
return Ok(json!(EvidenceSearchResponse { ok: true, results }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(mut results) =
|
if let Some(mut results) = local_search_index::query_evidence_sqlite_results(
|
||||||
local_search_index::query_evidence_sqlite_results(&root_path, &query, page_id, body.top_k)?
|
&root_path,
|
||||||
{
|
&query,
|
||||||
|
evidence_owner_filter,
|
||||||
|
body.top_k,
|
||||||
|
)? {
|
||||||
enrich_sqlite_evidence_results(&mut results, &root_path, &query);
|
enrich_sqlite_evidence_results(&mut results, &root_path, &query);
|
||||||
|
enrich_citation_links(&mut results);
|
||||||
if !results.is_empty() || !query.is_empty() {
|
if !results.is_empty() || !query.is_empty() {
|
||||||
let response = EvidenceSearchResponse { ok: true, results };
|
let response = EvidenceSearchResponse { ok: true, results };
|
||||||
return Ok(json!(response));
|
return Ok(json!(response));
|
||||||
@@ -77,9 +90,13 @@ pub(crate) async fn search_payload(
|
|||||||
)?;
|
)?;
|
||||||
let response = EvidenceSearchResponse {
|
let response = EvidenceSearchResponse {
|
||||||
ok: true,
|
ok: true,
|
||||||
results: evidence_results_from_local_search(
|
results: {
|
||||||
&search, &root_path, &root_uri, body.mode, &query,
|
let mut results = evidence_results_from_local_search(
|
||||||
),
|
&search, &root_path, &root_uri, body.mode, &query,
|
||||||
|
);
|
||||||
|
enrich_citation_links(&mut results);
|
||||||
|
results
|
||||||
|
},
|
||||||
};
|
};
|
||||||
Ok(json!(response))
|
Ok(json!(response))
|
||||||
}
|
}
|
||||||
@@ -127,6 +144,149 @@ fn enrich_sqlite_evidence_results(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn enrich_citation_links(results: &mut [EvidenceSearchResult]) {
|
||||||
|
for result in results {
|
||||||
|
let citation_url = citation_url_for_locator(&result.source);
|
||||||
|
result.source.open_action.url = citation_url.clone();
|
||||||
|
result.citation_label = Some(citation_label_for_locator(&result.source));
|
||||||
|
result.citation_markdown = Some(citation_markdown_for_locator(&result.source));
|
||||||
|
result.citation_url = Some(citation_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
|
||||||
|
let label = citation_label_for_locator(locator);
|
||||||
|
let url = citation_url_for_locator(locator);
|
||||||
|
format!(
|
||||||
|
"[{}]({})",
|
||||||
|
markdown_link_label_escape(&label),
|
||||||
|
url.replace(')', "%29")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn citation_label_for_locator(locator: &EvidenceLocator) -> String {
|
||||||
|
let source_path = locator
|
||||||
|
.resource_path
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or(locator.owner_document_path.as_str());
|
||||||
|
let name = source_path
|
||||||
|
.rsplit('/')
|
||||||
|
.find(|part| !part.trim().is_empty())
|
||||||
|
.unwrap_or(source_path)
|
||||||
|
.trim();
|
||||||
|
let mut parts = vec![if name.is_empty() {
|
||||||
|
"证据".to_string()
|
||||||
|
} else {
|
||||||
|
name.to_string()
|
||||||
|
}];
|
||||||
|
if let Some(page) = locator.page {
|
||||||
|
parts.push(format!("p.{page}"));
|
||||||
|
}
|
||||||
|
if let Some(section) = locator
|
||||||
|
.section_path
|
||||||
|
.last()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
parts.push(section.to_string());
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
|
||||||
|
let owner_document_id = locator.owner_document_id.trim();
|
||||||
|
let mut url = if owner_document_id.is_empty() {
|
||||||
|
let existing = locator.open_action.url.trim();
|
||||||
|
if existing.is_empty() {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
existing.to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("/documents/{owner_document_id}")
|
||||||
|
};
|
||||||
|
append_query_param(&mut url, "sourceKind", "local_folder");
|
||||||
|
append_query_param(&mut url, "rootUri", locator.root_uri.trim());
|
||||||
|
if let Some(resource_path) = locator
|
||||||
|
.resource_path
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
append_query_param(
|
||||||
|
&mut url,
|
||||||
|
"resourceTab",
|
||||||
|
&format!(
|
||||||
|
"resource:file:{}:{}",
|
||||||
|
locator.root_uri.trim(),
|
||||||
|
resource_path
|
||||||
|
),
|
||||||
|
);
|
||||||
|
append_query_param(&mut url, "resourcePath", resource_path);
|
||||||
|
}
|
||||||
|
if let Some(page) = locator.page {
|
||||||
|
append_query_param(&mut url, "page", &page.to_string());
|
||||||
|
}
|
||||||
|
if let Some(bbox) = &locator.bbox {
|
||||||
|
append_query_param(
|
||||||
|
&mut url,
|
||||||
|
"bbox",
|
||||||
|
&format!("{},{},{},{}", bbox.x0, bbox.y0, bbox.x1, bbox.y1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(block_id) = locator.block_id.as_deref() {
|
||||||
|
append_query_param(&mut url, "blockId", block_id);
|
||||||
|
}
|
||||||
|
if let Some(source_map_path) = locator.source_map_path.as_deref() {
|
||||||
|
append_query_param(&mut url, "sourceMapPath", source_map_path);
|
||||||
|
}
|
||||||
|
if let Some(line_range) = &locator.line_range {
|
||||||
|
append_query_param(
|
||||||
|
&mut url,
|
||||||
|
"lineRange",
|
||||||
|
&format!("{}-{}", line_range.start, line_range.end),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(char_range) = &locator.char_range {
|
||||||
|
append_query_param(
|
||||||
|
&mut url,
|
||||||
|
"charRange",
|
||||||
|
&format!("{}-{}", char_range.start, char_range.end),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_query_param(url: &mut String, key: &str, value: &str) {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let separator = if url.contains('?') { '&' } else { '?' };
|
||||||
|
url.push(separator);
|
||||||
|
url.push_str(&encode_query_component(key));
|
||||||
|
url.push('=');
|
||||||
|
url.push_str(&encode_query_component(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_query_component(value: &str) -> String {
|
||||||
|
let mut encoded = String::with_capacity(value.len());
|
||||||
|
for byte in value.as_bytes() {
|
||||||
|
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||||
|
encoded.push(*byte as char);
|
||||||
|
} else {
|
||||||
|
encoded.push_str(&format!("%{byte:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn markdown_link_label_escape(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('[', "\\[")
|
||||||
|
.replace(']', "\\]")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn read(
|
pub async fn read(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
@@ -154,12 +314,13 @@ pub(crate) async fn read_payload(
|
|||||||
state, context, &root_uri,
|
state, context, &root_uri,
|
||||||
)
|
)
|
||||||
.map_err(|error| error.with_context(context))?;
|
.map_err(|error| error.with_context(context))?;
|
||||||
if let Some(results) = read_source_map_context(
|
if let Some(mut results) = read_source_map_context(
|
||||||
&root_path,
|
&root_path,
|
||||||
&body.locator,
|
&body.locator,
|
||||||
body.context.before_blocks,
|
body.context.before_blocks,
|
||||||
body.context.after_blocks,
|
body.context.after_blocks,
|
||||||
) {
|
) {
|
||||||
|
enrich_citation_links(&mut results);
|
||||||
let quote = results
|
let quote = results
|
||||||
.iter()
|
.iter()
|
||||||
.find(|item| locator_matches(&item.source, &body.locator))
|
.find(|item| locator_matches(&item.source, &body.locator))
|
||||||
@@ -176,17 +337,21 @@ pub(crate) async fn read_payload(
|
|||||||
"locator": body.locator,
|
"locator": body.locator,
|
||||||
"quote": quote,
|
"quote": quote,
|
||||||
"sectionPath": section_path,
|
"sectionPath": section_path,
|
||||||
|
"citationUrl": citation_url_for_locator(&body.locator),
|
||||||
|
"citationLabel": citation_label_for_locator(&body.locator),
|
||||||
|
"citationMarkdown": citation_markdown_for_locator(&body.locator),
|
||||||
"contextBlocks": results,
|
"contextBlocks": results,
|
||||||
});
|
});
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
if let Some(results) = local_search_index::read_evidence_sqlite_context(
|
if let Some(mut results) = local_search_index::read_evidence_sqlite_context(
|
||||||
&root_path,
|
&root_path,
|
||||||
&body.locator,
|
&body.locator,
|
||||||
body.context.before_blocks,
|
body.context.before_blocks,
|
||||||
body.context.after_blocks,
|
body.context.after_blocks,
|
||||||
)? {
|
)? {
|
||||||
if !results.is_empty() {
|
if !results.is_empty() {
|
||||||
|
enrich_citation_links(&mut results);
|
||||||
let quote = results
|
let quote = results
|
||||||
.iter()
|
.iter()
|
||||||
.find(|item| locator_matches(&item.source, &body.locator))
|
.find(|item| locator_matches(&item.source, &body.locator))
|
||||||
@@ -197,6 +362,9 @@ pub(crate) async fn read_payload(
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"locator": body.locator,
|
"locator": body.locator,
|
||||||
"quote": quote,
|
"quote": quote,
|
||||||
|
"citationUrl": citation_url_for_locator(&body.locator),
|
||||||
|
"citationLabel": citation_label_for_locator(&body.locator),
|
||||||
|
"citationMarkdown": citation_markdown_for_locator(&body.locator),
|
||||||
"contextBlocks": results,
|
"contextBlocks": results,
|
||||||
});
|
});
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
@@ -222,13 +390,14 @@ pub(crate) async fn read_payload(
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
)?;
|
)?;
|
||||||
let results = evidence_results_from_local_search(
|
let mut results = evidence_results_from_local_search(
|
||||||
&search,
|
&search,
|
||||||
&root_path,
|
&root_path,
|
||||||
&root_uri,
|
&root_uri,
|
||||||
EvidenceSearchMode::Tree,
|
EvidenceSearchMode::Tree,
|
||||||
&query,
|
&query,
|
||||||
);
|
);
|
||||||
|
enrich_citation_links(&mut results);
|
||||||
let quote = results
|
let quote = results
|
||||||
.first()
|
.first()
|
||||||
.map(|item| item.quote.clone())
|
.map(|item| item.quote.clone())
|
||||||
@@ -237,6 +406,9 @@ pub(crate) async fn read_payload(
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"locator": body.locator,
|
"locator": body.locator,
|
||||||
"quote": quote,
|
"quote": quote,
|
||||||
|
"citationUrl": citation_url_for_locator(&body.locator),
|
||||||
|
"citationLabel": citation_label_for_locator(&body.locator),
|
||||||
|
"citationMarkdown": citation_markdown_for_locator(&body.locator),
|
||||||
"contextBlocks": results,
|
"contextBlocks": results,
|
||||||
});
|
});
|
||||||
Ok(response)
|
Ok(response)
|
||||||
@@ -365,6 +537,9 @@ fn source_map_block_result(
|
|||||||
quote: block.text.clone(),
|
quote: block.text.clone(),
|
||||||
score,
|
score,
|
||||||
source,
|
source,
|
||||||
|
citation_url: None,
|
||||||
|
citation_label: None,
|
||||||
|
citation_markdown: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +569,9 @@ pub(crate) async fn open_payload(
|
|||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
body: EvidenceOpenRequest,
|
body: EvidenceOpenRequest,
|
||||||
) -> Result<Value, WebError> {
|
) -> Result<Value, WebError> {
|
||||||
let locator = body.locator;
|
let mut locator = body.locator;
|
||||||
|
let citation_url = citation_url_for_locator(&locator);
|
||||||
|
locator.open_action.url = citation_url.clone();
|
||||||
let root_uri = locator.root_uri.trim().to_string();
|
let root_uri = locator.root_uri.trim().to_string();
|
||||||
if root_uri.is_empty() {
|
if root_uri.is_empty() {
|
||||||
return Err(
|
return Err(
|
||||||
@@ -410,6 +587,9 @@ pub(crate) async fn open_payload(
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"locator": locator.clone(),
|
"locator": locator.clone(),
|
||||||
"openAction": locator.open_action,
|
"openAction": locator.open_action,
|
||||||
|
"citationUrl": citation_url,
|
||||||
|
"citationLabel": citation_label_for_locator(&locator),
|
||||||
|
"citationMarkdown": citation_markdown_for_locator(&locator),
|
||||||
});
|
});
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
@@ -499,19 +679,29 @@ pub(crate) fn evidence_results_from_local_search(
|
|||||||
})
|
})
|
||||||
.or_else(|| source_map_hit.as_ref().and_then(|hit| hit.bbox.clone()));
|
.or_else(|| source_map_hit.as_ref().and_then(|hit| hit.bbox.clone()));
|
||||||
let block_id = result
|
let block_id = result
|
||||||
.get("ocrEvidence")
|
.get("blockId")
|
||||||
.and_then(|_| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone()))
|
.and_then(Value::as_str)
|
||||||
.or_else(|| result.get("id").and_then(Value::as_str).map(str::to_string))
|
.map(str::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
result
|
||||||
|
.get("ocrEvidence")
|
||||||
|
.and_then(|_| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone()))
|
||||||
|
})
|
||||||
.or_else(|| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone()));
|
.or_else(|| source_map_hit.as_ref().and_then(|hit| hit.block_id.clone()));
|
||||||
|
let line_range = result.get("lineRange").and_then(evidence_range_from_value);
|
||||||
let char_range = source_map_hit
|
let char_range = source_map_hit
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|hit| hit.char_range.clone());
|
.and_then(|hit| hit.char_range.clone())
|
||||||
|
.or_else(|| result.get("charRange").and_then(evidence_range_from_value));
|
||||||
let open_action_params = evidence_open_action_params(
|
let open_action_params = evidence_open_action_params(
|
||||||
&result,
|
&result,
|
||||||
query,
|
query,
|
||||||
&mode,
|
&mode,
|
||||||
page,
|
page,
|
||||||
bbox.clone(),
|
bbox.clone(),
|
||||||
|
block_id.clone(),
|
||||||
|
line_range.clone(),
|
||||||
|
char_range.clone(),
|
||||||
source_map_path.clone(),
|
source_map_path.clone(),
|
||||||
);
|
);
|
||||||
let locator = EvidenceLocator {
|
let locator = EvidenceLocator {
|
||||||
@@ -534,7 +724,7 @@ pub(crate) fn evidence_results_from_local_search(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
line_range: None,
|
line_range,
|
||||||
char_range,
|
char_range,
|
||||||
block_id,
|
block_id,
|
||||||
source_map_path: source_map_path.clone(),
|
source_map_path: source_map_path.clone(),
|
||||||
@@ -557,6 +747,9 @@ pub(crate) fn evidence_results_from_local_search(
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
score: result.get("score").and_then(Value::as_f64).unwrap_or(0.0),
|
score: result.get("score").and_then(Value::as_f64).unwrap_or(0.0),
|
||||||
source: locator,
|
source: locator,
|
||||||
|
citation_url: None,
|
||||||
|
citation_label: None,
|
||||||
|
citation_markdown: None,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -568,6 +761,9 @@ fn evidence_open_action_params(
|
|||||||
mode: &EvidenceSearchMode,
|
mode: &EvidenceSearchMode,
|
||||||
page: Option<u32>,
|
page: Option<u32>,
|
||||||
bbox: Option<EvidenceBBox>,
|
bbox: Option<EvidenceBBox>,
|
||||||
|
block_id: Option<String>,
|
||||||
|
line_range: Option<EvidenceRange>,
|
||||||
|
char_range: Option<EvidenceRange>,
|
||||||
source_map_path: Option<String>,
|
source_map_path: Option<String>,
|
||||||
) -> Map<String, Value> {
|
) -> Map<String, Value> {
|
||||||
let mut params = Map::new();
|
let mut params = Map::new();
|
||||||
@@ -586,12 +782,35 @@ fn evidence_open_action_params(
|
|||||||
if let Some(bbox) = bbox {
|
if let Some(bbox) = bbox {
|
||||||
params.insert("bbox".into(), json!(bbox));
|
params.insert("bbox".into(), json!(bbox));
|
||||||
}
|
}
|
||||||
|
if let Some(block_id) = block_id {
|
||||||
|
params.insert("blockId".into(), json!(block_id));
|
||||||
|
}
|
||||||
|
if let Some(line_range) = line_range {
|
||||||
|
params.insert("lineRange".into(), json!(line_range));
|
||||||
|
}
|
||||||
|
if let Some(char_range) = char_range {
|
||||||
|
params.insert("charRange".into(), json!(char_range));
|
||||||
|
}
|
||||||
if let Some(source_map_path) = source_map_path {
|
if let Some(source_map_path) = source_map_path {
|
||||||
params.insert("sourceMapPath".into(), json!(source_map_path));
|
params.insert("sourceMapPath".into(), json!(source_map_path));
|
||||||
}
|
}
|
||||||
params
|
params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn evidence_range_from_value(value: &Value) -> Option<EvidenceRange> {
|
||||||
|
if let Some(map) = value.as_object() {
|
||||||
|
let start = map.get("start")?.as_u64()?;
|
||||||
|
let end = map.get("end")?.as_u64()?;
|
||||||
|
return Some(EvidenceRange { start, end });
|
||||||
|
}
|
||||||
|
let text = value.as_str()?.trim();
|
||||||
|
let (start, end) = text.split_once('-')?;
|
||||||
|
Some(EvidenceRange {
|
||||||
|
start: start.trim().parse().ok()?,
|
||||||
|
end: end.trim().parse().ok()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn infer_resource_kind(resource_type: &str, resource_path: Option<&str>) -> EvidenceResourceKind {
|
fn infer_resource_kind(resource_type: &str, resource_path: Option<&str>) -> EvidenceResourceKind {
|
||||||
let extension = resource_path
|
let extension = resource_path
|
||||||
.and_then(|path| Path::new(path).extension())
|
.and_then(|path| Path::new(path).extension())
|
||||||
@@ -788,6 +1007,15 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("readme");
|
.expect("readme");
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
local_search_index::write_local_index_settings(
|
||||||
|
&root,
|
||||||
|
&[String::from(".")],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("settings");
|
||||||
local_search_index::refresh_local_search_index(&root, &root_uri, "local-ws-evidence-route")
|
local_search_index::refresh_local_search_index(&root, &root_uri, "local-ws-evidence-route")
|
||||||
.expect("refresh");
|
.expect("refresh");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -838,6 +1066,7 @@ mod tests {
|
|||||||
"scope": {
|
"scope": {
|
||||||
"workspaceId": "local-ws-evidence-route",
|
"workspaceId": "local-ws-evidence-route",
|
||||||
"rootUri": root_uri,
|
"rootUri": root_uri,
|
||||||
|
"targetDocumentId": "local-md:missing.md",
|
||||||
"includeResources": true,
|
"includeResources": true,
|
||||||
"includeOcr": true
|
"includeOcr": true
|
||||||
},
|
},
|
||||||
@@ -925,13 +1154,14 @@ mod tests {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
let results = evidence_results_from_local_search(
|
let mut results = evidence_results_from_local_search(
|
||||||
&payload,
|
&payload,
|
||||||
&root,
|
&root,
|
||||||
"file:///workspace",
|
"file:///workspace",
|
||||||
EvidenceSearchMode::Hybrid,
|
EvidenceSearchMode::Hybrid,
|
||||||
"OCR-only-token",
|
"OCR-only-token",
|
||||||
);
|
);
|
||||||
|
enrich_citation_links(&mut results);
|
||||||
|
|
||||||
fs::remove_dir_all(&root).ok();
|
fs::remove_dir_all(&root).ok();
|
||||||
|
|
||||||
@@ -954,6 +1184,16 @@ mod tests {
|
|||||||
locator.open_action.params["sourceMapPath"].as_str(),
|
locator.open_action.params["sourceMapPath"].as_str(),
|
||||||
Some("docs/Page.ocr/photo.png.source-map.json")
|
Some("docs/Page.ocr/photo.png.source-map.json")
|
||||||
);
|
);
|
||||||
|
let citation_url = results[0].citation_url.as_deref().unwrap_or_default();
|
||||||
|
assert!(citation_url.starts_with("/documents/local-md:docs~2FPage.md?"));
|
||||||
|
assert!(citation_url.contains("resourceTab=resource%3Afile%3Afile%3A%2F%2F%2Fworkspace%3Adocs%2FPage.assets%2Fphoto.png"));
|
||||||
|
assert!(citation_url.contains("page=1"));
|
||||||
|
assert!(citation_url.contains("blockId=p1_b1"));
|
||||||
|
assert!(results[0]
|
||||||
|
.citation_markdown
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("photo.png"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1011,9 +1251,13 @@ mod tests {
|
|||||||
quote: "NeedleToken 原文定位".into(),
|
quote: "NeedleToken 原文定位".into(),
|
||||||
score: 1.0,
|
score: 1.0,
|
||||||
source: locator,
|
source: locator,
|
||||||
|
citation_url: None,
|
||||||
|
citation_label: None,
|
||||||
|
citation_markdown: None,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
enrich_sqlite_evidence_results(&mut results, &root, "NeedleToken");
|
enrich_sqlite_evidence_results(&mut results, &root, "NeedleToken");
|
||||||
|
enrich_citation_links(&mut results);
|
||||||
fs::remove_dir_all(&root).ok();
|
fs::remove_dir_all(&root).ok();
|
||||||
|
|
||||||
let locator = &results[0].source;
|
let locator = &results[0].source;
|
||||||
@@ -1029,6 +1273,16 @@ mod tests {
|
|||||||
locator.open_action.params["blockId"].as_str(),
|
locator.open_action.params["blockId"].as_str(),
|
||||||
Some("p2_b7")
|
Some("p2_b7")
|
||||||
);
|
);
|
||||||
|
assert!(results[0]
|
||||||
|
.citation_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("sourceMapPath=docs%2FPage.ocr%2Fspec.pdf.source-map.json"));
|
||||||
|
assert!(results[0]
|
||||||
|
.citation_markdown
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("spec.pdf"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1318,6 +1318,123 @@ pub async fn toggle_skill(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_capabilities(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Query(query): Query<HashMap<String, String>>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||||
|
ensure_authenticated(&context)?;
|
||||||
|
let runtime = query.get("runtime").map(String::as_str).unwrap_or("mnote");
|
||||||
|
if runtime != "mnote" {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
stamp_client_headers(),
|
||||||
|
Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"runtime": runtime,
|
||||||
|
"categories": [],
|
||||||
|
"archived": []
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
|
||||||
|
let profile = query
|
||||||
|
.get("profile")
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or(fallback_profile.as_str());
|
||||||
|
let payload = mnote_capabilities_payload(
|
||||||
|
&state,
|
||||||
|
&context,
|
||||||
|
query.get("agentId").map(String::as_str),
|
||||||
|
profile,
|
||||||
|
)?;
|
||||||
|
Ok((StatusCode::OK, stamp_client_headers(), Json(payload)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn toggle_capability(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Json(payload): Json<Value>,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||||
|
ensure_authenticated(&context)?;
|
||||||
|
let capability_id = payload
|
||||||
|
.get("id")
|
||||||
|
.or_else(|| payload.get("name"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WebError::bad_request_code("hermes_client_bad_request", "缺少 capability id")
|
||||||
|
.with_context(&context)
|
||||||
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||||
|
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||||
|
})?;
|
||||||
|
let enabled = payload
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled")
|
||||||
|
.with_context(&context)
|
||||||
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||||
|
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||||
|
})?;
|
||||||
|
let runtime = payload
|
||||||
|
.get("runtime")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("mnote");
|
||||||
|
if runtime != "mnote" {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"hermes_client_capability_runtime_unsupported",
|
||||||
|
"当前只支持 MNote 内置能力开关",
|
||||||
|
)
|
||||||
|
.with_context(&context));
|
||||||
|
}
|
||||||
|
let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
|
||||||
|
let profile = payload
|
||||||
|
.get("profile")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(fallback_profile.as_str());
|
||||||
|
let skill = crate::hermes_tools::skill::find_skill(capability_id, None).ok_or_else(|| {
|
||||||
|
WebError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"mnote_capability_not_found",
|
||||||
|
"未知 MNote AI 能力",
|
||||||
|
)
|
||||||
|
.with_context(&context)
|
||||||
|
})?;
|
||||||
|
let actor_id = page_ai_actor_id(&state, &context)?;
|
||||||
|
ensure_page_ai_actor_user(&state, &actor_id, page_ai_actor_is_admin(&context))?;
|
||||||
|
set_mnote_builtin_capability_enabled(&state, &actor_id, capability_id, enabled, &context)?;
|
||||||
|
for tool_name in skill.tool_names {
|
||||||
|
if mnote_capability_tool_toggleable(tool_name) {
|
||||||
|
set_mnote_tool_enabled(profile, tool_name, enabled).map_err(|error| {
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"hermes_client_capability_tool_toggle_failed",
|
||||||
|
format!("更新 MNote 能力工具设置失败: {error}"),
|
||||||
|
)
|
||||||
|
.with_context(&context)
|
||||||
|
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||||
|
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
stamp_client_headers(),
|
||||||
|
Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"runtime": "mnote",
|
||||||
|
"id": capability_id,
|
||||||
|
"enabled": enabled,
|
||||||
|
"profile": profile,
|
||||||
|
"configScope": "user_sqlite+profile_tool_policy"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn toggle_tool(
|
pub async fn toggle_tool(
|
||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
Json(payload): Json<Value>,
|
Json(payload): Json<Value>,
|
||||||
@@ -4068,6 +4185,217 @@ fn mnote_tool_entry(tool: &Value, disabled: &[String]) -> Option<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mnote_capabilities_payload(
|
||||||
|
state: &AppState,
|
||||||
|
context: &RequestContext,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
profile: &str,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let mut skills_payload = mnote_builtin_skills_payload(agent_id);
|
||||||
|
stamp_mnote_builtin_skill_payload_policy(state, context, &mut skills_payload)?;
|
||||||
|
let tools_by_name = mnote_tools_payload(profile)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|tool| {
|
||||||
|
let name = tool.get("name").and_then(Value::as_str)?.to_string();
|
||||||
|
Some((name, tool))
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let mut capability_categories: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||||
|
for category in skills_payload
|
||||||
|
.get("categories")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let mut capabilities = Vec::new();
|
||||||
|
for skill in category
|
||||||
|
.get("skills")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let Some(id) = skill.get("id").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if id == "mnote-chat-only" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tool_names = skill
|
||||||
|
.get("toolNames")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.filter(|name| !name.trim().is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let tools = tool_names
|
||||||
|
.iter()
|
||||||
|
.filter_map(|name| tools_by_name.get(name).cloned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let disabled_tool_count = tools
|
||||||
|
.iter()
|
||||||
|
.filter(|tool| tool.get("enabled").and_then(Value::as_bool) == Some(false))
|
||||||
|
.count();
|
||||||
|
let enabled = skill
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(true);
|
||||||
|
let status = if !enabled {
|
||||||
|
"disabled"
|
||||||
|
} else if disabled_tool_count > 0 {
|
||||||
|
"partial"
|
||||||
|
} else {
|
||||||
|
"available"
|
||||||
|
};
|
||||||
|
let capability_category = skill
|
||||||
|
.get("category")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("mnote");
|
||||||
|
capabilities.push(json!({
|
||||||
|
"id": id,
|
||||||
|
"name": id,
|
||||||
|
"title": skill.get("title").cloned().unwrap_or_else(|| json!(id)),
|
||||||
|
"description": skill.get("description").cloned().unwrap_or(Value::Null),
|
||||||
|
"enabled": enabled,
|
||||||
|
"toggleable": skill.get("toggleable").cloned().unwrap_or_else(|| json!(true)),
|
||||||
|
"builtin": true,
|
||||||
|
"configurable": true,
|
||||||
|
"configScope": "user_sqlite+profile_tool_policy",
|
||||||
|
"skillKind": "mnote_capability",
|
||||||
|
"source": "mnote",
|
||||||
|
"origin": "builtin",
|
||||||
|
"category": capability_category,
|
||||||
|
"categoryTitle": mnote_capability_category_title(capability_category),
|
||||||
|
"capabilityId": id,
|
||||||
|
"capabilityKind": "mnote_builtin",
|
||||||
|
"uiKind": mnote_capability_ui_kind(capability_category),
|
||||||
|
"skillId": id,
|
||||||
|
"readOnly": skill.get("readOnly").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"agentIds": skill.get("agentIds").cloned().unwrap_or(Value::Null),
|
||||||
|
"requiresContextRefs": skill.get("requiresContextRefs").cloned().unwrap_or(Value::Null),
|
||||||
|
"toolNames": tool_names,
|
||||||
|
"tools": tools,
|
||||||
|
"toolCount": tools.len(),
|
||||||
|
"disabledToolCount": disabled_tool_count,
|
||||||
|
"status": status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for capability in capabilities {
|
||||||
|
let category_name = capability
|
||||||
|
.get("category")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("mnote")
|
||||||
|
.to_string();
|
||||||
|
capability_categories
|
||||||
|
.entry(category_name)
|
||||||
|
.or_default()
|
||||||
|
.push(capability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let categories = ordered_mnote_capability_categories(capability_categories)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, capabilities)| {
|
||||||
|
json!({
|
||||||
|
"name": name,
|
||||||
|
"title": mnote_capability_category_title(&name),
|
||||||
|
"description": mnote_capability_category_description(&name),
|
||||||
|
"capabilities": capabilities.clone(),
|
||||||
|
"skills": capabilities
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok(json!({
|
||||||
|
"ok": true,
|
||||||
|
"runtime": "mnote",
|
||||||
|
"profile": profile,
|
||||||
|
"categories": categories,
|
||||||
|
"archived": []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ordered_mnote_capability_categories(
|
||||||
|
mut categories: BTreeMap<String, Vec<Value>>,
|
||||||
|
) -> Vec<(String, Vec<Value>)> {
|
||||||
|
let mut ordered = Vec::new();
|
||||||
|
for name in ["mnote", "knowledge", "file", "resource", "office", "chat"] {
|
||||||
|
if let Some(capabilities) = categories.remove(name) {
|
||||||
|
ordered.push((name.to_string(), capabilities));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered.extend(categories);
|
||||||
|
ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mnote_capability_category_title(category: &str) -> &'static str {
|
||||||
|
match category {
|
||||||
|
"knowledge" => "知识库与索引",
|
||||||
|
"file" => "本地文件",
|
||||||
|
"resource" => "资源编辑",
|
||||||
|
"office" => "Office / ONLYOFFICE",
|
||||||
|
"chat" => "聊天",
|
||||||
|
_ => "MNote",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mnote_capability_category_description(category: &str) -> &'static str {
|
||||||
|
match category {
|
||||||
|
"knowledge" => "本地索引、证据检索和资料范围管理。",
|
||||||
|
"file" => "授权目录内的本地 Markdown 文件读写。",
|
||||||
|
"resource" => "MNote 资源型编辑器能力,例如思维导图。",
|
||||||
|
"office" => "Office 摘要、建议和 ONLYOFFICE 实时编辑桥。",
|
||||||
|
"chat" => "不读取文档上下文的普通对话能力。",
|
||||||
|
_ => "MNote 页面上下文与基础能力。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mnote_capability_ui_kind(category: &str) -> &'static str {
|
||||||
|
if category == "chat" {
|
||||||
|
"chat"
|
||||||
|
} else {
|
||||||
|
"ai_capability"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_mnote_builtin_capability_enabled(
|
||||||
|
state: &AppState,
|
||||||
|
actor_id: &str,
|
||||||
|
capability_id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
context: &RequestContext,
|
||||||
|
) -> Result<(), WebError> {
|
||||||
|
let key = format!("ai.agent.mnote_builtin.skill.{capability_id}.enabled");
|
||||||
|
state
|
||||||
|
.control_plane()
|
||||||
|
.upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput {
|
||||||
|
id: None,
|
||||||
|
user_id: actor_id.to_string(),
|
||||||
|
workspace_id: None,
|
||||||
|
source_kind: None,
|
||||||
|
scope_kind: "page_ai_capability".to_string(),
|
||||||
|
scope_id: "mnote_builtin".to_string(),
|
||||||
|
key,
|
||||||
|
value_json: Value::Bool(enabled).to_string(),
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| {
|
||||||
|
WebError::internal(format!("SQLite MNote AI 能力偏好写入失败: {error}"))
|
||||||
|
.with_context(context)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mnote_capability_tool_toggleable(tool_name: &str) -> bool {
|
||||||
|
!matches!(
|
||||||
|
tool_name,
|
||||||
|
"mnote.skill.read" | "mnote.context.snapshot" | "mnote.context.resolve_target"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_skill_description(markdown: &str) -> String {
|
fn extract_skill_description(markdown: &str) -> String {
|
||||||
markdown
|
markdown
|
||||||
.lines()
|
.lines()
|
||||||
@@ -4451,6 +4779,7 @@ fn mnote_builtin_skills_payload(agent_id: Option<&str>) -> Value {
|
|||||||
"skillKind": "mnote_builtin",
|
"skillKind": "mnote_builtin",
|
||||||
"source": "mnote",
|
"source": "mnote",
|
||||||
"origin": "builtin",
|
"origin": "builtin",
|
||||||
|
"category": skill.get("category").cloned().unwrap_or_else(|| json!("mnote")),
|
||||||
"agentIds": skill.get("agentIds").cloned().unwrap_or(Value::Null),
|
"agentIds": skill.get("agentIds").cloned().unwrap_or(Value::Null),
|
||||||
"toolNames": skill.get("toolNames").cloned().unwrap_or(Value::Null),
|
"toolNames": skill.get("toolNames").cloned().unwrap_or(Value::Null),
|
||||||
"requiresContextRefs": skill.get("requiresContextRefs").cloned().unwrap_or(Value::Null)
|
"requiresContextRefs": skill.get("requiresContextRefs").cloned().unwrap_or(Value::Null)
|
||||||
@@ -10144,6 +10473,223 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&hermes_home);
|
let _ = fs::remove_dir_all(&hermes_home);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn page_ai_capabilities_expose_local_index_and_toggle_tools() {
|
||||||
|
let _env_guard = env_lock().lock().expect("env lock");
|
||||||
|
let hermes_home = std::env::temp_dir().join(format!(
|
||||||
|
"mnote-web-ai-capability-policy-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&hermes_home);
|
||||||
|
fs::create_dir_all(&hermes_home).expect("hermes home");
|
||||||
|
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||||
|
let app = build_app(test_state());
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("capabilities");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
|
||||||
|
let all_capabilities = payload["categories"]
|
||||||
|
.as_array()
|
||||||
|
.expect("categories")
|
||||||
|
.iter()
|
||||||
|
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(
|
||||||
|
all_capabilities
|
||||||
|
.iter()
|
||||||
|
.all(|capability| capability["id"] != "mnote-chat-only"),
|
||||||
|
"纯聊天是 agent 模式,不应作为 MNote 公共能力展示"
|
||||||
|
);
|
||||||
|
let local_index = payload["categories"]
|
||||||
|
.as_array()
|
||||||
|
.expect("categories")
|
||||||
|
.iter()
|
||||||
|
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
|
||||||
|
.find(|capability| capability["id"] == "mnote-local-index")
|
||||||
|
.expect("local index capability");
|
||||||
|
assert_eq!(local_index["enabled"], true);
|
||||||
|
assert_eq!(local_index["uiKind"], "ai_capability");
|
||||||
|
assert!(local_index["tools"]
|
||||||
|
.as_array()
|
||||||
|
.expect("local index tools")
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool["name"] == "mnote.index.status"));
|
||||||
|
assert!(local_index["tools"]
|
||||||
|
.as_array()
|
||||||
|
.expect("local index tools")
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool["name"] == "mnote.index.update_settings"));
|
||||||
|
|
||||||
|
let toggle_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri("/api/hermes/client/capabilities/toggle")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"runtime": "mnote",
|
||||||
|
"profile": "chemist",
|
||||||
|
"id": "mnote-local-index",
|
||||||
|
"enabled": false
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("toggle capability");
|
||||||
|
assert_eq!(toggle_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("capabilities after toggle");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
|
||||||
|
let local_index = payload["categories"]
|
||||||
|
.as_array()
|
||||||
|
.expect("categories")
|
||||||
|
.iter()
|
||||||
|
.flat_map(|category| category["capabilities"].as_array().into_iter().flatten())
|
||||||
|
.find(|capability| capability["id"] == "mnote-local-index")
|
||||||
|
.expect("local index capability");
|
||||||
|
assert_eq!(local_index["enabled"], false);
|
||||||
|
assert_eq!(local_index["status"], "disabled");
|
||||||
|
assert!(local_index["tools"]
|
||||||
|
.as_array()
|
||||||
|
.expect("local index tools")
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool["name"] == "mnote.index.status" && tool["enabled"] == false));
|
||||||
|
|
||||||
|
let tools_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/hermes/client/tools?scope=mnote&profile=chemist")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("tools after toggle");
|
||||||
|
assert_eq!(tools_response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(tools_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("tools json");
|
||||||
|
let tools = payload["tools"]
|
||||||
|
.as_array()
|
||||||
|
.expect("tools")
|
||||||
|
.iter()
|
||||||
|
.map(|tool| {
|
||||||
|
(
|
||||||
|
tool["name"].as_str().unwrap_or_default().to_string(),
|
||||||
|
tool.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
assert_eq!(tools["mnote.index.status"]["enabled"], false);
|
||||||
|
assert_eq!(tools["mnote.index.update_settings"]["enabled"], false);
|
||||||
|
|
||||||
|
std::env::remove_var("HERMES_HOME");
|
||||||
|
let _ = fs::remove_dir_all(&hermes_home);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn page_ai_capabilities_group_onlyoffice_live_bridge() {
|
||||||
|
let _env_guard = env_lock().lock().expect("env lock");
|
||||||
|
let hermes_home = std::env::temp_dir().join(format!(
|
||||||
|
"mnote-web-ai-onlyoffice-capability-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&hermes_home);
|
||||||
|
fs::create_dir_all(&hermes_home).expect("hermes home");
|
||||||
|
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||||
|
|
||||||
|
let response = build_app(test_state())
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=chemist")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("capabilities");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
|
||||||
|
let office_category = payload["categories"]
|
||||||
|
.as_array()
|
||||||
|
.expect("categories")
|
||||||
|
.iter()
|
||||||
|
.find(|category| category["name"] == "office")
|
||||||
|
.expect("office category");
|
||||||
|
assert_eq!(office_category["title"], "Office / ONLYOFFICE");
|
||||||
|
let onlyoffice = office_category["capabilities"]
|
||||||
|
.as_array()
|
||||||
|
.expect("office capabilities")
|
||||||
|
.iter()
|
||||||
|
.find(|capability| capability["id"] == "mnote-onlyoffice-live")
|
||||||
|
.expect("onlyoffice capability");
|
||||||
|
assert_eq!(onlyoffice["title"], "ONLYOFFICE 实时编辑");
|
||||||
|
assert_eq!(onlyoffice["categoryTitle"], "Office / ONLYOFFICE");
|
||||||
|
assert_eq!(onlyoffice["enabled"], true);
|
||||||
|
assert!(onlyoffice["tools"]
|
||||||
|
.as_array()
|
||||||
|
.expect("onlyoffice tools")
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool["name"] == "mnote.onlyoffice.session.current"));
|
||||||
|
assert!(onlyoffice["tools"]
|
||||||
|
.as_array()
|
||||||
|
.expect("onlyoffice tools")
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values"));
|
||||||
|
assert!(onlyoffice["tools"]
|
||||||
|
.as_array()
|
||||||
|
.expect("onlyoffice tools")
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool["name"] == "mnote.onlyoffice.presentation.add_shape"));
|
||||||
|
|
||||||
|
std::env::remove_var("HERMES_HOME");
|
||||||
|
let _ = fs::remove_dir_all(&hermes_home);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reasonix_memory_policy_defaults_off_and_reads_user_preference() {
|
fn reasonix_memory_policy_defaults_off_and_reads_user_preference() {
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use crate::app::AppState;
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::{
|
use crate::hermes_tools::{
|
||||||
artifact, block, context_tools, doc, evidence, manifest, onlyoffice_live, page, resource,
|
artifact, block, context_tools, doc, evidence, index, manifest, onlyoffice_live, page,
|
||||||
skill, ToolCallInput,
|
resource, skill, ToolCallInput,
|
||||||
};
|
};
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
@@ -365,6 +365,11 @@ pub(crate) async fn execute_mnote_tool_call(
|
|||||||
"mnote.evidence.search" => evidence::evidence_search(&state, &context, &input).await,
|
"mnote.evidence.search" => evidence::evidence_search(&state, &context, &input).await,
|
||||||
"mnote.evidence.read" => evidence::evidence_read(&state, &context, &input).await,
|
"mnote.evidence.read" => evidence::evidence_read(&state, &context, &input).await,
|
||||||
"mnote.evidence.open" => evidence::evidence_open(&state, &context, &input).await,
|
"mnote.evidence.open" => evidence::evidence_open(&state, &context, &input).await,
|
||||||
|
"mnote.index.status" => index::index_status(&state, &context, &input).await,
|
||||||
|
"mnote.index.refresh" => index::index_refresh(&state, &context, &input).await,
|
||||||
|
"mnote.index.update_settings" => {
|
||||||
|
index::index_update_settings(&state, &context, &input).await
|
||||||
|
}
|
||||||
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
|
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
|
||||||
"mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
|
"mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
|
||||||
"mnote.block.replace" => block::block_replace(&state, &context, &input).await,
|
"mnote.block.replace" => block::block_replace(&state, &context, &input).await,
|
||||||
@@ -704,6 +709,8 @@ fn is_read_tool(tool_name: &str) -> bool {
|
|||||||
| "mnote.evidence.search"
|
| "mnote.evidence.search"
|
||||||
| "mnote.evidence.read"
|
| "mnote.evidence.read"
|
||||||
| "mnote.evidence.open"
|
| "mnote.evidence.open"
|
||||||
|
| "mnote.index.status"
|
||||||
|
| "mnote.index.refresh"
|
||||||
| "mnote.block.fetch"
|
| "mnote.block.fetch"
|
||||||
| "mnote.mindmap.fetch"
|
| "mnote.mindmap.fetch"
|
||||||
| "mnote.office.fetch_summary"
|
| "mnote.office.fetch_summary"
|
||||||
@@ -1578,6 +1585,70 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hermes_tools_manifest_exposes_mnote_capability_packs() {
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/hermes/tools/mnote/manifest")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
let manifest = &payload["manifest"];
|
||||||
|
let capabilities = manifest["capabilities"].as_array().expect("capabilities");
|
||||||
|
assert!(capabilities
|
||||||
|
.iter()
|
||||||
|
.any(|capability| capability["id"] == "mnote-local-index"));
|
||||||
|
assert!(capabilities
|
||||||
|
.iter()
|
||||||
|
.any(|capability| capability["id"] == "mnote-onlyoffice-live"));
|
||||||
|
|
||||||
|
let tools = manifest["tools"].as_array().expect("tools");
|
||||||
|
let index_status = tools
|
||||||
|
.iter()
|
||||||
|
.find(|tool| tool["name"] == "mnote.index.status")
|
||||||
|
.expect("index status tool");
|
||||||
|
assert!(index_status["capabilityIds"]
|
||||||
|
.as_array()
|
||||||
|
.expect("index capability ids")
|
||||||
|
.iter()
|
||||||
|
.any(|id| id == "mnote-local-index"));
|
||||||
|
|
||||||
|
let onlyoffice_batch_set = tools
|
||||||
|
.iter()
|
||||||
|
.find(|tool| tool["name"] == "mnote.onlyoffice.sheet.batch_set_values")
|
||||||
|
.expect("onlyoffice batch set tool");
|
||||||
|
assert_eq!(
|
||||||
|
onlyoffice_batch_set["capabilityId"],
|
||||||
|
"mnote-onlyoffice-live"
|
||||||
|
);
|
||||||
|
assert!(onlyoffice_batch_set["capabilityIds"]
|
||||||
|
.as_array()
|
||||||
|
.expect("onlyoffice capability ids")
|
||||||
|
.iter()
|
||||||
|
.any(|id| id == "mnote-onlyoffice-live"));
|
||||||
|
|
||||||
|
let context_snapshot = tools
|
||||||
|
.iter()
|
||||||
|
.find(|tool| tool["name"] == "mnote.context.snapshot")
|
||||||
|
.expect("context snapshot tool");
|
||||||
|
assert!(
|
||||||
|
context_snapshot["capabilityIds"]
|
||||||
|
.as_array()
|
||||||
|
.expect("context capability ids")
|
||||||
|
.len()
|
||||||
|
> 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn hermes_tools_onlyoffice_session_current_reads_registered_bridge_session() {
|
async fn hermes_tools_onlyoffice_session_current_reads_registered_bridge_session() {
|
||||||
let app = app();
|
let app = app();
|
||||||
@@ -5386,6 +5457,21 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("markdown");
|
.expect("markdown");
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
crate::routes::local_search_index::write_local_index_settings(
|
||||||
|
&root,
|
||||||
|
&[String::from(".")],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("settings");
|
||||||
|
crate::routes::local_search_index::refresh_local_search_index(
|
||||||
|
&root,
|
||||||
|
&root_uri,
|
||||||
|
"local-ws-docs-search",
|
||||||
|
)
|
||||||
|
.expect("refresh");
|
||||||
|
|
||||||
let response = app()
|
let response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -5437,6 +5523,12 @@ mod tests {
|
|||||||
result["source"]["ownerDocumentPath"].as_str(),
|
result["source"]["ownerDocumentPath"].as_str(),
|
||||||
Some("README.md")
|
Some("README.md")
|
||||||
);
|
);
|
||||||
|
assert!(result["citationMarkdown"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|value| value.contains("](/documents/")));
|
||||||
|
assert!(result["citationUrl"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|value| value.contains("resourceTab=")));
|
||||||
let evidence_id = result["evidenceId"].as_str().expect("evidence id");
|
let evidence_id = result["evidenceId"].as_str().expect("evidence id");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["result"]["evidenceIds"][0].as_str(),
|
payload["result"]["evidenceIds"][0].as_str(),
|
||||||
@@ -5480,6 +5572,112 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hermes_tools_local_index_update_and_status_manage_scope() {
|
||||||
|
let root = std::env::temp_dir().join(format!("mnote-index-tool-{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||||
|
fs::create_dir_all(root.join("docs")).expect("docs");
|
||||||
|
fs::write(
|
||||||
|
root.join(".mnote").join("workspace.json"),
|
||||||
|
r#"{"workspaceId":"local-ws-index-tool","ownerId":"user_1","createdAt":"2026-06-04T00:00:00Z","capabilities":["local_files","search"]}"#,
|
||||||
|
)
|
||||||
|
.expect("manifest");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("indexed.md"),
|
||||||
|
"# Indexed\n\nindex-tool-token\n",
|
||||||
|
)
|
||||||
|
.expect("markdown");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let app = app();
|
||||||
|
|
||||||
|
let update_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/hermes/tools/mnote/call")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"toolName": "mnote.index.update_settings",
|
||||||
|
"workspaceId": "local-ws-index-tool",
|
||||||
|
"sourceKind": "local_folder",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"sessionId": "sess_index_tool",
|
||||||
|
"runId": "run_index_tool",
|
||||||
|
"toolCallId": "call_index_update",
|
||||||
|
"traceId": "trace_index_tool",
|
||||||
|
"dryRun": false,
|
||||||
|
"idempotencyKey": "idem_index_tool_update",
|
||||||
|
"args": {
|
||||||
|
"includePaths": ["docs"],
|
||||||
|
"scheduleMode": "manual",
|
||||||
|
"scheduleTime": "02:00",
|
||||||
|
"runOnChange": false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("update request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("update response");
|
||||||
|
assert_eq!(update_response.status(), StatusCode::OK);
|
||||||
|
let update_body = to_bytes(update_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("update body");
|
||||||
|
let update_payload: Value = serde_json::from_slice(&update_body).expect("update json");
|
||||||
|
assert_eq!(
|
||||||
|
update_payload["result"]["settings"]["includePaths"][0],
|
||||||
|
"docs"
|
||||||
|
);
|
||||||
|
assert_eq!(update_payload["result"]["index"]["documentCount"], 1);
|
||||||
|
assert!(root.join(".mnote/index/search-index.json").exists());
|
||||||
|
assert!(root.join(".mnote/index/evidence.sqlite").exists());
|
||||||
|
|
||||||
|
let status_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/hermes/tools/mnote/call")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_1")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"toolName": "mnote.index.status",
|
||||||
|
"workspaceId": "local-ws-index-tool",
|
||||||
|
"sourceKind": "local_folder",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"sessionId": "sess_index_tool",
|
||||||
|
"runId": "run_index_tool",
|
||||||
|
"toolCallId": "call_index_status",
|
||||||
|
"traceId": "trace_index_tool",
|
||||||
|
"args": {}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("status request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("status response");
|
||||||
|
assert_eq!(status_response.status(), StatusCode::OK);
|
||||||
|
let status_body = to_bytes(status_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("status body");
|
||||||
|
let status_payload: Value = serde_json::from_slice(&status_body).expect("status json");
|
||||||
|
assert_eq!(
|
||||||
|
status_payload["result"]["result"]["settings"]["includePaths"][0],
|
||||||
|
"docs"
|
||||||
|
);
|
||||||
|
assert_eq!(status_payload["audit"]["effect"], "read");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn hermes_tools_legacy_docs_read_returns_document_with_locator() {
|
async fn hermes_tools_legacy_docs_read_returns_document_with_locator() {
|
||||||
let root =
|
let root =
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ struct LocalFolderRow {
|
|||||||
capabilities: Vec<String>,
|
capabilities: Vec<String>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
root_source_uri: String,
|
root_source_uri: String,
|
||||||
|
index_status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -303,6 +304,40 @@ struct LocalFolderScanResult {
|
|||||||
watch_revision: LocalFolderWatchRevision,
|
watch_revision: LocalFolderWatchRevision,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct LocalFileTreeIndexState {
|
||||||
|
indexed_paths: BTreeSet<String>,
|
||||||
|
failed_paths: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalFileTreeIndexState {
|
||||||
|
fn load(root: &Path) -> Self {
|
||||||
|
local_search_index::local_evidence_source_statuses(root)
|
||||||
|
.map(|statuses| Self {
|
||||||
|
indexed_paths: statuses.indexed_paths,
|
||||||
|
failed_paths: statuses.failed_paths,
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_for_entry(&self, entry: &LocalFolderEntry) -> Option<String> {
|
||||||
|
if entry.is_dir || entry.is_symlink {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = entry.relative_path.trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.failed_paths.contains(path) {
|
||||||
|
return Some("failed".to_string());
|
||||||
|
}
|
||||||
|
if self.indexed_paths.contains(path) {
|
||||||
|
return Some("indexed".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct LocalUploadFile {
|
pub(crate) struct LocalUploadFile {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -2717,6 +2752,7 @@ fn load_local_folder_file_tree_scope_snapshot(
|
|||||||
let workspace_id = local_workspace_id(&canonical_root);
|
let workspace_id = local_workspace_id(&canonical_root);
|
||||||
let root_source_uri = file_uri_for_path(&canonical_root);
|
let root_source_uri = file_uri_for_path(&canonical_root);
|
||||||
let metadata = load_local_folder_metadata(&canonical_root)?;
|
let metadata = load_local_folder_metadata(&canonical_root)?;
|
||||||
|
let index_state = LocalFileTreeIndexState::load(&canonical_root);
|
||||||
let parent_relative_path = parent_relative_path
|
let parent_relative_path = parent_relative_path
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty() && *value != ".")
|
.filter(|value| !value.is_empty() && *value != ".")
|
||||||
@@ -2746,6 +2782,7 @@ fn load_local_folder_file_tree_scope_snapshot(
|
|||||||
&root_source_uri,
|
&root_source_uri,
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
&metadata,
|
&metadata,
|
||||||
|
&index_state,
|
||||||
)?;
|
)?;
|
||||||
append_file_tree_reveal_rows(
|
append_file_tree_reveal_rows(
|
||||||
&canonical_root,
|
&canonical_root,
|
||||||
@@ -2754,6 +2791,7 @@ fn load_local_folder_file_tree_scope_snapshot(
|
|||||||
&root_source_uri,
|
&root_source_uri,
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
&metadata,
|
&metadata,
|
||||||
|
&index_state,
|
||||||
&mut scan_result.rows,
|
&mut scan_result.rows,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -6648,6 +6686,7 @@ fn scan_directory(
|
|||||||
root_source_uri: &str,
|
root_source_uri: &str,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
metadata: &LocalFolderMetadata,
|
metadata: &LocalFolderMetadata,
|
||||||
|
index_state: &LocalFileTreeIndexState,
|
||||||
rows: &mut Vec<LocalFolderRow>,
|
rows: &mut Vec<LocalFolderRow>,
|
||||||
) -> Result<(), WebError> {
|
) -> Result<(), WebError> {
|
||||||
let mut entries = read_sorted_entries(directory, root)?;
|
let mut entries = read_sorted_entries(directory, root)?;
|
||||||
@@ -6698,6 +6737,7 @@ fn scan_directory(
|
|||||||
capabilities: local_entry_capabilities(&entry),
|
capabilities: local_entry_capabilities(&entry),
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
root_source_uri: root_source_uri.to_string(),
|
root_source_uri: root_source_uri.to_string(),
|
||||||
|
index_status: index_state.status_for_entry(&entry),
|
||||||
});
|
});
|
||||||
if entry.is_dir && !entry.is_symlink && max_depth.map_or(true, |limit| depth < limit) {
|
if entry.is_dir && !entry.is_symlink && max_depth.map_or(true, |limit| depth < limit) {
|
||||||
scan_directory(
|
scan_directory(
|
||||||
@@ -6709,6 +6749,7 @@ fn scan_directory(
|
|||||||
root_source_uri,
|
root_source_uri,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
metadata,
|
metadata,
|
||||||
|
index_state,
|
||||||
rows,
|
rows,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
@@ -6725,6 +6766,7 @@ fn scan_directory_shallow_with_revision(
|
|||||||
root_source_uri: &str,
|
root_source_uri: &str,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
metadata: &LocalFolderMetadata,
|
metadata: &LocalFolderMetadata,
|
||||||
|
index_state: &LocalFileTreeIndexState,
|
||||||
) -> Result<LocalFolderScanResult, WebError> {
|
) -> Result<LocalFolderScanResult, WebError> {
|
||||||
let mut entries = read_sorted_entries(directory, root)?;
|
let mut entries = read_sorted_entries(directory, root)?;
|
||||||
let parent_key = file_order_parent_key_for_directory(root, directory)?;
|
let parent_key = file_order_parent_key_for_directory(root, directory)?;
|
||||||
@@ -6797,6 +6839,7 @@ fn scan_directory_shallow_with_revision(
|
|||||||
capabilities: local_entry_capabilities(entry),
|
capabilities: local_entry_capabilities(entry),
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
root_source_uri: root_source_uri.to_string(),
|
root_source_uri: root_source_uri.to_string(),
|
||||||
|
index_status: index_state.status_for_entry(entry),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6867,6 +6910,7 @@ fn append_file_tree_reveal_rows(
|
|||||||
root_source_uri: &str,
|
root_source_uri: &str,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
metadata: &LocalFolderMetadata,
|
metadata: &LocalFolderMetadata,
|
||||||
|
index_state: &LocalFileTreeIndexState,
|
||||||
rows: &mut Vec<LocalFolderRow>,
|
rows: &mut Vec<LocalFolderRow>,
|
||||||
) -> Result<(), WebError> {
|
) -> Result<(), WebError> {
|
||||||
let Some(reveal_relative_path) = reveal_relative_path
|
let Some(reveal_relative_path) = reveal_relative_path
|
||||||
@@ -6924,6 +6968,7 @@ fn append_file_tree_reveal_rows(
|
|||||||
root_source_uri,
|
root_source_uri,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
metadata,
|
metadata,
|
||||||
|
index_state,
|
||||||
&mut scoped_rows,
|
&mut scoped_rows,
|
||||||
)?;
|
)?;
|
||||||
for row in scoped_rows {
|
for row in scoped_rows {
|
||||||
@@ -7124,9 +7169,25 @@ fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
relative_path == ".mnote/trash"
|
relative_path == ".mnote/trash"
|
||||||
|| relative_path.starts_with(".mnote/trash/")
|
|| relative_path.starts_with(".mnote/trash/")
|
||||||
|
|| is_local_index_artifact_entry(relative_path)
|
||||||
|| is_local_ocr_intermediate_entry(relative_path, file_name)
|
|| is_local_ocr_intermediate_entry(relative_path, file_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_local_index_artifact_entry(relative_path: &str) -> bool {
|
||||||
|
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let segments = normalized
|
||||||
|
.split('/')
|
||||||
|
.filter(|segment| !segment.trim().is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
segments
|
||||||
|
.windows(2)
|
||||||
|
.any(|window| window[0] == ".mnote" && window[1] == "index")
|
||||||
|
|| segments.iter().any(|segment| segment.ends_with(".ocr"))
|
||||||
|
}
|
||||||
|
|
||||||
fn is_local_ocr_intermediate_entry(relative_path: &str, file_name: &str) -> bool {
|
fn is_local_ocr_intermediate_entry(relative_path: &str, file_name: &str) -> bool {
|
||||||
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
|
||||||
if normalized.is_empty() {
|
if normalized.is_empty() {
|
||||||
@@ -7435,6 +7496,7 @@ fn scan_markdown_page_tree(
|
|||||||
capabilities: local_entry_capabilities(&entry),
|
capabilities: local_entry_capabilities(&entry),
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
root_source_uri: root_source_uri.to_string(),
|
root_source_uri: root_source_uri.to_string(),
|
||||||
|
index_status: None,
|
||||||
});
|
});
|
||||||
directory_rows.extend(child_rows);
|
directory_rows.extend(child_rows);
|
||||||
contains_markdown = true;
|
contains_markdown = true;
|
||||||
@@ -7483,6 +7545,7 @@ fn scan_markdown_page_tree(
|
|||||||
capabilities: local_entry_capabilities(&entry),
|
capabilities: local_entry_capabilities(&entry),
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
root_source_uri: root_source_uri.to_string(),
|
root_source_uri: root_source_uri.to_string(),
|
||||||
|
index_status: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
directory_rows.extend(child_rows);
|
directory_rows.extend(child_rows);
|
||||||
@@ -7510,6 +7573,7 @@ fn scan_markdown_page_tree(
|
|||||||
capabilities: local_entry_capabilities(&entry),
|
capabilities: local_entry_capabilities(&entry),
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
root_source_uri: root_source_uri.to_string(),
|
root_source_uri: root_source_uri.to_string(),
|
||||||
|
index_status: None,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -7546,6 +7610,7 @@ fn scan_markdown_page_tree(
|
|||||||
capabilities: local_entry_capabilities(&entry),
|
capabilities: local_entry_capabilities(&entry),
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
root_source_uri: root_source_uri.to_string(),
|
root_source_uri: root_source_uri.to_string(),
|
||||||
|
index_status: None,
|
||||||
});
|
});
|
||||||
contains_markdown = true;
|
contains_markdown = true;
|
||||||
}
|
}
|
||||||
@@ -7691,6 +7756,10 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
|
|||||||
item["assetId"] = Value::String(asset_id.clone());
|
item["assetId"] = Value::String(asset_id.clone());
|
||||||
item["resourceMeta"]["assetId"] = Value::String(asset_id);
|
item["resourceMeta"]["assetId"] = Value::String(asset_id);
|
||||||
}
|
}
|
||||||
|
if let Some(index_status) = row.index_status.as_deref() {
|
||||||
|
item["indexStatus"] = Value::String(index_status.to_string());
|
||||||
|
item["resourceMeta"]["indexStatus"] = Value::String(index_status.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Phase A1: 为 File/Page/Resource tree 输出统一 workspacePath
|
// Phase A1: 为 File/Page/Resource tree 输出统一 workspacePath
|
||||||
let object_kind = match row.row_kind.as_str() {
|
let object_kind = match row.row_kind.as_str() {
|
||||||
@@ -11648,6 +11717,15 @@ fn main() {}
|
|||||||
.expect("write md");
|
.expect("write md");
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
|
||||||
|
crate::routes::local_search_index::write_local_index_settings(
|
||||||
|
&root,
|
||||||
|
&[String::from(".")],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(true),
|
||||||
|
)
|
||||||
|
.expect("settings");
|
||||||
crate::routes::local_search_index::refresh_local_search_index(
|
crate::routes::local_search_index::refresh_local_search_index(
|
||||||
&root,
|
&root,
|
||||||
&root_uri,
|
&root_uri,
|
||||||
@@ -12369,6 +12447,15 @@ fn main() {}
|
|||||||
std::fs::write(root.join("README.md"), "# Before\nold-token\n").expect("write md");
|
std::fs::write(root.join("README.md"), "# Before\nold-token\n").expect("write md");
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
|
||||||
|
crate::routes::local_search_index::write_local_index_settings(
|
||||||
|
&root,
|
||||||
|
&[String::from(".")],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(true),
|
||||||
|
)
|
||||||
|
.expect("settings");
|
||||||
crate::routes::local_search_index::refresh_local_search_index(
|
crate::routes::local_search_index::refresh_local_search_index(
|
||||||
&root,
|
&root,
|
||||||
&root_uri,
|
&root_uri,
|
||||||
@@ -13474,7 +13561,7 @@ fn main() {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_ocr_sidecar_is_filetree_resource_not_page_tree_document() {
|
fn local_ocr_sidecar_artifacts_are_hidden_from_filetree_and_page_tree() {
|
||||||
let root = temp_root("mnote-local-ocr-sidecar-page-tree");
|
let root = temp_root("mnote-local-ocr-sidecar-page-tree");
|
||||||
init_workspace(&root);
|
init_workspace(&root);
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
@@ -13515,15 +13602,22 @@ fn main() {}
|
|||||||
)
|
)
|
||||||
.expect("ocr image asset");
|
.expect("ocr image asset");
|
||||||
|
|
||||||
|
let docs_file_tree =
|
||||||
|
load_local_folder_file_tree_children_snapshot(&root_uri, "docs").expect("file tree");
|
||||||
|
let docs_file_items = docs_file_tree.projection["items"]
|
||||||
|
.as_array()
|
||||||
|
.expect("file items");
|
||||||
|
assert!(!docs_file_items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item["title"].as_str() == Some("Page.ocr")));
|
||||||
|
|
||||||
let file_tree = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/Page.ocr")
|
let file_tree = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/Page.ocr")
|
||||||
.expect("file tree");
|
.expect("direct hidden sidecar file tree");
|
||||||
let file_items = file_tree.projection["items"]
|
let file_items = file_tree.projection["items"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("file items");
|
.expect("file items");
|
||||||
assert!(file_items
|
|
||||||
.iter()
|
|
||||||
.any(|item| item["title"].as_str() == Some("photo.png.ocr.md")));
|
|
||||||
for hidden_title in [
|
for hidden_title in [
|
||||||
|
"photo.png.ocr.md",
|
||||||
"layout.json",
|
"layout.json",
|
||||||
"abc_content_list.json",
|
"abc_content_list.json",
|
||||||
"abc_model.json",
|
"abc_model.json",
|
||||||
@@ -13534,7 +13628,7 @@ fn main() {}
|
|||||||
!file_items
|
!file_items
|
||||||
.iter()
|
.iter()
|
||||||
.any(|item| item["title"].as_str() == Some(hidden_title)),
|
.any(|item| item["title"].as_str() == Some(hidden_title)),
|
||||||
"OCR 中间产物不应出现在 FileTree: {hidden_title}"
|
"OCR / 索引产物不应出现在 FileTree: {hidden_title}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13551,6 +13645,104 @@ fn main() {}
|
|||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_file_tree_marks_indexed_and_failed_source_files() {
|
||||||
|
let root = temp_root("mnote-local-filetree-index-status");
|
||||||
|
init_workspace(&root);
|
||||||
|
std::fs::write(root.join("ok.pdf"), b"%PDF-1.4\nok").expect("ok pdf");
|
||||||
|
std::fs::write(root.join("failed.pdf"), b"%PDF-1.4\nfailed").expect("failed pdf");
|
||||||
|
std::fs::write(root.join("draft.md"), "# Draft\n").expect("draft");
|
||||||
|
|
||||||
|
let index_dir = root.join(".mnote").join("index");
|
||||||
|
std::fs::create_dir_all(&index_dir).expect("index dir");
|
||||||
|
let evidence_path = index_dir.join("evidence.sqlite");
|
||||||
|
let connection = rusqlite::Connection::open(&evidence_path).expect("evidence sqlite");
|
||||||
|
connection
|
||||||
|
.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE evidence_resource(
|
||||||
|
resource_id TEXT PRIMARY KEY,
|
||||||
|
owner_document_id TEXT NOT NULL,
|
||||||
|
owner_document_path TEXT NOT NULL,
|
||||||
|
source_root_relative_path TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
source_hash TEXT NOT NULL,
|
||||||
|
artifact_root_relative_path TEXT NOT NULL,
|
||||||
|
source_map_root_relative_path TEXT NOT NULL,
|
||||||
|
updated_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("evidence schema");
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO evidence_resource(resource_id, owner_document_id, owner_document_path, source_root_relative_path, provider, source_hash, artifact_root_relative_path, source_map_root_relative_path, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
rusqlite::params![
|
||||||
|
"local-resource:ok.pdf#parse",
|
||||||
|
"local-resource:ok.pdf",
|
||||||
|
"ok.pdf",
|
||||||
|
"ok.pdf",
|
||||||
|
"liteparse",
|
||||||
|
"hash",
|
||||||
|
"ok.ocr/ok.pdf.parse.md",
|
||||||
|
"ok.ocr/ok.pdf.source-map.json",
|
||||||
|
1_i64,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("insert indexed evidence");
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO evidence_resource(resource_id, owner_document_id, owner_document_path, source_root_relative_path, provider, source_hash, artifact_root_relative_path, source_map_root_relative_path, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
rusqlite::params![
|
||||||
|
"local-md:draft.md",
|
||||||
|
"local-md:draft.md",
|
||||||
|
"draft.md",
|
||||||
|
"draft.md",
|
||||||
|
"markdown",
|
||||||
|
"hash",
|
||||||
|
"draft.md",
|
||||||
|
"draft.md.source-map.json",
|
||||||
|
1_i64,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("insert markdown evidence");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let search_index = json!({
|
||||||
|
"version": 1,
|
||||||
|
"builtAt": 1,
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"workspaceId": "local-filetree-index-status",
|
||||||
|
"indexedPaths": ["."],
|
||||||
|
"documents": [],
|
||||||
|
"resources": [
|
||||||
|
{"resourceId": "local-resource:ok.pdf", "resourceType": "pdf", "title": "ok", "path": "ok.pdf", "updatedAt": 1},
|
||||||
|
{"resourceId": "local-resource:failed.pdf", "resourceType": "pdf", "title": "failed", "path": "failed.pdf", "updatedAt": 1}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
std::fs::write(
|
||||||
|
index_dir.join("search-index.json"),
|
||||||
|
format!("{}\n", serde_json::to_string_pretty(&search_index).unwrap()),
|
||||||
|
)
|
||||||
|
.expect("search index");
|
||||||
|
|
||||||
|
let file_tree = load_local_folder_file_tree_snapshot(&format!("file://{}", root.display()))
|
||||||
|
.expect("file tree");
|
||||||
|
let items = file_tree.projection["items"].as_array().expect("items");
|
||||||
|
let status_for = |path: &str| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.find(|item| {
|
||||||
|
item["resourceMeta"]["extra"]["source"]["relativePath"].as_str() == Some(path)
|
||||||
|
})
|
||||||
|
.and_then(|item| item["indexStatus"].as_str())
|
||||||
|
};
|
||||||
|
assert_eq!(status_for("ok.pdf"), Some("indexed"));
|
||||||
|
assert_eq!(status_for("failed.pdf"), Some("failed"));
|
||||||
|
assert_eq!(status_for("draft.md"), None);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_rename_markdown_page_renames_nested_bundle() {
|
fn local_rename_markdown_page_renames_nested_bundle() {
|
||||||
let root = temp_root("mnote-local-rename-nested-bundle");
|
let root = temp_root("mnote-local-rename-nested-bundle");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use core_protocol::{
|
|||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -134,13 +135,19 @@ pub(crate) fn query_local_search_index_with_settings(
|
|||||||
)?;
|
)?;
|
||||||
let normalized_query = normalize_search_text(query);
|
let normalized_query = normalize_search_text(query);
|
||||||
let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
|
let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
|
||||||
|
let page_resource_path = page_id.and_then(local_resource_path_from_document_id);
|
||||||
|
let markdown_page_id = if page_resource_path.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
page_id
|
||||||
|
};
|
||||||
let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
|
let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
for document in index.documents.iter() {
|
for document in index.documents.iter() {
|
||||||
if !index_relative_path_is_included(&document.path, &result_settings.include_paths) {
|
if !index_relative_path_is_included(&document.path, &result_settings.include_paths) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(page_id) = page_id {
|
if let Some(page_id) = markdown_page_id {
|
||||||
if document.document_id != page_id {
|
if document.document_id != page_id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -157,11 +164,16 @@ pub(crate) fn query_local_search_index_with_settings(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if page_id.is_none() && results.len() < limit.max(1) as usize {
|
if markdown_page_id.is_none() && results.len() < limit.max(1) as usize {
|
||||||
for resource in index.resources.iter() {
|
for resource in index.resources.iter() {
|
||||||
if !index_relative_path_is_included(&resource.path, &result_settings.include_paths) {
|
if !index_relative_path_is_included(&resource.path, &result_settings.include_paths) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(resource_path) = page_resource_path.as_deref() {
|
||||||
|
if resource.path != resource_path {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
if !local_search_resource_matches(resource, &normalized_query, title_only, exact) {
|
if !local_search_resource_matches(resource, &normalized_query, title_only, exact) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -180,7 +192,11 @@ pub(crate) fn query_local_search_index_with_settings(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(page_id) = page_id {
|
if let Some(page_id) = page_id {
|
||||||
if entry.owner_document_id != page_id {
|
if let Some(resource_path) = page_resource_path.as_deref() {
|
||||||
|
if entry.source_root_relative_path != resource_path {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if entry.owner_document_id != page_id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +380,43 @@ pub(crate) fn write_user_local_index_settings(
|
|||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn preview_user_local_index_settings(
|
||||||
|
store: &dyn ControlPlaneStore,
|
||||||
|
user_id: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
root_path: &Path,
|
||||||
|
include_paths: &[String],
|
||||||
|
schedule_mode: Option<&str>,
|
||||||
|
schedule_time: Option<&str>,
|
||||||
|
schedule_date: Option<&str>,
|
||||||
|
run_on_change: Option<bool>,
|
||||||
|
) -> Result<LocalIndexSettings, WebError> {
|
||||||
|
let user_id = user_id.trim();
|
||||||
|
if user_id.is_empty() || user_id == "anonymous" {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"local_index_settings_auth_required",
|
||||||
|
"本地索引设置需要登录用户",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let existing = read_user_local_index_settings(store, user_id, workspace_id, root_path)?;
|
||||||
|
let include_paths = normalize_index_include_paths(root_path, include_paths)?;
|
||||||
|
let schedule_mode =
|
||||||
|
normalize_index_schedule_mode(schedule_mode.unwrap_or(&existing.schedule_mode))?;
|
||||||
|
let schedule_time =
|
||||||
|
normalize_index_schedule_time(schedule_time.unwrap_or(&existing.schedule_time))?;
|
||||||
|
let schedule_date =
|
||||||
|
normalize_index_schedule_date(schedule_date.or(existing.schedule_date.as_deref()))?;
|
||||||
|
Ok(LocalIndexSettings {
|
||||||
|
schema: LOCAL_INDEX_SETTINGS_SCHEMA.to_string(),
|
||||||
|
include_paths,
|
||||||
|
schedule_mode,
|
||||||
|
schedule_time,
|
||||||
|
schedule_date,
|
||||||
|
run_on_change: run_on_change.unwrap_or(existing.run_on_change),
|
||||||
|
updated_at: now_ms(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn effective_local_index_settings_for_root(
|
pub(crate) fn effective_local_index_settings_for_root(
|
||||||
store: &dyn ControlPlaneStore,
|
store: &dyn ControlPlaneStore,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -452,7 +505,7 @@ fn local_index_status_for_settings(
|
|||||||
let mut document_count = 0usize;
|
let mut document_count = 0usize;
|
||||||
let mut resource_count = 0usize;
|
let mut resource_count = 0usize;
|
||||||
let mut built_at = Value::Null;
|
let mut built_at = Value::Null;
|
||||||
let mut cache_matches_settings = false;
|
let cache_matches_settings;
|
||||||
let scheduled_due = if let Some(index) = index.as_ref() {
|
let scheduled_due = if let Some(index) = index.as_ref() {
|
||||||
document_count = index.documents.len();
|
document_count = index.documents.len();
|
||||||
resource_count = index.resources.len();
|
resource_count = index.resources.len();
|
||||||
@@ -463,6 +516,7 @@ fn local_index_status_for_settings(
|
|||||||
&& normalized_indexed_paths(&index.indexed_paths) == settings.include_paths;
|
&& normalized_indexed_paths(&index.indexed_paths) == settings.include_paths;
|
||||||
local_index_schedule_is_due(&settings, index.built_at)
|
local_index_schedule_is_due(&settings, index.built_at)
|
||||||
} else {
|
} else {
|
||||||
|
cache_matches_settings = settings.include_paths.is_empty();
|
||||||
local_index_schedule_is_due(&settings, 0)
|
local_index_schedule_is_due(&settings, 0)
|
||||||
};
|
};
|
||||||
let evidence_block_count = if evidence_path.exists() {
|
let evidence_block_count = if evidence_path.exists() {
|
||||||
@@ -622,6 +676,16 @@ pub(crate) fn query_evidence_sqlite_results(
|
|||||||
query: &str,
|
query: &str,
|
||||||
owner_document_id: Option<&str>,
|
owner_document_id: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
|
) -> Result<Option<Vec<EvidenceSearchResult>>, WebError> {
|
||||||
|
query_evidence_sqlite_results_with_mode(root_path, query, owner_document_id, limit, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn query_evidence_sqlite_results_with_mode(
|
||||||
|
root_path: &Path,
|
||||||
|
query: &str,
|
||||||
|
owner_document_id: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
exact: bool,
|
||||||
) -> Result<Option<Vec<EvidenceSearchResult>>, WebError> {
|
) -> Result<Option<Vec<EvidenceSearchResult>>, WebError> {
|
||||||
let path = evidence_sqlite_path(root_path);
|
let path = evidence_sqlite_path(root_path);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -631,24 +695,54 @@ pub(crate) fn query_evidence_sqlite_results(
|
|||||||
if normalized_query.is_empty() {
|
if normalized_query.is_empty() {
|
||||||
return Ok(Some(Vec::new()));
|
return Ok(Some(Vec::new()));
|
||||||
}
|
}
|
||||||
|
let owner_document_id = owner_document_id
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let resource_path = owner_document_id.and_then(local_resource_path_from_document_id);
|
||||||
|
let owner_document_id = if resource_path.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
owner_document_id
|
||||||
|
};
|
||||||
let connection = Connection::open(&path).map_err(|error| {
|
let connection = Connection::open(&path).map_err(|error| {
|
||||||
WebError::bad_request_code(
|
WebError::bad_request_code(
|
||||||
"evidence_index_open_failed",
|
"evidence_index_open_failed",
|
||||||
format!("无法打开 evidence 索引 {}: {error}", path.display()),
|
format!("无法打开 evidence 索引 {}: {error}", path.display()),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
if !exact {
|
||||||
|
return Ok(Some(query_evidence_sqlite_fuzzy(
|
||||||
|
&connection,
|
||||||
|
normalized_query,
|
||||||
|
owner_document_id,
|
||||||
|
resource_path.as_deref(),
|
||||||
|
limit,
|
||||||
|
)?));
|
||||||
|
}
|
||||||
let fts_query = evidence_fts_phrase(normalized_query);
|
let fts_query = evidence_fts_phrase(normalized_query);
|
||||||
let results = match query_evidence_sqlite_fts(
|
let results = match query_evidence_sqlite_fts(
|
||||||
&connection,
|
&connection,
|
||||||
&fts_query,
|
&fts_query,
|
||||||
normalized_query,
|
normalized_query,
|
||||||
owner_document_id,
|
owner_document_id,
|
||||||
|
resource_path.as_deref(),
|
||||||
limit,
|
limit,
|
||||||
) {
|
) {
|
||||||
|
Ok(results) if results.is_empty() => query_evidence_sqlite_like(
|
||||||
|
&connection,
|
||||||
|
normalized_query,
|
||||||
|
owner_document_id,
|
||||||
|
resource_path.as_deref(),
|
||||||
|
limit,
|
||||||
|
)?,
|
||||||
Ok(results) => results,
|
Ok(results) => results,
|
||||||
Err(_) => {
|
Err(_) => query_evidence_sqlite_like(
|
||||||
query_evidence_sqlite_like(&connection, normalized_query, owner_document_id, limit)?
|
&connection,
|
||||||
}
|
normalized_query,
|
||||||
|
owner_document_id,
|
||||||
|
resource_path.as_deref(),
|
||||||
|
limit,
|
||||||
|
)?,
|
||||||
};
|
};
|
||||||
Ok(Some(results))
|
Ok(Some(results))
|
||||||
}
|
}
|
||||||
@@ -718,6 +812,9 @@ pub(crate) fn read_evidence_sqlite_context(
|
|||||||
0.8
|
0.8
|
||||||
},
|
},
|
||||||
source: source.clone(),
|
source: source.clone(),
|
||||||
|
citation_url: None,
|
||||||
|
citation_label: None,
|
||||||
|
citation_markdown: None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
Ok(Some(results))
|
Ok(Some(results))
|
||||||
@@ -804,6 +901,9 @@ fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchRes
|
|||||||
block_id: source.block_id.or(Some(source_block_id)),
|
block_id: source.block_id.or(Some(source_block_id)),
|
||||||
..source
|
..source
|
||||||
},
|
},
|
||||||
|
citation_url: None,
|
||||||
|
citation_label: None,
|
||||||
|
citation_markdown: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,6 +924,7 @@ fn query_evidence_sqlite_fts(
|
|||||||
fts_query: &str,
|
fts_query: &str,
|
||||||
display_query: &str,
|
display_query: &str,
|
||||||
owner_document_id: Option<&str>,
|
owner_document_id: Option<&str>,
|
||||||
|
resource_path: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<Vec<EvidenceSearchResult>, WebError> {
|
) -> Result<Vec<EvidenceSearchResult>, WebError> {
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(
|
||||||
@@ -835,8 +936,10 @@ fn query_evidence_sqlite_fts(
|
|||||||
);
|
);
|
||||||
if owner_document_id.is_some() {
|
if owner_document_id.is_some() {
|
||||||
sql.push_str(" AND r.owner_document_id = ?2");
|
sql.push_str(" AND r.owner_document_id = ?2");
|
||||||
|
} else if resource_path.is_some() {
|
||||||
|
sql.push_str(" AND r.source_root_relative_path = ?2");
|
||||||
}
|
}
|
||||||
sql.push_str(if owner_document_id.is_some() {
|
sql.push_str(if owner_document_id.is_some() || resource_path.is_some() {
|
||||||
" ORDER BY rank LIMIT ?3"
|
" ORDER BY rank LIMIT ?3"
|
||||||
} else {
|
} else {
|
||||||
" ORDER BY rank LIMIT ?2"
|
" ORDER BY rank LIMIT ?2"
|
||||||
@@ -850,6 +953,13 @@ fn query_evidence_sqlite_fts(
|
|||||||
})
|
})
|
||||||
.map_err(sqlite_error)?
|
.map_err(sqlite_error)?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
} else if let Some(resource_path) = resource_path {
|
||||||
|
statement
|
||||||
|
.query_map(params![fts_query, resource_path, limit], |row| {
|
||||||
|
evidence_result_from_sqlite_row(row, display_query)
|
||||||
|
})
|
||||||
|
.map_err(sqlite_error)?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
} else {
|
} else {
|
||||||
statement
|
statement
|
||||||
.query_map(params![fts_query, limit], |row| {
|
.query_map(params![fts_query, limit], |row| {
|
||||||
@@ -865,6 +975,7 @@ fn query_evidence_sqlite_like(
|
|||||||
connection: &Connection,
|
connection: &Connection,
|
||||||
query: &str,
|
query: &str,
|
||||||
owner_document_id: Option<&str>,
|
owner_document_id: Option<&str>,
|
||||||
|
resource_path: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<Vec<EvidenceSearchResult>, WebError> {
|
) -> Result<Vec<EvidenceSearchResult>, WebError> {
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(
|
||||||
@@ -875,8 +986,10 @@ fn query_evidence_sqlite_like(
|
|||||||
);
|
);
|
||||||
if owner_document_id.is_some() {
|
if owner_document_id.is_some() {
|
||||||
sql.push_str(" AND r.owner_document_id = ?2");
|
sql.push_str(" AND r.owner_document_id = ?2");
|
||||||
|
} else if resource_path.is_some() {
|
||||||
|
sql.push_str(" AND r.source_root_relative_path = ?2");
|
||||||
}
|
}
|
||||||
sql.push_str(if owner_document_id.is_some() {
|
sql.push_str(if owner_document_id.is_some() || resource_path.is_some() {
|
||||||
" LIMIT ?3"
|
" LIMIT ?3"
|
||||||
} else {
|
} else {
|
||||||
" LIMIT ?2"
|
" LIMIT ?2"
|
||||||
@@ -891,6 +1004,13 @@ fn query_evidence_sqlite_like(
|
|||||||
})
|
})
|
||||||
.map_err(sqlite_error)?
|
.map_err(sqlite_error)?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
} else if let Some(resource_path) = resource_path {
|
||||||
|
statement
|
||||||
|
.query_map(params![like_query, resource_path, limit], |row| {
|
||||||
|
evidence_result_from_sqlite_row(row, query)
|
||||||
|
})
|
||||||
|
.map_err(sqlite_error)?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
} else {
|
} else {
|
||||||
statement
|
statement
|
||||||
.query_map(params![like_query, limit], |row| {
|
.query_map(params![like_query, limit], |row| {
|
||||||
@@ -902,6 +1022,80 @@ fn query_evidence_sqlite_like(
|
|||||||
rows.map_err(sqlite_error)
|
rows.map_err(sqlite_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn query_evidence_sqlite_fuzzy(
|
||||||
|
connection: &Connection,
|
||||||
|
query: &str,
|
||||||
|
owner_document_id: Option<&str>,
|
||||||
|
resource_path: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<EvidenceSearchResult>, WebError> {
|
||||||
|
let mut sql = String::from(
|
||||||
|
"SELECT b.block_id, b.text, b.locator_json, 0.0 AS rank \
|
||||||
|
FROM evidence_block b \
|
||||||
|
JOIN evidence_resource r ON r.resource_id = b.resource_id",
|
||||||
|
);
|
||||||
|
let first_char_like = query
|
||||||
|
.chars()
|
||||||
|
.find(|ch| !ch.is_whitespace())
|
||||||
|
.map(|ch| format!("%{}%", ch));
|
||||||
|
match (
|
||||||
|
owner_document_id.is_some() || resource_path.is_some(),
|
||||||
|
first_char_like.is_some(),
|
||||||
|
resource_path.is_some(),
|
||||||
|
) {
|
||||||
|
(true, true, true) => {
|
||||||
|
sql.push_str(" WHERE r.source_root_relative_path = ?1 AND b.text LIKE ?2 LIMIT ?3")
|
||||||
|
}
|
||||||
|
(true, true, false) => {
|
||||||
|
sql.push_str(" WHERE r.owner_document_id = ?1 AND b.text LIKE ?2 LIMIT ?3")
|
||||||
|
}
|
||||||
|
(true, false, true) => sql.push_str(" WHERE r.source_root_relative_path = ?1 LIMIT ?2"),
|
||||||
|
(true, false, false) => sql.push_str(" WHERE r.owner_document_id = ?1 LIMIT ?2"),
|
||||||
|
(false, true, _) => sql.push_str(" WHERE b.text LIKE ?1 LIMIT ?2"),
|
||||||
|
(false, false, _) => sql.push_str(" LIMIT ?1"),
|
||||||
|
}
|
||||||
|
let mut statement = connection.prepare(&sql).map_err(sqlite_error)?;
|
||||||
|
let scan_limit = i64::from(limit.max(1)) * 200;
|
||||||
|
let scope_value = owner_document_id.or(resource_path);
|
||||||
|
let rows = match (scope_value, first_char_like.as_deref()) {
|
||||||
|
(Some(scope_value), Some(first_char_like)) => statement
|
||||||
|
.query_map(params![scope_value, first_char_like, scan_limit], |row| {
|
||||||
|
evidence_result_from_sqlite_row(row, query)
|
||||||
|
})
|
||||||
|
.map_err(sqlite_error)?
|
||||||
|
.collect::<Result<Vec<_>, _>>(),
|
||||||
|
(Some(scope_value), None) => statement
|
||||||
|
.query_map(params![scope_value, scan_limit], |row| {
|
||||||
|
evidence_result_from_sqlite_row(row, query)
|
||||||
|
})
|
||||||
|
.map_err(sqlite_error)?
|
||||||
|
.collect::<Result<Vec<_>, _>>(),
|
||||||
|
(None, Some(first_char_like)) => statement
|
||||||
|
.query_map(params![first_char_like, scan_limit], |row| {
|
||||||
|
evidence_result_from_sqlite_row(row, query)
|
||||||
|
})
|
||||||
|
.map_err(sqlite_error)?
|
||||||
|
.collect::<Result<Vec<_>, _>>(),
|
||||||
|
(None, None) => statement
|
||||||
|
.query_map(params![scan_limit], |row| {
|
||||||
|
evidence_result_from_sqlite_row(row, query)
|
||||||
|
})
|
||||||
|
.map_err(sqlite_error)?
|
||||||
|
.collect::<Result<Vec<_>, _>>(),
|
||||||
|
}
|
||||||
|
.map_err(sqlite_error)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter(|result| {
|
||||||
|
fuzzy_search_match(
|
||||||
|
&normalize_search_text(&result.quote),
|
||||||
|
&normalize_search_text(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.take(limit.max(1) as usize)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
fn evidence_result_from_sqlite_row(
|
fn evidence_result_from_sqlite_row(
|
||||||
row: &rusqlite::Row<'_>,
|
row: &rusqlite::Row<'_>,
|
||||||
query: &str,
|
query: &str,
|
||||||
@@ -922,6 +1116,9 @@ fn evidence_result_from_sqlite_row(
|
|||||||
1.0 / (1.0 + rank.abs())
|
1.0 / (1.0 + rank.abs())
|
||||||
},
|
},
|
||||||
source,
|
source,
|
||||||
|
citation_url: None,
|
||||||
|
citation_label: None,
|
||||||
|
citation_markdown: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,6 +1142,18 @@ pub(crate) fn refresh_local_search_index_with_settings(
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
settings: &LocalIndexSettings,
|
settings: &LocalIndexSettings,
|
||||||
) -> Result<Value, WebError> {
|
) -> Result<Value, WebError> {
|
||||||
|
if settings.include_paths.is_empty() {
|
||||||
|
clear_local_search_index(root_path)?;
|
||||||
|
return Ok(json!({
|
||||||
|
"version": LOCAL_SEARCH_INDEX_VERSION,
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"workspaceId": workspace_id,
|
||||||
|
"indexedPaths": [],
|
||||||
|
"builtAt": now_ms(),
|
||||||
|
"documentCount": 0,
|
||||||
|
"resourceCount": 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
let index =
|
let index =
|
||||||
rebuild_local_search_index_with_settings(root_path, root_uri, workspace_id, settings)?;
|
rebuild_local_search_index_with_settings(root_path, root_uri, workspace_id, settings)?;
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
@@ -1427,7 +1636,7 @@ fn parse_local_index_settings_value(
|
|||||||
fn default_local_index_settings() -> LocalIndexSettings {
|
fn default_local_index_settings() -> LocalIndexSettings {
|
||||||
LocalIndexSettings {
|
LocalIndexSettings {
|
||||||
schema: LOCAL_INDEX_SETTINGS_SCHEMA.to_string(),
|
schema: LOCAL_INDEX_SETTINGS_SCHEMA.to_string(),
|
||||||
include_paths: default_indexed_paths(),
|
include_paths: Vec::new(),
|
||||||
schedule_mode: default_index_schedule_mode(),
|
schedule_mode: default_index_schedule_mode(),
|
||||||
schedule_time: default_index_schedule_time(),
|
schedule_time: default_index_schedule_time(),
|
||||||
schedule_date: None,
|
schedule_date: None,
|
||||||
@@ -1605,11 +1814,46 @@ fn normalize_index_include_paths(
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let raw_path = PathBuf::from(trimmed);
|
||||||
let normalized = if trimmed == "." || trimmed == "/" {
|
let normalized = if trimmed == "." || trimmed == "/" {
|
||||||
".".to_string()
|
".".to_string()
|
||||||
|
} else if raw_path.is_absolute() {
|
||||||
|
let canonical = raw_path.canonicalize().map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_index_scope_not_found",
|
||||||
|
format!("本地索引范围不存在 {}: {error}", raw_path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !canonical.starts_with(&root_canonical) {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"local_index_scope_escape",
|
||||||
|
"本地索引目录必须位于当前授权 root 内",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !canonical.is_dir() {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"local_index_scope_not_directory",
|
||||||
|
"本地索引范围必须是目录",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
canonical
|
||||||
|
.strip_prefix(&root_canonical)
|
||||||
|
.map_err(|_| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_index_scope_escape",
|
||||||
|
"本地索引目录必须位于当前授权 root 内",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/")
|
||||||
} else {
|
} else {
|
||||||
normalize_index_relative_path(trimmed.trim_start_matches("./"))?
|
normalize_index_relative_path(trimmed.trim_start_matches("./"))?
|
||||||
};
|
};
|
||||||
|
let normalized = if normalized.is_empty() {
|
||||||
|
".".to_string()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
};
|
||||||
if normalized.split('/').any(|part| part == ".mnote") {
|
if normalized.split('/').any(|part| part == ".mnote") {
|
||||||
return Err(WebError::bad_request_code(
|
return Err(WebError::bad_request_code(
|
||||||
"local_index_scope_reserved",
|
"local_index_scope_reserved",
|
||||||
@@ -1640,9 +1884,6 @@ fn normalize_index_include_paths(
|
|||||||
}
|
}
|
||||||
output.push(normalized);
|
output.push(normalized);
|
||||||
}
|
}
|
||||||
if output.is_empty() {
|
|
||||||
output.push(".".to_string());
|
|
||||||
}
|
|
||||||
Ok(normalized_indexed_paths(&output))
|
Ok(normalized_indexed_paths(&output))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1884,6 +2125,27 @@ fn write_local_search_index_json(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
|
||||||
|
let index_path = root_path
|
||||||
|
.join(".mnote")
|
||||||
|
.join("index")
|
||||||
|
.join("search-index.json");
|
||||||
|
let evidence_path = evidence_sqlite_path(root_path);
|
||||||
|
for path in [index_path, evidence_path] {
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"local_search_index_delete_failed",
|
||||||
|
format!("无法删除本地搜索索引文件 {}: {error}", path.display()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_evidence_sqlite_index(
|
fn ensure_evidence_sqlite_index(
|
||||||
root_path: &Path,
|
root_path: &Path,
|
||||||
index: &LocalSearchIndex,
|
index: &LocalSearchIndex,
|
||||||
@@ -3151,6 +3413,72 @@ fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
|
|||||||
.map_err(sqlite_error)
|
.map_err(sqlite_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(crate) struct LocalEvidenceSourceStatuses {
|
||||||
|
pub(crate) indexed_paths: BTreeSet<String>,
|
||||||
|
pub(crate) failed_paths: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn local_evidence_source_statuses(
|
||||||
|
root_path: &Path,
|
||||||
|
) -> Result<LocalEvidenceSourceStatuses, WebError> {
|
||||||
|
let mut statuses = LocalEvidenceSourceStatuses::default();
|
||||||
|
let evidence_path = evidence_sqlite_path(root_path);
|
||||||
|
if evidence_path.exists() {
|
||||||
|
let connection = Connection::open(&evidence_path).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"evidence_index_open_failed",
|
||||||
|
format!(
|
||||||
|
"无法打开 evidence 索引 {}: {error}",
|
||||||
|
evidence_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut statement = connection
|
||||||
|
.prepare(
|
||||||
|
"SELECT DISTINCT source_root_relative_path \
|
||||||
|
FROM evidence_resource \
|
||||||
|
WHERE provider NOT IN ('resource', 'markdown')",
|
||||||
|
)
|
||||||
|
.map_err(sqlite_error)?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))
|
||||||
|
.map_err(sqlite_error)?;
|
||||||
|
for row in rows {
|
||||||
|
if let Ok(path) = row {
|
||||||
|
if !path.trim().is_empty() {
|
||||||
|
statuses.indexed_paths.insert(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(index) = read_local_search_index(root_path)? {
|
||||||
|
for resource in index.resources {
|
||||||
|
if matches!(resource.resource_type.as_str(), "pdf" | "office")
|
||||||
|
&& !statuses.indexed_paths.contains(&resource.path)
|
||||||
|
{
|
||||||
|
statuses.failed_paths.insert(resource.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for entry in local_ocr::ocr_index_entries(root_path)? {
|
||||||
|
match entry.status.as_str() {
|
||||||
|
"done" => {
|
||||||
|
statuses
|
||||||
|
.indexed_paths
|
||||||
|
.insert(entry.source_root_relative_path);
|
||||||
|
}
|
||||||
|
"failed" | "interrupted" => {
|
||||||
|
statuses
|
||||||
|
.failed_paths
|
||||||
|
.insert(entry.source_root_relative_path);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(statuses)
|
||||||
|
}
|
||||||
|
|
||||||
fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
|
fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
|
||||||
match resource_type {
|
match resource_type {
|
||||||
"mindmap" => "mindmap",
|
"mindmap" => "mindmap",
|
||||||
@@ -3179,6 +3507,14 @@ fn evidence_resource_kind_for_path(path: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_resource_path_from_document_id(document_id: &str) -> Option<String> {
|
||||||
|
document_id
|
||||||
|
.trim()
|
||||||
|
.strip_prefix("local-resource:")
|
||||||
|
.map(|value| value.replace("~2F", "/").replace("~2f", "/"))
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
fn path_source_map_path(path: &str) -> Option<String> {
|
fn path_source_map_path(path: &str) -> Option<String> {
|
||||||
if let Some(stripped) = path.strip_suffix(".ocr.md") {
|
if let Some(stripped) = path.strip_suffix(".ocr.md") {
|
||||||
return Some(format!("{stripped}.source-map.json"));
|
return Some(format!("{stripped}.source-map.json"));
|
||||||
@@ -3267,9 +3603,9 @@ fn local_search_document_matches(
|
|||||||
))
|
))
|
||||||
};
|
};
|
||||||
if exact {
|
if exact {
|
||||||
haystack == query
|
|
||||||
} else {
|
|
||||||
haystack.contains(query)
|
haystack.contains(query)
|
||||||
|
} else {
|
||||||
|
fuzzy_search_match(&haystack, query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3291,9 +3627,9 @@ fn local_search_resource_matches(
|
|||||||
))
|
))
|
||||||
};
|
};
|
||||||
if exact {
|
if exact {
|
||||||
haystack == query
|
|
||||||
} else {
|
|
||||||
haystack.contains(query)
|
haystack.contains(query)
|
||||||
|
} else {
|
||||||
|
fuzzy_search_match(&haystack, query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3319,9 +3655,9 @@ fn local_search_ocr_matches(
|
|||||||
))
|
))
|
||||||
};
|
};
|
||||||
if exact {
|
if exact {
|
||||||
haystack == query
|
|
||||||
} else {
|
|
||||||
haystack.contains(query)
|
haystack.contains(query)
|
||||||
|
} else {
|
||||||
|
fuzzy_search_match(&haystack, query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3330,6 +3666,7 @@ fn local_search_document_projection(
|
|||||||
root_uri: &str,
|
root_uri: &str,
|
||||||
query: &str,
|
query: &str,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
|
let hit = search_document_hit(document, query);
|
||||||
json!({
|
json!({
|
||||||
"id": document.document_id,
|
"id": document.document_id,
|
||||||
"documentId": document.document_id,
|
"documentId": document.document_id,
|
||||||
@@ -3338,7 +3675,12 @@ fn local_search_document_projection(
|
|||||||
"resourceType": "markdown",
|
"resourceType": "markdown",
|
||||||
"sourceKind": "local_folder",
|
"sourceKind": "local_folder",
|
||||||
"rootUri": root_uri,
|
"rootUri": root_uri,
|
||||||
"snippet": search_snippet(document, query),
|
"snippet": hit.snippet,
|
||||||
|
"blockId": hit.block_id,
|
||||||
|
"lineRange": {
|
||||||
|
"start": hit.line_number,
|
||||||
|
"end": hit.line_number,
|
||||||
|
},
|
||||||
"tags": document.tags,
|
"tags": document.tags,
|
||||||
"backlinks": document.backlinks,
|
"backlinks": document.backlinks,
|
||||||
"resourceRefs": document.resource_refs,
|
"resourceRefs": document.resource_refs,
|
||||||
@@ -3548,16 +3890,43 @@ fn is_local_resource_reference(target: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
|
fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
|
||||||
for line in document.raw_text.lines() {
|
search_document_hit(document, query).snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SearchDocumentHit {
|
||||||
|
snippet: String,
|
||||||
|
line_number: usize,
|
||||||
|
block_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_document_hit(document: &LocalSearchDocument, query: &str) -> SearchDocumentHit {
|
||||||
|
for (line_index, line) in document.raw_text.lines().enumerate() {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if normalize_search_text(trimmed).contains(query) {
|
if normalize_search_text(trimmed).contains(query) {
|
||||||
return trimmed.chars().take(180).collect();
|
let line_number = line_index + 1;
|
||||||
|
return SearchDocumentHit {
|
||||||
|
snippet: trimmed.chars().take(180).collect(),
|
||||||
|
line_number,
|
||||||
|
block_id: format!("{}#line{}", document.document_id, line_number),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.raw_text.chars().take(180).collect()
|
let line_number = document
|
||||||
|
.raw_text
|
||||||
|
.lines()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, line)| !line.trim().is_empty())
|
||||||
|
.map(|(line_index, _)| line_index + 1)
|
||||||
|
.unwrap_or(1);
|
||||||
|
SearchDocumentHit {
|
||||||
|
snippet: document.raw_text.chars().take(180).collect(),
|
||||||
|
line_number,
|
||||||
|
block_id: format!("{}#line{}", document.document_id, line_number),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ocr_search_snippet(body: &str, query: &str) -> String {
|
fn ocr_search_snippet(body: &str, query: &str) -> String {
|
||||||
@@ -3576,11 +3945,73 @@ fn ocr_search_snippet(body: &str, query: &str) -> String {
|
|||||||
.map(|(idx, _)| idx)
|
.map(|(idx, _)| idx)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
normalized_body[start..].chars().take(160).collect()
|
normalized_body[start..].chars().take(160).collect()
|
||||||
|
} else if let Some(byte_index) = fuzzy_search_start_byte(&normalized_body, normalized_query) {
|
||||||
|
let start = normalized_body[..byte_index]
|
||||||
|
.char_indices()
|
||||||
|
.rev()
|
||||||
|
.nth(40)
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
.unwrap_or(0);
|
||||||
|
normalized_body[start..].chars().take(180).collect()
|
||||||
} else {
|
} else {
|
||||||
normalized_body.chars().take(160).collect()
|
normalized_body.chars().take(160).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fuzzy_search_match(haystack: &str, query: &str) -> bool {
|
||||||
|
if query.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if haystack.contains(query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut query_chars = query.chars().filter(|ch| !ch.is_whitespace());
|
||||||
|
let Some(mut wanted) = query_chars.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for ch in haystack.chars().filter(|ch| !ch.is_whitespace()) {
|
||||||
|
if ch == wanted {
|
||||||
|
match query_chars.next() {
|
||||||
|
Some(next) => wanted = next,
|
||||||
|
None => return true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fuzzy_search_start_byte(haystack: &str, query: &str) -> Option<usize> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let query_chars = query
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| !ch.is_whitespace())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if query_chars.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let haystack_chars = haystack.char_indices().collect::<Vec<_>>();
|
||||||
|
for (start_index, (byte_index, ch)) in haystack_chars.iter().enumerate() {
|
||||||
|
if ch != &query_chars[0] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut query_index = 1usize;
|
||||||
|
for (_, next_ch) in haystack_chars.iter().skip(start_index + 1) {
|
||||||
|
if next_ch.is_whitespace() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if query_index < query_chars.len() && next_ch == &query_chars[query_index] {
|
||||||
|
query_index += 1;
|
||||||
|
if query_index >= query_chars.len() {
|
||||||
|
return Some(*byte_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn is_markdown_path(path: &Path) -> bool {
|
fn is_markdown_path(path: &Path) -> bool {
|
||||||
path.extension()
|
path.extension()
|
||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
@@ -3905,6 +4336,89 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_index_settings_accepts_absolute_path_under_root_as_frozen_scope() {
|
||||||
|
let root = temp_root("mnote-local-index-settings-absolute");
|
||||||
|
fs::create_dir_all(root.join("docs").join("absolute")).expect("create absolute dir");
|
||||||
|
let absolute_scope = root.join("docs").join("absolute");
|
||||||
|
|
||||||
|
let settings = write_local_index_settings(
|
||||||
|
&root,
|
||||||
|
&[absolute_scope.to_string_lossy().to_string()],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("absolute scope under root");
|
||||||
|
|
||||||
|
assert_eq!(settings.include_paths, vec![String::from("docs/absolute")]);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_local_index_empty_scope_deletes_index_files() {
|
||||||
|
let root = temp_root("mnote-local-index-empty-delete");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("keep.md"),
|
||||||
|
"# Keep\nDeleteIndexToken\n",
|
||||||
|
)
|
||||||
|
.expect("write doc");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let workspace_id = "local-ws-empty-delete";
|
||||||
|
let store = SqliteControlPlaneStore::in_memory().expect("init control plane");
|
||||||
|
|
||||||
|
write_user_local_index_settings(
|
||||||
|
&store,
|
||||||
|
"alice",
|
||||||
|
workspace_id,
|
||||||
|
&root,
|
||||||
|
&[String::from("docs")],
|
||||||
|
Some("manual"),
|
||||||
|
Some("02:00"),
|
||||||
|
None,
|
||||||
|
Some(false),
|
||||||
|
)
|
||||||
|
.expect("write indexed scope");
|
||||||
|
let effective = effective_local_index_settings_for_root(&store, workspace_id, &root)
|
||||||
|
.expect("effective indexed");
|
||||||
|
refresh_local_search_index_with_settings(&root, &root_uri, workspace_id, &effective)
|
||||||
|
.expect("refresh indexed");
|
||||||
|
assert!(root.join(".mnote/index/search-index.json").exists());
|
||||||
|
assert!(root.join(".mnote/index/evidence.sqlite").exists());
|
||||||
|
|
||||||
|
write_user_local_index_settings(
|
||||||
|
&store,
|
||||||
|
"alice",
|
||||||
|
workspace_id,
|
||||||
|
&root,
|
||||||
|
&[],
|
||||||
|
Some("manual"),
|
||||||
|
Some("02:00"),
|
||||||
|
None,
|
||||||
|
Some(false),
|
||||||
|
)
|
||||||
|
.expect("delete indexed scopes");
|
||||||
|
let empty_effective = effective_local_index_settings_for_root(&store, workspace_id, &root)
|
||||||
|
.expect("effective empty");
|
||||||
|
assert!(empty_effective.include_paths.is_empty());
|
||||||
|
refresh_local_search_index_with_settings(&root, &root_uri, workspace_id, &empty_effective)
|
||||||
|
.expect("clear index files");
|
||||||
|
|
||||||
|
assert!(!root.join(".mnote/index/search-index.json").exists());
|
||||||
|
assert!(!root.join(".mnote/index/evidence.sqlite").exists());
|
||||||
|
let status = local_index_status_with_settings(
|
||||||
|
&root,
|
||||||
|
&root_uri,
|
||||||
|
workspace_id,
|
||||||
|
&empty_effective,
|
||||||
|
&empty_effective,
|
||||||
|
)
|
||||||
|
.expect("status");
|
||||||
|
assert_eq!(status["cacheMatchesSettings"].as_bool(), Some(true));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_index_settings_rejects_escape_and_internal_cache_scope() {
|
fn local_index_settings_rejects_escape_and_internal_cache_scope() {
|
||||||
let root = temp_root("mnote-local-index-settings-escape");
|
let root = temp_root("mnote-local-index-settings-escape");
|
||||||
@@ -3949,6 +4463,14 @@ mod tests {
|
|||||||
initial_status["settings"]["runOnChange"].as_bool(),
|
initial_status["settings"]["runOnChange"].as_bool(),
|
||||||
Some(false)
|
Some(false)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
initial_status["settings"]["includePaths"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(0),
|
||||||
|
"默认不应索引工作区根目录;用户新增范围后才开始索引"
|
||||||
|
);
|
||||||
|
assert_eq!(initial_status["cacheMatchesSettings"].as_bool(), Some(true));
|
||||||
|
|
||||||
let settings = write_local_index_settings(
|
let settings = write_local_index_settings(
|
||||||
&root,
|
&root,
|
||||||
@@ -4356,6 +4878,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("write child");
|
.expect("write child");
|
||||||
|
|
||||||
|
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||||||
|
.expect("settings");
|
||||||
refresh_local_search_index(&root, &root_uri, workspace_id).expect("initial refresh");
|
refresh_local_search_index(&root, &root_uri, workspace_id).expect("initial refresh");
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("docs").join("child.md"),
|
root.join("docs").join("child.md"),
|
||||||
@@ -4643,6 +5167,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("write child");
|
.expect("write child");
|
||||||
|
|
||||||
|
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||||||
|
.expect("settings");
|
||||||
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
|
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
|
||||||
|
|
||||||
let results = query_evidence_sqlite_results(&root, "EvidenceToken", None, 10)
|
let results = query_evidence_sqlite_results(&root, "EvidenceToken", None, 10)
|
||||||
@@ -4750,6 +5276,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("write home");
|
.expect("write home");
|
||||||
|
|
||||||
|
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||||||
|
.expect("settings");
|
||||||
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
|
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
|
||||||
let results = query_evidence_sqlite_results(&root, "ReadContextToken", None, 10)
|
let results = query_evidence_sqlite_results(&root, "ReadContextToken", None, 10)
|
||||||
.expect("sqlite query")
|
.expect("sqlite query")
|
||||||
@@ -4821,6 +5349,8 @@ JSON
|
|||||||
let old_bin = std::env::var("MNOTE_LITEPARSE_BIN").ok();
|
let old_bin = std::env::var("MNOTE_LITEPARSE_BIN").ok();
|
||||||
std::env::set_var("MNOTE_LITEPARSE_BIN", &lit);
|
std::env::set_var("MNOTE_LITEPARSE_BIN", &lit);
|
||||||
|
|
||||||
|
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||||||
|
.expect("settings");
|
||||||
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
|
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
|
||||||
|
|
||||||
if let Some(value) = old_bin {
|
if let Some(value) = old_bin {
|
||||||
@@ -4850,6 +5380,21 @@ JSON
|
|||||||
hit.source.source_map_path.as_deref(),
|
hit.source.source_map_path.as_deref(),
|
||||||
Some("docs/Page.ocr/spec.pdf.source-map.json")
|
Some("docs/Page.ocr/spec.pdf.source-map.json")
|
||||||
);
|
);
|
||||||
|
let resource_scoped = query_evidence_sqlite_results_with_mode(
|
||||||
|
&root,
|
||||||
|
"ResourceBodyToken",
|
||||||
|
Some("local-resource:docs~2FPage.assets~2Fspec.pdf"),
|
||||||
|
10,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("resource scoped sqlite query")
|
||||||
|
.expect("sqlite exists");
|
||||||
|
assert_eq!(resource_scoped.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
resource_scoped[0].source.resource_path.as_deref(),
|
||||||
|
Some("docs/Page.assets/spec.pdf"),
|
||||||
|
"页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown"
|
||||||
|
);
|
||||||
assert!(root
|
assert!(root
|
||||||
.join("docs")
|
.join("docs")
|
||||||
.join("Page.ocr")
|
.join("Page.ocr")
|
||||||
|
|||||||
@@ -37,16 +37,21 @@ pub(crate) mod ui_preferences;
|
|||||||
pub(crate) mod web_shell;
|
pub(crate) mod web_shell;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
|
pub(crate) use gateway::current_actor_id;
|
||||||
pub(crate) use local_folder_source::{
|
pub(crate) use local_folder_source::{
|
||||||
decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
|
decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
|
||||||
|
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
|
||||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||||
update_local_markdown_title, write_local_markdown_page_body,
|
update_local_markdown_title, write_local_markdown_page_body,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use local_search_index::write_local_index_settings;
|
pub(crate) use local_search_index::write_local_index_settings;
|
||||||
pub(crate) use local_search_index::{
|
pub(crate) use local_search_index::{
|
||||||
refresh_local_search_index_for_change_path_with_store,
|
effective_local_index_settings_for_root, local_index_status_with_settings,
|
||||||
|
preview_user_local_index_settings, read_local_index_settings_or_default,
|
||||||
|
read_user_local_index_settings, refresh_local_search_index_for_change_path_with_store,
|
||||||
refresh_local_search_index_if_scheduled_due_with_store,
|
refresh_local_search_index_if_scheduled_due_with_store,
|
||||||
|
refresh_local_search_index_with_settings, write_user_local_index_settings, LocalIndexSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
@@ -641,6 +646,14 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route("/client/skills", get(hermes_client::list_skills))
|
.route("/client/skills", get(hermes_client::list_skills))
|
||||||
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
|
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
|
||||||
|
.route(
|
||||||
|
"/client/capabilities",
|
||||||
|
get(hermes_client::list_capabilities),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/client/capabilities/toggle",
|
||||||
|
put(hermes_client::toggle_capability),
|
||||||
|
)
|
||||||
.route("/client/tools/toggle", put(hermes_client::toggle_tool))
|
.route("/client/tools/toggle", put(hermes_client::toggle_tool))
|
||||||
.route("/client/runs", post(hermes_client::create_run))
|
.route("/client/runs", post(hermes_client::create_run))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -229,6 +229,25 @@ pub async fn documents(
|
|||||||
EvidenceSearchMode::Hybrid,
|
EvidenceSearchMode::Hybrid,
|
||||||
&normalized_query,
|
&normalized_query,
|
||||||
);
|
);
|
||||||
|
let limit = body.limit.unwrap_or(30).max(1) as usize;
|
||||||
|
let direct_evidence_results = if !filters.title_only.unwrap_or(false) {
|
||||||
|
local_search_index::query_evidence_sqlite_results_with_mode(
|
||||||
|
&root_path,
|
||||||
|
&normalized_query,
|
||||||
|
page_id.as_deref(),
|
||||||
|
body.limit.unwrap_or(30),
|
||||||
|
filters.exact.unwrap_or(false),
|
||||||
|
)?
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let (result, evidence_results) = merge_local_search_with_evidence_results(
|
||||||
|
result,
|
||||||
|
evidence_results,
|
||||||
|
direct_evidence_results,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
(result, evidence_results)
|
(result, evidence_results)
|
||||||
} else {
|
} else {
|
||||||
let result = load_search_results_with_filters(
|
let result = load_search_results_with_filters(
|
||||||
@@ -419,6 +438,12 @@ pub async fn update_local_index_settings(
|
|||||||
&effective_workspace_id,
|
&effective_workspace_id,
|
||||||
&root_path,
|
&root_path,
|
||||||
)?;
|
)?;
|
||||||
|
let refreshed = local_search_index::refresh_local_search_index_with_settings(
|
||||||
|
&root_path,
|
||||||
|
root_uri,
|
||||||
|
&effective_workspace_id,
|
||||||
|
&effective_settings,
|
||||||
|
)?;
|
||||||
let status = local_search_index::local_index_status_with_settings(
|
let status = local_search_index::local_index_status_with_settings(
|
||||||
&root_path,
|
&root_path,
|
||||||
root_uri,
|
root_uri,
|
||||||
@@ -434,6 +459,7 @@ pub async fn update_local_index_settings(
|
|||||||
Json(json!({
|
Json(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"settings": settings,
|
"settings": settings,
|
||||||
|
"index": refreshed,
|
||||||
"result": status,
|
"result": status,
|
||||||
"meta": {
|
"meta": {
|
||||||
"owner": "mnote-web",
|
"owner": "mnote-web",
|
||||||
@@ -446,6 +472,75 @@ pub async fn update_local_index_settings(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_local_search_with_evidence_results(
|
||||||
|
mut result: Value,
|
||||||
|
mut evidence_results: Vec<EvidenceSearchResult>,
|
||||||
|
direct_evidence_results: Vec<EvidenceSearchResult>,
|
||||||
|
limit: usize,
|
||||||
|
) -> (Value, Vec<EvidenceSearchResult>) {
|
||||||
|
if direct_evidence_results.is_empty() {
|
||||||
|
return (result, evidence_results);
|
||||||
|
}
|
||||||
|
let mut result_items = result
|
||||||
|
.get("results")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut seen_evidence_ids = evidence_results
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.evidence_id.clone())
|
||||||
|
.collect::<std::collections::HashSet<_>>();
|
||||||
|
for evidence in direct_evidence_results {
|
||||||
|
if result_items.len() >= limit || !seen_evidence_ids.insert(evidence.evidence_id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result_items.push(search_result_from_evidence(&evidence));
|
||||||
|
evidence_results.push(evidence);
|
||||||
|
}
|
||||||
|
if let Some(map) = result.as_object_mut() {
|
||||||
|
map.insert("results".into(), Value::Array(result_items));
|
||||||
|
}
|
||||||
|
(result, evidence_results)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_result_from_evidence(evidence: &EvidenceSearchResult) -> Value {
|
||||||
|
let source = &evidence.source;
|
||||||
|
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
|
||||||
|
let resource_path = source
|
||||||
|
.resource_path
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(source.owner_document_path.as_str());
|
||||||
|
let title = std::path::Path::new(resource_path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or(resource_path)
|
||||||
|
.to_string();
|
||||||
|
let resource_type = match source.resource_kind {
|
||||||
|
core_protocol::EvidenceResourceKind::Markdown => "markdown",
|
||||||
|
core_protocol::EvidenceResourceKind::Pdf => "pdf",
|
||||||
|
core_protocol::EvidenceResourceKind::Image => "image",
|
||||||
|
core_protocol::EvidenceResourceKind::Office => "office",
|
||||||
|
core_protocol::EvidenceResourceKind::Mindmap => "mindmap",
|
||||||
|
core_protocol::EvidenceResourceKind::RawFile => "resource",
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"id": format!("evidence:{}", evidence.evidence_id),
|
||||||
|
"documentId": source.owner_document_id,
|
||||||
|
"title": title,
|
||||||
|
"path": source.owner_document_path,
|
||||||
|
"resourceType": resource_type,
|
||||||
|
"sourceKind": "local_folder",
|
||||||
|
"rootUri": source.root_uri,
|
||||||
|
"snippet": evidence.quote,
|
||||||
|
"score": evidence.score,
|
||||||
|
"publicPath": source.open_action.url,
|
||||||
|
"evidence": evidence_value,
|
||||||
|
"source": {
|
||||||
|
"locator": source
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn attach_evidence_to_search_results(
|
fn attach_evidence_to_search_results(
|
||||||
results: Value,
|
results: Value,
|
||||||
evidence_results: &[EvidenceSearchResult],
|
evidence_results: &[EvidenceSearchResult],
|
||||||
@@ -463,6 +558,9 @@ fn attach_evidence_to_search_results(
|
|||||||
};
|
};
|
||||||
let mut item = item;
|
let mut item = item;
|
||||||
if let Some(map) = item.as_object_mut() {
|
if let Some(map) = item.as_object_mut() {
|
||||||
|
if map.get("evidence").is_some() {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
|
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
|
||||||
map.insert("evidence".into(), evidence_value);
|
map.insert("evidence".into(), evidence_value);
|
||||||
let source = map.entry("source").or_insert_with(|| json!({}));
|
let source = map.entry("source").or_insert_with(|| json!({}));
|
||||||
@@ -793,6 +891,7 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::app::{build_app, AppConfig, AppState};
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
|
use crate::routes::local_search_index;
|
||||||
use axum::body::{to_bytes, Body};
|
use axum::body::{to_bytes, Body};
|
||||||
use axum::http::{Request, StatusCode};
|
use axum::http::{Request, StatusCode};
|
||||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||||
@@ -998,6 +1097,15 @@ mod tests {
|
|||||||
.expect("readme");
|
.expect("readme");
|
||||||
fs::write(root.join("docs").join("child.md"), "# Child\nalpha child\n").expect("child");
|
fs::write(root.join("docs").join("child.md"), "# Child\nalpha child\n").expect("child");
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
local_search_index::write_local_index_settings(
|
||||||
|
&root,
|
||||||
|
&[String::from(".")],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("settings");
|
||||||
|
|
||||||
let response = app()
|
let response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -1102,6 +1210,104 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_documents_local_folder_includes_evidence_sqlite_body_hits() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"mnote-local-search-evidence-route-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||||
|
fs::write(
|
||||||
|
root.join(".mnote").join("workspace.json"),
|
||||||
|
r#"{"workspaceId":"local-ws-search-evidence","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
|
||||||
|
)
|
||||||
|
.expect("manifest");
|
||||||
|
fs::write(
|
||||||
|
root.join("README.md"),
|
||||||
|
"# Search Home\nBodyOnlyEvidenceToken only exists inside evidence.sqlite after refresh.\n",
|
||||||
|
)
|
||||||
|
.expect("readme");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
local_search_index::write_local_index_settings(
|
||||||
|
&root,
|
||||||
|
&[String::from(".")],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("settings");
|
||||||
|
local_search_index::refresh_local_search_index(
|
||||||
|
&root,
|
||||||
|
&root_uri,
|
||||||
|
"local-ws-search-evidence",
|
||||||
|
)
|
||||||
|
.expect("refresh");
|
||||||
|
fs::write(
|
||||||
|
root.join(".mnote").join("index").join("search-index.json"),
|
||||||
|
serde_json::to_string_pretty(&json!({
|
||||||
|
"version": 1,
|
||||||
|
"builtAt": 1,
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"workspaceId": "local-ws-search-evidence",
|
||||||
|
"documents": [],
|
||||||
|
"resources": []
|
||||||
|
}))
|
||||||
|
.expect("stale search index"),
|
||||||
|
)
|
||||||
|
.expect("overwrite search index");
|
||||||
|
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/search/documents")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"workspaceId": "local-ws-search-evidence",
|
||||||
|
"sourceKind": "local_folder",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"query": "BodyOnlyEvidenceToken",
|
||||||
|
"limit": 10,
|
||||||
|
"filters": {
|
||||||
|
"titleOnly": false,
|
||||||
|
"includeOcr": true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
let results = payload["results"].as_array().expect("results");
|
||||||
|
let hit = results
|
||||||
|
.iter()
|
||||||
|
.find(|item| {
|
||||||
|
item["snippet"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("BodyOnlyEvidenceToken")
|
||||||
|
})
|
||||||
|
.expect("evidence sqlite body hit should be promoted to search result");
|
||||||
|
assert_eq!(hit["resourceType"].as_str(), Some("markdown"));
|
||||||
|
assert_eq!(
|
||||||
|
hit["evidence"]["source"]["schema"].as_str(),
|
||||||
|
Some("mnote.evidence_locator.v1")
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_local_index_refresh_rebuilds_authorized_root() {
|
async fn search_local_index_refresh_rebuilds_authorized_root() {
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
@@ -1156,6 +1362,102 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_index_settings_empty_scope_deletes_index_files() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"mnote-local-search-settings-delete-route-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||||
|
fs::create_dir_all(root.join("docs")).expect("docs");
|
||||||
|
fs::write(
|
||||||
|
root.join(".mnote").join("workspace.json"),
|
||||||
|
r#"{"workspaceId":"local-ws-settings-delete","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files","search"]}"#,
|
||||||
|
)
|
||||||
|
.expect("manifest");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("keep.md"),
|
||||||
|
"# Keep\nRouteDeleteToken\n",
|
||||||
|
)
|
||||||
|
.expect("doc");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let app = app();
|
||||||
|
|
||||||
|
let create_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri("/api/search/local-index/settings")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"workspaceId": "local-ws-settings-delete",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"includePaths": ["docs"],
|
||||||
|
"scheduleMode": "manual",
|
||||||
|
"scheduleTime": "02:00",
|
||||||
|
"runOnChange": false
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("create settings request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create settings response");
|
||||||
|
assert_eq!(create_response.status(), StatusCode::OK);
|
||||||
|
assert!(root.join(".mnote/index/search-index.json").exists());
|
||||||
|
assert!(root.join(".mnote/index/evidence.sqlite").exists());
|
||||||
|
|
||||||
|
let delete_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri("/api/search/local-index/settings")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-mnote-actor-id", "user_test")
|
||||||
|
.header("x-mnote-actor-type", "user")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({
|
||||||
|
"workspaceId": "local-ws-settings-delete",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"includePaths": [],
|
||||||
|
"scheduleMode": "manual",
|
||||||
|
"scheduleTime": "02:00",
|
||||||
|
"runOnChange": false
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("delete settings request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("delete settings response");
|
||||||
|
assert_eq!(delete_response.status(), StatusCode::OK);
|
||||||
|
let delete_body = to_bytes(delete_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("delete body");
|
||||||
|
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("delete json");
|
||||||
|
assert_eq!(
|
||||||
|
delete_payload["index"]["indexedPaths"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
delete_payload["result"]["settings"]["includePaths"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
assert!(!root.join(".mnote/index/search-index.json").exists());
|
||||||
|
assert!(!root.join(".mnote/index/evidence.sqlite").exists());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn search_local_index_backlinks_and_tags_read_authorized_root() {
|
async fn search_local_index_backlinks_and_tags_read_authorized_root() {
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
@@ -660,6 +660,17 @@ pub(crate) fn collect_filetree_render_rows(
|
|||||||
object_identity: resource_meta
|
object_identity: resource_meta
|
||||||
.and_then(|meta| meta.get("objectIdentity"))
|
.and_then(|meta| meta.get("objectIdentity"))
|
||||||
.and_then(|value| serde_json::to_string(value).ok()),
|
.and_then(|value| serde_json::to_string(value).ok()),
|
||||||
|
index_status: item
|
||||||
|
.get("indexStatus")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| {
|
||||||
|
resource_meta
|
||||||
|
.and_then(|meta| meta.get("indexStatus"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
})
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| *value == "indexed" || *value == "failed")
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
selected,
|
selected,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -986,6 +986,14 @@ pub struct OfficePreviewQuery {
|
|||||||
source_kind: Option<String>,
|
source_kind: Option<String>,
|
||||||
root_uri: Option<String>,
|
root_uri: Option<String>,
|
||||||
document_id: Option<String>,
|
document_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
page: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
bbox: Option<String>,
|
||||||
|
#[serde(default, alias = "sourceMapPath")]
|
||||||
|
source_map_path: Option<String>,
|
||||||
|
#[serde(default, alias = "blockId")]
|
||||||
|
block_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Response {
|
pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Response {
|
||||||
@@ -1013,6 +1021,13 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
|||||||
let source_kind = query.source_kind.unwrap_or_default();
|
let source_kind = query.source_kind.unwrap_or_default();
|
||||||
let root_uri = query.root_uri.unwrap_or_default();
|
let root_uri = query.root_uri.unwrap_or_default();
|
||||||
let document_id = query.document_id.unwrap_or_default();
|
let document_id = query.document_id.unwrap_or_default();
|
||||||
|
let target_page = query
|
||||||
|
.page
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let target_bbox = query.bbox.unwrap_or_default();
|
||||||
|
let target_source_map_path = query.source_map_path.unwrap_or_default();
|
||||||
|
let target_block_id = query.block_id.unwrap_or_default();
|
||||||
let html = format!(
|
let html = format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@@ -1047,6 +1062,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
|||||||
.mnote-office-viewer .mnote-docx-wrapper {{ width: 100%; background: transparent !important; padding: 0 !important; align-items: stretch !important; }}
|
.mnote-office-viewer .mnote-docx-wrapper {{ width: 100%; background: transparent !important; padding: 0 !important; align-items: stretch !important; }}
|
||||||
.mnote-office-viewer .docx-wrapper > section.docx,
|
.mnote-office-viewer .docx-wrapper > section.docx,
|
||||||
.mnote-office-viewer .mnote-docx-wrapper > section.mnote-docx {{ width: 100% !important; max-width: none !important; margin: 0 0 14px !important; box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
|
.mnote-office-viewer .mnote-docx-wrapper > section.mnote-docx {{ width: 100% !important; max-width: none !important; margin: 0 0 14px !important; box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
|
||||||
|
.mnote-office-viewer {{ position: relative; }}
|
||||||
|
.mnote-office-viewer [data-mnote-office-evidence-target="true"] {{ outline: 0; border-radius: 2px; background: #FFE9E6; color: #D83A32; box-shadow: 0 0 0 1px rgba(216, 58, 50, .22); }}
|
||||||
|
.mnote-office-evidence-marker {{ position: absolute; z-index: 3; left: 24px; max-width: min(720px, calc(100% - 48px)); padding: 6px 10px; border: 1px solid rgba(216, 58, 50, .45); border-radius: 6px; background: rgba(255, 249, 248, .96); color: #D83A32; font-size: 13px; line-height: 1.5; box-shadow: 0 2px 10px rgba(15, 23, 42, .12); }}
|
||||||
|
.mnote-office-evidence-marker[data-mnote-office-evidence-marker-mode="range"] {{ pointer-events: none; background: rgba(255, 233, 230, .72); box-shadow: 0 0 0 1px rgba(216, 58, 50, .28); }}
|
||||||
.mnote-office-pptx-stage {{ width: 100%; overflow: auto; }}
|
.mnote-office-pptx-stage {{ width: 100%; overflow: auto; }}
|
||||||
.mnote-office-pptx-stage .pptx-preview-wrapper {{ max-width: 100%; background: transparent !important; }}
|
.mnote-office-pptx-stage .pptx-preview-wrapper {{ max-width: 100%; background: transparent !important; }}
|
||||||
.mnote-office-pptx-stage .pptx-preview-slide-wrapper {{ box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
|
.mnote-office-pptx-stage .pptx-preview-slide-wrapper {{ box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
|
||||||
@@ -1055,7 +1074,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
|||||||
}}
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}">
|
<body data-file-url="{file_url}" data-file-name="{file_name}" data-file-type="{file_type}" data-page-width-content-type="{page_width_content_type}" data-workspace-id="{workspace_id}" data-mnote-source-kind="{source_kind}" data-mnote-root-uri="{root_uri}" data-document-id="{document_id}" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-source-map-path="{target_source_map_path}" data-evidence-block-id="{target_block_id}">
|
||||||
<main class="mnote-office-preview">
|
<main class="mnote-office-preview">
|
||||||
<section class="mnote-office-viewer" id="mnote-office-viewer"></section>
|
<section class="mnote-office-viewer" id="mnote-office-viewer"></section>
|
||||||
</main>
|
</main>
|
||||||
@@ -1070,6 +1089,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
|||||||
const fileName = body.dataset.fileName || '';
|
const fileName = body.dataset.fileName || '';
|
||||||
const fileType = (body.dataset.fileType || '').toLowerCase();
|
const fileType = (body.dataset.fileType || '').toLowerCase();
|
||||||
const pageWidthContentType = body.dataset.pageWidthContentType || 'word';
|
const pageWidthContentType = body.dataset.pageWidthContentType || 'word';
|
||||||
|
let evidencePage = Number(body.dataset.evidencePage || 0);
|
||||||
|
let evidenceBbox = body.dataset.evidenceBbox || '';
|
||||||
|
let evidenceSourceMapPath = body.dataset.evidenceSourceMapPath || '';
|
||||||
|
let evidenceBlockId = body.dataset.evidenceBlockId || '';
|
||||||
let currentPptxBuffer = null;
|
let currentPptxBuffer = null;
|
||||||
let pptxRenderToken = 0;
|
let pptxRenderToken = 0;
|
||||||
let pptxResizeTimer = 0;
|
let pptxResizeTimer = 0;
|
||||||
@@ -1136,6 +1159,299 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
|||||||
viewer.append(message);
|
viewer.append(message);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
function normalizeEvidenceText(value) {{
|
||||||
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function markEvidenceTarget(target) {{
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
|
||||||
|
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-office-evidence-target');
|
||||||
|
}});
|
||||||
|
target.setAttribute('data-mnote-office-evidence-target', 'true');
|
||||||
|
window.setTimeout(() => target.scrollIntoView({{ block: 'center', inline: 'nearest' }}), 0);
|
||||||
|
document.documentElement.setAttribute('data-mnote-office-evidence-applied', 'true');
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function normalizedTextWithRawOffsets(value) {{
|
||||||
|
const raw = String(value || '');
|
||||||
|
let text = '';
|
||||||
|
const offsets = [];
|
||||||
|
let previousWhitespace = true;
|
||||||
|
for (let index = 0; index < raw.length; index += 1) {{
|
||||||
|
const ch = raw[index];
|
||||||
|
if (/\s/.test(ch)) {{
|
||||||
|
if (text && !previousWhitespace) {{
|
||||||
|
text += ' ';
|
||||||
|
offsets.push(index);
|
||||||
|
}}
|
||||||
|
previousWhitespace = true;
|
||||||
|
}} else {{
|
||||||
|
text += ch;
|
||||||
|
offsets.push(index);
|
||||||
|
previousWhitespace = false;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
if (text.endsWith(' ')) {{
|
||||||
|
text = text.slice(0, -1);
|
||||||
|
offsets.pop();
|
||||||
|
}}
|
||||||
|
return {{ text, offsets }};
|
||||||
|
}}
|
||||||
|
|
||||||
|
function compactTextWithRawOffsets(value) {{
|
||||||
|
const raw = String(value || '');
|
||||||
|
let text = '';
|
||||||
|
const offsets = [];
|
||||||
|
for (let index = 0; index < raw.length; index += 1) {{
|
||||||
|
const ch = raw[index];
|
||||||
|
if (/\s/.test(ch)) continue;
|
||||||
|
text += ch;
|
||||||
|
offsets.push(index);
|
||||||
|
}}
|
||||||
|
return {{ text, offsets }};
|
||||||
|
}}
|
||||||
|
|
||||||
|
function wrapEvidenceTextNode(node, needle) {{
|
||||||
|
if (!(node instanceof Text)) return null;
|
||||||
|
const raw = String(node.textContent || '');
|
||||||
|
let start = raw.indexOf(needle);
|
||||||
|
let end = start >= 0 ? start + needle.length : -1;
|
||||||
|
if (start < 0) {{
|
||||||
|
const compact = compactTextWithRawOffsets(raw);
|
||||||
|
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
|
||||||
|
let normalizedStart = compact.text.indexOf(compactNeedle);
|
||||||
|
let sourceOffsets = compact.offsets;
|
||||||
|
if (normalizedStart < 0) {{
|
||||||
|
const mapped = normalizedTextWithRawOffsets(raw);
|
||||||
|
const mappedNeedle = normalizeEvidenceText(needle);
|
||||||
|
normalizedStart = mapped.text.indexOf(mappedNeedle);
|
||||||
|
sourceOffsets = mapped.offsets;
|
||||||
|
if (normalizedStart < 0) return null;
|
||||||
|
start = sourceOffsets[normalizedStart];
|
||||||
|
end = sourceOffsets[normalizedStart + mappedNeedle.length - 1] + 1;
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, start);
|
||||||
|
range.setEnd(node, end);
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.setAttribute('data-mnote-office-evidence-target', 'true');
|
||||||
|
try {{
|
||||||
|
range.surroundContents(span);
|
||||||
|
return span;
|
||||||
|
}} catch (_) {{
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
if (normalizedStart < 0) return null;
|
||||||
|
start = sourceOffsets[normalizedStart];
|
||||||
|
end = sourceOffsets[normalizedStart + compactNeedle.length - 1] + 1;
|
||||||
|
}}
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start) return null;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, start);
|
||||||
|
range.setEnd(node, end);
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.setAttribute('data-mnote-office-evidence-target', 'true');
|
||||||
|
try {{
|
||||||
|
range.surroundContents(span);
|
||||||
|
return span;
|
||||||
|
}} catch (_) {{
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
function markEvidenceRangeAcrossTextNodes(needle) {{
|
||||||
|
if (!viewer) return false;
|
||||||
|
const compactNeedle = normalizeEvidenceText(needle).replace(/\s+/g, '');
|
||||||
|
if (!compactNeedle) return false;
|
||||||
|
const refs = [];
|
||||||
|
let compactText = '';
|
||||||
|
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
|
||||||
|
let node = walker.nextNode();
|
||||||
|
while (node) {{
|
||||||
|
const raw = String(node.textContent || '');
|
||||||
|
for (let offset = 0; offset < raw.length; offset += 1) {{
|
||||||
|
const ch = raw[offset];
|
||||||
|
if (/\s/.test(ch)) continue;
|
||||||
|
compactText += ch;
|
||||||
|
refs.push({{ node, offset }});
|
||||||
|
}}
|
||||||
|
node = walker.nextNode();
|
||||||
|
}}
|
||||||
|
const startIndex = compactText.indexOf(compactNeedle);
|
||||||
|
if (startIndex < 0) return false;
|
||||||
|
const endIndex = startIndex + compactNeedle.length - 1;
|
||||||
|
const startRef = refs[startIndex];
|
||||||
|
const endRef = refs[endIndex];
|
||||||
|
if (!startRef || !endRef) return false;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(startRef.node, startRef.offset);
|
||||||
|
range.setEnd(endRef.node, endRef.offset + 1);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
||||||
|
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
|
||||||
|
if (!(marker instanceof HTMLElement)) {{
|
||||||
|
marker = document.createElement('div');
|
||||||
|
marker.className = 'mnote-office-evidence-marker';
|
||||||
|
marker.setAttribute('data-mnote-office-evidence-marker', 'true');
|
||||||
|
viewer.append(marker);
|
||||||
|
}}
|
||||||
|
const viewerRect = viewer.getBoundingClientRect();
|
||||||
|
marker.textContent = '';
|
||||||
|
marker.setAttribute('data-mnote-office-evidence-target-text', normalizeEvidenceText(needle));
|
||||||
|
marker.setAttribute('data-mnote-office-evidence-marker-mode', 'range');
|
||||||
|
marker.style.left = Math.max(0, Math.round(rect.left - viewerRect.left + viewer.scrollLeft)).toString() + 'px';
|
||||||
|
marker.style.top = Math.max(0, Math.round(rect.top - viewerRect.top + viewer.scrollTop)).toString() + 'px';
|
||||||
|
marker.style.width = Math.max(8, Math.round(rect.width)).toString() + 'px';
|
||||||
|
marker.style.height = Math.max(8, Math.round(rect.height)).toString() + 'px';
|
||||||
|
marker.style.maxWidth = 'none';
|
||||||
|
marker.style.padding = '0';
|
||||||
|
return markEvidenceTarget(marker);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function pageForEvidenceBlock(sourceMap, block) {{
|
||||||
|
if (!sourceMap || typeof sourceMap !== 'object' || !block) return null;
|
||||||
|
const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
|
||||||
|
for (const page of pages) {{
|
||||||
|
const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
|
||||||
|
if (blocks.includes(block)) return page;
|
||||||
|
}}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function findEvidenceBlockInSourceMap(sourceMap) {{
|
||||||
|
if (!sourceMap || typeof sourceMap !== 'object' || !evidenceBlockId) return null;
|
||||||
|
const pages = Array.isArray(sourceMap.pages) ? sourceMap.pages : [];
|
||||||
|
for (const page of pages) {{
|
||||||
|
const blocks = Array.isArray(page && page.blocks) ? page.blocks : [];
|
||||||
|
const block = blocks.find(item => String(item && (item.id || item.blockId || item.block_id) || '') === evidenceBlockId);
|
||||||
|
if (block) return block;
|
||||||
|
}}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function fetchEvidenceSourceMap() {{
|
||||||
|
if (!evidenceSourceMapPath || !body.dataset.mnoteRootUri) return null;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('rootUri', body.dataset.mnoteRootUri);
|
||||||
|
params.set('path', evidenceSourceMapPath);
|
||||||
|
const response = await fetch('/api/local-folder/files/open?' + params.toString(), {{
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {{ accept: 'application/json, text/plain, */*' }}
|
||||||
|
}});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json().catch(() => null);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function scrollToEvidenceText(text) {{
|
||||||
|
const needle = normalizeEvidenceText(text);
|
||||||
|
if (!needle || !viewer) return false;
|
||||||
|
viewer.querySelectorAll('[data-mnote-office-evidence-target="true"]').forEach(node => {{
|
||||||
|
if (node instanceof HTMLElement) {{
|
||||||
|
if (node.tagName === 'SPAN' && node.childNodes.length === 1 && node.firstChild instanceof Text) {{
|
||||||
|
node.replaceWith(node.firstChild);
|
||||||
|
}} else {{
|
||||||
|
node.removeAttribute('data-mnote-office-evidence-target');
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
const walker = document.createTreeWalker(viewer, NodeFilter.SHOW_TEXT);
|
||||||
|
let node = walker.nextNode();
|
||||||
|
while (node) {{
|
||||||
|
if (normalizeEvidenceText(node.textContent).includes(needle)) {{
|
||||||
|
const inlineTarget = wrapEvidenceTextNode(node, needle);
|
||||||
|
if (inlineTarget) return markEvidenceTarget(inlineTarget);
|
||||||
|
const target = node.parentElement && node.parentElement.closest('p, div, span, table, section') || node.parentElement;
|
||||||
|
if (target instanceof HTMLElement) {{
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
if (rect.height > window.innerHeight * 1.8 || normalizeEvidenceText(target.textContent).length > 4000) break;
|
||||||
|
}}
|
||||||
|
return markEvidenceTarget(target);
|
||||||
|
}}
|
||||||
|
node = walker.nextNode();
|
||||||
|
}}
|
||||||
|
if (markEvidenceRangeAcrossTextNodes(needle)) return true;
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function scrollToEvidenceCoordinate(sourceMap, block) {{
|
||||||
|
if (!viewer || !sourceMap || !block) return false;
|
||||||
|
const page = pageForEvidenceBlock(sourceMap, block);
|
||||||
|
const bbox = block.bbox && typeof block.bbox === 'object' ? block.bbox : null;
|
||||||
|
const pageNumber = Number(page && page.page || evidencePage || 0);
|
||||||
|
const pageCount = Math.max(1, Number(sourceMap.pageCount || (Array.isArray(sourceMap.pages) ? sourceMap.pages.length : 0)) || 1);
|
||||||
|
if (!Number.isFinite(pageNumber) || pageNumber <= 0) return false;
|
||||||
|
const renderedPages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'))
|
||||||
|
.filter(node => node instanceof HTMLElement);
|
||||||
|
const pageElement = renderedPages[pageNumber - 1];
|
||||||
|
let top = 0;
|
||||||
|
if (pageElement instanceof HTMLElement) {{
|
||||||
|
const pageHeight = Math.max(1, pageElement.scrollHeight || pageElement.getBoundingClientRect().height || 1);
|
||||||
|
const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || pageHeight);
|
||||||
|
top = pageElement.offsetTop + (bbox ? (Number(bbox.y0) / sourcePageHeight) * pageHeight : pageHeight / 2);
|
||||||
|
}} else {{
|
||||||
|
const contentHeight = Math.max(1, viewer.scrollHeight || document.documentElement.scrollHeight || 1);
|
||||||
|
const estimatedPageHeight = contentHeight / pageCount;
|
||||||
|
const sourcePageHeight = Math.max(1, Number(page && page.height || 0) || estimatedPageHeight);
|
||||||
|
top = estimatedPageHeight * (pageNumber - 1) + (bbox ? (Number(bbox.y0) / sourcePageHeight) * estimatedPageHeight : estimatedPageHeight / 2);
|
||||||
|
}}
|
||||||
|
let marker = viewer.querySelector('[data-mnote-office-evidence-marker="true"]');
|
||||||
|
if (!(marker instanceof HTMLElement)) {{
|
||||||
|
marker = document.createElement('div');
|
||||||
|
marker.className = 'mnote-office-evidence-marker';
|
||||||
|
marker.setAttribute('data-mnote-office-evidence-marker', 'true');
|
||||||
|
viewer.append(marker);
|
||||||
|
}}
|
||||||
|
marker.textContent = normalizeEvidenceText(block.text || evidenceBlockId || '命中位置');
|
||||||
|
marker.removeAttribute('data-mnote-office-evidence-target-text');
|
||||||
|
marker.setAttribute('data-mnote-office-evidence-marker-mode', 'estimated');
|
||||||
|
marker.style.width = '';
|
||||||
|
marker.style.height = '';
|
||||||
|
marker.style.padding = '';
|
||||||
|
marker.style.top = Math.max(0, Math.round(top)).toString() + 'px';
|
||||||
|
return markEvidenceTarget(marker);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function scrollToEvidencePageFallback() {{
|
||||||
|
if (!viewer || !Number.isFinite(evidencePage) || evidencePage <= 0) return false;
|
||||||
|
const pages = Array.from(viewer.querySelectorAll('section.docx, section.mnote-docx, .pptx-preview-slide-wrapper'));
|
||||||
|
const target = pages[Math.max(0, Math.min(pages.length - 1, evidencePage - 1))];
|
||||||
|
return markEvidenceTarget(target);
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function applyEvidenceLocator() {{
|
||||||
|
if (!viewer || (!evidencePage && !evidenceBlockId && !evidenceBbox)) return;
|
||||||
|
try {{
|
||||||
|
const sourceMap = await fetchEvidenceSourceMap();
|
||||||
|
const block = findEvidenceBlockInSourceMap(sourceMap);
|
||||||
|
if (block && scrollToEvidenceText(block.text)) return;
|
||||||
|
if (scrollToEvidenceCoordinate(sourceMap, block)) return;
|
||||||
|
}} catch (_) {{}}
|
||||||
|
scrollToEvidencePageFallback();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function updateEvidenceLocator(locator) {{
|
||||||
|
const next = locator && typeof locator === 'object' ? locator : {{}};
|
||||||
|
evidencePage = Number(next.page || 0);
|
||||||
|
evidenceBbox = String(next.bbox || '');
|
||||||
|
evidenceSourceMapPath = String(next.sourceMapPath || '');
|
||||||
|
evidenceBlockId = String(next.blockId || '');
|
||||||
|
body.dataset.evidencePage = evidencePage ? String(evidencePage) : '';
|
||||||
|
body.dataset.evidenceBbox = evidenceBbox;
|
||||||
|
body.dataset.evidenceSourceMapPath = evidenceSourceMapPath;
|
||||||
|
body.dataset.evidenceBlockId = evidenceBlockId;
|
||||||
|
document.documentElement.removeAttribute('data-mnote-office-evidence-applied');
|
||||||
|
void applyEvidenceLocator();
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {{
|
||||||
|
if (event.origin !== window.location.origin) return;
|
||||||
|
const data = event.data && typeof event.data === 'object' ? event.data : {{}};
|
||||||
|
if (data.type === 'mnote:office-evidence-locator') updateEvidenceLocator(data);
|
||||||
|
}});
|
||||||
|
|
||||||
async function fetchArrayBuffer() {{
|
async function fetchArrayBuffer() {{
|
||||||
const response = await fetch(fileUrl, {{ credentials: fileUrl.startsWith('/') ? 'same-origin' : 'include' }});
|
const response = await fetch(fileUrl, {{ credentials: fileUrl.startsWith('/') ? 'same-origin' : 'include' }});
|
||||||
if (!response.ok) throw new Error(fileReadErrorMessage(response.status));
|
if (!response.ok) throw new Error(fileReadErrorMessage(response.status));
|
||||||
@@ -1321,6 +1637,7 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
|||||||
else if (fileType === 'csv') await renderCsv();
|
else if (fileType === 'csv') await renderCsv();
|
||||||
else if (fileType === 'pptx') await renderPptx();
|
else if (fileType === 'pptx') await renderPptx();
|
||||||
else showMessage('当前轻量预览 POC 暂不支持 .' + fileType + ',请用 OnlyOffice 打开。');
|
else showMessage('当前轻量预览 POC 暂不支持 .' + fileType + ',请用 OnlyOffice 打开。');
|
||||||
|
await applyEvidenceLocator();
|
||||||
setStatus('完成');
|
setStatus('完成');
|
||||||
}} catch (error) {{
|
}} catch (error) {{
|
||||||
console.warn('[mnote office preview] render failed', error);
|
console.warn('[mnote office preview] render failed', error);
|
||||||
@@ -1342,6 +1659,10 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
|||||||
source_kind = escape_html(&source_kind),
|
source_kind = escape_html(&source_kind),
|
||||||
root_uri = escape_html(&root_uri),
|
root_uri = escape_html(&root_uri),
|
||||||
document_id = escape_html(&document_id),
|
document_id = escape_html(&document_id),
|
||||||
|
target_page = escape_html(&target_page),
|
||||||
|
target_bbox = escape_html(&target_bbox),
|
||||||
|
target_source_map_path = escape_html(&target_source_map_path),
|
||||||
|
target_block_id = escape_html(&target_block_id),
|
||||||
);
|
);
|
||||||
let mut response = Html(html).into_response();
|
let mut response = Html(html).into_response();
|
||||||
stamp_shell_headers(response.headers_mut(), "office-preview");
|
stamp_shell_headers(response.headers_mut(), "office-preview");
|
||||||
@@ -3310,6 +3631,12 @@ mod tests {
|
|||||||
let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS;
|
let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS;
|
||||||
assert!(runtime.contains("document-resource-tab-runtime.js"));
|
assert!(runtime.contains("document-resource-tab-runtime.js"));
|
||||||
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
|
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
|
||||||
|
assert!(resource_runtime.contains("后台任务"));
|
||||||
|
assert!(resource_runtime.contains("data-mnote-local-ocr-task-tab"));
|
||||||
|
assert!(resource_runtime.contains("data-mnote-local-ocr-task-clear-completed"));
|
||||||
|
assert!(resource_runtime.contains("role=\"progressbar\""));
|
||||||
|
assert!(resource_runtime.contains("localOcrTaskCategory(job)"));
|
||||||
|
assert!(resource_runtime.contains("localOcrTaskProgress(job)"));
|
||||||
assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
|
assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
|
||||||
assert!(runtime.contains("getOpenEditorsSnapshot"));
|
assert!(runtime.contains("getOpenEditorsSnapshot"));
|
||||||
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
|
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
|
||||||
@@ -3361,6 +3688,8 @@ mod tests {
|
|||||||
assert!(resource_runtime.contains("data-mnote-evidence-bbox"));
|
assert!(resource_runtime.contains("data-mnote-evidence-bbox"));
|
||||||
assert!(resource_runtime.contains("data-mnote-evidence-text-highlight"));
|
assert!(resource_runtime.contains("data-mnote-evidence-text-highlight"));
|
||||||
assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl"));
|
assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl"));
|
||||||
|
assert!(runtime.contains("sourceMapPath: String(url.searchParams.get('sourceMapPath')"));
|
||||||
|
assert!(runtime.contains("blockId: String(url.searchParams.get('blockId')"));
|
||||||
let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS;
|
let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS;
|
||||||
assert!(sidebar_runtime.contains("openEvidenceSearchResult"));
|
assert!(sidebar_runtime.contains("openEvidenceSearchResult"));
|
||||||
assert!(sidebar_runtime.contains("data-evidence-locator"));
|
assert!(sidebar_runtime.contains("data-evidence-locator"));
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ pub fn PageLayout(
|
|||||||
{children()}
|
{children()}
|
||||||
</article>
|
</article>
|
||||||
<div class="wolai-floating-actions" aria-label="浮动操作">
|
<div class="wolai-floating-actions" aria-label="浮动操作">
|
||||||
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button wolai-floating-button--help" aria-label="帮助"><span class="material-symbols-outlined" data-icon="help" aria-hidden="true"></span></button>
|
<button type="button" data-testid="mnote-floating-task-toggle" class="wolai-floating-button wolai-floating-button--tasks mnote-local-ocr-task-toggle" title="后台任务" aria-label="后台任务" data-mnote-action="toggle-ocr-tasks"><span class="material-symbols-outlined" data-icon="pending_actions" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
|
||||||
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,6 +232,8 @@ mod tests {
|
|||||||
include_str!("../../../browser/sidebar-page-tree-runtime.js");
|
include_str!("../../../browser/sidebar-page-tree-runtime.js");
|
||||||
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
|
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
|
||||||
include_str!("../../../browser/sidebar-page-ai-runtime.js");
|
include_str!("../../../browser/sidebar-page-ai-runtime.js");
|
||||||
|
const SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS: &str =
|
||||||
|
include_str!("../../../browser/sidebar-page-ai-markdown-runtime.js");
|
||||||
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
|
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
|
||||||
include_str!("../../../browser/sidebar-page-ai-render-runtime.js");
|
include_str!("../../../browser/sidebar-page-ai-render-runtime.js");
|
||||||
const SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS: &str =
|
const SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS: &str =
|
||||||
@@ -499,6 +501,9 @@ mod tests {
|
|||||||
assert!(html.contains(r#"data-icon="manage_search""#));
|
assert!(html.contains(r#"data-icon="manage_search""#));
|
||||||
assert!(html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
|
assert!(html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
|
||||||
assert!(html.contains(r#"data-mnote-action="open-ocr-settings""#));
|
assert!(html.contains(r#"data-mnote-action="open-ocr-settings""#));
|
||||||
|
assert!(html.contains(r#"data-testid="mnote-floating-task-toggle""#));
|
||||||
|
assert!(html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
|
||||||
|
assert!(html.contains(r#"data-icon="pending_actions""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -515,12 +520,30 @@ mod tests {
|
|||||||
"新增索引范围必须保留空白输入行,不能先过滤成默认 ."
|
"新增索引范围必须保留空白输入行,不能先过滤成默认 ."
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("if (!values.length) values = [''];"),
|
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-local-index-persisted=\"true\""),
|
||||||
"删除最后一个索引范围时 UI 应显示空行,不能强制回填 ."
|
"已保存索引范围必须锁定为不可直接修改"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
||||||
|
.contains("return currentLocalIndexRangeValues(popover).map"),
|
||||||
|
"删除最后一个索引范围后保存应提交空数组,不能强制回填 ."
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
||||||
|
.contains("var savedIncludePaths = Array.isArray(settings.includePaths) ? settings.includePaths : [];"),
|
||||||
|
"索引面板默认不应把空配置回填成工作区根目录"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains(
|
||||||
|
"var includePaths = savedIncludePaths.length ? savedIncludePaths : indexedPaths;"
|
||||||
|
),
|
||||||
|
"无用户配置但已有索引缓存时仍应展示旧索引范围,便于删除"
|
||||||
);
|
);
|
||||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
|
||||||
.contains("data-local-ocr-settings-action=\"run-active\""));
|
.contains("data-local-ocr-settings-action=\"run-active\""));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:local-ocr-settings-action"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:local-ocr-settings-action"));
|
||||||
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"toggle-ocr-tasks\"]"));
|
||||||
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("detail: { action: 'tasks' }"));
|
||||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\""));
|
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\""));
|
||||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("本地索引仅在本地工作区页面可用"));
|
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("本地索引仅在本地工作区页面可用"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -617,6 +640,11 @@ mod tests {
|
|||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessions"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessions"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/hermes/client/sessions?"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/hermes/client/sessions?"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("params.set('source', 'acp')"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("params.set('source', 'acp')"));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains(
|
||||||
|
"var draftSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter"
|
||||||
|
));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
|
||||||
|
.contains("pageAiDedupeSessions(backendSessions.concat(draftSessions))"));
|
||||||
assert!(
|
assert!(
|
||||||
SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")
|
SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")
|
||||||
);
|
);
|
||||||
@@ -625,6 +653,9 @@ mod tests {
|
|||||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete"));
|
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-resume"));
|
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-resume"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-search"));
|
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-search"));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderThoughtGroup"));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-thought-card"));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-collapse-card"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiUsageSummary"));
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiUsageSummary"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("usage.updated"));
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("usage.updated"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("thought.delta"));
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("thought.delta"));
|
||||||
@@ -664,9 +695,11 @@ mod tests {
|
|||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSessionStorageLabel"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSessionStorageLabel"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_private"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_private"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_shared"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_shared"));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sqlite_control_plane"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("convex_acp_runtime_store"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("convex_acp_runtime_store"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("本地私有"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("本地私有"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("共享会话"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("共享会话"));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("账号会话"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("云端会话"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("云端会话"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sessionStorage:"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sessionStorage:"));
|
||||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("permissionLevel:"));
|
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("permissionLevel:"));
|
||||||
@@ -688,6 +721,10 @@ mod tests {
|
|||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiClick"));
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiClick"));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiOpenCitationUrl"));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("a[data-page-ai-citation-link=\"true\"]"));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("data-page-ai-citation-link=\"true\""));
|
||||||
|
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("target=\"_blank\""));
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange"));
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
|
||||||
@@ -722,7 +759,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() {
|
fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() {
|
||||||
assert!(
|
assert!(
|
||||||
!SIDEBAR_TREE_RUNTIME_JS.contains("rememberCloudWorkspaceId(resolveWorkspaceId(document.body))"),
|
!SIDEBAR_TREE_RUNTIME_JS
|
||||||
|
.contains("rememberCloudWorkspaceId(resolveWorkspaceId(document.body))"),
|
||||||
"进入本地文件夹前不能用 document.body 推导 workspaceId;它会在无 workspaceId URL 时回退成 default"
|
"进入本地文件夹前不能用 document.body 推导 workspaceId;它会在无 workspaceId URL 时回退成 default"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -881,7 +919,8 @@ mod tests {
|
|||||||
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("uploadFilesWithResolvedTarget"));
|
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("uploadFilesWithResolvedTarget"));
|
||||||
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("__mnoteLastEditorUploadRoot"));
|
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("__mnoteLastEditorUploadRoot"));
|
||||||
assert!(
|
assert!(
|
||||||
LOCAL_UPLOAD_RUNTIME_JS.contains("var imageUrl = localOpenUrl || fallbackUrl || markdownHref;"),
|
LOCAL_UPLOAD_RUNTIME_JS
|
||||||
|
.contains("var imageUrl = localOpenUrl || fallbackUrl || markdownHref;"),
|
||||||
"图片上传首屏必须使用本地 open URL 渲染,避免相对 href 在 /documents 下命中退役 Convex 兼容路径"
|
"图片上传首屏必须使用本地 open URL 渲染,避免相对 href 在 /documents 下命中退役 Convex 兼容路径"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1530,8 +1569,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
.contains("fileTreeViewState.selectedRowIds.add(rowId)"));
|
.contains("fileTreeViewState.selectedRowIds.add(rowId)"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
|
||||||
.contains("row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)))"));
|
"row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)))"
|
||||||
|
));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains(
|
||||||
"row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId))"
|
"row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId))"
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1732,7 +1732,8 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 0 0 20px;
|
flex: 0 0 20px;
|
||||||
color: #9A958F;
|
color: #9A958F;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tree .tree-kind-badge::before {
|
.sidebar-tree .tree-kind-badge::before {
|
||||||
@@ -1745,6 +1746,32 @@ body {
|
|||||||
mask: var(--mnote-filetree-icon-mask) center / contain no-repeat;
|
mask: var(--mnote-filetree-icon-mask) center / contain no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-tree .tree-row[data-index-status="indexed"] .tree-kind-badge::after,
|
||||||
|
.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after {
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
bottom: 1px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 0 1px #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree .tree-row[data-index-status="indexed"] .tree-kind-badge::after {
|
||||||
|
content: "✓";
|
||||||
|
background: #38B86E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after {
|
||||||
|
content: "x";
|
||||||
|
background: #D94841;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-tree .tree-kind-badge[data-kind="page"]::before {
|
.sidebar-tree .tree-kind-badge[data-kind="page"]::before {
|
||||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-file);
|
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-file);
|
||||||
}
|
}
|
||||||
@@ -2783,9 +2810,10 @@ body {
|
|||||||
|
|
||||||
.mnote-local-ocr-task-dock {
|
.mnote-local-ocr-task-dock {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 16px;
|
top: 12px;
|
||||||
top: 48px;
|
right: 12px;
|
||||||
z-index: 90;
|
bottom: 12px;
|
||||||
|
z-index: 89;
|
||||||
color: #37352f;
|
color: #37352f;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -2813,30 +2841,61 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-drawer {
|
.mnote-local-ocr-task-drawer {
|
||||||
width: min(360px, calc(100vw - 36px));
|
width: min(440px, calc(100vw - 24px));
|
||||||
max-height: min(420px, calc(100vh - 120px));
|
height: 100%;
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid rgba(55, 53, 47, 0.14);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #FFF;
|
|
||||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
|
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-drawer[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: -18px 0 42px rgba(27, 28, 28, 0.14);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-head {
|
.mnote-local-ocr-task-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
}
|
||||||
border-bottom: 1px solid rgba(55, 53, 47, 0.1);
|
|
||||||
|
.mnote-local-ocr-task-head > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-head strong {
|
||||||
|
display: block;
|
||||||
|
color: #1B1C1C;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-head span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-close {
|
.mnote-local-ocr-task-close {
|
||||||
width: 24px;
|
flex: 0 0 auto;
|
||||||
height: 24px;
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #787774;
|
color: #787774;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -2847,55 +2906,208 @@ body {
|
|||||||
color: #37352f;
|
color: #37352f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-row {
|
.mnote-local-ocr-task-tabs {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
justify-content: space-between;
|
gap: 4px;
|
||||||
gap: 12px;
|
padding: 3px;
|
||||||
padding: 10px 12px;
|
border-radius: 8px;
|
||||||
border-bottom: 1px solid rgba(55, 53, 47, 0.08);
|
background: #F4F3F2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-main {
|
.mnote-local-ocr-task-tabs button {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
.mnote-local-ocr-task-main strong,
|
border-radius: 6px;
|
||||||
.mnote-local-ocr-task-main span {
|
background: transparent;
|
||||||
display: block;
|
color: #5A5A5A;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-main span,
|
.mnote-local-ocr-task-tabs button[aria-selected="true"] {
|
||||||
.mnote-local-ocr-task-empty {
|
background: #FFF;
|
||||||
|
color: #1B1C1C;
|
||||||
|
box-shadow: 0 1px 4px rgba(27, 28, 28, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-toolbar span {
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-toolbar button,
|
||||||
|
.mnote-local-ocr-task-actions button {
|
||||||
|
min-height: 28px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #FFF;
|
||||||
|
color: #1B1C1C;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-toolbar button:disabled {
|
||||||
|
color: #AAA6A0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-list {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-title-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-main strong {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #1B1C1C;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-main em {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #F0EFED;
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-row[data-mnote-local-ocr-task-category="attention"] .mnote-local-ocr-task-main em {
|
||||||
|
background: #FEE2E2;
|
||||||
|
color: #B3261E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-row[data-mnote-local-ocr-task-category="active"] .mnote-local-ocr-task-main em {
|
||||||
|
background: #DBEAFE;
|
||||||
|
color: #1D4ED8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-main span {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
color: #787774;
|
color: #787774;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 17px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-progress {
|
||||||
|
position: relative;
|
||||||
|
height: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ECE9E4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-progress i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #5B8DEF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-progress[data-progress-mode="indeterminate"] i {
|
||||||
|
width: 40%;
|
||||||
|
animation: mnote-task-progress-slide 1.15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mnote-task-progress-slide {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(260%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-empty {
|
.mnote-local-ocr-task-empty {
|
||||||
padding: 12px;
|
padding: 24px 12px;
|
||||||
|
border: 1px dashed rgba(27, 28, 28, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #787774;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-actions {
|
.mnote-local-ocr-task-actions {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-actions button {
|
.mnote-local-ocr-task-actions button {
|
||||||
height: 26px;
|
padding: 0 8px;
|
||||||
border: 1px solid rgba(55, 53, 47, 0.14);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #FFF;
|
|
||||||
color: #37352f;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mnote-local-ocr-task-actions button[data-mnote-local-ocr-task-delete] {
|
.mnote-local-ocr-task-actions button[data-mnote-local-ocr-task-delete] {
|
||||||
color: #b3261e;
|
color: #b3261e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.mnote-local-ocr-task-dock {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-drawer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mnote-local-ocr-task-actions {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mnote-resource-tab-frame,
|
.mnote-resource-tab-frame,
|
||||||
.mnote-resource-tab-image,
|
.mnote-resource-tab-image,
|
||||||
.mnote-resource-tab-text-shell {
|
.mnote-resource-tab-text-shell {
|
||||||
@@ -3481,7 +3693,7 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-floating-button--help {
|
.wolai-floating-button--tasks {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 1px solid rgba(27, 28, 28, 0.1);
|
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||||
@@ -3767,6 +3979,12 @@ body {
|
|||||||
color: #A19D97;
|
color: #A19D97;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-root-hint {
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.wolai-page-settings-index-remove,
|
.wolai-page-settings-index-remove,
|
||||||
.wolai-page-settings-index-add {
|
.wolai-page-settings-index-add {
|
||||||
border: 1px solid #D8D4CE;
|
border: 1px solid #D8D4CE;
|
||||||
@@ -4597,13 +4815,14 @@ body {
|
|||||||
|
|
||||||
.wolai-page-ai-skills-toolbar {
|
.wolai-page-ai-skills-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(124px, 160px) max-content;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-page-ai-skill-filter-toggle {
|
.wolai-page-ai-skill-filter-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -4670,10 +4889,10 @@ body {
|
|||||||
.wolai-page-ai-skill-row {
|
.wolai-page-ai-skill-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 6px 8px;
|
padding: 8px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -4687,20 +4906,21 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-page-ai-skill-main {
|
.wolai-page-ai-skill-main {
|
||||||
display: flex;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
gap: 2px;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-page-ai-skill-name {
|
.wolai-page-ai-skill-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
text-overflow: ellipsis;
|
text-overflow: clip;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-page-ai-skill-desc {
|
.wolai-page-ai-skill-desc {
|
||||||
@@ -4708,18 +4928,22 @@ body {
|
|||||||
color: #5A5A5A;
|
color: #5A5A5A;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 15px;
|
line-height: 15px;
|
||||||
text-overflow: ellipsis;
|
display: -webkit-box;
|
||||||
white-space: nowrap;
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-page-ai-skill-source {
|
.wolai-page-ai-skill-source {
|
||||||
flex: 0 0 auto;
|
padding: 0;
|
||||||
padding: 1px 5px;
|
border-radius: 0;
|
||||||
border-radius: 999px;
|
background: transparent;
|
||||||
background: #F0EFED;
|
|
||||||
color: #8B8782;
|
color: #8B8782;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 14px;
|
line-height: 14px;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wolai-page-ai-skill-switch {
|
.wolai-page-ai-skill-switch {
|
||||||
@@ -4753,6 +4977,17 @@ body {
|
|||||||
transform: translateX(14px);
|
transform: translateX(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-skill-readonly-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #F0EFED;
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.wolai-page-ai-message {
|
.wolai-page-ai-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4867,6 +5102,61 @@ button.wolai-page-ai-message-text {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tool-group-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-thought-group-list {
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 2px solid rgba(27, 28, 28, 0.12);
|
||||||
|
color: #6F6B66;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tool-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tool-item-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(92px, auto);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tool-item-head strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1B1C1C;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-ai-tool-item-head span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.wolai-page-ai-footer {
|
.wolai-page-ai-footer {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub struct FileTreeRenderRow {
|
|||||||
pub asset_id: Option<String>,
|
pub asset_id: Option<String>,
|
||||||
pub relative_path: Option<String>,
|
pub relative_path: Option<String>,
|
||||||
pub object_identity: Option<String>,
|
pub object_identity: Option<String>,
|
||||||
|
pub index_status: Option<String>,
|
||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +91,13 @@ fn render_filetree_row(
|
|||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
|
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
|
||||||
|
let index_status_attr = row
|
||||||
|
.index_status
|
||||||
|
.as_deref()
|
||||||
|
.map(|status| format!(r#" data-index-status="{}""#, escape_html(status)))
|
||||||
|
.unwrap_or_default();
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" data-object-identity="{object_identity}"{index_status_attr} data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-local-relative-path="{relative_path}" title="{title}"><span class="tree-link-title" title="{title}">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||||
node_id = escape_html(&row.node_id),
|
node_id = escape_html(&row.node_id),
|
||||||
aria_level = row.depth + 1,
|
aria_level = row.depth + 1,
|
||||||
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||||
@@ -104,6 +110,7 @@ fn render_filetree_row(
|
|||||||
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||||
relative_path = escape_html(row.relative_path.as_deref().unwrap_or_default()),
|
relative_path = escape_html(row.relative_path.as_deref().unwrap_or_default()),
|
||||||
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
|
||||||
|
index_status_attr = index_status_attr,
|
||||||
selected = row.selected,
|
selected = row.selected,
|
||||||
toggle_html = toggle_html,
|
toggle_html = toggle_html,
|
||||||
icon_kind = escape_html(&row.icon_kind),
|
icon_kind = escape_html(&row.icon_kind),
|
||||||
@@ -182,6 +189,7 @@ mod tests {
|
|||||||
object_identity: Some(
|
object_identity: Some(
|
||||||
r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
|
r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(),
|
||||||
),
|
),
|
||||||
|
index_status: None,
|
||||||
selected: true,
|
selected: true,
|
||||||
},
|
},
|
||||||
FileTreeRenderRow {
|
FileTreeRenderRow {
|
||||||
@@ -200,6 +208,7 @@ mod tests {
|
|||||||
object_identity: Some(
|
object_identity: Some(
|
||||||
r#"{"objectKind":"mindmap","documentId":"page_root","blockId":null,"assetId":"mind_1"}"#.into(),
|
r#"{"objectKind":"mindmap","documentId":"page_root","blockId":null,"assetId":"mind_1"}"#.into(),
|
||||||
),
|
),
|
||||||
|
index_status: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -241,6 +250,7 @@ mod tests {
|
|||||||
asset_id: None,
|
asset_id: None,
|
||||||
relative_path: Some("docs".into()),
|
relative_path: Some("docs".into()),
|
||||||
object_identity: None,
|
object_identity: None,
|
||||||
|
index_status: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
},
|
},
|
||||||
FileTreeRenderRow {
|
FileTreeRenderRow {
|
||||||
@@ -257,6 +267,7 @@ mod tests {
|
|||||||
asset_id: None,
|
asset_id: None,
|
||||||
relative_path: Some("docs/README.md".into()),
|
relative_path: Some("docs/README.md".into()),
|
||||||
object_identity: None,
|
object_identity: None,
|
||||||
|
index_status: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -286,11 +297,13 @@ mod tests {
|
|||||||
asset_id: None,
|
asset_id: None,
|
||||||
relative_path: Some("design/03-rust-web".into()),
|
relative_path: Some("design/03-rust-web".into()),
|
||||||
object_identity: None,
|
object_identity: None,
|
||||||
|
index_status: Some("indexed".into()),
|
||||||
selected: false,
|
selected: false,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(html.contains(r#"data-local-relative-path="design/03-rust-web""#));
|
assert!(html.contains(r#"data-local-relative-path="design/03-rust-web""#));
|
||||||
|
assert!(html.contains(r#"data-index-status="indexed""#));
|
||||||
assert!(html.contains(r#"<button type="button" class="tree-link""#));
|
assert!(html.contains(r#"<button type="button" class="tree-link""#));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ mod tests {
|
|||||||
asset_id: None,
|
asset_id: None,
|
||||||
relative_path: None,
|
relative_path: None,
|
||||||
object_identity: None,
|
object_identity: None,
|
||||||
|
index_status: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
},
|
},
|
||||||
FileTreeRenderRow {
|
FileTreeRenderRow {
|
||||||
@@ -151,6 +152,7 @@ mod tests {
|
|||||||
asset_id: Some("asset_1".into()),
|
asset_id: Some("asset_1".into()),
|
||||||
relative_path: Some("asset_1".into()),
|
relative_path: Some("asset_1".into()),
|
||||||
object_identity: None,
|
object_identity: None,
|
||||||
|
index_status: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mnote-web 生产构建与启动入口。
|
||||||
|
*
|
||||||
|
* 规则:
|
||||||
|
* - 只按已提交的 git HEAD 判断 release 是否已存在,未提交工作区改动不参与判定。
|
||||||
|
* - 新 release 从 0.0.1 开始递增,0.0.9 后是 0.1.0。
|
||||||
|
* - 每个 release 保留独立源码快照和二进制,不覆盖旧 release。
|
||||||
|
* - 默认启动端口是 3003,避免占用开发端口 3000。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const fsp = require("node:fs/promises");
|
||||||
|
const http = require("node:http");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawn, spawnSync, execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, "..");
|
||||||
|
const RELEASE_ROOT = path.join(ROOT, "dist", "releases", "mnote-web");
|
||||||
|
const BUILD_CACHE_DIR = path.join(ROOT, "dist", "prod-build-cache", "mnote-web");
|
||||||
|
const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod");
|
||||||
|
const RUN_STATE_PATH = path.join(RUN_DIR, "process.json");
|
||||||
|
const DEFAULT_PORT = 3003;
|
||||||
|
const DEFAULT_CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
|
||||||
|
|
||||||
|
function runChecked(command, args, options = {}) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: process.env,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: options.stdio || "pipe",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr ? `\n${result.stderr.trim()}` : "";
|
||||||
|
throw new Error(`${command} ${args.join(" ")} failed with code ${result.status}${stderr}`);
|
||||||
|
}
|
||||||
|
return result.stdout || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentGitHead() {
|
||||||
|
return runChecked("git", ["rev-parse", "HEAD"]).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(version) {
|
||||||
|
const match = String(version || "").match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return match.slice(1).map((part) => Number(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersion(a, b) {
|
||||||
|
const av = parseVersion(a);
|
||||||
|
const bv = parseVersion(b);
|
||||||
|
if (!av || !bv) return 0;
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
if (av[i] !== bv[i]) return av[i] - bv[i];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextVersionAfter(version) {
|
||||||
|
if (!version) return "0.0.1";
|
||||||
|
let [major, minor, patch] = parseVersion(version) || [0, 0, 0];
|
||||||
|
patch += 1;
|
||||||
|
if (patch > 9) {
|
||||||
|
patch = 0;
|
||||||
|
minor += 1;
|
||||||
|
}
|
||||||
|
if (minor > 9) {
|
||||||
|
minor = 0;
|
||||||
|
major += 1;
|
||||||
|
}
|
||||||
|
return `${major}.${minor}.${patch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(filePath) {
|
||||||
|
try {
|
||||||
|
await fsp.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listReleases() {
|
||||||
|
if (!(await pathExists(RELEASE_ROOT))) return [];
|
||||||
|
const entries = await fsp.readdir(RELEASE_ROOT, { withFileTypes: true });
|
||||||
|
const releases = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !parseVersion(entry.name)) continue;
|
||||||
|
const releaseDir = path.join(RELEASE_ROOT, entry.name);
|
||||||
|
const metadataPath = path.join(releaseDir, "metadata.json");
|
||||||
|
if (!(await pathExists(metadataPath))) {
|
||||||
|
releases.push({ version: entry.name, releaseDir, metadata: null, usable: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(await fsp.readFile(metadataPath, "utf8"));
|
||||||
|
const binaryPath = path.join(releaseDir, "mnote-web");
|
||||||
|
const sourceDir = path.join(releaseDir, "source");
|
||||||
|
const usable = (await pathExists(binaryPath)) && (await pathExists(sourceDir));
|
||||||
|
releases.push({ version: entry.name, releaseDir, metadata, usable });
|
||||||
|
} catch {
|
||||||
|
releases.push({ version: entry.name, releaseDir, metadata: null, usable: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
releases.sort((a, b) => compareVersion(a.version, b.version));
|
||||||
|
return releases;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findReleaseForHead(head) {
|
||||||
|
const releases = await listReleases();
|
||||||
|
return releases.find((release) => release.usable && release.metadata?.gitHead === head) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextReleaseVersion() {
|
||||||
|
const releases = await listReleases();
|
||||||
|
let version = releases.length > 0 ? releases[releases.length - 1].version : null;
|
||||||
|
let next = nextVersionAfter(version);
|
||||||
|
while (await pathExists(path.join(RELEASE_ROOT, next))) {
|
||||||
|
next = nextVersionAfter(next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pipeGitArchive(head, destination) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const git = spawn("git", ["archive", "--format=tar", head], {
|
||||||
|
cwd: ROOT,
|
||||||
|
stdio: ["ignore", "pipe", "inherit"],
|
||||||
|
});
|
||||||
|
const tar = spawn("tar", ["-x", "-C", destination], {
|
||||||
|
cwd: ROOT,
|
||||||
|
stdio: ["pipe", "inherit", "inherit"],
|
||||||
|
});
|
||||||
|
|
||||||
|
git.stdout.pipe(tar.stdin);
|
||||||
|
|
||||||
|
let gitCode = null;
|
||||||
|
let tarCode = null;
|
||||||
|
const maybeDone = () => {
|
||||||
|
if (gitCode === null || tarCode === null) return;
|
||||||
|
if (gitCode !== 0) {
|
||||||
|
reject(new Error(`git archive failed with code ${gitCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tarCode !== 0) {
|
||||||
|
reject(new Error(`tar extract failed with code ${tarCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
git.on("error", reject);
|
||||||
|
tar.on("error", reject);
|
||||||
|
git.on("close", (code) => {
|
||||||
|
gitCode = code;
|
||||||
|
maybeDone();
|
||||||
|
});
|
||||||
|
tar.on("close", (code) => {
|
||||||
|
tarCode = code;
|
||||||
|
maybeDone();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRelease(head) {
|
||||||
|
const version = await nextReleaseVersion();
|
||||||
|
const releaseDir = path.join(RELEASE_ROOT, version);
|
||||||
|
const sourceDir = path.join(releaseDir, "source");
|
||||||
|
const binaryPath = path.join(releaseDir, "mnote-web");
|
||||||
|
const metadataPath = path.join(releaseDir, "metadata.json");
|
||||||
|
|
||||||
|
await fsp.mkdir(sourceDir, { recursive: true });
|
||||||
|
console.log(`[prod] 创建 release ${version}: ${releaseDir}`);
|
||||||
|
await pipeGitArchive(head, sourceDir);
|
||||||
|
|
||||||
|
const targetDir = path.join(BUILD_CACHE_DIR, "target");
|
||||||
|
await fsp.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
console.log(`[prod] 构建已提交 HEAD ${head.slice(0, 12)} ...`);
|
||||||
|
runChecked(
|
||||||
|
"cargo",
|
||||||
|
["build", "--manifest-path", "rust/Cargo.toml", "-p", "mnote-web", "--bin", "mnote-web", "--release"],
|
||||||
|
{
|
||||||
|
cwd: sourceDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CARGO_TARGET_DIR: targetDir,
|
||||||
|
},
|
||||||
|
stdio: "inherit",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const builtBinary = path.join(targetDir, "release", "mnote-web");
|
||||||
|
await fsp.copyFile(builtBinary, binaryPath);
|
||||||
|
await fsp.chmod(binaryPath, 0o755);
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
schema: "mnote.prod_release.v1",
|
||||||
|
version,
|
||||||
|
gitHead: head,
|
||||||
|
builtAt: new Date().toISOString(),
|
||||||
|
binaryPath,
|
||||||
|
sourceDir,
|
||||||
|
cargoTargetDir: targetDir,
|
||||||
|
};
|
||||||
|
await fsp.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||||
|
return { version, releaseDir, metadata, usable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonIfExists(filePath) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessAlive(pid) {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processCommandLine(pid) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateLanAddress(address) {
|
||||||
|
if (/^10\./.test(address)) return true;
|
||||||
|
if (/^192\.168\./.test(address)) return true;
|
||||||
|
const match = address.match(/^172\.(\d+)\./);
|
||||||
|
if (!match) return false;
|
||||||
|
const second = Number(match[1]);
|
||||||
|
return second >= 16 && second <= 31;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lanAddresses() {
|
||||||
|
const ignoredInterface = /^(docker|br-|veth|virbr|tailscale|zt|lo)/;
|
||||||
|
const addresses = [];
|
||||||
|
for (const [name, entries] of Object.entries(os.networkInterfaces())) {
|
||||||
|
if (ignoredInterface.test(name)) continue;
|
||||||
|
for (const entry of entries || []) {
|
||||||
|
if (entry.family !== "IPv4" || entry.internal) continue;
|
||||||
|
if (!isPrivateLanAddress(entry.address)) continue;
|
||||||
|
addresses.push(entry.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(addresses)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPublicBind(port) {
|
||||||
|
return `${lanAddresses()[0] || "127.0.0.1"}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopProcess(pid) {
|
||||||
|
if (!isProcessAlive(pid)) return;
|
||||||
|
console.log(`[prod] 停止旧进程 pid=${pid}`);
|
||||||
|
process.kill(pid, "SIGTERM");
|
||||||
|
for (let i = 0; i < 40; i += 1) {
|
||||||
|
if (!isProcessAlive(pid)) return;
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
if (isProcessAlive(pid)) {
|
||||||
|
console.log(`[prod] 旧进程未及时退出,发送 SIGKILL pid=${pid}`);
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isManagedMnoteProcess(pid, expectedBinaryPath = "") {
|
||||||
|
const commandLine = processCommandLine(pid);
|
||||||
|
if (!commandLine) return false;
|
||||||
|
if (expectedBinaryPath && commandLine.includes(expectedBinaryPath)) return true;
|
||||||
|
return commandLine.includes(RELEASE_ROOT) || /\bmnote-web\b/.test(commandLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pidsListeningOnPort(port) {
|
||||||
|
const pids = new Set();
|
||||||
|
try {
|
||||||
|
const out = execFileSync("ss", ["-ltnp", `sport = :${port}`], { encoding: "utf8" });
|
||||||
|
for (const match of out.matchAll(/pid=(\d+)/g)) {
|
||||||
|
pids.add(Number(match[1]));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (pids.size > 0) return [...pids];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { encoding: "utf8" });
|
||||||
|
for (const line of out.split(/\r?\n/)) {
|
||||||
|
const pid = Number(line.trim());
|
||||||
|
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return [...pids];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopPreviousProcess(port) {
|
||||||
|
const state = readJsonIfExists(RUN_STATE_PATH);
|
||||||
|
if (state?.pid) {
|
||||||
|
const pid = Number(state.pid);
|
||||||
|
if (isManagedMnoteProcess(pid, state.binaryPath || "")) {
|
||||||
|
await stopProcess(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pid of pidsListeningOnPort(port)) {
|
||||||
|
if (isManagedMnoteProcess(pid)) {
|
||||||
|
await stopProcess(pid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const commandLine = processCommandLine(pid);
|
||||||
|
throw new Error(`端口 ${port} 已被非 mnote-web 进程占用: pid=${pid} ${commandLine}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestLocalAuth(port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = http.get(
|
||||||
|
{
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
path: "/auth",
|
||||||
|
timeout: 1000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume();
|
||||||
|
resolve(res.statusCode && res.statusCode < 500);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
req.on("error", () => resolve(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReady(port, logPath, pid) {
|
||||||
|
for (let i = 0; i < 60; i += 1) {
|
||||||
|
if (!isProcessAlive(pid)) break;
|
||||||
|
if (await requestLocalAuth(port)) return;
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
let logTail = "";
|
||||||
|
try {
|
||||||
|
const content = await fsp.readFile(logPath, "utf8");
|
||||||
|
logTail = content.split(/\r?\n/).slice(-40).join(os.EOL);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error(`mnote-web 启动失败或未就绪,日志:\n${logTail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRelease(release) {
|
||||||
|
const port = Number(process.env.MNOTE_PROD_PORT || DEFAULT_PORT);
|
||||||
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||||
|
throw new Error(`MNOTE_PROD_PORT 非法: ${process.env.MNOTE_PROD_PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(RUN_DIR, { recursive: true });
|
||||||
|
await stopPreviousProcess(port);
|
||||||
|
|
||||||
|
const binaryPath = path.join(release.releaseDir, "mnote-web");
|
||||||
|
const logPath = path.join(release.releaseDir, "mnote-web.log");
|
||||||
|
const logFd = fs.openSync(logPath, "a");
|
||||||
|
const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`;
|
||||||
|
const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port);
|
||||||
|
|
||||||
|
const child = spawn(binaryPath, [], {
|
||||||
|
cwd: release.metadata?.sourceDir || release.releaseDir,
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", logFd, logFd],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MNOTE_WEB_BIND: bind,
|
||||||
|
MNOTE_WEB_PUBLIC_BIND: publicBind,
|
||||||
|
MNOTE_CONTROL_PLANE_DB_PATH:
|
||||||
|
process.env.MNOTE_CONTROL_PLANE_DB_PATH || DEFAULT_CONTROL_PLANE_DB,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
fs.closeSync(logFd);
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
schema: "mnote.prod_process.v1",
|
||||||
|
pid: child.pid,
|
||||||
|
version: release.version,
|
||||||
|
gitHead: release.metadata?.gitHead,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
bind,
|
||||||
|
publicBind,
|
||||||
|
releaseDir: release.releaseDir,
|
||||||
|
binaryPath,
|
||||||
|
logPath,
|
||||||
|
};
|
||||||
|
await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||||
|
await waitForReady(port, logPath, child.pid);
|
||||||
|
|
||||||
|
console.log(`[prod] 已启动 mnote-web release ${release.version}`);
|
||||||
|
console.log(`[prod] pid=${child.pid}`);
|
||||||
|
console.log(`[prod] local URL=http://127.0.0.1:${port}`);
|
||||||
|
for (const address of lanAddresses()) {
|
||||||
|
console.log(`[prod] LAN URL=http://${address}:${port}`);
|
||||||
|
}
|
||||||
|
console.log(`[prod] log=${logPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const head = currentGitHead();
|
||||||
|
await fsp.mkdir(RELEASE_ROOT, { recursive: true });
|
||||||
|
|
||||||
|
let release = await findReleaseForHead(head);
|
||||||
|
if (release) {
|
||||||
|
console.log(`[prod] 当前 HEAD ${head.slice(0, 12)} 已包含在 release ${release.version},跳过 build`);
|
||||||
|
} else {
|
||||||
|
release = await buildRelease(head);
|
||||||
|
}
|
||||||
|
|
||||||
|
await startRelease(release);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`[prod] ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -47,6 +47,8 @@ function isWriteMnoteTool(toolName) {
|
|||||||
'mnote.evidence.search',
|
'mnote.evidence.search',
|
||||||
'mnote.evidence.read',
|
'mnote.evidence.read',
|
||||||
'mnote.evidence.open',
|
'mnote.evidence.open',
|
||||||
|
'mnote.index.status',
|
||||||
|
'mnote.index.refresh',
|
||||||
'mnote.doc.fetch',
|
'mnote.doc.fetch',
|
||||||
'mnote.page.get',
|
'mnote.page.get',
|
||||||
'mnote.block.fetch',
|
'mnote.block.fetch',
|
||||||
@@ -503,6 +505,9 @@ const MNOTE_TOOL_NAMES = [
|
|||||||
'mnote.evidence.search',
|
'mnote.evidence.search',
|
||||||
'mnote.evidence.read',
|
'mnote.evidence.read',
|
||||||
'mnote.evidence.open',
|
'mnote.evidence.open',
|
||||||
|
'mnote.index.status',
|
||||||
|
'mnote.index.refresh',
|
||||||
|
'mnote.index.update_settings',
|
||||||
];
|
];
|
||||||
|
|
||||||
const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
||||||
@@ -513,6 +518,9 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
|
|||||||
mnote_evidence_search: 'mnote.evidence.search',
|
mnote_evidence_search: 'mnote.evidence.search',
|
||||||
mnote_evidence_read: 'mnote.evidence.read',
|
mnote_evidence_read: 'mnote.evidence.read',
|
||||||
mnote_evidence_open: 'mnote.evidence.open',
|
mnote_evidence_open: 'mnote.evidence.open',
|
||||||
|
mnote_index_status: 'mnote.index.status',
|
||||||
|
mnote_index_refresh: 'mnote.index.refresh',
|
||||||
|
mnote_index_update_settings: 'mnote.index.update_settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function callMnoteTool(toolName, args) {
|
async function callMnoteTool(toolName, args) {
|
||||||
@@ -595,7 +603,7 @@ tools.register({
|
|||||||
|
|
||||||
tools.register({
|
tools.register({
|
||||||
name: 'mnote_evidence_search',
|
name: 'mnote_evidence_search',
|
||||||
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。',
|
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。查询语言与资料语言可能不一致时,先在提示层做轻量多语关键词扩展,再用简短关键词检索。',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -654,6 +662,59 @@ tools.register({
|
|||||||
parallelSafe: true,
|
parallelSafe: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tools.register({
|
||||||
|
name: 'mnote_index_status',
|
||||||
|
description: '查看 MNote 本地索引范围、缓存文件状态、文档数和 evidence block 数。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||||
|
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readOnly: true,
|
||||||
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_status, args),
|
||||||
|
parallelSafe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tools.register({
|
||||||
|
name: 'mnote_index_refresh',
|
||||||
|
description: '按当前有效范围重建 MNote 本地搜索/evidence 缓存,不修改 Markdown 正文。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||||
|
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readOnly: true,
|
||||||
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_refresh, args),
|
||||||
|
parallelSafe: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
tools.register({
|
||||||
|
name: 'mnote_index_update_settings',
|
||||||
|
description: '新增或删除 MNote 本地索引范围;includePaths 为空表示删除当前用户范围。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||||||
|
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||||||
|
includePaths: { type: 'array', items: { type: 'string' }, description: 'root 内相对路径列表,空数组表示删除范围' },
|
||||||
|
scheduleMode: { type: 'string', enum: ['manual', 'daily', 'weekly', 'monthly'], description: '刷新计划' },
|
||||||
|
scheduleTime: { type: 'string', description: 'HH:mm' },
|
||||||
|
scheduleDate: { type: 'string', description: '可选日期' },
|
||||||
|
runOnChange: { type: 'boolean', description: '文件变化时是否自动刷新' },
|
||||||
|
dryRun: { type: 'boolean', description: 'true 只返回计划;false 写入设置并刷新索引' },
|
||||||
|
idempotencyKey: { type: 'string', description: '写入幂等键' },
|
||||||
|
},
|
||||||
|
required: ['includePaths', 'dryRun', 'idempotencyKey'],
|
||||||
|
},
|
||||||
|
readOnly: false,
|
||||||
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_update_settings, args),
|
||||||
|
parallelSafe: false,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Session Store ────────────────────────────────────
|
// ── Session Store ────────────────────────────────────
|
||||||
|
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
@@ -693,7 +754,7 @@ onRequest('session/new', async (params) => {
|
|||||||
'<available-skills>',
|
'<available-skills>',
|
||||||
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
|
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
|
||||||
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
|
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
|
||||||
'- mnote-document-evidence — Search local documents and resources with clickable evidence locators.',
|
'- mnote-local-index — Search local documents with clickable evidence locators and manage local index scopes. When the query language may differ from the corpus language, infer likely corpus terms from filenames/titles/domain context, expand 2-6 concise multilingual keywords, and call mnote_evidence_search with the best short keyword queries. Do not assume the answer language from the corpus; answer in the user language and cite only retrieved evidence.',
|
||||||
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
|
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
|
||||||
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
|
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
|
||||||
'</available-skills>',
|
'</available-skills>',
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ async function main() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route("**/api/hermes/client/skills/toggle", async (route) => {
|
await page.route("**/api/hermes/client/skills/toggle", async (route) => {
|
||||||
skillToggleBodies.push(JSON.parse(route.request().postData() || "{}"));
|
skillToggleBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ async function main() {
|
|||||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route("**/api/hermes/client/tools**", async (route) => {
|
await page.route("**/api/hermes/client/tools**", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ async function main() {
|
|||||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||||
sessionBodies.push(JSON.parse(route.request().postData() || "{}"));
|
sessionBodies.push(JSON.parse(route.request().postData() || "{}"));
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ async function main() {
|
|||||||
const title = `TEST-HERMES-AI-smoke-${suffix}`;
|
const title = `TEST-HERMES-AI-smoke-${suffix}`;
|
||||||
const sessionId = `mnote_smoke_${suffix}`;
|
const sessionId = `mnote_smoke_${suffix}`;
|
||||||
const runId = `run_smoke_${suffix}`;
|
const runId = `run_smoke_${suffix}`;
|
||||||
|
let runRequestCount = 0;
|
||||||
let sessionDetailHits = 0;
|
let sessionDetailHits = 0;
|
||||||
const captured = [];
|
const captured = [];
|
||||||
const createdIds = [];
|
const createdIds = [];
|
||||||
@@ -117,6 +118,8 @@ async function main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||||
|
runRequestCount += 1;
|
||||||
|
const currentRunId = `${runId}_${runRequestCount}`;
|
||||||
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
|
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -124,27 +127,47 @@ async function main() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
ok: true,
|
ok: true,
|
||||||
sessionId,
|
sessionId,
|
||||||
runId,
|
runId: currentRunId,
|
||||||
events: [],
|
events: [],
|
||||||
traceId: "trace_smoke",
|
traceId: "trace_smoke",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
|
await page.route("**/api/hermes/client/events/**", async (route) => {
|
||||||
|
const currentRunId = route.request().url().split("/").pop() || runId;
|
||||||
captured.push({ kind: "events", method: route.request().method(), body: "" });
|
captured.push({ kind: "events", method: route.request().method(), body: "" });
|
||||||
|
const evidenceEvents = Array.from({ length: 24 }, (_, index) => {
|
||||||
|
const callId = `call_smoke_evidence_${index}`;
|
||||||
|
return (
|
||||||
|
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.evidence.search", args: { query: `evidence ${index}` } })}\n\n` +
|
||||||
|
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.evidence.search", summary: `证据 ${index}`, auditId: `audit_smoke_evidence_${index}` })}\n\n`
|
||||||
|
);
|
||||||
|
}).join("");
|
||||||
|
if (runRequestCount > 1) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||||
|
body:
|
||||||
|
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "Second " })}\n\n` +
|
||||||
|
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "response" })}\n\n` +
|
||||||
|
`data: ${JSON.stringify({ event: "run.completed", run_id: currentRunId, session_id: sessionId, output: "Second response" })}\n\n`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||||
body:
|
body:
|
||||||
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", args: { includeBody: false } })}\n\n` +
|
evidenceEvents +
|
||||||
`data: ${JSON.stringify({ event: "tool.completed", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", summary: "读取当前页面", auditId: "audit_smoke_page_get" })}\n\n` +
|
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", args: { includeBody: false } })}\n\n` +
|
||||||
`data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", args: { dryRun: true } })}\n\n` +
|
`data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", summary: "读取当前页面", auditId: "audit_smoke_page_get" })}\n\n` +
|
||||||
`data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", code: "permission_denied", error: "写入被拒绝", auditId: "audit_smoke_page_save" })}\n\n` +
|
`data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", args: { dryRun: true } })}\n\n` +
|
||||||
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Smoke " })}\n\n` +
|
`data: ${JSON.stringify({ event: "tool.failed", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", code: "permission_denied", error: "写入被拒绝", auditId: "audit_smoke_page_save" })}\n\n` +
|
||||||
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "response" })}\n\n` +
|
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "Smoke " })}\n\n` +
|
||||||
|
`data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "response" })}\n\n` +
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
event: "run.completed",
|
event: "run.completed",
|
||||||
run_id: runId,
|
run_id: currentRunId,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
output: "Smoke response",
|
output: "Smoke response",
|
||||||
agentAudit: {
|
agentAudit: {
|
||||||
@@ -193,23 +216,75 @@ async function main() {
|
|||||||
const toolCards = await page.$$eval("[data-page-ai-tool-card]", (cards) =>
|
const toolCards = await page.$$eval("[data-page-ai-tool-card]", (cards) =>
|
||||||
cards.map((card) => ({
|
cards.map((card) => ({
|
||||||
id: card.getAttribute("data-page-ai-tool-call-id"),
|
id: card.getAttribute("data-page-ai-tool-call-id"),
|
||||||
|
group: card.getAttribute("data-page-ai-tool-group"),
|
||||||
status: card.getAttribute("data-page-ai-tool-status"),
|
status: card.getAttribute("data-page-ai-tool-status"),
|
||||||
text: card.textContent || "",
|
text: card.textContent || "",
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
assert.equal(toolCards.length, 3, `应渲染 3 张工具卡:${JSON.stringify(toolCards)}`);
|
assert.equal(toolCards.length, 1, `批量工具调用应折叠成 1 张工具组卡:${JSON.stringify(toolCards)}`);
|
||||||
|
assert(toolCards[0].text.includes("调用工具") && toolCards[0].text.includes("27 个"), `工具组摘要应显示调用数量:${JSON.stringify(toolCards)}`);
|
||||||
|
const toolItems = await page.$$eval("[data-page-ai-tool-item]", (items) =>
|
||||||
|
items.map((item) => ({
|
||||||
|
id: item.getAttribute("data-page-ai-tool-call-id"),
|
||||||
|
status: item.getAttribute("data-page-ai-tool-status"),
|
||||||
|
text: item.textContent || "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
assert(toolItems.length >= 27, `工具组展开内容应包含批量工具调用:${JSON.stringify(toolItems)}`);
|
||||||
assert(
|
assert(
|
||||||
toolCards.some((card) => card.status === "completed" && card.text.includes("call_smoke_page_get")),
|
toolItems.some((item) => item.status === "completed" && item.text.includes("call_smoke_page_get")),
|
||||||
`缺少 completed 工具卡:${JSON.stringify(toolCards)}`,
|
`缺少 completed 工具项:${JSON.stringify(toolItems)}`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolCards.some((card) => card.status === "failed" && card.text.includes("permission_denied")),
|
toolItems.some((item) => item.status === "failed" && item.text.includes("permission_denied")),
|
||||||
`缺少 failed 工具卡:${JSON.stringify(toolCards)}`,
|
`缺少 failed 工具项:${JSON.stringify(toolItems)}`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolCards.some((card) => card.status === "completed" && card.text.includes("agent.changed_files") && card.text.includes("README.md")),
|
toolItems.some((item) => item.status === "completed" && item.text.includes("agent.changed_files") && item.text.includes("README.md")),
|
||||||
`缺少 changed files 工具卡:${JSON.stringify(toolCards)}`,
|
`缺少 changed files 工具项:${JSON.stringify(toolItems)}`,
|
||||||
);
|
);
|
||||||
|
const toolDetailsOpenByDefault = await page.$$eval("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details", (details) => details.map((node) => node.open));
|
||||||
|
assert.equal(toolDetailsOpenByDefault.length, 1, "工具调用应共用一个 details 折叠容器");
|
||||||
|
assert(toolDetailsOpenByDefault.every((open) => open === false), "工具调用组默认应折叠");
|
||||||
|
const orderState = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => {
|
||||||
|
const toolGroup = node.querySelector("[data-page-ai-tool-card]");
|
||||||
|
const assistant = Array.from(node.querySelectorAll(".wolai-page-ai-message--assistant")).find((item) =>
|
||||||
|
(item.textContent || "").includes("Smoke response")
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
toolBeforeAssistant: Boolean(toolGroup && assistant && (toolGroup.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_FOLLOWING)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(orderState.toolBeforeAssistant, "工具调用组应显示在 AI 输出结果之前");
|
||||||
|
await page.locator("[data-page-ai-tool-card] summary").first().click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
assert(
|
||||||
|
await page.locator("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details").first().evaluate((node) => node.open),
|
||||||
|
"点击工具组 summary 后应展开详情",
|
||||||
|
);
|
||||||
|
const scrollStateBeforeFollowup = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => {
|
||||||
|
node.scrollTop = 0;
|
||||||
|
return {
|
||||||
|
scrollTop: node.scrollTop,
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
clientHeight: node.clientHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(scrollStateBeforeFollowup.scrollHeight > scrollStateBeforeFollowup.clientHeight, `批量工具调用应撑出滚动区:${JSON.stringify(scrollStateBeforeFollowup)}`);
|
||||||
|
await page.locator("[data-page-ai-input]").fill("继续补充一句", { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Second response"),
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const scrollStateAfterFollowup = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => ({
|
||||||
|
scrollTop: node.scrollTop,
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
clientHeight: node.clientHeight,
|
||||||
|
firstToolOpen: Boolean(node.querySelector("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details")?.open),
|
||||||
|
}));
|
||||||
|
assert(scrollStateAfterFollowup.scrollTop <= 4, `用户已上滚时新输出不应强制贴底:${JSON.stringify(scrollStateAfterFollowup)}`);
|
||||||
|
assert(scrollStateAfterFollowup.firstToolOpen, "重渲染后应保留用户展开的工具调用详情");
|
||||||
|
|
||||||
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");
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async function main() {
|
|||||||
const sidebar = page.getByTestId("wolai-sidebar");
|
const sidebar = page.getByTestId("wolai-sidebar");
|
||||||
const topbar = page.getByTestId("wolai-topbar");
|
const topbar = page.getByTestId("wolai-topbar");
|
||||||
const floatingAi = page.getByTestId("wolai-floating-ai");
|
const floatingAi = page.getByTestId("wolai-floating-ai");
|
||||||
const floatingHelp = page.getByTestId("wolai-floating-help");
|
const floatingHelp = page.getByTestId("mnote-floating-task-toggle");
|
||||||
const sidebarTreeTabs = page.getByTestId("wolai-sidebar-tree-tabs");
|
const sidebarTreeTabs = page.getByTestId("wolai-sidebar-tree-tabs");
|
||||||
const pageTreePanel = page.locator('[data-mnote-sidebar-tree-panel="page"]').first();
|
const pageTreePanel = page.locator('[data-mnote-sidebar-tree-panel="page"]').first();
|
||||||
const fileTreePanel = page.locator('[data-mnote-sidebar-tree-panel="filetree"]').first();
|
const fileTreePanel = page.locator('[data-mnote-sidebar-tree-panel="filetree"]').first();
|
||||||
|
|||||||
@@ -198,6 +198,13 @@ async function main() {
|
|||||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ async function saveScreenshot(page, name) {
|
|||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForCapturedRunCount(captured, minCount, timeoutMs = UI_TIMEOUT_MS) {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
if (captured.filter((item) => item.kind === "run").length >= minCount) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
throw new Error(`captured run 数量不足,期望至少 ${minCount},实际 ${captured.filter((item) => item.kind === "run").length}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
const suffix = Date.now().toString(36);
|
const suffix = Date.now().toString(36);
|
||||||
@@ -189,6 +198,44 @@ async function main() {
|
|||||||
body: JSON.stringify({ ok: true }),
|
body: JSON.stringify({ ok: true }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities/toggle", async (route) => {
|
||||||
|
captured.push({ kind: "capability-toggle", method: route.request().method(), body: route.request().postData() || "" });
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
if (url.pathname.endsWith("/toggle")) {
|
||||||
|
captured.push({ kind: "capability-toggle", method: route.request().method(), body: route.request().postData() || "" });
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
ok: true,
|
||||||
|
runtime: "mnote",
|
||||||
|
categories: [{
|
||||||
|
name: "mnote",
|
||||||
|
title: "MNote AI 能力",
|
||||||
|
capabilities: [
|
||||||
|
{ id: "mnote-current-page", name: "mnote-current-page", title: "当前页读取", description: "读取当前页", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_capability", uiKind: "ai_capability", toolCount: 1, tools: [{ name: "mnote.context.snapshot", enabled: true }] },
|
||||||
|
{ id: "mnote-mindmap", name: "mnote-mindmap", title: "思维导图读写", description: "读取、编辑或从 outline 生成思维导图", enabled: true, source: "mnote", origin: "builtin", builtin: true, configurable: true, configScope: "user_sqlite", skillKind: "mnote_capability", uiKind: "ai_capability", toolNames: ["mnote.mindmap.fetch", "mnote.mindmap.create_from_outline"], toolCount: 2, tools: [{ name: "mnote.mindmap.fetch", enabled: true }, { name: "mnote.mindmap.create_from_outline", enabled: true }] },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
archived: [],
|
||||||
|
};
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route("**/api/hermes/client/skills**", async (route) => {
|
await page.route("**/api/hermes/client/skills**", async (route) => {
|
||||||
const url = new URL(route.request().url());
|
const url = new URL(route.request().url());
|
||||||
if (url.pathname.endsWith("/toggle")) {
|
if (url.pathname.endsWith("/toggle")) {
|
||||||
@@ -492,7 +539,38 @@ async function main() {
|
|||||||
bridgeSessionId: "mnote-oo-task502-office",
|
bridgeSessionId: "mnote-oo-task502-office",
|
||||||
bridgeSessionReady: true,
|
bridgeSessionReady: true,
|
||||||
};
|
};
|
||||||
const resources = [mindmapResource, officeResource];
|
const officePreviewResource = {
|
||||||
|
objectIdentity: "resource:office-preview:task502",
|
||||||
|
workspacePath: {
|
||||||
|
schema: "mnote.workspace_path.v1",
|
||||||
|
workspaceId,
|
||||||
|
sourceKind: "local_folder",
|
||||||
|
rootUri,
|
||||||
|
relativePath: "office/Task502 Preview.docx",
|
||||||
|
documentId,
|
||||||
|
objectIdentity: "resource:office-preview:task502",
|
||||||
|
assetId: "task502-office-preview",
|
||||||
|
resourceKind: "attachment",
|
||||||
|
},
|
||||||
|
paneRole: "primary",
|
||||||
|
documentId,
|
||||||
|
workspaceId,
|
||||||
|
title: "Task502 Preview DOCX",
|
||||||
|
kind: "office",
|
||||||
|
editorKind: "office",
|
||||||
|
active: false,
|
||||||
|
dirtyState: "",
|
||||||
|
preview: true,
|
||||||
|
pinned: true,
|
||||||
|
lastActiveAt: Date.now(),
|
||||||
|
assetId: "task502-office-preview",
|
||||||
|
path: "office/Task502 Preview.docx",
|
||||||
|
officeOpenMode: "preview",
|
||||||
|
onlyofficeSessionId: "",
|
||||||
|
bridgeSessionId: "",
|
||||||
|
bridgeSessionReady: false,
|
||||||
|
};
|
||||||
|
const resources = [mindmapResource, officeResource, officePreviewResource];
|
||||||
const withoutResource = (items) => (Array.isArray(items) ? items : [])
|
const withoutResource = (items) => (Array.isArray(items) ? items : [])
|
||||||
.filter((item) => !resources.some((resource) => item?.objectIdentity === resource.objectIdentity));
|
.filter((item) => !resources.some((resource) => item?.objectIdentity === resource.objectIdentity));
|
||||||
const groups = snapshot.groups || {};
|
const groups = snapshot.groups || {};
|
||||||
@@ -535,21 +613,24 @@ async function main() {
|
|||||||
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS });
|
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('[data-page-ai-panel="skills"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-panel="skills"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const skillSourceSelect = page.locator('[data-page-ai-panel="skills"] [data-page-ai-skill-source-select]');
|
const skillSourceSelect = page.locator('[data-page-ai-panel="skills"] [data-page-ai-skill-source-select]');
|
||||||
await skillSourceSelect.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await skillSourceSelect.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
|
||||||
const skillSourceOptions = await skillSourceSelect.locator("option").evaluateAll((nodes) =>
|
const skillSourceOptions = await skillSourceSelect.locator("option").evaluateAll((nodes) =>
|
||||||
nodes.map((node) => ({ value: node.value, text: node.textContent || "" })),
|
nodes.map((node) => ({ value: node.value, text: node.textContent || "" })),
|
||||||
);
|
);
|
||||||
assert(skillSourceOptions.some((item) => item.value === "mnote" && item.text.includes("mnote")), "技能来源应包含 mnote");
|
assert(skillSourceOptions.some((item) => item.value === "mnote" && item.text.includes("MNote 公共能力")), "能力来源应包含 MNote");
|
||||||
assert(skillSourceOptions.some((item) => item.value === "reasonix" && item.text.includes("reasonix")), "技能来源应包含 reasonix");
|
assert(skillSourceOptions.some((item) => item.value === "reasonix" && item.text.includes("Reasonix skill")), "能力来源应包含 Reasonix 自带 skill 查看入口");
|
||||||
assert(skillSourceOptions.some((item) => item.value === "hermes:usr_task502_default" && item.text.includes("Hermes_user")), "技能来源应包含个人 Hermes profile");
|
assert(skillSourceOptions.some((item) => item.value === "hermes:usr_task502_default" && item.text.includes("Hermes skill")), "能力来源应包含个人 Hermes profile skill 查看入口");
|
||||||
assert(skillSourceOptions.some((item) => item.value === "hermes:shared_lite" && item.text.includes("hermes_lite")), "技能来源应包含 shared lite profile");
|
assert(!skillSourceOptions.some((item) => item.value === "hermes:shared_deepseek_chat"), "Chat-only Hermes profile 不应作为能力来源展示");
|
||||||
await skillSourceSelect.selectOption("mnote", { timeout: UI_TIMEOUT_MS });
|
assert(!skillSourceOptions.some((item) => item.value === "hermes:shared_lite"), "Hermes Lite chat-only profile 不应作为能力来源展示");
|
||||||
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const skillPanelText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
|
const skillPanelText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||||
assert(skillPanelText.includes("mnote"), "技能面板应展示 mnote 来源");
|
assert(skillPanelText.includes("MNote 公共能力"), "能力面板应展示 MNote 来源");
|
||||||
assert(!skillPanelText.includes("Reasonix 技能"), "选择 mnote 时不应同时展示 Reasonix 技能");
|
assert(!skillPanelText.includes("user_sqlite"), "MNote 能力面板不应展示内部 SQLite policy 细节");
|
||||||
assert(!skillPanelText.includes("Hermes 技能"), "选择 mnote 时不应同时展示 Hermes 技能");
|
assert(!skillPanelText.includes("profile_tool_policy"), "MNote 能力面板不应展示内部 profile policy 细节");
|
||||||
|
assert(!skillPanelText.includes("reasonix-review"), "选择 MNote 时不应同时展示 Reasonix 能力条目");
|
||||||
|
assert(!skillPanelText.includes("Hermes writer"), "选择 MNote 时不应同时展示 Hermes 能力条目");
|
||||||
|
const mnoteSkillsScreenshot = await saveScreenshot(page, "00-mnote-skills-panel");
|
||||||
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').count(),
|
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').count(),
|
||||||
@@ -567,40 +648,20 @@ async function main() {
|
|||||||
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').click({ timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').click({ timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-mindmap"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
await skillSourceSelect.selectOption("reasonix", { timeout: UI_TIMEOUT_MS });
|
await skillSourceSelect.selectOption("reasonix", { timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-skill-group="reasonix"] .wolai-page-ai-skill-name', { hasText: "reasonix-review" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const reasonixOnlyText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
|
|
||||||
assert(reasonixOnlyText.includes("reasonix"), "选择 reasonix 时应展示 Reasonix 来源");
|
|
||||||
assert(!reasonixOnlyText.includes("当前页读取"), "选择 reasonix 时不应残留 MNote 技能");
|
|
||||||
assert(!reasonixOnlyText.includes("Hermes writer"), "选择 reasonix 时不应残留 Hermes 技能");
|
|
||||||
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').click({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await skillSourceSelect.selectOption("hermes:shared_lite", { timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-lite"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
const sharedHermesText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
|
|
||||||
assert(sharedHermesText.includes("hermes_lite"), "选择 hermes_lite 时应只展示 lite profile 技能");
|
|
||||||
assert(!sharedHermesText.includes("当前页读取"), "选择 hermes_lite 时不应残留 MNote 技能");
|
|
||||||
assert(!sharedHermesText.includes("reasonix-review"), "选择 hermes_lite 时不应残留 Reasonix 技能");
|
|
||||||
assert(
|
|
||||||
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-lite"]').isDisabled(),
|
|
||||||
"shared Hermes profile 普通用户应只读",
|
|
||||||
);
|
|
||||||
await page.locator('[data-page-ai-hide-hermes-builtin]').check({ timeout: UI_TIMEOUT_MS });
|
|
||||||
const hiddenBuiltinText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
|
|
||||||
assert(!hiddenBuiltinText.includes("Hermes builtin"), "隐藏 Hermes 内置后不应显示 Hermes 内置技能");
|
|
||||||
await page.locator('[data-page-ai-hide-hermes-builtin]').uncheck({ timeout: UI_TIMEOUT_MS });
|
|
||||||
await skillSourceSelect.selectOption("hermes:usr_task502_default", { timeout: UI_TIMEOUT_MS });
|
|
||||||
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-lite"]').count(),
|
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').count(),
|
||||||
0,
|
0,
|
||||||
"Hermes profile 切回 mnoteai 后不应残留 chemist 技能",
|
"Reasonix 自带 skill 在能力页只读查看,不显示开关",
|
||||||
);
|
);
|
||||||
const skillsScreenshot = await saveScreenshot(page, "00-skills-panel");
|
await skillSourceSelect.selectOption("hermes:usr_task502_default", { timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').click({ timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-skill-group="hermes"] .wolai-page-ai-skill-name', { hasText: "Hermes writer" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
await page.waitForFunction(
|
assert.strictEqual(
|
||||||
() => document.documentElement.getAttribute("data-mnote-page-ai-skill-toggled") === "hermes-writer",
|
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').count(),
|
||||||
null,
|
0,
|
||||||
{ timeout: UI_TIMEOUT_MS },
|
"Hermes profile skill 在能力页只读查看,不显示开关",
|
||||||
);
|
);
|
||||||
|
const skillsScreenshot = await saveScreenshot(page, "00-hermes-skills-panel");
|
||||||
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
const contextButtonText = await contextButton.innerText({ timeout: UI_TIMEOUT_MS });
|
const contextButtonText = await contextButton.innerText({ timeout: UI_TIMEOUT_MS });
|
||||||
@@ -663,6 +724,39 @@ async function main() {
|
|||||||
`默认目标应标记 only_office resourceKind: ${JSON.stringify(ackRunBody.targetPackage)}`,
|
`默认目标应标记 only_office resourceKind: ${JSON.stringify(ackRunBody.targetPackage)}`,
|
||||||
);
|
);
|
||||||
assert.strictEqual(ackRunBody.targetPackage?.policy?.writeRequiresExplicitTarget, true, "targetPackage policy 应要求显式目标");
|
assert.strictEqual(ackRunBody.targetPackage?.policy?.writeRequiresExplicitTarget, true, "targetPackage policy 应要求显式目标");
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => ["completed", "idle"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
await targetButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await targetPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await targetPopover.locator('[data-page-ai-target-option="resource:office-preview:task502"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector("[data-page-ai-target-chip]")?.textContent || "").includes("Task502 Preview DOCX"),
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const previewRunCountBefore = captured.filter((item) => item.kind === "run").length;
|
||||||
|
await page.locator("[data-page-ai-input]").fill("预览 docx 请正常回复", { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await waitForCapturedRunCount(captured, previewRunCountBefore + 1);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const previewRuns = captured.filter((item) => item.kind === "run");
|
||||||
|
assert(previewRuns.length >= 2, "未捕获 Office 预览 Page AI run payload");
|
||||||
|
const previewRunBody = JSON.parse(previewRuns[previewRuns.length - 1].body);
|
||||||
|
assert.strictEqual(previewRunBody.targetPackage?.primaryTargetId, "resource:office-preview:task502", "Office 预览 targetPackage 应冻结预览资源");
|
||||||
|
assert.strictEqual(previewRunBody.targetPackage?.resourceKind, "attachment", `Office 预览不应被归一为 only_office: ${JSON.stringify(previewRunBody.targetPackage)}`);
|
||||||
|
assert.strictEqual(previewRunBody.targetPackage?.onlyofficeSessionId, "", "Office 预览 targetPackage 不应要求 bridge session");
|
||||||
|
assert(
|
||||||
|
previewRunBody.targetPackage.targets.some((target) => target.resourceKind === "attachment" && target.relativePath === "office/Task502 Preview.docx" && !target.onlyofficeSessionId),
|
||||||
|
`Office 预览 target 应作为普通附件上下文发送: ${JSON.stringify(previewRunBody.targetPackage)}`,
|
||||||
|
);
|
||||||
|
|
||||||
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
@@ -748,7 +842,6 @@ async function main() {
|
|||||||
assert(runBody.contextRefs.some((item) => item.kind === "changed_files"));
|
assert(runBody.contextRefs.some((item) => item.kind === "changed_files"));
|
||||||
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-current-page"], false, "MNote skill 开关应进入 run payload");
|
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-current-page"], false, "MNote skill 开关应进入 run payload");
|
||||||
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-mindmap"], false, "MNote mindmap skill 开关应进入 run payload");
|
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-mindmap"], false, "MNote mindmap skill 开关应进入 run payload");
|
||||||
assert.strictEqual(runBody.skillPreferences?.reasonix?.["reasonix-review"], false, "Reasonix skill 开关应进入 run payload");
|
|
||||||
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "文档任务 run 应携带目标包");
|
assert.strictEqual(runBody.targetPackage?.schema, "mnote.agent_target_package.v1", "文档任务 run 应携带目标包");
|
||||||
assert(runBody.targetPackage?.primaryTargetId, "文档任务 targetPackage 应包含 primaryTargetId");
|
assert(runBody.targetPackage?.primaryTargetId, "文档任务 targetPackage 应包含 primaryTargetId");
|
||||||
assert(Array.isArray(runBody.targetPackage?.targets), "文档任务 targetPackage 应包含 targets 数组");
|
assert(Array.isArray(runBody.targetPackage?.targets), "文档任务 targetPackage 应包含 targets 数组");
|
||||||
@@ -762,12 +855,10 @@ async function main() {
|
|||||||
.filter((item) => item.kind === "ui-preferences" && item.method === "PUT")
|
.filter((item) => item.kind === "ui-preferences" && item.method === "PUT")
|
||||||
.map((item) => JSON.parse(item.body || "{}"));
|
.map((item) => JSON.parse(item.body || "{}"));
|
||||||
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile_id"] === "shared_lite"), "Agent 内的 Hermes profile 选择应写入 SQLite UI preference");
|
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile_id"] === "shared_lite"), "Agent 内的 Hermes profile 选择应写入 SQLite UI preference");
|
||||||
assert(captured.some((item) => item.kind === "skill-toggle" && JSON.parse(item.body || "{}").skillKind === "mnote_builtin" && JSON.parse(item.body || "{}").name === "mnote-current-page"), "MNote skill 开关应调用服务端 per-user SQLite policy");
|
assert(captured.some((item) => item.kind === "capability-toggle" && JSON.parse(item.body || "{}").id === "mnote-current-page"), "MNote 能力开关应调用服务端 per-user SQLite policy");
|
||||||
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.reasonix.skills.enabled"]?.["reasonix-review"] === false), "Reasonix skill 开关应写入 SQLite UI preference");
|
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.groups.collapsed"]?.mnote === true), "能力分组折叠状态应写入 SQLite UI preference");
|
||||||
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.groups.collapsed"]?.mnote === true), "技能分组折叠状态应写入 SQLite UI preference");
|
assert(!preferenceBodies.some((body) => body.updates?.["ai.agent.reasonix.skills.enabled"]), "能力页不应再写入 Reasonix 自带 skill 偏好");
|
||||||
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.active_source"] === "hermes:usr_task502_default"), "技能来源选择应写入 SQLite UI preference");
|
assert(!preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.skills.hide_builtin"] === true), "能力页不应再写入 Hermes 内置 skill 过滤偏好");
|
||||||
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.skills.hide_builtin"] === true), "Hermes 隐藏内置技能应写入用户 UI preference");
|
|
||||||
assert(captured.some((item) => item.kind === "skill-toggle" && JSON.parse(item.body || "{}").profileId === "usr_task502_default"), "Hermes skill 开关应按 profileId 调用");
|
|
||||||
assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组");
|
assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组");
|
||||||
assert(runBody.allowedRoots.some((item) =>
|
assert(runBody.allowedRoots.some((item) =>
|
||||||
item.rootUri === rootUri
|
item.rootUri === rootUri
|
||||||
@@ -794,6 +885,7 @@ async function main() {
|
|||||||
root,
|
root,
|
||||||
rootUri,
|
rootUri,
|
||||||
documentId,
|
documentId,
|
||||||
|
mnoteSkillsScreenshot,
|
||||||
screenshot,
|
screenshot,
|
||||||
skillsScreenshot,
|
skillsScreenshot,
|
||||||
captured,
|
captured,
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ async function main() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const capturedRuns = [];
|
const capturedRuns = [];
|
||||||
|
const capturedSessionCreates = [];
|
||||||
|
const splitDeltaDetailText = "历史回答不应按 delta 拆成多个气泡。";
|
||||||
let caughtError = null;
|
let caughtError = null;
|
||||||
const screenshots = {};
|
const screenshots = {};
|
||||||
|
|
||||||
@@ -282,8 +284,62 @@ async function main() {
|
|||||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route("**/api/hermes/client/sessions**", async (route) => {
|
await page.route("**/api/hermes/client/sessions**", async (route) => {
|
||||||
|
const requestUrl = new URL(route.request().url());
|
||||||
|
const detailMatch = requestUrl.pathname.match(/\/api\/hermes\/client\/sessions\/([^/]+)(?:\/resume)?$/);
|
||||||
if (route.request().method() === "GET") {
|
if (route.request().method() === "GET") {
|
||||||
|
if (detailMatch) {
|
||||||
|
const sessionId = decodeURIComponent(detailMatch[1]);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
persistence: "sqlite_acp_runtime_store",
|
||||||
|
sessionStorage: "sqlite_control_plane",
|
||||||
|
sessionId,
|
||||||
|
session: {
|
||||||
|
sessionId,
|
||||||
|
messages: [],
|
||||||
|
runs: [{
|
||||||
|
sessionId,
|
||||||
|
runId: `run_task504_detail_${suffix}`,
|
||||||
|
title: "Gemini 问答",
|
||||||
|
profile: "shared_gemini_chat",
|
||||||
|
acpRuntime: "hermes",
|
||||||
|
status: "completed",
|
||||||
|
payload: {
|
||||||
|
agentId: "chat_only",
|
||||||
|
profileId: "shared_gemini_chat",
|
||||||
|
profile: "shared_gemini_chat",
|
||||||
|
acpRuntime: "hermes",
|
||||||
|
message: "历史详情测试",
|
||||||
|
},
|
||||||
|
createdAt: "2026-05-30T08:00:00Z",
|
||||||
|
updatedAt: "2026-05-30T08:02:00Z",
|
||||||
|
persistence: "sqlite_acp_runtime_store",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
events: Array.from(splitDeltaDetailText).map((delta, index) => ({
|
||||||
|
eventId: `evt_task504_detail_${index}`,
|
||||||
|
sessionId,
|
||||||
|
runId: `run_task504_detail_${suffix}`,
|
||||||
|
eventType: "message.delta",
|
||||||
|
payload: { delta },
|
||||||
|
createdAt: `2026-05-30T08:01:${String(index).padStart(2, "0")}Z`,
|
||||||
|
persistence: "sqlite_acp_runtime_store",
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -295,6 +351,27 @@ async function main() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (route.request().method() === "POST" && detailMatch && requestUrl.pathname.endsWith("/resume")) {
|
||||||
|
const sessionId = decodeURIComponent(detailMatch[1]);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
persistence: "sqlite_acp_runtime_store",
|
||||||
|
sessionStorage: "sqlite_control_plane",
|
||||||
|
sessionId,
|
||||||
|
session: {
|
||||||
|
sessionId,
|
||||||
|
messages: [{ role: "assistant", content: splitDeltaDetailText }],
|
||||||
|
runs: [],
|
||||||
|
},
|
||||||
|
events: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
capturedSessionCreates.push(JSON.parse(route.request().postData() || "{}"));
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -359,6 +436,7 @@ async function main() {
|
|||||||
assert(historyText.includes("Hermes / Lite"), "历史会话应显示 Hermes profile");
|
assert(historyText.includes("Hermes / Lite"), "历史会话应显示 Hermes profile");
|
||||||
assert(!historyText.includes("ChatOnly / myHermes"), "ChatOnly 历史不应显示 Hermes profile 标签");
|
assert(!historyText.includes("ChatOnly / myHermes"), "ChatOnly 历史不应显示 Hermes profile 标签");
|
||||||
assert(!historyText.includes("FULL_TAIL_SHOULD_NOT_RENDER"), "历史预览不应显示 ChatOnly 完整长消息尾部");
|
assert(!historyText.includes("FULL_TAIL_SHOULD_NOT_RENDER"), "历史预览不应显示 ChatOnly 完整长消息尾部");
|
||||||
|
assert.strictEqual(capturedSessionCreates.length, 0, "打开 Page AI 和历史列表不应创建空白后端会话");
|
||||||
|
|
||||||
const filter = page.locator('[data-page-ai-session-agent-filter]');
|
const filter = page.locator('[data-page-ai-session-agent-filter]');
|
||||||
await filter.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await filter.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
@@ -454,6 +532,7 @@ async function main() {
|
|||||||
rawStrongMarkers: false,
|
rawStrongMarkers: false,
|
||||||
}, `AI 面板应渲染 Markdown,而不是显示原始标记: ${JSON.stringify(markdownState)}`);
|
}, `AI 面板应渲染 Markdown,而不是显示原始标记: ${JSON.stringify(markdownState)}`);
|
||||||
assert.strictEqual(capturedRuns.length, 1, "当前页 ChatOnly 请求应通过前置 target 校验并到达 runs API");
|
assert.strictEqual(capturedRuns.length, 1, "当前页 ChatOnly 请求应通过前置 target 校验并到达 runs API");
|
||||||
|
assert.strictEqual(capturedSessionCreates.length, 1, "只有真实发送消息时才应创建后端会话");
|
||||||
assert.strictEqual(capturedRuns[0].agentId, "chat_only", "应使用 ChatOnly agent");
|
assert.strictEqual(capturedRuns[0].agentId, "chat_only", "应使用 ChatOnly agent");
|
||||||
assert.strictEqual(capturedRuns[0].profile, "shared_deepseek_chat", "应使用 DeepSeek ChatOnly profile");
|
assert.strictEqual(capturedRuns[0].profile, "shared_deepseek_chat", "应使用 DeepSeek ChatOnly profile");
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
@@ -470,6 +549,24 @@ async function main() {
|
|||||||
"取消打开资源后不应发送 active_editor contextRef",
|
"取消打开资源后不应发送 active_editor contextRef",
|
||||||
);
|
);
|
||||||
screenshots.currentPageTarget = await saveScreenshot(page, "current-page-target");
|
screenshots.currentPageTarget = await saveScreenshot(page, "current-page-target");
|
||||||
|
|
||||||
|
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-page-ai-session-agent-filter]').selectOption("all", { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator(`[data-page-ai-session-resume="mnote_task504_gemini_${suffix}"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').getByText(splitDeltaDetailText).waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const restoredAssistantMessages = await page.locator(".wolai-page-ai-message--assistant").evaluateAll((nodes) => (
|
||||||
|
nodes.map((node) => node.textContent || "").filter((text) => text.includes("历史回答") || text.includes("不应按"))
|
||||||
|
));
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
restoredAssistantMessages,
|
||||||
|
[`AI${splitDeltaDetailText}`],
|
||||||
|
"恢复历史详情时 message.delta 必须合并为一条 assistant 消息,不能按字拆气泡",
|
||||||
|
);
|
||||||
|
screenshots.historyRestore = await saveScreenshot(page, "history-restore");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
caughtError = error;
|
caughtError = error;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -241,6 +241,13 @@ async function main() {
|
|||||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||||
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
|
|||||||
@@ -241,6 +241,13 @@ async function main() {
|
|||||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||||
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
|
|||||||
@@ -196,6 +196,13 @@ async function main() {
|
|||||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route("**/api/hermes/client/capabilities**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||||
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ async function postJson(pathname, data, headers = {}) {
|
|||||||
})).payload;
|
})).payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function putJson(pathname, data, headers = {}) {
|
||||||
|
return (await fetchJson(pathname, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})).payload;
|
||||||
|
}
|
||||||
|
|
||||||
async function getText(pathname, headers = {}, timeoutMs = 180_000) {
|
async function getText(pathname, headers = {}, timeoutMs = 180_000) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
@@ -170,7 +178,7 @@ async function runReasonixAcpEvidenceCheck(input) {
|
|||||||
allowedRoots: [{ rootUri, permission: "write" }],
|
allowedRoots: [{ rootUri, permission: "write" }],
|
||||||
skillPreferences: {
|
skillPreferences: {
|
||||||
mnote: {
|
mnote: {
|
||||||
"mnote-document-evidence": true,
|
"mnote-local-index": true,
|
||||||
"mnote-chat-only": false,
|
"mnote-chat-only": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -245,18 +253,33 @@ async function main() {
|
|||||||
headers: { cookie },
|
headers: { cookie },
|
||||||
})).payload;
|
})).payload;
|
||||||
const toolNames = (toolsPayload.tools || []).map((tool) => tool.name);
|
const toolNames = (toolsPayload.tools || []).map((tool) => tool.name);
|
||||||
for (const name of ["mnote.evidence.search", "mnote.evidence.read", "mnote.evidence.open"]) {
|
for (const name of [
|
||||||
|
"mnote.evidence.search",
|
||||||
|
"mnote.evidence.read",
|
||||||
|
"mnote.evidence.open",
|
||||||
|
"mnote.index.status",
|
||||||
|
"mnote.index.refresh",
|
||||||
|
"mnote.index.update_settings",
|
||||||
|
]) {
|
||||||
assert(toolNames.includes(name), `Reasonix tools 列表缺少 ${name}`);
|
assert(toolNames.includes(name), `Reasonix tools 列表缺少 ${name}`);
|
||||||
}
|
}
|
||||||
const skillsPayload = (await fetchJson("/api/hermes/client/skills?runtime=mnote&agentId=reasonix", {
|
const capabilitiesPayload = (await fetchJson("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=reasonix", {
|
||||||
headers: { cookie },
|
headers: { cookie },
|
||||||
})).payload;
|
})).payload;
|
||||||
const mnoteSkills = (skillsPayload.categories || []).flatMap((category) => category.skills || []);
|
const mnoteCapabilities = (capabilitiesPayload.categories || []).flatMap((category) => category.capabilities || category.skills || []);
|
||||||
assert(
|
assert(
|
||||||
mnoteSkills.some((skill) => skill.id === "mnote-document-evidence" && skill.enabled !== false),
|
mnoteCapabilities.some((capability) => capability.id === "mnote-local-index" && capability.enabled !== false),
|
||||||
"Reasonix agent 缺少启用的 mnote-document-evidence skill",
|
"Reasonix agent 缺少启用的 mnote-local-index 能力",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const settings = await putJson("/api/search/local-index/settings", {
|
||||||
|
workspaceId,
|
||||||
|
rootUri,
|
||||||
|
includePaths: ["."],
|
||||||
|
scheduleMode: "manual",
|
||||||
|
runOnChange: false,
|
||||||
|
}, actorHeaders);
|
||||||
|
assert.strictEqual(settings.ok, true, "local evidence index settings ok");
|
||||||
const refresh = await postJson("/api/search/local-index/refresh", { workspaceId, rootUri }, actorHeaders);
|
const refresh = await postJson("/api/search/local-index/refresh", { workspaceId, rootUri }, actorHeaders);
|
||||||
assert.strictEqual(refresh.ok, true, "local evidence index refresh ok");
|
assert.strictEqual(refresh.ok, true, "local evidence index refresh ok");
|
||||||
|
|
||||||
@@ -297,6 +320,8 @@ async function main() {
|
|||||||
const toolResult = toolEnvelope.result || toolEnvelope;
|
const toolResult = toolEnvelope.result || toolEnvelope;
|
||||||
const toolHit = (toolResult.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
|
const toolHit = (toolResult.results || []).find((result) => String(result.quote || "").includes("Printer test page"));
|
||||||
assertEvidenceHit(toolHit, { label: "agent-tool", ownerRel, pdfRel });
|
assertEvidenceHit(toolHit, { label: "agent-tool", ownerRel, pdfRel });
|
||||||
|
assert(String(toolHit.citationMarkdown || "").includes("](/documents/"), "agent tool 缺少可点击 citationMarkdown");
|
||||||
|
assert(String(toolHit.citationUrl || "").includes("resourceTab="), "agent tool citationUrl 缺少资源 tab 定位参数");
|
||||||
assert((toolEnvelope.audit?.evidenceIds || []).includes(toolHit.evidenceId), "agent tool audit 缺少 evidence id");
|
assert((toolEnvelope.audit?.evidenceIds || []).includes(toolHit.evidenceId), "agent tool audit 缺少 evidence id");
|
||||||
assert.strictEqual(toolEnvelope.audit?.runReceipt?.toolName, "mnote.evidence.search", "run receipt toolName");
|
assert.strictEqual(toolEnvelope.audit?.runReceipt?.toolName, "mnote.evidence.search", "run receipt toolName");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
BASE_URL,
|
||||||
|
UI_TIMEOUT_MS,
|
||||||
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
|
const TASK = "task529-local-search-result-open-locator-smoke";
|
||||||
|
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||||
|
const RESULT_PATH = path.join(OUT_DIR, "result.json");
|
||||||
|
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||||
|
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||||
|
.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
|
function fileUrl(localPath) {
|
||||||
|
return `file://${localPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localMdDocumentId(relativePath) {
|
||||||
|
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentUrl(root, relativePath) {
|
||||||
|
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||||
|
url.searchParams.set("sourceKind", "local_folder");
|
||||||
|
url.searchParams.set("rootUri", fileUrl(root));
|
||||||
|
url.searchParams.set("treeView", "filetree");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveScreenshot(page, name) {
|
||||||
|
const target = path.join(OUT_DIR, `${name}.png`);
|
||||||
|
await page.screenshot({ path: target, fullPage: false });
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginTestAccount(page) {
|
||||||
|
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
if (page.url().includes("/auth")) {
|
||||||
|
const quickLogin = page.locator('[data-auth-test-login]').first();
|
||||||
|
await quickLogin.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||||
|
waitUntil: "commit",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await page.evaluate(async () => {
|
||||||
|
const response = await fetch("/api/auth/whoami", { headers: { accept: "application/json" } });
|
||||||
|
return await response.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task529-search-"));
|
||||||
|
const rootUri = fileUrl(root);
|
||||||
|
const query = "三甲基硅酯";
|
||||||
|
const currentPath = "Current.md";
|
||||||
|
const targetPath = "docs/Silicon.md";
|
||||||
|
const debug = { root, rootUri, query, baseUrl: BASE_URL };
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.HEADFUL !== "1",
|
||||||
|
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 960 },
|
||||||
|
locale: "zh-CN",
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const viewer = await loginTestAccount(page);
|
||||||
|
const actorId = viewer.userId || "mnote-e2e";
|
||||||
|
const workspaceId = `local-ws:${actorId}:task529`;
|
||||||
|
debug.viewer = viewer;
|
||||||
|
debug.workspaceId = workspaceId;
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, ".mnote", "workspace.json"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
workspaceId,
|
||||||
|
ownerId: actorId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
capabilities: ["local_files", "search"],
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(root, currentPath), "# Current\n\n从这个页面打开搜索结果。\n", "utf8");
|
||||||
|
const filler = Array.from({ length: 42 }, (_, index) => `普通段落 ${index + 1}。`).join("\n\n");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, targetPath),
|
||||||
|
`# Silicon\n\n${filler}\n\n命中段落:羧酸可以转化成${query},本行用于测试搜索定位。\n\n尾部段落。\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(documentUrl(root, currentPath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = await page.evaluate(async ({ workspaceId, rootUri }) => {
|
||||||
|
const response = await fetch("/api/search/local-index/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId,
|
||||||
|
rootUri,
|
||||||
|
includePaths: ["."],
|
||||||
|
scheduleMode: "manual",
|
||||||
|
scheduleTime: "02:00",
|
||||||
|
runOnChange: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return { status: response.status, payload: await response.json().catch(() => null) };
|
||||||
|
}, { workspaceId, rootUri });
|
||||||
|
assert.equal(refresh.status, 200, `刷新本地索引应成功: ${JSON.stringify(refresh)}`);
|
||||||
|
debug.refresh = refresh.payload;
|
||||||
|
|
||||||
|
await page.locator('[data-mnote-action="open-search-modal"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-testid="wolai-search-input"]').fill(query, { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction((expected) => {
|
||||||
|
return Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'))
|
||||||
|
.some((row) => (row.textContent || "").includes(expected));
|
||||||
|
}, query, { timeout: UI_TIMEOUT_MS });
|
||||||
|
debug.searchScreenshot = await saveScreenshot(page, "search-results");
|
||||||
|
|
||||||
|
await page.locator('[data-testid="wolai-search-result-row"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction((expected) => {
|
||||||
|
const activeTab = document.querySelector('.mnote-main-tab[aria-selected="true"][data-mnote-tab-kind="markdown"]');
|
||||||
|
const highlighted = document.querySelector('[data-mnote-resource-tab-panel][data-pane-role="primary"] [data-mnote-evidence-text-highlight="true"]');
|
||||||
|
const visibleHit = Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel][data-pane-role="primary"]:not([hidden]) .ProseMirror *'))
|
||||||
|
.find((node) => node instanceof HTMLElement && (node.textContent || "").includes(expected));
|
||||||
|
return Boolean(activeTab && (
|
||||||
|
highlighted && (highlighted.textContent || "").includes(expected)
|
||||||
|
|| visibleHit && visibleHit.getBoundingClientRect().top > 80 && visibleHit.getBoundingClientRect().bottom < window.innerHeight
|
||||||
|
));
|
||||||
|
}, query, { timeout: UI_TIMEOUT_MS });
|
||||||
|
debug.openScreenshot = await saveScreenshot(page, "opened-resource-tab");
|
||||||
|
|
||||||
|
const state = await page.evaluate((expectedCurrentPath) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const activeTab = document.querySelector('.mnote-main-tab[aria-selected="true"][data-mnote-tab-kind="markdown"]');
|
||||||
|
const highlighted = document.querySelector('[data-mnote-resource-tab-panel][data-pane-role="primary"] [data-mnote-evidence-text-highlight="true"]');
|
||||||
|
const visibleHit = Array.from(document.querySelectorAll('[data-mnote-resource-tab-panel][data-pane-role="primary"]:not([hidden]) .ProseMirror *'))
|
||||||
|
.find((node) => node instanceof HTMLElement && (node.textContent || "").includes("三甲基硅酯"));
|
||||||
|
const target = highlighted instanceof HTMLElement ? highlighted : visibleHit;
|
||||||
|
const rect = target instanceof HTMLElement ? target.getBoundingClientRect() : null;
|
||||||
|
return {
|
||||||
|
pathname: url.pathname,
|
||||||
|
stayedOnCurrentDocument: url.pathname.includes(encodeURIComponent(`local-md:${expectedCurrentPath}`)),
|
||||||
|
resourceTab: url.searchParams.get("resourceTab") || "",
|
||||||
|
activeTabTitle: activeTab ? activeTab.textContent.trim() : "",
|
||||||
|
highlighted: Boolean(highlighted),
|
||||||
|
highlightedText: target ? target.textContent.trim() : "",
|
||||||
|
highlightedRect: rect ? { top: rect.top, bottom: rect.bottom, height: rect.height } : null,
|
||||||
|
};
|
||||||
|
}, currentPath);
|
||||||
|
debug.state = state;
|
||||||
|
assert.equal(state.stayedOnCurrentDocument, true, `点击搜索结果不应整页跳走: ${JSON.stringify(state)}`);
|
||||||
|
assert(state.resourceTab.includes(targetPath), `URL 应记录当前资源标签: ${JSON.stringify(state)}`);
|
||||||
|
assert(state.highlightedText.includes(query), `应高亮正文命中块: ${JSON.stringify(state)}`);
|
||||||
|
assert(
|
||||||
|
state.highlightedRect && state.highlightedRect.top > 80 && state.highlightedRect.bottom < 900,
|
||||||
|
`命中块应滚动到可视区域: ${JSON.stringify(state)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(debug, null, 2)}\n`, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
debug.error = error && error.stack || String(error);
|
||||||
|
try {
|
||||||
|
debug.failureScreenshot = await saveScreenshot(page, "failure");
|
||||||
|
} catch (_) {}
|
||||||
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(debug, null, 2)}\n`, "utf8");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error && error.stack || error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# MNote document evidence
|
|
||||||
|
|
||||||
使用场景:需要在本地工作区搜索 PDF、图片 OCR、Office 派生内容或 Markdown,并在回答中给出可点击回跳的原文证据。
|
|
||||||
|
|
||||||
优先使用工具:
|
|
||||||
|
|
||||||
- `mnote.evidence.search`:先按问题搜索证据,必须保留返回的 `quote`、`source` 和 `openAction`。
|
|
||||||
- `mnote.evidence.read`:需要更多上下文时,按上一轮返回的 `source` locator 读回周边证据。
|
|
||||||
- `mnote.evidence.open`:需要给 UI 打开动作时,按 locator 归一化,不要自行拼接 URL。
|
|
||||||
|
|
||||||
回答要求:
|
|
||||||
|
|
||||||
- 关键事实必须带可回跳来源,至少包含文档名、资源名或页码、quote 摘要。
|
|
||||||
- 不要直接读取 `.mnote/index`、OCR sidecar 或 provider 原始响应来替代 evidence tool。
|
|
||||||
- 不要把 OCR 文本当成 owner Markdown 正文真相。
|
|
||||||
- 如果 evidence 结果没有 page、bbox 或 section,只说明定位能力降级,不伪造页码。
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# MNote local index
|
||||||
|
|
||||||
|
使用场景:需要检索本地工作区资料、引用可回跳证据,或让 agent 帮忙查看、新增、刷新、删除本地索引范围。
|
||||||
|
|
||||||
|
证据检索:
|
||||||
|
|
||||||
|
- 先用 `mnote.evidence.search` 搜索资料证据,回答时保留返回的 `quote`、`source` 和 `openAction`。
|
||||||
|
- `mnote.evidence.search` / `mnote.evidence.read` 会返回 `citationUrl`、`citationLabel`、`citationMarkdown`。最终回答引用时优先直接使用 `citationMarkdown`,格式如 `[文件名 · p.3](citationUrl)`;该链接在 MNote Page AI 中点击会在当前页面的资源新 tab 打开,并按 `page` / `bbox` / `blockId` / `sourceMapPath` 跳转或高亮到证据位置。
|
||||||
|
- 如果用户查询语言与资料语言可能不一致,不要假定目标资料语言,也不要只把整句原文直接丢给 FTS。先保留用户意图,再根据文件名、题名、领域术语和已知上下文生成 2-6 个轻量多语关键词候选;不确定时优先同时尝试原语言关键词和常见英文术语。中文问法查生命科学文献时,例如“分子对接软件”可扩展为 `分子对接`, `molecular docking`, `docking software`, `AutoDock`, `Vina`。
|
||||||
|
- 关键词扩展只是检索策略,不是结论。回答仍按用户语言组织,且只根据 evidence 结果确认事实。
|
||||||
|
- 需要更多上下文时,用 `mnote.evidence.read` 按 locator 读回周边证据。
|
||||||
|
- 需要让 UI 打开定位时,用 `mnote.evidence.open`,不要自行拼接 URL。
|
||||||
|
|
||||||
|
索引管理:
|
||||||
|
|
||||||
|
- 用 `mnote.index.status` 查看当前 root 的 `settings`、`effectiveSettings`、索引文件是否存在、文档数和证据块数。
|
||||||
|
- 用 `mnote.index.update_settings` 新增或删除索引范围。`includePaths` 是 root 内相对路径;已保存范围视为固化记录,不直接改目录。要改目录时,删除旧范围并新增新范围。
|
||||||
|
- `includePaths: []` 表示删除当前用户的索引范围;当没有任何有效范围时,会清空 `.mnote/index/search-index.json` 和 `.mnote/index/evidence.sqlite`。
|
||||||
|
- 用 `mnote.index.refresh` 重建当前有效范围的索引缓存。刷新只重建索引,不修改 Markdown 正文。
|
||||||
|
|
||||||
|
写入约束:
|
||||||
|
|
||||||
|
- 调用 `mnote.index.update_settings` 必须显式携带 `dryRun` 和 `idempotencyKey`。
|
||||||
|
- 在只读或共享只读 AI scope 下,不要调用 `mnote.index.update_settings`。
|
||||||
|
- `dryRun: true` 只返回计划,不写设置、不重建索引。
|
||||||
|
|
||||||
|
回答要求:
|
||||||
|
|
||||||
|
- 关键事实必须带可回跳来源,至少包含文档名、资源名或页码、quote 摘要。
|
||||||
|
- 关键事实后必须给可点击引用链接;如果 `citationMarkdown` 缺失,先调用 `mnote.evidence.open` 取得 `citationUrl`,不要自己拼 `file://`、`.mnote/index` 或 OCR sidecar 路径。
|
||||||
|
- 不要直接读取 `.mnote/index`、OCR sidecar 或 provider 原始响应来替代 evidence tool。
|
||||||
|
- 不要把 OCR 文本当成 owner Markdown 正文真相。
|
||||||
|
- 如果 evidence 结果没有 page、bbox 或 section,只说明定位能力降级,不伪造页码。
|
||||||
Reference in New Issue
Block a user