dsh-ppt-studio 1.1.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -18
- package/agent-presets/ppt/agent.cordis.yml +22 -15
- package/cordis.patch.yml +19 -14
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +1 -1
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +5 -5
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +105 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +34 -3
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +5 -3
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +1 -1
- package/examples/fx/fx-pro/preview/deck.html +1 -1
- package/examples/fx/fx-pro/preview/measured.json +1 -1
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/imported/deck.yaml +6 -6
- package/examples/smoke/imported/import-styles.json +1 -33
- package/examples/smoke/imported/loss-audit.json +99 -0
- package/examples/smoke/imported/pages/slide_03.yaml +5 -36
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -1
- package/examples/smoke/imported/preview-rt/deck.html +1 -1
- package/examples/smoke/imported/preview-rt/layout.json +26 -78
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -1
- package/examples/smoke/preview/deck.html +1 -1
- package/lib/capabilities.js +189 -0
- package/lib/home.js +32 -0
- package/lib/index.js +278 -112
- package/lib/office-qa.js +111 -0
- package/lib/pptd/export-pptx.js +159 -10
- package/lib/pptd/import-pptx.js +12 -0
- package/lib/pptd/loss-audit.js +363 -0
- package/lib/pptd/nativeChart.js +454 -0
- package/lib/pptd/schema.js +48 -1
- package/lib/pptd/svgCharts.js +180 -37
- package/lib/pptxPy.js +96 -11
- package/lib/preset-delivery.js +107 -9
- package/lib/preview-server.js +7 -5
- package/lib/router.js +3 -2
- package/lib/state.js +8 -6
- package/lib/templates.js +4 -4
- package/lib/tools.js +81 -21
- package/package.json +2 -1
- package/scripts/audit-manual-facts.mjs +4 -2
- package/scripts/e2e-1.0.mjs +2 -2
- package/scripts/fixtures/workflow-baseline.json +10 -10
- package/scripts/install.mjs +13 -11
- package/scripts/smoke.mjs +390 -66
- package/scripts/verify-loss-audit.mjs +245 -0
- package/scripts/verify-native-charts.mjs +529 -0
- package/scripts/verify-preset-scope.mjs +593 -0
- package/skills/ppt-studio-data/SKILL.md +5 -7
- package/skills/ppt-studio-manual/SKILL.md +2 -2
package/lib/pptd/svgCharts.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 图表 SVG 生成(HTML 预览用):bar / line / pie。
|
|
3
|
-
*
|
|
3
|
+
* 导出侧:原生可编辑图表(默认,见 nativeChart.js)或矢量拼绘(`render: 'vector'`,见 export-pptx.js 的 chartSp)。
|
|
4
|
+
*
|
|
5
|
+
* 第三轮(2026-09-27)**预览与成品对齐**:原生图表自带坐标轴/网格线/刻度标签/图例/数据标签,
|
|
6
|
+
* SVG 也必须画这些——否则"预览 ≠ 成品",模型与用户会被误导(ppt_shot/ppt_measure 的输入就是这份 SVG)。
|
|
7
|
+
* 三层同源:`chartOptions()`(schema.js 给默认)→ `resolveChart()`(本文件,落地 auto 默认)→
|
|
8
|
+
* 预览 SVG 与导出 chartN.xml 都读同一个 resolveChart 结果。
|
|
4
9
|
*/
|
|
10
|
+
import { chartOptions } from './schema.js'
|
|
5
11
|
|
|
6
12
|
/** 解析 chart 数据为 { categories: [...], values: [number], series: [{name, values}] } */
|
|
7
13
|
export function chartData(chart) {
|
|
@@ -33,6 +39,33 @@ export function chartData(chart) {
|
|
|
33
39
|
return out
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
/**
|
|
43
|
+
* 解析成"预览/导出共用"的图表模型(**唯一真相**):
|
|
44
|
+
* 类别、系列(名 + 数值)、分类列名、配色、已落地的渲染选项(render/legend/labels/axes)。
|
|
45
|
+
*/
|
|
46
|
+
export function resolveChart(chart) {
|
|
47
|
+
const data = chartData(chart)
|
|
48
|
+
const cols = chart?.data?.cols ?? []
|
|
49
|
+
const series = data.series.map((s, i) => ({ name: String(s.name ?? `系列${i + 1}`), values: s.values }))
|
|
50
|
+
const opt = chartOptions(chart)
|
|
51
|
+
const multi = series.length > 1
|
|
52
|
+
const vector = opt.render === 'vector'
|
|
53
|
+
// auto 语义(schema.js chartOptions 注释里有同一份说明):多系列/饼图才有必要画图例;单系列图例是噪音。
|
|
54
|
+
const legend = vector ? false : (opt.legend === 'auto' ? (chart?.type === 'pie' || multi ? 'bottom' : false) : opt.legend)
|
|
55
|
+
// auto 语义:饼图的"百分比 + 分类名"是必要信息;柱/线逐点标数字会糊。
|
|
56
|
+
const labels = vector ? false : (opt.labels === 'auto' ? (chart?.type === 'pie') : opt.labels)
|
|
57
|
+
// 矢量拼绘(chartSp)不画坐标轴/网格线 ⇒ 预览也不画(否则"预览 ≠ 成品")。schema 对 vector+axes 显式值直接报错。
|
|
58
|
+
const axes = vector ? false : opt.axes
|
|
59
|
+
return {
|
|
60
|
+
type: chart?.type,
|
|
61
|
+
categories: data.categories,
|
|
62
|
+
series,
|
|
63
|
+
catHeader: String(cols[0] ?? '类别'),
|
|
64
|
+
colors: chartColors(chart),
|
|
65
|
+
opts: { render: opt.render, legend, labels, axes },
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
36
69
|
/**
|
|
37
70
|
* 图表数据检查(P1 修复,v0.6.1):把"静默失败"变显式。
|
|
38
71
|
* 返回问题描述字符串或 null(正常)。
|
|
@@ -58,54 +91,86 @@ export function chartColors(chart) {
|
|
|
58
91
|
return (chart.colors?.length ? chart.colors : PALETTE).map((c) => c)
|
|
59
92
|
}
|
|
60
93
|
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
const GRID = '#e2e8f0'
|
|
95
|
+
const AXIS = '#cbd5e1'
|
|
96
|
+
const TICK_TEXT = '#64748b'
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 生成 SVG 字符串;viewBox 由调用方传入(px 坐标)。
|
|
100
|
+
* 与成品对齐的内容:坐标轴(轴线+刻度)、网格线、刻度标签、分类标签、图例、数据标签。
|
|
63
101
|
*/
|
|
64
102
|
export function chartSvg(chart, w, h) {
|
|
65
|
-
const
|
|
66
|
-
const colors =
|
|
67
|
-
const
|
|
103
|
+
const m = resolveChart(chart)
|
|
104
|
+
const colors = m.colors
|
|
105
|
+
const colorOf = (i) => colors[i % colors.length]
|
|
106
|
+
const n = m.categories.length
|
|
107
|
+
const vals = m.series.flatMap((s) => s.values)
|
|
108
|
+
const maxV = Math.max(1e-9, ...vals)
|
|
109
|
+
const minV = Math.min(0, ...vals)
|
|
110
|
+
const span = Math.max(1e-9, maxV - minV)
|
|
111
|
+
const showAxes = m.type !== 'pie' && m.opts.axes !== false
|
|
112
|
+
|
|
113
|
+
// ── 图例(与原生图表的 c:legend 同位置)──
|
|
114
|
+
const legendItems = m.type === 'pie'
|
|
115
|
+
? m.categories.map((c, i) => ({ label: c, color: colorOf(i) }))
|
|
116
|
+
: m.series.map((s, i) => ({ label: s.name, color: colorOf(i) }))
|
|
117
|
+
const legend = m.opts.legend ? layoutLegend(legendItems, w, h, m.opts.legend) : null
|
|
118
|
+
|
|
119
|
+
const pad = { t: 12, r: 12, b: showAxes && n ? 28 : 12, l: showAxes ? 36 : 12 }
|
|
120
|
+
if (legend?.pos === 'top') pad.t += legend.h
|
|
121
|
+
if (legend?.pos === 'bottom') pad.b += legend.h
|
|
122
|
+
if (legend?.pos === 'left') pad.l += legend.w
|
|
123
|
+
if (legend?.pos === 'right') pad.r += legend.w
|
|
124
|
+
if (m.opts.labels && m.type !== 'pie') pad.t += 12 // 数据标签要地方
|
|
68
125
|
const iw = Math.max(1, w - pad.l - pad.r)
|
|
69
126
|
const ih = Math.max(1, h - pad.t - pad.b)
|
|
70
|
-
const
|
|
71
|
-
const minV = Math.min(0, ...data.series.flatMap((s) => s.values))
|
|
72
|
-
const span = Math.max(1e-9, maxV - minV)
|
|
127
|
+
const slot = iw / Math.max(1, n)
|
|
73
128
|
const y0 = pad.t + (maxV / span) * ih
|
|
74
129
|
const y = (v) => pad.t + ((maxV - v) / span) * ih
|
|
75
130
|
const parts = []
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
131
|
+
|
|
132
|
+
// 网格线 + 值轴刻度标签 + 刻度线
|
|
133
|
+
if (showAxes) {
|
|
134
|
+
for (let i = 0; i <= 4; i++) {
|
|
135
|
+
const gy = pad.t + (ih * i) / 4
|
|
136
|
+
parts.push(`<line x1="${pad.l}" y1="${gy.toFixed(1)}" x2="${(w - pad.r).toFixed(1)}" y2="${gy.toFixed(1)}" stroke="${GRID}" stroke-width="1"/>`)
|
|
137
|
+
parts.push(`<line x1="${pad.l - 3}" y1="${gy.toFixed(1)}" x2="${pad.l}" y2="${gy.toFixed(1)}" stroke="${AXIS}" stroke-width="1"/>`)
|
|
138
|
+
const gv = maxV - (span * i) / 4
|
|
139
|
+
parts.push(`<text x="${pad.l - 5}" y="${(gy + 3).toFixed(1)}" font-size="9" fill="${TICK_TEXT}" text-anchor="end">${fmtNum(gv)}</text>`)
|
|
140
|
+
}
|
|
141
|
+
// 坐标轴轴线(原生图表有,预览以前没有 ⇒ 补齐)
|
|
142
|
+
parts.push(`<line x1="${pad.l}" y1="${pad.t}" x2="${pad.l}" y2="${(pad.t + ih).toFixed(1)}" stroke="${AXIS}" stroke-width="1"/>`)
|
|
143
|
+
parts.push(`<line x1="${pad.l}" y1="${(pad.t + ih).toFixed(1)}" x2="${(w - pad.r).toFixed(1)}" y2="${(pad.t + ih).toFixed(1)}" stroke="${AXIS}" stroke-width="1"/>`)
|
|
82
144
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const sGroup = grouped ? 0.72 : 1
|
|
88
|
-
data.series.forEach((s, si) => {
|
|
145
|
+
|
|
146
|
+
if (m.type === 'bar') {
|
|
147
|
+
const grouped = m.series.length > 1
|
|
148
|
+
m.series.forEach((s, si) => {
|
|
89
149
|
s.values.forEach((v, i) => {
|
|
90
150
|
const cx = pad.l + slot * i + slot / 2
|
|
91
|
-
const bw = groupWidth(slot,
|
|
92
|
-
const off = grouped ? (si - (
|
|
151
|
+
const bw = groupWidth(slot, m.series.length)
|
|
152
|
+
const off = grouped ? (si - (m.series.length - 1) / 2) * (bw + 1.5) : 0
|
|
93
153
|
const bh = Math.max(1, Math.abs(y(v) - y0))
|
|
94
154
|
const by = v >= 0 ? y(v) : y0
|
|
95
|
-
parts.push(`<rect x="${cx + off - bw / 2}" y="${by}" width="${bw.toFixed(1)}" height="${bh.toFixed(1)}" fill="${
|
|
155
|
+
parts.push(`<rect x="${(cx + off - bw / 2).toFixed(1)}" y="${by.toFixed(1)}" width="${bw.toFixed(1)}" height="${bh.toFixed(1)}" fill="${colorOf(si)}"><title>${esc(s.name)} · ${esc(m.categories[i])}: ${v}</title></rect>`)
|
|
156
|
+
if (m.opts.labels) parts.push(labelText(cx + off, by - 4, fmtNum(v)))
|
|
96
157
|
})
|
|
97
158
|
})
|
|
98
|
-
} else if (
|
|
99
|
-
|
|
159
|
+
} else if (m.type === 'line') {
|
|
160
|
+
m.series.forEach((s, si) => {
|
|
100
161
|
const pts = s.values.map((v, i) => `${(pad.l + slot * i + slot / 2).toFixed(1)},${y(v).toFixed(1)}`)
|
|
101
|
-
parts.push(`<polyline points="${pts.join(' ')}" fill="none" stroke="${
|
|
102
|
-
pts.forEach((p, i) =>
|
|
162
|
+
parts.push(`<polyline points="${pts.join(' ')}" fill="none" stroke="${colorOf(si)}" stroke-width="2"/>`)
|
|
163
|
+
pts.forEach((p, i) => {
|
|
164
|
+
const [px, py] = p.split(',')
|
|
165
|
+
parts.push(`<circle cx="${px}" cy="${py}" r="2.5" fill="${colorOf(si)}"><title>${esc(s.name)} · ${esc(m.categories[i])}: ${s.values[i]}</title></circle>`)
|
|
166
|
+
if (m.opts.labels) parts.push(labelText(Number(px), Number(py) - 6, fmtNum(s.values[i])))
|
|
167
|
+
})
|
|
103
168
|
})
|
|
104
|
-
} else if (
|
|
105
|
-
const v =
|
|
169
|
+
} else if (m.type === 'pie') {
|
|
170
|
+
const v = m.series[0]?.values ?? []
|
|
106
171
|
const total = v.reduce((a, b) => a + Math.max(0, b), 0) || 1
|
|
107
|
-
const r = Math.min(iw, ih) / 2 - 2
|
|
108
|
-
const cx =
|
|
172
|
+
const r = Math.min(iw, ih) / 2 - (m.opts.labels ? 6 : 2)
|
|
173
|
+
const cx = pad.l + iw / 2
|
|
109
174
|
const cy = pad.t + ih / 2
|
|
110
175
|
let angle = -Math.PI / 2
|
|
111
176
|
v.forEach((val, i) => {
|
|
@@ -117,22 +182,100 @@ export function chartSvg(chart, w, h) {
|
|
|
117
182
|
const x2 = cx + r * Math.cos(a2)
|
|
118
183
|
const y2 = cy + r * Math.sin(a2)
|
|
119
184
|
if (frac > 0.0005) {
|
|
120
|
-
parts.push(`<path d="M${cx},${cy} L${x1.toFixed(1)},${y1.toFixed(1)} A${r},${r} 0 ${large} 1 ${x2.toFixed(1)},${y2.toFixed(1)} Z" fill="${
|
|
185
|
+
parts.push(`<path d="M${cx},${cy} L${x1.toFixed(1)},${y1.toFixed(1)} A${r},${r} 0 ${large} 1 ${x2.toFixed(1)},${y2.toFixed(1)} Z" fill="${colorOf(i)}"><title>${esc(m.categories[i])}: ${val}</title></path>`)
|
|
186
|
+
}
|
|
187
|
+
if (m.opts.labels && frac > 0.0005) {
|
|
188
|
+
const mid = angle + frac * Math.PI
|
|
189
|
+
const lx = cx + (r + 10) * Math.cos(mid)
|
|
190
|
+
const ly = cy + (r + 10) * Math.sin(mid)
|
|
191
|
+
parts.push(labelText(lx, ly + 3, `${m.categories[i]} ${Math.round(frac * 100)}%`, mid > Math.PI / 2 || mid < -Math.PI / 2 ? 'end' : 'start'))
|
|
121
192
|
}
|
|
122
193
|
angle = a2
|
|
123
194
|
})
|
|
124
195
|
}
|
|
125
|
-
|
|
126
|
-
|
|
196
|
+
|
|
197
|
+
m.categories.forEach((c, i) => {
|
|
198
|
+
if (m.type === 'pie') return
|
|
199
|
+
if (!showAxes) return
|
|
127
200
|
const cx = pad.l + slot * i + slot / 2
|
|
128
201
|
const label = c.length > 8 ? c.slice(0, 8) + '…' : c
|
|
129
|
-
parts.push(`<text x="${cx.toFixed(1)}" y="${
|
|
202
|
+
parts.push(`<text x="${cx.toFixed(1)}" y="${(pad.t + ih + 12).toFixed(1)}" font-size="9" fill="#475569" text-anchor="middle">${esc(label)}</text>`)
|
|
203
|
+
parts.push(`<line x1="${cx.toFixed(1)}" y1="${(pad.t + ih).toFixed(1)}" x2="${cx.toFixed(1)}" y2="${(pad.t + ih + 3).toFixed(1)}" stroke="${AXIS}" stroke-width="1"/>`)
|
|
130
204
|
})
|
|
205
|
+
|
|
206
|
+
if (legend) parts.push(...renderLegend(legend, w, h, pad, legendItems))
|
|
207
|
+
|
|
131
208
|
return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg" style="display:block">${parts.join('')}</svg>`
|
|
132
209
|
}
|
|
133
210
|
|
|
211
|
+
function labelText(x, y, text, anchor = 'middle') {
|
|
212
|
+
return `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" font-size="9" fill="#334155" text-anchor="${anchor}">${esc(text)}</text>`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 估算文本宽度(CJK 按 1em,其余 0.55em)——只用于图例排布,不需要精确。 */
|
|
216
|
+
function textW(s, size) {
|
|
217
|
+
let w = 0
|
|
218
|
+
for (const ch of String(s)) w += /[\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/.test(ch) ? size : size * 0.55
|
|
219
|
+
return w
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 图例占位计算:bottom/top 横排(超宽自动换行),left/right 竖排。 */
|
|
223
|
+
function layoutLegend(items, w, h, pos) {
|
|
224
|
+
const SIZE = 9
|
|
225
|
+
const swatch = 8
|
|
226
|
+
const gap = 6
|
|
227
|
+
const rowH = 14
|
|
228
|
+
if (pos === 'left' || pos === 'right') {
|
|
229
|
+
const maxItem = Math.max(20, ...items.map((it) => textW(it.label, SIZE))) + swatch + gap + 6
|
|
230
|
+
return { pos, w: Math.min(Math.round(w * 0.32), Math.ceil(maxItem)), h: rowH, rows: 1 }
|
|
231
|
+
}
|
|
232
|
+
const avail = Math.max(20, w - 16)
|
|
233
|
+
const items2 = items.map((it) => textW(it.label, SIZE) + swatch + gap + 8)
|
|
234
|
+
let rows = 1
|
|
235
|
+
let acc = 0
|
|
236
|
+
for (const iw2 of items2) {
|
|
237
|
+
if (acc > 0 && acc + iw2 > avail) { rows++; acc = iw2 } else acc += iw2
|
|
238
|
+
}
|
|
239
|
+
return { pos, w: 0, h: rows * rowH + 2, rows }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderLegend(legend, w, h, pad, items) {
|
|
243
|
+
const out = []
|
|
244
|
+
const SIZE = 9
|
|
245
|
+
const swatch = 8
|
|
246
|
+
const gap = 6
|
|
247
|
+
const rowH = 14
|
|
248
|
+
const widths = items.map((it) => textW(it.label, SIZE) + swatch + gap + 8)
|
|
249
|
+
if (legend.pos === 'left' || legend.pos === 'right') {
|
|
250
|
+
const totalH = items.length * rowH
|
|
251
|
+
let y = Math.max(pad.t, (h - totalH) / 2)
|
|
252
|
+
const x = legend.pos === 'left' ? 6 : w - legend.w + 6
|
|
253
|
+
for (let i = 0; i < items.length; i++) {
|
|
254
|
+
out.push(`<rect x="${x}" y="${(y + 3).toFixed(1)}" width="${swatch}" height="${swatch}" fill="${items[i].color}" rx="1"/>`)
|
|
255
|
+
out.push(`<text x="${x + swatch + gap}" y="${(y + 10).toFixed(1)}" font-size="${SIZE}" fill="${TICK_TEXT}">${esc(items[i].label)}</text>`)
|
|
256
|
+
y += rowH
|
|
257
|
+
}
|
|
258
|
+
return out
|
|
259
|
+
}
|
|
260
|
+
const top = legend.pos === 'top'
|
|
261
|
+
const avail = Math.max(20, w - 16)
|
|
262
|
+
let row = 0
|
|
263
|
+
let acc = 0
|
|
264
|
+
const baseY = top ? 4 : h - legend.h + 2
|
|
265
|
+
for (let i = 0; i < items.length; i++) {
|
|
266
|
+
if (acc > 0 && acc + widths[i] > avail) { row++; acc = 0 }
|
|
267
|
+
const x = 8 + acc
|
|
268
|
+
const y = baseY + row * rowH
|
|
269
|
+
out.push(`<rect x="${x}" y="${(y + 3).toFixed(1)}" width="${swatch}" height="${swatch}" fill="${items[i].color}" rx="1"/>`)
|
|
270
|
+
out.push(`<text x="${x + swatch + gap}" y="${(y + 10).toFixed(1)}" font-size="${SIZE}" fill="${TICK_TEXT}">${esc(items[i].label)}</text>`)
|
|
271
|
+
acc += widths[i]
|
|
272
|
+
}
|
|
273
|
+
return out
|
|
274
|
+
}
|
|
275
|
+
|
|
134
276
|
function groupWidth(slot, count) {
|
|
135
|
-
|
|
277
|
+
// 与原生图表 gapWidth=100(柱宽 = 1/2 槽)/ gapWidth=100+聚类(≈0.7/n 槽)对齐
|
|
278
|
+
const bw = count > 1 ? slot * 0.7 : slot * 0.5
|
|
136
279
|
return Math.max(2, bw / Math.max(1, count))
|
|
137
280
|
}
|
|
138
281
|
|
package/lib/pptxPy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* python-pptx 引擎:把中间层(deck.yaml)映射为 python-pptx 脚本(兜底引擎)。
|
|
3
|
-
* 覆盖 text / shape / image / table
|
|
3
|
+
* 覆盖 text / shape / image / table / **chart(原生可编辑图表 + 内嵌数据工作簿)**。
|
|
4
4
|
* 需要 python 环境 + python-pptx(运行前探测,缺失则报错提示)。
|
|
5
5
|
*/
|
|
6
6
|
import { readFile } from 'node:fs/promises'
|
|
@@ -8,6 +8,8 @@ import { join, isAbsolute } from 'node:path'
|
|
|
8
8
|
import { spawnSync } from 'node:child_process'
|
|
9
9
|
import YAML from 'yaml'
|
|
10
10
|
import { normalizePage, measureText } from './pptd/layout.js'
|
|
11
|
+
import { chartData, chartColors } from './pptd/svgCharts.js'
|
|
12
|
+
import { findBundledPython } from './capabilities.js'
|
|
11
13
|
|
|
12
14
|
export function genPythonScript(ctx) {
|
|
13
15
|
const py = []
|
|
@@ -16,6 +18,10 @@ export function genPythonScript(ctx) {
|
|
|
16
18
|
py.push('from pptx.dml.color import RGBColor')
|
|
17
19
|
py.push('from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR')
|
|
18
20
|
py.push('from pptx.enum.text import PP_ALIGN, MSO_ANCHOR')
|
|
21
|
+
// 原生图表(2026-09-26 第二轮 ③):python-pptx 会自己写 `ppt/charts/chartN.xml` + **内嵌数据工作簿**
|
|
22
|
+
// `ppt/embeddings/*.xlsx`,于是成品里的图表可"编辑数据/换类型"、数据随文件走。
|
|
23
|
+
py.push('from pptx.chart.data import CategoryChartData')
|
|
24
|
+
py.push('from pptx.enum.chart import XL_CHART_TYPE')
|
|
19
25
|
py.push('import json, sys')
|
|
20
26
|
py.push('')
|
|
21
27
|
// OUT 由 runPythonExport 替换成字面量路径(见文件末尾的 replace)。
|
|
@@ -126,7 +132,54 @@ export function genPythonScript(ctx) {
|
|
|
126
132
|
break
|
|
127
133
|
}
|
|
128
134
|
case 'chart': {
|
|
129
|
-
|
|
135
|
+
// 原生可编辑图表(2026-09-26 第二轮 ③)——**不再降级为表格**。
|
|
136
|
+
// 旧行为是"图表以表格表达",用户拿到成品看不见图、也改不了数据;现在交给 python-pptx
|
|
137
|
+
// 生成原生图表:`ppt/charts/chartN.xml` + 内嵌工作簿一起落盘 ⇒ PowerPoint 里可"编辑数据"、
|
|
138
|
+
// 可切换图表类型、坐标轴/图例/数据标签都由图表自己算。
|
|
139
|
+
const cdata = chartData(el.chart)
|
|
140
|
+
const ccolors = chartColors(el.chart)
|
|
141
|
+
const kindOf = { bar: 'COLUMN_CLUSTERED', line: 'LINE_MARKERS', pie: 'PIE' }
|
|
142
|
+
const kind = kindOf[el.chart?.type] ?? 'COLUMN_CLUSTERED'
|
|
143
|
+
const series = cdata.series.length ? cdata.series : [{ name: '系列1', values: [] }]
|
|
144
|
+
py.push(`chart_data = CategoryChartData()`)
|
|
145
|
+
py.push(`chart_data.categories = ${pyLit(cdata.categories)}`)
|
|
146
|
+
for (const s of series) {
|
|
147
|
+
const vals = s.values.map((v) => (Number.isFinite(v) ? String(v) : '0'))
|
|
148
|
+
// 单元素元组必须带尾逗号,否则 `(12)` 是整数而不是序列
|
|
149
|
+
py.push(`chart_data.add_series(${pyLit(s.name ?? '系列')}, (${vals.join(', ')}${vals.length === 1 ? ',' : ''}))`)
|
|
150
|
+
}
|
|
151
|
+
py.push(`gf = slide.shapes.add_chart(XL_CHART_TYPE.${kind}, Emu(${x}*12700), Emu(${y}*12700), Emu(${w}*12700), Emu(${h}*12700), chart_data)`)
|
|
152
|
+
py.push(`chart = gf.chart`)
|
|
153
|
+
py.push(`chart.has_legend = ${series.length > 1 ? 'True' : 'False'}`)
|
|
154
|
+
if (series.length > 1) py.push(`chart.legend.include_in_layout = False`)
|
|
155
|
+
py.push(`plot = chart.plots[0]`)
|
|
156
|
+
if (el.chart?.type === 'pie') {
|
|
157
|
+
py.push(`plot.has_data_labels = True`)
|
|
158
|
+
py.push(`plot.data_labels.show_percentage = True`)
|
|
159
|
+
py.push(`plot.data_labels.show_value = False`)
|
|
160
|
+
} else {
|
|
161
|
+
// `vary_by_categories=False` 是必须的:python-pptx 默认模板会按分类上色,
|
|
162
|
+
// 于是单系列图例列成分类名、每根柱子不同色(2026-09-26 实测),与我们的观感预期不符。
|
|
163
|
+
py.push(`plot.vary_by_categories = False`)
|
|
164
|
+
py.push(`plot.has_data_labels = True`)
|
|
165
|
+
py.push(`plot.data_labels.number_format = '0'`)
|
|
166
|
+
py.push(`plot.data_labels.number_format_is_linked = False`)
|
|
167
|
+
}
|
|
168
|
+
// 上色是**观感项**:任何 API 差异都不该让整册导出失败 ⇒ 整块 try/except 包住。
|
|
169
|
+
py.push(`try:`)
|
|
170
|
+
if (el.chart?.type === 'pie') {
|
|
171
|
+
py.push(` _pie = ${pyLit(ccolors)}`)
|
|
172
|
+
py.push(` for i, pt in enumerate(plot.points):`)
|
|
173
|
+
py.push(` pt.format.fill.solid(); pt.format.fill.fore_color.rgb = rgb(_pie[i % len(_pie)])`)
|
|
174
|
+
} else {
|
|
175
|
+
py.push(` _cols = ${pyLit(ccolors)}`)
|
|
176
|
+
py.push(` for i, s in enumerate(chart.series):`)
|
|
177
|
+
py.push(` _c = rgb(_cols[i % len(_cols)])`)
|
|
178
|
+
py.push(` s.format.fill.solid(); s.format.fill.fore_color.rgb = _c`)
|
|
179
|
+
if (el.chart?.type === 'line') py.push(` s.format.line.color.rgb = _c`)
|
|
180
|
+
}
|
|
181
|
+
py.push(`except Exception:`)
|
|
182
|
+
py.push(` pass`)
|
|
130
183
|
break
|
|
131
184
|
}
|
|
132
185
|
}
|
|
@@ -167,21 +220,51 @@ function isApproxFill(fill) {
|
|
|
167
220
|
&& (fill.type === 'gradient' || fill.alpha != null || Array.isArray(fill.stops)))
|
|
168
221
|
}
|
|
169
222
|
|
|
223
|
+
/**
|
|
224
|
+
* 找可用的 python-pptx 解释器。**顺序很重要**(2026-09-26,第二轮兜底增强):
|
|
225
|
+
* ① **捆绑 Python**(桌面端:`<dshHome>/dsh-runtimes/dsh-primary-runtime/dependencies/python/python.exe`
|
|
226
|
+
* 或 carrier `resources/runtime/primary-runtime/...`)——它自带 python-pptx 1.0.2 / lxml / Pillow /
|
|
227
|
+
* XlsxWriter / openpyxl / pandas,正是官方三个 office 技能假定的那一套;这条**不需要用户装任何东西**。
|
|
228
|
+
* ② PATH 上的 `python` / `py` / `python3`(老路子)。
|
|
229
|
+
* 语义(向后兼容):能 `import pptx` 的立刻返回 `has:true` 并带 `source`;只找到"能跑但缺库"的,
|
|
230
|
+
* 记下**第一个**作为 `has:false` 结果(继续往后找,别被一个残缺解释器挡住),供错误信息说明原因。
|
|
231
|
+
*/
|
|
170
232
|
export function findPython() {
|
|
171
|
-
|
|
233
|
+
let partial = null
|
|
234
|
+
const probe = (cmd, source) => {
|
|
172
235
|
try {
|
|
173
|
-
const r = spawnSync(cmd, ['-c', 'import pptx; print("ok")'], { encoding: 'utf8', timeout:
|
|
174
|
-
if (r.status === 0 && r.stdout.includes('ok')) return { cmd, has: true }
|
|
175
|
-
|
|
176
|
-
|
|
236
|
+
const r = spawnSync(cmd, ['-c', 'import pptx; print("ok")'], { encoding: 'utf8', timeout: 20000 })
|
|
237
|
+
if (r.status === 0 && String(r.stdout).includes('ok')) return { cmd, has: true, source }
|
|
238
|
+
// 能起来但没装 pptx(stderr 非空且不是 spawn 失败)⇒ 记为"残缺但可用",继续找下一个
|
|
239
|
+
if (r.status === 0 || (r.error === undefined && r.stderr === '')) return { cmd, has: false, source, partial: true }
|
|
240
|
+
return null
|
|
241
|
+
} catch { return null }
|
|
242
|
+
}
|
|
243
|
+
const bundled = findBundledPython()
|
|
244
|
+
if (bundled !== null) {
|
|
245
|
+
const r = probe(bundled.path, `bundled:${bundled.source}`)
|
|
246
|
+
if (r?.has) return { cmd: r.cmd, has: true, source: r.source }
|
|
247
|
+
if (r?.partial && partial === null) partial = r
|
|
177
248
|
}
|
|
178
|
-
|
|
249
|
+
for (const cmd of ['python', 'py', 'python3']) {
|
|
250
|
+
const r = probe(cmd, 'path')
|
|
251
|
+
if (r?.has) return { cmd: r.cmd, has: true, source: r.source }
|
|
252
|
+
if (r?.partial && partial === null) partial = r
|
|
253
|
+
}
|
|
254
|
+
return partial ?? { cmd: null, has: false, source: null }
|
|
179
255
|
}
|
|
180
256
|
|
|
181
257
|
export function runPythonScript(ctx, script) {
|
|
182
258
|
const py = findPython()
|
|
183
|
-
if (!py.has)
|
|
184
|
-
|
|
259
|
+
if (!py.has) {
|
|
260
|
+
const bundled = findBundledPython()
|
|
261
|
+
throw new Error('python-pptx 引擎不可用:未检测到带 python-pptx 的解释器'
|
|
262
|
+
+ (bundled === null
|
|
263
|
+
? '(未探测到捆绑 Python,PATH 上也没有可用的 python;可 pip install python-pptx)'
|
|
264
|
+
: `(探测到捆绑 Python 但缺库:${bundled.path})`)
|
|
265
|
+
+ '——可改用默认 pptd 引擎')
|
|
266
|
+
}
|
|
267
|
+
return { stdout: startPython(py.cmd, script), cmd: py.cmd, source: py.source }
|
|
185
268
|
}
|
|
186
269
|
|
|
187
270
|
function startPython(cmd, script) {
|
|
@@ -201,5 +284,7 @@ export async function runPythonExport(ctx, out) {
|
|
|
201
284
|
// `python -c` 下没有 argv[1] ⇒ **文件已写出却报执行失败**)
|
|
202
285
|
const script = genPythonScript(ctx).replace(/^OUT = .*$/m, `OUT = ${JSON.stringify(outPath)}`)
|
|
203
286
|
const result = await runPythonScript(ctx, script)
|
|
204
|
-
|
|
287
|
+
// 解释器来源一并带出(2026-09-26):兜底是"最后保险",报告里要能看出**用的是哪一个解释器**,
|
|
288
|
+
// 否则"捆绑 Python 到底有没有被用上"只能靠猜。
|
|
289
|
+
return { file: outPath, engine: 'python-pptx', note: result.stdout.trim(), interpreter: result.cmd, interpreterSource: result.source }
|
|
205
290
|
}
|
package/lib/preset-delivery.js
CHANGED
|
@@ -37,8 +37,22 @@
|
|
|
37
37
|
* · 注册表缺失(极简部署 / 低于 0.1.7 的宿主)只告警,**绝不抛**——预设没了也不能让插件挂不上;
|
|
38
38
|
* · 组合里出现解析不了的相对名时**拒绝声明**(宁可没有预设,也不声明一条注定 broken 的声明:
|
|
39
39
|
* broken 的预设会被 `presetOptions()` 过滤掉,等于白声明还多一条误导日志)。
|
|
40
|
+
*
|
|
41
|
+
* ── 2026-09-26 新增:**自定位行**("只在「PPT 工作室」里可见"的载体)──────────────────
|
|
42
|
+
* 用户要求"装上插件后**其他预设完全不受影响**"。做法是把本包**挂进自己的预设组合**:
|
|
43
|
+
* 声明预设时在 `plugins` 末尾追加一行
|
|
44
|
+
* { id: 'ppt-studio-scope', name: 'file:///<本包绝对路径>/lib/index.js', config: { scope: 'preset' } }
|
|
45
|
+
* —— 预设的常驻组合会真的 import 并 apply 这行(`dsh-agent-preset-registry` 声明即挂载),
|
|
46
|
+
* 而注册进"预设作用域 ctx"的工具/技能/提示段**只对该预设的会话可见**(分层注册表按 scope 链合并)。
|
|
47
|
+
* 三条必须守住的细节:
|
|
48
|
+
* ① **行名必须是绝对 `file://`**:预设行按**注册表所在 ctx 的 baseUrl** 解析(历史事故根因),
|
|
49
|
+
* 裸包名/相对名在 profile 里解析不到;而 Loader 对**非 `.` 开头**的名字**原样 `import(name)`**
|
|
50
|
+
* ⇒ `file://` 不看 baseUrl。这是 2026-09-26 真宿主实测通过的路径。
|
|
51
|
+
* ② **路径现算,不写死**:用 `import.meta.url` 推出本包绝对路径 ⇒ 升级换代、换 profile 都不会陈旧。
|
|
52
|
+
* ③ **预设档实例必须 `autoPreset: false`**:否则它会再声明一次预设 ⇒ 注册表对重复 id 直接 throw。
|
|
53
|
+
* 本行只在"想要隔离"时追加;不健康时由 index.js 自动回落到全局能力面(隔离失效但功能不丢)。
|
|
40
54
|
*/
|
|
41
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
55
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
42
56
|
import { dirname, join, resolve } from 'node:path'
|
|
43
57
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
44
58
|
import { parse } from 'yaml'
|
|
@@ -60,11 +74,59 @@ export function builtinPresetDir() {
|
|
|
60
74
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'agent-presets', PRESET_ID)
|
|
61
75
|
}
|
|
62
76
|
|
|
77
|
+
/** 包根(`lib/` 的上一级)。 */
|
|
78
|
+
export function packageRoot() {
|
|
79
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** **自定位行的 id**(预设档实例的身份标识,便于从日志/状态里认出来)。 */
|
|
83
|
+
export const SCOPE_ROW_ID = 'ppt-studio-scope'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 本包的加载入口(用于自定位行)。优先 `lib/index.js`(`package.json` 的 main),
|
|
87
|
+
* 其次读 `package.json.main`;都拿不到返回 null(调用方据此**不追加**该行——宁可不隔离,也不加一行注定 broken 的)。
|
|
88
|
+
* 路径经 `realpathSync` 归一:pnpm 顶层软链与 `.pnpm/...` 真实路径在 Node 里解析成同一模块实例。
|
|
89
|
+
*/
|
|
90
|
+
export function pluginEntryFile() {
|
|
91
|
+
const pkgRoot = packageRoot()
|
|
92
|
+
const candidate = join(pkgRoot, 'lib', 'index.js')
|
|
93
|
+
const fallbackMain = (() => {
|
|
94
|
+
try {
|
|
95
|
+
const main = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).main
|
|
96
|
+
return typeof main === 'string' && main ? resolve(pkgRoot, main) : null
|
|
97
|
+
} catch { return null }
|
|
98
|
+
})()
|
|
99
|
+
const entry = existsSync(candidate) ? candidate : (fallbackMain && existsSync(fallbackMain) ? fallbackMain : null)
|
|
100
|
+
if (entry === null) return null
|
|
101
|
+
try { return realpathSync(entry) } catch { return entry }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 自定位预设行:让本包被**自己的预设**挂载一次,从而把能力面注册进**预设作用域**。
|
|
106
|
+
* `config.scope: 'preset'` = 预设档实例;`autoPreset: false` = 它不再声明预设(重复 id 会 throw)。
|
|
107
|
+
* @param {{presetIds?: string[]}} [extra] 额外透传的配置
|
|
108
|
+
* @returns {object|null} 入口文件缺失时返回 null
|
|
109
|
+
*/
|
|
110
|
+
export function selfPresetRow(extra = {}) {
|
|
111
|
+
const entry = pluginEntryFile()
|
|
112
|
+
if (entry === null) return null
|
|
113
|
+
return {
|
|
114
|
+
id: SCOPE_ROW_ID,
|
|
115
|
+
name: pathToFileURL(entry).href,
|
|
116
|
+
config: { scope: 'preset', autoPreset: false, presetIds: extra?.presetIds ?? [PRESET_ID] },
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
63
120
|
/** 最近一次声明的结果(供 ppt_state 与测试观测;进程内单例)。 */
|
|
64
|
-
const STATUS = { id: PRESET_ID, declared: false, via: '', reason: '尚未装配', rows: 0 }
|
|
121
|
+
const STATUS = { id: PRESET_ID, declared: false, via: '', reason: '尚未装配', rows: 0, selfRow: false, health: 'unknown', isolation: 'unknown' }
|
|
65
122
|
export function presetDeliveryStatus() {
|
|
66
123
|
return { ...STATUS }
|
|
67
124
|
}
|
|
125
|
+
/** 覆盖/合并交付状态的附加字段(index.js 用它写入"隔离是否生效 / 是否已回落")。 */
|
|
126
|
+
export function notePresetStatus(patch = {}) {
|
|
127
|
+
for (const [k, v] of Object.entries(patch)) STATUS[k] = v
|
|
128
|
+
return presetDeliveryStatus()
|
|
129
|
+
}
|
|
68
130
|
|
|
69
131
|
function loggerOf(ctx) {
|
|
70
132
|
try { return typeof ctx.logger === 'function' ? ctx.logger('ppt-studio') : ctx.logger } catch { return undefined }
|
|
@@ -97,9 +159,11 @@ function absolutizeRows(rows, baseDir) {
|
|
|
97
159
|
/**
|
|
98
160
|
* 组装交给 `agentPresets.register()` 的声明。
|
|
99
161
|
* @param {string} [dir] 预设目录(缺省 = 包内自带那份)
|
|
100
|
-
* @
|
|
162
|
+
* @param {{selfRow?: boolean, presetIds?: string[]}} [opts] `selfRow: true` 时在末尾追加**自定位行**
|
|
163
|
+
* (= 让本包在自己的预设里以"预设档"再挂一次,能力面因此只对该预设可见)
|
|
164
|
+
* @returns {{id: string, name: string, description: string, order: number, plugins: object[], selfRow: boolean}}
|
|
101
165
|
*/
|
|
102
|
-
export function presetDeclaration(dir = builtinPresetDir()) {
|
|
166
|
+
export function presetDeclaration(dir = builtinPresetDir(), opts = {}) {
|
|
103
167
|
const composition = join(dir, 'agent.cordis.yml')
|
|
104
168
|
if (!existsSync(composition)) throw new Error(`包内预设组合缺失:${composition}`)
|
|
105
169
|
let meta = {}
|
|
@@ -108,12 +172,22 @@ export function presetDeclaration(dir = builtinPresetDir()) {
|
|
|
108
172
|
try { meta = parse(readFileSync(metaFile, 'utf8')) ?? {} } catch { meta = {} }
|
|
109
173
|
}
|
|
110
174
|
const plugins = absolutizeRows(readEntryList(composition), dir)
|
|
175
|
+
let selfRow = false
|
|
176
|
+
if (opts?.selfRow === true) {
|
|
177
|
+
const row = selfPresetRow({ presetIds: opts?.presetIds })
|
|
178
|
+
if (row !== null) {
|
|
179
|
+
// 幂等:组合里若已存在同名行(用户手工加过),不重复追加
|
|
180
|
+
if (!plugins.some((r) => r?.id === SCOPE_ROW_ID)) { plugins.push(row); selfRow = true }
|
|
181
|
+
else selfRow = true
|
|
182
|
+
}
|
|
183
|
+
}
|
|
111
184
|
return {
|
|
112
185
|
id: PRESET_ID,
|
|
113
186
|
name: typeof meta.name === 'string' ? meta.name : 'PPT 工作室',
|
|
114
187
|
description: typeof meta.description === 'string' ? meta.description : '',
|
|
115
188
|
order: typeof meta.order === 'number' ? meta.order : 2,
|
|
116
189
|
plugins,
|
|
190
|
+
selfRow,
|
|
117
191
|
}
|
|
118
192
|
}
|
|
119
193
|
|
|
@@ -123,55 +197,79 @@ function readRegistry(ctx) {
|
|
|
123
197
|
|
|
124
198
|
/**
|
|
125
199
|
* 幂等地把「PPT 工作室」预设声明给 agent-preset 注册表。
|
|
126
|
-
* @
|
|
200
|
+
* @param {object} ctx 插件作用域 ctx(profile 档)
|
|
201
|
+
* @param {object} [config] 插件配置(`autoPreset:false` 关闭;`scope:'preset'` 时追加自定位行)
|
|
202
|
+
* @param {{selfRow?: boolean, onSettled?: (health: {ok: boolean, reason: string, rows?: number, selfRow?: boolean, presetId: string}) => void}} [hooks]
|
|
203
|
+
* `onSettled` 在声明落地(成功/失败/注册表不可见)后**恰好调用一次**,供调用方决定是否需要回落。
|
|
204
|
+
* @returns {{id: string, declared: boolean, via: string, reason: string, rows: number, selfRow: boolean, health: string}} 当前(可能尚未完成的)状态
|
|
127
205
|
*/
|
|
128
|
-
export function declareAgentPreset(ctx, config = {}) {
|
|
206
|
+
export function declareAgentPreset(ctx, config = {}, hooks = {}) {
|
|
129
207
|
if (config?.autoPreset === false) {
|
|
130
208
|
STATUS.declared = false
|
|
131
209
|
STATUS.via = ''
|
|
210
|
+
STATUS.health = 'off'
|
|
132
211
|
STATUS.reason = 'autoPreset=false(配置关闭)'
|
|
133
212
|
return presetDeliveryStatus()
|
|
134
213
|
}
|
|
135
214
|
|
|
215
|
+
const wantIsolation = hooks?.selfRow === true || config?.scope === 'preset'
|
|
136
216
|
let declaration
|
|
137
217
|
try {
|
|
138
|
-
declaration = presetDeclaration()
|
|
218
|
+
declaration = presetDeclaration(builtinPresetDir(), { selfRow: wantIsolation, presetIds: config?.presetIds })
|
|
139
219
|
} catch (e) {
|
|
140
220
|
STATUS.declared = false
|
|
141
221
|
STATUS.via = ''
|
|
222
|
+
STATUS.health = 'unavailable'
|
|
142
223
|
STATUS.reason = `预设组合不可用(未声明):${String(e?.message ?? e)}`
|
|
143
224
|
loggerOf(ctx)?.warn?.(`[ppt-studio] ${STATUS.reason}`)
|
|
144
225
|
return presetDeliveryStatus()
|
|
145
226
|
}
|
|
146
227
|
STATUS.rows = declaration.plugins.length
|
|
228
|
+
STATUS.selfRow = declaration.selfRow === true
|
|
229
|
+
STATUS.health = 'pending'
|
|
147
230
|
STATUS.reason = '等待 agentPresets 注册表…'
|
|
148
231
|
|
|
149
232
|
const warn = (message) => { try { loggerOf(ctx)?.warn?.(`[ppt-studio] ${message}`) } catch { /* 无 logger 时静默 */ } }
|
|
150
233
|
let release
|
|
151
234
|
let closed = false
|
|
235
|
+
let settled = false
|
|
152
236
|
let queue = Promise.resolve()
|
|
237
|
+
const settle = (health) => {
|
|
238
|
+
if (settled) return
|
|
239
|
+
settled = true
|
|
240
|
+
try { hooks?.onSettled?.(health) } catch (e) { warn(`onSettled 回调异常:${String(e?.message ?? e)}`) }
|
|
241
|
+
}
|
|
153
242
|
|
|
154
243
|
const declare = (registry) => {
|
|
155
244
|
queue = queue.then(async () => {
|
|
156
245
|
if (closed || release !== undefined) return
|
|
157
246
|
if (registry === undefined) {
|
|
158
247
|
STATUS.reason = 'agentPresets 服务不可见(本部署未装配预设注册表?)——预设未声明'
|
|
248
|
+
STATUS.health = 'no-registry'
|
|
159
249
|
warn(STATUS.reason)
|
|
250
|
+
settle({ ok: false, reason: STATUS.reason, rows: STATUS.rows, selfRow: STATUS.selfRow, presetId: PRESET_ID })
|
|
160
251
|
return
|
|
161
252
|
}
|
|
162
253
|
try {
|
|
163
254
|
release = await registry.register(declaration)
|
|
164
255
|
STATUS.declared = true
|
|
165
256
|
STATUS.via = 'agentPresets.register'
|
|
166
|
-
STATUS.
|
|
257
|
+
STATUS.health = 'declared'
|
|
258
|
+
STATUS.reason = `已声明(${declaration.plugins.length} 行${declaration.selfRow ? ',含自定位行' : ''})——新建会话的预设选择器里可选「${declaration.name}」`
|
|
167
259
|
try { loggerOf(ctx)?.info?.(`[ppt-studio] ${STATUS.reason}`) } catch { /* 忽略 */ }
|
|
260
|
+
settle({ ok: true, reason: STATUS.reason, rows: STATUS.rows, selfRow: STATUS.selfRow, presetId: PRESET_ID })
|
|
168
261
|
} catch (e) {
|
|
169
262
|
STATUS.declared = false
|
|
170
263
|
STATUS.via = ''
|
|
264
|
+
STATUS.health = 'failed'
|
|
171
265
|
STATUS.reason = `声明失败(不影响插件本身):${String(e?.message ?? e)}`
|
|
172
266
|
warn(STATUS.reason)
|
|
267
|
+
settle({ ok: false, reason: STATUS.reason, rows: STATUS.rows, selfRow: STATUS.selfRow, presetId: PRESET_ID })
|
|
173
268
|
}
|
|
174
|
-
}).catch((e) => {
|
|
269
|
+
}).catch((e) => {
|
|
270
|
+
warn(`预设声明队列异常:${String(e?.message ?? e)}`)
|
|
271
|
+
settle({ ok: false, reason: `预设声明队列异常:${String(e?.message ?? e)}`, presetId: PRESET_ID })
|
|
272
|
+
})
|
|
175
273
|
}
|
|
176
274
|
|
|
177
275
|
ctx.effect(() => {
|