fix: restore AI history and tool receipts
This commit is contained in:
parent
f141317442
commit
2dd29502a6
@ -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, ¬e, |_| {})
|
||||
.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, ¬e, |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]
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user