build_deck.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import { pathToFileURL } from 'node:url';
  4. import { Presentation, PresentationFile } from '@oai/artifact-tool';
  5. import { createCanvas } from '@napi-rs/canvas';
  6. const workspaceDir = 'C:/Users/30214/Desktop/sub2本地/sub2新站';
  7. const buildDir = path.join(workspaceDir, '.codex-artifacts/juheai-astra-20260915');
  8. const assetDir = path.join(buildDir, 'assets');
  9. const outputDir = path.join(workspaceDir, 'output/presentations');
  10. const skillDir = 'C:/Users/30214/.codex/plugins/cache/openai-primary-runtime/presentations/26.909.12148/skills/presentations';
  11. const pythonExecutable = 'C:/Users/30214/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe';
  12. const revision = process.argv[2] || 'v1';
  13. const finalPath = path.join(outputDir, revision === '交付版' ? '聚合AI_Astra_方案一.pptx' : `聚合AI_Astra_方案一_${revision}.pptx`);
  14. const renderDir = path.join(buildDir, `rendered-${revision}`);
  15. const { resolvePresentationFont, finalizePresentation } = await import(pathToFileURL(path.join(skillDir, 'container_tools/artifact_tool_utils.mjs')).href);
  16. const font = resolvePresentationFont({ fontFamily: 'Microsoft YaHei' });
  17. const fontPolicy = { basis: 'design', families: [font] };
  18. const C = { black: '#000000', white: '#F0F4FA', silver: '#B6C3D1', muted: '#8C9BAE', blue: '#88D6FF', dim: '#344554' };
  19. const presentation = Presentation.create({ slideSize: { width: 1280, height: 720 } });
  20. const measureContext = createCanvas(1280, 720).getContext('2d');
  21. const textChecks = [];
  22. const pendingFolios = [];
  23. const assets = {};
  24. const imageCredits = {};
  25. const source = (rel, line) => `${workspaceDir}/${rel}:${line}`;
  26. const SOURCES = {
  27. platform: source('frontend/src/views/HomeView.vue', 62),
  28. protocols: source('backend/internal/server/routes/gateway.go', 196),
  29. gemini: source('backend/internal/server/routes/gateway.go', 339),
  30. clients: source('frontend/src/components/keys/UseKeyModal.vue', 941),
  31. astra: source('frontend/src/components/keys/UseKeyModal.vue', 1328),
  32. reasoning: source('backend/internal/service/openai_gateway_request_body.go', 1022),
  33. retryTest: source('backend/internal/service/openai_astra_pro_retry_test.go', 64),
  34. failover: source('backend/internal/handler/openai_gateway_handler.go', 883),
  35. failoverTest: source('backend/internal/handler/openai_responses_astra_pro_failover_test.go', 140),
  36. stream: source('backend/internal/service/openai_gateway_forward.go', 1207),
  37. usage: source('frontend/src/views/user/UsageView.vue', 650),
  38. monitor: source('frontend/src/views/user/ChannelStatusV2View.vue', 196),
  39. key: source('frontend/src/views/user/KeysView.vue', 205),
  40. };
  41. async function loadAsset(name, required = false) {
  42. for (const ext of ['jpg', 'jpeg', 'png', 'webp']) {
  43. const file = path.join(assetDir, `${name}.${ext}`);
  44. try {
  45. const bytes = await fs.readFile(file);
  46. return { bytes, contentType: ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : `image/${ext}`, file };
  47. } catch (error) {
  48. if (error.code !== 'ENOENT') throw error;
  49. }
  50. }
  51. if (required) throw new Error(`Missing required asset: ${name}`);
  52. return null;
  53. }
  54. function text(slide, name, value, left, top, width, height, size, opts = {}) {
  55. measureContext.font = `${opts.bold ? 'bold' : 'normal'} ${size}px "${font}"`;
  56. const metrics = measureContext.measureText(value);
  57. const measuredHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
  58. textChecks.push({ slide: presentation.slides.items.indexOf(slide) + 1, name, value, left, top, width, height, color: opts.color ?? C.white, measuredWidth: metrics.width, measuredHeight });
  59. if (metrics.width > width - 2 || measuredHeight > height || left < 0 || top < 0 || left + width > 1280 || top + height > 720) {
  60. throw new Error(`Text does not fit: ${name}: ${metrics.width}x${measuredHeight} in ${width}x${height}`);
  61. }
  62. const box = slide.shapes.add({
  63. name,
  64. geometry: 'textbox',
  65. position: { left, top, width, height },
  66. fill: 'none',
  67. line: { fill: 'none', width: 0 },
  68. });
  69. box.text = value;
  70. box.text.style = {
  71. typeface: font,
  72. fontSize: size,
  73. bold: opts.bold ?? false,
  74. color: opts.color ?? C.white,
  75. alignment: opts.align ?? 'left',
  76. verticalAlignment: 'middle',
  77. autoFit: 'none',
  78. wrap: 'none',
  79. insets: { left: 0, right: 0, top: 0, bottom: 0 },
  80. };
  81. return box;
  82. }
  83. function art(slide, name, left, top, width, height, fit = 'contain') {
  84. const asset = assets[name];
  85. if (!asset) return;
  86. slide.images.add({
  87. blob: asset.bytes,
  88. contentType: asset.contentType,
  89. alt: imageCredits[name]?.title || `Space image background: ${name}`,
  90. fit,
  91. position: { left, top, width, height },
  92. });
  93. }
  94. function notes(slide, narration, references = [], artNames = []) {
  95. const credit = artNames.filter(x => imageCredits[x]).map(x => {
  96. const a = imageCredits[x];
  97. return `Visual source: ${a.title}. ${a.credit || ''} ${a.page || ''} ${a.url || ''}`.trim();
  98. });
  99. slide.speakerNotes.textFrame.setText([
  100. narration,
  101. ...references.map(ref => `Source: ${ref}`),
  102. ...credit,
  103. ].join('\n\n'));
  104. }
  105. function slideBase(page, withFolio = true) {
  106. const slide = presentation.slides.add();
  107. slide.background.fill = C.black;
  108. if (withFolio) pendingFolios.push({ slide, page });
  109. return slide;
  110. }
  111. function title(slide, value) {
  112. return text(slide, 'slide-title', value, 76, 72, 1128, 84, 48, { bold: true });
  113. }
  114. async function build() {
  115. await fs.mkdir(outputDir, { recursive: true });
  116. await fs.mkdir(renderDir, { recursive: true });
  117. for (const name of ['cover-halo', 'astra-core', 'speed-light']) assets[name] = await loadAsset(name, name === 'cover-halo');
  118. Object.assign(imageCredits, JSON.parse(await fs.readFile(path.join(assetDir, 'credits.json'), 'utf8')));
  119. // 1. Platform first. Astra is the promoted model within the platform.
  120. {
  121. const s = slideBase(1, false);
  122. art(s, 'cover-halo', 575, 58, 705, 590);
  123. text(s, 'brand', '聚合AI', 76, 164, 650, 120, 88, { bold: true });
  124. text(s, 'featured-model', 'Astra 主推模型', 80, 306, 540, 80, 40);
  125. text(s, 'brand-promise', '不降智,稳定,高速', 80, 440, 550, 55, 30, { color: C.silver });
  126. text(s, 'platform-value', '多模型选择,统一接入', 80, 596, 540, 44, 24, { color: C.blue });
  127. notes(s, '聚合AI是平台品牌,Astra是本次重点介绍的模型。平台聚合多种模型接入方式,让用户在熟悉的工具中完成调用。品牌名称和“不降智,稳定,高速”的定位来自用户。', [SOURCES.platform, SOURCES.protocols], ['cover-halo']);
  128. }
  129. // 2. Clear aggregation identity and protocol compatibility.
  130. {
  131. const s = slideBase(2);
  132. title(s, '多模型,一个入口');
  133. text(s, 'platform-explanation', '模型接入、API Key、调用记录与费用管理,集中在聚合AI。', 78, 174, 1120, 56, 26, { color: C.silver });
  134. text(s, 'featured-model-label', '本站主推', 80, 316, 480, 42, 24, { color: C.blue });
  135. text(s, 'astra-word', 'Astra', 76, 354, 510, 112, 88, { bold: true });
  136. text(s, 'protocol-label', '支持多种接入协议', 682, 282, 510, 43, 24, { color: C.muted });
  137. text(s, 'protocol-openai', 'OpenAI 兼容', 682, 342, 510, 53, 34);
  138. text(s, 'protocol-anthropic', 'Anthropic Messages', 682, 412, 510, 53, 34);
  139. text(s, 'protocol-gemini', 'Gemini 原生', 682, 482, 510, 53, 34);
  140. text(s, 'platform-takeaway', '按任务选择模型,沿用熟悉的工具。', 80, 595, 1070, 46, 26, { color: C.silver });
  141. notes(s, '聚合AI提供多种协议路由,统一管理密钥和用量。OpenAI兼容包括Responses和Chat Completions,另有Anthropic Messages与Gemini原生路由。可用模型以账号分组、权限和控制台列表为准。', [SOURCES.platform, SOURCES.protocols, SOURCES.gemini]);
  142. }
  143. // 3. A quiet model hero, with task-oriented positioning.
  144. {
  145. const s = slideBase(3);
  146. text(s, 'model-section-label', '聚合AI 主推模型', 80, 88, 730, 50, 28, { color: C.blue });
  147. text(s, 'astra-hero', 'ASTRA', 72, 178, 1128, 181, 136, { bold: true });
  148. text(s, 'astra-positioning', '把 Astra 接入你的工作流', 80, 367, 740, 66, 36);
  149. text(s, 'astra-settings', '按所选模型与推理设置发起调用。', 80, 445, 760, 55, 26, { color: C.silver });
  150. text(s, 'task-coding', '编码协作', 80, 586, 218, 44, 26, { color: C.blue });
  151. text(s, 'task-analysis', '复杂分析', 303, 586, 218, 44, 26, { color: C.silver });
  152. text(s, 'task-agent', 'Agent 任务', 526, 586, 300, 44, 26, { color: C.silver });
  153. notes(s, 'Astra是用户指定的主推模型。项目客户端配置包含Astra模型标识,转发代码保留相应推理参数。编码协作、复杂分析与Agent任务是本页给出的使用方向,不构成与其他模型的能力排名或测试结论。', [SOURCES.astra, SOURCES.reasoning]);
  154. }
  155. // 4. Explain “不降智” narrowly and concretely.
  156. {
  157. const s = slideBase(4);
  158. title(s, '不降智,保留 Astra 推理设置');
  159. text(s, 'preserve-model', 'Astra 按设定调用', 76, 256, 630, 95, 56, { bold: true });
  160. text(s, 'preserve-reasoning', '保留推理设置', 76, 366, 630, 95, 56, { bold: true, color: C.blue });
  161. text(s, 'settings-body-1', 'Astra 请求重试或故障切换时,', 739, 263, 469, 44, 27, { color: C.silver });
  162. text(s, 'settings-body-2', '继续保留指定推理设置。', 739, 308, 469, 44, 27, { color: C.silver });
  163. text(s, 'mode-rejected-1', '推理设置不受支持时,', 739, 412, 469, 44, 27, { color: C.silver });
  164. text(s, 'mode-rejected-2', '明确提示原因。', 739, 457, 469, 44, 27, { color: C.silver });
  165. notes(s, '这里把“不降智”具体解释为Astra指定推理参数的保留。代码及现有测试覆盖pro/max参数在重试与账号切换时保留,并在上游拒绝指定模式时保留错误。测试也覆盖特定重试和故障切换路径中Astra模型标识不变;项目允许显式模型映射,不将这项测试泛化为所有配置下模型标识永不改变。引用测试文件属于实现依据,本次未运行线上质量测试;不把转发设置等同于上游每次回答质量保证。', [SOURCES.reasoning, SOURCES.retryTest, SOURCES.failoverTest]);
  166. }
  167. // 5. An honest workflow example, not a fabricated model response.
  168. {
  169. const s = slideBase(5);
  170. title(s, 'Astra 工作流示例');
  171. text(s, 'sample-task-line-1', '为已有项目增加登录能力', 76, 229, 1128, 77, 48, { bold: true });
  172. text(s, 'sample-task-line-2', '兼顾权限、异常处理与验证', 76, 313, 1128, 77, 48, { color: C.blue });
  173. text(s, 'context-label', '给模型的上下文', 78, 466, 550, 45, 28);
  174. text(s, 'context-body-1', '现有代码、技术栈、约束', 78, 523, 550, 44, 26, { color: C.silver });
  175. text(s, 'context-body-2', '与验收要求。', 78, 565, 550, 44, 26, { color: C.silver });
  176. text(s, 'review-label', '你要检查的结果', 683, 466, 520, 45, 28);
  177. text(s, 'review-body-1', '实现方案、代码修改、验证步骤', 683, 523, 520, 44, 26, { color: C.silver });
  178. text(s, 'review-body-2', '与边界说明。', 683, 565, 520, 44, 26, { color: C.silver });
  179. notes(s, '这一页是面向开发者的任务示例,展示如何组织输入与检查输出,不是Astra实际跑出的回答,也不是性能或正确率测评。讲解时可结合真实项目补充上下文,由用户核验模型给出的方案和代码。', [SOURCES.platform]);
  180. }
  181. // 6. Conditional recovery rather than an invented SLA.
  182. {
  183. const s = slideBase(6);
  184. title(s, '稳定调用,异常自动处理');
  185. text(s, 'recovery-condition', '请求条件允许时,平台识别可重试故障,并尝试切换合适账号。', 78, 173, 1128, 57, 26, { color: C.silver });
  186. const columns = [
  187. { x: 78, n: '01', label: '识别异常', detail: '判断请求是否可以重试' },
  188. { x: 471, n: '02', label: '切换账号', detail: '选择当前可用的账号' },
  189. { x: 864, n: '03', label: '保持设置', detail: '继续保留 Astra 推理参数' },
  190. ];
  191. for (const { x, n, label, detail } of columns) {
  192. text(s, `recovery-number-${n}`, n, x, 299, 320, 99, 76, { color: C.blue });
  193. text(s, `recovery-label-${n}`, label, x, 420, 330, 57, 36, { bold: true });
  194. text(s, `recovery-detail-${n}`, detail, x, 488, 340, 49, 24, { color: C.silver });
  195. }
  196. text(s, 'recovery-monitor', '渠道状态与成功率可查看。', 78, 602, 1040, 40, 25, { color: C.muted });
  197. notes(s, '自动重试与切换有明确条件,包括错误允许重试、请求可安全回放、存在合适账号并且未达到上限。页面不承诺零中断或无限重试。渠道监控提供状态及成功率,具体结果受样本、时间窗口和上游状态影响。', [SOURCES.failover, SOURCES.failoverTest, SOURCES.monitor]);
  198. }
  199. // 7. Streaming and observable latency, with no invented measurement.
  200. {
  201. const s = slideBase(7);
  202. art(s, 'speed-light', 685, 448, 519, 181, 'cover');
  203. title(s, '高速响应,进度看得见');
  204. text(s, 'stream-label', '流式输出', 78, 244, 520, 69, 44, { bold: true, color: C.blue });
  205. text(s, 'stream-description-1', '开启流式请求,', 78, 331, 555, 49, 27, { color: C.silver });
  206. text(s, 'stream-description-2', '生成内容逐步返回。', 78, 378, 555, 49, 27, { color: C.silver });
  207. text(s, 'latency-label', '响应可查', 685, 244, 518, 69, 44, { bold: true });
  208. text(s, 'latency-description-1', '首 Token 与总耗时,', 685, 331, 518, 49, 27, { color: C.silver });
  209. text(s, 'latency-description-2', '可在用量记录中核对。', 685, 378, 518, 49, 27, { color: C.silver });
  210. text(s, 'stream-closing-1', '使用过程更直观,', 78, 526, 550, 44, 26, { color: C.silver });
  211. text(s, 'stream-closing-2', '响应表现有记录。', 78, 569, 550, 44, 26, { color: C.silver });
  212. notes(s, '流式输出要求客户端开启stream并且相应上游协议支持。用量记录展示首Token时间和总耗时。本页不引用未经采集的速度数字,不把渠道监控中TPM除以60得到的窗口吞吐量当作单请求生成速度。', [SOURCES.stream, SOURCES.usage, SOURCES.monitor], ['speed-light']);
  213. }
  214. // 8. Return to the wider value of an AI aggregation platform.
  215. {
  216. const s = slideBase(8);
  217. title(s, '多模型接入,调用清楚可查');
  218. text(s, 'models-label', '按任务选模型', 78, 275, 552, 69, 40, { bold: true, color: C.blue });
  219. text(s, 'models-description-1', '通过控制台查看可用模型,', 78, 365, 552, 49, 27, { color: C.silver });
  220. text(s, 'models-description-2', '选择适合当前任务的配置。', 78, 412, 552, 49, 27, { color: C.silver });
  221. text(s, 'usage-label', '按记录看消耗', 684, 275, 520, 69, 40, { bold: true });
  222. text(s, 'usage-description-1', '模型、Token 和实际扣费,', 684, 365, 520, 49, 27, { color: C.silver });
  223. text(s, 'usage-description-2', '每笔调用都可核对。', 684, 412, 520, 49, 27, { color: C.silver });
  224. text(s, 'key-control', 'API Key 的额度、有效期与访问权限可分别设置。', 78, 579, 1128, 54, 26, { color: C.muted });
  225. notes(s, '聚合平台统一提供模型列表、密钥管理与调用记录。用量记录包括输入输出及缓存Token和实际费用。可用模型按账号分组与权限确定,费用以实际配置和用量记录为准。', [SOURCES.platform, SOURCES.usage, SOURCES.key]);
  226. }
  227. // 9. An actionable onboarding sequence without an invented URL.
  228. {
  229. const s = slideBase(9);
  230. title(s, '接入你正在用的工具');
  231. text(s, 'compatibility', '支持自定义服务地址的客户端或 SDK,可沿用熟悉的工作方式。', 78, 173, 1128, 57, 26, { color: C.silver });
  232. const rows = [
  233. ['01', '填写接入地址', '复制控制台提供的 Base URL', 288],
  234. ['02', '配置 API Key', '创建密钥,并选择所需的分组与额度', 407],
  235. ['03', '选择 Astra', '按控制台模型名称配置,开始调用', 526],
  236. ];
  237. for (const [n, label, detail, y] of rows) {
  238. text(s, `onboard-number-${n}`, n, 78, y, 140, 75, 52, { color: C.blue });
  239. text(s, `onboard-label-${n}`, label, 233, y + 6, 450, 62, 34, { bold: true });
  240. text(s, `onboard-detail-${n}`, detail, 704, y + 9, 500, 54, 25, { color: C.silver });
  241. }
  242. notes(s, '项目提供客户端配置辅助。适用于支持自定义Base URL的客户端和SDK。实际地址、密钥和模型标识从控制台复制,密钥权限需要覆盖所选模型分组。这里不公开任何用户密钥,也不填入未确认的站点域名。', [SOURCES.clients, SOURCES.astra, SOURCES.key]);
  243. }
  244. // 10. Close on the platform brand, not on a standalone model identity.
  245. {
  246. const s = slideBase(10, false);
  247. art(s, 'cover-halo', 610, 76, 670, 559);
  248. text(s, 'closing-brand', '聚合AI', 76, 164, 650, 116, 84, { bold: true });
  249. text(s, 'closing-positioning', 'Astra 主推,多模型聚合', 80, 302, 910, 74, 40);
  250. text(s, 'closing-promise', '不降智,稳定,高速', 80, 430, 850, 60, 32, { color: C.blue });
  251. text(s, 'closing-cta', '创建 API Key,连接你的常用工具。', 80, 584, 1060, 51, 27, { color: C.silver });
  252. notes(s, '总结聚合AI的定位:Astra为主推模型,多模型聚合作为平台价值。用户可以在控制台创建API Key,并按对应协议接入常用工具。', [SOURCES.platform, SOURCES.clients], ['cover-halo']);
  253. }
  254. for (const { slide, page } of pendingFolios) {
  255. text(slide, `folio-${page}`, String(page), 1180, 653, 28, 25, 17, { color: C.muted, align: 'right' });
  256. }
  257. const candidatePath = path.join(buildDir, `candidate-${revision}.pptx`);
  258. await fs.writeFile(path.join(buildDir, `text-metrics-${revision}.json`), JSON.stringify(textChecks, null, 2));
  259. await (await PresentationFile.exportPptx(presentation)).save(candidatePath);
  260. const result = await finalizePresentation({
  261. workspaceDir,
  262. candidatePath,
  263. finalPath,
  264. pythonExecutable,
  265. integrityValidatorPath: path.join(skillDir, 'container_tools/inspect_presentation_package_integrity.py'),
  266. layoutValidatorPath: path.join(skillDir, 'container_tools/inspect_presentation_layout_geometry.py'),
  267. layoutArgs: ['--expected-slide-size-emu', '12192000,6858000', '--validate-bullet-geometry', '--validate-heading-fit'],
  268. explicitTotalSlideCount: 10,
  269. requiredNativeTableOwnerSlides: [],
  270. requiredNativeChartOwnerSlides: [],
  271. fontPolicy,
  272. verifyArtifactToolImport: true,
  273. receiptPath: path.join(buildDir, `validation-${revision}.json`),
  274. });
  275. await fs.writeFile(path.join(buildDir, `presentation-${revision}.json`), JSON.stringify(presentation.toProto()));
  276. await fs.writeFile(path.join(buildDir, `build-result-${revision}.json`), JSON.stringify(result, null, 2));
  277. console.log(JSON.stringify({ finalPath, candidatePath, renderDir, slideCount: presentation.slides.items.length, font }, null, 2));
  278. }
  279. await build();