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