@ash-qw/dsh-theme-prts 2.5.5 → 2.6.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 +26 -0
- package/README.en.md +41 -3
- package/README.md +40 -3
- package/THIRD_PARTY_NOTICES.md +6 -0
- package/THIRD_PARTY_NOTICES.zh-CN.md +6 -0
- package/lib/client.js +536 -316
- package/package.json +5 -3
- package/src/client/avatar-crop.js +16 -3
- package/src/client/background-region-editor.js +2 -1
- package/src/client/conversation-control-adapter.js +131 -20
- package/src/client/cursor-adapter.js +44 -12
- package/src/client/cursor-follower.js +271 -0
- package/src/client/cursor-semantics.js +74 -0
- package/src/client/index.js +5 -1
- package/src/client/native-preferences.js +8 -4
- package/src/client/page-controls.js +185 -0
- package/src/client/preferences.js +25 -5
- package/src/client/theme-controller-v2.js +6 -5
- package/src/client/theme-controller.js +3 -2
- package/src/client/theme-settings-workbench.js +133 -19
- package/src/client/theme-transfer.js +5 -4
- package/src/index.js +27 -5
- package/src/server/avatar-tools.js +1 -1
- package/src/server/background-preview.js +100 -0
- package/src/server/background-tools.js +109 -0
- package/src/server/backgrounds.js +15 -9
- package/src/server/theme-tool-status.js +13 -0
- package/src/server/tools.js +11 -6
- package/src/server/transfer-tools.js +166 -0
- package/src/styles/cursor.css +26 -2
- package/src/styles/refinement.css +1 -1
- package/src/styles/theme-settings-workbench.css +190 -8
- package/src/vendor/mouse-follower/LICENSE +20 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { backgroundPreviewTool, readBackgroundAttachment } from './background-preview.js'
|
|
2
|
+
import { sessionImages } from './tools.js'
|
|
3
|
+
import { fault } from './emblems.js'
|
|
4
|
+
import { validBackgroundRegion } from '../shared/background-region.js'
|
|
5
|
+
import { normalizePreferences } from '../client/preferences.js'
|
|
6
|
+
|
|
7
|
+
const slots=['full','sidebar','conversation']
|
|
8
|
+
const materialTargets=['sidebar','rightSidebar','rail','baker','classicUser']
|
|
9
|
+
const materialSchema={type:'object',additionalProperties:false,properties:Object.fromEntries(
|
|
10
|
+
[['blur',32],['opacity',1]].map(([kind,max])=>[kind,{type:'object',additionalProperties:false,
|
|
11
|
+
properties:Object.fromEntries(materialTargets.map(target=>[target,{type:kind==='blur'?'integer':'number',minimum:0,maximum:max}]))}]))}
|
|
12
|
+
function materialView(value){
|
|
13
|
+
return Object.fromEntries(['blur','opacity'].map(kind=>[kind,Object.fromEntries(
|
|
14
|
+
materialTargets.map(target=>[target,value[target+(kind==='blur'?'Blur':'Opacity')]]))]))
|
|
15
|
+
}
|
|
16
|
+
function materialPatch(material){
|
|
17
|
+
const patch={}
|
|
18
|
+
const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value)
|
|
19
|
+
if(!object(material))throw fault(400,'material 必须是对象')
|
|
20
|
+
for(const [kind,values] of Object.entries(material)){
|
|
21
|
+
if(!['blur','opacity'].includes(kind)||!object(values))throw fault(400,'material 仅支持 blur 和 opacity 对象')
|
|
22
|
+
for(const [target,value] of Object.entries(values)){
|
|
23
|
+
if(!materialTargets.includes(target))throw fault(400,'未知材质区域:'+target)
|
|
24
|
+
const max=kind==='blur'?32:1
|
|
25
|
+
if(!Number.isFinite(value)||value<0||value>max||(kind==='blur'&&!Number.isInteger(value)))
|
|
26
|
+
throw fault(400,kind==='blur'?'blur 必须是 0..32 的整数(px)':'opacity 必须在 0..1')
|
|
27
|
+
patch[target+(kind==='blur'?'Blur':'Opacity')]=value
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return patch
|
|
31
|
+
}
|
|
32
|
+
const revision={type:'integer',minimum:0}
|
|
33
|
+
const region={anyOf:[{type:'null'},{type:'object',additionalProperties:false,properties:{
|
|
34
|
+
x:{type:'number',minimum:0,maximum:1},y:{type:'number',minimum:0,maximum:1},
|
|
35
|
+
width:{type:'number',exclusiveMinimum:0,maximum:1},height:{type:'number',exclusiveMinimum:0,maximum:1},
|
|
36
|
+
},required:['x','y','width','height']}]}
|
|
37
|
+
function checkRevision(value){if(!Number.isInteger(value)||value<0)throw fault(400,'请先 inspect 获取配置版本')}
|
|
38
|
+
export function backgroundTools(service,attachments,host){
|
|
39
|
+
const readSettings=()=>{
|
|
40
|
+
const settings=host?.get?.('settings'),ns=host?.fiber?.entry?.options?.id
|
|
41
|
+
const descriptor=ns && settings?.describe({redactSecrets:true}).find(item=>item.ns===ns)
|
|
42
|
+
if(!descriptor)return {available:false,writable:false}
|
|
43
|
+
const value=normalizePreferences(descriptor.value)
|
|
44
|
+
return {available:true,writable:settings.writable===true,revision:descriptor.revision,
|
|
45
|
+
enabled:value.enabled,mode:value.backgroundMode,dim:value.backgroundDim,material:materialView(value),conversationStyle:value.conversationStyle}
|
|
46
|
+
}
|
|
47
|
+
const view=()=>{
|
|
48
|
+
const state=service.snapshot(),settings=readSettings()
|
|
49
|
+
return {...state,settings,activeSlots:settings.enabled
|
|
50
|
+
? settings.mode==='full'?['full'].filter(slot=>state[slot])
|
|
51
|
+
:settings.mode==='split'?['sidebar','conversation'].filter(slot=>state[slot]):[]:[]}
|
|
52
|
+
}
|
|
53
|
+
const output={schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)}]}
|
|
54
|
+
return [backgroundPreviewTool(service,attachments,readSettings),{
|
|
55
|
+
name:'prts_background_inspect',
|
|
56
|
+
description:'查看 PRTS 背景图片尺寸、保护区域、图片 revision,以及当前 profile 的模式、遮罩 dim、五个区域的 material(blur/opacity)、conversationStyle、settings.revision 和启用状态;列出当前会话上传或工具返回的图片,不修改设置。支持用户图片和 AI 生成、编辑、搜索下载的图片:若生成/搜索工具未直接返回图片附件,先用该会话已有工具保存图片到工作区,再 read_image 读取,然后 inspect 获取 attachmentId。列表按最近出现排序,lastSeenOrder 是会话出现顺序,latestForName 标记同名图片最近出现的版本,并非文件修改时间。需要评估视觉效果时调用 prts_background_preview。本工具不生成、搜索或下载图片,不接受 URL 或任意本地路径。',
|
|
57
|
+
parameters:{type:'object',properties:{},additionalProperties:false},output,isConcurrencySafe:()=>true,
|
|
58
|
+
async execute(_args,exec){return {...view(),images:sessionImages(exec.agent)}},
|
|
59
|
+
},{
|
|
60
|
+
name:'prts_background_apply',
|
|
61
|
+
description:'按用户要求设置背景素材。slot=full 完整背景、sidebar 左侧边栏、conversation 会话正文。action=replace 使用 inspect 返回的当前会话 attachmentId;protect 调整已有图片的保护区域;remove 删除该区域图片。region 为按 EXIF 转正后图片的归一化矩形 x/y/width/height,范围 0..1;null 清除保护框;替换时未指定会清除旧框。保护框只指导铺满裁剪,不能保证所有窗口完整容纳。先 inspect 获取图片 revision,冲突时重新读取,勿盲目覆盖。支持上传及 AI 工具返回的图片;生成图或下载图先 read_image 再 inspect。支持 PNG/JPEG/WebP/AVIF,最大 10 MB、4000 万像素,保存为最长 3840×2160 范围内的 WebP。修改素材影响同一 DSH 实例,替换/移除不保留旧原图。本工具不切换模式;保存后检查 activeSlots,必要时用 configure 启用对应模式。主题总开关不会自动打开。',
|
|
62
|
+
parameters:{type:'object',additionalProperties:false,properties:{
|
|
63
|
+
slot:{type:'string',enum:slots},action:{type:'string',enum:['replace','protect','remove']},
|
|
64
|
+
attachmentId:{type:'string'},region,revision,
|
|
65
|
+
},required:['slot','action','revision']},output,isConcurrencySafe:()=>false,
|
|
66
|
+
async execute(args,exec){
|
|
67
|
+
const images=sessionImages(exec.agent)
|
|
68
|
+
exec.signal?.throwIfAborted()
|
|
69
|
+
checkRevision(args.revision)
|
|
70
|
+
if(!slots.includes(args.slot)||!['replace','protect','remove'].includes(args.action))throw fault(400,'背景区域或操作无效')
|
|
71
|
+
if(args.revision!==service.snapshot().revision)throw fault(409,'背景已更新,请重新 inspect')
|
|
72
|
+
if(args.action!=='replace'&&args.attachmentId!==undefined)throw fault(400,'仅替换图片可以指定 attachmentId')
|
|
73
|
+
if(Object.hasOwn(args,'region')&&!validBackgroundRegion(args.region))throw fault(400,'背景保护区域无效')
|
|
74
|
+
const data={slot:args.slot,revision:args.revision}
|
|
75
|
+
if(args.action==='protect'){
|
|
76
|
+
if(!Object.hasOwn(args,'region'))throw fault(400,'请指定保护区域或 null')
|
|
77
|
+
data.region=args.region
|
|
78
|
+
}else if(args.action==='remove'){
|
|
79
|
+
if(Object.hasOwn(args,'region'))throw fault(400,'移除图片不需要保护区域')
|
|
80
|
+
data.image=null
|
|
81
|
+
}else{
|
|
82
|
+
const bytes=await readBackgroundAttachment(attachments,images,args.attachmentId,exec)
|
|
83
|
+
data.image=bytes.toString('base64')
|
|
84
|
+
if(Object.hasOwn(args,'region'))data.region=args.region
|
|
85
|
+
}
|
|
86
|
+
exec.signal?.throwIfAborted()
|
|
87
|
+
await service.save(data,exec.signal)
|
|
88
|
+
return {applied:true,...view(),previewHint:'可调用 prts_background_preview 查看当前遮罩下的日夜背景合成图;该预览不包含材质模糊和底色不透明度效果。'}
|
|
89
|
+
},
|
|
90
|
+
},{
|
|
91
|
+
name:'prts_background_configure',
|
|
92
|
+
description:'按用户要求修改当前 profile 的背景模式、遮罩和材质。material.blur 为 0..32 整数像素;material.opacity 为 0..1 底色不透明度,越高越不透明(与界面的透明度方向相反)。区域 sidebar=左侧边栏、rightSidebar=右侧边栏、rail=设置侧栏、baker=Baker 气泡、classicUser=经典用户消息。Baker 气泡仅在 deck-chat 风格、经典用户消息仅在 native 风格生效,且需要启用完整背景图片或分区正文图片;其它区域遵循当前背景模式的材质样式。配置可提前保存,不自动切换会话风格或启用图片。mode=off 默认背景(保留素材)、split 分区背景、full 完整背景;dim=0..0.9,越高越接近主题底色,图片越淡。先 inspect 获取 settings.revision,作为本工具 revision;不要使用图片 revision。只修改传入字段,冲突时重新读取,不能盲目重试。模式和遮罩同步到同一 profile 在线页面,素材在实例内共享。工具不修改主题总开关;enabled=false 时应告知用户需要启用主题。',
|
|
93
|
+
parameters:{type:'object',additionalProperties:false,properties:{
|
|
94
|
+
revision,mode:{type:'string',enum:['off','split','full']},dim:{type:'number',minimum:0,maximum:.9},material:materialSchema,
|
|
95
|
+
},required:['revision']},output,isConcurrencySafe:()=>false,
|
|
96
|
+
async execute(args,exec){
|
|
97
|
+
sessionImages(exec.agent);exec.signal?.throwIfAborted();checkRevision(args.revision)
|
|
98
|
+
const patch=args.material===undefined?{}:materialPatch(args.material)
|
|
99
|
+
if(args.mode!==undefined){if(!['off','split','full'].includes(args.mode))throw fault(400,'背景模式无效');patch.backgroundMode=args.mode}
|
|
100
|
+
if(args.dim!==undefined){if(!Number.isFinite(args.dim)||args.dim<0||args.dim>.9)throw fault(400,'遮罩 dim 必须在 0..0.9');patch.backgroundDim=args.dim}
|
|
101
|
+
if(!Object.keys(patch).length)throw fault(400,'请指定背景模式、遮罩或至少一项材质参数')
|
|
102
|
+
const current=readSettings()
|
|
103
|
+
if(!current.writable)throw fault(400,'当前宿主不支持持久化主题设置,请在设置界面调整背景和材质')
|
|
104
|
+
if(current.revision!==args.revision)throw fault(409,'主题设置已更新,请重新 inspect')
|
|
105
|
+
await host.get('settings').update(host.fiber.entry.options.id,{...patch,preferencesInitialized:true},args.revision)
|
|
106
|
+
return {applied:true,...view(),previewHint:'可调用 prts_background_preview 查看当前遮罩下的日夜背景合成图;该预览不包含材质模糊和底色不透明度效果。'}
|
|
107
|
+
},
|
|
108
|
+
}]
|
|
109
|
+
}
|
|
@@ -8,6 +8,14 @@ import { fault } from './emblems.js'
|
|
|
8
8
|
|
|
9
9
|
export const BACKGROUND_API = '/prts-theme-api/backgrounds'
|
|
10
10
|
const BACKGROUND_SLOTS = ['full', 'sidebar', 'conversation']
|
|
11
|
+
export async function prepareBackgroundImage(bytes) {
|
|
12
|
+
try {
|
|
13
|
+
const input=sharp(bytes,{limitInputPixels:40000000,animated:false})
|
|
14
|
+
const metadata=await input.metadata()
|
|
15
|
+
if(!['png','jpeg','webp','avif'].includes(metadata.format))throw Error('format')
|
|
16
|
+
return await input.rotate().resize({width:3840,height:2160,fit:'inside',withoutEnlargement:true}).webp({quality:86}).toBuffer({resolveWithObject:true})
|
|
17
|
+
}catch{throw fault(400,'无法读取图片,请使用 PNG、JPEG、WebP 或 AVIF(最多 4000 万像素)')}
|
|
18
|
+
}
|
|
11
19
|
export async function createBackgroundService(directory) {
|
|
12
20
|
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
13
21
|
const configurationPath = join(directory, 'configuration.json')
|
|
@@ -23,8 +31,9 @@ export async function createBackgroundService(directory) {
|
|
|
23
31
|
if (!BACKGROUND_SLOTS.some(slot => state[slot]?.id === id)) throw fault(404, '背景不存在')
|
|
24
32
|
return readFile(join(directory, id + '.webp'))
|
|
25
33
|
},
|
|
26
|
-
save(data) {
|
|
34
|
+
save(data, signal) {
|
|
27
35
|
const task = queue.then(async () => {
|
|
36
|
+
signal?.throwIfAborted()
|
|
28
37
|
if (!BACKGROUND_SLOTS.includes(data?.slot)) throw fault(400, '背景区域无效')
|
|
29
38
|
if (data.revision !== state.revision) throw fault(409, '背景已在其他页面更新,请重新操作')
|
|
30
39
|
const regionOnly = Object.hasOwn(data, 'region') && !Object.hasOwn(data, 'image')
|
|
@@ -35,20 +44,17 @@ export async function createBackgroundService(directory) {
|
|
|
35
44
|
if (!isBase64(data.image) || data.image.length > 14 * 1024 * 1024) throw fault(400, '请上传不超过 10 MB 的图片')
|
|
36
45
|
const bytes = Buffer.from(data.image, 'base64')
|
|
37
46
|
if (!bytes.length || bytes.length > 10 * 1024 * 1024) throw fault(400, '请上传不超过 10 MB 的图片')
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!['png', 'jpeg', 'webp', 'avif'].includes(metadata.format)) throw Error('format')
|
|
42
|
-
const result = await input.rotate().resize({ width: 3840, height: 2160, fit: 'inside', withoutEnlargement: true }).webp({ quality: 86 }).toBuffer({ resolveWithObject: true })
|
|
43
|
-
output = result.data
|
|
44
|
-
entry = { id: randomUUID(), width: result.info.width, height: result.info.height, region: data.region ?? null }
|
|
45
|
-
} catch { throw fault(400, '无法读取图片,请使用 PNG、JPEG、WebP 或 AVIF(最多 4000 万像素)') }
|
|
47
|
+
const result=await prepareBackgroundImage(bytes)
|
|
48
|
+
output=result.data
|
|
49
|
+
entry={id:randomUUID(),width:result.info.width,height:result.info.height,region:data.region ?? null}
|
|
46
50
|
}
|
|
51
|
+
signal?.throwIfAborted()
|
|
47
52
|
const next = { ...state, revision: state.revision + 1, [data.slot]: entry }
|
|
48
53
|
const temporary = configurationPath + '.' + randomUUID() + '.tmp'
|
|
49
54
|
try {
|
|
50
55
|
if (output) await writeFile(join(directory, entry.id + '.webp'), output, { mode: 0o600 })
|
|
51
56
|
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 })
|
|
57
|
+
signal?.throwIfAborted()
|
|
52
58
|
await rename(temporary, configurationPath)
|
|
53
59
|
} catch (error) {
|
|
54
60
|
if (output) await rm(join(directory, entry.id + '.webp'), { force: true }).catch(() => {})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const ENABLE_THEME_TOOLS = '请用户手动打开主界面左上角罗德岛徽记 → AI 工具 →「允许 AI 定制主题」。若导航栏已隐藏,先展开 P.R.T.S. 导航。'
|
|
2
|
+
export const THEME_TOOL_POLICY = '涉及 PRTS 背景、头像、徽记、配置导入导出时,先检查 prts_theme_status。若工具被关闭,应先提醒用户手动开启「允许 AI 定制主题」,等待用户开启后再继续;不要自行启用开关,也不要用 shell、文件编辑、HTTP 或其他工具绕过开关修改主题配置或素材。'
|
|
3
|
+
export function themeToolStatus(isEnabled){
|
|
4
|
+
return {
|
|
5
|
+
name:'prts_theme_status',
|
|
6
|
+
description:'只读查看 PRTS AI 定制工具是否开启及手动开启位置;关闭定制工具后此入口仍可用,不读取配置内容或素材。'+THEME_TOOL_POLICY,
|
|
7
|
+
parameters:{type:'object',properties:{},additionalProperties:false},
|
|
8
|
+
output:{schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)}]},
|
|
9
|
+
isConcurrencySafe:()=>true,
|
|
10
|
+
execute:async()=>({enabled:isEnabled(),nextAction:isEnabled()?'使用 PRTS 专用工具处理用户要求。':ENABLE_THEME_TOOLS,
|
|
11
|
+
policy:THEME_TOOL_POLICY,scope:'此状态入口只报告状态,不能更改开关。'}),
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/server/tools.js
CHANGED
|
@@ -2,23 +2,28 @@ import { draftView, stateView, fault } from './emblems.js'
|
|
|
2
2
|
|
|
3
3
|
export function sessionImages(agent) {
|
|
4
4
|
if(!agent?.session)throw fault(400,'请在 DSH 会话中调用此工具')
|
|
5
|
-
const images=new Map(), calls=new Map()
|
|
5
|
+
const images=new Map(), calls=new Map();let order=0
|
|
6
6
|
for(const event of agent.session.snapshotEvents()) {
|
|
7
7
|
if(event.type==='tool/call')calls.set(event.data.callId,event.data.name)
|
|
8
8
|
const uploaded=event.type==='user/message'
|
|
9
9
|
const message=uploaded?event.data:event.type==='tool/result'?event.data.message:null
|
|
10
10
|
const toolName=calls.get(message?.toolCallId ?? message?.source?.callId)
|
|
11
11
|
// Preview attachments are rendered particle images, not editable source images.
|
|
12
|
-
if(!message || message.isError ||
|
|
12
|
+
if(!message || message.isError || ['prts_emblem_preview','prts_background_preview'].includes(toolName))continue
|
|
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
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
|
-
|
|
18
|
-
|
|
17
|
+
images.set(ref.attachmentId,{...ref,attachmentKind:part.type,origin:uploaded?'upload':'tool',...(toolName?{toolName}:{}),
|
|
18
|
+
lastSeenOrder:++order,...(Number.isFinite(event.createdAt)?{lastSeenAt:new Date(event.createdAt).toISOString()}:{})})
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
const result=[...images.values()].sort((a,b)=>b.lastSeenOrder-a.lastSeenOrder),names=new Set()
|
|
22
|
+
return result.map(image=>{
|
|
23
|
+
const latestForName=Boolean(image.name && !names.has(image.name))
|
|
24
|
+
if(image.name)names.add(image.name)
|
|
25
|
+
return {...image,latestForName}
|
|
26
|
+
})
|
|
22
27
|
}
|
|
23
28
|
export function emblemTools(service,attachments) {
|
|
24
29
|
const output={schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)},...(value.previewImage&&value.imageForModel?[{type:'image',attachment:value.previewImage}]:[])]}
|
|
@@ -42,7 +47,7 @@ export function emblemTools(service,attachments) {
|
|
|
42
47
|
const ref=images.find(e=>e.attachmentId===args.attachmentId)
|
|
43
48
|
if(!ref)throw fault(404,'当前会话没有这张图片,请先上传图片或用 read_image 读取生成图,再重新读取图片列表')
|
|
44
49
|
if(ref.bytes>8*1024*1024)throw fault(400,'图片超过 8 MB')
|
|
45
|
-
const {attachmentKind,origin,toolName,...attachment}=ref
|
|
50
|
+
const {attachmentKind,origin,toolName,lastSeenOrder,lastSeenAt,latestForName,...attachment}=ref
|
|
46
51
|
if(attachmentKind==='file') {
|
|
47
52
|
const chunks=[];let total=0
|
|
48
53
|
for await(const chunk of attachments.readFileStream(attachment,exec.signal)) {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { randomUUID, createHash } from 'node:crypto'
|
|
2
|
+
import { createThemeTransfer, validateThemePackage, ignoredThemePackageFields, describeThemePackage, mergeThemePreferences, THEME_PACKAGE_LIMIT } from '../client/theme-transfer.js'
|
|
3
|
+
import { normalizePreferences } from '../client/preferences.js'
|
|
4
|
+
import { draftView, fault } from './emblems.js'
|
|
5
|
+
|
|
6
|
+
function sessionFiles(agent){
|
|
7
|
+
if(!agent?.session)throw fault(400,'请在 DSH 会话中调用此工具')
|
|
8
|
+
const refs=new Map()
|
|
9
|
+
for(const event of agent.session.snapshotEvents()){
|
|
10
|
+
const message=event.type==='user/message'?event.data:event.type==='tool/result'?event.data.message:null
|
|
11
|
+
if(!message || message.isError)continue
|
|
12
|
+
for(const part of message.content ?? []){
|
|
13
|
+
if(part.type==='file' && typeof part.attachment?.attachmentId==='string')refs.set(part.attachment.attachmentId,part.attachment)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return [...refs.values()]
|
|
17
|
+
}
|
|
18
|
+
export function transferTools({backgrounds,avatars,emblems,attachments,host,themeVersion}){
|
|
19
|
+
const services={backgrounds,avatars,emblems},previews=new Map()
|
|
20
|
+
let busy=false
|
|
21
|
+
const settings=()=>{
|
|
22
|
+
const service=host.get?.('settings'),ns=host.fiber?.entry?.options?.id
|
|
23
|
+
const descriptor=service?.describe({redactSecrets:true}).find(value=>value.ns===ns)
|
|
24
|
+
if(!descriptor)throw fault(400,'当前宿主无法读取主题配置')
|
|
25
|
+
return {service,ns,...descriptor}
|
|
26
|
+
}
|
|
27
|
+
const revisions=assets=>({settings:settings().revision,...(assets?Object.fromEntries(Object.entries(services).map(([key,value])=>[key,value.snapshot().revision])):{})})
|
|
28
|
+
const verify=expected=>{
|
|
29
|
+
const current=revisions(Object.hasOwn(expected,'backgrounds'))
|
|
30
|
+
if(Object.keys(expected).some(key=>current[key]!==expected[key]))throw fault(409,'主题或素材已变化,请重新预检导入文件')
|
|
31
|
+
}
|
|
32
|
+
const saveFile=async(value,label,signal)=>{
|
|
33
|
+
signal?.throwIfAborted()
|
|
34
|
+
const data=Buffer.from(JSON.stringify(value))
|
|
35
|
+
if(data.length>THEME_PACKAGE_LIMIT)throw fault(400,'主题文件超过 160 MB')
|
|
36
|
+
const name=`prts-${label}-${new Date().toISOString().replace(/[:.]/g,'-')}.prts-theme.json`
|
|
37
|
+
const file=await attachments.saveFile({data,name})
|
|
38
|
+
return file
|
|
39
|
+
}
|
|
40
|
+
const read=async(ref,signal)=>{
|
|
41
|
+
if(ref.bytes>THEME_PACKAGE_LIMIT)throw fault(400,'主题文件超过 160 MB')
|
|
42
|
+
let size=0;const chunks=[]
|
|
43
|
+
for await(const chunk of attachments.readFileStream(ref,signal)){
|
|
44
|
+
signal?.throwIfAborted();size+=chunk.length
|
|
45
|
+
if(size>THEME_PACKAGE_LIMIT)throw fault(400,'主题文件超过 160 MB')
|
|
46
|
+
chunks.push(chunk)
|
|
47
|
+
}
|
|
48
|
+
signal?.throwIfAborted()
|
|
49
|
+
const data=Buffer.concat(chunks)
|
|
50
|
+
let value
|
|
51
|
+
try{value=JSON.parse(data.toString('utf8').replace(/^\uFEFF/,''))}catch{throw fault(400,'无法解析主题 JSON 文件')}
|
|
52
|
+
validateThemePackage(value)
|
|
53
|
+
return {value,hash:createHash('sha256').update(data).digest('hex')}
|
|
54
|
+
}
|
|
55
|
+
const bridge=(expected,onDownload)=>{
|
|
56
|
+
const request=async(path,options={})=>{
|
|
57
|
+
options.signal?.throwIfAborted()
|
|
58
|
+
const route=path.replace('/prts-theme-api/','')
|
|
59
|
+
const [kind,...parts]=route.split('/'),service=services[kind],tail=parts.join('/')
|
|
60
|
+
if(!service)throw Error('未知素材服务')
|
|
61
|
+
const data=options.body===undefined?undefined:JSON.parse(options.body)
|
|
62
|
+
let value
|
|
63
|
+
if(tail==='configuration'){
|
|
64
|
+
if(data===undefined){
|
|
65
|
+
value=service.snapshot()
|
|
66
|
+
if(expected && expected[kind]!==undefined && expected[kind]!==value.revision)throw fault(409,'素材已变化,请重新预检')
|
|
67
|
+
}else value=kind==='emblems'?await service.save(data.configuration,data.revision):await service.save(data)
|
|
68
|
+
}else if(kind==='backgrounds' && tail.startsWith('image/')){
|
|
69
|
+
return new Response(await backgrounds.image(parts[1]),{headers:{'content-type':'image/webp'}})
|
|
70
|
+
}else if(kind==='avatars' && tail.startsWith('edit/'))value=avatars.edit(parts[1])
|
|
71
|
+
else if(kind==='emblems' && tail==='import')value=draftView(await emblems.importAsset(data,options.signal))
|
|
72
|
+
else if(kind==='emblems' && parts[0]==='draft' && parts[2]==='export')value=await emblems.exportAsset(decodeURIComponent(parts[1]))
|
|
73
|
+
else throw Error('未知素材操作')
|
|
74
|
+
return {ok:true,json:async()=>value}
|
|
75
|
+
}
|
|
76
|
+
return createThemeTransfer({
|
|
77
|
+
window:{fetch:request,setTimeout,Blob},themeVersion,onDownload,
|
|
78
|
+
readImage:async(blob,signal)=>{signal?.throwIfAborted();return Buffer.from(await blob.arrayBuffer()).toString('base64')},
|
|
79
|
+
getPreferences:()=>normalizePreferences(settings().value),
|
|
80
|
+
checkExport:()=>settings().revision,
|
|
81
|
+
checkWritable:()=>{
|
|
82
|
+
const state=settings()
|
|
83
|
+
if(!state.service.writable)throw fault(400,'当前宿主无法持久化主题配置')
|
|
84
|
+
if(expected)verify(expected)
|
|
85
|
+
return state.revision
|
|
86
|
+
},
|
|
87
|
+
savePreferences:async(value,revision)=>{
|
|
88
|
+
const state=settings()
|
|
89
|
+
await state.service.update(state.ns,{...value,preferencesInitialized:true},revision)
|
|
90
|
+
},
|
|
91
|
+
backupNotice:'导入前备份已保存为附件,可重新导入恢复。',
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
const exclusive=async fn=>{
|
|
95
|
+
if(busy)throw fault(409,'正在处理主题文件,请稍候')
|
|
96
|
+
busy=true
|
|
97
|
+
try{return await fn()}finally{busy=false}
|
|
98
|
+
}
|
|
99
|
+
const prune=()=>{for(const [id,preview] of previews)if(preview.expires<Date.now())previews.delete(id)}
|
|
100
|
+
const output={schema:{type:'object'},render:(_args,value)=>[
|
|
101
|
+
{type:'text',text:JSON.stringify(value)},
|
|
102
|
+
...[value.file,value.backup].filter(ref=>ref && typeof ref==='object' && typeof ref.attachmentId==='string' && typeof ref.name==='string' && Number.isFinite(ref.bytes)).map(attachment=>({type:'file',attachment})),
|
|
103
|
+
]}
|
|
104
|
+
return [{
|
|
105
|
+
name:'prts_theme_export',
|
|
106
|
+
description:'按用户要求导出当前 PRTS 主题为可下载的 .prts-theme.json 文件附件,和设置界面导出格式一致。includeAssets 默认为 true,含背景、保护区域、用户/AI 头像原图与裁剪、徽记原图及排列;false 仅导出设置。不会修改主题。不包含会话或凭据。最大 160 MB,素材或配置在导出期间变化会拒绝不一致结果。',
|
|
107
|
+
parameters:{type:'object',additionalProperties:false,properties:{includeAssets:{type:'boolean'}}},output,isConcurrencySafe:()=>false,
|
|
108
|
+
execute:(args,exec)=>exclusive(async()=>{
|
|
109
|
+
sessionFiles(exec.agent)
|
|
110
|
+
if(args.includeAssets!==undefined && typeof args.includeAssets!=='boolean')throw fault(400,'includeAssets 必须是布尔值')
|
|
111
|
+
const value=await bridge().export(args.includeAssets!==false,undefined,exec.signal)
|
|
112
|
+
const file=await saveFile(value,'theme',exec.signal)
|
|
113
|
+
return {exported:true,summary:describeThemePackage(value),file}
|
|
114
|
+
}),
|
|
115
|
+
},{
|
|
116
|
+
name:'prts_theme_import_preview',
|
|
117
|
+
description:'只预检 PRTS 主题文件,不修改主题。未传 attachmentId 时列出当前会话上传或工具返回的 JSON 文件;指定后验证文件并返回 previewId、摘要、将忽略的字段和设置变化。支持本工具导出的文件,也支持上传或由会话文件工具返回的文件附件;不接受任意路径/URL。请向用户说明覆盖范围及 ignoredFields;得到导入授权后才调用 import。预检有效期 15 分钟,仅限当前会话,配置或素材变化后需重新预检。',
|
|
118
|
+
parameters:{type:'object',additionalProperties:false,properties:{attachmentId:{type:'string'}}},output,isConcurrencySafe:()=>false,
|
|
119
|
+
execute:(args,exec)=>exclusive(async()=>{
|
|
120
|
+
const files=sessionFiles(exec.agent);exec.signal?.throwIfAborted();prune()
|
|
121
|
+
if(!args.attachmentId)return {files:files.filter(ref=>/\.json$/i.test(ref.name || '') || /json/.test(ref.mediaType || ''))}
|
|
122
|
+
const ref=files.find(file=>file.attachmentId===args.attachmentId)
|
|
123
|
+
if(!ref)throw fault(404,'当前会话没有此文件,请先上传或通过会话工具返回文件附件')
|
|
124
|
+
const {value,hash}=await read(ref,exec.signal),expected=revisions(Boolean(value.assets))
|
|
125
|
+
const current=normalizePreferences(settings().value),next=mergeThemePreferences(current,value.preferences)
|
|
126
|
+
const previewId=randomUUID(),expires=Date.now()+15*60*1000
|
|
127
|
+
while(previews.size>=20)previews.delete(previews.keys().next().value)
|
|
128
|
+
previews.set(previewId,{ref,hash,expected,session:exec.agent.session,expires})
|
|
129
|
+
return {previewId,expiresAt:new Date(expires).toISOString(),summary:describeThemePackage(value),
|
|
130
|
+
ignoredFields:ignoredThemePackageFields(value),
|
|
131
|
+
changes:Object.entries(next).filter(([key,val])=>val!==current[key]).map(([key,after])=>({key,before:current[key],after})),
|
|
132
|
+
scope:'设置影响当前 profile;完整文件中的素材替换影响同一 DSH 实例。仅配置文件保留现有素材。',
|
|
133
|
+
backupNotice:'导入前会先保存备份附件;失败时尝试回退已确认写入的素材。',
|
|
134
|
+
effects:value.assets?[
|
|
135
|
+
'替换或移除背景会删除旧 WebP 文件;备份附件保留恢复所需数据。',
|
|
136
|
+
'替换或移除头像不保留旧原图;备份附件包含原图和裁剪参数。',
|
|
137
|
+
'每个自定义徽记会生成新的草稿和预览;相同原图按内容哈希复用。',
|
|
138
|
+
'草稿达到约 200 份时,按创建时间清理较旧且未受保护的草稿;正在应用的徽记受保护。反复完整导入会增加草稿。',
|
|
139
|
+
]:['仅修改设置,保留当前素材。'],
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
},{
|
|
143
|
+
name:'prts_theme_import',
|
|
144
|
+
description:'用户授权后,使用当前会话的 previewId 导入已预检的主题文件。完整文件替换背景、头像、徽记及排列,空素材恢复默认;仅设置文件保留现有素材。替换/移除背景会删除旧 WebP,头像旧原图也不会保留。每个自定义徽记会新增草稿和预览,相同原图按内容哈希复用;草稿达到约 200 份时清理较旧且未受保护的草稿,正在应用的徽记受保护。导入前先保存备份附件,失败或取消时尝试回退素材。配置/素材版本变化、预检过期、文件内容变化会拒绝执行,需要重新预检。返回 applied=false 时必须报告错误和备份,不能宣称成功;部分回退失败时应核对实际状态。导入可能关闭 AI 工具或主题总开关,预检时应告知相应变化。',
|
|
145
|
+
parameters:{type:'object',additionalProperties:false,properties:{previewId:{type:'string'}},required:['previewId']},output,isConcurrencySafe:()=>false,
|
|
146
|
+
execute:(args,exec)=>exclusive(async()=>{
|
|
147
|
+
sessionFiles(exec.agent);exec.signal?.throwIfAborted();prune()
|
|
148
|
+
const preview=previews.get(args.previewId)
|
|
149
|
+
if(!preview || preview.session!==exec.agent.session)throw fault(404,'预检不存在或已过期,请在当前会话重新预检')
|
|
150
|
+
verify(preview.expected)
|
|
151
|
+
const {value,hash}=await read(preview.ref,exec.signal)
|
|
152
|
+
if(hash!==preview.hash)throw fault(409,'文件内容已变化,请重新预检')
|
|
153
|
+
verify(preview.expected)
|
|
154
|
+
previews.delete(args.previewId)
|
|
155
|
+
let backup
|
|
156
|
+
const transfer=bridge(preview.expected,async data=>{backup=await saveFile(data,'before-import',exec.signal)})
|
|
157
|
+
try{
|
|
158
|
+
await transfer.import(value,undefined,exec.signal)
|
|
159
|
+
return {applied:true,summary:describeThemePackage(value),backup}
|
|
160
|
+
}catch(error){
|
|
161
|
+
if(!backup)throw error
|
|
162
|
+
return {applied:false,error:error.message,backup}
|
|
163
|
+
}
|
|
164
|
+
}),
|
|
165
|
+
}]
|
|
166
|
+
}
|
package/src/styles/cursor.css
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/* Native cursor; gesture frames override the resting image without a DOM overlay. */
|
|
2
2
|
@media (any-pointer: fine) {
|
|
3
3
|
html[data-dsh-prts][data-prts-cursor="prts"] {
|
|
4
|
-
--prts-hand-cursor:
|
|
4
|
+
--prts-rest-hand-cursor: url("__PRTS_HAND__") 7 6;
|
|
5
|
+
--prts-hand-cursor: var(--prts-hand-frame, var(--prts-rest-hand-cursor));
|
|
5
6
|
--prts-text-cursor: var(--prts-text-frame, url("__PRTS_TEXT__") 24 24);
|
|
6
7
|
--prts-sort-cursor: url("__PRTS_GRABBING__") 22 19, grabbing;
|
|
7
|
-
--prts-link-cursor: url("__PRTS_LINK__") 7 7;
|
|
8
|
+
--prts-rest-link-cursor: url("__PRTS_LINK__") 7 7;
|
|
9
|
+
--prts-link-cursor: var(--prts-rest-link-cursor);
|
|
8
10
|
cursor: var(--prts-hand-cursor), default;
|
|
9
11
|
}
|
|
10
12
|
html[data-dsh-prts][data-prts-cursor="prts"] :is(button, a[href], summary, [role="button"], [role="tab"], [role="treeitem"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [data-prts-workspace-row], [data-prts-session-row], .dsh-partner-sidebar-row, select, input[type="button"], input[type="submit"], input[type="checkbox"], input[type="radio"], input[type="color"], input[type="range"]) {
|
|
@@ -41,3 +43,25 @@ html[data-dsh-prts] [data-prts-region-stage] [data-prts-region-handle] { cursor:
|
|
|
41
43
|
html[data-dsh-prts] [data-prts-region-stage] :is([data-prts-region-handle="e"],[data-prts-region-handle="w"]) { cursor:ew-resize!important; }
|
|
42
44
|
html[data-dsh-prts] [data-prts-region-stage] :is([data-prts-region-handle="nw"],[data-prts-region-handle="se"]) { cursor:nwse-resize!important; }
|
|
43
45
|
html[data-dsh-prts] [data-prts-region-stage] :is([data-prts-region-handle="ne"],[data-prts-region-handle="sw"]) { cursor:nesw-resize!important; }
|
|
46
|
+
|
|
47
|
+
/* A single renderer owns every in-page cursor state. State classification
|
|
48
|
+
reads mirrored cursor metadata, so hiding never needs to be toggled to read. */
|
|
49
|
+
html[data-dsh-prts][data-prts-follower-visible],
|
|
50
|
+
html[data-dsh-prts][data-prts-follower-visible] :is(*,#prts-cursor-layer-specificity) {
|
|
51
|
+
cursor:none!important;
|
|
52
|
+
}
|
|
53
|
+
.prts-cursor-follower {
|
|
54
|
+
position:fixed;top:0;left:0;width:1px;height:1px;z-index:2147483647;
|
|
55
|
+
pointer-events:none!important;user-select:none;contain:layout style;
|
|
56
|
+
margin:0;padding:0;border:0;background:transparent;overflow:visible;right:auto;bottom:auto;
|
|
57
|
+
}
|
|
58
|
+
.prts-cursor-follower[hidden] { display:none!important; }
|
|
59
|
+
.prts-cursor-follower * { pointer-events:none!important; }
|
|
60
|
+
.prts-cursor-follower img { position:absolute;max-width:none!important;max-height:none!important; }
|
|
61
|
+
|
|
62
|
+
/* Known DSH horizontal resize boundaries must have their native image before
|
|
63
|
+
pointer events fire, including the chat width handle and its hit children. */
|
|
64
|
+
html[data-dsh-prts][data-prts-cursor="prts"] :is([class$="_widthHandle"],[class$="_detailsResizeHandle"],.dsh-ssh-workbench-resizer),
|
|
65
|
+
html[data-dsh-prts][data-prts-cursor="prts"] :is([class$="_widthHandle"],[class$="_detailsResizeHandle"],.dsh-ssh-workbench-resizer) * {
|
|
66
|
+
cursor: url("__PRTS_RESIZE_EW__") 16 16, col-resize;
|
|
67
|
+
}
|
|
@@ -520,7 +520,7 @@ html[data-dsh-prts] :is([data-chat-flow-kind],[data-message-role],[data-submissi
|
|
|
520
520
|
}
|
|
521
521
|
&:active{transform:none}
|
|
522
522
|
}
|
|
523
|
-
html[data-dsh-prts] [data-prts-body-target-frame] {
|
|
523
|
+
html[data-dsh-prts] :is([data-prts-body-target-frame],[data-prts-body-target-ghost]) {
|
|
524
524
|
position:fixed;inset:auto;left:0;top:0;width:0;height:0;margin:0;padding:0;border:0;background:transparent;overflow:visible;z-index:100;pointer-events:none;opacity:0;--prts-corner-width:1px;
|
|
525
525
|
& > i {
|
|
526
526
|
position:absolute;left:0;top:0;width:7px;height:7px;will-change:transform;
|