@ash-qw/dsh-theme-prts 2.5.5 → 2.6.1

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.
@@ -1,4 +1,4 @@
1
- import { createCursorAdapter } from './cursor-adapter.js'
1
+ import { createCursorAdapter as createFollowerCursorAdapter } from './cursor-follower.js'
2
2
  import { normalizePreferences } from './preferences.js'
3
3
 
4
4
  export const SCHEME_COMMIT_PROGRESS = .15
@@ -19,7 +19,8 @@ const ROOT_ATTRIBUTES = [
19
19
  'data-prts-background-mode',
20
20
  ]
21
21
  const SESSION_FLOW_COLOR_PROPERTIES = {
22
- '--prts-corner-color': 'cornerColor',
22
+ '--prts-corner-color-dark': 'cornerColor',
23
+ '--prts-corner-color-light': 'cornerColorLight',
23
24
  '--prts-session-flow-back': 'sessionFlowColorBack',
24
25
  '--prts-session-flow-core': 'sessionFlowColorCore',
25
26
  '--prts-session-flow-front': 'sessionFlowColorFront',
@@ -79,7 +80,7 @@ export function createThemeController({ document, window, cssText, service, curs
79
80
  return { apply() {}, sync() {}, refresh() {}, setTheme() {}, beginThemeTransition() { return gesture }, toggle() {}, dispose() {} }
80
81
  }
81
82
 
82
- const cursor = createCursorAdapter({ document, window, assets: cursorAssets })
83
+ const cursor = createFollowerCursorAdapter({ document, window, assets: cursorAssets })
83
84
  const root = document.documentElement
84
85
  let style
85
86
  let disposed = false
@@ -760,13 +761,13 @@ export function createThemeController({ document, window, cssText, service, curs
760
761
  root.dataset.prtsTexture = preferences.texture
761
762
  root.dataset.prtsGlass = preferences.glass
762
763
  root.dataset.prtsMotion = preferences.motion
763
- root.dataset.prtsCursor = preferences.cursor
764
+ root.dataset.prtsCursor = preferences.cursor === 'prts-follower' ? 'prts' : preferences.cursor
764
765
  root.dataset.prtsParticlePattern = 'orthogonal'
765
766
  root.dataset.prtsSessionFlow = preferences.sessionFlow ? 'on' : 'off'
766
767
  root.dataset.prtsSessionGlow = preferences.sessionGlow ? 'on' : 'off'
767
768
  root.dataset.prtsSessionFlowPalette = preferences.sessionFlowPalette
768
769
  root.dataset.prtsSessionFlowSpeed = String(preferences.sessionFlowSpeed)
769
- for (const [property, key] of Object.entries(SESSION_FLOW_COLOR_PROPERTIES)) root.style.setProperty(property, preferences[key])
770
+ for (const [property, key] of Object.entries(SESSION_FLOW_COLOR_PROPERTIES)) root.style.setProperty(property, preferences.cornerColorLinked && (key === 'cornerColor' || key === 'cornerColorLight') ? preferences[preferences.cornerColorSource === 'light' ? 'cornerColorLight' : 'cornerColor'] : preferences[key])
770
771
  root.dataset.prtsConversationStyle = preferences.conversationStyle
771
772
  root.toggleAttribute('data-prts-baker-loading', preferences.bakerLoading === true)
772
773
  root.dataset.prtsBackgroundMode = preferences.backgroundMode
@@ -14,7 +14,8 @@ const ROOT_ATTRIBUTES = [
14
14
  'data-prts-conversation-style',
15
15
  ]
16
16
  const SESSION_FLOW_COLOR_PROPERTIES = {
17
- '--prts-corner-color': 'cornerColor',
17
+ '--prts-corner-color-dark': 'cornerColor',
18
+ '--prts-corner-color-light': 'cornerColorLight',
18
19
  '--prts-session-flow-back': 'sessionFlowColorBack',
19
20
  '--prts-session-flow-core': 'sessionFlowColorCore',
20
21
  '--prts-session-flow-front': 'sessionFlowColorFront',
@@ -124,7 +125,7 @@ export function createThemeController({ document, window, cssText, service }) {
124
125
  root.dataset.prtsSessionGlow = preferences.sessionGlow ? 'on' : 'off'
125
126
  root.dataset.prtsSessionFlowPalette = preferences.sessionFlowPalette
126
127
  root.dataset.prtsSessionFlowSpeed = String(preferences.sessionFlowSpeed)
127
- for (const [property, key] of Object.entries(SESSION_FLOW_COLOR_PROPERTIES)) root.style.setProperty(property, preferences[key])
128
+ for (const [property, key] of Object.entries(SESSION_FLOW_COLOR_PROPERTIES)) root.style.setProperty(property, preferences.cornerColorLinked && (key === 'cornerColor' || key === 'cornerColorLight') ? preferences[preferences.cornerColorSource === 'light' ? 'cornerColorLight' : 'cornerColor'] : preferences[key])
128
129
  root.dataset.prtsConversationStyle = preferences.conversationStyle
129
130
  refresh()
130
131
  },
@@ -1,3 +1,4 @@
1
+ import { mountPageControls } from './page-controls.js'
1
2
  import { themeTransferMarkup, mountThemeTransfer } from './theme-transfer-panel.js'
2
3
  import { backgroundMarkup, mountBackgroundSettings } from './custom-background.js'
3
4
  import { avatarMarkup, mountAvatarSettings } from './custom-avatar.js'
@@ -13,8 +14,14 @@ const SETTINGS_FOCUSABLE = [
13
14
  '[tabindex]:not([tabindex="-1"])',
14
15
  ].join(',')
15
16
 
17
+ const CURSOR_DESCRIPTIONS = {
18
+ system: '使用系统默认外观,由浏览器与系统处理移动,不播放 PRTS 点击动画。',
19
+ prts: '由系统移动 PRTS 光标,页面繁忙时仍较跟手。小控件播放完整点击动画,中等容器仅显示按下与恢复,大容器保持静态。',
20
+ 'prts-follower': '由页面绘制并跟随鼠标,普通区域均可播放 PRTS 按压动画。页面繁忙时可能跟随延迟;文字、缩放等区域显示对应形态。',
21
+ }
22
+
16
23
  const COMMON_CONTROLS = [
17
- ['cursor', 'PRTS 光标', [['system', '关闭'], ['prts', '开启']]],
24
+ ['cursor', '光标模式', [['system', '系统光标'], ['prts', 'PRTS 原生光标'], ['prts-follower', 'PRTS 跟随光标(实验)']]],
18
25
  ['railDefaultHidden', '默认隐藏导航栏', [['false', '关闭'], ['true', '开启']]],
19
26
  ['motion', '动态效果', []],
20
27
  ['sessionFlow', '当前会话流线', [['false', '关闭'], ['true', '开启']]],
@@ -23,9 +30,9 @@ const COMMON_CONTROLS = [
23
30
  ]
24
31
 
25
32
  const SESSION_FLOW_COLOR_CONTROLS = [
26
- ['sessionFlowColorBack', '后层'],
27
- ['sessionFlowColorCore', '中层'],
28
33
  ['sessionFlowColorFront', '前层'],
34
+ ['sessionFlowColorCore', '中层'],
35
+ ['sessionFlowColorBack', '后层'],
29
36
  ]
30
37
 
31
38
  const PARTICLE_OPTIONS = [
@@ -62,10 +69,14 @@ function settingButtons(key, label, options) {
62
69
  : key === 'sessionFlowPalette'
63
70
  ? '<small data-prts-setting-note>同时适配日间与夜间模式</small>'
64
71
  : ''
65
- if (['heroContrast', 'composerGlass', 'bakerLoading', 'motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'aiToolsEnabled', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
72
+ if (['heroContrast', 'composerGlass', 'bakerLoading', 'motion', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'aiToolsEnabled', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
66
73
  <span><strong>${label}</strong>${note}</span>
67
74
  <button type="button" role="switch" aria-label="${label}" aria-checked="false" data-prts-setting-switch="${key}" ${key === 'cursor' ? 'data-prts-cursor-switch' : ''}><span aria-hidden="true"></span></button>
68
75
  </div>`
76
+ if (key === 'cursor') return `<label data-prts-setting-row="cursor">
77
+ <span><strong>光标模式</strong><small id="prts-cursor-description" data-prts-setting-note data-prts-cursor-description></small></span>
78
+ <select data-prts-cursor-mode aria-label="光标模式" aria-describedby="prts-cursor-description">${options.map(([value,text])=>`<option value="${value}" data-description="${CURSOR_DESCRIPTIONS[value]}">${text}</option>`).join('')}</select>
79
+ </label>`
69
80
  const sample = value => key === 'sessionFlowPalette'
70
81
  ? `<i data-prts-option-sample="${key}" data-prts-option-sample-value="${value}" aria-hidden="true"></i>`
71
82
  : ''
@@ -88,11 +99,11 @@ function sessionFlowPreviewMarkup() {
88
99
  ['core', 'M 3 24 C 30 35 61 7 94 23 C 124 37 148 7 177 20'],
89
100
  ['front', 'M 3 18 C 34 4 66 30 96 12 C 125 -1 148 30 177 24'],
90
101
  ]
91
- return `<svg data-prts-session-flow-preview viewBox="0 0 180 42" aria-label="三色流线预览" role="img">
102
+ return `<svg data-prts-session-flow-preview viewBox="0 0 180 42" preserveAspectRatio="none" aria-label="三色流线预览" role="img">
92
103
  ${filaments.map(([strand, path]) => `<g data-prts-session-flow-preview-filament="${strand}">
93
- <path data-prts-session-flow-preview-layer="halo" d="${path}"></path>
94
- <path data-prts-session-flow-preview-layer="body" d="${path}"></path>
95
- <path data-prts-session-flow-preview-layer="core" d="${path}"></path>
104
+ <path vector-effect="non-scaling-stroke" data-prts-session-flow-preview-layer="halo" d="${path}"></path>
105
+ <path vector-effect="non-scaling-stroke" data-prts-session-flow-preview-layer="body" d="${path}"></path>
106
+ <path vector-effect="non-scaling-stroke" data-prts-session-flow-preview-layer="core" d="${path}"></path>
96
107
  </g>`).join('')}
97
108
  </svg>`
98
109
  }
@@ -171,14 +182,14 @@ export function themeSettingsMarkup() {
171
182
  <section data-prts-settings-common data-prts-settings-section="interaction" role="tabpanel" id="prts-page-interaction" aria-labelledby="prts-tab-interaction" tabindex="0" hidden>
172
183
  <div data-prts-settings-common-grid>
173
184
  ${COMMON_CONTROLS.filter(([key]) => ['cursor', 'railDefaultHidden', 'motion'].includes(key)).map(([key, label, options]) => settingButtons(key, label, options)).join('')}
174
- <div data-prts-setting-row="cornerColor"><span><strong>交互提示框颜色</strong><small data-prts-setting-note>部分按钮和卡片的四角提示颜色,危险操作保持红色。</small></span><div data-prts-corner-presets role="group" aria-label="交互提示框预设颜色">${[['黄色','#f0c800'],['白色','#ffffff'],['青色','#65ddb0'],['蓝色','#6eb7f3'],['紫色','#b487ef']].map(([name,color]) => `<button type="button" data-prts-corner-preset="${color}" aria-label="${name}" title="${name}" aria-pressed="false" style="--swatch:${color}"><i aria-hidden="true"></i></button>`).join('')}<button type="button" data-prts-corner-custom title="选择自定义颜色"><i data-prts-corner-preview aria-hidden="true"></i><span>自定义</span></button><input type="color" data-prts-corner-color aria-label="交互提示框颜色" value="#f0c800" hidden></div></div>
185
+ <div data-prts-setting-row="cornerColor"><span><span data-prts-corner-heading><strong>交互提示框颜色</strong><span data-prts-corner-modes role="group" aria-label="编辑配色模式"><button type="button" data-prts-corner-mode="light" aria-pressed="false">☀ 日间</button><button type="button" data-prts-corner-mode="dark" aria-pressed="false">☾ 夜间</button></span><button type="button" data-prts-corner-sync role="switch" aria-checked="false" title="开启后,日间和夜间共用颜色"><span>日夜共用</span><i aria-hidden="true"></i></button></span><small data-prts-setting-note>部分按钮和卡片的四角提示颜色,危险操作保持红色。</small><small data-prts-setting-note>开启后,日夜都使用当前选定模式的配色,修改也保存到该模式;关闭后各用各的,另一套配色不变。</small></span><div data-prts-corner-presets role="group" aria-label="交互提示框预设颜色">${[['黄色','#f0c800'],['白色','#ffffff'],['青色','#65ddb0'],['蓝色','#6eb7f3'],['紫色','#b487ef']].map(([name,color]) => `<button type="button" data-prts-corner-preset="${color}" aria-label="${name}" title="${name}" aria-pressed="false" style="--swatch:${color}"><i aria-hidden="true"></i></button>`).join('')}<span data-prts-corner-picker><button type="button" data-prts-corner-custom aria-expanded="false" title="选择自定义颜色"><i data-prts-corner-preview aria-hidden="true"></i><span>自定义</span></button><input type="color" data-prts-corner-color aria-label="交互提示框颜色" value="#f0c800" tabindex="-1"><span data-prts-color-editor hidden role="group" aria-label="自定义提示框颜色"><span data-prts-color-editor-title>自定义颜色</span><span data-prts-color-sample></span><label data-prts-color-hex-label>HEX<input data-prts-color-hex aria-label="HEX 颜色" maxlength="7" spellcheck="false"></label><label data-prts-color-channel><span>R</span><input type="range" min="0" max="255" step="1" data-prts-color-channel-index="0" aria-label="R 通道"><output>0</output></label><label data-prts-color-channel><span>G</span><input type="range" min="0" max="255" step="1" data-prts-color-channel-index="1" aria-label="G 通道"><output>0</output></label><label data-prts-color-channel><span>B</span><input type="range" min="0" max="255" step="1" data-prts-color-channel-index="2" aria-label="B 通道"><output>0</output></label><span data-prts-color-actions><button type="button" data-prts-color-cancel>取消</button><button type="button" data-prts-color-apply>应用颜色</button></span></span></span></div></div>
175
186
  </div>
176
187
  </section>
177
188
  <section data-prts-settings-section="ai-tools" data-prts-ai-tools role="tabpanel" id="prts-page-ai-tools" aria-labelledby="prts-tab-ai-tools" tabindex="0" hidden>
178
189
  <div data-prts-preview-settings><div data-prts-preview-master>
179
- ${previewSetting('aiToolsEnabled', '允许 AI 定制主题', '默认开启 · 共 5 个工具 · 影响此 DSH 实例的所有会话')}
190
+ ${previewSetting('aiToolsEnabled', '允许 AI 定制主题', '默认开启 · 12 个定制工具,另有只读状态入口 · 影响此 DSH 实例的所有会话')}
180
191
  </div></div>
181
- <p>开启后,AI 可以通过工具调整粒子徽记和 Baker 头像。关闭后,后续模型请求不再提供这些工具,手动设置不受影响;已有对话中的工具记录不会删除。</p>
192
+ <p>开启后,AI 可以通过工具调整粒子徽记、Baker 头像和背景图片、保护区域、背景模式、遮罩与材质,以及导入、导出主题配置。关闭后停止提供定制工具,仅保留只读状态入口,提示 AI 请你手动开启开关,不自行改文件绕过。手动设置不受影响;已有对话中的工具记录不会删除。</p>
182
193
  <section>
183
194
  <h3>粒子徽记 <small>3 个工具</small></h3>
184
195
  <p>将会话中的图片制作成粒子徽记,用于新会话页面或正文轮换。</p>
@@ -200,6 +211,29 @@ export function themeSettingsMarkup() {
200
211
  <dt><code>prts_avatar_apply</code></dt><dd>替换用户或 AI 头像、基于现有原图重新裁剪,或恢复默认头像。</dd>
201
212
  </dl><p>单图最大 5 MB、2500 万像素;裁剪支持 1~4 倍缩放。修改立即影响此实例所有会话,不会自动切换会话样式。替换后不保留旧原图;恢复默认会删除自定义原图。</p></details>
202
213
  </section>
214
+ <section>
215
+ <h3>会话背景 <small>4 个工具</small></h3>
216
+ <p>使用上传、AI 生成或搜索下载的图片设置背景,也可以调整重要区域保护、背景模式、遮罩和材质。</p>
217
+ <ol><li>提供图片,或让具备相应工具的 AI 生成、寻找图片。</li><li>让 AI 将图片设为完整背景、左侧边栏背景或会话正文背景,再启用对应模式。</li></ol>
218
+ <blockquote>“找一张适合阅读的背景图,设为完整背景,尽量保住图中的人物。”</blockquote>
219
+ <details><summary>工具说明与限制</summary><dl>
220
+ <dt><code>prts_background_inspect</code></dt><dd>查看背景、模式、遮罩、材质及会话可用图片。</dd>
221
+ <dt><code>prts_background_preview</code></dt><dd>预览候选或已保存图片的日夜遮罩、裁剪效果,不修改主题;不包含气泡、文字和毛玻璃。</dd>
222
+ <dt><code>prts_background_apply</code></dt><dd>替换或移除图片,设置或清除保护区域。</dd>
223
+ <dt><code>prts_background_configure</code></dt><dd>切换默认、分区或完整背景,调整遮罩及五个区域的模糊度、底色不透明度。</dd>
224
+ </dl><p>支持 PNG、JPEG、WebP、AVIF,单图最大 10 MB、4000 万像素。图片在同一实例共享,模式、遮罩和材质在同一 profile 同步。替换或移除不保留旧图;切回默认背景会保留素材。图片列表按最近出现排序,并标记同名图片的最近版本。生成与搜索需要会话已有相应工具,下载或生成的本地图片可先用 read_image 读取,无需重新上传。工具不会自动启用主题总开关。</p></details>
225
+ </section>
226
+ <section>
227
+ <h3>主题导入与导出 <small>3 个工具</small></h3>
228
+ <p>在会话中导出主题文件,或上传主题文件,让 AI 先检查内容再导入。</p>
229
+ <blockquote>“导出当前主题,包含素材。” “检查这个主题文件,告诉我会覆盖哪些设置。”</blockquote>
230
+ <details><summary>工具说明与限制</summary><dl>
231
+ <dt><code>prts_theme_export</code></dt><dd>导出可下载的主题附件,默认包含背景、头像和徽记,也可仅导出设置。</dd>
232
+ <dt><code>prts_theme_import_preview</code></dt><dd>预检文件,列出配置变化、素材覆盖范围和无法保留的字段,不修改主题。</dd>
233
+ <dt><code>prts_theme_import</code></dt><dd>使用预检结果导入,先保存备份附件;失败时尝试回退已写入的素材。</dd>
234
+ </dl><p>与设置界面文件互通,最大 160 MB。完整导入会清理被替换的旧背景和头像,并为自定义徽记生成新草稿;草稿约 200 份时清理较旧且未受保护的记录,正在使用的徽记受保护。预检 15 分钟内有效,仅限当前会话,配置发生变化后需重新预检。导入可能关闭主题或 AI 工具,请先核对变化;需要恢复时可重新导入备份文件。</p></details>
235
+ </section>
236
+ <details><summary>工具关闭时的提示</summary><p><code>prts_theme_status</code> 始终可用,仅报告开关状态和手动开启位置,不读取素材、不修改配置。AI 应先检查此状态;关闭时先提醒你开启,不通过脚本、接口或文件编辑绕过开关。</p></details>
203
237
  </section>
204
238
  <section data-prts-settings-section="assistance" role="tabpanel" id="prts-page-assistance" aria-labelledby="prts-tab-assistance" tabindex="0" hidden>
205
239
  <section data-prts-preview-settings>
@@ -285,6 +319,35 @@ export function createThemeSettingsOverlay({
285
319
  const nativeModal = typeof panel?.showModal === 'function' && typeof panel?.close === 'function'
286
320
  let isOpen = false
287
321
  let previousFocus
322
+ let colorDraft = '#f0c800'
323
+ function closeColorEditor(restoreFocus = false) {
324
+ const editor = panel?.querySelector('[data-prts-color-editor]')
325
+ if (!editor || editor.hidden) return
326
+ editor.hidden = true
327
+ panel.querySelector('[data-prts-corner-custom]').setAttribute('aria-expanded', 'false')
328
+ onPreferencePreview(cornerKey(), currentPreferences[cornerKey()])
329
+ if (restoreFocus) panel.querySelector('[data-prts-corner-custom]').focus()
330
+ }
331
+ function renderColorDraft() {
332
+ const editor = panel.querySelector('[data-prts-color-editor]')
333
+ editor.querySelector('[data-prts-color-sample]').style.background = colorDraft
334
+ const hex = editor.querySelector('[data-prts-color-hex]')
335
+ hex.value = colorDraft.toUpperCase()
336
+ hex.removeAttribute('aria-invalid')
337
+ const channels = [1, 3, 5].map(start => parseInt(colorDraft.slice(start, start + 2), 16))
338
+ for (const slider of editor.querySelectorAll('[data-prts-color-channel-index]')) {
339
+ const index = Number(slider.dataset.prtsColorChannelIndex)
340
+ slider.value = channels[index]
341
+ slider.nextElementSibling.value = channels[index]
342
+ const end = [...channels], start = [...channels]
343
+ start[index] = 0; end[index] = 255
344
+ slider.style.background = 'linear-gradient(to right,rgb(' + start.join(',') + '),rgb(' + end.join(',') + '))'
345
+ }
346
+ }
347
+ let cornerEditMode = 'dark'
348
+ const cornerMode = () => currentPreferences?.cornerColorLinked ? currentPreferences.cornerColorSource : cornerEditMode
349
+ const cornerKey = () => cornerMode() === 'light' ? 'cornerColorLight' : 'cornerColor'
350
+ const cornerCustomKey = () => cornerMode() === 'light' ? 'cornerCustomColorLight' : 'cornerCustomColor'
288
351
  let currentPreferences
289
352
  let currentStatus
290
353
  let currentPersistence = { phase: 'idle', revision: 0 }
@@ -342,6 +405,8 @@ export function createThemeSettingsOverlay({
342
405
  const value = Boolean(next)
343
406
  if (value === isOpen) return isOpen
344
407
  if (!value) {
408
+ closeColorEditor()
409
+ if (currentPreferences) onPreferencePreview(cornerKey(), currentPreferences[cornerKey()])
345
410
  commitParticleTraversalSpeed()
346
411
  commitSessionFlowSpeed()
347
412
  commitSessionFlowColors()
@@ -351,6 +416,8 @@ export function createThemeSettingsOverlay({
351
416
  document.documentElement.toggleAttribute('data-prts-settings-open', isOpen)
352
417
  panel.setAttribute('aria-hidden', String(!isOpen))
353
418
  if (isOpen) {
419
+ cornerEditMode = document.documentElement.getAttribute('data-prts-scheme') === 'light' ? 'light' : 'dark'
420
+ renderState()
354
421
  previousFocus = document.activeElement
355
422
  onOpen()
356
423
  showPanel()
@@ -532,10 +599,18 @@ export function createThemeSettingsOverlay({
532
599
 
533
600
  function renderState() {
534
601
  if (!panel || !currentPreferences) return
602
+ panel.querySelector('[data-prts-corner-modes]').hidden = currentPreferences.cornerColorLinked
603
+ panel.querySelector('[data-prts-corner-sync]').setAttribute('aria-checked', String(currentPreferences.cornerColorLinked))
604
+ for (const mode of panel.querySelectorAll('[data-prts-corner-mode]')) mode.setAttribute('aria-pressed', String(mode.dataset.prtsCornerMode === cornerEditMode))
535
605
  const cornerColor = panel.querySelector('[data-prts-corner-color]')
536
- if (cornerColor) cornerColor.value = currentPreferences.cornerCustomColor
537
- panel.querySelector('[data-prts-corner-preview]')?.style.setProperty('--swatch', currentPreferences.cornerCustomColor)
538
- for (const preset of panel.querySelectorAll('[data-prts-corner-preset]')) preset.setAttribute('aria-pressed', String(preset.dataset.prtsCornerPreset === currentPreferences.cornerColor))
606
+ if (cornerColor) cornerColor.value = currentPreferences[cornerCustomKey()]
607
+ panel.querySelector('[data-prts-corner-preview]')?.style.setProperty('--swatch', currentPreferences[cornerCustomKey()])
608
+ const customButton = panel.querySelector('[data-prts-corner-custom]')
609
+ const customApplied = currentPreferences[cornerKey()] === currentPreferences[cornerCustomKey()]
610
+ customButton.setAttribute('aria-pressed', String(customApplied))
611
+ customButton.title = customApplied ? '自定义颜色已应用,点击编辑' : '选择自定义颜色'
612
+ customButton.querySelector('span').textContent = '自定义'
613
+ for (const preset of panel.querySelectorAll('[data-prts-corner-preset]')) preset.setAttribute('aria-pressed', String(preset.dataset.prtsCornerPreset === currentPreferences[cornerKey()]))
539
614
  for (const toggle of panel.querySelectorAll('[data-prts-setting-switch]')) {
540
615
  const key = toggle.dataset.prtsSettingSwitch
541
616
  const enabled = key === 'cursor' ? currentPreferences.cursor === 'prts'
@@ -544,6 +619,10 @@ export function createThemeSettingsOverlay({
544
619
  toggle.setAttribute('aria-checked', String(enabled))
545
620
  toggle.classList.toggle('is-active', enabled)
546
621
  }
622
+ const cursorSelect = panel.querySelector('[data-prts-cursor-mode]')
623
+ if (cursorSelect) cursorSelect.value = currentPreferences.cursor
624
+ const cursorDescription = panel.querySelector('[data-prts-cursor-description]')
625
+ if (cursorDescription) cursorDescription.textContent = CURSOR_DESCRIPTIONS[currentPreferences.cursor]
547
626
  const replyLimitSelect = panel.querySelector('[data-prts-preview-reply-limit]')
548
627
  if (replyLimitSelect) replyLimitSelect.value = String(currentPreferences.previewReplyLimit)
549
628
  const replyLimitRow = panel.querySelector('[data-prts-setting-row="previewReplyLimit"]')
@@ -628,10 +707,29 @@ export function createThemeSettingsOverlay({
628
707
 
629
708
  function onPanelClick(event) {
630
709
  if (event.target === panel) { setOpen(false); return }
710
+ if (!event.target?.closest?.('[data-prts-corner-picker]')) closeColorEditor()
631
711
  const target = event.target?.closest?.('button')
632
712
  if (!target) return
633
- if (target.hasAttribute('data-prts-corner-custom')) { panel.querySelector('[data-prts-corner-color]').click(); return }
634
- if (target.hasAttribute('data-prts-corner-preset')) { onPreferenceChange('cornerColor', target.dataset.prtsCornerPreset); return }
713
+ if (target.hasAttribute('data-prts-corner-mode')) { onPreferencePreview(cornerKey(), currentPreferences[cornerKey()]); cornerEditMode = target.dataset.prtsCornerMode; renderState(); return }
714
+ if (target.hasAttribute('data-prts-corner-sync')) { if (currentPreferences.cornerColorLinked) cornerEditMode = currentPreferences.cornerColorSource; onPreferenceChange('cornerColorLink', currentPreferences.cornerColorLinked ? false : cornerEditMode); return }
715
+ if (target.hasAttribute('data-prts-color-cancel')) { closeColorEditor(true); return }
716
+ if (target.hasAttribute('data-prts-color-apply')) {
717
+ if (panel.querySelector('[data-prts-color-hex]').getAttribute('aria-invalid') === 'true') return
718
+ onPreferenceChange(cornerCustomKey(), colorDraft); closeColorEditor(true); return
719
+ }
720
+ if (target.hasAttribute('data-prts-corner-custom')) {
721
+ const editor = panel.querySelector('[data-prts-color-editor]')
722
+ if (!editor.hidden) { closeColorEditor(); return }
723
+ colorDraft = currentPreferences[cornerCustomKey()]
724
+ renderColorDraft(); editor.hidden = false
725
+ editor.removeAttribute('data-open-below')
726
+ const scroller = panel.querySelector('[data-prts-theme-settings-scroll]')
727
+ if (editor.getBoundingClientRect().top < (scroller?.getBoundingClientRect().top ?? 0)) editor.setAttribute('data-open-below', '')
728
+ target.setAttribute('aria-expanded', 'true')
729
+ editor.querySelector('[data-prts-color-hex]').focus()
730
+ return
731
+ }
732
+ if (target.hasAttribute('data-prts-corner-preset')) { onPreferenceChange(cornerKey(), target.dataset.prtsCornerPreset); return }
635
733
  if (target.hasAttribute('data-prts-settings-jump')) {
636
734
  selectSection(target.dataset.prtsSettingsJump)
637
735
  return
@@ -670,7 +768,20 @@ export function createThemeSettingsOverlay({
670
768
 
671
769
  function onPanelInput(event) {
672
770
  const target = event.target
673
- if (target?.matches?.('[data-prts-corner-color]')) { onPreferencePreview('cornerColor', target.value); return }
771
+ if (target?.matches?.('[data-prts-color-channel-index], [data-prts-color-hex]')) {
772
+ if (target.hasAttribute('data-prts-color-hex')) {
773
+ const value = target.value.trim()
774
+ if (!/^#?[0-9a-f]{6}$/i.test(value)) { target.setAttribute('aria-invalid', 'true'); return }
775
+ colorDraft = (value.startsWith('#') ? value : '#' + value).toLowerCase()
776
+ } else {
777
+ const channels = [...panel.querySelectorAll('[data-prts-color-channel-index]')].map(input => Number(input.value).toString(16).padStart(2, '0'))
778
+ colorDraft = '#' + channels.join('')
779
+ }
780
+ renderColorDraft()
781
+ onPreferencePreview(cornerKey(), colorDraft)
782
+ return
783
+ }
784
+ if (target?.matches?.('[data-prts-corner-color]')) { onPreferencePreview(cornerKey(), target.value); return }
674
785
  if (target?.matches?.('[data-prts-preview-reply-limit]')) { onPreferenceChange('previewReplyLimit', Number(target.value)); return }
675
786
  if (target?.matches?.('input[data-prts-setting-color]')) {
676
787
  const key = target.dataset.prtsSettingKey
@@ -713,7 +824,7 @@ export function createThemeSettingsOverlay({
713
824
  selectSection(tabs[next].dataset.prtsSettingsJump, true)
714
825
  return
715
826
  }
716
- if (event.key === 'Escape') { event.preventDefault(); setOpen(false); return }
827
+ if (event.key === 'Escape') { event.preventDefault(); if (!panel.querySelector('[data-prts-color-editor]').hidden) { event.stopPropagation(); closeColorEditor(true) } else setOpen(false); return }
717
828
  if (nativeModal || event.key !== 'Tab') return
718
829
  const nodes = focusable()
719
830
  if (!nodes.length) return
@@ -733,7 +844,8 @@ export function createThemeSettingsOverlay({
733
844
  const renderMediaState = () => renderState()
734
845
  const commitFromControl = event => {
735
846
  const target = event.target
736
- if (target?.matches?.('[data-prts-corner-color]')) { if (event.type === 'change') onPreferenceChange('cornerCustomColor', target.value); return }
847
+ if (target?.matches?.('[data-prts-cursor-mode]')) { if (event.type === 'change') onPreferenceChange('cursor', target.value); return }
848
+ if (target?.matches?.('[data-prts-corner-color]')) { if (event.type === 'change') onPreferenceChange(cornerCustomKey(), target.value); return }
737
849
  if (target?.matches?.('input[data-prts-setting-color]')) {
738
850
  if (event.type === 'change' || event.type === 'focusout') commitSessionFlowColor(target.dataset.prtsSettingKey)
739
851
  return
@@ -743,6 +855,7 @@ export function createThemeSettingsOverlay({
743
855
  if (target.dataset.prtsSettingKey === 'sessionFlowSpeed') commitSessionFlowSpeed()
744
856
  }
745
857
 
858
+ const pageControls = mountPageControls({panel,window})
746
859
  trigger?.addEventListener('click', toggle)
747
860
  panel?.addEventListener('click', onPanelClick)
748
861
  selectSection('appearance')
@@ -782,6 +895,7 @@ export function createThemeSettingsOverlay({
782
895
  close: () => setOpen(false),
783
896
  toggle,
784
897
  dispose() {
898
+ pageControls.dispose()
785
899
  transferEditor.dispose()
786
900
  customEditor.dispose()
787
901
  avatarEditor.dispose()
@@ -101,7 +101,7 @@ export function describeThemePackage(value) {
101
101
  return `${count} 项主题设置、${transferSlots.filter(k => a.backgrounds[k]).length} 张背景、${transferRoles.filter(k => a.avatars[k]).length} 个头像、${a.emblems.items.length} 个自定义徽记。将替换当前素材及徽记排列,空项恢复为默认。`
102
102
  }
103
103
 
104
- export function createThemeTransfer({ window, getPreferences, savePreferences, checkWritable, checkExport = checkWritable, themeVersion, onAvatarConfiguration = () => {} }) {
104
+ export function createThemeTransfer({ window, getPreferences, savePreferences, checkWritable, checkExport = checkWritable, themeVersion, onAvatarConfiguration = () => {}, onDownload, readImage = (blob, signal) => readTransferFile(window, blob, signal, undefined, true), backupNotice = '导入前备份已发起下载,可重新导入恢复。' }) {
105
105
  let busy = false, backup
106
106
  const request = async (path, data, signal) => {
107
107
  checkTransferCancelled(signal)
@@ -128,7 +128,7 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
128
128
  if (!/^[a-f0-9-]{36}$/.test(entry.id)) throw Error('背景引用无效')
129
129
  const response = await window.fetch('/prts-theme-api/backgrounds/image/' + entry.id, { credentials: 'same-origin', signal })
130
130
  if (!response.ok) throw Error('无法导出背景图片')
131
- backgrounds[slot] = await readTransferFile(window, await response.blob(), signal, undefined, true)
131
+ backgrounds[slot] = await readImage(await response.blob(), signal)
132
132
  }
133
133
  }
134
134
  progress({ value: 0.35, label: '正在读取头像…' })
@@ -169,6 +169,7 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
169
169
  try { return await fn() } finally { busy = false }
170
170
  }
171
171
  function download(value, name = 'theme') {
172
+ if (onDownload) return onDownload(value, name)
172
173
  const blob = new window.Blob([JSON.stringify(value)], { type: 'application/json' })
173
174
  if (blob.size > THEME_PACKAGE_LIMIT) throw Error('主题文件超过 160 MB,请减少素材后重试')
174
175
  const url = window.URL.createObjectURL(blob), link = window.document.createElement('a')
@@ -185,7 +186,7 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
185
186
  progress({ value: 0.3, label: '正在生成导入前备份…' })
186
187
  await new Promise(resolve => window.setTimeout(resolve, 0))
187
188
  checkTransferCancelled(signal)
188
- download(backup, 'before-import')
189
+ await download(backup, 'before-import')
189
190
  let completed = 0
190
191
  const total = value.assets ? value.assets.emblems.items.length + 7 : 1
191
192
  const step = label => progress({ value: 0.35 + 0.65 * completed / total, label })
@@ -229,7 +230,7 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
229
230
  let failures = 0
230
231
  for (const restore of undo.reverse()) { try { await restore() } catch { failures++ } }
231
232
  if (signal?.aborted && !failures && error.name === 'AbortError') throw Error('操作已取消,已回退已写入的素材。')
232
- throw Error(`${error.message}。${failures ? '部分素材未能自动恢复。' : '已回退已确认写入的素材。'}若连接中断,请检查实际状态;导入前备份已发起下载,可重新导入恢复。`)
233
+ throw Error(`${error.message}。${failures ? '部分素材未能自动恢复。' : '已回退已确认写入的素材。'}若连接中断,请检查实际状态;${backupNotice}`)
233
234
  }
234
235
  }
235
236
  return {
package/src/index.js CHANGED
@@ -5,6 +5,10 @@ import { createAvatarService, registerAvatarApi } from './server/avatars.js'
5
5
  import { join } from 'node:path'
6
6
  import { homedir } from 'node:os'
7
7
  import { createEmblemService } from './server/emblems.js'
8
+ import { themeToolStatus, THEME_TOOL_POLICY, ENABLE_THEME_TOOLS } from './server/theme-tool-status.js'
9
+ import { transferTools } from './server/transfer-tools.js'
10
+ import { readFile } from 'node:fs/promises'
11
+ import { backgroundTools } from './server/background-tools.js'
8
12
  import { avatarTools } from './server/avatar-tools.js'
9
13
  import { emblemTools } from './server/tools.js'
10
14
  import { registerEmblemApi } from './server/api.js'
@@ -12,9 +16,10 @@ export const PACKAGE_ID = '@ash-qw/dsh-theme-prts'
12
16
  export const THEME_PLUGIN_ID = 'dsh-theme-prts'
13
17
  export const inject = ['tools', 'attachments', 'connection', 'webServer']
14
18
 
19
+ // Leave new palette fields absent until normalization can inherit the legacy color.
15
20
  export const Config = Schema.object(Object.fromEntries([
16
21
  ...Object.entries(DEFAULT_PREFERENCES).map(([key, value]) => [key,
17
- (typeof value === 'boolean' ? Schema.boolean() : typeof value === 'number' ? Schema.number() : Schema.string()).default(value).volatile(),
22
+ (typeof value === 'boolean' ? Schema.boolean() : typeof value === 'number' ? Schema.number() : Schema.string()).default(['cornerColorLight', 'cornerCustomColorLight', 'cornerColorSource'].includes(key) ? undefined : value).volatile(),
18
23
  ]),
19
24
  ['preferencesInitialized', Schema.boolean().default(false).volatile()],
20
25
  ]))
@@ -27,12 +32,29 @@ export async function apply(ctx, config = {}) {
27
32
  const service=await createEmblemService(directory)
28
33
  const backgrounds=await createBackgroundService(join(directory,'..','backgrounds'))
29
34
  const avatars=await createAvatarService(join(directory,'..','avatars'))
30
- const definitions=[...emblemTools(service,ctx.attachments),...avatarTools(avatars,ctx.attachments)]
35
+ const themeVersion=JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8')).version
36
+ const definitions=[...emblemTools(service,ctx.attachments),...avatarTools(avatars,ctx.attachments),...backgroundTools(backgrounds,ctx.attachments,ctx),...transferTools({backgrounds,avatars,emblems:service,attachments:ctx.attachments,host:ctx,themeVersion})]
37
+ const toolsEnabled=()=>(config.aiToolsEnabled?.get?.() ?? config.aiToolsEnabled) !== false
38
+ const disposeStatus=ctx.tools.register(themeToolStatus(toolsEnabled))
39
+ ctx.inject(['systemPrompt'], promptCtx=>{
40
+ if(typeof promptCtx.systemPrompt?.context!=='function')return
41
+ promptCtx.effect(()=>promptCtx.systemPrompt.context({
42
+ name:'prts-theme-tools:'+ (ctx.fiber?.entry?.id ?? THEME_PLUGIN_ID),
43
+ order:1500,required:true,interpolate:false,
44
+ text:()=> 'PRTS AI 定制工具当前'+(toolsEnabled()?'已开启。':'已关闭。'+ENABLE_THEME_TOOLS)+' '+THEME_TOOL_POLICY,
45
+ }))
46
+ })
31
47
  let toolDisposers=[]
32
48
  const syncTools=()=>{
33
- const enabled=(config.aiToolsEnabled?.get?.() ?? config.aiToolsEnabled) !== false
49
+ const enabled=toolsEnabled()
34
50
  if(enabled && !toolDisposers.length) {
35
- for(const tool of definitions)toolDisposers.push(ctx.tools.register(tool))
51
+ for(const tool of definitions)toolDisposers.push(ctx.tools.register({...tool,
52
+ description:tool.description+' '+THEME_TOOL_POLICY,
53
+ execute:(args,exec)=>{
54
+ if(!toolsEnabled())throw Error('PRTS AI 定制工具已关闭。'+ENABLE_THEME_TOOLS+' '+THEME_TOOL_POLICY)
55
+ return tool.execute(args,exec)
56
+ },
57
+ }))
36
58
  } else if(!enabled) {
37
59
  for(const dispose of toolDisposers.splice(0).reverse())dispose()
38
60
  }
@@ -42,7 +64,7 @@ export async function apply(ctx, config = {}) {
42
64
  const stopConfig=ctx.on('app-boot/config-reload',syncTools)
43
65
  const disposers=[registerEmblemApi(ctx,service),registerAvatarApi(ctx,avatars),registerBackgroundApi(ctx,backgrounds)]
44
66
  ctx.effect(()=>async()=>{
45
- stopConfig()
67
+ stopConfig();disposeStatus()
46
68
  for(const dispose of toolDisposers.splice(0).reverse())dispose()
47
69
  for(const dispose of disposers.reverse())dispose()
48
70
  await service.dispose();await backgrounds.dispose()
@@ -43,7 +43,7 @@ export function avatarTools(service,attachments){
43
43
  const ref=images.find(image=>image.attachmentId===args.attachmentId)
44
44
  if(!ref)throw fault(404,'当前会话没有这张图片,请先上传或用 read_image 读取图片,再 inspect')
45
45
  if(ref.bytes>limit)throw fault(400,'图片超过 5 MB')
46
- const {attachmentKind,origin,toolName,...attachment}=ref
46
+ const {attachmentKind,origin,toolName,lastSeenOrder,lastSeenAt,latestForName,...attachment}=ref
47
47
  let bytes
48
48
  if(attachmentKind==='file'){
49
49
  const chunks=[];let size=0
@@ -0,0 +1,100 @@
1
+ import sharp from 'sharp'
2
+ import { sessionImages } from './tools.js'
3
+ import { fault } from './emblems.js'
4
+ import { prepareBackgroundImage } from './backgrounds.js'
5
+ import { backgroundPlacement, validBackgroundRegion } from '../shared/background-region.js'
6
+
7
+ // Keep these paired with the root light/dark canvas tokens in prts.css.
8
+ export const BACKGROUND_CANVAS={dark:'#080a0c',light:'#eef1f3'}
9
+ export async function readBackgroundAttachment(attachments,images,id,exec){
10
+ const ref=images.find(image=>image.attachmentId===id),limit=10*1024*1024
11
+ if(!ref)throw fault(404,'当前会话没有这张图片,请先上传或 read_image 读取生成/下载的图片,再 inspect')
12
+ if(ref.bytes>limit)throw fault(400,'图片超过 10 MB')
13
+ const {attachmentKind,origin,toolName,lastSeenOrder,lastSeenAt,latestForName,...attachment}=ref
14
+ let bytes
15
+ if(attachmentKind==='file'){
16
+ const chunks=[];let size=0
17
+ for await(const chunk of attachments.readFileStream(attachment,exec.signal)){
18
+ exec.signal?.throwIfAborted()
19
+ size+=chunk.length;if(size>limit)throw fault(400,'图片超过 10 MB');chunks.push(chunk)
20
+ }
21
+ bytes=Buffer.concat(chunks)
22
+ }else bytes=(await attachments.readImage(attachment,exec.signal)).data
23
+ exec.signal?.throwIfAborted()
24
+ if(bytes.length>limit)throw fault(400,'图片超过 10 MB')
25
+ return Buffer.from(bytes)
26
+ }
27
+ export async function renderBackgroundPreview(bytes,{width,height,scheme,dim,region}){
28
+ const metadata=await sharp(bytes).metadata(),sw=metadata.width,sh=metadata.height
29
+ const placement=backgroundPlacement(sw,sh,width,height,region)
30
+ const scale=placement.width/sw
31
+ // Crop before resizing to bound memory even for extremely wide/tall images.
32
+ const left=Math.max(0,Math.min(sw-1,Math.round(-placement.x/scale)))
33
+ const top=Math.max(0,Math.min(sh-1,Math.round(-placement.y/scale)))
34
+ const crop={left,top,width:Math.max(1,Math.min(sw-left,Math.round(width/scale))),height:Math.max(1,Math.min(sh-top,Math.round(height/scale)))}
35
+ const canvas=BACKGROUND_CANVAS[scheme]
36
+ const image=await sharp(bytes).extract(crop).resize(width,height,{fit:'fill'}).flatten({background:canvas}).toColourspace('srgb').png().toBuffer()
37
+ // Match CSS color-mix(in srgb); do not blend in linear-light space.
38
+ const rgb={r:parseInt(canvas.slice(1,3),16),g:parseInt(canvas.slice(3,5),16),b:parseInt(canvas.slice(5,7),16),alpha:dim}
39
+ const overlay=await sharp({create:{width,height,channels:4,background:rgb}}).png().toBuffer()
40
+ return {data:await sharp(image).composite([{input:overlay,blend:'over'}]).png().toBuffer(),clipped:placement.clipped,crop,canvas}
41
+ }
42
+ export function backgroundPreviewTool(service,attachments,readSettings){
43
+ return {
44
+ name:'prts_background_preview',
45
+ description:'预览 PRTS 背景的裁剪与遮罩合成,不应用、不修改主题。指定 slot 预览已保存背景,或 attachmentId 预览会话候选图片(二选一);候选图先按实际保存规则转正、缩放并转 WebP。dim 默认使用当前背景遮罩,可临时覆盖;scheme 默认为 both,同时生成日间/夜间图,因为服务端不能判断用户页面当前日夜模式。可指定预览宽高和归一化保护区域,默认使用已保存图片的保护区域。返回预览附件、尺寸、底色、遮罩、裁剪信息;这是背景层预览,不含消息泡、文字、侧栏毛玻璃等界面材质,不是实际页面截图。必须查看预览才能评价视觉效果;预览图不能再作为原始背景素材。修改 dim 后可再次预览。',
46
+ parameters:{type:'object',additionalProperties:false,properties:{
47
+ slot:{type:'string',enum:['full','sidebar','conversation']},attachmentId:{type:'string'},
48
+ dim:{type:'number',minimum:0,maximum:.9},scheme:{type:'string',enum:['dark','light','both']},
49
+ width:{type:'integer',minimum:64,maximum:1920},height:{type:'integer',minimum:64,maximum:1920},
50
+ region:{anyOf:[{type:'null'},{type:'object',additionalProperties:false,properties:{
51
+ x:{type:'number',minimum:0,maximum:1},y:{type:'number',minimum:0,maximum:1},
52
+ width:{type:'number',exclusiveMinimum:0,maximum:1},height:{type:'number',exclusiveMinimum:0,maximum:1},
53
+ },required:['x','y','width','height']}]},
54
+ }},
55
+ output:{schema:{type:'object'},render:(_args,value)=>[
56
+ {type:'text',text:JSON.stringify(value)},
57
+ ...value.previews.flatMap(p=>[{type:'file',attachment:p.file},...(value.imageForModel?[{type:'image',attachment:p.image}]:[])]),
58
+ ]},
59
+ isConcurrencySafe:()=>false,
60
+ async execute(args,exec){
61
+ exec.signal?.throwIfAborted()
62
+ const images=sessionImages(exec.agent)
63
+ if(Boolean(args.slot)===Boolean(args.attachmentId))throw fault(400,'请选择已保存背景 slot 或候选 attachmentId,二选一')
64
+ if(args.slot && !['full','sidebar','conversation'].includes(args.slot))throw fault(400,'背景区域无效')
65
+ const configuration=service.snapshot(),settings=readSettings()
66
+ const scheme=args.scheme ?? 'both',dim=args.dim ?? settings.dim
67
+ const width=args.width ?? (args.slot==='sidebar'?360:1280),height=args.height ?? (args.slot==='sidebar'?900:720)
68
+ if(!['dark','light','both'].includes(scheme)||!Number.isFinite(dim)||dim<0||dim>.9)throw fault(400,'请指定有效日夜模式和 0..0.9 遮罩;设置不可读时须显式指定 dim')
69
+ if(![width,height].every(v=>Number.isInteger(v)&&v>=64&&v<=1920))throw fault(400,'预览宽高须为 64..1920 的整数')
70
+ const region=Object.hasOwn(args,'region')?args.region:args.slot?configuration[args.slot]?.region ?? null:null
71
+ if(!validBackgroundRegion(region))throw fault(400,'背景保护区域无效')
72
+ let bytes
73
+ if(args.slot){
74
+ const entry=configuration[args.slot]
75
+ if(!entry)throw fault(400,'此区域尚未设置图片')
76
+ bytes=await service.image(entry.id)
77
+ }else bytes=(await prepareBackgroundImage(await readBackgroundAttachment(attachments,images,args.attachmentId,exec))).data
78
+ let imageForModel=false
79
+ const llm=exec.agent.ctx?.get?.('llm'),routed=exec.agent.session.requestHeader?.()?.config
80
+ const provider=routed?.provider ?? exec.agent.options?.provider,model=routed?.model ?? exec.agent.options?.model
81
+ if(llm && provider && model)imageForModel=(await llm.resolveModelInfo(provider,model,exec.signal)).inputModalities?.includes('image')===true
82
+ const previews=[]
83
+ for(const mode of scheme==='both'?['dark','light']:[scheme]){
84
+ exec.signal?.throwIfAborted()
85
+ const {data,...details}=await renderBackgroundPreview(bytes,{width,height,scheme:mode,dim,region})
86
+ exec.signal?.throwIfAborted()
87
+ const name=`prts-background-preview-${mode}.png`,file=await attachments.saveFile({data,name})
88
+ const image=imageForModel?await attachments.saveImage({data,mediaType:'image/png',name}):undefined
89
+ const hostPath=attachments.fileHostPath?.(file),fs=exec.agent.ctx?.get?.('fs')
90
+ const previewPath=hostPath?fs?.processPathFromHostPath(hostPath):undefined
91
+ previews.push({scheme:mode,width,height,dim,region,...details,file,...(image?{image}:{}),
92
+ ...(previewPath?{previewPath,previewMarkdown:`![${mode==='dark'?'夜间':'日间'}背景预览](<${previewPath}>)`}:{})})
93
+ }
94
+ exec.signal?.throwIfAborted()
95
+ if(args.slot && service.snapshot().revision!==configuration.revision || args.dim===undefined && readSettings().revision!==settings.revision)throw fault(409,'背景或遮罩已变化,请重新预览')
96
+ return {applied:false,imageForModel,revision:configuration.revision,settingsRevision:settings.revision,
97
+ note:'仅模拟背景铺满裁剪、保护区域和遮罩;裁剪按源图像素取整,不包含正文、气泡、毛玻璃。',previews}
98
+ },
99
+ }
100
+ }