fix: restore AI history and tool receipts

This commit is contained in:
冰朔 2026-07-22 14:33:54 +08:00
parent f141317442
commit 2dd29502a6
3 changed files with 155 additions and 77 deletions

View File

@ -49,11 +49,20 @@ struct OpenAiToolCall {
raw_arguments: String,
}
struct CreatedNoteToolResult {
struct OpenAiToolResult {
summary: String,
output: String,
}
impl OpenAiToolResult {
fn from_summary(summary: String) -> Self {
Self {
output: summary.clone(),
summary,
}
}
}
pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json::Value {
let offers_tools = should_offer_openai_tools(request);
let mut payload = serde_json::json!({
@ -204,12 +213,14 @@ where
{
let mut summaries = Vec::new();
for tool_call in tool_calls {
summaries.push(execute_openai_tool_call(request, tool_call, &mut emit)?);
summaries.push(execute_openai_tool_call_with_events(
request, tool_call, &mut emit,
)?);
}
Ok(summaries.join("\n"))
}
fn execute_openai_tool_call<F>(
fn execute_openai_tool_call_with_events<F>(
request: &AiModelStreamRequest,
tool_call: &OpenAiToolCall,
emit: &mut F,
@ -217,44 +228,13 @@ fn execute_openai_tool_call<F>(
where
F: FnMut(AiAgentStreamEvent),
{
if tool_call.name == SEARCH_NOTES_TOOL_NAME {
return search_notes_from_tool_args(request, &tool_call.arguments);
}
if tool_call.name == FIFTH_DOMAIN_WAKE_TOOL_NAME {
return Ok(FIFTH_DOMAIN_WAKE_SKILL.to_string());
}
if tool_call.name == GET_VAULT_CONTEXT_TOOL_NAME {
return vault_context_from_tool_args(request);
}
if tool_call.name == GET_NOTE_TOOL_NAME {
return get_note_from_tool_args(request, &tool_call.arguments);
}
if tool_call.name == READ_GUANGHU_URL_TOOL_NAME {
return read_guanghu_url_from_tool_args(&tool_call.arguments);
}
if tool_call.name == EDIT_NOTE_TOOL_NAME {
return edit_note_from_tool_args(request, &tool_call.arguments);
}
if tool_call.name == DELETE_NOTE_TOOL_NAME {
return delete_note_from_tool_args(request, &tool_call.arguments);
}
if tool_call.name == MAGIC_BRUSH_TOOL_NAME {
return run_magic_brush_from_args(request, &tool_call.arguments, emit);
}
if tool_call.name != CREATE_NOTE_TOOL_NAME {
return Ok(format!(
"当前聊天模式不支持工具:{}。未访问或修改任何文件。",
tool_call.name
));
}
emit(AiAgentStreamEvent::ToolStart {
tool_name: CREATE_NOTE_TOOL_NAME.into(),
tool_name: tool_call.name.clone(),
tool_id: tool_call.id.clone(),
input: Some(tool_call.raw_arguments.clone()),
});
match create_note_from_tool_args(request, &tool_call.arguments) {
match execute_openai_tool_call(request, tool_call, emit) {
Ok(result) => {
emit(AiAgentStreamEvent::ToolDone {
tool_id: tool_call.id.clone(),
@ -272,6 +252,29 @@ where
}
}
fn execute_openai_tool_call<F>(
request: &AiModelStreamRequest,
tool_call: &OpenAiToolCall,
emit: &mut F,
) -> Result<OpenAiToolResult, String>
where
F: FnMut(AiAgentStreamEvent),
{
let summary = match tool_call.name.as_str() {
SEARCH_NOTES_TOOL_NAME => search_notes_from_tool_args(request, &tool_call.arguments)?,
FIFTH_DOMAIN_WAKE_TOOL_NAME => FIFTH_DOMAIN_WAKE_SKILL.to_string(),
GET_VAULT_CONTEXT_TOOL_NAME => vault_context_from_tool_args(request)?,
GET_NOTE_TOOL_NAME => get_note_from_tool_args(request, &tool_call.arguments)?,
READ_GUANGHU_URL_TOOL_NAME => read_guanghu_url_from_tool_args(&tool_call.arguments)?,
EDIT_NOTE_TOOL_NAME => edit_note_from_tool_args(request, &tool_call.arguments)?,
DELETE_NOTE_TOOL_NAME => delete_note_from_tool_args(request, &tool_call.arguments)?,
MAGIC_BRUSH_TOOL_NAME => run_magic_brush_from_args(request, &tool_call.arguments, emit)?,
CREATE_NOTE_TOOL_NAME => return create_note_from_tool_args(request, &tool_call.arguments),
unsupported => format!("当前聊天模式不支持工具:{unsupported}。未访问或修改任何文件。"),
};
Ok(OpenAiToolResult::from_summary(summary))
}
fn active_vault_path(request: &AiModelStreamRequest) -> Result<&str, String> {
non_empty_option(request.vault_path.as_deref())
.ok_or_else(|| "No active vault is available for this tool.".to_string())
@ -489,16 +492,7 @@ where
raw_arguments: arguments.to_string(),
arguments,
};
emit(AiAgentStreamEvent::ToolStart {
tool_name: tool.to_string(),
tool_id: call.id.clone(),
input: Some(call.raw_arguments.clone()),
});
let result = execute_openai_tool_call(request, &call, emit)?;
emit(AiAgentStreamEvent::ToolDone {
tool_id: call.id.clone(),
output: Some("完成,继续当前对话。".into()),
});
let result = execute_openai_tool_call_with_events(request, &call, emit)?;
results.push(result);
}
Ok(format!(
@ -566,7 +560,7 @@ fn parse_tool_arguments(value: &serde_json::Value) -> Result<(serde_json::Value,
fn create_note_from_tool_args(
request: &AiModelStreamRequest,
args: &serde_json::Value,
) -> Result<CreatedNoteToolResult, String> {
) -> Result<OpenAiToolResult, String> {
let note_path = required_tool_string(args, "path")?;
let content = create_note_tool_content(args, note_path);
let vault_path = tool_vault_path(request, args)?;
@ -580,7 +574,7 @@ fn create_note_from_tool_args(
"vaultPath": vault_path,
})
.to_string();
Ok(CreatedNoteToolResult {
Ok(OpenAiToolResult {
summary: format!("Created note: {note_path}"),
output,
})
@ -952,7 +946,13 @@ mod tests {
summary.as_deref(),
Some("当前聊天模式不支持工具run_shell。未访问或修改任何文件。")
);
assert!(events.is_empty());
assert!(matches!(
events.as_slice(),
[
AiAgentStreamEvent::ToolStart { .. },
AiAgentStreamEvent::ToolDone { .. }
]
));
}
#[test]
@ -976,18 +976,33 @@ mod tests {
"function": { "name": GET_NOTE_TOOL_NAME, "arguments": r#"{"path":"identity.md"}"# }
}));
assert!(execute_openai_tool_calls(&request, &search, |_| {})
.unwrap()
.unwrap()
.contains("identity.md"));
assert!(execute_openai_tool_calls(&request, &context, |_| {})
.unwrap()
.unwrap()
.contains("AGENTS.md"));
assert!(execute_openai_tool_calls(&request, &note, |_| {})
.unwrap()
.unwrap()
.contains("ICE-GL-TEST-001"));
let mut events = Vec::new();
assert!(
execute_openai_tool_calls(&request, &search, |event| events.push(event))
.unwrap()
.unwrap()
.contains("identity.md")
);
assert!(
execute_openai_tool_calls(&request, &context, |event| events.push(event))
.unwrap()
.unwrap()
.contains("AGENTS.md")
);
assert!(
execute_openai_tool_calls(&request, &note, |event| events.push(event))
.unwrap()
.unwrap()
.contains("ICE-GL-TEST-001")
);
assert_eq!(events.len(), 6);
assert!(events.chunks_exact(2).all(|pair| matches!(
pair,
[
AiAgentStreamEvent::ToolStart { .. },
AiAgentStreamEvent::ToolDone { .. }
]
)));
}
#[test]
@ -1003,19 +1018,32 @@ mod tests {
"function": { "name": DELETE_NOTE_TOOL_NAME, "arguments": r#"{"path":"remove.md"}"# }
}));
assert!(execute_openai_tool_calls(&request, &edit, |_| {})
.unwrap()
.unwrap()
.contains("已更新笔记"));
let mut events = Vec::new();
assert!(
execute_openai_tool_calls(&request, &edit, |event| events.push(event))
.unwrap()
.unwrap()
.contains("已更新笔记")
);
assert_eq!(
fs::read_to_string(dir.path().join("memory.md")).unwrap(),
"# New memory"
);
assert!(execute_openai_tool_calls(&request, &delete, |_| {})
.unwrap()
.unwrap()
.contains("已删除笔记"));
assert!(
execute_openai_tool_calls(&request, &delete, |event| events.push(event))
.unwrap()
.unwrap()
.contains("已删除笔记")
);
assert!(!dir.path().join("remove.md").exists());
assert_eq!(events.len(), 4);
assert!(events.chunks_exact(2).all(|pair| matches!(
pair,
[
AiAgentStreamEvent::ToolStart { .. },
AiAgentStreamEvent::ToolDone { .. }
]
)));
}
#[test]

View File

@ -2,6 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const STORAGE_KEY = 'tolaria:ai-workspace-sessions:v1'
const { invokeMock, isTauriState } = vi.hoisted(() => ({
invokeMock: vi.fn(),
isTauriState: { value: false },
}))
vi.mock('@tauri-apps/api/core', () => ({
invoke: invokeMock,
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => isTauriState.value,
}))
function createStorageMock() {
const store = new Map<string, string>()
let writesFail = false
@ -34,6 +47,8 @@ describe('aiWorkspaceSessionStore', () => {
beforeEach(() => {
storageMock = createStorageMock()
vi.stubGlobal('localStorage', storageMock.storage)
invokeMock.mockReset()
isTauriState.value = false
vi.resetModules()
})
@ -120,4 +135,32 @@ describe('aiWorkspaceSessionStore', () => {
}),
])
})
it('merges native and local histories instead of hiding either source', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
'local-chat': {
messages: [{ userMessage: 'Local', actions: [], response: 'History', id: 'local-message' }],
status: 'done',
},
}))
isTauriState.value = true
invokeMock.mockImplementation(async (command: string) => {
if (command === 'get_ai_workspace_sessions') {
return {
'native-chat': {
messages: [{ userMessage: 'Native', actions: [], response: 'History', id: 'native-message' }],
status: 'done',
},
}
}
return undefined
})
const store = await import('./aiWorkspaceSessionStore')
await vi.waitFor(() => {
expect(store.aiWorkspaceSessionSnapshot('local-chat').messages).toHaveLength(1)
expect(store.aiWorkspaceSessionSnapshot('native-chat').messages).toHaveLength(1)
})
})
})

View File

@ -84,6 +84,17 @@ function normalizeStoredSessionsForReason(
return normalizeStoredSessions(value, reason !== 'storage')
}
function mergeStoredSessions(localSessions: SessionMap, nativeSessions: SessionMap): SessionMap {
const merged = { ...nativeSessions }
for (const [sessionId, localSession] of Object.entries(localSessions)) {
const nativeSession = nativeSessions[sessionId]
if (!nativeSession || localSession.messages.length >= nativeSession.messages.length) {
merged[sessionId] = localSession
}
}
return merged
}
async function readNativeSessions(): Promise<SessionMap> {
if (!isTauri()) return {}
@ -145,14 +156,10 @@ async function syncFromNativeStorage(): Promise<void> {
const loadVersion = storeVersion
const nativeSessions = await readNativeSessions()
if (storeVersion !== loadVersion) return
if (Object.keys(nativeSessions).length === 0) {
const currentSessions = sessionStore.getSnapshot()
if (Object.keys(currentSessions).length > 0) scheduleNativeSessionsWrite(currentSessions)
return
}
sessionStore.replaceSnapshot(nativeSessions)
sessionStore.writeStoredSnapshot(nativeSessions)
const mergedSessions = mergeStoredSessions(sessionStore.getSnapshot(), nativeSessions)
sessionStore.replaceSnapshot(mergedSessions)
sessionStore.writeStoredSnapshot(mergedSessions)
if (Object.keys(mergedSessions).length > 0) scheduleNativeSessionsWrite(mergedSessions)
}
function ensureSessionStoreSync(): void {