@pellux/goodvibes-tui 0.20.2 → 0.21.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/CHANGELOG.md +33 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +3 -2
- package/src/audio/spoken-turn-controller.ts +31 -1
- package/src/audio/spoken-turn-wiring.ts +26 -4
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/completions/generate.ts +662 -0
- package/src/cli/config-overrides.ts +68 -0
- package/src/cli/help.ts +4 -2
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management.ts +1 -8
- package/src/cli/parser.ts +14 -18
- package/src/cli/service-command.ts +1 -1
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/tui-startup.ts +72 -10
- package/src/cli/types.ts +12 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/read-versioned.ts +115 -0
- package/src/core/conversation-rendering.ts +49 -15
- package/src/core/conversation.ts +101 -16
- package/src/core/format-user-error.ts +192 -0
- package/src/core/stream-event-wiring.ts +144 -0
- package/src/core/stream-stall-watchdog.ts +103 -0
- package/src/core/system-message-router.ts +5 -1
- package/src/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +31 -1
- package/src/input/commands/control-room-runtime.ts +5 -5
- package/src/input/commands/experience-runtime.ts +5 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +27 -5
- package/src/input/commands/local-setup.ts +4 -6
- package/src/input/commands/memory-product-runtime.ts +8 -6
- package/src/input/commands/operator-panel-runtime.ts +1 -1
- package/src/input/commands/operator-runtime.ts +3 -10
- package/src/input/commands/platform-sandbox-qemu.ts +60 -16
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- package/src/input/commands/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +3 -3
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -2
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +2 -15
- package/src/input/handler-modal-routes.ts +91 -12
- package/src/input/handler-modal-token-routes.ts +3 -0
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +55 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +5 -2
- package/src/input/input-history.ts +76 -6
- package/src/input/model-picker-filter.ts +265 -0
- package/src/input/model-picker-items.ts +208 -0
- package/src/input/model-picker.ts +92 -325
- package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
- package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
- package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
- package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
- package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
- package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-data.ts +304 -0
- package/src/input/settings-modal-mutations.ts +154 -0
- package/src/input/settings-modal.ts +182 -220
- package/src/main.ts +57 -57
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +4 -1
- package/src/panels/confirm-state.ts +27 -12
- package/src/panels/cost-tracker-panel.ts +23 -67
- package/src/panels/eval-panel.ts +10 -9
- package/src/panels/knowledge-panel.ts +3 -5
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- package/src/panels/subscription-panel.ts +33 -25
- package/src/panels/types.ts +28 -1
- package/src/panels/wrfc-panel.ts +224 -41
- package/src/renderer/agent-detail-modal.ts +11 -10
- package/src/renderer/code-block.ts +10 -2
- package/src/renderer/compositor.ts +18 -4
- package/src/renderer/context-inspector.ts +1 -5
- package/src/renderer/diff.ts +94 -21
- package/src/renderer/markdown.ts +29 -13
- package/src/renderer/settings-modal-helpers.ts +1 -1
- package/src/renderer/settings-modal.ts +77 -8
- package/src/renderer/syntax-highlighter.ts +10 -3
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/theme.ts +158 -0
- package/src/renderer/tool-call.ts +12 -2
- package/src/renderer/ui-factory.ts +50 -6
- package/src/runtime/bootstrap-command-context.ts +1 -0
- package/src/runtime/bootstrap-command-parts.ts +14 -0
- package/src/runtime/bootstrap-core.ts +121 -13
- package/src/runtime/bootstrap.ts +2 -0
- package/src/runtime/onboarding/apply.ts +4 -6
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/markers.ts +42 -49
- package/src/runtime/onboarding/progress.ts +148 -0
- package/src/runtime/onboarding/state.ts +133 -55
- package/src/runtime/onboarding/types.ts +20 -0
- package/src/runtime/sandbox-qemu-templates.ts +15 -0
- package/src/runtime/services.ts +21 -0
- package/src/runtime/wrfc-persistence.ts +237 -0
- package/src/shell/blocking-input.ts +20 -5
- package/src/tools/wrfc-agent-guard.ts +64 -3
- package/src/utils/format-elapsed.ts +30 -0
- package/src/utils/terminal-width.ts +45 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +4 -6
- package/src/planning/project-planning-coordinator.ts +0 -543
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { atomicWriteFileSync } from '@/config/atomic-write.ts';
|
|
3
4
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
4
5
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
5
6
|
|
|
@@ -100,11 +101,39 @@ export class HistorySearch {
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A redaction rule applied to command text before it is stored or saved.
|
|
106
|
+
* Any match of `pattern` in the raw text is replaced with `replacement`.
|
|
107
|
+
*/
|
|
108
|
+
export interface HistoryRedactionRule {
|
|
109
|
+
readonly pattern: RegExp;
|
|
110
|
+
readonly replacement: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Built-in redaction rules that apply regardless of caller-supplied rules.
|
|
115
|
+
* Scrubs passwords from local-auth commands before they reach disk.
|
|
116
|
+
*/
|
|
117
|
+
const BUILTIN_REDACTION_RULES: readonly HistoryRedactionRule[] = [
|
|
118
|
+
{
|
|
119
|
+
// /auth local add-user <user> <password> [roles]
|
|
120
|
+
pattern: /(\/auth\s+local\s+add-user\s+\S+)\s+(\S+)/i,
|
|
121
|
+
replacement: '$1 <redacted>',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
// /auth local rotate-password <user> <password>
|
|
125
|
+
pattern: /(\/auth\s+local\s+rotate-password\s+\S+)\s+(\S+)/i,
|
|
126
|
+
replacement: '$1 <redacted>',
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
|
|
103
130
|
export interface InputHistoryOptions {
|
|
104
131
|
readonly historyPath?: string;
|
|
105
132
|
readonly userRoot?: string;
|
|
106
133
|
readonly homeDirectory?: string;
|
|
107
134
|
readonly persist?: boolean;
|
|
135
|
+
/** Additional redaction rules applied on top of the built-in set. */
|
|
136
|
+
readonly redactionRules?: readonly HistoryRedactionRule[];
|
|
108
137
|
}
|
|
109
138
|
|
|
110
139
|
type StoredInputHistoryEntry = string | {
|
|
@@ -130,25 +159,44 @@ export class InputHistory {
|
|
|
130
159
|
private maxEntries = 500;
|
|
131
160
|
private historyPath: string;
|
|
132
161
|
private persist: boolean;
|
|
162
|
+
private redactionRules: readonly HistoryRedactionRule[];
|
|
133
163
|
|
|
134
164
|
constructor(options: InputHistoryOptions) {
|
|
135
165
|
this.persist = options.persist ?? true;
|
|
136
166
|
this.historyPath = resolveHistoryPath(options);
|
|
167
|
+
this.redactionRules = [
|
|
168
|
+
...BUILTIN_REDACTION_RULES,
|
|
169
|
+
...(options.redactionRules ?? []),
|
|
170
|
+
];
|
|
137
171
|
if (this.persist) {
|
|
138
172
|
this.load();
|
|
139
173
|
}
|
|
140
174
|
}
|
|
141
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Apply all active redaction rules to a text string.
|
|
178
|
+
* Returns the scrubbed text (password arguments replaced with `<redacted>`).
|
|
179
|
+
*/
|
|
180
|
+
private applyRedaction(text: string): string {
|
|
181
|
+
let result = text;
|
|
182
|
+
for (const rule of this.redactionRules) {
|
|
183
|
+
result = result.replace(rule.pattern, rule.replacement);
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
142
188
|
/**
|
|
143
189
|
* Add a new entry. Called on submit.
|
|
144
190
|
* - Ignores empty/whitespace-only strings.
|
|
145
191
|
* - Deduplicates consecutive identical entries.
|
|
192
|
+
* - Applies redaction rules to scrub sensitive arguments before persistence.
|
|
146
193
|
* - Resets browsing position.
|
|
147
194
|
*/
|
|
148
195
|
add(text: string, options: { readonly recallText?: string } = {}): void {
|
|
149
|
-
const trimmed = text.trim();
|
|
196
|
+
const trimmed = this.applyRedaction(text.trim());
|
|
150
197
|
if (!trimmed) return;
|
|
151
|
-
const
|
|
198
|
+
const rawRecallText = options.recallText?.trim();
|
|
199
|
+
const recallText = rawRecallText ? this.applyRedaction(rawRecallText) : undefined;
|
|
152
200
|
const entry: StoredInputHistoryEntry = recallText && recallText !== trimmed
|
|
153
201
|
? { text: trimmed, recallText }
|
|
154
202
|
: trimmed;
|
|
@@ -240,8 +288,7 @@ export class InputHistory {
|
|
|
240
288
|
*/
|
|
241
289
|
save(): void {
|
|
242
290
|
try {
|
|
243
|
-
|
|
244
|
-
writeFileSync(this.historyPath, JSON.stringify(this.entries), 'utf-8');
|
|
291
|
+
atomicWriteFileSync(this.historyPath, JSON.stringify(this.entries), { mkdirp: true });
|
|
245
292
|
} catch (err) {
|
|
246
293
|
logger.debug('InputHistory save failed (non-fatal)', { error: summarizeError(err) });
|
|
247
294
|
}
|
|
@@ -249,6 +296,10 @@ export class InputHistory {
|
|
|
249
296
|
|
|
250
297
|
/**
|
|
251
298
|
* Load history from disk.
|
|
299
|
+
*
|
|
300
|
+
* Redaction is applied to every loaded entry so that cleartext passwords
|
|
301
|
+
* persisted before the redaction rules were deployed are scrubbed on first
|
|
302
|
+
* load and will not be re-persisted on the next save().
|
|
252
303
|
*/
|
|
253
304
|
load(): void {
|
|
254
305
|
try {
|
|
@@ -259,6 +310,7 @@ export class InputHistory {
|
|
|
259
310
|
this.entries = (parsed as unknown[])
|
|
260
311
|
.map((entry) => this.normalizeStoredEntry(entry))
|
|
261
312
|
.filter((entry): entry is StoredInputHistoryEntry => entry !== null)
|
|
313
|
+
.map((entry) => this.redactEntry(entry))
|
|
262
314
|
.slice(0, this.maxEntries);
|
|
263
315
|
}
|
|
264
316
|
}
|
|
@@ -281,6 +333,24 @@ export class InputHistory {
|
|
|
281
333
|
&& this.getRecallText(a) === this.getRecallText(b);
|
|
282
334
|
}
|
|
283
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Apply redaction rules to a loaded entry, scrubbing any sensitive text that
|
|
338
|
+
* was persisted before redaction was deployed.
|
|
339
|
+
*/
|
|
340
|
+
private redactEntry(entry: StoredInputHistoryEntry): StoredInputHistoryEntry {
|
|
341
|
+
if (typeof entry === 'string') {
|
|
342
|
+
return this.applyRedaction(entry);
|
|
343
|
+
}
|
|
344
|
+
const redactedText = this.applyRedaction(entry.text);
|
|
345
|
+
const redactedRecallText = entry.recallText !== undefined
|
|
346
|
+
? this.applyRedaction(entry.recallText)
|
|
347
|
+
: undefined;
|
|
348
|
+
if (redactedRecallText !== undefined && redactedRecallText !== redactedText) {
|
|
349
|
+
return { text: redactedText, recallText: redactedRecallText };
|
|
350
|
+
}
|
|
351
|
+
return redactedText;
|
|
352
|
+
}
|
|
353
|
+
|
|
284
354
|
private normalizeStoredEntry(entry: unknown): StoredInputHistoryEntry | null {
|
|
285
355
|
if (typeof entry === 'string') return entry;
|
|
286
356
|
if (!entry || typeof entry !== 'object') return null;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-picker-filter — pure filtering, sorting, and cache-key helpers for ModelPickerModal.
|
|
3
|
+
*
|
|
4
|
+
* All functions are stateless: they receive inputs and return results without
|
|
5
|
+
* side-effects. The class in model-picker.ts uses these as delegates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers';
|
|
9
|
+
import { compositeScore, A_TIER_THRESHOLD } from '@pellux/goodvibes-sdk/platform/providers';
|
|
10
|
+
import type { BenchmarkStore } from '@pellux/goodvibes-sdk/platform/providers';
|
|
11
|
+
import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
|
|
12
|
+
import { tierToCategoryFilter } from './model-picker-types.ts';
|
|
13
|
+
import type {
|
|
14
|
+
BenchmarkSort,
|
|
15
|
+
CapabilityFilter,
|
|
16
|
+
CategoryFilter,
|
|
17
|
+
FilteredModelsCache,
|
|
18
|
+
FilteredProvidersCache,
|
|
19
|
+
GroupByMode,
|
|
20
|
+
} from './model-picker-types.ts';
|
|
21
|
+
import { filterProviders } from './model-picker-provider-filter.ts';
|
|
22
|
+
|
|
23
|
+
export { filterProviders };
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Cache key helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export function setKey(values: ReadonlySet<string>): string {
|
|
30
|
+
if (values.size === 0) return '';
|
|
31
|
+
return [...values].sort().join('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function orderedListKey(values: readonly string[]): string {
|
|
35
|
+
return values.length === 0 ? '' : values.join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mapKey(values: ReadonlyMap<string, string | undefined>): string {
|
|
39
|
+
if (values.size === 0) return '';
|
|
40
|
+
return [...values.entries()]
|
|
41
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
42
|
+
.map(([key, value]) => `${key}${value ?? ''}`)
|
|
43
|
+
.join('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Synthetic sub-group classification
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export function getSyntheticSubgroup(
|
|
51
|
+
model: ModelDefinition,
|
|
52
|
+
providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
|
|
53
|
+
): 'top' | 'all' {
|
|
54
|
+
const info = providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
|
|
55
|
+
const score = info?.bestCompositeScore ?? null;
|
|
56
|
+
return score !== null && score >= A_TIER_THRESHOLD ? 'top' : 'all';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// buildFilteredModels
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export interface FilterModelParams {
|
|
64
|
+
readonly models: ModelDefinition[];
|
|
65
|
+
readonly configuredProviders: ReadonlySet<string>;
|
|
66
|
+
readonly pinnedIds: ReadonlySet<string>;
|
|
67
|
+
readonly recentIds: readonly string[];
|
|
68
|
+
readonly query: string;
|
|
69
|
+
readonly categoryFilter: CategoryFilter;
|
|
70
|
+
readonly capabilityFilter: CapabilityFilter;
|
|
71
|
+
readonly availableOnly: boolean;
|
|
72
|
+
readonly benchmarkSort: BenchmarkSort;
|
|
73
|
+
readonly groupBy: GroupByMode;
|
|
74
|
+
readonly benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>;
|
|
75
|
+
readonly providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildFilteredModels(
|
|
79
|
+
params: FilterModelParams,
|
|
80
|
+
cache: FilteredModelsCache | null,
|
|
81
|
+
): { result: ModelDefinition[]; cache: FilteredModelsCache } {
|
|
82
|
+
const {
|
|
83
|
+
models,
|
|
84
|
+
configuredProviders,
|
|
85
|
+
pinnedIds,
|
|
86
|
+
recentIds,
|
|
87
|
+
query,
|
|
88
|
+
categoryFilter,
|
|
89
|
+
capabilityFilter,
|
|
90
|
+
availableOnly,
|
|
91
|
+
benchmarkSort,
|
|
92
|
+
groupBy,
|
|
93
|
+
benchmarkStore,
|
|
94
|
+
providerRegistry,
|
|
95
|
+
} = params;
|
|
96
|
+
|
|
97
|
+
const configuredProvidersKey = setKey(configuredProviders);
|
|
98
|
+
const pinnedIdsKey = setKey(pinnedIds);
|
|
99
|
+
const recentIdsKey = orderedListKey(recentIds);
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
cache !== null
|
|
103
|
+
&& cache.modelsRef === models
|
|
104
|
+
&& cache.configuredProvidersKey === configuredProvidersKey
|
|
105
|
+
&& cache.pinnedIdsKey === pinnedIdsKey
|
|
106
|
+
&& cache.recentIdsKey === recentIdsKey
|
|
107
|
+
&& cache.query === query
|
|
108
|
+
&& cache.categoryFilter === categoryFilter
|
|
109
|
+
&& cache.capabilityFilter === capabilityFilter
|
|
110
|
+
&& cache.availableOnly === availableOnly
|
|
111
|
+
&& cache.benchmarkSort === benchmarkSort
|
|
112
|
+
&& cache.groupBy === groupBy
|
|
113
|
+
) {
|
|
114
|
+
return { result: cache.result, cache };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let result = models;
|
|
118
|
+
|
|
119
|
+
// Available-only filter
|
|
120
|
+
if (availableOnly && configuredProviders.size > 0) {
|
|
121
|
+
result = result.filter(m => configuredProviders.has(m.provider));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Pricing tier / category filter
|
|
125
|
+
if (categoryFilter === 'free') {
|
|
126
|
+
result = result.filter(m => m.tier === 'free');
|
|
127
|
+
} else if (categoryFilter === 'paid') {
|
|
128
|
+
result = result.filter(m => m.tier === 'standard' || m.tier === 'premium' || m.tier == null);
|
|
129
|
+
} else if (categoryFilter === 'subscription') {
|
|
130
|
+
result = result.filter(m => tierToCategoryFilter(m.tier) === 'subscription');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Capability filter
|
|
134
|
+
if (capabilityFilter === 'reasoning') {
|
|
135
|
+
result = result.filter(m => m.capabilities?.reasoning === true);
|
|
136
|
+
} else if (capabilityFilter === 'toolUse') {
|
|
137
|
+
result = result.filter(m => m.capabilities?.toolCalling === true);
|
|
138
|
+
} else if (capabilityFilter === 'multimodal') {
|
|
139
|
+
result = result.filter(m => m.capabilities?.multimodal === true);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Query filter — fuzzy: every space-separated word must appear somewhere
|
|
143
|
+
if (query.trim().length > 0) {
|
|
144
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
145
|
+
result = result.filter(m => {
|
|
146
|
+
const haystack = `${m.id} ${m.displayName} ${m.provider}`.toLowerCase();
|
|
147
|
+
return words.every(w => haystack.includes(w));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Benchmark sort
|
|
152
|
+
if (benchmarkSort !== 'none') {
|
|
153
|
+
result = [...result].sort((a, b) => {
|
|
154
|
+
let scoreA: number | null = null;
|
|
155
|
+
let scoreB: number | null = null;
|
|
156
|
+
|
|
157
|
+
if (benchmarkSort === 'composite') {
|
|
158
|
+
if (a.provider === 'synthetic') {
|
|
159
|
+
scoreA = providerRegistry.getSyntheticModelInfoFromCatalog(a.id)?.bestCompositeScore ?? null;
|
|
160
|
+
} else {
|
|
161
|
+
const bA = benchmarkStore.getBenchmarks(a.id) ?? benchmarkStore.getBenchmarks(a.displayName);
|
|
162
|
+
scoreA = bA ? compositeScore(bA.benchmarks) : null;
|
|
163
|
+
}
|
|
164
|
+
if (b.provider === 'synthetic') {
|
|
165
|
+
scoreB = providerRegistry.getSyntheticModelInfoFromCatalog(b.id)?.bestCompositeScore ?? null;
|
|
166
|
+
} else {
|
|
167
|
+
const bB = benchmarkStore.getBenchmarks(b.id) ?? benchmarkStore.getBenchmarks(b.displayName);
|
|
168
|
+
scoreB = bB ? compositeScore(bB.benchmarks) : null;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
// swe/gpqa sort — individual scores not available for synthetic models
|
|
172
|
+
const bA = a.provider === 'synthetic' ? null : (benchmarkStore.getBenchmarks(a.id) ?? benchmarkStore.getBenchmarks(a.displayName));
|
|
173
|
+
const bB = b.provider === 'synthetic' ? null : (benchmarkStore.getBenchmarks(b.id) ?? benchmarkStore.getBenchmarks(b.displayName));
|
|
174
|
+
if (benchmarkSort === 'swe') {
|
|
175
|
+
scoreA = bA?.benchmarks.swe ?? null;
|
|
176
|
+
scoreB = bB?.benchmarks.swe ?? null;
|
|
177
|
+
} else if (benchmarkSort === 'gpqa') {
|
|
178
|
+
scoreA = bA?.benchmarks.gpqa ?? null;
|
|
179
|
+
scoreB = bB?.benchmarks.gpqa ?? null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Models with no score sink to the end
|
|
183
|
+
if (scoreA == null && scoreB == null) return 0;
|
|
184
|
+
if (scoreA == null) return 1;
|
|
185
|
+
if (scoreB == null) return -1;
|
|
186
|
+
return scoreB - scoreA; // descending
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Synthetic sub-grouping: when groupBy is 'provider', order synthetic models so that
|
|
191
|
+
// "Top Models" (score >= 0.65) appear before "All Synthetic"
|
|
192
|
+
if (groupBy === 'provider' && benchmarkSort === 'none') {
|
|
193
|
+
const nonSynthetic = result.filter(m => m.provider !== 'synthetic');
|
|
194
|
+
const synthetic = result.filter(m => m.provider === 'synthetic');
|
|
195
|
+
|
|
196
|
+
if (synthetic.length > 0) {
|
|
197
|
+
const topModels = synthetic.filter(m => getSyntheticSubgroup(m, providerRegistry) === 'top');
|
|
198
|
+
const allModels = synthetic.filter(m => getSyntheticSubgroup(m, providerRegistry) === 'all');
|
|
199
|
+
|
|
200
|
+
// Sort top models by composite score descending
|
|
201
|
+
topModels.sort((a, b) => {
|
|
202
|
+
const sA = providerRegistry.getSyntheticModelInfoFromCatalog(a.id)?.bestCompositeScore ?? null;
|
|
203
|
+
const sB = providerRegistry.getSyntheticModelInfoFromCatalog(b.id)?.bestCompositeScore ?? null;
|
|
204
|
+
if (sA == null && sB == null) return 0;
|
|
205
|
+
if (sA == null) return 1;
|
|
206
|
+
if (sB == null) return -1;
|
|
207
|
+
return sB - sA;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Sort remaining alphabetically by id
|
|
211
|
+
allModels.sort((a, b) => a.id.localeCompare(b.id));
|
|
212
|
+
|
|
213
|
+
result = [...nonSynthetic, ...topModels, ...allModels];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Boost recent (non-pinned) models to the front
|
|
218
|
+
if (recentIds.length > 0) {
|
|
219
|
+
const recentSet = new Set(recentIds);
|
|
220
|
+
const recent = recentIds
|
|
221
|
+
.filter(id => result.some(m => m.id === id && !pinnedIds.has(id)))
|
|
222
|
+
.map(id => result.find(m => m.id === id)!)
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
const rest = result.filter(m => !recentSet.has(m.id) || pinnedIds.has(m.id));
|
|
225
|
+
result = [...recent, ...rest];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const newCache: FilteredModelsCache = {
|
|
229
|
+
modelsRef: models,
|
|
230
|
+
configuredProvidersKey,
|
|
231
|
+
pinnedIdsKey,
|
|
232
|
+
recentIdsKey,
|
|
233
|
+
query,
|
|
234
|
+
categoryFilter,
|
|
235
|
+
capabilityFilter,
|
|
236
|
+
availableOnly,
|
|
237
|
+
benchmarkSort,
|
|
238
|
+
groupBy,
|
|
239
|
+
result,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return { result, cache: newCache };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// buildFilteredProviders (cache-aware)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
export function buildFilteredProviders(
|
|
250
|
+
providers: string[],
|
|
251
|
+
query: string,
|
|
252
|
+
cache: FilteredProvidersCache | null,
|
|
253
|
+
): { result: string[]; cache: FilteredProvidersCache } {
|
|
254
|
+
if (
|
|
255
|
+
cache !== null
|
|
256
|
+
&& cache.providersRef === providers
|
|
257
|
+
&& cache.query === query
|
|
258
|
+
) {
|
|
259
|
+
return { result: cache.result, cache };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const result = filterProviders(providers, query);
|
|
263
|
+
const newCache: FilteredProvidersCache = { providersRef: providers, query, result };
|
|
264
|
+
return { result, cache: newCache };
|
|
265
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-picker-items — pure item-building helpers for ModelPickerModal.
|
|
3
|
+
*
|
|
4
|
+
* Converts filtered ModelDefinition/provider arrays into PickerItem lists
|
|
5
|
+
* with group headers, quality-tier badges, pin markers, and configured-via flags.
|
|
6
|
+
* All functions are stateless.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers';
|
|
10
|
+
import { EFFORT_DESCRIPTIONS, getQualityTier, getQualityTierFromScore } from '@pellux/goodvibes-sdk/platform/providers';
|
|
11
|
+
import type { BenchmarkStore } from '@pellux/goodvibes-sdk/platform/providers';
|
|
12
|
+
import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
|
|
13
|
+
import {
|
|
14
|
+
POPULAR_PROVIDERS,
|
|
15
|
+
detectFamily,
|
|
16
|
+
tierToCategoryFilter,
|
|
17
|
+
} from './model-picker-types.ts';
|
|
18
|
+
import type {
|
|
19
|
+
GroupByMode,
|
|
20
|
+
ModelItemsCache,
|
|
21
|
+
PickerItem,
|
|
22
|
+
ProviderItemsCache,
|
|
23
|
+
} from './model-picker-types.ts';
|
|
24
|
+
import { getSyntheticSubgroup, setKey, mapKey } from './model-picker-filter.ts';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// getModelGroupKey
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export function getModelGroupKey(
|
|
31
|
+
model: ModelDefinition,
|
|
32
|
+
groupBy: GroupByMode,
|
|
33
|
+
providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
|
|
34
|
+
benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>,
|
|
35
|
+
): string {
|
|
36
|
+
switch (groupBy) {
|
|
37
|
+
case 'provider':
|
|
38
|
+
if (model.provider === 'synthetic') {
|
|
39
|
+
return getSyntheticSubgroup(model, providerRegistry) === 'top' ? 'Top Models' : 'All Synthetic';
|
|
40
|
+
}
|
|
41
|
+
return model.provider;
|
|
42
|
+
case 'family':
|
|
43
|
+
return detectFamily(model);
|
|
44
|
+
case 'pricingTier':
|
|
45
|
+
return tierToCategoryFilter(model.tier);
|
|
46
|
+
case 'qualityTier': {
|
|
47
|
+
if (model.provider === 'synthetic') {
|
|
48
|
+
const info = providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
|
|
49
|
+
return info?.bestCompositeScore != null ? getQualityTierFromScore(info.bestCompositeScore) : 'C';
|
|
50
|
+
}
|
|
51
|
+
const b = benchmarkStore.getBenchmarks(model.id) ?? benchmarkStore.getBenchmarks(model.displayName);
|
|
52
|
+
return b ? getQualityTier(b.benchmarks) : 'C';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// toModelItem — single model -> PickerItem
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
export function toModelItem(
|
|
62
|
+
model: ModelDefinition,
|
|
63
|
+
isPinned: boolean,
|
|
64
|
+
providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
|
|
65
|
+
benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>,
|
|
66
|
+
): PickerItem {
|
|
67
|
+
let qualityTier: string | undefined;
|
|
68
|
+
let detail: string;
|
|
69
|
+
|
|
70
|
+
if (model.provider === 'synthetic') {
|
|
71
|
+
const synthInfo = providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
|
|
72
|
+
if (synthInfo?.bestCompositeScore != null) {
|
|
73
|
+
qualityTier = getQualityTierFromScore(synthInfo.bestCompositeScore);
|
|
74
|
+
}
|
|
75
|
+
detail = synthInfo !== null
|
|
76
|
+
? `${model.provider} [${synthInfo.keyedBackendCount} provider${synthInfo.keyedBackendCount !== 1 ? 's' : ''}]`
|
|
77
|
+
: model.provider;
|
|
78
|
+
} else {
|
|
79
|
+
detail = model.provider;
|
|
80
|
+
const b = benchmarkStore.getBenchmarks(model.id) ?? benchmarkStore.getBenchmarks(model.displayName);
|
|
81
|
+
qualityTier = b ? getQualityTier(b.benchmarks) : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isFree = tierToCategoryFilter(model.tier) === 'free';
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id: model.id,
|
|
88
|
+
label: model.displayName,
|
|
89
|
+
detail,
|
|
90
|
+
qualityTier,
|
|
91
|
+
isPinned,
|
|
92
|
+
isFree,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// buildModelItems — filtered models -> PickerItem[] with group headers
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
export function buildModelItems(
|
|
101
|
+
filtered: ModelDefinition[],
|
|
102
|
+
pinnedIds: ReadonlySet<string>,
|
|
103
|
+
groupBy: GroupByMode,
|
|
104
|
+
providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
|
|
105
|
+
benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>,
|
|
106
|
+
cache: ModelItemsCache | null,
|
|
107
|
+
): { result: PickerItem[]; cache: ModelItemsCache } {
|
|
108
|
+
const pinnedIdsKey = setKey(pinnedIds);
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
cache !== null
|
|
112
|
+
&& cache.filteredModelsRef === filtered
|
|
113
|
+
&& cache.pinnedIdsKey === pinnedIdsKey
|
|
114
|
+
&& cache.groupBy === groupBy
|
|
115
|
+
) {
|
|
116
|
+
return { result: cache.result, cache };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const pinned = filtered.filter(m => pinnedIds.has(m.id));
|
|
120
|
+
const unpinned = filtered.filter(m => !pinnedIds.has(m.id));
|
|
121
|
+
|
|
122
|
+
const items: PickerItem[] = [];
|
|
123
|
+
|
|
124
|
+
// Pinned section header (only if pinned models are in the filtered list)
|
|
125
|
+
if (pinned.length > 0) {
|
|
126
|
+
items.push({ id: '__header__pinned', label: 'Favorites', isGroupHeader: true });
|
|
127
|
+
for (const m of pinned) {
|
|
128
|
+
items.push(toModelItem(m, true, providerRegistry, benchmarkStore));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Grouped unpinned models
|
|
133
|
+
let lastGroupKey = '';
|
|
134
|
+
for (const m of unpinned) {
|
|
135
|
+
const groupKey = getModelGroupKey(m, groupBy, providerRegistry, benchmarkStore);
|
|
136
|
+
if (groupKey !== lastGroupKey) {
|
|
137
|
+
items.push({ id: `__header__${groupKey}`, label: groupKey, isGroupHeader: true });
|
|
138
|
+
lastGroupKey = groupKey;
|
|
139
|
+
}
|
|
140
|
+
items.push(toModelItem(m, false, providerRegistry, benchmarkStore));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const newCache: ModelItemsCache = {
|
|
144
|
+
filteredModelsRef: filtered,
|
|
145
|
+
pinnedIdsKey,
|
|
146
|
+
groupBy,
|
|
147
|
+
result: items,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return { result: items, cache: newCache };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// buildProviderItems — filtered providers -> PickerItem[] with group headers
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
export function buildProviderItems(
|
|
158
|
+
filteredProviders: string[],
|
|
159
|
+
configuredProviders: ReadonlySet<string>,
|
|
160
|
+
configuredViaMap: ReadonlyMap<string, 'env' | 'secrets' | 'subscription' | 'anonymous'>,
|
|
161
|
+
cache: ProviderItemsCache | null,
|
|
162
|
+
): { result: PickerItem[]; cache: ProviderItemsCache } {
|
|
163
|
+
const configuredProvidersKey = setKey(configuredProviders);
|
|
164
|
+
const configuredViaKey = mapKey(configuredViaMap);
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
cache !== null
|
|
168
|
+
&& cache.filteredProvidersRef === filteredProviders
|
|
169
|
+
&& cache.configuredProvidersKey === configuredProvidersKey
|
|
170
|
+
&& cache.configuredViaKey === configuredViaKey
|
|
171
|
+
) {
|
|
172
|
+
return { result: cache.result, cache };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const items: PickerItem[] = [];
|
|
176
|
+
let currentGroup: 'Popular' | 'All Providers' | null = null;
|
|
177
|
+
|
|
178
|
+
for (const p of filteredProviders) {
|
|
179
|
+
const group: 'Popular' | 'All Providers' = POPULAR_PROVIDERS.has(p.toLowerCase()) ? 'Popular' : 'All Providers';
|
|
180
|
+
if (group !== currentGroup) {
|
|
181
|
+
items.push({ id: `__header__${group}`, label: group, isGroupHeader: true });
|
|
182
|
+
currentGroup = group;
|
|
183
|
+
}
|
|
184
|
+
items.push({
|
|
185
|
+
id: p,
|
|
186
|
+
label: p,
|
|
187
|
+
isConfigured: configuredProviders.has(p),
|
|
188
|
+
configuredVia: configuredViaMap.get(p),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const newCache: ProviderItemsCache = {
|
|
193
|
+
filteredProvidersRef: filteredProviders,
|
|
194
|
+
configuredProvidersKey,
|
|
195
|
+
configuredViaKey,
|
|
196
|
+
result: items,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return { result: items, cache: newCache };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// buildEffortItems — effort levels -> PickerItem[]
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
export function buildEffortItems(effortLevels: readonly string[]): PickerItem[] {
|
|
207
|
+
return effortLevels.map(e => ({ id: e, label: e, detail: EFFORT_DESCRIPTIONS[e] ?? '' }));
|
|
208
|
+
}
|