diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c13132a..7bb6e34 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,9 @@ jobs: - name: Run frontend tests run: npm run test + - name: Build frontend + run: npm run build + rust-test: name: Rust Tests runs-on: ubuntu-22.04 diff --git a/src/__tests__/runtime.test.ts b/src/__tests__/runtime.test.ts index 92b87a9..03a1ae3 100644 --- a/src/__tests__/runtime.test.ts +++ b/src/__tests__/runtime.test.ts @@ -110,7 +110,7 @@ describe('Runtime Utility & Bridge', () => { }); globalThis.fetch = mockFetch; - const result = await appInvoke('get_mobile_service_status'); + const result = await appInvoke('get_mobile_service_status'); expect(result.isRunning).toBe(true); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/mobile/status', { cache: 'no-store', @@ -128,7 +128,7 @@ describe('Runtime Utility & Bridge', () => { }); globalThis.fetch = mockFetch; - const result = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); + const result = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); expect(result).toEqual([]); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/mobile/sessions?prefix=partner-session-', { cache: 'no-store', @@ -146,7 +146,7 @@ describe('Runtime Utility & Bridge', () => { }); globalThis.fetch = mockFetch; - await appInvoke('list_agent_sessions', { + await appInvoke('list_agent_sessions', { prefix: 'story-session-', sessionKind: 'story', }); @@ -164,8 +164,16 @@ describe('Runtime Utility & Bridge', () => { }); globalThis.fetch = mockFetch; - const sessionObj = { id: 's1', title: 'test session' }; - const result = await appInvoke('save_agent_session', { session: sessionObj }); + const sessionObj = { + id: 's1', + title: 'test session', + savedAt: 0, + messages: [], + selectedReferenceFiles: [], + selectedOutlineFile: null, + todos: [], + }; + const result = await appInvoke('save_agent_session', { session: sessionObj }); expect(result.id).toBe('s1'); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/mobile/sessions', { method: 'POST', @@ -198,7 +206,7 @@ describe('Runtime Utility & Bridge', () => { characterCardId: 'card-1', selectedWorldBookId: null, }; - await appInvoke('save_agent_session', { session: sessionObj }); + await appInvoke('save_agent_session', { session: sessionObj }); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/mobile/sessions', expect.objectContaining({ method: 'POST', @@ -214,7 +222,7 @@ describe('Runtime Utility & Bridge', () => { globalThis.fetch = mockFetch; const reqBody = { messages: [] }; - const result = await appInvoke('start_chat_completion_stream', { request: reqBody }); + const result = await appInvoke('start_chat_completion_stream', { request: reqBody }); expect(result.runId).toBe('run-123'); expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/mobile/chat/start', { method: 'POST', diff --git a/src/pages/MobileBond.tsx b/src/pages/MobileBond.tsx index 4a71e75..dbebb0f 100644 --- a/src/pages/MobileBond.tsx +++ b/src/pages/MobileBond.tsx @@ -138,7 +138,7 @@ const MobileBond: React.FC = () => { // Reload partner store from backend to ensure latest character cards try { - const partnerStoreContent = await appInvoke('load_app_state', { name: 'partner-store' }); + const partnerStoreContent = await appInvoke('load_app_state', { name: 'partner-store' }); if (partnerStoreContent) { const parsed = JSON.parse(partnerStoreContent); if (parsed.state) { @@ -150,7 +150,7 @@ const MobileBond: React.FC = () => { } try { - const summaries = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); + const summaries = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); patchUiState({ sessions: summaries }); } catch (err) { console.error('加载会话足迹失败:', err); @@ -159,7 +159,7 @@ const MobileBond: React.FC = () => { } try { - const summaries = await appInvoke('list_agent_sessions', { + const summaries = await appInvoke('list_agent_sessions', { prefix: 'story-session-', sessionKind: 'story', }); @@ -193,7 +193,7 @@ const MobileBond: React.FC = () => { setExpandedSessionId(id); setLoadingRecord(true); try { - const record = await appInvoke('load_agent_session', { id }); + const record = await appInvoke('load_agent_session', { id }); setExpandedRecord(record); } catch (err) { console.error('加载会话详情失败:', err); @@ -211,7 +211,7 @@ const MobileBond: React.FC = () => { setExpandedAdventureId(id); setLoadingAdventureRecord(true); try { - const record = await appInvoke('load_agent_session', { id }); + const record = await appInvoke('load_agent_session', { id }); setExpandedAdventureRecord(record); } catch (err) { console.error('加载冒险详情失败:', err); diff --git a/src/pages/MobileChat.tsx b/src/pages/MobileChat.tsx index 3d7bc7b..8b6424b 100644 --- a/src/pages/MobileChat.tsx +++ b/src/pages/MobileChat.tsx @@ -22,7 +22,7 @@ import { createStableContentKey } from '../utils/renderKeys'; import { useStateGroup } from '../utils/reducerState'; import { ensureSessionId } from '../utils/sessionIds'; import { resolveSessionTitle } from '../utils/sessionTitle'; -import type { Message, AgentSessionSummary } from '../stores/useAgentStore'; +import type { AgentSessionRecord, Message } from '../stores/useAgentStore'; interface MobileChatUiState { isArchiveModalOpen: boolean; @@ -172,7 +172,7 @@ const useMobileChatView = () => { // Load session list on mount useEffect(() => { - appInvoke('list_agent_sessions', { prefix: 'partner-session-' }) + appInvoke('list_agent_sessions', { prefix: 'partner-session-' }) .then((list) => setSessions(list)) .catch((e) => console.error('加载会话列表失败:', e)); }, [setSessions]); @@ -185,7 +185,7 @@ const useMobileChatView = () => { setSessionId(currentSessionId); } try { - const record = { + const record: AgentSessionRecord = { id: currentSessionId, title, messages: list, @@ -198,10 +198,10 @@ const useMobileChatView = () => { characterCardId: selectedCharacterCardId, selectedWorldBookId, }; - await appInvoke('save_agent_session', { session: record }); + await appInvoke('save_agent_session', { session: record }); // Update session summary list - const listRes = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); + const listRes = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); setSessions(listRes); return true; } catch (e) { @@ -230,10 +230,10 @@ const useMobileChatView = () => { messages, finalFallback: '未命名会话', summarize: async () => { - const res = await appInvoke<{ title: string }>('summarize_text', { + const res = await appInvoke('summarize_text', { request: { text: chatHistoryText }, }); - return res.title; + return typeof res === 'string' ? res : res.title; }, }); setSessionTitle(finalTitle); @@ -257,7 +257,7 @@ const useMobileChatView = () => { return; } try { - const record = await appInvoke('load_agent_session', { id }); + const record = await appInvoke('load_agent_session', { id }); setSessionId(record.id); setSessionTitle(record.title); setMessages(record.messages || []); @@ -285,7 +285,7 @@ const useMobileChatView = () => { if (sessionId === id) { createNewSession(); } - const listRes = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); + const listRes = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); setSessions(listRes); } catch (e) { message.error('删除会话失败'); @@ -371,7 +371,7 @@ const useMobileChatView = () => { })); try { - const { runId } = await appInvoke<{ runId: string }>('start_chat_completion_stream', { + const { runId } = await appInvoke('start_chat_completion_stream', { request: { agentId: 'partnerChat', modelInterface: settings.modelInterface, @@ -502,7 +502,7 @@ const useMobileChatView = () => { return; } - const result = await appInvoke>('analyze_character_memory', { sessionId }); + const result = await appInvoke('analyze_character_memory', { sessionId }); const parsed = parseArchiveAnalysisResponse(result); setArchiveAnalysis(parsed); setEditedTitle(parsed.sessionTitle || parsed.recommendedSessionTitle || sessionTitle); @@ -536,11 +536,11 @@ const useMobileChatView = () => { message.success('伴侣记忆封存成功!该会话已归档锁定。'); // Reload sessions - const sessList = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); + const sessList = await appInvoke('list_agent_sessions', { prefix: 'partner-session-' }); setSessions(sessList); // Reload partner store - const partnerStoreContent = await appInvoke('load_app_state', { name: 'partner-store' }); + const partnerStoreContent = await appInvoke('load_app_state', { name: 'partner-store' }); if (partnerStoreContent) { const parsed = JSON.parse(partnerStoreContent); if (parsed.state) { diff --git a/src/pages/MobileHome.tsx b/src/pages/MobileHome.tsx index 4d05a24..d291386 100644 --- a/src/pages/MobileHome.tsx +++ b/src/pages/MobileHome.tsx @@ -5,7 +5,6 @@ import { appInvoke, clearMobileToken, getMobileToken, setMobileToken } from '../ import { usePartnerChatStore } from '../stores/usePartnerChatStore'; import { usePartnerStore } from '../stores/usePartnerStore'; import { useStoryStore } from '../stores/useStoryStore'; -import type { AgentSessionSummary } from '../stores/useAgentStore'; type ConnectionStatus = 'waiting' | 'verifying' | 'verified' | 'invalid'; @@ -59,10 +58,10 @@ const MobileHome: React.FC = () => { useStoryStore.persist.rehydrate(), ]); const [chatSessions, storySessions] = await Promise.all([ - appInvoke('list_agent_sessions', { + appInvoke('list_agent_sessions', { prefix: 'partner-session-', }), - appInvoke('list_agent_sessions', { + appInvoke('list_agent_sessions', { prefix: 'story-session-', sessionKind: 'story', }), diff --git a/src/pages/MobileStory.tsx b/src/pages/MobileStory.tsx index b6a1845..54a7824 100644 --- a/src/pages/MobileStory.tsx +++ b/src/pages/MobileStory.tsx @@ -29,7 +29,7 @@ import { getStoryAllowedTools, getRolePlayCharacterName, } from './storyAgent'; -import type { Message, AgentSessionSummary, AgentToolEntry } from '../stores/useAgentStore'; +import type { AgentSessionRecord, AgentToolEntry, Message } from '../stores/useAgentStore'; interface MobileStoryUiState { isArchiveModalOpen: boolean; @@ -196,7 +196,7 @@ const useMobileStoryView = () => { // Load session list on mount useEffect(() => { - appInvoke('list_agent_sessions', { + appInvoke('list_agent_sessions', { prefix: 'story-session-', sessionKind: 'story', }) @@ -212,7 +212,7 @@ const useMobileStoryView = () => { setSessionId(currentSessionId); } try { - const record = { + const record: AgentSessionRecord = { id: currentSessionId, title, messages: list, @@ -227,10 +227,10 @@ const useMobileStoryView = () => { selectedWorldBookId, dynamicRoleLoadingEnabled, }; - await appInvoke('save_agent_session', { session: record }); - + await appInvoke('save_agent_session', { session: record }); + // Update session summary list - const listRes = await appInvoke('list_agent_sessions', { + const listRes = await appInvoke('list_agent_sessions', { prefix: 'story-session-', sessionKind: 'story', }); @@ -262,10 +262,10 @@ const useMobileStoryView = () => { messages, finalFallback: '未命名故事', summarize: async () => { - const res = await appInvoke<{ title: string }>('summarize_text', { + const res = await appInvoke('summarize_text', { request: { text: chatHistoryText }, }); - return res.title; + return typeof res === 'string' ? res : res.title; }, }); setSessionTitle(finalTitle); @@ -289,7 +289,7 @@ const useMobileStoryView = () => { return; } try { - const record = await appInvoke('load_agent_session', { id }); + const record = await appInvoke('load_agent_session', { id }); setSessionId(record.id); setSessionTitle(record.title); setMessages(record.messages || []); @@ -318,7 +318,7 @@ const useMobileStoryView = () => { if (sessionId === id) { createNewSession(); } - const listRes = await appInvoke('list_agent_sessions', { + const listRes = await appInvoke('list_agent_sessions', { prefix: 'story-session-', sessionKind: 'story', }); @@ -408,7 +408,7 @@ const useMobileStoryView = () => { const storyAgentConfig = settings.agentConfigs?.[storyAgentConfigId] || {}; try { - const { runId } = await appInvoke<{ runId: string }>('start_chat_completion_stream', { + const { runId } = await appInvoke('start_chat_completion_stream', { request: { agentId: storyAgentConfigId, modelInterface: settings.modelInterface, @@ -630,7 +630,7 @@ const useMobileStoryView = () => { const filteredCards = selectedCards.filter(card => tempSelectedCardIds.includes(card.id)); const results = await Promise.all(filteredCards.map(async (card) => { - const result = await appInvoke>('analyze_character_memory', { + const result = await appInvoke('analyze_character_memory', { sessionId, characterCardId: card.id, }); @@ -692,14 +692,14 @@ const useMobileStoryView = () => { message.success('记忆封存成功!故事已锁定归档。'); // Reload sessions - const listRes = await appInvoke('list_agent_sessions', { + const listRes = await appInvoke('list_agent_sessions', { prefix: 'story-session-', sessionKind: 'story', }); setSessions(listRes); // Reload partner store - const partnerStoreContent = await appInvoke('load_app_state', { name: 'partner-store' }); + const partnerStoreContent = await appInvoke('load_app_state', { name: 'partner-store' }); if (partnerStoreContent) { const parsed = JSON.parse(partnerStoreContent); if (parsed.state) { diff --git a/src/stores/diskStorage.ts b/src/stores/diskStorage.ts index 5e13364..feaed30 100644 --- a/src/stores/diskStorage.ts +++ b/src/stores/diskStorage.ts @@ -8,7 +8,7 @@ export function createDiskStorage( return { getItem: async () => { try { - const content = await appInvoke('load_app_state', { name }); + const content = await appInvoke('load_app_state', { name }); return content; } catch { if (localStorageKey) { diff --git a/src/utils/bookTravelMaterials.ts b/src/utils/bookTravelMaterials.ts index 42ea294..2582027 100644 --- a/src/utils/bookTravelMaterials.ts +++ b/src/utils/bookTravelMaterials.ts @@ -8,7 +8,7 @@ const fileNameFromPath = (path: string) => { }; export const resolveOutlineMaterial = async (path: string): Promise => { - const content = await appInvoke('read_file', { path }); + const content = await appInvoke('read_file', { path }); return { id: path, title: fileNameFromPath(path), diff --git a/src/utils/runtime.ts b/src/utils/runtime.ts index 61c097a..d4c5564 100644 --- a/src/utils/runtime.ts +++ b/src/utils/runtime.ts @@ -1,5 +1,98 @@ import { invoke } from '@tauri-apps/api/core'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { AgentSessionRecord, AgentSessionSummary } from '../stores/useAgentStore'; + +interface ArchiveCharacterMemoryPayload { + characterCardId: string; + userRelationType: string; + userInteractionModel: string; + userRelationBottomLine: string; + keyEvents: string; +} + +interface ArchiveSessionPayload { + title: string; + userRelationType?: string; + userInteractionModel?: string; + userRelationBottomLine?: string; + keyEvents?: string; + characterMemories?: ArchiveCharacterMemoryPayload[]; +} + +type ChatCompletionRequest = Record & { + allowedTools?: string[]; +}; + +type AgentSessionRecordResponse = AgentSessionRecord & { + character_card_id?: string | null; + character_card_ids?: string[] | null; + selected_world_book_id?: string | null; + context_compaction?: AgentSessionRecord['contextCompaction']; + is_archived?: boolean; + dynamic_role_loading_enabled?: boolean; +}; + +// Type-safe command definitions +export interface AppInvokeCommands { + get_mobile_service_status: { + args: void; + result: { isRunning: boolean; url: string | null; token: string | null; error: string | null }; + }; + list_agent_sessions: { + args: { prefix?: string; sessionKind?: string }; + result: AgentSessionSummary[]; + }; + load_agent_session: { + args: { id: string }; + result: AgentSessionRecordResponse; + }; + save_agent_session: { + args: { session: AgentSessionRecord }; + result: AgentSessionSummary; + }; + delete_agent_session: { + args: { id: string }; + result: void; + }; + update_agent_session_title: { + args: { id: string; title: string }; + result: AgentSessionSummary; + }; + summarize_text: { + args: { request: { text: string } }; + result: string | { title: string }; + }; + analyze_character_memory: { + args: { sessionId: string; characterCardId?: string | null }; + result: unknown; + }; + archive_agent_session: { + args: { sessionId: string; payload: ArchiveSessionPayload }; + result: void; + }; + start_chat_completion_stream: { + args: { request: ChatCompletionRequest }; + result: { runId: string }; + }; + stop_chat_stream: { + args: { runId: string }; + result: void; + }; + load_app_state: { + args: { name: string }; + result: string; + }; + save_app_state: { + args: { name: string; content: string }; + result: void; + }; + read_file: { + args: { path: string }; + result: string; + }; +} + +export type AppInvokeCommand = keyof AppInvokeCommands; export const isTauriHost = (): boolean => { if (typeof window === 'undefined') return false; @@ -89,9 +182,13 @@ if (typeof window !== 'undefined') { } } -export async function appInvoke(cmd: string, args?: any): Promise { +export async function appInvoke( + cmd: C, + ...args: AppInvokeCommands[C]['args'] extends void ? [] : [AppInvokeCommands[C]['args']] +): Promise { if (isTauriHost()) { - return invoke(cmd, args); + // Type assertion needed because invoke doesn't know about our conditional args + return invoke(cmd, args[0] as any); } // Mobile HTTP mapping @@ -106,64 +203,66 @@ export async function appInvoke(cmd: string, args?: any): Promise { return `${window.location.origin}${path}`; }; + const cmdArgs = args[0] as any; + switch (cmd) { case 'get_mobile_service_status': { const res = await fetch(getUrl('/api/mobile/status'), { headers, cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return res.json() as Promise; + return res.json(); } case 'list_agent_sessions': { const params = new URLSearchParams(); - if (args?.prefix) params.set('prefix', args.prefix); - if (args?.sessionKind) params.set('sessionKind', args.sessionKind); + if (cmdArgs?.prefix) params.set('prefix', cmdArgs.prefix); + if (cmdArgs?.sessionKind) params.set('sessionKind', cmdArgs.sessionKind); const query = params.toString(); const res = await fetch(getUrl(`/api/mobile/sessions${query ? `?${query}` : ''}`), { headers, cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return res.json() as Promise; + return res.json(); } case 'load_agent_session': { - const res = await fetch(getUrl(`/api/mobile/sessions/${args.id}`), { headers, cache: 'no-store' }); + const res = await fetch(getUrl(`/api/mobile/sessions/${cmdArgs.id}`), { headers, cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return res.json() as Promise; + return res.json(); } case 'save_agent_session': { const res = await fetch(getUrl('/api/mobile/sessions'), { method: 'POST', headers, cache: 'no-store', - body: JSON.stringify(args.session), + body: JSON.stringify(cmdArgs.session), }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return res.json() as Promise; + return res.json(); } case 'delete_agent_session': { - const res = await fetch(getUrl(`/api/mobile/sessions/${args.id}`), { + const res = await fetch(getUrl(`/api/mobile/sessions/${cmdArgs.id}`), { method: 'DELETE', headers, cache: 'no-store', }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return undefined as any; + return undefined; } case 'update_agent_session_title': { - const res = await fetch(getUrl(`/api/mobile/sessions/${args.id}/title`), { + const res = await fetch(getUrl(`/api/mobile/sessions/${cmdArgs.id}/title`), { method: 'PUT', headers, cache: 'no-store', - body: JSON.stringify({ title: args.title }), + body: JSON.stringify({ title: cmdArgs.title }), }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return res.json() as Promise; + return res.json(); } case 'summarize_text': { const res = await fetch(getUrl('/api/mobile/summarize'), { method: 'POST', headers, cache: 'no-store', - body: JSON.stringify({ text: args.request.text }), + body: JSON.stringify({ text: cmdArgs.request.text }), }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return res.json() as Promise; + return res.json(); } case 'analyze_character_memory': { // For memory analysis on mobile, we use the session ID from args to call the automated server-side endpoint. @@ -172,7 +271,7 @@ export async function appInvoke(cmd: string, args?: any): Promise { // To keep it simple, we can pass `sessionId` inside our mobile invoke, or read it from args. // Let's check: can we pass `sessionId` as a field of args, e.g. invoke('analyze_character_memory', { request, sessionId })? // Yes! We will update Chat/Story to pass `sessionId` as well. - const sessionId = args?.sessionId; + const sessionId = cmdArgs.sessionId; if (!sessionId) { throw new Error('Missing sessionId for mobile memory analysis'); } @@ -180,15 +279,15 @@ export async function appInvoke(cmd: string, args?: any): Promise { method: 'POST', headers, cache: 'no-store', - body: JSON.stringify({ characterCardId: args?.characterCardId ?? null }), + body: JSON.stringify({ characterCardId: cmdArgs.characterCardId ?? null }), }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return res.json() as Promise; + return res.json(); } case 'archive_agent_session': { // Special mobile-only cmd to archive session memory. - const sessionId = args?.sessionId; - const payload = args?.payload; + const sessionId = cmdArgs.sessionId; + const payload = cmdArgs.payload; if (!sessionId || !payload) { throw new Error('Missing sessionId or payload for mobile archiving'); } @@ -199,45 +298,44 @@ export async function appInvoke(cmd: string, args?: any): Promise { body: JSON.stringify(payload), }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return undefined as any; + return undefined; } case 'start_chat_completion_stream': { - const isStory = args?.request?.allowedTools && args.request.allowedTools.length > 0; + const isStory = cmdArgs.request?.allowedTools && cmdArgs.request.allowedTools.length > 0; const endpoint = isStory ? '/api/mobile/story/start' : '/api/mobile/chat/start'; const res = await fetch(getUrl(endpoint), { method: 'POST', headers, cache: 'no-store', - body: JSON.stringify(args.request), + body: JSON.stringify(cmdArgs.request), }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return res.json() as Promise; + return res.json(); } case 'stop_chat_stream': { const res = await fetch(getUrl('/api/mobile/chat/stop'), { method: 'POST', headers, cache: 'no-store', - body: JSON.stringify({ run_id: args.runId }), + body: JSON.stringify({ run_id: cmdArgs.runId }), }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return undefined as any; + return undefined; } case 'load_app_state': { - const res = await fetch(getUrl(`/api/mobile/state/${args.name}`), { headers, cache: 'no-store' }); + const res = await fetch(getUrl(`/api/mobile/state/${cmdArgs.name}`), { headers, cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - const text = await res.text(); - return text as unknown as T; + return res.text(); } case 'save_app_state': { - const res = await fetch(getUrl(`/api/mobile/state/${args.name}`), { + const res = await fetch(getUrl(`/api/mobile/state/${cmdArgs.name}`), { method: 'POST', headers, cache: 'no-store', - body: args.content, + body: cmdArgs.content, }); if (!res.ok) throw new Error(`HTTP error ${res.status}`); - return undefined as any; + return undefined; } default: throw new Error(`Command ${cmd} is not supported on mobile browser.`);