Files
maestro/app/src/app.jsx
T
wangjia 8d3192e7cb app接线: 上传去重响应/已存附件删除/缩略图白名单对齐后端 (tsk_sTHRFtFoHf-G)
- 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>
2026-06-30 07:42:46 +08:00

465 lines
28 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 真实数据版 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 />);
}