@gmickel/gno 0.37.0 → 0.39.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.
@@ -15,6 +15,10 @@ import type { ModelType } from "./types";
15
15
 
16
16
  import { DEFAULT_MODEL_PRESETS } from "../config/types";
17
17
 
18
+ export type ModelResolutionSource = "override" | "preset" | "default";
19
+ export type ModelResolutionMap = Record<ModelType, string>;
20
+ export type ModelResolutionSourceMap = Record<ModelType, ModelResolutionSource>;
21
+
18
22
  // ─────────────────────────────────────────────────────────────────────────────
19
23
  // Registry Functions
20
24
  // ─────────────────────────────────────────────────────────────────────────────
@@ -130,6 +134,55 @@ export function resolveModelUri(
130
134
  return preset[type];
131
135
  }
132
136
 
137
+ export function resolveModelSource(
138
+ config: Config,
139
+ type: ModelType,
140
+ override?: string,
141
+ collection?: string
142
+ ): ModelResolutionSource {
143
+ if (override) {
144
+ return "override";
145
+ }
146
+ const collectionModels = getCollectionModelOverrides(config, collection);
147
+ if (collectionModels?.[type]) {
148
+ return "override";
149
+ }
150
+
151
+ const modelConfig = getModelConfig(config);
152
+ const preset = modelConfig.presets.find(
153
+ (p) => p.id === modelConfig.activePreset
154
+ );
155
+ if (preset) {
156
+ return "preset";
157
+ }
158
+
159
+ return "default";
160
+ }
161
+
162
+ export function getCollectionEffectiveModels(
163
+ config: Config,
164
+ collection?: string
165
+ ): ModelResolutionMap {
166
+ return {
167
+ embed: resolveModelUri(config, "embed", undefined, collection),
168
+ rerank: resolveModelUri(config, "rerank", undefined, collection),
169
+ expand: resolveModelUri(config, "expand", undefined, collection),
170
+ gen: resolveModelUri(config, "gen", undefined, collection),
171
+ };
172
+ }
173
+
174
+ export function getCollectionModelSources(
175
+ config: Config,
176
+ collection?: string
177
+ ): ModelResolutionSourceMap {
178
+ return {
179
+ embed: resolveModelSource(config, "embed", undefined, collection),
180
+ rerank: resolveModelSource(config, "rerank", undefined, collection),
181
+ expand: resolveModelSource(config, "expand", undefined, collection),
182
+ gen: resolveModelSource(config, "gen", undefined, collection),
183
+ };
184
+ }
185
+
133
186
  /**
134
187
  * List all available presets.
135
188
  */
@@ -7,6 +7,7 @@
7
7
  import type { IndexStatus } from "../../store/types";
8
8
  import type { ToolContext } from "../server";
9
9
 
10
+ import { resolveModelUri } from "../../llm/registry";
10
11
  import { runTool, type ToolResult } from "./index";
11
12
 
12
13
  type StatusInput = Record<string, never>;
@@ -66,7 +67,9 @@ export function handleStatus(
66
67
  ctx,
67
68
  "gno_status",
68
69
  async () => {
69
- const result = await ctx.store.getStatus();
70
+ const result = await ctx.store.getStatus({
71
+ embedModel: resolveModelUri(ctx.config, "embed"),
72
+ });
70
73
  if (!result.ok) {
71
74
  throw new Error(result.error.message);
72
75
  }
package/src/sdk/client.ts CHANGED
@@ -621,7 +621,11 @@ class GnoClientImpl implements GnoClient {
621
621
 
622
622
  async status(): Promise<IndexStatus> {
623
623
  this.assertOpen();
624
- return unwrapStore(await this.store.getStatus());
624
+ return unwrapStore(
625
+ await this.store.getStatus({
626
+ embedModel: resolveModelUri(this.config, "embed"),
627
+ })
628
+ );
625
629
  }
626
630
 
627
631
  async update(options: GnoUpdateOptions = {}): Promise<SyncResult> {
@@ -55,6 +55,8 @@ interface SetPresetResponse {
55
55
  success: boolean;
56
56
  activePreset: string;
57
57
  capabilities: Capabilities;
58
+ embedModelChanged?: boolean;
59
+ note?: string;
58
60
  }
59
61
 
60
62
  interface DownloadProgress {
@@ -355,7 +357,12 @@ export function AIModelSelector({
355
357
  checkCapabilities(data.capabilities);
356
358
  setOpen(false);
357
359
  const presetName = presets.find((preset) => preset.id === id)?.name ?? id;
358
- setNotice(`Switched to ${presetName}`);
360
+ setNotice(
361
+ data.embedModelChanged
362
+ ? (data.note ??
363
+ `Switched to ${presetName}. Run embeddings again so vector results catch up.`)
364
+ : `Switched to ${presetName}`
365
+ );
359
366
  const { data: statusData } =
360
367
  await apiFetch<AppStatusResponse>("/api/status");
361
368
  await syncFromStatus(statusData);
@@ -0,0 +1,399 @@
1
+ import {
2
+ AlertTriangleIcon,
3
+ CpuIcon,
4
+ Loader2Icon,
5
+ RotateCcwIcon,
6
+ SparklesIcon,
7
+ } from "lucide-react";
8
+ import { useEffect, useMemo, useState } from "react";
9
+
10
+ import { apiFetch } from "../hooks/use-api";
11
+ import { Button } from "./ui/button";
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from "./ui/dialog";
20
+ import { Input } from "./ui/input";
21
+
22
+ const MODEL_ROLES = ["embed", "rerank", "expand", "gen"] as const;
23
+
24
+ type ModelRole = (typeof MODEL_ROLES)[number];
25
+ type ModelSource = "override" | "preset" | "default";
26
+
27
+ const ROLE_LABELS: Record<ModelRole, string> = {
28
+ embed: "Embedding",
29
+ rerank: "Reranker",
30
+ expand: "Query Expansion",
31
+ gen: "Answer Model",
32
+ };
33
+
34
+ const ROLE_NOTES: Record<ModelRole, string> = {
35
+ embed: "Drives vector search and embedding backlog for this collection.",
36
+ rerank: "Scores candidate passages/documents after retrieval.",
37
+ expand:
38
+ "Generates lexical and semantic expansion variants for harder queries.",
39
+ gen: "Used for collection-targeted answer generation flows.",
40
+ };
41
+
42
+ export interface CollectionModelDetails {
43
+ activePresetId?: string;
44
+ chunkCount: number;
45
+ documentCount: number;
46
+ effectiveModels?: Record<ModelRole, string>;
47
+ include?: string[];
48
+ modelSources?: Record<ModelRole, ModelSource>;
49
+ models?: Partial<Record<ModelRole, string>>;
50
+ name: string;
51
+ path: string;
52
+ pattern?: string;
53
+ }
54
+
55
+ interface UpdateCollectionResponse {
56
+ collection: CollectionModelDetails;
57
+ success: boolean;
58
+ }
59
+
60
+ interface CollectionModelDialogProps {
61
+ collection: CollectionModelDetails | null;
62
+ onOpenChange: (open: boolean) => void;
63
+ onSaved: () => void;
64
+ open: boolean;
65
+ }
66
+
67
+ function normalizeValue(value: string | undefined): string {
68
+ return value?.trim() ?? "";
69
+ }
70
+
71
+ const CODE_EMBED_RECOMMENDATION =
72
+ "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf";
73
+
74
+ const CODE_PATH_HINTS = [
75
+ "/src",
76
+ "/lib",
77
+ "/app",
78
+ "/apps",
79
+ "/packages",
80
+ "/server",
81
+ "/services",
82
+ ] as const;
83
+
84
+ const CODE_EXT_HINTS = [
85
+ ".ts",
86
+ ".tsx",
87
+ ".js",
88
+ ".jsx",
89
+ ".go",
90
+ ".rs",
91
+ ".py",
92
+ ".swift",
93
+ ".c",
94
+ ] as const;
95
+
96
+ function collectionLooksCodeHeavy(collection: CollectionModelDetails): boolean {
97
+ const path = collection.path.toLowerCase();
98
+ if (
99
+ CODE_PATH_HINTS.some(
100
+ (hint) => path.endsWith(hint) || path.includes(`${hint}/`)
101
+ )
102
+ ) {
103
+ return true;
104
+ }
105
+
106
+ const includeValues = collection.include ?? [];
107
+ if (
108
+ includeValues.some((value) =>
109
+ CODE_EXT_HINTS.some((ext) => value.includes(ext))
110
+ )
111
+ ) {
112
+ return true;
113
+ }
114
+
115
+ const pattern = collection.pattern?.toLowerCase() ?? "";
116
+ return CODE_EXT_HINTS.some(
117
+ (ext) => pattern.includes(ext) || pattern.includes(ext.replace(".", ""))
118
+ );
119
+ }
120
+
121
+ export function CollectionModelDialog({
122
+ collection,
123
+ onOpenChange,
124
+ onSaved,
125
+ open,
126
+ }: CollectionModelDialogProps) {
127
+ const [draft, setDraft] = useState<Record<ModelRole, string>>({
128
+ embed: "",
129
+ rerank: "",
130
+ expand: "",
131
+ gen: "",
132
+ });
133
+ const [error, setError] = useState<string | null>(null);
134
+ const [saving, setSaving] = useState(false);
135
+ const showCodeRecommendation =
136
+ collection !== null &&
137
+ collectionLooksCodeHeavy(collection) &&
138
+ collection.effectiveModels?.embed !== CODE_EMBED_RECOMMENDATION;
139
+
140
+ useEffect(() => {
141
+ if (!open || !collection) {
142
+ return;
143
+ }
144
+
145
+ setDraft({
146
+ embed: collection.models?.embed ?? "",
147
+ rerank: collection.models?.rerank ?? "",
148
+ expand: collection.models?.expand ?? "",
149
+ gen: collection.models?.gen ?? "",
150
+ });
151
+ setError(null);
152
+ setSaving(false);
153
+ }, [collection, open]);
154
+
155
+ const patch = useMemo(() => {
156
+ if (!collection) {
157
+ return {};
158
+ }
159
+
160
+ const nextPatch: Partial<Record<ModelRole, string | null>> = {};
161
+ for (const role of MODEL_ROLES) {
162
+ const original = normalizeValue(collection.models?.[role]);
163
+ const current = normalizeValue(draft[role]);
164
+ if (original === current) {
165
+ continue;
166
+ }
167
+ nextPatch[role] = current.length === 0 ? null : current;
168
+ }
169
+
170
+ return nextPatch;
171
+ }, [collection, draft]);
172
+
173
+ const hasChanges = Object.keys(patch).length > 0;
174
+ const embedChanged = Object.hasOwn(patch, "embed");
175
+
176
+ const handleSave = async () => {
177
+ if (!collection || !hasChanges) {
178
+ return;
179
+ }
180
+
181
+ setSaving(true);
182
+ setError(null);
183
+
184
+ const { error: requestError } = await apiFetch<UpdateCollectionResponse>(
185
+ `/api/collections/${encodeURIComponent(collection.name)}`,
186
+ {
187
+ method: "PATCH",
188
+ body: JSON.stringify({ models: patch }),
189
+ }
190
+ );
191
+
192
+ setSaving(false);
193
+
194
+ if (requestError) {
195
+ setError(requestError);
196
+ return;
197
+ }
198
+
199
+ onSaved();
200
+ onOpenChange(false);
201
+ };
202
+
203
+ return (
204
+ <Dialog onOpenChange={onOpenChange} open={open}>
205
+ <DialogContent className="flex max-h-[85vh] max-w-3xl flex-col gap-0 overflow-x-hidden border-none bg-[#0f1115] p-0 shadow-[0_30px_90px_-35px_rgba(0,0,0,0.8)]">
206
+ {/* Header */}
207
+ <DialogHeader className="shrink-0 border-border/20 border-b px-6 pt-5 pb-4 text-left">
208
+ <div className="flex items-start justify-between gap-4">
209
+ <div className="min-w-0 space-y-1.5">
210
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
211
+ <span className="rounded bg-muted/40 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/70 uppercase tracking-[0.12em]">
212
+ Collection models
213
+ </span>
214
+ <span className="rounded bg-muted/30 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/50 tracking-[0.05em]">
215
+ preset: {collection?.activePresetId ?? "unknown"}
216
+ </span>
217
+ </div>
218
+ <DialogTitle className="font-[Iowan_Old_Style,Palatino_Linotype,Palatino,Book_Antiqua,Georgia,serif] text-2xl leading-tight">
219
+ {collection?.name ?? "Collection"}
220
+ </DialogTitle>
221
+ <DialogDescription className="text-muted-foreground/70 text-[13px]">
222
+ Override model roles for one collection without changing the
223
+ active preset for the rest of the workspace.
224
+ </DialogDescription>
225
+ </div>
226
+ <div className="hidden shrink-0 max-w-[200px] space-y-1 border-border/15 border-l pl-4 lg:block">
227
+ <p className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
228
+ Path
229
+ </p>
230
+ <p
231
+ className="break-all font-mono text-[10px] leading-relaxed text-muted-foreground/50"
232
+ title={collection?.path}
233
+ >
234
+ {collection?.path}
235
+ </p>
236
+ </div>
237
+ </div>
238
+ </DialogHeader>
239
+
240
+ {/* Scrollable model roles */}
241
+ <div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
242
+ <div className="divide-y divide-border/15">
243
+ {MODEL_ROLES.map((role) => {
244
+ const source = collection?.modelSources?.[role] ?? "preset";
245
+ const effectiveValue = collection?.effectiveModels?.[role] ?? "";
246
+ const draftValue = draft[role];
247
+ const isOverride = source === "override";
248
+
249
+ return (
250
+ <div
251
+ className="grid gap-x-5 gap-y-3 px-6 py-4 lg:grid-cols-[180px_minmax(0,1fr)]"
252
+ key={role}
253
+ >
254
+ {/* Left: role info */}
255
+ <div className="space-y-1">
256
+ <div className="flex items-center gap-2">
257
+ <CpuIcon className="size-3.5 text-secondary/70" />
258
+ <h3 className="font-medium text-[13px]">
259
+ {ROLE_LABELS[role]}
260
+ </h3>
261
+ </div>
262
+ <p className="text-muted-foreground/50 text-xs leading-relaxed">
263
+ {ROLE_NOTES[role]}
264
+ </p>
265
+ </div>
266
+
267
+ {/* Right: controls */}
268
+ <div className="space-y-2.5">
269
+ {/* Source + effective model */}
270
+ <div className="flex items-baseline gap-2">
271
+ <span
272
+ className={`shrink-0 rounded px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.1em] ${
273
+ isOverride
274
+ ? "bg-secondary/15 text-secondary/80"
275
+ : "bg-muted/40 text-muted-foreground/50"
276
+ }`}
277
+ >
278
+ {isOverride ? "override" : "inherits"}
279
+ </span>
280
+ <span className="font-mono text-[9px] text-muted-foreground/35 uppercase tracking-[0.12em]">
281
+ effective
282
+ </span>
283
+ </div>
284
+ <p
285
+ className="break-all font-mono text-[11px] leading-relaxed text-foreground/70"
286
+ title={effectiveValue}
287
+ >
288
+ {effectiveValue}
289
+ </p>
290
+
291
+ {/* Code embed recommendation */}
292
+ {role === "embed" && showCodeRecommendation ? (
293
+ <button
294
+ className="flex w-full cursor-pointer items-center gap-2.5 rounded-md border border-secondary/15 bg-secondary/5 px-3 py-2 text-left transition-colors hover:border-secondary/25 hover:bg-secondary/8"
295
+ onClick={() =>
296
+ setDraft((current) => ({
297
+ ...current,
298
+ embed: CODE_EMBED_RECOMMENDATION,
299
+ }))
300
+ }
301
+ type="button"
302
+ >
303
+ <SparklesIcon className="size-3.5 shrink-0 text-secondary/60" />
304
+ <div className="min-w-0">
305
+ <p className="text-[11px] text-foreground/70">
306
+ Apply code-optimized embedding
307
+ </p>
308
+ <p className="truncate font-mono text-[10px] text-muted-foreground/40">
309
+ {CODE_EMBED_RECOMMENDATION}
310
+ </p>
311
+ </div>
312
+ </button>
313
+ ) : null}
314
+
315
+ {/* Input + reset */}
316
+ <div className="flex items-center gap-1.5">
317
+ <Input
318
+ className="h-8 border-border/20 bg-muted/10 font-mono text-[11px] placeholder:text-muted-foreground/25"
319
+ onChange={(event) =>
320
+ setDraft((current) => ({
321
+ ...current,
322
+ [role]: event.target.value,
323
+ }))
324
+ }
325
+ placeholder="Leave empty to inherit from preset"
326
+ value={draftValue}
327
+ />
328
+ <Button
329
+ className="size-8 shrink-0 border-border/20 text-muted-foreground/30 hover:text-muted-foreground/60"
330
+ disabled={draftValue.trim().length === 0}
331
+ onClick={() =>
332
+ setDraft((current) => ({
333
+ ...current,
334
+ [role]: "",
335
+ }))
336
+ }
337
+ size="icon-sm"
338
+ variant="outline"
339
+ >
340
+ <RotateCcwIcon className="size-3" />
341
+ </Button>
342
+ </div>
343
+ </div>
344
+ </div>
345
+ );
346
+ })}
347
+ </div>
348
+
349
+ {/* Warnings */}
350
+ {collection && embedChanged && collection.documentCount > 0 ? (
351
+ <div className="border-border/15 border-t px-6 py-3">
352
+ <div className="flex items-start gap-2.5 rounded-md border border-secondary/15 bg-secondary/5 px-3 py-2.5">
353
+ <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0 text-secondary/60" />
354
+ <div>
355
+ <p className="font-medium text-secondary/80 text-xs">
356
+ Re-index needed after save
357
+ </p>
358
+ <p className="mt-0.5 text-muted-foreground/50 text-xs">
359
+ {collection.documentCount} docs / {collection.chunkCount}{" "}
360
+ chunks will need re-embedding for the new model.
361
+ </p>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ ) : null}
366
+
367
+ {error ? (
368
+ <div className="border-border/15 border-t px-6 py-3">
369
+ <div className="rounded-md border border-destructive/25 bg-destructive/8 px-3 py-2.5 text-destructive text-xs">
370
+ {error}
371
+ </div>
372
+ </div>
373
+ ) : null}
374
+ </div>
375
+
376
+ {/* Footer — always visible */}
377
+ <DialogFooter className="shrink-0 border-border/20 border-t px-6 py-3">
378
+ <Button
379
+ className="border-border/20 text-xs"
380
+ onClick={() => onOpenChange(false)}
381
+ size="sm"
382
+ variant="outline"
383
+ >
384
+ Cancel
385
+ </Button>
386
+ <Button
387
+ className="text-xs"
388
+ disabled={!hasChanges || saving}
389
+ onClick={() => void handleSave()}
390
+ size="sm"
391
+ >
392
+ {saving ? <Loader2Icon className="size-3.5 animate-spin" /> : null}
393
+ Save model settings
394
+ </Button>
395
+ </DialogFooter>
396
+ </DialogContent>
397
+ </Dialog>
398
+ );
399
+ }