@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.
- package/CHANGELOG.md +10 -0
- package/README.en.md +10 -8
- package/README.md +10 -8
- package/lib/client.js +384 -58
- package/package.json +2 -2
- package/src/client/custom-emblem.js +390 -55
- package/src/client/theme-settings-workbench.js +7 -1
- package/src/index.js +1 -1
- package/src/server/api.js +3 -3
- package/src/server/emblems.js +51 -18
- package/src/server/image-worker.js +30 -13
- package/src/server/tools.js +19 -8
- package/src/styles/theme-settings-workbench.css +546 -642
|
@@ -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) {
|
|
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()}
|
package/src/server/emblems.js
CHANGED
|
@@ -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',
|
|
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(),
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
125
|
-
const
|
|
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
|
-
}
|
|
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 } =
|
|
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
|
|
12
|
-
if (
|
|
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
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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)
|
package/src/server/tools.js
CHANGED
|
@@ -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
|
|
8
|
-
|
|
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:'
|
|
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)) {
|