copy path and chat about file/folder

This commit is contained in:
2026-02-23 05:15:13 +00:00
parent bdefc52331
commit 9acef6cf6c
24 changed files with 1052 additions and 45 deletions
+81 -19
View File
@@ -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);