agent-purge 0.2.11 → 0.2.12
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/bin/agent-purge.mjs +50 -0
- package/lib/persona-import.mjs +351 -0
- package/package.json +1 -1
- package/web/app.js +65 -0
- package/web/server.mjs +24 -0
- package/web/styles.css +155 -0
- package/web/ui.html +13 -1
package/bin/agent-purge.mjs
CHANGED
|
@@ -106,6 +106,12 @@ switch (cmd) {
|
|
|
106
106
|
case "ls":
|
|
107
107
|
await lsCmd();
|
|
108
108
|
break;
|
|
109
|
+
case "pull":
|
|
110
|
+
await personaImportCmd(argv.slice(1));
|
|
111
|
+
break;
|
|
112
|
+
case "persona":
|
|
113
|
+
await personaImportCmd(argv.slice(1));
|
|
114
|
+
break;
|
|
109
115
|
case "doctor":
|
|
110
116
|
case "detect": {
|
|
111
117
|
const { doctor } = await import("../lib/doctor.mjs");
|
|
@@ -639,6 +645,48 @@ async function statusCmd() {
|
|
|
639
645
|
});
|
|
640
646
|
}
|
|
641
647
|
|
|
648
|
+
async function personaImportCmd(args = []) {
|
|
649
|
+
let sub = args[0];
|
|
650
|
+
let source = args[1];
|
|
651
|
+
if (sub !== "pull" && sub !== "import") {
|
|
652
|
+
source = sub;
|
|
653
|
+
sub = "pull";
|
|
654
|
+
}
|
|
655
|
+
if (!source && sub === "pull") {
|
|
656
|
+
source = "ZiChuanLan/some-magic";
|
|
657
|
+
}
|
|
658
|
+
if (!source) {
|
|
659
|
+
die("用法:agent-purge pull [owner/repo 或 本地目录] 或 agent-purge persona import <dir>");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const noOverwrite = argv.includes("--no-overwrite");
|
|
663
|
+
const { importPersonas } = await import("../lib/persona-import.mjs");
|
|
664
|
+
console.log(`正在从 ${source} 拉取人设...`);
|
|
665
|
+
try {
|
|
666
|
+
const res = await importPersonas(source, DATA, { overwrite: !noOverwrite });
|
|
667
|
+
console.log(`\n拉取完成!共导入 ${res.total} 个人设:`);
|
|
668
|
+
for (const item of res.imported) {
|
|
669
|
+
console.log(` ✓ ${item.id.padEnd(14)} ${item.name} (${item.chars} 字, ${item.action === "created" ? "新增" : "更新"})`);
|
|
670
|
+
}
|
|
671
|
+
if (res.skipped?.length) {
|
|
672
|
+
console.log(`\n跳过 ${res.skipped.length} 个文件:`);
|
|
673
|
+
for (const item of res.skipped) {
|
|
674
|
+
console.log(` - ${item.file || item.id}: ${item.reason}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (res.errors?.length) {
|
|
678
|
+
console.log(`\n错误 ${res.errors.length} 项:`);
|
|
679
|
+
for (const item of res.errors) {
|
|
680
|
+
console.log(` ✗ ${item.file}: ${item.error}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
console.log(`\n查看已安装人设:agent-purge ls`);
|
|
684
|
+
console.log(`切换当前人设:agent-purge use <id>`);
|
|
685
|
+
} catch (e) {
|
|
686
|
+
die(`拉取失败:${e.message}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
642
690
|
async function lsCmd() {
|
|
643
691
|
const { createStore } = await import("../lib/personas.mjs");
|
|
644
692
|
const store = createStore(DATA);
|
|
@@ -825,6 +873,8 @@ function help() {
|
|
|
825
873
|
① 选(写共享配置并同步;已加载动态插件下一轮读取,静态入口需新会话)
|
|
826
874
|
agent-purge use <id> 换 persona(ls 看有哪些,* = 当前;自动刷新已武装的常驻落点)
|
|
827
875
|
agent-purge use <id> --for <h> 只给某一家换 persona;--for <h> follow 恢复跟随全局
|
|
876
|
+
agent-purge pull [repo] 从 GitHub(默认 ZiChuanLan/some-magic)拉取人设
|
|
877
|
+
agent-purge persona import <dir> 从本地目录批量导入人设 (.md)
|
|
828
878
|
agent-purge sync 只刷新常驻落点/命令文件,不换 persona
|
|
829
879
|
agent-purge mode <m> replace(换身份, 默认) / strict(只留人设) / append(追加) / off
|
|
830
880
|
agent-purge mode <m> --for <h> 只给某一家设模式;--for <h> follow 恢复跟随全局
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// 从外部来源(GitHub 仓库或本地目录)拉取/导入人设并存入本地人设库
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
4
|
+
import { createStore, isReservedId } from "./personas.mjs";
|
|
5
|
+
|
|
6
|
+
const IGNORED_NAMES = new Set([
|
|
7
|
+
"readme.md",
|
|
8
|
+
"readme_zh.md",
|
|
9
|
+
"readme.zh.md",
|
|
10
|
+
"readme.zh-cn.md",
|
|
11
|
+
"license.md",
|
|
12
|
+
"licence.md",
|
|
13
|
+
"changelog.md",
|
|
14
|
+
"contributing.md",
|
|
15
|
+
"security.md",
|
|
16
|
+
"active.md",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** 解析源字符串:判断是本地目录还是 GitHub 仓库 */
|
|
20
|
+
export function parseSource(source) {
|
|
21
|
+
const raw = String(source ?? "").trim();
|
|
22
|
+
if (!raw) throw new Error("导入源不能为空");
|
|
23
|
+
|
|
24
|
+
// 1. 本地目录判断:绝对路径、相对路径前缀、或本地已存在该目录
|
|
25
|
+
if (
|
|
26
|
+
raw.startsWith("file://") ||
|
|
27
|
+
raw.startsWith(".") ||
|
|
28
|
+
raw.startsWith("/") ||
|
|
29
|
+
/^[A-Za-z]:[\\/]/.test(raw) ||
|
|
30
|
+
existsSync(raw)
|
|
31
|
+
) {
|
|
32
|
+
let cleanPath = raw;
|
|
33
|
+
if (raw.startsWith("file://")) {
|
|
34
|
+
try {
|
|
35
|
+
cleanPath = new URL(raw).pathname;
|
|
36
|
+
} catch {
|
|
37
|
+
cleanPath = raw.slice(7);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const localDir = resolve(process.cwd(), cleanPath);
|
|
41
|
+
if (!existsSync(localDir) || !statSync(localDir).isDirectory()) {
|
|
42
|
+
throw new Error(`本地目录不存在: ${localDir}`);
|
|
43
|
+
}
|
|
44
|
+
return { kind: "local", path: localDir };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. GitHub URL 或 owner/repo 格式
|
|
48
|
+
let owner = "";
|
|
49
|
+
let repo = "";
|
|
50
|
+
let ref = "main";
|
|
51
|
+
let subpath = "";
|
|
52
|
+
|
|
53
|
+
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
|
54
|
+
try {
|
|
55
|
+
const u = new URL(raw);
|
|
56
|
+
if (!u.hostname.includes("github.com")) {
|
|
57
|
+
throw new Error("目前外部拉取仅支持 GitHub 仓库或本地目录");
|
|
58
|
+
}
|
|
59
|
+
const parts = u.pathname.replace(/^\/+|\/+$/g, "").split("/");
|
|
60
|
+
if (parts.length < 2) throw new Error("无效的 GitHub 仓库 URL");
|
|
61
|
+
owner = parts[0];
|
|
62
|
+
repo = parts[1].replace(/\.git$/i, "");
|
|
63
|
+
if (parts[2] === "tree" && parts[3]) {
|
|
64
|
+
ref = parts[3];
|
|
65
|
+
subpath = parts.slice(4).join("/");
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
throw new Error(`无法解析 GitHub URL: ${e.message}`);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
// 可能是 "owner/repo" 或 "owner/repo@ref" 或 "owner/repo/subpath"
|
|
72
|
+
const clean = raw.replace(/\.git$/i, "");
|
|
73
|
+
const [repoPart, refPart] = clean.split("@");
|
|
74
|
+
if (refPart) ref = refPart;
|
|
75
|
+
|
|
76
|
+
const parts = repoPart.split("/");
|
|
77
|
+
if (parts.length >= 2) {
|
|
78
|
+
owner = parts[0];
|
|
79
|
+
repo = parts[1];
|
|
80
|
+
if (parts.length > 2) subpath = parts.slice(2).join("/");
|
|
81
|
+
} else {
|
|
82
|
+
throw new Error(`无法识别导入源: ${raw}(请指定 owner/repo 或本地目录路径)`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { kind: "github", owner, repo, ref, subpath };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 规范化从文件名或 frontmatter 得出的 persona id */
|
|
90
|
+
export function sanitizeId(rawId, fallbackName = "persona") {
|
|
91
|
+
let s = String(rawId || "").trim().toLowerCase();
|
|
92
|
+
// 移除非法字符,替换为空格或连字符
|
|
93
|
+
s = s.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
94
|
+
if (!s || isReservedId(s)) {
|
|
95
|
+
let fb = String(fallbackName || "persona").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
96
|
+
s = fb && !isReservedId(fb) ? fb : `p-${Date.now().toString(36)}`;
|
|
97
|
+
}
|
|
98
|
+
return s.slice(0, 64);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 解析 markdown 中的 frontmatter 与提示词正文 */
|
|
102
|
+
export function parsePersonaDoc(raw, fallbackId = "") {
|
|
103
|
+
let meta = {};
|
|
104
|
+
let body = raw;
|
|
105
|
+
|
|
106
|
+
if (raw.startsWith("---")) {
|
|
107
|
+
const end = raw.indexOf("\n---", 3);
|
|
108
|
+
if (end !== -1) {
|
|
109
|
+
const head = raw.slice(3, end).trim();
|
|
110
|
+
body = raw.slice(end + 4).replace(/^\r?\n/, "");
|
|
111
|
+
meta = parseFrontmatter(head);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const id = sanitizeId(meta.id || fallbackId, fallbackId);
|
|
116
|
+
const name = meta.name || meta.title || fallbackId || id;
|
|
117
|
+
const description = meta.description || meta.desc || "";
|
|
118
|
+
const harness = Array.isArray(meta.harness) ? meta.harness : ["*"];
|
|
119
|
+
const model = Array.isArray(meta.model) ? meta.model : ["*"];
|
|
120
|
+
const order = typeof meta.order === "number" ? meta.order : 50;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
meta: {
|
|
125
|
+
...meta,
|
|
126
|
+
id,
|
|
127
|
+
name,
|
|
128
|
+
description,
|
|
129
|
+
harness,
|
|
130
|
+
model,
|
|
131
|
+
order,
|
|
132
|
+
},
|
|
133
|
+
body: body.trim(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseFrontmatter(head) {
|
|
138
|
+
const meta = {};
|
|
139
|
+
let key = null;
|
|
140
|
+
for (const line of head.split(/\r?\n/)) {
|
|
141
|
+
if (!line.trim()) continue;
|
|
142
|
+
const arr = line.match(/^\s*-\s+(.*)$/);
|
|
143
|
+
if (arr && key) {
|
|
144
|
+
if (!Array.isArray(meta[key])) meta[key] = [];
|
|
145
|
+
meta[key].push(unquote(arr[1]));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
149
|
+
if (!kv) continue;
|
|
150
|
+
key = kv[1];
|
|
151
|
+
const v = kv[2].trim();
|
|
152
|
+
if (!v) {
|
|
153
|
+
meta[key] = [];
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (v.startsWith("[")) {
|
|
157
|
+
meta[key] = v
|
|
158
|
+
.replace(/^\[|\]$/g, "")
|
|
159
|
+
.split(",")
|
|
160
|
+
.map((s) => unquote(s.trim()))
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
} else {
|
|
163
|
+
meta[key] = coerce(v);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return meta;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const unquote = (s) => s.replace(/^["']|["']$/g, "");
|
|
170
|
+
const coerce = (v) => {
|
|
171
|
+
if (v === "true") return true;
|
|
172
|
+
if (v === "false") return false;
|
|
173
|
+
const n = Number(v);
|
|
174
|
+
return Number.isFinite(n) && !Number.isNaN(n) && String(n) === v ? n : unquote(v);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** 从本地目录导入人设 */
|
|
178
|
+
export function importFromDirectory(dirPath, root, { overwrite = true } = {}) {
|
|
179
|
+
const resolvedDir = resolve(dirPath);
|
|
180
|
+
if (!existsSync(resolvedDir)) throw new Error(`目录不存在: ${resolvedDir}`);
|
|
181
|
+
|
|
182
|
+
const entries = readdirSync(resolvedDir, { withFileTypes: true });
|
|
183
|
+
const store = createStore(root);
|
|
184
|
+
const imported = [];
|
|
185
|
+
const skipped = [];
|
|
186
|
+
const errors = [];
|
|
187
|
+
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
if (entry.isDirectory()) continue;
|
|
190
|
+
const name = entry.name;
|
|
191
|
+
const lower = name.toLowerCase();
|
|
192
|
+
if (!lower.endsWith(".md") && !lower.endsWith(".markdown")) continue;
|
|
193
|
+
if (IGNORED_NAMES.has(lower) || lower.startsWith(".")) {
|
|
194
|
+
skipped.push({ file: name, reason: "忽略的文件名(文档或隐藏文件)" });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const filePath = join(resolvedDir, name);
|
|
200
|
+
const raw = readFileSync(filePath, "utf8");
|
|
201
|
+
const fallbackId = basename(name, extname(name));
|
|
202
|
+
const parsed = parsePersonaDoc(raw, fallbackId);
|
|
203
|
+
|
|
204
|
+
if (!parsed.body) {
|
|
205
|
+
skipped.push({ file: name, reason: "提示词正文为空" });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const existing = store.read(parsed.id);
|
|
210
|
+
if (existing && !overwrite) {
|
|
211
|
+
skipped.push({ id: parsed.id, name: parsed.meta.name, file: name, reason: "已存在且未开启覆盖" });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const saved = store.save(parsed.id, { meta: parsed.meta, body: parsed.body });
|
|
216
|
+
imported.push({
|
|
217
|
+
id: saved.id,
|
|
218
|
+
name: saved.meta.name,
|
|
219
|
+
chars: saved.body.length,
|
|
220
|
+
action: existing ? "updated" : "created",
|
|
221
|
+
});
|
|
222
|
+
} catch (e) {
|
|
223
|
+
errors.push({ file: name, error: e.message });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
success: errors.length === 0,
|
|
229
|
+
source: { kind: "local", path: resolvedDir },
|
|
230
|
+
imported,
|
|
231
|
+
skipped,
|
|
232
|
+
errors,
|
|
233
|
+
total: imported.length,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 从 GitHub 仓库拉取人设 */
|
|
238
|
+
export async function pullFromGitHub(
|
|
239
|
+
{ owner, repo, ref = "main", subpath = "" },
|
|
240
|
+
root,
|
|
241
|
+
{ overwrite = true, token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN } = {}
|
|
242
|
+
) {
|
|
243
|
+
const store = createStore(root);
|
|
244
|
+
const headers = {
|
|
245
|
+
"User-Agent": "agent-purge-client",
|
|
246
|
+
Accept: "application/vnd.github.v3+json",
|
|
247
|
+
};
|
|
248
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
249
|
+
|
|
250
|
+
const apiPath = subpath ? `contents/${encodeURIComponent(subpath).replace(/%2F/g, "/")}` : "contents";
|
|
251
|
+
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${apiPath}?ref=${encodeURIComponent(ref)}`;
|
|
252
|
+
|
|
253
|
+
let res;
|
|
254
|
+
try {
|
|
255
|
+
res = await fetch(url, { headers });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
throw new Error(`连接 GitHub 失败: ${e.message}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
const text = await res.text();
|
|
262
|
+
throw new Error(`GitHub API 响应 ${res.status}: ${text.slice(0, 200)}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const list = await res.json();
|
|
266
|
+
if (!Array.isArray(list)) {
|
|
267
|
+
throw new Error("GitHub 仓库指定路径不是文件列表");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const imported = [];
|
|
271
|
+
const skipped = [];
|
|
272
|
+
const errors = [];
|
|
273
|
+
|
|
274
|
+
for (const item of list) {
|
|
275
|
+
if (item.type !== "file") continue;
|
|
276
|
+
const name = item.name;
|
|
277
|
+
const lower = name.toLowerCase();
|
|
278
|
+
if (!lower.endsWith(".md") && !lower.endsWith(".markdown")) continue;
|
|
279
|
+
if (IGNORED_NAMES.has(lower) || lower.startsWith(".")) {
|
|
280
|
+
skipped.push({ file: name, reason: "忽略的文件名(文档或隐藏文件)" });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
let content = "";
|
|
286
|
+
// 优先使用 download_url(raw),失败时回退到 git_url 获取 base64 blob
|
|
287
|
+
if (item.download_url) {
|
|
288
|
+
try {
|
|
289
|
+
const r = await fetch(item.download_url, { headers: { "User-Agent": "agent-purge-client" } });
|
|
290
|
+
if (r.ok) content = await r.text();
|
|
291
|
+
} catch {
|
|
292
|
+
// 忽略 raw 失败,走 blob 降级
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!content && item.git_url) {
|
|
297
|
+
const blobRes = await fetch(item.git_url, { headers });
|
|
298
|
+
if (blobRes.ok) {
|
|
299
|
+
const blobData = await blobRes.json();
|
|
300
|
+
if (blobData.content && blobData.encoding === "base64") {
|
|
301
|
+
content = Buffer.from(blobData.content, "base64").toString("utf8");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!content) throw new Error("无法读取文件内容");
|
|
307
|
+
|
|
308
|
+
const fallbackId = basename(name, extname(name));
|
|
309
|
+
const parsed = parsePersonaDoc(content, fallbackId);
|
|
310
|
+
|
|
311
|
+
if (!parsed.body) {
|
|
312
|
+
skipped.push({ file: name, reason: "提示词正文为空" });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const existing = store.read(parsed.id);
|
|
317
|
+
if (existing && !overwrite) {
|
|
318
|
+
skipped.push({ id: parsed.id, name: parsed.meta.name, file: name, reason: "已存在且未开启覆盖" });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const saved = store.save(parsed.id, { meta: parsed.meta, body: parsed.body });
|
|
323
|
+
imported.push({
|
|
324
|
+
id: saved.id,
|
|
325
|
+
name: saved.meta.name,
|
|
326
|
+
chars: saved.body.length,
|
|
327
|
+
action: existing ? "updated" : "created",
|
|
328
|
+
});
|
|
329
|
+
} catch (e) {
|
|
330
|
+
errors.push({ file: name, error: e.message });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
success: errors.length === 0,
|
|
336
|
+
source: { kind: "github", owner, repo, ref, subpath },
|
|
337
|
+
imported,
|
|
338
|
+
skipped,
|
|
339
|
+
errors,
|
|
340
|
+
total: imported.length,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 统一入口:根据来源自动分流导入 */
|
|
345
|
+
export async function importPersonas(sourceStr, root, options = {}) {
|
|
346
|
+
const parsed = parseSource(sourceStr);
|
|
347
|
+
if (parsed.kind === "local") {
|
|
348
|
+
return importFromDirectory(parsed.path, root, options);
|
|
349
|
+
}
|
|
350
|
+
return pullFromGitHub(parsed, root, options);
|
|
351
|
+
}
|
package/package.json
CHANGED
package/web/app.js
CHANGED
|
@@ -479,6 +479,71 @@ $('harness-list').addEventListener('change', e => {
|
|
|
479
479
|
$('default-form').addEventListener('submit', e => { e.preventDefault(); operation(async () => { const result = await api('defaults', { harness: $('default-harness').value || null }); await refresh(); announce(result, '默认启动设置已保存,应用范围未改变。'); }); });
|
|
480
480
|
$('edit-current').addEventListener('click', () => { openEditor(draft.persona); navigate('library'); });
|
|
481
481
|
$('new-persona').addEventListener('click', () => openEditor(''));
|
|
482
|
+
|
|
483
|
+
// 外部拉取人设
|
|
484
|
+
const importDialog = $('import-dialog');
|
|
485
|
+
const importStatus = $('import-status');
|
|
486
|
+
const importSourceInput = $('import-source');
|
|
487
|
+
const startImportBtn = $('start-import');
|
|
488
|
+
|
|
489
|
+
$('pull-personas')?.addEventListener('click', () => {
|
|
490
|
+
if (!importDialog) return;
|
|
491
|
+
if (!importSourceInput.value.trim()) {
|
|
492
|
+
importSourceInput.value = 'ZiChuanLan/some-magic';
|
|
493
|
+
}
|
|
494
|
+
importStatus.hidden = true;
|
|
495
|
+
importStatus.className = 'import-status';
|
|
496
|
+
importStatus.textContent = '';
|
|
497
|
+
startImportBtn.disabled = false;
|
|
498
|
+
startImportBtn.textContent = '开始拉取';
|
|
499
|
+
importDialog.showModal();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
$('close-import')?.addEventListener('click', () => importDialog?.close());
|
|
503
|
+
$('cancel-import')?.addEventListener('click', () => importDialog?.close());
|
|
504
|
+
|
|
505
|
+
importDialog?.addEventListener('click', e => {
|
|
506
|
+
const pill = e.target.closest('[data-preset]');
|
|
507
|
+
if (pill) {
|
|
508
|
+
importSourceInput.value = pill.dataset.preset;
|
|
509
|
+
importSourceInput.focus();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
$('import-form')?.addEventListener('submit', async e => {
|
|
514
|
+
e.preventDefault();
|
|
515
|
+
const source = importSourceInput.value.trim();
|
|
516
|
+
if (!source) return;
|
|
517
|
+
const overwrite = $('import-overwrite').checked;
|
|
518
|
+
startImportBtn.disabled = true;
|
|
519
|
+
startImportBtn.textContent = '正在拉取…';
|
|
520
|
+
importStatus.hidden = false;
|
|
521
|
+
importStatus.className = 'import-status';
|
|
522
|
+
importStatus.textContent = `正在从 ${source} 拉取人设…`;
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
const res = await api('persona/import', { source, overwrite });
|
|
526
|
+
if (!res.ok) throw new Error(res.error || '拉取失败');
|
|
527
|
+
const { total = 0, imported = [] } = res.result || {};
|
|
528
|
+
importStatus.className = 'import-status success';
|
|
529
|
+
const names = imported.map(x => x.name || x.id).slice(0, 3).join('、');
|
|
530
|
+
const suffix = imported.length > 3 ? '等' : '';
|
|
531
|
+
const detail = names ? `(${names}${suffix})` : '';
|
|
532
|
+
importStatus.textContent = `✓ 成功导入 ${total} 个人设${detail}!`;
|
|
533
|
+
await refresh();
|
|
534
|
+
notify(`成功拉取 ${total} 个人设。`);
|
|
535
|
+
setTimeout(() => {
|
|
536
|
+
importDialog?.close();
|
|
537
|
+
}, 1200);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
importStatus.className = 'import-status error';
|
|
540
|
+
importStatus.textContent = `✗ 导入失败:${err.message}`;
|
|
541
|
+
notify(`人设导入失败:${err.message}`, 'error');
|
|
542
|
+
} finally {
|
|
543
|
+
startImportBtn.disabled = false;
|
|
544
|
+
startImportBtn.textContent = '开始拉取';
|
|
545
|
+
}
|
|
546
|
+
});
|
|
482
547
|
$('persona-search').addEventListener('input', renderPersonas);
|
|
483
548
|
$('persona-list').addEventListener('click', e => { const el = e.target.closest('[data-persona]'); if (el) openEditor(el.dataset.persona); });
|
|
484
549
|
$('editor-form').addEventListener('input', renderEditorStatus);
|
package/web/server.mjs
CHANGED
|
@@ -122,6 +122,7 @@ async function handle(req, res) {
|
|
|
122
122
|
if (req.method === "POST" && p === "/api/omp-auto") return send(res, 200, { ompAuto: setOmpAuto(DATA, booleanField(await body(req), "on")) });
|
|
123
123
|
if (req.method === "POST" && p === "/api/dsh-profile") return send(res, 200, saveDshProfile(await body(req)));
|
|
124
124
|
if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
|
|
125
|
+
if (req.method === "POST" && p === "/api/persona/import") return send(res, 200, await handleImportPersonas(await body(req)));
|
|
125
126
|
if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
|
|
126
127
|
if (req.method === "POST" && p === "/api/harness") return send(res, 200, saveHarnessConfig(await body(req)));
|
|
127
128
|
if (req.method === "POST" && p === "/api/use") return send(res, 200, usePersona(await body(req)));
|
|
@@ -407,6 +408,29 @@ function usePersona(payload) {
|
|
|
407
408
|
return { ...applyChanges({ persona: payload.id ?? "" }), active: store.activeId() };
|
|
408
409
|
}
|
|
409
410
|
|
|
411
|
+
async function handleImportPersonas(payload) {
|
|
412
|
+
const source = String(payload?.source || "").trim();
|
|
413
|
+
if (!source) throw httpError(400, "导入源不能为空(请提供 GitHub 仓库如 ZiChuanLan/some-magic 或本地目录)");
|
|
414
|
+
const overwrite = payload?.overwrite !== false;
|
|
415
|
+
const { importPersonas } = await import("../lib/persona-import.mjs");
|
|
416
|
+
try {
|
|
417
|
+
const result = await importPersonas(source, DATA, { overwrite });
|
|
418
|
+
let applied = null;
|
|
419
|
+
if (payload?.activateId && store.read(payload.activateId)) {
|
|
420
|
+
applied = applyConfiguration(DATA, { persona: payload.activateId });
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
result,
|
|
425
|
+
personas: store.list().map((x) => ({ id: x.id, meta: x.meta, body: x.body, chars: x.body.trim().length })),
|
|
426
|
+
persona: store.activeId(),
|
|
427
|
+
applied,
|
|
428
|
+
};
|
|
429
|
+
} catch (e) {
|
|
430
|
+
throw httpError(400, `拉取人设失败: ${e.message}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
410
434
|
// 按执行器覆盖人设/模式:值传 null / "" / "follow" = 恢复跟随全局。
|
|
411
435
|
function saveHarnessConfig(payload) {
|
|
412
436
|
const id = knownHarness(payload.harness);
|
package/web/styles.css
CHANGED
|
@@ -885,6 +885,161 @@ summary:hover { color: var(--ink); }
|
|
|
885
885
|
.result-row strong { font-size: 13.5px; }
|
|
886
886
|
.result-row .error-text { color: var(--red); }
|
|
887
887
|
|
|
888
|
+
/* ---------- Import Dialog ---------- */
|
|
889
|
+
dialog.modal-dialog {
|
|
890
|
+
border: 1px solid var(--line);
|
|
891
|
+
border-radius: var(--radius-lg);
|
|
892
|
+
background: #ffffff;
|
|
893
|
+
color: var(--ink);
|
|
894
|
+
box-shadow: 0 20px 25px -5px rgba(16, 24, 40, 0.12), 0 10px 10px -5px rgba(16, 24, 40, 0.04);
|
|
895
|
+
padding: 0;
|
|
896
|
+
max-width: 480px;
|
|
897
|
+
width: calc(100vw - 32px);
|
|
898
|
+
margin: auto;
|
|
899
|
+
overflow: hidden;
|
|
900
|
+
}
|
|
901
|
+
dialog.modal-dialog::backdrop {
|
|
902
|
+
background: rgba(15, 23, 42, 0.45);
|
|
903
|
+
backdrop-filter: blur(4px);
|
|
904
|
+
}
|
|
905
|
+
.dialog-header {
|
|
906
|
+
display: flex;
|
|
907
|
+
justify-content: space-between;
|
|
908
|
+
align-items: center;
|
|
909
|
+
padding: 16px 20px;
|
|
910
|
+
border-bottom: 1px solid var(--line-subtle);
|
|
911
|
+
background: #ffffff;
|
|
912
|
+
}
|
|
913
|
+
.dialog-header h2 {
|
|
914
|
+
margin: 0;
|
|
915
|
+
font-size: 16px;
|
|
916
|
+
font-weight: 600;
|
|
917
|
+
color: var(--ink);
|
|
918
|
+
letter-spacing: -0.01em;
|
|
919
|
+
}
|
|
920
|
+
.dialog-header .close-btn {
|
|
921
|
+
background: transparent;
|
|
922
|
+
border: none;
|
|
923
|
+
color: var(--muted);
|
|
924
|
+
width: 28px;
|
|
925
|
+
height: 28px;
|
|
926
|
+
min-height: 28px;
|
|
927
|
+
border-radius: var(--radius-sm);
|
|
928
|
+
display: inline-flex;
|
|
929
|
+
align-items: center;
|
|
930
|
+
justify-content: center;
|
|
931
|
+
font-size: 15px;
|
|
932
|
+
cursor: pointer;
|
|
933
|
+
padding: 0;
|
|
934
|
+
box-shadow: none;
|
|
935
|
+
transition: all 120ms ease;
|
|
936
|
+
}
|
|
937
|
+
.dialog-header .close-btn:hover {
|
|
938
|
+
background: var(--panel-muted);
|
|
939
|
+
color: var(--ink);
|
|
940
|
+
border: none;
|
|
941
|
+
}
|
|
942
|
+
.dialog-desc {
|
|
943
|
+
padding: 14px 20px 0;
|
|
944
|
+
margin: 0;
|
|
945
|
+
font-size: 13px;
|
|
946
|
+
line-height: 1.5;
|
|
947
|
+
color: var(--muted);
|
|
948
|
+
background: #ffffff;
|
|
949
|
+
}
|
|
950
|
+
.preset-row {
|
|
951
|
+
display: flex;
|
|
952
|
+
align-items: center;
|
|
953
|
+
gap: 8px;
|
|
954
|
+
padding: 10px 20px 0;
|
|
955
|
+
flex-wrap: wrap;
|
|
956
|
+
background: #ffffff;
|
|
957
|
+
}
|
|
958
|
+
.preset-row .muted {
|
|
959
|
+
font-size: 12px;
|
|
960
|
+
color: var(--muted);
|
|
961
|
+
}
|
|
962
|
+
.preset-pill {
|
|
963
|
+
padding: 3px 10px;
|
|
964
|
+
min-height: auto;
|
|
965
|
+
font-size: 12px;
|
|
966
|
+
font-family: "Cascadia Code", "Fira Code", Consolas, monospace;
|
|
967
|
+
border: 1px solid var(--blue-border);
|
|
968
|
+
background: var(--blue-subtle);
|
|
969
|
+
color: var(--blue);
|
|
970
|
+
border-radius: 999px;
|
|
971
|
+
cursor: pointer;
|
|
972
|
+
display: inline-flex;
|
|
973
|
+
align-items: center;
|
|
974
|
+
gap: 4px;
|
|
975
|
+
box-shadow: none;
|
|
976
|
+
transition: all 120ms ease;
|
|
977
|
+
}
|
|
978
|
+
.preset-pill:hover {
|
|
979
|
+
background: var(--blue-soft);
|
|
980
|
+
border-color: var(--blue);
|
|
981
|
+
}
|
|
982
|
+
.dialog-body {
|
|
983
|
+
padding: 14px 20px 18px;
|
|
984
|
+
display: flex;
|
|
985
|
+
flex-direction: column;
|
|
986
|
+
gap: 12px;
|
|
987
|
+
background: #ffffff;
|
|
988
|
+
}
|
|
989
|
+
.dialog-body label:not(.check-label) {
|
|
990
|
+
font-size: 13px;
|
|
991
|
+
font-weight: 500;
|
|
992
|
+
color: var(--text-secondary);
|
|
993
|
+
}
|
|
994
|
+
.dialog-body input[type="text"] {
|
|
995
|
+
font-size: 13.5px;
|
|
996
|
+
}
|
|
997
|
+
.dialog-body .check-label {
|
|
998
|
+
display: flex;
|
|
999
|
+
align-items: center;
|
|
1000
|
+
gap: 8px;
|
|
1001
|
+
font-size: 12.5px;
|
|
1002
|
+
color: var(--text-secondary);
|
|
1003
|
+
cursor: pointer;
|
|
1004
|
+
user-select: none;
|
|
1005
|
+
}
|
|
1006
|
+
.dialog-body .check-label input {
|
|
1007
|
+
margin: 0;
|
|
1008
|
+
}
|
|
1009
|
+
.import-status {
|
|
1010
|
+
padding: 10px 14px;
|
|
1011
|
+
border-radius: var(--radius-sm);
|
|
1012
|
+
font-size: 12.5px;
|
|
1013
|
+
line-height: 1.5;
|
|
1014
|
+
background: var(--panel-muted);
|
|
1015
|
+
border: 1px solid var(--line);
|
|
1016
|
+
}
|
|
1017
|
+
.import-status.success {
|
|
1018
|
+
background: var(--green-soft);
|
|
1019
|
+
color: var(--green);
|
|
1020
|
+
border-color: var(--green-border);
|
|
1021
|
+
}
|
|
1022
|
+
.import-status.error {
|
|
1023
|
+
background: var(--red-soft);
|
|
1024
|
+
color: var(--red);
|
|
1025
|
+
border-color: var(--red-border);
|
|
1026
|
+
}
|
|
1027
|
+
.dialog-footer {
|
|
1028
|
+
display: flex;
|
|
1029
|
+
justify-content: flex-end;
|
|
1030
|
+
gap: 10px;
|
|
1031
|
+
padding: 12px 20px;
|
|
1032
|
+
border-top: 1px solid var(--line-subtle);
|
|
1033
|
+
background: var(--bg);
|
|
1034
|
+
}
|
|
1035
|
+
.dialog-footer button {
|
|
1036
|
+
min-height: 34px;
|
|
1037
|
+
padding: 6px 14px;
|
|
1038
|
+
font-size: 13px;
|
|
1039
|
+
font-weight: 500;
|
|
1040
|
+
border-radius: var(--radius-sm);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
888
1043
|
/* ---------- Responsive ---------- */
|
|
889
1044
|
|
|
890
1045
|
@media (max-width: 1100px) {
|
package/web/ui.html
CHANGED
|
@@ -47,7 +47,19 @@
|
|
|
47
47
|
<details class="panel launch-settings"><summary>默认命令启动 <span id="default-summary" class="muted">未设置</span></summary><div class="detail-body"><p class="help">只决定裸敲 <code>agent-purge</code> 时启动谁,不改变上面的应用范围。</p><form id="default-form" class="inline-form"><label for="default-harness">默认执行器</label><select id="default-harness"></select><button class="secondary" type="submit">保存启动设置</button></form></div></details>
|
|
48
48
|
</section>
|
|
49
49
|
<section id="page-library" class="page" aria-labelledby="library-title" hidden>
|
|
50
|
-
<div class="page-heading"><div><h1 id="library-title">人设库</h1><p>正文用于提示词,名称与说明只用于管理。</p></div><button id="new-persona" class="primary">新建人设</button></div>
|
|
50
|
+
<div class="page-heading"><div><h1 id="library-title">人设库</h1><p>正文用于提示词,名称与说明只用于管理。</p></div><div class="actions"><button id="pull-personas" class="secondary" type="button">从外部拉取</button><button id="new-persona" class="primary" type="button">新建人设</button></div></div>
|
|
51
|
+
<dialog id="import-dialog" class="modal-dialog"><form id="import-form" method="dialog">
|
|
52
|
+
<div class="dialog-header"><h2>从外部拉取人设</h2><button type="button" id="close-import" class="quiet close-btn" aria-label="关闭">✕</button></div>
|
|
53
|
+
<p class="help dialog-desc">可从公开 GitHub 仓库或本机目录批量导入 <code>.md</code> 提示词文件,自动跳过说明文档并写入本地人设库。</p>
|
|
54
|
+
<div class="preset-row"><span class="muted">快捷推荐:</span><button type="button" class="preset-pill" data-preset="ZiChuanLan/some-magic">ZiChuanLan/some-magic</button></div>
|
|
55
|
+
<div class="dialog-body">
|
|
56
|
+
<label for="import-source">导入源(GitHub owner/repo 或 本地目录路径)</label>
|
|
57
|
+
<input id="import-source" placeholder="如 ZiChuanLan/some-magic 或 D:\prompts" required autocomplete="off">
|
|
58
|
+
<label class="check-label"><input type="checkbox" id="import-overwrite" checked>覆盖同名标识(已有标识以新内容为主)</label>
|
|
59
|
+
<div id="import-status" class="import-status" hidden></div>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="dialog-footer"><button type="button" id="cancel-import" class="quiet">取消</button><button type="submit" id="start-import" class="primary">开始拉取</button></div>
|
|
62
|
+
</form></dialog>
|
|
51
63
|
<div class="library-layout"><aside class="panel persona-sidebar"><label class="sr-only" for="persona-search">搜索人设</label><input id="persona-search" type="search" placeholder="搜索名称或说明"><div id="persona-list"></div></aside>
|
|
52
64
|
<section class="panel editor-panel"><div id="editor-empty" class="empty">选择一份人设,查看或编辑正文。</div><form id="editor-form" hidden>
|
|
53
65
|
<div class="section-heading"><h2 id="editor-title">编辑人设</h2><span id="editor-status" class="muted">未修改</span></div>
|