diff --git a/Tools/Dashboard/css/style.css b/Tools/Dashboard/css/style.css index 2605c2807..5eaff06db 100644 --- a/Tools/Dashboard/css/style.css +++ b/Tools/Dashboard/css/style.css @@ -4087,6 +4087,19 @@ body::after { flex-wrap: wrap; } +.codex-voice-btn.recording { + border-color: #dc2626; + background: #dc2626; + color: #fff; +} + +.codex-voice-status { + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + min-height: 18px; +} + .codex-job-status { color: var(--text-secondary); font-size: 12px; diff --git a/Tools/Dashboard/index.html b/Tools/Dashboard/index.html index 5ffd38b62..edffd6951 100644 --- a/Tools/Dashboard/index.html +++ b/Tools/Dashboard/index.html @@ -837,15 +837,17 @@
选择一个 TH1 会话
-
本面板只显示工作目录为 C:\TH1\TH1 的本机 Codex 会话。
+
本面板读取 ~/.codex/sessions 的本机 CLI 会话,只显示工作目录为 C:\TH1\TH1 的记录;这些记录不一定出现在桌面版侧栏线程里。
+ +

diff --git a/Tools/Dashboard/js/codex_threads.js b/Tools/Dashboard/js/codex_threads.js
index 1ee76daf6..66aca526d 100644
--- a/Tools/Dashboard/js/codex_threads.js
+++ b/Tools/Dashboard/js/codex_threads.js
@@ -6,6 +6,9 @@ let codexSessions = [];
 let codexLoaded = false;
 let codexSelectedId = '';
 let codexPollTimer = null;
+let codexRecorder = null;
+let codexVoiceChunks = [];
+let codexVoiceStream = null;
 
 function codexEsc(value) {
     const div = document.createElement('div');
@@ -24,6 +27,145 @@ function codexShortId(id) {
     return id ? id.slice(0, 8) : '';
 }
 
+function codexPreferredAudioMimeType() {
+    const candidates = [
+        'audio/webm;codecs=opus',
+        'audio/webm',
+        'audio/mp4',
+        'audio/wav',
+    ];
+    if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return '';
+    return candidates.find(type => MediaRecorder.isTypeSupported(type)) || '';
+}
+
+function codexAudioExtension(mimeType) {
+    const lower = (mimeType || '').toLowerCase();
+    if (lower.includes('mp4')) return 'm4a';
+    if (lower.includes('wav')) return 'wav';
+    if (lower.includes('mpeg') || lower.includes('mp3')) return 'mp3';
+    if (lower.includes('ogg')) return 'ogg';
+    return 'webm';
+}
+
+function codexSetVoiceStatus(text) {
+    const el = document.getElementById('codex-voice-status');
+    if (el) el.textContent = text || '';
+}
+
+function codexSetVoiceRecording(recording) {
+    const btn = document.getElementById('codex-voice-toggle');
+    if (!btn) return;
+    btn.classList.toggle('recording', recording);
+    btn.textContent = recording ? '停止录音' : '语音输入';
+}
+
+function codexCleanupVoiceStream() {
+    if (!codexVoiceStream) return;
+    codexVoiceStream.getTracks().forEach(track => track.stop());
+    codexVoiceStream = null;
+}
+
+function codexBlobToBase64(blob) {
+    return new Promise((resolve, reject) => {
+        const reader = new FileReader();
+        reader.onload = () => {
+            const value = String(reader.result || '');
+            resolve(value.includes(',') ? value.split(',', 2)[1] : value);
+        };
+        reader.onerror = () => reject(reader.error || new Error('读取录音失败'));
+        reader.readAsDataURL(blob);
+    });
+}
+
+function codexAppendPromptText(text) {
+    const promptEl = document.getElementById('codex-prompt');
+    if (!promptEl || !text) return;
+    const current = promptEl.value || '';
+    const separator = current.trim() ? (current.endsWith('\n') ? '' : '\n') : '';
+    promptEl.value = current + separator + text.trim();
+    promptEl.focus();
+    promptEl.selectionStart = promptEl.selectionEnd = promptEl.value.length;
+}
+
+async function codexStartVoiceInput() {
+    if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
+        codexSetVoiceStatus('当前浏览器不支持录音');
+        return;
+    }
+    try {
+        codexVoiceChunks = [];
+        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+        codexVoiceStream = stream;
+        const mimeType = codexPreferredAudioMimeType();
+        codexRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
+        codexRecorder.addEventListener('dataavailable', event => {
+            if (event.data && event.data.size > 0) codexVoiceChunks.push(event.data);
+        });
+        codexRecorder.addEventListener('stop', () => {
+            const blob = new Blob(codexVoiceChunks, { type: codexRecorder?.mimeType || mimeType || 'audio/webm' });
+            codexRecorder = null;
+            codexCleanupVoiceStream();
+            codexSetVoiceRecording(false);
+            codexFinishVoiceInput(blob);
+        });
+        codexRecorder.addEventListener('error', event => {
+            codexSetVoiceStatus(event.error?.message || '录音失败');
+            codexRecorder = null;
+            codexCleanupVoiceStream();
+            codexSetVoiceRecording(false);
+        });
+        codexRecorder.start();
+        codexSetVoiceRecording(true);
+        codexSetVoiceStatus('录音中,再点一次停止');
+    } catch (err) {
+        codexRecorder = null;
+        codexCleanupVoiceStream();
+        codexSetVoiceRecording(false);
+        codexSetVoiceStatus(err?.message || '无法启动麦克风');
+    }
+}
+
+function codexStopVoiceInput() {
+    if (codexRecorder && codexRecorder.state !== 'inactive') {
+        codexRecorder.stop();
+    }
+}
+
+async function codexToggleVoiceInput() {
+    if (codexRecorder && codexRecorder.state === 'recording') {
+        codexStopVoiceInput();
+    } else {
+        await codexStartVoiceInput();
+    }
+}
+
+async function codexFinishVoiceInput(blob) {
+    if (!blob || blob.size === 0) {
+        codexSetVoiceStatus('没有录到声音');
+        return;
+    }
+    codexSetVoiceStatus('正在转写...');
+    try {
+        const audioBase64 = await codexBlobToBase64(blob);
+        const resp = await fetch('/api/codex/transcribe', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({
+                audioBase64,
+                mimeType: blob.type || 'audio/webm',
+                filename: `codex-voice-${Date.now()}.${codexAudioExtension(blob.type)}`,
+                language: 'zh',
+            }),
+        });
+        const data = await resp.json();
+        if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
+        codexAppendPromptText(data.text || '');
+        codexSetVoiceStatus(data.model ? `已转成文字 · ${data.model}` : '已转成文字');
+    } catch (err) {
+        codexSetVoiceStatus(err?.message || '转写失败');
+    }
+}
+
 async function codexLoadSessions(force = false) {
     if (codexLoaded && !force) return;
     const list = document.getElementById('codex-session-list');
@@ -113,7 +255,7 @@ async function codexSelectSession(id) {
         const session = data.session || {};
         if (title) title.textContent = session.title || session.id || 'Codex 会话';
         if (meta) {
-            meta.textContent = `${codexFormatDate(session.updated_at || session.created_at)} · ${session.id || ''} · ${session.archived ? '归档' : '当前'}`;
+            meta.textContent = `${codexFormatDate(session.updated_at || session.created_at)} · ${session.id || ''} · ${session.archived ? '归档' : '当前'} · 本机 CLI 会话`;
         }
         codexRenderMessages(data.messages || []);
     } catch (err) {
@@ -237,6 +379,11 @@ function codexBind() {
         refresh.dataset.bound = '1';
         refresh.addEventListener('click', () => codexLoadSessions(true));
     }
+    const voice = document.getElementById('codex-voice-toggle');
+    if (voice && !voice.dataset.bound) {
+        voice.dataset.bound = '1';
+        voice.addEventListener('click', codexToggleVoiceInput);
+    }
     const runNew = document.getElementById('codex-run-new');
     if (runNew && !runNew.dataset.bound) {
         runNew.dataset.bound = '1';
@@ -261,6 +408,7 @@ function codexBind() {
     if (panel) {
         observer.observe(panel, { attributes: true, attributeFilter: ['class'] });
     }
+    window.addEventListener('beforeunload', codexCleanupVoiceStream);
     document.addEventListener('DOMContentLoaded', () => {
         codexBind();
         const panelNow = document.getElementById('panel-codex');
diff --git a/Tools/Dashboard/serve.py b/Tools/Dashboard/serve.py
index dce5c7fc5..053d64cea 100644
--- a/Tools/Dashboard/serve.py
+++ b/Tools/Dashboard/serve.py
@@ -54,6 +54,8 @@ Usage:
 """
 
 import http.server
+import base64
+import binascii
 import csv
 import json
 import os
@@ -103,6 +105,10 @@ CODEX_HOME = os.path.join(os.path.expanduser('~'), '.codex')
 CODEX_SESSIONS_DIR = os.path.join(CODEX_HOME, 'sessions')
 CODEX_ARCHIVED_SESSIONS_DIR = os.path.join(CODEX_HOME, 'archived_sessions')
 CODEX_SESSION_INDEX = os.path.join(CODEX_HOME, 'session_index.jsonl')
+CODEX_TRANSCRIBE_MAX_BYTES = 25 * 1024 * 1024
+OPENROUTER_ENV_FILE = os.path.join(SCRIPT_DIR, 'private', 'community_monitor.env')
+OPENROUTER_TRANSCRIBE_ENDPOINT = 'https://openrouter.ai/api/v1/audio/transcriptions'
+OPENROUTER_TRANSCRIBE_DEFAULT_MODEL = 'openai/whisper-large-v3'
 CODEX_JOBS = {}
 CODEX_JOBS_LOCK = threading.Lock()
 COMMUNITY_MONITOR_API = None
@@ -418,6 +424,30 @@ def _read_jsonl_records(path, limit=None):
     return records
 
 
+def _load_dashboard_env_file(path):
+    if not os.path.exists(path):
+        return
+    try:
+        with open(path, 'r', encoding='utf-8-sig', errors='replace') as f:
+            for raw_line in f:
+                line = raw_line.strip()
+                if not line or line.startswith('#') or '=' not in line:
+                    continue
+                key, value = line.split('=', 1)
+                key = key.strip()
+                value = value.strip().strip('"').strip("'")
+                if key and key not in os.environ:
+                    os.environ[key] = value
+    except Exception:
+        pass
+
+
+def _openrouter_setting(name, default=''):
+    if name not in os.environ:
+        _load_dashboard_env_file(OPENROUTER_ENV_FILE)
+    return os.environ.get(name, default)
+
+
 def _message_text(content):
     if isinstance(content, str):
         return content
@@ -432,6 +462,90 @@ def _message_text(content):
     return '\n'.join(part for part in parts if part)
 
 
+def _decode_codex_audio_base64(value):
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError('audioBase64 required')
+    cleaned = value.strip()
+    if ',' in cleaned and cleaned[:80].lower().startswith('data:'):
+        cleaned = cleaned.split(',', 1)[1]
+    try:
+        data = base64.b64decode(cleaned, validate=True)
+    except (binascii.Error, ValueError):
+        raise ValueError('invalid audioBase64')
+    if not data:
+        raise ValueError('audio is empty')
+    if len(data) > CODEX_TRANSCRIBE_MAX_BYTES:
+        raise ValueError('audio too large')
+    return data
+
+
+def _codex_audio_format(filename='', mime_type=''):
+    source = f'{mime_type or ""} {filename or ""}'.lower()
+    for fmt in ('webm', 'wav', 'mp3', 'flac', 'm4a', 'ogg', 'aac'):
+        if fmt in source:
+            return fmt
+    return 'webm'
+
+
+def _codex_transcribe_audio(audio_base64, filename='', mime_type='', language=''):
+    api_key = _openrouter_setting('OPENROUTER_API_KEY', '').strip()
+    if not api_key:
+        raise RuntimeError('OPENROUTER_API_KEY is not configured')
+
+    audio_bytes = _decode_codex_audio_base64(audio_base64)
+    model = _openrouter_setting('OPENROUTER_TRANSCRIBE_MODEL', OPENROUTER_TRANSCRIBE_DEFAULT_MODEL).strip()
+    if not model:
+        model = OPENROUTER_TRANSCRIBE_DEFAULT_MODEL
+
+    payload = {
+        'model': model,
+        'input_audio': {
+            'data': base64.b64encode(audio_bytes).decode('ascii'),
+            'format': _codex_audio_format(filename, mime_type),
+        },
+    }
+    if language:
+        payload['language'] = str(language)[:32]
+
+    body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
+    request = urllib.request.Request(
+        OPENROUTER_TRANSCRIBE_ENDPOINT,
+        data=body,
+        headers={
+            'Authorization': f'Bearer {api_key}',
+            'Content-Type': 'application/json',
+            'HTTP-Referer': _openrouter_setting('OPENROUTER_REFERER', 'http://localhost:8080'),
+            'X-Title': _openrouter_setting('OPENROUTER_TITLE', 'TH1 Dashboard Codex Voice Input'),
+        },
+        method='POST',
+    )
+    try:
+        with urllib.request.urlopen(request, timeout=120) as resp:
+            raw = resp.read().decode('utf-8', errors='replace')
+    except urllib.error.HTTPError as e:
+        detail = e.read().decode('utf-8', errors='replace')[:1200]
+        raise RuntimeError(f'OpenRouter transcription failed: HTTP {e.code} {detail}')
+    except urllib.error.URLError as e:
+        raise RuntimeError(f'OpenRouter transcription failed: {e.reason}')
+
+    try:
+        data = json.loads(raw)
+    except Exception:
+        raise RuntimeError('OpenRouter transcription returned invalid JSON')
+    text = str(data.get('text') or '').strip()
+    if not text and isinstance(data.get('choices'), list) and data['choices']:
+        choice = data['choices'][0] or {}
+        message = choice.get('message') if isinstance(choice.get('message'), dict) else {}
+        text = str(message.get('content') or choice.get('text') or '').strip()
+    if not text:
+        raise RuntimeError('OpenRouter transcription returned empty text')
+    return {
+        'text': text,
+        'model': data.get('model') or model,
+        'duration': data.get('duration'),
+    }
+
+
 def _is_codex_context_message(role, text):
     if role != 'user':
         return False
@@ -1112,6 +1226,8 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
             self._handle_dashboard_preferences_save()
         elif self.path == '/api/codex/run':
             self._handle_codex_run()
+        elif self.path == '/api/codex/transcribe':
+            self._handle_codex_transcribe()
         elif self.path.startswith('/api/community-monitor/'):
             self._handle_community_monitor_post()
         # SNS APIs
@@ -1516,6 +1632,21 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
         except Exception as e:
             self._send_json({'success': False, 'error': str(e)}, 500)
 
+    def _handle_codex_transcribe(self):
+        try:
+            payload = self._read_json_body()
+            result = _codex_transcribe_audio(
+                payload.get('audioBase64'),
+                filename=str(payload.get('filename') or ''),
+                mime_type=str(payload.get('mimeType') or ''),
+                language=str(payload.get('language') or 'zh'),
+            )
+            self._send_json({'success': True, **result})
+        except ValueError as e:
+            self._send_json({'success': False, 'error': str(e)}, 400)
+        except Exception as e:
+            self._send_json({'success': False, 'error': str(e)}, 500)
+
     def _handle_codex_job_get(self):
         try:
             parsed = urlparse(self.path)