@xia-sc/dsh-cc-studio 0.3.2
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/LICENSE +21 -0
- package/README.md +295 -0
- package/cordis.patch.yml +6 -0
- package/lib/agent.js +614 -0
- package/lib/client.js +1536 -0
- package/lib/index.js +944 -0
- package/package.json +61 -0
- package/presets/cc/agent.cordis.yml +278 -0
- package/presets/cc/preset.yml +2 -0
- package/prototypes/index.html +11 -0
- package/prototypes/prototype-a.html +246 -0
- package/prototypes/prototype-b.html +175 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xia-sc/dsh-cc-studio — host half.
|
|
3
|
+
* CCv3 工坊:校验 + 共享草稿(供 CC 模式的 LLM Tools 与浏览器胶囊同步)+ 已存角色侧边栏落盘。
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { inflateSync as zlibInflateSync } from "node:zlib";
|
|
12
|
+
|
|
13
|
+
const name = "dsh-cc-studio";
|
|
14
|
+
// dsh >= 0.1.5-rc.1: this package owns its HTTP route outright.
|
|
15
|
+
//
|
|
16
|
+
// `connection.rpc.handle()` is NOT usable by an outside plugin in this version:
|
|
17
|
+
// `HostConnectionService.rpc` closes over `this.ctx` — the connection plugin's
|
|
18
|
+
// OWN Context, whose `inject` is only `["credentials"]` — and registers through
|
|
19
|
+
// it (`owner.effect(() => owner.webServer.register(route))`,
|
|
20
|
+
// dsh-client-connection lib/index.js:618). The connection plugin reaches
|
|
21
|
+
// `webServer` through an inner `ctx.inject(["webServer"], …)` scope, so that
|
|
22
|
+
// owner fiber never resolves it and the call throws
|
|
23
|
+
// "cannot get property \"webServer\" without inject" no matter what the caller
|
|
24
|
+
// injects. We therefore register the channel on `webServer` ourselves and speak
|
|
25
|
+
// the same connection RPC wire protocol on the browser's behalf.
|
|
26
|
+
const inject = ["webServer", "connection", "agents", "agentPresets"];
|
|
27
|
+
|
|
28
|
+
function ok(value){ return { ok:true, value }; }
|
|
29
|
+
function fail(code, message, details={}){ return { ok:false, error:{ code:"internal", message, details:{...details, code} } }; }
|
|
30
|
+
|
|
31
|
+
function validateCard(card){
|
|
32
|
+
const errors=[];
|
|
33
|
+
const warnings=[];
|
|
34
|
+
if(!card || typeof card!=="object") errors.push("card 必须是对象");
|
|
35
|
+
else {
|
|
36
|
+
if(card.spec!=="chara_card_v3") errors.push('spec 必须为 "chara_card_v3"');
|
|
37
|
+
if(card.spec_version!=="3.0") warnings.push('spec_version 建议为 "3.0"');
|
|
38
|
+
const d=card.data;
|
|
39
|
+
if(!d || typeof d!=="object") errors.push("data 缺失");
|
|
40
|
+
else {
|
|
41
|
+
if(typeof d.name!=="string" || d.name.trim()==="") errors.push("data.name 必填");
|
|
42
|
+
if(!Array.isArray(d.group_only_greetings)) errors.push("data.group_only_greetings 必填且为 string[]");
|
|
43
|
+
if(d.tags && !Array.isArray(d.tags)) errors.push("data.tags 必须为 string[]");
|
|
44
|
+
if(d.assets){
|
|
45
|
+
if(!Array.isArray(d.assets)) errors.push("data.assets 必须为数组");
|
|
46
|
+
else {
|
|
47
|
+
const mains=d.assets.filter(a=>a.type==="icon" && a.name==="main");
|
|
48
|
+
if(mains.length!==1) warnings.push("assets 中 icon/main 应恰好 1 个(缺省会自动补)");
|
|
49
|
+
for(const a of d.assets){
|
|
50
|
+
if(typeof a.type!=="string"||typeof a.uri!=="string"||typeof a.name!=="string"||typeof a.ext!=="string") errors.push("assets[] 元素需含 type/uri/name/ext 均为 string");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if(d.character_book){
|
|
55
|
+
const lb=d.character_book;
|
|
56
|
+
if(!Array.isArray(lb.entries)) errors.push("character_book.entries 必须为数组");
|
|
57
|
+
else {
|
|
58
|
+
for(let i=0;i<lb.entries.length;i++){
|
|
59
|
+
const e=lb.entries[i];
|
|
60
|
+
if(typeof e.content!=="string") errors.push(`entries[${i}].content 必须为 string`);
|
|
61
|
+
if(typeof e.enabled!=="boolean") warnings.push(`entries[${i}].enabled 建议为 boolean`);
|
|
62
|
+
if(typeof e.insertion_order!=="number") warnings.push(`entries[${i}].insertion_order 建议为 number`);
|
|
63
|
+
if(typeof e.use_regex!=="boolean") warnings.push(`entries[${i}].use_regex 建议为 boolean`);
|
|
64
|
+
if(typeof e.content==="string"){
|
|
65
|
+
const heads=e.content.split("\n").filter(l=>l.trim().startsWith("@@"));
|
|
66
|
+
for(const h of heads){
|
|
67
|
+
if(!/^@@[a-z_]+(\s+.+)?$/.test(h.trim())) warnings.push(`entries[${i}] decorator 语法可疑: ${h.trim().slice(0,40)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if(e.use_regex && Array.isArray(e.keys)){
|
|
71
|
+
for(const k of e.keys){
|
|
72
|
+
try{ new RegExp(k); }catch{ warnings.push(`entries[${i}] keys 正则非法: ${k.slice(0,30)}`); }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if(d.creation_date!==undefined && typeof d.creation_date!=="number") warnings.push("creation_date 应为 number(秒)");
|
|
79
|
+
if(d.modification_date!==undefined && typeof d.modification_date!=="number") warnings.push("modification_date 应为 number(秒)");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { valid: errors.length===0, errors, warnings };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nowSec(){ return Math.floor(Date.now()/1000); }
|
|
86
|
+
function makeDefaultDraft(){
|
|
87
|
+
return {
|
|
88
|
+
spec:"chara_card_v3", spec_version:"3.0",
|
|
89
|
+
data:{
|
|
90
|
+
name:"", nickname:"", tags:[], creator:"", character_version:"0.1",
|
|
91
|
+
description:"", personality:"", scenario:"",
|
|
92
|
+
system_prompt:"", post_history_instructions:"",
|
|
93
|
+
first_mes:"", alternate_greetings:[], group_only_greetings:[],
|
|
94
|
+
mes_example:"", creator_notes:"", creator_notes_multilingual:{}, source:[], assets:[{type:"icon", uri:"ccdefault:", name:"main", ext:"png"}],
|
|
95
|
+
creation_date: nowSec(), modification_date: nowSec(),
|
|
96
|
+
character_book:{ name:"", description:"", scan_depth:4, token_budget:1200, recursive_scanning:false, extensions:{}, entries:[] }
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if(!globalThis.__CC_DRAFTS__) globalThis.__CC_DRAFTS__ = new Map();
|
|
101
|
+
// —— 草稿持久化(fix #2):~/.dsh/cc-drafts/<session>.json,变更即落盘,重启后按会话 id 恢复 ——
|
|
102
|
+
if(!globalThis.__CC_DRAFT_META__) globalThis.__CC_DRAFT_META__ = new Map();
|
|
103
|
+
function draftDir(){
|
|
104
|
+
try{ return join(homedir(), ".dsh", "cc-drafts"); }catch{ return join(process.cwd(), ".cc-drafts"); }
|
|
105
|
+
}
|
|
106
|
+
function safeDraftFile(key){
|
|
107
|
+
const raw = String(key || "default");
|
|
108
|
+
let h = 0;
|
|
109
|
+
for(let i=0;i<raw.length;i++){ h = (Math.imul(h, 31) + raw.charCodeAt(i)) | 0; }
|
|
110
|
+
const hex = (h >>> 0).toString(16).padStart(8, "0");
|
|
111
|
+
const safe = (raw.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48)) || "default";
|
|
112
|
+
return safe + "-" + hex + ".json";
|
|
113
|
+
}
|
|
114
|
+
function draftPath(key){ return join(draftDir(), safeDraftFile(key)); }
|
|
115
|
+
function loadPersistedDraftSync(key){
|
|
116
|
+
try{
|
|
117
|
+
const raw = readFileSync(draftPath(key), "utf8");
|
|
118
|
+
const obj = JSON.parse(raw);
|
|
119
|
+
const d = obj && obj.draft ? obj.draft : null;
|
|
120
|
+
if(d && typeof d === "object" && d.spec === "chara_card_v3" && d.data && typeof d.data === "object") return d;
|
|
121
|
+
}catch{}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
function persistDraftSync(key, draft){
|
|
125
|
+
try{
|
|
126
|
+
mkdirSync(draftDir(), { recursive: true });
|
|
127
|
+
writeFileSync(draftPath(key), JSON.stringify({ key: String(key), updatedAt: nowSec(), draft }, null, 2), "utf8");
|
|
128
|
+
}catch(e){ try{ console.error("[dsh-cc-studio] persist draft failed:", (e && e.message) || e); }catch{} }
|
|
129
|
+
}
|
|
130
|
+
function draftMeta(key){
|
|
131
|
+
const k = key || "default";
|
|
132
|
+
if(!globalThis.__CC_DRAFT_META__.has(k)) globalThis.__CC_DRAFT_META__.set(k, { isNew: false, recovered: false });
|
|
133
|
+
return globalThis.__CC_DRAFT_META__.get(k);
|
|
134
|
+
}
|
|
135
|
+
function draftStatusFor(key, draft){
|
|
136
|
+
const m = draftMeta(key);
|
|
137
|
+
return { isNew: !!m.isNew, recovered: !!m.recovered, creation_date: (draft && draft.data) ? draft.data.creation_date : null, key: String(key) };
|
|
138
|
+
}
|
|
139
|
+
function draftKeyFrom(ctx, args){
|
|
140
|
+
if(args && typeof args.sessionId==="string" && args.sessionId) return String(args.sessionId);
|
|
141
|
+
try{
|
|
142
|
+
const agents = ctx.get("agents");
|
|
143
|
+
const ag = agents && agents.currentInitiator && agents.currentInitiator();
|
|
144
|
+
if(ag){
|
|
145
|
+
if(ag.session && ag.session.id) return String(ag.session.id);
|
|
146
|
+
if(ag.id) return String(ag.id);
|
|
147
|
+
if(ag.sessionId) return String(ag.sessionId);
|
|
148
|
+
}
|
|
149
|
+
}catch{}
|
|
150
|
+
return "default";
|
|
151
|
+
}
|
|
152
|
+
function getDraftFor(key){
|
|
153
|
+
const k = key || "default";
|
|
154
|
+
if(!globalThis.__CC_DRAFTS__.has(k)){
|
|
155
|
+
// fix #2:内存 miss 时先尝试从磁盘恢复,而非静默建空
|
|
156
|
+
const restored = loadPersistedDraftSync(k);
|
|
157
|
+
if(restored){
|
|
158
|
+
globalThis.__CC_DRAFTS__.set(k, restored);
|
|
159
|
+
const m = draftMeta(k); m.isNew = false; m.recovered = true;
|
|
160
|
+
} else {
|
|
161
|
+
globalThis.__CC_DRAFTS__.set(k, makeDefaultDraft());
|
|
162
|
+
const m = draftMeta(k); m.isNew = true; m.recovered = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return globalThis.__CC_DRAFTS__.get(k);
|
|
166
|
+
}
|
|
167
|
+
function setDraftFor(key, draft){
|
|
168
|
+
draft.data.modification_date = nowSec();
|
|
169
|
+
const k = key || "default";
|
|
170
|
+
globalThis.__CC_DRAFTS__.set(k, draft);
|
|
171
|
+
const m = draftMeta(k); m.isNew = false; m.recovered = false;
|
|
172
|
+
persistDraftSync(k, draft);
|
|
173
|
+
return draft;
|
|
174
|
+
}
|
|
175
|
+
function isCcPreset(ctx, sessionId){
|
|
176
|
+
if(sessionId){
|
|
177
|
+
try{
|
|
178
|
+
const agents = ctx.get("agents");
|
|
179
|
+
const presets = ctx.get("agentPresets");
|
|
180
|
+
if(agents && presets){
|
|
181
|
+
const ag = agents.get(sessionId);
|
|
182
|
+
if(ag){
|
|
183
|
+
const pid = presets.composedPreset(ag.ctx);
|
|
184
|
+
if(pid) return pid === "cc";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}catch{}
|
|
188
|
+
}
|
|
189
|
+
try{
|
|
190
|
+
const agents2 = ctx.get("agents");
|
|
191
|
+
const presets2 = ctx.get("agentPresets");
|
|
192
|
+
if(agents2 && presets2){
|
|
193
|
+
const ag2 = agents2.currentInitiator && agents2.currentInitiator();
|
|
194
|
+
if(ag2){
|
|
195
|
+
const pid2 = presets2.composedPreset(ag2.ctx);
|
|
196
|
+
if(pid2) return pid2 === "cc";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}catch{}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// —— 已存角色侧边栏:落盘到 ~/.dsh/cc-library/<id>.json ——
|
|
204
|
+
function libDir(){
|
|
205
|
+
try{ return join(homedir(), ".dsh", "cc-library"); }catch{ return join(process.cwd(), ".cc-library"); }
|
|
206
|
+
}
|
|
207
|
+
async function ensureLibDir(){
|
|
208
|
+
const dir = libDir();
|
|
209
|
+
await mkdir(dir, { recursive: true });
|
|
210
|
+
return dir;
|
|
211
|
+
}
|
|
212
|
+
// ========== PNG / CHARX 共享 ==========
|
|
213
|
+
function normalizeDraft(d){
|
|
214
|
+
if(!d || typeof d!=="object") throw new Error("draft 必须是对象");
|
|
215
|
+
if(!d.spec) throw new Error("缺少 spec");
|
|
216
|
+
if(d.spec!=="chara_card_v3") throw new Error("spec 需为 chara_card_v3,当前 "+String(d.spec));
|
|
217
|
+
if(!d.data || typeof d.data!=="object") throw new Error("缺少 data");
|
|
218
|
+
d.spec_version=d.spec_version||"3.0";
|
|
219
|
+
d.data.character_book=d.data.character_book||{name:"",description:"",scan_depth:4,token_budget:1200,recursive_scanning:false,extensions:{},entries:[]};
|
|
220
|
+
if(!Array.isArray(d.data.character_book.entries)) d.data.character_book.entries=[];
|
|
221
|
+
if(!Array.isArray(d.data.group_only_greetings)) d.data.group_only_greetings=[];
|
|
222
|
+
if(!Array.isArray(d.data.alternate_greetings)) d.data.alternate_greetings=[];
|
|
223
|
+
if(!Array.isArray(d.data.tags)) d.data.tags=[];
|
|
224
|
+
if(!Array.isArray(d.data.assets)) d.data.assets=[{type:"icon",uri:"ccdefault:",name:"main",ext:"png"}];
|
|
225
|
+
if(!d.data.creation_date) d.data.creation_date=nowSec();
|
|
226
|
+
d.data.modification_date=nowSec();
|
|
227
|
+
return d;
|
|
228
|
+
}
|
|
229
|
+
let _crcTable=null;
|
|
230
|
+
function crc32(buf){
|
|
231
|
+
if(!_crcTable){
|
|
232
|
+
_crcTable=new Uint32Array(256);
|
|
233
|
+
for(let n=0;n<256;n++){ let c=n; for(let k=0;k<8;k++) c=(c&1)?(0xEDB88320 ^ (c>>>1)):(c>>>1); _crcTable[n]=c>>>0; }
|
|
234
|
+
}
|
|
235
|
+
let c=0xFFFFFFFF;
|
|
236
|
+
for(let i=0;i<buf.length;i++) c=_crcTable[(c ^ buf[i]) & 0xFF] ^ (c>>>8);
|
|
237
|
+
return (c ^ 0xFFFFFFFF)>>>0;
|
|
238
|
+
}
|
|
239
|
+
function u32be(n){ const b=new Uint8Array(4); b[0]=(n>>>24)&0xFF; b[1]=(n>>>16)&0xFF; b[2]=(n>>>8)&0xFF; b[3]=n&0xFF; return b; }
|
|
240
|
+
function concatBytes(arrs){ let len=0; for(const a of arrs) len+=a.length; const o=new Uint8Array(len); let off=0; for(const a of arrs){ o.set(a,off); off+=a.length; } return o; }
|
|
241
|
+
function strToBytes(s){ return new TextEncoder().encode(String(s)); }
|
|
242
|
+
function bytesToStr(b){ return new TextDecoder().decode(b); }
|
|
243
|
+
function b64DecodeBytes(b64){ return new Uint8Array(Buffer.from(String(b64),"base64")); }
|
|
244
|
+
function extractCardFromPngBytes(bytes){
|
|
245
|
+
if(bytes.length<8 || bytes[0]!==0x89 || bytes[1]!==0x50) throw new Error("不是合法 PNG(缺少 PNG 签名)");
|
|
246
|
+
let off=8;
|
|
247
|
+
let found=null;
|
|
248
|
+
let lastError=null;
|
|
249
|
+
while(off+8 <= bytes.length){
|
|
250
|
+
const len=(bytes[off]<<24)|(bytes[off+1]<<16)|(bytes[off+2]<<8)|bytes[off+3];
|
|
251
|
+
if(len<0 || len> 20*1024*1024) break;
|
|
252
|
+
const type=bytesToStr(bytes.slice(off+4,off+8));
|
|
253
|
+
if(off+8+len+4 > bytes.length) break;
|
|
254
|
+
const data=bytes.slice(off+8, off+8+len);
|
|
255
|
+
if(type==="tEXt"){
|
|
256
|
+
try{
|
|
257
|
+
const zero=data.indexOf(0);
|
|
258
|
+
if(zero>=0){
|
|
259
|
+
const keyword=bytesToStr(data.slice(0,zero));
|
|
260
|
+
const text=bytesToStr(data.slice(zero+1));
|
|
261
|
+
const kw=String(keyword).trim().toLowerCase();
|
|
262
|
+
if(kw==="ccv3" || kw==="ccv3_card" || kw==="chara" || kw==="chara_card_v3"){
|
|
263
|
+
let jsonStr=text.trim();
|
|
264
|
+
try{ const decoded=bytesToStr(b64DecodeBytes(jsonStr)); if(decoded.trim().startsWith("{")) jsonStr=decoded; }catch{}
|
|
265
|
+
const obj=JSON.parse(jsonStr);
|
|
266
|
+
if(obj && obj.spec){ found=obj; break; }
|
|
267
|
+
if(obj && obj.data){ found={spec:"chara_card_v3",spec_version:"3.0",data:obj}; break; }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}catch(e){ lastError=e; }
|
|
271
|
+
} else if(type==="zTXt" || type==="iTXt"){
|
|
272
|
+
lastError=new Error("检测到 "+type+" 压缩块,请用 tEXt 格式导出(当前仅支持 tEXt ccv3)");
|
|
273
|
+
}
|
|
274
|
+
if(type==="IEND") break;
|
|
275
|
+
off+=12+len;
|
|
276
|
+
}
|
|
277
|
+
if(!found) throw new Error(lastError? String(lastError.message):"PNG 未找到 ccv3/chara 块(tEXt ccv3),请确认是否为 CCv3 PNG");
|
|
278
|
+
if(found.data) return found;
|
|
279
|
+
throw new Error("PNG 内卡格式错误");
|
|
280
|
+
}
|
|
281
|
+
function buildTEXtChunk(cardJson){
|
|
282
|
+
const jsonStr=JSON.stringify(cardJson);
|
|
283
|
+
const b64=Buffer.from(jsonStr,"utf8").toString("base64");
|
|
284
|
+
const keyword=strToBytes("ccv3");
|
|
285
|
+
const textBytes=strToBytes(b64);
|
|
286
|
+
const chunkData=concatBytes([keyword, new Uint8Array([0]), textBytes]);
|
|
287
|
+
const typeBytes=strToBytes("tEXt");
|
|
288
|
+
const crc=crc32(concatBytes([typeBytes, chunkData]));
|
|
289
|
+
return concatBytes([u32be(chunkData.length), typeBytes, chunkData, u32be(crc)]);
|
|
290
|
+
}
|
|
291
|
+
function buildPngWithCard(cardJson){
|
|
292
|
+
const chunk=buildTEXtChunk(cardJson);
|
|
293
|
+
const baseB64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=";
|
|
294
|
+
const base=new Uint8Array(Buffer.from(baseB64,"base64"));
|
|
295
|
+
const iendOff=base.length-12;
|
|
296
|
+
const head=base.slice(0,iendOff);
|
|
297
|
+
const tail=base.slice(iendOff);
|
|
298
|
+
return concatBytes([head, chunk, tail]);
|
|
299
|
+
}
|
|
300
|
+
function embedCardIntoPngBytes(pngBytes, cardJson){
|
|
301
|
+
if(pngBytes.length<8 || pngBytes[0]!==0x89 || pngBytes[1]!==0x50) throw new Error("不是合法 PNG(缺少 PNG 签名)");
|
|
302
|
+
if(pngBytes.length > 15*1024*1024) throw new Error("PNG 过大(>15MB)");
|
|
303
|
+
const newChunk=buildTEXtChunk(cardJson);
|
|
304
|
+
let off=8;
|
|
305
|
+
const parts=[pngBytes.slice(0,8)];
|
|
306
|
+
let inserted=false;
|
|
307
|
+
while(off+8 <= pngBytes.length){
|
|
308
|
+
const len=(pngBytes[off]<<24)|(pngBytes[off+1]<<16)|(pngBytes[off+2]<<8)|pngBytes[off+3];
|
|
309
|
+
if(len<0 || len> 20*1024*1024) throw new Error("PNG 块长度异常");
|
|
310
|
+
const type=bytesToStr(pngBytes.slice(off+4,off+8));
|
|
311
|
+
if(off+8+len+4 > pngBytes.length) throw new Error("PNG 截断");
|
|
312
|
+
const data=pngBytes.slice(off+8, off+8+len);
|
|
313
|
+
const chunkBytes=pngBytes.slice(off, off+12+len);
|
|
314
|
+
if(type==="IEND"){
|
|
315
|
+
parts.push(newChunk);
|
|
316
|
+
parts.push(chunkBytes);
|
|
317
|
+
inserted=true;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
// 跳过旧的 ccv3/chara 块
|
|
321
|
+
let skip=false;
|
|
322
|
+
if(type==="tEXt"){
|
|
323
|
+
const zero=data.indexOf(0);
|
|
324
|
+
if(zero>=0){
|
|
325
|
+
const kw=String(bytesToStr(data.slice(0,zero))).trim().toLowerCase();
|
|
326
|
+
if(kw==="ccv3" || kw==="ccv3_card" || kw==="chara" || kw==="chara_card_v3") skip=true;
|
|
327
|
+
}
|
|
328
|
+
} else if(type==="zTXt" || type==="iTXt"){
|
|
329
|
+
const zero=data.indexOf(0);
|
|
330
|
+
if(zero>=0){
|
|
331
|
+
const kw=String(bytesToStr(data.slice(0,zero))).trim().toLowerCase();
|
|
332
|
+
if(kw==="ccv3" || kw==="ccv3_card" || kw==="chara" || kw==="chara_card_v3") skip=true;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if(!skip) parts.push(chunkBytes);
|
|
336
|
+
off+=12+len;
|
|
337
|
+
}
|
|
338
|
+
if(!inserted) throw new Error("PNG 缺少 IEND 块");
|
|
339
|
+
return concatBytes(parts);
|
|
340
|
+
}
|
|
341
|
+
function parseZipEntries(bytes){
|
|
342
|
+
const entries=[];
|
|
343
|
+
let off=0;
|
|
344
|
+
while(off+30 <= bytes.length){
|
|
345
|
+
if(bytes[off]!==0x50 || bytes[off+1]!==0x4B || bytes[off+2]!==0x03 || bytes[off+3]!==0x04) break;
|
|
346
|
+
const method=bytes[off+8]|(bytes[off+9]<<8);
|
|
347
|
+
const compSize=(bytes[off+18]|(bytes[off+19]<<8)|(bytes[off+20]<<16)|(bytes[off+21]<<24))>>>0;
|
|
348
|
+
const nameLen=bytes[off+26]|(bytes[off+27]<<8);
|
|
349
|
+
const extraLen=bytes[off+28]|(bytes[off+29]<<8);
|
|
350
|
+
const nameStart=off+30;
|
|
351
|
+
const nameEnd=nameStart+nameLen;
|
|
352
|
+
if(nameEnd+extraLen > bytes.length) break;
|
|
353
|
+
const name=bytesToStr(bytes.slice(nameStart,nameEnd));
|
|
354
|
+
const dataStart=nameEnd+extraLen;
|
|
355
|
+
const dataEnd=dataStart+compSize;
|
|
356
|
+
if(dataEnd > bytes.length) break;
|
|
357
|
+
let data=bytes.slice(dataStart,dataEnd);
|
|
358
|
+
if(method===8){
|
|
359
|
+
try{ data=new Uint8Array(zlibInflateSync(Buffer.from(data))); }catch(e){ throw new Error("ZIP DEFLATE 解压失败: "+String(e.message)); }
|
|
360
|
+
} else if(method!==0){
|
|
361
|
+
throw new Error("不支持的 ZIP 方法 "+method+"(仅支持 STORE/DEFLATE)");
|
|
362
|
+
}
|
|
363
|
+
entries.push({name, data});
|
|
364
|
+
off=dataEnd;
|
|
365
|
+
if(entries.length>64) break;
|
|
366
|
+
}
|
|
367
|
+
return entries;
|
|
368
|
+
}
|
|
369
|
+
function buildZipCharx(cardJson){
|
|
370
|
+
const jsonBytes=strToBytes(JSON.stringify(cardJson,null,2));
|
|
371
|
+
const nameBytes=strToBytes("card.json");
|
|
372
|
+
const crc=crc32(jsonBytes);
|
|
373
|
+
const header=new Uint8Array(30);
|
|
374
|
+
header[0]=0x50;header[1]=0x4B;header[2]=0x03;header[3]=0x04;
|
|
375
|
+
header[4]=0x14;header[5]=0x00;
|
|
376
|
+
header[6]=0x00;header[7]=0x00;
|
|
377
|
+
header[8]=0x00;header[9]=0x00;
|
|
378
|
+
header[14]=crc&0xFF; header[15]=(crc>>>8)&0xFF; header[16]=(crc>>>16)&0xFF; header[17]=(crc>>>24)&0xFF;
|
|
379
|
+
header[18]=jsonBytes.length&0xFF; header[19]=(jsonBytes.length>>>8)&0xFF; header[20]=(jsonBytes.length>>>16)&0xFF; header[21]=(jsonBytes.length>>>24)&0xFF;
|
|
380
|
+
header[22]=jsonBytes.length&0xFF; header[23]=(jsonBytes.length>>>8)&0xFF; header[24]=(jsonBytes.length>>>16)&0xFF; header[25]=(jsonBytes.length>>>24)&0xFF;
|
|
381
|
+
header[26]=nameBytes.length&0xFF; header[27]=(nameBytes.length>>>8)&0xFF;
|
|
382
|
+
header[28]=0;header[29]=0;
|
|
383
|
+
const local=concatBytes([header, nameBytes, jsonBytes]);
|
|
384
|
+
const cdHeader=new Uint8Array(46);
|
|
385
|
+
cdHeader[0]=0x50;cdHeader[1]=0x4B;cdHeader[2]=0x01;cdHeader[3]=0x02;
|
|
386
|
+
cdHeader[4]=0x14;cdHeader[5]=0x00; cdHeader[6]=0x14;cdHeader[7]=0x00;
|
|
387
|
+
cdHeader[8]=0x00;cdHeader[9]=0x00; cdHeader[10]=0x00;cdHeader[11]=0x00;
|
|
388
|
+
cdHeader[14]=crc&0xFF;cdHeader[15]=(crc>>>8)&0xFF;cdHeader[16]=(crc>>>16)&0xFF;cdHeader[17]=(crc>>>24)&0xFF;
|
|
389
|
+
cdHeader[18]=jsonBytes.length&0xFF;cdHeader[19]=(jsonBytes.length>>>8)&0xFF;cdHeader[20]=(jsonBytes.length>>>16)&0xFF;cdHeader[21]=(jsonBytes.length>>>24)&0xFF;
|
|
390
|
+
cdHeader[22]=jsonBytes.length&0xFF;cdHeader[23]=(jsonBytes.length>>>8)&0xFF;cdHeader[24]=(jsonBytes.length>>>16)&0xFF;cdHeader[25]=(jsonBytes.length>>>24)&0xFF;
|
|
391
|
+
cdHeader[26]=nameBytes.length&0xFF;cdHeader[27]=(nameBytes.length>>>8)&0xFF;
|
|
392
|
+
cdHeader[28]=0;cdHeader[29]=0; cdHeader[30]=0;cdHeader[31]=0;
|
|
393
|
+
cdHeader[32]=0;cdHeader[33]=0; cdHeader[34]=0;cdHeader[35]=0;
|
|
394
|
+
cdHeader[36]=0;cdHeader[37]=0;cdHeader[38]=0;cdHeader[39]=0;
|
|
395
|
+
cdHeader[40]=0;cdHeader[41]=0;cdHeader[42]=0;cdHeader[43]=0;
|
|
396
|
+
cdHeader[44]=0;cdHeader[45]=0;
|
|
397
|
+
const central=concatBytes([cdHeader, nameBytes]);
|
|
398
|
+
const eocd=new Uint8Array(22);
|
|
399
|
+
eocd[0]=0x50;eocd[1]=0x4B;eocd[2]=0x05;eocd[3]=0x06;
|
|
400
|
+
eocd[8]=1;eocd[9]=0; eocd[10]=1;eocd[11]=0;
|
|
401
|
+
const centralSize=central.length;
|
|
402
|
+
eocd[12]=centralSize&0xFF;eocd[13]=(centralSize>>>8)&0xFF;eocd[14]=(centralSize>>>16)&0xFF;eocd[15]=(centralSize>>>24)&0xFF;
|
|
403
|
+
const localSize=local.length;
|
|
404
|
+
eocd[16]=localSize&0xFF;eocd[17]=(localSize>>>8)&0xFF;eocd[18]=(localSize>>>16)&0xFF;eocd[19]=(localSize>>>24)&0xFF;
|
|
405
|
+
return concatBytes([local, central, eocd]);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function makeLibraryEntryMeta(draft, existingMeta, id){
|
|
409
|
+
const data = draft && draft.data ? draft.data : {};
|
|
410
|
+
const now = nowSec();
|
|
411
|
+
const loreCount = Array.isArray(data.character_book?.entries) ? data.character_book.entries.length : 0;
|
|
412
|
+
const tags = Array.isArray(data.tags) ? data.tags.slice(0,6).map(String) : [];
|
|
413
|
+
return {
|
|
414
|
+
id: String(id),
|
|
415
|
+
name: (data.name && String(data.name).trim()) || (existingMeta && existingMeta.name) || "未命名角色",
|
|
416
|
+
loreCount,
|
|
417
|
+
tags,
|
|
418
|
+
descriptionSnippet: String(data.description || "").slice(0,80),
|
|
419
|
+
createdAt: existingMeta && existingMeta.createdAt ? existingMeta.createdAt : now,
|
|
420
|
+
updatedAt: now,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
async function listLibraryEntries(){
|
|
424
|
+
const dir = await ensureLibDir();
|
|
425
|
+
let files=[];
|
|
426
|
+
try{ files = await readdir(dir); }catch{ return []; }
|
|
427
|
+
const entries=[];
|
|
428
|
+
for(const f of files){
|
|
429
|
+
if(!f.endsWith(".json")) continue;
|
|
430
|
+
const p = join(dir, f);
|
|
431
|
+
try{
|
|
432
|
+
const raw = await readFile(p, "utf8");
|
|
433
|
+
const obj = JSON.parse(raw);
|
|
434
|
+
if(obj && obj.meta && obj.id){
|
|
435
|
+
// 兼容:meta.id 与文件名 id 一致
|
|
436
|
+
entries.push(obj.meta);
|
|
437
|
+
} else if(obj && obj.draft){
|
|
438
|
+
// 旧格式兜底:从 draft 重建 meta
|
|
439
|
+
const id = f.replace(/\.json$/,"");
|
|
440
|
+
entries.push(makeLibraryEntryMeta(obj.draft, null, id));
|
|
441
|
+
}
|
|
442
|
+
}catch{}
|
|
443
|
+
}
|
|
444
|
+
entries.sort((a,b)=> (b.updatedAt||0)-(a.updatedAt||0));
|
|
445
|
+
return entries;
|
|
446
|
+
}
|
|
447
|
+
async function saveLibraryEntry(draft, opts){
|
|
448
|
+
const dir = await ensureLibDir();
|
|
449
|
+
let id = opts && opts.id ? String(opts.id) : "";
|
|
450
|
+
let existing = null;
|
|
451
|
+
if(id){
|
|
452
|
+
try{
|
|
453
|
+
const raw = await readFile(join(dir, id+".json"), "utf8");
|
|
454
|
+
existing = JSON.parse(raw);
|
|
455
|
+
}catch{}
|
|
456
|
+
}
|
|
457
|
+
if(!id){
|
|
458
|
+
id = String(Date.now())+"-"+Math.random().toString(36).slice(2,6);
|
|
459
|
+
}
|
|
460
|
+
const meta = makeLibraryEntryMeta(draft, existing ? existing.meta : null, id);
|
|
461
|
+
// 若调用方显式传 name,覆盖 meta.name(不改 draft.data.name,保持原样)
|
|
462
|
+
if(opts && typeof opts.name==="string" && opts.name.trim()){
|
|
463
|
+
meta.name = String(opts.name).trim().slice(0,60);
|
|
464
|
+
}
|
|
465
|
+
const payload = { id, meta, draft };
|
|
466
|
+
await writeFile(join(dir, id+".json"), JSON.stringify(payload, null, 2), "utf8");
|
|
467
|
+
return { id, meta, draft };
|
|
468
|
+
}
|
|
469
|
+
async function loadLibraryEntry(id){
|
|
470
|
+
const dir = await ensureLibDir();
|
|
471
|
+
const p = join(dir, String(id)+".json");
|
|
472
|
+
const raw = await readFile(p, "utf8");
|
|
473
|
+
const obj = JSON.parse(raw);
|
|
474
|
+
if(!obj || !obj.draft) throw new Error("文件格式错误");
|
|
475
|
+
return obj;
|
|
476
|
+
}
|
|
477
|
+
async function deleteLibraryEntry(id){
|
|
478
|
+
const dir = await ensureLibDir();
|
|
479
|
+
const p = join(dir, String(id)+".json");
|
|
480
|
+
await unlink(p);
|
|
481
|
+
}
|
|
482
|
+
async function renameLibraryEntry(id, newName){
|
|
483
|
+
const dir = await ensureLibDir();
|
|
484
|
+
const p = join(dir, String(id)+".json");
|
|
485
|
+
const raw = await readFile(p, "utf8");
|
|
486
|
+
const obj = JSON.parse(raw);
|
|
487
|
+
if(!obj || !obj.meta) throw new Error("文件格式错误");
|
|
488
|
+
const name = String(newName||"").trim().slice(0,60);
|
|
489
|
+
if(!name) throw new Error("name 不能为空");
|
|
490
|
+
obj.meta.name = name;
|
|
491
|
+
obj.meta.updatedAt = nowSec();
|
|
492
|
+
await writeFile(p, JSON.stringify(obj, null, 2), "utf8");
|
|
493
|
+
return obj.meta;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// —— Connection RPC 通道(自持路由,见文件头 inject 注释)——
|
|
497
|
+
const RPC_CHANNEL = "/dsh-cc-studio-rpc";
|
|
498
|
+
// 与 client-connection ENDPOINT_SEGMENT_PATTERN 一致:单段不含 / . ..
|
|
499
|
+
const RPC_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
|
|
500
|
+
// 导入/导出端点承载 base64 卡与底图(PNG ≤20MB、CHARX ≤30MB base64),留出包裹余量
|
|
501
|
+
const RPC_MAX_BODY_BYTES = 48 * 1024 * 1024;
|
|
502
|
+
|
|
503
|
+
function endpointFromPath(pathname){
|
|
504
|
+
if(typeof pathname!=="string" || !pathname.startsWith(RPC_CHANNEL+"/")) return undefined;
|
|
505
|
+
const q = pathname.indexOf("?");
|
|
506
|
+
const rest = pathname.slice(RPC_CHANNEL.length+1, q===-1 ? undefined : q);
|
|
507
|
+
if(!rest) return undefined;
|
|
508
|
+
for(const seg of rest.split("/")){
|
|
509
|
+
if(seg==="" || seg==="." || seg===".." || !RPC_SEGMENT.test(seg)) return undefined;
|
|
510
|
+
}
|
|
511
|
+
return rest;
|
|
512
|
+
}
|
|
513
|
+
// 用经典 data/end/error 事件读取请求体:IncomingMessage 的异步迭代在本运行时
|
|
514
|
+
// 会在读取时抛错(连 client-connection 自带的 /api 帧也因此拿不到 body),
|
|
515
|
+
// 事件形式则是各版本都稳定的路径。
|
|
516
|
+
function readBoundedBody(req, maxBytes){
|
|
517
|
+
return new Promise((resolve)=>{
|
|
518
|
+
const chunks=[]; let size=0; let settled=false;
|
|
519
|
+
const finish=(value)=>{ if(settled) return; settled=true; cleanup(); resolve(value); };
|
|
520
|
+
function onData(chunk){
|
|
521
|
+
size += chunk.length;
|
|
522
|
+
if(size > maxBytes){ try{ req.resume(); }catch{} finish(undefined); return; }
|
|
523
|
+
chunks.push(chunk);
|
|
524
|
+
}
|
|
525
|
+
function onEnd(){ finish(Buffer.concat(chunks, size).toString("utf8")); }
|
|
526
|
+
function onError(){ finish(undefined); }
|
|
527
|
+
function onAborted(){ finish(undefined); }
|
|
528
|
+
function cleanup(){
|
|
529
|
+
try{
|
|
530
|
+
req.removeListener("data", onData);
|
|
531
|
+
req.removeListener("end", onEnd);
|
|
532
|
+
req.removeListener("error", onError);
|
|
533
|
+
req.removeListener("aborted", onAborted);
|
|
534
|
+
}catch{}
|
|
535
|
+
}
|
|
536
|
+
req.on("data", onData);
|
|
537
|
+
req.on("end", onEnd);
|
|
538
|
+
req.on("error", onError);
|
|
539
|
+
req.on("aborted", onAborted);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
function sendEnvelope(res, status, payload){
|
|
543
|
+
try{
|
|
544
|
+
if(res.writableEnded) return;
|
|
545
|
+
res.statusCode = status;
|
|
546
|
+
res.setHeader("content-type","application/json; charset=utf-8");
|
|
547
|
+
res.setHeader("cache-control","no-store");
|
|
548
|
+
res.end(JSON.stringify(payload));
|
|
549
|
+
}catch(e){ try{ res.end(); }catch{} }
|
|
550
|
+
}
|
|
551
|
+
// 与 client-connection fullResponse / errorResponse 同一 envelope
|
|
552
|
+
function rpcFull(rpcId, result){ return { type:"server-response", rpcId, result }; }
|
|
553
|
+
function rpcError(rpcId, code, message, details){ return rpcFull(rpcId, { ok:false, error:{ code, message, details } }); }
|
|
554
|
+
|
|
555
|
+
// ========== CC 预设自动安装 ==========
|
|
556
|
+
// 背景:预设此前必须手工拷到 <DSH_HOME>/.agent-presets/cc。但 dsh 的预设发现只扫三个根
|
|
557
|
+
// ——shipped 根(dsh-agent-presets 包内)+ 部署 config.roots + 用户根 <DSH_HOME>/.agent-presets
|
|
558
|
+
// (见 dsh-agent-presets lib/index.js:1300-1310);插件包内的 presets/ 不在其中,
|
|
559
|
+
// 「随包分发」≠「已注册」。所以这里在插件挂载时把模板装进用户根,让「装完插件即有 CC 模式」成立。
|
|
560
|
+
//
|
|
561
|
+
// 安全策略(绝不静默覆盖用户改动):
|
|
562
|
+
// · 目标文件不存在 → 写入模板
|
|
563
|
+
// · 目标与模板一致 → 不动(只补记安装记录)
|
|
564
|
+
// · 目标与我们上次写入的记录一致 → 是我们装的且用户没改 → 安全更新
|
|
565
|
+
// · 其余(用户改过 / 无记录的历史手工拷贝)→ 跳过并告警,交由用户决定
|
|
566
|
+
// 安装记录是目标目录里的 JSON 侧车文件,含每个受管文件写入时的 sha256,
|
|
567
|
+
// 因此不依赖版本号比较也能正确判定「这是不是我们写的、用户有没有动过」。
|
|
568
|
+
// config.presetInstall: "auto"(默认)| "off" | "force"(连用户改动一起覆盖)
|
|
569
|
+
const PRESET_ID = "cc";
|
|
570
|
+
const PRESET_MANAGED_FILES = ["preset.yml", "agent.cordis.yml"];
|
|
571
|
+
const PRESET_MARKER_FILE = ".dsh-cc-studio-preset.json";
|
|
572
|
+
function dshHomeDir(){
|
|
573
|
+
const env = process.env.DSH_HOME;
|
|
574
|
+
if(typeof env === "string" && env.trim().length > 0) return env.trim();
|
|
575
|
+
try{ return join(homedir(), ".dsh"); }catch{ return join(process.cwd(), ".dsh"); }
|
|
576
|
+
}
|
|
577
|
+
function presetSourceRoot(){
|
|
578
|
+
try{ return fileURLToPath(new URL("../presets/"+PRESET_ID+"/", import.meta.url)); }catch{ return null; }
|
|
579
|
+
}
|
|
580
|
+
function presetTargetRoot(){ return join(dshHomeDir(), ".agent-presets", PRESET_ID); }
|
|
581
|
+
function sha256(text){ return createHash("sha256").update(String(text), "utf8").digest("hex"); }
|
|
582
|
+
function pluginVersion(){
|
|
583
|
+
try{ return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version || "unknown"; }
|
|
584
|
+
catch{ return "unknown"; }
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* 纯决策函数(便于单测):给定目标现状、模板、上次安装记录,决定每个受管文件该写还是该跳过。
|
|
588
|
+
* @param targets 目标文件内容表(缺失的文件用 null)
|
|
589
|
+
* @param templates 模板内容表
|
|
590
|
+
* @param marker 上次安装记录(可缺省)
|
|
591
|
+
* @param force 为 true 时连用户改动一起覆盖
|
|
592
|
+
*/
|
|
593
|
+
function planPresetInstall(targets, templates, marker, force){
|
|
594
|
+
const recorded = (marker && marker.files && typeof marker.files === "object") ? marker.files : {};
|
|
595
|
+
const write = [], skip = [], ok = [];
|
|
596
|
+
for(const name of Object.keys(templates)){
|
|
597
|
+
const tpl = templates[name];
|
|
598
|
+
const cur = Object.prototype.hasOwnProperty.call(targets, name) ? targets[name] : null;
|
|
599
|
+
if(cur === null || cur === undefined){ write.push(name); continue; } // 缺文件 → 补齐
|
|
600
|
+
if(cur === tpl){ ok.push(name); continue; } // 已是当前模板
|
|
601
|
+
if(force){ write.push(name); continue; }
|
|
602
|
+
if(recorded[name] && recorded[name] === sha256(cur)){ write.push(name); continue; } // 我们写的、用户没改
|
|
603
|
+
skip.push(name); // 用户改动 / 历史手工拷贝
|
|
604
|
+
}
|
|
605
|
+
return { write, skip, ok };
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* 把 presets/cc 安装到 <DSH_HOME>/.agent-presets/cc。同步执行(只有两个小文件),幂等。
|
|
609
|
+
* 注意:卸载插件**不会**删除已安装的预设(与 README 的既有说明一致),因此这里不注册删除型 disposer。
|
|
610
|
+
*/
|
|
611
|
+
function installCcPreset(ctx, config){
|
|
612
|
+
// 开关优先级:插件行 config.presetInstall > 环境变量 DSH_CC_STUDIO_PRESET_INSTALL > auto。
|
|
613
|
+
// 环境变量是给用户的轻量逃生口(不必写 overlay patch)。
|
|
614
|
+
const fromCfg = (config && typeof config.presetInstall === "string") ? config.presetInstall.trim() : "";
|
|
615
|
+
const fromEnv = (typeof process.env.DSH_CC_STUDIO_PRESET_INSTALL === "string") ? process.env.DSH_CC_STUDIO_PRESET_INSTALL.trim() : "";
|
|
616
|
+
const raw = fromCfg || fromEnv || "auto";
|
|
617
|
+
const mode = (raw === "off" || raw === "force") ? raw : "auto";
|
|
618
|
+
const out = { mode, status: "unknown", installed: [], updated: [], skipped: [], reason: null };
|
|
619
|
+
if(mode === "off"){ out.status = "off"; return out; }
|
|
620
|
+
const log = (level, msg)=>{ try{
|
|
621
|
+
const l = ctx && ctx.logger;
|
|
622
|
+
if(l && typeof l[level] === "function") l[level]("[dsh-cc-studio] " + msg);
|
|
623
|
+
else if(level === "warn") console.warn("[dsh-cc-studio] " + msg);
|
|
624
|
+
}catch{} };
|
|
625
|
+
try{
|
|
626
|
+
const src = presetSourceRoot();
|
|
627
|
+
const templates = {};
|
|
628
|
+
for(const name of PRESET_MANAGED_FILES){
|
|
629
|
+
const p = src ? join(src, name) : null;
|
|
630
|
+
if(p && existsSync(p)) templates[name] = readFileSync(p, "utf8");
|
|
631
|
+
}
|
|
632
|
+
if(Object.keys(templates).length === 0){
|
|
633
|
+
out.status = "no-template"; out.reason = "插件包内找不到 presets/" + PRESET_ID + "(打包时漏了 presets?)";
|
|
634
|
+
log("warn", out.reason); return out;
|
|
635
|
+
}
|
|
636
|
+
const dst = presetTargetRoot();
|
|
637
|
+
const targets = {};
|
|
638
|
+
for(const name of Object.keys(templates)){
|
|
639
|
+
const p = join(dst, name);
|
|
640
|
+
targets[name] = existsSync(p) ? readFileSync(p, "utf8") : null;
|
|
641
|
+
}
|
|
642
|
+
const markerPath = join(dst, PRESET_MARKER_FILE);
|
|
643
|
+
let marker = null;
|
|
644
|
+
if(existsSync(markerPath)){
|
|
645
|
+
try{ marker = JSON.parse(readFileSync(markerPath, "utf8")); }catch{ marker = null; }
|
|
646
|
+
}
|
|
647
|
+
const plan = planPresetInstall(targets, templates, marker, mode === "force");
|
|
648
|
+
const isFresh = !existsSync(dst) || Object.keys(targets).every((k) => targets[k] === null);
|
|
649
|
+
if(plan.write.length > 0){
|
|
650
|
+
mkdirSync(dst, { recursive: true });
|
|
651
|
+
for(const name of plan.write){
|
|
652
|
+
writeFileSync(join(dst, name), templates[name], "utf8");
|
|
653
|
+
(isFresh ? out.installed : out.updated).push(name);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// 刷新记录的前提是「我们至少拥有一个受管文件」(写入的或本已一致的)。
|
|
657
|
+
// 若全部文件都属于用户(例如纯手工拷贝),则不写任何记录——不在用户自己管理的目录里留痕迹。
|
|
658
|
+
const files = {};
|
|
659
|
+
for(const name of Object.keys(templates)){
|
|
660
|
+
if(plan.write.indexOf(name) >= 0 || plan.ok.indexOf(name) >= 0) files[name] = sha256(templates[name]);
|
|
661
|
+
}
|
|
662
|
+
const owned = Object.keys(files).length > 0;
|
|
663
|
+
const nextMarker = { version: pluginVersion(), installedAt: nowSec(), files };
|
|
664
|
+
const markerStale = !marker || marker.version !== nextMarker.version || JSON.stringify(marker.files||{}) !== JSON.stringify(files);
|
|
665
|
+
if(owned && (plan.write.length > 0 || markerStale)){
|
|
666
|
+
mkdirSync(dst, { recursive: true });
|
|
667
|
+
writeFileSync(markerPath, JSON.stringify(nextMarker, null, 2), "utf8");
|
|
668
|
+
}
|
|
669
|
+
out.skipped = plan.skip;
|
|
670
|
+
if(out.installed.length) { out.status = "installed"; log("info", "已自动安装 CC 预设到 " + dst + "(" + out.installed.join(", ") + "):重启后可在会话模式里选择「CC 模式」"); }
|
|
671
|
+
else if(out.updated.length) { out.status = "updated"; log("info", "已自动更新 CC 预设 " + out.updated.join(", ") + " → " + nextMarker.version); }
|
|
672
|
+
else { out.status = "current"; }
|
|
673
|
+
if(plan.skip.length){
|
|
674
|
+
out.status = out.status === "current" ? "skipped-user-edited" : out.status;
|
|
675
|
+
log("warn", "CC 预设的 " + plan.skip.join(", ") + " 与插件模板不一致,且不是本插件写入的(可能是你手工改过或旧版手工拷贝),已保留未覆盖。" +
|
|
676
|
+
"如需用插件模板刷新,请删除 " + dst + " 后重启,或在插件行配置 presetInstall: force。");
|
|
677
|
+
}
|
|
678
|
+
return out;
|
|
679
|
+
}catch(e){
|
|
680
|
+
out.status = "error"; out.reason = String(e && e.message ? e.message : e);
|
|
681
|
+
log("warn", "自动安装 CC 预设失败(不影响其它功能):" + out.reason);
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function apply(ctx, config){
|
|
687
|
+
globalThis.__CC_CTX__ = ctx;
|
|
688
|
+
// 装完插件即有 CC 模式:把模板预设写进用户预设根(幂等、不覆盖用户改动)
|
|
689
|
+
try{ globalThis.__CC_PRESET_INSTALL__ = installCcPreset(ctx, config); }catch{}
|
|
690
|
+
const dispatch = async (endpoint, payload, signal)=>{
|
|
691
|
+
const args= payload && typeof payload==="object" && payload.args && typeof payload.args==="object" ? payload.args : {};
|
|
692
|
+
try{
|
|
693
|
+
switch(endpoint){
|
|
694
|
+
case "validate":{
|
|
695
|
+
const card=args.card || getDraftFor(draftKeyFrom(ctx, args));
|
|
696
|
+
const res=validateCard(card);
|
|
697
|
+
return ok(res);
|
|
698
|
+
}
|
|
699
|
+
case "ping":{
|
|
700
|
+
return ok({ ok:true, time: Date.now() });
|
|
701
|
+
}
|
|
702
|
+
case "cc_isCcMode":{
|
|
703
|
+
return ok({ isCc: isCcPreset(ctx, args.sessionId) });
|
|
704
|
+
}
|
|
705
|
+
case "cc_getDraft":{
|
|
706
|
+
const k = draftKeyFrom(ctx, args);
|
|
707
|
+
const d = getDraftFor(k);
|
|
708
|
+
return ok({ draft: d, key: k, isCc: isCcPreset(ctx, args.sessionId), draftStatus: draftStatusFor(k, d) });
|
|
709
|
+
}
|
|
710
|
+
case "cc_setDraft":{
|
|
711
|
+
const k = draftKeyFrom(ctx, args);
|
|
712
|
+
const draft = args.draft;
|
|
713
|
+
if(!draft || typeof draft!=="object") return fail("invalid-draft","draft 必须为对象");
|
|
714
|
+
const v = validateCard(draft);
|
|
715
|
+
if(!v.valid) return fail("invalid-card","校验未通过: "+v.errors.join("; "), {errors:v.errors});
|
|
716
|
+
setDraftFor(k, draft);
|
|
717
|
+
return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
|
|
718
|
+
}
|
|
719
|
+
case "cc_patchDraft":{
|
|
720
|
+
const k = draftKeyFrom(ctx, args);
|
|
721
|
+
const patch = args.patch && typeof args.patch==="object" ? args.patch : {};
|
|
722
|
+
const cur = JSON.parse(JSON.stringify(getDraftFor(k)));
|
|
723
|
+
if(patch.name!==undefined) cur.data.name = String(patch.name).slice(0,120);
|
|
724
|
+
if(patch.nickname!==undefined) cur.data.nickname = String(patch.nickname).slice(0,120);
|
|
725
|
+
if(patch.description!==undefined) cur.data.description = String(patch.description).slice(0,4000);
|
|
726
|
+
if(patch.personality!==undefined) cur.data.personality = String(patch.personality).slice(0,4000);
|
|
727
|
+
if(patch.scenario!==undefined) cur.data.scenario = String(patch.scenario).slice(0,4000);
|
|
728
|
+
if(patch.system_prompt!==undefined) cur.data.system_prompt = String(patch.system_prompt).slice(0,2000);
|
|
729
|
+
if(patch.post_history_instructions!==undefined) cur.data.post_history_instructions = String(patch.post_history_instructions).slice(0,2000);
|
|
730
|
+
if(patch.first_mes!==undefined) cur.data.first_mes = String(patch.first_mes).slice(0,4000);
|
|
731
|
+
if(Array.isArray(patch.alternate_greetings)) cur.data.alternate_greetings = patch.alternate_greetings.map(String).slice(0,10);
|
|
732
|
+
if(Array.isArray(patch.group_only_greetings)) cur.data.group_only_greetings = patch.group_only_greetings.map(String).slice(0,10);
|
|
733
|
+
if(patch.mes_example!==undefined) cur.data.mes_example = String(patch.mes_example).slice(0,4000);
|
|
734
|
+
if(Array.isArray(patch.tags)) cur.data.tags = patch.tags.map(String).slice(0,12);
|
|
735
|
+
if(patch.creator_notes!==undefined) cur.data.creator_notes = String(patch.creator_notes).slice(0,2000);
|
|
736
|
+
if(patch.world && typeof patch.world==="object"){
|
|
737
|
+
cur.data.extensions = cur.data.extensions || {};
|
|
738
|
+
cur.data.extensions.cc_world = Object.assign({}, cur.data.extensions.cc_world||{}, patch.world);
|
|
739
|
+
}
|
|
740
|
+
if(patch.lorebookEntry && typeof patch.lorebookEntry==="object"){
|
|
741
|
+
const e = patch.lorebookEntry;
|
|
742
|
+
const entry = {
|
|
743
|
+
keys: Array.isArray(e.keys)? e.keys.map(String): [],
|
|
744
|
+
content: String(e.content||"").slice(0,4000),
|
|
745
|
+
enabled: e.enabled!==undefined? !!e.enabled : true,
|
|
746
|
+
insertion_order: cur.data.character_book.entries.length,
|
|
747
|
+
case_sensitive: !!e.case_sensitive,
|
|
748
|
+
use_regex: !!e.use_regex,
|
|
749
|
+
constant: !!e.constant,
|
|
750
|
+
name: String(e.name||"未命名").slice(0,60),
|
|
751
|
+
priority: typeof e.priority==="number"? e.priority : 5,
|
|
752
|
+
id: Date.now()+Math.floor(Math.random()*1000),
|
|
753
|
+
};
|
|
754
|
+
cur.data.character_book.entries.push(entry);
|
|
755
|
+
}
|
|
756
|
+
if(patch.lorebookEntries && Array.isArray(patch.lorebookEntries)){
|
|
757
|
+
patch.lorebookEntries.slice(0,20).forEach(function(e){
|
|
758
|
+
const entry = {
|
|
759
|
+
keys: Array.isArray(e.keys)? e.keys.map(String): [],
|
|
760
|
+
content: String(e.content||"").slice(0,4000),
|
|
761
|
+
enabled: e.enabled!==undefined? !!e.enabled : true,
|
|
762
|
+
insertion_order: cur.data.character_book.entries.length,
|
|
763
|
+
case_sensitive: !!e.case_sensitive,
|
|
764
|
+
use_regex: !!e.use_regex,
|
|
765
|
+
constant: !!e.constant,
|
|
766
|
+
name: String(e.name||"未命名").slice(0,60),
|
|
767
|
+
priority: typeof e.priority==="number"? e.priority : 5,
|
|
768
|
+
id: Date.now()+Math.floor(Math.random()*10000),
|
|
769
|
+
};
|
|
770
|
+
cur.data.character_book.entries.push(entry);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
setDraftFor(k, cur);
|
|
774
|
+
return ok({ draft: cur, key:k, draftStatus: draftStatusFor(k, cur) });
|
|
775
|
+
}
|
|
776
|
+
case "cc_validateDraft":{
|
|
777
|
+
const k = draftKeyFrom(ctx, args);
|
|
778
|
+
const d = getDraftFor(k);
|
|
779
|
+
const res = validateCard(d);
|
|
780
|
+
res.draftStatus = draftStatusFor(k, d);
|
|
781
|
+
return ok(res);
|
|
782
|
+
}
|
|
783
|
+
// —— 已存角色库 ——
|
|
784
|
+
case "cc_listLibrary":{
|
|
785
|
+
const entries = await listLibraryEntries();
|
|
786
|
+
return ok({ entries });
|
|
787
|
+
}
|
|
788
|
+
case "cc_saveToLibrary":{
|
|
789
|
+
// args: {draft?, id?, name?, sessionId?} draft 缺省则取当前会话草稿
|
|
790
|
+
let draft = args.draft;
|
|
791
|
+
if(!draft || typeof draft!=="object"){
|
|
792
|
+
const k = draftKeyFrom(ctx, args);
|
|
793
|
+
draft = JSON.parse(JSON.stringify(getDraftFor(k)));
|
|
794
|
+
}
|
|
795
|
+
// 允许保存未完全校验的草稿,但至少需要 name?不强制,meta 会兜底“未命名角色”
|
|
796
|
+
const res = await saveLibraryEntry(draft, { id: args.id, name: args.name });
|
|
797
|
+
return ok(res);
|
|
798
|
+
}
|
|
799
|
+
case "cc_loadFromLibrary":{
|
|
800
|
+
const id = args.id;
|
|
801
|
+
if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
|
|
802
|
+
const obj = await loadLibraryEntry(id);
|
|
803
|
+
// 载入到当前会话草稿
|
|
804
|
+
const k = draftKeyFrom(ctx, args);
|
|
805
|
+
setDraftFor(k, JSON.parse(JSON.stringify(obj.draft)));
|
|
806
|
+
return ok({ draft: obj.draft, meta: obj.meta, key: k, draftStatus: draftStatusFor(k, obj.draft) });
|
|
807
|
+
}
|
|
808
|
+
case "cc_deleteFromLibrary":{
|
|
809
|
+
const id = args.id;
|
|
810
|
+
if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
|
|
811
|
+
await deleteLibraryEntry(id);
|
|
812
|
+
return ok({ ok:true, id });
|
|
813
|
+
}
|
|
814
|
+
case "cc_renameInLibrary":{
|
|
815
|
+
const id = args.id;
|
|
816
|
+
const newName = args.name;
|
|
817
|
+
if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
|
|
818
|
+
if(typeof newName!=="string" || !newName.trim()) return fail("invalid-name","name 必填");
|
|
819
|
+
const meta = await renameLibraryEntry(id, newName);
|
|
820
|
+
return ok({ meta });
|
|
821
|
+
}
|
|
822
|
+
case "cc_getLibraryEntry":{
|
|
823
|
+
const id = args.id;
|
|
824
|
+
if(typeof id!=="string" || !id) return fail("invalid-id","id 必填");
|
|
825
|
+
const obj = await loadLibraryEntry(id);
|
|
826
|
+
return ok(obj);
|
|
827
|
+
}
|
|
828
|
+
case "cc_importFromPng":{
|
|
829
|
+
const b64 = args.b64;
|
|
830
|
+
if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
|
|
831
|
+
if(b64.length > 20*1024*1024) return fail("too-large","PNG 过大(>20MB base64)");
|
|
832
|
+
let draft;
|
|
833
|
+
try{
|
|
834
|
+
const bytes = b64DecodeBytes(b64);
|
|
835
|
+
if(bytes.length > 15*1024*1024) return fail("too-large","PNG 过大(>15MB)");
|
|
836
|
+
draft = extractCardFromPngBytes(bytes);
|
|
837
|
+
draft = normalizeDraft(draft);
|
|
838
|
+
}catch(e){ return fail("parse-failed", String(e.message)); }
|
|
839
|
+
const k = draftKeyFrom(ctx, args);
|
|
840
|
+
setDraftFor(k, draft);
|
|
841
|
+
return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
|
|
842
|
+
}
|
|
843
|
+
case "cc_exportPng":{
|
|
844
|
+
const k = draftKeyFrom(ctx, args);
|
|
845
|
+
const draft = JSON.parse(JSON.stringify(getDraftFor(k)));
|
|
846
|
+
draft.data.modification_date = nowSec();
|
|
847
|
+
try{
|
|
848
|
+
let pngBytes;
|
|
849
|
+
if(args.imageB64 && typeof args.imageB64==="string" && args.imageB64.length>0){
|
|
850
|
+
if(args.imageB64.length > 20*1024*1024) return fail("too-large","底图 PNG 过大(>20MB base64)");
|
|
851
|
+
const baseBytes = b64DecodeBytes(args.imageB64);
|
|
852
|
+
pngBytes = embedCardIntoPngBytes(baseBytes, draft);
|
|
853
|
+
} else {
|
|
854
|
+
pngBytes = buildPngWithCard(draft);
|
|
855
|
+
}
|
|
856
|
+
return ok({ b64: Buffer.from(pngBytes).toString("base64"), filename: (draft.data.name||"card")+".png", draft });
|
|
857
|
+
}catch(e){ return fail("build-failed", String(e.message)); }
|
|
858
|
+
}
|
|
859
|
+
case "cc_importFromCharx":{
|
|
860
|
+
const b64 = args.b64;
|
|
861
|
+
if(typeof b64!=="string" || !b64) return fail("invalid-b64","b64 必填");
|
|
862
|
+
if(b64.length > 30*1024*1024) return fail("too-large","CHARX 过大(>30MB base64)");
|
|
863
|
+
let draft;
|
|
864
|
+
try{
|
|
865
|
+
const bytes = b64DecodeBytes(b64);
|
|
866
|
+
if(bytes.length > 22*1024*1024) return fail("too-large","CHARX 过大(>22MB)");
|
|
867
|
+
const entries = parseZipEntries(bytes);
|
|
868
|
+
let cardEntry = entries.find(e=> e.name==="card.json" || e.name.endsWith("/card.json"));
|
|
869
|
+
if(!cardEntry) cardEntry = entries.find(e=> e.name.toLowerCase().endsWith(".json"));
|
|
870
|
+
if(!cardEntry) return fail("parse-failed","CHARX 未找到 card.json");
|
|
871
|
+
const jsonStr = bytesToStr(cardEntry.data);
|
|
872
|
+
const obj = JSON.parse(jsonStr);
|
|
873
|
+
draft = obj && obj.spec ? obj : (obj && obj.data ? obj : null);
|
|
874
|
+
if(!draft) throw new Error("card.json 格式错误");
|
|
875
|
+
draft = normalizeDraft(draft);
|
|
876
|
+
}catch(e){ return fail("parse-failed", String(e.message)); }
|
|
877
|
+
const k = draftKeyFrom(ctx, args);
|
|
878
|
+
setDraftFor(k, draft);
|
|
879
|
+
return ok({ draft, key:k, draftStatus: draftStatusFor(k, draft) });
|
|
880
|
+
}
|
|
881
|
+
case "cc_exportCharx":{
|
|
882
|
+
const k = draftKeyFrom(ctx, args);
|
|
883
|
+
const draft = JSON.parse(JSON.stringify(getDraftFor(k)));
|
|
884
|
+
draft.data.modification_date = nowSec();
|
|
885
|
+
try{
|
|
886
|
+
const zipBytes = buildZipCharx(draft);
|
|
887
|
+
return ok({ b64: Buffer.from(zipBytes).toString("base64"), filename: (draft.data.name||"card")+".charx", draft });
|
|
888
|
+
}catch(e){ return fail("build-failed", String(e.message)); }
|
|
889
|
+
}
|
|
890
|
+
default:
|
|
891
|
+
return fail("unknown-endpoint", `unknown endpoint ${JSON.stringify(endpoint)}`);
|
|
892
|
+
}
|
|
893
|
+
}catch(e){
|
|
894
|
+
return fail("internal-error", e && e.message ? String(e.message) : String(e));
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
ctx.effect(() => ctx.webServer.register({
|
|
899
|
+
kind: "prefix",
|
|
900
|
+
path: RPC_CHANNEL,
|
|
901
|
+
handler: async (req, res) => {
|
|
902
|
+
const url = req.url || "/";
|
|
903
|
+
const q = url.indexOf("?");
|
|
904
|
+
const pathname = q===-1 ? url : url.slice(0, q);
|
|
905
|
+
const endpoint = endpointFromPath(pathname);
|
|
906
|
+
if(!endpoint){ sendEnvelope(res, 404, rpcError("not-found", "not-found", "not found", {})); return; }
|
|
907
|
+
// connection 服务的 host/origin 围栏 + 浏览器会话鉴权:插件不能绕过它
|
|
908
|
+
const connection = ctx.get("connection");
|
|
909
|
+
if(connection && typeof connection.requestRejection === "function"){
|
|
910
|
+
const rejection = connection.requestRejection(req);
|
|
911
|
+
if(rejection!==undefined){ res.statusCode = rejection; res.end(rejection===401 ? "unauthorized" : "forbidden"); return; }
|
|
912
|
+
}
|
|
913
|
+
if(req.method!=="POST"){ res.statusCode = 405; res.setHeader("allow","POST"); res.end(); return; }
|
|
914
|
+
const ctype = String(req.headers["content-type"]||"").split(";",1)[0].trim().toLowerCase();
|
|
915
|
+
if(ctype!=="application/json"){ sendEnvelope(res, 415, rpcError("invalid-request","gateway/bad-request","content type must be application/json",{})); return; }
|
|
916
|
+
let text;
|
|
917
|
+
try{ text = await readBoundedBody(req, RPC_MAX_BODY_BYTES); }
|
|
918
|
+
catch(e){ sendEnvelope(res, 400, rpcError("invalid-request","gateway/bad-request","body read failed",{})); return; }
|
|
919
|
+
if(text===undefined){ sendEnvelope(res, 413, rpcError("invalid-request","gateway/bad-request","request body too large",{})); return; }
|
|
920
|
+
let message;
|
|
921
|
+
try{ message = JSON.parse(text); }
|
|
922
|
+
catch{ sendEnvelope(res, 400, rpcError("invalid-request","gateway/bad-request","body is not JSON",{})); return; }
|
|
923
|
+
// 与 client-connection clientRequestSchema 同一外层信封校验
|
|
924
|
+
if(!message || typeof message!=="object" || Array.isArray(message) || message.type!=="client-request"
|
|
925
|
+
|| typeof message.rpcId!=="string" || typeof message.method!=="string" || !("payload" in message)){
|
|
926
|
+
sendEnvelope(res, 400, rpcError("invalid-request","gateway/bad-request","invalid client-request message",{})); return;
|
|
927
|
+
}
|
|
928
|
+
const rpcId = message.rpcId;
|
|
929
|
+
if(message.method!==endpoint){
|
|
930
|
+
sendEnvelope(res, 200, rpcError(rpcId, "gateway/bad-request",
|
|
931
|
+
`method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, {}));
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
let result;
|
|
935
|
+
try{ result = await dispatch(endpoint, message.payload, undefined); }
|
|
936
|
+
catch(e){ sendEnvelope(res, 500, rpcError(rpcId, "internal", String(e && e.message ? e.message : e), {})); return; }
|
|
937
|
+
sendEnvelope(res, 200, rpcFull(rpcId, result));
|
|
938
|
+
}
|
|
939
|
+
}), "dsh-cc-studio: POST "+RPC_CHANNEL+"/*");
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export { apply, inject, name, planPresetInstall, installCcPreset, presetTargetRoot, presetSourceRoot };
|
|
943
|
+
|
|
944
|
+
|