Add Dashboard Codex voice input
This commit is contained in:
parent
fb3834240d
commit
2f29d78b5a
@ -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;
|
||||
|
||||
@ -837,15 +837,17 @@
|
||||
<div class="codex-detail-head">
|
||||
<div>
|
||||
<div class="codex-detail-title" id="codex-detail-title">选择一个 TH1 会话</div>
|
||||
<div class="codex-detail-meta" id="codex-detail-meta">本面板只显示工作目录为 C:\TH1\TH1 的本机 Codex 会话。</div>
|
||||
<div class="codex-detail-meta" id="codex-detail-meta">本面板读取 ~/.codex/sessions 的本机 CLI 会话,只显示工作目录为 C:\TH1\TH1 的记录;这些记录不一定出现在桌面版侧栏线程里。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="codex-runner">
|
||||
<textarea id="codex-prompt" class="codex-prompt" rows="5" placeholder="输入要交给本机 Codex 执行的任务..."></textarea>
|
||||
<div class="codex-runner-actions">
|
||||
<button class="bug-btn codex-voice-btn" id="codex-voice-toggle" type="button">语音输入</button>
|
||||
<button class="bug-btn" id="codex-run-new" type="button">新会话执行</button>
|
||||
<button class="bug-btn bug-btn-primary" id="codex-run-resume" type="button" disabled>续接所选会话执行</button>
|
||||
<span id="codex-voice-status" class="codex-voice-status"></span>
|
||||
<span id="codex-job-status" class="codex-job-status"></span>
|
||||
</div>
|
||||
<pre id="codex-job-output" class="codex-job-output"></pre>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user