Files
mnote/rust/crates/core-protocol/src/ai.rs
T

261 lines
8.2 KiB
Rust
Raw Normal View History

use crate::kernel::WorkspaceSourceKind;
use serde::de;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiSession {
pub session_id: String,
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>,
pub owner: AiRuntimeOwner,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum AiRuntimeOwner {
RustWebHermes,
CompatReactIsland,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AiToolCall {
pub call_id: String,
pub tool_name: String,
pub args: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AiEvent {
pub event_id: String,
pub session_id: String,
pub kind: AiEventKind,
#[serde(default)]
pub payload: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AiEventKind {
SessionCreated,
ToolCallPlanned,
ToolCallApplied,
StructuredWriteCompleted,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiStructuredWriteResult {
pub ok: bool,
pub write_kind: AiStructuredWriteKind,
pub command_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AiStructuredWriteKind {
SummaryNode,
AiNoteNode,
ReferenceEdge,
PageBody,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiAccessPermissionLevel {
Admin,
ReadOnly,
ReadWrite,
SharedRead,
SharedWrite,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiShareContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub share_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiAccessScope {
pub user_id: String,
pub workspace_id: String,
pub session_id: String,
pub source_kind: WorkspaceSourceKind,
pub permission_level: AiAccessPermissionLevel,
#[serde(default)]
pub allowed_roots: Vec<String>,
#[serde(default)]
pub allowed_file_paths: Vec<String>,
#[serde(default)]
pub allowed_resource_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub share_context: Option<AiShareContext>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AiAccessScopeRaw {
user_id: String,
workspace_id: String,
session_id: String,
source_kind: WorkspaceSourceKind,
permission_level: AiAccessPermissionLevel,
#[serde(default)]
allowed_roots: Vec<String>,
#[serde(default)]
allowed_file_paths: Vec<String>,
#[serde(default)]
allowed_resource_ids: Vec<String>,
#[serde(default)]
share_context: Option<AiShareContext>,
}
impl<'de> Deserialize<'de> for AiAccessScope {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = AiAccessScopeRaw::deserialize(deserializer)?;
let scope = Self {
user_id: raw.user_id,
workspace_id: raw.workspace_id,
session_id: raw.session_id,
source_kind: raw.source_kind,
permission_level: raw.permission_level,
allowed_roots: raw.allowed_roots,
allowed_file_paths: raw.allowed_file_paths,
allowed_resource_ids: raw.allowed_resource_ids,
share_context: raw.share_context,
};
scope.validate().map_err(de::Error::custom)?;
Ok(scope)
}
}
impl AiAccessScope {
pub fn validate(&self) -> Result<(), String> {
reject_blank("userId", &self.user_id)?;
reject_blank("workspaceId", &self.workspace_id)?;
reject_blank("sessionId", &self.session_id)?;
reject_blank_vec("allowedRoots", &self.allowed_roots)?;
reject_blank_vec("allowedFilePaths", &self.allowed_file_paths)?;
reject_blank_vec("allowedResourceIds", &self.allowed_resource_ids)?;
if let Some(share_context) = &self.share_context {
if let Some(share_id) = &share_context.share_id {
reject_blank("shareContext.shareId", share_id)?;
}
if let Some(mode) = &share_context.mode {
reject_blank("shareContext.mode", mode)?;
}
}
Ok(())
}
}
fn reject_blank(field: &str, value: &str) -> Result<(), String> {
if value.trim().is_empty() {
return Err(format!("{field} must not be blank"));
}
Ok(())
}
fn reject_blank_vec(field: &str, values: &[String]) -> Result<(), String> {
for value in values {
reject_blank(field, value)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::WorkspaceSourceKind;
use serde_json::json;
#[test]
fn ai_access_scope_uses_camel_case_payload() {
let scope: AiAccessScope = serde_json::from_value(json!({
"userId": "user_1",
"workspaceId": "workspace_1",
"sessionId": "session_1",
"sourceKind": "local_folder",
"permissionLevel": "read_write",
"allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"],
"allowedFilePaths": ["file:///mnt/Data1T/Mnote_data/users/user_1/我的空间/README.md"],
"allowedResourceIds": ["local-md:README.md"],
"shareContext": {
"shareId": "share_1",
"mode": "shared_write"
}
}))
.expect("scope");
assert_eq!(scope.user_id, "user_1");
assert_eq!(scope.workspace_id, "workspace_1");
assert_eq!(scope.session_id, "session_1");
assert_eq!(scope.source_kind, WorkspaceSourceKind::LocalFolder);
assert_eq!(scope.permission_level, AiAccessPermissionLevel::ReadWrite);
assert_eq!(scope.allowed_roots.len(), 1);
assert_eq!(scope.allowed_file_paths.len(), 1);
assert_eq!(scope.allowed_resource_ids, vec!["local-md:README.md"]);
assert_eq!(
scope
.share_context
.as_ref()
.and_then(|value| value.share_id.as_deref()),
Some("share_1")
);
let serialized = serde_json::to_value(&scope).expect("serialized");
assert_eq!(serialized["userId"], "user_1");
assert_eq!(serialized["permissionLevel"], "read_write");
assert_eq!(
serialized["allowedRoots"][0],
"file:///mnt/Data1T/Mnote_data/users/user_1"
);
}
#[test]
fn ai_access_scope_rejects_blank_required_fields_and_paths() {
let error = serde_json::from_value::<AiAccessScope>(json!({
"userId": " ",
"workspaceId": "workspace_1",
"sessionId": "session_1",
"sourceKind": "local_folder",
"permissionLevel": "read_only",
"allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"],
"allowedFilePaths": [],
"allowedResourceIds": []
}))
.expect_err("blank user id should be rejected");
assert!(error.to_string().contains("userId"));
let error = serde_json::from_value::<AiAccessScope>(json!({
"userId": "user_1",
"workspaceId": "workspace_1",
"sessionId": "session_1",
"sourceKind": "local_folder",
"permissionLevel": "read_only",
"allowedRoots": [" "],
"allowedFilePaths": [],
"allowedResourceIds": []
}))
.expect_err("blank allowed root should be rejected");
assert!(error.to_string().contains("allowedRoots"));
}
}