chore: 保存当前架构收口与 bug 修复快照

归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
lix-2026
2026-05-18 17:01:35 +08:00
parent 61ee4a38a2
commit a2cb1338c8
953 changed files with 14383 additions and 211845 deletions
+36 -1
View File
@@ -179,7 +179,7 @@ impl AcpRunBridge {
/// Map an AcpSessionEvent to an SSE event for the frontend.
///
/// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate
/// Reference: wolai-frontend bridge.ts HermesRunEvent type
/// 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 {
@@ -233,6 +233,24 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
}),
AcpSessionEvent::PermissionRequest {
permission_id,
tool_name,
params,
decision,
} => Some(SseEvent {
event: if decision == "allowed" {
"permission.allowed".into()
} else {
"permission.denied".into()
},
data: json!({
"permissionId": permission_id,
"toolName": tool_name,
"params": params,
"decision": decision,
}),
}),
AcpSessionEvent::SessionInfoUpdate { .. } => {
None // Not forwarded to frontend
}
@@ -292,6 +310,23 @@ mod tests {
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_tool_events_keep_detail_for_collapsible_ui() {
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
+100 -8
View File
@@ -149,7 +149,7 @@ impl AcpClient {
.take()
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
let writer = BufWriter::new(stdin);
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()));
@@ -159,15 +159,16 @@ impl AcpClient {
// Start background reader task
let pending_clone = pending.clone();
let handler_clone = notification_handler.clone();
let writer_clone = writer.clone();
let child_pid = child.id().unwrap_or(0);
tokio::spawn(async move {
Self::reader_loop(reader, pending_clone, handler_clone).await;
Self::reader_loop(reader, writer_clone, pending_clone, handler_clone).await;
info!("ACP reader loop ended (pid={})", child_pid);
});
let client = Self {
child: Some(child),
writer: Arc::new(Mutex::new(writer)),
writer,
pending,
next_id: AtomicU64::new(1),
notification_handler,
@@ -285,6 +286,7 @@ impl AcpClient {
/// 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>,
) {
@@ -319,7 +321,7 @@ impl AcpClient {
}
};
Self::dispatch_message(msg, &pending, &notification_handler).await;
Self::dispatch_message(msg, &writer, &pending, &notification_handler).await;
}
// Process died or EOF — resolve all pending
@@ -334,6 +336,7 @@ impl AcpClient {
/// 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>,
) {
@@ -348,11 +351,32 @@ impl AcpClient {
// Incoming request from agent (e.g. session/request_permission)
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
// For now, reject all incoming requests since we don't need permission dialogs yet.
// Reference: acpClient.ts handleIncomingRequest (L200-220)
let id = msg.get("id").cloned().unwrap_or(Value::Null);
// 当前还没有权限确认 UI,必须明确拒绝,避免 agent 等待到超时。
warn!("ACP incoming request not handled: {method} (params={params:?})");
// If we wanted to reply, we'd need to write back a response...
// For now just log. Phase C will add permission support.
let response = json!({
"jsonrpc": "2.0",
"id": id,
"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}");
}
if method == "session/request_permission" {
let mut event_params = params;
if let Some(object) = event_params.as_object_mut() {
object.insert("decision".into(), Value::String("denied".into()));
object.insert("method".into(), Value::String(method.clone()));
object.insert("jsonrpcId".into(), id);
}
let handler_guard = notification_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(method, event_params);
}
}
} else if has_id {
// Response to one of our requests
if let Some(id) = msg["id"].as_u64() {
@@ -388,6 +412,18 @@ impl AcpClient {
}
}
}
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 {
@@ -443,6 +479,48 @@ rl.on('line', (line) => {
.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")
}
#[tokio::test]
async fn test_request_response() {
let client = spawn_mock_acp_server().await;
@@ -504,4 +582,18 @@ rl.on('line', (line) => {
// 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());
}
}
@@ -43,6 +43,13 @@ pub enum AcpSessionEvent {
},
/// Context usage update.
UsageUpdate { used: u64, size: u64 },
/// Agent 发起权限请求;当前 runtime 会自动拒绝并同步给前端。
PermissionRequest {
permission_id: String,
tool_name: String,
params: Value,
decision: String,
},
/// Session metadata update (e.g. auto-title).
SessionInfoUpdate { title: String },
/// Plan entries update.
@@ -51,6 +58,16 @@ pub enum AcpSessionEvent {
Disconnected { reason: String },
}
#[derive(Debug, Clone, Default)]
pub struct AcpMnoteToolContext {
pub mnote_session_id: Option<String>,
pub run_id: Option<String>,
pub actor_id: Option<String>,
pub trace_id: Option<String>,
pub workspace_id: Option<String>,
pub document_id: Option<String>,
}
/// Handler for session events.
pub type SessionEventHandler = Arc<dyn Fn(AcpSessionEvent) + Send + Sync + 'static>;
@@ -101,6 +118,34 @@ impl AcpSessionManager {
let in_prompt_clone = in_prompt.clone();
client.on_notification(move |method, params| {
if method == "session/request_permission" {
let permission_id = params
.get("permissionId")
.or_else(|| params.get("permission_id"))
.or_else(|| params.get("requestId"))
.or_else(|| params.get("id"))
.and_then(Value::as_str)
.unwrap_or("permission_auto_denied")
.to_string();
let tool_name = params
.get("toolName")
.or_else(|| params.get("tool"))
.or_else(|| params.get("name"))
.or_else(|| params.get("method"))
.and_then(Value::as_str)
.unwrap_or("session/request_permission")
.to_string();
let handler = event_handler_clone.lock().unwrap();
if let Some(ref h) = *handler {
h(AcpSessionEvent::PermissionRequest {
permission_id,
tool_name,
params,
decision: "denied".into(),
});
}
return;
}
if method != "session/update" {
return;
}
@@ -193,6 +238,14 @@ impl AcpSessionManager {
pub async fn run_prompt(
&self,
prompt: Vec<ContentBlock>,
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
self.run_prompt_with_mnote_context(prompt, None).await
}
pub async fn run_prompt_with_mnote_context(
&self,
prompt: Vec<ContentBlock>,
mnote_context: Option<AcpMnoteToolContext>,
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
{
let mut in_prompt = self.in_prompt.lock().unwrap();
@@ -215,9 +268,16 @@ impl AcpSessionManager {
)
})?;
let mnote_context = mnote_context.unwrap_or_default();
let params = SessionPromptParams {
session_id: session_id.clone(),
prompt,
mnote_session_id: mnote_context.mnote_session_id,
run_id: mnote_context.run_id,
actor_id: mnote_context.actor_id,
trace_id: mnote_context.trace_id,
workspace_id: mnote_context.workspace_id,
document_id: mnote_context.document_id,
};
debug!("ACP session/prompt (session={})", session_id);
+12
View File
@@ -163,6 +163,18 @@ pub struct ResourceContent {
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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -430,5 +430,6 @@ fn block_type_name(block_type: &EditorBlockType) -> String {
EditorBlockType::Toc => "table_of_contents".into(),
EditorBlockType::PageReference => "page_reference".into(),
EditorBlockType::BlockReference => "block_reference".into(),
EditorBlockType::Resource => "resource".into(),
}
}
+181 -52
View File
@@ -87,6 +87,7 @@ pub async fn block_replace(
ensure_page_write_preconditions(context, input, &aggregate)?;
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
ensure_block_editable(context, &block)?;
ensure_allowed_target(context, input, &Value::Null, &block_id, "replace")?;
let replacement_text = content_to_text(&content);
let diff = json!([{
"op": "replace",
@@ -155,58 +156,73 @@ pub async fn block_insert_after(
ensure_page_write_preconditions(context, input, &aggregate)?;
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
ensure_block_editable(context, &anchor)?;
let content = input
.arg_value("content")
.or_else(|| input.arg_value("block"))
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 缺少 content",
)
.with_context(context)
})?;
let new_block_id = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
let new_block = build_insert_block(&new_block_id, &content);
let diff = json!([{
"op": "insert_after",
"anchorBlockId": anchor_block_id,
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
"block": new_block
}]);
if input.dry_run.unwrap_or(false) {
return Ok(dry_run_result(
input,
&aggregate,
"block_insert_after",
diff,
false,
));
}
let current_content = current_body_content(&aggregate);
let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
after_block_id: anchor_block_id.clone(),
block: build_editor_block(&new_block_id, &content),
});
let (next_content, delta_opt) = compute_next_content_via_actor(
state,
&aggregate,
input,
&current_content,
&command,
"block_insert_after",
)?;
let mut result = execute_page_body_save(
state,
ensure_allowed_target(
context,
input,
next_content,
vec![json!({
"blockId": new_block_id,
&Value::Null,
&anchor_block_id,
"insert_after",
)?;
let insert_values = insert_after_values(context, input)?;
let id_prefix = format!("ai_block_{}", context.trace.request_id.replace('-', "_"));
let inserted_block_ids = insert_values
.iter()
.enumerate()
.map(|(index, _)| {
if insert_values.len() == 1 {
id_prefix.clone()
} else {
format!("{id_prefix}_{index}")
}
})
.collect::<Vec<_>>();
let diff = insert_values
.iter()
.zip(inserted_block_ids.iter())
.map(|(content, block_id)| {
json!({
"op": "insert_after",
"anchorBlockId": anchor_block_id,
"after": anchor.get("text").cloned().unwrap_or(Value::Null),
"block": build_insert_block(block_id, content)
})
})
.collect::<Vec<_>>();
if input.dry_run.unwrap_or(false) {
let mut result =
dry_run_result(input, &aggregate, "block_insert_after", json!(diff), false);
result["insertedBlockIds"] = json!(inserted_block_ids);
return Ok(result);
}
let mut next_content = current_body_content(&aggregate);
let mut changed_blocks = Vec::with_capacity(insert_values.len());
let mut delta_opt = None;
let mut after_block_id = anchor_block_id.clone();
for (content, block_id) in insert_values.iter().zip(inserted_block_ids.iter()) {
let command = EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
after_block_id: after_block_id.clone(),
block: build_editor_block(block_id, content),
});
let (content_after_command, command_delta) = compute_next_content_via_actor(
state,
&aggregate,
input,
&next_content,
&command,
"block_insert_after",
)?;
next_content = content_after_command;
delta_opt = command_delta;
changed_blocks.push(json!({
"blockId": block_id,
"op": "insert_after",
"anchorBlockId": anchor_block_id
})],
)
.await?;
"anchorBlockId": after_block_id
}));
after_block_id = block_id.clone();
}
let mut result =
execute_page_body_save(state, context, input, next_content, changed_blocks).await?;
result["insertedBlockIds"] = json!(inserted_block_ids);
merge_block_delta(&mut result, delta_opt);
Ok(result)
}
@@ -224,6 +240,7 @@ pub async fn block_delete(
ensure_page_write_preconditions(context, input, &aggregate)?;
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
ensure_block_editable(context, &block)?;
ensure_allowed_target(context, input, &Value::Null, &block_id, "delete")?;
let leaf = block
.get("children")
.and_then(Value::as_array)
@@ -298,6 +315,14 @@ pub async fn block_move_after(
ensure_page_write_preconditions(context, input, &aggregate)?;
ensure_block_revision_ref(context, input, &block, "blockRevisionRef")?;
ensure_block_revision_ref(context, input, &anchor, "anchorRevisionRef")?;
ensure_allowed_target(context, input, &Value::Null, &block_id, "move_after")?;
ensure_allowed_target(
context,
input,
&Value::Null,
&anchor_block_id,
"move_after_anchor",
)?;
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
let leaf = block
.get("children")
@@ -413,6 +438,7 @@ pub async fn doc_apply_block_ops(
)
.with_context(context));
}
ensure_page_write_preconditions(context, input, &aggregate)?;
let blocks = block_projection_blocks(&aggregate);
let mut next_content = current_body_content(&aggregate);
@@ -431,6 +457,13 @@ pub async fn doc_apply_block_ops(
"replace" | "block_replace" => {
let block = resolve_target_block(context, &blocks, operation, false)?;
ensure_block_editable(context, &block)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&block,
"blockRevisionRef",
)?;
let block_id = block_id_of(&block).unwrap_or_default();
let content = operation.get("content").cloned().ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "replace 操作缺少 content")
@@ -466,6 +499,13 @@ pub async fn doc_apply_block_ops(
"insert_after" | "block_insert_after" => {
let anchor = resolve_anchor_block(context, &blocks, operation)?;
ensure_block_editable(context, &anchor)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&anchor,
"anchorRevisionRef",
)?;
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
ensure_allowed_target(context, input, operation, &anchor_block_id, "insert_after")?;
let content = operation.get("content").cloned().ok_or_else(|| {
@@ -511,6 +551,13 @@ pub async fn doc_apply_block_ops(
"delete" | "block_delete" => {
let block = resolve_target_block(context, &blocks, operation, false)?;
ensure_block_editable(context, &block)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&block,
"blockRevisionRef",
)?;
let block_id = block_id_of(&block).unwrap_or_default();
ensure_allowed_target(context, input, operation, &block_id, "delete")?;
ensure_leaf_block(context, &block, "delete")?;
@@ -540,6 +587,20 @@ pub async fn doc_apply_block_ops(
let block = resolve_target_block(context, &blocks, operation, false)?;
let anchor = resolve_anchor_block(context, &blocks, operation)?;
ensure_block_editable(context, &block)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&block,
"blockRevisionRef",
)?;
ensure_operation_block_revision_ref(
context,
input,
operation,
&anchor,
"anchorRevisionRef",
)?;
ensure_leaf_block(context, &block, "move_after")?;
let block_id = block_id_of(&block).unwrap_or_default();
let anchor_block_id = block_id_of(&anchor).unwrap_or_default();
@@ -628,7 +689,10 @@ pub async fn doc_apply_block_ops(
.await
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
pub(crate) fn ensure_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
@@ -833,6 +897,37 @@ fn ensure_block_revision_ref(
Ok(())
}
fn ensure_operation_block_revision_ref(
context: &RequestContext,
input: &ToolCallInput,
operation: &Value,
block: &Value,
arg_name: &'static str,
) -> Result<(), WebError> {
if input.dry_run.unwrap_or(false) {
return Ok(());
}
let expected = operation
.get(arg_name)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| missing_write_precondition(context, arg_name))?;
let current = block
.get("revisionRef")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("");
if expected != current {
return Err(WebError::bad_request_code(
"mnote_tool_conflict",
format!("{arg_name} 已过期,请重新读取 block projection 后再写入"),
)
.with_context(context));
}
Ok(())
}
fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(), WebError> {
if block
.get("editable")
@@ -851,6 +946,40 @@ fn ensure_block_editable(context: &RequestContext, block: &Value) -> Result<(),
.with_context(context))
}
fn insert_after_values(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Vec<Value>, WebError> {
if let Some(blocks) = input.arg_value("blocks") {
let values = blocks.as_array().ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 的 blocks 必须是数组",
)
.with_context(context)
})?;
if values.is_empty() || values.len() > 20 {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 的 blocks 数量必须在 1 到 20 之间",
)
.with_context(context));
}
return Ok(values.clone());
}
input
.arg_value("content")
.or_else(|| input.arg_value("block"))
.map(|value| vec![value])
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.block.insert_after 缺少 content、block 或 blocks",
)
.with_context(context)
})
}
fn missing_write_precondition(context: &RequestContext, name: &'static str) -> WebError {
WebError::bad_request_code(
"mnote_tool_write_precondition_required",
@@ -1049,7 +1178,7 @@ async fn execute_page_body_save(
}))
}
async fn execute_page_body_save_from_aggregate(
pub(crate) async fn execute_page_body_save_from_aggregate(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
@@ -1255,7 +1384,7 @@ fn merge_block_delta(response: &mut Value, delta_opt: Option<Value>) {
}
}
fn current_body_content(aggregate: &Value) -> Value {
pub(crate) fn current_body_content(aggregate: &Value) -> Value {
aggregate
.pointer("/body/content")
.cloned()
File diff suppressed because it is too large Load Diff
@@ -180,7 +180,11 @@ fn block_replace_tool() -> Value {
"content": { "type": ["string", "object", "array"] },
"revision": { "type": ["number", "string"] },
"conflictDetectionKey": { "type": "string" },
"blockRevisionRef": { "type": "string" }
"blockRevisionRef": { "type": "string" },
"allowedTargetBlockIds": {
"type": "array",
"items": { "type": "string" }
}
}),
[
"blockId",
@@ -193,7 +197,7 @@ fn block_replace_tool() -> Value {
}
fn block_insert_after_tool() -> Value {
write_tool(
let mut tool = write_tool(
"mnote.block.insert_after",
"在指定块后插入新块;真实写入走 Rust page.body.save 链路",
["block.write", "page.write"],
@@ -201,18 +205,38 @@ fn block_insert_after_tool() -> Value {
"anchorBlockId": { "type": "string" },
"content": { "type": ["string", "object", "array"] },
"block": { "type": "object" },
"blocks": {
"type": "array",
"minItems": 1,
"maxItems": 20,
"items": { "type": ["string", "object", "array"] }
},
"revision": { "type": ["number", "string"] },
"conflictDetectionKey": { "type": "string" },
"anchorRevisionRef": { "type": "string" }
"anchorRevisionRef": { "type": "string" },
"allowedTargetBlockIds": {
"type": "array",
"items": { "type": "string" }
}
}),
[
"anchorBlockId",
"content",
"revision",
"conflictDetectionKey",
"anchorRevisionRef",
],
)
);
if let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) {
schema.insert(
"anyOf".into(),
json!([
{ "required": ["content"] },
{ "required": ["block"] },
{ "required": ["blocks"] }
]),
);
}
tool
}
fn block_move_after_tool() -> Value {
@@ -226,7 +250,11 @@ fn block_move_after_tool() -> Value {
"revision": { "type": ["number", "string"] },
"conflictDetectionKey": { "type": "string" },
"blockRevisionRef": { "type": "string" },
"anchorRevisionRef": { "type": "string" }
"anchorRevisionRef": { "type": "string" },
"allowedTargetBlockIds": {
"type": "array",
"items": { "type": "string" }
}
}),
[
"blockId",
@@ -248,7 +276,11 @@ fn block_delete_tool() -> Value {
"blockId": { "type": "string" },
"revision": { "type": ["number", "string"] },
"conflictDetectionKey": { "type": "string" },
"blockRevisionRef": { "type": "string" }
"blockRevisionRef": { "type": "string" },
"allowedTargetBlockIds": {
"type": "array",
"items": { "type": "string" }
}
}),
[
"blockId",
@@ -411,11 +443,12 @@ fn available_tool(
}
fn doc_markdown_edit_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert(
"operations".into(),
json!({
let mut tool = write_tool(
"mnote.doc.markdown_edit",
"通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
["block.write", "page.write"],
json!({
"operations": {
"type": "array",
"description": "搜索替换操作列表",
"items": {
@@ -426,25 +459,24 @@ fn doc_markdown_edit_tool() -> Value {
},
"required": ["search", "replace"]
}
}),
);
map.insert(
"full_content".into(),
json!({
},
"full_content": {
"type": "string",
"description": "完整修改后的 markdown 文本(替代 operations"
}),
}
}),
[],
);
if let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) {
schema.insert(
"anyOf".into(),
json!([
{ "required": ["operations"] },
{ "required": ["full_content"] }
]),
);
}
json!({
"name": "mnote.doc.markdown_edit",
"description": "通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["block.write", "page.write"],
"status": "available",
"properties": properties,
"annotations": tool_annotations(false, false, true, false)
})
tool
}
fn tool_annotations(
+10
View File
@@ -20,3 +20,13 @@ pub mod tree_shell;
pub mod workspace_shell;
pub use app::{build_app, AppConfig, AppState};
#[cfg(test)]
pub(crate) mod test_support {
use std::sync::{Mutex, OnceLock};
pub(crate) fn hermes_env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -179,14 +179,26 @@ pub fn build_router(state: AppState) -> Router {
"/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),
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}/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(
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use crate::hermes_tools::ToolCallInput;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
@@ -88,6 +88,30 @@ pub async fn block_edit_workflow(
} else {
context.auth.actor_id.clone()
};
let allowed_target_block_ids = ai_context
.get("allowedTargetBlockIds")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut markdown_args = json!({
"operations": markdown_operations.clone()
});
if !allowed_target_block_ids.is_empty() {
if let Value::Object(map) = &mut markdown_args {
map.insert(
"allowedTargetBlockIds".into(),
json!(allowed_target_block_ids),
);
}
}
let edit_input = ToolCallInput {
tool_name: "mnote.doc.markdown_edit".into(),
workspace_id: Some(workspace_id.clone()),
@@ -101,12 +125,12 @@ pub async fn block_edit_workflow(
idempotency_key: Some(format!("page_ai_fast_edit_{}", context.trace.request_id)),
dry_run: Some(false),
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
args: Some(json!({
"operations": markdown_operations
})),
args: Some(markdown_args),
};
let apply_started = Instant::now();
let apply_result = doc::doc_markdown_edit(&state, &context, &edit_input).await?;
let tool_response =
crate::routes::hermes_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!(
trace_id = %trace_id,
@@ -128,6 +152,7 @@ pub async fn block_edit_workflow(
"traceId": trace_id,
"operations": markdown_operations,
"applyResult": apply_result,
"toolExecution": tool_response,
"message": "已通过页面 markdown 编辑快路径完成写入。",
"timingsMs": {
"total": started.elapsed().as_millis(),
@@ -540,6 +565,124 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{direct_block_edit_operations, extract_operations_from_model_text};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::routing::post;
use axum::{Json, Router};
use serde_json::{json, Value};
use std::fs;
use std::sync::Mutex;
use tower::util::ServiceExt;
fn env_lock() -> &'static Mutex<()> {
crate::test_support::hermes_env_lock()
}
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,
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: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"can_edit": true,
"wide_layout": false,
"use_small_text": false,
"show_toc": true,
"block_count": 2
},
"documents:getContent": {
"title": "服务端页面",
"content": [
{
"id": "p_1",
"type": "paragraph",
"content": [{ "type": "text", "text": "第一段" }]
},
{
"id": "p_2",
"type": "paragraph",
"content": [{ "type": "text", "text": "第二段" }]
}
],
"revision": 7,
"conflict_detection_key": "doc_1:7"
}
}"#
.into(),
),
mutation_fixtures_json: Some(
r#"{
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
}"#
.into(),
),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
async fn spawn_mock_model_server() -> String {
async fn completions() -> Json<Value> {
Json(json!({
"choices": [{
"message": {
"content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"ok\"}"
}
}]
}))
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind mock model");
let addr = listener.local_addr().expect("mock model addr");
let server = Router::new().route("/chat/completions", post(completions));
tokio::spawn(async move {
let _ = axum::serve(listener, server).await;
});
format!("http://{addr}")
}
async fn spawn_out_of_scope_mock_model_server() -> String {
async fn completions() -> Json<Value> {
Json(json!({
"choices": [{
"message": {
"content": "{\"operations\":[{\"search\":\"第一段\",\"replace\":\"越权修改\"}],\"summary\":\"out_of_scope\"}"
}
}]
}))
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind out-of-scope mock model");
let addr = listener.local_addr().expect("mock model addr");
let server = Router::new().route("/chat/completions", post(completions));
tokio::spawn(async move {
let _ = axum::serve(listener, server).await;
});
format!("http://{addr}")
}
#[test]
fn extracts_operations_from_fenced_model_json() {
@@ -570,4 +713,137 @@ mod tests {
assert_eq!(operations[2]["op"], "delete");
assert_eq!(operations[2]["matchText"], "第三段");
}
#[tokio::test]
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!(
"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");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
format!(
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\nmnote:\n tools:\n disabled:\n - mnote.doc.markdown_edit\n"
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/page-ai/block-edit-workflow")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"documentId": "doc_1",
"message": "把第二段改成测试123",
"profile": "mnoteai",
"sessionId": "sess_page_ai_disabled",
"runId": "run_page_ai_disabled",
"traceId": "trace_page_ai_disabled",
"pageContext": {
"aiContext": {
"schema": "mnote.page_ai_context.v1",
"pageText": "第一段\n\n第二段",
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
"contextBlocks": [
{"blockId": "p_1", "text": "第一段"},
{"blockId": "p_2", "text": "第二段"}
]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
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["code"], "mnote_tool_disabled");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_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!(
"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");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
format!(
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\n"
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/page-ai/block-edit-workflow")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"documentId": "doc_1",
"message": "把选中段落改成测试123",
"profile": "mnoteai",
"sessionId": "sess_page_ai_scope",
"runId": "run_page_ai_scope",
"traceId": "trace_page_ai_scope",
"pageContext": {
"aiContext": {
"schema": "mnote.page_ai_context.v1",
"allowedTargetBlockIds": ["p_2"],
"pageText": "第一段\n\n第二段",
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
"contextBlocks": [
{"blockId": "p_1", "text": "第一段"},
{"blockId": "p_2", "text": "第二段"}
]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
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["code"], "mnote_markdown_edit_target_out_of_scope");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
}
}
+96 -14
View File
@@ -2,9 +2,9 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
StreamSnapshotQuery,
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
StreamChangeKind, StreamSnapshotQuery,
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
@@ -94,15 +94,11 @@ async fn events_with_stream_delta(
if let Some(ref mut rx) = state.stream_delta_rx {
match rx.try_recv() {
Ok(payload) => {
let hint = json!({
"kind": "delta",
"hint": "command_committed",
"commandName": payload.get("commandName"),
"commandId": payload.get("commandId"),
"workspaceId": payload.get("workspaceId"),
"requestId": payload.get("requestId"),
"traceId": payload.get("traceId"),
});
let hint = build_stream_push_delta_hint(
&payload,
&state.context.trace.request_id,
&state.context.trace.trace_id,
);
return Some((Ok(stream_event("delta", &hint)), Some(state)));
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
@@ -113,9 +109,68 @@ async fn events_with_stream_delta(
}
}
// If push-driven and we already checked both broadcasts, brief sleep then re-check
// If push-driven and broadcasts are quiet, polling remains the safety net.
if state.stream_delta_rx.is_some() {
sleep(Duration::from_millis(250)).await;
if let Some(max_polls) = max_polls {
if state.polls >= max_polls {
return None;
}
}
state.polls += 1;
sleep(Duration::from_millis(poll_ms)).await;
let poll_query = live_poll_query(&state.query);
let Ok((workspace_id, overview)) =
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
.await
else {
return None;
};
if let Some(change) =
resolve_stream_change(&overview, state.current_cursor.as_deref())
{
state.current_cursor = change.cursor.clone();
match change.kind {
StreamChangeKind::Delta => {
let Ok(payload) = build_stream_delta_payload(
state.app_state.config(),
&state.context,
&poll_query,
&workspace_id,
&overview,
change.cursor,
change
.delta
.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
)
.await
else {
return None;
};
return Some((Ok(stream_event("delta", &payload)), Some(state)));
}
StreamChangeKind::Resync => {
let Ok(snapshot_payload) = load_stream_snapshot(
state.app_state.config(),
&state.context,
&poll_query,
)
.await
else {
return None;
};
state.current_cursor =
read_stream_cursor_from_payload(&snapshot_payload);
return Some((
Ok(stream_event(
"resync",
&with_stream_kind(&snapshot_payload, "resync"),
)),
Some(state),
));
}
}
}
return Some((Ok(stream_event("heartbeat", &json!({}))), Some(state)));
}
@@ -289,6 +344,8 @@ mod tests {
use crate::routes::stream_support::StreamSnapshotQuery;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use std::time::Duration;
use tokio::time::timeout;
use tower::util::ServiceExt;
fn app() -> axum::Router {
@@ -395,6 +452,31 @@ mod tests {
assert!(text.contains("\"revision\""));
}
#[tokio::test]
async fn tree_events_push_mode_honors_polling_safety_net_max_polls() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=1&pollMs=1")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = timeout(
Duration::from_secs(2),
to_bytes(response.into_body(), usize::MAX),
)
.await
.expect("push SSE stream should stop after maxPolls")
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
assert!(text.contains("event: heartbeat") || text.contains("event:heartbeat"));
}
#[test]
fn live_poll_query_drops_bridge_pagination_cursor() {
let query = StreamSnapshotQuery {
@@ -574,6 +574,18 @@ pub fn with_stream_kind(payload: &Value, kind: &str) -> Value {
})
}
pub fn build_stream_push_delta_hint(payload: &Value, request_id: &str, trace_id: &str) -> Value {
json!({
"kind": "delta",
"hint": "command_committed",
"commandName": payload.get("commandName"),
"commandId": payload.get("commandId"),
"workspaceId": payload.get("workspaceId"),
"requestId": request_id,
"traceId": trace_id,
})
}
pub async fn build_stream_delta_payload(
config: &AppConfig,
context: &RequestContext,
@@ -1042,7 +1054,7 @@ mod tests {
"id": "clog_2",
"command_id": "cmd_2",
"created_at": "2026-04-25T10:00:02Z",
"command_name": "tree.resource.delete",
"command_name": "tree.resource.archive",
"payload": {
"streamDelta": {
"op": "remove_asset",
+68 -31
View File
@@ -2018,12 +2018,17 @@ fn build_tree_shell_html(
const getFileTreeRowDocumentId = (item) => {
if (!item) return "";
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
if (item.rowKind === "document" || item.rowKind === "markdown") return item.nodeId;
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
return "";
};
const getFileTreeRowOwnerDocumentId = (item) => {
if (!item) return "";
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
return getFileTreeRowDocumentId(item);
};
const getFileTreeRowAssetId = (item) => {
if (!item) return "";
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
@@ -2084,6 +2089,7 @@ fn build_tree_shell_html(
rowKind: "root",
nodeId: null,
documentId: null,
ownerDocumentId: null,
assetId: null,
};
}
@@ -2092,6 +2098,7 @@ fn build_tree_shell_html(
rowKind: normalizeText(row.dataset.rowKind, "document"),
nodeId: normalizeText(row.dataset.nodeId) || null,
documentId: normalizeText(row.dataset.documentId) || null,
ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
assetId: normalizeText(row.dataset.assetId) || null,
};
};
@@ -2934,13 +2941,14 @@ fn build_tree_shell_html(
const targetItem = targetRowId ? fileTreeRowById.get(targetRowId) : null;
const target = targetItem
? {
rowId: targetItem.rowId,
rowKind: targetItem.rowKind,
nodeId: targetItem.nodeId,
documentId: getFileTreeRowDocumentId(targetItem) || null,
assetId: getFileTreeRowAssetId(targetItem) || null,
}
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, assetId: null };
rowId: targetItem.rowId,
rowKind: targetItem.rowKind,
nodeId: targetItem.nodeId,
documentId: getFileTreeRowDocumentId(targetItem) || null,
ownerDocumentId: getFileTreeRowOwnerDocumentId(targetItem) || null,
assetId: getFileTreeRowAssetId(targetItem) || null,
}
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, ownerDocumentId: null, assetId: null };
if (sourceKind === "local_folder") {
const pasted = await executeLocalFileTreeInternalDrop(
target,
@@ -3030,7 +3038,12 @@ fn build_tree_shell_html(
if (event.key === "F2") {
event.preventDefault();
const rowId = item?.rowId || fileTreeFocusedRowId;
if (rowId) beginInlineRename("filetree", rowId);
const renameItem = rowId ? fileTreeRowById.get(rowId) : null;
if (rowId && getFileTreeRowDocumentId(renameItem)) {
beginInlineRename("filetree", rowId);
} else {
setLastAction("当前资源暂不支持重命名", "error");
}
return;
}
if (event.key === "ArrowDown") {
@@ -3057,7 +3070,11 @@ fn build_tree_shell_html(
event.preventDefault();
if (item) {
const documentId = getFileTreeRowDocumentId(item);
if (documentId) handleNavigate(documentId);
if (documentId) {
handleNavigate(documentId);
} else {
openHydratedFileTreeItem(item);
}
}
return;
}
@@ -3219,7 +3236,7 @@ fn build_tree_shell_html(
});
}
normalizedItems.slice().forEach((item) => {
const itemDocumentId = getFileTreeRowDocumentId(item) || item.resourceMeta?.documentId || item.nodeId;
const itemDocumentId = getFileTreeRowOwnerDocumentId(item) || item.nodeId;
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
removeTreeItemEverywhere(item);
changed = true;
@@ -4149,8 +4166,7 @@ fn build_tree_shell_html(
rowKind === "markdown" ||
rowKind === "document" ||
rowKind === "folder" ||
rowKind === "index" ||
(localSource && rowKind === "asset");
rowKind === "index";
const canCopyCut =
rowKind === "markdown" ||
rowKind === "document" ||
@@ -4282,7 +4298,9 @@ fn build_tree_shell_html(
return;
}
if (kind === "rename") {
if (target.rowId) beginInlineRename("filetree", target.rowId);
if (target.rowId && getFileTreeRowDocumentId(item)) {
beginInlineRename("filetree", target.rowId);
}
return;
}
if (kind === "copy" || kind === "cut") {
@@ -4639,8 +4657,14 @@ fn build_tree_shell_html(
const id = inlineRenameState.id;
const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id);
const documentId = renameMode === "filetree"
? getFileTreeRowDocumentId(item) || item?.nodeId || id
? getFileTreeRowDocumentId(item)
: id;
if (!documentId) {
inlineRenameState = { mode: null, id: null, committing: false };
setLastAction("当前资源暂不支持重命名", "error");
renderTree();
return;
}
try {
const result = await sendCommand({
action: "rename",
@@ -4953,6 +4977,7 @@ fn build_tree_shell_html(
const openHydratedFileTreeItem = (item) => {
const documentId = getFileTreeRowDocumentId(item);
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
const assetId = getFileTreeRowAssetId(item);
if (item.rowKind === "document" || item.rowKind === "index") {
handleNavigate(documentId || item.nodeId);
@@ -4960,12 +4985,12 @@ fn build_tree_shell_html(
}
setLastAction(` ${assetId || item.rowId}`);
postToHost("tree.asset.open", {
documentId: documentId || null,
documentId: ownerDocumentId || null,
assetId: assetId || null,
objectIdentity: item.resourceMeta?.objectIdentity || null,
target: { documentId: documentId || null },
target: { documentId: ownerDocumentId || null },
payload: {
documentId: documentId || null,
documentId: ownerDocumentId || null,
assetId: assetId || null,
rowId: item.rowId,
rowKind: item.rowKind,
@@ -5122,6 +5147,7 @@ fn build_tree_shell_html(
const bindFileTreeRowEvents = (row, item) => {
if (!(row instanceof HTMLElement) || !item) return;
const documentId = getFileTreeRowDocumentId(item) || null;
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
const assetId = getFileTreeRowAssetId(item) || null;
row.dataset.active = String(
item.rowKind === "document" && documentId === currentActiveDocumentId
@@ -5130,6 +5156,7 @@ fn build_tree_shell_html(
row.dataset.rowId = item.rowId;
row.dataset.rowKind = item.rowKind;
row.dataset.documentId = documentId || "";
row.dataset.ownerDocumentId = ownerDocumentId || "";
row.dataset.assetId = assetId || "";
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
? JSON.stringify(item.resourceMeta.objectIdentity)
@@ -5577,6 +5604,7 @@ fn build_tree_shell_html(
const openFileTreeItem = (item) => {
const documentId = getFileTreeRowDocumentId(item);
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
const assetId = getFileTreeRowAssetId(item);
if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
handleNavigate(documentId || item.nodeId);
@@ -5584,12 +5612,12 @@ fn build_tree_shell_html(
}
setLastAction(` ${assetId || item.rowId}`);
postToHost("tree.asset.open", {
documentId: documentId || null,
documentId: ownerDocumentId || null,
assetId: assetId || null,
objectIdentity: item.resourceMeta?.objectIdentity || null,
target: { documentId: documentId || null },
target: { documentId: ownerDocumentId || null },
payload: {
documentId: documentId || null,
documentId: ownerDocumentId || null,
assetId: assetId || null,
rowId: item.rowId,
rowKind: item.rowKind,
@@ -5649,6 +5677,7 @@ fn build_tree_shell_html(
const children = getSiblings(item.nodeId);
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
const documentId = getFileTreeRowDocumentId(item) || null;
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
const assetId = getFileTreeRowAssetId(item) || null;
const row = document.createElement("div");
@@ -5661,6 +5690,7 @@ fn build_tree_shell_html(
row.dataset.rowId = item.rowId;
row.dataset.rowKind = item.rowKind;
row.dataset.documentId = documentId || "";
row.dataset.ownerDocumentId = ownerDocumentId || "";
row.dataset.assetId = assetId || "";
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
? JSON.stringify(item.resourceMeta.objectIdentity)
@@ -5769,15 +5799,17 @@ fn build_tree_shell_html(
const actions = document.createElement("div");
actions.className = "tree-actions";
actions.appendChild(
createActionButton(
ICONS.edit,
"filetree-action-rename",
` ${item.title}`,
() => beginInlineRename("filetree", item.rowId),
false,
),
);
if (getFileTreeRowDocumentId(item)) {
actions.appendChild(
createActionButton(
ICONS.edit,
"filetree-action-rename",
` ${item.title}`,
() => beginInlineRename("filetree", item.rowId),
false,
),
);
}
actions.appendChild(
createActionButton(
ICONS.more,
@@ -7143,6 +7175,11 @@ mod tests {
assert!(html.contains("tree.filetree.external-drop"));
assert!(html.contains("\"rowKind\":\"asset_folder\""));
assert!(html.contains("\"resourceMeta\""));
assert!(html.contains("const getFileTreeRowOwnerDocumentId = (item) => {"));
assert!(html.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
assert!(html.contains("if (rowId && getFileTreeRowDocumentId(renameItem))"));
assert!(html.contains("if (getFileTreeRowDocumentId(item))"));
assert!(html.contains("documentId: ownerDocumentId || null"));
assert!(html.contains("dragover"));
assert!(html.contains("hydrateInitialFileTree"));
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
@@ -7897,7 +7934,7 @@ mod tests {
payload["result"]["documentId"],
Value::String("page_child".into())
);
assert_eq!(payload["result"]["sortOrder"], Value::from(1));
assert_eq!(payload["result"]["sortOrder"], Value::Null);
assert_eq!(
payload["result"]["execution"]["deletedCount"],
Value::from(1)
@@ -1210,6 +1210,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
titleEndpoint: '/api/documents/title',
editorHostKind: 'leptos_tiptap_island',
});
const syncPageAggregateScript = (session, aggregate) => {
if (!session || !aggregate) return;
const scriptId = session.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__';
const node = document.getElementById(scriptId);
if (!node) return;
try {
node.textContent = JSON.stringify(aggregate);
node.setAttribute('data-mnote-page-aggregate-synced-at', String(Date.now()));
} catch (error) {
console.warn('mnote Page Aggregate script ', error);
}
};
const documentSessionRegistry = new Map();
const localFolderEventRegistry = new Map();
@@ -1622,6 +1634,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
@@ -1891,6 +1904,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
sourceKind,
rootUri: runtimeDescriptor.bootstrap.rootUri,
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '',
currentTiptapDocument: tiptapDocument,
@@ -3082,6 +3096,7 @@ mod tests {
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
assert!(html.contains("/api/tree/events"));
assert!(html.contains("data-mnote-tree-live-transport"));
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
assert!(!html.contains("mnote-web-document-shell"));
}
+49 -10
View File
@@ -1,7 +1,9 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
use crate::routes::stream_support::{
build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery,
};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::response::Response;
@@ -41,14 +43,15 @@ async fn handle_socket(
delta_result = stream_delta_rx.recv() => {
match delta_result {
Ok(delta) => {
let notify = json!({
"kind": "delta",
"data": delta,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"workspaceId": delta.get("workspaceId").and_then(Value::as_str).unwrap_or(""),
});
if socket.send(Message::Text(notify.to_string().into())).await.is_err() {
if socket
.send(serialize_delta_message(
&delta,
&context.trace.request_id,
&context.trace.trace_id,
))
.await
.is_err()
{
break;
}
}
@@ -130,6 +133,14 @@ fn serialize_snapshot_message(payload: &Value) -> Message {
Message::Text(payload.to_string().into())
}
fn serialize_delta_message(payload: &Value, request_id: &str, trace_id: &str) -> Message {
Message::Text(
build_stream_push_delta_hint(payload, request_id, trace_id)
.to_string()
.into(),
)
}
fn serialize_resync_message(payload: &Value) -> Message {
Message::Text(
json!({
@@ -156,7 +167,10 @@ fn is_resync_request(text: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{is_resync_request, serialize_resync_message, serialize_snapshot_message};
use super::{
is_resync_request, serialize_delta_message, serialize_resync_message,
serialize_snapshot_message,
};
use axum::extract::ws::Message;
use serde_json::json;
@@ -183,4 +197,29 @@ mod tests {
};
assert!(resync_text.contains("\"kind\":\"resync\""));
}
#[test]
fn ws_delta_serializer_uses_stream_hint_contract() {
let delta = json!({
"kind": "command_committed",
"commandName": "tree.rename",
"commandId": "cmd_1",
"workspaceId": "ws_demo",
"requestId": "req_command",
"traceId": "trace_command"
});
let Message::Text(delta_text) = serialize_delta_message(&delta, "req_ws", "trace_ws")
else {
panic!("delta message 应该是文本帧");
};
let payload: serde_json::Value = serde_json::from_str(&delta_text).expect("delta json");
assert_eq!(payload["kind"], "delta");
assert_eq!(payload["hint"], "command_committed");
assert_eq!(payload["commandName"], "tree.rename");
assert_eq!(payload["commandId"], "cmd_1");
assert_eq!(payload["workspaceId"], "ws_demo");
assert_eq!(payload["requestId"], "req_ws");
assert_eq!(payload["traceId"], "trace_ws");
assert!(payload.get("data").is_none());
}
}
+644 -27
View File
@@ -61,13 +61,32 @@ const SIDEBAR_TREE_JS: &str = r##"
pageAiSkillQuery: '',
pageAiSkillError: '',
pageAiSessions: [],
pageAiActiveSessionId: ''
pageAiActiveSessionId: '',
pageAiSessionSearchQuery: '',
pageAiSessionSearchResults: [],
pageAiSessionSearchTimer: 0,
pageAiSessionError: '',
pageAiPermissionRequests: []
};
function closestAction(target, selector) {
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
}
function toggleWorkspaceSidebar(trigger) {
var shell = document.querySelector('.mnote-shell, .wolai-workspace-shell');
if (!(shell instanceof HTMLElement)) return;
var collapsed = shell.getAttribute('data-mnote-sidebar-collapsed') === 'true';
var next = !collapsed;
shell.setAttribute('data-mnote-sidebar-collapsed', String(next));
document.documentElement.setAttribute('data-mnote-sidebar-collapsed', String(next));
document.documentElement.setAttribute('data-mnote-sidebar-toggle-applied', 'true');
if (trigger instanceof HTMLElement) {
trigger.setAttribute('aria-pressed', String(next));
trigger.setAttribute('title', next ? '' : '');
}
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
@@ -227,20 +246,7 @@ const SIDEBAR_TREE_JS: &str = r##"
function currentPageAiContextSnapshot() {
var aggregate = currentPageAggregate();
var body = aggregate.body || {};
var serverContent = body.content || null;
var serverText = searchText(textFromUnknown(serverContent));
var localBlocks = readLocalEditorBlocks();
var localText = searchText(localBlocks.map(function(block) { return block.content || ''; }).join(' '));
var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
if (localBlocks.length && localText && localText !== serverText) {
return {
aggregate: aggregate,
body: Object.assign({}, body, { content: localBlocks }),
subtree: buildPageAiLocalSubtree(localBlocks, title),
pageSubtreeSource: 'local'
};
}
return {
aggregate: aggregate,
body: body,
@@ -3930,6 +3936,30 @@ const SIDEBAR_TREE_JS: &str = r##"
return 'hermes_page_ai_session:' + currentDocumentId();
}
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 pageAiBackendSessionQuery(extra) {
var params = new URLSearchParams();
params.set('source', 'acp');
params.set('workspaceId', resolveWorkspaceId(document.body));
params.set('documentId', currentDocumentId());
params.set('profile', pageAiRunProfile());
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();
return {
@@ -3939,10 +3969,126 @@ const SIDEBAR_TREE_JS: &str = r##"
acpRuntime: pageUiState.pageAiAcpRuntime || '',
createdAt: now,
updatedAt: now,
source: 'local',
usage: null,
status: 'idle',
messages: []
};
}
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(' ') || '';
}
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 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';
return {
role: 'tool',
kind: 'permission',
permissionId: permissionId,
toolName: toolName,
argsSummary: pageAiPreviewValue(args),
content: decision === 'denied' ? '' : (decision === 'allowed' ? '' : ''),
resolved: decision === 'denied' || decision === 'allowed',
decision: decision
};
}
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);
if (!message.resolved) {
pageAiShowPermissionDialog(message);
} else {
pageAiHidePermissionDialog();
}
}
function pageAiResolvePermission(permissionId, decision) {
permissionId = String(permissionId || '').trim();
if (!permissionId) return;
pageUiState.pageAiMessages.forEach(function(item) {
if (item.kind === 'permission' && item.permissionId === permissionId) {
item.resolved = true;
item.decision = decision;
item.content = decision === 'allow' ? '' : '';
}
});
var dialog = document.querySelector('[data-page-ai-permission-dialog]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
renderPageAiConversation();
}
function pageAiHidePermissionDialog() {
var dialog = document.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 = document.querySelector('[data-page-ai-permission-dialog]');
if (!(dialog instanceof HTMLElement)) {
dialog = document.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-tool-meta" data-page-ai-permission-args></div>' +
'<div class="wolai-page-ai-message-actions">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" 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-dialog-action></button>' +
'</div>' +
'</div>';
document.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.textContent = message.argsSummary || message.content || '';
dialog.querySelectorAll('[data-page-ai-permission-dialog-action]').forEach(function(button) {
if (button instanceof HTMLButtonElement) {
button.setAttribute('data-page-ai-permission-id', message.permissionId || '');
button.disabled = Boolean(message.resolved);
}
});
dialog.hidden = false;
}
function pageAiNormalizeSessions(sessions) {
return (Array.isArray(sessions) ? sessions : [])
.slice(0, 20)
@@ -3952,8 +4098,13 @@ const SIDEBAR_TREE_JS: &str = r##"
title: String(session && session.title || '').trim() || '',
profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default',
acpRuntime: String(session && session.acpRuntime || '').trim(),
createdAt: Number(session && session.createdAt || Date.now()),
updatedAt: Number(session && session.updatedAt || Date.now()),
createdAt: pageAiTimestamp(session && session.createdAt),
updatedAt: pageAiTimestamp(session && session.updatedAt),
source: String(session && session.source || 'local').trim() || 'local',
runId: String(session && (session.runId || session.run_id) || '').trim(),
status: String(session && session.status || '').trim(),
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) : []
};
})
@@ -3962,6 +4113,43 @@ const SIDEBAR_TREE_JS: &str = r##"
});
}
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) + '…';
return {
id: sessionId,
title: title || '',
profile: String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default',
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: 'acp',
runId: String(row.runId || row.run_id || '').trim(),
status: String(row.status || (row.runtime && row.runtime.status) || '').trim(),
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 pageAiLoadSessions() {
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
try {
@@ -3996,6 +4184,135 @@ const SIDEBAR_TREE_JS: &str = r##"
pageUiState.pageAiMessages = [];
}
async function pageAiLoadBackendSessions() {
var response = await fetch('/api/hermes/client/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);
if (!backendSessions.length) return [];
pageUiState.pageAiSessions = pageAiMergeSessions(pageUiState.pageAiSessions, backendSessions);
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 (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 (eventType === 'message.delta') {
var delta = String(payload.delta || payload.text || payload.output_text || '').trim();
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();
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),
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);
}
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 messages = 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; });
events.forEach(function(event) {
var message = pageAiMessageFromRuntimeEvent(event);
if (message) messages.push(message);
});
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 (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/hermes/client/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/hermes/client/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;
}
function pageAiPersistSessions() {
document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
@@ -4049,12 +4366,18 @@ const SIDEBAR_TREE_JS: &str = r##"
async function pageAiRestoreHermesSession() {
var current = pageAiCurrentSession();
if (!current || !String(current.id || '').startsWith('mnote_')) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume', {
var response = await fetch('/api/hermes/client/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);
@@ -4101,6 +4424,12 @@ const SIDEBAR_TREE_JS: &str = r##"
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() {
@@ -4114,6 +4443,71 @@ const SIDEBAR_TREE_JS: &str = r##"
renderPageAiConversation();
}
async function pageAiRenameBackendSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
var title = window.prompt(' AI ', session.title || '');
if (title === null) return;
title = String(title || '').trim();
if (!title) return;
var response = await fetch('/api/hermes/client/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));
}
session.title = String((payload.result && payload.result.title) || title);
session.updatedAt = Date.now();
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
async function pageAiDeleteBackendSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
if (!window.confirm(' AI ' + (session.title || sessionId) + '')) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + 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));
}
pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(item) { return item.id !== sessionId; });
if (pageUiState.pageAiActiveSessionId === sessionId) {
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() : [];
}
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
async function pageAiResumeBackendSession(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return;
pageAiSetActiveSession(sessionId);
var response = await fetch('/api/hermes/client/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();
}
function pageAiProviderLabel(provider) {
if (provider === 'codex') return 'Codex';
if (provider === 'claudecode') return 'ClaudeCode';
@@ -4970,7 +5364,10 @@ const SIDEBAR_TREE_JS: &str = r##"
var sessionNode = drawer.querySelector('[data-page-ai-session-status]');
if (sessionNode instanceof HTMLElement) {
var session = pageAiCurrentSession();
sessionNode.textContent = session && session.id ? String(session.title || '') : ' Hermes session';
var usageText = session && session.usage ? pageAiUsageSummary(session.usage) : '';
sessionNode.textContent = session && session.id
? [String(session.title || ''), usageText].filter(Boolean).join(' · ')
: ' Hermes session';
}
var modelNode = drawer.querySelector('[data-page-ai-model-status]');
if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel();
@@ -5024,6 +5421,15 @@ const SIDEBAR_TREE_JS: &str = r##"
skillError.textContent = pageUiState.pageAiSkillError || '';
skillError.hidden = !pageUiState.pageAiSkillError;
}
var sessionError = drawer.querySelector('[data-page-ai-session-error]');
if (sessionError instanceof HTMLElement) {
sessionError.textContent = pageUiState.pageAiSessionError || '';
sessionError.hidden = !pageUiState.pageAiSessionError;
}
var sessionSearch = drawer.querySelector('[data-page-ai-session-search]');
if (sessionSearch instanceof HTMLInputElement && document.activeElement !== sessionSearch) {
sessionSearch.value = pageUiState.pageAiSessionSearchQuery || '';
}
var skillSearch = drawer.querySelector('[data-page-ai-skill-search]');
if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) {
skillSearch.value = pageUiState.pageAiSkillQuery;
@@ -5290,6 +5696,11 @@ const SIDEBAR_TREE_JS: &str = r##"
'<div class="wolai-page-ai-settings-head">' +
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat"> </button>' +
'</div>' +
'<label class="wolai-page-ai-skill-search">' +
'<span></span>' +
'<input type="search" data-page-ai-session-search placeholder="搜索后端 AI 会话…" />' +
'</label>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-session-error hidden></div>' +
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
'</section>' +
'</div>' +
@@ -5332,20 +5743,33 @@ const SIDEBAR_TREE_JS: &str = r##"
var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]');
if (!(conversation instanceof HTMLElement)) return;
if (pageUiState.pageAiPage === 'history') {
if (!pageUiState.pageAiSessions.length) {
var historyRows = pageUiState.pageAiSessionSearchResults.length
? pageUiState.pageAiSessionSearchResults
: pageUiState.pageAiSessions;
if (!historyRows.length) {
conversation.innerHTML = '<div class="wolai-page-ai-empty"></div>';
return;
}
conversation.innerHTML = pageUiState.pageAiSessions.map(function(session) {
conversation.innerHTML = historyRows.map(function(session) {
var preview = Array.isArray(session.messages) && session.messages.length
? session.messages.slice(-1)[0].content
: '';
: (session.snippet || session.preview || '');
var active = session.id === pageUiState.pageAiActiveSessionId;
var usage = pageAiUsageSummary(session.usage);
var meta = [session.source === 'acp' ? 'Convex' : '', session.status, usage].filter(Boolean).join(' · ');
return '' +
'<button type="button" class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session="' + escapeHtml(session.id) + '">' +
'<div class="wolai-page-ai-message-role"></div>' +
'<div class="wolai-page-ai-message-text"><strong>' + escapeHtml(session.title || '') + '</strong><br />' + escapeHtml(preview) + '</div>' +
'</button>';
'<div class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session-row="' + escapeHtml(session.id) + '">' +
'<button type="button" class="wolai-page-ai-message-text" data-page-ai-session="' + escapeHtml(session.id) + '">' +
'<strong>' + escapeHtml(session.title || '') + '</strong><br />' +
'<span>' + escapeHtml(preview) + '</span>' +
(meta ? '<br /><span class="wolai-page-ai-tool-meta">' + escapeHtml(meta) + '</span>' : '') +
'</button>' +
'<div class="wolai-page-ai-message-actions">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-resume="' + escapeHtml(session.id) + '"></button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-rename="' + escapeHtml(session.id) + '"></button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-session-delete="' + escapeHtml(session.id) + '"></button>' +
'</div>' +
'</div>';
}).join('');
return;
}
@@ -5376,6 +5800,30 @@ const SIDEBAR_TREE_JS: &str = r##"
'</div>' +
'</div>';
}
if (item.kind === 'thought') {
return '' +
'<details class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-thought-delta="true">' +
'<summary class="wolai-page-ai-message-role" style="cursor:pointer;user-select:none;opacity:0.7;font-size:0.85em"></summary>' +
'<div class="wolai-page-ai-message-text" style="margin-top:4px;opacity:0.6;font-size:0.9em;border-left:2px solid var(--wolai-border-color, #ddd);padding-left:8px">' + escapeHtml(item.content || '') + '</div>' +
'</details>';
}
if (item.kind === 'permission') {
var permissionActions = item.resolved ? '' : (
'<div class="wolai-page-ai-message-actions">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '"></button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '"></button>' +
'</div>'
);
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-permission-request="' + escapeHtml(item.permissionId || '') + '">' +
'<div class="wolai-page-ai-message-role"></div>' +
'<div class="wolai-page-ai-message-text">' +
'<strong>' + escapeHtml(item.toolName || 'session/request_permission') + '</strong><br />' +
'<span>' + escapeHtml(item.argsSummary || item.content || '') + '</span>' +
permissionActions +
'</div>' +
'</div>';
}
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
@@ -5401,7 +5849,8 @@ const SIDEBAR_TREE_JS: &str = r##"
pageAiLoadProfileMemory(),
pageAiLoadSkills(),
pageAiLoadTools(),
pageAiLoadGatewayHealth()
pageAiLoadGatewayHealth(),
pageAiLoadBackendSessions()
]).then(function() {
renderPageAiControls();
}).catch(function() {}).then(function() {
@@ -5687,10 +6136,54 @@ const SIDEBAR_TREE_JS: &str = r##"
if (pageUiState.pageAiStoppedRunIds[runId]) return;
assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText));
}
if (eventName === 'thought.delta') {
try {
var thoughtPayload = JSON.parse(payloadText || 'null');
var thoughtText = String((thoughtPayload && (thoughtPayload.delta || thoughtPayload.text)) || '');
if (thoughtText) {
var msgs = pageUiState.pageAiMessages;
var lastThought = msgs.length > 0 && msgs[msgs.length - 1].kind === 'thought' ? msgs[msgs.length - 1] : null;
if (lastThought) {
lastThought.content += thoughtText;
} else {
msgs.push({ role: 'assistant', kind: 'thought', content: thoughtText });
}
pageAiSyncCurrentSessionMessages();
pageAiPersistSessions();
renderPageAiConversation();
}
} catch (_) {}
}
if (eventName === 'usage.updated') {
try {
var usagePayload = JSON.parse(payloadText || 'null') || {};
var sessionForUsage = pageAiCurrentSession();
if (sessionForUsage) {
sessionForUsage.usage = {
source: 'usage_update',
used: Number(usagePayload.used ?? usagePayload.contextUsed ?? 0),
size: Number(usagePayload.size ?? usagePayload.contextSize ?? 0)
};
pageAiPersistSessions();
}
} catch (_) {}
renderPageAiControls();
}
if (eventName === 'permission.requested' || eventName === 'permission.denied' || eventName === 'permission.allowed') {
pageAiApplyPermissionEvent(eventName, payloadText);
pageAiSyncCurrentSessionMessages();
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
if (eventName === 'run.completed') {
try {
var completed = JSON.parse(payloadText || 'null');
if (completed && completed.output) assistantText = String(completed.output || '');
if (completed && completed.usage) {
var completedSession = pageAiCurrentSession();
if (completedSession) completedSession.usage = completed.usage;
}
} catch (_) {}
pageAiSetRunStatus('completed', runId);
}
@@ -6181,6 +6674,13 @@ const SIDEBAR_TREE_JS: &str = r##"
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
if (activeTreeContextMenu) closeTreeContextMenu();
var sidebarToggle = closestAction(e.target, '[data-mnote-action="toggle-sidebar"]');
if (sidebarToggle) {
e.preventDefault();
toggleWorkspaceSidebar(sidebarToggle);
return;
}
var attachmentAction = closestAction(e.target, '[data-attachment-action]');
if (attachmentAction) {
e.preventDefault();
@@ -6362,6 +6862,46 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
var pageAiSessionResume = closestAction(e.target, '[data-page-ai-session-resume]');
if (pageAiSessionResume) {
e.preventDefault();
void pageAiResumeBackendSession(pageAiSessionResume.getAttribute('data-page-ai-session-resume') || '').catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
var pageAiSessionRename = closestAction(e.target, '[data-page-ai-session-rename]');
if (pageAiSessionRename) {
e.preventDefault();
void pageAiRenameBackendSession(pageAiSessionRename.getAttribute('data-page-ai-session-rename') || '').catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
var pageAiSessionDelete = closestAction(e.target, '[data-page-ai-session-delete]');
if (pageAiSessionDelete) {
e.preventDefault();
void pageAiDeleteBackendSession(pageAiSessionDelete.getAttribute('data-page-ai-session-delete') || '').catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
var pageAiPermissionAction = closestAction(e.target, '[data-page-ai-permission-action]');
if (pageAiPermissionAction) {
e.preventDefault();
pageAiResolvePermission(
pageAiPermissionAction.getAttribute('data-page-ai-permission-id') || '',
pageAiPermissionAction.getAttribute('data-page-ai-permission-action') || 'deny'
);
return;
}
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
if (pageAiSession) {
e.preventDefault();
@@ -6396,6 +6936,12 @@ const SIDEBAR_TREE_JS: &str = r##"
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
renderPageAiControls();
renderPageAiConversation();
if (pageUiState.pageAiPage === 'history') {
void pageAiLoadBackendSessions().catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}
return;
}
@@ -6661,6 +7207,18 @@ const SIDEBAR_TREE_JS: &str = r##"
renderPageAiControls();
return;
}
var sessionSearch = closestAction(event.target, '[data-page-ai-session-search]');
if (sessionSearch instanceof HTMLInputElement) {
var sessionQuery = sessionSearch.value;
window.clearTimeout(pageUiState.pageAiSessionSearchTimer || 0);
pageUiState.pageAiSessionSearchTimer = window.setTimeout(function() {
void pageAiSearchBackendSessions(sessionQuery).catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}, 200);
return;
}
var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]');
if (memoryEditor instanceof HTMLTextAreaElement) {
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
@@ -6681,6 +7239,9 @@ const SIDEBAR_TREE_JS: &str = r##"
void pageAiLoadProfiles();
if (next !== 'reasonix') void pageAiLoadProfileMemory();
void pageAiLoadSkills();
void pageAiLoadBackendSessions().catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
});
renderPageAiControls();
renderPageAiProviderButtons();
return;
@@ -7343,7 +7904,7 @@ pub fn PageLayout(
<div class="mnote-main">
<header class="wolai-topbar" data-testid="wolai-topbar">
<div class="wolai-topbar-left">
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button wolai-menu-button" aria-label="切换侧栏" aria-pressed="false" data-mnote-action="toggle-sidebar" data-testid="wolai-sidebar-toggle"><span class="material-symbols-outlined" data-icon="menu" aria-hidden="true"></span></button>
<nav class="wolai-breadcrumb" aria-label="页面路径">
<span class="wolai-breadcrumb-root">{ws_name.clone()}</span>
<span class="wolai-breadcrumb-separator" aria-hidden="true">""</span>
@@ -7445,6 +8006,59 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root"));
}
#[test]
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
assert!(
!SIDEBAR_TREE_JS.contains("pageSubtreeSource: 'local'"),
"页面 AI context 不能从编辑器 DOM 派生 local page subtree;必须以 Rust Page Aggregate projection 为准"
);
assert!(
!SIDEBAR_TREE_JS.contains("buildPageAiLocalSubtree(localBlocks"),
"页面 AI context 不应调用本地 page subtree builder"
);
}
#[test]
fn page_ai_uses_backend_acp_session_runtime_store() {
assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessions"));
assert!(SIDEBAR_TREE_JS.contains("/api/hermes/client/sessions?"));
assert!(SIDEBAR_TREE_JS.contains("params.set('source', 'acp')"));
assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessionDetail"));
assert!(SIDEBAR_TREE_JS.contains("function pageAiSearchBackendSessions"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-rename"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-delete"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-resume"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-session-search"));
assert!(SIDEBAR_TREE_JS.contains("pageAiUsageSummary"));
assert!(SIDEBAR_TREE_JS.contains("usage.updated"));
assert!(SIDEBAR_TREE_JS.contains("thought.delta"));
assert!(SIDEBAR_TREE_JS.contains("permission.requested"));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"allow\""));
assert!(SIDEBAR_TREE_JS.contains("data-page-ai-permission-action=\"deny\""));
assert!(SIDEBAR_TREE_JS.contains("function pageAiHidePermissionDialog"));
assert!(SIDEBAR_TREE_JS.contains("if (!message.resolved)"));
assert!(
!SIDEBAR_TREE_JS.contains("(item.resolved ? ' disabled' : '')"),
"已决 ACP permission 事件不能继续展示假审批按钮"
);
}
#[test]
fn sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector() {
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
assert!(!SIDEBAR_TREE_JS.contains("usePreferredSidebarSnapshot"));
assert!(!SIDEBAR_TREE_JS.contains("preferredSidebarSnapshot"));
assert!(!SIDEBAR_TREE_JS.contains("liveSidebarTitle"));
}
#[test]
fn page_layout_sidebar_toggle_is_wired_to_shell_state() {
assert!(SIDEBAR_TREE_JS.contains("function toggleWorkspaceSidebar"));
assert!(SIDEBAR_TREE_JS.contains("data-mnote-sidebar-collapsed"));
assert!(SIDEBAR_TREE_JS.contains("[data-mnote-action=\"toggle-sidebar\"]"));
}
#[test]
fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() {
assert!(
@@ -7593,6 +8207,9 @@ mod tests {
#[test]
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-ws"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("new WebSocket(url.toString())"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("startWithSseFallback"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
+41 -3
View File
@@ -224,6 +224,11 @@ a:hover {
background: var(--wolai-bg);
}
.mnote-shell[data-mnote-sidebar-collapsed="true"] .mnote-sidebar,
.mnote-shell[data-mnote-sidebar-collapsed="true"] .wolai-sidebar {
display: none;
}
.wolai-workspace-shell {
--wolai-bg-sidebar: #F7F7F6;
--wolai-bg-selected: #F8E6E7;
@@ -3376,7 +3381,6 @@ body {
width: 100%;
border: 1px solid rgba(27, 28, 28, 0.08);
text-align: left;
cursor: pointer;
}
.wolai-page-ai-message--history.is-active {
@@ -3399,6 +3403,39 @@ body {
white-space: pre-wrap;
}
button.wolai-page-ai-message-text {
width: 100%;
border: 0;
padding: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.wolai-page-ai-message-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wolai-page-ai-permission-dialog {
position: fixed;
right: 28px;
bottom: 28px;
z-index: 80;
max-width: min(360px, calc(100vw - 32px));
}
.wolai-page-ai-permission-panel {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 8px;
background: #FFFFFF;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
}
.wolai-page-ai-tool-details {
display: grid;
gap: 6px;
@@ -3670,6 +3707,7 @@ mod tests {
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
assert!(MNOTE_CSS.contains(".mnote-main"));
assert!(MNOTE_CSS.contains(".mnote-content"));
assert!(MNOTE_CSS.contains("[data-mnote-sidebar-collapsed=\"true\"]"));
}
#[test]
@@ -3713,7 +3751,7 @@ mod tests {
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 70000);
// 当前整合了工作区壳、编辑器样式、树菜单、页面 AI 和双 pane 布局,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 90000);
}
}
@@ -83,8 +83,14 @@ fn render_filetree_row(
} else {
String::new()
};
let command_document_id = match row.row_kind.as_str() {
"document" | "markdown" => row.document_id.as_deref().unwrap_or(&row.node_id),
"index" => row.node_id.strip_prefix("index:").unwrap_or(&row.node_id),
_ => "",
};
let owner_document_id = row.document_id.as_deref().unwrap_or_default();
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}"{parent_attr} data-document-id="{document_id}" data-doc-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}" data-object-identity="{object_identity}" data-shell-mode="filetree" data-selected="{selected}" data-active="false" draggable="true">{toggle_html}<span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}" data-document-id="{document_id}" data-owner-document-id="{owner_document_id}" data-asset-id="{asset_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions">{create_action_html}<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
@@ -92,7 +98,8 @@ fn render_filetree_row(
row_id = escape_html(&row.row_id),
row_kind = escape_html(&row.row_kind),
parent_attr = parent_attr,
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
document_id = escape_html(command_document_id),
owner_document_id = escape_html(owner_document_id),
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()),
selected = row.selected,
@@ -208,6 +215,7 @@ mod tests {
assert!(!html.contains("data-testid=\"filetree-index-row\""));
assert!(!html.contains("index.md"));
assert!(html.contains("data-doc-id=\"page_root\""));
assert!(html.contains("data-row-id=\"asset:mind_1\" data-row-kind=\"asset\" data-node-id=\"asset:mind_1\" data-parent-id=\"page_root\" data-document-id=\"\" data-doc-id=\"\" data-owner-document-id=\"page_root\" data-asset-id=\"mind_1\""));
assert!(html.contains("data-asset-id=\"mind_1\""));
assert!(html.contains("data-object-identity=\"{&quot;objectKind&quot;:&quot;mindmap&quot;"));
assert!(html.contains("tree-children"));