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