copy path and chat about file/folder
This commit is contained in:
@@ -5,7 +5,9 @@ import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { readConfig } from '@@/api/server-settings/resources';
|
||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||
|
||||
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Video', 'Pictures', 'Onboarding'];
|
||||
const ONBOARDING_SEED = join(DATA_PATH, 'Onboarding');
|
||||
@@ -81,7 +83,7 @@ router.get('/ls', async (ctx) => {
|
||||
);
|
||||
|
||||
const path = '/' + absPath.slice(rootDir.length).replace(/^\/+/, '');
|
||||
return ctx.json({ path, entries: entries.filter(Boolean) });
|
||||
return ctx.json({ path, rootDir, entries: entries.filter(Boolean) });
|
||||
});
|
||||
|
||||
// Read file contents
|
||||
@@ -267,6 +269,33 @@ router.get('/transcode', async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Save a cached result (ocr/tts/transcriptions/audio) next to the original file
|
||||
const CACHE_PREFIXES = ['ocr/', 'tts/', 'transcriptions/', 'audio/'];
|
||||
|
||||
router.post('/save-result', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const { path: cachedPath } = ctx.get('body') as { path: string };
|
||||
if (!cachedPath) throw errors.BAD_REQUEST('path is required');
|
||||
|
||||
const prefix = CACHE_PREFIXES.find((p) => cachedPath.startsWith(p));
|
||||
if (!prefix) throw errors.BAD_REQUEST('Not a cached result path');
|
||||
|
||||
const relativePath = cachedPath.slice(prefix.length);
|
||||
const userDataDir = getUserDataDir(user.email);
|
||||
const srcAbs = resolve(userDataDir, cachedPath);
|
||||
if (!existsSync(srcAbs)) throw errors.BAD_REQUEST('Cached file not found');
|
||||
|
||||
const homeDir = getHomeDir(user.email);
|
||||
const destAbs = resolve(homeDir, relativePath);
|
||||
if (!destAbs.startsWith(homeDir)) throw errors.FORBIDDEN('Path outside home directory');
|
||||
|
||||
await mkdir(dirname(destAbs), { recursive: true });
|
||||
await cp(srcAbs, destAbs);
|
||||
|
||||
const destPath = '/' + destAbs.slice(homeDir.length).replace(/^\/+/, '');
|
||||
return ctx.json({ savedPath: destPath });
|
||||
});
|
||||
|
||||
// Text-to-speech with caching
|
||||
router.post('/tts', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -287,16 +316,29 @@ router.post('/tts', async (ctx) => {
|
||||
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 ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
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' }),
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
let res: Response;
|
||||
|
||||
if (ttsConfig.provider === 'elevenlabs') {
|
||||
if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
|
||||
res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(ttsConfig.voice)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
|
||||
body: JSON.stringify({ text: content, model_id: ttsConfig.model }),
|
||||
});
|
||||
} else {
|
||||
if (ttsConfig.apiKey) headers['Authorization'] = `Bearer ${ttsConfig.apiKey}`;
|
||||
res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ model: ttsConfig.model, input: content, voice: ttsConfig.voice, response_format: 'mp3' }),
|
||||
});
|
||||
}
|
||||
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
||||
|
||||
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||
@@ -327,26 +369,46 @@ router.post('/ocr', async (ctx) => {
|
||||
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 ocrConfig = await readOcrConfig();
|
||||
if (!ocrConfig) throw errors.BAD_REQUEST('OCR not configured — set it up in Settings → OCR');
|
||||
|
||||
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`, {
|
||||
const res = await fetch(`${ocrConfig.url.replace(/\/+$/, '')}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'Qwen2.5-VL-7B-Instruct-q4_k_m.gguf',
|
||||
model: ocrConfig.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
'You are an OCR assistant. Extract meaningful text content from images.',
|
||||
'Rules:',
|
||||
'- Output ONLY the extracted text, no commentary or explanations.',
|
||||
'- For documents, articles, books: preserve the original text, paragraphs, and structure as markdown.',
|
||||
'- For tables: use markdown table format.',
|
||||
'- For code/terminal screenshots: use fenced code blocks.',
|
||||
'- For social media posts/threads: extract as clean conversation. Format as:',
|
||||
' **username** says: "their text"',
|
||||
' **replier** replies: "their text"',
|
||||
' Strip all UI chrome (follow buttons, timestamps, like counts, avatars, "Everybody can reply", etc).',
|
||||
' Keep only usernames and what they actually wrote.',
|
||||
'- For memes/image macros: describe the image briefly, then extract any text.',
|
||||
'- For handwriting: transcribe as accurately as possible.',
|
||||
'- For receipts/invoices: extract as structured text with line items.',
|
||||
'- Strip all UI elements, navigation, ads, watermarks, and other noise.',
|
||||
'- Preserve the original language of the text.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
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.' },
|
||||
{ type: 'text', text: 'Extract the text from this image.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -507,9 +569,9 @@ router.post('/transcribe', async (ctx) => {
|
||||
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 sttConfig = await readSttConfig();
|
||||
const whisperUrl = sttConfig?.url;
|
||||
if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured — set it up in Settings → Speech to Text');
|
||||
|
||||
const audioFile = Bun.file(absPath);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readApiKeys } from "../server-settings/pi-mono";
|
||||
import { PI_CONFIG_DIR } from "../../data-path";
|
||||
import { ensureDockerContainer } from "../terminal/websocket";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export type PiEventHandler = (event: PiEvent) => void;
|
||||
@@ -10,6 +11,7 @@ export type PiEventHandler = (event: PiEvent) => void;
|
||||
type SandboxOptions = {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
homeDir: string;
|
||||
};
|
||||
|
||||
@@ -22,9 +24,10 @@ export async function spawnPi(
|
||||
let proc: Subprocess;
|
||||
|
||||
if (sandbox) {
|
||||
const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username);
|
||||
const storedKeys = await readApiKeys();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const containerId = `officer-terminal-${sandbox.userId}`;
|
||||
const containerId = container.dockerId;
|
||||
const containerHome = `/home/${sandbox.username}`;
|
||||
const containerPiConfig = `${containerHome}/.pi/agent`;
|
||||
const piArgs = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||
|
||||
@@ -281,7 +281,7 @@ async function handleChat(
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const onEvent = createEventHandler(sessionId, model, cwd);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, homeDir } : undefined);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
|
||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||
@@ -348,7 +348,7 @@ async function handleResume(
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, homeDir } : undefined;
|
||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd);
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox);
|
||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { settingsPath } from './server-settings';
|
||||
|
||||
type OcrConfig = {
|
||||
url: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export async function readOcrConfig(): Promise<OcrConfig | undefined> {
|
||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
||||
return settings.ocr as OcrConfig | undefined;
|
||||
}
|
||||
|
||||
export const ocrRouter = createRouter();
|
||||
|
||||
ocrRouter.get('/', async (ctx) => {
|
||||
const ocr = await readOcrConfig();
|
||||
if (!ocr) return ctx.json(null);
|
||||
return ctx.json(ocr);
|
||||
});
|
||||
|
||||
ocrRouter.put('/', async (ctx) => {
|
||||
const body = await ctx.req.json<OcrConfig>();
|
||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
||||
settings.ocr = body;
|
||||
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
|
||||
return ctx.json({ success: true });
|
||||
});
|
||||
|
||||
ocrRouter.post('/models', async (ctx) => {
|
||||
const body = await ctx.req.json<{ url: string }>();
|
||||
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/models`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return ctx.json({ error: `Server returned ${res.status}` }, 500);
|
||||
const json = (await res.json()) as { data?: { id: string }[] };
|
||||
const models = (json.data ?? []).map((m) => m.id);
|
||||
return ctx.json({ models });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return ctx.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
ocrRouter.post('/test', async (ctx) => {
|
||||
const body = await ctx.req.json<OcrConfig>();
|
||||
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/models`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return ctx.json({ error: `Server returned ${res.status}` }, 500);
|
||||
return ctx.json({ success: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return ctx.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,9 @@ 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';
|
||||
|
||||
const configDir = `${homedir()}/.config/officer.dev`;
|
||||
export const settingsPath = `${configDir}/server-settings.json`;
|
||||
@@ -28,6 +31,9 @@ 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.get('/settings', async (ctx) => {
|
||||
const settings = await Bun.file(settingsPath).json();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { settingsPath } from './server-settings';
|
||||
|
||||
type SttConfig = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function readSttConfig(): Promise<SttConfig | undefined> {
|
||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
||||
return settings.stt as SttConfig | undefined;
|
||||
}
|
||||
|
||||
export const sttRouter = createRouter();
|
||||
|
||||
sttRouter.get('/', async (ctx) => {
|
||||
const stt = await readSttConfig();
|
||||
if (!stt) return ctx.json(null);
|
||||
return ctx.json(stt);
|
||||
});
|
||||
|
||||
sttRouter.put('/', async (ctx) => {
|
||||
const body = await ctx.req.json<SttConfig>();
|
||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
||||
settings.stt = body;
|
||||
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
|
||||
return ctx.json({ success: true });
|
||||
});
|
||||
|
||||
sttRouter.post('/test', async (ctx) => {
|
||||
const body = await ctx.req.json<{ url: string }>();
|
||||
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(`${body.url.replace(/\/+$/, '')}/inference`, {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
// Whisper will return an error for empty form, but a response means it's reachable
|
||||
return ctx.json({ success: true, status: res.status });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return ctx.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { settingsPath } from './server-settings';
|
||||
|
||||
type TtsConfig = {
|
||||
provider: 'openai' | 'elevenlabs';
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
};
|
||||
|
||||
function maskSecret(value: string | undefined): string | undefined {
|
||||
if (!value || value.length < 8) return value ? '****' : undefined;
|
||||
return value.slice(0, 4) + '****' + value.slice(-4);
|
||||
}
|
||||
|
||||
export async function readTtsConfig(): Promise<TtsConfig | undefined> {
|
||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
||||
return settings.tts as TtsConfig | undefined;
|
||||
}
|
||||
|
||||
export const ttsRouter = createRouter();
|
||||
|
||||
ttsRouter.get('/', async (ctx) => {
|
||||
const tts = await readTtsConfig();
|
||||
if (!tts) return ctx.json(null);
|
||||
return ctx.json({ ...tts, apiKey: maskSecret(tts.apiKey) });
|
||||
});
|
||||
|
||||
ttsRouter.put('/', async (ctx) => {
|
||||
const body = await ctx.req.json<TtsConfig>();
|
||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
||||
|
||||
const existing: TtsConfig | undefined = settings.tts;
|
||||
if (existing && body.apiKey?.includes('****')) {
|
||||
body.apiKey = existing.apiKey;
|
||||
}
|
||||
|
||||
settings.tts = body;
|
||||
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
|
||||
return ctx.json({ success: true });
|
||||
});
|
||||
|
||||
ttsRouter.post('/voices', async (ctx) => {
|
||||
const body = await ctx.req.json<{ provider: string; url?: string; apiKey?: string }>();
|
||||
|
||||
try {
|
||||
if (body.provider === 'elevenlabs') {
|
||||
if (!body.apiKey) return ctx.json({ error: 'API key required' }, 400);
|
||||
const res = await fetch('https://api.elevenlabs.io/v1/voices', {
|
||||
headers: { 'xi-api-key': body.apiKey },
|
||||
});
|
||||
if (!res.ok) return ctx.json({ error: `ElevenLabs error: ${res.status}` }, 500);
|
||||
const json = (await res.json()) as { voices: { voice_id: string; name: string }[] };
|
||||
return ctx.json({ voices: json.voices.map((v) => v.voice_id) });
|
||||
}
|
||||
|
||||
// OpenAI-compatible
|
||||
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
||||
const headers: Record<string, string> = {};
|
||||
if (body.apiKey) headers['Authorization'] = `Bearer ${body.apiKey}`;
|
||||
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/voices`, { headers });
|
||||
if (!res.ok) return ctx.json({ error: `Voices fetch failed: ${res.status}` }, 500);
|
||||
const json = (await res.json()) as { voices: string[] };
|
||||
return ctx.json({ voices: json.voices });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return ctx.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
ttsRouter.post('/test', async (ctx) => {
|
||||
const body = await ctx.req.json<TtsConfig>();
|
||||
|
||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
||||
const saved: TtsConfig | undefined = settings.tts;
|
||||
if (saved && body.apiKey?.includes('****')) {
|
||||
body.apiKey = saved.apiKey;
|
||||
}
|
||||
|
||||
try {
|
||||
if (body.provider === 'elevenlabs') {
|
||||
if (!body.apiKey) return ctx.json({ error: 'API key required for ElevenLabs' }, 400);
|
||||
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(body.voice)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': body.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: 'This is a test of the text to speech system.',
|
||||
model_id: body.model,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
return ctx.json({ error: `ElevenLabs error: ${res.status} ${text}` }, 500);
|
||||
}
|
||||
const buffer = await res.arrayBuffer();
|
||||
return new Response(buffer, { headers: { 'Content-Type': 'audio/mpeg' } });
|
||||
}
|
||||
|
||||
// OpenAI-compatible (Kokoro, OpenAI, etc.)
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (body.apiKey) headers['Authorization'] = `Bearer ${body.apiKey}`;
|
||||
|
||||
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: body.model,
|
||||
input: 'This is a test of the text to speech system.',
|
||||
voice: body.voice,
|
||||
response_format: 'mp3',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
return ctx.json({ error: `TTS error: ${res.status} ${text}` }, 500);
|
||||
}
|
||||
const buffer = await res.arrayBuffer();
|
||||
return new Response(buffer, { headers: { 'Content-Type': 'audio/mpeg' } });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return ctx.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -210,7 +210,7 @@ const dockerStart = (dockerId: string) => {
|
||||
return result.exitCode === 0;
|
||||
};
|
||||
|
||||
const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => {
|
||||
export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => {
|
||||
const map = await loadContainerMap();
|
||||
const existing = map[email];
|
||||
if (existing && dockerContainerRunning(existing.dockerId)) return existing;
|
||||
|
||||
Reference in New Issue
Block a user