mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-27 21:51:09 +02:00
feat(x): one model-selection experience — unified settings, provider lifecycle, split-view picker
Task slots (config + runtime): - taskModels gains backgroundTask and subagent; getBackgroundTaskAgentModel resolves its own override instead of mirroring live-notes (migration materializes the old mirror rule so effective models are unchanged) - spawn-agent precedence: explicit per-spawn args > configured subagent override > parent turn's model - repo.setProvider rejects auth-derived flavors (rowboat/codex) and merges over existing entries so key rotation keeps contextLength/reasoningEffort Models settings, rebuilt: - ModelSelectionSection: ONE Assistant model picker (with an Unavailable badge when the saved model drops off its provider's live list) + all seven tasks defaulting to "Same as Assistant" with resolve subtext and a clear-override link. Replaces both the signed-in and BYOK screens; the "Auto (recommended)" sentinel and both hardcoded preferredDefaults maps are gone — connect-time resolution uses backend recommendations - ProvidersSection replaces the old ModelSettings card grid: status cards (Connected · N models / error + Retry), an add-provider dialog (choose → creds → loading → first-vs-more result states → error with manual-model fallback), and a manage dialog (masked key + Replace, endpoint, Refresh models, Used-by list, disconnect with consequences spelled out). Rowboat and ChatGPT are cards in the same list - connecting a provider seeds the assistant ONLY when none is configured — never replaces a saved choice - models:getConfig IPC serves selections + credential-free provider metadata; the probe machinery (use-provider-models, liveCredentials) is deleted — the add flow validates credentials click-driven Onboarding: - screen 2 is the same ProvidersSection (onboarding variant) for BOTH paths: "Add more providers" after sign-in, "Connect a model provider" otherwise; Continue gates on an assistant model existing. The tile grid, per-provider key forms, and ~350 lines of duplicate state are deleted; one step sequence replaces the two path-dependent indicators Picker: - split-view browse (Popover + cmdk Command): providers left with counts and error dots, selected provider's models right, ←/→ switches provider, ↑/↓ navigates, typing collapses to a flat cross-provider list that matches provider NAMES as well as model ids (searching "rowboat" now works). Scoped/static pickers stay flat; public API unchanged Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f39daa9f5d
commit
c695ddf1c1
24 changed files with 1599 additions and 1917 deletions
|
|
@ -29,7 +29,7 @@ export interface CatalogModelEntry {
|
|||
|
||||
export interface CatalogProviderEntry {
|
||||
/**
|
||||
* Provider INSTANCE identifier — what ModelRef.provider, defaultSelection,
|
||||
* Provider INSTANCE identifier — what ModelRef.provider, assistantModel,
|
||||
* task overrides, and refreshProvider all reference. Today one instance
|
||||
* exists per flavor, so id always equals the flavor key ("openai",
|
||||
* "ollama", "rowboat", …); a future multi-key setup ("openai-work" /
|
||||
|
|
|
|||
|
|
@ -107,12 +107,18 @@ export async function getChatTitleModel(): Promise<ModelSelection> {
|
|||
return getCategoryModel("chatTitle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by the background-task agent + routing classifier. Currently
|
||||
* mirrors `getLiveNoteAgentModel()` — both surfaces want a fast, reliable
|
||||
* agent model. Split into its own getter so a future per-feature override
|
||||
* doesn't require touching all call sites.
|
||||
*/
|
||||
/** Model used by the background-task agent + routing classifier. */
|
||||
export async function getBackgroundTaskAgentModel(): Promise<ModelSelection> {
|
||||
return getLiveNoteAgentModel();
|
||||
return getCategoryModel("backgroundTask");
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit subagent model override, or null to inherit the PARENT turn's
|
||||
* model (spawn-agent's default — which is the assistant for a top-level
|
||||
* chat). Not getCategoryModel: the no-override fallback is the parent, not
|
||||
* the assistant, and the caller owns that resolution.
|
||||
*/
|
||||
export async function getSubagentModelOverride(): Promise<ModelSelection | null> {
|
||||
const cfg = await readConfig();
|
||||
return cfg?.taskModels?.subagent ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ describe('migrateModelsConfig', () => {
|
|||
taskModels: {
|
||||
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
// v1 background tasks mirrored the live-note model.
|
||||
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
// chat titles used flash-lite because the assistant routes
|
||||
// through the gateway; meeting notes used the curated default
|
||||
|
|
@ -59,6 +61,7 @@ describe('migrateModelsConfig', () => {
|
|||
expect(v2?.taskModels).toEqual({
|
||||
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
// Meeting notes used the curated gateway default, which now
|
||||
// differs from the (BYOK) assistant — preserved explicitly.
|
||||
|
|
@ -83,6 +86,18 @@ describe('migrateModelsConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('a v1 live-note override propagates to backgroundTask (v1 bg tasks mirrored live-note)', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
model: 'gpt-5.4',
|
||||
liveNoteAgentModel: { provider: 'ollama', model: 'qwen3' },
|
||||
};
|
||||
expect(migrateModelsConfig(v1, false)?.taskModels).toEqual({
|
||||
liveNoteAgent: { provider: 'ollama', model: 'qwen3' },
|
||||
backgroundTask: { provider: 'ollama', model: 'qwen3' },
|
||||
});
|
||||
});
|
||||
|
||||
it('an override equal to the assistant is dropped (inherit produces the same model)', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
|
|
|
|||
|
|
@ -131,11 +131,15 @@ export function migrateModelsConfig(rawInput: unknown, signedIn: boolean): V2 |
|
|||
const assistantModel = v1EffectiveAssistant(raw, signedIn);
|
||||
|
||||
// Old effective model per task, via the deleted v1 rules.
|
||||
const liveNoteEffective = v1Override(raw, "liveNoteAgentModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel);
|
||||
const oldTaskModels: Record<string, Ref | undefined> = {
|
||||
knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
|
||||
liveNoteAgent: v1Override(raw, "liveNoteAgentModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
|
||||
liveNoteAgent: liveNoteEffective,
|
||||
// v1 had no backgroundTask key — getBackgroundTaskAgentModel mirrored
|
||||
// the live-note model (override included). v2 gives it its own slot.
|
||||
backgroundTask: liveNoteEffective,
|
||||
autoPermissionDecision: v1Override(raw, "autoPermissionDecisionModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
|
||||
meetingNotes: v1Override(raw, "meetingNotesModel", signedIn)
|
||||
|
|
|
|||
|
|
@ -90,8 +90,20 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
}
|
||||
|
||||
async setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void> {
|
||||
// The credential-less flavors are never stored: their connection IS
|
||||
// their auth token store (oauth.json / chatgpt-auth.json), and the
|
||||
// catalog derives their presence from auth state — a providers-map
|
||||
// entry would double-list them.
|
||||
if (provider.flavor === "rowboat" || provider.flavor === "codex") {
|
||||
throw new Error(`Provider flavor '${provider.flavor}' is auth-derived and cannot be stored in models.json`);
|
||||
}
|
||||
const config = await this.read();
|
||||
config.providers[id] = LlmProvider.parse(provider);
|
||||
// Merge over an existing entry: replacing a key must not wipe
|
||||
// hand-tuned connection prefs (contextLength, reasoningEffort).
|
||||
config.providers[id] = LlmProvider.parse({
|
||||
...config.providers[id],
|
||||
...provider,
|
||||
});
|
||||
await this.write(config);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import type { TurnEvent, TurnState } from "@x/shared/dist/turns.js";
|
||||
import type { ITurnRuntime } from "../turns/api.js";
|
||||
|
|
@ -10,6 +10,16 @@ import type {
|
|||
} from "./headless.js";
|
||||
import { runSpawnedAgent } from "./spawn-agent.js";
|
||||
|
||||
// spawn-agent reads the configured subagent override lazily from
|
||||
// models/defaults.js; stub it so tests control the whole precedence chain
|
||||
// (explicit args > configured override > parent model).
|
||||
const subagentOverride = vi.hoisted(() => ({
|
||||
value: null as { provider: string; model: string } | null,
|
||||
}));
|
||||
vi.mock("../../models/defaults.js", () => ({
|
||||
getSubagentModelOverride: async () => subagentOverride.value,
|
||||
}));
|
||||
|
||||
const TS = "2026-07-07T10:00:00Z";
|
||||
|
||||
function parentCreated(
|
||||
|
|
@ -95,6 +105,34 @@ function fakeServices(opts: {
|
|||
const signal = new AbortController().signal;
|
||||
|
||||
describe("runSpawnedAgent", () => {
|
||||
beforeEach(() => {
|
||||
subagentOverride.value = null;
|
||||
});
|
||||
|
||||
it("uses the configured subagent override when no explicit model is passed", async () => {
|
||||
subagentOverride.value = { provider: "ollama", model: "qwen3" };
|
||||
const { services, started } = fakeServices({});
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[0].agent).toMatchObject({
|
||||
inline: { model: { provider: "ollama", model: "qwen3" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("explicit per-spawn model args beat the configured override", async () => {
|
||||
subagentOverride.value = { provider: "ollama", model: "qwen3" };
|
||||
const { services, started } = fakeServices({});
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x", model: "gpt-5.4", provider: "openai" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[0].agent).toMatchObject({
|
||||
inline: { model: { provider: "openai", model: "gpt-5.4" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("runs an inline child on the parent's model and returns the result envelope", async () => {
|
||||
const { services, started } = fakeServices({});
|
||||
const progress: unknown[] = [];
|
||||
|
|
|
|||
|
|
@ -135,12 +135,15 @@ export async function runSpawnedAgent(
|
|||
parentModel = undefined;
|
||||
}
|
||||
|
||||
// Model precedence: explicit per-spawn args > the user's configured
|
||||
// subagent override (taskModels.subagent) > the parent turn's model.
|
||||
const subagentOverride = input.model ? null : await readSubagentOverride();
|
||||
const model: z.infer<typeof ModelDescriptor> | undefined = input.model
|
||||
? {
|
||||
provider: input.provider ?? parentModel?.provider ?? "",
|
||||
model: input.model,
|
||||
}
|
||||
: parentModel;
|
||||
: subagentOverride ?? parentModel;
|
||||
if (model && !model.provider) {
|
||||
return spawnError(
|
||||
"`model` was set but no provider could be determined; pass `provider` too",
|
||||
|
|
@ -232,6 +235,21 @@ export async function runSpawnedAgent(
|
|||
};
|
||||
}
|
||||
|
||||
// Lazy for the same import-cycle reason as resolveServices; best-effort —
|
||||
// an unreadable config means "no override", never a failed spawn.
|
||||
async function readSubagentOverride(): Promise<
|
||||
z.infer<typeof ModelDescriptor> | null
|
||||
> {
|
||||
try {
|
||||
const { getSubagentModelOverride } = await import(
|
||||
"../../models/defaults.js"
|
||||
);
|
||||
return await getSubagentModelOverride();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServices(): Promise<
|
||||
NonNullable<SpawnedAgentCallbacks["services"]>
|
||||
> {
|
||||
|
|
|
|||
|
|
@ -656,7 +656,7 @@ const ipcSchemas = {
|
|||
}).nullable(),
|
||||
res: z.object({
|
||||
providers: z.array(z.object({
|
||||
// Provider INSTANCE id — what ModelRef.provider / defaultSelection /
|
||||
// Provider INSTANCE id — what ModelRef.provider / assistantModel /
|
||||
// refreshProvider reference. One instance per flavor today, so it
|
||||
// always equals the flavor key; kept distinct so a future
|
||||
// multi-instance setup ("openai-work") is additive.
|
||||
|
|
@ -748,6 +748,33 @@ const ipcSchemas = {
|
|||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// Current model selections plus credential-FREE provider metadata (the
|
||||
// renderer never needs keys to render pickers or provider cards). Null
|
||||
// assistantModel = not configured yet.
|
||||
'models:getConfig': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
// Configured BYOK provider entries, secrets stripped: enough to
|
||||
// render manage/edit surfaces (masked key indicator, endpoint).
|
||||
providers: z.array(z.object({
|
||||
id: z.string(),
|
||||
flavor: z.string(),
|
||||
baseURL: z.string().optional(),
|
||||
hasApiKey: z.boolean(),
|
||||
})),
|
||||
assistantModel: ModelRef.nullable(),
|
||||
taskModels: z.object({
|
||||
knowledgeGraph: ModelRef.nullable(),
|
||||
meetingNotes: ModelRef.nullable(),
|
||||
liveNoteAgent: ModelRef.nullable(),
|
||||
autoPermissionDecision: ModelRef.nullable(),
|
||||
chatTitle: ModelRef.nullable(),
|
||||
backgroundTask: ModelRef.nullable(),
|
||||
subagent: ModelRef.nullable(),
|
||||
}),
|
||||
deferBackgroundTasks: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Partial merge of model selections into models.json. Omitted keys are
|
||||
// untouched; null clears a key (a cleared task override inherits the
|
||||
// assistant model again). taskModels merges per-key.
|
||||
|
|
@ -760,6 +787,8 @@ const ipcSchemas = {
|
|||
liveNoteAgent: ModelRef.nullable().optional(),
|
||||
autoPermissionDecision: ModelRef.nullable().optional(),
|
||||
chatTitle: ModelRef.nullable().optional(),
|
||||
backgroundTask: ModelRef.nullable().optional(),
|
||||
subagent: ModelRef.nullable().optional(),
|
||||
}).optional(),
|
||||
deferBackgroundTasks: z.boolean().nullable().optional(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -41,13 +41,17 @@ export const ModelRef = z.object({
|
|||
model: z.string(),
|
||||
});
|
||||
|
||||
// The per-task model override slots. Absence = inherit the assistant model.
|
||||
// The per-task model override slots. Absence = inherit the assistant model
|
||||
// (except `subagent`, whose default is the PARENT turn's model — which is
|
||||
// the assistant for a top-level chat).
|
||||
export const TaskModels = z.object({
|
||||
knowledgeGraph: ModelRef.optional(),
|
||||
meetingNotes: ModelRef.optional(),
|
||||
liveNoteAgent: ModelRef.optional(),
|
||||
autoPermissionDecision: ModelRef.optional(),
|
||||
chatTitle: ModelRef.optional(),
|
||||
backgroundTask: ModelRef.optional(),
|
||||
subagent: ModelRef.optional(),
|
||||
});
|
||||
export type TaskModelKey = keyof z.infer<typeof TaskModels>;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue