kanna-code 0.34.5 → 0.35.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 (32) hide show
  1. package/dist/client/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  2. package/dist/client/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  3. package/dist/client/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  4. package/dist/client/assets/index-BbuEYb7n.css +32 -0
  5. package/dist/client/assets/index-Dj4wUBuS.js +2613 -0
  6. package/dist/client/index.html +2 -2
  7. package/dist/export-viewer/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  8. package/dist/export-viewer/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  9. package/dist/export-viewer/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  10. package/dist/export-viewer/assets/index-BcwN0dxP.css +1 -0
  11. package/dist/export-viewer/assets/index-BmJ7RE79.js +400 -0
  12. package/dist/export-viewer/fonts/body-medium.woff2 +0 -0
  13. package/dist/export-viewer/fonts/body-regular-italic.woff2 +0 -0
  14. package/dist/export-viewer/fonts/body-regular.woff2 +0 -0
  15. package/dist/export-viewer/fonts/body-semibold.woff2 +0 -0
  16. package/dist/export-viewer/index.html +14 -0
  17. package/package.json +10 -5
  18. package/src/server/codex-app-server.test.ts +3 -1
  19. package/src/server/codex-app-server.ts +2 -2
  20. package/src/server/paths.ts +4 -0
  21. package/src/server/provider-catalog.test.ts +1 -0
  22. package/src/server/provider-catalog.ts +2 -1
  23. package/src/server/read-models.test.ts +1 -0
  24. package/src/server/standalone-export.test.ts +173 -0
  25. package/src/server/standalone-export.ts +402 -0
  26. package/src/server/ws-router.ts +14 -0
  27. package/src/shared/branding.ts +1 -0
  28. package/src/shared/protocol.ts +9 -1
  29. package/src/shared/types.test.ts +2 -1
  30. package/src/shared/types.ts +29 -2
  31. package/dist/client/assets/index-Cr3Y40f9.js +0 -2608
  32. package/dist/client/assets/index-N86Y2O-U.css +0 -32
@@ -0,0 +1,402 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import type { Dirent } from "node:fs"
3
+ import path from "node:path"
4
+ import { cp as copyPath, copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"
5
+ import type {
6
+ StandaloneTranscriptAttachmentMode,
7
+ StandaloneTranscriptBundle,
8
+ StandaloneTranscriptExportResult,
9
+ StandaloneTranscriptTheme,
10
+ TranscriptEntry,
11
+ } from "../shared/types"
12
+ import { APP_VERSION } from "../shared/branding"
13
+ import { getProjectExportDir } from "./paths"
14
+
15
+ const STANDALONE_TRANSCRIPT_BUNDLE_VERSION = 1 as const
16
+ const STANDALONE_SHARE_UPLOAD_BASE_URL = "https://kanna.sh/api/share"
17
+ const STANDALONE_SHARE_PUBLIC_BASE_URL = "https://share.kanna.sh"
18
+ const STANDALONE_SHARE_WORKSPACE_PATH = "/workspace"
19
+ const STANDALONE_SHARE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
20
+ const CONTENT_TYPES_BY_EXTENSION: Record<string, string> = {
21
+ ".css": "text/css; charset=utf-8",
22
+ ".gif": "image/gif",
23
+ ".html": "text/html; charset=utf-8",
24
+ ".ico": "image/x-icon",
25
+ ".jpeg": "image/jpeg",
26
+ ".jpg": "image/jpeg",
27
+ ".js": "text/javascript; charset=utf-8",
28
+ ".json": "application/json; charset=utf-8",
29
+ ".manifest": "application/manifest+json; charset=utf-8",
30
+ ".mp3": "audio/mpeg",
31
+ ".png": "image/png",
32
+ ".svg": "image/svg+xml",
33
+ ".txt": "text/plain; charset=utf-8",
34
+ ".webmanifest": "application/manifest+json; charset=utf-8",
35
+ ".webp": "image/webp",
36
+ ".woff2": "font/woff2",
37
+ }
38
+
39
+ export interface WriteStandaloneTranscriptExportArgs {
40
+ chatId: string
41
+ title: string
42
+ localPath: string
43
+ theme: StandaloneTranscriptTheme
44
+ attachmentMode: StandaloneTranscriptAttachmentMode
45
+ messages: TranscriptEntry[]
46
+ }
47
+
48
+ export interface StandaloneExportDeps {
49
+ viewerDistDir?: string
50
+ now?: Date
51
+ mkdir?: typeof mkdir
52
+ writeFile?: typeof writeFile
53
+ readFile?: typeof readFile
54
+ copyDirectory?: (sourceDir: string, targetDir: string) => Promise<void>
55
+ copyFile?: typeof copyFile
56
+ readDir?: (targetPath: string) => Promise<Dirent[]>
57
+ pathExists?: (targetPath: string) => Promise<boolean>
58
+ fetch?: FetchLike
59
+ shareUploadBaseUrl?: string
60
+ sharePublicBaseUrl?: string
61
+ shareSlugSuffix?: string
62
+ }
63
+
64
+ interface PreparedMessagesResult {
65
+ messages: TranscriptEntry[]
66
+ totalAttachmentCount: number
67
+ bundledAttachmentCount: number
68
+ }
69
+
70
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
71
+
72
+ export function getStandaloneViewerDistDir() {
73
+ return path.join(import.meta.dir, "..", "..", "dist", "export-viewer")
74
+ }
75
+
76
+ export async function writeStandaloneTranscriptExport(
77
+ args: WriteStandaloneTranscriptExportArgs,
78
+ deps: StandaloneExportDeps = {},
79
+ ): Promise<StandaloneTranscriptExportResult> {
80
+ const viewerDistDir = deps.viewerDistDir ?? getStandaloneViewerDistDir()
81
+ const ensureDir = deps.mkdir ?? mkdir
82
+ const writeFileImpl = deps.writeFile ?? writeFile
83
+ const readFileImpl = deps.readFile ?? readFile
84
+ const copyDirectory = deps.copyDirectory ?? (async (sourceDir, targetDir) => {
85
+ await copyPath(sourceDir, targetDir, { recursive: true })
86
+ })
87
+ const copyFileImpl = deps.copyFile ?? copyFile
88
+ const readDir = deps.readDir ?? defaultReadDir
89
+ const pathExists = deps.pathExists ?? defaultPathExists
90
+ const fetchImpl = deps.fetch ?? fetch
91
+ const now = deps.now ?? new Date()
92
+ const shareUploadBaseUrl = deps.shareUploadBaseUrl ?? STANDALONE_SHARE_UPLOAD_BASE_URL
93
+ const sharePublicBaseUrl = deps.sharePublicBaseUrl ?? STANDALONE_SHARE_PUBLIC_BASE_URL
94
+
95
+ if (!(await pathExists(viewerDistDir))) {
96
+ throw new Error("Standalone viewer bundle not found. Run `bun run build`.")
97
+ }
98
+
99
+ const exportRootDir = getProjectExportDir(args.localPath)
100
+ await ensureDir(exportRootDir, { recursive: true })
101
+
102
+ const outputDir = await resolveUniqueExportDir(exportRootDir, args.title || args.chatId, now, pathExists)
103
+ await copyDirectory(viewerDistDir, outputDir)
104
+
105
+ const attachmentsDir = path.join(outputDir, "attachments")
106
+ const prepared = await prepareStandaloneMessages(args.messages, {
107
+ attachmentMode: args.attachmentMode,
108
+ localPath: args.localPath,
109
+ attachmentsDir,
110
+ copyFile: copyFileImpl,
111
+ mkdir: ensureDir,
112
+ pathExists,
113
+ })
114
+
115
+ const bundle: StandaloneTranscriptBundle = {
116
+ version: STANDALONE_TRANSCRIPT_BUNDLE_VERSION,
117
+ chatId: args.chatId,
118
+ title: args.title,
119
+ localPath: STANDALONE_SHARE_WORKSPACE_PATH,
120
+ exportedAt: now.toISOString(),
121
+ viewerVersion: APP_VERSION,
122
+ theme: args.theme,
123
+ attachmentMode: args.attachmentMode,
124
+ messages: prepared.messages,
125
+ }
126
+
127
+ const transcriptJsonPath = path.join(outputDir, "transcript.json")
128
+ await writeFileImpl(transcriptJsonPath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8")
129
+ const shareSlug = buildStandaloneShareSlug(args.title || args.chatId, deps.shareSlugSuffix)
130
+ const shareUrl = buildStandaloneShareUrl(sharePublicBaseUrl, shareSlug)
131
+ const uploadedFileCount = await uploadStandaloneExportDirectory({
132
+ outputDir,
133
+ shareSlug,
134
+ uploadBaseUrl: shareUploadBaseUrl,
135
+ fetch: fetchImpl,
136
+ pathExists,
137
+ readDir,
138
+ readFile: readFileImpl,
139
+ })
140
+
141
+ return {
142
+ outputDir,
143
+ indexHtmlPath: path.join(outputDir, "index.html"),
144
+ transcriptJsonPath,
145
+ attachmentMode: args.attachmentMode,
146
+ totalAttachmentCount: prepared.totalAttachmentCount,
147
+ bundledAttachmentCount: prepared.bundledAttachmentCount,
148
+ shareSlug,
149
+ shareUrl,
150
+ uploadedFileCount,
151
+ }
152
+ }
153
+
154
+ async function prepareStandaloneMessages(
155
+ messages: TranscriptEntry[],
156
+ args: {
157
+ attachmentMode: StandaloneTranscriptAttachmentMode
158
+ localPath: string
159
+ attachmentsDir: string
160
+ copyFile: typeof copyFile
161
+ mkdir: typeof mkdir
162
+ pathExists: (targetPath: string) => Promise<boolean>
163
+ },
164
+ ): Promise<PreparedMessagesResult> {
165
+ const preparedMessages = structuredClone(messages)
166
+ let totalAttachmentCount = 0
167
+ let bundledAttachmentCount = 0
168
+ let attachmentsDirCreated = false
169
+
170
+ for (const message of preparedMessages) {
171
+ if (message.kind !== "user_prompt" || !message.attachments?.length) {
172
+ continue
173
+ }
174
+
175
+ totalAttachmentCount += message.attachments.length
176
+
177
+ for (const attachment of message.attachments) {
178
+ if (args.attachmentMode === "metadata") {
179
+ rewriteAttachmentAsMetadata(attachment)
180
+ continue
181
+ }
182
+
183
+ if (!attachment.absolutePath || !(await args.pathExists(attachment.absolutePath))) {
184
+ rewriteAttachmentAsMetadata(attachment)
185
+ continue
186
+ }
187
+
188
+ if (!attachmentsDirCreated) {
189
+ await args.mkdir(args.attachmentsDir, { recursive: true })
190
+ attachmentsDirCreated = true
191
+ }
192
+
193
+ const exportedFileName = `${sanitizeFileNameSegment(attachment.id)}-${sanitizeFileNameSegment(path.basename(attachment.displayName || attachment.absolutePath))}`
194
+ const destinationPath = path.join(args.attachmentsDir, exportedFileName)
195
+ await args.copyFile(attachment.absolutePath, destinationPath)
196
+ bundledAttachmentCount += 1
197
+
198
+ const relativeDestinationPath = `./attachments/${exportedFileName}`
199
+ attachment.absolutePath = relativeDestinationPath
200
+ attachment.relativePath = relativeDestinationPath
201
+ attachment.contentUrl = relativeDestinationPath
202
+ }
203
+ }
204
+
205
+ rewriteLocalPathsForShare(preparedMessages, args.localPath)
206
+
207
+ return {
208
+ messages: preparedMessages,
209
+ totalAttachmentCount,
210
+ bundledAttachmentCount,
211
+ }
212
+ }
213
+
214
+ function rewriteAttachmentAsMetadata(attachment: {
215
+ absolutePath: string
216
+ relativePath: string
217
+ contentUrl: string
218
+ }) {
219
+ attachment.absolutePath = ""
220
+ attachment.relativePath = ""
221
+ attachment.contentUrl = ""
222
+ }
223
+
224
+ async function resolveUniqueExportDir(
225
+ exportRootDir: string,
226
+ title: string,
227
+ now: Date,
228
+ pathExists: (targetPath: string) => Promise<boolean>,
229
+ ) {
230
+ const baseName = `${sanitizeFileNameSegment(title) || "chat"}-${formatExportTimestamp(now)}`
231
+ let candidate = path.join(exportRootDir, baseName)
232
+ let suffix = 2
233
+
234
+ while (await pathExists(candidate)) {
235
+ candidate = path.join(exportRootDir, `${baseName}-${suffix}`)
236
+ suffix += 1
237
+ }
238
+
239
+ return candidate
240
+ }
241
+
242
+ function formatExportTimestamp(value: Date) {
243
+ return value
244
+ .toISOString()
245
+ .replace(/:/g, "-")
246
+ .replace(/\.\d{3}Z$/u, "Z")
247
+ }
248
+
249
+ function sanitizeFileNameSegment(value: string) {
250
+ return value
251
+ .trim()
252
+ .replace(/[^\w.-]+/g, "-")
253
+ .replace(/^-+|-+$/g, "")
254
+ }
255
+
256
+ async function defaultPathExists(targetPath: string) {
257
+ try {
258
+ await stat(targetPath)
259
+ return true
260
+ } catch {
261
+ return false
262
+ }
263
+ }
264
+
265
+ async function defaultReadDir(targetPath: string) {
266
+ return await readdir(targetPath, { withFileTypes: true })
267
+ }
268
+
269
+ async function uploadStandaloneExportDirectory(args: {
270
+ outputDir: string
271
+ shareSlug: string
272
+ uploadBaseUrl: string
273
+ fetch: FetchLike
274
+ pathExists: (targetPath: string) => Promise<boolean>
275
+ readDir: (targetPath: string) => Promise<Dirent[]>
276
+ readFile: typeof readFile
277
+ }) {
278
+ const filePaths = await listShareUploadFiles(args.outputDir, args.readDir, args.pathExists)
279
+ let uploadedFileCount = 0
280
+
281
+ for (const filePath of filePaths) {
282
+ const relativePath = path.relative(args.outputDir, filePath).split(path.sep).join("/")
283
+ const body = await args.readFile(filePath)
284
+ const response = await args.fetch(buildShareUploadUrl(args.uploadBaseUrl, args.shareSlug, relativePath), {
285
+ method: "PUT",
286
+ headers: {
287
+ "Cache-Control": getShareUploadCacheControl(relativePath),
288
+ "Content-Type": getContentTypeForPath(relativePath),
289
+ },
290
+ body,
291
+ })
292
+
293
+ if (!response.ok) {
294
+ const detail = await response.text().catch(() => "")
295
+ const suffix = detail ? `: ${detail}` : ` (status ${response.status})`
296
+ throw new Error(`Failed to upload shared transcript file ${relativePath}${suffix}`)
297
+ }
298
+
299
+ uploadedFileCount += 1
300
+ }
301
+
302
+ return uploadedFileCount
303
+ }
304
+
305
+ async function listShareUploadFiles(
306
+ outputDir: string,
307
+ readDir: (targetPath: string) => Promise<Dirent[]>,
308
+ pathExists: (targetPath: string) => Promise<boolean>,
309
+ ): Promise<string[]> {
310
+ const filePaths = [path.join(outputDir, "transcript.json")]
311
+ const attachmentsDir = path.join(outputDir, "attachments")
312
+
313
+ if (await pathExists(attachmentsDir)) {
314
+ filePaths.push(...await listExportFiles(attachmentsDir, readDir))
315
+ }
316
+
317
+ return filePaths
318
+ }
319
+
320
+ async function listExportFiles(
321
+ rootDir: string,
322
+ readDir: (targetPath: string) => Promise<Dirent[]>,
323
+ ): Promise<string[]> {
324
+ const entries = await readDir(rootDir)
325
+ const files: string[] = []
326
+
327
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
328
+ const entryPath = path.join(rootDir, entry.name)
329
+ if (entry.isDirectory()) {
330
+ files.push(...await listExportFiles(entryPath, readDir))
331
+ continue
332
+ }
333
+ if (entry.isFile()) {
334
+ files.push(entryPath)
335
+ }
336
+ }
337
+
338
+ return files
339
+ }
340
+
341
+ function buildStandaloneShareSlug(title: string, providedSuffix?: string) {
342
+ const baseSlug = title
343
+ .trim()
344
+ .toLowerCase()
345
+ .replace(/[^a-z0-9]+/g, "-")
346
+ .replace(/^-+|-+$/g, "")
347
+ .slice(0, 64) || "chat"
348
+ const suffix = (providedSuffix ?? generateStandaloneShareSlugSuffix())
349
+ .trim()
350
+ .toLowerCase()
351
+ .replace(/[^a-z0-9]+/g, "")
352
+ .slice(0, 12) || "share"
353
+ return `${baseSlug}-${suffix}`
354
+ }
355
+
356
+ function generateStandaloneShareSlugSuffix() {
357
+ return BigInt(`0x${randomBytes(8).toString("hex")}`).toString(36).slice(0, 10).padStart(10, "0")
358
+ }
359
+
360
+ function buildStandaloneShareUrl(baseUrl: string, shareSlug: string) {
361
+ return `${baseUrl.replace(/\/+$/u, "")}/${shareSlug}`
362
+ }
363
+
364
+ function buildShareUploadUrl(baseUrl: string, shareSlug: string, relativePath: string) {
365
+ const encodedSegments = [shareSlug, ...relativePath.split("/")].map((segment) => encodeURIComponent(segment))
366
+ return `${baseUrl.replace(/\/+$/u, "")}/${encodedSegments.join("/")}`
367
+ }
368
+
369
+ function getShareUploadCacheControl(relativePath: string) {
370
+ return STANDALONE_SHARE_ASSET_CACHE_CONTROL
371
+ }
372
+
373
+ function getContentTypeForPath(relativePath: string) {
374
+ return CONTENT_TYPES_BY_EXTENSION[path.extname(relativePath).toLowerCase()] ?? "application/octet-stream"
375
+ }
376
+
377
+ function rewriteLocalPathsForShare(value: unknown, localPath: string) {
378
+ if (!localPath) {
379
+ return
380
+ }
381
+
382
+ if (typeof value === "string") {
383
+ return value.replaceAll(localPath, STANDALONE_SHARE_WORKSPACE_PATH)
384
+ }
385
+
386
+ if (Array.isArray(value)) {
387
+ for (let index = 0; index < value.length; index += 1) {
388
+ value[index] = rewriteLocalPathsForShare(value[index], localPath)
389
+ }
390
+ return value
391
+ }
392
+
393
+ if (!value || typeof value !== "object") {
394
+ return value
395
+ }
396
+
397
+ for (const [key, nestedValue] of Object.entries(value)) {
398
+ ;(value as Record<string, unknown>)[key] = rewriteLocalPathsForShare(nestedValue, localPath)
399
+ }
400
+
401
+ return value
402
+ }
@@ -12,6 +12,7 @@ import { EventStore } from "./event-store"
12
12
  import { openExternal } from "./external-open"
13
13
  import { KeybindingsManager } from "./keybindings"
14
14
  import { ensureProjectDirectory, resolveLocalPath } from "./paths"
15
+ import { writeStandaloneTranscriptExport } from "./standalone-export"
15
16
  import { TerminalManager } from "./terminal-manager"
16
17
  import type { UpdateManager } from "./update-manager"
17
18
  import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
@@ -1098,6 +1099,19 @@ export function createWsRouter({
1098
1099
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
1099
1100
  return
1100
1101
  }
1102
+ case "chat.exportStandalone": {
1103
+ const { chat, project } = resolveChatProject(command.chatId)
1104
+ const result = await writeStandaloneTranscriptExport({
1105
+ chatId: chat.id,
1106
+ title: chat.title,
1107
+ localPath: project.localPath,
1108
+ theme: command.theme,
1109
+ attachmentMode: command.attachmentMode,
1110
+ messages: store.getMessages(command.chatId),
1111
+ })
1112
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
1113
+ return
1114
+ }
1101
1115
  case "chat.loadHistory": {
1102
1116
  const chat = store.getChat(command.chatId)
1103
1117
  if (!chat) throw new Error("Chat not found")
@@ -6,6 +6,7 @@ export const PACKAGE_NAME = "kanna-code"
6
6
  export const RUNTIME_PROFILE_ENV_VAR = "KANNA_RUNTIME_PROFILE"
7
7
  // Read version from package.json — JSON import works in both Bun and Vite
8
8
  import pkg from "../../package.json"
9
+ export const APP_VERSION = pkg.version
9
10
  export const SDK_CLIENT_APP = `kanna/${pkg.version}`
10
11
  export const LOG_PREFIX = "[kanna]"
11
12
  export const DEFAULT_NEW_PROJECT_ROOT = `~/${APP_NAME}`
@@ -11,6 +11,8 @@ import type {
11
11
  LocalProjectsSnapshot,
12
12
  ModelOptions,
13
13
  SidebarData,
14
+ StandaloneTranscriptAttachmentMode,
15
+ StandaloneTranscriptExportResult,
14
16
  UpdateSnapshot,
15
17
  } from "./types"
16
18
 
@@ -173,6 +175,12 @@ export type ClientCommand =
173
175
  | { type: "chat.ignoreDiffFile"; chatId: string; path: string }
174
176
  | { type: "chat.cancel"; chatId: string }
175
177
  | { type: "chat.stopDraining"; chatId: string }
178
+ | {
179
+ type: "chat.exportStandalone"
180
+ chatId: string
181
+ theme: "light" | "dark"
182
+ attachmentMode: StandaloneTranscriptAttachmentMode
183
+ }
176
184
  | { type: "chat.loadHistory"; chatId: string; beforeCursor: string; limit: number }
177
185
  | { type: "chat.respondTool"; chatId: string; toolUseId: string; result: unknown }
178
186
  | {
@@ -219,7 +227,7 @@ export type ServerSnapshot =
219
227
  export type ServerEnvelope =
220
228
  | { v: 1; type: "snapshot"; id: string; snapshot: ServerSnapshot }
221
229
  | { v: 1; type: "event"; id: string; event: TerminalEvent }
222
- | { v: 1; type: "ack"; id: string; result?: unknown | ChatHistoryPage }
230
+ | { v: 1; type: "ack"; id: string; result?: unknown | ChatHistoryPage | StandaloneTranscriptExportResult }
223
231
  | { v: 1; type: "error"; id?: string; message: string }
224
232
 
225
233
  export function isClientEnvelope(value: unknown): value is ClientEnvelope {
@@ -12,7 +12,8 @@ describe("shared model normalization", () => {
12
12
  expect(normalizeClaudeModelId("haiku")).toBe("claude-haiku-4-5-20251001")
13
13
  })
14
14
 
15
- test("normalizes legacy Codex aliases via the provider catalog", () => {
15
+ test("normalizes legacy Codex aliases and defaults to the latest catalog model", () => {
16
+ expect(normalizeCodexModelId()).toBe("gpt-5.5")
16
17
  expect(normalizeCodexModelId("gpt-5-codex")).toBe("gpt-5.3-codex")
17
18
  })
18
19
 
@@ -7,6 +7,8 @@ export const DEFAULT_OPENAI_SDK_MODEL = "gpt-5.4-mini"
7
7
  export const DEFAULT_OPENROUTER_SDK_MODEL = "moonshotai/kimi-k2.5:nitro"
8
8
 
9
9
  export type AttachmentKind = "image" | "file"
10
+ export type StandaloneTranscriptAttachmentMode = "metadata" | "bundle"
11
+ export type StandaloneTranscriptTheme = "light" | "dark"
10
12
 
11
13
  export interface ChatAttachment {
12
14
  id: string
@@ -19,6 +21,30 @@ export interface ChatAttachment {
19
21
  size: number
20
22
  }
21
23
 
24
+ export interface StandaloneTranscriptBundle {
25
+ version: 1
26
+ chatId: string
27
+ title: string
28
+ localPath: string
29
+ exportedAt: string
30
+ viewerVersion: string
31
+ theme: StandaloneTranscriptTheme
32
+ attachmentMode: StandaloneTranscriptAttachmentMode
33
+ messages: TranscriptEntry[]
34
+ }
35
+
36
+ export interface StandaloneTranscriptExportResult {
37
+ outputDir: string
38
+ indexHtmlPath: string
39
+ transcriptJsonPath: string
40
+ attachmentMode: StandaloneTranscriptAttachmentMode
41
+ totalAttachmentCount: number
42
+ bundledAttachmentCount: number
43
+ shareSlug: string
44
+ shareUrl: string
45
+ uploadedFileCount: number
46
+ }
47
+
22
48
  export interface QueuedChatMessage {
23
49
  id: string
24
50
  content: string
@@ -166,9 +192,10 @@ export const PROVIDERS: ProviderCatalogEntry[] = [
166
192
  {
167
193
  id: "codex",
168
194
  label: "Codex",
169
- defaultModel: "gpt-5.4",
195
+ defaultModel: "gpt-5.5",
170
196
  supportsPlanMode: true,
171
197
  models: [
198
+ { id: "gpt-5.5", label: "GPT-5.5", supportsEffort: false },
172
199
  { id: "gpt-5.4", label: "GPT-5.4", supportsEffort: false },
173
200
  { id: "gpt-5.3-codex", label: "GPT-5.3 Codex", supportsEffort: false, aliases: ["gpt-5-codex"] },
174
201
  { id: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", supportsEffort: false },
@@ -207,7 +234,7 @@ export function normalizeClaudeModelId(modelId?: string, fallbackModelId = "clau
207
234
  return normalizeProviderModelId("claude", modelId, fallbackModelId)
208
235
  }
209
236
 
210
- export function normalizeCodexModelId(modelId?: string, fallbackModelId = "gpt-5.4"): string {
237
+ export function normalizeCodexModelId(modelId?: string, fallbackModelId = "gpt-5.5"): string {
211
238
  return normalizeProviderModelId("codex", modelId, fallbackModelId)
212
239
  }
213
240