@ash-qw/dsh-theme-prts 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.0",
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",
@@ -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,17 @@ 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
+ // File previews are transparent hit targets; the enclosing card owns its material and outline.
18
+ if (button.closest('[data-presented-file]') && hasClassSuffix(button, '_cardPreview')) return null
17
19
  if (button.tagName !== 'BUTTON' || !operation.contains(button)) return null
18
20
  if (hasClassSuffix(button, '_sessionLogButton')) return 'header'
19
- if (button.closest('[data-produced-files-row]') || hasClassSuffix(button, '_showFolder')) return 'utility'
21
+ const tokens=[...(button.classList ?? [])]
22
+ const fileLink=tokens.some(token=>/(?:^|_)file(?:Link|Mention)(?:_|$)/.test(token))
23
+ const fileChange=hasClassSuffix(button,'_header')
24
+ && [...button.children].some(node=>hasClassSuffix(node,'_tile'))
25
+ && [...button.children].some(node=>hasClassSuffix(node,'_titles'))
26
+ if (fileLink || fileChange || button.closest('[data-produced-files-row]')) return 'file'
27
+ if (hasClassSuffix(button, '_showFolder')) return 'utility'
20
28
  if (closestWithClassSuffix(button, '_older')) return 'utility'
21
29
  if (hasClassSuffix(button, '_inspectButton')) return 'inspect'
22
30
 
@@ -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
+ }
@@ -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
  },
@@ -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,
@@ -1,3 +1,4 @@
1
+ import { avatarMarkup, mountAvatarSettings } from './custom-avatar.js'
1
2
  import { customEmblemMarkup, mountCustomEmblemEditor } from './custom-emblem.js'
2
3
  import { PARTICLE_TRAVERSAL_SPEED_DEFAULT, PARTICLE_TRAVERSAL_SPEED_MAX, PARTICLE_TRAVERSAL_SPEED_MIN, PARTICLE_TRAVERSAL_SPEED_STEP, SESSION_FLOW_SPEED_DEFAULT, SESSION_FLOW_SPEED_MAX, SESSION_FLOW_SPEED_MIN, SESSION_FLOW_SPEED_STEP, resolveParticleDetail } from './preferences.js'
3
4
 
@@ -156,9 +157,16 @@ export function themeSettingsMarkup() {
156
157
  <header><span><strong>界面外观</strong></span></header>
157
158
  <div data-prts-settings-common-grid>
158
159
  ${COMMON_CONTROLS.filter(([key]) => ['texture', 'glass'].includes(key)).map(([key, label, options]) => settingButtons(key, label, options)).join('')}
159
- ${settingButtons('conversationStyle', '会话主题', [['native', '原有'], ['deck-chat', '通讯链路']])}
160
160
  </div>
161
161
  </section>
162
+ <section data-prts-conversation-settings>
163
+ <header><strong>会话主题</strong><small>选择消息的呈现方式</small></header>
164
+ <div data-prts-conversation-cards>
165
+ <button type="button" data-prts-setting-key="conversationStyle" data-prts-setting-value="native" aria-pressed="false"><span data-prts-conversation-sample="classic" aria-hidden="true"><i></i><i></i></span><strong>经典</strong><small>简洁正文,保留熟悉的阅读布局</small></button>
166
+ <button type="button" data-prts-setting-key="conversationStyle" data-prts-setting-value="deck-chat" aria-pressed="false"><span data-prts-conversation-sample="baker" aria-hidden="true"><i></i><i></i></span><strong>Baker</strong><small>终末地通讯风格,圆形头像与消息气泡</small></button>
167
+ </div>
168
+ ${avatarMarkup()}
169
+ </section>
162
170
  </section>
163
171
  <section data-prts-settings-common data-prts-settings-section="interaction" role="tabpanel" id="prts-page-interaction" aria-labelledby="prts-tab-interaction" tabindex="0" hidden>
164
172
  <header data-prts-section-heading><strong>交互</strong><small>光标、导航栏与动态反馈</small></header>
@@ -207,7 +215,7 @@ export function createThemeSettingsOverlay({
207
215
  trigger,
208
216
  panel,
209
217
  backdrop,
210
- customEmblemStore,
218
+ customEmblemStore, avatarStore,
211
219
  onOpen = () => {},
212
220
  onPreferenceChange = () => {},
213
221
  onPreferencePreview = () => {},
@@ -215,6 +223,7 @@ export function createThemeSettingsOverlay({
215
223
  onRetrySave = () => {},
216
224
  }) {
217
225
  const window = document?.defaultView
226
+ const avatarEditor = mountAvatarSettings({panel,window,store:avatarStore})
218
227
  const customEditor = mountCustomEmblemEditor({ panel, window, store: customEmblemStore })
219
228
  const transparencyMedia = window?.matchMedia?.('(prefers-reduced-transparency: reduce)')
220
229
  const motionMedia = window?.matchMedia?.('(prefers-reduced-motion: reduce)')
@@ -294,6 +303,7 @@ export function createThemeSettingsOverlay({
294
303
  ;(panel.querySelector('[data-prts-theme-settings-close]') ?? focusable()[0] ?? panel).focus?.()
295
304
  } else {
296
305
  customEditor.close?.()
306
+ avatarEditor.close?.()
297
307
  panel.removeAttribute('data-prts-settings-visible')
298
308
  hidePanel()
299
309
  setResetConfirmation(false)
@@ -678,6 +688,7 @@ export function createThemeSettingsOverlay({
678
688
 
679
689
  return {
680
690
  update(preferences, status, persistenceState = currentPersistence) {
691
+ panel.querySelector('[data-prts-avatars]').hidden = preferences?.conversationStyle !== 'deck-chat'
681
692
  currentPreferences = preferences
682
693
  currentStatus = status
683
694
  currentPersistence = persistenceState ?? { phase: 'idle', revision: 0 }
@@ -693,6 +704,7 @@ export function createThemeSettingsOverlay({
693
704
  toggle,
694
705
  dispose() {
695
706
  customEditor.dispose()
707
+ avatarEditor.dispose()
696
708
  isOpen = false
697
709
  hidePanel()
698
710
  if (savedTimer !== undefined) window?.clearTimeout?.(savedTimer)
package/src/index.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { createAvatarService, registerAvatarApi } from './server/avatars.js'
1
2
  import { join } from 'node:path'
2
3
  import { homedir } from 'node:os'
3
4
  import { createEmblemService } from './server/emblems.js'
5
+ import { avatarTools } from './server/avatar-tools.js'
4
6
  import { emblemTools } from './server/tools.js'
5
7
  import { registerEmblemApi } from './server/api.js'
6
8
  export const PACKAGE_ID = '@ash-qw/dsh-theme-prts'
@@ -10,7 +12,8 @@ export const inject = ['tools', 'attachments', 'connection', 'webServer']
10
12
  export async function apply(ctx) {
11
13
  const directory=join(process.env.DSH_HOME?.trim() || join(homedir(),'.dsh'),'plugins','dsh-theme-prts','emblems')
12
14
  const service=await createEmblemService(directory)
13
- const disposers=emblemTools(service,ctx.attachments).map(tool=>ctx.tools.register(tool))
14
- disposers.push(registerEmblemApi(ctx,service))
15
- ctx.on('dispose',async()=>{for(const dispose of disposers.reverse())dispose();await service.dispose()})
15
+ const avatars=await createAvatarService(join(directory,'..','avatars'))
16
+ const disposers=[...emblemTools(service,ctx.attachments),...avatarTools(avatars,ctx.attachments)].map(tool=>ctx.tools.register(tool))
17
+ disposers.push(registerEmblemApi(ctx,service),registerAvatarApi(ctx,avatars))
18
+ ctx.effect(()=>async()=>{for(const dispose of disposers.reverse())dispose();await service.dispose()})
16
19
  }
package/src/server/api.js CHANGED
@@ -1,16 +1,22 @@
1
1
  import { API, draftView, fault } from './emblems.js'
2
2
 
3
- async function body(req) {
3
+ // Avoid repeated groups: multi-megabyte images can exhaust V8's regexp stack.
4
+ export function isBase64(value){return typeof value==='string'&&value.length%4===0&&/^[A-Za-z0-9+/]*={0,2}$/.test(value)}
5
+
6
+ export async function body(req) {
4
7
  const chunks=[];let size=0
5
8
  for await(const chunk of req){size+=chunk.length;if(size>16*1024*1024)throw fault(413,'请求过大');chunks.push(chunk)}
6
9
  try{return JSON.parse(Buffer.concat(chunks).toString('utf8'))}catch{throw fault(400,'请求数据无效')}
7
10
  }
8
- function json(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(value))}
11
+ export function json(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});res.end(JSON.stringify(value))}
9
12
  export function registerEmblemApi(ctx,service) {
13
+ const connection=ctx.connection
14
+ let disposed=false
10
15
  const streams=new Set()
11
16
  const dispose=ctx.webServer.register({kind:'prefix',path:API,handler:async(req,res)=>{
12
17
  try {
13
- const admission=ctx.connection.admit(req)
18
+ if(disposed){json(res,503,{error:'主题服务正在重新加载,请稍后重试'});return}
19
+ const admission=connection.admit(req)
14
20
  if('rejection' in admission){json(res,401,{error:'请先登录 DSH'});return}
15
21
  const path=new URL(req.url,'http://localhost').pathname.slice(API.length)
16
22
  if(req.method==='GET'&&path==='/events') {
@@ -43,12 +49,12 @@ export function registerEmblemApi(ctx,service) {
43
49
  json(res,200,{...draftView(d),value:d.value,previewPoints:d.previewPoints});return
44
50
  }
45
51
  if(path==='/preview'){
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,'图片编码无效')
52
+ if(data.image && !isBase64(data.image))throw fault(400,'图片编码无效')
47
53
  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
54
  json(res,200,{...draftView(d),value:d.value,previewPoints:d.previewPoints});return
49
55
  }
50
56
  throw fault(404,'接口不存在')
51
57
  }catch(error){if(!res.headersSent)json(res,error.status||500,{error:error.message});else res.end()}
52
58
  }})
53
- return()=>{dispose();for(const res of streams)res.end();streams.clear()}
59
+ return()=>{disposed=true;dispose();for(const res of streams)res.end();streams.clear()}
54
60
  }
@@ -0,0 +1,67 @@
1
+ import sharp from 'sharp'
2
+ import { sessionImages } from './tools.js'
3
+ import { fault } from './emblems.js'
4
+
5
+ const limit=5*1024*1024
6
+ const cropSchema={type:'object',additionalProperties:false,properties:{
7
+ zoom:{type:'number',minimum:1,maximum:4},x:{type:'number',minimum:0,maximum:1},y:{type:'number',minimum:0,maximum:1},
8
+ },required:['zoom','x','y']}
9
+ const view=state=>({revision:state.revision,user:{custom:Boolean(state.user)},assistant:{custom:Boolean(state.assistant)}})
10
+ export function avatarTools(service,attachments){
11
+ const output={schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)}]}
12
+ return [{
13
+ name:'prts_avatar_inspect',
14
+ description:'查看 PRTS 主题 Baker 会话头像的配置版本、用户和 AI 头像的原图尺寸与裁剪参数,以及当前会话上传或工具返回的图片。头像在同一 DSH 实例的所有会话中共享,仅在 Baker 样式下显示;此工具不修改设置。AI 生成或编辑的本地图片先用 read_image 读取,再调用本工具获取 attachmentId。',
15
+ parameters:{type:'object',properties:{},additionalProperties:false},output,isConcurrencySafe:()=>true,
16
+ async execute(_args,exec){
17
+ const images=sessionImages(exec.agent),state=service.snapshot(),result=view(state)
18
+ // Capture both sources before awaiting so metadata and revision describe one snapshot.
19
+ const originals=['user','assistant'].map(role=>[role,service.edit(role)])
20
+ for(const [role,source] of originals){
21
+ if(!source.image)continue
22
+ const meta=await sharp(Buffer.from(source.image,'base64'),{limitInputPixels:25000000,animated:false}).metadata()
23
+ const swapped=[5,6,7,8].includes(meta.orientation)
24
+ result[role]={custom:true,width:swapped?meta.height:meta.width,height:swapped?meta.width:meta.height,crop:source.crop}
25
+ }
26
+ return {...result,images}
27
+ },
28
+ },{
29
+ name:'prts_avatar_apply',
30
+ description:'按用户要求修改 Baker 头像。role=user 为用户头像,assistant 为 AI 头像。action=replace 使用当前会话的 attachmentId 替换图片;recrop 使用服务器当前原图重新裁剪;reset 恢复默认并删除自定义原图。crop 的 x/y 是相对于按 EXIF 转正后原图宽高的裁剪中心(0..1),zoom=1..4,默认居中且 zoom=1;圆形裁剪直径为原图短边除以 zoom,越界会被限制。先 inspect 获取 revision,冲突时重新检查,勿盲目覆盖。修改立即影响同一实例所有会话;保存替换后旧原图不保留。单图最大 5 MB、2500 万像素。工具不会切换用户本机的会话主题。',
31
+ parameters:{type:'object',additionalProperties:false,properties:{
32
+ role:{type:'string',enum:['user','assistant']},action:{type:'string',enum:['replace','recrop','reset']},
33
+ attachmentId:{type:'string'},crop:cropSchema,revision:{type:'integer',minimum:0},
34
+ },required:['role','action','revision']},output,isConcurrencySafe:()=>false,
35
+ async execute(args,exec){
36
+ const images=sessionImages(exec.agent)
37
+ if(!['user','assistant'].includes(args.role)||!['replace','recrop','reset'].includes(args.action))throw fault(400,'头像类型或操作无效')
38
+ if(args.revision!==service.snapshot().revision)throw fault(409,'头像已更新,请重新检查配置')
39
+ if(args.action!=='replace'&&args.attachmentId)throw fault(400,'仅替换头像时可以指定图片')
40
+ if(args.action==='reset'&&args.crop)throw fault(400,'恢复默认时不需要裁剪参数')
41
+ let image=null
42
+ if(args.action==='replace'){
43
+ const ref=images.find(image=>image.attachmentId===args.attachmentId)
44
+ if(!ref)throw fault(404,'当前会话没有这张图片,请先上传或用 read_image 读取图片,再 inspect')
45
+ if(ref.bytes>limit)throw fault(400,'图片超过 5 MB')
46
+ const {attachmentKind,origin,toolName,...attachment}=ref
47
+ let bytes
48
+ if(attachmentKind==='file'){
49
+ const chunks=[];let size=0
50
+ for await(const chunk of attachments.readFileStream(attachment,exec.signal)){
51
+ size+=chunk.length;if(size>limit)throw fault(400,'图片超过 5 MB');chunks.push(chunk)
52
+ }
53
+ bytes=Buffer.concat(chunks)
54
+ }else bytes=(await attachments.readImage(attachment,exec.signal)).data
55
+ if(bytes.length>limit)throw fault(400,'图片超过 5 MB')
56
+ image=Buffer.from(bytes).toString('base64')
57
+ }else if(args.action==='recrop'){
58
+ image=service.edit(args.role).image
59
+ if(!image)throw fault(400,'尚未设置自定义头像,请先替换图片')
60
+ if(!args.crop)throw fault(400,'请提供新的裁剪参数')
61
+ }
62
+ exec.signal?.throwIfAborted()
63
+ const state=await service.save({role:args.role,image,crop:args.crop,revision:args.revision})
64
+ return {applied:true,...view(state)}
65
+ },
66
+ }]
67
+ }
@@ -0,0 +1,93 @@
1
+ import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import sharp from 'sharp'
5
+ import { fault } from './emblems.js'
6
+ import { body, json, isBase64 } from './api.js'
7
+
8
+ export const AVATAR_API = '/prts-theme-api/avatars'
9
+ export async function createAvatarService(directory) {
10
+ await mkdir(directory,{recursive:true,mode:0o700})
11
+ const path=join(directory,'avatars.json')
12
+ let state={revision:0,user:null,assistant:null}, queue=Promise.resolve()
13
+ try{state=JSON.parse(await readFile(path,'utf8'))}catch(error){if(error.code!=='ENOENT')throw error}
14
+ const listeners=new Set()
15
+ const snapshot=()=>({revision:state.revision,user:state.user,assistant:state.assistant})
16
+ return {
17
+ snapshot,
18
+ edit(role){
19
+ if(!['user','assistant'].includes(role))throw fault(400,'头像类型无效')
20
+ return structuredClone({revision:state.revision,image:state.sources?.[role]?.image||state[role]?.split(',')[1]||null,crop:state.sources?.[role]?.crop||{zoom:1,x:.5,y:.5}})
21
+ },
22
+ subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn)},
23
+ save(data){
24
+ const next=queue.then(async()=>{
25
+ if(!['user','assistant'].includes(data?.role))throw fault(400,'头像类型无效')
26
+ if(data.revision!==state.revision)throw fault(409,'头像已在其他页面更新,请重试')
27
+ let image=null
28
+ if(data.image!==null){
29
+ if(typeof data.image!=='string'||data.image.length>8*1024*1024||!isBase64(data.image))throw fault(400,'请上传不超过 5 MB 的图片')
30
+ const bytes=Buffer.from(data.image,'base64')
31
+ if(!bytes.length||bytes.length>5*1024*1024)throw fault(400,'请上传不超过 5 MB 的图片')
32
+ try{
33
+ const input=sharp(bytes,{limitInputPixels:25000000,animated:false})
34
+ const meta=await input.metadata()
35
+ if(!['png','jpeg','webp','gif','avif'].includes(meta.format))throw Error('format')
36
+ let pipeline=input.rotate()
37
+ if(data.crop!==undefined){
38
+ const c=data.crop
39
+ if(!c||!Number.isFinite(c.zoom)||c.zoom<1||c.zoom>4||!Number.isFinite(c.x)||c.x<0||c.x>1||!Number.isFinite(c.y)||c.y<0||c.y>1)throw Error('crop')
40
+ const swapped=[5,6,7,8].includes(meta.orientation)
41
+ const width=swapped?meta.height:meta.width,height=swapped?meta.width:meta.height
42
+ const size=Math.max(1,Math.round(Math.min(width,height)/c.zoom))
43
+ const left=Math.max(0,Math.min(width-size,Math.round(c.x*width-size/2)))
44
+ const top=Math.max(0,Math.min(height-size,Math.round(c.y*height-size/2)))
45
+ pipeline=pipeline.extract({left,top,width:size,height:size})
46
+ }
47
+ const result=await pipeline.resize(256,256,{fit:'cover'}).png().toBuffer()
48
+ image='data:image/png;base64,'+result.toString('base64')
49
+ }catch{throw fault(400,'图片无法读取,请使用 PNG、JPEG、WebP、GIF 或 AVIF 图片(不超过 2500 万像素)')}
50
+ }
51
+ const updated={...state,revision:state.revision+1,[data.role]:image,sources:{...state.sources,[data.role]:image?{image:data.image,crop:data.crop||{zoom:1,x:.5,y:.5}}:null}}
52
+ const temp=path+'.'+randomUUID()+'.tmp'
53
+ // Atomically replace the current records; no source-image history is retained.
54
+ try{await writeFile(temp,JSON.stringify(updated),{mode:0o600});await rename(temp,path)}
55
+ finally{await rm(temp,{force:true}).catch(()=>{})}
56
+ state=updated
57
+ for(const fn of listeners){try{fn(state.revision)}catch{}}
58
+ return snapshot()
59
+ })
60
+ queue=next.catch(()=>{})
61
+ return next
62
+ },
63
+ }
64
+ }
65
+
66
+ export function registerAvatarApi(ctx,service){
67
+ const connection=ctx.connection
68
+ let disposed=false
69
+ const streams=new Set()
70
+ const dispose=ctx.webServer.register({kind:'prefix',path:AVATAR_API,handler:async(req,res)=>{
71
+ try{
72
+ if(disposed){json(res,503,{error:'主题服务正在重新加载,请稍后重试'});return}
73
+ if('rejection' in connection.admit(req)){json(res,401,{error:'请先登录 DSH'});return}
74
+ const path=new URL(req.url,'http://localhost').pathname.slice(AVATAR_API.length)
75
+ if(req.method==='GET'&&path==='/events'){
76
+ res.writeHead(200,{'content-type':'text/event-stream','cache-control':'no-cache','x-accel-buffering':'no'})
77
+ streams.add(res)
78
+ const send=revision=>res.write('data: '+JSON.stringify({revision})+'\n\n')
79
+ const unsubscribe=service.subscribe(send);send(service.snapshot().revision)
80
+ const timer=setInterval(()=>res.write(': heartbeat\n\n'),25000)
81
+ res.on('close',()=>{clearInterval(timer);unsubscribe();streams.delete(res)})
82
+ return
83
+ }
84
+ if(req.method==='GET'&&path.startsWith('/edit/')){json(res,200,service.edit(path.slice(6)));return}
85
+ if(path!=='/configuration')throw fault(404,'接口不存在')
86
+ if(req.method==='GET'){json(res,200,service.snapshot());return}
87
+ if(req.method!=='POST')throw fault(404,'接口不存在')
88
+ if(req.headers['x-prts-request']!=='1')throw fault(403,'缺少请求标识')
89
+ json(res,200,await service.save(await body(req)))
90
+ }catch(error){if(!res.headersSent)json(res,error.status||500,{error:error.message});else res.end()}
91
+ }})
92
+ return()=>{disposed=true;dispose();for(const res of streams)res.end();streams.clear()}
93
+ }
@@ -13,7 +13,7 @@ export function sessionImages(agent) {
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
- if(part.type!=='image' && !(part.type==='file' && (/^image\/(png|jpeg|webp)$/.test(ref.mediaType || '') || /\.(png|jpe?g|webp)$/i.test(ref.name || ''))))continue
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
17
  if(images.has(ref.attachmentId))continue
18
18
  images.set(ref.attachmentId,{...ref,attachmentKind:part.type,origin:uploaded?'upload':'tool',...(toolName?{toolName}:{})})
19
19
  }