@gmickel/gno 0.37.0 → 0.38.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 +7 -89
- package/assets/skill/SKILL.md +22 -0
- package/assets/skill/cli-reference.md +5 -1
- package/package.json +1 -1
- package/src/cli/commands/collection/add.ts +6 -0
- package/src/cli/program.ts +2 -0
- package/src/collection/add.ts +1 -0
- package/src/collection/index.ts +2 -0
- package/src/collection/types.ts +13 -0
- package/src/collection/update.ts +93 -0
- package/src/llm/registry.ts +53 -0
- package/src/serve/public/components/CollectionModelDialog.tsx +397 -0
- package/src/serve/public/pages/Collections.tsx +96 -7
- package/src/serve/routes/api.ts +154 -17
- package/src/serve/server.ts +18 -1
package/README.md
CHANGED
|
@@ -85,45 +85,15 @@ gno daemon
|
|
|
85
85
|
|
|
86
86
|
---
|
|
87
87
|
|
|
88
|
-
## What's New
|
|
88
|
+
## What's New
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
- **Default Preset Upgrade**: `slim-tuned` is now the built-in default, using the fine-tuned retrieval expansion model while keeping the same embed, rerank, and answer stack as `slim`
|
|
93
|
-
- **Workspace UI Polish**: richer scholarly-dusk presentation across dashboard, tabs, search, ask, footer, and global styling without introducing external font or asset dependencies
|
|
90
|
+
> Latest release: [v0.37.0](./CHANGELOG.md#0370---2026-04-06)
|
|
91
|
+
> Full release history: [CHANGELOG.md](./CHANGELOG.md)
|
|
94
92
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
- **
|
|
98
|
-
- **
|
|
99
|
-
- **Web/Desktop UI Polish**: sharper workspace styling across dashboard, tabs, search, ask, and footer surfaces
|
|
100
|
-
|
|
101
|
-
## What's New in v0.31
|
|
102
|
-
|
|
103
|
-
- **Windows Desktop Beta Artifact**: release flow now includes a packaged `windows-x64` desktop beta zip, not just source-level support claims
|
|
104
|
-
- **Packaged Runtime Proof**: Windows desktop packaging validates bundled Bun + staged GNO runtime + FTS5 + vendored snowball + `sqlite-vec`
|
|
105
|
-
- **Scoped Index Fix**: `gno index <collection>` now embeds only that collection instead of accidentally burning through unrelated backlog from other collections
|
|
106
|
-
- **CLI Reporting Fix**: long embed runs now report sane durations instead of bogus sub-second summaries
|
|
107
|
-
|
|
108
|
-
### v0.24
|
|
109
|
-
|
|
110
|
-
- **Structured Query Documents**: first-class multi-line query syntax using `term:`, `intent:`, and `hyde:`
|
|
111
|
-
- **Cross-Surface Rollout**: works across CLI, API, MCP, SDK, and Web Search/Ask
|
|
112
|
-
- **Portable Retrieval Prompts**: save/share advanced retrieval intent as one text payload instead of repeated flags or JSON arrays
|
|
113
|
-
|
|
114
|
-
### v0.23
|
|
115
|
-
|
|
116
|
-
- **SDK / Library Mode**: package-root importable SDK with `createGnoClient(...)` for direct retrieval, document access, and indexing flows
|
|
117
|
-
- **Inline Config Support**: embed GNO in another app without writing YAML config files
|
|
118
|
-
- **Programmatic Indexing**: call `update`, `embed`, and `index` directly from Bun/TypeScript
|
|
119
|
-
- **Docs & Website**: dedicated SDK guide, feature page, homepage section, and architecture docs
|
|
120
|
-
|
|
121
|
-
### v0.22
|
|
122
|
-
|
|
123
|
-
- **Promoted Slim Retrieval Model**: published `slim-retrieval-v1` on Hugging Face for direct `hf:` installation in GNO
|
|
124
|
-
- **Fine-Tuning Workflow**: local MLX LoRA training, portable GGUF export, automatic checkpoint selection, promotion bundles, and repeatable benchmark comparisons
|
|
125
|
-
- **Autonomous Search Harness**: bounded candidate search with early-stop guards, repeated incumbent confirmation, and promotion targets
|
|
126
|
-
- **Public Docs & Site**: fine-tuned model docs and feature pages now point at the published HF model and the `slim-tuned` preset
|
|
93
|
+
- **Retrieval Quality Upgrade**: stronger BM25 lexical handling, code-aware chunking, terminal result hyperlinks, and per-collection model overrides
|
|
94
|
+
- **Code Embedding Benchmarks**: new benchmark workflow across canonical, real-GNO, and pinned OSS slices for comparing alternate embedding models
|
|
95
|
+
- **Recommended Code Embed Model**: docs and benchmark pages now point to `Qwen3-Embedding-0.6B-GGUF` as the current code-specialist embedding option
|
|
96
|
+
- **Regression Fixes**: tightened phrase/negation/hyphen/underscore BM25 behavior, cleaned non-TTY hyperlink output, improved `gno doctor` chunking visibility, and fixed the embedding autoresearch harness
|
|
127
97
|
|
|
128
98
|
### Fine-Tuned Model Quick Use
|
|
129
99
|
|
|
@@ -150,58 +120,6 @@ gno query "ECONNREFUSED 127.0.0.1:5432" --thorough
|
|
|
150
120
|
|
|
151
121
|
> Full guide: [Fine-Tuned Models](https://gno.sh/docs/FINE-TUNED-MODELS/) · [Feature page](https://gno.sh/features/fine-tuned-models/)
|
|
152
122
|
|
|
153
|
-
## What's New in v0.21
|
|
154
|
-
|
|
155
|
-
- **Ask CLI Query Modes**: `gno ask` now accepts repeatable `--query-mode term|intent|hyde` entries, matching the existing Ask API and Web controls
|
|
156
|
-
|
|
157
|
-
### v0.20
|
|
158
|
-
|
|
159
|
-
- **Improved Model Init Fallbacks**: upgraded `node-llama-cpp` to `3.17.1` and switched to `build: "autoAttempt"` for better backend selection/fallback behavior
|
|
160
|
-
|
|
161
|
-
### v0.19
|
|
162
|
-
|
|
163
|
-
- **Exclusion Filters**: explicit `exclude` controls across CLI, API, Web, and MCP to hard-prune unwanted docs by title/path/body text
|
|
164
|
-
- **Ask Query-Mode Parity**: Ask now supports structured `term` / `intent` / `hyde` controls in both API and Web UI
|
|
165
|
-
|
|
166
|
-
### v0.18
|
|
167
|
-
|
|
168
|
-
- **Intent Steering**: optional `intent` control for ambiguous queries across CLI, API, Web, and MCP query flows
|
|
169
|
-
- **Rerank Controls**: `candidateLimit` lets you tune rerank cost vs. recall on slower or memory-constrained machines
|
|
170
|
-
- **Stability**: query expansion now uses a bounded configurable context size (`models.expandContextSize`, default `2048`)
|
|
171
|
-
- **Rerank Efficiency**: identical chunk texts are deduplicated before scoring and expanded back out deterministically
|
|
172
|
-
|
|
173
|
-
### v0.17
|
|
174
|
-
|
|
175
|
-
- **Structured Query Modes**: `term`, `intent`, and `hyde` controls across CLI, API, MCP, and Web
|
|
176
|
-
- **Temporal Retrieval Upgrades**: `since`/`until`, date-range parsing, and recency sorting with frontmatter-date fallback
|
|
177
|
-
- **Web Retrieval UX Polish**: richer advanced controls in Search and Ask (collection/date/category/author/tags + query modes)
|
|
178
|
-
- **Metadata-Aware Retrieval**: ingestion now materializes document metadata/date fields for better filtering and ranking
|
|
179
|
-
- **Migration Reliability**: SQLite-compatible migration path for existing indexes (including older SQLite engines)
|
|
180
|
-
|
|
181
|
-
### v0.15
|
|
182
|
-
|
|
183
|
-
- **HTTP Backends**: Offload embedding, reranking, and generation to remote GPU servers
|
|
184
|
-
- Simple URI config: `http://host:port/path#modelname`
|
|
185
|
-
- Works with llama-server, Ollama, LocalAI, vLLM
|
|
186
|
-
- Run GNO on lightweight machines while GPU inference runs on your network
|
|
187
|
-
|
|
188
|
-
### v0.13
|
|
189
|
-
|
|
190
|
-
- **Knowledge Graph**: Interactive force-directed visualization of document connections
|
|
191
|
-
- **Graph with Similarity**: See semantic similarity as golden edges (not just wiki/markdown links)
|
|
192
|
-
- **CLI**: `gno graph` command with collection filtering and similarity options
|
|
193
|
-
- **Web UI**: `/graph` page with zoom, pan, collection filter, similarity toggle
|
|
194
|
-
- **MCP**: `gno_graph` tool for AI agents to explore document relationships
|
|
195
|
-
- **REST API**: `/api/graph` endpoint with full query parameters
|
|
196
|
-
|
|
197
|
-
### v0.12
|
|
198
|
-
|
|
199
|
-
- **Note Linking**: Wiki-style `[[links]]`, backlinks, and AI-powered related notes
|
|
200
|
-
- **Tag System**: Filter searches by frontmatter tags with `--tags-any`/`--tags-all`
|
|
201
|
-
- **Web UI**: Outgoing links panel, backlinks panel, related notes sidebar
|
|
202
|
-
- **CLI**: `gno links`, `gno backlinks`, `gno similar` commands
|
|
203
|
-
- **MCP**: `gno_links`, `gno_backlinks`, `gno_similar` tools
|
|
204
|
-
|
|
205
123
|
---
|
|
206
124
|
|
|
207
125
|
## Quick Start
|
package/assets/skill/SKILL.md
CHANGED
|
@@ -177,6 +177,28 @@ gno embed # Embed only (if already synced)
|
|
|
177
177
|
|
|
178
178
|
MCP `gno.sync` and `gno.capture` do NOT auto-embed. Use CLI for embedding.
|
|
179
179
|
|
|
180
|
+
## Collection-specific embedding models
|
|
181
|
+
|
|
182
|
+
Collections can override the global embedding model with `models.embed`.
|
|
183
|
+
|
|
184
|
+
CLI path:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
gno collection add ~/work/gno/src \
|
|
188
|
+
--name gno-code \
|
|
189
|
+
--embed-model "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Good default guidance:
|
|
193
|
+
|
|
194
|
+
- keep the global preset for mixed notes/docs collections
|
|
195
|
+
- use a collection-specific embed override for code-heavy collections when benchmark guidance says so
|
|
196
|
+
- after changing an embed model on an existing populated collection, run:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
gno embed --collection gno-code
|
|
200
|
+
```
|
|
201
|
+
|
|
180
202
|
## Reference Documentation
|
|
181
203
|
|
|
182
204
|
| Topic | File |
|
|
@@ -42,7 +42,11 @@ gno init [<path>] [options]
|
|
|
42
42
|
gno collection add <path> --name <name> [options]
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
Options same as `init
|
|
45
|
+
Options same as `init`, plus:
|
|
46
|
+
|
|
47
|
+
| Option | Description |
|
|
48
|
+
| --------------------- | ---------------------------------------------------- |
|
|
49
|
+
| `--embed-model <uri>` | Initial collection-specific embedding model override |
|
|
46
50
|
|
|
47
51
|
### gno collection list
|
|
48
52
|
|
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { CliError } from "../../errors";
|
|
13
13
|
|
|
14
14
|
interface AddOptions {
|
|
15
|
+
embedModel?: string;
|
|
15
16
|
name?: string;
|
|
16
17
|
pattern?: string;
|
|
17
18
|
include?: string;
|
|
@@ -51,6 +52,11 @@ export async function collectionAdd(
|
|
|
51
52
|
pattern: options.pattern,
|
|
52
53
|
include: options.include,
|
|
53
54
|
exclude: options.exclude,
|
|
55
|
+
models: options.embedModel
|
|
56
|
+
? {
|
|
57
|
+
embed: options.embedModel,
|
|
58
|
+
}
|
|
59
|
+
: undefined,
|
|
54
60
|
updateCmd: options.update,
|
|
55
61
|
});
|
|
56
62
|
|
package/src/cli/program.ts
CHANGED
|
@@ -1264,10 +1264,12 @@ function wireManagementCommands(program: Command): void {
|
|
|
1264
1264
|
.option("--pattern <glob>", "file matching pattern")
|
|
1265
1265
|
.option("--include <exts>", "extension allowlist (CSV)")
|
|
1266
1266
|
.option("--exclude <patterns>", "exclude patterns (CSV)")
|
|
1267
|
+
.option("--embed-model <uri>", "collection-specific embedding model URI")
|
|
1267
1268
|
.option("--update <cmd>", "shell command to run before indexing")
|
|
1268
1269
|
.action(async (path: string, cmdOpts: Record<string, unknown>) => {
|
|
1269
1270
|
const { collectionAdd } = await import("./commands/collection");
|
|
1270
1271
|
await collectionAdd(path, {
|
|
1272
|
+
embedModel: cmdOpts.embedModel as string | undefined,
|
|
1271
1273
|
name: cmdOpts.name as string,
|
|
1272
1274
|
pattern: cmdOpts.pattern as string | undefined,
|
|
1273
1275
|
include: cmdOpts.include as string | undefined,
|
package/src/collection/add.ts
CHANGED
package/src/collection/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
export { addCollection } from "./add";
|
|
9
9
|
export { removeCollection } from "./remove";
|
|
10
|
+
export { updateCollection } from "./update";
|
|
10
11
|
export type {
|
|
11
12
|
AddCollectionInput,
|
|
12
13
|
CollectionError,
|
|
@@ -14,4 +15,5 @@ export type {
|
|
|
14
15
|
CollectionSuccess,
|
|
15
16
|
RemoveCollectionInput,
|
|
16
17
|
RenameCollectionInput,
|
|
18
|
+
UpdateCollectionInput,
|
|
17
19
|
} from "./types";
|
package/src/collection/types.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Collection, Config } from "../config/types";
|
|
8
|
+
import type { ModelType } from "../llm/types";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Input for adding a collection.
|
|
@@ -22,6 +23,8 @@ export interface AddCollectionInput {
|
|
|
22
23
|
exclude?: string[] | string;
|
|
23
24
|
/** Update command to run before sync */
|
|
24
25
|
updateCmd?: string;
|
|
26
|
+
/** Optional initial model overrides */
|
|
27
|
+
models?: Partial<Record<ModelType, string>>;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -42,6 +45,16 @@ export interface RenameCollectionInput {
|
|
|
42
45
|
newName: string;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Input for updating a collection.
|
|
50
|
+
*/
|
|
51
|
+
export interface UpdateCollectionInput {
|
|
52
|
+
/** Collection name (case-insensitive) */
|
|
53
|
+
name: string;
|
|
54
|
+
/** Partial model override patch; null clears one role */
|
|
55
|
+
models?: Partial<Record<ModelType, string | null>>;
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
/**
|
|
46
59
|
* Successful collection operation result.
|
|
47
60
|
*/
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update collection core logic.
|
|
3
|
+
* Pure function that mutates config - caller handles I/O.
|
|
4
|
+
*
|
|
5
|
+
* @module src/collection/update
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Collection,
|
|
10
|
+
CollectionModelOverrides,
|
|
11
|
+
Config,
|
|
12
|
+
} from "../config/types";
|
|
13
|
+
import type { CollectionResult, UpdateCollectionInput } from "./types";
|
|
14
|
+
|
|
15
|
+
import { CollectionSchema } from "../config";
|
|
16
|
+
|
|
17
|
+
function normalizeOverrides(
|
|
18
|
+
models?: UpdateCollectionInput["models"]
|
|
19
|
+
): CollectionModelOverrides | undefined {
|
|
20
|
+
if (!models) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const entries = Object.entries(models).filter(
|
|
25
|
+
([, value]) => value !== undefined && value !== null
|
|
26
|
+
);
|
|
27
|
+
if (entries.length === 0) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return Object.fromEntries(entries) as CollectionModelOverrides;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Update a collection in config.
|
|
36
|
+
*/
|
|
37
|
+
export function updateCollection(
|
|
38
|
+
config: Config,
|
|
39
|
+
input: UpdateCollectionInput
|
|
40
|
+
): CollectionResult {
|
|
41
|
+
const collectionName = input.name.toLowerCase();
|
|
42
|
+
const index = config.collections.findIndex((c) => c.name === collectionName);
|
|
43
|
+
if (index < 0) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
code: "NOT_FOUND",
|
|
47
|
+
message: `Collection "${collectionName}" not found`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const current = config.collections[index];
|
|
52
|
+
if (!current) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
code: "NOT_FOUND",
|
|
56
|
+
message: `Collection "${collectionName}" not found`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let nextModels = current.models;
|
|
61
|
+
if (input.models) {
|
|
62
|
+
nextModels = normalizeOverrides({
|
|
63
|
+
...current.models,
|
|
64
|
+
...input.models,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const nextCollection: Collection = {
|
|
69
|
+
...current,
|
|
70
|
+
models: nextModels,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const validation = CollectionSchema.safeParse(nextCollection);
|
|
74
|
+
if (!validation.success) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
code: "VALIDATION",
|
|
78
|
+
message: `Invalid collection: ${validation.error.issues[0]?.message ?? "unknown error"}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nextCollections = [...config.collections];
|
|
83
|
+
nextCollections[index] = validation.data;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
config: {
|
|
88
|
+
...config,
|
|
89
|
+
collections: nextCollections,
|
|
90
|
+
},
|
|
91
|
+
collection: validation.data,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/llm/registry.ts
CHANGED
|
@@ -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
|
*/
|
|
@@ -0,0 +1,397 @@
|
|
|
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 && collectionLooksCodeHeavy(collection);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!open || !collection) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setDraft({
|
|
144
|
+
embed: collection.models?.embed ?? "",
|
|
145
|
+
rerank: collection.models?.rerank ?? "",
|
|
146
|
+
expand: collection.models?.expand ?? "",
|
|
147
|
+
gen: collection.models?.gen ?? "",
|
|
148
|
+
});
|
|
149
|
+
setError(null);
|
|
150
|
+
setSaving(false);
|
|
151
|
+
}, [collection, open]);
|
|
152
|
+
|
|
153
|
+
const patch = useMemo(() => {
|
|
154
|
+
if (!collection) {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const nextPatch: Partial<Record<ModelRole, string | null>> = {};
|
|
159
|
+
for (const role of MODEL_ROLES) {
|
|
160
|
+
const original = normalizeValue(collection.models?.[role]);
|
|
161
|
+
const current = normalizeValue(draft[role]);
|
|
162
|
+
if (original === current) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
nextPatch[role] = current.length === 0 ? null : current;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return nextPatch;
|
|
169
|
+
}, [collection, draft]);
|
|
170
|
+
|
|
171
|
+
const hasChanges = Object.keys(patch).length > 0;
|
|
172
|
+
const embedChanged = Object.hasOwn(patch, "embed");
|
|
173
|
+
|
|
174
|
+
const handleSave = async () => {
|
|
175
|
+
if (!collection || !hasChanges) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setSaving(true);
|
|
180
|
+
setError(null);
|
|
181
|
+
|
|
182
|
+
const { error: requestError } = await apiFetch<UpdateCollectionResponse>(
|
|
183
|
+
`/api/collections/${encodeURIComponent(collection.name)}`,
|
|
184
|
+
{
|
|
185
|
+
method: "PATCH",
|
|
186
|
+
body: JSON.stringify({ models: patch }),
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
setSaving(false);
|
|
191
|
+
|
|
192
|
+
if (requestError) {
|
|
193
|
+
setError(requestError);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
onSaved();
|
|
198
|
+
onOpenChange(false);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<Dialog onOpenChange={onOpenChange} open={open}>
|
|
203
|
+
<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)]">
|
|
204
|
+
{/* Header */}
|
|
205
|
+
<DialogHeader className="shrink-0 border-border/20 border-b px-6 pt-5 pb-4 text-left">
|
|
206
|
+
<div className="flex items-start justify-between gap-4">
|
|
207
|
+
<div className="min-w-0 space-y-1.5">
|
|
208
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
209
|
+
<span className="rounded bg-muted/40 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/70 uppercase tracking-[0.12em]">
|
|
210
|
+
Collection models
|
|
211
|
+
</span>
|
|
212
|
+
<span className="rounded bg-muted/30 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/50 tracking-[0.05em]">
|
|
213
|
+
preset: {collection?.activePresetId ?? "unknown"}
|
|
214
|
+
</span>
|
|
215
|
+
</div>
|
|
216
|
+
<DialogTitle className="font-[Iowan_Old_Style,Palatino_Linotype,Palatino,Book_Antiqua,Georgia,serif] text-2xl leading-tight">
|
|
217
|
+
{collection?.name ?? "Collection"}
|
|
218
|
+
</DialogTitle>
|
|
219
|
+
<DialogDescription className="text-muted-foreground/70 text-[13px]">
|
|
220
|
+
Override model roles for one collection without changing the
|
|
221
|
+
active preset for the rest of the workspace.
|
|
222
|
+
</DialogDescription>
|
|
223
|
+
</div>
|
|
224
|
+
<div className="hidden shrink-0 max-w-[200px] space-y-1 border-border/15 border-l pl-4 lg:block">
|
|
225
|
+
<p className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
|
|
226
|
+
Path
|
|
227
|
+
</p>
|
|
228
|
+
<p
|
|
229
|
+
className="break-all font-mono text-[10px] leading-relaxed text-muted-foreground/50"
|
|
230
|
+
title={collection?.path}
|
|
231
|
+
>
|
|
232
|
+
{collection?.path}
|
|
233
|
+
</p>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</DialogHeader>
|
|
237
|
+
|
|
238
|
+
{/* Scrollable model roles */}
|
|
239
|
+
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
|
240
|
+
<div className="divide-y divide-border/15">
|
|
241
|
+
{MODEL_ROLES.map((role) => {
|
|
242
|
+
const source = collection?.modelSources?.[role] ?? "preset";
|
|
243
|
+
const effectiveValue = collection?.effectiveModels?.[role] ?? "";
|
|
244
|
+
const draftValue = draft[role];
|
|
245
|
+
const isOverride = source === "override";
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div
|
|
249
|
+
className="grid gap-x-5 gap-y-3 px-6 py-4 lg:grid-cols-[180px_minmax(0,1fr)]"
|
|
250
|
+
key={role}
|
|
251
|
+
>
|
|
252
|
+
{/* Left: role info */}
|
|
253
|
+
<div className="space-y-1">
|
|
254
|
+
<div className="flex items-center gap-2">
|
|
255
|
+
<CpuIcon className="size-3.5 text-secondary/70" />
|
|
256
|
+
<h3 className="font-medium text-[13px]">
|
|
257
|
+
{ROLE_LABELS[role]}
|
|
258
|
+
</h3>
|
|
259
|
+
</div>
|
|
260
|
+
<p className="text-muted-foreground/50 text-xs leading-relaxed">
|
|
261
|
+
{ROLE_NOTES[role]}
|
|
262
|
+
</p>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
{/* Right: controls */}
|
|
266
|
+
<div className="space-y-2.5">
|
|
267
|
+
{/* Source + effective model */}
|
|
268
|
+
<div className="flex items-baseline gap-2">
|
|
269
|
+
<span
|
|
270
|
+
className={`shrink-0 rounded px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.1em] ${
|
|
271
|
+
isOverride
|
|
272
|
+
? "bg-secondary/15 text-secondary/80"
|
|
273
|
+
: "bg-muted/40 text-muted-foreground/50"
|
|
274
|
+
}`}
|
|
275
|
+
>
|
|
276
|
+
{isOverride ? "override" : "inherits"}
|
|
277
|
+
</span>
|
|
278
|
+
<span className="font-mono text-[9px] text-muted-foreground/35 uppercase tracking-[0.12em]">
|
|
279
|
+
effective
|
|
280
|
+
</span>
|
|
281
|
+
</div>
|
|
282
|
+
<p
|
|
283
|
+
className="break-all font-mono text-[11px] leading-relaxed text-foreground/70"
|
|
284
|
+
title={effectiveValue}
|
|
285
|
+
>
|
|
286
|
+
{effectiveValue}
|
|
287
|
+
</p>
|
|
288
|
+
|
|
289
|
+
{/* Code embed recommendation */}
|
|
290
|
+
{role === "embed" && showCodeRecommendation ? (
|
|
291
|
+
<button
|
|
292
|
+
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"
|
|
293
|
+
onClick={() =>
|
|
294
|
+
setDraft((current) => ({
|
|
295
|
+
...current,
|
|
296
|
+
embed: CODE_EMBED_RECOMMENDATION,
|
|
297
|
+
}))
|
|
298
|
+
}
|
|
299
|
+
type="button"
|
|
300
|
+
>
|
|
301
|
+
<SparklesIcon className="size-3.5 shrink-0 text-secondary/60" />
|
|
302
|
+
<div className="min-w-0">
|
|
303
|
+
<p className="text-[11px] text-foreground/70">
|
|
304
|
+
Apply code-optimized embedding
|
|
305
|
+
</p>
|
|
306
|
+
<p className="truncate font-mono text-[10px] text-muted-foreground/40">
|
|
307
|
+
{CODE_EMBED_RECOMMENDATION}
|
|
308
|
+
</p>
|
|
309
|
+
</div>
|
|
310
|
+
</button>
|
|
311
|
+
) : null}
|
|
312
|
+
|
|
313
|
+
{/* Input + reset */}
|
|
314
|
+
<div className="flex items-center gap-1.5">
|
|
315
|
+
<Input
|
|
316
|
+
className="h-8 border-border/20 bg-muted/10 font-mono text-[11px] placeholder:text-muted-foreground/25"
|
|
317
|
+
onChange={(event) =>
|
|
318
|
+
setDraft((current) => ({
|
|
319
|
+
...current,
|
|
320
|
+
[role]: event.target.value,
|
|
321
|
+
}))
|
|
322
|
+
}
|
|
323
|
+
placeholder="Leave empty to inherit from preset"
|
|
324
|
+
value={draftValue}
|
|
325
|
+
/>
|
|
326
|
+
<Button
|
|
327
|
+
className="size-8 shrink-0 border-border/20 text-muted-foreground/30 hover:text-muted-foreground/60"
|
|
328
|
+
disabled={draftValue.trim().length === 0}
|
|
329
|
+
onClick={() =>
|
|
330
|
+
setDraft((current) => ({
|
|
331
|
+
...current,
|
|
332
|
+
[role]: "",
|
|
333
|
+
}))
|
|
334
|
+
}
|
|
335
|
+
size="icon-sm"
|
|
336
|
+
variant="outline"
|
|
337
|
+
>
|
|
338
|
+
<RotateCcwIcon className="size-3" />
|
|
339
|
+
</Button>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
})}
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
{/* Warnings */}
|
|
348
|
+
{collection && embedChanged && collection.documentCount > 0 ? (
|
|
349
|
+
<div className="border-border/15 border-t px-6 py-3">
|
|
350
|
+
<div className="flex items-start gap-2.5 rounded-md border border-secondary/15 bg-secondary/5 px-3 py-2.5">
|
|
351
|
+
<AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0 text-secondary/60" />
|
|
352
|
+
<div>
|
|
353
|
+
<p className="font-medium text-secondary/80 text-xs">
|
|
354
|
+
Re-index needed after save
|
|
355
|
+
</p>
|
|
356
|
+
<p className="mt-0.5 text-muted-foreground/50 text-xs">
|
|
357
|
+
{collection.documentCount} docs / {collection.chunkCount}{" "}
|
|
358
|
+
chunks will need re-embedding for the new model.
|
|
359
|
+
</p>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
) : null}
|
|
364
|
+
|
|
365
|
+
{error ? (
|
|
366
|
+
<div className="border-border/15 border-t px-6 py-3">
|
|
367
|
+
<div className="rounded-md border border-destructive/25 bg-destructive/8 px-3 py-2.5 text-destructive text-xs">
|
|
368
|
+
{error}
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
) : null}
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
{/* Footer — always visible */}
|
|
375
|
+
<DialogFooter className="shrink-0 border-border/20 border-t px-6 py-3">
|
|
376
|
+
<Button
|
|
377
|
+
className="border-border/20 text-xs"
|
|
378
|
+
onClick={() => onOpenChange(false)}
|
|
379
|
+
size="sm"
|
|
380
|
+
variant="outline"
|
|
381
|
+
>
|
|
382
|
+
Cancel
|
|
383
|
+
</Button>
|
|
384
|
+
<Button
|
|
385
|
+
className="text-xs"
|
|
386
|
+
disabled={!hasChanges || saving}
|
|
387
|
+
onClick={() => void handleSave()}
|
|
388
|
+
size="sm"
|
|
389
|
+
>
|
|
390
|
+
{saving ? <Loader2Icon className="size-3.5 animate-spin" /> : null}
|
|
391
|
+
Save model settings
|
|
392
|
+
</Button>
|
|
393
|
+
</DialogFooter>
|
|
394
|
+
</DialogContent>
|
|
395
|
+
</Dialog>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import {
|
|
13
13
|
AlertCircleIcon,
|
|
14
14
|
ArrowLeftIcon,
|
|
15
|
+
CpuIcon,
|
|
15
16
|
DatabaseIcon,
|
|
16
17
|
FileTextIcon,
|
|
17
18
|
FolderIcon,
|
|
@@ -29,6 +30,10 @@ import type { AppStatusResponse } from "../../status-model";
|
|
|
29
30
|
|
|
30
31
|
import { AddCollectionDialog } from "../components/AddCollectionDialog";
|
|
31
32
|
import { Loader } from "../components/ai-elements/loader";
|
|
33
|
+
import {
|
|
34
|
+
CollectionModelDialog,
|
|
35
|
+
type CollectionModelDetails,
|
|
36
|
+
} from "../components/CollectionModelDialog";
|
|
32
37
|
import { CollectionsEmptyState } from "../components/CollectionsEmptyState";
|
|
33
38
|
import { IndexingProgress } from "../components/IndexingProgress";
|
|
34
39
|
import { Badge } from "../components/ui/badge";
|
|
@@ -67,11 +72,20 @@ interface PageProps {
|
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
interface CollectionStats {
|
|
75
|
+
activePresetId?: string;
|
|
70
76
|
name: string;
|
|
71
77
|
path: string;
|
|
72
78
|
documentCount: number;
|
|
73
79
|
chunkCount: number;
|
|
74
80
|
embeddedCount: number;
|
|
81
|
+
include?: string[];
|
|
82
|
+
models?: Partial<Record<"embed" | "rerank" | "expand" | "gen", string>>;
|
|
83
|
+
effectiveModels?: Record<"embed" | "rerank" | "expand" | "gen", string>;
|
|
84
|
+
modelSources?: Record<
|
|
85
|
+
"embed" | "rerank" | "expand" | "gen",
|
|
86
|
+
"override" | "preset" | "default"
|
|
87
|
+
>;
|
|
88
|
+
pattern?: string;
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
interface StatusResponse {
|
|
@@ -86,10 +100,13 @@ interface SyncResponse {
|
|
|
86
100
|
jobId: string;
|
|
87
101
|
}
|
|
88
102
|
|
|
103
|
+
interface CollectionsResponseItem extends CollectionModelDetails {}
|
|
104
|
+
|
|
89
105
|
interface CollectionCardProps {
|
|
90
106
|
actionsDisabled: boolean;
|
|
91
107
|
collection: CollectionStats;
|
|
92
108
|
onBrowse: () => void;
|
|
109
|
+
onModelSettings: () => void;
|
|
93
110
|
onReindex: () => void;
|
|
94
111
|
onRemove: () => void;
|
|
95
112
|
isReindexing: boolean;
|
|
@@ -118,6 +135,7 @@ function CollectionCard({
|
|
|
118
135
|
actionsDisabled,
|
|
119
136
|
collection,
|
|
120
137
|
onBrowse,
|
|
138
|
+
onModelSettings,
|
|
121
139
|
onReindex,
|
|
122
140
|
onRemove,
|
|
123
141
|
isReindexing,
|
|
@@ -164,6 +182,17 @@ function CollectionCard({
|
|
|
164
182
|
</Button>
|
|
165
183
|
</DropdownMenuTrigger>
|
|
166
184
|
<DropdownMenuContent align="end">
|
|
185
|
+
<DropdownMenuItem
|
|
186
|
+
disabled={actionsDisabled}
|
|
187
|
+
onClick={(event) => {
|
|
188
|
+
event.stopPropagation();
|
|
189
|
+
onModelSettings();
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<CpuIcon className="mr-2 size-4" />
|
|
193
|
+
Model settings
|
|
194
|
+
</DropdownMenuItem>
|
|
195
|
+
<DropdownMenuSeparator />
|
|
167
196
|
<DropdownMenuItem
|
|
168
197
|
disabled={actionsDisabled || isReindexing}
|
|
169
198
|
onClick={(event) => {
|
|
@@ -244,6 +273,27 @@ function CollectionCard({
|
|
|
244
273
|
</div>
|
|
245
274
|
)}
|
|
246
275
|
|
|
276
|
+
{collection.modelSources ? (
|
|
277
|
+
<div className="mt-3 flex flex-wrap items-center gap-2 border-border/30 border-t pt-3">
|
|
278
|
+
{(["embed", "rerank", "expand", "gen"] as const).map((role) => {
|
|
279
|
+
const source = collection.modelSources?.[role];
|
|
280
|
+
if (source !== "override") {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<Badge
|
|
286
|
+
className="font-mono text-[10px] uppercase tracking-[0.12em]"
|
|
287
|
+
key={role}
|
|
288
|
+
variant="secondary"
|
|
289
|
+
>
|
|
290
|
+
{role} override
|
|
291
|
+
</Badge>
|
|
292
|
+
);
|
|
293
|
+
})}
|
|
294
|
+
</div>
|
|
295
|
+
) : null}
|
|
296
|
+
|
|
247
297
|
<div className="mt-3 border-border/40 border-t pt-3 text-muted-foreground text-xs">
|
|
248
298
|
Click card to browse documents in this collection.
|
|
249
299
|
</div>
|
|
@@ -268,19 +318,46 @@ export default function Collections({ navigate }: PageProps) {
|
|
|
268
318
|
);
|
|
269
319
|
const [removing, setRemoving] = useState(false);
|
|
270
320
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
|
321
|
+
const [modelDialogCollection, setModelDialogCollection] =
|
|
322
|
+
useState<CollectionStats | null>(null);
|
|
271
323
|
const [initialCollectionPath, setInitialCollectionPath] = useState<
|
|
272
324
|
string | undefined
|
|
273
325
|
>(undefined);
|
|
274
326
|
|
|
275
327
|
const loadCollections = useCallback(async () => {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
setError(
|
|
328
|
+
const [statusResult, collectionsResult] = await Promise.all([
|
|
329
|
+
apiFetch<StatusResponse>("/api/status"),
|
|
330
|
+
apiFetch<CollectionsResponseItem[]>("/api/collections"),
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
if (statusResult.error) {
|
|
334
|
+
setError(statusResult.error);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!statusResult.data) {
|
|
339
|
+
return;
|
|
283
340
|
}
|
|
341
|
+
|
|
342
|
+
const collectionsByName = new Map(
|
|
343
|
+
(collectionsResult.data ?? []).map((item) => [item.name, item] as const)
|
|
344
|
+
);
|
|
345
|
+
const merged = statusResult.data.collections.map((collection) => {
|
|
346
|
+
const config = collectionsByName.get(collection.name);
|
|
347
|
+
return {
|
|
348
|
+
...collection,
|
|
349
|
+
activePresetId: config?.activePresetId,
|
|
350
|
+
effectiveModels: config?.effectiveModels,
|
|
351
|
+
include: config?.include,
|
|
352
|
+
models: config?.models,
|
|
353
|
+
modelSources: config?.modelSources,
|
|
354
|
+
pattern: config?.pattern,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
setCollections(merged);
|
|
359
|
+
setOnboarding(statusResult.data.onboarding);
|
|
360
|
+
setError(collectionsResult.error ?? null);
|
|
284
361
|
}, []);
|
|
285
362
|
|
|
286
363
|
// Initial load
|
|
@@ -500,6 +577,7 @@ export default function Collections({ navigate }: PageProps) {
|
|
|
500
577
|
`/browse?collection=${encodeURIComponent(collection.name)}`
|
|
501
578
|
)
|
|
502
579
|
}
|
|
580
|
+
onModelSettings={() => setModelDialogCollection(collection)}
|
|
503
581
|
onReindex={() => void handleReindex(collection.name)}
|
|
504
582
|
onRemove={() => setRemoveDialog(collection)}
|
|
505
583
|
/>
|
|
@@ -516,6 +594,17 @@ export default function Collections({ navigate }: PageProps) {
|
|
|
516
594
|
open={addDialogOpen}
|
|
517
595
|
/>
|
|
518
596
|
|
|
597
|
+
<CollectionModelDialog
|
|
598
|
+
collection={modelDialogCollection}
|
|
599
|
+
onOpenChange={(open) => {
|
|
600
|
+
if (!open) {
|
|
601
|
+
setModelDialogCollection(null);
|
|
602
|
+
}
|
|
603
|
+
}}
|
|
604
|
+
onSaved={() => void loadCollections()}
|
|
605
|
+
open={!!modelDialogCollection}
|
|
606
|
+
/>
|
|
607
|
+
|
|
519
608
|
{/* Remove confirmation dialog */}
|
|
520
609
|
<Dialog
|
|
521
610
|
onOpenChange={(open) => !open && setRemoveDialog(null)}
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -10,8 +10,12 @@ import { readdir } from "node:fs/promises";
|
|
|
10
10
|
// node:path has no Bun equivalent
|
|
11
11
|
import { posix as pathPosix } from "node:path";
|
|
12
12
|
|
|
13
|
-
import type {
|
|
14
|
-
|
|
13
|
+
import type {
|
|
14
|
+
Collection,
|
|
15
|
+
CollectionModelOverrides,
|
|
16
|
+
Config,
|
|
17
|
+
ModelPreset,
|
|
18
|
+
} from "../../config/types";
|
|
15
19
|
import type {
|
|
16
20
|
AskResult,
|
|
17
21
|
Citation,
|
|
@@ -26,7 +30,11 @@ import type { StartJobError } from "../jobs";
|
|
|
26
30
|
import type { CollectionWatchService } from "../watch-service";
|
|
27
31
|
|
|
28
32
|
import { modelsPull } from "../../cli/commands/models/pull";
|
|
29
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
addCollection,
|
|
35
|
+
removeCollection,
|
|
36
|
+
updateCollection,
|
|
37
|
+
} from "../../collection";
|
|
30
38
|
import {
|
|
31
39
|
buildEditableCopyContent,
|
|
32
40
|
deriveEditableCopyRelPath,
|
|
@@ -68,7 +76,13 @@ import {
|
|
|
68
76
|
import { validateRelPath } from "../../core/validation";
|
|
69
77
|
import { defaultSyncService, type SyncResult } from "../../ingestion";
|
|
70
78
|
import { updateFrontmatterTags } from "../../ingestion/frontmatter";
|
|
71
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
getCollectionEffectiveModels,
|
|
81
|
+
getCollectionModelSources,
|
|
82
|
+
getModelConfig,
|
|
83
|
+
getPreset,
|
|
84
|
+
listPresets,
|
|
85
|
+
} from "../../llm/registry";
|
|
72
86
|
import {
|
|
73
87
|
generateGroundedAnswer,
|
|
74
88
|
processAnswerResult,
|
|
@@ -189,6 +203,59 @@ export interface CreateCollectionRequestBody {
|
|
|
189
203
|
gitPull?: boolean;
|
|
190
204
|
}
|
|
191
205
|
|
|
206
|
+
export interface UpdateCollectionRequestBody {
|
|
207
|
+
models?: {
|
|
208
|
+
embed?: string | null;
|
|
209
|
+
rerank?: string | null;
|
|
210
|
+
expand?: string | null;
|
|
211
|
+
gen?: string | null;
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface CollectionResponse {
|
|
216
|
+
name: string;
|
|
217
|
+
path: string;
|
|
218
|
+
pattern: string;
|
|
219
|
+
include: string[];
|
|
220
|
+
exclude: string[];
|
|
221
|
+
updateCmd?: string;
|
|
222
|
+
languageHint?: string;
|
|
223
|
+
models?: CollectionModelOverrides;
|
|
224
|
+
effectiveModels: {
|
|
225
|
+
embed: string;
|
|
226
|
+
rerank: string;
|
|
227
|
+
expand: string;
|
|
228
|
+
gen: string;
|
|
229
|
+
};
|
|
230
|
+
modelSources: {
|
|
231
|
+
embed: "override" | "preset" | "default";
|
|
232
|
+
rerank: "override" | "preset" | "default";
|
|
233
|
+
expand: "override" | "preset" | "default";
|
|
234
|
+
gen: "override" | "preset" | "default";
|
|
235
|
+
};
|
|
236
|
+
activePresetId: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function serializeCollection(
|
|
240
|
+
config: Config,
|
|
241
|
+
collection: Collection
|
|
242
|
+
): CollectionResponse {
|
|
243
|
+
const modelConfig = getModelConfig(config);
|
|
244
|
+
return {
|
|
245
|
+
name: collection.name,
|
|
246
|
+
path: collection.path,
|
|
247
|
+
pattern: collection.pattern,
|
|
248
|
+
include: collection.include,
|
|
249
|
+
exclude: collection.exclude,
|
|
250
|
+
updateCmd: collection.updateCmd,
|
|
251
|
+
languageHint: collection.languageHint,
|
|
252
|
+
models: collection.models,
|
|
253
|
+
effectiveModels: getCollectionEffectiveModels(config, collection.name),
|
|
254
|
+
modelSources: getCollectionModelSources(config, collection.name),
|
|
255
|
+
activePresetId: modelConfig.activePreset,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
192
259
|
export interface ImportPreviewRequestBody {
|
|
193
260
|
path: string;
|
|
194
261
|
name?: string;
|
|
@@ -630,19 +697,9 @@ export async function handleStatus(
|
|
|
630
697
|
* GET /api/collections
|
|
631
698
|
* Returns list of collections.
|
|
632
699
|
*/
|
|
633
|
-
export async function handleCollections(
|
|
634
|
-
store: SqliteAdapter
|
|
635
|
-
): Promise<Response> {
|
|
636
|
-
const result = await store.getCollections();
|
|
637
|
-
if (!result.ok) {
|
|
638
|
-
return errorResponse("RUNTIME", result.error.message, 500);
|
|
639
|
-
}
|
|
640
|
-
|
|
700
|
+
export async function handleCollections(config: Config): Promise<Response> {
|
|
641
701
|
return jsonResponse(
|
|
642
|
-
|
|
643
|
-
name: c.name,
|
|
644
|
-
path: c.path,
|
|
645
|
-
}))
|
|
702
|
+
config.collections.map((c) => serializeCollection(config, c))
|
|
646
703
|
);
|
|
647
704
|
}
|
|
648
705
|
|
|
@@ -850,6 +907,86 @@ export async function handleDeleteCollection(
|
|
|
850
907
|
});
|
|
851
908
|
}
|
|
852
909
|
|
|
910
|
+
/**
|
|
911
|
+
* PATCH /api/collections/:name
|
|
912
|
+
* Update collection model overrides.
|
|
913
|
+
*/
|
|
914
|
+
export async function handleUpdateCollection(
|
|
915
|
+
ctxHolder: ContextHolder,
|
|
916
|
+
store: SqliteAdapter,
|
|
917
|
+
name: string,
|
|
918
|
+
req: Request
|
|
919
|
+
): Promise<Response> {
|
|
920
|
+
let body: UpdateCollectionRequestBody;
|
|
921
|
+
try {
|
|
922
|
+
body = (await req.json()) as UpdateCollectionRequestBody;
|
|
923
|
+
} catch {
|
|
924
|
+
return errorResponse("VALIDATION", "Invalid JSON body");
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (body.models !== undefined && typeof body.models !== "object") {
|
|
928
|
+
return errorResponse("VALIDATION", "models must be an object");
|
|
929
|
+
}
|
|
930
|
+
if (!body.models) {
|
|
931
|
+
return errorResponse("VALIDATION", "Missing models patch");
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
for (const [role, value] of Object.entries(body.models)) {
|
|
935
|
+
if (!["embed", "rerank", "expand", "gen"].includes(role)) {
|
|
936
|
+
return errorResponse("VALIDATION", `Unknown model role: ${role}`);
|
|
937
|
+
}
|
|
938
|
+
if (value !== undefined && value !== null && typeof value !== "string") {
|
|
939
|
+
return errorResponse("VALIDATION", `${role} must be a string or null`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const syncResult = await applyConfigChangeTyped<Collection>(
|
|
944
|
+
ctxHolder,
|
|
945
|
+
store,
|
|
946
|
+
(cfg) => {
|
|
947
|
+
const result = updateCollection(cfg, {
|
|
948
|
+
name,
|
|
949
|
+
models: body.models,
|
|
950
|
+
});
|
|
951
|
+
if (!result.ok) {
|
|
952
|
+
return Promise.resolve({
|
|
953
|
+
ok: false as const,
|
|
954
|
+
error: result.message,
|
|
955
|
+
code: result.code,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
return Promise.resolve({
|
|
959
|
+
ok: true as const,
|
|
960
|
+
config: result.config,
|
|
961
|
+
value: result.collection,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
if (!syncResult.ok) {
|
|
967
|
+
const statusMap: Record<string, number> = {
|
|
968
|
+
NOT_FOUND: 404,
|
|
969
|
+
VALIDATION: 400,
|
|
970
|
+
};
|
|
971
|
+
const status = statusMap[syncResult.code] ?? 500;
|
|
972
|
+
return errorResponse(syncResult.code, syncResult.error, status);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const collection =
|
|
976
|
+
syncResult.value ??
|
|
977
|
+
syncResult.config.collections.find(
|
|
978
|
+
(item) => item.name === name.toLowerCase()
|
|
979
|
+
);
|
|
980
|
+
if (!collection) {
|
|
981
|
+
return errorResponse("RUNTIME", "Collection not found after update", 500);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
return jsonResponse({
|
|
985
|
+
success: true,
|
|
986
|
+
collection: serializeCollection(syncResult.config, collection),
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
853
990
|
/**
|
|
854
991
|
* POST /api/sync
|
|
855
992
|
* Trigger re-index of all or specific collection.
|
|
@@ -3577,7 +3714,7 @@ export async function routeApi(
|
|
|
3577
3714
|
}
|
|
3578
3715
|
|
|
3579
3716
|
if (path === "/api/collections") {
|
|
3580
|
-
return handleCollections(
|
|
3717
|
+
return handleCollections(config);
|
|
3581
3718
|
}
|
|
3582
3719
|
|
|
3583
3720
|
if (path === "/api/docs") {
|
package/src/serve/server.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
handleSync,
|
|
52
52
|
handleTags,
|
|
53
53
|
handleTrashDoc,
|
|
54
|
+
handleUpdateCollection,
|
|
54
55
|
handleUpdateDoc,
|
|
55
56
|
} from "./routes/api";
|
|
56
57
|
import { handleGraph } from "./routes/graph";
|
|
@@ -192,7 +193,10 @@ export async function startServer(
|
|
|
192
193
|
},
|
|
193
194
|
"/api/collections": {
|
|
194
195
|
GET: async () =>
|
|
195
|
-
withSecurityHeaders(
|
|
196
|
+
withSecurityHeaders(
|
|
197
|
+
await handleCollections(ctxHolder.config),
|
|
198
|
+
isDev
|
|
199
|
+
),
|
|
196
200
|
POST: async (req: Request) => {
|
|
197
201
|
if (!isRequestAllowed(req, port)) {
|
|
198
202
|
return withSecurityHeaders(forbiddenResponse(), isDev);
|
|
@@ -516,6 +520,19 @@ export async function startServer(
|
|
|
516
520
|
withSecurityHeaders(handleEmbedStatus(ctxHolder.scheduler), isDev),
|
|
517
521
|
},
|
|
518
522
|
"/api/collections/:name": {
|
|
523
|
+
PATCH: async (req: Request) => {
|
|
524
|
+
if (!isRequestAllowed(req, port)) {
|
|
525
|
+
return withSecurityHeaders(forbiddenResponse(), isDev);
|
|
526
|
+
}
|
|
527
|
+
const url = new URL(req.url);
|
|
528
|
+
const name = decodeURIComponent(
|
|
529
|
+
url.pathname.split("/").pop() || ""
|
|
530
|
+
);
|
|
531
|
+
return withSecurityHeaders(
|
|
532
|
+
await handleUpdateCollection(ctxHolder, store, name, req),
|
|
533
|
+
isDev
|
|
534
|
+
);
|
|
535
|
+
},
|
|
519
536
|
DELETE: async (req: Request) => {
|
|
520
537
|
if (!isRequestAllowed(req, port)) {
|
|
521
538
|
return withSecurityHeaders(forbiddenResponse(), isDev);
|