From 9acef6cf6c4a4d86799dcf1a41beb256afd553e1 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 23 Feb 2026 05:15:13 +0000 Subject: [PATCH] copy path and chat about file/folder --- .../Settings/ServerSettings/OCRSection.tsx | 159 ++++++++++++ .../Settings/ServerSettings/STTSection.tsx | 104 ++++++++ .../Settings/ServerSettings/TTSSection.tsx | 243 ++++++++++++++++++ .../Dashboard/Settings/SystemSettings.tsx | 8 +- src/servers/api/file-browser/router.ts | 100 +++++-- src/servers/api/pi/pi-bridge.ts | 5 +- src/servers/api/pi/websocket.ts | 4 +- src/servers/api/server-settings/ocr.ts | 68 +++++ .../api/server-settings/server-settings.ts | 6 + src/servers/api/server-settings/stt.ts | 48 ++++ src/servers/api/server-settings/tts.ts | 127 +++++++++ src/servers/api/terminal/websocket.ts | 2 +- .../Chat/EmbeddableChat/useEmbeddableChat.ts | 23 +- .../FileBrowserApp/components/FileGrid.tsx | 2 + .../FileBrowserApp/components/FileItem.tsx | 16 +- .../components/FileViewContainer.tsx | 12 +- .../FileBrowserApp/useFileBrowserApp.ts | 45 +++- .../src/apps/FileViewer/FileViewerContext.tsx | 2 + .../src/apps/FileViewer/FileViewerHeader.tsx | 14 +- .../apps/FileViewer/FileViewerProvider.tsx | 22 ++ .../hooks/useFileViewerPanels/Providers.tsx | 29 +++ .../src/hooks/useFileViewerPanels/layouts.ts | 6 + .../useFileViewerPanels.tsx | 48 +++- .../officerdev/src/hooks/useFilesAPI.ts | 4 + 24 files changed, 1052 insertions(+), 45 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/OCRSection.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/STTSection.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TTSSection.tsx create mode 100644 src/servers/api/server-settings/ocr.ts create mode 100644 src/servers/api/server-settings/stt.ts create mode 100644 src/servers/api/server-settings/tts.ts diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/OCRSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/OCRSection.tsx new file mode 100644 index 00000000..277a2721 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/OCRSection.tsx @@ -0,0 +1,159 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useClient } from 'hooks/useClient'; + +type OcrConfig = { + url: string; + model: string; +}; + +export const OCRSection = () => { + const client = useClient(); + const queryClient = useQueryClient(); + + const { data: config, isLoading } = useQuery({ + queryKey: ['OCR_CONFIG'], + queryFn: () => client.get('/server-settings/ocr'), + }); + + const [url, setUrl] = useState('http://localhost:64203'); + const [model, setModel] = useState('Qwen2.5-VL-7B-Instruct-q4_k_m.gguf'); + const [models, setModels] = useState([]); + const [modelsLoading, setModelsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + + const fetchModels = async (u: string) => { + setModelsLoading(true); + try { + const res = await client.post<{ models?: string[]; error?: string }>('/server-settings/ocr/models', { url: u }); + if (res.models) setModels(res.models); + else setModels([]); + } catch { + setModels([]); + } finally { + setModelsLoading(false); + } + }; + + useEffect(() => { + if (!config) return; + setUrl(config.url); + setModel(config.model); + fetchModels(config.url); + }, [config]); + + const handleSave = async () => { + if (isSaving) return; + setIsSaving(true); + try { + await client.put('/server-settings/ocr', { url, model }); + queryClient.invalidateQueries({ queryKey: ['OCR_CONFIG'] }); + toast.success('OCR settings saved'); + } catch { + toast.error('Failed to save OCR settings'); + } finally { + setIsSaving(false); + } + }; + + const handleTest = async () => { + if (isTesting) return; + setIsTesting(true); + try { + const res = await client.post<{ success?: boolean; error?: string }>('/server-settings/ocr/test', { url, model }); + if (res.error) { + toast.error(res.error); + } else { + toast.success('Vision model server is reachable'); + } + } catch (err: unknown) { + const raw = (err as { message?: string })?.message; + let msg = 'Connection failed'; + try { + if (raw) msg = JSON.parse(raw).error ?? msg; + } catch { + /* ignore */ + } + toast.error(msg); + } finally { + setIsTesting(false); + } + }; + + if (isLoading) return

Loading...

; + + return ( +
+ + + + +
+ + +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/STTSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/STTSection.tsx new file mode 100644 index 00000000..68ef5a60 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/STTSection.tsx @@ -0,0 +1,104 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useClient } from 'hooks/useClient'; + +type SttConfig = { + url: string; +}; + +export const STTSection = () => { + const client = useClient(); + const queryClient = useQueryClient(); + + const { data: config, isLoading } = useQuery({ + queryKey: ['STT_CONFIG'], + queryFn: () => client.get('/server-settings/stt'), + }); + + const [url, setUrl] = useState('http://localhost:64201'); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + + useEffect(() => { + if (!config) return; + setUrl(config.url); + }, [config]); + + const handleSave = async () => { + if (isSaving) return; + setIsSaving(true); + try { + await client.put('/server-settings/stt', { url }); + queryClient.invalidateQueries({ queryKey: ['STT_CONFIG'] }); + toast.success('STT settings saved'); + } catch { + toast.error('Failed to save STT settings'); + } finally { + setIsSaving(false); + } + }; + + const handleTest = async () => { + if (isTesting) return; + setIsTesting(true); + try { + const res = await client.post<{ success?: boolean; error?: string }>('/server-settings/stt/test', { url }); + if (res.error) { + toast.error(res.error); + } else { + toast.success('Whisper server is reachable'); + } + } catch (err: unknown) { + const raw = (err as { message?: string })?.message; + let msg = 'Connection failed'; + try { + if (raw) msg = JSON.parse(raw).error ?? msg; + } catch { + /* ignore */ + } + toast.error(msg); + } finally { + setIsTesting(false); + } + }; + + if (isLoading) return

Loading...

; + + return ( +
+ + +
+ + +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TTSSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TTSSection.tsx new file mode 100644 index 00000000..a56ec9c0 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/TTSSection.tsx @@ -0,0 +1,243 @@ +import { useState, useEffect, useRef } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useClient } from 'hooks/useClient'; + +type Provider = 'openai' | 'elevenlabs'; + +type TtsConfig = { + provider: Provider; + url: string; + apiKey?: string; + model: string; + voice: string; +}; + +const PROVIDER_DEFAULTS: Record> = { + openai: { url: 'http://localhost:64202', model: 'kokoro', voice: 'af_heart' }, + elevenlabs: { url: '', model: 'eleven_multilingual_v2', voice: 'Rachel' }, +}; + +export const TTSSection = () => { + const client = useClient(); + const queryClient = useQueryClient(); + const audioRef = useRef(null); + + const { data: config, isLoading } = useQuery({ + queryKey: ['TTS_CONFIG'], + queryFn: () => client.get('/server-settings/tts'), + }); + + const [provider, setProvider] = useState('openai'); + const [url, setUrl] = useState('http://localhost:64202'); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState('kokoro'); + const [voice, setVoice] = useState('af_heart'); + const [voices, setVoices] = useState([]); + const [voicesLoading, setVoicesLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + + const fetchVoices = async (p: Provider, u: string, key: string) => { + setVoicesLoading(true); + try { + const res = await client.post<{ voices?: string[]; error?: string }>('/server-settings/tts/voices', { + provider: p, + url: u, + apiKey: key || undefined, + }); + if (res.voices) setVoices(res.voices); + else setVoices([]); + } catch { + setVoices([]); + } finally { + setVoicesLoading(false); + } + }; + + useEffect(() => { + if (!config) return; + setProvider(config.provider); + setUrl(config.url ?? ''); + setApiKey(config.apiKey ?? ''); + setModel(config.model); + setVoice(config.voice); + fetchVoices(config.provider, config.url ?? '', config.apiKey ?? ''); + }, [config]); + + const handleProviderChange = (v: Provider) => { + setProvider(v); + const defaults = PROVIDER_DEFAULTS[v]; + if (defaults.url !== undefined) setUrl(defaults.url); + if (defaults.model !== undefined) setModel(defaults.model); + if (defaults.voice !== undefined) setVoice(defaults.voice); + setApiKey(''); + }; + + const buildConfig = (): TtsConfig => ({ + provider, + url, + model, + voice, + ...(apiKey ? { apiKey } : {}), + }); + + const handleSave = async () => { + if (isSaving) return; + setIsSaving(true); + try { + await client.put('/server-settings/tts', buildConfig()); + queryClient.invalidateQueries({ queryKey: ['TTS_CONFIG'] }); + toast.success('TTS settings saved'); + } catch { + toast.error('Failed to save TTS settings'); + } finally { + setIsSaving(false); + } + }; + + const handleTest = async () => { + if (isTesting) return; + setIsTesting(true); + try { + const res = await fetch(`${client.baseUrl}/server-settings/tts/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${client.token}` }, + body: JSON.stringify(buildConfig()), + }); + + if (!res.ok || res.headers.get('content-type')?.includes('json')) { + const json = await res.json().catch(() => ({ error: 'Test failed' })); + toast.error(json.error ?? 'Test failed'); + return; + } + + const blob = await res.blob(); + const audioUrl = URL.createObjectURL(blob); + if (audioRef.current) { + audioRef.current.pause(); + URL.revokeObjectURL(audioRef.current.src); + } + const audio = new Audio(audioUrl); + audioRef.current = audio; + audio.play(); + toast.success('Playing test audio'); + } catch { + toast.error('Test failed — could not reach TTS server'); + } finally { + setIsTesting(false); + } + }; + + if (isLoading) return

Loading...

; + + return ( +
+ + + {provider === 'openai' && ( + + )} + + + +
+ + +
+ +
+ + +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index 0c4813b3..7345b198 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback, type DragEvent } from 'react'; import { toast } from 'sonner'; -import { Terminal, Eye, Bot, Settings, X, Plus, Mail } from 'lucide-react'; +import { Terminal, Eye, Bot, Settings, X, Plus, Mail, Volume2, Mic, ScanText } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; @@ -20,6 +20,9 @@ import type { UserSettings } from 'state/useSettings'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel'; import { SMTPSection } from './ServerSettings/SMTPSection'; +import { TTSSection } from './ServerSettings/TTSSection'; +import { STTSection } from './ServerSettings/STTSection'; +import { OCRSection } from './ServerSettings/OCRSection'; const PROVIDER_DISPLAY: Record = { anthropic: 'Anthropic', @@ -50,6 +53,9 @@ const groups: SettingsSectionGroup[] = [ { key: 'ai-harnesses', icon: Terminal, title: 'Providers', description: 'Remote and local AI providers', content: }, { key: 'model-visibility', icon: Eye, title: 'Models', description: 'Enable or disable models', content: }, { key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: }, + { key: 'tts', icon: Volume2, title: 'Text to Speech', description: 'TTS provider and voice', content: }, + { key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: }, + { key: 'ocr', icon: ScanText, title: 'OCR', description: 'Vision model for text extraction', content: }, ], }, { diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 1d115059..315c3cb7 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -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 = { '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); diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 99539bca..f5498d37 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -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']; diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index c28a40fb..a6ef2386 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -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 }); diff --git a/src/servers/api/server-settings/ocr.ts b/src/servers/api/server-settings/ocr.ts new file mode 100644 index 00000000..948ecc24 --- /dev/null +++ b/src/servers/api/server-settings/ocr.ts @@ -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 { + 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(); + 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(); + 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); + } +}); diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index c177291b..33b4c241 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -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(); diff --git a/src/servers/api/server-settings/stt.ts b/src/servers/api/server-settings/stt.ts new file mode 100644 index 00000000..c04edd73 --- /dev/null +++ b/src/servers/api/server-settings/stt.ts @@ -0,0 +1,48 @@ +import { createRouter } from '../../create-router'; +import { settingsPath } from './server-settings'; + +type SttConfig = { + url: string; +}; + +export async function readSttConfig(): Promise { + 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(); + 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); + } +}); diff --git a/src/servers/api/server-settings/tts.ts b/src/servers/api/server-settings/tts.ts new file mode 100644 index 00000000..ff417a10 --- /dev/null +++ b/src/servers/api/server-settings/tts.ts @@ -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 { + 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(); + 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 = {}; + 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(); + + 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 = { '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); + } +}); diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index 09ef8d27..b19db6a9 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -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; diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index ac8ee1ef..3b2ff01d 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -108,12 +108,31 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams) { }; // Auto-resize textarea - useEffect(() => { + const resizeTextarea = () => { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; - }, [input]); + }; + + useEffect(resizeTextarea, [input]); + + // Re-measure when textarea width changes (e.g. panel animation) + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + let rafId = 0; + let prevWidth = textarea.clientWidth; + const observer = new ResizeObserver(() => { + const width = textarea.clientWidth; + if (width === prevWidth) return; + prevWidth = width; + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(resizeTextarea); + }); + observer.observe(textarea); + return () => { observer.disconnect(); cancelAnimationFrame(rafId); }; + }, []); // Auto-scroll to bottom on new messages useEffect(() => { diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx index a9484e89..f781c390 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx @@ -53,6 +53,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { handleDelete, handleRename, handleChat, + handleCopyPath, handleDownload, setSelected, handleCut, @@ -170,6 +171,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { onDelete={handleDelete} onRename={handleRename} onChat={handleChat} + onCopyPath={handleCopyPath} onDownload={handleDownload} onSelect={handleSelect} onCut={handleCut} diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index e67b4779..7d5f2d7e 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive } from 'lucide-react'; +import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive, ClipboardCopy } from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { DropdownMenu, @@ -37,6 +37,7 @@ export type FileItemProps = { onDelete: (entry: DirEntry) => void; onRename: (entry: DirEntry, newName: string) => void; onChat: (entry: DirEntry) => void; + onCopyPath: (entry: DirEntry) => void; onDownload: (entry: DirEntry) => void; onSelect: (entry: DirEntry, ev: React.MouseEvent) => void; onCut: () => void; @@ -73,6 +74,7 @@ type MenuItemsProps = { onDelete: (e: DirEntry) => void; onStartRename: () => void; onChat: (e: DirEntry) => void; + onCopyPath: (e: DirEntry) => void; onDownload: (e: DirEntry) => void; onReadAloud: (e: DirEntry) => void; onOcr: (e: DirEntry) => void; @@ -92,6 +94,7 @@ const DropdownMenuItems = ({ onDelete, onStartRename, onChat, + onCopyPath, onDownload, onReadAloud, onOcr, @@ -117,6 +120,10 @@ const DropdownMenuItems = ({ Chat... + onCopyPath(entry)} className="cursor-pointer"> + + Copy path + onDownload(entry)} className="cursor-pointer"> Download @@ -205,6 +212,7 @@ const ContextMenuItems = ({ onDelete, onStartRename, onChat, + onCopyPath, onDownload, onReadAloud, onOcr, @@ -230,6 +238,10 @@ const ContextMenuItems = ({ Chat... + onCopyPath(entry)} className="cursor-pointer"> + + Copy path + onDownload(entry)} className="cursor-pointer"> Download @@ -414,6 +426,7 @@ export const FileItem = ({ onDelete, onRename, onChat, + onCopyPath, onDownload, onSelect, onCut, @@ -499,6 +512,7 @@ export const FileItem = ({ onDelete, onStartRename: () => setRenaming(true), onChat, + onCopyPath, onDownload, onReadAloud, onOcr, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx index a9fc12d5..de5659a3 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx @@ -1,4 +1,4 @@ -import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Download, Upload } from 'lucide-react'; +import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Download, Upload, ClipboardCopy, MessageSquare } from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import type { UseFileBrowserAppType } from '../useFileBrowserApp'; @@ -23,6 +23,8 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps handleBackgroundClick, loading, handlePaste, + handleCopyCurrentPath, + handleChatHere, handleCreateDir, handleCreateWorkspaceHere, setShowVideoDownload, @@ -96,6 +98,14 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps + + + Copy path + + + + Chat about this + Paste diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 34533db6..83e05051 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -23,6 +23,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi const currentPath = (scoped || isolated) ? localPath : globalPath; const setCurrentPath = (scoped || isolated) ? setLocalPath : setGlobalPath; const [entries, setEntries] = useState([]); + const [rootDir, setRootDir] = useState(''); const [loading, setLoading] = useState(true); const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid'); const [showHidden, setShowHidden] = useUserState('files/showHidden', false); @@ -66,6 +67,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi return; } setEntries(data.entries); + if (data.rootDir) setRootDir(data.rootDir); } catch (err: any) { console.error('[FileBrowser] refresh error:', err); toast.error(err?.message || 'Failed to load directory'); @@ -332,16 +334,19 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi } }; + const absPath = (relPath: string) => { + const rel = relPath.replace(/^\/+/, ''); + return rel ? `${rootDir}/${rel}` : rootDir; + }; + const handleChat = (entry: DirEntry) => { - const path = entryPath(entry.name).replace(/^\//, ''); - const isDir = entry.type === 'directory'; - const tag = isDir ? 'folder' : 'file'; - const cwdPath = isDir ? path : currentPath.replace(/^\//, ''); - const message = isDir - ? `[${tag}: ${path}] consider, for this session, this directory as your current working directory` - : `[${tag}: ${path}] Let's talk about this file`; - navigate('/chat/new', { - state: { initialMessage: message, cwd: { root: homeRoot, path: cwdPath } }, + const path = entryPath(entry.name); + const type = entry.type === 'directory' ? 'folder' : 'file'; + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('chatContext', path); + next.set('chatType', type); + return next; }); }; @@ -373,6 +378,25 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi navigate(`/workspaces/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`); }; + const handleCopyPath = (entry: DirEntry) => { + navigator.clipboard.writeText(absPath(entryPath(entry.name))); + toast.success('Path copied'); + }; + + const handleCopyCurrentPath = () => { + navigator.clipboard.writeText(absPath(currentPath)); + toast.success('Path copied'); + }; + + const handleChatHere = () => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('chatContext', currentPath); + next.set('chatType', 'folder'); + return next; + }); + }; + const handleCreateWorkspaceHere = () => { const dirName = currentPath === '/' ? '' : currentPath.split('/').pop()!; const params = new URLSearchParams({ cwd: currentPath }); @@ -663,6 +687,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi handleDelete, handleDeleteSelected, handleChat, + handleCopyPath, + handleCopyCurrentPath, + handleChatHere, handleDownload, handleDownloadSelected, handleRunTask, diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx index 947225e9..c7c3603e 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx @@ -22,6 +22,8 @@ export type FileViewerContextValue = { handleExtractAudio: () => void; handleExtract: () => void; handleDownload: () => void; + handleSaveResult: (() => void) | null; + saveResultLoading: boolean; }; export const FileViewerContext = createContext(null); diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerHeader.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerHeader.tsx index 8d152b46..1499ed89 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerHeader.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerHeader.tsx @@ -1,4 +1,4 @@ -import { Download, Loader2, Music, Film, Image, FileType2, Volume2, ScanText, FileText, AudioLines, FolderArchive } from 'lucide-react'; +import { Download, Loader2, Music, Film, Image, FileType2, Volume2, ScanText, FileText, AudioLines, FolderArchive, Save } from 'lucide-react'; import { getIcon } from 'material-file-icons'; import { getLang } from './file-types'; import { useFileViewer } from './FileViewerContext'; @@ -20,6 +20,8 @@ export const FileViewerHeader = () => { handleExtractAudio, handleExtract, handleDownload, + handleSaveResult, + saveResultLoading, } = useFileViewer(); const headerIcon = @@ -94,6 +96,16 @@ export const FileViewerHeader = () => { {extractLoading ? : } )} + {handleSaveResult && ( + + )} {!directContent && (