1.0 mvp
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user