@ouraihub/aether-ui 0.0.0-stage → 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.
Files changed (179) hide show
  1. package/LICENSE +789 -0
  2. package/fixtures/model-classify.json +42 -0
  3. package/fixtures/pricing-cost.json +56 -0
  4. package/fixtures/reasoning-infer.json +44 -0
  5. package/fixtures/sse/anthropic-messages/chinese.sse +24 -0
  6. package/fixtures/sse/anthropic-messages/multi-tool.sse +33 -0
  7. package/fixtures/sse/anthropic-messages/single-tool.sse +39 -0
  8. package/fixtures/sse/anthropic-messages/text.sse +27 -0
  9. package/fixtures/sse/anthropic-messages/truncated.sse +21 -0
  10. package/fixtures/sse/anthropic-messages/upstream-error.sse +12 -0
  11. package/fixtures/sse/expect.json +20 -0
  12. package/fixtures/sse/openai-chat/chinese.sse +14 -0
  13. package/fixtures/sse/openai-chat/multi-tool.sse +18 -0
  14. package/fixtures/sse/openai-chat/single-tool.sse +22 -0
  15. package/fixtures/sse/openai-chat/text.sse +14 -0
  16. package/fixtures/sse/openai-chat/truncated.sse +12 -0
  17. package/fixtures/sse/openai-chat/upstream-error.sse +6 -0
  18. package/fixtures/sse/openai-responses/chinese.sse +33 -0
  19. package/fixtures/sse/openai-responses/multi-tool.sse +36 -0
  20. package/fixtures/sse/openai-responses/single-tool.sse +27 -0
  21. package/fixtures/sse/openai-responses/text.sse +33 -0
  22. package/fixtures/sse/openai-responses/truncated.sse +30 -0
  23. package/fixtures/sse/openai-responses/upstream-error.sse +9 -0
  24. package/package.json +71 -3
  25. package/src/CopyInput.svelte +33 -0
  26. package/src/KitNotifier.svelte +33 -0
  27. package/src/chat/ApprovalCard.svelte +23 -0
  28. package/src/chat/ChatPanel.svelte +2097 -0
  29. package/src/chat/MessageItem.svelte +583 -0
  30. package/src/chat/SkillPicker.svelte +126 -0
  31. package/src/chat/ToolCallItem.svelte +23 -0
  32. package/src/chat/UsageDetails.svelte +138 -0
  33. package/src/chat/index.ts +2 -0
  34. package/src/chat/run-session.svelte.ts +556 -0
  35. package/src/components/ui/alert/alert-action.svelte +20 -0
  36. package/src/components/ui/alert/alert-description.svelte +23 -0
  37. package/src/components/ui/alert/alert-title.svelte +23 -0
  38. package/src/components/ui/alert/alert.svelte +43 -0
  39. package/src/components/ui/alert/index.ts +17 -0
  40. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  41. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  42. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  43. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  44. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  45. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  46. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  47. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  48. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  49. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  50. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  51. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  52. package/src/components/ui/alert-dialog/index.ts +40 -0
  53. package/src/components/ui/button/button.svelte +81 -0
  54. package/src/components/ui/button/index.ts +17 -0
  55. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  56. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  57. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  58. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  59. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  62. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  63. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  64. package/src/components/ui/dialog/dialog.svelte +7 -0
  65. package/src/components/ui/dialog/index.ts +34 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  69. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  70. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  71. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  72. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  73. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  74. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  75. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  76. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  77. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  78. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  79. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  80. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  81. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  82. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  83. package/src/components/ui/dropdown-menu/index.ts +54 -0
  84. package/src/components/ui/empty/empty-content.svelte +23 -0
  85. package/src/components/ui/empty/empty-description.svelte +23 -0
  86. package/src/components/ui/empty/empty-header.svelte +20 -0
  87. package/src/components/ui/empty/empty-media.svelte +41 -0
  88. package/src/components/ui/empty/empty-title.svelte +20 -0
  89. package/src/components/ui/empty/empty.svelte +23 -0
  90. package/src/components/ui/empty/index.ts +22 -0
  91. package/src/components/ui/field/field-content.svelte +20 -0
  92. package/src/components/ui/field/field-description.svelte +25 -0
  93. package/src/components/ui/field/field-error.svelte +58 -0
  94. package/src/components/ui/field/field-group.svelte +23 -0
  95. package/src/components/ui/field/field-label.svelte +25 -0
  96. package/src/components/ui/field/field-legend.svelte +24 -0
  97. package/src/components/ui/field/field-separator.svelte +35 -0
  98. package/src/components/ui/field/field-set.svelte +20 -0
  99. package/src/components/ui/field/field-title.svelte +20 -0
  100. package/src/components/ui/field/field.svelte +47 -0
  101. package/src/components/ui/field/index.ts +33 -0
  102. package/src/components/ui/input/index.ts +7 -0
  103. package/src/components/ui/input/input.svelte +48 -0
  104. package/src/components/ui/label/index.ts +7 -0
  105. package/src/components/ui/label/label.svelte +20 -0
  106. package/src/components/ui/select/index.ts +40 -0
  107. package/src/components/ui/select/select-content.svelte +45 -0
  108. package/src/components/ui/select/select-group-heading.svelte +21 -0
  109. package/src/components/ui/select/select-group.svelte +17 -0
  110. package/src/components/ui/select/select-item.svelte +43 -0
  111. package/src/components/ui/select/select-label.svelte +20 -0
  112. package/src/components/ui/select/select-portal.svelte +7 -0
  113. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  114. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  115. package/src/components/ui/select/select-separator.svelte +18 -0
  116. package/src/components/ui/select/select-trigger.svelte +29 -0
  117. package/src/components/ui/select/select-value.svelte +7 -0
  118. package/src/components/ui/select/select.svelte +11 -0
  119. package/src/components/ui/separator/index.ts +7 -0
  120. package/src/components/ui/separator/separator.svelte +21 -0
  121. package/src/components/ui/sonner/index.ts +1 -0
  122. package/src/components/ui/sonner/sonner.svelte +40 -0
  123. package/src/components/ui/switch/index.ts +7 -0
  124. package/src/components/ui/switch/switch.svelte +34 -0
  125. package/src/components/ui/textarea/index.ts +7 -0
  126. package/src/components/ui/textarea/textarea.svelte +22 -0
  127. package/src/context.ts +34 -0
  128. package/src/dev/Demo.svelte +45 -0
  129. package/src/dev/main.ts +7 -0
  130. package/src/i18n/en.ts +617 -0
  131. package/src/i18n/index.ts +23 -0
  132. package/src/i18n/zh-CN.ts +599 -0
  133. package/src/index.ts +10 -0
  134. package/src/logs/LogPanel.svelte +97 -0
  135. package/src/logs/LogSettings.svelte +56 -0
  136. package/src/logs/index.ts +5 -0
  137. package/src/logs/logger.ts +96 -0
  138. package/src/markdown.ts +8 -0
  139. package/src/mcp/McpClientSettings.svelte +491 -0
  140. package/src/mcp/McpServerSettings.svelte +327 -0
  141. package/src/mcp/config.ts +26 -0
  142. package/src/mcp/index.ts +3 -0
  143. package/src/mcp/input.ts +73 -0
  144. package/src/notify.svelte.ts +43 -0
  145. package/src/ports/contract.ts +165 -0
  146. package/src/ports/fake.ts +484 -0
  147. package/src/ports/index.ts +7 -0
  148. package/src/ports/search.ts +47 -0
  149. package/src/ports/skill-sources.ts +45 -0
  150. package/src/providers/EndpointKeysDialog.svelte +343 -0
  151. package/src/providers/EndpointPanel.svelte +491 -0
  152. package/src/providers/ModelPickerDialog.svelte +187 -0
  153. package/src/providers/ModelSelect.svelte +434 -0
  154. package/src/providers/ModelSettingsDialog.svelte +453 -0
  155. package/src/providers/ProvidersSettings.svelte +631 -0
  156. package/src/providers/RequestConfigDialog.svelte +250 -0
  157. package/src/providers/SecretInput.svelte +276 -0
  158. package/src/providers/draft.ts +329 -0
  159. package/src/providers/edit-session.ts +146 -0
  160. package/src/providers/index.ts +5 -0
  161. package/src/providers/reasoning.ts +41 -0
  162. package/src/providers/revision.svelte.ts +4 -0
  163. package/src/search/SearchSettings.svelte +703 -0
  164. package/src/search/index.ts +13 -0
  165. package/src/search/settings-flow.ts +62 -0
  166. package/src/session.ts +20 -0
  167. package/src/skills/SkillAdd.svelte +347 -0
  168. package/src/skills/SkillAuthoring.svelte +381 -0
  169. package/src/skills/SkillEditor.svelte +94 -0
  170. package/src/skills/SkillPreviewDialog.svelte +122 -0
  171. package/src/skills/SkillsSettings.svelte +140 -0
  172. package/src/skills/error.ts +32 -0
  173. package/src/skills/index.ts +4 -0
  174. package/src/styles/kit.css +137 -0
  175. package/src/styles/tokens.css +22 -0
  176. package/src/tauri/index.ts +282 -0
  177. package/src/utils.ts +22 -0
  178. package/src/web/index.ts +376 -0
  179. package/README.md +0 -3
@@ -0,0 +1,583 @@
1
+ <script lang="ts">
2
+ // Full reply candidate; the panel groups shared user input by questionId.
3
+ import { Button } from '#lib/components/ui/button/index.js'
4
+ import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js'
5
+ import { getKitContext } from '../context'
6
+ import { renderMarkdown } from '../markdown'
7
+ import ApprovalCard from './ApprovalCard.svelte'
8
+ import type { ChatTurn, ToolItem } from './run-session.svelte'
9
+ import type { ImageRef, ModelOption, ModelRef } from '../ports'
10
+ import ModelSelect from '../providers/ModelSelect.svelte'
11
+ import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
12
+ import { onDestroy } from 'svelte'
13
+ import CopyIcon from '@lucide/svelte/icons/copy'
14
+ import CheckIcon from '@lucide/svelte/icons/check'
15
+ import FileInputIcon from '@lucide/svelte/icons/file-input'
16
+ import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
17
+ import ToolCallItem from './ToolCallItem.svelte'
18
+ import MenuIcon from '@lucide/svelte/icons/menu'
19
+ import LanguagesIcon from '@lucide/svelte/icons/languages'
20
+ import TrashIcon from '@lucide/svelte/icons/trash-2'
21
+ import FilePlusIcon from '@lucide/svelte/icons/file-plus'
22
+ import AtSignIcon from '@lucide/svelte/icons/at-sign'
23
+ import ThumbsUpIcon from '@lucide/svelte/icons/thumbs-up'
24
+ import FilePenLineIcon from '@lucide/svelte/icons/file-pen-line'
25
+ import SplitIcon from '@lucide/svelte/icons/split'
26
+ import CopyPlusIcon from '@lucide/svelte/icons/copy-plus'
27
+ import SaveIcon from '@lucide/svelte/icons/save'
28
+ import UploadIcon from '@lucide/svelte/icons/upload'
29
+
30
+ let {
31
+ turn,
32
+ onApprove,
33
+ onInsert,
34
+ onRetry,
35
+ modelLabel,
36
+ chosen = false,
37
+ showInput = true,
38
+ busy = false,
39
+ editable = false,
40
+ onEdit,
41
+ onBranch,
42
+ onCopyConversation,
43
+ onSwitchModel,
44
+ modelOptions = null,
45
+ sourceModel = null,
46
+ onTranslate,
47
+ onDelete,
48
+ onUseContext,
49
+ onSaveNote,
50
+ onExportMarkdown,
51
+ }: {
52
+ turn: ChatTurn
53
+ onApprove: (tool: ToolItem, allow: boolean) => void
54
+ onInsert?: ((markdown: string) => void) | undefined
55
+ onRetry?: (() => void) | undefined
56
+ modelLabel?: string
57
+ chosen?: boolean
58
+ showInput?: boolean
59
+ busy?: boolean
60
+ editable?: boolean
61
+ onEdit?: (() => void) | undefined
62
+ onBranch?: (() => void) | undefined
63
+ onCopyConversation?: (() => void) | undefined
64
+ /** Regenerates this reply with the picked model. */
65
+ onSwitchModel?: ((model: ModelRef) => void) | undefined
66
+ modelOptions?: ModelOption[] | null
67
+ sourceModel?: ModelRef | null
68
+ onTranslate?: (() => void) | undefined
69
+ onDelete?: (() => void) | undefined
70
+ onUseContext?: (() => void) | undefined
71
+ onSaveNote?: ((markdown: string, images: ImageRef[]) => Promise<void> | void) | undefined
72
+ onExportMarkdown?: ((markdown: string) => Promise<void> | void) | undefined
73
+ } = $props()
74
+ const { i18n, notify } = getKitContext()
75
+ const { t } = i18n
76
+ const html = $derived(renderMarkdown(turn.text))
77
+ const savable = $derived(turn.text !== '' || turn.generated.length > 0)
78
+ function safeUrl(value: string): string | null {
79
+ try {
80
+ const protocol = new URL(value).protocol
81
+ return protocol === 'http:' || protocol === 'https:' ? value : null
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+ let copied = $state(false)
87
+ let copyTimer: ReturnType<typeof setTimeout> | undefined
88
+ let alive = true
89
+ onDestroy(() => {
90
+ alive = false
91
+ clearTimeout(copyTimer)
92
+ })
93
+
94
+ function insert() {
95
+ try {
96
+ onInsert?.(turn.text)
97
+ } catch {
98
+ notify.toast('error', t('errors.internal'))
99
+ }
100
+ }
101
+
102
+ async function copy() {
103
+ try {
104
+ await navigator.clipboard.writeText(turn.text)
105
+ } catch {
106
+ notify.toast('error', t('chat.copyFailed'))
107
+ return
108
+ }
109
+ if (!alive) return
110
+ copied = true
111
+ clearTimeout(copyTimer)
112
+ copyTimer = setTimeout(() => (copied = false), 1500)
113
+ }
114
+
115
+ async function saveNote() {
116
+ try {
117
+ await onSaveNote?.(turn.text, turn.generated)
118
+ } catch {
119
+ notify.toast('error', t('chat.saveNoteFailed'))
120
+ }
121
+ }
122
+ async function download() {
123
+ let url: string | null = null
124
+ try {
125
+ if (onExportMarkdown) {
126
+ await onExportMarkdown(turn.text)
127
+ return
128
+ }
129
+ url = URL.createObjectURL(new Blob([turn.text], { type: 'text/markdown;charset=utf-8' }))
130
+ const link = document.createElement('a')
131
+ link.href = url
132
+ link.download = `reply-${turn.id}.md`
133
+ document.body.append(link)
134
+ try {
135
+ link.click()
136
+ } finally {
137
+ link.remove()
138
+ }
139
+ } catch {
140
+ notify.toast('error', t('chat.exportFailed'))
141
+ } finally {
142
+ if (url) URL.revokeObjectURL(url)
143
+ }
144
+ }
145
+ // Browsers refuse to navigate a new tab to a `data:` URL, and a strict CSP connect-src
146
+ // blocks fetch(data:); decode locally and hand it over as a blob.
147
+ function openImage(event: MouseEvent, url: string | null) {
148
+ if (!url?.startsWith('data:')) return
149
+ event.preventDefault()
150
+ const comma = url.indexOf(',')
151
+ const mime = url.slice(5, url.indexOf(';'))
152
+ const bytes = Uint8Array.from(atob(url.slice(comma + 1)), (c) => c.charCodeAt(0))
153
+ const blobUrl = URL.createObjectURL(new Blob([bytes], { type: mime }))
154
+ window.open(blobUrl, '_blank', 'noopener')
155
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
156
+ }
157
+ </script>
158
+
159
+ <li class="message-turn flex min-w-0 flex-col gap-2">
160
+ {#if showInput && turn.images.length > 0}
161
+ <ul class="user-images">
162
+ {#each turn.images as image, index (index)}
163
+ <li>
164
+ {#if image.url}<a
165
+ href={image.url}
166
+ target="_blank"
167
+ rel="noopener"
168
+ onclick={(event) => openImage(event, image.url)}
169
+ aria-label={t('chat.openImage', { index: index + 1 })}
170
+ title={t('chat.openImage', { index: index + 1 })}><img src={image.url} alt="" /></a
171
+ >{:else}<span class="image-expired">{t('chat.imageExpired')}</span>{/if}
172
+ </li>
173
+ {/each}
174
+ </ul>
175
+ {/if}
176
+ {#if showInput}<p class="user-message">{turn.input}</p>{/if}
177
+ <div class="assistant-message" class:chosen>
178
+ {#if modelLabel}<div class="reply-source">{modelLabel}</div>{/if}
179
+ {#if turn.reasoningText}
180
+ <details class="thinking text-muted-foreground text-kit-sm">
181
+ <summary class="cursor-pointer">{t('chat.reasoning')}</summary>
182
+ <p class="whitespace-pre-wrap">{turn.reasoningText}</p>
183
+ </details>
184
+ {/if}
185
+ {#each turn.tools as tool (tool.callId)}
186
+ {#if tool.pending === null}
187
+ <ToolCallItem {tool} />
188
+ {:else}
189
+ <ApprovalCard {tool} onAnswer={(allow) => onApprove(tool, allow)} />
190
+ {/if}
191
+ {/each}
192
+ {#if turn.generated.length > 0}
193
+ <ul class="generated-images" aria-label={t('chat.generatedImages')}>
194
+ {#each turn.generated as image, index (index)}
195
+ <li>
196
+ {#if image.url}<a
197
+ href={image.url}
198
+ target="_blank"
199
+ rel="noopener"
200
+ onclick={(event) => openImage(event, image.url)}
201
+ aria-label={t('chat.openGeneratedImage', { index: index + 1 })}
202
+ title={t('chat.openGeneratedImage', { index: index + 1 })}><img src={image.url} alt="" /></a
203
+ >{:else}<span class="image-expired">{t('chat.imageExpired')}</span>{/if}
204
+ </li>
205
+ {/each}
206
+ </ul>
207
+ {/if}
208
+ <div
209
+ class="markdown"
210
+ {@attach (node) => {
211
+ node.innerHTML = html
212
+ }}
213
+ ></div>
214
+ {#if savable || (onRetry && turn.status !== 'running') || onUseContext || onDelete}
215
+ <div class="message-actions">
216
+ {#if turn.text !== ''}
217
+ <Button
218
+ variant="ghost"
219
+ size="icon-xs"
220
+ title={t('chat.copy')}
221
+ aria-label={t('chat.copy')}
222
+ onclick={() => void copy()}
223
+ >
224
+ {#if copied}<CheckIcon />{:else}<CopyIcon />{/if}
225
+ </Button>
226
+ {/if}
227
+ {#if onRetry && turn.status !== 'running'}
228
+ {@const label = t(turn.status === 'failed' ? 'chat.retry' : 'chat.regenerate')}
229
+ <Button variant="ghost" size="icon-xs" title={label} aria-label={label} disabled={busy} onclick={onRetry}
230
+ ><RotateCcwIcon /></Button
231
+ >
232
+ {/if}
233
+ {#if onSwitchModel}<ModelSelect
234
+ value={sourceModel}
235
+ options={modelOptions}
236
+ disabled={busy}
237
+ onchange={(model) => onSwitchModel(model)}
238
+ >
239
+ {#snippet trigger({ props })}<Button
240
+ {...props}
241
+ variant="ghost"
242
+ size="icon-xs"
243
+ title={t('chat.switchModel')}
244
+ aria-label={t('chat.switchModel')}><AtSignIcon /></Button
245
+ >{/snippet}
246
+ </ModelSelect>{/if}
247
+ {#if onTranslate}<Button
248
+ variant="ghost"
249
+ size="icon-xs"
250
+ title={t('chat.translate')}
251
+ aria-label={t('chat.translate')}
252
+ disabled={busy || !turn.text}
253
+ onclick={onTranslate}><LanguagesIcon /></Button
254
+ >{/if}
255
+ {#if onUseContext}<Button
256
+ variant="ghost"
257
+ size="icon-xs"
258
+ title={t('chat.useContext')}
259
+ aria-label={t('chat.useContext')}
260
+ aria-pressed={chosen}
261
+ disabled={busy || turn.status !== 'completed'}
262
+ onclick={onUseContext}><ThumbsUpIcon class={chosen ? 'fill-primary text-primary' : ''} /></Button
263
+ >{/if}
264
+ {#if onSaveNote && savable}<Button
265
+ variant="ghost"
266
+ size="icon-xs"
267
+ title={t('chat.saveNote')}
268
+ aria-label={t('chat.saveNote')}
269
+ disabled={busy || turn.status === 'running'}
270
+ onclick={() => void saveNote()}><FilePlusIcon /></Button
271
+ >{/if}
272
+ {#if onDelete}<Button
273
+ variant="ghost"
274
+ size="icon-xs"
275
+ title={t('chat.deleteReply')}
276
+ aria-label={t('chat.deleteReply')}
277
+ disabled={busy || turn.status === 'running'}
278
+ onclick={onDelete}><TrashIcon /></Button
279
+ >{/if}
280
+ {#if onInsert}
281
+ <Button
282
+ variant="ghost"
283
+ size="icon-xs"
284
+ title={t('chat.insert')}
285
+ aria-label={t('chat.insert')}
286
+ disabled={busy || turn.status === 'running'}
287
+ onclick={insert}
288
+ >
289
+ <FileInputIcon />
290
+ </Button>
291
+ {/if}
292
+ {#if savable || onEdit || onBranch || onCopyConversation}
293
+ <DropdownMenu.Root>
294
+ <DropdownMenu.Trigger>
295
+ {#snippet child({ props })}<Button
296
+ {...props}
297
+ variant="ghost"
298
+ size="icon-xs"
299
+ title={t('chat.more')}
300
+ aria-label={t('chat.more')}><MenuIcon /></Button
301
+ >{/snippet}
302
+ </DropdownMenu.Trigger>
303
+ <DropdownMenu.Content class="w-auto">
304
+ {#if onEdit || onBranch || onCopyConversation}
305
+ <DropdownMenu.Group>
306
+ {#if onEdit}<DropdownMenu.Item disabled={busy || !editable} onSelect={() => onEdit?.()}
307
+ ><FilePenLineIcon />{t('chat.editReply')}</DropdownMenu.Item
308
+ >{/if}
309
+ {#if onBranch}<DropdownMenu.Item disabled={busy} onSelect={() => onBranch?.()}
310
+ ><SplitIcon />{t('chat.branch')}</DropdownMenu.Item
311
+ >{/if}
312
+ {#if onCopyConversation}<DropdownMenu.Item disabled={busy} onSelect={() => onCopyConversation?.()}
313
+ ><CopyPlusIcon />{t('chat.copyConversation')}</DropdownMenu.Item
314
+ >{/if}
315
+ </DropdownMenu.Group>
316
+ <DropdownMenu.Separator />
317
+ {/if}
318
+ <DropdownMenu.Sub>
319
+ <DropdownMenu.SubTrigger disabled={!savable}><SaveIcon />{t('chat.save')}</DropdownMenu.SubTrigger>
320
+ <DropdownMenu.SubContent>
321
+ <DropdownMenu.Item disabled={!turn.text} onSelect={() => void download()}
322
+ >{t('chat.saveFile')}</DropdownMenu.Item
323
+ >
324
+ {#if onSaveNote}<DropdownMenu.Item
325
+ disabled={busy || turn.status === 'running' || !savable}
326
+ onSelect={() => void saveNote()}>{t('chat.saveNote')}</DropdownMenu.Item
327
+ >{/if}
328
+ </DropdownMenu.SubContent>
329
+ </DropdownMenu.Sub>
330
+ <DropdownMenu.Separator />
331
+ <DropdownMenu.Sub>
332
+ <DropdownMenu.SubTrigger disabled={!turn.text}><UploadIcon />{t('chat.export')}</DropdownMenu.SubTrigger
333
+ >
334
+ <DropdownMenu.SubContent>
335
+ <DropdownMenu.Item disabled={!turn.text} onSelect={() => void download()}
336
+ >{t('chat.exportMarkdown')}</DropdownMenu.Item
337
+ >
338
+ </DropdownMenu.SubContent>
339
+ </DropdownMenu.Sub>
340
+ </DropdownMenu.Content>
341
+ </DropdownMenu.Root>
342
+ {/if}
343
+ <span class="text-muted-foreground text-kit-sm" role="status">{copied ? t('chat.copied') : ''}</span>
344
+ </div>
345
+ {/if}
346
+ {#if turn.search.status === 'complete'}
347
+ <section class="flex flex-col gap-1 text-kit-sm" aria-label={t('chat.sources')}>
348
+ <p class="text-muted-foreground">{t('chat.sources')}</p>
349
+ {#each turn.search.hits as hit (hit.url)}
350
+ {@const url = safeUrl(hit.url)}
351
+ {#if url}<a
352
+ class="text-primary flex min-w-0 items-center gap-1 underline"
353
+ href={url}
354
+ target="_blank"
355
+ rel="noopener noreferrer"
356
+ ><ExternalLinkIcon data-icon="inline-start" /><span class="truncate">{hit.title || url}</span></a
357
+ >{/if}
358
+ {/each}
359
+ </section>
360
+ {/if}
361
+ {#if turn.status === 'cancelled'}
362
+ <p class="text-muted-foreground text-kit-sm" role="status">{t('chat.stopped')}</p>
363
+ {:else if turn.status === 'failed'}
364
+ <div class="flex items-center gap-2">
365
+ <p class="text-destructive text-kit-sm" role="alert">
366
+ {turn.error === 'interrupted' ? t('chat.interrupted') : t(`errors.${turn.error ?? 'internal'}`)}
367
+ </p>
368
+ </div>
369
+ {:else if turn.exhausted}
370
+ <p class="text-muted-foreground text-kit-sm">{t('chat.exhausted')}</p>
371
+ {/if}
372
+ </div>
373
+ </li>
374
+
375
+ <style>
376
+ .message-turn,
377
+ .user-message,
378
+ .assistant-message,
379
+ .markdown {
380
+ box-sizing: border-box;
381
+ min-width: 0;
382
+ max-width: 100%;
383
+ overflow-wrap: anywhere;
384
+ }
385
+ .user-images {
386
+ display: flex;
387
+ flex-wrap: wrap;
388
+ justify-content: flex-end;
389
+ gap: 0.375rem;
390
+ margin: 0;
391
+ padding: 0;
392
+ list-style: none;
393
+ }
394
+ .generated-images {
395
+ display: grid;
396
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
397
+ gap: 0.5rem;
398
+ margin: 0;
399
+ padding: 0;
400
+ list-style: none;
401
+ }
402
+ .generated-images a {
403
+ display: block;
404
+ overflow: hidden;
405
+ border: 1px solid var(--color-border);
406
+ border-radius: var(--kit-radius, var(--radius));
407
+ }
408
+ .generated-images a:focus-visible {
409
+ outline: 2px solid var(--color-ring);
410
+ outline-offset: 2px;
411
+ }
412
+ .generated-images img {
413
+ display: block;
414
+ width: 100%;
415
+ height: auto;
416
+ }
417
+ .user-images a,
418
+ .image-expired {
419
+ display: flex;
420
+ align-items: center;
421
+ justify-content: center;
422
+ width: 5rem;
423
+ height: 5rem;
424
+ overflow: hidden;
425
+ border: 1px solid var(--color-border);
426
+ border-radius: var(--kit-radius, var(--radius));
427
+ }
428
+ .user-images a:focus-visible {
429
+ outline: 2px solid var(--color-ring);
430
+ outline-offset: 2px;
431
+ }
432
+ .user-images img {
433
+ width: 100%;
434
+ height: 100%;
435
+ object-fit: cover;
436
+ }
437
+ .image-expired {
438
+ padding: 0.25rem;
439
+ color: var(--color-muted-foreground);
440
+ font-size: var(--kit-font-size-sm, 0.8125rem);
441
+ text-align: center;
442
+ }
443
+ .user-message {
444
+ align-self: flex-end;
445
+ margin: 0;
446
+ padding: 0.625rem 0.75rem;
447
+ border-radius: var(--kit-radius);
448
+ background: var(--color-primary);
449
+ color: var(--color-primary-foreground);
450
+ font-size: var(--kit-text-base);
451
+ white-space: pre-wrap;
452
+ }
453
+ .assistant-message {
454
+ display: flex;
455
+ flex-direction: column;
456
+ gap: 0.75rem;
457
+ padding: 0.75rem;
458
+ border-radius: var(--kit-radius);
459
+ background: var(--color-muted);
460
+ color: var(--color-foreground);
461
+ }
462
+ .assistant-message.chosen {
463
+ outline: 1px solid var(--color-primary);
464
+ }
465
+ .reply-source {
466
+ font-size: var(--kit-text-sm);
467
+ color: var(--color-muted-foreground);
468
+ overflow-wrap: anywhere;
469
+ }
470
+ .thinking {
471
+ min-width: 0;
472
+ }
473
+ .message-actions {
474
+ display: flex;
475
+ align-items: center;
476
+ flex-wrap: wrap;
477
+ gap: 0.25rem;
478
+ border-top: 1px solid var(--color-border);
479
+ padding-top: 0.5rem;
480
+ }
481
+ .markdown {
482
+ font-size: var(--kit-text-base);
483
+ line-height: 1.6;
484
+ }
485
+ .markdown :global(p),
486
+ .markdown :global(ul),
487
+ .markdown :global(ol),
488
+ .markdown :global(blockquote),
489
+ .markdown :global(pre),
490
+ .markdown :global(table) {
491
+ margin: 0.75em 0;
492
+ }
493
+ .markdown :global(h1),
494
+ .markdown :global(h2),
495
+ .markdown :global(h3),
496
+ .markdown :global(h4),
497
+ .markdown :global(h5),
498
+ .markdown :global(h6) {
499
+ margin: 1em 0 0.5em;
500
+ font-weight: 600;
501
+ line-height: 1.35;
502
+ }
503
+ .markdown :global(h1) {
504
+ font-size: 1.4em;
505
+ }
506
+ .markdown :global(h2) {
507
+ font-size: 1.25em;
508
+ }
509
+ .markdown :global(h3) {
510
+ font-size: 1.1em;
511
+ }
512
+ .markdown :global(ul),
513
+ .markdown :global(ol) {
514
+ padding-inline-start: 1.5em;
515
+ }
516
+ .markdown :global(ul) {
517
+ list-style: disc;
518
+ }
519
+ .markdown :global(ol) {
520
+ list-style: decimal;
521
+ }
522
+ .markdown :global(li) {
523
+ margin: 0.25em 0;
524
+ }
525
+ .markdown :global(li::marker) {
526
+ color: var(--color-muted-foreground);
527
+ }
528
+ .markdown :global(blockquote) {
529
+ border-inline-start: 3px solid var(--color-border);
530
+ padding-inline-start: 0.75em;
531
+ color: var(--color-muted-foreground);
532
+ }
533
+ .markdown :global(a) {
534
+ color: var(--color-primary);
535
+ text-decoration: underline;
536
+ overflow-wrap: anywhere;
537
+ }
538
+ .markdown :global(code) {
539
+ border-radius: var(--kit-radius-sm);
540
+ background: var(--color-background);
541
+ padding: 0.1em 0.3em;
542
+ font-size: 0.95em;
543
+ }
544
+ .markdown :global(pre) {
545
+ max-width: 100%;
546
+ overflow-x: auto;
547
+ padding: 0.75em;
548
+ border: 1px solid var(--color-border);
549
+ border-radius: var(--kit-radius-sm);
550
+ background: var(--color-background);
551
+ white-space: pre;
552
+ overflow-wrap: normal;
553
+ }
554
+ .markdown :global(pre code) {
555
+ padding: 0;
556
+ background: none;
557
+ }
558
+ .markdown :global(table) {
559
+ display: block;
560
+ max-width: 100%;
561
+ overflow-x: auto;
562
+ border-collapse: collapse;
563
+ }
564
+ .markdown :global(th),
565
+ .markdown :global(td) {
566
+ padding: 0.4em 0.6em;
567
+ border: 1px solid var(--color-border);
568
+ }
569
+ .markdown :global(th) {
570
+ font-weight: 600;
571
+ }
572
+ .markdown :global(img),
573
+ .markdown :global(video) {
574
+ max-width: 100%;
575
+ height: auto;
576
+ }
577
+ .markdown :global(:first-child) {
578
+ margin-top: 0;
579
+ }
580
+ .markdown :global(:last-child) {
581
+ margin-bottom: 0;
582
+ }
583
+ </style>
@@ -0,0 +1,126 @@
1
+ <script lang="ts">
2
+ // Per-run skill selection above the composer (docs/16 §4.2): metadata only, selected chars / budget.
3
+ // `changed` (a `skill_changed` rejection) opens the current text for re-confirmation (docs/14 §5).
4
+ import * as Select from '#lib/components/ui/select/index.js'
5
+ import { cn } from '#lib/utils.js'
6
+ import { getKitContext } from '../context'
7
+ import { isKitClientError } from '../ports'
8
+ import type { SkillDto, SkillMeta, SkillRef, SkillsPort } from '../ports'
9
+ import SkillPreviewDialog from '../skills/SkillPreviewDialog.svelte'
10
+
11
+ let {
12
+ port,
13
+ value,
14
+ budget,
15
+ changed,
16
+ onchange,
17
+ onresolved,
18
+ }: {
19
+ port: SkillsPort
20
+ value: SkillRef[]
21
+ /** `Budget.skills_chars` of the backend. */
22
+ budget: number
23
+ /** Skill id from a `skill_changed` rejection, or null. */
24
+ changed: string | null
25
+ onchange: (refs: SkillRef[]) => void
26
+ onresolved: () => void
27
+ } = $props()
28
+
29
+ const { i18n, notify } = getKitContext()
30
+ const { t } = i18n
31
+ let skills = $state<SkillMeta[]>([])
32
+ let reconfirm = $state<SkillDto | null>(null)
33
+ let reconfirmOpen = $state(false)
34
+
35
+ const ids = $derived(value.map((r) => r.id))
36
+ const chars = $derived(skills.filter((s) => ids.includes(s.id)).reduce((sum, s) => sum + s.bodyChars, 0))
37
+
38
+ async function refresh() {
39
+ try {
40
+ skills = await port.list()
41
+ } catch (error) {
42
+ notify.toast('error', t(`errors.${isKitClientError(error) ? error.code : 'internal'}`))
43
+ }
44
+ }
45
+ void refresh()
46
+
47
+ // Kept refs keep their pinned revision: an edited skill is caught by `snapshot`, not silently re-pinned.
48
+ function pick(next: string[]) {
49
+ onchange(
50
+ next.flatMap((id) => {
51
+ const kept = value.find((r) => r.id === id)
52
+ const meta = skills.find((s) => s.id === id)
53
+ return kept ? [kept] : meta ? [{ id, revision: meta.revision }] : []
54
+ }),
55
+ )
56
+ }
57
+
58
+ $effect(() => {
59
+ if (changed !== null) void openChanged(changed)
60
+ })
61
+
62
+ async function openChanged(id: string) {
63
+ void refresh()
64
+ try {
65
+ reconfirm = await port.get(id)
66
+ reconfirmOpen = true
67
+ } catch {
68
+ // Deleted since it was selected: drop it.
69
+ onchange(value.filter((r) => r.id !== id))
70
+ notify.toast('info', t('skills.removed'))
71
+ onresolved()
72
+ }
73
+ }
74
+
75
+ function confirmChanged() {
76
+ const skill = reconfirm
77
+ if (skill) onchange(value.map((r) => (r.id === skill.id ? { id: r.id, revision: skill.revision } : r)))
78
+ reconfirm = null
79
+ reconfirmOpen = false
80
+ onresolved()
81
+ }
82
+
83
+ // Closing the dialog without confirming deselects the changed skill.
84
+ $effect(() => {
85
+ if (!reconfirmOpen && reconfirm) {
86
+ const id = reconfirm.id
87
+ reconfirm = null
88
+ onchange(value.filter((r) => r.id !== id))
89
+ onresolved()
90
+ }
91
+ })
92
+ </script>
93
+
94
+ <div class="flex items-center gap-2 text-kit-sm">
95
+ <Select.Root
96
+ type="multiple"
97
+ value={ids}
98
+ onValueChange={pick}
99
+ onOpenChange={(open) => {
100
+ if (open) void refresh()
101
+ }}
102
+ >
103
+ <Select.Trigger size="sm" class="w-64" aria-label={t('skills.picker')}>
104
+ {value.length === 0 ? t('skills.picker_none') : t('skills.picker_count', { count: value.length })}
105
+ </Select.Trigger>
106
+ <Select.Content>
107
+ <Select.Group>
108
+ {#each skills as skill (skill.id)}
109
+ <Select.Item value={skill.id} label={skill.name} />
110
+ {:else}
111
+ <p class="text-muted-foreground px-2 py-1">{t('skills.empty')}</p>
112
+ {/each}
113
+ </Select.Group>
114
+ </Select.Content>
115
+ </Select.Root>
116
+ <span class={cn('text-muted-foreground', chars > budget && 'text-destructive')}>
117
+ {t('skills.budget', { chars, budget })}
118
+ </span>
119
+ </div>
120
+ <SkillPreviewDialog
121
+ bind:open={reconfirmOpen}
122
+ skill={reconfirm}
123
+ title={t('skills.changed_title')}
124
+ confirmLabel={t('skills.use')}
125
+ onconfirm={confirmChanged}
126
+ />