@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.
@@ -38,6 +38,8 @@ export const DEFAULT_PREFERENCES = Object.freeze({
38
38
  sessionFlowColorFront: SESSION_FLOW_PALETTES.triad.front,
39
39
  sessionFlowSpeed: SESSION_FLOW_SPEED_DEFAULT,
40
40
  conversationStyle: 'native',
41
+ bakerGroupAvatars: true,
42
+ conversationEmblem: true,
41
43
  })
42
44
 
43
45
  export const VISUAL_PRESETS = Object.freeze({
@@ -79,7 +81,7 @@ export const PREFERENCE_GROUPS = Object.freeze({
79
81
  material: Object.freeze(['glass']),
80
82
  accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
81
83
  navigation: Object.freeze(['railDefaultHidden']),
82
- conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle']),
84
+ conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle', 'bakerGroupAvatars', 'conversationEmblem']),
83
85
  })
84
86
 
85
87
  const TEXTURES = new Set(['off', 'restrained', 'full'])
@@ -195,6 +197,8 @@ export function normalizePreferences(value) {
195
197
  sessionFlowColorCore: sessionFlowColors.core,
196
198
  sessionFlowColorFront: sessionFlowColors.front,
197
199
  sessionFlowSpeed: sessionFlowSpeedOr(input.sessionFlowSpeed, DEFAULT_PREFERENCES.sessionFlowSpeed),
200
+ conversationEmblem: booleanOr(input.conversationEmblem, DEFAULT_PREFERENCES.conversationEmblem),
201
+ bakerGroupAvatars: booleanOr(input.bakerGroupAvatars, DEFAULT_PREFERENCES.bakerGroupAvatars),
198
202
  conversationStyle: enumOr(input.conversationStyle, CONVERSATION_STYLES, DEFAULT_PREFERENCES.conversationStyle),
199
203
  }
200
204
  normalized.preset = matchedVisualPreset(normalized)
@@ -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
 
@@ -50,12 +51,16 @@ function settingButtons(key, label, options) {
50
51
  ? '<small data-prts-setting-note>立即切换会话背景、头像与消息气泡</small>'
51
52
  : key === 'sessionFlow'
52
53
  ? '<small data-prts-setting-note>仅控制当前会话卡片的三色流线</small>'
54
+ : key === 'conversationEmblem'
55
+ ? '<small data-prts-setting-note>控制经典主题正文中的粒子徽记,不影响新会话徽记</small>'
56
+ : key === 'bakerGroupAvatars'
57
+ ? '<small data-prts-setting-note>过程正文仅首条显示头像,最终回答独立保留;关闭后每条回复均显示</small>'
53
58
  : key === 'sessionGlow'
54
59
  ? '<small data-prts-setting-note>为流线添加柔和的同色辉光</small>'
55
60
  : key === 'sessionFlowPalette'
56
61
  ? '<small data-prts-setting-note>同时适配日间与夜间模式</small>'
57
62
  : ''
58
- if (['cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow'].includes(key)) return `<div data-prts-setting-row="${key}">
63
+ if (['cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem'].includes(key)) return `<div data-prts-setting-row="${key}">
59
64
  <span><strong>${label}</strong>${note}</span>
60
65
  <button type="button" role="switch" aria-label="${label}" aria-checked="false" data-prts-setting-switch="${key}" ${key === 'cursor' ? 'data-prts-cursor-switch' : ''}><span aria-hidden="true"></span></button>
61
66
  </div>`
@@ -156,9 +161,18 @@ export function themeSettingsMarkup() {
156
161
  <header><span><strong>界面外观</strong></span></header>
157
162
  <div data-prts-settings-common-grid>
158
163
  ${COMMON_CONTROLS.filter(([key]) => ['texture', 'glass'].includes(key)).map(([key, label, options]) => settingButtons(key, label, options)).join('')}
159
- ${settingButtons('conversationStyle', '会话主题', [['native', '原有'], ['deck-chat', '通讯链路']])}
160
164
  </div>
161
165
  </section>
166
+ <section data-prts-conversation-settings>
167
+ <header><strong>会话主题</strong><small>选择消息的呈现方式</small></header>
168
+ <div data-prts-conversation-cards>
169
+ <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>
170
+ <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>
171
+ </div>
172
+ <div data-prts-classic-options>${settingButtons('conversationEmblem', '正文徽记', [])}</div>
173
+ ${avatarMarkup()}
174
+ <div data-prts-baker-options>${settingButtons('bakerGroupAvatars', '合并过程回复头像', [])}</div>
175
+ </section>
162
176
  </section>
163
177
  <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
178
  <header data-prts-section-heading><strong>交互</strong><small>光标、导航栏与动态反馈</small></header>
@@ -207,7 +221,7 @@ export function createThemeSettingsOverlay({
207
221
  trigger,
208
222
  panel,
209
223
  backdrop,
210
- customEmblemStore,
224
+ customEmblemStore, avatarStore,
211
225
  onOpen = () => {},
212
226
  onPreferenceChange = () => {},
213
227
  onPreferencePreview = () => {},
@@ -215,6 +229,7 @@ export function createThemeSettingsOverlay({
215
229
  onRetrySave = () => {},
216
230
  }) {
217
231
  const window = document?.defaultView
232
+ const avatarEditor = mountAvatarSettings({panel,window,store:avatarStore})
218
233
  const customEditor = mountCustomEmblemEditor({ panel, window, store: customEmblemStore })
219
234
  const transparencyMedia = window?.matchMedia?.('(prefers-reduced-transparency: reduce)')
220
235
  const motionMedia = window?.matchMedia?.('(prefers-reduced-motion: reduce)')
@@ -294,6 +309,7 @@ export function createThemeSettingsOverlay({
294
309
  ;(panel.querySelector('[data-prts-theme-settings-close]') ?? focusable()[0] ?? panel).focus?.()
295
310
  } else {
296
311
  customEditor.close?.()
312
+ avatarEditor.close?.()
297
313
  panel.removeAttribute('data-prts-settings-visible')
298
314
  hidePanel()
299
315
  setResetConfirmation(false)
@@ -678,6 +694,11 @@ export function createThemeSettingsOverlay({
678
694
 
679
695
  return {
680
696
  update(preferences, status, persistenceState = currentPersistence) {
697
+ const emblemNote = panel.querySelector('[data-prts-emblem-baker-note]')
698
+ if (emblemNote) emblemNote.hidden = preferences?.conversationStyle !== 'deck-chat'
699
+ panel.querySelector('[data-prts-classic-options]').hidden = preferences?.conversationStyle === 'deck-chat'
700
+ panel.querySelector('[data-prts-baker-options]').hidden = preferences?.conversationStyle !== 'deck-chat'
701
+ panel.querySelector('[data-prts-avatars]').hidden = preferences?.conversationStyle !== 'deck-chat'
681
702
  currentPreferences = preferences
682
703
  currentStatus = status
683
704
  currentPersistence = persistenceState ?? { phase: 'idle', revision: 0 }
@@ -693,6 +714,7 @@ export function createThemeSettingsOverlay({
693
714
  toggle,
694
715
  dispose() {
695
716
  customEditor.dispose()
717
+ avatarEditor.dispose()
696
718
  isOpen = false
697
719
  hidePanel()
698
720
  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
  }