Commit 203d25798c0f968c499740164f2c408ed7108992
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>
Showing
9 changed files
with
16 additions
and
1638 deletions
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('/templates', (req, res) => { |
| 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('/:companyId/hire', async (req, res) => { |
| 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, 'template-usage.json'); |
| 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('work 层本体的路径口径(cwd 深度 3)', () => { |
| 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('装配来源: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> |