kanna-code 0.30.0 → 0.32.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/README.md +13 -0
- package/dist/client/assets/index-BxEBJJ_f.css +32 -0
- package/dist/client/assets/index-CoeDwJZ0.js +2593 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/server/auth.test.ts +207 -0
- package/src/server/auth.ts +304 -0
- package/src/server/cli-runtime.test.ts +28 -0
- package/src/server/cli-runtime.ts +11 -0
- package/src/server/event-store.test.ts +18 -0
- package/src/server/event-store.ts +32 -0
- package/src/server/events.ts +8 -0
- package/src/server/generate-commit-message.test.ts +20 -0
- package/src/server/generate-commit-message.ts +1 -1
- package/src/server/generate-title.ts +1 -1
- package/src/server/llm-provider.test.ts +134 -0
- package/src/server/llm-provider.ts +149 -0
- package/src/server/quick-response.test.ts +202 -0
- package/src/server/quick-response.ts +56 -2
- package/src/server/read-models.test.ts +78 -1
- package/src/server/read-models.ts +54 -4
- package/src/server/server.ts +47 -0
- package/src/server/ws-router.test.ts +274 -10
- package/src/server/ws-router.ts +111 -21
- package/src/shared/branding.ts +8 -0
- package/src/shared/protocol.ts +11 -0
- package/src/shared/types.ts +24 -0
- package/dist/client/assets/index-Bnur1EEv.css +0 -32
- package/dist/client/assets/index-DNENqajG.js +0 -2593
|
@@ -3,8 +3,56 @@ import { fallbackTitleFromMessage, generateTitleForChat, generateTitleForChatDet
|
|
|
3
3
|
import { getQuickResponseWorkspace, QuickResponseAdapter } from "./quick-response"
|
|
4
4
|
|
|
5
5
|
describe("QuickResponseAdapter", () => {
|
|
6
|
+
test("returns the SDK structured result when configured and it validates", async () => {
|
|
7
|
+
const adapter = new QuickResponseAdapter({
|
|
8
|
+
readLlmProvider: async () => ({
|
|
9
|
+
provider: "openai",
|
|
10
|
+
apiKey: "test-key",
|
|
11
|
+
model: "gpt-5-mini",
|
|
12
|
+
baseUrl: "",
|
|
13
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
14
|
+
enabled: true,
|
|
15
|
+
warning: null,
|
|
16
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
17
|
+
}),
|
|
18
|
+
runOpenAIStructured: async () => ({ title: "SDK title" }),
|
|
19
|
+
runClaudeStructured: async () => ({ title: "Claude title" }),
|
|
20
|
+
runCodexStructured: async () => ({ title: "Codex title" }),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const result = await adapter.generateStructured({
|
|
24
|
+
cwd: "/tmp/project",
|
|
25
|
+
task: "title generation",
|
|
26
|
+
prompt: "Generate a title",
|
|
27
|
+
schema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
title: { type: "string" },
|
|
31
|
+
},
|
|
32
|
+
required: ["title"],
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
},
|
|
35
|
+
parse: (value) => {
|
|
36
|
+
const output = value && typeof value === "object" ? value as { title?: unknown } : {}
|
|
37
|
+
return typeof output.title === "string" ? output.title : null
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(result).toBe("SDK title")
|
|
42
|
+
})
|
|
43
|
+
|
|
6
44
|
test("returns the Claude structured result when it validates", async () => {
|
|
7
45
|
const adapter = new QuickResponseAdapter({
|
|
46
|
+
readLlmProvider: async () => ({
|
|
47
|
+
provider: "openai",
|
|
48
|
+
apiKey: "",
|
|
49
|
+
model: "",
|
|
50
|
+
baseUrl: "",
|
|
51
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
52
|
+
enabled: false,
|
|
53
|
+
warning: null,
|
|
54
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
55
|
+
}),
|
|
8
56
|
runClaudeStructured: async () => ({ title: "Claude title" }),
|
|
9
57
|
runCodexStructured: async () => ({ title: "Codex title" }),
|
|
10
58
|
})
|
|
@@ -32,6 +80,16 @@ describe("QuickResponseAdapter", () => {
|
|
|
32
80
|
|
|
33
81
|
test("falls back to Codex when Claude fails validation", async () => {
|
|
34
82
|
const adapter = new QuickResponseAdapter({
|
|
83
|
+
readLlmProvider: async () => ({
|
|
84
|
+
provider: "openai",
|
|
85
|
+
apiKey: "",
|
|
86
|
+
model: "",
|
|
87
|
+
baseUrl: "",
|
|
88
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
89
|
+
enabled: false,
|
|
90
|
+
warning: null,
|
|
91
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
92
|
+
}),
|
|
35
93
|
runClaudeStructured: async () => ({ bad: true }),
|
|
36
94
|
runCodexStructured: async () => ({ title: "Codex title" }),
|
|
37
95
|
})
|
|
@@ -59,6 +117,16 @@ describe("QuickResponseAdapter", () => {
|
|
|
59
117
|
|
|
60
118
|
test("falls back to Codex when Claude throws", async () => {
|
|
61
119
|
const adapter = new QuickResponseAdapter({
|
|
120
|
+
readLlmProvider: async () => ({
|
|
121
|
+
provider: "openai",
|
|
122
|
+
apiKey: "",
|
|
123
|
+
model: "",
|
|
124
|
+
baseUrl: "",
|
|
125
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
126
|
+
enabled: false,
|
|
127
|
+
warning: null,
|
|
128
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
129
|
+
}),
|
|
62
130
|
runClaudeStructured: async () => {
|
|
63
131
|
throw new Error("Not authenticated")
|
|
64
132
|
},
|
|
@@ -93,6 +161,16 @@ describe("QuickResponseAdapter", () => {
|
|
|
93
161
|
try {
|
|
94
162
|
let claudeCwd = ""
|
|
95
163
|
const adapter = new QuickResponseAdapter({
|
|
164
|
+
readLlmProvider: async () => ({
|
|
165
|
+
provider: "openai",
|
|
166
|
+
apiKey: "",
|
|
167
|
+
model: "",
|
|
168
|
+
baseUrl: "",
|
|
169
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
170
|
+
enabled: false,
|
|
171
|
+
warning: null,
|
|
172
|
+
filePathDisplay: "~/.kanna-dev/llm-provider.json",
|
|
173
|
+
}),
|
|
96
174
|
runClaudeStructured: async (args) => {
|
|
97
175
|
claudeCwd = args.cwd
|
|
98
176
|
return { title: "Claude title" }
|
|
@@ -131,6 +209,16 @@ describe("QuickResponseAdapter", () => {
|
|
|
131
209
|
test("uses gpt-5.4-mini for Codex title generation fallback", async () => {
|
|
132
210
|
const requests: Array<{ cwd: string; prompt: string; model?: string }> = []
|
|
133
211
|
const adapter = new QuickResponseAdapter({
|
|
212
|
+
readLlmProvider: async () => ({
|
|
213
|
+
provider: "openai",
|
|
214
|
+
apiKey: "",
|
|
215
|
+
model: "",
|
|
216
|
+
baseUrl: "",
|
|
217
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
218
|
+
enabled: false,
|
|
219
|
+
warning: null,
|
|
220
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
221
|
+
}),
|
|
134
222
|
codexManager: {
|
|
135
223
|
async generateStructured(args: { cwd: string; prompt: string; model?: string }) {
|
|
136
224
|
requests.push(args)
|
|
@@ -162,6 +250,48 @@ describe("QuickResponseAdapter", () => {
|
|
|
162
250
|
expect(requests).toHaveLength(1)
|
|
163
251
|
expect(requests[0]?.model).toBe("gpt-5.4-mini")
|
|
164
252
|
})
|
|
253
|
+
|
|
254
|
+
test("falls through to Claude when the SDK is not configured", async () => {
|
|
255
|
+
let openAICalls = 0
|
|
256
|
+
const adapter = new QuickResponseAdapter({
|
|
257
|
+
readLlmProvider: async () => ({
|
|
258
|
+
provider: "openai",
|
|
259
|
+
apiKey: "",
|
|
260
|
+
model: "",
|
|
261
|
+
baseUrl: "",
|
|
262
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
263
|
+
enabled: false,
|
|
264
|
+
warning: null,
|
|
265
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
266
|
+
}),
|
|
267
|
+
runOpenAIStructured: async () => {
|
|
268
|
+
openAICalls += 1
|
|
269
|
+
return { title: "SDK title" }
|
|
270
|
+
},
|
|
271
|
+
runClaudeStructured: async () => ({ title: "Claude title" }),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const result = await adapter.generateStructured({
|
|
275
|
+
cwd: "/tmp/project",
|
|
276
|
+
task: "title generation",
|
|
277
|
+
prompt: "Generate a title",
|
|
278
|
+
schema: {
|
|
279
|
+
type: "object",
|
|
280
|
+
properties: {
|
|
281
|
+
title: { type: "string" },
|
|
282
|
+
},
|
|
283
|
+
required: ["title"],
|
|
284
|
+
additionalProperties: false,
|
|
285
|
+
},
|
|
286
|
+
parse: (value) => {
|
|
287
|
+
const output = value && typeof value === "object" ? value as { title?: unknown } : {}
|
|
288
|
+
return typeof output.title === "string" ? output.title : null
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
expect(result).toBe("Claude title")
|
|
293
|
+
expect(openAICalls).toBe(0)
|
|
294
|
+
})
|
|
165
295
|
})
|
|
166
296
|
|
|
167
297
|
describe("generateTitleForChat", () => {
|
|
@@ -170,6 +300,16 @@ describe("generateTitleForChat", () => {
|
|
|
170
300
|
"hello",
|
|
171
301
|
"/tmp/project",
|
|
172
302
|
new QuickResponseAdapter({
|
|
303
|
+
readLlmProvider: async () => ({
|
|
304
|
+
provider: "openai",
|
|
305
|
+
apiKey: "",
|
|
306
|
+
model: "",
|
|
307
|
+
baseUrl: "",
|
|
308
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
309
|
+
enabled: false,
|
|
310
|
+
warning: null,
|
|
311
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
312
|
+
}),
|
|
173
313
|
runClaudeStructured: async () => ({ title: " Example\nTitle " }),
|
|
174
314
|
})
|
|
175
315
|
)
|
|
@@ -182,6 +322,16 @@ describe("generateTitleForChat", () => {
|
|
|
182
322
|
"hello",
|
|
183
323
|
"/tmp/project",
|
|
184
324
|
new QuickResponseAdapter({
|
|
325
|
+
readLlmProvider: async () => ({
|
|
326
|
+
provider: "openai",
|
|
327
|
+
apiKey: "",
|
|
328
|
+
model: "",
|
|
329
|
+
baseUrl: "",
|
|
330
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
331
|
+
enabled: false,
|
|
332
|
+
warning: null,
|
|
333
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
334
|
+
}),
|
|
185
335
|
runClaudeStructured: async () => ({ title: " " }),
|
|
186
336
|
runCodexStructured: async () => ({ title: "New Chat" }),
|
|
187
337
|
})
|
|
@@ -195,6 +345,16 @@ describe("generateTitleForChat", () => {
|
|
|
195
345
|
"This message is definitely longer than thirty five characters",
|
|
196
346
|
"/tmp/project",
|
|
197
347
|
new QuickResponseAdapter({
|
|
348
|
+
readLlmProvider: async () => ({
|
|
349
|
+
provider: "openai",
|
|
350
|
+
apiKey: "",
|
|
351
|
+
model: "",
|
|
352
|
+
baseUrl: "",
|
|
353
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
354
|
+
enabled: false,
|
|
355
|
+
warning: null,
|
|
356
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
357
|
+
}),
|
|
198
358
|
runClaudeStructured: async () => {
|
|
199
359
|
throw new Error("Not authenticated")
|
|
200
360
|
},
|
|
@@ -210,6 +370,16 @@ describe("generateTitleForChat", () => {
|
|
|
210
370
|
"hello there",
|
|
211
371
|
"/tmp/project",
|
|
212
372
|
new QuickResponseAdapter({
|
|
373
|
+
readLlmProvider: async () => ({
|
|
374
|
+
provider: "openai",
|
|
375
|
+
apiKey: "",
|
|
376
|
+
model: "",
|
|
377
|
+
baseUrl: "",
|
|
378
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
379
|
+
enabled: false,
|
|
380
|
+
warning: null,
|
|
381
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
382
|
+
}),
|
|
213
383
|
runClaudeStructured: async () => {
|
|
214
384
|
throw new Error("Not authenticated")
|
|
215
385
|
},
|
|
@@ -225,6 +395,38 @@ describe("generateTitleForChat", () => {
|
|
|
225
395
|
failureMessage: "claude failed conversation title generation: Not authenticated; codex failed conversation title generation: Codex unavailable",
|
|
226
396
|
})
|
|
227
397
|
})
|
|
398
|
+
|
|
399
|
+
test("includes SDK failure details before Claude and Codex", async () => {
|
|
400
|
+
const result = await generateTitleForChatDetailed(
|
|
401
|
+
"hello there",
|
|
402
|
+
"/tmp/project",
|
|
403
|
+
new QuickResponseAdapter({
|
|
404
|
+
readLlmProvider: async () => ({
|
|
405
|
+
provider: "openai",
|
|
406
|
+
apiKey: "test-key",
|
|
407
|
+
model: "gpt-5-mini",
|
|
408
|
+
baseUrl: "",
|
|
409
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
410
|
+
enabled: true,
|
|
411
|
+
warning: null,
|
|
412
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
413
|
+
}),
|
|
414
|
+
runOpenAIStructured: async () => {
|
|
415
|
+
throw new Error("SDK unavailable")
|
|
416
|
+
},
|
|
417
|
+
runClaudeStructured: async () => {
|
|
418
|
+
throw new Error("Not authenticated")
|
|
419
|
+
},
|
|
420
|
+
runCodexStructured: async () => {
|
|
421
|
+
throw new Error("Codex unavailable")
|
|
422
|
+
},
|
|
423
|
+
})
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
expect(result.failureMessage).toBe(
|
|
427
|
+
"openai failed conversation title generation: SDK unavailable; claude failed conversation title generation: Not authenticated; codex failed conversation title generation: Codex unavailable"
|
|
428
|
+
)
|
|
429
|
+
})
|
|
228
430
|
})
|
|
229
431
|
|
|
230
432
|
describe("fallbackTitleFromMessage", () => {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
|
+
import OpenAI from "openai"
|
|
3
4
|
import { getDataRootDir } from "../shared/branding"
|
|
5
|
+
import type { LlmProviderSnapshot } from "../shared/types"
|
|
4
6
|
import { CodexAppServerManager } from "./codex-app-server"
|
|
7
|
+
import { readLlmProviderSnapshot } from "./llm-provider"
|
|
5
8
|
|
|
6
9
|
const CLAUDE_STRUCTURED_TIMEOUT_MS = 5_000
|
|
7
10
|
|
|
@@ -22,12 +25,17 @@ export interface StructuredQuickResponseArgs<T> {
|
|
|
22
25
|
|
|
23
26
|
interface QuickResponseAdapterArgs {
|
|
24
27
|
codexManager?: CodexAppServerManager
|
|
28
|
+
readLlmProvider?: () => Promise<LlmProviderSnapshot>
|
|
29
|
+
runOpenAIStructured?: (
|
|
30
|
+
config: LlmProviderSnapshot,
|
|
31
|
+
args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
|
|
32
|
+
) => Promise<unknown | null>
|
|
25
33
|
runClaudeStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
|
|
26
34
|
runCodexStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
export interface StructuredQuickResponseFailure {
|
|
30
|
-
provider: "claude" | "codex"
|
|
38
|
+
provider: "openai" | "claude" | "codex"
|
|
31
39
|
reason: string
|
|
32
40
|
}
|
|
33
41
|
|
|
@@ -133,6 +141,31 @@ export async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs
|
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
143
|
|
|
144
|
+
export async function runOpenAIStructured(
|
|
145
|
+
config: LlmProviderSnapshot,
|
|
146
|
+
args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
|
|
147
|
+
): Promise<unknown | null> {
|
|
148
|
+
const client = new OpenAI({
|
|
149
|
+
apiKey: config.apiKey,
|
|
150
|
+
baseURL: config.resolvedBaseUrl,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const response = await client.responses.create({
|
|
154
|
+
model: config.model,
|
|
155
|
+
input: args.prompt,
|
|
156
|
+
text: {
|
|
157
|
+
format: {
|
|
158
|
+
type: "json_schema",
|
|
159
|
+
name: "quick_response",
|
|
160
|
+
schema: args.schema,
|
|
161
|
+
strict: true,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
return parseJsonText(response.output_text)
|
|
167
|
+
}
|
|
168
|
+
|
|
136
169
|
export async function runCodexStructured(
|
|
137
170
|
codexManager: CodexAppServerManager,
|
|
138
171
|
args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
|
|
@@ -148,11 +181,18 @@ export async function runCodexStructured(
|
|
|
148
181
|
|
|
149
182
|
export class QuickResponseAdapter {
|
|
150
183
|
private readonly codexManager: CodexAppServerManager
|
|
184
|
+
private readonly readLlmProvider: () => Promise<LlmProviderSnapshot>
|
|
185
|
+
private readonly runOpenAIStructured: (
|
|
186
|
+
config: LlmProviderSnapshot,
|
|
187
|
+
args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
|
|
188
|
+
) => Promise<unknown | null>
|
|
151
189
|
private readonly runClaudeStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
|
|
152
190
|
private readonly runCodexStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
|
|
153
191
|
|
|
154
192
|
constructor(args: QuickResponseAdapterArgs = {}) {
|
|
155
193
|
this.codexManager = args.codexManager ?? new CodexAppServerManager()
|
|
194
|
+
this.readLlmProvider = args.readLlmProvider ?? (() => readLlmProviderSnapshot())
|
|
195
|
+
this.runOpenAIStructured = args.runOpenAIStructured ?? runOpenAIStructured
|
|
156
196
|
this.runClaudeStructured = args.runClaudeStructured ?? runClaudeStructured
|
|
157
197
|
this.runCodexStructured = args.runCodexStructured ?? ((structuredArgs) =>
|
|
158
198
|
runCodexStructured(this.codexManager, structuredArgs))
|
|
@@ -171,6 +211,20 @@ export class QuickResponseAdapter {
|
|
|
171
211
|
}
|
|
172
212
|
|
|
173
213
|
const failures: StructuredQuickResponseFailure[] = []
|
|
214
|
+
const llmProvider = await this.readLlmProvider()
|
|
215
|
+
if (llmProvider.enabled) {
|
|
216
|
+
const openAIResult = await this.tryProvider("openai", args.task, args.parse, () => this.runOpenAIStructured(llmProvider, request))
|
|
217
|
+
if (openAIResult.value !== null) {
|
|
218
|
+
return {
|
|
219
|
+
value: openAIResult.value,
|
|
220
|
+
failures,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (openAIResult.failure) {
|
|
224
|
+
failures.push(openAIResult.failure)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
174
228
|
const claudeResult = await this.tryProvider("claude", args.task, args.parse, () => this.runClaudeStructured(request))
|
|
175
229
|
if (claudeResult.value !== null) {
|
|
176
230
|
return {
|
|
@@ -200,7 +254,7 @@ export class QuickResponseAdapter {
|
|
|
200
254
|
}
|
|
201
255
|
|
|
202
256
|
private async tryProvider<T>(
|
|
203
|
-
provider: "claude" | "codex",
|
|
257
|
+
provider: "openai" | "claude" | "codex",
|
|
204
258
|
task: string,
|
|
205
259
|
parse: (value: unknown) => T | null,
|
|
206
260
|
run: () => Promise<unknown | null>
|
|
@@ -26,9 +26,12 @@ describe("read models", () => {
|
|
|
26
26
|
lastTurnOutcome: null,
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
-
const sidebar = deriveSidebarData(state, new Map())
|
|
29
|
+
const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
|
|
30
30
|
expect(sidebar.projectGroups[0]?.chats[0]?.provider).toBe("codex")
|
|
31
31
|
expect(sidebar.projectGroups[0]?.chats[0]?.unread).toBe(true)
|
|
32
|
+
expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
|
|
33
|
+
expect(sidebar.projectGroups[0]?.olderChats).toEqual([])
|
|
34
|
+
expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
|
|
32
35
|
})
|
|
33
36
|
|
|
34
37
|
test("includes available providers in chat snapshots", () => {
|
|
@@ -171,4 +174,78 @@ describe("read models", () => {
|
|
|
171
174
|
const sidebar = deriveSidebarData(state, new Map())
|
|
172
175
|
expect(sidebar.projectGroups[0]?.chats.map((chat) => chat.chatId)).toEqual(["chat-new", "chat-old"])
|
|
173
176
|
})
|
|
177
|
+
|
|
178
|
+
test("honors persisted project order before fallback updated-at ordering", () => {
|
|
179
|
+
const state = createEmptyState()
|
|
180
|
+
state.projectsById.set("project-1", {
|
|
181
|
+
id: "project-1",
|
|
182
|
+
localPath: "/tmp/project-1",
|
|
183
|
+
title: "One",
|
|
184
|
+
createdAt: 1,
|
|
185
|
+
updatedAt: 10,
|
|
186
|
+
})
|
|
187
|
+
state.projectsById.set("project-2", {
|
|
188
|
+
id: "project-2",
|
|
189
|
+
localPath: "/tmp/project-2",
|
|
190
|
+
title: "Two",
|
|
191
|
+
createdAt: 2,
|
|
192
|
+
updatedAt: 20,
|
|
193
|
+
})
|
|
194
|
+
state.projectsById.set("project-3", {
|
|
195
|
+
id: "project-3",
|
|
196
|
+
localPath: "/tmp/project-3",
|
|
197
|
+
title: "Three",
|
|
198
|
+
createdAt: 3,
|
|
199
|
+
updatedAt: 15,
|
|
200
|
+
})
|
|
201
|
+
state.sidebarProjectOrder = ["project-1"]
|
|
202
|
+
|
|
203
|
+
const sidebar = deriveSidebarData(state, new Map())
|
|
204
|
+
|
|
205
|
+
expect(sidebar.projectGroups.map((group) => group.groupKey)).toEqual(["project-1", "project-2", "project-3"])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test("builds preview and older chat slices using the current sidebar rules", () => {
|
|
209
|
+
const state = createEmptyState()
|
|
210
|
+
state.projectsById.set("project-1", {
|
|
211
|
+
id: "project-1",
|
|
212
|
+
localPath: "/tmp/project",
|
|
213
|
+
title: "Project",
|
|
214
|
+
createdAt: 1,
|
|
215
|
+
updatedAt: 1,
|
|
216
|
+
})
|
|
217
|
+
state.projectIdsByPath.set("/tmp/project", "project-1")
|
|
218
|
+
state.chatsById.set("chat-1", {
|
|
219
|
+
id: "chat-1",
|
|
220
|
+
projectId: "project-1",
|
|
221
|
+
title: "Recent",
|
|
222
|
+
createdAt: 10,
|
|
223
|
+
updatedAt: 10,
|
|
224
|
+
unread: false,
|
|
225
|
+
provider: "claude",
|
|
226
|
+
planMode: false,
|
|
227
|
+
sessionToken: null,
|
|
228
|
+
lastMessageAt: 1_000_000 - 60 * 60 * 1_000,
|
|
229
|
+
lastTurnOutcome: null,
|
|
230
|
+
})
|
|
231
|
+
state.chatsById.set("chat-2", {
|
|
232
|
+
id: "chat-2",
|
|
233
|
+
projectId: "project-1",
|
|
234
|
+
title: "Older",
|
|
235
|
+
createdAt: 20,
|
|
236
|
+
updatedAt: 20,
|
|
237
|
+
unread: false,
|
|
238
|
+
provider: "claude",
|
|
239
|
+
planMode: false,
|
|
240
|
+
sessionToken: null,
|
|
241
|
+
lastMessageAt: 1_000_000 - 26 * 60 * 60 * 1_000,
|
|
242
|
+
lastTurnOutcome: null,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const sidebar = deriveSidebarData(state, new Map(), 1_000_000)
|
|
246
|
+
|
|
247
|
+
expect(sidebar.projectGroups[0]?.previewChats.map((chat) => chat.chatId)).toEqual(["chat-1"])
|
|
248
|
+
expect(sidebar.projectGroups[0]?.olderChats.map((chat) => chat.chatId)).toEqual(["chat-2"])
|
|
249
|
+
expect(sidebar.projectGroups[0]?.defaultCollapsed).toBe(false)
|
|
250
|
+
})
|
|
174
251
|
})
|
|
@@ -11,6 +11,10 @@ import type { ChatRecord, StoreState } from "./events"
|
|
|
11
11
|
import { resolveLocalPath } from "./paths"
|
|
12
12
|
import { SERVER_PROVIDERS } from "./provider-catalog"
|
|
13
13
|
|
|
14
|
+
const SIDEBAR_RECENT_WINDOW_MS = 24 * 60 * 60 * 1_000
|
|
15
|
+
const SIDEBAR_RECENT_PREVIEW_LIMIT = 10
|
|
16
|
+
const SIDEBAR_FALLBACK_PREVIEW_LIMIT = 5
|
|
17
|
+
|
|
14
18
|
export function deriveStatus(chat: ChatRecord, activeStatus?: KannaStatus): KannaStatus {
|
|
15
19
|
if (activeStatus) return activeStatus
|
|
16
20
|
if (chat.lastTurnOutcome === "failed") return "failed"
|
|
@@ -21,17 +25,59 @@ function getSidebarChatSortTimestamp(chat: ChatRecord) {
|
|
|
21
25
|
return chat.lastMessageAt ?? chat.createdAt
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
function getSidebarChatTimestamp(chat: Pick<SidebarChatRow, "lastMessageAt" | "_creationTime">) {
|
|
29
|
+
return chat.lastMessageAt ?? chat._creationTime
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isSidebarChatRecent(chat: Pick<SidebarChatRow, "lastMessageAt" | "_creationTime">, nowMs: number) {
|
|
33
|
+
return Math.max(0, nowMs - getSidebarChatTimestamp(chat)) < SIDEBAR_RECENT_WINDOW_MS
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getSidebarChatBuckets(chats: SidebarChatRow[], nowMs: number) {
|
|
37
|
+
const recentChats = chats.filter((chat) => isSidebarChatRecent(chat, nowMs))
|
|
38
|
+
const previewChats = recentChats.length > 0
|
|
39
|
+
? recentChats.slice(0, SIDEBAR_RECENT_PREVIEW_LIMIT)
|
|
40
|
+
: chats.slice(0, Math.min(SIDEBAR_FALLBACK_PREVIEW_LIMIT, chats.length))
|
|
41
|
+
const previewChatIds = new Set(previewChats.map((chat) => chat.chatId))
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
previewChats,
|
|
45
|
+
olderChats: chats.filter((chat) => !previewChatIds.has(chat.chatId)),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
export function deriveSidebarData(
|
|
25
50
|
state: StoreState,
|
|
26
|
-
activeStatuses: Map<string, KannaStatus
|
|
51
|
+
activeStatuses: Map<string, KannaStatus>,
|
|
52
|
+
nowMs = Date.now()
|
|
27
53
|
): SidebarData {
|
|
28
|
-
const
|
|
54
|
+
const chatsByProjectId = new Map<string, ChatRecord[]>()
|
|
55
|
+
for (const chat of state.chatsById.values()) {
|
|
56
|
+
if (chat.deletedAt) continue
|
|
57
|
+
const projectChats = chatsByProjectId.get(chat.projectId)
|
|
58
|
+
if (projectChats) {
|
|
59
|
+
projectChats.push(chat)
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
chatsByProjectId.set(chat.projectId, [chat])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const allProjects = [...state.projectsById.values()]
|
|
29
66
|
.filter((project) => !project.deletedAt)
|
|
67
|
+
const unorderedProjects = allProjects
|
|
30
68
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
69
|
+
const projectById = new Map(unorderedProjects.map((project) => [project.id, project]))
|
|
70
|
+
const orderedProjects = state.sidebarProjectOrder
|
|
71
|
+
.map((projectId) => projectById.get(projectId))
|
|
72
|
+
.filter((project): project is NonNullable<typeof project> => Boolean(project))
|
|
73
|
+
const orderedProjectIds = new Set(orderedProjects.map((project) => project.id))
|
|
74
|
+
const projects = [
|
|
75
|
+
...orderedProjects,
|
|
76
|
+
...unorderedProjects.filter((project) => !orderedProjectIds.has(project.id)),
|
|
77
|
+
]
|
|
31
78
|
|
|
32
79
|
const projectGroups: SidebarProjectGroup[] = projects.map((project) => {
|
|
33
|
-
const chats
|
|
34
|
-
.filter((chat) => chat.projectId === project.id && !chat.deletedAt)
|
|
80
|
+
const chats = (chatsByProjectId.get(project.id) ?? [])
|
|
35
81
|
.sort((a, b) => getSidebarChatSortTimestamp(b) - getSidebarChatSortTimestamp(a))
|
|
36
82
|
.map((chat) => ({
|
|
37
83
|
_id: chat.id,
|
|
@@ -45,11 +91,15 @@ export function deriveSidebarData(
|
|
|
45
91
|
lastMessageAt: chat.lastMessageAt,
|
|
46
92
|
hasAutomation: false,
|
|
47
93
|
}))
|
|
94
|
+
const { previewChats, olderChats } = getSidebarChatBuckets(chats, nowMs)
|
|
48
95
|
|
|
49
96
|
return {
|
|
50
97
|
groupKey: project.id,
|
|
51
98
|
localPath: project.localPath,
|
|
52
99
|
chats,
|
|
100
|
+
previewChats,
|
|
101
|
+
olderChats,
|
|
102
|
+
defaultCollapsed: chats.every((chat) => !isSidebarChatRecent(chat, nowMs)),
|
|
53
103
|
}
|
|
54
104
|
})
|
|
55
105
|
|
package/src/server/server.ts
CHANGED
|
@@ -2,11 +2,13 @@ import path from "node:path"
|
|
|
2
2
|
import { stat } from "node:fs/promises"
|
|
3
3
|
import { APP_NAME, getRuntimeProfile } from "../shared/branding"
|
|
4
4
|
import type { ChatAttachment } from "../shared/types"
|
|
5
|
+
import { createAuthManager } from "./auth"
|
|
5
6
|
import { EventStore } from "./event-store"
|
|
6
7
|
import { AgentCoordinator } from "./agent"
|
|
7
8
|
import { DiffStore } from "./diff-store"
|
|
8
9
|
import { discoverProjects, type DiscoveredProject } from "./discovery"
|
|
9
10
|
import { KeybindingsManager } from "./keybindings"
|
|
11
|
+
import { readLlmProviderSnapshot, writeLlmProviderSnapshot } from "./llm-provider"
|
|
10
12
|
import { getMachineDisplayName } from "./machine-name"
|
|
11
13
|
import { TerminalManager } from "./terminal-manager"
|
|
12
14
|
import { UpdateManager } from "./update-manager"
|
|
@@ -56,6 +58,7 @@ export async function persistUploadedFiles(args: {
|
|
|
56
58
|
export interface StartKannaServerOptions {
|
|
57
59
|
port?: number
|
|
58
60
|
host?: string
|
|
61
|
+
password?: string | null
|
|
59
62
|
strictPort?: boolean
|
|
60
63
|
onMigrationProgress?: (message: string) => void
|
|
61
64
|
update?: {
|
|
@@ -69,6 +72,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
69
72
|
const port = options.port ?? 3210
|
|
70
73
|
const hostname = options.host ?? "127.0.0.1"
|
|
71
74
|
const strictPort = options.strictPort ?? false
|
|
75
|
+
const auth = options.password ? createAuthManager(options.password) : null
|
|
72
76
|
const store = new EventStore()
|
|
73
77
|
const diffStore = new DiffStore(store.dataDir)
|
|
74
78
|
const machineDisplayName = getMachineDisplayName()
|
|
@@ -117,6 +121,10 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
117
121
|
agent,
|
|
118
122
|
terminals,
|
|
119
123
|
keybindings,
|
|
124
|
+
llmProvider: {
|
|
125
|
+
read: readLlmProviderSnapshot,
|
|
126
|
+
write: writeLlmProviderSnapshot,
|
|
127
|
+
},
|
|
120
128
|
refreshDiscovery,
|
|
121
129
|
getDiscoveredProjects: () => discoveredProjects,
|
|
122
130
|
machineDisplayName,
|
|
@@ -140,6 +148,45 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
140
148
|
async fetch(req, serverInstance) {
|
|
141
149
|
const url = new URL(req.url)
|
|
142
150
|
|
|
151
|
+
if (url.pathname === "/auth/status") {
|
|
152
|
+
return auth
|
|
153
|
+
? auth.handleStatus(req)
|
|
154
|
+
: Response.json({ enabled: false, authenticated: true })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (url.pathname === "/auth/logout") {
|
|
158
|
+
if (req.method !== "POST") {
|
|
159
|
+
return new Response(null, { status: 405, headers: { Allow: "POST" } })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return auth
|
|
163
|
+
? auth.handleLogout(req)
|
|
164
|
+
: Response.json({ ok: true })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (auth) {
|
|
168
|
+
if (url.pathname === "/auth/login") {
|
|
169
|
+
if (req.method === "GET") {
|
|
170
|
+
return auth.renderLoginPage(req)
|
|
171
|
+
}
|
|
172
|
+
if (req.method === "POST") {
|
|
173
|
+
return auth.handleLogin(req, "/")
|
|
174
|
+
}
|
|
175
|
+
return new Response(null, { status: 405, headers: { Allow: "GET, POST" } })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (url.pathname === "/ws") {
|
|
179
|
+
if (!auth.validateOrigin(req)) {
|
|
180
|
+
return new Response("Forbidden", { status: 403 })
|
|
181
|
+
}
|
|
182
|
+
if (!auth.isAuthenticated(req)) {
|
|
183
|
+
return new Response("Unauthorized", { status: 401 })
|
|
184
|
+
}
|
|
185
|
+
} else if (!auth.isAuthenticated(req)) {
|
|
186
|
+
return auth.unauthorizedResponse(req)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
143
190
|
if (url.pathname === "/ws") {
|
|
144
191
|
const upgraded = serverInstance.upgrade(req, {
|
|
145
192
|
data: {
|