@ash-qw/dsh-theme-prts 2.1.1 → 2.3.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.
@@ -976,7 +976,7 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
976
976
 
977
977
  return {
978
978
  start(preferences = {}) {
979
- const keys = ['sessionPreview','previewOngoing','previewWarning','previewDone','previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']
979
+ const keys = ['sessionPreview','previewOngoing','previewTools','previewWarning','previewDone','previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']
980
980
  if (keys.some(key => previewPreferences[key] !== preferences[key])) preview.stop()
981
981
  previewPreferences = Object.fromEntries(keys.map(key => [key, preferences[key]]))
982
982
  if (!document) return
@@ -72,7 +72,7 @@ function resolveScheme(value) {
72
72
  return identity.includes('dark') || identity.includes('night') ? 'dark' : 'light'
73
73
  }
74
74
 
75
- export function createThemeController({ document, window, cssText, service, cursorAssets, onTransitionStateChange = () => {} }) {
75
+ export function createThemeController({ document, window, cssText, service, cursorAssets, onTransitionStateChange = () => {}, onSchemeChange = () => {} }) {
76
76
  if (!document || !window) {
77
77
  const gesture = { update() {}, finish() {}, cancel() {} }
78
78
  return { apply() {}, sync() {}, refresh() {}, setTheme() {}, beginThemeTransition() { return gesture }, toggle() {}, dispose() {} }
@@ -153,6 +153,7 @@ export function createThemeController({ document, window, cssText, service, curs
153
153
  if (disposed) return
154
154
  const next = resolveScheme(value)
155
155
  root.dataset.prtsScheme = next
156
+ onSchemeChange(next)
156
157
  return next
157
158
  }
158
159
 
@@ -1,3 +1,4 @@
1
+ import { themeTransferMarkup, mountThemeTransfer } from './theme-transfer-panel.js'
1
2
  import { backgroundMarkup, mountBackgroundSettings } from './custom-background.js'
2
3
  import { avatarMarkup, mountAvatarSettings } from './custom-avatar.js'
3
4
  import { customEmblemMarkup, mountCustomEmblemEditor } from './custom-emblem.js'
@@ -38,7 +39,9 @@ function previewSetting(key, label, description) {
38
39
  }
39
40
 
40
41
  function settingButtons(key, label, options) {
41
- const note = key === 'composerGlass'
42
+ const note = key === 'heroContrast'
43
+ ? '<small data-prts-setting-note>为新会话徽记添加柔光衬底,使它在背景图片上更清晰。</small>'
44
+ : key === 'composerGlass'
42
45
  ? '<small data-prts-setting-note>增强输入框的背景模糊与轻微折射,关闭时使用原有材质</small>'
43
46
  : key === 'bakerLoading'
44
47
  ? '<small data-prts-setting-note>每轮正文生成时显示三点动画,完成后显示全文;中断时保留已收到的内容</small>'
@@ -59,7 +62,7 @@ function settingButtons(key, label, options) {
59
62
  : key === 'sessionFlowPalette'
60
63
  ? '<small data-prts-setting-note>同时适配日间与夜间模式</small>'
61
64
  : ''
62
- if (['composerGlass', 'bakerLoading', 'motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'previewOngoing', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
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}">
63
66
  <span><strong>${label}</strong>${note}</span>
64
67
  <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>
65
68
  </div>`
@@ -127,15 +130,17 @@ export function themeSettingsMarkup() {
127
130
  <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
131
  <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>
129
132
  <button type="button" data-prts-settings-jump="assistance" role="tab" id="prts-tab-assistance" aria-controls="prts-page-assistance" aria-selected="false" tabindex="-1">辅助功能</button>
133
+ <button type="button" data-prts-settings-jump="ai-tools" role="tab" id="prts-tab-ai-tools" aria-controls="prts-page-ai-tools" aria-selected="false" tabindex="-1">AI 工具</button>
130
134
  </nav>
131
135
  <footer data-prts-settings-actions aria-label="全局主题设置操作">
132
136
  <div data-prts-persistence-error hidden role="alert">
133
137
  <span><strong>设置未保存</strong><small>未能保存到 DSH,可能存在连接中断、配置冲突或当前访问不支持持久保存。重试将重新提交本次修改。</small></span>
134
138
  <button type="button" data-prts-retry-save>重试保存</button>
135
139
  </div>
140
+ <button type="button" data-prts-transfer-open data-prts-footer-action aria-haspopup="dialog" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6 14V3m-3 3 3-3 3 3M14 6v11m-3-3 3 3 3-3"/></svg><span>导入与导出</span></button>
136
141
  <div data-prts-reset-zone>
137
142
 
138
- <button type="button" data-prts-reset-visual title="恢复全部主题设置" aria-label="恢复全部主题设置">恢复默认</button>
143
+ <button type="button" data-prts-reset-visual data-prts-footer-action title="恢复全部主题设置" aria-label="恢复全部主题设置"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 7a6.5 6.5 0 1 1-.4 5M4 3v4h4"/></svg><span>恢复默认</span></button>
139
144
  <div data-prts-reset-confirm hidden role="group" aria-label="确认恢复全部主题设置">
140
145
  <span>重置全部分类的主题设置,保留主题开关、启动动画和徽记配置。</span>
141
146
  <button type="button" data-prts-reset-confirm-action>确认恢复</button>
@@ -143,6 +148,7 @@ export function themeSettingsMarkup() {
143
148
  </div>
144
149
  </div>
145
150
  </footer>
151
+ ${themeTransferMarkup()}
146
152
  </div>
147
153
  <div data-prts-theme-settings-scroll>
148
154
  <section data-prts-settings-section="appearance" role="tabpanel" id="prts-page-appearance" aria-labelledby="prts-tab-appearance" tabindex="0">
@@ -158,7 +164,7 @@ export function themeSettingsMarkup() {
158
164
  </section>
159
165
  </section>
160
166
  <section data-prts-settings-section="background" role="tabpanel" id="prts-page-background" aria-labelledby="prts-tab-background" tabindex="0" hidden>
161
- ${backgroundMarkup()}
167
+ ${backgroundMarkup(settingButtons('heroContrast', '新会话徽记增强', []))}
162
168
  <div data-prts-composer-glass-setting>${settingButtons('composerGlass', '输入框增强毛玻璃', [])}</div>
163
169
  <small data-prts-composer-glass-required hidden>自定义背景下,输入框增强毛玻璃始终开启。</small>
164
170
  </section>
@@ -167,6 +173,33 @@ export function themeSettingsMarkup() {
167
173
  ${COMMON_CONTROLS.filter(([key]) => ['cursor', 'railDefaultHidden', 'motion'].includes(key)).map(([key, label, options]) => settingButtons(key, label, options)).join('')}
168
174
  </div>
169
175
  </section>
176
+ <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>
177
+ <div data-prts-preview-settings><div data-prts-preview-master>
178
+ ${previewSetting('aiToolsEnabled', '允许 AI 定制主题', '默认开启 · 共 5 个工具 · 影响此 DSH 实例的所有会话')}
179
+ </div></div>
180
+ <p>开启后,AI 可以通过工具调整粒子徽记和 Baker 头像。关闭后,后续模型请求不再提供这些工具,手动设置不受影响;已有对话中的工具记录不会删除。</p>
181
+ <section>
182
+ <h3>粒子徽记 <small>3 个工具</small></h3>
183
+ <p>将会话中的图片制作成粒子徽记,用于新会话页面或正文轮换。</p>
184
+ <ol><li>上传图片,或让 AI 读取已生成的图片。</li><li>让 AI 制作草稿,调整轮廓、位置和缩放,先查看预览。</li><li>满意后明确要求应用到新会话徽记,或加入正文轮换。</li></ol>
185
+ <blockquote>“把这张图片做成粒子徽记,先给我看看效果。”</blockquote>
186
+ <details><summary>工具说明与限制</summary><dl>
187
+ <dt><code>prts_emblem_inspect</code></dt><dd>查看徽记配置、版本与当前会话的可用图片,不修改设置。</dd>
188
+ <dt><code>prts_emblem_preview</code></dt><dd>制作或调整粒子徽记草稿,只生成预览,不直接应用。</dd>
189
+ <dt><code>prts_emblem_apply</code></dt><dd>经你确认后,替换新会话徽记,或添加、替换正文轮换徽记。</dd>
190
+ </dl><p>单张来源图片最大 8 MB。徽记设置跨工作区和会话共享。粒子预览不等同于原图;配置发生变化时,AI 需要重新核对后再应用。</p></details>
191
+ </section>
192
+ <section>
193
+ <h3>Baker 头像 <small>2 个工具</small></h3>
194
+ <p>为用户和 AI 设置头像,调整圆形裁剪,或恢复默认头像。仅在 Baker 会话样式下显示。</p>
195
+ <ol><li>上传图片,说明要修改用户头像还是 AI 头像。</li><li>让 AI 查看当前配置,再按你的要求替换、裁剪或恢复默认。</li></ol>
196
+ <blockquote>“把这张图片设为 AI 头像,居中裁剪。”</blockquote>
197
+ <details><summary>工具说明与限制</summary><dl>
198
+ <dt><code>prts_avatar_inspect</code></dt><dd>查看头像配置、原图尺寸、裁剪参数及会话图片,不修改设置。</dd>
199
+ <dt><code>prts_avatar_apply</code></dt><dd>替换用户或 AI 头像、基于现有原图重新裁剪,或恢复默认头像。</dd>
200
+ </dl><p>单图最大 5 MB、2500 万像素;裁剪支持 1~4 倍缩放。修改立即影响此实例所有会话,不会自动切换会话样式。替换后不保留旧原图;恢复默认会删除自定义原图。</p></details>
201
+ </section>
202
+ </section>
170
203
  <section data-prts-settings-section="assistance" role="tabpanel" id="prts-page-assistance" aria-labelledby="prts-tab-assistance" tabindex="0" hidden>
171
204
  <section data-prts-preview-settings>
172
205
  <div data-prts-preview-master>
@@ -177,6 +210,7 @@ export function themeSettingsMarkup() {
177
210
  <header><strong>悬停预览</strong><small>选择哪些状态显示详细浮窗</small></header>
178
211
  ${previewSetting('previewOngoing', '运行中预览', '逐轮查看已完成的回复,最新内容置顶')}
179
212
  <div data-prts-preview-details="previewOngoing">
213
+ ${previewSetting('previewTools', '显示工具调用', '显示工具进度,悬停查看最新正文之后的调用记录')}
180
214
  <label data-prts-setting-row="previewReplyLimit"><span><strong>回复条数</strong><small data-prts-preview-setting-description>仅保留最新回复</small></span><select data-prts-preview-reply-limit aria-label="最多显示回复"><option value="5">最新 5 条</option><option value="10">最新 10 条</option><option value="0">不限制</option></select></label>
181
215
  ${previewSetting('previewFollowOngoing', '运行结束后跟随', '转为待处理或已完成时,自动切换预览')}
182
216
  </div>
@@ -233,7 +267,7 @@ export function createThemeSettingsOverlay({
233
267
  trigger,
234
268
  panel,
235
269
  backdrop,
236
- customEmblemStore, avatarStore, backgroundStore,
270
+ customEmblemStore, avatarStore, backgroundStore, themeTransfer,
237
271
  onOpen = () => {},
238
272
  onPreferenceChange = () => {},
239
273
  onPreferencePreview = () => {},
@@ -241,6 +275,7 @@ export function createThemeSettingsOverlay({
241
275
  onRetrySave = () => {},
242
276
  }) {
243
277
  const window = document?.defaultView
278
+ const transferEditor = mountThemeTransfer({ panel, window, transfer: themeTransfer })
244
279
  const backgroundEditor = mountBackgroundSettings({panel,window,store:backgroundStore,onChange:onPreferenceChange})
245
280
  const avatarEditor = mountAvatarSettings({panel,window,store:avatarStore})
246
281
  const customEditor = mountCustomEmblemEditor({ panel, window, store: customEmblemStore })
@@ -720,6 +755,7 @@ export function createThemeSettingsOverlay({
720
755
  panel.querySelector('[data-prts-baker-options]').hidden = preferences?.conversationStyle !== 'deck-chat'
721
756
  panel.querySelector('[data-prts-avatars]').hidden = preferences?.conversationStyle !== 'deck-chat'
722
757
  const customBackground = preferences.backgroundMode !== 'off'
758
+ panel.querySelector('[data-prts-background-hero-setting]').hidden = !customBackground
723
759
  panel.querySelector('[data-prts-composer-glass-setting]').hidden = customBackground
724
760
  panel.querySelector('[data-prts-composer-glass-required]').hidden = !customBackground
725
761
  backgroundEditor.update(preferences)
@@ -737,6 +773,7 @@ export function createThemeSettingsOverlay({
737
773
  close: () => setOpen(false),
738
774
  toggle,
739
775
  dispose() {
776
+ transferEditor.dispose()
740
777
  customEditor.dispose()
741
778
  avatarEditor.dispose()
742
779
  backgroundEditor.dispose()
@@ -0,0 +1,98 @@
1
+ import { THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage } from './theme-transfer.js'
2
+
3
+ export function themeTransferMarkup() {
4
+ return `<dialog data-prts-transfer-dialog aria-labelledby="prts-transfer-title">
5
+ <section data-prts-transfer>
6
+ <header data-prts-transfer-heading><div><small>P.R.T.S. / 主题文件</small><h2 id="prts-transfer-title">导入与导出</h2></div><button type="button" data-prts-transfer-close aria-label="关闭导入与导出">×</button></header>
7
+ <div data-prts-transfer-columns>
8
+ <section data-prts-transfer-card>
9
+ <h3>导出主题</h3><p>保存一份主题,备份或带到另一台设备。</p>
10
+ <label data-prts-transfer-option><input type="checkbox" data-prts-transfer-assets checked><span>包含自定义素材<small>背景、头像和徽记,保留编辑参数与排列。</small></span></label>
11
+ <small>取消勾选,仅导出主题设置。</small>
12
+ <button type="button" data-prts-transfer-export>导出主题文件 <span aria-hidden="true">↓</span></button>
13
+ </section>
14
+ <section data-prts-transfer-card>
15
+ <h3>导入主题</h3><p>选择已导出的文件,确认内容后应用。</p>
16
+ <div data-prts-transfer-file-hint><span aria-hidden="true">↑</span><small>.prts-theme.json · 最大 160 MB</small></div>
17
+ <button type="button" data-prts-transfer-choose>选择主题文件</button>
18
+ <input type="file" accept=".prts-theme.json,application/json" aria-label="导入主题文件" data-prts-transfer-file hidden>
19
+ </section>
20
+ </div>
21
+ <div data-prts-transfer-preview hidden>
22
+ <strong>确认导入</strong><p data-prts-transfer-summary></p>
23
+ <small>应用前会下载当前配置备份,请保留备份文件。</small>
24
+ <div data-prts-transfer-buttons><button type="button" data-prts-transfer-apply>确认导入</button><button type="button" data-prts-transfer-cancel>取消</button></div>
25
+ </div>
26
+ <div data-prts-transfer-recovery hidden><small>恢复将替换最近一次导入后所做的修改。</small><button type="button" data-prts-transfer-restore>恢复导入前配置</button></div>
27
+ <p data-prts-transfer-status role="status" aria-live="polite"></p>
28
+ </section>
29
+ </dialog>`
30
+ }
31
+
32
+ export function mountThemeTransfer({ panel, window, transfer }) {
33
+ const section = panel.querySelector('[data-prts-transfer]')
34
+ if (!section || !transfer) return { dispose() {} }
35
+ const dialog = panel.querySelector('[data-prts-transfer-dialog]')
36
+ const trigger = panel.querySelector('[data-prts-transfer-open]')
37
+ const open = () => { dialog.showModal(); trigger?.setAttribute('aria-expanded', 'true'); get('close').focus() }
38
+ const close = () => { if (busy) return; dialog.close(); trigger?.setAttribute('aria-expanded', 'false'); trigger?.focus() }
39
+ const keydown = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() } }
40
+ const cancel = event => { if (event.target === dialog) { event.preventDefault(); event.stopPropagation(); close() } }
41
+ const get = name => section.querySelector(`[data-prts-transfer-${name}]`)
42
+ let pending, busy = false, disposed = false, generation = 0
43
+ const render = () => {
44
+ if (disposed) return
45
+ get('preview').hidden = !pending
46
+ get('recovery').hidden = !transfer.hasBackup
47
+ for (const control of section.querySelectorAll('button,input')) control.disabled = busy
48
+ // Prevent local editors from racing a multi-service import; other clients use revision checks.
49
+ for (const page of panel.querySelectorAll('[data-prts-settings-section]')) if (!page.contains(section)) page.inert = busy
50
+ }
51
+ async function run(task) {
52
+ if (busy) return
53
+ busy = true; get('status').textContent = '正在处理,请稍候…'; render()
54
+ try { get('status').textContent = await task() }
55
+ catch (error) { if (!disposed) get('status').textContent = error.message }
56
+ finally { busy = false; render() }
57
+ }
58
+ const change = event => {
59
+ if (event.target !== get('file')) return
60
+ const file = event.target.files?.[0], selected = ++generation
61
+ pending = undefined; render()
62
+ if (!file) return
63
+ void run(async () => {
64
+ if (file.size > THEME_PACKAGE_LIMIT) throw Error('主题文件不能超过 160 MB')
65
+ const text = await new Promise((resolve, reject) => {
66
+ const reader = new window.FileReader()
67
+ reader.onload = () => resolve(reader.result); reader.onerror = () => reject(Error('无法读取主题文件')); reader.readAsText(file)
68
+ })
69
+ let value
70
+ try { value = JSON.parse(text) } catch { throw Error('主题文件不是有效的 JSON') }
71
+ validateThemePackage(value)
72
+ if (disposed || selected !== generation) return ''
73
+ pending = value
74
+ get('summary').textContent = describeThemePackage(value)
75
+ return '已读取文件,请确认导入内容。'
76
+ })
77
+ }
78
+ const click = event => {
79
+ const button = event.target.closest?.('button')
80
+ if (button === get('close')) close()
81
+ if (button === get('choose')) get('file').click()
82
+ if (button === get('export')) void run(async () => {
83
+ const value = await transfer.export(get('assets').checked)
84
+ transfer.download(value)
85
+ return '已发起下载,请确认主题文件已保存。'
86
+ })
87
+ if (button === get('apply') && pending) void run(async () => {
88
+ await transfer.import(pending); pending = undefined; get('file').value = ''
89
+ return '主题已导入并保存。'
90
+ })
91
+ if (button === get('cancel')) { pending = undefined; get('file').value = ''; get('status').textContent = ''; render() }
92
+ if (button === get('restore')) void run(async () => { await transfer.restore(); return '已恢复导入前配置。' })
93
+ }
94
+ trigger?.addEventListener('click', open)
95
+ dialog.addEventListener('keydown', keydown); dialog.addEventListener('cancel', cancel)
96
+ section.addEventListener('change', change); section.addEventListener('click', click); render()
97
+ return { dispose() { disposed = true; generation++; if (dialog.open) dialog.close(); trigger?.removeEventListener('click', open); dialog.removeEventListener('keydown', keydown); dialog.removeEventListener('cancel', cancel); section.removeEventListener('change', change); section.removeEventListener('click', click) } }
98
+ }
@@ -0,0 +1,167 @@
1
+ import { DEFAULT_PREFERENCES, normalizePreferences } from './preferences.js'
2
+
3
+ export const THEME_PACKAGE_LIMIT = 160 * 1024 * 1024
4
+ const transferSlots = ['full', 'sidebar', 'conversation']
5
+ const transferRoles = ['user', 'assistant']
6
+ const transferBuiltins = ['rhodes-island', 'lungmen', 'penguin-logistics', 'rhine-lab', 'reunion']
7
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
8
+ const encodedImage = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= Math.ceil(limit / 3) * 4 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
9
+
10
+ export function validateThemePackage(value) {
11
+ if (!record(value) || value.format !== 'prts-theme' || value.version !== 1) throw Error('不支持的主题文件格式或版本')
12
+ if (!record(value.preferences) || !Object.keys(value.preferences).some(key => key !== 'version' && Object.hasOwn(DEFAULT_PREFERENCES, key))) throw Error('主题文件缺少设置')
13
+ if (!Number.isInteger(value.preferences.version) || value.preferences.version < 1 || value.preferences.version > DEFAULT_PREFERENCES.version) throw Error('不支持此设置版本,请更新主题后重试')
14
+ const normalized = normalizePreferences(value.preferences)
15
+ for (const [key, input] of Object.entries(value.preferences)) {
16
+ if (!Object.hasOwn(DEFAULT_PREFERENCES, key) || key === 'version') continue
17
+ if (typeof input !== typeof DEFAULT_PREFERENCES[key] || (typeof input === 'number' && !Number.isFinite(input))) throw Error(`设置 ${key} 的类型无效`)
18
+ // Current-format files must not silently turn malformed settings into defaults.
19
+ if (value.preferences.version === DEFAULT_PREFERENCES.version && normalized[key] !== input) throw Error(`设置 ${key} 的值无效`)
20
+ }
21
+ if (value.assets === undefined) return value
22
+ const a = value.assets
23
+ if (!record(a) || !record(a.backgrounds) || !record(a.avatars) || !record(a.emblems)) throw Error('主题素材数据不完整')
24
+ for (const slot of transferSlots) if (a.backgrounds[slot] !== null && !encodedImage(a.backgrounds[slot], 10 * 1024 * 1024)) throw Error('背景图片数据无效')
25
+ for (const role of transferRoles) {
26
+ const avatar = a.avatars[role]
27
+ if (avatar === null) continue
28
+ const crop = avatar?.crop
29
+ if (!record(avatar) || !encodedImage(avatar.image, 5 * 1024 * 1024) || !record(crop)
30
+ || !Number.isFinite(crop.zoom) || crop.zoom < 1 || crop.zoom > 4
31
+ || !Number.isFinite(crop.x) || crop.x < 0 || crop.x > 1 || !Number.isFinite(crop.y) || crop.y < 0 || crop.y > 1) throw Error('头像数据或裁剪参数无效')
32
+ }
33
+ const { items, hero, conversation } = a.emblems
34
+ if (!Array.isArray(items) || items.length > 13 || !Array.isArray(conversation) || conversation.length < 1 || conversation.length > 12) throw Error('徽记数量无效')
35
+ for (const item of items) {
36
+ if (!record(item) || item.format !== 'prts-emblem' || item.version !== 1 || typeof item.label !== 'string' || !item.label.trim() || item.label.length > 100 || !record(item.params) || !encodedImage(item.image, 8 * 1024 * 1024)) throw Error('徽记素材无效')
37
+ }
38
+ const validEntry = entry => record(entry) && (entry.kind === 'builtin' ? transferBuiltins.includes(entry.key) : entry.kind === 'custom' && Number.isInteger(entry.asset) && entry.asset >= 0 && entry.asset < items.length && typeof entry.label === 'string' && entry.label.length <= 100)
39
+ if ((hero !== null && (!validEntry(hero) || hero.kind !== 'custom')) || !conversation.every(validEntry)) throw Error('徽记引用无效')
40
+ return value
41
+ }
42
+
43
+ export function mergeThemePreferences(current, imported) {
44
+ const normalized = normalizePreferences(imported)
45
+ const patch = Object.fromEntries(Object.keys(DEFAULT_PREFERENCES).filter(key => key !== 'version' && Object.hasOwn(imported, key)).map(key => [key, normalized[key]]))
46
+ return normalizePreferences({ ...current, ...patch, version: DEFAULT_PREFERENCES.version })
47
+ }
48
+
49
+ export function describeThemePackage(value) {
50
+ const count = Object.keys(value.preferences).filter(key => key !== 'version' && Object.hasOwn(DEFAULT_PREFERENCES, key)).length
51
+ if (!value.assets) return `${count} 项主题设置;不包含素材,将保留当前背景、头像和徽记。`
52
+ const a = value.assets
53
+ return `${count} 项主题设置、${transferSlots.filter(k => a.backgrounds[k]).length} 张背景、${transferRoles.filter(k => a.avatars[k]).length} 个头像、${a.emblems.items.length} 个自定义徽记。将替换当前素材及徽记排列,空项恢复为默认。`
54
+ }
55
+
56
+ export function createThemeTransfer({ window, getPreferences, savePreferences, checkWritable, checkExport = checkWritable, themeVersion }) {
57
+ let busy = false, backup
58
+ const request = async (path, data) => {
59
+ const response = await window.fetch('/prts-theme-api/' + path, { credentials: 'same-origin',
60
+ ...(data === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json', 'x-prts-request': '1' }, body: JSON.stringify(data) }) })
61
+ const result = await response.json()
62
+ if (!response.ok) throw Error(result.error || '无法连接主题素材服务')
63
+ return result
64
+ }
65
+ const readImage = blob => new Promise((resolve, reject) => {
66
+ const reader = new window.FileReader()
67
+ reader.onload = () => resolve(String(reader.result).split(',')[1])
68
+ reader.onerror = () => reject(Error('无法读取背景图片'))
69
+ reader.readAsDataURL(blob)
70
+ })
71
+ async function capture(includeAssets) {
72
+ const value = { format: 'prts-theme', version: 1, themeVersion, exportedAt: new Date().toISOString(), preferences: { ...getPreferences() } }
73
+ const states = {}
74
+ if (includeAssets) {
75
+ const backgrounds = {}, avatars = {}, items = [], ids = new Map()
76
+ states.backgrounds = await request('backgrounds/configuration')
77
+ for (const slot of transferSlots) {
78
+ const entry = states.backgrounds[slot]
79
+ backgrounds[slot] = null
80
+ if (entry) {
81
+ if (!/^[a-f0-9-]{36}$/.test(entry.id)) throw Error('背景引用无效')
82
+ const response = await window.fetch('/prts-theme-api/backgrounds/image/' + entry.id, { credentials: 'same-origin' })
83
+ if (!response.ok) throw Error('无法导出背景图片')
84
+ backgrounds[slot] = await readImage(await response.blob())
85
+ }
86
+ }
87
+ states.avatars = await request('avatars/configuration')
88
+ for (const role of transferRoles) {
89
+ const source = await request('avatars/edit/' + role)
90
+ if (source.revision !== states.avatars.revision) throw Error('头像已变化,请重新导出')
91
+ avatars[role] = source.image ? { image: source.image, crop: source.crop } : null
92
+ }
93
+ states.emblems = await request('emblems/configuration')
94
+ async function reference(entry) {
95
+ if (!entry) return null
96
+ if (entry.kind === 'builtin') return { kind: 'builtin', key: entry.key, label: entry.label }
97
+ if (!ids.has(entry.draftId)) {
98
+ ids.set(entry.draftId, items.length)
99
+ items.push(await request('emblems/draft/' + encodeURIComponent(entry.draftId) + '/export'))
100
+ }
101
+ return { kind: 'custom', asset: ids.get(entry.draftId), label: entry.label }
102
+ }
103
+ const hero = await reference(states.emblems.configuration.hero), conversation = []
104
+ for (const entry of states.emblems.configuration.conversation) conversation.push(await reference(entry))
105
+ value.assets = { backgrounds, avatars, emblems: { items, hero, conversation } }
106
+ for (const kind of ['backgrounds', 'avatars', 'emblems']) {
107
+ if ((await request(kind + '/configuration')).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
108
+ }
109
+ }
110
+ if (JSON.stringify(value.preferences) !== JSON.stringify(getPreferences())) throw Error('设置在导出期间发生变化,请重试')
111
+ validateThemePackage(value)
112
+ return { value, states }
113
+ }
114
+ async function exclusive(fn) {
115
+ if (busy) throw Error('正在处理主题文件,请稍候')
116
+ busy = true
117
+ try { return await fn() } finally { busy = false }
118
+ }
119
+ function download(value, name = 'theme') {
120
+ const blob = new window.Blob([JSON.stringify(value)], { type: 'application/json' })
121
+ if (blob.size > THEME_PACKAGE_LIMIT) throw Error('主题文件超过 160 MB,请减少素材后重试')
122
+ const url = window.URL.createObjectURL(blob), link = window.document.createElement('a')
123
+ link.href = url; link.download = `prts-${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.prts-theme.json`
124
+ window.document.body.append(link); link.click(); link.remove()
125
+ window.setTimeout(() => window.URL.revokeObjectURL(url), 1000)
126
+ }
127
+ async function apply(value) {
128
+ validateThemePackage(value)
129
+ const preferenceRevision = checkWritable()
130
+ const before = await capture(Boolean(value.assets))
131
+ backup = before.value
132
+ download(backup, 'before-import')
133
+ const undo = [], revisions = Object.fromEntries(Object.entries(before.states).map(([key, state]) => [key, state.revision]))
134
+ const write = async (kind, data, previous) => {
135
+ const result = await request(kind + '/configuration', { ...data, revision: revisions[kind] })
136
+ revisions[kind] = result.revision
137
+ undo.push(async () => {
138
+ const restored = await request(kind + '/configuration', { ...previous, revision: revisions[kind] })
139
+ revisions[kind] = restored.revision
140
+ })
141
+ }
142
+ try {
143
+ if (value.assets) {
144
+ const a = value.assets, old = before.value.assets, drafts = []
145
+ // Generate every custom draft before changing the active configuration.
146
+ for (const item of a.emblems.items) drafts.push(await request('emblems/import', item))
147
+ const entry = (ref, index) => ref === null ? null : ref.kind === 'builtin' ? ref : { kind: 'custom', key: 'import-' + index + '-' + drafts[ref.asset].draftId, label: ref.label, draftId: drafts[ref.asset].draftId }
148
+ const configuration = { version: 2, hero: entry(a.emblems.hero, 'hero'), conversation: a.emblems.conversation.map(entry) }
149
+ for (const slot of transferSlots) await write('backgrounds', { slot, image: a.backgrounds[slot] }, { slot, image: old.backgrounds[slot] })
150
+ for (const role of transferRoles) await write('avatars', { role, image: a.avatars[role]?.image ?? null, crop: a.avatars[role]?.crop }, { role, image: old.avatars[role]?.image ?? null, crop: old.avatars[role]?.crop })
151
+ await write('emblems', { configuration }, { configuration: before.states.emblems.configuration })
152
+ }
153
+ await savePreferences(mergeThemePreferences(before.value.preferences, value.preferences), preferenceRevision)
154
+ } catch (error) {
155
+ let failures = 0
156
+ for (const restore of undo.reverse()) { try { await restore() } catch { failures++ } }
157
+ throw Error(`${error.message}。${failures ? '部分素材未能自动恢复。' : '已回退已确认写入的素材。'}若连接中断,请检查实际状态;导入前备份已发起下载,可重新导入恢复。`)
158
+ }
159
+ }
160
+ return {
161
+ export: includeAssets => exclusive(async () => { checkExport(); return (await capture(includeAssets)).value }),
162
+ import: value => exclusive(() => apply(value)),
163
+ restore: () => exclusive(() => { if (!backup) throw Error('没有可恢复的备份'); return apply(backup) }),
164
+ get hasBackup() { return Boolean(backup) },
165
+ download,
166
+ }
167
+ }
package/src/index.js CHANGED
@@ -19,7 +19,7 @@ export const Config = Schema.object(Object.fromEntries([
19
19
  ['preferencesInitialized', Schema.boolean().default(false).volatile()],
20
20
  ]))
21
21
 
22
- export async function apply(ctx) {
22
+ export async function apply(ctx, config = {}) {
23
23
  ctx.inject(['settings'], settingsCtx => {
24
24
  settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
25
25
  })
@@ -27,7 +27,24 @@ export async function apply(ctx) {
27
27
  const service=await createEmblemService(directory)
28
28
  const backgrounds=await createBackgroundService(join(directory,'..','backgrounds'))
29
29
  const avatars=await createAvatarService(join(directory,'..','avatars'))
30
- const disposers=[...emblemTools(service,ctx.attachments),...avatarTools(avatars,ctx.attachments)].map(tool=>ctx.tools.register(tool))
31
- disposers.push(registerEmblemApi(ctx,service),registerAvatarApi(ctx,avatars),registerBackgroundApi(ctx,backgrounds))
32
- ctx.effect(()=>async()=>{for(const dispose of disposers.reverse())dispose();await service.dispose();await backgrounds.dispose()})
30
+ const definitions=[...emblemTools(service,ctx.attachments),...avatarTools(avatars,ctx.attachments)]
31
+ let toolDisposers=[]
32
+ const syncTools=()=>{
33
+ const enabled=(config.aiToolsEnabled?.get?.() ?? config.aiToolsEnabled) !== false
34
+ if(enabled && !toolDisposers.length) {
35
+ for(const tool of definitions)toolDisposers.push(ctx.tools.register(tool))
36
+ } else if(!enabled) {
37
+ for(const dispose of toolDisposers.splice(0).reverse())dispose()
38
+ }
39
+ }
40
+ syncTools()
41
+ // Host volatile fields are updated before this notification; they expose get(), not subscribe().
42
+ const stopConfig=ctx.on('app-boot/config-reload',syncTools)
43
+ const disposers=[registerEmblemApi(ctx,service),registerAvatarApi(ctx,avatars),registerBackgroundApi(ctx,backgrounds)]
44
+ ctx.effect(()=>async()=>{
45
+ stopConfig()
46
+ for(const dispose of toolDisposers.splice(0).reverse())dispose()
47
+ for(const dispose of disposers.reverse())dispose()
48
+ await service.dispose();await backgrounds.dispose()
49
+ })
33
50
  }
@@ -329,3 +329,17 @@ animation: prts-baker-typing 1.2s linear infinite;
329
329
  33% { background: currentColor; box-shadow: -10px 0 0 color-mix(in srgb,currentColor 30%,transparent), 10px 0 0 color-mix(in srgb,currentColor 30%,transparent); }
330
330
  66% { background: color-mix(in srgb,currentColor 30%,transparent); box-shadow: -10px 0 0 color-mix(in srgb,currentColor 30%,transparent), 10px 0 0 currentColor; }
331
331
  }
332
+
333
+ /* Keep the hero treatment beside the background readability controls. */
334
+ html[data-dsh-prts] [data-prts-background-hero-setting] {
335
+ padding:16px 0 2px; border-top:1px solid var(--prts-line);
336
+ & [data-prts-setting-row] { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:20px; margin:0; padding:0; border:0; background:none; }
337
+ & [data-prts-setting-row] > span { display:grid; gap:5px; min-width:0; }
338
+ & strong { font:500 13px/1.5 var(--prts-body); color:var(--prts-ink); }
339
+ & small { max-width:36em; font:400 12px/1.6 var(--prts-body); color:var(--prts-muted); }
340
+ & [data-prts-setting-switch] { justify-self:end; align-self:center; margin:0; }
341
+ }
342
+ html[data-dsh-prts] [data-prts-background-hero-setting][hidden] { display:none; }
343
+
344
+ /* Windows chrome must not clip a different canvas color into the conversation. */
345
+ html[data-dsh-prts][data-windows-titlebar] [data-prts-region="operation"] { border-radius: 0; }
@@ -776,10 +776,10 @@ html[data-dsh-prts] [data-prts-preview-body] a { color:var(--prts-cyan);text-dec
776
776
  html[data-dsh-prts] [data-prts-preview-message] + [data-prts-preview-message] { margin-top:5px; }
777
777
  html[data-dsh-prts] [data-prts-preview-message] > small { display:block;margin-bottom:6px;color:var(--prts-muted);font:10px/1.5 var(--prts-mono); }
778
778
 
779
- html[data-dsh-prts] [data-prts-reply-entry] { display:flex;align-items:flex-start;gap:8px;width:100%;padding:6px 8px;text-align:left;border:1px solid var(--prts-line);border-radius:5px;background:transparent;color:var(--prts-ink);cursor:pointer; }
780
- html[data-dsh-prts] [data-prts-reply-entry]:is(:hover,:focus-visible,[data-prts-reply-active]) { background:color-mix(in srgb,var(--prts-ink) 7%,transparent);border-color:var(--prts-muted); }
781
- html[data-dsh-prts] [data-prts-reply-entry] small { flex:0 0 auto;white-space:nowrap;padding-top:2px;color:var(--prts-muted);font:10px/1.5 var(--prts-mono); }
782
- html[data-dsh-prts] [data-prts-reply-entry] span { min-width:0;flex:1;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;font:12px/1.6 var(--prts-body);overflow-wrap:anywhere; }
779
+ html[data-dsh-prts] :is([data-prts-reply-entry],[data-prts-tool-entry]) { display:flex;align-items:flex-start;gap:8px;width:100%;padding:6px 8px;text-align:left;border:1px solid var(--prts-line);border-radius:5px;background:transparent;color:var(--prts-ink);cursor:pointer; }
780
+ html[data-dsh-prts] :is([data-prts-reply-entry],[data-prts-tool-entry]):is(:hover,:focus-visible,[data-prts-reply-active]) { background:color-mix(in srgb,var(--prts-ink) 7%,transparent);border-color:var(--prts-muted); }
781
+ html[data-dsh-prts] :is([data-prts-reply-entry],[data-prts-tool-entry]) small { flex:0 0 auto;white-space:nowrap;padding-top:2px;color:var(--prts-muted);font:10px/1.5 var(--prts-mono); }
782
+ html[data-dsh-prts] :is([data-prts-reply-entry],[data-prts-tool-entry]) span { min-width:0;flex:1;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;font:12px/1.6 var(--prts-body);overflow-wrap:anywhere; }
783
783
  html[data-dsh-prts] [data-prts-reply-detail]:not([data-prts-preview-question]) > [data-prts-preview-body] { max-height:190px; }
784
784
 
785
785
  html[data-dsh-prts] [data-prts-preview-marker] { display:none;flex:none;margin-left:auto;color:var(--prts-muted);font:600 9px/1.4 var(--prts-body); }
@@ -864,3 +864,25 @@ html[data-dsh-prts] [data-prts-summary-dismissed] [data-prts-session-summary-pop
864
864
  pointer-events: none !important;
865
865
  transition: none;
866
866
  }
867
+
868
+ html[data-dsh-prts] [data-prts-preview-tool] { margin-top:8px; }
869
+ html[data-dsh-prts] [data-prts-preview-tool][hidden] { display:none; }
870
+ html[data-dsh-prts] [data-prts-preview-tool] > button { box-sizing:border-box;height:32px;align-items:center;gap:7px;padding:4px 8px;overflow:hidden;border-left:2px solid var(--prts-cyan);background:color-mix(in srgb,var(--prts-cyan) 7%,transparent); }
871
+ html[data-dsh-prts] [data-prts-tool-entry]::before { content:">_";flex:none;color:var(--prts-cyan);font:600 11px var(--prts-mono); }
872
+ html[data-dsh-prts] [data-prts-preview-tool] small { padding:0;color:var(--prts-cyan); }
873
+ html[data-dsh-prts] [data-prts-preview-tool] span { display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:11px/1.6 var(--prts-mono); }
874
+
875
+ html[data-dsh-prts] [data-prts-summary-reply] > [data-prts-preview-body]:empty { display:none;margin:0; }
876
+
877
+ html[data-dsh-prts] [data-prts-preview-message] > [data-prts-reply-entry] { border-color:transparent;border-radius:3px; }
878
+ html[data-dsh-prts] [data-prts-reply-entry] small { display:flex;align-items:center;gap:4px;color:#8aaee0; }
879
+ html[data-dsh-prts] [data-prts-reply-entry] svg { width:14px;height:14px;flex:none;fill:none;stroke:currentColor;stroke-width:1.2; }
880
+
881
+ html[data-dsh-prts][data-prts-scheme="light"] [data-prts-reply-entry] small { color:#476b9c; }
882
+
883
+ html[data-dsh-prts] [data-prts-summary-reply][data-prts-tool-history] > [data-prts-preview-body] { height:190px;max-height:190px; }
884
+ html[data-dsh-prts] [data-prts-tool-record] { padding:8px 0;border-bottom:1px solid var(--prts-line); }
885
+ html[data-dsh-prts] [data-prts-tool-record] > div { display:flex;align-items:baseline;gap:8px; }
886
+ html[data-dsh-prts] [data-prts-tool-record] b { flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--prts-cyan);font:11px/1.6 var(--prts-mono); }
887
+ html[data-dsh-prts] [data-prts-tool-record] small { flex:none;color:var(--prts-muted);font-size:10px; }
888
+ html[data-dsh-prts] [data-prts-tool-record] p { display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;margin:4px 0 0; }
@@ -482,3 +482,25 @@ html[data-dsh-prts][data-prts-conversation-style="deck-chat"] :is([class$="_bubb
482
482
  padding:2px 7px!important;border-radius:6px!important;
483
483
  &:is(:hover,:focus-visible){border-color:var(--prts-cyan)!important}
484
484
  }
485
+
486
+ /* Notification cards own one surface across the disclosure and its content. */
487
+ html[data-dsh-prts] [data-turn-trigger] {
488
+ background:var(--prts-glass-soft-surface);
489
+ border:1px solid var(--prts-line);
490
+ border-radius:var(--dsw-radius-xl,16px);
491
+ -webkit-backdrop-filter:blur(12px);
492
+ backdrop-filter:blur(12px);
493
+ color:var(--prts-ink);
494
+ & > button {
495
+ background:transparent;
496
+ border:0;
497
+ border-radius:inherit;
498
+ color:inherit;
499
+ &:hover { background:color-mix(in srgb,var(--prts-ink) 5%,transparent); }
500
+ &:focus-visible { outline:2px solid var(--prts-cyan); outline-offset:-3px; }
501
+ & time { color:var(--prts-muted); }
502
+ }
503
+ & > div { border-top:1px solid var(--prts-line); }
504
+ & > div > p { color:var(--prts-muted); }
505
+ & [data-context-text] { color:var(--prts-ink); }
506
+ }