Commit c72d1b103649d914c978a5389642778a86c1f347

Authored by yibo
1 parent 007a391e

feat: template agent creation + Agent Creator + Innovator integration

- POST /api/company/templates: scaffold new template via AgentScaffold
- POST /api/company/templates/:id/generate: trigger agent-creator to write definitions
- POST /api/company/templates/:id/train: trigger innovator FDE training pipeline
- PUT /api/company/templates/:id/files/content: save template file content
- TemplateAgents.vue: create form, action buttons, editable preview, status banner
server/routes/company.js
@@ -102,6 +102,126 @@ router.get('/templates', (req, res) => { @@ -102,6 +102,126 @@ router.get('/templates', (req, res) => {
102 res.json(companyService.listTemplates()); 102 res.json(companyService.listTemplates());
103 }); 103 });
104 104
  105 +// POST /api/company/templates — 创建新模板 Agent
  106 +router.post('/templates', async (req, res) => {
  107 + try {
  108 + const { id, name, type, description } = req.body;
  109 + if (!id || !name) return res.status(400).json({ error: 'id 和 name 为必填项' });
  110 + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
  111 + return res.status(400).json({ error: 'id 格式无效 (只允许小写字母、数字、-)' });
  112 + }
  113 +
  114 + const path = require('path');
  115 + const fs = require('fs');
  116 + const TEMPLATES_DIR = path.resolve(__dirname, '../../data/system/templates');
  117 + const templateDir = path.join(TEMPLATES_DIR, id);
  118 +
  119 + if (fs.existsSync(templateDir)) {
  120 + return res.status(409).json({ error: `模板 "${id}" 已存在` });
  121 + }
  122 +
  123 + // 使用 AgentScaffold 创建标准骨架
  124 + const { AgentScaffold } = await import('../../packages/agent-core/AgentScaffold.js');
  125 + const scaffold = new AgentScaffold(TEMPLATES_DIR);
  126 + await scaffold.create({
  127 + id,
  128 + type: type || 'skill',
  129 + name: name,
  130 + parentId: null,
  131 + });
  132 +
  133 + // 如果有 description,追加到 config.json
  134 + if (description) {
  135 + const configPath = path.join(templateDir, 'config.json');
  136 + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
  137 + config.description = description;
  138 + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
  139 + }
  140 +
  141 + console.log(`[Template] ✅ 新模板已创建: ${id} (${type || 'skill'})`);
  142 + res.status(201).json({ success: true, id, name, type: type || 'skill' });
  143 + } catch (err) {
  144 + res.status(500).json({ error: err.message });
  145 + }
  146 +});
  147 +
  148 +// POST /api/company/templates/:templateId/generate — 调用 Agent Creator 生成定义文件
  149 +router.post('/templates/:templateId/generate', async (req, res) => {
  150 + try {
  151 + const { templateId } = req.params;
  152 + const { description } = req.body;
  153 + const path = require('path');
  154 + const fs = require('fs');
  155 + const config = require('../config');
  156 + const TEMPLATES_DIR = path.resolve(__dirname, '../../data/system/templates');
  157 + const templateDir = path.join(TEMPLATES_DIR, templateId);
  158 +
  159 + if (!fs.existsSync(templateDir)) {
  160 + return res.status(404).json({ error: `模板 "${templateId}" 不存在` });
  161 + }
  162 +
  163 + const PORT = config.port || 3000;
  164 + const prompt = `请你为模板 Agent "${templateId}" 编写完整的定义文件。
  165 +模板位于 \`../../data/system/templates/${templateId}\` 目录。
  166 +该 Agent 的描述/用途: ${description || '通用执行型 Agent'}
  167 +请使用 Write 工具为其补充完整的 agent.md, identity.md, soul.md, user.md, tools.md 定义文件。
  168 +写完后用 ls 验证目录结构。务必使用 Write 工具写入,不要使用 Edit 工具。`;
  169 +
  170 + // 异步调用 agent-creator
  171 + fetch(`http://127.0.0.1:${PORT}/api/agent/agent-creator/chat`, {
  172 + method: 'POST',
  173 + headers: { 'Content-Type': 'application/json' },
  174 + body: JSON.stringify({
  175 + message: prompt,
  176 + conversationId: `tpl-gen-${templateId}-${Date.now().toString(36)}`,
  177 + }),
  178 + }).catch(e => console.error('[Template] Agent Creator 请求失败:', e.message));
  179 +
  180 + console.log(`[Template] 🤖 已触发 Agent Creator 为 ${templateId} 生成定义`);
  181 + res.json({ success: true, message: `已触发 Agent Creator 为 "${templateId}" 生成定义文件`, status: 'generating' });
  182 + } catch (err) {
  183 + res.status(500).json({ error: err.message });
  184 + }
  185 +});
  186 +
  187 +// POST /api/company/templates/:templateId/train — 调用 Innovator 训练模板
  188 +router.post('/templates/:templateId/train', async (req, res) => {
  189 + try {
  190 + const { templateId } = req.params;
  191 + const path = require('path');
  192 + const fs = require('fs');
  193 + const config = require('../config');
  194 + const TEMPLATES_DIR = path.resolve(__dirname, '../../data/system/templates');
  195 + const templateDir = path.join(TEMPLATES_DIR, templateId);
  196 +
  197 + if (!fs.existsSync(templateDir)) {
  198 + return res.status(404).json({ error: `模板 "${templateId}" 不存在` });
  199 + }
  200 +
  201 + const PORT = config.port || 3000;
  202 + const prompt = `请你对模板 Agent "${templateId}" 进行评估和训练。
  203 +模板位于 \`../../data/system/templates/${templateId}\` 目录。
  204 +请使用 FDE pipeline 对该模板的技能进行评测和进化训练,目标: Benchmark Score ≥ 95。
  205 +训练过程包括 Hunt(验证数据), Digest(知识消化), Evolver(技能进化) 等。
  206 +请立即开始评估和训练,训练完成后请汇报最终分数。`;
  207 +
  208 + // 异步调用 innovator
  209 + fetch(`http://127.0.0.1:${PORT}/api/agent/innovator/chat`, {
  210 + method: 'POST',
  211 + headers: { 'Content-Type': 'application/json' },
  212 + body: JSON.stringify({
  213 + message: prompt,
  214 + conversationId: `tpl-train-${templateId}-${Date.now().toString(36)}`,
  215 + }),
  216 + }).catch(e => console.error('[Template] Innovator 请求失败:', e.message));
  217 +
  218 + console.log(`[Template] 🎓 已触发 Innovator 训练 ${templateId}`);
  219 + res.json({ success: true, message: `已触发 Innovator 训练 "${templateId}"`, status: 'training' });
  220 + } catch (err) {
  221 + res.status(500).json({ error: err.message });
  222 + }
  223 +});
  224 +
105 // GET /api/company/templates/:templateId/files 225 // GET /api/company/templates/:templateId/files
106 router.get('/templates/:templateId/files', (req, res) => { 226 router.get('/templates/:templateId/files', (req, res) => {
107 try { 227 try {
@@ -189,6 +309,35 @@ router.get('/templates/:templateId/files/content', (req, res) => { @@ -189,6 +309,35 @@ router.get('/templates/:templateId/files/content', (req, res) => {
189 res.status(500).json({ error: err.message }); 309 res.status(500).json({ error: err.message });
190 } 310 }
191 }); 311 });
  312 +
  313 +// PUT /api/company/templates/:templateId/files/content — 保存模板文件内容
  314 +router.put('/templates/:templateId/files/content', (req, res) => {
  315 + try {
  316 + const path = require('path');
  317 + const fs = require('fs');
  318 + const DATA_DIR = path.resolve(__dirname, '../../data/system/templates');
  319 + const filePath = req.body.path;
  320 +
  321 + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' });
  322 +
  323 + const absPath = path.resolve(DATA_DIR, req.params.templateId, filePath);
  324 + const safeBoundary = path.resolve(DATA_DIR, req.params.templateId);
  325 + if (!absPath.startsWith(safeBoundary)) {
  326 + return res.status(403).json({ error: '路径越界' });
  327 + }
  328 +
  329 + // 确保目录存在
  330 + const dir = path.dirname(absPath);
  331 + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  332 +
  333 + fs.writeFileSync(absPath, req.body.content || '', 'utf-8');
  334 + console.log(`[Template] 💾 已保存模板文件 ${req.params.templateId}/${filePath}`);
  335 + res.json({ success: true, path: filePath });
  336 + } catch (err) {
  337 + res.status(500).json({ error: err.message });
  338 + }
  339 +});
  340 +
192 // GET /api/company/:companyId 341 // GET /api/company/:companyId
193 router.get('/:companyId', (req, res) => { 342 router.get('/:companyId', (req, res) => {
194 const company = companyService.get(req.params.companyId); 343 const company = companyService.get(req.params.companyId);
web/src/views/TemplateAgents.vue
@@ -6,8 +6,30 @@ @@ -6,8 +6,30 @@
6 <!-- ═══ Left Sidebar (Template List) ═══ --> 6 <!-- ═══ Left Sidebar (Template List) ═══ -->
7 <aside class="sidebar-panel"> 7 <aside class="sidebar-panel">
8 <div class="sidebar-header"> 8 <div class="sidebar-header">
9 - <h3>📦 模板库库 ({{ templates.length }})</h3> 9 + <h3>📦 模板库 ({{ templates.length }})</h3>
  10 + <button class="create-btn" @click="showCreateForm = !showCreateForm">+ 新建</button>
10 </div> 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 +
11 <div class="sidebar-search"> 33 <div class="sidebar-search">
12 <input type="text" v-model="searchQuery" placeholder="搜索模板..." /> 34 <input type="text" v-model="searchQuery" placeholder="搜索模板..." />
13 </div> 35 </div>
@@ -45,6 +67,24 @@ @@ -45,6 +67,24 @@
45 </div> 67 </div>
46 </div> 68 </div>
47 </div> 69 </div>
  70 + <!-- Action Buttons -->
  71 + <div class="header-actions">
  72 + <button class="action-btn action-generate" :disabled="actionStatus !== null" @click="triggerGenerate">
  73 + {{ actionStatus === 'generating' ? '⏳ 生成中...' : '🤖 Agent Creator 生成定义' }}
  74 + </button>
  75 + <button class="action-btn action-train" :disabled="actionStatus !== null" @click="triggerTrain">
  76 + {{ actionStatus === 'training' ? '⏳ 训练中...' : '🎓 Innovator 训练' }}
  77 + </button>
  78 + </div>
  79 + </div>
  80 +
  81 + <!-- Status Banner -->
  82 + <div v-if="actionStatus" class="status-banner" :class="'status-' + actionStatus">
  83 + <span v-if="actionStatus === 'generating'">🤖 Agent Creator 正在为该模板生成定义文件,请稍候后刷新文件树查看结果...</span>
  84 + <span v-else-if="actionStatus === 'training'">🎓 Innovator 正在训练该模板,训练完成后 Benchmark 评分将自动更新...</span>
  85 + <span v-else-if="actionStatus === 'done'">✅ {{ actionMessage }}</span>
  86 + <span v-else-if="actionStatus === 'error'">❌ {{ actionMessage }}</span>
  87 + <button class="status-close" @click="actionStatus = null">✕</button>
48 </div> 88 </div>
49 89
50 <!-- File Explorer --> 90 <!-- File Explorer -->
@@ -53,6 +93,7 @@ @@ -53,6 +93,7 @@
53 <div class="panel-title"> 93 <div class="panel-title">
54 📁 模板文件结构 94 📁 模板文件结构
55 <span class="file-count-badge">{{ templateFiles?.totalFiles || 0 }} 文件</span> 95 <span class="file-count-badge">{{ templateFiles?.totalFiles || 0 }} 文件</span>
  96 + <button class="refresh-btn" @click="loadTemplateFiles(selectedTemplateId)" title="刷新">🔄</button>
56 </div> 97 </div>
57 <div class="files-tree-scroll"> 98 <div class="files-tree-scroll">
58 <div v-if="templateFiles?.tree?.length > 0"> 99 <div v-if="templateFiles?.tree?.length > 0">
@@ -66,12 +107,16 @@ @@ -66,12 +107,16 @@
66 107
67 <div class="files-preview-panel"> 108 <div class="files-preview-panel">
68 <div class="panel-title"> 109 <div class="panel-title">
69 - 👁️ {{ selectedFilePath || '文件预览' }}  
70 - <span v-if="filePreviewSize" class="file-size-badge">{{ formatFileSize(filePreviewSize) }}</span> 110 + <span>✏️ {{ selectedFilePath || '编辑器' }}</span>
  111 + <div v-if="filePreviewContent !== null" class="editor-actions">
  112 + <span v-if="fileDirty" class="dirty-badge">未保存</span>
  113 + <button v-if="fileDirty" class="editor-btn reset-btn" @click="editFileContent = filePreviewContent">↩ 重置</button>
  114 + <button v-if="fileDirty" class="editor-btn save-btn" @click="saveTemplateFile">💾 保存</button>
  115 + </div>
71 </div> 116 </div>
72 - <div class="files-preview-scroll">  
73 - <pre v-if="filePreviewContent !== null" class="file-preview-code">{{ filePreviewContent }}</pre>  
74 - <div v-else class="empty-mini">← 点击左侧文件树以预览内容</div> 117 + <div class="files-preview-scroll editor-scroll">
  118 + <textarea v-if="filePreviewContent !== null" v-model="editFileContent" class="editor-textarea" spellcheck="false"></textarea>
  119 + <div v-else class="empty-mini">← 点击左侧文件树以编辑内容</div>
75 </div> 120 </div>
76 </div> 121 </div>
77 </div> 122 </div>
@@ -80,6 +125,7 @@ @@ -80,6 +125,7 @@
80 <div v-else class="empty-state"> 125 <div v-else class="empty-state">
81 <div class="empty-icon">📦</div> 126 <div class="empty-icon">📦</div>
82 <h3>请在左侧选择一个 Agent 模板</h3> 127 <h3>请在左侧选择一个 Agent 模板</h3>
  128 + <p class="empty-hint">或点击「+ 新建」创建一个新的模板 Agent</p>
83 </div> 129 </div>
84 </main> 130 </main>
85 </div> 131 </div>
@@ -103,8 +149,20 @@ const selectedTemplateId = ref(null) @@ -103,8 +149,20 @@ const selectedTemplateId = ref(null)
103 const templateFiles = ref(null) 149 const templateFiles = ref(null)
104 const selectedFilePath = ref(null) 150 const selectedFilePath = ref(null)
105 const filePreviewContent = ref(null) 151 const filePreviewContent = ref(null)
  152 +const editFileContent = ref(null)
106 const filePreviewSize = ref(0) 153 const filePreviewSize = ref(0)
107 154
  155 +// Create form state
  156 +const showCreateForm = ref(false)
  157 +const creating = ref(false)
  158 +const newTemplate = ref({ id: '', name: '', type: 'skill', description: '' })
  159 +
  160 +// Action state
  161 +const actionStatus = ref(null)
  162 +const actionMessage = ref('')
  163 +
  164 +const fileDirty = computed(() => editFileContent.value !== null && filePreviewContent.value !== null && editFileContent.value !== filePreviewContent.value)
  165 +
108 const filteredTemplates = computed(() => { 166 const filteredTemplates = computed(() => {
109 if (!searchQuery.value) return templates.value; 167 if (!searchQuery.value) return templates.value;
110 const q = searchQuery.value.toLowerCase(); 168 const q = searchQuery.value.toLowerCase();
@@ -116,7 +174,7 @@ const selectedTemplate = computed(() =&gt; { @@ -116,7 +174,7 @@ const selectedTemplate = computed(() =&gt; {
116 }) 174 })
117 175
118 function agentEmoji(type) { 176 function agentEmoji(type) {
119 - const map = { cxo: '👔', role: '🏢', skill: '⚙️', specialist: '🔬', writer: '✍️', 'data-analyst': '📈' } 177 + const map = { cxo: '👔', role: '🏢', skill: '🔧', specialist: '🔬', writer: '✍️', executor: '⚡', 'data-analyst': '📈' }
120 return map[type] || '🤖' 178 return map[type] || '🤖'
121 } 179 }
122 180
@@ -127,6 +185,8 @@ function formatFileSize(bytes) { @@ -127,6 +185,8 @@ function formatFileSize(bytes) {
127 return `${(bytes/1048576).toFixed(1)} MB` 185 return `${(bytes/1048576).toFixed(1)} MB`
128 } 186 }
129 187
  188 +// ─── Template CRUD ───
  189 +
130 async function loadTemplates() { 190 async function loadTemplates() {
131 try { 191 try {
132 const { data } = await axios.get('/api/company/templates') 192 const { data } = await axios.get('/api/company/templates')
@@ -137,7 +197,6 @@ async function loadTemplates() { @@ -137,7 +197,6 @@ async function loadTemplates() {
137 selectedTemplateId.value = route.params.agentId; 197 selectedTemplateId.value = route.params.agentId;
138 await loadTemplateFiles(route.params.agentId); 198 await loadTemplateFiles(route.params.agentId);
139 } else if (templates.value.length > 0) { 199 } else if (templates.value.length > 0) {
140 - // Default auto-select the first one and replace route  
141 selectTemplate(templates.value[0].id) 200 selectTemplate(templates.value[0].id)
142 } 201 }
143 } catch (e) { 202 } catch (e) {
@@ -145,12 +204,30 @@ async function loadTemplates() { @@ -145,12 +204,30 @@ async function loadTemplates() {
145 } 204 }
146 } 205 }
147 206
  207 +async function createTemplate() {
  208 + const { id, name, type, description } = newTemplate.value
  209 + if (!id || !name) return
  210 + creating.value = true
  211 + try {
  212 + await axios.post('/api/company/templates', { id, name, type, description })
  213 + showCreateForm.value = false
  214 + newTemplate.value = { id: '', name: '', type: 'skill', description: '' }
  215 + await loadTemplates()
  216 + selectTemplate(id)
  217 + } catch (e) {
  218 + alert(`创建失败: ${e.response?.data?.error || e.message}`)
  219 + } finally {
  220 + creating.value = false
  221 + }
  222 +}
  223 +
148 function selectTemplate(id) { 224 function selectTemplate(id) {
149 selectedTemplateId.value = id; 225 selectedTemplateId.value = id;
150 - // Clear file preview selection  
151 selectedFilePath.value = null; 226 selectedFilePath.value = null;
152 filePreviewContent.value = null; 227 filePreviewContent.value = null;
  228 + editFileContent.value = null;
153 filePreviewSize.value = 0; 229 filePreviewSize.value = 0;
  230 + actionStatus.value = null;
154 231
155 router.push(`/templates/${id}`); 232 router.push(`/templates/${id}`);
156 } 233 }
@@ -172,13 +249,70 @@ async function previewTemplateFile(filePath) { @@ -172,13 +249,70 @@ async function previewTemplateFile(filePath) {
172 params: { path: filePath } 249 params: { path: filePath }
173 }); 250 });
174 filePreviewContent.value = data.content; 251 filePreviewContent.value = data.content;
  252 + editFileContent.value = data.content;
175 filePreviewSize.value = data.size || 0; 253 filePreviewSize.value = data.size || 0;
176 } catch (e) { 254 } catch (e) {
177 filePreviewContent.value = '(加载失败)'; 255 filePreviewContent.value = '(加载失败)';
  256 + editFileContent.value = filePreviewContent.value;
178 filePreviewSize.value = 0; 257 filePreviewSize.value = 0;
179 } 258 }
180 } 259 }
181 260
  261 +async function saveTemplateFile() {
  262 + if (!selectedTemplateId.value || !selectedFilePath.value) return
  263 + try {
  264 + // Use the template file save endpoint
  265 + await axios.put(`/api/company/templates/${selectedTemplateId.value}/files/content`, {
  266 + path: selectedFilePath.value,
  267 + content: editFileContent.value,
  268 + })
  269 + filePreviewContent.value = editFileContent.value
  270 + await loadTemplateFiles(selectedTemplateId.value)
  271 + } catch (e) {
  272 + alert(`保存失败: ${e.response?.data?.error || e.message}`)
  273 + }
  274 +}
  275 +
  276 +// ─── Agent Creator & Innovator ───
  277 +
  278 +async function triggerGenerate() {
  279 + if (!selectedTemplateId.value) return
  280 + actionStatus.value = 'generating'
  281 + try {
  282 + const { data } = await axios.post(`/api/company/templates/${selectedTemplateId.value}/generate`, {
  283 + description: selectedTemplate.value?.description || '',
  284 + })
  285 + actionMessage.value = data.message
  286 + // Auto-refresh after a delay
  287 + setTimeout(async () => {
  288 + await loadTemplateFiles(selectedTemplateId.value)
  289 + actionStatus.value = 'done'
  290 + actionMessage.value = 'Agent Creator 已完成(请检查文件树)'
  291 + }, 30000)
  292 + } catch (e) {
  293 + actionStatus.value = 'error'
  294 + actionMessage.value = e.response?.data?.error || e.message
  295 + }
  296 +}
  297 +
  298 +async function triggerTrain() {
  299 + if (!selectedTemplateId.value) return
  300 + actionStatus.value = 'training'
  301 + try {
  302 + const { data } = await axios.post(`/api/company/templates/${selectedTemplateId.value}/train`)
  303 + actionMessage.value = data.message
  304 + setTimeout(async () => {
  305 + await loadTemplates()
  306 + await loadTemplateFiles(selectedTemplateId.value)
  307 + actionStatus.value = 'done'
  308 + actionMessage.value = 'Innovator 训练已触发(请稍后查看分数更新)'
  309 + }, 60000)
  310 + } catch (e) {
  311 + actionStatus.value = 'error'
  312 + actionMessage.value = e.response?.data?.error || e.message
  313 + }
  314 +}
  315 +
182 watch(() => route.params.agentId, (newId) => { 316 watch(() => route.params.agentId, (newId) => {
183 if (newId && newId !== selectedTemplateId.value) { 317 if (newId && newId !== selectedTemplateId.value) {
184 selectedTemplateId.value = newId; 318 selectedTemplateId.value = newId;
@@ -222,10 +356,46 @@ onMounted(() =&gt; { @@ -222,10 +356,46 @@ onMounted(() =&gt; {
222 .sidebar-header { 356 .sidebar-header {
223 padding: 16px 20px; 357 padding: 16px 20px;
224 border-bottom: 1px solid var(--border-primary); 358 border-bottom: 1px solid var(--border-primary);
  359 + display: flex; align-items: center; justify-content: space-between;
225 } 360 }
226 .sidebar-header h3 { 361 .sidebar-header h3 {
227 font-size: 16px; font-weight: 700; margin: 0; color: var(--text-primary); 362 font-size: 16px; font-weight: 700; margin: 0; color: var(--text-primary);
228 } 363 }
  364 +.create-btn {
  365 + padding: 4px 12px; border-radius: 6px; border: none;
  366 + background: var(--accent); color: #fff; font-size: 12px; font-weight: 700;
  367 + cursor: pointer; transition: all 0.2s;
  368 +}
  369 +.create-btn:hover { box-shadow: 0 3px 12px rgba(16,185,129,0.3); transform: translateY(-1px); }
  370 +
  371 +/* Create Form */
  372 +.create-form {
  373 + padding: 12px; border-bottom: 1px solid var(--border-primary);
  374 + background: var(--bg-overlay); display: flex; flex-direction: column; gap: 8px;
  375 + animation: slideDown 0.2s ease-out;
  376 +}
  377 +@keyframes slideDown { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
  378 +.form-input {
  379 + width: 100%; padding: 8px 10px; border: 1px solid var(--border-primary);
  380 + border-radius: 6px; background: var(--bg-input); color: var(--text-primary);
  381 + font-size: 12px; font-family: inherit; box-sizing: border-box;
  382 + transition: border-color 0.2s;
  383 +}
  384 +.form-input:focus { outline: none; border-color: var(--accent); background: var(--bg-input-focus); }
  385 +.form-textarea { resize: vertical; min-height: 40px; }
  386 +.form-actions { display: flex; gap: 6px; justify-content: flex-end; }
  387 +.btn-cancel {
  388 + padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border-primary);
  389 + background: transparent; color: var(--text-muted); font-size: 11px; cursor: pointer;
  390 +}
  391 +.btn-confirm {
  392 + padding: 5px 14px; border-radius: 6px; border: none;
  393 + background: linear-gradient(135deg, var(--accent), var(--accent-light)); color: #fff;
  394 + font-size: 11px; font-weight: 700; cursor: pointer; transition: all 0.2s;
  395 +}
  396 +.btn-confirm:hover { box-shadow: 0 3px 10px rgba(16,185,129,0.3); }
  397 +.btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
  398 +
229 .sidebar-search { 399 .sidebar-search {
230 padding: 12px; 400 padding: 12px;
231 border-bottom: 1px solid var(--border-primary); 401 border-bottom: 1px solid var(--border-primary);
@@ -235,7 +405,7 @@ onMounted(() =&gt; { @@ -235,7 +405,7 @@ onMounted(() =&gt; {
235 border: 1px solid var(--border-primary); 405 border: 1px solid var(--border-primary);
236 border-radius: 8px; background: var(--bg-input); 406 border-radius: 8px; background: var(--bg-input);
237 color: var(--text-primary); font-size: 13px; 407 color: var(--text-primary); font-size: 13px;
238 - transition: all 0.2s; 408 + transition: all 0.2s; box-sizing: border-box;
239 } 409 }
240 .sidebar-search input:focus { 410 .sidebar-search input:focus {
241 outline: none; border-color: var(--accent); background: var(--bg-input-focus); 411 outline: none; border-color: var(--accent); background: var(--bg-input-focus);
@@ -278,22 +448,50 @@ onMounted(() =&gt; { @@ -278,22 +448,50 @@ onMounted(() =&gt; {
278 height: 100%; color: var(--text-muted); 448 height: 100%; color: var(--text-muted);
279 } 449 }
280 .empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; } 450 .empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
  451 +.empty-hint { font-size: 13px; color: var(--text-faint); margin-top: 4px; }
281 .template-detail { 452 .template-detail {
282 - display: flex; flex-direction: column; height: 100%; gap: 24px; 453 + display: flex; flex-direction: column; height: 100%; gap: 16px;
283 } 454 }
284 .detail-header { 455 .detail-header {
285 background: var(--bg-secondary); border: 1px solid var(--border-primary); 456 background: var(--bg-secondary); border: 1px solid var(--border-primary);
286 - border-radius: 16px; padding: 24px; display: flex; justify-content: space-between; align-items: center; 457 + border-radius: 16px; padding: 20px 24px; display: flex; justify-content: space-between; align-items: center;
  458 + flex-wrap: wrap; gap: 12px;
287 } 459 }
288 .header-main { display: flex; align-items: center; gap: 20px; } 460 .header-main { display: flex; align-items: center; gap: 20px; }
289 .header-icon { font-size: 48px; } 461 .header-icon { font-size: 48px; }
290 .header-main h2 { font-size: 24px; font-weight: 800; margin: 0 0 8px 0; color: var(--text-primary); } 462 .header-main h2 { font-size: 24px; font-weight: 800; margin: 0 0 8px 0; color: var(--text-primary); }
291 -.meta-row { display: flex; gap: 12px; } 463 +.meta-row { display: flex; gap: 12px; flex-wrap: wrap; }
292 .badge { padding: 4px 10px; border-radius: 8px; font-size: 12px; font-weight: 600; } 464 .badge { padding: 4px 10px; border-radius: 8px; font-size: 12px; font-weight: 600; }
293 .badge-type { background: var(--bg-surface-hover); color: var(--text-secondary); border: 1px solid var(--border-primary); text-transform: uppercase; } 465 .badge-type { background: var(--bg-surface-hover); color: var(--text-secondary); border: 1px solid var(--border-primary); text-transform: uppercase; }
294 .badge-used { background: rgba(59,130,246,0.1); color: var(--blue-text); } 466 .badge-used { background: rgba(59,130,246,0.1); color: var(--blue-text); }
295 .badge-score { background: rgba(251,191,36,0.1); color: var(--yellow-text); } 467 .badge-score { background: rgba(251,191,36,0.1); color: var(--yellow-text); }
296 468
  469 +/* Action buttons */
  470 +.header-actions { display: flex; gap: 8px; }
  471 +.action-btn {
  472 + padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border-primary);
  473 + font-size: 12px; font-weight: 700; cursor: pointer; transition: all 0.2s;
  474 + white-space: nowrap;
  475 +}
  476 +.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
  477 +.action-generate { background: rgba(59,130,246,0.1); color: var(--blue-text); border-color: rgba(59,130,246,0.2); }
  478 +.action-generate:hover:not(:disabled) { background: rgba(59,130,246,0.2); box-shadow: 0 3px 10px rgba(59,130,246,0.15); }
  479 +.action-train { background: rgba(139,92,246,0.1); color: var(--purple-text); border-color: rgba(139,92,246,0.2); }
  480 +.action-train:hover:not(:disabled) { background: rgba(139,92,246,0.2); box-shadow: 0 3px 10px rgba(139,92,246,0.15); }
  481 +
  482 +/* Status Banner */
  483 +.status-banner {
  484 + padding: 10px 16px; border-radius: 10px; font-size: 12px; line-height: 1.6;
  485 + display: flex; align-items: center; justify-content: space-between; gap: 8px;
  486 + animation: slideDown 0.3s ease-out;
  487 +}
  488 +.status-generating { background: rgba(59,130,246,0.08); border: 1px solid rgba(59,130,246,0.2); color: var(--blue-text); }
  489 +.status-training { background: rgba(139,92,246,0.08); border: 1px solid rgba(139,92,246,0.2); color: var(--purple-text); }
  490 +.status-done { background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.2); color: var(--accent); }
  491 +.status-error { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); color: var(--red-text, #ef4444); }
  492 +.status-close { border: none; background: transparent; cursor: pointer; font-size: 14px; color: inherit; opacity: 0.6; }
  493 +.status-close:hover { opacity: 1; }
  494 +
297 /* ═══ Files Layout ═══ */ 495 /* ═══ Files Layout ═══ */
298 .files-layout { 496 .files-layout {
299 display: flex; gap: 0; flex: 1; min-height: 0; 497 display: flex; gap: 0; flex: 1; min-height: 0;
@@ -313,13 +511,38 @@ onMounted(() =&gt; { @@ -313,13 +511,38 @@ onMounted(() =&gt; {
313 display: flex; align-items: center; justify-content: space-between; gap: 8px; 511 display: flex; align-items: center; justify-content: space-between; gap: 8px;
314 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 512 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
315 } 513 }
  514 +.refresh-btn {
  515 + border: none; background: transparent; cursor: pointer; font-size: 13px;
  516 + padding: 2px 4px; border-radius: 4px; transition: all 0.15s;
  517 +}
  518 +.refresh-btn:hover { background: var(--bg-surface-hover); transform: rotate(90deg); }
316 .files-tree-scroll { flex: 1; overflow-y: auto; padding: 8px; } 519 .files-tree-scroll { flex: 1; overflow-y: auto; padding: 8px; }
317 -.files-preview-scroll { flex: 1; overflow: auto; padding: 12px; background: var(--bg-input); }  
318 -.file-preview-code {  
319 - font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 13px;  
320 - line-height: 1.6; color: var(--text-secondary); white-space: pre-wrap;  
321 - word-break: break-all; margin: 0; padding: 4px; 520 +
  521 +/* Editor in template */
  522 +.editor-scroll { flex: 1; display: flex; flex-direction: column; padding: 0 !important; }
  523 +.files-preview-scroll.editor-scroll { padding: 0; }
  524 +.editor-textarea {
  525 + flex: 1; width: 100%; border: none; outline: none; resize: none;
  526 + background: var(--bg-input); color: var(--text-secondary);
  527 + font-family: 'JetBrains Mono', monospace; font-size: 13px;
  528 + line-height: 1.6; padding: 12px; box-sizing: border-box;
  529 + tab-size: 2;
322 } 530 }
  531 +.editor-textarea:focus { background: var(--bg-input-focus); }
  532 +.editor-actions { display: flex; align-items: center; gap: 4px; }
  533 +.dirty-badge {
  534 + font-size: 9px; padding: 2px 6px; border-radius: 4px;
  535 + background: rgba(251,191,36,0.15); color: var(--yellow-text); font-weight: 600;
  536 +}
  537 +.editor-btn {
  538 + border: none; padding: 3px 8px; border-radius: 4px; font-size: 11px;
  539 + cursor: pointer; transition: all 0.15s; font-weight: 600;
  540 +}
  541 +.reset-btn { background: var(--bg-input); color: var(--text-muted); }
  542 +.reset-btn:hover { background: var(--bg-surface-hover); }
  543 +.save-btn { background: var(--accent); color: #fff; }
  544 +.save-btn:hover { box-shadow: 0 2px 8px rgba(16,185,129,0.3); }
  545 +
323 .file-count-badge { 546 .file-count-badge {
324 padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; 547 padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600;
325 background: var(--accent-bg); color: var(--accent); flex-shrink: 0; 548 background: var(--accent-bg); color: var(--accent); flex-shrink: 0;