@ash-qw/dsh-theme-prts 1.1.1 → 1.2.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.
@@ -293,6 +293,7 @@ export function createThemeSettingsOverlay({
293
293
  panel.setAttribute('data-prts-settings-visible', '')
294
294
  ;(panel.querySelector('[data-prts-theme-settings-close]') ?? focusable()[0] ?? panel).focus?.()
295
295
  } else {
296
+ customEditor.close?.()
296
297
  panel.removeAttribute('data-prts-settings-visible')
297
298
  hidePanel()
298
299
  setResetConfirmation(false)
@@ -533,6 +534,7 @@ export function createThemeSettingsOverlay({
533
534
  const scroller = panel?.querySelector('[data-prts-theme-settings-scroll]')
534
535
  const section = panel?.querySelector(`[data-prts-settings-section="${key}"]`)
535
536
  if (!scroller || !section) return
537
+ if (key === 'particles') void customEditor.prefetch?.()
536
538
  if (key !== activeSection) {
537
539
  sectionScroll.set(activeSection, scroller.scrollTop)
538
540
  customEditor.cancelReorder?.()
@@ -642,7 +644,11 @@ export function createThemeSettingsOverlay({
642
644
  else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
643
645
  }
644
646
 
645
- function onCancel(event) { event.preventDefault(); setOpen(false) }
647
+ function onCancel(event) {
648
+ // File inputs also emit bubbling cancel events when their picker is dismissed.
649
+ if (event.target !== panel) return
650
+ event.preventDefault(); setOpen(false)
651
+ }
646
652
  const toggle = () => setOpen(!isOpen)
647
653
  const closeFromBackdrop = () => setOpen(false)
648
654
  const renderMediaState = () => renderState()
package/src/index.js CHANGED
@@ -12,5 +12,5 @@ export async function apply(ctx) {
12
12
  const service=await createEmblemService(directory)
13
13
  const disposers=emblemTools(service,ctx.attachments).map(tool=>ctx.tools.register(tool))
14
14
  disposers.push(registerEmblemApi(ctx,service))
15
- ctx.on('dispose',()=>{for(const dispose of disposers.reverse())dispose()})
15
+ ctx.on('dispose',async()=>{for(const dispose of disposers.reverse())dispose();await service.dispose()})
16
16
  }
package/src/server/api.js CHANGED
@@ -28,7 +28,7 @@ export function registerEmblemApi(ctx,service) {
28
28
  if(req.method==='GET'&&exported){json(res,200,await service.exportAsset(exported[1]));return}
29
29
  const match=path.match(/^\/draft\/([a-zA-Z0-9-]+)(?:\/(preview\.png|source))?$/)
30
30
  if(req.method==='GET'&&match){
31
- if(!match[2]){const d=await service.draft(match[1]);json(res,200,{...draftView(d),value:d.value});return}
31
+ if(!match[2]){const d=await service.draft(match[1]);json(res,200,{...draftView(d),value:d.value,previewPoints:d.previewPoints});return}
32
32
  const bytes=await service.image(match[1],match[2]==='source')
33
33
  res.writeHead(200,{'content-type':match[2]==='source'?'application/octet-stream':'image/png','cache-control':'private, max-age=3600','x-content-type-options':'nosniff'});res.end(bytes);return
34
34
  }
@@ -40,12 +40,12 @@ export function registerEmblemApi(ctx,service) {
40
40
  if(path==='/configuration'){json(res,200,await service.save(data.configuration,data.revision,controller.signal));return}
41
41
  if(path==='/import'){
42
42
  const d=await service.importAsset(data,controller.signal)
43
- json(res,200,{...draftView(d),value:d.value});return
43
+ json(res,200,{...draftView(d),value:d.value,previewPoints:d.previewPoints});return
44
44
  }
45
45
  if(path==='/preview'){
46
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,'图片编码无效')
47
47
  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
- json(res,200,{...draftView(d),value:d.value});return
48
+ json(res,200,{...draftView(d),value:d.value,previewPoints:d.previewPoints});return
49
49
  }
50
50
  throw fault(404,'接口不存在')
51
51
  }catch(error){if(!res.headersSent)json(res,error.status||500,{error:error.message});else res.end()}
@@ -8,36 +8,43 @@ export const EMBLEMS = [
8
8
  ['rhodes-island','罗德岛'],['lungmen','龙门'],['penguin-logistics','企鹅物流'],['rhine-lab','莱茵生命'],['reunion','整合运动'],
9
9
  ].map(([key,label])=>({key,label}))
10
10
  export const API = '/prts-theme-api/emblems'
11
- export const DEFAULT_PARAMS = Object.freeze({mode:'auto',threshold:128,zoom:.85,x:0,y:0})
11
+ export const DEFAULT_PARAMS = Object.freeze({mode:'auto',lineSource:'edges',cleanup:2,threshold:128,zoom:.85,x:0,y:0})
12
12
  export function fault(status,message) { return Object.assign(new Error(message),{status}) }
13
13
  export function parameters(input = {}, base = DEFAULT_PARAMS) {
14
14
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw fault(400,'编辑参数无效')
15
- const result = {...base,...input}
16
- if (Object.keys(result).some(k=>!Object.hasOwn(DEFAULT_PARAMS,k)) || !['auto','alpha','dark','light'].includes(result.mode)
15
+ const result = {...DEFAULT_PARAMS,...base,...input}
16
+ if (Object.keys(result).some(k=>!Object.hasOwn(DEFAULT_PARAMS,k)) || !['auto','alpha','dark','light','lineart'].includes(result.mode)
17
+ || !['edges','ink'].includes(result.lineSource) || !Number.isInteger(result.cleanup) || result.cleanup<0 || result.cleanup>12
17
18
  || !Number.isInteger(result.threshold) || result.threshold<1 || result.threshold>254
18
19
  || !Number.isFinite(result.zoom) || result.zoom<.5 || result.zoom>2
19
20
  || !Number.isFinite(result.x) || Math.abs(result.x)>.5 || !Number.isFinite(result.y) || Math.abs(result.y)>.5) throw fault(400,'编辑参数超出范围')
20
21
  return result
21
22
  }
22
- export function processImage(bytes, params, signal) {
23
+ export function processImage(bytes, params, signal, prepared, reusableWorker) {
23
24
  signal?.throwIfAborted()
24
25
  return new Promise((resolve,reject)=>{
25
- const worker = new Worker(new URL('./image-worker.js',import.meta.url), {workerData:{bytes,params},execArgv:[]})
26
+ const worker = reusableWorker || new Worker(new URL('./image-worker.js',import.meta.url), {workerData:{bytes,params,prepared},execArgv:[]})
27
+ worker.ref()
26
28
  let settled = false
27
29
  const finish = async (error,value) => {
28
30
  if(settled)return; settled=true;clearTimeout(timer);signal?.removeEventListener('abort',abort)
29
- await worker.terminate()
31
+ if(error || !reusableWorker) await worker.terminate()
32
+ worker.off('error',onError);worker.off('exit',onExit);worker.off('message',onMessage)
33
+ worker.unref()
30
34
  if(error)reject(error);else resolve(value)
31
35
  }
32
36
  const abort = ()=>void finish(signal.reason || new Error('已取消'))
37
+ const onError = error=>void finish(error)
38
+ const onExit = code=>{if(!settled)void finish(new Error(`图片处理进程提前退出 (${code})`))}
39
+ const onMessage = result=>void finish(result.error?fault(400,result.error):null,result)
33
40
  const timer = setTimeout(()=>void finish(fault(408,'图片处理超时,请缩小图片后重试')),15000)
34
41
  signal?.addEventListener('abort',abort,{once:true})
35
- worker.once('error',error=>void finish(error))
36
- worker.once('exit',code=>{if(!settled)void finish(new Error(`图片处理进程提前退出 (${code})`))})
37
- worker.once('message',result=>void finish(result.error?fault(400,result.error):null,result))
42
+ worker.once('error',onError);worker.once('exit',onExit);worker.once('message',onMessage)
38
43
  if(signal?.aborted)abort()
44
+ else if(reusableWorker) worker.postMessage({bytes,params,prepared})
39
45
  })
40
46
  }
47
+
41
48
  async function atomic(path,value) {
42
49
  const temp = `${path}.${randomUUID()}.tmp`
43
50
  await writeFile(temp,value,{mode:0o600});await rename(temp,path)
@@ -53,7 +60,7 @@ export async function createEmblemService(directory) {
53
60
  if(!Number.isInteger(stored.revision)||!validEmblemConfiguration(stored.configuration,EMBLEMS))throw Error('已保存的徽记配置无效')
54
61
  state=stored
55
62
  } catch(error) { if(error.code!=='ENOENT')throw error }
56
- let queue=Promise.resolve(), processing=false
63
+ let queue=Promise.resolve(), previewQueue=Promise.resolve(), preparedCache, imageWorker, workerIdleTimer, disposed=false
57
64
  const listeners=new Set()
58
65
  const serialize=fn=>{const next=queue.then(fn);queue=next.catch(()=>{});return next}
59
66
  const snapshot=()=>structuredClone(state)
@@ -84,6 +91,11 @@ export async function createEmblemService(directory) {
84
91
  })
85
92
  return {
86
93
  snapshot,save,draft,
94
+ async dispose() {
95
+ disposed=true;clearTimeout(workerIdleTimer);preparedCache=undefined
96
+ const worker=imageWorker;imageWorker=undefined
97
+ if(worker)await worker.terminate()
98
+ },
87
99
  async exportAsset(id) {
88
100
  const d=await draft(id)
89
101
  const bytes=await readFile(join(directory,`${d.sourceId}.source`))
@@ -101,10 +113,10 @@ export async function createEmblemService(directory) {
101
113
  },
102
114
  previewPath: id=>join(directory,`${checkedId(id)}.png`),
103
115
  subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn)},
104
- async preview({bytes,params={},baseId,label},signal) {
105
- if(processing)throw fault(409,'正在处理另一张徽记,请稍后重试')
106
- processing=true
107
- try {
116
+ preview({bytes,params={},baseId,label},signal) {
117
+ const pending = previewQueue.then(async () => {
118
+ if(disposed)throw fault(503,'徽记服务已关闭')
119
+ signal?.throwIfAborted()
108
120
  const previous=baseId?await draft(baseId):null
109
121
  const effective=parameters(params,previous?.params)
110
122
  if(previous)bytes=await readFile(join(directory,`${previous.sourceId}.source`))
@@ -119,15 +131,36 @@ export async function createEmblemService(directory) {
119
131
  for(const d of expired){await unlink(join(directory,`${d.id}.draft.json`));await unlink(join(directory,`${d.id}.png`))}
120
132
  for(const sourceId of new Set(expired.map(d=>d.sourceId)))if(!retainedSources.has(sourceId))await unlink(join(directory,`${sourceId}.source`))
121
133
  })
122
- const result=await processImage(bytes,effective,signal)
123
134
  signal?.throwIfAborted()
124
- const id=randomUUID(),sourceId=createHash('sha256').update(bytes).digest('hex')
125
- const value={id,sourceId,params:effective,label:String(label||previous?.label||'自定义徽记').slice(0,100),createdAt:Date.now(),value:result.value}
135
+ const sourceHash=createHash('sha256').update(bytes).digest('hex')
136
+ const cacheKey=`${sourceHash}:${effective.zoom}`
137
+ clearTimeout(workerIdleTimer)
138
+ if (!imageWorker || imageWorker.threadId === -1) imageWorker = new Worker(new URL('./image-worker.js',import.meta.url),{execArgv:[]})
139
+ let result
140
+ try {
141
+ result=await processImage(bytes,effective,signal,preparedCache?.key===cacheKey?preparedCache.data:undefined,imageWorker)
142
+ } finally {
143
+ // Reuse the warmed-up worker while editing, then release its memory.
144
+ if (!disposed) {
145
+ workerIdleTimer=setTimeout(()=>{
146
+ void imageWorker?.terminate();imageWorker=undefined;preparedCache=undefined
147
+ },30000)
148
+ workerIdleTimer.unref()
149
+ }
150
+ }
151
+ if(disposed)throw fault(503,'徽记服务已关闭')
152
+ preparedCache={key:cacheKey,data:result.prepared}
153
+ signal?.throwIfAborted()
154
+ const id=randomUUID(),sourceId=sourceHash
155
+ const value={id,sourceId,params:effective,label:String(label||previous?.label||'自定义徽记').slice(0,100),createdAt:Date.now(),value:result.value,previewPoints:result.previewPoints}
126
156
  await atomic(join(directory,`${sourceId}.source`),bytes)
127
157
  await atomic(join(directory,`${id}.png`),result.preview)
128
158
  await atomic(join(directory,`${id}.draft.json`),JSON.stringify(value))
129
159
  return value
130
- } finally {processing=false}
160
+ })
161
+ // A cancelled worker must fully exit before the next request starts.
162
+ previewQueue=pending.catch(()=>{})
163
+ return pending
131
164
  },
132
165
  async image(id,source=false) {const d=await draft(id);return readFile(join(directory,source?`${d.sourceId}.source`:`${d.id}.png`))},
133
166
  async apply({draftId,target,entryKey,revision},signal) {
@@ -2,31 +2,48 @@ import { parentPort, workerData } from 'node:worker_threads'
2
2
  import sharp from 'sharp'
3
3
  import { processCustomEmblemPixels } from '../client/custom-emblem.js'
4
4
 
5
+ async function run(job) {
5
6
  try {
6
- const { bytes, params } = workerData
7
+ const { bytes, params } = job
8
+ let prepared = job.prepared
9
+ if (!prepared) {
7
10
  const image = sharp(Buffer.from(bytes), { limitInputPixels: 16000000, failOn: 'error' }).rotate()
8
11
  const metadata = await image.metadata()
9
12
  if (!['png','jpeg','webp'].includes(metadata.format)) throw new Error('仅支持 PNG、JPEG、WebP 图片')
10
13
  const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true })
11
- let mode = params.mode
12
- if (mode === 'auto') {
13
- mode = 'dark'
14
- for (let i = 3; i < data.length; i += 4) if (data[i] < 128) { mode = 'alpha'; break }
15
- }
14
+ let autoMode = 'dark'
15
+ for (let i = 3; i < data.length; i += 4) if (data[i] < 128) { autoMode = 'alpha'; break }
16
16
  const scale = 320 / Math.max(info.width, info.height) * params.zoom
17
17
  const width = Math.max(1, Math.round(info.width * scale)), height = Math.max(1, Math.round(info.height * scale))
18
18
  const resized = await sharp(data, { raw: info }).resize(width, height).raw().toBuffer()
19
- const rgba = new Uint8ClampedArray(320 * 320 * 4)
20
- const left = Math.round((320-width)/2 + params.x*320), top = Math.round((320-height)/2 + params.y*320)
21
- for (let y = Math.max(0,top); y < Math.min(320,top+height); y++) {
22
- const x = Math.max(0,left), end = Math.min(320,left+width)
23
- if (end > x) rgba.set(resized.subarray(((y-top)*width+x-left)*4,((y-top)*width+end-left)*4),(y*320+x)*4)
19
+ // Extract the entire centered image before applying the viewport translation.
20
+ const size = Math.max(320, width, height)
21
+ const rgba = new Uint8ClampedArray(size * size * 4)
22
+ const left = Math.round((size-width)/2), top = Math.round((size-height)/2)
23
+ for (let y = Math.max(0,top); y < Math.min(size,top+height); y++) {
24
+ const x = Math.max(0,left), end = Math.min(size,left+width)
25
+ if (end > x) rgba.set(resized.subarray(((y-top)*width+x-left)*4,((y-top)*width+end-left)*4),(y*size+x)*4)
26
+ }
27
+ prepared = {rgba,size,autoMode}
24
28
  }
25
- const value = processCustomEmblemPixels(rgba,320,mode,params.threshold)
29
+ const {rgba,size,autoMode} = prepared
30
+ const mode = params.mode === 'auto' ? autoMode : params.mode
31
+ const value = processCustomEmblemPixels(rgba,size,mode,params.threshold,undefined,params)
32
+ let previewPoints
33
+ for (const [density, points] of Object.entries(value.targets)) {
34
+ const shifted = points.map(p => ({...p, x:p.x*size/320+params.x, y:p.y*size/320+params.y}))
35
+ if (density === 'standard') previewPoints = shifted
36
+ value.targets[density] = shifted.filter(p => Math.abs(p.x)<=.5 && Math.abs(p.y)<=.5)
37
+ }
38
+ if (Object.values(value.targets).some(points => points.length < 4)) throw new Error('取景框内可用轮廓太少,请调整位置或缩放')
26
39
  const dots = value.targets.standard.map(p => {
27
40
  const r = p.kind === 'skeleton' ? 1.2 : .9
28
41
  return `<circle cx="${192+p.x*350}" cy="${192+p.y*350}" r="${r}"/>`
29
42
  }).join('')
30
43
  const preview = await sharp(Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="384" height="384"><rect width="384" height="384" fill="#0d1114"/><g fill="#edf1f2">${dots}</g></svg>`)).png().toBuffer()
31
- parentPort.postMessage({ value, preview })
44
+ parentPort.postMessage({ value, preview, previewPoints, prepared })
32
45
  } catch (error) { parentPort.postMessage({ error: error.message }) }
46
+
47
+ }
48
+ parentPort.on('message',run)
49
+ if (workerData?.bytes) void run(workerData)
@@ -2,10 +2,21 @@ 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()
5
+ const images=new Map(), calls=new Map()
6
6
  for(const event of agent.session.snapshotEvents()) {
7
- if(event.type!=='user/message')continue
8
- for(const part of event.data.content ?? [])if(part.attachment && (part.type==='image' || part.type==='file' && /\.(png|jpe?g|webp)$/i.test(part.attachment.name || '')))images.set(part.attachment.attachmentId,{...part.attachment,attachmentKind:part.type})
7
+ if(event.type==='tool/call')calls.set(event.data.callId,event.data.name)
8
+ const uploaded=event.type==='user/message'
9
+ const message=uploaded?event.data:event.type==='tool/result'?event.data.message:null
10
+ const toolName=calls.get(message?.toolCallId ?? message?.source?.callId)
11
+ // Preview attachments are rendered particle images, not editable source images.
12
+ if(!message || message.isError || toolName==='prts_emblem_preview')continue
13
+ for(const part of message.content ?? []) {
14
+ const ref=part.attachment
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
17
+ if(images.has(ref.attachmentId))continue
18
+ images.set(ref.attachmentId,{...ref,attachmentKind:part.type,origin:uploaded?'upload':'tool',...(toolName?{toolName}:{})})
19
+ }
9
20
  }
10
21
  return [...images.values()]
11
22
  }
@@ -13,15 +24,15 @@ export function emblemTools(service,attachments) {
13
24
  const output={schema:{type:'object'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)},...(value.previewImage&&value.imageForModel?[{type:'image',attachment:value.previewImage}]:[])]}
14
25
  return [{
15
26
  name:'prts_emblem_inspect',
16
- description:'查看 PRTS 主题的新会话徽记、正文轮换列表、配置版本,以及当前会话已上传的图片。仅当用户要求查看或调整主题徽记时使用。徽记设置跨工作区和会话共享;工具不会修改设置。',
27
+ description:'查看 PRTS 主题的新会话徽记、正文轮换列表、配置版本,以及当前会话上传或工具返回的图片。仅当用户要求查看或调整主题徽记时使用。徽记设置跨工作区和会话共享;工具不会修改设置。',
17
28
  parameters:{type:'object',properties:{},additionalProperties:false},output,isConcurrencySafe:()=>true,
18
29
  async execute(_args,exec){return JSON.parse(JSON.stringify({...stateView(service.snapshot()),images:sessionImages(exec.agent)}))}
19
30
  },{
20
31
  name:'prts_emblem_preview',
21
- description:'将当前会话上传的图片制作成 PRTS 粒子徽记草稿,或继续调整已有草稿。传 attachmentId(来自 inspect)或 draftId,二选一。调整已有正式徽记可使用 inspect 返回的 draftId。仅生成预览,不应用。优先原样使用返回的 previewMarkdown 展示给用户;没有此字段时,告知用户可展开工具卡片查看。不要将 previewUrl 当成本地图片路径。标准密度预览不是原图。用户满意后使用 apply。不要声称已看到预览,除非实际通过视觉工具查看。',
32
+ description:'将当前会话上传或工具返回的图片制作成 PRTS 粒子徽记草稿,或继续调整已有草稿。传 attachmentId(来自 inspect)或 draftId,二选一。调整已有正式徽记可使用 inspect 返回的 draftId。仅生成预览,不应用。优先原样使用返回的 previewMarkdown 展示给用户;没有此字段时,告知用户可展开工具卡片查看。不要将 previewUrl 当成本地图片路径。AI 生成或编辑的本地图片先用 read_image 读取,再 inspect 获取 attachmentId,无需用户重新上传。线稿效果使用 params.mode=lineart,阈值越高保留的轮廓越少;彩色图用 lineSource=edges,纯线稿用 lineSource=ink 避免双边缘;cleanup=0..12 去除孤立碎线,五官细节丢失时降低到 0。无需自行对齐粒子格点。标准密度预览不是原图。用户满意后使用 apply。不要声称已看到预览,除非实际通过视觉工具查看。',
22
33
  parameters:{type:'object',additionalProperties:false,properties:{
23
34
  attachmentId:{type:'string'},draftId:{type:'string'},label:{type:'string',maxLength:100},
24
- params:{type:'object',additionalProperties:false,properties:{mode:{type:'string',enum:['auto','alpha','dark','light']},threshold:{type:'integer',minimum:1,maximum:254},zoom:{type:'number',minimum:.5,maximum:2},x:{type:'number',minimum:-.5,maximum:.5},y:{type:'number',minimum:-.5,maximum:.5}}},
35
+ params:{type:'object',additionalProperties:false,properties:{mode:{type:'string',enum:['auto','alpha','dark','light','lineart']},lineSource:{type:'string',enum:['edges','ink']},cleanup:{type:'integer',minimum:0,maximum:12},threshold:{type:'integer',minimum:1,maximum:254},zoom:{type:'number',minimum:.5,maximum:2},x:{type:'number',minimum:-.5,maximum:.5},y:{type:'number',minimum:-.5,maximum:.5}}},
25
36
  }},output,isConcurrencySafe:()=>false,
26
37
  async execute(args,exec){
27
38
  const images=sessionImages(exec.agent)
@@ -29,9 +40,9 @@ export function emblemTools(service,attachments) {
29
40
  let bytes,label=args.label
30
41
  if(args.attachmentId) {
31
42
  const ref=images.find(e=>e.attachmentId===args.attachmentId)
32
- if(!ref)throw fault(404,'当前会话没有这张图片,请先上传图片或重新读取图片列表')
43
+ if(!ref)throw fault(404,'当前会话没有这张图片,请先上传图片或用 read_image 读取生成图,再重新读取图片列表')
33
44
  if(ref.bytes>8*1024*1024)throw fault(400,'图片超过 8 MB')
34
- const {attachmentKind,...attachment}=ref
45
+ const {attachmentKind,origin,toolName,...attachment}=ref
35
46
  if(attachmentKind==='file') {
36
47
  const chunks=[];let total=0
37
48
  for await(const chunk of attachments.readFileStream(attachment,exec.signal)) {