Merge branch 'feat/conductor' into main

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yiqi-017andClaude Opus 4.6 committed 2026-05-27 18:21:19 +08:00
commit bbfa91c97a
4 files changed
+860 -106

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ builtins.print = _filtered_print
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
# allow: python frontends/conductor.py
@@ -27,6 +28,7 @@ PORT = 8900
HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conductor.html")
app = FastAPI(title="Conductor")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
class ChatIn(BaseModel):
msg: str
+607 -95
View File
@@ -221,9 +221,10 @@ const I18N = {
'app.title': 'GenericAgent 桌面版',
'brand.sub': '桌面终端',
'nav.chat': '聊天', 'nav.channels': '消息通道', 'nav.status': '状态面板',
'nav.collab': '协作动态', 'nav.token': 'Token 统计',
'nav.collab': 'Agent 指挥', 'nav.token': 'Token 统计',
'foot.settings': '配置', 'foot.ver': 'GenericAgent · 桌面版',
'chat.startTitle': '开始对话', 'chat.startSub': '直接输入,或点预设功能一键启动',
'preset.butler.t': 'Conductor', 'preset.butler.d': '复杂任务自动拆活,进度在 Conductor 查看',
'preset.goal.t': 'Goal 模式', 'preset.goal.d': '设定目标,自主完成',
'preset.explore.t': '自主探索', 'preset.explore.d': '自动浏览并周期汇总',
'preset.hive.t': 'Hive 协作', 'preset.hive.d': '多 worker 协同攻坚',
@@ -258,7 +259,41 @@ const I18N = {
'confirm.modelDelete': '确定删除该模型配置?',
'page.channels.title': '消息通道', 'page.channels.sub': '后台 IM 进程:列表、启停与日志(同 hub.pyw)',
'page.status.title': '状态面板', 'page.status.sub': 'hub.pyw 管理的后台进程/服务,集中查看与启停',
'page.collab.title': '协作动态', 'page.collab.sub': 'subagent / Hive worker 的实时状态与产出',
'page.collab.title': 'Conductor', 'page.collab.sub': '交代目标,自动拆活与跟进',
'collab.progressTitle': '分工进度',
'collab.progressEmpty': '还没有任务在执行。告诉 Conductor 你的目标后,这里会显示拆分后的处理进度。',
'collab.chatTitle': '与 Conductor 对话',
'collab.placeholder': '描述你想完成的目标,Enter 发送…',
'collab.guideTitle': '把要完成的事告诉 Conductor',
'collab.guideWhen': '适合需要多步处理、要花一些时间才能完成的目标。日常聊天和快问快答,请用左侧「聊天」。',
'collab.guideStep1t': '描述目标',
'collab.guideStep1d': '在聊天框里写下你想做的事,发给 Conductor',
'collab.guideStep2t': '自动拆解',
'collab.guideStep2d': 'Conductor 自动拆解、分配任务,实时监督和调度',
'collab.guideStep3t': '交付摘要',
'collab.guideStep3d': 'Conductor 根据执行状态,呈上任务简报',
'collab.guideStep4t': '随时调整',
'collab.guideStep4d': '随时补充要求或细节,Conductor 都会处理',
'collab.chipProgress': '现在进展如何?',
'collab.chipPause': '先暂停当前任务',
'collab.chipSummary': '总结一下目前的结果',
'collab.showProgressTitle': '查看任务执行进度(窄屏)',
'collab.progressStats': '{running} 进行中 · {done} 已完成 · {issue} 需关注',
'collab.typing': 'Conductor 正在处理',
'collab.offline': '无法连接 Conductor(8900)。请确认服务已启动且本地已穿透 8900 端口。',
'collab.retry': '重试',
'collab.reconnect': '连接断开,正在重连… 已保留上次任务进度。',
'collab.reconnectIn': '{n} 秒后重试',
'collab.sticky': '{n} 个任务进行中',
'collab.stRunning': '执行中', 'collab.stReported': '已回报', 'collab.stPaused': '已暂停',
'collab.stFailed': '遇到问题', 'collab.stTerminated': '已终止',
'collab.summaryRunning': '正在处理中…', 'collab.summaryWait': '等待回报',
'collab.taskFallback': '任务 {n}',
'collab.timeJust': '刚刚',
'collab.timeSec': '{n} 秒前',
'collab.timeMin': '{n} 分钟前',
'collab.timeHr': '{n} 小时前',
'collab.timeDay': '{n} 天前',
'page.token.title': 'Token 统计', 'page.token.sub': '每会话与累计的 token 用量及估算成本',
'status.connecting': '连接中…', 'status.ready': '就绪', 'status.running': '运行中',
'status.disconnected': '未连接', 'status.stopped': '已停止', 'status.idle': '空闲',
@@ -327,9 +362,10 @@ const I18N = {
'app.title': 'GenericAgent Desktop',
'brand.sub': 'Desktop terminal',
'nav.chat': 'Chat', 'nav.channels': 'Channels', 'nav.status': 'Status',
'nav.collab': 'Collaboration', 'nav.token': 'Token usage',
'nav.collab': 'Conductor', 'nav.token': 'Token usage',
'foot.settings': 'Settings', 'foot.ver': 'GenericAgent · Desktop',
'chat.startTitle': 'Start a conversation', 'chat.startSub': 'Type a message, or pick a preset',
'preset.butler.t': 'Conductor', 'preset.butler.d': 'Delegate complex tasks; track progress in Conductor',
'preset.goal.t': 'Goal mode', 'preset.goal.d': 'Set a goal, run autonomously',
'preset.explore.t': 'Auto explore', 'preset.explore.d': 'Browse & summarize periodically',
'preset.hive.t': 'Hive', 'preset.hive.d': 'Multi-worker collaboration',
@@ -364,7 +400,41 @@ const I18N = {
'confirm.modelDelete': 'Delete this model profile?',
'page.channels.title': 'Channels', 'page.channels.sub': 'Background IM processes: list, start/stop, logs (hub.pyw style)',
'page.status.title': 'Status', 'page.status.sub': 'Background processes/services managed by hub.pyw',
'page.collab.title': 'Collaboration', 'page.collab.sub': 'Live state & output of subagents / Hive workers',
'page.collab.title': 'Conductor', 'page.collab.sub': 'Describe a goal — split, delegate, and follow up',
'collab.progressTitle': 'Progress',
'collab.progressEmpty': 'No tasks running yet. After you describe a goal to Conductor, split tasks will appear here.',
'collab.chatTitle': 'Chat with Conductor',
'collab.placeholder': 'Describe your goal, Enter to send…',
'collab.guideTitle': 'Tell Conductor what you want done',
'collab.guideWhen': 'Best for multi-step goals that take a while. For everyday chat and quick questions, use Chat in the sidebar.',
'collab.guideStep1t': 'Describe your goal',
'collab.guideStep1d': 'Write what you want done in the chat box and send it to Conductor',
'collab.guideStep2t': 'Auto breakdown',
'collab.guideStep2d': 'Conductor breaks down, assigns, monitors, and coordinates',
'collab.guideStep3t': 'Summary',
'collab.guideStep3d': 'Conductor delivers a briefing based on execution status',
'collab.guideStep4t': 'Adjust anytime',
'collab.guideStep4d': 'Add requirements or details anytime — Conductor handles them',
'collab.chipProgress': 'How is it going?',
'collab.chipPause': 'Pause current tasks',
'collab.chipSummary': 'Summarize progress so far',
'collab.showProgressTitle': 'View task progress (narrow screens)',
'collab.progressStats': '{running} running · {done} done · {issue} need attention',
'collab.typing': 'Conductor is working',
'collab.offline': 'Cannot reach Conductor (8900). Start the service and forward port 8900.',
'collab.retry': 'Retry',
'collab.reconnect': 'Disconnected — reconnecting… Your last progress is kept.',
'collab.reconnectIn': 'Retry in {n}s',
'collab.sticky': '{n} task(s) running',
'collab.stRunning': 'Running', 'collab.stReported': 'Reported', 'collab.stPaused': 'Paused',
'collab.stFailed': 'Issue', 'collab.stTerminated': 'Ended',
'collab.summaryRunning': 'Working…', 'collab.summaryWait': 'Awaiting report',
'collab.taskFallback': 'Task {n}',
'collab.timeJust': 'just now',
'collab.timeSec': '{n}s ago',
'collab.timeMin': '{n}m ago',
'collab.timeHr': '{n}h ago',
'collab.timeDay': '{n}d ago',
'page.token.title': 'Token usage', 'page.token.sub': 'Per-session and total token usage & estimated cost',
'status.connecting': 'Connecting…', 'status.ready': 'Ready', 'status.running': 'Running',
'status.disconnected': 'Disconnected', 'status.stopped': 'Stopped', 'status.idle': 'Idle',
@@ -499,6 +569,14 @@ async function bridgeFetch(path, opts = {}) {
return data;
}
function t(key) { return (I18N[lang] && I18N[lang][key]) || (I18N.zh[key]) || key; }
window.gaT = t;
document.addEventListener('collab:running-count', e => {
const b = document.getElementById('collab-badge');
if (!b) return;
const n = e.detail?.count || 0;
b.hidden = !n;
b.textContent = n ? (n > 9 ? '9+' : String(n)) : '';
});
function optionalPh(key) {
const sep = (lang === 'en') ? ', ' : ',';
return `${t('common.optional')}${sep}${t(key)}`;
@@ -513,6 +591,7 @@ function applyI18n() {
});
document.querySelectorAll('[data-i18n-title]').forEach(el => { el.setAttribute('title', t(el.dataset.i18nTitle)); });
renderLangList();
window.collabRetranslate?.();
}
// 语言对应国旗 SVG(en 用美国旗,按要求)
const FLAGS = {
@@ -665,12 +744,22 @@ function applyAppearance(nextApp, nextPlain, { persist } = { persist: true }) {
/* ═══════════════ 侧边栏导航 ═══════════════ */
const nav = document.getElementById('nav');
const pages = document.querySelectorAll('#pages .page');
let currentPage = 'chat';
function gaGoPage(key) {
const item = nav?.querySelector(`.nav-item[data-page="${key}"]`);
if (!item) return;
currentPage = key;
nav.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n === item));
pages.forEach(p => p.classList.toggle('active', p.dataset.page === key));
renderSessionList();
window.gaSetActiveFileComposer?.(key === 'collab' ? 'collab' : 'chat');
if (key === 'collab') window.collabInit?.();
}
window.gaGoPage = gaGoPage;
nav.addEventListener('click', (e) => {
const item = e.target.closest('.nav-item');
if (!item) return;
const key = item.dataset.page;
nav.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n === item));
pages.forEach(p => p.classList.toggle('active', p.dataset.page === key));
gaGoPage(item.dataset.page);
});
/* ═══════════════ 弹窗开关 ═══════════════ */
@@ -1313,7 +1402,7 @@ function renderSessionList() {
const r = state.runtime.get(sess.id);
const busy = !!(r && r.busy);
const item = document.createElement('div');
item.className = 'conv-item' + (sess.id === state.activeId ? ' active' : '') + (busy ? '' : ' idle');
item.className = 'conv-item' + (currentPage === 'chat' && sess.id === state.activeId ? ' active' : '') + (busy ? '' : ' idle');
item.dataset.id = sess.id;
const pinSvg = sess.pinned ? `<svg class="ci-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6z"/></svg>` : '';
item.innerHTML =
@@ -1485,8 +1574,9 @@ async function pollSession(sess) {
function removeUsedPendingFiles(usedFiles) {
if (!usedFiles.length) return;
const usedSids = new Set(usedFiles.map(f => f.sid));
const touched = new Set(usedFiles.map(f => fileCtx(f)));
state.pendingFiles = state.pendingFiles.filter(f => !usedSids.has(f.sid));
renderThumbStrip();
touched.forEach(ctx => renderThumbStrip(ctx));
}
function clearDraft(sess) {
@@ -1702,6 +1792,8 @@ document.querySelectorAll('.feature-grid').forEach(grid => {
return;
}
if (!key) { inputEl.focus(); closeModals(); return; }
const bp = BUILTIN_PRESETS.find(p => p.key === key);
if (bp?.navigate) { closeModals(); gaGoPage(bp.navigate); window.collabFocus?.(); return; }
const prompt = I18N[lang]['presetPrompt.' + key] || I18N.zh['presetPrompt.' + key];
closeModals();
if (prompt) sendPrompt(prompt);
@@ -2010,23 +2102,34 @@ const imgInput = document.getElementById('img-input');
const thumbStrip = document.getElementById('thumb-strip');
const uploadBtn = document.getElementById('upload-btn');
const chatPanel = document.querySelector('main.main');
let activeFileComposer = 'chat';
function isImageFile(f) {
return (f && (f.type || '').startsWith('image/')) || IMG_EXT_RE.test(f?.name || '');
function fileCtx(f) { return f.ctx || 'chat'; }
function filesForCtx(ctx) { return state.pendingFiles.filter(f => fileCtx(f) === ctx); }
function composerCfg(ctx = activeFileComposer) {
if (ctx === 'collab') {
return {
input: document.getElementById('collab-input'),
strip: document.getElementById('collab-thumb-strip'),
uploadBtn: document.getElementById('collab-upload-btn'),
imgInput: document.getElementById('collab-img-input'),
dropZone: document.querySelector('.collab-chat'),
};
}
return { input: inputEl, strip: thumbStrip, uploadBtn, imgInput, dropZone: chatPanel };
}
function placeholderFor(file) {
return file.isImage ? `[Image #${file.sid}]` : `[File #${file.sid}]`;
}
function renderThumbStrip() {
if (!thumbStrip) return;
if (state.pendingFiles.length === 0) {
thumbStrip.innerHTML = '';
thumbStrip.hidden = true;
function renderThumbStrip(ctx = activeFileComposer) {
const cfg = composerCfg(ctx);
if (!cfg.strip) return;
const files = filesForCtx(ctx);
if (files.length === 0) {
cfg.strip.innerHTML = '';
cfg.strip.hidden = true;
return;
}
thumbStrip.innerHTML = state.pendingFiles.map(f => {
cfg.strip.innerHTML = files.map(f => {
if (f.isImage && f.dataUrl) {
return `<div class="thumb" data-sid="${f.sid}"><img src="${f.dataUrl}"><button class="x" data-sid="${f.sid}" data-i18n-title="upload.removeTitle" title="">×</button></div>`;
}
@@ -2037,31 +2140,41 @@ function renderThumbStrip() {
const dataName = name.replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
return `<div class="file-chip pending" data-sid="${f.sid}" data-path="${path}" data-name="${dataName}"><span class="fc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span><span class="fc-meta"><span class="fc-name">${label}</span><span class="fc-sub">${sub}</span></span><button class="x" data-sid="${f.sid}" data-i18n-title="upload.removeTitle" title="">×</button></div>`;
}).join('');
thumbStrip.hidden = false;
cfg.strip.hidden = false;
applyI18n();
}
function insertPlaceholderInComposer(marker) {
if (!inputEl) return;
const start = inputEl.selectionStart ?? inputEl.value.length;
const end = inputEl.selectionEnd ?? inputEl.value.length;
const before = inputEl.value.slice(0, start);
const after = inputEl.value.slice(end);
function insertPlaceholderInComposer(marker, ctx = activeFileComposer) {
const input = composerCfg(ctx).input;
if (!input) return;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
const before = input.value.slice(0, start);
const after = input.value.slice(end);
const needSpace = before && !/\s$/.test(before);
const insertion = (needSpace ? ' ' : '') + marker + ' ';
inputEl.value = before + insertion + after;
input.value = before + insertion + after;
const caret = (before + insertion).length;
inputEl.setSelectionRange(caret, caret);
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
inputEl.focus();
input.setSelectionRange(caret, caret);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
}
function removePlaceholderFromComposer(file) {
if (!inputEl) return;
const input = composerCfg(fileCtx(file)).input;
if (!input) return;
const marker = placeholderFor(file);
const re = new RegExp('\\s?' + marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s?', '');
inputEl.value = inputEl.value.replace(re, ' ').replace(/ +/g, ' ').trim();
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
input.value = input.value.replace(re, ' ').replace(/ +/g, ' ').trim();
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function isImageFile(f) {
return (f && (f.type || '').startsWith('image/')) || IMG_EXT_RE.test(f?.name || '');
}
function placeholderFor(file) {
return file.isImage ? `[Image #${file.sid}]` : `[File #${file.sid}]`;
}
function expandFilePlaceholders(text) {
@@ -2109,11 +2222,16 @@ async function addFiles(fileList) {
if (skipped) showChanToast(t('upload.tooLarge'), '', 'err');
return;
}
// group uploads under the active session (create one if there's none yet)
const ctx = activeFileComposer;
let uploadSid = '';
if (ctx === 'collab') {
uploadSid = 'collab';
} else {
let upSess = activeSess();
if (!upSess) { await newSession(); upSess = activeSess(); }
if (upSess && !upSess.bridgeSessionId) { try { await ensureBridgeSession(upSess); } catch (_) {} }
const uploadSid = (upSess && upSess.bridgeSessionId) || '';
uploadSid = (upSess && upSess.bridgeSessionId) || '';
}
for (const f of accepted) {
try {
const dataUrl = await new Promise((resolve, reject) => {
@@ -2129,10 +2247,11 @@ async function addFiles(fileList) {
const entry = {
sid, name: f.name || 'file', isImage, path,
dataUrl: isImage ? dataUrl : '',
ctx,
};
state.pendingFiles.push(entry);
insertPlaceholderInComposer(placeholderFor(entry));
renderThumbStrip();
insertPlaceholderInComposer(placeholderFor(entry), ctx);
renderThumbStrip(ctx);
} catch (e) {
showChanToast(t('upload.failed'), e.message || String(e), 'err');
}
@@ -2140,26 +2259,16 @@ async function addFiles(fileList) {
if (skipped) showChanToast(t('upload.tooLarge'), '', 'err');
}
if (uploadBtn && imgInput) uploadBtn.addEventListener('click', (e) => {
e.preventDefault();
imgInput.click();
});
if (imgInput) imgInput.addEventListener('change', () => {
addFiles(imgInput.files);
imgInput.value = '';
});
if (thumbStrip) thumbStrip.addEventListener('click', (e) => {
function handleThumbStripClick(e, ctx) {
const x = e.target.closest('.x');
if (x) {
const sid = Number(x.dataset.sid);
const idx = state.pendingFiles.findIndex(f => f.sid === sid);
const idx = state.pendingFiles.findIndex(f => f.sid === sid && fileCtx(f) === ctx);
if (idx >= 0) {
const removed = state.pendingFiles[idx];
state.pendingFiles.splice(idx, 1);
removePlaceholderFromComposer(removed);
renderThumbStrip();
renderThumbStrip(ctx);
if (removed.path) {
fetch(`http://${location.hostname}:14168/upload`, {
method: 'DELETE',
@@ -2179,51 +2288,25 @@ if (thumbStrip) thumbStrip.addEventListener('click', (e) => {
}
const img = e.target.closest('img');
if (img && img.src) openLightbox(img.src);
});
/* ─── drag & drop on chat panel ─── */
if (chatPanel) {
let dragDepth = 0;
const hasFiles = (e) => {
const types = e.dataTransfer && e.dataTransfer.types;
if (!types) return false;
for (let i = 0; i < types.length; i += 1) {
if (types[i] === 'Files') return true;
}
return false;
};
// Prevent the browser from opening dropped files outside chatPanel.
window.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); });
window.addEventListener('drop', (e) => { if (hasFiles(e)) e.preventDefault(); });
chatPanel.addEventListener('dragenter', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth += 1;
chatPanel.classList.add('dragover');
chatPanel.dataset.dropHint = t('upload.dropHint');
});
chatPanel.addEventListener('dragover', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
chatPanel.addEventListener('dragleave', (e) => {
if (!hasFiles(e)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) chatPanel.classList.remove('dragover');
});
chatPanel.addEventListener('drop', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth = 0;
chatPanel.classList.remove('dragover');
addFiles(e.dataTransfer.files);
});
}
/* ─── paste file/image into composer ─── */
if (inputEl) {
inputEl.addEventListener('paste', (e) => {
function bindComposerUpload(ctx) {
const cfg = composerCfg(ctx);
if (!cfg.uploadBtn || cfg.uploadBtn.dataset.bound) return;
cfg.uploadBtn.dataset.bound = '1';
cfg.uploadBtn.addEventListener('click', (e) => {
e.preventDefault();
activeFileComposer = ctx;
cfg.imgInput?.click();
});
cfg.imgInput?.addEventListener('change', () => {
activeFileComposer = ctx;
addFiles(cfg.imgInput.files);
cfg.imgInput.value = '';
});
cfg.strip?.addEventListener('click', (e) => handleThumbStripClick(e, ctx));
cfg.input?.addEventListener('paste', (e) => {
activeFileComposer = ctx;
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
const files = [];
@@ -2237,6 +2320,66 @@ if (inputEl) {
e.preventDefault();
addFiles(files);
});
const zone = cfg.dropZone;
if (!zone || zone.dataset.dropBound) return;
zone.dataset.dropBound = '1';
let dragDepth = 0;
const hasFiles = (e) => {
const types = e.dataTransfer && e.dataTransfer.types;
if (!types) return false;
for (let i = 0; i < types.length; i += 1) {
if (types[i] === 'Files') return true;
}
return false;
};
zone.addEventListener('dragenter', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
activeFileComposer = ctx;
dragDepth += 1;
zone.classList.add('dragover');
zone.dataset.dropHint = t('upload.dropHint');
});
zone.addEventListener('dragover', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
activeFileComposer = ctx;
e.dataTransfer.dropEffect = 'copy';
});
zone.addEventListener('dragleave', (e) => {
if (!hasFiles(e)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) zone.classList.remove('dragover');
});
zone.addEventListener('drop', (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth = 0;
zone.classList.remove('dragover');
activeFileComposer = ctx;
addFiles(e.dataTransfer.files);
});
}
bindComposerUpload('chat');
bindComposerUpload('collab');
Object.assign(window, {
gaSetActiveFileComposer: ctx => { activeFileComposer = ctx === 'collab' ? 'collab' : 'chat'; },
gaExpandFilePlaceholders: expandFilePlaceholders,
gaCollectUsedFiles: collectUsedFiles,
gaClearUsedPendingFiles: text => removeUsedPendingFiles(collectUsedFiles(text)),
gaFileSubLabel: fileSubLabel,
});
if (chatPanel) {
const blockFileDrop = e => {
const types = e.dataTransfer?.types;
if (!types) return;
for (let i = 0; i < types.length; i += 1) if (types[i] === 'Files') { e.preventDefault(); return; }
};
window.addEventListener('dragover', blockFileDrop);
window.addEventListener('drop', blockFileDrop);
}
/* ═══════════════ bridge 事件 ═══════════════ */
@@ -2423,6 +2566,8 @@ const CP_KEY = 'ga_custom_presets';
const HB_KEY = 'ga_hidden_builtins';
const BUILTIN_PRESETS = [
{ key: 'butler', titleKey: 'preset.butler.t', descKey: 'preset.butler.d', navigate: 'collab',
iconSvg: '<svg class="fc-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
{ key: 'goal', titleKey: 'preset.goal.t', descKey: 'preset.goal.d', promptKey: 'presetPrompt.goal',
iconSvg: '<svg class="fc-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.4"/></svg>' },
{ key: 'explore', titleKey: 'preset.explore.t', descKey: 'preset.explore.d', promptKey: 'presetPrompt.explore',
@@ -3017,3 +3162,370 @@ else refreshEmptyState(null);
runLabel.textContent = t('status.connecting');
window.ga.startBridge && window.ga.startBridge();
})();
/* Conductor 页 — 直连 Conductor WS,不走 bridge session */
(function () {
'use strict';
const wsUrl = () => `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.hostname}:8900/ws`;
const FAIL_MAX = 5, RECON_BASE = 1200, RECON_MAX = 30000;
const $ = id => document.getElementById(id);
const t = k => (window.gaT && window.gaT(k)) || k;
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const md = s => { try { return typeof marked !== 'undefined' ? marked.parse(s || '') : esc(s); } catch { return esc(s); } };
const stripAttach = text => String(text || '').replace(/\[(Image|File)\s+#\d+\]\s*/g, '').trim();
const FC_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
const ST_ICONS = {
running: '<span class="collab-st-ic collab-st-ic--spin" aria-hidden="true"></span>',
reported: '<span class="collab-st-ic collab-st-ic--ok" aria-hidden="true">✓</span>',
paused: '<span class="collab-st-ic collab-st-ic--pause" aria-hidden="true">⏸</span>',
failed: '<span class="collab-st-ic collab-st-ic--warn" aria-hidden="true">!</span>',
terminated: '<span class="collab-st-ic collab-st-ic--off" aria-hidden="true">×</span>',
};
const ST_KEYS = { running: 'collab.stRunning', reported: 'collab.stReported', paused: 'collab.stPaused', failed: 'collab.stFailed', terminated: 'collab.stTerminated' };
const CHIP_KEYS = ['collab.chipProgress', 'collab.chipPause', 'collab.chipSummary'];
const S = {
everConnected: false, reconnecting: false, serviceAvailable: false,
messages: [], workers: [], runningCount: 0,
conductorTyping: false, failCount: 0,
historyReady: false, reconnectAt: 0, progressOpen: false,
};
let ws, connectTimer, reconnectTick, titleSeq = 0, wsGen = 0, localSeq = 0;
const titleSeen = new Map();
let draftEl = null;
const scrollMsgs = () => { const a = $('collab-msgs'); if (a) a.scrollTop = a.scrollHeight; };
const showDraft = () => S.conductorTyping && S.serviceAvailable && S.historyReady && S.messages.length > 0;
function clearDraft() {
if (draftEl) { draftEl.remove(); draftEl = null; }
}
function syncDraft() {
const list = $('collab-msg-list');
if (!list || list.hidden || !showDraft()) return clearDraft();
if (!draftEl) draftEl = document.createElement('div');
draftEl.className = 'msg system collab-msg-enter';
draftEl.setAttribute('aria-label', t('collab.typing'));
draftEl.innerHTML = '<div class="bubble sys"><span class="collab-wait-dots" aria-hidden="true"><i></i><i></i><i></i></span></div>';
list.appendChild(draftEl);
requestAnimationFrame(scrollMsgs);
}
function relTime(ts) {
if (!ts) return '';
const ms = typeof ts === 'number' ? (ts > 1e12 ? ts : ts * 1000) : Date.parse(ts);
if (!ms || Number.isNaN(ms)) return '';
const sec = Math.max(0, Math.floor((Date.now() - ms) / 1000));
if (sec < 10) return t('collab.timeJust');
if (sec < 60) return t('collab.timeSec').replace('{n}', sec);
const min = Math.floor(sec / 60);
if (min < 60) return t('collab.timeMin').replace('{n}', min);
const hr = Math.floor(min / 60);
return hr < 24 ? t('collab.timeHr').replace('{n}', hr) : t('collab.timeDay').replace('{n}', Math.floor(hr / 24));
}
function mapStatus(status, reply) {
const r = (reply || '').trim();
if (status === 'running') return 'running';
if (status === 'failed') return 'failed';
if (status === 'aborted') return 'terminated';
if (status === 'stopped') return r ? 'reported' : 'paused';
return 'paused';
}
function normalizeWorker(raw) {
if (!titleSeen.has(raw.id)) titleSeen.set(raw.id, ++titleSeq);
const ui = mapStatus(raw.status, raw.reply);
let title = String(raw.prompt ?? '').replace(/^[\s请帮我麻烦]+/u, '').trim();
if (!title) title = t('collab.taskFallback').replace('{n}', titleSeen.get(raw.id));
else {
title = (title.split(/[\n。!?.!?]/)[0] || '').trim();
if (title.length > 18) title = title.slice(0, 18) + '…';
}
const reply = String(raw.reply || '').replace(/\s+/g, ' ').trim();
let summary = reply ? (reply.length > 80 ? reply.slice(0, 80) + '…' : reply) : t(ui === 'running' ? 'collab.summaryRunning' : 'collab.summaryWait');
return { id: raw.id, title, status: ui, summary, fullReply: raw.reply || '', updatedAt: raw.updated_at };
}
function syncProgressToggle() {
const btn = $('collab-progress-toggle'), body = $('collab-body');
if (!btn || !body) return;
const split = body.classList.contains('collab-body--split');
btn.hidden = !split;
body.classList.toggle('collab-progress-open', split && S.progressOpen);
}
function setConnUi() {
const off = $('collab-offline'), recon = $('collab-reconnect'), inp = $('collab-input'), btn = $('collab-send');
const avail = S.serviceAvailable;
const trying = !avail && !S.everConnected && S.failCount < FAIL_MAX;
if (off) off.hidden = avail || S.reconnecting || trying;
if (recon) {
recon.hidden = !S.reconnecting;
recon.textContent = S.reconnecting && S.reconnectAt > Date.now()
? t('collab.reconnect') + ' ' + t('collab.reconnectIn').replace('{n}', Math.ceil((S.reconnectAt - Date.now()) / 1000))
: t('collab.reconnect');
}
if (inp) inp.disabled = !avail;
if (btn) btn.disabled = !avail;
syncDraft();
syncProgressToggle();
}
let cardMenu = null;
function hideCardMenu() { if (cardMenu) { cardMenu.remove(); cardMenu = null; } }
function showCardMenu(x, y, sid) {
hideCardMenu();
cardMenu = document.createElement('div');
cardMenu.className = 'ctx-menu';
cardMenu.style.left = x + 'px';
cardMenu.style.top = y + 'px';
cardMenu.innerHTML = '<div class="ctx-item danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>删除</div>';
cardMenu.querySelector('.ctx-item').onmousedown = (e) => {
e.stopPropagation();
fetch(`http://${location.hostname}:8900/subagent/${sid}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'kill' }) });
hideCardMenu();
};
document.body.appendChild(cardMenu);
setTimeout(() => document.addEventListener('mousedown', hideCardMenu, { once: true }), 0);
}
let drawerEl = null;
function closeWorkerDrawer() { if (drawerEl) { drawerEl.remove(); drawerEl = null; } }
function openWorkerDrawer(w) {
closeWorkerDrawer();
drawerEl = document.createElement('div');
drawerEl.className = 'collab-drawer-wrap';
drawerEl.innerHTML = `<div class="collab-drawer-backdrop"></div><aside class="collab-drawer"><div class="collab-drawer-head"><span class="collab-drawer-title">${esc(w.title)}</span><button class="modal-x collab-drawer-close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg></button></div><div class="collab-drawer-body">${md(w.fullReply || t('collab.summaryWait'))}</div></aside>`;
drawerEl.querySelector('.collab-drawer-backdrop').onclick = closeWorkerDrawer;
drawerEl.querySelector('.collab-drawer-close').onclick = closeWorkerDrawer;
document.body.appendChild(drawerEl);
}
function renderWorkers() {
const box = $('collab-workers'), empty = $('collab-progress-empty');
if (!box) return;
if (empty) empty.hidden = S.workers.length > 0;
box.innerHTML = S.workers.map(w => `
<article class="collab-card collab-card--${w.status}" data-sid="${esc(w.id)}">
<div class="collab-card-st">${ST_ICONS[w.status] || ''}<span class="collab-dot"></span>${esc(t(ST_KEYS[w.status] || 'collab.stPaused'))}${w.updatedAt ? `<span class="collab-card-time">${esc(relTime(w.updatedAt))}</span>` : ''}</div>
<div class="collab-card-title">${esc(w.title)}</div>
<div class="collab-card-sum">${esc(w.summary)}</div>
</article>`).join('');
box.querySelectorAll('.collab-card').forEach(el => {
el.addEventListener('contextmenu', e => {
e.preventDefault();
showCardMenu(e.clientX, e.clientY, el.dataset.sid);
});
el.addEventListener('click', () => {
const w = S.workers.find(w => w.id === el.dataset.sid);
if (w) openWorkerDrawer(w);
});
});
const running = S.workers.filter(w => w.status === 'running').length;
S.runningCount = running;
document.dispatchEvent(new CustomEvent('collab:running-count', { detail: { count: running } }));
const sticky = $('collab-sticky');
if (sticky) { sticky.hidden = !running; sticky.textContent = t('collab.sticky').replace('{n}', running); }
const stats = $('collab-progress-stats');
if (stats) {
const has = S.workers.length > 0;
stats.hidden = !has;
if (has) stats.textContent = t('collab.progressStats')
.replace('{running}', running)
.replace('{done}', S.workers.filter(w => w.status === 'reported').length)
.replace('{issue}', S.workers.filter(w => w.status === 'failed' || w.status === 'terminated').length);
}
}
function renderUserMsg(item) {
const sub = n => (window.gaFileSubLabel && window.gaFileSubLabel(n)) || n;
const imgs = (item.images || []).map(im => `<img src="${esc(im.dataUrl || '')}" alt="">`).join('');
const files = (item.files || []).map(f => {
const name = f.name || 'file';
return `<div class="file-chip" data-path="${esc(f.path || '')}" data-name="${esc(name)}"><span class="fc-icon">${FC_SVG}</span><span class="fc-meta"><span class="fc-name">${esc(name)}</span><span class="fc-sub">${esc(sub(name))}</span></span></div>`;
}).join('');
const clean = stripAttach(item.msg);
const text = clean ? `<div class="bubble">${esc(clean).replace(/\n/g, '<br>')}</div>` : '';
return `<div class="msg user collab-msg-enter"><div class="user-stack">${files ? `<div class="user-files">${files}</div>` : ''}${imgs ? `<div class="user-imgs">${imgs}</div>` : ''}${text}</div></div>`;
}
function renderMsg(item) {
if (item.role === 'user') return renderUserMsg(item);
if (item.role === 'conductor') return `<div class="msg assistant collab-msg-enter"><div class="bubble md">${md(item.msg)}</div></div>`;
return `<div class="msg system collab-msg-enter"><div class="bubble sys">${esc(item.msg)}</div></div>`;
}
function syncLayout() {
const body = $('collab-body');
if (!body) return;
body.classList.toggle('collab-body--split', S.historyReady && S.messages.length > 0);
syncProgressToggle();
}
function syncMessages() {
const area = $('collab-msgs'), welcome = $('collab-welcome'), list = $('collab-msg-list'), head = $('collab-chat-head');
if (!area || !list) return;
if (!S.historyReady) {
area.classList.remove('has-msgs');
if (welcome) welcome.hidden = true;
if (head) head.hidden = true;
list.hidden = true;
return syncLayout();
}
const has = S.messages.length > 0;
area.classList.toggle('has-msgs', has);
if (welcome) welcome.hidden = has;
if (head) head.hidden = !has;
list.hidden = !has;
list.innerHTML = S.messages.map(renderMsg).join('');
syncDraft();
scrollMsgs();
syncLayout();
}
function pushMsg(item) {
if (item.id && S.messages.some(m => m.id === item.id)) return;
if (item.role === 'user') {
const plain = stripAttach(item.msg);
for (let i = S.messages.length - 1; i >= 0; i--) {
const m = S.messages[i];
if (m._local && m.role === 'user' && (stripAttach(m.msg) === plain || m.msg === item.msg)) {
S.messages.splice(i, 1);
break;
}
}
}
S.messages.push(item);
if (item.role === 'conductor') S.conductorTyping = false;
syncMessages();
setConnUi();
}
function setWorkers(rawList) {
S.workers = (rawList || []).map(normalizeWorker);
renderWorkers();
syncLayout();
}
function onWsData(data, gen) {
if (gen !== wsGen) return;
if (data.type === 'hello') {
S.historyReady = true;
S.messages = (data.chat || []).map(raw => ({ id: raw.id, role: raw.role || 'system', msg: raw.msg || '', ts: raw.ts, read: raw.read }));
setWorkers(data.subagents || []);
syncMessages();
setConnUi();
} else if (data.type === 'subagents') setWorkers(data.items || []);
else if (data.type === 'chat') pushMsg({ id: data.item.id, role: data.item.role || 'system', msg: data.item.msg || '', ts: data.item.ts, read: data.item.read });
}
function resetWs() {
wsGen++;
if (!ws) return;
const old = ws;
ws = null;
old.onopen = old.onclose = old.onerror = old.onmessage = null;
try { old.close(); } catch {}
}
function scheduleReconnect() {
clearTimeout(connectTimer);
clearInterval(reconnectTick);
if (!S.everConnected && S.failCount >= FAIL_MAX) {
S.reconnecting = false;
return setConnUi();
}
const delay = Math.min(RECON_MAX, RECON_BASE * Math.pow(2, Math.max(0, S.failCount - 1)));
S.reconnectAt = Date.now() + delay;
S.reconnecting = S.everConnected;
setConnUi();
reconnectTick = setInterval(() => { if (!S.reconnecting) clearInterval(reconnectTick); else setConnUi(); }, 500);
connectTimer = setTimeout(connect, delay);
}
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
clearTimeout(connectTimer);
clearInterval(reconnectTick);
const gen = ++wsGen;
setConnUi();
let sock;
try { sock = new WebSocket(wsUrl()); } catch (e) {
if (gen !== wsGen) return;
S.failCount++;
return scheduleReconnect();
}
ws = sock;
sock.onopen = () => {
if (gen !== wsGen) return;
S.everConnected = true;
S.serviceAvailable = true;
S.reconnecting = false;
S.failCount = 0;
setConnUi();
};
sock.onclose = (ev) => {
if (gen !== wsGen) return;
S.serviceAvailable = false;
if (S.everConnected) S.reconnecting = true;
else S.failCount++;
setConnUi();
scheduleReconnect();
};
sock.onerror = () => {};
sock.onmessage = ev => {
if (gen !== wsGen) return;
try { onWsData(JSON.parse(ev.data), gen); } catch {}
};
}
function sendText(rawText) {
const text = (rawText || '').trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return false;
const expand = window.gaExpandFilePlaceholders || (s => s);
const collect = window.gaCollectUsedFiles || (() => []);
const clearUsed = window.gaClearUsedPendingFiles || (() => {});
const used = collect(text);
const images = [], files = [];
for (const f of used) (f.isImage ? images : files).push(f.isImage ? { path: f.path, dataUrl: f.dataUrl } : { path: f.path, name: f.name });
S.messages.push({ id: `_local_${++localSeq}`, _local: true, role: 'user', msg: text, ts: Date.now() / 1000, images, files });
S.conductorTyping = true;
syncMessages();
ws.send(JSON.stringify({ msg: expand(text) }));
clearUsed(text);
const inp = $('collab-input');
if (inp && inp.value.trim() === text) inp.value = '';
setConnUi();
return true;
}
function buildChips() {
const box = $('collab-chips');
if (box) box.innerHTML = CHIP_KEYS.map(k => `<button type="button" class="chip sm collab-chip" data-text-key="${k}">${esc(t(k))}</button>`).join('');
}
$('collab-send')?.addEventListener('click', () => sendText($('collab-input')?.value || ''));
$('collab-retry')?.addEventListener('click', () => { S.failCount = 0; S.reconnecting = false; resetWs(); connect(); });
$('collab-input')?.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendText(e.target.value); } });
$('collab-chips')?.addEventListener('click', e => { const k = e.target.closest('.collab-chip')?.dataset.textKey; if (k) sendText(t(k)); });
$('collab-progress-toggle')?.addEventListener('click', () => { S.progressOpen = !S.progressOpen; syncProgressToggle(); });
document.querySelector('[data-page="collab"]')?.addEventListener('click', e => {
if (!S.progressOpen || e.target.closest('.collab-progress') || e.target.closest('#collab-progress-toggle')) return;
S.progressOpen = false;
syncProgressToggle();
});
window.collabInit = () => {
window.gaSetActiveFileComposer?.('collab');
buildChips();
syncMessages();
setConnUi();
renderWorkers();
connect();
};
window.collabFocus = () => $('collab-input')?.focus();
window.collabRetranslate = () => { buildChips(); renderWorkers(); syncMessages(); setConnUi(); };
})();
+68 -11
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<title>GenericAgent</title>
<link rel="stylesheet" href="styles.css?v=20">
<link rel="stylesheet" href="styles.css?v=72">
<!-- KaTeX: LaTeX 公式渲染 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
<!-- highlight.js: 代码高亮 -->
@@ -73,6 +73,7 @@
<a class="nav-item" data-page="collab">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="nav.collab"></span>
<span id="collab-badge" class="collab-nav-badge" hidden></span>
</a>
<a class="nav-item" data-page="token">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v10M9.5 9.6c0-1 1.1-1.7 2.5-1.7s2.5.7 2.5 1.7-1.1 1.5-2.5 1.5-2.5.7-2.5 1.7 1.1 1.8 2.5 1.8 2.5-.7 2.5-1.7"/></svg>
@@ -172,16 +173,72 @@
</div>
</section>
<!-- 协作动态 -->
<!-- Conductor -->
<section class="page" data-page="collab">
<div class="page-pad">
<div class="page-title" data-i18n="page.collab.title"></div>
<div class="page-sub" data-i18n="page.collab.sub"></div>
<div class="card-row">
<div class="card-mini"><b>hive-master</b><div class="cm-st on" data-i18n="cm.scheduling"></div><div class="cm-d" data-i18n="cm.master"></div></div>
<div class="card-mini"><b>hive-worker-1</b><div class="cm-st on" data-i18n="cm.running"></div><div class="cm-d" data-i18n="cm.w1"></div></div>
<div class="card-mini"><b>hive-worker-2</b><div class="cm-st on" data-i18n="cm.running"></div><div class="cm-d" data-i18n="cm.w2"></div></div>
<div class="card-mini"><b>subagent-abc</b><div class="cm-st idle" data-i18n="cm.idleSt"></div><div class="cm-d" data-i18n="cm.sub"></div></div>
<div class="collab-offline" id="collab-offline" hidden>
<span data-i18n="collab.offline"></span>
<button type="button" class="chip sm" id="collab-retry" data-i18n="collab.retry"></button>
</div>
<div class="collab-reconnect" id="collab-reconnect" hidden data-i18n="collab.reconnect"></div>
<div class="collab-sticky" id="collab-sticky" hidden></div>
<div class="collab-body" id="collab-body">
<aside class="collab-progress">
<div class="collab-progress-stats" id="collab-progress-stats" hidden></div>
<div class="collab-progress-head" data-i18n="collab.progressTitle"></div>
<div class="collab-progress-empty" id="collab-progress-empty" data-i18n="collab.progressEmpty"></div>
<div id="collab-workers"></div>
</aside>
<div class="collab-chat">
<div class="collab-chat-head" id="collab-chat-head" hidden>
<span data-i18n="collab.chatTitle"></span>
<button type="button" class="ic-btn collab-progress-toggle" id="collab-progress-toggle" hidden data-i18n-title="collab.showProgressTitle" title="">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/></svg>
</button>
</div>
<div class="msg-area collab-msgs" id="collab-msgs">
<div class="collab-guide" id="collab-welcome" hidden>
<div class="feature-head">
<div class="page-title" data-i18n="collab.guideTitle"></div>
<p class="page-sub" data-i18n="collab.guideWhen"></p>
</div>
<div class="collab-guide-steps">
<div class="collab-guide-step">
<span class="collab-guide-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep1t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep1d"></span></span>
</div>
<div class="collab-guide-step">
<span class="collab-guide-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg></span>
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep2t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep2d"></span></span>
</div>
<div class="collab-guide-step">
<span class="collab-guide-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15l2 2 4-4"/></svg></span>
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep3t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep3d"></span></span>
</div>
<div class="collab-guide-step">
<span class="collab-guide-ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
<span class="collab-guide-txt"><span class="collab-guide-label" data-i18n="collab.guideStep4t"></span><span class="collab-guide-desc" data-i18n="collab.guideStep4d"></span></span>
</div>
</div>
</div>
<div class="msgs" id="collab-msg-list" hidden></div>
</div>
<div class="composer collab-composer">
<div class="composer-top">
<button class="ic-btn" id="collab-upload-btn" type="button" data-i18n-title="upload.button" title="">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12 20.5a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<div class="collab-chips" id="collab-chips"></div>
</div>
<textarea class="input" id="collab-input" data-i18n-ph="collab.placeholder" rows="1"></textarea>
<input type="file" id="collab-img-input" multiple hidden>
<div class="thumb-strip" id="collab-thumb-strip" hidden></div>
<div class="composer-bot">
<div class="cb-left"></div>
<button class="send" id="collab-send" type="button" aria-label="send">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
</div>
</div>
</section>
@@ -451,7 +508,7 @@
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<!-- highlight.js -->
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js" crossorigin="anonymous"></script>
<script src="app.js?v=28"></script>
<script src="app.js?v=43"></script>
<script src="annot.js"></script><!-- DEV 脚手架,可删 -->
</body>
</html>
+183
View File
@@ -1349,3 +1349,186 @@ html[data-appearance="dark"] .bubble.md pre code{ color:var(--txt-2); }
padding:0 6px; margin-bottom:2px;
border-radius:var(--radius-sm);
}
/* ---- Conductor ---- */
.page[data-page="collab"]{
padding:0; min-height:0; overflow:hidden;
background:
radial-gradient(ellipse 120% 80% at 50% -20%, color-mix(in srgb, var(--blue) 8%, transparent), transparent 55%),
var(--panel);
}
.collab-offline,.collab-reconnect{
flex:0 0 auto; padding:10px 16px; font-size:13px; text-align:center;
background:color-mix(in srgb, var(--amber) 12%, var(--panel));
border-bottom:1px solid var(--line); align-items:center; justify-content:center; gap:10px;
}
.collab-offline[hidden], .collab-reconnect[hidden], .collab-sticky[hidden], #collab-welcome[hidden], #collab-chat-head[hidden], .collab-msgs .msgs[hidden], .collab-progress-stats[hidden]{ display:none !important; }
.collab-offline:not([hidden]), .collab-reconnect:not([hidden]){ display:flex; }
.collab-reconnect{ background:color-mix(in srgb, var(--blue) 10%, var(--panel)); }
.collab-sticky{
flex:0 0 auto; padding:8px 16px; font-size:12px; color:var(--muted);
border-bottom:1px solid var(--line-soft); background:var(--field-bg);
}
.collab-sticky:not([hidden]){ display:block; }
.collab-body{
flex:1 1 0; min-height:0; display:grid; grid-template-columns:1fr; gap:0; overflow:hidden;
}
.collab-body.collab-body--split{ grid-template-columns:minmax(240px,36%) 1fr; }
.collab-body:not(.collab-body--split) .collab-progress{ display:none; }
.collab-progress,.collab-chat{ min-height:0; min-width:0; display:flex; flex-direction:column; overflow:hidden; }
.collab-chat{ flex:1; }
.collab-progress{
border-right:1px solid var(--line-soft);
background:linear-gradient(180deg, var(--field-bg), var(--panel));
padding:16px; overflow:auto;
}
.collab-progress-stats{
font-size:11px; font-weight:600; color:var(--muted); letter-spacing:.02em;
padding:8px 10px; margin-bottom:10px; border-radius:var(--radius-sm);
background:color-mix(in srgb, var(--panel) 70%, var(--field-bg));
border:1px solid var(--line-soft);
}
.collab-progress-head,.collab-chat-head{
font-size:13px; font-weight:700; color:var(--txt-strong); margin-bottom:12px; flex:0 0 auto;
}
.collab-chat-head{ display:flex; align-items:center; justify-content:space-between; gap:10px; padding:16px 20px 0; }
.collab-progress-toggle{ display:none !important; flex:0 0 auto; }
.collab-progress-empty{ font-size:12px; color:var(--muted); line-height:1.5; margin-bottom:8px; }
.collab-card{
position:relative;
border:1px solid var(--line); border-radius:var(--radius-sm); background:var(--card);
padding:12px 12px 12px 14px; margin-bottom:10px; border-left:3px solid var(--collab-st, var(--line));
box-shadow:0 2px 8px rgba(0,0,0,.04);
transition:border-color .3s, box-shadow .3s, transform .2s;
}
.collab-card:hover{ transform:translateY(-1px); box-shadow:0 4px 14px rgba(0,0,0,.07); }
.collab-card--running{ --collab-st:var(--ok); }
.collab-card--reported{ --collab-st:var(--muted); }
.collab-card--paused{ --collab-st:var(--amber); }
.collab-card--failed{ --collab-st:var(--danger); }
.collab-card--terminated{ --collab-st:var(--line-strong); }
.collab-card-st{ display:flex; align-items:center; gap:6px; font-size:11px; color:var(--muted); margin-bottom:6px; flex-wrap:wrap; }
.collab-dot{ width:7px; height:7px; border-radius:50%; background:var(--collab-st); flex:0 0 auto; }
.collab-card--running .collab-dot{ animation:collab-pulse 1.6s ease-in-out infinite; }
@keyframes collab-pulse{
0%,100%{ opacity:1; transform:scale(1); box-shadow:0 0 0 0 color-mix(in srgb, var(--ok) 40%, transparent); }
50%{ opacity:.85; transform:scale(1.15); box-shadow:0 0 0 4px color-mix(in srgb, var(--ok) 0%, transparent); }
}
.collab-st-ic{ display:inline-flex; align-items:center; justify-content:center; width:14px; height:14px; font-size:10px; font-weight:700; flex:0 0 auto; }
.collab-st-ic--spin{
width:12px; height:12px; border:2px solid color-mix(in srgb, var(--ok) 25%, transparent);
border-top-color:var(--ok); border-radius:50%; animation:collab-spin .8s linear infinite;
}
@keyframes collab-spin{ to{ transform:rotate(360deg); } }
.collab-st-ic--ok{ color:var(--ok); }
.collab-st-ic--warn{ color:var(--danger); }
.collab-st-ic--pause,.collab-st-ic--off{ color:var(--muted); }
.collab-card-time{ margin-left:auto; font-size:10px; color:var(--muted); opacity:.85; }
.collab-card-title{ font-size:13px; font-weight:600; color:var(--txt-strong); margin-bottom:6px; }
.collab-card-sum{ font-size:12px; color:var(--muted); line-height:1.45; cursor:pointer; }
.collab-drawer-wrap{ position:fixed; inset:0; z-index:550; display:flex; }
.collab-drawer-backdrop{ position:absolute; inset:0; background:var(--overlay); }
.collab-drawer{
position:absolute; right:0; top:0; bottom:0; width:min(420px,85vw);
background:var(--panel); border-left:1px solid var(--line);
box-shadow:var(--shadow-modal); display:flex; flex-direction:column;
animation:collabDrawerIn .2s ease-out;
}
@keyframes collabDrawerIn{ from{ transform:translateX(100%); } to{ transform:translateX(0); } }
.collab-drawer-head{
display:flex; align-items:center; justify-content:space-between;
padding:14px 16px; border-bottom:1px solid var(--line-soft);
}
.collab-drawer-title{ font-size:14px; font-weight:700; color:var(--txt-strong); }
.collab-drawer-body{
flex:1; overflow-y:auto; padding:16px; font-size:13px; line-height:1.6; color:var(--txt);
}
.collab-drawer-body p,.collab-drawer-body ul,.collab-drawer-body ol,.collab-drawer-body pre{ margin:0 0 .5em; }
.collab-drawer-body code{ font-size:12px; background:var(--field-bg); padding:1px 4px; border-radius:4px; }
.collab-drawer-body pre code{ display:block; padding:8px; border-radius:6px; white-space:pre-wrap; }
.collab-chat{ padding:0; background:var(--panel); position:relative; }
.collab-chat.dragover{ position:relative; }
.collab-chat.dragover::after{
content:attr(data-drop-hint); position:absolute; inset:8px; z-index:20; pointer-events:none;
display:flex; align-items:center; justify-content:center; border-radius:var(--radius);
border:2px dashed var(--blue); background:color-mix(in srgb, var(--blue) 8%, var(--panel));
font-size:13px; color:var(--blue); font-weight:600;
}
.collab-composer{ flex:0 0 auto; background:var(--panel); }
.collab-composer .composer-top{ align-items:center; flex-wrap:wrap; }
.collab-chips{ display:flex; flex-wrap:wrap; gap:6px; flex:1; min-width:0; padding:0; }
.collab-msgs{ flex:1 1 0; min-height:0; }
.collab-msgs:not(.has-msgs){ position:relative; }
.collab-msg-enter{ animation:collab-msg-in .35s ease-out both; }
.bubble.sys .collab-wait-dots{
display:inline-flex; align-items:center; justify-content:center; gap:5px;
vertical-align:middle; padding:2px 0;
}
.collab-wait-dots i{
display:block; width:5px; height:5px; border-radius:50%; background:var(--muted);
animation:collab-wait-dot 1.2s ease-in-out infinite; font-style:normal;
}
.collab-wait-dots i:nth-child(1){ animation-delay:0s; }
.collab-wait-dots i:nth-child(2){ animation-delay:.2s; }
.collab-wait-dots i:nth-child(3){ animation-delay:.4s; }
@keyframes collab-wait-dot{
0%,80%,100%{ opacity:.35; transform:translateY(0); }
40%{ opacity:1; transform:translateY(-3px); }
}
@keyframes collab-msg-in{
from{ opacity:0; transform:translateY(8px); }
to{ opacity:1; transform:translateY(0); }
}
.collab-guide{ width:100%; max-width:var(--chat-max-w,920px); margin:0 auto; padding:96px 0 0; }
.collab-guide .feature-head{
position:absolute; top:32px;
left:max(28px, calc(50% - var(--chat-max-w, 920px) / 2 + 28px));
right:28px; max-width:var(--chat-max-w,920px);
margin:0; text-align:left;
}
.collab-guide .page-title{ font-size:20px; line-height:1.35; margin-bottom:2px; }
.collab-guide .page-sub{ font-size:13px; line-height:1.6; margin-top:10px; margin-bottom:0; }
.collab-guide-steps{
display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:48px 56px;
margin:0 0 8px; align-items:start;
}
.collab-guide-step{
display:flex; flex-direction:column; align-items:center; gap:22px;
text-align:center; min-height:100%;
}
.collab-guide-ic{
flex:0 0 auto; width:52px; height:52px; border-radius:14px;
display:flex; align-items:center; justify-content:center;
background:color-mix(in srgb, var(--blue) 10%, var(--panel));
color:var(--blue);
border:1px solid color-mix(in srgb, var(--blue) 16%, var(--line));
}
.collab-guide-ic svg{ width:24px; height:24px; }
.collab-guide-txt{ display:flex; flex-direction:column; gap:12px; width:100%; }
.collab-guide-label{ font-size:14px; font-weight:600; color:var(--txt-strong); line-height:1.45; letter-spacing:.01em; }
.collab-guide-desc{ font-size:12px; font-weight:400; font-style:italic; color:var(--muted); line-height:1.7; }
.collab-nav-badge{
margin-left:auto; min-width:18px; height:18px; padding:0 5px; border-radius:999px;
background:var(--ok); color:#fff; font-size:10px; font-weight:700; line-height:18px; text-align:center;
}
@media (max-width:900px){
.collab-body.collab-body--split{ grid-template-columns:1fr; }
.collab-body.collab-body--split .collab-progress{
display:none; position:fixed; z-index:40; top:0; left:0; bottom:0; width:min(88vw,320px);
max-width:320px; box-shadow:8px 0 24px rgba(0,0,0,.12);
animation:collab-drawer-in .25s ease-out;
}
.collab-body.collab-body--split.collab-progress-open .collab-progress{ display:flex; }
.collab-body.collab-body--split.collab-progress-open::before{
content:''; position:fixed; inset:0; z-index:35;
background:rgba(0,0,0,.25); animation:collab-fade-in .2s ease-out;
}
.collab-body.collab-body--split .collab-progress-toggle:not([hidden]){ display:inline-flex !important; }
@keyframes collab-drawer-in{ from{ transform:translateX(-12px); opacity:0; } to{ transform:translateX(0); opacity:1; } }
@keyframes collab-fade-in{ from{ opacity:0; } to{ opacity:1; } }
.collab-guide{ padding-top:88px; }
.collab-guide .feature-head{ top:24px; left:24px; right:24px; }
.collab-guide-steps{ grid-template-columns:repeat(2, minmax(0, 1fr)); gap:40px 32px; }
.collab-guide-step{ gap:20px; }
.collab-msgs:not(.has-msgs){ padding:20px 20px 12px; }
}