| 1 | import fs from 'fs' |
| 2 | import path from 'path' |
| 3 | import log from 'electron-log/main.js' |
| 4 | import { resolveModelTimeoutMs } from '@shared/model-timeout' |
| 5 | import { extractJsonBlock, extractModelText, resolveModel } from '../../agent-runtime/model' |
| 6 | import type { ModelRuntimeConfig } from '../../agent-runtime/model' |
| 7 | import { resolveBuiltinSkillsSourcePath } from '../../product-skills/paths' |
| 8 | import type { |
| 9 | PptxChartRewriteHandler, |
| 10 | PptxChartRewriteRequest, |
| 11 | PptxChartRewriteResult |
| 12 | } from './types' |
| 13 | |
| 14 | type PptxChartRewriteAgentOptions = { |
| 15 | provider: string |
| 16 | apiKey: string |
| 17 | model: string |
| 18 | baseUrl?: string |
| 19 | maxTokens?: number |
| 20 | modelRuntime?: ModelRuntimeConfig |
| 21 | modelTimeoutMs: number |
| 22 | maxRewrites?: number |
| 23 | } |
| 24 | |
| 25 | type ChartSkillDocs = { |
| 26 | skill: string |
| 27 | reference: string |
| 28 | } |
| 29 | |
| 30 | const DEFAULT_MAX_REWRITES = 6 |
| 31 | let cachedChartSkillDocs: ChartSkillDocs | null = null |
| 32 | |
| 33 | const readChartSkillDocs = async (): Promise<ChartSkillDocs> => { |
| 34 | if (cachedChartSkillDocs) return cachedChartSkillDocs |
| 35 | const skillRoot = path.join(resolveBuiltinSkillsSourcePath(), 'oh-my-ppt-chart') |
| 36 | const [skill, reference] = await Promise.all([ |
| 37 | fs.promises.readFile(path.join(skillRoot, 'SKILL.md'), 'utf-8'), |
| 38 | fs.promises.readFile(path.join(skillRoot, 'references', 'chart.md'), 'utf-8').catch(() => '') |
| 39 | ]) |
| 40 | cachedChartSkillDocs = { skill, reference } |
| 41 | return cachedChartSkillDocs |
| 42 | } |
| 43 | |
| 44 | const compactUnknown = (value: unknown, maxLength = 12000): unknown => { |
| 45 | const text = JSON.stringify(value, (_key, item) => { |
| 46 | if (typeof item === 'string' && item.length > 500) return `${item.slice(0, 500)}...` |
| 47 | return item |
| 48 | }) |
| 49 | if (!text || text.length <= maxLength) return value |
| 50 | return `${text.slice(0, maxLength)}...` |
| 51 | } |
| 52 | |
| 53 | const summarizeChartElement = (request: PptxChartRewriteRequest): Record<string, unknown> => { |
| 54 | const record = request.element as unknown as Record<string, unknown> |
| 55 | return { |
| 56 | chartType: request.element.chartType, |
| 57 | barDir: 'barDir' in request.element ? request.element.barDir : undefined, |
| 58 | colors: request.element.colors || [], |
| 59 | data: compactUnknown('data' in request.element ? request.element.data : null), |
| 60 | position: { |
| 61 | left: record.left, |
| 62 | top: record.top, |
| 63 | width: record.width, |
| 64 | height: record.height |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | export const buildPptxChartRewriteSystemPrompt = (docs: ChartSkillDocs): string => `You are the dedicated PPTX chart parsing agent for Oh My PPT. |
| 70 | |
| 71 | You convert one unsupported PPTX chart element into a safe Chart.js config for the existing importer. |
| 72 | |
| 73 | You MUST follow the bundled product skill below. |
| 74 | |
| 75 | <oh-my-ppt-chart/SKILL.md> |
| 76 | ${docs.skill} |
| 77 | </oh-my-ppt-chart/SKILL.md> |
| 78 | |
| 79 | <oh-my-ppt-chart/references/chart.md> |
| 80 | ${docs.reference} |
| 81 | </oh-my-ppt-chart/references/chart.md> |
| 82 | |
| 83 | Importer-specific override: |
| 84 | - The PPTX importer owns the HTML frame and MUST preserve its original absolute-positioned style. |
| 85 | - Do not rewrite or suggest changing the importer frame style. It must stay exactly like: |
| 86 | style="position:absolute; left:...; top:...; width:...; height:...; ..." |
| 87 | - Do not output HTML. |
| 88 | - Return only a Chart.js config object that can be passed to PPT.createChart(canvasElement, config). |
| 89 | - Keep options responsive: true and maintainAspectRatio: false. |
| 90 | - Use only Chart.js v4-safe chart types: bar, line, pie, doughnut, radar, polarArea, scatter, bubble. |
| 91 | - If the source is too complex, choose the closest readable Chart.js representation instead of refusing.` |
| 92 | |
| 93 | export const buildPptxChartRewriteUserPrompt = (request: PptxChartRewriteRequest): string => { |
| 94 | const chart = summarizeChartElement(request) |
| 95 | return `Rewrite this PPTX chart as a Chart.js config. |
| 96 | |
| 97 | Output strict JSON only: |
| 98 | { |
| 99 | "config": { |
| 100 | "type": "line", |
| 101 | "data": { "labels": [], "datasets": [] }, |
| 102 | "options": { "responsive": true, "maintainAspectRatio": false } |
| 103 | }, |
| 104 | "warnings": [] |
| 105 | } |
| 106 | |
| 107 | Importer frame context that must be preserved by the caller, not rewritten by you: |
| 108 | { |
| 109 | "blockId": ${JSON.stringify(request.blockId)}, |
| 110 | "canvasId": ${JSON.stringify(request.canvasId)}, |
| 111 | "frameStyle": ${JSON.stringify(request.frameStyle)}, |
| 112 | "animationAttrs": ${JSON.stringify(request.animationAttrs)} |
| 113 | } |
| 114 | |
| 115 | PPTX chart element summary: |
| 116 | ${JSON.stringify(chart, null, 2)}` |
| 117 | } |
| 118 | |
| 119 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 120 | Boolean(value && typeof value === 'object' && !Array.isArray(value)) |
| 121 | |
| 122 | export const parsePptxChartRewriteAgentResponse = ( |
| 123 | response: unknown |
| 124 | ): PptxChartRewriteResult | null => { |
| 125 | const text = extractModelText(response) || (typeof response === 'string' ? response : '') |
| 126 | const jsonText = extractJsonBlock(text).trim() |
| 127 | if (!jsonText) return null |
| 128 | |
| 129 | const parsed = JSON.parse(jsonText) as Record<string, unknown> |
| 130 | const config = parsed.config |
| 131 | if (!isRecord(config) || typeof config.type !== 'string') return null |
| 132 | const data = config.data |
| 133 | if (!isRecord(data) || !Array.isArray(data.datasets)) return null |
| 134 | |
| 135 | const options = isRecord(config.options) ? config.options : {} |
| 136 | config.options = { |
| 137 | ...options, |
| 138 | responsive: true, |
| 139 | maintainAspectRatio: false |
| 140 | } |
| 141 | |
| 142 | const warnings = Array.isArray(parsed.warnings) |
| 143 | ? parsed.warnings.map((item) => String(item || '').trim()).filter(Boolean) |
| 144 | : [] |
| 145 | |
| 146 | return { config, warnings } |
| 147 | } |
| 148 | |
| 149 | const rewritePptxChart = async ( |
| 150 | options: PptxChartRewriteAgentOptions, |
| 151 | request: PptxChartRewriteRequest |
| 152 | ): Promise<PptxChartRewriteResult | null> => { |
| 153 | const docs = await readChartSkillDocs() |
| 154 | const model = resolveModel( |
| 155 | options.provider, |
| 156 | options.apiKey, |
| 157 | options.model, |
| 158 | options.baseUrl, |
| 159 | 0.2, |
| 160 | options.maxTokens, |
| 161 | options.modelRuntime |
| 162 | ) |
| 163 | const response = await model.invoke( |
| 164 | [ |
| 165 | { role: 'system', content: buildPptxChartRewriteSystemPrompt(docs) }, |
| 166 | { role: 'user', content: buildPptxChartRewriteUserPrompt(request) } |
| 167 | ], |
| 168 | { signal: AbortSignal.timeout(resolveModelTimeoutMs(options.modelTimeoutMs, 'document')) } |
| 169 | ) |
| 170 | return parsePptxChartRewriteAgentResponse(response) |
| 171 | } |
| 172 | |
| 173 | export const createPptxChartRewriteHandler = ( |
| 174 | options: PptxChartRewriteAgentOptions |
| 175 | ): PptxChartRewriteHandler => { |
| 176 | let rewriteCount = 0 |
| 177 | const maxRewrites = Math.max(0, options.maxRewrites ?? DEFAULT_MAX_REWRITES) |
| 178 | return async (request) => { |
| 179 | if (rewriteCount >= maxRewrites) return null |
| 180 | rewriteCount += 1 |
| 181 | try { |
| 182 | const result = await rewritePptxChart(options, request) |
| 183 | log.info('[pptx:chartRewrite] completed', { |
| 184 | pageNumber: request.pageNumber, |
| 185 | blockId: request.blockId, |
| 186 | chartType: request.element.chartType, |
| 187 | success: Boolean(result) |
| 188 | }) |
| 189 | return result |
| 190 | } catch (error) { |
| 191 | log.warn('[pptx:chartRewrite] failed', { |
| 192 | pageNumber: request.pageNumber, |
| 193 | blockId: request.blockId, |
| 194 | chartType: request.element.chartType, |
| 195 | message: error instanceof Error ? error.message : String(error) |
| 196 | }) |
| 197 | return null |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 |