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 { TurnInputError } from "../turns/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";
|
||||
|
||||
|
|
@ -61,9 +61,16 @@ interface Harness {
|
|||
getTurn: ReturnType<typeof vi.fn>;
|
||||
listSessions: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
listModels: ReturnType<typeof vi.fn>;
|
||||
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 {
|
||||
const bus = new EmitterSessionBus();
|
||||
const publish = (turnId: string, event: TurnStreamEvent) =>
|
||||
|
|
@ -76,15 +83,17 @@ function harness(entries: SessionIndexEntry[] = []): Harness {
|
|||
getTurn: vi.fn(async () => ({ turnId: "t1", events: [] })),
|
||||
listSessions: vi.fn(() => entries),
|
||||
};
|
||||
const listModels = vi.fn(async () => MODELS);
|
||||
const bridge = new ChannelBridge({
|
||||
sessions: sessions as unknown as ISessions,
|
||||
sessionBus: bus,
|
||||
listModels,
|
||||
});
|
||||
const replies: string[] = [];
|
||||
const reply = async (text: string) => {
|
||||
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
|
||||
|
|
@ -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", () => {
|
||||
it("creates a session, acks, and delivers the completed text", async () => {
|
||||
const h = harness();
|
||||
|
|
|
|||
|
|
@ -27,18 +27,31 @@ const HELP_TEXT = [
|
|||
"• list — recent chats",
|
||||
"• resume N — continue chat N from the list",
|
||||
"• new [message] — start a fresh chat",
|
||||
"• model [N or name] — pick the model (\"model default\" resets)",
|
||||
"• status — current chat and what it's doing",
|
||||
"• stop — cancel the running task",
|
||||
"",
|
||||
"Anything else is sent to the current chat.",
|
||||
].join("\n");
|
||||
|
||||
const MODEL_LIST_LIMIT = 20;
|
||||
|
||||
export type ReplyFn = (text: string) => Promise<void>;
|
||||
|
||||
export interface ModelChoice {
|
||||
provider: string;
|
||||
model: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SenderState {
|
||||
activeSessionId: string | null;
|
||||
// sessionIds as last shown by `list` (1-based indexing for `resume N`).
|
||||
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;
|
||||
busy: boolean;
|
||||
}
|
||||
|
|
@ -124,6 +137,7 @@ export class ChannelBridge {
|
|||
private readonly deps: {
|
||||
sessions: ISessions;
|
||||
sessionBus: EmitterSessionBus;
|
||||
listModels: () => Promise<ModelChoice[]>;
|
||||
},
|
||||
) {}
|
||||
|
||||
|
|
@ -147,6 +161,15 @@ export class ChannelBridge {
|
|||
await reply(this.resumeSession(state, Number(resume[1])));
|
||||
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") {
|
||||
await reply(this.renderStatus(state));
|
||||
return;
|
||||
|
|
@ -178,12 +201,81 @@ export class ChannelBridge {
|
|||
private senderState(senderKey: string): SenderState {
|
||||
let state = this.senders.get(senderKey);
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
return this.deps.sessions.listSessions().find((e) => e.sessionId === sessionId);
|
||||
}
|
||||
|
|
@ -302,7 +394,13 @@ export class ChannelBridge {
|
|||
const sent = await this.deps.sessions.sendMessage(
|
||||
state.activeSessionId,
|
||||
{ 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);
|
||||
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 type { ISessions } from "../sessions/api.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 { TelegramTransport } from "./transports/telegram.js";
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (!bridge) {
|
||||
bridge = new ChannelBridge({
|
||||
sessions: container.resolve<ISessions>("sessions"),
|
||||
sessionBus: container.resolve<EmitterSessionBus>("sessionBus"),
|
||||
listModels: listBridgeModels,
|
||||
});
|
||||
}
|
||||
return bridge;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue