feat: add pi lab ai management integration

This commit is contained in:
Agent Board
2026-07-04 21:47:42 +08:00
parent 36d027a4a1
commit c8472bb898
62 changed files with 15077 additions and 443 deletions
+232 -374
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
-- P2: AI tool events (receipts) and file patches for per-user/per-session query.
-- See design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md
CREATE TABLE IF NOT EXISTS ai_tool_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT,
session_id TEXT NOT NULL,
run_id TEXT,
provider TEXT NOT NULL,
provider_session_id TEXT,
tool_name TEXT NOT NULL,
allowed INTEGER NOT NULL DEFAULT 1,
deny_reason TEXT,
root_uri TEXT NOT NULL,
page_path TEXT,
normalized_file_path TEXT,
diff_summary TEXT,
citation_count INTEGER NOT NULL DEFAULT 0,
before_file_version TEXT,
after_file_version TEXT,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_ai_tool_events_user_session
ON ai_tool_events(user_id, session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ai_tool_events_user_run
ON ai_tool_events(user_id, run_id, created_at);
CREATE TABLE IF NOT EXISTS ai_file_patches (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT,
session_id TEXT NOT NULL,
run_id TEXT,
tool_event_id TEXT NOT NULL REFERENCES ai_tool_events(id) ON DELETE CASCADE,
root_uri TEXT NOT NULL DEFAULT '',
relative_path TEXT NOT NULL,
before_file_version TEXT,
after_file_version TEXT,
patch_summary_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_ai_file_patches_user_session
ON ai_file_patches(user_id, session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ai_file_patches_user_run
ON ai_file_patches(user_id, run_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ai_file_patches_tool_event
ON ai_file_patches(tool_event_id);
@@ -33,6 +33,8 @@ const TABLE_ORDER: &[&str] = &[
"ai_agent_profiles",
"ai_agent_profile_grants",
"ai_external_conversation_bindings",
"ai_tool_events",
"ai_file_patches",
];
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -78,6 +80,16 @@ impl ControlPlaneStore for StoreHandle {
}
}
fn list_users(
&self,
limit: usize,
) -> Result<Vec<control_plane::UserRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.list_users(limit),
StoreHandle::Turso(store) => store.list_users(limit),
}
}
fn create_password_identity(
&self,
input: control_plane::CreatePasswordIdentityInput,
@@ -702,6 +714,50 @@ impl ControlPlaneStore for StoreHandle {
}
}
}
fn append_ai_tool_event(
&self,
input: control_plane::AppendAiToolEventInput,
) -> Result<control_plane::AiToolEventRecord, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.append_ai_tool_event(input),
StoreHandle::Turso(store) => store.append_ai_tool_event(input),
}
}
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<control_plane::AiToolEventRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.list_ai_tool_events(user_id, session_id, limit),
StoreHandle::Turso(store) => store.list_ai_tool_events(user_id, session_id, limit),
}
}
fn append_ai_file_patch(
&self,
input: control_plane::AppendAiFilePatchInput,
) -> Result<control_plane::AiFilePatchRecord, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.append_ai_file_patch(input),
StoreHandle::Turso(store) => store.append_ai_file_patch(input),
}
}
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<control_plane::AiFilePatchRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.list_ai_file_patches(user_id, session_id, limit),
StoreHandle::Turso(store) => store.list_ai_file_patches(user_id, session_id, limit),
}
}
}
#[derive(Debug)]
@@ -39,6 +39,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
"v8-ai-external-conversation-bindings",
include_str!("../migrations/008-ai-external-conversation-bindings.sql"),
),
(
"v9-ai-tool-events-file-patches",
include_str!("../migrations/009-ai-tool-events-file-patches.sql"),
),
];
/// Create the `_migrations` meta-table if it does not exist.
@@ -169,6 +173,8 @@ mod tests {
"ai_agent_profiles",
"ai_agent_profile_grants",
"ai_external_conversation_bindings",
"ai_tool_events",
"ai_file_patches",
] {
assert!(table_exists(&conn, table), "table {table} should exist");
}
@@ -224,6 +230,8 @@ mod tests {
"ai_agent_profiles",
"ai_agent_profile_grants",
"ai_external_conversation_bindings",
"ai_tool_events",
"ai_file_patches",
];
for table in tables {
let exists = rt.block_on(async { libsql_table_exists(&conn, table).await });
+82
View File
@@ -534,3 +534,85 @@ pub struct AppendAuditInput {
pub target_id: Option<EntityId>,
pub metadata_json: String,
}
// ---------------------------------------------------------------------------
// P2: AI tool events (receipts) and file patches — 7-71
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiToolEventRecord {
pub id: EntityId,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub provider: String,
pub provider_session_id: Option<String>,
pub tool_name: String,
pub allowed: bool,
pub deny_reason: Option<String>,
pub root_uri: String,
pub page_path: Option<String>,
pub normalized_file_path: Option<String>,
pub diff_summary: Option<String>,
pub citation_count: i64,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub payload_json: String,
pub created_at: Timestamp,
pub deleted_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppendAiToolEventInput {
pub id: Option<EntityId>,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub provider: String,
pub provider_session_id: Option<String>,
pub tool_name: String,
pub allowed: bool,
pub deny_reason: Option<String>,
pub root_uri: String,
pub page_path: Option<String>,
pub normalized_file_path: Option<String>,
pub diff_summary: Option<String>,
pub citation_count: i64,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub payload_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiFilePatchRecord {
pub id: EntityId,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub tool_event_id: EntityId,
pub root_uri: String,
pub relative_path: String,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub patch_summary_json: String,
pub created_at: Timestamp,
pub deleted_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppendAiFilePatchInput {
pub id: Option<EntityId>,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub tool_event_id: EntityId,
pub root_uri: String,
pub relative_path: String,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub patch_summary_json: String,
}
+395 -13
View File
@@ -9,17 +9,17 @@ use crate::error::ControlPlaneError;
use crate::migrations;
use crate::model::{
password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, 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, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiFilePatchRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, AiToolEventRecord,
AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, 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, UpsertWorkspaceInput,
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -469,6 +469,49 @@ fn row_to_ai_external_conversation_binding(
})
}
fn row_to_ai_tool_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiToolEventRecord> {
Ok(AiToolEventRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
provider: row.get(5)?,
provider_session_id: row.get(6)?,
tool_name: row.get(7)?,
allowed: row.get(8)?,
deny_reason: row.get(9)?,
root_uri: row.get(10)?,
page_path: row.get(11)?,
normalized_file_path: row.get(12)?,
diff_summary: row.get(13)?,
citation_count: row.get(14)?,
before_file_version: row.get(15)?,
after_file_version: row.get(16)?,
payload_json: row.get(17)?,
created_at: row.get(18)?,
deleted_at: row.get(19)?,
})
}
fn row_to_ai_file_patch(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiFilePatchRecord> {
Ok(AiFilePatchRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
tool_event_id: row.get(5)?,
root_uri: row.get(6)?,
relative_path: row.get(7)?,
before_file_version: row.get(8)?,
after_file_version: row.get(9)?,
patch_summary_json: row.get(10)?,
created_at: row.get(11)?,
deleted_at: row.get(12)?,
})
}
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
let title = serde_json::from_str::<serde_json::Value>(payload_json)
.ok()
@@ -557,7 +600,9 @@ fn list_ai_agent_profile_access_rows(
}
impl SqliteControlPlaneStore {
fn lock_conn(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, ControlPlaneError> {
fn lock_conn(
&self,
) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, ControlPlaneError> {
self.conn
.lock()
.map_err(|e| ControlPlaneError::Storage(format!("sqlite lock poisoned: {e}")))
@@ -639,6 +684,22 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
})
}
fn list_users(&self, limit: usize) -> Result<Vec<UserRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let limit = limit.min(1000).max(1);
let mut stmt = conn.prepare(
"SELECT id, email, username, display_name, role, status, created_at, updated_at, revision
FROM users
ORDER BY created_at ASC, id ASC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], row_to_user)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn create_password_identity(
&self,
input: CreatePasswordIdentityInput,
@@ -950,7 +1011,10 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
let name = input.name.trim().to_string();
let root_uri = input.root_uri.trim().to_string();
let root_path = input.root_path.trim().to_string();
if owner_user_id.is_empty() || name.is_empty() || root_uri.is_empty() || root_path.is_empty()
if owner_user_id.is_empty()
|| name.is_empty()
|| root_uri.is_empty()
|| root_path.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"workspace owner/name/root 不能为空".to_string(),
@@ -2964,6 +3028,154 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
)?;
Ok(changed)
}
// -----------------------------------------------------------------------
// P2: AI tool events (receipts) — 7-71
// -----------------------------------------------------------------------
fn append_ai_tool_event(
&self,
input: AppendAiToolEventInput,
) -> Result<AiToolEventRecord, ControlPlaneError> {
if input.user_id.trim().is_empty() || input.session_id.trim().is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai tool event user_id/session_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.lock_conn()?;
let record = AiToolEventRecord {
id: input.id.unwrap_or_else(|| new_id("ate")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
provider: input.provider,
provider_session_id: input.provider_session_id,
tool_name: input.tool_name,
allowed: input.allowed,
deny_reason: input.deny_reason,
root_uri: input.root_uri,
page_path: input.page_path,
normalized_file_path: input.normalized_file_path,
diff_summary: input.diff_summary,
citation_count: input.citation_count,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
payload_json: input.payload_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_tool_events (id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.provider, record.provider_session_id, record.tool_name, record.allowed, record.deny_reason,
record.root_uri, record.page_path, record.normalized_file_path, record.diff_summary, record.citation_count,
record.before_file_version, record.after_file_version, record.payload_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiToolEventRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at, deleted_at
FROM ai_tool_events
WHERE user_id = ?1 AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_tool_event,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
// -----------------------------------------------------------------------
// P2: AI file patches — 7-71
// -----------------------------------------------------------------------
fn append_ai_file_patch(
&self,
input: AppendAiFilePatchInput,
) -> Result<AiFilePatchRecord, ControlPlaneError> {
if input.user_id.trim().is_empty()
|| input.session_id.trim().is_empty()
|| input.tool_event_id.trim().is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"ai file patch user_id/session_id/tool_event_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.patch_summary_json)?;
let conn = self.lock_conn()?;
let record = AiFilePatchRecord {
id: input.id.unwrap_or_else(|| new_id("afp")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
tool_event_id: input.tool_event_id,
root_uri: input.root_uri,
relative_path: input.relative_path,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
patch_summary_json: input.patch_summary_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_file_patches (id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.tool_event_id, record.root_uri, record.relative_path,
record.before_file_version, record.after_file_version, record.patch_summary_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiFilePatchRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at, deleted_at
FROM ai_file_patches
WHERE user_id = ?1
AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_file_patch,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
}
fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str {
@@ -3050,6 +3262,48 @@ mod tests {
assert_eq!(updated.revision, 2);
}
#[test]
fn list_users_returns_stable_ordering() {
let store = store();
// Create 3 users in known order
let u1 = create_user(&store, "bravo");
let u2 = create_user(&store, "alpha");
let u3 = create_user(&store, "charlie");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 3, "should return all three users");
// Order must be by created_at ASC, id ASC
// u1 (bravo) created first, u2 (alpha) second, u3 (charlie) third
assert_eq!(users[0].id, u1.id, "first created should be first");
assert_eq!(users[1].id, u2.id, "second created should be second");
assert_eq!(users[2].id, u3.id, "third created should be third");
}
#[test]
fn list_users_respects_limit() {
let store = store();
for i in 0..5usize {
create_user(&store, &format!("user{i}"));
}
let users = store.list_users(3).expect("list users with limit");
assert_eq!(users.len(), 3, "should respect limit=3");
}
#[test]
fn list_users_excludes_password_data() {
let store = store();
create_user(&store, "nopassword");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 1);
// UserRecord has no password_hash field — this is a compile-time
// guarantee that list_users cannot leak password data.
assert!(users[0].email.is_some());
}
#[test]
fn ensure_default_workspace_creates_owner_grant() {
let store = store();
@@ -4224,4 +4478,132 @@ mod tests {
.expect("lookup failed session")
.is_none());
}
#[test]
fn ai_tool_events_append_and_list() {
let store = store();
create_user(&store, "tool_user");
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_1".to_string()),
provider: "doubao-web".to_string(),
provider_session_id: Some("ps_1".to_string()),
tool_name: "mnote.local_file.read".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("page.md".to_string()),
normalized_file_path: Some("/tmp/page.md".to_string()),
diff_summary: Some("read file".to_string()),
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append tool event");
assert_eq!(event.tool_name, "mnote.local_file.read");
assert!(event.allowed);
assert_eq!(event.citation_count, 0);
// Append a denied event
store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_2".to_string()),
provider: "reasonix".to_string(),
provider_session_id: None,
tool_name: "fs.write".to_string(),
allowed: false,
deny_reason: Some("path not in allowed roots".to_string()),
root_uri: "file:///etc".to_string(),
page_path: None,
normalized_file_path: Some("/etc/passwd".to_string()),
diff_summary: None,
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append denied event");
let events = store
.list_ai_tool_events("tool_user", Some("sess_1"), 10)
.expect("list events");
assert_eq!(events.len(), 2);
assert_eq!(events[0].tool_name, "fs.write");
assert!(!events[0].allowed);
let all_events = store
.list_ai_tool_events("tool_user", None, 10)
.expect("list all events");
assert_eq!(all_events.len(), 2);
}
#[test]
fn ai_file_patches_append_and_list() {
let store = store();
create_user(&store, "patch_user");
// First create a tool event to reference
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: Some("ate_patch_ref".to_string()),
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
provider: "openclaw".to_string(),
provider_session_id: None,
tool_name: "mnote.local_file.patch".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("test.md".to_string()),
normalized_file_path: Some("/tmp/test.md".to_string()),
diff_summary: Some("edit file".to_string()),
citation_count: 0,
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
payload_json: "{}".to_string(),
})
.expect("append reference tool event");
let patch = store
.append_ai_file_patch(AppendAiFilePatchInput {
id: None,
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
tool_event_id: event.id.clone(),
root_uri: "file:///tmp".to_string(),
relative_path: "test.md".to_string(),
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
patch_summary_json: r#"{"insertions":5,"deletions":2}"#.to_string(),
})
.expect("append file patch");
assert_eq!(patch.tool_event_id, event.id);
assert_eq!(patch.relative_path, "test.md");
assert!(patch.deleted_at.is_none());
let patches = store
.list_ai_file_patches("patch_user", Some("sess_p1"), 10)
.expect("list patches");
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].tool_event_id, event.id);
let all_patches = store
.list_ai_file_patches("patch_user", None, 10)
.expect("list all patches");
assert_eq!(all_patches.len(), 1);
}
}
+47 -11
View File
@@ -2,22 +2,26 @@
use crate::error::ControlPlaneError;
use crate::model::{
AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, 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, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AiFilePatchRecord,
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord,
AiToolEventRecord, AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput,
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,
UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
};
pub trait ControlPlaneStore: Send + Sync {
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError>;
/// List users with stable ordering by created_at (ASC), id (ASC) as tiebreaker.
/// Returns at most `limit` records. Passwords are never included.
fn list_users(&self, limit: usize) -> Result<Vec<UserRecord>, ControlPlaneError>;
fn create_password_identity(
&self,
input: CreatePasswordIdentityInput,
@@ -308,4 +312,36 @@ pub trait ControlPlaneStore: Send + Sync {
session_id: &str,
workspace_id: Option<&str>,
) -> Result<usize, ControlPlaneError>;
// -----------------------------------------------------------------------
// P2: AI tool events (receipts) — 7-71
// -----------------------------------------------------------------------
fn append_ai_tool_event(
&self,
input: AppendAiToolEventInput,
) -> Result<AiToolEventRecord, ControlPlaneError>;
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiToolEventRecord>, ControlPlaneError>;
// -----------------------------------------------------------------------
// P2: AI file patches — 7-71
// -----------------------------------------------------------------------
fn append_ai_file_patch(
&self,
input: AppendAiFilePatchInput,
) -> Result<AiFilePatchRecord, ControlPlaneError>;
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiFilePatchRecord>, ControlPlaneError>;
}
+386 -15
View File
@@ -15,17 +15,17 @@ use crate::error::ControlPlaneError;
use crate::migrations;
use crate::model::{
password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, 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, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiFilePatchRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, AiToolEventRecord,
AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, 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, UpsertWorkspaceInput,
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -486,9 +486,9 @@ impl TursoControlPlaneStore {
}
fn lock_conn(&self) -> Result<MutexGuard<'_, TursoConnection>, ControlPlaneError> {
self.conn
.lock()
.map_err(|error| ControlPlaneError::Storage(format!("libSQL control-plane lock poisoned: {error}")))
self.conn.lock().map_err(|error| {
ControlPlaneError::Storage(format!("libSQL control-plane lock poisoned: {error}"))
})
}
}
@@ -1003,6 +1003,49 @@ fn row_to_ai_external_conversation_binding(
})
}
fn row_to_ai_tool_event(row: &libsql::Row) -> libsql::Result<AiToolEventRecord> {
Ok(AiToolEventRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
provider: row.get(5)?,
provider_session_id: row.get(6)?,
tool_name: row.get(7)?,
allowed: row.get(8)?,
deny_reason: row.get(9)?,
root_uri: row.get(10)?,
page_path: row.get(11)?,
normalized_file_path: row.get(12)?,
diff_summary: row.get(13)?,
citation_count: row.get(14)?,
before_file_version: row.get(15)?,
after_file_version: row.get(16)?,
payload_json: row.get(17)?,
created_at: row.get(18)?,
deleted_at: row.get(19)?,
})
}
fn row_to_ai_file_patch(row: &libsql::Row) -> libsql::Result<AiFilePatchRecord> {
Ok(AiFilePatchRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
tool_event_id: row.get(5)?,
root_uri: row.get(6)?,
relative_path: row.get(7)?,
before_file_version: row.get(8)?,
after_file_version: row.get(9)?,
patch_summary_json: row.get(10)?,
created_at: row.get(11)?,
deleted_at: row.get(12)?,
})
}
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
let title = serde_json::from_str::<serde_json::Value>(payload_json)
.ok()
@@ -1165,6 +1208,22 @@ impl ControlPlaneStore for TursoControlPlaneStore {
})
}
fn list_users(&self, limit: usize) -> Result<Vec<UserRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let limit = limit.min(1000).max(1);
let mut stmt = conn.prepare(
"SELECT id, email, username, display_name, role, status, created_at, updated_at, revision
FROM users
ORDER BY created_at ASC, id ASC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], row_to_user)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn create_password_identity(
&self,
input: CreatePasswordIdentityInput,
@@ -3493,6 +3552,153 @@ impl ControlPlaneStore for TursoControlPlaneStore {
)?;
Ok(changed)
}
// -----------------------------------------------------------------------
// P2: AI tool events (receipts) — 7-71
// -----------------------------------------------------------------------
fn append_ai_tool_event(
&self,
input: AppendAiToolEventInput,
) -> Result<AiToolEventRecord, ControlPlaneError> {
if input.user_id.trim().is_empty() || input.session_id.trim().is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai tool event user_id/session_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.lock_conn()?;
let record = AiToolEventRecord {
id: input.id.unwrap_or_else(|| new_id("ate")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
provider: input.provider,
provider_session_id: input.provider_session_id,
tool_name: input.tool_name,
allowed: input.allowed,
deny_reason: input.deny_reason,
root_uri: input.root_uri,
page_path: input.page_path,
normalized_file_path: input.normalized_file_path,
diff_summary: input.diff_summary,
citation_count: input.citation_count,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
payload_json: input.payload_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_tool_events (id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.provider, record.provider_session_id, record.tool_name, record.allowed, record.deny_reason,
record.root_uri, record.page_path, record.normalized_file_path, record.diff_summary, record.citation_count,
record.before_file_version, record.after_file_version, record.payload_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiToolEventRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at, deleted_at
FROM ai_tool_events
WHERE user_id = ?1 AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_tool_event,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
// -----------------------------------------------------------------------
// P2: AI file patches — 7-71
// -----------------------------------------------------------------------
fn append_ai_file_patch(
&self,
input: AppendAiFilePatchInput,
) -> Result<AiFilePatchRecord, ControlPlaneError> {
if input.user_id.trim().is_empty()
|| input.session_id.trim().is_empty()
|| input.tool_event_id.trim().is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"ai file patch user_id/session_id/tool_event_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.patch_summary_json)?;
let conn = self.lock_conn()?;
let record = AiFilePatchRecord {
id: input.id.unwrap_or_else(|| new_id("afp")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
tool_event_id: input.tool_event_id,
root_uri: input.root_uri,
relative_path: input.relative_path,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
patch_summary_json: input.patch_summary_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_file_patches (id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.tool_event_id, record.root_uri, record.relative_path,
record.before_file_version, record.after_file_version, record.patch_summary_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiFilePatchRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at, deleted_at
FROM ai_file_patches
WHERE user_id = ?1
AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_file_patch,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
}
fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str {
@@ -3582,6 +3788,48 @@ mod tests {
assert_eq!(updated.revision, 2);
}
#[test]
fn list_users_returns_stable_ordering() {
let store = store();
// Create 3 users in known order
let u1 = create_user(&store, "bravo");
let u2 = create_user(&store, "alpha");
let u3 = create_user(&store, "charlie");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 3, "should return all three users");
// Order must be by created_at ASC, id ASC
// u1 (bravo) created first, u2 (alpha) second, u3 (charlie) third
assert_eq!(users[0].id, u1.id, "first created should be first");
assert_eq!(users[1].id, u2.id, "second created should be second");
assert_eq!(users[2].id, u3.id, "third created should be third");
}
#[test]
fn list_users_respects_limit() {
let store = store();
for i in 0..5usize {
create_user(&store, &format!("user{i}"));
}
let users = store.list_users(3).expect("list users with limit");
assert_eq!(users.len(), 3, "should respect limit=3");
}
#[test]
fn list_users_excludes_password_data() {
let store = store();
create_user(&store, "nopassword");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 1);
// UserRecord has no password_hash field — this is a compile-time
// guarantee that list_users cannot leak password data.
assert!(users[0].email.is_some());
}
#[test]
fn ensure_default_workspace_creates_owner_grant() {
let store = store();
@@ -4757,7 +5005,6 @@ mod tests {
.is_none());
}
// --- Fault injection tests ---
#[test]
@@ -4900,4 +5147,128 @@ mod tests {
"expected RateLimit: prefix, got {display}"
);
}
#[test]
fn ai_tool_events_append_and_list() {
let store = store();
create_user(&store, "tool_user");
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_1".to_string()),
provider: "doubao-web".to_string(),
provider_session_id: Some("ps_1".to_string()),
tool_name: "mnote.local_file.read".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("page.md".to_string()),
normalized_file_path: Some("/tmp/page.md".to_string()),
diff_summary: Some("read file".to_string()),
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append tool event");
assert_eq!(event.tool_name, "mnote.local_file.read");
assert!(event.allowed);
assert_eq!(event.citation_count, 0);
store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_2".to_string()),
provider: "reasonix".to_string(),
provider_session_id: None,
tool_name: "fs.write".to_string(),
allowed: false,
deny_reason: Some("path not in allowed roots".to_string()),
root_uri: "file:///etc".to_string(),
page_path: None,
normalized_file_path: Some("/etc/passwd".to_string()),
diff_summary: None,
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append denied event");
let events = store
.list_ai_tool_events("tool_user", Some("sess_1"), 10)
.expect("list events");
assert_eq!(events.len(), 2);
assert_eq!(events[0].tool_name, "fs.write");
assert!(!events[0].allowed);
let all_events = store
.list_ai_tool_events("tool_user", None, 10)
.expect("list all");
assert_eq!(all_events.len(), 2);
}
#[test]
fn ai_file_patches_append_and_list() {
let store = store();
create_user(&store, "patch_user");
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: Some("ate_turso_patch_ref".to_string()),
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
provider: "openclaw".to_string(),
provider_session_id: None,
tool_name: "mnote.local_file.patch".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("test.md".to_string()),
normalized_file_path: Some("/tmp/test.md".to_string()),
diff_summary: Some("edit file".to_string()),
citation_count: 0,
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
payload_json: "{}".to_string(),
})
.expect("append reference tool event");
let patch = store
.append_ai_file_patch(AppendAiFilePatchInput {
id: None,
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
tool_event_id: event.id.clone(),
root_uri: "file:///tmp".to_string(),
relative_path: "test.md".to_string(),
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
patch_summary_json: r#"{"insertions":5}"#.to_string(),
})
.expect("append file patch");
assert_eq!(patch.tool_event_id, event.id);
assert!(patch.deleted_at.is_none());
let patches = store
.list_ai_file_patches("patch_user", Some("sess_p1"), 10)
.expect("list patches");
assert_eq!(patches.len(), 1);
let all_patches = store
.list_ai_file_patches("patch_user", None, 10)
.expect("list all");
assert_eq!(all_patches.len(), 1);
}
}
@@ -257,7 +257,9 @@ fn libsql_local_store_handles_parallel_control_plane_writes() {
}
for handle in handles {
handle.join().expect("parallel writer thread should not panic");
handle
.join()
.expect("parallel writer thread should not panic");
}
assert_eq!(
@@ -275,10 +277,7 @@ fn libsql_local_store_handles_parallel_control_plane_writes() {
8
);
assert_eq!(
store
.list_audit_log(20)
.expect("list parallel audit")
.len(),
store.list_audit_log(20).expect("list parallel audit").len(),
8
);
assert_eq!(
File diff suppressed because it is too large Load Diff
@@ -950,6 +950,7 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
menu.innerHTML =
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-profile" role="menuitem"><span class="material-symbols-outlined" data-icon="account_circle" aria-hidden="true"></span><span>个人信息</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-access-policy" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="admin_panel_settings" aria-hidden="true"></span><span>授权管理</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-ai-management" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="smart_toy" aria-hidden="true"></span><span>AI 管理</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__logout" data-testid="mnote-account-sign-out" role="menuitem">退出登录</button>' +
'<div class="mnote-account-menu__error" data-account-error hidden></div>';
var sessionPromise = fetchAccountSession();
@@ -972,6 +973,20 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
openAdminAccessPolicyDialog(accessPolicyLink);
});
}
var aiManagementLink = menu.querySelector('[data-testid="mnote-account-ai-management"]');
sessionPromise.then(function(session) {
if (!(aiManagementLink instanceof HTMLElement)) return;
aiManagementLink.hidden = false;
aiManagementLink.setAttribute('data-ai-management-role', sessionIsAdmin(session) ? 'admin' : 'user');
});
if (aiManagementLink) {
aiManagementLink.addEventListener('click', function(event) {
event.preventDefault();
sessionPromise.then(function(session) {
window.location.assign(sessionIsAdmin(session) ? '/admin/ai' : '/user/ai');
});
});
}
var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]');
if (signOutButton) {
signOutButton.addEventListener('click', function(event) {
+3
View File
@@ -31,6 +31,7 @@ pub struct AppConfig {
pub enable_legacy_next_compat: bool,
pub enable_debug_shell_routes: bool,
pub enable_editor_actor: bool,
pub enable_page_ai_pi_lab: bool,
pub hermes_base_path: String,
pub compat_next_base_path: String,
pub convex_url: Option<String>,
@@ -64,6 +65,7 @@ impl AppConfig {
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false),
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true),
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
.unwrap_or_else(|_| "/api/hermes".into()),
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
@@ -382,6 +384,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
File diff suppressed because it is too large Load Diff
@@ -167,6 +167,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -73,6 +73,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -112,6 +113,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -168,6 +170,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -236,6 +239,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
+3 -8
View File
@@ -164,10 +164,7 @@ pub async fn seed(
Ok(Json(DevSeedResponse { ok: true, results }))
}
fn apply_seed_operation(
state: &AppState,
operation: DevSeedOperation,
) -> Result<Value, WebError> {
fn apply_seed_operation(state: &AppState, operation: DevSeedOperation) -> Result<Value, WebError> {
match operation {
DevSeedOperation::SetupWorkspace {
user_id,
@@ -478,6 +475,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: false,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -573,10 +571,7 @@ mod tests {
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["status"], "running");
assert_eq!(
payload["results"][0]["events"].as_array().unwrap().len(),
1
);
assert_eq!(payload["results"][0]["events"].as_array().unwrap().len(), 1);
// Step 3: getAiRuntimeRun → 回读验证
let (status, payload) = send_seed(
@@ -1196,6 +1196,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -1779,6 +1779,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -388,6 +388,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
+115
View File
@@ -261,6 +261,88 @@ pub async fn user_access_policy_entry(
Ok(response)
}
pub async fn admin_ai_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
if !has_real_auth_context(&state, &context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_required",
"只有管理员可以访问 AI 管理中心",
)
.with_context(&context));
}
ai_management_response(&state, &context, true)
}
pub async fn user_ai_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
if !has_real_auth_context(&state, &context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
ai_management_response(&state, &context, false)
}
fn ai_management_response(
state: &AppState,
context: &RequestContext,
is_admin: bool,
) -> Result<Response, WebError> {
let workspace_name = default_workspace_name_for_context(state, context);
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::ai_admin::AiManagementPage
workspace_name={workspace_name}
is_admin={is_admin}
/>
});
let title = if is_admin { "AI 管理" } else { "AI 设置" };
let shell = if is_admin {
"admin-ai"
} else {
"user-ai-admin"
};
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="{}" data-mnote-actor-id="{}">
{}
</body>
</html>"#,
title,
crate::ssr::MNOTE_CSS,
shell,
escape_html(context.auth.actor_id.as_str()),
content
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
pub async fn root_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -579,6 +661,21 @@ pub async fn root_entry(
escape_script_json(&panes_bootstrap_json),
render_editor_island_adapter_script(),
);
let body_extra = if state.config().enable_page_ai_pi_lab {
body_extra
+ &format!(
r#"<script>
(function() {{
var s = document.createElement('script');
s.src = '/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js';
s.onload = function() {{ if (window.createSidebarPageAiPiLabRuntime) window.createSidebarPageAiPiLabRuntime({{}}); }};
document.body.appendChild(s);
}})();
</script>"#
)
} else {
body_extra
};
("MNOTE".to_string(), render_workspace_entry(), body_extra)
} else {
match build_page_aggregate_snapshot(
@@ -639,6 +736,21 @@ pub async fn root_entry(
render_document_title_controller_script(),
render_editor_island_adapter_script(),
);
let body_extra = if state.config().enable_page_ai_pi_lab {
body_extra
+ &format!(
r#"<script>
(function() {{
var s = document.createElement('script');
s.src = '/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js';
s.onload = function() {{ if (window.createSidebarPageAiPiLabRuntime) window.createSidebarPageAiPiLabRuntime({{}}); }};
document.body.appendChild(s);
}})();
</script>"#
)
} else {
body_extra
};
(title.to_string(), content, body_extra)
}
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
@@ -2554,6 +2666,7 @@ mod tests {
enable_legacy_next_compat,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url,
@@ -3074,6 +3187,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -3366,6 +3480,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -166,6 +166,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -1450,7 +1450,9 @@ pub async fn toggle_skill(
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_control_plane"})),
Json(
json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_control_plane"}),
),
));
}
let access =
@@ -14553,6 +14555,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -15520,6 +15523,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -15562,6 +15566,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -15991,7 +15996,10 @@ mod tests {
.await
.expect("detail body");
let detail_payload: Value = serde_json::from_slice(&detail_body).expect("detail json");
assert_eq!(detail_payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(
detail_payload["persistence"],
ACP_RUNTIME_CONTROL_PLANE_STORE
);
assert_eq!(
detail_payload["session"]["runs"][0]["sessionId"],
session_id
@@ -16399,6 +16407,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url),
@@ -16558,6 +16567,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url),
@@ -16646,6 +16656,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url),
@@ -16722,6 +16733,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url),
@@ -16905,6 +16917,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url),
@@ -1113,6 +1113,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -1180,6 +1181,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -282,6 +282,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -504,6 +504,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -9630,6 +9630,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -2147,10 +2147,7 @@ fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
Err(error) => {
return Err(WebError::bad_request_code(
"local_search_index_delete_failed",
format!(
"无法删除本地搜索索引文件 {}: {error}",
index_path.display()
),
format!("无法删除本地搜索索引文件 {}: {error}", index_path.display()),
));
}
}
@@ -486,6 +486,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -352,6 +352,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -420,6 +421,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
+55 -3
View File
@@ -1,8 +1,9 @@
mod ai_settings;
mod bridge;
pub(crate) mod command_support;
mod compat;
mod dev_seed;
pub(crate) mod dev_hot;
mod dev_seed;
mod documents;
mod editor;
pub(crate) mod evidence;
@@ -28,6 +29,7 @@ pub(crate) mod onlyoffice_bridge;
mod page_ai_board;
mod page_ai_opencode;
mod page_ai_openhub;
mod page_ai_pi;
mod page_ai_workflow;
mod query_support;
mod resource_trash;
@@ -49,8 +51,7 @@ pub(crate) use local_folder_source::{
control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access,
ensure_local_workspace_access, ensure_local_workspace_read_access_with_state,
ensure_local_workspace_write_access_with_state, local_markdown_conflict_detection_key,
local_workspace_id_from_root_uri, update_local_markdown_title,
write_local_markdown_page_body,
local_workspace_id_from_root_uri, update_local_markdown_title, write_local_markdown_page_body,
};
#[cfg(test)]
pub(crate) use local_search_index::write_local_index_settings;
@@ -84,6 +85,8 @@ pub fn build_router(state: AppState) -> Router {
"/user/access-policy",
get(gateway::user_access_policy_entry),
)
.route("/admin/ai", get(gateway::admin_ai_entry))
.route("/user/ai", get(gateway::user_ai_entry))
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
.route("/search", get(search::shell))
.route("/knowledge", get(gateway::root_entry))
@@ -275,6 +278,10 @@ pub fn build_router(state: AppState) -> Router {
"/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-ai-pi-lab-runtime.js",
get(web_shell::sidebar_page_ai_pi_lab_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
get(web_shell::sidebar_page_settings_runtime_asset),
@@ -588,6 +595,27 @@ pub fn build_router(state: AppState) -> Router {
"/api/page-ai/board/runs/{run_id}/cancel",
post(page_ai_board::cancel_run),
)
.route("/api/page-ai/pi/status", get(page_ai_pi::status))
.route("/api/page-ai/pi/bootstrap", post(page_ai_pi::bootstrap))
.route("/api/page-ai/pi/start", post(page_ai_pi::start))
.route("/api/page-ai/pi/send", post(page_ai_pi::send))
.route("/api/page-ai/pi/abort", post(page_ai_pi::abort))
.route("/api/page-ai/pi/events", get(page_ai_pi::events))
.route("/api/page-ai/pi/sessions", get(page_ai_pi::list_sessions))
.route(
"/api/page-ai/pi/sessions/{session_id}",
get(page_ai_pi::get_session_history),
)
.route(
"/api/page-ai/pi/sessions/{session_id}/events",
get(page_ai_pi::get_session_events),
)
.route("/api/page-ai/pi/tool-call", post(page_ai_pi::tool_call))
.route(
"/api/page-ai/pi/tool-call-bridge",
post(page_ai_pi::tool_call_bridge),
)
.route("/page-ai/pi", get(page_ai_pi::shell))
.route(
"/api/sidebar/shortcuts",
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
@@ -628,6 +656,29 @@ pub fn build_router(state: AppState) -> Router {
"/api/user/access-policy/grants/{grant_id}",
delete(local_folder_source::delete_user_access_grant),
)
.route(
"/api/ai-settings/effective",
get(ai_settings::effective_settings),
)
.route(
"/api/ai-settings/access-scopes",
get(ai_settings::user_access_scopes),
)
.route(
"/api/ai-admin/access-scopes",
get(ai_settings::admin_access_scopes),
)
.route("/api/ai-settings/receipts", get(ai_settings::user_receipts))
.route("/api/ai-admin/receipts", get(ai_settings::admin_receipts))
.route(
"/api/ai-admin/settings",
get(ai_settings::admin_get_settings).put(ai_settings::admin_put_settings),
)
.route("/api/ai-admin/users", get(ai_settings::admin_list_users))
.route(
"/api/ai-admin/users/{user_id}/settings",
get(ai_settings::admin_get_user_settings).put(ai_settings::admin_put_user_settings),
)
.route(
"/api/admin/share-links",
get(local_folder_source::get_share_links).post(local_folder_source::create_share_link),
@@ -1035,6 +1086,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -369,6 +369,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -1998,6 +1998,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
File diff suppressed because it is too large Load Diff
@@ -611,6 +611,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -1163,6 +1163,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -776,6 +776,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -906,6 +907,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -1517,6 +1519,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -229,6 +229,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -331,6 +332,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -400,6 +402,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -480,6 +483,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
+1
View File
@@ -358,6 +358,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
+3
View File
@@ -2656,6 +2656,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -3001,6 +3002,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -3682,6 +3684,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
+45 -1
View File
@@ -298,6 +298,8 @@ pub async fn document_page_shell(
secondary_requested,
secondary_invalid,
);
let pi_lab_loader_script =
render_page_ai_pi_lab_loader_script(state.config().enable_page_ai_pi_lab);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage
title={title.to_string()}
@@ -339,6 +341,7 @@ pub async fn document_page_shell(
{}
{}
{}
{}
</body>
</html>"#,
escape_html(title),
@@ -368,6 +371,7 @@ pub async fn document_page_shell(
r#"<script type="module" src="{}"></script>"#,
mnote_browser_runtime_src("document-conflict-panel-runtime.js")
),
pi_lab_loader_script,
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "document");
@@ -850,6 +854,23 @@ pub(crate) fn render_editor_island_adapter_script() -> String {
)
}
pub(crate) fn render_page_ai_pi_lab_loader_script(enabled: bool) -> String {
if !enabled {
return String::new();
}
format!(
r#"<script>
(function() {{
var s = document.createElement('script');
s.src = '{}';
s.onload = function() {{ if (window.createSidebarPageAiPiLabRuntime) window.createSidebarPageAiPiLabRuntime({{}}); }};
document.body.appendChild(s);
}})();
</script>"#,
mnote_browser_runtime_src("sidebar-page-ai-pi-lab-runtime.js")
)
}
fn leptos_tiptap_runtime_src(asset: &str) -> String {
let base = format!("/api/leptos-tiptap-runtime/{asset}");
if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
@@ -2591,6 +2612,20 @@ pub async fn sidebar_page_ai_target_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_pi_lab_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-pi-lab-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()
@@ -3669,6 +3704,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -3726,6 +3762,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -3847,6 +3884,7 @@ mod tests {
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:9".into()),
@@ -3989,6 +4027,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -4729,6 +4768,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -4797,6 +4837,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -4935,7 +4976,8 @@ mod tests {
}
#[tokio::test]
async fn local_folder_page_aggregate_prefers_control_plane_ui_preference_over_default_title_header() {
async fn local_folder_page_aggregate_prefers_control_plane_ui_preference_over_default_title_header(
) {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-ui-pref-{}",
std::process::id()
@@ -4954,6 +4996,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -5499,6 +5542,7 @@ mod tests {
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
//! SSR 页面组件
pub mod admin;
pub mod ai_admin;
pub mod auth;
pub mod document;
pub mod home;