8d3192e7cb
- createTask 消费后端去重后的权威 attachments 列表,按入库数报数并提示去重数
- 附件删除 toast 语义由 warn 调整为 ok(attDeleted 为成功语义)
- TaskDetail 缩略图 isImg 收敛到 KIT_ATT_SAFE_INLINE 硬白名单(png/jpeg/gif/webp),
与后端 src/api/server.ts SAFE_INLINE 一字不差,svg/html 不走 <img src=服务端URL> inline,
钉死同源存储型 XSS
- i18n 新增 attUploaded/attDeduped 两个带 {n} 占位的 key,覆盖 zh/en/es/ja/fr
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
465 lines
28 KiB
React
465 lines
28 KiB
React
// 真实数据版 App:从本地 daemon 的 REST 拉取、订阅 WS 增量刷新,
|
||
// 通过 src/adapt.js 映射成各 surface 期望的形状(替代 window.MAESTRO_MOCK)。
|
||
import { createRoot } from 'react-dom/client';
|
||
import { api, subscribeEvents } from './api.js';
|
||
import {
|
||
adaptProject, buildTaskTree, adaptApproval, adaptEvent, adaptQuota,
|
||
deriveGlobal, deriveAgentSummary, adaptActiveAgents,
|
||
} from './adapt.js';
|
||
|
||
const { Toast } = window.MaestroDesignSystem_a6a290;
|
||
|
||
const USER = { name: 'local', handle: '@local', plan: 'Claude Code', initial: 'L', hue: 200 };
|
||
|
||
// 新建项目模态(design 无现成 surface,用 DS 组件就地拼装)。标签 zh/en 自含。
|
||
function NewProjectModal({ t, lang, onCreate, onClose, defaultAutonomy }) {
|
||
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
|
||
const L = lang === 'zh'
|
||
? { title: '新建项目', name: '项目名', repo: '仓库路径', branch: '默认分支', mode: '工作模式', namePh: 'my-app', repoPh: '/Users/you/code/my-app', create: '创建' }
|
||
: { title: 'New project', name: 'Name', repo: 'Repo path', branch: 'Default branch', mode: 'Mode', namePh: 'my-app', repoPh: '/Users/you/code/my-app', create: 'Create' };
|
||
const [name, setName] = React.useState('');
|
||
const [repoPath, setRepoPath] = React.useState('');
|
||
const [defaultBranch, setDefaultBranch] = React.useState('main');
|
||
const [autonomy, setAutonomy] = React.useState(defaultAutonomy || 'manual');
|
||
const submit = (e) => {
|
||
e.preventDefault();
|
||
if (!name.trim() || !repoPath.trim()) return;
|
||
onCreate({ name: name.trim(), repoPath: repoPath.trim(), defaultBranch: defaultBranch.trim() || 'main', autonomy });
|
||
};
|
||
return (
|
||
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
|
||
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
|
||
<form onSubmit={submit} style={{ position: 'relative', width: 'min(520px, 92vw)', background: 'var(--panel)', border: '1px solid var(--green-dim)', borderRadius: 'var(--radius-lg,10px)', padding: 18, boxShadow: '0 0 0 1px var(--line-soft), 0 24px 64px rgba(0,0,0,.6)', animation: 'maestro-rise .18s ease both' }}>
|
||
<div style={{ fontWeight: 700, fontSize: 14, letterSpacing: '.06em', marginBottom: 14, color: 'var(--green)' }}>▍ {L.title}</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<Input label={L.name} required placeholder={L.namePh} value={name} onChange={setName} style={{ width: '100%' }} />
|
||
<Input label={L.repo} required placeholder={L.repoPh} value={repoPath} onChange={setRepoPath} style={{ width: '100%' }} />
|
||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||
<Input label={L.branch} value={defaultBranch} onChange={setDefaultBranch} width={140} />
|
||
<Select label={L.mode} value={autonomy} onChange={setAutonomy} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 18, paddingTop: 12, borderTop: '1px solid var(--line-soft)' }}>
|
||
<Button onClick={onClose}>{t.cancel}</Button>
|
||
<Button variant="solid" type="submit">{L.create}</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 工具参数 → 可读摘要(命令 / 文件 / 模式…)
|
||
function toolArg(input) {
|
||
if (!input || typeof input !== 'object') return '';
|
||
return input.command || input.file_path || input.path || input.pattern || input.url || input.prompt
|
||
|| (Object.keys(input).length ? JSON.stringify(input) : '');
|
||
}
|
||
const trunc = (s, n) => { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) + '…' : s; };
|
||
|
||
// SDK 流式消息 → 结构化块(助手文本 / 工具调用含参数 / 工具结果预览 / 终态 / 元事件)
|
||
function messageBlocks(m) {
|
||
if (!m || !m.type) return [];
|
||
const content = m.message && m.message.content;
|
||
const out = [];
|
||
if (m.type === 'assistant' && Array.isArray(content)) {
|
||
for (const c of content) {
|
||
if (c.type === 'text' && c.text && c.text.trim()) out.push({ kind: 'text', text: c.text.trim() });
|
||
else if (c.type === 'tool_use') out.push({ kind: 'tool', name: c.name, arg: trunc(toolArg(c.input), 220) });
|
||
}
|
||
} else if (m.type === 'user' && Array.isArray(content)) {
|
||
for (const c of content) {
|
||
if (c.type === 'tool_result') {
|
||
const cnt = typeof c.content === 'string' ? c.content
|
||
: Array.isArray(c.content) ? c.content.map((x) => (x && x.text) || '').join('') : '';
|
||
out.push({ kind: 'result', text: trunc((cnt || '').trim(), 400), isError: c.is_error });
|
||
}
|
||
}
|
||
} else if (m.type === 'result') out.push({ kind: 'meta', text: '■ ' + (m.subtype || 'done') });
|
||
else if (typeof m.type === 'string' && m.type.startsWith('maestro.')) out.push({ kind: 'meta', text: '· ' + m.type });
|
||
return out;
|
||
}
|
||
|
||
// 单块渲染:文本 / 工具调用(青) / 结果预览(暗、缩进) / 元事件
|
||
function StreamLine({ b }) {
|
||
if (b.kind === 'tool') {
|
||
return <div style={{ padding: '3px 0' }}><span style={{ color: 'var(--cyan)', fontWeight: 600 }}>⚙ {b.name}</span>{b.arg ? <span style={{ color: 'var(--muted)' }}> {b.arg}</span> : null}</div>;
|
||
}
|
||
if (b.kind === 'result') {
|
||
return <div style={{ padding: '1px 0 5px 16px', color: b.isError ? 'var(--red)' : 'var(--faint)', fontSize: 11.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>↩ {b.text || '(空)'}</div>;
|
||
}
|
||
if (b.kind === 'meta') return <div style={{ padding: '3px 0', color: 'var(--green)' }}>{b.text}</div>;
|
||
return <div style={{ padding: '3px 0', color: 'var(--ink)' }}>{b.text}</div>;
|
||
}
|
||
|
||
// 实时流模态:EventSource 跟随某 run 的 transcript,逐条渲染 agent 输出
|
||
function StreamModal({ agent, onClose }) {
|
||
const [lines, setLines] = React.useState([]);
|
||
const [status, setStatus] = React.useState('connecting');
|
||
const boxRef = React.useRef(null);
|
||
React.useEffect(() => {
|
||
const es = new EventSource(`/api/tasks/${agent.taskId}/stream`);
|
||
es.addEventListener('open', () => setStatus('streaming'));
|
||
es.onmessage = (e) => {
|
||
let bs = [];
|
||
try { bs = messageBlocks(JSON.parse(e.data)); } catch { bs = []; }
|
||
if (bs.length) setLines((ls) => [...ls, ...bs].slice(-500));
|
||
};
|
||
es.addEventListener('end', () => { setStatus('done'); es.close(); });
|
||
es.onerror = () => { setStatus('disconnected'); es.close(); };
|
||
return () => es.close();
|
||
}, [agent.taskId]);
|
||
React.useEffect(() => { if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight; }, [lines]);
|
||
const dot = { connecting: 'var(--amber)', streaming: 'var(--cyan)', done: 'var(--green)', disconnected: 'var(--faint)' }[status];
|
||
return (
|
||
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
|
||
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
|
||
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', width: 'min(960px,94vw)', height: '82vh', background: 'var(--panel)', border: '1px solid var(--cyan-dim)', borderRadius: 'var(--radius-lg,10px)', overflow: 'hidden', boxShadow: '0 0 0 1px var(--line-soft), 0 24px 64px rgba(0,0,0,.6)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 16px', borderBottom: '1px solid var(--line)', background: 'var(--panel-2)' }}>
|
||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: dot, boxShadow: `0 0 8px ${dot}` }}></span>
|
||
<b style={{ fontSize: 13 }}>{agent.title}</b>
|
||
<span style={{ fontSize: 10, letterSpacing: '.1em', color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', padding: '0 6px', borderRadius: 3 }}>{agent.kind}</span>
|
||
<span style={{ fontSize: 11, color: 'var(--faint)' }}>实时输出 · {status}</span>
|
||
<span style={{ flex: 1 }}></span>
|
||
<button onClick={onClose} style={{ fontFamily: 'var(--mono)', fontSize: 13, background: 'transparent', color: 'var(--muted)', border: 'none', cursor: 'pointer' }}>✕</button>
|
||
</div>
|
||
<div ref={boxRef} style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>
|
||
{lines.length ? lines.map((b, i) => <StreamLine key={i} b={b} />)
|
||
: <div style={{ color: 'var(--faint)' }}>等待输出…</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 接管模态:展示 daemon 准备好的 worktree + 在自己终端起交互式 claude 的命令
|
||
function TakeoverModal({ info, onClose }) {
|
||
const [copied, setCopied] = React.useState(false);
|
||
const copy = () => { try { navigator.clipboard.writeText(info.console); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* 非安全上下文 */ } };
|
||
return (
|
||
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
|
||
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
|
||
<div style={{ position: 'relative', width: 'min(620px,92vw)', background: 'var(--panel)', border: '1px solid var(--cyan-dim)', borderRadius: 'var(--radius-lg,10px)', padding: 18, boxShadow: '0 0 0 1px var(--line-soft), 0 24px 64px rgba(0,0,0,.6)' }}>
|
||
<div style={{ fontWeight: 700, fontSize: 14, color: 'var(--cyan)', marginBottom: 12 }}>⌨ 人工接管 · {info.title}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8 }}>worktree 已就绪。在你自己的终端运行下面命令,起交互式 Claude Code 手动接手:</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||
<code style={{ flex: 1, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--cyan-dim)', borderRadius: 4, padding: '10px 12px', fontSize: 12.5, color: 'var(--ink)', wordBreak: 'break-all' }}>{info.console}</code>
|
||
<button onClick={copy} style={{ flex: 'none', fontFamily: 'var(--mono)', fontSize: 12, cursor: 'pointer', background: 'transparent', color: copied ? 'var(--green)' : 'var(--cyan)', border: '1px solid var(--cyan-dim)', borderRadius: 4, padding: '0 12px' }}>{copied ? '✓ 已复制' : '复制'}</button>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--faint)', marginTop: 10 }}>分支 {info.branch} · 完成后用 git 提交,可经审核区合并。</div>
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||
<button onClick={onClose} style={{ fontFamily: 'var(--mono)', fontSize: 13, background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 4, padding: '5px 14px', cursor: 'pointer' }}>关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function App() {
|
||
const I18N = window.MAESTRO_I18N;
|
||
const [lang, setLang] = React.useState(() => {
|
||
const saved = localStorage.getItem('maestro-kit-lang');
|
||
return I18N[saved] ? saved : 'zh';
|
||
});
|
||
const t = I18N[lang];
|
||
const { Button } = window.MaestroDesignSystem_a6a290;
|
||
|
||
// ── 真实数据 state ──────────────────────────────────
|
||
const [projectsRaw, setProjectsRaw] = React.useState([]);
|
||
const [tasksRaw, setTasksRaw] = React.useState([]);
|
||
const [approvalsRaw, setApprovalsRaw] = React.useState([]);
|
||
const [eventsRaw, setEventsRaw] = React.useState([]);
|
||
const [agentsResp, setAgentsResp] = React.useState({ totalActive: 0, agents: [] });
|
||
const [usage, setUsage] = React.useState(null);
|
||
const [settings, setSettings] = React.useState(null);
|
||
const [currentId, setCurrentId] = React.useState(() => localStorage.getItem('maestro-kit-project') || null);
|
||
React.useEffect(() => { if (currentId) localStorage.setItem('maestro-kit-project', currentId); }, [currentId]);
|
||
const [wsState, setWsState] = React.useState('closed');
|
||
|
||
// ── UI state(沿用原型)─────────────────────────────
|
||
const [showConfig, setShowConfig] = React.useState(false);
|
||
const [showNew, setShowNew] = React.useState(false);
|
||
const [archiveItem, setArchiveItem] = React.useState(null);
|
||
const [streamAgent, setStreamAgent] = React.useState(null);
|
||
const [takeoverInfo, setTakeoverInfo] = React.useState(null);
|
||
const [toasts, setToasts] = React.useState([]);
|
||
const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark');
|
||
React.useEffect(() => {
|
||
document.documentElement.dataset.theme = theme;
|
||
localStorage.setItem('maestro-kit-theme', theme);
|
||
}, [theme]);
|
||
React.useEffect(() => { localStorage.setItem('maestro-kit-lang', lang); }, [lang]);
|
||
const [collapsed, setCollapsed] = React.useState(() => localStorage.getItem('maestro-kit-sidebar') === 'collapsed');
|
||
const [evCollapsed, setEvCollapsed] = React.useState(() => localStorage.getItem('maestro-kit-events') === 'collapsed');
|
||
const toggleEvents = () => setEvCollapsed((c) => { localStorage.setItem('maestro-kit-events', c ? 'open' : 'collapsed'); return !c; });
|
||
const toggleCollapse = () => setCollapsed((c) => { localStorage.setItem('maestro-kit-sidebar', c ? 'open' : 'collapsed'); return !c; });
|
||
const SIDE_MIN = 200, SIDE_MAX = 420, EV_MIN = 240, EV_MAX = 520, CENTER_MIN = 480;
|
||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
const sideMax = () => Math.min(SIDE_MAX, window.innerWidth - (evCollapsed ? 52 : evW) - CENTER_MIN);
|
||
const evMax = () => Math.min(EV_MAX, window.innerWidth - (collapsed ? 52 : sideW) - CENTER_MIN);
|
||
const [sideW, setSideW] = React.useState(() => clamp(Number(localStorage.getItem('maestro-kit-sidew')) || 232, SIDE_MIN, SIDE_MAX));
|
||
const [evW, setEvW] = React.useState(() => clamp(Number(localStorage.getItem('maestro-kit-evw')) || 320, EV_MIN, EV_MAX));
|
||
const [dragging, setDragging] = React.useState(null);
|
||
React.useEffect(() => { localStorage.setItem('maestro-kit-sidew', sideW); }, [sideW]);
|
||
React.useEffect(() => { localStorage.setItem('maestro-kit-evw', evW); }, [evW]);
|
||
const startDrag = (which) => (e) => {
|
||
e.preventDefault();
|
||
setDragging(which);
|
||
const startX = e.clientX;
|
||
const startW = which === 'side' ? sideW : evW;
|
||
const onMove = (ev) => {
|
||
if (which === 'side') setSideW(clamp(startW + (ev.clientX - startX), SIDE_MIN, Math.max(SIDE_MIN, sideMax())));
|
||
else setEvW(clamp(startW - (ev.clientX - startX), EV_MIN, Math.max(EV_MIN, evMax())));
|
||
};
|
||
const onUp = () => {
|
||
setDragging(null);
|
||
window.removeEventListener('pointermove', onMove);
|
||
window.removeEventListener('pointerup', onUp);
|
||
document.body.style.userSelect = '';
|
||
};
|
||
document.body.style.userSelect = 'none';
|
||
window.addEventListener('pointermove', onMove);
|
||
window.addEventListener('pointerup', onUp);
|
||
};
|
||
const Resizer = ({ which }) => (
|
||
<div onPointerDown={startDrag(which)} title="拖拽调整宽度"
|
||
style={{
|
||
position: 'absolute', top: 0, bottom: 0, width: 9, cursor: 'col-resize', zIndex: 40,
|
||
...(which === 'side' ? { left: sideW - 4 } : { right: evW - 4 }),
|
||
display: 'flex', justifyContent: 'center',
|
||
}}>
|
||
<span style={{ width: 1, background: dragging === which ? 'var(--green)' : 'var(--line-soft)', boxShadow: dragging === which ? '0 0 8px var(--green)' : 'none', transition: 'background .12s' }}></span>
|
||
</div>
|
||
);
|
||
|
||
// ── 数据加载 ────────────────────────────────────────
|
||
const toast = (kind, text) => {
|
||
const id = Date.now() + Math.random();
|
||
setToasts((list) => [...list, { id, kind, text }]);
|
||
setTimeout(() => setToasts((list) => list.filter((x) => x.id !== id)), 2600);
|
||
};
|
||
|
||
const loadGlobal = React.useCallback(async () => {
|
||
try {
|
||
const [pj, ag, us, ap] = await Promise.all([api.listProjects(), api.agents(), api.usage(), api.approvals()]);
|
||
setProjectsRaw(pj); setAgentsResp(ag); setUsage(us); setApprovalsRaw(ap);
|
||
// settings 端点可能未部署到旧 daemon:独立拉取,失败则降级为默认(不阻断主加载)
|
||
api.getSettings().then(setSettings).catch(() => setSettings(null));
|
||
setCurrentId((cur) => (cur && pj.some((p) => p.id === cur) ? cur : (pj[0] && pj[0].id) || null));
|
||
} catch (e) { toast('warn', '加载失败:' + e.message); }
|
||
}, []);
|
||
|
||
const loadProject = React.useCallback(async (pid) => {
|
||
if (!pid) return;
|
||
try {
|
||
const [tasks, events] = await Promise.all([api.projectTasks(pid), api.projectEvents(pid)]);
|
||
setTasksRaw(tasks);
|
||
setEventsRaw(events.slice(0, 60));
|
||
} catch (e) { toast('warn', '加载任务失败:' + e.message); }
|
||
}, []);
|
||
|
||
// 左侧栏项目拖拽排序:乐观重排 + 落库(reorder 写 sortOrder)
|
||
const reorderProjects = async (order) => {
|
||
setProjectsRaw((prev) => order.map((id) => prev.find((p) => p.id === id)).filter(Boolean));
|
||
try { await api.reorderProjects(order); } catch (e) { toast('warn', '排序保存失败:' + e.message); loadGlobal(); }
|
||
};
|
||
|
||
// 用户级全局默认配置:弹框保存 → 落库
|
||
const saveSettings = async (data) => {
|
||
try { const saved = await api.putSettings(data); setSettings(saved); toast('ok', t.toastSaved); }
|
||
catch (e) { toast('warn', '保存失败:' + e.message); }
|
||
};
|
||
|
||
React.useEffect(() => { loadGlobal(); }, [loadGlobal]);
|
||
React.useEffect(() => { loadProject(currentId); }, [currentId, loadProject]);
|
||
|
||
// WS:增量刷新。事件即时入流;任务/审批/agent 做轻量去抖刷新。
|
||
const refreshTimer = React.useRef(null);
|
||
React.useEffect(() => {
|
||
const unsub = subscribeEvents((evt) => {
|
||
if (!evt || !evt.type) return;
|
||
if (!evt.projectId || evt.projectId === currentId) {
|
||
setEventsRaw((list) => [evt, ...list].slice(0, 60));
|
||
}
|
||
if (evt.type === 'budget.exceeded') {
|
||
const pay = evt.payload || {};
|
||
toast('warn', `⚠ 项目超预算已暂停 · 已用 $${(pay.spend ?? 0).toFixed?.(2) ?? pay.spend} / $${pay.budget}`);
|
||
}
|
||
clearTimeout(refreshTimer.current);
|
||
refreshTimer.current = setTimeout(() => {
|
||
loadProject(currentId);
|
||
api.approvals().then(setApprovalsRaw).catch(() => {});
|
||
api.agents().then(setAgentsResp).catch(() => {});
|
||
api.listProjects().then(setProjectsRaw).catch(() => {});
|
||
}, 350);
|
||
}, setWsState);
|
||
return () => { unsub(); clearTimeout(refreshTimer.current); };
|
||
}, [currentId, loadProject]);
|
||
|
||
// ── 派生:映射成 surface 形状 ───────────────────────
|
||
const projects = projectsRaw.map(adaptProject);
|
||
const project = projects.find((p) => p.id === currentId) || projects[0];
|
||
const projNameOf = (pid) => (projectsRaw.find((p) => p.id === pid) || {}).name;
|
||
const { active: tasks, archived } = buildTaskTree(tasksRaw);
|
||
// 「需你处理」= 三个审批闸 + needs_attention(卡住待人工)。后者并入同区但操作不同。
|
||
const REVIEW = new Set(['plan_review', 'spec_review', 'exec_review']);
|
||
const gates = approvalsRaw
|
||
.filter((a) => a.projectId === currentId && (REVIEW.has(a.status) || a.status === 'needs_attention'))
|
||
.map((a) => {
|
||
const g = adaptApproval(a, projNameOf(a.projectId));
|
||
// 拆解评审:挂上实际拆出的子任务(标题/复杂度/优先级/依赖/状态),供评审弹层结构化展示
|
||
if (g.gate === 'plan') {
|
||
g.subtasks = tasksRaw
|
||
.filter((tk) => tk.parentId === a.id)
|
||
.map((tk) => ({ id: tk.id, title: tk.title, complexity: tk.complexity, priority: tk.priority, deps: tk.deps || [], status: tk.status, scopeFiles: tk.scopeFiles || [] }));
|
||
}
|
||
return g;
|
||
})
|
||
// 审批闸在前、需人工在后
|
||
.sort((x, y) => (x.gate === 'attention' ? 1 : 0) - (y.gate === 'attention' ? 1 : 0));
|
||
const agents = adaptActiveAgents(agentsResp, currentId);
|
||
const events = eventsRaw.map(adaptEvent);
|
||
const quota = adaptQuota(usage);
|
||
const global = deriveGlobal(projects, agentsResp);
|
||
const agentSummary = deriveAgentSummary(projects, agentsResp, usage?.cost);
|
||
const NONTERMINAL = (s) => !['done', 'cancelled', 'decomposed'].includes(s);
|
||
const counts = {
|
||
gate: gates.length,
|
||
ready: tasksRaw.filter((x) => x.projectId === currentId && (x.status === 'ready' || x.status === 'queued')).length,
|
||
run: tasksRaw.filter((x) => x.projectId === currentId && x.status === 'executing').length,
|
||
blocked: tasksRaw.filter((x) => x.projectId === currentId && x.status === 'blocked').length,
|
||
total: tasksRaw.filter((x) => x.projectId === currentId && NONTERMINAL(x.status)).length,
|
||
};
|
||
|
||
// ── 动作(真实写入)────────────────────────────────
|
||
const decide = async (a, action, reason) => {
|
||
setApprovalsRaw((list) => list.filter((x) => x.id !== a.id)); // 乐观移除
|
||
try {
|
||
await api.decide(a.id, action, reason);
|
||
toast(action === 'accept' ? 'ok' : 'warn', (action === 'accept' ? t.toastAccepted : t.toastRejected) + (action === 'accept' ? a.title : ''));
|
||
} catch (e) {
|
||
toast('warn', '裁决失败:' + e.message);
|
||
api.approvals().then(setApprovalsRaw).catch(() => {}); // 回滚
|
||
}
|
||
};
|
||
const onSync = async () => {
|
||
try { await api.syncProject(currentId); toast('ok', t.toastSynced); loadProject(currentId); loadGlobal(); }
|
||
catch (e) { toast('warn', '同步失败:' + e.message); }
|
||
};
|
||
const createTask = async (payload) => {
|
||
const { files, ...body } = payload;
|
||
try {
|
||
const task = await api.createTask(currentId, body);
|
||
if (files && files.length) {
|
||
try {
|
||
// 消费后端去重后的权威列表:新建任务原本无附件,stored 即本批去重后入库条数
|
||
const { attachments } = await api.uploadAttachments(task.id, files);
|
||
const stored = attachments.length;
|
||
const deduped = files.length - stored; // >0 表示有同内容文件被去重
|
||
toast('ok', t.attUploaded.replace('{n}', stored) + (deduped > 0 ? t.attDeduped.replace('{n}', deduped) : ''));
|
||
} catch (e) { toast('warn', e.message); }
|
||
}
|
||
toast('ok', t.toastCreated);
|
||
loadProject(currentId); loadGlobal();
|
||
} catch (e) { toast('warn', '创建失败:' + e.message); }
|
||
};
|
||
const saveConfig = async (patch) => {
|
||
try {
|
||
await api.patchProject(currentId, patch);
|
||
setShowConfig(false); toast('ok', t.toastSaved); loadGlobal();
|
||
} catch (e) { toast('warn', '保存失败:' + e.message); }
|
||
};
|
||
const takeover = async (task) => {
|
||
try {
|
||
const info = await api.takeover(task.id);
|
||
setTakeoverInfo({ ...info, title: task.title });
|
||
} catch (e) { toast('warn', '接管失败:' + e.message); }
|
||
};
|
||
// 任务生命周期操作(编辑落库 / 取消 / 重投 / 删除),统一带刷新
|
||
const taskOps = {
|
||
patch: async (taskId, body) => {
|
||
try { await api.patchTask(taskId, body); toast('ok', '已更新'); loadProject(currentId); }
|
||
catch (e) { toast('warn', '更新失败:' + e.message); }
|
||
},
|
||
cancel: async (taskId) => {
|
||
try { await api.cancelTask(taskId); toast('warn', '已取消'); loadProject(currentId); loadGlobal(); }
|
||
catch (e) { toast('warn', '取消失败:' + e.message); }
|
||
},
|
||
requeue: async (taskId) => {
|
||
try { await api.requeueTask(taskId); toast('ok', '已重投'); loadProject(currentId); loadGlobal(); }
|
||
catch (e) { toast('warn', '重投失败:' + e.message); }
|
||
},
|
||
del: async (taskId) => {
|
||
try { await api.deleteTask(taskId); toast('warn', '已删除'); loadProject(currentId); loadGlobal(); }
|
||
catch (e) { toast('warn', '删除失败:' + e.message); }
|
||
},
|
||
// 删除已建任务的单个附件(name = 磁盘文件名 basename(att.path)),成功后局部刷新
|
||
delAttachment: async (taskId, name) => {
|
||
try { await api.deleteAttachment(taskId, name); toast('ok', t.attDeleted); loadProject(currentId); }
|
||
catch (e) { toast('warn', t.attDeleteFailed + e.message); }
|
||
},
|
||
};
|
||
const [showNewProject, setShowNewProject] = React.useState(false);
|
||
const createProject = async (body) => {
|
||
try {
|
||
const p = await api.createProject(body);
|
||
setShowNewProject(false); toast('ok', '项目已创建 · ' + body.name);
|
||
await loadGlobal(); setCurrentId(p.id);
|
||
} catch (e) { toast('warn', '创建项目失败:' + e.message); }
|
||
};
|
||
|
||
return (
|
||
<div style={{ position: 'relative', display: 'grid', gridTemplateColumns: (collapsed ? '52px' : sideW + 'px') + ' minmax(0,1fr) ' + (evCollapsed ? '52px' : evW + 'px'), height: '100vh' }}>
|
||
{!collapsed ? <Resizer which="side" /> : null}
|
||
{!evCollapsed ? <Resizer which="ev" /> : null}
|
||
<window.MaestroKitSidebar projects={projects} currentId={currentId} t={t}
|
||
global={global} user={USER} summary={agentSummary} onGlobalConfig={() => toast('warn', t.toastModal)}
|
||
collapsed={collapsed} onToggleCollapse={toggleCollapse}
|
||
onSelect={setCurrentId} onNewProject={() => setShowNewProject(true)} onReorder={reorderProjects}
|
||
settings={settings} onSaveSettings={saveSettings} />
|
||
<main style={{ overflowY: 'auto', minHeight: 0, padding: '0 22px 60px' }}>
|
||
{project ? <window.MaestroKitTopbar project={project} t={t} theme={theme}
|
||
onToggleTheme={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||
lang={lang} onSelectLang={setLang}
|
||
counts={counts}
|
||
onSync={onSync}
|
||
onToggleConfig={() => setShowConfig(!showConfig)} /> : null}
|
||
{project ? (
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 14 }}>
|
||
<Button variant="solid" onClick={() => setShowNew((s) => !s)}>{t.newTask}</Button>
|
||
</div>
|
||
) : null}
|
||
{showNew && project ? <window.MaestroKitNewTaskPanel tasks={tasks} t={t}
|
||
onCancel={() => setShowNew(false)}
|
||
onCreate={(payload) => { setShowNew(false); createTask(payload); }} /> : null}
|
||
{showConfig && project ? <window.MaestroKitConfigPanel project={project} t={t} lang={lang}
|
||
onSave={saveConfig}
|
||
onSync={onSync}
|
||
onClose={() => setShowConfig(false)} /> : null}
|
||
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} onOpenStream={setStreamAgent} />
|
||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} taskOps={taskOps} onTakeover={takeover} t={t} />
|
||
{tasks.length ? (
|
||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} onTakeover={takeover} taskOps={taskOps} t={t} />
|
||
) : (
|
||
<div style={{ padding: '46px 0', textAlign: 'center', color: 'var(--faint)', border: '1px dashed var(--line)', borderRadius: 6, marginTop: 18 }}>
|
||
<b style={{ display: 'block', fontSize: 15, color: 'var(--muted)', marginBottom: 6, letterSpacing: '.2em' }}>{t.empty}</b>
|
||
{t.emptyTasks}
|
||
</div>
|
||
)}
|
||
<window.MaestroKitArchiveSection items={archived} t={t} onOpen={setArchiveItem} />
|
||
</main>
|
||
<window.MaestroKitEventPanel events={events} collapsed={evCollapsed} onToggleCollapse={toggleEvents} t={t} />
|
||
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
|
||
{showNewProject ? <NewProjectModal t={t} lang={lang} defaultAutonomy={settings?.autonomy} onCreate={createProject} onClose={() => setShowNewProject(false)} /> : null}
|
||
{streamAgent ? <StreamModal agent={streamAgent} onClose={() => setStreamAgent(null)} /> : null}
|
||
{takeoverInfo ? <TakeoverModal info={takeoverInfo} onClose={() => setTakeoverInfo(null)} /> : null}
|
||
<div style={{ position: 'fixed', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 1200, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||
{toasts.map((x) => <Toast key={x.id} kind={x.kind}>{x.text}</Toast>)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function mount() {
|
||
createRoot(document.getElementById('root')).render(<App />);
|
||
}
|