feat(app): delegate chat to Archy's node-side assistant loop when embedded (Archy phase 13, D-01/D-17)

Adds archyBridge.sendChat(text) built on the existing postToParent +
origin-validated listener pattern (same request-id correlation as
requestContext), with a 180s timeout matching the node's
ASSISTANT_HTTP_TIMEOUT. Adds useAI.ts's streamViaArchy, which branches all
three existing send sites on the same __AIUI_EMBEDDED__ signal useArchy.ts
already reads: embedded mode delegates the model call, the tool-calling
loop and the model key to the node; standalone mode is untouched and keeps
using streamClaude/streamOpenRouter with AIUI's own dev proxy (D-17).

CLAUDE_PATH/OPENROUTER_PATH are not removed — 13-02 changes what those
paths resolve to on a node, 13-09 retires them.

Verified: vitest run 332/335 passing (3 pre-existing failures confirmed via
a scratch worktree at the prior HEAD, unrelated to this change — seed
extraction count assertions and a web-search-integration body.webSearch
assertion); vue-tsc --noEmit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-08-03 14:38:19 -04:00
co-authored by Claude Opus 5
parent 6e8b96d135
commit e30ac1d106
2 changed files with 82 additions and 3 deletions
+43 -3
View File
@@ -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<void> {
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)
+39
View File
@@ -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' })