kanna-code 0.31.0 → 0.32.1
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-B3EUEMfY.css +32 -0
- package/dist/client/assets/index-EVYrrkP5.js +2593 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/server/diff-store.test.ts +35 -0
- package/src/server/diff-store.ts +27 -7
- 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 +207 -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 +6 -0
- package/src/server/ws-router.test.ts +278 -10
- package/src/server/ws-router.ts +130 -21
- package/src/shared/branding.ts +8 -0
- package/src/shared/protocol.ts +18 -0
- package/src/shared/types.ts +29 -0
- package/dist/client/assets/index-D_R1gYah.js +0 -2598
- package/dist/client/assets/index-KvdfpGdt.css +0 -32
|
@@ -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,98 @@ 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
|
+
validate: async () => ({
|
|
146
|
+
ok: true,
|
|
147
|
+
error: null,
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
refreshDiscovery: async () => [],
|
|
151
|
+
getDiscoveredProjects: () => [],
|
|
152
|
+
machineDisplayName: "Local Machine",
|
|
153
|
+
updateManager: null,
|
|
154
|
+
})
|
|
155
|
+
const ws = new FakeWebSocket()
|
|
156
|
+
|
|
157
|
+
await router.handleMessage(
|
|
158
|
+
ws as never,
|
|
159
|
+
JSON.stringify({
|
|
160
|
+
v: 1,
|
|
161
|
+
type: "command",
|
|
162
|
+
id: "llm-read-1",
|
|
163
|
+
command: { type: "settings.readLlmProvider" },
|
|
164
|
+
})
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
await router.handleMessage(
|
|
168
|
+
ws as never,
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
v: 1,
|
|
171
|
+
type: "command",
|
|
172
|
+
id: "llm-write-1",
|
|
173
|
+
command: {
|
|
174
|
+
type: "settings.writeLlmProvider",
|
|
175
|
+
provider: "custom",
|
|
176
|
+
apiKey: "test-key",
|
|
177
|
+
model: "gpt-test",
|
|
178
|
+
baseUrl: "https://example.com/v1",
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
expect(ws.sent).toEqual([
|
|
184
|
+
{
|
|
185
|
+
v: PROTOCOL_VERSION,
|
|
186
|
+
type: "ack",
|
|
187
|
+
id: "llm-read-1",
|
|
188
|
+
result: DEFAULT_LLM_PROVIDER_SNAPSHOT,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
v: PROTOCOL_VERSION,
|
|
192
|
+
type: "ack",
|
|
193
|
+
id: "llm-write-1",
|
|
194
|
+
result: {
|
|
195
|
+
...DEFAULT_LLM_PROVIDER_SNAPSHOT,
|
|
196
|
+
provider: "custom",
|
|
197
|
+
apiKey: "test-key",
|
|
198
|
+
model: "gpt-test",
|
|
199
|
+
baseUrl: "https://example.com/v1",
|
|
200
|
+
resolvedBaseUrl: "https://example.com/v1",
|
|
201
|
+
enabled: true,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
])
|
|
205
|
+
expect(writes).toEqual([{
|
|
206
|
+
provider: "custom",
|
|
207
|
+
apiKey: "test-key",
|
|
208
|
+
model: "gpt-test",
|
|
209
|
+
baseUrl: "https://example.com/v1",
|
|
210
|
+
}])
|
|
211
|
+
})
|
|
212
|
+
|
|
86
213
|
test("acks terminal.input without rebroadcasting terminal snapshots", async () => {
|
|
87
214
|
const router = createWsRouter({
|
|
88
215
|
store: { state: createEmptyState() } as never,
|
|
@@ -183,6 +310,54 @@ describe("ws-router", () => {
|
|
|
183
310
|
})
|
|
184
311
|
})
|
|
185
312
|
|
|
313
|
+
test("reuses one sidebar derivation across sockets in the same broadcast pass", async () => {
|
|
314
|
+
const state = createEmptyState()
|
|
315
|
+
state.projectsById.set("project-1", {
|
|
316
|
+
id: "project-1",
|
|
317
|
+
localPath: "/tmp/project",
|
|
318
|
+
title: "Project",
|
|
319
|
+
createdAt: 1,
|
|
320
|
+
updatedAt: 1,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
let activeStatusCalls = 0
|
|
324
|
+
const router = createWsRouter({
|
|
325
|
+
store: { state } as never,
|
|
326
|
+
agent: {
|
|
327
|
+
getActiveStatuses: () => {
|
|
328
|
+
activeStatusCalls += 1
|
|
329
|
+
return new Map()
|
|
330
|
+
},
|
|
331
|
+
getDrainingChatIds: () => new Set(),
|
|
332
|
+
} as never,
|
|
333
|
+
terminals: {
|
|
334
|
+
getSnapshot: () => null,
|
|
335
|
+
onEvent: () => () => {},
|
|
336
|
+
} as never,
|
|
337
|
+
keybindings: {
|
|
338
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
339
|
+
onChange: () => () => {},
|
|
340
|
+
} as never,
|
|
341
|
+
refreshDiscovery: async () => [],
|
|
342
|
+
getDiscoveredProjects: () => [],
|
|
343
|
+
machineDisplayName: "Local Machine",
|
|
344
|
+
updateManager: null,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
const wsA = new FakeWebSocket()
|
|
348
|
+
const wsB = new FakeWebSocket()
|
|
349
|
+
router.handleOpen(wsA as never)
|
|
350
|
+
router.handleOpen(wsB as never)
|
|
351
|
+
wsA.data.subscriptions.set("sidebar-a", { type: "sidebar" })
|
|
352
|
+
wsB.data.subscriptions.set("sidebar-b", { type: "sidebar" })
|
|
353
|
+
|
|
354
|
+
await router.broadcastSnapshots()
|
|
355
|
+
|
|
356
|
+
expect(activeStatusCalls).toBe(1)
|
|
357
|
+
expect(wsA.sent).toHaveLength(1)
|
|
358
|
+
expect(wsB.sent).toHaveLength(1)
|
|
359
|
+
})
|
|
360
|
+
|
|
186
361
|
test("subscribes to project git snapshots independently from chat snapshots", async () => {
|
|
187
362
|
const state = createEmptyState()
|
|
188
363
|
state.projectsById.set("project-1", {
|
|
@@ -625,7 +800,7 @@ describe("ws-router", () => {
|
|
|
625
800
|
snapshot: {
|
|
626
801
|
type: "sidebar",
|
|
627
802
|
data: {
|
|
628
|
-
projectGroups: [{
|
|
803
|
+
projectGroups: [withSidebarGroupDefaults({
|
|
629
804
|
groupKey: "project-1",
|
|
630
805
|
localPath: "/tmp/project",
|
|
631
806
|
chats: [{
|
|
@@ -637,10 +812,9 @@ describe("ws-router", () => {
|
|
|
637
812
|
unread: false,
|
|
638
813
|
localPath: "/tmp/project",
|
|
639
814
|
provider: null,
|
|
640
|
-
lastMessageAt: undefined,
|
|
641
815
|
hasAutomation: false,
|
|
642
816
|
}],
|
|
643
|
-
}],
|
|
817
|
+
})],
|
|
644
818
|
},
|
|
645
819
|
},
|
|
646
820
|
})
|
|
@@ -651,7 +825,7 @@ describe("ws-router", () => {
|
|
|
651
825
|
snapshot: {
|
|
652
826
|
type: "sidebar",
|
|
653
827
|
data: {
|
|
654
|
-
projectGroups: [{
|
|
828
|
+
projectGroups: [withSidebarGroupDefaults({
|
|
655
829
|
groupKey: "project-1",
|
|
656
830
|
localPath: "/tmp/project",
|
|
657
831
|
chats: [{
|
|
@@ -663,10 +837,102 @@ describe("ws-router", () => {
|
|
|
663
837
|
unread: false,
|
|
664
838
|
localPath: "/tmp/project",
|
|
665
839
|
provider: null,
|
|
666
|
-
lastMessageAt: undefined,
|
|
667
840
|
hasAutomation: false,
|
|
668
841
|
}],
|
|
669
|
-
}],
|
|
842
|
+
})],
|
|
843
|
+
},
|
|
844
|
+
},
|
|
845
|
+
})
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
test("reorders sidebar project groups on the server and rebroadcasts the snapshot", async () => {
|
|
849
|
+
const state = createEmptyState()
|
|
850
|
+
state.projectsById.set("project-1", {
|
|
851
|
+
id: "project-1",
|
|
852
|
+
localPath: "/tmp/project-1",
|
|
853
|
+
title: "Project 1",
|
|
854
|
+
createdAt: 1,
|
|
855
|
+
updatedAt: 1,
|
|
856
|
+
})
|
|
857
|
+
state.projectsById.set("project-2", {
|
|
858
|
+
id: "project-2",
|
|
859
|
+
localPath: "/tmp/project-2",
|
|
860
|
+
title: "Project 2",
|
|
861
|
+
createdAt: 2,
|
|
862
|
+
updatedAt: 2,
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
const setSidebarProjectOrderCalls: string[][] = []
|
|
866
|
+
const router = createWsRouter({
|
|
867
|
+
store: {
|
|
868
|
+
state,
|
|
869
|
+
async setSidebarProjectOrder(projectIds: string[]) {
|
|
870
|
+
setSidebarProjectOrderCalls.push(projectIds)
|
|
871
|
+
state.sidebarProjectOrder = [...projectIds]
|
|
872
|
+
},
|
|
873
|
+
} as never,
|
|
874
|
+
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
875
|
+
terminals: {
|
|
876
|
+
getSnapshot: () => null,
|
|
877
|
+
onEvent: () => () => {},
|
|
878
|
+
} as never,
|
|
879
|
+
keybindings: {
|
|
880
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
881
|
+
onChange: () => () => {},
|
|
882
|
+
} as never,
|
|
883
|
+
refreshDiscovery: async () => [],
|
|
884
|
+
getDiscoveredProjects: () => [],
|
|
885
|
+
machineDisplayName: "Local Machine",
|
|
886
|
+
updateManager: null,
|
|
887
|
+
})
|
|
888
|
+
const ws = new FakeWebSocket()
|
|
889
|
+
router.handleOpen(ws as never)
|
|
890
|
+
|
|
891
|
+
await router.handleMessage(
|
|
892
|
+
ws as never,
|
|
893
|
+
JSON.stringify({
|
|
894
|
+
v: 1,
|
|
895
|
+
type: "subscribe",
|
|
896
|
+
id: "sidebar-sub-1",
|
|
897
|
+
topic: { type: "sidebar" },
|
|
898
|
+
})
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
await router.handleMessage(
|
|
902
|
+
ws as never,
|
|
903
|
+
JSON.stringify({
|
|
904
|
+
v: 1,
|
|
905
|
+
type: "command",
|
|
906
|
+
id: "sidebar-reorder-1",
|
|
907
|
+
command: { type: "sidebar.reorderProjectGroups", projectIds: ["project-1", "project-2"] },
|
|
908
|
+
})
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
expect(setSidebarProjectOrderCalls).toEqual([["project-1", "project-2"]])
|
|
912
|
+
expect(ws.sent.at(-2)).toEqual({
|
|
913
|
+
v: PROTOCOL_VERSION,
|
|
914
|
+
type: "ack",
|
|
915
|
+
id: "sidebar-reorder-1",
|
|
916
|
+
})
|
|
917
|
+
expect(ws.sent.at(-1)).toEqual({
|
|
918
|
+
v: PROTOCOL_VERSION,
|
|
919
|
+
type: "snapshot",
|
|
920
|
+
id: "sidebar-sub-1",
|
|
921
|
+
snapshot: {
|
|
922
|
+
type: "sidebar",
|
|
923
|
+
data: {
|
|
924
|
+
projectGroups: [
|
|
925
|
+
withSidebarGroupDefaults({
|
|
926
|
+
groupKey: "project-1",
|
|
927
|
+
localPath: "/tmp/project-1",
|
|
928
|
+
chats: [],
|
|
929
|
+
}),
|
|
930
|
+
withSidebarGroupDefaults({
|
|
931
|
+
groupKey: "project-2",
|
|
932
|
+
localPath: "/tmp/project-2",
|
|
933
|
+
chats: [],
|
|
934
|
+
}),
|
|
935
|
+
],
|
|
670
936
|
},
|
|
671
937
|
},
|
|
672
938
|
})
|
|
@@ -741,9 +1007,11 @@ describe("ws-router", () => {
|
|
|
741
1007
|
type: "sidebar",
|
|
742
1008
|
data: {
|
|
743
1009
|
projectGroups: [{
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1010
|
+
...withSidebarGroupDefaults({
|
|
1011
|
+
groupKey: "project-1",
|
|
1012
|
+
localPath: "/tmp/project",
|
|
1013
|
+
chats: [],
|
|
1014
|
+
}),
|
|
747
1015
|
}],
|
|
748
1016
|
},
|
|
749
1017
|
},
|
package/src/server/ws-router.ts
CHANGED
|
@@ -12,6 +12,8 @@ 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"
|
|
16
|
+
import type { LlmProviderValidationResult } from "../shared/types"
|
|
15
17
|
|
|
16
18
|
const DEFAULT_CHAT_RECENT_LIMIT = 200
|
|
17
19
|
|
|
@@ -96,6 +98,11 @@ interface CreateWsRouterArgs {
|
|
|
96
98
|
agent: AgentCoordinator
|
|
97
99
|
terminals: TerminalManager
|
|
98
100
|
keybindings: KeybindingsManager
|
|
101
|
+
llmProvider?: {
|
|
102
|
+
read: () => Promise<LlmProviderSnapshot>
|
|
103
|
+
write: (value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">) => Promise<LlmProviderSnapshot>
|
|
104
|
+
validate: (value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">) => Promise<LlmProviderValidationResult>
|
|
105
|
+
}
|
|
99
106
|
refreshDiscovery: () => Promise<DiscoveredProject[]>
|
|
100
107
|
getDiscoveredProjects: () => DiscoveredProject[]
|
|
101
108
|
machineDisplayName: string
|
|
@@ -112,6 +119,13 @@ interface SnapshotBroadcastFilter {
|
|
|
112
119
|
terminalIds?: Set<string>
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
interface SnapshotComputationCache {
|
|
123
|
+
sidebar?: {
|
|
124
|
+
data: ReturnType<typeof deriveSidebarData>
|
|
125
|
+
signature: string
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
115
129
|
function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
|
|
116
130
|
const payload = JSON.stringify(message)
|
|
117
131
|
ws.send(payload)
|
|
@@ -132,6 +146,7 @@ export function createWsRouter({
|
|
|
132
146
|
agent,
|
|
133
147
|
terminals,
|
|
134
148
|
keybindings,
|
|
149
|
+
llmProvider,
|
|
135
150
|
refreshDiscovery,
|
|
136
151
|
getDiscoveredProjects,
|
|
137
152
|
machineDisplayName,
|
|
@@ -160,6 +175,44 @@ export function createWsRouter({
|
|
|
160
175
|
ignoreFile: async () => ({ snapshotChanged: false }),
|
|
161
176
|
readPatch: async () => ({ patch: "" }),
|
|
162
177
|
}
|
|
178
|
+
const resolvedLlmProvider = llmProvider ?? {
|
|
179
|
+
read: async () => ({
|
|
180
|
+
provider: "openai" as const,
|
|
181
|
+
apiKey: "",
|
|
182
|
+
model: "gpt-5.4-mini",
|
|
183
|
+
baseUrl: "",
|
|
184
|
+
resolvedBaseUrl: "https://api.openai.com/v1",
|
|
185
|
+
enabled: false,
|
|
186
|
+
warning: null,
|
|
187
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
188
|
+
}),
|
|
189
|
+
write: async ({ provider, apiKey, model, baseUrl }: {
|
|
190
|
+
provider: "openai" | "openrouter" | "custom"
|
|
191
|
+
apiKey: string
|
|
192
|
+
model: string
|
|
193
|
+
baseUrl: string
|
|
194
|
+
}) => ({
|
|
195
|
+
provider,
|
|
196
|
+
apiKey,
|
|
197
|
+
model,
|
|
198
|
+
baseUrl,
|
|
199
|
+
resolvedBaseUrl: provider === "openrouter"
|
|
200
|
+
? "https://openrouter.ai/api/v1"
|
|
201
|
+
: provider === "custom"
|
|
202
|
+
? baseUrl
|
|
203
|
+
: "https://api.openai.com/v1",
|
|
204
|
+
enabled: false,
|
|
205
|
+
warning: null,
|
|
206
|
+
filePathDisplay: "~/.kanna/llm-provider.json",
|
|
207
|
+
}),
|
|
208
|
+
validate: async () => ({
|
|
209
|
+
ok: false,
|
|
210
|
+
error: {
|
|
211
|
+
type: "config_error",
|
|
212
|
+
message: "LLM provider validation unavailable.",
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
}
|
|
163
216
|
|
|
164
217
|
function getProtectedChatIds() {
|
|
165
218
|
const activeStatuses = agent.getActiveStatuses()
|
|
@@ -241,28 +294,50 @@ export function createWsRouter({
|
|
|
241
294
|
return true
|
|
242
295
|
}
|
|
243
296
|
|
|
244
|
-
function
|
|
297
|
+
function getSidebarSnapshotCacheEntry(cache?: SnapshotComputationCache) {
|
|
298
|
+
if (cache?.sidebar) {
|
|
299
|
+
return cache.sidebar
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const startedAt = performance.now()
|
|
303
|
+
const data = deriveSidebarData(store.state, agent.getActiveStatuses())
|
|
304
|
+
if (isSendToStartingProfilingEnabled()) {
|
|
305
|
+
const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
|
|
306
|
+
console.log("[kanna/send->starting][server]", JSON.stringify({
|
|
307
|
+
stage: "ws.sidebar_snapshot_built",
|
|
308
|
+
elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
|
|
309
|
+
projectGroupCount: data.projectGroups.length,
|
|
310
|
+
chatCount: totalChats,
|
|
311
|
+
totalChatCount: store.state.chatsById.size,
|
|
312
|
+
totalProjectCount: store.state.projectsById.size,
|
|
313
|
+
}))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const sidebar = {
|
|
317
|
+
data,
|
|
318
|
+
signature: JSON.stringify({
|
|
319
|
+
type: "sidebar" as const,
|
|
320
|
+
data,
|
|
321
|
+
}),
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (cache) {
|
|
325
|
+
cache.sidebar = sidebar
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return sidebar
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function createEnvelope(id: string, topic: SubscriptionTopic, cache?: SnapshotComputationCache): ServerEnvelope {
|
|
245
332
|
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
|
-
}
|
|
333
|
+
const sidebar = getSidebarSnapshotCacheEntry(cache)
|
|
259
334
|
return {
|
|
260
335
|
v: PROTOCOL_VERSION,
|
|
261
336
|
type: "snapshot",
|
|
262
337
|
id,
|
|
263
338
|
snapshot: {
|
|
264
339
|
type: "sidebar",
|
|
265
|
-
data,
|
|
340
|
+
data: sidebar.data,
|
|
266
341
|
},
|
|
267
342
|
}
|
|
268
343
|
}
|
|
@@ -360,7 +435,7 @@ export function createWsRouter({
|
|
|
360
435
|
|
|
361
436
|
async function pushSnapshots(
|
|
362
437
|
ws: ServerWebSocket<ClientState>,
|
|
363
|
-
options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter }
|
|
438
|
+
options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter; cache?: SnapshotComputationCache }
|
|
364
439
|
) {
|
|
365
440
|
const pushStartedAt = performance.now()
|
|
366
441
|
if (!options?.skipPrune) {
|
|
@@ -374,11 +449,13 @@ export function createWsRouter({
|
|
|
374
449
|
continue
|
|
375
450
|
}
|
|
376
451
|
const envelopeStartedAt = performance.now()
|
|
377
|
-
const envelope = createEnvelope(id, topic)
|
|
452
|
+
const envelope = createEnvelope(id, topic, options?.cache)
|
|
378
453
|
const createdAt = performance.now()
|
|
379
454
|
if (envelope.type !== "snapshot") continue
|
|
380
|
-
const signature =
|
|
381
|
-
|
|
455
|
+
const signature = topic.type === "sidebar"
|
|
456
|
+
? getSidebarSnapshotCacheEntry(options?.cache).signature
|
|
457
|
+
: JSON.stringify(envelope.snapshot)
|
|
458
|
+
const signatureReadyAt = topic.type === "sidebar" ? createdAt : performance.now()
|
|
382
459
|
if (snapshotSignatures.get(id) === signature) {
|
|
383
460
|
skippedCount += 1
|
|
384
461
|
continue
|
|
@@ -420,9 +497,10 @@ export function createWsRouter({
|
|
|
420
497
|
async function broadcastSnapshots() {
|
|
421
498
|
const startedAt = performance.now()
|
|
422
499
|
let socketCount = 0
|
|
500
|
+
const cache: SnapshotComputationCache = {}
|
|
423
501
|
for (const ws of sockets) {
|
|
424
502
|
socketCount += 1
|
|
425
|
-
await pushSnapshots(ws, { skipPrune: true })
|
|
503
|
+
await pushSnapshots(ws, { skipPrune: true, cache })
|
|
426
504
|
}
|
|
427
505
|
if (isSendToStartingProfilingEnabled()) {
|
|
428
506
|
console.log("[kanna/send->starting][server]", JSON.stringify({
|
|
@@ -439,9 +517,10 @@ export function createWsRouter({
|
|
|
439
517
|
async function broadcastFilteredSnapshots(filter: SnapshotBroadcastFilter) {
|
|
440
518
|
const startedAt = performance.now()
|
|
441
519
|
let socketCount = 0
|
|
520
|
+
const cache: SnapshotComputationCache = {}
|
|
442
521
|
for (const ws of sockets) {
|
|
443
522
|
socketCount += 1
|
|
444
|
-
await pushSnapshots(ws, { skipPrune: true, filter })
|
|
523
|
+
await pushSnapshots(ws, { skipPrune: true, filter, cache })
|
|
445
524
|
}
|
|
446
525
|
if (isSendToStartingProfilingEnabled()) {
|
|
447
526
|
console.log("[kanna/send->starting][server]", JSON.stringify({
|
|
@@ -646,6 +725,30 @@ export function createWsRouter({
|
|
|
646
725
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
|
|
647
726
|
return
|
|
648
727
|
}
|
|
728
|
+
case "settings.readLlmProvider": {
|
|
729
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: await resolvedLlmProvider.read() })
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
case "settings.writeLlmProvider": {
|
|
733
|
+
const snapshot = await resolvedLlmProvider.write({
|
|
734
|
+
provider: command.provider,
|
|
735
|
+
apiKey: command.apiKey,
|
|
736
|
+
model: command.model,
|
|
737
|
+
baseUrl: command.baseUrl,
|
|
738
|
+
})
|
|
739
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
case "settings.validateLlmProvider": {
|
|
743
|
+
const result = await resolvedLlmProvider.validate({
|
|
744
|
+
provider: command.provider,
|
|
745
|
+
apiKey: command.apiKey,
|
|
746
|
+
model: command.model,
|
|
747
|
+
baseUrl: command.baseUrl,
|
|
748
|
+
})
|
|
749
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
|
|
750
|
+
return
|
|
751
|
+
}
|
|
649
752
|
case "project.open": {
|
|
650
753
|
await ensureProjectDirectory(command.localPath)
|
|
651
754
|
const project = await store.openProject(command.localPath)
|
|
@@ -673,6 +776,12 @@ export function createWsRouter({
|
|
|
673
776
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
674
777
|
break
|
|
675
778
|
}
|
|
779
|
+
case "sidebar.reorderProjectGroups": {
|
|
780
|
+
await store.setSidebarProjectOrder(command.projectIds)
|
|
781
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
782
|
+
await broadcastFilteredSnapshots({ includeSidebar: true })
|
|
783
|
+
return
|
|
784
|
+
}
|
|
676
785
|
case "project.readDiffPatch": {
|
|
677
786
|
const project = store.getProject(command.projectId)
|
|
678
787
|
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,28 @@ 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
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
type: "settings.validateLlmProvider"
|
|
71
|
+
provider: LlmProviderSnapshot["provider"]
|
|
72
|
+
apiKey: string
|
|
73
|
+
model: string
|
|
74
|
+
baseUrl: string
|
|
75
|
+
}
|
|
59
76
|
| {
|
|
60
77
|
type: "system.openExternal"
|
|
61
78
|
localPath: string
|
|
@@ -189,6 +206,7 @@ export type ServerSnapshot =
|
|
|
189
206
|
| { type: "local-projects"; data: LocalProjectsSnapshot }
|
|
190
207
|
| { type: "update"; data: UpdateSnapshot }
|
|
191
208
|
| { type: "keybindings"; data: KeybindingsSnapshot }
|
|
209
|
+
| { type: "llm-provider"; data: LlmProviderSnapshot }
|
|
192
210
|
| { type: "chat"; data: ChatSnapshot | null }
|
|
193
211
|
| { type: "project-git"; data: ChatDiffSnapshot | null }
|
|
194
212
|
| { 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,29 @@ 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
|
+
|
|
273
|
+
export interface LlmProviderValidationResult {
|
|
274
|
+
ok: boolean
|
|
275
|
+
error: unknown | null
|
|
276
|
+
}
|
|
277
|
+
|
|
249
278
|
export type UpdateStatus =
|
|
250
279
|
| "idle"
|
|
251
280
|
| "checking"
|