@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.
- package/CHANGELOG.md +26 -0
- package/README.en.md +41 -3
- package/README.md +40 -3
- package/THIRD_PARTY_NOTICES.md +6 -0
- package/THIRD_PARTY_NOTICES.zh-CN.md +6 -0
- package/lib/client.js +536 -316
- package/package.json +5 -3
- package/src/client/avatar-crop.js +16 -3
- package/src/client/background-region-editor.js +2 -1
- package/src/client/conversation-control-adapter.js +131 -20
- package/src/client/cursor-adapter.js +44 -12
- package/src/client/cursor-follower.js +271 -0
- package/src/client/cursor-semantics.js +74 -0
- package/src/client/index.js +5 -1
- package/src/client/native-preferences.js +8 -4
- package/src/client/page-controls.js +185 -0
- package/src/client/preferences.js +25 -5
- package/src/client/theme-controller-v2.js +6 -5
- package/src/client/theme-controller.js +3 -2
- package/src/client/theme-settings-workbench.js +133 -19
- package/src/client/theme-transfer.js +5 -4
- package/src/index.js +27 -5
- package/src/server/avatar-tools.js +1 -1
- package/src/server/background-preview.js +100 -0
- package/src/server/background-tools.js +109 -0
- package/src/server/backgrounds.js +15 -9
- package/src/server/theme-tool-status.js +13 -0
- package/src/server/tools.js +11 -6
- package/src/server/transfer-tools.js +166 -0
- package/src/styles/cursor.css +26 -2
- package/src/styles/refinement.css +1 -1
- package/src/styles/theme-settings-workbench.css +190 -8
- package/src/vendor/mouse-follower/LICENSE +20 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createCursorAdapter } from './cursor-
|
|
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 =
|
|
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', '
|
|
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', '
|
|
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 定制主题', '默认开启 ·
|
|
190
|
+
${previewSetting('aiToolsEnabled', '允许 AI 定制主题', '默认开启 · 12 个定制工具,另有只读状态入口 · 影响此 DSH 实例的所有会话')}
|
|
180
191
|
</div></div>
|
|
181
|
-
<p>开启后,AI
|
|
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
|
|
537
|
-
panel.querySelector('[data-prts-corner-preview]')?.style.setProperty('--swatch', currentPreferences
|
|
538
|
-
|
|
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-
|
|
634
|
-
if (target.hasAttribute('data-prts-corner-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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=(
|
|
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:``}:{})})
|
|
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
|
+
}
|