Backend: - /api/pi/models now calls 'pi --list-models' with stored API keys - pi-bridge.ts: callback-based event handling (matches pi-monorepo) - pi-bridge.ts: correct RPC format (type: 'prompt' not jsonrpc) - pi-bridge.ts: pass API keys to Pi process env - websocket.ts: event handler runs in background, no blocking - rest.ts: fix user home path (getHomeDir instead of hardcoded) Frontend: - Fix /api/ double prefix in useChatSessions, useChatGroups, useModels - Add PROVIDER_DISPLAY mapping in SystemSettings.tsx - Provider tabs show friendly names (e.g., 'OpenCode Zen') UI (from previous session): - Grouped session list with collapsible folders - CreateGroupDialog, GroupContextMenu, SessionContextMenu components
534 lines
15 KiB
TypeScript
534 lines
15 KiB
TypeScript
import type { Context } from 'hono';
|
|
import { createRouter } from '../../create-router';
|
|
import * as storage from './storage';
|
|
import { readApiKeys } from '../server-settings/pi-mono';
|
|
import { getHomeDir } from '../../data-path';
|
|
import type { ModelInfo } from './types';
|
|
import { logger } from './logger';
|
|
|
|
/**
|
|
* REST API Endpoints — Session management and model info
|
|
*/
|
|
|
|
export const piRestRouter = createRouter();
|
|
|
|
/**
|
|
* GET /api/pi/models
|
|
* List available models by running `pi --list-models` with stored API keys
|
|
*/
|
|
piRestRouter.get('/pi/models', async (ctx: Context) => {
|
|
try {
|
|
const storedKeys = await readApiKeys();
|
|
|
|
const proc = Bun.spawn(['pi', '--list-models'], {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
env: { ...process.env, ...storedKeys },
|
|
});
|
|
|
|
const output = await new Response(proc.stdout).text();
|
|
await proc.exited;
|
|
|
|
if (proc.exitCode !== 0) {
|
|
logger.error('pi --list-models failed', { exitCode: proc.exitCode });
|
|
return ctx.json({ models: [] });
|
|
}
|
|
|
|
// 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: ModelInfo[] = [];
|
|
|
|
// 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, context, maxOut] = cols;
|
|
|
|
// Parse context window (e.g., "200K" -> 200000)
|
|
const parseSize = (s?: string): number => {
|
|
if (!s) return 128000;
|
|
const match = s.match(/^(\d+)([KMG])?$/i);
|
|
if (!match) return 128000;
|
|
const num = parseInt(match[1]!, 10);
|
|
const unit = (match[2] ?? '').toUpperCase();
|
|
if (unit === 'K') return num * 1000;
|
|
if (unit === 'M') return num * 1000000;
|
|
if (unit === 'G') return num * 1000000000;
|
|
return num;
|
|
};
|
|
|
|
models.push({
|
|
id: `${provider}/${model}`,
|
|
name: model!,
|
|
provider: provider!,
|
|
contextWindow: parseSize(context),
|
|
maxTokens: parseSize(maxOut),
|
|
});
|
|
}
|
|
|
|
return ctx.json({ models });
|
|
} catch (err) {
|
|
logger.error('Failed to list models', { error: String(err) });
|
|
return ctx.json({ models: [] });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/pi/sessions
|
|
* List all sessions for the current user
|
|
*/
|
|
piRestRouter.post('/pi/sessions', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
// TODO: Implement proper user home directory resolution
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
const sessions = await storage.listUserSessions(userHome);
|
|
return ctx.json({ sessions });
|
|
} catch (err) {
|
|
logger.error('Failed to list sessions', { email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Failed to list sessions' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/pi/sessions/:sessionId
|
|
* Get session detail with full message history
|
|
*/
|
|
piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const sessionId = ctx.req.param('sessionId');
|
|
if (!sessionId) {
|
|
return ctx.json({ error: 'Session ID required' }, 400);
|
|
}
|
|
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
// Try loading from root first
|
|
let meta, messages;
|
|
try {
|
|
({ meta, messages } = await storage.loadSession(userHome, sessionId));
|
|
} catch {
|
|
// Not in root, search in groups
|
|
const groups = await storage.listGroups(userHome);
|
|
let found = false;
|
|
|
|
for (const group of groups) {
|
|
try {
|
|
({ meta, messages } = await storage.loadSession(userHome, sessionId, group.slug));
|
|
found = true;
|
|
break;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
throw new Error('Session not found');
|
|
}
|
|
}
|
|
|
|
return ctx.json({
|
|
session: {
|
|
...meta,
|
|
messages,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.error('Failed to get session', { sessionId, email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Session not found' }, 404);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PATCH /api/pi/sessions/:sessionId
|
|
* Update session metadata (e.g., rename)
|
|
*/
|
|
piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const sessionId = ctx.req.param('sessionId');
|
|
if (!sessionId) {
|
|
return ctx.json({ error: 'Session ID required' }, 400);
|
|
}
|
|
|
|
const body = await ctx.req.json();
|
|
if (!body.title || typeof body.title !== 'string') {
|
|
return ctx.json({ error: 'Title is required and must be a string' }, 400);
|
|
}
|
|
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
// Find the session (root or in group)
|
|
let groupSlug: string | null = null;
|
|
|
|
try {
|
|
const { meta } = await storage.loadSession(userHome, sessionId);
|
|
groupSlug = meta.groupSlug || null;
|
|
} catch {
|
|
// Not in root, search groups
|
|
const groups = await storage.listGroups(userHome);
|
|
for (const group of groups) {
|
|
try {
|
|
await storage.loadSession(userHome, sessionId, group.slug);
|
|
groupSlug = group.slug;
|
|
break;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
const updatedMeta = await storage.updateSessionMeta(userHome, sessionId, {
|
|
title: body.title,
|
|
}, groupSlug);
|
|
|
|
return ctx.json({
|
|
success: true,
|
|
session: updatedMeta,
|
|
});
|
|
} catch (err) {
|
|
logger.error('Failed to update session', { sessionId, email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Failed to update session' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/pi/sessions/:sessionId
|
|
* Delete a session
|
|
*/
|
|
piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const sessionId = ctx.req.param('sessionId');
|
|
if (!sessionId) {
|
|
return ctx.json({ error: 'Session ID required' }, 400);
|
|
}
|
|
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
// Find the session (root or in group)
|
|
let groupSlug: string | null = null;
|
|
|
|
try {
|
|
const { meta } = await storage.loadSession(userHome, sessionId);
|
|
groupSlug = meta.groupSlug || null;
|
|
} catch {
|
|
// Not in root, search groups
|
|
const groups = await storage.listGroups(userHome);
|
|
for (const group of groups) {
|
|
try {
|
|
const { meta } = await storage.loadSession(userHome, sessionId, group.slug);
|
|
groupSlug = meta.groupSlug || null;
|
|
break;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
await storage.deleteSession(userHome, sessionId, groupSlug);
|
|
|
|
// Update group session count if in a group
|
|
if (groupSlug) {
|
|
try {
|
|
const group = await storage.loadGroup(userHome, groupSlug);
|
|
group.sessionCount = Math.max(0, group.sessionCount - 1);
|
|
group.updatedAt = Date.now();
|
|
await storage.saveGroup(userHome, group);
|
|
} catch {
|
|
// Group might not exist anymore
|
|
}
|
|
}
|
|
|
|
return ctx.json({ success: true });
|
|
} catch (err) {
|
|
logger.error('Failed to delete session', { sessionId, email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Failed to delete session' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/pi/sessions/search
|
|
* Search sessions by query
|
|
*/
|
|
piRestRouter.get('/pi/sessions/search', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const query = ctx.req.query('q');
|
|
if (!query) {
|
|
return ctx.json({ error: 'Query parameter required' }, 400);
|
|
}
|
|
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
const results = await storage.searchSessions(userHome, query);
|
|
return ctx.json({ results });
|
|
} catch (err) {
|
|
logger.error('Failed to search sessions', { query, email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Failed to search sessions' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/pi/groups
|
|
* Create a new group
|
|
*/
|
|
piRestRouter.post('/pi/groups', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const body = await ctx.req.json();
|
|
|
|
if (!body.name || typeof body.name !== 'string') {
|
|
return ctx.json({ error: 'Name is required and must be a string' }, 400);
|
|
}
|
|
|
|
if (!body.slug || typeof body.slug !== 'string') {
|
|
return ctx.json({ error: 'Slug is required and must be a string' }, 400);
|
|
}
|
|
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
// Check if group already exists
|
|
const exists = await storage.groupExists(userHome, body.slug);
|
|
if (exists) {
|
|
return ctx.json({ error: 'Group with this slug already exists' }, 409);
|
|
}
|
|
|
|
const groupMeta = {
|
|
name: body.name,
|
|
slug: body.slug,
|
|
description: body.description || '',
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
sessionCount: 0,
|
|
};
|
|
|
|
await storage.saveGroup(userHome, groupMeta);
|
|
|
|
// Move sessions to group if provided
|
|
if (body.sessionIds && Array.isArray(body.sessionIds)) {
|
|
for (const sessionId of body.sessionIds) {
|
|
try {
|
|
// Find session (check both root and other groups)
|
|
let sessionMeta;
|
|
try {
|
|
const { meta } = await storage.loadSession(userHome, sessionId);
|
|
sessionMeta = meta;
|
|
} catch {
|
|
// Session might be in another group - skip
|
|
continue;
|
|
}
|
|
|
|
await storage.moveSession(userHome, sessionId, sessionMeta.groupSlug || null, body.slug);
|
|
groupMeta.sessionCount++;
|
|
} catch (err) {
|
|
logger.error('Failed to move session to group', { sessionId, groupSlug: body.slug, error: String(err) });
|
|
}
|
|
}
|
|
|
|
// Update group with final session count
|
|
await storage.saveGroup(userHome, groupMeta);
|
|
}
|
|
|
|
return ctx.json({
|
|
success: true,
|
|
group: groupMeta,
|
|
});
|
|
} catch (err) {
|
|
logger.error('Failed to create group', { slug: body.slug, email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Failed to create group' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/pi/groups
|
|
* List all groups
|
|
*/
|
|
piRestRouter.get('/pi/groups', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
const groups = await storage.listGroups(userHome);
|
|
return ctx.json({ groups });
|
|
} catch (err) {
|
|
logger.error('Failed to list groups', { email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Failed to list groups' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PATCH /api/pi/groups/:groupSlug
|
|
* Update group metadata
|
|
*/
|
|
piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const groupSlug = ctx.req.param('groupSlug');
|
|
if (!groupSlug) {
|
|
return ctx.json({ error: 'Group slug required' }, 400);
|
|
}
|
|
|
|
const body = await ctx.req.json();
|
|
|
|
const updates: any = {};
|
|
if (body.name && typeof body.name === 'string') {
|
|
updates.name = body.name;
|
|
}
|
|
if (body.description !== undefined) {
|
|
updates.description = body.description;
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
return ctx.json({ error: 'No valid updates provided' }, 400);
|
|
}
|
|
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates);
|
|
return ctx.json({
|
|
success: true,
|
|
group: updatedGroup,
|
|
});
|
|
} catch (err) {
|
|
logger.error('Failed to update group', { groupSlug, email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Failed to update group' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/pi/groups/:groupSlug
|
|
* Delete a group (moves sessions to root)
|
|
*/
|
|
piRestRouter.delete('/pi/groups/:groupSlug', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const groupSlug = ctx.req.param('groupSlug');
|
|
if (!groupSlug) {
|
|
return ctx.json({ error: 'Group slug required' }, 400);
|
|
}
|
|
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
await storage.deleteGroup(userHome, groupSlug);
|
|
return ctx.json({ success: true });
|
|
} catch (err) {
|
|
logger.error('Failed to delete group', { groupSlug, email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Failed to delete group' }, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/pi/sessions/:sessionId/move
|
|
* Move session to/from a group
|
|
*/
|
|
piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
|
|
const user = ctx.get('user');
|
|
if (!user) {
|
|
return ctx.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const sessionId = ctx.req.param('sessionId');
|
|
if (!sessionId) {
|
|
return ctx.json({ error: 'Session ID required' }, 400);
|
|
}
|
|
|
|
const body = await ctx.req.json();
|
|
const toGroupSlug = body.groupSlug === null ? null : body.groupSlug;
|
|
|
|
if (toGroupSlug !== null && typeof toGroupSlug !== 'string') {
|
|
return ctx.json({ error: 'groupSlug must be a string or null' }, 400);
|
|
}
|
|
|
|
const userHome = getHomeDir(user.email);
|
|
|
|
try {
|
|
// Find the session in root or any group
|
|
let fromGroupSlug: string | null = null;
|
|
let sessionFound = false;
|
|
|
|
// Try root level first
|
|
try {
|
|
await storage.loadSession(userHome, sessionId);
|
|
fromGroupSlug = null;
|
|
sessionFound = true;
|
|
} catch {
|
|
// Not in root, check groups
|
|
const groups = await storage.listGroups(userHome);
|
|
for (const group of groups) {
|
|
try {
|
|
await storage.loadSession(userHome, sessionId, group.slug);
|
|
fromGroupSlug = group.slug;
|
|
sessionFound = true;
|
|
break;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!sessionFound) {
|
|
return ctx.json({ error: 'Session not found' }, 404);
|
|
}
|
|
|
|
// Validate target group exists
|
|
if (toGroupSlug !== null) {
|
|
const groupExists = await storage.groupExists(userHome, toGroupSlug);
|
|
if (!groupExists) {
|
|
return ctx.json({ error: 'Target group does not exist' }, 404);
|
|
}
|
|
}
|
|
|
|
const updatedMeta = await storage.moveSession(userHome, sessionId, fromGroupSlug, toGroupSlug);
|
|
|
|
return ctx.json({
|
|
success: true,
|
|
session: updatedMeta,
|
|
});
|
|
} catch (err) {
|
|
logger.error('Failed to move session', { sessionId, toGroupSlug, email: ctx.get('email'), error: String(err) });
|
|
return ctx.json({ error: 'Failed to move session' }, 500);
|
|
}
|
|
});
|