459 lines
17 KiB
JavaScript
459 lines
17 KiB
JavaScript
/**
|
|
* TH1 Dashboard - local Codex sessions.
|
|
*/
|
|
|
|
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');
|
|
div.textContent = value == null ? '' : String(value);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function codexFormatDate(value) {
|
|
if (!value) return '';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
return date.toLocaleString('zh-CN', { hour12: false });
|
|
}
|
|
|
|
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 codexIsSecureVoiceContext() {
|
|
return window.isSecureContext || ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function codexOpenAudioFilePicker(message) {
|
|
const fileInput = document.getElementById('codex-audio-file');
|
|
if (!fileInput) {
|
|
codexSetVoiceStatus(message || '请改用安全来源或手动输入');
|
|
return;
|
|
}
|
|
if (message) codexSetVoiceStatus(message);
|
|
fileInput.value = '';
|
|
fileInput.click();
|
|
}
|
|
|
|
async function codexStartVoiceInput() {
|
|
if (!codexIsSecureVoiceContext()) {
|
|
codexOpenAudioFilePicker('手机 Chrome 需要 HTTPS 才能直接录音,请改用系统录音/音频文件');
|
|
return;
|
|
}
|
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
|
codexOpenAudioFilePicker('当前浏览器不能直接录音,请改用系统录音/音频文件');
|
|
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 codexHandleAudioFilePicked(event) {
|
|
const file = event.target?.files?.[0];
|
|
if (!file) return;
|
|
if (!file.type.startsWith('audio/')) {
|
|
codexSetVoiceStatus('请选择音频文件');
|
|
return;
|
|
}
|
|
await codexFinishVoiceInput(file);
|
|
}
|
|
|
|
async function codexLoadSessions(force = false) {
|
|
if (codexLoaded && !force) return;
|
|
const list = document.getElementById('codex-session-list');
|
|
const count = document.getElementById('codex-count');
|
|
if (list) list.innerHTML = '<div class="loading-inline">正在读取本机 Codex 会话...</div>';
|
|
if (count) count.textContent = '加载中...';
|
|
|
|
try {
|
|
const resp = await fetch('/api/codex/sessions?limit=1000&t=' + Date.now());
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
|
|
codexSessions = data.sessions || [];
|
|
codexLoaded = true;
|
|
codexRenderSessions();
|
|
} catch (err) {
|
|
if (list) list.innerHTML = `<div class="loading-inline">加载失败:${codexEsc(err.message)}</div>`;
|
|
if (count) count.textContent = '加载失败';
|
|
}
|
|
}
|
|
|
|
function codexRenderSessions() {
|
|
const list = document.getElementById('codex-session-list');
|
|
const count = document.getElementById('codex-count');
|
|
if (!list) return;
|
|
|
|
const query = (document.getElementById('codex-search')?.value || '').trim().toLowerCase();
|
|
const includeArchived = document.getElementById('codex-include-archived')?.checked !== false;
|
|
let rows = codexSessions.filter(item => includeArchived || !item.archived);
|
|
|
|
if (query) {
|
|
rows = rows.filter(item => {
|
|
const haystack = [
|
|
item.id,
|
|
item.title,
|
|
item.preview,
|
|
item.updated_at,
|
|
].join('\n').toLowerCase();
|
|
return haystack.includes(query);
|
|
});
|
|
}
|
|
|
|
if (count) {
|
|
const archived = codexSessions.filter(item => item.archived).length;
|
|
count.textContent = `${rows.length}/${codexSessions.length} 个 TH1 会话,归档 ${archived} 个`;
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
list.innerHTML = '<div class="loading-inline">没有匹配的 TH1 Codex 会话。</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = rows.map(item => {
|
|
const active = item.id === codexSelectedId ? ' active' : '';
|
|
const archived = item.archived ? '<span class="codex-badge">归档</span>' : '';
|
|
return `
|
|
<button class="codex-session-item${active}" data-id="${codexEsc(item.id)}" type="button">
|
|
<span class="codex-session-top">
|
|
<span class="codex-session-title">${codexEsc(item.title || item.id)}</span>
|
|
${archived}
|
|
</span>
|
|
<span class="codex-session-meta">${codexFormatDate(item.updated_at || item.created_at)} · ${codexShortId(item.id)} · ${item.message_count == null ? '-' : item.message_count} 条</span>
|
|
<span class="codex-session-preview">${codexEsc(item.preview || '')}</span>
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
|
|
list.querySelectorAll('.codex-session-item').forEach(btn => {
|
|
btn.addEventListener('click', () => codexSelectSession(btn.dataset.id));
|
|
});
|
|
}
|
|
|
|
async function codexSelectSession(id) {
|
|
codexSelectedId = id || '';
|
|
codexRenderSessions();
|
|
const title = document.getElementById('codex-detail-title');
|
|
const meta = document.getElementById('codex-detail-meta');
|
|
const messages = document.getElementById('codex-messages');
|
|
const resumeBtn = document.getElementById('codex-run-resume');
|
|
if (resumeBtn) resumeBtn.disabled = !codexSelectedId;
|
|
if (!codexSelectedId) return;
|
|
|
|
if (messages) messages.innerHTML = '<div class="loading-inline">正在读取会话详情...</div>';
|
|
try {
|
|
const resp = await fetch('/api/codex/session?id=' + encodeURIComponent(codexSelectedId) + '&t=' + Date.now());
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
|
|
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 ? '归档' : '当前'} · 本机 CLI 会话`;
|
|
}
|
|
codexRenderMessages(data.messages || []);
|
|
} catch (err) {
|
|
if (messages) messages.innerHTML = `<div class="loading-inline">读取失败:${codexEsc(err.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function codexRenderMessages(messages) {
|
|
const box = document.getElementById('codex-messages');
|
|
if (!box) return;
|
|
const visible = messages
|
|
.filter(item => item.role === 'user' || item.role === 'assistant')
|
|
.slice(-80);
|
|
if (visible.length === 0) {
|
|
box.innerHTML = '<div class="loading-inline">这个会话没有可展示的用户/助手消息。</div>';
|
|
return;
|
|
}
|
|
box.innerHTML = visible.map(item => {
|
|
const role = item.role === 'user' ? '用户' : (item.phase === 'commentary' ? 'Codex 进度' : 'Codex');
|
|
return `
|
|
<article class="codex-message ${item.role}">
|
|
<div class="codex-message-head">
|
|
<span>${role}</span>
|
|
<span>${codexFormatDate(item.timestamp)}</span>
|
|
</div>
|
|
<pre>${codexEsc(item.text)}</pre>
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function codexRun(resume) {
|
|
const promptEl = document.getElementById('codex-prompt');
|
|
const prompt = (promptEl?.value || '').trim();
|
|
if (!prompt) {
|
|
alert('请输入要交给 Codex 的任务。');
|
|
return;
|
|
}
|
|
if (resume && !codexSelectedId) {
|
|
alert('请先选择要续接的会话。');
|
|
return;
|
|
}
|
|
|
|
codexSetJobStatus('提交中...');
|
|
codexSetJobOutput('');
|
|
try {
|
|
const resp = await fetch('/api/codex/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt,
|
|
sessionId: resume ? codexSelectedId : '',
|
|
}),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
|
|
codexPollJob(data.job.id);
|
|
} catch (err) {
|
|
codexSetJobStatus('启动失败');
|
|
codexSetJobOutput(err.message);
|
|
}
|
|
}
|
|
|
|
function codexSetJobStatus(text) {
|
|
const el = document.getElementById('codex-job-status');
|
|
if (el) el.textContent = text || '';
|
|
}
|
|
|
|
function codexSetJobOutput(text) {
|
|
const el = document.getElementById('codex-job-output');
|
|
if (!el) return;
|
|
el.textContent = text || '';
|
|
el.style.display = text ? 'block' : 'none';
|
|
}
|
|
|
|
async function codexPollJob(jobId) {
|
|
if (codexPollTimer) clearTimeout(codexPollTimer);
|
|
if (!jobId) return;
|
|
try {
|
|
const resp = await fetch('/api/codex/jobs?id=' + encodeURIComponent(jobId) + '&t=' + Date.now());
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
|
|
const job = data.job || {};
|
|
codexSetJobStatus(codexJobLabel(job));
|
|
const output = job.last_message || job.error || job.stderr || job.stdout || '';
|
|
codexSetJobOutput(output);
|
|
if (job.status === 'queued' || job.status === 'running') {
|
|
codexPollTimer = setTimeout(() => codexPollJob(jobId), 2500);
|
|
} else {
|
|
codexLoaded = false;
|
|
codexLoadSessions(true);
|
|
}
|
|
} catch (err) {
|
|
codexSetJobStatus('轮询失败');
|
|
codexSetJobOutput(err.message);
|
|
}
|
|
}
|
|
|
|
function codexJobLabel(job) {
|
|
if (!job) return '';
|
|
if (job.status === 'queued') return '排队中';
|
|
if (job.status === 'running') return `执行中${job.pid ? ' · PID ' + job.pid : ''}`;
|
|
if (job.status === 'done') return '执行完成';
|
|
if (job.status === 'failed') return `执行失败${job.returncode != null ? ' · code ' + job.returncode : ''}`;
|
|
return job.status || '';
|
|
}
|
|
|
|
function codexBind() {
|
|
const search = document.getElementById('codex-search');
|
|
if (search && !search.dataset.bound) {
|
|
search.dataset.bound = '1';
|
|
search.addEventListener('input', codexRenderSessions);
|
|
}
|
|
const archived = document.getElementById('codex-include-archived');
|
|
if (archived && !archived.dataset.bound) {
|
|
archived.dataset.bound = '1';
|
|
archived.addEventListener('change', codexRenderSessions);
|
|
}
|
|
const refresh = document.getElementById('codex-refresh');
|
|
if (refresh && !refresh.dataset.bound) {
|
|
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 audioPick = document.getElementById('codex-audio-pick');
|
|
if (audioPick && !audioPick.dataset.bound) {
|
|
audioPick.dataset.bound = '1';
|
|
audioPick.addEventListener('click', () => codexOpenAudioFilePicker('选择或录制一段音频后自动转写'));
|
|
}
|
|
const audioFile = document.getElementById('codex-audio-file');
|
|
if (audioFile && !audioFile.dataset.bound) {
|
|
audioFile.dataset.bound = '1';
|
|
audioFile.addEventListener('change', codexHandleAudioFilePicked);
|
|
}
|
|
const runNew = document.getElementById('codex-run-new');
|
|
if (runNew && !runNew.dataset.bound) {
|
|
runNew.dataset.bound = '1';
|
|
runNew.addEventListener('click', () => codexRun(false));
|
|
}
|
|
const runResume = document.getElementById('codex-run-resume');
|
|
if (runResume && !runResume.dataset.bound) {
|
|
runResume.dataset.bound = '1';
|
|
runResume.addEventListener('click', () => codexRun(true));
|
|
}
|
|
}
|
|
|
|
(function () {
|
|
const observer = new MutationObserver(() => {
|
|
const panel = document.getElementById('panel-codex');
|
|
if (panel && panel.classList.contains('active')) {
|
|
codexBind();
|
|
codexLoadSessions();
|
|
}
|
|
});
|
|
const panel = document.getElementById('panel-codex');
|
|
if (panel) {
|
|
observer.observe(panel, { attributes: true, attributeFilter: ['class'] });
|
|
}
|
|
window.addEventListener('beforeunload', codexCleanupVoiceStream);
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
codexBind();
|
|
const panelNow = document.getElementById('panel-codex');
|
|
if (panelNow && panelNow.classList.contains('active')) {
|
|
codexLoadSessions();
|
|
}
|
|
});
|
|
})();
|