TH1/Tools/Dashboard/js/codex_threads.js

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();
}
});
})();