wip: remove opencode, searxng, resources; fix user settings read
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user