kanna-code 0.34.4 → 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.
- package/dist/client/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
- package/dist/client/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
- package/dist/client/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
- package/dist/client/assets/index-BbuEYb7n.css +32 -0
- package/dist/client/assets/index-Dj4wUBuS.js +2613 -0
- package/dist/client/index.html +3 -3
- package/dist/export-viewer/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
- package/dist/export-viewer/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
- package/dist/export-viewer/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
- package/dist/export-viewer/assets/index-BcwN0dxP.css +1 -0
- package/dist/export-viewer/assets/index-BmJ7RE79.js +400 -0
- package/dist/export-viewer/fonts/body-medium.woff2 +0 -0
- package/dist/export-viewer/fonts/body-regular-italic.woff2 +0 -0
- package/dist/export-viewer/fonts/body-regular.woff2 +0 -0
- package/dist/export-viewer/fonts/body-semibold.woff2 +0 -0
- package/dist/export-viewer/index.html +14 -0
- package/package.json +10 -5
- package/src/server/codex-app-server.test.ts +3 -1
- package/src/server/codex-app-server.ts +2 -2
- package/src/server/paths.ts +4 -0
- package/src/server/provider-catalog.test.ts +1 -0
- package/src/server/provider-catalog.ts +2 -1
- package/src/server/read-models.test.ts +1 -0
- package/src/server/standalone-export.test.ts +173 -0
- package/src/server/standalone-export.ts +402 -0
- package/src/server/ws-router.ts +14 -0
- package/src/shared/branding.ts +1 -0
- package/src/shared/protocol.ts +9 -1
- package/src/shared/types.test.ts +2 -1
- package/src/shared/types.ts +29 -2
- package/dist/client/assets/index-C661c2yH.js +0 -2608
- 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
|
+
}
|
package/src/server/ws-router.ts
CHANGED
|
@@ -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")
|
package/src/shared/branding.ts
CHANGED
|
@@ -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}`
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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 {
|
package/src/shared/types.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/shared/types.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
237
|
+
export function normalizeCodexModelId(modelId?: string, fallbackModelId = "gpt-5.5"): string {
|
|
211
238
|
return normalizeProviderModelId("codex", modelId, fallbackModelId)
|
|
212
239
|
}
|
|
213
240
|
|