Compare commits
8 Commits
d9d3de26b8
...
ee9232e575
| Author | SHA1 | Date | |
|---|---|---|---|
| ee9232e575 | |||
| 8d3192e7cb | |||
| 80f257171d | |||
| 33bef7f5fc | |||
| db4e6e2fb4 | |||
| eac3575851 | |||
| 64b8c912ef | |||
| f1ee537ab7 |
+1
-1
@@ -25,7 +25,7 @@ export function adaptProject(p) {
|
||||
return {
|
||||
id: p.id, name: p.name, path: shortPath(p.repoPath), branch: p.defaultBranch,
|
||||
autonomy: p.autonomy, concurrency: p.concurrency, hue: hueFor(p.name),
|
||||
state, pending: s.attention || 0, agents: s.executing || 0,
|
||||
state, pending: s.attention || 0, agents: s.executing || 0, blocked: s.blocked || 0,
|
||||
// 透传给 ConfigPanel 用的原始字段
|
||||
model: p.model, verifyCmd: p.verifyCmd, maxRetries: p.maxRetries, repoPath: p.repoPath,
|
||||
logo: p.logo, budgetUsd: p.budgetUsd ?? null, budgetPeriod: p.budgetPeriod ?? 'month',
|
||||
|
||||
+8
-3
@@ -350,8 +350,13 @@ export function App() {
|
||||
try {
|
||||
const task = await api.createTask(currentId, body);
|
||||
if (files && files.length) {
|
||||
try { await api.uploadAttachments(task.id, files); toast('ok', `附件已上传 · ${files.length} 个`); }
|
||||
catch (e) { toast('warn', e.message); }
|
||||
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();
|
||||
@@ -389,7 +394,7 @@ export function App() {
|
||||
},
|
||||
// 删除已建任务的单个附件(name = 磁盘文件名 basename(att.path)),成功后局部刷新
|
||||
delAttachment: async (taskId, name) => {
|
||||
try { await api.deleteAttachment(taskId, name); toast('warn', t.attDeleted); loadProject(currentId); }
|
||||
try { await api.deleteAttachment(taskId, name); toast('ok', t.attDeleted); loadProject(currentId); }
|
||||
catch (e) { toast('warn', t.attDeleteFailed + e.message); }
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,6 +48,17 @@ function projStateMeta(state, t) {
|
||||
})[state] || { color: 'var(--muted)', pulse: false, label: t.projIdle };
|
||||
}
|
||||
|
||||
// 项目计数徽标:按优先级展示「需人工 / 运行中 / 被阻塞」,仅非零显示
|
||||
function CountBadge({ value, color, title }) {
|
||||
return (
|
||||
<span title={title} style={{
|
||||
fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 700, lineHeight: '15px',
|
||||
minWidth: 15, height: 15, textAlign: 'center', padding: '0 4px',
|
||||
color: 'var(--bg-deep)', background: color, borderRadius: 8,
|
||||
}}>{value}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjStatusDot({ meta, size = 7 }) {
|
||||
return (
|
||||
<span style={{
|
||||
@@ -509,7 +520,10 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
|
||||
const active = p.id === currentId;
|
||||
const meta = projStateMeta(p.state, t);
|
||||
return (
|
||||
<li key={p.id} onClick={() => onSelect(p.id)} title={p.name + ' · ' + meta.label + (p.pending ? ' · ' + t.projPendingTip.replace('{n}', p.pending) : '')}
|
||||
<li key={p.id} onClick={() => onSelect(p.id)} title={p.name + ' · ' + meta.label
|
||||
+ (p.pending ? ' · ' + t.projPendingTip.replace('{n}', p.pending) : '')
|
||||
+ (p.agents ? ' · ' + t.projAgentsTip.replace('{n}', p.agents) : '')
|
||||
+ (p.blocked ? ' · ' + t.projBlockedTip.replace('{n}', p.blocked) : '')}
|
||||
draggable={!collapsed}
|
||||
onDragStart={(e) => { setDragId(p.id); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { if (dragId) { e.preventDefault(); if (p.id !== dragId && overId !== p.id) setOverId(p.id); } }}
|
||||
@@ -536,20 +550,16 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: active ? 'var(--green)' : 'var(--ink)' }}>{p.name}</span>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.path}</span>
|
||||
</span>
|
||||
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{p.pending > 0 ? (
|
||||
<span title={t.projPendingTip.replace('{n}', p.pending)} style={{
|
||||
fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 700, lineHeight: '15px',
|
||||
minWidth: 15, height: 15, textAlign: 'center', padding: '0 4px',
|
||||
color: 'var(--bg-deep)', background: 'var(--violet)', borderRadius: 8,
|
||||
}}>{p.pending}</span>
|
||||
) : null}
|
||||
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
{p.pending > 0 ? <CountBadge value={p.pending} color="var(--violet)" title={t.projPendingTip.replace('{n}', p.pending)} /> : null}
|
||||
{p.agents > 0 ? <CountBadge value={p.agents} color="var(--cyan)" title={t.projAgentsTip.replace('{n}', p.agents)} /> : null}
|
||||
{p.blocked > 0 ? <CountBadge value={p.blocked} color="var(--amber)" title={t.projBlockedTip.replace('{n}', p.blocked)} /> : null}
|
||||
<ProjStatusDot meta={meta} />
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<span style={{ position: 'absolute', top: 7, right: 9, display: 'flex', alignItems: 'center' }}>
|
||||
<ProjStatusDot meta={meta} size={p.pending > 0 ? 8 : 6} />
|
||||
<ProjStatusDot meta={meta} size={(p.pending || p.agents || p.blocked) > 0 ? 8 : 6} />
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -9,6 +9,10 @@ const KIT_STATUS_ORDER = [
|
||||
];
|
||||
const KIT_CPLX_OPTS = [{ value: 'hard', label: 'HARD' }, { value: 'medium', label: 'MED' }, { value: 'easy', label: 'EASY' }];
|
||||
const KIT_PRIO_OPTS = [{ value: 'P0', label: 'P0' }, { value: 'P1', label: 'P1' }, { value: 'P2', label: 'P2' }];
|
||||
// 与后端 src/api/server.ts 的 SAFE_INLINE 一字不差:只有这些类型后端才 inline 回原图,
|
||||
// 其余(尤其 image/svg+xml、text/html)强制 attachment+octet-stream 下载。前端缩略图必须对齐同一硬白名单,
|
||||
// 否则对 svg 用 <img src=服务端URL> 会拿到 octet-stream 出坏图,且策略漂移时埋同源存储型 XSS 隐患。
|
||||
const KIT_ATT_SAFE_INLINE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
|
||||
// 主显示区各区块标题通用折叠按钮(▾ 展开 / ▸ 折叠);暴露到 window 供 Agent/审批闸/归档复用
|
||||
function KitFoldBtn({ folded, onClick, t }) {
|
||||
@@ -292,7 +296,7 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
|
||||
<div className="m-nt-grid">
|
||||
{attachments.map((a) => {
|
||||
const dn = attDiskName(a);
|
||||
const isImg = (a.type || '').startsWith('image/');
|
||||
const isImg = KIT_ATT_SAFE_INLINE.has(a.type || '');
|
||||
const url = attUrl(task.id, dn);
|
||||
return (
|
||||
<div key={dn || a.name} className="m-nt-chip">
|
||||
@@ -451,13 +455,15 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
|
||||
const [dragOver, setDragOver] = React.useState(false);
|
||||
// 图片缩略图 objectURL 缓存(按去重键),卸载/移除时显式 revoke 防泄漏
|
||||
const urlMapRef = React.useRef(new Map());
|
||||
const thumbUrl = (f) => {
|
||||
if (!f.type || !f.type.startsWith('image/')) return null;
|
||||
const [warn, setWarn] = React.useState('');
|
||||
// 为图片预生成缩略图 objectURL(幂等,按去重键缓存)——只在 addFiles 调用,render 仅读,避免严格模式下重复 render 泄漏
|
||||
const ensureThumb = (f) => {
|
||||
if (!f.type || !f.type.startsWith('image/')) return;
|
||||
const key = ntFileKey(f);
|
||||
const map = urlMapRef.current;
|
||||
if (!map.has(key)) map.set(key, URL.createObjectURL(f));
|
||||
return map.get(key);
|
||||
};
|
||||
const thumbUrl = (f) => (f.type && f.type.startsWith('image/')) ? (urlMapRef.current.get(ntFileKey(f)) || null) : null;
|
||||
// 组件卸载时释放全部 objectURL
|
||||
React.useEffect(() => () => {
|
||||
urlMapRef.current.forEach((u) => URL.revokeObjectURL(u));
|
||||
@@ -466,11 +472,16 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
|
||||
// 合并新文件并按复合键去重(剪贴板无名图先补名)
|
||||
const addFiles = (incoming) => {
|
||||
if (!incoming || !incoming.length) return;
|
||||
const MAX_BYTES = 25 * 1024 * 1024; // 单文件软上限,避免超大截图卡住上传
|
||||
const named = Array.from(incoming).map((f, i) => ntNamed(f, i));
|
||||
const ok = named.filter((f) => f.size <= MAX_BYTES);
|
||||
setWarn(ok.length < named.length ? t.attTooLarge : '');
|
||||
if (!ok.length) return;
|
||||
ok.forEach(ensureThumb); // 预生成缩略图,render 不再产生副作用
|
||||
setFiles((prev) => {
|
||||
const seen = new Set(prev.map(ntFileKey));
|
||||
const merged = prev.slice();
|
||||
named.forEach((f) => { const k = ntFileKey(f); if (!seen.has(k)) { seen.add(k); merged.push(f); } });
|
||||
ok.forEach((f) => { const k = ntFileKey(f); if (!seen.has(k)) { seen.add(k); merged.push(f); } });
|
||||
return merged;
|
||||
});
|
||||
};
|
||||
@@ -530,6 +541,7 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
|
||||
onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }}
|
||||
style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} />
|
||||
<span className={'m-nt-hint' + (dragOver ? ' is-over' : '')}>{dragOver ? t.dropHint : t.pasteHint}</span>
|
||||
{warn ? <span className="m-nt-hint" style={{ color: 'var(--amber)' }}>{warn}</span> : null}
|
||||
{files.length ? (
|
||||
<div className="m-nt-grid">
|
||||
{files.map((f) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// MAESTRO 调度台 · UI kit 假数据(与 src/model 状态机对齐)
|
||||
window.MAESTRO_MOCK = {
|
||||
projects: [
|
||||
{ id: 'p1', name: 'maestro', path: '~/dev/maestro', branch: 'main', autonomy: 'auto-easy', concurrency: 2, hue: 140, state: 'running', pending: 1, agents: 1 },
|
||||
{ id: 'p2', name: 'blog-engine', path: '~/dev/blog-engine', branch: 'main', autonomy: 'manual', concurrency: 1, hue: 38, state: 'paused', pending: 0, agents: 0 },
|
||||
{ id: 'p3', name: 'dotfiles', path: '~/.dotfiles', branch: 'master', autonomy: 'auto-approved', concurrency: 1, hue: 265, state: 'blocked', pending: 0, agents: 0 },
|
||||
{ id: 'p1', name: 'maestro', path: '~/dev/maestro', branch: 'main', autonomy: 'auto-easy', concurrency: 2, hue: 140, state: 'running', pending: 1, agents: 1, blocked: 0 },
|
||||
{ id: 'p2', name: 'blog-engine', path: '~/dev/blog-engine', branch: 'main', autonomy: 'manual', concurrency: 1, hue: 38, state: 'paused', pending: 0, agents: 0, blocked: 0 },
|
||||
{ id: 'p3', name: 'dotfiles', path: '~/.dotfiles', branch: 'master', autonomy: 'auto-approved', concurrency: 1, hue: 265, state: 'blocked', pending: 2, agents: 0, blocked: 2 },
|
||||
],
|
||||
agents: [
|
||||
{ id: 'r1', project: 'maestro', title: '把轮询改为 WS 推送', kind: 'EXECUTOR', time: '06:12', meta: 'wt-127 · claude-sonnet' },
|
||||
|
||||
@@ -22,6 +22,8 @@ window.MAESTRO_I18N = {
|
||||
agentSection: 'Agent 执行', agentEmpty: '无运行中的 agent', running: 'RUNNING', since: '起',
|
||||
projRunning: '运行中', projPaused: '暂停', projBlocked: '阻塞', projIdle: '空闲',
|
||||
projPendingTip: '{n} 项待你审批',
|
||||
projAgentsTip: '{n} 个 agent 运行中',
|
||||
projBlockedTip: '{n} 项被阻塞',
|
||||
gAgents: '全局 Agent', gAgentsDetail: '{p}/{P} 个项目运行 · {a} 个 agent',
|
||||
apActive: '运行', apRuns: '次运行', apTokens: 'token', apWeek: '本周', apCost: '花费', apTotal: '所有项目', apPerProj: '按项目', apIdle: '空闲', apMore: '展开其余 {n} 个', apCollapse: '收起',
|
||||
apDetail: '详情', apDetailTitle: '用量详情', apByDay: '按天', apByWeek: '按周', apByMonth: '按月', apColProject: '项目', apColTasks: '任务数', apColModels: '模型', apEmpty: '暂无用量数据',
|
||||
@@ -41,7 +43,7 @@ window.MAESTRO_I18N = {
|
||||
complexity: '复杂度', cplxAuto: '智能', parentTask: '父任务', topLevel: '(顶层)', priority: '优先级',
|
||||
p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: '创建任务', cancel: '取消',
|
||||
attachLabel: '附件 · 图片/文件(可选)', dropHint: '拖拽文件到此处,或粘贴截图', removeFile: '移除', pasteHint: '支持 ⌘V 粘贴图片',
|
||||
attSection: '附件', attDownload: '下载', attDeleteConfirm: '删除附件「{name}」?此操作不可恢复。', attDeleted: '附件已删除', attDeleteFailed: '删除附件失败:',
|
||||
attSection: '附件', attDownload: '下载', attDeleteConfirm: '删除附件「{name}」?此操作不可恢复。', attDeleted: '附件已删除', attDeleteFailed: '删除附件失败:', attTooLarge: '单个文件超过 25MB,已跳过', attUploaded: '附件已上传 · {n} 个', attDeduped: '(去重 {n} 个)',
|
||||
depsWait: '待依赖', specLabel: '改动方案(SPEC)', opsLabel: '将执行的操作(OPERATIONS)',
|
||||
pendingDoc: '待 Claude Code 产出(经 MCP 写入并提交评审)',
|
||||
emptyTasks: '该项目暂无任务 —— 点「+ 新建任务」或经 MCP 创建', empty: '空',
|
||||
@@ -74,6 +76,8 @@ window.MAESTRO_I18N = {
|
||||
agentSection: 'Agent runs', agentEmpty: 'No running agents', running: 'RUNNING', since: '',
|
||||
projRunning: 'Running', projPaused: 'Paused', projBlocked: 'Blocked', projIdle: 'Idle',
|
||||
projPendingTip: '{n} awaiting your review',
|
||||
projAgentsTip: '{n} agents running',
|
||||
projBlockedTip: '{n} blocked',
|
||||
gAgents: 'Agents', gAgentsDetail: '{p}/{P} projects · {a} agents',
|
||||
apActive: 'active', apRuns: 'runs', apTokens: 'tokens', apWeek: 'this week', apCost: 'cost', apTotal: 'All projects', apPerProj: 'BY PROJECT', apIdle: 'idle', apMore: '+{n} more', apCollapse: 'Collapse',
|
||||
apDetail: 'Details', apDetailTitle: 'Usage detail', apByDay: 'By day', apByWeek: 'By week', apByMonth: 'By month', apColProject: 'Project', apColTasks: 'Tasks', apColModels: 'Models', apEmpty: 'No usage data yet',
|
||||
@@ -93,7 +97,7 @@ window.MAESTRO_I18N = {
|
||||
complexity: 'Complexity', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(top level)', priority: 'Priority',
|
||||
p0: 'P0 · high', p1: 'P1 · medium', p2: 'P2 · low', create: 'Create task', cancel: 'Cancel',
|
||||
attachLabel: 'Attachments · images/files (optional)', dropHint: 'Drop files here, or paste a screenshot', removeFile: 'Remove', pasteHint: '⌘V to paste images',
|
||||
attSection: 'Attachments', attDownload: 'Download', attDeleteConfirm: 'Delete attachment "{name}"? This cannot be undone.', attDeleted: 'Attachment deleted', attDeleteFailed: 'Failed to delete attachment: ',
|
||||
attSection: 'Attachments', attDownload: 'Download', attDeleteConfirm: 'Delete attachment "{name}"? This cannot be undone.', attDeleted: 'Attachment deleted', attDeleteFailed: 'Failed to delete attachment: ', attTooLarge: 'File exceeds 25MB and was skipped', attUploaded: 'Attachments uploaded · {n}', attDeduped: ' ({n} duplicates skipped)',
|
||||
depsWait: 'deps', specLabel: 'CHANGE SPEC', opsLabel: 'PLANNED OPERATIONS',
|
||||
pendingDoc: 'Awaiting Claude Code output (written via MCP, then submitted for review)',
|
||||
emptyTasks: 'No tasks in this project — hit "+ New task" or create via MCP', empty: 'EMPTY',
|
||||
@@ -126,6 +130,8 @@ window.MAESTRO_I18N = {
|
||||
agentSection: 'Ejecución de agents', agentEmpty: 'Sin agents en ejecución', running: 'RUNNING', since: '',
|
||||
projRunning: 'En curso', projPaused: 'Pausado', projBlocked: 'Bloqueado', projIdle: 'Inactivo',
|
||||
projPendingTip: '{n} esperan tu revisión',
|
||||
projAgentsTip: '{n} agentes en curso',
|
||||
projBlockedTip: '{n} bloqueadas',
|
||||
gAgents: 'Agents globales', gAgentsDetail: '{p}/{P} proyectos · {a} agents',
|
||||
apActive: 'activos', apRuns: 'ejecuciones', apTokens: 'tokens', apWeek: 'esta semana', apCost: 'coste', apTotal: 'Todos los proyectos', apPerProj: 'POR PROYECTO', apIdle: 'inactivo', apMore: '+{n} más', apCollapse: 'Contraer',
|
||||
apDetail: 'Detalles', apDetailTitle: 'Detalle de uso', apByDay: 'Por día', apByWeek: 'Por semana', apByMonth: 'Por mes', apColProject: 'Proyecto', apColTasks: 'Tareas', apColModels: 'Modelos', apEmpty: 'Sin datos de uso',
|
||||
@@ -145,7 +151,7 @@ window.MAESTRO_I18N = {
|
||||
complexity: 'Complejidad', cplxAuto: 'AUTO', parentTask: 'Padre', topLevel: '(raíz)', priority: 'Prioridad',
|
||||
p0: 'P0 · alta', p1: 'P1 · media', p2: 'P2 · baja', create: 'Crear tarea', cancel: 'Cancelar',
|
||||
attachLabel: 'Adjuntos · imágenes/archivos (opcional)', dropHint: 'Suelta archivos aquí o pega una captura', removeFile: 'Quitar', pasteHint: '⌘V para pegar imágenes',
|
||||
attSection: 'Adjuntos', attDownload: 'Descargar', attDeleteConfirm: '¿Eliminar el adjunto «{name}»? Esta acción no se puede deshacer.', attDeleted: 'Adjunto eliminado', attDeleteFailed: 'Error al eliminar el adjunto: ',
|
||||
attSection: 'Adjuntos', attDownload: 'Descargar', attDeleteConfirm: '¿Eliminar el adjunto «{name}»? Esta acción no se puede deshacer.', attDeleted: 'Adjunto eliminado', attDeleteFailed: 'Error al eliminar el adjunto: ', attTooLarge: 'El archivo supera los 25MB y se omitió', attUploaded: 'Adjuntos subidos · {n}', attDeduped: ' ({n} duplicados omitidos)',
|
||||
depsWait: 'deps', specLabel: 'SPEC DE CAMBIOS', opsLabel: 'OPERACIONES PREVISTAS',
|
||||
pendingDoc: 'Esperando salida de Claude Code (vía MCP, luego a revisión)',
|
||||
emptyTasks: 'Sin tareas — pulsa "+ Nueva tarea" o crea vía MCP', empty: 'VACÍO',
|
||||
@@ -178,6 +184,8 @@ window.MAESTRO_I18N = {
|
||||
agentSection: 'Agent 実行', agentEmpty: '実行中の agent はありません', running: 'RUNNING', since: '開始',
|
||||
projRunning: '実行中', projPaused: '一時停止', projBlocked: 'ブロック', projIdle: 'アイドル',
|
||||
projPendingTip: '{n} 件があなたの承認待ち',
|
||||
projAgentsTip: '{n} 件のエージェント実行中',
|
||||
projBlockedTip: '{n} 件ブロック中',
|
||||
gAgents: 'グローバル Agent', gAgentsDetail: '{p}/{P} プロジェクト · {a} 個の agent',
|
||||
apActive: '実行中', apRuns: '回実行', apTokens: 'token', apWeek: '今週', apCost: 'コスト', apTotal: '全プロジェクト', apPerProj: 'プロジェクト別', apIdle: 'アイドル', apMore: '他 {n} 件', apCollapse: '折りたたむ',
|
||||
apDetail: '詳細', apDetailTitle: '使用量の詳細', apByDay: '日別', apByWeek: '週別', apByMonth: '月別', apColProject: 'プロジェクト', apColTasks: 'タスク数', apColModels: 'モデル', apEmpty: '使用量データなし',
|
||||
@@ -197,7 +205,7 @@ window.MAESTRO_I18N = {
|
||||
complexity: '複雑度', cplxAuto: '智能', parentTask: '親タスク', topLevel: '(トップ)', priority: '優先度',
|
||||
p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: 'タスク作成', cancel: 'キャンセル',
|
||||
attachLabel: '添付 · 画像/ファイル(任意)', dropHint: 'ここにファイルをドロップ、または貼り付け', removeFile: '削除', pasteHint: '⌘Vで画像を貼り付け',
|
||||
attSection: '添付', attDownload: 'ダウンロード', attDeleteConfirm: '添付「{name}」を削除しますか?この操作は元に戻せません。', attDeleted: '添付を削除しました', attDeleteFailed: '添付の削除に失敗:',
|
||||
attSection: '添付', attDownload: 'ダウンロード', attDeleteConfirm: '添付「{name}」を削除しますか?この操作は元に戻せません。', attDeleted: '添付を削除しました', attDeleteFailed: '添付の削除に失敗:', attTooLarge: 'ファイルが25MBを超えたためスキップしました', attUploaded: '添付をアップロード · {n} 件', attDeduped: '(重複 {n} 件を除外)',
|
||||
depsWait: '依存待ち', specLabel: 'SPEC · 変更案', opsLabel: 'OPERATIONS · 操作',
|
||||
pendingDoc: 'Claude Code の出力待ち(MCP 経由で書き込み後レビューへ)',
|
||||
emptyTasks: 'タスクなし — 「+ 新規タスク」または MCP で作成', empty: '空',
|
||||
@@ -230,6 +238,8 @@ window.MAESTRO_I18N = {
|
||||
agentSection: 'Exécutions agent', agentEmpty: 'Aucun agent en cours', running: 'RUNNING', since: '',
|
||||
projRunning: 'En cours', projPaused: 'En pause', projBlocked: 'Bloqué', projIdle: 'Inactif',
|
||||
projPendingTip: '{n} en attente de votre revue',
|
||||
projAgentsTip: '{n} agents en cours',
|
||||
projBlockedTip: '{n} bloquées',
|
||||
gAgents: 'Agents globaux', gAgentsDetail: '{p}/{P} projets · {a} agents',
|
||||
apActive: 'actifs', apRuns: 'exécutions', apTokens: 'tokens', apWeek: 'cette semaine', apCost: 'coût', apTotal: 'Tous les projets', apPerProj: 'PAR PROJET', apIdle: 'inactif', apMore: '+{n} de plus', apCollapse: 'Réduire',
|
||||
apDetail: 'Détails', apDetailTitle: "Détail d'usage", apByDay: 'Par jour', apByWeek: 'Par semaine', apByMonth: 'Par mois', apColProject: 'Projet', apColTasks: 'Tâches', apColModels: 'Modèles', apEmpty: "Aucune donnée d'usage",
|
||||
@@ -249,7 +259,7 @@ window.MAESTRO_I18N = {
|
||||
complexity: 'Complexité', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(racine)', priority: 'Priorité',
|
||||
p0: 'P0 · haute', p1: 'P1 · moyenne', p2: 'P2 · basse', create: 'Créer la tâche', cancel: 'Annuler',
|
||||
attachLabel: 'Pièces jointes · images/fichiers (facultatif)', dropHint: 'Déposez des fichiers ici ou collez une capture', removeFile: 'Retirer', pasteHint: '⌘V pour coller des images',
|
||||
attSection: 'Pièces jointes', attDownload: 'Télécharger', attDeleteConfirm: 'Supprimer la pièce jointe « {name} » ? Action irréversible.', attDeleted: 'Pièce jointe supprimée', attDeleteFailed: 'Échec de la suppression : ',
|
||||
attSection: 'Pièces jointes', attDownload: 'Télécharger', attDeleteConfirm: 'Supprimer la pièce jointe « {name} » ? Action irréversible.', attDeleted: 'Pièce jointe supprimée', attDeleteFailed: 'Échec de la suppression : ', attTooLarge: 'Le fichier dépasse 25 Mo et a été ignoré', attUploaded: 'Pièces jointes envoyées · {n}', attDeduped: ' ({n} doublons ignorés)',
|
||||
depsWait: 'deps', specLabel: 'SPEC DES CHANGEMENTS', opsLabel: 'OPÉRATIONS PRÉVUES',
|
||||
pendingDoc: 'En attente de la sortie de Claude Code (via MCP, puis revue)',
|
||||
emptyTasks: 'Aucune tâche — « + Nouvelle tâche » ou via MCP', empty: 'VIDE',
|
||||
|
||||
+39
-9
@@ -4,7 +4,7 @@ import { Store, StoreError, type ActiveRun, type PatchProjectInput, type PatchTa
|
||||
import type { Complexity } from '../model/complexity.js';
|
||||
import { isComplexity } from '../model/complexity.js';
|
||||
import type { TaskStatus } from '../model/status.js';
|
||||
import type { Project, Autonomy } from '../model/types.js';
|
||||
import type { Project, Autonomy, Attachment } from '../model/types.js';
|
||||
import { syncProject, hasTodoJson } from '../sync/todo-sync.js';
|
||||
import { resolvedExecutorModels } from '../executor/models.js';
|
||||
import { classifyComplexity, type ClassifierFn } from '../executor/classify.js';
|
||||
@@ -12,7 +12,8 @@ import { acceptAndMerge } from '../executor/exec-merge.js';
|
||||
import { createWorktree, git } from '../executor/worktree.js';
|
||||
import { resolveLogo, LOGO_MIME } from './logo.js';
|
||||
import { readTranscript, TranscriptError } from '../executor/transcript.js';
|
||||
import { createReadStream, createWriteStream, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { transcriptDir } from '../executor/cc.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, basename, resolve, sep } from 'node:path';
|
||||
@@ -343,14 +344,40 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
if (!store.getTask(id)) return reply.code(404).send({ error: `任务不存在: ${id}` });
|
||||
const dir = join(dataDir(), 'tasks', id, 'attachments');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const saved: Array<{ name: string; type: string; path: string }> = [];
|
||||
let seq = 0;
|
||||
const saved: Attachment[] = [];
|
||||
for await (const part of req.files()) {
|
||||
// 无名/无扩展名(如剪贴板粘贴 blob)→ 按 mimetype 生成安全文件名+扩展名
|
||||
const safe = safeAttachmentName(part.filename, part.mimetype, seq++);
|
||||
await pipeline(part.file, createWriteStream(join(dir, safe)));
|
||||
if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename || safe} 超过 25MB 上限` });
|
||||
saved.push({ name: part.filename || safe, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` });
|
||||
// 内容寻址:先流式写临时文件并同步计算 sha256,再按 hash 重命名为最终磁盘名。
|
||||
// 磁盘名完全由内容 hash 决定(不再用文件名):相同内容天然单份、不同内容永不撞名,
|
||||
// 且十六进制 sha256 不含 `/`、`..`、脚本可控字符,从根上杜绝路径穿越/撞名覆盖。
|
||||
const ext = extFromMime(part.mimetype) || (hasExtension(part.filename ?? '')
|
||||
? (part.filename as string).slice((part.filename as string).lastIndexOf('.') + 1).replace(/[^A-Za-z0-9]/g, '')
|
||||
: '');
|
||||
const tmp = join(dir, `.tmp-${randomUUID()}`);
|
||||
const hash = createHash('sha256');
|
||||
let size = 0;
|
||||
try {
|
||||
await pipeline(part.file, async function* (src) {
|
||||
for await (const c of src) { hash.update(c as Buffer); size += (c as Buffer).length; yield c; }
|
||||
}, createWriteStream(tmp));
|
||||
} catch (e) {
|
||||
rmSync(tmp, { force: true });
|
||||
throw e;
|
||||
}
|
||||
// 超限:清掉临时文件,绝不留垃圾;@fastify/multipart 在超过 fileSize 时置 truncated。
|
||||
if (part.file.truncated) {
|
||||
rmSync(tmp, { force: true });
|
||||
return reply.code(400).send({ error: `文件 ${part.filename || 'attachment'} 超过 25MB 上限` });
|
||||
}
|
||||
const digest = hash.digest('hex');
|
||||
const finalName = ext ? `${digest}.${ext}` : digest;
|
||||
const finalAbs = join(dir, finalName);
|
||||
// 相同内容已存在 → 丢弃临时文件(真去重);否则原子 rename 同目录就位。
|
||||
if (existsSync(finalAbs)) rmSync(tmp, { force: true });
|
||||
else renameSync(tmp, finalAbs);
|
||||
saved.push({
|
||||
name: part.filename || finalName, type: part.mimetype,
|
||||
path: `tasks/${id}/attachments/${finalName}`, hash: digest, size,
|
||||
});
|
||||
}
|
||||
if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' });
|
||||
return { attachments: store.addAttachments(id, saved) };
|
||||
@@ -392,6 +419,9 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
});
|
||||
|
||||
// 删除单个附件:先删元数据(拿到 removed),再尽力删磁盘文件
|
||||
// 前提:附件目录按 task 隔离(tasks/<id>/attachments/),内容寻址后同一 hash 在一个 task 内
|
||||
// 去重为一条元数据 → 删元数据即可安全删盘,无需跨任务引用计数。
|
||||
// ⚠️ 若将来改为跨任务共享存储(同一 <sha256> 文件被多任务引用),必须引入 refcount,本次不做。
|
||||
app.delete('/api/tasks/:id/attachments/:name', (req, reply) => {
|
||||
const { id, name } = req.params as { id: string; name: string };
|
||||
if (!store.getTask(id)) return reply.code(404).send({ error: `任务不存在: ${id}` });
|
||||
|
||||
+4
-2
@@ -114,9 +114,11 @@ export interface Task {
|
||||
|
||||
/** 任务附件:图片/文件随任务提交,落盘于 <MAESTRO_DATA_DIR>/tasks/<taskId>/attachments/ */
|
||||
export interface Attachment {
|
||||
name: string; // 原始文件名
|
||||
name: string; // 原始文件名(展示用)
|
||||
type: string; // MIME 类型(如 image/png)
|
||||
path: string; // 相对 data 根的存储路径(tasks/<taskId>/attachments/<name>)
|
||||
path: string; // 相对 data 根的存储路径,内容寻址:tasks/<taskId>/attachments/<sha256>.<ext>
|
||||
hash?: string; // 内容 sha256(hex)——去重键;老数据无此字段,回退按 path 去重
|
||||
size?: number; // 字节数;老数据可能缺省
|
||||
}
|
||||
|
||||
export type RunKind = 'planner' | 'executor' | 'reviewer' | 'security';
|
||||
|
||||
+6
-4
@@ -420,10 +420,12 @@ export class Store {
|
||||
const t = this.getTaskRow(taskId);
|
||||
if (!t) throw new StoreError(`任务不存在: ${taskId}`);
|
||||
const cur: Attachment[] = t.attachments ? JSON.parse(t.attachments) as Attachment[] : [];
|
||||
// 按 path 去重(later-wins):path 唯一对应一份磁盘文件,重传同名文件只保留最新一条元数据。
|
||||
const byPath = new Map<string, Attachment>();
|
||||
for (const a of [...cur, ...items]) byPath.set(a.path, a);
|
||||
const next = [...byPath.values()];
|
||||
// 按内容 hash 去重(later-wins):磁盘文件内容寻址,相同内容=同一份磁盘文件;
|
||||
// 重传相同内容(即便改名)只保留最新一条元数据(更新展示名/type)。老数据无 hash → 回退按 path 去重。
|
||||
const keyOf = (a: Attachment) => a.hash ?? a.path;
|
||||
const byKey = new Map<string, Attachment>();
|
||||
for (const a of [...cur, ...items]) byKey.set(keyOf(a), a);
|
||||
const next = [...byKey.values()];
|
||||
this.db.prepare(`UPDATE tasks SET attachments = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(next), now(), taskId);
|
||||
this.emit(t.project_id, taskId, 'task.updated', { field: 'attachments', count: next.length });
|
||||
return next;
|
||||
|
||||
+98
-26
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store/index.js';
|
||||
@@ -30,20 +30,46 @@ function placeFile(dir: string, taskId: string, name: string, mime: string, body
|
||||
|
||||
// ───────────────────────── Store 层 ─────────────────────────
|
||||
|
||||
test('addAttachments:按 path 去重(later-wins)', () => {
|
||||
test('addAttachments:按内容 hash 去重(later-wins,同内容不同名只一条)', () => {
|
||||
const { store, taskId } = freshTask();
|
||||
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', path: `tasks/${taskId}/attachments/a.png` }]);
|
||||
// 重传同一 path(safeName 相同)→ 元数据只保留一条,且取最新 name/type
|
||||
const hA = 'a'.repeat(64);
|
||||
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', hash: hA, size: 3, path: `tasks/${taskId}/attachments/${hA}.png` }]);
|
||||
// 重传相同内容(hash 相同)但改名 → 元数据只保留一条,且取最新 name/type
|
||||
const hB = 'b'.repeat(64);
|
||||
const next = store.addAttachments(taskId, [
|
||||
{ name: 'a-renamed.png', type: 'image/jpeg', path: `tasks/${taskId}/attachments/a.png` },
|
||||
{ name: 'b.pdf', type: 'application/pdf', path: `tasks/${taskId}/attachments/b.pdf` },
|
||||
{ name: 'a-renamed.png', type: 'image/jpeg', hash: hA, size: 3, path: `tasks/${taskId}/attachments/${hA}.png` },
|
||||
{ name: 'b.pdf', type: 'application/pdf', hash: hB, size: 7, path: `tasks/${taskId}/attachments/${hB}.pdf` },
|
||||
]);
|
||||
assert.equal(next.length, 2);
|
||||
const a = next.find((x) => x.path.endsWith('/a.png'));
|
||||
const a = next.find((x) => x.hash === hA);
|
||||
assert.equal(a?.name, 'a-renamed.png');
|
||||
assert.equal(a?.type, 'image/jpeg');
|
||||
// 插入顺序保持:a 在前、b 在后
|
||||
assert.deepEqual(next.map((x) => x.path.split('/').pop()), ['a.png', 'b.pdf']);
|
||||
// 插入顺序保持:A 在前、B 在后
|
||||
assert.deepEqual(next.map((x) => x.hash), [hA, hB]);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('addAttachments:不同内容(hash 不同)即便同显示名也各保留一条(不再静默覆盖丢数据)', () => {
|
||||
const { store, taskId } = freshTask();
|
||||
const h1 = '1'.repeat(64);
|
||||
const h2 = '2'.repeat(64);
|
||||
const next = store.addAttachments(taskId, [
|
||||
{ name: 'screenshot.png', type: 'image/png', hash: h1, size: 3, path: `tasks/${taskId}/attachments/${h1}.png` },
|
||||
{ name: 'screenshot.png', type: 'image/png', hash: h2, size: 3, path: `tasks/${taskId}/attachments/${h2}.png` },
|
||||
]);
|
||||
assert.equal(next.length, 2, '不同内容同名应各保留一条');
|
||||
assert.deepEqual(next.map((x) => x.hash), [h1, h2]);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('addAttachments:老数据无 hash 时回退按 path 去重', () => {
|
||||
const { store, taskId } = freshTask();
|
||||
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', path: `tasks/${taskId}/attachments/a.png` }]);
|
||||
const next = store.addAttachments(taskId, [
|
||||
{ name: 'a-renamed.png', type: 'image/jpeg', path: `tasks/${taskId}/attachments/a.png` },
|
||||
]);
|
||||
assert.equal(next.length, 1);
|
||||
assert.equal(next[0].name, 'a-renamed.png');
|
||||
store.close();
|
||||
});
|
||||
|
||||
@@ -198,38 +224,84 @@ test('DELETE:元数据在但磁盘文件已被外部删除 → 仍成功清元
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('上传→去重端到端:同名文件连传两次,元数据只一条', async () => {
|
||||
function multipart(filename: string, content: string): { payload: Buffer; headers: Record<string, string> } {
|
||||
const boundary = '----maestrotest';
|
||||
const body =
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="files"; filename="${filename}"\r\n` +
|
||||
`Content-Type: image/png\r\n\r\n` +
|
||||
content + `\r\n` +
|
||||
`--${boundary}--\r\n`;
|
||||
return { payload: Buffer.from(body), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
|
||||
}
|
||||
|
||||
test('上传→内容去重端到端:同内容不同名连传 → 元数据 1 条、磁盘 1 份 <sha256>.png', async () => {
|
||||
const dir = tmpData();
|
||||
process.env.MAESTRO_DATA_DIR = dir;
|
||||
const { store, taskId } = freshTask();
|
||||
const app = buildServer({ store });
|
||||
|
||||
function multipart(filename: string, content: string): { payload: Buffer; headers: Record<string, string> } {
|
||||
const boundary = '----maestrotest';
|
||||
const body =
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="files"; filename="${filename}"\r\n` +
|
||||
`Content-Type: image/png\r\n\r\n` +
|
||||
content + `\r\n` +
|
||||
`--${boundary}--\r\n`;
|
||||
return { payload: Buffer.from(body), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
|
||||
}
|
||||
|
||||
// 同内容('aaa'),但文件名不同 → 内容寻址应去重为一条、磁盘一份
|
||||
const m1 = multipart('shot.png', 'aaa');
|
||||
const up1 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m1.payload, headers: m1.headers });
|
||||
assert.equal(up1.statusCode, 200);
|
||||
assert.equal(up1.json().attachments.length, 1);
|
||||
|
||||
const m2 = multipart('shot.png', 'bbb');
|
||||
const m2 = multipart('shot-renamed.png', 'aaa');
|
||||
const up2 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m2.payload, headers: m2.headers });
|
||||
assert.equal(up2.statusCode, 200);
|
||||
// 同名 safeName → path 相同 → 去重为一条
|
||||
const atts = up2.json().attachments;
|
||||
assert.equal(atts.length, 1, '同名重传应去重为一条');
|
||||
assert.equal(atts.length, 1, '同内容重传应去重为一条');
|
||||
|
||||
// 通过 GET /api/tasks/:id 复核
|
||||
// 磁盘上只有 1 个 <sha256>.png(外加可能的 .tmp-* 已清理)
|
||||
const attDir = join(dir, 'tasks', taskId, 'attachments');
|
||||
const files = readdirSync(attDir).filter((f) => !f.startsWith('.tmp-'));
|
||||
assert.equal(files.length, 1, '磁盘应只有一份内容文件');
|
||||
assert.match(files[0], /^[0-9a-f]{64}\.png$/, '磁盘名应为 <sha256>.png');
|
||||
|
||||
// GET /api/tasks/:id 复核:1 条,且含 hash(64 hex)/size 字段
|
||||
const t = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}` });
|
||||
assert.equal(t.json().attachments.length, 1);
|
||||
const list = t.json().attachments;
|
||||
assert.equal(list.length, 1);
|
||||
assert.match(list[0].hash, /^[0-9a-f]{64}$/);
|
||||
assert.equal(list[0].size, 3);
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('上传→内容寻址端到端:不同内容同显示名连传 → 元数据 2 条、磁盘 2 份(旧的覆盖丢数据 bug 已消除)', async () => {
|
||||
const dir = tmpData();
|
||||
process.env.MAESTRO_DATA_DIR = dir;
|
||||
const { store, taskId } = freshTask();
|
||||
const app = buildServer({ store });
|
||||
|
||||
const m1 = multipart('screenshot.png', 'aaa');
|
||||
await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m1.payload, headers: m1.headers });
|
||||
const m2 = multipart('screenshot.png', 'bbb'); // 同名、不同内容
|
||||
const up2 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m2.payload, headers: m2.headers });
|
||||
assert.equal(up2.statusCode, 200);
|
||||
assert.equal(up2.json().attachments.length, 2, '不同内容同名应保留两条');
|
||||
|
||||
const attDir = join(dir, 'tasks', taskId, 'attachments');
|
||||
const files = readdirSync(attDir).filter((f) => !f.startsWith('.tmp-'));
|
||||
assert.equal(files.length, 2, '磁盘应有两份内容文件,不再静默覆盖');
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('上传:响应附件含 hash(64 hex) 与 size 字段', async () => {
|
||||
const dir = tmpData();
|
||||
process.env.MAESTRO_DATA_DIR = dir;
|
||||
const { store, taskId } = freshTask();
|
||||
const app = buildServer({ store });
|
||||
const m = multipart('pic.png', 'hello-bytes');
|
||||
const up = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m.payload, headers: m.headers });
|
||||
assert.equal(up.statusCode, 200);
|
||||
const att = up.json().attachments[0];
|
||||
assert.match(att.hash, /^[0-9a-f]{64}$/);
|
||||
assert.equal(att.size, 'hello-bytes'.length);
|
||||
// 磁盘名 = path 末段 = <hash>.png
|
||||
assert.equal(att.path, `tasks/${taskId}/attachments/${att.hash}.png`);
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user