wip: remove opencode, searxng, resources; fix user settings read

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 07:27:19 +00:00
co-authored by Claude Opus 4.6
parent 5925ac49a1
commit 7bbcccabf1
46 changed files with 325 additions and 2037 deletions
+38 -19
View File
@@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router';
import { resolve, dirname, join, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path';
import { getHomeDir, DATA_PATH } from '@@/data-path';
import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readSttConfig } from '@@/api/server-settings/stt';
@@ -11,7 +11,7 @@ import { getUserSettings } from 'officerdb';
async function getUserTtsVoice(userId: number): Promise<string | null> {
try {
const settings = await getUserSettings(userId) as { tts?: { voice?: string | null } };
const settings = (await getUserSettings(userId)) as { tts?: { voice?: string | null } };
return settings.tts?.voice ?? null;
} catch {}
return null;
@@ -282,11 +282,23 @@ router.get('/transcode', async (ctx) => {
const proc = Bun.spawn(
[
'ffmpeg', '-i', absPath,
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
'-y', tmpPath,
'ffmpeg',
'-i',
absPath,
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-crf',
'23',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
'-y',
tmpPath,
],
{ stdout: 'ignore', stderr: 'pipe' },
);
@@ -343,10 +355,10 @@ router.get('/transcode-audio', async (ctx) => {
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory');
const proc = Bun.spawn(
['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'],
{ stdout: 'pipe', stderr: 'ignore' },
);
const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'], {
stdout: 'pipe',
stderr: 'ignore',
});
return new Response(proc.stdout as ReadableStream, {
headers: {
@@ -799,13 +811,10 @@ router.post('/transcribe', async (ctx) => {
// Step 2: Check user's spoken languages to decide if translation is needed
let shouldTranslate = false;
const settingsFile = Bun.file(getUserSettingsFile(user.email));
if (await settingsFile.exists()) {
const settings = (await settingsFile.json()) as { languages?: { spoken?: string[] } };
const spokenLanguages = settings.languages?.spoken ?? [];
if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) {
shouldTranslate = true;
}
const settings = await getUserSettings(user.id);
const spokenLanguages = (settings.languages as { spoken?: string[] })?.spoken ?? [];
if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) {
shouldTranslate = true;
}
// Step 3: Full transcription
@@ -997,7 +1006,17 @@ router.post('/download-video', async (ctx) => {
const absPath = resolveUserPath(rootDir, path);
await mkdir(absPath, { recursive: true });
const ytdlp = Bun.which('yt-dlp') ?? `${process.env.HOME}/.local/bin/yt-dlp`;
const args = [ytdlp, '--remote-components', 'ejs:github', '--js-runtimes', 'node', '--cookies-from-browser', 'brave', '-o', '%(title)s.%(ext)s'];
const args = [
ytdlp,
'--remote-components',
'ejs:github',
'--js-runtimes',
'node',
'--cookies-from-browser',
'brave',
'-o',
'%(title)s.%(ext)s',
];
if (audioOnly) args.push('-x', '--audio-format', 'mp3');
args.push(url);
+20 -145
View File
@@ -1,8 +1,7 @@
import { join } from 'path';
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { readdirSync, existsSync, mkdirSync } from 'node:fs';
import type { Subprocess } from 'bun';
import type { PiEvent, MessageCost } from './types';
import { readSearxngConfig } from '../server-settings/searxng';
import {
PI_CONFIG_DIR,
DATA_PATH,
@@ -13,13 +12,10 @@ import {
getUserExtensionsDir,
getGlobalToolsDir,
getUserToolsDir,
getNativeResourcesDir,
getGlobalResourcesDir,
toShellUsername,
} from '../../data-path';
import { getServerIntegration, getUserIntegration } from 'officerdb';
import { getServerIntegration, getUserIntegration, readConfigValue } from 'officerdb';
import { logger } from './logger';
import { parseFrontmatter } from '../skills/skills';
import { getRelayPort } from '../browser/relay';
import { registerUserToken } from '../browser/relay-auth';
@@ -75,125 +71,6 @@ function collectExtensionFlags(email: string): string[] {
return flags;
}
export function generateResourceSkill(outputDir: string): string | null {
const nativeDir = getNativeResourcesDir();
const globalDir = getGlobalResourcesDir();
// Collect all resource dirs (global overrides native)
const resourceDirs = new Map<string, string>();
for (const dir of [nativeDir, globalDir]) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(dir, entry.name, 'RESOURCE.md'))) {
resourceDirs.set(entry.name, dir);
}
}
}
if (resourceDirs.size === 0) return null;
const sections: string[] = [];
for (const [name, baseDir] of resourceDirs) {
const resourceMd = join(baseDir, name, 'RESOURCE.md');
let mdContent = '';
try {
mdContent = readFileSync(resourceMd, 'utf-8');
} catch {
continue;
}
const { frontmatter } = parseFrontmatter(mdContent);
// Merge native + global config
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try {
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
} catch {}
try {
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
} catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!;
const hasValues = Object.values(config).some((v) => v !== '');
const configLines = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => (/key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`));
sections.push(
[
`### ${frontmatter.name || name}`,
hasValues ? 'Status: **configured**' : 'Status: not configured',
...configLines,
].join('\n'),
);
}
const skillContent = [
'---',
'name: Available Resources',
'description: External services and APIs configured on this Officer instance',
'---',
'',
'These are external services available to you. Use their configured URLs directly via HTTP requests.',
'Do NOT try to install local alternatives (like tesseract, whisper, etc.) — use the configured HTTP APIs instead.',
'',
...sections,
].join('\n');
const skillDir = join(outputDir, '.generated', 'available-resources');
try {
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8');
return skillDir;
} catch {
logger.error(`Failed to write resource skill to ${skillDir}`);
return null;
}
}
function buildResourcesEnv(): string {
const nativeDir = getNativeResourcesDir();
const globalDir = getGlobalResourcesDir();
const resourceDirs = new Map<string, string>();
for (const dir of [nativeDir, globalDir]) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(dir, entry.name, 'RESOURCE.md')) || existsSync(join(dir, entry.name, 'config.json'))) {
resourceDirs.set(entry.name, dir);
}
}
}
const result: Record<string, Record<string, string>> = {};
for (const [name] of resourceDirs) {
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try {
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
} catch {}
try {
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
} catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!;
// Only include resources that have at least one non-empty value
if (Object.values(config).some((v) => v !== '')) {
result[name] = config;
}
}
return JSON.stringify(result);
}
async function getApifyToken(): Promise<string> {
try {
const integration = await getServerIntegration('apify');
@@ -248,13 +125,10 @@ export async function spawnPi(
onEvent: PiEventHandler,
options?: SpawnPiOptions,
): Promise<Subprocess> {
const searxng = await readSearxngConfig();
const searxngUrl = await readConfigValue('searxng-url', '');
const skillFlags = collectSkillFlags(email);
const extensionFlags = collectExtensionFlags(email);
const resourceSkillDir = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
const piArgs = [
...PI_CMD,
'--mode',
@@ -264,7 +138,6 @@ export async function spawnPi(
'--no-themes',
...skillFlags,
...extensionFlags,
...resourceSkillFlags,
];
if (model) piArgs.push('--model', model);
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
@@ -290,8 +163,7 @@ export async function spawnPi(
OFFICER_USER_ROOT: join(DATA_PATH, email),
PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'),
PI_TOOLS_DIRS: toolsDirs,
PI_SEARXNG_URL: searxng.url,
OFFICER_RESOURCES: buildResourcesEnv(),
PI_SEARXNG_URL: searxngUrl,
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
TERM: 'xterm-256color',
PATH: process.env.PATH ?? '',
@@ -465,12 +337,14 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
const toolName = (event.toolName as string) ?? 'unknown';
const args = (event.args as Record<string, unknown>) ?? {};
return [{
type: 'tool:start',
toolCallId,
toolName,
toolInput: args,
}];
return [
{
type: 'tool:start',
toolCallId,
toolName,
toolInput: args,
},
];
}
case 'tool_execution_end': {
@@ -489,12 +363,14 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false;
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
return [{
type: 'tool:result',
toolCallId,
output,
isError,
}];
return [
{
type: 'tool:result',
toolCallId,
output,
isError,
},
];
}
case 'agent_end': {
@@ -586,7 +462,6 @@ export async function buildHostToolEnv(userId: number, email: string, role?: str
HOME: homeDir,
OFFICER_USER_HOME: homeDir,
OFFICER_USER_ROOT: join(DATA_PATH, email),
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}),
...browserRelayEnv,
+1 -1
View File
@@ -33,7 +33,7 @@ scrapeRouter.post('/', async (ctx) => {
const { url, sessionId, provider } = ctx.get('body') as {
url: string;
sessionId?: string;
provider?: 'claude' | 'opencode' | 'pi-mono';
provider?: 'claude' | 'pi-mono';
};
if (!url) return ctx.json({ error: 'url is required' }, 400);
-4
View File
@@ -1,6 +1,5 @@
import { createRouter } from '../../create-router';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { readResourceConfig } from './resources';
type OcrConfig = {
url: string;
@@ -8,9 +7,6 @@ type OcrConfig = {
};
export async function readOcrConfig(): Promise<OcrConfig | undefined> {
const config = await readResourceConfig('optical-character-recognition');
if (config.url) return { url: config.url, model: config.model ?? '' };
// Fallback to legacy DB settings
const settings = await readServerSettings();
return settings.ocr as OcrConfig | undefined;
}
@@ -1,76 +0,0 @@
import { createRouter } from '../../create-router';
export const opencodeRouter = createRouter();
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
const getPaths = async () => {
try {
const proc = Bun.spawn(['which', '-a', 'opencode'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return { path: null, globalPath: null };
const paths = [...new Set(output.trim().split('\n'))];
const path = paths[0] ?? null;
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
return { path, globalPath };
} catch {
return { path: null, globalPath: null };
}
};
opencodeRouter.post('/auth/login', async (ctx) => {
try {
Bun.spawn(['opencode', 'auth', 'login'], { stdout: 'ignore', stderr: 'ignore' });
return ctx.json({ started: true });
} catch {
return ctx.json({ started: false, error: 'Failed to start login' }, 500);
}
});
opencodeRouter.get('/auth', async (ctx) => {
try {
const authPath = `${process.env.HOME}/.local/share/opencode/auth.json`;
const file = Bun.file(authPath);
if (!(await file.exists())) return ctx.json({ authenticated: false, providers: [] });
const auth = await file.json();
const providers = Object.keys(auth);
return ctx.json({ authenticated: providers.length > 0, providers });
} catch {
return ctx.json({ authenticated: false, providers: [] });
}
});
opencodeRouter.post('/install', async (ctx) => {
try {
const proc = Bun.spawn(['bash', '-c', 'curl -fsSL https://opencode.ai/install | bash'], {
stdout: 'pipe',
stderr: 'pipe',
});
await proc.exited;
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
}
const versionProc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(versionProc.stdout).text();
await versionProc.exited;
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
});
opencodeRouter.get('/version', async (ctx) => {
try {
const proc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null });
}
});
@@ -1,235 +0,0 @@
import { readdir, mkdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { createRouter } from '../../create-router';
import { getNativeResourcesDir, getGlobalResourcesDir } from '../../data-path';
import { parseFrontmatter } from '../skills/skills';
async function readResourceDirs(dir: string): Promise<Map<string, string>> {
const result = new Map<string, string>();
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const resourceFile = join(dir, entry.name, 'RESOURCE.md');
if (await Bun.file(resourceFile).exists()) {
result.set(entry.name, resourceFile);
}
}
} catch {
// directory doesn't exist yet
}
return result;
}
async function readConfigFile(dir: string): Promise<Record<string, string>> {
try {
return await Bun.file(join(dir, 'config.json')).json();
} catch {
return {};
}
}
function mergeConfig(native: Record<string, string>, global: Record<string, string>): Record<string, string> {
const merged: Record<string, string> = {};
for (const key of Object.keys(native)) {
merged[key] = global[key] ?? native[key]!;
}
for (const key of Object.keys(global)) {
if (!(key in merged)) merged[key] = global[key]!;
}
return merged;
}
function isPrivileged(role: string) {
return role === 'Super Admin';
}
export async function readResourceConfig(name: string): Promise<Record<string, string>> {
const nativeConfig = await readConfigFile(join(getNativeResourcesDir(), name));
const globalConfig = await readConfigFile(join(getGlobalResourcesDir(), name));
return mergeConfig(nativeConfig, globalConfig);
}
const CHECK_TIMEOUT_MS = 3_000;
export const resourcesRouter = createRouter();
resourcesRouter.get('/', async (ctx) => {
const nativeResources = await readResourceDirs(getNativeResourcesDir());
const globalResources = await readResourceDirs(getGlobalResourcesDir());
const merged = new Map(nativeResources);
for (const [name, path] of globalResources) merged.set(name, path);
const resources = await Promise.all(
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter } = parseFrontmatter(raw);
const scope = globalResources.has(dirName) && !nativeResources.has(dirName) ? 'global' as const : nativeResources.has(dirName) ? 'native' as const : 'global' as const;
const config = await readResourceConfig(dirName);
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, scope, config };
}),
);
return ctx.json(resources);
});
resourcesRouter.get('/:name', async (ctx) => {
const name = ctx.req.param('name');
const nativeResources = await readResourceDirs(getNativeResourcesDir());
const globalResources = await readResourceDirs(getGlobalResourcesDir());
const filePath = globalResources.get(name) ?? nativeResources.get(name);
if (!filePath) return ctx.text('Not found', 404);
const scope = globalResources.has(name) && !nativeResources.has(name) ? 'global' as const : 'native' as const;
const raw = await Bun.file(filePath).text();
const { frontmatter, body, rawYaml } = parseFrontmatter(raw);
const config = await readResourceConfig(name);
const globalConfigPath = join(getGlobalResourcesDir(), name, 'config.json');
const chatMeta = join(dirname(filePath), 'chat', 'meta.json');
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
const guidePath = join(getNativeResourcesDir(), 'GUIDE.md');
return ctx.json({
dirName: name,
name: frontmatter.name || name,
description: frontmatter.description,
scope,
body,
rawFrontmatter: rawYaml,
filePath,
config,
configPath: globalConfigPath,
chatSessionId,
guidePath,
});
});
resourcesRouter.patch('/:name/config', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const name = ctx.req.param('name');
const body = await ctx.req.json<Record<string, string | null>>();
const globalDir = join(getGlobalResourcesDir(), name);
await mkdir(globalDir, { recursive: true });
const existing = await readConfigFile(globalDir);
for (const [key, value] of Object.entries(body)) {
if (value === null) delete existing[key];
else existing[key] = value;
}
await Bun.write(join(globalDir, 'config.json'), JSON.stringify(existing, null, 2));
const merged = await readResourceConfig(name);
return ctx.json(merged);
});
resourcesRouter.post('/', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const { name } = await ctx.req.json<{ name: string }>();
if (!name?.trim()) return ctx.text('Name is required', 400);
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
if (!dirName) return ctx.text('Invalid name', 400);
const dir = join(getGlobalResourcesDir(), dirName);
const filePath = join(dir, 'RESOURCE.md');
if (await Bun.file(filePath).exists()) {
return ctx.text('Resource already exists', 409);
}
await mkdir(dir, { recursive: true });
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
await Bun.write(join(dir, 'config.json'), JSON.stringify({ url: '', api_key: '', username: '', password: '' }, null, 2));
return ctx.json({ name: name.trim(), dirName, filePath, scope: 'global' });
});
resourcesRouter.delete('/:name', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const name = ctx.req.param('name');
const nativeResources = await readResourceDirs(getNativeResourcesDir());
if (nativeResources.has(name)) return ctx.text('Cannot delete native resource', 400);
const dir = join(getGlobalResourcesDir(), name);
const filePath = join(dir, 'RESOURCE.md');
if (!(await Bun.file(filePath).exists())) return ctx.text('Not found', 404);
await rm(dir, { recursive: true });
return ctx.json({ ok: true });
});
resourcesRouter.get('/:name/chat', async (ctx) => {
const name = ctx.req.param('name');
const nativeResources = await readResourceDirs(getNativeResourcesDir());
const globalResources = await readResourceDirs(getGlobalResourcesDir());
const filePath = globalResources.get(name) ?? nativeResources.get(name);
if (!filePath) return ctx.text('Not found', 404);
const chatDir = join(getGlobalResourcesDir(), name, 'chat');
const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null);
const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []);
return ctx.json({ sessionId, messages });
});
resourcesRouter.put('/:name/chat', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const name = ctx.req.param('name');
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
const chatDir = join(getGlobalResourcesDir(), name, 'chat');
await mkdir(chatDir, { recursive: true });
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
return ctx.json({ ok: true });
});
resourcesRouter.delete('/:name/chat', async (ctx) => {
const user = ctx.get('user');
if (!isPrivileged(user.role)) return ctx.text('Forbidden', 403);
const name = ctx.req.param('name');
const chatDir = join(getGlobalResourcesDir(), name, 'chat');
await rm(chatDir, { recursive: true, force: true });
return ctx.json({ ok: true });
});
resourcesRouter.post('/:name/ping', async (ctx) => {
const name = ctx.req.param('name');
const body = await ctx.req.json<{ url?: string }>().catch((): { url?: string } => ({}));
const config = await readResourceConfig(name);
const url = body.url ?? config.url;
if (!url) return ctx.json({ error: 'No URL configured' }, 400);
try {
const start = performance.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
const latencyMs = Math.round(performance.now() - start);
return ctx.json({ reachable: true, latencyMs });
} catch {
return ctx.json({ reachable: false, latencyMs: null });
}
});
@@ -1,39 +0,0 @@
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { DATA_PATH } from '../../data-path';
export const searxngRouter = createRouter();
const SEARXNG_FILE = join(DATA_PATH, 'searxng.json');
const DEFAULT_URL = 'https://searxng.home.pastilhas.eu';
export type SearxngConfig = {
url: string;
};
export async function readSearxngConfig(): Promise<SearxngConfig> {
try {
const file = Bun.file(SEARXNG_FILE);
if (!(await file.exists())) return { url: DEFAULT_URL };
return (await file.json()) as SearxngConfig;
} catch {
return { url: DEFAULT_URL };
}
}
async function writeSearxngConfig(config: SearxngConfig) {
await Bun.write(SEARXNG_FILE, JSON.stringify(config, null, 2));
}
searxngRouter.get('/', async (ctx) => {
return ctx.json(await readSearxngConfig());
});
searxngRouter.put('/', async (ctx) => {
const { url } = await ctx.req.json<{ url: string }>();
if (!url?.trim()) return ctx.json({ error: 'URL is required' }, 400);
const config: SearxngConfig = { url: url.trim().replace(/\/+$/, '') };
await writeSearxngConfig(config);
return ctx.json(config);
});
@@ -3,28 +3,22 @@ import { readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { claudeCodeRouter } from './claude-code';
import { opencodeRouter } from './opencode';
import { piMonoRouter } from './pi-mono';
import { applicationsRouter } from './applications';
import { resourcesRouter } from './resources';
import { smtpRouter } from './smtp';
import { ttsRouter } from './tts';
import { sttRouter } from './stt';
import { ocrRouter } from './ocr';
import { searxngRouter } from './searxng';
export const serverSettingsRouter = createRouter();
serverSettingsRouter.route('/claude-code', claudeCodeRouter);
serverSettingsRouter.route('/opencode', opencodeRouter);
serverSettingsRouter.route('/pi-mono', piMonoRouter);
serverSettingsRouter.route('/applications', applicationsRouter);
serverSettingsRouter.route('/resources', resourcesRouter);
serverSettingsRouter.route('/smtp', smtpRouter);
serverSettingsRouter.route('/tts', ttsRouter);
serverSettingsRouter.route('/stt', sttRouter);
serverSettingsRouter.route('/ocr', ocrRouter);
serverSettingsRouter.route('/searxng', searxngRouter);
export { readServerSettings as readSettings };
-4
View File
@@ -1,15 +1,11 @@
import { createRouter } from '../../create-router';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { readResourceConfig } from './resources';
type SttConfig = {
url: string;
};
export async function readSttConfig(): Promise<SttConfig | undefined> {
const config = await readResourceConfig('speech-to-text');
if (config.url) return { url: config.url };
// Fallback to legacy DB settings
const settings = await readServerSettings();
return settings.stt as SttConfig | undefined;
}
+2 -14
View File
@@ -1,6 +1,5 @@
import { createRouter } from '../../create-router';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { readResourceConfig } from './resources';
type TtsConfig = {
provider: 'openai' | 'elevenlabs';
@@ -16,19 +15,8 @@ function maskSecret(value: string | undefined): string | undefined {
}
export async function readTtsConfig(): Promise<TtsConfig | undefined> {
const config = await readResourceConfig('text-to-speech');
if (!config.url && !config.provider) {
// Fallback to legacy DB settings
const settings = await readServerSettings();
return settings.tts as TtsConfig | undefined;
}
return {
provider: (config.provider || 'openai') as 'openai' | 'elevenlabs',
url: config.url ?? '',
apiKey: config.api_key || undefined,
model: config.model ?? '',
voice: config.voice ?? '',
};
const settings = await readServerSettings();
return settings.tts as TtsConfig | undefined;
}
export const ttsRouter = createRouter();
+25 -141
View File
@@ -1,12 +1,9 @@
import { Hono } from 'hono';
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getClaudeDir, getSessionDir, getArchivedSessionDir, getOpencodeDir, getOpencodeSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path';
import { getClaudeDir, getSessionDir, getArchivedSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path';
import type { HonoVariables } from '@@/create-router';
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
const OPENCODE_BASE = `http://localhost:${OPENCODE_PORT}`;
export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
// --- List all sessions (merged from both providers) ---
@@ -14,13 +11,9 @@ export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
sessionsRouter.get('/sessions', async (ctx) => {
const { email } = ctx.get('user');
const [claudeSessions, opencodeSessions, piMonoSessions] = await Promise.all([
fetchClaudeSessions(email),
fetchOpencodeSessions(email),
fetchPiMonoSessions(email),
]);
const [claudeSessions, piMonoSessions] = await Promise.all([fetchClaudeSessions(email), fetchPiMonoSessions(email)]);
const merged = [...claudeSessions, ...opencodeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt);
const merged = [...claudeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt);
return ctx.json(merged);
});
@@ -34,17 +27,21 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
if (provider === 'claude') {
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
}
if (provider === 'opencode') {
return ctx.json(await fetchOpencodeMessages(id));
try {
return ctx.json(await file.json());
} catch {
return ctx.json([]);
}
}
if (provider === 'pi-mono') {
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
try {
return ctx.json(await file.json());
} catch {
return ctx.json([]);
}
}
return ctx.json({ error: 'invalid provider' }, 400);
@@ -55,7 +52,6 @@ sessionsRouter.put('/sessions/:provider/:id/messages', async (ctx) => {
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'opencode') return ctx.json({ error: 'opencode sessions are read-only' }, 400);
if (provider !== 'claude' && provider !== 'pi-mono') return ctx.json({ error: 'invalid provider' }, 400);
const messages = ctx.get('body');
@@ -79,37 +75,26 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
try {
meta = await metaFile.json();
} catch {
return ctx.json({ error: 'corrupted session' }, 500);
}
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
}
if (provider === 'opencode') {
const dir = getOpencodeSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
// Best-effort sync to OpenCode API
fetch(`${OPENCODE_BASE}/session/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: meta.title }),
}).catch(() => {});
return ctx.json({ ok: true });
}
if (provider === 'pi-mono') {
const dir = getPiMonoSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
try {
meta = await metaFile.json();
} catch {
return ctx.json({ error: 'corrupted session' }, 500);
}
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
@@ -135,18 +120,6 @@ sessionsRouter.delete('/sessions/:provider/:id', async (ctx) => {
return ctx.json({ ok: true });
}
if (provider === 'opencode') {
const dir = getOpencodeSessionDir(email, id);
try {
await rm(dir, { recursive: true });
} catch {
// dir may not exist
}
// Best-effort sync to OpenCode API
fetch(`${OPENCODE_BASE}/session/${id}`, { method: 'DELETE' }).catch(() => {});
return ctx.json({ ok: true });
}
if (provider === 'pi-mono') {
const dir = getPiMonoSessionDir(email, id);
try {
@@ -167,7 +140,7 @@ sessionsRouter.post('/sessions/:provider/:id/archive', async (ctx) => {
const provider = ctx.req.param('provider');
const id = ctx.req.param('id');
if (provider === 'opencode' || provider === 'pi-mono') return ctx.json({ error: `${provider} sessions cannot be archived` }, 400);
if (provider === 'pi-mono') return ctx.json({ error: 'pi-mono sessions cannot be archived' }, 400);
if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400);
const src = getSessionDir(email, id);
@@ -183,7 +156,7 @@ type SessionMeta = {
id: string;
title: string;
createdAt: number;
provider: 'claude' | 'opencode' | 'pi-mono';
provider: 'claude' | 'pi-mono';
model?: string | null;
};
@@ -211,28 +184,6 @@ async function fetchClaudeSessions(email: string): Promise<SessionMeta[]> {
}
}
async function fetchOpencodeSessions(email: string): Promise<SessionMeta[]> {
const dir = getOpencodeDir(email);
try {
const entries = await readdir(dir);
const sessions = await Promise.all(
entries.map(async (id) => {
try {
const metaFile = Bun.file(join(dir, id, 'meta.json'));
if (!(await metaFile.exists())) return null;
const meta = await metaFile.json();
return { ...meta, provider: 'opencode' as const };
} catch {
return null;
}
}),
);
return sessions.filter((s): s is SessionMeta => s !== null);
} catch {
return [];
}
}
async function fetchPiMonoSessions(email: string): Promise<SessionMeta[]> {
const dir = getPiMonoDir(email);
try {
@@ -254,70 +205,3 @@ async function fetchPiMonoSessions(email: string): Promise<SessionMeta[]> {
return [];
}
}
async function fetchOpencodeMessages(id: string) {
try {
const res = await fetch(`${OPENCODE_BASE}/session/${id}/message`);
if (!res.ok) return [];
const data = (await res.json()) as any[];
const messages = Array.isArray(data) ? data : Object.values(data);
const chatMessages: any[] = [];
for (const msg of messages) {
const role = msg.info?.role ?? msg.role;
if (role === 'user') {
const text = Array.isArray(msg.parts)
? msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text ?? p.content ?? '')
.join('')
: typeof msg.content === 'string'
? msg.content
: '';
if (text) chatMessages.push({ role: 'user', text });
} else if (role === 'assistant') {
if (Array.isArray(msg.parts)) {
for (const part of msg.parts) {
if (part.type === 'text' && (part.text || part.content)) {
chatMessages.push({ role: 'assistant', text: part.text ?? part.content ?? '' });
} else if (part.type === 'tool') {
chatMessages.push({
role: 'tool',
toolName: part.tool ?? 'unknown',
toolInput: part.state?.input ?? {},
toolUseId: part.callID ?? part.id ?? '',
output:
part.state?.output != null
? typeof part.state.output === 'string'
? part.state.output
: JSON.stringify(part.state.output)
: undefined,
isError: part.state?.status === 'error',
});
} else if (part.type === 'tool-invocation') {
const inv = part.toolInvocation ?? part;
chatMessages.push({
role: 'tool',
toolName: inv.toolName ?? 'unknown',
toolInput: inv.args ?? {},
toolUseId: inv.toolCallId ?? part.id ?? '',
output:
inv.result != null
? typeof inv.result === 'string'
? inv.result
: JSON.stringify(inv.result)
: undefined,
isError: !!part.isError,
});
}
}
}
}
}
return chatMessages;
} catch {
return [];
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ uploadRouter.post('/', async (ctx) => {
const file = body.file as File | null;
const sessionId = (body.sessionId as string) || null;
const provider = (body.provider as 'claude' | 'opencode' | 'pi-mono') || null;
const provider = (body.provider as 'claude' | 'pi-mono') || null;
if (!file || !(file instanceof File)) {
return ctx.json({ error: 'file is required' }, 400);