@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.
- package/app/components/ChatWorkspace.vue +905 -183
- package/app/components/MessageContent.vue +2 -0
- package/app/components/MessagePartRenderer.ts +10 -0
- package/app/components/SubagentDrawerList.vue +64 -0
- package/app/components/SubagentTranscriptPanel.vue +305 -0
- package/app/components/ThreadPanel.vue +64 -45
- package/app/components/VisualSubagentStack.vue +13 -243
- package/app/components/message-part/Attachment.vue +61 -0
- package/app/composables/useChatAttachments.ts +208 -0
- package/app/composables/useChatSession.ts +31 -0
- package/app/composables/useProjects.ts +42 -0
- package/app/layouts/default.vue +56 -5
- package/app/pages/index.vue +0 -1
- package/app/pages/projects/[...projectId]/index.vue +2 -2
- package/app/pages/projects/[...projectId]/threads/[threadId].vue +223 -70
- package/app/utils/chat-turn-engagement.ts +46 -0
- package/package.json +1 -1
- package/server/api/codori/projects/[projectId]/attachments/file.get.ts +62 -0
- package/server/api/codori/projects/[projectId]/attachments.post.ts +53 -0
- package/server/api/codori/service/update.get.ts +7 -0
- package/server/api/codori/service/update.post.ts +9 -0
- package/server/utils/server-proxy.ts +23 -0
- package/shared/chat-attachments.ts +135 -0
- package/shared/chat-prompt-controls.ts +339 -0
- package/shared/codex-chat.ts +79 -14
- package/shared/codex-rpc.ts +19 -0
- package/shared/codori.ts +12 -0
- package/shared/subagent-panels.ts +158 -0
- package/app/components/TunnelNotice.vue +0 -27
|
@@ -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
|
+
}
|
package/shared/codex-chat.ts
CHANGED
|
@@ -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
|
|
139
|
+
const streamingState = (pending?: boolean) => pending ? 'streaming' : 'done'
|
|
140
|
+
|
|
141
|
+
const userInputToParts = (input: CodexUserInput): ChatPart[] => {
|
|
129
142
|
if (input.type === 'text') {
|
|
130
|
-
|
|
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
|
|
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
|
+
}
|