@ash-qw/dsh-theme-prts 1.0.5 → 1.1.0
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/CHANGELOG.md +20 -0
- package/README.en.md +9 -1
- package/README.md +8 -2
- package/lib/client.js +649 -31
- package/lib/index.js +1 -3
- package/package.json +5 -2
- package/src/client/conversation-control-adapter.js +3 -1
- package/src/client/custom-emblem.js +404 -0
- package/src/client/emblem-reorder.js +143 -0
- package/src/client/emblem-tool-preview.js +17 -0
- package/src/client/index.js +7 -1
- package/src/client/operations-shell-v2.js +2 -0
- package/src/client/particle-field-adapter.js +32 -7
- package/src/client/theme-settings-workbench.js +56 -20
- package/src/index.js +13 -1
- package/src/server/api.js +54 -0
- package/src/server/emblems.js +153 -0
- package/src/server/image-worker.js +32 -0
- package/src/server/tools.js +67 -0
- package/src/styles/prts.css +19 -18
- package/src/styles/theme-settings-workbench.css +115 -30
package/src/client/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { registerEmblemToolPreview } from './emblem-tool-preview.js'
|
|
2
|
+
import { createCustomEmblemStore } from './custom-emblem.js'
|
|
1
3
|
import { DEFAULT_PREFERENCES, isSafeMode, loadPreferences, persistPreferences, resetPreferenceGroup, updatePreferenceValue } from './preferences.js'
|
|
2
4
|
import { createHostGeometryAdapter } from './host-geometry-adapter.js'
|
|
3
5
|
import { createComposerGlassAdapter } from './composer-glass-adapter.js'
|
|
@@ -42,6 +44,7 @@ export function applyPrtsPlugin(ctx, environment) {
|
|
|
42
44
|
startupTimings,
|
|
43
45
|
} = environment
|
|
44
46
|
const safeMode = isSafeMode(window?.location?.search ?? '')
|
|
47
|
+
const stopEmblemPreview = safeMode ? null : registerEmblemToolPreview(ctx, React)
|
|
45
48
|
const storage = window?.localStorage
|
|
46
49
|
const themeService = contextService(ctx, 'theme')
|
|
47
50
|
const composerGlass = createComposerGlassAdapter({ document, window })
|
|
@@ -61,6 +64,7 @@ export function applyPrtsPlugin(ctx, environment) {
|
|
|
61
64
|
sync()
|
|
62
65
|
},
|
|
63
66
|
})
|
|
67
|
+
const customEmblemStore = createCustomEmblemStore({ window, emblems: assets.emblems, preview: entry => particleField.previewEmblem?.(entry), onChange: value => particleField.setCustomEmblemConfiguration?.(value) })
|
|
64
68
|
const startup = createPrtsStartupSequence({
|
|
65
69
|
document,
|
|
66
70
|
window,
|
|
@@ -187,7 +191,7 @@ export function applyPrtsPlugin(ctx, environment) {
|
|
|
187
191
|
}
|
|
188
192
|
if (!themeAlreadyApplied) theme.apply(preferences)
|
|
189
193
|
operations ||= createOperationsShell({
|
|
190
|
-
document, window, assets, adapter, hostGeometry,
|
|
194
|
+
document, window, assets, adapter, hostGeometry, customEmblemStore,
|
|
191
195
|
onSchemeToggle(next, interaction) {
|
|
192
196
|
return theme.setTheme(next, { ...interaction, animate: true })
|
|
193
197
|
},
|
|
@@ -339,7 +343,9 @@ export function applyPrtsPlugin(ctx, environment) {
|
|
|
339
343
|
applyVisualState()
|
|
340
344
|
}
|
|
341
345
|
ctx.effect(() => () => {
|
|
346
|
+
if (typeof stopEmblemPreview === 'function') stopEmblemPreview()
|
|
342
347
|
statusSource.dispose()
|
|
348
|
+
customEmblemStore.dispose()
|
|
343
349
|
particleField.dispose()
|
|
344
350
|
startup.dispose({ restoreFocus: false })
|
|
345
351
|
stopStartupReadyWatch()
|
|
@@ -46,6 +46,7 @@ export function createOperationsShell({
|
|
|
46
46
|
onSchemeToggle = () => {},
|
|
47
47
|
onSchemeDragStart = () => undefined,
|
|
48
48
|
onThemeDisable = () => {},
|
|
49
|
+
customEmblemStore,
|
|
49
50
|
onPreferenceChange = () => {},
|
|
50
51
|
onPreferencePreview = () => {},
|
|
51
52
|
onVisualReset = () => {},
|
|
@@ -456,6 +457,7 @@ export function createOperationsShell({
|
|
|
456
457
|
trigger: shell.querySelector('[data-prts-rail-brand]'),
|
|
457
458
|
panel: shell.querySelector('[data-prts-theme-settings]'),
|
|
458
459
|
backdrop: shell.querySelector('[data-prts-settings-backdrop]'),
|
|
460
|
+
customEmblemStore,
|
|
459
461
|
onPreferenceChange,
|
|
460
462
|
onPreferencePreview,
|
|
461
463
|
onResetVisual: onVisualReset,
|
|
@@ -365,6 +365,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
|
|
|
365
365
|
},
|
|
366
366
|
opticalScale: suppliedHeroMask.opticalScale,
|
|
367
367
|
} : maskRecords[rhodesIndex]
|
|
368
|
+
let customEmblemConfiguration = null
|
|
368
369
|
const targetCache = new Map()
|
|
369
370
|
let particles = []
|
|
370
371
|
let pointerActive = false
|
|
@@ -448,35 +449,44 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
|
|
|
448
449
|
}
|
|
449
450
|
|
|
450
451
|
function sequenceIndexForSegment(segmentValue) {
|
|
451
|
-
const length = Math.max(1, emblemSequence.length)
|
|
452
|
+
const length = Math.max(1, (customEmblemConfiguration?.conversation ?? emblemSequence).length)
|
|
452
453
|
return ((segmentValue % length) + length) % length
|
|
453
454
|
}
|
|
454
455
|
|
|
455
456
|
function emblemForSegment(segmentValue) {
|
|
456
|
-
|
|
457
|
+
const sequence = customEmblemConfiguration?.conversation ?? emblemSequence
|
|
458
|
+
return sequence[sequenceIndexForSegment(segmentValue)] ?? sequence[0]
|
|
457
459
|
}
|
|
458
460
|
|
|
459
|
-
function
|
|
460
|
-
const
|
|
461
|
+
function targetsForBuiltin(entry, density = 'standard') {
|
|
462
|
+
const index = emblemSequence.findIndex(item => item.key === entry?.key)
|
|
463
|
+
const key = `builtin:${index}:${density}`
|
|
461
464
|
const cached = targetCache.get(key)
|
|
462
465
|
if (cached) return cached
|
|
463
466
|
const record = maskRecords[index]
|
|
464
467
|
const targets = record ? createOrthogonalTargets(record.mask, density) : []
|
|
465
|
-
if (
|
|
466
|
-
targetCache.set(key, targets)
|
|
468
|
+
if (targets.length) targetCache.set(key, targets)
|
|
467
469
|
return targets
|
|
468
470
|
}
|
|
469
471
|
|
|
472
|
+
function targetsForIndex(index, density = 'standard') {
|
|
473
|
+
const entry = (customEmblemConfiguration?.conversation ?? emblemSequence)[index]
|
|
474
|
+
if (entry?.kind === 'custom') return entry.targets[density] ?? entry.targets.standard
|
|
475
|
+
return targetsForBuiltin(entry, density)
|
|
476
|
+
}
|
|
477
|
+
|
|
470
478
|
function targetsForSegment(segmentValue, density = preferences.conversationParticleDensity) {
|
|
471
479
|
return targetsForIndex(sequenceIndexForSegment(segmentValue), density)
|
|
472
480
|
}
|
|
473
481
|
|
|
474
482
|
function heroEmblem() {
|
|
483
|
+
if (customEmblemConfiguration?.hero) return customEmblemConfiguration.hero
|
|
475
484
|
return emblemSequence[rhodesIndex] ?? emblemSequence[0]
|
|
476
485
|
}
|
|
477
486
|
|
|
478
487
|
function targetsForHero(density = preferences.heroParticleDensity) {
|
|
479
488
|
const effectiveDensity = effectiveHeroDensity(density)
|
|
489
|
+
if (customEmblemConfiguration?.hero) return customEmblemConfiguration.hero.targets[effectiveDensity] ?? customEmblemConfiguration.hero.targets.standard
|
|
480
490
|
const key = `hero:${effectiveDensity}`
|
|
481
491
|
const cached = targetCache.get(key)
|
|
482
492
|
if (cached) return cached
|
|
@@ -688,7 +698,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
|
|
|
688
698
|
function scheduleTargetPrewarm() {
|
|
689
699
|
cancelTargetPrewarm()
|
|
690
700
|
if (typeof window.requestIdleCallback !== 'function' || !particles.length) return
|
|
691
|
-
targetPrewarmQueue = emblemSequence.map((_, index) => index)
|
|
701
|
+
targetPrewarmQueue = (customEmblemConfiguration?.conversation ?? emblemSequence).map((_, index) => index)
|
|
692
702
|
.filter(index => index !== sequenceIndexForSegment(segment))
|
|
693
703
|
const warmNext = () => {
|
|
694
704
|
targetPrewarmHandle = undefined
|
|
@@ -1836,6 +1846,21 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
|
|
|
1836
1846
|
schemeTransitionPaused = next
|
|
1837
1847
|
updateFrameGate()
|
|
1838
1848
|
},
|
|
1849
|
+
previewEmblem(entry) {
|
|
1850
|
+
if (entry?.kind === 'custom') return entry
|
|
1851
|
+
const points = entry ? targetsForBuiltin(entry, 'standard') : heroMaskRecord ? createOrthogonalTargets(heroMaskRecord.mask, 'standard') : []
|
|
1852
|
+
return { targets: { standard: points } }
|
|
1853
|
+
},
|
|
1854
|
+
setCustomEmblemConfiguration(value) {
|
|
1855
|
+
const previousSequence = customEmblemConfiguration?.conversation ?? emblemSequence
|
|
1856
|
+
const nextSequence = value?.conversation ?? emblemSequence
|
|
1857
|
+
const changed = heroActive
|
|
1858
|
+
? (customEmblemConfiguration?.hero ?? null) !== (value?.hero ?? null)
|
|
1859
|
+
: previousSequence.length !== nextSequence.length || previousSequence.some((entry, index) =>
|
|
1860
|
+
entry.key !== nextSequence[index]?.key || entry.targets !== nextSequence[index]?.targets)
|
|
1861
|
+
customEmblemConfiguration = value
|
|
1862
|
+
if (canvas && changed) { resetTraversal(); scheduleFrame() }
|
|
1863
|
+
},
|
|
1839
1864
|
update(nextPreferences = {}) {
|
|
1840
1865
|
const previousPreferences = preferences
|
|
1841
1866
|
const traversalSpeedChanged = previousPreferences.particleTraversalSpeed !== undefined
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { customEmblemMarkup, mountCustomEmblemEditor } from './custom-emblem.js'
|
|
1
2
|
import { PARTICLE_TRAVERSAL_SPEED_DEFAULT, PARTICLE_TRAVERSAL_SPEED_MAX, PARTICLE_TRAVERSAL_SPEED_MIN, PARTICLE_TRAVERSAL_SPEED_STEP, SESSION_FLOW_SPEED_DEFAULT, SESSION_FLOW_SPEED_MAX, SESSION_FLOW_SPEED_MIN, SESSION_FLOW_SPEED_STEP, resolveParticleDetail } from './preferences.js'
|
|
2
3
|
|
|
3
4
|
const SETTINGS_FOCUSABLE = [
|
|
4
5
|
'button:not([disabled])',
|
|
5
6
|
'input:not([disabled])',
|
|
7
|
+
'select:not([disabled])',
|
|
6
8
|
'summary',
|
|
7
9
|
'[href]',
|
|
8
10
|
'[tabindex]:not([tabindex="-1"])',
|
|
@@ -119,14 +121,16 @@ export function themeSettingsMarkup() {
|
|
|
119
121
|
</header>
|
|
120
122
|
|
|
121
123
|
<div data-prts-settings-body>
|
|
122
|
-
<nav data-prts-settings-nav aria-label="设置分区">
|
|
123
|
-
<button type="button" data-prts-settings-jump="appearance">外观</button>
|
|
124
|
-
<button type="button" data-prts-settings-jump="interaction">交互</button>
|
|
125
|
-
<button type="button" data-prts-settings-jump="flow">会话流线</button>
|
|
126
|
-
<button type="button" data-prts-settings-jump="particles">粒子效果</button>
|
|
124
|
+
<nav data-prts-settings-nav role="tablist" aria-label="设置分区">
|
|
125
|
+
<button type="button" data-prts-settings-jump="appearance" role="tab" id="prts-tab-appearance" aria-controls="prts-page-appearance" aria-selected="true" tabindex="0">外观</button>
|
|
126
|
+
<button type="button" data-prts-settings-jump="interaction" role="tab" id="prts-tab-interaction" aria-controls="prts-page-interaction" aria-selected="false" tabindex="-1">交互</button>
|
|
127
|
+
<button type="button" data-prts-settings-jump="flow" role="tab" id="prts-tab-flow" aria-controls="prts-page-flow" aria-selected="false" tabindex="-1">会话流线</button>
|
|
128
|
+
<button type="button" data-prts-settings-jump="particles" role="tab" id="prts-tab-particles" aria-controls="prts-page-particles" aria-selected="false" tabindex="-1">粒子效果</button>
|
|
127
129
|
</nav>
|
|
128
130
|
<div data-prts-theme-settings-scroll>
|
|
129
|
-
<section data-prts-settings-
|
|
131
|
+
<section data-prts-settings-section="appearance" role="tabpanel" id="prts-page-appearance" aria-labelledby="prts-tab-appearance" tabindex="0">
|
|
132
|
+
<header data-prts-section-heading><strong>外观</strong><small>预设、界面材质与会话样式</small></header>
|
|
133
|
+
<section data-prts-settings-presets>
|
|
130
134
|
<header><span><strong>视觉预设</strong></span></header>
|
|
131
135
|
<div>${presetMarkup()}</div>
|
|
132
136
|
</section>
|
|
@@ -138,14 +142,15 @@ export function themeSettingsMarkup() {
|
|
|
138
142
|
${settingButtons('conversationStyle', '会话主题', [['native', '原有'], ['deck-chat', '通讯链路']])}
|
|
139
143
|
</div>
|
|
140
144
|
</section>
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
</section>
|
|
146
|
+
<section data-prts-settings-common data-prts-settings-section="interaction" role="tabpanel" id="prts-page-interaction" aria-labelledby="prts-tab-interaction" tabindex="0" hidden>
|
|
147
|
+
<header data-prts-section-heading><strong>交互</strong><small>光标、导航栏与动态反馈</small></header>
|
|
143
148
|
<div data-prts-settings-common-grid>
|
|
144
149
|
${COMMON_CONTROLS.filter(([key]) => ['cursor', 'railDefaultHidden', 'motion'].includes(key)).map(([key, label, options]) => settingButtons(key, label, options)).join('')}
|
|
145
150
|
</div>
|
|
146
151
|
</section>
|
|
147
|
-
<section data-prts-settings-common data-prts-settings-section="flow"
|
|
148
|
-
<header><
|
|
152
|
+
<section data-prts-settings-common data-prts-settings-section="flow" role="tabpanel" id="prts-page-flow" aria-labelledby="prts-tab-flow" tabindex="0" hidden>
|
|
153
|
+
<header data-prts-section-heading><strong>会话流线</strong><small>当前会话的流线与辉光</small></header>
|
|
149
154
|
<div data-prts-settings-common-grid>
|
|
150
155
|
${COMMON_CONTROLS.filter(([key]) => ['sessionFlow', 'sessionGlow', 'sessionFlowPalette'].includes(key)).map(([key, label, options]) => settingButtons(key, label, options)).join('')}
|
|
151
156
|
${sessionFlowColorMarkup()}
|
|
@@ -157,9 +162,10 @@ export function themeSettingsMarkup() {
|
|
|
157
162
|
</div>
|
|
158
163
|
</section>
|
|
159
164
|
|
|
160
|
-
<
|
|
161
|
-
<
|
|
165
|
+
<section data-prts-settings-advanced data-prts-settings-section="particles" role="tabpanel" id="prts-page-particles" aria-labelledby="prts-tab-particles" tabindex="0" hidden>
|
|
166
|
+
<header data-prts-section-heading><span><strong>粒子效果</strong><small>徽记精度与跟随移动速度</small></span><output data-prts-advanced-summary>标准 / 1×</output></header>
|
|
162
167
|
<div data-prts-settings-advanced-content>
|
|
168
|
+
${customEmblemMarkup()}
|
|
163
169
|
<section data-prts-particle-detail>
|
|
164
170
|
<header><strong>粒子精度</strong><span>同时调整会话与新会话徽记</span></header>
|
|
165
171
|
<div role="radiogroup" aria-label="粒子精度">${particleMarkup()}</div>
|
|
@@ -170,7 +176,7 @@ export function themeSettingsMarkup() {
|
|
|
170
176
|
<span data-prts-particle-error role="alert" hidden>粒子资源加载异常,主题已使用静态后备效果</span>
|
|
171
177
|
</section>
|
|
172
178
|
</div>
|
|
173
|
-
</
|
|
179
|
+
</section>
|
|
174
180
|
|
|
175
181
|
<footer data-prts-settings-actions>
|
|
176
182
|
<div data-prts-persistence-error hidden role="alert">
|
|
@@ -198,6 +204,7 @@ export function createThemeSettingsOverlay({
|
|
|
198
204
|
trigger,
|
|
199
205
|
panel,
|
|
200
206
|
backdrop,
|
|
207
|
+
customEmblemStore,
|
|
201
208
|
onOpen = () => {},
|
|
202
209
|
onPreferenceChange = () => {},
|
|
203
210
|
onPreferencePreview = () => {},
|
|
@@ -205,6 +212,7 @@ export function createThemeSettingsOverlay({
|
|
|
205
212
|
onRetrySave = () => {},
|
|
206
213
|
}) {
|
|
207
214
|
const window = document?.defaultView
|
|
215
|
+
const customEditor = mountCustomEmblemEditor({ panel, window, store: customEmblemStore })
|
|
208
216
|
const transparencyMedia = window?.matchMedia?.('(prefers-reduced-transparency: reduce)')
|
|
209
217
|
const motionMedia = window?.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
210
218
|
const nativeModal = typeof panel?.showModal === 'function' && typeof panel?.close === 'function'
|
|
@@ -516,18 +524,34 @@ export function createThemeSettingsOverlay({
|
|
|
516
524
|
}, 300)
|
|
517
525
|
}
|
|
518
526
|
|
|
527
|
+
let activeSection = 'appearance'
|
|
528
|
+
const sectionScroll = new Map()
|
|
529
|
+
function selectSection(key, focus = false) {
|
|
530
|
+
const scroller = panel?.querySelector('[data-prts-theme-settings-scroll]')
|
|
531
|
+
const section = panel?.querySelector(`[data-prts-settings-section="${key}"]`)
|
|
532
|
+
if (!scroller || !section) return
|
|
533
|
+
if (key !== activeSection) {
|
|
534
|
+
sectionScroll.set(activeSection, scroller.scrollTop)
|
|
535
|
+
customEditor.cancelReorder?.()
|
|
536
|
+
for (const page of panel.querySelectorAll('[data-prts-settings-section]')) page.hidden = page !== section
|
|
537
|
+
activeSection = key
|
|
538
|
+
scroller.scrollTop = sectionScroll.get(key) || 0
|
|
539
|
+
setResetConfirmation(false)
|
|
540
|
+
}
|
|
541
|
+
for (const tab of panel.querySelectorAll('[data-prts-settings-jump]')) {
|
|
542
|
+
const selected = tab.dataset.prtsSettingsJump === key
|
|
543
|
+
tab.setAttribute('aria-selected', String(selected))
|
|
544
|
+
tab.tabIndex = selected ? 0 : -1
|
|
545
|
+
if (selected && focus) tab.focus({preventScroll:true})
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
519
549
|
function onPanelClick(event) {
|
|
520
550
|
if (event.target === panel) { setOpen(false); return }
|
|
521
551
|
const target = event.target?.closest?.('button')
|
|
522
552
|
if (!target) return
|
|
523
553
|
if (target.hasAttribute('data-prts-settings-jump')) {
|
|
524
|
-
|
|
525
|
-
const scroller = panel.querySelector('[data-prts-theme-settings-scroll]')
|
|
526
|
-
if (section && scroller) {
|
|
527
|
-
if (section.tagName === 'DETAILS') section.open = true
|
|
528
|
-
scroller.scrollTo?.({ top: target.dataset.prtsSettingsJump === 'appearance' ? 0 : scroller.scrollTop + section.getBoundingClientRect().top - scroller.getBoundingClientRect().top, behavior: 'instant' })
|
|
529
|
-
section.focus?.({ preventScroll: true })
|
|
530
|
-
}
|
|
554
|
+
selectSection(target.dataset.prtsSettingsJump)
|
|
531
555
|
return
|
|
532
556
|
}
|
|
533
557
|
if (target.hasAttribute('data-prts-theme-settings-close')) { setOpen(false); return }
|
|
@@ -595,6 +619,16 @@ export function createThemeSettingsOverlay({
|
|
|
595
619
|
|
|
596
620
|
function onKeydown(event) {
|
|
597
621
|
if (!isOpen) return
|
|
622
|
+
const tab = event.target.closest?.('[data-prts-settings-jump]')
|
|
623
|
+
if (tab && ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Home','End'].includes(event.key)) {
|
|
624
|
+
event.preventDefault()
|
|
625
|
+
const tabs = [...panel.querySelectorAll('[data-prts-settings-jump]')]
|
|
626
|
+
const index = tabs.indexOf(tab)
|
|
627
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length-1
|
|
628
|
+
: (index + (['ArrowRight','ArrowDown'].includes(event.key) ? 1 : -1) + tabs.length) % tabs.length
|
|
629
|
+
selectSection(tabs[next].dataset.prtsSettingsJump, true)
|
|
630
|
+
return
|
|
631
|
+
}
|
|
598
632
|
if (event.key === 'Escape') { event.preventDefault(); setOpen(false); return }
|
|
599
633
|
if (nativeModal || event.key !== 'Tab') return
|
|
600
634
|
const nodes = focusable()
|
|
@@ -622,6 +656,7 @@ export function createThemeSettingsOverlay({
|
|
|
622
656
|
|
|
623
657
|
trigger?.addEventListener('click', toggle)
|
|
624
658
|
panel?.addEventListener('click', onPanelClick)
|
|
659
|
+
selectSection('appearance')
|
|
625
660
|
panel?.addEventListener('cancel', onCancel)
|
|
626
661
|
panel?.addEventListener('input', onPanelInput)
|
|
627
662
|
panel?.addEventListener('change', commitFromControl)
|
|
@@ -648,6 +683,7 @@ export function createThemeSettingsOverlay({
|
|
|
648
683
|
close: () => setOpen(false),
|
|
649
684
|
toggle,
|
|
650
685
|
dispose() {
|
|
686
|
+
customEditor.dispose()
|
|
651
687
|
isOpen = false
|
|
652
688
|
hidePanel()
|
|
653
689
|
if (savedTimer !== undefined) window?.clearTimeout?.(savedTimer)
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { createEmblemService } from './server/emblems.js'
|
|
4
|
+
import { emblemTools } from './server/tools.js'
|
|
5
|
+
import { registerEmblemApi } from './server/api.js'
|
|
1
6
|
export const PACKAGE_ID = '@ash-qw/dsh-theme-prts'
|
|
2
7
|
export const THEME_PLUGIN_ID = 'dsh-theme-prts'
|
|
8
|
+
export const inject = ['tools', 'attachments', 'connection', 'webServer']
|
|
3
9
|
|
|
4
|
-
export function apply() {
|
|
10
|
+
export async function apply(ctx) {
|
|
11
|
+
const directory=join(process.env.DSH_HOME?.trim() || join(homedir(),'.dsh'),'plugins','dsh-theme-prts','emblems')
|
|
12
|
+
const service=await createEmblemService(directory)
|
|
13
|
+
const disposers=emblemTools(service,ctx.attachments).map(tool=>ctx.tools.register(tool))
|
|
14
|
+
disposers.push(registerEmblemApi(ctx,service))
|
|
15
|
+
ctx.on('dispose',()=>{for(const dispose of disposers.reverse())dispose()})
|
|
16
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { API, draftView, fault } from './emblems.js'
|
|
2
|
+
|
|
3
|
+
async function body(req) {
|
|
4
|
+
const chunks=[];let size=0
|
|
5
|
+
for await(const chunk of req){size+=chunk.length;if(size>16*1024*1024)throw fault(413,'请求过大');chunks.push(chunk)}
|
|
6
|
+
try{return JSON.parse(Buffer.concat(chunks).toString('utf8'))}catch{throw fault(400,'请求数据无效')}
|
|
7
|
+
}
|
|
8
|
+
function json(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(value))}
|
|
9
|
+
export function registerEmblemApi(ctx,service) {
|
|
10
|
+
const streams=new Set()
|
|
11
|
+
const dispose=ctx.webServer.register({kind:'prefix',path:API,handler:async(req,res)=>{
|
|
12
|
+
try {
|
|
13
|
+
const admission=ctx.connection.admit(req)
|
|
14
|
+
if('rejection' in admission){json(res,401,{error:'请先登录 DSH'});return}
|
|
15
|
+
const path=new URL(req.url,'http://localhost').pathname.slice(API.length)
|
|
16
|
+
if(req.method==='GET'&&path==='/events') {
|
|
17
|
+
res.writeHead(200,{'content-type':'text/event-stream','cache-control':'no-cache','x-accel-buffering':'no'})
|
|
18
|
+
streams.add(res)
|
|
19
|
+
const send=revision=>res.write(`data: ${JSON.stringify({revision})}\n\n`)
|
|
20
|
+
const unsubscribe=service.subscribe(send)
|
|
21
|
+
send(service.snapshot().revision)
|
|
22
|
+
const heartbeat=setInterval(()=>res.write(': heartbeat\n\n'),25000)
|
|
23
|
+
res.on('close',()=>{clearInterval(heartbeat);unsubscribe();streams.delete(res)})
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
if(req.method==='GET'&&path==='/configuration'){json(res,200,service.snapshot());return}
|
|
27
|
+
const exported=path.match(/^\/draft\/([a-zA-Z0-9-]+)\/export$/)
|
|
28
|
+
if(req.method==='GET'&&exported){json(res,200,await service.exportAsset(exported[1]));return}
|
|
29
|
+
const match=path.match(/^\/draft\/([a-zA-Z0-9-]+)(?:\/(preview\.png|source))?$/)
|
|
30
|
+
if(req.method==='GET'&&match){
|
|
31
|
+
if(!match[2]){const d=await service.draft(match[1]);json(res,200,{...draftView(d),value:d.value});return}
|
|
32
|
+
const bytes=await service.image(match[1],match[2]==='source')
|
|
33
|
+
res.writeHead(200,{'content-type':match[2]==='source'?'application/octet-stream':'image/png','cache-control':'private, max-age=3600','x-content-type-options':'nosniff'});res.end(bytes);return
|
|
34
|
+
}
|
|
35
|
+
if(req.method!=='POST')throw fault(404,'接口不存在')
|
|
36
|
+
if(req.headers['x-prts-request']!=='1')throw fault(403,'缺少请求标识')
|
|
37
|
+
const controller=new AbortController()
|
|
38
|
+
res.on('close',()=>{if(!res.writableEnded)controller.abort(new Error('请求已取消'))})
|
|
39
|
+
const data=await body(req)
|
|
40
|
+
if(path==='/configuration'){json(res,200,await service.save(data.configuration,data.revision,controller.signal));return}
|
|
41
|
+
if(path==='/import'){
|
|
42
|
+
const d=await service.importAsset(data,controller.signal)
|
|
43
|
+
json(res,200,{...draftView(d),value:d.value});return
|
|
44
|
+
}
|
|
45
|
+
if(path==='/preview'){
|
|
46
|
+
if(data.image && (typeof data.image!=='string'||! /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data.image)))throw fault(400,'图片编码无效')
|
|
47
|
+
const d=await service.preview({bytes:data.image?Buffer.from(data.image,'base64'):undefined,baseId:data.draftId,params:data.params,label:data.label},controller.signal)
|
|
48
|
+
json(res,200,{...draftView(d),value:d.value});return
|
|
49
|
+
}
|
|
50
|
+
throw fault(404,'接口不存在')
|
|
51
|
+
}catch(error){if(!res.headersSent)json(res,error.status||500,{error:error.message});else res.end()}
|
|
52
|
+
}})
|
|
53
|
+
return()=>{dispose();for(const res of streams)res.end();streams.clear()}
|
|
54
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, readdir, unlink } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto'
|
|
4
|
+
import { Worker } from 'node:worker_threads'
|
|
5
|
+
import { defaultEmblemConfiguration, validEmblemConfiguration } from '../client/custom-emblem.js'
|
|
6
|
+
|
|
7
|
+
export const EMBLEMS = [
|
|
8
|
+
['rhodes-island','罗德岛'],['lungmen','龙门'],['penguin-logistics','企鹅物流'],['rhine-lab','莱茵生命'],['reunion','整合运动'],
|
|
9
|
+
].map(([key,label])=>({key,label}))
|
|
10
|
+
export const API = '/prts-theme-api/emblems'
|
|
11
|
+
export const DEFAULT_PARAMS = Object.freeze({mode:'auto',threshold:128,zoom:.85,x:0,y:0})
|
|
12
|
+
export function fault(status,message) { return Object.assign(new Error(message),{status}) }
|
|
13
|
+
export function parameters(input = {}, base = DEFAULT_PARAMS) {
|
|
14
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw fault(400,'编辑参数无效')
|
|
15
|
+
const result = {...base,...input}
|
|
16
|
+
if (Object.keys(result).some(k=>!Object.hasOwn(DEFAULT_PARAMS,k)) || !['auto','alpha','dark','light'].includes(result.mode)
|
|
17
|
+
|| !Number.isInteger(result.threshold) || result.threshold<1 || result.threshold>254
|
|
18
|
+
|| !Number.isFinite(result.zoom) || result.zoom<.5 || result.zoom>2
|
|
19
|
+
|| !Number.isFinite(result.x) || Math.abs(result.x)>.5 || !Number.isFinite(result.y) || Math.abs(result.y)>.5) throw fault(400,'编辑参数超出范围')
|
|
20
|
+
return result
|
|
21
|
+
}
|
|
22
|
+
export function processImage(bytes, params, signal) {
|
|
23
|
+
signal?.throwIfAborted()
|
|
24
|
+
return new Promise((resolve,reject)=>{
|
|
25
|
+
const worker = new Worker(new URL('./image-worker.js',import.meta.url), {workerData:{bytes,params},execArgv:[]})
|
|
26
|
+
let settled = false
|
|
27
|
+
const finish = async (error,value) => {
|
|
28
|
+
if(settled)return; settled=true;clearTimeout(timer);signal?.removeEventListener('abort',abort)
|
|
29
|
+
await worker.terminate()
|
|
30
|
+
if(error)reject(error);else resolve(value)
|
|
31
|
+
}
|
|
32
|
+
const abort = ()=>void finish(signal.reason || new Error('已取消'))
|
|
33
|
+
const timer = setTimeout(()=>void finish(fault(408,'图片处理超时,请缩小图片后重试')),15000)
|
|
34
|
+
signal?.addEventListener('abort',abort,{once:true})
|
|
35
|
+
worker.once('error',error=>void finish(error))
|
|
36
|
+
worker.once('exit',code=>{if(!settled)void finish(new Error(`图片处理进程提前退出 (${code})`))})
|
|
37
|
+
worker.once('message',result=>void finish(result.error?fault(400,result.error):null,result))
|
|
38
|
+
if(signal?.aborted)abort()
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
async function atomic(path,value) {
|
|
42
|
+
const temp = `${path}.${randomUUID()}.tmp`
|
|
43
|
+
await writeFile(temp,value,{mode:0o600});await rename(temp,path)
|
|
44
|
+
}
|
|
45
|
+
const idPattern = /^[a-zA-Z0-9-]{1,80}$/
|
|
46
|
+
export function checkedId(id) { if(typeof id!=='string'||!idPattern.test(id))throw fault(400,'徽记标识无效');return id }
|
|
47
|
+
|
|
48
|
+
export async function createEmblemService(directory) {
|
|
49
|
+
await mkdir(directory,{recursive:true,mode:0o700})
|
|
50
|
+
let state = {revision:0,configuration:defaultEmblemConfiguration(EMBLEMS)}
|
|
51
|
+
try {
|
|
52
|
+
const stored=JSON.parse(await readFile(join(directory,'configuration.json'),'utf8'))
|
|
53
|
+
if(!Number.isInteger(stored.revision)||!validEmblemConfiguration(stored.configuration,EMBLEMS))throw Error('已保存的徽记配置无效')
|
|
54
|
+
state=stored
|
|
55
|
+
} catch(error) { if(error.code!=='ENOENT')throw error }
|
|
56
|
+
let queue=Promise.resolve(), processing=false
|
|
57
|
+
const listeners=new Set()
|
|
58
|
+
const serialize=fn=>{const next=queue.then(fn);queue=next.catch(()=>{});return next}
|
|
59
|
+
const snapshot=()=>structuredClone(state)
|
|
60
|
+
const draft=async id=>{
|
|
61
|
+
try{return JSON.parse(await readFile(join(directory,`${checkedId(id)}.draft.json`),'utf8'))}
|
|
62
|
+
catch(error){if(error.code==='ENOENT')throw fault(404,'草稿不存在');throw error}
|
|
63
|
+
}
|
|
64
|
+
const save = (configuration,revision,signal)=>serialize(async()=>{
|
|
65
|
+
signal?.throwIfAborted()
|
|
66
|
+
if(revision!==state.revision)throw fault(409,'徽记设置已在其他会话或页面更新,请重新读取后再操作')
|
|
67
|
+
if(configuration?.version!==2 || !Array.isArray(configuration.conversation) || configuration.conversation.length<1 || configuration.conversation.length>12)throw fault(400,'徽记配置无效')
|
|
68
|
+
// Custom entries must be generated by this service; clients cannot inject arbitrary particle arrays.
|
|
69
|
+
const canonical=structuredClone({version:2,hero:configuration.hero,conversation:configuration.conversation})
|
|
70
|
+
const resolveEntry=async entry=>{
|
|
71
|
+
if(!entry || typeof entry!=='object')throw fault(400,'徽记配置无效')
|
|
72
|
+
if(entry.kind==='builtin')return {kind:'builtin',...EMBLEMS.find(e=>e.key===entry.key)}
|
|
73
|
+
if(entry.kind!=='custom')throw fault(400,'徽记类型无效')
|
|
74
|
+
const source=await draft(entry.draftId)
|
|
75
|
+
return {...source.value,kind:'custom',key:entry.key,label:entry.label,draftId:source.id}
|
|
76
|
+
}
|
|
77
|
+
if(canonical.hero)canonical.hero=await resolveEntry(canonical.hero)
|
|
78
|
+
canonical.conversation=await Promise.all(canonical.conversation.map(resolveEntry))
|
|
79
|
+
if(!validEmblemConfiguration(canonical,EMBLEMS))throw fault(400,'徽记配置无效')
|
|
80
|
+
const next={revision:state.revision+1,configuration:canonical}
|
|
81
|
+
signal?.throwIfAborted();await atomic(join(directory,'configuration.json'),JSON.stringify(next));state=next
|
|
82
|
+
for(const fn of listeners) {try{fn(state.revision)}catch{}}
|
|
83
|
+
return snapshot()
|
|
84
|
+
})
|
|
85
|
+
return {
|
|
86
|
+
snapshot,save,draft,
|
|
87
|
+
async exportAsset(id) {
|
|
88
|
+
const d=await draft(id)
|
|
89
|
+
const bytes=await readFile(join(directory,`${d.sourceId}.source`))
|
|
90
|
+
return {format:'prts-emblem',version:1,label:d.label,params:d.params,image:bytes.toString('base64')}
|
|
91
|
+
},
|
|
92
|
+
async importAsset(asset,signal) {
|
|
93
|
+
if(asset?.format!=='prts-emblem'||asset.version!==1)throw fault(400,'不支持的徽记素材包格式或版本')
|
|
94
|
+
if(typeof asset.label!=='string'||!asset.label.trim()||asset.label.length>100)throw fault(400,'徽记名称无效')
|
|
95
|
+
if(typeof asset.image!=='string'||!asset.image.length||asset.image.length>Math.ceil(8*1024*1024/3)*4
|
|
96
|
+
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(asset.image)||asset.image.length%4!==0)throw fault(400,'素材包图片编码或大小无效')
|
|
97
|
+
const bytes=Buffer.from(asset.image,'base64')
|
|
98
|
+
if(bytes.toString('base64')!==asset.image)throw fault(400,'素材包图片编码无效')
|
|
99
|
+
// Rebuild from the editable source; never trust particle arrays or filesystem paths in a package.
|
|
100
|
+
return this.preview({bytes,params:parameters(asset.params),label:asset.label},signal)
|
|
101
|
+
},
|
|
102
|
+
previewPath: id=>join(directory,`${checkedId(id)}.png`),
|
|
103
|
+
subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn)},
|
|
104
|
+
async preview({bytes,params={},baseId,label},signal) {
|
|
105
|
+
if(processing)throw fault(409,'正在处理另一张徽记,请稍后重试')
|
|
106
|
+
processing=true
|
|
107
|
+
try {
|
|
108
|
+
const previous=baseId?await draft(baseId):null
|
|
109
|
+
const effective=parameters(params,previous?.params)
|
|
110
|
+
if(previous)bytes=await readFile(join(directory,`${previous.sourceId}.source`))
|
|
111
|
+
if(!bytes?.length || bytes.length>8*1024*1024)throw fault(400,'图片须为 8 MB 以内的 PNG、JPEG 或 WebP')
|
|
112
|
+
const drafts=(await readdir(directory)).filter(f=>f.endsWith('.draft.json'))
|
|
113
|
+
if(drafts.length>=200)await serialize(async()=>{
|
|
114
|
+
const protectedIds=new Set([previous?.id,state.configuration.hero?.draftId,...state.configuration.conversation.map(e=>e.draftId)])
|
|
115
|
+
const records=await Promise.all(drafts.map(file=>draft(file.slice(0,-11))))
|
|
116
|
+
const expired=records.filter(d=>!protectedIds.has(d.id)).sort((a,b)=>a.createdAt-b.createdAt).slice(0,drafts.length-199)
|
|
117
|
+
const expiredIds=new Set(expired.map(d=>d.id))
|
|
118
|
+
const retainedSources=new Set(records.filter(d=>!expiredIds.has(d.id)).map(d=>d.sourceId))
|
|
119
|
+
for(const d of expired){await unlink(join(directory,`${d.id}.draft.json`));await unlink(join(directory,`${d.id}.png`))}
|
|
120
|
+
for(const sourceId of new Set(expired.map(d=>d.sourceId)))if(!retainedSources.has(sourceId))await unlink(join(directory,`${sourceId}.source`))
|
|
121
|
+
})
|
|
122
|
+
const result=await processImage(bytes,effective,signal)
|
|
123
|
+
signal?.throwIfAborted()
|
|
124
|
+
const id=randomUUID(),sourceId=createHash('sha256').update(bytes).digest('hex')
|
|
125
|
+
const value={id,sourceId,params:effective,label:String(label||previous?.label||'自定义徽记').slice(0,100),createdAt:Date.now(),value:result.value}
|
|
126
|
+
await atomic(join(directory,`${sourceId}.source`),bytes)
|
|
127
|
+
await atomic(join(directory,`${id}.png`),result.preview)
|
|
128
|
+
await atomic(join(directory,`${id}.draft.json`),JSON.stringify(value))
|
|
129
|
+
return value
|
|
130
|
+
} finally {processing=false}
|
|
131
|
+
},
|
|
132
|
+
async image(id,source=false) {const d=await draft(id);return readFile(join(directory,source?`${d.sourceId}.source`:`${d.id}.png`))},
|
|
133
|
+
async apply({draftId,target,entryKey,revision},signal) {
|
|
134
|
+
const d=await draft(draftId),configuration=snapshot().configuration
|
|
135
|
+
const entry={...d.value,kind:'custom',key:randomUUID(),label:d.label,draftId:d.id}
|
|
136
|
+
if(target==='hero')configuration.hero=entry
|
|
137
|
+
else if(target==='add')configuration.conversation.push(entry)
|
|
138
|
+
else if(target==='replace') {
|
|
139
|
+
const index=configuration.conversation.findIndex(e=>e.key===entryKey)
|
|
140
|
+
if(index<0)throw fault(404,'要替换的正文徽记不存在')
|
|
141
|
+
configuration.conversation[index]=entry
|
|
142
|
+
} else throw fault(400,'应用位置无效')
|
|
143
|
+
return save(configuration,revision,signal)
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function draftView(d) {
|
|
148
|
+
return {draftId:d.id,label:d.label,params:d.params,particles:Object.fromEntries(Object.entries(d.value.targets).map(([k,v])=>[k,v.length])),previewUrl:`${API}/draft/${d.id}/preview.png`}
|
|
149
|
+
}
|
|
150
|
+
export function stateView(state) {
|
|
151
|
+
const entry=e=>e?{key:e.key,label:e.label,kind:e.kind,...(e.draftId?{draftId:e.draftId}:{})}:null
|
|
152
|
+
return {revision:state.revision,scope:'当前 DSH 实例,跨工作区和会话共享',hero:entry(state.configuration.hero),conversation:state.configuration.conversation.map(entry)}
|
|
153
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
2
|
+
import sharp from 'sharp'
|
|
3
|
+
import { processCustomEmblemPixels } from '../client/custom-emblem.js'
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
const { bytes, params } = workerData
|
|
7
|
+
const image = sharp(Buffer.from(bytes), { limitInputPixels: 16000000, failOn: 'error' }).rotate()
|
|
8
|
+
const metadata = await image.metadata()
|
|
9
|
+
if (!['png','jpeg','webp'].includes(metadata.format)) throw new Error('仅支持 PNG、JPEG、WebP 图片')
|
|
10
|
+
const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true })
|
|
11
|
+
let mode = params.mode
|
|
12
|
+
if (mode === 'auto') {
|
|
13
|
+
mode = 'dark'
|
|
14
|
+
for (let i = 3; i < data.length; i += 4) if (data[i] < 128) { mode = 'alpha'; break }
|
|
15
|
+
}
|
|
16
|
+
const scale = 320 / Math.max(info.width, info.height) * params.zoom
|
|
17
|
+
const width = Math.max(1, Math.round(info.width * scale)), height = Math.max(1, Math.round(info.height * scale))
|
|
18
|
+
const resized = await sharp(data, { raw: info }).resize(width, height).raw().toBuffer()
|
|
19
|
+
const rgba = new Uint8ClampedArray(320 * 320 * 4)
|
|
20
|
+
const left = Math.round((320-width)/2 + params.x*320), top = Math.round((320-height)/2 + params.y*320)
|
|
21
|
+
for (let y = Math.max(0,top); y < Math.min(320,top+height); y++) {
|
|
22
|
+
const x = Math.max(0,left), end = Math.min(320,left+width)
|
|
23
|
+
if (end > x) rgba.set(resized.subarray(((y-top)*width+x-left)*4,((y-top)*width+end-left)*4),(y*320+x)*4)
|
|
24
|
+
}
|
|
25
|
+
const value = processCustomEmblemPixels(rgba,320,mode,params.threshold)
|
|
26
|
+
const dots = value.targets.standard.map(p => {
|
|
27
|
+
const r = p.kind === 'skeleton' ? 1.2 : .9
|
|
28
|
+
return `<circle cx="${192+p.x*350}" cy="${192+p.y*350}" r="${r}"/>`
|
|
29
|
+
}).join('')
|
|
30
|
+
const preview = await sharp(Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="384" height="384"><rect width="384" height="384" fill="#0d1114"/><g fill="#edf1f2">${dots}</g></svg>`)).png().toBuffer()
|
|
31
|
+
parentPort.postMessage({ value, preview })
|
|
32
|
+
} catch (error) { parentPort.postMessage({ error: error.message }) }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { draftView, stateView, fault } from './emblems.js'
|
|
2
|
+
|
|
3
|
+
export function sessionImages(agent) {
|
|
4
|
+
if(!agent?.session)throw fault(400,'请在 DSH 会话中调用此工具')
|
|
5
|
+
const images=new Map()
|
|
6
|
+
for(const event of agent.session.snapshotEvents()) {
|
|
7
|
+
if(event.type!=='user/message')continue
|
|
8
|
+
for(const part of event.data.content ?? [])if(part.attachment && (part.type==='image' || part.type==='file' && /\.(png|jpe?g|webp)$/i.test(part.attachment.name || '')))images.set(part.attachment.attachmentId,{...part.attachment,attachmentKind:part.type})
|
|
9
|
+
}
|
|
10
|
+
return [...images.values()]
|
|
11
|
+
}
|
|
12
|
+
export function emblemTools(service,attachments) {
|
|
13
|
+
const output={schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)},...(value.previewImage&&value.imageForModel?[{type:'image',attachment:value.previewImage}]:[])]}
|
|
14
|
+
return [{
|
|
15
|
+
name:'prts_emblem_inspect',
|
|
16
|
+
description:'查看 PRTS 主题的新会话徽记、正文轮换列表、配置版本,以及当前会话已上传的图片。仅当用户要求查看或调整主题徽记时使用。徽记设置跨工作区和会话共享;工具不会修改设置。',
|
|
17
|
+
parameters:{type:'object',properties:{},additionalProperties:false},output,isConcurrencySafe:()=>true,
|
|
18
|
+
async execute(_args,exec){return JSON.parse(JSON.stringify({...stateView(service.snapshot()),images:sessionImages(exec.agent)}))}
|
|
19
|
+
},{
|
|
20
|
+
name:'prts_emblem_preview',
|
|
21
|
+
description:'将当前会话上传的图片制作成 PRTS 粒子徽记草稿,或继续调整已有草稿。传 attachmentId(来自 inspect)或 draftId,二选一。调整已有正式徽记可使用 inspect 返回的 draftId。仅生成预览,不应用。优先原样使用返回的 previewMarkdown 展示给用户;没有此字段时,告知用户可展开工具卡片查看。不要将 previewUrl 当成本地图片路径。标准密度预览不是原图。用户满意后使用 apply。不要声称已看到预览,除非实际通过视觉工具查看。',
|
|
22
|
+
parameters:{type:'object',additionalProperties:false,properties:{
|
|
23
|
+
attachmentId:{type:'string'},draftId:{type:'string'},label:{type:'string',maxLength:100},
|
|
24
|
+
params:{type:'object',additionalProperties:false,properties:{mode:{type:'string',enum:['auto','alpha','dark','light']},threshold:{type:'integer',minimum:1,maximum:254},zoom:{type:'number',minimum:.5,maximum:2},x:{type:'number',minimum:-.5,maximum:.5},y:{type:'number',minimum:-.5,maximum:.5}}},
|
|
25
|
+
}},output,isConcurrencySafe:()=>false,
|
|
26
|
+
async execute(args,exec){
|
|
27
|
+
const images=sessionImages(exec.agent)
|
|
28
|
+
if(Boolean(args.attachmentId)===Boolean(args.draftId))throw fault(400,'请选择一张会话图片或一个已有草稿')
|
|
29
|
+
let bytes,label=args.label
|
|
30
|
+
if(args.attachmentId) {
|
|
31
|
+
const ref=images.find(e=>e.attachmentId===args.attachmentId)
|
|
32
|
+
if(!ref)throw fault(404,'当前会话没有这张图片,请先上传图片或重新读取图片列表')
|
|
33
|
+
if(ref.bytes>8*1024*1024)throw fault(400,'图片超过 8 MB')
|
|
34
|
+
const {attachmentKind,...attachment}=ref
|
|
35
|
+
if(attachmentKind==='file') {
|
|
36
|
+
const chunks=[];let total=0
|
|
37
|
+
for await(const chunk of attachments.readFileStream(attachment,exec.signal)) {
|
|
38
|
+
total+=chunk.length;if(total>8*1024*1024)throw fault(400,'图片超过 8 MB');chunks.push(chunk)
|
|
39
|
+
}
|
|
40
|
+
bytes=Buffer.concat(chunks)
|
|
41
|
+
} else bytes=(await attachments.readImage(attachment,exec.signal)).data
|
|
42
|
+
label ||= ref.name
|
|
43
|
+
}
|
|
44
|
+
const d=await service.preview({bytes,baseId:args.draftId,params:args.params,label},exec.signal)
|
|
45
|
+
const hostPath=service.previewPath(d.id)
|
|
46
|
+
const fs=exec.agent.ctx?.get?.('fs')
|
|
47
|
+
const previewPath=fs?.processPathFromHostPath(hostPath)
|
|
48
|
+
let previewImage, imageForModel=false
|
|
49
|
+
const llm=exec.agent.ctx?.get?.('llm')
|
|
50
|
+
const routed=exec.agent.session.requestHeader?.()?.config
|
|
51
|
+
const provider=routed?.provider ?? exec.agent.options?.provider, model=routed?.model ?? exec.agent.options?.model
|
|
52
|
+
if(llm && provider && model) {
|
|
53
|
+
const info=await llm.resolveModelInfo(provider,model,exec.signal)
|
|
54
|
+
imageForModel=info.inputModalities?.includes('image') === true
|
|
55
|
+
}
|
|
56
|
+
if(imageForModel)previewImage=await attachments.saveImage({data:await service.image(d.id),mediaType:'image/png',name:`prts-preview-${d.id}.png`})
|
|
57
|
+
return JSON.parse(JSON.stringify({...draftView(d),applied:false,revision:service.snapshot().revision,
|
|
58
|
+
...(previewPath?{previewPath,previewMarkdown:``}:{}),
|
|
59
|
+
imageForModel,...(previewImage?{previewImage}:{})}))
|
|
60
|
+
},
|
|
61
|
+
},{
|
|
62
|
+
name:'prts_emblem_apply',
|
|
63
|
+
description:'按用户要求应用 PRTS 粒子徽记草稿。hero 替换新会话徽记;add 添加正文轮换徽记;replace 替换 entryKey 对应的正文徽记。先 inspect 取得 revision;版本冲突时重新读取并核对,不能盲目重试覆盖。只有用户明确要求应用时才调用,讨论或预览不等于应用。',
|
|
64
|
+
parameters:{type:'object',additionalProperties:false,properties:{draftId:{type:'string'},target:{type:'string',enum:['hero','add','replace']},entryKey:{type:'string'},revision:{type:'integer',minimum:0}},required:['draftId','target','revision']},output,isConcurrencySafe:()=>false,
|
|
65
|
+
async execute(args,exec){sessionImages(exec.agent);return {applied:true,...stateView(await service.apply(args,exec.signal))}},
|
|
66
|
+
}]
|
|
67
|
+
}
|