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
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import type { KeybindingsSnapshot, UpdateSnapshot } from "../shared/types"
|
|
2
|
+
import type { KeybindingsSnapshot, LlmProviderSnapshot, UpdateSnapshot } from "../shared/types"
|
|
3
3
|
import { PROTOCOL_VERSION } from "../shared/types"
|
|
4
4
|
import { createEmptyState } from "./events"
|
|
5
5
|
import { createWsRouter } from "./ws-router"
|
|
6
6
|
|
|
7
|
+
function withSidebarGroupDefaults(group: {
|
|
8
|
+
groupKey: string
|
|
9
|
+
localPath: string
|
|
10
|
+
chats: Array<{
|
|
11
|
+
_id: string
|
|
12
|
+
_creationTime: number
|
|
13
|
+
chatId: string
|
|
14
|
+
title: string
|
|
15
|
+
status: "idle" | "starting" | "running" | "waiting_for_user" | "failed"
|
|
16
|
+
unread: boolean
|
|
17
|
+
localPath: string
|
|
18
|
+
provider: "claude" | "codex" | null
|
|
19
|
+
lastMessageAt?: number
|
|
20
|
+
hasAutomation: boolean
|
|
21
|
+
}>
|
|
22
|
+
}) {
|
|
23
|
+
return {
|
|
24
|
+
...group,
|
|
25
|
+
previewChats: group.chats,
|
|
26
|
+
olderChats: [],
|
|
27
|
+
defaultCollapsed: true,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
class FakeWebSocket {
|
|
8
32
|
readonly sent: unknown[] = []
|
|
9
33
|
readonly data = {
|
|
@@ -42,6 +66,17 @@ const DEFAULT_UPDATE_SNAPSHOT: UpdateSnapshot = {
|
|
|
42
66
|
reloadRequestedAt: null,
|
|
43
67
|
}
|
|
44
68
|
|
|
69
|
+
const DEFAULT_LLM_PROVIDER_SNAPSHOT: LlmProviderSnapshot = {
|
|
70
|
+
provider: "openai",
|
|
71
|
+
apiKey: "",
|
|
72
|
+
model: "",
|
|
73
|
+
baseUrl: "",
|
|
74
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
75
|
+
enabled: false,
|
|
76
|
+
warning: null,
|
|
77
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
78
|
+
}
|
|
79
|
+
|
|
45
80
|
describe("ws-router", () => {
|
|
46
81
|
test("acks system.ping without broadcasting snapshots", async () => {
|
|
47
82
|
const router = createWsRouter({
|
|
@@ -83,6 +118,94 @@ describe("ws-router", () => {
|
|
|
83
118
|
])
|
|
84
119
|
})
|
|
85
120
|
|
|
121
|
+
test("reads and writes llm provider settings via commands", async () => {
|
|
122
|
+
const writes: Array<Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">> = []
|
|
123
|
+
const router = createWsRouter({
|
|
124
|
+
store: { state: createEmptyState() } as never,
|
|
125
|
+
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
126
|
+
terminals: {
|
|
127
|
+
getSnapshot: () => null,
|
|
128
|
+
onEvent: () => () => {},
|
|
129
|
+
} as never,
|
|
130
|
+
keybindings: {
|
|
131
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
132
|
+
onChange: () => () => {},
|
|
133
|
+
} as never,
|
|
134
|
+
llmProvider: {
|
|
135
|
+
read: async () => DEFAULT_LLM_PROVIDER_SNAPSHOT,
|
|
136
|
+
write: async (value) => {
|
|
137
|
+
writes.push(value)
|
|
138
|
+
return {
|
|
139
|
+
...DEFAULT_LLM_PROVIDER_SNAPSHOT,
|
|
140
|
+
...value,
|
|
141
|
+
resolvedBaseUrl: value.provider === "custom" ? value.baseUrl : "https://api.openai.com/v1",
|
|
142
|
+
enabled: Boolean(value.apiKey && value.model),
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
refreshDiscovery: async () => [],
|
|
147
|
+
getDiscoveredProjects: () => [],
|
|
148
|
+
machineDisplayName: "Local Machine",
|
|
149
|
+
updateManager: null,
|
|
150
|
+
})
|
|
151
|
+
const ws = new FakeWebSocket()
|
|
152
|
+
|
|
153
|
+
await router.handleMessage(
|
|
154
|
+
ws as never,
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
v: 1,
|
|
157
|
+
type: "command",
|
|
158
|
+
id: "llm-read-1",
|
|
159
|
+
command: { type: "settings.readLlmProvider" },
|
|
160
|
+
})
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
await router.handleMessage(
|
|
164
|
+
ws as never,
|
|
165
|
+
JSON.stringify({
|
|
166
|
+
v: 1,
|
|
167
|
+
type: "command",
|
|
168
|
+
id: "llm-write-1",
|
|
169
|
+
command: {
|
|
170
|
+
type: "settings.writeLlmProvider",
|
|
171
|
+
provider: "custom",
|
|
172
|
+
apiKey: "test-key",
|
|
173
|
+
model: "gpt-test",
|
|
174
|
+
baseUrl: "https://example.com/v1",
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(ws.sent).toEqual([
|
|
180
|
+
{
|
|
181
|
+
v: PROTOCOL_VERSION,
|
|
182
|
+
type: "ack",
|
|
183
|
+
id: "llm-read-1",
|
|
184
|
+
result: DEFAULT_LLM_PROVIDER_SNAPSHOT,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
v: PROTOCOL_VERSION,
|
|
188
|
+
type: "ack",
|
|
189
|
+
id: "llm-write-1",
|
|
190
|
+
result: {
|
|
191
|
+
...DEFAULT_LLM_PROVIDER_SNAPSHOT,
|
|
192
|
+
provider: "custom",
|
|
193
|
+
apiKey: "test-key",
|
|
194
|
+
model: "gpt-test",
|
|
195
|
+
baseUrl: "https://example.com/v1",
|
|
196
|
+
resolvedBaseUrl: "https://example.com/v1",
|
|
197
|
+
enabled: true,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
])
|
|
201
|
+
expect(writes).toEqual([{
|
|
202
|
+
provider: "custom",
|
|
203
|
+
apiKey: "test-key",
|
|
204
|
+
model: "gpt-test",
|
|
205
|
+
baseUrl: "https://example.com/v1",
|
|
206
|
+
}])
|
|
207
|
+
})
|
|
208
|
+
|
|
86
209
|
test("acks terminal.input without rebroadcasting terminal snapshots", async () => {
|
|
87
210
|
const router = createWsRouter({
|
|
88
211
|
store: { state: createEmptyState() } as never,
|
|
@@ -183,6 +306,54 @@ describe("ws-router", () => {
|
|
|
183
306
|
})
|
|
184
307
|
})
|
|
185
308
|
|
|
309
|
+
test("reuses one sidebar derivation across sockets in the same broadcast pass", async () => {
|
|
310
|
+
const state = createEmptyState()
|
|
311
|
+
state.projectsById.set("project-1", {
|
|
312
|
+
id: "project-1",
|
|
313
|
+
localPath: "/tmp/project",
|
|
314
|
+
title: "Project",
|
|
315
|
+
createdAt: 1,
|
|
316
|
+
updatedAt: 1,
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
let activeStatusCalls = 0
|
|
320
|
+
const router = createWsRouter({
|
|
321
|
+
store: { state } as never,
|
|
322
|
+
agent: {
|
|
323
|
+
getActiveStatuses: () => {
|
|
324
|
+
activeStatusCalls += 1
|
|
325
|
+
return new Map()
|
|
326
|
+
},
|
|
327
|
+
getDrainingChatIds: () => new Set(),
|
|
328
|
+
} as never,
|
|
329
|
+
terminals: {
|
|
330
|
+
getSnapshot: () => null,
|
|
331
|
+
onEvent: () => () => {},
|
|
332
|
+
} as never,
|
|
333
|
+
keybindings: {
|
|
334
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
335
|
+
onChange: () => () => {},
|
|
336
|
+
} as never,
|
|
337
|
+
refreshDiscovery: async () => [],
|
|
338
|
+
getDiscoveredProjects: () => [],
|
|
339
|
+
machineDisplayName: "Local Machine",
|
|
340
|
+
updateManager: null,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const wsA = new FakeWebSocket()
|
|
344
|
+
const wsB = new FakeWebSocket()
|
|
345
|
+
router.handleOpen(wsA as never)
|
|
346
|
+
router.handleOpen(wsB as never)
|
|
347
|
+
wsA.data.subscriptions.set("sidebar-a", { type: "sidebar" })
|
|
348
|
+
wsB.data.subscriptions.set("sidebar-b", { type: "sidebar" })
|
|
349
|
+
|
|
350
|
+
await router.broadcastSnapshots()
|
|
351
|
+
|
|
352
|
+
expect(activeStatusCalls).toBe(1)
|
|
353
|
+
expect(wsA.sent).toHaveLength(1)
|
|
354
|
+
expect(wsB.sent).toHaveLength(1)
|
|
355
|
+
})
|
|
356
|
+
|
|
186
357
|
test("subscribes to project git snapshots independently from chat snapshots", async () => {
|
|
187
358
|
const state = createEmptyState()
|
|
188
359
|
state.projectsById.set("project-1", {
|
|
@@ -625,7 +796,7 @@ describe("ws-router", () => {
|
|
|
625
796
|
snapshot: {
|
|
626
797
|
type: "sidebar",
|
|
627
798
|
data: {
|
|
628
|
-
projectGroups: [{
|
|
799
|
+
projectGroups: [withSidebarGroupDefaults({
|
|
629
800
|
groupKey: "project-1",
|
|
630
801
|
localPath: "/tmp/project",
|
|
631
802
|
chats: [{
|
|
@@ -637,10 +808,9 @@ describe("ws-router", () => {
|
|
|
637
808
|
unread: false,
|
|
638
809
|
localPath: "/tmp/project",
|
|
639
810
|
provider: null,
|
|
640
|
-
lastMessageAt: undefined,
|
|
641
811
|
hasAutomation: false,
|
|
642
812
|
}],
|
|
643
|
-
}],
|
|
813
|
+
})],
|
|
644
814
|
},
|
|
645
815
|
},
|
|
646
816
|
})
|
|
@@ -651,7 +821,7 @@ describe("ws-router", () => {
|
|
|
651
821
|
snapshot: {
|
|
652
822
|
type: "sidebar",
|
|
653
823
|
data: {
|
|
654
|
-
projectGroups: [{
|
|
824
|
+
projectGroups: [withSidebarGroupDefaults({
|
|
655
825
|
groupKey: "project-1",
|
|
656
826
|
localPath: "/tmp/project",
|
|
657
827
|
chats: [{
|
|
@@ -663,10 +833,102 @@ describe("ws-router", () => {
|
|
|
663
833
|
unread: false,
|
|
664
834
|
localPath: "/tmp/project",
|
|
665
835
|
provider: null,
|
|
666
|
-
lastMessageAt: undefined,
|
|
667
836
|
hasAutomation: false,
|
|
668
837
|
}],
|
|
669
|
-
}],
|
|
838
|
+
})],
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
})
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
test("reorders sidebar project groups on the server and rebroadcasts the snapshot", async () => {
|
|
845
|
+
const state = createEmptyState()
|
|
846
|
+
state.projectsById.set("project-1", {
|
|
847
|
+
id: "project-1",
|
|
848
|
+
localPath: "/tmp/project-1",
|
|
849
|
+
title: "Project 1",
|
|
850
|
+
createdAt: 1,
|
|
851
|
+
updatedAt: 1,
|
|
852
|
+
})
|
|
853
|
+
state.projectsById.set("project-2", {
|
|
854
|
+
id: "project-2",
|
|
855
|
+
localPath: "/tmp/project-2",
|
|
856
|
+
title: "Project 2",
|
|
857
|
+
createdAt: 2,
|
|
858
|
+
updatedAt: 2,
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
const setSidebarProjectOrderCalls: string[][] = []
|
|
862
|
+
const router = createWsRouter({
|
|
863
|
+
store: {
|
|
864
|
+
state,
|
|
865
|
+
async setSidebarProjectOrder(projectIds: string[]) {
|
|
866
|
+
setSidebarProjectOrderCalls.push(projectIds)
|
|
867
|
+
state.sidebarProjectOrder = [...projectIds]
|
|
868
|
+
},
|
|
869
|
+
} as never,
|
|
870
|
+
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
871
|
+
terminals: {
|
|
872
|
+
getSnapshot: () => null,
|
|
873
|
+
onEvent: () => () => {},
|
|
874
|
+
} as never,
|
|
875
|
+
keybindings: {
|
|
876
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
877
|
+
onChange: () => () => {},
|
|
878
|
+
} as never,
|
|
879
|
+
refreshDiscovery: async () => [],
|
|
880
|
+
getDiscoveredProjects: () => [],
|
|
881
|
+
machineDisplayName: "Local Machine",
|
|
882
|
+
updateManager: null,
|
|
883
|
+
})
|
|
884
|
+
const ws = new FakeWebSocket()
|
|
885
|
+
router.handleOpen(ws as never)
|
|
886
|
+
|
|
887
|
+
await router.handleMessage(
|
|
888
|
+
ws as never,
|
|
889
|
+
JSON.stringify({
|
|
890
|
+
v: 1,
|
|
891
|
+
type: "subscribe",
|
|
892
|
+
id: "sidebar-sub-1",
|
|
893
|
+
topic: { type: "sidebar" },
|
|
894
|
+
})
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
await router.handleMessage(
|
|
898
|
+
ws as never,
|
|
899
|
+
JSON.stringify({
|
|
900
|
+
v: 1,
|
|
901
|
+
type: "command",
|
|
902
|
+
id: "sidebar-reorder-1",
|
|
903
|
+
command: { type: "sidebar.reorderProjectGroups", projectIds: ["project-1", "project-2"] },
|
|
904
|
+
})
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
expect(setSidebarProjectOrderCalls).toEqual([["project-1", "project-2"]])
|
|
908
|
+
expect(ws.sent.at(-2)).toEqual({
|
|
909
|
+
v: PROTOCOL_VERSION,
|
|
910
|
+
type: "ack",
|
|
911
|
+
id: "sidebar-reorder-1",
|
|
912
|
+
})
|
|
913
|
+
expect(ws.sent.at(-1)).toEqual({
|
|
914
|
+
v: PROTOCOL_VERSION,
|
|
915
|
+
type: "snapshot",
|
|
916
|
+
id: "sidebar-sub-1",
|
|
917
|
+
snapshot: {
|
|
918
|
+
type: "sidebar",
|
|
919
|
+
data: {
|
|
920
|
+
projectGroups: [
|
|
921
|
+
withSidebarGroupDefaults({
|
|
922
|
+
groupKey: "project-1",
|
|
923
|
+
localPath: "/tmp/project-1",
|
|
924
|
+
chats: [],
|
|
925
|
+
}),
|
|
926
|
+
withSidebarGroupDefaults({
|
|
927
|
+
groupKey: "project-2",
|
|
928
|
+
localPath: "/tmp/project-2",
|
|
929
|
+
chats: [],
|
|
930
|
+
}),
|
|
931
|
+
],
|
|
670
932
|
},
|
|
671
933
|
},
|
|
672
934
|
})
|
|
@@ -741,9 +1003,11 @@ describe("ws-router", () => {
|
|
|
741
1003
|
type: "sidebar",
|
|
742
1004
|
data: {
|
|
743
1005
|
projectGroups: [{
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1006
|
+
...withSidebarGroupDefaults({
|
|
1007
|
+
groupKey: "project-1",
|
|
1008
|
+
localPath: "/tmp/project",
|
|
1009
|
+
chats: [],
|
|
1010
|
+
}),
|
|
747
1011
|
}],
|
|
748
1012
|
},
|
|
749
1013
|
},
|
package/src/server/ws-router.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { ensureProjectDirectory } from "./paths"
|
|
|
12
12
|
import { TerminalManager } from "./terminal-manager"
|
|
13
13
|
import type { UpdateManager } from "./update-manager"
|
|
14
14
|
import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
|
|
15
|
+
import type { LlmProviderSnapshot } from "../shared/types"
|
|
15
16
|
|
|
16
17
|
const DEFAULT_CHAT_RECENT_LIMIT = 200
|
|
17
18
|
|
|
@@ -96,6 +97,10 @@ interface CreateWsRouterArgs {
|
|
|
96
97
|
agent: AgentCoordinator
|
|
97
98
|
terminals: TerminalManager
|
|
98
99
|
keybindings: KeybindingsManager
|
|
100
|
+
llmProvider?: {
|
|
101
|
+
read: () => Promise<LlmProviderSnapshot>
|
|
102
|
+
write: (value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">) => Promise<LlmProviderSnapshot>
|
|
103
|
+
}
|
|
99
104
|
refreshDiscovery: () => Promise<DiscoveredProject[]>
|
|
100
105
|
getDiscoveredProjects: () => DiscoveredProject[]
|
|
101
106
|
machineDisplayName: string
|
|
@@ -112,6 +117,13 @@ interface SnapshotBroadcastFilter {
|
|
|
112
117
|
terminalIds?: Set<string>
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
interface SnapshotComputationCache {
|
|
121
|
+
sidebar?: {
|
|
122
|
+
data: ReturnType<typeof deriveSidebarData>
|
|
123
|
+
signature: string
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
115
127
|
function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
|
|
116
128
|
const payload = JSON.stringify(message)
|
|
117
129
|
ws.send(payload)
|
|
@@ -132,6 +144,7 @@ export function createWsRouter({
|
|
|
132
144
|
agent,
|
|
133
145
|
terminals,
|
|
134
146
|
keybindings,
|
|
147
|
+
llmProvider,
|
|
135
148
|
refreshDiscovery,
|
|
136
149
|
getDiscoveredProjects,
|
|
137
150
|
machineDisplayName,
|
|
@@ -160,6 +173,37 @@ export function createWsRouter({
|
|
|
160
173
|
ignoreFile: async () => ({ snapshotChanged: false }),
|
|
161
174
|
readPatch: async () => ({ patch: "" }),
|
|
162
175
|
}
|
|
176
|
+
const resolvedLlmProvider = llmProvider ?? {
|
|
177
|
+
read: async () => ({
|
|
178
|
+
provider: "openai" as const,
|
|
179
|
+
apiKey: "",
|
|
180
|
+
model: "gpt-5.4-mini",
|
|
181
|
+
baseUrl: "",
|
|
182
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
183
|
+
enabled: false,
|
|
184
|
+
warning: null,
|
|
185
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
186
|
+
}),
|
|
187
|
+
write: async ({ provider, apiKey, model, baseUrl }: {
|
|
188
|
+
provider: "openai" | "openrouter" | "custom"
|
|
189
|
+
apiKey: string
|
|
190
|
+
model: string
|
|
191
|
+
baseUrl: string
|
|
192
|
+
}) => ({
|
|
193
|
+
provider,
|
|
194
|
+
apiKey,
|
|
195
|
+
model,
|
|
196
|
+
baseUrl,
|
|
197
|
+
resolvedBaseUrl: provider === "openrouter"
|
|
198
|
+
? "https://openrouter.ai/api/v1"
|
|
199
|
+
: provider === "custom"
|
|
200
|
+
? baseUrl
|
|
201
|
+
: "https://api.openai.com/v1",
|
|
202
|
+
enabled: false,
|
|
203
|
+
warning: null,
|
|
204
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
205
|
+
}),
|
|
206
|
+
}
|
|
163
207
|
|
|
164
208
|
function getProtectedChatIds() {
|
|
165
209
|
const activeStatuses = agent.getActiveStatuses()
|
|
@@ -241,28 +285,50 @@ export function createWsRouter({
|
|
|
241
285
|
return true
|
|
242
286
|
}
|
|
243
287
|
|
|
244
|
-
function
|
|
288
|
+
function getSidebarSnapshotCacheEntry(cache?: SnapshotComputationCache) {
|
|
289
|
+
if (cache?.sidebar) {
|
|
290
|
+
return cache.sidebar
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const startedAt = performance.now()
|
|
294
|
+
const data = deriveSidebarData(store.state, agent.getActiveStatuses())
|
|
295
|
+
if (isSendToStartingProfilingEnabled()) {
|
|
296
|
+
const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
|
|
297
|
+
console.log("[kanna/send->starting][server]", JSON.stringify({
|
|
298
|
+
stage: "ws.sidebar_snapshot_built",
|
|
299
|
+
elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
|
|
300
|
+
projectGroupCount: data.projectGroups.length,
|
|
301
|
+
chatCount: totalChats,
|
|
302
|
+
totalChatCount: store.state.chatsById.size,
|
|
303
|
+
totalProjectCount: store.state.projectsById.size,
|
|
304
|
+
}))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const sidebar = {
|
|
308
|
+
data,
|
|
309
|
+
signature: JSON.stringify({
|
|
310
|
+
type: "sidebar" as const,
|
|
311
|
+
data,
|
|
312
|
+
}),
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (cache) {
|
|
316
|
+
cache.sidebar = sidebar
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return sidebar
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function createEnvelope(id: string, topic: SubscriptionTopic, cache?: SnapshotComputationCache): ServerEnvelope {
|
|
245
323
|
if (topic.type === "sidebar") {
|
|
246
|
-
const
|
|
247
|
-
const data = deriveSidebarData(store.state, agent.getActiveStatuses())
|
|
248
|
-
if (isSendToStartingProfilingEnabled()) {
|
|
249
|
-
const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
|
|
250
|
-
console.log("[kanna/send->starting][server]", JSON.stringify({
|
|
251
|
-
stage: "ws.sidebar_snapshot_built",
|
|
252
|
-
elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
|
|
253
|
-
projectGroupCount: data.projectGroups.length,
|
|
254
|
-
chatCount: totalChats,
|
|
255
|
-
totalChatCount: store.state.chatsById.size,
|
|
256
|
-
totalProjectCount: store.state.projectsById.size,
|
|
257
|
-
}))
|
|
258
|
-
}
|
|
324
|
+
const sidebar = getSidebarSnapshotCacheEntry(cache)
|
|
259
325
|
return {
|
|
260
326
|
v: PROTOCOL_VERSION,
|
|
261
327
|
type: "snapshot",
|
|
262
328
|
id,
|
|
263
329
|
snapshot: {
|
|
264
330
|
type: "sidebar",
|
|
265
|
-
data,
|
|
331
|
+
data: sidebar.data,
|
|
266
332
|
},
|
|
267
333
|
}
|
|
268
334
|
}
|
|
@@ -360,7 +426,7 @@ export function createWsRouter({
|
|
|
360
426
|
|
|
361
427
|
async function pushSnapshots(
|
|
362
428
|
ws: ServerWebSocket<ClientState>,
|
|
363
|
-
options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter }
|
|
429
|
+
options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter; cache?: SnapshotComputationCache }
|
|
364
430
|
) {
|
|
365
431
|
const pushStartedAt = performance.now()
|
|
366
432
|
if (!options?.skipPrune) {
|
|
@@ -374,11 +440,13 @@ export function createWsRouter({
|
|
|
374
440
|
continue
|
|
375
441
|
}
|
|
376
442
|
const envelopeStartedAt = performance.now()
|
|
377
|
-
const envelope = createEnvelope(id, topic)
|
|
443
|
+
const envelope = createEnvelope(id, topic, options?.cache)
|
|
378
444
|
const createdAt = performance.now()
|
|
379
445
|
if (envelope.type !== "snapshot") continue
|
|
380
|
-
const signature =
|
|
381
|
-
|
|
446
|
+
const signature = topic.type === "sidebar"
|
|
447
|
+
? getSidebarSnapshotCacheEntry(options?.cache).signature
|
|
448
|
+
: JSON.stringify(envelope.snapshot)
|
|
449
|
+
const signatureReadyAt = topic.type === "sidebar" ? createdAt : performance.now()
|
|
382
450
|
if (snapshotSignatures.get(id) === signature) {
|
|
383
451
|
skippedCount += 1
|
|
384
452
|
continue
|
|
@@ -420,9 +488,10 @@ export function createWsRouter({
|
|
|
420
488
|
async function broadcastSnapshots() {
|
|
421
489
|
const startedAt = performance.now()
|
|
422
490
|
let socketCount = 0
|
|
491
|
+
const cache: SnapshotComputationCache = {}
|
|
423
492
|
for (const ws of sockets) {
|
|
424
493
|
socketCount += 1
|
|
425
|
-
await pushSnapshots(ws, { skipPrune: true })
|
|
494
|
+
await pushSnapshots(ws, { skipPrune: true, cache })
|
|
426
495
|
}
|
|
427
496
|
if (isSendToStartingProfilingEnabled()) {
|
|
428
497
|
console.log("[kanna/send->starting][server]", JSON.stringify({
|
|
@@ -439,9 +508,10 @@ export function createWsRouter({
|
|
|
439
508
|
async function broadcastFilteredSnapshots(filter: SnapshotBroadcastFilter) {
|
|
440
509
|
const startedAt = performance.now()
|
|
441
510
|
let socketCount = 0
|
|
511
|
+
const cache: SnapshotComputationCache = {}
|
|
442
512
|
for (const ws of sockets) {
|
|
443
513
|
socketCount += 1
|
|
444
|
-
await pushSnapshots(ws, { skipPrune: true, filter })
|
|
514
|
+
await pushSnapshots(ws, { skipPrune: true, filter, cache })
|
|
445
515
|
}
|
|
446
516
|
if (isSendToStartingProfilingEnabled()) {
|
|
447
517
|
console.log("[kanna/send->starting][server]", JSON.stringify({
|
|
@@ -646,6 +716,20 @@ export function createWsRouter({
|
|
|
646
716
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
|
|
647
717
|
return
|
|
648
718
|
}
|
|
719
|
+
case "settings.readLlmProvider": {
|
|
720
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: await resolvedLlmProvider.read() })
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
case "settings.writeLlmProvider": {
|
|
724
|
+
const snapshot = await resolvedLlmProvider.write({
|
|
725
|
+
provider: command.provider,
|
|
726
|
+
apiKey: command.apiKey,
|
|
727
|
+
model: command.model,
|
|
728
|
+
baseUrl: command.baseUrl,
|
|
729
|
+
})
|
|
730
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
|
|
731
|
+
return
|
|
732
|
+
}
|
|
649
733
|
case "project.open": {
|
|
650
734
|
await ensureProjectDirectory(command.localPath)
|
|
651
735
|
const project = await store.openProject(command.localPath)
|
|
@@ -673,6 +757,12 @@ export function createWsRouter({
|
|
|
673
757
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
674
758
|
break
|
|
675
759
|
}
|
|
760
|
+
case "sidebar.reorderProjectGroups": {
|
|
761
|
+
await store.setSidebarProjectOrder(command.projectIds)
|
|
762
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
763
|
+
await broadcastFilteredSnapshots({ includeSidebar: true })
|
|
764
|
+
return
|
|
765
|
+
}
|
|
676
766
|
case "project.readDiffPatch": {
|
|
677
767
|
const project = store.getProject(command.projectId)
|
|
678
768
|
if (!project) {
|
package/src/shared/branding.ts
CHANGED
|
@@ -55,6 +55,14 @@ export function getKeybindingsFilePathDisplay(env: RuntimeEnv = getRuntimeEnv())
|
|
|
55
55
|
return `${getDataRootDirDisplay(env)}/keybindings.json`
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export function getLlmProviderFilePath(homeDir: string, env: RuntimeEnv = getRuntimeEnv()) {
|
|
59
|
+
return `${getDataRootDir(homeDir, env)}/llm-provider.json`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getLlmProviderFilePathDisplay(env: RuntimeEnv = getRuntimeEnv()) {
|
|
63
|
+
return `${getDataRootDirDisplay(env)}/llm-provider.json`
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
export function getCliInvocation(arg?: string) {
|
|
59
67
|
return arg ? `${CLI_COMMAND} ${arg}` : CLI_COMMAND
|
|
60
68
|
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ChatSnapshot,
|
|
7
7
|
DiffCommitMode,
|
|
8
8
|
KeybindingsSnapshot,
|
|
9
|
+
LlmProviderSnapshot,
|
|
9
10
|
LocalProjectsSnapshot,
|
|
10
11
|
ModelOptions,
|
|
11
12
|
SidebarData,
|
|
@@ -50,12 +51,21 @@ export type ClientCommand =
|
|
|
50
51
|
| { type: "project.open"; localPath: string }
|
|
51
52
|
| { type: "project.create"; localPath: string; title: string }
|
|
52
53
|
| { type: "project.remove"; projectId: string }
|
|
54
|
+
| { type: "sidebar.reorderProjectGroups"; projectIds: string[] }
|
|
53
55
|
| { type: "project.readDiffPatch"; projectId: string; path: string }
|
|
54
56
|
| { type: "system.ping" }
|
|
55
57
|
| { type: "update.check"; force?: boolean }
|
|
56
58
|
| { type: "update.install" }
|
|
57
59
|
| { type: "settings.readKeybindings" }
|
|
58
60
|
| { type: "settings.writeKeybindings"; bindings: KeybindingsSnapshot["bindings"] }
|
|
61
|
+
| { type: "settings.readLlmProvider" }
|
|
62
|
+
| {
|
|
63
|
+
type: "settings.writeLlmProvider"
|
|
64
|
+
provider: LlmProviderSnapshot["provider"]
|
|
65
|
+
apiKey: string
|
|
66
|
+
model: string
|
|
67
|
+
baseUrl: string
|
|
68
|
+
}
|
|
59
69
|
| {
|
|
60
70
|
type: "system.openExternal"
|
|
61
71
|
localPath: string
|
|
@@ -189,6 +199,7 @@ export type ServerSnapshot =
|
|
|
189
199
|
| { type: "local-projects"; data: LocalProjectsSnapshot }
|
|
190
200
|
| { type: "update"; data: UpdateSnapshot }
|
|
191
201
|
| { type: "keybindings"; data: KeybindingsSnapshot }
|
|
202
|
+
| { type: "llm-provider"; data: LlmProviderSnapshot }
|
|
192
203
|
| { type: "chat"; data: ChatSnapshot | null }
|
|
193
204
|
| { type: "project-git"; data: ChatDiffSnapshot | null }
|
|
194
205
|
| { type: "terminal"; data: TerminalSnapshot | null }
|
package/src/shared/types.ts
CHANGED
|
@@ -2,6 +2,9 @@ export const STORE_VERSION = 2 as const
|
|
|
2
2
|
export const PROTOCOL_VERSION = 1 as const
|
|
3
3
|
|
|
4
4
|
export type AgentProvider = "claude" | "codex"
|
|
5
|
+
export type LlmProviderKind = "openai" | "openrouter" | "custom"
|
|
6
|
+
export const DEFAULT_OPENAI_SDK_MODEL = "gpt-5.4-mini"
|
|
7
|
+
export const DEFAULT_OPENROUTER_SDK_MODEL = "moonshotai/kimi-k2.5:nitro"
|
|
5
8
|
|
|
6
9
|
export type AttachmentKind = "image" | "file"
|
|
7
10
|
|
|
@@ -224,6 +227,9 @@ export interface SidebarProjectGroup {
|
|
|
224
227
|
groupKey: string
|
|
225
228
|
localPath: string
|
|
226
229
|
chats: SidebarChatRow[]
|
|
230
|
+
previewChats: SidebarChatRow[]
|
|
231
|
+
olderChats: SidebarChatRow[]
|
|
232
|
+
defaultCollapsed: boolean
|
|
227
233
|
}
|
|
228
234
|
|
|
229
235
|
export interface SidebarData {
|
|
@@ -246,6 +252,24 @@ export interface LocalProjectsSnapshot {
|
|
|
246
252
|
projects: LocalProjectSummary[]
|
|
247
253
|
}
|
|
248
254
|
|
|
255
|
+
export interface LlmProviderFile {
|
|
256
|
+
provider?: LlmProviderKind
|
|
257
|
+
apiKey?: string
|
|
258
|
+
model?: string
|
|
259
|
+
baseUrl?: string | null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface LlmProviderSnapshot {
|
|
263
|
+
provider: LlmProviderKind
|
|
264
|
+
apiKey: string
|
|
265
|
+
model: string
|
|
266
|
+
baseUrl: string
|
|
267
|
+
resolvedBaseUrl: string
|
|
268
|
+
enabled: boolean
|
|
269
|
+
warning: string | null
|
|
270
|
+
filePathDisplay: string
|
|
271
|
+
}
|
|
272
|
+
|
|
249
273
|
export type UpdateStatus =
|
|
250
274
|
| "idle"
|
|
251
275
|
| "checking"
|