feat: add pi lab ai management integration
This commit is contained in:
Generated
+232
-374
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 });
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user