v2.1.0-provider-picker: BooCoder systemd migration + provider picker

- BooCoder moves from Docker to host systemd service (boocoder.service)
- Agent dispatch (ACP + PTY) switches from SSH to direct spawn/exec
- SSH helpers marked @deprecated (kept for one release cycle)
- Provider registry (5 providers: boocode, opencode, goose, claude, qwen)
- Agent probe with direct which/exec + model discovery (qwen settings, static claude models)
- GET /api/providers route with installed status, models, transport fallback
- ProviderPicker frontend component in CoderPane header
- External provider messages route through tasks row instead of inference enqueue
- Smart scroll: MessageList only auto-scrolls when near bottom (150px threshold)
- DB: available_agents gets models, label, transport columns
- Bug fix: loadContext SELECT includes allowed_read_paths
- Bug fix: cap hit sentinel inserted before buildMessagesPayload
- docker-compose.yml: boocoder service commented out, BOOCODER_URL env var added
- CLAUDE.md: updated docs for systemd, provider registry, JSONB gotcha, loadContext
This commit is contained in:
2026-05-25 19:20:53 +00:00
parent e423579e99
commit d8ffee1950
21 changed files with 687 additions and 222 deletions

View File

@@ -6,7 +6,9 @@ import type { WsFrame } from '@boocode/server/ws-frames';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
chat_id: z.string().uuid(),
chat_id: z.string().uuid().optional(),
provider: z.string().max(100).optional(),
model: z.string().max(200).optional(),
});
interface InferenceApi {
@@ -32,73 +34,104 @@ export function registerMessageRoutes(
}
const sessionId = req.params.sessionId;
const { content, chat_id: chatId } = parsed.data;
const { content, chat_id: explicitChatId, provider, model } = parsed.data;
const isExternal = provider && provider !== 'boocode';
// Validate session exists
const sessionRows = await sql<{ id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId}
const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
// Validate chat belongs to session and is open
const chatRows = await sql<{ id: string; session_id: string }[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
// Resolve chat_id: use explicit value or find/create a default chat
let chatId: string;
if (explicitChatId) {
const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
}
chatId = explicitChatId;
} else {
const existing = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at LIMIT 1
`;
if (existing.length > 0) {
chatId = existing[0]!.id;
} else {
const [newChat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'Chat', 'open')
RETURNING id
`;
chatId = newChat!.id;
}
}
if (!isExternal) {
// Reject if inference is already running on this chat
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
}
}
// Create user message
const [userMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
RETURNING id
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found or not open in this session' };
}
await sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
// Reject if inference is already running on this chat
if (inference.hasActive(chatId)) {
reply.code(409);
return { error: 'inference already running on this chat' };
}
// Create user message + streaming assistant row in a transaction
const result = await sql.begin(async (tx) => {
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
});
// Publish user message frames so WS subscribers see it immediately
// Publish user message frames
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: result.user_message_id,
message_id: userMsg!.id,
chat_id: chatId,
role: 'user',
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: result.user_message_id,
message_id: userMsg!.id,
chat_id: chatId,
content,
} as unknown as WsFrame);
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: result.user_message_id,
message_id: userMsg!.id,
chat_id: chatId,
} as unknown as WsFrame);
// Enqueue inference — the runner will stream assistant deltas via broker
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
if (isExternal) {
// External provider: create a task for the dispatcher
const projectId = sessionRows[0]!.project_id;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, session_id)
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${sessionId})
RETURNING id, state
`;
reply.code(202);
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
}
// Native provider: create streaming assistant row + enqueue inference
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
reply.code(202);
return result;
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
},
);

View File

@@ -0,0 +1,80 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import { PROVIDERS } from '../services/provider-registry.js';
interface ProviderModel {
id: string;
label: string;
}
interface ProviderResponse {
name: string;
label: string;
transport: string;
installed: boolean;
models: ProviderModel[];
}
interface LlamaSwapModel {
id: string;
[key: string]: unknown;
}
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
try {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
if (!res.ok) return [];
const parsed = (await res.json()) as { data?: LlamaSwapModel[] };
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
} catch {
return [];
}
}
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
app.get('/api/providers', async (_req, _reply) => {
const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<{ name: string; models: ProviderModel[]; label: string | null; transport: string | null; supports_acp: boolean }[]>`
SELECT name, models, label, transport, supports_acp FROM available_agents
`;
const agentMap = new Map(agents.map((a) => [a.name, a]));
const result: ProviderResponse[] = [];
for (const provider of PROVIDERS) {
const isNative = provider.name === 'boocode';
const agentRow = agentMap.get(provider.name);
const installed = isNative || !!agentRow;
if (!installed) continue;
let models: ProviderModel[];
if (provider.modelSource === 'llama-swap') {
models = llamaModels;
} else if (agentRow?.models && agentRow.models.length > 0) {
models = agentRow.models;
} else if (provider.staticModels) {
models = provider.staticModels;
} else {
models = [];
}
let transport: string = provider.transport;
if (agentRow) {
transport = provider.transport === 'acp' && !agentRow.supports_acp ? 'pty' : provider.transport;
}
result.push({
name: provider.name,
label: agentRow?.label ?? provider.label,
transport,
installed,
models,
});
}
return result;
});
}