diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 8826dfa2..f3f698a0 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -6,6 +6,7 @@ import type { ImageAttachment } from '@aiui/core/types/message' import { usePersonaStore } from '@/stores/personas' import { useMemoryStore } from '@/stores/memory' import { useArchy } from '@/composables/useArchy' +import { archyBridge } from '@/services/archyBridge' import { useCodeContext } from '@/composables/useCodeContext' import { apiFetch } from '@/utils/api-fetch' import { useSettingsStore } from '@/stores/settings' @@ -374,6 +375,33 @@ async function streamOpenRouter( }, onError, signal) } +/** + * Embedded-mode chat delegation (D-01/D-17): when AIUI is running inside + * Archy, the model call, the tool-calling loop, and the model key all live + * node-side. This sends only the latest user turn over the existing + * origin-checked postMessage bridge (`archyBridge.sendChat`) and emits the + * node's final answer as a single token — there is no streaming across the + * bridge in this tracer, only a one-shot `chat:request`/`chat:response` + * round trip. + */ +async function streamViaArchy( + messages: ChatMessage[], + onToken: (text: string) => void, + onError: (err: string) => void, + signal?: AbortSignal, +): Promise { + const lastUser = [...messages].reverse().find((m) => m.role === 'user') + const text = lastUser?.content ?? '' + try { + const result = await archyBridge.sendChat(text) + if (signal?.aborted) return + onToken(result.text) + } catch (err) { + if (signal?.aborted) return + onError(err instanceof Error ? err.message : 'Archy chat request failed') + } +} + async function readSSE( res: Response, onData: (data: string) => void, @@ -560,7 +588,11 @@ export async function streamWithModel( activeModel.value = model try { - if (provider === 'claude') { + if (useArchy().isEmbedded.value) { + // D-17: embedded mode delegates the loop, the tools and the key to + // Archy — provider/model selection here doesn't apply node-side. + await streamViaArchy(history, onToken, onError, signal) + } else if (provider === 'claude') { await streamClaude(history, onToken, onError, 'You are a helpful assistant.', false, signal) } else if (provider === 'openrouter') { await streamOpenRouter(history, onToken, onError, 'You are a helpful assistant.', signal) @@ -643,7 +675,11 @@ export function useAI() { const genParams = getConversationParams(chatStore) try { - if (provider === 'claude') { + if (useArchy().isEmbedded.value) { + // D-17: embedded mode delegates the loop, the tools and the key to + // Archy — provider/model selection here doesn't apply node-side. + await streamViaArchy(history, onToken, onError, signal) + } else if (provider === 'claude') { await streamClaude(history, onToken, onError, systemPrompt, proxyWebSearch, signal, genParams) } else if (provider === 'openrouter') { await streamOpenRouter(history, onToken, onError, systemPrompt, signal) @@ -755,7 +791,11 @@ export function useAI() { const genParams = getConversationParams(chatStore) try { - if (provider === 'claude') { + if (useArchy().isEmbedded.value) { + // D-17: embedded mode delegates the loop, the tools and the key to + // Archy — provider/model selection here doesn't apply node-side. + await streamViaArchy(history, onToken, onError, signal) + } else if (provider === 'claude') { await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal, genParams) } else if (provider === 'openrouter') { await streamOpenRouter(history, onToken, onError, systemPrompt, signal) diff --git a/packages/app/src/services/archyBridge.ts b/packages/app/src/services/archyBridge.ts index 8cdce41f..b239d81e 100644 --- a/packages/app/src/services/archyBridge.ts +++ b/packages/app/src/services/archyBridge.ts @@ -74,6 +74,19 @@ function handleMessage(event: MessageEvent) { break } + case 'chat:response': { + const pending = pendingRequests.get(msg.id) + if (pending) { + pendingRequests.delete(msg.id) + if (msg.success) { + pending.resolve({ text: msg.text ?? '' }) + } else { + pending.reject(new Error(msg.error || 'Chat request failed')) + } + } + break + } + case 'permissions:update': { currentPermissions = msg.categories || [] for (const cb of permissionsCallbacks) cb(currentPermissions) @@ -189,6 +202,32 @@ export const archyBridge = { }) }, + /** + * Send a chat turn to Archy's node-side assistant loop (D-01: the model + * key and the tool-calling loop live on the node, never in this bundle). + * Returns the assistant's final text answer for this turn. + */ + sendChat(text: string): Promise<{ text: string }> { + const id = generateId() + return new Promise((resolve, reject) => { + pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject }) + postToParent({ + type: 'chat:request', + id, + text, + }) + + // Matches the node's ASSISTANT_HTTP_TIMEOUT (180s) — the assistant + // loop's tool-calling round trip can legitimately take that long. + setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id) + reject(new Error('Chat request timed out')) + } + }, 180000) + }) + }, + /** Request Archy's theme info */ requestTheme() { postToParent({ type: 'theme:request' })