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
+882 -128

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ builtins.print = _filtered_print
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
# allow: python frontends/conductor.py # 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") HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conductor.html")
app = FastAPI(title="Conductor") app = FastAPI(title="Conductor")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
class ChatIn(BaseModel): class ChatIn(BaseModel):
msg: str msg: str
+629 -117
View File
@@ -221,9 +221,10 @@ const I18N = {
'app.title': 'GenericAgent 桌面版', 'app.title': 'GenericAgent 桌面版',
'brand.sub': '桌面终端', 'brand.sub': '桌面终端',
'nav.chat': '聊天', 'nav.channels': '消息通道', 'nav.status': '状态面板', 'nav.chat': '聊天', 'nav.channels': '消息通道', 'nav.status': '状态面板',
'nav.collab': '协作动态', 'nav.token': 'Token 统计', 'nav.collab': 'Agent 指挥', 'nav.token': 'Token 统计',
'foot.settings': '配置', 'foot.ver': 'GenericAgent · 桌面版', 'foot.settings': '配置', 'foot.ver': 'GenericAgent · 桌面版',
'chat.startTitle': '开始对话', 'chat.startSub': '直接输入,或点预设功能一键启动', 'chat.startTitle': '开始对话', 'chat.startSub': '直接输入,或点预设功能一键启动',
'preset.butler.t': 'Conductor', 'preset.butler.d': '复杂任务自动拆活,进度在 Conductor 查看',
'preset.goal.t': 'Goal 模式', 'preset.goal.d': '设定目标,自主完成', 'preset.goal.t': 'Goal 模式', 'preset.goal.d': '设定目标,自主完成',
'preset.explore.t': '自主探索', 'preset.explore.d': '自动浏览并周期汇总', 'preset.explore.t': '自主探索', 'preset.explore.d': '自动浏览并周期汇总',
'preset.hive.t': 'Hive 协作', 'preset.hive.d': '多 worker 协同攻坚', 'preset.hive.t': 'Hive 协作', 'preset.hive.d': '多 worker 协同攻坚',
@@ -258,7 +259,41 @@ const I18N = {
'confirm.modelDelete': '确定删除该模型配置?', 'confirm.modelDelete': '确定删除该模型配置?',
'page.channels.title': '消息通道', 'page.channels.sub': '后台 IM 进程:列表、启停与日志(同 hub.pyw)', 'page.channels.title': '消息通道', 'page.channels.sub': '后台 IM 进程:列表、启停与日志(同 hub.pyw)',
'page.status.title': '状态面板', 'page.status.sub': '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 用量及估算成本', 'page.token.title': 'Token 统计', 'page.token.sub': '每会话与累计的 token 用量及估算成本',
'status.connecting': '连接中…', 'status.ready': '就绪', 'status.running': '运行中', 'status.connecting': '连接中…', 'status.ready': '就绪', 'status.running': '运行中',
'status.disconnected': '未连接', 'status.stopped': '已停止', 'status.idle': '空闲', 'status.disconnected': '未连接', 'status.stopped': '已停止', 'status.idle': '空闲',
@@ -327,9 +362,10 @@ const I18N = {
'app.title': 'GenericAgent Desktop', 'app.title': 'GenericAgent Desktop',
'brand.sub': 'Desktop terminal', 'brand.sub': 'Desktop terminal',
'nav.chat': 'Chat', 'nav.channels': 'Channels', 'nav.status': 'Status', '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', 'foot.settings': 'Settings', 'foot.ver': 'GenericAgent · Desktop',
'chat.startTitle': 'Start a conversation', 'chat.startSub': 'Type a message, or pick a preset', '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.goal.t': 'Goal mode', 'preset.goal.d': 'Set a goal, run autonomously',
'preset.explore.t': 'Auto explore', 'preset.explore.d': 'Browse & summarize periodically', 'preset.explore.t': 'Auto explore', 'preset.explore.d': 'Browse & summarize periodically',
'preset.hive.t': 'Hive', 'preset.hive.d': 'Multi-worker collaboration', 'preset.hive.t': 'Hive', 'preset.hive.d': 'Multi-worker collaboration',
@@ -364,7 +400,41 @@ const I18N = {
'confirm.modelDelete': 'Delete this model profile?', 'confirm.modelDelete': 'Delete this model profile?',
'page.channels.title': 'Channels', 'page.channels.sub': 'Background IM processes: list, start/stop, logs (hub.pyw style)', '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.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', '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.connecting': 'Connecting…', 'status.ready': 'Ready', 'status.running': 'Running',
'status.disconnected': 'Disconnected', 'status.stopped': 'Stopped', 'status.idle': 'Idle', 'status.disconnected': 'Disconnected', 'status.stopped': 'Stopped', 'status.idle': 'Idle',
@@ -499,6 +569,14 @@ async function bridgeFetch(path, opts = {}) {
return data; return data;
} }
function t(key) { return (I18N[lang] && I18N[lang][key]) || (I18N.zh[key]) || key; } 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) { function optionalPh(key) {
const sep = (lang === 'en') ? ', ' : ','; const sep = (lang === 'en') ? ', ' : ',';
return `${t('common.optional')}${sep}${t(key)}`; 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)); }); document.querySelectorAll('[data-i18n-title]').forEach(el => { el.setAttribute('title', t(el.dataset.i18nTitle)); });
renderLangList(); renderLangList();
window.collabRetranslate?.();
} }
// 语言对应国旗 SVG(en 用美国旗,按要求) // 语言对应国旗 SVG(en 用美国旗,按要求)
const FLAGS = { const FLAGS = {
@@ -665,12 +744,22 @@ function applyAppearance(nextApp, nextPlain, { persist } = { persist: true }) {
/* ═══════════════ 侧边栏导航 ═══════════════ */ /* ═══════════════ 侧边栏导航 ═══════════════ */
const nav = document.getElementById('nav'); const nav = document.getElementById('nav');
const pages = document.querySelectorAll('#pages .page'); 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) => { nav.addEventListener('click', (e) => {
const item = e.target.closest('.nav-item'); const item = e.target.closest('.nav-item');
if (!item) return; if (!item) return;
const key = item.dataset.page; gaGoPage(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));
}); });
/* ═══════════════ 弹窗开关 ═══════════════ */ /* ═══════════════ 弹窗开关 ═══════════════ */
@@ -1313,7 +1402,7 @@ function renderSessionList() {
const r = state.runtime.get(sess.id); const r = state.runtime.get(sess.id);
const busy = !!(r && r.busy); const busy = !!(r && r.busy);
const item = document.createElement('div'); 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; 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>` : ''; 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 = item.innerHTML =
@@ -1485,8 +1574,9 @@ async function pollSession(sess) {
function removeUsedPendingFiles(usedFiles) { function removeUsedPendingFiles(usedFiles) {
if (!usedFiles.length) return; if (!usedFiles.length) return;
const usedSids = new Set(usedFiles.map(f => f.sid)); 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)); state.pendingFiles = state.pendingFiles.filter(f => !usedSids.has(f.sid));
renderThumbStrip(); touched.forEach(ctx => renderThumbStrip(ctx));
} }
function clearDraft(sess) { function clearDraft(sess) {
@@ -1702,6 +1792,8 @@ document.querySelectorAll('.feature-grid').forEach(grid => {
return; return;
} }
if (!key) { inputEl.focus(); closeModals(); 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]; const prompt = I18N[lang]['presetPrompt.' + key] || I18N.zh['presetPrompt.' + key];
closeModals(); closeModals();
if (prompt) sendPrompt(prompt); if (prompt) sendPrompt(prompt);
@@ -2010,6 +2102,72 @@ const imgInput = document.getElementById('img-input');
const thumbStrip = document.getElementById('thumb-strip'); const thumbStrip = document.getElementById('thumb-strip');
const uploadBtn = document.getElementById('upload-btn'); const uploadBtn = document.getElementById('upload-btn');
const chatPanel = document.querySelector('main.main'); const chatPanel = document.querySelector('main.main');
let activeFileComposer = 'chat';
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 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;
}
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>`;
}
const name = f.name || 'file';
const label = name.replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const sub = fileSubLabel(name).replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const path = (f.path || '').replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
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('');
cfg.strip.hidden = false;
applyI18n();
}
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 + ' ';
input.value = before + insertion + after;
const caret = (before + insertion).length;
input.setSelectionRange(caret, caret);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
}
function removePlaceholderFromComposer(file) {
const input = composerCfg(fileCtx(file)).input;
if (!input) return;
const marker = placeholderFor(file);
const re = new RegExp('\\s?' + marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s?', '');
input.value = input.value.replace(re, ' ').replace(/ +/g, ' ').trim();
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function isImageFile(f) { function isImageFile(f) {
return (f && (f.type || '').startsWith('image/')) || IMG_EXT_RE.test(f?.name || ''); return (f && (f.type || '').startsWith('image/')) || IMG_EXT_RE.test(f?.name || '');
@@ -2019,51 +2177,6 @@ function placeholderFor(file) {
return file.isImage ? `[Image #${file.sid}]` : `[File #${file.sid}]`; return file.isImage ? `[Image #${file.sid}]` : `[File #${file.sid}]`;
} }
function renderThumbStrip() {
if (!thumbStrip) return;
if (state.pendingFiles.length === 0) {
thumbStrip.innerHTML = '';
thumbStrip.hidden = true;
return;
}
thumbStrip.innerHTML = state.pendingFiles.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>`;
}
const name = f.name || 'file';
const label = name.replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
const sub = fileSubLabel(name).replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
const path = (f.path || '').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
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;
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);
const needSpace = before && !/\s$/.test(before);
const insertion = (needSpace ? ' ' : '') + marker + ' ';
inputEl.value = before + insertion + after;
const caret = (before + insertion).length;
inputEl.setSelectionRange(caret, caret);
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
inputEl.focus();
}
function removePlaceholderFromComposer(file) {
if (!inputEl) 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 }));
}
function expandFilePlaceholders(text) { function expandFilePlaceholders(text) {
return text.replace(/\[(Image|File) #(\d+)\]/g, (m, kind, n) => { return text.replace(/\[(Image|File) #(\d+)\]/g, (m, kind, n) => {
const f = state.pendingFiles.find(x => x.sid === Number(n)); const f = state.pendingFiles.find(x => x.sid === Number(n));
@@ -2109,11 +2222,16 @@ async function addFiles(fileList) {
if (skipped) showChanToast(t('upload.tooLarge'), '', 'err'); if (skipped) showChanToast(t('upload.tooLarge'), '', 'err');
return; return;
} }
// group uploads under the active session (create one if there's none yet) const ctx = activeFileComposer;
let upSess = activeSess(); let uploadSid = '';
if (!upSess) { await newSession(); upSess = activeSess(); } if (ctx === 'collab') {
if (upSess && !upSess.bridgeSessionId) { try { await ensureBridgeSession(upSess); } catch (_) {} } uploadSid = 'collab';
const uploadSid = (upSess && upSess.bridgeSessionId) || ''; } else {
let upSess = activeSess();
if (!upSess) { await newSession(); upSess = activeSess(); }
if (upSess && !upSess.bridgeSessionId) { try { await ensureBridgeSession(upSess); } catch (_) {} }
uploadSid = (upSess && upSess.bridgeSessionId) || '';
}
for (const f of accepted) { for (const f of accepted) {
try { try {
const dataUrl = await new Promise((resolve, reject) => { const dataUrl = await new Promise((resolve, reject) => {
@@ -2129,10 +2247,11 @@ async function addFiles(fileList) {
const entry = { const entry = {
sid, name: f.name || 'file', isImage, path, sid, name: f.name || 'file', isImage, path,
dataUrl: isImage ? dataUrl : '', dataUrl: isImage ? dataUrl : '',
ctx,
}; };
state.pendingFiles.push(entry); state.pendingFiles.push(entry);
insertPlaceholderInComposer(placeholderFor(entry)); insertPlaceholderInComposer(placeholderFor(entry), ctx);
renderThumbStrip(); renderThumbStrip(ctx);
} catch (e) { } catch (e) {
showChanToast(t('upload.failed'), e.message || String(e), 'err'); 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 (skipped) showChanToast(t('upload.tooLarge'), '', 'err');
} }
if (uploadBtn && imgInput) uploadBtn.addEventListener('click', (e) => { function handleThumbStripClick(e, ctx) {
e.preventDefault();
imgInput.click();
});
if (imgInput) imgInput.addEventListener('change', () => {
addFiles(imgInput.files);
imgInput.value = '';
});
if (thumbStrip) thumbStrip.addEventListener('click', (e) => {
const x = e.target.closest('.x'); const x = e.target.closest('.x');
if (x) { if (x) {
const sid = Number(x.dataset.sid); 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) { if (idx >= 0) {
const removed = state.pendingFiles[idx]; const removed = state.pendingFiles[idx];
state.pendingFiles.splice(idx, 1); state.pendingFiles.splice(idx, 1);
removePlaceholderFromComposer(removed); removePlaceholderFromComposer(removed);
renderThumbStrip(); renderThumbStrip(ctx);
if (removed.path) { if (removed.path) {
fetch(`http://${location.hostname}:14168/upload`, { fetch(`http://${location.hostname}:14168/upload`, {
method: 'DELETE', method: 'DELETE',
@@ -2179,51 +2288,25 @@ if (thumbStrip) thumbStrip.addEventListener('click', (e) => {
} }
const img = e.target.closest('img'); const img = e.target.closest('img');
if (img && img.src) openLightbox(img.src); 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 ─── */ function bindComposerUpload(ctx) {
if (inputEl) { const cfg = composerCfg(ctx);
inputEl.addEventListener('paste', (e) => { 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; const items = e.clipboardData && e.clipboardData.items;
if (!items) return; if (!items) return;
const files = []; const files = [];
@@ -2237,6 +2320,66 @@ if (inputEl) {
e.preventDefault(); e.preventDefault();
addFiles(files); 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 事件 ═══════════════ */ /* ═══════════════ bridge 事件 ═══════════════ */
@@ -2423,6 +2566,8 @@ const CP_KEY = 'ga_custom_presets';
const HB_KEY = 'ga_hidden_builtins'; const HB_KEY = 'ga_hidden_builtins';
const BUILTIN_PRESETS = [ 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', { 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>' }, 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', { 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'); runLabel.textContent = t('status.connecting');
window.ga.startBridge && window.ga.startBridge(); 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> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>GenericAgent</title> <title>GenericAgent</title>
<link rel="stylesheet" href="styles.css?v=20"> <link rel="stylesheet" href="styles.css?v=72">
<!-- KaTeX: LaTeX 公式渲染 --> <!-- KaTeX: LaTeX 公式渲染 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
<!-- highlight.js: 代码高亮 --> <!-- highlight.js: 代码高亮 -->
@@ -73,6 +73,7 @@
<a class="nav-item" data-page="collab"> <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> <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 data-i18n="nav.collab"></span>
<span id="collab-badge" class="collab-nav-badge" hidden></span>
</a> </a>
<a class="nav-item" data-page="token"> <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> <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> </div>
</section> </section>
<!-- 协作动态 --> <!-- Conductor -->
<section class="page" data-page="collab"> <section class="page" data-page="collab">
<div class="page-pad"> <div class="collab-offline" id="collab-offline" hidden>
<div class="page-title" data-i18n="page.collab.title"></div> <span data-i18n="collab.offline"></span>
<div class="page-sub" data-i18n="page.collab.sub"></div> <button type="button" class="chip sm" id="collab-retry" data-i18n="collab.retry"></button>
<div class="card-row"> </div>
<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="collab-reconnect" id="collab-reconnect" hidden data-i18n="collab.reconnect"></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="collab-sticky" id="collab-sticky" hidden></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="collab-body" id="collab-body">
<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> <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>
</div> </div>
</section> </section>
@@ -451,7 +508,7 @@
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<!-- highlight.js --> <!-- highlight.js -->
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js" crossorigin="anonymous"></script> <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 脚手架,可删 --> <script src="annot.js"></script><!-- DEV 脚手架,可删 -->
</body> </body>
</html> </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; padding:0 6px; margin-bottom:2px;
border-radius:var(--radius-sm); 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; }
}