feat: 完成 rust cutover phase 8 收口
This commit is contained in:
Generated
+1662
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/adapter-onlyoffice",
|
||||
"crates/bridge-runtime",
|
||||
"crates/core-domain",
|
||||
"crates/core-protocol",
|
||||
"crates/event-log",
|
||||
"crates/mnote-cli",
|
||||
"crates/storage-convex-bridge",
|
||||
"crates/index-fts",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Rust Bridge Runtime
|
||||
|
||||
当前目录用于承载 Phase 1 的真实 Rust bridge/runtime 说明。
|
||||
|
||||
当前最小样板已经落到 workspace crate `rust/crates/bridge-runtime/`,职责是:
|
||||
|
||||
- 接收 Web 侧传入的 bridge context 与 command/query envelope
|
||||
- 在 Rust 内完成协议校验、命令/查询映射与 Convex transport args 生成
|
||||
- 把执行计划返回给 Next route,由 TS 仅负责 transport 调用 Convex
|
||||
|
||||
当前已接管的最小链路:
|
||||
|
||||
- `documents.content.get`
|
||||
- `documents.title.update`
|
||||
- `documents.save`
|
||||
|
||||
当前刻意保留在 TypeScript 的内容:
|
||||
|
||||
- Convex HTTP client 调用
|
||||
- Next route 的认证、HTTP transport 与错误转译
|
||||
- 现有 bridge log 写回
|
||||
|
||||
这样做的目的不是长期保留双层执行,而是先让 Web 真实进入 Rust 执行器,再在后续阶段继续把 transport 之外的剩余业务域收口到 Rust。
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "adapter-onlyoffice"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
url = "2"
|
||||
@@ -0,0 +1,615 @@
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::Sha256;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use url::Url;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeAssetLocator {
|
||||
pub asset_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeSessionRef {
|
||||
pub session_id: String,
|
||||
pub asset_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeSignTokens {
|
||||
pub token: Option<String>,
|
||||
pub document_token: Option<String>,
|
||||
pub editor_config_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeProxyPreparationInput {
|
||||
pub encoded_url: String,
|
||||
pub method: String,
|
||||
pub range: Option<String>,
|
||||
pub supabase_url: Option<String>,
|
||||
pub supabase_internal_url: Option<String>,
|
||||
pub onlyoffice_storage_host_override: Option<String>,
|
||||
pub convex_origin: Option<String>,
|
||||
pub supabase_anon_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeProxyPreparation {
|
||||
pub target_url: String,
|
||||
pub forward_headers: Vec<OnlyOfficeHeader>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeCallbackPreparationInput {
|
||||
pub asset_id: String,
|
||||
pub document_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub status: i64,
|
||||
pub url: Option<String>,
|
||||
pub key: Option<String>,
|
||||
pub onlyoffice_internal_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeCallbackPreparation {
|
||||
pub should_write: bool,
|
||||
pub download_url: Option<String>,
|
||||
pub idempotency_key: Option<String>,
|
||||
pub locator: OnlyOfficeAssetLocator,
|
||||
pub session: OnlyOfficeSessionRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeForcesavePreparationInput {
|
||||
pub asset_id: String,
|
||||
pub key: String,
|
||||
pub onlyoffice_internal_url: String,
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeOutboundRequest {
|
||||
pub via: String,
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
pub headers: Vec<OnlyOfficeHeader>,
|
||||
pub body_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeSessionResolveInput {
|
||||
pub asset_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnlyOfficeSessionResolveResult {
|
||||
pub locator: OnlyOfficeAssetLocator,
|
||||
pub session: OnlyOfficeSessionRef,
|
||||
}
|
||||
|
||||
pub fn adapter_boundary_statement() -> &'static str {
|
||||
"adapter-onlyoffice 只定义资产定位、会话定位、签名/代理/callback/forcesave 边界,不把 OnlyOffice 变成主事实层。"
|
||||
}
|
||||
|
||||
pub fn normalize_secret(raw: &str) -> String {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|
||||
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
|
||||
&& trimmed.len() >= 2
|
||||
{
|
||||
return trimmed[1..trimmed.len() - 1].trim().to_string();
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
pub fn sign_config(config: &Value, secret: &str) -> Result<OnlyOfficeSignTokens, String> {
|
||||
let normalized_secret = normalize_secret(secret);
|
||||
if normalized_secret.is_empty() {
|
||||
return Ok(OnlyOfficeSignTokens {
|
||||
token: None,
|
||||
document_token: None,
|
||||
editor_config_token: None,
|
||||
});
|
||||
}
|
||||
|
||||
let token = sign_hs256(config, &normalized_secret)?;
|
||||
let document_token = config
|
||||
.get("document")
|
||||
.map(|value| sign_hs256(value, &normalized_secret))
|
||||
.transpose()?;
|
||||
let editor_config_token = config
|
||||
.get("editorConfig")
|
||||
.map(|value| sign_hs256(value, &normalized_secret))
|
||||
.transpose()?;
|
||||
|
||||
Ok(OnlyOfficeSignTokens {
|
||||
token: Some(token),
|
||||
document_token,
|
||||
editor_config_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_session(input: OnlyOfficeSessionResolveInput) -> OnlyOfficeSessionResolveResult {
|
||||
let session_id = input
|
||||
.session_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("onlyoffice:{}", input.asset_id.trim()));
|
||||
let locator = OnlyOfficeAssetLocator {
|
||||
asset_id: input.asset_id.trim().to_string(),
|
||||
workspace_id: input.workspace_id.filter(|value| !value.trim().is_empty()),
|
||||
document_id: input.document_id.filter(|value| !value.trim().is_empty()),
|
||||
};
|
||||
let session = OnlyOfficeSessionRef {
|
||||
session_id,
|
||||
asset_id: locator.asset_id.clone(),
|
||||
workspace_id: locator.workspace_id.clone(),
|
||||
document_id: locator.document_id.clone(),
|
||||
user_id: input.user_id.filter(|value| !value.trim().is_empty()),
|
||||
};
|
||||
OnlyOfficeSessionResolveResult { locator, session }
|
||||
}
|
||||
|
||||
pub fn prepare_proxy_request(
|
||||
input: OnlyOfficeProxyPreparationInput,
|
||||
) -> Result<OnlyOfficeProxyPreparation, String> {
|
||||
let decoded = decode_base64url_to_utf8(&input.encoded_url)?;
|
||||
let mut target = Url::parse(&decoded).map_err(|_| "u 不是有效的 URL".to_string())?;
|
||||
|
||||
if target.scheme() != "http" && target.scheme() != "https" {
|
||||
return Err("仅支持 http/https URL".into());
|
||||
}
|
||||
|
||||
let supa = try_parse_origin_host(input.supabase_url.as_deref());
|
||||
let supa_internal_origin = try_parse_origin_url(input.supabase_internal_url.as_deref());
|
||||
let storage_override = try_parse_origin_host(input.onlyoffice_storage_host_override.as_deref());
|
||||
let convex_origin = try_parse_origin_url(input.convex_origin.as_deref());
|
||||
|
||||
let is_supabase_path = target.path().starts_with("/storage/v1/")
|
||||
|| target.path().starts_with("/auth/v1/")
|
||||
|| target.path().starts_with("/rest/v1/")
|
||||
|| target.path().starts_with("/functions/v1/")
|
||||
|| target.path().starts_with("/realtime/v1/");
|
||||
|
||||
if is_supabase_path {
|
||||
if let Some(origin) = supa_internal_origin.as_ref() {
|
||||
target.set_scheme(origin.scheme()).ok();
|
||||
target.set_host(origin.host_str()).ok();
|
||||
target.set_port(origin.port()).ok();
|
||||
} else if let Some(origin) = try_parse_origin_url(input.supabase_url.as_deref()) {
|
||||
target.set_scheme(origin.scheme()).ok();
|
||||
target.set_host(origin.host_str()).ok();
|
||||
target.set_port(origin.port()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
let mut allowed_hostnames = BTreeSet::new();
|
||||
let mut allowed_ports_by_hostname = BTreeMap::<String, BTreeSet<String>>::new();
|
||||
let mut add_allowed = |hostname: &str, port: &str| {
|
||||
if hostname.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
allowed_hostnames.insert(hostname.to_string());
|
||||
if !port.trim().is_empty() {
|
||||
allowed_ports_by_hostname
|
||||
.entry(hostname.to_string())
|
||||
.or_default()
|
||||
.insert(port.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((hostname, port)) = supa.as_ref() {
|
||||
add_allowed(hostname, port);
|
||||
if is_local_hostname(hostname) {
|
||||
add_allowed("127.0.0.1", port);
|
||||
add_allowed("localhost", port);
|
||||
add_allowed("host.docker.internal", port);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(origin) = supa_internal_origin.as_ref() {
|
||||
let port = origin.port().map(|value| value.to_string()).unwrap_or_default();
|
||||
if let Some(hostname) = origin.host_str() {
|
||||
add_allowed(hostname, &port);
|
||||
if is_local_hostname(hostname) {
|
||||
add_allowed("127.0.0.1", &port);
|
||||
add_allowed("localhost", &port);
|
||||
add_allowed("host.docker.internal", &port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((hostname, port)) = storage_override.as_ref() {
|
||||
add_allowed(hostname, port);
|
||||
}
|
||||
|
||||
if let Some(origin) = convex_origin.as_ref() {
|
||||
let port = origin.port().map(|value| value.to_string()).unwrap_or_default();
|
||||
if let Some(hostname) = origin.host_str() {
|
||||
add_allowed(hostname, &port);
|
||||
if is_local_hostname(hostname) {
|
||||
add_allowed("127.0.0.1", &port);
|
||||
add_allowed("localhost", &port);
|
||||
add_allowed("host.docker.internal", &port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hostname = target
|
||||
.host_str()
|
||||
.ok_or_else(|| "目标 URL 缺少 hostname".to_string())?;
|
||||
|
||||
if is_private_ipv4(hostname) && !is_local_hostname(hostname) && !allowed_hostnames.contains(hostname) {
|
||||
return Err("禁止访问内网/私有地址".into());
|
||||
}
|
||||
|
||||
if !allowed_hostnames.is_empty() && !allowed_hostnames.contains(hostname) {
|
||||
return Err("禁止代理到非允许的主机".into());
|
||||
}
|
||||
|
||||
if let Some(allowed_ports) = allowed_ports_by_hostname.get(hostname) {
|
||||
if !allowed_ports.is_empty() {
|
||||
let port = target
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| "目标 URL 缺少端口信息".to_string())?
|
||||
.to_string();
|
||||
if !allowed_ports.contains(&port) {
|
||||
return Err("禁止代理到该端口".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut forward_headers = Vec::new();
|
||||
if let Some(range) = input.range.filter(|value| !value.trim().is_empty()) {
|
||||
forward_headers.push(OnlyOfficeHeader {
|
||||
name: "range".into(),
|
||||
value: range,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(anon_key) = input.supabase_anon_key.filter(|value| !value.trim().is_empty()) {
|
||||
let matches_public = supa.as_ref().map(|(value, _)| value == hostname).unwrap_or(false);
|
||||
let matches_internal = supa_internal_origin
|
||||
.as_ref()
|
||||
.and_then(|value| value.host_str())
|
||||
.map(|value| value == hostname)
|
||||
.unwrap_or(false);
|
||||
if matches_public || matches_internal {
|
||||
forward_headers.push(OnlyOfficeHeader {
|
||||
name: "apikey".into(),
|
||||
value: anon_key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(OnlyOfficeProxyPreparation {
|
||||
target_url: target.to_string(),
|
||||
forward_headers,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare_callback(
|
||||
input: OnlyOfficeCallbackPreparationInput,
|
||||
) -> Result<OnlyOfficeCallbackPreparation, String> {
|
||||
let session_result = resolve_session(OnlyOfficeSessionResolveInput {
|
||||
asset_id: input.asset_id.clone(),
|
||||
workspace_id: input.workspace_id.clone(),
|
||||
document_id: input.document_id.clone(),
|
||||
user_id: input.user_id.clone(),
|
||||
session_id: input.session_id.clone().or_else(|| Some("onlyoffice-callback".into())),
|
||||
});
|
||||
|
||||
if input.status != 2 && input.status != 6 {
|
||||
return Ok(OnlyOfficeCallbackPreparation {
|
||||
should_write: false,
|
||||
download_url: None,
|
||||
idempotency_key: input.key.filter(|value| !value.trim().is_empty()),
|
||||
locator: session_result.locator,
|
||||
session: session_result.session,
|
||||
});
|
||||
}
|
||||
|
||||
let raw_url = input
|
||||
.url
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "OnlyOffice callback 缺少下载地址".to_string())?;
|
||||
|
||||
Ok(OnlyOfficeCallbackPreparation {
|
||||
should_write: true,
|
||||
download_url: Some(rewrite_onlyoffice_download_url(
|
||||
&raw_url,
|
||||
&input.onlyoffice_internal_url,
|
||||
)),
|
||||
idempotency_key: input.key.filter(|value| !value.trim().is_empty()),
|
||||
locator: session_result.locator,
|
||||
session: session_result.session,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare_forcesave(
|
||||
input: OnlyOfficeForcesavePreparationInput,
|
||||
) -> Result<Vec<OnlyOfficeOutboundRequest>, String> {
|
||||
let base = input.onlyoffice_internal_url.trim().trim_end_matches('/');
|
||||
if base.is_empty() {
|
||||
return Err("缺少 onlyofficeInternalUrl".into());
|
||||
}
|
||||
if input.asset_id.trim().is_empty() {
|
||||
return Err("缺少 assetId".into());
|
||||
}
|
||||
if input.key.trim().is_empty() {
|
||||
return Err("缺少 key".into());
|
||||
}
|
||||
|
||||
let payload = json!({
|
||||
"c": "forcesave",
|
||||
"key": input.key,
|
||||
"userdata": format!("asset:{}", input.asset_id.trim()),
|
||||
});
|
||||
let raw_body_json = serde_json::to_string(&payload).map_err(|error| error.to_string())?;
|
||||
|
||||
let normalized_secret = normalize_secret(input.secret.as_deref().unwrap_or(""));
|
||||
let mut requests = Vec::new();
|
||||
|
||||
if !normalized_secret.is_empty() {
|
||||
let token = sign_hs256(&payload, &normalized_secret)?;
|
||||
requests.push(OnlyOfficeOutboundRequest {
|
||||
via: "command".into(),
|
||||
url: format!("{base}/command"),
|
||||
method: "POST".into(),
|
||||
headers: vec![OnlyOfficeHeader {
|
||||
name: "content-type".into(),
|
||||
value: "application/json".into(),
|
||||
}],
|
||||
body_json: serde_json::to_string(&json!({ "token": token }))
|
||||
.map_err(|error| error.to_string())?,
|
||||
});
|
||||
requests.push(OnlyOfficeOutboundRequest {
|
||||
via: "forcesave".into(),
|
||||
url: format!("{base}/forcesave"),
|
||||
method: "POST".into(),
|
||||
headers: vec![OnlyOfficeHeader {
|
||||
name: "content-type".into(),
|
||||
value: "application/json".into(),
|
||||
}],
|
||||
body_json: raw_body_json,
|
||||
});
|
||||
return Ok(requests);
|
||||
}
|
||||
|
||||
requests.push(OnlyOfficeOutboundRequest {
|
||||
via: "forcesave".into(),
|
||||
url: format!("{base}/forcesave"),
|
||||
method: "POST".into(),
|
||||
headers: vec![OnlyOfficeHeader {
|
||||
name: "content-type".into(),
|
||||
value: "application/json".into(),
|
||||
}],
|
||||
body_json: raw_body_json.clone(),
|
||||
});
|
||||
requests.push(OnlyOfficeOutboundRequest {
|
||||
via: "command_no_token".into(),
|
||||
url: format!("{base}/command"),
|
||||
method: "POST".into(),
|
||||
headers: vec![OnlyOfficeHeader {
|
||||
name: "content-type".into(),
|
||||
value: "application/json".into(),
|
||||
}],
|
||||
body_json: raw_body_json,
|
||||
});
|
||||
Ok(requests)
|
||||
}
|
||||
|
||||
pub fn rewrite_onlyoffice_download_url(raw: &str, onlyoffice_internal_url: &str) -> String {
|
||||
let base = onlyoffice_internal_url.trim().trim_end_matches('/');
|
||||
if base.is_empty() {
|
||||
return raw.to_string();
|
||||
}
|
||||
let Ok(url) = Url::parse(raw) else {
|
||||
return raw.to_string();
|
||||
};
|
||||
let prefix = "/onlyoffice-server";
|
||||
if !url.path().starts_with(prefix) {
|
||||
return raw.to_string();
|
||||
}
|
||||
let next_path = url.path()[prefix.len()..].trim_start_matches('/');
|
||||
if next_path.is_empty() {
|
||||
return raw.to_string();
|
||||
}
|
||||
let mut rewritten = format!("{base}/{next_path}");
|
||||
if let Some(query) = url.query() {
|
||||
rewritten.push('?');
|
||||
rewritten.push_str(query);
|
||||
}
|
||||
rewritten
|
||||
}
|
||||
|
||||
fn sign_hs256(payload: &Value, secret: &str) -> Result<String, String> {
|
||||
let header = json!({ "alg": "HS256", "typ": "JWT" });
|
||||
let header_part = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
|
||||
let payload_part = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
|
||||
let signing_input = format!("{header_part}.{payload_part}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
|
||||
mac.update(signing_input.as_bytes());
|
||||
let signature = mac.finalize().into_bytes();
|
||||
let signature_part = URL_SAFE_NO_PAD.encode(signature);
|
||||
Ok(format!("{signing_input}.{signature_part}"))
|
||||
}
|
||||
|
||||
fn decode_base64url_to_utf8(input: &str) -> Result<String, String> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(input.trim())
|
||||
.map_err(|_| "u 不是有效的 base64url URL".to_string())?;
|
||||
String::from_utf8(bytes).map_err(|_| "u 不是有效的 base64url URL".to_string())
|
||||
}
|
||||
|
||||
fn is_local_hostname(hostname: &str) -> bool {
|
||||
matches!(hostname, "127.0.0.1" | "localhost" | "host.docker.internal")
|
||||
}
|
||||
|
||||
fn is_private_ipv4(hostname: &str) -> bool {
|
||||
let parts = hostname
|
||||
.split('.')
|
||||
.map(str::parse::<u16>)
|
||||
.collect::<Result<Vec<_>, _>>();
|
||||
let Ok(parts) = parts else {
|
||||
return false;
|
||||
};
|
||||
if parts.len() != 4 || parts.iter().any(|value| *value > 255) {
|
||||
return false;
|
||||
}
|
||||
let a = parts[0];
|
||||
let b = parts[1];
|
||||
a == 10
|
||||
|| a == 127
|
||||
|| (a == 169 && b == 254)
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
|| a == 0
|
||||
}
|
||||
|
||||
fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> {
|
||||
let value = raw?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value.starts_with("http://") || value.starts_with("https://") {
|
||||
let url = Url::parse(value).ok()?;
|
||||
return Some((
|
||||
url.host_str()?.to_string(),
|
||||
url.port().map(|value| value.to_string()).unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
Some((value.to_string(), String::new()))
|
||||
}
|
||||
|
||||
fn try_parse_origin_url(raw: Option<&str>) -> Option<Url> {
|
||||
let value = raw?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Url::parse(value)
|
||||
.or_else(|_| Url::parse(&format!("http://{value}")))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn boundary_statement_mentions_non_source_of_truth() {
|
||||
assert!(adapter_boundary_statement().contains("不把 OnlyOffice 变成主事实层"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_secret_strips_quotes() {
|
||||
assert_eq!(normalize_secret(" \"abc\" "), "abc");
|
||||
assert_eq!(normalize_secret("'xyz'"), "xyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_config_returns_document_and_editor_tokens() {
|
||||
let tokens = sign_config(
|
||||
&json!({
|
||||
"document": {"title": "A"},
|
||||
"editorConfig": {"mode": "edit"},
|
||||
}),
|
||||
"secret",
|
||||
)
|
||||
.expect("sign should succeed");
|
||||
assert!(tokens.token.is_some());
|
||||
assert!(tokens.document_token.is_some());
|
||||
assert!(tokens.editor_config_token.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_request_rewrites_supabase_path_to_internal_origin() {
|
||||
let encoded_url = URL_SAFE_NO_PAD.encode(
|
||||
b"https://public.example.com/storage/v1/object/sign/documents/a.docx?token=abc",
|
||||
);
|
||||
let result = prepare_proxy_request(OnlyOfficeProxyPreparationInput {
|
||||
encoded_url,
|
||||
method: "GET".into(),
|
||||
range: Some("bytes=0-99".into()),
|
||||
supabase_url: Some("https://public.example.com".into()),
|
||||
supabase_internal_url: Some("http://127.0.0.1:18000".into()),
|
||||
onlyoffice_storage_host_override: None,
|
||||
convex_origin: Some("http://127.0.0.1:3210".into()),
|
||||
supabase_anon_key: Some("anon".into()),
|
||||
})
|
||||
.expect("proxy should prepare");
|
||||
assert!(result.target_url.starts_with("http://127.0.0.1:18000/storage/v1/"));
|
||||
assert_eq!(result.forward_headers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_preparation_rewrites_onlyoffice_server_path() {
|
||||
let result = prepare_callback(OnlyOfficeCallbackPreparationInput {
|
||||
asset_id: "asset_1".into(),
|
||||
document_id: Some("doc_1".into()),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
user_id: Some("user_1".into()),
|
||||
session_id: None,
|
||||
status: 6,
|
||||
url: Some("http://app.example.com/onlyoffice-server/cache/files/out.docx?token=1".into()),
|
||||
key: Some("doc_key_1".into()),
|
||||
onlyoffice_internal_url: "http://127.0.0.1:8082".into(),
|
||||
})
|
||||
.expect("callback should prepare");
|
||||
assert!(result.should_write);
|
||||
assert_eq!(
|
||||
result.download_url.as_deref(),
|
||||
Some("http://127.0.0.1:8082/cache/files/out.docx?token=1")
|
||||
);
|
||||
assert_eq!(result.session.session_id, "onlyoffice-callback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forcesave_preparation_prefers_command_when_secret_present() {
|
||||
let result = prepare_forcesave(OnlyOfficeForcesavePreparationInput {
|
||||
asset_id: "asset_1".into(),
|
||||
key: "doc_key_1".into(),
|
||||
onlyoffice_internal_url: "http://127.0.0.1:8082".into(),
|
||||
secret: Some("secret".into()),
|
||||
})
|
||||
.expect("forcesave should prepare");
|
||||
assert_eq!(result[0].via, "command");
|
||||
assert_eq!(result[1].via, "forcesave");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "bridge-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
adapter-onlyoffice = { path = "../adapter-onlyoffice" }
|
||||
core-domain = { path = "../core-domain" }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
event-log = { path = "../event-log" }
|
||||
index-fts = { path = "../index-fts" }
|
||||
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
urlencoding = "2"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
use std::io::{self, Read};
|
||||
|
||||
use bridge_runtime::{
|
||||
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
||||
runtime_input_requests_result, RuntimeFailure, RuntimeInput,
|
||||
};
|
||||
use storage_convex_bridge::{BridgeError, BridgeErrorKind};
|
||||
|
||||
fn main() {
|
||||
let input = match read_stdin() {
|
||||
Ok(input) => input,
|
||||
Err(error) => {
|
||||
emit_failure(BridgeError {
|
||||
kind: BridgeErrorKind::Transport,
|
||||
message: format!("读取 bridge runtime stdin 失败: {error}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let runtime_input = match serde_json::from_str::<RuntimeInput>(&input) {
|
||||
Ok(runtime_input) => runtime_input,
|
||||
Err(error) => {
|
||||
emit_failure(BridgeError::validation(format!(
|
||||
"bridge runtime 输入 JSON 非法: {error}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if runtime_input_requests_result(&runtime_input) {
|
||||
match execute_runtime_query(runtime_input) {
|
||||
Ok(result) => {
|
||||
let payload = serde_json::to_string(&serde_json::json!({
|
||||
"ok": true,
|
||||
"result": result,
|
||||
}))
|
||||
.expect("bridge runtime query result 必须可序列化");
|
||||
println!("{payload}");
|
||||
}
|
||||
Err(error) => emit_failure(error),
|
||||
}
|
||||
} else {
|
||||
match execute_runtime_input(runtime_input) {
|
||||
Ok(plan) => {
|
||||
let payload = serde_json::to_string(&build_success_response(plan))
|
||||
.expect("bridge runtime 成功响应必须可序列化");
|
||||
println!("{payload}");
|
||||
}
|
||||
Err(error) => emit_failure(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stdin() -> io::Result<String> {
|
||||
let mut buffer = String::new();
|
||||
io::stdin().read_to_string(&mut buffer)?;
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn emit_failure(error: BridgeError) -> ! {
|
||||
let payload =
|
||||
serde_json::to_string(&build_failure_response(error)).unwrap_or_else(|serialize_error| {
|
||||
serde_json::to_string(&RuntimeFailure {
|
||||
ok: false,
|
||||
error: bridge_runtime::RuntimeErrorPayload {
|
||||
kind: "transport".into(),
|
||||
message: format!("bridge runtime 错误序列化失败: {serialize_error}"),
|
||||
},
|
||||
})
|
||||
.expect("bridge runtime 兜底错误响应必须可序列化")
|
||||
});
|
||||
println!("{payload}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -7,3 +7,5 @@ authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
core-domain = { path = "../core-domain" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -44,6 +44,44 @@ pub struct UpdatePageTitle {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateDocumentPage {
|
||||
pub page_id: String,
|
||||
pub parent_page_id: Option<String>,
|
||||
pub title: String,
|
||||
pub workspace_seed_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MoveDocumentPage {
|
||||
pub page_id: String,
|
||||
pub parent_page_id: Option<String>,
|
||||
pub sort_order: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteDocumentPage {
|
||||
pub page_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RestoreDocumentPage {
|
||||
pub page_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DuplicateDocumentPage {
|
||||
pub source_page_id: String,
|
||||
pub new_page_id: String,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CopyTreeDocumentPages {
|
||||
pub items_json: String,
|
||||
pub target_parent_page_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdatePageStats {
|
||||
pub page_id: String,
|
||||
@@ -87,6 +125,16 @@ pub struct SavePageContent {
|
||||
pub conflict_detection_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PatchBlock {
|
||||
pub page_id: String,
|
||||
pub block_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub revision: Option<u64>,
|
||||
pub block_snapshot_json: String,
|
||||
pub conflict_detection_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PatchPageBlock {
|
||||
pub page_id: String,
|
||||
@@ -97,6 +145,14 @@ pub struct PatchPageBlock {
|
||||
pub conflict_detection_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PutMindmap {
|
||||
pub document_id: String,
|
||||
pub mindmap_id: String,
|
||||
pub data_json: String,
|
||||
pub create_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InsertBlock {
|
||||
pub page_id: String,
|
||||
@@ -120,6 +176,14 @@ pub struct MoveBlock {
|
||||
pub prev_block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EmbedBlock {
|
||||
pub source_document_id: String,
|
||||
pub source_block_id: String,
|
||||
pub target_document_id: String,
|
||||
pub target_block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteBlock {
|
||||
pub block_id: String,
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
pub mod command;
|
||||
pub mod common;
|
||||
pub mod governance;
|
||||
pub mod mindmap;
|
||||
pub mod query;
|
||||
pub mod tool;
|
||||
|
||||
pub use command::{
|
||||
CommandEnvelope, CreatePage, CreateWorkspace, DeleteBlock, InsertBlock, MoveBlock,
|
||||
PatchPageBlock, ReplaceMediaAssetStorage, SavePageContent, UpdateBlock, UpdatePageOptions,
|
||||
UpdatePageStats, UpdatePageTitle,
|
||||
CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
|
||||
DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock, InsertBlock, MoveBlock,
|
||||
MoveDocumentPage, PatchBlock, PatchPageBlock, PutMindmap, ReplaceMediaAssetStorage,
|
||||
RestoreDocumentPage, SavePageContent, UpdateBlock, UpdatePageOptions, UpdatePageStats,
|
||||
UpdatePageTitle,
|
||||
};
|
||||
pub use common::{
|
||||
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
|
||||
SourcePayload, TargetRef,
|
||||
};
|
||||
pub use mindmap::{
|
||||
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode,
|
||||
};
|
||||
pub use query::{
|
||||
GetPage, GetPageContent, GetPageMeta, ListPageBlocks, ListSidebarDataset, QueryEnvelope,
|
||||
SearchBlocks, SearchPages,
|
||||
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
||||
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks,
|
||||
ListSidebarDataset, QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
||||
};
|
||||
pub use tool::{
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
||||
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
|
||||
DOC_TOOLSET_READ, DOC_TOOLSET_WRITE, INDEX_TOOL_REBUILD, MINDMAP_TOOL_APPLY_OPS,
|
||||
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET,
|
||||
MINDMAP_TOOL_GET_SUBTREE,
|
||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, MINDMAP_TOOLSET_READ,
|
||||
MINDMAP_TOOLSET_WRITE, OBSERVE_TOOLSET_READ, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY,
|
||||
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, ONLYOFFICE_TOOLSET_SERVICE,
|
||||
RECOVERY_TOOLSET_JOB, REPLAY_TOOL_EVENTS,
|
||||
};
|
||||
pub use tool::{InvocationKind, ToolInvocation};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -51,4 +71,124 @@ mod tests {
|
||||
|
||||
assert!(envelope.validate_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_tool_registry_exposes_doc_tools() {
|
||||
let registry = default_tool_registry();
|
||||
assert_eq!(
|
||||
registry.tool_names(),
|
||||
vec![
|
||||
"search_web",
|
||||
"doc_get",
|
||||
"doc_find",
|
||||
"image_read",
|
||||
"doc_insert_blocks",
|
||||
"doc_replace_range",
|
||||
"slash_run",
|
||||
"mindmap_get",
|
||||
"mindmap_get_subtree",
|
||||
"mindmap_put",
|
||||
"mindmap_apply_ops",
|
||||
"mindmap_expand_node",
|
||||
"mindmap_empty_trash",
|
||||
"mindmap_outline_to_mindmap",
|
||||
"bridge_request_get",
|
||||
"bridge_trace_get",
|
||||
"bridge_command_get",
|
||||
"event_replay",
|
||||
"index_rebuild",
|
||||
"onlyoffice_session_resolve",
|
||||
"onlyoffice_sign",
|
||||
"onlyoffice_prepare_proxy",
|
||||
"onlyoffice_prepare_callback",
|
||||
"onlyoffice_prepare_forcesave",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.tool("search_web")
|
||||
.expect("search_web should exist")
|
||||
.toolset_id,
|
||||
"toolset.readonly"
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.tool("image_read")
|
||||
.expect("image_read should exist")
|
||||
.toolset_id,
|
||||
"toolset.media_read"
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.tool("slash_run")
|
||||
.expect("slash_run should exist")
|
||||
.toolset_id,
|
||||
"toolset.slash_write"
|
||||
);
|
||||
let readonly = registry
|
||||
.toolset("toolset.readonly")
|
||||
.expect("readonly toolset should exist");
|
||||
assert!(!readonly.write_toolset);
|
||||
assert_eq!(readonly.tool_names, &["search_web"]);
|
||||
let media = registry
|
||||
.toolset("toolset.media_read")
|
||||
.expect("media toolset should exist");
|
||||
assert!(!media.write_toolset);
|
||||
assert_eq!(media.tool_names, &["image_read"]);
|
||||
let slash = registry
|
||||
.toolset("toolset.slash_write")
|
||||
.expect("slash toolset should exist");
|
||||
assert!(slash.write_toolset);
|
||||
assert_eq!(slash.tool_names, &["slash_run"]);
|
||||
let doc_write = registry
|
||||
.toolset("toolset.doc_write")
|
||||
.expect("doc_write toolset should exist");
|
||||
assert!(doc_write.write_toolset);
|
||||
assert_eq!(doc_write.tool_names, &["doc_insert_blocks", "doc_replace_range"]);
|
||||
assert_eq!(
|
||||
registry.tool("doc_get").expect("doc_get should exist").toolset_id,
|
||||
"toolset.doc_read"
|
||||
);
|
||||
let mindmap_write = registry
|
||||
.toolset("toolset.mindmap_write")
|
||||
.expect("mindmap_write toolset should exist");
|
||||
assert!(mindmap_write.write_toolset);
|
||||
assert_eq!(
|
||||
mindmap_write.tool_names,
|
||||
&[
|
||||
"mindmap_put",
|
||||
"mindmap_apply_ops",
|
||||
"mindmap_expand_node",
|
||||
"mindmap_empty_trash",
|
||||
"mindmap_outline_to_mindmap",
|
||||
]
|
||||
);
|
||||
let onlyoffice_service = registry
|
||||
.toolset("toolset.onlyoffice_service")
|
||||
.expect("onlyoffice service toolset should exist");
|
||||
assert!(onlyoffice_service.write_toolset);
|
||||
assert_eq!(
|
||||
onlyoffice_service.tool_names,
|
||||
&[
|
||||
"onlyoffice_session_resolve",
|
||||
"onlyoffice_sign",
|
||||
"onlyoffice_prepare_proxy",
|
||||
"onlyoffice_prepare_callback",
|
||||
"onlyoffice_prepare_forcesave",
|
||||
]
|
||||
);
|
||||
let observe = registry
|
||||
.toolset("toolset.observe_read")
|
||||
.expect("observe toolset should exist");
|
||||
assert!(!observe.write_toolset);
|
||||
assert_eq!(
|
||||
observe.tool_names,
|
||||
&["bridge_request_get", "bridge_trace_get", "bridge_command_get"]
|
||||
);
|
||||
let recovery = registry
|
||||
.toolset("toolset.recovery_job")
|
||||
.expect("recovery toolset should exist");
|
||||
assert!(recovery.write_toolset);
|
||||
assert_eq!(recovery.tool_names, &["event_replay", "index_rebuild"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapNodeRef {
|
||||
pub kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub asset_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub slide: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snippet: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapNodeData {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hyperlink: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refs: Option<Vec<MindmapNodeRef>>,
|
||||
#[serde(flatten, default)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MindmapTreeNode {
|
||||
pub data: MindmapNodeData,
|
||||
#[serde(default)]
|
||||
pub children: Vec<MindmapTreeNode>,
|
||||
#[serde(flatten, default)]
|
||||
pub extra: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapNodeInput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uid: Option<String>,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hyperlink: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refs: Option<Vec<MindmapNodeRef>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "op", rename_all = "camelCase")]
|
||||
pub enum MindmapOp {
|
||||
#[serde(alias = "add_child")]
|
||||
AddChild {
|
||||
#[serde(rename = "parentUid")]
|
||||
#[serde(alias = "parent_uid")]
|
||||
parent_uid: String,
|
||||
node: MindmapNodeInput,
|
||||
},
|
||||
#[serde(alias = "add_sibling_after")]
|
||||
AddSiblingAfter {
|
||||
#[serde(rename = "targetUid")]
|
||||
#[serde(alias = "target_uid")]
|
||||
target_uid: String,
|
||||
node: MindmapNodeInput,
|
||||
},
|
||||
#[serde(alias = "update_node", alias = "updateNode")]
|
||||
UpdateText {
|
||||
uid: String,
|
||||
#[serde(alias = "value")]
|
||||
text: String,
|
||||
},
|
||||
#[serde(alias = "set_link", alias = "set_hyperlink")]
|
||||
SetHyperlink {
|
||||
#[serde(alias = "id")]
|
||||
uid: String,
|
||||
#[serde(alias = "url")]
|
||||
hyperlink: Option<String>,
|
||||
},
|
||||
#[serde(alias = "set_refs")]
|
||||
SetRefs {
|
||||
#[serde(alias = "id")]
|
||||
uid: String,
|
||||
refs: Vec<MindmapNodeRef>,
|
||||
},
|
||||
#[serde(alias = "append_note")]
|
||||
AppendNote {
|
||||
#[serde(alias = "id")]
|
||||
uid: String,
|
||||
#[serde(alias = "note")]
|
||||
markdown: String,
|
||||
},
|
||||
#[serde(alias = "delete_node")]
|
||||
DeleteNode {
|
||||
#[serde(alias = "id")]
|
||||
uid: String,
|
||||
},
|
||||
}
|
||||
@@ -27,6 +27,18 @@ pub struct GetPageContent {
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetBlock {
|
||||
pub block_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetMindmap {
|
||||
pub document_id: String,
|
||||
pub mindmap_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListSidebarDataset {
|
||||
pub workspace_id: String,
|
||||
@@ -45,9 +57,62 @@ pub struct SearchPages {
|
||||
pub pagination: Pagination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SearchDocuments {
|
||||
pub query: String,
|
||||
pub workspace_id: String,
|
||||
pub page_id: Option<String>,
|
||||
pub pagination: Pagination,
|
||||
pub title_only: bool,
|
||||
pub exact: bool,
|
||||
pub include_ocr: bool,
|
||||
pub time_range: String,
|
||||
pub time_field: String,
|
||||
pub custom_range_from: Option<String>,
|
||||
pub custom_range_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SearchRecent {
|
||||
pub workspace_id: String,
|
||||
pub pagination: Pagination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SearchBlocks {
|
||||
pub query: String,
|
||||
pub page_id: Option<String>,
|
||||
pub pagination: Pagination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetBridgeRequest {
|
||||
pub workspace_id: String,
|
||||
pub request_id: String,
|
||||
pub command_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetBridgeTrace {
|
||||
pub workspace_id: String,
|
||||
pub trace_id: String,
|
||||
pub command_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetBridgeCommand {
|
||||
pub workspace_id: String,
|
||||
pub command_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListBridgeWorkspaceOverview {
|
||||
pub workspace_id: String,
|
||||
pub pagination: Pagination,
|
||||
pub command_status: Option<String>,
|
||||
pub event_status: Option<String>,
|
||||
pub target_page_id: Option<String>,
|
||||
pub target_block_id: Option<String>,
|
||||
pub aggregate_type: Option<String>,
|
||||
pub aggregate_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,534 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InvocationKind {
|
||||
Command,
|
||||
Query,
|
||||
Job,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolExecutionMode {
|
||||
Plan,
|
||||
Result,
|
||||
ExplainPlan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolInvocation {
|
||||
pub tool: String,
|
||||
pub kind: InvocationKind,
|
||||
pub mode: ToolExecutionMode,
|
||||
pub args_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolEffect {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ToolSpec {
|
||||
pub name: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub toolset_id: &'static str,
|
||||
pub invocation_kind: InvocationKind,
|
||||
pub effect: ToolEffect,
|
||||
pub requires_confirmation: bool,
|
||||
pub input_schema_json: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ToolSetSpec {
|
||||
pub id: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub write_toolset: bool,
|
||||
pub tool_names: &'static [&'static str],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ToolRegistry {
|
||||
pub toolsets: &'static [ToolSetSpec],
|
||||
pub tools: &'static [ToolSpec],
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub const fn new(toolsets: &'static [ToolSetSpec], tools: &'static [ToolSpec]) -> Self {
|
||||
Self { toolsets, tools }
|
||||
}
|
||||
|
||||
pub fn tool(&self, name: &str) -> Option<&'static ToolSpec> {
|
||||
self.tools.iter().find(|spec| spec.name == name)
|
||||
}
|
||||
|
||||
pub fn toolset(&self, id: &str) -> Option<&'static ToolSetSpec> {
|
||||
self.toolsets.iter().find(|spec| spec.id == id)
|
||||
}
|
||||
|
||||
pub fn tool_names(&self) -> Vec<&'static str> {
|
||||
self.tools.iter().map(|spec| spec.name).collect()
|
||||
}
|
||||
|
||||
pub fn tool_names_in_set(&self, id: &str) -> Option<Vec<&'static str>> {
|
||||
self.toolset(id)
|
||||
.map(|toolset| toolset.tool_names.to_vec())
|
||||
}
|
||||
|
||||
pub fn tools_in_set(&self, id: &str) -> Option<Vec<&'static ToolSpec>> {
|
||||
self.toolset(id).map(|toolset| {
|
||||
toolset
|
||||
.tool_names
|
||||
.iter()
|
||||
.filter_map(|tool_name| self.tool(tool_name))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub const DOC_TOOL_GET: ToolSpec = ToolSpec {
|
||||
name: "doc_get",
|
||||
display_name: "文档读取",
|
||||
description: "读取当前文档快照,用于查看页面内容和结构。",
|
||||
toolset_id: "toolset.doc_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json: r#"{"type":"object","properties":{"maxBlocks":{"type":"integer","minimum":10,"maximum":240}}}"#,
|
||||
};
|
||||
|
||||
pub const SEARCH_WEB_TOOL: ToolSpec = ToolSpec {
|
||||
name: "search_web",
|
||||
display_name: "联网检索",
|
||||
description: "使用 SearxNG 进行联网检索,返回标题、URL 与摘要。",
|
||||
toolset_id: "toolset.readonly",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
||||
name: "doc_find",
|
||||
display_name: "文档查找",
|
||||
description: "在当前文档范围内查找片段、标题或结构信息。",
|
||||
toolset_id: "toolset.doc_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||
};
|
||||
|
||||
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||
name: "image_read",
|
||||
display_name: "读取图片",
|
||||
description: "读取图片/附件 OCR 信息并返回归一化结果。",
|
||||
toolset_id: "toolset.media_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
|
||||
name: "doc_insert_blocks",
|
||||
display_name: "插入块",
|
||||
description: "在文档中插入一个或多个块,是写入类操作。",
|
||||
toolset_id: "toolset.doc_write",
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["blocks"],"properties":{"afterBlockId":{"type":"string"},"beforeBlockId":{"type":"string"},"blocks":{"type":"array","minItems":1,"maxItems":20}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
|
||||
name: "doc_replace_range",
|
||||
display_name: "替换范围",
|
||||
description: "替换文档中的块范围,是写入类操作。",
|
||||
toolset_id: "toolset.doc_write",
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["blockId","text"],"properties":{"blockId":{"type":"string"},"text":{"type":"string"},"mode":{"enum":["replace","append","prepend"]}}}"#,
|
||||
};
|
||||
|
||||
pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
|
||||
name: "slash_run",
|
||||
display_name: "斜杠命令",
|
||||
description: "解析并规范化斜杠命令,用于创建或重命名页面。",
|
||||
toolset_id: "toolset.slash_write",
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"text":{"type":"string"},"command":{"enum":["new_doc","rename_doc"]},"params":{"type":"object"},"reason":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
|
||||
name: "mindmap_get",
|
||||
display_name: "导图读取",
|
||||
description: "读取当前思维导图的节点摘要,用于定位节点和规划改动。",
|
||||
toolset_id: "toolset.mindmap_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
|
||||
name: "mindmap_get_subtree",
|
||||
display_name: "导图子树读取",
|
||||
description: "读取指定节点 uid 的子树摘要,供 AI 精确查看局部结构。",
|
||||
toolset_id: "toolset.mindmap_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["uid"],"properties":{"uid":{"type":"string"},"depth":{"type":"integer","minimum":0,"maximum":6},"maxNodes":{"type":"integer","minimum":5,"maximum":200}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
|
||||
name: "mindmap_put",
|
||||
display_name: "导图覆盖写入",
|
||||
description: "使用完整思维导图树覆盖当前导图,适合 CLI 或 AI 在拿到完整快照后直接写回。",
|
||||
toolset_id: "toolset.mindmap_write",
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
|
||||
name: "mindmap_apply_ops",
|
||||
display_name: "导图增量写入",
|
||||
description: "对思维导图应用增量 ops,并返回新的整棵树快照。",
|
||||
toolset_id: "toolset.mindmap_write",
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["ops"],"properties":{"ops":{"type":"array","minItems":1,"maxItems":80},"reason":{"type":"string"},"targetUid":{"type":"string"},"searchResults":{"type":"array"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
|
||||
name: "mindmap_expand_node",
|
||||
display_name: "补完思维导图节点",
|
||||
description: "对 AI 生成的补完候选做统一归一化,再应用到当前思维导图并返回新树。",
|
||||
toolset_id: "toolset.mindmap_write",
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["targetUid"],"properties":{"targetUid":{"type":"string"},"instruction":{"type":"string"},"ops":{"type":"array","maxItems":80},"searchResults":{"type":"array"},"reason":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
|
||||
name: "mindmap_empty_trash",
|
||||
display_name: "清空导图回收站",
|
||||
description: "校验并标准化清空导图回收站所需的工作区对象,供 route 继续执行实际写入。",
|
||||
toolset_id: "toolset.mindmap_write",
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec {
|
||||
name: "mindmap_outline_to_mindmap",
|
||||
display_name: "大纲转导图",
|
||||
description: "把结构化大纲转换为标准思维导图树,统一 uid、refs 与 page 链接生成规则。",
|
||||
toolset_id: "toolset.mindmap_write",
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["rootTitle","pageLinkPattern","outline"],"properties":{"rootTitle":{"type":"string"},"pageLinkPattern":{"type":"string"},"outline":{"type":"array","items":{"type":"object","required":["title","level","page"],"properties":{"title":{"type":"string"},"level":{"type":"integer","minimum":1,"maximum":6},"page":{"type":"integer","minimum":1}}}}}}"#,
|
||||
};
|
||||
|
||||
pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
|
||||
name: "bridge_request_get",
|
||||
display_name: "请求回查",
|
||||
description: "按 request_id 查询统一 command log 与 domain event 视图。",
|
||||
toolset_id: "toolset.observe_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
|
||||
name: "bridge_trace_get",
|
||||
display_name: "链路回查",
|
||||
description: "按 trace_id 查询统一 command log 与 domain event 视图。",
|
||||
toolset_id: "toolset.observe_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
|
||||
name: "bridge_command_get",
|
||||
display_name: "命令回查",
|
||||
description: "按 command_id 查询统一 command log 与 domain event 视图。",
|
||||
toolset_id: "toolset.observe_read",
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
|
||||
name: "event_replay",
|
||||
display_name: "事件回放",
|
||||
description: "基于统一事件流做回放预演,输出将被追平的 event 与派生批次摘要。",
|
||||
toolset_id: "toolset.recovery_job",
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
|
||||
name: "index_rebuild",
|
||||
display_name: "索引重建",
|
||||
description: "根据事件流重建全文索引游标与派生批次,作为回放和恢复前置命令。",
|
||||
toolset_id: "toolset.recovery_job",
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
|
||||
name: "onlyoffice_session_resolve",
|
||||
display_name: "OnlyOffice 会话解析",
|
||||
description: "解析 OnlyOffice 的附件/页面/用户上下文,返回统一 session 与 asset 边界。",
|
||||
toolset_id: "toolset.onlyoffice_service",
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["assetId"],"properties":{"assetId":{"type":"string"},"workspaceId":{"type":"string"},"documentId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
|
||||
name: "onlyoffice_sign",
|
||||
display_name: "OnlyOffice 签名",
|
||||
description: "为 OnlyOffice config/document/editorConfig 生成 JWT 签名。",
|
||||
toolset_id: "toolset.onlyoffice_service",
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
|
||||
name: "onlyoffice_prepare_proxy",
|
||||
display_name: "OnlyOffice 代理预处理",
|
||||
description: "校验 proxy 回源目标并返回同源回源所需的上游 URL 与请求头。",
|
||||
toolset_id: "toolset.onlyoffice_service",
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
|
||||
name: "onlyoffice_prepare_callback",
|
||||
display_name: "OnlyOffice callback 预处理",
|
||||
description: "解析 callback 状态、下载地址和 session 边界,供写回链继续执行。",
|
||||
toolset_id: "toolset.onlyoffice_service",
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["assetId","status","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"documentId":{"type":"string"},"workspaceId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"},"status":{"type":"integer"},"url":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
|
||||
name: "onlyoffice_prepare_forcesave",
|
||||
display_name: "OnlyOffice forcesave 预处理",
|
||||
description: "生成触发 forcesave 所需的请求序列与 JWT 负载。",
|
||||
toolset_id: "toolset.onlyoffice_service",
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["assetId","key","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"},"secret":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.doc_read",
|
||||
display_name: "文档读取",
|
||||
description: "只读文档工具集合。",
|
||||
write_toolset: false,
|
||||
tool_names: &["doc_get", "doc_find"],
|
||||
};
|
||||
|
||||
pub const READONLY_TOOLSET: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.readonly",
|
||||
display_name: "只读工具",
|
||||
description: "Rust 只读工具集合。",
|
||||
write_toolset: false,
|
||||
tool_names: &["search_web"],
|
||||
};
|
||||
|
||||
pub const MEDIA_TOOLSET: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.media_read",
|
||||
display_name: "媒体读取",
|
||||
description: "Rust 媒体读取工具集合。",
|
||||
write_toolset: false,
|
||||
tool_names: &["image_read"],
|
||||
};
|
||||
|
||||
pub const DOC_TOOLSET_WRITE: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.doc_write",
|
||||
display_name: "文档写入",
|
||||
description: "写入文档工具集合。",
|
||||
write_toolset: true,
|
||||
tool_names: &["doc_insert_blocks", "doc_replace_range"],
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.mindmap_read",
|
||||
display_name: "导图读取",
|
||||
description: "只读思维导图工具集合。",
|
||||
write_toolset: false,
|
||||
tool_names: &["mindmap_get", "mindmap_get_subtree"],
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOLSET_WRITE: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.mindmap_write",
|
||||
display_name: "导图写入",
|
||||
description: "写入思维导图的工具集合。",
|
||||
write_toolset: true,
|
||||
tool_names: &[
|
||||
"mindmap_put",
|
||||
"mindmap_apply_ops",
|
||||
"mindmap_expand_node",
|
||||
"mindmap_empty_trash",
|
||||
"mindmap_outline_to_mindmap",
|
||||
],
|
||||
};
|
||||
|
||||
pub const OBSERVE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.observe_read",
|
||||
display_name: "统一观测",
|
||||
description: "按 request、trace、command 回查统一日志与事件视图。",
|
||||
write_toolset: false,
|
||||
tool_names: &["bridge_request_get", "bridge_trace_get", "bridge_command_get"],
|
||||
};
|
||||
|
||||
pub const RECOVERY_TOOLSET_JOB: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.recovery_job",
|
||||
display_name: "恢复与重建",
|
||||
description: "统一事件回放、索引重建与恢复类任务集合。",
|
||||
write_toolset: true,
|
||||
tool_names: &["event_replay", "index_rebuild"],
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOLSET_SERVICE: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.onlyoffice_service",
|
||||
display_name: "OnlyOffice 服务边界",
|
||||
description: "供 Web route / CLI / AI 共享的 OnlyOffice 对象适配工具集合。",
|
||||
write_toolset: true,
|
||||
tool_names: &[
|
||||
"onlyoffice_session_resolve",
|
||||
"onlyoffice_sign",
|
||||
"onlyoffice_prepare_proxy",
|
||||
"onlyoffice_prepare_callback",
|
||||
"onlyoffice_prepare_forcesave",
|
||||
],
|
||||
};
|
||||
|
||||
pub const SLASH_TOOLSET_WRITE: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.slash_write",
|
||||
display_name: "斜杠命令",
|
||||
description: "Rust 斜杠命令工具集合。",
|
||||
write_toolset: true,
|
||||
tool_names: &["slash_run"],
|
||||
};
|
||||
|
||||
pub const DOC_TOOL_REGISTRY: ToolRegistry = ToolRegistry::new(
|
||||
&[
|
||||
READONLY_TOOLSET,
|
||||
MEDIA_TOOLSET,
|
||||
SLASH_TOOLSET_WRITE,
|
||||
DOC_TOOLSET_READ,
|
||||
DOC_TOOLSET_WRITE,
|
||||
MINDMAP_TOOLSET_READ,
|
||||
MINDMAP_TOOLSET_WRITE,
|
||||
OBSERVE_TOOLSET_READ,
|
||||
RECOVERY_TOOLSET_JOB,
|
||||
ONLYOFFICE_TOOLSET_SERVICE,
|
||||
],
|
||||
&[
|
||||
SEARCH_WEB_TOOL,
|
||||
DOC_TOOL_GET,
|
||||
DOC_TOOL_FIND,
|
||||
IMAGE_READ_TOOL,
|
||||
DOC_TOOL_INSERT_BLOCKS,
|
||||
DOC_TOOL_REPLACE_RANGE,
|
||||
SLASH_RUN_TOOL,
|
||||
MINDMAP_TOOL_GET,
|
||||
MINDMAP_TOOL_GET_SUBTREE,
|
||||
MINDMAP_TOOL_PUT,
|
||||
MINDMAP_TOOL_APPLY_OPS,
|
||||
MINDMAP_TOOL_EXPAND_NODE,
|
||||
MINDMAP_TOOL_EMPTY_TRASH,
|
||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP,
|
||||
BRIDGE_TOOL_REQUEST_GET,
|
||||
BRIDGE_TOOL_TRACE_GET,
|
||||
BRIDGE_TOOL_COMMAND_GET,
|
||||
REPLAY_TOOL_EVENTS,
|
||||
INDEX_TOOL_REBUILD,
|
||||
ONLYOFFICE_TOOL_SESSION_RESOLVE,
|
||||
ONLYOFFICE_TOOL_SIGN,
|
||||
ONLYOFFICE_TOOL_PREPARE_PROXY,
|
||||
ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
ONLYOFFICE_TOOL_PREPARE_FORCESAVE,
|
||||
],
|
||||
);
|
||||
|
||||
pub fn default_tool_registry() -> &'static ToolRegistry {
|
||||
&DOC_TOOL_REGISTRY
|
||||
}
|
||||
|
||||
pub fn tool_mode_label(mode: &ToolExecutionMode) -> &'static str {
|
||||
match mode {
|
||||
ToolExecutionMode::Plan => "plan",
|
||||
ToolExecutionMode::Result => "result",
|
||||
ToolExecutionMode::ExplainPlan => "explain-plan",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invocation_kind_label(kind: &InvocationKind) -> &'static str {
|
||||
match kind {
|
||||
InvocationKind::Command => "command",
|
||||
InvocationKind::Query => "query",
|
||||
InvocationKind::Job => "job",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_effect_label(effect: &ToolEffect) -> &'static str {
|
||||
match effect {
|
||||
ToolEffect::Read => "read",
|
||||
ToolEffect::Write => "write",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,6 @@ authors.workspace = true
|
||||
[dependencies]
|
||||
core-domain = { path = "../core-domain" }
|
||||
event-log = { path = "../event-log" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use event_log::DomainEventRecord;
|
||||
use regex::RegexBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IndexedEntityKind {
|
||||
@@ -57,6 +62,107 @@ pub struct ProjectionResult {
|
||||
pub batches: Vec<ProjectedDocumentBatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchDocumentRecord {
|
||||
pub id: String,
|
||||
pub workspace_id: String,
|
||||
pub title: Option<String>,
|
||||
pub raw_text: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchMindmapRecord {
|
||||
pub document_id: String,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchTableRecord {
|
||||
pub id: String,
|
||||
pub document_id: String,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchTableRowRecord {
|
||||
pub table_id: String,
|
||||
pub document_id: String,
|
||||
pub row_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchAssetRecord {
|
||||
pub id: String,
|
||||
pub document_id: String,
|
||||
pub asset_type: Option<String>,
|
||||
pub file_name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
pub ocr_text: Option<String>,
|
||||
pub ocr_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchDocumentsDataset {
|
||||
pub documents: Vec<SearchDocumentRecord>,
|
||||
pub mindmaps: Vec<SearchMindmapRecord>,
|
||||
pub tables: Vec<SearchTableRecord>,
|
||||
pub table_rows: Vec<SearchTableRowRecord>,
|
||||
pub assets: Vec<SearchAssetRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchDocumentsRequest {
|
||||
pub query: String,
|
||||
pub workspace_id: String,
|
||||
pub page_id: Option<String>,
|
||||
pub limit: usize,
|
||||
pub title_only: bool,
|
||||
pub exact: bool,
|
||||
pub include_ocr: bool,
|
||||
pub time_range: String,
|
||||
pub time_field: String,
|
||||
pub custom_range_from: Option<String>,
|
||||
pub custom_range_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SearchMatchField {
|
||||
Title,
|
||||
Content,
|
||||
Recent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RankedSearchDocument {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
pub updated_at: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub match_field: SearchMatchField,
|
||||
pub has_ocr: bool,
|
||||
pub public_path: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchDocumentsEvaluation {
|
||||
pub enqueue_asset_ids: Vec<String>,
|
||||
pub results: Vec<RankedSearchDocument>,
|
||||
}
|
||||
|
||||
pub trait DomainEventProjector {
|
||||
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument>;
|
||||
}
|
||||
@@ -150,10 +256,22 @@ pub fn search_pages(
|
||||
let query = query.trim().to_lowercase();
|
||||
let hits = documents
|
||||
.iter()
|
||||
.filter(|document| matches!(document.entity_kind, IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary))
|
||||
.filter(|document| {
|
||||
matches!(
|
||||
document.entity_kind,
|
||||
IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary
|
||||
)
|
||||
})
|
||||
.filter(|document| workspace_id.map_or(true, |workspace| document.workspace_id == workspace))
|
||||
.filter(|document| document.title.as_deref().unwrap_or("").to_lowercase().contains(&query)
|
||||
|| document.content.to_lowercase().contains(&query))
|
||||
.filter(|document| {
|
||||
document
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
.contains(&query)
|
||||
|| document.content.to_lowercase().contains(&query)
|
||||
})
|
||||
.take(limit)
|
||||
.map(|document| SearchHit {
|
||||
workspace_id: document.workspace_id.clone(),
|
||||
@@ -180,8 +298,17 @@ pub fn search_blocks(
|
||||
let query = query.trim().to_lowercase();
|
||||
let hits = documents
|
||||
.iter()
|
||||
.filter(|document| matches!(document.entity_kind, IndexedEntityKind::BlockContent | IndexedEntityKind::BlockPath))
|
||||
.filter(|document| page_id.map_or(true, |page| document.parent_id.as_deref() == Some(page) || document.entity_id == page))
|
||||
.filter(|document| {
|
||||
matches!(
|
||||
document.entity_kind,
|
||||
IndexedEntityKind::BlockContent | IndexedEntityKind::BlockPath
|
||||
)
|
||||
})
|
||||
.filter(|document| {
|
||||
page_id.map_or(true, |page| {
|
||||
document.parent_id.as_deref() == Some(page) || document.entity_id == page
|
||||
})
|
||||
})
|
||||
.filter(|document| document.content.to_lowercase().contains(&query))
|
||||
.take(limit)
|
||||
.map(|document| SearchHit {
|
||||
@@ -200,6 +327,281 @@ pub fn search_blocks(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evaluate_search_documents(
|
||||
request: &SearchDocumentsRequest,
|
||||
dataset: &SearchDocumentsDataset,
|
||||
) -> SearchDocumentsEvaluation {
|
||||
let normalized_query = request.query.trim();
|
||||
if normalized_query.is_empty() {
|
||||
return SearchDocumentsEvaluation {
|
||||
enqueue_asset_ids: Vec::new(),
|
||||
results: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let normalized_lower = normalized_query.to_lowercase();
|
||||
let boundary_iso = match request.time_range.as_str() {
|
||||
"7d" => iso_days_ago(7),
|
||||
"30d" => iso_days_ago(30),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let eligible_docs: Vec<&SearchDocumentRecord> = dataset
|
||||
.documents
|
||||
.iter()
|
||||
.filter(|document| document.workspace_id == request.workspace_id)
|
||||
.filter(|document| {
|
||||
if let Some(page_id) = request.page_id.as_ref() {
|
||||
document.id == *page_id
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.filter(|document| match request.time_field.as_str() {
|
||||
"created" => within_range(
|
||||
document.created_at.as_deref(),
|
||||
boundary_iso.as_deref(),
|
||||
request.custom_range_from.as_deref(),
|
||||
request.custom_range_to.as_deref(),
|
||||
),
|
||||
_ => within_range(
|
||||
document.updated_at
|
||||
.as_deref()
|
||||
.or(document.created_at.as_deref()),
|
||||
boundary_iso.as_deref(),
|
||||
request.custom_range_from.as_deref(),
|
||||
request.custom_range_to.as_deref(),
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let eligible_doc_ids: HashSet<&str> = eligible_docs.iter().map(|document| document.id.as_str()).collect();
|
||||
let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs
|
||||
.iter()
|
||||
.map(|document| (document.id.as_str(), *document))
|
||||
.collect();
|
||||
|
||||
let mut matches = HashMap::<String, SearchMatchInfo>::new();
|
||||
for document in &eligible_docs {
|
||||
let title = normalize_title(document.title.as_deref());
|
||||
let title_lower = title.to_lowercase();
|
||||
let hit_title = if request.exact {
|
||||
title_lower == normalized_lower
|
||||
} else {
|
||||
title_lower.contains(&normalized_lower)
|
||||
};
|
||||
|
||||
if hit_title {
|
||||
upsert_match(
|
||||
&mut matches,
|
||||
&document.id,
|
||||
SearchMatchInfo {
|
||||
score: 3.0,
|
||||
match_field: SearchMatchField::Title,
|
||||
snippet: build_snippet(&title, normalized_query),
|
||||
has_ocr: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if request.title_only {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw_text = document.raw_text.as_deref().unwrap_or("").trim();
|
||||
if !raw_text.is_empty() && raw_text.to_lowercase().contains(&normalized_lower) {
|
||||
upsert_match(
|
||||
&mut matches,
|
||||
&document.id,
|
||||
SearchMatchInfo {
|
||||
score: 2.0,
|
||||
match_field: SearchMatchField::Content,
|
||||
snippet: build_snippet(raw_text, normalized_query),
|
||||
has_ocr: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut table_title_by_id = HashMap::<String, String>::new();
|
||||
if !request.title_only {
|
||||
for mindmap in &dataset.mindmaps {
|
||||
if !eligible_doc_ids.contains(mindmap.document_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let text = extract_text_from_mindmap_data(&mindmap.data, 60_000);
|
||||
if text.is_empty() || !text.to_lowercase().contains(&normalized_lower) {
|
||||
continue;
|
||||
}
|
||||
upsert_match(
|
||||
&mut matches,
|
||||
&mindmap.document_id,
|
||||
SearchMatchInfo {
|
||||
score: 1.6,
|
||||
match_field: SearchMatchField::Content,
|
||||
snippet: build_snippet(&format!("思维导图:{text}"), normalized_query),
|
||||
has_ocr: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for table in &dataset.tables {
|
||||
if !eligible_doc_ids.contains(table.document_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let title = normalize_title(table.title.as_deref());
|
||||
table_title_by_id.insert(table.id.clone(), title.clone());
|
||||
if title.to_lowercase().contains(&normalized_lower) {
|
||||
upsert_match(
|
||||
&mut matches,
|
||||
&table.document_id,
|
||||
SearchMatchInfo {
|
||||
score: 1.5,
|
||||
match_field: SearchMatchField::Content,
|
||||
snippet: build_snippet(&format!("表格:{title}"), normalized_query),
|
||||
has_ocr: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for row in &dataset.table_rows {
|
||||
if !eligible_doc_ids.contains(row.document_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let row_hash = row.row_hash.as_deref().unwrap_or("").trim();
|
||||
if row_hash.is_empty() || !row_hash.to_lowercase().contains(&normalized_lower) {
|
||||
continue;
|
||||
}
|
||||
let table_title = table_title_by_id
|
||||
.get(&row.table_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "未命名表格".into());
|
||||
upsert_match(
|
||||
&mut matches,
|
||||
&row.document_id,
|
||||
SearchMatchInfo {
|
||||
score: 1.4,
|
||||
match_field: SearchMatchField::Content,
|
||||
snippet: build_snippet(
|
||||
&format!("表格:{table_title}\n{row_hash}"),
|
||||
normalized_query,
|
||||
),
|
||||
has_ocr: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut enqueue_asset_ids = Vec::new();
|
||||
for asset in &dataset.assets {
|
||||
if !eligible_doc_ids.contains(asset.document_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = asset.file_name.as_deref().unwrap_or("").trim();
|
||||
if !request.title_only && !file_name.is_empty() && file_name.to_lowercase().contains(&normalized_lower) {
|
||||
upsert_match(
|
||||
&mut matches,
|
||||
&asset.document_id,
|
||||
SearchMatchInfo {
|
||||
score: 1.2,
|
||||
match_field: SearchMatchField::Content,
|
||||
snippet: build_snippet(&format!("附件:{file_name}"), normalized_query),
|
||||
has_ocr: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if !request.include_ocr {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ocr_text = asset.ocr_text.as_deref().unwrap_or("").trim();
|
||||
if ocr_text.is_empty() {
|
||||
let ocr_status = asset.ocr_status.as_deref().unwrap_or("").trim();
|
||||
let busy = ocr_status == "queued" || ocr_status == "running";
|
||||
if !busy
|
||||
&& should_extract_attachment_text(
|
||||
asset.asset_type.as_deref(),
|
||||
asset.mime_type.as_deref(),
|
||||
asset.file_name.as_deref(),
|
||||
)
|
||||
{
|
||||
enqueue_asset_ids.push(asset.id.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ocr_text.to_lowercase().contains(&normalized_lower) {
|
||||
upsert_match(
|
||||
&mut matches,
|
||||
&asset.document_id,
|
||||
SearchMatchInfo {
|
||||
score: 1.7,
|
||||
match_field: SearchMatchField::Content,
|
||||
snippet: build_snippet(
|
||||
&format!("附件:{}\n{ocr_text}", if file_name.is_empty() { asset.id.as_str() } else { file_name }),
|
||||
normalized_query,
|
||||
),
|
||||
has_ocr: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut results = matches
|
||||
.into_iter()
|
||||
.filter_map(|(document_id, matched)| {
|
||||
let document = doc_map.get(document_id.as_str())?;
|
||||
Some(RankedSearchDocument {
|
||||
id: document.id.clone(),
|
||||
title: normalize_title(document.title.as_deref()),
|
||||
snippet: matched.snippet,
|
||||
updated_at: document.updated_at.clone(),
|
||||
created_at: document.created_at.clone(),
|
||||
match_field: matched.match_field,
|
||||
has_ocr: matched.has_ocr,
|
||||
public_path: format!("/documents/{}", document.id),
|
||||
score: matched.score,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
results.sort_by(|left, right| {
|
||||
right
|
||||
.score
|
||||
.partial_cmp(&left.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| {
|
||||
let left_time = left
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.or(left.created_at.as_deref())
|
||||
.unwrap_or("");
|
||||
let right_time = right
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.or(right.created_at.as_deref())
|
||||
.unwrap_or("");
|
||||
right_time.cmp(left_time)
|
||||
})
|
||||
.then_with(|| left.title.cmp(&right.title))
|
||||
});
|
||||
results.truncate(request.limit);
|
||||
|
||||
enqueue_asset_ids.sort();
|
||||
enqueue_asset_ids.dedup();
|
||||
if enqueue_asset_ids.len() > 3 {
|
||||
enqueue_asset_ids.truncate(3);
|
||||
}
|
||||
|
||||
SearchDocumentsEvaluation {
|
||||
enqueue_asset_ids,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MinimalWorkspaceProjector;
|
||||
|
||||
impl DomainEventProjector for MinimalWorkspaceProjector {
|
||||
@@ -233,11 +635,258 @@ impl DomainEventProjector for MinimalWorkspaceProjector {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SearchMatchInfo {
|
||||
score: f64,
|
||||
match_field: SearchMatchField,
|
||||
snippet: String,
|
||||
has_ocr: bool,
|
||||
}
|
||||
|
||||
fn normalize_title(title: Option<&str>) -> String {
|
||||
let normalized = title.unwrap_or("").trim();
|
||||
if normalized.is_empty() {
|
||||
"无标题".into()
|
||||
} else {
|
||||
normalized.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn within_range(
|
||||
timestamp: Option<&str>,
|
||||
boundary_iso: Option<&str>,
|
||||
custom_range_from: Option<&str>,
|
||||
custom_range_to: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(timestamp) = timestamp else {
|
||||
return boundary_iso.is_none() && custom_range_from.is_none() && custom_range_to.is_none();
|
||||
};
|
||||
|
||||
if let Some(boundary_iso) = boundary_iso {
|
||||
if timestamp < boundary_iso {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(custom_range_from) = custom_range_from {
|
||||
if timestamp < custom_range_from {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(custom_range_to) = custom_range_to {
|
||||
if timestamp > custom_range_to {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn iso_days_ago(days: i64) -> Option<String> {
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
let seconds = days.checked_mul(24 * 60 * 60)?;
|
||||
let duration = Duration::from_secs(seconds as u64);
|
||||
let cutoff = SystemTime::now().checked_sub(duration)?;
|
||||
let datetime = chrono_like::system_time_to_iso(cutoff)?;
|
||||
Some(datetime)
|
||||
}
|
||||
|
||||
fn upsert_match(
|
||||
matches: &mut HashMap<String, SearchMatchInfo>,
|
||||
document_id: &str,
|
||||
patch: SearchMatchInfo,
|
||||
) {
|
||||
let Some(previous) = matches.get(document_id).cloned() else {
|
||||
matches.insert(document_id.into(), patch);
|
||||
return;
|
||||
};
|
||||
|
||||
let mut next = SearchMatchInfo {
|
||||
score: previous.score.max(patch.score),
|
||||
match_field: patch.match_field.clone(),
|
||||
snippet: patch.snippet.clone(),
|
||||
has_ocr: previous.has_ocr || patch.has_ocr,
|
||||
};
|
||||
|
||||
if matches!(previous.match_field, SearchMatchField::Title)
|
||||
&& !matches!(patch.match_field, SearchMatchField::Title)
|
||||
{
|
||||
next.match_field = SearchMatchField::Title;
|
||||
next.snippet = previous.snippet;
|
||||
} else if patch.score <= previous.score {
|
||||
next.match_field = previous.match_field;
|
||||
next.snippet = previous.snippet;
|
||||
}
|
||||
|
||||
matches.insert(document_id.into(), next);
|
||||
}
|
||||
|
||||
fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
|
||||
fn walk(node: &Value, output: &mut Vec<String>, max_chars: usize) {
|
||||
if output.join("\n").len() >= max_chars {
|
||||
return;
|
||||
}
|
||||
match node {
|
||||
Value::Object(map) => {
|
||||
if let Some(Value::Object(data)) = map.get("data") {
|
||||
if let Some(Value::String(text)) = data.get("text") {
|
||||
let normalized = normalize_text(text);
|
||||
if !normalized.is_empty() {
|
||||
output.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(children)) = map.get("children") {
|
||||
for child in children {
|
||||
walk(child, output, max_chars);
|
||||
if output.join("\n").len() >= max_chars {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(root) = map.get("root") {
|
||||
walk(root, output, max_chars);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
walk(item, output, max_chars);
|
||||
if output.join("\n").len() >= max_chars {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
walk(value, &mut output, max_chars);
|
||||
let joined = output.join("\n").trim().to_string();
|
||||
if joined.len() > max_chars {
|
||||
format!("{}…", &joined[..max_chars])
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_text(value: &str) -> String {
|
||||
value.split_whitespace().collect::<Vec<_>>().join(" ").trim().to_string()
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn build_snippet(text: &str, keyword: &str) -> String {
|
||||
let source = text.trim();
|
||||
if source.is_empty() {
|
||||
return "暂无正文内容".into();
|
||||
}
|
||||
let escaped = escape_html(source);
|
||||
if keyword.trim().is_empty() {
|
||||
return truncate_snippet(&escaped);
|
||||
}
|
||||
|
||||
let regex = match RegexBuilder::new(®ex::escape(keyword))
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
{
|
||||
Ok(regex) => regex,
|
||||
Err(_) => return truncate_snippet(&escaped),
|
||||
};
|
||||
|
||||
let Some(found) = regex.find(&escaped) else {
|
||||
return truncate_snippet(&escaped);
|
||||
};
|
||||
|
||||
let start = found.start().saturating_sub(20);
|
||||
let end = (found.end() + 80).min(escaped.len());
|
||||
let segment = escaped[start..end].to_string();
|
||||
regex
|
||||
.replace_all(&segment, |captures: ®ex::Captures<'_>| {
|
||||
format!("<mark>{}</mark>", &captures[0])
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn truncate_snippet(value: &str) -> String {
|
||||
let mut chars = value.chars();
|
||||
let truncated = chars.by_ref().take(120).collect::<String>();
|
||||
if chars.next().is_some() {
|
||||
format!("{truncated}…")
|
||||
} else {
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
fn should_extract_attachment_text(
|
||||
asset_type: Option<&str>,
|
||||
mime_type: Option<&str>,
|
||||
file_name: Option<&str>,
|
||||
) -> bool {
|
||||
if asset_type.unwrap_or("").trim() != "file" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mime_type = mime_type.unwrap_or("").trim().to_lowercase();
|
||||
if matches!(
|
||||
mime_type.as_str(),
|
||||
"application/pdf"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let file_name = file_name.unwrap_or("").trim().to_lowercase();
|
||||
[".pdf", ".docx", ".pptx", ".xlsx"]
|
||||
.iter()
|
||||
.any(|suffix| file_name.ends_with(suffix))
|
||||
}
|
||||
|
||||
mod chrono_like {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub fn system_time_to_iso(time: SystemTime) -> Option<String> {
|
||||
let duration = time.duration_since(UNIX_EPOCH).ok()?;
|
||||
let seconds = duration.as_secs() as i64;
|
||||
let days = seconds.div_euclid(86_400);
|
||||
let secs_of_day = seconds.rem_euclid(86_400);
|
||||
|
||||
let (year, month, day) = civil_from_days(days)?;
|
||||
let hour = secs_of_day / 3_600;
|
||||
let minute = (secs_of_day % 3_600) / 60;
|
||||
let second = secs_of_day % 60;
|
||||
Some(format!(
|
||||
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
|
||||
))
|
||||
}
|
||||
|
||||
fn civil_from_days(days: i64) -> Option<(i64, i64, i64)> {
|
||||
let z = days.checked_add(719_468)?;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = mp + if mp < 10 { 3 } else { -9 };
|
||||
let year = y + if m <= 2 { 1 } else { 0 };
|
||||
Some((year, m, d))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use core_domain::Timestamp;
|
||||
use event_log::EventStatus;
|
||||
use serde_json::json;
|
||||
|
||||
fn cursor() -> IndexCursor {
|
||||
IndexCursor {
|
||||
@@ -352,4 +1001,68 @@ mod tests {
|
||||
assert_eq!(blocks.hits.len(), 1);
|
||||
assert_eq!(blocks.hits[0].entity_id, "block_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_search_documents_uses_rust_scoring_and_snippet() {
|
||||
let result = evaluate_search_documents(
|
||||
&SearchDocumentsRequest {
|
||||
query: "rust".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
page_id: None,
|
||||
limit: 10,
|
||||
title_only: false,
|
||||
exact: false,
|
||||
include_ocr: true,
|
||||
time_range: "any".into(),
|
||||
time_field: "updated".into(),
|
||||
custom_range_from: None,
|
||||
custom_range_to: None,
|
||||
},
|
||||
&SearchDocumentsDataset {
|
||||
documents: vec![
|
||||
SearchDocumentRecord {
|
||||
id: "page_1".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
title: Some("Rust Notes".into()),
|
||||
raw_text: Some("正文包含 rust 搜索".into()),
|
||||
created_at: Some("2026-04-11T00:00:01Z".into()),
|
||||
updated_at: Some("2026-04-11T00:00:02Z".into()),
|
||||
},
|
||||
SearchDocumentRecord {
|
||||
id: "page_2".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
title: Some("附件页".into()),
|
||||
raw_text: Some("".into()),
|
||||
created_at: Some("2026-04-11T00:00:01Z".into()),
|
||||
updated_at: Some("2026-04-11T00:00:03Z".into()),
|
||||
},
|
||||
],
|
||||
mindmaps: vec![SearchMindmapRecord {
|
||||
document_id: "page_1".into(),
|
||||
data: json!({
|
||||
"root": {
|
||||
"data": { "text": "Rust 脑图节点" },
|
||||
"children": [],
|
||||
}
|
||||
}),
|
||||
}],
|
||||
tables: vec![],
|
||||
table_rows: vec![],
|
||||
assets: vec![SearchAssetRecord {
|
||||
id: "asset_1".into(),
|
||||
document_id: "page_2".into(),
|
||||
asset_type: Some("file".into()),
|
||||
file_name: Some("demo.pdf".into()),
|
||||
mime_type: Some("application/pdf".into()),
|
||||
ocr_text: None,
|
||||
ocr_status: Some("idle".into()),
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result.results.len(), 1);
|
||||
assert_eq!(result.results[0].id, "page_1");
|
||||
assert!(result.results[0].snippet.contains("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>"));
|
||||
assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "mnote-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.38", features = ["derive"] }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||
@@ -0,0 +1,46 @@
|
||||
# mnote-cli
|
||||
|
||||
当前 crate 是 Phase 2 的最小 CLI 协议入口。
|
||||
|
||||
当前目标不是直接替代全部执行链,而是先冻结这五类命令面的命名和 `--json` 输出协议:
|
||||
|
||||
- `page`
|
||||
- `block`
|
||||
- `search`
|
||||
- `sidebar`
|
||||
- `tool`
|
||||
|
||||
当前最小子命令面:
|
||||
|
||||
- `page get`
|
||||
- `page title`
|
||||
- `page save`
|
||||
- `block insert`
|
||||
- `block patch`
|
||||
- `search documents`
|
||||
- `search blocks`
|
||||
- `sidebar dataset`
|
||||
- `tool run`
|
||||
|
||||
最小示例:
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json page get --page-id page_demo --workspace-id ws_demo
|
||||
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json page title --page-id page_demo --workspace-id ws_demo --title "新标题"
|
||||
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json page save --page-id page_demo --workspace-id ws_demo --content-json '[{\"id\":\"block_1\"}]'
|
||||
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json sidebar dataset --workspace-id ws_demo
|
||||
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json tool run --tool-name doc_get --kind query --mode explain-plan --args-json '{"pageId":"page_demo"}'
|
||||
```
|
||||
|
||||
当前输出的重点字段:
|
||||
|
||||
- `domain` / `action`
|
||||
- `context.requestId` / `context.traceId`
|
||||
- `operation.kind`
|
||||
- `operation.name` 或 `operation.toolName`
|
||||
- `operation.executionMode` / `operation.toolsetId` / `operation.effect`
|
||||
- `operation.transport.functionName`
|
||||
- `operation.transport.payloadJson`
|
||||
- `operation.transport.argsJson`
|
||||
|
||||
`tool run` 当前会先校验 Rust 内置 tool registry,并统一暴露 `validateOnly` / `dryRun` / `explain-plan` 等安全模式字段。后续阶段会继续把这些 CLI 面接到真实执行器,但命令命名和 JSON 契约应尽量保持稳定。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,477 @@
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use core_protocol::{InvocationKind, ToolExecutionMode};
|
||||
use mnote_cli::{
|
||||
plan_block_insert, plan_block_patch, plan_mindmap_get, plan_mindmap_op, plan_mindmap_put,
|
||||
plan_page_get, plan_page_save, plan_page_title, plan_search_blocks,
|
||||
plan_search_documents, plan_sidebar_dataset, plan_tool_run, render_plain_output, CliContext,
|
||||
CliError, CliJsonOutput,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "mnote-cli")]
|
||||
#[command(about = "Phase 2 最小 CLI 协议冻结器", long_about = None)]
|
||||
struct Cli {
|
||||
#[command(flatten)]
|
||||
global: GlobalArgs,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
struct GlobalArgs {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
#[arg(long, default_value = "cli_user")]
|
||||
actor_id: String,
|
||||
|
||||
#[arg(long, default_value = "human")]
|
||||
actor_type: String,
|
||||
|
||||
#[arg(long)]
|
||||
session_id: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
reason: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
idempotency_key: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
validate_only: bool,
|
||||
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
Page(PageCommand),
|
||||
Block(BlockCommand),
|
||||
Mindmap(MindmapCommand),
|
||||
Search(SearchCommand),
|
||||
Sidebar(SidebarCommand),
|
||||
Tool(ToolCommand),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct PageCommand {
|
||||
#[command(subcommand)]
|
||||
action: PageAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum PageAction {
|
||||
Get(PageGetArgs),
|
||||
Title(PageTitleArgs),
|
||||
Save(PageSaveArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct PageGetArgs {
|
||||
#[arg(long)]
|
||||
page_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct PageTitleArgs {
|
||||
#[arg(long)]
|
||||
page_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
workspace_id: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct PageSaveArgs {
|
||||
#[arg(long)]
|
||||
page_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
workspace_id: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
revision: Option<u64>,
|
||||
|
||||
#[arg(long)]
|
||||
content_json: String,
|
||||
|
||||
#[arg(long)]
|
||||
conflict_detection_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct BlockCommand {
|
||||
#[command(subcommand)]
|
||||
action: BlockAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum BlockAction {
|
||||
Insert(BlockInsertArgs),
|
||||
Patch(BlockPatchArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct BlockInsertArgs {
|
||||
#[arg(long)]
|
||||
workspace_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
page_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
content: String,
|
||||
|
||||
#[arg(long, default_value = "paragraph")]
|
||||
block_type: String,
|
||||
|
||||
#[arg(long)]
|
||||
parent_block_id: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
prev_block_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct BlockPatchArgs {
|
||||
#[arg(long)]
|
||||
page_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
block_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
workspace_id: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
revision: Option<u64>,
|
||||
|
||||
#[arg(long)]
|
||||
snapshot_json: String,
|
||||
|
||||
#[arg(long)]
|
||||
conflict_detection_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct MindmapCommand {
|
||||
#[command(subcommand)]
|
||||
action: MindmapAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum MindmapAction {
|
||||
Get(MindmapGetArgs),
|
||||
Put(MindmapPutArgs),
|
||||
Op(MindmapOpArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct MindmapGetArgs {
|
||||
#[arg(long)]
|
||||
document_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
mindmap_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct MindmapPutArgs {
|
||||
#[arg(long)]
|
||||
document_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
mindmap_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
workspace_id: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
data_json: String,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
create_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct MindmapOpArgs {
|
||||
#[arg(long)]
|
||||
document_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
mindmap_id: String,
|
||||
|
||||
#[arg(long)]
|
||||
workspace_id: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
ops_json: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SearchCommand {
|
||||
#[command(subcommand)]
|
||||
action: SearchAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SearchAction {
|
||||
Documents(SearchDocumentsArgs),
|
||||
Blocks(SearchBlocksArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SearchDocumentsArgs {
|
||||
#[arg(long)]
|
||||
query: String,
|
||||
|
||||
#[arg(long)]
|
||||
workspace_id: Option<String>,
|
||||
|
||||
#[arg(long, default_value_t = 20)]
|
||||
limit: u32,
|
||||
|
||||
#[arg(long)]
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SearchBlocksArgs {
|
||||
#[arg(long)]
|
||||
query: String,
|
||||
|
||||
#[arg(long)]
|
||||
page_id: Option<String>,
|
||||
|
||||
#[arg(long, default_value_t = 20)]
|
||||
limit: u32,
|
||||
|
||||
#[arg(long)]
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SidebarCommand {
|
||||
#[command(subcommand)]
|
||||
action: SidebarAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SidebarAction {
|
||||
Dataset(SidebarDatasetArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SidebarDatasetArgs {
|
||||
#[arg(long)]
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct ToolCommand {
|
||||
#[command(subcommand)]
|
||||
action: ToolAction,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ToolAction {
|
||||
Run(ToolRunArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct ToolRunArgs {
|
||||
#[arg(long)]
|
||||
tool_name: String,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = ToolKindArg::Command)]
|
||||
kind: ToolKindArg,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = ToolModeArg::Plan)]
|
||||
mode: ToolModeArg,
|
||||
|
||||
#[arg(long)]
|
||||
args_json: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||
enum ToolKindArg {
|
||||
Command,
|
||||
Query,
|
||||
Job,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||
enum ToolModeArg {
|
||||
Plan,
|
||||
Result,
|
||||
ExplainPlan,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
let context = CliContext {
|
||||
actor_id: cli.global.actor_id,
|
||||
actor_type: cli.global.actor_type,
|
||||
session_id: cli.global.session_id,
|
||||
reason: cli.global.reason,
|
||||
idempotency_key: cli.global.idempotency_key,
|
||||
validate_only: cli.global.validate_only,
|
||||
dry_run: cli.global.dry_run,
|
||||
};
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Page(command) => match command.action {
|
||||
PageAction::Get(args) => {
|
||||
plan_page_get(&context, &args.page_id, args.workspace_id.as_deref())
|
||||
}
|
||||
PageAction::Title(args) => plan_page_title(
|
||||
&context,
|
||||
&args.page_id,
|
||||
args.workspace_id.as_deref(),
|
||||
&args.title,
|
||||
),
|
||||
PageAction::Save(args) => plan_page_save(
|
||||
&context,
|
||||
&args.page_id,
|
||||
args.workspace_id.as_deref(),
|
||||
args.revision,
|
||||
&args.content_json,
|
||||
args.conflict_detection_key.as_deref(),
|
||||
),
|
||||
},
|
||||
Commands::Block(command) => match command.action {
|
||||
BlockAction::Insert(args) => plan_block_insert(
|
||||
&context,
|
||||
&args.workspace_id,
|
||||
&args.page_id,
|
||||
&args.content,
|
||||
&args.block_type,
|
||||
args.parent_block_id.as_deref(),
|
||||
args.prev_block_id.as_deref(),
|
||||
),
|
||||
BlockAction::Patch(args) => plan_block_patch(
|
||||
&context,
|
||||
args.workspace_id.as_deref(),
|
||||
&args.page_id,
|
||||
&args.block_id,
|
||||
args.revision,
|
||||
&args.snapshot_json,
|
||||
args.conflict_detection_key.as_deref(),
|
||||
),
|
||||
},
|
||||
Commands::Mindmap(command) => match command.action {
|
||||
MindmapAction::Get(args) => plan_mindmap_get(
|
||||
&context,
|
||||
args.workspace_id.as_deref(),
|
||||
&args.document_id,
|
||||
&args.mindmap_id,
|
||||
),
|
||||
MindmapAction::Put(args) => plan_mindmap_put(
|
||||
&context,
|
||||
args.workspace_id.as_deref(),
|
||||
&args.document_id,
|
||||
&args.mindmap_id,
|
||||
&args.data_json,
|
||||
args.create_only,
|
||||
),
|
||||
MindmapAction::Op(args) => plan_mindmap_op(
|
||||
&context,
|
||||
args.workspace_id.as_deref(),
|
||||
&args.document_id,
|
||||
&args.mindmap_id,
|
||||
&args.ops_json,
|
||||
),
|
||||
},
|
||||
Commands::Search(command) => match command.action {
|
||||
SearchAction::Documents(args) => plan_search_documents(
|
||||
&context,
|
||||
&args.query,
|
||||
args.workspace_id.as_deref(),
|
||||
args.limit,
|
||||
args.cursor.as_deref(),
|
||||
),
|
||||
SearchAction::Blocks(args) => plan_search_blocks(
|
||||
&context,
|
||||
&args.query,
|
||||
args.page_id.as_deref(),
|
||||
args.limit,
|
||||
args.cursor.as_deref(),
|
||||
),
|
||||
},
|
||||
Commands::Sidebar(command) => match command.action {
|
||||
SidebarAction::Dataset(args) => plan_sidebar_dataset(&context, &args.workspace_id),
|
||||
},
|
||||
Commands::Tool(command) => match command.action {
|
||||
ToolAction::Run(args) => plan_tool_run(
|
||||
&context,
|
||||
&args.tool_name,
|
||||
to_invocation_kind(args.kind),
|
||||
to_execution_mode(args.mode),
|
||||
&args.args_json,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(output) => emit_success(&output, cli.global.json),
|
||||
Err(error) => emit_error(&error, cli.global.json),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_success(output: &CliJsonOutput, json_mode: bool) {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(output).expect("CLI JSON 输出必须可序列化")
|
||||
);
|
||||
} else {
|
||||
println!("{}", render_plain_output(output));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_error(error: &CliError, json_mode: bool) -> ! {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"ok": false,
|
||||
"entrypoint": "mnote-cli",
|
||||
"error": {
|
||||
"code": error.code,
|
||||
"message": error.message,
|
||||
}
|
||||
}))
|
||||
.expect("CLI 错误 JSON 输出必须可序列化")
|
||||
);
|
||||
} else {
|
||||
eprintln!("{}: {}", error.code, error.message);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fn to_invocation_kind(kind: ToolKindArg) -> InvocationKind {
|
||||
match kind {
|
||||
ToolKindArg::Command => InvocationKind::Command,
|
||||
ToolKindArg::Query => InvocationKind::Query,
|
||||
ToolKindArg::Job => InvocationKind::Job,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_execution_mode(mode: ToolModeArg) -> ToolExecutionMode {
|
||||
match mode {
|
||||
ToolModeArg::Plan => ToolExecutionMode::Plan,
|
||||
ToolModeArg::Result => ToolExecutionMode::Result,
|
||||
ToolModeArg::ExplainPlan => ToolExecutionMode::ExplainPlan,
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,16 @@ mod tests {
|
||||
use super::*;
|
||||
use core_protocol::{
|
||||
command::{
|
||||
CreatePage, CreateWorkspace, PatchPageBlock, ReplaceMediaAssetStorage,
|
||||
SavePageContent, UpdatePageOptions, UpdatePageStats, UpdatePageTitle,
|
||||
CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
|
||||
DeleteDocumentPage, DuplicateDocumentPage, MoveDocumentPage, PatchPageBlock,
|
||||
PutMindmap, ReplaceMediaAssetStorage, RestoreDocumentPage, SavePageContent,
|
||||
UpdatePageOptions, UpdatePageStats, UpdatePageTitle,
|
||||
},
|
||||
query::{
|
||||
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
||||
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListSidebarDataset,
|
||||
SearchDocuments, SearchRecent,
|
||||
},
|
||||
query::{GetPage, GetPageContent, GetPageMeta, ListSidebarDataset},
|
||||
ActorPayload, CommandEnvelope, QueryEnvelope, SourcePayload, TargetRef,
|
||||
};
|
||||
|
||||
@@ -80,7 +86,8 @@ mod tests {
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
let request =
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "pages:create");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert_eq!(request.deployment_id.as_deref(), Some("dep_1"));
|
||||
@@ -99,7 +106,8 @@ mod tests {
|
||||
},
|
||||
};
|
||||
|
||||
let request = build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "pages:get");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert_eq!(request.request_id, "req_1");
|
||||
@@ -134,12 +142,19 @@ mod tests {
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let pipeline = build_write_pipeline(&demo_context(), &command).expect("pipeline should build");
|
||||
let pipeline =
|
||||
build_write_pipeline(&demo_context(), &command).expect("pipeline should build");
|
||||
assert_eq!(pipeline.command_log.command_name, "create_workspace");
|
||||
assert!(pipeline.command_log.payload_summary.contains("request_id=req_1"));
|
||||
assert!(pipeline
|
||||
.command_log
|
||||
.payload_summary
|
||||
.contains("request_id=req_1"));
|
||||
assert_eq!(pipeline.command_log.trace_id, "trace_1");
|
||||
assert_eq!(pipeline.command_log.request_id, "req_1");
|
||||
assert!(pipeline.domain_event.payload_json.contains("\"trace_id\":\"trace_1\""));
|
||||
assert!(pipeline
|
||||
.domain_event
|
||||
.payload_json
|
||||
.contains("\"trace_id\":\"trace_1\""));
|
||||
assert_eq!(pipeline.result.event_ids, vec!["evt_cmd_2".to_string()]);
|
||||
}
|
||||
|
||||
@@ -173,10 +188,13 @@ mod tests {
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
let request =
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "documents:updateTitle");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"documents.title.update\""));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"documents.title.update\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -212,12 +230,190 @@ mod tests {
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
let request =
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "documents:updateContent");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"documents.save\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_lifecycle_commands_map_to_document_mutations() {
|
||||
let base_actor = ActorPayload {
|
||||
actor_type: "human".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("session_1".into()),
|
||||
};
|
||||
let base_source = SourcePayload {
|
||||
channel: "next-route".into(),
|
||||
client: "wolai-frontend".into(),
|
||||
};
|
||||
|
||||
let create_request = build_write_request(
|
||||
&demo_context(),
|
||||
&CommandEnvelope {
|
||||
name: "documents.create".into(),
|
||||
command_id: "cmd_create_1".into(),
|
||||
idempotency_key: Some("idem_create".into()),
|
||||
actor: base_actor.clone(),
|
||||
source: base_source.clone(),
|
||||
target: Some(TargetRef {
|
||||
workspace_id: None,
|
||||
page_id: Some("page_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: CreateDocumentPage {
|
||||
page_id: "page_1".into(),
|
||||
parent_page_id: Some("parent_1".into()),
|
||||
title: "无标题".into(),
|
||||
workspace_seed_id: "ws_seed_1".into(),
|
||||
},
|
||||
reason: Some("创建页面".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
)
|
||||
.expect("create request should build");
|
||||
assert_eq!(
|
||||
create_request.function_name,
|
||||
"documents:createWithParentReference"
|
||||
);
|
||||
|
||||
let move_request = build_write_request(
|
||||
&demo_context(),
|
||||
&CommandEnvelope {
|
||||
name: "documents.move".into(),
|
||||
command_id: "cmd_move_1".into(),
|
||||
idempotency_key: Some("idem_move".into()),
|
||||
actor: base_actor.clone(),
|
||||
source: base_source.clone(),
|
||||
target: Some(TargetRef {
|
||||
workspace_id: None,
|
||||
page_id: Some("page_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: MoveDocumentPage {
|
||||
page_id: "page_1".into(),
|
||||
parent_page_id: Some("parent_2".into()),
|
||||
sort_order: 3,
|
||||
},
|
||||
reason: Some("移动页面".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
)
|
||||
.expect("move request should build");
|
||||
assert_eq!(move_request.function_name, "documents:move");
|
||||
|
||||
let delete_request = build_write_request(
|
||||
&demo_context(),
|
||||
&CommandEnvelope {
|
||||
name: "documents.delete".into(),
|
||||
command_id: "cmd_delete_1".into(),
|
||||
idempotency_key: Some("idem_delete".into()),
|
||||
actor: base_actor.clone(),
|
||||
source: base_source.clone(),
|
||||
target: Some(TargetRef {
|
||||
workspace_id: None,
|
||||
page_id: Some("page_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: DeleteDocumentPage {
|
||||
page_id: "page_1".into(),
|
||||
},
|
||||
reason: Some("删除页面".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
)
|
||||
.expect("delete request should build");
|
||||
assert_eq!(delete_request.function_name, "documents:softDelete");
|
||||
|
||||
let restore_request = build_write_request(
|
||||
&demo_context(),
|
||||
&CommandEnvelope {
|
||||
name: "documents.restore".into(),
|
||||
command_id: "cmd_restore_1".into(),
|
||||
idempotency_key: Some("idem_restore".into()),
|
||||
actor: base_actor.clone(),
|
||||
source: base_source.clone(),
|
||||
target: Some(TargetRef {
|
||||
workspace_id: None,
|
||||
page_id: Some("page_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: RestoreDocumentPage {
|
||||
page_id: "page_1".into(),
|
||||
},
|
||||
reason: Some("恢复页面".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
)
|
||||
.expect("restore request should build");
|
||||
assert_eq!(restore_request.function_name, "documents:restore");
|
||||
|
||||
let duplicate_request = build_write_request(
|
||||
&demo_context(),
|
||||
&CommandEnvelope {
|
||||
name: "documents.duplicate".into(),
|
||||
command_id: "cmd_duplicate_1".into(),
|
||||
idempotency_key: Some("idem_duplicate".into()),
|
||||
actor: base_actor.clone(),
|
||||
source: base_source.clone(),
|
||||
target: Some(TargetRef {
|
||||
workspace_id: None,
|
||||
page_id: Some("page_copy_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: DuplicateDocumentPage {
|
||||
source_page_id: "page_1".into(),
|
||||
new_page_id: "page_copy_1".into(),
|
||||
title: Some("无标题 副本".into()),
|
||||
},
|
||||
reason: Some("复制页面".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
)
|
||||
.expect("duplicate request should build");
|
||||
assert_eq!(
|
||||
duplicate_request.function_name,
|
||||
"documents:duplicateWithMindmaps"
|
||||
);
|
||||
|
||||
let copy_tree_request = build_write_request(
|
||||
&demo_context(),
|
||||
&CommandEnvelope {
|
||||
name: "documents.copy_tree".into(),
|
||||
command_id: "cmd_copy_tree_1".into(),
|
||||
idempotency_key: Some("idem_copy_tree".into()),
|
||||
actor: base_actor,
|
||||
source: base_source,
|
||||
target: Some(TargetRef {
|
||||
workspace_id: None,
|
||||
page_id: Some("target_parent_1".into()),
|
||||
block_id: None,
|
||||
}),
|
||||
payload: CopyTreeDocumentPages {
|
||||
items_json: "[{\"documentId\":\"page_1\",\"recursive\":true}]".into(),
|
||||
target_parent_page_id: Some("target_parent_1".into()),
|
||||
},
|
||||
reason: Some("复制页面树".into()),
|
||||
refs: vec![],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
},
|
||||
)
|
||||
.expect("copy tree request should build");
|
||||
assert_eq!(copy_tree_request.function_name, "documents:copyTree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_patch_command_maps_to_documents_update_content() {
|
||||
let command = CommandEnvelope {
|
||||
@@ -252,12 +448,29 @@ mod tests {
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
let request =
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "documents:updateContent");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"blocks.patch\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_get_query_maps_to_blocks_get_by_id() {
|
||||
let query = QueryEnvelope {
|
||||
name: "blocks.get".into(),
|
||||
payload: GetBlock {
|
||||
block_id: "block_1".into(),
|
||||
},
|
||||
};
|
||||
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "blocks:getById");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"blocks.get\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_stats_command_maps_to_documents_update_stats() {
|
||||
let command = CommandEnvelope {
|
||||
@@ -292,10 +505,13 @@ mod tests {
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
let request =
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "documents:updateStats");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"documents.stats.update\""));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"documents.stats.update\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -340,10 +556,13 @@ mod tests {
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
let request =
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "documents:updateOptions");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"documents.options.update\""));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"documents.options.update\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -376,10 +595,16 @@ mod tests {
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let request = build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "mediaAssets:replaceStorageFromUpload");
|
||||
let request =
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(
|
||||
request.function_name,
|
||||
"mediaAssets:replaceStorageFromUpload"
|
||||
);
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"media.assets.replace_storage\""));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"media.assets.replace_storage\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -392,10 +617,13 @@ mod tests {
|
||||
},
|
||||
};
|
||||
|
||||
let request = build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "documents:getMeta");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"documents.meta.get\""));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"documents.meta.get\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -408,10 +636,31 @@ mod tests {
|
||||
},
|
||||
};
|
||||
|
||||
let request = build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "documents:getContent");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"documents.content.get\""));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"documents.content.get\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_get_query_maps_to_mindmaps_get() {
|
||||
let query = QueryEnvelope {
|
||||
name: "mindmaps.get".into(),
|
||||
payload: GetMindmap {
|
||||
document_id: "page_1".into(),
|
||||
mindmap_id: "mind_1".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
},
|
||||
};
|
||||
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "mindmaps:get");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"mindmaps.get\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -423,9 +672,191 @@ mod tests {
|
||||
},
|
||||
};
|
||||
|
||||
let request = build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "sidebar:datasetList");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"sidebar.dataset.list\""));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"sidebar.dataset.list\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_documents_query_maps_to_search_documents() {
|
||||
let query = QueryEnvelope {
|
||||
name: "search.documents".into(),
|
||||
payload: SearchDocuments {
|
||||
query: "rust".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
page_id: Some("page_1".into()),
|
||||
pagination: core_protocol::query::Pagination {
|
||||
limit: 20,
|
||||
cursor: None,
|
||||
},
|
||||
title_only: false,
|
||||
exact: false,
|
||||
include_ocr: true,
|
||||
time_range: "any".into(),
|
||||
time_field: "updated".into(),
|
||||
custom_range_from: None,
|
||||
custom_range_to: None,
|
||||
},
|
||||
};
|
||||
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "search:documents");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"search.documents\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_recent_query_maps_to_search_recent() {
|
||||
let query = QueryEnvelope {
|
||||
name: "search.recent".into(),
|
||||
payload: SearchRecent {
|
||||
workspace_id: "ws_1".into(),
|
||||
pagination: core_protocol::query::Pagination {
|
||||
limit: 10,
|
||||
cursor: None,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "search:recent");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"search.recent\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_request_query_maps_to_bridge_logs_request() {
|
||||
let query = QueryEnvelope {
|
||||
name: "bridge.request.get".into(),
|
||||
payload: GetBridgeRequest {
|
||||
workspace_id: "ws_1".into(),
|
||||
request_id: "req_1".into(),
|
||||
command_id: Some("cmd_1".into()),
|
||||
},
|
||||
};
|
||||
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "bridgeLogs:listByRequest");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"bridge.request.get\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_trace_query_maps_to_bridge_logs_trace() {
|
||||
let query = QueryEnvelope {
|
||||
name: "bridge.trace.get".into(),
|
||||
payload: GetBridgeTrace {
|
||||
workspace_id: "ws_1".into(),
|
||||
trace_id: "trace_1".into(),
|
||||
command_id: None,
|
||||
},
|
||||
};
|
||||
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "bridgeLogs:listByTrace");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"bridge.trace.get\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_command_query_maps_to_bridge_logs_command() {
|
||||
let query = QueryEnvelope {
|
||||
name: "bridge.command.get".into(),
|
||||
payload: GetBridgeCommand {
|
||||
workspace_id: "ws_1".into(),
|
||||
command_id: "cmd_1".into(),
|
||||
},
|
||||
};
|
||||
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "bridgeLogs:listByCommand");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"bridge.command.get\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_workspace_overview_query_maps_to_bridge_logs_workspace_overview() {
|
||||
let query = QueryEnvelope {
|
||||
name: "bridge.workspace.overview".into(),
|
||||
payload: ListBridgeWorkspaceOverview {
|
||||
workspace_id: "ws_1".into(),
|
||||
pagination: core_protocol::query::Pagination {
|
||||
limit: 20,
|
||||
cursor: Some("cursor_1".into()),
|
||||
},
|
||||
command_status: Some("failed".into()),
|
||||
event_status: Some("failed".into()),
|
||||
target_page_id: Some("page_1".into()),
|
||||
target_block_id: None,
|
||||
aggregate_type: Some("page".into()),
|
||||
aggregate_id: Some("page_1".into()),
|
||||
},
|
||||
};
|
||||
|
||||
let request =
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "bridgeLogs:listWorkspaceOverview");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"bridge.workspace.overview\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_put_command_maps_to_mindmaps_put() {
|
||||
let command = CommandEnvelope {
|
||||
name: "mindmaps.put".into(),
|
||||
command_id: "cmd_mindmap_put_1".into(),
|
||||
idempotency_key: Some("idem_mindmap_put".into()),
|
||||
actor: ActorPayload {
|
||||
actor_type: "human".into(),
|
||||
actor_id: "user_1".into(),
|
||||
session_id: Some("session_1".into()),
|
||||
},
|
||||
source: SourcePayload {
|
||||
channel: "cli".into(),
|
||||
client: "mnote-cli".into(),
|
||||
},
|
||||
target: Some(TargetRef {
|
||||
workspace_id: Some("ws_1".into()),
|
||||
page_id: Some("page_1".into()),
|
||||
block_id: Some("mind_1".into()),
|
||||
}),
|
||||
payload: PutMindmap {
|
||||
document_id: "page_1".into(),
|
||||
mindmap_id: "mind_1".into(),
|
||||
data_json: r#"{"data":{"text":"中心主题"},"children":[]}"#.into(),
|
||||
create_only: false,
|
||||
},
|
||||
reason: Some("保存导图".into()),
|
||||
refs: vec!["task-032".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let request =
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "mindmaps:put");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request.payload_json.contains("\"name\":\"mindmaps.put\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,24 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
||||
match command_name {
|
||||
"create_workspace" => "workspaces:create",
|
||||
"create_page" => "pages:create",
|
||||
"documents.create" => "documents:createWithParentReference",
|
||||
"documents.move" => "documents:move",
|
||||
"documents.delete" => "documents:softDelete",
|
||||
"documents.restore" => "documents:restore",
|
||||
"documents.duplicate" => "documents:duplicateWithMindmaps",
|
||||
"documents.copy_tree" => "documents:copyTree",
|
||||
"blocks.patch" => "documents:updateContent",
|
||||
"documents.save" => "documents:updateContent",
|
||||
"documents.title.update" => "documents:updateTitle",
|
||||
"documents.stats.update" => "documents:updateStats",
|
||||
"documents.options.update" => "documents:updateOptions",
|
||||
"mindmaps.put" => "mindmaps:put",
|
||||
"insert_block" => "blocks:insert",
|
||||
"update_block" => "blocks:update",
|
||||
"move_block" => "blocks:move",
|
||||
"delete_block" => "blocks:delete",
|
||||
"blocks.move" => "blocks:move",
|
||||
"blocks.embed" => "blocks:insert",
|
||||
"media.assets.replace_storage" => "mediaAssets:replaceStorageFromUpload",
|
||||
_ => "commands:unknown",
|
||||
}
|
||||
@@ -45,9 +54,17 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
||||
|
||||
pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
|
||||
match query_name {
|
||||
"bridge.request.get" => "bridgeLogs:listByRequest",
|
||||
"bridge.trace.get" => "bridgeLogs:listByTrace",
|
||||
"bridge.command.get" => "bridgeLogs:listByCommand",
|
||||
"bridge.workspace.overview" => "bridgeLogs:listWorkspaceOverview",
|
||||
"documents.meta.get" => "documents:getMeta",
|
||||
"documents.content.get" => "documents:getContent",
|
||||
"mindmaps.get" => "mindmaps:get",
|
||||
"blocks.get" => "blocks:getById",
|
||||
"sidebar.dataset.list" => "sidebar:datasetList",
|
||||
"search.documents" => "search:documents",
|
||||
"search.recent" => "search:recent",
|
||||
"get_page" => "pages:get",
|
||||
"list_page_blocks" => "blocks:list_by_page",
|
||||
"search_pages" => "search:pages",
|
||||
|
||||
@@ -21,6 +21,13 @@ impl BridgeError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transport(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Transport,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type BridgeResult<T> = Result<T, BridgeError>;
|
||||
|
||||
@@ -21,15 +21,6 @@ pub fn validate_command_envelope<T>(
|
||||
if context.actor_id.trim().is_empty() {
|
||||
return Err(BridgeError::validation("actor_id 不能为空"));
|
||||
}
|
||||
if context
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(BridgeError::validation("workspace_id 不能为空"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user