Files FIles Files
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user