@d4y/agent-runtime-nuxt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +314 -0
- package/dist/module.d.mts +69 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +60 -0
- package/dist/runtime/components/AgentRuntimeArtifactPreview.d.vue.ts +14 -0
- package/dist/runtime/components/AgentRuntimeArtifactPreview.vue +112 -0
- package/dist/runtime/components/AgentRuntimeArtifactPreview.vue.d.ts +14 -0
- package/dist/runtime/composables/useAgentRuntime.d.ts +130 -0
- package/dist/runtime/composables/useAgentRuntime.js +306 -0
- package/dist/runtime/composables/useAgentRuntimeMarkdown.d.ts +15 -0
- package/dist/runtime/composables/useAgentRuntimeMarkdown.js +109 -0
- package/dist/runtime/server/api/app.get.d.ts +6 -0
- package/dist/runtime/server/api/app.get.js +26 -0
- package/dist/runtime/server/api/conversations/[id]/abort.post.d.ts +6 -0
- package/dist/runtime/server/api/conversations/[id]/abort.post.js +18 -0
- package/dist/runtime/server/api/conversations/[id]/env.post.d.ts +10 -0
- package/dist/runtime/server/api/conversations/[id]/env.post.js +22 -0
- package/dist/runtime/server/api/conversations/[id]/files/raw/[...path].get.d.ts +11 -0
- package/dist/runtime/server/api/conversations/[id]/files/raw/[...path].get.js +31 -0
- package/dist/runtime/server/api/conversations/[id]/files.get.d.ts +14 -0
- package/dist/runtime/server/api/conversations/[id]/files.get.js +16 -0
- package/dist/runtime/server/api/conversations/[id]/history.get.d.ts +2 -0
- package/dist/runtime/server/api/conversations/[id]/history.get.js +16 -0
- package/dist/runtime/server/api/conversations/[id]/messages.post.d.ts +7 -0
- package/dist/runtime/server/api/conversations/[id]/messages.post.js +20 -0
- package/dist/runtime/server/api/conversations/[id]/stream.get.d.ts +9 -0
- package/dist/runtime/server/api/conversations/[id]/stream.get.js +28 -0
- package/dist/runtime/server/api/conversations/[id].delete.d.ts +2 -0
- package/dist/runtime/server/api/conversations/[id].delete.js +18 -0
- package/dist/runtime/server/api/conversations.post.d.ts +7 -0
- package/dist/runtime/server/api/conversations.post.js +17 -0
- package/dist/runtime/server/utils/agent-runtime.d.ts +12 -0
- package/dist/runtime/server/utils/agent-runtime.js +12 -0
- package/dist/runtime/utils/files.d.ts +16 -0
- package/dist/runtime/utils/files.js +42 -0
- package/dist/types.d.mts +9 -0
- package/package.json +67 -0
- package/src/frontend.ts +16 -0
- package/src/module.ts +155 -0
- package/src/nitro-globals.d.ts +8 -0
- package/src/runtime/components/AgentRuntimeArtifactPreview.vue +192 -0
- package/src/runtime/composables/useAgentRuntime.ts +527 -0
- package/src/runtime/composables/useAgentRuntimeMarkdown.ts +145 -0
- package/src/runtime/server/api/app.get.ts +50 -0
- package/src/runtime/server/api/conversations/[id]/abort.post.ts +26 -0
- package/src/runtime/server/api/conversations/[id]/env.post.ts +34 -0
- package/src/runtime/server/api/conversations/[id]/files/raw/[...path].get.ts +48 -0
- package/src/runtime/server/api/conversations/[id]/files.get.ts +33 -0
- package/src/runtime/server/api/conversations/[id]/history.get.ts +20 -0
- package/src/runtime/server/api/conversations/[id]/messages.post.ts +29 -0
- package/src/runtime/server/api/conversations/[id]/stream.get.ts +41 -0
- package/src/runtime/server/api/conversations/[id].delete.ts +22 -0
- package/src/runtime/server/api/conversations.post.ts +26 -0
- package/src/runtime/server/utils/agent-runtime.ts +33 -0
- package/src/runtime/utils/files.ts +78 -0
- package/src/shared.ts +46 -0
- package/src/vue-shim.d.ts +6 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { computed, onMounted, ref, shallowRef, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
import type { UIMessage as AiUIMessage } from 'ai'
|
|
3
|
+
import { useRuntimeConfig } from 'nuxt/app'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-export the AI SDK's `UIMessage` shape — this is the type Nuxt UI's chat
|
|
7
|
+
* components accept, so we use it everywhere in the public surface to keep
|
|
8
|
+
* downstream code zero-friction.
|
|
9
|
+
*/
|
|
10
|
+
export type UIMessage = AiUIMessage
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lifecycle status for a chat. Mirrors what the AI-SDK `UIMessage` consumers
|
|
14
|
+
* expect, so you can wire this directly into Nuxt UI's `<UChatMessages :status>`.
|
|
15
|
+
*/
|
|
16
|
+
export type ChatStatus = 'idle' | 'submitted' | 'streaming' | 'ready' | 'error'
|
|
17
|
+
|
|
18
|
+
/* -------------------------------------------------------------------------- */
|
|
19
|
+
/* Internal part shapes (AI SDK compatible) */
|
|
20
|
+
/* -------------------------------------------------------------------------- */
|
|
21
|
+
|
|
22
|
+
interface TextPart { type: 'text', text: string }
|
|
23
|
+
interface ReasoningPart { type: 'reasoning', text: string }
|
|
24
|
+
interface ToolPart {
|
|
25
|
+
type: `tool-${string}`
|
|
26
|
+
toolCallId: string
|
|
27
|
+
state: 'input-available' | 'output-available' | 'output-error'
|
|
28
|
+
input?: unknown
|
|
29
|
+
output?: unknown
|
|
30
|
+
errorText?: string
|
|
31
|
+
}
|
|
32
|
+
type AgentRuntimePart = TextPart | ReasoningPart | ToolPart
|
|
33
|
+
|
|
34
|
+
/** Internal message representation — cast to {@link UIMessage} at the boundary. */
|
|
35
|
+
interface AgentRuntimeMessage {
|
|
36
|
+
id: string
|
|
37
|
+
role: 'user' | 'assistant' | 'system'
|
|
38
|
+
parts: AgentRuntimePart[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* -------------------------------------------------------------------------- */
|
|
42
|
+
/* App / auth */
|
|
43
|
+
/* -------------------------------------------------------------------------- */
|
|
44
|
+
|
|
45
|
+
/** Single env-var slot declared by an app's manifest. */
|
|
46
|
+
export interface AppEnvField {
|
|
47
|
+
required: boolean
|
|
48
|
+
secret: boolean
|
|
49
|
+
description?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Manifest summary for the active app, as returned by `${apiPrefix}/app`. */
|
|
53
|
+
export interface AppInfo {
|
|
54
|
+
appId: string
|
|
55
|
+
name: string
|
|
56
|
+
envSchema: Record<string, AppEnvField>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generic auth state. Keys are app-defined (whatever the active app declares
|
|
61
|
+
* in its manifest `envSchema`) — the module does not care what they mean,
|
|
62
|
+
* it just ships the resulting map to agent-runtime as the conversation env.
|
|
63
|
+
*/
|
|
64
|
+
export interface AppAuth {
|
|
65
|
+
values: Record<string, string>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* -------------------------------------------------------------------------- */
|
|
69
|
+
/* Misc */
|
|
70
|
+
/* -------------------------------------------------------------------------- */
|
|
71
|
+
|
|
72
|
+
/** Side-channel UI-action event emitted by the agent (via the `ui_action` SSE event). */
|
|
73
|
+
export interface UiAction {
|
|
74
|
+
toolCallId: string
|
|
75
|
+
type: string
|
|
76
|
+
payload: unknown
|
|
77
|
+
at: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Workspace file entry, mirrors the backend response. */
|
|
81
|
+
export interface FileEntry {
|
|
82
|
+
name: string
|
|
83
|
+
relPath: string
|
|
84
|
+
size: number
|
|
85
|
+
mtime: string
|
|
86
|
+
mimeType: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Per-turn options for `send`. */
|
|
90
|
+
export interface SendOptions {
|
|
91
|
+
/**
|
|
92
|
+
* Optional dynamic context object forwarded verbatim to the agent for this
|
|
93
|
+
* turn (the harness passes it into `agent.run`'s `context` field).
|
|
94
|
+
*/
|
|
95
|
+
context?: unknown
|
|
96
|
+
/**
|
|
97
|
+
* Pre-`send` hook to mutate the *forwarded* prompt without changing what is
|
|
98
|
+
* displayed to the user. Useful for model-specific soft switches like
|
|
99
|
+
* Qwen3's `/think` / `/no_think` toggles. Return the rewritten string.
|
|
100
|
+
*/
|
|
101
|
+
rewriteContent?: (text: string) => string
|
|
102
|
+
/** Optional language override for this turn / conversation, e.g. `en` or `de-CH`. */
|
|
103
|
+
language?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Options for `start`. */
|
|
107
|
+
export interface StartOptions {
|
|
108
|
+
/** Optional language override for the newly created conversation. */
|
|
109
|
+
language?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* -------------------------------------------------------------------------- */
|
|
113
|
+
/* Public surface */
|
|
114
|
+
/* -------------------------------------------------------------------------- */
|
|
115
|
+
|
|
116
|
+
/** Return shape of {@link useAgentRuntime}. All members are reactive. */
|
|
117
|
+
export interface UseAgentRuntime {
|
|
118
|
+
/** Active conversation id, or `null` until `start()` (or the first `send()`) succeeds. */
|
|
119
|
+
conversationId: Ref<string | null>
|
|
120
|
+
/** Ordered list of UI messages to render. Mutated as SSE events arrive. */
|
|
121
|
+
messages: Ref<UIMessage[]>
|
|
122
|
+
/** Lifecycle status — drive your UI from this. */
|
|
123
|
+
status: Ref<ChatStatus>
|
|
124
|
+
/** Last error encountered (network failure, upstream error, missing auth, ...). */
|
|
125
|
+
error: Ref<Error | null>
|
|
126
|
+
/** Most recent UI-action events, newest first, capped at 50. */
|
|
127
|
+
uiActions: Ref<UiAction[]>
|
|
128
|
+
/** Active app manifest. `null` until the first `onMounted` fetch resolves. */
|
|
129
|
+
app: Ref<AppInfo | null>
|
|
130
|
+
/** Current auth values (persisted in localStorage, scoped per appId). */
|
|
131
|
+
auth: Ref<AppAuth>
|
|
132
|
+
/** True iff every required env var has a non-empty value. */
|
|
133
|
+
authReady: ComputedRef<boolean>
|
|
134
|
+
/** Workspace files for the current conversation. Refreshed on every stream `end` event. */
|
|
135
|
+
files: Ref<FileEntry[]>
|
|
136
|
+
/** Build the proxied download URL for a workspace-relative path. */
|
|
137
|
+
fileUrl: (relPath: string) => string
|
|
138
|
+
/** Manually re-fetch the workspace file listing. */
|
|
139
|
+
refreshFiles: () => Promise<void>
|
|
140
|
+
/** Persist a new auth map; if a conversation is open, push the change to its env. */
|
|
141
|
+
saveAuth: (next: AppAuth) => Promise<void>
|
|
142
|
+
/** Open a fresh conversation with the current auth. Returns the new id. */
|
|
143
|
+
start: (options?: StartOptions) => Promise<string>
|
|
144
|
+
/** Append a user message; lazily calls `start()` if no conversation exists yet. */
|
|
145
|
+
send: (text: string, options?: SendOptions) => Promise<void>
|
|
146
|
+
/** Cancel the in-flight agent run. */
|
|
147
|
+
abort: () => Promise<void>
|
|
148
|
+
/** Tear down state — drops the conversation reference, clears messages and files. */
|
|
149
|
+
reset: () => Promise<void>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* -------------------------------------------------------------------------- */
|
|
153
|
+
/* Implementation */
|
|
154
|
+
/* -------------------------------------------------------------------------- */
|
|
155
|
+
|
|
156
|
+
const newId = (): string =>
|
|
157
|
+
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
158
|
+
? crypto.randomUUID()
|
|
159
|
+
: `id-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
160
|
+
|
|
161
|
+
const authStorageKey = (appId: string): string => `agent-runtime.app-auth.${appId}`
|
|
162
|
+
|
|
163
|
+
const emptyAuth = (): AppAuth => ({ values: {} })
|
|
164
|
+
|
|
165
|
+
const loadAuth = (appId: string): AppAuth => {
|
|
166
|
+
if (typeof window === 'undefined') return emptyAuth()
|
|
167
|
+
try {
|
|
168
|
+
const raw = window.localStorage.getItem(authStorageKey(appId))
|
|
169
|
+
if (!raw) return emptyAuth()
|
|
170
|
+
const parsed = JSON.parse(raw) as Partial<AppAuth>
|
|
171
|
+
return { values: { ...(parsed.values ?? {}) } }
|
|
172
|
+
} catch {
|
|
173
|
+
return emptyAuth()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const persistAuth = (appId: string, auth: AppAuth): void => {
|
|
178
|
+
if (typeof window === 'undefined') return
|
|
179
|
+
try {
|
|
180
|
+
window.localStorage.setItem(authStorageKey(appId), JSON.stringify(auth))
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const buildEnvFromAuth = (auth: AppAuth): Record<string, string> => {
|
|
185
|
+
const out: Record<string, string> = {}
|
|
186
|
+
for (const [k, v] of Object.entries(auth.values)) {
|
|
187
|
+
const trimmed = (v ?? '').trim()
|
|
188
|
+
if (trimmed) out[k] = trimmed
|
|
189
|
+
}
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const computeAuthReady = (auth: AppAuth, app: AppInfo | null): boolean => {
|
|
194
|
+
if (!app) return false
|
|
195
|
+
const required = Object.entries(app.envSchema).filter(([, f]) => f.required).map(([k]) => k)
|
|
196
|
+
return required.every(k => (auth.values[k] ?? '').trim().length > 0)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const encodeRelPath = (relPath: string): string =>
|
|
200
|
+
relPath.split('/').map(encodeURIComponent).join('/')
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Headless chat client for agent-runtime.
|
|
204
|
+
*
|
|
205
|
+
* Wires up:
|
|
206
|
+
* - Conversation lifecycle (`start`, `send`, `abort`, `reset`).
|
|
207
|
+
* - SSE stream consumption with incremental message updates.
|
|
208
|
+
* - Generic auth/env state (whatever the active app declares).
|
|
209
|
+
* - Workspace file listing + a download URL builder.
|
|
210
|
+
*
|
|
211
|
+
* The composable is **headless** — it produces reactive state shaped for
|
|
212
|
+
* AI-SDK / Nuxt UI chat components but never imports a single UI package
|
|
213
|
+
* itself. Render whatever you want on top.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```vue
|
|
217
|
+
* <script setup lang="ts">
|
|
218
|
+
* const chat = useAgentRuntime()
|
|
219
|
+
* </script>
|
|
220
|
+
*
|
|
221
|
+
* <template>
|
|
222
|
+
* <UChatMessages :messages="chat.messages.value" :status="chat.status.value" />
|
|
223
|
+
* <UChatPrompt v-model="input" @submit="chat.send(input)" />
|
|
224
|
+
* </template>
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
export const useAgentRuntime = (): UseAgentRuntime => {
|
|
228
|
+
const cfg = useRuntimeConfig().public.agentRuntime as { apiPrefix: string, appId: string } | undefined
|
|
229
|
+
const apiPrefix = (cfg?.apiPrefix ?? '/api/agent-runtime').replace(/\/+$/, '')
|
|
230
|
+
|
|
231
|
+
const conversationId = ref<string | null>(null)
|
|
232
|
+
// Internally we use the simpler AgentRuntimeMessage shape for ergonomic mutation;
|
|
233
|
+
// we cast to AI SDK's UIMessage when exposing the ref so downstream Nuxt
|
|
234
|
+
// UI components are happy.
|
|
235
|
+
const messages = shallowRef<AgentRuntimeMessage[]>([])
|
|
236
|
+
const status = ref<ChatStatus>('idle')
|
|
237
|
+
const error = ref<Error | null>(null)
|
|
238
|
+
const uiActions = ref<UiAction[]>([])
|
|
239
|
+
|
|
240
|
+
// IMPORTANT: must start with the same value the server renders so SSR and
|
|
241
|
+
// client-hydration markup match. We then read localStorage in `onMounted`
|
|
242
|
+
// (which never runs during SSR) and update reactively. Reading localStorage
|
|
243
|
+
// synchronously in setup would render with auth populated on the client
|
|
244
|
+
// while the SSR markup had it empty, producing a hydration mismatch.
|
|
245
|
+
const app = ref<AppInfo | null>(null)
|
|
246
|
+
const auth = ref<AppAuth>(emptyAuth())
|
|
247
|
+
const authReady = computed(() => computeAuthReady(auth.value, app.value))
|
|
248
|
+
|
|
249
|
+
onMounted(async () => {
|
|
250
|
+
try {
|
|
251
|
+
const info = await $fetch<AppInfo>(`${apiPrefix}/app`)
|
|
252
|
+
app.value = info
|
|
253
|
+
auth.value = loadAuth(info.appId)
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.warn('[agent-runtime] failed to load app manifest', err)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const files = ref<FileEntry[]>([])
|
|
260
|
+
|
|
261
|
+
const fileUrl = (relPath: string): string => {
|
|
262
|
+
const cid = conversationId.value
|
|
263
|
+
if (!cid) return ''
|
|
264
|
+
return `${apiPrefix}/conversations/${cid}/files/raw/${encodeRelPath(relPath)}`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const refreshFiles = async (): Promise<void> => {
|
|
268
|
+
const cid = conversationId.value
|
|
269
|
+
if (!cid) {
|
|
270
|
+
files.value = []
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const res = await $fetch<{ items: FileEntry[] }>(`${apiPrefix}/conversations/${cid}/files`)
|
|
275
|
+
files.value = res.items
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.warn('[agent-runtime] file list failed', err)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let abortController: AbortController | null = null
|
|
282
|
+
let currentAssistantId: string | null = null
|
|
283
|
+
|
|
284
|
+
const replaceMessages = (next: AgentRuntimeMessage[]): void => {
|
|
285
|
+
messages.value = next
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const updateAssistant = (mutate: (msg: AgentRuntimeMessage) => AgentRuntimeMessage): void => {
|
|
289
|
+
if (!currentAssistantId) return
|
|
290
|
+
const id = currentAssistantId
|
|
291
|
+
replaceMessages(messages.value.map(m => (m.id === id ? mutate(m) : m)))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const ensureAssistant = (): void => {
|
|
295
|
+
if (currentAssistantId) return
|
|
296
|
+
currentAssistantId = newId()
|
|
297
|
+
replaceMessages([
|
|
298
|
+
...messages.value,
|
|
299
|
+
{ id: currentAssistantId, role: 'assistant', parts: [] }
|
|
300
|
+
])
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const appendDelta = (kind: 'text' | 'reasoning', delta: string): void => {
|
|
304
|
+
ensureAssistant()
|
|
305
|
+
updateAssistant((msg) => {
|
|
306
|
+
const parts = msg.parts.slice()
|
|
307
|
+
const last = parts[parts.length - 1] as TextPart | ReasoningPart | undefined
|
|
308
|
+
if (last && last.type === kind) {
|
|
309
|
+
parts[parts.length - 1] = { ...last, text: (last.text ?? '') + delta }
|
|
310
|
+
} else {
|
|
311
|
+
parts.push({ type: kind, text: delta } as TextPart | ReasoningPart)
|
|
312
|
+
}
|
|
313
|
+
return { ...msg, parts }
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const upsertToolPart = (toolName: string, toolCallId: string, mut: (existing: ToolPart | undefined) => ToolPart): void => {
|
|
318
|
+
ensureAssistant()
|
|
319
|
+
updateAssistant((msg) => {
|
|
320
|
+
const parts = msg.parts.slice()
|
|
321
|
+
const idx = parts.findIndex(p => p.type === `tool-${toolName}` && (p as ToolPart).toolCallId === toolCallId)
|
|
322
|
+
const next = mut(idx >= 0 ? (parts[idx] as ToolPart) : undefined)
|
|
323
|
+
if (idx >= 0) parts[idx] = next
|
|
324
|
+
else parts.push(next)
|
|
325
|
+
return { ...msg, parts }
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const onSseEvent = (eventName: string, data: unknown): void => {
|
|
330
|
+
switch (eventName) {
|
|
331
|
+
case 'agent_start':
|
|
332
|
+
status.value = 'streaming'
|
|
333
|
+
break
|
|
334
|
+
case 'delta': {
|
|
335
|
+
const d = data as { kind: 'text' | 'thinking', delta: string }
|
|
336
|
+
appendDelta(d.kind === 'thinking' ? 'reasoning' : 'text', d.delta ?? '')
|
|
337
|
+
break
|
|
338
|
+
}
|
|
339
|
+
case 'tool_start': {
|
|
340
|
+
const d = data as { toolCallId: string, toolName: string, args: unknown }
|
|
341
|
+
upsertToolPart(d.toolName, d.toolCallId, () => ({
|
|
342
|
+
type: `tool-${d.toolName}`,
|
|
343
|
+
toolCallId: d.toolCallId,
|
|
344
|
+
state: 'input-available',
|
|
345
|
+
input: d.args
|
|
346
|
+
}))
|
|
347
|
+
break
|
|
348
|
+
}
|
|
349
|
+
case 'tool_end': {
|
|
350
|
+
const d = data as { toolCallId: string, toolName: string, isError: boolean }
|
|
351
|
+
upsertToolPart(d.toolName, d.toolCallId, prev => ({
|
|
352
|
+
...(prev ?? { type: `tool-${d.toolName}`, toolCallId: d.toolCallId, input: undefined, state: 'input-available' }),
|
|
353
|
+
type: `tool-${d.toolName}`,
|
|
354
|
+
toolCallId: d.toolCallId,
|
|
355
|
+
state: d.isError ? 'output-error' : 'output-available',
|
|
356
|
+
output: d.isError ? undefined : 'ok',
|
|
357
|
+
errorText: d.isError ? 'Tool returned an error' : undefined
|
|
358
|
+
}))
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
case 'ui_action': {
|
|
362
|
+
const d = data as { toolCallId: string, type: string, payload: unknown }
|
|
363
|
+
uiActions.value = [{ toolCallId: d.toolCallId, type: d.type, payload: d.payload, at: Date.now() }, ...uiActions.value].slice(0, 50)
|
|
364
|
+
break
|
|
365
|
+
}
|
|
366
|
+
case 'end':
|
|
367
|
+
status.value = 'ready'
|
|
368
|
+
currentAssistantId = null
|
|
369
|
+
void refreshFiles()
|
|
370
|
+
break
|
|
371
|
+
case 'error': {
|
|
372
|
+
const d = data as { message?: string }
|
|
373
|
+
error.value = new Error(d?.message ?? 'stream error')
|
|
374
|
+
status.value = 'error'
|
|
375
|
+
currentAssistantId = null
|
|
376
|
+
break
|
|
377
|
+
}
|
|
378
|
+
default:
|
|
379
|
+
break
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const consume = async (signal: AbortSignal): Promise<void> => {
|
|
384
|
+
const res = await fetch(`${apiPrefix}/conversations/${conversationId.value}/stream`, { signal })
|
|
385
|
+
if (!res.ok || !res.body) throw new Error(`stream failed: ${res.status}`)
|
|
386
|
+
const reader = res.body.getReader()
|
|
387
|
+
const decoder = new TextDecoder()
|
|
388
|
+
let buffer = ''
|
|
389
|
+
while (true) {
|
|
390
|
+
const { value, done } = await reader.read()
|
|
391
|
+
if (done) break
|
|
392
|
+
buffer += decoder.decode(value, { stream: true })
|
|
393
|
+
let idx
|
|
394
|
+
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
|
395
|
+
const frame = buffer.slice(0, idx)
|
|
396
|
+
buffer = buffer.slice(idx + 2)
|
|
397
|
+
if (!frame.trim()) continue
|
|
398
|
+
let event = 'message'
|
|
399
|
+
const dataLines: string[] = []
|
|
400
|
+
for (const raw of frame.split('\n')) {
|
|
401
|
+
if (raw.startsWith(':')) continue
|
|
402
|
+
if (raw.startsWith('event:')) event = raw.slice(6).trim()
|
|
403
|
+
else if (raw.startsWith('data:')) dataLines.push(raw.slice(5).trim())
|
|
404
|
+
}
|
|
405
|
+
if (dataLines.length === 0) continue
|
|
406
|
+
const dataStr = dataLines.join('\n')
|
|
407
|
+
let data: unknown = dataStr
|
|
408
|
+
try {
|
|
409
|
+
data = JSON.parse(dataStr)
|
|
410
|
+
} catch {}
|
|
411
|
+
onSseEvent(event, data)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const start = async (options: StartOptions = {}): Promise<string> => {
|
|
417
|
+
if (!authReady.value) {
|
|
418
|
+
const missing = app.value
|
|
419
|
+
? Object.entries(app.value.envSchema)
|
|
420
|
+
.filter(([k, f]) => f.required && !(auth.value.values[k] ?? '').trim())
|
|
421
|
+
.map(([k]) => k)
|
|
422
|
+
: []
|
|
423
|
+
throw new Error(
|
|
424
|
+
missing.length > 0
|
|
425
|
+
? `Fill in required env vars before starting a chat: ${missing.join(', ')}`
|
|
426
|
+
: 'App manifest not loaded yet. Try again in a moment.'
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
error.value = null
|
|
430
|
+
const res = await $fetch<{ conversationId: string }>(`${apiPrefix}/conversations`, {
|
|
431
|
+
method: 'POST',
|
|
432
|
+
body: { env: buildEnvFromAuth(auth.value), language: options.language }
|
|
433
|
+
})
|
|
434
|
+
conversationId.value = res.conversationId
|
|
435
|
+
abortController?.abort()
|
|
436
|
+
abortController = new AbortController()
|
|
437
|
+
const signal = abortController.signal
|
|
438
|
+
consume(signal).catch((err) => {
|
|
439
|
+
if (signal.aborted) return
|
|
440
|
+
error.value = err instanceof Error ? err : new Error(String(err))
|
|
441
|
+
status.value = 'error'
|
|
442
|
+
})
|
|
443
|
+
void refreshFiles()
|
|
444
|
+
return res.conversationId
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const saveAuth = async (next: AppAuth): Promise<void> => {
|
|
448
|
+
auth.value = { values: { ...next.values } }
|
|
449
|
+
if (app.value) persistAuth(app.value.appId, auth.value)
|
|
450
|
+
if (conversationId.value) {
|
|
451
|
+
await $fetch(`${apiPrefix}/conversations/${conversationId.value}/env`, {
|
|
452
|
+
method: 'POST',
|
|
453
|
+
body: { env: buildEnvFromAuth(auth.value), merge: true }
|
|
454
|
+
}).catch((err) => {
|
|
455
|
+
error.value = err instanceof Error ? err : new Error(String(err))
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const send = async (text: string, options: SendOptions = {}): Promise<void> => {
|
|
461
|
+
if (!text.trim()) return
|
|
462
|
+
if (!conversationId.value) {
|
|
463
|
+
try {
|
|
464
|
+
await start({ language: options.language })
|
|
465
|
+
} catch (err) {
|
|
466
|
+
error.value = err instanceof Error ? err : new Error(String(err))
|
|
467
|
+
status.value = 'error'
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
error.value = null
|
|
472
|
+
status.value = 'submitted'
|
|
473
|
+
currentAssistantId = null
|
|
474
|
+
replaceMessages([
|
|
475
|
+
...messages.value,
|
|
476
|
+
{ id: newId(), role: 'user', parts: [{ type: 'text', text }] }
|
|
477
|
+
])
|
|
478
|
+
const wireContent = options.rewriteContent ? options.rewriteContent(text) : text
|
|
479
|
+
await $fetch(`${apiPrefix}/conversations/${conversationId.value}/messages`, {
|
|
480
|
+
method: 'POST',
|
|
481
|
+
body: { content: wireContent, context: options.context, language: options.language }
|
|
482
|
+
}).catch((err) => {
|
|
483
|
+
error.value = err instanceof Error ? err : new Error(String(err))
|
|
484
|
+
status.value = 'error'
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const abort = async (): Promise<void> => {
|
|
489
|
+
if (!conversationId.value) return
|
|
490
|
+
await $fetch(`${apiPrefix}/conversations/${conversationId.value}/abort`, { method: 'POST' }).catch(() => {})
|
|
491
|
+
status.value = 'ready'
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const reset = async (): Promise<void> => {
|
|
495
|
+
abortController?.abort()
|
|
496
|
+
abortController = null
|
|
497
|
+
conversationId.value = null
|
|
498
|
+
currentAssistantId = null
|
|
499
|
+
replaceMessages([])
|
|
500
|
+
uiActions.value = []
|
|
501
|
+
files.value = []
|
|
502
|
+
status.value = 'idle'
|
|
503
|
+
error.value = null
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
conversationId,
|
|
508
|
+
// Cast at the boundary: AgentRuntimeMessage is a structurally-compatible subset of
|
|
509
|
+
// the AI SDK's UIMessage, but the latter's tagged union is too narrow for
|
|
510
|
+
// TypeScript to accept the cast implicitly.
|
|
511
|
+
messages: messages as unknown as Ref<UIMessage[]>,
|
|
512
|
+
status,
|
|
513
|
+
error,
|
|
514
|
+
uiActions,
|
|
515
|
+
app,
|
|
516
|
+
auth,
|
|
517
|
+
authReady,
|
|
518
|
+
files,
|
|
519
|
+
fileUrl,
|
|
520
|
+
refreshFiles,
|
|
521
|
+
saveAuth,
|
|
522
|
+
start,
|
|
523
|
+
send,
|
|
524
|
+
abort,
|
|
525
|
+
reset
|
|
526
|
+
}
|
|
527
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it'
|
|
2
|
+
import {
|
|
3
|
+
isAgentRuntimeImagePath,
|
|
4
|
+
resolveAgentRuntimeWorkspaceUri,
|
|
5
|
+
toAgentRuntimeWorkspaceRelativePath,
|
|
6
|
+
} from '../utils/files'
|
|
7
|
+
|
|
8
|
+
export interface AgentRuntimeMarkdownRenderOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a workspace-relative path (e.g. `outputs/foo.pdf`) to a fetchable
|
|
11
|
+
* URL on the runtime backend. Returns null/empty if the path can't be
|
|
12
|
+
* resolved, in which case the renderer falls back to the original text.
|
|
13
|
+
*/
|
|
14
|
+
resolveWorkspacePath?: (relPath: string) => string | null | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BARE_WORKSPACE_PATH_RE = /(sandbox:[^\s)\]"'<>]+|\/workspace\/[^\s)\]"'<>]+)/g
|
|
18
|
+
|
|
19
|
+
export const createAgentRuntimeMarkdownRenderer = (options: AgentRuntimeMarkdownRenderOptions = {}): MarkdownIt => {
|
|
20
|
+
const md = new MarkdownIt({
|
|
21
|
+
html: false,
|
|
22
|
+
linkify: true,
|
|
23
|
+
breaks: true,
|
|
24
|
+
typographer: false,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const resolve = (uri: string): string | null =>
|
|
28
|
+
resolveAgentRuntimeWorkspaceUri(uri, options.resolveWorkspacePath)
|
|
29
|
+
|
|
30
|
+
const defaultLinkOpen: NonNullable<typeof md.renderer.rules.link_open> = md.renderer.rules.link_open
|
|
31
|
+
?? ((tokens, idx, opts, _env, self) => self.renderToken(tokens, idx, opts))
|
|
32
|
+
|
|
33
|
+
md.renderer.rules.link_open = (tokens, idx, opts, env, self) => {
|
|
34
|
+
const token = tokens[idx]
|
|
35
|
+
if (token) {
|
|
36
|
+
const href = token.attrGet('href') ?? ''
|
|
37
|
+
const rewritten = resolve(href)
|
|
38
|
+
if (rewritten) {
|
|
39
|
+
token.attrSet('href', rewritten)
|
|
40
|
+
token.attrSet('target', '_blank')
|
|
41
|
+
token.attrSet('rel', 'noopener noreferrer')
|
|
42
|
+
token.attrSet('class', 'agent-runtime-md-file-link')
|
|
43
|
+
} else if (/^https?:\/\//i.test(href)) {
|
|
44
|
+
token.attrSet('target', '_blank')
|
|
45
|
+
token.attrSet('rel', 'noopener noreferrer')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return defaultLinkOpen(tokens, idx, opts, env, self)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
md.renderer.rules.image = (tokens, idx) => {
|
|
53
|
+
const token = tokens[idx]
|
|
54
|
+
if (!token) return ''
|
|
55
|
+
|
|
56
|
+
const rawSrc = token.attrGet('src') ?? ''
|
|
57
|
+
const rewritten = resolve(rawSrc)
|
|
58
|
+
const src = rewritten ?? rawSrc
|
|
59
|
+
const alt = token.content || ''
|
|
60
|
+
const title = token.attrGet('title')
|
|
61
|
+
const titleAttr = title ? ` title="${md.utils.escapeHtml(title)}"` : ''
|
|
62
|
+
const safeAlt = md.utils.escapeHtml(alt)
|
|
63
|
+
const safeSrc = md.utils.escapeHtml(src)
|
|
64
|
+
const looksLikeImage = isAgentRuntimeImagePath(rawSrc) || isAgentRuntimeImagePath(src)
|
|
65
|
+
|
|
66
|
+
if (!looksLikeImage && rewritten) {
|
|
67
|
+
return `<a href="${safeSrc}" target="_blank" rel="noopener noreferrer" class="agent-runtime-md-file-link">${safeAlt || safeSrc}</a>`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return `<a href="${safeSrc}" target="_blank" rel="noopener noreferrer" class="agent-runtime-md-image-link"><img src="${safeSrc}" alt="${safeAlt}" loading="lazy" class="agent-runtime-md-image"${titleAttr} /></a>`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
md.core.ruler.after('inline', 'agent_runtime_workspace_autolink', (state) => {
|
|
74
|
+
for (const blockToken of state.tokens) {
|
|
75
|
+
if (blockToken.type !== 'inline' || !blockToken.children) continue
|
|
76
|
+
|
|
77
|
+
const out: typeof blockToken.children = []
|
|
78
|
+
for (const child of blockToken.children) {
|
|
79
|
+
if (child.type !== 'text') {
|
|
80
|
+
out.push(child)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const text = child.content
|
|
85
|
+
BARE_WORKSPACE_PATH_RE.lastIndex = 0
|
|
86
|
+
if (!BARE_WORKSPACE_PATH_RE.test(text)) {
|
|
87
|
+
out.push(child)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
BARE_WORKSPACE_PATH_RE.lastIndex = 0
|
|
92
|
+
let lastIndex = 0
|
|
93
|
+
let match: RegExpExecArray | null
|
|
94
|
+
|
|
95
|
+
while ((match = BARE_WORKSPACE_PATH_RE.exec(text)) !== null) {
|
|
96
|
+
const uri = match[0]
|
|
97
|
+
const url = resolve(uri)
|
|
98
|
+
if (!url) continue
|
|
99
|
+
|
|
100
|
+
if (match.index > lastIndex) {
|
|
101
|
+
const before = new state.Token('text', '', 0)
|
|
102
|
+
before.content = text.slice(lastIndex, match.index)
|
|
103
|
+
out.push(before)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const open = new state.Token('link_open', 'a', 1)
|
|
107
|
+
open.attrSet('href', url)
|
|
108
|
+
open.attrSet('target', '_blank')
|
|
109
|
+
open.attrSet('rel', 'noopener noreferrer')
|
|
110
|
+
open.attrSet('class', 'agent-runtime-md-file-link')
|
|
111
|
+
|
|
112
|
+
const inner = new state.Token('text', '', 0)
|
|
113
|
+
const relPath = toAgentRuntimeWorkspaceRelativePath(uri) ?? uri
|
|
114
|
+
inner.content = relPath.split('/').pop() || relPath
|
|
115
|
+
|
|
116
|
+
const close = new state.Token('link_close', 'a', -1)
|
|
117
|
+
out.push(open, inner, close)
|
|
118
|
+
lastIndex = match.index + uri.length
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (lastIndex < text.length) {
|
|
122
|
+
const tail = new state.Token('text', '', 0)
|
|
123
|
+
tail.content = text.slice(lastIndex)
|
|
124
|
+
out.push(tail)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
blockToken.children = out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return false
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return md
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const useAgentRuntimeMarkdown = (options: AgentRuntimeMarkdownRenderOptions = {}) => {
|
|
138
|
+
const markdown = createAgentRuntimeMarkdownRenderer(options)
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
markdown,
|
|
142
|
+
render: (text: string): string => (text ? markdown.render(text) : ''),
|
|
143
|
+
renderInline: (text: string): string => (text ? markdown.renderInline(text) : ''),
|
|
144
|
+
}
|
|
145
|
+
}
|