mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(channels): model selection from WhatsApp/Telegram
New bridge commands: "model" lists the available models (same catalog as the desktop picker), "model N" / "model <name>" sets a per-sender override passed as agent.overrides.model on every turn, and "model default" resets to the app default. In-memory scope: resets on app restart, never sticks a session to a bad model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c80a09daba
commit
930e377f34
3 changed files with 191 additions and 5 deletions
|
|
@ -4,7 +4,7 @@ import type { TurnStreamEvent } from "@x/shared/dist/turns.js";
|
||||||
import { EmitterSessionBus } from "../sessions/bus.js";
|
import { EmitterSessionBus } from "../sessions/bus.js";
|
||||||
import { TurnInputError } from "../turns/api.js";
|
import { TurnInputError } from "../turns/api.js";
|
||||||
import type { ISessions } from "../sessions/api.js";
|
import type { ISessions } from "../sessions/api.js";
|
||||||
import { ChannelBridge } from "./bridge.js";
|
import { ChannelBridge, type ModelChoice } from "./bridge.js";
|
||||||
|
|
||||||
const SENDER = "test:1";
|
const SENDER = "test:1";
|
||||||
|
|
||||||
|
|
@ -61,9 +61,16 @@ interface Harness {
|
||||||
getTurn: ReturnType<typeof vi.fn>;
|
getTurn: ReturnType<typeof vi.fn>;
|
||||||
listSessions: ReturnType<typeof vi.fn>;
|
listSessions: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
listModels: ReturnType<typeof vi.fn>;
|
||||||
publish: (turnId: string, event: TurnStreamEvent) => void;
|
publish: (turnId: string, event: TurnStreamEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MODELS: ModelChoice[] = [
|
||||||
|
{ provider: "anthropic", model: "claude-fable-5", label: "Fable 5 — Anthropic" },
|
||||||
|
{ provider: "anthropic", model: "claude-sonnet-5", label: "Sonnet 5 — Anthropic" },
|
||||||
|
{ provider: "openai", model: "gpt-5", label: "GPT-5 — OpenAI" },
|
||||||
|
];
|
||||||
|
|
||||||
function harness(entries: SessionIndexEntry[] = []): Harness {
|
function harness(entries: SessionIndexEntry[] = []): Harness {
|
||||||
const bus = new EmitterSessionBus();
|
const bus = new EmitterSessionBus();
|
||||||
const publish = (turnId: string, event: TurnStreamEvent) =>
|
const publish = (turnId: string, event: TurnStreamEvent) =>
|
||||||
|
|
@ -76,15 +83,17 @@ function harness(entries: SessionIndexEntry[] = []): Harness {
|
||||||
getTurn: vi.fn(async () => ({ turnId: "t1", events: [] })),
|
getTurn: vi.fn(async () => ({ turnId: "t1", events: [] })),
|
||||||
listSessions: vi.fn(() => entries),
|
listSessions: vi.fn(() => entries),
|
||||||
};
|
};
|
||||||
|
const listModels = vi.fn(async () => MODELS);
|
||||||
const bridge = new ChannelBridge({
|
const bridge = new ChannelBridge({
|
||||||
sessions: sessions as unknown as ISessions,
|
sessions: sessions as unknown as ISessions,
|
||||||
sessionBus: bus,
|
sessionBus: bus,
|
||||||
|
listModels,
|
||||||
});
|
});
|
||||||
const replies: string[] = [];
|
const replies: string[] = [];
|
||||||
const reply = async (text: string) => {
|
const reply = async (text: string) => {
|
||||||
replies.push(text);
|
replies.push(text);
|
||||||
};
|
};
|
||||||
return { bridge, bus, replies, reply, sessions, publish };
|
return { bridge, bus, replies, reply, sessions, listModels, publish };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settle the turn as soon as sendMessage is called: the watcher subscribes
|
// Settle the turn as soon as sendMessage is called: the watcher subscribes
|
||||||
|
|
@ -133,6 +142,67 @@ describe("ChannelBridge commands", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ChannelBridge model selection", () => {
|
||||||
|
it("lists models and applies a by-index selection to later turns", async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.bridge.handleInbound(SENDER, "model", h.reply);
|
||||||
|
expect(h.replies[0]).toContain("1. Fable 5 — Anthropic");
|
||||||
|
expect(h.replies[0]).toContain("app default");
|
||||||
|
|
||||||
|
await h.bridge.handleInbound(SENDER, "model 3", h.reply);
|
||||||
|
expect(h.replies[1]).toContain("GPT-5");
|
||||||
|
|
||||||
|
settleOnSend(h, completedEvent("t1", "done"));
|
||||||
|
await h.bridge.handleInbound(SENDER, "hello", h.reply);
|
||||||
|
expect(h.sessions.sendMessage).toHaveBeenCalledWith(
|
||||||
|
"s1",
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
agent: {
|
||||||
|
agentId: "copilot",
|
||||||
|
overrides: { model: { provider: "openai", model: "gpt-5" } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects by unique name match and resets on 'model default'", async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.bridge.handleInbound(SENDER, "model fable", h.reply);
|
||||||
|
expect(h.replies[0]).toContain("Fable 5");
|
||||||
|
|
||||||
|
settleOnSend(h, completedEvent("t1", "done"));
|
||||||
|
await h.bridge.handleInbound(SENDER, "hi", h.reply);
|
||||||
|
expect(h.sessions.sendMessage).toHaveBeenCalledWith(
|
||||||
|
"s1",
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
agent: expect.objectContaining({
|
||||||
|
overrides: { model: { provider: "anthropic", model: "claude-fable-5" } },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await h.bridge.handleInbound(SENDER, "model default", h.reply);
|
||||||
|
settleOnSend(h, completedEvent("t2", "done"), "t2");
|
||||||
|
await h.bridge.handleInbound(SENDER, "hi again", h.reply);
|
||||||
|
expect(h.sessions.sendMessage).toHaveBeenLastCalledWith(
|
||||||
|
"s1",
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ agent: { agentId: "copilot" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for disambiguation on an ambiguous name and rejects unknown ones", async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.bridge.handleInbound(SENDER, "model claude", h.reply);
|
||||||
|
expect(h.replies[0]).toContain("matches 2 models");
|
||||||
|
|
||||||
|
await h.bridge.handleInbound(SENDER, "model llama", h.reply);
|
||||||
|
expect(h.replies[1]).toContain('No model matching "llama"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ChannelBridge message flow", () => {
|
describe("ChannelBridge message flow", () => {
|
||||||
it("creates a session, acks, and delivers the completed text", async () => {
|
it("creates a session, acks, and delivers the completed text", async () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,31 @@ const HELP_TEXT = [
|
||||||
"• list — recent chats",
|
"• list — recent chats",
|
||||||
"• resume N — continue chat N from the list",
|
"• resume N — continue chat N from the list",
|
||||||
"• new [message] — start a fresh chat",
|
"• new [message] — start a fresh chat",
|
||||||
|
"• model [N or name] — pick the model (\"model default\" resets)",
|
||||||
"• status — current chat and what it's doing",
|
"• status — current chat and what it's doing",
|
||||||
"• stop — cancel the running task",
|
"• stop — cancel the running task",
|
||||||
"",
|
"",
|
||||||
"Anything else is sent to the current chat.",
|
"Anything else is sent to the current chat.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
const MODEL_LIST_LIMIT = 20;
|
||||||
|
|
||||||
export type ReplyFn = (text: string) => Promise<void>;
|
export type ReplyFn = (text: string) => Promise<void>;
|
||||||
|
|
||||||
|
export interface ModelChoice {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SenderState {
|
interface SenderState {
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
// sessionIds as last shown by `list` (1-based indexing for `resume N`).
|
// sessionIds as last shown by `list` (1-based indexing for `resume N`).
|
||||||
lastList: string[];
|
lastList: string[];
|
||||||
|
// Choices as last shown by `model` (1-based indexing for `model N`).
|
||||||
|
lastModels: ModelChoice[];
|
||||||
|
// Per-sender override passed on every turn; null = app default model.
|
||||||
|
model: { provider: string; model: string } | null;
|
||||||
pendingAsk: { turnId: string; toolCallId: string } | null;
|
pendingAsk: { turnId: string; toolCallId: string } | null;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +137,7 @@ export class ChannelBridge {
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
sessions: ISessions;
|
sessions: ISessions;
|
||||||
sessionBus: EmitterSessionBus;
|
sessionBus: EmitterSessionBus;
|
||||||
|
listModels: () => Promise<ModelChoice[]>;
|
||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -147,6 +161,15 @@ export class ChannelBridge {
|
||||||
await reply(this.resumeSession(state, Number(resume[1])));
|
await reply(this.resumeSession(state, Number(resume[1])));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (lower === "model" || lower === "models") {
|
||||||
|
await reply(await this.renderModelList(state));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = /^model\s+(.+)$/i.exec(trimmed);
|
||||||
|
if (model) {
|
||||||
|
await reply(await this.selectModel(state, model[1].trim()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (lower === "status") {
|
if (lower === "status") {
|
||||||
await reply(this.renderStatus(state));
|
await reply(this.renderStatus(state));
|
||||||
return;
|
return;
|
||||||
|
|
@ -178,12 +201,81 @@ export class ChannelBridge {
|
||||||
private senderState(senderKey: string): SenderState {
|
private senderState(senderKey: string): SenderState {
|
||||||
let state = this.senders.get(senderKey);
|
let state = this.senders.get(senderKey);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = { activeSessionId: null, lastList: [], pendingAsk: null, busy: false };
|
state = {
|
||||||
|
activeSessionId: null,
|
||||||
|
lastList: [],
|
||||||
|
lastModels: [],
|
||||||
|
model: null,
|
||||||
|
pendingAsk: null,
|
||||||
|
busy: false,
|
||||||
|
};
|
||||||
this.senders.set(senderKey, state);
|
this.senders.set(senderKey, state);
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCurrentModel(state: SenderState, choice: ModelChoice): boolean {
|
||||||
|
return (
|
||||||
|
state.model?.provider === choice.provider && state.model?.model === choice.model
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderModelList(state: SenderState): Promise<string> {
|
||||||
|
const choices = await this.deps.listModels();
|
||||||
|
if (choices.length === 0) {
|
||||||
|
return "No models available — configure one in Rowboat → Settings → Models.";
|
||||||
|
}
|
||||||
|
state.lastModels = choices;
|
||||||
|
const shown = choices.slice(0, MODEL_LIST_LIMIT);
|
||||||
|
const lines = shown.map((c, i) => {
|
||||||
|
const current = this.isCurrentModel(state, c) ? " ← current" : "";
|
||||||
|
return `${i + 1}. ${c.label}${current}`;
|
||||||
|
});
|
||||||
|
if (choices.length > shown.length) {
|
||||||
|
lines.push(`… and ${choices.length - shown.length} more — pick by name.`);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`Models${state.model ? "" : " (using app default)"}:`,
|
||||||
|
...lines,
|
||||||
|
"",
|
||||||
|
`Reply "model N" or "model <name>" to switch, "model default" to reset.`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectModel(state: SenderState, arg: string): Promise<string> {
|
||||||
|
const lower = arg.toLowerCase();
|
||||||
|
if (lower === "default" || lower === "reset") {
|
||||||
|
state.model = null;
|
||||||
|
return "✅ Using the app default model.";
|
||||||
|
}
|
||||||
|
if (state.lastModels.length === 0) {
|
||||||
|
state.lastModels = await this.deps.listModels();
|
||||||
|
}
|
||||||
|
let choice: ModelChoice | undefined;
|
||||||
|
if (/^\d+$/.test(lower)) {
|
||||||
|
choice = state.lastModels[Number(lower) - 1];
|
||||||
|
if (!choice) {
|
||||||
|
return `No model #${lower} — send "model" to see the list.`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const matches = state.lastModels.filter(
|
||||||
|
(c) =>
|
||||||
|
c.label.toLowerCase().includes(lower) ||
|
||||||
|
c.model.toLowerCase().includes(lower),
|
||||||
|
);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return `No model matching "${arg}" — send "model" to see the list.`;
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
const preview = matches.slice(0, 5).map((c) => `• ${c.label}`);
|
||||||
|
return [`"${arg}" matches ${matches.length} models:`, ...preview, "", "Be more specific."].join("\n");
|
||||||
|
}
|
||||||
|
choice = matches[0];
|
||||||
|
}
|
||||||
|
state.model = { provider: choice.provider, model: choice.model };
|
||||||
|
return `✅ Model set to ${choice.label} for your chats from here.`;
|
||||||
|
}
|
||||||
|
|
||||||
private sessionEntry(sessionId: string): SessionIndexEntry | undefined {
|
private sessionEntry(sessionId: string): SessionIndexEntry | undefined {
|
||||||
return this.deps.sessions.listSessions().find((e) => e.sessionId === sessionId);
|
return this.deps.sessions.listSessions().find((e) => e.sessionId === sessionId);
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +394,13 @@ export class ChannelBridge {
|
||||||
const sent = await this.deps.sessions.sendMessage(
|
const sent = await this.deps.sessions.sendMessage(
|
||||||
state.activeSessionId,
|
state.activeSessionId,
|
||||||
{ role: "user", content: text },
|
{ role: "user", content: text },
|
||||||
{ agent: { agentId: AGENT_ID }, autoPermission: true },
|
{
|
||||||
|
agent: {
|
||||||
|
agentId: AGENT_ID,
|
||||||
|
...(state.model ? { overrides: { model: state.model } } : {}),
|
||||||
|
},
|
||||||
|
autoPermission: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const settled = await watcher.waitFor(sent.turnId, TURN_TIMEOUT_MS);
|
const settled = await watcher.waitFor(sent.turnId, TURN_TIMEOUT_MS);
|
||||||
await this.deliverSettled(state, sent.turnId, settled, reply);
|
await this.deliverSettled(state, sent.turnId, settled, reply);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import container from "../di/container.js";
|
||||||
import { WorkDir } from "../config/config.js";
|
import { WorkDir } from "../config/config.js";
|
||||||
import type { ISessions } from "../sessions/api.js";
|
import type { ISessions } from "../sessions/api.js";
|
||||||
import type { EmitterSessionBus } from "../sessions/bus.js";
|
import type { EmitterSessionBus } from "../sessions/bus.js";
|
||||||
import { ChannelBridge } from "./bridge.js";
|
import { isSignedIn } from "../account/account.js";
|
||||||
|
import { listGatewayModels } from "../models/gateway.js";
|
||||||
|
import { listOnboardingModels } from "../models/models-dev.js";
|
||||||
|
import { ChannelBridge, type ModelChoice } from "./bridge.js";
|
||||||
import type { IChannelsConfigRepo } from "./repo.js";
|
import type { IChannelsConfigRepo } from "./repo.js";
|
||||||
import { TelegramTransport } from "./transports/telegram.js";
|
import { TelegramTransport } from "./transports/telegram.js";
|
||||||
// Type-only: the real module (which pulls the ~9MB baileys dependency) is
|
// Type-only: the real module (which pulls the ~9MB baileys dependency) is
|
||||||
|
|
@ -77,11 +80,26 @@ export function subscribeChannelsStatus(listener: (status: Status) => void): ()
|
||||||
return () => statusListeners.delete(listener);
|
return () => statusListeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same catalog the desktop model picker uses (models:list IPC).
|
||||||
|
async function listBridgeModels(): Promise<ModelChoice[]> {
|
||||||
|
const catalog = (await isSignedIn())
|
||||||
|
? await listGatewayModels()
|
||||||
|
: await listOnboardingModels();
|
||||||
|
return catalog.providers.flatMap((provider) =>
|
||||||
|
provider.models.map((m) => ({
|
||||||
|
provider: provider.id,
|
||||||
|
model: m.id,
|
||||||
|
label: `${m.name ?? m.id} — ${provider.name}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureBridge(): ChannelBridge {
|
function ensureBridge(): ChannelBridge {
|
||||||
if (!bridge) {
|
if (!bridge) {
|
||||||
bridge = new ChannelBridge({
|
bridge = new ChannelBridge({
|
||||||
sessions: container.resolve<ISessions>("sessions"),
|
sessions: container.resolve<ISessions>("sessions"),
|
||||||
sessionBus: container.resolve<EmitterSessionBus>("sessionBus"),
|
sessionBus: container.resolve<EmitterSessionBus>("sessionBus"),
|
||||||
|
listModels: listBridgeModels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return bridge;
|
return bridge;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue