@ash-qw/dsh-theme-prts 1.2.2 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.en.md +6 -0
- package/README.md +6 -0
- package/lib/client.js +375 -27
- package/package.json +1 -1
- package/src/client/assistant-glass-adapter.js +68 -4
- package/src/client/avatar-crop.js +134 -0
- package/src/client/conversation-control-adapter.js +11 -1
- package/src/client/cursor-adapter.js +19 -2
- package/src/client/custom-avatar.js +91 -0
- package/src/client/custom-emblem.js +1 -1
- package/src/client/index.js +5 -2
- package/src/client/operations-shell-v2.js +2 -2
- package/src/client/particle-field-adapter.js +18 -9
- package/src/client/preferences.js +5 -1
- package/src/client/theme-settings-workbench.js +25 -3
- 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 +124 -119
- package/src/styles/refinement.css +37 -6
- package/src/styles/theme-settings-workbench.css +58 -5
|
@@ -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
|
|
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
|
}
|