@codori/client 0.0.2 → 0.0.4

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.
@@ -0,0 +1,53 @@
1
+ import {
2
+ createError,
3
+ defineEventHandler,
4
+ getRequestHeader,
5
+ getRouterParam,
6
+ setResponseStatus
7
+ } from 'h3'
8
+ import { encodeProjectIdSegment } from '~~/shared/codori'
9
+ import { proxyServerFetch } from '../../../../utils/server-proxy'
10
+
11
+ export default defineEventHandler(async (event) => {
12
+ const projectId = getRouterParam(event, 'projectId')
13
+ if (!projectId) {
14
+ throw createError({
15
+ statusCode: 400,
16
+ statusMessage: 'Missing project id.'
17
+ })
18
+ }
19
+
20
+ const contentType = getRequestHeader(event, 'content-type')
21
+ if (!contentType) {
22
+ throw createError({
23
+ statusCode: 400,
24
+ statusMessage: 'Missing content type.'
25
+ })
26
+ }
27
+
28
+ const response = await proxyServerFetch(
29
+ event,
30
+ `/api/projects/${encodeProjectIdSegment(projectId)}/attachments`,
31
+ {
32
+ method: 'POST',
33
+ headers: {
34
+ 'content-type': contentType
35
+ },
36
+ body: event.node.req as unknown as BodyInit
37
+ }
38
+ )
39
+
40
+ const body = await response.json()
41
+ setResponseStatus(event, response.status)
42
+
43
+ if (!response.ok) {
44
+ const errorBody = body as { error?: { message?: string } }
45
+ throw createError({
46
+ statusCode: response.status,
47
+ statusMessage: errorBody.error?.message ?? 'Attachment upload failed.',
48
+ data: body
49
+ })
50
+ }
51
+
52
+ return body
53
+ })
@@ -0,0 +1,7 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import type { ServiceUpdateResponse } from '~~/shared/codori'
3
+ import { proxyServerRequest } from '../../../utils/server-proxy'
4
+
5
+ export default defineEventHandler(async (event) =>
6
+ await proxyServerRequest<ServiceUpdateResponse>(event, '/api/service/update')
7
+ )
@@ -0,0 +1,9 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import type { ServiceUpdateResponse } from '~~/shared/codori'
3
+ import { proxyServerRequest } from '../../../utils/server-proxy'
4
+
5
+ export default defineEventHandler(async (event) =>
6
+ await proxyServerRequest<ServiceUpdateResponse>(event, '/api/service/update', {
7
+ method: 'POST'
8
+ })
9
+ )
@@ -23,3 +23,26 @@ export const proxyServerRequest = async <T>(
23
23
  body: options.body as BodyInit | Record<string, unknown> | undefined
24
24
  })
25
25
  }
26
+
27
+ export const proxyServerFetch = async (
28
+ event: H3Event,
29
+ path: string,
30
+ options: {
31
+ method?: 'GET' | 'POST'
32
+ headers?: HeadersInit
33
+ body?: BodyInit | null
34
+ } = {}
35
+ ) => {
36
+ const baseURL = getServerBase(event)
37
+ const requestInit: RequestInit & { duplex?: 'half' } = {
38
+ method: options.method,
39
+ headers: options.headers,
40
+ body: options.body ?? null
41
+ }
42
+
43
+ if (options.body) {
44
+ requestInit.duplex = 'half'
45
+ }
46
+
47
+ return await fetch(`${baseURL}${path}`, requestInit)
48
+ }
@@ -0,0 +1,135 @@
1
+ import type { CodexUserInput } from './codex-rpc'
2
+ import { encodeProjectIdSegment } from './codori'
3
+ import { resolveApiUrl, shouldUseServerProxy } from './network'
4
+
5
+ export const MAX_ATTACHMENTS_PER_MESSAGE = 8
6
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
7
+
8
+ export type FileLike = {
9
+ name: string
10
+ size: number
11
+ type: string
12
+ }
13
+
14
+ export type AttachmentValidationIssue = {
15
+ code: 'tooMany' | 'unsupportedType' | 'tooLarge'
16
+ fileName: string
17
+ message: string
18
+ }
19
+
20
+ export type PersistedProjectAttachment = {
21
+ filename: string
22
+ mediaType: string | null
23
+ path: string
24
+ }
25
+
26
+ export type ProjectAttachmentUploadResponse = {
27
+ threadId: string
28
+ files: PersistedProjectAttachment[]
29
+ }
30
+
31
+ export const isSupportedAttachmentType = (mediaType: string) =>
32
+ mediaType.toLowerCase().startsWith('image/')
33
+
34
+ export const validateAttachmentSelection = <T extends FileLike>(
35
+ files: T[],
36
+ existingCount: number
37
+ ) => {
38
+ const issues: AttachmentValidationIssue[] = []
39
+ const accepted: T[] = []
40
+
41
+ for (const file of files) {
42
+ if (existingCount + accepted.length >= MAX_ATTACHMENTS_PER_MESSAGE) {
43
+ issues.push({
44
+ code: 'tooMany',
45
+ fileName: file.name,
46
+ message: `You can attach up to ${MAX_ATTACHMENTS_PER_MESSAGE} images per message.`
47
+ })
48
+ continue
49
+ }
50
+
51
+ const mediaType = file.type || 'application/octet-stream'
52
+ if (!isSupportedAttachmentType(mediaType)) {
53
+ issues.push({
54
+ code: 'unsupportedType',
55
+ fileName: file.name,
56
+ message: 'Only image attachments are currently supported.'
57
+ })
58
+ continue
59
+ }
60
+
61
+ if (file.size > MAX_ATTACHMENT_BYTES) {
62
+ issues.push({
63
+ code: 'tooLarge',
64
+ fileName: file.name,
65
+ message: `Each image must be ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))} MB or smaller.`
66
+ })
67
+ continue
68
+ }
69
+
70
+ accepted.push(file)
71
+ }
72
+
73
+ return {
74
+ accepted,
75
+ issues
76
+ }
77
+ }
78
+
79
+ export const buildTurnStartInput = (
80
+ text: string,
81
+ attachments: Array<{ path: string }>
82
+ ): CodexUserInput[] => {
83
+ const input: CodexUserInput[] = []
84
+ const trimmedText = text.trim()
85
+
86
+ if (trimmedText) {
87
+ input.push({
88
+ type: 'text',
89
+ text: trimmedText,
90
+ text_elements: []
91
+ })
92
+ }
93
+
94
+ for (const attachment of attachments) {
95
+ input.push({
96
+ type: 'localImage',
97
+ path: attachment.path
98
+ })
99
+ }
100
+
101
+ return input
102
+ }
103
+
104
+ export const resolveAttachmentPreviewUrl = (input: {
105
+ projectId: string
106
+ path: string
107
+ configuredBase?: string | null
108
+ }) => {
109
+ const query = new URLSearchParams({
110
+ path: input.path
111
+ })
112
+ const requestPath = `/projects/${encodeProjectIdSegment(input.projectId)}/attachments/file?${query.toString()}`
113
+
114
+ if (shouldUseServerProxy(input.configuredBase)) {
115
+ return `/api/codori${requestPath}`
116
+ }
117
+
118
+ return resolveApiUrl(
119
+ requestPath,
120
+ input.configuredBase
121
+ )
122
+ }
123
+
124
+ export const resolveAttachmentUploadUrl = (input: {
125
+ projectId: string
126
+ configuredBase?: string | null
127
+ }) => {
128
+ const requestPath = `/projects/${encodeProjectIdSegment(input.projectId)}/attachments`
129
+
130
+ if (shouldUseServerProxy(input.configuredBase)) {
131
+ return `/api/codori${requestPath}`
132
+ }
133
+
134
+ return resolveApiUrl(requestPath, input.configuredBase)
135
+ }
@@ -0,0 +1,339 @@
1
+ export const FALLBACK_REASONING_EFFORTS = [
2
+ 'none',
3
+ 'minimal',
4
+ 'low',
5
+ 'medium',
6
+ 'high',
7
+ 'xhigh'
8
+ ] as const
9
+
10
+ export type ReasoningEffort = typeof FALLBACK_REASONING_EFFORTS[number]
11
+
12
+ export type ModelOption = {
13
+ id: string
14
+ model: string
15
+ displayName: string
16
+ hidden: boolean
17
+ isDefault: boolean
18
+ defaultReasoningEffort: ReasoningEffort
19
+ supportedReasoningEfforts: ReasoningEffort[]
20
+ }
21
+
22
+ export type TokenUsageSnapshot = {
23
+ totalInputTokens: number
24
+ totalCachedInputTokens: number
25
+ totalOutputTokens: number
26
+ lastInputTokens: number
27
+ lastCachedInputTokens: number
28
+ lastOutputTokens: number
29
+ modelContextWindow: number | null
30
+ }
31
+
32
+ type ReasoningEffortOptionRecord = {
33
+ reasoningEffort?: unknown
34
+ }
35
+
36
+ type ModelRecord = {
37
+ id?: unknown
38
+ model?: unknown
39
+ displayName?: unknown
40
+ hidden?: unknown
41
+ isDefault?: unknown
42
+ defaultReasoningEffort?: unknown
43
+ supportedReasoningEfforts?: unknown
44
+ }
45
+
46
+ export const FALLBACK_MODELS: ModelOption[] = [
47
+ {
48
+ id: 'gpt-5.4',
49
+ model: 'gpt-5.4',
50
+ displayName: 'GPT-5.4',
51
+ hidden: false,
52
+ isDefault: true,
53
+ defaultReasoningEffort: 'medium',
54
+ supportedReasoningEfforts: [...FALLBACK_REASONING_EFFORTS]
55
+ },
56
+ {
57
+ id: 'gpt-5.4-mini',
58
+ model: 'gpt-5.4-mini',
59
+ displayName: 'GPT-5.4 Mini',
60
+ hidden: false,
61
+ isDefault: false,
62
+ defaultReasoningEffort: 'medium',
63
+ supportedReasoningEfforts: [...FALLBACK_REASONING_EFFORTS]
64
+ },
65
+ {
66
+ id: 'gpt-5.3-codex',
67
+ model: 'gpt-5.3-codex',
68
+ displayName: 'GPT-5.3 Codex',
69
+ hidden: false,
70
+ isDefault: false,
71
+ defaultReasoningEffort: 'medium',
72
+ supportedReasoningEfforts: [...FALLBACK_REASONING_EFFORTS]
73
+ }
74
+ ]
75
+
76
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
77
+ typeof value === 'object' && value !== null && !Array.isArray(value)
78
+
79
+ const isReasoningEffort = (value: unknown): value is ReasoningEffort =>
80
+ typeof value === 'string' && FALLBACK_REASONING_EFFORTS.includes(value as ReasoningEffort)
81
+
82
+ const toFiniteNumber = (value: unknown) => {
83
+ if (typeof value === 'number' && Number.isFinite(value)) {
84
+ return value
85
+ }
86
+
87
+ if (typeof value === 'bigint') {
88
+ return Number(value)
89
+ }
90
+
91
+ if (typeof value === 'string' && value.trim()) {
92
+ const parsed = Number(value)
93
+ if (Number.isFinite(parsed)) {
94
+ return parsed
95
+ }
96
+ }
97
+
98
+ return null
99
+ }
100
+
101
+ const toReasoningEfforts = (value: unknown): ReasoningEffort[] => {
102
+ if (!Array.isArray(value)) {
103
+ return []
104
+ }
105
+
106
+ return value.flatMap((entry) => {
107
+ if (isReasoningEffort(entry)) {
108
+ return [entry]
109
+ }
110
+
111
+ const record = isObjectRecord(entry) ? entry as ReasoningEffortOptionRecord : null
112
+ return isReasoningEffort(record?.reasoningEffort) ? [record.reasoningEffort] : []
113
+ })
114
+ }
115
+
116
+ const normalizeModel = (value: unknown): ModelOption | null => {
117
+ const record = isObjectRecord(value) ? value as ModelRecord : null
118
+ if (!record || typeof record.model !== 'string') {
119
+ return null
120
+ }
121
+
122
+ const supportedReasoningEfforts = toReasoningEfforts(record.supportedReasoningEfforts)
123
+ const defaultReasoningEffort = isReasoningEffort(record.defaultReasoningEffort)
124
+ ? record.defaultReasoningEffort
125
+ : supportedReasoningEfforts[0] ?? 'medium'
126
+
127
+ return {
128
+ id: typeof record.id === 'string' ? record.id : record.model,
129
+ model: record.model,
130
+ displayName: typeof record.displayName === 'string' && record.displayName.trim()
131
+ ? record.displayName.trim()
132
+ : record.model,
133
+ hidden: Boolean(record.hidden),
134
+ isDefault: Boolean(record.isDefault),
135
+ defaultReasoningEffort,
136
+ supportedReasoningEfforts: supportedReasoningEfforts.length > 0
137
+ ? supportedReasoningEfforts
138
+ : [...FALLBACK_REASONING_EFFORTS]
139
+ }
140
+ }
141
+
142
+ export const normalizeModelList = (value: unknown): ModelOption[] => {
143
+ const data = isObjectRecord(value) && Array.isArray(value.data)
144
+ ? value.data
145
+ : Array.isArray(value)
146
+ ? value
147
+ : []
148
+
149
+ const models = data
150
+ .map(normalizeModel)
151
+ .filter((entry): entry is ModelOption => entry !== null)
152
+
153
+ return models.length > 0 ? models : FALLBACK_MODELS
154
+ }
155
+
156
+ export const ensureModelOption = (
157
+ models: ModelOption[],
158
+ model: string | null | undefined,
159
+ effort?: ReasoningEffort | null
160
+ ) => {
161
+ if (!model || models.some(entry => entry.model === model)) {
162
+ return models
163
+ }
164
+
165
+ return [{
166
+ id: model,
167
+ model,
168
+ displayName: model,
169
+ hidden: false,
170
+ isDefault: false,
171
+ defaultReasoningEffort: effort ?? 'medium',
172
+ supportedReasoningEfforts: [...FALLBACK_REASONING_EFFORTS]
173
+ }, ...models]
174
+ }
175
+
176
+ export const visibleModelOptions = (models: ModelOption[]) => {
177
+ const visible = models.filter(model => !model.hidden)
178
+ return visible.length > 0 ? visible : FALLBACK_MODELS
179
+ }
180
+
181
+ export const resolveSelectedModel = (
182
+ models: ModelOption[],
183
+ preferredModel?: string | null
184
+ ) => {
185
+ if (preferredModel && models.some(model => model.model === preferredModel)) {
186
+ return preferredModel
187
+ }
188
+
189
+ const defaultModel = models.find(model => model.isDefault)?.model
190
+ return defaultModel ?? models[0]?.model ?? FALLBACK_MODELS[0]!.model
191
+ }
192
+
193
+ export const resolveEffortOptions = (
194
+ models: ModelOption[],
195
+ model: string | null | undefined
196
+ ) => {
197
+ const selectedModel = models.find(entry => entry.model === model)
198
+ return selectedModel?.supportedReasoningEfforts.length
199
+ ? selectedModel.supportedReasoningEfforts
200
+ : [...FALLBACK_REASONING_EFFORTS]
201
+ }
202
+
203
+ export const resolveSelectedEffort = (
204
+ models: ModelOption[],
205
+ model: string | null | undefined,
206
+ preferredEffort?: ReasoningEffort | null
207
+ ) => {
208
+ const effortOptions = resolveEffortOptions(models, model)
209
+ if (preferredEffort && effortOptions.includes(preferredEffort)) {
210
+ return preferredEffort
211
+ }
212
+
213
+ const selectedModel = models.find(entry => entry.model === model)
214
+ if (selectedModel && effortOptions.includes(selectedModel.defaultReasoningEffort)) {
215
+ return selectedModel.defaultReasoningEffort
216
+ }
217
+
218
+ return effortOptions[0] ?? 'medium'
219
+ }
220
+
221
+ export const coercePromptSelection = (
222
+ models: ModelOption[],
223
+ preferredModel?: string | null,
224
+ preferredEffort?: ReasoningEffort | null
225
+ ) => {
226
+ const nextModel = resolveSelectedModel(models, preferredModel)
227
+ return {
228
+ model: nextModel,
229
+ effort: resolveSelectedEffort(models, nextModel, preferredEffort)
230
+ }
231
+ }
232
+
233
+ export const normalizeConfigDefaults = (value: unknown) => {
234
+ const config = isObjectRecord(value) && isObjectRecord(value.config)
235
+ ? value.config
236
+ : null
237
+
238
+ return {
239
+ model: typeof config?.model === 'string' ? config.model : null,
240
+ effort: isReasoningEffort(config?.model_reasoning_effort) ? config.model_reasoning_effort : null,
241
+ contextWindow: toFiniteNumber(config?.model_context_window)
242
+ }
243
+ }
244
+
245
+ export const normalizeThreadTokenUsage = (value: unknown): TokenUsageSnapshot | null => {
246
+ const params = isObjectRecord(value) ? value : null
247
+ const tokenUsage = isObjectRecord(params?.tokenUsage) ? params.tokenUsage : null
248
+ if (!tokenUsage) {
249
+ return null
250
+ }
251
+
252
+ const total = isObjectRecord(tokenUsage.total) ? tokenUsage.total : {}
253
+ const last = isObjectRecord(tokenUsage.last) ? tokenUsage.last : {}
254
+
255
+ return {
256
+ totalInputTokens: toFiniteNumber(total.inputTokens) ?? 0,
257
+ totalCachedInputTokens: toFiniteNumber(total.cachedInputTokens) ?? 0,
258
+ totalOutputTokens: toFiniteNumber(total.outputTokens) ?? 0,
259
+ lastInputTokens: toFiniteNumber(last.inputTokens) ?? 0,
260
+ lastCachedInputTokens: toFiniteNumber(last.cachedInputTokens) ?? 0,
261
+ lastOutputTokens: toFiniteNumber(last.outputTokens) ?? 0,
262
+ modelContextWindow: toFiniteNumber(tokenUsage.modelContextWindow)
263
+ }
264
+ }
265
+
266
+ export const buildTurnOverrides = (
267
+ model: string | null | undefined,
268
+ effort: ReasoningEffort | null | undefined
269
+ ) => {
270
+ const overrides: {
271
+ model?: string
272
+ effort?: ReasoningEffort
273
+ } = {}
274
+
275
+ if (model) {
276
+ overrides.model = model
277
+ }
278
+
279
+ if (effort) {
280
+ overrides.effort = effort
281
+ }
282
+
283
+ return overrides
284
+ }
285
+
286
+ export const formatReasoningEffortLabel = (value: ReasoningEffort) => {
287
+ switch (value) {
288
+ case 'xhigh':
289
+ return 'Very high'
290
+ case 'none':
291
+ return 'None'
292
+ default:
293
+ return value.charAt(0).toUpperCase() + value.slice(1)
294
+ }
295
+ }
296
+
297
+ export const formatCompactTokenCount = (value: number) => {
298
+ if (value < 1000) {
299
+ return String(value)
300
+ }
301
+
302
+ const short = value / 1000
303
+ const rounded = short >= 10 ? short.toFixed(0) : short.toFixed(1)
304
+ return `${rounded.replace(/\\.0$/, '')}k`
305
+ }
306
+
307
+ export const resolveContextWindowState = (
308
+ tokenUsage: TokenUsageSnapshot | null,
309
+ fallbackContextWindow: number | null,
310
+ usageKnown = true
311
+ ) => {
312
+ const contextWindow = tokenUsage?.modelContextWindow ?? fallbackContextWindow
313
+ const usedTokens = tokenUsage
314
+ ? tokenUsage.totalInputTokens + tokenUsage.totalOutputTokens
315
+ : usageKnown
316
+ ? 0
317
+ : null
318
+
319
+ if (!contextWindow || usedTokens == null) {
320
+ return {
321
+ contextWindow,
322
+ usedTokens,
323
+ remainingTokens: null,
324
+ usedPercent: null,
325
+ remainingPercent: null
326
+ }
327
+ }
328
+
329
+ const cappedUsedTokens = Math.max(0, Math.min(contextWindow, usedTokens))
330
+ const usedPercent = Math.max(0, Math.min(100, (cappedUsedTokens / contextWindow) * 100))
331
+
332
+ return {
333
+ contextWindow,
334
+ usedTokens: cappedUsedTokens,
335
+ remainingTokens: Math.max(0, contextWindow - cappedUsedTokens),
336
+ usedPercent,
337
+ remainingPercent: Math.max(0, 100 - usedPercent)
338
+ }
339
+ }
@@ -2,6 +2,7 @@ import type { CodexThread, CodexThreadItem, CodexUserInput } from './codex-rpc'
2
2
 
3
3
  export const EVENT_PART = 'data-thread-event' as const
4
4
  export const ITEM_PART = 'data-thread-item' as const
5
+ export const THINKING_PLACEHOLDER_MESSAGE_ID = 'assistant-thinking-placeholder'
5
6
 
6
7
  export type ThreadEventData =
7
8
  | {
@@ -100,6 +101,16 @@ export type ChatPart =
100
101
  text: string
101
102
  state?: 'done' | 'streaming'
102
103
  }
104
+ | {
105
+ type: 'attachment'
106
+ attachment: {
107
+ kind: 'image'
108
+ name: string
109
+ mediaType: string
110
+ url?: string | null
111
+ localPath?: string | null
112
+ }
113
+ }
103
114
  | {
104
115
  type: 'reasoning'
105
116
  summary: string[]
@@ -125,27 +136,39 @@ export type ChatMessage = {
125
136
  export const isSubagentActiveStatus = (status: SubagentAgentStatus) =>
126
137
  status === null || status === 'pendingInit' || status === 'running'
127
138
 
128
- const formatUserInput = (input: CodexUserInput) => {
139
+ const streamingState = (pending?: boolean) => pending ? 'streaming' : 'done'
140
+
141
+ const userInputToParts = (input: CodexUserInput): ChatPart[] => {
129
142
  if (input.type === 'text') {
130
- return input.text
143
+ if (!input.text.trim()) {
144
+ return []
145
+ }
146
+
147
+ return [{
148
+ type: 'text',
149
+ text: input.text,
150
+ state: 'done'
151
+ }]
131
152
  }
132
153
 
133
- return `[local image] ${input.path}`
154
+ return [{
155
+ type: 'attachment',
156
+ attachment: {
157
+ kind: 'image',
158
+ name: input.path.split(/[\\/]/).pop() || 'image',
159
+ mediaType: 'image/*',
160
+ localPath: input.path
161
+ }
162
+ }]
134
163
  }
135
164
 
136
- const streamingState = (pending?: boolean) => pending ? 'streaming' : 'done'
137
-
138
165
  export const itemToMessages = (item: CodexThreadItem): ChatMessage[] => {
139
166
  switch (item.type) {
140
167
  case 'userMessage':
141
168
  return [{
142
169
  id: item.id,
143
170
  role: 'user',
144
- parts: [{
145
- type: 'text',
146
- text: item.content.map(formatUserInput).join('\n').trim(),
147
- state: 'done'
148
- }]
171
+ parts: item.content.flatMap(userInputToParts)
149
172
  }]
150
173
  case 'agentMessage':
151
174
  return [{
@@ -314,11 +337,13 @@ const normalizeParts = (message: ChatMessage): ChatPart[] =>
314
337
  return part
315
338
  })
316
339
 
340
+ const normalizeMessage = (message: ChatMessage): ChatMessage => ({
341
+ ...message,
342
+ parts: normalizeParts(message)
343
+ })
344
+
317
345
  export const upsertStreamingMessage = (messages: ChatMessage[], nextMessage: ChatMessage) => {
318
- const normalizedMessage = {
319
- ...nextMessage,
320
- parts: normalizeParts(nextMessage)
321
- }
346
+ const normalizedMessage = normalizeMessage(nextMessage)
322
347
  const nextMessages = messages.slice()
323
348
  const existingIndex = nextMessages.findIndex(message => message.id === normalizedMessage.id)
324
349
 
@@ -338,3 +363,43 @@ export const upsertStreamingMessage = (messages: ChatMessage[], nextMessage: Cha
338
363
 
339
364
  return nextMessages
340
365
  }
366
+
367
+ export const replaceStreamingMessage = (messages: ChatMessage[], nextMessage: ChatMessage) => {
368
+ const normalizedMessage = normalizeMessage(nextMessage)
369
+ const nextMessages = messages.slice()
370
+ const existingIndex = nextMessages.findIndex(message => message.id === normalizedMessage.id)
371
+
372
+ if (existingIndex === -1) {
373
+ nextMessages.push(normalizedMessage)
374
+ return nextMessages
375
+ }
376
+
377
+ nextMessages.splice(existingIndex, 1, normalizedMessage)
378
+ return nextMessages
379
+ }
380
+
381
+ export const buildThinkingPlaceholderMessage = (): ChatMessage => ({
382
+ id: THINKING_PLACEHOLDER_MESSAGE_ID,
383
+ role: 'assistant',
384
+ pending: true,
385
+ parts: [{
386
+ type: 'reasoning',
387
+ summary: ['Thinking...'],
388
+ content: [],
389
+ state: 'streaming'
390
+ }]
391
+ })
392
+
393
+ export const showThinkingPlaceholder = (messages: ChatMessage[]) =>
394
+ upsertStreamingMessage(messages, buildThinkingPlaceholderMessage())
395
+
396
+ export const hideThinkingPlaceholder = (messages: ChatMessage[]) => {
397
+ const index = messages.findIndex(message => message.id === THINKING_PLACEHOLDER_MESSAGE_ID)
398
+ if (index === -1) {
399
+ return messages
400
+ }
401
+
402
+ const nextMessages = messages.slice()
403
+ nextMessages.splice(index, 1)
404
+ return nextMessages
405
+ }