@ash-qw/dsh-theme-prts 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -8
- package/README.en.md +10 -8
- package/README.md +10 -8
- package/THIRD_PARTY_NOTICES.md +4 -2
- package/THIRD_PARTY_NOTICES.zh-CN.md +4 -2
- package/lib/client.js +402 -145
- package/package.json +2 -2
- package/src/assets/cursors/animation.json +56 -0
- package/src/assets/cursors/prts-grab.png +0 -0
- package/src/assets/cursors/prts-grabbing.png +0 -0
- package/src/assets/cursors/prts-not-allowed.png +0 -0
- package/src/client/cursor-adapter.js +2 -2
- package/src/client/custom-emblem.js +390 -55
- package/src/client/operations-shell-v2.js +0 -8
- package/src/client/theme-settings-workbench.js +25 -16
- package/src/index.js +1 -1
- package/src/server/api.js +3 -3
- package/src/server/emblems.js +51 -18
- package/src/server/image-worker.js +30 -13
- package/src/server/tools.js +19 -8
- package/src/styles/cursor.css +7 -1
- package/src/styles/prts.css +0 -8
- package/src/styles/refinement.css +2 -2
- package/src/styles/theme-settings-workbench.css +560 -676
- package/src/client/resize-shield-adapter.js +0 -69
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createThemeSettingsOverlay, themeSettingsMarkup } from './theme-settings-workbench.js'
|
|
2
|
-
import { createResizeShieldAdapter } from './resize-shield-adapter.js'
|
|
3
2
|
|
|
4
3
|
const PHONE_RAIL_BREAKPOINT = 640
|
|
5
4
|
const RAIL_HOLD_DELAY_MS = 350
|
|
@@ -53,7 +52,6 @@ export function createOperationsShell({
|
|
|
53
52
|
onRetrySave = () => {},
|
|
54
53
|
}) {
|
|
55
54
|
const root = document.documentElement
|
|
56
|
-
const resizeShield = createResizeShieldAdapter({ window })
|
|
57
55
|
let shell
|
|
58
56
|
let navRail
|
|
59
57
|
let railLauncher
|
|
@@ -463,11 +461,6 @@ export function createOperationsShell({
|
|
|
463
461
|
onResetVisual: onVisualReset,
|
|
464
462
|
onRetrySave,
|
|
465
463
|
})
|
|
466
|
-
resizeShield.start([
|
|
467
|
-
operationRegion,
|
|
468
|
-
shell.querySelector('[data-prts-theme-settings]'),
|
|
469
|
-
shell.querySelector('[data-prts-settings-backdrop]'),
|
|
470
|
-
])
|
|
471
464
|
conversationObserver = new window.MutationObserver(syncConversationState)
|
|
472
465
|
conversationObserver.observe(operationRegion, { childList: true, subtree: true, attributes: true, attributeFilter: ['hidden', 'data-chat-flow-kind', 'data-message-role'] })
|
|
473
466
|
schemeObserver = new window.MutationObserver(syncSchemeToggle)
|
|
@@ -520,7 +513,6 @@ export function createOperationsShell({
|
|
|
520
513
|
railViewportQuery = undefined
|
|
521
514
|
railPointerQuery = undefined
|
|
522
515
|
railHoverQuery = undefined
|
|
523
|
-
resizeShield.dispose()
|
|
524
516
|
settingsOverlay?.dispose()
|
|
525
517
|
conversationObserver?.disconnect()
|
|
526
518
|
schemeObserver?.disconnect()
|
|
@@ -121,12 +121,29 @@ export function themeSettingsMarkup() {
|
|
|
121
121
|
</header>
|
|
122
122
|
|
|
123
123
|
<div data-prts-settings-body>
|
|
124
|
+
<div data-prts-settings-sidebar>
|
|
124
125
|
<nav data-prts-settings-nav role="tablist" aria-label="设置分区">
|
|
125
126
|
<button type="button" data-prts-settings-jump="appearance" role="tab" id="prts-tab-appearance" aria-controls="prts-page-appearance" aria-selected="true" tabindex="0">外观</button>
|
|
126
127
|
<button type="button" data-prts-settings-jump="interaction" role="tab" id="prts-tab-interaction" aria-controls="prts-page-interaction" aria-selected="false" tabindex="-1">交互</button>
|
|
127
128
|
<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
129
|
<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
130
|
</nav>
|
|
131
|
+
<footer data-prts-settings-actions aria-label="全局主题设置操作">
|
|
132
|
+
<div data-prts-persistence-error hidden role="alert">
|
|
133
|
+
<span><strong>仅本次会话生效</strong><small>无法写入本地设置,当前视觉效果仍会保留。</small></span>
|
|
134
|
+
<button type="button" data-prts-retry-save>重试保存</button>
|
|
135
|
+
</div>
|
|
136
|
+
<div data-prts-reset-zone>
|
|
137
|
+
|
|
138
|
+
<button type="button" data-prts-reset-visual title="恢复全部外观设置" aria-label="恢复全部外观设置">恢复默认</button>
|
|
139
|
+
<div data-prts-reset-confirm hidden role="group" aria-label="确认恢复全部外观设置">
|
|
140
|
+
<span>重置四个分类的外观参数,保留主题开关、启动动画和徽记配置。</span>
|
|
141
|
+
<button type="button" data-prts-reset-confirm-action>确认恢复</button>
|
|
142
|
+
<button type="button" data-prts-reset-cancel>取消</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</footer>
|
|
146
|
+
</div>
|
|
130
147
|
<div data-prts-theme-settings-scroll>
|
|
131
148
|
<section data-prts-settings-section="appearance" role="tabpanel" id="prts-page-appearance" aria-labelledby="prts-tab-appearance" tabindex="0">
|
|
132
149
|
<header data-prts-section-heading><strong>外观</strong><small>预设、界面材质与会话样式</small></header>
|
|
@@ -178,23 +195,9 @@ export function themeSettingsMarkup() {
|
|
|
178
195
|
</div>
|
|
179
196
|
</section>
|
|
180
197
|
|
|
181
|
-
<footer data-prts-settings-actions>
|
|
182
|
-
<div data-prts-persistence-error hidden role="alert">
|
|
183
|
-
<span><strong>仅本次会话生效</strong><small>无法写入本地设置,当前视觉效果仍会保留。</small></span>
|
|
184
|
-
<button type="button" data-prts-retry-save>重试保存</button>
|
|
185
|
-
</div>
|
|
186
|
-
<div data-prts-reset-zone>
|
|
187
|
-
<span><strong>恢复主题默认</strong><small>保留主题开关和启动动画设置</small></span>
|
|
188
|
-
<button type="button" data-prts-reset-visual>恢复默认</button>
|
|
189
|
-
<div data-prts-reset-confirm hidden role="group" aria-label="确认恢复主题默认">
|
|
190
|
-
<span>确认恢复全部主题参数?</span>
|
|
191
|
-
<button type="button" data-prts-reset-confirm-action>确认恢复</button>
|
|
192
|
-
<button type="button" data-prts-reset-cancel>取消</button>
|
|
193
|
-
</div>
|
|
194
|
-
</div>
|
|
195
|
-
</footer>
|
|
196
198
|
</div>
|
|
197
199
|
</div>
|
|
200
|
+
|
|
198
201
|
</div>
|
|
199
202
|
</dialog>`
|
|
200
203
|
}
|
|
@@ -290,6 +293,7 @@ export function createThemeSettingsOverlay({
|
|
|
290
293
|
panel.setAttribute('data-prts-settings-visible', '')
|
|
291
294
|
;(panel.querySelector('[data-prts-theme-settings-close]') ?? focusable()[0] ?? panel).focus?.()
|
|
292
295
|
} else {
|
|
296
|
+
customEditor.close?.()
|
|
293
297
|
panel.removeAttribute('data-prts-settings-visible')
|
|
294
298
|
hidePanel()
|
|
295
299
|
setResetConfirmation(false)
|
|
@@ -530,6 +534,7 @@ export function createThemeSettingsOverlay({
|
|
|
530
534
|
const scroller = panel?.querySelector('[data-prts-theme-settings-scroll]')
|
|
531
535
|
const section = panel?.querySelector(`[data-prts-settings-section="${key}"]`)
|
|
532
536
|
if (!scroller || !section) return
|
|
537
|
+
if (key === 'particles') void customEditor.prefetch?.()
|
|
533
538
|
if (key !== activeSection) {
|
|
534
539
|
sectionScroll.set(activeSection, scroller.scrollTop)
|
|
535
540
|
customEditor.cancelReorder?.()
|
|
@@ -639,7 +644,11 @@ export function createThemeSettingsOverlay({
|
|
|
639
644
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
|
640
645
|
}
|
|
641
646
|
|
|
642
|
-
function onCancel(event) {
|
|
647
|
+
function onCancel(event) {
|
|
648
|
+
// File inputs also emit bubbling cancel events when their picker is dismissed.
|
|
649
|
+
if (event.target !== panel) return
|
|
650
|
+
event.preventDefault(); setOpen(false)
|
|
651
|
+
}
|
|
643
652
|
const toggle = () => setOpen(!isOpen)
|
|
644
653
|
const closeFromBackdrop = () => setOpen(false)
|
|
645
654
|
const renderMediaState = () => renderState()
|
package/src/index.js
CHANGED
|
@@ -12,5 +12,5 @@ export async function apply(ctx) {
|
|
|
12
12
|
const service=await createEmblemService(directory)
|
|
13
13
|
const disposers=emblemTools(service,ctx.attachments).map(tool=>ctx.tools.register(tool))
|
|
14
14
|
disposers.push(registerEmblemApi(ctx,service))
|
|
15
|
-
ctx.on('dispose',()=>{for(const dispose of disposers.reverse())dispose()})
|
|
15
|
+
ctx.on('dispose',async()=>{for(const dispose of disposers.reverse())dispose();await service.dispose()})
|
|
16
16
|
}
|
package/src/server/api.js
CHANGED
|
@@ -28,7 +28,7 @@ export function registerEmblemApi(ctx,service) {
|
|
|
28
28
|
if(req.method==='GET'&&exported){json(res,200,await service.exportAsset(exported[1]));return}
|
|
29
29
|
const match=path.match(/^\/draft\/([a-zA-Z0-9-]+)(?:\/(preview\.png|source))?$/)
|
|
30
30
|
if(req.method==='GET'&&match){
|
|
31
|
-
if(!match[2]){const d=await service.draft(match[1]);json(res,200,{...draftView(d),value:d.value});return}
|
|
31
|
+
if(!match[2]){const d=await service.draft(match[1]);json(res,200,{...draftView(d),value:d.value,previewPoints:d.previewPoints});return}
|
|
32
32
|
const bytes=await service.image(match[1],match[2]==='source')
|
|
33
33
|
res.writeHead(200,{'content-type':match[2]==='source'?'application/octet-stream':'image/png','cache-control':'private, max-age=3600','x-content-type-options':'nosniff'});res.end(bytes);return
|
|
34
34
|
}
|
|
@@ -40,12 +40,12 @@ export function registerEmblemApi(ctx,service) {
|
|
|
40
40
|
if(path==='/configuration'){json(res,200,await service.save(data.configuration,data.revision,controller.signal));return}
|
|
41
41
|
if(path==='/import'){
|
|
42
42
|
const d=await service.importAsset(data,controller.signal)
|
|
43
|
-
json(res,200,{...draftView(d),value:d.value});return
|
|
43
|
+
json(res,200,{...draftView(d),value:d.value,previewPoints:d.previewPoints});return
|
|
44
44
|
}
|
|
45
45
|
if(path==='/preview'){
|
|
46
46
|
if(data.image && (typeof data.image!=='string'||! /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data.image)))throw fault(400,'图片编码无效')
|
|
47
47
|
const d=await service.preview({bytes:data.image?Buffer.from(data.image,'base64'):undefined,baseId:data.draftId,params:data.params,label:data.label},controller.signal)
|
|
48
|
-
json(res,200,{...draftView(d),value:d.value});return
|
|
48
|
+
json(res,200,{...draftView(d),value:d.value,previewPoints:d.previewPoints});return
|
|
49
49
|
}
|
|
50
50
|
throw fault(404,'接口不存在')
|
|
51
51
|
}catch(error){if(!res.headersSent)json(res,error.status||500,{error:error.message});else res.end()}
|
package/src/server/emblems.js
CHANGED
|
@@ -8,36 +8,43 @@ export const EMBLEMS = [
|
|
|
8
8
|
['rhodes-island','罗德岛'],['lungmen','龙门'],['penguin-logistics','企鹅物流'],['rhine-lab','莱茵生命'],['reunion','整合运动'],
|
|
9
9
|
].map(([key,label])=>({key,label}))
|
|
10
10
|
export const API = '/prts-theme-api/emblems'
|
|
11
|
-
export const DEFAULT_PARAMS = Object.freeze({mode:'auto',threshold:128,zoom:.85,x:0,y:0})
|
|
11
|
+
export const DEFAULT_PARAMS = Object.freeze({mode:'auto',lineSource:'edges',cleanup:2,threshold:128,zoom:.85,x:0,y:0})
|
|
12
12
|
export function fault(status,message) { return Object.assign(new Error(message),{status}) }
|
|
13
13
|
export function parameters(input = {}, base = DEFAULT_PARAMS) {
|
|
14
14
|
if (!input || typeof input !== 'object' || Array.isArray(input)) throw fault(400,'编辑参数无效')
|
|
15
|
-
const result = {...base,...input}
|
|
16
|
-
if (Object.keys(result).some(k=>!Object.hasOwn(DEFAULT_PARAMS,k)) || !['auto','alpha','dark','light'].includes(result.mode)
|
|
15
|
+
const result = {...DEFAULT_PARAMS,...base,...input}
|
|
16
|
+
if (Object.keys(result).some(k=>!Object.hasOwn(DEFAULT_PARAMS,k)) || !['auto','alpha','dark','light','lineart'].includes(result.mode)
|
|
17
|
+
|| !['edges','ink'].includes(result.lineSource) || !Number.isInteger(result.cleanup) || result.cleanup<0 || result.cleanup>12
|
|
17
18
|
|| !Number.isInteger(result.threshold) || result.threshold<1 || result.threshold>254
|
|
18
19
|
|| !Number.isFinite(result.zoom) || result.zoom<.5 || result.zoom>2
|
|
19
20
|
|| !Number.isFinite(result.x) || Math.abs(result.x)>.5 || !Number.isFinite(result.y) || Math.abs(result.y)>.5) throw fault(400,'编辑参数超出范围')
|
|
20
21
|
return result
|
|
21
22
|
}
|
|
22
|
-
export function processImage(bytes, params, signal) {
|
|
23
|
+
export function processImage(bytes, params, signal, prepared, reusableWorker) {
|
|
23
24
|
signal?.throwIfAborted()
|
|
24
25
|
return new Promise((resolve,reject)=>{
|
|
25
|
-
const worker = new Worker(new URL('./image-worker.js',import.meta.url), {workerData:{bytes,params},execArgv:[]})
|
|
26
|
+
const worker = reusableWorker || new Worker(new URL('./image-worker.js',import.meta.url), {workerData:{bytes,params,prepared},execArgv:[]})
|
|
27
|
+
worker.ref()
|
|
26
28
|
let settled = false
|
|
27
29
|
const finish = async (error,value) => {
|
|
28
30
|
if(settled)return; settled=true;clearTimeout(timer);signal?.removeEventListener('abort',abort)
|
|
29
|
-
await worker.terminate()
|
|
31
|
+
if(error || !reusableWorker) await worker.terminate()
|
|
32
|
+
worker.off('error',onError);worker.off('exit',onExit);worker.off('message',onMessage)
|
|
33
|
+
worker.unref()
|
|
30
34
|
if(error)reject(error);else resolve(value)
|
|
31
35
|
}
|
|
32
36
|
const abort = ()=>void finish(signal.reason || new Error('已取消'))
|
|
37
|
+
const onError = error=>void finish(error)
|
|
38
|
+
const onExit = code=>{if(!settled)void finish(new Error(`图片处理进程提前退出 (${code})`))}
|
|
39
|
+
const onMessage = result=>void finish(result.error?fault(400,result.error):null,result)
|
|
33
40
|
const timer = setTimeout(()=>void finish(fault(408,'图片处理超时,请缩小图片后重试')),15000)
|
|
34
41
|
signal?.addEventListener('abort',abort,{once:true})
|
|
35
|
-
worker.once('error',
|
|
36
|
-
worker.once('exit',code=>{if(!settled)void finish(new Error(`图片处理进程提前退出 (${code})`))})
|
|
37
|
-
worker.once('message',result=>void finish(result.error?fault(400,result.error):null,result))
|
|
42
|
+
worker.once('error',onError);worker.once('exit',onExit);worker.once('message',onMessage)
|
|
38
43
|
if(signal?.aborted)abort()
|
|
44
|
+
else if(reusableWorker) worker.postMessage({bytes,params,prepared})
|
|
39
45
|
})
|
|
40
46
|
}
|
|
47
|
+
|
|
41
48
|
async function atomic(path,value) {
|
|
42
49
|
const temp = `${path}.${randomUUID()}.tmp`
|
|
43
50
|
await writeFile(temp,value,{mode:0o600});await rename(temp,path)
|
|
@@ -53,7 +60,7 @@ export async function createEmblemService(directory) {
|
|
|
53
60
|
if(!Number.isInteger(stored.revision)||!validEmblemConfiguration(stored.configuration,EMBLEMS))throw Error('已保存的徽记配置无效')
|
|
54
61
|
state=stored
|
|
55
62
|
} catch(error) { if(error.code!=='ENOENT')throw error }
|
|
56
|
-
let queue=Promise.resolve(),
|
|
63
|
+
let queue=Promise.resolve(), previewQueue=Promise.resolve(), preparedCache, imageWorker, workerIdleTimer, disposed=false
|
|
57
64
|
const listeners=new Set()
|
|
58
65
|
const serialize=fn=>{const next=queue.then(fn);queue=next.catch(()=>{});return next}
|
|
59
66
|
const snapshot=()=>structuredClone(state)
|
|
@@ -84,6 +91,11 @@ export async function createEmblemService(directory) {
|
|
|
84
91
|
})
|
|
85
92
|
return {
|
|
86
93
|
snapshot,save,draft,
|
|
94
|
+
async dispose() {
|
|
95
|
+
disposed=true;clearTimeout(workerIdleTimer);preparedCache=undefined
|
|
96
|
+
const worker=imageWorker;imageWorker=undefined
|
|
97
|
+
if(worker)await worker.terminate()
|
|
98
|
+
},
|
|
87
99
|
async exportAsset(id) {
|
|
88
100
|
const d=await draft(id)
|
|
89
101
|
const bytes=await readFile(join(directory,`${d.sourceId}.source`))
|
|
@@ -101,10 +113,10 @@ export async function createEmblemService(directory) {
|
|
|
101
113
|
},
|
|
102
114
|
previewPath: id=>join(directory,`${checkedId(id)}.png`),
|
|
103
115
|
subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn)},
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
preview({bytes,params={},baseId,label},signal) {
|
|
117
|
+
const pending = previewQueue.then(async () => {
|
|
118
|
+
if(disposed)throw fault(503,'徽记服务已关闭')
|
|
119
|
+
signal?.throwIfAborted()
|
|
108
120
|
const previous=baseId?await draft(baseId):null
|
|
109
121
|
const effective=parameters(params,previous?.params)
|
|
110
122
|
if(previous)bytes=await readFile(join(directory,`${previous.sourceId}.source`))
|
|
@@ -119,15 +131,36 @@ export async function createEmblemService(directory) {
|
|
|
119
131
|
for(const d of expired){await unlink(join(directory,`${d.id}.draft.json`));await unlink(join(directory,`${d.id}.png`))}
|
|
120
132
|
for(const sourceId of new Set(expired.map(d=>d.sourceId)))if(!retainedSources.has(sourceId))await unlink(join(directory,`${sourceId}.source`))
|
|
121
133
|
})
|
|
122
|
-
const result=await processImage(bytes,effective,signal)
|
|
123
134
|
signal?.throwIfAborted()
|
|
124
|
-
const
|
|
125
|
-
const
|
|
135
|
+
const sourceHash=createHash('sha256').update(bytes).digest('hex')
|
|
136
|
+
const cacheKey=`${sourceHash}:${effective.zoom}`
|
|
137
|
+
clearTimeout(workerIdleTimer)
|
|
138
|
+
if (!imageWorker || imageWorker.threadId === -1) imageWorker = new Worker(new URL('./image-worker.js',import.meta.url),{execArgv:[]})
|
|
139
|
+
let result
|
|
140
|
+
try {
|
|
141
|
+
result=await processImage(bytes,effective,signal,preparedCache?.key===cacheKey?preparedCache.data:undefined,imageWorker)
|
|
142
|
+
} finally {
|
|
143
|
+
// Reuse the warmed-up worker while editing, then release its memory.
|
|
144
|
+
if (!disposed) {
|
|
145
|
+
workerIdleTimer=setTimeout(()=>{
|
|
146
|
+
void imageWorker?.terminate();imageWorker=undefined;preparedCache=undefined
|
|
147
|
+
},30000)
|
|
148
|
+
workerIdleTimer.unref()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if(disposed)throw fault(503,'徽记服务已关闭')
|
|
152
|
+
preparedCache={key:cacheKey,data:result.prepared}
|
|
153
|
+
signal?.throwIfAborted()
|
|
154
|
+
const id=randomUUID(),sourceId=sourceHash
|
|
155
|
+
const value={id,sourceId,params:effective,label:String(label||previous?.label||'自定义徽记').slice(0,100),createdAt:Date.now(),value:result.value,previewPoints:result.previewPoints}
|
|
126
156
|
await atomic(join(directory,`${sourceId}.source`),bytes)
|
|
127
157
|
await atomic(join(directory,`${id}.png`),result.preview)
|
|
128
158
|
await atomic(join(directory,`${id}.draft.json`),JSON.stringify(value))
|
|
129
159
|
return value
|
|
130
|
-
}
|
|
160
|
+
})
|
|
161
|
+
// A cancelled worker must fully exit before the next request starts.
|
|
162
|
+
previewQueue=pending.catch(()=>{})
|
|
163
|
+
return pending
|
|
131
164
|
},
|
|
132
165
|
async image(id,source=false) {const d=await draft(id);return readFile(join(directory,source?`${d.sourceId}.source`:`${d.id}.png`))},
|
|
133
166
|
async apply({draftId,target,entryKey,revision},signal) {
|
|
@@ -2,31 +2,48 @@ import { parentPort, workerData } from 'node:worker_threads'
|
|
|
2
2
|
import sharp from 'sharp'
|
|
3
3
|
import { processCustomEmblemPixels } from '../client/custom-emblem.js'
|
|
4
4
|
|
|
5
|
+
async function run(job) {
|
|
5
6
|
try {
|
|
6
|
-
const { bytes, params } =
|
|
7
|
+
const { bytes, params } = job
|
|
8
|
+
let prepared = job.prepared
|
|
9
|
+
if (!prepared) {
|
|
7
10
|
const image = sharp(Buffer.from(bytes), { limitInputPixels: 16000000, failOn: 'error' }).rotate()
|
|
8
11
|
const metadata = await image.metadata()
|
|
9
12
|
if (!['png','jpeg','webp'].includes(metadata.format)) throw new Error('仅支持 PNG、JPEG、WebP 图片')
|
|
10
13
|
const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true })
|
|
11
|
-
let
|
|
12
|
-
if (
|
|
13
|
-
mode = 'dark'
|
|
14
|
-
for (let i = 3; i < data.length; i += 4) if (data[i] < 128) { mode = 'alpha'; break }
|
|
15
|
-
}
|
|
14
|
+
let autoMode = 'dark'
|
|
15
|
+
for (let i = 3; i < data.length; i += 4) if (data[i] < 128) { autoMode = 'alpha'; break }
|
|
16
16
|
const scale = 320 / Math.max(info.width, info.height) * params.zoom
|
|
17
17
|
const width = Math.max(1, Math.round(info.width * scale)), height = Math.max(1, Math.round(info.height * scale))
|
|
18
18
|
const resized = await sharp(data, { raw: info }).resize(width, height).raw().toBuffer()
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
// Extract the entire centered image before applying the viewport translation.
|
|
20
|
+
const size = Math.max(320, width, height)
|
|
21
|
+
const rgba = new Uint8ClampedArray(size * size * 4)
|
|
22
|
+
const left = Math.round((size-width)/2), top = Math.round((size-height)/2)
|
|
23
|
+
for (let y = Math.max(0,top); y < Math.min(size,top+height); y++) {
|
|
24
|
+
const x = Math.max(0,left), end = Math.min(size,left+width)
|
|
25
|
+
if (end > x) rgba.set(resized.subarray(((y-top)*width+x-left)*4,((y-top)*width+end-left)*4),(y*size+x)*4)
|
|
26
|
+
}
|
|
27
|
+
prepared = {rgba,size,autoMode}
|
|
24
28
|
}
|
|
25
|
-
const
|
|
29
|
+
const {rgba,size,autoMode} = prepared
|
|
30
|
+
const mode = params.mode === 'auto' ? autoMode : params.mode
|
|
31
|
+
const value = processCustomEmblemPixels(rgba,size,mode,params.threshold,undefined,params)
|
|
32
|
+
let previewPoints
|
|
33
|
+
for (const [density, points] of Object.entries(value.targets)) {
|
|
34
|
+
const shifted = points.map(p => ({...p, x:p.x*size/320+params.x, y:p.y*size/320+params.y}))
|
|
35
|
+
if (density === 'standard') previewPoints = shifted
|
|
36
|
+
value.targets[density] = shifted.filter(p => Math.abs(p.x)<=.5 && Math.abs(p.y)<=.5)
|
|
37
|
+
}
|
|
38
|
+
if (Object.values(value.targets).some(points => points.length < 4)) throw new Error('取景框内可用轮廓太少,请调整位置或缩放')
|
|
26
39
|
const dots = value.targets.standard.map(p => {
|
|
27
40
|
const r = p.kind === 'skeleton' ? 1.2 : .9
|
|
28
41
|
return `<circle cx="${192+p.x*350}" cy="${192+p.y*350}" r="${r}"/>`
|
|
29
42
|
}).join('')
|
|
30
43
|
const preview = await sharp(Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="384" height="384"><rect width="384" height="384" fill="#0d1114"/><g fill="#edf1f2">${dots}</g></svg>`)).png().toBuffer()
|
|
31
|
-
parentPort.postMessage({ value, preview })
|
|
44
|
+
parentPort.postMessage({ value, preview, previewPoints, prepared })
|
|
32
45
|
} catch (error) { parentPort.postMessage({ error: error.message }) }
|
|
46
|
+
|
|
47
|
+
}
|
|
48
|
+
parentPort.on('message',run)
|
|
49
|
+
if (workerData?.bytes) void run(workerData)
|
package/src/server/tools.js
CHANGED
|
@@ -2,10 +2,21 @@ 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()
|
|
5
|
+
const images=new Map(), calls=new Map()
|
|
6
6
|
for(const event of agent.session.snapshotEvents()) {
|
|
7
|
-
if(event.type
|
|
8
|
-
|
|
7
|
+
if(event.type==='tool/call')calls.set(event.data.callId,event.data.name)
|
|
8
|
+
const uploaded=event.type==='user/message'
|
|
9
|
+
const message=uploaded?event.data:event.type==='tool/result'?event.data.message:null
|
|
10
|
+
const toolName=calls.get(message?.toolCallId ?? message?.source?.callId)
|
|
11
|
+
// Preview attachments are rendered particle images, not editable source images.
|
|
12
|
+
if(!message || message.isError || toolName==='prts_emblem_preview')continue
|
|
13
|
+
for(const part of message.content ?? []) {
|
|
14
|
+
const ref=part.attachment
|
|
15
|
+
if(!ref || typeof ref.attachmentId!=='string' || !ref.attachmentId)continue
|
|
16
|
+
if(part.type!=='image' && !(part.type==='file' && (/^image\/(png|jpeg|webp)$/.test(ref.mediaType || '') || /\.(png|jpe?g|webp)$/i.test(ref.name || ''))))continue
|
|
17
|
+
if(images.has(ref.attachmentId))continue
|
|
18
|
+
images.set(ref.attachmentId,{...ref,attachmentKind:part.type,origin:uploaded?'upload':'tool',...(toolName?{toolName}:{})})
|
|
19
|
+
}
|
|
9
20
|
}
|
|
10
21
|
return [...images.values()]
|
|
11
22
|
}
|
|
@@ -13,15 +24,15 @@ export function emblemTools(service,attachments) {
|
|
|
13
24
|
const output={schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)},...(value.previewImage&&value.imageForModel?[{type:'image',attachment:value.previewImage}]:[])]}
|
|
14
25
|
return [{
|
|
15
26
|
name:'prts_emblem_inspect',
|
|
16
|
-
description:'查看 PRTS
|
|
27
|
+
description:'查看 PRTS 主题的新会话徽记、正文轮换列表、配置版本,以及当前会话上传或工具返回的图片。仅当用户要求查看或调整主题徽记时使用。徽记设置跨工作区和会话共享;工具不会修改设置。',
|
|
17
28
|
parameters:{type:'object',properties:{},additionalProperties:false},output,isConcurrencySafe:()=>true,
|
|
18
29
|
async execute(_args,exec){return JSON.parse(JSON.stringify({...stateView(service.snapshot()),images:sessionImages(exec.agent)}))}
|
|
19
30
|
},{
|
|
20
31
|
name:'prts_emblem_preview',
|
|
21
|
-
description:'
|
|
32
|
+
description:'将当前会话上传或工具返回的图片制作成 PRTS 粒子徽记草稿,或继续调整已有草稿。传 attachmentId(来自 inspect)或 draftId,二选一。调整已有正式徽记可使用 inspect 返回的 draftId。仅生成预览,不应用。优先原样使用返回的 previewMarkdown 展示给用户;没有此字段时,告知用户可展开工具卡片查看。不要将 previewUrl 当成本地图片路径。AI 生成或编辑的本地图片先用 read_image 读取,再 inspect 获取 attachmentId,无需用户重新上传。线稿效果使用 params.mode=lineart,阈值越高保留的轮廓越少;彩色图用 lineSource=edges,纯线稿用 lineSource=ink 避免双边缘;cleanup=0..12 去除孤立碎线,五官细节丢失时降低到 0。无需自行对齐粒子格点。标准密度预览不是原图。用户满意后使用 apply。不要声称已看到预览,除非实际通过视觉工具查看。',
|
|
22
33
|
parameters:{type:'object',additionalProperties:false,properties:{
|
|
23
34
|
attachmentId:{type:'string'},draftId:{type:'string'},label:{type:'string',maxLength:100},
|
|
24
|
-
params:{type:'object',additionalProperties:false,properties:{mode:{type:'string',enum:['auto','alpha','dark','light']},threshold:{type:'integer',minimum:1,maximum:254},zoom:{type:'number',minimum:.5,maximum:2},x:{type:'number',minimum:-.5,maximum:.5},y:{type:'number',minimum:-.5,maximum:.5}}},
|
|
35
|
+
params:{type:'object',additionalProperties:false,properties:{mode:{type:'string',enum:['auto','alpha','dark','light','lineart']},lineSource:{type:'string',enum:['edges','ink']},cleanup:{type:'integer',minimum:0,maximum:12},threshold:{type:'integer',minimum:1,maximum:254},zoom:{type:'number',minimum:.5,maximum:2},x:{type:'number',minimum:-.5,maximum:.5},y:{type:'number',minimum:-.5,maximum:.5}}},
|
|
25
36
|
}},output,isConcurrencySafe:()=>false,
|
|
26
37
|
async execute(args,exec){
|
|
27
38
|
const images=sessionImages(exec.agent)
|
|
@@ -29,9 +40,9 @@ export function emblemTools(service,attachments) {
|
|
|
29
40
|
let bytes,label=args.label
|
|
30
41
|
if(args.attachmentId) {
|
|
31
42
|
const ref=images.find(e=>e.attachmentId===args.attachmentId)
|
|
32
|
-
if(!ref)throw fault(404,'
|
|
43
|
+
if(!ref)throw fault(404,'当前会话没有这张图片,请先上传图片或用 read_image 读取生成图,再重新读取图片列表')
|
|
33
44
|
if(ref.bytes>8*1024*1024)throw fault(400,'图片超过 8 MB')
|
|
34
|
-
const {attachmentKind,...attachment}=ref
|
|
45
|
+
const {attachmentKind,origin,toolName,...attachment}=ref
|
|
35
46
|
if(attachmentKind==='file') {
|
|
36
47
|
const chunks=[];let total=0
|
|
37
48
|
for await(const chunk of attachments.readFileStream(attachment,exec.signal)) {
|
package/src/styles/cursor.css
CHANGED
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
html[data-dsh-prts][data-prts-cursor="prts"] :is(textarea, input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="url"], input[type="password"], input[type="number"], input[type="tel"], [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]) {
|
|
16
16
|
cursor: var(--prts-text-cursor), text;
|
|
17
17
|
}
|
|
18
|
+
html[data-dsh-prts][data-prts-cursor="prts"] :is(.dsh-partner-pendant-hit, .dsh-partner-pendant-anchor, [data-prts-emblem-reorder]):enabled {
|
|
19
|
+
cursor: url("__PRTS_GRAB__") 22 19, grab;
|
|
20
|
+
}
|
|
21
|
+
html[data-dsh-prts][data-prts-cursor="prts"] :is(.dsh-partner-pendant-hit, .dsh-partner-pendant-anchor, [data-prts-emblem-reorder]):enabled:active {
|
|
22
|
+
cursor: url("__PRTS_GRABBING__") 22 19, grabbing;
|
|
23
|
+
}
|
|
18
24
|
html[data-dsh-prts][data-prts-cursor="prts"] :is(:disabled, [aria-disabled="true"]) {
|
|
19
|
-
cursor: not-allowed !important;
|
|
25
|
+
cursor: url("__PRTS_NOT_ALLOWED__") 7 7, not-allowed !important;
|
|
20
26
|
}
|
|
21
27
|
}
|
package/src/styles/prts.css
CHANGED
|
@@ -1461,14 +1461,6 @@ html[data-dsh-prts] [data-prts-particle-layer] {
|
|
|
1461
1461
|
transition: opacity 120ms ease-out;
|
|
1462
1462
|
}
|
|
1463
1463
|
|
|
1464
|
-
/* Keep the host frame measurable while pruning the expensive operation subtree
|
|
1465
|
-
during a viewport resize burst. Navigation and the native sessions sidebar
|
|
1466
|
-
remain rendered; the shield is removed after the viewport settles. */
|
|
1467
|
-
html[data-dsh-prts] [data-prts-resize-shield] {
|
|
1468
|
-
content-visibility: hidden !important;
|
|
1469
|
-
contain-intrinsic-size: auto 800px;
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
1464
|
/* Resize state lives on the small visual surfaces that need it, never on the root tree. */
|
|
1473
1465
|
html[data-dsh-prts] [data-prts-particle-layer][data-prts-resizing] {
|
|
1474
1466
|
opacity: .78;
|
|
@@ -203,8 +203,8 @@ html[data-dsh-prts] [data-prts-composer-fallback]::after {
|
|
|
203
203
|
calc(100% - 2px) 0, 100% 2px, 100% calc(100% - 2px), calc(100% - 2px) 100%,
|
|
204
204
|
2px 100%, 0 calc(100% - 2px), 0 2px
|
|
205
205
|
);
|
|
206
|
-
backdrop-filter:
|
|
207
|
-
-webkit-backdrop-filter:
|
|
206
|
+
backdrop-filter: none;
|
|
207
|
+
-webkit-backdrop-filter: none;
|
|
208
208
|
pointer-events: none;
|
|
209
209
|
}
|
|
210
210
|
|