@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/CHANGELOG.md +11 -0
- package/README.en.md +6 -0
- package/README.md +6 -0
- package/lib/client.js +253 -8
- package/package.json +1 -1
- package/src/client/avatar-crop.js +134 -0
- package/src/client/conversation-control-adapter.js +9 -1
- package/src/client/custom-avatar.js +91 -0
- package/src/client/index.js +4 -1
- package/src/client/operations-shell-v2.js +2 -2
- package/src/client/theme-settings-workbench.js +14 -2
- package/src/index.js +6 -3
- package/src/server/api.js +11 -5
- package/src/server/avatar-tools.js +67 -0
- package/src/server/avatars.js +93 -0
- package/src/server/tools.js +1 -1
- package/src/styles/prts.css +100 -115
- package/src/styles/refinement.css +18 -6
- package/src/styles/theme-settings-workbench.css +58 -5
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/client/index.js
CHANGED
|
@@ -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
|
|
14
|
-
disposers.
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 && (
|
|
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
|
+
}
|
package/src/server/tools.js
CHANGED
|
@@ -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
|
}
|