@ash-qw/dsh-theme-prts 1.2.2 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web and the official desktop client",
@@ -103,6 +103,52 @@ function cssUrl(value) {
103
103
 
104
104
  export function createAssistantGlassAdapter({ document, window, avatarImage = '' }) {
105
105
  let observer
106
+ let observationRoot
107
+ let groupAvatars = true
108
+ let avatarsDirty = true
109
+ const avatarSteps = new Set()
110
+ const emptySteps = new Set()
111
+ function refreshAvatars() {
112
+ if (!avatarsDirty || !observationRoot) return
113
+ avatarsDirty = false
114
+ for (const step of avatarSteps) {
115
+ if (!observationRoot.contains(step)) {
116
+ step.removeAttribute('data-prts-avatar-hidden')
117
+ avatarSteps.delete(step)
118
+ }
119
+ }
120
+ emptySteps.clear()
121
+ if (!groupAvatars) {
122
+ for (const step of avatarSteps) step.removeAttribute('data-prts-avatar-hidden')
123
+ avatarSteps.clear()
124
+ return
125
+ }
126
+ const groups = new Map()
127
+ for (const node of observationRoot.querySelectorAll('[data-chat-flow-kind="user"], [data-chat-flow-kind="steering"], ' + STEP_SELECTOR)) {
128
+ const scope = node.closest('[data-conversation-scroll]') ?? observationRoot
129
+ let group = groups.get(scope)
130
+ if (!group) groups.set(scope, group = new Set())
131
+ if (node.getAttribute('data-chat-flow-kind') !== 'assistant-step') {
132
+ if (node.closest('[hidden], [data-turn-process-hidden="true"]')) continue
133
+ group.clear()
134
+ continue
135
+ }
136
+ const surface = stepSurfaces.get(node) ?? resolveAssistantSurface(node)
137
+ const hasContent = Boolean(surface?.textContent?.trim() || surface?.querySelector('img,video,audio,canvas,svg'))
138
+ if (!hasContent) emptySteps.add(node)
139
+ const eligible = hasContent && node.getAttribute('data-chat-group-part') !== 'reasoning'
140
+ && !node.closest('[hidden], [data-turn-process-hidden="true"]')
141
+ const turn = node.getAttribute('data-chat-turn') ?? 'unassigned'
142
+ // The compact-answer flag disappears when expanded; process membership
143
+ // remains stable, so final responses keep their independent avatars.
144
+ const process = node.hasAttribute('data-turn-process-member')
145
+ const show = eligible && (!process || !group.has(turn))
146
+ if (show && process) group.add(turn)
147
+ if (show) node.removeAttribute('data-prts-avatar-hidden')
148
+ else if (!node.hasAttribute('data-prts-avatar-hidden')) node.setAttribute('data-prts-avatar-hidden', '')
149
+ avatarSteps.add(node)
150
+ }
151
+ }
106
152
  let scanFrame
107
153
  const ownedSurfaces = new Set()
108
154
  const ownedSteps = new Set()
@@ -198,6 +244,7 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
198
244
  }
199
245
 
200
246
  function scan() {
247
+ refreshAvatars()
201
248
  const startedAt = window?.performance?.now?.() ?? Date.now()
202
249
  let processed = 0
203
250
  for (const step of pendingSteps) {
@@ -224,10 +271,16 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
224
271
 
225
272
  function onMutations(mutations) {
226
273
  for (const mutation of mutations) {
274
+ if (mutation.type === 'characterData') {
275
+ const step = mutation.target.parentElement?.closest(STEP_SELECTOR)
276
+ if (emptySteps.has(step)) avatarsDirty = true
277
+ continue
278
+ }
227
279
  const parentStep = mutation.target?.closest?.(STEP_SELECTOR)
228
280
  const stableContentMutation = parentStep
229
281
  && stableBodyContains(parentStep, mutation.target)
230
282
  && !(mutation.type === 'attributes' && stepSurfaces.get(parentStep) === mutation.target)
283
+ if (!stableContentMutation || emptySteps.has(parentStep)) avatarsDirty = true
231
284
  if (parentStep && !stableContentMutation) {
232
285
  pendingSteps.add(parentStep)
233
286
  }
@@ -239,7 +292,7 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
239
292
  if (surface && (surface === node || node.contains?.(surface))) pendingSteps.add(parentStep)
240
293
  }
241
294
  }
242
- if (pendingSteps.size) queueScan()
295
+ if (pendingSteps.size || avatarsDirty) queueScan()
243
296
  }
244
297
 
245
298
  function removeMarkers() {
@@ -248,13 +301,19 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
248
301
  }
249
302
 
250
303
  return {
251
- start() {
304
+ start({ groupAvatars: enabled = true } = {}) {
252
305
  if (!document) return
306
+ if (groupAvatars !== enabled) {
307
+ groupAvatars = enabled
308
+ avatarsDirty = true
309
+ refreshAvatars()
310
+ }
253
311
  mountAvatarImage()
254
- const observationRoot = document.querySelector('[data-prts-region="operation"]')
312
+ observationRoot = document.querySelector('[data-prts-region="operation"]')
255
313
  ?? document.querySelector('[data-slot="main.conversation"], [data-slot="conversation"]')
256
314
  if (!observationRoot) return
257
315
  if (observer) return
316
+ avatarsDirty = true
258
317
  collectInitialSteps(observationRoot)
259
318
  scan()
260
319
  const Observer = window?.MutationObserver
@@ -263,8 +322,9 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
263
322
  observer.observe(observationRoot, {
264
323
  subtree: true,
265
324
  childList: true,
325
+ characterData: true,
266
326
  attributes: true,
267
- attributeFilter: ['class', 'data-chat-flow-kind', 'data-chat-group-part', 'data-markdown', 'data-slot'],
327
+ attributeFilter: ['class', 'data-chat-flow-kind', 'data-chat-group-part', 'data-markdown', 'data-slot', 'data-chat-turn', 'hidden', 'data-turn-process-hidden', 'data-turn-process-member', 'data-turn-process-answer'],
268
328
  })
269
329
  },
270
330
  dispose() {
@@ -276,6 +336,10 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
276
336
  }
277
337
  scanFrame = undefined
278
338
  removeMarkers()
339
+ for (const step of avatarSteps) step.removeAttribute('data-prts-avatar-hidden')
340
+ avatarSteps.clear()
341
+ emptySteps.clear()
342
+ observationRoot = undefined
279
343
  releaseAvatarImage()
280
344
  },
281
345
  }
@@ -0,0 +1,134 @@
1
+ export function mountAvatarCrop({panel,window,load,onSave}){
2
+ const dialog=panel.ownerDocument.createElement('dialog')
3
+ dialog.setAttribute('data-prts-avatar-crop','')
4
+ dialog.setAttribute('aria-labelledby','prts-avatar-crop-title')
5
+ dialog.innerHTML=`<header><strong id="prts-avatar-crop-title">头像设置</strong><label data-prts-avatar-upload>更换图片<input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" data-crop-file aria-label="上传新头像"></label></header>
6
+ <p>拖动亮区选择头像范围,滚轮或触摸板调整大小。</p>
7
+ <canvas width="320" height="320" tabindex="0" aria-label="原图与头像裁剪范围,可拖动或用方向键调整亮区"></canvas>
8
+ <div data-crop-tools><label>缩放 <input type="range" min="1" max="4" step=".01" value="1" aria-label="头像裁剪缩放"></label><button type="button" data-crop-center>重置裁剪</button></div>
9
+ <p role="status" data-prts-avatar-crop-status></p>
10
+ <footer><button type="button" data-crop-reset>恢复默认</button><span></span><button type="button" data-crop-cancel>取消</button><button type="button" data-crop-save>保存头像</button></footer>`
11
+ panel.append(dialog)
12
+ const canvas=dialog.querySelector('canvas'),zoom=dialog.querySelector('input[type=range]'),status=dialog.querySelector('[role=status]')
13
+ let bitmap,original,role,crop={zoom:1,x:.5,y:.5},drag,busy=false,disposed=false,generation=0,previousFocus,revision,hasCustom=false,ready=false
14
+ const clean=()=>{bitmap?.close();bitmap=null;original=null;drag=null}
15
+ const close=()=>{generation++;dialog.close?.();clean();previousFocus?.focus?.({preventScroll:true})}
16
+ const draw=()=>{
17
+ zoom.value=String(crop.zoom)
18
+ if(!bitmap){canvas.getContext('2d').clearRect(0,0,320,320);return}
19
+ const side=Math.min(bitmap.width,bitmap.height)/crop.zoom
20
+ crop.x=Math.max(side/2/bitmap.width,Math.min(1-side/2/bitmap.width,crop.x))
21
+ crop.y=Math.max(side/2/bitmap.height,Math.min(1-side/2/bitmap.height,crop.y))
22
+ const ctx=canvas.getContext('2d')
23
+ ctx.clearRect(0,0,320,320)
24
+ const scale=288/Math.max(bitmap.width,bitmap.height)
25
+ const width=bitmap.width*scale,height=bitmap.height*scale,left=(320-width)/2,top=(320-height)/2
26
+ const x=left+crop.x*width,y=top+crop.y*height,radius=side*scale/2
27
+ ctx.drawImage(bitmap,left,top,width,height)
28
+ ctx.beginPath();ctx.rect(0,0,320,320);ctx.arc(x,y,radius,0,Math.PI*2,true)
29
+ ctx.fillStyle='rgba(0,0,0,.65)';ctx.fill('evenodd')
30
+ ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2)
31
+ ctx.strokeStyle='rgba(255,255,255,.95)';ctx.lineWidth=1.5;ctx.stroke()
32
+ }
33
+ const controls=()=>{
34
+ for(const el of dialog.querySelectorAll('button,input'))el.disabled=busy
35
+ for(const el of [zoom,dialog.querySelector('[data-crop-save]'),dialog.querySelector('[data-crop-center]')])el.disabled=busy||!bitmap
36
+ dialog.querySelector('[data-crop-file]').disabled=busy||!ready
37
+ dialog.querySelector('[data-crop-reset]').disabled=busy||!hasCustom
38
+ }
39
+ const end=()=>{if(drag&&canvas.hasPointerCapture(drag.id))canvas.releasePointerCapture(drag.id);drag=null}
40
+ canvas.addEventListener('pointerdown',e=>{
41
+ if(busy||!bitmap||e.button!==0||!e.isPrimary)return
42
+ drag={id:e.pointerId,x:e.clientX,y:e.clientY,crop:{...crop}}
43
+ canvas.setPointerCapture(e.pointerId);e.preventDefault()
44
+ })
45
+ canvas.addEventListener('pointermove',e=>{
46
+ if(!drag||e.pointerId!==drag.id)return
47
+ const scale=Math.max(bitmap.width,bitmap.height)/288*320/canvas.getBoundingClientRect().width
48
+ crop.x=drag.crop.x+(e.clientX-drag.x)*scale/bitmap.width
49
+ crop.y=drag.crop.y+(e.clientY-drag.y)*scale/bitmap.height
50
+ draw();e.preventDefault()
51
+ })
52
+ canvas.addEventListener('pointerup',end)
53
+ canvas.addEventListener('pointercancel',()=>{if(drag)crop=drag.crop;end();draw()})
54
+ canvas.addEventListener('keydown',e=>{
55
+ if(busy||!bitmap||!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return
56
+ e.preventDefault();const step=(e.shiftKey?10:2)*Math.max(bitmap.width,bitmap.height)/288
57
+ if(e.key==='ArrowLeft')crop.x-=step/bitmap.width
58
+ if(e.key==='ArrowRight')crop.x+=step/bitmap.width
59
+ if(e.key==='ArrowUp')crop.y-=step/bitmap.height
60
+ if(e.key==='ArrowDown')crop.y+=step/bitmap.height
61
+ draw()
62
+ })
63
+ canvas.addEventListener('wheel',e=>{
64
+ e.preventDefault()
65
+ if(busy||!bitmap)return
66
+ end()
67
+ const delta=e.deltaY*(e.deltaMode===1?16:e.deltaMode===2?canvas.clientHeight:1)
68
+ crop.zoom=Math.max(1,Math.min(4,crop.zoom*Math.exp(-delta*(e.ctrlKey ? .01 : .002))))
69
+ draw()
70
+ },{passive:false})
71
+ zoom.addEventListener('input',()=>{end();crop.zoom=Number(zoom.value);draw()})
72
+ dialog.querySelector('[data-crop-center]').addEventListener('click',()=>{end();crop={zoom:1,x:.5,y:.5};zoom.value='1';draw()})
73
+ dialog.querySelector('[data-crop-cancel]').addEventListener('click',close)
74
+ dialog.addEventListener('cancel',e=>{e.preventDefault();e.stopPropagation();if(!busy)close()})
75
+ dialog.addEventListener('keydown',e=>{if(e.key==='Escape')e.stopPropagation()})
76
+ dialog.querySelector('[data-crop-save]').addEventListener('click',async()=>{
77
+ if(busy||!bitmap)return
78
+ end();busy=true;controls();status.textContent='正在保存…'
79
+ try{await onSave(role,original,{...crop},revision);if(!disposed)close()}
80
+ catch(error){if(!disposed)status.textContent=error.message}
81
+ finally{busy=false;if(!disposed)controls()}
82
+ })
83
+ const setImage=async(file,initial={zoom:1,x:.5,y:.5})=>{
84
+ if(file.size>5*1024*1024)throw Error('请选择不超过 5 MB 的图片')
85
+ const ticket=generation
86
+ const next=await window.createImageBitmap(file,{imageOrientation:'from-image'})
87
+ try{
88
+ if(next.width*next.height>25000000)throw Error('图片不能超过 2500 万像素')
89
+ const data=await new Promise((resolve,reject)=>{
90
+ const reader=new window.FileReader();reader.onload=()=>resolve(String(reader.result).split(',')[1])
91
+ reader.onerror=()=>reject(Error('无法读取图片'));reader.readAsDataURL(file)
92
+ })
93
+ if(disposed||ticket!==generation){next.close();return}
94
+ clean();bitmap=next;original=data;crop={...initial};draw()
95
+ }catch(error){next.close();throw error}
96
+ }
97
+ dialog.querySelector('[data-crop-file]').addEventListener('change',async e=>{
98
+ const file=e.target.files?.[0];e.target.value=''
99
+ if(!file||busy)return
100
+ busy=true;controls();status.textContent='正在读取图片…'
101
+ try{await setImage(file);status.textContent=''}
102
+ catch{status.textContent='图片无法读取,请选择不超过 5 MB、2500 万像素的图片'}
103
+ finally{busy=false;controls()}
104
+ })
105
+ dialog.querySelector('[data-crop-reset]').addEventListener('click',async()=>{
106
+ if(busy||!hasCustom||!window.confirm('恢复默认头像将移除当前图片及裁剪设置,是否继续?'))return
107
+ busy=true;controls()
108
+ try{await onSave(role,null,undefined,revision);if(!disposed)close()}
109
+ catch(error){status.textContent=error.message}
110
+ finally{busy=false;controls()}
111
+ })
112
+ return{
113
+ async open(target){
114
+ const ticket=++generation
115
+ clean();role=target;ready=false;hasCustom=false;crop={zoom:1,x:.5,y:.5};busy=true
116
+ previousFocus=panel.ownerDocument.activeElement
117
+ dialog.querySelector('strong').textContent=target==='user'?'用户头像':'AI 头像'
118
+ draw();controls();status.textContent='正在读取头像…';dialog.showModal()
119
+ try{
120
+ const value=await load(target)
121
+ if(disposed||ticket!==generation)return
122
+ revision=value.revision;ready=true;hasCustom=Boolean(value.image)
123
+ if(value.image){
124
+ const bytes=Uint8Array.from(window.atob(value.image),c=>c.charCodeAt(0))
125
+ await setImage(new window.Blob([bytes]),value.crop)
126
+ }
127
+ if(ticket===generation)status.textContent=hasCustom?'可直接继续调整裁剪范围':'上传图片以设置头像,支持 PNG、JPEG、WebP、GIF、AVIF,最大 5 MB。'
128
+ }catch(error){if(ticket===generation)status.textContent=error.message}
129
+ finally{if(ticket===generation){busy=false;controls();canvas.focus()}}
130
+ },
131
+ close,
132
+ dispose(){disposed=true;close();dialog.remove()},
133
+ }
134
+ }
@@ -14,9 +14,19 @@ function closestWithClassSuffix(node, suffix) {
14
14
  function classify(button, operation) {
15
15
  // The host's process disclosure owns its transparent, full-width hit area.
16
16
  if (button.hasAttribute('data-turn-process')) return null
17
+ // Changed-file cards own one outer surface; their header and rows stay native.
18
+ if (button.closest('[data-changed-files]')) return null
19
+ // File previews are transparent hit targets; the enclosing card owns its material and outline.
20
+ if (button.closest('[data-presented-file]') && hasClassSuffix(button, '_cardPreview')) return null
17
21
  if (button.tagName !== 'BUTTON' || !operation.contains(button)) return null
18
22
  if (hasClassSuffix(button, '_sessionLogButton')) return 'header'
19
- if (button.closest('[data-produced-files-row]') || hasClassSuffix(button, '_showFolder')) return 'utility'
23
+ const tokens=[...(button.classList ?? [])]
24
+ const fileLink=tokens.some(token=>/(?:^|_)file(?:Link|Mention)(?:_|$)/.test(token))
25
+ const fileChange=hasClassSuffix(button,'_header')
26
+ && [...button.children].some(node=>hasClassSuffix(node,'_tile'))
27
+ && [...button.children].some(node=>hasClassSuffix(node,'_titles'))
28
+ if (fileLink || fileChange || button.closest('[data-produced-files-row]')) return 'file'
29
+ if (hasClassSuffix(button, '_showFolder')) return 'utility'
20
30
  if (closestWithClassSuffix(button, '_older')) return 'utility'
21
31
  if (hasClassSuffix(button, '_inspectButton')) return 'inspect'
22
32
 
@@ -88,8 +88,18 @@ export function createCursorAdapter({ document, window, assets }) {
88
88
  let originalCursor
89
89
  let originalPriority
90
90
  let appliedCursor
91
+ let appliedPriority
92
+ function ownsHandCursor() {
93
+ return handTarget?.style.cursor === appliedCursor
94
+ && handTarget.style.getPropertyPriority('cursor') === appliedPriority
95
+ }
96
+ function stopIfHandChanged() {
97
+ if (!handTarget || ownsHandCursor()) return false
98
+ reset()
99
+ return true
100
+ }
91
101
  function restoreHandTarget() {
92
- if (handTarget && handTarget.style.cursor === appliedCursor) {
102
+ if (handTarget && ownsHandCursor()) {
93
103
  if (originalCursor) handTarget.style.setProperty('cursor', originalCursor, originalPriority)
94
104
  else handTarget.style.removeProperty('cursor')
95
105
  }
@@ -103,6 +113,7 @@ export function createCursorAdapter({ document, window, assets }) {
103
113
  return target && target.getElementsByTagName('*').length <= maxAnimatedDescendants
104
114
  }
105
115
  function followHand(target) {
116
+ if (stopIfHandChanged()) return
106
117
  if (target === handTarget && target !== textTarget) return
107
118
  restoreHandTarget()
108
119
  if (!canAnimate(target) || !handCursor || target === textTarget) return
@@ -111,13 +122,16 @@ export function createCursorAdapter({ document, window, assets }) {
111
122
  handTarget = target
112
123
  originalCursor = target.style.getPropertyValue('cursor')
113
124
  originalPriority = target.style.getPropertyPriority('cursor')
125
+ appliedCursor = originalCursor
126
+ appliedPriority = originalPriority
114
127
  target.setAttribute('data-prts-hand-active', '')
115
128
  applyHandFrame()
116
129
  }
117
130
  function applyHandFrame() {
118
- if (!handTarget) return
131
+ if (stopIfHandChanged() || !handTarget) return
119
132
  handTarget.style.setProperty('cursor', handCursor)
120
133
  appliedCursor = handTarget.style.cursor
134
+ appliedPriority = handTarget.style.getPropertyPriority('cursor')
121
135
  }
122
136
  function clearTimer() {
123
137
  if (timer !== undefined) window.clearTimeout(timer)
@@ -144,10 +158,12 @@ export function createCursorAdapter({ document, window, assets }) {
144
158
  let index = 0
145
159
  function next() {
146
160
  timer = undefined
161
+ if (stopIfHandChanged()) return
147
162
  // The source ANI ends with a 60-second hold frame. Use the static state instead.
148
163
  if (index >= frames.length - 1) { done(); return }
149
164
  const frame = frames[index++]
150
165
  show(frame.url)
166
+ if (!handCursor) return
151
167
  timer = window.setTimeout(next, frame.duration)
152
168
  }
153
169
  next()
@@ -179,6 +195,7 @@ export function createCursorAdapter({ document, window, assets }) {
179
195
  play(gestureAssets.release, reset)
180
196
  }
181
197
  function up(event) {
198
+ if (stopIfHandChanged()) return
182
199
  if (pointerId === undefined || event.pointerId !== pointerId || event.button !== 0) return
183
200
  pointerId = undefined
184
201
  if (reduced()) { reset(); return }
@@ -0,0 +1,91 @@
1
+ import { mountAvatarCrop } from './avatar-crop.js'
2
+ const API='/prts-theme-api/avatars'
3
+ const roles=['user','assistant']
4
+ export function createAvatarStore({window,document}){
5
+ let state={revision:-1,user:null,assistant:null},disposed=false,events,refreshing
6
+ const listeners=new Set()
7
+ const controller=window.AbortController?new window.AbortController():null
8
+ const apply=value=>{
9
+ if(disposed||value.revision<state.revision)return
10
+ state=value
11
+ for(const role of roles){
12
+ const image=state[role]
13
+ const safe=typeof image==='string'&&/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(image)
14
+ const property=`--prts-custom-${role}-avatar`
15
+ if(safe)document.documentElement.style.setProperty(property,`url("${image}")`)
16
+ else document.documentElement.style.removeProperty(property)
17
+ }
18
+ document.documentElement.style.setProperty('--prts-user-avatar-label',state.user?'""':'"我"')
19
+ for(const fn of listeners)fn(state)
20
+ }
21
+ const request=async(data)=>{
22
+ const response=await window.fetch(API+'/configuration',{credentials:'same-origin',signal:controller?.signal,
23
+ ...(data===undefined?{}:{method:'POST',headers:{'content-type':'application/json','x-prts-request':'1'},body:JSON.stringify(data)})})
24
+ const value=await response.json()
25
+ if(!response.ok)throw Error(value.error||'头像服务暂不可用')
26
+ apply(value);return state
27
+ }
28
+ const refresh=()=>refreshing??(refreshing=request().finally(()=>{refreshing=null}))
29
+ if(window.fetch){
30
+ void refresh().catch(()=>{}).then(()=>{
31
+ if(disposed||!window.EventSource)return
32
+ events=new window.EventSource(API+'/events')
33
+ events.onmessage=event=>{
34
+ try{
35
+ const revision=JSON.parse(event.data).revision
36
+ if(revision>state.revision)void refresh().then(()=>{
37
+ if(!disposed&&revision>state.revision)return refresh()
38
+ }).catch(()=>{})
39
+ }catch{}
40
+ }
41
+ })
42
+ }
43
+ const focus=()=>{if(window.fetch)void refresh().catch(()=>{})}
44
+ window.addEventListener('focus',focus)
45
+ return{
46
+ snapshot:()=>state,refresh,
47
+ subscribe(fn){listeners.add(fn);fn(state);return()=>listeners.delete(fn)},
48
+ async edit(role){
49
+ const response=await window.fetch(API+'/edit/'+role,{credentials:'same-origin',signal:controller?.signal})
50
+ const value=await response.json()
51
+ if(!response.ok)throw Error(value.error||'无法读取头像')
52
+ return value
53
+ },
54
+ async save(role,image,crop,revision=state.revision){
55
+ try{return await request({role,image,crop,revision})}
56
+ catch(error){await refresh().catch(()=>{});throw error}
57
+ },
58
+ dispose(){
59
+ disposed=true;controller?.abort();events?.close();listeners.clear();window.removeEventListener('focus',focus)
60
+ for(const role of roles)document.documentElement.style.removeProperty(`--prts-custom-${role}-avatar`)
61
+ document.documentElement.style.removeProperty('--prts-user-avatar-label')
62
+ },
63
+ }
64
+ }
65
+
66
+ export function avatarMarkup(){
67
+ return `<section data-prts-avatars hidden>
68
+ <header><strong>Baker 头像</strong><small>点击头像调整图片与裁剪范围,所有会话共用。</small></header>
69
+ <div data-prts-avatar-choices>${roles.map(role=>`<div data-prts-avatar-row="${role}">
70
+ <button type="button" data-prts-avatar-preview="${role}" aria-label="设置${role==='user'?'用户':'AI'}头像"></button>
71
+ <span><strong>${role==='user'?'用户头像':'AI 头像'}</strong><small>点击编辑</small></span>
72
+ </div>`).join('')}</div>
73
+ <p data-prts-avatar-status role="status">保存在当前 DSH 实例,自动同步至其他设备。</p>
74
+ </section>`
75
+ }
76
+ export function mountAvatarSettings({panel,window,store}){
77
+ const section=panel.querySelector('[data-prts-avatars]')
78
+ if(!section||!store)return{dispose(){}}
79
+ const status=section.querySelector('[data-prts-avatar-status]')
80
+ let disposed=false
81
+ const cropEditor=mountAvatarCrop({panel,window,load:role=>store.edit(role),onSave:async(role,image,crop,revision)=>{
82
+ await store.save(role,image,crop,revision)
83
+ if(!disposed)status.textContent='头像已保存,已应用到所有会话'
84
+ }})
85
+ const click=event=>{
86
+ const button=event.target.closest('[data-prts-avatar-preview]')
87
+ if(button)void cropEditor.open(button.dataset.prtsAvatarPreview)
88
+ }
89
+ section.addEventListener('click',click)
90
+ return{close:()=>cropEditor.close(),dispose(){disposed=true;cropEditor.dispose();section.removeEventListener('click',click)}}
91
+ }
@@ -245,7 +245,7 @@ export function customEmblemMarkup() {
245
245
  return `<section data-prts-custom-emblem>
246
246
  <header><strong>徽记管理</strong><small>新会话与正文分别设置,可在任意会话中让 AI 调整,设置在当前 DSH 实例共享</small></header>
247
247
  <div data-prts-emblem-hero-panel><h4>新会话徽记</h4><div data-prts-emblem-hero-row></div></div>
248
- <div><h4>正文轮换徽记</h4><small>按列表顺序随正文滚动轮换,可混合使用内置与上传的图片,最多 12 张。长按徽记选项任意位置拖动排序,松开保存。</small>
248
+ <div><h4>正文轮换徽记</h4><small data-prts-emblem-baker-note hidden>Baker 会话正文不显示或运行粒子徽记;下列配置保留供经典主题使用,新会话徽记不受影响。</small><small>按列表顺序随正文滚动轮换,可混合使用内置与上传的图片,最多 12 张。长按徽记选项任意位置拖动排序,松开保存。</small>
249
249
  <ol data-prts-emblem-list></ol>
250
250
  <div data-prts-emblem-actions><button type="button" data-prts-emblem-add>新增徽记</button><button type="button" data-prts-emblem-reset-list>恢复内置列表</button></div>
251
251
  </div>
@@ -1,3 +1,4 @@
1
+ import { createAvatarStore } from './custom-avatar.js'
1
2
  import { registerEmblemToolPreview } from './emblem-tool-preview.js'
2
3
  import { createCustomEmblemStore } from './custom-emblem.js'
3
4
  import { DEFAULT_PREFERENCES, isSafeMode, loadPreferences, persistPreferences, resetPreferenceGroup, updatePreferenceValue } from './preferences.js'
@@ -64,6 +65,7 @@ export function applyPrtsPlugin(ctx, environment) {
64
65
  sync()
65
66
  },
66
67
  })
68
+ const avatarStore = createAvatarStore({window,document})
67
69
  const customEmblemStore = createCustomEmblemStore({ window, emblems: assets.emblems, preview: entry => particleField.previewEmblem?.(entry), onChange: value => particleField.setCustomEmblemConfiguration?.(value) })
68
70
  const startup = createPrtsStartupSequence({
69
71
  document,
@@ -191,7 +193,7 @@ export function applyPrtsPlugin(ctx, environment) {
191
193
  }
192
194
  if (!themeAlreadyApplied) theme.apply(preferences)
193
195
  operations ||= createOperationsShell({
194
- document, window, assets, adapter, hostGeometry, customEmblemStore,
196
+ document, window, assets, adapter, hostGeometry, customEmblemStore, avatarStore,
195
197
  onSchemeToggle(next, interaction) {
196
198
  return theme.setTheme(next, { ...interaction, animate: true })
197
199
  },
@@ -250,7 +252,7 @@ export function applyPrtsPlugin(ctx, environment) {
250
252
  }
251
253
  particleField.update(preferences)
252
254
  composerGlass.start()
253
- if (preferences.conversationStyle === 'deck-chat') assistantGlass.start()
255
+ if (preferences.conversationStyle === 'deck-chat') assistantGlass.start({ groupAvatars: preferences.bakerGroupAvatars })
254
256
  else assistantGlass.dispose()
255
257
  toBottom.start()
256
258
  conversationControls.start()
@@ -346,6 +348,7 @@ export function applyPrtsPlugin(ctx, environment) {
346
348
  if (typeof stopEmblemPreview === 'function') stopEmblemPreview()
347
349
  statusSource.dispose()
348
350
  customEmblemStore.dispose()
351
+ avatarStore.dispose()
349
352
  particleField.dispose()
350
353
  startup.dispose({ restoreFocus: false })
351
354
  stopStartupReadyWatch()
@@ -45,7 +45,7 @@ export function createOperationsShell({
45
45
  onSchemeToggle = () => {},
46
46
  onSchemeDragStart = () => undefined,
47
47
  onThemeDisable = () => {},
48
- customEmblemStore,
48
+ customEmblemStore, avatarStore,
49
49
  onPreferenceChange = () => {},
50
50
  onPreferencePreview = () => {},
51
51
  onVisualReset = () => {},
@@ -455,7 +455,7 @@ export function createOperationsShell({
455
455
  trigger: shell.querySelector('[data-prts-rail-brand]'),
456
456
  panel: shell.querySelector('[data-prts-theme-settings]'),
457
457
  backdrop: shell.querySelector('[data-prts-settings-backdrop]'),
458
- customEmblemStore,
458
+ customEmblemStore, avatarStore,
459
459
  onPreferenceChange,
460
460
  onPreferencePreview,
461
461
  onResetVisual: onVisualReset,
@@ -431,7 +431,8 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
431
431
  : preferences.conversationParticleDensity
432
432
  const densityProfile = density => PARTICLE_DENSITY_PROFILES[density] ?? PARTICLE_DENSITY_PROFILES.standard
433
433
  const particleRadius = density => densityProfile(density ?? activeDensity()).radius
434
- const canRenderFrame = () => !disposed && documentVisible && canvasVisible && !hydrationPaused && !startupPaused && !schemeTransitionPaused && !resizing
434
+ const fieldEnabled = () => heroActive || (preferences.conversationStyle !== 'deck-chat' && preferences.conversationEmblem !== false)
435
+ const canRenderFrame = () => fieldEnabled() && !disposed && documentVisible && canvasVisible && !hydrationPaused && !startupPaused && !schemeTransitionPaused && !resizing
435
436
  const layoutWidth = () => width
436
437
  const layoutHeight = () => height
437
438
  const layoutShiftX = () => 0
@@ -496,7 +497,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
496
497
  }
497
498
 
498
499
  function populationSource(density = activeDensity()) {
499
- return heroActive ? targetsForHero(density) : targetsForSegment(segment, density)
500
+ return !fieldEnabled() ? [] : heroActive ? targetsForHero(density) : targetsForSegment(segment, density)
500
501
  }
501
502
 
502
503
  function targetParticleCount(density = activeDensity()) {
@@ -697,7 +698,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
697
698
 
698
699
  function scheduleTargetPrewarm() {
699
700
  cancelTargetPrewarm()
700
- if (typeof window.requestIdleCallback !== 'function' || !particles.length) return
701
+ if (preferences.conversationEmblem === false || preferences.conversationStyle === 'deck-chat' || !fieldEnabled() || typeof window.requestIdleCallback !== 'function' || !particles.length) return
701
702
  targetPrewarmQueue = (customEmblemConfiguration?.conversation ?? emblemSequence).map((_, index) => index)
702
703
  .filter(index => index !== sequenceIndexForSegment(segment))
703
704
  const warmNext = () => {
@@ -718,6 +719,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
718
719
  }
719
720
 
720
721
  function buildParticles() {
722
+ if (!fieldEnabled()) { particles = []; cancelTargetPrewarm(); return false }
721
723
  if (startupPaused) return false
722
724
  const selected = populationSource()
723
725
  if (!selected.length) {
@@ -796,6 +798,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
796
798
  }
797
799
 
798
800
  function startDensityReassembly(nextCount, force = false) {
801
+ if (!fieldEnabled()) return
799
802
  const baseNextTargets = populationSource()
800
803
  const targetCount = baseNextTargets.length
801
804
  if (!targetCount) {
@@ -921,6 +924,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
921
924
  }
922
925
 
923
926
  function commitTransitionTargets(targetSegment, snap = false, density = preferences.conversationParticleDensity) {
927
+ if (!fieldEnabled()) return
924
928
  const selected = targetsForSegment(targetSegment, density)
925
929
  if (!selected.length) return
926
930
  segment = targetSegment
@@ -1507,7 +1511,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1507
1511
  heroActive = true
1508
1512
  scroller?.setAttribute('data-prts-hero-active', '')
1509
1513
  resetTraversal()
1510
- scheduleFrame()
1514
+ updateFrameGate()
1511
1515
  }
1512
1516
 
1513
1517
  function leaveHero() {
@@ -1516,6 +1520,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1516
1520
  heroMarker = undefined
1517
1521
  heroActive = false
1518
1522
  scroller?.removeAttribute('data-prts-hero-active')
1523
+ if (!fieldEnabled()) { resetTraversal(); updateFrameGate(); return }
1519
1524
  side = -1
1520
1525
  segment = 0
1521
1526
  traversalOffset = -currentScrollTop() * particleTraversalSpeed() / traversalViewport()
@@ -1558,7 +1563,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1558
1563
  if (heroActive) leaveHero()
1559
1564
  }
1560
1565
  function onScroll() {
1561
- if (!scroller || phoneStatic()) return
1566
+ if (!fieldEnabled() || !scroller || phoneStatic()) return
1562
1567
  if (heroActive) {
1563
1568
  scheduleFrame()
1564
1569
  return
@@ -1570,7 +1575,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1570
1575
  }
1571
1576
 
1572
1577
  function onPointerMove(event) {
1573
- if (!operation || transition || motionReduced() || phoneStatic()) return
1578
+ if (!fieldEnabled() || !operation || transition || motionReduced() || phoneStatic()) return
1574
1579
  pointerClientX = event.clientX
1575
1580
  pointerClientY = event.clientY
1576
1581
  pointerActive = true
@@ -1607,7 +1612,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1607
1612
  }
1608
1613
 
1609
1614
  function registerContentMutations(mutations) {
1610
- if (phoneStatic()) return
1615
+ if (!fieldEnabled() || phoneStatic()) return
1611
1616
  let weight = mutations.length
1612
1617
  for (const mutation of mutations) {
1613
1618
  for (const node of [...mutation.addedNodes, ...mutation.removedNodes]) {
@@ -1652,6 +1657,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1652
1657
  }
1653
1658
 
1654
1659
  function updateFrameGate() {
1660
+ if (canvas) canvas.hidden = !fieldEnabled()
1655
1661
  const now = Number(window.performance?.now?.()) || 0
1656
1662
  if (!canRenderFrame()) {
1657
1663
  pauseStartedAt ??= now
@@ -1741,7 +1747,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1741
1747
  if (!phoneStatic()) restoreTraversalFromScroll()
1742
1748
  commitTransitionTargets(segment, true)
1743
1749
  }
1744
- scheduleFrame()
1750
+ updateFrameGate()
1745
1751
  }
1746
1752
 
1747
1753
  function mount(nextOperation, nextScroller) {
@@ -1768,6 +1774,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1768
1774
  targetCache.clear()
1769
1775
  initializeFieldSize()
1770
1776
  syncHeroState()
1777
+ canvas.hidden = !fieldEnabled()
1771
1778
  if (!heroActive) restoreTraversalFromScroll()
1772
1779
  if (!particles.length && maskRecords.length) buildParticles()
1773
1780
  if (context && particles.length) {
@@ -1880,6 +1887,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1880
1887
  }
1881
1888
 
1882
1889
  findMount()
1890
+ if (!fieldEnabled()) resetTraversal()
1883
1891
  const previousActiveDensity = heroActive
1884
1892
  ? previousPreferences.heroParticleDensity
1885
1893
  : previousPreferences.conversationParticleDensity
@@ -1914,10 +1922,11 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1914
1922
  }
1915
1923
  }
1916
1924
  if (ambient) ambient.hidden = preferences.texture === 'off' && preferences.conversationStyle !== 'deck-chat'
1917
- scheduleFrame()
1925
+ updateFrameGate()
1918
1926
  },
1919
1927
  inspect() {
1920
1928
  return {
1929
+ enabled: fieldEnabled(),
1921
1930
  mounted: Boolean(ambient?.isConnected && canvas?.isConnected),
1922
1931
  hero: heroActive,
1923
1932
  side,