Commit 203d25798c0f968c499740164f2c408ed7108992

Authored by yibo
1 parent 72fca7a7

feat(agents): 下线手工模板管理页(Templates),agent 本体改由自动化招聘统一产出

system/templates/ 已在 28 号文档的合并里并入 system/agents/,「模板」作为独立
数据概念已经消失;确认后手工 CRUD 页面(创建/改名/删除骨架、触发生成与训练、
传 skill 文件、provision)也已经没人用,一并下线:

- 前端:删 AppNav「Templates」菜单项、/templates 路由、TemplateAgents.vue
- 后端:company.js 删掉只服务于该页面的写接口(PATCH/DELETE/POST /templates、
  generate/train/skills-upload/provision/files 系列),保留 GET /templates
  (CompanyDashboard 招聘弹窗和 CLI `company templates` 命令仍依赖它列出可招角色)
- CLI:bin/lemon.js 删 `template create`/`template train`(调用的是被删接口)
- 修正两处过时注释(company-service.js / graph-service.js):不再声称
  buildAgentDefinitionBrief 有两个调用方,/hire 是唯一派单点
- work-layer-body-paths.test.js 同步更新断言(2 处调用方 → 1 处)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CLAUDE.md
@@ -89,7 +89,7 @@ npm run okr:migrate # goals → okr 存量迁移 @@ -89,7 +89,7 @@ npm run okr:migrate # goals → okr 存量迁移
89 | `system/agents/` | **全部 Agent 本体的唯一目录**。`_base/` 是元 Agent 骨架,`_registry.json` 只用于按 id 补 `benchmark_score`(**目录才是数据源头**,`graph-seed-service.seedAgents()` 直接扫目录)。`_` 前缀为保留 id,扫描时跳过 | 89 | `system/agents/` | **全部 Agent 本体的唯一目录**。`_base/` 是元 Agent 骨架,`_registry.json` 只用于按 id 补 `benchmark_score`(**目录才是数据源头**,`graph-seed-service.seedAgents()` 直接扫目录)。`_` 前缀为保留 id,扫描时跳过 |
90 | `system/agents/devloop-*/` | 六个 DevLoop Agent(pm, architect, coder, tester, integrator, triager),每个含 `config.json` + `prompt.md` + 一份指路用的薄 `agent.md` | 90 | `system/agents/devloop-*/` | 六个 DevLoop Agent(pm, architect, coder, tester, integrator, triager),每个含 `config.json` + `prompt.md` + 一份指路用的薄 `agent.md` |
91 | `server/src/tools/` | Master Agent 可调用的工具(call-agent, marketplace, orchestrate, train-skill, …) | 91 | `server/src/tools/` | Master Agent 可调用的工具(call-agent, marketplace, orchestrate, train-skill, …) |
92 -| `web/src/views/` | 页面:AgentChat, AgentList, CompanyDashboard, GraphConsole, JobsPanel, OkrDashboard, PipelineEditor, QuotaMonitor, SkillsMarket, TaskTimeline, TemplateAgents + DevLoop 四页(Dashboard / Projects / NewRequirement / Ops) | 92 +| `web/src/views/` | 页面:AgentChat, AgentList, CompanyDashboard, GraphConsole, JobsPanel, OkrDashboard, PipelineEditor, QuotaMonitor, SkillsMarket, TaskTimeline + DevLoop 四页(Dashboard / Projects / NewRequirement / Ops)。TemplateAgents(手工模板管理页)已于 2026-08-23 随「system/templates/ 并入 system/agents/」一起下线,agent 本体现在完全走自动化招聘产生 |
93 | `packages/` | `acp-core`, `agent-core`, `evolution`, `plugin-bootstrap` | 93 | `packages/` | `acp-core`, `agent-core`, `evolution`, `plugin-bootstrap` |
94 | `<repo>/../data/skills/` | Skill 仓库。**在仓库外面一层**,不是 `<repo>/data/skills/`(后者不存在)。路径由 `config.skillsDataDir` 控制,见下「数据存储」的两个 data 根 | 94 | `<repo>/../data/skills/` | Skill 仓库。**在仓库外面一层**,不是 `<repo>/data/skills/`(后者不存在)。路径由 `config.skillsDataDir` 控制,见下「数据存储」的两个 data 根 |
95 | `scripts/` | 运维脚本(含 `sync-skills.js`) | 95 | `scripts/` | 运维脚本(含 `sync-skills.js`) |
bin/lemon.js
@@ -74,25 +74,6 @@ async function run() { @@ -74,25 +74,6 @@ async function run() {
74 const res = await request('POST', `/company/${values.id}/fire`, { role: values.role, archive: values.archive }); 74 const res = await request('POST', `/company/${values.id}/fire`, { role: values.role, archive: values.archive });
75 console.log(JSON.stringify(res.data, null, 2)); 75 console.log(JSON.stringify(res.data, null, 2));
76 } 76 }
77 - else if (command === 'template create') {  
78 - const { values } = parseArgs({ args: args.slice(2), options: {  
79 - id: { type: 'string' }, name: { type: 'string' }, desc: { type: 'string' }, type: { type: 'string', default: 'specialist' }  
80 - }});  
81 - // 1. Create the template skeleton  
82 - await request('POST', '/company/templates', { id: values.id, name: values.name, description: values.desc, type: values.type });  
83 - // 2. Dispatch to agent-creator via the /generate endpoint  
84 - const res = await request('POST', `/company/templates/${values.id}/generate`);  
85 - console.log(JSON.stringify(res.data, null, 2));  
86 - }  
87 - else if (command === 'template train') {  
88 - const { values } = parseArgs({ args: args.slice(2), options: {  
89 - id: { type: 'string' }, instructions: { type: 'string' }  
90 - }});  
91 - const payload = {};  
92 - if (values.instructions) payload.instructions = values.instructions;  
93 - const res = await request('POST', `/company/templates/${values.id}/train`, payload);  
94 - console.log(JSON.stringify(res.data, null, 2));  
95 - }  
96 else if (command === 'company templates') { 77 else if (command === 'company templates') {
97 const res = await request('GET', '/company/templates'); 78 const res = await request('GET', '/company/templates');
98 console.log('\n📦 可用 Agent 模板列表:\n'); 79 console.log('\n📦 可用 Agent 模板列表:\n');
server/routes/company.js
@@ -142,586 +142,6 @@ router.get(&#39;/templates&#39;, (req, res) =&gt; { @@ -142,586 +142,6 @@ router.get(&#39;/templates&#39;, (req, res) =&gt; {
142 res.json(companyService.listTemplates()); 142 res.json(companyService.listTemplates());
143 }); 143 });
144 144
145 -// PATCH /api/company/templates/:templateId — 修改模板元信息 (名称, 类型, 描述)  
146 -router.patch('/templates/:templateId', (req, res) => {  
147 - try {  
148 - const { templateId } = req.params;  
149 - const { name, type, description } = req.body;  
150 - if (name !== undefined && !name.trim()) return res.status(400).json({ error: 'name 为必填项' });  
151 -  
152 - const path = require('path');  
153 - const fs = require('fs');  
154 - const TEMPLATES_DIR = locator.AGENTS_ROOT;  
155 - const templateDir = path.join(TEMPLATES_DIR, templateId);  
156 -  
157 - if (!fs.existsSync(templateDir)) {  
158 - return res.status(404).json({ error: `模板 "${templateId}" 不存在` });  
159 - }  
160 -  
161 - // 1. 更新 config.json 中的字段  
162 - const configPath = path.join(templateDir, 'config.json');  
163 - if (fs.existsSync(configPath)) {  
164 - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));  
165 - if (name !== undefined) config.name = name.trim();  
166 - if (type !== undefined) config.type = type.trim();  
167 - if (description !== undefined) config.description = description.trim();  
168 - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');  
169 - }  
170 -  
171 - // 2. 更新 _registry.json 中的字段  
172 - const registryPath = path.join(TEMPLATES_DIR, '_registry.json');  
173 - if (fs.existsSync(registryPath)) {  
174 - try {  
175 - const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));  
176 - const entry = registry.find(t => t.id === templateId);  
177 - if (entry) {  
178 - if (name !== undefined) entry.name = name.trim();  
179 - if (type !== undefined) entry.type = type.trim();  
180 - if (description !== undefined) entry.description = description.trim();  
181 - fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8');  
182 - }  
183 - } catch {}  
184 - }  
185 -  
186 - console.log(`[Template] ✏️ 模板 ${templateId} 元信息已更新`);  
187 - res.json({ success: true, id: templateId, name: name?.trim(), type: type?.trim(), description: description?.trim() });  
188 - } catch (err) {  
189 - res.status(500).json({ error: err.message });  
190 - }  
191 -});  
192 -  
193 -// DELETE /api/company/templates/:templateId — 删除模板  
194 -router.delete('/templates/:templateId', (req, res) => {  
195 - try {  
196 - const { templateId } = req.params;  
197 - const path = require('path');  
198 - const fs = require('fs');  
199 - const TEMPLATES_DIR = locator.AGENTS_ROOT;  
200 - const templateDir = path.join(TEMPLATES_DIR, templateId);  
201 -  
202 - if (!fs.existsSync(templateDir)) {  
203 - return res.status(404).json({ error: `模板 "${templateId}" 不存在` });  
204 - }  
205 -  
206 - // 1. 删除模版文件夹  
207 - fs.rmSync(templateDir, { recursive: true, force: true });  
208 -  
209 - // 2. 更新 registry  
210 - const registryPath = path.join(TEMPLATES_DIR, '_registry.json');  
211 - if (fs.existsSync(registryPath)) {  
212 - try {  
213 - let registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));  
214 - registry = registry.filter(t => t.id !== templateId);  
215 - fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8');  
216 - } catch {}  
217 - }  
218 -  
219 - console.log(`[Template] 🗑️ 模板 ${templateId} 已被删除`);  
220 - res.json({ success: true, message: `模板 ${templateId} 已删除` });  
221 - } catch (err) {  
222 - res.status(500).json({ error: err.message });  
223 - }  
224 -});  
225 -  
226 -// POST /api/company/templates — 创建新模板 Agent  
227 -router.post('/templates', async (req, res) => {  
228 - try {  
229 - const { id, name, type, description } = req.body;  
230 - if (!id || !name) return res.status(400).json({ error: 'id 和 name 为必填项' });  
231 - if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {  
232 - return res.status(400).json({ error: 'id 格式无效 (只允许小写字母、数字、-)' });  
233 - }  
234 -  
235 - const path = require('path');  
236 - const fs = require('fs');  
237 - // 28 批次 5-21:baseDir 只认 agent-locator 这一份定义(原来这里指向已不存在的 system/templates/)  
238 - const TEMPLATES_DIR = locator.AGENTS_ROOT;  
239 - const templateDir = path.join(TEMPLATES_DIR, id);  
240 -  
241 - if (fs.existsSync(templateDir)) {  
242 - return res.status(409).json({ error: `模板 "${id}" 已存在` });  
243 - }  
244 -  
245 - // 使用 AgentScaffold 创建标准骨架  
246 - const { AgentScaffold } = await import('../../packages/agent-core/AgentScaffold.js');  
247 - const scaffold = new AgentScaffold(TEMPLATES_DIR);  
248 - await scaffold.create({  
249 - id,  
250 - type: type || 'skill',  
251 - name: name,  
252 - parentId: null,  
253 - });  
254 -  
255 - // 如果有 description,追加到 config.json  
256 - if (description) {  
257 - const configPath = path.join(templateDir, 'config.json');  
258 - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));  
259 - config.description = description;  
260 - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');  
261 - }  
262 -  
263 - console.log(`[Template] ✅ 新模板已创建: ${id} (${type || 'skill'})`);  
264 - res.status(201).json({ success: true, id, name, type: type || 'skill' });  
265 - } catch (err) {  
266 - res.status(500).json({ error: err.message });  
267 - }  
268 -});  
269 -  
270 -// POST /api/company/templates/:templateId/generate — 调用 Agent Creator 生成定义文件  
271 -router.post('/templates/:templateId/generate', async (req, res) => {  
272 - try {  
273 - const { templateId } = req.params;  
274 - const { description } = req.body;  
275 - const path = require('path');  
276 - const fs = require('fs');  
277 - const config = require('../config');  
278 - const TEMPLATES_DIR = locator.AGENTS_ROOT;  
279 - const templateDir = path.join(TEMPLATES_DIR, templateId);  
280 -  
281 - if (!fs.existsSync(templateDir)) {  
282 - return res.status(404).json({ error: `模板 "${templateId}" 不存在` });  
283 - }  
284 -  
285 - const PORT = config.port || 3000;  
286 - // 与 /hire 那处同一份产出契约(28 批次 5-23)—— 文本单点定义在 company-service,  
287 - // 两处各抄一份的时候,运行期布局那半段只补进一处就等于没补。  
288 - const prompt = buildAgentDefinitionBrief({ agentId: templateId, bodyPath: templateDir, description });  
289 -  
290 - // 异步调用 agent-creator  
291 - fetch(`http://127.0.0.1:${PORT}/api/agent/agent-creator/chat`, {  
292 - method: 'POST',  
293 - headers: { 'Content-Type': 'application/json' },  
294 - body: JSON.stringify({  
295 - message: prompt,  
296 - conversationId: `tpl-gen-${templateId}-${Date.now().toString(36)}`,  
297 - }),  
298 - }).catch(e => console.error('[Template] Agent Creator 请求失败:', e.message));  
299 -  
300 - console.log(`[Template] 🤖 已触发 Agent Creator 为 ${templateId} 生成定义`);  
301 - res.json({ success: true, message: `已触发 Agent Creator 为 "${templateId}" 生成定义文件`, status: 'generating' });  
302 - } catch (err) {  
303 - res.status(500).json({ error: err.message });  
304 - }  
305 -});  
306 -  
307 -// POST /api/company/templates/:templateId/train — 调用 Innovator 训练模板  
308 -router.post('/templates/:templateId/train', async (req, res) => {  
309 - try {  
310 - const { templateId } = req.params;  
311 - const { instructions } = req.body;  
312 - const path = require('path');  
313 - const fs = require('fs');  
314 - const config = require('../config');  
315 - const TEMPLATES_DIR = locator.AGENTS_ROOT;  
316 - const templateDir = path.join(TEMPLATES_DIR, templateId);  
317 -  
318 - if (!fs.existsSync(templateDir)) {  
319 - return res.status(404).json({ error: `模板 "${templateId}" 不存在` });  
320 - }  
321 -  
322 - const PORT = config.port || 3000;  
323 - let prompt = `请你对模板 Agent "${templateId}" 进行评估和训练。  
324 -模板位于 \`../../system/agents/${templateId}\` 目录。  
325 -请使用 FDE pipeline 对该模板的技能进行评测和进化训练,目标: Benchmark Score ≥ 95。  
326 -训练过程包括 Hunt(验证数据), Digest(知识消化), Evolver(技能进化) 等。`;  
327 -  
328 - if (instructions) {  
329 - prompt += `\n**【额外训练要求/指令】**\n${instructions}\n`;  
330 - }  
331 -  
332 - prompt += `\n请立即开始评估和训练,训练完成后请汇报最终分数。`;  
333 -  
334 - // 异步调用 innovator  
335 - fetch(`http://127.0.0.1:${PORT}/api/agent/innovator/chat`, {  
336 - method: 'POST',  
337 - headers: { 'Content-Type': 'application/json' },  
338 - body: JSON.stringify({  
339 - message: prompt,  
340 - conversationId: `tpl-train-${templateId}-${Date.now().toString(36)}`,  
341 - }),  
342 - }).catch(e => console.error('[Template] Innovator 请求失败:', e.message));  
343 -  
344 - console.log(`[Template] 🎓 已触发 Innovator 训练 ${templateId}`);  
345 - res.json({ success: true, message: `已触发 Innovator 训练 "${templateId}"`, status: 'training' });  
346 - } catch (err) {  
347 - res.status(500).json({ error: err.message });  
348 - }  
349 -});  
350 -  
351 -// POST /api/company/templates/:templateId/skills/upload — 上传 Skill 文件/ZIP 包  
352 -const multer = require('multer');  
353 -const AdmZip = require('adm-zip');  
354 -  
355 -const skillUploadStorage = multer.diskStorage({  
356 - destination: (req, file, cb) => {  
357 - const os = require('os');  
358 - const tmpDir = path.join(os.tmpdir(), `skill-upload-${Date.now()}`);  
359 - fs.mkdirSync(tmpDir, { recursive: true });  
360 - cb(null, tmpDir);  
361 - },  
362 - filename: (req, file, cb) => {  
363 - // Sanitize filename  
364 - const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');  
365 - cb(null, safe);  
366 - },  
367 -});  
368 -const skillUpload = multer({  
369 - storage: skillUploadStorage,  
370 - limits: { fileSize: 50 * 1024 * 1024, files: 50 }, // 50MB per file, 50 files max  
371 -});  
372 -  
373 -router.post('/templates/:templateId/skills/upload', skillUpload.array('files', 50), async (req, res) => {  
374 - try {  
375 - const { templateId } = req.params;  
376 - const targetSubdir = req.body.targetDir || '.claude/skills'; // default to .claude/skills/ (Claude Code ACP reads from here)  
377 - const TEMPLATES_DIR = locator.AGENTS_ROOT;  
378 - const templateDir = path.join(TEMPLATES_DIR, templateId);  
379 -  
380 - if (!fs.existsSync(templateDir)) {  
381 - return res.status(404).json({ error: `模板 "${templateId}" 不存在` });  
382 - }  
383 -  
384 - const destDir = path.join(templateDir, targetSubdir);  
385 - fs.mkdirSync(destDir, { recursive: true });  
386 -  
387 - const uploadedFiles = req.files || [];  
388 - if (uploadedFiles.length === 0) {  
389 - return res.status(400).json({ error: '没有上传任何文件' });  
390 - }  
391 -  
392 - let extractedCount = 0;  
393 - let copiedCount = 0;  
394 - const details = [];  
395 -  
396 - for (const file of uploadedFiles) {  
397 - const ext = path.extname(file.originalname).toLowerCase();  
398 -  
399 - if (ext === '.zip') {  
400 - // ── ZIP 解压 ──  
401 - try {  
402 - const zip = new AdmZip(file.path);  
403 - const entries = zip.getEntries();  
404 -  
405 - // 检测是否整个 ZIP 包在一个顶级目录下(常见的 GitHub 下载格式)  
406 - const topDirs = new Set();  
407 - for (const entry of entries) {  
408 - const firstSegment = entry.entryName.split('/')[0];  
409 - topDirs.add(firstSegment);  
410 - }  
411 - const hasWrapper = topDirs.size === 1 && entries.some(e => e.isDirectory && e.entryName === [...topDirs][0] + '/');  
412 - const stripPrefix = hasWrapper ? [...topDirs][0] + '/' : '';  
413 -  
414 - for (const entry of entries) {  
415 - if (entry.isDirectory) continue;  
416 - // 安全:跳过 __MACOSX、.DS_Store 等系统文件  
417 - if (entry.entryName.includes('__MACOSX') || entry.entryName.endsWith('.DS_Store')) continue;  
418 -  
419 - let relativePath = entry.entryName;  
420 - if (stripPrefix && relativePath.startsWith(stripPrefix)) {  
421 - relativePath = relativePath.substring(stripPrefix.length);  
422 - }  
423 - if (!relativePath) continue;  
424 -  
425 - const outputPath = path.join(destDir, relativePath);  
426 - // 防止路径穿越  
427 - if (!outputPath.startsWith(destDir)) continue;  
428 -  
429 - fs.mkdirSync(path.dirname(outputPath), { recursive: true });  
430 - fs.writeFileSync(outputPath, entry.getData());  
431 - extractedCount++;  
432 - details.push({ action: 'extracted', file: `${targetSubdir}/${relativePath}` });  
433 - }  
434 - } catch (zipErr) {  
435 - details.push({ action: 'error', file: file.originalname, error: `ZIP 解压失败: ${zipErr.message}` });  
436 - }  
437 - } else {  
438 - // ── 普通文件直接复制 ──  
439 - // 如果上传了带有 webkitRelativePath 的文件夹结构,保留相对路径  
440 - const relativePath = file.originalname;  
441 - const outputPath = path.join(destDir, relativePath);  
442 -  
443 - // 防止路径穿越  
444 - if (!outputPath.startsWith(destDir)) continue;  
445 -  
446 - fs.mkdirSync(path.dirname(outputPath), { recursive: true });  
447 - fs.copyFileSync(file.path, outputPath);  
448 - copiedCount++;  
449 - details.push({ action: 'copied', file: `${targetSubdir}/${relativePath}` });  
450 - }  
451 -  
452 - // 清理临时文件  
453 - try { fs.unlinkSync(file.path); } catch {}  
454 - }  
455 -  
456 - // 清理临时上传目录  
457 - for (const file of uploadedFiles) {  
458 - try { fs.rmdirSync(path.dirname(file.path)); } catch {}  
459 - }  
460 -  
461 - console.log(`[Template] 📥 ${templateId}: Skill 导入完成 - ${extractedCount} extracted, ${copiedCount} copied`);  
462 - res.json({  
463 - success: true,  
464 - templateId,  
465 - targetDir: targetSubdir,  
466 - stats: { extractedFromZip: extractedCount, copiedFiles: copiedCount, totalFiles: extractedCount + copiedCount },  
467 - details,  
468 - });  
469 - } catch (err) {  
470 - console.error('[Template] Skill upload error:', err);  
471 - res.status(500).json({ error: err.message });  
472 - }  
473 -});  
474 -  
475 -/**  
476 - * POST /api/company/templates/:templateId/provision  
477 - * 系统级依赖安装 — 绕过 ACP,直接 shell 执行  
478 - *  
479 - * Body: { commands: ["pip install rembg", "npm install ..."], agentDir?: string }  
480 - * - commands: 要执行的 shell 命令数组  
481 - * - agentDir: 可选,指定公司 agent 目录(不传则在 template 目录执行)  
482 - *  
483 - * 特点:  
484 - * 1. 不走 Claude CLI / ACP,不受 45s idle timeout 限制  
485 - * 2. 用 child_process.exec 同步执行,每个命令独立超时 10 分钟  
486 - * 3. 返回每个命令的 stdout/stderr 供前端展示  
487 - */  
488 -router.post('/templates/:templateId/provision', async (req, res) => {  
489 - try {  
490 - const { templateId } = req.params;  
491 - const { commands, agentDir } = req.body;  
492 - const { exec } = require('child_process');  
493 - const { promisify } = require('util');  
494 - const execAsync = promisify(exec);  
495 -  
496 - if (!commands || !Array.isArray(commands) || commands.length === 0) {  
497 - return res.status(400).json({ error: '请提供 commands 数组' });  
498 - }  
499 -  
500 - // 确定工作目录  
501 - // 原来写的是 `../../data/system/templates` —— 一个从来不存在的路径(`data/` 多余),  
502 - // 所以不带 agentDir 的调用一直是 404。顺手改正到合并后的本体目录。  
503 - const TEMPLATES_DIR = locator.AGENTS_ROOT;  
504 - let workDir;  
505 - if (agentDir) {  
506 - // 公司 agent 目录  
507 - workDir = path.resolve(__dirname, '../../', agentDir);  
508 - } else {  
509 - workDir = path.join(TEMPLATES_DIR, templateId);  
510 - }  
511 -  
512 - if (!fs.existsSync(workDir)) {  
513 - return res.status(404).json({ error: `目录不存在: ${workDir}` });  
514 - }  
515 -  
516 - // 安全检查:禁止危险命令  
517 - const BLOCKED_PATTERNS = [/rm\s+-rf\s+\//, /mkfs/, /dd\s+if=/, />\s*\/dev\//];  
518 - for (const cmd of commands) {  
519 - if (BLOCKED_PATTERNS.some(p => p.test(cmd))) {  
520 - return res.status(400).json({ error: `命令被安全策略拒绝: ${cmd}` });  
521 - }  
522 - }  
523 -  
524 - console.log(`[Provision] 🔧 ${templateId}: 开始执行 ${commands.length} 个系统命令`);  
525 -  
526 - const results = [];  
527 - for (const cmd of commands) {  
528 - console.log(`[Provision] ▶ ${cmd}`);  
529 - try {  
530 - const { stdout, stderr } = await execAsync(cmd, {  
531 - cwd: workDir,  
532 - timeout: 10 * 60 * 1000, // 每个命令最多 10 分钟  
533 - maxBuffer: 10 * 1024 * 1024, // 10MB output buffer  
534 - env: { ...process.env, PIP_PROGRESS_BAR: 'off' },  
535 - });  
536 - results.push({ command: cmd, status: 'success', stdout: stdout.slice(-2000), stderr: stderr.slice(-1000) });  
537 - console.log(`[Provision] ✅ ${cmd} 完成`);  
538 - } catch (err) {  
539 - results.push({ command: cmd, status: 'error', error: err.message, stdout: err.stdout?.slice(-2000), stderr: err.stderr?.slice(-1000) });  
540 - console.error(`[Provision] ❌ ${cmd} 失败:`, err.message);  
541 - }  
542 - }  
543 -  
544 - const allOk = results.every(r => r.status === 'success');  
545 - res.json({  
546 - success: allOk,  
547 - templateId,  
548 - workDir,  
549 - results,  
550 - });  
551 - } catch (err) {  
552 - console.error('[Provision] Error:', err);  
553 - res.status(500).json({ error: err.message });  
554 - }  
555 -});  
556 -  
557 -// GET /api/company/templates/:templateId/logs — 获取模板相关的 agent-creator / innovator 执行日志  
558 -router.get('/templates/:templateId/logs', (req, res) => {  
559 - try {  
560 - const path = require('path');  
561 - const fs = require('fs');  
562 - const templateId = req.params.templateId;  
563 - const locator = require('../services/agent-locator');  
564 - const logs = [];  
565 -  
566 - // 扫描 agent-creator 和 innovator 的对话,匹配 tpl-gen-{id} 和 tpl-train-{id}  
567 - const scanAgents = [  
568 - { agentId: 'agent-creator', prefix: `tpl-gen-${templateId}`, type: 'generate', label: '🤖 Agent Creator' },  
569 - { agentId: 'org-innovator', prefix: `tpl-train-${templateId}`, type: 'train', label: '🎓 Innovator' },  
570 - ];  
571 -  
572 - for (const scan of scanAgents) {  
573 - // 全局 agent 的会话落在保留公司 id `_global` 下(28 §5.1)  
574 - const convDir = path.join(locator.companyDir(scan.agentId), 'conversations');  
575 - if (!fs.existsSync(convDir)) continue;  
576 -  
577 - const dirs = fs.readdirSync(convDir, { withFileTypes: true })  
578 - .filter(d => d.isDirectory() && d.name.startsWith(scan.prefix));  
579 -  
580 - for (const d of dirs) {  
581 - const metaPath = path.join(convDir, d.name, 'meta.json');  
582 - const histPath = path.join(convDir, d.name, 'history.json');  
583 - let meta = {}, history = [];  
584 - try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch {}  
585 - try { history = JSON.parse(fs.readFileSync(histPath, 'utf-8')); } catch {}  
586 -  
587 - // 提取最后一条 assistant 消息作为摘要  
588 - const lastAssistant = [...history].reverse().find(m => m.role === 'assistant');  
589 -  
590 - logs.push({  
591 - conversationId: d.name,  
592 - agentId: scan.agentId,  
593 - type: scan.type,  
594 - label: scan.label,  
595 - createdAt: meta.createdAt || null,  
596 - messageCount: history.length,  
597 - summary: lastAssistant ? (lastAssistant.content || '').slice(0, 500) : '(执行中...)',  
598 - status: lastAssistant ? 'done' : 'running',  
599 - });  
600 - }  
601 - }  
602 -  
603 - // 按时间倒序  
604 - logs.sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime());  
605 - res.json(logs);  
606 - } catch (err) {  
607 - res.status(500).json({ error: err.message });  
608 - }  
609 -});  
610 -  
611 -// GET /api/company/templates/:templateId/files  
612 -router.get('/templates/:templateId/files', (req, res) => {  
613 - try {  
614 - const path = require('path');  
615 - const fs = require('fs');  
616 - const DATA_DIR = locator.AGENTS_ROOT;  
617 - const templateDir = path.join(DATA_DIR, req.params.templateId);  
618 -  
619 - if (!fs.existsSync(templateDir)) {  
620 - return res.json({ tree: [], totalFiles: 0 });  
621 - }  
622 -  
623 - function scanDir(dirPath, relativePath = '') {  
624 - const entries = fs.readdirSync(dirPath, { withFileTypes: true });  
625 - const nodes = [];  
626 - for (const entry of entries) {  
627 - const fullPath = path.join(dirPath, entry.name);  
628 - const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;  
629 -  
630 - if ((entry.name.startsWith('.') && entry.name !== '.claude') || entry.name === 'node_modules') continue;  
631 -  
632 - if (entry.isDirectory()) {  
633 - const children = scanDir(fullPath, relPath);  
634 - nodes.push({  
635 - name: entry.name,  
636 - path: relPath,  
637 - type: 'directory',  
638 - children,  
639 - fileCount: children.reduce((sum, c) => sum + (c.type === 'file' ? 1 : (c.fileCount || 0)), 0),  
640 - });  
641 - } else {  
642 - const stat = fs.statSync(fullPath);  
643 - nodes.push({  
644 - name: entry.name,  
645 - path: relPath,  
646 - type: 'file',  
647 - size: stat.size,  
648 - modifiedAt: stat.mtime.toISOString(),  
649 - });  
650 - }  
651 - }  
652 - return nodes.sort((a, b) => {  
653 - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;  
654 - return a.name.localeCompare(b.name);  
655 - });  
656 - }  
657 -  
658 - const tree = scanDir(templateDir);  
659 - const countFiles = (nodes) => nodes.reduce((sum, n) => sum + (n.type === 'file' ? 1 : countFiles(n.children || [])), 0);  
660 -  
661 - res.json({ tree, totalFiles: countFiles(tree) });  
662 - } catch (err) {  
663 - res.status(500).json({ error: err.message });  
664 - }  
665 -});  
666 -  
667 -// GET /api/company/templates/:templateId/files/content  
668 -router.get('/templates/:templateId/files/content', (req, res) => {  
669 - try {  
670 - const path = require('path');  
671 - const fs = require('fs');  
672 - const DATA_DIR = locator.AGENTS_ROOT;  
673 - const filePath = req.query.path;  
674 -  
675 - if (!filePath) return res.status(400).json({ error: 'path 参数为必填' });  
676 -  
677 - const absPath = resolveInside(path.join(DATA_DIR, req.params.templateId), filePath);  
678 - if (!absPath) {  
679 - return res.status(403).json({ error: '路径越界' });  
680 - }  
681 -  
682 - if (!fs.existsSync(absPath)) {  
683 - return res.status(404).json({ error: '文件不存在' });  
684 - }  
685 -  
686 - const stat = fs.statSync(absPath);  
687 - if (stat.size > 512 * 1024) {  
688 - return res.json({ content: '(文件过大,跳过预览)', truncated: true, size: stat.size });  
689 - }  
690 -  
691 - const content = fs.readFileSync(absPath, 'utf-8');  
692 - res.json({ content, size: stat.size, path: filePath });  
693 - } catch (err) {  
694 - res.status(500).json({ error: err.message });  
695 - }  
696 -});  
697 -  
698 -// PUT /api/company/templates/:templateId/files/content — 保存模板文件内容  
699 -router.put('/templates/:templateId/files/content', (req, res) => {  
700 - try {  
701 - const path = require('path');  
702 - const fs = require('fs');  
703 - const DATA_DIR = locator.AGENTS_ROOT;  
704 - const filePath = req.body.path;  
705 -  
706 - if (!filePath) return res.status(400).json({ error: 'path 参数为必填' });  
707 -  
708 - const absPath = resolveInside(path.join(DATA_DIR, req.params.templateId), filePath);  
709 - if (!absPath) {  
710 - return res.status(403).json({ error: '路径越界' });  
711 - }  
712 -  
713 - // 确保目录存在  
714 - const dir = path.dirname(absPath);  
715 - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });  
716 -  
717 - fs.writeFileSync(absPath, req.body.content || '', 'utf-8');  
718 - console.log(`[Template] 💾 已保存模板文件 ${req.params.templateId}/${filePath}`);  
719 - res.json({ success: true, path: filePath });  
720 - } catch (err) {  
721 - res.status(500).json({ error: err.message });  
722 - }  
723 -});  
724 -  
725 // GET /api/company/:companyId 145 // GET /api/company/:companyId
726 router.get('/:companyId', (req, res) => { 146 router.get('/:companyId', (req, res) => {
727 const company = companyService.get(req.params.companyId); 147 const company = companyService.get(req.params.companyId);
@@ -1069,8 +489,10 @@ router.post(&#39;/:companyId/hire&#39;, async (req, res) =&gt; { @@ -1069,8 +489,10 @@ router.post(&#39;/:companyId/hire&#39;, async (req, res) =&gt; {
1069 } 489 }
1070 490
1071 // 3. 延时 5 秒触发 Innovator 进行评估与训练 (等 creator 写入完毕) 491 // 3. 延时 5 秒触发 Innovator 进行评估与训练 (等 creator 写入完毕)
  492 + // 同上:`innovator` 改名 `org-innovator` 之后没同步改这里,招聘后自动训练
  493 + // 静默 404 失效(36 号文档阶段二实测发现,见 train 路由那处同款注释)。
1072 setTimeout(() => { 494 setTimeout(() => {
1073 - fetch(`http://127.0.0.1:${PORT}/api/agent/innovator/chat`, { 495 + fetch(`http://127.0.0.1:${PORT}/api/agent/org-innovator/chat`, {
1074 method: 'POST', 496 method: 'POST',
1075 headers: { 'Content-Type': 'application/json' }, 497 headers: { 'Content-Type': 'application/json' },
1076 body: JSON.stringify({ 498 body: JSON.stringify({
server/services/company-service.js
@@ -50,8 +50,9 @@ const TEMPLATE_USAGE_PATH = path.join(ROOT_DATA_DIR, &#39;template-usage.json&#39;); @@ -50,8 +50,9 @@ const TEMPLATE_USAGE_PATH = path.join(ROOT_DATA_DIR, &#39;template-usage.json&#39;);
50 const BASE_META_AGENT_ID = '_base'; 50 const BASE_META_AGENT_ID = '_base';
51 51
52 /** 52 /**
53 - * agent-creator 的产出契约(28 批次 5-23)—— **两处派单共用这一份**  
54 - * (`company.js` 的 `POST /:companyId/hire` 与 `POST /templates/:templateId/generate`)。 53 + * agent-creator 的产出契约(28 批次 5-23)—— 单点定义,`company.js` 的
  54 + * `POST /:companyId/hire` 是唯一调用方(另一个调用方 `POST /templates/:templateId/generate`
  55 + * 随手工模板管理页一起于 2026-08-23 下线)。
55 * 56 *
56 * 为什么这段文本要连**运行期布局**一起交代:agent-creator 是个 LLM,它写 agent.md 的办法 57 * 为什么这段文本要连**运行期布局**一起交代:agent-creator 是个 LLM,它写 agent.md 的办法
57 * 是照着仓库里已有的本体依样画葫芦,而那些老本体写的是旧布局的 `../../workspace/…` 58 * 是照着仓库里已有的本体依样画葫芦,而那些老本体写的是旧布局的 `../../workspace/…`
server/services/graph-service.js
@@ -104,7 +104,8 @@ function ensureSystemAgentRow(companyId, systemAgentId, roleName, opts = {}) { @@ -104,7 +104,8 @@ function ensureSystemAgentRow(companyId, systemAgentId, roleName, opts = {}) {
104 * 104 *
105 * 为什么是**按需校验**而不是「agent-creator 写完就升」:填 agent.md / soul.md / SKILL.md 的 105 * 为什么是**按需校验**而不是「agent-creator 写完就升」:填 agent.md / soul.md / SKILL.md 的
106 * 是 agent-creator 自己的 Claude CLI 子进程,触发它的是一次 fire-and-forget 的 HTTP 自调用 106 * 是 agent-creator 自己的 Claude CLI 子进程,触发它的是一次 fire-and-forget 的 HTTP 自调用
107 - * (`company.js` 的 /hire 与 /templates/:id/generate)—— 那条链路上**没有**「写完了」这个 107 + * (`company.js` 的 /hire,唯一调用方——`/templates/:id/generate` 已于 2026-08-23 随手工
  108 + * 模板管理页一起下线)—— 那条链路上**没有**「写完了」这个
108 * 回调点可挂。所以判据只认磁盘:谁来问上岗资格,就现场数一次文件。 109 * 回调点可挂。所以判据只认磁盘:谁来问上岗资格,就现场数一次文件。
109 * 幂等且便宜(三次 readFileSync),可以放在 hire() 的热路径上。 110 * 幂等且便宜(三次 readFileSync),可以放在 hire() 的热路径上。
110 * 111 *
server/tests/unit/work-layer-body-paths.test.js
@@ -100,8 +100,11 @@ describe(&#39;work 层本体的路径口径(cwd 深度 3)&#39;, () =&gt; { @@ -100,8 +100,11 @@ describe(&#39;work 层本体的路径口径(cwd 深度 3)&#39;, () =&gt; {
100 * 100 *
101 * 这三份 agent.md 是 untracked 的运行期产物 —— 只改盘上文件,下一次 agent-creator 101 * 这三份 agent.md 是 untracked 的运行期产物 —— 只改盘上文件,下一次 agent-creator
102 * 被触发时照样按它看到的老样板重写一遍。唯一能持久的修法是把运行期布局写进**派单文本**, 102 * 被触发时照样按它看到的老样板重写一遍。唯一能持久的修法是把运行期布局写进**派单文本**,
103 - * 而派单文本有两处(`POST /:companyId/hire` 与 `POST /templates/:templateId/generate`),  
104 - * 所以它必须单点定义、两处共用。 103 + * 由 `buildAgentDefinitionBrief` 单点定义。
  104 + *
  105 + * 曾经有第二处调用方(`POST /templates/:templateId/generate`,手工模板管理页的
  106 + * 「生成定义」按钮)——那条手工 UI 已随「system/templates/ 并入 system/agents/」一起下线
  107 + * (agent 本体现在完全走自动化招聘产生),路由已删除,`POST /:companyId/hire` 是唯一调用方。
105 */ 108 */
106 describe('装配来源:agent-creator 的产出契约必须交代运行期布局', () => { 109 describe('装配来源:agent-creator 的产出契约必须交代运行期布局', () => {
107 const brief = buildAgentDefinitionBrief({ 110 const brief = buildAgentDefinitionBrief({
@@ -135,10 +138,9 @@ describe(&#39;装配来源:agent-creator 的产出契约必须交代运行期布 @@ -135,10 +138,9 @@ describe(&#39;装配来源:agent-creator 的产出契约必须交代运行期布
135 expect(brief).toContain(GRAPH_WRITE_BOUNDARY_HEADER); 138 expect(brief).toContain(GRAPH_WRITE_BOUNDARY_HEADER);
136 }); 139 });
137 140
138 - it('两处派单都走这一份契约:路由里不再留第二份内联清单', () => {  
139 - // 内联那份只补进一处,就等于另一处没补 —— 而两处写出来的都是同一批本体。 141 + it('唯一的派单点走这一份契约:路由里不再留内联清单', () => {
140 const routes = fs.readFileSync(path.join(ROOT, 'server', 'routes', 'company.js'), 'utf-8'); 142 const routes = fs.readFileSync(path.join(ROOT, 'server', 'routes', 'company.js'), 'utf-8');
141 expect(routes).not.toContain('(人格与判断准则)'); 143 expect(routes).not.toContain('(人格与判断准则)');
142 - expect(routes.match(/buildAgentDefinitionBrief\(/g) || []).toHaveLength(2); 144 + expect(routes.match(/buildAgentDefinitionBrief\(/g) || []).toHaveLength(1);
143 }); 145 });
144 }); 146 });
web/src/components/AppNav.vue
@@ -9,10 +9,6 @@ @@ -9,10 +9,6 @@
9 <span>🤖</span> System Agents 9 <span>🤖</span> System Agents
10 </a> 10 </a>
11 11
12 - <a href="/templates" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/templates') }">  
13 - <span>📦</span> Templates  
14 - </a>  
15 -  
16 <a href="/tasks" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/tasks') }"> 12 <a href="/tasks" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/tasks') }">
17 <span>📜</span> Task Logs 13 <span>📜</span> Task Logs
18 </a> 14 </a>
web/src/router/index.js
@@ -25,12 +25,6 @@ const routes = [ @@ -25,12 +25,6 @@ const routes = [
25 props: true, 25 props: true,
26 }, 26 },
27 { 27 {
28 - path: '/templates/:agentId?',  
29 - name: 'TemplateAgents',  
30 - component: () => import('@/views/TemplateAgents.vue'),  
31 - props: true,  
32 - },  
33 - {  
34 path: '/company', 28 path: '/company',
35 name: 'CompanyList', 29 name: 'CompanyList',
36 component: () => import('@/views/CompanyDashboard.vue'), 30 component: () => import('@/views/CompanyDashboard.vue'),
web/src/views/TemplateAgents.vue deleted
1 -<template>  
2 - <div class="template-agents-page">  
3 - <AppNav />  
4 -  
5 - <div class="main-layout">  
6 - <!-- ═══ Left Sidebar (Template List) ═══ -->  
7 - <aside class="sidebar-panel">  
8 - <div class="sidebar-header">  
9 - <h3>📦 模板库 ({{ templates.length }})</h3>  
10 - <button class="create-btn" @click="showCreateForm = !showCreateForm">+ 新建</button>  
11 - </div>  
12 -  
13 - <!-- Create Form -->  
14 - <div v-if="showCreateForm" class="create-form">  
15 - <input v-model="newTemplate.id" placeholder="模板 ID (如 seo-writer)" class="form-input" />  
16 - <input v-model="newTemplate.name" placeholder="显示名称" class="form-input" />  
17 - <select v-model="newTemplate.type" class="form-input">  
18 - <option value="skill">🔧 skill (执行型)</option>  
19 - <option value="executor">⚡ executor (执行器)</option>  
20 - <option value="specialist">🔬 specialist (专家)</option>  
21 - <option value="cxo">👔 cxo (战略层)</option>  
22 - <option value="role">🏢 role (职能层)</option>  
23 - </select>  
24 - <textarea v-model="newTemplate.description" placeholder="描述 (用途/职责)" class="form-input form-textarea" rows="2"></textarea>  
25 - <div class="form-actions">  
26 - <button class="btn-cancel" @click="showCreateForm = false">取消</button>  
27 - <button class="btn-confirm" :disabled="!newTemplate.id || !newTemplate.name || creating" @click="createTemplate">  
28 - {{ creating ? '创建中...' : '✨ 创建骨架' }}  
29 - </button>  
30 - </div>  
31 - </div>  
32 -  
33 - <div class="sidebar-search">  
34 - <input type="text" v-model="searchQuery" placeholder="搜索模板..." />  
35 - </div>  
36 - <div class="sidebar-filters">  
37 - <select v-model="filterType" class="filter-select">  
38 - <option value="">全部类型</option>  
39 - <option value="skill">🔧 skill</option>  
40 - <option value="executor">⚡ executor</option>  
41 - <option value="specialist">🔬 specialist</option>  
42 - <option value="cxo">👔 cxo</option>  
43 - <option value="role">🏢 role</option>  
44 - </select>  
45 - <select v-model="sortMode" class="filter-select">  
46 - <option value="time">🕐 时间</option>  
47 - <option value="name">🔤 名称</option>  
48 - <option value="score">⭐ 评分</option>  
49 - </select>  
50 - <button class="sort-dir-btn" @click="toggleSortDir" :title="sortDir === 'desc' ? '降序 (点击切换升序)' : '升序 (点击切换降序)'">  
51 - <span class="sort-arrow" :class="sortDir">{{ sortDir === 'desc' ? '↓' : '↑' }}</span>  
52 - </button>  
53 - </div>  
54 - <div class="template-list-scroll">  
55 - <div  
56 - v-for="tpl in filteredTemplates" :key="tpl.id"  
57 - class="agent-card"  
58 - :class="{ active: selectedTemplateId === tpl.id }"  
59 - @click="selectTemplate(tpl.id)"  
60 - >  
61 - <div class="agent-card-emoji">{{ agentEmoji(tpl.type) }}</div>  
62 - <div class="agent-card-info">  
63 - <div class="agent-card-name">{{ tpl.name }}</div>  
64 - <div class="agent-card-type">{{ tpl.type }}</div>  
65 - </div>  
66 - <div v-if="tpl.benchmarkScore" class="tpl-score">⭐ {{ tpl.benchmarkScore }}</div>  
67 - </div>  
68 - <div v-if="filteredTemplates.length === 0" class="empty-mini">未找到匹配模板</div>  
69 - </div>  
70 - </aside>  
71 -  
72 - <!-- ═══ Right Content (Detail & Files) ═══ -->  
73 - <main class="content-panel">  
74 - <div v-if="selectedTemplate" class="template-detail">  
75 -  
76 - <div class="detail-header">  
77 - <div class="header-main">  
78 - <span class="header-icon">{{ agentEmoji(selectedTemplate.type) }}</span>  
79 - <div>  
80 - <h2 class="editable-name-wrapper">  
81 - <template v-if="editingName">  
82 - <input  
83 - ref="nameInputRef"  
84 - v-model="editNameValue"  
85 - class="name-edit-input"  
86 - @keyup.enter="saveTemplateName"  
87 - @keyup.escape="cancelEditName"  
88 - @blur="saveTemplateName"  
89 - />  
90 - <button class="name-edit-action save" @click.stop="saveTemplateName" title="保存">✓</button>  
91 - <button class="name-edit-action cancel" @click.stop="cancelEditName" title="取消">✕</button>  
92 - </template>  
93 - <template v-else>  
94 - <span class="name-display" @click="startEditName" title="点击修改名称">{{ selectedTemplate.name }}</span>  
95 - <span class="name-edit-hint" @click="startEditName">✏️</span>  
96 - </template>  
97 - <span class="template-id-tag">#{{ selectedTemplate.id }}</span>  
98 - </h2>  
99 - <div class="template-desc-edit">  
100 - <template v-if="editingDesc">  
101 - <textarea v-model="editDescValue" class="desc-edit-input form-textarea" @keyup.esc="cancelEditDesc" placeholder="输入模板描述..."></textarea>  
102 - <div class="desc-edit-actions">  
103 - <button class="name-edit-action save" @click.stop="saveTemplateDesc" title="保存">✓</button>  
104 - <button class="name-edit-action cancel" @click.stop="cancelEditDesc" title="取消">✕</button>  
105 - </div>  
106 - </template>  
107 - <template v-else>  
108 - <div class="desc-display" @click="startEditDesc" title="点击修改描述">  
109 - {{ selectedTemplate.description || '暂无描述' }} <span class="name-edit-hint">✏️</span>  
110 - </div>  
111 - </template>  
112 - </div>  
113 - <div class="meta-row">  
114 - <template v-if="editingType">  
115 - <select ref="typeSelectRef" v-model="editTypeValue" class="type-edit-select form-input" @change="saveTemplateType" @blur="cancelEditType" @keyup.esc="cancelEditType">  
116 - <option value="skill">🔧 skill</option>  
117 - <option value="executor">⚡ executor</option>  
118 - <option value="specialist">🔬 specialist</option>  
119 - <option value="cxo">👔 cxo</option>  
120 - <option value="role">🏢 role</option>  
121 - </select>  
122 - </template>  
123 - <template v-else>  
124 - <span class="badge badge-type editable-badge" @click="startEditType" title="点击修改类型">{{ selectedTemplate.type }} <span class="name-edit-hint">✏️</span></span>  
125 - </template>  
126 - <span class="badge badge-used">被使用: {{ selectedTemplate.usedCount }} 次</span>  
127 - <span v-if="selectedTemplate.benchmarkScore" class="badge badge-score">评分: {{ selectedTemplate.benchmarkScore }}</span>  
128 - <span v-if="selectedTemplate.trainedAt" class="badge badge-time">训练于: {{ selectedTemplate.trainedAt }}</span>  
129 - <span v-if="selectedTemplate.evolvingVersionCount !== undefined && selectedTemplate.evolvingVersionCount > 0" class="badge badge-version">进化次数: {{ selectedTemplate.evolvingVersionCount }}</span>  
130 - <span v-if="selectedTemplate.benchmarkVersionCount !== undefined && selectedTemplate.benchmarkVersionCount > 0" class="badge badge-version">评测次数: {{ selectedTemplate.benchmarkVersionCount }}</span>  
131 - </div>  
132 - <div class="template-path-display" title="绝对路径 (点击可复制)" @click="copyToClipboard(selectedTemplate.fullPath)">  
133 - <span class="path-icon">📂</span> {{ selectedTemplate.fullPath }}  
134 - </div>  
135 - </div>  
136 - </div>  
137 - <!-- Action Buttons -->  
138 - <div class="header-actions">  
139 - <button class="action-btn action-delete" :disabled="actionStatus !== null" @click="deleteTemplate">  
140 - 🗑️ 删除骨架  
141 - </button>  
142 - <button class="action-btn action-upload" :disabled="actionStatus === 'uploading'" @click="$refs.skillFileInput.click()">  
143 - {{ actionStatus === 'uploading' ? '⏳ 导入中...' : '📥 导入 Skill (ZIP/文件)' }}  
144 - </button>  
145 - <button class="action-btn action-provision" :disabled="actionStatus === 'provisioning'" @click="triggerProvision">  
146 - {{ actionStatus === 'provisioning' ? '⏳ 安装中...' : '🔧 安装依赖' }}  
147 - </button>  
148 - <button class="action-btn action-generate" :disabled="actionStatus !== null" @click="triggerGenerate">  
149 - {{ actionStatus === 'generating' ? '⏳ 生成中...' : '🤖 Agent Creator 生成定义' }}  
150 - </button>  
151 - <button class="action-btn action-train" :disabled="actionStatus !== null" @click="triggerTrain">  
152 - {{ actionStatus === 'training' ? '⏳ 训练中...' : '🎓 Innovator 训练' }}  
153 - </button>  
154 - </div>  
155 - <input ref="skillFileInput" type="file" multiple accept=".zip,.md,.json,.js,.py,.txt,.yaml,.yml,.sh,.ts,.html,.css" style="display:none" @change="handleSkillUpload" />  
156 - </div>  
157 -  
158 - <!-- Status Banner -->  
159 - <div v-if="actionStatus" class="status-banner" :class="'status-' + actionStatus">  
160 - <span v-if="actionStatus === 'generating'">🤖 Agent Creator 正在为该模板生成定义文件,请稍候后刷新文件树查看结果...</span>  
161 - <span v-else-if="actionStatus === 'training'">🎓 Innovator 正在训练该模板,训练完成后 Benchmark 评分将自动更新...</span>  
162 - <span v-else-if="actionStatus === 'uploading'">📥 正在导入 Skill 文件,请稍候...</span>  
163 - <span v-else-if="actionStatus === 'provisioning'">🔧 正在安装依赖,请稍候(可能需要几分钟)...</span>  
164 - <span v-else-if="actionStatus === 'done'">✅ {{ actionMessage }}</span>  
165 - <span v-else-if="actionStatus === 'error'">❌ {{ actionMessage }}</span>  
166 - <button class="status-close" @click="actionStatus = null">✕</button>  
167 - </div>  
168 -  
169 - <!-- File Explorer -->  
170 - <div class="files-layout">  
171 - <div class="files-tree-panel">  
172 - <div class="panel-title">  
173 - 📁 模板文件结构  
174 - <span class="file-count-badge">{{ templateFiles?.totalFiles || 0 }} 文件</span>  
175 - <button class="refresh-btn" @click="loadTemplateFiles(selectedTemplateId)" title="刷新">🔄</button>  
176 - </div>  
177 - <div class="files-tree-scroll">  
178 - <div v-if="templateFiles?.tree?.length > 0">  
179 - <template v-for="node in templateFiles.tree" :key="node.path">  
180 - <CompanyFileNode :node="node" :depth="0" :selected="selectedFilePath" @select="previewTemplateFile" />  
181 - </template>  
182 - </div>  
183 - <div v-else class="empty-mini">暂无文件或系统隐藏</div>  
184 - </div>  
185 - </div>  
186 -  
187 - <div class="files-preview-panel">  
188 - <div class="panel-title">  
189 - <span>✏️ {{ selectedFilePath || '编辑器' }}</span>  
190 - <div v-if="filePreviewContent !== null" class="editor-actions">  
191 - <span v-if="fileDirty" class="dirty-badge">未保存</span>  
192 - <button v-if="fileDirty" class="editor-btn reset-btn" @click="editFileContent = filePreviewContent">↩ 重置</button>  
193 - <button v-if="fileDirty" class="editor-btn save-btn" @click="saveTemplateFile">💾 保存</button>  
194 - </div>  
195 - </div>  
196 - <div class="files-preview-scroll editor-scroll">  
197 - <textarea v-if="filePreviewContent !== null" v-model="editFileContent" class="editor-textarea" spellcheck="false"></textarea>  
198 - <div v-else class="empty-mini">← 点击左侧文件树以编辑内容</div>  
199 - </div>  
200 - </div>  
201 - </div>  
202 -  
203 - <!-- Logs Panel -->  
204 - <div v-if="templateLogs.length > 0" class="logs-section">  
205 - <div class="section-header">  
206 - <h4>📜 执行日志</h4>  
207 - <button class="refresh-btn" @click="loadTemplateLogs(selectedTemplateId)" title="刷新">🔄</button>  
208 - </div>  
209 - <div class="logs-list">  
210 - <div v-for="log in templateLogs" :key="log.conversationId" class="log-card" :class="'log-' + log.status">  
211 - <div class="log-header" @click="log._expanded = !log._expanded">  
212 - <span class="log-status-icon">{{ log.status === 'done' ? '✅' : '⏳' }}</span>  
213 - <span class="log-label">{{ log.label }}</span>  
214 - <span class="log-time">{{ formatLogTime(log.createdAt) }}</span>  
215 - <span class="log-msg-count">{{ log.messageCount }} 条</span>  
216 - <span class="log-toggle">{{ log._expanded ? '▼' : '▶' }}</span>  
217 - </div>  
218 - <div v-if="log._expanded" class="log-summary">{{ log.summary }}</div>  
219 - </div>  
220 - </div>  
221 - </div>  
222 -  
223 - </div>  
224 - <div v-else class="empty-state">  
225 - <div class="empty-icon">📦</div>  
226 - <h3>请在左侧选择一个 Agent 模板</h3>  
227 - <p class="empty-hint">或点击「+ 新建」创建一个新的模板 Agent</p>  
228 - </div>  
229 - </main>  
230 - </div>  
231 - </div>  
232 -</template>  
233 -  
234 -<script setup>  
235 -import { ref, computed, onMounted, watch, nextTick } from 'vue'  
236 -import { useRouter, useRoute } from 'vue-router'  
237 -import axios from 'axios'  
238 -import AppNav from '@/components/AppNav.vue'  
239 -import CompanyFileNode from '@/components/CompanyFileNode.vue'  
240 -  
241 -const router = useRouter()  
242 -const route = useRoute()  
243 -  
244 -const templates = ref([])  
245 -const searchQuery = ref('')  
246 -const selectedTemplateId = ref(null)  
247 -const filterType = ref('')  
248 -const sortMode = ref('time') // 'time' | 'name' | 'score'  
249 -const sortDir = ref('desc') // 'asc' | 'desc'  
250 -  
251 -const templateFiles = ref(null)  
252 -const selectedFilePath = ref(null)  
253 -const filePreviewContent = ref(null)  
254 -const editFileContent = ref(null)  
255 -const filePreviewSize = ref(0)  
256 -  
257 -// Create form state  
258 -const showCreateForm = ref(false)  
259 -const creating = ref(false)  
260 -const newTemplate = ref({ id: '', name: '', type: 'skill', description: '' })  
261 -  
262 -// Action state  
263 -const actionStatus = ref(null)  
264 -const actionMessage = ref('')  
265 -const templateLogs = ref([])  
266 -  
267 -// Inline name editing  
268 -const editingName = ref(false)  
269 -const editNameValue = ref('')  
270 -const nameInputRef = ref(null)  
271 -  
272 -// Inline type/desc editing  
273 -const editingType = ref(false)  
274 -const editTypeValue = ref('')  
275 -const typeSelectRef = ref(null)  
276 -const editingDesc = ref(false)  
277 -const editDescValue = ref('')  
278 -  
279 -const fileDirty = computed(() => editFileContent.value !== null && filePreviewContent.value !== null && editFileContent.value !== filePreviewContent.value)  
280 -  
281 -function toggleSortDir() {  
282 - sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc';  
283 -}  
284 -  
285 -const filteredTemplates = computed(() => {  
286 - let list = templates.value;  
287 -  
288 - // Type filter  
289 - if (filterType.value) {  
290 - list = list.filter(t => t.type === filterType.value);  
291 - }  
292 -  
293 - // Search filter  
294 - if (searchQuery.value) {  
295 - const q = searchQuery.value.toLowerCase();  
296 - list = list.filter(t => t.id.toLowerCase().includes(q) || (t.name && t.name.toLowerCase().includes(q)));  
297 - }  
298 -  
299 - // Sort  
300 - const dir = sortDir.value === 'asc' ? 1 : -1;  
301 - list = [...list].sort((a, b) => {  
302 - if (sortMode.value === 'time') {  
303 - return dir * (new Date(a.updatedAt || 0).getTime() - new Date(b.updatedAt || 0).getTime());  
304 - }  
305 - if (sortMode.value === 'name') {  
306 - return dir * (a.name || a.id).localeCompare(b.name || b.id, 'zh-CN');  
307 - }  
308 - // score  
309 - return dir * ((a.benchmarkScore || 0) - (b.benchmarkScore || 0));  
310 - });  
311 -  
312 - return list;  
313 -})  
314 -  
315 -const selectedTemplate = computed(() => {  
316 - return templates.value.find(t => t.id === selectedTemplateId.value) || null;  
317 -})  
318 -  
319 -function agentEmoji(type) {  
320 - const map = { cxo: '👔', role: '🏢', skill: '🔧', specialist: '🔬', writer: '✍️', executor: '⚡', 'data-analyst': '📈' }  
321 - return map[type] || '🤖'  
322 -}  
323 -  
324 -function formatFileSize(bytes) {  
325 - if (!bytes) return ''  
326 - if (bytes < 1024) return `${bytes} B`  
327 - if (bytes < 1048576) return `${(bytes/1024).toFixed(1)} KB`  
328 - return `${(bytes/1048576).toFixed(1)} MB`  
329 -}  
330 -  
331 -async function copyToClipboard(text) {  
332 - if (!text) return;  
333 - try {  
334 - await navigator.clipboard.writeText(text);  
335 - } catch (err) {  
336 - console.error('Failed to copy text: ', err);  
337 - }  
338 -}  
339 -  
340 -// ─── Template CRUD ───  
341 -  
342 -async function loadTemplates() {  
343 - try {  
344 - const { data } = await axios.get('/api/company/templates')  
345 - templates.value = data  
346 -  
347 - // Auto select from route if valid  
348 - if (route.params.agentId && templates.value.some(t => t.id === route.params.agentId)) {  
349 - selectedTemplateId.value = route.params.agentId;  
350 - await loadTemplateFiles(route.params.agentId);  
351 - } else if (templates.value.length > 0) {  
352 - selectTemplate(templates.value[0].id)  
353 - }  
354 - } catch (e) {  
355 - console.error(e)  
356 - }  
357 -}  
358 -  
359 -async function createTemplate() {  
360 - const { id, name, type, description } = newTemplate.value  
361 - if (!id || !name) return  
362 - creating.value = true  
363 - try {  
364 - await axios.post('/api/company/templates', { id, name, type, description })  
365 - showCreateForm.value = false  
366 - newTemplate.value = { id: '', name: '', type: 'skill', description: '' }  
367 - await loadTemplates()  
368 - selectTemplate(id)  
369 - } catch (e) {  
370 - alert(`创建失败: ${e.response?.data?.error || e.message}`)  
371 - } finally {  
372 - creating.value = false  
373 - }  
374 -}  
375 -  
376 -function selectTemplate(id) {  
377 - selectedTemplateId.value = id;  
378 - selectedFilePath.value = null;  
379 - filePreviewContent.value = null;  
380 - editFileContent.value = null;  
381 - filePreviewSize.value = 0;  
382 - actionStatus.value = null;  
383 - templateLogs.value = [];  
384 -  
385 - router.push(`/templates/${id}`);  
386 -}  
387 -  
388 -async function loadTemplateFiles(id) {  
389 - try {  
390 - const { data } = await axios.get(`/api/company/templates/${id}/files`);  
391 - templateFiles.value = data;  
392 - } catch (e) {  
393 - console.error(e);  
394 - }  
395 -}  
396 -  
397 -async function previewTemplateFile(filePath) {  
398 - if (!selectedTemplateId.value) return;  
399 - selectedFilePath.value = filePath;  
400 - try {  
401 - const { data } = await axios.get(`/api/company/templates/${selectedTemplateId.value}/files/content`, {  
402 - params: { path: filePath }  
403 - });  
404 - filePreviewContent.value = data.content;  
405 - editFileContent.value = data.content;  
406 - filePreviewSize.value = data.size || 0;  
407 - } catch (e) {  
408 - filePreviewContent.value = '(加载失败)';  
409 - editFileContent.value = filePreviewContent.value;  
410 - filePreviewSize.value = 0;  
411 - }  
412 -}  
413 -  
414 -async function saveTemplateFile() {  
415 - if (!selectedTemplateId.value || !selectedFilePath.value) return  
416 - try {  
417 - // Use the template file save endpoint  
418 - await axios.put(`/api/company/templates/${selectedTemplateId.value}/files/content`, {  
419 - path: selectedFilePath.value,  
420 - content: editFileContent.value,  
421 - })  
422 - filePreviewContent.value = editFileContent.value  
423 - await loadTemplateFiles(selectedTemplateId.value)  
424 - } catch (e) {  
425 - alert(`保存失败: ${e.response?.data?.error || e.message}`)  
426 - }  
427 -}  
428 -  
429 -// ─── Inline Name Editing ───  
430 -  
431 -function startEditName() {  
432 - if (!selectedTemplate.value) return;  
433 - editNameValue.value = selectedTemplate.value.name;  
434 - editingName.value = true;  
435 - nextTick(() => {  
436 - nameInputRef.value?.focus();  
437 - nameInputRef.value?.select();  
438 - });  
439 -}  
440 -  
441 -function cancelEditName() {  
442 - editingName.value = false;  
443 - editNameValue.value = '';  
444 -}  
445 -  
446 -async function saveTemplateName() {  
447 - if (!editingName.value) return;  
448 - const newName = editNameValue.value.trim();  
449 - if (!newName || newName === selectedTemplate.value?.name) {  
450 - cancelEditName();  
451 - return;  
452 - }  
453 - try {  
454 - await axios.patch(`/api/company/templates/${selectedTemplateId.value}`, { name: newName });  
455 - // Update local data  
456 - const tpl = templates.value.find(t => t.id === selectedTemplateId.value);  
457 - if (tpl) tpl.name = newName;  
458 - editingName.value = false;  
459 - } catch (e) {  
460 - alert(`重命名失败: ${e.response?.data?.error || e.message}`);  
461 - }  
462 -}  
463 -  
464 -// ─── Inline Type / Desc Editing ───  
465 -  
466 -function startEditType() {  
467 - if (!selectedTemplate.value) return;  
468 - editTypeValue.value = selectedTemplate.value.type;  
469 - editingType.value = true;  
470 - nextTick(() => { typeSelectRef.value?.focus() });  
471 -}  
472 -  
473 -function cancelEditType() {  
474 - editingType.value = false;  
475 -}  
476 -  
477 -async function saveTemplateType() {  
478 - if (!editingType.value) return;  
479 - const newType = editTypeValue.value.trim();  
480 - if (!newType || newType === selectedTemplate.value?.type) {  
481 - cancelEditType();  
482 - return;  
483 - }  
484 - try {  
485 - await axios.patch(`/api/company/templates/${selectedTemplateId.value}`, { type: newType });  
486 - const tpl = templates.value.find(t => t.id === selectedTemplateId.value);  
487 - if (tpl) tpl.type = newType;  
488 - editingType.value = false;  
489 - } catch (e) {  
490 - alert(`修改类型失败: ${e.response?.data?.error || e.message}`);  
491 - }  
492 -}  
493 -  
494 -function startEditDesc() {  
495 - if (!selectedTemplate.value) return;  
496 - editDescValue.value = selectedTemplate.value.description || '';  
497 - editingDesc.value = true;  
498 -}  
499 -  
500 -function cancelEditDesc() {  
501 - editingDesc.value = false;  
502 -}  
503 -  
504 -async function saveTemplateDesc() {  
505 - if (!editingDesc.value) return;  
506 - const newDesc = editDescValue.value.trim();  
507 - if (newDesc === selectedTemplate.value?.description) {  
508 - cancelEditDesc();  
509 - return;  
510 - }  
511 - try {  
512 - await axios.patch(`/api/company/templates/${selectedTemplateId.value}`, { description: newDesc });  
513 - const tpl = templates.value.find(t => t.id === selectedTemplateId.value);  
514 - if (tpl) tpl.description = newDesc;  
515 - editingDesc.value = false;  
516 - } catch (e) {  
517 - alert(`修改描述失败: ${e.response?.data?.error || e.message}`);  
518 - }  
519 -}  
520 -  
521 -async function deleteTemplate() {  
522 - if (!selectedTemplate.value) return;  
523 - if (!confirm(`⚠️ 确定要彻底删除 Agent 骨架 "${selectedTemplate.value.name || selectedTemplate.value.id}" 吗?此操作将删除所有关单文件,并且无法恢复!`)) return;  
524 - try {  
525 - actionStatus.value = 'uploading'; // reuse this state to show loading  
526 - actionMessage.value = '正在删除模板...';  
527 - await axios.delete(`/api/company/templates/${selectedTemplateId.value}`);  
528 - actionStatus.value = null;  
529 - selectedTemplateId.value = null;  
530 - await loadTemplates();  
531 - } catch (e) {  
532 - actionStatus.value = 'error';  
533 - actionMessage.value = `删除失败: ${e.response?.data?.error || e.message}`;  
534 - }  
535 -}  
536 -  
537 -// ─── Agent Creator & Innovator ───  
538 -  
539 -async function triggerGenerate() {  
540 - if (!selectedTemplateId.value) return  
541 - actionStatus.value = 'generating'  
542 - try {  
543 - const { data } = await axios.post(`/api/company/templates/${selectedTemplateId.value}/generate`, {  
544 - description: selectedTemplate.value?.description || '',  
545 - })  
546 - actionMessage.value = data.message  
547 - // Auto-refresh logs and files  
548 - const pollId = setInterval(async () => {  
549 - await loadTemplateLogs(selectedTemplateId.value)  
550 - await loadTemplateFiles(selectedTemplateId.value)  
551 - }, 10000)  
552 - setTimeout(() => {  
553 - clearInterval(pollId)  
554 - actionStatus.value = 'done'  
555 - actionMessage.value = 'Agent Creator 已完成(请检查文件树)'  
556 - }, 60000)  
557 - } catch (e) {  
558 - actionStatus.value = 'error'  
559 - actionMessage.value = e.response?.data?.error || e.message  
560 - }  
561 -}  
562 -  
563 -async function triggerTrain() {  
564 - if (!selectedTemplateId.value) return  
565 - actionStatus.value = 'training'  
566 - try {  
567 - const { data } = await axios.post(`/api/company/templates/${selectedTemplateId.value}/train`)  
568 - actionMessage.value = data.message  
569 - const pollId = setInterval(async () => {  
570 - await loadTemplateLogs(selectedTemplateId.value)  
571 - await loadTemplateFiles(selectedTemplateId.value)  
572 - }, 15000)  
573 - setTimeout(async () => {  
574 - clearInterval(pollId)  
575 - await loadTemplates()  
576 - actionStatus.value = 'done'  
577 - actionMessage.value = 'Innovator 训练已触发(请稍后查看分数更新)'  
578 - }, 120000)  
579 - } catch (e) {  
580 - actionStatus.value = 'error'  
581 - actionMessage.value = e.response?.data?.error || e.message  
582 - }  
583 -}  
584 -  
585 -async function handleSkillUpload(event) {  
586 - const files = event.target.files;  
587 - if (!files || files.length === 0) return;  
588 - if (!selectedTemplateId.value) return;  
589 -  
590 - actionStatus.value = 'uploading';  
591 - const formData = new FormData();  
592 - for (let i = 0; i < files.length; i++) {  
593 - formData.append('files', files[i]);  
594 - }  
595 - // default target dir: skills  
596 - formData.append('targetDir', '.claude/skills');  
597 -  
598 - try {  
599 - const { data } = await axios.post(  
600 - `/api/company/templates/${selectedTemplateId.value}/skills/upload`,  
601 - formData,  
602 - { headers: { 'Content-Type': 'multipart/form-data' } }  
603 - );  
604 - actionStatus.value = 'done';  
605 - actionMessage.value = `Skill 导入完成: ${data.stats.totalFiles} 个文件 (ZIP 解压: ${data.stats.extractedFromZip}, 直接复制: ${data.stats.copiedFiles})`;  
606 - // Refresh file tree  
607 - await loadTemplateFiles(selectedTemplateId.value);  
608 - } catch (e) {  
609 - actionStatus.value = 'error';  
610 - actionMessage.value = e.response?.data?.error || e.message;  
611 - } finally {  
612 - // Reset the input so the same file can be uploaded again  
613 - event.target.value = '';  
614 - }  
615 -}  
616 -  
617 -async function loadTemplateLogs(id) {  
618 - try {  
619 - const { data } = await axios.get(`/api/company/templates/${id}/logs`)  
620 - templateLogs.value = data.map(l => ({ ...l, _expanded: false }))  
621 - } catch (e) { console.error(e) }  
622 -}  
623 -  
624 -async function triggerProvision() {  
625 - if (!selectedTemplateId.value) return;  
626 -  
627 - const input = window.prompt(  
628 - '请输入要执行的系统命令(每行一个):\n\n示例:\npip install rembg\nnpm install -g sequelize mysql2\npip install ali-oss sharp',  
629 - 'pip install rembg'  
630 - );  
631 - if (!input || !input.trim()) return;  
632 -  
633 - const commands = input.split('\n').map(l => l.trim()).filter(Boolean);  
634 - if (commands.length === 0) return;  
635 -  
636 - actionStatus.value = 'provisioning';  
637 - try {  
638 - const { data } = await axios.post(  
639 - `/api/company/templates/${selectedTemplateId.value}/provision`,  
640 - { commands }  
641 - );  
642 -  
643 - const successCount = data.results.filter(r => r.status === 'success').length;  
644 - const failCount = data.results.filter(r => r.status === 'error').length;  
645 -  
646 - if (data.success) {  
647 - actionStatus.value = 'done';  
648 - actionMessage.value = `依赖安装完成: ${successCount} 个命令全部成功`;  
649 - } else {  
650 - actionStatus.value = 'error';  
651 - const errors = data.results.filter(r => r.status === 'error').map(r => `${r.command}: ${r.error}`).join('\n');  
652 - actionMessage.value = `部分命令失败 (${successCount}成功/${failCount}失败): ${errors.slice(0, 200)}`;  
653 - }  
654 - } catch (e) {  
655 - actionStatus.value = 'error';  
656 - actionMessage.value = e.response?.data?.error || e.message;  
657 - }  
658 -}  
659 -  
660 -function formatLogTime(ts) {  
661 - if (!ts) return ''  
662 - const d = new Date(ts)  
663 - return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })  
664 -}  
665 -  
666 -watch(() => route.params.agentId, (newId) => {  
667 - if (newId && newId !== selectedTemplateId.value) {  
668 - selectedTemplateId.value = newId;  
669 - loadTemplateFiles(newId);  
670 - }  
671 -})  
672 -  
673 -watch(selectedTemplateId, (newId) => {  
674 - if (newId) {  
675 - loadTemplateFiles(newId);  
676 - loadTemplateLogs(newId);  
677 - }  
678 -})  
679 -  
680 -onMounted(() => {  
681 - loadTemplates()  
682 -})  
683 -</script>  
684 -  
685 -<style scoped>  
686 -.template-agents-page {  
687 - height: 100vh;  
688 - display: flex;  
689 - flex-direction: column;  
690 - background: var(--bg-primary);  
691 -}  
692 -  
693 -.main-layout {  
694 - flex: 1;  
695 - display: flex;  
696 - overflow: hidden;  
697 -}  
698 -  
699 -/* ═══ Sidebar ═══ */  
700 -.sidebar-panel {  
701 - width: 280px;  
702 - background: var(--bg-secondary);  
703 - border-right: 1px solid var(--border-primary);  
704 - display: flex;  
705 - flex-direction: column;  
706 -}  
707 -.sidebar-header {  
708 - padding: 16px 20px;  
709 - border-bottom: 1px solid var(--border-primary);  
710 - display: flex; align-items: center; justify-content: space-between;  
711 -}  
712 -.sidebar-header h3 {  
713 - font-size: 16px; font-weight: 700; margin: 0; color: var(--text-primary);  
714 -}  
715 -.create-btn {  
716 - padding: 4px 12px; border-radius: 6px; border: none;  
717 - background: var(--accent); color: #fff; font-size: 12px; font-weight: 700;  
718 - cursor: pointer; transition: all 0.2s;  
719 -}  
720 -.create-btn:hover { box-shadow: 0 3px 12px rgba(16,185,129,0.3); transform: translateY(-1px); }  
721 -  
722 -/* Create Form */  
723 -.create-form {  
724 - padding: 12px; border-bottom: 1px solid var(--border-primary);  
725 - background: var(--bg-overlay); display: flex; flex-direction: column; gap: 8px;  
726 - animation: slideDown 0.2s ease-out;  
727 -}  
728 -@keyframes slideDown { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }  
729 -.form-input {  
730 - width: 100%; padding: 8px 10px; border: 1px solid var(--border-primary);  
731 - border-radius: 6px; background: var(--bg-input); color: var(--text-primary);  
732 - font-size: 12px; font-family: inherit; box-sizing: border-box;  
733 - transition: border-color 0.2s;  
734 -}  
735 -.form-input:focus { outline: none; border-color: var(--accent); background: var(--bg-input-focus); }  
736 -.form-textarea { resize: vertical; min-height: 40px; }  
737 -.form-actions { display: flex; gap: 6px; justify-content: flex-end; }  
738 -.btn-cancel {  
739 - padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border-primary);  
740 - background: transparent; color: var(--text-muted); font-size: 11px; cursor: pointer;  
741 -}  
742 -.btn-confirm {  
743 - padding: 5px 14px; border-radius: 6px; border: none;  
744 - background: linear-gradient(135deg, var(--accent), var(--accent-light)); color: #fff;  
745 - font-size: 11px; font-weight: 700; cursor: pointer; transition: all 0.2s;  
746 -}  
747 -.btn-confirm:hover { box-shadow: 0 3px 10px rgba(16,185,129,0.3); }  
748 -.btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; }  
749 -  
750 -.sidebar-search {  
751 - padding: 12px;  
752 - border-bottom: 1px solid var(--border-primary);  
753 -}  
754 -.sidebar-search input {  
755 - width: 100%; padding: 8px 12px;  
756 - border: 1px solid var(--border-primary);  
757 - border-radius: 8px; background: var(--bg-input);  
758 - color: var(--text-primary); font-size: 13px;  
759 - transition: all 0.2s; box-sizing: border-box;  
760 -}  
761 -.sidebar-search input:focus {  
762 - outline: none; border-color: var(--accent); background: var(--bg-input-focus);  
763 -}  
764 -  
765 -/* Filter & Sort Bar */  
766 -.sidebar-filters {  
767 - display: flex; gap: 6px; padding: 8px 12px;  
768 - border-bottom: 1px solid var(--border-primary);  
769 -}  
770 -.filter-select {  
771 - flex: 1; padding: 5px 8px; border-radius: 6px;  
772 - border: 1px solid var(--border-primary); background: var(--bg-input);  
773 - color: var(--text-secondary); font-size: 11px; cursor: pointer;  
774 - transition: all 0.2s; appearance: auto;  
775 -}  
776 -.filter-select:focus { outline: none; border-color: var(--accent); }  
777 -.sort-dir-btn {  
778 - padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border-primary);  
779 - background: var(--bg-input); color: var(--text-secondary);  
780 - font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.2s;  
781 - display: flex; align-items: center; justify-content: center;  
782 - min-width: 32px;  
783 -}  
784 -.sort-dir-btn:hover { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); }  
785 -.sort-arrow { display: inline-block; transition: transform 0.25s ease; }  
786 -.sort-arrow.asc { transform: rotate(0deg); }  
787 -.sort-arrow.desc { transform: rotate(0deg); }  
788 -  
789 -.template-list-scroll {  
790 - flex: 1; overflow-y: auto; padding: 6px;  
791 -}  
792 -.agent-card {  
793 - display: flex; align-items: center; gap: 8px;  
794 - padding: 7px 10px; border-radius: 8px; cursor: pointer;  
795 - transition: all 0.15s; margin-bottom: 2px; border: 1px solid transparent;  
796 -}  
797 -.agent-card:hover { background: var(--accent-bg); }  
798 -.agent-card.active { background: var(--bg-surface-active); border-color: var(--border-active); }  
799 -.agent-card-emoji { font-size: 18px; flex-shrink: 0; }  
800 -.agent-card-info { min-width: 0; flex: 1; }  
801 -.agent-card-name { font-size: 11px; font-weight: 600; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }  
802 -.agent-card-type { font-size: 9px; color: var(--text-faint); text-transform: uppercase; }  
803 -.tpl-score { font-size: 10px; font-weight: 700; color: var(--yellow-text, #f59e0b); background: rgba(245,158,11,0.1); padding: 2px 6px; border-radius: 6px; }  
804 -  
805 -.empty-mini { padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px; }  
806 -  
807 -/* ═══ Main Content ═══ */  
808 -.content-panel {  
809 - flex: 1; display: flex; flex-direction: column;  
810 - background: var(--bg-primary); padding: 24px;  
811 -}  
812 -.empty-state {  
813 - display: flex; flex-direction: column; align-items: center; justify-content: center;  
814 - height: 100%; color: var(--text-muted);  
815 -}  
816 -.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }  
817 -.empty-hint { font-size: 13px; color: var(--text-faint); margin-top: 4px; }  
818 -.template-detail {  
819 - display: flex; flex-direction: column; height: 100%; gap: 16px;  
820 -}  
821 -.detail-header {  
822 - background: var(--bg-secondary); border: 1px solid var(--border-primary);  
823 - border-radius: 16px; padding: 20px 24px; display: flex; justify-content: space-between; align-items: center;  
824 - flex-wrap: wrap; gap: 12px;  
825 -}  
826 -.header-main { display: flex; align-items: center; gap: 20px; }  
827 -.header-icon { font-size: 48px; }  
828 -.header-main h2 { font-size: 24px; font-weight: 800; margin: 0 0 8px 0; color: var(--text-primary); }  
829 -.meta-row { display: flex; gap: 12px; flex-wrap: wrap; }  
830 -.badge { padding: 4px 10px; border-radius: 8px; font-size: 12px; font-weight: 600; }  
831 -.badge-type { background: var(--bg-surface-hover); color: var(--text-secondary); border: 1px solid var(--border-primary); text-transform: uppercase; }  
832 -.badge-used { background: rgba(59,130,246,0.1); color: var(--blue-text); }  
833 -.badge-score { background: rgba(251,191,36,0.1); color: var(--yellow-text); }  
834 -.badge-time { background: rgba(16, 185, 129, 0.1); color: var(--accent); }  
835 -.badge-version { background: rgba(99, 102, 241, 0.1); color: #6366f1; }  
836 -.template-id-tag { font-size: 16px; color: var(--text-muted); font-weight: 500; margin-left: 8px; font-family: 'JetBrains Mono', monospace; opacity: 0.6; }  
837 -  
838 -/* Editable Name */  
839 -.editable-name-wrapper { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; }  
840 -.name-display {  
841 - cursor: pointer; border-bottom: 1.5px dashed transparent; transition: all 0.2s;  
842 - padding-bottom: 1px;  
843 -}  
844 -.name-display:hover {  
845 - border-bottom-color: var(--accent); color: var(--accent);  
846 -}  
847 -.name-edit-hint {  
848 - opacity: 0; font-size: 14px; cursor: pointer; transition: opacity 0.2s;  
849 -}  
850 -.editable-name-wrapper:hover .name-edit-hint { opacity: 0.6; }  
851 -.name-edit-hint:hover { opacity: 1 !important; }  
852 -.name-edit-input {  
853 - font-size: 22px; font-weight: 800; color: var(--text-primary);  
854 - background: var(--bg-input); border: 1.5px solid var(--accent);  
855 - border-radius: 8px; padding: 2px 10px; outline: none;  
856 - font-family: inherit; min-width: 120px;  
857 - box-shadow: 0 0 0 3px rgba(16,185,129,0.12);  
858 - transition: box-shadow 0.2s;  
859 -}  
860 -.name-edit-input:focus { box-shadow: 0 0 0 3px rgba(16,185,129,0.25); }  
861 -.name-edit-action {  
862 - border: none; cursor: pointer; font-size: 14px; font-weight: 700;  
863 - width: 26px; height: 26px; border-radius: 6px;  
864 - display: flex; align-items: center; justify-content: center;  
865 - transition: all 0.15s;  
866 -}  
867 -.name-edit-action.save { background: var(--accent); color: #fff; }  
868 -.name-edit-action.save:hover { box-shadow: 0 2px 8px rgba(16,185,129,0.3); }  
869 -.name-edit-action.cancel { background: var(--bg-surface-hover); color: var(--text-muted); }  
870 -.name-edit-action.cancel:hover { background: rgba(239,68,68,0.1); color: var(--red-text, #ef4444); }  
871 -.template-path-display {  
872 - font-size: 11px; color: var(--text-faint); margin-top: 10px; font-family: 'JetBrains Mono', monospace;  
873 - user-select: all; cursor: copy; display: inline-flex; align-items: center; gap: 4px;  
874 - background: var(--bg-overlay); padding: 4px 8px; border-radius: 6px; border: 1px dashed var(--border-primary);  
875 - transition: all 0.2s;  
876 -}  
877 -.template-path-display:hover { background: var(--bg-surface-hover); border-color: var(--text-muted); color: var(--text-secondary); }  
878 -.path-icon { opacity: 0.7; }  
879 -  
880 -/* Action buttons */  
881 -.header-actions { display: flex; gap: 8px; }  
882 -.action-btn {  
883 - padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border-primary);  
884 - font-size: 12px; font-weight: 700; cursor: pointer; transition: all 0.2s;  
885 - white-space: nowrap;  
886 -}  
887 -.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }  
888 -.action-generate { background: rgba(59,130,246,0.1); color: var(--blue-text); border-color: rgba(59,130,246,0.2); }  
889 -.action-generate:hover:not(:disabled) { background: rgba(59,130,246,0.2); box-shadow: 0 3px 10px rgba(59,130,246,0.15); }  
890 -.action-train { background: rgba(139,92,246,0.1); color: var(--purple-text); border-color: rgba(139,92,246,0.2); }  
891 -.action-train:hover:not(:disabled) { background: rgba(139,92,246,0.2); box-shadow: 0 3px 10px rgba(139,92,246,0.15); }  
892 -.action-upload { background: rgba(245,158,11,0.1); color: #f59e0b; border-color: rgba(245,158,11,0.2); }  
893 -.action-upload:hover:not(:disabled) { background: rgba(245,158,11,0.2); box-shadow: 0 3px 10px rgba(245,158,11,0.15); }  
894 -.action-provision { background: rgba(20,184,166,0.1); color: #14b8a6; border-color: rgba(20,184,166,0.2); }  
895 -.action-provision:hover:not(:disabled) { background: rgba(20,184,166,0.2); box-shadow: 0 3px 10px rgba(20,184,166,0.15); }  
896 -.action-delete { background: rgba(239, 68, 68, 0.1); color: var(--red-text, #ef4444); border-color: rgba(239, 68, 68, 0.2); }  
897 -.action-delete:hover:not(:disabled) { background: rgba(239, 68, 68, 0.2); box-shadow: 0 3px 10px rgba(239, 68, 68, 0.15); }  
898 -  
899 -/* Field Editing overrides */  
900 -.template-desc-edit { margin-bottom: 12px; margin-top: -2px; }  
901 -.desc-display { font-size: 13px; color: var(--text-secondary); cursor: pointer; display: inline-flex; align-items: flex-start; gap: 6px; }  
902 -.desc-display:hover .name-edit-hint { opacity: 0.6; }  
903 -.desc-display:hover { color: var(--text-primary); }  
904 -.desc-edit-input { width: 100%; max-width: 500px; display: block; margin-bottom: 8px; min-height: 60px; font-size: 13px; }  
905 -.desc-edit-actions { display: flex; gap: 6px; }  
906 -.editable-badge { cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 4px; }  
907 -.editable-badge:hover { background: var(--bg-input-focus); border-color: var(--accent); color: var(--accent); }  
908 -.type-edit-select { width: 140px; }  
909 -  
910 -/* Status Banner */  
911 -.status-banner {  
912 - padding: 10px 16px; border-radius: 10px; font-size: 12px; line-height: 1.6;  
913 - display: flex; align-items: center; justify-content: space-between; gap: 8px;  
914 - animation: slideDown 0.3s ease-out;  
915 -}  
916 -.status-generating { background: rgba(59,130,246,0.08); border: 1px solid rgba(59,130,246,0.2); color: var(--blue-text); }  
917 -.status-training { background: rgba(139,92,246,0.08); border: 1px solid rgba(139,92,246,0.2); color: var(--purple-text); }  
918 -.status-done { background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.2); color: var(--accent); }  
919 -.status-error { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); color: var(--red-text, #ef4444); }  
920 -.status-close { border: none; background: transparent; cursor: pointer; font-size: 14px; color: inherit; opacity: 0.6; }  
921 -.status-close:hover { opacity: 1; }  
922 -  
923 -/* ═══ Files Layout ═══ */  
924 -.files-layout {  
925 - display: flex; gap: 0; flex: 1; min-height: 0;  
926 - border: 1px solid var(--border-primary); border-radius: 12px; overflow: hidden;  
927 - background: var(--bg-surface);  
928 -}  
929 -.files-tree-panel {  
930 - width: 300px; min-width: 240px; border-right: 1px solid var(--border-primary);  
931 - display: flex; flex-direction: column; background: var(--bg-overlay);  
932 -}  
933 -.files-preview-panel {  
934 - flex: 1; display: flex; flex-direction: column; min-width: 0;  
935 -}  
936 -.panel-title {  
937 - padding: 12px 16px; font-size: 13px; font-weight: 700; color: var(--text-secondary);  
938 - border-bottom: 1px solid var(--border-primary);  
939 - display: flex; align-items: center; justify-content: space-between; gap: 8px;  
940 - white-space: nowrap; overflow: hidden; text-overflow: ellipsis;  
941 -}  
942 -.refresh-btn {  
943 - border: none; background: transparent; cursor: pointer; font-size: 13px;  
944 - padding: 2px 4px; border-radius: 4px; transition: all 0.15s;  
945 -}  
946 -.refresh-btn:hover { background: var(--bg-surface-hover); transform: rotate(90deg); }  
947 -.files-tree-scroll { flex: 1; overflow-y: auto; padding: 8px; }  
948 -  
949 -/* Editor in template */  
950 -.editor-scroll { flex: 1; display: flex; flex-direction: column; padding: 0 !important; }  
951 -.files-preview-scroll.editor-scroll { padding: 0; }  
952 -.editor-textarea {  
953 - flex: 1; width: 100%; border: none; outline: none; resize: none;  
954 - background: var(--bg-input); color: var(--text-secondary);  
955 - font-family: 'JetBrains Mono', monospace; font-size: 13px;  
956 - line-height: 1.6; padding: 12px; box-sizing: border-box;  
957 - tab-size: 2;  
958 -}  
959 -.editor-textarea:focus { background: var(--bg-input-focus); }  
960 -.editor-actions { display: flex; align-items: center; gap: 4px; }  
961 -.dirty-badge {  
962 - font-size: 9px; padding: 2px 6px; border-radius: 4px;  
963 - background: rgba(251,191,36,0.15); color: var(--yellow-text); font-weight: 600;  
964 -}  
965 -.editor-btn {  
966 - border: none; padding: 3px 8px; border-radius: 4px; font-size: 11px;  
967 - cursor: pointer; transition: all 0.15s; font-weight: 600;  
968 -}  
969 -.reset-btn { background: var(--bg-input); color: var(--text-muted); }  
970 -.reset-btn:hover { background: var(--bg-surface-hover); }  
971 -.save-btn { background: var(--accent); color: #fff; }  
972 -.save-btn:hover { box-shadow: 0 2px 8px rgba(16,185,129,0.3); }  
973 -  
974 -.file-count-badge {  
975 - padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600;  
976 - background: var(--accent-bg); color: var(--accent); flex-shrink: 0;  
977 -}  
978 -.file-size-badge {  
979 - padding: 2px 8px; border-radius: 6px; font-size: 11px;  
980 - background: var(--purple-bg, rgba(139,92,246,0.1)); color: var(--purple-text, #a78bfa);  
981 - flex-shrink: 0; font-weight: 600;  
982 -}  
983 -  
984 -/* ═══ Logs Panel ═══ */  
985 -.logs-section {  
986 - margin-top: 8px; border: 1px solid var(--border-primary); border-radius: 12px;  
987 - background: var(--bg-surface); overflow: hidden;  
988 -}  
989 -.logs-section .section-header {  
990 - display: flex; align-items: center; justify-content: space-between;  
991 - padding: 10px 16px; border-bottom: 1px solid var(--border-primary);  
992 -}  
993 -.logs-section .section-header h4 {  
994 - margin: 0; font-size: 14px; font-weight: 700; color: var(--text-primary);  
995 -}  
996 -.logs-list { max-height: 250px; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }  
997 -.log-card {  
998 - border: 1px solid var(--border-primary); border-radius: 8px; overflow: hidden;  
999 - transition: border-color 0.2s;  
1000 -}  
1001 -.log-card.log-done { border-left: 3px solid rgba(16,185,129,0.4); }  
1002 -.log-card.log-running { border-left: 3px solid rgba(59,130,246,0.4); }  
1003 -.log-header {  
1004 - display: flex; align-items: center; gap: 8px; padding: 8px 12px;  
1005 - cursor: pointer; user-select: none; transition: background 0.15s;  
1006 -}  
1007 -.log-header:hover { background: var(--bg-surface-hover); }  
1008 -.log-status-icon { font-size: 12px; }  
1009 -.log-label { font-size: 12px; font-weight: 700; color: var(--text-secondary); flex: 1; }  
1010 -.log-time { font-size: 10px; color: var(--text-muted); }  
1011 -.log-msg-count { font-size: 10px; color: var(--text-faint); }  
1012 -.log-toggle { font-size: 9px; color: var(--text-faint); }  
1013 -.log-summary {  
1014 - padding: 8px 12px; font-size: 12px; line-height: 1.6; color: var(--text-secondary);  
1015 - background: var(--bg-input); border-top: 1px solid var(--border-primary);  
1016 - white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto;  
1017 - font-family: 'JetBrains Mono', monospace;  
1018 -}  
1019 -</style>