收口 MNote P0 P1 P2 审查尾项
- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目 - 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线 - 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径 验证: - cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1 - cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1 - git diff --check - git diff --cached --check - codegraph index . --force && codegraph status . - codegraph sync . && codegraph status .
This commit is contained in:
@@ -202,6 +202,10 @@ fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> {
|
||||
Ok("documents:updateContent")
|
||||
}
|
||||
"mindmaps.put" => Ok("mindmaps:put"),
|
||||
"tree.resource.archive" => Ok("treeResource:archive"),
|
||||
"tree.resource.restore" => Ok("treeResource:restore"),
|
||||
"tree.resource.purge" => Ok("treeResource:purge"),
|
||||
"tree.resource.rename" => Ok("treeResource:rename"),
|
||||
_ => Err(retired_bridge_error()),
|
||||
}
|
||||
}
|
||||
@@ -6416,6 +6420,25 @@ fn legacy_block_projection_attrs(block: &Value, block_type: &str) -> Value {
|
||||
attrs.insert("language".into(), json!(language));
|
||||
}
|
||||
}
|
||||
if block_type == "mindmap" {
|
||||
for (raw_key, canonical_key) in [
|
||||
("mindmapId", "mindmapId"),
|
||||
("mindmap_id", "mindmapId"),
|
||||
("sourcePath", "sourcePath"),
|
||||
("source_path", "sourcePath"),
|
||||
("rootNodeId", "rootNodeId"),
|
||||
("root_node_id", "rootNodeId"),
|
||||
] {
|
||||
if let Some(value) = props
|
||||
.and_then(|map| map.get(raw_key))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
attrs.insert(canonical_key.into(), json!(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(attrs)
|
||||
}
|
||||
|
||||
@@ -13086,6 +13109,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_aggregate_block_document_preserves_mindmap_projection_attrs() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
context: demo_context(),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "page.aggregate.get".into(),
|
||||
payload: json!({
|
||||
"documentId": "doc_mindmap",
|
||||
"workspaceId": "ws_1",
|
||||
}),
|
||||
},
|
||||
data: Some(json!({
|
||||
"meta": {
|
||||
"id": "doc_mindmap",
|
||||
"workspace_id": "ws_1",
|
||||
"title": "Mindmap Page"
|
||||
},
|
||||
"content": {
|
||||
"content": [
|
||||
{
|
||||
"id": "local-block-1",
|
||||
"type": "mindmap",
|
||||
"props": {
|
||||
"mindmapId": "mindmap-123456.json",
|
||||
"sourcePath": "mindmap-123456.json",
|
||||
"rootNodeId": "root"
|
||||
},
|
||||
"content": []
|
||||
}
|
||||
],
|
||||
"revision": 3,
|
||||
"conflict_detection_key": "doc_mindmap:3"
|
||||
}
|
||||
})),
|
||||
})
|
||||
.expect("page aggregate query should build");
|
||||
|
||||
let block = &result["body"]["blockDocument"]["blocks"][0];
|
||||
assert_eq!(block["type"], json!("mindmap"));
|
||||
assert_eq!(block["attrs"]["mindmapId"], json!("mindmap-123456.json"));
|
||||
assert_eq!(block["attrs"]["sourcePath"], json!("mindmap-123456.json"));
|
||||
assert_eq!(block["attrs"]["rootNodeId"], json!("root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_aggregate_get_prefers_editor_document_over_legacy_content() {
|
||||
let result = execute_runtime_query(RuntimeInput::Query {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 007-ai-agent-profile-policy.sql
|
||||
-- Page AI agent profile policy: SQLite 是 profile 授权与归属真相层。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_agent_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
profile_kind TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL DEFAULT '',
|
||||
base_profile_name TEXT NOT NULL,
|
||||
isolated_profile_name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(agent_id, profile_kind, owner_user_id, base_profile_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_agent_profiles_agent
|
||||
ON ai_agent_profiles(agent_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_agent_profiles_owner
|
||||
ON ai_agent_profiles(owner_user_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_agent_profile_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL REFERENCES ai_agent_profiles(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL,
|
||||
can_run INTEGER NOT NULL DEFAULT 1,
|
||||
can_manage_skills INTEGER NOT NULL DEFAULT 0,
|
||||
can_manage_config INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(profile_id, user_id, role)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_agent_profile_grants_profile
|
||||
ON ai_agent_profile_grants(profile_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_agent_profile_grants_user
|
||||
ON ai_agent_profile_grants(user_id);
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS ai_external_conversation_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT,
|
||||
mnote_session_id TEXT NOT NULL,
|
||||
acp_session_id TEXT,
|
||||
agent_id TEXT NOT NULL,
|
||||
profile TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
remote_conversation_id TEXT NOT NULL,
|
||||
remote_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
CHECK (status IN ('active', 'local_deleted', 'remote_deleted', 'remote_delete_failed', 'remote_missing'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_external_conv_user_session_provider
|
||||
ON ai_external_conversation_bindings(user_id, mnote_session_id, provider);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_external_conv_user_provider_remote
|
||||
ON ai_external_conversation_bindings(user_id, provider, remote_conversation_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_external_conv_lookup
|
||||
ON ai_external_conversation_bindings(user_id, workspace_id, mnote_session_id, provider, status);
|
||||
@@ -30,6 +30,14 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
"v6-navigation-recent",
|
||||
include_str!("../migrations/006-navigation-recent.sql"),
|
||||
),
|
||||
(
|
||||
"v7-ai-agent-profile-policy",
|
||||
include_str!("../migrations/007-ai-agent-profile-policy.sql"),
|
||||
),
|
||||
(
|
||||
"v8-ai-external-conversation-bindings",
|
||||
include_str!("../migrations/008-ai-external-conversation-bindings.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
/// Create the `_migrations` meta-table if it does not exist.
|
||||
@@ -113,6 +121,9 @@ mod tests {
|
||||
"sidebar_shortcuts",
|
||||
"user_ui_preferences",
|
||||
"user_navigation_recent",
|
||||
"ai_agent_profiles",
|
||||
"ai_agent_profile_grants",
|
||||
"ai_external_conversation_bindings",
|
||||
] {
|
||||
assert!(table_exists(&conn, table), "table {table} should exist");
|
||||
}
|
||||
|
||||
@@ -243,6 +243,41 @@ pub struct UpsertUserUiPreferenceInput {
|
||||
pub value_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiAgentProfileRecord {
|
||||
pub id: EntityId,
|
||||
pub agent_id: String,
|
||||
pub profile_kind: String,
|
||||
pub owner_user_id: Option<EntityId>,
|
||||
pub base_profile_name: String,
|
||||
pub isolated_profile_name: String,
|
||||
pub display_name: String,
|
||||
pub status: String,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiAgentProfileGrantRecord {
|
||||
pub id: EntityId,
|
||||
pub profile_id: EntityId,
|
||||
pub user_id: Option<EntityId>,
|
||||
pub role: String,
|
||||
pub can_run: bool,
|
||||
pub can_manage_skills: bool,
|
||||
pub can_manage_config: bool,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiAgentProfileAccessRecord {
|
||||
pub profile: AiAgentProfileRecord,
|
||||
pub grant: AiAgentProfileGrantRecord,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NavigationRecentRecord {
|
||||
pub id: EntityId,
|
||||
@@ -393,6 +428,42 @@ pub struct AppendAiRuntimeEventInput {
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiExternalConversationBindingRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub mnote_session_id: EntityId,
|
||||
pub acp_session_id: Option<EntityId>,
|
||||
pub agent_id: String,
|
||||
pub profile: String,
|
||||
pub provider: String,
|
||||
pub remote_conversation_id: String,
|
||||
pub remote_url: Option<String>,
|
||||
pub status: String,
|
||||
pub metadata_json: String,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub deleted_at: Option<Timestamp>,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpsertAiExternalConversationBindingInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub mnote_session_id: EntityId,
|
||||
pub acp_session_id: Option<EntityId>,
|
||||
pub agent_id: String,
|
||||
pub profile: String,
|
||||
pub provider: String,
|
||||
pub remote_conversation_id: String,
|
||||
pub remote_url: Option<String>,
|
||||
pub status: String,
|
||||
pub metadata_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShareLinkRecord {
|
||||
pub id: EntityId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! SQLite-backed control-plane store.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
@@ -7,15 +8,17 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use crate::error::ControlPlaneError;
|
||||
use crate::migrations;
|
||||
use crate::model::{
|
||||
password_hash_v1, share_token_hash_v1, AiPolicyRecord, AiRuntimeEventRecord,
|
||||
AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord,
|
||||
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
|
||||
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
|
||||
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord,
|
||||
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
|
||||
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
|
||||
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
|
||||
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
||||
password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord,
|
||||
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
|
||||
AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput,
|
||||
AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
||||
CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
|
||||
DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
|
||||
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
|
||||
SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
|
||||
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
|
||||
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
|
||||
UserUiPreferenceRecord, WorkspaceRecord,
|
||||
};
|
||||
use crate::store::ControlPlaneStore;
|
||||
|
||||
@@ -214,6 +217,66 @@ fn option_to_stored_text(value: Option<String>) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn sanitize_profile_component(value: &str) -> String {
|
||||
let mut sanitized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
while sanitized.contains("--") {
|
||||
sanitized = sanitized.replace("--", "-");
|
||||
}
|
||||
sanitized = sanitized.trim_matches('-').to_string();
|
||||
if sanitized.is_empty() {
|
||||
"user".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_ai_agent_profile(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiAgentProfileRecord> {
|
||||
let owner_user_id: String = row.get(3)?;
|
||||
Ok(AiAgentProfileRecord {
|
||||
id: row.get(0)?,
|
||||
agent_id: row.get(1)?,
|
||||
profile_kind: row.get(2)?,
|
||||
owner_user_id: empty_string_to_option(owner_user_id),
|
||||
base_profile_name: row.get(4)?,
|
||||
isolated_profile_name: row.get(5)?,
|
||||
display_name: row.get(6)?,
|
||||
status: row.get(7)?,
|
||||
created_at: row.get(8)?,
|
||||
updated_at: row.get(9)?,
|
||||
revision: row.get(10)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_ai_agent_profile_access(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<AiAgentProfileAccessRecord> {
|
||||
Ok(AiAgentProfileAccessRecord {
|
||||
profile: row_to_ai_agent_profile(row)?,
|
||||
grant: AiAgentProfileGrantRecord {
|
||||
id: row.get(11)?,
|
||||
profile_id: row.get(12)?,
|
||||
user_id: empty_string_to_option(row.get(13)?),
|
||||
role: row.get(14)?,
|
||||
can_run: row.get::<_, i64>(15)? != 0,
|
||||
can_manage_skills: row.get::<_, i64>(16)? != 0,
|
||||
can_manage_config: row.get::<_, i64>(17)? != 0,
|
||||
created_at: row.get(18)?,
|
||||
updated_at: row.get(19)?,
|
||||
revision: row.get(20)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUiPreferenceRecord> {
|
||||
Ok(UserUiPreferenceRecord {
|
||||
id: row.get(0)?,
|
||||
@@ -281,6 +344,19 @@ fn navigation_recent_target_key(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ai_external_conversation_status(status: &str) -> Result<(), ControlPlaneError> {
|
||||
match status {
|
||||
"active"
|
||||
| "local_deleted"
|
||||
| "remote_deleted"
|
||||
| "remote_delete_failed"
|
||||
| "remote_missing" => Ok(()),
|
||||
_ => Err(ControlPlaneError::InvalidInput(
|
||||
"external conversation status 不合法".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
|
||||
Ok(AiPolicyRecord {
|
||||
id: row.get(0)?,
|
||||
@@ -348,6 +424,29 @@ fn row_to_ai_runtime_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiRuntim
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_ai_external_conversation_binding(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<AiExternalConversationBindingRecord> {
|
||||
Ok(AiExternalConversationBindingRecord {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
workspace_id: row.get(2)?,
|
||||
mnote_session_id: row.get(3)?,
|
||||
acp_session_id: row.get(4)?,
|
||||
agent_id: row.get(5)?,
|
||||
profile: row.get(6)?,
|
||||
provider: row.get(7)?,
|
||||
remote_conversation_id: row.get(8)?,
|
||||
remote_url: row.get(9)?,
|
||||
status: row.get(10)?,
|
||||
metadata_json: row.get(11)?,
|
||||
created_at: row.get(12)?,
|
||||
updated_at: row.get(13)?,
|
||||
deleted_at: row.get(14)?,
|
||||
revision: row.get(15)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
|
||||
let title = serde_json::from_str::<serde_json::Value>(payload_json)
|
||||
.ok()
|
||||
@@ -391,6 +490,50 @@ fn prune_navigation_recent_for_kind(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_ai_agent_profile_access_rows(
|
||||
conn: &Connection,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
p.id, p.agent_id, p.profile_kind, p.owner_user_id, p.base_profile_name,
|
||||
p.isolated_profile_name, p.display_name, p.status, p.created_at, p.updated_at,
|
||||
p.revision,
|
||||
g.id, g.profile_id, g.user_id, g.role, g.can_run, g.can_manage_skills,
|
||||
g.can_manage_config, g.created_at, g.updated_at, g.revision
|
||||
FROM ai_agent_profiles p
|
||||
JOIN ai_agent_profile_grants g ON g.profile_id = p.id
|
||||
WHERE p.agent_id = 'hermes'
|
||||
AND p.status = 'active'
|
||||
AND g.can_run = 1
|
||||
AND (
|
||||
(p.profile_kind = 'personal' AND p.owner_user_id = ?1 AND g.user_id = ?1)
|
||||
OR (p.profile_kind = 'shared' AND (g.user_id = '' OR g.user_id = ?1))
|
||||
)
|
||||
ORDER BY
|
||||
CASE p.profile_kind WHEN 'personal' THEN 0 ELSE 1 END,
|
||||
p.display_name ASC,
|
||||
g.can_manage_skills DESC,
|
||||
g.can_manage_config DESC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![user_id], row_to_ai_agent_profile_access)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(ControlPlaneError::from)?;
|
||||
let mut by_profile = BTreeMap::<String, AiAgentProfileAccessRecord>::new();
|
||||
for row in rows {
|
||||
by_profile
|
||||
.entry(row.profile.id.clone())
|
||||
.and_modify(|existing| {
|
||||
if row.grant.can_manage_skills && !existing.grant.can_manage_skills {
|
||||
*existing = row.clone();
|
||||
}
|
||||
})
|
||||
.or_insert(row);
|
||||
}
|
||||
Ok(by_profile.into_values().collect())
|
||||
}
|
||||
|
||||
impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
|
||||
if input.username.trim().is_empty() {
|
||||
@@ -1461,6 +1604,168 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn ensure_ai_agent_profile_policy(
|
||||
&self,
|
||||
user_id: &str,
|
||||
is_admin: bool,
|
||||
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Err(ControlPlaneError::InvalidInput(
|
||||
"ai agent profile user_id 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = now_text();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_agent_profiles (
|
||||
id, agent_id, profile_kind, owner_user_id, base_profile_name,
|
||||
isolated_profile_name, display_name, status, created_at, updated_at, revision
|
||||
)
|
||||
VALUES ('shared_lite', 'hermes', 'shared', '', 'lite', 'lite', 'Lite', 'active', ?1, ?2, 1)
|
||||
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
|
||||
DO UPDATE SET status = 'active', updated_at = excluded.updated_at",
|
||||
params![now, now],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO ai_agent_profile_grants (
|
||||
id, profile_id, user_id, role, can_run, can_manage_skills,
|
||||
can_manage_config, created_at, updated_at, revision
|
||||
)
|
||||
VALUES ('grant_shared_lite_all', 'shared_lite', '', 'user', 1, 0, 0, ?1, ?2, 1)
|
||||
ON CONFLICT(profile_id, user_id, role)
|
||||
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
|
||||
updated_at = excluded.updated_at",
|
||||
params![now, now],
|
||||
)?;
|
||||
|
||||
// 旧的泛化 OpenClaw 网页问答入口已拆成明确的三类 chat agent。
|
||||
conn.execute(
|
||||
"UPDATE ai_agent_profiles
|
||||
SET status = 'retired', updated_at = ?1
|
||||
WHERE id = 'shared_openclaw_webqa'",
|
||||
params![now],
|
||||
)?;
|
||||
let shared_chat_profiles = [
|
||||
(
|
||||
"shared_deepseek_chat",
|
||||
"deepseek-chat",
|
||||
"openclaw-deepseek-chat",
|
||||
"DeepSeek Chat",
|
||||
),
|
||||
(
|
||||
"shared_gemini_chat",
|
||||
"gemini-chat",
|
||||
"openclaw-gemini-chat",
|
||||
"Gemini Chat",
|
||||
),
|
||||
(
|
||||
"shared_doubao_chat",
|
||||
"doubao-chat",
|
||||
"openclaw-doubao-chat",
|
||||
"豆包 Chat",
|
||||
),
|
||||
];
|
||||
for (profile_id, base_profile, isolated_profile, display_name) in shared_chat_profiles {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_agent_profiles (
|
||||
id, agent_id, profile_kind, owner_user_id, base_profile_name,
|
||||
isolated_profile_name, display_name, status, created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, 'hermes', 'shared', '', ?2, ?3, ?4, 'active', ?5, ?6, 1)
|
||||
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
|
||||
DO UPDATE SET status = 'active', display_name = excluded.display_name,
|
||||
isolated_profile_name = excluded.isolated_profile_name, updated_at = excluded.updated_at",
|
||||
params![profile_id, base_profile, isolated_profile, display_name, now, now],
|
||||
)?;
|
||||
let grant_id = format!("grant_{profile_id}_all");
|
||||
conn.execute(
|
||||
"INSERT INTO ai_agent_profile_grants (
|
||||
id, profile_id, user_id, role, can_run, can_manage_skills,
|
||||
can_manage_config, created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, ?2, '', 'user', 1, 0, 0, ?3, ?4, 1)
|
||||
ON CONFLICT(profile_id, user_id, role)
|
||||
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
|
||||
updated_at = excluded.updated_at",
|
||||
params![grant_id, profile_id, now, now],
|
||||
)?;
|
||||
}
|
||||
|
||||
let user_part = sanitize_profile_component(user_id);
|
||||
let personal_profile_id = format!("usr_{user_part}_default");
|
||||
let personal_profile_name = format!("mnote-u-{user_part}-default");
|
||||
conn.execute(
|
||||
"INSERT INTO ai_agent_profiles (
|
||||
id, agent_id, profile_kind, owner_user_id, base_profile_name,
|
||||
isolated_profile_name, display_name, status, created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, 'hermes', 'personal', ?2, 'default', ?3, '我的 Hermes', 'active', ?4, ?5, 1)
|
||||
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
|
||||
DO UPDATE SET status = 'active', updated_at = excluded.updated_at",
|
||||
params![personal_profile_id, user_id, personal_profile_name, now, now],
|
||||
)?;
|
||||
let personal_grant_id = format!("grant_{personal_profile_id}_owner");
|
||||
conn.execute(
|
||||
"INSERT INTO ai_agent_profile_grants (
|
||||
id, profile_id, user_id, role, can_run, can_manage_skills,
|
||||
can_manage_config, created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, ?2, ?3, 'owner', 1, 1, 1, ?4, ?5, 1)
|
||||
ON CONFLICT(profile_id, user_id, role)
|
||||
DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1,
|
||||
updated_at = excluded.updated_at",
|
||||
params![personal_grant_id, personal_profile_id, user_id, now, now],
|
||||
)?;
|
||||
|
||||
if is_admin {
|
||||
let admin_grant_id = format!("grant_shared_lite_admin_{user_part}");
|
||||
conn.execute(
|
||||
"INSERT INTO ai_agent_profile_grants (
|
||||
id, profile_id, user_id, role, can_run, can_manage_skills,
|
||||
can_manage_config, created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, 'shared_lite', ?2, 'admin', 1, 1, 1, ?3, ?4, 1)
|
||||
ON CONFLICT(profile_id, user_id, role)
|
||||
DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1,
|
||||
updated_at = excluded.updated_at",
|
||||
params![admin_grant_id, user_id, now, now],
|
||||
)?;
|
||||
for (profile_id, _, _, _) in shared_chat_profiles {
|
||||
let admin_chat_grant_id = format!("grant_{profile_id}_admin_{user_part}");
|
||||
conn.execute(
|
||||
"INSERT INTO ai_agent_profile_grants (
|
||||
id, profile_id, user_id, role, can_run, can_manage_skills,
|
||||
can_manage_config, created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, ?2, ?3, 'admin', 1, 0, 0, ?4, ?5, 1)
|
||||
ON CONFLICT(profile_id, user_id, role)
|
||||
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
|
||||
updated_at = excluded.updated_at",
|
||||
params![admin_chat_grant_id, profile_id, user_id, now, now],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
list_ai_agent_profile_access_rows(&conn, user_id)
|
||||
}
|
||||
|
||||
fn resolve_ai_agent_profile(
|
||||
&self,
|
||||
user_id: &str,
|
||||
is_admin: bool,
|
||||
profile_id: &str,
|
||||
) -> Result<Option<AiAgentProfileAccessRecord>, ControlPlaneError> {
|
||||
let profile_id = profile_id.trim();
|
||||
if profile_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let access = self.ensure_ai_agent_profile_policy(user_id, is_admin)?;
|
||||
Ok(access
|
||||
.into_iter()
|
||||
.find(|item| item.profile.id == profile_id && item.grant.can_run))
|
||||
}
|
||||
|
||||
fn upsert_navigation_recent(
|
||||
&self,
|
||||
input: UpsertNavigationRecentInput,
|
||||
@@ -2047,6 +2352,207 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn upsert_ai_external_conversation_binding(
|
||||
&self,
|
||||
input: UpsertAiExternalConversationBindingInput,
|
||||
) -> Result<AiExternalConversationBindingRecord, ControlPlaneError> {
|
||||
let user_id = input.user_id.trim();
|
||||
let mnote_session_id = input.mnote_session_id.trim();
|
||||
let provider = input.provider.trim();
|
||||
let remote_conversation_id = input.remote_conversation_id.trim();
|
||||
if user_id.is_empty()
|
||||
|| mnote_session_id.is_empty()
|
||||
|| provider.is_empty()
|
||||
|| remote_conversation_id.is_empty()
|
||||
{
|
||||
return Err(ControlPlaneError::InvalidInput(
|
||||
"external conversation binding user_id/session_id/provider/remote_id 不能为空"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
|
||||
validate_ai_external_conversation_status(&input.status)?;
|
||||
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = now_text();
|
||||
let existing = conn
|
||||
.query_row(
|
||||
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
|
||||
FROM ai_external_conversation_bindings
|
||||
WHERE user_id = ?1 AND mnote_session_id = ?2 AND provider = ?3
|
||||
LIMIT 1",
|
||||
params![user_id, mnote_session_id, provider],
|
||||
row_to_ai_external_conversation_binding,
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(existing) = existing {
|
||||
let revision = existing.revision + 1;
|
||||
let deleted_at = if input.status == "active" {
|
||||
None
|
||||
} else {
|
||||
existing.deleted_at.or_else(|| Some(now.clone()))
|
||||
};
|
||||
conn.execute(
|
||||
"UPDATE ai_external_conversation_bindings
|
||||
SET workspace_id = ?1, acp_session_id = ?2, agent_id = ?3, profile = ?4, remote_conversation_id = ?5, remote_url = ?6, status = ?7, metadata_json = ?8, updated_at = ?9, deleted_at = ?10, revision = ?11
|
||||
WHERE id = ?12",
|
||||
params![
|
||||
input.workspace_id,
|
||||
input.acp_session_id,
|
||||
input.agent_id,
|
||||
input.profile,
|
||||
remote_conversation_id,
|
||||
input.remote_url,
|
||||
input.status,
|
||||
input.metadata_json,
|
||||
now,
|
||||
deleted_at,
|
||||
revision,
|
||||
existing.id
|
||||
],
|
||||
)?;
|
||||
return Ok(AiExternalConversationBindingRecord {
|
||||
id: existing.id,
|
||||
user_id: existing.user_id,
|
||||
workspace_id: input.workspace_id,
|
||||
mnote_session_id: existing.mnote_session_id,
|
||||
acp_session_id: input.acp_session_id,
|
||||
agent_id: input.agent_id,
|
||||
profile: input.profile,
|
||||
provider: existing.provider,
|
||||
remote_conversation_id: remote_conversation_id.to_string(),
|
||||
remote_url: input.remote_url,
|
||||
status: input.status,
|
||||
metadata_json: input.metadata_json,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
deleted_at,
|
||||
revision,
|
||||
});
|
||||
}
|
||||
|
||||
let deleted_at = if input.status == "active" {
|
||||
None
|
||||
} else {
|
||||
Some(now.clone())
|
||||
};
|
||||
let record = AiExternalConversationBindingRecord {
|
||||
id: input.id.unwrap_or_else(|| new_id("aecb")),
|
||||
user_id: user_id.to_string(),
|
||||
workspace_id: input.workspace_id,
|
||||
mnote_session_id: mnote_session_id.to_string(),
|
||||
acp_session_id: input.acp_session_id,
|
||||
agent_id: input.agent_id,
|
||||
profile: input.profile,
|
||||
provider: provider.to_string(),
|
||||
remote_conversation_id: remote_conversation_id.to_string(),
|
||||
remote_url: input.remote_url,
|
||||
status: input.status,
|
||||
metadata_json: input.metadata_json,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
deleted_at,
|
||||
revision: 1,
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO ai_external_conversation_bindings (id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 1)",
|
||||
params![
|
||||
record.id,
|
||||
record.user_id,
|
||||
record.workspace_id,
|
||||
record.mnote_session_id,
|
||||
record.acp_session_id,
|
||||
record.agent_id,
|
||||
record.profile,
|
||||
record.provider,
|
||||
record.remote_conversation_id,
|
||||
record.remote_url,
|
||||
record.status,
|
||||
record.metadata_json,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
record.deleted_at
|
||||
],
|
||||
)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn find_ai_external_conversation_binding(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
mnote_session_id: &str,
|
||||
provider: &str,
|
||||
) -> Result<Option<AiExternalConversationBindingRecord>, ControlPlaneError> {
|
||||
let user_id = user_id.trim();
|
||||
let mnote_session_id = mnote_session_id.trim();
|
||||
let provider = provider.trim();
|
||||
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
|
||||
FROM ai_external_conversation_bindings
|
||||
WHERE user_id = ?1
|
||||
AND (?2 IS NULL OR workspace_id = ?2)
|
||||
AND mnote_session_id = ?3
|
||||
AND provider = ?4
|
||||
LIMIT 1",
|
||||
params![user_id, workspace_id, mnote_session_id, provider],
|
||||
row_to_ai_external_conversation_binding,
|
||||
)
|
||||
.optional()
|
||||
.map_err(ControlPlaneError::from)
|
||||
}
|
||||
|
||||
fn mark_ai_external_conversation_binding_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
mnote_session_id: &str,
|
||||
provider: &str,
|
||||
status: &str,
|
||||
metadata_json: Option<&str>,
|
||||
) -> Result<usize, ControlPlaneError> {
|
||||
let user_id = user_id.trim();
|
||||
let mnote_session_id = mnote_session_id.trim();
|
||||
let provider = provider.trim();
|
||||
validate_ai_external_conversation_status(status)?;
|
||||
let metadata_json = metadata_json.unwrap_or("{}");
|
||||
serde_json::from_str::<serde_json::Value>(metadata_json)?;
|
||||
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = now_text();
|
||||
let deleted_at = if status == "active" {
|
||||
None
|
||||
} else {
|
||||
Some(now.clone())
|
||||
};
|
||||
let changed = conn.execute(
|
||||
"UPDATE ai_external_conversation_bindings
|
||||
SET status = ?1, metadata_json = ?2, updated_at = ?3, deleted_at = COALESCE(?4, deleted_at), revision = revision + 1
|
||||
WHERE user_id = ?5
|
||||
AND (?6 IS NULL OR workspace_id = ?6)
|
||||
AND mnote_session_id = ?7
|
||||
AND provider = ?8",
|
||||
params![
|
||||
status,
|
||||
metadata_json,
|
||||
now,
|
||||
deleted_at,
|
||||
user_id,
|
||||
workspace_id,
|
||||
mnote_session_id,
|
||||
provider
|
||||
],
|
||||
)?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn rename_ai_runtime_session(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -2195,9 +2701,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::model::{
|
||||
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
|
||||
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
||||
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput,
|
||||
UpsertUserUiPreferenceInput,
|
||||
CreatePasswordIdentityInput, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
|
||||
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
|
||||
UpsertSyncStateInput, UpsertUserUiPreferenceInput,
|
||||
};
|
||||
|
||||
fn store() -> SqliteControlPlaneStore {
|
||||
@@ -2716,6 +3222,95 @@ mod tests {
|
||||
assert!(bob_sidebar_preferences.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_agent_profile_policy_provisions_personal_and_shared_boundaries() {
|
||||
let store = store();
|
||||
create_user(&store, "alice");
|
||||
create_user(&store, "bob");
|
||||
store
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("admin".to_string()),
|
||||
email: Some("admin@example.com".to_string()),
|
||||
username: "admin".to_string(),
|
||||
display_name: "admin".to_string(),
|
||||
role: Some("admin".to_string()),
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("admin user");
|
||||
|
||||
let alice_profiles = store
|
||||
.ensure_ai_agent_profile_policy("alice", false)
|
||||
.expect("alice profiles");
|
||||
assert_eq!(alice_profiles.len(), 5);
|
||||
let alice_personal = alice_profiles
|
||||
.iter()
|
||||
.find(|item| item.profile.profile_kind == "personal")
|
||||
.expect("alice personal profile");
|
||||
assert_eq!(
|
||||
alice_personal.profile.owner_user_id.as_deref(),
|
||||
Some("alice")
|
||||
);
|
||||
assert!(alice_personal.grant.can_manage_skills);
|
||||
let alice_shared = alice_profiles
|
||||
.iter()
|
||||
.find(|item| item.profile.id == "shared_lite")
|
||||
.expect("alice shared profile");
|
||||
assert!(alice_shared.grant.can_run);
|
||||
assert!(!alice_shared.grant.can_manage_skills);
|
||||
for (profile_id, isolated_profile, display_name) in [
|
||||
(
|
||||
"shared_deepseek_chat",
|
||||
"openclaw-deepseek-chat",
|
||||
"DeepSeek Chat",
|
||||
),
|
||||
("shared_gemini_chat", "openclaw-gemini-chat", "Gemini Chat"),
|
||||
("shared_doubao_chat", "openclaw-doubao-chat", "豆包 Chat"),
|
||||
] {
|
||||
let alice_chat = alice_profiles
|
||||
.iter()
|
||||
.find(|item| item.profile.id == profile_id)
|
||||
.expect("alice shared chat profile");
|
||||
assert_eq!(alice_chat.profile.isolated_profile_name, isolated_profile);
|
||||
assert_eq!(alice_chat.profile.display_name, display_name);
|
||||
assert!(alice_chat.grant.can_run);
|
||||
assert!(!alice_chat.grant.can_manage_skills);
|
||||
assert!(!alice_chat.grant.can_manage_config);
|
||||
}
|
||||
|
||||
let bob_profiles = store
|
||||
.ensure_ai_agent_profile_policy("bob", false)
|
||||
.expect("bob profiles");
|
||||
let bob_personal = bob_profiles
|
||||
.iter()
|
||||
.find(|item| item.profile.profile_kind == "personal")
|
||||
.expect("bob personal profile");
|
||||
assert_ne!(bob_personal.profile.id, alice_personal.profile.id);
|
||||
assert!(store
|
||||
.resolve_ai_agent_profile("bob", false, &alice_personal.profile.id)
|
||||
.expect("resolve cross user")
|
||||
.is_none());
|
||||
|
||||
let admin_shared = store
|
||||
.resolve_ai_agent_profile("admin", true, "shared_lite")
|
||||
.expect("admin shared")
|
||||
.expect("admin can see shared");
|
||||
assert!(admin_shared.grant.can_manage_skills);
|
||||
assert!(admin_shared.grant.can_manage_config);
|
||||
for profile_id in [
|
||||
"shared_deepseek_chat",
|
||||
"shared_gemini_chat",
|
||||
"shared_doubao_chat",
|
||||
] {
|
||||
let admin_chat = store
|
||||
.resolve_ai_agent_profile("admin", true, profile_id)
|
||||
.expect("admin chat")
|
||||
.expect("admin can see shared chat profile");
|
||||
assert!(admin_chat.grant.can_run);
|
||||
assert!(!admin_chat.grant.can_manage_skills);
|
||||
assert!(!admin_chat.grant.can_manage_config);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() {
|
||||
let store = store();
|
||||
@@ -3025,6 +3620,79 @@ mod tests {
|
||||
assert!(runs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_external_conversation_binding_is_user_scoped_and_statused() {
|
||||
let store = store();
|
||||
create_user(&store, "doubao_user_a");
|
||||
create_user(&store, "doubao_user_b");
|
||||
|
||||
let binding = store
|
||||
.upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput {
|
||||
id: None,
|
||||
user_id: "doubao_user_a".to_string(),
|
||||
workspace_id: Some("ws_1".to_string()),
|
||||
mnote_session_id: "chatonly_session_1".to_string(),
|
||||
acp_session_id: Some("acp_session_1".to_string()),
|
||||
agent_id: "chat_only".to_string(),
|
||||
profile: "openclaw-doubao-chat".to_string(),
|
||||
provider: "doubao-web".to_string(),
|
||||
remote_conversation_id: "38428454119180290".to_string(),
|
||||
remote_url: Some("https://www.doubao.com/chat/38428454119180290".to_string()),
|
||||
status: "active".to_string(),
|
||||
metadata_json: "{\"source\":\"provider.conversation.bound\"}".to_string(),
|
||||
})
|
||||
.expect("upsert external conversation binding");
|
||||
|
||||
assert_eq!(binding.status, "active");
|
||||
assert_eq!(binding.remote_conversation_id, "38428454119180290");
|
||||
|
||||
let found = store
|
||||
.find_ai_external_conversation_binding(
|
||||
"doubao_user_a",
|
||||
Some("ws_1"),
|
||||
"chatonly_session_1",
|
||||
"doubao-web",
|
||||
)
|
||||
.expect("find binding")
|
||||
.expect("binding exists");
|
||||
assert_eq!(found.id, binding.id);
|
||||
assert_eq!(found.acp_session_id.as_deref(), Some("acp_session_1"));
|
||||
|
||||
let other_user = store
|
||||
.find_ai_external_conversation_binding(
|
||||
"doubao_user_b",
|
||||
Some("ws_1"),
|
||||
"chatonly_session_1",
|
||||
"doubao-web",
|
||||
)
|
||||
.expect("find other user binding");
|
||||
assert!(other_user.is_none());
|
||||
|
||||
let changed = store
|
||||
.mark_ai_external_conversation_binding_status(
|
||||
"doubao_user_a",
|
||||
Some("ws_1"),
|
||||
"chatonly_session_1",
|
||||
"doubao-web",
|
||||
"local_deleted",
|
||||
Some("{\"reason\":\"mnote_session_deleted\"}"),
|
||||
)
|
||||
.expect("mark local deleted");
|
||||
assert_eq!(changed, 1);
|
||||
|
||||
let deleted = store
|
||||
.find_ai_external_conversation_binding(
|
||||
"doubao_user_a",
|
||||
Some("ws_1"),
|
||||
"chatonly_session_1",
|
||||
"doubao-web",
|
||||
)
|
||||
.expect("find deleted binding")
|
||||
.expect("binding remains auditable");
|
||||
assert_eq!(deleted.status, "local_deleted");
|
||||
assert!(deleted.deleted_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_lookup_resolves_active_user_by_token_hash() {
|
||||
let store = store();
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
use crate::error::ControlPlaneError;
|
||||
use crate::model::{
|
||||
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput,
|
||||
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
|
||||
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
|
||||
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord,
|
||||
OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
|
||||
SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
||||
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
|
||||
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
||||
AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
|
||||
AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput,
|
||||
AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
||||
CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
|
||||
DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
|
||||
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
|
||||
SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
|
||||
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
|
||||
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
|
||||
UserUiPreferenceRecord, WorkspaceRecord,
|
||||
};
|
||||
|
||||
pub trait ControlPlaneStore: Send + Sync {
|
||||
@@ -136,6 +138,19 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
source_kind: Option<&str>,
|
||||
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
|
||||
|
||||
fn ensure_ai_agent_profile_policy(
|
||||
&self,
|
||||
user_id: &str,
|
||||
is_admin: bool,
|
||||
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError>;
|
||||
|
||||
fn resolve_ai_agent_profile(
|
||||
&self,
|
||||
user_id: &str,
|
||||
is_admin: bool,
|
||||
profile_id: &str,
|
||||
) -> Result<Option<AiAgentProfileAccessRecord>, ControlPlaneError>;
|
||||
|
||||
fn upsert_navigation_recent(
|
||||
&self,
|
||||
input: UpsertNavigationRecentInput,
|
||||
@@ -196,6 +211,29 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
limit: usize,
|
||||
) -> Result<Vec<AiRuntimeEventRecord>, ControlPlaneError>;
|
||||
|
||||
fn upsert_ai_external_conversation_binding(
|
||||
&self,
|
||||
input: UpsertAiExternalConversationBindingInput,
|
||||
) -> Result<AiExternalConversationBindingRecord, ControlPlaneError>;
|
||||
|
||||
fn find_ai_external_conversation_binding(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
mnote_session_id: &str,
|
||||
provider: &str,
|
||||
) -> Result<Option<AiExternalConversationBindingRecord>, ControlPlaneError>;
|
||||
|
||||
fn mark_ai_external_conversation_binding_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
mnote_session_id: &str,
|
||||
provider: &str,
|
||||
status: &str,
|
||||
metadata_json: Option<&str>,
|
||||
) -> Result<usize, ControlPlaneError>;
|
||||
|
||||
fn rename_ai_runtime_session(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -264,10 +264,18 @@ impl DocumentBuffer {
|
||||
self.base_content_hash = Some(content_hash);
|
||||
self.current_content_hash = None;
|
||||
self.dirty_state = DocBufferDirtyState::Clean;
|
||||
if write_intent_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
|
||||
if write_intent_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
self.last_write_intent_id = write_intent_id;
|
||||
}
|
||||
if save_operation_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
|
||||
if save_operation_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
self.last_save_operation_id = save_operation_id;
|
||||
}
|
||||
self.last_saved_at = Some(
|
||||
@@ -922,7 +930,10 @@ mod tests {
|
||||
request.expected_file_version.as_deref(),
|
||||
Some("local-md:local-md:README.md:1:2:hash")
|
||||
);
|
||||
assert_eq!(request.write_intent_id.as_deref(), Some("intent:editor:abc"));
|
||||
assert_eq!(
|
||||
request.write_intent_id.as_deref(),
|
||||
Some("intent:editor:abc")
|
||||
);
|
||||
assert_eq!(request.save_operation_id.as_deref(), Some("save:op:abc"));
|
||||
assert_eq!(request.content_format, "editorBlocks");
|
||||
assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
|
||||
|
||||
@@ -31,3 +31,4 @@ comrak = { version = "0.52", default-features = false }
|
||||
notify = "8.2.0"
|
||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
zip = "2"
|
||||
|
||||
@@ -64,6 +64,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
const resourceTabMru = { primary: [], secondary: [] };
|
||||
const resourceTabMruMax = 20;
|
||||
const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
|
||||
let onlyofficeBridgeReadyListenerBound = false;
|
||||
|
||||
const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary';
|
||||
|
||||
@@ -127,6 +128,30 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const currentFileTreeWorkspacePath = () => {
|
||||
try {
|
||||
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path]');
|
||||
const reader = window.__mnoteFileTreeRuntime?.readWorkspacePathFromRow;
|
||||
if (row instanceof HTMLElement && typeof reader === 'function') {
|
||||
const workspacePath = reader(row);
|
||||
if (workspacePath && typeof workspacePath === 'object') {
|
||||
return {
|
||||
...workspacePath,
|
||||
sourceKind: String(workspacePath.sourceKind || row.getAttribute('data-source-kind') || '').trim(),
|
||||
rootUri: String(workspacePath.rootUri || row.getAttribute('data-root-uri') || '').trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (row instanceof HTMLElement) {
|
||||
return {
|
||||
sourceKind: String(row.getAttribute('data-source-kind') || '').trim(),
|
||||
rootUri: String(row.getAttribute('data-root-uri') || '').trim(),
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentWebShellDocumentId = () => {
|
||||
const explicit = typeof currentDocumentId === 'function' ? String(currentDocumentId() || '').trim() : '';
|
||||
if (explicit) return explicit;
|
||||
@@ -157,17 +182,23 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
|
||||
const currentWebShellSourceKind = () => {
|
||||
try {
|
||||
return currentUrl().searchParams.get('sourceKind') || '';
|
||||
return currentUrl().searchParams.get('sourceKind')
|
||||
|| document.body?.dataset?.mnoteSourceKind
|
||||
|| currentFileTreeWorkspacePath()?.sourceKind
|
||||
|| '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
return document.body?.dataset?.mnoteSourceKind || currentFileTreeWorkspacePath()?.sourceKind || '';
|
||||
}
|
||||
};
|
||||
|
||||
const currentWebShellRootUri = () => {
|
||||
try {
|
||||
return currentUrl().searchParams.get('rootUri') || '';
|
||||
return currentUrl().searchParams.get('rootUri')
|
||||
|| document.body?.dataset?.mnoteRootUri
|
||||
|| currentFileTreeWorkspacePath()?.rootUri
|
||||
|| '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
return document.body?.dataset?.mnoteRootUri || currentFileTreeWorkspacePath()?.rootUri || '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,7 +227,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
resourceKind: String(fromInput.resourceKind || fromInput.objectKind || resourceKind || '').trim(),
|
||||
};
|
||||
}
|
||||
const id = String(objectIdentity || documentId || assetId || relativePath || '').trim();
|
||||
const structuredObjectIdentity = objectIdentity && typeof objectIdentity === 'object' ? objectIdentity : null;
|
||||
const objectIdentityText = structuredObjectIdentity ? '' : String(objectIdentity || '').trim();
|
||||
const id = String(objectIdentityText || documentId || assetId || relativePath || '').trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
schema: 'mnote.workspace_path.v1',
|
||||
@@ -205,7 +238,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
rootUri: String(rootUri || '').trim(),
|
||||
relativePath: String(relativePath || '').trim(),
|
||||
documentId: String(documentId || '').trim(),
|
||||
objectIdentity: String(objectIdentity || '').trim(),
|
||||
objectIdentity: structuredObjectIdentity || objectIdentityText,
|
||||
assetId: String(assetId || '').trim(),
|
||||
resourceKind: String(resourceKind || '').trim(),
|
||||
};
|
||||
@@ -371,6 +404,18 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
const officeBridgeDebugForEntry = (entry) => {
|
||||
if (!entry?.panel || !(entry.panel instanceof HTMLElement)) return null;
|
||||
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
|
||||
if (!(frame instanceof HTMLIFrameElement)) return null;
|
||||
try {
|
||||
const debug = frame.contentWindow?.__MNOTE_ONLYOFFICE_DEBUG__;
|
||||
return debug && typeof debug === 'object' ? debug : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
|
||||
const active = entry?.tab instanceof HTMLElement
|
||||
? entry.tab.getAttribute('aria-selected') === 'true'
|
||||
@@ -384,6 +429,13 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim();
|
||||
const kind = normalizeResourceTabKind(entry);
|
||||
const dirtyState = resourceTabCloseGuardReason(entry?.session);
|
||||
const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null;
|
||||
const onlyofficeSessionId = String(
|
||||
officeBridgeDebug?.bridgeSessionId
|
||||
|| entry?.onlyofficeSessionId
|
||||
|| entry?.bridgeSessionId
|
||||
|| '',
|
||||
).trim();
|
||||
return {
|
||||
objectIdentity,
|
||||
workspacePath: buildWorkspacePath({
|
||||
@@ -411,6 +463,11 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
dirtyGuard: dirtyState,
|
||||
assetId,
|
||||
path: relativePath,
|
||||
onlyofficeSessionId,
|
||||
bridgeSessionId: onlyofficeSessionId,
|
||||
bridgeSessionReady: Boolean(onlyofficeSessionId),
|
||||
bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(),
|
||||
bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(),
|
||||
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
|
||||
preview: false,
|
||||
pinned: false,
|
||||
@@ -427,6 +484,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
const rootUri = currentWebShellRootUri();
|
||||
const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : '';
|
||||
const objectIdentity = `page:${paneRole}`;
|
||||
const workspaceObjectIdentity = {
|
||||
objectKind: 'page',
|
||||
documentId,
|
||||
blockId: null,
|
||||
assetId: null,
|
||||
};
|
||||
const active = nodes.pageTab instanceof HTMLElement
|
||||
? nodes.pageTab.getAttribute('aria-selected') === 'true'
|
||||
: false;
|
||||
@@ -443,7 +506,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
rootUri,
|
||||
relativePath,
|
||||
documentId,
|
||||
objectIdentity,
|
||||
objectIdentity: workspaceObjectIdentity,
|
||||
assetId: '',
|
||||
resourceKind: 'page',
|
||||
}),
|
||||
@@ -1035,6 +1098,17 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
markIntendedSlashRoot(entry);
|
||||
};
|
||||
|
||||
const ensureOnlyofficeBridgeReadyListener = () => {
|
||||
if (onlyofficeBridgeReadyListenerBound) return;
|
||||
onlyofficeBridgeReadyListenerBound = true;
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
const detail = event.data && typeof event.data === 'object' ? event.data : null;
|
||||
if (!detail || detail.type !== 'mnote:onlyoffice-bridge-ready') return;
|
||||
syncOpenEditorsSnapshot();
|
||||
});
|
||||
};
|
||||
|
||||
const openPassiveResourceTab = (entry, input) => {
|
||||
const href = String(input.officeUrl || input.href || '').trim();
|
||||
if (entry.kind === 'image') {
|
||||
@@ -1051,6 +1125,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
const frame = entry.panel.querySelector('iframe');
|
||||
if (frame instanceof HTMLIFrameElement) {
|
||||
frame.title = entry.title;
|
||||
if (entry.kind === 'office') {
|
||||
ensureOnlyofficeBridgeReadyListener();
|
||||
frame.addEventListener('load', () => {
|
||||
syncOpenEditorsSnapshot();
|
||||
}, { once: true });
|
||||
}
|
||||
frame.src = href;
|
||||
}
|
||||
installPassiveResourceWatch(entry);
|
||||
|
||||
@@ -15,6 +15,18 @@ export const firstNonEmptyText = (...values) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
export const normalizeMindmapDimension = (value, kind) => {
|
||||
const raw = typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? Number(value.trim().replace(/px$/i, ''))
|
||||
: NaN;
|
||||
if (!Number.isFinite(raw)) return null;
|
||||
const rounded = Math.round(raw);
|
||||
const min = kind === 'height' ? 240 : 900;
|
||||
return rounded >= min ? rounded : null;
|
||||
};
|
||||
|
||||
// 过渡适配(TODO step-4):legacy→Tiptap inline marks 转换函数组。
|
||||
// AST/block 迁移 complete 后,前端应直接消费 block document 中的
|
||||
// tiptap 格式 marks(已由 Rust 侧 local_markdown_parser 输出),
|
||||
@@ -125,19 +137,26 @@ export const legacyBlockToTiptap = (block, index = 0) => {
|
||||
}
|
||||
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
|
||||
if (type === 'mindmap') {
|
||||
const attrs = block?.attrs && typeof block.attrs === 'object' ? block.attrs : null;
|
||||
const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null;
|
||||
const mindmapId = firstNonEmptyText(
|
||||
block?.props?.mindmapId,
|
||||
block?.props?.mindmap_id,
|
||||
block?.props?.sourcePath,
|
||||
block?.props?.source_path,
|
||||
attrs?.mindmapId,
|
||||
attrs?.mindmap_id,
|
||||
attrs?.sourcePath,
|
||||
attrs?.source_path,
|
||||
block?.mindmapId,
|
||||
block?.mindmap_id,
|
||||
data?.mindmapId,
|
||||
data?.mindmap_id,
|
||||
data?.id
|
||||
);
|
||||
const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
|
||||
const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
|
||||
const mindmapWidth = normalizeMindmapDimension(block?.props?.mindmapWidth ?? block?.props?.mindmap_width ?? attrs?.mindmapWidth ?? attrs?.mindmap_width ?? data?.mindmapWidth ?? data?.mindmap_width, 'width');
|
||||
const mindmapHeight = normalizeMindmapDimension(block?.props?.mindmapHeight ?? block?.props?.mindmap_height ?? attrs?.mindmapHeight ?? attrs?.mindmap_height ?? data?.mindmapHeight ?? data?.mindmap_height, 'height');
|
||||
return {
|
||||
type: 'paragraph',
|
||||
attrs: withTextAlign({
|
||||
@@ -145,6 +164,8 @@ export const legacyBlockToTiptap = (block, index = 0) => {
|
||||
mnoteBlockType: 'mindmap',
|
||||
mindmapId,
|
||||
rootNodeId,
|
||||
...(mindmapWidth !== null ? { mindmapWidth } : {}),
|
||||
...(mindmapHeight !== null ? { mindmapHeight } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -225,7 +246,9 @@ export const mindmapDomDescriptors = (root) => {
|
||||
const rootNodeId = typeof node.dataset.mnoteRootNodeId === 'string' && node.dataset.mnoteRootNodeId.trim()
|
||||
? node.dataset.mnoteRootNodeId.trim()
|
||||
: 'root';
|
||||
return [{ mindmapId, rootNodeId }];
|
||||
const mindmapWidth = normalizeMindmapDimension(node.dataset.mnoteMindmapWidth, 'width');
|
||||
const mindmapHeight = normalizeMindmapDimension(node.dataset.mnoteMindmapHeight, 'height');
|
||||
return [{ mindmapId, rootNodeId, mindmapWidth, mindmapHeight }];
|
||||
});
|
||||
};
|
||||
|
||||
@@ -246,6 +269,12 @@ export const hydrateMindmapAttrsFromDom = (tiptapDocument, root) => {
|
||||
if (typeof node.attrs.rootNodeId !== 'string' || !node.attrs.rootNodeId.trim()) {
|
||||
node.attrs.rootNodeId = descriptor.rootNodeId;
|
||||
}
|
||||
if (normalizeMindmapDimension(node.attrs.mindmapWidth, 'width') === null && descriptor.mindmapWidth !== null) {
|
||||
node.attrs.mindmapWidth = descriptor.mindmapWidth;
|
||||
}
|
||||
if (normalizeMindmapDimension(node.attrs.mindmapHeight, 'height') === null && descriptor.mindmapHeight !== null) {
|
||||
node.attrs.mindmapHeight = descriptor.mindmapHeight;
|
||||
}
|
||||
}
|
||||
return tiptapDocument;
|
||||
};
|
||||
@@ -445,20 +474,17 @@ export const localizeTiptapAssetUrls = (node, context) => {
|
||||
};
|
||||
|
||||
export const pageBodyTiptapDocumentSource = (body, fallbackText = '') => {
|
||||
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||
if (blockDocument) return 'page_aggregate.block_document';
|
||||
if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) {
|
||||
return 'local_markdown.content';
|
||||
}
|
||||
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||
if (blockDocument) return 'page_aggregate.block_document';
|
||||
if (body?.content) return 'compat.legacy_content';
|
||||
return fallbackText ? 'degraded.fallback_text' : 'empty';
|
||||
};
|
||||
|
||||
export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
|
||||
const projectionContext = { ...(context || {}), body };
|
||||
if (pageBodyTiptapDocumentSource(body, fallbackText) === 'local_markdown.content') {
|
||||
return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), projectionContext);
|
||||
}
|
||||
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), projectionContext);
|
||||
return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), projectionContext);
|
||||
@@ -533,10 +559,14 @@ export const mindmapPropsFromAttrs = (attrs, fallbackMindmapId) => {
|
||||
);
|
||||
const rootNodeId = firstNonEmptyText(attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
|
||||
const projectionVersion = Number(attrs?.projectionVersion ?? attrs?.projection_version);
|
||||
const mindmapWidth = normalizeMindmapDimension(attrs?.mindmapWidth ?? attrs?.mindmap_width ?? data?.mindmapWidth ?? data?.mindmap_width, 'width');
|
||||
const mindmapHeight = normalizeMindmapDimension(attrs?.mindmapHeight ?? attrs?.mindmap_height ?? data?.mindmapHeight ?? data?.mindmap_height, 'height');
|
||||
return {
|
||||
mindmapId,
|
||||
rootNodeId,
|
||||
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
|
||||
...(mindmapWidth !== null ? { mindmapWidth } : {}),
|
||||
...(mindmapHeight !== null ? { mindmapHeight } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ function fileTreeRowKind(row) {
|
||||
|
||||
function fileTreeRowDocumentId(row) {
|
||||
if (!(row instanceof HTMLElement)) return '';
|
||||
return String(row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '').trim();
|
||||
return String(
|
||||
row.getAttribute('data-document-id')
|
||||
|| row.getAttribute('data-doc-id')
|
||||
|| row.getAttribute('data-owner-document-id')
|
||||
|| '',
|
||||
).trim();
|
||||
}
|
||||
|
||||
function fileTreeRowAssetId(row, deps) {
|
||||
@@ -98,6 +103,11 @@ function readWorkspacePathFromRow(row, deps) {
|
||||
var documentId = fileTreeRowDocumentId(row);
|
||||
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
||||
var rowKind = fileTreeRowKind(row);
|
||||
if (!documentId && (rowKind === 'folder' || objectKind === 'index') && relativePath) {
|
||||
documentId = 'local-dir:' + relativePath.replace(/^\/+/, '').split('/').map(function(segment) {
|
||||
return encodeURIComponent(segment).replace(/%20/g, '~20');
|
||||
}).join('~2F');
|
||||
}
|
||||
var sourceKind = String(row.getAttribute('data-source-kind') || currentSourceKind() || '').trim();
|
||||
var rootUri = String(row.getAttribute('data-root-uri') || currentRootUri() || '').trim();
|
||||
return {
|
||||
|
||||
@@ -410,7 +410,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim());
|
||||
return;
|
||||
}
|
||||
var requestedOfficeMode = forceNewWindow || forceEditMode ? 'edit' : 'view';
|
||||
var requestedOfficeMode = forceEditMode ? 'edit' : 'view';
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, requestedOfficeMode);
|
||||
if (localOfficeUrl) {
|
||||
if (!forceNewWindow && await openLocalResourceInActiveTab({
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export function createSidebarPageAiMarkdownRuntime(context) {
|
||||
const { escapeHtml } = context;
|
||||
|
||||
function textFromUnknown(value) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' ');
|
||||
if (typeof value !== 'object') return '';
|
||||
var parts = [];
|
||||
['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
var text = textFromUnknown(value[key]);
|
||||
if (text) parts.push(text);
|
||||
}
|
||||
});
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function renderPageAiMarkdownInline(text) {
|
||||
var html = escapeHtml(String(text || ''));
|
||||
var codeSpans = [];
|
||||
html = html.replace(/`([^`\n]+)`/g, function(_, code) {
|
||||
var key = '\u0000CODE' + codeSpans.length + '\u0000';
|
||||
codeSpans.push('<code>' + code + '</code>');
|
||||
return key;
|
||||
});
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
codeSpans.forEach(function(value, index) {
|
||||
html = html.replace('\u0000CODE' + index + '\u0000', value);
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderPageAiMarkdown(content) {
|
||||
var lines = String(content || '').replace(/\r\n?/g, '\n').split('\n');
|
||||
var blocks = [];
|
||||
var index = 0;
|
||||
function isBlockBoundary(line) {
|
||||
return !line.trim() ||
|
||||
/^```/.test(line.trim()) ||
|
||||
/^#{1,6}\s+/.test(line) ||
|
||||
/^\s*[-*]\s+/.test(line) ||
|
||||
/^\s*\d+[.)]\s+/.test(line);
|
||||
}
|
||||
while (index < lines.length) {
|
||||
var line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^```/.test(line.trim())) {
|
||||
index += 1;
|
||||
var codeLines = [];
|
||||
while (index < lines.length && !/^```/.test(lines[index].trim())) {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
blocks.push('<pre><code>' + escapeHtml(codeLines.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
var heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
var level = Math.min(6, heading[1].length);
|
||||
blocks.push('<h' + level + '>' + renderPageAiMarkdownInline(heading[2]) + '</h' + level + '>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^\s*[-*]\s+/.test(line)) {
|
||||
var unordered = [];
|
||||
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
|
||||
unordered.push('<li>' + renderPageAiMarkdownInline(lines[index].replace(/^\s*[-*]\s+/, '')) + '</li>');
|
||||
index += 1;
|
||||
}
|
||||
blocks.push('<ul>' + unordered.join('') + '</ul>');
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\d+[.)]\s+/.test(line)) {
|
||||
var ordered = [];
|
||||
while (index < lines.length && /^\s*\d+[.)]\s+/.test(lines[index])) {
|
||||
ordered.push('<li>' + renderPageAiMarkdownInline(lines[index].replace(/^\s*\d+[.)]\s+/, '')) + '</li>');
|
||||
index += 1;
|
||||
}
|
||||
blocks.push('<ol>' + ordered.join('') + '</ol>');
|
||||
continue;
|
||||
}
|
||||
var paragraph = [];
|
||||
while (index < lines.length && !isBlockBoundary(lines[index])) {
|
||||
paragraph.push(renderPageAiMarkdownInline(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
if (paragraph.length) {
|
||||
blocks.push('<p>' + paragraph.join('<br />') + '</p>');
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return blocks.join('') || escapeHtml(String(content || ''));
|
||||
}
|
||||
|
||||
return {
|
||||
textFromUnknown,
|
||||
renderPageAiMarkdown,
|
||||
renderPageAiMarkdownInline
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
export function createSidebarPageAiPermissionRuntime(context) {
|
||||
const {
|
||||
documentRef,
|
||||
pageAiPreviewValue,
|
||||
pageUiState,
|
||||
renderPageAiConversation,
|
||||
} = context;
|
||||
const doc = documentRef || document;
|
||||
|
||||
function pageAiPermissionMessage(payload, eventType) {
|
||||
payload = payload && typeof payload === 'object' ? payload : {};
|
||||
var permissionId = String(payload.permissionId || payload.permission_id || payload.id || ('perm_' + Date.now())).trim();
|
||||
var toolName = String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim();
|
||||
var args = payload.arguments || payload.args || payload.input || payload.params || payload;
|
||||
var decision = String(payload.decision || payload.result || '').trim();
|
||||
if (!decision && eventType === 'permission.denied') decision = 'denied';
|
||||
if (!decision && eventType === 'permission.allowed') decision = 'allowed';
|
||||
return {
|
||||
role: 'tool',
|
||||
kind: 'permission',
|
||||
permissionId: permissionId,
|
||||
toolName: toolName,
|
||||
argsSummary: pageAiPreviewValue(args),
|
||||
content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'),
|
||||
resolved: decision === 'denied' || decision === 'allowed',
|
||||
decision: decision
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiApplyPermissionEvent(eventName, payloadText) {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(payloadText || 'null');
|
||||
} catch (_) {
|
||||
payload = {};
|
||||
}
|
||||
var message = pageAiPermissionMessage(payload, eventName);
|
||||
var existing = pageUiState.pageAiMessages.find(function(item) {
|
||||
return item.kind === 'permission' && item.permissionId === message.permissionId;
|
||||
});
|
||||
if (existing) {
|
||||
Object.assign(existing, message);
|
||||
} else {
|
||||
pageUiState.pageAiMessages.push(message);
|
||||
}
|
||||
pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) {
|
||||
return item.permissionId !== message.permissionId;
|
||||
}).concat([message]).slice(-20);
|
||||
if (!message.resolved) {
|
||||
pageAiShowPermissionDialog(message);
|
||||
} else {
|
||||
pageAiHidePermissionDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiResolvePermission(permissionId, decision) {
|
||||
permissionId = String(permissionId || '').trim();
|
||||
if (!permissionId) return;
|
||||
var runId = pageUiState.pageAiCurrentRunId;
|
||||
if (runId) {
|
||||
fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ permissionId: permissionId, decision: decision })
|
||||
}).then(function(response) {
|
||||
if (!response.ok) console.warn('resolve-permission 后端返回异常', response.status);
|
||||
}).catch(function(err) {
|
||||
console.warn('resolve-permission 请求失败', err);
|
||||
});
|
||||
} else {
|
||||
console.warn('resolve-permission: 无活跃 runId,只能本地更新');
|
||||
}
|
||||
pageUiState.pageAiMessages.forEach(function(item) {
|
||||
if (item.kind === 'permission' && item.permissionId === permissionId) {
|
||||
item.resolved = true;
|
||||
item.decision = decision;
|
||||
item.content = decision === 'allow' ? '已允许权限请求' : '已拒绝权限请求';
|
||||
}
|
||||
});
|
||||
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (dialog instanceof HTMLElement) dialog.hidden = true;
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function pageAiHidePermissionDialog() {
|
||||
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (dialog instanceof HTMLElement) dialog.hidden = true;
|
||||
}
|
||||
|
||||
function pageAiShowPermissionDialog(message) {
|
||||
if (!message || message.kind !== 'permission') return;
|
||||
if (message.resolved) {
|
||||
pageAiHidePermissionDialog();
|
||||
return;
|
||||
}
|
||||
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (!(dialog instanceof HTMLElement)) {
|
||||
dialog = doc.createElement('div');
|
||||
dialog.className = 'wolai-page-ai-permission-dialog';
|
||||
dialog.setAttribute('data-page-ai-permission-dialog', 'true');
|
||||
dialog.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-permission-panel" role="dialog" aria-modal="false">' +
|
||||
'<div class="wolai-page-ai-memory-title" data-page-ai-permission-tool></div>' +
|
||||
'<div class="wolai-page-ai-tool-meta" data-page-ai-permission-args></div>' +
|
||||
'<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-dialog-action>允许</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-dialog-action>拒绝</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
doc.body.appendChild(dialog);
|
||||
}
|
||||
var tool = dialog.querySelector('[data-page-ai-permission-tool]');
|
||||
if (tool instanceof HTMLElement) tool.textContent = message.toolName || 'session/request_permission';
|
||||
var args = dialog.querySelector('[data-page-ai-permission-args]');
|
||||
if (args instanceof HTMLElement) args.textContent = message.argsSummary || message.content || '';
|
||||
dialog.querySelectorAll('[data-page-ai-permission-dialog-action]').forEach(function(button) {
|
||||
if (button instanceof HTMLButtonElement) {
|
||||
button.setAttribute('data-page-ai-permission-id', message.permissionId || '');
|
||||
button.disabled = Boolean(message.resolved);
|
||||
}
|
||||
});
|
||||
dialog.hidden = false;
|
||||
}
|
||||
|
||||
return {
|
||||
pageAiPermissionMessage,
|
||||
pageAiApplyPermissionEvent,
|
||||
pageAiResolvePermission,
|
||||
pageAiHidePermissionDialog,
|
||||
pageAiShowPermissionDialog
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
export function createSidebarPageAiProfileRuntime(context) {
|
||||
const {
|
||||
chatOnlyProfileRegistry,
|
||||
documentRef,
|
||||
pageAiAgentRecord,
|
||||
pageAiCurrentAgentId,
|
||||
pageAiNormalizeAgentId,
|
||||
pageUiState,
|
||||
} = context;
|
||||
const PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY = Array.isArray(chatOnlyProfileRegistry) ? chatOnlyProfileRegistry : [];
|
||||
|
||||
function pageAiProviderLabel(provider) {
|
||||
if (provider === 'codex') return 'Codex';
|
||||
if (provider === 'claudecode') return 'ClaudeCode';
|
||||
return 'Hermes';
|
||||
}
|
||||
|
||||
function pageAiNormalizeArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function pageAiDefaultAcpRuntimes() {
|
||||
return [
|
||||
{
|
||||
name: 'reasonix',
|
||||
title: 'ACP · Reasonix',
|
||||
description: '通过 ACP 协议直连 Reasonix(DeepSeek 缓存优先)',
|
||||
model: 'deepseek-chat',
|
||||
preset: 'auto'
|
||||
},
|
||||
{
|
||||
name: 'hermes',
|
||||
title: 'ACP · Hermes',
|
||||
description: '通过 ACP 协议直连 Hermes agent runtime'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function pageAiNormalizeAcpRuntimes(runtimes) {
|
||||
var byName = {};
|
||||
pageAiDefaultAcpRuntimes().forEach(function(runtime) {
|
||||
byName[runtime.name] = Object.assign({}, runtime);
|
||||
});
|
||||
pageAiNormalizeArray(runtimes).forEach(function(runtime) {
|
||||
var name = String(runtime && runtime.name || '').trim();
|
||||
if (name !== 'reasonix' && name !== 'hermes') return;
|
||||
byName[name] = Object.assign({}, byName[name] || {}, runtime, { name: name });
|
||||
});
|
||||
return ['reasonix', 'hermes'].map(function(name) { return byName[name]; }).filter(Boolean);
|
||||
}
|
||||
|
||||
function pageAiUnwrapUpstream(payload) {
|
||||
if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream;
|
||||
return payload || null;
|
||||
}
|
||||
|
||||
function pageAiProfileValue(profile) {
|
||||
if (profile && typeof profile === 'object') {
|
||||
return String(profile.profileId || profile.name || profile.profile || profile.id || '').trim();
|
||||
}
|
||||
return String(profile || '').trim();
|
||||
}
|
||||
|
||||
function pageAiCurrentProfile() {
|
||||
var active = String(pageUiState.pageAiActiveProfileName || '').trim();
|
||||
if (active) return active;
|
||||
var selected = pageUiState.pageAiProfiles.find(function(profile) {
|
||||
return profile && profile.active;
|
||||
});
|
||||
return pageAiProfileValue(selected) || 'mnoteai';
|
||||
}
|
||||
|
||||
function pageAiRunProfile() {
|
||||
if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return 'reasonix';
|
||||
if (pageAiCurrentAgentId() === 'chat_only') return pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile());
|
||||
return pageAiCurrentProfile();
|
||||
}
|
||||
|
||||
function pageAiMnoteToolModel() {
|
||||
var doc = documentRef || document;
|
||||
return String(doc.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash';
|
||||
}
|
||||
|
||||
function pageAiCurrentProfileRecord() {
|
||||
var active = pageAiCurrentProfile();
|
||||
return pageUiState.pageAiProfiles.find(function(profile) {
|
||||
return pageAiProfileValue(profile) === active;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiChatOnlyProfileSpec(profile) {
|
||||
var profileId = pageAiProfileValue(profile);
|
||||
var baseProfile = String(profile && profile.baseProfile || '').trim();
|
||||
var isolatedProfile = String(profile && profile.isolatedProfile || '').trim();
|
||||
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY.find(function(spec) {
|
||||
return spec.profileId === profileId || spec.baseProfile === baseProfile || isolatedProfile.indexOf(spec.baseProfile) >= 0;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiDefaultChatOnlyProfileSpec() {
|
||||
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY[0] || { profileId: 'shared_deepseek_chat', baseProfile: 'deepseek-chat', label: 'DeepSeek' };
|
||||
}
|
||||
|
||||
function pageAiNormalizeChatOnlyProfileId(profileId) {
|
||||
var profile = pageAiProfileRecordById(profileId) || { profileId: String(profileId || '').trim(), name: String(profileId || '').trim() };
|
||||
var spec = pageAiChatOnlyProfileSpec(profile);
|
||||
return (spec || pageAiDefaultChatOnlyProfileSpec()).profileId;
|
||||
}
|
||||
|
||||
function pageAiProfileDisplayLabel(profile, fallback) {
|
||||
var alias = String(profile && profile.alias || '').trim();
|
||||
var displayName = String(profile && (profile.displayName || profile.label || '') || '').trim();
|
||||
var name = pageAiProfileValue(profile);
|
||||
return alias || displayName || fallback || name || 'default';
|
||||
}
|
||||
|
||||
function pageAiProfileRecordById(profileId) {
|
||||
var normalized = String(profileId || '').trim();
|
||||
if (!normalized) return null;
|
||||
return pageAiNormalizeArray(pageUiState.pageAiProfiles).find(function(profile) {
|
||||
return pageAiProfileValue(profile) === normalized;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiSessionAgentFilterValue(session) {
|
||||
var agentId = pageAiNormalizeAgentId(session && session.agentId);
|
||||
if (agentId === 'reasonix') return 'reasonix';
|
||||
var profileId = String(session && (session.profileId || session.profile) || '').trim();
|
||||
if (agentId === 'chat_only') profileId = pageAiNormalizeChatOnlyProfileId(profileId);
|
||||
return agentId + ':' + profileId;
|
||||
}
|
||||
|
||||
function pageAiSessionAgentLabel(session) {
|
||||
var agentId = pageAiNormalizeAgentId(session && session.agentId);
|
||||
if (agentId === 'reasonix') return 'Reasonix';
|
||||
var profileId = String(session && (session.profileId || session.profile) || '').trim();
|
||||
var profile = pageAiProfileRecordById(profileId) || { profileId: profileId, name: profileId };
|
||||
var chatOnlySpec = pageAiChatOnlyProfileSpec(profile);
|
||||
if (agentId === 'chat_only') {
|
||||
return 'ChatOnly / ' + (chatOnlySpec || pageAiDefaultChatOnlyProfileSpec()).label;
|
||||
}
|
||||
if (agentId === 'hermes') return 'Hermes / ' + pageAiProfileDisplayLabel(profile, profileId);
|
||||
return pageAiAgentRecord(agentId).label;
|
||||
}
|
||||
|
||||
function pageAiSessionPreviewText(session) {
|
||||
var preview = Array.isArray(session && session.messages) && session.messages.length
|
||||
? String(session.messages.slice(-1)[0].content || '')
|
||||
: String(session && (session.snippet || session.preview || '暂无消息') || '暂无消息');
|
||||
preview = preview.replace(/\s+/g, ' ').trim();
|
||||
var limit = 96;
|
||||
return preview.length > limit ? preview.slice(0, limit) + '…' : preview;
|
||||
}
|
||||
|
||||
function pageAiSessionAgentFilterOptions(rows) {
|
||||
var byValue = { all: '全部 agent' };
|
||||
pageAiNormalizeArray(rows).forEach(function(session) {
|
||||
var value = pageAiSessionAgentFilterValue(session);
|
||||
byValue[value] = pageAiSessionAgentLabel(session);
|
||||
});
|
||||
return Object.keys(byValue).map(function(value) {
|
||||
return { value: value, label: byValue[value] };
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiFilteredHistoryRows(rows) {
|
||||
var filterValue = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all';
|
||||
var normalized = pageAiNormalizeArray(rows);
|
||||
if (filterValue === 'all') return normalized;
|
||||
return normalized.filter(function(session) {
|
||||
return pageAiSessionAgentFilterValue(session) === filterValue;
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiTimestamp(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
var parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function pageAiUsageSummary(usage) {
|
||||
if (!usage || typeof usage !== 'object') return '';
|
||||
var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
|
||||
var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens;
|
||||
var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
|
||||
var parts = [];
|
||||
if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used));
|
||||
if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size));
|
||||
if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output));
|
||||
return parts.join(' ') || '';
|
||||
}
|
||||
|
||||
return {
|
||||
pageAiProviderLabel,
|
||||
pageAiNormalizeArray,
|
||||
pageAiDefaultAcpRuntimes,
|
||||
pageAiNormalizeAcpRuntimes,
|
||||
pageAiUnwrapUpstream,
|
||||
pageAiProfileValue,
|
||||
pageAiCurrentProfile,
|
||||
pageAiRunProfile,
|
||||
pageAiMnoteToolModel,
|
||||
pageAiCurrentProfileRecord,
|
||||
pageAiChatOnlyProfileSpec,
|
||||
pageAiDefaultChatOnlyProfileSpec,
|
||||
pageAiNormalizeChatOnlyProfileId,
|
||||
pageAiProfileDisplayLabel,
|
||||
pageAiProfileRecordById,
|
||||
pageAiSessionAgentFilterValue,
|
||||
pageAiSessionAgentLabel,
|
||||
pageAiSessionPreviewText,
|
||||
pageAiSessionAgentFilterOptions,
|
||||
pageAiFilteredHistoryRows,
|
||||
pageAiTimestamp,
|
||||
pageAiUsageSummary
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,613 @@
|
||||
export function createSidebarPageAiSessionRuntime(context) {
|
||||
const {
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
documentRef,
|
||||
pageAiApplyRuntimeState,
|
||||
pageAiCurrentAgentId,
|
||||
pageAiCurrentProfile,
|
||||
pageAiErrorMessage,
|
||||
pageAiNormalizeAgentId,
|
||||
pageAiNormalizeArray,
|
||||
pageAiNormalizeChatOnlyProfileId,
|
||||
pageAiPermissionMessage,
|
||||
pageAiPreviewValue,
|
||||
pageAiRunProfile,
|
||||
pageAiSetActiveProfile,
|
||||
pageAiTimestamp,
|
||||
pageUiState,
|
||||
renderPageAiControls,
|
||||
renderPageAiConversation,
|
||||
resolveWorkspaceId,
|
||||
sessionStorageVersion,
|
||||
windowRef,
|
||||
} = context;
|
||||
const doc = documentRef || document;
|
||||
const win = windowRef || window;
|
||||
|
||||
function pageAiStorageKey() {
|
||||
return 'hermes_page_ai_session:' + currentDocumentId();
|
||||
}
|
||||
|
||||
function pageAiBackendSessionQuery(extra) {
|
||||
var params = new URLSearchParams();
|
||||
params.set('source', 'acp');
|
||||
params.set('workspaceId', resolveWorkspaceId(doc.body));
|
||||
params.set('documentId', currentDocumentId());
|
||||
params.set('profile', pageAiRunProfile());
|
||||
params.set('sourceKind', currentSourceKind());
|
||||
if (currentRootUri()) params.set('rootUri', currentRootUri());
|
||||
Object.keys(extra || {}).forEach(function(key) {
|
||||
var value = extra[key];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function pageAiNewSession(title) {
|
||||
var now = Date.now();
|
||||
var agentId = pageAiCurrentAgentId();
|
||||
var profile = agentId === 'chat_only' ? pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile()) : pageAiCurrentProfile();
|
||||
return {
|
||||
id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8),
|
||||
title: title || '新会话',
|
||||
agentId: agentId,
|
||||
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? profile : '',
|
||||
profile: profile,
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
source: 'local',
|
||||
usage: null,
|
||||
status: 'idle',
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiNormalizeSessions(sessions) {
|
||||
return (Array.isArray(sessions) ? sessions : [])
|
||||
.slice(0, 20)
|
||||
.map(function(session) {
|
||||
var sessionStorage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
|
||||
var persistence = String(session && session.persistence || '').trim();
|
||||
var agentId = pageAiNormalizeAgentId(session && (session.agentId || session.agent_id));
|
||||
var profileId = String(session && (session.profileId || session.profile_id || '') || '').trim();
|
||||
var profile = String(session && session.profile || pageAiCurrentProfile()).trim() || 'default';
|
||||
if (!profileId && agentId !== 'reasonix') profileId = profile;
|
||||
if (agentId === 'chat_only') {
|
||||
profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile);
|
||||
profile = profileId;
|
||||
}
|
||||
return {
|
||||
id: String(session && session.id || '').trim() || pageAiNewSession().id,
|
||||
title: String(session && session.title || '').trim() || '新会话',
|
||||
agentId: agentId,
|
||||
profileId: profileId,
|
||||
profile: profile,
|
||||
acpRuntime: String(session && session.acpRuntime || 'reasonix').trim(),
|
||||
createdAt: pageAiTimestamp(session && session.createdAt),
|
||||
updatedAt: pageAiTimestamp(session && session.updatedAt),
|
||||
source: String(session && session.source || 'local').trim() || 'local',
|
||||
persistence: persistence,
|
||||
sessionStorage: sessionStorage,
|
||||
permissionLevel: String(session && (session.permissionLevel || session.permission_level) || '').trim(),
|
||||
shareId: String(session && (session.shareId || session.share_id) || '').trim(),
|
||||
acpSessionId: String(session && (session.acpSessionId || session.acp_session_id) || '').trim(),
|
||||
runId: String(session && (session.runId || session.run_id) || '').trim(),
|
||||
status: String(session && session.status || '').trim(),
|
||||
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
|
||||
preview: String(session && session.preview || '').trim(),
|
||||
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300) : []
|
||||
};
|
||||
})
|
||||
.sort(function(a, b) {
|
||||
return Number(b.updatedAt || 0) - Number(a.updatedAt || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiNormalizeBackendSessionRow(row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
var payload = row.payload && typeof row.payload === 'object' ? row.payload : {};
|
||||
var sessionId = String(row.sessionId || row.session_id || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var title = String(row.title || payload.title || payload.message || '').trim();
|
||||
if (title.length > 28) title = title.slice(0, 28) + '…';
|
||||
var persistence = String(row.persistence || payload.persistence || '').trim();
|
||||
var sessionStorage = String(row.sessionStorage || row.session_storage || payload.sessionStorage || '').trim();
|
||||
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 profile = String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default';
|
||||
if (!profileId && agentId !== 'reasonix') profileId = profile;
|
||||
if (agentId === 'chat_only') {
|
||||
profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile);
|
||||
profile = profileId;
|
||||
}
|
||||
if (!sessionStorage && persistence === 'convex_acp_runtime_store') sessionStorage = 'cloud';
|
||||
if (!sessionStorage && persistence === 'local_ai_session_jsonl') sessionStorage = String(row.shareId || payload.shareId || '').trim() ? 'local_shared' : 'local_private';
|
||||
return {
|
||||
id: sessionId,
|
||||
title: title || '当前页问答',
|
||||
agentId: agentId,
|
||||
profileId: profileId,
|
||||
profile: profile,
|
||||
acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(),
|
||||
createdAt: pageAiTimestamp(row.createdAt || row.created_at),
|
||||
updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at),
|
||||
source: sessionStorage === 'local_private' || sessionStorage === 'local_shared' ? 'local' : 'acp',
|
||||
persistence: persistence,
|
||||
sessionStorage: sessionStorage,
|
||||
permissionLevel: String(row.permissionLevel || row.permission_level || payload.permissionLevel || '').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(),
|
||||
runId: String(row.runId || row.run_id || '').trim(),
|
||||
status: String(row.status || (row.runtime && row.runtime.status) || '').trim(),
|
||||
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
|
||||
preview: String(payload.message || row.snippet || '').trim(),
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiMergeSessions(localSessions, backendSessions) {
|
||||
var byId = {};
|
||||
pageAiNormalizeSessions(localSessions).forEach(function(session) {
|
||||
byId[session.id] = session;
|
||||
});
|
||||
pageAiNormalizeSessions(backendSessions).forEach(function(session) {
|
||||
var existing = byId[session.id];
|
||||
byId[session.id] = Object.assign({}, existing || {}, session, {
|
||||
messages: session.messages.length ? session.messages : (existing && existing.messages || [])
|
||||
});
|
||||
});
|
||||
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
|
||||
}
|
||||
|
||||
function pageAiSessionStorageLabel(session) {
|
||||
var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
|
||||
var persistence = String(session && session.persistence || '').trim();
|
||||
if (storage === 'local_shared') return '共享会话';
|
||||
if (storage === 'local_private') return '本地私有';
|
||||
if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话';
|
||||
if (persistence === 'local_ai_session_jsonl') return '本地私有';
|
||||
return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有';
|
||||
}
|
||||
|
||||
function pageAiLoadSessions() {
|
||||
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
|
||||
try {
|
||||
var raw = win.localStorage.getItem(pageAiStorageKey());
|
||||
var parsed = raw ? JSON.parse(raw) : null;
|
||||
var activeId = String(parsed && parsed.activeSessionId || '').trim();
|
||||
var activeProfile = String(parsed && parsed.activeProfileName || '').trim();
|
||||
var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim();
|
||||
var sessions = pageAiNormalizeSessions(parsed && parsed.sessions);
|
||||
var storageVersion = Number(parsed && parsed.version || 0);
|
||||
if (storageVersion >= sessionStorageVersion && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
|
||||
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 (_) {}
|
||||
var fresh = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fresh];
|
||||
pageUiState.pageAiActiveSessionId = fresh.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
|
||||
async function pageAiLoadBackendSessions() {
|
||||
var response = await fetch('/api/hermes/client/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status));
|
||||
}
|
||||
var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean);
|
||||
if (!backendSessions.length) return [];
|
||||
pageUiState.pageAiSessions = pageAiMergeSessions(pageUiState.pageAiSessions, backendSessions);
|
||||
if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) {
|
||||
pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id;
|
||||
}
|
||||
var active = pageAiCurrentSession();
|
||||
if (active) {
|
||||
if (active.profile) pageAiSetActiveProfile(active.profile);
|
||||
if (!pageUiState.pageAiAcpRuntime && active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime;
|
||||
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages;
|
||||
}
|
||||
pageUiState.pageAiSessionError = '';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return backendSessions;
|
||||
}
|
||||
|
||||
function pageAiMessageFromRuntimeEvent(event) {
|
||||
if (!event || typeof event !== 'object') return null;
|
||||
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
|
||||
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
|
||||
if (eventType === 'message.delta') {
|
||||
var delta = String(payload.delta || payload.text || payload.output_text || '').trim();
|
||||
return delta ? { role: 'assistant', content: delta } : null;
|
||||
}
|
||||
if (eventType === 'thought.delta') {
|
||||
var thought = String(payload.delta || payload.text || '').trim();
|
||||
return thought ? { role: 'assistant', kind: 'thought', content: thought } : null;
|
||||
}
|
||||
if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') {
|
||||
var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim();
|
||||
var rawLocations = payload.locations;
|
||||
return {
|
||||
role: 'tool',
|
||||
content: toolName,
|
||||
toolName: toolName,
|
||||
toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''),
|
||||
toolKind: String(payload.kind || ''),
|
||||
status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'),
|
||||
argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input),
|
||||
resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error),
|
||||
locations: Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [],
|
||||
traceId: String(payload.traceId || payload.trace_id || ''),
|
||||
auditId: String(payload.auditId || payload.audit_id || '')
|
||||
};
|
||||
}
|
||||
if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') {
|
||||
return pageAiPermissionMessage(payload, eventType);
|
||||
}
|
||||
if (eventType === 'run.completed') {
|
||||
var output = String(payload.output || payload.text || '').trim();
|
||||
return output ? { role: 'assistant', content: output } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pageAiApplyBackendSessionDetail(payload) {
|
||||
var sessionPayload = payload && payload.session ? payload.session : {};
|
||||
var runs = pageAiNormalizeArray(sessionPayload.runs);
|
||||
var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null;
|
||||
var events = pageAiNormalizeArray(payload && payload.events);
|
||||
var storedMessages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) {
|
||||
return {
|
||||
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
|
||||
content: String(message.content || '')
|
||||
};
|
||||
}).filter(function(message) { return message.content; });
|
||||
var eventsByRunId = {};
|
||||
events.forEach(function(event) {
|
||||
var runId = String(event && (event.runId || event.run_id) || '').trim();
|
||||
if (!runId) return;
|
||||
if (!eventsByRunId[runId]) eventsByRunId[runId] = [];
|
||||
eventsByRunId[runId].push(event);
|
||||
});
|
||||
var messages = [];
|
||||
if (runs.length) {
|
||||
runs.slice().reverse().forEach(function(run) {
|
||||
var runPayload = run && run.payload && typeof run.payload === 'object' ? run.payload : {};
|
||||
var userMessage = String(runPayload.message || runPayload.input || '').trim();
|
||||
if (userMessage) messages.push({ role: 'user', content: userMessage });
|
||||
var runId = String(run && (run.runId || run.run_id) || '').trim();
|
||||
pageAiNormalizeArray(eventsByRunId[runId]).forEach(function(event) {
|
||||
var message = pageAiMessageFromRuntimeEvent(event);
|
||||
if (message) messages.push(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (!messages.length) messages = storedMessages;
|
||||
var acpSessionId = '';
|
||||
events.forEach(function(event) {
|
||||
var eventType = String(event && (event.eventType || event.event_type || event.event) || '').trim();
|
||||
var payload = event && event.payload && typeof event.payload === 'object' ? event.payload : event;
|
||||
if (eventType === 'session.info.updated') {
|
||||
var nextAcpSessionId = String(payload && (payload.acpSessionId || payload.acp_session_id) || '').trim();
|
||||
if (nextAcpSessionId) acpSessionId = nextAcpSessionId;
|
||||
}
|
||||
});
|
||||
var current = pageAiCurrentSession();
|
||||
if (latest && current) {
|
||||
Object.assign(current, latest);
|
||||
}
|
||||
if (current) {
|
||||
current.messages = messages.slice(-300);
|
||||
current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now());
|
||||
if (acpSessionId) current.acpSessionId = acpSessionId;
|
||||
if (latest && latest.usage) current.usage = latest.usage;
|
||||
}
|
||||
pageAiApplyRuntimeState(payload && payload.runtime);
|
||||
pageUiState.pageAiMessages = messages.slice(-300);
|
||||
pageAiPersistSessions();
|
||||
}
|
||||
|
||||
async function pageAiLoadBackendSessionDetail(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_detail_failed_' + response.status));
|
||||
}
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function pageAiSearchBackendSessions(query) {
|
||||
var q = String(query || '').trim();
|
||||
pageUiState.pageAiSessionSearchQuery = q;
|
||||
if (!q) {
|
||||
pageUiState.pageAiSessionSearchResults = [];
|
||||
renderPageAiConversation();
|
||||
return [];
|
||||
}
|
||||
var response = await fetch('/api/hermes/client/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_search_failed_' + response.status));
|
||||
}
|
||||
pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) {
|
||||
var normalized = pageAiNormalizeBackendSessionRow(row) || {};
|
||||
normalized.snippet = String(row && row.snippet || normalized.preview || '').trim();
|
||||
return normalized;
|
||||
}).filter(function(row) { return row.id; });
|
||||
renderPageAiConversation();
|
||||
return pageUiState.pageAiSessionSearchResults;
|
||||
}
|
||||
|
||||
function pageAiPersistSessions() {
|
||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||
try {
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
win.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
|
||||
version: sessionStorageVersion,
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId,
|
||||
activeProfileName: pageAiCurrentProfile(),
|
||||
activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions)
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function pageAiEnsureHermesSession(forceCreate) {
|
||||
pageAiLoadSessions();
|
||||
var current = pageAiCurrentSession();
|
||||
var runProfile = pageAiRunProfile();
|
||||
if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === runProfile) return current;
|
||||
var response = await fetch('/api/hermes/client/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(doc.body),
|
||||
documentId: currentDocumentId(),
|
||||
sourceKind: currentSourceKind(),
|
||||
rootUri: currentRootUri(),
|
||||
traceId: 'page-ai-' + Date.now().toString(36),
|
||||
profile: runProfile,
|
||||
agentId: pageAiCurrentAgentId(),
|
||||
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '',
|
||||
title: current && current.title ? current.title : '当前页问答'
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'hermes_session_failed_' + response.status));
|
||||
}
|
||||
var session = {
|
||||
id: String(payload.sessionId || '').trim(),
|
||||
title: String(payload.title || '当前页问答'),
|
||||
agentId: pageAiCurrentAgentId(),
|
||||
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '',
|
||||
profile: String(payload.profile || runProfile).trim() || 'default',
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
persistence: String(payload.persistence || '').trim(),
|
||||
sessionStorage: String(payload.sessionStorage || '').trim(),
|
||||
permissionLevel: String(payload.permissionLevel || '').trim(),
|
||||
shareId: String(payload.shareId || '').trim(),
|
||||
acpSessionId: String(payload.acpSessionId || payload.acp_session_id || '').trim(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messages: pageUiState.pageAiMessages.slice()
|
||||
};
|
||||
pageUiState.pageAiSessions = pageAiNormalizeSessions([session]);
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
return session;
|
||||
}
|
||||
|
||||
async function pageAiRestoreHermesSession() {
|
||||
var current = pageAiCurrentSession();
|
||||
if (!current || !String(current.id || '').startsWith('mnote_')) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (payload && payload.persistence === 'convex_acp_runtime_store') {
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
|
||||
var messages = session && Array.isArray(session.messages) ? session.messages : [];
|
||||
pageAiApplyRuntimeState(payload && payload.runtime);
|
||||
if (session && (session.profile || session.profileName)) {
|
||||
current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile());
|
||||
}
|
||||
if (!messages.length) {
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
pageUiState.pageAiMessages = messages.slice(-300).map(function(message) {
|
||||
return {
|
||||
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
|
||||
content: String(message.content || '')
|
||||
};
|
||||
});
|
||||
current.messages = pageUiState.pageAiMessages.slice();
|
||||
current.updatedAt = Date.now();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiCurrentSession() {
|
||||
return pageUiState.pageAiSessions.find(function(session) {
|
||||
return session.id === pageUiState.pageAiActiveSessionId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiSyncCurrentSessionMessages() {
|
||||
var session = pageAiCurrentSession();
|
||||
if (!session) return;
|
||||
var runProfile = pageAiRunProfile();
|
||||
session.messages = Array.isArray(pageUiState.pageAiMessages) ? pageUiState.pageAiMessages.slice(-300) : [];
|
||||
session.agentId = pageAiCurrentAgentId();
|
||||
session.profileId = pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '';
|
||||
session.profile = runProfile;
|
||||
session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix';
|
||||
session.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
function pageAiSetActiveSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
if (session.agentId) pageUiState.pageAiAgentId = pageAiNormalizeAgentId(session.agentId);
|
||||
if (session.acpRuntime) pageUiState.pageAiAcpRuntime = session.acpRuntime;
|
||||
if (session.profileId || session.profile) pageAiSetActiveProfile(session.profileId || session.profile);
|
||||
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) {
|
||||
void pageAiLoadBackendSessionDetail(session.id).catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiStartNewSession() {
|
||||
var session = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(pageUiState.pageAiSessions));
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
async function pageAiRenameBackendSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
var title = win.prompt('重命名 AI 会话', session.title || '当前页问答');
|
||||
if (title === null) return;
|
||||
title = String(title || '').trim();
|
||||
if (!title) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||||
body: JSON.stringify({ title: title })
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status));
|
||||
}
|
||||
session.title = String((payload.result && payload.result.title) || title);
|
||||
session.updatedAt = Date.now();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
async function pageAiDeleteBackendSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
if (!win.confirm('确定删除 AI 会话“' + (session.title || sessionId) + '”吗?')) return;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'DELETE',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_delete_failed_' + response.status));
|
||||
}
|
||||
pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(item) { return item.id !== sessionId; });
|
||||
if (pageUiState.pageAiActiveSessionId === sessionId) {
|
||||
var next = pageUiState.pageAiSessions[0] || pageAiNewSession();
|
||||
if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next];
|
||||
pageUiState.pageAiActiveSessionId = next.id;
|
||||
pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : [];
|
||||
}
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
async function pageAiResumeBackendSession(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return;
|
||||
pageAiSetActiveSession(sessionId);
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_resume_failed_' + response.status));
|
||||
}
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
return {
|
||||
pageAiStorageKey,
|
||||
pageAiBackendSessionQuery,
|
||||
pageAiNewSession,
|
||||
pageAiNormalizeSessions,
|
||||
pageAiNormalizeBackendSessionRow,
|
||||
pageAiMergeSessions,
|
||||
pageAiSessionStorageLabel,
|
||||
pageAiLoadSessions,
|
||||
pageAiLoadBackendSessions,
|
||||
pageAiMessageFromRuntimeEvent,
|
||||
pageAiApplyBackendSessionDetail,
|
||||
pageAiLoadBackendSessionDetail,
|
||||
pageAiSearchBackendSessions,
|
||||
pageAiPersistSessions,
|
||||
pageAiEnsureHermesSession,
|
||||
pageAiRestoreHermesSession,
|
||||
pageAiCurrentSession,
|
||||
pageAiSyncCurrentSessionMessages,
|
||||
pageAiSetActiveSession,
|
||||
pageAiStartNewSession,
|
||||
pageAiRenameBackendSession,
|
||||
pageAiDeleteBackendSession,
|
||||
pageAiResumeBackendSession
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
export function createSidebarPageAiSkillRuntime(context) {
|
||||
const {
|
||||
pageAiCurrentAgentId,
|
||||
pageAiCurrentProfile,
|
||||
pageAiLoadSkills,
|
||||
pageAiNormalizeArray,
|
||||
pageAiPersistAiPreference,
|
||||
pageAiPersistRawAiPreference,
|
||||
pageAiProfileValue,
|
||||
pageUiState,
|
||||
renderPageAiControls,
|
||||
} = context;
|
||||
|
||||
function pageAiSkillSourceOptions() {
|
||||
var options = [
|
||||
{ value: 'mnote', group: 'mnote', label: 'mnote', profile: '' },
|
||||
{ value: 'reasonix', group: 'reasonix', label: 'reasonix', profile: '' }
|
||||
];
|
||||
pageAiNormalizeArray(pageUiState.pageAiProfiles).forEach(function(profile) {
|
||||
var profileId = pageAiProfileValue(profile);
|
||||
if (!profileId) return;
|
||||
var label = profile.kind === 'shared'
|
||||
? (profile.baseProfile === 'lite' || profileId === 'shared_lite' ? 'hermes_lite' : 'Hermes_shared')
|
||||
: 'Hermes_user';
|
||||
var alias = String(profile.alias || '').trim();
|
||||
options.push({
|
||||
value: 'hermes:' + profileId,
|
||||
group: 'hermes',
|
||||
profile: profileId,
|
||||
label: alias && alias !== label ? label + ' · ' + alias : label,
|
||||
readonly: profile.readonly === true
|
||||
});
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
function pageAiDefaultSkillSource() {
|
||||
if (pageAiCurrentAgentId() === 'hermes') return 'hermes:' + pageAiCurrentProfile();
|
||||
if (pageAiCurrentAgentId() === 'reasonix') return 'reasonix';
|
||||
return 'mnote';
|
||||
}
|
||||
|
||||
function pageAiNormalizeSkillSource(source) {
|
||||
var value = String(source || '').trim();
|
||||
var options = pageAiSkillSourceOptions();
|
||||
if (options.some(function(option) { return option.value === value; })) return value;
|
||||
if (value === 'hermes') return 'hermes:' + pageAiCurrentProfile();
|
||||
if (value === 'mnote_builtin') return 'mnote';
|
||||
var fallback = pageAiDefaultSkillSource();
|
||||
if (options.some(function(option) { return option.value === fallback; })) return fallback;
|
||||
return options.length ? options[0].value : 'mnote';
|
||||
}
|
||||
|
||||
function pageAiCurrentSkillSource() {
|
||||
var normalized = pageAiNormalizeSkillSource(pageUiState.pageAiActiveSkillSource);
|
||||
pageUiState.pageAiActiveSkillSource = normalized;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pageAiSetSkillSource(source) {
|
||||
var normalized = pageAiNormalizeSkillSource(source);
|
||||
pageUiState.pageAiActiveSkillSource = normalized;
|
||||
pageAiPersistAiPreference('skills.active_source', normalized);
|
||||
pageUiState.pageAiSkills = { categories: [], archived: [] };
|
||||
pageUiState.pageAiSkillError = '';
|
||||
void pageAiLoadSkills();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSkillSourceParts(source) {
|
||||
var normalized = pageAiNormalizeSkillSource(source);
|
||||
if (normalized.indexOf('hermes:') === 0) {
|
||||
return { group: 'hermes', profile: normalized.slice('hermes:'.length), source: normalized };
|
||||
}
|
||||
if (normalized === 'reasonix') return { group: 'reasonix', profile: '', source: normalized };
|
||||
return { group: 'mnote', profile: '', source: 'mnote' };
|
||||
}
|
||||
|
||||
function pageAiCurrentSkillSourceLabel() {
|
||||
var source = pageAiCurrentSkillSource();
|
||||
var option = pageAiSkillSourceOptions().find(function(item) { return item.value === source; });
|
||||
return option ? option.label : source;
|
||||
}
|
||||
|
||||
function pageAiSkillOriginLabel(skill) {
|
||||
var origin = String(skill && skill.origin || '').trim();
|
||||
if (origin === 'generated' || String(skill && skill.createdBy || '') === 'agent') return '生成';
|
||||
if (origin === 'installed') return '安装';
|
||||
if (origin === 'builtin') return '内置';
|
||||
if (origin === 'copied') return '本地';
|
||||
var source = String(skill && skill.source || '').trim();
|
||||
if (source === 'hub') return '安装';
|
||||
if (source === 'builtin') return '内置';
|
||||
if (source === 'reasonix') {
|
||||
if (origin === 'project') return 'Reasonix 项目';
|
||||
if (origin === 'global') return 'Reasonix 全局';
|
||||
return 'Reasonix';
|
||||
}
|
||||
return '本地';
|
||||
}
|
||||
|
||||
function pageAiSkillPreferenceKey(group, profile) {
|
||||
var groupName = String(group || '').trim();
|
||||
if (groupName === 'mnote') return 'ai.agent.mnote_builtin.skills.enabled';
|
||||
if (groupName === 'reasonix') return 'ai.agent.reasonix.skills.enabled';
|
||||
if (groupName === 'hermes') {
|
||||
var nextProfile = String(profile || pageAiCurrentProfile() || 'default').trim() || 'default';
|
||||
return 'ai.agent.hermes.profile.' + nextProfile + '.skills.enabled';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pageAiSkillPreferenceTable(group, profile) {
|
||||
var key = pageAiSkillPreferenceKey(group, profile);
|
||||
var preferences = pageUiState.pageAiSkillPreferences || {};
|
||||
var value = preferences[key];
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
||||
return {};
|
||||
}
|
||||
|
||||
function pageAiHermesHideBuiltinPreferenceKey(profile) {
|
||||
return 'ai.agent.hermes.skills.hide_builtin';
|
||||
}
|
||||
|
||||
function pageAiHideHermesBuiltinSkills(profile) {
|
||||
var preferences = pageUiState.pageAiSkillPreferences || {};
|
||||
return preferences[pageAiHermesHideBuiltinPreferenceKey(profile)] === true;
|
||||
}
|
||||
|
||||
function pageAiReasonixMemoryEnabled() {
|
||||
var preferences = pageUiState.pageAiSkillPreferences || {};
|
||||
return preferences['ai.agent.reasonix.memory_enabled'] === true;
|
||||
}
|
||||
|
||||
function pageAiSetReasonixMemoryEnabled(enabled) {
|
||||
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
|
||||
preferences['ai.agent.reasonix.memory_enabled'] = Boolean(enabled);
|
||||
pageUiState.pageAiSkillPreferences = preferences;
|
||||
pageAiPersistRawAiPreference('ai.agent.reasonix.memory_enabled', Boolean(enabled));
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSetHideHermesBuiltinSkills(enabled, profile) {
|
||||
var key = pageAiHermesHideBuiltinPreferenceKey(profile);
|
||||
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
|
||||
preferences[key] = Boolean(enabled);
|
||||
pageUiState.pageAiSkillPreferences = preferences;
|
||||
pageAiPersistRawAiPreference(key, Boolean(enabled));
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSkillIsBuiltin(skill) {
|
||||
var origin = String(skill && skill.origin || '').trim();
|
||||
var source = String(skill && skill.source || '').trim();
|
||||
return origin === 'builtin' || source === 'builtin';
|
||||
}
|
||||
|
||||
function pageAiToggleableSkillEntries(group, profile) {
|
||||
var catalogKey = group === 'hermes' && profile ? 'hermes:' + profile : group;
|
||||
var catalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[catalogKey]
|
||||
? pageUiState.pageAiSkillCatalogs[catalogKey]
|
||||
: pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[group]
|
||||
? pageUiState.pageAiSkillCatalogs[group]
|
||||
: { categories: [], archived: [] };
|
||||
var overrides = pageAiSkillPreferenceTable(group, profile);
|
||||
var result = [];
|
||||
pageAiNormalizeArray(catalog.categories).forEach(function(category) {
|
||||
pageAiNormalizeArray(category.skills).forEach(function(skill) {
|
||||
var id = String(skill.id || skill.name || '').trim();
|
||||
if (!id) return;
|
||||
result.push({
|
||||
group: group,
|
||||
profile: profile || '',
|
||||
category: category.name,
|
||||
id: id,
|
||||
name: skill.name || id,
|
||||
title: skill.title || skill.name || id,
|
||||
description: skill.description || '',
|
||||
enabled: group === 'hermes' ? skill.enabled !== false : (skill.enabled !== false && overrides[id] !== false),
|
||||
toggleable: skill.toggleable !== false,
|
||||
source: skill.source || group,
|
||||
origin: skill.origin || '',
|
||||
readOnly: skill.readOnly === true || skill.readonly === true || skill.configurable === false,
|
||||
builtin: pageAiSkillIsBuiltin(skill),
|
||||
configurable: skill.configurable !== false,
|
||||
configScope: skill.configScope || '',
|
||||
skillKind: skill.skillKind || '',
|
||||
profileId: skill.profileId || '',
|
||||
toolNames: skill.toolNames || [],
|
||||
requiresContextRefs: skill.requiresContextRefs || []
|
||||
});
|
||||
});
|
||||
});
|
||||
pageAiNormalizeArray(catalog.archived).forEach(function(skill) {
|
||||
var id = String(skill.id || skill.name || '').trim();
|
||||
if (!id) return;
|
||||
result.push({
|
||||
group: group,
|
||||
profile: profile || '',
|
||||
category: 'archived',
|
||||
id: id,
|
||||
name: skill.name || id,
|
||||
title: skill.title || skill.name || id,
|
||||
description: skill.description || '',
|
||||
enabled: group === 'hermes' ? skill.enabled !== false : (skill.enabled !== false && overrides[id] !== false),
|
||||
toggleable: skill.toggleable !== false,
|
||||
source: skill.source || group,
|
||||
origin: skill.origin || '',
|
||||
readOnly: skill.readOnly === true || skill.readonly === true || skill.configurable === false,
|
||||
builtin: pageAiSkillIsBuiltin(skill),
|
||||
configurable: skill.configurable !== false,
|
||||
configScope: skill.configScope || '',
|
||||
skillKind: skill.skillKind || '',
|
||||
profileId: skill.profileId || '',
|
||||
toolNames: skill.toolNames || [],
|
||||
requiresContextRefs: skill.requiresContextRefs || []
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function pageAiAllSkillEntries() {
|
||||
return []
|
||||
.concat(pageAiToggleableSkillEntries('mnote', ''))
|
||||
.concat(pageAiToggleableSkillEntries('reasonix', ''))
|
||||
.concat(pageAiToggleableSkillEntries('hermes', pageAiCurrentProfile()));
|
||||
}
|
||||
|
||||
function pageAiSkillGroupCollapsed(group) {
|
||||
var table = pageUiState.pageAiCollapsedSkillGroups || {};
|
||||
return table[String(group || '').trim()] === true;
|
||||
}
|
||||
|
||||
function pageAiToggleSkillGroup(group) {
|
||||
var normalized = String(group || '').trim();
|
||||
if (!normalized) return;
|
||||
var table = Object.assign({}, pageUiState.pageAiCollapsedSkillGroups || {});
|
||||
table[normalized] = table[normalized] !== true;
|
||||
pageUiState.pageAiCollapsedSkillGroups = table;
|
||||
pageAiPersistRawAiPreference('ai.common.skills.groups.collapsed', table);
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSetSkillPreference(group, skillId, enabled, profile) {
|
||||
var key = pageAiSkillPreferenceKey(group, profile);
|
||||
if (!key || !skillId) return;
|
||||
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
|
||||
var current = preferences[key] && typeof preferences[key] === 'object' && !Array.isArray(preferences[key])
|
||||
? Object.assign({}, preferences[key])
|
||||
: {};
|
||||
current[skillId] = Boolean(enabled);
|
||||
preferences[key] = current;
|
||||
pageUiState.pageAiSkillPreferences = preferences;
|
||||
pageAiPersistRawAiPreference(key, current);
|
||||
}
|
||||
|
||||
function pageAiSkillEnabled(skill) {
|
||||
return skill.enabled !== false;
|
||||
}
|
||||
|
||||
return {
|
||||
pageAiSkillSourceOptions,
|
||||
pageAiDefaultSkillSource,
|
||||
pageAiNormalizeSkillSource,
|
||||
pageAiCurrentSkillSource,
|
||||
pageAiSetSkillSource,
|
||||
pageAiSkillSourceParts,
|
||||
pageAiCurrentSkillSourceLabel,
|
||||
pageAiSkillOriginLabel,
|
||||
pageAiSkillPreferenceKey,
|
||||
pageAiSkillPreferenceTable,
|
||||
pageAiHermesHideBuiltinPreferenceKey,
|
||||
pageAiHideHermesBuiltinSkills,
|
||||
pageAiReasonixMemoryEnabled,
|
||||
pageAiSetReasonixMemoryEnabled,
|
||||
pageAiSetHideHermesBuiltinSkills,
|
||||
pageAiSkillIsBuiltin,
|
||||
pageAiToggleableSkillEntries,
|
||||
pageAiAllSkillEntries,
|
||||
pageAiSkillGroupCollapsed,
|
||||
pageAiToggleSkillGroup,
|
||||
pageAiSetSkillPreference,
|
||||
pageAiSkillEnabled
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
export function createSidebarPageAiTargetRuntime(context) {
|
||||
const {
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
currentPageOptions,
|
||||
documentRef,
|
||||
escapeHtml,
|
||||
pageAiEnsureContextRefState,
|
||||
pageUiState,
|
||||
resolveWorkspaceId,
|
||||
searchText,
|
||||
pageAiNormalizeArray,
|
||||
} = context;
|
||||
|
||||
function pageAiCloneJson(value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function localMarkdownRelativePathFromPageAiDocumentId(documentId) {
|
||||
var value = String(documentId || '').trim();
|
||||
if (!value.startsWith('local-md:')) return '';
|
||||
return value.slice('local-md:'.length).replace(/~2F/g, '/');
|
||||
}
|
||||
|
||||
function localMarkdownDocumentIdFromPageAiRelativePath(relativePath) {
|
||||
var normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!normalized) return '';
|
||||
return 'local-md:' + normalized.split('/').map(function(segment) {
|
||||
return encodeURIComponent(segment).replace(/%20/g, '~20');
|
||||
}).join('~2F');
|
||||
}
|
||||
|
||||
function pageAiWorkspacePathForDocument(documentId, seed) {
|
||||
var relativePath = String(seed && seed.relativePath || localMarkdownRelativePathFromPageAiDocumentId(documentId) || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
var resolvedDocumentId = String(documentId || seed && seed.documentId || '').trim()
|
||||
|| localMarkdownDocumentIdFromPageAiRelativePath(relativePath);
|
||||
return {
|
||||
schema: 'mnote.workspace_path.v1',
|
||||
workspaceId: String(seed && seed.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim(),
|
||||
sourceKind: String(seed && seed.sourceKind || currentSourceKind() || '').trim(),
|
||||
rootUri: String(seed && seed.rootUri || currentRootUri() || '').trim(),
|
||||
relativePath: relativePath,
|
||||
documentId: resolvedDocumentId,
|
||||
objectIdentity: seed && seed.objectIdentity && typeof seed.objectIdentity === 'object'
|
||||
? seed.objectIdentity
|
||||
: String(seed && seed.objectIdentity || resolvedDocumentId || '').trim(),
|
||||
assetId: String(seed && seed.assetId || '').trim(),
|
||||
resourceKind: String(seed && seed.resourceKind || 'markdown_page').trim()
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiResourceKindForTarget(entry) {
|
||||
var kind = String(entry && (entry.editorKind || entry.kind) || '').trim().toLowerCase();
|
||||
var assetId = String(entry && entry.assetId || '').trim();
|
||||
var path = String(entry && entry.path || '').trim().toLowerCase();
|
||||
if (kind === 'page' || kind === 'markdown_page') return 'markdown_page';
|
||||
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 === 'resource' && assetId) return 'resource';
|
||||
return kind || 'markdown_page';
|
||||
}
|
||||
|
||||
function pageAiTargetId(entry) {
|
||||
if (!entry || typeof entry !== 'object') return '';
|
||||
var workspacePath = entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {};
|
||||
var entryIdentity = typeof entry.objectIdentity === 'string' ? entry.objectIdentity : '';
|
||||
var workspaceIdentity = typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : '';
|
||||
return String(entryIdentity || workspaceIdentity || entry.documentId || entry.assetId || entry.path || '').trim();
|
||||
}
|
||||
|
||||
function pageAiWorkspacePathForTarget(entry) {
|
||||
var seed = Object.assign({}, entry && entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {});
|
||||
var resourceKind = pageAiResourceKindForTarget(entry);
|
||||
if (!seed.relativePath && entry && entry.path) seed.relativePath = entry.path;
|
||||
if (!seed.assetId && entry && entry.assetId) seed.assetId = entry.assetId;
|
||||
var seedResourceKind = String(seed.resourceKind || '').trim();
|
||||
if (!seedResourceKind || seedResourceKind === 'page' || seedResourceKind === 'office') seed.resourceKind = resourceKind;
|
||||
if (!seed.objectIdentity && entry && entry.objectIdentity) seed.objectIdentity = entry.objectIdentity;
|
||||
if (!seed.workspaceId && entry && entry.workspaceId) seed.workspaceId = entry.workspaceId;
|
||||
return pageAiWorkspacePathForDocument(entry && entry.documentId || currentDocumentId(), seed);
|
||||
}
|
||||
|
||||
function pageAiTargetFromOpenEditor(entry, source) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
var workspacePath = pageAiWorkspacePathForTarget(entry);
|
||||
var targetId = pageAiTargetId(entry) || workspacePath.objectIdentity || workspacePath.documentId;
|
||||
if (!targetId) return null;
|
||||
var objectIdentity = typeof entry.objectIdentity === 'string'
|
||||
? entry.objectIdentity
|
||||
: (typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : targetId);
|
||||
return {
|
||||
schema: 'mnote.ai_editor_target.v1',
|
||||
source: source || 'open_editors_snapshot',
|
||||
targetId: targetId,
|
||||
objectIdentity: objectIdentity,
|
||||
workspacePath: workspacePath,
|
||||
paneRole: entry.paneRole || 'primary',
|
||||
documentId: entry.documentId || workspacePath.documentId,
|
||||
workspaceId: entry.workspaceId || workspacePath.workspaceId || resolveWorkspaceId(documentRef.body),
|
||||
editorKind: entry.editorKind || entry.kind || workspacePath.resourceKind,
|
||||
resourceKind: workspacePath.resourceKind,
|
||||
title: entry.title || '',
|
||||
active: entry.active === true,
|
||||
dirtyState: entry.dirtyState || '',
|
||||
preview: entry.preview === true,
|
||||
pinned: entry.pinned === true,
|
||||
lastActiveAt: entry.lastActiveAt || 0,
|
||||
assetId: entry.assetId || workspacePath.assetId || '',
|
||||
path: entry.path || workspacePath.relativePath || '',
|
||||
onlyofficeSessionId: entry.onlyofficeSessionId || entry.bridgeSessionId || '',
|
||||
bridgeSessionId: entry.bridgeSessionId || entry.onlyofficeSessionId || '',
|
||||
bridgeSessionReady: entry.bridgeSessionReady === true
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOpenEditorEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
return {
|
||||
objectIdentity: String(entry.objectIdentity || '').trim(),
|
||||
workspacePath: entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : null,
|
||||
paneRole: String(entry.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary',
|
||||
documentId: String(entry.documentId || '').trim(),
|
||||
workspaceId: String(entry.workspaceId || '').trim(),
|
||||
title: String(entry.title || '').trim(),
|
||||
kind: String(entry.kind || entry.editorKind || '').trim(),
|
||||
editorKind: String(entry.editorKind || entry.kind || '').trim(),
|
||||
active: entry.active === true,
|
||||
dirtyState: String(entry.dirtyState || entry.dirtyGuard || '').trim(),
|
||||
preview: entry.preview === true,
|
||||
pinned: entry.pinned === true,
|
||||
lastActiveAt: Number(entry.lastActiveAt || 0) || 0,
|
||||
assetId: String(entry.assetId || '').trim(),
|
||||
path: String(entry.path || '').trim(),
|
||||
onlyofficeSessionId: String(entry.onlyofficeSessionId || entry.bridgeSessionId || '').trim(),
|
||||
bridgeSessionId: String(entry.bridgeSessionId || entry.onlyofficeSessionId || '').trim(),
|
||||
bridgeSessionReady: entry.bridgeSessionReady === true
|
||||
};
|
||||
}
|
||||
|
||||
function currentPageAiOpenEditorsSnapshot() {
|
||||
var snapshot = null;
|
||||
try {
|
||||
if (window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === 'function') {
|
||||
snapshot = window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot();
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!snapshot || typeof snapshot !== 'object') snapshot = window.__mnoteOpenEditorsSnapshot || null;
|
||||
if (!snapshot || typeof snapshot !== 'object') return null;
|
||||
var editors = Array.isArray(snapshot.editors)
|
||||
? snapshot.editors.map(normalizeOpenEditorEntry).filter(Boolean)
|
||||
: [];
|
||||
var resources = Array.isArray(snapshot.resourceEditors)
|
||||
? snapshot.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean)
|
||||
: editors.filter(function(entry) { return entry.kind !== 'page'; });
|
||||
var normalizeGroup = function(group, paneRole) {
|
||||
var groupEditors = group && Array.isArray(group.editors)
|
||||
? group.editors.map(normalizeOpenEditorEntry).filter(Boolean)
|
||||
: editors.filter(function(entry) { return entry.paneRole === paneRole; });
|
||||
var groupResources = group && Array.isArray(group.resourceEditors)
|
||||
? group.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean)
|
||||
: resources.filter(function(entry) { return entry.paneRole === paneRole; });
|
||||
var groupActive = groupEditors.find(function(entry) { return entry.active; }) || null;
|
||||
return {
|
||||
paneRole: paneRole,
|
||||
activeObjectIdentity: String(group && group.activeObjectIdentity || (groupActive ? groupActive.objectIdentity : '') || '').trim(),
|
||||
editors: groupEditors,
|
||||
resourceEditors: groupResources
|
||||
};
|
||||
};
|
||||
var groups = {
|
||||
primary: normalizeGroup(snapshot.groups && snapshot.groups.primary, 'primary'),
|
||||
secondary: normalizeGroup(snapshot.groups && snapshot.groups.secondary, 'secondary')
|
||||
};
|
||||
var activeObjectIdentity = String(snapshot.activeObjectIdentity || '').trim();
|
||||
var allTargets = editors.concat(resources);
|
||||
var activeEditor = allTargets.find(function(entry) {
|
||||
return entry.active && (!activeObjectIdentity || entry.objectIdentity === activeObjectIdentity);
|
||||
}) || groups.primary.editors.find(function(entry) {
|
||||
return entry.active;
|
||||
}) || groups.primary.resourceEditors.find(function(entry) {
|
||||
return entry.active;
|
||||
}) || groups.secondary.editors.find(function(entry) {
|
||||
return entry.active;
|
||||
}) || groups.secondary.resourceEditors.find(function(entry) {
|
||||
return entry.active;
|
||||
}) || null;
|
||||
return {
|
||||
schema: String(snapshot.schema || 'mnote.open_editors_snapshot.v1'),
|
||||
generatedAt: Number(snapshot.generatedAt || 0) || Date.now(),
|
||||
activeObjectIdentity: activeObjectIdentity || (activeEditor ? activeEditor.objectIdentity : ''),
|
||||
activeEditor: activeEditor,
|
||||
editors: editors,
|
||||
resourceEditors: resources,
|
||||
groups: groups
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiFallbackEditorTarget() {
|
||||
var fallbackDocumentId = currentDocumentId();
|
||||
var fallbackWorkspacePath = pageAiWorkspacePathForDocument(fallbackDocumentId, null);
|
||||
var fallbackTargetId = fallbackWorkspacePath.objectIdentity || fallbackDocumentId || '';
|
||||
return {
|
||||
schema: 'mnote.ai_editor_target.v1',
|
||||
source: 'fallback_current_document',
|
||||
targetId: fallbackTargetId,
|
||||
objectIdentity: fallbackTargetId,
|
||||
workspacePath: fallbackWorkspacePath,
|
||||
paneRole: 'primary',
|
||||
documentId: fallbackDocumentId,
|
||||
workspaceId: resolveWorkspaceId(documentRef.body),
|
||||
editorKind: 'page',
|
||||
active: true,
|
||||
dirtyState: '',
|
||||
preview: false,
|
||||
pinned: true,
|
||||
lastActiveAt: Date.now(),
|
||||
assetId: '',
|
||||
path: ''
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiEditorTargetCandidates() {
|
||||
var snapshot = currentPageAiOpenEditorsSnapshot();
|
||||
var entries = [];
|
||||
if (snapshot) {
|
||||
entries = pageAiNormalizeArray(snapshot.editors).concat(pageAiNormalizeArray(snapshot.resourceEditors));
|
||||
}
|
||||
var seen = {};
|
||||
var targets = entries.map(function(entry) {
|
||||
return pageAiTargetFromOpenEditor(entry, 'open_editors_snapshot');
|
||||
}).filter(function(target) {
|
||||
var id = String(target && target.targetId || '').trim();
|
||||
if (!id || seen[id]) return false;
|
||||
seen[id] = true;
|
||||
return true;
|
||||
});
|
||||
if (!targets.length) targets.push(pageAiFallbackEditorTarget());
|
||||
return targets;
|
||||
}
|
||||
|
||||
function currentPageAiEditorTarget() {
|
||||
var targets = pageAiEditorTargetCandidates();
|
||||
var selectedId = String(pageUiState.pageAiSelectedTargetId || '').trim();
|
||||
var selected = selectedId ? targets.find(function(target) { return target.targetId === selectedId; }) : null;
|
||||
return selected
|
||||
|| targets.find(function(target) { return target.active === true; })
|
||||
|| targets[0]
|
||||
|| pageAiFallbackEditorTarget();
|
||||
}
|
||||
|
||||
function currentPageAiPageEditorTarget() {
|
||||
var snapshot = currentPageAiOpenEditorsSnapshot();
|
||||
var documentId = currentDocumentId();
|
||||
var workspaceId = resolveWorkspaceId(documentRef.body);
|
||||
var sourceKind = currentSourceKind();
|
||||
var rootUri = currentRootUri();
|
||||
var relativePath = localMarkdownRelativePathFromPageAiDocumentId(documentId);
|
||||
var fallbackWorkspacePath = pageAiWorkspacePathForDocument(documentId, {
|
||||
relativePath: relativePath
|
||||
});
|
||||
var pageEditor = snapshot && Array.isArray(snapshot.editors)
|
||||
? snapshot.editors.find(function(entry) {
|
||||
return entry
|
||||
&& String(entry.editorKind || entry.kind || '') === 'page'
|
||||
&& String(entry.documentId || '').trim() === String(documentId || '').trim();
|
||||
})
|
||||
: null;
|
||||
var workspacePath = pageEditor && pageEditor.workspacePath
|
||||
? Object.assign({}, pageEditor.workspacePath, {
|
||||
workspaceId: workspaceId,
|
||||
sourceKind: sourceKind,
|
||||
rootUri: rootUri,
|
||||
relativePath: relativePath,
|
||||
documentId: documentId,
|
||||
resourceKind: pageEditor.workspacePath.resourceKind || 'markdown_page',
|
||||
objectIdentity: pageEditor.workspacePath.objectIdentity || fallbackWorkspacePath.objectIdentity
|
||||
})
|
||||
: fallbackWorkspacePath;
|
||||
var objectIdentity = String(pageEditor && pageEditor.objectIdentity || workspacePath.objectIdentity || documentId || '').trim();
|
||||
return {
|
||||
schema: 'mnote.ai_editor_target.v1',
|
||||
source: pageEditor ? 'current_page_from_open_editors_snapshot' : 'current_page_fallback',
|
||||
targetId: objectIdentity,
|
||||
objectIdentity: objectIdentity,
|
||||
workspacePath: Object.assign({}, workspacePath, { objectIdentity: objectIdentity }),
|
||||
paneRole: String(pageEditor && pageEditor.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary',
|
||||
documentId: documentId,
|
||||
workspaceId: workspaceId,
|
||||
editorKind: 'page',
|
||||
active: pageEditor ? pageEditor.active === true : true,
|
||||
dirtyState: String(pageEditor && pageEditor.dirtyState || '').trim(),
|
||||
preview: pageEditor ? pageEditor.preview === true : false,
|
||||
pinned: true,
|
||||
lastActiveAt: Number(pageEditor && pageEditor.lastActiveAt || 0) || Date.now(),
|
||||
assetId: '',
|
||||
path: relativePath
|
||||
};
|
||||
}
|
||||
|
||||
function currentPageAiScopedEditorTarget() {
|
||||
var selected = pageAiEnsureContextRefState();
|
||||
return selected.active_editor ? currentPageAiEditorTarget() : currentPageAiPageEditorTarget();
|
||||
}
|
||||
|
||||
function pageAiSetRunTargetSnapshot(snapshot) {
|
||||
pageUiState.pageAiCurrentRunTargetSnapshot = snapshot || null;
|
||||
var target = snapshot && snapshot.editorTarget ? snapshot.editorTarget : null;
|
||||
var documentId = String(target && target.documentId || snapshot && snapshot.documentId || '').trim();
|
||||
var workspaceId = String(target && target.workspaceId || snapshot && snapshot.workspaceId || '').trim();
|
||||
var rootUri = String(target && target.workspacePath && target.workspacePath.rootUri || snapshot && snapshot.rootUri || '').trim();
|
||||
if (documentId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-document-id', documentId);
|
||||
if (workspaceId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-workspace-id', workspaceId);
|
||||
if (rootUri) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-root-uri', rootUri);
|
||||
}
|
||||
|
||||
function assertPageAiTargetInCurrentWorkspace(editorTarget) {
|
||||
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
|
||||
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
|
||||
var targetSourceKind = String(workspacePath.sourceKind || '').trim();
|
||||
var currentKind = String(currentSourceKind() || '').trim();
|
||||
if (targetSourceKind && currentKind && targetSourceKind !== currentKind) {
|
||||
var sourceError = new Error('AI target 与当前页面 sourceKind 不一致,请重新选择当前工作区内的目标。');
|
||||
sourceError.code = 'page_ai_target_workspace_mismatch';
|
||||
throw sourceError;
|
||||
}
|
||||
var targetRootUri = String(workspacePath.rootUri || '').trim();
|
||||
var currentRoot = String(currentRootUri() || '').trim();
|
||||
if (targetRootUri && currentRoot && targetRootUri !== currentRoot) {
|
||||
var rootError = new Error('AI target 与当前本地工作区 rootUri 不一致,请重新选择当前工作区内的目标。');
|
||||
rootError.code = 'page_ai_target_workspace_mismatch';
|
||||
throw rootError;
|
||||
}
|
||||
var targetWorkspaceId = String(target.workspaceId || workspacePath.workspaceId || '').trim();
|
||||
var currentWorkspaceId = String(resolveWorkspaceId(documentRef.body) || '').trim();
|
||||
if (targetWorkspaceId && currentWorkspaceId && targetWorkspaceId !== currentWorkspaceId) {
|
||||
var workspaceError = new Error('AI target 与当前 workspaceId 不一致,请重新选择当前工作区内的目标。');
|
||||
workspaceError.code = 'page_ai_target_workspace_mismatch';
|
||||
throw workspaceError;
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiBuildContextRefs(scopedContext, runTargetSnapshot) {
|
||||
var selected = pageAiEnsureContextRefState();
|
||||
var refs = [];
|
||||
var documentId = currentDocumentId();
|
||||
var rootUri = currentRootUri();
|
||||
var workspaceId = resolveWorkspaceId(documentRef.body);
|
||||
var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
|
||||
if (selected.current_page) {
|
||||
refs.push({
|
||||
kind: 'current_page',
|
||||
documentId: documentId,
|
||||
rootUri: rootUri,
|
||||
workspaceId: workspaceId
|
||||
});
|
||||
}
|
||||
if (selected.selection && scopedContext && scopedContext.selectedText) {
|
||||
refs.push({
|
||||
kind: 'selection',
|
||||
documentId: documentId,
|
||||
rootUri: rootUri,
|
||||
selectedBlockId: scopedContext.selectedBlockId || ''
|
||||
});
|
||||
}
|
||||
if (selected.active_editor && editorTarget) {
|
||||
var workspacePath = editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
|
||||
refs.push({
|
||||
kind: 'active_editor',
|
||||
documentId: editorTarget.documentId || documentId,
|
||||
workspaceId: editorTarget.workspaceId || workspacePath.workspaceId || workspaceId,
|
||||
rootUri: workspacePath.rootUri || rootUri,
|
||||
relativePath: workspacePath.relativePath || '',
|
||||
editorKind: editorTarget.editorKind || '',
|
||||
resourceKind: editorTarget.resourceKind || workspacePath.resourceKind || '',
|
||||
targetId: editorTarget.targetId || workspacePath.objectIdentity || '',
|
||||
objectIdentity: editorTarget.objectIdentity || workspacePath.objectIdentity || '',
|
||||
assetId: editorTarget.assetId || workspacePath.assetId || '',
|
||||
onlyofficeSessionId: editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId || '',
|
||||
bridgeSessionId: editorTarget.bridgeSessionId || editorTarget.onlyofficeSessionId || ''
|
||||
});
|
||||
}
|
||||
if (selected.file) {
|
||||
refs.push({
|
||||
kind: 'file',
|
||||
documentId: documentId,
|
||||
rootUri: rootUri,
|
||||
relativePath: localMarkdownRelativePathFromPageAiDocumentId(documentId)
|
||||
});
|
||||
}
|
||||
if (selected.folder) {
|
||||
refs.push({
|
||||
kind: 'folder',
|
||||
rootUri: rootUri,
|
||||
relativePath: ''
|
||||
});
|
||||
}
|
||||
if (selected.changed_files) {
|
||||
refs.push({
|
||||
kind: 'changed_files',
|
||||
rootUri: rootUri,
|
||||
sinceRunTargetSnapshot: runTargetSnapshot && runTargetSnapshot.frozenAt || null
|
||||
});
|
||||
}
|
||||
return refs.filter(function(ref) {
|
||||
return ref && String(ref.kind || '').trim();
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiBuildAllowedRoots() {
|
||||
return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) {
|
||||
return {
|
||||
rootUri: root.rootUri,
|
||||
permission: root.permission === 'write' || root.permission === 'read_write' ? 'write' : 'read',
|
||||
recursive: root.recursive !== false,
|
||||
source: root.source === 'auto' || root.source === 'user' || root.source === 'admin'
|
||||
? 'sqlite_directory_grant'
|
||||
: (root.source || 'sqlite_directory_grant'),
|
||||
grantId: root.id || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiBuildRunTargetSnapshot(scopedContext, prompt) {
|
||||
var aiContext = scopedContext && scopedContext.pageContext && scopedContext.pageContext.aiContext
|
||||
? scopedContext.pageContext.aiContext
|
||||
: {};
|
||||
var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
|
||||
return {
|
||||
schema: 'mnote.page_ai_run_target_snapshot.v1',
|
||||
source: 'open_editors_snapshot',
|
||||
frozenAt: Date.now(),
|
||||
workspaceId: resolveWorkspaceId(documentRef.body),
|
||||
documentId: currentDocumentId(),
|
||||
sourceKind: currentSourceKind(),
|
||||
rootUri: currentRootUri(),
|
||||
contextScope: pageUiState.pageAiContextScope || 'page',
|
||||
promptPreview: searchText(prompt || '').slice(0, 160),
|
||||
editorTarget: pageAiCloneJson(editorTarget),
|
||||
activeEditorTarget: pageAiCloneJson(aiContext.activeEditorTarget || editorTarget),
|
||||
openEditorsSnapshot: pageAiCloneJson(aiContext.openEditorsSnapshot || currentPageAiOpenEditorsSnapshot())
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiContextKindsFromRefs(contextRefs) {
|
||||
var kinds = {};
|
||||
pageAiNormalizeArray(contextRefs).forEach(function(ref) {
|
||||
var kind = String(ref && ref.kind || '').trim();
|
||||
if (kind) kinds[kind] = true;
|
||||
});
|
||||
return kinds;
|
||||
}
|
||||
|
||||
function pageAiPageContextForRefs(pageContext, contextRefs) {
|
||||
var cloned = pageAiCloneJson(pageContext) || {};
|
||||
var aiContext = cloned.aiContext && typeof cloned.aiContext === 'object' ? cloned.aiContext : {};
|
||||
var kinds = pageAiContextKindsFromRefs(contextRefs);
|
||||
delete cloned.documentBlocks;
|
||||
delete cloned.evidence;
|
||||
delete aiContext.contextBlocks;
|
||||
delete aiContext.pageText;
|
||||
delete aiContext.pageXml;
|
||||
delete aiContext.truncated;
|
||||
delete aiContext.warnings;
|
||||
if (!kinds.selection) {
|
||||
delete aiContext.selectedText;
|
||||
delete aiContext.selectedBlockIds;
|
||||
delete aiContext.selectedBlocks;
|
||||
delete aiContext.allowedTargetBlockIds;
|
||||
}
|
||||
cloned.aiContext = aiContext;
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot) {
|
||||
var target = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
|
||||
var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object'
|
||||
? pageAiWorkspacePathForDocument(target.documentId || currentDocumentId(), target.workspacePath)
|
||||
: pageAiWorkspacePathForDocument(target && target.documentId || currentDocumentId(), null);
|
||||
var relativePath = String(workspacePath.relativePath || '').trim();
|
||||
var allowedFiles = relativePath ? [relativePath] : [];
|
||||
var writable = pageAiBuildAllowedRoots().some(function(root) {
|
||||
return String(root && root.rootUri || '').trim() === String(workspacePath.rootUri || '').trim()
|
||||
&& String(root && root.permission || '').trim() === 'write';
|
||||
});
|
||||
var primaryTargetId = String(target && target.targetId || workspacePath.objectIdentity || workspacePath.documentId || '').trim();
|
||||
var onlyofficeSessionId = String(target && (target.onlyofficeSessionId || target.bridgeSessionId) || '').trim();
|
||||
var targetEntry = {
|
||||
targetId: primaryTargetId,
|
||||
objectIdentity: primaryTargetId,
|
||||
documentId: workspacePath.documentId,
|
||||
workspaceId: workspacePath.workspaceId,
|
||||
sourceKind: workspacePath.sourceKind,
|
||||
rootUri: workspacePath.rootUri,
|
||||
relativePath: relativePath,
|
||||
resourceKind: workspacePath.resourceKind,
|
||||
assetId: workspacePath.assetId || target && target.assetId || '',
|
||||
onlyofficeSessionId: onlyofficeSessionId,
|
||||
bridgeSessionId: onlyofficeSessionId,
|
||||
paneRole: target && target.paneRole || 'primary',
|
||||
title: target && target.title || '',
|
||||
policy: {
|
||||
permission: allowedFiles.length && writable ? 'read_write' : 'read',
|
||||
writeRequiresCleanBuffer: true,
|
||||
conflictPolicy: 'fail_on_dirty_or_stale'
|
||||
}
|
||||
};
|
||||
return {
|
||||
schema: 'mnote.agent_target_package.v1',
|
||||
source: 'page_ai_run_target_snapshot',
|
||||
frozenAt: runTargetSnapshot && runTargetSnapshot.frozenAt || Date.now(),
|
||||
primaryTargetId: primaryTargetId,
|
||||
onlyofficeSessionId: onlyofficeSessionId,
|
||||
bridgeSessionId: onlyofficeSessionId,
|
||||
workspaceId: workspacePath.workspaceId,
|
||||
sourceKind: workspacePath.sourceKind,
|
||||
rootUri: workspacePath.rootUri,
|
||||
documentId: workspacePath.documentId,
|
||||
objectIdentity: primaryTargetId,
|
||||
resourceKind: workspacePath.resourceKind,
|
||||
workspacePath: workspacePath,
|
||||
currentFile: relativePath ? {
|
||||
rootUri: workspacePath.rootUri,
|
||||
relativePath: relativePath,
|
||||
documentId: workspacePath.documentId,
|
||||
objectIdentity: primaryTargetId,
|
||||
resourceKind: workspacePath.resourceKind
|
||||
} : null,
|
||||
allowedFiles: allowedFiles,
|
||||
targets: [targetEntry],
|
||||
policy: {
|
||||
writeRequiresExplicitTarget: true,
|
||||
allowedFilesSource: 'selected_page_ai_target',
|
||||
conflictPolicy: 'fail_on_dirty_or_stale'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiBlockingDirtyState(dirtyState) {
|
||||
var state = String(dirtyState || '').trim();
|
||||
var normalized = state.toLowerCase();
|
||||
if (normalized === 'dirty') return 'Dirty';
|
||||
if (normalized === 'stale') return 'Stale';
|
||||
if (normalized === 'deleted') return 'Deleted';
|
||||
if (normalized === 'externalmodified' || normalized === 'external-change-conflict' || normalized === 'hasexternalconflict') return 'ExternalModified';
|
||||
return '';
|
||||
}
|
||||
|
||||
async function fetchPageAiTargetBufferState(editorTarget) {
|
||||
if (currentSourceKind() !== 'local_folder') return null;
|
||||
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
|
||||
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
|
||||
var resourceKind = String(target.resourceKind || target.editorKind || workspacePath.resourceKind || '').trim();
|
||||
if (resourceKind === 'office' || resourceKind === 'only_office' || resourceKind === 'onlyoffice') return null;
|
||||
var documentId = String(target.documentId || currentDocumentId() || '').trim();
|
||||
var rootUri = String(workspacePath.rootUri || currentRootUri() || '').trim();
|
||||
if (!documentId || !rootUri) return null;
|
||||
var relativePath = String(workspacePath.relativePath || '').trim()
|
||||
|| localMarkdownRelativePathFromPageAiDocumentId(documentId);
|
||||
var url = new URL('/api/documents/buffer-state', window.location.origin);
|
||||
url.searchParams.set('documentId', documentId);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
var workspaceId = String(target.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim();
|
||||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||
if (relativePath) url.searchParams.set('relativePath', relativePath);
|
||||
try {
|
||||
var response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) return null;
|
||||
return payload.result || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPageAiTargetWritable(editorTarget) {
|
||||
var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
|
||||
var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim();
|
||||
var onlyofficeSessionId = String(editorTarget && (editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId) || '').trim();
|
||||
if ((resourceKind === 'office' || resourceKind === 'only_office' || resourceKind === 'onlyoffice') && !onlyofficeSessionId) {
|
||||
var sessionError = new Error('ONLYOFFICE 资源仍在连接 MNote bridge,请等待 Office 页面加载完成后再让 AI 操作。');
|
||||
sessionError.code = 'page_ai_onlyoffice_bridge_not_ready';
|
||||
throw sessionError;
|
||||
}
|
||||
var snapshotState = pageAiBlockingDirtyState(editorTarget && editorTarget.dirtyState);
|
||||
var bufferState = await fetchPageAiTargetBufferState(editorTarget);
|
||||
var bufferDirtyState = pageAiBlockingDirtyState(bufferState && bufferState.dirtyState);
|
||||
var blockedState = bufferDirtyState || snapshotState;
|
||||
if (!blockedState) return bufferState;
|
||||
var documentId = String(editorTarget && editorTarget.documentId || currentDocumentId() || '').trim();
|
||||
var error = new Error('目标文档存在未保存或外部变更状态(' + blockedState + '),请先保存、解决冲突或刷新后再让 AI 写入。');
|
||||
error.code = 'page_ai_target_buffer_not_writable';
|
||||
error.documentId = documentId;
|
||||
error.dirtyState = blockedState;
|
||||
throw error;
|
||||
}
|
||||
|
||||
function currentPageAiSelectedText() {
|
||||
try {
|
||||
var selection = window.getSelection ? window.getSelection() : null;
|
||||
return selection ? searchText(selection.toString() || '') : '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiProjectionBlocks(aggregate) {
|
||||
var blocks = aggregate && aggregate.body && aggregate.body.blockDocument && aggregate.body.blockDocument.blocks;
|
||||
return Array.isArray(blocks) ? blocks : [];
|
||||
}
|
||||
|
||||
function pageAiBlockText(block) {
|
||||
return searchText(block && (block.text || block.title || block.content) || '');
|
||||
}
|
||||
|
||||
function pageAiSelectedBlockIdsFromSelection() {
|
||||
try {
|
||||
var selection = window.getSelection ? window.getSelection() : null;
|
||||
if (!selection || selection.rangeCount === 0 || searchText(selection.toString() || '') === '') return [];
|
||||
var range = selection.getRangeAt(0);
|
||||
var editor = documentRef.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) return [];
|
||||
return Array.from(editor.children).filter(function(node) {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
try {
|
||||
return range.intersectsNode(node);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}).map(function(node) {
|
||||
return searchText(node.getAttribute('data-id') || node.id || '');
|
||||
}).filter(Boolean);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiBlocksToPageXml(blocks, aggregate) {
|
||||
var revision = aggregate && aggregate.body ? String(aggregate.body.revision || '') : '';
|
||||
var pageId = currentDocumentId() || 'current-page';
|
||||
var lines = ['<page id="' + escapeHtml(pageId) + '" revision="' + escapeHtml(revision) + '">'];
|
||||
blocks.forEach(function(block) {
|
||||
var blockId = String(block && (block.blockId || block.id) || '');
|
||||
var type = String(block && block.type || 'paragraph');
|
||||
var revisionRef = String(block && block.revisionRef || '');
|
||||
var level = block && block.attrs && block.attrs.level ? ' level="' + escapeHtml(block.attrs.level) + '"' : '';
|
||||
lines.push(' <block id="' + escapeHtml(blockId) + '" type="' + escapeHtml(type) + '" revisionRef="' + escapeHtml(revisionRef) + '"' + level + '>' + escapeHtml(pageAiBlockText(block)) + '</block>');
|
||||
});
|
||||
lines.push('</page>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildPageAiContext(contextSnapshot, scope, selectedText) {
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = aggregate.body || {};
|
||||
var allBlocks = pageAiProjectionBlocks(aggregate);
|
||||
var selectedBlockIds = scope === 'selection' ? pageAiSelectedBlockIdsFromSelection() : [];
|
||||
var selectedSet = {};
|
||||
selectedBlockIds.forEach(function(id) { selectedSet[id] = true; });
|
||||
var selectedBlocks = selectedBlockIds.length
|
||||
? allBlocks.filter(function(block) { return selectedSet[String(block.blockId || block.id || '')]; })
|
||||
: [];
|
||||
var contextBlocks = selectedBlocks.length ? selectedBlocks : allBlocks.slice(0, 120);
|
||||
var truncated = !selectedBlocks.length && allBlocks.length > contextBlocks.length;
|
||||
return {
|
||||
schema: 'mnote.page_ai_context.v1',
|
||||
workspaceId: resolveWorkspaceId(documentRef.body),
|
||||
documentId: currentDocumentId(),
|
||||
activeEditorTarget: currentPageAiScopedEditorTarget(),
|
||||
openEditorsSnapshot: currentPageAiOpenEditorsSnapshot(),
|
||||
scope: scope,
|
||||
revision: body.revision || null,
|
||||
conflictDetectionKey: body.conflictDetectionKey || null,
|
||||
selectedText: selectedText || '',
|
||||
selectedBlockIds: selectedBlockIds,
|
||||
allowedTargetBlockIds: selectedBlockIds,
|
||||
selectedBlocks: selectedBlocks,
|
||||
contextBlocks: contextBlocks,
|
||||
pageText: contextBlocks.map(pageAiBlockText).filter(Boolean).join('\n'),
|
||||
pageXml: pageAiBlocksToPageXml(contextBlocks, aggregate),
|
||||
truncated: truncated,
|
||||
warnings: truncated ? [{ code: 'page_ai_context_truncated', message: '页面 AI context 已按前 120 个块裁剪' }] : []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiScopedPageContext(contextSnapshot) {
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
var subtree = contextSnapshot.subtree || null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
var scope = pageUiState.pageAiContextScope || 'page';
|
||||
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
|
||||
var selectedText = scope === 'selection' ? currentPageAiSelectedText() : '';
|
||||
var aiContext = buildPageAiContext(contextSnapshot, scope, selectedText);
|
||||
var editorTarget = aiContext.activeEditorTarget || currentPageAiScopedEditorTarget();
|
||||
return {
|
||||
pageContext: {
|
||||
contextScope: scope,
|
||||
documentBlocks: null,
|
||||
node: {
|
||||
documentId: currentDocumentId(),
|
||||
title: title
|
||||
},
|
||||
subtree: null,
|
||||
outline: null,
|
||||
pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope,
|
||||
evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null,
|
||||
pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null,
|
||||
contentAccess: 'mnote.context.read_current_page',
|
||||
aiContext: aiContext
|
||||
},
|
||||
editorTarget: editorTarget,
|
||||
selectedText: selectedText,
|
||||
selectedBlockId: aiContext.selectedBlockIds[0] || null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
currentPageAiSelectedText,
|
||||
currentPageAiEditorTarget,
|
||||
currentPageAiPageEditorTarget,
|
||||
currentPageAiScopedEditorTarget,
|
||||
currentPageAiOpenEditorsSnapshot,
|
||||
pageAiBlockText,
|
||||
pageAiBlockingDirtyState,
|
||||
pageAiBlocksToPageXml,
|
||||
pageAiBuildAgentTargetPackage,
|
||||
pageAiBuildAllowedRoots,
|
||||
pageAiBuildContextRefs,
|
||||
pageAiBuildRunTargetSnapshot,
|
||||
pageAiCloneJson,
|
||||
pageAiContextKindsFromRefs,
|
||||
pageAiEditorTargetCandidates,
|
||||
pageAiFallbackEditorTarget,
|
||||
pageAiPageContextForRefs,
|
||||
pageAiProjectionBlocks,
|
||||
pageAiResourceKindForTarget,
|
||||
pageAiSelectedBlockIdsFromSelection,
|
||||
pageAiSetRunTargetSnapshot,
|
||||
pageAiScopedPageContext,
|
||||
pageAiTargetFromOpenEditor,
|
||||
pageAiTargetId,
|
||||
pageAiWorkspacePathForDocument,
|
||||
pageAiWorkspacePathForTarget,
|
||||
assertPageAiTargetInCurrentWorkspace,
|
||||
assertPageAiTargetWritable,
|
||||
buildPageAiContext,
|
||||
fetchPageAiTargetBufferState,
|
||||
localMarkdownDocumentIdFromPageAiRelativePath,
|
||||
localMarkdownRelativePathFromPageAiDocumentId,
|
||||
};
|
||||
}
|
||||
@@ -262,6 +262,9 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var shell = document.querySelector('.document-shell');
|
||||
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
|
||||
var mindmapWidthPreference = currentPageWidthPreferences().mindmap || DEFAULT_PAGE_WIDTH_PREFERENCES.mindmap;
|
||||
var mindmapMaxWidth = String(mindmapWidthPreference.cssMaxWidth || pageWidthCssMaxWidth(mindmapWidthPreference.resolvedMode));
|
||||
var mindmapMaxWidthValue = mindmapMaxWidth === 'none' ? 'none' : mindmapMaxWidth;
|
||||
if (shell instanceof HTMLElement) {
|
||||
var widthPreference = activeResourceWidthPreference(options);
|
||||
var cssMaxWidth = String(widthPreference.cssMaxWidth || pageWidthCssMaxWidth(widthPreference.resolvedMode));
|
||||
@@ -276,12 +279,14 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
shell.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||
shell.style.width = '100%';
|
||||
shell.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
|
||||
shell.style.setProperty('--mnote-mindmap-block-max-width', mindmapMaxWidthValue);
|
||||
}
|
||||
if (editorRoot instanceof HTMLElement) {
|
||||
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||
editorRoot.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||
editorRoot.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||
editorRoot.setAttribute('data-page-embed-default-block-id', options.embedDefaultBlockId == null ? '' : String(options.embedDefaultBlockId));
|
||||
editorRoot.style.setProperty('--mnote-mindmap-block-max-width', mindmapMaxWidthValue);
|
||||
}
|
||||
if (editorSurface instanceof HTMLElement) {
|
||||
editorSurface.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
||||
@@ -299,6 +304,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
document.documentElement.setAttribute('data-global-show-heading-numbers', String(globalShowHeadingNumbers));
|
||||
document.documentElement.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||
document.documentElement.setAttribute('data-page-hide-title-header', String(Boolean(options.hideTitleHeader)));
|
||||
document.documentElement.style.setProperty('--mnote-mindmap-block-max-width', mindmapMaxWidthValue);
|
||||
var titleHeader = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header') || document.querySelector('.document-shell-header');
|
||||
if (titleHeader instanceof HTMLElement) {
|
||||
var hidden = Boolean(options.hideTitleHeader);
|
||||
|
||||
@@ -32,46 +32,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
pageWidthPreferences: null,
|
||||
historySnapshots: [],
|
||||
pageSettingsOpen: false,
|
||||
pageAiOpen: false,
|
||||
pageAiBusy: false,
|
||||
pageAiMessages: [],
|
||||
pageAiSuggestionIndex: 0,
|
||||
pageAiProvider: 'hermes',
|
||||
pageAiPage: 'chat',
|
||||
pageAiRunStatus: 'idle',
|
||||
pageAiCurrentRunId: '',
|
||||
pageAiAcpRuntime: 'reasonix',
|
||||
pageAiAcpRuntimes: [],
|
||||
pageAiQueueLength: 0,
|
||||
pageAiQueuedItems: [],
|
||||
pageAiStoppedRunIds: {},
|
||||
pageAiAbortController: null,
|
||||
pageAiContextScope: 'page',
|
||||
pageAiTools: [],
|
||||
pageAiToolsError: '',
|
||||
pageAiGatewayHealth: null,
|
||||
pageAiGatewayHealthError: '',
|
||||
pageAiLastToolCall: null,
|
||||
pageAiProfiles: [],
|
||||
pageAiActiveProfileName: 'mnoteai',
|
||||
pageAiProfileError: '',
|
||||
pageAiProfileMemory: { memory: '', user: '', soul: '' },
|
||||
pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' },
|
||||
pageAiProfileMemoryError: '',
|
||||
pageAiSkills: { categories: [], archived: [] },
|
||||
pageAiSkillCatalogs: { mnote: { categories: [], archived: [] }, reasonix: { categories: [], archived: [] }, hermes: { categories: [], archived: [] } },
|
||||
pageAiSkillPreferences: {},
|
||||
pageAiCollapsedSkillGroups: {},
|
||||
pageAiSkillQuery: '',
|
||||
pageAiSkillError: '',
|
||||
pageAiSkillLoadSeq: 0,
|
||||
pageAiSessions: [],
|
||||
pageAiActiveSessionId: '',
|
||||
pageAiSessionSearchQuery: '',
|
||||
pageAiSessionSearchResults: [],
|
||||
pageAiSessionSearchTimer: 0,
|
||||
pageAiSessionError: '',
|
||||
pageAiPermissionRequests: [],
|
||||
localIndexSummary: {
|
||||
scopeKey: '',
|
||||
loading: false,
|
||||
@@ -928,6 +888,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
function fileTreeRuntimeDeps() {
|
||||
return {
|
||||
currentSourceKind: currentSourceKind,
|
||||
currentRootUri: currentRootUri,
|
||||
currentDocumentId: currentDocumentId,
|
||||
localFilePathFromAssetId: localFilePathFromAssetId,
|
||||
rowTitle: rowTitle,
|
||||
@@ -2382,12 +2343,16 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args);
|
||||
const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
|
||||
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
|
||||
const pageAiSetSkillSource = (...args) => sidebarPageAi.pageAiSetSkillSource(...args);
|
||||
const pageAiToggleSkillGroup = (...args) => sidebarPageAi.pageAiToggleSkillGroup(...args);
|
||||
const pageAiSetReasonixMemoryEnabled = (...args) => sidebarPageAi.pageAiSetReasonixMemoryEnabled(...args);
|
||||
const pageAiSetHideHermesBuiltinSkills = (...args) => sidebarPageAi.pageAiSetHideHermesBuiltinSkills(...args);
|
||||
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args);
|
||||
const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args);
|
||||
const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args);
|
||||
const pageAiSetAgentPopoverOpen = (...args) => sidebarPageAi.pageAiSetAgentPopoverOpen(...args);
|
||||
const pageAiSetTargetPopoverOpen = (...args) => sidebarPageAi.pageAiSetTargetPopoverOpen(...args);
|
||||
const pageAiSelectTarget = (...args) => sidebarPageAi.pageAiSelectTarget(...args);
|
||||
const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
|
||||
|
||||
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
|
||||
@@ -2532,255 +2497,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiClose = closestAction(e.target, '[data-page-ai-action="close"]');
|
||||
if (pageAiClose) {
|
||||
e.preventDefault();
|
||||
closePageAiDrawer();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSettings = closestAction(e.target, '[data-page-ai-action="open-hermes-settings"]');
|
||||
if (pageAiSettings) {
|
||||
e.preventDefault();
|
||||
pageAiOpenHermesSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiStop = closestAction(e.target, '[data-page-ai-action="stop-run"]');
|
||||
if (pageAiStop) {
|
||||
e.preventDefault();
|
||||
void pageAiStopRun();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiRotate = closestAction(e.target, '[data-page-ai-action="rotate"]');
|
||||
if (pageAiRotate) {
|
||||
e.preventDefault();
|
||||
pageUiState.pageAiSuggestionIndex += 1;
|
||||
renderPageAiSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiIntent = closestAction(e.target, '[data-page-ai-intent]');
|
||||
if (pageAiIntent) {
|
||||
e.preventDefault();
|
||||
var intentName = pageAiIntent.getAttribute('data-page-ai-intent') || '';
|
||||
if (intentName === 'create-summary') {
|
||||
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_summary,为当前页面创建或更新 AI Summary。');
|
||||
return;
|
||||
}
|
||||
if (intentName === 'create-ai-note') {
|
||||
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_ai_note,基于当前页面创建一篇新的 AI Note。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var pageAiTab = closestAction(e.target, '[data-page-ai-tab]');
|
||||
if (pageAiTab) {
|
||||
e.preventDefault();
|
||||
pageUiState.pageAiPage = pageAiTab.getAttribute('data-page-ai-tab') || 'chat';
|
||||
if (pageUiState.pageAiPage === 'runtime') void pageAiLoadGatewayHealth();
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
|
||||
if (pageAiProvider) {
|
||||
e.preventDefault();
|
||||
pageUiState.pageAiProvider = pageAiProvider.getAttribute('data-page-ai-provider') || 'hermes';
|
||||
renderPageAiProviderButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiAgent = closestAction(e.target, '[data-page-ai-agent-id]');
|
||||
if (pageAiAgent) {
|
||||
e.preventDefault();
|
||||
pageAiSetAgentId(pageAiAgent.getAttribute('data-page-ai-agent-id') || 'reasonix');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiAgentButton = closestAction(e.target, '[data-page-ai-agent-button]');
|
||||
if (pageAiAgentButton) {
|
||||
e.preventDefault();
|
||||
pageAiSetAgentPopoverOpen(pageAiAgentButton.getAttribute('aria-expanded') !== 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiAgentPopoverClose = closestAction(e.target, '[data-page-ai-action="close-agent-popover"]');
|
||||
if (pageAiAgentPopoverClose) {
|
||||
e.preventDefault();
|
||||
pageAiSetAgentPopoverOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiContextButton = closestAction(e.target, '[data-page-ai-context-button]');
|
||||
if (pageAiContextButton) {
|
||||
e.preventDefault();
|
||||
sidebarPageAi.pageAiSetContextPopoverOpen(
|
||||
pageAiContextButton.getAttribute('aria-expanded') !== 'true'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiContextPopoverClose = closestAction(e.target, '[data-page-ai-action="close-context-popover"]');
|
||||
if (pageAiContextPopoverClose) {
|
||||
e.preventDefault();
|
||||
sidebarPageAi.pageAiSetContextPopoverOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiContextRef = closestAction(e.target, '[data-page-ai-context-ref]');
|
||||
if (pageAiContextRef) {
|
||||
e.preventDefault();
|
||||
pageAiToggleContextRef(pageAiContextRef.getAttribute('data-page-ai-context-ref') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiMemorySave = closestAction(e.target, '[data-page-ai-memory-save]');
|
||||
if (pageAiMemorySave) {
|
||||
e.preventDefault();
|
||||
void pageAiSaveProfileMemory(pageAiMemorySave.getAttribute('data-page-ai-memory-save') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSkillToggle = closestAction(e.target, '[data-page-ai-skill-toggle]');
|
||||
if (pageAiSkillToggle) {
|
||||
e.preventDefault();
|
||||
var skillName = pageAiSkillToggle.getAttribute('data-page-ai-skill-toggle') || '';
|
||||
var skillGroup = pageAiSkillToggle.getAttribute('data-page-ai-skill-group') || '';
|
||||
var skillProfile = pageAiSkillToggle.getAttribute('data-page-ai-skill-profile') || '';
|
||||
var nextEnabled = pageAiSkillToggle.getAttribute('aria-pressed') !== 'true';
|
||||
void pageAiToggleSkill(skillName, nextEnabled, skillGroup, skillProfile);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSkillGroupToggle = closestAction(e.target, '[data-page-ai-skill-group-toggle]');
|
||||
if (pageAiSkillGroupToggle) {
|
||||
e.preventDefault();
|
||||
pageAiToggleSkillGroup(pageAiSkillGroupToggle.getAttribute('data-page-ai-skill-group-toggle') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiToolToggle = closestAction(e.target, '[data-page-ai-tool-toggle]');
|
||||
if (pageAiToolToggle) {
|
||||
e.preventDefault();
|
||||
var toolName = pageAiToolToggle.getAttribute('data-page-ai-tool-toggle') || '';
|
||||
var nextToolEnabled = pageAiToolToggle.getAttribute('aria-pressed') !== 'true';
|
||||
void pageAiToggleTool(toolName, nextToolEnabled);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSessionResume = closestAction(e.target, '[data-page-ai-session-resume]');
|
||||
if (pageAiSessionResume) {
|
||||
e.preventDefault();
|
||||
void pageAiResumeBackendSession(pageAiSessionResume.getAttribute('data-page-ai-session-resume') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSessionRename = closestAction(e.target, '[data-page-ai-session-rename]');
|
||||
if (pageAiSessionRename) {
|
||||
e.preventDefault();
|
||||
void pageAiRenameBackendSession(pageAiSessionRename.getAttribute('data-page-ai-session-rename') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSessionDelete = closestAction(e.target, '[data-page-ai-session-delete]');
|
||||
if (pageAiSessionDelete) {
|
||||
e.preventDefault();
|
||||
void pageAiDeleteBackendSession(pageAiSessionDelete.getAttribute('data-page-ai-session-delete') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiPermissionAction = closestAction(e.target, '[data-page-ai-permission-action]');
|
||||
if (pageAiPermissionAction) {
|
||||
e.preventDefault();
|
||||
pageAiResolvePermission(
|
||||
pageAiPermissionAction.getAttribute('data-page-ai-permission-id') || '',
|
||||
pageAiPermissionAction.getAttribute('data-page-ai-permission-action') || 'deny'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiOpenLocationAction = closestAction(e.target, '[data-page-ai-open-location]');
|
||||
if (pageAiOpenLocationAction) {
|
||||
e.preventDefault();
|
||||
var loc = String(pageAiOpenLocationAction.getAttribute('data-page-ai-open-location') || '').trim();
|
||||
if (loc) pageAiOpenLocation(loc);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
|
||||
if (pageAiSession) {
|
||||
e.preventDefault();
|
||||
pageAiSetActiveSession(pageAiSession.getAttribute('data-page-ai-session') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSuggestion = closestAction(e.target, '[data-page-ai-suggestion]');
|
||||
if (pageAiSuggestion) {
|
||||
e.preventDefault();
|
||||
var text = pageAiSuggestion.getAttribute('data-page-ai-suggestion') || '';
|
||||
var inputNode = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
|
||||
if (inputNode instanceof HTMLTextAreaElement) {
|
||||
inputNode.value = text;
|
||||
inputNode.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiNewSession = closestAction(e.target, '[data-page-ai-action="new-session"]');
|
||||
if (pageAiNewSession) {
|
||||
e.preventDefault();
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiStartNewSession();
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiHistory = closestAction(e.target, '[data-page-ai-action="history"]');
|
||||
if (pageAiHistory) {
|
||||
e.preventDefault();
|
||||
pageAiLoadSessions();
|
||||
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
if (pageUiState.pageAiPage === 'history') {
|
||||
void pageAiLoadBackendSessions().catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSend = closestAction(e.target, '[data-page-ai-action="send"]');
|
||||
if (pageAiSend) {
|
||||
e.preventDefault();
|
||||
var input = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
|
||||
if (input instanceof HTMLTextAreaElement) {
|
||||
var message = input.value;
|
||||
input.value = '';
|
||||
void sendPageAiMessage(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var cancelQueuedRun = closestAction(e.target, '[data-page-ai-action="cancel-queued-run"]');
|
||||
if (cancelQueuedRun) {
|
||||
e.preventDefault();
|
||||
void pageAiCancelQueuedRun(cancelQueuedRun.getAttribute('data-page-ai-queue-id'));
|
||||
return;
|
||||
}
|
||||
|
||||
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
|
||||
if (searchTrigger) {
|
||||
e.preventDefault();
|
||||
@@ -3112,79 +2828,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
closeSearchModal();
|
||||
closeTreeContextMenu();
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
var aiInput = closestAction(event.target, '[data-page-ai-input]');
|
||||
if (aiInput instanceof HTMLTextAreaElement) {
|
||||
event.preventDefault();
|
||||
var text = aiInput.value;
|
||||
aiInput.value = '';
|
||||
void sendPageAiMessage(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('input', function(event) {
|
||||
var skillSearch = closestAction(event.target, '[data-page-ai-skill-search]');
|
||||
if (skillSearch instanceof HTMLInputElement) {
|
||||
pageUiState.pageAiSkillQuery = skillSearch.value;
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var sessionSearch = closestAction(event.target, '[data-page-ai-session-search]');
|
||||
if (sessionSearch instanceof HTMLInputElement) {
|
||||
var sessionQuery = sessionSearch.value;
|
||||
window.clearTimeout(pageUiState.pageAiSessionSearchTimer || 0);
|
||||
pageUiState.pageAiSessionSearchTimer = window.setTimeout(function() {
|
||||
void pageAiSearchBackendSessions(sessionQuery).catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]');
|
||||
if (memoryEditor instanceof HTMLTextAreaElement) {
|
||||
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
|
||||
if (['memory', 'user', 'soul'].indexOf(section) >= 0) {
|
||||
pageUiState.pageAiProfileMemoryDrafts[section] = memoryEditor.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(event) {
|
||||
var pageAiAcpRuntimeSelect = closestAction(event.target, '[data-page-ai-acp-runtime]');
|
||||
if (pageAiAcpRuntimeSelect instanceof HTMLSelectElement) {
|
||||
var next = String(pageAiAcpRuntimeSelect.value || 'reasonix').trim() || 'reasonix';
|
||||
pageUiState.pageAiAcpRuntime = next;
|
||||
pageUiState.pageAiSkills = { categories: [], archived: [] };
|
||||
pageUiState.pageAiSkillError = '';
|
||||
pageAiPersistSessions();
|
||||
void pageAiLoadProfiles();
|
||||
if (next !== 'reasonix') void pageAiLoadProfileMemory();
|
||||
void pageAiLoadSkills();
|
||||
void pageAiLoadBackendSessions().catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
renderPageAiControls();
|
||||
renderPageAiProviderButtons();
|
||||
return;
|
||||
}
|
||||
var pageAiProfileSelect = closestAction(event.target, '[data-page-ai-profile-select]');
|
||||
if (pageAiProfileSelect instanceof HTMLSelectElement) {
|
||||
void pageAiSwitchProfile(pageAiProfileSelect.value);
|
||||
return;
|
||||
}
|
||||
var pageAiHideHermesBuiltin = closestAction(event.target, '[data-page-ai-hide-hermes-builtin]');
|
||||
if (pageAiHideHermesBuiltin instanceof HTMLInputElement) {
|
||||
pageAiSetHideHermesBuiltinSkills(pageAiHideHermesBuiltin.checked);
|
||||
return;
|
||||
}
|
||||
var pageAiContextSelect = closestAction(event.target, '[data-page-ai-context-scope]');
|
||||
if (pageAiContextSelect instanceof HTMLSelectElement) {
|
||||
pageAiSetContextScope(pageAiContextSelect.value);
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var globalCheckbox = closestAction(event.target, '[data-global-option-checkbox="showHeadingNumbers"]');
|
||||
if (globalCheckbox instanceof HTMLInputElement) {
|
||||
writeGlobalShowHeadingNumbers(globalCheckbox.checked);
|
||||
@@ -3218,6 +2864,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
void persistPageWidthPreference(pageWidthType, pageWidthSelect.value);
|
||||
}
|
||||
});
|
||||
sidebarPageAi.installPageAiDelegates();
|
||||
|
||||
function initializePageUiSurfaces() {
|
||||
pageUiState.pageOptions = null;
|
||||
@@ -3243,6 +2890,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
}
|
||||
scheduleInitializePageUiSurfaces();
|
||||
|
||||
function mnoteDevHotReloadEnabled() {
|
||||
try {
|
||||
return new URL(import.meta.url).searchParams.has('devHot');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function installMnoteDevHotReload() {
|
||||
var bootId = '';
|
||||
var failedOnce = false;
|
||||
@@ -3271,7 +2926,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
tick();
|
||||
timer = window.setInterval(tick, 1000);
|
||||
}
|
||||
installMnoteDevHotReload();
|
||||
if (mnoteDevHotReloadEnabled()) {
|
||||
installMnoteDevHotReload();
|
||||
}
|
||||
|
||||
document.addEventListener('dragstart', function(event) {
|
||||
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"][draggable="true"]');
|
||||
|
||||
@@ -260,6 +260,20 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
event: "session.info.updated".into(),
|
||||
data: json!({ "title": title }),
|
||||
}),
|
||||
AcpSessionEvent::ProviderConversationBound {
|
||||
provider,
|
||||
remote_conversation_id,
|
||||
remote_url,
|
||||
acp_session_id,
|
||||
} => Some(SseEvent {
|
||||
event: "provider.conversation.bound".into(),
|
||||
data: json!({
|
||||
"provider": provider,
|
||||
"remoteConversationId": remote_conversation_id,
|
||||
"remoteUrl": remote_url,
|
||||
"acpSessionId": acp_session_id,
|
||||
}),
|
||||
}),
|
||||
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
|
||||
event: "plan.updated".into(),
|
||||
data: json!({ "entries": entries }),
|
||||
|
||||
@@ -54,6 +54,13 @@ pub enum AcpSessionEvent {
|
||||
},
|
||||
/// 会话元数据更新,例如自动标题。
|
||||
SessionInfoUpdate { title: String },
|
||||
/// Provider 侧远端会话绑定,例如豆包 conversation_id。
|
||||
ProviderConversationBound {
|
||||
provider: String,
|
||||
remote_conversation_id: String,
|
||||
remote_url: Option<String>,
|
||||
acp_session_id: Option<String>,
|
||||
},
|
||||
/// 计划条目更新。
|
||||
PlanUpdate { entries: Vec<String> },
|
||||
/// 连接关闭或异常。
|
||||
@@ -765,6 +772,46 @@ impl AcpSessionManager {
|
||||
Some(AcpSessionEvent::PlanUpdate { entries: summaries })
|
||||
}
|
||||
|
||||
SessionUpdate::Unknown {
|
||||
session_update,
|
||||
extra,
|
||||
} if session_update == "provider.conversation.bound" => {
|
||||
let provider = extra
|
||||
.get("provider")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("doubao-web")
|
||||
.to_string();
|
||||
let remote_conversation_id = extra
|
||||
.get("remoteConversationId")
|
||||
.or_else(|| extra.get("remote_conversation_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let remote_url = extra
|
||||
.get("remoteUrl")
|
||||
.or_else(|| extra.get("remote_url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let acp_session_id = extra
|
||||
.get("acpSessionId")
|
||||
.or_else(|| extra.get("acp_session_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
Some(AcpSessionEvent::ProviderConversationBound {
|
||||
provider,
|
||||
remote_conversation_id,
|
||||
remote_url,
|
||||
acp_session_id,
|
||||
})
|
||||
}
|
||||
|
||||
SessionUpdate::Unknown { .. } => {
|
||||
warn!("ACP unknown session/update variant");
|
||||
None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ pub mod block;
|
||||
pub mod context_tools;
|
||||
pub mod doc;
|
||||
pub mod manifest;
|
||||
pub mod onlyoffice_live;
|
||||
pub mod page;
|
||||
pub mod resource;
|
||||
pub mod skill;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,11 +23,17 @@ pub async fn mindmap_fetch(
|
||||
})?;
|
||||
let data =
|
||||
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| json!({ "raw": content }));
|
||||
let nodes = collect_mindmap_nodes(&data);
|
||||
let scope = input
|
||||
.arg_string("scope")
|
||||
.unwrap_or_else(|| "tree".into())
|
||||
.to_ascii_lowercase();
|
||||
let root = mindmap_root_value(&data).clone();
|
||||
let nodes = collect_mindmap_nodes(&root);
|
||||
let envelope = if scope == "full_envelope" {
|
||||
data
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
Ok(json!({
|
||||
"objectIdentity": target.object_identity,
|
||||
"resourceKind": "mindmap",
|
||||
@@ -35,6 +41,8 @@ pub async fn mindmap_fetch(
|
||||
"mindmapId": target.resource_id,
|
||||
"resourcePath": target.resource_path,
|
||||
"scope": scope,
|
||||
"root": root,
|
||||
"envelope": envelope,
|
||||
"nodes": nodes,
|
||||
"edges": [],
|
||||
"markdownSummary": mindmap_markdown_summary(&nodes),
|
||||
@@ -67,14 +75,150 @@ pub async fn mindmap_apply_ops(
|
||||
"diff": [{"op": "mindmap.apply_ops", "ops": ops}]
|
||||
}));
|
||||
}
|
||||
Err(WebError::bad_request_code(
|
||||
"mnote_resource_native_patch_required",
|
||||
format!(
|
||||
"本地 mindmap resource 写入请使用 agent 原生 patch 编辑授权文件;已校验可写资源路径 {}",
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
.with_context(context))
|
||||
ensure_mindmap_revision_precondition(context, input, &path)?;
|
||||
let content = fs::read_to_string(&path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_read_failed",
|
||||
format!("无法读取 mindmap resource: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let mut envelope = serde_json::from_str::<Value>(&content).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_json_invalid",
|
||||
format!("mindmap resource 不是有效 JSON: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
apply_mindmap_ops(context, &mut envelope, &ops)?;
|
||||
let serialized = serde_json::to_string_pretty(&envelope).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_serialize_failed",
|
||||
format!("无法序列化 mindmap JSON: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
fs::write(&path, serialized).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_write_failed",
|
||||
format!("无法写入 mindmap resource: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let root = mindmap_root_value(&envelope).clone();
|
||||
let nodes = collect_mindmap_nodes(&root);
|
||||
Ok(json!({
|
||||
"dryRun": false,
|
||||
"commandName": "mnote.mindmap.apply_ops",
|
||||
"objectIdentity": target.object_identity,
|
||||
"resourceKind": "mindmap",
|
||||
"documentId": target.document_id,
|
||||
"mindmapId": target.resource_id,
|
||||
"resourcePath": target.resource_path,
|
||||
"root": root,
|
||||
"nodes": nodes,
|
||||
"edges": [],
|
||||
"markdownSummary": mindmap_markdown_summary(&nodes),
|
||||
"revision": file_revision(&path),
|
||||
"changedFiles": [target.relative_path()],
|
||||
"source": "local_folder"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn mindmap_create_from_outline(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
ensure_resource_write_contract(context, input)?;
|
||||
let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?;
|
||||
ensure_resource_scope_allowed(context, input, &target)?;
|
||||
let path = target.resolve_create_path(context, input)?;
|
||||
let title = input
|
||||
.arg_string("title")
|
||||
.unwrap_or_else(|| "KMIND".to_string());
|
||||
let outline = input.arg_value("outline").ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_mindmap_outline_required", "创建思维导图缺少 outline")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let source_refs = input.arg_value("sourceRefs").unwrap_or_else(|| json!([]));
|
||||
let envelope = mindmap_envelope_from_outline(&title, &outline, source_refs, context)?;
|
||||
let root = mindmap_root_value(&envelope).clone();
|
||||
let nodes = collect_mindmap_nodes(&root);
|
||||
let resource_relative_path = target.relative_path();
|
||||
let mut changed_files = if input.dry_run.unwrap_or(false) {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![resource_relative_path.clone()]
|
||||
};
|
||||
let mut embed_result = Value::Null;
|
||||
|
||||
if !input.dry_run.unwrap_or(false) {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_bad_path", "无法解析 mindmap 父目录")
|
||||
.with_context(context)
|
||||
})?;
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_create_dir_failed",
|
||||
format!("无法创建 mindmap 目录: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let content = serde_json::to_string_pretty(&envelope).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_serialize_failed",
|
||||
format!("无法序列化 mindmap JSON: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
fs::write(&path, content).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_write_failed",
|
||||
format!("无法写入 mindmap resource: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
if input
|
||||
.arg_value("embedIntoPage")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
embed_result = embed_mindmap_into_local_markdown_page(
|
||||
context,
|
||||
input,
|
||||
&target.document_id,
|
||||
&resource_relative_path,
|
||||
&title,
|
||||
)?;
|
||||
if let Some(changed_file) = embed_result
|
||||
.get("changedFile")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
changed_files.push(changed_file.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"dryRun": input.dry_run.unwrap_or(false),
|
||||
"commandName": "mnote.mindmap.create_from_outline",
|
||||
"objectIdentity": target.object_identity,
|
||||
"resourceKind": "mindmap",
|
||||
"documentId": target.document_id,
|
||||
"mindmapId": target.resource_id,
|
||||
"resourcePath": target.resource_path,
|
||||
"envelope": envelope,
|
||||
"root": root,
|
||||
"nodes": nodes,
|
||||
"edges": [],
|
||||
"markdownSummary": mindmap_markdown_summary(&nodes),
|
||||
"revision": if input.dry_run.unwrap_or(false) { Value::Null } else { file_revision(&path) },
|
||||
"changedFiles": changed_files,
|
||||
"embedResult": embed_result,
|
||||
"source": "local_folder"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn office_fetch_summary(
|
||||
@@ -235,6 +379,45 @@ impl ResourceToolTarget {
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn resolve_create_path(
|
||||
&self,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_resource_root_uri_required",
|
||||
"资源工具需要授权 rootUri",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let relative = self.relative_path();
|
||||
let relative_path = Path::new(&relative);
|
||||
if relative_path.is_absolute()
|
||||
|| relative_path
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_resource_root_escape",
|
||||
"资源工具不能越过授权目录",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
let path = root.join(relative_path);
|
||||
if !path.starts_with(&root) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_resource_root_escape",
|
||||
"资源工具不能越过授权目录",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn relative_path(&self) -> String {
|
||||
self.resource_path
|
||||
.as_deref()
|
||||
@@ -315,9 +498,508 @@ fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn embed_mindmap_into_local_markdown_page(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
document_id: &str,
|
||||
resource_relative_path: &str,
|
||||
title: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_resource_root_uri_required",
|
||||
"资源工具需要授权 rootUri",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let page_relative_path = local_markdown_relative_path_from_document_id(context, document_id)?;
|
||||
let page_path = root.join(&page_relative_path);
|
||||
if !page_path.starts_with(&root) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_resource_root_escape",
|
||||
"资源工具不能越过授权目录",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if !page_path.is_file() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_resource_page_not_found",
|
||||
"找不到要绑定 mindmap 的本地 Markdown 页面",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
let href = markdown_href_from_page(&root, &page_path, resource_relative_path);
|
||||
let mut markdown = fs::read_to_string(&page_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_page_read_failed",
|
||||
format!("无法读取本地 Markdown 页面: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let link = format!("[{}]({href})", markdown_link_label(title));
|
||||
if markdown.contains(&link) || markdown.contains(&format!("]({href})")) {
|
||||
return Ok(json!({
|
||||
"status": "already_present",
|
||||
"documentId": document_id,
|
||||
"changedFile": page_relative_path,
|
||||
"href": href
|
||||
}));
|
||||
}
|
||||
if !markdown.ends_with('\n') {
|
||||
markdown.push('\n');
|
||||
}
|
||||
if !markdown.ends_with("\n\n") {
|
||||
markdown.push('\n');
|
||||
}
|
||||
markdown.push_str(&link);
|
||||
markdown.push('\n');
|
||||
fs::write(&page_path, markdown).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_page_write_failed",
|
||||
format!("无法写入本地 Markdown 页面: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
Ok(json!({
|
||||
"status": "embedded",
|
||||
"documentId": document_id,
|
||||
"changedFile": page_relative_path,
|
||||
"href": href
|
||||
}))
|
||||
}
|
||||
|
||||
fn local_markdown_relative_path_from_document_id(
|
||||
context: &RequestContext,
|
||||
document_id: &str,
|
||||
) -> Result<String, WebError> {
|
||||
let encoded = document_id
|
||||
.trim()
|
||||
.strip_prefix("local-md:")
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_local_markdown_required",
|
||||
"绑定 mindmap 需要 local-md 页面",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let relative = crate::routes::decode_local_id_segment(encoded)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let path = Path::new(&relative);
|
||||
if path.is_absolute()
|
||||
|| path
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_resource_root_escape",
|
||||
"资源工具不能越过授权目录",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
fn markdown_href_from_page(root: &Path, page_path: &Path, resource_relative_path: &str) -> String {
|
||||
let page_dir = page_path.parent().unwrap_or(root);
|
||||
let target = root.join(resource_relative_path);
|
||||
if let Ok(relative) = target.strip_prefix(page_dir) {
|
||||
return path_to_markdown_href(relative);
|
||||
}
|
||||
let page_dir_relative = page_dir.strip_prefix(root).unwrap_or(Path::new(""));
|
||||
let depth = page_dir_relative
|
||||
.components()
|
||||
.filter(|component| matches!(component, std::path::Component::Normal(_)))
|
||||
.count();
|
||||
let mut href = String::new();
|
||||
for _ in 0..depth {
|
||||
href.push_str("../");
|
||||
}
|
||||
href.push_str(&resource_relative_path.replace('\\', "/"));
|
||||
href
|
||||
}
|
||||
|
||||
fn path_to_markdown_href(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
fn markdown_link_label(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('[', "\\[")
|
||||
.replace(']', "\\]")
|
||||
.replace('\n', " ")
|
||||
}
|
||||
|
||||
fn default_mindmap_view() -> Value {
|
||||
json!({
|
||||
"state": {
|
||||
"scale": 1,
|
||||
"sx": 0,
|
||||
"sy": 0,
|
||||
"x": -44.99991989135742_f64,
|
||||
"y": -15.500006675720217_f64
|
||||
},
|
||||
"transform": {
|
||||
"a": 1,
|
||||
"b": 0,
|
||||
"c": 0,
|
||||
"d": 1,
|
||||
"e": -44.99991989135742_f64,
|
||||
"f": -15.500006675720217_f64,
|
||||
"originX": 0,
|
||||
"originY": 0,
|
||||
"rotate": 0,
|
||||
"scaleX": 1,
|
||||
"scaleY": 1,
|
||||
"shear": 0,
|
||||
"translateX": -44.99991989135742_f64,
|
||||
"translateY": -15.500006675720217_f64
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn mindmap_envelope_from_outline(
|
||||
title: &str,
|
||||
outline: &Value,
|
||||
source_refs: Value,
|
||||
context: &RequestContext,
|
||||
) -> Result<Value, WebError> {
|
||||
let outline_items = outline.as_array().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_mindmap_outline_invalid",
|
||||
"mindmap outline 必须是数组",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let title = title.trim();
|
||||
let root_title = if title.is_empty() { "KMIND" } else { title };
|
||||
Ok(json!({
|
||||
"data": {
|
||||
"children": mindmap_outline_items_to_children(outline_items, "node"),
|
||||
"data": {
|
||||
"expand": true,
|
||||
"isActive": false,
|
||||
"text": root_title,
|
||||
"uid": "root"
|
||||
}
|
||||
},
|
||||
"view": default_mindmap_view(),
|
||||
"metadata": {
|
||||
"sourceRefs": source_refs
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn mindmap_outline_items_to_children(items: &[Value], prefix: &str) -> Vec<Value> {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let ordinal = index + 1;
|
||||
let uid = format!("{prefix}_{ordinal}");
|
||||
let text = item
|
||||
.get("text")
|
||||
.or_else(|| item.get("title"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("未命名节点");
|
||||
let children = item
|
||||
.get("children")
|
||||
.and_then(Value::as_array)
|
||||
.map(|children| mindmap_outline_items_to_children(children, &uid))
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"data": {
|
||||
"expand": true,
|
||||
"isActive": false,
|
||||
"text": text,
|
||||
"uid": uid
|
||||
},
|
||||
"children": children
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mindmap_root_value(value: &Value) -> &Value {
|
||||
value
|
||||
.get("data")
|
||||
.filter(|data| data.get("data").is_some() || data.get("children").is_some())
|
||||
.unwrap_or(value)
|
||||
}
|
||||
|
||||
fn mindmap_root_value_mut(value: &mut Value) -> Option<&mut Value> {
|
||||
let has_envelope_root = value
|
||||
.get("data")
|
||||
.map(|data| data.get("data").is_some() || data.get("children").is_some())
|
||||
.unwrap_or(false);
|
||||
if has_envelope_root {
|
||||
return value.get_mut("data");
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn apply_mindmap_ops(
|
||||
context: &RequestContext,
|
||||
envelope: &mut Value,
|
||||
ops: &Value,
|
||||
) -> Result<(), WebError> {
|
||||
let ops = ops.as_array().ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_ops_invalid", "mindmap ops 必须是数组")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let root = mindmap_root_value_mut(envelope).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_json_invalid", "mindmap 缺少 root")
|
||||
.with_context(context)
|
||||
})?;
|
||||
for op in ops {
|
||||
apply_mindmap_op(context, root, op)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_mindmap_op(
|
||||
context: &RequestContext,
|
||||
root: &mut Value,
|
||||
op: &Value,
|
||||
) -> Result<(), WebError> {
|
||||
let op_name = op
|
||||
.get("op")
|
||||
.or_else(|| op.get("type"))
|
||||
.or_else(|| op.get("action"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_mindmap_op_name)
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_op_required", "mindmap op 缺少 op")
|
||||
.with_context(context)
|
||||
})?;
|
||||
match op_name.as_str() {
|
||||
"updatetext" | "updatenode" => {
|
||||
let node_id = mindmap_op_string(op, &["nodeId", "node_id", "id"]).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_node_required", "更新节点缺少 nodeId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let text = mindmap_op_string(op, &["text", "title"]).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_text_required", "更新节点缺少 text")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let node = find_mindmap_node_mut(root, &node_id).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_node_not_found", "找不到要更新的节点")
|
||||
.with_context(context)
|
||||
})?;
|
||||
set_mindmap_node_text(node, &text);
|
||||
}
|
||||
"insertchild" | "addchild" => {
|
||||
let parent_id = mindmap_op_string(op, &["parentId", "parent_id", "nodeId"])
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_parent_required",
|
||||
"插入子节点缺少 parentId",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let parent = find_mindmap_node_mut(root, &parent_id).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_node_not_found", "找不到父节点")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let child_input = op.get("node").unwrap_or(op);
|
||||
let child = mindmap_node_from_input(context, child_input)?;
|
||||
ensure_mindmap_children_array(context, parent)?.push(child);
|
||||
}
|
||||
"deletenode" => {
|
||||
let node_id = mindmap_op_string(op, &["nodeId", "node_id", "id"]).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_node_required", "删除节点缺少 nodeId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
if mindmap_node_matches(root, &node_id) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_resource_root_delete_forbidden",
|
||||
"不能删除 mindmap 根节点",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
remove_mindmap_node(root, &node_id).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_node_not_found", "找不到要删除的节点")
|
||||
.with_context(context)
|
||||
})?;
|
||||
}
|
||||
_ => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_resource_op_unsupported",
|
||||
format!("暂不支持 mindmap op: {op_name}"),
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_mindmap_op_name(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| *ch != '_' && *ch != '-' && !ch.is_whitespace())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mindmap_op_string(op: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter().find_map(|key| {
|
||||
op.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_mindmap_node_mut<'a>(node: &'a mut Value, node_id: &str) -> Option<&'a mut Value> {
|
||||
if mindmap_node_matches(node, node_id) {
|
||||
return Some(node);
|
||||
}
|
||||
let children = node.get_mut("children").and_then(Value::as_array_mut)?;
|
||||
for child in children {
|
||||
if let Some(found) = find_mindmap_node_mut(child, node_id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mindmap_node_matches(node: &Value, node_id: &str) -> bool {
|
||||
mindmap_node_id(node)
|
||||
.as_deref()
|
||||
.map(|value| value == node_id)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn mindmap_node_id(node: &Value) -> Option<String> {
|
||||
node.pointer("/data/uid")
|
||||
.or_else(|| node.pointer("/data/id"))
|
||||
.or_else(|| node.get("uid"))
|
||||
.or_else(|| node.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn set_mindmap_node_text(node: &mut Value, text: &str) {
|
||||
if let Some(data) = node.get_mut("data").and_then(Value::as_object_mut) {
|
||||
data.insert("text".into(), json!(text));
|
||||
return;
|
||||
}
|
||||
if let Some(object) = node.as_object_mut() {
|
||||
object.insert("text".into(), json!(text));
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_mindmap_children_array<'a>(
|
||||
context: &RequestContext,
|
||||
node: &'a mut Value,
|
||||
) -> Result<&'a mut Vec<Value>, WebError> {
|
||||
let object = node.as_object_mut().ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_node_invalid", "mindmap 节点必须是对象")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let children = object
|
||||
.entry("children")
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !children.is_array() {
|
||||
*children = Value::Array(Vec::new());
|
||||
}
|
||||
Ok(children.as_array_mut().expect("children 已归一为数组"))
|
||||
}
|
||||
|
||||
fn mindmap_node_from_input(context: &RequestContext, input: &Value) -> Result<Value, WebError> {
|
||||
if input.get("data").is_some() || input.get("children").is_some() {
|
||||
let mut node = input.clone();
|
||||
ensure_mindmap_children_array(context, &mut node)?;
|
||||
return Ok(node);
|
||||
}
|
||||
let text = input
|
||||
.get("text")
|
||||
.or_else(|| input.get("title"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("未命名节点");
|
||||
let uid = input
|
||||
.get("uid")
|
||||
.or_else(|| input.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(text);
|
||||
let mut node = json!({
|
||||
"data": {
|
||||
"expand": true,
|
||||
"isActive": false,
|
||||
"text": text,
|
||||
"uid": uid
|
||||
},
|
||||
"children": []
|
||||
});
|
||||
if let Some(object) = node.as_object_mut() {
|
||||
for key in ["metadata", "sourceRefs", "refs", "note", "hyperlink"] {
|
||||
if let Some(value) = input.get(key) {
|
||||
object.insert(key.into(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn remove_mindmap_node(parent: &mut Value, node_id: &str) -> Option<Value> {
|
||||
let children = parent.get_mut("children").and_then(Value::as_array_mut)?;
|
||||
if let Some(index) = children
|
||||
.iter()
|
||||
.position(|child| mindmap_node_matches(child, node_id))
|
||||
{
|
||||
return Some(children.remove(index));
|
||||
}
|
||||
for child in children {
|
||||
if let Some(removed) = remove_mindmap_node(child, node_id) {
|
||||
return Some(removed);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ensure_mindmap_revision_precondition(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
path: &Path,
|
||||
) -> Result<(), WebError> {
|
||||
let Some(expected) = input.arg_value("expectedRevision") else {
|
||||
return Ok(());
|
||||
};
|
||||
let current = file_revision(path);
|
||||
if revision_label(&expected).as_deref() != revision_label(¤t).as_deref() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_resource_revision_conflict",
|
||||
"mindmap resource revision 已变化,请重新读取后再写入",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn revision_label(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(value) => Some(value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
Value::Number(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_mindmap_nodes(value: &Value) -> Vec<Value> {
|
||||
let mut nodes = Vec::new();
|
||||
collect_mindmap_nodes_inner(value, &mut nodes);
|
||||
collect_mindmap_nodes_inner(mindmap_root_value(value), &mut nodes);
|
||||
nodes
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,68 @@ const SKILLS: &[MnoteSkill] = &[
|
||||
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
||||
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
|
||||
},
|
||||
MnoteSkill {
|
||||
id: "mnote-onlyoffice-live",
|
||||
title: "MNote ONLYOFFICE live bridge",
|
||||
description: "Operate the currently open ONLYOFFICE editor session for Word, Excel, and PPT.",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: false,
|
||||
requires_context_refs: &["onlyoffice"],
|
||||
tool_names: &[
|
||||
"mnote.onlyoffice.session.current",
|
||||
"mnote.onlyoffice.capabilities",
|
||||
"mnote.onlyoffice.selection.get",
|
||||
"mnote.onlyoffice.document.insert_text",
|
||||
"mnote.onlyoffice.document.replace_selection",
|
||||
"mnote.onlyoffice.document.insert_html",
|
||||
"mnote.onlyoffice.document.export",
|
||||
"mnote.onlyoffice.document.search_replace",
|
||||
"mnote.onlyoffice.document.insert_table",
|
||||
"mnote.onlyoffice.document.get_comments",
|
||||
"mnote.onlyoffice.document.add_comment",
|
||||
"mnote.onlyoffice.sheet.get_sheets",
|
||||
"mnote.onlyoffice.sheet.add_sheet",
|
||||
"mnote.onlyoffice.sheet.rename_sheet",
|
||||
"mnote.onlyoffice.sheet.get_range",
|
||||
"mnote.onlyoffice.sheet.get_range_values",
|
||||
"mnote.onlyoffice.sheet.get_values",
|
||||
"mnote.onlyoffice.sheet.set_value",
|
||||
"mnote.onlyoffice.sheet.set_formula",
|
||||
"mnote.onlyoffice.sheet.batch_set_values",
|
||||
"mnote.onlyoffice.sheet.set_range_values",
|
||||
"mnote.onlyoffice.sheet.format_range",
|
||||
"mnote.onlyoffice.sheet.set_dimensions",
|
||||
"mnote.onlyoffice.sheet.sort_range",
|
||||
"mnote.onlyoffice.sheet.add_chart",
|
||||
"mnote.onlyoffice.presentation.get_slides",
|
||||
"mnote.onlyoffice.presentation.get_slide_texts",
|
||||
"mnote.onlyoffice.presentation.get_shapes",
|
||||
"mnote.onlyoffice.presentation.add_text_slide",
|
||||
"mnote.onlyoffice.presentation.replace_text",
|
||||
"mnote.onlyoffice.presentation.set_shape_text",
|
||||
"mnote.onlyoffice.presentation.delete_slide",
|
||||
"mnote.onlyoffice.presentation.add_table",
|
||||
"mnote.onlyoffice.presentation.clear_slide",
|
||||
"mnote.onlyoffice.presentation.add_shape",
|
||||
],
|
||||
content: include_str!("../../../../../skills/mnote-onlyoffice-live/SKILL.md"),
|
||||
},
|
||||
MnoteSkill {
|
||||
id: "mnote-mindmap",
|
||||
title: "MNote mindmap editing",
|
||||
description: "Read, update, summarize, or create MNote mindmap resources, including generating a new mindmap from PDF or document outlines.",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: false,
|
||||
requires_context_refs: &["current_page", "file", "folder", "resource"],
|
||||
tool_names: &[
|
||||
"mnote.context.snapshot",
|
||||
"mnote.context.resolve_target",
|
||||
"mnote.mindmap.fetch",
|
||||
"mnote.mindmap.apply_ops",
|
||||
"mnote.mindmap.create_from_outline",
|
||||
],
|
||||
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
|
||||
},
|
||||
MnoteSkill {
|
||||
id: "mnote-chat-only",
|
||||
title: "MNote chat only",
|
||||
@@ -146,4 +208,108 @@ mod tests {
|
||||
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
|
||||
assert!(find_skill("missing", Some("reasonix")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
|
||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
||||
let skill = reasonix_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-onlyoffice-live")
|
||||
.expect("reasonix should see live ONLYOFFICE skill");
|
||||
assert_eq!(skill["readOnly"], false);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.session.current"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.sheet.batch_set_values"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.sheet.add_chart"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.presentation.add_shape"),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.onlyoffice.presentation.replace_text"),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_registry_exposes_mindmap_skill_to_agents() {
|
||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
||||
let skill = reasonix_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-mindmap")
|
||||
.expect("reasonix should see mindmap skill");
|
||||
assert_eq!(skill["readOnly"], false);
|
||||
assert!(skill["requiresContextRefs"]
|
||||
.as_array()
|
||||
.expect("context refs")
|
||||
.iter()
|
||||
.any(|value| value == "resource"));
|
||||
assert!(skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.mindmap.create_from_outline"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_read_returns_mindmap_skill_content() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/tools/execute".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let input: ToolCallInput = serde_json::from_value(json!({
|
||||
"toolName": "mnote.skill.read",
|
||||
"args": {
|
||||
"skillId": "mnote-mindmap",
|
||||
"agentId": "reasonix"
|
||||
}
|
||||
}))
|
||||
.expect("input");
|
||||
|
||||
let payload = skill_read(&context, &input).await.expect("skill read");
|
||||
|
||||
assert_eq!(payload["skill"]["id"], "mnote-mindmap");
|
||||
assert!(payload["content"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("mnote.mindmap.create_from_outline"));
|
||||
let content = payload["content"].as_str().unwrap_or_default();
|
||||
assert!(content.contains("Mindmap outline format"));
|
||||
assert!(content.contains("Use concise keywords or short phrases"));
|
||||
assert!(content.contains("Avoid long paragraphs"));
|
||||
assert!(payload["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.any(|tool| tool == "mnote.mindmap.create_from_outline"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ impl PageAggregateBuilder {
|
||||
Self {
|
||||
document_id: String::new(),
|
||||
workspace_id: "default".to_string(),
|
||||
source: PageAggregateSource::CompatMetaContentJoin,
|
||||
source: PageAggregateSource::KernelProjection,
|
||||
parent_id: None,
|
||||
path: Vec::new(),
|
||||
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
|
||||
@@ -372,3 +372,29 @@ impl Default for PageAggregateBuilder {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_defaults_to_kernel_projection() {
|
||||
let aggregate = PageAggregateBuilder::new()
|
||||
.document_id("doc_1")
|
||||
.workspace_id("ws_demo")
|
||||
.build();
|
||||
|
||||
assert_eq!(aggregate.source, PageAggregateSource::KernelProjection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_compat_source_is_still_supported() {
|
||||
let aggregate = PageAggregateBuilder::new()
|
||||
.document_id("doc_1")
|
||||
.workspace_id("ws_demo")
|
||||
.source(PageAggregateSource::CompatMetaContentJoin)
|
||||
.build();
|
||||
|
||||
assert_eq!(aggregate.source, PageAggregateSource::CompatMetaContentJoin);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@ use crate::page_aggregate::{
|
||||
use crate::routes::local_markdown_parser::{
|
||||
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
use crate::routes::local_search_index;
|
||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use crate::routes::{local_ocr, local_search_index};
|
||||
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -4076,8 +4076,13 @@ fn local_mindmap_file_name(mindmap_id: &str) -> String {
|
||||
fn is_local_mindmap_file_name(file_name: &str) -> bool {
|
||||
let trimmed = file_name.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if !lower.ends_with(".json") {
|
||||
return false;
|
||||
}
|
||||
lower.ends_with(".mindmap.json")
|
||||
|| (trimmed.starts_with("思维导图") && lower.ends_with(".json"))
|
||||
|| lower.starts_with("mindmap-")
|
||||
|| lower.starts_with("mindmap_")
|
||||
|| trimmed.starts_with("思维导图")
|
||||
}
|
||||
|
||||
fn resolve_local_mindmap_path(
|
||||
@@ -7395,6 +7400,9 @@ fn scan_markdown_page_tree(
|
||||
continue;
|
||||
}
|
||||
if is_markdown_file(&entry.file_name) {
|
||||
if local_ocr::is_local_ocr_sidecar_relative_path(&entry.relative_path) {
|
||||
continue;
|
||||
}
|
||||
let markdown = fs::read_to_string(&entry.path).unwrap_or_default();
|
||||
let parsed = parse_markdown_page(&markdown, &entry.file_name);
|
||||
let page_id = local_markdown_path_page_id(&entry.relative_path);
|
||||
@@ -8426,7 +8434,6 @@ fn editor_blocks_to_markdown_with_rewrite(
|
||||
lines.push(text);
|
||||
} else {
|
||||
let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context)
|
||||
.map(|value| markdown_href_for_relative_path(&value))
|
||||
.unwrap_or_else(|| src.to_string());
|
||||
lines.push(format!(
|
||||
"",
|
||||
@@ -10424,6 +10431,61 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_generated_mindmap_id_roundtrips_as_mindmap_block() {
|
||||
let root = temp_root("mnote-local-markdown-generated-mindmap-roundtrip");
|
||||
init_workspace(&root);
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
write_local_mindmap_data(
|
||||
&root_uri,
|
||||
"local-md:Page~2FPage.md",
|
||||
"mindmap-123456",
|
||||
serde_json::json!({"data":{"text":"中心主题"},"children":[]}),
|
||||
false,
|
||||
)
|
||||
.expect("write generated mindmap");
|
||||
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
"local-md:Page~2FPage.md",
|
||||
None,
|
||||
&serde_json::json!([
|
||||
{
|
||||
"type": "mindmap",
|
||||
"props": {
|
||||
"name": "思维导图",
|
||||
"mindmapId": "mindmap-123456",
|
||||
"rootNodeId": "root"
|
||||
}
|
||||
}
|
||||
]),
|
||||
)
|
||||
.expect("save");
|
||||
|
||||
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
|
||||
assert!(saved.contains("[思维导图](mindmap-123456.json)"));
|
||||
|
||||
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:Page~2FPage.md")
|
||||
.expect("aggregate");
|
||||
let mindmap = aggregate.body.content.as_array().expect("blocks")[0].clone();
|
||||
assert_eq!(mindmap["type"], "mindmap");
|
||||
assert_eq!(mindmap["props"]["sourcePath"], "mindmap-123456.json");
|
||||
assert_eq!(mindmap["props"]["mindmapId"], "mindmap-123456.json");
|
||||
assert_eq!(
|
||||
aggregate.body.block_document["blocks"][0]["attrs"]["mindmapId"],
|
||||
serde_json::json!("mindmap-123456.json")
|
||||
);
|
||||
assert_eq!(
|
||||
aggregate.body.block_document["blocks"][0]["attrs"]["sourcePath"],
|
||||
serde_json::json!("mindmap-123456.json")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_workspace_access_rejects_owner_mismatch() {
|
||||
let root = temp_root("mnote-local-workspace-owner-mismatch");
|
||||
@@ -12656,6 +12718,8 @@ fn main() {}
|
||||
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
|
||||
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
|
||||
std::fs::write(root.join("Page").join("思维导图123456.json"), "{}").expect("write mindmap");
|
||||
std::fs::write(root.join("Page").join("mindmap-123456.json"), "{}")
|
||||
.expect("write generated mindmap");
|
||||
std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx");
|
||||
std::fs::write(root.join("Page").join("sheet.xlsx"), b"xlsx").expect("write xlsx");
|
||||
std::fs::write(root.join("Page").join("slides.pptx"), b"pptx").expect("write pptx");
|
||||
@@ -12687,6 +12751,17 @@ fn main() {}
|
||||
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
|
||||
Some("local-file:Page/思维导图123456.json")
|
||||
);
|
||||
let generated_mindmap = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("mindmap-123456.json"))
|
||||
.expect("generated mindmap row");
|
||||
assert_eq!(generated_mindmap["rowKind"].as_str(), Some("asset"));
|
||||
assert_eq!(generated_mindmap["iconHint"].as_str(), Some("mindmap"));
|
||||
assert_eq!(
|
||||
generated_mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"]
|
||||
.as_str(),
|
||||
Some("mindmap")
|
||||
);
|
||||
|
||||
let office = items
|
||||
.iter()
|
||||
@@ -13239,6 +13314,41 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_ocr_sidecar_is_filetree_resource_not_page_tree_document() {
|
||||
let root = temp_root("mnote-local-ocr-sidecar-page-tree");
|
||||
init_workspace(&root);
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
std::fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
|
||||
std::fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
|
||||
std::fs::write(
|
||||
root.join("docs").join("Page.ocr").join("photo.png.ocr.md"),
|
||||
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token\n",
|
||||
)
|
||||
.expect("ocr markdown");
|
||||
|
||||
let file_tree = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/Page.ocr")
|
||||
.expect("file tree");
|
||||
let file_items = file_tree.projection["items"]
|
||||
.as_array()
|
||||
.expect("file items");
|
||||
assert!(file_items
|
||||
.iter()
|
||||
.any(|item| item["title"].as_str() == Some("photo.png.ocr.md")));
|
||||
|
||||
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
|
||||
let page_items = page_tree.projection["items"]
|
||||
.as_array()
|
||||
.expect("page items");
|
||||
assert!(page_items
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")));
|
||||
assert!(!page_items.iter().any(|item| item["documentId"].as_str()
|
||||
== Some("local-md:docs~2FPage.ocr~2Fphoto.png.ocr.md")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_rename_markdown_page_renames_nested_bundle() {
|
||||
let root = temp_root("mnote-local-rename-nested-bundle");
|
||||
|
||||
@@ -915,10 +915,7 @@ fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(target)
|
||||
.trim();
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
let is_mindmap = lower.ends_with(".mindmap.json")
|
||||
|| (file_name.starts_with("思维导图") && lower.ends_with(".json"));
|
||||
if !is_mindmap {
|
||||
if !is_local_mindmap_file_name(file_name) {
|
||||
return None;
|
||||
}
|
||||
let name = collect_plain_text(node).trim().to_string();
|
||||
@@ -932,6 +929,18 @@ fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
|
||||
))
|
||||
}
|
||||
|
||||
fn is_local_mindmap_file_name(file_name: &str) -> bool {
|
||||
let trimmed = file_name.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if !lower.ends_with(".json") {
|
||||
return false;
|
||||
}
|
||||
lower.ends_with(".mindmap.json")
|
||||
|| lower.starts_with("mindmap-")
|
||||
|| lower.starts_with("mindmap_")
|
||||
|| trimmed.starts_with("思维导图")
|
||||
}
|
||||
|
||||
fn markdown_ast_document_to_blocks(document: &MarkdownAstDocument) -> Value {
|
||||
Value::Array(
|
||||
document
|
||||
@@ -1239,6 +1248,25 @@ mod tests {
|
||||
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_mindmap_link_parses_generated_mindmap_json_as_mindmap_block() {
|
||||
let blocks = markdown_to_blocks("[思维导图](mindmap-123456.json)\n");
|
||||
let first = blocks
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.expect("first block");
|
||||
|
||||
assert_eq!(first["type"].as_str(), Some("mindmap"));
|
||||
assert_eq!(
|
||||
first["props"]["sourcePath"].as_str(),
|
||||
Some("mindmap-123456.json")
|
||||
);
|
||||
assert_eq!(
|
||||
first["props"]["mindmapId"].as_str(),
|
||||
Some("mindmap-123456.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_attachment_refs_parse_standard_href_variants() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ use crate::routes::local_folder_source::encode_local_id_segment;
|
||||
use crate::routes::local_markdown_parser::{
|
||||
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
use crate::routes::local_ocr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
@@ -55,6 +56,7 @@ pub(crate) fn query_local_search_index(
|
||||
limit: u32,
|
||||
title_only: bool,
|
||||
exact: bool,
|
||||
include_ocr: bool,
|
||||
) -> Result<Value, WebError> {
|
||||
let index = load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
let normalized_query = normalize_search_text(query);
|
||||
@@ -90,6 +92,39 @@ pub(crate) fn query_local_search_index(
|
||||
}
|
||||
}
|
||||
}
|
||||
if include_ocr && results.len() < limit.max(1) as usize {
|
||||
for entry in local_ocr::ocr_index_entries(root_path)? {
|
||||
if let Some(page_id) = page_id {
|
||||
if entry.owner_document_id != page_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if entry.status != "done" {
|
||||
continue;
|
||||
}
|
||||
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
|
||||
let markdown = match fs::read_to_string(&ocr_path) {
|
||||
Ok(markdown) => markdown,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if local_ocr::parse_ocr_frontmatter(&markdown).is_none() {
|
||||
continue;
|
||||
}
|
||||
let body = local_ocr::strip_ocr_frontmatter(&markdown);
|
||||
if !local_search_ocr_matches(&entry, body, &normalized_query, title_only, exact) {
|
||||
continue;
|
||||
}
|
||||
results.push(local_search_ocr_projection(
|
||||
&entry,
|
||||
body,
|
||||
root_uri,
|
||||
&normalized_query,
|
||||
));
|
||||
if results.len() >= limit.max(1) as usize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"enqueueAssetIds": [],
|
||||
"projectionOwner": "rust-kernel",
|
||||
@@ -100,7 +135,8 @@ pub(crate) fn query_local_search_index(
|
||||
"workspaceId": index.workspace_id,
|
||||
"builtAt": index.built_at,
|
||||
"documentCount": index.documents.len(),
|
||||
"resourceCount": index.resources.len()
|
||||
"resourceCount": index.resources.len(),
|
||||
"includeOcr": include_ocr
|
||||
},
|
||||
"recentChanges": recent_changes,
|
||||
"results": results
|
||||
@@ -404,6 +440,14 @@ fn collect_markdown_documents(
|
||||
continue;
|
||||
}
|
||||
if is_markdown_path(&path) {
|
||||
let relative_path = path
|
||||
.strip_prefix(root_path)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
|
||||
continue;
|
||||
}
|
||||
documents.push(index_markdown_file(root_path, &path)?);
|
||||
} else if resource_type_from_path(&path).is_some() {
|
||||
resources.push(index_resource_file(root_path, &path)?);
|
||||
@@ -565,6 +609,34 @@ fn local_search_resource_matches(
|
||||
}
|
||||
}
|
||||
|
||||
fn local_search_ocr_matches(
|
||||
entry: &local_ocr::OcrIndexEntry,
|
||||
body: &str,
|
||||
query: &str,
|
||||
title_only: bool,
|
||||
exact: bool,
|
||||
) -> bool {
|
||||
if query.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let haystack = if title_only {
|
||||
normalize_search_text(&format!(
|
||||
"{}\n{}",
|
||||
entry.source_root_relative_path, entry.ocr_root_relative_path
|
||||
))
|
||||
} else {
|
||||
normalize_search_text(&format!(
|
||||
"{}\n{}\n{}",
|
||||
entry.source_root_relative_path, entry.ocr_root_relative_path, body
|
||||
))
|
||||
};
|
||||
if exact {
|
||||
haystack == query
|
||||
} else {
|
||||
haystack.contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
fn local_search_document_projection(
|
||||
document: &LocalSearchDocument,
|
||||
root_uri: &str,
|
||||
@@ -608,6 +680,42 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
|
||||
})
|
||||
}
|
||||
|
||||
fn local_search_ocr_projection(
|
||||
entry: &local_ocr::OcrIndexEntry,
|
||||
body: &str,
|
||||
root_uri: &str,
|
||||
query: &str,
|
||||
) -> Value {
|
||||
let title = Path::new(&entry.owner_document_path)
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("OCR")
|
||||
.to_string();
|
||||
json!({
|
||||
"id": format!("{}#ocr:{}", entry.owner_document_id, entry.source_root_relative_path),
|
||||
"documentId": entry.owner_document_id,
|
||||
"title": title,
|
||||
"path": entry.owner_document_path,
|
||||
"resourceType": "markdown",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"hasOcr": true,
|
||||
"snippet": ocr_search_snippet(body, query),
|
||||
"ocrEvidence": {
|
||||
"sourceRootRelativePath": entry.source_root_relative_path,
|
||||
"ocrRootRelativePath": entry.ocr_root_relative_path,
|
||||
"provider": entry.provider,
|
||||
"status": entry.status,
|
||||
},
|
||||
"updatedAt": entry.updated_at_ms,
|
||||
"publicPath": format!(
|
||||
"/documents/{}?sourceKind=local_folder&rootUri={}",
|
||||
entry.owner_document_id,
|
||||
encode_query_component(root_uri),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_query_component(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.as_bytes() {
|
||||
@@ -764,6 +872,27 @@ fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
|
||||
document.raw_text.chars().take(180).collect()
|
||||
}
|
||||
|
||||
fn ocr_search_snippet(body: &str, query: &str) -> String {
|
||||
let normalized_body = body.replace('\n', " ");
|
||||
let normalized_query = query.trim();
|
||||
if normalized_query.is_empty() {
|
||||
return normalized_body.chars().take(160).collect();
|
||||
}
|
||||
let lower = normalized_body.to_ascii_lowercase();
|
||||
let lower_query = normalized_query.to_ascii_lowercase();
|
||||
if let Some(byte_index) = lower.find(&lower_query) {
|
||||
let start = normalized_body[..byte_index]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.nth(40)
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(0);
|
||||
normalized_body[start..].chars().take(160).collect()
|
||||
} else {
|
||||
normalized_body.chars().take(160).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_markdown_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
@@ -845,6 +974,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("projection");
|
||||
let results = projection["results"].as_array().expect("results");
|
||||
@@ -898,6 +1028,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("child search");
|
||||
let child_result = child_search["results"]
|
||||
@@ -929,6 +1060,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("mindmap projection");
|
||||
assert!(mindmap_projection["results"]
|
||||
@@ -953,6 +1085,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("office projection");
|
||||
assert!(office_projection["results"]
|
||||
@@ -1017,6 +1150,104 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_search_ocr_sidecar_requires_include_ocr_and_returns_owner_page() {
|
||||
let root = temp_root("mnote-local-search-ocr");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let workspace_id = "local-ws-ocr";
|
||||
fs::write(root.join("docs").join("Page.md"), "# Page\n正文\n").expect("page");
|
||||
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.assets").join("photo.png"),
|
||||
b"png",
|
||||
)
|
||||
.expect("photo");
|
||||
fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
|
||||
fs::write(
|
||||
root.join("docs").join("Page.ocr").join("photo.png.ocr.md"),
|
||||
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token 识别正文\n",
|
||||
)
|
||||
.expect("ocr markdown");
|
||||
fs::create_dir_all(root.join(".mnote")).expect("mnote dir");
|
||||
fs::write(
|
||||
root.join(".mnote").join("ocr-index.json"),
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"version": 1,
|
||||
"entries": {
|
||||
"docs/Page.assets/photo.png": {
|
||||
"jobId": "ocr_test",
|
||||
"ownerDocumentId": "local-md:docs~2FPage.md",
|
||||
"ownerDocumentPath": "docs/Page.md",
|
||||
"sourceRootRelativePath": "docs/Page.assets/photo.png",
|
||||
"ocrRootRelativePath": "docs/Page.ocr/photo.png.ocr.md",
|
||||
"provider": "mock",
|
||||
"modelVersion": "vlm",
|
||||
"status": "done",
|
||||
"sourceSize": 3,
|
||||
"sourceMtimeMs": 1,
|
||||
"createdAtMs": 1,
|
||||
"updatedAtMs": 1,
|
||||
"plainTextPreview": "OCR-only-token 识别正文"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("serialize index"),
|
||||
)
|
||||
.expect("ocr index");
|
||||
|
||||
let without_ocr = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"OCR-only-token",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("without ocr");
|
||||
assert_eq!(without_ocr["results"].as_array().map(Vec::len), Some(0));
|
||||
|
||||
let with_ocr = query_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
workspace_id,
|
||||
"OCR-only-token",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("with ocr");
|
||||
let result = with_ocr["results"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.expect("ocr result");
|
||||
assert_eq!(
|
||||
result["documentId"].as_str(),
|
||||
Some("local-md:docs~2FPage.md")
|
||||
);
|
||||
assert_eq!(result["hasOcr"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
result["ocrEvidence"]["sourceRootRelativePath"].as_str(),
|
||||
Some("docs/Page.assets/photo.png")
|
||||
);
|
||||
assert_eq!(
|
||||
result["ocrEvidence"]["ocrRootRelativePath"].as_str(),
|
||||
Some("docs/Page.ocr/photo.png.ocr.md")
|
||||
);
|
||||
let index = read_local_search_index(&root)
|
||||
.expect("read search index")
|
||||
.expect("search index");
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_search_query_reads_existing_index_without_rebuilding() {
|
||||
let root = temp_root("mnote-local-search-query-cache");
|
||||
@@ -1038,6 +1269,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("first query");
|
||||
assert_eq!(
|
||||
@@ -1060,6 +1292,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("query existing index");
|
||||
assert_eq!(
|
||||
@@ -1078,6 +1311,7 @@ mod tests {
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("query refreshed index");
|
||||
assert_eq!(
|
||||
|
||||
@@ -13,12 +13,14 @@ mod kernel;
|
||||
mod local_folder_events;
|
||||
mod local_folder_source;
|
||||
mod local_markdown_parser;
|
||||
mod local_ocr;
|
||||
mod local_search_index;
|
||||
mod media;
|
||||
mod mindmap_api;
|
||||
mod mindmap_shell;
|
||||
pub(crate) mod navigation_recent;
|
||||
mod onlyoffice;
|
||||
pub(crate) mod onlyoffice_bridge;
|
||||
mod page_ai_workflow;
|
||||
mod query_support;
|
||||
mod resource_trash;
|
||||
@@ -35,7 +37,7 @@ pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
pub(crate) use local_folder_source::{
|
||||
ensure_local_path_read_access, ensure_local_workspace_access,
|
||||
decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
|
||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||
update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
@@ -183,6 +185,34 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_markdown_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_render_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_permission_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_profile_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_session_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_skill_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_target_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
|
||||
get(web_shell::sidebar_page_settings_runtime_asset),
|
||||
@@ -288,6 +318,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/whoami", get(session::session))
|
||||
.route("/api/auth/mnote-web-token", get(session::session))
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route(
|
||||
"/api/ai/agent-profiles",
|
||||
get(hermes_client::list_agent_profiles),
|
||||
)
|
||||
.route(
|
||||
"/api/sidebar/shortcuts",
|
||||
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
|
||||
@@ -382,6 +416,54 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/onlyoffice/proxy", get(onlyoffice::proxy))
|
||||
.route("/api/onlyoffice/callback", post(onlyoffice::callback))
|
||||
.route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave))
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/plugin/config",
|
||||
get(onlyoffice_bridge::plugin_config),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/plugin/index",
|
||||
get(onlyoffice_bridge::plugin_index),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/plugin/index/config.json",
|
||||
get(onlyoffice_bridge::plugin_config),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/plugin/index/{state}",
|
||||
get(onlyoffice_bridge::plugin_index_with_state),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/session",
|
||||
post(onlyoffice_bridge::register_session),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/session/current",
|
||||
get(onlyoffice_bridge::current_session),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/session/close",
|
||||
post(onlyoffice_bridge::close_session),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/sessions",
|
||||
get(onlyoffice_bridge::list_sessions),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/capabilities",
|
||||
get(onlyoffice_bridge::capabilities),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/commands",
|
||||
post(onlyoffice_bridge::enqueue_command),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/commands/next",
|
||||
get(onlyoffice_bridge::next_command),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/results",
|
||||
get(onlyoffice_bridge::get_result).post(onlyoffice_bridge::post_result),
|
||||
)
|
||||
.route("/api/media/upload", post(media::upload))
|
||||
.route("/api/media/sign", get(media::sign))
|
||||
.route("/api/media/batch", post(resource_trash::media_batch))
|
||||
@@ -452,6 +534,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/local-folder/events",
|
||||
get(local_folder_events::local_folder_events),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/ocr/jobs",
|
||||
get(local_ocr::list_jobs).post(local_ocr::create_job),
|
||||
)
|
||||
.route("/api/local-folder/ocr/status", get(local_ocr::status))
|
||||
.route("/api/local-folder/ocr/read", get(local_ocr::read))
|
||||
.route("/api/local-folder/ocr/insert", post(local_ocr::insert))
|
||||
.route(
|
||||
"/api/local-folder/workspaces/default",
|
||||
post(local_folder_source::create_default_local_workspace),
|
||||
@@ -1027,6 +1116,13 @@ mod tests {
|
||||
"/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
|
||||
"/api/mnote-browser-runtime/tree-live-controller.js",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::onlyoffice_bridge;
|
||||
use crate::app::AppConfig;
|
||||
use crate::app::AppState;
|
||||
use crate::error::WebError;
|
||||
@@ -147,6 +148,9 @@ pub struct OnlyOfficeCallbackQuery {
|
||||
asset_id: Option<String>,
|
||||
#[serde(rename = "userId")]
|
||||
user_id: Option<String>,
|
||||
#[serde(rename = "sessionId")]
|
||||
session_id: Option<String>,
|
||||
token: Option<String>,
|
||||
#[serde(rename = "rootUri")]
|
||||
root_uri: Option<String>,
|
||||
path: Option<String>,
|
||||
@@ -506,10 +510,12 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
return null;
|
||||
}}
|
||||
}}
|
||||
function buildCallbackUrl(assetId, userId, localFile) {{
|
||||
function buildCallbackUrl(assetId, userId, localFile, bridgeSessionId, bridgeToken) {{
|
||||
const callback = new URL("/api/onlyoffice/callback", callbackOrigin || location.origin);
|
||||
if (assetId) callback.searchParams.set("assetId", assetId);
|
||||
if (userId) callback.searchParams.set("userId", userId);
|
||||
if (bridgeSessionId) callback.searchParams.set("sessionId", bridgeSessionId);
|
||||
if (bridgeToken) callback.searchParams.set("token", bridgeToken);
|
||||
if (localFile && localFile.rootUri && localFile.path) {{
|
||||
callback.searchParams.set("rootUri", localFile.rootUri);
|
||||
callback.searchParams.set("path", localFile.path);
|
||||
@@ -598,6 +604,11 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
if (initial.fileUrl && initial.fileUrl.indexOf("/api/local-folder/files/open") !== -1) return true;
|
||||
return false;
|
||||
}}
|
||||
function editModeLocationHref() {{
|
||||
const next = new URL(location.href);
|
||||
next.searchParams.set("mode", "edit");
|
||||
return next.toString();
|
||||
}}
|
||||
async function resolveAssetUrlAndKey() {{
|
||||
let effectiveFileUrl = initial.fileUrl;
|
||||
let storageId = "";
|
||||
@@ -639,6 +650,18 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
const localFile = localFolderOpenParams(fileState.effectiveFileUrl || initial.fileUrl);
|
||||
const displayUserId = String(userId || initial.userId || "mnote-local-user").trim() || "mnote-local-user";
|
||||
const displayUserName = displayUserId === "mnote-local-user" ? "MNote" : displayUserId;
|
||||
const bridgeSessionSalt = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2));
|
||||
const bridgeSessionId = "mnote-oo-" + fileState.docKey + "-" + bridgeSessionSalt;
|
||||
const bridgeToken = (crypto && crypto.randomUUID) ? crypto.randomUUID() : ("mnote-oo-token-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2));
|
||||
const bridgePluginConfigUrl = new URL("/api/onlyoffice/bridge/plugin/config", location.origin);
|
||||
bridgePluginConfigUrl.searchParams.set("sessionId", bridgeSessionId);
|
||||
bridgePluginConfigUrl.searchParams.set("token", bridgeToken);
|
||||
bridgePluginConfigUrl.searchParams.set("apiBase", location.origin);
|
||||
bridgePluginConfigUrl.searchParams.set("documentId", initial.documentId || "");
|
||||
bridgePluginConfigUrl.searchParams.set("assetId", initial.assetId || "");
|
||||
bridgePluginConfigUrl.searchParams.set("fileType", initial.fileType || "");
|
||||
bridgePluginConfigUrl.searchParams.set("docKey", fileState.docKey);
|
||||
bridgePluginConfigUrl.searchParams.set("pageOrigin", location.origin);
|
||||
const config = {{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
@@ -658,11 +681,15 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
editorConfig: {{
|
||||
mode: resolvedMode,
|
||||
lang: "zh-CN",
|
||||
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile),
|
||||
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile, bridgeSessionId, bridgeToken),
|
||||
user: {{
|
||||
id: displayUserId,
|
||||
name: displayUserName
|
||||
}},
|
||||
plugins: {{
|
||||
autostart: [MNOTE_AGENT_PLUGIN_GUID],
|
||||
pluginsData: [bridgePluginConfigUrl.toString()]
|
||||
}},
|
||||
customization: {{
|
||||
feedback: {{ visible: false }},
|
||||
anonymous: {{ request: false, label: "Guest" }},
|
||||
@@ -673,6 +700,14 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
events: {{
|
||||
onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
|
||||
onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
|
||||
onRequestEditRights: () => {{
|
||||
const editHref = editModeLocationHref();
|
||||
window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__ = {{
|
||||
requested: true,
|
||||
targetUrl: editHref
|
||||
}};
|
||||
window.location.replace(editHref);
|
||||
}},
|
||||
onError: (event) => showError(JSON.stringify(event))
|
||||
}}
|
||||
}};
|
||||
@@ -690,7 +725,52 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
resolvedMode,
|
||||
assetId: initial.assetId,
|
||||
documentId: initial.documentId,
|
||||
docKey: fileState.docKey
|
||||
docKey: fileState.docKey,
|
||||
bridgeSessionId,
|
||||
bridgeToken,
|
||||
bridgePluginConfigUrl: bridgePluginConfigUrl.toString()
|
||||
}};
|
||||
try {{
|
||||
if (window.parent && window.parent !== window) {{
|
||||
window.parent.postMessage({{
|
||||
type: "mnote:onlyoffice-bridge-ready",
|
||||
bridgeSessionId,
|
||||
documentId: initial.documentId,
|
||||
assetId: initial.assetId,
|
||||
fileType: initial.fileType
|
||||
}}, location.origin);
|
||||
}}
|
||||
}} catch (_) {{}}
|
||||
window.__MNOTE_ONLYOFFICE_BRIDGE__ = {{
|
||||
sessionId: bridgeSessionId,
|
||||
run: async function(action, payload, timeoutMs) {{
|
||||
const enqueue = await fetch("/api/onlyoffice/bridge/commands", {{
|
||||
method: "POST",
|
||||
headers: {{ "content-type": "application/json" }},
|
||||
body: JSON.stringify({{
|
||||
sessionId: bridgeSessionId,
|
||||
token: bridgeToken,
|
||||
action,
|
||||
payload: payload || {{}}
|
||||
}})
|
||||
}});
|
||||
const enqueued = await enqueue.json().catch(() => null);
|
||||
if (!enqueue.ok) throw new Error(enqueued && enqueued.message || "ONLYOFFICE bridge command enqueue failed");
|
||||
const commandId = enqueued && enqueued.command && enqueued.command.id;
|
||||
if (!commandId) throw new Error("ONLYOFFICE bridge command id missing");
|
||||
const resultUrl = new URL("/api/onlyoffice/bridge/results", location.origin);
|
||||
resultUrl.searchParams.set("sessionId", bridgeSessionId);
|
||||
resultUrl.searchParams.set("token", bridgeToken);
|
||||
resultUrl.searchParams.set("commandId", commandId);
|
||||
resultUrl.searchParams.set("timeoutMs", String(timeoutMs || 25000));
|
||||
const resultResponse = await fetch(resultUrl.toString());
|
||||
if (resultResponse.status === 204) throw new Error("ONLYOFFICE bridge command timed out");
|
||||
const result = await resultResponse.json().catch(() => null);
|
||||
if (!resultResponse.ok || !result || result.ok === false) {{
|
||||
throw new Error(result && result.error || result && result.message || "ONLYOFFICE bridge command failed");
|
||||
}}
|
||||
return result.result;
|
||||
}}
|
||||
}};
|
||||
const readyDeadline = Date.now() + 120000;
|
||||
const timer = window.setInterval(() => {{
|
||||
@@ -1070,12 +1150,15 @@ fn onlyoffice_callback_success(extra: Value) -> Response {
|
||||
}
|
||||
|
||||
fn onlyoffice_callback_failure(error: WebError) -> Response {
|
||||
Json(json!({
|
||||
(
|
||||
error.status(),
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"code": error.code(),
|
||||
"message": error.message(),
|
||||
}))
|
||||
.into_response()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn download_onlyoffice_callback_body(download_url: &str) -> Result<Bytes, WebError> {
|
||||
@@ -1135,6 +1218,53 @@ async fn local_folder_onlyoffice_callback(
|
||||
)
|
||||
})?;
|
||||
let target = resolve_onlyoffice_local_file_path(root_uri, relative_path)?;
|
||||
let session_id = query
|
||||
.session_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"onlyoffice_local_callback_session_required",
|
||||
"OnlyOffice 本地保存缺少 bridge sessionId",
|
||||
)
|
||||
})?;
|
||||
let token = query
|
||||
.token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"onlyoffice_local_callback_token_required",
|
||||
"OnlyOffice 本地保存缺少 bridge token",
|
||||
)
|
||||
})?;
|
||||
if !onlyoffice_bridge::session_token_matches(session_id, token) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"onlyoffice_local_callback_token_invalid",
|
||||
"OnlyOffice 本地保存 bridge token 无效",
|
||||
));
|
||||
}
|
||||
let session = onlyoffice_bridge::session_info(session_id).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"onlyoffice_local_callback_session_unregistered",
|
||||
"OnlyOffice 本地保存 bridge session 未注册",
|
||||
)
|
||||
})?;
|
||||
if let Some(session_asset_id) = session.asset_id.as_deref() {
|
||||
if !session_asset_id.trim().is_empty() && session_asset_id.trim() != asset_id {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"onlyoffice_local_callback_asset_mismatch",
|
||||
"OnlyOffice 本地保存 session 与资源不匹配",
|
||||
));
|
||||
}
|
||||
}
|
||||
let onlyoffice_internal_url = resolve_onlyoffice_internal_url().await;
|
||||
let prepared = prepare_callback(OnlyOfficeCallbackPreparationInput {
|
||||
asset_id: asset_id.to_string(),
|
||||
@@ -1840,6 +1970,22 @@ mod tests {
|
||||
));
|
||||
assert!(html.contains("if (proxyOrigin && isLocalFolderFileOpen)"));
|
||||
assert!(html.contains("if (proxyOrigin && (isLocal || url.searchParams.has(\"token\")))"));
|
||||
assert!(html.contains(
|
||||
"const MNOTE_AGENT_PLUGIN_GUID = \"asc.{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}\";"
|
||||
));
|
||||
assert!(html.contains(
|
||||
"const bridgePluginConfigUrl = new URL(\"/api/onlyoffice/bridge/plugin/config\", location.origin);"
|
||||
));
|
||||
assert!(
|
||||
html.contains("bridgePluginConfigUrl.searchParams.set(\"apiBase\", location.origin);")
|
||||
);
|
||||
assert!(html.contains("const bridgeSessionSalt ="));
|
||||
assert!(html.contains(
|
||||
"const bridgeSessionId = \"mnote-oo-\" + fileState.docKey + \"-\" + bridgeSessionSalt;"
|
||||
));
|
||||
assert!(html.contains("/api/onlyoffice/bridge/plugin/config"));
|
||||
assert!(html.contains("pluginsData: [bridgePluginConfigUrl.toString()]"));
|
||||
assert!(html.contains("window.__MNOTE_ONLYOFFICE_BRIDGE__"));
|
||||
}
|
||||
|
||||
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
|
||||
@@ -1897,6 +2043,8 @@ mod tests {
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: None,
|
||||
session_id: None,
|
||||
token: None,
|
||||
root_uri: None,
|
||||
path: None,
|
||||
}),
|
||||
@@ -1917,9 +2065,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
|
||||
async fn onlyoffice_local_callback_rejects_unauthenticated_local_write() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-onlyoffice-local-callback-{}",
|
||||
"mnote-onlyoffice-local-callback-unauth-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -1938,6 +2086,74 @@ mod tests {
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
user_id: None,
|
||||
session_id: None,
|
||||
token: None,
|
||||
root_uri: Some(format!("file://{}", root.display())),
|
||||
path: Some("Page/report.docx".into()),
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"key": "doc_key",
|
||||
"url": download_url
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["error"], 1);
|
||||
assert_eq!(
|
||||
payload["code"],
|
||||
"onlyoffice_local_callback_session_required"
|
||||
);
|
||||
assert_eq!(fs::read(&target).expect("read target"), b"old");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-onlyoffice-local-callback-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
let target = root.join("Page").join("report.docx");
|
||||
fs::write(&target, b"old").expect("write old docx");
|
||||
let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await;
|
||||
let session_id = format!("mnote-oo-local-callback-{}", std::process::id());
|
||||
let token = format!("token-{session_id}");
|
||||
let registered =
|
||||
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
|
||||
session_id: session_id.clone(),
|
||||
token: Some(token.clone()),
|
||||
editor_type: Some("word".into()),
|
||||
document_id: Some("local-md:Page".into()),
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
file_type: Some("docx".into()),
|
||||
doc_key: None,
|
||||
page_origin: None,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(registered.status(), StatusCode::OK);
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
session_id,
|
||||
token,
|
||||
root.display(),
|
||||
)
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
user_id: None,
|
||||
session_id: Some(session_id),
|
||||
token: Some(token),
|
||||
root_uri: Some(format!("file://{}", root.display())),
|
||||
path: Some("Page/report.docx".into()),
|
||||
}),
|
||||
@@ -1958,6 +2174,147 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_local_callback_writes_status_six_body_to_original_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-onlyoffice-local-callback-status-six-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
let target = root.join("Page").join("report.docx");
|
||||
fs::write(&target, b"old").expect("write old docx");
|
||||
let (download_url, _captured) = spawn_legacy_json_server("status six bytes").await;
|
||||
let session_id = format!("mnote-oo-local-callback-six-{}", std::process::id());
|
||||
let token = format!("token-{session_id}");
|
||||
let registered =
|
||||
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
|
||||
session_id: session_id.clone(),
|
||||
token: Some(token.clone()),
|
||||
editor_type: Some("word".into()),
|
||||
document_id: Some("local-md:Page".into()),
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
file_type: Some("docx".into()),
|
||||
doc_key: None,
|
||||
page_origin: None,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(registered.status(), StatusCode::OK);
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
session_id,
|
||||
token,
|
||||
root.display(),
|
||||
)
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
user_id: None,
|
||||
session_id: Some(session_id),
|
||||
token: Some(token),
|
||||
root_uri: Some(format!("file://{}", root.display())),
|
||||
path: Some("Page/report.docx".into()),
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 6,
|
||||
"key": "doc_key",
|
||||
"url": download_url
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
|
||||
assert_eq!(payload["error"], 0);
|
||||
assert_eq!(fs::read(&target).expect("read target"), b"status six bytes");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_local_callback_rejects_root_escape_path() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-onlyoffice-local-callback-root-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let outside = std::env::temp_dir().join(format!(
|
||||
"mnote-onlyoffice-local-callback-outside-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let _ = fs::remove_dir_all(&outside);
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
fs::create_dir_all(&outside).expect("create outside");
|
||||
fs::write(outside.join("report.docx"), b"outside").expect("write outside");
|
||||
let (download_url, _captured) = spawn_legacy_json_server("should not write").await;
|
||||
let session_id = format!("mnote-oo-local-callback-escape-{}", std::process::id());
|
||||
let token = format!("token-{session_id}");
|
||||
let registered =
|
||||
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
|
||||
session_id: session_id.clone(),
|
||||
token: Some(token.clone()),
|
||||
editor_type: Some("word".into()),
|
||||
document_id: Some("local-md:Page".into()),
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
file_type: Some("docx".into()),
|
||||
doc_key: None,
|
||||
page_origin: None,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(registered.status(), StatusCode::OK);
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=..%2F{}%2Freport.docx",
|
||||
session_id,
|
||||
token,
|
||||
root.display(),
|
||||
outside.file_name().and_then(|value| value.to_str()).unwrap_or_default(),
|
||||
)
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
user_id: None,
|
||||
session_id: Some(session_id),
|
||||
token: Some(token),
|
||||
root_uri: Some(format!("file://{}", root.display())),
|
||||
path: Some(format!(
|
||||
"../{}/report.docx",
|
||||
outside
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
)),
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"key": "doc_key",
|
||||
"url": download_url
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let status = response.status();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(payload["error"], 1);
|
||||
assert_eq!(payload["code"], "onlyoffice_local_file_root_escape");
|
||||
assert_eq!(
|
||||
fs::read(outside.join("report.docx")).expect("read outside"),
|
||||
b"outside"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let _ = fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_local_callback_ignores_non_write_status() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
@@ -1968,17 +2325,36 @@ mod tests {
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
let target = root.join("Page").join("report.docx");
|
||||
fs::write(&target, b"old").expect("write old docx");
|
||||
let session_id = format!("mnote-oo-local-callback-ignore-{}", std::process::id());
|
||||
let token = format!("token-{session_id}");
|
||||
let registered =
|
||||
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
|
||||
session_id: session_id.clone(),
|
||||
token: Some(token.clone()),
|
||||
editor_type: Some("word".into()),
|
||||
document_id: Some("local-md:Page".into()),
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
file_type: Some("docx".into()),
|
||||
doc_key: None,
|
||||
page_origin: None,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(registered.status(), StatusCode::OK);
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
root.display()
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
session_id,
|
||||
token,
|
||||
root.display(),
|
||||
)
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("local:asset:Page/report.docx".into()),
|
||||
user_id: None,
|
||||
session_id: Some(session_id),
|
||||
token: Some(token),
|
||||
root_uri: Some(format!("file://{}", root.display())),
|
||||
path: Some("Page/report.docx".into()),
|
||||
}),
|
||||
@@ -2006,6 +2382,8 @@ mod tests {
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: Some("user_1".into()),
|
||||
session_id: None,
|
||||
token: None,
|
||||
root_uri: None,
|
||||
path: None,
|
||||
}),
|
||||
@@ -2128,4 +2506,32 @@ mod tests {
|
||||
assert!(html.contains("anonymous: { request: false, label: \"Guest\" }"));
|
||||
assert!(html.contains("features: { featuresTips: false }"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_page_reinitializes_edit_url_on_request_edit_rights() {
|
||||
let response = page(Query(OnlyOfficePageQuery {
|
||||
file_url: Some(
|
||||
"http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx"
|
||||
.into(),
|
||||
),
|
||||
file_name: Some("report.docx".into()),
|
||||
file_type: Some("docx".into()),
|
||||
asset_id: Some("local-file:Page/report.docx".into()),
|
||||
document_id: Some("local-md:Page".into()),
|
||||
user_id: None,
|
||||
mode: Some("view".into()),
|
||||
}))
|
||||
.await
|
||||
.expect("onlyoffice page");
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
|
||||
assert!(html.contains("function editModeLocationHref()"));
|
||||
assert!(html.contains("next.searchParams.set(\"mode\", \"edit\");"));
|
||||
assert!(html.contains("onRequestEditRights: () =>"));
|
||||
assert!(html.contains("window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__"));
|
||||
assert!(html.contains("window.location.replace(editHref);"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1215,10 +1215,11 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
||||
serde_json::from_slice(&body).expect("json")
|
||||
}
|
||||
|
||||
@@ -1236,10 +1237,11 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
||||
serde_json::from_slice(&body).expect("json")
|
||||
}
|
||||
|
||||
@@ -1256,10 +1258,11 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
||||
serde_json::from_slice(&body).expect("json")
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ pub async fn documents(
|
||||
body.limit.unwrap_or(30),
|
||||
filters.title_only.unwrap_or(false),
|
||||
filters.exact.unwrap_or(false),
|
||||
filters.include_ocr.unwrap_or(false),
|
||||
)?
|
||||
} else {
|
||||
load_search_results_with_filters(
|
||||
|
||||
@@ -1623,6 +1623,104 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_markdown_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-markdown-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_render_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-render-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_permission_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-permission-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_profile_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-profile-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_session_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-session-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_skill_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-skill-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_target_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-target-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_settings_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js");
|
||||
Response::builder()
|
||||
@@ -3151,8 +3249,18 @@ mod tests {
|
||||
"const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {"
|
||||
));
|
||||
assert!(conversion_runtime.contains("body?.blockDocument || body?.block_document"));
|
||||
let block_document_source_index = conversion_runtime
|
||||
.find("if (blockDocument) return 'page_aggregate.block_document';")
|
||||
.expect("blockDocument source should be explicit");
|
||||
let local_markdown_source_index = conversion_runtime
|
||||
.find("return 'local_markdown.content';")
|
||||
.expect("local markdown legacy fallback should remain explicit");
|
||||
assert!(
|
||||
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")
|
||||
block_document_source_index < local_markdown_source_index,
|
||||
"local-first 浏览器转换应优先消费 blockDocument,再降级到 body.content"
|
||||
);
|
||||
assert!(
|
||||
conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body?.content")
|
||||
);
|
||||
assert!(conversion_runtime.contains("localAttachmentClassForTiptapHref"));
|
||||
assert!(conversion_runtime.contains("mnote-uploaded-attachment-code"));
|
||||
@@ -4428,4 +4536,15 @@ mod tests {
|
||||
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_resize_runtime_contract_includes_dimension_attrs() {
|
||||
let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS;
|
||||
assert!(runtime.contains("mindmapWidth"));
|
||||
assert!(runtime.contains("mindmapHeight"));
|
||||
assert!(runtime.contains("data?.mindmap_width"));
|
||||
assert!(runtime.contains("dataset.mnoteMindmapWidth"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("--mnote-mindmap-block-max-width"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("currentPageWidthPreferences().mindmap"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,18 @@ mod tests {
|
||||
include_str!("../../../browser/sidebar-page-tree-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-render-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-permission-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-profile-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-session-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-skill-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-target-runtime.js");
|
||||
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-settings-runtime.js");
|
||||
const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str =
|
||||
@@ -504,25 +516,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_agent_target_picker_contract_is_visible_and_serialized() {
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS
|
||||
.contains("export function createSidebarPageAiRenderRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-button"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-popover"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-option"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-chip"));
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("primaryTargetId"));
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("targets: ["));
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("policy: {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_uses_backend_acp_session_runtime_store() {
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadBackendSessions"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/hermes/client/sessions?"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("params.set('source', 'acp')"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiSearchBackendSessions"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-rename"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-delete"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-resume"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-search"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSkillRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiTargetRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiControls"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function ensurePageAiDrawer"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiConversation"));
|
||||
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS
|
||||
.contains("export function createSidebarPageAiSkillRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS
|
||||
.contains("export function createSidebarPageAiTargetRuntime"));
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("function currentPageAiOpenEditorsSnapshot")
|
||||
);
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("mnote.agent_target_package.v1"));
|
||||
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillSourceOptions"));
|
||||
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillPreferenceTable"));
|
||||
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("ai.agent.reasonix.memory_enabled"));
|
||||
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("params.set('source', 'acp')"));
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")
|
||||
);
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSearchBackendSessions"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-rename"));
|
||||
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-search"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiUsageSummary"));
|
||||
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("permission.requested"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-permission-action=\"allow\""));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-permission-action=\"deny\""));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiHidePermissionDialog"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("if (!message.resolved)"));
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
|
||||
.contains("data-page-ai-permission-action=\"allow\""));
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
|
||||
.contains("data-page-ai-permission-action=\"deny\""));
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiHidePermissionDialog")
|
||||
);
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("if (!message.resolved)"));
|
||||
assert!(
|
||||
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("(item.resolved ? ' disabled' : '')"),
|
||||
"已决 ACP permission 事件不能继续展示假审批按钮"
|
||||
@@ -536,38 +585,50 @@ mod tests {
|
||||
"ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-plan"),
|
||||
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-plan"),
|
||||
"plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("执行计划 · "),
|
||||
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("执行计划 · "),
|
||||
"plan 面板标题应显示执行计划和步数"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_session_ui_labels_local_shared_and_cloud_storage() {
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiSessionStorageLabel"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("local_private"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("local_shared"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("convex_acp_runtime_store"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("本地私有"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("共享会话"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("云端会话"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("sessionStorage:"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("permissionLevel:"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("shareId:"));
|
||||
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_shared"));
|
||||
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("sessionStorage:"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("permissionLevel:"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("shareId:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch() {
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 3"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("return ['reasonix', 'hermes']"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function ensurePageAiStateFacade"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiPermissionRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiResolvePermission"));
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("resolve-permission"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiProfileRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes"));
|
||||
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("return ['reasonix', 'hermes']"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS
|
||||
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 handlePageAiChange"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.handlePageAi"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("closestAction(e.target, '[data-page-ai-action"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
|
||||
.contains("activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("默认 (Hermes HTTP)"));
|
||||
}
|
||||
@@ -1009,7 +1070,8 @@ mod tests {
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
|
||||
.contains("data-mnote-last-mindmap-asset-open-mode', 'local-mindmap-object-shell'"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildLocalOnlyOfficeOpenUrl"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("isLocalAsset ? onlyOfficeUrl"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
|
||||
.contains("var localOfficeUrl = buildLocalOnlyOfficeOpenUrl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1072,6 +1134,9 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("installMnoteDevHotReload"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/dev/hot-reload"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-dev-hot-reload"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("import.meta.url"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnoteDevHotReloadEnabled()"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("\n installMnoteDevHotReload();\n"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.clearInterval(timer)"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:primary-document-activated"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:page-aggregate-synced"));
|
||||
@@ -1591,6 +1656,11 @@ mod tests {
|
||||
.contains("openLocalOfficeFileInActiveTab(detail, 'edit')"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.contains("openEditorAttachmentNewWindow(detail, 'edit')"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
|
||||
.contains("var requestedOfficeMode = forceEditMode ? 'edit' : 'view';"));
|
||||
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains(
|
||||
"var requestedOfficeMode = forceNewWindow || forceEditMode ? 'edit' : 'view';"
|
||||
));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("forceEditMode ? 'edit' : 'view'"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
|
||||
.contains("url.searchParams.set('mode', requestedMode);"));
|
||||
|
||||
@@ -3898,6 +3898,7 @@ body {
|
||||
.wolai-page-ai-profile-select select,
|
||||
.wolai-page-ai-context-select select,
|
||||
.wolai-page-ai-skill-search input,
|
||||
.wolai-page-ai-skill-search select,
|
||||
.wolai-page-ai-agent-profile select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -4499,25 +4500,48 @@ button.wolai-page-ai-message-text {
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-picker,
|
||||
.wolai-page-ai-context-picker {
|
||||
.wolai-page-ai-context-picker,
|
||||
.wolai-page-ai-target-picker {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-button,
|
||||
.wolai-page-ai-context-button {
|
||||
.wolai-page-ai-context-button,
|
||||
.wolai-page-ai-target-button {
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-button[aria-expanded="true"],
|
||||
.wolai-page-ai-context-button[aria-expanded="true"] {
|
||||
.wolai-page-ai-context-button[aria-expanded="true"],
|
||||
.wolai-page-ai-target-button[aria-expanded="true"] {
|
||||
border-color: rgba(27, 28, 28, 0.32);
|
||||
background: #F7F6F4;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-chip,
|
||||
.wolai-page-ai-target-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||
border-radius: 999px;
|
||||
background: #F7F6F4;
|
||||
color: #5A5A5A;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-popover,
|
||||
.wolai-page-ai-context-popover {
|
||||
.wolai-page-ai-context-popover,
|
||||
.wolai-page-ai-target-popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: min(320px, calc(100vw - 44px));
|
||||
@@ -4533,7 +4557,8 @@ button.wolai-page-ai-message-text {
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-popover[hidden],
|
||||
.wolai-page-ai-context-popover[hidden] {
|
||||
.wolai-page-ai-context-popover[hidden],
|
||||
.wolai-page-ai-target-popover[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -4552,12 +4577,26 @@ button.wolai-page-ai-message-text {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-option-list {
|
||||
.wolai-page-ai-agent-option-list,
|
||||
.wolai-page-ai-target-option-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-option {
|
||||
.wolai-page-ai-agent-popover-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-popover-title {
|
||||
color: #8B8782;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-option,
|
||||
.wolai-page-ai-target-option {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
@@ -4570,17 +4609,20 @@ button.wolai-page-ai-message-text {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-option.is-active {
|
||||
.wolai-page-ai-agent-option.is-active,
|
||||
.wolai-page-ai-target-option.is-active {
|
||||
border-color: rgba(27, 28, 28, 0.2);
|
||||
background: #F7F6F4;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-option-label {
|
||||
.wolai-page-ai-agent-option-label,
|
||||
.wolai-page-ai-target-option-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-option-detail {
|
||||
.wolai-page-ai-agent-option-detail,
|
||||
.wolai-page-ai-target-option-detail {
|
||||
color: #8B8782;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use bridge_runtime::{
|
||||
RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan,
|
||||
build_runtime_command_artifact_plan, RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan,
|
||||
RuntimeQueryExecutionPlan,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RetiredCloudCommandExecution {
|
||||
@@ -184,23 +186,38 @@ pub async fn execute_retired_mutation_by_name(
|
||||
|
||||
pub async fn persist_runtime_command_artifacts(
|
||||
_config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
_context: &RequestContext,
|
||||
_artifacts: &RuntimeCommandArtifactPlan,
|
||||
) -> Result<(), WebError> {
|
||||
Err(retired_error(context, "convex_artifacts_retired"))
|
||||
// Convex 已退役。Rust 侧仍会把 artifact plan 返回给调用方和 realtime
|
||||
// consumer;这里保持 no-op,避免兼容路径因为历史持久化层退役而失败。
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_retired_command_plan_with_artifacts(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
_runtime_context: &bridge_runtime::RuntimeBridgeContextWire,
|
||||
_command: &bridge_runtime::RuntimeCommandEnvelopeWire,
|
||||
runtime_context: &bridge_runtime::RuntimeBridgeContextWire,
|
||||
command: &bridge_runtime::RuntimeCommandEnvelopeWire,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Result<RetiredCloudCommandExecution, WebError> {
|
||||
let result = execute_retired_command_plan(config, context, plan).await?;
|
||||
let now = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());
|
||||
let artifacts =
|
||||
build_runtime_command_artifact_plan(runtime_context, command, plan, &result, &now);
|
||||
let artifact_error = if let Some(artifacts) = artifacts.as_ref() {
|
||||
persist_runtime_command_artifacts(config, context, artifacts)
|
||||
.await
|
||||
.err()
|
||||
.map(|error| error.message().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(RetiredCloudCommandExecution {
|
||||
result,
|
||||
artifacts: None,
|
||||
artifact_error: None,
|
||||
artifacts,
|
||||
artifact_error,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user