feat: purge legacy agent hosts and land vault Chrome extension path
Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault extension + extension token route, pre-release purge design, and soft-retire legacy smokes for the small-group production cut.
This commit is contained in:
Generated
+3
@@ -2424,6 +2424,8 @@ dependencies = [
|
||||
"control-plane",
|
||||
"core-protocol",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
"leptos",
|
||||
@@ -2434,6 +2436,7 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::store::{
|
||||
self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials,
|
||||
load_cipher_book, login_session_is_fresh, now_rfc3339, project_item_l0_with_cipher,
|
||||
project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session,
|
||||
resolve_record_secret_field, resolve_secret_with_cipher_book, VaultCredentialRecord,
|
||||
VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
|
||||
resolve_login_session, resolve_record_secret_field, resolve_secret_with_cipher_book,
|
||||
VaultCredentialRecord, VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
|
||||
};
|
||||
use crate::token::DEFAULT_ACTOR;
|
||||
use serde_json::{json, Value};
|
||||
@@ -376,6 +376,7 @@ pub fn put_ai_vault_session(
|
||||
expires_at: Some(expires.clone()),
|
||||
last_login_at: Some(now),
|
||||
source: Some(source.trim().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let record = put_login_session(&root, credential_id, session)?;
|
||||
let _ = append_vault_audit(
|
||||
@@ -432,8 +433,11 @@ pub fn login_ai_vault_credential(
|
||||
)
|
||||
});
|
||||
|
||||
// Prefer per-account session files (12-3); frontmatter is fallback inside resolve.
|
||||
let resolved_session = resolve_login_session(&root, &record, None).ok().flatten();
|
||||
|
||||
if playbook.is_human_required() && !force_refresh {
|
||||
if let Some(sess) = record.login_session.as_ref() {
|
||||
if let Some(sess) = resolved_session.as_ref() {
|
||||
if login_session_is_fresh(sess) {
|
||||
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
||||
let _ = append_vault_audit(
|
||||
@@ -452,6 +456,7 @@ pub fn login_ai_vault_credential(
|
||||
"mode": "session",
|
||||
"cookieHeader": cookie,
|
||||
"expiresAt": sess.expires_at,
|
||||
"accountId": sess.account_id,
|
||||
"loginPlaybook": playbook,
|
||||
"transcriptHint": "已复用登录态",
|
||||
"note": "playbook=human_required;有可用 session 直接复用",
|
||||
@@ -479,7 +484,7 @@ pub fn login_ai_vault_credential(
|
||||
}
|
||||
|
||||
if !force_refresh {
|
||||
if let Some(sess) = record.login_session.as_ref() {
|
||||
if let Some(sess) = resolved_session.as_ref() {
|
||||
if login_session_is_fresh(sess) {
|
||||
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
||||
let _ = append_vault_audit(
|
||||
@@ -499,6 +504,7 @@ pub fn login_ai_vault_credential(
|
||||
"cookieHeader": cookie,
|
||||
"expiresAt": sess.expires_at,
|
||||
"source": sess.source,
|
||||
"accountId": sess.account_id,
|
||||
"loginPlaybook": {
|
||||
"mode": playbook.mode,
|
||||
"preferredAccount": playbook.preferred_account,
|
||||
@@ -736,6 +742,7 @@ pub fn login_ai_vault_credential(
|
||||
expires_at: Some(expires.clone()),
|
||||
last_login_at: Some(now),
|
||||
source: Some("api".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = put_login_session(&root, credential_id, session)?;
|
||||
let _ = append_vault_audit(
|
||||
|
||||
@@ -168,8 +168,32 @@ pub struct VaultLoginPlaybook {
|
||||
pub human_note: Option<String>,
|
||||
}
|
||||
|
||||
/// Structured cookie from chrome.cookies / browser capture (secret values).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VaultSessionCookie {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub domain: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub secure: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub http_only: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub same_site: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expiration_date: Option<f64>,
|
||||
}
|
||||
|
||||
/// Captured browser/API session for multi-agent reuse (cookie header is secret).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
///
|
||||
/// **Disk truth (12-3):** `sessions/{credId}/{accountId}.json`.
|
||||
/// Frontmatter `loginSession` is read-only fallback for pre-12-3 data.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VaultLoginSession {
|
||||
/// Full Cookie request header value (secret).
|
||||
@@ -179,11 +203,44 @@ pub struct VaultLoginSession {
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: Option<String>,
|
||||
/// api | browser | human_bridge
|
||||
/// chrome_extension | api | browser | human_bridge | login_api
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub cookies: Vec<VaultSessionCookie>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub revision: Option<u64>,
|
||||
}
|
||||
|
||||
/// On-disk session file schema (`mnote.vault.session.v1`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VaultSessionFile {
|
||||
pub schema: String,
|
||||
pub credential_id: String,
|
||||
pub account_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cookie_header: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub cookies: Vec<VaultSessionCookie>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: Option<String>,
|
||||
pub source: String,
|
||||
pub updated_at: String,
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
pub const VAULT_SESSION_SCHEMA: &str = "mnote.vault.session.v1";
|
||||
pub const SESSION_ACCOUNT_PRIMARY: &str = "primary";
|
||||
|
||||
impl VaultLoginPlaybook {
|
||||
pub fn default_for_credential(email: Option<&str>, username: Option<&str>) -> Self {
|
||||
let preferred = if email.map(str::trim).filter(|s| !s.is_empty()).is_some() {
|
||||
@@ -411,9 +468,11 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
|
||||
for relative in [
|
||||
"",
|
||||
"entries",
|
||||
"sessions",
|
||||
"attachments",
|
||||
"trash",
|
||||
"trash/entries",
|
||||
"trash/sessions",
|
||||
"trash/attachments",
|
||||
] {
|
||||
let path = if relative.is_empty() {
|
||||
@@ -431,6 +490,359 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// Normalize account slot for session file name: empty / "primary" → `primary`.
|
||||
pub fn normalize_session_account_id(account_id: Option<&str>) -> String {
|
||||
let raw = account_id.map(str::trim).unwrap_or("");
|
||||
if raw.is_empty() || raw.eq_ignore_ascii_case(SESSION_ACCOUNT_PRIMARY) {
|
||||
SESSION_ACCOUNT_PRIMARY.to_string()
|
||||
} else {
|
||||
// reject path traversal
|
||||
if raw.contains('/') || raw.contains('\\') || raw.contains("..") {
|
||||
return SESSION_ACCOUNT_PRIMARY.to_string();
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// `sessions/{credId}/` under vault root.
|
||||
pub fn session_dir_rel(credential_id: &str) -> String {
|
||||
format!("sessions/{credential_id}")
|
||||
}
|
||||
|
||||
/// `sessions/{credId}/{accountId}.json`
|
||||
pub fn session_file_rel(credential_id: &str, account_id: Option<&str>) -> String {
|
||||
let acc = normalize_session_account_id(account_id);
|
||||
format!("sessions/{credential_id}/{acc}.json")
|
||||
}
|
||||
|
||||
fn session_dir_abs(vault: &Path, credential_id: &str) -> PathBuf {
|
||||
vault.join("sessions").join(credential_id)
|
||||
}
|
||||
|
||||
fn session_file_abs(vault: &Path, credential_id: &str, account_id: Option<&str>) -> PathBuf {
|
||||
let acc = normalize_session_account_id(account_id);
|
||||
session_dir_abs(vault, credential_id).join(format!("{acc}.json"))
|
||||
}
|
||||
|
||||
/// Build Cookie header from structured cookies (preferred when both present).
|
||||
pub fn cookie_header_from_cookies(cookies: &[VaultSessionCookie]) -> String {
|
||||
cookies
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let name = c.name.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let value = c.value.as_deref().unwrap_or("").trim();
|
||||
Some(format!("{name}={value}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn session_file_to_login(file: &VaultSessionFile) -> VaultLoginSession {
|
||||
VaultLoginSession {
|
||||
cookie_header: file.cookie_header.clone(),
|
||||
expires_at: file.expires_at.clone(),
|
||||
last_login_at: file.last_login_at.clone(),
|
||||
source: Some(file.source.clone()),
|
||||
origin: file.origin.clone(),
|
||||
account_id: Some(file.account_id.clone()),
|
||||
cookies: file.cookies.clone(),
|
||||
revision: Some(file.revision),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_session_file_at(path: &Path) -> Result<Option<VaultSessionFile>, WebError> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = fs::read_to_string(path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_read_failed",
|
||||
format!("无法读取登录态文件: {error}"),
|
||||
)
|
||||
})?;
|
||||
let file: VaultSessionFile = serde_json::from_str(&raw).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_parse_failed",
|
||||
format!("登录态文件 JSON 无效: {error}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
/// Read one session file (no frontmatter fallback).
|
||||
pub fn read_session_file(
|
||||
workspace_root: &Path,
|
||||
credential_id: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<Option<VaultLoginSession>, WebError> {
|
||||
let vault = ensure_vault_directories(workspace_root)?;
|
||||
let path = session_file_abs(&vault, credential_id, account_id);
|
||||
Ok(read_session_file_at(&path)?.map(|f| session_file_to_login(&f)))
|
||||
}
|
||||
|
||||
/// List accountIds that have a session file for this credential.
|
||||
pub fn list_session_account_ids(
|
||||
workspace_root: &Path,
|
||||
credential_id: &str,
|
||||
) -> Result<Vec<String>, WebError> {
|
||||
let vault = ensure_vault_directories(workspace_root)?;
|
||||
let dir = session_dir_abs(&vault, credential_id);
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
let entries = fs::read_dir(&dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_list_failed",
|
||||
format!("无法列出登录态目录: {error}"),
|
||||
)
|
||||
})?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if let Some(stem) = name.strip_suffix(".json") {
|
||||
if !stem.is_empty() {
|
||||
ids.push(stem.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ids.sort();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Resolve session for login: explicit account → primary → accounts[0] → frontmatter.
|
||||
pub fn resolve_login_session(
|
||||
workspace_root: &Path,
|
||||
record: &VaultCredentialRecord,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<Option<VaultLoginSession>, WebError> {
|
||||
let vault = ensure_vault_directories(workspace_root)?;
|
||||
if let Some(aid) = account_id.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if let Some(sess) = read_session_file_at(&session_file_abs(&vault, &record.id, Some(aid)))?
|
||||
.map(|f| session_file_to_login(&f))
|
||||
{
|
||||
return Ok(Some(sess));
|
||||
}
|
||||
// explicit account miss: do not fall through to other accounts
|
||||
return Ok(record
|
||||
.login_session
|
||||
.clone()
|
||||
.filter(|s| s.cookie_header.as_ref().map(|c| !c.trim().is_empty()).unwrap_or(false)));
|
||||
}
|
||||
|
||||
// primary file
|
||||
if let Some(sess) = read_session_file_at(&session_file_abs(
|
||||
&vault,
|
||||
&record.id,
|
||||
Some(SESSION_ACCOUNT_PRIMARY),
|
||||
))?
|
||||
.map(|f| session_file_to_login(&f))
|
||||
{
|
||||
return Ok(Some(sess));
|
||||
}
|
||||
|
||||
// first multi-account slot
|
||||
if let Some(first) = record.accounts.first() {
|
||||
let aid = first.id.trim();
|
||||
if !aid.is_empty() {
|
||||
if let Some(sess) =
|
||||
read_session_file_at(&session_file_abs(&vault, &record.id, Some(aid)))?
|
||||
.map(|f| session_file_to_login(&f))
|
||||
{
|
||||
return Ok(Some(sess));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// any other session file
|
||||
for aid in list_session_account_ids(workspace_root, &record.id)? {
|
||||
if aid == SESSION_ACCOUNT_PRIMARY {
|
||||
continue;
|
||||
}
|
||||
if let Some(sess) =
|
||||
read_session_file_at(&session_file_abs(&vault, &record.id, Some(&aid)))?
|
||||
.map(|f| session_file_to_login(&f))
|
||||
{
|
||||
return Ok(Some(sess));
|
||||
}
|
||||
}
|
||||
|
||||
// frontmatter fallback (legacy)
|
||||
Ok(record.login_session.clone().filter(|s| {
|
||||
s.cookie_header
|
||||
.as_ref()
|
||||
.map(|c| !c.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether credential has any usable session (files or frontmatter).
|
||||
pub fn credential_has_login_session(
|
||||
workspace_root: &Path,
|
||||
record: &VaultCredentialRecord,
|
||||
) -> bool {
|
||||
if let Ok(ids) = list_session_account_ids(workspace_root, &record.id) {
|
||||
if !ids.is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
record
|
||||
.login_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.cookie_header.as_ref())
|
||||
.map(|c| !c.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn best_session_expires_at(
|
||||
workspace_root: &Path,
|
||||
record: &VaultCredentialRecord,
|
||||
) -> Option<String> {
|
||||
if let Ok(Some(sess)) = resolve_login_session(workspace_root, record, None) {
|
||||
return sess.expires_at;
|
||||
}
|
||||
record
|
||||
.login_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.expires_at.clone())
|
||||
}
|
||||
|
||||
fn remove_sessions_dir(vault: &Path, credential_id: &str) {
|
||||
let dir = session_dir_abs(vault, credential_id);
|
||||
if dir.exists() {
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_sessions_dir(vault: &Path, credential_id: &str, to_trash: bool) {
|
||||
let active = session_dir_abs(vault, credential_id);
|
||||
let trash = vault.join("trash").join("sessions").join(credential_id);
|
||||
if to_trash {
|
||||
if active.exists() {
|
||||
let _ = fs::create_dir_all(trash.parent().unwrap_or(vault));
|
||||
let _ = move_path(&active, &trash);
|
||||
}
|
||||
} else if trash.exists() {
|
||||
let _ = fs::create_dir_all(active.parent().unwrap_or(vault));
|
||||
let _ = move_path(&trash, &active);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `sessions/{src_id}/**` → `sessions/{dst_id}/**` (share-to-ai).
|
||||
pub fn copy_session_files(
|
||||
source_workspace: &Path,
|
||||
target_workspace: &Path,
|
||||
source_id: &str,
|
||||
target_id: &str,
|
||||
) -> Result<usize, WebError> {
|
||||
let src_vault = ensure_vault_directories(source_workspace)?;
|
||||
let dst_vault = ensure_vault_directories(target_workspace)?;
|
||||
let src_dir = session_dir_abs(&src_vault, source_id);
|
||||
if !src_dir.exists() {
|
||||
// migrate frontmatter-only session into target primary.json if present
|
||||
return Ok(0);
|
||||
}
|
||||
let dst_dir = session_dir_abs(&dst_vault, target_id);
|
||||
fs::create_dir_all(&dst_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_copy_failed",
|
||||
format!("无法创建目标登录态目录: {error}"),
|
||||
)
|
||||
})?;
|
||||
let mut count = 0usize;
|
||||
let entries = fs::read_dir(&src_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_copy_failed",
|
||||
format!("无法读取源登录态目录: {error}"),
|
||||
)
|
||||
})?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let dst_path = dst_dir.join(&name);
|
||||
// rewrite credentialId inside file
|
||||
if let Ok(Some(mut file)) = read_session_file_at(&path) {
|
||||
file.credential_id = target_id.to_string();
|
||||
file.updated_at = now_rfc3339();
|
||||
let json = serde_json::to_string_pretty(&file).map_err(|e| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_copy_failed",
|
||||
format!("序列化登录态失败: {e}"),
|
||||
)
|
||||
})?;
|
||||
atomic_write_string(&dst_path, &json)?;
|
||||
count += 1;
|
||||
} else {
|
||||
let _ = fs::copy(&path, &dst_path);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn atomic_write_string(path: &Path, content: &str) -> Result<(), WebError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_write_failed",
|
||||
format!("无法创建目录: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
{
|
||||
let mut f = fs::File::create(&tmp).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_write_failed",
|
||||
format!("无法创建临时文件: {error}"),
|
||||
)
|
||||
})?;
|
||||
f.write_all(content.as_bytes()).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_write_failed",
|
||||
format!("无法写入登录态: {error}"),
|
||||
)
|
||||
})?;
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_write_failed",
|
||||
format!("无法落盘登录态: {error}"),
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch_index_session_meta(
|
||||
workspace_root: &Path,
|
||||
credential_id: &str,
|
||||
has_session: bool,
|
||||
expires_at: Option<String>,
|
||||
) -> Result<(), WebError> {
|
||||
let vault = ensure_vault_directories(workspace_root)?;
|
||||
let mut index = load_index(&vault)?;
|
||||
if let Some(entry) = index.entries.get_mut(credential_id) {
|
||||
entry.has_login_session = has_session;
|
||||
entry.session_expires_at = expires_at;
|
||||
index.revision = index.revision.saturating_add(1);
|
||||
index.updated_at = now_rfc3339();
|
||||
write_index_atomic(&vault, &index)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn now_rfc3339() -> String {
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
@@ -1165,6 +1577,8 @@ fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry {
|
||||
revision: record.revision,
|
||||
shared_to_ai: record.shared_to_ai.clone(),
|
||||
shared_from: record.shared_from.clone(),
|
||||
// Index is written without workspace path for file scan; use frontmatter
|
||||
// flag here. List/get projections re-check session files when workspace known.
|
||||
has_login_session: record
|
||||
.login_session
|
||||
.as_ref()
|
||||
@@ -1183,6 +1597,23 @@ fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh has_login_session / session_expires_at from disk session files.
|
||||
pub fn enrich_index_entry_session(
|
||||
workspace_root: &Path,
|
||||
entry: &mut VaultIndexEntry,
|
||||
) {
|
||||
if let Ok(ids) = list_session_account_ids(workspace_root, &entry.id) {
|
||||
if !ids.is_empty() {
|
||||
entry.has_login_session = true;
|
||||
if let Ok(record) = get_credential(workspace_root, &entry.id) {
|
||||
entry.session_expires_at = best_session_expires_at(workspace_root, &record);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// keep frontmatter-derived flags already on entry
|
||||
}
|
||||
|
||||
/// Normalize logical folder path: trim, collapse `//`, strip leading/trailing `/`.
|
||||
pub fn normalize_folder_path(raw: Option<&str>) -> Option<String> {
|
||||
let Some(s) = raw.map(str::trim).filter(|v| !v.is_empty()) else {
|
||||
@@ -2178,6 +2609,7 @@ fn parse_login_session(map: &BTreeMap<String, Value>) -> Option<VaultLoginSessio
|
||||
expires_at,
|
||||
last_login_at,
|
||||
source,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2366,6 +2798,9 @@ pub fn list_credentials(
|
||||
.filter(|entry| entry.status == status)
|
||||
.cloned()
|
||||
.collect();
|
||||
for entry in &mut items {
|
||||
enrich_index_entry_session(workspace_root, entry);
|
||||
}
|
||||
items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at).then(a.title.cmp(&b.title)));
|
||||
Ok((index, items))
|
||||
}
|
||||
@@ -2590,6 +3025,9 @@ pub fn sync_shared_ai_copy(
|
||||
});
|
||||
write_record_with_index(target_workspace, &target)?;
|
||||
|
||||
// Keep AI copy sessions in sync with source session files.
|
||||
let _ = copy_session_files(source_workspace, target_workspace, &source.id, target_id)?;
|
||||
|
||||
let mut source_updated = source.clone();
|
||||
source_updated.shared_to_ai = Some(VaultShareToAi {
|
||||
target_actor_id: target_actor_id.to_string(),
|
||||
@@ -2707,6 +3145,27 @@ pub fn share_credential_to_workspace(
|
||||
item.login_session = None;
|
||||
write_record_with_index(target_workspace, &item)?;
|
||||
|
||||
// Copy per-account session files so Agent login can reuse cookies.
|
||||
let _ = copy_session_files(source_workspace, target_workspace, id, &item.id)?;
|
||||
// Legacy frontmatter-only session → write primary on target when no files copied.
|
||||
if !credential_has_login_session(target_workspace, &item) {
|
||||
if let Some(sess) = source.login_session.clone().filter(|s| {
|
||||
s.cookie_header
|
||||
.as_ref()
|
||||
.map(|c| !c.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
let _ = put_login_session(target_workspace, &item.id, sess);
|
||||
}
|
||||
}
|
||||
// Refresh item with resolved session for callers.
|
||||
if let Ok(mut refreshed) = get_credential(target_workspace, &item.id) {
|
||||
if let Ok(Some(sess)) = resolve_login_session(target_workspace, &refreshed, None) {
|
||||
refreshed.login_session = Some(sess);
|
||||
}
|
||||
item = refreshed;
|
||||
}
|
||||
|
||||
let mut source_updated = source;
|
||||
source_updated.shared_to_ai = Some(VaultShareToAi {
|
||||
target_actor_id: target_actor_id.to_string(),
|
||||
@@ -3224,6 +3683,7 @@ pub fn soft_delete_credential(
|
||||
if active_att.exists() {
|
||||
let _ = move_path(&active_att, &trash_att);
|
||||
}
|
||||
move_sessions_dir(&vault, id, true);
|
||||
for att in &mut record.attachments {
|
||||
if att.relative_path.starts_with("attachments/") {
|
||||
att.relative_path = format!("trash/{}", att.relative_path);
|
||||
@@ -3262,6 +3722,7 @@ pub fn restore_credential(
|
||||
if trash_att.exists() {
|
||||
let _ = move_path(&trash_att, &active_att);
|
||||
}
|
||||
move_sessions_dir(&vault, id, false);
|
||||
for att in &mut record.attachments {
|
||||
if let Some(rest) = att.relative_path.strip_prefix("trash/") {
|
||||
att.relative_path = rest.to_string();
|
||||
@@ -3310,6 +3771,12 @@ pub fn purge_credential(workspace_root: &Path, id: &str) -> Result<(), WebError>
|
||||
if trash_att.exists() {
|
||||
let _ = fs::remove_dir_all(&trash_att);
|
||||
}
|
||||
// purge active + trash session dirs
|
||||
remove_sessions_dir(&vault, id);
|
||||
let trash_sess = vault.join("trash").join("sessions").join(id);
|
||||
if trash_sess.exists() {
|
||||
let _ = fs::remove_dir_all(&trash_sess);
|
||||
}
|
||||
index.entries.remove(id);
|
||||
index.revision = index.revision.saturating_add(1);
|
||||
index.updated_at = now_rfc3339();
|
||||
@@ -3444,21 +3911,41 @@ pub fn project_item_l0_with_cipher(
|
||||
"isSharedToAi": record.shared_to_ai.is_some(),
|
||||
"isAiSharedCopy": record.shared_from.is_some()
|
||||
|| record.tags.iter().any(|t| t == "ai-shared"),
|
||||
"hasLoginSession": record
|
||||
.login_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.cookie_header.as_ref())
|
||||
.map(|c| !c.trim().is_empty())
|
||||
.unwrap_or(false),
|
||||
"sessionExpiresAt": record
|
||||
.login_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.expires_at.clone()),
|
||||
"lastLoginAt": record
|
||||
.login_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.last_login_at.clone()),
|
||||
"hasLoginSession": false,
|
||||
"sessionExpiresAt": Value::Null,
|
||||
"lastLoginAt": Value::Null,
|
||||
});
|
||||
// Prefer session files when workspace known; else frontmatter.
|
||||
let resolved_sess = workspace_root
|
||||
.and_then(|root| resolve_login_session(root, record, None).ok().flatten())
|
||||
.or_else(|| record.login_session.clone());
|
||||
if let Some(sess) = resolved_sess.as_ref() {
|
||||
let has = sess
|
||||
.cookie_header
|
||||
.as_ref()
|
||||
.map(|c| !c.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
item["hasLoginSession"] = json!(has);
|
||||
item["sessionExpiresAt"] = json!(sess.expires_at);
|
||||
item["lastLoginAt"] = json!(sess.last_login_at);
|
||||
// Never project cookieHeader / cookies[].value in L0.
|
||||
item["loginSession"] = json!({
|
||||
"hasCookie": has,
|
||||
"expiresAt": sess.expires_at,
|
||||
"lastLoginAt": sess.last_login_at,
|
||||
"source": sess.source,
|
||||
"accountId": sess.account_id,
|
||||
"origin": sess.origin,
|
||||
});
|
||||
}
|
||||
if let Some(root) = workspace_root {
|
||||
if let Ok(ids) = list_session_account_ids(root, &record.id) {
|
||||
if !ids.is_empty() {
|
||||
item["sessionAccountIds"] = json!(ids);
|
||||
item["hasLoginSession"] = json!(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = username_template {
|
||||
item["usernameTemplate"] = json!(t);
|
||||
}
|
||||
@@ -3483,15 +3970,6 @@ pub fn project_item_l0_with_cipher(
|
||||
"humanNote": pb.human_note,
|
||||
});
|
||||
}
|
||||
// Never project cookieHeader in L0.
|
||||
if let Some(sess) = &record.login_session {
|
||||
item["loginSession"] = json!({
|
||||
"hasCookie": sess.cookie_header.as_ref().map(|c| !c.trim().is_empty()).unwrap_or(false),
|
||||
"expiresAt": sess.expires_at,
|
||||
"lastLoginAt": sess.last_login_at,
|
||||
"source": sess.source,
|
||||
});
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
@@ -3559,24 +4037,116 @@ pub fn project_list_entry(entry: &VaultIndexEntry) -> Value {
|
||||
project_list_entry_with_cipher(entry, None)
|
||||
}
|
||||
|
||||
/// Persist login session on a credential (AI vault session write-back).
|
||||
/// Persist login session as `sessions/{id}/{accountId}.json` (12-3).
|
||||
/// Does **not** write cookie into credential frontmatter (legacy field left as-is or cleared).
|
||||
pub fn put_login_session(
|
||||
workspace_root: &Path,
|
||||
id: &str,
|
||||
session: VaultLoginSession,
|
||||
) -> Result<VaultCredentialRecord, WebError> {
|
||||
let mut record = get_credential(workspace_root, id)?;
|
||||
let account_id = session.account_id.clone();
|
||||
put_login_session_for_account(workspace_root, id, account_id.as_deref(), session)
|
||||
}
|
||||
|
||||
/// Write session file for a specific account slot (`primary` when omitted).
|
||||
pub fn put_login_session_for_account(
|
||||
workspace_root: &Path,
|
||||
id: &str,
|
||||
account_id: Option<&str>,
|
||||
mut session: VaultLoginSession,
|
||||
) -> Result<VaultCredentialRecord, WebError> {
|
||||
let record = get_credential(workspace_root, id)?;
|
||||
if record.status != VaultItemStatus::Active {
|
||||
return Err(WebError::bad_request_code(
|
||||
"vault_session_inactive",
|
||||
"只能给在用条目写入登录态",
|
||||
));
|
||||
}
|
||||
record.login_session = Some(session);
|
||||
record.updated_at = now_rfc3339();
|
||||
record.revision = record.revision.saturating_add(1);
|
||||
write_record_with_index(workspace_root, &record)?;
|
||||
Ok(record)
|
||||
|
||||
// Prefer structured cookies for header when both present.
|
||||
if !session.cookies.is_empty() {
|
||||
let rebuilt = cookie_header_from_cookies(&session.cookies);
|
||||
if !rebuilt.is_empty() {
|
||||
session.cookie_header = Some(rebuilt);
|
||||
}
|
||||
}
|
||||
let cookie = session
|
||||
.cookie_header
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_cookie_required",
|
||||
"cookieHeader 与 cookies[] 至少一个非空",
|
||||
)
|
||||
})?;
|
||||
|
||||
let acc = normalize_session_account_id(
|
||||
account_id.or(session.account_id.as_deref()),
|
||||
);
|
||||
let vault = ensure_vault_directories(workspace_root)?;
|
||||
let path = session_file_abs(&vault, id, Some(&acc));
|
||||
|
||||
let prev_rev = read_session_file_at(&path)?
|
||||
.map(|f| f.revision)
|
||||
.unwrap_or(0);
|
||||
let now = now_rfc3339();
|
||||
let expires = session
|
||||
.expires_at
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
let hours = std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.filter(|h: &i64| *h > 0)
|
||||
.unwrap_or(168);
|
||||
(OffsetDateTime::now_utc() + time::Duration::hours(hours))
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| now.clone())
|
||||
});
|
||||
let source = session
|
||||
.source
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("api")
|
||||
.to_string();
|
||||
let last_login = session
|
||||
.last_login_at
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| now.clone());
|
||||
|
||||
let file = VaultSessionFile {
|
||||
schema: VAULT_SESSION_SCHEMA.into(),
|
||||
credential_id: id.to_string(),
|
||||
account_id: acc.clone(),
|
||||
cookie_header: Some(cookie.to_string()),
|
||||
cookies: session.cookies.clone(),
|
||||
origin: session.origin.clone(),
|
||||
expires_at: Some(expires.clone()),
|
||||
last_login_at: Some(last_login),
|
||||
source,
|
||||
updated_at: now,
|
||||
revision: prev_rev.saturating_add(1),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&file).map_err(|e| {
|
||||
WebError::bad_request_code(
|
||||
"vault_session_write_failed",
|
||||
format!("序列化登录态失败: {e}"),
|
||||
)
|
||||
})?;
|
||||
atomic_write_string(&path, &json)?;
|
||||
|
||||
// Index meta only — do not bump credential revision / rewrite md for cookie.
|
||||
patch_index_session_meta(workspace_root, id, true, Some(expires))?;
|
||||
|
||||
// Return record with resolved session for callers that inspect login_session.
|
||||
let mut out = record;
|
||||
out.login_session = Some(session_file_to_login(&file));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn put_login_playbook(
|
||||
@@ -4409,4 +4979,159 @@ mod tests {
|
||||
assert_eq!(created.accounts[0].password.as_deref(), Some("secret"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_file_put_resolve_and_share_copy() {
|
||||
let src = temp_root();
|
||||
let dst = temp_root();
|
||||
let created = create_credential(
|
||||
&src,
|
||||
VaultCreateInput {
|
||||
title: "session-file-test".into(),
|
||||
url: Some("https://example.com/login".into()),
|
||||
username: Some("user@ex.com".into()),
|
||||
password: Some("secret".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let put = put_login_session(
|
||||
&src,
|
||||
&created.id,
|
||||
VaultLoginSession {
|
||||
// When cookies[] present, header is rebuilt from them (preferred).
|
||||
cookie_header: Some("ignored-if-cookies-present".into()),
|
||||
expires_at: Some("2099-01-01T00:00:00Z".into()),
|
||||
last_login_at: Some("2026-07-24T12:00:00Z".into()),
|
||||
source: Some("chrome_extension".into()),
|
||||
origin: Some("https://example.com".into()),
|
||||
account_id: None,
|
||||
cookies: vec![
|
||||
VaultSessionCookie {
|
||||
name: "sid".into(),
|
||||
value: Some("abc".into()),
|
||||
domain: Some(".example.com".into()),
|
||||
path: Some("/".into()),
|
||||
secure: Some(true),
|
||||
http_only: Some(true),
|
||||
same_site: Some("lax".into()),
|
||||
expiration_date: None,
|
||||
},
|
||||
VaultSessionCookie {
|
||||
name: "csrf".into(),
|
||||
value: Some("xyz".into()),
|
||||
domain: Some(".example.com".into()),
|
||||
path: Some("/".into()),
|
||||
secure: Some(true),
|
||||
http_only: Some(false),
|
||||
same_site: Some("lax".into()),
|
||||
expiration_date: None,
|
||||
},
|
||||
],
|
||||
revision: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
put.login_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.cookie_header.as_deref()),
|
||||
Some("sid=abc; csrf=xyz")
|
||||
);
|
||||
|
||||
let path = vault_root(&src)
|
||||
.join("sessions")
|
||||
.join(&created.id)
|
||||
.join("primary.json");
|
||||
assert!(path.exists(), "session file should exist at {:?}", path);
|
||||
let raw = fs::read_to_string(&path).unwrap();
|
||||
assert!(raw.contains("mnote.vault.session.v1"));
|
||||
assert!(raw.contains("chrome_extension"));
|
||||
// credential md must not contain cookie plaintext after file write
|
||||
let md = fs::read_to_string(vault_root(&src).join(&created.relative_path)).unwrap();
|
||||
assert!(!md.contains("sid=abc"));
|
||||
|
||||
let resolved = resolve_login_session(&src, &created, None).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
resolved.cookie_header.as_deref(),
|
||||
Some("sid=abc; csrf=xyz")
|
||||
);
|
||||
assert!(login_session_is_fresh(&resolved));
|
||||
|
||||
let (_, list) = list_credentials(&src, VaultItemStatus::Active).unwrap();
|
||||
let entry = list.iter().find(|e| e.id == created.id).unwrap();
|
||||
assert!(entry.has_login_session);
|
||||
|
||||
let l0 = project_item_l0_with_cipher(&created, Some(&src));
|
||||
assert_eq!(l0["hasLoginSession"], true);
|
||||
assert!(!l0.to_string().contains("sid=abc"));
|
||||
|
||||
let _ = put_login_session_for_account(
|
||||
&src,
|
||||
&created.id,
|
||||
Some("acc_alt"),
|
||||
VaultLoginSession {
|
||||
cookie_header: Some("sid=alt".into()),
|
||||
expires_at: Some("2099-06-01T00:00:00Z".into()),
|
||||
source: Some("api".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let ids = list_session_account_ids(&src, &created.id).unwrap();
|
||||
assert!(ids.contains(&"primary".to_string()));
|
||||
assert!(ids.contains(&"acc_alt".to_string()));
|
||||
|
||||
let shared = share_credential_to_workspace(
|
||||
&src,
|
||||
&dst,
|
||||
&created.id,
|
||||
Some("from-user"),
|
||||
&[],
|
||||
false,
|
||||
"user-a",
|
||||
"ai-agent",
|
||||
)
|
||||
.unwrap();
|
||||
let dst_ids = list_session_account_ids(&dst, &shared.item.id).unwrap();
|
||||
assert!(
|
||||
dst_ids.contains(&"primary".to_string()),
|
||||
"share must copy session files"
|
||||
);
|
||||
let dst_sess = resolve_login_session(&dst, &shared.item, None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(dst_sess
|
||||
.cookie_header
|
||||
.as_ref()
|
||||
.map(|c| c.contains("sid=abc"))
|
||||
.unwrap_or(false));
|
||||
|
||||
soft_delete_credential(&src, &created.id).unwrap();
|
||||
assert!(!vault_root(&src)
|
||||
.join("sessions")
|
||||
.join(&created.id)
|
||||
.exists());
|
||||
assert!(vault_root(&src)
|
||||
.join("trash")
|
||||
.join("sessions")
|
||||
.join(&created.id)
|
||||
.exists());
|
||||
restore_credential(&src, &created.id).unwrap();
|
||||
assert!(vault_root(&src)
|
||||
.join("sessions")
|
||||
.join(&created.id)
|
||||
.exists());
|
||||
soft_delete_credential(&src, &created.id).unwrap();
|
||||
purge_credential(&src, &created.id).unwrap();
|
||||
assert!(!vault_root(&src)
|
||||
.join("trash")
|
||||
.join("sessions")
|
||||
.join(&created.id)
|
||||
.exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&src);
|
||||
let _ = fs::remove_dir_all(&dst);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
||||
tower = "0.5"
|
||||
base64 = "0.22"
|
||||
comrak = { version = "0.52", default-features = false }
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
notify = "8.2.0"
|
||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||
sha2 = "0.10"
|
||||
time = { version = "0.3", features = ["formatting", "local-offset", "parsing"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
zip = "2"
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
// AgentStreamEventRouter — 统一的 agent 流式事件状态机
|
||||
// 供 Page AI sidebar runtime 和 document editor adapter runtime 共用
|
||||
//
|
||||
// 事件状态:
|
||||
// init — 建立 request_id 绑定
|
||||
// loading — message_delta / tool_call_delta → 增量更新
|
||||
// stream_event — tool-started / tool-finished / agent_state
|
||||
// finished — 正常终止
|
||||
// interrupted — 中断(等待审批/恢复)
|
||||
// error — 错误终止
|
||||
// approval_required — 等待用户确认
|
||||
|
||||
export const AGENT_EVENT_STATES = {
|
||||
INIT: 'init',
|
||||
LOADING: 'loading',
|
||||
STREAM_EVENT: 'stream_event',
|
||||
FINISHED: 'finished',
|
||||
INTERRUPTED: 'interrupted',
|
||||
ERROR: 'error',
|
||||
APPROVAL_REQUIRED: 'approval_required',
|
||||
};
|
||||
|
||||
export const TERMINAL_STATES = new Set([
|
||||
AGENT_EVENT_STATES.FINISHED,
|
||||
AGENT_EVENT_STATES.INTERRUPTED,
|
||||
AGENT_EVENT_STATES.ERROR,
|
||||
]);
|
||||
|
||||
export function isTerminalState(status) {
|
||||
return TERMINAL_STATES.has(status);
|
||||
}
|
||||
|
||||
// 解析 SSE 帧为 eventName + payloadText
|
||||
export function parseSSEFrames(buffer, lastBoundary) {
|
||||
var frames = buffer.split('\n\n');
|
||||
var remaining = frames.pop() || '';
|
||||
var events = [];
|
||||
frames.forEach(function(frame) {
|
||||
var eventName = '';
|
||||
var dataLines = [];
|
||||
frame.split('\n').forEach(function(line) {
|
||||
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||||
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
});
|
||||
var payloadText = dataLines.join('\n');
|
||||
if (!eventName && payloadText) {
|
||||
try {
|
||||
var parsed = JSON.parse(payloadText);
|
||||
eventName = parsed && parsed.event ? String(parsed.event) : '';
|
||||
} catch (_) {}
|
||||
}
|
||||
if (eventName) events.push({ eventName: eventName, payloadText: payloadText });
|
||||
});
|
||||
return { events: events, remaining: remaining };
|
||||
}
|
||||
|
||||
// 从 SSE 流读取事件
|
||||
export async function readSSEStream(response, onFrame) {
|
||||
if (!response.body || typeof response.body.getReader !== 'function') return;
|
||||
var reader = response.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var buffer = '';
|
||||
while (true) {
|
||||
var chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
var result = parseSSEFrames(buffer, '');
|
||||
buffer = result.remaining;
|
||||
result.events.forEach(function(evt) {
|
||||
onFrame(evt.eventName, evt.payloadText);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建标准事件路由器
|
||||
// handlers: { onInit, onDelta, onToolCall, onToolResult, onAgentState, onTerminal, onApprovalRequired }
|
||||
export function createAgentStreamRouter(handlers) {
|
||||
var h = handlers || {};
|
||||
|
||||
return function routeEvent(eventName, payloadText) {
|
||||
var payload = null;
|
||||
try { payload = JSON.parse(payloadText || 'null'); } catch (_) {}
|
||||
|
||||
var status = (payload && payload.status) || eventName;
|
||||
|
||||
switch (status) {
|
||||
case AGENT_EVENT_STATES.INIT:
|
||||
if (h.onInit) h.onInit(payload);
|
||||
break;
|
||||
|
||||
case AGENT_EVENT_STATES.LOADING:
|
||||
if (h.onDelta) h.onDelta(payload);
|
||||
if (h.onToolCall) {
|
||||
var toolChunks = (payload && payload.tool_call_chunks) || (payload && payload.msg && payload.msg.tool_call_chunks);
|
||||
if (toolChunks && toolChunks.length) h.onToolCall(payload);
|
||||
}
|
||||
break;
|
||||
|
||||
case AGENT_EVENT_STATES.STREAM_EVENT:
|
||||
if (payload && payload.event === 'tool-finished') {
|
||||
if (h.onToolResult) h.onToolResult(payload);
|
||||
} else if (payload && payload.event === 'tool-started') {
|
||||
// tool start — 可选处理
|
||||
} else if (payload && payload.agent_state) {
|
||||
if (h.onAgentState) h.onAgentState(payload.agent_state);
|
||||
}
|
||||
break;
|
||||
|
||||
case AGENT_EVENT_STATES.FINISHED:
|
||||
case AGENT_EVENT_STATES.INTERRUPTED:
|
||||
case AGENT_EVENT_STATES.ERROR:
|
||||
if (h.onTerminal) h.onTerminal(status, payload);
|
||||
break;
|
||||
|
||||
case AGENT_EVENT_STATES.APPROVAL_REQUIRED:
|
||||
if (h.onApprovalRequired) h.onApprovalRequired(payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
// 未知状态:尝试作为 loading 处理
|
||||
if (payload && (payload.text || payload.delta || payload.content)) {
|
||||
if (h.onDelta) h.onDelta(payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -621,7 +621,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
|
||||
const conflictSourceLabel = (session) => {
|
||||
if (!session) return '';
|
||||
if (session.lastExternalWriteSource === 'mnote-hermes-tool') {
|
||||
const source = String(session.lastExternalWriteSource || '');
|
||||
// 兼容历史 externalActor 名;产品面统一称 agent tool
|
||||
if (source === 'mnote-agent-tool' || source === 'mnote-hermes-tool') {
|
||||
const runId = String(session.lastExternalWriteRunId || '').trim();
|
||||
return runId ? `agent run ${runId}` : 'agent run';
|
||||
}
|
||||
@@ -1820,7 +1822,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
window.addEventListener('tree:delta', handleTreeExternalChange);
|
||||
window.addEventListener('tree:resync', handleTreeExternalChange);
|
||||
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
|
||||
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
|
||||
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-agent-tool');
|
||||
});
|
||||
window.addEventListener('mnote:local-upload-editor-save-completed', (event) => {
|
||||
applyLocalUploadEditorSave(event?.detail || {});
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
export function createSidebarPageAiMarkdownRuntime(context) {
|
||||
const { escapeHtml } = context;
|
||||
|
||||
function textFromUnknown(value) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' ');
|
||||
if (typeof value !== 'object') return '';
|
||||
var parts = [];
|
||||
['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
var text = textFromUnknown(value[key]);
|
||||
if (text) parts.push(text);
|
||||
}
|
||||
});
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function renderPageAiMarkdownInline(text) {
|
||||
var html = escapeHtml(String(text || ''));
|
||||
var codeSpans = [];
|
||||
var htmlSpans = [];
|
||||
function stashHtml(value) {
|
||||
var key = '\u0000HTML' + htmlSpans.length + '\u0000';
|
||||
htmlSpans.push(value);
|
||||
return key;
|
||||
}
|
||||
html = html.replace(/`([^`\n]+)`/g, function(_, code) {
|
||||
var key = '\u0000CODE' + codeSpans.length + '\u0000';
|
||||
codeSpans.push('<code>' + code + '</code>');
|
||||
return key;
|
||||
});
|
||||
html = html.replace(/\[((?:\\.|[^\]\n])+)\]\(([^)\n]+)\)/g, function(match, label, href) {
|
||||
var normalizedHref = normalizePageAiMarkdownHref(href);
|
||||
if (!normalizedHref) return match;
|
||||
var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : '';
|
||||
return stashHtml('<a href="' + escapeHtml(normalizedHref) + '" target="_blank" rel="noopener noreferrer"' + citationAttr + '>' + unescapePageAiMarkdownLabel(label) + '</a>');
|
||||
});
|
||||
html = html.replace(/(^|[\s(])((?:https?:\/\/|\/documents\/|mnote:\/\/open(?:Resource)?)[^\s<>()]+[^\s<>().,;:!?])/g, function(_, prefix, href) {
|
||||
var normalizedHref = normalizePageAiMarkdownHref(href);
|
||||
if (!normalizedHref) return prefix + href;
|
||||
var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : '';
|
||||
return prefix + stashHtml('<a href="' + escapeHtml(normalizedHref) + '" target="_blank" rel="noopener noreferrer"' + citationAttr + '>' + escapeHtml(normalizedHref) + '</a>');
|
||||
});
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
codeSpans.forEach(function(value, index) {
|
||||
html = html.replace('\u0000CODE' + index + '\u0000', value);
|
||||
});
|
||||
htmlSpans.forEach(function(value, index) {
|
||||
html = html.replace('\u0000HTML' + index + '\u0000', value);
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function normalizePageAiMarkdownHref(value) {
|
||||
var href = String(value || '').trim()
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
while (href.includes('&')) href = href.replace(/&/g, '&');
|
||||
if (href.startsWith('<') && href.endsWith('>')) href = href.slice(1, -1).trim();
|
||||
if (!href || /[\u0000-\u001f<>"']/.test(href)) return '';
|
||||
var lower = href.toLowerCase();
|
||||
if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) return '';
|
||||
if (lower.startsWith('mnote://open')) href = normalizePageAiMnoteOpenHref(href);
|
||||
href = normalizePageAiLegacyCitationHref(href);
|
||||
if (href.startsWith('/') || href.startsWith('#')) return href;
|
||||
if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) return href;
|
||||
return '';
|
||||
}
|
||||
|
||||
function unescapePageAiMarkdownLabel(value) {
|
||||
return String(value || '').replace(/\\([\\\[\]()*_`])/g, '$1');
|
||||
}
|
||||
|
||||
function normalizePageAiMnoteOpenHref(href) {
|
||||
try {
|
||||
var url = new URL(href);
|
||||
if (url.protocol !== 'mnote:' || (url.hostname !== 'open' && url.hostname !== 'openResource')) return href;
|
||||
var path = String(url.searchParams.get('path') || url.searchParams.get('resourcePath') || '').trim();
|
||||
if (!path) return '';
|
||||
var params = new URLSearchParams();
|
||||
var rootUri = String(url.searchParams.get('rootUri') || '').trim();
|
||||
if (!rootUri) {
|
||||
try {
|
||||
rootUri = new URL(window.location.href).searchParams.get('rootUri') || '';
|
||||
} catch (_locationError) {}
|
||||
}
|
||||
if (rootUri) params.set('rootUri', rootUri);
|
||||
params.set('path', path);
|
||||
['page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) {
|
||||
var value = String(url.searchParams.get(key) || '').trim();
|
||||
if (value) params.set(key, value);
|
||||
});
|
||||
return '/api/local-folder/files/open?' + params.toString();
|
||||
} catch (_error) {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePageAiLegacyCitationHref(href) {
|
||||
try {
|
||||
var url = new URL(href, window.location.origin);
|
||||
var unwrappedHref = normalizePageAiSearchWrappedCitationHref(url);
|
||||
if (unwrappedHref) return normalizePageAiLegacyCitationHref(unwrappedHref);
|
||||
if (!isPageAiSameMnoteOrigin(url) && !isPageAiPortableMnoteCitationUrl(url)) return href;
|
||||
if (url.pathname === '/api/local-folder/files/open') return url.pathname + url.search;
|
||||
if (!url.pathname.startsWith('/documents/')) return href;
|
||||
if (isPageAiUnsafeCitationDocumentId(url)) {
|
||||
var fileOpenHref = normalizePageAiCitationFileOpenHref(url);
|
||||
if (fileOpenHref) return fileOpenHref;
|
||||
}
|
||||
var decodedHash = '';
|
||||
try {
|
||||
decodedHash = decodeURIComponent(url.hash || '');
|
||||
} catch (_decodeError) {
|
||||
decodedHash = url.hash || '';
|
||||
}
|
||||
var marker = '#resource-tab-';
|
||||
var markerIndex = decodedHash.indexOf(marker);
|
||||
if (markerIndex < 0 || url.searchParams.get('resourceTab')) {
|
||||
return url.pathname + url.search + url.hash;
|
||||
}
|
||||
var identity = decodedHash.slice(markerIndex + marker.length).replace(/^#+/, '').trim();
|
||||
if (!identity.startsWith('resource:file:')) return url.pathname + url.search + url.hash;
|
||||
url.searchParams.set('resourceTab', identity);
|
||||
if (!url.searchParams.get('sourceKind')) url.searchParams.set('sourceKind', 'local_folder');
|
||||
var rootUri = String(url.searchParams.get('rootUri') || '').trim();
|
||||
var prefix = 'resource:file:' + rootUri + ':';
|
||||
if (rootUri && identity.startsWith(prefix) && !url.searchParams.get('resourcePath')) {
|
||||
url.searchParams.set('resourcePath', identity.slice(prefix.length).replace(/^\/+/, ''));
|
||||
}
|
||||
url.hash = '';
|
||||
return url.pathname + url.search;
|
||||
} catch (_error) {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePageAiSearchWrappedCitationHref(url) {
|
||||
var raw = '';
|
||||
['wd', 'q', 'query'].some(function(key) {
|
||||
raw = String(url.searchParams.get(key) || '').trim();
|
||||
return Boolean(raw);
|
||||
});
|
||||
if (!raw) return '';
|
||||
if (/^documents\//i.test(raw)) raw = '/' + raw;
|
||||
if (!/^\/documents\//i.test(raw) && !/^https?:\/\/[^/]+\/documents\//i.test(raw) && !/^mnote:\/\/open/i.test(raw)) return '';
|
||||
if (/^mnote:\/\/open/i.test(raw)) return normalizePageAiMnoteOpenHref(raw);
|
||||
var nested = new URL(raw, window.location.origin);
|
||||
if (!nested.pathname.startsWith('/documents/')) return '';
|
||||
['sourceKind', 'rootUri', 'workspaceId', 'resourceTab', 'resourcePath', 'page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) {
|
||||
if (nested.searchParams.get(key)) return;
|
||||
var value = String(url.searchParams.get(key) || '').trim();
|
||||
if (value) nested.searchParams.set(key, value);
|
||||
});
|
||||
return nested.pathname + nested.search;
|
||||
}
|
||||
|
||||
function normalizePageAiCitationFileOpenHref(url) {
|
||||
var rootUri = String(url.searchParams.get('rootUri') || '').trim();
|
||||
var path = String(url.searchParams.get('resourcePath') || '').trim();
|
||||
if (!rootUri || !path) return '';
|
||||
var params = new URLSearchParams();
|
||||
params.set('rootUri', rootUri);
|
||||
params.set('path', path);
|
||||
['page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) {
|
||||
var value = String(url.searchParams.get(key) || '').trim();
|
||||
if (value) params.set(key, value);
|
||||
});
|
||||
return '/api/local-folder/files/open?' + params.toString();
|
||||
}
|
||||
|
||||
function isPageAiUnsafeCitationDocumentId(url) {
|
||||
try {
|
||||
var raw = String(url.pathname || '').replace(/^\/documents\//, '').split('/')[0] || '';
|
||||
if (!raw) return false;
|
||||
var decoded = decodeURIComponent(raw);
|
||||
return decoded.indexOf('%') >= 0 || decoded.indexOf('\uFFFD') >= 0;
|
||||
} catch (_error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function isPageAiPortableMnoteCitationUrl(url) {
|
||||
try {
|
||||
if (url.pathname === '/api/local-folder/files/open') {
|
||||
return Boolean(String(url.searchParams.get('rootUri') || '').trim()) &&
|
||||
Boolean(String(url.searchParams.get('path') || '').trim());
|
||||
}
|
||||
if (!url.pathname.startsWith('/documents/')) return false;
|
||||
var resourceTab = String(url.searchParams.get('resourceTab') || '').trim();
|
||||
return String(url.searchParams.get('sourceKind') || '').trim() === 'local_folder' ||
|
||||
Boolean(String(url.searchParams.get('rootUri') || '').trim()) ||
|
||||
Boolean(String(url.searchParams.get('resourcePath') || '').trim()) ||
|
||||
resourceTab.startsWith('resource:file:');
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPageAiMarkdownTableDivider(line) {
|
||||
var cells = splitPageAiMarkdownTableRow(line);
|
||||
if (cells.length < 2) return false;
|
||||
return cells.every(function(cell) {
|
||||
return /^:?-{3,}:?$/.test(cell.trim());
|
||||
});
|
||||
}
|
||||
|
||||
function splitPageAiMarkdownTableRow(line) {
|
||||
var text = String(line || '').trim();
|
||||
if (!text.includes('|')) return [];
|
||||
if (text.startsWith('|')) text = text.slice(1);
|
||||
if (text.endsWith('|')) text = text.slice(0, -1);
|
||||
return text.split('|').map(function(cell) { return cell.trim(); });
|
||||
}
|
||||
|
||||
function renderPageAiMarkdownTable(lines, startIndex) {
|
||||
if (startIndex + 1 >= lines.length || !isPageAiMarkdownTableDivider(lines[startIndex + 1])) return null;
|
||||
var header = splitPageAiMarkdownTableRow(lines[startIndex]);
|
||||
var divider = splitPageAiMarkdownTableRow(lines[startIndex + 1]);
|
||||
if (!header.length || header.length !== divider.length) return null;
|
||||
var rows = [];
|
||||
var index = startIndex + 2;
|
||||
while (index < lines.length && lines[index].trim() && lines[index].includes('|')) {
|
||||
var cells = splitPageAiMarkdownTableRow(lines[index]);
|
||||
if (!cells.length) break;
|
||||
rows.push(cells);
|
||||
index += 1;
|
||||
}
|
||||
function cellHtml(tag, value) {
|
||||
return '<' + tag + '>' + renderPageAiMarkdownInline(value) + '</' + tag + '>';
|
||||
}
|
||||
var head = '<thead><tr>' + header.map(function(cell) { return cellHtml('th', cell); }).join('') + '</tr></thead>';
|
||||
var body = rows.length
|
||||
? '<tbody>' + rows.map(function(row) {
|
||||
return '<tr>' + header.map(function(_, cellIndex) { return cellHtml('td', row[cellIndex] || ''); }).join('') + '</tr>';
|
||||
}).join('') + '</tbody>'
|
||||
: '';
|
||||
return {
|
||||
html: '<div class="wolai-page-ai-markdown-table-wrap"><table>' + head + body + '</table></div>',
|
||||
nextIndex: index
|
||||
};
|
||||
}
|
||||
|
||||
function isMnoteCitationHref(href) {
|
||||
try {
|
||||
var url = new URL(href, window.location.origin);
|
||||
if (!isPageAiSameMnoteOrigin(url)) return false;
|
||||
return url.pathname.startsWith('/documents/') || url.pathname === '/api/local-folder/files/open';
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPageAiSameMnoteOrigin(url) {
|
||||
try {
|
||||
var current = new URL(window.location.origin);
|
||||
if (url.origin === current.origin) return true;
|
||||
if (url.hostname === 'mnote.local') return true;
|
||||
var localNames = ['localhost', '127.0.0.1', '::1', 'mnote.local'];
|
||||
return localNames.indexOf(url.hostname) >= 0 &&
|
||||
localNames.indexOf(current.hostname) >= 0 &&
|
||||
String(url.port || defaultPortForProtocol(url.protocol)) === String(current.port || defaultPortForProtocol(current.protocol));
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultPortForProtocol(protocol) {
|
||||
return protocol === 'https:' ? '443' : protocol === 'http:' ? '80' : '';
|
||||
}
|
||||
|
||||
function renderPageAiMarkdown(content) {
|
||||
var lines = String(content || '').replace(/\r\n?/g, '\n').split('\n');
|
||||
var blocks = [];
|
||||
var index = 0;
|
||||
function isBlockBoundary(line) {
|
||||
return !line.trim() ||
|
||||
/^```/.test(line.trim()) ||
|
||||
/^#{1,6}\s+/.test(line) ||
|
||||
/^\s*[-*]\s+/.test(line) ||
|
||||
/^\s*\d+[.)]\s+/.test(line);
|
||||
}
|
||||
while (index < lines.length) {
|
||||
var line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^```/.test(line.trim())) {
|
||||
index += 1;
|
||||
var codeLines = [];
|
||||
while (index < lines.length && !/^```/.test(lines[index].trim())) {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
blocks.push('<pre><code>' + escapeHtml(codeLines.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
if (/^\s*-{3,}\s*$/.test(line)) {
|
||||
blocks.push('<hr />');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
var table = renderPageAiMarkdownTable(lines, index);
|
||||
if (table) {
|
||||
blocks.push(table.html);
|
||||
index = table.nextIndex;
|
||||
continue;
|
||||
}
|
||||
var heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
var level = Math.min(6, heading[1].length);
|
||||
blocks.push('<h' + level + '>' + renderPageAiMarkdownInline(heading[2]) + '</h' + level + '>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^\s*[-*]\s+/.test(line)) {
|
||||
var unordered = [];
|
||||
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
|
||||
unordered.push('<li>' + renderPageAiMarkdownInline(lines[index].replace(/^\s*[-*]\s+/, '')) + '</li>');
|
||||
index += 1;
|
||||
}
|
||||
blocks.push('<ul>' + unordered.join('') + '</ul>');
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\d+[.)]\s+/.test(line)) {
|
||||
var ordered = [];
|
||||
while (index < lines.length && /^\s*\d+[.)]\s+/.test(lines[index])) {
|
||||
ordered.push('<li>' + renderPageAiMarkdownInline(lines[index].replace(/^\s*\d+[.)]\s+/, '')) + '</li>');
|
||||
index += 1;
|
||||
}
|
||||
blocks.push('<ol>' + ordered.join('') + '</ol>');
|
||||
continue;
|
||||
}
|
||||
var paragraph = [];
|
||||
while (index < lines.length && !isBlockBoundary(lines[index])) {
|
||||
paragraph.push(renderPageAiMarkdownInline(lines[index]));
|
||||
index += 1;
|
||||
}
|
||||
if (paragraph.length) {
|
||||
blocks.push('<p>' + paragraph.join('<br />') + '</p>');
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return blocks.join('') || escapeHtml(String(content || ''));
|
||||
}
|
||||
|
||||
return {
|
||||
textFromUnknown,
|
||||
renderPageAiMarkdown,
|
||||
renderPageAiMarkdownInline
|
||||
};
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
export function createSidebarPageAiPermissionRuntime(context) {
|
||||
const {
|
||||
documentRef,
|
||||
pageAiPreviewValue,
|
||||
pageUiState,
|
||||
renderPageAiConversation,
|
||||
} = context;
|
||||
const doc = documentRef || document;
|
||||
|
||||
function pageAiPermissionStorageKey() {
|
||||
var sessionId = String(pageUiState.pageAiActiveSessionId || 'no-session').trim() || 'no-session';
|
||||
var path = doc && doc.location ? String(doc.location.pathname || '') : '';
|
||||
return 'mnote.page_ai.permission_queue.v1:' + path + ':' + sessionId;
|
||||
}
|
||||
|
||||
function pageAiPersistPermissionRequests() {
|
||||
try {
|
||||
var pending = pageUiState.pageAiPermissionRequests.filter(function(item) {
|
||||
return item && item.kind === 'permission' && !item.resolved;
|
||||
}).slice(-20);
|
||||
if (window.localStorage) window.localStorage.setItem(pageAiPermissionStorageKey(), JSON.stringify(pending));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function pageAiRestorePermissionRequests() {
|
||||
try {
|
||||
var raw = window.localStorage ? window.localStorage.getItem(pageAiPermissionStorageKey()) : '';
|
||||
var items = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(items)) items = [];
|
||||
pageUiState.pageAiPermissionRequests = items.filter(function(item) {
|
||||
return item && item.kind === 'permission' && !item.resolved;
|
||||
}).slice(-20);
|
||||
pageUiState.pageAiPermissionRequests.forEach(function(item) {
|
||||
var exists = pageUiState.pageAiMessages.some(function(message) {
|
||||
return message.kind === 'permission' && message.permissionId === item.permissionId;
|
||||
});
|
||||
if (!exists) pageUiState.pageAiMessages.push(item);
|
||||
});
|
||||
var pending = pageUiState.pageAiPermissionRequests.find(function(item) { return !item.resolved; });
|
||||
if (pending) {
|
||||
if (!pageAiApplyPermissionMode(pending)) pageAiShowPermissionDialog(pending);
|
||||
}
|
||||
return pageUiState.pageAiPermissionRequests;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiPermissionMessage(payload, eventType) {
|
||||
payload = payload && typeof payload === 'object' ? payload : {};
|
||||
var permissionId = String(payload.permissionId || payload.permission_id || payload.id || ('perm_' + Date.now())).trim();
|
||||
var detail = pageAiPermissionDetail(payload, permissionId);
|
||||
var toolName = detail.toolName || String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim();
|
||||
var args = payload.arguments || payload.args || payload.input || payload.params || payload;
|
||||
var decision = String(payload.decision || payload.result || '').trim();
|
||||
if (!decision && eventType === 'permission.denied') decision = 'denied';
|
||||
if (!decision && eventType === 'permission.allowed') decision = 'allowed';
|
||||
var options = pageAiPermissionOptions(payload);
|
||||
return {
|
||||
role: 'tool',
|
||||
kind: 'permission',
|
||||
permissionId: permissionId,
|
||||
runId: String(payload.runId || payload.run_id || pageUiState.pageAiCurrentRunId || '').trim(),
|
||||
toolName: toolName,
|
||||
argsSummary: detail.summary || pageAiPreviewValue(args),
|
||||
content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'),
|
||||
resolved: decision === 'denied' || decision === 'allowed',
|
||||
decision: decision,
|
||||
options: options,
|
||||
permissionDetails: detail
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiPermissionDetail(payload, permissionId) {
|
||||
var params = payload && payload.params && typeof payload.params === 'object' ? payload.params : {};
|
||||
var toolCall = params.toolCall && typeof params.toolCall === 'object' ? params.toolCall : {};
|
||||
var rawInput = toolCall.rawInput && typeof toolCall.rawInput === 'object' ? toolCall.rawInput : {};
|
||||
var title = String(toolCall.title || payload.toolName || payload.tool || payload.name || '').trim();
|
||||
var kind = String(toolCall.kind || params.kind || '').trim();
|
||||
var command = String(rawInput.command || rawInput.cmd || '').trim();
|
||||
var path = String(rawInput.path || rawInput.file || rawInput.target || '').trim();
|
||||
var toolName = title || String(payload.toolName || payload.tool || payload.name || 'session/request_permission').trim();
|
||||
var primary = command || path || String(rawInput.pattern || rawInput.query || '').trim() || title || '权限请求';
|
||||
var rows = [];
|
||||
rows.push({ label: '操作', value: toolName });
|
||||
if (kind) rows.push({ label: '类型', value: pageAiPermissionKindLabel(kind) });
|
||||
if (command) rows.push({ label: '命令', value: command });
|
||||
return {
|
||||
toolName: toolName,
|
||||
kind: kind,
|
||||
kindLabel: pageAiPermissionKindLabel(kind),
|
||||
command: command,
|
||||
path: path,
|
||||
summary: primary,
|
||||
rows: rows
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiPermissionKindLabel(kind) {
|
||||
var normalized = String(kind || '').trim().toLowerCase();
|
||||
if (normalized === 'execute') return '执行命令';
|
||||
if (normalized === 'edit') return '写入文件';
|
||||
if (normalized === 'read') return '读取';
|
||||
if (normalized === 'other') return '其他';
|
||||
return kind || '';
|
||||
}
|
||||
|
||||
function pageAiPermissionOptions(payload) {
|
||||
var rawOptions = payload && Array.isArray(payload.options)
|
||||
? payload.options
|
||||
: (payload && payload.params && Array.isArray(payload.params.options) ? payload.params.options : []);
|
||||
return rawOptions.map(function(option) {
|
||||
option = option && typeof option === 'object' ? option : {};
|
||||
var optionId = String(option.optionId || option.option_id || option.id || '').trim();
|
||||
if (!optionId) return null;
|
||||
return {
|
||||
optionId: optionId,
|
||||
name: pageAiPermissionOptionLabel(optionId, option),
|
||||
kind: String(option.kind || '').trim()
|
||||
};
|
||||
}).filter(Boolean).slice(0, 8);
|
||||
}
|
||||
|
||||
function pageAiPermissionOptionLabel(optionId, option) {
|
||||
var id = String(optionId || '').trim().toLowerCase();
|
||||
var raw = String((option && (option.name || option.title)) || '').trim();
|
||||
if (id === 'allow_once') return raw && !/^allow$/i.test(raw) ? raw.replace(/^Allow\b/i, '允许') : '允许一次';
|
||||
if (id === 'allow_always') return '本会话允许';
|
||||
if (id === 'allow_persistent') return '始终允许';
|
||||
if (id === 'reject_once' || id === 'reject') return '拒绝';
|
||||
if (id === 'cancel') return '取消';
|
||||
if (id === 'refine' || id === 'revise') return '要求修改';
|
||||
if (id === 'accept') return '接受';
|
||||
return raw || optionId || '选择';
|
||||
}
|
||||
|
||||
function pageAiApplyPermissionEvent(eventName, payloadText) {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(payloadText || 'null');
|
||||
} catch (_) {
|
||||
payload = {};
|
||||
}
|
||||
var message = pageAiPermissionMessage(payload, eventName);
|
||||
var existing = pageUiState.pageAiMessages.find(function(item) {
|
||||
return item.kind === 'permission' && item.permissionId === message.permissionId;
|
||||
});
|
||||
if (existing) {
|
||||
Object.assign(existing, message);
|
||||
} else {
|
||||
pageUiState.pageAiMessages.push(message);
|
||||
}
|
||||
pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) {
|
||||
return item.permissionId !== message.permissionId;
|
||||
}).concat([message]).slice(-20);
|
||||
pageAiPersistPermissionRequests();
|
||||
if (!message.resolved && pageAiApplyPermissionMode(message)) {
|
||||
return;
|
||||
}
|
||||
if (!message.resolved) {
|
||||
pageAiShowPermissionDialog(message);
|
||||
} else {
|
||||
pageAiHidePermissionDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiReasonixApprovalMode() {
|
||||
var preferences = pageUiState.pageAiSkillPreferences && typeof pageUiState.pageAiSkillPreferences === 'object'
|
||||
? pageUiState.pageAiSkillPreferences
|
||||
: {};
|
||||
var mode = String(preferences['ai.agent.reasonix.approval_mode'] || 'ask').trim() || 'ask';
|
||||
return mode === 'allow' || mode === 'deny' ? mode : 'ask';
|
||||
}
|
||||
|
||||
function pageAiApplyPermissionMode(message) {
|
||||
if (!message || message.resolved) return false;
|
||||
var mode = pageAiReasonixApprovalMode();
|
||||
if (mode === 'ask') return false;
|
||||
var optionId = pageAiPermissionPreferredOptionId(message, mode);
|
||||
window.setTimeout(function() {
|
||||
pageAiResolvePermission(message.permissionId, mode, optionId);
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
function pageAiPermissionPreferredOptionId(message, decision) {
|
||||
var options = Array.isArray(message && message.options) ? message.options : [];
|
||||
if (!options.length) return '';
|
||||
var allowKeywords = ['allow_once', 'allow', 'approve', 'yes', 'allow_always', 'allow_persistent'];
|
||||
var denyKeywords = ['deny_once', 'reject_once', 'deny', 'reject', 'no', 'cancel', 'stop'];
|
||||
var keywords = decision === 'allow' ? allowKeywords : denyKeywords;
|
||||
for (var i = 0; i < keywords.length; i += 1) {
|
||||
var keyword = keywords[i];
|
||||
var found = options.find(function(option) {
|
||||
var text = [option && option.optionId, option && option.kind, option && option.name].map(function(value) {
|
||||
return String(value || '').toLowerCase();
|
||||
}).join(' ');
|
||||
return text.indexOf(keyword) >= 0;
|
||||
});
|
||||
if (found && found.optionId) return String(found.optionId || '');
|
||||
}
|
||||
if (decision === 'allow') {
|
||||
var allowFallback = options.find(function(option) { return !pageAiPermissionOptionRejectLike(option); });
|
||||
return allowFallback && allowFallback.optionId ? String(allowFallback.optionId || '') : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pageAiResolvePermission(permissionId, decision, optionId) {
|
||||
permissionId = String(permissionId || '').trim();
|
||||
decision = String(decision || '').trim() || 'deny';
|
||||
optionId = String(optionId || '').trim();
|
||||
if (!permissionId) return;
|
||||
var runId = String(pageUiState.pageAiCurrentRunId || '').trim();
|
||||
if (!runId && pageUiState.pageAiActiveSessionId && Array.isArray(pageUiState.pageAiSessions)) {
|
||||
var activeSession = pageUiState.pageAiSessions.find(function(session) {
|
||||
return session && session.id === pageUiState.pageAiActiveSessionId;
|
||||
});
|
||||
runId = String(activeSession && (activeSession.runId || activeSession.hostRunId) || '').trim();
|
||||
}
|
||||
if (!runId && Array.isArray(pageUiState.pageAiPermissionRequests)) {
|
||||
var pending = pageUiState.pageAiPermissionRequests.find(function(item) {
|
||||
return item && item.permissionId === permissionId;
|
||||
});
|
||||
runId = String(pending && pending.runId || '').trim();
|
||||
}
|
||||
if (!runId && doc && doc.documentElement) {
|
||||
runId = String(doc.documentElement.getAttribute('data-mnote-page-ai-run-id') || doc.documentElement.getAttribute('data-mnote-page-ai-active-host-run-id') || '').trim();
|
||||
}
|
||||
if (runId) {
|
||||
var body = { permissionId: permissionId, decision: decision };
|
||||
if (optionId) body.optionId = optionId;
|
||||
fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}).then(function(response) {
|
||||
if (!response.ok) console.warn('resolve-permission 后端返回异常', response.status);
|
||||
}).catch(function(err) {
|
||||
console.warn('resolve-permission 请求失败', err);
|
||||
});
|
||||
} else {
|
||||
console.warn('resolve-permission: 无活跃 runId,只能本地更新');
|
||||
}
|
||||
pageUiState.pageAiMessages.forEach(function(item) {
|
||||
if (item.kind === 'permission' && item.permissionId === permissionId) {
|
||||
item.resolved = true;
|
||||
item.decision = decision;
|
||||
item.content = decision === 'allow' ? '已允许权限请求' : '已拒绝权限请求';
|
||||
}
|
||||
});
|
||||
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (dialog instanceof HTMLElement) dialog.hidden = true;
|
||||
pageAiPersistPermissionRequests();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function pageAiHidePermissionDialog() {
|
||||
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (dialog instanceof HTMLElement) dialog.hidden = true;
|
||||
}
|
||||
|
||||
function pageAiShowPermissionDialog(message) {
|
||||
if (!message || message.kind !== 'permission') return;
|
||||
if (message.resolved) {
|
||||
pageAiHidePermissionDialog();
|
||||
return;
|
||||
}
|
||||
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
|
||||
if (!(dialog instanceof HTMLElement)) {
|
||||
dialog = doc.createElement('div');
|
||||
dialog.className = 'wolai-page-ai-permission-dialog';
|
||||
dialog.setAttribute('data-page-ai-permission-dialog', 'true');
|
||||
dialog.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-permission-panel" role="dialog" aria-modal="false">' +
|
||||
'<div class="wolai-page-ai-memory-title" data-page-ai-permission-tool></div>' +
|
||||
'<div class="wolai-page-ai-permission-summary" data-page-ai-permission-args></div>' +
|
||||
'<div class="wolai-page-ai-message-actions" data-page-ai-permission-actions></div>' +
|
||||
'</div>';
|
||||
doc.body.appendChild(dialog);
|
||||
}
|
||||
var tool = dialog.querySelector('[data-page-ai-permission-tool]');
|
||||
if (tool instanceof HTMLElement) tool.textContent = "权限 - " + (message.toolName || "session/request_permission");
|
||||
var args = dialog.querySelector('[data-page-ai-permission-args]');
|
||||
if (args instanceof HTMLElement) args.innerHTML = pageAiPermissionDetailsHtml(message);
|
||||
var actions = dialog.querySelector('[data-page-ai-permission-actions]');
|
||||
if (actions instanceof HTMLElement) actions.innerHTML = pageAiPermissionActionsHtml(message);
|
||||
dialog.hidden = false;
|
||||
}
|
||||
|
||||
function pageAiPermissionDetailsHtml(message) {
|
||||
var details = message && message.permissionDetails && typeof message.permissionDetails === 'object'
|
||||
? message.permissionDetails
|
||||
: {};
|
||||
var rows = Array.isArray(details.rows) ? details.rows : [];
|
||||
if (!rows.length) {
|
||||
return '<div class="wolai-page-ai-permission-row"><span>请求</span><strong>' + escapeHtml(message.argsSummary || message.content || '') + '</strong></div>';
|
||||
}
|
||||
return rows.slice(0, 6).map(function(row) {
|
||||
return '<div class="wolai-page-ai-permission-row"><span>' + escapeHtml(row.label || '') + '</span><strong>' + escapeHtml(row.value || '') + '</strong></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function pageAiPermissionActionsHtml(message) {
|
||||
var options = Array.isArray(message.options) ? message.options : [];
|
||||
if (!options.length) {
|
||||
return '' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeAttr(message.permissionId || '') + '" data-page-ai-permission-dialog-action>允许</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeAttr(message.permissionId || '') + '" data-page-ai-permission-dialog-action>拒绝</button>';
|
||||
}
|
||||
return options.map(function(option) {
|
||||
var denyLike = pageAiPermissionOptionRejectLike(option);
|
||||
return '<button type="button" class="wolai-page-ai-ghost' + (denyLike ? ' wolai-page-ai-ghost--danger' : '') + '" data-page-ai-permission-action="' + (denyLike ? 'deny' : 'allow') + '" data-page-ai-permission-option-id="' + escapeAttr(option.optionId || '') + '" data-page-ai-permission-id="' + escapeAttr(message.permissionId || '') + '" data-page-ai-permission-dialog-action>' + escapeHtml(option.name || option.optionId || '选择') + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function pageAiPermissionOptionRejectLike(option) {
|
||||
var text = String((option && option.optionId) || '') + ' ' + String((option && option.kind) || '') + ' ' + String((option && option.name) || '');
|
||||
text = text.toLowerCase();
|
||||
return /reject|deny|cancel|stop|no/.test(text);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(String(value || ''));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
return {
|
||||
pageAiPermissionMessage,
|
||||
pageAiApplyPermissionEvent,
|
||||
pageAiResolvePermission,
|
||||
pageAiRestorePermissionRequests,
|
||||
pageAiHidePermissionDialog,
|
||||
pageAiShowPermissionDialog
|
||||
};
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
export function createSidebarPageAiProfileRuntime(context) {
|
||||
const {
|
||||
chatOnlyProfileRegistry,
|
||||
documentRef,
|
||||
pageAiAgentRecord,
|
||||
pageAiCurrentAgentId,
|
||||
pageAiNormalizeAgentId,
|
||||
pageUiState,
|
||||
} = context;
|
||||
const PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY = Array.isArray(chatOnlyProfileRegistry) ? chatOnlyProfileRegistry : [];
|
||||
|
||||
function pageAiProviderLabel(provider) {
|
||||
if (provider === 'codex') return 'Codex';
|
||||
if (provider === 'claudecode') return 'ClaudeCode';
|
||||
return 'Hermes';
|
||||
}
|
||||
|
||||
function pageAiNormalizeArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function pageAiDefaultAcpRuntimes() {
|
||||
return [
|
||||
{
|
||||
name: 'reasonix',
|
||||
title: 'ACP · Reasonix',
|
||||
description: '通过 ACP 协议直连 Reasonix(DeepSeek 缓存优先)',
|
||||
model: 'deepseek-chat',
|
||||
preset: 'auto'
|
||||
},
|
||||
{
|
||||
name: 'hermes',
|
||||
title: 'ACP · Hermes',
|
||||
description: '通过 ACP 协议直连 Hermes agent runtime'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function pageAiNormalizeAcpRuntimes(runtimes) {
|
||||
var byName = {};
|
||||
pageAiDefaultAcpRuntimes().forEach(function(runtime) {
|
||||
byName[runtime.name] = Object.assign({}, runtime);
|
||||
});
|
||||
pageAiNormalizeArray(runtimes).forEach(function(runtime) {
|
||||
var name = String(runtime && runtime.name || '').trim();
|
||||
if (name !== 'reasonix' && name !== 'hermes') return;
|
||||
byName[name] = Object.assign({}, byName[name] || {}, runtime, { name: name });
|
||||
});
|
||||
return ['reasonix', 'hermes'].map(function(name) { return byName[name]; }).filter(Boolean);
|
||||
}
|
||||
|
||||
function pageAiUnwrapUpstream(payload) {
|
||||
if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream;
|
||||
return payload || null;
|
||||
}
|
||||
|
||||
function pageAiProfileValue(profile) {
|
||||
if (profile && typeof profile === 'object') {
|
||||
return String(profile.profileId || profile.name || profile.profile || profile.id || '').trim();
|
||||
}
|
||||
return String(profile || '').trim();
|
||||
}
|
||||
|
||||
function pageAiCurrentProfile() {
|
||||
var active = String(pageUiState.pageAiActiveProfileName || '').trim();
|
||||
if (active) return active;
|
||||
var selected = pageUiState.pageAiProfiles.find(function(profile) {
|
||||
return profile && profile.active;
|
||||
});
|
||||
return pageAiProfileValue(selected) || 'mnoteai';
|
||||
}
|
||||
|
||||
function pageAiRunProfile() {
|
||||
if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return 'reasonix';
|
||||
if (pageAiCurrentAgentId() === 'chat_only') return pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile());
|
||||
return pageAiCurrentProfile();
|
||||
}
|
||||
|
||||
function pageAiMnoteToolModel() {
|
||||
var doc = documentRef || document;
|
||||
return String(doc.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash';
|
||||
}
|
||||
|
||||
function pageAiCurrentProfileRecord() {
|
||||
var active = pageAiCurrentProfile();
|
||||
return pageUiState.pageAiProfiles.find(function(profile) {
|
||||
return pageAiProfileValue(profile) === active;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiChatOnlyProfileSpec(profile) {
|
||||
var profileId = pageAiProfileValue(profile);
|
||||
var baseProfile = String(profile && profile.baseProfile || '').trim();
|
||||
var isolatedProfile = String(profile && profile.isolatedProfile || '').trim();
|
||||
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY.find(function(spec) {
|
||||
return spec.profileId === profileId || spec.baseProfile === profileId || spec.baseProfile === baseProfile || isolatedProfile.indexOf(spec.baseProfile) >= 0;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiDefaultChatOnlyProfileSpec() {
|
||||
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY[0] || { profileId: 'shared_deepseek_chat', baseProfile: 'deepseek-chat', label: 'DeepSeek' };
|
||||
}
|
||||
|
||||
function pageAiNormalizeChatOnlyProfileId(profileId) {
|
||||
var profile = pageAiProfileRecordById(profileId) || { profileId: String(profileId || '').trim(), name: String(profileId || '').trim() };
|
||||
var spec = pageAiChatOnlyProfileSpec(profile);
|
||||
return (spec || pageAiDefaultChatOnlyProfileSpec()).profileId;
|
||||
}
|
||||
|
||||
function pageAiProfileDisplayLabel(profile, fallback) {
|
||||
var alias = String(profile && profile.alias || '').trim();
|
||||
var displayName = String(profile && (profile.displayName || profile.label || '') || '').trim();
|
||||
var name = pageAiProfileValue(profile);
|
||||
return alias || displayName || fallback || name || 'default';
|
||||
}
|
||||
|
||||
function pageAiProfileRecordById(profileId) {
|
||||
var normalized = String(profileId || '').trim();
|
||||
if (!normalized) return null;
|
||||
return pageAiNormalizeArray(pageUiState.pageAiProfiles).find(function(profile) {
|
||||
return pageAiProfileValue(profile) === normalized;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiSessionAgentFilterValue(session) {
|
||||
if (String(session && session.source || '') === 'board' || session && session.workerPresetId) return 'board:mnote-page-ai';
|
||||
var agentId = pageAiNormalizeAgentId(session && session.agentId);
|
||||
if (agentId === 'reasonix') return 'reasonix';
|
||||
var profileId = String(session && (session.profileId || session.profile) || '').trim();
|
||||
if (agentId === 'chat_only') profileId = pageAiNormalizeChatOnlyProfileId(profileId);
|
||||
return agentId + ':' + profileId;
|
||||
}
|
||||
|
||||
function pageAiSessionAgentLabel(session) {
|
||||
if (String(session && session.source || '') === 'board' || session && session.workerPresetId) {
|
||||
var worker = String(session && session.workerPresetId || 'mnote-page-ai-zcode').trim();
|
||||
var model = String(session && session.modelOverride || '').trim();
|
||||
return 'Agent Board / ' + worker + (model ? ' / ' + model : '');
|
||||
}
|
||||
var agentId = pageAiNormalizeAgentId(session && session.agentId);
|
||||
if (agentId === 'reasonix') return pageAiAgentRecord(agentId).label || 'Reasonix';
|
||||
var profileId = String(session && (session.profileId || session.profile) || '').trim();
|
||||
var profile = pageAiProfileRecordById(profileId) || { profileId: profileId, name: profileId };
|
||||
var chatOnlySpec = pageAiChatOnlyProfileSpec(profile);
|
||||
if (agentId === 'chat_only') {
|
||||
return 'ChatOnly / ' + (chatOnlySpec || pageAiDefaultChatOnlyProfileSpec()).label;
|
||||
}
|
||||
if (agentId === 'hermes') return 'Hermes / ' + pageAiProfileDisplayLabel(profile, profileId);
|
||||
return pageAiAgentRecord(agentId).label;
|
||||
}
|
||||
|
||||
function pageAiSessionPreviewText(session) {
|
||||
var preview = Array.isArray(session && session.messages) && session.messages.length
|
||||
? String(session.messages.slice(-1)[0].content || '')
|
||||
: String(session && (session.snippet || session.preview || '暂无消息') || '暂无消息');
|
||||
preview = preview.replace(/\s+/g, ' ').trim();
|
||||
var limit = 96;
|
||||
return preview.length > limit ? preview.slice(0, limit) + '…' : preview;
|
||||
}
|
||||
|
||||
function pageAiSessionAgentFilterOptions(rows) {
|
||||
var byValue = { all: '全部 agent' };
|
||||
pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession).forEach(function(session) {
|
||||
var value = pageAiSessionAgentFilterValue(session);
|
||||
byValue[value] = pageAiSessionAgentLabel(session);
|
||||
});
|
||||
return Object.keys(byValue).map(function(value) {
|
||||
return { value: value, label: byValue[value] };
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiSessionStatusFilterValue(session) {
|
||||
var status = String(session && session.status || '').trim();
|
||||
var mode = String(session && (session.runtimeMode || session.mode) || '').trim();
|
||||
if (pageUiState.pageAiActiveSessionId && session && session.id === pageUiState.pageAiActiveSessionId) return 'active';
|
||||
if (session && session.replaySeen) return 'replay_seen';
|
||||
if (mode === 'native_live' || mode === 'cold_resumed' || mode === 'replay_seen') return mode;
|
||||
if (['running', 'tool_calling', 'queued', 'pending', 'acp_pending'].indexOf(status) >= 0) return 'active';
|
||||
if (['failed', 'aborted', 'cancelled', 'canceled'].indexOf(status) >= 0) return 'failed';
|
||||
if (status === 'completed') return 'completed';
|
||||
return status || 'unknown';
|
||||
}
|
||||
|
||||
function pageAiSessionStatusLabel(value) {
|
||||
return {
|
||||
all: '全部状态',
|
||||
active: 'Active',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
native_live: 'Reasonix native-live',
|
||||
cold_resumed: 'Cold resumed',
|
||||
replay_seen: 'Replay seen',
|
||||
unknown: 'Unknown'
|
||||
}[value] || value;
|
||||
}
|
||||
|
||||
function pageAiSessionStatusFilterOptions(rows) {
|
||||
var byValue = {
|
||||
all: pageAiSessionStatusLabel('all'),
|
||||
active: pageAiSessionStatusLabel('active'),
|
||||
completed: pageAiSessionStatusLabel('completed'),
|
||||
failed: pageAiSessionStatusLabel('failed'),
|
||||
native_live: pageAiSessionStatusLabel('native_live'),
|
||||
cold_resumed: pageAiSessionStatusLabel('cold_resumed'),
|
||||
replay_seen: pageAiSessionStatusLabel('replay_seen')
|
||||
};
|
||||
pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession).forEach(function(session) {
|
||||
var value = pageAiSessionStatusFilterValue(session);
|
||||
byValue[value] = pageAiSessionStatusLabel(value);
|
||||
});
|
||||
return Object.keys(byValue).map(function(value) {
|
||||
return { value: value, label: byValue[value] };
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiVisibleHistorySession(session) {
|
||||
var source = String(session && session.source || '').trim();
|
||||
var status = String(session && session.status || '').trim();
|
||||
var messages = pageAiNormalizeArray(session && session.messages);
|
||||
return !(source === 'draft' && status === 'draft' && messages.length === 0);
|
||||
}
|
||||
|
||||
function pageAiFilteredHistoryRows(rows) {
|
||||
var filterValue = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all';
|
||||
var statusFilter = String(pageUiState.pageAiSessionStatusFilter || 'all').trim() || 'all';
|
||||
var normalized = pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession);
|
||||
return normalized.filter(function(session) {
|
||||
return (filterValue === 'all' || pageAiSessionAgentFilterValue(session) === filterValue)
|
||||
&& (statusFilter === 'all' || pageAiSessionStatusFilterValue(session) === statusFilter);
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiTimestamp(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
var parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function pageAiUsageSummary(usage) {
|
||||
if (!usage || typeof usage !== 'object') return '';
|
||||
var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
|
||||
var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens;
|
||||
var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
|
||||
var parts = [];
|
||||
if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used));
|
||||
if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size));
|
||||
if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output));
|
||||
return parts.join(' ') || '';
|
||||
}
|
||||
|
||||
return {
|
||||
pageAiProviderLabel,
|
||||
pageAiNormalizeArray,
|
||||
pageAiDefaultAcpRuntimes,
|
||||
pageAiNormalizeAcpRuntimes,
|
||||
pageAiUnwrapUpstream,
|
||||
pageAiProfileValue,
|
||||
pageAiCurrentProfile,
|
||||
pageAiRunProfile,
|
||||
pageAiMnoteToolModel,
|
||||
pageAiCurrentProfileRecord,
|
||||
pageAiChatOnlyProfileSpec,
|
||||
pageAiDefaultChatOnlyProfileSpec,
|
||||
pageAiNormalizeChatOnlyProfileId,
|
||||
pageAiProfileDisplayLabel,
|
||||
pageAiProfileRecordById,
|
||||
pageAiSessionAgentFilterValue,
|
||||
pageAiSessionAgentLabel,
|
||||
pageAiSessionPreviewText,
|
||||
pageAiSessionAgentFilterOptions,
|
||||
pageAiSessionStatusFilterValue,
|
||||
pageAiSessionStatusLabel,
|
||||
pageAiSessionStatusFilterOptions,
|
||||
pageAiFilteredHistoryRows,
|
||||
pageAiTimestamp,
|
||||
pageAiUsageSummary
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,863 +0,0 @@
|
||||
export function createSidebarPageAiSessionRuntime(context) {
|
||||
const {
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
documentRef,
|
||||
pageAiApplyRuntimeState,
|
||||
pageAiCurrentAgentId,
|
||||
pageAiCurrentProfile,
|
||||
pageAiErrorMessage,
|
||||
pageAiNormalizeAgentId,
|
||||
pageAiNormalizeArray,
|
||||
pageAiNormalizeChatOnlyProfileId,
|
||||
pageAiPermissionMessage,
|
||||
pageAiPreviewValue,
|
||||
pageAiRunProfile,
|
||||
pageAiSetActiveProfile,
|
||||
pageAiTimestamp,
|
||||
pageUiState,
|
||||
renderPageAiControls,
|
||||
renderPageAiConversation,
|
||||
resolveWorkspaceId,
|
||||
sessionStorageVersion,
|
||||
windowRef,
|
||||
} = context;
|
||||
const doc = documentRef || document;
|
||||
const win = windowRef || window;
|
||||
|
||||
function pageAiStorageKey() {
|
||||
return 'hermes_page_ai_session:' + currentDocumentId();
|
||||
}
|
||||
|
||||
function pageAiBackendSessionQuery(extra) {
|
||||
var params = new URLSearchParams();
|
||||
params.set('source', 'acp');
|
||||
params.set('workspaceId', resolveWorkspaceId(doc.body));
|
||||
params.set('documentId', currentDocumentId());
|
||||
params.set('profile', pageAiRunProfile());
|
||||
params.set('sourceKind', currentSourceKind());
|
||||
if (currentRootUri()) params.set('rootUri', currentRootUri());
|
||||
Object.keys(extra || {}).forEach(function(key) {
|
||||
var value = extra[key];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function pageAiNewSession(title) {
|
||||
var now = Date.now();
|
||||
var agentId = pageAiCurrentAgentId();
|
||||
var profile = agentId === 'chat_only' ? pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile()) : pageAiCurrentProfile();
|
||||
return {
|
||||
id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8),
|
||||
title: title || '新会话',
|
||||
agentId: agentId,
|
||||
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? profile : '',
|
||||
profile: profile,
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
source: 'draft',
|
||||
usage: null,
|
||||
status: 'draft',
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiNormalizeSessions(sessions) {
|
||||
return (Array.isArray(sessions) ? sessions : [])
|
||||
.slice(0, 20)
|
||||
.map(function(session) {
|
||||
var sessionStorage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
|
||||
var persistence = String(session && session.persistence || '').trim();
|
||||
var agentId = pageAiNormalizeAgentId(session && (session.agentId || session.agent_id));
|
||||
var profileId = String(session && (session.profileId || session.profile_id || '') || '').trim();
|
||||
var profile = String(session && session.profile || pageAiCurrentProfile()).trim() || 'default';
|
||||
if (!profileId && agentId !== 'reasonix') profileId = profile;
|
||||
if (agentId === 'chat_only') {
|
||||
profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile);
|
||||
profile = profileId;
|
||||
}
|
||||
return {
|
||||
schema: String(session && session.schema || '').trim(),
|
||||
id: String(session && session.id || '').trim() || pageAiNewSession().id,
|
||||
title: String(session && session.title || '').trim() || '新会话',
|
||||
agentId: agentId,
|
||||
profileId: profileId,
|
||||
profile: profile,
|
||||
acpRuntime: String(session && session.acpRuntime || 'reasonix').trim(),
|
||||
createdAt: pageAiTimestamp(session && session.createdAt),
|
||||
updatedAt: pageAiTimestamp(session && session.updatedAt),
|
||||
source: String(session && session.source || 'local').trim() || 'local',
|
||||
persistence: persistence,
|
||||
sessionStorage: sessionStorage,
|
||||
permissionLevel: String(session && (session.permissionLevel || session.permission_level) || '').trim(),
|
||||
shareId: String(session && (session.shareId || session.share_id) || '').trim(),
|
||||
acpSessionId: String(session && (session.acpSessionId || session.acp_session_id) || '').trim(),
|
||||
runId: String(session && (session.runId || session.run_id) || '').trim(),
|
||||
boardRunId: String(session && (session.boardRunId || session.board_run_id || session.runId || session.run_id) || '').trim(),
|
||||
workflowId: String(session && (session.workflowId || session.workflow_id) || '').trim(),
|
||||
workerPresetId: String(session && (session.workerPresetId || session.worker_preset_id) || '').trim(),
|
||||
modelOverride: String(session && (session.modelOverride || session.model_override) || '').trim(),
|
||||
receiptId: String(session && (session.receiptId || session.receipt_id) || '').trim(),
|
||||
boardRuns: session && session.boardRuns && typeof session.boardRuns === 'object' ? session.boardRuns : {},
|
||||
status: String(session && session.status || '').trim(),
|
||||
runtimeMode: String(session && (session.runtimeMode || session.runtime_mode) || '').trim(),
|
||||
replaySeen: Boolean(session && session.replaySeen),
|
||||
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
|
||||
preview: String(session && session.preview || '').trim(),
|
||||
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300).map(function(message) { return Object.assign({}, message); }) : []
|
||||
};
|
||||
})
|
||||
.sort(function(a, b) {
|
||||
if (a.id === pageUiState.pageAiActiveSessionId && b.id !== pageUiState.pageAiActiveSessionId) return -1;
|
||||
if (b.id === pageUiState.pageAiActiveSessionId && a.id !== pageUiState.pageAiActiveSessionId) return 1;
|
||||
return Number(b.updatedAt || 0) - Number(a.updatedAt || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiNormalizeBackendSessionRow(row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
var payload = row.payload && typeof row.payload === 'object' ? row.payload : {};
|
||||
var sessionId = String(row.sessionId || row.session_id || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var title = String(row.title || payload.title || payload.message || '').trim();
|
||||
if (title.length > 28) title = title.slice(0, 28) + '…';
|
||||
var persistence = String(row.persistence || payload.persistence || '').trim();
|
||||
var sessionStorage = String(row.sessionStorage || row.session_storage || payload.sessionStorage || '').trim();
|
||||
var runtime = row.runtime && typeof row.runtime === 'object' ? row.runtime : {};
|
||||
var status = String(row.status || runtime.status || '').trim();
|
||||
var hasConversationSignal = String(payload.message || payload.input || row.snippet || '').trim()
|
||||
|| pageAiNormalizeArray(row.messages).length > 0;
|
||||
if (status === 'session.created' && !hasConversationSignal) return null;
|
||||
var agentId = pageAiNormalizeAgentId(row.agentId || row.agent_id || payload.agentId || payload.agent_id);
|
||||
var profileId = String(row.profileId || row.profile_id || payload.profileId || payload.profile_id || '').trim();
|
||||
var profile = String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default';
|
||||
if (!profileId && agentId !== 'reasonix') profileId = profile;
|
||||
if (agentId === 'chat_only') {
|
||||
profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile);
|
||||
profile = profileId;
|
||||
}
|
||||
if (!sessionStorage && persistence === 'convex_acp_runtime_store') sessionStorage = 'cloud';
|
||||
if (!sessionStorage && persistence === 'local_ai_session_jsonl') sessionStorage = String(row.shareId || payload.shareId || '').trim() ? 'local_shared' : 'local_private';
|
||||
return {
|
||||
id: sessionId,
|
||||
title: title || '当前页问答',
|
||||
agentId: agentId,
|
||||
profileId: profileId,
|
||||
profile: profile,
|
||||
acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(),
|
||||
createdAt: pageAiTimestamp(row.createdAt || row.created_at),
|
||||
updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at),
|
||||
source: sessionStorage === 'local_private' || sessionStorage === 'local_shared' ? 'local' : 'acp',
|
||||
persistence: persistence,
|
||||
sessionStorage: sessionStorage,
|
||||
permissionLevel: String(row.permissionLevel || row.permission_level || payload.permissionLevel || '').trim(),
|
||||
shareId: String(row.shareId || row.share_id || payload.shareId || '').trim(),
|
||||
acpSessionId: String(row.acpSessionId || row.acp_session_id || payload.acpSessionId || payload.acp_session_id || '').trim(),
|
||||
runId: String(row.runId || row.run_id || '').trim(),
|
||||
status: status,
|
||||
runtimeMode: String(row.runtimeMode || row.runtime_mode || runtime.mode || payload.reasonixSessionMode || '').trim(),
|
||||
replaySeen: Boolean(row.replaySeen || row.replay_seen || runtime.replaySeen || payload.replay === true),
|
||||
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
|
||||
preview: String(payload.message || row.snippet || '').trim(),
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiMergeSessions(localSessions, backendSessions) {
|
||||
var byId = {};
|
||||
pageAiNormalizeSessions(localSessions).forEach(function(session) {
|
||||
byId[session.id] = session;
|
||||
});
|
||||
pageAiNormalizeSessions(backendSessions).forEach(function(session) {
|
||||
var existing = byId[session.id];
|
||||
byId[session.id] = Object.assign({}, existing || {}, session, {
|
||||
messages: session.messages.length ? session.messages : (existing && existing.messages || [])
|
||||
});
|
||||
});
|
||||
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
|
||||
}
|
||||
|
||||
function pageAiDedupeSessions(sessions) {
|
||||
var byId = {};
|
||||
var ordered = [];
|
||||
pageAiNormalizeSessions(sessions).forEach(function(session) {
|
||||
var id = String(session && session.id || '').trim();
|
||||
if (!id || byId[id]) return;
|
||||
byId[id] = true;
|
||||
ordered.push(session);
|
||||
});
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function pageAiSessionStorageLabel(session) {
|
||||
var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
|
||||
var persistence = String(session && session.persistence || '').trim();
|
||||
if (storage === 'local_shared') return '共享会话';
|
||||
if (storage === 'local_private') return '本地私有';
|
||||
if (storage === 'sqlite_control_plane' || persistence === 'sqlite_acp_runtime_store') return '账号会话';
|
||||
if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话';
|
||||
if (persistence === 'local_ai_session_jsonl') return '本地私有';
|
||||
return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有';
|
||||
}
|
||||
|
||||
function pageAiLoadSessions() {
|
||||
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
|
||||
try {
|
||||
var raw = win.localStorage.getItem(pageAiStorageKey());
|
||||
var parsed = raw ? JSON.parse(raw) : null;
|
||||
var activeProfile = String(parsed && parsed.activeProfileName || '').trim();
|
||||
var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim();
|
||||
var storageVersion = Number(parsed && parsed.version || 0);
|
||||
if (storageVersion >= sessionStorageVersion && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
|
||||
if (activeProfile) pageAiSetActiveProfile(activeProfile);
|
||||
var storedSessions = pageAiNormalizeSessions(parsed && parsed.sessions);
|
||||
if (storageVersion >= sessionStorageVersion && storedSessions.length) {
|
||||
pageUiState.pageAiSessions = storedSessions;
|
||||
var activeSessionId = String(parsed && parsed.activeSessionId || '').trim();
|
||||
pageUiState.pageAiActiveSessionId = storedSessions.some(function(session) { return session.id === activeSessionId; }) ? activeSessionId : storedSessions[0].id;
|
||||
var active = pageAiCurrentSession();
|
||||
pageUiState.pageAiMessages = active && Array.isArray(active.messages) ? active.messages.slice() : [];
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
var fresh = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fresh];
|
||||
pageUiState.pageAiActiveSessionId = fresh.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
|
||||
async function pageAiLoadBackendSessions() {
|
||||
var response = await fetch('/api/page-ai/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status));
|
||||
}
|
||||
var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean);
|
||||
var draftSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(session) {
|
||||
var id = String(session && session.id || '').trim();
|
||||
return id && !id.startsWith('mnote_')
|
||||
&& (id === pageUiState.pageAiActiveSessionId || (Array.isArray(session.messages) && session.messages.length > 0));
|
||||
});
|
||||
if (!backendSessions.length) {
|
||||
if (!draftSessions.length && !pageUiState.pageAiActiveSessionId) {
|
||||
var fresh = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = [fresh];
|
||||
pageUiState.pageAiActiveSessionId = fresh.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
}
|
||||
pageUiState.pageAiSessionError = '';
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return [];
|
||||
}
|
||||
pageUiState.pageAiSessions = pageAiDedupeSessions(backendSessions.concat(draftSessions));
|
||||
if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) {
|
||||
pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id;
|
||||
}
|
||||
var active = pageAiCurrentSession();
|
||||
if (active) {
|
||||
if (active.profile) pageAiSetActiveProfile(active.profile);
|
||||
if (!pageUiState.pageAiAcpRuntime && active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime;
|
||||
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages;
|
||||
}
|
||||
pageUiState.pageAiSessionError = '';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return backendSessions;
|
||||
}
|
||||
|
||||
function pageAiMessageFromRuntimeEvent(event) {
|
||||
if (!event || typeof event !== 'object') return null;
|
||||
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
|
||||
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
|
||||
if (payload && (payload.source === 'adapter_replay' || payload.replay === true)) return null;
|
||||
if (eventType === 'message.delta') {
|
||||
var delta = String(payload.delta || payload.text || payload.output_text || '');
|
||||
return delta ? { role: 'assistant', content: delta } : null;
|
||||
}
|
||||
if (eventType === 'thought.delta') {
|
||||
var thought = String(payload.delta || payload.text || '').trim();
|
||||
return thought ? { role: 'assistant', kind: 'thought', content: thought } : null;
|
||||
}
|
||||
if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') {
|
||||
var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim();
|
||||
var rawLocations = payload.locations;
|
||||
return {
|
||||
role: 'tool',
|
||||
content: toolName,
|
||||
toolName: toolName,
|
||||
toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''),
|
||||
toolKind: String(payload.kind || ''),
|
||||
status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'),
|
||||
argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input),
|
||||
resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error),
|
||||
locations: Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [],
|
||||
traceId: String(payload.traceId || payload.trace_id || ''),
|
||||
auditId: String(payload.auditId || payload.audit_id || '')
|
||||
};
|
||||
}
|
||||
if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') {
|
||||
return pageAiPermissionMessage(payload, eventType);
|
||||
}
|
||||
if (eventType === 'run.completed') {
|
||||
var output = String(payload.output || payload.text || '').trim();
|
||||
return output ? { role: 'assistant', content: output } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pageAiApplyBackendSessionDetail(payload) {
|
||||
var sessionPayload = payload && payload.session ? payload.session : {};
|
||||
var runs = pageAiNormalizeArray(sessionPayload.runs);
|
||||
var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null;
|
||||
var events = pageAiNormalizeArray(payload && payload.events);
|
||||
var storedMessages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) {
|
||||
return {
|
||||
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
|
||||
content: String(message.content || '')
|
||||
};
|
||||
}).filter(function(message) { return message.content; });
|
||||
var eventsByRunId = {};
|
||||
events.forEach(function(event) {
|
||||
var runId = String(event && (event.runId || event.run_id) || '').trim();
|
||||
if (!runId) return;
|
||||
if (!eventsByRunId[runId]) eventsByRunId[runId] = [];
|
||||
eventsByRunId[runId].push(event);
|
||||
});
|
||||
var messages = [];
|
||||
if (runs.length) {
|
||||
runs.slice().reverse().forEach(function(run) {
|
||||
var runPayload = run && run.payload && typeof run.payload === 'object' ? run.payload : {};
|
||||
var userMessage = String(runPayload.message || runPayload.input || '').trim();
|
||||
if (userMessage) messages.push({ role: 'user', content: userMessage });
|
||||
var runId = String(run && (run.runId || run.run_id) || '').trim();
|
||||
var assistantDelta = '';
|
||||
var completedOutput = '';
|
||||
function flushAssistantDelta() {
|
||||
var content = assistantDelta.trim();
|
||||
if (content) messages.push({ role: 'assistant', content: content });
|
||||
assistantDelta = '';
|
||||
}
|
||||
pageAiNormalizeArray(eventsByRunId[runId]).forEach(function(event) {
|
||||
var message = pageAiMessageFromRuntimeEvent(event);
|
||||
if (!message) return;
|
||||
if (message.role === 'assistant' && !message.kind) {
|
||||
var eventType = String(event && (event.eventType || event.event_type || event.event) || '').trim();
|
||||
if (eventType === 'run.completed') {
|
||||
completedOutput = String(message.content || '').trim();
|
||||
return;
|
||||
}
|
||||
assistantDelta += String(message.content || '');
|
||||
return;
|
||||
}
|
||||
flushAssistantDelta();
|
||||
messages.push(message);
|
||||
});
|
||||
flushAssistantDelta();
|
||||
if (completedOutput && !messages.some(function(message) {
|
||||
return message.role === 'assistant' && String(message.content || '').trim() === completedOutput;
|
||||
})) {
|
||||
messages.push({ role: 'assistant', content: completedOutput });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!messages.length) messages = storedMessages;
|
||||
var acpSessionId = '';
|
||||
events.forEach(function(event) {
|
||||
var eventType = String(event && (event.eventType || event.event_type || event.event) || '').trim();
|
||||
var payload = event && event.payload && typeof event.payload === 'object' ? event.payload : event;
|
||||
if (eventType === 'session.info.updated') {
|
||||
var nextAcpSessionId = String(payload && (payload.acpSessionId || payload.acp_session_id) || '').trim();
|
||||
if (nextAcpSessionId) acpSessionId = nextAcpSessionId;
|
||||
}
|
||||
});
|
||||
var current = pageAiCurrentSession();
|
||||
if (latest && current) {
|
||||
Object.assign(current, latest);
|
||||
}
|
||||
if (current) {
|
||||
current.messages = messages.slice(-300);
|
||||
current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now());
|
||||
if (acpSessionId) current.acpSessionId = acpSessionId;
|
||||
if (latest && latest.usage) current.usage = latest.usage;
|
||||
}
|
||||
pageAiApplyRuntimeState(payload && payload.runtime);
|
||||
pageUiState.pageAiMessages = messages.slice(-300);
|
||||
pageAiPersistSessions();
|
||||
}
|
||||
|
||||
async function pageAiLoadBackendSessionDetail(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_detail_failed_' + response.status));
|
||||
}
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function pageAiSearchBackendSessions(query) {
|
||||
var q = String(query || '').trim();
|
||||
pageUiState.pageAiSessionSearchQuery = q;
|
||||
if (!q) {
|
||||
pageUiState.pageAiSessionSearchResults = [];
|
||||
renderPageAiConversation();
|
||||
return [];
|
||||
}
|
||||
var response = await fetch('/api/page-ai/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_search_failed_' + response.status));
|
||||
}
|
||||
pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) {
|
||||
var normalized = pageAiNormalizeBackendSessionRow(row) || {};
|
||||
normalized.snippet = String(row && row.snippet || normalized.preview || '').trim();
|
||||
return normalized;
|
||||
}).filter(function(row) { return row.id; });
|
||||
renderPageAiConversation();
|
||||
return pageUiState.pageAiSessionSearchResults;
|
||||
}
|
||||
|
||||
async function pageAiExportBackendSession(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/export?' + pageAiBackendSessionQuery({ limit: 200 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_export_failed_' + response.status));
|
||||
}
|
||||
var exported = payload.export && typeof payload.export === 'object' ? payload.export : {};
|
||||
var session = pageAiFindSessionById(sessionId) || pageAiCurrentSession();
|
||||
if (session) {
|
||||
session.exportedAt = Date.now();
|
||||
session.exportMarkdown = String(exported.markdown || '');
|
||||
session.updatedAt = Date.now();
|
||||
}
|
||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-exported', sessionId);
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return payload;
|
||||
}
|
||||
|
||||
function pageAiPersistSessions() {
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||
try {
|
||||
win.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
|
||||
version: sessionStorageVersion,
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId,
|
||||
activeProfileName: pageAiCurrentProfile(),
|
||||
activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
sessions: pageAiNormalizeArray(pageUiState.pageAiSessions).slice(0, 20)
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function pageAiEnsureHermesSession(forceCreate) {
|
||||
pageAiLoadSessions();
|
||||
var current = pageAiCurrentSession();
|
||||
var runProfile = pageAiRunProfile();
|
||||
if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === runProfile) return current;
|
||||
var response = await fetch('/api/page-ai/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(doc.body),
|
||||
documentId: currentDocumentId(),
|
||||
sourceKind: currentSourceKind(),
|
||||
rootUri: currentRootUri(),
|
||||
traceId: 'page-ai-' + Date.now().toString(36),
|
||||
profile: runProfile,
|
||||
agentId: pageAiCurrentAgentId(),
|
||||
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '',
|
||||
title: current && current.title ? current.title : '当前页问答'
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'hermes_session_failed_' + response.status));
|
||||
}
|
||||
var session = {
|
||||
id: String(payload.sessionId || '').trim(),
|
||||
title: String(payload.title || '当前页问答'),
|
||||
agentId: pageAiCurrentAgentId(),
|
||||
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '',
|
||||
profile: String(payload.profile || runProfile).trim() || 'default',
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
persistence: String(payload.persistence || '').trim(),
|
||||
sessionStorage: String(payload.sessionStorage || '').trim(),
|
||||
permissionLevel: String(payload.permissionLevel || '').trim(),
|
||||
shareId: String(payload.shareId || '').trim(),
|
||||
acpSessionId: String(payload.acpSessionId || payload.acp_session_id || '').trim(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messages: pageUiState.pageAiMessages.slice()
|
||||
};
|
||||
var previousSessionId = String(current && current.id || '').trim();
|
||||
var retainedSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(item) {
|
||||
var itemId = String(item && item.id || '').trim();
|
||||
return itemId && itemId !== session.id && itemId !== previousSessionId;
|
||||
});
|
||||
pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(retainedSessions));
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
return session;
|
||||
}
|
||||
|
||||
async function pageAiRestoreHermesSession() {
|
||||
var current = pageAiCurrentSession();
|
||||
if (!current || !String(current.id || '').startsWith('mnote_')) return;
|
||||
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (payload && payload.persistence === 'convex_acp_runtime_store') {
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
|
||||
var messages = session && Array.isArray(session.messages) ? session.messages : [];
|
||||
pageAiApplyRuntimeState(payload && payload.runtime);
|
||||
if (session && (session.profile || session.profileName)) {
|
||||
current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile());
|
||||
}
|
||||
if (!messages.length) {
|
||||
renderPageAiControls();
|
||||
return;
|
||||
}
|
||||
pageUiState.pageAiMessages = messages.slice(-300).map(function(message) {
|
||||
return {
|
||||
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
|
||||
content: String(message.content || '')
|
||||
};
|
||||
});
|
||||
current.messages = pageUiState.pageAiMessages.slice();
|
||||
current.updatedAt = Date.now();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiCurrentSession() {
|
||||
return pageUiState.pageAiSessions.find(function(session) {
|
||||
return session.id === pageUiState.pageAiActiveSessionId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiSelectedSessionIds() {
|
||||
if (!pageUiState.pageAiSelectedSessionIds || typeof pageUiState.pageAiSelectedSessionIds !== 'object') {
|
||||
pageUiState.pageAiSelectedSessionIds = {};
|
||||
}
|
||||
return pageUiState.pageAiSelectedSessionIds;
|
||||
}
|
||||
|
||||
function pageAiSessionSelected(sessionId) {
|
||||
sessionId = String(sessionId || '').trim();
|
||||
return Boolean(sessionId && pageAiSelectedSessionIds()[sessionId]);
|
||||
}
|
||||
|
||||
function pageAiSelectedSessionList() {
|
||||
var selected = pageAiSelectedSessionIds();
|
||||
return Object.keys(selected).filter(function(sessionId) {
|
||||
return selected[sessionId] === true;
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiSelectedSessionCount() {
|
||||
return pageAiSelectedSessionList().length;
|
||||
}
|
||||
|
||||
function pageAiToggleSessionSelection(sessionId, selected) {
|
||||
sessionId = String(sessionId || '').trim();
|
||||
if (!sessionId) return;
|
||||
var selectedMap = Object.assign({}, pageAiSelectedSessionIds());
|
||||
if (selected === false) {
|
||||
delete selectedMap[sessionId];
|
||||
} else {
|
||||
selectedMap[sessionId] = true;
|
||||
}
|
||||
pageUiState.pageAiSelectedSessionIds = selectedMap;
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiClearSessionSelection() {
|
||||
pageUiState.pageAiSelectedSessionIds = {};
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSyncCurrentSessionMessages() {
|
||||
var session = pageAiCurrentSession();
|
||||
if (!session) return;
|
||||
var runProfile = pageAiRunProfile();
|
||||
session.messages = Array.isArray(pageUiState.pageAiMessages) ? pageUiState.pageAiMessages.slice(-300) : [];
|
||||
session.agentId = pageAiCurrentAgentId();
|
||||
session.profileId = pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '';
|
||||
session.profile = runProfile;
|
||||
session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix';
|
||||
if (String(session.source || '') === 'board') {
|
||||
session.schema = session.schema || 'mnote.page_ai.session.v2';
|
||||
session.workerPresetId = pageUiState.pageAiBoardWorkerId || session.workerPresetId || '';
|
||||
session.workflowId = pageUiState.pageAiBoardWorkflowId || session.workflowId || '';
|
||||
session.modelOverride = pageUiState.pageAiBoardModelOverride || session.modelOverride || '';
|
||||
}
|
||||
session.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
function pageAiSetActiveSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
if (session.agentId) pageUiState.pageAiAgentId = pageAiNormalizeAgentId(session.agentId);
|
||||
if (session.source === 'board') {
|
||||
if (session.workerPresetId) pageUiState.pageAiBoardWorkerId = session.workerPresetId;
|
||||
if (session.workflowId) pageUiState.pageAiBoardWorkflowId = session.workflowId;
|
||||
if (session.modelOverride) pageUiState.pageAiBoardModelOverride = session.modelOverride;
|
||||
if (session.boardRuns && typeof session.boardRuns === 'object') {
|
||||
pageUiState.pageAiBoardRunDetails = Object.assign({}, pageUiState.pageAiBoardRunDetails || {}, session.boardRuns);
|
||||
}
|
||||
}
|
||||
if (session.acpRuntime) pageUiState.pageAiAcpRuntime = session.acpRuntime;
|
||||
if (session.profileId || session.profile) pageAiSetActiveProfile(session.profileId || session.profile);
|
||||
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) {
|
||||
void pageAiLoadBackendSessionDetail(session.id).catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiStartNewSession() {
|
||||
var session = pageAiNewSession();
|
||||
pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(pageUiState.pageAiSessions));
|
||||
pageUiState.pageAiActiveSessionId = session.id;
|
||||
pageUiState.pageAiMessages = [];
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
async function pageAiRenameBackendSession(sessionId) {
|
||||
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
|
||||
if (!session) return;
|
||||
var title = win.prompt('重命名 AI 会话', session.title || '当前页问答');
|
||||
if (title === null) return;
|
||||
title = String(title || '').trim();
|
||||
if (!title) return;
|
||||
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||||
body: JSON.stringify({ title: title })
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status));
|
||||
}
|
||||
var nextTitle = String((payload.result && payload.result.title) || title);
|
||||
[pageUiState.pageAiSessions, pageUiState.pageAiSessionSearchResults].forEach(function(list) {
|
||||
pageAiNormalizeArray(list).forEach(function(item) {
|
||||
if (String(item && item.id || '').trim() === sessionId) {
|
||||
item.title = nextTitle;
|
||||
item.updatedAt = Date.now();
|
||||
}
|
||||
});
|
||||
});
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSessionRequiresBackendDelete(session) {
|
||||
var id = String(session && session.id || '').trim();
|
||||
var source = String(session && session.source || '').trim();
|
||||
return Boolean(id && (
|
||||
id.startsWith('mnote_')
|
||||
|| source === 'acp'
|
||||
|| String(session && session.persistence || '').trim()
|
||||
|| String(session && (session.sessionStorage || session.session_storage) || '').trim()
|
||||
));
|
||||
}
|
||||
|
||||
function pageAiFindSessionById(sessionId) {
|
||||
sessionId = String(sessionId || '').trim();
|
||||
if (!sessionId) return null;
|
||||
return pageAiNormalizeArray(pageUiState.pageAiSessions).find(function(item) {
|
||||
return String(item && item.id || '').trim() === sessionId;
|
||||
}) || pageAiNormalizeArray(pageUiState.pageAiSessionSearchResults).find(function(item) {
|
||||
return String(item && item.id || '').trim() === sessionId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function pageAiApplyDeletedSessionIds(sessionIds) {
|
||||
var deleted = {};
|
||||
pageAiNormalizeArray(sessionIds).forEach(function(sessionId) {
|
||||
sessionId = String(sessionId || '').trim();
|
||||
if (sessionId) deleted[sessionId] = true;
|
||||
});
|
||||
pageUiState.pageAiSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(item) {
|
||||
return !deleted[String(item && item.id || '').trim()];
|
||||
});
|
||||
pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(pageUiState.pageAiSessionSearchResults).filter(function(item) {
|
||||
return !deleted[String(item && item.id || '').trim()];
|
||||
});
|
||||
var selected = Object.assign({}, pageAiSelectedSessionIds());
|
||||
Object.keys(deleted).forEach(function(sessionId) { delete selected[sessionId]; });
|
||||
pageUiState.pageAiSelectedSessionIds = selected;
|
||||
if (deleted[pageUiState.pageAiActiveSessionId]) {
|
||||
var next = pageUiState.pageAiSessions[0] || pageAiNewSession();
|
||||
if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next];
|
||||
pageUiState.pageAiActiveSessionId = next.id;
|
||||
pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : [];
|
||||
}
|
||||
}
|
||||
|
||||
async function pageAiDeleteBackendSessions(sessionIds) {
|
||||
var ids = pageAiNormalizeArray(sessionIds).map(function(sessionId) {
|
||||
return String(sessionId || '').trim();
|
||||
}).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
var sessions = ids.map(pageAiFindSessionById).filter(Boolean);
|
||||
if (!sessions.length) return;
|
||||
var confirmText = sessions.length === 1
|
||||
? '确定删除 AI 会话“' + (sessions[0].title || sessions[0].id) + '”吗?仅删除 MNote 历史,不删除外部 Hermes/provider 会话。'
|
||||
: '确定删除选中的 ' + String(sessions.length) + ' 个 AI 会话吗?仅删除 MNote 历史,不删除外部 Hermes/provider 会话。';
|
||||
if (!win.confirm(confirmText)) return;
|
||||
for (var index = 0; index < sessions.length; index += 1) {
|
||||
var session = sessions[index];
|
||||
if (!pageAiSessionRequiresBackendDelete(session)) continue;
|
||||
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(session.id) + '?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'DELETE',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_delete_failed_' + response.status));
|
||||
}
|
||||
}
|
||||
pageAiApplyDeletedSessionIds(sessions.map(function(session) { return session.id; }));
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
async function pageAiDeleteBackendSession(sessionId) {
|
||||
return pageAiDeleteBackendSessions([sessionId]);
|
||||
}
|
||||
|
||||
async function pageAiDeleteSelectedBackendSessions() {
|
||||
return pageAiDeleteBackendSessions(pageAiSelectedSessionList());
|
||||
}
|
||||
|
||||
async function pageAiResumeBackendSession(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return;
|
||||
pageAiSetActiveSession(sessionId);
|
||||
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), {
|
||||
method: 'POST',
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_resume_failed_' + response.status));
|
||||
}
|
||||
pageAiApplyBackendSessionDetail(payload);
|
||||
pageUiState.pageAiPage = 'chat';
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
async function pageAiCheckActiveRun(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/active-run?' + pageAiBackendSessionQuery({}), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'active_run_failed_' + response.status));
|
||||
}
|
||||
if (!payload.active || !payload.run) return null;
|
||||
var run = payload.run;
|
||||
var hostRunId = String(run.hostRunId || run.runId || '').trim();
|
||||
var status = String(run.status || '').trim();
|
||||
var current = pageAiCurrentSession();
|
||||
if (current && current.id === sessionId) {
|
||||
current.runId = hostRunId;
|
||||
current.status = status || current.status || 'running';
|
||||
current.updatedAt = Date.now();
|
||||
}
|
||||
pageAiApplyRuntimeState(Object.assign({}, run.runtime || {}, {
|
||||
status: status || 'running',
|
||||
runId: hostRunId
|
||||
}));
|
||||
doc.documentElement.setAttribute('data-mnote-page-ai-active-run-checked', 'true');
|
||||
if (hostRunId) doc.documentElement.setAttribute('data-mnote-page-ai-active-host-run-id', hostRunId);
|
||||
pageAiPersistSessions();
|
||||
renderPageAiControls();
|
||||
return run;
|
||||
}
|
||||
|
||||
return {
|
||||
pageAiStorageKey,
|
||||
pageAiBackendSessionQuery,
|
||||
pageAiNewSession,
|
||||
pageAiNormalizeSessions,
|
||||
pageAiNormalizeBackendSessionRow,
|
||||
pageAiMergeSessions,
|
||||
pageAiSessionStorageLabel,
|
||||
pageAiLoadSessions,
|
||||
pageAiLoadBackendSessions,
|
||||
pageAiMessageFromRuntimeEvent,
|
||||
pageAiApplyBackendSessionDetail,
|
||||
pageAiLoadBackendSessionDetail,
|
||||
pageAiSearchBackendSessions,
|
||||
pageAiPersistSessions,
|
||||
pageAiEnsureHermesSession,
|
||||
pageAiRestoreHermesSession,
|
||||
pageAiCurrentSession,
|
||||
pageAiSessionSelected,
|
||||
pageAiToggleSessionSelection,
|
||||
pageAiClearSessionSelection,
|
||||
pageAiSelectedSessionCount,
|
||||
pageAiSyncCurrentSessionMessages,
|
||||
pageAiSetActiveSession,
|
||||
pageAiStartNewSession,
|
||||
pageAiRenameBackendSession,
|
||||
pageAiExportBackendSession,
|
||||
pageAiDeleteBackendSession,
|
||||
pageAiDeleteSelectedBackendSessions,
|
||||
pageAiResumeBackendSession,
|
||||
pageAiCheckActiveRun
|
||||
};
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
export function createSidebarPageAiSkillRuntime(context) {
|
||||
const {
|
||||
pageAiCurrentAgentId,
|
||||
pageAiCurrentProfile,
|
||||
pageAiLoadSkills,
|
||||
pageAiNormalizeArray,
|
||||
pageAiPersistAiPreference,
|
||||
pageAiPersistRawAiPreference,
|
||||
pageAiProfileValue,
|
||||
pageUiState,
|
||||
renderPageAiControls,
|
||||
} = context;
|
||||
|
||||
function pageAiSkillSourceOptions() {
|
||||
var options = [
|
||||
{ value: 'mnote', group: 'mnote', label: 'MNote 公共能力', profile: '' },
|
||||
{ value: 'reasonix', group: 'reasonix', label: 'Reasonix skill(查看)', profile: '', readonly: true }
|
||||
];
|
||||
pageAiNormalizeArray(pageUiState.pageAiProfiles).forEach(function(profile) {
|
||||
var profileId = pageAiProfileValue(profile);
|
||||
if (!profileId || pageAiProfileIsChatOnlySkillSource(profile)) return;
|
||||
var label = profile.kind === 'shared' ? 'Hermes 共享 skill(查看)' : 'Hermes skill(查看)';
|
||||
var alias = String(profile.alias || profile.displayName || '').trim();
|
||||
options.push({
|
||||
value: 'hermes:' + profileId,
|
||||
group: 'hermes',
|
||||
profile: profileId,
|
||||
label: alias && alias !== label ? label + ' · ' + alias : label,
|
||||
readonly: true
|
||||
});
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
function pageAiProfileIsChatOnlySkillSource(profile) {
|
||||
var profileId = String(pageAiProfileValue(profile) || '').trim().toLowerCase();
|
||||
var baseProfile = String(profile && profile.baseProfile || '').trim().toLowerCase();
|
||||
var label = String(profile && (profile.displayName || profile.alias || profile.name) || '').trim().toLowerCase();
|
||||
var providerKind = String(profile && profile.providerKind || '').trim().toLowerCase();
|
||||
return profileId.indexOf('chat') >= 0
|
||||
|| baseProfile.indexOf('chat') >= 0
|
||||
|| label.indexOf('chat') >= 0
|
||||
|| providerKind.indexOf('chat') >= 0
|
||||
|| profileId === 'shared_lite'
|
||||
|| baseProfile === 'lite';
|
||||
}
|
||||
|
||||
function pageAiDefaultSkillSource() {
|
||||
return 'mnote';
|
||||
}
|
||||
|
||||
function pageAiNormalizeSkillSource(source) {
|
||||
var value = String(source || '').trim();
|
||||
var options = pageAiSkillSourceOptions();
|
||||
if (options.some(function(option) { return option.value === value; })) return value;
|
||||
if (value === 'hermes') return 'hermes:' + pageAiCurrentProfile();
|
||||
if (value === 'mnote_builtin') return 'mnote';
|
||||
var fallback = pageAiDefaultSkillSource();
|
||||
if (options.some(function(option) { return option.value === fallback; })) return fallback;
|
||||
return options.length ? options[0].value : 'mnote';
|
||||
}
|
||||
|
||||
function pageAiCurrentSkillSource() {
|
||||
var normalized = pageAiNormalizeSkillSource(pageUiState.pageAiActiveSkillSource);
|
||||
pageUiState.pageAiActiveSkillSource = normalized;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pageAiSetSkillSource(source) {
|
||||
var normalized = pageAiNormalizeSkillSource(source);
|
||||
pageUiState.pageAiActiveSkillSource = normalized;
|
||||
pageAiPersistAiPreference('skills.active_source', normalized);
|
||||
pageUiState.pageAiSkills = { categories: [], archived: [] };
|
||||
pageUiState.pageAiSkillError = '';
|
||||
void pageAiLoadSkills();
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSkillSourceParts(source) {
|
||||
var normalized = pageAiNormalizeSkillSource(source);
|
||||
if (normalized.indexOf('hermes:') === 0) {
|
||||
return { group: 'hermes', profile: normalized.slice('hermes:'.length), source: normalized };
|
||||
}
|
||||
if (normalized === 'reasonix') return { group: 'reasonix', profile: '', source: normalized };
|
||||
return { group: 'mnote', profile: '', source: 'mnote' };
|
||||
}
|
||||
|
||||
function pageAiCurrentSkillSourceLabel() {
|
||||
var source = pageAiCurrentSkillSource();
|
||||
var option = pageAiSkillSourceOptions().find(function(item) { return item.value === source; });
|
||||
return option ? option.label : source;
|
||||
}
|
||||
|
||||
function pageAiSkillOriginLabel(skill) {
|
||||
var origin = String(skill && skill.origin || '').trim();
|
||||
if (origin === 'generated' || String(skill && skill.createdBy || '') === 'agent') return '生成';
|
||||
if (origin === 'installed') return '安装';
|
||||
if (origin === 'builtin') return '内置';
|
||||
if (origin === 'copied') return '本地';
|
||||
var source = String(skill && skill.source || '').trim();
|
||||
if (source === 'hub') return '安装';
|
||||
if (source === 'builtin') return '内置';
|
||||
if (source === 'reasonix') {
|
||||
if (origin === 'project') return 'Reasonix 项目';
|
||||
if (origin === 'global') return 'Reasonix 全局';
|
||||
return 'Reasonix';
|
||||
}
|
||||
return '本地';
|
||||
}
|
||||
|
||||
function pageAiSkillPreferenceKey(group, profile) {
|
||||
var groupName = String(group || '').trim();
|
||||
if (groupName === 'mnote') return 'ai.agent.mnote_builtin.skills.enabled';
|
||||
if (groupName === 'reasonix') return 'ai.agent.reasonix.skills.enabled';
|
||||
if (groupName === 'hermes') {
|
||||
var nextProfile = String(profile || pageAiCurrentProfile() || 'default').trim() || 'default';
|
||||
return 'ai.agent.hermes.profile.' + nextProfile + '.skills.enabled';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pageAiSkillPreferenceTable(group, profile) {
|
||||
var key = pageAiSkillPreferenceKey(group, profile);
|
||||
var preferences = pageUiState.pageAiSkillPreferences || {};
|
||||
var value = preferences[key];
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
||||
return {};
|
||||
}
|
||||
|
||||
function pageAiHermesHideBuiltinPreferenceKey(profile) {
|
||||
return 'ai.agent.hermes.skills.hide_builtin';
|
||||
}
|
||||
|
||||
function pageAiHideHermesBuiltinSkills(profile) {
|
||||
var preferences = pageUiState.pageAiSkillPreferences || {};
|
||||
return preferences[pageAiHermesHideBuiltinPreferenceKey(profile)] === true;
|
||||
}
|
||||
|
||||
function pageAiReasonixMemoryEnabled() {
|
||||
var preferences = pageUiState.pageAiSkillPreferences || {};
|
||||
return preferences['ai.agent.reasonix.memory_enabled'] === true;
|
||||
}
|
||||
|
||||
function pageAiSetReasonixMemoryEnabled(enabled) {
|
||||
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
|
||||
preferences['ai.agent.reasonix.memory_enabled'] = Boolean(enabled);
|
||||
pageUiState.pageAiSkillPreferences = preferences;
|
||||
pageAiPersistRawAiPreference('ai.agent.reasonix.memory_enabled', Boolean(enabled));
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSetHideHermesBuiltinSkills(enabled, profile) {
|
||||
var key = pageAiHermesHideBuiltinPreferenceKey(profile);
|
||||
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
|
||||
preferences[key] = Boolean(enabled);
|
||||
pageUiState.pageAiSkillPreferences = preferences;
|
||||
pageAiPersistRawAiPreference(key, Boolean(enabled));
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSkillIsBuiltin(skill) {
|
||||
var origin = String(skill && skill.origin || '').trim();
|
||||
var source = String(skill && skill.source || '').trim();
|
||||
return origin === 'builtin' || source === 'builtin';
|
||||
}
|
||||
|
||||
function pageAiToggleableSkillEntries(group, profile) {
|
||||
var catalogKey = group === 'hermes' && profile ? 'hermes:' + profile : group;
|
||||
var catalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[catalogKey]
|
||||
? pageUiState.pageAiSkillCatalogs[catalogKey]
|
||||
: pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[group]
|
||||
? pageUiState.pageAiSkillCatalogs[group]
|
||||
: { categories: [], archived: [] };
|
||||
var overrides = pageAiSkillPreferenceTable(group, profile);
|
||||
var result = [];
|
||||
pageAiNormalizeArray(catalog.categories).forEach(function(category) {
|
||||
pageAiNormalizeArray(category.skills).forEach(function(skill) {
|
||||
var id = String(skill.id || skill.name || '').trim();
|
||||
if (!id) return;
|
||||
result.push({
|
||||
group: group,
|
||||
profile: profile || '',
|
||||
category: category.name,
|
||||
categoryTitle: skill.categoryTitle || category.title || category.name || '',
|
||||
id: id,
|
||||
name: skill.name || id,
|
||||
title: skill.title || skill.name || id,
|
||||
description: skill.description || '',
|
||||
enabled: group === 'mnote' ? (skill.enabled !== false && overrides[id] !== false) : skill.enabled !== false,
|
||||
toggleable: group === 'mnote' && skill.toggleable !== false,
|
||||
source: skill.source || group,
|
||||
origin: skill.origin || '',
|
||||
readOnly: group !== 'mnote' || skill.readOnly === true || skill.readonly === true || skill.configurable === false,
|
||||
builtin: pageAiSkillIsBuiltin(skill),
|
||||
configurable: skill.configurable !== false,
|
||||
configScope: skill.configScope || '',
|
||||
skillKind: skill.skillKind || '',
|
||||
profileId: skill.profileId || '',
|
||||
toolNames: skill.toolNames || [],
|
||||
tools: skill.tools || [],
|
||||
toolCount: Number(skill.toolCount || 0),
|
||||
disabledToolCount: Number(skill.disabledToolCount || 0),
|
||||
status: skill.status || '',
|
||||
capabilityId: skill.capabilityId || '',
|
||||
capabilityKind: skill.capabilityKind || '',
|
||||
uiKind: skill.uiKind || '',
|
||||
requiresContextRefs: skill.requiresContextRefs || []
|
||||
});
|
||||
});
|
||||
});
|
||||
pageAiNormalizeArray(catalog.archived).forEach(function(skill) {
|
||||
var id = String(skill.id || skill.name || '').trim();
|
||||
if (!id) return;
|
||||
result.push({
|
||||
group: group,
|
||||
profile: profile || '',
|
||||
category: 'archived',
|
||||
categoryTitle: skill.categoryTitle || 'archived',
|
||||
id: id,
|
||||
name: skill.name || id,
|
||||
title: skill.title || skill.name || id,
|
||||
description: skill.description || '',
|
||||
enabled: group === 'mnote' ? (skill.enabled !== false && overrides[id] !== false) : skill.enabled !== false,
|
||||
toggleable: group === 'mnote' && skill.toggleable !== false,
|
||||
source: skill.source || group,
|
||||
origin: skill.origin || '',
|
||||
readOnly: group !== 'mnote' || skill.readOnly === true || skill.readonly === true || skill.configurable === false,
|
||||
builtin: pageAiSkillIsBuiltin(skill),
|
||||
configurable: skill.configurable !== false,
|
||||
configScope: skill.configScope || '',
|
||||
skillKind: skill.skillKind || '',
|
||||
profileId: skill.profileId || '',
|
||||
toolNames: skill.toolNames || [],
|
||||
tools: skill.tools || [],
|
||||
toolCount: Number(skill.toolCount || 0),
|
||||
disabledToolCount: Number(skill.disabledToolCount || 0),
|
||||
status: skill.status || '',
|
||||
capabilityId: skill.capabilityId || '',
|
||||
capabilityKind: skill.capabilityKind || '',
|
||||
uiKind: skill.uiKind || '',
|
||||
requiresContextRefs: skill.requiresContextRefs || []
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function pageAiAllSkillEntries() {
|
||||
return []
|
||||
.concat(pageAiToggleableSkillEntries('mnote', ''))
|
||||
.concat(pageAiToggleableSkillEntries('reasonix', ''))
|
||||
.concat(pageAiToggleableSkillEntries('hermes', pageAiCurrentProfile()));
|
||||
}
|
||||
|
||||
function pageAiSkillGroupCollapsed(group) {
|
||||
var table = pageUiState.pageAiCollapsedSkillGroups || {};
|
||||
return table[String(group || '').trim()] === true;
|
||||
}
|
||||
|
||||
function pageAiToggleSkillGroup(group) {
|
||||
var normalized = String(group || '').trim();
|
||||
if (!normalized) return;
|
||||
var table = Object.assign({}, pageUiState.pageAiCollapsedSkillGroups || {});
|
||||
table[normalized] = table[normalized] !== true;
|
||||
pageUiState.pageAiCollapsedSkillGroups = table;
|
||||
pageAiPersistRawAiPreference('ai.common.skills.groups.collapsed', table);
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function pageAiSetSkillPreference(group, skillId, enabled, profile) {
|
||||
var key = pageAiSkillPreferenceKey(group, profile);
|
||||
if (!key || !skillId) return;
|
||||
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
|
||||
var current = preferences[key] && typeof preferences[key] === 'object' && !Array.isArray(preferences[key])
|
||||
? Object.assign({}, preferences[key])
|
||||
: {};
|
||||
current[skillId] = Boolean(enabled);
|
||||
preferences[key] = current;
|
||||
pageUiState.pageAiSkillPreferences = preferences;
|
||||
pageAiPersistRawAiPreference(key, current);
|
||||
}
|
||||
|
||||
function pageAiSkillEnabled(skill) {
|
||||
return skill.enabled !== false;
|
||||
}
|
||||
|
||||
return {
|
||||
pageAiSkillSourceOptions,
|
||||
pageAiDefaultSkillSource,
|
||||
pageAiNormalizeSkillSource,
|
||||
pageAiCurrentSkillSource,
|
||||
pageAiSetSkillSource,
|
||||
pageAiSkillSourceParts,
|
||||
pageAiCurrentSkillSourceLabel,
|
||||
pageAiSkillOriginLabel,
|
||||
pageAiSkillPreferenceKey,
|
||||
pageAiSkillPreferenceTable,
|
||||
pageAiHermesHideBuiltinPreferenceKey,
|
||||
pageAiHideHermesBuiltinSkills,
|
||||
pageAiReasonixMemoryEnabled,
|
||||
pageAiSetReasonixMemoryEnabled,
|
||||
pageAiSetHideHermesBuiltinSkills,
|
||||
pageAiSkillIsBuiltin,
|
||||
pageAiToggleableSkillEntries,
|
||||
pageAiAllSkillEntries,
|
||||
pageAiSkillGroupCollapsed,
|
||||
pageAiToggleSkillGroup,
|
||||
pageAiSetSkillPreference,
|
||||
pageAiSkillEnabled
|
||||
};
|
||||
}
|
||||
@@ -1,773 +0,0 @@
|
||||
export function createSidebarPageAiTargetRuntime(context) {
|
||||
const {
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
currentPageOptions,
|
||||
documentRef,
|
||||
escapeHtml,
|
||||
pageAiEnsureContextRefState,
|
||||
pageUiState,
|
||||
resolveWorkspaceId,
|
||||
searchText,
|
||||
pageAiNormalizeArray,
|
||||
} = context;
|
||||
|
||||
function pageAiCloneJson(value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function localMarkdownRelativePathFromPageAiDocumentId(documentId) {
|
||||
var value = String(documentId || '').trim();
|
||||
if (!value.startsWith('local-md:')) return '';
|
||||
return value.slice('local-md:'.length).replace(/~2F/g, '/');
|
||||
}
|
||||
|
||||
function localMarkdownDocumentIdFromPageAiRelativePath(relativePath) {
|
||||
var normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!normalized) return '';
|
||||
return 'local-md:' + normalized.split('/').map(function(segment) {
|
||||
return encodeURIComponent(segment).replace(/%20/g, '~20');
|
||||
}).join('~2F');
|
||||
}
|
||||
|
||||
function pageAiWorkspacePathForDocument(documentId, seed) {
|
||||
var relativePath = String(seed && seed.relativePath || localMarkdownRelativePathFromPageAiDocumentId(documentId) || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
var resolvedDocumentId = String(documentId || seed && seed.documentId || '').trim()
|
||||
|| localMarkdownDocumentIdFromPageAiRelativePath(relativePath);
|
||||
return {
|
||||
schema: 'mnote.workspace_path.v1',
|
||||
workspaceId: String(seed && seed.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim(),
|
||||
sourceKind: String(seed && seed.sourceKind || currentSourceKind() || '').trim(),
|
||||
rootUri: String(seed && seed.rootUri || currentRootUri() || '').trim(),
|
||||
relativePath: relativePath,
|
||||
documentId: resolvedDocumentId,
|
||||
objectIdentity: seed && seed.objectIdentity && typeof seed.objectIdentity === 'object'
|
||||
? seed.objectIdentity
|
||||
: String(seed && seed.objectIdentity || resolvedDocumentId || '').trim(),
|
||||
assetId: String(seed && seed.assetId || '').trim(),
|
||||
resourceKind: String(seed && seed.resourceKind || 'markdown_page').trim()
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiResourceKindForTarget(entry) {
|
||||
var kind = String(entry && (entry.editorKind || entry.kind) || '').trim().toLowerCase();
|
||||
var assetId = String(entry && entry.assetId || '').trim();
|
||||
var path = String(entry && entry.path || '').trim().toLowerCase();
|
||||
var workspacePath = entry && entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {};
|
||||
var workspaceResourceKind = String(workspacePath.resourceKind || '').trim().toLowerCase();
|
||||
var officeOpenMode = String(entry && entry.officeOpenMode || '').trim().toLowerCase();
|
||||
var onlyofficeSessionId = String(entry && (entry.onlyofficeSessionId || entry.bridgeSessionId) || '').trim();
|
||||
if (kind === 'page' || kind === 'markdown_page') return 'markdown_page';
|
||||
if (kind === 'mindmap' || path.endsWith('.mindmap.json')) return 'mindmap';
|
||||
if (kind === 'office' || kind === 'onlyoffice' || kind === 'only_office' || workspaceResourceKind === 'only_office' || /\.(doc|docx|ppt|pptx|xls|xlsx)$/.test(path)) {
|
||||
return officeOpenMode === 'onlyoffice_live' || onlyofficeSessionId ? 'only_office' : 'attachment';
|
||||
}
|
||||
if (kind === 'resource' && assetId) return 'resource';
|
||||
return kind || 'markdown_page';
|
||||
}
|
||||
|
||||
function pageAiIsOnlyOfficeLiveTarget(editorTarget) {
|
||||
var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
|
||||
var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim().toLowerCase();
|
||||
var officeOpenMode = String(editorTarget && editorTarget.officeOpenMode || '').trim().toLowerCase();
|
||||
return resourceKind === 'only_office' || resourceKind === 'onlyoffice' || officeOpenMode === 'onlyoffice_live';
|
||||
}
|
||||
|
||||
function pageAiTargetId(entry) {
|
||||
if (!entry || typeof entry !== 'object') return '';
|
||||
var workspacePath = entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {};
|
||||
var entryIdentity = typeof entry.objectIdentity === 'string' ? entry.objectIdentity : '';
|
||||
var workspaceIdentity = typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : '';
|
||||
return String(entryIdentity || workspaceIdentity || entry.documentId || entry.assetId || entry.path || '').trim();
|
||||
}
|
||||
|
||||
function pageAiWorkspacePathForTarget(entry) {
|
||||
var seed = Object.assign({}, entry && entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {});
|
||||
var resourceKind = pageAiResourceKindForTarget(entry);
|
||||
if (!seed.relativePath && entry && entry.path) seed.relativePath = entry.path;
|
||||
if (!seed.assetId && entry && entry.assetId) seed.assetId = entry.assetId;
|
||||
var seedResourceKind = String(seed.resourceKind || '').trim();
|
||||
if (!seedResourceKind || seedResourceKind === 'page' || seedResourceKind === 'office') seed.resourceKind = resourceKind;
|
||||
if (!seed.objectIdentity && entry && entry.objectIdentity) seed.objectIdentity = entry.objectIdentity;
|
||||
if (!seed.workspaceId && entry && entry.workspaceId) seed.workspaceId = entry.workspaceId;
|
||||
return pageAiWorkspacePathForDocument(entry && entry.documentId || currentDocumentId(), seed);
|
||||
}
|
||||
|
||||
function pageAiTargetFromOpenEditor(entry, source) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
var workspacePath = pageAiWorkspacePathForTarget(entry);
|
||||
var targetId = pageAiTargetId(entry) || workspacePath.objectIdentity || workspacePath.documentId;
|
||||
if (!targetId) return null;
|
||||
var objectIdentity = typeof entry.objectIdentity === 'string'
|
||||
? entry.objectIdentity
|
||||
: (typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : targetId);
|
||||
return {
|
||||
schema: 'mnote.ai_editor_target.v1',
|
||||
source: source || 'open_editors_snapshot',
|
||||
targetId: targetId,
|
||||
objectIdentity: objectIdentity,
|
||||
workspacePath: workspacePath,
|
||||
paneRole: entry.paneRole || 'primary',
|
||||
documentId: entry.documentId || workspacePath.documentId,
|
||||
workspaceId: entry.workspaceId || workspacePath.workspaceId || resolveWorkspaceId(documentRef.body),
|
||||
editorKind: entry.editorKind || entry.kind || workspacePath.resourceKind,
|
||||
resourceKind: workspacePath.resourceKind,
|
||||
title: entry.title || '',
|
||||
active: entry.active === true,
|
||||
dirtyState: entry.dirtyState || '',
|
||||
preview: entry.preview === true,
|
||||
pinned: entry.pinned === true,
|
||||
lastActiveAt: entry.lastActiveAt || 0,
|
||||
assetId: entry.assetId || workspacePath.assetId || '',
|
||||
path: entry.path || workspacePath.relativePath || '',
|
||||
officeOpenMode: entry.officeOpenMode || '',
|
||||
onlyofficeSessionId: entry.onlyofficeSessionId || entry.bridgeSessionId || '',
|
||||
bridgeSessionId: entry.bridgeSessionId || entry.onlyofficeSessionId || '',
|
||||
bridgeSessionReady: entry.bridgeSessionReady === true
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOpenEditorEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
return {
|
||||
objectIdentity: String(entry.objectIdentity || '').trim(),
|
||||
workspacePath: entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : null,
|
||||
paneRole: String(entry.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary',
|
||||
documentId: String(entry.documentId || '').trim(),
|
||||
workspaceId: String(entry.workspaceId || '').trim(),
|
||||
title: String(entry.title || '').trim(),
|
||||
kind: String(entry.kind || entry.editorKind || '').trim(),
|
||||
editorKind: String(entry.editorKind || entry.kind || '').trim(),
|
||||
active: entry.active === true,
|
||||
dirtyState: String(entry.dirtyState || entry.dirtyGuard || '').trim(),
|
||||
preview: entry.preview === true,
|
||||
pinned: entry.pinned === true,
|
||||
lastActiveAt: Number(entry.lastActiveAt || 0) || 0,
|
||||
assetId: String(entry.assetId || '').trim(),
|
||||
path: String(entry.path || '').trim(),
|
||||
officeOpenMode: String(entry.officeOpenMode || '').trim(),
|
||||
onlyofficeSessionId: String(entry.onlyofficeSessionId || entry.bridgeSessionId || '').trim(),
|
||||
bridgeSessionId: String(entry.bridgeSessionId || entry.onlyofficeSessionId || '').trim(),
|
||||
bridgeSessionReady: entry.bridgeSessionReady === true
|
||||
};
|
||||
}
|
||||
|
||||
function currentPageAiOpenEditorsSnapshot() {
|
||||
var snapshot = null;
|
||||
try {
|
||||
if (window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === 'function') {
|
||||
snapshot = window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot();
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!snapshot || typeof snapshot !== 'object') snapshot = window.__mnoteOpenEditorsSnapshot || null;
|
||||
if (!snapshot || typeof snapshot !== 'object') return null;
|
||||
var editors = Array.isArray(snapshot.editors)
|
||||
? snapshot.editors.map(normalizeOpenEditorEntry).filter(Boolean)
|
||||
: [];
|
||||
var resources = Array.isArray(snapshot.resourceEditors)
|
||||
? snapshot.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean)
|
||||
: editors.filter(function(entry) { return entry.kind !== 'page'; });
|
||||
var normalizeGroup = function(group, paneRole) {
|
||||
var groupEditors = group && Array.isArray(group.editors)
|
||||
? group.editors.map(normalizeOpenEditorEntry).filter(Boolean)
|
||||
: editors.filter(function(entry) { return entry.paneRole === paneRole; });
|
||||
var groupResources = group && Array.isArray(group.resourceEditors)
|
||||
? group.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean)
|
||||
: resources.filter(function(entry) { return entry.paneRole === paneRole; });
|
||||
var groupActive = groupEditors.find(function(entry) { return entry.active; }) || null;
|
||||
return {
|
||||
paneRole: paneRole,
|
||||
activeObjectIdentity: String(group && group.activeObjectIdentity || (groupActive ? groupActive.objectIdentity : '') || '').trim(),
|
||||
editors: groupEditors,
|
||||
resourceEditors: groupResources
|
||||
};
|
||||
};
|
||||
var groups = {
|
||||
primary: normalizeGroup(snapshot.groups && snapshot.groups.primary, 'primary'),
|
||||
secondary: normalizeGroup(snapshot.groups && snapshot.groups.secondary, 'secondary')
|
||||
};
|
||||
var activeObjectIdentity = String(snapshot.activeObjectIdentity || '').trim();
|
||||
var allTargets = editors.concat(resources);
|
||||
var activeEditor = allTargets.find(function(entry) {
|
||||
return entry.active && (!activeObjectIdentity || entry.objectIdentity === activeObjectIdentity);
|
||||
}) || groups.primary.editors.find(function(entry) {
|
||||
return entry.active;
|
||||
}) || groups.primary.resourceEditors.find(function(entry) {
|
||||
return entry.active;
|
||||
}) || groups.secondary.editors.find(function(entry) {
|
||||
return entry.active;
|
||||
}) || groups.secondary.resourceEditors.find(function(entry) {
|
||||
return entry.active;
|
||||
}) || null;
|
||||
return {
|
||||
schema: String(snapshot.schema || 'mnote.open_editors_snapshot.v1'),
|
||||
generatedAt: Number(snapshot.generatedAt || 0) || Date.now(),
|
||||
activeObjectIdentity: activeObjectIdentity || (activeEditor ? activeEditor.objectIdentity : ''),
|
||||
activeEditor: activeEditor,
|
||||
editors: editors,
|
||||
resourceEditors: resources,
|
||||
groups: groups
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiFallbackEditorTarget() {
|
||||
var fallbackDocumentId = currentDocumentId();
|
||||
var fallbackWorkspacePath = pageAiWorkspacePathForDocument(fallbackDocumentId, null);
|
||||
var fallbackTargetId = fallbackWorkspacePath.objectIdentity || fallbackDocumentId || '';
|
||||
return {
|
||||
schema: 'mnote.ai_editor_target.v1',
|
||||
source: 'fallback_current_document',
|
||||
targetId: fallbackTargetId,
|
||||
objectIdentity: fallbackTargetId,
|
||||
workspacePath: fallbackWorkspacePath,
|
||||
paneRole: 'primary',
|
||||
documentId: fallbackDocumentId,
|
||||
workspaceId: resolveWorkspaceId(documentRef.body),
|
||||
editorKind: 'page',
|
||||
active: true,
|
||||
dirtyState: '',
|
||||
preview: false,
|
||||
pinned: true,
|
||||
lastActiveAt: Date.now(),
|
||||
assetId: '',
|
||||
path: ''
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiEditorTargetCandidates() {
|
||||
var snapshot = currentPageAiOpenEditorsSnapshot();
|
||||
var entries = [];
|
||||
if (snapshot) {
|
||||
entries = pageAiNormalizeArray(snapshot.editors).concat(pageAiNormalizeArray(snapshot.resourceEditors));
|
||||
}
|
||||
var seen = {};
|
||||
var targets = entries.map(function(entry) {
|
||||
return pageAiTargetFromOpenEditor(entry, 'open_editors_snapshot');
|
||||
}).filter(function(target) {
|
||||
var id = String(target && target.targetId || '').trim();
|
||||
if (!id || seen[id]) return false;
|
||||
seen[id] = true;
|
||||
return true;
|
||||
});
|
||||
if (!targets.length) targets.push(pageAiFallbackEditorTarget());
|
||||
return targets;
|
||||
}
|
||||
|
||||
function currentPageAiEditorTarget() {
|
||||
var targets = pageAiEditorTargetCandidates();
|
||||
var selectedId = String(pageUiState.pageAiSelectedTargetId || '').trim();
|
||||
var selected = selectedId ? targets.find(function(target) { return target.targetId === selectedId; }) : null;
|
||||
return selected
|
||||
|| targets.find(function(target) { return target.active === true; })
|
||||
|| targets[0]
|
||||
|| pageAiFallbackEditorTarget();
|
||||
}
|
||||
|
||||
function currentPageAiPageEditorTarget() {
|
||||
var snapshot = currentPageAiOpenEditorsSnapshot();
|
||||
var documentId = currentDocumentId();
|
||||
var workspaceId = resolveWorkspaceId(documentRef.body);
|
||||
var sourceKind = currentSourceKind();
|
||||
var rootUri = currentRootUri();
|
||||
var relativePath = localMarkdownRelativePathFromPageAiDocumentId(documentId);
|
||||
var fallbackWorkspacePath = pageAiWorkspacePathForDocument(documentId, {
|
||||
relativePath: relativePath
|
||||
});
|
||||
var pageEditor = snapshot && Array.isArray(snapshot.editors)
|
||||
? snapshot.editors.find(function(entry) {
|
||||
return entry
|
||||
&& String(entry.editorKind || entry.kind || '') === 'page'
|
||||
&& String(entry.documentId || '').trim() === String(documentId || '').trim();
|
||||
})
|
||||
: null;
|
||||
var workspacePath = pageEditor && pageEditor.workspacePath
|
||||
? Object.assign({}, pageEditor.workspacePath, {
|
||||
workspaceId: workspaceId,
|
||||
sourceKind: sourceKind,
|
||||
rootUri: rootUri,
|
||||
relativePath: relativePath,
|
||||
documentId: documentId,
|
||||
resourceKind: pageEditor.workspacePath.resourceKind || 'markdown_page',
|
||||
objectIdentity: pageEditor.workspacePath.objectIdentity || fallbackWorkspacePath.objectIdentity
|
||||
})
|
||||
: fallbackWorkspacePath;
|
||||
var objectIdentity = String(pageEditor && pageEditor.objectIdentity || workspacePath.objectIdentity || documentId || '').trim();
|
||||
return {
|
||||
schema: 'mnote.ai_editor_target.v1',
|
||||
source: pageEditor ? 'current_page_from_open_editors_snapshot' : 'current_page_fallback',
|
||||
targetId: objectIdentity,
|
||||
objectIdentity: objectIdentity,
|
||||
workspacePath: Object.assign({}, workspacePath, { objectIdentity: objectIdentity }),
|
||||
paneRole: String(pageEditor && pageEditor.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary',
|
||||
documentId: documentId,
|
||||
workspaceId: workspaceId,
|
||||
editorKind: 'page',
|
||||
active: pageEditor ? pageEditor.active === true : true,
|
||||
dirtyState: String(pageEditor && pageEditor.dirtyState || '').trim(),
|
||||
preview: pageEditor ? pageEditor.preview === true : false,
|
||||
pinned: true,
|
||||
lastActiveAt: Number(pageEditor && pageEditor.lastActiveAt || 0) || Date.now(),
|
||||
assetId: '',
|
||||
path: relativePath
|
||||
};
|
||||
}
|
||||
|
||||
function currentPageAiScopedEditorTarget() {
|
||||
var selected = pageAiEnsureContextRefState();
|
||||
return selected.active_editor ? currentPageAiEditorTarget() : currentPageAiPageEditorTarget();
|
||||
}
|
||||
|
||||
function pageAiSetRunTargetSnapshot(snapshot) {
|
||||
pageUiState.pageAiCurrentRunTargetSnapshot = snapshot || null;
|
||||
var target = snapshot && snapshot.editorTarget ? snapshot.editorTarget : null;
|
||||
var documentId = String(target && target.documentId || snapshot && snapshot.documentId || '').trim();
|
||||
var workspaceId = String(target && target.workspaceId || snapshot && snapshot.workspaceId || '').trim();
|
||||
var rootUri = String(target && target.workspacePath && target.workspacePath.rootUri || snapshot && snapshot.rootUri || '').trim();
|
||||
if (documentId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-document-id', documentId);
|
||||
if (workspaceId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-workspace-id', workspaceId);
|
||||
if (rootUri) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-root-uri', rootUri);
|
||||
}
|
||||
|
||||
function assertPageAiTargetInCurrentWorkspace(editorTarget) {
|
||||
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
|
||||
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
|
||||
var targetSourceKind = String(workspacePath.sourceKind || '').trim();
|
||||
var currentKind = String(currentSourceKind() || '').trim();
|
||||
if (targetSourceKind && currentKind && targetSourceKind !== currentKind) {
|
||||
var sourceError = new Error('AI target 与当前页面 sourceKind 不一致,请重新选择当前工作区内的目标。');
|
||||
sourceError.code = 'page_ai_target_workspace_mismatch';
|
||||
throw sourceError;
|
||||
}
|
||||
var targetRootUri = String(workspacePath.rootUri || '').trim();
|
||||
var currentRoot = String(currentRootUri() || '').trim();
|
||||
if (targetRootUri && currentRoot && targetRootUri !== currentRoot) {
|
||||
var rootError = new Error('AI target 与当前本地工作区 rootUri 不一致,请重新选择当前工作区内的目标。');
|
||||
rootError.code = 'page_ai_target_workspace_mismatch';
|
||||
throw rootError;
|
||||
}
|
||||
var targetWorkspaceId = String(target.workspaceId || workspacePath.workspaceId || '').trim();
|
||||
var currentWorkspaceId = String(resolveWorkspaceId(documentRef.body) || '').trim();
|
||||
if (targetWorkspaceId && currentWorkspaceId && targetWorkspaceId !== currentWorkspaceId) {
|
||||
var workspaceError = new Error('AI target 与当前 workspaceId 不一致,请重新选择当前工作区内的目标。');
|
||||
workspaceError.code = 'page_ai_target_workspace_mismatch';
|
||||
throw workspaceError;
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiBuildContextRefs(scopedContext, runTargetSnapshot) {
|
||||
var selected = pageAiEnsureContextRefState();
|
||||
var refs = [];
|
||||
var documentId = currentDocumentId();
|
||||
var rootUri = currentRootUri();
|
||||
var workspaceId = resolveWorkspaceId(documentRef.body);
|
||||
var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
|
||||
if (selected.current_page) {
|
||||
refs.push({
|
||||
kind: 'current_page',
|
||||
documentId: documentId,
|
||||
rootUri: rootUri,
|
||||
workspaceId: workspaceId
|
||||
});
|
||||
}
|
||||
if (selected.selection && scopedContext && scopedContext.selectedText) {
|
||||
refs.push({
|
||||
kind: 'selection',
|
||||
documentId: documentId,
|
||||
rootUri: rootUri,
|
||||
selectedBlockId: scopedContext.selectedBlockId || ''
|
||||
});
|
||||
}
|
||||
if (selected.active_editor && editorTarget) {
|
||||
var workspacePath = editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
|
||||
refs.push({
|
||||
kind: 'active_editor',
|
||||
documentId: editorTarget.documentId || documentId,
|
||||
workspaceId: editorTarget.workspaceId || workspacePath.workspaceId || workspaceId,
|
||||
rootUri: workspacePath.rootUri || rootUri,
|
||||
relativePath: workspacePath.relativePath || '',
|
||||
editorKind: editorTarget.editorKind || '',
|
||||
resourceKind: editorTarget.resourceKind || workspacePath.resourceKind || '',
|
||||
targetId: editorTarget.targetId || workspacePath.objectIdentity || '',
|
||||
objectIdentity: editorTarget.objectIdentity || workspacePath.objectIdentity || '',
|
||||
assetId: editorTarget.assetId || workspacePath.assetId || '',
|
||||
onlyofficeSessionId: editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId || '',
|
||||
bridgeSessionId: editorTarget.bridgeSessionId || editorTarget.onlyofficeSessionId || ''
|
||||
});
|
||||
}
|
||||
if (selected.file) {
|
||||
refs.push({
|
||||
kind: 'file',
|
||||
documentId: documentId,
|
||||
rootUri: rootUri,
|
||||
relativePath: localMarkdownRelativePathFromPageAiDocumentId(documentId)
|
||||
});
|
||||
}
|
||||
if (selected.folder) {
|
||||
refs.push({
|
||||
kind: 'folder',
|
||||
rootUri: rootUri,
|
||||
relativePath: ''
|
||||
});
|
||||
}
|
||||
if (selected.changed_files) {
|
||||
refs.push({
|
||||
kind: 'changed_files',
|
||||
rootUri: rootUri,
|
||||
sinceRunTargetSnapshot: runTargetSnapshot && runTargetSnapshot.frozenAt || null
|
||||
});
|
||||
}
|
||||
return refs.filter(function(ref) {
|
||||
return ref && String(ref.kind || '').trim();
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiBuildAllowedRoots() {
|
||||
return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) {
|
||||
return {
|
||||
rootUri: root.rootUri,
|
||||
permission: root.permission === 'write' || root.permission === 'read_write' ? 'write' : 'read',
|
||||
recursive: root.recursive !== false,
|
||||
source: root.source === 'auto' || root.source === 'user' || root.source === 'admin'
|
||||
? 'sqlite_directory_grant'
|
||||
: (root.source || 'sqlite_directory_grant'),
|
||||
grantId: root.id || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiBuildRunTargetSnapshot(scopedContext, prompt) {
|
||||
var aiContext = scopedContext && scopedContext.pageContext && scopedContext.pageContext.aiContext
|
||||
? scopedContext.pageContext.aiContext
|
||||
: {};
|
||||
var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
|
||||
return {
|
||||
schema: 'mnote.page_ai_run_target_snapshot.v1',
|
||||
source: 'open_editors_snapshot',
|
||||
frozenAt: Date.now(),
|
||||
workspaceId: resolveWorkspaceId(documentRef.body),
|
||||
documentId: currentDocumentId(),
|
||||
sourceKind: currentSourceKind(),
|
||||
rootUri: currentRootUri(),
|
||||
contextScope: pageUiState.pageAiContextScope || 'page',
|
||||
promptPreview: searchText(prompt || '').slice(0, 160),
|
||||
editorTarget: pageAiCloneJson(editorTarget),
|
||||
activeEditorTarget: pageAiCloneJson(aiContext.activeEditorTarget || editorTarget),
|
||||
openEditorsSnapshot: pageAiCloneJson(aiContext.openEditorsSnapshot || currentPageAiOpenEditorsSnapshot())
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiContextKindsFromRefs(contextRefs) {
|
||||
var kinds = {};
|
||||
pageAiNormalizeArray(contextRefs).forEach(function(ref) {
|
||||
var kind = String(ref && ref.kind || '').trim();
|
||||
if (kind) kinds[kind] = true;
|
||||
});
|
||||
return kinds;
|
||||
}
|
||||
|
||||
function pageAiPageContextForRefs(pageContext, contextRefs) {
|
||||
var cloned = pageAiCloneJson(pageContext) || {};
|
||||
var aiContext = cloned.aiContext && typeof cloned.aiContext === 'object' ? cloned.aiContext : {};
|
||||
var kinds = pageAiContextKindsFromRefs(contextRefs);
|
||||
delete cloned.documentBlocks;
|
||||
delete cloned.evidence;
|
||||
delete aiContext.contextBlocks;
|
||||
delete aiContext.pageText;
|
||||
delete aiContext.pageXml;
|
||||
delete aiContext.truncated;
|
||||
delete aiContext.warnings;
|
||||
if (!kinds.selection) {
|
||||
delete aiContext.selectedText;
|
||||
delete aiContext.selectedBlockIds;
|
||||
delete aiContext.selectedBlocks;
|
||||
delete aiContext.allowedTargetBlockIds;
|
||||
}
|
||||
cloned.aiContext = aiContext;
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot) {
|
||||
var target = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
|
||||
var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object'
|
||||
? pageAiWorkspacePathForDocument(target.documentId || currentDocumentId(), target.workspacePath)
|
||||
: pageAiWorkspacePathForDocument(target && target.documentId || currentDocumentId(), null);
|
||||
var relativePath = String(workspacePath.relativePath || '').trim();
|
||||
var allowedFiles = relativePath ? [relativePath] : [];
|
||||
var writable = pageAiBuildAllowedRoots().some(function(root) {
|
||||
return String(root && root.rootUri || '').trim() === String(workspacePath.rootUri || '').trim()
|
||||
&& String(root && root.permission || '').trim() === 'write';
|
||||
});
|
||||
var primaryTargetId = String(target && target.targetId || workspacePath.objectIdentity || workspacePath.documentId || '').trim();
|
||||
var onlyofficeSessionId = String(target && (target.onlyofficeSessionId || target.bridgeSessionId) || '').trim();
|
||||
var targetEntry = {
|
||||
targetId: primaryTargetId,
|
||||
objectIdentity: primaryTargetId,
|
||||
documentId: workspacePath.documentId,
|
||||
workspaceId: workspacePath.workspaceId,
|
||||
sourceKind: workspacePath.sourceKind,
|
||||
rootUri: workspacePath.rootUri,
|
||||
relativePath: relativePath,
|
||||
resourceKind: workspacePath.resourceKind,
|
||||
assetId: workspacePath.assetId || target && target.assetId || '',
|
||||
onlyofficeSessionId: onlyofficeSessionId,
|
||||
bridgeSessionId: onlyofficeSessionId,
|
||||
paneRole: target && target.paneRole || 'primary',
|
||||
title: target && target.title || '',
|
||||
policy: {
|
||||
permission: allowedFiles.length && writable ? 'read_write' : 'read',
|
||||
writeRequiresCleanBuffer: true,
|
||||
conflictPolicy: 'fail_on_dirty_or_stale'
|
||||
}
|
||||
};
|
||||
return {
|
||||
schema: 'mnote.agent_target_package.v1',
|
||||
source: 'page_ai_run_target_snapshot',
|
||||
frozenAt: runTargetSnapshot && runTargetSnapshot.frozenAt || Date.now(),
|
||||
primaryTargetId: primaryTargetId,
|
||||
onlyofficeSessionId: onlyofficeSessionId,
|
||||
bridgeSessionId: onlyofficeSessionId,
|
||||
workspaceId: workspacePath.workspaceId,
|
||||
sourceKind: workspacePath.sourceKind,
|
||||
rootUri: workspacePath.rootUri,
|
||||
documentId: workspacePath.documentId,
|
||||
objectIdentity: primaryTargetId,
|
||||
resourceKind: workspacePath.resourceKind,
|
||||
workspacePath: workspacePath,
|
||||
currentFile: relativePath ? {
|
||||
rootUri: workspacePath.rootUri,
|
||||
relativePath: relativePath,
|
||||
documentId: workspacePath.documentId,
|
||||
objectIdentity: primaryTargetId,
|
||||
resourceKind: workspacePath.resourceKind
|
||||
} : null,
|
||||
allowedFiles: allowedFiles,
|
||||
targets: [targetEntry],
|
||||
policy: {
|
||||
writeRequiresExplicitTarget: true,
|
||||
allowedFilesSource: 'selected_page_ai_target',
|
||||
conflictPolicy: 'fail_on_dirty_or_stale'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiBlockingDirtyState(dirtyState) {
|
||||
var state = String(dirtyState || '').trim();
|
||||
var normalized = state.toLowerCase();
|
||||
if (normalized === 'dirty') return 'Dirty';
|
||||
if (normalized === 'stale') return 'Stale';
|
||||
if (normalized === 'deleted') return 'Deleted';
|
||||
if (normalized === 'externalmodified' || normalized === 'external-change-conflict' || normalized === 'hasexternalconflict') return 'ExternalModified';
|
||||
return '';
|
||||
}
|
||||
|
||||
async function fetchPageAiTargetBufferState(editorTarget) {
|
||||
if (currentSourceKind() !== 'local_folder') return null;
|
||||
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
|
||||
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
|
||||
var resourceKind = String(target.resourceKind || target.editorKind || workspacePath.resourceKind || '').trim();
|
||||
if (resourceKind === 'office' || resourceKind === 'only_office' || resourceKind === 'onlyoffice') return null;
|
||||
var documentId = String(target.documentId || currentDocumentId() || '').trim();
|
||||
var rootUri = String(workspacePath.rootUri || currentRootUri() || '').trim();
|
||||
if (!documentId || !rootUri) return null;
|
||||
var relativePath = String(workspacePath.relativePath || '').trim()
|
||||
|| localMarkdownRelativePathFromPageAiDocumentId(documentId);
|
||||
var url = new URL('/api/documents/buffer-state', window.location.origin);
|
||||
url.searchParams.set('documentId', documentId);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
var workspaceId = String(target.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim();
|
||||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||
if (relativePath) url.searchParams.set('relativePath', relativePath);
|
||||
try {
|
||||
var response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) return null;
|
||||
return payload.result || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPageAiTargetWritable(editorTarget) {
|
||||
var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
|
||||
var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim();
|
||||
var onlyofficeSessionId = String(editorTarget && (editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId) || '').trim();
|
||||
if (pageAiIsOnlyOfficeLiveTarget(editorTarget) && !onlyofficeSessionId) {
|
||||
var sessionError = new Error('ONLYOFFICE 资源仍在连接 MNote bridge,请等待 Office 页面加载完成后再让 AI 操作。');
|
||||
sessionError.code = 'page_ai_onlyoffice_bridge_not_ready';
|
||||
throw sessionError;
|
||||
}
|
||||
var snapshotState = pageAiBlockingDirtyState(editorTarget && editorTarget.dirtyState);
|
||||
var bufferState = await fetchPageAiTargetBufferState(editorTarget);
|
||||
var bufferDirtyState = pageAiBlockingDirtyState(bufferState && bufferState.dirtyState);
|
||||
var blockedState = bufferDirtyState || snapshotState;
|
||||
if (!blockedState) return bufferState;
|
||||
var documentId = String(editorTarget && editorTarget.documentId || currentDocumentId() || '').trim();
|
||||
var error = new Error('目标文档存在未保存或外部变更状态(' + blockedState + '),请先保存、解决冲突或刷新后再让 AI 写入。');
|
||||
error.code = 'page_ai_target_buffer_not_writable';
|
||||
error.documentId = documentId;
|
||||
error.dirtyState = blockedState;
|
||||
throw error;
|
||||
}
|
||||
|
||||
function currentPageAiSelectedText() {
|
||||
try {
|
||||
var selection = window.getSelection ? window.getSelection() : null;
|
||||
return selection ? searchText(selection.toString() || '') : '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiProjectionBlocks(aggregate) {
|
||||
var blocks = aggregate && aggregate.body && aggregate.body.blockDocument && aggregate.body.blockDocument.blocks;
|
||||
return Array.isArray(blocks) ? blocks : [];
|
||||
}
|
||||
|
||||
function pageAiBlockText(block) {
|
||||
return searchText(block && (block.text || block.title || block.content) || '');
|
||||
}
|
||||
|
||||
function pageAiSelectedBlockIdsFromSelection() {
|
||||
try {
|
||||
var selection = window.getSelection ? window.getSelection() : null;
|
||||
if (!selection || selection.rangeCount === 0 || searchText(selection.toString() || '') === '') return [];
|
||||
var range = selection.getRangeAt(0);
|
||||
var editor = documentRef.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) return [];
|
||||
return Array.from(editor.children).filter(function(node) {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
try {
|
||||
return range.intersectsNode(node);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}).map(function(node) {
|
||||
return searchText(node.getAttribute('data-id') || node.id || '');
|
||||
}).filter(Boolean);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiBlocksToPageXml(blocks, aggregate) {
|
||||
var revision = aggregate && aggregate.body ? String(aggregate.body.revision || '') : '';
|
||||
var pageId = currentDocumentId() || 'current-page';
|
||||
var lines = ['<page id="' + escapeHtml(pageId) + '" revision="' + escapeHtml(revision) + '">'];
|
||||
blocks.forEach(function(block) {
|
||||
var blockId = String(block && (block.blockId || block.id) || '');
|
||||
var type = String(block && block.type || 'paragraph');
|
||||
var revisionRef = String(block && block.revisionRef || '');
|
||||
var level = block && block.attrs && block.attrs.level ? ' level="' + escapeHtml(block.attrs.level) + '"' : '';
|
||||
lines.push(' <block id="' + escapeHtml(blockId) + '" type="' + escapeHtml(type) + '" revisionRef="' + escapeHtml(revisionRef) + '"' + level + '>' + escapeHtml(pageAiBlockText(block)) + '</block>');
|
||||
});
|
||||
lines.push('</page>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildPageAiContext(contextSnapshot, scope, selectedText) {
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = aggregate.body || {};
|
||||
var allBlocks = pageAiProjectionBlocks(aggregate);
|
||||
var selectedBlockIds = scope === 'selection' ? pageAiSelectedBlockIdsFromSelection() : [];
|
||||
var selectedSet = {};
|
||||
selectedBlockIds.forEach(function(id) { selectedSet[id] = true; });
|
||||
var selectedBlocks = selectedBlockIds.length
|
||||
? allBlocks.filter(function(block) { return selectedSet[String(block.blockId || block.id || '')]; })
|
||||
: [];
|
||||
var contextBlocks = selectedBlocks.length ? selectedBlocks : allBlocks.slice(0, 120);
|
||||
var truncated = !selectedBlocks.length && allBlocks.length > contextBlocks.length;
|
||||
return {
|
||||
schema: 'mnote.page_ai_context.v1',
|
||||
workspaceId: resolveWorkspaceId(documentRef.body),
|
||||
documentId: currentDocumentId(),
|
||||
activeEditorTarget: currentPageAiScopedEditorTarget(),
|
||||
openEditorsSnapshot: currentPageAiOpenEditorsSnapshot(),
|
||||
scope: scope,
|
||||
revision: body.revision || null,
|
||||
conflictDetectionKey: body.conflictDetectionKey || null,
|
||||
selectedText: selectedText || '',
|
||||
selectedBlockIds: selectedBlockIds,
|
||||
allowedTargetBlockIds: selectedBlockIds,
|
||||
selectedBlocks: selectedBlocks,
|
||||
contextBlocks: contextBlocks,
|
||||
pageText: contextBlocks.map(pageAiBlockText).filter(Boolean).join('\n'),
|
||||
pageXml: pageAiBlocksToPageXml(contextBlocks, aggregate),
|
||||
truncated: truncated,
|
||||
warnings: truncated ? [{ code: 'page_ai_context_truncated', message: '页面 AI context 已按前 120 个块裁剪' }] : []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiScopedPageContext(contextSnapshot) {
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
var subtree = contextSnapshot.subtree || null;
|
||||
var outline = subtree && subtree.outline ? subtree.outline : null;
|
||||
var scope = pageUiState.pageAiContextScope || 'page';
|
||||
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
|
||||
var selectedText = scope === 'selection' ? currentPageAiSelectedText() : '';
|
||||
var aiContext = buildPageAiContext(contextSnapshot, scope, selectedText);
|
||||
var editorTarget = aiContext.activeEditorTarget || currentPageAiScopedEditorTarget();
|
||||
return {
|
||||
pageContext: {
|
||||
contextScope: scope,
|
||||
documentBlocks: null,
|
||||
node: {
|
||||
documentId: currentDocumentId(),
|
||||
title: title
|
||||
},
|
||||
subtree: null,
|
||||
outline: null,
|
||||
pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope,
|
||||
evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null,
|
||||
pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null,
|
||||
contentAccess: 'mnote.context.read_current_page',
|
||||
aiContext: aiContext
|
||||
},
|
||||
editorTarget: editorTarget,
|
||||
selectedText: selectedText,
|
||||
selectedBlockId: aiContext.selectedBlockIds[0] || null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
currentPageAiSelectedText,
|
||||
currentPageAiEditorTarget,
|
||||
currentPageAiPageEditorTarget,
|
||||
currentPageAiScopedEditorTarget,
|
||||
currentPageAiOpenEditorsSnapshot,
|
||||
pageAiBlockText,
|
||||
pageAiBlockingDirtyState,
|
||||
pageAiBlocksToPageXml,
|
||||
pageAiBuildAgentTargetPackage,
|
||||
pageAiBuildAllowedRoots,
|
||||
pageAiBuildContextRefs,
|
||||
pageAiBuildRunTargetSnapshot,
|
||||
pageAiCloneJson,
|
||||
pageAiContextKindsFromRefs,
|
||||
pageAiEditorTargetCandidates,
|
||||
pageAiFallbackEditorTarget,
|
||||
pageAiPageContextForRefs,
|
||||
pageAiProjectionBlocks,
|
||||
pageAiResourceKindForTarget,
|
||||
pageAiSelectedBlockIdsFromSelection,
|
||||
pageAiSetRunTargetSnapshot,
|
||||
pageAiScopedPageContext,
|
||||
pageAiTargetFromOpenEditor,
|
||||
pageAiTargetId,
|
||||
pageAiWorkspacePathForDocument,
|
||||
pageAiWorkspacePathForTarget,
|
||||
assertPageAiTargetInCurrentWorkspace,
|
||||
assertPageAiTargetWritable,
|
||||
buildPageAiContext,
|
||||
fetchPageAiTargetBufferState,
|
||||
localMarkdownDocumentIdFromPageAiRelativePath,
|
||||
localMarkdownRelativePathFromPageAiDocumentId,
|
||||
};
|
||||
}
|
||||
@@ -599,7 +599,6 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'<div class="mnote-extensions-subtabs" role="tablist" aria-label="扩展子面板">' +
|
||||
'<button type="button" class="mnote-extensions-subtab is-active" role="tab" aria-selected="true" data-extensions-subtab="knowledge">知识库</button>' +
|
||||
'<button type="button" class="mnote-extensions-subtab" role="tab" aria-selected="false" data-extensions-subtab="skills">Skills</button>' +
|
||||
'<button type="button" class="mnote-extensions-subtab" role="tab" aria-selected="false" data-extensions-subtab="mcp">MCP</button>' +
|
||||
'<button type="button" class="mnote-extensions-subtab" role="tab" aria-selected="false" data-extensions-subtab="tools">Agent 工具</button>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-extensions-subpanel" data-extensions-panel="knowledge">' +
|
||||
@@ -608,9 +607,6 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'<div class="mnote-extensions-subpanel" data-extensions-panel="skills" hidden>' +
|
||||
'<div class="mnote-extensions-status" data-extensions-status="skills"><span class="mnote-extensions-loading">加载中...</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-extensions-subpanel" data-extensions-panel="mcp" hidden>' +
|
||||
'<div class="mnote-extensions-status" data-extensions-status="mcp"><span class="mnote-extensions-loading">加载中...</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-extensions-subpanel" data-extensions-panel="tools" hidden>' +
|
||||
'<div class="mnote-extensions-status" data-extensions-status="tools"><span class="mnote-extensions-loading">加载中...</span></div>' +
|
||||
'</div>' +
|
||||
@@ -625,17 +621,12 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'<div class="mnote-dashboard-card" data-dashboard-card="skills">' +
|
||||
'<div class="mnote-dashboard-card-title">Skills</div>' +
|
||||
'<div class="mnote-dashboard-card-value" data-dashboard-value="skills">--</div>' +
|
||||
'<div class="mnote-dashboard-card-desc">已安装 / 可用</div>' +
|
||||
'<div class="mnote-dashboard-card-desc">工具清单中的 skill 能力</div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-dashboard-card" data-dashboard-card="mcp">' +
|
||||
'<div class="mnote-dashboard-card-title">MCP</div>' +
|
||||
'<div class="mnote-dashboard-card-value" data-dashboard-value="mcp">--</div>' +
|
||||
'<div class="mnote-dashboard-card-desc">服务器 / 已连接</div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-dashboard-card" data-dashboard-card="agent">' +
|
||||
'<div class="mnote-dashboard-card-title">Agent Runs</div>' +
|
||||
'<div class="mnote-dashboard-card-value" data-dashboard-value="agent">--</div>' +
|
||||
'<div class="mnote-dashboard-card-desc">最近 7 天运行次数</div>' +
|
||||
'<div class="mnote-dashboard-card" data-dashboard-card="tools">' +
|
||||
'<div class="mnote-dashboard-card-title">Agent 工具</div>' +
|
||||
'<div class="mnote-dashboard-card-value" data-dashboard-value="tools">--</div>' +
|
||||
'<div class="mnote-dashboard-card-desc">/api/mnote/tools/manifest</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="wolai-page-settings-index-add" style="margin-top:10px" data-dashboard-action="refresh">刷新仪表盘</button>' +
|
||||
@@ -3003,10 +2994,29 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
// 切换时懒加载对应面板数据
|
||||
if (subtabName === 'knowledge') refreshKnowledgeExtensionsPanel();
|
||||
else if (subtabName === 'skills') refreshSkillsExtensionsPanel();
|
||||
else if (subtabName === 'mcp') refreshMcpExtensionsPanel();
|
||||
else if (subtabName === 'tools') refreshToolsExtensionsPanel();
|
||||
}
|
||||
|
||||
async function fetchAgentToolsManifest() {
|
||||
var resp = await fetch('/api/mnote/tools/manifest');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var data = await resp.json();
|
||||
var tools = Array.isArray(data && data.tools) ? data.tools : [];
|
||||
var capabilities = data && data.capabilities && typeof data.capabilities === 'object'
|
||||
? data.capabilities
|
||||
: {};
|
||||
return { data: data, tools: tools, capabilities: capabilities };
|
||||
}
|
||||
|
||||
function isSkillRelatedTool(tool) {
|
||||
if (!tool || typeof tool !== 'object') return false;
|
||||
var name = String(tool.name || tool.function_name || '');
|
||||
if (/skill/i.test(name)) return true;
|
||||
var scopes = tool.capabilityScope || tool.capabilities || [];
|
||||
if (Array.isArray(scopes) && scopes.some(function(s) { return /skill/i.test(String(s)); })) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 当切换到扩展 tab 时刷新当前子面板
|
||||
function refreshExtensionsPanel() {
|
||||
var popover = ensurePageSettingsPopover();
|
||||
@@ -3051,26 +3061,30 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Skills 子面板 ---
|
||||
// --- Skills 子面板(来自 /api/mnote/tools/manifest,非历史 agent 网关) ---
|
||||
async function refreshSkillsExtensionsPanel() {
|
||||
var statusEl = document.querySelector('[data-extensions-status="skills"]');
|
||||
if (!statusEl) return;
|
||||
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
|
||||
try {
|
||||
var resp = await fetch('/api/page-ai/agent-descriptors');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var data = await resp.json();
|
||||
var descriptors = data.descriptors || data || [];
|
||||
// 从 agent descriptor 中收集 skills 信息
|
||||
var pack = await fetchAgentToolsManifest();
|
||||
var allSkills = [];
|
||||
descriptors.forEach(function(desc) {
|
||||
var caps = desc.capabilities || desc.capabilityStates || {};
|
||||
if (caps.skills && Array.isArray(caps.skills)) {
|
||||
caps.skills.forEach(function(s) { allSkills.push({ name: s, source: desc.name || desc.id || 'unknown' }); });
|
||||
}
|
||||
pack.tools.forEach(function(tool) {
|
||||
if (!isSkillRelatedTool(tool)) return;
|
||||
allSkills.push({
|
||||
name: tool.name || tool.function_name || 'skill',
|
||||
source: 'mnote-agent-tools'
|
||||
});
|
||||
});
|
||||
var caps = pack.capabilities;
|
||||
Object.keys(caps).forEach(function(key) {
|
||||
if (!/skill/i.test(key)) return;
|
||||
var entry = caps[key];
|
||||
var label = entry && (entry.name || entry.id) ? (entry.name || entry.id) : key;
|
||||
allSkills.push({ name: String(label), source: 'capability' });
|
||||
});
|
||||
if (!allSkills.length) {
|
||||
allSkills.push({ name: 'skill-read (内置)', source: 'hermes' });
|
||||
allSkills.push({ name: 'mnote.skill.read', source: 'builtin' });
|
||||
}
|
||||
var html = '';
|
||||
allSkills.forEach(function(skill) {
|
||||
@@ -3078,63 +3092,29 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">' + escapeHtml(skill.name) + '</span><span class="mnote-extensions-source">' + escapeHtml(skill.source) + '</span></div>' +
|
||||
'</div>';
|
||||
});
|
||||
if (!html) html = '<div class="mnote-extensions-item"><span>暂无已安装 Skills</span></div>';
|
||||
if (!html) html = '<div class="mnote-extensions-item"><span>暂无 Skills</span></div>';
|
||||
statusEl.innerHTML = html;
|
||||
} catch (err) {
|
||||
statusEl.innerHTML = '<div class="mnote-extensions-item"><span class="mnote-extensions-error">无法加载 Skills 信息</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// --- MCP 子面板 ---
|
||||
async function refreshMcpExtensionsPanel() {
|
||||
var statusEl = document.querySelector('[data-extensions-status="mcp"]');
|
||||
if (!statusEl) return;
|
||||
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
|
||||
try {
|
||||
var resp = await fetch('/api/hermes/mcp/servers');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var servers = await resp.json();
|
||||
var list = Array.isArray(servers) ? servers : (servers.servers || []);
|
||||
var html = '';
|
||||
if (list.length === 0) {
|
||||
html = '<div class="mnote-extensions-item"><span>暂无已配置 MCP 服务器</span></div>';
|
||||
} else {
|
||||
list.forEach(function(srv) {
|
||||
var name = srv.name || srv.id || '未命名';
|
||||
var url = srv.url || srv.command || '';
|
||||
var connected = srv.connected ? '已连接' : '未连接';
|
||||
var connClass = srv.connected ? 'mnote-status-ready' : 'mnote-status-pending';
|
||||
html += '<div class="mnote-extensions-item">' +
|
||||
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">' + escapeHtml(name) + '</span><span class="' + connClass + '">' + connected + '</span></div>' +
|
||||
(url ? '<div class="mnote-extensions-item-row"><span class="mnote-extensions-detail">' + escapeHtml(String(url)) + '</span></div>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
statusEl.innerHTML = html;
|
||||
} catch (err) {
|
||||
statusEl.innerHTML = '<div class="mnote-extensions-item"><span class="mnote-extensions-error">无法加载 MCP 配置</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Agent 工具子面板 ---
|
||||
async function refreshToolsExtensionsPanel() {
|
||||
var statusEl = document.querySelector('[data-extensions-status="tools"]');
|
||||
if (!statusEl) return;
|
||||
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
|
||||
try {
|
||||
var resp = await fetch('/api/page-ai/agent-descriptors');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var data = await resp.json();
|
||||
var descriptors = data.descriptors || data || [];
|
||||
var pack = await fetchAgentToolsManifest();
|
||||
var html = '';
|
||||
descriptors.forEach(function(desc) {
|
||||
var tools = desc.tools || [];
|
||||
var name = desc.name || desc.id || 'unknown';
|
||||
if (!tools.length) return;
|
||||
if (!pack.tools.length) {
|
||||
html = '<div class="mnote-extensions-item"><span>暂无 Agent 工具信息</span></div>';
|
||||
} else {
|
||||
html += '<div class="mnote-extensions-item mnote-extensions-agent-group">' +
|
||||
'<div class="mnote-extensions-agent-name">' + escapeHtml(name) + '</div>';
|
||||
tools.forEach(function(tool) {
|
||||
'<div class="mnote-extensions-agent-name">mnote agent tools</div>';
|
||||
pack.tools.forEach(function(tool) {
|
||||
var toolName = tool.name || tool.function_name || '';
|
||||
if (!toolName) return;
|
||||
var disabled = tool.disabled ? ' (已禁用)' : '';
|
||||
html += '<div class="mnote-extensions-tool-item">' +
|
||||
'<span class="mnote-extensions-tool-name">' + escapeHtml(toolName) + '</span>' +
|
||||
@@ -3142,8 +3122,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
if (!html) html = '<div class="mnote-extensions-item"><span>暂无 Agent 工具信息</span></div>';
|
||||
}
|
||||
statusEl.innerHTML = html;
|
||||
} catch (err) {
|
||||
statusEl.innerHTML = '<div class="mnote-extensions-item"><span class="mnote-extensions-error">无法加载 Agent 工具信息</span></div>';
|
||||
@@ -3371,14 +3350,9 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
// --- 仪表盘 ---
|
||||
function refreshDashboardPanel() {
|
||||
var popover = ensurePageSettingsPopover();
|
||||
// 知识库状态
|
||||
refreshDashboardKnowledgeCard(popover);
|
||||
// Skills
|
||||
refreshDashboardSkillsCard(popover);
|
||||
// MCP
|
||||
refreshDashboardMcpCard(popover);
|
||||
// Agent runs
|
||||
refreshDashboardAgentCard(popover);
|
||||
refreshDashboardToolsCard(popover);
|
||||
}
|
||||
|
||||
async function refreshDashboardKnowledgeCard(popover) {
|
||||
@@ -3408,49 +3382,22 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
if (!el) return;
|
||||
el.textContent = '加载中...';
|
||||
try {
|
||||
var resp = await fetch('/api/page-ai/agent-descriptors');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var data = await resp.json();
|
||||
var descriptors = data.descriptors || data || [];
|
||||
var count = 0;
|
||||
descriptors.forEach(function(d) {
|
||||
var caps = d.capabilities || d.capabilityStates || {};
|
||||
if (caps.skills && Array.isArray(caps.skills)) count += caps.skills.length;
|
||||
});
|
||||
if (!count) count = 1; // skill-read 内置
|
||||
var pack = await fetchAgentToolsManifest();
|
||||
var count = pack.tools.filter(isSkillRelatedTool).length;
|
||||
if (!count) count = 1; // mnote.skill.read 内置
|
||||
el.textContent = count + ' 个';
|
||||
} catch (_) {
|
||||
el.textContent = '--';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDashboardMcpCard(popover) {
|
||||
var el = popover.querySelector('[data-dashboard-value="mcp"]');
|
||||
async function refreshDashboardToolsCard(popover) {
|
||||
var el = popover.querySelector('[data-dashboard-value="tools"]');
|
||||
if (!el) return;
|
||||
el.textContent = '加载中...';
|
||||
try {
|
||||
var resp = await fetch('/api/hermes/mcp/servers');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var servers = await resp.json();
|
||||
var list = Array.isArray(servers) ? servers : (servers.servers || []);
|
||||
var connected = list.filter(function(s) { return s.connected; }).length;
|
||||
el.textContent = list.length + ' / ' + connected + ' 已连接';
|
||||
} catch (_) {
|
||||
el.textContent = '--';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDashboardAgentCard(popover) {
|
||||
var el = popover.querySelector('[data-dashboard-value="agent"]');
|
||||
if (!el) return;
|
||||
el.textContent = '加载中...';
|
||||
try {
|
||||
var resp = await fetch('/api/hermes/client/runs?limit=100');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var data = await resp.json();
|
||||
var runs = Array.isArray(data) ? data : (data.runs || []);
|
||||
var recentCount = runs.length;
|
||||
el.textContent = recentCount + ' 次';
|
||||
var pack = await fetchAgentToolsManifest();
|
||||
el.textContent = pack.tools.length + ' 个';
|
||||
} catch (_) {
|
||||
el.textContent = '--';
|
||||
}
|
||||
|
||||
@@ -2982,61 +2982,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
openSearchModal();
|
||||
}, true);
|
||||
|
||||
const sidebarPageAi = createSidebarPageAiRuntime({
|
||||
buildLocalFileOpenUrl,
|
||||
currentDocumentId,
|
||||
currentPageAggregate,
|
||||
currentPageOptions,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
escapeHtml,
|
||||
cssEscape,
|
||||
openLocalResourceInActiveTab,
|
||||
pageUiState,
|
||||
resolveWorkspaceId,
|
||||
searchText,
|
||||
});
|
||||
// Page AI 产品入口仅 Pi Lab;facade 只桥接 open / trigger / no-op delegates。
|
||||
const sidebarPageAi = createSidebarPageAiRuntime({ pageUiState });
|
||||
const openPageAiDrawer = (...args) => sidebarPageAi.openPageAiDrawer(...args);
|
||||
const closePageAiDrawer = (...args) => sidebarPageAi.closePageAiDrawer(...args);
|
||||
const isPageAiDrawerOpen = (...args) => sidebarPageAi.isPageAiDrawerOpen(...args);
|
||||
const ensurePageAiDrawer = (...args) => sidebarPageAi.ensurePageAiDrawer(...args);
|
||||
const sendPageAiMessage = (...args) => sidebarPageAi.sendPageAiMessage(...args);
|
||||
const pageAiOpenHermesSettings = (...args) => sidebarPageAi.pageAiOpenHermesSettings(...args);
|
||||
const pageAiStopRun = (...args) => sidebarPageAi.pageAiStopRun(...args);
|
||||
const pageAiLoadGatewayHealth = (...args) => sidebarPageAi.pageAiLoadGatewayHealth(...args);
|
||||
const renderPageAiControls = (...args) => sidebarPageAi.renderPageAiControls(...args);
|
||||
const renderPageAiConversation = (...args) => sidebarPageAi.renderPageAiConversation(...args);
|
||||
const renderPageAiProviderButtons = (...args) => sidebarPageAi.renderPageAiProviderButtons(...args);
|
||||
const renderPageAiSuggestions = (...args) => sidebarPageAi.renderPageAiSuggestions(...args);
|
||||
const pageAiSaveProfileMemory = (...args) => sidebarPageAi.pageAiSaveProfileMemory(...args);
|
||||
const pageAiToggleSkill = (...args) => sidebarPageAi.pageAiToggleSkill(...args);
|
||||
const pageAiToggleTool = (...args) => sidebarPageAi.pageAiToggleTool(...args);
|
||||
const pageAiResumeBackendSession = (...args) => sidebarPageAi.pageAiResumeBackendSession(...args);
|
||||
const pageAiRenameBackendSession = (...args) => sidebarPageAi.pageAiRenameBackendSession(...args);
|
||||
const pageAiDeleteBackendSession = (...args) => sidebarPageAi.pageAiDeleteBackendSession(...args);
|
||||
const pageAiResolvePermission = (...args) => sidebarPageAi.pageAiResolvePermission(...args);
|
||||
const pageAiOpenLocation = (...args) => sidebarPageAi.pageAiOpenLocation(...args);
|
||||
const pageAiSetActiveSession = (...args) => sidebarPageAi.pageAiSetActiveSession(...args);
|
||||
const pageAiStartNewSession = (...args) => sidebarPageAi.pageAiStartNewSession(...args);
|
||||
const pageAiLoadSessions = (...args) => sidebarPageAi.pageAiLoadSessions(...args);
|
||||
const pageAiLoadBackendSessions = (...args) => sidebarPageAi.pageAiLoadBackendSessions(...args);
|
||||
const pageAiCancelQueuedRun = (...args) => sidebarPageAi.pageAiCancelQueuedRun(...args);
|
||||
const pageAiSearchBackendSessions = (...args) => sidebarPageAi.pageAiSearchBackendSessions(...args);
|
||||
const pageAiPersistSessions = (...args) => sidebarPageAi.pageAiPersistSessions(...args);
|
||||
const pageAiLoadProfiles = (...args) => sidebarPageAi.pageAiLoadProfiles(...args);
|
||||
const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args);
|
||||
const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
|
||||
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
|
||||
const pageAiSetSkillSource = (...args) => sidebarPageAi.pageAiSetSkillSource(...args);
|
||||
const pageAiToggleSkillGroup = (...args) => sidebarPageAi.pageAiToggleSkillGroup(...args);
|
||||
const pageAiSetReasonixMemoryEnabled = (...args) => sidebarPageAi.pageAiSetReasonixMemoryEnabled(...args);
|
||||
const pageAiSetHideHermesBuiltinSkills = (...args) => sidebarPageAi.pageAiSetHideHermesBuiltinSkills(...args);
|
||||
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args);
|
||||
const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args);
|
||||
const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args);
|
||||
const pageAiSetAgentPopoverOpen = (...args) => sidebarPageAi.pageAiSetAgentPopoverOpen(...args);
|
||||
const pageAiSetTargetPopoverOpen = (...args) => sidebarPageAi.pageAiSetTargetPopoverOpen(...args);
|
||||
const pageAiSelectTarget = (...args) => sidebarPageAi.pageAiSelectTarget(...args);
|
||||
const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
|
||||
|
||||
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
|
||||
@@ -3374,7 +3322,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var pageAiTrigger = closestAction(e.target, '[data-mnote-action="open-page-ai"]');
|
||||
if (pageAiTrigger) {
|
||||
e.preventDefault();
|
||||
openPageAiDrawer();
|
||||
// 产品面唯一 Page AI:Pi Lab(不再打开 Hermes/OpenCode legacy drawer)
|
||||
if (window.createSidebarPageAiPiLabRuntime) {
|
||||
window.createSidebarPageAiPiLabRuntime({});
|
||||
}
|
||||
window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, window.location.origin);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -163,12 +163,17 @@
|
||||
|
||||
function ensureFormUi() {
|
||||
if (!state._formUi) {
|
||||
state._formUi = { openAccountIds: {}, expandedSecretPanels: {} };
|
||||
state._formUi = {
|
||||
openAccountIds: {},
|
||||
expandedSecretPanels: {},
|
||||
folderPickerOpen: {},
|
||||
};
|
||||
}
|
||||
if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {};
|
||||
if (!state._formUi.expandedSecretPanels) {
|
||||
state._formUi.expandedSecretPanels = {};
|
||||
}
|
||||
if (!state._formUi.folderPickerOpen) state._formUi.folderPickerOpen = {};
|
||||
return state._formUi;
|
||||
}
|
||||
|
||||
@@ -892,6 +897,8 @@
|
||||
kind: (s && s.kind) || 'apikey',
|
||||
label: (s && s.label) || '',
|
||||
value: (s && s.value) || { state: 'absent' },
|
||||
// Draft-only flag from reRenderFormKeepingSlots; absent on server items.
|
||||
valueClear: !!(s && s.valueClear),
|
||||
accountId: (s && (s.accountId || s.account_id)) || '',
|
||||
};
|
||||
}
|
||||
@@ -924,6 +931,96 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 将 ISO 时间格式化为本地可读短串;无效则原样返回。 */
|
||||
function formatVaultDateTime(raw) {
|
||||
var s = String(raw || '').trim();
|
||||
if (!s) return '';
|
||||
var d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
try {
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch (_e) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号级登录态展示(扩展 Cookie session)。
|
||||
* 优先账号槽位字段;合成单账号时回退条目级 hasLoginSession / sessionUpdatedAt。
|
||||
*/
|
||||
function accountSessionView(acc, item, isSyntheticSingle) {
|
||||
var has =
|
||||
acc && typeof acc.hasLoginSession === 'boolean'
|
||||
? acc.hasLoginSession
|
||||
: isSyntheticSingle && item
|
||||
? !!item.hasLoginSession
|
||||
: false;
|
||||
var savedAt =
|
||||
(acc && (acc.lastLoginAt || acc.sessionUpdatedAt)) ||
|
||||
(isSyntheticSingle && item
|
||||
? item.lastLoginAt || item.sessionUpdatedAt || ''
|
||||
: '') ||
|
||||
'';
|
||||
var expiresAt =
|
||||
(acc && acc.sessionExpiresAt) ||
|
||||
(isSyntheticSingle && item ? item.sessionExpiresAt || '' : '') ||
|
||||
'';
|
||||
return {
|
||||
hasLoginSession: !!has,
|
||||
savedAt: String(savedAt || '').trim(),
|
||||
expiresAt: String(expiresAt || '').trim(),
|
||||
source: (acc && acc.sessionSource) || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 详情页:账号下「是否保存登录态 + 保存日期」行。 */
|
||||
function renderAccountSessionRows(sess, showEmpty) {
|
||||
if (!sess.hasLoginSession && !showEmpty) {
|
||||
// 未保存时也简短展示一行,避免用户以为功能缺失
|
||||
return (
|
||||
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session">' +
|
||||
'<label>登录态</label>' +
|
||||
'<div><span class="mnote-vault-session-badge is-absent">未保存</span></div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
var badge = sess.hasLoginSession
|
||||
? '<span class="mnote-vault-session-badge is-saved" data-testid="vault-session-saved">已保存</span>'
|
||||
: '<span class="mnote-vault-session-badge is-absent" data-testid="vault-session-absent">未保存</span>';
|
||||
var dateText = sess.hasLoginSession
|
||||
? formatVaultDateTime(sess.savedAt) || '—'
|
||||
: '—';
|
||||
var html =
|
||||
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session">' +
|
||||
'<label>登录态</label>' +
|
||||
'<div class="mnote-vault-session-meta">' +
|
||||
badge +
|
||||
'</div></div>' +
|
||||
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session-date">' +
|
||||
'<label>保存日期</label>' +
|
||||
'<div>' +
|
||||
(sess.hasLoginSession
|
||||
? escapeHtml(dateText)
|
||||
: '<span class="mnote-vault-muted">—</span>') +
|
||||
'</div></div>';
|
||||
if (sess.hasLoginSession && sess.expiresAt) {
|
||||
html +=
|
||||
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session-expires">' +
|
||||
'<label>过期时间</label>' +
|
||||
'<div>' +
|
||||
escapeHtml(formatVaultDateTime(sess.expiresAt) || sess.expiresAt) +
|
||||
'</div></div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function normalizeAccountsFromItem(item, forEdit) {
|
||||
var topSecrets = normalizeSecretsFromItem(item);
|
||||
var accounts = (item && item.accounts) || [];
|
||||
@@ -941,6 +1038,25 @@
|
||||
return s.accountId && s.accountId === a.id;
|
||||
});
|
||||
}
|
||||
// 多账号时:若槽位未挂 session 元数据,首账号可回退条目级(兼容旧投影)
|
||||
var hasSess =
|
||||
typeof a.hasLoginSession === 'boolean'
|
||||
? a.hasLoginSession
|
||||
: aidx === 0
|
||||
? !!(item && item.hasLoginSession)
|
||||
: false;
|
||||
var lastLogin =
|
||||
a.lastLoginAt ||
|
||||
(aidx === 0 && item ? item.lastLoginAt : null) ||
|
||||
null;
|
||||
var sessUpdated =
|
||||
a.sessionUpdatedAt ||
|
||||
(aidx === 0 && item ? item.sessionUpdatedAt : null) ||
|
||||
null;
|
||||
var sessExpires =
|
||||
a.sessionExpiresAt ||
|
||||
(aidx === 0 && item ? item.sessionExpiresAt : null) ||
|
||||
null;
|
||||
return {
|
||||
id: a.id || '',
|
||||
label: a.label || '',
|
||||
@@ -952,7 +1068,14 @@
|
||||
: a.email || '',
|
||||
passwordHint: a.passwordHint || '',
|
||||
password: a.password || { state: 'absent' },
|
||||
// Draft-only flag from reRenderFormKeepingSlots; absent on server items.
|
||||
passwordClear: !!a.passwordClear,
|
||||
secrets: nested,
|
||||
hasLoginSession: hasSess,
|
||||
lastLoginAt: lastLogin,
|
||||
sessionUpdatedAt: sessUpdated,
|
||||
sessionExpiresAt: sessExpires,
|
||||
sessionSource: a.sessionSource || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -974,6 +1097,13 @@
|
||||
passwordHint: (item && item.passwordHint) || '',
|
||||
password: (item && item.password) || { state: 'absent' },
|
||||
secrets: topSecrets,
|
||||
// 合成单账号:登录态取条目级
|
||||
hasLoginSession: !!(item && item.hasLoginSession),
|
||||
lastLoginAt: (item && item.lastLoginAt) || null,
|
||||
sessionUpdatedAt: (item && item.sessionUpdatedAt) || null,
|
||||
sessionExpiresAt: (item && item.sessionExpiresAt) || null,
|
||||
sessionSource: '',
|
||||
_syntheticFromItem: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -986,6 +1116,12 @@
|
||||
passwordHint: '',
|
||||
password: { state: 'absent' },
|
||||
secrets: [],
|
||||
hasLoginSession: !!(item && item.hasLoginSession),
|
||||
lastLoginAt: (item && item.lastLoginAt) || null,
|
||||
sessionUpdatedAt: (item && item.sessionUpdatedAt) || null,
|
||||
sessionExpiresAt: (item && item.sessionExpiresAt) || null,
|
||||
sessionSource: '',
|
||||
_syntheticFromItem: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1157,6 +1293,11 @@
|
||||
acc.username ||
|
||||
acc.email ||
|
||||
'账号 ' + (idx + 1);
|
||||
var sess = accountSessionView(
|
||||
acc,
|
||||
item,
|
||||
!!acc._syntheticFromItem || accounts.length === 1
|
||||
);
|
||||
var body =
|
||||
fieldRow('标签名', acc.label, showEmpty) +
|
||||
fieldRow('用户名', acc.username, showEmpty) +
|
||||
@@ -1164,7 +1305,8 @@
|
||||
secretRow('密码', 'password', acc.password, showEmpty, {
|
||||
accountId: acc.id || '',
|
||||
}) +
|
||||
fieldRow('密码提示', acc.passwordHint, showEmpty);
|
||||
fieldRow('密码提示', acc.passwordHint, showEmpty) +
|
||||
renderAccountSessionRows(sess, showEmpty);
|
||||
var secList = acc.secrets || [];
|
||||
var nestedSecretsHtml = secList
|
||||
.map(function (sec, sidx) {
|
||||
@@ -1220,6 +1362,9 @@
|
||||
(secList.length
|
||||
? '<span class="mnote-vault-muted">密钥 ' + secList.length + '</span>'
|
||||
: '') +
|
||||
(sess.hasLoginSession
|
||||
? '<span class="mnote-vault-session-badge is-saved is-compact" title="已保存登录态">登录态</span>'
|
||||
: '') +
|
||||
'</summary>' +
|
||||
'<div class="mnote-vault-slot-body">' +
|
||||
body +
|
||||
@@ -1329,7 +1474,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
/** Unique folderPath values from current list (for select / datalist). */
|
||||
/** Unique folderPath values from current list (for picker / datalist). */
|
||||
function collectFolderPaths() {
|
||||
var set = {};
|
||||
(state.items || []).forEach(function (item) {
|
||||
@@ -1346,47 +1491,152 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested tree of known folderPath segments for the expand-style picker.
|
||||
* @returns {{ name: string, path: string, children: Array }}
|
||||
*/
|
||||
function buildFolderPickerTree(paths) {
|
||||
var root = { name: '', path: '', children: [] };
|
||||
var byPath = { '': root };
|
||||
(paths || []).forEach(function (fp) {
|
||||
var parts = String(fp || '')
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
var acc = [];
|
||||
var parent = root;
|
||||
parts.forEach(function (part) {
|
||||
acc.push(part);
|
||||
var p = acc.join('/');
|
||||
if (!byPath[p]) {
|
||||
var node = { name: part, path: p, children: [] };
|
||||
byPath[p] = node;
|
||||
parent.children.push(node);
|
||||
}
|
||||
parent = byPath[p];
|
||||
});
|
||||
});
|
||||
function sortNodes(nodes) {
|
||||
nodes.sort(function (a, b) {
|
||||
return a.name.localeCompare(b.name, 'zh');
|
||||
});
|
||||
nodes.forEach(function (n) {
|
||||
if (n.children && n.children.length) sortNodes(n.children);
|
||||
});
|
||||
}
|
||||
sortNodes(root.children);
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a picker branch starts expanded.
|
||||
* Default: all collapsed. Only expand ancestors of the current selection
|
||||
* (so the selected path stays visible), or branches the user toggled open.
|
||||
*/
|
||||
function isFolderPickerBranchOpen(path, depth, current) {
|
||||
var ui = ensureFormUi();
|
||||
if (!ui.folderPickerOpen) ui.folderPickerOpen = {};
|
||||
if (Object.prototype.hasOwnProperty.call(ui.folderPickerOpen, path)) {
|
||||
return !!ui.folderPickerOpen[path];
|
||||
}
|
||||
// Only auto-open ancestors of the selected path (not the leaf itself unless it has kids under selection).
|
||||
if (current && path && current.indexOf(path + '/') === 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderFolderPickerNode(node, depth, current) {
|
||||
var kids = node.children || [];
|
||||
var hasKids = kids.length > 0;
|
||||
var selected = current === node.path;
|
||||
var open = hasKids && isFolderPickerBranchOpen(node.path, depth, current);
|
||||
var html = '';
|
||||
html +=
|
||||
'<div class="mnote-vault-folder-picker-node" data-vault-picker-path="' +
|
||||
escapeHtml(node.path) +
|
||||
'" style="--vault-picker-depth:' +
|
||||
depth +
|
||||
'">';
|
||||
html += '<div class="mnote-vault-folder-picker-row' + (selected ? ' is-selected' : '') + '">';
|
||||
if (hasKids) {
|
||||
html +=
|
||||
'<button type="button" class="mnote-vault-folder-picker-chevron" data-vault-folder-picker-toggle="' +
|
||||
escapeHtml(node.path) +
|
||||
'" aria-expanded="' +
|
||||
(open ? 'true' : 'false') +
|
||||
'" title="' +
|
||||
(open ? '收起' : '展开') +
|
||||
'">' +
|
||||
(open ? '▼' : '▶') +
|
||||
'</button>';
|
||||
} else {
|
||||
html += '<span class="mnote-vault-folder-picker-chevron is-leaf" aria-hidden="true"></span>';
|
||||
}
|
||||
html +=
|
||||
'<button type="button" class="mnote-vault-folder-picker-label" data-vault-folder-pick="' +
|
||||
escapeHtml(node.path) +
|
||||
'" data-testid="vault-folder-pick-' +
|
||||
escapeHtml(node.path || 'root') +
|
||||
'">' +
|
||||
escapeHtml(node.name || node.path) +
|
||||
'</button>';
|
||||
html += '</div>';
|
||||
if (hasKids) {
|
||||
html +=
|
||||
'<div class="mnote-vault-folder-picker-children"' +
|
||||
(open ? '' : ' hidden') +
|
||||
'>';
|
||||
kids.forEach(function (child) {
|
||||
html += renderFolderPickerNode(child, depth + 1, current);
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function formFolderPathField(value) {
|
||||
var current = (value || '').trim();
|
||||
var current = normalizeFolderPath(value || '');
|
||||
var paths = collectFolderPaths();
|
||||
var seen = {};
|
||||
paths.forEach(function (p) {
|
||||
seen[p] = true;
|
||||
});
|
||||
var options =
|
||||
'<option value=""' +
|
||||
(!current ? ' selected' : '') +
|
||||
'>(无分组)</option>';
|
||||
paths.forEach(function (p) {
|
||||
options +=
|
||||
'<option value="' +
|
||||
escapeHtml(p) +
|
||||
'"' +
|
||||
(p === current ? ' selected' : '') +
|
||||
'>' +
|
||||
escapeHtml(p) +
|
||||
'</option>';
|
||||
});
|
||||
// 当前值若不在列表中,仍挂到树上便于高亮/再选。
|
||||
var treePaths = paths.slice();
|
||||
if (current && !seen[current]) {
|
||||
options +=
|
||||
'<option value="' +
|
||||
escapeHtml(current) +
|
||||
'" selected>' +
|
||||
escapeHtml(current) +
|
||||
'</option>';
|
||||
treePaths.push(current);
|
||||
var parts = current.split('/').filter(Boolean);
|
||||
for (var i = 1; i < parts.length; i++) {
|
||||
var prefix = parts.slice(0, i).join('/');
|
||||
if (treePaths.indexOf(prefix) < 0) treePaths.push(prefix);
|
||||
}
|
||||
}
|
||||
var tree = buildFolderPickerTree(treePaths);
|
||||
var treeHtml = '';
|
||||
treeHtml +=
|
||||
'<button type="button" class="mnote-vault-folder-picker-none' +
|
||||
(!current ? ' is-selected' : '') +
|
||||
'" data-vault-folder-pick="" data-testid="vault-folder-pick-none">(无分组)</button>';
|
||||
tree.children.forEach(function (child) {
|
||||
treeHtml += renderFolderPickerNode(child, 0, current);
|
||||
});
|
||||
if (!tree.children.length && !current) {
|
||||
treeHtml +=
|
||||
'<div class="mnote-vault-folder-picker-empty mnote-vault-muted">暂无已有分组,可在下方输入新建</div>';
|
||||
}
|
||||
return (
|
||||
'<div class="mnote-vault-field-row mnote-vault-folder-field">' +
|
||||
'<label for="vault-f-folderPath">分组</label>' +
|
||||
'<div class="mnote-vault-folder-controls">' +
|
||||
'<select id="vault-f-folderSelect" data-vault-folder-select data-testid="vault-folder-select" aria-label="选择分组">' +
|
||||
options +
|
||||
'</select>' +
|
||||
'<div class="mnote-vault-folder-picker" data-vault-folder-picker data-testid="vault-folder-select" role="listbox" aria-label="选择分组">' +
|
||||
treeHtml +
|
||||
'</div>' +
|
||||
'<input id="vault-f-folderPath" name="folderPath" type="text" ' +
|
||||
'value="' +
|
||||
escapeHtml(current) +
|
||||
'" placeholder="新分组可直接输入,用 / 分层" ' +
|
||||
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" />' +
|
||||
'" placeholder="点上方分组选择,或直接输入;用 / 分层" ' +
|
||||
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" autocomplete="off" />' +
|
||||
'<datalist id="vault-folder-path-list">' +
|
||||
paths
|
||||
.map(function (p) {
|
||||
@@ -1394,39 +1644,75 @@
|
||||
})
|
||||
.join('') +
|
||||
'</datalist>' +
|
||||
'<p class="mnote-vault-form-hint mnote-vault-folder-hint">分组默认折叠;点 ▶ 展开,点名称即可选中。也可在下方直接输入路径。</p>' +
|
||||
'</div></div>'
|
||||
);
|
||||
}
|
||||
|
||||
function syncFolderPickerSelection(form, path) {
|
||||
if (!form) return;
|
||||
var picker = qs('[data-vault-folder-picker]', form);
|
||||
if (!picker) return;
|
||||
var normalized = normalizeFolderPath(path || '');
|
||||
qsa('[data-vault-folder-pick]', picker).forEach(function (btn) {
|
||||
var p = btn.getAttribute('data-vault-folder-pick');
|
||||
if (p == null) p = '';
|
||||
var row = btn.closest('.mnote-vault-folder-picker-row');
|
||||
var selected = p === normalized;
|
||||
btn.classList.toggle('is-selected', selected);
|
||||
if (row) row.classList.toggle('is-selected', selected);
|
||||
if (btn.classList.contains('mnote-vault-folder-picker-none')) {
|
||||
btn.classList.toggle('is-selected', selected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindFolderPathControls(form) {
|
||||
if (!form) return;
|
||||
var select = qs('[data-vault-folder-select]', form);
|
||||
var input = qs('[data-vault-folder-input]', form);
|
||||
if (select && input) {
|
||||
select.addEventListener('change', function () {
|
||||
input.value = select.value || '';
|
||||
state.dirty = true;
|
||||
var picker = qs('[data-vault-folder-picker]', form);
|
||||
if (picker) {
|
||||
picker.addEventListener('click', function (ev) {
|
||||
var t = ev.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
var toggle = t.closest('[data-vault-folder-picker-toggle]');
|
||||
if (toggle && picker.contains(toggle)) {
|
||||
ev.preventDefault();
|
||||
var tpath = toggle.getAttribute('data-vault-folder-picker-toggle') || '';
|
||||
var nodeEl = toggle.closest('.mnote-vault-folder-picker-node');
|
||||
// First matching descendants is the direct children panel for this node.
|
||||
var kidsEl =
|
||||
nodeEl && nodeEl.querySelector('.mnote-vault-folder-picker-children');
|
||||
var open = toggle.getAttribute('aria-expanded') === 'true';
|
||||
var next = !open;
|
||||
ensureFormUi().folderPickerOpen = ensureFormUi().folderPickerOpen || {};
|
||||
ensureFormUi().folderPickerOpen[tpath] = next;
|
||||
toggle.setAttribute('aria-expanded', next ? 'true' : 'false');
|
||||
toggle.textContent = next ? '▼' : '▶';
|
||||
toggle.setAttribute('title', next ? '收起' : '展开');
|
||||
if (kidsEl) kidsEl.hidden = !next;
|
||||
return;
|
||||
}
|
||||
var pick = t.closest('[data-vault-folder-pick]');
|
||||
if (pick && picker.contains(pick)) {
|
||||
ev.preventDefault();
|
||||
var chosen = pick.getAttribute('data-vault-folder-pick');
|
||||
if (chosen == null) chosen = '';
|
||||
chosen = normalizeFolderPath(chosen);
|
||||
if (input) input.value = chosen;
|
||||
syncFolderPickerSelection(form, chosen);
|
||||
state.dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (input) {
|
||||
input.addEventListener('input', function () {
|
||||
var n = normalizeFolderPath(input.value);
|
||||
// keep select in sync when user picks a known path via typing
|
||||
if (select) {
|
||||
var match = false;
|
||||
for (var i = 0; i < select.options.length; i++) {
|
||||
if (select.options[i].value === n) {
|
||||
select.selectedIndex = i;
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) select.selectedIndex = 0;
|
||||
}
|
||||
syncFolderPickerSelection(form, input.value);
|
||||
});
|
||||
input.addEventListener('change', function () {
|
||||
var n = normalizeFolderPath(input.value);
|
||||
input.value = n;
|
||||
syncFolderPickerSelection(form, n);
|
||||
state.dirty = true;
|
||||
});
|
||||
}
|
||||
@@ -1439,24 +1725,55 @@
|
||||
var urls = normalizeUrlsFromItem(item);
|
||||
if (!urls.length) urls = [''];
|
||||
state._formUrls = urls.slice();
|
||||
/**
|
||||
* Rebuild draft slots from item projection.
|
||||
* reRenderFormKeepingSlots injects in-progress plaintext as
|
||||
* `{ state: 'revealed', value }` — must become valueText/passwordValue,
|
||||
* not be wiped to '' (that made the 1st new secret disappear after +密钥).
|
||||
* valueClear/passwordClear on the pseudo-item are also preserved.
|
||||
*/
|
||||
state._formAccounts = accounts.map(function (a) {
|
||||
var pwd = a.password || { state: 'absent' };
|
||||
var passwordClear = !!a.passwordClear;
|
||||
var passwordValue = '';
|
||||
var passwordState = pwd;
|
||||
if (passwordClear) {
|
||||
passwordState = { state: 'absent' };
|
||||
passwordValue = '';
|
||||
} else if (pwd && pwd.state === 'revealed') {
|
||||
// Draft plaintext typed in this session (not yet saved / mid re-render).
|
||||
passwordValue = String(pwd.value || '');
|
||||
// Treat as "no stored secret yet" for keep-logic; save uses passwordValue.
|
||||
passwordState = { state: 'absent' };
|
||||
}
|
||||
return {
|
||||
id: a.id || newClientId('acc'),
|
||||
label: a.label || '',
|
||||
username: a.username || '',
|
||||
email: a.email || '',
|
||||
passwordHint: a.passwordHint || '',
|
||||
passwordState: a.password || { state: 'absent' },
|
||||
passwordClear: false,
|
||||
passwordValue: '',
|
||||
passwordState: passwordState,
|
||||
passwordClear: passwordClear,
|
||||
passwordValue: passwordValue,
|
||||
secrets: (a.secrets || []).map(function (s) {
|
||||
var v = s.value || { state: 'absent' };
|
||||
var valueClear = !!s.valueClear;
|
||||
var valueText = '';
|
||||
var valueState = v;
|
||||
if (valueClear) {
|
||||
valueState = { state: 'absent' };
|
||||
valueText = '';
|
||||
} else if (v && v.state === 'revealed') {
|
||||
valueText = String(v.value || '');
|
||||
valueState = { state: 'absent' };
|
||||
}
|
||||
return {
|
||||
id: s.id || newClientId('sec'),
|
||||
kind: s.kind || 'apikey',
|
||||
label: s.label || '',
|
||||
valueState: s.value || { state: 'absent' },
|
||||
valueClear: false,
|
||||
valueText: '',
|
||||
valueState: valueState,
|
||||
valueClear: valueClear,
|
||||
valueText: valueText,
|
||||
accountId: a.id || '',
|
||||
};
|
||||
}),
|
||||
@@ -1592,9 +1909,11 @@
|
||||
'<div class="mnote-vault-secret-edit">' +
|
||||
'<input type="text" autocomplete="off" spellcheck="false" data-acc-field="password" data-acc-id="' +
|
||||
escapeHtml(acc.id) +
|
||||
'" class="mnote-vault-secret-input-plain" placeholder="' +
|
||||
'" class="mnote-vault-secret-input-plain" value="' +
|
||||
escapeHtml(acc.passwordValue || '') +
|
||||
'" placeholder="' +
|
||||
escapeHtml(
|
||||
!isCreate && hasPwd
|
||||
!isCreate && hasPwd && !acc.passwordValue
|
||||
? '留空不改;输入新值覆盖'
|
||||
: '可选;可用 [Key] 密文片段'
|
||||
) +
|
||||
@@ -1602,7 +1921,11 @@
|
||||
(!isCreate && hasPwd
|
||||
? '<button type="button" data-acc-show-password="' +
|
||||
escapeHtml(acc.id) +
|
||||
'" data-testid="vault-acc-show-password">显示</button>' +
|
||||
'"' +
|
||||
(acc.passwordValue ? ' data-shown="1"' : '') +
|
||||
' data-testid="vault-acc-show-password">' +
|
||||
(acc.passwordValue ? '隐藏' : '显示') +
|
||||
'</button>' +
|
||||
'<button type="button" data-acc-clear-password="' +
|
||||
escapeHtml(acc.id) +
|
||||
'">清空</button>'
|
||||
@@ -1624,6 +1947,7 @@
|
||||
sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key';
|
||||
var title = sec.label || kindLabel + ' ' + (idx + 1);
|
||||
var hasVal = sec.valueState && sec.valueState.state === 'masked';
|
||||
var draftText = sec.valueText || '';
|
||||
return (
|
||||
'<div class="mnote-vault-nested-secret" data-vault-secret-slot="' +
|
||||
escapeHtml(sec.id) +
|
||||
@@ -1672,15 +1996,21 @@
|
||||
escapeHtml(sec.id) +
|
||||
'" data-acc-id="' +
|
||||
escapeHtml(accountId) +
|
||||
'" value="" placeholder="' +
|
||||
escapeHtml(hasVal ? '留空不改;输入新值覆盖' : '密钥明文') +
|
||||
'" value="' +
|
||||
escapeHtml(draftText) +
|
||||
'" placeholder="' +
|
||||
escapeHtml(hasVal && !draftText ? '留空不改;输入新值覆盖' : '密钥明文') +
|
||||
'" />' +
|
||||
(hasVal
|
||||
? '<button type="button" data-sec-show-value="' +
|
||||
escapeHtml(sec.id) +
|
||||
'" data-acc-id="' +
|
||||
escapeHtml(accountId) +
|
||||
'" data-testid="vault-sec-show-value">显示</button>' +
|
||||
'"' +
|
||||
(draftText ? ' data-shown="1"' : '') +
|
||||
' data-testid="vault-sec-show-value">' +
|
||||
(draftText ? '隐藏' : '显示') +
|
||||
'</button>' +
|
||||
'<button type="button" data-sec-clear-value="' +
|
||||
escapeHtml(sec.id) +
|
||||
'" data-acc-id="' +
|
||||
@@ -1830,6 +2160,8 @@
|
||||
username: a.username,
|
||||
email: a.email,
|
||||
passwordHint: a.passwordHint,
|
||||
// Flags survive re-render so clear intent is not lost.
|
||||
passwordClear: !!a.passwordClear,
|
||||
password: a.passwordClear
|
||||
? { state: 'absent' }
|
||||
: a.passwordValue
|
||||
@@ -1841,6 +2173,7 @@
|
||||
kind: s.kind,
|
||||
label: s.label,
|
||||
accountId: a.id,
|
||||
valueClear: !!s.valueClear,
|
||||
value: s.valueClear
|
||||
? { state: 'absent' }
|
||||
: s.valueText
|
||||
@@ -2040,13 +2373,30 @@
|
||||
var folderPath = normalizeFolderPath(String(fd.get('folderPath') || '').trim());
|
||||
var accounts = (state._formAccounts || [])
|
||||
.map(function (acc) {
|
||||
// Prefer live input draft; fall back to revealed draft left by re-render inject.
|
||||
var raw = String(acc.passwordValue || '');
|
||||
if (
|
||||
!raw &&
|
||||
acc.passwordState &&
|
||||
acc.passwordState.state === 'revealed' &&
|
||||
acc.passwordState.value
|
||||
) {
|
||||
raw = String(acc.passwordState.value);
|
||||
}
|
||||
if (/^[•*·]+$/.test(raw) || raw === '••••••••') {
|
||||
toast('不能将掩码写回 password', 'error');
|
||||
throw new Error('mask');
|
||||
}
|
||||
var nestedSecrets = (acc.secrets || []).map(function (sec) {
|
||||
var sraw = String(sec.valueText || '');
|
||||
if (
|
||||
!sraw &&
|
||||
sec.valueState &&
|
||||
sec.valueState.state === 'revealed' &&
|
||||
sec.valueState.value
|
||||
) {
|
||||
sraw = String(sec.valueState.value);
|
||||
}
|
||||
if (/^[•*·]+$/.test(sraw) || sraw === '••••••••') {
|
||||
toast('不能将掩码写回 secret', 'error');
|
||||
throw new Error('mask');
|
||||
|
||||
@@ -1,621 +0,0 @@
|
||||
/// ACP ↔ SSE bridge for Hermes route integration.
|
||||
///
|
||||
/// Transforms ACP session events into the SSE event format expected by the
|
||||
/// frontend (HermesRunEvent), and manages the background prompt lifecycle.
|
||||
///
|
||||
/// Reference: `hermes-vscode-main/src/sessionManager.ts` handleUpdate()
|
||||
use crate::acp_runtime::AcpRuntimeManager;
|
||||
use crate::acp_session_manager::{AcpSessionEvent, AcpSessionManager};
|
||||
use crate::acp_types::ContentBlock;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::Response;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Errors from the ACP bridge.
|
||||
#[derive(Debug)]
|
||||
pub enum AcpBridgeError {
|
||||
NoActiveRuntime,
|
||||
SessionError(String),
|
||||
StreamError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AcpBridgeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AcpBridgeError::NoActiveRuntime => write!(f, "no active ACP runtime"),
|
||||
AcpBridgeError::SessionError(msg) => write!(f, "ACP session error: {msg}"),
|
||||
AcpBridgeError::StreamError(msg) => write!(f, "ACP stream error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE event types sent to the frontend.
|
||||
/// Mirrors HermesRunEvent from bridge.ts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SseEvent {
|
||||
pub event: String,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
/// Bridge state for one run: holds the broadcast channel for SSE events.
|
||||
pub struct AcpRunBridge {
|
||||
session_id: String,
|
||||
event_tx: broadcast::Sender<SseEvent>,
|
||||
}
|
||||
|
||||
fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
|
||||
fn add_citation_value(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
|
||||
let Some(citation) = value.get("citationMarkdown").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let citation = citation.trim();
|
||||
if citation.is_empty() || !seen.insert(citation.to_string()) {
|
||||
return;
|
||||
}
|
||||
out.push(json!({
|
||||
"schema": value.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
|
||||
"citationMarkdown": citation,
|
||||
"citationId": value.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": value.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": value.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"sourcePath": value.get("sourcePath").cloned().unwrap_or(Value::Null),
|
||||
"filePath": value.get("filePath").or_else(|| value.get("lightRagFilePath")).cloned().unwrap_or(Value::Null),
|
||||
"headingPath": value.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
||||
"displayQuote": value.get("displayQuote").or_else(|| value.get("quote")).cloned().unwrap_or(Value::Null),
|
||||
"locatorEvidenceText": value.get("locatorEvidenceText").cloned().unwrap_or(Value::Null),
|
||||
"locatorPrecision": value.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||
"locatorDegraded": value.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||
"citationUrl": value.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
}
|
||||
|
||||
fn add_reference_citations(
|
||||
references: &[Value],
|
||||
seen: &mut HashSet<String>,
|
||||
out: &mut Vec<Value>,
|
||||
) -> bool {
|
||||
let has_precise = references.iter().any(|reference| {
|
||||
reference
|
||||
.get("citationMarkdown")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
|
||||
});
|
||||
let mut added = false;
|
||||
for reference in references {
|
||||
if out.len() >= 8 {
|
||||
break;
|
||||
}
|
||||
let Some(_citation) = reference.get("citationMarkdown").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if has_precise
|
||||
&& reference.get("locatorDegraded").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let before = out.len();
|
||||
add_citation_value(reference, seen, out);
|
||||
added = added || out.len() > before;
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
fn visit(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
|
||||
if out.len() >= 8 {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
if (trimmed.starts_with('{') || trimmed.starts_with('['))
|
||||
&& trimmed.contains("citationMarkdown")
|
||||
{
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
|
||||
visit(&parsed, seen, out);
|
||||
} else if let Some(first_line) = trimmed.lines().next() {
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(first_line.trim()) {
|
||||
visit(&parsed, seen, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
visit(item, seen, out);
|
||||
if out.len() >= 8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
let has_filtered_references = map
|
||||
.get("references")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|references| add_reference_citations(references, seen, out));
|
||||
if let Some(_citation) = map.get("citationMarkdown").and_then(Value::as_str) {
|
||||
if !has_filtered_references {
|
||||
add_citation_value(value, seen, out);
|
||||
}
|
||||
}
|
||||
for (key, item) in map {
|
||||
if has_filtered_references
|
||||
&& matches!(key.as_str(), "references" | "citations" | "uiCitations")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
visit(item, seen, out);
|
||||
if out.len() >= 8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
visit(value, &mut seen, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
impl AcpRunBridge {
|
||||
/// Create a new ACP run: create session + start prompt in background.
|
||||
///
|
||||
/// Returns a bridge with a broadcast receiver that the SSE endpoint can use.
|
||||
pub async fn start(
|
||||
runtime_mgr: &AcpRuntimeManager,
|
||||
runtime_name: &str,
|
||||
prompt_blocks: Vec<ContentBlock>,
|
||||
) -> Result<Self, AcpBridgeError> {
|
||||
// Get or activate the runtime
|
||||
let client = if runtime_mgr.is_active().await {
|
||||
runtime_mgr
|
||||
.active_client()
|
||||
.await
|
||||
.ok_or(AcpBridgeError::NoActiveRuntime)?
|
||||
} else {
|
||||
runtime_mgr
|
||||
.switch_to(runtime_name)
|
||||
.await
|
||||
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?
|
||||
};
|
||||
|
||||
// Create session manager
|
||||
let mgr = Arc::new(AcpSessionManager::new(client));
|
||||
|
||||
// Create event channel (256 buffered, enough for SSE streaming)
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
let event_tx_clone = event_tx.clone();
|
||||
|
||||
// Set up event handler
|
||||
mgr.on_event(move |event| {
|
||||
if let Some(sse) = acp_event_to_sse(event) {
|
||||
let _ = event_tx_clone.send(sse);
|
||||
}
|
||||
});
|
||||
|
||||
// Create session
|
||||
let sid = mgr
|
||||
.create_session(None, None)
|
||||
.await
|
||||
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?;
|
||||
|
||||
// Start prompt in background
|
||||
let mgr_clone = mgr.clone();
|
||||
let event_tx_prompt = event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
match mgr_clone.run_prompt(prompt_blocks).await {
|
||||
Ok(result) => {
|
||||
info!("ACP prompt completed: stop_reason={:?}", result.stop_reason);
|
||||
let _ = event_tx_prompt.send(SseEvent {
|
||||
event: "run.completed".into(),
|
||||
data: json!({
|
||||
"stopReason": format!("{:?}", result.stop_reason),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("ACP prompt failed: {e}");
|
||||
let _ = event_tx_prompt.send(SseEvent {
|
||||
event: "run.failed".into(),
|
||||
data: json!({ "error": e.to_string() }),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
info!("ACP run started: session={}", sid);
|
||||
Ok(Self {
|
||||
session_id: sid,
|
||||
event_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cancel the current run.
|
||||
pub async fn abort(&self) {
|
||||
// Cancellation is sent via the session manager.
|
||||
// For now, we just drop the bridge — the background task will detect this
|
||||
// via the broadcast channel being closed.
|
||||
info!("ACP run aborted: session={}", self.session_id);
|
||||
}
|
||||
|
||||
/// Create an SSE response body from the event broadcast receiver.
|
||||
pub fn into_sse_response(self) -> Response {
|
||||
use tokio::sync::mpsc;
|
||||
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
|
||||
let mut broadcast_rx = self.event_tx.subscribe();
|
||||
|
||||
// Forward events from broadcast to mpsc
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match broadcast_rx.recv().await {
|
||||
Ok(event) => {
|
||||
let json =
|
||||
serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
|
||||
let bytes = axum::body::Bytes::from(format!(
|
||||
"event: {}\ndata: {}\n\n",
|
||||
event.event, json
|
||||
));
|
||||
if tx.send(Ok(bytes)).await.is_err() {
|
||||
break; // receiver dropped
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("ACP SSE lagged: {n} events dropped");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break; // stream ended
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||
.header("x-accel-buffering", "no")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap_or_else(|_| {
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an AcpSessionEvent to an SSE event for the frontend.
|
||||
///
|
||||
/// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate
|
||||
/// Reference: recycle/wolai-frontend bridge.ts HermesRunEvent type (historic)
|
||||
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
||||
match event {
|
||||
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
|
||||
event: "message.delta".into(),
|
||||
data: json!({ "delta": text }),
|
||||
}),
|
||||
AcpSessionEvent::ThoughtDelta { text } => Some(SseEvent {
|
||||
event: "thought.delta".into(),
|
||||
data: json!({ "delta": text }),
|
||||
}),
|
||||
AcpSessionEvent::ToolCall {
|
||||
tool_call_id,
|
||||
title,
|
||||
kind,
|
||||
status,
|
||||
raw_input,
|
||||
locations,
|
||||
} => Some(SseEvent {
|
||||
event: "tool.started".into(),
|
||||
data: json!({
|
||||
"tool": title,
|
||||
"toolCallId": tool_call_id,
|
||||
"kind": kind,
|
||||
"status": status,
|
||||
"input": raw_input,
|
||||
"locations": locations,
|
||||
}),
|
||||
}),
|
||||
AcpSessionEvent::ToolCallUpdate {
|
||||
tool_call_id,
|
||||
status,
|
||||
content,
|
||||
} => {
|
||||
let output = json!(content);
|
||||
let citation_markdowns = collect_citation_markdowns_from_value(&output);
|
||||
let error = status == crate::acp_types::ToolCallStatus::Failed;
|
||||
let event = if error {
|
||||
"tool.failed"
|
||||
} else if status == crate::acp_types::ToolCallStatus::Completed {
|
||||
"tool.completed"
|
||||
} else {
|
||||
"tool.started"
|
||||
};
|
||||
Some(SseEvent {
|
||||
event: event.into(),
|
||||
data: json!({
|
||||
"toolCallId": tool_call_id,
|
||||
"status": status,
|
||||
"error": error,
|
||||
"output": output,
|
||||
"citationMarkdowns": citation_markdowns,
|
||||
}),
|
||||
})
|
||||
}
|
||||
AcpSessionEvent::UsageUpdate { used, size } => Some(SseEvent {
|
||||
event: "usage.updated".into(),
|
||||
data: json!({ "used": used, "size": size }),
|
||||
}),
|
||||
AcpSessionEvent::PermissionRequest {
|
||||
permission_id,
|
||||
tool_name,
|
||||
params,
|
||||
decision,
|
||||
} => {
|
||||
let event = match decision.as_str() {
|
||||
"allowed" => "permission.allowed",
|
||||
"requested" => "permission.requested",
|
||||
_ => "permission.denied",
|
||||
};
|
||||
Some(SseEvent {
|
||||
event: event.into(),
|
||||
data: json!({
|
||||
"permissionId": permission_id,
|
||||
"toolName": tool_name,
|
||||
"params": params,
|
||||
"decision": decision,
|
||||
}),
|
||||
})
|
||||
}
|
||||
AcpSessionEvent::SessionInfoUpdate { title } => Some(SseEvent {
|
||||
event: "session.info.updated".into(),
|
||||
data: json!({ "title": title }),
|
||||
}),
|
||||
AcpSessionEvent::ProviderConversationBound {
|
||||
provider,
|
||||
remote_conversation_id,
|
||||
remote_url,
|
||||
acp_session_id,
|
||||
} => Some(SseEvent {
|
||||
event: "provider.conversation.bound".into(),
|
||||
data: json!({
|
||||
"provider": provider,
|
||||
"remoteConversationId": remote_conversation_id,
|
||||
"remoteUrl": remote_url,
|
||||
"acpSessionId": acp_session_id,
|
||||
}),
|
||||
}),
|
||||
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
|
||||
event: "plan.updated".into(),
|
||||
data: json!({ "entries": entries }),
|
||||
}),
|
||||
AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None,
|
||||
AcpSessionEvent::Disconnected { reason } => Some(SseEvent {
|
||||
event: "run.failed".into(),
|
||||
data: json!({ "error": reason }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: get the runtime name from a profile.
|
||||
/// For now, we use "hermes" or "reasonix" directly.
|
||||
/// In Step 12, this will come from the profile config.
|
||||
pub fn runtime_name_for_profile(profile: &str) -> &str {
|
||||
match profile {
|
||||
"reasonix" => "reasonix",
|
||||
_ => "hermes",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn acp_normal_session_close_does_not_emit_failure() {
|
||||
let event = AcpSessionEvent::Disconnected {
|
||||
reason: "session closed".into(),
|
||||
};
|
||||
|
||||
assert!(acp_event_to_sse(event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_unexpected_disconnect_emits_failure() {
|
||||
let event = AcpSessionEvent::Disconnected {
|
||||
reason: "transport lost".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("unexpected disconnect should be forwarded");
|
||||
assert_eq!(sse.event, "run.failed");
|
||||
assert_eq!(sse.data["error"], "transport lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_thought_delta_does_not_emit_message_delta() {
|
||||
let event = AcpSessionEvent::ThoughtDelta {
|
||||
text: "internal reasoning".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("thought delta should be forwarded separately");
|
||||
assert_eq!(sse.event, "thought.delta");
|
||||
assert_eq!(sse.data["delta"], "internal reasoning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_permission_request_emits_frontend_decision_event() {
|
||||
let event = AcpSessionEvent::PermissionRequest {
|
||||
permission_id: "perm_1".into(),
|
||||
tool_name: "mnote.page.save".into(),
|
||||
params: json!({"documentId": "doc_1"}),
|
||||
decision: "denied".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("permission decision should be forwarded");
|
||||
assert_eq!(sse.event, "permission.denied");
|
||||
assert_eq!(sse.data["permissionId"], "perm_1");
|
||||
assert_eq!(sse.data["toolName"], "mnote.page.save");
|
||||
assert_eq!(sse.data["params"]["documentId"], "doc_1");
|
||||
assert_eq!(sse.data["decision"], "denied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_permission_requested_emits_permission_requested_event() {
|
||||
let event = AcpSessionEvent::PermissionRequest {
|
||||
permission_id: "perm_2".into(),
|
||||
tool_name: "mnote.page.get".into(),
|
||||
params: json!({"documentId": "doc_2"}),
|
||||
decision: "requested".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("permission requested should be forwarded");
|
||||
assert_eq!(sse.event, "permission.requested");
|
||||
assert_eq!(sse.data["permissionId"], "perm_2");
|
||||
assert_eq!(sse.data["toolName"], "mnote.page.get");
|
||||
assert_eq!(sse.data["decision"], "requested");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_permission_allowed_emits_permission_allowed_event() {
|
||||
let event = AcpSessionEvent::PermissionRequest {
|
||||
permission_id: "perm_3".into(),
|
||||
tool_name: "mnote.page.save".into(),
|
||||
params: json!({"documentId": "doc_1"}),
|
||||
decision: "allowed".into(),
|
||||
};
|
||||
|
||||
let sse = acp_event_to_sse(event).expect("permission allowed should be forwarded");
|
||||
assert_eq!(sse.event, "permission.allowed");
|
||||
assert_eq!(sse.data["permissionId"], "perm_3");
|
||||
assert_eq!(sse.data["decision"], "allowed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_tool_events_keep_detail_for_collapsible_ui() {
|
||||
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
|
||||
tool_call_id: "tool_1".into(),
|
||||
title: "mnote.page.get".into(),
|
||||
kind: "read".into(),
|
||||
status: crate::acp_types::ToolCallStatus::InProgress,
|
||||
raw_input: Some(json!({"documentId": "doc_1", "includeBody": true})),
|
||||
locations: vec!["/mnt/Data1T/mnote/src/main.rs".into()],
|
||||
})
|
||||
.expect("tool start");
|
||||
assert_eq!(started.event, "tool.started");
|
||||
assert_eq!(started.data["tool"], "mnote.page.get");
|
||||
assert_eq!(started.data["status"], "in_progress");
|
||||
assert_eq!(started.data["input"]["documentId"], "doc_1");
|
||||
assert_eq!(
|
||||
started.data["locations"][0],
|
||||
"/mnt/Data1T/mnote/src/main.rs"
|
||||
);
|
||||
assert_eq!(started.data["locations"].as_array().unwrap().len(), 1);
|
||||
|
||||
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
|
||||
tool_call_id: "tool_1".into(),
|
||||
status: crate::acp_types::ToolCallStatus::Completed,
|
||||
content: Some(vec![crate::acp_types::ContentBlockWrapper {
|
||||
wrapper_type: "content".into(),
|
||||
content: crate::acp_types::TextContent {
|
||||
content_type: "text".into(),
|
||||
text: "读取完成".into(),
|
||||
},
|
||||
}]),
|
||||
})
|
||||
.expect("tool complete");
|
||||
assert_eq!(completed.event, "tool.completed");
|
||||
assert_eq!(completed.data["status"], "completed");
|
||||
assert_eq!(completed.data["output"][0]["content"]["text"], "读取完成");
|
||||
|
||||
let running = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
|
||||
tool_call_id: "tool_1".into(),
|
||||
status: crate::acp_types::ToolCallStatus::InProgress,
|
||||
content: None,
|
||||
})
|
||||
.expect("tool running");
|
||||
assert_eq!(running.event, "tool.started");
|
||||
assert_eq!(running.data["status"], "in_progress");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_tool_completed_extracts_precise_ui_citations_from_prefixed_text() {
|
||||
let prefix = json!({
|
||||
"schema": "mnote.acp.tool_result_ui_citations.v1",
|
||||
"references": [{
|
||||
"citationMarkdown": "[来源定位降级:a.md](/documents/a)",
|
||||
"locatorDegraded": true
|
||||
}, {
|
||||
"citationMarkdown": "[b.md · p.2](/documents/b?page=2)",
|
||||
"locatorDegraded": false
|
||||
}],
|
||||
"uiCitations": [{
|
||||
"citationMarkdown": "[来源定位降级:a.md](/documents/a)"
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
|
||||
tool_call_id: "tool_2".into(),
|
||||
status: crate::acp_types::ToolCallStatus::Completed,
|
||||
content: Some(vec![crate::acp_types::ContentBlockWrapper {
|
||||
wrapper_type: "content".into(),
|
||||
content: crate::acp_types::TextContent {
|
||||
content_type: "text".into(),
|
||||
text: format!("{prefix}\n工具正文"),
|
||||
},
|
||||
}]),
|
||||
})
|
||||
.expect("tool complete");
|
||||
|
||||
assert_eq!(
|
||||
completed.data["citationMarkdowns"][0]["citationMarkdown"].as_str(),
|
||||
Some("[b.md · p.2](/documents/b?page=2)")
|
||||
);
|
||||
assert_eq!(
|
||||
completed.data["citationMarkdowns"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_session_info_update_emits_session_info_updated_sse() {
|
||||
let sse = acp_event_to_sse(AcpSessionEvent::SessionInfoUpdate {
|
||||
title: "我的新会话标题".into(),
|
||||
})
|
||||
.expect("session info update should be forwarded");
|
||||
assert_eq!(sse.event, "session.info.updated");
|
||||
assert_eq!(sse.data["title"], "我的新会话标题");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_plan_update_emits_plan_updated_sse() {
|
||||
let entries = vec![
|
||||
"步骤 1:读取文件".into(),
|
||||
"步骤 2:修改配置".into(),
|
||||
"步骤 3:验证更改".into(),
|
||||
];
|
||||
let sse = acp_event_to_sse(AcpSessionEvent::PlanUpdate {
|
||||
entries: entries.clone(),
|
||||
})
|
||||
.expect("plan update should be forwarded");
|
||||
assert_eq!(sse.event, "plan.updated");
|
||||
let sse_entries: Vec<String> =
|
||||
serde_json::from_value(sse.data["entries"].clone()).unwrap_or_default();
|
||||
assert_eq!(sse_entries.len(), 3);
|
||||
assert_eq!(sse_entries[0], "步骤 1:读取文件");
|
||||
assert_eq!(sse_entries[1], "步骤 2:修改配置");
|
||||
assert_eq!(sse_entries[2], "步骤 3:验证更改");
|
||||
}
|
||||
}
|
||||
@@ -1,755 +0,0 @@
|
||||
/// ACP (Agent Client Protocol) JSON-RPC 2.0 client.
|
||||
///
|
||||
/// Walks an agent runtime subprocess (e.g. `hermes acp` or `node reasonix-acp-wrapper.mjs`)
|
||||
/// over NDJSON stdio: one JSON object per line, newline-delimited.
|
||||
///
|
||||
/// Reference implementations:
|
||||
/// - `reference-code/hermes-vscode-main/src/acpClient.ts` (primary reference)
|
||||
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
|
||||
///
|
||||
/// Wire format:
|
||||
/// Request: { jsonrpc: "2.0", id: number, method: string, params?: object }
|
||||
/// Response: { jsonrpc: "2.0", id: number, result?: any, error?: { code, message } }
|
||||
/// Notification:{ jsonrpc: "2.0", method: string, params?: object } (no id)
|
||||
/// Incoming: { jsonrpc: "2.0", id: number, method: string, params?: object } (from agent)
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
// ── Error types ──────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AcpError {
|
||||
Spawn(std::io::Error),
|
||||
JsonParse(serde_json::Error),
|
||||
JsonRpc { code: i64, message: String },
|
||||
Timeout(u64),
|
||||
Closed,
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AcpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AcpError::Spawn(e) => write!(f, "ACP spawn failed: {e}"),
|
||||
AcpError::JsonParse(e) => write!(f, "ACP JSON parse error: {e}"),
|
||||
AcpError::JsonRpc { code, message } => {
|
||||
write!(f, "ACP JSON-RPC error [{code}]: {message}")
|
||||
}
|
||||
AcpError::Timeout(secs) => write!(f, "ACP request timed out after {secs}s"),
|
||||
AcpError::Closed => write!(f, "ACP connection closed"),
|
||||
AcpError::Internal(msg) => write!(f, "ACP internal: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AcpError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
AcpError::Spawn(e) => Some(e),
|
||||
AcpError::JsonParse(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AcpError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
AcpError::Spawn(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AcpError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
AcpError::JsonParse(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification handler type ────────────────────────
|
||||
|
||||
type NotificationHandler = Box<dyn Fn(String, Value) + Send + 'static>;
|
||||
|
||||
/// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits).
|
||||
type NotificationHandlerMutex = std::sync::Mutex<Option<NotificationHandler>>;
|
||||
|
||||
// ── Incoming request handler type ────────────────────
|
||||
///
|
||||
/// agent 发送 JSON-RPC request(同时包含 `id` 与 `method`)时调用。
|
||||
/// 返回 `true` 表示 handler 已负责稍后响应;返回 `false` 则由 dispatch_message
|
||||
/// 直接回复 method-not-found。handler 应通过 [`AcpClient::respond_to_incoming`]
|
||||
/// 或 [`AcpClient::respond_to_incoming_error`] 回写响应。
|
||||
type IncomingRequestHandler = Box<dyn Fn(Value, String, Value) -> bool + Send + 'static>;
|
||||
|
||||
/// incoming request handler 的线程安全容器。
|
||||
type IncomingRequestHandlerMutex = std::sync::Mutex<Option<IncomingRequestHandler>>;
|
||||
|
||||
// ── Pending request entry ────────────────────────────
|
||||
|
||||
type PendingEntry = oneshot::Sender<Result<Value, AcpError>>;
|
||||
|
||||
// ── AcpClient ────────────────────────────────────────
|
||||
|
||||
/// ACP JSON-RPC 2.0 client over stdio.
|
||||
///
|
||||
/// Create via [`AcpClient::spawn`], then use [`request`](Self::request) for RPC
|
||||
/// calls and [`notification`](Self::notification) for fire-and-forget messages.
|
||||
/// Register a handler with [`on_notification`](Self::on_notification) to receive
|
||||
/// agent push events (e.g. `session/update`).
|
||||
// Manual Debug impl: Child doesn't impl Debug, so we skip it
|
||||
impl std::fmt::Debug for AcpClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AcpClient")
|
||||
.field("next_id", &self.next_id)
|
||||
.field("pending_count", &self.pending.blocking_lock().len())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AcpClient {
|
||||
child: Option<Child>,
|
||||
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
next_id: AtomicU64,
|
||||
notification_handler: Arc<NotificationHandlerMutex>,
|
||||
/// agent 发来的 incoming JSON-RPC request handler(同时有 id 和 method)。
|
||||
/// 未设置时会直接回复 method-not-found。
|
||||
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
|
||||
}
|
||||
|
||||
impl AcpClient {
|
||||
/// Spawn an ACP subprocess and establish the JSON-RPC connection.
|
||||
///
|
||||
/// After spawn, sends an `initialize` handshake (as Hermes does in acpClient.ts
|
||||
/// `start()` → `call('initialize', { protocolVersion: 1 })`).
|
||||
/// Launches a background tokio task that reads NDJSON lines from the child's stdout.
|
||||
///
|
||||
/// Reference: `hermes-vscode-main/src/acpClient.ts` L50-80 (spawn + stdio setup)
|
||||
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self, AcpError> {
|
||||
Self::spawn_with_env(bin, args, None).await
|
||||
}
|
||||
|
||||
/// Spawn an ACP subprocess with extra environment variables.
|
||||
pub async fn spawn_with_env(
|
||||
bin: &str,
|
||||
args: &[&str],
|
||||
env_overrides: Option<&HashMap<String, String>>,
|
||||
) -> Result<Self, AcpError> {
|
||||
let mut command = Command::new(bin);
|
||||
command
|
||||
.args(args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
if let Some(env) = env_overrides {
|
||||
if let Some(workspace_root) = env.get("MNOTE_AI_WORKSPACE_ROOT") {
|
||||
let workspace_root = std::path::Path::new(workspace_root);
|
||||
if workspace_root.is_dir() {
|
||||
// 本地 workspace run 以授权根目录作为进程工作目录,贴近 VSCode agent 行为。
|
||||
command.current_dir(workspace_root);
|
||||
}
|
||||
}
|
||||
command.envs(env);
|
||||
}
|
||||
let mut child = command.spawn().map_err(AcpError::Spawn)?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| AcpError::Internal("failed to take child stdin".into()))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
|
||||
|
||||
let writer = Arc::new(Mutex::new(BufWriter::new(stdin)));
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let notification_handler: Arc<NotificationHandlerMutex> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
let incoming_request_handler: Arc<IncomingRequestHandlerMutex> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
|
||||
// Start background reader task
|
||||
let pending_clone = pending.clone();
|
||||
let handler_clone = notification_handler.clone();
|
||||
let incoming_clone = incoming_request_handler.clone();
|
||||
let writer_clone = writer.clone();
|
||||
let child_pid = child.id().unwrap_or(0);
|
||||
tokio::spawn(async move {
|
||||
Self::reader_loop(
|
||||
reader,
|
||||
writer_clone,
|
||||
pending_clone,
|
||||
handler_clone,
|
||||
incoming_clone,
|
||||
)
|
||||
.await;
|
||||
info!("ACP reader loop ended (pid={})", child_pid);
|
||||
});
|
||||
|
||||
let client = Self {
|
||||
child: Some(child),
|
||||
writer,
|
||||
pending,
|
||||
next_id: AtomicU64::new(1),
|
||||
notification_handler,
|
||||
incoming_request_handler,
|
||||
};
|
||||
|
||||
// Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1}))
|
||||
let init_result: Value = client
|
||||
.request("initialize", json!({ "protocolVersion": 1 }))
|
||||
.await?;
|
||||
debug!(?init_result, "ACP initialize OK");
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC request and await the response.
|
||||
///
|
||||
/// Returns `Result<R>` where `R` is the deserialized `result` field.
|
||||
/// On JSON-RPC error, returns [`AcpError::JsonRpc`].
|
||||
/// Default timeout: 300 seconds. Override with `MNOTE_ACP_REQUEST_TIMEOUT_SECS`.
|
||||
///
|
||||
/// Reference: `acpClient.ts` L95-110 (`call()` method)
|
||||
pub async fn request<P: Serialize, R: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: P,
|
||||
) -> Result<R, AcpError> {
|
||||
let timeout_secs = std::env::var("MNOTE_ACP_REQUEST_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value >= 30)
|
||||
.unwrap_or(300);
|
||||
self.request_with_timeout(method, params, Duration::from_secs(timeout_secs))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Same as [`request`] but with a configurable timeout.
|
||||
pub async fn request_with_timeout<P: Serialize, R: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: P,
|
||||
dur: Duration,
|
||||
) -> Result<R, AcpError> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
self.pending.lock().await.insert(id, tx);
|
||||
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
let line = serde_json::to_string(&req)?;
|
||||
debug!("ACP --> {} #{} ({} bytes)", method, id, line.len());
|
||||
|
||||
{
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
|
||||
match timeout(dur, rx).await {
|
||||
Ok(Ok(Ok(value))) => {
|
||||
let result: R = serde_json::from_value(value)?;
|
||||
Ok(result)
|
||||
}
|
||||
Ok(Ok(Err(err))) => Err(err),
|
||||
Ok(Err(_recv_err)) => Err(AcpError::Closed),
|
||||
Err(_elapsed) => Err(AcpError::Timeout(dur.as_secs())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a fire-and-forget notification (no id, no response expected).
|
||||
///
|
||||
/// Reference: `acpClient.ts` L115-118 (`notify()`)
|
||||
pub async fn notification(&self, method: &str, params: Value) -> Result<(), AcpError> {
|
||||
let msg = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
let line = serde_json::to_string(&msg)?;
|
||||
debug!("ACP ~~> {} ({} bytes)", method, line.len());
|
||||
|
||||
{
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a handler for incoming notifications (messages with `method` but no `id`).
|
||||
/// Only one handler at a time — subsequent calls replace the previous.
|
||||
pub fn on_notification<F>(&self, handler: F)
|
||||
where
|
||||
F: Fn(String, Value) + Send + 'static,
|
||||
{
|
||||
let mut guard = self.notification_handler.lock().unwrap();
|
||||
*guard = Some(Box::new(handler));
|
||||
}
|
||||
|
||||
/// 注册 incoming JSON-RPC request handler(消息同时包含 `id` 与 `method`)。
|
||||
/// handler 接收原始 request id、method 和 params,并应稍后通过
|
||||
/// [`respond_to_incoming`] 或 [`respond_to_incoming_error`] 响应。
|
||||
/// 同一时间只保留一个 handler,后续注册会覆盖前一个。
|
||||
pub fn on_incoming_request<F>(&self, handler: F)
|
||||
where
|
||||
F: Fn(Value, String, Value) -> bool + Send + 'static,
|
||||
{
|
||||
let mut guard = self.incoming_request_handler.lock().unwrap();
|
||||
*guard = Some(Box::new(handler));
|
||||
}
|
||||
|
||||
/// 用 result 响应 agent 发来的 incoming JSON-RPC request。
|
||||
///
|
||||
/// 必须使用 incoming request handler 收到的原始 `id`,避免丢失字符串 id。
|
||||
pub async fn respond_to_incoming(&self, id: Value, result: Value) -> Result<(), AcpError> {
|
||||
let msg = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.clone(),
|
||||
"result": result,
|
||||
});
|
||||
debug!("ACP <-- respond to incoming #{}", id);
|
||||
Self::write_jsonrpc_message(&self.writer, &msg).await
|
||||
}
|
||||
|
||||
/// 用 error 响应 incoming JSON-RPC request。
|
||||
pub async fn respond_to_incoming_error(
|
||||
&self,
|
||||
id: Value,
|
||||
code: i64,
|
||||
message: &str,
|
||||
) -> Result<(), AcpError> {
|
||||
let msg = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id.clone(),
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
});
|
||||
debug!(
|
||||
"ACP <-- respond error to incoming #{}: [{}] {}",
|
||||
id, code, message
|
||||
);
|
||||
Self::write_jsonrpc_message(&self.writer, &msg).await
|
||||
}
|
||||
|
||||
/// Gracefully close the ACP connection and kill the subprocess.
|
||||
pub async fn close(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
// Resolve all pending with Closed error
|
||||
let mut pending = self.pending.lock().await;
|
||||
for (_, tx) in pending.drain() {
|
||||
let _ = tx.send(Err(AcpError::Closed));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Background reader ────────────────────────────
|
||||
|
||||
/// Background loop: reads NDJSON lines from the child's stdout,
|
||||
/// routes responses to pending requests and notifications to the handler.
|
||||
///
|
||||
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
|
||||
async fn reader_loop(
|
||||
mut reader: BufReader<ChildStdout>,
|
||||
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
notification_handler: Arc<NotificationHandlerMutex>,
|
||||
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
|
||||
) {
|
||||
let mut line_buf = String::new();
|
||||
loop {
|
||||
line_buf.clear();
|
||||
match reader.read_line(&mut line_buf).await {
|
||||
Ok(0) => {
|
||||
info!("ACP stdout closed (EOF)");
|
||||
break;
|
||||
}
|
||||
Ok(_n) => {}
|
||||
Err(e) => {
|
||||
warn!("ACP read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = line_buf.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let msg: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"ACP parse error: {e} (line: {})",
|
||||
&trimmed[..trimmed.len().min(80)]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
Self::dispatch_message(
|
||||
msg,
|
||||
&writer,
|
||||
&pending,
|
||||
¬ification_handler,
|
||||
&incoming_request_handler,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Process died or EOF — resolve all pending
|
||||
let mut pending_guard = pending.lock().await;
|
||||
for (_, tx) in pending_guard.drain() {
|
||||
let _ = tx.send(Err(AcpError::Closed));
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a single JSON message to pending request, notification handler, or incoming request.
|
||||
///
|
||||
/// Reference: `acpClient.ts` L160-200 (dispatch)
|
||||
async fn dispatch_message(
|
||||
msg: Value,
|
||||
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
|
||||
notification_handler: &Arc<NotificationHandlerMutex>,
|
||||
incoming_request_handler: &Arc<IncomingRequestHandlerMutex>,
|
||||
) {
|
||||
let has_id = msg.get("id").is_some();
|
||||
let has_method = msg
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_id && has_method {
|
||||
// agent 发来的 incoming request,例如 session/request_permission。
|
||||
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(Value::Null);
|
||||
let id_val = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
|
||||
// 若已注册 handler,则由 handler 决定是否负责稍后响应。
|
||||
let handled = {
|
||||
let handler_guard = incoming_request_handler.lock().unwrap();
|
||||
if let Some(ref handler) = *handler_guard {
|
||||
handler(id_val.clone(), method.clone(), params.clone())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if handled {
|
||||
debug!("ACP incoming request dispatched: {method} #{}", id_val);
|
||||
} else {
|
||||
// 没有 handler 时必须立即响应,避免 agent 一直等待。
|
||||
warn!("ACP incoming request not handled (no handler registered): {method}");
|
||||
let response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id_val,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": format!("ACP incoming request not supported: {method}")
|
||||
}
|
||||
});
|
||||
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
|
||||
warn!("ACP incoming request response write failed: {error}");
|
||||
}
|
||||
}
|
||||
} else if has_id {
|
||||
// Response to one of our requests
|
||||
if let Some(id) = msg["id"].as_u64() {
|
||||
let mut pending_guard = pending.lock().await;
|
||||
if let Some(tx) = pending_guard.remove(&id) {
|
||||
if let Some(error) = msg.get("error") {
|
||||
let code = error["code"].as_i64().unwrap_or(-1);
|
||||
let message = error["message"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown error")
|
||||
.to_string();
|
||||
let _ = tx.send(Err(AcpError::JsonRpc { code, message }));
|
||||
} else if let Some(result) = msg.get("result") {
|
||||
let _ = tx.send(Ok(result.clone()));
|
||||
} else {
|
||||
let _ = tx.send(Err(AcpError::Internal(
|
||||
"response without result or error".into(),
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
debug!("ACP response for unknown request id={id}");
|
||||
}
|
||||
}
|
||||
} else if has_method {
|
||||
// Notification (no id)
|
||||
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(Value::Null);
|
||||
let handler_guard = notification_handler.lock().unwrap();
|
||||
if let Some(ref handler) = *handler_guard {
|
||||
handler(method, params);
|
||||
} else {
|
||||
debug!("ACP notification unhandled: {method}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_jsonrpc_message(
|
||||
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
msg: &Value,
|
||||
) -> Result<(), AcpError> {
|
||||
let line = serde_json::to_string(msg)?;
|
||||
let mut writer = writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AcpClient {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// Helper: create a mock subprocess that echoes back requests as responses.
|
||||
/// Simulates a minimal ACP server for testing.
|
||||
async fn spawn_mock_acp_server() -> AcpClient {
|
||||
// We spawn a small node script that reads NDJSON and echoes back
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
if (msg.method === 'initialize') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
|
||||
}) + '\n');
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { ok: true, echo: msg.params }
|
||||
}) + '\n');
|
||||
}
|
||||
} else if (msg.method && msg.id === undefined) {
|
||||
// Notification → ignore
|
||||
}
|
||||
});
|
||||
"#;
|
||||
|
||||
// Write script to temp file
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_test_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write mock script");
|
||||
|
||||
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn mock ACP")
|
||||
}
|
||||
|
||||
async fn spawn_permission_request_mock_server() -> AcpClient {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
let permissionResponse = null;
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id !== undefined && msg.method === 'initialize') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
|
||||
});
|
||||
setTimeout(() => send({
|
||||
jsonrpc: '2.0',
|
||||
id: 77,
|
||||
method: 'session/request_permission',
|
||||
params: { reason: 'test permission' }
|
||||
}), 10);
|
||||
} else if (msg.id === 77 && msg.method === undefined) {
|
||||
permissionResponse = msg;
|
||||
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { permissionResponse }
|
||||
});
|
||||
}
|
||||
});
|
||||
"#;
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_permission_request_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write permission mock script");
|
||||
|
||||
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn permission mock ACP")
|
||||
}
|
||||
|
||||
async fn spawn_string_id_permission_request_mock_server() -> AcpClient {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
let permissionResponse = null;
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id !== undefined && msg.method === 'initialize') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
|
||||
});
|
||||
setTimeout(() => send({
|
||||
jsonrpc: '2.0',
|
||||
id: 'perm-string-id',
|
||||
method: 'session/request_permission',
|
||||
params: { reason: 'test permission' }
|
||||
}), 10);
|
||||
} else if (msg.id === 'perm-string-id' && msg.method === undefined) {
|
||||
permissionResponse = msg;
|
||||
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
|
||||
send({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: { permissionResponse }
|
||||
});
|
||||
}
|
||||
});
|
||||
"#;
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_permission_request_string_id_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write permission string id mock script");
|
||||
|
||||
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn permission string id mock ACP")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_response() {
|
||||
let client = spawn_mock_acp_server().await;
|
||||
let result: Value = client
|
||||
.request("test_method", json!({ "hello": "world" }))
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(result["ok"], true);
|
||||
assert_eq!(result["echo"]["hello"], "world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notification() {
|
||||
let client = spawn_mock_acp_server().await;
|
||||
// Notifications are fire-and-forget, no response expected
|
||||
client
|
||||
.notification("test_notify", json!({ "foo": "bar" }))
|
||||
.await
|
||||
.expect("notification should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_on_notification_received() {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
let client = spawn_mock_acp_server().await;
|
||||
let received = Arc::new(AtomicBool::new(false));
|
||||
let received_clone = received.clone();
|
||||
|
||||
client.on_notification(move |method, _params| {
|
||||
if method == "test_push" {
|
||||
received_clone.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
// Send a notification that the mock server will echo back as...
|
||||
// Actually the mock doesn't send unsolicited notifications.
|
||||
// This test just validates the handler registration doesn't crash.
|
||||
client
|
||||
.notification("test_push", json!({}))
|
||||
.await
|
||||
.expect("notification");
|
||||
|
||||
// Give background task time to process
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
// In this mock, no notification will be received; that's OK
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_close() {
|
||||
let mut client = spawn_mock_acp_server().await;
|
||||
client.close().await;
|
||||
// Second close should be no-op
|
||||
client.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initialize_handshake() {
|
||||
// spawn already calls initialize; if it fails, the test fails
|
||||
let _client = spawn_mock_acp_server().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_incoming_permission_request_gets_response() {
|
||||
let client = spawn_permission_request_mock_server().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let result: Value = client
|
||||
.request("get_permission_response", json!({}))
|
||||
.await
|
||||
.expect("permission response probe");
|
||||
let response = &result["permissionResponse"];
|
||||
assert_eq!(response["jsonrpc"], "2.0");
|
||||
assert_eq!(response["id"], 77);
|
||||
assert!(response.get("result").is_some() || response.get("error").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_incoming_permission_request_preserves_string_id() {
|
||||
let client = spawn_string_id_permission_request_mock_server().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let result: Value = client
|
||||
.request("get_permission_response", json!({}))
|
||||
.await
|
||||
.expect("permission response probe");
|
||||
let response = &result["permissionResponse"];
|
||||
assert_eq!(response["jsonrpc"], "2.0");
|
||||
assert_eq!(response["id"], "perm-string-id");
|
||||
assert!(response.get("result").is_some() || response.get("error").is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
/// ACP Runtime Manager — manages agent runtime subprocess lifecycle.
|
||||
///
|
||||
/// Supports multiple runtimes (Hermes, Reasonix) and switching between them.
|
||||
/// Each runtime is spawned as a subprocess communicating via the ACP JSON-RPC 2.0 protocol.
|
||||
///
|
||||
/// Configuration:
|
||||
/// `MNOTE_WEB_ACP_DEFAULT_RUNTIME` — default runtime name ("hermes" | "reasonix")
|
||||
/// `MNOTE_WEB_HERMES_BIN` — path to Hermes binary (default: "hermes")
|
||||
/// `MNOTE_WEB_HERMES_ACP_PROFILE` — Hermes profile for ACP runtime (default: "default")
|
||||
/// `MNOTE_WEB_REASONIX_WRAPPER` — path to Reasonix wrapper script (default: "scripts/reasonix-acp-wrapper.mjs")
|
||||
///
|
||||
/// Or via JSON env var:
|
||||
/// `MNOTE_WEB_ACP_RUNTIMES` — JSON array of runtime configs
|
||||
use crate::acp_client::{AcpClient, AcpError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
// ── Config ───────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcpRuntimeConfig {
|
||||
/// Display name (e.g. "hermes", "reasonix").
|
||||
pub name: String,
|
||||
/// Binary path (e.g. "hermes", "node").
|
||||
pub bin: String,
|
||||
/// Command arguments (e.g. ["acp"], ["scripts/reasonix-acp-wrapper.mjs"]).
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Extra environment variables.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
/// Human-readable title for the runtime selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
impl AcpRuntimeConfig {
|
||||
/// Create a Hermes ACP runtime config.
|
||||
pub fn hermes(bin: Option<&str>, profile: Option<&str>) -> Self {
|
||||
let profile = profile.unwrap_or("default").trim();
|
||||
let args = if profile.is_empty() {
|
||||
vec!["acp".into()]
|
||||
} else {
|
||||
vec!["-p".into(), profile.to_string(), "acp".into()]
|
||||
};
|
||||
Self {
|
||||
name: "hermes".into(),
|
||||
bin: bin.unwrap_or("hermes").to_string(),
|
||||
args,
|
||||
env: None,
|
||||
title: Some("Hermes".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Reasonix ACP runtime config.
|
||||
/// `wrapper_path` is relative to the project root (where Cargo.toml's parent is).
|
||||
/// Default: `design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/scripts/reasonix-acp-wrapper.mjs` (dev),
|
||||
/// or in production, the absolute path is resolved via `CARGO_MANIFEST_DIR` (the `rust/` directory).
|
||||
pub fn reasonix(wrapper_path: Option<&str>) -> Self {
|
||||
// CARGO_MANIFEST_DIR is the directory containing this crate's Cargo.toml:
|
||||
// /mnt/Data1T/mnote/rust/crates/mnote-web/
|
||||
// We need the project root: /mnt/Data1T/mnote/
|
||||
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(3) // up: mnote-web/ → crates/ → rust/ → mnote/ (project root)
|
||||
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let default_path = format!("{project_root}/scripts/reasonix-acp-wrapper.mjs");
|
||||
// Resolve wrapper_path: if it's relative, prepend project_root; absolute paths used as-is
|
||||
let resolved_path = wrapper_path
|
||||
.map(|p| {
|
||||
if p.starts_with('/') {
|
||||
p.to_string()
|
||||
} else {
|
||||
format!("{project_root}/{p}")
|
||||
}
|
||||
})
|
||||
.unwrap_or(default_path);
|
||||
Self {
|
||||
name: "reasonix".into(),
|
||||
bin: "node".into(),
|
||||
args: vec![resolved_path],
|
||||
env: None,
|
||||
title: Some("Reasonix".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Runtime Manager ──────────────────────────────────
|
||||
|
||||
/// Manages lifecycle of multiple agent runtimes.
|
||||
///
|
||||
/// Each runtime is defined by a name and spawn configuration.
|
||||
/// At most one runtime is "active" at a time, providing an [`AcpClient`].
|
||||
#[derive(Debug)]
|
||||
pub struct AcpRuntimeManager {
|
||||
runtimes: HashMap<String, AcpRuntimeConfig>,
|
||||
active: Mutex<Option<ActiveRuntime>>,
|
||||
default_runtime: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ActiveRuntime {
|
||||
config: AcpRuntimeConfig,
|
||||
client: Arc<AcpClient>,
|
||||
}
|
||||
|
||||
impl AcpRuntimeManager {
|
||||
/// Create a new runtime manager with built-in default configurations.
|
||||
///
|
||||
/// Reads environment variables to configure Hermes and Reasonix runtimes.
|
||||
/// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "reasonix").
|
||||
pub fn from_env() -> Self {
|
||||
let mut runtimes: HashMap<String, AcpRuntimeConfig> = HashMap::new();
|
||||
|
||||
// Check for JSON-based configuration first
|
||||
if let Ok(json) = env::var("MNOTE_WEB_ACP_RUNTIMES") {
|
||||
if let Ok(custom_runtimes) = serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json) {
|
||||
for rt in custom_runtimes {
|
||||
let name = rt.name.clone();
|
||||
runtimes.insert(name, rt);
|
||||
}
|
||||
} else {
|
||||
warn!("Failed to parse MNOTE_WEB_ACP_RUNTIMES JSON");
|
||||
}
|
||||
}
|
||||
|
||||
// Always add default Hermes if not already configured
|
||||
if !runtimes.contains_key("hermes") {
|
||||
let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN").unwrap_or_else(|_| "hermes".into());
|
||||
let hermes_profile =
|
||||
env::var("MNOTE_WEB_HERMES_ACP_PROFILE").unwrap_or_else(|_| "default".into());
|
||||
runtimes.insert(
|
||||
"hermes".into(),
|
||||
AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(&hermes_profile)),
|
||||
);
|
||||
}
|
||||
|
||||
// Always add default Reasonix if not already configured
|
||||
if !runtimes.contains_key("reasonix") {
|
||||
let wrapper = env::var("MNOTE_WEB_REASONIX_WRAPPER")
|
||||
.unwrap_or_else(|_| "scripts/reasonix-acp-wrapper.mjs".into());
|
||||
runtimes.insert(
|
||||
"reasonix".into(),
|
||||
AcpRuntimeConfig::reasonix(Some(&wrapper)),
|
||||
);
|
||||
}
|
||||
|
||||
let default =
|
||||
env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "reasonix".into());
|
||||
|
||||
Self {
|
||||
runtimes,
|
||||
active: Mutex::new(None),
|
||||
default_runtime: default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the list of available runtime names.
|
||||
pub fn available_runtimes(&self) -> Vec<String> {
|
||||
self.runtimes.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get a runtime config by name.
|
||||
pub fn get_config(&self, name: &str) -> Option<&AcpRuntimeConfig> {
|
||||
self.runtimes.get(name)
|
||||
}
|
||||
|
||||
/// Get the default runtime name.
|
||||
pub fn default_runtime(&self) -> &str {
|
||||
&self.default_runtime
|
||||
}
|
||||
|
||||
/// Get the currently active runtime name, if any.
|
||||
pub async fn active_runtime_name(&self) -> Option<String> {
|
||||
self.active
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|a| a.config.name.clone())
|
||||
}
|
||||
|
||||
/// Get a reference to the currently active [`AcpClient`], if any.
|
||||
pub async fn active_client(&self) -> Option<Arc<AcpClient>> {
|
||||
self.active.lock().await.as_ref().map(|a| a.client.clone())
|
||||
}
|
||||
|
||||
/// Check if a runtime is active and the client is available.
|
||||
pub async fn is_active(&self) -> bool {
|
||||
self.active.lock().await.is_some()
|
||||
}
|
||||
|
||||
/// Activate a runtime by name, spawning a new subprocess if needed.
|
||||
///
|
||||
/// If another runtime is currently active, it will be shut down first.
|
||||
/// After spawn, performs an `initialize` handshake to verify the runtime is healthy.
|
||||
pub async fn switch_to(&self, name: &str) -> Result<Arc<AcpClient>, AcpError> {
|
||||
let config = self
|
||||
.runtimes
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| AcpError::Internal(format!("unknown runtime: {name}")))?;
|
||||
self.switch_to_config(config).await
|
||||
}
|
||||
|
||||
/// Activate a runtime from an explicit config.
|
||||
///
|
||||
/// This is used by Hermes ACP because the binary is the same runtime name,
|
||||
/// but the selected Hermes profile changes the launch args.
|
||||
pub async fn switch_to_config(
|
||||
&self,
|
||||
config: AcpRuntimeConfig,
|
||||
) -> Result<Arc<AcpClient>, AcpError> {
|
||||
let name = config.name.clone();
|
||||
// Shutdown current active runtime
|
||||
let mut active_guard = self.active.lock().await;
|
||||
if let Some(ref current) = *active_guard {
|
||||
if current.config == config {
|
||||
// Already active — return existing client
|
||||
return Ok(current.client.clone());
|
||||
}
|
||||
// Drop the old ActiveRuntime, which will kill the child process
|
||||
// (via AcpClient's Drop impl)
|
||||
}
|
||||
|
||||
info!(
|
||||
"ACP runtime: switching to {name} (bin={}, args={:?})",
|
||||
config.bin, config.args
|
||||
);
|
||||
|
||||
// Convert Vec<String> to Vec<&str> for AcpClient::spawn
|
||||
let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect();
|
||||
let client =
|
||||
AcpClient::spawn_with_env(&config.bin, &args_refs, config.env.as_ref()).await?;
|
||||
let client = Arc::new(client);
|
||||
|
||||
*active_guard = Some(ActiveRuntime {
|
||||
config,
|
||||
client: client.clone(),
|
||||
});
|
||||
|
||||
info!("ACP runtime: {name} active");
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Shut down the currently active runtime.
|
||||
pub async fn shutdown_active(&self) {
|
||||
let mut active_guard = self.active.lock().await;
|
||||
if let Some(active) = active_guard.take() {
|
||||
info!("ACP runtime: shutting down {}", active.config.name);
|
||||
// AcpClient's Drop kills the process
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a health check on the active runtime.
|
||||
///
|
||||
/// Returns `true` if the runtime responds to an `initialize` handshake within 5 seconds.
|
||||
pub async fn health_check(&self) -> bool {
|
||||
let client = match self.active_client().await {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// Use request_with_timeout with a short timeout
|
||||
let result: Result<serde_json::Value, AcpError> = timeout(
|
||||
Duration::from_secs(5),
|
||||
client.request("initialize", serde_json::json!({ "protocolVersion": 1 })),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| AcpError::Timeout(5))
|
||||
.and_then(|r| r);
|
||||
|
||||
match result {
|
||||
Ok(val) => {
|
||||
let ok = val.get("protocolVersion").and_then(|v| v.as_u64()) == Some(1);
|
||||
if ok {
|
||||
debug!("ACP health check OK");
|
||||
} else {
|
||||
warn!("ACP health check: unexpected response: {val:?}");
|
||||
}
|
||||
ok
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("ACP health check failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AcpRuntimeManager {
|
||||
fn drop(&mut self) {
|
||||
// The active runtime's AcpClient Drop will kill the process
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_hermes() {
|
||||
let cfg = AcpRuntimeConfig::hermes(None, None);
|
||||
assert_eq!(cfg.name, "hermes");
|
||||
assert_eq!(cfg.bin, "hermes");
|
||||
assert_eq!(cfg.args, vec!["-p", "default", "acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_hermes_profile_can_be_disabled() {
|
||||
let cfg = AcpRuntimeConfig::hermes(None, Some(""));
|
||||
assert_eq!(cfg.args, vec!["acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_reasonix() {
|
||||
let cfg = AcpRuntimeConfig::reasonix(None);
|
||||
assert_eq!(cfg.name, "reasonix");
|
||||
assert_eq!(cfg.bin, "node");
|
||||
assert_eq!(cfg.args.len(), 1);
|
||||
assert!(cfg.args[0].ends_with("/scripts/reasonix-acp-wrapper.mjs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_config_custom() {
|
||||
let cfg = AcpRuntimeConfig {
|
||||
name: "custom".into(),
|
||||
bin: "/usr/local/bin/my-agent".into(),
|
||||
args: vec!["--acp".into(), "--debug".into()],
|
||||
env: None,
|
||||
title: Some("My Agent".into()),
|
||||
};
|
||||
let json = serde_json::to_value(&cfg).unwrap();
|
||||
assert_eq!(json["name"], "custom");
|
||||
assert_eq!(json["bin"], "/usr/local/bin/my-agent");
|
||||
assert_eq!(json["title"], "My Agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_manager_from_env_defaults() {
|
||||
// Without env overrides, should contain hermes and reasonix
|
||||
let mgr = AcpRuntimeManager::from_env();
|
||||
let runtimes = mgr.available_runtimes();
|
||||
assert!(runtimes.contains(&"hermes".into()));
|
||||
assert!(runtimes.contains(&"reasonix".into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_switch_to_unknown_runtime() {
|
||||
let mgr = AcpRuntimeManager::from_env();
|
||||
let result = mgr.switch_to("nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
let err_str = format!("{}", result.err().unwrap());
|
||||
assert!(
|
||||
err_str.contains("unknown runtime"),
|
||||
"should return error for unknown runtime, got: {err_str}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_no_active() {
|
||||
let mgr = AcpRuntimeManager::from_env();
|
||||
assert!(!mgr.health_check().await, "no active runtime = unhealthy");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_switch_to_hermes_requires_binary() {
|
||||
let mgr = AcpRuntimeManager::from_env();
|
||||
// This might fail if `hermes` binary is not in PATH — that's OK for this test
|
||||
let result = mgr.switch_to("hermes").await;
|
||||
// We just verify it doesn't panic; either succeeds or returns Spawn error
|
||||
if let Err(e) = &result {
|
||||
assert!(
|
||||
matches!(e, AcpError::Spawn(_)),
|
||||
"expected Spawn error if hermes not in PATH, got: {e}"
|
||||
);
|
||||
} else {
|
||||
// Success — clean up
|
||||
mgr.shutdown_active().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,802 +0,0 @@
|
||||
/// ACP (Agent Client Protocol) type definitions.
|
||||
///
|
||||
/// Strongly-typed Rust representations of the ACP JSON-RPC 2.0 messages.
|
||||
/// Both Hermes (`hermes acp`) and Reasonix share this protocol shape.
|
||||
///
|
||||
/// Reference:
|
||||
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
|
||||
/// - `reference-code/hermes-vscode-main/src/protocol.ts`
|
||||
use serde::{de, Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ── JSON-RPC 2.0 basics ──────────────────────────────
|
||||
|
||||
pub type JsonRpcId = serde_json::Value; // number or string
|
||||
|
||||
// ── Initialize ───────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeParams {
|
||||
pub protocol_version: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_capabilities: Option<ClientCapabilities>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_info: Option<ClientInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fs: Option<FsCapabilities>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub terminal: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_text_file: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub write_text_file: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientInfo {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeResult {
|
||||
pub protocol_version: u64,
|
||||
pub agent_capabilities: AgentCapabilities,
|
||||
pub agent_info: AgentInfo,
|
||||
pub auth_methods: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub load_session: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_capabilities: Option<PromptCapabilities>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_capabilities: Option<McpCapabilities>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub embedded_context: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sse: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentInfo {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
// ── Session ──────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionNewParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_servers: Option<Vec<McpServerSpec>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServerSpec {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub args: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub env: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionNewResult {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
// ── Session load ─────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionLoadParams {
|
||||
pub session_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_servers: Option<Vec<McpServerSpec>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionLoadResult {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
// ── Content blocks ───────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "resource")]
|
||||
Resource { resource: ResourceContent },
|
||||
#[serde(rename = "image")]
|
||||
Image { mime_type: String, data: String },
|
||||
#[serde(rename = "audio")]
|
||||
Audio { mime_type: String, data: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceContent {
|
||||
pub uri: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
// ── Session prompt ───────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionPromptParams {
|
||||
pub session_id: String,
|
||||
pub prompt: Vec<ContentBlock>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mnote_session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub run_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actor_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub document_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mnote_capabilities: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionPromptResult {
|
||||
pub stop_reason: StopReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StopReason {
|
||||
EndTurn,
|
||||
ToolUseComplete,
|
||||
Cancelled,
|
||||
Error,
|
||||
}
|
||||
|
||||
// ── Session cancel (notification, no result) ─────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionCancelParams {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
// ── Session update (notification from agent to client) ──
|
||||
|
||||
/// The `session/update` notification payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionUpdateParams {
|
||||
pub session_id: String,
|
||||
pub update: SessionUpdate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum SessionUpdate {
|
||||
AgentMessageChunk {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String, // "agent_message_chunk"
|
||||
content: TextContent,
|
||||
},
|
||||
AgentThoughtChunk {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String, // "agent_thought_chunk"
|
||||
content: TextContent,
|
||||
},
|
||||
ToolCall {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String, // "tool_call"
|
||||
#[serde(rename = "toolCallId")]
|
||||
tool_call_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<ToolCallKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ToolCallStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
raw_input: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
locations: Vec<ToolLocation>,
|
||||
},
|
||||
ToolCallUpdate {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String, // "tool_call_update"
|
||||
#[serde(rename = "toolCallId")]
|
||||
tool_call_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ToolCallStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<Vec<ContentBlockWrapper>>,
|
||||
},
|
||||
Plan {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String, // "plan"
|
||||
entries: Vec<PlanEntry>,
|
||||
},
|
||||
UsageUpdate {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String, // "usage_update"
|
||||
used: u64,
|
||||
size: u64,
|
||||
},
|
||||
SessionInfoUpdate {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String, // "session_info_update"
|
||||
title: String,
|
||||
},
|
||||
/// Catch-all for any future/unknown session update variants.
|
||||
Unknown {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
#[serde(flatten)]
|
||||
extra: std::collections::HashMap<String, Value>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SessionUpdate {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
let kind = value
|
||||
.get("sessionUpdate")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| de::Error::missing_field("sessionUpdate"))?
|
||||
.to_string();
|
||||
|
||||
match kind.as_str() {
|
||||
SessionUpdate::AGENT_MESSAGE_CHUNK => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
content: TextContent,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::AgentMessageChunk {
|
||||
session_update: raw.session_update,
|
||||
content: raw.content,
|
||||
})
|
||||
}
|
||||
SessionUpdate::AGENT_THOUGHT_CHUNK => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
content: TextContent,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::AgentThoughtChunk {
|
||||
session_update: raw.session_update,
|
||||
content: raw.content,
|
||||
})
|
||||
}
|
||||
SessionUpdate::TOOL_CALL => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
#[serde(rename = "toolCallId")]
|
||||
tool_call_id: String,
|
||||
title: Option<String>,
|
||||
kind: Option<ToolCallKind>,
|
||||
status: Option<ToolCallStatus>,
|
||||
raw_input: Option<Value>,
|
||||
#[serde(default)]
|
||||
locations: Vec<ToolLocation>,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::ToolCall {
|
||||
session_update: raw.session_update,
|
||||
tool_call_id: raw.tool_call_id,
|
||||
title: raw.title,
|
||||
kind: raw.kind,
|
||||
status: raw.status,
|
||||
raw_input: raw.raw_input,
|
||||
locations: raw.locations,
|
||||
})
|
||||
}
|
||||
SessionUpdate::TOOL_CALL_UPDATE => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
#[serde(rename = "toolCallId")]
|
||||
tool_call_id: String,
|
||||
status: Option<ToolCallStatus>,
|
||||
content: Option<Vec<ContentBlockWrapper>>,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::ToolCallUpdate {
|
||||
session_update: raw.session_update,
|
||||
tool_call_id: raw.tool_call_id,
|
||||
status: raw.status,
|
||||
content: raw.content,
|
||||
})
|
||||
}
|
||||
SessionUpdate::PLAN => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
entries: Vec<PlanEntry>,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::Plan {
|
||||
session_update: raw.session_update,
|
||||
entries: raw.entries,
|
||||
})
|
||||
}
|
||||
SessionUpdate::USAGE_UPDATE => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
used: u64,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::UsageUpdate {
|
||||
session_update: raw.session_update,
|
||||
used: raw.used,
|
||||
size: raw.size,
|
||||
})
|
||||
}
|
||||
SessionUpdate::SESSION_INFO_UPDATE => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Raw {
|
||||
#[serde(rename = "sessionUpdate")]
|
||||
session_update: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(SessionUpdate::SessionInfoUpdate {
|
||||
session_update: raw.session_update,
|
||||
title: raw.title,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
let mut extra = match value {
|
||||
Value::Object(map) => map.into_iter().collect(),
|
||||
_ => std::collections::HashMap::new(),
|
||||
};
|
||||
extra.remove("sessionUpdate");
|
||||
Ok(SessionUpdate::Unknown {
|
||||
session_update: kind,
|
||||
extra,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TextContent {
|
||||
#[serde(rename = "type")]
|
||||
pub content_type: String, // "text"
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentBlockWrapper {
|
||||
#[serde(rename = "type")]
|
||||
pub wrapper_type: String, // "content"
|
||||
pub content: TextContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallKind {
|
||||
Read,
|
||||
Edit,
|
||||
Search,
|
||||
Execute,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A file path location referenced by a tool call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolLocation {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlanEntry {
|
||||
pub content: String,
|
||||
pub priority: PlanPriority,
|
||||
pub status: PlanEntryStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlanPriority {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlanEntryStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
}
|
||||
|
||||
// ── Permission request (from agent to client) ────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionRequestParams {
|
||||
pub session_id: String,
|
||||
pub tool_call: PermissionToolCall,
|
||||
pub options: Vec<PermissionOption>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionToolCall {
|
||||
#[serde(rename = "toolCallId")]
|
||||
pub tool_call_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<ToolCallKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<ToolCallStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_input: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionOption {
|
||||
pub option_id: String,
|
||||
pub name: String,
|
||||
pub kind: PermissionOptionKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PermissionOptionKind {
|
||||
AllowOnce,
|
||||
AllowAlways,
|
||||
RejectOnce,
|
||||
RejectAlways,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionRequestResult {
|
||||
pub outcome: PermissionOutcome,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PermissionOutcome {
|
||||
Selected { outcome: String, option_id: String },
|
||||
Cancelled { outcome: String },
|
||||
}
|
||||
|
||||
// ── Error codes (JSON-RPC standard) ──────────────────
|
||||
|
||||
pub const ERR_PARSE: i64 = -32700;
|
||||
pub const ERR_INVALID_REQUEST: i64 = -32600;
|
||||
pub const ERR_METHOD_NOT_FOUND: i64 = -32601;
|
||||
pub const ERR_INVALID_PARAMS: i64 = -32602;
|
||||
pub const ERR_INTERNAL: i64 = -32603;
|
||||
|
||||
// ── Session update kind discriminants ────────────────
|
||||
|
||||
/// Constants for the `sessionUpdate` string field.
|
||||
impl SessionUpdate {
|
||||
pub const AGENT_MESSAGE_CHUNK: &'static str = "agent_message_chunk";
|
||||
pub const AGENT_THOUGHT_CHUNK: &'static str = "agent_thought_chunk";
|
||||
pub const TOOL_CALL: &'static str = "tool_call";
|
||||
pub const TOOL_CALL_UPDATE: &'static str = "tool_call_update";
|
||||
pub const PLAN: &'static str = "plan";
|
||||
pub const USAGE_UPDATE: &'static str = "usage_update";
|
||||
pub const SESSION_INFO_UPDATE: &'static str = "session_info_update";
|
||||
}
|
||||
|
||||
/// Parse the `sessionUpdate` string field from a raw JSON value and return the discriminant.
|
||||
pub fn session_update_kind<'a>(value: &'a serde_json::Value) -> Option<&'a str> {
|
||||
value
|
||||
.get("update")
|
||||
.and_then(|u| u.get("sessionUpdate"))
|
||||
.and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
// ── Helper: extract text from agent_message_chunk / agent_thought_chunk ──
|
||||
|
||||
/// Extract the text content from a session update's content block.
|
||||
/// Returns `None` for non-text updates or malformed content.
|
||||
///
|
||||
/// Reference: `hermes-vscode-main/src/protocol.ts` `extractTextContent()`
|
||||
pub fn extract_text_from_update(update: &SessionUpdate) -> Option<&str> {
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk { content, .. }
|
||||
| SessionUpdate::AgentThoughtChunk { content, .. } => Some(&content.text),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_initialize_params_roundtrip() {
|
||||
let params = InitializeParams {
|
||||
protocol_version: 1,
|
||||
client_capabilities: None,
|
||||
client_info: Some(ClientInfo {
|
||||
name: "mnote-web".into(),
|
||||
title: Some("MNote".into()),
|
||||
version: Some("0.1.0".into()),
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_value(¶ms).unwrap();
|
||||
assert_eq!(json["protocolVersion"], 1);
|
||||
assert_eq!(json["clientInfo"]["name"], "mnote-web");
|
||||
|
||||
let deserialized: InitializeParams = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(deserialized.protocol_version, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_new_params() {
|
||||
let params = SessionNewParams {
|
||||
cwd: Some("/mnt/Data1T/mnote".into()),
|
||||
mcp_servers: Some(Vec::new()),
|
||||
};
|
||||
let json = serde_json::to_value(¶ms).unwrap();
|
||||
assert_eq!(json["cwd"], "/mnt/Data1T/mnote");
|
||||
assert_eq!(json["mcpServers"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_agent_message_chunk() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "test_1",
|
||||
"update": {
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": { "type": "text", "text": "Hello" }
|
||||
}
|
||||
});
|
||||
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(parsed.session_id, "test_1");
|
||||
match &parsed.update {
|
||||
SessionUpdate::AgentMessageChunk { content, .. } => {
|
||||
assert_eq!(content.text, "Hello");
|
||||
}
|
||||
_ => panic!("expected AgentMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_agent_thought_chunk() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "test_1",
|
||||
"update": {
|
||||
"sessionUpdate": "agent_thought_chunk",
|
||||
"content": { "type": "text", "text": "thinking" }
|
||||
}
|
||||
});
|
||||
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(parsed.session_id, "test_1");
|
||||
match &parsed.update {
|
||||
SessionUpdate::AgentThoughtChunk { content, .. } => {
|
||||
assert_eq!(content.text, "thinking");
|
||||
}
|
||||
_ => panic!("expected AgentThoughtChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_tool_call() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "test_1",
|
||||
"update": {
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "tc_1",
|
||||
"title": "mnote.doc.fetch",
|
||||
"kind": "read",
|
||||
"status": "pending"
|
||||
}
|
||||
});
|
||||
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
|
||||
match &parsed.update {
|
||||
SessionUpdate::ToolCall {
|
||||
title,
|
||||
kind,
|
||||
locations,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(title.as_deref(), Some("mnote.doc.fetch"));
|
||||
assert!(matches!(kind, Some(ToolCallKind::Read)));
|
||||
assert!(locations.is_empty(), "no locations in this fixture");
|
||||
}
|
||||
_ => panic!("expected ToolCall"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_tool_call_with_locations() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "test_1",
|
||||
"update": {
|
||||
"sessionUpdate": "tool_call",
|
||||
"toolCallId": "tc_2",
|
||||
"title": "mnote.doc.read",
|
||||
"kind": "read",
|
||||
"status": "in_progress",
|
||||
"locations": [
|
||||
{ "path": "/mnt/Data1T/mnote/src/main.rs" },
|
||||
{ "path": "/mnt/Data1T/mnote/src/lib.rs" }
|
||||
]
|
||||
}
|
||||
});
|
||||
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
|
||||
match &parsed.update {
|
||||
SessionUpdate::ToolCall {
|
||||
tool_call_id,
|
||||
locations,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(tool_call_id, "tc_2");
|
||||
assert_eq!(locations.len(), 2);
|
||||
assert_eq!(locations[0].path, "/mnt/Data1T/mnote/src/main.rs");
|
||||
assert_eq!(locations[1].path, "/mnt/Data1T/mnote/src/lib.rs");
|
||||
}
|
||||
_ => panic!("expected ToolCall"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_usage() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "test_1",
|
||||
"update": {
|
||||
"sessionUpdate": "usage_update",
|
||||
"used": 1500,
|
||||
"size": 4000
|
||||
}
|
||||
});
|
||||
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
|
||||
match &parsed.update {
|
||||
SessionUpdate::UsageUpdate { used, size, .. } => {
|
||||
assert_eq!(*used, 1500);
|
||||
assert_eq!(*size, 4000);
|
||||
}
|
||||
_ => panic!("expected UsageUpdate"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_block_text() {
|
||||
let block = ContentBlock::Text {
|
||||
text: "hello".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&block).unwrap();
|
||||
assert_eq!(json["type"], "text");
|
||||
assert_eq!(json["text"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_prompt() {
|
||||
let blocks = vec![
|
||||
ContentBlock::Text {
|
||||
text: "Hello".into(),
|
||||
},
|
||||
ContentBlock::Resource {
|
||||
resource: ResourceContent {
|
||||
uri: "file:///test.md".into(),
|
||||
mime_type: None,
|
||||
text: Some(" world".into()),
|
||||
},
|
||||
},
|
||||
];
|
||||
// flattenPrompt equivalent: concatenate text blocks + resource text
|
||||
let text: Vec<String> = blocks
|
||||
.iter()
|
||||
.map(|b| match b {
|
||||
ContentBlock::Text { text } => text.clone(),
|
||||
ContentBlock::Resource { resource } => resource.text.clone().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(text.join(""), "Hello world");
|
||||
}
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
use crate::acp_bridge::SseEvent;
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
|
||||
const DEFAULT_API_CHAT_BASE_URL: &str = "http://127.0.0.1:20128/v1";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ApiChatProfile {
|
||||
pub profile_id: &'static str,
|
||||
pub base_profile: &'static str,
|
||||
pub isolated_profile: &'static str,
|
||||
pub label: &'static str,
|
||||
pub model: &'static str,
|
||||
pub provider_kind: &'static str,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedApiChatProfile {
|
||||
pub profile_id: String,
|
||||
pub base_profile: String,
|
||||
pub isolated_profile: String,
|
||||
pub label: String,
|
||||
pub model: String,
|
||||
pub provider_kind: String,
|
||||
pub status: String,
|
||||
pub base_url: String,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApiChatError {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ApiChatError {
|
||||
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiChatError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}: {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiChatError {}
|
||||
|
||||
pub const API_CHAT_PROFILES: &[ApiChatProfile] = &[
|
||||
ApiChatProfile {
|
||||
profile_id: "shared_api_deepseek_flash_chat",
|
||||
base_profile: "api-deepseek-flash-chat",
|
||||
isolated_profile: "api-deepseek-flash-chat",
|
||||
label: "DeepSeek Flash Chat",
|
||||
model: "deepseek-v4-flash",
|
||||
provider_kind: "api-chat",
|
||||
status: "active",
|
||||
},
|
||||
ApiChatProfile {
|
||||
profile_id: "shared_api_deepseek_pro_chat",
|
||||
base_profile: "api-deepseek-pro-chat",
|
||||
isolated_profile: "api-deepseek-pro-chat",
|
||||
label: "DeepSeek Pro Chat",
|
||||
model: "deepseek-v4-pro",
|
||||
provider_kind: "api-chat",
|
||||
status: "active",
|
||||
},
|
||||
ApiChatProfile {
|
||||
profile_id: "shared_api_gpt_chat",
|
||||
base_profile: "api-gpt-chat",
|
||||
isolated_profile: "api-gpt-chat",
|
||||
label: "GPT Chat",
|
||||
model: "aisz-chat/gpt-5.5-extra-high-fast",
|
||||
provider_kind: "api-chat",
|
||||
status: "active",
|
||||
},
|
||||
ApiChatProfile {
|
||||
profile_id: "shared_api_kimi_chat",
|
||||
base_profile: "api-kimi-chat",
|
||||
isolated_profile: "api-kimi-chat",
|
||||
label: "Kimi Chat",
|
||||
model: "aisz-chat/kimi-k2.5",
|
||||
provider_kind: "api-chat",
|
||||
status: "active",
|
||||
},
|
||||
ApiChatProfile {
|
||||
profile_id: "shared_api_gemini_chat",
|
||||
base_profile: "api-gemini-chat",
|
||||
isolated_profile: "api-gemini-chat",
|
||||
label: "Gemini API Chat",
|
||||
model: "aisz-chat/gemini-3.1-pro",
|
||||
provider_kind: "api-chat",
|
||||
status: "active",
|
||||
},
|
||||
ApiChatProfile {
|
||||
profile_id: "shared_api_grok_chat",
|
||||
base_profile: "api-grok-chat",
|
||||
isolated_profile: "api-grok-chat",
|
||||
label: "Grok API Chat",
|
||||
model: "aisz-chat/grok-4.3",
|
||||
provider_kind: "api-chat",
|
||||
status: "active",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn api_chat_profiles() -> &'static [ApiChatProfile] {
|
||||
API_CHAT_PROFILES
|
||||
}
|
||||
|
||||
pub fn api_chat_profile_by_id(value: &str) -> Option<ApiChatProfile> {
|
||||
let needle = value.trim();
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
API_CHAT_PROFILES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|profile| profile_matches(*profile, needle))
|
||||
}
|
||||
|
||||
pub fn payload_uses_api_chat_profile(payload: &Value, registration_profile: &str) -> bool {
|
||||
let agent_id = payload
|
||||
.get("agentId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if agent_id != "chat_only" {
|
||||
return false;
|
||||
}
|
||||
let agent_profile = payload.get("agentProfileRef");
|
||||
[
|
||||
Some(registration_profile),
|
||||
payload.get("profile").and_then(Value::as_str),
|
||||
payload.get("profileId").and_then(Value::as_str),
|
||||
payload.get("profile_id").and_then(Value::as_str),
|
||||
agent_profile
|
||||
.and_then(|value| value.get("baseProfile"))
|
||||
.and_then(Value::as_str),
|
||||
agent_profile
|
||||
.and_then(|value| value.get("isolatedProfile"))
|
||||
.and_then(Value::as_str),
|
||||
agent_profile
|
||||
.and_then(|value| value.get("profileId"))
|
||||
.and_then(Value::as_str),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|candidate| api_chat_profile_by_id(candidate).is_some())
|
||||
}
|
||||
|
||||
pub fn resolve_api_chat_profile(value: &str) -> Result<ResolvedApiChatProfile, ApiChatError> {
|
||||
let profile = api_chat_profile_by_id(value).ok_or_else(|| {
|
||||
ApiChatError::new(
|
||||
"api_chat_profile_unknown",
|
||||
format!("未知 API Chat profile: {value}"),
|
||||
)
|
||||
})?;
|
||||
let env_prefix = profile_env_prefix(profile.profile_id);
|
||||
let model = env_value(&format!("{env_prefix}_MODEL"))
|
||||
.or_else(|| env_value("MNOTE_API_CHAT_MODEL"))
|
||||
.unwrap_or_else(|| profile.model.to_string());
|
||||
let base_url = env_value(&format!("{env_prefix}_BASE_URL"))
|
||||
.or_else(|| env_value("MNOTE_API_CHAT_BASE_URL"))
|
||||
.unwrap_or_else(|| DEFAULT_API_CHAT_BASE_URL.to_string());
|
||||
let api_key = env_value(&format!("{env_prefix}_API_KEY"))
|
||||
.or_else(|| env_value("MNOTE_API_CHAT_API_KEY"))
|
||||
.or_else(|| env_value("OPENAI_API_KEY"));
|
||||
Ok(ResolvedApiChatProfile {
|
||||
profile_id: profile.profile_id.to_string(),
|
||||
base_profile: profile.base_profile.to_string(),
|
||||
isolated_profile: profile.isolated_profile.to_string(),
|
||||
label: profile.label.to_string(),
|
||||
model,
|
||||
provider_kind: profile.provider_kind.to_string(),
|
||||
status: profile.status.to_string(),
|
||||
base_url: base_url.trim().trim_end_matches('/').to_string(),
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn runtime_events_from_openai_sse_chunk(
|
||||
run_id: &str,
|
||||
chunk: &str,
|
||||
) -> Result<Vec<SseEvent>, ApiChatError> {
|
||||
let mut decoder = OpenAiSseDecoder::default();
|
||||
decoder.push_chunk(run_id, chunk)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OpenAiSseDecoder {
|
||||
buffer: String,
|
||||
output: String,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
impl OpenAiSseDecoder {
|
||||
pub fn push_chunk(&mut self, run_id: &str, chunk: &str) -> Result<Vec<SseEvent>, ApiChatError> {
|
||||
self.buffer.push_str(chunk);
|
||||
let mut frames = self
|
||||
.buffer
|
||||
.split("\n\n")
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
self.buffer = frames.pop().unwrap_or_default();
|
||||
let mut events = Vec::new();
|
||||
for frame in frames {
|
||||
events.extend(self.parse_frame(run_id, &frame)?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, run_id: &str) -> Result<Vec<SseEvent>, ApiChatError> {
|
||||
let rest = std::mem::take(&mut self.buffer);
|
||||
let mut events = if rest.trim().is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.parse_frame(run_id, &rest)?
|
||||
};
|
||||
if !self.completed {
|
||||
self.completed = true;
|
||||
events.push(self.completed_event());
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn parse_frame(&mut self, run_id: &str, frame: &str) -> Result<Vec<SseEvent>, ApiChatError> {
|
||||
let mut events = Vec::new();
|
||||
for data in sse_frame_data_lines(frame) {
|
||||
if data == "[DONE]" {
|
||||
if !self.completed {
|
||||
self.completed = true;
|
||||
events.push(self.completed_event());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let payload = serde_json::from_str::<Value>(&data).map_err(|error| {
|
||||
ApiChatError::new(
|
||||
"api_chat_stream_parse_error",
|
||||
format!("OpenAI SSE chunk 解析失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(error) = payload.get("error") {
|
||||
self.completed = true;
|
||||
events.push(SseEvent {
|
||||
event: "run.failed".into(),
|
||||
data: json!({
|
||||
"runId": run_id,
|
||||
"code": "api_chat_upstream_error",
|
||||
"message": error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("API Chat upstream error"),
|
||||
"error": error
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for delta in extract_delta_texts(&payload) {
|
||||
self.output.push_str(&delta);
|
||||
events.push(SseEvent {
|
||||
event: "message.delta".into(),
|
||||
data: json!({
|
||||
"runId": run_id,
|
||||
"delta": delta
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn completed_event(&self) -> SseEvent {
|
||||
SseEvent {
|
||||
event: "run.completed".into(),
|
||||
data: json!({
|
||||
"output": self.output
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_matches(profile: ApiChatProfile, value: &str) -> bool {
|
||||
profile.profile_id == value
|
||||
|| profile.base_profile == value
|
||||
|| profile.isolated_profile == value
|
||||
|| profile.label == value
|
||||
}
|
||||
|
||||
fn profile_env_prefix(profile_id: &str) -> String {
|
||||
let suffix = profile_id
|
||||
.trim()
|
||||
.strip_prefix("shared_api_")
|
||||
.unwrap_or(profile_id)
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_uppercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
format!("MNOTE_API_CHAT_{suffix}")
|
||||
}
|
||||
|
||||
fn env_value(key: &str) -> Option<String> {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn sse_frame_data_lines(frame: &str) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
for line in frame.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(data) = trimmed.strip_prefix("data:") {
|
||||
lines.push(data.trim().to_string());
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn extract_delta_texts(payload: &Value) -> Vec<String> {
|
||||
let mut texts = Vec::new();
|
||||
if let Some(choices) = payload.get("choices").and_then(Value::as_array) {
|
||||
for choice in choices {
|
||||
for value in [
|
||||
choice
|
||||
.get("delta")
|
||||
.and_then(|delta| delta.get("content"))
|
||||
.and_then(Value::as_str),
|
||||
choice
|
||||
.get("message")
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_str),
|
||||
choice.get("text").and_then(Value::as_str),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !value.is_empty() {
|
||||
texts.push(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if texts.is_empty() {
|
||||
for value in [
|
||||
payload.get("delta").and_then(Value::as_str),
|
||||
payload.get("text").and_then(Value::as_str),
|
||||
payload.get("content").and_then(Value::as_str),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !value.is_empty() {
|
||||
texts.push(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
texts
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::acp_runtime::AcpRuntimeManager;
|
||||
use crate::document_buffer_store::BufferStore;
|
||||
use crate::editor_actor::EditorRuntimeActor;
|
||||
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
||||
@@ -33,7 +32,6 @@ pub struct AppConfig {
|
||||
pub enable_debug_shell_routes: bool,
|
||||
pub enable_editor_actor: bool,
|
||||
pub enable_page_ai_pi_lab: bool,
|
||||
pub hermes_base_path: String,
|
||||
pub compat_next_base_path: String,
|
||||
pub convex_url: Option<String>,
|
||||
pub convex_admin_key: Option<String>,
|
||||
@@ -67,8 +65,6 @@ impl AppConfig {
|
||||
.unwrap_or(false),
|
||||
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
|
||||
enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true),
|
||||
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
|
||||
.unwrap_or_else(|_| "/api/hermes".into()),
|
||||
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
|
||||
.unwrap_or_else(|_| "/api/compat/next".into()),
|
||||
convex_url: None,
|
||||
@@ -163,7 +159,6 @@ pub struct AppState {
|
||||
pub editor_actor: EditorRuntimeActor,
|
||||
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub acp_runtime: Arc<AcpRuntimeManager>,
|
||||
pub buffer_store: BufferStore,
|
||||
control_plane: Arc<dyn ControlPlaneStore>,
|
||||
}
|
||||
@@ -185,7 +180,6 @@ impl AppState {
|
||||
editor_actor: actor,
|
||||
block_delta_tx,
|
||||
stream_delta_tx,
|
||||
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
|
||||
buffer_store,
|
||||
control_plane,
|
||||
}
|
||||
@@ -389,7 +383,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -192,7 +192,7 @@ mod tests {
|
||||
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::POST,
|
||||
&"/api/hermes/bridge".parse::<Uri>().expect("uri"),
|
||||
&"/api/mnote/tools".parse::<Uri>().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ pub fn buffer_key_string(path: &ObjectWorkspacePath) -> String {
|
||||
|
||||
/// 从本地文件夹写入上下文的参数构建 ObjectWorkspacePath。
|
||||
///
|
||||
/// 在 save_local_markdown_page、watcher event 和 Hermes 写入链中统一使用此函数构造路径。
|
||||
/// 在 save_local_markdown_page、watcher event 和 agent tool 写入链中统一使用此函数构造路径。
|
||||
pub fn build_local_folder_workspace_path(
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
#![recursion_limit = "1024"]
|
||||
|
||||
pub mod acp_bridge;
|
||||
pub mod acp_client;
|
||||
pub mod acp_runtime;
|
||||
pub mod acp_session_manager;
|
||||
pub mod acp_types;
|
||||
pub mod api_chat;
|
||||
pub mod app;
|
||||
pub mod context;
|
||||
pub mod document_buffer_store;
|
||||
pub mod editor_actor;
|
||||
pub mod error;
|
||||
pub mod evidence_parse;
|
||||
pub mod hermes_tools;
|
||||
pub mod mnote_agent_tools;
|
||||
pub mod local_folder_watcher_registry;
|
||||
pub mod middleware;
|
||||
pub mod page_aggregate;
|
||||
@@ -29,7 +23,7 @@ pub use app::{build_app, AppConfig, AppState};
|
||||
pub(crate) mod test_support {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub(crate) fn hermes_env_lock() -> &'static Mutex<()> {
|
||||
pub(crate) fn agent_env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token};
|
||||
use axum::extract::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
|
||||
pub async fn inject_request_context(mut request: Request, next: Next) -> Response {
|
||||
let context =
|
||||
let mut context =
|
||||
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
|
||||
|
||||
// 12-3 E2: Authorization Bearer mnext1.* → actor (when cookie/header actor is anonymous)
|
||||
if context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty() {
|
||||
if let Some(token) = bearer_mnext1(context.auth.authorization.as_deref()) {
|
||||
if let Ok(claims) = verify_extension_token(token) {
|
||||
context.auth.actor_id = claims.actor;
|
||||
if context.auth.actor_type.trim().is_empty()
|
||||
|| context.auth.actor_type.trim() == "anonymous"
|
||||
{
|
||||
context.auth.actor_type = "user".into();
|
||||
}
|
||||
if context.auth.session_id.is_none() {
|
||||
context.auth.session_id = Some(format!("ext:{}", claims.jti));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request.extensions_mut().insert(context.clone());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::ensure_local_workspace_access;
|
||||
use bridge_runtime::{
|
||||
@@ -179,8 +179,8 @@ async fn create_artifact_node(
|
||||
.or_else(|| context.auth.session_id.clone()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "hermes".into(),
|
||||
client: "mnote-hermes-plugin".into(),
|
||||
channel: "agent".into(),
|
||||
client: "mnote-agent-plugin".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: workspace_id.clone(),
|
||||
@@ -208,7 +208,7 @@ async fn create_artifact_node(
|
||||
"artifact": {
|
||||
"kind": node_type,
|
||||
"sourceDocumentId": document_id,
|
||||
"source": "hermes",
|
||||
"source": "agent",
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": input.tool_call_id,
|
||||
@@ -221,7 +221,7 @@ async fn create_artifact_node(
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some(tool_name.into()),
|
||||
refs: vec![tool_name.into(), "hermes-tool-call".into()],
|
||||
refs: vec![tool_name.into(), "agent-tool-call".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
@@ -276,5 +276,5 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
crate::mnote_agent_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
+16
-16
@@ -1,11 +1,11 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::doc::{
|
||||
use crate::mnote_agent_tools::doc::{
|
||||
aggregate_value, block_id_of, block_not_found, block_projection_blocks, find_block,
|
||||
required_arg,
|
||||
};
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use bridge_runtime::{
|
||||
apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
@@ -693,7 +693,7 @@ pub(crate) fn ensure_write_contract(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
crate::mnote_agent_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
fn ensure_leaf_block(
|
||||
@@ -1128,8 +1128,8 @@ async fn execute_page_body_save(
|
||||
.or_else(|| context.auth.session_id.clone()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "hermes".into(),
|
||||
client: "mnote-hermes-plugin".into(),
|
||||
channel: "agent".into(),
|
||||
client: "mnote-agent-plugin".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: workspace_id.clone(),
|
||||
@@ -1142,8 +1142,8 @@ async fn execute_page_body_save(
|
||||
}),
|
||||
payload,
|
||||
preflight_data: None,
|
||||
reason: Some("Hermes block tool page.body.save".into()),
|
||||
refs: vec!["page.body.save".into(), "hermes-block-tool-call".into()],
|
||||
reason: Some("agent block tool page.body.save".into()),
|
||||
refs: vec!["page.body.save".into(), "agent-block-tool-call".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
@@ -1199,8 +1199,8 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
|
||||
.or_else(|| context.auth.session_id.clone()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "hermes".into(),
|
||||
client: "mnote-hermes-plugin".into(),
|
||||
channel: "agent".into(),
|
||||
client: "mnote-agent-plugin".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: workspace_id.clone(),
|
||||
@@ -1213,10 +1213,10 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
|
||||
}),
|
||||
payload,
|
||||
preflight_data: None,
|
||||
reason: Some("Hermes batch block tool page.body.save".into()),
|
||||
reason: Some("agent batch block tool page.body.save".into()),
|
||||
refs: vec![
|
||||
"page.body.save".into(),
|
||||
"hermes-batch-block-tool-call".into(),
|
||||
"agent-batch-block-tool-call".into(),
|
||||
],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
@@ -1480,7 +1480,7 @@ mod tests {
|
||||
fn ensure_write_contract_rejects_read_only_ai_scope() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/tools".parse().expect("uri"),
|
||||
&"/api/mnote/tools".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let input = ToolCallInput {
|
||||
@@ -1521,13 +1521,13 @@ mod tests {
|
||||
"attrs": {},
|
||||
"payload": {
|
||||
"marks": [],
|
||||
"text": "Hermes 插件替换第二段",
|
||||
"text": "agent 插件替换第二段",
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
assert_eq!(content_to_text(&value), "Hermes 插件替换第二段");
|
||||
assert_eq!(content_to_text(&value), "agent 插件替换第二段");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1540,7 +1540,7 @@ mod tests {
|
||||
"attrs": {},
|
||||
"payload": {
|
||||
"marks": [],
|
||||
"text": "Hermes 插件插入段",
|
||||
"text": "agent 插件插入段",
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
@@ -1548,6 +1548,6 @@ mod tests {
|
||||
}
|
||||
]);
|
||||
|
||||
assert_eq!(content_to_text(&value), "Hermes 插件插入段");
|
||||
assert_eq!(content_to_text(&value), "agent 插件插入段");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{doc, ToolCallInput};
|
||||
use crate::mnote_agent_tools::{doc, ToolCallInput};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn context_snapshot(
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
@@ -346,7 +346,7 @@ pub async fn plan_update(
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入计划型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
"写入计划型 mnote agent tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
@@ -1492,7 +1492,7 @@ pub async fn doc_markdown_edit(
|
||||
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
|
||||
let is_local_workspace =
|
||||
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
|
||||
crate::hermes_tools::block::ensure_write_contract(context, input)?;
|
||||
crate::mnote_agent_tools::block::ensure_write_contract(context, input)?;
|
||||
|
||||
// 1. 读取当前文档内容(markdown 形式)
|
||||
let (current_md, source) = if is_local_file {
|
||||
@@ -1650,7 +1650,7 @@ pub async fn doc_markdown_edit(
|
||||
match aggregate_value(state, context, input).await {
|
||||
Ok(agg) => {
|
||||
let blocks = block_projection_blocks(&agg);
|
||||
let original_content = crate::hermes_tools::block::current_body_content(&agg);
|
||||
let original_content = crate::mnote_agent_tools::block::current_body_content(&agg);
|
||||
(agg, blocks, original_content)
|
||||
}
|
||||
Err(_) if use_full_content.is_some() => {
|
||||
@@ -1756,8 +1756,8 @@ pub async fn doc_markdown_edit(
|
||||
.or_else(|| context.auth.session_id.clone()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "mnote-hermes".into(),
|
||||
client: "mnote-hermes-plugin".into(),
|
||||
channel: "mnote-agent".into(),
|
||||
client: "mnote-agent-plugin".into(),
|
||||
source_kind,
|
||||
root_uri,
|
||||
workspace_id: None,
|
||||
@@ -1771,7 +1771,7 @@ pub async fn doc_markdown_edit(
|
||||
payload,
|
||||
preflight_data: None,
|
||||
reason: Some("mnote.doc.markdown_edit (7-27)".into()),
|
||||
refs: vec!["page.body.save".into(), "mnote-hermes-tool-call".into()],
|
||||
refs: vec!["page.body.save".into(), "mnote-agent-tool-call".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{ensure_write_authorized, ToolCallInput};
|
||||
use crate::mnote_agent_tools::{ensure_write_authorized, ToolCallInput};
|
||||
use crate::routes;
|
||||
use axum::http::StatusCode;
|
||||
use serde::Deserialize;
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use crate::routes::knowledge_rag::{
|
||||
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSearchRequest,
|
||||
KnowledgeRagSectionContextRequest, KnowledgeRagStatusQuery,
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
use super::skill;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.hermes_tool_manifest.v1";
|
||||
pub const TOOL_SCHEMA_VERSION: &str = "mnote.hermes_tool.v1";
|
||||
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.agent_tool_manifest.v1";
|
||||
pub const TOOL_SCHEMA_VERSION: &str = "mnote.agent_tool.v1";
|
||||
|
||||
pub fn manifest() -> Value {
|
||||
let tools = annotate_tools_with_capabilities(assemble_all_tools());
|
||||
+5
-5
@@ -177,7 +177,7 @@ impl ToolCallInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 hermes_tools 写入守卫。
|
||||
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 agent tools 写入守卫。
|
||||
///
|
||||
/// 当此桥接可用时,`ensure_write_authorized` 除检查 `ToolCallInput` 自带的
|
||||
/// `aiAccessScope.permissionLevel` 外,额外检查 `ai_can_write` 和 `workspace_readonly`。
|
||||
@@ -191,7 +191,7 @@ pub struct CommandContextBridge {
|
||||
pub ai_can_write: bool,
|
||||
}
|
||||
|
||||
/// 统一的 hermes_tools 写入守卫。检查:
|
||||
/// 统一的 agent tools 写入守卫。检查:
|
||||
///
|
||||
/// - `idempotencyKey` 必须存在
|
||||
/// - `dryRun` 必须显式携带
|
||||
@@ -207,14 +207,14 @@ pub fn ensure_write_authorized(
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
"写入型 mnote agent tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
||||
"写入型 mnote agent tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
@@ -256,7 +256,7 @@ mod tests {
|
||||
fn context() -> RequestContext {
|
||||
RequestContext::from_http_parts(
|
||||
&Method::POST,
|
||||
&"/api/hermes/tools".parse().expect("uri"),
|
||||
&"/api/mnote/tools".parse().expect("uri"),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{ensure_write_authorized, ToolCallInput};
|
||||
use crate::mnote_agent_tools::{ensure_write_authorized, ToolCallInput};
|
||||
use crate::routes::onlyoffice_bridge::{self, BridgeResultWire, BridgeRunError};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||
use bridge_runtime::{
|
||||
@@ -18,7 +18,7 @@ pub async fn page_get(
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::hermes_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
crate::mnote_agent_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let source_kind = input.effective_source_kind();
|
||||
let root_uri = input.effective_root_uri();
|
||||
@@ -72,7 +72,7 @@ pub async fn page_get(
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
crate::mnote_agent_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
pub async fn page_save(
|
||||
@@ -287,8 +287,8 @@ async fn page_command(
|
||||
.or_else(|| context.auth.session_id.clone()),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: "hermes".into(),
|
||||
client: "mnote-hermes-plugin".into(),
|
||||
channel: "agent".into(),
|
||||
client: "mnote-agent-plugin".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: workspace_id.clone(),
|
||||
@@ -301,8 +301,8 @@ async fn page_command(
|
||||
}),
|
||||
payload,
|
||||
preflight_data: None,
|
||||
reason: Some(format!("Hermes tool {command_name}")),
|
||||
refs: vec![command_name.into(), "hermes-tool-call".into()],
|
||||
reason: Some(format!("agent tool {command_name}")),
|
||||
refs: vec![command_name.into(), "agent-tool-call".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
@@ -468,7 +468,7 @@ fn ensure_resource_write_contract(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
||||
crate::mnote_agent_tools::ensure_write_authorized(context, input)
|
||||
}
|
||||
|
||||
fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
|
||||
+29
-33
@@ -1,6 +1,6 @@
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -25,7 +25,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
title: "当前页读取",
|
||||
description: "仅在任务需要当前 MNote Markdown 页面内容时读取当前页。",
|
||||
category: "mnote",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
agent_ids: &["pi"],
|
||||
read_only: true,
|
||||
requires_context_refs: &["current_page"],
|
||||
tool_names: &[
|
||||
@@ -40,7 +40,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
title: "知识库问答",
|
||||
description: "通过 LightRAG 知识库检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 LightRAG。",
|
||||
category: "knowledge",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
agent_ids: &["pi"],
|
||||
read_only: true,
|
||||
requires_context_refs: &["folder"],
|
||||
tool_names: &[
|
||||
@@ -58,7 +58,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
title: "本地文件编辑",
|
||||
description: "在 MNote 授权目录内读取和修改本地 Markdown 文件。",
|
||||
category: "file",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
agent_ids: &["pi"],
|
||||
read_only: false,
|
||||
requires_context_refs: &["current_page", "file", "folder"],
|
||||
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
||||
@@ -69,7 +69,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
title: "ONLYOFFICE 实时编辑",
|
||||
description: "操作当前已打开的 ONLYOFFICE Word、Excel、PPT 编辑会话。",
|
||||
category: "office",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
agent_ids: &["pi"],
|
||||
read_only: false,
|
||||
requires_context_refs: &["onlyoffice"],
|
||||
tool_names: &[
|
||||
@@ -116,7 +116,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
title: "思维导图",
|
||||
description: "读取、更新、总结或创建 MNote 思维导图资源。",
|
||||
category: "resource",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
agent_ids: &["pi"],
|
||||
read_only: false,
|
||||
requires_context_refs: &["current_page", "file", "folder", "resource"],
|
||||
tool_names: &[
|
||||
@@ -133,7 +133,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
title: "密码箱 / AI 密码本",
|
||||
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*(token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
|
||||
category: "security",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
agent_ids: &["pi"],
|
||||
read_only: true,
|
||||
requires_context_refs: &["folder"],
|
||||
tool_names: &[
|
||||
@@ -153,7 +153,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
title: "纯聊天",
|
||||
description: "只进行对话回复,不读取或写入 MNote 页面、文件上下文。",
|
||||
category: "chat",
|
||||
agent_ids: &["chat_only", "hermes", "reasonix"],
|
||||
agent_ids: &["chat_only", "pi"],
|
||||
read_only: true,
|
||||
requires_context_refs: &[],
|
||||
tool_names: &[],
|
||||
@@ -306,18 +306,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn skill_lookup_rejects_unknown_or_agent_mismatch() {
|
||||
assert!(find_skill("mnote-current-page", Some("reasonix")).is_some());
|
||||
assert!(find_skill("mnote-current-page", Some("pi")).is_some());
|
||||
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
|
||||
assert!(find_skill("missing", Some("reasonix")).is_none());
|
||||
assert!(find_skill("missing", Some("pi")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
|
||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
||||
let skill = reasonix_skills
|
||||
let pi_agent_skills = skill_summaries_for_agent(Some("pi"));
|
||||
let skill = pi_agent_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-onlyoffice-live")
|
||||
.expect("reasonix should see live ONLYOFFICE skill");
|
||||
.expect("pi should see live ONLYOFFICE skill");
|
||||
assert_eq!(skill["readOnly"], false);
|
||||
assert_eq!(
|
||||
skill["toolNames"]
|
||||
@@ -363,11 +363,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn skill_registry_exposes_mindmap_skill_to_agents() {
|
||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
||||
let skill = reasonix_skills
|
||||
let pi_agent_skills = skill_summaries_for_agent(Some("pi"));
|
||||
let skill = pi_agent_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-mindmap")
|
||||
.expect("reasonix should see mindmap skill");
|
||||
.expect("pi should see mindmap skill");
|
||||
assert_eq!(skill["readOnly"], false);
|
||||
assert!(skill["requiresContextRefs"]
|
||||
.as_array()
|
||||
@@ -383,12 +383,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn skill_registry_exposes_vault_skill_to_agents() {
|
||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
||||
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
|
||||
let skill = reasonix_skills
|
||||
let pi_skills = skill_summaries_for_agent(Some("pi"));
|
||||
let skill = pi_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-vault")
|
||||
.expect("reasonix should see vault skill");
|
||||
.expect("pi should see vault skill");
|
||||
assert_eq!(skill["readOnly"], true);
|
||||
assert_eq!(skill["category"], "security");
|
||||
assert!(skill["toolNames"]
|
||||
@@ -396,12 +395,9 @@ mod tests {
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.vault.resolve"));
|
||||
assert!(hermes_skills
|
||||
.iter()
|
||||
.any(|skill| skill["id"] == "mnote-vault"));
|
||||
assert!(find_skill("mnote-vault", Some("chat_only")).is_none());
|
||||
let body = find_skill("mnote-vault", Some("hermes"))
|
||||
.expect("hermes can read vault skill")
|
||||
let body = find_skill("mnote-vault", Some("pi"))
|
||||
.expect("pi can read vault skill")
|
||||
.content;
|
||||
assert!(body.contains(".mnote/vault"));
|
||||
assert!(body.contains("共享到 AI") || body.contains("AI 密码本"));
|
||||
@@ -409,28 +405,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
|
||||
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
|
||||
assert!(!hermes_skills
|
||||
let pi_skills = skill_summaries_for_agent(Some("pi"));
|
||||
assert!(!pi_skills
|
||||
.iter()
|
||||
.any(|skill| skill["id"] == "mnote-local-index"));
|
||||
let skill = hermes_skills
|
||||
let skill = pi_skills
|
||||
.iter()
|
||||
.find(|skill| skill["id"] == "mnote-knowledge-rag")
|
||||
.expect("hermes should see LightRAG skill");
|
||||
.expect("pi should see LightRAG skill");
|
||||
assert_eq!(skill["readOnly"], true);
|
||||
assert!(skill["toolNames"]
|
||||
.as_array()
|
||||
.expect("tool names")
|
||||
.iter()
|
||||
.any(|name| name == "mnote.knowledge_rag.query"));
|
||||
assert!(!hermes_skills
|
||||
assert!(!pi_skills
|
||||
.iter()
|
||||
.any(|skill| skill["id"] == "mnote-document-evidence"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_read_maps_document_evidence_alias_to_lightrag() {
|
||||
let skill = find_skill("mnote-document-evidence", Some("reasonix"))
|
||||
let skill = find_skill("mnote-document-evidence", Some("pi"))
|
||||
.expect("compat alias should resolve");
|
||||
assert_eq!(skill.id, "mnote-knowledge-rag");
|
||||
}
|
||||
@@ -439,14 +435,14 @@ mod tests {
|
||||
async fn skill_read_returns_mindmap_skill_content() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/tools/execute".parse().expect("uri"),
|
||||
&"/api/mnote/tools/call".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let input: ToolCallInput = serde_json::from_value(json!({
|
||||
"toolName": "mnote.skill.read",
|
||||
"args": {
|
||||
"skillId": "mnote-mindmap",
|
||||
"agentId": "reasonix"
|
||||
"agentId": "pi"
|
||||
}
|
||||
}))
|
||||
.expect("input");
|
||||
@@ -1385,7 +1385,7 @@ fn default_skill_registry() -> HashMap<String, SkillConfig> {
|
||||
skill_config(
|
||||
"Global Search",
|
||||
"聚合本机/网页搜索线索,适合研究型查询入口。",
|
||||
"/home/lix/.hermes/profiles/lite/skills/global-search/SKILL.md",
|
||||
"/home/lix/.mnote/agent-profiles/lite/skills/global-search/SKILL.md",
|
||||
"medium",
|
||||
&["network:search"],
|
||||
),
|
||||
@@ -3611,7 +3611,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn admin_user_display_role_includes_access_policy_admins() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let policy_root = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -168,7 +168,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -30,7 +30,7 @@ pub async fn next_ai_agent_run(
|
||||
StatusCode::GONE,
|
||||
"legacy_ai_agent_run_retired",
|
||||
format!(
|
||||
"旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Hermes client proxy 与 mnote Hermes plugin。"
|
||||
"旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Pi Lab 与 /api/mnote/tools。"
|
||||
),
|
||||
)
|
||||
.with_context(&context)
|
||||
@@ -74,7 +74,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -114,7 +113,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -156,7 +154,7 @@ mod tests {
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("legacy_ai_agent_run_retired"));
|
||||
assert!(text.contains("Hermes client proxy"));
|
||||
assert!(text.contains("Pi Lab"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -171,7 +169,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -210,7 +207,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge() {
|
||||
async fn explicit_retired_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge() {
|
||||
let next_app = axum::Router::new().route(
|
||||
"/api/ai-agent/run",
|
||||
axum::routing::post(|| async move {
|
||||
@@ -240,7 +237,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -523,7 +523,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: false,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -601,7 +600,7 @@ mod tests {
|
||||
"session_id": "test-session-1",
|
||||
"run_id": "test-run-1",
|
||||
"profile": "test-profile",
|
||||
"acp_runtime": "reasonix",
|
||||
"acp_runtime": "pi",
|
||||
"status": "running",
|
||||
"events": [{
|
||||
"eventType": "message",
|
||||
@@ -616,7 +615,7 @@ mod tests {
|
||||
assert_eq!(payload["results"][0]["kind"], "seedAiRuntime");
|
||||
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
|
||||
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
|
||||
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
|
||||
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "pi");
|
||||
assert_eq!(payload["results"][0]["run"]["status"], "running");
|
||||
assert_eq!(payload["results"][0]["events"].as_array().unwrap().len(), 1);
|
||||
|
||||
@@ -636,7 +635,7 @@ mod tests {
|
||||
assert!(payload["results"][0]["run"].is_object());
|
||||
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
|
||||
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
|
||||
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
|
||||
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "pi");
|
||||
assert_eq!(payload["results"][0]["run"]["status"], "running");
|
||||
}
|
||||
|
||||
|
||||
@@ -1197,7 +1197,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -1780,7 +1780,6 @@ mod tests {
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -389,7 +389,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -2998,7 +2998,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url,
|
||||
convex_admin_key: None,
|
||||
@@ -3418,7 +3417,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_first_landing_without_convex() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let base = temp_root("mnote-root-local-first-landing");
|
||||
@@ -3521,7 +3520,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -3589,7 +3587,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -3897,7 +3894,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -4039,11 +4035,13 @@ mod tests {
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
// pageId 触发 reveal:lazy PageTree 在 scope 内展开 active 文档父链。
|
||||
let page_id = "local-md:design~2F05-editor-mainline~2FTarget.md";
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design&pageId={page_id}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
@@ -4181,7 +4179,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_initializes_default_local_workspace_page() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let base = temp_root("mnote-root-default-local-workspace");
|
||||
@@ -4239,7 +4237,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::{
|
||||
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
||||
runtime_input_requests_result, RuntimeInput,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HermesHealthResponse {
|
||||
pub ok: bool,
|
||||
pub service: String,
|
||||
pub bridge: &'static str,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
pub async fn health(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Json<HermesHealthResponse> {
|
||||
Json(HermesHealthResponse {
|
||||
ok: true,
|
||||
service: state.config().service_name.clone(),
|
||||
bridge: "hermes",
|
||||
request_id: context.trace.request_id,
|
||||
trace_id: context.trace.trace_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn bridge_runtime(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let session_id = format!("hermes_{}", context.trace.request_id);
|
||||
let runtime_input = match serde_json::from_value::<RuntimeInput>(payload.clone()) {
|
||||
Ok(runtime_input) => runtime_input,
|
||||
Err(_) => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
stamp_ai_bridge_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"bridge": "hermes_session",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(&session_id),
|
||||
"structuredWrite": {
|
||||
"owner": "rust-web-hermes",
|
||||
"allowedCommands": [
|
||||
"page.body.save",
|
||||
"tree.node.create",
|
||||
"kernel.edge.attach"
|
||||
]
|
||||
},
|
||||
"compatPayload": payload,
|
||||
})),
|
||||
));
|
||||
}
|
||||
};
|
||||
let payload = if runtime_input_requests_result(&runtime_input) {
|
||||
match execute_runtime_query(runtime_input) {
|
||||
Ok(result) => json!({
|
||||
"ok": true,
|
||||
"bridge": "hermes_runtime_result",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(&session_id),
|
||||
"result": result,
|
||||
}),
|
||||
Err(error) => {
|
||||
let failure = build_failure_response(error);
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
stamp_ai_bridge_headers(),
|
||||
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match execute_runtime_input(runtime_input) {
|
||||
Ok(plan) => {
|
||||
let success = build_success_response(plan);
|
||||
json!({
|
||||
"ok": success.ok,
|
||||
"bridge": "hermes_runtime_plan",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"contract": ai_bridge_contract(&session_id),
|
||||
"plan": success.plan,
|
||||
})
|
||||
}
|
||||
Err(error) => {
|
||||
let failure = build_failure_response(error);
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
stamp_ai_bridge_headers(),
|
||||
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
|
||||
}
|
||||
|
||||
fn ai_bridge_contract(session_id: &str) -> Value {
|
||||
json!({
|
||||
"schema": "mnote.ai_bridge.v1",
|
||||
"owner": "mnote-web",
|
||||
"bridge": "hermes",
|
||||
"sessionId": session_id,
|
||||
"eventStreamEndpoint": format!("/api/hermes/events/{session_id}"),
|
||||
"canonicalRoute": "/api/hermes/bridge",
|
||||
"sessionOwner": "rust-web-hermes",
|
||||
"toolEventOwner": "rust-web-hermes",
|
||||
"clientActionOwner": "rust-web-hermes",
|
||||
"structuredWriteOwner": "rust-web-hermes"
|
||||
})
|
||||
}
|
||||
|
||||
fn stamp_ai_bridge_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("mnote-web"));
|
||||
}
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_AI_BRIDGE_OWNER.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static("rust-web-hermes"));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_bridge_route_returns_hermes_owner_contract() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/bridge")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"kind": "tool",
|
||||
"context": {
|
||||
"deploymentId": null,
|
||||
"projectId": null,
|
||||
"workspaceId": "ws_demo",
|
||||
"requestId": "req_1",
|
||||
"traceId": "trace_1",
|
||||
"actor": {
|
||||
"actorType": "user",
|
||||
"actorId": "user_1",
|
||||
"sessionId": null
|
||||
},
|
||||
"source": {
|
||||
"channel": "rust-web",
|
||||
"client": "mnote-web"
|
||||
},
|
||||
"tenantId": null,
|
||||
"authToken": null,
|
||||
"idempotencyKey": null,
|
||||
"validateOnly": false,
|
||||
"dryRun": false
|
||||
},
|
||||
"tool": {
|
||||
"tool": "search_web",
|
||||
"kind": "query",
|
||||
"mode": "plan",
|
||||
"argsJson": {"query": "Rust Web"},
|
||||
"target": null,
|
||||
"reason": "owner gate",
|
||||
"refs": []
|
||||
},
|
||||
"data": null
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-web-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-mnote-ai-bridge-owner")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("rust-web-hermes")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["contract"]["schema"], "mnote.ai_bridge.v1");
|
||||
assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
||||
assert!(payload["eventStreamEndpoint"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("/api/hermes/events/"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ai_bridge_accepts_legacy_intent_payload_as_hermes_session() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/bridge")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"stream": true,
|
||||
"scope": "document",
|
||||
"messages": [{"role": "user", "content": "生成摘要"}],
|
||||
"context": {"documentId": "doc_1"}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["bridge"], "hermes_session");
|
||||
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
||||
assert_eq!(
|
||||
payload["contract"]["structuredWriteOwner"],
|
||||
"rust-web-hermes"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -299,7 +299,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -505,7 +505,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -144,12 +144,24 @@ fn local_folder_metadata_fingerprints(root: &Path) -> [u64; 4] {
|
||||
}
|
||||
|
||||
fn invalidate_local_folder_metadata_cache(root: &Path) {
|
||||
let key = root.to_string_lossy().to_string();
|
||||
// 与 local_folder_watch_revision / metadata 写入侧一致:优先用 canonicalize 后的 key。
|
||||
let key = root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| root.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let raw_key = root.to_string_lossy().to_string();
|
||||
if let Ok(mut cache) = local_folder_metadata_cache().lock() {
|
||||
cache.remove(&key);
|
||||
if raw_key != key {
|
||||
cache.remove(&raw_key);
|
||||
}
|
||||
}
|
||||
if let Ok(mut cache) = local_folder_watch_revision_cache().lock() {
|
||||
cache.remove(&key);
|
||||
if raw_key != key {
|
||||
cache.remove(&raw_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10703,7 +10715,7 @@ mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
crate::test_support::hermes_env_lock()
|
||||
crate::test_support::agent_env_lock()
|
||||
}
|
||||
|
||||
fn temp_root(name: &str) -> std::path::PathBuf {
|
||||
@@ -10735,7 +10747,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -10926,6 +10937,7 @@ mod tests {
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
// 根快照 lazy:仅一层;根级 md 直接可见。
|
||||
let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot");
|
||||
let first_json = first.projection.to_string();
|
||||
assert!(first_json.contains("local-md:page.md"));
|
||||
@@ -10934,9 +10946,14 @@ mod tests {
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::rename(root.join("page.md"), root.join("docs").join("renamed.md"))
|
||||
.expect("move md");
|
||||
let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot");
|
||||
// 嵌套页需 scope 到父目录才能在 lazy PageTree 中看到。
|
||||
let second =
|
||||
load_local_folder_page_tree_scope_snapshot(&root_uri, "docs").expect("second snapshot");
|
||||
let second_json = second.projection.to_string();
|
||||
assert!(second_json.contains("local-md:docs~2Frenamed.md"));
|
||||
assert!(
|
||||
second_json.contains("local-md:docs~2Frenamed.md"),
|
||||
"second_json missing expected id; got: {second_json}"
|
||||
);
|
||||
assert!(!second_json.contains("local-mdid:stable-frontmatter-id"));
|
||||
assert!(second_json.contains("renamed.md"));
|
||||
|
||||
@@ -10957,9 +10974,14 @@ mod tests {
|
||||
.expect("write page ids");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("snapshot");
|
||||
// lazy:嵌套 page 在 parent scope 中按 path 派生 id,忽略 page-ids.json 稳定 id。
|
||||
let snapshot =
|
||||
load_local_folder_page_tree_scope_snapshot(&root_uri, "docs").expect("snapshot");
|
||||
let html_json = snapshot.projection.to_string();
|
||||
assert!(html_json.contains("local-md:docs~2Fpage.md"));
|
||||
assert!(
|
||||
html_json.contains("local-md:docs~2Fpage.md"),
|
||||
"missing path id; got: {html_json}"
|
||||
);
|
||||
assert!(!html_json.contains("local-mdid:stable-from-page-ids"));
|
||||
assert!(!html_json.contains("page-ids.json"));
|
||||
|
||||
@@ -15285,13 +15307,18 @@ fn main() {}
|
||||
);
|
||||
}
|
||||
|
||||
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
|
||||
// lazy 根快照只有 docs 文件夹;真实 page 在 docs scope 内。
|
||||
let page_tree =
|
||||
load_local_folder_page_tree_scope_snapshot(&root_uri, "docs").expect("page tree");
|
||||
let page_items = page_tree.projection["items"]
|
||||
.as_array()
|
||||
.expect("page items");
|
||||
assert!(page_items
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")));
|
||||
assert!(
|
||||
page_items
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")),
|
||||
"Page.md 应出现在 docs scope PageTree"
|
||||
);
|
||||
assert!(!page_items.iter().any(|item| item["documentId"].as_str()
|
||||
== Some("local-md:docs~2FPage.ocr~2Fphoto.png.ocr.md")));
|
||||
|
||||
@@ -15525,14 +15552,23 @@ fn main() {}
|
||||
|
||||
let first = local_folder_watch_revision(&root_uri).expect("first revision");
|
||||
// Mutate tree immediately; TTL should still serve previous revision.
|
||||
std::fs::write(root.join("docs").join("page.md"), "# Two\n").expect("update md");
|
||||
// 改长度 + 增文件,避免仅改同长内容时 mtime 精度导致 hash 不变。
|
||||
std::fs::write(
|
||||
root.join("docs").join("page.md"),
|
||||
"# Two — longer body to change len fingerprint\n",
|
||||
)
|
||||
.expect("update md");
|
||||
std::fs::write(root.join("docs").join("extra.md"), "# Extra\n").expect("add md");
|
||||
let cached = local_folder_watch_revision(&root_uri).expect("cached revision");
|
||||
assert_eq!(first.revision, cached.revision);
|
||||
|
||||
// Explicit invalidation path (same as metadata writes) must force recompute.
|
||||
invalidate_local_folder_metadata_cache(&root);
|
||||
let after_invalidate = local_folder_watch_revision(&root_uri).expect("fresh revision");
|
||||
assert_ne!(first.revision, after_invalidate.revision);
|
||||
assert_ne!(
|
||||
first.revision, after_invalidate.revision,
|
||||
"invalidate 后应看到 entry_count/len 变化"
|
||||
);
|
||||
|
||||
set_local_folder_watch_revision_cache_ttl_ms_for_test(2_000);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
@@ -5170,7 +5170,7 @@ mod tests {
|
||||
let workspace_id = "local-ws-evidence-sqlite";
|
||||
fs::write(
|
||||
root.join("README.md"),
|
||||
"# Home\nIntro body.\n## Evidence Section\nEvidenceToken root body.\nAsk @Reasonix for citation.\n",
|
||||
"# Home\nIntro body.\n## Evidence Section\nEvidenceToken root body.\nAsk @AtlasNote for citation.\n",
|
||||
)
|
||||
.expect("write home");
|
||||
fs::write(
|
||||
@@ -5239,7 +5239,7 @@ mod tests {
|
||||
assert!(section_count >= 3);
|
||||
let mention_source_block: String = connection
|
||||
.query_row(
|
||||
"SELECT source_block_id FROM evidence_edge WHERE edge_type = 'mentions' AND to_id = 'entity:Reasonix'",
|
||||
"SELECT source_block_id FROM evidence_edge WHERE edge_type = 'mentions' AND to_id = 'entity:AtlasNote'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -5253,7 +5253,7 @@ mod tests {
|
||||
)
|
||||
.expect("mention source locator");
|
||||
assert!(mention_locator.contains("mnote.evidence_locator.v1"));
|
||||
let graph_results = query_evidence_graph_results(&root, "Reasonix", None, 10)
|
||||
let graph_results = query_evidence_graph_results(&root, "AtlasNote", None, 10)
|
||||
.expect("graph query")
|
||||
.expect("sqlite exists");
|
||||
let mention_result = graph_results
|
||||
|
||||
@@ -487,7 +487,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -353,7 +353,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -422,7 +421,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -508,7 +506,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mindmap_standalone_bootstrap_propagates_dev_hot_to_island_runtime() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||
|
||||
+431
-211
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,7 @@ mod editor;
|
||||
pub(crate) mod evidence;
|
||||
mod gateway;
|
||||
mod health;
|
||||
mod hermes;
|
||||
mod hermes_client;
|
||||
mod hermes_tools;
|
||||
mod mnote_tools;
|
||||
mod kernel;
|
||||
pub(crate) mod knowledge_rag;
|
||||
mod local_folder_events;
|
||||
@@ -26,8 +24,6 @@ mod mindmap_shell;
|
||||
pub(crate) mod navigation_recent;
|
||||
mod onlyoffice;
|
||||
pub(crate) mod onlyoffice_bridge;
|
||||
mod page_ai_board;
|
||||
mod page_ai_opencode;
|
||||
mod page_ai_pi;
|
||||
mod page_ai_workflow;
|
||||
mod query_support;
|
||||
@@ -43,6 +39,7 @@ mod tree_view_state;
|
||||
mod ui_debug;
|
||||
pub(crate) mod ui_preferences;
|
||||
mod vault;
|
||||
pub(crate) mod vault_extension_token;
|
||||
mod vault_path;
|
||||
mod vault_store;
|
||||
mod vault_transport;
|
||||
@@ -72,7 +69,6 @@ use axum::routing::{any, delete, get, post, put};
|
||||
use axum::Router;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let hermes_base_path = state.config().hermes_base_path.clone();
|
||||
let enable_debug_shell_routes = state.config().enable_debug_shell_routes;
|
||||
|
||||
let mut router = Router::new()
|
||||
@@ -248,42 +244,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
|
||||
get(web_shell::sidebar_attachment_open_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/agent-stream-event-router.js",
|
||||
get(web_shell::agent_stream_event_router_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_markdown_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_render_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_permission_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_profile_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_session_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_skill_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_target_runtime_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js",
|
||||
get(web_shell::sidebar_page_ai_pi_lab_runtime_asset),
|
||||
@@ -411,6 +375,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/vault/items/{id}/unshare-from-ai",
|
||||
post(vault::unshare_from_ai),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/items/{id}/session",
|
||||
put(vault::put_item_session),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/extension/token",
|
||||
post(vault::issue_extension_token),
|
||||
)
|
||||
.route(
|
||||
"/api/vault/extension/token/revoke",
|
||||
post(vault::revoke_extension_token),
|
||||
)
|
||||
.route("/api/vault/ai/list", get(vault::list_ai))
|
||||
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
|
||||
.route(
|
||||
@@ -463,140 +439,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/whoami", get(session::session))
|
||||
.route("/api/auth/mnote-web-token", get(session::session))
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route(
|
||||
"/api/ai/agent-profiles",
|
||||
get(hermes_client::list_agent_profiles),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/opencode/status",
|
||||
get(page_ai_opencode::status),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/opencode/session",
|
||||
post(page_ai_opencode::bind_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/opencode/sessions",
|
||||
get(page_ai_opencode::sessions),
|
||||
)
|
||||
.route("/api/page-ai/opencode/abort", post(page_ai_opencode::abort))
|
||||
.route("/api/page-ai/opencode/todo", get(page_ai_opencode::todo))
|
||||
.route("/api/page-ai/opencode/diff", get(page_ai_opencode::diff))
|
||||
.route(
|
||||
"/api/page-ai/opencode/messages",
|
||||
get(page_ai_opencode::messages),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/opencode/prompt",
|
||||
post(page_ai_opencode::prompt),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/opencode/permissions",
|
||||
get(page_ai_opencode::permissions),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/opencode/permission/reply",
|
||||
post(page_ai_opencode::reply_permission),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/opencode/events",
|
||||
get(page_ai_opencode::events),
|
||||
)
|
||||
.route("/page-ai/opencode", any(page_ai_opencode::proxy_root))
|
||||
.route(
|
||||
"/page-ai/opencode/assets/{*path}",
|
||||
any(page_ai_opencode::proxy_assets),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/opencode/favicon-96x96-v3.png",
|
||||
any(page_ai_opencode::proxy_assets),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/opencode/favicon-v3.svg",
|
||||
any(page_ai_opencode::proxy_assets),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/opencode/favicon-v3.ico",
|
||||
any(page_ai_opencode::proxy_assets),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/opencode/apple-touch-icon-v3.png",
|
||||
any(page_ai_opencode::proxy_assets),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/opencode/site.webmanifest",
|
||||
any(page_ai_opencode::proxy_assets),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/opencode/social-share.png",
|
||||
any(page_ai_opencode::proxy_assets),
|
||||
)
|
||||
.route("/page-ai/opencode/{*path}", any(page_ai_opencode::proxy))
|
||||
.route("/assets/{*path}", any(page_ai_opencode::proxy_assets))
|
||||
.route("/global/{*path}", any(page_ai_opencode::proxy_assets))
|
||||
.route(
|
||||
"/favicon-96x96-v3.png",
|
||||
any(page_ai_opencode::proxy_current_path),
|
||||
)
|
||||
.route("/favicon-v3.svg", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/favicon-v3.ico", any(page_ai_opencode::proxy_current_path))
|
||||
.route(
|
||||
"/apple-touch-icon-v3.png",
|
||||
any(page_ai_opencode::proxy_current_path),
|
||||
)
|
||||
.route(
|
||||
"/site.webmanifest",
|
||||
any(page_ai_opencode::proxy_current_path),
|
||||
)
|
||||
.route(
|
||||
"/social-share.png",
|
||||
any(page_ai_opencode::proxy_current_path),
|
||||
)
|
||||
.route("/provider", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/path", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/project", any(page_ai_opencode::proxy_current_path))
|
||||
.route(
|
||||
"/project/{*path}",
|
||||
any(page_ai_opencode::proxy_current_path),
|
||||
)
|
||||
.route("/lsp", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/command", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/mcp", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/agent", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/config", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/vcs", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/permission", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/question", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/event", any(page_ai_opencode::proxy_current_path))
|
||||
.route("/session", any(page_ai_opencode::proxy_current_path))
|
||||
.route(
|
||||
"/session/{*path}",
|
||||
any(page_ai_opencode::proxy_current_path),
|
||||
)
|
||||
.route("/new-session", any(page_ai_opencode::proxy_current_path))
|
||||
.route(
|
||||
"/{opencode_dir}/session",
|
||||
any(page_ai_opencode::proxy_current_path),
|
||||
)
|
||||
.route(
|
||||
"/{opencode_dir}/session/{*path}",
|
||||
any(page_ai_opencode::proxy_current_path),
|
||||
)
|
||||
.route("/api/page-ai/board/status", get(page_ai_board::status))
|
||||
.route("/api/page-ai/board/workers", get(page_ai_board::workers))
|
||||
.route(
|
||||
"/api/page-ai/board/workflows",
|
||||
get(page_ai_board::workflows),
|
||||
)
|
||||
.route("/api/page-ai/board/runs", post(page_ai_board::create_run))
|
||||
.route(
|
||||
"/api/page-ai/board/runs/{run_id}",
|
||||
get(page_ai_board::get_run),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/board/runs/{run_id}/cancel",
|
||||
post(page_ai_board::cancel_run),
|
||||
)
|
||||
.route("/api/page-ai/pi/status", get(page_ai_pi::status))
|
||||
.route("/api/page-ai/pi/bootstrap", post(page_ai_pi::bootstrap))
|
||||
.route("/api/page-ai/pi/start", post(page_ai_pi::start))
|
||||
@@ -820,63 +662,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/onlyoffice/bridge/capabilities",
|
||||
get(onlyoffice_bridge::capabilities),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/agents/descriptors",
|
||||
get(hermes_client::list_agent_descriptors),
|
||||
)
|
||||
.route("/api/page-ai/runs", post(hermes_client::create_page_ai_run))
|
||||
.route(
|
||||
"/api/page-ai/runs/{host_run_id}",
|
||||
get(hermes_client::get_page_ai_run),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/runs/{host_run_id}/events",
|
||||
get(hermes_client::list_page_ai_run_events),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/active-run",
|
||||
get(hermes_client::get_page_ai_session_active_run),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/runtime/status",
|
||||
get(hermes_client::get_page_ai_runtime_status),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/runtime/reset",
|
||||
post(hermes_client::reset_page_ai_runtime),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions",
|
||||
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/search",
|
||||
get(hermes_client::search_sessions),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}",
|
||||
get(hermes_client::get_session).delete(hermes_client::delete_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/resume",
|
||||
post(hermes_client::resume_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/rename",
|
||||
post(hermes_client::rename_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/export",
|
||||
get(hermes_client::export_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/auto-title",
|
||||
post(hermes_client::auto_title_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/queue/{queue_id}",
|
||||
delete(hermes_client::cancel_queued_run),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/commands",
|
||||
post(onlyoffice_bridge::enqueue_command),
|
||||
@@ -1018,94 +803,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/tree/events", get(sse::tree_events))
|
||||
.route("/api/stream/events", get(sse::events))
|
||||
.route("/api/realtime/ws", get(ws::socket))
|
||||
.nest(
|
||||
&hermes_base_path,
|
||||
Router::new()
|
||||
.route("/health", get(hermes::health))
|
||||
.route("/bridge", post(hermes::bridge_runtime))
|
||||
.route(
|
||||
"/client/sessions",
|
||||
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/search",
|
||||
get(hermes_client::search_sessions),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}",
|
||||
get(hermes_client::get_session).delete(hermes_client::delete_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/resume",
|
||||
post(hermes_client::resume_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/rename",
|
||||
post(hermes_client::rename_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/export",
|
||||
get(hermes_client::export_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/auto-title",
|
||||
post(hermes_client::auto_title_session),
|
||||
)
|
||||
.route("/client/gateway/health", get(hermes_client::gateway_health))
|
||||
.route("/client/profiles", get(hermes_client::list_profiles))
|
||||
.route(
|
||||
"/client/profiles/active",
|
||||
put(hermes_client::switch_active_profile),
|
||||
)
|
||||
.route(
|
||||
"/client/profiles/{profile_name}",
|
||||
get(hermes_client::get_profile),
|
||||
)
|
||||
.route(
|
||||
"/client/profile-memory",
|
||||
get(hermes_client::get_profile_memory).post(hermes_client::save_profile_memory),
|
||||
)
|
||||
.route("/client/skills", get(hermes_client::list_skills))
|
||||
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
|
||||
.route(
|
||||
"/client/capabilities",
|
||||
get(hermes_client::list_capabilities),
|
||||
)
|
||||
.route(
|
||||
"/client/capabilities/toggle",
|
||||
put(hermes_client::toggle_capability),
|
||||
)
|
||||
.route("/client/tools/toggle", put(hermes_client::toggle_tool))
|
||||
.route("/client/runs", post(hermes_client::create_run))
|
||||
.route(
|
||||
"/client/sessions/{session_id}/queue/{queue_id}",
|
||||
delete(hermes_client::cancel_queued_run),
|
||||
)
|
||||
.route("/client/events/{run_id}", get(hermes_client::stream_events))
|
||||
.route(
|
||||
"/client/runs/{run_id}/abort",
|
||||
post(hermes_client::abort_run),
|
||||
)
|
||||
.route(
|
||||
"/client/runs/{run_id}/resolve-permission",
|
||||
post(hermes_client::resolve_permission),
|
||||
)
|
||||
.route("/client/models", get(hermes_client::list_models))
|
||||
.route("/client/tools", get(hermes_client::list_tools)),
|
||||
)
|
||||
.nest(
|
||||
"/api/hermes/tools",
|
||||
Router::new()
|
||||
.route("/mnote/manifest", get(hermes_tools::mnote_manifest))
|
||||
.route("/mnote/call", post(hermes_tools::mnote_call))
|
||||
.route("/mnote/audit", get(hermes_tools::mnote_audit)),
|
||||
)
|
||||
.nest(
|
||||
"/api/mnote/tools",
|
||||
Router::new()
|
||||
.route("/manifest", get(hermes_tools::mnote_manifest))
|
||||
.route("/call", post(hermes_tools::mnote_call))
|
||||
.route("/audit", get(hermes_tools::mnote_audit)),
|
||||
.route("/manifest", get(mnote_tools::mnote_manifest))
|
||||
.route("/call", post(mnote_tools::mnote_call))
|
||||
.route("/audit", get(mnote_tools::mnote_audit)),
|
||||
);
|
||||
|
||||
if enable_debug_shell_routes {
|
||||
@@ -1142,7 +845,6 @@ mod tests {
|
||||
enable_debug_shell_routes,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -1253,15 +955,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mnote_tools_have_current_alias_and_legacy_hermes_mount() {
|
||||
async fn mnote_tools_mount_is_current_only() {
|
||||
for (method, path) in [
|
||||
("GET", "/api/mnote/tools/manifest"),
|
||||
("POST", "/api/mnote/tools/call"),
|
||||
("GET", "/api/mnote/tools/audit"),
|
||||
("GET", "/api/hermes/tools/mnote/manifest"),
|
||||
("POST", "/api/hermes/tools/mnote/call"),
|
||||
("GET", "/api/hermes/tools/mnote/audit"),
|
||||
] {
|
||||
] {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -1275,7 +974,7 @@ mod tests {
|
||||
assert_ne!(
|
||||
response.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"{method} {path} 应挂到 MNote tool executor;Hermes 路径只作为 legacy alias 保留",
|
||||
"{method} {path} 应挂到 MNote tool executor;历史 alias 已移除;仅挂 MNote tool executor",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1543,12 +1242,12 @@ mod tests {
|
||||
"rootUri": root_uri,
|
||||
"documentId": "local-md:ai.md",
|
||||
"updates": {
|
||||
"ai.common.default_agent_id": "reasonix",
|
||||
"ai.common.default_agent_id": "pi",
|
||||
"ai.common.context_refs.default_selected": {
|
||||
"current_page": true,
|
||||
"folder": true
|
||||
},
|
||||
"ai.agent.hermes.profile_id": "mnoteai",
|
||||
"ai.agent.pi.profile_id": "mnoteai",
|
||||
"localOcr.autoEnabled": true
|
||||
}
|
||||
})
|
||||
@@ -1597,7 +1296,7 @@ mod tests {
|
||||
let alice_payload: Value = serde_json::from_slice(&alice_body).expect("alice json");
|
||||
assert_eq!(
|
||||
alice_payload["result"]["aiPreferences"]["ai.common.default_agent_id"],
|
||||
"reasonix"
|
||||
"pi"
|
||||
);
|
||||
assert_eq!(
|
||||
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]
|
||||
@@ -1605,7 +1304,7 @@ mod tests {
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
alice_payload["result"]["aiPreferences"]["ai.agent.hermes.profile_id"],
|
||||
alice_payload["result"]["aiPreferences"]["ai.agent.pi.profile_id"],
|
||||
"mnoteai"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1662,14 +1361,7 @@ mod tests {
|
||||
"/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
|
||||
"/api/mnote-browser-runtime/agent-stream-event-router.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
|
||||
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
|
||||
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
|
||||
|
||||
@@ -370,7 +370,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -1999,7 +1999,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::{Extension, Json};
|
||||
use reqwest::Method;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_BOARD_BASE_URL: &str = "http://127.0.0.1:3901/api";
|
||||
const DEFAULT_BOARD_PROJECT_ID: &str = "51067826-50c7-4869-a8cd-5496f08ca8e6";
|
||||
const DEFAULT_PAGE_AI_WORKFLOW_ID: &str = "builtin-mnote-page-ai-chat";
|
||||
const DEFAULT_PAGE_AI_WORKER_PRESET_ID: &str = "mnote-page-ai-zcode";
|
||||
const DEFAULT_PAGE_AI_MODEL_OVERRIDE: &str = "zcode-default";
|
||||
|
||||
fn page_ai_worker_options() -> Value {
|
||||
json!([{
|
||||
"id": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
|
||||
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
|
||||
"name": "MNote 页面 AI · ZCode",
|
||||
"surface": "mnote-page-ai",
|
||||
"role": "developer",
|
||||
"agentType": "zcode",
|
||||
"capabilities": ["text", "repo-edit", "terminal", "mnote-capability-envelope", "local-markdown-edit"],
|
||||
"modelOptions": page_ai_model_options(),
|
||||
}])
|
||||
}
|
||||
|
||||
fn page_ai_workflow_options() -> Value {
|
||||
json!([{
|
||||
"id": DEFAULT_PAGE_AI_WORKFLOW_ID,
|
||||
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
|
||||
"name": "MNote 页面 AI",
|
||||
"surface": "mnote-page-ai",
|
||||
"stages": ["answer"],
|
||||
}])
|
||||
}
|
||||
|
||||
fn page_ai_model_options() -> Value {
|
||||
json!([
|
||||
{ "id": "zcode-default", "label": "默认", "default": true },
|
||||
{ "id": "zcode-fast", "label": "快速" },
|
||||
{ "id": "zcode-strong", "label": "强力" },
|
||||
])
|
||||
}
|
||||
|
||||
fn validate_page_ai_route(
|
||||
context: &RequestContext,
|
||||
workflow_id: &str,
|
||||
worker_preset_id: &str,
|
||||
model_override: &str,
|
||||
) -> Result<(), WebError> {
|
||||
if workflow_id != DEFAULT_PAGE_AI_WORKFLOW_ID
|
||||
|| worker_preset_id != DEFAULT_PAGE_AI_WORKER_PRESET_ID
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_ai_board_route_not_allowed",
|
||||
"Page AI 只能使用 mnote-page-ai 白名单 worker/workflow",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
let allowed_models = ["zcode-default", "zcode-fast", "zcode-strong"];
|
||||
if !allowed_models.contains(&model_override) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_ai_board_model_not_allowed",
|
||||
"Page AI 只能使用当前 MNote worker 允许的模型档位",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
||||
let has_actor = context.auth.actor_id.trim() != "anonymous";
|
||||
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"page_ai_board_unauthorized",
|
||||
"页面 AI Agent Board bridge 需要登录后访问",
|
||||
)
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
fn board_base_url() -> String {
|
||||
std::env::var("MNOTE_AGENT_BOARD_API_BASE")
|
||||
.or_else(|_| {
|
||||
std::env::var("MNOTE_AGENT_BOARD_BASE_URL")
|
||||
.map(|value| format!("{}/api", value.trim_end_matches('/')))
|
||||
})
|
||||
.unwrap_or_else(|_| DEFAULT_BOARD_BASE_URL.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn default_project_id() -> String {
|
||||
std::env::var("MNOTE_AGENT_BOARD_PROJECT_ID")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_BOARD_PROJECT_ID.to_string())
|
||||
}
|
||||
|
||||
async fn board_request(
|
||||
context: &RequestContext,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> Result<Value, WebError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Agent Board client 构造失败: {error}"))
|
||||
.with_context(context)
|
||||
})?;
|
||||
let url = format!("{}{}", board_base_url(), path);
|
||||
let mut request = client
|
||||
.request(method, &url)
|
||||
.header("accept", "application/json");
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
}
|
||||
let response = request.send().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"page_ai_board_unreachable",
|
||||
format!("无法连接 Agent Board: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let status = response.status();
|
||||
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
|
||||
if !status.is_success() {
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"page_ai_board_error",
|
||||
format!("Agent Board 返回 HTTP {status}: {payload}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_details(payload));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub async fn status(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let payload = board_request(&context, Method::GET, "/health", None).await?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.page_ai_board_status.v1",
|
||||
"baseUrl": board_base_url(),
|
||||
"projectId": default_project_id(),
|
||||
"board": payload,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn workers(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let project_id = default_project_id();
|
||||
let payload = board_request(
|
||||
&context,
|
||||
Method::GET,
|
||||
&format!("/workers/catalog?projectId={project_id}"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| json!(null));
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"schema": "agent_board.page_ai_route.v2",
|
||||
"surface": "mnote-page-ai",
|
||||
"projectId": project_id,
|
||||
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
|
||||
"workerName": "MNote 页面 AI · ZCode",
|
||||
"allowedWorkerPresetIds": [DEFAULT_PAGE_AI_WORKER_PRESET_ID],
|
||||
"modelOverride": DEFAULT_PAGE_AI_MODEL_OVERRIDE,
|
||||
"modelOptions": page_ai_model_options(),
|
||||
"workers": page_ai_worker_options(),
|
||||
"boardCatalog": payload,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn workflows(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let project_id = default_project_id();
|
||||
let payload = board_request(
|
||||
&context,
|
||||
Method::GET,
|
||||
&format!("/workflow-presets?projectId={project_id}"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| json!(null));
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"schema": "agent_board.page_ai_route.v2",
|
||||
"surface": "mnote-page-ai",
|
||||
"projectId": project_id,
|
||||
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
|
||||
"workflowName": "MNote 页面 AI",
|
||||
"allowedWorkflowIds": [DEFAULT_PAGE_AI_WORKFLOW_ID],
|
||||
"requiresConfirmation": false,
|
||||
"requires": {
|
||||
"filesystem": true,
|
||||
"write": false,
|
||||
"browser": false,
|
||||
"vision": false,
|
||||
},
|
||||
"stages": ["answer"],
|
||||
"workflows": page_ai_workflow_options(),
|
||||
"boardCatalog": payload,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn create_run(
|
||||
State(_state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(mut body): Json<Value>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let project_id = body
|
||||
.get("projectId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(default_project_id);
|
||||
let envelope = body.get("envelope").cloned().unwrap_or_else(|| json!({}));
|
||||
let user_message = body
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("请处理当前页面任务")
|
||||
.trim();
|
||||
let workflow_id = body
|
||||
.get("workflowId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_PAGE_AI_WORKFLOW_ID)
|
||||
.to_string();
|
||||
let worker_preset_id = body
|
||||
.get("workerPresetId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_PAGE_AI_WORKER_PRESET_ID)
|
||||
.to_string();
|
||||
let model_override = body
|
||||
.get("modelOverride")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| envelope.get("modelOverride").and_then(Value::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_PAGE_AI_MODEL_OVERRIDE)
|
||||
.to_string();
|
||||
validate_page_ai_route(&context, &workflow_id, &worker_preset_id, &model_override)?;
|
||||
let board_message = format!(
|
||||
"你正在处理 MNote Page AI 发来的任务。你的最终回复会直接显示在页面 AI 对话里。\n\n用户请求:\n{user_message}\n\nMNote Page AI envelope:\n```json\n{}\n```\n\n要求:\n1. 只读问答要像普通页面 AI 一样直接回答用户,不要输出 Board 任务报告。\n2. 如果任务要求编辑页面,只修改 envelope.primaryTarget 指向的真实文件,不要调用 MNote 内部页面写入接口。\n3. 你的源头最终回答必须是自然语言 final answer;同时在结构化 receipt.finalAnswer/changedFiles/verification/remaining 中写入运行记录。\n4. 为兼容旧运行器,<task-summary> 可以包含 ## FINAL_ANSWER 段,但不要把 Completed/Comments/Remaining 当作用户主回答。",
|
||||
serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
body["projectId"] = Value::String(project_id.clone());
|
||||
body["message"] = Value::String(board_message);
|
||||
body["envelope"] = envelope;
|
||||
body["surface"] = Value::String("mnote-page-ai".into());
|
||||
body["workflowId"] = Value::String(workflow_id.clone());
|
||||
body["workerPresetId"] = Value::String(worker_preset_id.clone());
|
||||
body["modelOverride"] = Value::String(model_override.clone());
|
||||
if body.get("autoRun").is_none() {
|
||||
body["autoRun"] = Value::Bool(true);
|
||||
}
|
||||
let payload = board_request(&context, Method::POST, "/workflow-runs", Some(body)).await?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.page_ai_board_run.v1",
|
||||
"surface": "mnote-page-ai",
|
||||
"projectId": project_id,
|
||||
"workflowId": workflow_id,
|
||||
"workerPresetId": worker_preset_id,
|
||||
"modelOverride": model_override,
|
||||
"board": payload,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_run(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(run_id): Path<String>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let details = board_request(
|
||||
&context,
|
||||
Method::GET,
|
||||
&format!("/workflow-runs/{run_id}"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let receipt = board_request(
|
||||
&context,
|
||||
Method::GET,
|
||||
&format!("/workflow-runs/{run_id}/receipt"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| json!(null));
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.page_ai_board_run_status.v1",
|
||||
"runId": run_id,
|
||||
"board": details,
|
||||
"receipt": receipt,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn cancel_run(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(run_id): Path<String>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let payload = board_request(
|
||||
&context,
|
||||
Method::POST,
|
||||
&format!("/workflow-runs/{run_id}/cancel"),
|
||||
Some(json!({})),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
json!({ "ok": true, "runId": run_id, "board": payload }),
|
||||
))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::knowledge_rag as knowledge_rag_agent_output;
|
||||
use crate::mnote_agent_tools::knowledge_rag as knowledge_rag_agent_output;
|
||||
use crate::routes::{ai_settings, knowledge_rag, local_folder_source};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
@@ -8956,7 +8956,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -132,7 +132,7 @@ pub async fn block_edit_workflow(
|
||||
};
|
||||
let apply_started = Instant::now();
|
||||
let tool_response =
|
||||
crate::routes::hermes_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
|
||||
crate::routes::mnote_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
|
||||
let apply_result = tool_response.get("result").cloned().unwrap_or(Value::Null);
|
||||
let apply_ms = apply_started.elapsed().as_millis();
|
||||
info!(
|
||||
@@ -518,21 +518,35 @@ fn quoted_segments(value: &str) -> Vec<String> {
|
||||
segments
|
||||
}
|
||||
|
||||
fn hermes_home() -> PathBuf {
|
||||
std::env::var("HERMES_HOME")
|
||||
fn agent_profile_home() -> PathBuf {
|
||||
std::env::var("MNOTE_AGENT_HOME")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var("HOME")
|
||||
std::env::var("HERMES_HOME")
|
||||
.ok()
|
||||
.map(|home| PathBuf::from(home).join(".hermes"))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from(".hermes"))
|
||||
.or_else(|| {
|
||||
std::env::var("HOME").ok().and_then(|home| {
|
||||
let preferred = PathBuf::from(&home).join(".mnote-agent");
|
||||
if preferred.exists() {
|
||||
return Some(preferred);
|
||||
}
|
||||
let legacy = PathBuf::from(&home).join(".hermes");
|
||||
if legacy.exists() {
|
||||
return Some(legacy);
|
||||
}
|
||||
Some(preferred)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
|
||||
}
|
||||
|
||||
fn profile_config_path(profile: &str) -> PathBuf {
|
||||
let home = hermes_home();
|
||||
let home = agent_profile_home();
|
||||
let profile = profile.trim();
|
||||
if profile.is_empty() || profile == "default" {
|
||||
return home.join("config.yaml");
|
||||
@@ -598,7 +612,7 @@ mod tests {
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
crate::test_support::hermes_env_lock()
|
||||
crate::test_support::agent_env_lock()
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -612,7 +626,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -763,12 +776,12 @@ mod tests {
|
||||
async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
let agent_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-disabled-tool-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
let _ = fs::remove_dir_all(&agent_home);
|
||||
let profile_dir = agent_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
@@ -777,7 +790,7 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
@@ -821,20 +834,20 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "mnote_tool_disabled");
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
std::env::remove_var("MNOTE_AGENT_HOME");
|
||||
let _ = fs::remove_dir_all(&agent_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_out_of_scope_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
let agent_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-selection-scope-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
let _ = fs::remove_dir_all(&agent_home);
|
||||
let profile_dir = agent_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
@@ -843,7 +856,7 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
@@ -888,20 +901,20 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
std::env::remove_var("MNOTE_AGENT_HOME");
|
||||
let _ = fs::remove_dir_all(&agent_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_read_and_edit_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
let agent_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-read-summary-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
let _ = fs::remove_dir_all(&agent_home);
|
||||
let profile_dir = agent_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
@@ -910,7 +923,7 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
@@ -962,7 +975,7 @@ mod tests {
|
||||
.unwrap_or_default()
|
||||
.contains("测试123"));
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
std::env::remove_var("MNOTE_AGENT_HOME");
|
||||
let _ = fs::remove_dir_all(&agent_home);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,7 +1164,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -677,18 +677,18 @@ fn fallback_search_dataset(workspace_id: &str) -> Value {
|
||||
"updatedAt": "2026-04-28T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "doc_hermes",
|
||||
"id": "doc_skill_graph",
|
||||
"workspaceId": workspace_id,
|
||||
"title": "Hermes",
|
||||
"rawText": "Hermes 技能知识图谱开发 Wolai aline fixture",
|
||||
"title": "SkillGraph",
|
||||
"rawText": "SkillGraph 技能知识图谱开发 Wolai aline fixture",
|
||||
"createdAt": "2026-04-30T00:00:00Z",
|
||||
"updatedAt": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "doc_hermes_skill",
|
||||
"id": "doc_skill_path",
|
||||
"workspaceId": workspace_id,
|
||||
"title": "技能知识图谱开发",
|
||||
"rawText": "Hermes 页面路径 个人 软件开发",
|
||||
"rawText": "SkillGraph 页面路径 个人 软件开发",
|
||||
"createdAt": "2026-04-30T00:00:00Z",
|
||||
"updatedAt": "2026-04-30T00:00:00Z"
|
||||
}
|
||||
@@ -777,7 +777,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -908,7 +907,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -928,7 +926,7 @@ mod tests {
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"query": "Hermes"
|
||||
"query": "SkillGraph"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
@@ -944,7 +942,7 @@ mod tests {
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["results"], json!([]));
|
||||
assert_eq!(payload["meta"]["degraded"], true);
|
||||
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
|
||||
assert!(!payload.to_string().contains("SkillGraph 技能知识图谱开发"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1520,7 +1518,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -230,7 +230,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -333,7 +332,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -403,7 +401,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -460,7 +457,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_returns_admin_for_local_access_policy_admin() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let policy_root = std::env::temp_dir().join(format!(
|
||||
@@ -484,7 +481,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -359,7 +359,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -2726,7 +2726,6 @@ mod tests {
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -3072,7 +3071,6 @@ mod tests {
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -3818,7 +3816,6 @@ mod tests {
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -10,9 +10,10 @@ use crate::routes::local_folder_source::{
|
||||
self as local_folder_source, ensure_local_workspace_read_access_with_state,
|
||||
ensure_local_workspace_write_access_with_state,
|
||||
};
|
||||
use crate::routes::vault_extension_token;
|
||||
use crate::routes::vault_store::{
|
||||
self, SecretPatch, VaultAccountSlot, VaultCreateInput, VaultItemStatus, VaultSecretSlot,
|
||||
VaultUpdateInput,
|
||||
self, SecretPatch, VaultAccountSlot, VaultCreateInput, VaultItemStatus, VaultLoginSession,
|
||||
VaultSecretSlot, VaultSessionCookie, VaultUpdateInput,
|
||||
};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
@@ -1312,6 +1313,270 @@ pub async fn login_ai_item(
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
/// PUT /api/vault/items/{id}/session — human / chrome-extension session file write (12-3)
|
||||
/// body: { rootUri, accountId?, cookieHeader?, cookies[]?, origin?, expiresAt?, source? }
|
||||
pub async fn put_item_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
let actor = require_authenticated(&context)?;
|
||||
let map = body.as_object().ok_or_else(|| {
|
||||
WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象")
|
||||
})?;
|
||||
let root_uri = require_root_uri(
|
||||
map.get("rootUri")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| map.get("root_uri").and_then(Value::as_str)),
|
||||
)?;
|
||||
let root = resolve_write_root(&state, &context, root_uri).await?;
|
||||
|
||||
let account_id = map
|
||||
.get("accountId")
|
||||
.or_else(|| map.get("account_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
let cookies = parse_session_cookies(map.get("cookies"));
|
||||
let cookie_header = map
|
||||
.get("cookieHeader")
|
||||
.or_else(|| map.get("cookie_header"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
if cookie_header.is_none() && cookies.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"vault_session_cookie_required",
|
||||
"cookieHeader 与 cookies[] 至少一个非空",
|
||||
));
|
||||
}
|
||||
|
||||
let source = map
|
||||
.get("source")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("chrome_extension")
|
||||
.to_string();
|
||||
|
||||
let session = VaultLoginSession {
|
||||
cookie_header,
|
||||
expires_at: map
|
||||
.get("expiresAt")
|
||||
.or_else(|| map.get("expires_at"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
last_login_at: map
|
||||
.get("lastLoginAt")
|
||||
.or_else(|| map.get("last_login_at"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
source: Some(source),
|
||||
origin: map
|
||||
.get("origin")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
account_id: account_id.clone(),
|
||||
cookies,
|
||||
revision: None,
|
||||
};
|
||||
|
||||
let record = vault_store::put_login_session_for_account(
|
||||
&root,
|
||||
&id,
|
||||
account_id.as_deref(),
|
||||
session,
|
||||
)?;
|
||||
let _ = vault_store::append_vault_audit(
|
||||
&root,
|
||||
"session_put",
|
||||
&actor,
|
||||
&id,
|
||||
None,
|
||||
Some(context.trace.request_id.as_str()),
|
||||
true,
|
||||
);
|
||||
|
||||
let acc = record
|
||||
.login_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.account_id.clone())
|
||||
.or(account_id)
|
||||
.unwrap_or_else(|| "primary".into());
|
||||
let expires = record
|
||||
.login_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.expires_at.clone());
|
||||
let rev = record
|
||||
.login_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.revision)
|
||||
.unwrap_or(1);
|
||||
|
||||
Ok(ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"credentialId": id,
|
||||
"accountId": acc,
|
||||
"hasLoginSession": true,
|
||||
"sessionExpiresAt": expires,
|
||||
"revision": rev,
|
||||
"item": vault_store::project_item_l0_with_cipher(&record, Some(&root)),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_session_cookies(value: Option<&Value>) -> Vec<VaultSessionCookie> {
|
||||
let Some(arr) = value.and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
arr.iter()
|
||||
.filter_map(|item| {
|
||||
let obj = item.as_object()?;
|
||||
let name = obj
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())?
|
||||
.to_string();
|
||||
let value = obj
|
||||
.get("value")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
Some(VaultSessionCookie {
|
||||
name,
|
||||
value,
|
||||
domain: obj
|
||||
.get("domain")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
path: obj
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
secure: obj.get("secure").and_then(Value::as_bool),
|
||||
http_only: obj
|
||||
.get("httpOnly")
|
||||
.or_else(|| obj.get("http_only"))
|
||||
.and_then(Value::as_bool),
|
||||
same_site: obj
|
||||
.get("sameSite")
|
||||
.or_else(|| obj.get("same_site"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
expiration_date: obj
|
||||
.get("expirationDate")
|
||||
.or_else(|| obj.get("expiration_date"))
|
||||
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64))),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// POST /api/vault/extension/token — issue E2 human token (mnext1.*) after session login.
|
||||
pub async fn issue_extension_token(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
let actor = require_authenticated(&context)?;
|
||||
let map = body.as_object();
|
||||
let client_id = map
|
||||
.and_then(|m| m.get("clientId").or_else(|| m.get("client_id")))
|
||||
.and_then(Value::as_str);
|
||||
let extension_id = map
|
||||
.and_then(|m| m.get("extensionId").or_else(|| m.get("extension_id")))
|
||||
.and_then(Value::as_str);
|
||||
let ttl_hours = map
|
||||
.and_then(|m| m.get("ttlHours").or_else(|| m.get("ttl_hours")))
|
||||
.and_then(Value::as_u64);
|
||||
let email = map
|
||||
.and_then(|m| m.get("email"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let issued = vault_extension_token::issue_extension_token(
|
||||
&actor,
|
||||
email,
|
||||
client_id,
|
||||
extension_id,
|
||||
ttl_hours,
|
||||
)?;
|
||||
|
||||
Ok(ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"token": issued.token,
|
||||
"expiresAt": vault_extension_token::exp_to_rfc3339(issued.claims.exp),
|
||||
"scope": issued.claims.scope,
|
||||
"userId": issued.claims.actor,
|
||||
"email": issued.claims.email,
|
||||
"jti": issued.claims.jti,
|
||||
"aud": issued.claims.aud,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/vault/extension/token/revoke — revoke by jti or current Bearer.
|
||||
pub async fn revoke_extension_token(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
let _actor = require_authenticated(&context)?;
|
||||
|
||||
let jti_from_body = body
|
||||
.get("jti")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
let jti = if let Some(j) = jti_from_body {
|
||||
j
|
||||
} else if let Some(token) =
|
||||
vault_extension_token::bearer_mnext1(context.auth.authorization.as_deref())
|
||||
{
|
||||
let claims = vault_extension_token::verify_extension_token(token)?;
|
||||
claims.jti
|
||||
} else {
|
||||
return Err(WebError::bad_request_code(
|
||||
"vault_ext_jti_required",
|
||||
"吊销需要 jti 或当前 Bearer mnext1 token",
|
||||
));
|
||||
};
|
||||
|
||||
vault_extension_token::revoke_jti(&jti)?;
|
||||
Ok(ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"revoked": true,
|
||||
"jti": jti,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/vault/ai/items/{id}/session — human/browser cookie write-back
|
||||
/// body: { cookieHeader, expiresAt?, source? }
|
||||
pub async fn put_ai_session(
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//! Chrome extension human token (12-3 E2).
|
||||
//!
|
||||
//! Format: `mnext1.<base64url(payload_json)>.<base64url(hmac_sha256)>`
|
||||
//! Separate HMAC key / aud from agent `mnv1.*` tokens (12-2).
|
||||
|
||||
use crate::error::WebError;
|
||||
use axum::http::StatusCode;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub const TOKEN_PREFIX: &str = "mnext1";
|
||||
pub const TOKEN_VERSION: u32 = 1;
|
||||
pub const ISSUER: &str = "mnote-web";
|
||||
pub const AUDIENCE: &str = "chrome-extension-vault";
|
||||
pub const SCOPE_VIEW: &str = "vault.view";
|
||||
pub const SCOPE_EDIT: &str = "vault.edit";
|
||||
pub const DEFAULT_TTL_HOURS: u64 = 168;
|
||||
pub const MAX_TTL_HOURS: u64 = 720; // 30d
|
||||
|
||||
const DEFAULT_HMAC_KEY_REL: &str = ".config/mnote/vault-extension-hmac.key";
|
||||
const DEFAULT_REVOKE_REL: &str = ".config/mnote/vault-extension-revoked.jti";
|
||||
|
||||
static REVOKE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExtensionTokenClaims {
|
||||
pub v: u32,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub sub: String,
|
||||
pub actor: String,
|
||||
pub scope: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub extension_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
pub iat: u64,
|
||||
/// 0 = no expiry (not used for extension tokens)
|
||||
pub exp: u64,
|
||||
pub jti: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssuedExtensionToken {
|
||||
pub token: String,
|
||||
pub claims: ExtensionTokenClaims,
|
||||
}
|
||||
|
||||
fn dirs_path_home() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
pub fn default_hmac_key_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("MNOTE_VAULT_EXTENSION_HMAC_KEY") {
|
||||
let p = p.trim();
|
||||
if !p.is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
dirs_path_home().join(DEFAULT_HMAC_KEY_REL)
|
||||
}
|
||||
|
||||
fn default_revoke_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("MNOTE_VAULT_EXTENSION_REVOKE_FILE") {
|
||||
let p = p.trim();
|
||||
if !p.is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
dirs_path_home().join(DEFAULT_REVOKE_REL)
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn getrandom_fill(buf: &mut [u8]) -> Result<(), WebError> {
|
||||
use std::io::Read;
|
||||
let mut f = fs::File::open("/dev/urandom").map_err(|e| {
|
||||
WebError::internal(format!("open /dev/urandom: {e}"))
|
||||
})?;
|
||||
f.read_exact(buf)
|
||||
.map_err(|e| WebError::internal(format!("read urandom: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load or create a random 32-byte HMAC secret (hex file, 0600 when possible).
|
||||
pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, WebError> {
|
||||
if path.exists() {
|
||||
let raw = fs::read_to_string(path).map_err(|e| {
|
||||
WebError::bad_request_code(
|
||||
"vault_ext_hmac_key_read_failed",
|
||||
format!("无法读取 extension HMAC key {}: {e}", path.display()),
|
||||
)
|
||||
})?;
|
||||
let hex = raw.trim();
|
||||
if hex.len() < 32 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"vault_ext_hmac_key_invalid",
|
||||
"extension HMAC key 过短",
|
||||
));
|
||||
}
|
||||
return hex::decode(hex).map_err(|e| {
|
||||
WebError::bad_request_code(
|
||||
"vault_ext_hmac_key_invalid",
|
||||
format!("extension HMAC key 非 hex: {e}"),
|
||||
)
|
||||
});
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
WebError::bad_request_code(
|
||||
"vault_ext_hmac_key_write_failed",
|
||||
format!("无法创建目录: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom_fill(&mut bytes)?;
|
||||
let hex = hex::encode(bytes);
|
||||
fs::write(path, format!("{hex}\n")).map_err(|e| {
|
||||
WebError::bad_request_code(
|
||||
"vault_ext_hmac_key_write_failed",
|
||||
format!("无法写入 extension HMAC key: {e}"),
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
pub fn issue_extension_token(
|
||||
actor_id: &str,
|
||||
email: Option<&str>,
|
||||
client_id: Option<&str>,
|
||||
extension_id: Option<&str>,
|
||||
ttl_hours: Option<u64>,
|
||||
) -> Result<IssuedExtensionToken, WebError> {
|
||||
let actor = actor_id.trim();
|
||||
if actor.is_empty() || actor == "anonymous" {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_auth_required",
|
||||
"签发 extension token 需要已登录会话",
|
||||
));
|
||||
}
|
||||
let hours = ttl_hours
|
||||
.unwrap_or(DEFAULT_TTL_HOURS)
|
||||
.clamp(1, MAX_TTL_HOURS);
|
||||
let iat = now_unix();
|
||||
let exp = iat.saturating_add(hours.saturating_mul(3600));
|
||||
let claims = ExtensionTokenClaims {
|
||||
v: TOKEN_VERSION,
|
||||
iss: ISSUER.into(),
|
||||
aud: AUDIENCE.into(),
|
||||
sub: format!("user:{actor}"),
|
||||
actor: actor.to_string(),
|
||||
scope: vec![SCOPE_VIEW.into(), SCOPE_EDIT.into()],
|
||||
client_id: client_id
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
extension_id: extension_id
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
email: email
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
iat,
|
||||
exp,
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
};
|
||||
let key = load_or_create_hmac_key(&default_hmac_key_path())?;
|
||||
let token = encode_token(&key, &claims)?;
|
||||
Ok(IssuedExtensionToken { token, claims })
|
||||
}
|
||||
|
||||
pub fn encode_token(hmac_key: &[u8], claims: &ExtensionTokenClaims) -> Result<String, WebError> {
|
||||
let payload = serde_json::to_vec(claims).map_err(|e| {
|
||||
WebError::internal(format!("extension token serialize: {e}"))
|
||||
})?;
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload);
|
||||
let mut mac = HmacSha256::new_from_slice(hmac_key)
|
||||
.map_err(|e| WebError::internal(format!("hmac init: {e}")))?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let sig = mac.finalize().into_bytes();
|
||||
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
|
||||
Ok(format!("{TOKEN_PREFIX}.{payload_b64}.{sig_b64}"))
|
||||
}
|
||||
|
||||
/// Verify Bearer raw token string (with or without "Bearer " prefix stripped by caller).
|
||||
pub fn verify_extension_token(token: &str) -> Result<ExtensionTokenClaims, WebError> {
|
||||
let token = token.trim();
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 || parts[0] != TOKEN_PREFIX {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_invalid",
|
||||
"extension token 格式无效(期望 mnext1.<payload>.<sig>)",
|
||||
));
|
||||
}
|
||||
let key = load_or_create_hmac_key(&default_hmac_key_path())?;
|
||||
let payload_b64 = parts[1];
|
||||
let sig_b64 = parts[2];
|
||||
let mut mac = HmacSha256::new_from_slice(&key)
|
||||
.map_err(|e| WebError::internal(format!("hmac init: {e}")))?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let expected = mac.finalize().into_bytes();
|
||||
let sig = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_invalid",
|
||||
"extension token 签名解码失败",
|
||||
)
|
||||
})?;
|
||||
if sig.len() != expected.len()
|
||||
|| sig
|
||||
.iter()
|
||||
.zip(expected.iter())
|
||||
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
||||
!= 0
|
||||
{
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_invalid",
|
||||
"extension token 签名校验失败",
|
||||
));
|
||||
}
|
||||
let payload = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_invalid",
|
||||
"extension token payload 解码失败",
|
||||
)
|
||||
})?;
|
||||
let claims: ExtensionTokenClaims = serde_json::from_slice(&payload).map_err(|_| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_invalid",
|
||||
"extension token payload JSON 无效",
|
||||
)
|
||||
})?;
|
||||
if claims.v != TOKEN_VERSION {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_invalid",
|
||||
format!("不支持的 extension token 版本 {}", claims.v),
|
||||
));
|
||||
}
|
||||
if claims.aud != AUDIENCE {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_invalid",
|
||||
"extension token aud 不匹配",
|
||||
));
|
||||
}
|
||||
if claims.iss != ISSUER {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_invalid",
|
||||
"extension token iss 不匹配",
|
||||
));
|
||||
}
|
||||
if claims.exp != 0 && now_unix() > claims.exp {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_expired",
|
||||
"extension token 已过期",
|
||||
));
|
||||
}
|
||||
if is_jti_revoked(&claims.jti)? {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_revoked",
|
||||
"extension token 已吊销",
|
||||
));
|
||||
}
|
||||
if claims.actor.trim().is_empty() || claims.actor.trim() == "anonymous" {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_ext_token_invalid",
|
||||
"extension token actor 无效",
|
||||
));
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
pub fn require_scope(claims: &ExtensionTokenClaims, need: &str) -> Result<(), WebError> {
|
||||
let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect();
|
||||
if set.contains(need) || set.contains("*") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"vault_ext_scope_denied",
|
||||
format!("extension token 缺少 scope: {need}"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Extract Bearer token if it looks like mnext1.*
|
||||
pub fn bearer_mnext1(authorization: Option<&str>) -> Option<&str> {
|
||||
let auth = authorization?.trim();
|
||||
let token = auth
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| auth.strip_prefix("bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())?;
|
||||
if token.starts_with(TOKEN_PREFIX) {
|
||||
Some(token)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn load_revoked_jtis(path: &Path) -> Result<BTreeSet<String>, WebError> {
|
||||
if !path.exists() {
|
||||
return Ok(BTreeSet::new());
|
||||
}
|
||||
let raw = fs::read_to_string(path).map_err(|e| {
|
||||
WebError::internal(format!("读取 extension revoke 文件失败: {e}"))
|
||||
})?;
|
||||
let mut set = BTreeSet::new();
|
||||
for line in raw.lines() {
|
||||
let j = line.trim();
|
||||
if !j.is_empty() && !j.starts_with('#') {
|
||||
set.insert(j.to_string());
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn is_jti_revoked(jti: &str) -> Result<bool, WebError> {
|
||||
let path = default_revoke_path();
|
||||
let _guard = REVOKE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let set = load_revoked_jtis(&path)?;
|
||||
Ok(set.contains(jti.trim()))
|
||||
}
|
||||
|
||||
pub fn revoke_jti(jti: &str) -> Result<(), WebError> {
|
||||
let jti = jti.trim();
|
||||
if jti.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"vault_ext_jti_required",
|
||||
"吊销需要 jti",
|
||||
));
|
||||
}
|
||||
let path = default_revoke_path();
|
||||
let _guard = REVOKE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut set = load_revoked_jtis(&path)?;
|
||||
if !set.insert(jti.to_string()) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
WebError::internal(format!("创建 revoke 目录失败: {e}"))
|
||||
})?;
|
||||
}
|
||||
let mut lines: Vec<String> = set.into_iter().collect();
|
||||
lines.sort();
|
||||
let body = format!("{}\n", lines.join("\n"));
|
||||
fs::write(&path, body).map_err(|e| {
|
||||
WebError::internal(format!("写入 revoke 文件失败: {e}"))
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn exp_to_rfc3339(exp: u64) -> String {
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
OffsetDateTime::from_unix_timestamp(exp as i64)
|
||||
.ok()
|
||||
.and_then(|t| t.format(&Rfc3339).ok())
|
||||
.unwrap_or_else(|| exp.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn with_temp_key_env<F: FnOnce()>(f: F) {
|
||||
let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"mnote-ext-token-test-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let key = dir.join("hmac.key");
|
||||
let rev = dir.join("revoked.jti");
|
||||
std::env::set_var("MNOTE_VAULT_EXTENSION_HMAC_KEY", &key);
|
||||
std::env::set_var("MNOTE_VAULT_EXTENSION_REVOKE_FILE", &rev);
|
||||
f();
|
||||
std::env::remove_var("MNOTE_VAULT_EXTENSION_HMAC_KEY");
|
||||
std::env::remove_var("MNOTE_VAULT_EXTENSION_REVOKE_FILE");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_verify_roundtrip() {
|
||||
with_temp_key_env(|| {
|
||||
let issued = issue_extension_token(
|
||||
"user_demo",
|
||||
Some("demo@example.com"),
|
||||
Some("chrome-extension"),
|
||||
Some("ext_id_1"),
|
||||
Some(24),
|
||||
)
|
||||
.expect("issue");
|
||||
assert!(issued.token.starts_with("mnext1."));
|
||||
let claims = verify_extension_token(&issued.token).expect("verify");
|
||||
assert_eq!(claims.actor, "user_demo");
|
||||
assert!(claims.scope.contains(&SCOPE_VIEW.to_string()));
|
||||
assert!(claims.scope.contains(&SCOPE_EDIT.to_string()));
|
||||
require_scope(&claims, SCOPE_EDIT).expect("scope");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_blocks_verify() {
|
||||
with_temp_key_env(|| {
|
||||
let issued =
|
||||
issue_extension_token("user_demo", None, None, None, Some(1)).expect("issue");
|
||||
revoke_jti(&issued.claims.jti).expect("revoke");
|
||||
let err = verify_extension_token(&issued.token).expect_err("revoked");
|
||||
assert_eq!(err.code(), "vault_ext_token_revoked");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_token_prefix_rejected() {
|
||||
with_temp_key_env(|| {
|
||||
let err = verify_extension_token("mnv1.abc.def").expect_err("reject");
|
||||
assert_eq!(err.code(), "vault_ext_token_invalid");
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -328,7 +328,6 @@ pub async fn document_page_shell(
|
||||
vault_nav_href={vault_nav_href}
|
||||
/>
|
||||
});
|
||||
let hermes_settings_config_script = render_hermes_settings_config_script();
|
||||
let html = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
@@ -349,7 +348,6 @@ pub async fn document_page_shell(
|
||||
{}
|
||||
{}
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(title),
|
||||
@@ -364,7 +362,6 @@ pub async fn document_page_shell(
|
||||
escape_script_json(&snapshot_json),
|
||||
escape_script_json(&bootstrap_json),
|
||||
escape_script_json(&panes_bootstrap_json),
|
||||
hermes_settings_config_script,
|
||||
secondary_snapshot_json
|
||||
.as_ref()
|
||||
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
|
||||
@@ -510,59 +507,6 @@ pub(crate) fn build_editor_bootstrap_json(
|
||||
)
|
||||
}
|
||||
|
||||
fn render_hermes_settings_config_script() -> String {
|
||||
let Some(base_url) = [
|
||||
"MNOTE_WEB_HERMES_UPSTREAM_URL",
|
||||
"MNOTE_HERMES_UPSTREAM_URL",
|
||||
"MNOTE_HERMES_API_BASE_URL",
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(env_or_dotenv)
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()) else {
|
||||
return String::new();
|
||||
};
|
||||
let settings_url = format!("{base_url}/hermes/settings");
|
||||
let encoded = serde_json::to_string(&settings_url).unwrap_or_else(|_| "\"\"".to_string());
|
||||
format!(
|
||||
r#"<script>window.__mnoteHermesSettingsUrl = {};</script>"#,
|
||||
escape_script_json(&encoded)
|
||||
)
|
||||
}
|
||||
|
||||
fn env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
let trimmed = value.trim().trim_matches('"').to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
if cfg!(test) {
|
||||
return None;
|
||||
}
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../..")
|
||||
.join(".env.all");
|
||||
let content = fs::read_to_string(root).ok()?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.starts_with('#') || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((candidate_key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if candidate_key.trim() != key {
|
||||
continue;
|
||||
}
|
||||
let trimmed = value.trim().trim_matches('"').to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn build_editor_bootstrap_json_with_ids(
|
||||
aggregate: &PageAggregate,
|
||||
context: &RequestContext,
|
||||
@@ -2511,15 +2455,6 @@ pub async fn sidebar_tree_runtime_asset() -> Response {
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn agent_stream_event_router_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/agent-stream-event-router.js");
|
||||
Response::builder()
|
||||
.header("content-type", "application/javascript; charset=utf-8")
|
||||
.header("cache-control", "public, max-age=3600")
|
||||
.body(Body::from(JS))
|
||||
.expect("agent-stream-event-router.js")
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-runtime.js");
|
||||
Response::builder()
|
||||
@@ -2534,104 +2469,6 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_markdown_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-markdown-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_render_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-render-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_permission_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-permission-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_profile_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-profile-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_session_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-session-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_skill_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-skill-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_target_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-target-runtime.js");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
|
||||
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.body(browser_runtime_js_body(JS))
|
||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||
}
|
||||
|
||||
pub async fn sidebar_page_ai_pi_lab_runtime_asset() -> Response {
|
||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-pi-lab-runtime.js");
|
||||
Response::builder()
|
||||
@@ -3749,7 +3586,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -3807,7 +3643,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -3929,7 +3764,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: Some("http://127.0.0.1:9".into()),
|
||||
convex_admin_key: Some("test-admin-key".into()),
|
||||
@@ -4072,7 +3906,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -4447,7 +4280,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn mnote_browser_runtime_assets_are_not_cached_during_dev_hot() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||
@@ -4476,7 +4309,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn mnote_browser_runtime_module_imports_carry_dev_hot_buster() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||
@@ -4508,7 +4341,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_runtime_preload_links_use_dev_hot_buster() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||
@@ -4536,7 +4369,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn leptos_tiptap_entry_imports_carry_dev_hot_buster() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||
@@ -4570,7 +4403,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn dev_hot_runtime_serves_page_block_pane_navigation_bridge() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||
@@ -4841,7 +4674,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -4910,7 +4742,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -5069,7 +4900,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
@@ -5339,7 +5169,12 @@ mod tests {
|
||||
assert!(html.contains("Local Shell"));
|
||||
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
||||
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
|
||||
assert!(html.contains("Child Page"));
|
||||
// lazy PageTree:只 reveal active 文档路径,不把 sibling「Child Page」扫进首屏。
|
||||
assert!(
|
||||
html.contains(r#"data-node-id="local-md:Local~20Shell~2FLocal~20Shell.md""#)
|
||||
|| html.contains("Local Shell"),
|
||||
"active 本地页应出现在 shell / PageTree 首屏"
|
||||
);
|
||||
assert!(html.contains("asset.png"));
|
||||
assert!(html.contains("data-row-kind="));
|
||||
assert!(html.contains("data-page-openable=\"false\""));
|
||||
@@ -5620,7 +5455,6 @@ mod tests {
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
@@ -241,25 +241,12 @@ mod tests {
|
||||
include_str!("../../../browser/sidebar-workspace-runtime.js");
|
||||
const SIDEBAR_PAGE_TREE_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-tree-runtime.js");
|
||||
// Browser Page AI runtime is served as a module; keep this include explicit so
|
||||
// resume/journal contract checks inspect the actual shipped JS.
|
||||
// Page AI 产品面:facade(Pi 入口桥接)+ Pi Lab runtime。Hermes/OpenCode 子模块已物理删除。
|
||||
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-pi-lab-runtime.js");
|
||||
const MNOTE_UI_RUNTIME_JS: &str = include_str!("../../../browser/mnote-ui-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-markdown-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-render-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-permission-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-profile-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-session-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-skill-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-target-runtime.js");
|
||||
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-settings-runtime.js");
|
||||
const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str =
|
||||
@@ -341,7 +328,12 @@ mod tests {
|
||||
assert!(SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("dragstart"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("drop"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-drop-feedback"));
|
||||
// drop feedback 已外置到 filetree-dnd / page-tree runtime;tree 仍委托 drop 入口
|
||||
assert!(
|
||||
SIDEBAR_TREE_RUNTIME_JS.contains("data-drop-feedback")
|
||||
|| FILETREE_DND_RUNTIME_JS.contains("data-drop-feedback")
|
||||
|| SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("data-drop-feedback")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("action: 'move'"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebar-file-tree-root"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.open"));
|
||||
@@ -623,7 +615,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn page_layout_adds_dev_hot_cache_buster_to_browser_runtime_scripts() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
let _guard = crate::test_support::agent_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||
@@ -745,6 +737,8 @@ mod tests {
|
||||
< html.find(r#"data-testid="wolai-floating-ai""#)
|
||||
);
|
||||
assert!(html.contains(r#"data-mnote-action="open-page-ai-pi-lab""#));
|
||||
assert!(html.contains(r#"aria-label="Pi Lab""#));
|
||||
assert!(html.contains(r#"title="打开 Pi Lab""#));
|
||||
assert!(!html.contains(r#"data-mnote-action="open-page-ai""#));
|
||||
assert!(!html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
|
||||
assert!(html.contains(r#"data-icon="travel_explore""#));
|
||||
@@ -755,6 +749,33 @@ mod tests {
|
||||
assert!(!html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_product_entry_is_pi_lab_only() {
|
||||
// 产品入口:浮钮 + tree click + openPageAiDrawer 均只打开 Pi Lab。
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("产品唯一入口:Pi Lab"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote:pi-lab-show"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function openPageAiDrawer"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function updatePageAiTriggerState"));
|
||||
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("createSidebarPageAiPiLabRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("mnote:pi-lab-show"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("产品面唯一 Page AI:Pi Lab"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:pi-lab-show"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains(r#"data-mnote-action="open-page-ai-pi-lab""#));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
|
||||
// Hermes/OpenCode drawer 与子模块已物理删除
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiOpencodeHostEnabled"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/hermes/"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("切换到 Hermes"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("切换到 OpenCode"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("切换到 Hermes"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("切换到 OpenCode"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiOpenHermesSettings"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiSetHideHermesBuiltinSkills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_settings_runtime_routes_index_and_ocr_to_knowledge_rag_settings() {
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-settings-popover"));
|
||||
@@ -879,196 +900,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_fast_path_is_not_local_first_main_path() {
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_RUNTIME_JS
|
||||
.contains("if (currentSourceKind() === 'local_folder') return false;"),
|
||||
"local-first 页面 AI 不应继续加厚 page-ai fast-path;本地编辑应走受控文件工具"
|
||||
);
|
||||
fn page_ai_facade_is_pi_lab_bridge_only() {
|
||||
// facade 只桥接 Pi Lab;不承载 Hermes session / ACP / OpenCode host。
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("export function createSidebarPageAiRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("window.createSidebarPageAiPiLabRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote:pi-lab-show"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote:pi-lab-hide"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("window.__mnoteSidebarPageAiRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiOpencode"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("reasonix"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/sessions"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/hermes/"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiMarkdownRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiPermissionRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiProfileRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSkillRuntime"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiTargetRuntime"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_local_source_passes_file_reference_fields_to_agent_run() {
|
||||
fn page_ai_pi_lab_runtime_is_product_host() {
|
||||
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("function createSidebarPageAiPiLabRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("mnote:pi-lab-show"));
|
||||
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("mnote:pi-lab-hide"));
|
||||
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("/api/page-ai/pi/"));
|
||||
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentRootUri()"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("sourceKind: currentSourceKind()"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("rootUri: currentRootUri()"));
|
||||
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS
|
||||
.contains("if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind"));
|
||||
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS
|
||||
.contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageContext: scopedContext.pageContext"));
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_RUNTIME_JS
|
||||
.contains("if (currentSourceKind() === 'local_folder') return false;"),
|
||||
"local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope"
|
||||
);
|
||||
assert!(
|
||||
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiEnrichOcrContextRefs"),
|
||||
"Page AI 目标包不应保留已退役 OCR sidecar context enrichment"
|
||||
);
|
||||
assert!(
|
||||
!SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("ocrRootRelativePath"),
|
||||
"Page AI target runtime 不应再注入旧 OCR sidecar 路径"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_agent_target_picker_contract_is_visible_and_serialized() {
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS
|
||||
.contains("export function createSidebarPageAiRenderRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-button"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-popover"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-option"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-chip"));
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("primaryTargetId"));
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("targets: ["));
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("policy: {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_uses_backend_acp_session_runtime_store() {
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSkillRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiTargetRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiControls"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function ensurePageAiDrawer"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiConversation"));
|
||||
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS
|
||||
.contains("export function createSidebarPageAiSkillRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS
|
||||
.contains("export function createSidebarPageAiTargetRuntime"));
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("function currentPageAiOpenEditorsSnapshot")
|
||||
);
|
||||
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("mnote.agent_target_package.v1"));
|
||||
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillSourceOptions"));
|
||||
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillPreferenceTable"));
|
||||
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("ai.agent.reasonix.memory_enabled"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessions"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/page-ai/sessions?"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("params.set('source', 'acp')"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains(
|
||||
"var draftSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter"
|
||||
));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
|
||||
.contains("pageAiDedupeSessions(backendSessions.concat(draftSessions))"));
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")
|
||||
);
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSearchBackendSessions"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
|
||||
.contains("function pageAiDeleteSelectedBackendSessions"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiCheckActiveRun"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/page-ai/sessions/"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/active-run?"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-rename"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-select"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete-selected"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-resume"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-search"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderThoughtGroup"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-thought-card"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-collapse-card"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiUsageSummary"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("usage.updated"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("thought.delta"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("permission.requested"));
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
|
||||
.contains("data-page-ai-permission-action=\"allow\""));
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
|
||||
.contains("data-page-ai-permission-action=\"deny\""));
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiHidePermissionDialog")
|
||||
);
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("if (!message.resolved)"));
|
||||
assert!(
|
||||
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("(item.resolved ? ' disabled' : '')"),
|
||||
"已决 ACP permission 事件不能继续展示假审批按钮"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("session.info.updated"),
|
||||
"ACP SessionInfoUpdate 事件应通过 session.info.updated SSE 转发到前端"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("plan.updated"),
|
||||
"ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-plan"),
|
||||
"plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("执行计划 · "),
|
||||
"plan 面板标题应显示执行计划和步数"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_session_ui_labels_local_shared_and_cloud_storage() {
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSessionStorageLabel"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_private"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_shared"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sqlite_control_plane"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("convex_acp_runtime_store"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("本地私有"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("共享会话"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("账号会话"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("云端会话"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sessionStorage:"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("permissionLevel:"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("shareId:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_acp_runtime_legacy_selector_contract_is_explicit() {
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 4"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function ensurePageAiStateFacade"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiPermissionRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiResolvePermission"));
|
||||
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("resolve-permission"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiProfileRuntime"));
|
||||
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes"));
|
||||
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("return ['reasonix', 'hermes']"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiClick"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiPointerDown"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote.page_ai.drawer_width"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-resize-handle"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiOpenCitationUrl"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("a[data-page-ai-citation-link=\"true\"]"));
|
||||
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("data-page-ai-citation-link=\"true\""));
|
||||
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("renderPageAiMarkdownTable"));
|
||||
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("wolai-page-ai-markdown-table-wrap"));
|
||||
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("target=\"_blank\""));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("document.addEventListener('visibilitychange'"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiCheckActiveRun(sessionId || undefined)"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiCheckAndResumeActiveRun"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiResumeActiveRunJournal"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiTrackStreamingRunEvent"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("void pageAiCheckAndResumeActiveRun().catch"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote.page_ai_active_run_snapshot.v1"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-mnote-page-ai-active-run-last-seq"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/runs/"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("afterSeq="));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiCheckAndResumeActiveRun()"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/agents/descriptors"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadAgentDescriptors"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-descriptor-field"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderDescriptorField"));
|
||||
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-agent-descriptor-card"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.handlePageAi"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("closestAction(e.target, '[data-page-ai-action"));
|
||||
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
|
||||
.contains("activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'"));
|
||||
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("默认 (Hermes HTTP)"));
|
||||
// 已退役:OCR sidecar / Hermes agent 切换 / ACP session store 前端
|
||||
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("pageAiEnrichOcrContextRefs"));
|
||||
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("ocrRootRelativePath"));
|
||||
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("切换到 Hermes"));
|
||||
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("切换到 OpenCode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1158,8 +1026,10 @@ mod tests {
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("tree:local-folder-watch-batch"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("viaEventBus: true"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("emitSyntheticWatchBatch"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("synthetic_page_ai_receipt"));
|
||||
// Page AI 写回本地文件夹:由 Pi Lab runtime 调 event bus,不再经 legacy drawer。
|
||||
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
|
||||
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("emitChangedFiles"));
|
||||
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("pi_lab_tool_call"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteLocalFolderEventBus"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("startLocalFolderWatcher"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
@@ -2032,9 +1902,17 @@ mod tests {
|
||||
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("recordFileTreeActionStatus('blocked'")
|
||||
);
|
||||
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("/api/tree/filetree/drop-preflight"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.contains("moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'"));
|
||||
assert!(
|
||||
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.contains("moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds")
|
||||
|| SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.contains("moveSidebarFileTreeRows(clipboard.rowIds")
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'")
|
||||
|| FILETREE_DND_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'")
|
||||
|| SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("ensureFileTreeWritableTarget")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -220,7 +220,8 @@ mod tests {
|
||||
assert!(MNOTE_CSS.len() > 2000);
|
||||
// CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装;
|
||||
// 当前包含主壳、Page AI、搜索、toast、debug 与 vault 样式,继续用上限防止意外重复打包。
|
||||
assert!(MNOTE_CSS.len() < 220000);
|
||||
// vault workbench 样式增长后合计约 234KB;上限放宽到 280KB。
|
||||
assert!(MNOTE_CSS.len() < 280000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2002,37 +2002,6 @@ button.wolai-page-ai-history-main span {
|
||||
border-color: rgba(27, 28, 28, 0.32);
|
||||
}
|
||||
|
||||
.wolai-page-ai-reasonix-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 0 8px 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-reasonix-controls[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wolai-page-ai-reasonix-control {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
color: #8B8782;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-reasonix-control select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||
border-radius: 7px;
|
||||
background: #FFF;
|
||||
color: #1B1C1C;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-composer-bar {
|
||||
display: flex;
|
||||
min-height: 36px;
|
||||
@@ -2214,298 +2183,3 @@ button.wolai-page-ai-history-main span {
|
||||
}
|
||||
}
|
||||
|
||||
.wolai-page-ai-drawer[data-page-ai-opencode-host="true"] .wolai-page-ai-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chrome {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 10px;
|
||||
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
|
||||
background: rgba(247, 247, 245, 0.92);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: rgba(27, 28, 28, 0.58);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-row strong,
|
||||
.wolai-page-ai-opencode-row code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: rgba(27, 28, 28, 0.86);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-badges,
|
||||
.wolai-page-ai-opencode-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-badges span,
|
||||
.wolai-page-ai-opencode-empty,
|
||||
.wolai-page-ai-opencode-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-height: 24px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: rgba(27, 28, 28, 0.68);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chip:hover {
|
||||
border-color: rgba(35, 131, 226, 0.32);
|
||||
color: var(--wolai-accent, #2383e2);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chip span {
|
||||
margin-left: 6px;
|
||||
color: rgba(27, 28, 28, 0.45);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-frame-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-iframe {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-iframe[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-iframe-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
color: rgba(27, 28, 28, 0.62);
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-iframe-fallback[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chat {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-messages {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message {
|
||||
margin: 0 0 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 12px;
|
||||
background: rgba(247, 247, 245, 0.9);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message[data-role="assistant"] {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message-role {
|
||||
margin-bottom: 4px;
|
||||
color: rgba(27, 28, 28, 0.52);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message-body {
|
||||
color: rgba(27, 28, 28, 0.88);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid rgba(27, 28, 28, 0.08);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-composer textarea {
|
||||
flex: 1 1 auto;
|
||||
min-height: 42px;
|
||||
resize: vertical;
|
||||
border: 1px solid rgba(27, 28, 28, 0.14);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-send,
|
||||
.wolai-page-ai-opencode-permission button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
background: #1f6feb;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-permissions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-permission {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(227, 115, 14, 0.24);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 247, 237, 0.92);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-permission span {
|
||||
overflow: hidden;
|
||||
color: rgba(27, 28, 28, 0.62);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-sessions-wrap {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-sessions {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-session-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-session-row[data-active="true"] {
|
||||
border-color: rgba(31, 111, 235, 0.38);
|
||||
background: rgba(31, 111, 235, 0.08);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-session-row span,
|
||||
.wolai-page-ai-opencode-message-role span {
|
||||
overflow: hidden;
|
||||
color: rgba(27, 28, 28, 0.52);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message-role {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-part {
|
||||
margin-top: 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
background: rgba(250, 250, 249, 0.88);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-part summary {
|
||||
cursor: pointer;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-part pre,
|
||||
.wolai-page-ai-opencode-error {
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-tool summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-patch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-patch button,
|
||||
.wolai-page-ai-opencode-part button {
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-todo {
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
list-style-position: inside;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -456,6 +456,44 @@
|
||||
border-top: 1px solid rgba(27, 28, 28, 0.06);
|
||||
}
|
||||
|
||||
/* 账号下登录态(扩展 Cookie session)状态徽章 */
|
||||
.mnote-vault-session-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.mnote-vault-session-badge.is-saved {
|
||||
background: rgba(34, 160, 107, 0.12);
|
||||
color: #1a7a4c;
|
||||
border: 1px solid rgba(34, 160, 107, 0.28);
|
||||
}
|
||||
.mnote-vault-session-badge.is-absent {
|
||||
background: rgba(109, 106, 101, 0.08);
|
||||
color: #6d6a65;
|
||||
border: 1px solid rgba(109, 106, 101, 0.18);
|
||||
}
|
||||
.mnote-vault-session-badge.is-compact {
|
||||
font-size: 10px;
|
||||
padding: 0 6px;
|
||||
line-height: 16px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.mnote-vault-session-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.mnote-vault-session-row > label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-vault-field-row > label {
|
||||
padding-top: 4px;
|
||||
color: #6d6a65;
|
||||
@@ -556,6 +594,114 @@
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* Expand-style folder picker: top-level groups stay visible (no long select). */
|
||||
.mnote-vault-folder-picker {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 6px;
|
||||
background: #fafaf9;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-none {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 0 4px;
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #6d6a65;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-none:hover,
|
||||
.mnote-vault-folder-picker-label:hover {
|
||||
background: rgba(35, 131, 226, 0.08);
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-none.is-selected,
|
||||
.mnote-vault-folder-picker-row.is-selected .mnote-vault-folder-picker-label {
|
||||
background: rgba(35, 131, 226, 0.14);
|
||||
color: #1b64c2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-node {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-height: 26px;
|
||||
padding-left: calc(var(--vault-picker-depth, 0) * 12px);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-chevron {
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9b9a97;
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-chevron.is-leaf {
|
||||
cursor: default;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #37352f;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-children {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-picker-empty {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #9b9a97;
|
||||
}
|
||||
|
||||
.mnote-vault-folder-controls input[type="text"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
Reference in New Issue
Block a user