kanna-code 0.17.0 → 0.19.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/index-C952_xJD.css +32 -0
- package/dist/client/assets/index-lgfU0zw2.js +606 -0
- package/dist/client/chat-sounds/Blow.mp3 +0 -0
- package/dist/client/chat-sounds/Bottle.mp3 +0 -0
- package/dist/client/chat-sounds/Frog.mp3 +0 -0
- package/dist/client/chat-sounds/Funk.mp3 +0 -0
- package/dist/client/chat-sounds/Glass.mp3 +0 -0
- package/dist/client/chat-sounds/Ping.mp3 +0 -0
- package/dist/client/chat-sounds/Pop.mp3 +0 -0
- package/dist/client/chat-sounds/Purr.mp3 +0 -0
- package/dist/client/chat-sounds/Tink.mp3 +0 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/server/agent.test.ts +153 -4
- package/src/server/agent.ts +76 -15
- package/src/server/event-store.test.ts +65 -0
- package/src/server/event-store.ts +27 -1
- package/src/server/events.ts +8 -0
- package/src/server/generate-title.ts +42 -2
- package/src/server/paths.ts +4 -0
- package/src/server/quick-response.test.ts +126 -3
- package/src/server/quick-response.ts +107 -14
- package/src/server/read-models.test.ts +4 -0
- package/src/server/read-models.ts +1 -0
- package/src/server/server.ts +178 -1
- package/src/server/title-generation.live.test.ts +44 -0
- package/src/server/uploads.test.ts +282 -0
- package/src/server/uploads.ts +127 -0
- package/src/server/ws-router.test.ts +181 -0
- package/src/server/ws-router.ts +18 -0
- package/src/shared/protocol.ts +3 -0
- package/src/shared/tools.test.ts +65 -0
- package/src/shared/tools.ts +76 -0
- package/src/shared/types.ts +34 -1
- package/dist/client/assets/index-CtKZIZdD.js +0 -533
- package/dist/client/assets/index-Cwex1U8S.css +0 -32
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { generateTitleForChat } from "./generate-title"
|
|
3
|
-
import { QuickResponseAdapter } from "./quick-response"
|
|
2
|
+
import { fallbackTitleFromMessage, generateTitleForChat, generateTitleForChatDetailed } from "./generate-title"
|
|
3
|
+
import { getQuickResponseWorkspace, QuickResponseAdapter } from "./quick-response"
|
|
4
4
|
|
|
5
5
|
describe("QuickResponseAdapter", () => {
|
|
6
6
|
test("returns the Claude structured result when it validates", async () => {
|
|
@@ -85,6 +85,83 @@ describe("QuickResponseAdapter", () => {
|
|
|
85
85
|
|
|
86
86
|
expect(result).toBe("Codex title")
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
test("uses the Kanna app data root as the quick-response workspace", async () => {
|
|
90
|
+
const previousProfile = process.env.KANNA_RUNTIME_PROFILE
|
|
91
|
+
process.env.KANNA_RUNTIME_PROFILE = "dev"
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
let claudeCwd = ""
|
|
95
|
+
const adapter = new QuickResponseAdapter({
|
|
96
|
+
runClaudeStructured: async (args) => {
|
|
97
|
+
claudeCwd = args.cwd
|
|
98
|
+
return { title: "Claude title" }
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
await adapter.generateStructured({
|
|
103
|
+
cwd: "/tmp/project",
|
|
104
|
+
task: "title generation",
|
|
105
|
+
prompt: "Generate a title",
|
|
106
|
+
schema: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: {
|
|
109
|
+
title: { type: "string" },
|
|
110
|
+
},
|
|
111
|
+
required: ["title"],
|
|
112
|
+
additionalProperties: false,
|
|
113
|
+
},
|
|
114
|
+
parse: (value) => {
|
|
115
|
+
const output = value && typeof value === "object" ? value as { title?: unknown } : {}
|
|
116
|
+
return typeof output.title === "string" ? output.title : null
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(claudeCwd).toBe(getQuickResponseWorkspace(process.env))
|
|
121
|
+
expect(claudeCwd.endsWith("/.kanna-dev")).toBe(true)
|
|
122
|
+
} finally {
|
|
123
|
+
if (previousProfile === undefined) {
|
|
124
|
+
delete process.env.KANNA_RUNTIME_PROFILE
|
|
125
|
+
} else {
|
|
126
|
+
process.env.KANNA_RUNTIME_PROFILE = previousProfile
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("uses gpt-5.4-mini for Codex title generation fallback", async () => {
|
|
132
|
+
const requests: Array<{ cwd: string; prompt: string; model?: string }> = []
|
|
133
|
+
const adapter = new QuickResponseAdapter({
|
|
134
|
+
codexManager: {
|
|
135
|
+
async generateStructured(args: { cwd: string; prompt: string; model?: string }) {
|
|
136
|
+
requests.push(args)
|
|
137
|
+
return "{\"title\":\"Codex title\"}"
|
|
138
|
+
},
|
|
139
|
+
} as never,
|
|
140
|
+
runClaudeStructured: async () => null,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const result = await adapter.generateStructured({
|
|
144
|
+
cwd: "/tmp/project",
|
|
145
|
+
task: "title generation",
|
|
146
|
+
prompt: "Generate a title",
|
|
147
|
+
schema: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
title: { type: "string" },
|
|
151
|
+
},
|
|
152
|
+
required: ["title"],
|
|
153
|
+
additionalProperties: false,
|
|
154
|
+
},
|
|
155
|
+
parse: (value) => {
|
|
156
|
+
const output = value && typeof value === "object" ? value as { title?: unknown } : {}
|
|
157
|
+
return typeof output.title === "string" ? output.title : null
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(result).toBe("Codex title")
|
|
162
|
+
expect(requests).toHaveLength(1)
|
|
163
|
+
expect(requests[0]?.model).toBe("gpt-5.4-mini")
|
|
164
|
+
})
|
|
88
165
|
})
|
|
89
166
|
|
|
90
167
|
describe("generateTitleForChat", () => {
|
|
@@ -110,6 +187,52 @@ describe("generateTitleForChat", () => {
|
|
|
110
187
|
})
|
|
111
188
|
)
|
|
112
189
|
|
|
113
|
-
expect(title).
|
|
190
|
+
expect(title).toBe("hello")
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test("falls back to the first 35 characters of the message with ellipsis", async () => {
|
|
194
|
+
const title = await generateTitleForChat(
|
|
195
|
+
"This message is definitely longer than thirty five characters",
|
|
196
|
+
"/tmp/project",
|
|
197
|
+
new QuickResponseAdapter({
|
|
198
|
+
runClaudeStructured: async () => {
|
|
199
|
+
throw new Error("Not authenticated")
|
|
200
|
+
},
|
|
201
|
+
runCodexStructured: async () => null,
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
expect(title).toBe("This message is definitely longer t...")
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test("returns fallback metadata when providers fail", async () => {
|
|
209
|
+
const result = await generateTitleForChatDetailed(
|
|
210
|
+
"hello there",
|
|
211
|
+
"/tmp/project",
|
|
212
|
+
new QuickResponseAdapter({
|
|
213
|
+
runClaudeStructured: async () => {
|
|
214
|
+
throw new Error("Not authenticated")
|
|
215
|
+
},
|
|
216
|
+
runCodexStructured: async () => {
|
|
217
|
+
throw new Error("Codex unavailable")
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
expect(result).toEqual({
|
|
223
|
+
title: "hello there",
|
|
224
|
+
usedFallback: true,
|
|
225
|
+
failureMessage: "claude failed conversation title generation: Not authenticated; codex failed conversation title generation: Codex unavailable",
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe("fallbackTitleFromMessage", () => {
|
|
231
|
+
test("normalizes whitespace", () => {
|
|
232
|
+
expect(fallbackTitleFromMessage(" hello\n world ")).toBe("hello world")
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test("returns null for blank input", () => {
|
|
236
|
+
expect(fallbackTitleFromMessage(" \n ")).toBeNull()
|
|
114
237
|
})
|
|
115
238
|
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { getDataRootDir } from "../shared/branding"
|
|
2
4
|
import { CodexAppServerManager } from "./codex-app-server"
|
|
3
5
|
|
|
4
6
|
const CLAUDE_STRUCTURED_TIMEOUT_MS = 5_000
|
|
@@ -24,6 +26,20 @@ interface QuickResponseAdapterArgs {
|
|
|
24
26
|
runCodexStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
export interface StructuredQuickResponseFailure {
|
|
30
|
+
provider: "claude" | "codex"
|
|
31
|
+
reason: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StructuredQuickResponseResult<T> {
|
|
35
|
+
value: T | null
|
|
36
|
+
failures: StructuredQuickResponseFailure[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getQuickResponseWorkspace(env: Record<string, string | undefined> = process.env) {
|
|
40
|
+
return getDataRootDir(homedir(), env)
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
function parseJsonText(value: string): unknown | null {
|
|
28
44
|
const trimmed = value.trim()
|
|
29
45
|
if (!trimmed) return null
|
|
@@ -45,10 +61,35 @@ function parseJsonText(value: string): unknown | null {
|
|
|
45
61
|
return null
|
|
46
62
|
}
|
|
47
63
|
|
|
48
|
-
|
|
64
|
+
function structuredOutputFromSdkMessage(message: unknown): unknown | null {
|
|
65
|
+
if (!message || typeof message !== "object") return null
|
|
66
|
+
|
|
67
|
+
const record = message as Record<string, unknown>
|
|
68
|
+
if (record.type === "result") {
|
|
69
|
+
return record.structured_output ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const assistantMessage = record.message
|
|
73
|
+
if (!assistantMessage || typeof assistantMessage !== "object") return null
|
|
74
|
+
const content = (assistantMessage as { content?: unknown }).content
|
|
75
|
+
if (!Array.isArray(content)) return null
|
|
76
|
+
|
|
77
|
+
for (const item of content) {
|
|
78
|
+
if (!item || typeof item !== "object") continue
|
|
79
|
+
const toolUse = item as Record<string, unknown>
|
|
80
|
+
if (toolUse.type === "tool_use" && toolUse.name === "StructuredOutput") {
|
|
81
|
+
return toolUse.input ?? null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknown>, "parse">): Promise<unknown | null> {
|
|
49
89
|
const q = query({
|
|
50
90
|
prompt: args.prompt,
|
|
51
91
|
options: {
|
|
92
|
+
cwd: args.cwd,
|
|
52
93
|
model: "haiku",
|
|
53
94
|
tools: [],
|
|
54
95
|
systemPrompt: "",
|
|
@@ -66,8 +107,9 @@ async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknow
|
|
|
66
107
|
const result = await Promise.race<unknown | null>([
|
|
67
108
|
(async () => {
|
|
68
109
|
for await (const message of q) {
|
|
69
|
-
|
|
70
|
-
|
|
110
|
+
const structuredOutput = structuredOutputFromSdkMessage(message)
|
|
111
|
+
if (structuredOutput !== null) {
|
|
112
|
+
return structuredOutput
|
|
71
113
|
}
|
|
72
114
|
}
|
|
73
115
|
return null
|
|
@@ -91,12 +133,13 @@ async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknow
|
|
|
91
133
|
}
|
|
92
134
|
}
|
|
93
135
|
|
|
94
|
-
async function runCodexStructured(
|
|
136
|
+
export async function runCodexStructured(
|
|
95
137
|
codexManager: CodexAppServerManager,
|
|
96
138
|
args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
|
|
97
139
|
): Promise<unknown | null> {
|
|
98
140
|
const response = await codexManager.generateStructured({
|
|
99
141
|
cwd: args.cwd,
|
|
142
|
+
model: "gpt-5.4-mini",
|
|
100
143
|
prompt: `${args.prompt}\n\nReturn JSON only that matches this schema:\n${JSON.stringify(args.schema, null, 2)}`,
|
|
101
144
|
})
|
|
102
145
|
if (typeof response !== "string") return null
|
|
@@ -115,17 +158,45 @@ export class QuickResponseAdapter {
|
|
|
115
158
|
runCodexStructured(this.codexManager, structuredArgs))
|
|
116
159
|
}
|
|
117
160
|
async generateStructured<T>(args: StructuredQuickResponseArgs<T>): Promise<T | null> {
|
|
161
|
+
const result = await this.generateStructuredWithDiagnostics(args)
|
|
162
|
+
return result.value
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async generateStructuredWithDiagnostics<T>(args: StructuredQuickResponseArgs<T>): Promise<StructuredQuickResponseResult<T>> {
|
|
118
166
|
const request = {
|
|
119
|
-
cwd:
|
|
167
|
+
cwd: getQuickResponseWorkspace(),
|
|
120
168
|
task: args.task,
|
|
121
169
|
prompt: args.prompt,
|
|
122
170
|
schema: args.schema,
|
|
123
171
|
}
|
|
124
172
|
|
|
173
|
+
const failures: StructuredQuickResponseFailure[] = []
|
|
125
174
|
const claudeResult = await this.tryProvider("claude", args.task, args.parse, () => this.runClaudeStructured(request))
|
|
126
|
-
if (claudeResult !== null)
|
|
175
|
+
if (claudeResult.value !== null) {
|
|
176
|
+
return {
|
|
177
|
+
value: claudeResult.value,
|
|
178
|
+
failures,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (claudeResult.failure) {
|
|
182
|
+
failures.push(claudeResult.failure)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const codexResult = await this.tryProvider("codex", args.task, args.parse, () => this.runCodexStructured(request))
|
|
186
|
+
if (codexResult.value !== null) {
|
|
187
|
+
return {
|
|
188
|
+
value: codexResult.value,
|
|
189
|
+
failures,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (codexResult.failure) {
|
|
193
|
+
failures.push(codexResult.failure)
|
|
194
|
+
}
|
|
127
195
|
|
|
128
|
-
return
|
|
196
|
+
return {
|
|
197
|
+
value: null,
|
|
198
|
+
failures,
|
|
199
|
+
}
|
|
129
200
|
}
|
|
130
201
|
|
|
131
202
|
private async tryProvider<T>(
|
|
@@ -133,21 +204,43 @@ export class QuickResponseAdapter {
|
|
|
133
204
|
task: string,
|
|
134
205
|
parse: (value: unknown) => T | null,
|
|
135
206
|
run: () => Promise<unknown | null>
|
|
136
|
-
): Promise<T | null> {
|
|
207
|
+
): Promise<{ value: T | null; failure: StructuredQuickResponseFailure | null }> {
|
|
137
208
|
try {
|
|
138
209
|
const result = await run()
|
|
139
210
|
if (result === null) {
|
|
140
|
-
return
|
|
211
|
+
return {
|
|
212
|
+
value: null,
|
|
213
|
+
failure: {
|
|
214
|
+
provider,
|
|
215
|
+
reason: `${provider} returned no result for ${task}`,
|
|
216
|
+
},
|
|
217
|
+
}
|
|
141
218
|
}
|
|
142
|
-
|
|
219
|
+
|
|
143
220
|
const parsed = parse(result)
|
|
144
221
|
if (parsed === null) {
|
|
145
|
-
return
|
|
222
|
+
return {
|
|
223
|
+
value: null,
|
|
224
|
+
failure: {
|
|
225
|
+
provider,
|
|
226
|
+
reason: `${provider} returned invalid structured output for ${task}`,
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
value: parsed,
|
|
233
|
+
failure: null,
|
|
146
234
|
}
|
|
147
|
-
|
|
148
|
-
return parsed
|
|
149
235
|
} catch (error) {
|
|
150
|
-
|
|
236
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
237
|
+
return {
|
|
238
|
+
value: null,
|
|
239
|
+
failure: {
|
|
240
|
+
provider,
|
|
241
|
+
reason: `${provider} failed ${task}: ${message}`,
|
|
242
|
+
},
|
|
243
|
+
}
|
|
151
244
|
}
|
|
152
245
|
}
|
|
153
246
|
}
|
|
@@ -19,6 +19,7 @@ describe("read models", () => {
|
|
|
19
19
|
title: "Chat",
|
|
20
20
|
createdAt: 1,
|
|
21
21
|
updatedAt: 1,
|
|
22
|
+
unread: true,
|
|
22
23
|
provider: "codex",
|
|
23
24
|
planMode: false,
|
|
24
25
|
sessionToken: "thread-1",
|
|
@@ -27,6 +28,7 @@ describe("read models", () => {
|
|
|
27
28
|
|
|
28
29
|
const sidebar = deriveSidebarData(state, new Map())
|
|
29
30
|
expect(sidebar.projectGroups[0]?.chats[0]?.provider).toBe("codex")
|
|
31
|
+
expect(sidebar.projectGroups[0]?.chats[0]?.unread).toBe(true)
|
|
30
32
|
})
|
|
31
33
|
|
|
32
34
|
test("includes available providers in chat snapshots", () => {
|
|
@@ -45,6 +47,7 @@ describe("read models", () => {
|
|
|
45
47
|
title: "Chat",
|
|
46
48
|
createdAt: 1,
|
|
47
49
|
updatedAt: 1,
|
|
50
|
+
unread: false,
|
|
48
51
|
provider: "claude",
|
|
49
52
|
planMode: true,
|
|
50
53
|
sessionToken: "session-1",
|
|
@@ -77,6 +80,7 @@ describe("read models", () => {
|
|
|
77
80
|
title: "Chat",
|
|
78
81
|
createdAt: 1,
|
|
79
82
|
updatedAt: 75,
|
|
83
|
+
unread: false,
|
|
80
84
|
provider: "codex",
|
|
81
85
|
planMode: false,
|
|
82
86
|
sessionToken: null,
|
package/src/server/server.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
+
import { stat } from "node:fs/promises"
|
|
2
3
|
import { APP_NAME, getRuntimeProfile } from "../shared/branding"
|
|
4
|
+
import type { ChatAttachment } from "../shared/types"
|
|
3
5
|
import { EventStore } from "./event-store"
|
|
4
6
|
import { AgentCoordinator } from "./agent"
|
|
5
7
|
import { discoverProjects, type DiscoveredProject } from "./discovery"
|
|
@@ -9,6 +11,45 @@ import { TerminalManager } from "./terminal-manager"
|
|
|
9
11
|
import { UpdateManager } from "./update-manager"
|
|
10
12
|
import type { UpdateInstallAttemptResult } from "./cli-runtime"
|
|
11
13
|
import { createWsRouter, type ClientState } from "./ws-router"
|
|
14
|
+
import { deleteProjectUpload, inferAttachmentContentType, persistProjectUpload } from "./uploads"
|
|
15
|
+
import { getProjectUploadDir } from "./paths"
|
|
16
|
+
|
|
17
|
+
const MAX_UPLOAD_FILES = 10
|
|
18
|
+
const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
export async function persistUploadedFiles(args: {
|
|
21
|
+
projectId: string
|
|
22
|
+
localPath: string
|
|
23
|
+
files: File[]
|
|
24
|
+
persistUpload?: typeof persistProjectUpload
|
|
25
|
+
}): Promise<ChatAttachment[]> {
|
|
26
|
+
const persistUpload = args.persistUpload ?? persistProjectUpload
|
|
27
|
+
const attachments: ChatAttachment[] = []
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
for (const file of args.files) {
|
|
31
|
+
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
32
|
+
const attachment = await persistUpload({
|
|
33
|
+
projectId: args.projectId,
|
|
34
|
+
localPath: args.localPath,
|
|
35
|
+
fileName: file.name,
|
|
36
|
+
bytes,
|
|
37
|
+
fallbackMimeType: file.type || undefined,
|
|
38
|
+
})
|
|
39
|
+
attachments.push(attachment)
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
await Promise.allSettled(
|
|
43
|
+
attachments.map((attachment) => deleteProjectUpload({
|
|
44
|
+
localPath: args.localPath,
|
|
45
|
+
storedName: path.basename(attachment.absolutePath),
|
|
46
|
+
}))
|
|
47
|
+
)
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return attachments
|
|
52
|
+
}
|
|
12
53
|
|
|
13
54
|
export interface StartKannaServerOptions {
|
|
14
55
|
port?: number
|
|
@@ -79,7 +120,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
79
120
|
server = Bun.serve<ClientState>({
|
|
80
121
|
port: actualPort,
|
|
81
122
|
hostname,
|
|
82
|
-
fetch(req, serverInstance) {
|
|
123
|
+
async fetch(req, serverInstance) {
|
|
83
124
|
const url = new URL(req.url)
|
|
84
125
|
|
|
85
126
|
if (url.pathname === "/ws") {
|
|
@@ -95,6 +136,21 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
95
136
|
return Response.json({ ok: true, port: actualPort })
|
|
96
137
|
}
|
|
97
138
|
|
|
139
|
+
const uploadResponse = await handleProjectUpload(req, url, store)
|
|
140
|
+
if (uploadResponse) {
|
|
141
|
+
return uploadResponse
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const deleteUploadResponse = await handleProjectUploadDelete(req, url, store)
|
|
145
|
+
if (deleteUploadResponse) {
|
|
146
|
+
return deleteUploadResponse
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const attachmentContentResponse = await handleAttachmentContent(req, url, store)
|
|
150
|
+
if (attachmentContentResponse) {
|
|
151
|
+
return attachmentContentResponse
|
|
152
|
+
}
|
|
153
|
+
|
|
98
154
|
return serveStatic(distDir, url.pathname)
|
|
99
155
|
},
|
|
100
156
|
websocket: {
|
|
@@ -140,6 +196,127 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
140
196
|
}
|
|
141
197
|
}
|
|
142
198
|
|
|
199
|
+
async function handleProjectUpload(req: Request, url: URL, store: EventStore) {
|
|
200
|
+
if (req.method !== "POST") {
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads$/)
|
|
205
|
+
if (!match) {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const project = store.getProject(match[1])
|
|
210
|
+
if (!project) {
|
|
211
|
+
return Response.json({ error: "Project not found" }, { status: 404 })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const formData = await req.formData()
|
|
215
|
+
const files = formData
|
|
216
|
+
.getAll("files")
|
|
217
|
+
.filter((value): value is File => value instanceof File)
|
|
218
|
+
|
|
219
|
+
if (files.length === 0) {
|
|
220
|
+
return Response.json({ error: "No files uploaded" }, { status: 400 })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (files.length > MAX_UPLOAD_FILES) {
|
|
224
|
+
return Response.json({ error: `You can upload up to ${MAX_UPLOAD_FILES} files at a time.` }, { status: 400 })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const file of files) {
|
|
228
|
+
if (file.size > MAX_UPLOAD_SIZE_BYTES) {
|
|
229
|
+
return Response.json(
|
|
230
|
+
{ error: `File "${file.name}" exceeds the ${Math.floor(MAX_UPLOAD_SIZE_BYTES / (1024 * 1024))} MB limit.` },
|
|
231
|
+
{ status: 413 }
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const attachments = await persistUploadedFiles({
|
|
238
|
+
projectId: project.id,
|
|
239
|
+
localPath: project.localPath,
|
|
240
|
+
files,
|
|
241
|
+
})
|
|
242
|
+
return Response.json({ attachments })
|
|
243
|
+
} catch (error) {
|
|
244
|
+
console.error("[uploads] Upload failed:", error)
|
|
245
|
+
return Response.json({ error: "Upload failed" }, { status: 500 })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function handleAttachmentContent(req: Request, url: URL, store: EventStore) {
|
|
250
|
+
const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)\/content$/)
|
|
251
|
+
if (!match) {
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (req.method !== "GET") {
|
|
256
|
+
return new Response(null, {
|
|
257
|
+
status: 405,
|
|
258
|
+
headers: {
|
|
259
|
+
Allow: "GET",
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const project = store.getProject(match[1])
|
|
265
|
+
if (!project) {
|
|
266
|
+
return Response.json({ error: "Project not found" }, { status: 404 })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const storedName = decodeURIComponent(match[2])
|
|
270
|
+
if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
|
|
271
|
+
return Response.json({ error: "Invalid attachment path" }, { status: 400 })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const filePath = path.join(getProjectUploadDir(project.localPath), storedName)
|
|
275
|
+
const file = Bun.file(filePath)
|
|
276
|
+
try {
|
|
277
|
+
const info = await stat(filePath)
|
|
278
|
+
if (!info.isFile()) {
|
|
279
|
+
return Response.json({ error: "Attachment not found" }, { status: 404 })
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
return Response.json({ error: "Attachment not found" }, { status: 404 })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return new Response(file, {
|
|
286
|
+
headers: {
|
|
287
|
+
"Content-Type": inferAttachmentContentType(storedName, file.type),
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function handleProjectUploadDelete(req: Request, url: URL, store: EventStore) {
|
|
293
|
+
if (req.method !== "DELETE") {
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)$/)
|
|
298
|
+
if (!match) {
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const project = store.getProject(match[1])
|
|
303
|
+
if (!project) {
|
|
304
|
+
return Response.json({ error: "Project not found" }, { status: 404 })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const storedName = decodeURIComponent(match[2])
|
|
308
|
+
if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
|
|
309
|
+
return Response.json({ error: "Invalid attachment path" }, { status: 400 })
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const deleted = await deleteProjectUpload({
|
|
313
|
+
localPath: project.localPath,
|
|
314
|
+
storedName,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
return Response.json({ ok: deleted })
|
|
318
|
+
}
|
|
319
|
+
|
|
143
320
|
async function serveStatic(distDir: string, pathname: string) {
|
|
144
321
|
const requestedPath = pathname === "/" ? "/index.html" : pathname
|
|
145
322
|
const filePath = path.join(distDir, requestedPath)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { CodexAppServerManager } from "./codex-app-server"
|
|
3
|
+
import { fallbackTitleFromMessage, generateTitleForChatDetailed } from "./generate-title"
|
|
4
|
+
import { QuickResponseAdapter, runClaudeStructured, runCodexStructured } from "./quick-response"
|
|
5
|
+
|
|
6
|
+
const shouldRunLiveTests = process.env.KANNA_RUN_LIVE_TITLE_TESTS === "1"
|
|
7
|
+
const LIVE_MESSAGE = "Please help me debug a websocket reconnection issue in a Bun server app"
|
|
8
|
+
|
|
9
|
+
if (shouldRunLiveTests) {
|
|
10
|
+
describe("live title generation", () => {
|
|
11
|
+
test("generates a title with Claude", async () => {
|
|
12
|
+
const adapter = new QuickResponseAdapter({
|
|
13
|
+
runClaudeStructured,
|
|
14
|
+
runCodexStructured: async () => {
|
|
15
|
+
throw new Error("Codex fallback should not be used in the Claude live title test")
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const result = await generateTitleForChatDetailed(LIVE_MESSAGE, process.cwd(), adapter)
|
|
20
|
+
|
|
21
|
+
expect(result.usedFallback).toBe(false)
|
|
22
|
+
expect(result.failureMessage).toBeNull()
|
|
23
|
+
expect(typeof result.title).toBe("string")
|
|
24
|
+
expect(result.title).not.toBe(fallbackTitleFromMessage(LIVE_MESSAGE))
|
|
25
|
+
}, 15_000)
|
|
26
|
+
|
|
27
|
+
test("generates a title with Codex", async () => {
|
|
28
|
+
const codexManager = new CodexAppServerManager()
|
|
29
|
+
const adapter = new QuickResponseAdapter({
|
|
30
|
+
runClaudeStructured: async () => {
|
|
31
|
+
throw new Error("Claude should not be used in the Codex live title test")
|
|
32
|
+
},
|
|
33
|
+
runCodexStructured: async (args) => runCodexStructured(codexManager, args),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const result = await generateTitleForChatDetailed(LIVE_MESSAGE, process.cwd(), adapter)
|
|
37
|
+
|
|
38
|
+
expect(result.usedFallback).toBe(false)
|
|
39
|
+
expect(result.failureMessage).toBeNull()
|
|
40
|
+
expect(typeof result.title).toBe("string")
|
|
41
|
+
expect(result.title).not.toBe(fallbackTitleFromMessage(LIVE_MESSAGE))
|
|
42
|
+
}, 15_000)
|
|
43
|
+
})
|
|
44
|
+
}
|