This commit is contained in:
lix-2026
2026-05-16 12:34:48 +08:00
parent f9336257a5
commit d8bfaea306
17 changed files with 2817 additions and 543 deletions
+26 -453
View File
@@ -433,51 +433,6 @@ const SPIKE_STYLE: &str = r#"
overflow: hidden;
}
.ai-bridge-status {
position: absolute;
right: 18px;
top: 14px;
z-index: 8;
display: inline-flex;
align-items: center;
gap: 6px;
max-width: min(320px, 42vw);
padding: 5px 8px;
border: 1px solid rgba(17, 24, 39, 0.12);
border-radius: 6px;
background: rgba(255, 255, 255, 0.94);
color: #374151;
font-size: 12px;
line-height: 16px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
}
.ai-bridge-status[data-state="pending"] {
border-color: rgba(37, 99, 235, 0.28);
color: #1d4ed8;
}
.ai-bridge-status[data-state="ready"] {
border-color: rgba(22, 163, 74, 0.28);
color: #15803d;
}
.ai-bridge-status[data-state="error"] {
border-color: rgba(220, 38, 38, 0.28);
color: #b91c1c;
}
.ai-bridge-status span {
font-weight: 700;
}
.ai-bridge-status strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.editor-topbar {
display: flex;
justify-content: space-between;
@@ -7816,157 +7771,18 @@ fn apply_ok_feedback<E>(
apply_feedback_result(setter, result.map(|_| ok_message));
}
fn selected_text_from_document(editor: TiptapEditorHandle) -> String {
window()
.and_then(|win| win.get_selection().ok().flatten())
.map(|selection| String::from(selection.to_string()))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
editor
.get_json()
.ok()
.map(|document| collect_plain_text(&document))
})
.unwrap_or_default()
}
fn ai_action_prompt(action: &str, selected_text: &str) -> String {
let instruction = match action {
"continue_writing" => "请基于当前块继续写作",
"summarize" => "请总结当前块内容",
"translate" => "请把当前块内容翻译为简体中文",
"improve" => "请改写并润色当前块内容",
_ => "请分析当前块并给出可用于编辑的建议",
fn open_hermes_page_ai_drawer() {
let Some(win) = window() else {
return;
};
if selected_text.trim().is_empty() {
instruction.to_string()
} else {
format!("{instruction}:\n\n{selected_text}")
}
}
fn build_ai_bridge_payload(
editor: TiptapEditorHandle,
action: &str,
document_id: Option<String>,
workspace_id: Option<String>,
selected_block_index: Option<usize>,
selected_block_id: Option<String>,
selection_state: TiptapSelectionState,
) -> Value {
let tiptap_document = editor.get_json().unwrap_or_else(
|err| json!({ "type": "error", "message": format!("读取 Tiptap JSON 失败:{err}") }),
);
let selection = serde_json::to_value(selection_state).unwrap_or(Value::Null);
let selected_text = selected_text_from_document(editor);
let selected_uids = selected_block_id
.as_ref()
.map(|block_id| vec![Value::String(block_id.clone())])
.unwrap_or_default();
json!({
"stream": true,
"scope": "document",
"messages": [{
"role": "user",
"content": ai_action_prompt(action, &selected_text),
}],
"maxSteps": 8,
"context": {
"source": "leptos-tiptap-island",
"action": action,
"documentId": document_id,
"workspaceId": workspace_id,
"selectedBlockIndex": selected_block_index,
"selectedBlockId": selected_block_id,
"selectedUids": selected_uids,
"selectedText": selected_text,
"selection": {
"currentBlockId": selected_block_id,
"state": selection,
},
"tiptapDocument": tiptap_document,
"documentBlocks": tiptap_document,
},
"options": {
"ai": {
"provider": "online",
},
},
})
}
async fn post_ai_bridge_request(payload: Value) -> Result<Value, String> {
let body_string = serde_json::to_string(&payload)
.map_err(|err| format!("序列化 AI bridge 请求失败:{err}"))?;
let request_init = RequestInit::new();
request_init.set_method("POST");
request_init.set_mode(RequestMode::SameOrigin);
request_init.set_body(&JsValue::from_str(&body_string));
let headers = js_sys::Object::new();
js_sys::Reflect::set(
&headers,
&JsValue::from_str("content-type"),
&JsValue::from_str("application/json"),
)
.map_err(|_| "设置 AI bridge 请求头失败".to_string())?;
js_sys::Reflect::set(
request_init.as_ref(),
&JsValue::from_str("headers"),
&headers,
)
.map_err(|_| "设置 AI bridge 请求头失败".to_string())?;
let win = window().ok_or_else(|| "当前浏览器窗口不可用".to_string())?;
let response_value =
JsFuture::from(win.fetch_with_str_and_init("/api/ai-agent/run", &request_init))
.await
.map_err(|err| format!("AI agent 请求失败:{err:?}"))?;
let response: Response = response_value
.dyn_into()
.map_err(|_| "AI agent 响应类型错误".to_string())?;
let status = response.status();
let content_type = response
.headers()
.get("content-type")
.ok()
.flatten()
.unwrap_or_default();
if content_type.contains("text/event-stream") {
let text_value = JsFuture::from(
response
.text()
.map_err(|_| "读取 AI agent 事件流失败".to_string())?,
)
.await
.map_err(|err| format!("解析 AI agent 事件流失败:{err:?}"))?;
let stream_text = text_value.as_string().unwrap_or_default();
if !response.ok() {
return Err(format!("AI agent 返回失败:HTTP {status}; {stream_text}"));
let Some(document) = win.document() else {
return;
};
if let Ok(Some(trigger)) = document.query_selector("[data-mnote-action=\"open-page-ai\"]") {
if let Some(button) = trigger.dyn_ref::<HtmlElement>() {
button.click();
}
return Ok(json!({
"ok": true,
"stream": stream_text,
"contract": {
"schema": "mnote.ai_agent.run.v1",
"structuredWriteOwner": "openai-agents-python",
},
}));
}
let json_value = JsFuture::from(
response
.json()
.map_err(|_| "读取 AI agent 响应失败".to_string())?,
)
.await
.map_err(|err| format!("解析 AI agent 响应失败:{err:?}"))?;
let parsed = serde_wasm_bindgen::from_value::<Value>(json_value).unwrap_or(Value::Null);
if !response.ok() {
return Err(format!("AI agent 返回失败:HTTP {status}; {parsed}"));
}
Ok(parsed)
}
fn request_ai_edit_bridge(
@@ -7986,261 +7802,26 @@ fn request_ai_edit_bridge(
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
) {
let persisted_identity = runtime_persisted_identity(document_id, workspace_id);
let payload = build_ai_bridge_payload(
let _ = (
editor,
action,
document_id.get_untracked(),
workspace_id.get_untracked(),
document_id,
workspace_id,
selected_block_index,
selected_block_id,
selection_state.get_untracked(),
selection_state,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
);
set_ai_bridge_state.set("pending".to_string());
set_ai_bridge_message.set("正在连接 mnote AI agent".to_string());
set_command_feedback.set("AI 请求已发送到 /api/ai-agent/run".to_string());
let _ = bump_mnote_e27_ai_bridge_request_count();
spawn_local(async move {
match post_ai_bridge_request(payload).await {
Ok(response) => {
let owner = response
.get("contract")
.and_then(|contract| contract.get("structuredWriteOwner"))
.and_then(Value::as_str)
.unwrap_or("rust-web-hermes");
match apply_ai_doc_write_response(
editor,
&response,
&persisted_identity,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
)
.await
{
Ok(true) => {
set_ai_bridge_state.set("ready".to_string());
set_ai_bridge_message.set(format!("AI agent 已写入:{owner}"));
}
Ok(false) => {
set_ai_bridge_state.set("ready".to_string());
set_ai_bridge_message.set(format!("AI agent 主路径已就绪:{owner}"));
set_command_feedback
.set("AI agent 已返回,未收到可应用的 doc 写入结果".to_string());
}
Err(err) => {
set_ai_bridge_state.set("error".to_string());
set_ai_bridge_message.set(err.clone());
set_command_feedback.set(err);
}
}
}
Err(err) => {
set_ai_bridge_state.set("error".to_string());
set_ai_bridge_message.set(err.clone());
set_command_feedback.set(err);
}
}
});
}
fn ai_legacy_block_text(block: &Value) -> String {
match block.get("content") {
Some(Value::String(text)) => text.clone(),
Some(Value::Array(children)) => children
.iter()
.map(|child| {
child
.get("text")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| collect_plain_text(child))
})
.collect::<Vec<_>>()
.join(""),
Some(other) => collect_plain_text(other),
None => String::new(),
}
}
fn ai_text_content(text: &str) -> Vec<Value> {
if text.is_empty() {
Vec::new()
} else {
vec![json!({ "type": "text", "text": text })]
}
}
fn ai_block_attrs(block: &Value) -> Option<Map<String, Value>> {
let mut attrs = Map::new();
let block_id = block
.get("id")
.or_else(|| block.get("blockId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(block_id) = block_id {
attrs.insert("blockId".to_string(), Value::String(block_id.to_string()));
}
Some(attrs).filter(|attrs| !attrs.is_empty())
}
fn ai_heading_attrs(block: &Value) -> Option<Map<String, Value>> {
let mut attrs = ai_block_attrs(block).unwrap_or_default();
let level = block
.get("level")
.or_else(|| block.get("props").and_then(|props| props.get("level")))
.or_else(|| block.get("attrs").and_then(|attrs| attrs.get("level")))
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 6);
attrs.insert("level".to_string(), json!(level));
Some(attrs)
}
fn ai_legacy_block_to_tiptap_node(block: &Value) -> Value {
let block_type = block
.get("type")
.and_then(Value::as_str)
.unwrap_or("paragraph");
let text = ai_legacy_block_text(block);
let inline = ai_text_content(&text);
match block_type {
"heading" | "header" => node_with_attrs("heading", ai_heading_attrs(block), inline),
"quote" | "blockquote" => node_with_attrs(
"blockquote",
ai_block_attrs(block),
vec![paragraph_node(inline)],
),
"code" | "codeBlock" => node_with_attrs("codeBlock", ai_block_attrs(block), inline),
"divider" | "horizontalRule" => {
node_with_attrs("horizontalRule", ai_block_attrs(block), Vec::new())
}
_ => node_with_attrs("paragraph", ai_block_attrs(block), inline),
}
}
fn ai_legacy_blocks_to_tiptap_document(blocks: &[Value]) -> Value {
json!({
"type": "doc",
"content": blocks.iter().map(ai_legacy_block_to_tiptap_node).collect::<Vec<_>>(),
})
}
fn ai_sse_tool_result_payloads(stream_text: &str) -> Vec<Value> {
let mut results = Vec::new();
let mut current_event = String::new();
let mut data_lines: Vec<String> = Vec::new();
let mut flush = |event: &mut String, lines: &mut Vec<String>| {
if event == "tool_result" && !lines.is_empty() {
let raw = lines.join("\n");
if let Ok(value) = serde_json::from_str::<Value>(&raw) {
results.push(value);
}
}
event.clear();
lines.clear();
};
for line in stream_text.lines() {
if line.trim().is_empty() {
flush(&mut current_event, &mut data_lines);
continue;
}
if let Some(event) = line.strip_prefix("event:") {
current_event = event.trim().to_string();
continue;
}
if let Some(data) = line.strip_prefix("data:") {
data_lines.push(data.trim_start().to_string());
}
}
flush(&mut current_event, &mut data_lines);
results
}
async fn persist_ai_doc_write_blocks(
persisted_identity: &PersistedDocumentIdentity,
blocks: &[Value],
tiptap_document: &Value,
) -> Result<(), String> {
let document_id = persisted_identity
.document_id
.clone()
.ok_or_else(|| "AI 写入缺少 documentId,无法保存".to_string())?;
let workspace_id = persisted_identity
.workspace_id
.clone()
.ok_or_else(|| "AI 写入缺少 workspaceId,无法保存".to_string())?;
let payload = json!({
"documentId": document_id,
"workspaceId": workspace_id,
"content": blocks,
"tiptapDocument": tiptap_document,
"blockCount": blocks.len(),
"snapshotCapturedAt": js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default(),
});
let body =
serde_json::to_string(&payload).map_err(|err| format!("序列化 AI 保存请求失败:{err}"))?;
post_mnote_document_save(body)
.await
.map_err(|err| format!("AI 写入保存失败:{err:?}"))?;
Ok(())
}
async fn apply_ai_doc_write_response(
editor: TiptapEditorHandle,
response: &Value,
persisted_identity: &PersistedDocumentIdentity,
set_dirty_count: WriteSignal<u32>,
set_html_output: WriteSignal<String>,
set_document_json: WriteSignal<Value>,
set_json_output: WriteSignal<String>,
title: ReadSignal<String>,
set_command_feedback: WriteSignal<String>,
) -> Result<bool, String> {
let Some(stream_text) = response.get("stream").and_then(Value::as_str) else {
return Ok(false);
};
for payload in ai_sse_tool_result_payloads(stream_text) {
let tool = payload
.get("tool")
.and_then(Value::as_str)
.unwrap_or_default();
let ok = payload.get("ok").and_then(Value::as_bool).unwrap_or(false);
if !ok || !(tool == "doc_insert_blocks" || tool == "doc_replace_range") {
continue;
}
let Some(blocks) = payload
.get("result")
.and_then(|result| result.get("data"))
.and_then(Value::as_array)
else {
continue;
};
let next_document = ai_legacy_blocks_to_tiptap_document(blocks);
persist_ai_doc_write_blocks(persisted_identity, blocks, &next_document).await?;
apply_document_update(
editor,
persisted_identity,
next_document,
set_dirty_count,
set_html_output,
set_document_json,
set_json_output,
title,
set_command_feedback,
"AI 已写入当前页面",
);
return Ok(true);
}
Ok(false)
let _ = (
set_ai_bridge_state,
set_ai_bridge_message,
set_command_feedback,
);
open_hermes_page_ai_drawer();
}
fn normalize_layout_density(value: Option<String>) -> String {
@@ -8366,8 +7947,8 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let (block_menu_open, set_block_menu_open) = signal(false);
let (block_turn_into_open, set_block_turn_into_open) = signal(false);
let (block_folded_title_open, set_block_folded_title_open) = signal(false);
let (ai_bridge_state, set_ai_bridge_state) = signal("idle".to_string());
let (ai_bridge_message, set_ai_bridge_message) = signal("AI bridge 未连接".to_string());
let (_ai_bridge_state, set_ai_bridge_state) = signal("idle".to_string());
let (_ai_bridge_message, set_ai_bridge_message) = signal("".to_string());
let (pending_drag, set_pending_drag) = signal(None::<PendingDragState>);
let (dragging_block_index, set_dragging_block_index) = signal(None::<usize>);
let (dragging_block_anchor, set_dragging_block_anchor) = signal(None::<HoveredBlockState>);
@@ -9320,14 +8901,6 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}}
<section class="editor-card">
<div
class="ai-bridge-status"
data-testid="mnote-leptos-tiptap-ai-status"
data-state=move || ai_bridge_state.get()
>
<span>"AI"</span>
<strong>{move || ai_bridge_message.get()}</strong>
</div>
{move || {
if is_embedded {
().into_any()