feat(page-ai): align ACP session runtime
This commit is contained in:
@@ -1359,6 +1359,7 @@ export function createSidebarPageAiRenderRuntime(context) {
|
||||
'<div class="wolai-page-ai-message-actions wolai-page-ai-history-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-export="' + escapeHtml(session.id) + '">导出</button>' +
|
||||
'<button type="button" class="wolai-page-ai-ghost wolai-page-ai-ghost--danger" data-page-ai-session-delete="' + escapeHtml(session.id) + '">删除</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
@@ -494,6 +494,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
const pageAiSetActiveSession = (...args) => pageAiSessionRuntime.pageAiSetActiveSession(...args);
|
||||
const pageAiStartNewSession = (...args) => pageAiSessionRuntime.pageAiStartNewSession(...args);
|
||||
const pageAiRenameBackendSession = (...args) => pageAiSessionRuntime.pageAiRenameBackendSession(...args);
|
||||
const pageAiExportBackendSession = (...args) => pageAiSessionRuntime.pageAiExportBackendSession(...args);
|
||||
const pageAiDeleteBackendSession = (...args) => pageAiSessionRuntime.pageAiDeleteBackendSession(...args);
|
||||
const pageAiDeleteSelectedBackendSessions = (...args) => pageAiSessionRuntime.pageAiDeleteSelectedBackendSessions(...args);
|
||||
const pageAiResumeBackendSession = (...args) => pageAiSessionRuntime.pageAiResumeBackendSession(...args);
|
||||
@@ -1380,6 +1381,12 @@ export function createSidebarPageAiRuntime(context) {
|
||||
pageAiSetActiveSession(sessionId);
|
||||
}
|
||||
if (run) await pageAiResumeActiveRunJournal(run);
|
||||
if (!run && snapshot && snapshot.hostRunId) {
|
||||
pageAiStopStreamingAssistantMessage(snapshot.hostRunId);
|
||||
pageAiClearActiveRunSnapshot(snapshot.hostRunId);
|
||||
pageUiState.pageAiBusy = false;
|
||||
pageAiSetRunStatus('idle', snapshot.hostRunId);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
@@ -2475,6 +2482,21 @@ export function createSidebarPageAiRuntime(context) {
|
||||
void pageAiAppendKnowledgeRagFallbackCitations(id, promptText);
|
||||
}
|
||||
|
||||
function pageAiStopStreamingAssistantMessage(runId) {
|
||||
var id = String(runId || pageUiState.pageAiCurrentRunId || '');
|
||||
var changed = false;
|
||||
pageUiState.pageAiMessages.forEach(function(message) {
|
||||
if (message && message.role === 'assistant' && message.streaming === true && (!id || message.runId === id)) {
|
||||
message.streaming = false;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (!changed) return;
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
function pageAiToolOutputText(value) {
|
||||
var parts = [];
|
||||
function visit(node) {
|
||||
@@ -2612,6 +2634,10 @@ export function createSidebarPageAiRuntime(context) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
var eventName = String(event.kind || event.eventType || event.event || '').trim();
|
||||
var payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
|
||||
if (payload.source === 'adapter_replay' || payload.replay === true) {
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-adapter-replay-seen', 'true');
|
||||
return;
|
||||
}
|
||||
var payloadText = pageAiJournalPayloadText(event);
|
||||
var prompt = pageAiLastUserPrompt();
|
||||
if (eventName === 'message.delta') {
|
||||
@@ -2624,6 +2650,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
pageAiSetRunStatus('failed', runId);
|
||||
} else if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') {
|
||||
pageUiState.pageAiStoppedRunIds[runId] = true;
|
||||
pageAiStopStreamingAssistantMessage(runId);
|
||||
pageAiSetRunStatus('aborted', runId);
|
||||
} else if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
|
||||
var toolItem = pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId);
|
||||
@@ -2896,6 +2923,14 @@ export function createSidebarPageAiRuntime(context) {
|
||||
var autoCitations = [];
|
||||
await streamPageAiResponse(eventResponse, function(eventName, payloadText) {
|
||||
pageAiTrackStreamingRunEvent(runId, eventName, payloadText);
|
||||
var streamPayload = null;
|
||||
try {
|
||||
streamPayload = JSON.parse(payloadText || 'null');
|
||||
} catch (_) {}
|
||||
if (streamPayload && (streamPayload.source === 'adapter_replay' || streamPayload.replay === true)) {
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-adapter-replay-seen', 'true');
|
||||
return;
|
||||
}
|
||||
if (eventName === 'message.delta') {
|
||||
if (pageUiState.pageAiStoppedRunIds[runId]) return;
|
||||
assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText));
|
||||
@@ -2995,6 +3030,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
}
|
||||
if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') {
|
||||
pageUiState.pageAiStoppedRunIds[runId] = true;
|
||||
pageAiStopStreamingAssistantMessage(runId);
|
||||
pageAiSetRunStatus('aborted', runId);
|
||||
}
|
||||
if (eventName === 'session.info.updated') {
|
||||
@@ -3294,6 +3330,15 @@ export function createSidebarPageAiRuntime(context) {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
var pageAiSessionExport = closestAction(event.target, '[data-page-ai-session-export]');
|
||||
if (pageAiSessionExport) {
|
||||
event.preventDefault();
|
||||
void pageAiExportBackendSession(pageAiSessionExport.getAttribute('data-page-ai-session-export') || '').catch(function(error) {
|
||||
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
|
||||
renderPageAiControls();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
var pageAiSessionDelete = closestAction(event.target, '[data-page-ai-session-delete]');
|
||||
if (pageAiSessionDelete) {
|
||||
event.preventDefault();
|
||||
@@ -3501,6 +3546,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
pageAiToggleTool: (...args) => pageAiToggleTool(...args),
|
||||
pageAiResumeBackendSession: (...args) => pageAiResumeBackendSession(...args),
|
||||
pageAiRenameBackendSession: (...args) => pageAiRenameBackendSession(...args),
|
||||
pageAiExportBackendSession: (...args) => pageAiExportBackendSession(...args),
|
||||
pageAiDeleteBackendSession: (...args) => pageAiDeleteBackendSession(...args),
|
||||
pageAiResolvePermission: (...args) => pageAiResolvePermission(...args),
|
||||
pageAiOpenLocation: (...args) => pageAiOpenLocation(...args),
|
||||
|
||||
@@ -255,6 +255,7 @@ export function createSidebarPageAiSessionRuntime(context) {
|
||||
if (!event || typeof event !== 'object') return null;
|
||||
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
|
||||
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
|
||||
if (payload && (payload.source === 'adapter_replay' || payload.replay === true)) return null;
|
||||
if (eventType === 'message.delta') {
|
||||
var delta = String(payload.delta || payload.text || payload.output_text || '');
|
||||
return delta ? { role: 'assistant', content: delta } : null;
|
||||
@@ -410,6 +411,30 @@ export function createSidebarPageAiSessionRuntime(context) {
|
||||
return pageUiState.pageAiSessionSearchResults;
|
||||
}
|
||||
|
||||
async function pageAiExportBackendSession(sessionId) {
|
||||
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
|
||||
if (!sessionId) return null;
|
||||
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/export?' + pageAiBackendSessionQuery({ limit: 200 }), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(pageAiErrorMessage(payload, 'session_export_failed_' + response.status));
|
||||
}
|
||||
var exported = payload.export && typeof payload.export === 'object' ? payload.export : {};
|
||||
var session = pageAiFindSessionById(sessionId) || pageAiCurrentSession();
|
||||
if (session) {
|
||||
session.exportedAt = Date.now();
|
||||
session.exportMarkdown = String(exported.markdown || '');
|
||||
session.updatedAt = Date.now();
|
||||
}
|
||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-exported', sessionId);
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return payload;
|
||||
}
|
||||
|
||||
function pageAiPersistSessions() {
|
||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
|
||||
doc.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
|
||||
@@ -621,8 +646,15 @@ export function createSidebarPageAiSessionRuntime(context) {
|
||||
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();
|
||||
var nextTitle = String((payload.result && payload.result.title) || title);
|
||||
[pageUiState.pageAiSessions, pageUiState.pageAiSessionSearchResults].forEach(function(list) {
|
||||
pageAiNormalizeArray(list).forEach(function(item) {
|
||||
if (String(item && item.id || '').trim() === sessionId) {
|
||||
item.title = nextTitle;
|
||||
item.updatedAt = Date.now();
|
||||
}
|
||||
});
|
||||
});
|
||||
pageAiPersistSessions();
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
@@ -784,6 +816,7 @@ export function createSidebarPageAiSessionRuntime(context) {
|
||||
pageAiSetActiveSession,
|
||||
pageAiStartNewSession,
|
||||
pageAiRenameBackendSession,
|
||||
pageAiExportBackendSession,
|
||||
pageAiDeleteBackendSession,
|
||||
pageAiDeleteSelectedBackendSessions,
|
||||
pageAiResumeBackendSession,
|
||||
|
||||
@@ -1159,6 +1159,158 @@ rl.on('line', (line) => {
|
||||
Arc::new(client)
|
||||
}
|
||||
|
||||
/// Creates a Hermes-like ACP mock where session/load succeeds and replays
|
||||
/// history as session/update before the load response completes.
|
||||
async fn spawn_mock_hermes_load_replay_acp() -> Arc<AcpClient> {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (!msg.id) return;
|
||||
if (msg.method === 'initialize') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: { loadSession: true },
|
||||
agentInfo: { name: 'hermes-replay-mock', version: '1.0' },
|
||||
authMethods: []
|
||||
}
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/load') {
|
||||
const sessionId = msg.params?.sessionId || 'loaded_session_1';
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'REPLAYED_HISTORY_BEFORE_LOAD_RETURN' }
|
||||
}
|
||||
}
|
||||
}) + '\n');
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { sessionId }
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/new') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { sessionId: 'new_session_should_not_be_used' }
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/prompt') {
|
||||
const sessionId = msg.params?.sessionId || 'loaded_session_1';
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'CURRENT_PROMPT_AFTER_LOAD' }
|
||||
}
|
||||
}
|
||||
}) + '\n');
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { stopReason: 'end_turn' }
|
||||
}) + '\n');
|
||||
}
|
||||
});
|
||||
"#;
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_session_hermes_replay_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write hermes replay mock");
|
||||
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn hermes replay mock");
|
||||
Arc::new(client)
|
||||
}
|
||||
|
||||
/// Creates a Reasonix-like ACP mock: no session/load support, context lives
|
||||
/// in the live in-process session map and is visible on the next prompt.
|
||||
async fn spawn_mock_reasonix_live_acp() -> Arc<AcpClient> {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
const sessions = new Map();
|
||||
let nextId = 1;
|
||||
function textFromPrompt(prompt) {
|
||||
return (Array.isArray(prompt) ? prompt : [])
|
||||
.map((block) => block && block.type === 'text' ? String(block.text || '') : '')
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (!msg.id) return;
|
||||
if (msg.method === 'initialize') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } },
|
||||
agentInfo: { name: 'reasonix-live-mock', version: '1.0' },
|
||||
authMethods: []
|
||||
}
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/new') {
|
||||
const sessionId = `reasonix_session_${nextId++}`;
|
||||
sessions.set(sessionId, []);
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { sessionId }
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/load') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
error: { code: -32601, message: 'session/load not supported' }
|
||||
}) + '\n');
|
||||
} else if (msg.method === 'session/prompt') {
|
||||
const sessionId = msg.params?.sessionId || '';
|
||||
const history = sessions.get(sessionId);
|
||||
if (!history) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
error: { code: -32602, message: `unknown session ${sessionId}` }
|
||||
}) + '\n');
|
||||
return;
|
||||
}
|
||||
const text = textFromPrompt(msg.params?.prompt);
|
||||
const previous = history.length ? history[history.length - 1] : '';
|
||||
history.push(text);
|
||||
const answer = previous ? `previous=${previous}; current=${text}` : `current=${text}`;
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: answer }
|
||||
}
|
||||
}
|
||||
}) + '\n');
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', id: msg.id,
|
||||
result: { stopReason: 'end_turn' }
|
||||
}) + '\n');
|
||||
}
|
||||
});
|
||||
"#;
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_session_reasonix_live_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write reasonix mock");
|
||||
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn reasonix mock");
|
||||
Arc::new(client)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_session_success() {
|
||||
let client = spawn_mock_acp_with_load().await;
|
||||
@@ -1271,6 +1423,74 @@ rl.on('line', (line) => {
|
||||
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reasonix_live_session_keeps_short_reply_context() {
|
||||
let client = spawn_mock_reasonix_live_acp().await;
|
||||
let mgr = AcpSessionManager::new(client);
|
||||
let chunks = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let chunks_for_handler = chunks.clone();
|
||||
mgr.on_event(move |event| {
|
||||
if let AcpSessionEvent::TextDelta { text, .. } = event {
|
||||
chunks_for_handler.lock().unwrap().push(text);
|
||||
}
|
||||
});
|
||||
let session_id = mgr
|
||||
.create_session(Some("/test"), None)
|
||||
.await
|
||||
.expect("create reasonix session");
|
||||
assert_eq!(session_id, "reasonix_session_1");
|
||||
|
||||
mgr.run_prompt(vec![ContentBlock::Text {
|
||||
text: "上一轮计划:检查 LightRAG 服务".into(),
|
||||
}])
|
||||
.await
|
||||
.expect("first prompt");
|
||||
mgr.run_prompt(vec![ContentBlock::Text {
|
||||
text: "可以".into(),
|
||||
}])
|
||||
.await
|
||||
.expect("second prompt");
|
||||
|
||||
let output = chunks.lock().unwrap().join("\n");
|
||||
assert!(
|
||||
output.contains("previous=上一轮计划:检查 LightRAG 服务; current=可以"),
|
||||
"Reasonix live mock should remember previous prompt, got: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hermes_load_replay_events_before_prompt() {
|
||||
let client = spawn_mock_hermes_load_replay_acp().await;
|
||||
let mgr = AcpSessionManager::new(client);
|
||||
let chunks = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let chunks_for_handler = chunks.clone();
|
||||
mgr.on_event(move |event| {
|
||||
if let AcpSessionEvent::TextDelta { text, .. } = event {
|
||||
chunks_for_handler.lock().unwrap().push(text);
|
||||
}
|
||||
});
|
||||
|
||||
let loaded = mgr
|
||||
.ensure_session(Some("/test"), Some("stored_hermes_session"))
|
||||
.await
|
||||
.expect("load stored Hermes session");
|
||||
assert_eq!(loaded, "stored_hermes_session");
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
let replayed = chunks.lock().unwrap().join("\n");
|
||||
assert!(
|
||||
replayed.contains("REPLAYED_HISTORY_BEFORE_LOAD_RETURN"),
|
||||
"Hermes load should replay history before prompt, got: {replayed}"
|
||||
);
|
||||
|
||||
mgr.run_prompt(vec![ContentBlock::Text {
|
||||
text: "current prompt".into(),
|
||||
}])
|
||||
.await
|
||||
.expect("prompt after load");
|
||||
let output = chunks.lock().unwrap().join("\n");
|
||||
assert!(output.contains("CURRENT_PROMPT_AFTER_LOAD"), "prompt output missing: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thought_chunk_maps_to_thought_delta() {
|
||||
let accumulated = Arc::new(Mutex::new(String::new()));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -679,6 +679,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/client/sessions/{session_id}/rename",
|
||||
post(hermes_client::rename_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/export",
|
||||
get(hermes_client::export_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/auto-title",
|
||||
post(hermes_client::auto_title_session),
|
||||
|
||||
@@ -5248,7 +5248,7 @@ html[data-mnote-page-ai-resizing="true"] {
|
||||
.wolai-page-ai-message--history {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
align-items: flex-start;
|
||||
@@ -5452,14 +5452,15 @@ button.wolai-page-ai-history-main span {
|
||||
}
|
||||
|
||||
.wolai-page-ai-history-actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
position: static;
|
||||
z-index: 2;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
background: inherit;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.wolai-page-ai-message--history:hover .wolai-page-ai-history-actions,
|
||||
|
||||
Reference in New Issue
Block a user