@ash-qw/dsh-theme-prts 2.6.0 → 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 +14 -0
- package/README.en.md +38 -1
- package/README.md +37 -1
- package/THIRD_PARTY_NOTICES.md +6 -0
- package/THIRD_PARTY_NOTICES.zh-CN.md +6 -0
- package/lib/client.js +404 -295
- package/package.json +4 -2
- package/src/client/avatar-crop.js +16 -3
- package/src/client/background-region-editor.js +2 -1
- package/src/client/conversation-control-adapter.js +118 -16
- 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/page-controls.js +185 -0
- package/src/client/preferences.js +1 -1
- package/src/client/theme-controller-v2.js +3 -3
- package/src/client/theme-settings-workbench.js +51 -10
- package/src/client/theme-transfer.js +5 -4
- package/src/index.js +25 -4
- 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 +138 -6
- package/src/vendor/mouse-follower/LICENSE +20 -0
|
@@ -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
|
}
|
|
@@ -176,9 +187,9 @@ export function themeSettingsMarkup() {
|
|
|
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>
|
|
@@ -585,6 +619,10 @@ export function createThemeSettingsOverlay({
|
|
|
585
619
|
toggle.setAttribute('aria-checked', String(enabled))
|
|
586
620
|
toggle.classList.toggle('is-active', enabled)
|
|
587
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]
|
|
588
626
|
const replyLimitSelect = panel.querySelector('[data-prts-preview-reply-limit]')
|
|
589
627
|
if (replyLimitSelect) replyLimitSelect.value = String(currentPreferences.previewReplyLimit)
|
|
590
628
|
const replyLimitRow = panel.querySelector('[data-prts-setting-row="previewReplyLimit"]')
|
|
@@ -806,6 +844,7 @@ export function createThemeSettingsOverlay({
|
|
|
806
844
|
const renderMediaState = () => renderState()
|
|
807
845
|
const commitFromControl = event => {
|
|
808
846
|
const target = event.target
|
|
847
|
+
if (target?.matches?.('[data-prts-cursor-mode]')) { if (event.type === 'change') onPreferenceChange('cursor', target.value); return }
|
|
809
848
|
if (target?.matches?.('[data-prts-corner-color]')) { if (event.type === 'change') onPreferenceChange(cornerCustomKey(), target.value); return }
|
|
810
849
|
if (target?.matches?.('input[data-prts-setting-color]')) {
|
|
811
850
|
if (event.type === 'change' || event.type === 'focusout') commitSessionFlowColor(target.dataset.prtsSettingKey)
|
|
@@ -816,6 +855,7 @@ export function createThemeSettingsOverlay({
|
|
|
816
855
|
if (target.dataset.prtsSettingKey === 'sessionFlowSpeed') commitSessionFlowSpeed()
|
|
817
856
|
}
|
|
818
857
|
|
|
858
|
+
const pageControls = mountPageControls({panel,window})
|
|
819
859
|
trigger?.addEventListener('click', toggle)
|
|
820
860
|
panel?.addEventListener('click', onPanelClick)
|
|
821
861
|
selectSection('appearance')
|
|
@@ -855,6 +895,7 @@ export function createThemeSettingsOverlay({
|
|
|
855
895
|
close: () => setOpen(false),
|
|
856
896
|
toggle,
|
|
857
897
|
dispose() {
|
|
898
|
+
pageControls.dispose()
|
|
858
899
|
transferEditor.dispose()
|
|
859
900
|
customEditor.dispose()
|
|
860
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'
|
|
@@ -28,12 +32,29 @@ export async function apply(ctx, config = {}) {
|
|
|
28
32
|
const service=await createEmblemService(directory)
|
|
29
33
|
const backgrounds=await createBackgroundService(join(directory,'..','backgrounds'))
|
|
30
34
|
const avatars=await createAvatarService(join(directory,'..','avatars'))
|
|
31
|
-
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
|
+
})
|
|
32
47
|
let toolDisposers=[]
|
|
33
48
|
const syncTools=()=>{
|
|
34
|
-
const enabled=(
|
|
49
|
+
const enabled=toolsEnabled()
|
|
35
50
|
if(enabled && !toolDisposers.length) {
|
|
36
|
-
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
|
+
}))
|
|
37
58
|
} else if(!enabled) {
|
|
38
59
|
for(const dispose of toolDisposers.splice(0).reverse())dispose()
|
|
39
60
|
}
|
|
@@ -43,7 +64,7 @@ export async function apply(ctx, config = {}) {
|
|
|
43
64
|
const stopConfig=ctx.on('app-boot/config-reload',syncTools)
|
|
44
65
|
const disposers=[registerEmblemApi(ctx,service),registerAvatarApi(ctx,avatars),registerBackgroundApi(ctx,backgrounds)]
|
|
45
66
|
ctx.effect(()=>async()=>{
|
|
46
|
-
stopConfig()
|
|
67
|
+
stopConfig();disposeStatus()
|
|
47
68
|
for(const dispose of toolDisposers.splice(0).reverse())dispose()
|
|
48
69
|
for(const dispose of disposers.reverse())dispose()
|
|
49
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
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { backgroundPreviewTool, readBackgroundAttachment } from './background-preview.js'
|
|
2
|
+
import { sessionImages } from './tools.js'
|
|
3
|
+
import { fault } from './emblems.js'
|
|
4
|
+
import { validBackgroundRegion } from '../shared/background-region.js'
|
|
5
|
+
import { normalizePreferences } from '../client/preferences.js'
|
|
6
|
+
|
|
7
|
+
const slots=['full','sidebar','conversation']
|
|
8
|
+
const materialTargets=['sidebar','rightSidebar','rail','baker','classicUser']
|
|
9
|
+
const materialSchema={type:'object',additionalProperties:false,properties:Object.fromEntries(
|
|
10
|
+
[['blur',32],['opacity',1]].map(([kind,max])=>[kind,{type:'object',additionalProperties:false,
|
|
11
|
+
properties:Object.fromEntries(materialTargets.map(target=>[target,{type:kind==='blur'?'integer':'number',minimum:0,maximum:max}]))}]))}
|
|
12
|
+
function materialView(value){
|
|
13
|
+
return Object.fromEntries(['blur','opacity'].map(kind=>[kind,Object.fromEntries(
|
|
14
|
+
materialTargets.map(target=>[target,value[target+(kind==='blur'?'Blur':'Opacity')]]))]))
|
|
15
|
+
}
|
|
16
|
+
function materialPatch(material){
|
|
17
|
+
const patch={}
|
|
18
|
+
const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value)
|
|
19
|
+
if(!object(material))throw fault(400,'material 必须是对象')
|
|
20
|
+
for(const [kind,values] of Object.entries(material)){
|
|
21
|
+
if(!['blur','opacity'].includes(kind)||!object(values))throw fault(400,'material 仅支持 blur 和 opacity 对象')
|
|
22
|
+
for(const [target,value] of Object.entries(values)){
|
|
23
|
+
if(!materialTargets.includes(target))throw fault(400,'未知材质区域:'+target)
|
|
24
|
+
const max=kind==='blur'?32:1
|
|
25
|
+
if(!Number.isFinite(value)||value<0||value>max||(kind==='blur'&&!Number.isInteger(value)))
|
|
26
|
+
throw fault(400,kind==='blur'?'blur 必须是 0..32 的整数(px)':'opacity 必须在 0..1')
|
|
27
|
+
patch[target+(kind==='blur'?'Blur':'Opacity')]=value
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return patch
|
|
31
|
+
}
|
|
32
|
+
const revision={type:'integer',minimum:0}
|
|
33
|
+
const region={anyOf:[{type:'null'},{type:'object',additionalProperties:false,properties:{
|
|
34
|
+
x:{type:'number',minimum:0,maximum:1},y:{type:'number',minimum:0,maximum:1},
|
|
35
|
+
width:{type:'number',exclusiveMinimum:0,maximum:1},height:{type:'number',exclusiveMinimum:0,maximum:1},
|
|
36
|
+
},required:['x','y','width','height']}]}
|
|
37
|
+
function checkRevision(value){if(!Number.isInteger(value)||value<0)throw fault(400,'请先 inspect 获取配置版本')}
|
|
38
|
+
export function backgroundTools(service,attachments,host){
|
|
39
|
+
const readSettings=()=>{
|
|
40
|
+
const settings=host?.get?.('settings'),ns=host?.fiber?.entry?.options?.id
|
|
41
|
+
const descriptor=ns && settings?.describe({redactSecrets:true}).find(item=>item.ns===ns)
|
|
42
|
+
if(!descriptor)return {available:false,writable:false}
|
|
43
|
+
const value=normalizePreferences(descriptor.value)
|
|
44
|
+
return {available:true,writable:settings.writable===true,revision:descriptor.revision,
|
|
45
|
+
enabled:value.enabled,mode:value.backgroundMode,dim:value.backgroundDim,material:materialView(value),conversationStyle:value.conversationStyle}
|
|
46
|
+
}
|
|
47
|
+
const view=()=>{
|
|
48
|
+
const state=service.snapshot(),settings=readSettings()
|
|
49
|
+
return {...state,settings,activeSlots:settings.enabled
|
|
50
|
+
? settings.mode==='full'?['full'].filter(slot=>state[slot])
|
|
51
|
+
:settings.mode==='split'?['sidebar','conversation'].filter(slot=>state[slot]):[]:[]}
|
|
52
|
+
}
|
|
53
|
+
const output={schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)}]}
|
|
54
|
+
return [backgroundPreviewTool(service,attachments,readSettings),{
|
|
55
|
+
name:'prts_background_inspect',
|
|
56
|
+
description:'查看 PRTS 背景图片尺寸、保护区域、图片 revision,以及当前 profile 的模式、遮罩 dim、五个区域的 material(blur/opacity)、conversationStyle、settings.revision 和启用状态;列出当前会话上传或工具返回的图片,不修改设置。支持用户图片和 AI 生成、编辑、搜索下载的图片:若生成/搜索工具未直接返回图片附件,先用该会话已有工具保存图片到工作区,再 read_image 读取,然后 inspect 获取 attachmentId。列表按最近出现排序,lastSeenOrder 是会话出现顺序,latestForName 标记同名图片最近出现的版本,并非文件修改时间。需要评估视觉效果时调用 prts_background_preview。本工具不生成、搜索或下载图片,不接受 URL 或任意本地路径。',
|
|
57
|
+
parameters:{type:'object',properties:{},additionalProperties:false},output,isConcurrencySafe:()=>true,
|
|
58
|
+
async execute(_args,exec){return {...view(),images:sessionImages(exec.agent)}},
|
|
59
|
+
},{
|
|
60
|
+
name:'prts_background_apply',
|
|
61
|
+
description:'按用户要求设置背景素材。slot=full 完整背景、sidebar 左侧边栏、conversation 会话正文。action=replace 使用 inspect 返回的当前会话 attachmentId;protect 调整已有图片的保护区域;remove 删除该区域图片。region 为按 EXIF 转正后图片的归一化矩形 x/y/width/height,范围 0..1;null 清除保护框;替换时未指定会清除旧框。保护框只指导铺满裁剪,不能保证所有窗口完整容纳。先 inspect 获取图片 revision,冲突时重新读取,勿盲目覆盖。支持上传及 AI 工具返回的图片;生成图或下载图先 read_image 再 inspect。支持 PNG/JPEG/WebP/AVIF,最大 10 MB、4000 万像素,保存为最长 3840×2160 范围内的 WebP。修改素材影响同一 DSH 实例,替换/移除不保留旧原图。本工具不切换模式;保存后检查 activeSlots,必要时用 configure 启用对应模式。主题总开关不会自动打开。',
|
|
62
|
+
parameters:{type:'object',additionalProperties:false,properties:{
|
|
63
|
+
slot:{type:'string',enum:slots},action:{type:'string',enum:['replace','protect','remove']},
|
|
64
|
+
attachmentId:{type:'string'},region,revision,
|
|
65
|
+
},required:['slot','action','revision']},output,isConcurrencySafe:()=>false,
|
|
66
|
+
async execute(args,exec){
|
|
67
|
+
const images=sessionImages(exec.agent)
|
|
68
|
+
exec.signal?.throwIfAborted()
|
|
69
|
+
checkRevision(args.revision)
|
|
70
|
+
if(!slots.includes(args.slot)||!['replace','protect','remove'].includes(args.action))throw fault(400,'背景区域或操作无效')
|
|
71
|
+
if(args.revision!==service.snapshot().revision)throw fault(409,'背景已更新,请重新 inspect')
|
|
72
|
+
if(args.action!=='replace'&&args.attachmentId!==undefined)throw fault(400,'仅替换图片可以指定 attachmentId')
|
|
73
|
+
if(Object.hasOwn(args,'region')&&!validBackgroundRegion(args.region))throw fault(400,'背景保护区域无效')
|
|
74
|
+
const data={slot:args.slot,revision:args.revision}
|
|
75
|
+
if(args.action==='protect'){
|
|
76
|
+
if(!Object.hasOwn(args,'region'))throw fault(400,'请指定保护区域或 null')
|
|
77
|
+
data.region=args.region
|
|
78
|
+
}else if(args.action==='remove'){
|
|
79
|
+
if(Object.hasOwn(args,'region'))throw fault(400,'移除图片不需要保护区域')
|
|
80
|
+
data.image=null
|
|
81
|
+
}else{
|
|
82
|
+
const bytes=await readBackgroundAttachment(attachments,images,args.attachmentId,exec)
|
|
83
|
+
data.image=bytes.toString('base64')
|
|
84
|
+
if(Object.hasOwn(args,'region'))data.region=args.region
|
|
85
|
+
}
|
|
86
|
+
exec.signal?.throwIfAborted()
|
|
87
|
+
await service.save(data,exec.signal)
|
|
88
|
+
return {applied:true,...view(),previewHint:'可调用 prts_background_preview 查看当前遮罩下的日夜背景合成图;该预览不包含材质模糊和底色不透明度效果。'}
|
|
89
|
+
},
|
|
90
|
+
},{
|
|
91
|
+
name:'prts_background_configure',
|
|
92
|
+
description:'按用户要求修改当前 profile 的背景模式、遮罩和材质。material.blur 为 0..32 整数像素;material.opacity 为 0..1 底色不透明度,越高越不透明(与界面的透明度方向相反)。区域 sidebar=左侧边栏、rightSidebar=右侧边栏、rail=设置侧栏、baker=Baker 气泡、classicUser=经典用户消息。Baker 气泡仅在 deck-chat 风格、经典用户消息仅在 native 风格生效,且需要启用完整背景图片或分区正文图片;其它区域遵循当前背景模式的材质样式。配置可提前保存,不自动切换会话风格或启用图片。mode=off 默认背景(保留素材)、split 分区背景、full 完整背景;dim=0..0.9,越高越接近主题底色,图片越淡。先 inspect 获取 settings.revision,作为本工具 revision;不要使用图片 revision。只修改传入字段,冲突时重新读取,不能盲目重试。模式和遮罩同步到同一 profile 在线页面,素材在实例内共享。工具不修改主题总开关;enabled=false 时应告知用户需要启用主题。',
|
|
93
|
+
parameters:{type:'object',additionalProperties:false,properties:{
|
|
94
|
+
revision,mode:{type:'string',enum:['off','split','full']},dim:{type:'number',minimum:0,maximum:.9},material:materialSchema,
|
|
95
|
+
},required:['revision']},output,isConcurrencySafe:()=>false,
|
|
96
|
+
async execute(args,exec){
|
|
97
|
+
sessionImages(exec.agent);exec.signal?.throwIfAborted();checkRevision(args.revision)
|
|
98
|
+
const patch=args.material===undefined?{}:materialPatch(args.material)
|
|
99
|
+
if(args.mode!==undefined){if(!['off','split','full'].includes(args.mode))throw fault(400,'背景模式无效');patch.backgroundMode=args.mode}
|
|
100
|
+
if(args.dim!==undefined){if(!Number.isFinite(args.dim)||args.dim<0||args.dim>.9)throw fault(400,'遮罩 dim 必须在 0..0.9');patch.backgroundDim=args.dim}
|
|
101
|
+
if(!Object.keys(patch).length)throw fault(400,'请指定背景模式、遮罩或至少一项材质参数')
|
|
102
|
+
const current=readSettings()
|
|
103
|
+
if(!current.writable)throw fault(400,'当前宿主不支持持久化主题设置,请在设置界面调整背景和材质')
|
|
104
|
+
if(current.revision!==args.revision)throw fault(409,'主题设置已更新,请重新 inspect')
|
|
105
|
+
await host.get('settings').update(host.fiber.entry.options.id,{...patch,preferencesInitialized:true},args.revision)
|
|
106
|
+
return {applied:true,...view(),previewHint:'可调用 prts_background_preview 查看当前遮罩下的日夜背景合成图;该预览不包含材质模糊和底色不透明度效果。'}
|
|
107
|
+
},
|
|
108
|
+
}]
|
|
109
|
+
}
|
|
@@ -8,6 +8,14 @@ import { fault } from './emblems.js'
|
|
|
8
8
|
|
|
9
9
|
export const BACKGROUND_API = '/prts-theme-api/backgrounds'
|
|
10
10
|
const BACKGROUND_SLOTS = ['full', 'sidebar', 'conversation']
|
|
11
|
+
export async function prepareBackgroundImage(bytes) {
|
|
12
|
+
try {
|
|
13
|
+
const input=sharp(bytes,{limitInputPixels:40000000,animated:false})
|
|
14
|
+
const metadata=await input.metadata()
|
|
15
|
+
if(!['png','jpeg','webp','avif'].includes(metadata.format))throw Error('format')
|
|
16
|
+
return await input.rotate().resize({width:3840,height:2160,fit:'inside',withoutEnlargement:true}).webp({quality:86}).toBuffer({resolveWithObject:true})
|
|
17
|
+
}catch{throw fault(400,'无法读取图片,请使用 PNG、JPEG、WebP 或 AVIF(最多 4000 万像素)')}
|
|
18
|
+
}
|
|
11
19
|
export async function createBackgroundService(directory) {
|
|
12
20
|
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
13
21
|
const configurationPath = join(directory, 'configuration.json')
|
|
@@ -23,8 +31,9 @@ export async function createBackgroundService(directory) {
|
|
|
23
31
|
if (!BACKGROUND_SLOTS.some(slot => state[slot]?.id === id)) throw fault(404, '背景不存在')
|
|
24
32
|
return readFile(join(directory, id + '.webp'))
|
|
25
33
|
},
|
|
26
|
-
save(data) {
|
|
34
|
+
save(data, signal) {
|
|
27
35
|
const task = queue.then(async () => {
|
|
36
|
+
signal?.throwIfAborted()
|
|
28
37
|
if (!BACKGROUND_SLOTS.includes(data?.slot)) throw fault(400, '背景区域无效')
|
|
29
38
|
if (data.revision !== state.revision) throw fault(409, '背景已在其他页面更新,请重新操作')
|
|
30
39
|
const regionOnly = Object.hasOwn(data, 'region') && !Object.hasOwn(data, 'image')
|
|
@@ -35,20 +44,17 @@ export async function createBackgroundService(directory) {
|
|
|
35
44
|
if (!isBase64(data.image) || data.image.length > 14 * 1024 * 1024) throw fault(400, '请上传不超过 10 MB 的图片')
|
|
36
45
|
const bytes = Buffer.from(data.image, 'base64')
|
|
37
46
|
if (!bytes.length || bytes.length > 10 * 1024 * 1024) throw fault(400, '请上传不超过 10 MB 的图片')
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!['png', 'jpeg', 'webp', 'avif'].includes(metadata.format)) throw Error('format')
|
|
42
|
-
const result = await input.rotate().resize({ width: 3840, height: 2160, fit: 'inside', withoutEnlargement: true }).webp({ quality: 86 }).toBuffer({ resolveWithObject: true })
|
|
43
|
-
output = result.data
|
|
44
|
-
entry = { id: randomUUID(), width: result.info.width, height: result.info.height, region: data.region ?? null }
|
|
45
|
-
} catch { throw fault(400, '无法读取图片,请使用 PNG、JPEG、WebP 或 AVIF(最多 4000 万像素)') }
|
|
47
|
+
const result=await prepareBackgroundImage(bytes)
|
|
48
|
+
output=result.data
|
|
49
|
+
entry={id:randomUUID(),width:result.info.width,height:result.info.height,region:data.region ?? null}
|
|
46
50
|
}
|
|
51
|
+
signal?.throwIfAborted()
|
|
47
52
|
const next = { ...state, revision: state.revision + 1, [data.slot]: entry }
|
|
48
53
|
const temporary = configurationPath + '.' + randomUUID() + '.tmp'
|
|
49
54
|
try {
|
|
50
55
|
if (output) await writeFile(join(directory, entry.id + '.webp'), output, { mode: 0o600 })
|
|
51
56
|
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 })
|
|
57
|
+
signal?.throwIfAborted()
|
|
52
58
|
await rename(temporary, configurationPath)
|
|
53
59
|
} catch (error) {
|
|
54
60
|
if (output) await rm(join(directory, entry.id + '.webp'), { force: true }).catch(() => {})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const ENABLE_THEME_TOOLS = '请用户手动打开主界面左上角罗德岛徽记 → AI 工具 →「允许 AI 定制主题」。若导航栏已隐藏,先展开 P.R.T.S. 导航。'
|
|
2
|
+
export const THEME_TOOL_POLICY = '涉及 PRTS 背景、头像、徽记、配置导入导出时,先检查 prts_theme_status。若工具被关闭,应先提醒用户手动开启「允许 AI 定制主题」,等待用户开启后再继续;不要自行启用开关,也不要用 shell、文件编辑、HTTP 或其他工具绕过开关修改主题配置或素材。'
|
|
3
|
+
export function themeToolStatus(isEnabled){
|
|
4
|
+
return {
|
|
5
|
+
name:'prts_theme_status',
|
|
6
|
+
description:'只读查看 PRTS AI 定制工具是否开启及手动开启位置;关闭定制工具后此入口仍可用,不读取配置内容或素材。'+THEME_TOOL_POLICY,
|
|
7
|
+
parameters:{type:'object',properties:{},additionalProperties:false},
|
|
8
|
+
output:{schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)}]},
|
|
9
|
+
isConcurrencySafe:()=>true,
|
|
10
|
+
execute:async()=>({enabled:isEnabled(),nextAction:isEnabled()?'使用 PRTS 专用工具处理用户要求。':ENABLE_THEME_TOOLS,
|
|
11
|
+
policy:THEME_TOOL_POLICY,scope:'此状态入口只报告状态,不能更改开关。'}),
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/server/tools.js
CHANGED
|
@@ -2,23 +2,28 @@ import { draftView, stateView, fault } from './emblems.js'
|
|
|
2
2
|
|
|
3
3
|
export function sessionImages(agent) {
|
|
4
4
|
if(!agent?.session)throw fault(400,'请在 DSH 会话中调用此工具')
|
|
5
|
-
const images=new Map(), calls=new Map()
|
|
5
|
+
const images=new Map(), calls=new Map();let order=0
|
|
6
6
|
for(const event of agent.session.snapshotEvents()) {
|
|
7
7
|
if(event.type==='tool/call')calls.set(event.data.callId,event.data.name)
|
|
8
8
|
const uploaded=event.type==='user/message'
|
|
9
9
|
const message=uploaded?event.data:event.type==='tool/result'?event.data.message:null
|
|
10
10
|
const toolName=calls.get(message?.toolCallId ?? message?.source?.callId)
|
|
11
11
|
// Preview attachments are rendered particle images, not editable source images.
|
|
12
|
-
if(!message || message.isError ||
|
|
12
|
+
if(!message || message.isError || ['prts_emblem_preview','prts_background_preview'].includes(toolName))continue
|
|
13
13
|
for(const part of message.content ?? []) {
|
|
14
14
|
const ref=part.attachment
|
|
15
15
|
if(!ref || typeof ref.attachmentId!=='string' || !ref.attachmentId)continue
|
|
16
16
|
if(part.type!=='image' && !(part.type==='file' && (/^image\/(png|jpeg|webp|gif|avif)$/.test(ref.mediaType || '') || /\.(png|jpe?g|webp|gif|avif)$/i.test(ref.name || ''))))continue
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
images.set(ref.attachmentId,{...ref,attachmentKind:part.type,origin:uploaded?'upload':'tool',...(toolName?{toolName}:{}),
|
|
18
|
+
lastSeenOrder:++order,...(Number.isFinite(event.createdAt)?{lastSeenAt:new Date(event.createdAt).toISOString()}:{})})
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
const result=[...images.values()].sort((a,b)=>b.lastSeenOrder-a.lastSeenOrder),names=new Set()
|
|
22
|
+
return result.map(image=>{
|
|
23
|
+
const latestForName=Boolean(image.name && !names.has(image.name))
|
|
24
|
+
if(image.name)names.add(image.name)
|
|
25
|
+
return {...image,latestForName}
|
|
26
|
+
})
|
|
22
27
|
}
|
|
23
28
|
export function emblemTools(service,attachments) {
|
|
24
29
|
const output={schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)},...(value.previewImage&&value.imageForModel?[{type:'image',attachment:value.previewImage}]:[])]}
|
|
@@ -42,7 +47,7 @@ export function emblemTools(service,attachments) {
|
|
|
42
47
|
const ref=images.find(e=>e.attachmentId===args.attachmentId)
|
|
43
48
|
if(!ref)throw fault(404,'当前会话没有这张图片,请先上传图片或用 read_image 读取生成图,再重新读取图片列表')
|
|
44
49
|
if(ref.bytes>8*1024*1024)throw fault(400,'图片超过 8 MB')
|
|
45
|
-
const {attachmentKind,origin,toolName,...attachment}=ref
|
|
50
|
+
const {attachmentKind,origin,toolName,lastSeenOrder,lastSeenAt,latestForName,...attachment}=ref
|
|
46
51
|
if(attachmentKind==='file') {
|
|
47
52
|
const chunks=[];let total=0
|
|
48
53
|
for await(const chunk of attachments.readFileStream(attachment,exec.signal)) {
|