workflow.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. /**
  2. * 工作流 API —— 外部 /api/v1 适配版。
  3. *
  4. * 与平台内部版(同目录 workflow.internal.js)保持相同的函数签名与返回形态,
  5. * 因此 composables/useWorkflowRunner.js 可以零改动对接外部 API。
  6. *
  7. * 与内部版的差异(详见 ../README.md「内外 API 差异」):
  8. * 1. 基础路径 /api/v1(内部版 /api),每个请求携带 X-API-Key 请求头。
  9. * 2. 创建运行为两阶段:POST /runs(async:true)→ POST /runs/{runId}/start
  10. * → GET /runs/{runId}/stream。对上层仍表现为"一次调用返回 SSE Response",
  11. * 与内部版行为等价。
  12. * 3. SSE 续传用 Last-Event-ID 请求头(内部版是 ?lastEventId= query 参数,
  13. * 外部端点不支持 query 参数)。
  14. * 4. 外部 API 的 stream 路径含 {workflowName} 段:模块内记录最近一次运行的
  15. * 工作流标识;页面刷新后内存丢失,重连前需调用 setWorkflowName() 恢复。
  16. * 5. /state 与 /resume 走外部端点 /api/v1/runs/{runId}/...(v1.2 已开放,
  17. * 路径不带 workflowName 段,页面刷新后仍可直接调用)。
  18. * submitClarifyAnswer 把外部响应的 accepted 映射为内部版语义的 resolved,
  19. * 并对 409 CLARIFY_ALREADY_ANSWERED 做幂等兼容(视为成功,spec §14.6)。
  20. *
  21. * 配置:BASE_URL 与 API_KEY 从构建期环境变量注入(此处以 Vite 约定为例),
  22. * 严禁硬编码进源码;生产环境推荐业务方后端反代并注入密钥,前端走同源。
  23. */
  24. const BASE_URL = (import.meta.env.VITE_AMP_BASE_URL || '').replace(/\/+$/, '')
  25. const API_KEY = import.meta.env.VITE_AMP_API_KEY || ''
  26. const API = `${BASE_URL}/api/v1`
  27. /** 最近一次运行的工作流标识(name 或数字 id),供 reconnectRunStream 拼路径 */
  28. let lastWorkflowNameOrId = null
  29. function requireApiKey() {
  30. if (!API_KEY) {
  31. throw new Error('缺少 X-API-Key:请配置 VITE_AMP_API_KEY(或改用后端反代注入)')
  32. }
  33. }
  34. /** 统一 JSON 请求:解包 {code:'OK', message, data},非 OK 抛错(code 挂在 err.code) */
  35. async function apiJson(path, options = {}) {
  36. const headers = { 'X-API-Key': API_KEY }
  37. if (options.body !== undefined) headers['Content-Type'] = 'application/json'
  38. const resp = await fetch(API + path, {
  39. method: options.method || 'GET',
  40. headers,
  41. body: options.body !== undefined ? JSON.stringify(options.body) : undefined
  42. })
  43. let json = null
  44. try { json = await resp.json() } catch (_) { /* 非 JSON 响应 */ }
  45. if (!resp.ok || !json || json.code !== 'OK') {
  46. const err = new Error((json && json.message) || `HTTP ${resp.status}`)
  47. err.code = json && json.code
  48. err.httpStatus = resp.status
  49. throw err
  50. }
  51. return json.data
  52. }
  53. /** 打开 SSE 订阅,返回原生 fetch Response(供 consumeSSEStream 消费) */
  54. function openStream(nameOrId, runId, lastEventId) {
  55. const headers = { 'X-API-Key': API_KEY, Accept: 'text/event-stream' }
  56. if (lastEventId != null) headers['Last-Event-ID'] = String(lastEventId)
  57. return fetch(`${API}/workflows/${nameOrId}/runs/${runId}/stream`, { headers })
  58. }
  59. /** 页面刷新后恢复工作流标识(reconnectRunStream 的路径需要它) */
  60. export function setWorkflowName(nameOrId) {
  61. lastWorkflowNameOrId = nameOrId
  62. }
  63. /**
  64. * 运行工作流:两阶段创建 + 启动 + 订阅,返回原生 fetch Response(SSE 流)。
  65. *
  66. * @param {string|number} nameOrId 工作流 name(kebab-case,推荐)或数字 id
  67. * @param {object} inputs 输入变量(对应外部 API 的 variables)
  68. * @param {File[]} files 可选上传文件
  69. * @param {string[]} paths 每个文件在工作目录中的相对路径(目录上传场景)
  70. *
  71. * 目录结构说明:浏览器 FormData 无法为单个 part 设置 X-Relative-Path 请求头,
  72. * 此处以"含路径的 filename"尽力透传;严格目录结构需求请由业务方后端代理
  73. * (反代层为 part 补 X-Relative-Path 头)。
  74. */
  75. export async function runWorkflow(nameOrId, inputs, files = [], paths = []) {
  76. requireApiKey()
  77. lastWorkflowNameOrId = nameOrId
  78. // 1) 创建运行(async:true 仅创建,不启动)
  79. const formData = new FormData()
  80. formData.append(
  81. 'payload',
  82. new Blob([JSON.stringify({ variables: inputs || {}, async: true })],
  83. { type: 'application/json' })
  84. )
  85. files.forEach((file, i) => formData.append('files', file, paths[i] || file.name))
  86. // multipart 不走 apiJson:不能预设 Content-Type(需保留 FormData 边界)
  87. const createResp = await fetch(`${API}/workflows/${nameOrId}/runs`, {
  88. method: 'POST',
  89. headers: { 'X-API-Key': API_KEY },
  90. body: formData
  91. })
  92. let created = null
  93. try { created = await createResp.json() } catch (_) { /* 非 JSON 响应 */ }
  94. if (!createResp.ok || !created || created.code !== 'OK') {
  95. const err = new Error((created && created.message) || `HTTP ${createResp.status}`)
  96. err.code = created && created.code
  97. err.httpStatus = createResp.status
  98. throw err
  99. }
  100. const runId = created.data.runId
  101. // 2) 启动执行(运行启动信号由本接口的 HTTP 响应承担,SSE 无 run_started 事件)
  102. await apiJson(`/workflows/${nameOrId}/runs/${runId}/start`, { method: 'POST' })
  103. // 3) 订阅 SSE 流
  104. return openStream(nameOrId, runId, null)
  105. }
  106. /**
  107. * 断线重连:按 runId 重新订阅 SSE,携带 Last-Event-ID 请求头续传。
  108. * 返回原生 fetch Response(与 runWorkflow 一致)。
  109. *
  110. * @param {string} runId 工作流运行 ID
  111. * @param {number} lastEventId 上次收到的最大事件 id(来自 SSE 帧 id: 字段)
  112. */
  113. export function reconnectRunStream(runId, lastEventId) {
  114. requireApiKey()
  115. if (!lastWorkflowNameOrId) {
  116. throw new Error('缺少工作流标识:页面刷新后请先调用 setWorkflowName()')
  117. }
  118. return openStream(lastWorkflowNameOrId, runId, lastEventId)
  119. }
  120. /**
  121. * 查询运行状态 + 当前未答暂停点(AskFor 恢复用)。
  122. *
  123. * 走外部端点 GET /runs/{runId}/state(不带 workflowName 段)。
  124. * 返回解包后的 data({runId, status, currentPause});上层 `res?.data || res` 容错兼容。
  125. */
  126. export async function getRunState(runId) {
  127. return apiJson(`/runs/${runId}/state`)
  128. }
  129. /**
  130. * 提交用户对 clarify_request 的回答,唤醒平台后端阻塞的执行线程。
  131. *
  132. * 走外部端点 POST /runs/{runId}/resume(不带 workflowName 段)。
  133. * 外部响应 data 为 {accepted, runId, clarifyId},此处映射为内部版语义的
  134. * resolved 供 useWorkflowRunner 消费;重复提交返回 409 时按幂等约定视为成功。
  135. */
  136. export async function submitClarifyAnswer(runId, clarifyId, answer) {
  137. try {
  138. const data = await apiJson(`/runs/${runId}/resume`, {
  139. method: 'POST',
  140. body: { clarifyId, answer }
  141. })
  142. return { resolved: !!data.accepted, runId: data.runId, clarifyId: data.clarifyId }
  143. } catch (e) {
  144. if (e.code === 'CLARIFY_ALREADY_ANSWERED') {
  145. return { resolved: true, idempotent: true }
  146. }
  147. throw e
  148. }
  149. }
  150. /**
  151. * 下载工作空间 zip(二进制,不走 JSON 解析;平台内部版无此独立函数,
  152. * RunHistory.vue 通过 window.location.href 直链下载,见其 downloadWorkspace)。
  153. */
  154. export function workspaceDownloadUrl(nameOrId, runId) {
  155. return `${API}/workflows/${nameOrId}/runs/${runId}/workspace`
  156. }