diff --git a/src/servers/api/stt/transcribe.ts b/src/servers/api/stt/transcribe.ts index a93e187e..4b58f2a5 100644 --- a/src/servers/api/stt/transcribe.ts +++ b/src/servers/api/stt/transcribe.ts @@ -1,13 +1,11 @@ -// Whisper transcription with language-aware translation. +// Whisper transcription, always in the audio's own language. // -// Strategy: detect the audio's language, then transcribe in that language. -// Only ask Whisper to translate (which is English-only) when the detected -// language is NOT one the user speaks — i.e. they wouldn't understand the -// raw transcript anyway. +// Detect the language first, then transcribe in it. Whisper's translate mode is English-only, so it +// cannot honour "translate into " for anything else — rather than silently returning a +// rough English rendering of Portuguese audio, transcription is always faithful to the source. // -// The detection must win when Whisper is confident. Biasing toward the user's spoken languages -// unconditionally means a user who speaks only English forces `language=en` onto every recording, -// and Whisper answers non-English audio with a rough, unmarked translation instead of a transcript. +// The detection must win when Whisper is confident. `spokenLanguages` only breaks ties on clips +// Whisper is genuinely unsure about, where its low-confidence guess defaults to English. type TranscribeArgs = { file: File | Blob; @@ -18,7 +16,6 @@ type TranscribeArgs = { export type TranscribeResult = { text: string; detectedLanguage: string; - translated: boolean; }; // Above this probability Whisper's own detection is taken as authoritative. Below it the clip is @@ -88,21 +85,17 @@ export async function transcribeAudio({ detectedLanguage = LANG_NAME_TO_ISO[raw] ?? raw; } - // Step 2: translate only when the user doesn't speak the detected language - const translated = spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLanguage); - - // Step 3: full transcription + // Step 2: transcribe in the detected language const transcribeForm = new FormData(); transcribeForm.append('file', file); transcribeForm.append('temperature', '0.0'); transcribeForm.append('temperature_inc', '0.2'); transcribeForm.append('response_format', 'text'); transcribeForm.append('language', detectedLanguage); - if (translated) transcribeForm.append('translate', 'true'); const res = await fetch(`${base}/inference`, { method: 'POST', body: transcribeForm }); if (!res.ok) throw new Error(`Transcription failed (${res.status})`); const text = (await res.text()).trim(); - return { text, detectedLanguage, translated }; + return { text, detectedLanguage }; } diff --git a/src/servers/api/tasks/execute-script.ts b/src/servers/api/tasks/execute-script.ts index ab8ddc0b..a3a0809d 100644 --- a/src/servers/api/tasks/execute-script.ts +++ b/src/servers/api/tasks/execute-script.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { getTaskByDirName } from './task-files'; import { getOwnerHomeDir, DATA_PATH } from '../../data-path'; import { killTree } from './process-tree'; +import { buildTaskApiEnv } from './task-api-env'; // Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log // file); `started`/`exit`/`error` are structural and get buffered for viewer replay. @@ -16,7 +17,9 @@ export type ScriptEvent = export type ExecuteScriptParams = { jobId: string; + userId: number; email: string; + username?: string | null; taskDirName: string; inputs: Record; cwd?: string; @@ -66,7 +69,7 @@ export const jobLogPath = (jobId: string) => join(DATA_PATH, 'jobs', `${jobId}.l // output to a durable log file. Resolves with the process exit code; throws only on spawn failure or // when aborted (the manager maps those to failed/stopped). export async function executeScript(params: ExecuteScriptParams): Promise<{ exitCode: number }> { - const { jobId, email, inputs, abortSignal, emit } = params; + const { jobId, userId, email, username, inputs, abortSignal, emit } = params; const task = await getTaskByDirName(params.taskDirName); if (!task) throw new Error(`Task not found: ${params.taskDirName}`); @@ -83,7 +86,8 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : join(homeDir, params.cwd)) : homeDir; const spawnCmd = cmd; - const spawnEnv = { ...(process.env as Record), ...inputEnv }; + const apiEnv = await buildTaskApiEnv({ id: userId, email, username }); + const spawnEnv = { ...(process.env as Record), ...apiEnv, ...inputEnv }; const spawnCwd = cwd; mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true }); diff --git a/src/servers/api/tasks/pipeline-job-manager.ts b/src/servers/api/tasks/pipeline-job-manager.ts index 6338aa0e..57991cff 100644 --- a/src/servers/api/tasks/pipeline-job-manager.ts +++ b/src/servers/api/tasks/pipeline-job-manager.ts @@ -179,7 +179,9 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) { mode === 'script' ? executeScript({ jobId, + userId: params.userId, email: params.email, + username: params.username, taskDirName: params.taskDirName, inputs: params.inputs, cwd: params.cwd, diff --git a/src/servers/api/tasks/task-api-env.ts b/src/servers/api/tasks/task-api-env.ts new file mode 100644 index 00000000..d8334e93 --- /dev/null +++ b/src/servers/api/tasks/task-api-env.ts @@ -0,0 +1,31 @@ +import { sign } from '@@/jwt'; + +const { PORT = '5000', PUBLIC_URL } = process.env; + +const PUBLIC_HOST = (() => { + try { + return PUBLIC_URL ? new URL(PUBLIC_URL).host : undefined; + } catch { + return undefined; + } +})(); + +type TaskApiUser = { id: number; email: string; username?: string | null }; + +// Environment that lets a script task call Officer's own API instead of reimplementing server-side +// work (transcription, for one — the endpoint already handles language detection and caching). +// +// Requests go to 127.0.0.1 so a task never depends on DNS or the reverse proxy being up. Origin +// validation matches the Host header against PUBLIC_URL though, so a bare localhost request is +// rejected — hence OFFICER_API_HOST, which scripts must pass as an explicit Host header: +// +// curl -H "Authorization: Bearer $OFFICER_AUTH_TOKEN" -H "Host: $OFFICER_API_HOST" \ +// "$OFFICER_API_URL/api/file-browser/transcribe" +export async function buildTaskApiEnv(user: TaskApiUser): Promise> { + const token = await sign({ id: user.id, email: user.email, username: user.username ?? '' }, '12h'); + return { + OFFICER_API_URL: `http://127.0.0.1:${PORT}`, + OFFICER_API_HOST: PUBLIC_HOST ?? `127.0.0.1:${PORT}`, + OFFICER_AUTH_TOKEN: token, + }; +} diff --git a/src/servers/api/tasks/task-executor.ts b/src/servers/api/tasks/task-executor.ts index 810bc6c9..10658499 100644 --- a/src/servers/api/tasks/task-executor.ts +++ b/src/servers/api/tasks/task-executor.ts @@ -3,6 +3,7 @@ import { join, isAbsolute } from 'node:path'; import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs'; import { getTaskByDirName } from './task-files'; import { getOwnerHomeDir } from '../../data-path'; +import { buildTaskApiEnv } from './task-api-env'; type WSData = { userId: number; @@ -157,7 +158,7 @@ function buildArgs(inputs: Record, argsOrder?: string[] | null): } async function handleRun(ws: ServerWebSocket, msg: RunMessage) { - const { email } = ws.data; + const { userId, email, username } = ws.data; // Resolve task from the file-backed store const task = await getTaskByDirName(msg.taskDirName); @@ -197,7 +198,8 @@ async function handleRun(ws: ServerWebSocket, msg: RunMessage) { const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir; const spawnCmd = cmd; - const spawnEnv = { ...(process.env as Record), ...inputEnv }; + const apiEnv = await buildTaskApiEnv({ id: userId, email, username }); + const spawnEnv = { ...(process.env as Record), ...apiEnv, ...inputEnv }; const spawnCwd = cwd; const cleanup = () => { diff --git a/src/servers/api/tasks/task-files.ts b/src/servers/api/tasks/task-files.ts index c5ef97ca..5a7b271b 100644 --- a/src/servers/api/tasks/task-files.ts +++ b/src/servers/api/tasks/task-files.ts @@ -14,7 +14,9 @@ export type TaskFrontmatter = { category: string | null; // groups the task into a context-menu submenu; null falls back to 'Other' version: number; mode: string; - inline: boolean; // quick/interactive tasks run in the modal instead of as a background job + // How the task runs: false = background job, true = ephemerally in the modal, 'ask' = the modal + // offers the choice per run. + inline: boolean | 'ask'; language: string | null; args: string[] | null; tags: string[] | null; @@ -40,7 +42,7 @@ export type TaskSummary = { description: string | null; category: string | null; mode: string; - inline: boolean; + inline: boolean | 'ask'; version: number; trigger: unknown; }; @@ -112,7 +114,7 @@ function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } { category: parsed.category == null ? null : String(parsed.category), version, mode: parsed.mode == null ? 'agentic' : String(parsed.mode), - inline: parsed.inline === true, + inline: parsed.inline === 'ask' ? 'ask' : parsed.inline === true, language: parsed.language == null ? null : String(parsed.language), args: asArray(parsed.args), tags: asArray(parsed.tags), diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index 9cbb3385..e9ad05fa 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -869,8 +869,11 @@ const ScriptRunner = ({ const client = useClient(); const navigate = useNavigate(); const files = useFilesAPI('home'); - // Inline tasks (quick/interactive) run ephemerally in this modal; everything else becomes a job. - const [inline, setInline] = useState(false); + // How this task runs: false = job, true = ephemerally in this modal, 'ask' = the task leaves the + // choice to the user and both affordances are offered. + const [inlineMode, setInlineMode] = useState(false); + const alwaysInline = inlineMode === true; + const canChooseRunMode = inlineMode === 'ask'; // Is a job already running? (drives the Run vs Queue affordance). const [jobRunning, setJobRunning] = useState(false); // After a job is created, show a confirmation (View / Close) rather than navigating away. @@ -901,13 +904,13 @@ const ScriptRunner = ({ useEffect(() => { client .get<{ - inline?: boolean; + inline?: boolean | 'ask'; inputs?: Record; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean }; }>(`/tasks/${taskDirName}`) .then((task) => { const defs: Record = task.inputs ?? {}; - setInline(task.inline === true); + setInlineMode(task.inline === 'ask' ? 'ask' : task.inline === true); setFolderKeepAll(task.config?.folderKeepAll === true); setPerGroupTracks(task.config?.perGroupTracks === true); setPerGroupAllFiles(task.config?.perGroupAllFiles === true); @@ -1036,12 +1039,12 @@ const ScriptRunner = ({ // Non-inline tasks become jobs — check if one is already running so we can offer Queue. useEffect(() => { - if (inline) return; + if (alwaysInline) return; client .get>('/jobs?live=1') .then((live) => setJobRunning(live.length > 0)) .catch(() => {}); - }, [inline]); + }, [alwaysInline]); // Which per-group pickers this task declares, and whether the current selection is real work. const has = pickerKinds(inputDefs); @@ -1166,20 +1169,24 @@ const ScriptRunner = ({ const noWork = perGroupTracks && entryType === 'directory' && !perGroupHasWork; const base = 'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer'; - if (inline) { - return ( - - ); - } const jobDisabled = !inputDefs || probing || noWork; + const runHere = ( + + ); + if (alwaysInline) return runHere; return ( <> + {canChooseRunMode && runHere} {jobRunning && ( );