import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
import { DB } from '../db.js'
import path from 'path'
import fs from 'fs'
import vm from 'vm'
import { fileURLToPath } from 'url'
import { normalizeFlowchartSpec, parseFlowchartJson } from './flowchart-utils.js'
import {
  isAgentRunReady,
  missingAgentRunConfig,
  rowToAgentRunProvider,
} from '../runtime/agentrun-config.js'
import {
  buildLocalRuntimePrompt,
  missingRuntimeConfig,
  resolveRuntimeForPurpose,
  rowToAgent,
  rowToRuntime,
  streamLocalRuntime,
} from '../runtime/runtime-registry.js'
import {
  buildAgentRunChatCompletionsRequest,
  extractOpenAIChunkText,
  isAgentRunIntegrationUrl,
} from '../runtime/agentrun-openai.js'
import {
  buildQoderCloudPrompt,
  runQoderCloudAgent,
  QODER_CLOUD_AGENT_PROTOCOL,
} from '../runtime/qoder-cloud-agent.js'
import { normalizeArtifact } from '../runtime/runtime-events.js'
import { getActiveSystemSkillContent } from './system-skills.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

const app = new Hono()

// Skill ZIP 包根目录（uploads.js 同一份逻辑：cf-worker/uploads/，可被 UPLOADS_DIR 覆盖）
const UPLOADS_ROOT = process.env.UPLOADS_DIR || path.join(__dirname, '../../uploads')

// ──────────────────────────────────────────────────────────
// 输出目录：每次会话一个子目录，LLM 通过工具往里写文件
//   本地默认 ./outputs，服务器 .env 里设 /home/aipro/outputs
// ──────────────────────────────────────────────────────────
const OUTPUTS_DIR = path.resolve(process.env.OUTPUTS_DIR || './outputs')
const SESSION_EVENTS_FILE = '.skill-run-events.jsonl'
const SESSION_STATE_FILE = '.skill-run-state.json'
const SKILL_RUN_TIMEOUT_MS = Math.max(Number(process.env.SKILL_RUN_TIMEOUT_MS) || 30 * 60 * 1000, 60_000)
const SKILL_RUN_HEARTBEAT_MS = Math.max(Number(process.env.SKILL_RUN_HEARTBEAT_MS) || 15_000, 1000)
const RUN_JOBS = new Map()
const STANDARD_SKILL_PACKAGE_FILES = ['SKILL.md', 'examples.md', 'rubric.md']
const SKILL_SPEC_FILE = 'skill.spec.json'

// ──────────────────────────────────────────────────────────
// 默认 Skill 评测系统提示词（前端没传 systemPrompt 时兜底）
// 注意：不要在此处用自然语言重复描述工具参数格式（write_file/read_file/list_files
// 这些说明在历史直连模式中曾通过工具 schema 下发给模型）。两份描述会让部分模型
// 把工具调用 arguments 输出成空对象 {}。这里只保留行为指引。
// ──────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────
// Skill 创建助手系统提示词（mode === 'create' 时使用）
// ──────────────────────────────────────────────────────────
export const SKILL_CREATOR_PROMPT = `你是当前配置的 AI Skill 创建智能体。

请直接执行用户交给你的任务。信息不足时可以直接向用户追问，并在用户补充后沿用当前会话继续执行。可以自由使用当前运行时提供的工具并生成任意需要的文件；平台不会要求固定输出格式，也不会在生成阶段进行评测或完整性卡控。把运行时实际产生的进度、可公开的推理摘要、工具事件、结果和文件如实返回。`

export const SKILL_RUNNER_RUNTIME_CONTRACT = `## Skill 运行契约

你正在真实运行一个已加载的 Skill，不是在讲解、演示或展示伪代码。

- 如果 Skill 的“依赖工具”或“核心指令”要求生成文件、联网搜索、浏览器操作、代码执行或 Office 产物，必须真实发起对应工具调用。
- 禁止在正文里模拟工具调用格式，例如 create_word(...)、create_pdf(...)、create_excel(...)、create_html(...)。
- 禁止在没有收到工具结果前说“文件已生成”“已调用工具”“执行成功”。
- 如果用户输入不足，但用户要求样例或当前是自动试运行，请使用合理默认假设继续真实执行，并在结果中标记“样例版，待确认”。
- 工具失败时，必须说明真实失败原因并给出重试建议。`

export const SKILL_EVALUATOR_RUNTIME_CONTRACT = `## Skill 评测运行契约

你正在真实评测一个已解压到当前工作目录的 Skill 包。

- 必须先调用 list_files 查看包内文件清单。
- 必须调用 read_file 阅读 SKILL.md、examples.md、rubric.md、skill.spec.json；如果某个文件不存在，必须按 list_files 的真实结果指出缺失。
- 没有成功读取上述已存在文件前，禁止给总分、等级或建议上架结论。
- 评测报告必须引用具体文件名和证据，不允许只根据用户消息或历史印象评分。
- 不得使用固定分、历史分或模板化评分；四个分项必须能相加得到总分。
- 如果包内文件缺失，必须按真实文件清单指出缺失项。`

const SKILL_EVALUATOR_PROMPT = `你是一个专业的 AI Skill 评测助手，负责评测 Agent Skill 文档的质量。

评测维度（满分100）：
- 规格完整性（25分）：描述是否清晰完整，边界是否明确
- 触发准确性（25分）：触发词/条件是否合理，不会误触也不会漏触
- 内容质量（30分）：步骤清晰度，提示词质量，示例是否充分
- 可维护性（20分）：结构是否清晰，是否易于后续更新

评测时请：
1. 给出总分（xx/100）
2. 按维度评分并简评
3. 列出2-3个核心问题
4. 给出具体改进建议
5. 必须引用具体文件名、文件字段和工具读取证据；不得套用固定分、历史分或模板化评分。若总分与历史评测反复相同，必须说明四个分项相加依据。

你是一个能生成文件的助手。当你需要产出报告、文档或任何可下载的文件时，必须调用工具完成，不要只把内容输出在对话里。

你可以使用以下工具操作 Skill 包内的任意文件（不限于 .md，也支持子目录）：
- list_files：递归列出包内所有文件（含相对路径）
- read_file：读取任意文件内容
- write_file：修改或新增文件（支持 images/logo.png 这样的子路径）
- delete_file：删除文件

评测开始时，请先用 list_files 查看包结构，再用 read_file 阅读关键文件，然后给出评测报告。
当用户要求采纳改进时，你可以直接修改对应文件（不限于 .md，可以修改任何文件）。

请用中文回复。`

async function resolveSystemSkillPrompt(kind, fallbackPrompt) {
  const configured = await getActiveSystemSkillContent(kind)
  return configured || fallbackPrompt
}

async function readPlatformSkillPrompt(skillId) {
  const id = String(skillId || '').trim()
  if (!id) return ''
  const row = await DB.prepare(`
    SELECT id, name, description, package_key, package_name
    FROM skills
    WHERE id = ?
    LIMIT 1
  `).bind(id).first()
  if (!row) return ''

  const parts = [
    `# ${row.name || '平台 Skill'}`,
    row.description ? `\n${row.description}` : '',
  ]
  const key = String(row.package_key || '').trim()
  if (!key) return parts.join('\n').trim()

  try {
    const packagePath = path.resolve(UPLOADS_ROOT, path.normalize(key))
    const uploadsResolved = path.resolve(UPLOADS_ROOT)
    if (!packagePath.startsWith(uploadsResolved + path.sep)) return parts.join('\n').trim()
    const buffer = await fs.promises.readFile(packagePath)
    const ext = path.extname(key).toLowerCase()
    if (ext === '.zip') {
      const JSZip = (await import('jszip')).default
      const zip = await JSZip.loadAsync(buffer)
      const entries = Object.values(zip.files)
        .filter(entry => !entry.dir)
        .filter(entry => /\.(md|markdown|txt|json|ya?ml)$/i.test(entry.name))
        .sort((a, b) => {
          const rank = (name) => /(^|\/)skill\.md$/i.test(name) ? 0 : /(^|\/)examples\.md$/i.test(name) ? 1 : /(^|\/)rubric\.md$/i.test(name) ? 2 : 10
          return rank(a.name) - rank(b.name) || a.name.localeCompare(b.name)
        })
        .slice(0, 12)
      for (const entry of entries) {
        const text = await entry.async('string')
        if (text.trim()) parts.push(`\n\n## ${entry.name}\n${text.trim().slice(0, 30000)}`)
      }
    } else if (['.md', '.markdown', '.txt', '.json', '.yaml', '.yml'].includes(ext)) {
      parts.push(`\n\n## ${row.package_name || path.basename(key)}\n${buffer.toString('utf8').slice(0, 60000)}`)
    }
  } catch (e) {
    console.warn('[skill-run] mounted skill read failed:', e?.message || e)
  }
  return parts.join('\n').trim().slice(0, 90000)
}

async function resolveAgentSkillPrompt(agent, fallbackKind, fallbackPrompt) {
  const mounted = await readPlatformSkillPrompt(agent?.skill_id)
  if (mounted) return mounted
  return resolveSystemSkillPrompt(fallbackKind, fallbackPrompt)
}

export function buildCreatorSystemPrompt(systemSkillContent) {
  return String(systemSkillContent || SKILL_CREATOR_PROMPT).trim() || SKILL_CREATOR_PROMPT
}

const FLOWCHART_GENERATOR_PROMPT = `你是一个严谨的业务流程图生成 Agent。你运行在当前 AI 执行内核中，必须基于用户输入的自然语言和流程文件内容生成可编辑流程图数据。

目标：
1. 识别流程活动、执行者、上下游依赖、输入输出、关键上下文。
2. 输出平台可直接渲染和编辑的流程图 JSON。
3. 不要编造具体人名、公司名、内部 ID；如果输入中出现真实敏感标识，请泛化成角色或部门。

只输出一个 JSON 对象，不要输出 Markdown，不要输出解释。

JSON Schema：
{
  "title": "流程图标题",
  "summary": "一句话说明流程边界",
  "lanes": [
    { "id": "lane_requester", "name": "申请人" }
  ],
  "nodes": [
    {
      "id": "node_1",
      "label": "活动名称",
      "type": "start|activity|decision|document|system|end",
      "actor": "执行者/角色",
      "context": "该节点要点、上下文、表单或规则",
      "inputs": ["输入材料"],
      "outputs": ["输出结果"],
      "x": 8,
      "y": 20
    }
  ],
  "edges": [
    { "id": "edge_1", "source": "node_1", "target": "node_2", "label": "条件或依赖" }
  ],
  "notes": ["可选风险或后续澄清点"]
}

约束：
- nodes 至少 4 个，除非用户明确要求极简。
- id 只能使用英文字母、数字、下划线、短横线。
- x/y 使用 0-100 的相对坐标，按从左到右、从上到下排列。
- actor 必须是业务角色，不要留空。
- edges 的 source/target 必须引用已有 node id。`

// ──────────────────────────────────────────────────────────
// OpenAI 兼容工具定义：AgentRun 直连接口使用
// ──────────────────────────────────────────────────────────
const TOOLS = [
  {
    type: 'function',
    function: {
      name: 'write_file',
      description: '把内容写入服务器磁盘文件。支持 .html .md .txt .json 等格式。写完后用户可以下载。',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名（只填文件名，不含目录，如 report.html）' },
          content: { type: 'string', description: '文件完整内容' },
        },
        required: ['path', 'content'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'create_html',
      description: '生成完整 HTML 文件并保存到会话目录。适合生成公众号排版、报告页面、可交互工具、小型前端原型。支持 bodyHtml/content、CSS 和 JavaScript。',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名，必须以 .html 或 .htm 结尾，如 report.html' },
          title: { type: 'string', description: '页面标题' },
          bodyHtml: { type: 'string', description: 'body 内的 HTML 内容' },
          content: { type: 'string', description: '完整 HTML，或 body 内容。提供 bodyHtml 时优先使用 bodyHtml' },
          css: { type: 'string', description: '可选 CSS，会写入 <style>' },
          script: { type: 'string', description: '可选 JavaScript，会写入 <script>' },
        },
        required: ['path'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'read_file',
      description: '读取之前写入的文件内容',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名' },
        },
        required: ['path'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'list_files',
      description: '列出当前会话已生成的文件',
      parameters: { type: 'object', properties: {} },
    },
  },
  {
    type: 'function',
    function: {
      name: 'fetch_url',
      description: '通过 HTTP GET 获取网页或 API 接口的文本内容。返回响应正文的前 10000 字符。适合抓取网页内容、读取 JSON 接口数据。支持可选自定义请求头。',
      parameters: {
        type: 'object',
        properties: {
          url: { type: 'string', description: '要访问的 URL，必须以 http:// 或 https:// 开头' },
          headers: { type: 'object', description: '可选自定义请求头，如 {"Authorization": "Bearer xxx", "Accept": "application/json"}' },
        },
        required: ['url'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'http_request',
      description: '向外部 API 发起 HTTP 请求。支持 GET/POST/PUT/DELETE/PATCH，支持自定义请求头和请求体。可用于调用公司内部系统、第三方 API、webhook 等。',
      parameters: {
        type: 'object',
        properties: {
          url: { type: 'string', description: '请求的完整 URL' },
          method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], description: 'HTTP 方法，默认 GET' },
          headers: { type: 'object', description: '自定义请求头，如 {"Authorization": "Bearer xxx", "Content-Type": "application/json"}' },
          body: { type: 'string', description: '请求体内容（POST/PUT/PATCH 时使用），JSON 字符串或其他格式' },
          timeout: { type: 'number', description: '超时时间（毫秒），默认 30000' },
        },
        required: ['url'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'web_search',
      description: '搜索网络，返回与关键词相关的摘要和链接。适合查找资料、了解最新信息。',
      parameters: {
        type: 'object',
        properties: {
          query: { type: 'string', description: '搜索关键词' },
        },
        required: ['query'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'run_js',
      description: '在安全沙盒中执行 JavaScript 代码，返回执行结果或输出。适合数学计算、数据处理、格式转换等。注意：沙盒内无法访问文件系统、网络或 require，超时 5 秒。',
      parameters: {
        type: 'object',
        properties: {
          code: { type: 'string', description: '要执行的 JavaScript 代码' },
        },
        required: ['code'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'browser_screenshot',
      description: '用真实无头浏览器打开公网网页并截图，截图保存到会话目录。默认禁止访问 localhost、127.0.0.1 和内网地址。',
      parameters: {
        type: 'object',
        properties: {
          url: { type: 'string', description: '要打开的公网 URL，必须以 http:// 或 https:// 开头' },
          path: { type: 'string', description: '截图文件名，必须以 .png 结尾，如 page.png' },
          fullPage: { type: 'boolean', description: '是否截全页，默认 true' },
          waitMs: { type: 'number', description: '打开后额外等待毫秒数，默认 500，最多 5000' },
        },
        required: ['url', 'path'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'browser_automation',
      description: '用真实无头浏览器执行一组页面操作。支持 goto、fill、click、press、wait、text、screenshot，并返回页面标题/URL/文本或截图文件。默认禁止访问 localhost、127.0.0.1 和内网地址。',
      parameters: {
        type: 'object',
        properties: {
          url: { type: 'string', description: '起始公网 URL，必须以 http:// 或 https:// 开头' },
          steps: {
            type: 'array',
            description: '操作步骤数组',
            items: {
              type: 'object',
              properties: {
                action: { type: 'string', enum: ['goto', 'fill', 'click', 'press', 'wait', 'text', 'screenshot'] },
                selector: { type: 'string', description: 'CSS 选择器，fill/click/press/text 使用' },
                value: { type: 'string', description: 'fill/press 使用的文本或按键' },
                url: { type: 'string', description: 'goto 使用的公网 URL' },
                path: { type: 'string', description: 'screenshot 使用的 .png 文件名' },
                waitMs: { type: 'number', description: 'wait 或截图前等待时间，最多 5000' },
                fullPage: { type: 'boolean', description: '截图是否全页' },
              },
              required: ['action'],
            },
          },
          timeoutMs: { type: 'number', description: '总超时，默认 30000，最多 60000' },
        },
        required: ['url'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'get_time',
      description: '获取当前日期和时间（ISO 8601 格式，北京时间）',
      parameters: { type: 'object', properties: {} },
    },
  },
  {
    type: 'function',
    function: {
      name: 'append_file',
      description: '向已存在的文件末尾追加内容（文件不存在则自动创建）。适合分批写入内容、追加日志、逐段生成长文档。',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名（只填文件名，不含目录）' },
          content: { type: 'string', description: '要追加的内容' },
        },
        required: ['path', 'content'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'delete_file',
      description: '删除当前会话目录中的一个文件',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名' },
        },
        required: ['path'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'create_word',
      description: '根据 Markdown 文本生成 Word 文档（.docx）并保存到会话目录，用户可下载。支持标题（# ## ###）、段落、粗体（**text**）、无序列表（- item）。',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名，必须以 .docx 结尾，如 report.docx' },
          content: { type: 'string', description: 'Markdown 格式的文档内容' },
        },
        required: ['path', 'content'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'create_pdf',
      description: '根据 Markdown 或 HTML 内容生成 PDF 文件（.pdf）并保存到会话目录，用户可下载。适合正式报告、流程文件、评测报告等最终交付物。',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名，必须以 .pdf 结尾，如 report.pdf' },
          title: { type: 'string', description: 'PDF 标题，可选' },
          content: { type: 'string', description: 'Markdown 或纯文本内容。提供 html 时可省略' },
          html: { type: 'string', description: '完整 HTML 或 body HTML。提供后优先使用 HTML 渲染' },
          css: { type: 'string', description: '可选 CSS，会写入 <style>' },
        },
        required: ['path'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'create_excel',
      description: '根据表格数据生成 Excel 文件（.xlsx）并保存到会话目录，用户可下载。',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名，必须以 .xlsx 结尾，如 data.xlsx' },
          headers: { type: 'array', items: { type: 'string' }, description: '表头列名数组，如 ["姓名","部门","薪资"]' },
          rows: { type: 'array', items: { type: 'array' }, description: '数据行数组，如 [["张三","技术",15000]]' },
          sheet_name: { type: 'string', description: 'Sheet 名称，默认 Sheet1' },
        },
        required: ['path', 'headers', 'rows'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'create_pptx',
      description: '根据幻灯片数据生成 PowerPoint 文件（.pptx）并保存到会话目录，用户可下载。',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名，必须以 .pptx 结尾，如 slides.pptx' },
          slides: {
            type: 'array',
            description: '幻灯片数组，每张包含 title（标题）、content（正文）或 bullets（要点列表）',
            items: {
              type: 'object',
              properties: {
                title: { type: 'string' },
                content: { type: 'string' },
                bullets: { type: 'array', items: { type: 'string' } },
              },
            },
          },
        },
        required: ['path', 'slides'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'parse_pdf',
      description: '解析当前会话目录中的 PDF 文件，返回其文本内容。用于读取用户上传或生成的 PDF。',
      parameters: {
        type: 'object',
        properties: {
          path: { type: 'string', description: '文件名，必须以 .pdf 结尾' },
        },
        required: ['path'],
      },
    },
  },
]

const TOOL_GROUPS = {
  write_file: 'files',
  read_file: 'files',
  list_files: 'files',
  append_file: 'files',
  delete_file: 'files',
  create_html: 'html',
  fetch_url: 'network',
  http_request: 'network',
  web_search: 'network',
  run_js: 'code',
  get_time: 'runtime',
  browser_screenshot: 'browser',
  browser_automation: 'browser',
  create_word: 'office',
  create_pdf: 'office',
  create_excel: 'office',
  create_pptx: 'office',
  parse_pdf: 'office',
}

export function getSkillRuntimeTools() {
  return TOOLS.map(tool => ({
    name: tool.function.name,
    description: tool.function.description,
    group: TOOL_GROUPS[tool.function.name] || 'other',
    parameters: tool.function.parameters || { type: 'object', properties: {} },
  }))
}

async function pickActiveAgentRunRow() {
  return await DB.prepare(
    "SELECT * FROM runtime_engines WHERE code = 'agentrun' AND enabled = 1 AND is_active = 1 LIMIT 1"
  ).first() || null
}

function isSuperAdminIdentity(identity) {
  return identity?.email === '494161546@qq.com' || identity?.phone === '13136092523'
}

function missingAgentRunRuntimeError() {
  return '请在「智能体配置 / 运行时注册」启用并设为当前运行时：AgentRun。Skill 生成、运行、评测统一走 AgentRun，不再回退旧执行内核。'
}

function missingRuntimeError(purpose = 'run') {
  const map = { create: 'AI 创建', evaluate: '评测', run: '运行' }
  return `请在「智能体配置 / 运行时注册」为「${map[purpose] || purpose}」配置并启用运行时。`
}

function runtimeLabel(row = {}) {
  if (row.protocol === 'agentrun') return 'AgentRun'
  if (row.protocol === QODER_CLOUD_AGENT_PROTOCOL) return 'QODER Cloud Agent'
  if (row.protocol === 'local-cli') return `本地 CLI：${row.name || row.code}`
  return row.name || row.code || '运行时'
}

// GET / —— 返回 AgentRun 引擎是否就绪（不含 key）
// ──────────────────────────────────────────────────────────
// 关于 systemPrompt 字段：skills 表里 skill 的文本内容字段名是 `description`
// （表结构：id / name / description / node_id / node_path / source / status / ...
//  没有 prompt / content / system_prompt 字段，完整 SKILL.md 在 R2 的 package_key zip 里）。
// 前端调用 POST /api/skill-run/stream 时，应把 skill.description（或解压后的
// SKILL.md 全文）作为 body.systemPrompt 传过来；不传则使用 SKILL_EVALUATOR_PROMPT 兜底。
// ──────────────────────────────────────────────────────────
app.get('/', async (c) => {
  const identity = c.get('identity')
  if (!identity?.phone && !identity?.email) return c.json({ error: 'Unauthorized' }, 401)

  const { agent, runtime } = await resolveRuntimeForPurpose('run')
  if (runtime) {
    const missing = missingRuntimeConfig(runtime)
    return c.json({
      hasEngine: missing.length === 0,
      engineKind: runtime.protocol,
      agentRun: runtime.protocol === 'agentrun' ? rowToAgentRunProvider(runtime) : null,
      runtime: rowToRuntime(runtime),
      agent: agent ? rowToAgent(agent) : null,
      missing,
    })
  }

  return c.json({
    hasEngine: false,
    engineKind: null,
    agentRun: null,
    runtime: null,
    agent: null,
    missing: ['runtime'],
    error: missingRuntimeError('run'),
  })
})

app.get('/tools', async (c) => {
  const identity = c.get('identity')
  if (!identity?.phone && !identity?.email) return c.json({ error: 'Unauthorized' }, 401)
  const tools = getSkillRuntimeTools()
  const groups = tools.reduce((acc, tool) => {
    acc[tool.group] ||= []
    acc[tool.group].push(tool)
    return acc
  }, {})
  return c.json({ ok: true, count: tools.length, tools, groups })
})

// ──────────────────────────────────────────────────────────
// POST /flowchart —— AI 绘图专用入口：自然语言/流程文件 → 当前执行内核 → 流程图 JSON
// ──────────────────────────────────────────────────────────
app.post('/flowchart', async (c) => {
  const identity = c.get('identity')
  if (!identity?.phone && !identity?.email) return c.json({ error: 'Unauthorized' }, 401)
  if (!isSuperAdminIdentity(identity)) return c.json({ error: '仅超级管理员可使用 AI 绘图' }, 403)

  const agentRun = await pickActiveAgentRunRow()
  if (!agentRun) {
    return c.json({ error: missingAgentRunRuntimeError() }, 503)
  }
  const missing = missingAgentRunConfig(agentRun)
  if (missing.length) {
    return c.json({ error: `请在「智能体配置 / 运行时注册」补齐 AgentRun 配置：${missing.join(', ')}` }, 503)
  }

  const contentType = c.req.header('content-type') || ''
  let prompt = ''
  let mode = 'react-flow'
  let uploaded = { name: '', text: '' }
  const sessionId = newSessionId()
  const sessionDir = resolveSessionDir(sessionId)

  if (contentType.includes('multipart/form-data')) {
    const body = await c.req.parseBody()
    prompt = String(body.prompt || '').trim()
    mode = String(body.mode || 'react-flow').trim()
    if (body.file && typeof body.file === 'object' && typeof body.file.arrayBuffer === 'function') {
      uploaded = await extractUploadedFileText(body.file, sessionDir)
    }
  } else {
    let body
    try { body = await c.req.json() } catch { body = {} }
    prompt = String(body.prompt || '').trim()
    mode = String(body.mode || 'react-flow').trim()
    if (body.sourceFile && typeof body.sourceFile === 'object') {
      uploaded = {
        name: String(body.sourceFile.name || ''),
        text: String(body.sourceFile.text || body.sourceFile.content || '').slice(0, 120_000),
      }
    }
  }

  if (!prompt && !uploaded.text) {
    return c.json({ error: '请先输入流程描述，或选择可抽取文本的流程文件' }, 400)
  }

  const userPrompt = [
    '请生成业务流程图。',
    `输出底座偏好：${mode || 'react-flow'}`,
    prompt ? `\n## 用户流程描述\n${prompt}` : '',
    uploaded.name ? `\n## 用户选择的流程文件\n文件名：${uploaded.name}` : '',
    uploaded.text ? `\n## 流程文件正文\n${uploaded.text}` : '',
    uploaded.name && !uploaded.text ? '\n说明：文件已保存到当前 AI 工作目录，你可以读取它辅助理解。' : '',
  ].filter(Boolean).join('\n')

  const upstreamCtrl = new AbortController()
  const timer = setTimeout(() => {
    try { upstreamCtrl.abort(new Error('timeout after 300000ms')) } catch {}
  }, 300000)

  try {
    const collectFlowchartText = (systemPrompt, chatMessages) => collectAgentRunText({
        agentRunRow: agentRun,
        systemPrompt,
        chatMessages,
        sessionId,
        sessionDir,
        abortController: upstreamCtrl,
      })

    const { raw, events } = await collectFlowchartText(
      FLOWCHART_GENERATOR_PROMPT,
      [{ role: 'user', content: userPrompt }]
    )

    let parsed
    try {
      parsed = parseFlowchartJson(raw)
    } catch (firstError) {
      const repairPrompt = `请把下面内容修复为严格 JSON 对象，只输出 JSON，不要解释。\n\n${raw.slice(0, 120000)}`
      const repair = await collectFlowchartText(
        '你是 JSON 修复器。只输出一个合法 JSON 对象。',
        [{ role: 'user', content: repairPrompt }]
      )
      try {
        parsed = parseFlowchartJson(repair.raw)
      } catch {
        throw new Error(`大模型返回内容不是合法流程图 JSON：${firstError.message}`)
      }
    }

    const data = normalizeFlowchartSpec(parsed)
    return c.json({
      ok: true,
      data,
      raw,
      sessionId,
      events,
      sourceFile: uploaded.name ? { name: uploaded.name, extractedChars: uploaded.text?.length || 0 } : null,
      engine: { kind: 'agentrun', agentRun: rowToAgentRunProvider(agentRun) },
    })
  } catch (e) {
    return c.json({ error: e?.message || String(e) }, /auth|token|login|credential|配置|PAT/i.test(e?.message || '') ? 503 : 502)
  } finally {
    clearTimeout(timer)
    try { upstreamCtrl.abort() } catch {}
  }
})

// ──────────────────────────────────────────────────────────
// 描述 fetch 错误
// ──────────────────────────────────────────────────────────
function describeFetchError(e) {
  const msg = String(e?.message || '')
  const causeCode = e?.cause?.code || ''
  const causeMsg = e?.cause?.message || ''
  if (/timeout/i.test(msg)) return '请求超时，请稍后重试'
  if (/terminated/i.test(msg) || causeCode === 'UND_ERR_SOCKET' || /ECONNRESET/.test(causeCode + causeMsg)) {
    return '网络波动导致连接中断，请稍后再试'
  }
  if (causeCode === 'ENOTFOUND' || /ENOTFOUND/.test(causeMsg)) return 'DNS 解析失败，请检查网络'
  if (causeCode === 'ECONNREFUSED' || /ECONNREFUSED/.test(causeMsg)) return '服务器拒绝连接'
  if (/abort/i.test(msg)) return '请求被中止'
  return `请求失败：${msg}${causeMsg ? '（' + causeMsg + '）' : ''}`
}

// ──────────────────────────────────────────────────────────
// 会话相关工具函数
// ──────────────────────────────────────────────────────────
function newSessionId() {
  return globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + Math.random().toString(36).slice(2, 10))
}

function resolveSessionDir(sessionId) {
  // 防御：去掉所有路径分隔符
  const safeId = String(sessionId || '').replace(/[^a-z0-9_-]/gi, '')
  if (!safeId) return null
  return path.resolve(OUTPUTS_DIR, safeId)
}

// 兼容子路径：支持 'images/logo.png' 这种相对路径，并禁止 .. / 隐藏文件 / 控制字符
function safeFilename(name) {
  if (!name || typeof name !== 'string') return null
  const normalized = name.replace(/\\/g, '/')
  const trimmed = normalized.replace(/^\/+/, '')
  if (!trimmed) return null
  const segments = trimmed.split('/')
  for (const seg of segments) {
    if (!seg || seg === '.' || seg === '..') return null
    if (seg.startsWith('.')) return null
    if (/[\x00-\x1f]/.test(seg)) return null
  }
  return segments.join('/')
}

// 路径越权检查：确保 fp 仍在 sessionDir 内
function isInside(parentDir, fp) {
  const resolvedParent = path.resolve(parentDir)
  const resolvedChild = path.resolve(fp)
  return resolvedChild === resolvedParent || resolvedChild.startsWith(resolvedParent + path.sep)
}

async function mirrorRemoteWriteArtifact(event, sessionDir) {
  if (!event || event.t !== 'tool_call' || !sessionDir) return ''
  const toolName = String(event.name || '').toLowerCase()
  if (!['write', 'write_file'].includes(toolName)) return ''
  const args = event.args && typeof event.args === 'object' ? event.args : {}
  if (typeof args.content !== 'string') return ''
  const remotePath = String(args.file_path || args.path || args.filename || '')
  const filename = safeFilename(path.basename(remotePath))
  if (!filename) return ''
  const target = path.join(sessionDir, filename)
  if (!isInside(sessionDir, target)) return ''
  await fs.promises.mkdir(path.dirname(target), { recursive: true })
  await fs.promises.writeFile(target, args.content, 'utf8')
  return filename
}

function safePackageEntryPath(relativePath, zipEntry) {
  const originalPath = String(zipEntry?.unsafeOriginalName || relativePath || '').replace(/\\/g, '/')
  if (!originalPath) return null
  if (originalPath.startsWith('/') || /^[a-z]:\//i.test(originalPath)) return null
  if (originalPath.startsWith('__MACOSX/')) return null
  return safeFilename(originalPath)
}

export async function materializePackageBufferToSession({ buffer, packageKey, sessionDir }) {
  if (!buffer || !sessionDir) return []
  await fs.promises.mkdir(sessionDir, { recursive: true })

  const key = String(packageKey || '')
  const ext = path.extname(key).toLowerCase()
  const written = []

  if (ext === '.zip') {
    const JSZip = (await import('jszip')).default
    const zip = await JSZip.loadAsync(buffer)
    const entries = Object.entries(zip.files)
    for (const [relativePath, zipEntry] of entries) {
      if (zipEntry.dir) continue
      const safePath = safePackageEntryPath(relativePath, zipEntry)
      if (!safePath) continue
      const fp = path.join(sessionDir, safePath)
      if (!isInside(sessionDir, fp)) continue
      await fs.promises.mkdir(path.dirname(fp), { recursive: true })
      const content = await zipEntry.async('nodebuffer')
      await fs.promises.writeFile(fp, content)
      written.push(safePath)
    }
    return written
  }

  const rawBase = safeFilename(path.basename(key))
  const fallbackName = ['.md', '.markdown'].includes(ext)
    ? 'SKILL.md'
    : (rawBase || (ext === '.txt' ? 'package.txt' : 'package.bin'))
  const fp = path.join(sessionDir, fallbackName)
  if (!isInside(sessionDir, fp)) return written
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
  await fs.promises.writeFile(fp, buffer)
  written.push(fallbackName)
  return written
}

const MIME_MAP = {
  '.html': 'text/html; charset=utf-8',
  '.htm': 'text/html; charset=utf-8',
  '.md': 'text/markdown; charset=utf-8',
  '.markdown': 'text/markdown; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.txt': 'text/plain; charset=utf-8',
  '.csv': 'text/csv; charset=utf-8',
  '.svg': 'image/svg+xml',
  '.xml': 'application/xml; charset=utf-8',
  '.yaml': 'text/yaml; charset=utf-8',
  '.yml': 'text/yaml; charset=utf-8',
  '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  '.pdf': 'application/pdf',
}

function decodeXmlEntities(text) {
  return String(text || '')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'")
}

async function extractDocxText(buffer) {
  const JSZip = (await import('jszip')).default
  const zip = await JSZip.loadAsync(buffer)
  const entry = zip.file('word/document.xml')
  if (!entry) return ''
  const xml = await entry.async('text')
  return decodeXmlEntities(
    xml
      .replace(/<w:tab\/>/g, '\t')
      .replace(/<\/w:p>/g, '\n')
      .replace(/<[^>]+>/g, '')
  ).trim()
}

async function extractXlsxText(buffer) {
  const XLSX = await import('xlsx')
  const workbook = XLSX.read(buffer, { type: 'buffer' })
  return workbook.SheetNames.map(name => {
    const csv = XLSX.utils.sheet_to_csv(workbook.Sheets[name] || {})
    return csv ? `# ${name}\n${csv}` : ''
  }).filter(Boolean).join('\n\n').trim()
}

async function extractPdfText(buffer) {
  const mod = await import('pdf-parse')
  const parse = mod.default || mod
  const result = await parse(buffer)
  return String(result?.text || '').trim()
}

async function extractUploadedFileText(file, sessionDir) {
  if (!file || typeof file.arrayBuffer !== 'function') return { name: '', text: '' }
  const originalName = String(file.name || 'process-file')
  const filename = safeFilename(path.basename(originalName)) || 'process-file'
  const buffer = Buffer.from(await file.arrayBuffer())
  await fs.promises.mkdir(sessionDir, { recursive: true })
  const filePath = path.join(sessionDir, filename)
  if (isInside(sessionDir, filePath)) {
    await fs.promises.writeFile(filePath, buffer)
  }

  const ext = path.extname(filename).toLowerCase()
  let text = ''
  try {
    if (['.txt', '.md', '.markdown', '.csv', '.json', '.xml', '.yaml', '.yml'].includes(ext)) {
      text = buffer.toString('utf8')
    } else if (ext === '.docx') {
      text = await extractDocxText(buffer)
    } else if (ext === '.xlsx') {
      text = await extractXlsxText(buffer)
    } else if (ext === '.pdf') {
      text = await extractPdfText(buffer)
    } else if (buffer.length <= 300_000) {
      text = buffer.toString('utf8')
    }
  } catch (e) {
    text = `（文件 ${filename} 已上传到当前 AI 工作目录，但服务端抽取文本失败：${e?.message || e}）`
  }

  return {
    name: filename,
    size: buffer.length,
    text: String(text || '').slice(0, 120_000),
  }
}

async function collectAgentRunText(args) {
  let raw = ''
  const events = []
  for await (const evt of runAgentRunAgentLoop(args)) {
    if (evt?.t === 'delta') raw += evt.v || ''
    if (evt?.t === 'tool_call' || evt?.t === 'tool_result') events.push(evt)
    if (evt?.t === 'error') throw new Error(evt.v || 'AgentRun 执行失败')
  }
  return { raw, events }
}

// ──────────────────────────────────────────────────────────
// Bing 搜索（拓取 HTML 结果页）
// ──────────────────────────────────────────────────────────
async function bingSearch(query, count = 5) {
  const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${count}&mkt=zh-CN`
  const resp = await fetch(url, {
    headers: {
      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      'Accept': 'text/html,application/xhtml+xml',
      'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    },
    signal: AbortSignal.timeout(15000),
  })
  if (!resp.ok) throw new Error(`Bing search failed: ${resp.status}`)
  const html = await resp.text()

  const results = []
  // 按 <li class="b_algo" 分割每个搜索结果块
  const blocks = html.split(/<li class="b_algo"/)
  for (let i = 1; i < blocks.length && results.length < count; i++) {
    const block = blocks[i]
    // 提取 h2 内 a 标签的 href 和文本
    const hrefMatch = block.match(/<h2[^>]*>\s*<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i)
    if (!hrefMatch) continue
    const resultUrl = hrefMatch[1]
    const title = hrefMatch[2].replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#\d+;/g, '').replace(/&ensp;/g, ' ').trim()
    if (!title || !resultUrl.startsWith('http')) continue
    // 提取摘要：b_caption 内的 p 标签
    let snippet = ''
    const snippetMatch = block.match(/<div class="b_caption">\s*<p[^>]*>([\s\S]*?)<\/p>/i)
    if (snippetMatch) {
      snippet = snippetMatch[1].replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&ensp;/g, ' ').replace(/&#0?183;/g, '·').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim()
    }
    results.push({ url: resultUrl, title, snippet })
  }

  // 备用正则：如果分块方式没匹配到
  if (results.length === 0) {
    const altRegex = /<h2[^>]*>\s*<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>\s*<\/h2>/gi
    let altMatch
    while ((altMatch = altRegex.exec(html)) && results.length < count) {
      const t = altMatch[2].replace(/<[^>]+>/g, '').trim()
      if (t) results.push({ url: altMatch[1], title: t, snippet: '' })
    }
  }

  return results
}

function isBlockedInternalHostname(hostname) {
  const host = String(hostname || '').toLowerCase()
  const isPrivateIPv4 = (h) => {
    if (!/^\d+\.\d+\.\d+\.\d+$/.test(h)) return false
    const parts = h.split('.').map(Number)
    if (parts[0] === 10) return true
    if (parts[0] === 127) return true
    if (parts[0] === 192 && parts[1] === 168) return true
    if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
    if (parts[0] === 169 && parts[1] === 254) return true
    if (parts[0] === 0) return true
    return false
  }
  return (
    host === 'localhost' ||
    host === '0.0.0.0' ||
    host === '::1' ||
    host.endsWith('.localhost') ||
    isPrivateIPv4(host)
  )
}

function escapeHtml(text) {
  return String(text || '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
}

function buildHtmlDocument(args = {}) {
  const rawContent = typeof args.bodyHtml === 'string'
    ? args.bodyHtml
    : (typeof args.content === 'string' ? args.content : '')
  const css = typeof args.css === 'string' ? args.css : ''
  const script = typeof args.script === 'string' ? args.script : ''
  const title = String(args.title || 'Skill Output')

  if (!args.bodyHtml && !css && !script && /<!doctype html>|<html[\s>]/i.test(rawContent)) {
    return rawContent
  }

  return [
    '<!doctype html>',
    '<html lang="zh-CN">',
    '<head>',
    '  <meta charset="utf-8">',
    '  <meta name="viewport" content="width=device-width, initial-scale=1">',
    `  <title>${escapeHtml(title)}</title>`,
    css ? `  <style>\n${css}\n  </style>` : '',
    '</head>',
    '<body>',
    rawContent || '<main></main>',
    script ? `  <script>\n${script}\n  </script>` : '',
    '</body>',
    '</html>',
  ].filter(line => line !== '').join('\n')
}

function markdownToHtml(content = '') {
  const lines = String(content || '').split(/\r?\n/)
  const out = []
  let inList = false
  const closeList = () => {
    if (inList) {
      out.push('</ul>')
      inList = false
    }
  }
  for (const rawLine of lines) {
    const line = rawLine.trimEnd()
    if (!line.trim()) {
      closeList()
      out.push('<p class="spacer"></p>')
    } else if (line.startsWith('### ')) {
      closeList()
      out.push(`<h3>${escapeHtml(line.slice(4))}</h3>`)
    } else if (line.startsWith('## ')) {
      closeList()
      out.push(`<h2>${escapeHtml(line.slice(3))}</h2>`)
    } else if (line.startsWith('# ')) {
      closeList()
      out.push(`<h1>${escapeHtml(line.slice(2))}</h1>`)
    } else if (/^[-*]\s+/.test(line)) {
      if (!inList) {
        out.push('<ul>')
        inList = true
      }
      out.push(`<li>${escapeHtml(line.replace(/^[-*]\s+/, ''))}</li>`)
    } else {
      closeList()
      const escaped = escapeHtml(line).replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
      out.push(`<p>${escaped}</p>`)
    }
  }
  closeList()
  return out.join('\n')
}

function buildPdfHtmlDocument(args = {}) {
  const title = String(args.title || 'Skill Output')
  const bodyHtml = typeof args.html === 'string' && args.html.trim()
    ? args.html
    : markdownToHtml(args.content || '')
  const looksFullHtml = /<!doctype html>|<html[\s>]/i.test(bodyHtml)
  if (looksFullHtml && !args.css) return bodyHtml
  return [
    '<!doctype html>',
    '<html lang="zh-CN">',
    '<head>',
    '  <meta charset="utf-8">',
    `  <title>${escapeHtml(title)}</title>`,
    '  <style>',
    '    @page { size: A4; margin: 18mm 16mm; }',
    '    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; color: #172033; line-height: 1.62; font-size: 12px; }',
    '    h1 { font-size: 24px; margin: 0 0 18px; }',
    '    h2 { font-size: 17px; margin: 20px 0 8px; border-bottom: 1px solid #e5e7eb; padding-bottom: 5px; }',
    '    h3 { font-size: 14px; margin: 14px 0 6px; }',
    '    p { margin: 5px 0; }',
    '    .spacer { height: 6px; }',
    '    ul { margin: 6px 0 10px 18px; padding: 0; }',
    '    li { margin: 4px 0; }',
    '    table { width: 100%; border-collapse: collapse; margin: 10px 0; }',
    '    th, td { border: 1px solid #d7dde8; padding: 6px 8px; vertical-align: top; }',
    '    th { background: #f4f7fb; }',
    args.css || '',
    '  </style>',
    '</head>',
    '<body>',
    bodyHtml || '<main></main>',
    '</body>',
    '</html>',
  ].join('\n')
}

function validatePublicHttpUrl(rawUrl) {
  const url = String(rawUrl || '').trim()
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
    return { error: '错误：URL 必须以 http:// 或 https:// 开头' }
  }
  let urlObj
  try {
    urlObj = new URL(url)
  } catch {
    return { error: '错误：非法 URL' }
  }
  if (isBlockedInternalHostname(urlObj.hostname)) {
    return { error: '错误：不允许访问内网地址' }
  }
  return { url, urlObj }
}

function clampWaitMs(value, fallback = 500) {
  const n = Number(value)
  if (!Number.isFinite(n) || n < 0) return fallback
  return Math.min(n, 5000)
}

function resolveBrowserExecutablePath() {
  const configured = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || process.env.CHROME_EXECUTABLE_PATH
  if (configured && fs.existsSync(configured)) return configured
  const candidates = [
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    '/Applications/Chromium.app/Contents/MacOS/Chromium',
    '/usr/bin/google-chrome',
    '/usr/bin/chromium-browser',
    '/usr/bin/chromium',
  ]
  return candidates.find(candidate => fs.existsSync(candidate)) || undefined
}

async function launchSkillBrowser() {
  const { chromium } = await import('playwright')
  const executablePath = resolveBrowserExecutablePath()
  return await chromium.launch({
    headless: true,
    executablePath,
    args: ['--no-sandbox', '--disable-dev-shm-usage'],
  })
}

async function runBrowserScreenshotTool(args, sessionDir) {
  const checked = validatePublicHttpUrl(args?.url)
  if (checked.error) return checked.error
  const fname = safeFilename(args?.path)
  if (!fname || !fname.toLowerCase().endsWith('.png')) return '错误：截图文件名必须以 .png 结尾'
  const fp = path.join(sessionDir, fname)
  if (!isInside(sessionDir, fp)) return '错误：路径越权'

  let browser
  try {
    browser = await launchSkillBrowser()
    const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
    await page.goto(checked.url, { waitUntil: 'networkidle', timeout: 30000 })
    const waitMs = clampWaitMs(args?.waitMs, 500)
    if (waitMs) await page.waitForTimeout(waitMs)
    await fs.promises.mkdir(path.dirname(fp), { recursive: true })
    await page.screenshot({ path: fp, fullPage: args?.fullPage !== false })
    const stat = await fs.promises.stat(fp)
    return `浏览器截图 ${fname} 已生成（${stat.size} 字节，标题：${await page.title()}）`
  } catch (e) {
    return `浏览器截图失败：${e?.message || e}`
  } finally {
    try { await browser?.close?.() } catch {}
  }
}

async function runBrowserAutomationTool(args, sessionDir) {
  const checked = validatePublicHttpUrl(args?.url)
  if (checked.error) return checked.error
  const steps = Array.isArray(args?.steps) ? args.steps.slice(0, 30) : []
  const timeoutMs = Math.min(Math.max(Number(args?.timeoutMs) || 30000, 1000), 60000)
  const results = []

  let browser
  try {
    browser = await launchSkillBrowser()
    const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
    page.setDefaultTimeout(timeoutMs)
    await page.goto(checked.url, { waitUntil: 'networkidle', timeout: timeoutMs })
    results.push({ action: 'goto', url: page.url(), title: await page.title() })

    for (const step of steps) {
      const action = String(step?.action || '').trim()
      if (!action) continue

      if (action === 'goto') {
        const next = validatePublicHttpUrl(step?.url)
        if (next.error) {
          results.push({ action, error: next.error })
          continue
        }
        await page.goto(next.url, { waitUntil: 'networkidle', timeout: timeoutMs })
        results.push({ action, url: page.url(), title: await page.title() })
      } else if (action === 'fill') {
        const selector = String(step?.selector || '')
        if (!selector) {
          results.push({ action, error: 'selector 不能为空' })
          continue
        }
        await page.locator(selector).first().fill(String(step?.value || ''))
        results.push({ action, selector, ok: true })
      } else if (action === 'click') {
        const selector = String(step?.selector || '')
        if (!selector) {
          results.push({ action, error: 'selector 不能为空' })
          continue
        }
        await page.locator(selector).first().click()
        results.push({ action, selector, ok: true, url: page.url() })
      } else if (action === 'press') {
        const selector = String(step?.selector || '')
        if (!selector) {
          results.push({ action, error: 'selector 不能为空' })
          continue
        }
        await page.locator(selector).first().press(String(step?.value || 'Enter'))
        results.push({ action, selector, key: String(step?.value || 'Enter') })
      } else if (action === 'wait') {
        const waitMs = clampWaitMs(step?.waitMs, 500)
        await page.waitForTimeout(waitMs)
        results.push({ action, waitMs })
      } else if (action === 'text') {
        const selector = String(step?.selector || 'body')
        const text = await page.locator(selector).first().innerText({ timeout: timeoutMs }).catch(() => '')
        results.push({ action, selector, text: text.slice(0, 4000) })
      } else if (action === 'screenshot') {
        const fname = safeFilename(step?.path || `browser-${Date.now()}.png`)
        if (!fname || !fname.toLowerCase().endsWith('.png')) {
          results.push({ action, error: '截图文件名必须以 .png 结尾' })
          continue
        }
        const fp = path.join(sessionDir, fname)
        if (!isInside(sessionDir, fp)) {
          results.push({ action, error: '路径越权' })
          continue
        }
        const waitMs = clampWaitMs(step?.waitMs, 0)
        if (waitMs) await page.waitForTimeout(waitMs)
        await fs.promises.mkdir(path.dirname(fp), { recursive: true })
        await page.screenshot({ path: fp, fullPage: step?.fullPage !== false })
        results.push({ action, path: fname, ok: true })
      } else {
        results.push({ action, error: '不支持的浏览器操作' })
      }
    }

    const bodyText = await page.locator('body').innerText({ timeout: 5000 }).catch(() => '')
    return JSON.stringify({
      ok: true,
      url: page.url(),
      title: await page.title(),
      textPreview: bodyText.slice(0, 4000),
      results,
    }, null, 2)
  } catch (e) {
    return `浏览器自动化失败：${e?.message || e}`
  } finally {
    try { await browser?.close?.() } catch {}
  }
}

// ──────────────────────────────────────────────────────────
// 工具执行
// ──────────────────────────────────────────────────────────
async function runTool(name, args, sessionDir) {
  if (!sessionDir) return '错误：会话目录不可用'
  if (name === 'write_file') {
    const fname = safeFilename(args?.path)
    if (!fname) return '错误：非法文件名'
    if (typeof args?.content !== 'string') return '错误：content 必须是字符串'
    if (args.content.length > 2_000_000) return '错误：单文件不得超过 2MB'
    const fp = path.join(sessionDir, fname)
    if (!isInside(sessionDir, fp)) return '错误：路径越权'
    await fs.promises.mkdir(path.dirname(fp), { recursive: true })
    await fs.promises.writeFile(fp, args.content, 'utf8')
    return `文件 ${fname} 已写入（${args.content.length} 字符）`
  }
  if (name === 'create_html') {
    const fname = safeFilename(args?.path)
    if (!fname || !/\.(html|htm)$/i.test(fname)) return '错误：文件名必须以 .html 或 .htm 结尾'
    const html = buildHtmlDocument(args)
    if (html.length > 2_000_000) return '错误：HTML 文件不得超过 2MB'
    const fp = path.join(sessionDir, fname)
    if (!isInside(sessionDir, fp)) return '错误：路径越权'
    await fs.promises.mkdir(path.dirname(fp), { recursive: true })
    await fs.promises.writeFile(fp, html, 'utf8')
    return `HTML 文件 ${fname} 已生成（${html.length} 字符）`
  }
  if (name === 'read_file') {
    const fname = safeFilename(args?.path)
    if (!fname) return '错误：非法文件名'
    const fp = path.join(sessionDir, fname)
    if (!isInside(sessionDir, fp)) return '错误：路径越权'
    try {
      const buf = await fs.promises.readFile(fp, 'utf8')
      // 截断防止把超长文本回灌给模型
      return buf.length > 100_000 ? buf.slice(0, 100_000) + '\n...[已截断]' : buf
    } catch {
      return '文件不存在'
    }
  }
  if (name === 'list_files') {
    const files = await listSessionFiles(sessionDir)
    return files.length ? files.join('\n') : '（空目录）'
  }
  if (name === 'fetch_url') {
    const url = String(args?.url || '')
    if (!url.startsWith('http://') && !url.startsWith('https://')) return '错误：URL 必须以 http:// 或 https:// 开头'
    let urlObj
    try {
      urlObj = new URL(url)
    } catch {
      return '错误：非法 URL'
    }
    if (isBlockedInternalHostname(urlObj.hostname)) return '错误：不允许请求内网地址'
    const userHeaders = (args?.headers && typeof args.headers === 'object') ? args.headers : {}
    let timer
    try {
      const ctrl = new AbortController()
      timer = setTimeout(() => ctrl.abort(), 15000)
      const res = await fetch(url, {
        signal: ctrl.signal,
        headers: {
          'User-Agent': 'Mozilla/5.0 (compatible; SkillAgent/1.0)',
          ...userHeaders,
        },
      })
      clearTimeout(timer)
      const text = await res.text()
      const preview = text.length > 10000 ? text.slice(0, 10000) + '\n...[已截断]' : text
      return `HTTP ${res.status}\n\n${preview}`
    } catch (e) {
      return `请求失败：${e?.message || e}`
    } finally {
      if (timer) clearTimeout(timer)
    }
  }
  if (name === 'http_request') {
    const url = String(args?.url || '')
    if (!url.startsWith('http://') && !url.startsWith('https://')) {
      return JSON.stringify({ error: 'URL 必须以 http:// 或 https:// 开头' })
    }
    const method = String(args?.method || 'GET').toUpperCase()
    const headers = (args?.headers && typeof args.headers === 'object') ? args.headers : {}
    const body = args?.body
    const requestedTimeout = Number(args?.timeout)
    const timeout = Number.isFinite(requestedTimeout) && requestedTimeout > 0
      ? Math.min(requestedTimeout, 60000)
      : 30000

    // 安全检查：禁止请求内网地址（防 SSRF）
    let urlObj
    try {
      urlObj = new URL(url)
    } catch {
      return JSON.stringify({ error: '非法 URL' })
    }
    if (isBlockedInternalHostname(urlObj.hostname)) {
      return JSON.stringify({ error: '不允许请求内网地址' })
    }

    let timer
    try {
      const controller = new AbortController()
      timer = setTimeout(() => controller.abort(), timeout)

      const fetchOptions = {
        method,
        headers: {
          'User-Agent': 'SkillAgent/1.0',
          ...headers,
        },
        signal: controller.signal,
      }

      if (body && ['POST', 'PUT', 'PATCH'].includes(method)) {
        fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body)
      }

      const resp = await fetch(url, fetchOptions)
      clearTimeout(timer)

      const status = resp.status
      const responseHeaders = Object.fromEntries(resp.headers.entries())
      const text = await resp.text()

      // 限制返回体大小，防止超出 LLM 上下文
      const truncated = text.length > 20000
        ? text.slice(0, 20000) + '\n...[响应已截断，共 ' + text.length + ' 字符]'
        : text

      return JSON.stringify({
        status,
        headers: responseHeaders,
        body: truncated,
      })
    } catch (e) {
      if (e?.name === 'AbortError') {
        return JSON.stringify({ error: `请求超时（${timeout}ms）` })
      }
      return JSON.stringify({ error: e?.message || String(e) })
    } finally {
      if (timer) clearTimeout(timer)
    }
  }
  if (name === 'web_search') {
    const query = String(args?.query || '').trim()
    if (!query) return '错误：query 不能为空'
    try {
      const results = await bingSearch(query, 6)
      if (!results.length) return `未找到关于"${query}"的搜索结果`
      const lines = [`搜索"${query}"的结果：\n`]
      for (let i = 0; i < results.length; i++) {
        const r = results[i]
        lines.push(`${i + 1}. ${r.title}`)
        if (r.snippet) lines.push(`   ${r.snippet}`)
        lines.push(`   链接：${r.url}`)
        lines.push('')
      }
      return lines.join('\n')
    } catch (e) {
      return `搜索失败：${e?.message || e}`
    }
  }
  if (name === 'run_js') {
    const code = String(args?.code || '')
    if (!code.trim()) return '错误：code 不能为空'
    if (code.length > 50000) return '错误：代码长度不得超过 50000 字符'
    try {
      const logs = []
      const sandbox = {
        console: {
          log: (...a) => logs.push(a.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' ')),
          error: (...a) => logs.push('[error] ' + a.map(x => String(x)).join(' ')),
          warn: (...a) => logs.push('[warn] ' + a.map(x => String(x)).join(' ')),
        },
        Math, JSON, Date, Array, Object, String, Number, Boolean,
        parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent,
        setTimeout: undefined, setInterval: undefined, fetch: undefined, require: undefined, process: undefined,
      }
      vm.createContext(sandbox)
      const result = vm.runInContext(code, sandbox, { timeout: 5000 })
      const out = []
      if (logs.length) out.push('输出：\n' + logs.join('\n'))
      if (result !== undefined) out.push('返回值：' + (typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result)))
      return out.length ? out.join('\n') : '（无输出）'
    } catch (e) {
      return `执行错误：${e?.message || e}`
    }
  }
  if (name === 'browser_screenshot') {
    return await runBrowserScreenshotTool(args, sessionDir)
  }
  if (name === 'browser_automation') {
    return await runBrowserAutomationTool(args, sessionDir)
  }
  if (name === 'get_time') {
    const now = new Date()
    return `当前时间：${now.toISOString()} (UTC+8: ${new Date(now.getTime() + 8*3600000).toISOString().replace('Z', '+08:00')})`
  }
  if (name === 'append_file') {
    const fname = safeFilename(args?.path)
    if (!fname) return '错误：非法文件名'
    if (typeof args?.content !== 'string') return '错误：content 必须是字符串'
    const fp = path.join(sessionDir, fname)
    if (!isInside(sessionDir, fp)) return '错误：路径越权'
    await fs.promises.mkdir(path.dirname(fp), { recursive: true })
    await fs.promises.appendFile(fp, args.content, 'utf8')
    return `已追加 ${args.content.length} 字符到 ${fname}`
  }
  if (name === 'delete_file') {
    const fname = safeFilename(args?.path)
    if (!fname) return '错误：非法文件名'
    const fp = path.join(sessionDir, fname)
    if (!isInside(sessionDir, fp)) return '错误：路径越权'
    try {
      await fs.promises.unlink(fp)
      return `已删除文件 ${fname}`
    } catch {
      return `文件不存在或删除失败：${fname}`
    }
  }
  if (name === 'create_word') {
    const fname = safeFilename(args?.path)
    if (!fname || !fname.toLowerCase().endsWith('.docx')) return '错误：文件名必须以 .docx 结尾'
    const content = String(args?.content || '')
    try {
      const { Document, Packer, Paragraph, TextRun, HeadingLevel } = await import('docx')
      const lines = content.split('\n')
      const children = []
      for (const line of lines) {
        if (line.startsWith('### ')) {
          children.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
        } else if (line.startsWith('## ')) {
          children.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
        } else if (line.startsWith('# ')) {
          children.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
        } else if (line.startsWith('- ') || line.startsWith('* ')) {
          children.push(new Paragraph({ text: line.slice(2), bullet: { level: 0 } }))
        } else {
          const parts = line.split(/(\*\*[^*]+\*\*)/)
          const runs = parts.map(p => {
            if (p.startsWith('**') && p.endsWith('**')) return new TextRun({ text: p.slice(2, -2), bold: true })
            return new TextRun({ text: p })
          })
          children.push(new Paragraph({ children: runs }))
        }
      }
      const doc = new Document({ sections: [{ children }] })
      const buf = await Packer.toBuffer(doc)
      await fs.promises.mkdir(sessionDir, { recursive: true })
      await fs.promises.writeFile(path.join(sessionDir, fname), buf)
      return `Word 文档 ${fname} 已生成（${buf.length} 字节）`
    } catch (e) {
      return `生成 Word 失败：${e?.message || e}`
    }
  }
  if (name === 'create_pdf') {
    const fname = safeFilename(args?.path)
    if (!fname || !fname.toLowerCase().endsWith('.pdf')) return '错误：文件名必须以 .pdf 结尾'
    const html = buildPdfHtmlDocument(args)
    if (html.length > 2_000_000) return '错误：PDF 源内容不得超过 2MB'
    const fp = path.join(sessionDir, fname)
    if (!isInside(sessionDir, fp)) return '错误：路径越权'
    let browser
    try {
      browser = await launchSkillBrowser()
      const page = await browser.newPage({ viewport: { width: 1240, height: 1754 } })
      await page.setContent(html, { waitUntil: 'load', timeout: 30000 })
      await fs.promises.mkdir(path.dirname(fp), { recursive: true })
      await page.pdf({
        path: fp,
        format: 'A4',
        printBackground: true,
        margin: { top: '18mm', right: '16mm', bottom: '18mm', left: '16mm' },
      })
      const stat = await fs.promises.stat(fp)
      return `PDF 文件 ${fname} 已生成（${stat.size} 字节）`
    } catch (e) {
      return `生成 PDF 失败：${e?.message || e}`
    } finally {
      try { await browser?.close?.() } catch {}
    }
  }
  if (name === 'create_excel') {
    const fname = safeFilename(args?.path)
    if (!fname || !fname.toLowerCase().endsWith('.xlsx')) return '错误：文件名必须以 .xlsx 结尾'
    const headers = Array.isArray(args?.headers) ? args.headers : []
    const rows = Array.isArray(args?.rows) ? args.rows : []
    try {
      const ExcelJS = (await import('exceljs')).default
      const workbook = new ExcelJS.Workbook()
      const sheet = workbook.addWorksheet(String(args?.sheet_name || 'Sheet1'))
      const headerRow = sheet.addRow(headers)
      headerRow.eachCell(cell => {
        cell.font = { bold: true }
        cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2EEFF' } }
        cell.border = { bottom: { style: 'thin' } }
      })
      for (const row of rows) sheet.addRow(row)
      sheet.columns.forEach((col, i) => {
        const maxLen = Math.max(
          String(headers[i] || '').length,
          ...rows.map(r => String(r[i] ?? '').length)
        )
        col.width = Math.min(Math.max(maxLen + 2, 8), 50)
      })
      await fs.promises.mkdir(sessionDir, { recursive: true })
      const fp = path.join(sessionDir, fname)
      await workbook.xlsx.writeFile(fp)
      const stat = await fs.promises.stat(fp)
      return `Excel 文件 ${fname} 已生成（${stat.size} 字节，${rows.length} 行数据）`
    } catch (e) {
      return `生成 Excel 失败：${e?.message || e}`
    }
  }
  if (name === 'create_pptx') {
    const fname = safeFilename(args?.path)
    if (!fname || !fname.toLowerCase().endsWith('.pptx')) return '错误：文件名必须以 .pptx 结尾'
    const slides = Array.isArray(args?.slides) ? args.slides : []
    if (!slides.length) return '错误：至少需要一张幻灯片'
    try {
      const PptxGenJS = (await import('pptxgenjs')).default
      const pptx = new PptxGenJS()
      pptx.layout = 'LAYOUT_WIDE'
      for (const s of slides) {
        const slide = pptx.addSlide()
        slide.addText(String(s.title || ''), {
          x: 0.5, y: 0.3, w: '90%', h: 1.0,
          fontSize: 28, bold: true, color: '1a1a2e',
        })
        if (Array.isArray(s.bullets) && s.bullets.length) {
          const bulletObjs = s.bullets.map(b => ({ text: String(b), options: { bullet: true, fontSize: 18 } }))
          slide.addText(bulletObjs, { x: 0.5, y: 1.5, w: '90%', h: 4.5, color: '333333' })
        } else if (s.content) {
          slide.addText(String(s.content), {
            x: 0.5, y: 1.5, w: '90%', h: 4.5,
            fontSize: 18, color: '333333', valign: 'top',
          })
        }
      }
      await fs.promises.mkdir(sessionDir, { recursive: true })
      const fp = path.join(sessionDir, fname)
      await pptx.writeFile({ fileName: fp })
      const stat = await fs.promises.stat(fp)
      return `PPT 文件 ${fname} 已生成（${stat.size} 字节，${slides.length} 张幻灯片）`
    } catch (e) {
      return `生成 PPT 失败：${e?.message || e}`
    }
  }
  if (name === 'parse_pdf') {
    const fname = safeFilename(args?.path)
    if (!fname || !fname.toLowerCase().endsWith('.pdf')) return '错误：文件名必须以 .pdf 结尾'
    const fp = path.join(sessionDir, fname)
    try {
      const buf = await fs.promises.readFile(fp)
      const pdfParse = (await import('pdf-parse')).default
      const data = await pdfParse(buf)
      const text = data.text || ''
      return text.length > 20000 ? text.slice(0, 20000) + '\n...[已截断]' : text || '（PDF 无可提取文本）'
    } catch (e) {
      if (e?.code === 'ENOENT') return '文件不存在'
      return `解析失败：${e?.message || e}`
    }
  }
  return `未知工具：${name}`
}

export async function runSkillTool(name, args, sessionDir) {
  return await runTool(name, args, sessionDir)
}

async function listSessionFiles(sessionDir) {
  if (!sessionDir) return []
  const out = []
  try {
    async function walk(dir, prefix = '') {
      const entries = await fs.promises.readdir(dir, { withFileTypes: true })
      for (const e of entries) {
        if (e.name === SESSION_EVENTS_FILE || e.name === SESSION_STATE_FILE) continue
        const rel = prefix ? `${prefix}/${e.name}` : e.name
        if (e.isDirectory()) {
          await walk(path.join(dir, e.name), rel)
        } else if (e.isFile()) {
          out.push(rel)
        }
      }
    }
    await walk(sessionDir)
    return out
  } catch {
    return []
  }
}

function truncateToolResult(text, limit = 4000) {
  const value = String(text ?? '')
  return value.length > limit ? value.slice(0, limit) + '\n...[已截断]' : value
}

export function chooseEvaluationReadTargets(files) {
  const normalized = Array.isArray(files) ? files.filter(Boolean) : []
  const byLower = new Map(normalized.map(file => [String(file).toLowerCase(), file]))
  const targets = []
  for (const required of [...STANDARD_SKILL_PACKAGE_FILES, SKILL_SPEC_FILE]) {
    const found = byLower.get(required.toLowerCase())
    if (found) targets.push(found)
  }
  if (!targets.length) {
    const firstMarkdown = normalized.find(file => /\.(md|markdown|txt)$/i.test(file))
    if (firstMarkdown) targets.push(firstMarkdown)
  }
  return [...new Set(targets)]
}

async function runEvaluationPackagePreflight({ job, sessionDir }) {
  const evidence = []

  await job.emit({ t: 'tool_call', name: 'list_files', args: {}, preflight: true })
  const files = await listSessionFiles(sessionDir)
  const listResult = files.length ? files.join('\n') : '（空目录）'
  await job.emit({ t: 'tool_result', name: 'list_files', result: truncateToolResult(listResult), preflight: true })
  evidence.push(`## list_files\n${listResult}`)

  const targets = chooseEvaluationReadTargets(files)
  for (const target of targets) {
    const args = { path: target }
    await job.emit({ t: 'tool_call', name: 'read_file', args, preflight: true })
    const result = await runTool('read_file', args, sessionDir).catch((e) => `执行错误：${e?.message || e}`)
    const resultStr = String(result)
    await job.emit({ t: 'tool_result', name: 'read_file', result: truncateToolResult(resultStr), preflight: true })
    evidence.push(`## read_file ${target}\n${truncateToolResult(resultStr, 12000)}`)
  }

  const missing = [...STANDARD_SKILL_PACKAGE_FILES, SKILL_SPEC_FILE]
    .filter(file => !files.some(existing => existing.toLowerCase() === file.toLowerCase()))
  if (missing.length) evidence.push(`## 缺失的标准文件\n${missing.join('\n')}`)

  return {
    files,
    toolEventCount: 2 + targets.length * 2,
    evidencePrompt: [
      '平台已经在评测模式下真实执行了包文件检查。下面是 list_files/read_file 的真实结果。',
      '请不要再输出工具调用格式，也不要复述“准备调用工具”；请基于这些证据直接给出中文 Skill 评测报告。',
      '评测报告必须引用具体文件名和证据；如果 examples.md 或 rubric.md 缺失，请按真实清单说明。',
      '',
      evidence.join('\n\n'),
    ].join('\n'),
  }
}

async function appendSessionEvent(sessionDir, event) {
  if (!sessionDir || !event) return
  try {
    await fs.promises.mkdir(sessionDir, { recursive: true })
    await fs.promises.appendFile(
      path.join(sessionDir, SESSION_EVENTS_FILE),
      JSON.stringify(event) + '\n',
      'utf8'
    )
  } catch (e) {
    console.warn('[skill-run] append event failed:', e?.message || e)
  }
}

async function readSessionEvents(sessionDir, afterId = 0) {
  if (!sessionDir) return []
  try {
    const text = await fs.promises.readFile(path.join(sessionDir, SESSION_EVENTS_FILE), 'utf8')
    return text
      .split(/\r?\n/)
      .filter(Boolean)
      .map(line => {
        try { return JSON.parse(line) } catch { return null }
      })
      .filter(Boolean)
      .filter(evt => Number(evt.id || 0) > Number(afterId || 0))
  } catch {
    return []
  }
}

async function readLatestSessionEventId(sessionDir) {
  const events = await readSessionEvents(sessionDir, 0)
  return events.reduce((max, evt) => Math.max(max, Number(evt?.id || evt?.eventId || 0)), 0)
}

async function writeSessionState(sessionDir, state) {
  if (!sessionDir) return
  try {
    await fs.promises.mkdir(sessionDir, { recursive: true })
    await fs.promises.writeFile(path.join(sessionDir, SESSION_STATE_FILE), JSON.stringify(state, null, 2), 'utf8')
  } catch (e) {
    console.warn('[skill-run] write state failed:', e?.message || e)
  }
}

async function readSessionState(sessionDir) {
  if (!sessionDir) return null
  try {
    const text = await fs.promises.readFile(path.join(sessionDir, SESSION_STATE_FILE), 'utf8')
    return JSON.parse(text)
  } catch {
    return null
  }
}

async function writeSkillRunEvent(stream, event) {
  if (!event) return
  await stream.writeSSE({
    id: event.id != null ? String(event.id) : undefined,
    data: JSON.stringify(event),
  })
}

export async function flushMissedSkillRunTerminalEvents({
  stream,
  sessionDir,
  afterEventId = 0,
} = {}) {
  if (!stream || typeof stream.writeSSE !== 'function') return 0
  const missed = await readSessionEvents(sessionDir, afterEventId)
  for (const evt of missed) {
    await writeSkillRunEvent(stream, evt)
  }
  await stream.writeSSE({ data: '[DONE]' })
  return missed.length
}

function buildSessionState(job, extra = {}) {
  return {
    sessionId: job.sessionId,
    state: job.state,
    eventId: job.eventId,
    startedAt: job.startedAt,
    updatedAt: job.updatedAt,
    completedAt: job.completedAt || null,
    error: job.error || '',
    providerSession: job.providerSession || null,
    ...extra,
  }
}

export function looksLikeFakeToolExecutionClaim(text) {
  const value = String(text || '')
  if (!value) return false
  const toolNames = getSkillRuntimeTools().map(tool => tool.name).join('|')
  const toolNameRe = new RegExp(`\\b(?:${toolNames.replace(/_/g, '_')})\\b`, 'i')
  const toolInvocationTextRe = new RegExp(`(?:调用工具|工具调用|tool call|function call)\\s*[:：]?\\s*(?:${toolNames})\\s*\\(`, 'i')
  const plainToolCallRe = new RegExp(`\\b(?:${toolNames})\\s*\\(`, 'i')
  const claimsTool = /(已|已经|成功|完成).{0,30}(调用|使用|执行).{0,30}(工具|tool|function|函数)/i.test(value)
    || toolNameRe.test(value) && /(已|已经|成功|完成).{0,40}(调用|使用|执行)/i.test(value)
  const promisesTool = /(将|会|准备|正在|马上|现在).{0,30}(调用|使用|执行).{0,40}(工具|tool|function|函数|write_file|create_html|create_word|create_pdf|create_excel|create_docx|create_xlsx)/i.test(value)
    || toolNameRe.test(value) && /(将|会|准备|正在|马上|现在).{0,40}(调用|使用|执行)/i.test(value)
  const claimsFile = /(已|已经|成功|完成).{0,40}(生成|创建|写入|保存).{0,80}\.(html|htm|xlsx|docx|pptx|pdf|md|json|csv|png)\b/i.test(value)
    || /(文件|文档|报告|表格|页面).{0,12}(已|已经|成功|完成).{0,20}(生成|创建|写入|保存).{0,40}\.(html|htm|xlsx|docx|pptx|pdf|md|json|csv|png)\b/i.test(value)
  return claimsTool || promisesTool || claimsFile || toolInvocationTextRe.test(value) || plainToolCallRe.test(value)
}

const FALLBACK_TOOL_EXT = {
  create_word: 'docx',
  create_pdf: 'pdf',
  create_excel: 'xlsx',
  create_html: 'html',
}

function firstMatchingFilename(text, ext, fallback) {
  const re = new RegExp(`([\\w./_-]+\\.${ext})\\b`, 'i')
  const m = String(text || '').match(re)
  return safeFilename(m?.[1] || fallback)
}

function extractTripleQuotedArg(segment, key) {
  const re = new RegExp(`${key}\\s*[=:]\\s*(?:[rRuUbBfF]*)(["']{3})([\\s\\S]*?)\\1`, 'i')
  const m = String(segment || '').match(re)
  return m?.[2]?.trim() || ''
}

function extractQuotedArg(segment, key) {
  const re = new RegExp(`${key}\\s*[=:]\\s*["']([^"']+)["']`, 'i')
  const m = String(segment || '').match(re)
  return m?.[1]?.trim() || ''
}

function extractPseudoToolSegment(text, name) {
  const value = String(text || '')
  const m = value.match(new RegExp(`\\b${name}\\s*\\(`, 'i'))
  if (!m || m.index == null) return ''
  return value.slice(m.index, Math.min(value.length, m.index + 120_000))
}

function fallbackExcelArgs(text, segment) {
  const path = firstMatchingFilename(segment, 'xlsx', 'process_checklist.xlsx')
  const headers = ['序号', '检查项', '责任部门', '责任人', '证据材料', '截止日期', '状态', '风险等级']
  const rowHints = String(text || '')
    .split(/\r?\n/)
    .map(line => line.replace(/^[\s\-*0-9.、|]+/, '').trim())
    .filter(line => line && !/^create_excel|^headers|^rows|^sheet_name/i.test(line))
    .filter(line => /(检查|审批|复核|归档|付款|合同|证据|风险|责任|截止|状态)/.test(line))
    .slice(0, 8)
  const rows = (rowHints.length ? rowHints : [
    '确认流程输入材料齐全',
    '复核审批节点和权限',
    '检查关键证据是否归档',
    '跟踪超期和高风险事项',
  ]).map((item, idx) => [
    idx + 1,
    item.slice(0, 80),
    idx % 2 === 0 ? '流程管理部' : '业务责任部门',
    '待指定',
    '流程文件/系统截图/审批记录',
    '样例版待确认',
    '待处理',
    idx === 0 ? '高' : '中',
  ])
  return { path, headers, rows, sheet_name: extractQuotedArg(segment, 'sheet_name') || '流程检查台账' }
}

function inferSkillTitle(systemPrompt) {
  const value = String(systemPrompt || '')
  const title = value.match(/^#\s+(.+)$/m)?.[1]?.trim()
    || value.match(/name:\s*([^\n]+)/i)?.[1]?.trim()
    || 'Skill 样例输出'
  return title.replace(/[`"'#]/g, '').slice(0, 80)
}

function latestUserInput(chatMessages = []) {
  const reversed = [...(chatMessages || [])].reverse()
  return reversed.find(m => m?.role === 'user' && m?.content)?.content || ''
}

function systemRequiresTool(systemPrompt, name) {
  const value = String(systemPrompt || '')
  if (!value) return false
  const dependencyLine = new RegExp(`(^|\\n)\\s*[-*]\\s*\`?${name}\`?\\s*[：:]`, 'i')
  const explicitMustCall = new RegExp(`必须调用\\s*\`?${name}\`?`, 'i')
  return dependencyLine.test(value) || explicitMustCall.test(value)
}

function fallbackDocumentContent({ systemPrompt, chatMessages, assistantText, title }) {
  const userInput = latestUserInput(chatMessages)
  return [
    `# ${title}`,
    '',
    '> 样例版，基于当前 Skill 说明和用户输入自动生成；评测/上架前请确认真实业务规则。',
    '',
    '## 1. 目的',
    '规范相关流程的执行口径，明确角色职责、关键步骤、表单记录和风险控制要求。',
    '',
    '## 2. 适用范围',
    '适用于当前主题涉及的业务流程、相关部门及参与角色。',
    '',
    '## 3. 输入依据',
    userInput ? userInput.slice(0, 1000) : '用户未提供额外材料，本次使用通用企业流程默认假设。',
    '',
    '## 4. 角色职责',
    '- 流程负责人：维护流程规则，推动问题闭环。',
    '- 业务执行人：按流程提交材料、执行任务并保留证据。',
    '- 审批/复核人：按授权规则完成审批、复核和异常升级。',
    '- 归档责任人：维护表单、记录和版本变更。',
    '',
    '## 5. 流程步骤',
    '1. 发起：提交流程事项、背景说明和必要材料。',
    '2. 初审：检查材料完整性、格式和关键字段。',
    '3. 复核/审批：按权限、金额、风险等级或业务规则完成复核。',
    '4. 执行：完成业务动作并记录处理结果。',
    '5. 归档：沉淀表单、附件、审批记录和复盘结论。',
    '',
    '## 6. 表单记录',
    '- 流程申请表',
    '- 审批记录',
    '- 证据材料清单',
    '- 异常处理记录',
    '- 版本变更记录',
    '',
    '## 7. 风险控制点',
    '- 材料缺失：提交前设置必填校验。',
    '- 审批越权：按授权矩阵自动校验。',
    '- 证据不足：关键节点必须上传留痕。',
    '- 超期未处理：设置 SLA 和升级提醒。',
    '',
    '## 8. 版本记录',
    '| 版本 | 日期 | 说明 |',
    '|---|---|---|',
    '| V1.0 | 2026-06-28 | 样例版初稿 |',
    '',
    assistantText ? `## 9. 模型运行记录\n${assistantText.slice(0, 1000)}` : '',
  ].filter(Boolean).join('\n')
}

function synthesizeRequiredToolCall({ name, systemPrompt, chatMessages, assistantText }) {
  const title = inferSkillTitle(systemPrompt)
  const content = fallbackDocumentContent({ systemPrompt, chatMessages, assistantText, title })
  if (name === 'create_word') {
    return { name, args: { path: 'process_document.docx', content } }
  }
  if (name === 'create_pdf') {
    return { name, args: { path: 'process_review_report.pdf', title, content } }
  }
  if (name === 'create_excel') {
    return { name, args: fallbackExcelArgs(`${latestUserInput(chatMessages)}\n${assistantText}`, '') }
  }
  if (name === 'create_html') {
    const bodyHtml = `
<main class="dashboard">
  <section class="hero"><h1>${escapeHtml(title)}</h1><p>样例版，基于当前 Skill 说明和用户输入自动生成；待确认真实业务数据。</p></section>
  <section class="cards">
    <article><strong>SLA</strong><span>待配置</span></article>
    <article><strong>积压事项</strong><span>样例 12 项</span></article>
    <article><strong>高风险</strong><span>样例 3 项</span></article>
    <article><strong>闭环率</strong><span>样例 82%</span></article>
  </section>
  <section><h2>流程步骤</h2><ol><li>发起</li><li>审核</li><li>执行</li><li>归档</li></ol></section>
  <section><h2>风险列表</h2><ul><li>材料缺失</li><li>审批超期</li><li>证据不足</li></ul></section>
  <section><h2>行动项</h2><p>${escapeHtml(latestUserInput(chatMessages) || '补充真实业务数据后刷新看板。')}</p></section>
</main>`
    return {
      name,
      args: {
        path: 'process_dashboard.html',
        title,
        bodyHtml,
        css: 'body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;margin:0;background:#f6f7f9;color:#17202a}.dashboard{max-width:980px;margin:0 auto;padding:32px}.hero{background:#111827;color:white;border-radius:8px;padding:28px}.cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:18px 0}.cards article{background:white;border:1px solid #e5e7eb;border-radius:8px;padding:16px}.cards strong{display:block;color:#4b5563}.cards span{font-size:24px;font-weight:700}section{background:white;border:1px solid #e5e7eb;border-radius:8px;padding:18px;margin-top:14px}',
      },
    }
  }
  return null
}

export function recoverToolCallsFromAssistantText(text, context = {}) {
  const value = String(text || '')
  const recovered = []
  for (const name of Object.keys(FALLBACK_TOOL_EXT)) {
    if (!new RegExp(`\\b${name}\\s*\\(`, 'i').test(value)) continue
    const segment = extractPseudoToolSegment(value, name)
    if (!segment) continue

    if (name === 'create_word') {
      recovered.push({
        name,
        args: {
          path: firstMatchingFilename(segment, 'docx', 'process_document.docx'),
          content: extractTripleQuotedArg(segment, 'content') || value,
        },
      })
    } else if (name === 'create_pdf') {
      recovered.push({
        name,
        args: {
          path: firstMatchingFilename(segment, 'pdf', 'process_review_report.pdf'),
          title: extractQuotedArg(segment, 'title') || '流程评审报告',
          content: extractTripleQuotedArg(segment, 'content') || value,
        },
      })
    } else if (name === 'create_html') {
      recovered.push({
        name,
        args: {
          path: firstMatchingFilename(segment, 'html', 'process_dashboard.html'),
          title: extractQuotedArg(segment, 'title') || '流程运行看板',
          bodyHtml: extractTripleQuotedArg(segment, 'bodyHtml') || extractTripleQuotedArg(segment, 'html') || '',
          content: extractTripleQuotedArg(segment, 'content') || value,
          css: extractTripleQuotedArg(segment, 'css') || '',
        },
      })
    } else if (name === 'create_excel') {
      recovered.push({ name, args: fallbackExcelArgs(value, segment) })
    }
  }
  if (!recovered.length && context?.systemPrompt) {
    for (const name of Object.keys(FALLBACK_TOOL_EXT)) {
      if (!systemRequiresTool(context.systemPrompt, name)) continue
      if (!value.includes(name) && !/(调用|生成|创建|写入|保存).{0,20}(工具|文件|文档|报告|表格|页面)/i.test(value)) continue
      const call = synthesizeRequiredToolCall({
        name,
        systemPrompt: context.systemPrompt,
        chatMessages: context.chatMessages || [],
        assistantText: value,
      })
      if (call) recovered.push(call)
      break
    }
  }
  return recovered.slice(0, 3)
}

function createSkillRunJob({
  sessionId,
  sessionDir,
  runtime,
  agent,
  systemPrompt,
  chatMessages,
  isCreateMode,
  isEvaluateMode = false,
  mode = 'run',
  initialEventId = 0,
  providerSession = null,
}) {
  const existing = RUN_JOBS.get(sessionId)
  if (existing && existing.state === 'running') return existing

  const abortController = new AbortController()
  const startedAt = new Date().toISOString()
  const job = {
    sessionId,
    sessionDir,
    runtimeKind: runtime?.protocol || 'unknown',
    state: 'running',
    eventId: Math.max(0, Number(initialEventId || 0)),
    events: [],
    subscribers: new Set(),
    abortController,
    startedAt,
    updatedAt: startedAt,
    completedAt: null,
    error: '',
    providerSession: providerSession && typeof providerSession === 'object'
      ? { ...providerSession }
      : null,
  }
  RUN_JOBS.set(sessionId, job)
  let assistantText = ''
  let toolEventCount = 0

  job.emit = async (evt) => {
    const next = {
      id: ++job.eventId,
      eventId: job.eventId,
      sessionId,
      ts: new Date().toISOString(),
      ...evt,
    }
    if (next.t === 'provider_session' && next.providerSessionId) {
      job.providerSession = {
        provider: next.provider || runtime?.code || runtime?.protocol || '',
        id: String(next.providerSessionId),
        lastEventId: String(next.providerEventId || job.providerSession?.lastEventId || ''),
        turnId: String(next.providerTurnId || job.providerSession?.turnId || ''),
      }
    } else if (job.providerSession && (next.providerEventId || next.providerTurnId)) {
      job.providerSession = {
        ...job.providerSession,
        ...(next.providerEventId ? { lastEventId: String(next.providerEventId) } : {}),
        ...(next.providerTurnId ? { turnId: String(next.providerTurnId) } : {}),
      }
    }
    job.updatedAt = next.ts
    job.events.push(next)
    if (job.events.length > 1000) job.events.shift()
    await appendSessionEvent(sessionDir, next)
    for (const subscriber of [...job.subscribers]) {
      subscriber(next).catch(() => job.subscribers.delete(subscriber))
    }
    return next
  }

  job.promise = (async () => {
    const totalTimer = setTimeout(() => {
      try { abortController.abort(new Error(`timeout after ${SKILL_RUN_TIMEOUT_MS}ms`)) } catch {}
    }, SKILL_RUN_TIMEOUT_MS)
    totalTimer.unref?.()

    await writeSessionState(sessionDir, buildSessionState(job))
    await job.emit({
      t: 'session',
      sessionId,
      runtime: rowToRuntime(runtime),
      agent: agent ? rowToAgent(agent) : null,
    })

    try {
      const baselineFiles = await listSessionFiles(sessionDir)
      let effectiveChatMessages = chatMessages
      if (isEvaluateMode && sessionDir) {
        const inspection = await runEvaluationPackagePreflight({ job, sessionDir })
        toolEventCount += inspection.toolEventCount
        effectiveChatMessages = [
          ...chatMessages,
          { role: 'user', content: inspection.evidencePrompt },
        ]
      }

      if (runtime.protocol === 'local-cli') {
        await job.emit({ t: 'status', v: `正在调用${runtimeLabel(runtime)}` })
        const prompt = buildLocalRuntimePrompt({
          systemPrompt,
          chatMessages: effectiveChatMessages,
          mode,
          agentPrompt: agent?.prompt || '',
        })
        const localEvents = streamLocalRuntime({
          runtimeRow: runtime,
          prompt,
          workdir: sessionDir,
          signal: abortController.signal,
          timeoutMs: runtime.timeout_ms,
        })

        for await (const evt of localEvents) {
          if (evt?.t === 'delta') assistantText += evt.v || ''
          if (evt?.t === 'tool_call' || evt?.t === 'tool_result') toolEventCount += 1
          await job.emit(evt)
          if (evt?.t === 'error') throw new Error(evt.v || 'Skill 运行失败')
        }
      } else if (runtime.protocol === 'agentrun') {
        const loop = runAgentRunAgentLoop({
          agentRunRow: runtime,
          systemPrompt,
          chatMessages: effectiveChatMessages,
          sessionId,
          sessionDir,
          abortController,
          maxTokens: isCreateMode ? 8192 : 4096,
          initialToolChoice: undefined,
        })

        for await (const evt of loop) {
          if (evt?.t === 'delta') assistantText += evt.v || ''
          if (evt?.t === 'tool_call' || evt?.t === 'tool_result') toolEventCount += 1
          await job.emit(evt)
          if (evt.t === 'error') throw new Error(evt.v || 'Skill 运行失败')
        }
      } else if (runtime.protocol === QODER_CLOUD_AGENT_PROTOCOL) {
        await job.emit({ t: 'status', v: `正在调用${runtimeLabel(runtime)}` })
        const prompt = buildQoderCloudPrompt({
          systemPrompt,
          chatMessages: effectiveChatMessages,
          mode,
          agentPrompt: agent?.prompt || '',
        })
        for await (const evt of runQoderCloudAgent({
          runtimeRow: runtime,
          prompt,
          signal: abortController.signal,
          providerSessionId: job.providerSession?.id || '',
          afterProviderEventId: job.providerSession?.lastEventId || '',
          aiprosSessionId: sessionId,
        })) {
          if (evt?.t === 'delta') assistantText += evt.v || ''
          if (evt?.t === 'tool_call' || evt?.t === 'tool_result') toolEventCount += 1
          await mirrorRemoteWriteArtifact(evt, sessionDir)
          await job.emit(evt)
          if (evt.t === 'error') throw new Error(evt.v || 'Skill 运行失败')
        }
      } else {
        throw new Error(`暂不支持的运行时协议：${runtime.protocol}`)
      }

      let files = await listSessionFiles(sessionDir)
      const shouldEnforceTrackedToolClaims = runtime.protocol !== 'local-cli'
      if (shouldEnforceTrackedToolClaims && !isCreateMode && toolEventCount === 0 && looksLikeFakeToolExecutionClaim(assistantText)) {
        const recoveredCalls = recoverToolCallsFromAssistantText(assistantText, { systemPrompt, chatMessages })
        for (const call of recoveredCalls) {
          await job.emit({ t: 'tool_call', name: call.name, args: call.args, recovered: true })
          const result = await runTool(call.name, call.args, sessionDir).catch((e) => `执行错误：${e?.message || e}`)
          const resultStr = String(result)
          await job.emit({ t: 'tool_result', name: call.name, result: resultStr.length > 4000 ? resultStr.slice(0, 4000) + '\n...[已截断]' : resultStr, recovered: true })
          toolEventCount += 1
        }
        if (recoveredCalls.length) files = await listSessionFiles(sessionDir)
      }
      if (shouldEnforceTrackedToolClaims && !isCreateMode && toolEventCount === 0 && looksLikeFakeToolExecutionClaim(assistantText)) {
        throw new Error('模型声称已调用工具，但运行记录里没有真实工具调用。请重试，或切换支持工具调用的运行模型。')
      }
      const baselineSet = new Set(baselineFiles.map(file => String(file).toLowerCase()))
      const newFiles = files.filter(file => !baselineSet.has(String(file).toLowerCase()))
      if (!newFiles.length && assistantText.trim()) {
        const fallbackNames = {
          create: 'CREATE_RESULT.md',
          evaluate: 'EVALUATION_REPORT.md',
          run: 'RUN_RESULT.md',
        }
        const fallbackName = fallbackNames[mode] || 'RUN_RESULT.md'
        await fs.promises.writeFile(path.join(sessionDir, fallbackName), assistantText.trim() + '\n', 'utf8')
        files = await listSessionFiles(sessionDir)
      }
      for (const file of files) {
        const stat = await fs.promises.stat(path.join(sessionDir, file)).catch(() => null)
        await job.emit({
          t: 'artifact',
          artifact: normalizeArtifact({ path: file, size: stat?.size ?? null }, {
            sessionId,
            provider: runtime?.code || runtime?.protocol || '',
          }),
        })
      }
      if (files.length) await job.emit({ t: 'files', sessionId, files })
      job.state = 'completed'
      job.completedAt = new Date().toISOString()
      await job.emit({ t: 'done', state: job.state })
    } catch (e) {
      job.state = abortController.signal?.aborted ? 'timeout' : 'failed'
      job.error = describeFetchError(e)
      job.completedAt = new Date().toISOString()
      await job.emit({ t: 'error', v: job.error })
      await job.emit({ t: 'done', state: job.state })
    } finally {
      clearTimeout(totalTimer)
      job.updatedAt = new Date().toISOString()
      await writeSessionState(sessionDir, buildSessionState(job))
      const cleanupTimer = setTimeout(() => RUN_JOBS.delete(sessionId), 10 * 60 * 1000)
      cleanupTimer.unref?.()
    }
  })()

  return job
}

async function streamSessionEvents({ stream, sessionId, sessionDir, fromEventId = 0 }) {
  const job = RUN_JOBS.get(sessionId)
  const replay = await readSessionEvents(sessionDir, fromEventId)
  const state = job?.state || (await readSessionState(sessionDir))?.state || 'unknown'
  let lastSentEventId = Number(fromEventId || 0)

  await stream.writeSSE({
    id: String(Number(fromEventId || 0)),
    data: JSON.stringify({
      t: 'resume',
      sessionId,
      fromEventId: Number(fromEventId || 0),
      replayed: replay.length,
      state,
    }),
  })

  for (const evt of replay) {
    await writeSkillRunEvent(stream, evt)
    lastSentEventId = Math.max(lastSentEventId, Number(evt.id || evt.eventId || 0))
  }

  if (!job || job.state !== 'running') {
    if (job?.promise) await job.promise.catch(() => {})
    await flushMissedSkillRunTerminalEvents({ stream, sessionDir, afterEventId: lastSentEventId })
    return
  }

  let closed = false
  const subscriber = async (evt) => {
    if (closed) return
    await writeSkillRunEvent(stream, evt)
    lastSentEventId = Math.max(lastSentEventId, Number(evt.id || evt.eventId || 0))
    if (evt.t === 'done') {
      closed = true
      try { await stream.writeSSE({ data: '[DONE]' }) } catch {}
    }
  }
  job.subscribers.add(subscriber)

  const heartbeatTimer = setInterval(() => {
    if (closed) return
    stream.writeSSE({
      id: String(job.eventId),
      data: JSON.stringify({
        t: 'heartbeat',
        sessionId,
        state: job.state,
        eventId: job.eventId,
        elapsedMs: Date.now() - Date.parse(job.startedAt),
      }),
    }).catch(() => {})
  }, SKILL_RUN_HEARTBEAT_MS)
  heartbeatTimer.unref?.()

  stream.onAbort(() => {
    closed = true
    clearInterval(heartbeatTimer)
    job.subscribers.delete(subscriber)
  })

  await new Promise(resolve => {
    const doneTimer = setInterval(() => {
      if (closed || job.state !== 'running') {
        clearInterval(doneTimer)
        clearInterval(heartbeatTimer)
        job.subscribers.delete(subscriber)
        if (!closed) {
          closed = true
          Promise.resolve(job.promise)
            .catch(() => {})
            .then(() => flushMissedSkillRunTerminalEvents({ stream, sessionDir, afterEventId: lastSentEventId }))
            .catch(() => {})
            .finally(resolve)
        } else {
          resolve()
        }
      }
    }, 250)
    doneTimer.unref?.()
  })
}

function collectOpenAIToolCallDelta(choice, toolCallsMap) {
  const delta = choice?.delta || {}
  const calls = Array.isArray(delta.tool_calls)
    ? delta.tool_calls
    : (Array.isArray(choice?.message?.tool_calls) ? choice.message.tool_calls : [])
  for (const [position, tc] of calls.entries()) {
    const idx = tc.index ?? position
    if (!toolCallsMap[idx]) {
      toolCallsMap[idx] = { id: '', name: '', argumentsStr: '' }
    }
    if (tc.id) toolCallsMap[idx].id = tc.id
    if (tc.function?.name) toolCallsMap[idx].name = tc.function.name
    if (typeof tc.function?.arguments === 'string') {
      toolCallsMap[idx].argumentsStr += tc.function.arguments
    }
  }
}

function toolCallsFromMap(toolCallsMap) {
  return Object.keys(toolCallsMap)
    .sort((a, b) => Number(a) - Number(b))
    .map((k) => toolCallsMap[k])
    .filter((tc) => tc.name)
    .map((tc) => ({
      id: tc.id,
      type: 'function',
      function: { name: tc.name, arguments: tc.argumentsStr },
    }))
}

async function* streamAgentRunOpenAIRound({ agentRunRow, allMessages, sessionId, signal, maxTokens, toolChoice = 'auto' }) {
  const { url, options } = buildAgentRunChatCompletionsRequest({
    endpointPublicUrl: agentRunRow.endpoint_public_url,
    invocationKey: agentRunRow.api_key,
    sessionId: sessionId || newSessionId(),
    messages: allMessages,
    stream: true,
    tools: TOOLS,
    toolChoice,
    maxTokens,
  })
  const resp = await fetch(url, { ...options, signal })
  if (!resp.ok) {
    const text = await resp.text().catch(() => '')
    yield { t: 'error', v: `AgentRun 返回错误: HTTP ${resp.status} - ${text.slice(0, 300)}` }
    return
  }

  const reader = resp.body.getReader()
  const decoder = new TextDecoder()
  let buf = ''
  const toolCallsMap = {}
  let assistantContent = ''
  let finishReason = null

  try {
    while (true) {
      if (signal?.aborted) return
      const { value, done } = await reader.read()
      if (done) break
      buf += decoder.decode(value, { stream: true })
      const parts = buf.split(/\r?\n\r?\n/)
      buf = parts.pop() || ''
      for (const part of parts) {
        const data = part
          .split(/\r?\n/)
          .filter(line => line.trim().startsWith('data:'))
          .map(line => line.replace(/^\s*data:\s?/, ''))
          .join('\n')
          .trim()
        if (!data) continue
        if (data === '[DONE]') {
          yield {
            __roundResult: true,
            assistantMessage: {
              role: 'assistant',
              content: assistantContent || '',
              tool_calls: undefined,
            },
            toolCalls: toolCallsFromMap(toolCallsMap),
            finishReason,
          }
          return
        }
        let obj
        try { obj = JSON.parse(data) } catch { continue }
        const choice = obj?.choices?.[0]
        if (!choice) continue
        if (choice.finish_reason) finishReason = choice.finish_reason

        const text = extractOpenAIChunkText(obj)
        if (text) {
          assistantContent += text
          yield { t: 'delta', v: text }
        }
        collectOpenAIToolCallDelta(choice, toolCallsMap)
      }
    }
  } finally {
    try { await reader.cancel().catch(() => {}) } catch {}
  }

  const toolCalls = toolCallsFromMap(toolCallsMap)
  yield {
    __roundResult: true,
    assistantMessage: {
      role: 'assistant',
      content: assistantContent || '',
      tool_calls: toolCalls.length ? toolCalls : undefined,
    },
    toolCalls,
    finishReason,
  }
}

async function* runAgentRunAgentLoop({ agentRunRow, systemPrompt, chatMessages, sessionId, sessionDir, abortController, maxTokens, initialToolChoice }) {
  if (!isAgentRunReady(agentRunRow)) {
    yield { t: 'error', v: `AgentRun 配置未就绪：${missingAgentRunConfig(agentRunRow).join(', ')}` }
    return
  }

  if (isAgentRunIntegrationUrl(agentRunRow.endpoint_public_url)) {
    try {
      const allMessages = [
        ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
        ...chatMessages,
      ]
      const MAX_ROUNDS = 50
      for (let round = 0; round < MAX_ROUNDS; round++) {
        if (abortController?.signal?.aborted) return

        let roundResult = null
        let errored = false
        for await (const evt of streamAgentRunOpenAIRound({
          agentRunRow,
          allMessages,
          sessionId,
          signal: abortController?.signal,
          maxTokens,
          toolChoice: round === 0 && initialToolChoice ? initialToolChoice : 'auto',
        })) {
          if (evt && evt.__roundResult) {
            roundResult = evt
          } else if (evt && evt.t === 'error') {
            yield evt
            errored = true
            break
          } else {
            yield evt
          }
        }
        if (errored) return
        if (!roundResult) return

        const { assistantMessage, toolCalls } = roundResult
        if (!toolCalls.length) return
        const validToolCalls = []
        for (const tc of toolCalls) {
          const fn = tc.function || {}
          if (tc.id) validToolCalls.push(tc)
          else yield { t: 'warn', v: `工具 ${fn.name || 'tool'} 的 call_id 为空，已跳过` }
        }
        if (!validToolCalls.length) return
        allMessages.push({ ...assistantMessage, tool_calls: validToolCalls })

        for (const tc of validToolCalls) {
          const fn = tc.function || {}
          const name = fn.name || ''
          const toolId = tc.id || ''

          let args = {}
          try { args = JSON.parse(fn.arguments || '{}') } catch {}
          yield { t: 'tool_call', name, args }

          const result = await runTool(name, args, sessionDir).catch((e) => `执行错误：${e?.message || e}`)
          const resultStr = String(result)
          yield { t: 'tool_result', name, result: resultStr.length > 4000 ? resultStr.slice(0, 4000) + '\n...[已截断]' : resultStr }

          allMessages.push({
            role: 'tool',
            tool_call_id: toolId,
            content: resultStr,
          })
        }
      }
      yield { t: 'limit_reached', message: `工具调用已达 ${MAX_ROUNDS} 轮上限，点击继续执行` }
    } catch (e) {
      yield { t: 'error', v: `AgentRun 执行失败：${e?.message || e}` }
    }
    return
  }

  try {
    const { AgentRuntimeClient, Config } = await import('@agentrun/sdk')
    const config = new Config({
      accessKeyId: agentRunRow.access_key_id,
      accessKeySecret: agentRunRow.access_key_secret,
      securityToken: agentRunRow.security_token || undefined,
      accountId: agentRunRow.account_id,
      regionId: agentRunRow.region_id || 'cn-hangzhou',
      readTimeout: 300000,
      timeout: 300000,
    })
    const client = new AgentRuntimeClient(config)
    const messages = [
      ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
      ...chatMessages,
    ]
    const stream = await client.invokeOpenai({
      agentRuntimeName: agentRunRow.runtime_name,
      agentRuntimeEndpointName: agentRunRow.endpoint_name || 'default',
      messages,
      stream: true,
    })

    for await (const chunk of stream) {
      if (abortController?.signal?.aborted) return
      const delta = chunk?.choices?.[0]?.delta?.content
      if (typeof delta === 'string' && delta) {
        yield { t: 'delta', v: delta }
      }
      const text = chunk?.choices?.[0]?.message?.content
      if (typeof text === 'string' && text) {
        yield { t: 'delta', v: text }
      }
    }
  } catch (e) {
    const msg = e?.message || String(e)
    if (/accesskey|signature|credential|forbidden|unauthorized|auth/i.test(msg)) {
      yield { t: 'error', v: `AgentRun 鉴权或权限未就绪：${msg}` }
      return
    }
    yield { t: 'error', v: `AgentRun 执行失败：${msg}` }
  }
}

// ──────────────────────────────────────────────────────────
// POST /stream —— Skill 生成 / 运行 / 评测唯一主入口：必须走 AgentRun
// ──────────────────────────────────────────────────────────
app.post('/stream', async (c) => {
  const identity = c.get('identity')
  if (!identity?.phone && !identity?.email) return c.json({ error: 'Unauthorized' }, 401)

  let reqBody
  try { reqBody = await c.req.json() } catch { reqBody = {} }
  // 平台级创建/评测能力优先来自智能体挂载的平台 Skill；只有通用沙箱/调试才接受前端显式 systemPrompt。
  const isCreateMode = reqBody.mode === 'create'
  const isEvaluateMode = reqBody.mode === 'evaluate'
  const runtimePurpose = isCreateMode ? 'create' : (isEvaluateMode ? 'evaluate' : 'run')
  const { agent, runtime } = await resolveRuntimeForPurpose(runtimePurpose)
  if (!runtime) {
    return c.json({ error: missingRuntimeError(runtimePurpose) }, 503)
  }
  const missing = missingRuntimeConfig(runtime)
  if (missing.length) {
    return c.json({ error: `请在「智能体配置 / 运行时注册」补齐${runtimeLabel(runtime)}配置：${missing.join(', ')}` }, 503)
  }

  let systemPrompt
  if (isCreateMode) {
    systemPrompt = buildCreatorSystemPrompt(await resolveAgentSkillPrompt(agent, 'creator', SKILL_CREATOR_PROMPT))
  } else if (isEvaluateMode) {
    systemPrompt = `${await resolveAgentSkillPrompt(agent, 'evaluator', SKILL_EVALUATOR_PROMPT)}\n\n${SKILL_EVALUATOR_RUNTIME_CONTRACT}`
  } else if (typeof reqBody.systemPrompt === 'string' && reqBody.systemPrompt.trim()) {
    const mounted = await readPlatformSkillPrompt(agent?.skill_id)
    systemPrompt = `${SKILL_RUNNER_RUNTIME_CONTRACT}\n\n${mounted ? `${mounted}\n\n` : ''}${reqBody.systemPrompt}`
  } else {
    systemPrompt = await resolveAgentSkillPrompt(agent, 'evaluator', SKILL_EVALUATOR_PROMPT)
  }
  if (agent?.prompt && runtime.protocol === 'agentrun') {
    systemPrompt = `${agent.prompt}\n\n${systemPrompt}`
  }

  if (isCreateMode && reqBody.sourceFile && typeof reqBody.sourceFile === 'object') {
    const sf = reqBody.sourceFile
    systemPrompt += `\n\n## 用户选择的流程文件上下文\n\n文件名：${sf.name || '未命名文件'}\n所属流程：${sf.node_path || '未绑定'}\n文件描述：${sf.description || '无'}\n\n文件正文：\n${String(sf.content || '').slice(0, 50000)}`
  }

  let chatMessages = []
  if (Array.isArray(reqBody.messages)) {
    chatMessages = reqBody.messages.filter(m => m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string')
  } else if (typeof reqBody.message === 'string' && reqBody.message.trim()) {
    chatMessages = [{ role: 'user', content: reqBody.message }]
  }
  if (!chatMessages.length) {
    return c.json({ error: '缺少消息内容（messages 数组或 message 字符串）' }, 400)
  }

  // 复用前端传入的 sessionId（多轮对话续接），否则新建
  const sessionId = (typeof reqBody.sessionId === 'string' && /^[a-z0-9_-]{4,64}$/i.test(reqBody.sessionId))
    ? reqBody.sessionId
    : newSessionId()
  const sessionDir = resolveSessionDir(sessionId)

  // 如果传了 packageKey，还原到 session 目录（ZIP 解压；Markdown/TXT 单文件直接写入）
  // packageKey 形如：pkg/2026-05-22/file_xxx.zip 或 file_xxx_SKILL.md（即 files 表里的 r2_key）
  const reqPackageKey = typeof reqBody.packageKey === 'string' ? reqBody.packageKey.trim() : ''
  if (reqPackageKey && sessionDir) {
    try {
      // 校验 packageKey 不越权
      const packagePath = path.resolve(UPLOADS_ROOT, path.normalize(reqPackageKey))
      const uploadsResolved = path.resolve(UPLOADS_ROOT)
      if (packagePath.startsWith(uploadsResolved + path.sep)) {
        const packageBuf = await fs.promises.readFile(packagePath)
        await materializePackageBufferToSession({
          buffer: packageBuf,
          packageKey: reqPackageKey,
          sessionDir,
        })
      }
    } catch (e) {
      console.error('[skill-run] package materialize failed:', e?.message || e)
    }
  }

  const explicitFromEventId = Number(reqBody.resumeFromEventId || reqBody.afterEventId || 0)
  const resumeOnly = reqBody.resume === true || reqBody.resumeOnly === true
  const hasClientSessionId = typeof reqBody.sessionId === 'string' && /^[a-z0-9_-]{4,64}$/i.test(reqBody.sessionId)
  const latestStoredEventId = hasClientSessionId ? await readLatestSessionEventId(sessionDir) : 0
  const storedSessionState = hasClientSessionId ? await readSessionState(sessionDir) : null
  const fromEventId = (!resumeOnly && hasClientSessionId && explicitFromEventId === 0)
    ? latestStoredEventId
    : explicitFromEventId
  if (!resumeOnly || RUN_JOBS.get(sessionId)?.state === 'running') {
    createSkillRunJob({
      sessionId,
      sessionDir,
      runtime,
      agent,
      systemPrompt,
      chatMessages,
      isCreateMode,
      isEvaluateMode,
      mode: runtimePurpose,
      initialEventId: latestStoredEventId,
      providerSession: storedSessionState?.providerSession || null,
    })
  }

  return streamSSE(c, async (stream) => {
    await streamSessionEvents({ stream, sessionId, sessionDir, fromEventId })
  })
})

// ──────────────────────────────────────────────────────────
// GET /sessions/:sid/status —— 查询会话状态和已记录事件，用于断线恢复
// ──────────────────────────────────────────────────────────
app.get('/sessions/:sid/status', async (c) => {
  const identity = c.get('identity')
  if (!identity?.phone && !identity?.email) return c.json({ error: 'Unauthorized' }, 401)

  const sid = String(c.req.param('sid') || '')
  if (!/^[a-z0-9_-]{4,64}$/i.test(sid)) return c.json({ error: 'invalid_session_id' }, 400)

  const sessionDir = resolveSessionDir(sid)
  const job = RUN_JOBS.get(sid)
  const storedState = await readSessionState(sessionDir)
  const events = await readSessionEvents(sessionDir, Number(c.req.query('after') || 0))
  const files = await listSessionFiles(sessionDir)

  if (!job && !storedState && !events.length && !files.length) {
    return c.json({ error: 'session_not_found' }, 404)
  }

  const state = job ? buildSessionState(job) : storedState
  return c.json({
    ok: true,
    sessionId: sid,
    state: state?.state || 'unknown',
    eventId: state?.eventId || events.at(-1)?.id || 0,
    startedAt: state?.startedAt || null,
    updatedAt: state?.updatedAt || null,
    completedAt: state?.completedAt || null,
    error: state?.error || '',
    running: job?.state === 'running',
    events,
    files,
  })
})

// ──────────────────────────────────────────────────────────
// GET /sessions/:sid/repack —— 把 session 目录递归打包成 ZIP 返回
// 用于 AI 直接修改包内文件后，前端获取改进后的完整 Skill 包
// ──────────────────────────────────────────────────────────
app.get('/sessions/:sid/repack', async (c) => {
  const identity = c.get('identity') || {}
  if (!identity.phone && !identity.email) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const sid = c.req.param('sid')
  if (!sid || !/^[a-z0-9_-]{4,64}$/i.test(sid)) {
    return c.json({ error: 'invalid_session_id' }, 400)
  }
  const sessionDir = resolveSessionDir(sid)
  if (!sessionDir) return c.json({ error: 'invalid_session_id' }, 400)

  try {
    await fs.promises.access(sessionDir)
  } catch {
    return c.json({ error: 'session_not_found' }, 404)
  }

  try {
    const JSZip = (await import('jszip')).default
    const zip = new JSZip()

    async function addDir(dir, prefix) {
      const entries = await fs.promises.readdir(dir, { withFileTypes: true })
      for (const e of entries) {
        const rel = prefix ? `${prefix}/${e.name}` : e.name
        const fullPath = path.join(dir, e.name)
        if (e.isDirectory()) {
          await addDir(fullPath, rel)
        } else if (e.isFile()) {
          const buf = await fs.promises.readFile(fullPath)
          zip.file(rel, buf)
        }
      }
    }
    await addDir(sessionDir, '')

    const zipBuf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
    return new Response(zipBuf, {
      status: 200,
      headers: {
        'Content-Type': 'application/zip',
        'Content-Disposition': `attachment; filename="skill-${sid}.zip"`,
        'Content-Length': String(zipBuf.length),
        'Cache-Control': 'no-store',
      },
    })
  } catch (e) {
    console.error('[skill-run] repack failed:', e?.message || e)
    return c.json({ error: 'repack_failed', message: e?.message || String(e) }, 500)
  }
})

// ──────────────────────────────────────────────────────────
// GET /outputs/:sessionId/:filename —— 下载/预览会话生成的文件
// 路径：/api/skill-run/outputs/:sessionId/:filename
// 注意：此端点不校验登录态。sessionId 是 36 进制随机串（newSessionId），
// 浏览器 iframe / 直接打开都不会带自定义 X-User-* 头，强制鉴权会导致预览 401。
// 安全模型：sessionId 本身就是不可猜测的 token，相当于一次性签名。
// ──────────────────────────────────────────────────────────
app.get('/outputs/:sessionId/:filename', async (c) => {
  const sessionDir = resolveSessionDir(c.req.param('sessionId'))
  const fname = safeFilename(c.req.param('filename'))
  if (!sessionDir || !fname) return c.json({ error: 'bad_request' }, 400)

  const filePath = path.join(sessionDir, fname)
  // 防越权：必须落在 OUTPUTS_DIR 之内
  if (!filePath.startsWith(OUTPUTS_DIR + path.sep)) {
    return c.json({ error: 'forbidden' }, 403)
  }

  let buf
  try {
    buf = await fs.promises.readFile(filePath)
  } catch {
    return c.json({ error: 'not_found' }, 404)
  }

  const ext = path.extname(filePath).toLowerCase()
  const mime = MIME_MAP[ext] || 'application/octet-stream'

  // 默认 inline 预览（HTML/MD 等可直接在浏览器查看）；带 ?dl=1 时才强制下载
  const wantDownload = c.req.query('dl') === '1'
  const disposition = wantDownload
    ? `attachment; filename="${encodeURIComponent(fname)}"`
    : `inline; filename="${encodeURIComponent(fname)}"`

  return new Response(buf, {
    status: 200,
    headers: {
      'Content-Type': mime,
      'Content-Disposition': disposition,
      'Cache-Control': 'no-store',
    },
  })
})

export default app
