PI-MONO
This commit is contained in:
@@ -20,6 +20,7 @@ export type ClientMessage =
|
||||
resourceChatDir?: string;
|
||||
taskInfo?: TaskInfo;
|
||||
}
|
||||
| { type: 'resume'; sessionId: string }
|
||||
| { type: 'stop' };
|
||||
|
||||
// Server → Client
|
||||
|
||||
@@ -1,54 +1,44 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { HonoVariables } from '@@/create-router';
|
||||
import { readApiKeys } from '@@/api/server-settings/pi-mono';
|
||||
|
||||
export const piMonoModelsRouter = new Hono<{ Variables: HonoVariables }>();
|
||||
|
||||
// Hardcoded fallback models — pi supports many providers but these are the most common
|
||||
const FALLBACK_MODELS = [
|
||||
{ id: 'claude-sonnet-4-5-20250514', name: 'Claude Sonnet 4.5', provider: 'anthropic', providerId: 'anthropic' },
|
||||
{ id: 'claude-opus-4-20250918', name: 'Claude Opus 4', provider: 'anthropic', providerId: 'anthropic' },
|
||||
{ id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', providerId: 'openai' },
|
||||
{ id: 'o3', name: 'o3', provider: 'openai', providerId: 'openai' },
|
||||
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'google', providerId: 'google' },
|
||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'google', providerId: 'google' },
|
||||
];
|
||||
|
||||
piMonoModelsRouter.get('/pi-mono/models', async (ctx) => {
|
||||
// Spawn a short-lived pi process to query available models
|
||||
try {
|
||||
const proc = Bun.spawn(['pi', '--list-models', '--mode', 'json'], {
|
||||
const storedKeys = await readApiKeys();
|
||||
const proc = Bun.spawn(['pi', '--list-models'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, ...storedKeys },
|
||||
});
|
||||
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (proc.exitCode !== 0) return ctx.json(FALLBACK_MODELS);
|
||||
if (proc.exitCode !== 0) return ctx.json([]);
|
||||
|
||||
// Parse the output — pi --list-models outputs model info
|
||||
// Parse the whitespace-separated table output:
|
||||
// provider model context max-out thinking images
|
||||
// anthropic claude-sonnet-4-6 200K 128K yes yes
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
const models: { id: string; name: string; provider: string; providerId: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.id && data.provider) {
|
||||
models.push({
|
||||
id: data.id,
|
||||
name: data.name ?? data.id,
|
||||
provider: data.provider,
|
||||
providerId: data.provider,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip non-JSON lines
|
||||
}
|
||||
// Skip header line (first line)
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i]!.trim().split(/\s+/);
|
||||
if (cols.length < 2) continue;
|
||||
const [provider, model] = cols;
|
||||
models.push({
|
||||
id: `${provider}/${model}`,
|
||||
name: model!,
|
||||
provider: provider!,
|
||||
providerId: provider!,
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.json(models.length > 0 ? models : FALLBACK_MODELS);
|
||||
return ctx.json(models);
|
||||
} catch {
|
||||
return ctx.json(FALLBACK_MODELS);
|
||||
return ctx.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,26 +15,33 @@ import {
|
||||
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
|
||||
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
|
||||
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
|
||||
import { readApiKeys, readLocalProviders } from '@@/api/server-settings/pi-mono';
|
||||
|
||||
type WSData = { userId: number; email: string };
|
||||
|
||||
type ConnectionState = {
|
||||
// Pi process state, keyed by sessionId — survives websocket reconnects
|
||||
type PiSession = {
|
||||
piProcess: Subprocess | null;
|
||||
sessionId: string | null;
|
||||
pendingTitle: string | null;
|
||||
ws: ServerWebSocket<WSData> | null;
|
||||
selectedModel: string | null;
|
||||
pendingAttachmentIds: string[];
|
||||
cwd: string | null;
|
||||
resourceChatDir: string | null;
|
||||
logId: string | null;
|
||||
fullText: string;
|
||||
rpcReady: boolean;
|
||||
killTimer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
const connections = new Map<ServerWebSocket<WSData>, ConnectionState>();
|
||||
// Session pool — pi processes persist across websocket reconnects
|
||||
const sessions = new Map<string, PiSession>();
|
||||
|
||||
function send(ws: ServerWebSocket<WSData>, msg: ServerMessage) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
||||
// Map ws → sessionId for quick lookup on close
|
||||
const wsToSession = new Map<ServerWebSocket<WSData>, string>();
|
||||
|
||||
// Grace period before killing orphaned pi processes (ms)
|
||||
const ORPHAN_GRACE_MS = 30_000;
|
||||
|
||||
function send(ws: ServerWebSocket<WSData> | null, msg: ServerMessage) {
|
||||
if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function resolveRootDir(email: string, root?: string): string {
|
||||
@@ -68,29 +75,186 @@ async function buildSkillsPrompt(email: string): Promise<string> {
|
||||
}
|
||||
|
||||
function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>) {
|
||||
const writer = proc.stdin as WritableStream;
|
||||
const textEncoder = new TextEncoder();
|
||||
const w = writer.getWriter();
|
||||
w.write(textEncoder.encode(JSON.stringify(command) + '\n'));
|
||||
w.releaseLock();
|
||||
const stdin = proc.stdin;
|
||||
if (!stdin || typeof stdin === 'number') return;
|
||||
try {
|
||||
(stdin as { write: (data: string) => void; flush: () => void }).write(JSON.stringify(command) + '\n');
|
||||
(stdin as { flush: () => void }).flush();
|
||||
} catch (err) {
|
||||
console.error('[pi-mono-ws] writeRpcCommand error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, workingDir: string) {
|
||||
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||
function getOrCreateSession(sessionId: string): PiSession {
|
||||
let session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
session = {
|
||||
piProcess: null,
|
||||
ws: null,
|
||||
selectedModel: null,
|
||||
cwd: null,
|
||||
resourceChatDir: null,
|
||||
logId: null,
|
||||
fullText: '',
|
||||
killTimer: null,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
if (state.selectedModel) {
|
||||
args.push('--model', state.selectedModel);
|
||||
function attachWs(sessionId: string, ws: ServerWebSocket<WSData>) {
|
||||
const session = getOrCreateSession(sessionId);
|
||||
|
||||
// Cancel any pending kill timer — the session is alive again
|
||||
if (session.killTimer) {
|
||||
clearTimeout(session.killTimer);
|
||||
session.killTimer = null;
|
||||
}
|
||||
|
||||
session.ws = ws;
|
||||
wsToSession.set(ws, sessionId);
|
||||
}
|
||||
|
||||
function detachWs(ws: ServerWebSocket<WSData>) {
|
||||
const sessionId = wsToSession.get(ws);
|
||||
wsToSession.delete(ws);
|
||||
if (!sessionId) return;
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ws !== ws) return;
|
||||
|
||||
// Detach ws but keep pi process alive for grace period
|
||||
session.ws = null;
|
||||
|
||||
if (session.piProcess) {
|
||||
session.killTimer = setTimeout(() => {
|
||||
// If no new ws has attached, kill the process
|
||||
if (!session.ws && session.piProcess) {
|
||||
try {
|
||||
session.piProcess.kill();
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
session.piProcess = null;
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
}, ORPHAN_GRACE_MS);
|
||||
} else {
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLocalModel(modelId: string): { providerId: string; modelName: string } | null {
|
||||
if (!modelId.startsWith('local:')) return null;
|
||||
const parts = modelId.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
return { providerId: parts[1]!, modelName: parts.slice(2).join(':') };
|
||||
}
|
||||
|
||||
async function handleLocalChat(session: PiSession, prompt: string) {
|
||||
const parsed = session.selectedModel ? resolveLocalModel(session.selectedModel) : null;
|
||||
if (!parsed) {
|
||||
send(session.ws, { type: 'error', message: 'Invalid local model' });
|
||||
return;
|
||||
}
|
||||
|
||||
const providers = await readLocalProviders();
|
||||
const provider = providers.find((p) => p.id === parsed.providerId);
|
||||
if (!provider) {
|
||||
send(session.ws, { type: 'error', message: 'Local provider not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const base = provider.url.replace(/\/+$/, '');
|
||||
const url = `${base}/v1/chat/completions`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (provider.auth?.type === 'api-key') {
|
||||
headers['Authorization'] = `Bearer ${provider.auth.apiKey}`;
|
||||
} else if (provider.auth?.type === 'basic') {
|
||||
headers['Authorization'] = `Basic ${btoa(`${provider.auth.username}:${provider.auth.password}`)}`;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: parsed.modelName,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method: 'POST', headers, body });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
send(session.ws, { type: 'error', message: `Local provider error: ${res.status} ${text}` });
|
||||
return;
|
||||
}
|
||||
|
||||
session.fullText = '';
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(data) as { choices?: { delta?: { content?: string } }[] };
|
||||
const delta = chunk.choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
session.fullText += delta;
|
||||
send(session.ws, { type: 'assistant:partial', text: delta });
|
||||
}
|
||||
} catch {
|
||||
// skip unparseable chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (session.fullText) {
|
||||
send(session.ws, { type: 'assistant:text', text: session.fullText });
|
||||
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
|
||||
session.fullText = '';
|
||||
}
|
||||
|
||||
send(session.ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
|
||||
if (session.logId) {
|
||||
appendToLog(session.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
|
||||
finalizeLog(session.logId);
|
||||
session.logId = null;
|
||||
}
|
||||
} catch (err) {
|
||||
send(session.ws, { type: 'error', message: `Local provider error: ${err}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnPiProcess(session: PiSession, workingDir: string) {
|
||||
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||
|
||||
if (session.selectedModel) {
|
||||
args.push('--model', session.selectedModel);
|
||||
}
|
||||
|
||||
const storedKeys = await readApiKeys();
|
||||
const proc = Bun.spawn(args, {
|
||||
cwd: workingDir,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, ...storedKeys },
|
||||
});
|
||||
|
||||
state.piProcess = proc;
|
||||
session.piProcess = proc;
|
||||
|
||||
// Read stdout line-by-line for JSON events
|
||||
const reader = proc.stdout.getReader();
|
||||
@@ -111,7 +275,7 @@ function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, wor
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
handlePiEvent(ws, state, event);
|
||||
handlePiEvent(session, event);
|
||||
} catch {
|
||||
// skip unparseable lines
|
||||
}
|
||||
@@ -144,21 +308,19 @@ function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, wor
|
||||
// Handle process exit
|
||||
proc.exited.then((code) => {
|
||||
console.log(`[pi-mono-ws] pi process exited with code ${code}`);
|
||||
if (state.piProcess === proc) {
|
||||
state.piProcess = null;
|
||||
if (session.piProcess === proc) {
|
||||
session.piProcess = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, event: Record<string, unknown>) {
|
||||
function handlePiEvent(session: PiSession, event: Record<string, unknown>) {
|
||||
const type = event.type as string;
|
||||
const ws = session.ws;
|
||||
|
||||
// RPC responses (type === 'response')
|
||||
if (type === 'response') {
|
||||
const command = event.command as string;
|
||||
if (command === 'get_available_models' && event.success) {
|
||||
// Models are handled by the REST endpoint, not here
|
||||
}
|
||||
if (command === 'prompt' && !event.success) {
|
||||
send(ws, { type: 'error', message: (event.error as string) ?? 'Prompt failed' });
|
||||
}
|
||||
@@ -167,29 +329,27 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
|
||||
|
||||
switch (type) {
|
||||
case 'agent_start':
|
||||
state.fullText = '';
|
||||
session.fullText = '';
|
||||
break;
|
||||
|
||||
case 'message_update': {
|
||||
// message_update contains assistantMessageEvent with content deltas
|
||||
const ame = event.assistantMessageEvent as Record<string, unknown> | undefined;
|
||||
if (!ame) break;
|
||||
|
||||
const ameType = ame.type as string;
|
||||
if (ameType === 'text_delta') {
|
||||
const delta = ame.delta as string;
|
||||
state.fullText += delta;
|
||||
session.fullText += delta;
|
||||
send(ws, { type: 'assistant:partial', text: delta });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message_end': {
|
||||
// Full assistant message complete
|
||||
if (state.fullText) {
|
||||
send(ws, { type: 'assistant:text', text: state.fullText });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
|
||||
state.fullText = '';
|
||||
if (session.fullText) {
|
||||
send(ws, { type: 'assistant:text', text: session.fullText });
|
||||
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
|
||||
session.fullText = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -199,15 +359,14 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
|
||||
const toolName = (event.toolName as string) ?? 'unknown';
|
||||
const args = (event.args as Record<string, unknown>) ?? {};
|
||||
|
||||
// Commit any streaming text before tool use
|
||||
if (state.fullText) {
|
||||
send(ws, { type: 'assistant:text', text: state.fullText });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
|
||||
state.fullText = '';
|
||||
if (session.fullText) {
|
||||
send(ws, { type: 'assistant:text', text: session.fullText });
|
||||
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
|
||||
session.fullText = '';
|
||||
}
|
||||
|
||||
send(ws, { type: 'tool:use', toolName, toolInput: args, toolUseId: toolCallId });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'tool', toolName, toolInput: args, toolUseId: toolCallId });
|
||||
if (session.logId) appendToLog(session.logId, { role: 'tool', toolName, toolInput: args, toolUseId: toolCallId });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -218,8 +377,8 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
|
||||
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
|
||||
|
||||
send(ws, { type: 'tool:result', toolUseId: toolCallId, output, isError });
|
||||
if (state.logId)
|
||||
appendToLog(state.logId, {
|
||||
if (session.logId)
|
||||
appendToLog(session.logId, {
|
||||
role: 'tool',
|
||||
toolName: '',
|
||||
toolInput: {},
|
||||
@@ -231,26 +390,24 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
// Commit any remaining streaming text
|
||||
if (state.fullText) {
|
||||
send(ws, { type: 'assistant:text', text: state.fullText });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
|
||||
state.fullText = '';
|
||||
if (session.fullText) {
|
||||
send(ws, { type: 'assistant:text', text: session.fullText });
|
||||
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
|
||||
session.fullText = '';
|
||||
}
|
||||
|
||||
send(ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
|
||||
if (state.logId) {
|
||||
appendToLog(state.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
|
||||
finalizeLog(state.logId);
|
||||
state.logId = null;
|
||||
if (session.logId) {
|
||||
appendToLog(session.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
|
||||
finalizeLog(session.logId);
|
||||
session.logId = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'extension_ui_request': {
|
||||
// Auto-cancel extension UI requests since we don't support them
|
||||
if (state.piProcess && event.id) {
|
||||
writeRpcCommand(state.piProcess, { type: 'extension_ui_response', id: event.id, cancelled: true });
|
||||
if (session.piProcess && event.id) {
|
||||
writeRpcCommand(session.piProcess, { type: 'extension_ui_response', id: event.id, cancelled: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -280,37 +437,46 @@ async function handleChat({
|
||||
resourceChatDir,
|
||||
taskInfo,
|
||||
}: HandleChatParams) {
|
||||
const state = connections.get(ws);
|
||||
if (!state) return;
|
||||
const email = ws.data.email;
|
||||
|
||||
if (taskInfo && !state.logId) {
|
||||
state.logId = createTaskLog(ws.data.email, taskInfo, 'pi-mono', model ?? 'unknown');
|
||||
appendToLog(state.logId, { role: 'user', text: prompt });
|
||||
// Determine or create session ID
|
||||
let sid = sessionId ?? wsToSession.get(ws) ?? null;
|
||||
let isNewSession = false;
|
||||
|
||||
if (!sid) {
|
||||
sid = crypto.randomUUID();
|
||||
isNewSession = true;
|
||||
}
|
||||
|
||||
if (resourceChatDir) state.resourceChatDir = resourceChatDir;
|
||||
if (model) state.selectedModel = model;
|
||||
// Attach this ws to the session (adopts existing pi process if any)
|
||||
attachWs(sid, ws);
|
||||
const session = getOrCreateSession(sid);
|
||||
|
||||
if (!sessionId && !state.sessionId) {
|
||||
// New session — generate our own sessionId for officer tracking
|
||||
const newSessionId = crypto.randomUUID();
|
||||
state.sessionId = newSessionId;
|
||||
state.pendingTitle = prompt.slice(0, 100);
|
||||
if (attachmentIds?.length) state.pendingAttachmentIds = attachmentIds;
|
||||
if (taskInfo && !session.logId) {
|
||||
session.logId = createTaskLog(email, taskInfo, 'pi-mono', model ?? 'unknown');
|
||||
appendToLog(session.logId, { role: 'user', text: prompt });
|
||||
}
|
||||
|
||||
send(ws, { type: 'session:init', sessionId: newSessionId, model: model ?? 'pi-mono' });
|
||||
if (resourceChatDir) session.resourceChatDir = resourceChatDir;
|
||||
if (model) session.selectedModel = model;
|
||||
|
||||
if (state.resourceChatDir) {
|
||||
const chatDir = join(state.resourceChatDir, 'chat');
|
||||
const meta = { id: newSessionId, model: model ?? 'pi-mono' };
|
||||
if (isNewSession) {
|
||||
const pendingTitle = prompt.slice(0, 100);
|
||||
|
||||
// Send session:init AFTER attaching ws so the pi process survives the reconnect
|
||||
send(ws, { type: 'session:init', sessionId: sid, model: model ?? 'pi-mono' });
|
||||
|
||||
if (session.resourceChatDir) {
|
||||
const chatDir = join(session.resourceChatDir, 'chat');
|
||||
const meta = { id: sid, model: model ?? 'pi-mono' };
|
||||
mkdir(chatDir, { recursive: true })
|
||||
.then(() => Bun.write(join(chatDir, 'meta.json'), JSON.stringify(meta)))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
const dir = getPiMonoSessionDir(ws.data.email, newSessionId);
|
||||
const dir = getPiMonoSessionDir(email, sid);
|
||||
const meta = {
|
||||
id: newSessionId,
|
||||
title: state.pendingTitle ?? 'New chat',
|
||||
id: sid,
|
||||
title: pendingTitle,
|
||||
createdAt: Date.now(),
|
||||
model: model ?? 'pi-mono',
|
||||
};
|
||||
@@ -319,95 +485,67 @@ async function handleChat({
|
||||
.catch(() => {});
|
||||
|
||||
// Move tmp attachments to session dir
|
||||
if (state.pendingAttachmentIds.length > 0) {
|
||||
const tmpDir = getTmpAttachmentsDir(ws.data.email);
|
||||
const destDir = getAttachmentsDir(ws.data.email, 'pi-mono', newSessionId);
|
||||
if (attachmentIds?.length) {
|
||||
const tmpDir = getTmpAttachmentsDir(email);
|
||||
const destDir = getAttachmentsDir(email, 'pi-mono', sid);
|
||||
mkdir(destDir, { recursive: true })
|
||||
.then(() =>
|
||||
Promise.all(
|
||||
state.pendingAttachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {})),
|
||||
),
|
||||
Promise.all(attachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {}))),
|
||||
)
|
||||
.catch(() => {});
|
||||
state.pendingAttachmentIds = [];
|
||||
}
|
||||
}
|
||||
state.pendingTitle = null;
|
||||
} else if (sessionId && !state.sessionId) {
|
||||
state.sessionId = sessionId;
|
||||
}
|
||||
|
||||
// Local provider models — bypass pi, call API directly
|
||||
if (session.selectedModel?.startsWith('local:')) {
|
||||
handleLocalChat(session, prompt);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure pi process is running
|
||||
const homeDir = getHomeDir(ws.data.email);
|
||||
if (cwd) state.cwd = join(resolveRootDir(ws.data.email, cwd.root), cwd.path);
|
||||
const workingDir = state.cwd ?? homeDir;
|
||||
const homeDir = getHomeDir(email);
|
||||
if (cwd) session.cwd = join(resolveRootDir(email, cwd.root), cwd.path);
|
||||
const workingDir = session.cwd ?? homeDir;
|
||||
|
||||
if (!state.piProcess) {
|
||||
spawnPiProcess(ws, state, workingDir);
|
||||
if (!session.piProcess) {
|
||||
await spawnPiProcess(session, workingDir);
|
||||
// Give pi a moment to initialize
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
if (!state.piProcess) {
|
||||
send(ws, { type: 'error', message: 'Failed to start pi process' });
|
||||
if (!session.piProcess) {
|
||||
send(session.ws, { type: 'error', message: 'Failed to start pi process' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set model if specified
|
||||
if (model && model !== state.selectedModel) {
|
||||
state.selectedModel = model;
|
||||
// Model is set via CLI args on spawn, would need a new process to change
|
||||
}
|
||||
|
||||
// Build context and send prompt
|
||||
const skillsAppend = await buildSkillsPrompt(ws.data.email);
|
||||
const skillsAppend = await buildSkillsPrompt(email);
|
||||
const contextAppend = `\n\nThe user's home directory is: ${homeDir}` + skillsAppend;
|
||||
send(ws, { type: 'system:prompt', text: contextAppend });
|
||||
send(session.ws, { type: 'system:prompt', text: contextAppend });
|
||||
|
||||
const fullPrompt = `<system>${contextAppend}</system>\n\n${prompt}`;
|
||||
|
||||
const rpcCommand: Record<string, unknown> = {
|
||||
writeRpcCommand(session.piProcess, {
|
||||
type: 'prompt',
|
||||
id: `req_${Date.now()}`,
|
||||
message: fullPrompt,
|
||||
};
|
||||
|
||||
writeRpcCommand(state.piProcess, rpcCommand);
|
||||
});
|
||||
}
|
||||
|
||||
function handleStop(ws: ServerWebSocket<WSData>) {
|
||||
const state = connections.get(ws);
|
||||
if (!state?.piProcess) return;
|
||||
const sessionId = wsToSession.get(ws);
|
||||
if (!sessionId) return;
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session?.piProcess) return;
|
||||
|
||||
writeRpcCommand(state.piProcess, { type: 'abort', id: `abort_${Date.now()}` });
|
||||
writeRpcCommand(session.piProcess, { type: 'abort', id: `abort_${Date.now()}` });
|
||||
send(ws, { type: 'stopped' });
|
||||
}
|
||||
|
||||
function killPiProcess(state: ConnectionState) {
|
||||
if (state.piProcess) {
|
||||
try {
|
||||
state.piProcess.kill();
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
state.piProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const piMonoWebsocket = {
|
||||
open(ws: ServerWebSocket<WSData>) {
|
||||
connections.set(ws, {
|
||||
piProcess: null,
|
||||
sessionId: null,
|
||||
pendingTitle: null,
|
||||
selectedModel: null,
|
||||
pendingAttachmentIds: [],
|
||||
cwd: null,
|
||||
resourceChatDir: null,
|
||||
logId: null,
|
||||
fullText: '',
|
||||
rpcReady: false,
|
||||
});
|
||||
// Nothing to do — session is attached when a chat message arrives
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||
@@ -431,17 +569,15 @@ export const piMonoWebsocket = {
|
||||
resourceChatDir: msg.resourceChatDir,
|
||||
taskInfo: msg.taskInfo,
|
||||
});
|
||||
} else if (msg.type === 'resume') {
|
||||
attachWs(msg.sessionId, ws);
|
||||
} else if (msg.type === 'stop') {
|
||||
handleStop(ws);
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
const state = connections.get(ws);
|
||||
if (state) {
|
||||
killPiProcess(state);
|
||||
}
|
||||
connections.delete(ws);
|
||||
detachWs(ws);
|
||||
},
|
||||
|
||||
drain() {},
|
||||
|
||||
@@ -1,7 +1,221 @@
|
||||
import { join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
|
||||
export const piMonoRouter = createRouter();
|
||||
|
||||
const API_KEYS_FILE = join(DATA_PATH, 'pi_mono_api_keys.json');
|
||||
const LOCAL_PROVIDERS_FILE = join(DATA_PATH, 'pi_mono_local_providers.json');
|
||||
|
||||
// --- Local provider types ---
|
||||
|
||||
export type LocalProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
apiType: 'ollama' | 'openai-compatible' | 'lmstudio';
|
||||
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string };
|
||||
};
|
||||
|
||||
type ProbeResult = {
|
||||
success: boolean;
|
||||
apiType?: LocalProvider['apiType'];
|
||||
name?: string;
|
||||
needsAuth?: boolean;
|
||||
authType?: 'api-key' | 'basic' | 'unknown';
|
||||
models?: string[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function readLocalProviders(): Promise<LocalProvider[]> {
|
||||
try {
|
||||
const file = Bun.file(LOCAL_PROVIDERS_FILE);
|
||||
if (!(await file.exists())) return [];
|
||||
return (await file.json()) as LocalProvider[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeLocalProviders(providers: LocalProvider[]) {
|
||||
await Bun.write(LOCAL_PROVIDERS_FILE, JSON.stringify(providers, null, 2));
|
||||
}
|
||||
|
||||
async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<ProbeResult> {
|
||||
const base = url.replace(/\/+$/, '');
|
||||
const timeout = 5000;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (auth?.type === 'api-key') {
|
||||
headers['Authorization'] = `Bearer ${auth.apiKey}`;
|
||||
} else if (auth?.type === 'basic') {
|
||||
headers['Authorization'] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`;
|
||||
}
|
||||
|
||||
const tryFetch = async (path: string) => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
|
||||
return res;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Try Ollama: GET /api/tags
|
||||
const ollamaRes = await tryFetch('/api/tags');
|
||||
if (ollamaRes) {
|
||||
if (ollamaRes.status === 401 || ollamaRes.status === 403) {
|
||||
return { success: true, apiType: 'ollama', name: 'Ollama', needsAuth: true, authType: 'unknown' };
|
||||
}
|
||||
if (ollamaRes.ok) {
|
||||
try {
|
||||
const data = (await ollamaRes.json()) as { models?: { name: string }[] };
|
||||
if (data.models) {
|
||||
return {
|
||||
success: true,
|
||||
apiType: 'ollama',
|
||||
name: 'Ollama',
|
||||
needsAuth: false,
|
||||
models: data.models.map((m) => m.name),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// not ollama, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try LM Studio: GET /v1/models (LM Studio returns specific format)
|
||||
// 3. Try OpenAI-compatible: GET /v1/models
|
||||
const oaiRes = await tryFetch('/v1/models');
|
||||
if (oaiRes) {
|
||||
if (oaiRes.status === 401 || oaiRes.status === 403) {
|
||||
const wwwAuth = oaiRes.headers.get('www-authenticate') ?? '';
|
||||
const authType = wwwAuth.toLowerCase().includes('basic') ? 'basic' as const : 'api-key' as const;
|
||||
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType };
|
||||
}
|
||||
if (oaiRes.ok) {
|
||||
try {
|
||||
const data = (await oaiRes.json()) as { data?: { id: string }[]; object?: string };
|
||||
if (data.data) {
|
||||
// LM Studio includes "lm-studio" in model IDs
|
||||
const isLmStudio = data.data.some((m) => m.id.includes('lm-studio'));
|
||||
const apiType = isLmStudio ? 'lmstudio' as const : 'openai-compatible' as const;
|
||||
const name = isLmStudio ? 'LM Studio' : 'OpenAI-compatible';
|
||||
return {
|
||||
success: true,
|
||||
apiType,
|
||||
name,
|
||||
needsAuth: false,
|
||||
models: data.data.map((m) => m.id),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// not valid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try bare /models (some servers)
|
||||
const bareRes = await tryFetch('/models');
|
||||
if (bareRes) {
|
||||
if (bareRes.status === 401 || bareRes.status === 403) {
|
||||
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType: 'api-key' };
|
||||
}
|
||||
if (bareRes.ok) {
|
||||
try {
|
||||
const data = (await bareRes.json()) as { data?: { id: string }[] };
|
||||
if (data.data) {
|
||||
return {
|
||||
success: true,
|
||||
apiType: 'openai-compatible',
|
||||
name: 'OpenAI-compatible',
|
||||
needsAuth: false,
|
||||
models: data.data.map((m) => m.id),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: 'Could not detect API type at this URL' };
|
||||
}
|
||||
|
||||
export async function readApiKeys(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const file = Bun.file(API_KEYS_FILE);
|
||||
if (!(await file.exists())) return {};
|
||||
return (await file.json()) as Record<string, string>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeApiKeys(keys: Record<string, string>) {
|
||||
await Bun.write(API_KEYS_FILE, JSON.stringify(keys, null, 2));
|
||||
}
|
||||
|
||||
const PROVIDERS: { key: string; env: string[] }[] = [
|
||||
{ key: 'OpenAI', env: ['OPENAI_API_KEY'] },
|
||||
{ key: 'Google', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
|
||||
{ key: 'OpenCode Zen', env: ['OPENCODE_API_KEY'] },
|
||||
{ key: 'MiniMax', env: ['MINIMAX_API_KEY'] },
|
||||
{ key: 'Groq', env: ['GROQ_API_KEY'] },
|
||||
{ key: 'Mistral', env: ['MISTRAL_API_KEY'] },
|
||||
{ key: 'xAI', env: ['XAI_API_KEY'] },
|
||||
{ key: 'OpenRouter', env: ['OPENROUTER_API_KEY'] },
|
||||
{ key: 'Hugging Face', env: ['HF_TOKEN'] },
|
||||
{ key: 'GitHub Copilot', env: ['COPILOT_GITHUB_TOKEN'] },
|
||||
{ key: 'Amazon Bedrock', env: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'] },
|
||||
{ key: 'Google Vertex AI', env: ['GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'] },
|
||||
{ key: 'Azure OpenAI', env: ['AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_BASE_URL'] },
|
||||
{ key: 'Anthropic', env: ['ANTHROPIC_API_KEY'] },
|
||||
];
|
||||
|
||||
piMonoRouter.get('/auth', async (ctx) => {
|
||||
const storedKeys = await readApiKeys();
|
||||
const providers = PROVIDERS.filter((p) =>
|
||||
p.env.some((e) => storedKeys[e]?.trim() || process.env[e]?.trim()),
|
||||
).map((p) => p.key);
|
||||
return ctx.json({ authenticated: providers.length > 0, providers });
|
||||
});
|
||||
|
||||
const maskValue = (value: string) => {
|
||||
if (value.length <= 8) return '***';
|
||||
return value.slice(0, 3) + '...' + value.slice(-3);
|
||||
};
|
||||
|
||||
piMonoRouter.get('/api-keys', async (ctx) => {
|
||||
const storedKeys = await readApiKeys();
|
||||
const keys = PROVIDERS.flatMap((p) =>
|
||||
p.env
|
||||
.filter((e) => storedKeys[e]?.trim())
|
||||
.map((e) => ({ env: e, value: maskValue(storedKeys[e]!) })),
|
||||
);
|
||||
return ctx.json({ keys });
|
||||
});
|
||||
|
||||
piMonoRouter.put('/api-keys', async (ctx) => {
|
||||
const { key, value } = await ctx.req.json<{ key: string; value: string }>();
|
||||
const allEnvs = PROVIDERS.flatMap((p) => p.env);
|
||||
if (!allEnvs.includes(key)) return ctx.json({ error: 'Invalid key' }, 400);
|
||||
|
||||
const keys = await readApiKeys();
|
||||
if (value.trim()) {
|
||||
keys[key] = value.trim();
|
||||
} else {
|
||||
delete keys[key];
|
||||
}
|
||||
await writeApiKeys(keys);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
|
||||
|
||||
const getPaths = async () => {
|
||||
@@ -52,3 +266,72 @@ piMonoRouter.post('/install', async (ctx) => {
|
||||
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Local providers ---
|
||||
|
||||
piMonoRouter.get('/local-providers', async (ctx) => {
|
||||
const providers = await readLocalProviders();
|
||||
return ctx.json(providers.map((p) => ({
|
||||
...p,
|
||||
auth: p.auth ? { type: p.auth.type } : undefined,
|
||||
})));
|
||||
});
|
||||
|
||||
piMonoRouter.post('/local-providers/probe', async (ctx) => {
|
||||
const { url, auth } = await ctx.req.json<{ url: string; auth?: LocalProvider['auth'] }>();
|
||||
if (!url?.trim()) return ctx.json({ success: false, error: 'URL is required' }, 400);
|
||||
const result = await probeUrl(url.trim(), auth);
|
||||
return ctx.json(result);
|
||||
});
|
||||
|
||||
piMonoRouter.post('/local-providers', async (ctx) => {
|
||||
const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>();
|
||||
const providers = await readLocalProviders();
|
||||
|
||||
const provider: LocalProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
name: body.name ?? body.apiType,
|
||||
url: body.url.replace(/\/+$/, ''),
|
||||
apiType: body.apiType,
|
||||
auth: body.auth,
|
||||
};
|
||||
|
||||
providers.push(provider);
|
||||
await writeLocalProviders(providers);
|
||||
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
|
||||
});
|
||||
|
||||
piMonoRouter.delete('/local-providers/:id', async (ctx) => {
|
||||
const { id } = ctx.req.param();
|
||||
const providers = await readLocalProviders();
|
||||
const filtered = providers.filter((p) => p.id !== id);
|
||||
if (filtered.length === providers.length) return ctx.json({ error: 'Not found' }, 404);
|
||||
await writeLocalProviders(filtered);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
piMonoRouter.get('/local-providers/health', async (ctx) => {
|
||||
const providers = await readLocalProviders();
|
||||
const results: Record<string, boolean> = {};
|
||||
|
||||
await Promise.all(providers.map(async (p) => {
|
||||
const base = p.url.replace(/\/+$/, '');
|
||||
const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models';
|
||||
const headers: Record<string, string> = {};
|
||||
if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`;
|
||||
else if (p.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
|
||||
results[p.id] = res.ok;
|
||||
} catch {
|
||||
results[p.id] = false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}));
|
||||
|
||||
return ctx.json(results);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user