| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169 |
- /**
- * 工作流 API —— 外部 /api/v1 适配版。
- *
- * 与平台内部版(同目录 workflow.internal.js)保持相同的函数签名与返回形态,
- * 因此 composables/useWorkflowRunner.js 可以零改动对接外部 API。
- *
- * 与内部版的差异(详见 ../README.md「内外 API 差异」):
- * 1. 基础路径 /api/v1(内部版 /api),每个请求携带 X-API-Key 请求头。
- * 2. 创建运行为两阶段:POST /runs(async:true)→ POST /runs/{runId}/start
- * → GET /runs/{runId}/stream。对上层仍表现为"一次调用返回 SSE Response",
- * 与内部版行为等价。
- * 3. SSE 续传用 Last-Event-ID 请求头(内部版是 ?lastEventId= query 参数,
- * 外部端点不支持 query 参数)。
- * 4. 外部 API 的 stream 路径含 {workflowName} 段:模块内记录最近一次运行的
- * 工作流标识;页面刷新后内存丢失,重连前需调用 setWorkflowName() 恢复。
- * 5. /state 与 /resume 走外部端点 /api/v1/runs/{runId}/...(v1.2 已开放,
- * 路径不带 workflowName 段,页面刷新后仍可直接调用)。
- * submitClarifyAnswer 把外部响应的 accepted 映射为内部版语义的 resolved,
- * 并对 409 CLARIFY_ALREADY_ANSWERED 做幂等兼容(视为成功,spec §14.6)。
- *
- * 配置:BASE_URL 与 API_KEY 从构建期环境变量注入(此处以 Vite 约定为例),
- * 严禁硬编码进源码;生产环境推荐业务方后端反代并注入密钥,前端走同源。
- */
- const BASE_URL = (import.meta.env.VITE_AMP_BASE_URL || '').replace(/\/+$/, '')
- const API_KEY = import.meta.env.VITE_AMP_API_KEY || ''
- const API = `${BASE_URL}/api/v1`
- /** 最近一次运行的工作流标识(name 或数字 id),供 reconnectRunStream 拼路径 */
- let lastWorkflowNameOrId = null
- function requireApiKey() {
- if (!API_KEY) {
- throw new Error('缺少 X-API-Key:请配置 VITE_AMP_API_KEY(或改用后端反代注入)')
- }
- }
- /** 统一 JSON 请求:解包 {code:'OK', message, data},非 OK 抛错(code 挂在 err.code) */
- async function apiJson(path, options = {}) {
- const headers = { 'X-API-Key': API_KEY }
- if (options.body !== undefined) headers['Content-Type'] = 'application/json'
- const resp = await fetch(API + path, {
- method: options.method || 'GET',
- headers,
- body: options.body !== undefined ? JSON.stringify(options.body) : undefined
- })
- let json = null
- try { json = await resp.json() } catch (_) { /* 非 JSON 响应 */ }
- if (!resp.ok || !json || json.code !== 'OK') {
- const err = new Error((json && json.message) || `HTTP ${resp.status}`)
- err.code = json && json.code
- err.httpStatus = resp.status
- throw err
- }
- return json.data
- }
- /** 打开 SSE 订阅,返回原生 fetch Response(供 consumeSSEStream 消费) */
- function openStream(nameOrId, runId, lastEventId) {
- const headers = { 'X-API-Key': API_KEY, Accept: 'text/event-stream' }
- if (lastEventId != null) headers['Last-Event-ID'] = String(lastEventId)
- return fetch(`${API}/workflows/${nameOrId}/runs/${runId}/stream`, { headers })
- }
- /** 页面刷新后恢复工作流标识(reconnectRunStream 的路径需要它) */
- export function setWorkflowName(nameOrId) {
- lastWorkflowNameOrId = nameOrId
- }
- /**
- * 运行工作流:两阶段创建 + 启动 + 订阅,返回原生 fetch Response(SSE 流)。
- *
- * @param {string|number} nameOrId 工作流 name(kebab-case,推荐)或数字 id
- * @param {object} inputs 输入变量(对应外部 API 的 variables)
- * @param {File[]} files 可选上传文件
- * @param {string[]} paths 每个文件在工作目录中的相对路径(目录上传场景)
- *
- * 目录结构说明:浏览器 FormData 无法为单个 part 设置 X-Relative-Path 请求头,
- * 此处以"含路径的 filename"尽力透传;严格目录结构需求请由业务方后端代理
- * (反代层为 part 补 X-Relative-Path 头)。
- */
- export async function runWorkflow(nameOrId, inputs, files = [], paths = []) {
- requireApiKey()
- lastWorkflowNameOrId = nameOrId
- // 1) 创建运行(async:true 仅创建,不启动)
- const formData = new FormData()
- formData.append(
- 'payload',
- new Blob([JSON.stringify({ variables: inputs || {}, async: true })],
- { type: 'application/json' })
- )
- files.forEach((file, i) => formData.append('files', file, paths[i] || file.name))
- // multipart 不走 apiJson:不能预设 Content-Type(需保留 FormData 边界)
- const createResp = await fetch(`${API}/workflows/${nameOrId}/runs`, {
- method: 'POST',
- headers: { 'X-API-Key': API_KEY },
- body: formData
- })
- let created = null
- try { created = await createResp.json() } catch (_) { /* 非 JSON 响应 */ }
- if (!createResp.ok || !created || created.code !== 'OK') {
- const err = new Error((created && created.message) || `HTTP ${createResp.status}`)
- err.code = created && created.code
- err.httpStatus = createResp.status
- throw err
- }
- const runId = created.data.runId
- // 2) 启动执行(运行启动信号由本接口的 HTTP 响应承担,SSE 无 run_started 事件)
- await apiJson(`/workflows/${nameOrId}/runs/${runId}/start`, { method: 'POST' })
- // 3) 订阅 SSE 流
- return openStream(nameOrId, runId, null)
- }
- /**
- * 断线重连:按 runId 重新订阅 SSE,携带 Last-Event-ID 请求头续传。
- * 返回原生 fetch Response(与 runWorkflow 一致)。
- *
- * @param {string} runId 工作流运行 ID
- * @param {number} lastEventId 上次收到的最大事件 id(来自 SSE 帧 id: 字段)
- */
- export function reconnectRunStream(runId, lastEventId) {
- requireApiKey()
- if (!lastWorkflowNameOrId) {
- throw new Error('缺少工作流标识:页面刷新后请先调用 setWorkflowName()')
- }
- return openStream(lastWorkflowNameOrId, runId, lastEventId)
- }
- /**
- * 查询运行状态 + 当前未答暂停点(AskFor 恢复用)。
- *
- * 走外部端点 GET /runs/{runId}/state(不带 workflowName 段)。
- * 返回解包后的 data({runId, status, currentPause});上层 `res?.data || res` 容错兼容。
- */
- export async function getRunState(runId) {
- return apiJson(`/runs/${runId}/state`)
- }
- /**
- * 提交用户对 clarify_request 的回答,唤醒平台后端阻塞的执行线程。
- *
- * 走外部端点 POST /runs/{runId}/resume(不带 workflowName 段)。
- * 外部响应 data 为 {accepted, runId, clarifyId},此处映射为内部版语义的
- * resolved 供 useWorkflowRunner 消费;重复提交返回 409 时按幂等约定视为成功。
- */
- export async function submitClarifyAnswer(runId, clarifyId, answer) {
- try {
- const data = await apiJson(`/runs/${runId}/resume`, {
- method: 'POST',
- body: { clarifyId, answer }
- })
- return { resolved: !!data.accepted, runId: data.runId, clarifyId: data.clarifyId }
- } catch (e) {
- if (e.code === 'CLARIFY_ALREADY_ANSWERED') {
- return { resolved: true, idempotent: true }
- }
- throw e
- }
- }
- /**
- * 下载工作空间 zip(二进制,不走 JSON 解析;平台内部版无此独立函数,
- * RunHistory.vue 通过 window.location.href 直链下载,见其 downloadWorkspace)。
- */
- export function workspaceDownloadUrl(nameOrId, runId) {
- return `${API}/workflows/${nameOrId}/runs/${runId}/workspace`
- }
|