innovator.js 18.8 KB
/**
 * Innovator API Routes — Skill 管理 + Conversations + API Logs
 */
const express = require('express');
const path = require('path');
const fs = require('fs');
const router = express.Router();
const { InnovatorService } = require('../services/innovator-service');
const config = require('../config');
const { requireAuth } = require('../middleware/jwt-auth');
const { JobManager } = require('../services/job-manager');
const { RetrainPipeline } = require('../services/retrain-pipeline');
const { watchdog: retrainWatchdog } = require('../services/retrain-watchdog');
const { MasterSession } = require('../src/core/master-session');

const h = (fn) => (req, res) => {
  try { fn(req, res); } catch (e) { console.error(`[Innovator] ${req.method} ${req.path}:`, e.message); res.status(500).json({ error: e.message }); }
};

// ── 单例 ──
let _innovator = null;
function getInnovator() {
  if (!_innovator) _innovator = new InnovatorService({ skillsDir: config.skillsCodeDir, skillsDataDir: config.skillsDataDir, trainedSkillsDir: config.trainedSkillsDir, conversationsDir: config.conversationsDir });
  return _innovator;
}

const retrainJobManager = new JobManager({ storePath: path.resolve(__dirname, '../../.data/jobs-retrain.json') });
let _retrainMasterSession = null;
function getRetrainMasterSession() {
  if (_retrainMasterSession) return _retrainMasterSession;
  try {
    const backends = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/backends.json'), 'utf-8'));
    const { loadConfigFromSkills } = require('../src/core/skill-config-loader');
    _retrainMasterSession = new MasterSession(loadConfigFromSkills(config.skillsCodeDir, backends), config.skillsCodeDir);
    return _retrainMasterSession;
  } catch (err) { console.error('[Retrain] ⚠️ 无法创建 Master session:', err.message); return null; }
}

setTimeout(() => retrainWatchdog.start(retrainJobManager, getInnovator, getRetrainMasterSession), 5000);

// ══════════════════════════════════════
// Skills CRUD
// ══════════════════════════════════════

router.get('/skills', h((req, res) => {
  const skillDb = require('../services/skill-db');
  const page = parseInt(req.query.page) || 1;
  const pageSize = Math.min(parseInt(req.query.pageSize) || 50, 200);
  const { search, status, storageType, orderBy = 'created_at', order = 'DESC' } = req.query;
  const offset = (page - 1) * pageSize;

  if (search?.trim()) {
    const results = skillDb.searchSkills(search, { status: status || undefined, limit: pageSize, offset });
    const total = results.length < pageSize ? offset + results.length : offset + pageSize + 1;
    return res.json({ skills: results, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
  }
  const total = skillDb.countSkills({ status: status || undefined, storageType: storageType || undefined });
  const skills = skillDb.listSkills({ limit: pageSize, offset, status: status || undefined, storageType: storageType || undefined, orderBy, order: order.toUpperCase() === 'ASC' ? 'ASC' : 'DESC' });
  res.json({ skills, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
}));

router.get('/skills/:name', h((req, res) => {
  const skill = getInnovator().registry.getSkill(req.params.name);
  skill ? res.json(skill) : res.status(404).json({ error: 'Skill not found' });
}));

router.delete('/skills/:name', h((req, res) => {
  const removed = getInnovator().registry.removeSkill(req.params.name);
  removed ? res.json({ success: true }) : res.status(404).json({ error: 'Skill not found' });
}));

router.patch('/skills/:name', h((req, res) => res.json(getInnovator().registry.updateSkill(req.params.name, req.body))));

router.post('/skills/:name/rename', h((req, res) => {
  if (!req.body.newName) return res.status(400).json({ error: '缺少 newName' });
  res.json(getInnovator().registry.renameSkill(req.params.name, req.body.newName));
}));

router.get('/skills/:name/evolve-log', h((req, res) => {
  const skill = getInnovator().registry.getSkill(req.params.name);
  if (!skill) return res.status(404).json({ error: 'Skill not found' });
  try { res.json({ content: fs.readFileSync(path.join(getInnovator().skillsDir, req.params.name, 'evolve', 'evolve_log.md'), 'utf-8') }); }
  catch { res.json({ content: '' }); }
}));

router.post('/skills/scan', h((req, res) => {
  const registered = getInnovator().autoRegisterNewSkills();
  res.json({ registered, count: registered.length });
}));

// ── Retrain ──
router.post('/skills/:name/retrain', requireAuth, h((req, res) => {
  const innovator = getInnovator();
  const skillName = req.params.name;
  const skill = innovator.registry.getSkill(skillName);
  if (!skill) return res.status(404).json({ error: 'Skill not found' });

  const job = retrainJobManager.createJob({ type: 'retrain', skillName, status: 'queued', progress: '排队中...', instructions: req.body?.instructions || '' });
  innovator.registry.updateSkill(skillName, { status: 'training' });

  const pipeline = new RetrainPipeline({ job, skill, innovator, jobManager: retrainJobManager, instructions: req.body?.instructions || '', userId: req.user.id });
  const masterSession = getRetrainMasterSession();
  (masterSession ? pipeline.runWithMaster(masterSession) : pipeline.run()).catch(err => {
    retrainJobManager.updateJob(job.jobId, { status: 'failed', progress: `失败: ${err.message}` });
    try { innovator.registry.updateSkill(skillName, { status: 'enable' }); } catch {}
  });

  res.json({ jobId: job.jobId, skillName, status: 'queued', mode: masterSession ? 'master-orchestrated' : 'node-pipeline' });
}));

router.get('/jobs/:jobId', (req, res) => {
  const job = retrainJobManager.getJob(req.params.jobId);
  job ? res.json(job) : res.status(404).json({ error: 'Job not found' });
});

// ══════════════════════════════════════
// Conversations
// ══════════════════════════════════════

function safeReadDir(dir) { try { return fs.readdirSync(dir); } catch { return []; } }
function safeFilesInDir(dir) { return safeReadDir(dir).filter(f => { try { return !fs.statSync(path.join(dir, f)).isDirectory(); } catch { return false; } }); }

router.get('/conversations', requireAuth, h((req, res) => {
  const baseConversationsDir = getInnovator().conversationsDir;

  // Admin can see all users' conversations with ?all=true
  const isAdmin = req.user.role === 'admin';
  const showAll = isAdmin && req.query.all === 'true';

  // Collect user directories to scan
  const userDirs = [];
  if (showAll) {
    // Scan all user-* directories
    try {
      const entries = fs.readdirSync(baseConversationsDir);
      for (const entry of entries) {
        if (entry.startsWith('user-') && fs.statSync(path.join(baseConversationsDir, entry)).isDirectory()) {
          userDirs.push({ userId: entry.replace('user-', ''), dirPath: path.join(baseConversationsDir, entry) });
        }
      }
    } catch {}
  } else {
    const convDir = path.join(baseConversationsDir, `user-${req.user.id}`);
    if (fs.existsSync(convDir)) {
      userDirs.push({ userId: String(req.user.id), dirPath: convDir });
    }
  }

  if (userDirs.length === 0) return res.json([]);

  const allConversations = [];

  for (const { userId, dirPath: convDir } of userDirs) {
    const dirs = fs.readdirSync(convDir)
      .filter(d => !d.startsWith('chatbot-') && fs.statSync(path.join(convDir, d)).isDirectory())
      .map(d => {
        const dirPath = path.join(convDir, d);
        const stat = fs.statSync(dirPath);
        const useDirPath = path.join(dirPath, 'use');

        // Extract skill name from dispatchResult
        let skillName = null;
        for (const drPath of [path.join(useDirPath, 'dispatchResult.md'), path.join(dirPath, 'train', 'dispatchResult.md'), path.join(dirPath, 'dispatchResult.md')]) {
          if (skillName) break;
          try {
            const dr = fs.readFileSync(drPath, 'utf-8');
            for (const pat of [/Matched Skill[*]*[:\s]+([a-zA-Z0-9_-]+)/i, /Suggested Skill[*]*[:\s]+([a-zA-Z0-9_-]+)/i, /matchedSkillName[:\s"]*([a-zA-Z0-9_-]+)/i]) {
              const m = dr.match(pat);
              if (m && m[1] !== 'none' && m[1] !== 'null') { skillName = m[1]; break; }
            }
          } catch {}
        }

        // Extract title from meta.json
        let title = null;
        let metaData = null;
        try { metaData = JSON.parse(fs.readFileSync(path.join(dirPath, 'meta.json'), 'utf-8')); title = metaData.title; } catch {}
        if (!title) {
          for (const dlPath of [path.join(useDirPath, 'dispatch.log'), path.join(dirPath, 'dispatch.log')]) {
            try { const m = fs.readFileSync(dlPath, 'utf-8').match(/"question"\s*:\s*"([^"]+)"/); if (m) { title = m[1].substring(0, 60); break; } } catch {}
          }
        }

        // Collect files from all locations
        let useFiles = safeFilesInDir(useDirPath);
        const trainFiles = safeFilesInDir(path.join(dirPath, 'train'));
        const rootFiles = safeFilesInDir(dirPath);

        // Support ChatSession format: if messages.jsonl exists at root but no use/messages.log,
        // include it so the frontend knows to load from jsonl
        const hasJsonl = rootFiles.includes('messages.jsonl');
        const hasUseMessagesLog = useFiles.includes('messages.log');
        if (hasJsonl && !hasUseMessagesLog) {
          useFiles = [...useFiles, 'messages.jsonl'];
        }

        return {
          id: d, createdAt: stat.birthtime, skillName, title,
          userId,
          files: rootFiles, useFiles,
          trainFiles,
          outputFiles: safeFilesInDir(path.join(dirPath, 'output')),
          messageCount: metaData?.messageCount || metaData?.stats?.messageCount || 0,
          status: metaData?.status || null,
        };
      });

    allConversations.push(...dirs);
  }

  allConversations.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  res.json(allConversations);
}));

router.get('/conversations/:id/file/*', requireAuth, h((req, res) => {
  const baseDir = getInnovator().conversationsDir;
  const convId = req.params.id;
  const relFile = req.params[0];

  // Try current user's directory first
  let userConvDir = path.join(baseDir, `user-${req.user.id}`);
  let filePath = path.join(userConvDir, convId, relFile);

  // If not found and user is admin, search all user-* directories
  if (!fs.existsSync(filePath) && req.user.role === 'admin') {
    try {
      const entries = fs.readdirSync(baseDir);
      for (const entry of entries) {
        if (!entry.startsWith('user-')) continue;
        const candidatePath = path.join(baseDir, entry, convId, relFile);
        if (fs.existsSync(candidatePath)) {
          userConvDir = path.join(baseDir, entry);
          filePath = candidatePath;
          break;
        }
      }
    } catch {}
  }

  if (!path.resolve(filePath).startsWith(path.resolve(baseDir))) return res.status(403).json({ error: 'Access denied' });
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
  res.json({ name: relFile, content: fs.readFileSync(filePath, 'utf-8'), size: fs.statSync(filePath).size });
}));

router.get('/conversations/:id/evolve', requireAuth, h((req, res) => {
  const evolveDir = path.join(getInnovator().conversationsDir, `user-${req.user.id}`, req.params.id, 'evolve');
  if (!fs.existsSync(evolveDir)) return res.json([]);
  const versions = fs.readdirSync(evolveDir)
    .filter(d => d.match(/^v\d+$/) && fs.statSync(path.join(evolveDir, d)).isDirectory())
    .map(v => {
      const vDir = path.join(evolveDir, v);
      const files = fs.readdirSync(vDir).map(f => ({ name: f, path: `evolve/${v}/${f}`, size: fs.statSync(path.join(vDir, f)).size }));
      let score = null;
      try { const d = JSON.parse(fs.readFileSync(path.join(vDir, 'score.json'), 'utf-8')); score = d.totalScore || d.score; } catch {}
      return { version: v, files, score };
    })
    .sort((a, b) => parseInt(a.version.slice(1)) - parseInt(b.version.slice(1)));
  res.json(versions);
}));

router.get('/conversations/:id/evolve/:version/:file', requireAuth, h((req, res) => {
  const filePath = path.join(getInnovator().conversationsDir, `user-${req.user.id}`, req.params.id, 'evolve', req.params.version, req.params.file);
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
  res.json({ name: req.params.file, content: fs.readFileSync(filePath, 'utf-8'), size: fs.statSync(filePath).size });
}));

// ══════════════════════════════════════
// Skill Artifacts
// ══════════════════════════════════════

function resolveSkillDir(skillName) {
  const innovator = getInnovator();
  const skill = innovator.registry.getSkill(skillName);
  let dir = skill?.directory;
  if (dir && fs.existsSync(dir)) return dir;
  const basename = dir ? path.basename(dir) : null;
  if (basename && basename !== skillName) { dir = path.join(config.skillsDataDir, basename); if (fs.existsSync(dir)) return dir; }
  dir = path.join(config.skillsDataDir, skillName); if (fs.existsSync(dir)) return dir;
  dir = path.join(innovator.skillsDir, skillName); if (fs.existsSync(dir)) return dir;
  // 搜索训练目录
  const { resolveTrainedSkillDir: resolveTrainedDir } = require('../services/skill-dir-manager');
  const trainedDir = resolveTrainedDir(skillName);
  if (trainedDir) return trainedDir;
  return null;
}

router.get('/skills/:name/artifacts', h((req, res) => {
  const skillDir = resolveSkillDir(req.params.name);
  if (!skillDir) return res.status(404).json({ error: 'Skill directory not found' });
  const maxDepth = Math.min(parseInt(req.query.depth) || 3, 10);

  function walk(dir, prefix = '', depth = 0) {
    const items = [];
    for (const entry of safeReadDir(dir)) {
      const full = path.join(dir, entry), rel = prefix ? `${prefix}/${entry}` : entry;
      let stat; try { stat = fs.statSync(full); } catch { continue; }
      if (stat.isDirectory()) {
        items.push(depth < maxDepth
          ? { path: rel, type: 'dir', children: walk(full, rel, depth + 1) }
          : { path: rel, type: 'dir', children: [], truncated: true });
      } else {
        items.push({ path: rel, type: 'file', size: stat.size, modified: stat.mtime });
      }
    }
    return items;
  }
  res.json(walk(skillDir));
}));

router.get('/skills/:name/artifact/*', h((req, res) => {
  const skillDir = resolveSkillDir(req.params.name);
  if (!skillDir) return res.status(404).json({ error: 'Skill not found' });
  const filePath = path.join(skillDir, req.params[0]);
  if (!path.resolve(filePath).startsWith(path.resolve(skillDir))) return res.status(403).json({ error: 'Access denied' });
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
  res.json({ path: req.params[0], content: fs.readFileSync(filePath, 'utf-8'), size: fs.statSync(filePath).size, modified: fs.statSync(filePath).mtime });
}));

// ══════════════════════════════════════
// API Conversation Logs
// ══════════════════════════════════════

const API_CONV_DIR = path.resolve(__dirname, '../../apiconversation');

function parseLogLines(filePath) {
  try { return fs.readFileSync(filePath, 'utf-8').split('\n').filter(l => l.trim()); } catch { return []; }
}

router.get('/apiconversation', h((req, res) => {
  if (!fs.existsSync(API_CONV_DIR)) return res.json([]);
  const results = [];
  for (const entry of fs.readdirSync(API_CONV_DIR, { withFileTypes: true })) {
    if (entry.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(entry.name)) {
      const dateDir = path.join(API_CONV_DIR, entry.name);
      const files = fs.readdirSync(dateDir).filter(f => f.endsWith('.jsonl'));
      let totalCount = 0, totalSize = 0;
      const jobFiles = [];
      for (const f of files) {
        totalCount += parseLogLines(path.join(dateDir, f)).length;
        totalSize += fs.statSync(path.join(dateDir, f)).size;
        if (f !== 'access.jsonl') jobFiles.push(f.replace('.jsonl', ''));
      }
      results.push({ date: entry.name, count: totalCount, size: totalSize, files: files.length, jobs: jobFiles });
    } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
      const filePath = path.join(API_CONV_DIR, entry.name);
      results.push({ date: entry.name.replace('.jsonl', ''), count: parseLogLines(filePath).length, size: fs.statSync(filePath).size, files: 1, jobs: [], legacy: true });
    }
  }
  res.json(results.sort((a, b) => b.date.localeCompare(a.date)));
}));

router.get('/apiconversation/:date', h((req, res) => {
  const { date } = req.params;
  const filterFile = req.query.file;
  let all = [];
  const dateDir = path.join(API_CONV_DIR, date);
  const legacyFile = path.join(API_CONV_DIR, `${date}.jsonl`);

  if (fs.existsSync(dateDir) && fs.statSync(dateDir).isDirectory()) {
    for (const f of fs.readdirSync(dateDir).filter(f => f.endsWith('.jsonl'))) {
      const source = f.replace('.jsonl', '');
      if (filterFile && source !== filterFile) continue;
      all.push(...parseLogLines(path.join(dateDir, f)).map(l => { try { return { ...JSON.parse(l), _source: source }; } catch { return null; } }).filter(Boolean));
    }
  } else if (fs.existsSync(legacyFile)) {
    all = parseLogLines(legacyFile).map(l => { try { return { ...JSON.parse(l), _source: 'access' }; } catch { return null; } }).filter(Boolean);
  } else {
    return res.status(404).json({ error: 'Log not found' });
  }

  all.reverse();
  const categories = { search: { label: '🔍 搜索', entries: [] }, download: { label: '📦 下载', entries: [] }, create: { label: '🛠 创建', entries: [] }, feedback: { label: '💬 反馈', entries: [] }, jobs: { label: '⏳ Job', entries: [] }, auth_fail: { label: '🔒 认证失败', entries: [] }, other: { label: '📋 其他', entries: [] } };
  for (const e of all) {
    const p = e.path || '';
    const cat = (e.status === 401 || e.status === 403) ? 'auth_fail' : p.includes('/search') ? 'search' : p.includes('/download') ? 'download' : p.includes('/feedback') ? 'feedback' : (p.includes('/create') || e.action?.includes('创建')) ? 'create' : p.includes('/jobs') ? 'jobs' : 'other';
    categories[cat].entries.push(e);
  }

  const summary = Object.fromEntries(Object.entries(categories).map(([k, v]) => [k, { label: v.label, count: v.entries.length }]));
  const fileList = fs.existsSync(dateDir) && fs.statSync(dateDir).isDirectory() ? fs.readdirSync(dateDir).filter(f => f.endsWith('.jsonl')).map(f => f.replace('.jsonl', '')) : [];
  res.json({ date, total: all.length, summary, categories, files: fileList });
}));

module.exports = router;