Files FIles Files

This commit is contained in:
2026-02-20 04:28:02 +00:00
parent 1f7eb64eb3
commit d7503ca56b
20 changed files with 2195 additions and 633 deletions
+301 -2
View File
@@ -1,16 +1,23 @@
import { createRouter } from '@@/create-router';
import { resolve, dirname } from 'node:path';
import { resolve, dirname, join, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { getHomeDir } from '@@/data-path';
import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path';
import * as errors from '@@/custom-errors';
import { readConfig } from '@@/api/server-settings/resources';
export const router = createRouter();
type UserCtx = { email: string; role: string | null };
function getUserDataDir(email: string): string {
return join(DATA_PATH, email);
}
function getRootDir(user: UserCtx, root?: string): string {
if (!root || root === 'home') return getHomeDir(user.email);
if (root === 'user-data') return getUserDataDir(user.email);
if (user.role !== 'Super Admin') throw errors.FORBIDDEN('Only Super Admin can access this root');
if (root === '~') return homedir();
if (root === 'officer.dev') return resolve(process.cwd(), '..');
@@ -244,6 +251,298 @@ router.get('/transcode', async (ctx) => {
});
});
// Text-to-speech with caching
router.post('/tts', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory');
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
const cacheRel = dir ? `tts/${dir}/${name}.mp3` : `tts/${name}.mp3`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (existsSync(cacheAbs)) {
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
}
const config = await readConfig();
const kokoroUrl = config.kokoro?.url;
if (!kokoroUrl) throw errors.BAD_REQUEST('Kokoro TTS not configured');
const content = await readFile(absPath, 'utf-8');
const res = await fetch(`${kokoroUrl}/v1/audio/speech`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'kokoro', input: content, voice: 'af_heart', response_format: 'mp3' }),
});
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
await mkdir(dirname(cacheAbs), { recursive: true });
const buffer = await res.arrayBuffer();
await Bun.write(cacheAbs, buffer);
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
});
// OCR image via vision model with caching
router.post('/ocr', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot OCR a directory');
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
const cacheRel = dir ? `ocr/${dir}/${name}.md` : `ocr/${name}.md`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (existsSync(cacheAbs)) {
const text = await readFile(cacheAbs, 'utf-8');
return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: true });
}
const config = await readConfig();
const llamaUrl = config.llama?.url;
if (!llamaUrl) throw errors.BAD_REQUEST('llama.cpp not configured');
const imageBytes = await Bun.file(absPath).arrayBuffer();
const base64 = Buffer.from(imageBytes).toString('base64');
const ext = absPath.split('.').pop()?.toLowerCase() ?? 'png';
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : `image/${ext}`;
const res = await fetch(`${llamaUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'Qwen2.5-VL-7B-Instruct-q4_k_m.gguf',
messages: [
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } },
{ type: 'text', text: 'Extract all text from this image. Return only the extracted text, nothing else.' },
],
},
],
}),
});
if (!res.ok) throw errors.BAD_REQUEST('OCR request failed');
const json = (await res.json()) as { choices: { message: { content: string } }[] };
const text = json.choices[0]?.message?.content ?? '';
await mkdir(dirname(cacheAbs), { recursive: true });
await Bun.write(cacheAbs, text);
return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: false });
});
// Extract audio from video via ffmpeg with caching
router.post('/extract-audio', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract audio from a directory');
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
const cacheRel = dir ? `audio/${dir}/${name}.mp3` : `audio/${name}.mp3`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (existsSync(cacheAbs)) {
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: true });
}
await mkdir(dirname(cacheAbs), { recursive: true });
const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-vn', '-codec:a', 'libmp3lame', '-q:a', '2', '-y', cacheAbs], {
stdout: 'ignore',
stderr: 'pipe',
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw errors.BAD_REQUEST(stderr.trim() || 'Audio extraction failed');
}
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: false });
});
// Extract archive (zip, tar, 7z, rar) into a sibling folder
router.post('/extract', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract a directory');
const fileName = absPath.split('/').pop()!;
const lower = fileName.toLowerCase();
// Determine archive type and build command
type ArchiveType = 'tar' | 'zip' | '7z' | 'rar';
let archiveType: ArchiveType;
if (
lower.endsWith('.tar') ||
lower.endsWith('.tar.gz') ||
lower.endsWith('.tgz') ||
lower.endsWith('.tar.bz2') ||
lower.endsWith('.tbz2') ||
lower.endsWith('.tar.xz') ||
lower.endsWith('.txz') ||
lower.endsWith('.tar.zst') ||
lower.endsWith('.gz') ||
lower.endsWith('.bz2') ||
lower.endsWith('.xz') ||
lower.endsWith('.zst')
) {
archiveType = 'tar';
} else if (lower.endsWith('.zip')) {
archiveType = 'zip';
} else if (lower.endsWith('.7z')) {
archiveType = '7z';
} else if (lower.endsWith('.rar')) {
archiveType = 'rar';
} else {
throw errors.BAD_REQUEST('Unsupported archive format');
}
// Compute destination folder name (strip archive extension)
const stripArchiveExt = (name: string): string => {
const l = name.toLowerCase();
for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) {
if (l.endsWith(compound)) return name.slice(0, -compound.length);
}
const dotIdx = name.lastIndexOf('.');
return dotIdx > 0 ? name.slice(0, dotIdx) : name;
};
const baseName = stripArchiveExt(fileName);
const destPath = resolve(dirname(absPath), baseName);
const finalDest = await resolveCollision(destPath);
await mkdir(finalDest, { recursive: true });
let cmd: string[];
switch (archiveType) {
case 'tar':
cmd = ['tar', 'xf', absPath, '-C', finalDest];
break;
case 'zip':
cmd = ['unzip', '-q', absPath, '-d', finalDest];
break;
case '7z':
cmd = ['7z', 'x', absPath, `-o${finalDest}`, '-y'];
break;
case 'rar':
cmd = ['unrar', 'x', '-o+', absPath, `${finalDest}/`];
break;
}
const proc = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' });
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
// Clean up the empty directory on failure
await rm(finalDest, { recursive: true, force: true }).catch(() => {});
throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed');
}
const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
return ctx.json({ extractedPath });
});
// Transcribe audio via Whisper with caching
// Workflow: detect language → check user's spoken languages → translate if needed → transcribe
router.post('/transcribe', async (ctx) => {
const user = ctx.get('user');
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
if (!filePath) throw errors.BAD_REQUEST('path is required');
const rootDir = getRootDir(user, root);
const absPath = resolveUserPath(rootDir, filePath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcribe a directory');
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
const cacheRel = dir ? `transcriptions/${dir}/${name}.md` : `transcriptions/${name}.md`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (existsSync(cacheAbs)) {
return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: true });
}
const config = await readConfig();
const whisperUrl = config.whisper?.url;
if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured');
const audioFile = Bun.file(absPath);
// Step 1: Detect language
const detectForm = new FormData();
detectForm.append('file', audioFile);
detectForm.append('temperature', '0.0');
detectForm.append('response_format', 'verbose_json');
detectForm.append('detect_language', 'true');
const detectRes = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: detectForm });
if (!detectRes.ok) throw errors.BAD_REQUEST('Language detection failed');
const detectJson = (await detectRes.json()) as { language?: string };
const detectedLang = detectJson.language ?? 'en';
// 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;
}
}
// Step 3: Full transcription
const transcribeForm = new FormData();
transcribeForm.append('file', audioFile);
transcribeForm.append('temperature', '0.0');
transcribeForm.append('temperature_inc', '0.2');
transcribeForm.append('response_format', 'text');
transcribeForm.append('language', detectedLang);
if (shouldTranslate) {
transcribeForm.append('translate', 'true');
}
const res = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: transcribeForm });
if (!res.ok) throw errors.BAD_REQUEST('Transcription request failed');
const text = (await res.text()).trim();
await mkdir(dirname(cacheAbs), { recursive: true });
await Bun.write(cacheAbs, text);
return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: false });
});
// Search files by name
router.get('/search', async (ctx) => {
const user = ctx.get('user');
+33 -8
View File
@@ -2,7 +2,7 @@ import { readdir, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { getResourcesDir } from '../../data-path';
import { DATA_PATH, getResourcesDir } from '../../data-path';
type ResourceCredentials = {
apiKey?: string;
@@ -10,7 +10,7 @@ type ResourceCredentials = {
password?: string;
};
type ResourceConnectionConfig = {
export type ResourceConnectionConfig = {
url: string;
credentials?: ResourceCredentials;
};
@@ -37,6 +37,10 @@ type Resource = {
const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1');
const resolveCommand = (command: string): string => {
return command.replace(/\$DATA_PATH/g, DATA_PATH);
};
function parseResourceFile(
filename: string,
content: string,
@@ -70,11 +74,11 @@ function parseResourceFile(
port,
path: rawPath ? stripBackticks(rawPath) : null,
description: field('Description') ?? '',
installCommand: field('Install') ? stripBackticks(field('Install')!) : null,
uninstallCommand: field('Uninstall') ? stripBackticks(field('Uninstall')!) : null,
manageCommand: field('Manage') ? stripBackticks(field('Manage')!) : null,
verifyCommand: field('Verify') ? stripBackticks(field('Verify')!) : null,
updateCommand: field('Update') ? stripBackticks(field('Update')!) : null,
installCommand: field('Install') ? resolveCommand(stripBackticks(field('Install')!)) : null,
uninstallCommand: field('Uninstall') ? resolveCommand(stripBackticks(field('Uninstall')!)) : null,
manageCommand: field('Manage') ? resolveCommand(stripBackticks(field('Manage')!)) : null,
verifyCommand: field('Verify') ? resolveCommand(stripBackticks(field('Verify')!)) : null,
updateCommand: field('Update') ? resolveCommand(stripBackticks(field('Update')!)) : null,
};
}
@@ -82,7 +86,7 @@ const CONFIG_FILENAME = 'resources-config.json';
const getConfigPath = () => join(getResourcesDir(), CONFIG_FILENAME);
async function readConfig(): Promise<ResourcesConfig> {
export async function readConfig(): Promise<ResourcesConfig> {
const path = getConfigPath();
if (!existsSync(path)) return {};
const text = await Bun.file(path).text();
@@ -265,6 +269,27 @@ resourcesRouter.post('/:id/ping', async (ctx) => {
}
});
resourcesRouter.post('/error-log', async (ctx) => {
const body = await ctx.req.json<{ command: string; output: string; exitCode: number }>();
const timestamp = Date.now();
const filePath = `/tmp/officer-error-${timestamp}.md`;
const md = [
`# Command Failed (exit code ${body.exitCode})`,
'',
'```',
body.command,
'```',
'',
'## Output',
'',
'```',
body.output,
'```',
].join('\n');
await Bun.write(filePath, md);
return ctx.json({ filePath });
});
resourcesRouter.get('/:id', async (ctx) => {
const resources = await loadResources();
const resource = resources.find((r) => r.id === ctx.req.param('id'));