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 89 | `system/agents/` | **全部 Agent 本体的唯一目录**。`_base/` 是元 Agent 骨架,`_registry.json` 只用于按 id 补 `benchmark_score`(**目录才是数据源头**,`graph-seed-service.seedAgents()` 直接扫目录)。`_` 前缀为保留 id,扫描时跳过 |
90 90 | `system/agents/devloop-*/` | 六个 DevLoop Agent(pm, architect, coder, tester, integrator, triager),每个含 `config.json` + `prompt.md` + 一份指路用的薄 `agent.md` |
91 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 93 | `packages/` | `acp-core`, `agent-core`, `evolution`, `plugin-bootstrap` |
94 94 | `<repo>/../data/skills/` | Skill 仓库。**在仓库外面一层**,不是 `<repo>/data/skills/`(后者不存在)。路径由 `config.skillsDataDir` 控制,见下「数据存储」的两个 data 根 |
95 95 | `scripts/` | 运维脚本(含 `sync-skills.js`) |
... ...
bin/lemon.js
... ... @@ -74,25 +74,6 @@ async function run() {
74 74 const res = await request('POST', `/company/${values.id}/fire`, { role: values.role, archive: values.archive });
75 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 77 else if (command === 'company templates') {
97 78 const res = await request('GET', '/company/templates');
98 79 console.log('\n📦 可用 Agent 模板列表:\n');
... ...
server/routes/company.js
... ... @@ -142,586 +142,6 @@ router.get(&#39;/templates&#39;, (req, res) =&gt; {
142 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 145 // GET /api/company/:companyId
726 146 router.get('/:companyId', (req, res) => {
727 147 const company = companyService.get(req.params.companyId);
... ... @@ -1069,8 +489,10 @@ router.post(&#39;/:companyId/hire&#39;, async (req, res) =&gt; {
1069 489 }
1070 490  
1071 491 // 3. 延时 5 秒触发 Innovator 进行评估与训练 (等 creator 写入完毕)
  492 + // 同上:`innovator` 改名 `org-innovator` 之后没同步改这里,招聘后自动训练
  493 + // 静默 404 失效(36 号文档阶段二实测发现,见 train 路由那处同款注释)。
1072 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 496 method: 'POST',
1075 497 headers: { 'Content-Type': 'application/json' },
1076 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 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 57 * 为什么这段文本要连**运行期布局**一起交代:agent-creator 是个 LLM,它写 agent.md 的办法
57 58 * 是照着仓库里已有的本体依样画葫芦,而那些老本体写的是旧布局的 `../../workspace/…`
... ...
server/services/graph-service.js
... ... @@ -104,7 +104,8 @@ function ensureSystemAgentRow(companyId, systemAgentId, roleName, opts = {}) {
104 104 *
105 105 * 为什么是**按需校验**而不是「agent-creator 写完就升」:填 agent.md / soul.md / SKILL.md 的
106 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 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 100 *
101 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 109 describe('装配来源:agent-creator 的产出契约必须交代运行期布局', () => {
107 110 const brief = buildAgentDefinitionBrief({
... ... @@ -135,10 +138,9 @@ describe(&#39;装配来源:agent-creator 的产出契约必须交代运行期布
135 138 expect(brief).toContain(GRAPH_WRITE_BOUNDARY_HEADER);
136 139 });
137 140  
138   - it('两处派单都走这一份契约:路由里不再留第二份内联清单', () => {
139   - // 内联那份只补进一处,就等于另一处没补 —— 而两处写出来的都是同一批本体。
  141 + it('唯一的派单点走这一份契约:路由里不再留内联清单', () => {
140 142 const routes = fs.readFileSync(path.join(ROOT, 'server', 'routes', 'company.js'), 'utf-8');
141 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 9 <span>🤖</span> System Agents
10 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 12 <a href="/tasks" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/tasks') }">
17 13 <span>📜</span> Task Logs
18 14 </a>
... ...
web/src/router/index.js
... ... @@ -25,12 +25,6 @@ const routes = [
25 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 28 path: '/company',
35 29 name: 'CompanyList',
36 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>