@d4y/agent-runtime-nuxt 0.1.0 → 0.1.1

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.
Files changed (28) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/frontend.d.ts +5 -0
  3. package/dist/runtime/frontend.js +14 -0
  4. package/dist/runtime/server/utils/agent-runtime.d.ts +1 -1
  5. package/dist/runtime/server/utils/agent-runtime.js +1 -1
  6. package/dist/runtime/shared.d.ts +16 -0
  7. package/dist/runtime/shared.js +22 -0
  8. package/package.json +22 -11
  9. package/src/frontend.ts +0 -16
  10. package/src/module.ts +0 -155
  11. package/src/nitro-globals.d.ts +0 -8
  12. package/src/runtime/components/AgentRuntimeArtifactPreview.vue +0 -192
  13. package/src/runtime/composables/useAgentRuntime.ts +0 -527
  14. package/src/runtime/composables/useAgentRuntimeMarkdown.ts +0 -145
  15. package/src/runtime/server/api/app.get.ts +0 -50
  16. package/src/runtime/server/api/conversations/[id]/abort.post.ts +0 -26
  17. package/src/runtime/server/api/conversations/[id]/env.post.ts +0 -34
  18. package/src/runtime/server/api/conversations/[id]/files/raw/[...path].get.ts +0 -48
  19. package/src/runtime/server/api/conversations/[id]/files.get.ts +0 -33
  20. package/src/runtime/server/api/conversations/[id]/history.get.ts +0 -20
  21. package/src/runtime/server/api/conversations/[id]/messages.post.ts +0 -29
  22. package/src/runtime/server/api/conversations/[id]/stream.get.ts +0 -41
  23. package/src/runtime/server/api/conversations/[id].delete.ts +0 -22
  24. package/src/runtime/server/api/conversations.post.ts +0 -26
  25. package/src/runtime/server/utils/agent-runtime.ts +0 -33
  26. package/src/runtime/utils/files.ts +0 -78
  27. package/src/shared.ts +0 -46
  28. package/src/vue-shim.d.ts +0 -6
@@ -1,527 +0,0 @@
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
- }
@@ -1,145 +0,0 @@
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
- }