@ouraihub/aether-ui 0.1.0 → 0.1.2
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/package.json +3 -3
- package/src/chat/ApprovalCard.svelte +8 -2
- package/src/chat/ChatComposer.svelte +319 -0
- package/src/chat/ChatMessage.svelte +262 -0
- package/src/chat/ChatPanel.css +386 -0
- package/src/chat/ChatPanel.svelte +276 -1880
- package/src/chat/ChatPanelApprovals.svelte +28 -0
- package/src/chat/ChatPanelComposer.svelte +336 -0
- package/src/chat/ChatPanelDialogs.svelte +242 -0
- package/src/chat/ChatPanelHeader.svelte +108 -0
- package/src/chat/ChatPanelMessages.svelte +166 -0
- package/src/chat/ChatPanelMode.svelte +190 -0
- package/src/chat/ChatPanelState.svelte.ts +1 -0
- package/src/chat/ChatPanelToolbar.svelte +436 -0
- package/src/chat/ChatSurface.svelte +168 -0
- package/src/chat/ContextUsageStatus.svelte +97 -0
- package/src/chat/MessageItem.svelte +111 -214
- package/src/chat/ModelEffortPicker.svelte +326 -0
- package/src/chat/PickerEffort.svelte +71 -0
- package/src/chat/ReasoningControl.svelte +184 -0
- package/src/chat/ReasoningSlider.svelte +174 -0
- package/src/chat/SkillPicker.svelte +14 -6
- package/src/chat/UsageControl.svelte +223 -0
- package/src/chat/UsageDetails.svelte +44 -90
- package/src/chat/chat-attachments.svelte.ts +78 -0
- package/src/chat/chat-panel-history.svelte.ts +430 -0
- package/src/chat/chat-panel-models.svelte.ts +97 -0
- package/src/chat/chat-panel-props.ts +38 -0
- package/src/chat/context-status.ts +101 -0
- package/src/chat/icons/LICENSE-REMIX +186 -0
- package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
- package/src/chat/icons/PathIcon.svelte +8 -0
- package/src/chat/icons/effort-icons.ts +65 -0
- package/src/chat/icons/status-icons.ts +105 -0
- package/src/chat/index.ts +25 -1
- package/src/chat/model-effort.ts +177 -0
- package/src/chat/run-session-history.ts +56 -0
- package/src/chat/run-session-types.ts +69 -0
- package/src/chat/run-session.svelte.ts +73 -135
- package/src/chat/test-support/chat-fixture.ts +194 -0
- package/src/chat/test-support/conversation-fixtures.ts +32 -0
- package/src/chat/test-support/run-session-fixture.ts +112 -0
- package/src/dev/Demo.svelte +3 -1
- package/src/i18n/en-chat.d.ts +1 -0
- package/src/i18n/en-chat.js +201 -0
- package/src/i18n/en.ts +58 -180
- package/src/i18n/index.ts +7 -2
- package/src/i18n/zh-CN-chat.d.ts +1 -0
- package/src/i18n/zh-CN-chat.js +190 -0
- package/src/i18n/zh-CN.ts +8 -153
- package/src/logs/LogPanel.svelte +9 -3
- package/src/logs/LogSettings.svelte +3 -1
- package/src/mcp/McpClientEditor.svelte +204 -0
- package/src/mcp/McpClientSettings.svelte +57 -130
- package/src/mcp/McpServerSettings.svelte +47 -11
- package/src/mcp/input.ts +2 -1
- package/src/ports/contract.ts +11 -2
- package/src/ports/fake-optional.ts +81 -0
- package/src/ports/fake.ts +90 -86
- package/src/ports/search.ts +11 -2
- package/src/ports/test-support/fake-fixture.ts +63 -0
- package/src/providers/EndpointKeysDialog.svelte +27 -13
- package/src/providers/EndpointModels.svelte +311 -0
- package/src/providers/EndpointPanel.svelte +33 -258
- package/src/providers/ModelPickerDialog.svelte +59 -16
- package/src/providers/ModelPricing.svelte +125 -0
- package/src/providers/ModelSelect.svelte +22 -8
- package/src/providers/ModelSettingsDialog.svelte +61 -126
- package/src/providers/ProviderDialogs.svelte +120 -0
- package/src/providers/ProviderList.svelte +159 -0
- package/src/providers/ProvidersSettings.svelte +67 -207
- package/src/providers/RequestConfigDialog.svelte +40 -12
- package/src/providers/SecretInput.svelte +11 -3
- package/src/providers/draft.ts +39 -13
- package/src/providers/edit-session.ts +31 -7
- package/src/providers/reasoning.ts +18 -4
- package/src/providers/revision.svelte.ts +3 -1
- package/src/providers/test-support/providers-fixture.ts +120 -0
- package/src/search/SearchAdvancedTools.svelte +182 -0
- package/src/search/SearchProviderCard.svelte +195 -0
- package/src/search/SearchSettings.svelte +62 -265
- package/src/search/settings-flow.ts +8 -3
- package/src/skills/SkillAdd.svelte +51 -18
- package/src/skills/SkillAuthoring.svelte +69 -20
- package/src/skills/SkillEditor.svelte +13 -3
- package/src/skills/SkillPreviewDialog.svelte +19 -5
- package/src/skills/SkillsSettings.svelte +11 -4
- package/src/skills/test-support/skills-fixture.ts +139 -0
- package/src/tauri/index.ts +23 -7
- package/src/web/index.ts +90 -25
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { SvelteMap } from 'svelte/reactivity'
|
|
2
|
+
import type { KitNotify } from '../context'
|
|
3
|
+
import type {
|
|
4
|
+
Conversation,
|
|
5
|
+
ConversationClient,
|
|
6
|
+
ConversationSummary,
|
|
7
|
+
ModelOption,
|
|
8
|
+
ModelRef,
|
|
9
|
+
} from '../ports'
|
|
10
|
+
import type { ChatTurn, RunSession } from './run-session.svelte'
|
|
11
|
+
|
|
12
|
+
type Translate = (key: string, values?: Record<string, string | number>) => string
|
|
13
|
+
|
|
14
|
+
type HistoryDependencies = {
|
|
15
|
+
history: ConversationClient | undefined
|
|
16
|
+
getSession: () => RunSession | null
|
|
17
|
+
getModelOptions: () => ModelOption[] | null
|
|
18
|
+
getModelError: () => string | null
|
|
19
|
+
setDraft: (value: string) => void
|
|
20
|
+
bumpDraftRevision: () => void
|
|
21
|
+
setFullAccess: (value: boolean) => void
|
|
22
|
+
getScope: () => string | undefined
|
|
23
|
+
getLocale: () => string
|
|
24
|
+
onConversationChange?: ((id: string | null) => void) | undefined
|
|
25
|
+
bumpScrollRevision: () => void
|
|
26
|
+
notify: KitNotify
|
|
27
|
+
t: Translate
|
|
28
|
+
errorText: (error: unknown) => string
|
|
29
|
+
consumeBase: () => Parameters<RunSession['translate']>[0]
|
|
30
|
+
discardRecovery: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ChatPanelHistoryState {
|
|
34
|
+
readonly history: ConversationClient | undefined
|
|
35
|
+
conversations = $state<ConversationSummary[]>([])
|
|
36
|
+
shownSummary = $state<ConversationSummary | null>(null)
|
|
37
|
+
historyOpen = $state(false)
|
|
38
|
+
historyBusy = $state(false)
|
|
39
|
+
historyLoading = $state(false)
|
|
40
|
+
historyError = $state<string | null>(null)
|
|
41
|
+
retryHistory = $state<() => Promise<void>>(async () => {
|
|
42
|
+
await this.refresh()
|
|
43
|
+
})
|
|
44
|
+
renameTitle = $state('')
|
|
45
|
+
renaming = $state(false)
|
|
46
|
+
restored = $state(false)
|
|
47
|
+
restoreBlocked = $state(false)
|
|
48
|
+
replyDialog = $state<'edit' | 'translate' | null>(null)
|
|
49
|
+
replyOpen = $state(false)
|
|
50
|
+
sourceReply = $state<ChatTurn | null>(null)
|
|
51
|
+
editDraft = $state('')
|
|
52
|
+
expectedText = ''
|
|
53
|
+
targetLanguage = $state('')
|
|
54
|
+
replyModel = $state<ModelRef | null>(null)
|
|
55
|
+
replyError = $state<string | null>(null)
|
|
56
|
+
viewGeneration = $state(0)
|
|
57
|
+
disposed = $state(false)
|
|
58
|
+
#listGeneration = 0
|
|
59
|
+
#reported: string | null
|
|
60
|
+
readonly #deps: HistoryDependencies
|
|
61
|
+
|
|
62
|
+
constructor(deps: HistoryDependencies, initialConversationId: string | null) {
|
|
63
|
+
this.#deps = deps
|
|
64
|
+
this.history = deps.history
|
|
65
|
+
this.#reported = initialConversationId
|
|
66
|
+
this.retryHistory = this.refresh
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get title() {
|
|
70
|
+
const session = this.#deps.getSession()
|
|
71
|
+
return (
|
|
72
|
+
(this.shownSummary?.id === session?.conversationId ? this.shownSummary?.title : null) ??
|
|
73
|
+
this.conversations.find((conversation) => conversation.id === session?.conversationId)
|
|
74
|
+
?.title ??
|
|
75
|
+
this.#deps.t('chat.newConversation')
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get groups() {
|
|
80
|
+
const grouped = new SvelteMap<string, ChatTurn[]>()
|
|
81
|
+
for (const turn of this.#deps.getSession()?.turns ?? []) {
|
|
82
|
+
const replies = grouped.get(turn.questionId)
|
|
83
|
+
if (replies) replies.push(turn)
|
|
84
|
+
else grouped.set(turn.questionId, [turn])
|
|
85
|
+
}
|
|
86
|
+
return [...grouped.entries()]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get actionBusy() {
|
|
90
|
+
return this.historyBusy || this.#deps.getSession()?.running === true || this.restoreBlocked
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get validReplyModel() {
|
|
94
|
+
const model = this.replyModel
|
|
95
|
+
return (
|
|
96
|
+
this.#deps
|
|
97
|
+
.getModelOptions()
|
|
98
|
+
?.some(
|
|
99
|
+
(option) => option.endpointId === model?.endpointId && option.modelId === model?.modelId,
|
|
100
|
+
) === true
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get isRestored() {
|
|
105
|
+
return this.restored
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
dispose = () => {
|
|
109
|
+
this.disposed = true
|
|
110
|
+
this.viewGeneration++
|
|
111
|
+
this.#listGeneration++
|
|
112
|
+
this.#deps.setFullAccess(false)
|
|
113
|
+
this.#deps.getSession()?.dispose()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
syncConversation = () => {
|
|
117
|
+
const session = this.#deps.getSession()
|
|
118
|
+
const id = session?.conversationId ?? null
|
|
119
|
+
if (!this.restored || this.restoreBlocked || id === this.#reported) return
|
|
120
|
+
this.#reported = id
|
|
121
|
+
this.#deps.onConversationChange?.(id)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
refresh = async () => {
|
|
125
|
+
if (!this.history) return
|
|
126
|
+
const generation = ++this.#listGeneration
|
|
127
|
+
this.historyLoading = true
|
|
128
|
+
try {
|
|
129
|
+
const next = await this.history.list(this.#deps.getScope())
|
|
130
|
+
if (this.disposed || generation !== this.#listGeneration) return
|
|
131
|
+
this.conversations = next
|
|
132
|
+
if (this.retryHistory === this.refresh) this.historyError = null
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!this.disposed && generation === this.#listGeneration && !this.restoreBlocked) {
|
|
135
|
+
this.historyError = this.#deps.errorText(error)
|
|
136
|
+
this.retryHistory = this.refresh
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
if (!this.disposed && generation === this.#listGeneration) this.historyLoading = false
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
restore = async (id: string | null) => {
|
|
144
|
+
if (id !== null && this.history && this.#deps.getSession()) {
|
|
145
|
+
this.restoreBlocked = true
|
|
146
|
+
await this.open(id)
|
|
147
|
+
}
|
|
148
|
+
this.restored = true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
open = async (id: string) => {
|
|
152
|
+
const session = this.#deps.getSession()
|
|
153
|
+
if (!this.history || !session || session.running || this.historyBusy) return
|
|
154
|
+
const generation = ++this.viewGeneration
|
|
155
|
+
this.#deps.setFullAccess(false)
|
|
156
|
+
this.historyBusy = true
|
|
157
|
+
try {
|
|
158
|
+
const conversation = await this.history.get(id)
|
|
159
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
160
|
+
session.show(id, conversation.turns, conversation.summary.selectedTurnId)
|
|
161
|
+
this.shownSummary = conversation.summary
|
|
162
|
+
this.restoreBlocked = false
|
|
163
|
+
this.restored = true
|
|
164
|
+
this.historyError = null
|
|
165
|
+
this.retryHistory = this.refresh
|
|
166
|
+
this.historyOpen = false
|
|
167
|
+
this.renaming = false
|
|
168
|
+
this.#deps.bumpScrollRevision()
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
171
|
+
this.historyError = this.#deps.errorText(error)
|
|
172
|
+
this.retryHistory = () => this.open(id)
|
|
173
|
+
} finally {
|
|
174
|
+
if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
newConversation = () => {
|
|
179
|
+
const session = this.#deps.getSession()
|
|
180
|
+
if (!session || session.running || this.historyBusy) return
|
|
181
|
+
this.viewGeneration++
|
|
182
|
+
this.#deps.setFullAccess(false)
|
|
183
|
+
session.show(null)
|
|
184
|
+
this.shownSummary = null
|
|
185
|
+
this.restoreBlocked = false
|
|
186
|
+
this.restored = true
|
|
187
|
+
this.historyError = null
|
|
188
|
+
this.historyOpen = false
|
|
189
|
+
this.renaming = false
|
|
190
|
+
this.#deps.bumpScrollRevision()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
rename = async () => {
|
|
194
|
+
const session = this.#deps.getSession()
|
|
195
|
+
const id = session?.conversationId
|
|
196
|
+
const next = this.renameTitle.trim()
|
|
197
|
+
if (!this.history || !id || !next || session?.running || this.historyBusy) return
|
|
198
|
+
const generation = this.viewGeneration
|
|
199
|
+
this.historyBusy = true
|
|
200
|
+
try {
|
|
201
|
+
const summary = await this.history.rename(id, next)
|
|
202
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
203
|
+
this.shownSummary = summary
|
|
204
|
+
this.conversations = this.conversations.map((item) => (item.id === id ? summary : item))
|
|
205
|
+
this.renaming = false
|
|
206
|
+
this.historyError = null
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
209
|
+
this.historyError = this.#deps.errorText(error)
|
|
210
|
+
this.retryHistory = this.rename
|
|
211
|
+
} finally {
|
|
212
|
+
if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
remove = async () => {
|
|
217
|
+
const session = this.#deps.getSession()
|
|
218
|
+
const id = session?.conversationId
|
|
219
|
+
if (!this.history || !session || !id || session.running || this.historyBusy) return
|
|
220
|
+
const generation = this.viewGeneration
|
|
221
|
+
this.historyBusy = true
|
|
222
|
+
try {
|
|
223
|
+
const ok = await this.#deps.notify.confirm({
|
|
224
|
+
title: this.#deps.t('chat.deleteTitle'),
|
|
225
|
+
body: this.#deps.t('chat.deleteBody'),
|
|
226
|
+
danger: true,
|
|
227
|
+
})
|
|
228
|
+
if (!ok || this.disposed || generation !== this.viewGeneration) return
|
|
229
|
+
await this.history.remove(id)
|
|
230
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
231
|
+
if (session.conversationId === id) {
|
|
232
|
+
session.show(null)
|
|
233
|
+
this.shownSummary = null
|
|
234
|
+
this.historyOpen = false
|
|
235
|
+
this.renaming = false
|
|
236
|
+
this.#deps.bumpScrollRevision()
|
|
237
|
+
}
|
|
238
|
+
this.historyError = null
|
|
239
|
+
void this.refresh()
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
242
|
+
this.historyError = this.#deps.errorText(error)
|
|
243
|
+
this.retryHistory = this.remove
|
|
244
|
+
} finally {
|
|
245
|
+
if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
sourceModel = (turn: ChatTurn): ModelRef | null =>
|
|
250
|
+
turn.endpointId && turn.modelId ? { endpointId: turn.endpointId, modelId: turn.modelId } : null
|
|
251
|
+
|
|
252
|
+
sourceLabel = (turn: ChatTurn) => {
|
|
253
|
+
const option = this.#deps
|
|
254
|
+
.getModelOptions()
|
|
255
|
+
?.find((item) => item.endpointId === turn.endpointId && item.modelId === turn.modelId)
|
|
256
|
+
return option
|
|
257
|
+
? (option.name ?? option.modelId)
|
|
258
|
+
: turn.modelId
|
|
259
|
+
? `${turn.endpointId ?? this.#deps.t('chat.unknown')} · ${turn.modelId}`
|
|
260
|
+
: this.#deps.t('chat.noSourceModel')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#hasChildren(turn: ChatTurn) {
|
|
264
|
+
return (
|
|
265
|
+
this.#deps
|
|
266
|
+
.getSession()
|
|
267
|
+
?.turns.some((child) => child.parentId === turn.id || child.supersedesId === turn.id) ===
|
|
268
|
+
true
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
canEdit = (turn: ChatTurn) => turn.status !== 'running' && !this.#hasChildren(turn)
|
|
273
|
+
|
|
274
|
+
editReply = (turn: ChatTurn) => {
|
|
275
|
+
this.sourceReply = turn
|
|
276
|
+
this.editDraft = turn.text
|
|
277
|
+
this.expectedText = turn.text
|
|
278
|
+
this.replyDialog = 'edit'
|
|
279
|
+
this.replyOpen = true
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
translateDialog = (turn: ChatTurn) => {
|
|
283
|
+
this.sourceReply = turn
|
|
284
|
+
this.replyModel = this.sourceModel(turn)
|
|
285
|
+
this.targetLanguage = this.#deps.getLocale()
|
|
286
|
+
this.replyError = null
|
|
287
|
+
this.replyDialog = 'translate'
|
|
288
|
+
this.replyOpen = true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#showConversation(conversation: Conversation) {
|
|
292
|
+
this.viewGeneration++
|
|
293
|
+
this.#deps.setFullAccess(false)
|
|
294
|
+
this.#deps
|
|
295
|
+
.getSession()
|
|
296
|
+
?.show(conversation.summary.id, conversation.turns, conversation.summary.selectedTurnId)
|
|
297
|
+
this.shownSummary = conversation.summary
|
|
298
|
+
this.#deps.bumpScrollRevision()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
mutateReply = async (turn: ChatTurn, action: 'select' | 'remove' | 'branch' | 'copy') => {
|
|
302
|
+
const session = this.#deps.getSession()
|
|
303
|
+
const id = session?.conversationId
|
|
304
|
+
if (!this.history || !session || !id || this.actionBusy) return
|
|
305
|
+
let generation = this.viewGeneration
|
|
306
|
+
this.historyBusy = true
|
|
307
|
+
try {
|
|
308
|
+
if (action === 'remove') {
|
|
309
|
+
if (turn.status === 'running') return
|
|
310
|
+
const reparent = session.turns.some((child) => child.parentId === turn.id)
|
|
311
|
+
if (
|
|
312
|
+
!(await this.#deps.notify.confirm({
|
|
313
|
+
title: this.#deps.t('chat.deleteReplyTitle'),
|
|
314
|
+
body: reparent ? this.#deps.t('chat.deleteReparentBody') : '',
|
|
315
|
+
danger: true,
|
|
316
|
+
}))
|
|
317
|
+
)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
321
|
+
const conversation =
|
|
322
|
+
action === 'select' || action === 'branch'
|
|
323
|
+
? await this.history.selectTurn(id, turn.id)
|
|
324
|
+
: action === 'remove'
|
|
325
|
+
? await this.history.removeTurn(id, turn.id)
|
|
326
|
+
: await this.history.fork(id, turn.id)
|
|
327
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
328
|
+
this.#showConversation(conversation)
|
|
329
|
+
generation = this.viewGeneration
|
|
330
|
+
if (action === 'branch') {
|
|
331
|
+
this.#deps.setDraft('')
|
|
332
|
+
this.#deps.bumpDraftRevision()
|
|
333
|
+
this.#deps.discardRecovery()
|
|
334
|
+
}
|
|
335
|
+
this.historyError = null
|
|
336
|
+
void this.refresh()
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
339
|
+
this.#deps.notify.toast('error', this.#deps.errorText(error))
|
|
340
|
+
} finally {
|
|
341
|
+
if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
retryReply = async (turn: ChatTurn) => {
|
|
346
|
+
const session = this.#deps.getSession()
|
|
347
|
+
if (!session || this.actionBusy) return
|
|
348
|
+
const source = this.sourceModel(turn)
|
|
349
|
+
const options = this.#deps.getModelOptions()
|
|
350
|
+
if (this.history && turn.persisted) {
|
|
351
|
+
if (options === null || this.#deps.getModelError()) {
|
|
352
|
+
this.#deps.notify.toast(
|
|
353
|
+
'error',
|
|
354
|
+
this.#deps.getModelError() ?? this.#deps.t('chat.modelsLoading'),
|
|
355
|
+
)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
if (
|
|
359
|
+
!source ||
|
|
360
|
+
!options.some(
|
|
361
|
+
(option) => option.endpointId === source.endpointId && option.modelId === source.modelId,
|
|
362
|
+
)
|
|
363
|
+
) {
|
|
364
|
+
this.#deps.notify.toast('error', this.#deps.t('chat.sourceModelUnavailable'))
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
this.#deps.bumpScrollRevision()
|
|
369
|
+
await session.retry(this.#deps.consumeBase(), turn.id)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
switchModel = async (turn: ChatTurn, model: ModelRef) => {
|
|
373
|
+
const session = this.#deps.getSession()
|
|
374
|
+
if (!session || this.actionBusy) return
|
|
375
|
+
this.#deps.bumpScrollRevision()
|
|
376
|
+
if (!(await session.retry(this.#deps.consumeBase(), turn.id, model)))
|
|
377
|
+
this.#deps.notify.toast('error', this.#deps.t(`errors.${session.actionError ?? 'internal'}`))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
submitReply = async () => {
|
|
381
|
+
const session = this.#deps.getSession()
|
|
382
|
+
const turn = this.sourceReply
|
|
383
|
+
if (this.disposed || !session || !turn || this.actionBusy) return
|
|
384
|
+
let generation = this.viewGeneration
|
|
385
|
+
this.replyError = null
|
|
386
|
+
if (this.replyDialog === 'edit') {
|
|
387
|
+
const id = session.conversationId
|
|
388
|
+
if (
|
|
389
|
+
!this.history ||
|
|
390
|
+
!id ||
|
|
391
|
+
!this.editDraft.trim() ||
|
|
392
|
+
this.editDraft.length > 24_000 ||
|
|
393
|
+
!this.canEdit(turn)
|
|
394
|
+
)
|
|
395
|
+
return
|
|
396
|
+
this.historyBusy = true
|
|
397
|
+
try {
|
|
398
|
+
const conversation = await this.history.editTurn(
|
|
399
|
+
id,
|
|
400
|
+
turn.id,
|
|
401
|
+
this.editDraft,
|
|
402
|
+
this.expectedText,
|
|
403
|
+
)
|
|
404
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
405
|
+
this.#showConversation(conversation)
|
|
406
|
+
generation = this.viewGeneration
|
|
407
|
+
this.replyOpen = false
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
410
|
+
this.replyError = this.#deps.errorText(error)
|
|
411
|
+
} finally {
|
|
412
|
+
if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
if (!this.validReplyModel || !this.replyModel) return
|
|
416
|
+
if (!this.targetLanguage.trim() || this.targetLanguage.trim().length > 80) return
|
|
417
|
+
const accepted = await session.translate(
|
|
418
|
+
{ ...this.#deps.consumeBase(), model: this.replyModel },
|
|
419
|
+
turn.id,
|
|
420
|
+
this.targetLanguage.trim(),
|
|
421
|
+
)
|
|
422
|
+
if (this.disposed || generation !== this.viewGeneration) return
|
|
423
|
+
if (accepted) this.replyOpen = false
|
|
424
|
+
else
|
|
425
|
+
this.replyError = session.actionError
|
|
426
|
+
? this.#deps.t(`errors.${session.actionError}`)
|
|
427
|
+
: this.#deps.t('errors.internal')
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Chat model catalog behind the composer picker (docs/31 §5.1). Provider metadata only enriches
|
|
2
|
+
// rows; a failed `list()` never blocks model selection.
|
|
3
|
+
import type { KitI18n } from '../i18n'
|
|
4
|
+
import type { ModelOption, ModelRef, Provider, ProvidersPort, ReasoningEffort } from '../ports'
|
|
5
|
+
import { nearestEffort, REASONING_EFFORTS } from '../providers/reasoning'
|
|
6
|
+
import { modelKey, pickerModels, type PickerModel, type PickerValue } from './model-effort'
|
|
7
|
+
|
|
8
|
+
export class ChatPanelModels {
|
|
9
|
+
options = $state<ModelOption[] | null>(null)
|
|
10
|
+
providers = $state<Provider[] | null>(null)
|
|
11
|
+
error = $state<string | null>(null)
|
|
12
|
+
refreshing = $state(false)
|
|
13
|
+
notice = $state('')
|
|
14
|
+
readonly #port: ProvidersPort | undefined
|
|
15
|
+
readonly #i18n: KitI18n
|
|
16
|
+
readonly #errorText: (error: unknown) => string
|
|
17
|
+
#generation = 0
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
port: ProvidersPort | undefined,
|
|
21
|
+
i18n: KitI18n,
|
|
22
|
+
errorText: (error: unknown) => string,
|
|
23
|
+
) {
|
|
24
|
+
this.#port = port
|
|
25
|
+
this.#i18n = i18n
|
|
26
|
+
this.#errorText = errorText
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get picker(): PickerModel[] {
|
|
30
|
+
return pickerModels(this.options ?? [], this.providers, this.#i18n)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Rereads in place: nulling options would reset the chosen effort (docs/41 §3.5). */
|
|
34
|
+
async load(announce = false): Promise<void> {
|
|
35
|
+
const generation = ++this.#generation
|
|
36
|
+
this.error = null
|
|
37
|
+
if (!this.#port) {
|
|
38
|
+
this.options = []
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
this.refreshing = announce
|
|
42
|
+
const [options, providers] = await Promise.allSettled([
|
|
43
|
+
this.#port.modelOptions({ modelType: 'chat' }),
|
|
44
|
+
this.#port.list(),
|
|
45
|
+
])
|
|
46
|
+
if (generation !== this.#generation) return
|
|
47
|
+
this.refreshing = false
|
|
48
|
+
if (options.status === 'rejected') {
|
|
49
|
+
this.error = this.#errorText(options.reason)
|
|
50
|
+
if (announce) this.notice = this.error
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
this.options = options.value
|
|
54
|
+
this.providers = providers.status === 'fulfilled' ? providers.value : null
|
|
55
|
+
if (announce)
|
|
56
|
+
this.notice = this.#i18n.t('chat.picker.reloaded', { count: options.value.length })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#find(key: string): ModelOption | undefined {
|
|
60
|
+
return this.options?.find((item) => modelKey(item.endpointId, item.modelId) === key)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Hands the host the model behind the key; a failed save is shown in the picker notice. */
|
|
64
|
+
async saveDefault(
|
|
65
|
+
value: PickerValue,
|
|
66
|
+
save: (model: ModelRef, effort: ReasoningEffort) => Promise<void> | void,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const option = this.#find(value.key)
|
|
69
|
+
if (!option) return
|
|
70
|
+
try {
|
|
71
|
+
await save({ endpointId: option.endpointId, modelId: option.modelId }, value.effort)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
this.notice = this.#errorText(error)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A model switch keeps the saved effort policy (nearest allowed level); only a level the user
|
|
79
|
+
* picked on the same model, or typed as a search suffix, is written back.
|
|
80
|
+
*/
|
|
81
|
+
select(
|
|
82
|
+
value: PickerValue,
|
|
83
|
+
picked: ModelRef | null,
|
|
84
|
+
chosen: ReasoningEffort,
|
|
85
|
+
pick: (ref: ModelRef) => void,
|
|
86
|
+
choose: (effort: ReasoningEffort) => void,
|
|
87
|
+
): void {
|
|
88
|
+
const option = this.#find(value.key)
|
|
89
|
+
if (!option) return
|
|
90
|
+
const same = picked !== null && modelKey(picked.endpointId, picked.modelId) === value.key
|
|
91
|
+
if (!same) pick({ endpointId: option.endpointId, modelId: option.modelId })
|
|
92
|
+
const declared = option.reasoning
|
|
93
|
+
const allowed = REASONING_EFFORTS.filter((effort) => declared?.efforts.includes(effort))
|
|
94
|
+
const implied = nearestEffort(chosen, allowed)
|
|
95
|
+
if (value.effort !== implied && (same || value.effort !== 'default')) choose(value.effort)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ImageRef, ModelRef, PermissionMode, ReasoningEffort, SkillRef } from '../ports'
|
|
2
|
+
|
|
3
|
+
/** Host inputs for the chat container, independent of its private view composition. */
|
|
4
|
+
export interface ChatPanelProps {
|
|
5
|
+
purpose?: string
|
|
6
|
+
model?: ModelRef | null
|
|
7
|
+
scope?: string
|
|
8
|
+
/** Host context can be removed for the next message without changing conversation ownership. */
|
|
9
|
+
context?: { kind: string; id: string; label: string } | null
|
|
10
|
+
skills?: SkillRef[]
|
|
11
|
+
onSkillsChange?: (refs: SkillRef[]) => void
|
|
12
|
+
skillsBudget?: number
|
|
13
|
+
onInsert?: (markdown: string) => void
|
|
14
|
+
onSaveNote?: (markdown: string, images: ImageRef[]) => Promise<void> | void
|
|
15
|
+
onExportMarkdown?: (markdown: string) => Promise<void> | void
|
|
16
|
+
onOpenSettings?: () => void
|
|
17
|
+
/** A failed restore stays visible until retried or replaced with a new conversation. */
|
|
18
|
+
conversationId?: string | null
|
|
19
|
+
onConversationChange?: (id: string | null) => void
|
|
20
|
+
onClose?: () => void
|
|
21
|
+
/** A host painting model gates image generation. */
|
|
22
|
+
paintingModel?: boolean
|
|
23
|
+
/** Ordered pinned tool ids; omitted means the default toolbar. */
|
|
24
|
+
toolbar?: string[]
|
|
25
|
+
onToolbarChange?: (ids: string[]) => void
|
|
26
|
+
/** Saved preference mapped to the selected model's nearest supported effort. */
|
|
27
|
+
reasoningEffort?: ReasoningEffort
|
|
28
|
+
onReasoningEffortChange?: (effort: ReasoningEffort) => void
|
|
29
|
+
/** Host default for new conversations; enables「设为默认」when `onDefaultModelChange` is set. */
|
|
30
|
+
defaultModel?: ModelRef | null
|
|
31
|
+
/** Saved default effort, independent of the current session choice. */
|
|
32
|
+
defaultReasoningEffort?: ReasoningEffort
|
|
33
|
+
/** The host persists the default model and effort; the picker never writes storage itself. */
|
|
34
|
+
onDefaultModelChange?: (model: ModelRef, effort: ReasoningEffort) => Promise<void> | void
|
|
35
|
+
permissionModes?: PermissionMode[]
|
|
36
|
+
/** Full access stays private and is consumed once, never saved as a preference. */
|
|
37
|
+
permissionMode?: PermissionMode
|
|
38
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Pure rules for the inline context status beside Send (docs/31 §4.3). No I/O.
|
|
2
|
+
import type { ContextUsage, Cost, TokenUsage } from '../ports'
|
|
3
|
+
|
|
4
|
+
export type Pressure = 'normal' | 'warning' | 'high' | 'critical'
|
|
5
|
+
export type Platform = 'windows' | 'macos' | 'linux' | 'other'
|
|
6
|
+
|
|
7
|
+
/** Occupancy in percent, clamped to 0–100; `null` when tokens or window are unknown. */
|
|
8
|
+
export function contextPercent(context: ContextUsage | null, window: number | null): number | null {
|
|
9
|
+
const tokens = context?.tokens
|
|
10
|
+
if (tokens == null || window == null || window <= 0) return null
|
|
11
|
+
return Math.min(100, Math.max(0, (tokens / window) * 100))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Highest of percentage and absolute pressure; absolute counts need a known window. */
|
|
15
|
+
export function pressure(context: ContextUsage | null, window: number | null): Pressure {
|
|
16
|
+
const percent = contextPercent(context, window) ?? 0
|
|
17
|
+
const tokens = window != null && window > 0 ? (context?.tokens ?? 0) : 0
|
|
18
|
+
if (percent >= 90 || tokens >= 500_000) return 'critical'
|
|
19
|
+
if (percent >= 70 || tokens >= 270_000) return 'high'
|
|
20
|
+
if (percent >= 50 || tokens >= 150_000) return 'warning'
|
|
21
|
+
return 'normal'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Truncates rather than rounds, so 999 999 never reads as 1000k. */
|
|
25
|
+
export function compact(value: number): string {
|
|
26
|
+
const n = Math.max(0, Math.floor(value))
|
|
27
|
+
const [divisor, suffix] =
|
|
28
|
+
n >= 1e9 ? [1e9, 'B'] : n >= 1e6 ? [1e6, 'M'] : n >= 1e3 ? [1e3, 'k'] : [1, '']
|
|
29
|
+
if (divisor === 1) return String(n)
|
|
30
|
+
const tenth = Math.floor((n * 10) / divisor)
|
|
31
|
+
const whole = Math.floor(tenth / 10)
|
|
32
|
+
return tenth % 10 === 0 ? `${whole}${suffix}` : `${whole}.${tenth % 10}${suffix}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `~12.3%/1.1M`; unknown parts render as `?`. */
|
|
36
|
+
export function summary(context: ContextUsage | null, window: number | null): string {
|
|
37
|
+
const percent = contextPercent(context, window)
|
|
38
|
+
const size = window != null && window > 0 ? compact(window) : '?'
|
|
39
|
+
const estimated = context?.source === 'estimated' ? '~' : ''
|
|
40
|
+
return `${estimated}${percent === null ? '?' : percent.toFixed(1)}%/${size}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Estimated money from configured prices; invalid amounts are unknown. */
|
|
44
|
+
export function money({ amount, currency }: Cost): string | null {
|
|
45
|
+
if (!Number.isFinite(amount) || amount < 0) return null
|
|
46
|
+
const text =
|
|
47
|
+
amount > 0 && amount < 0.0001
|
|
48
|
+
? '<0.0001'
|
|
49
|
+
: amount > 0 && amount < 0.01
|
|
50
|
+
? amount.toFixed(4)
|
|
51
|
+
: amount.toFixed(2)
|
|
52
|
+
return `≈${currency === 'CNY' ? '¥' : '$'}${text}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SpendingTurn {
|
|
56
|
+
usage: TokenUsage | null
|
|
57
|
+
cost: Cost | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Money only when every turn with usage has a priced cost in one currency; otherwise tokens.
|
|
62
|
+
* Input already contains cache, output contains reasoning, so only the two totals are added.
|
|
63
|
+
*/
|
|
64
|
+
export function spending(turns: readonly SpendingTurn[]): string {
|
|
65
|
+
const used = turns.filter(
|
|
66
|
+
({ usage }) => usage != null && (usage.inputTokens != null || usage.outputTokens != null),
|
|
67
|
+
)
|
|
68
|
+
if (used.length === 0) return '? tokens'
|
|
69
|
+
const priced = used.flatMap(({ usage, cost }) =>
|
|
70
|
+
cost &&
|
|
71
|
+
Number.isFinite(cost.amount) &&
|
|
72
|
+
cost.amount >= 0 &&
|
|
73
|
+
usage?.inputTokens != null &&
|
|
74
|
+
usage.outputTokens != null
|
|
75
|
+
? [cost]
|
|
76
|
+
: [],
|
|
77
|
+
)
|
|
78
|
+
const first = priced[0]
|
|
79
|
+
const oneCurrency = priced.every((cost) => cost.currency === first?.currency)
|
|
80
|
+
if (first && priced.length === used.length && oneCurrency) {
|
|
81
|
+
const amount = priced.reduce((sum, cost) => sum + cost.amount, 0)
|
|
82
|
+
const text = money({ amount, currency: first.currency })
|
|
83
|
+
if (text) return text
|
|
84
|
+
}
|
|
85
|
+
let tokens = 0
|
|
86
|
+
for (const { usage } of used) tokens += (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0)
|
|
87
|
+
return `${compact(tokens)} tokens`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type PlatformNavigator = Navigator & { userAgentData?: { platform?: string } }
|
|
91
|
+
|
|
92
|
+
/** Browser-reported OS; on the web this is the visitor's system, not the server's. */
|
|
93
|
+
export function detectPlatform(
|
|
94
|
+
nav: PlatformNavigator | undefined = globalThis.navigator,
|
|
95
|
+
): Platform {
|
|
96
|
+
const name = (nav?.userAgentData?.platform || nav?.platform || '').toLowerCase()
|
|
97
|
+
if (name.startsWith('win')) return 'windows'
|
|
98
|
+
if (name.startsWith('mac') || name.includes('darwin')) return 'macos'
|
|
99
|
+
if (name.includes('linux') && !name.includes('android')) return 'linux'
|
|
100
|
+
return 'other'
|
|
101
|
+
}
|