tavern-harness 0.1.29 → 0.1.30
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 +55 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/public/favicon.png +0 -0
- package/sandbox-server.mjs +40 -21
- package/src/App.tsx +3 -6
- package/src/assets/memory-skill.json +76 -0
- package/src/assets/tavern-handbook.md +1 -0
- package/src/components/CharactersView.tsx +36 -23
- package/src/components/FileDisplayModal.tsx +90 -99
- package/src/components/SettingsView.tsx +5 -5
- package/src/components/Sidebar.tsx +14 -18
- package/src/components/WorkspaceFileManagerModal.tsx +2 -2
- package/src/core/i18n.ts +3 -0
- package/src/core/tools/generatedSkillExecutor.ts +12 -4
- package/src/core/tools/toolExecutor.ts +7 -6
- package/src/db/database.ts +24 -2
- package/src/generated/changelog.json +77 -1
- package/src/store/store.ts +1 -1
- package/src/theme/global.css +10 -1
- package/src/theme/views.css +20 -0
- package/src/types/models.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,60 @@
|
|
|
3
3
|
> 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
|
|
4
4
|
> 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
|
|
5
5
|
|
|
6
|
+
## [0.1.30] - 2026-10-29
|
|
7
|
+
|
|
8
|
+
### 中文
|
|
9
|
+
|
|
10
|
+
#### 新增
|
|
11
|
+
|
|
12
|
+
- 技能列表默认提供一个「记忆」技能(可编辑、可删除,也能装备给任意角色):它把玩家的称呼与偏好、人物关系、重要事件等关键信息写入本地长期记忆文件,跨会话保留;条数超限时自动遗忘最久未触发的一条,记忆发生变化时还能自动弹出记忆看板
|
|
13
|
+
|
|
14
|
+
#### 改进
|
|
15
|
+
|
|
16
|
+
- 内置世界书「酒馆经营手册(精华版)」改为只读,并在每次启动时同步为随版本更新的内置内容;编辑、重置按钮和知识库、Markdown 开关都不再可用。此前手动改过的内容会被内置版本覆盖
|
|
17
|
+
- 文件展示窗口会记住画中画的位置和大小,HTML 预览的缩放比例也会按文件保存,重新打开预览或重启应用后自动恢复
|
|
18
|
+
- 图片文件不再按文本读取并尝试预览,打开时会明确提示「不支持这种类型的文件」,不再出现乱码或损坏的图片
|
|
19
|
+
- 移动端的「导出游戏」按钮从「更多」菜单移到会话标题栏,与主题切换并排;桌面端按钮顺序也调整为「重置 / 删除 / 导出 / 更多」
|
|
20
|
+
- 侧边栏搜索会等到停止输入 250 毫秒后再执行,并改为一次性扫描消息,连续打字时列表不再卡顿
|
|
21
|
+
- 打开文件管理器时不再重复向本地文件服务发送探测请求,文件列表显示更快
|
|
22
|
+
- 站点图标换成更高分辨率的版本,在高分屏和书签栏中更清晰
|
|
23
|
+
|
|
24
|
+
#### 修复
|
|
25
|
+
|
|
26
|
+
- 删除技能后,角色身上残留的启用记录会一并清除,不会再保留一个已不存在的技能名
|
|
27
|
+
- 测试提供方连接后,接口地址不会再被自动改写成代理地址;模型重名检查也只比较启用中的模型,停用的模型不再误报冲突
|
|
28
|
+
- 沙箱脚本改为在执行前校验整条管道:重定向写错时不会再让前面的命令先执行并产生副作用;`2>` 和 `2>>` 可以用在管道的任意一段,重定向文件也会完整写完再返回
|
|
29
|
+
|
|
30
|
+
#### 内部
|
|
31
|
+
|
|
32
|
+
- 酒馆手册补充约定:HTML 技能应先初始化数据与页面文件
|
|
33
|
+
|
|
34
|
+
### English
|
|
35
|
+
|
|
36
|
+
#### Added
|
|
37
|
+
|
|
38
|
+
- The skill list now ships with a ready-made **Memory** skill (editable, deletable, and equippable on any character). It saves key facts — how the player likes to be addressed, relationships, important events — to a local long-term memory file that persists across sessions, automatically forgets the least recently used entry when the list is full, and can pop up a memory dashboard whenever something is written
|
|
39
|
+
|
|
40
|
+
#### Changed
|
|
41
|
+
|
|
42
|
+
- The built-in **Tavern Management Handbook (Essentials)** lorebook is now read-only and is synced to the latest built-in content on every launch; the edit and reset buttons, the knowledge base, and the Markdown toggle are no longer available. Any previous manual edits to it will be overwritten by the built-in version
|
|
43
|
+
- The file display window now remembers its picture-in-picture position and size, and HTML preview zoom is saved per file, so both are restored after reopening the preview or restarting the app
|
|
44
|
+
- Image files are no longer read as text and previewed; opening one now shows a clear "This file type is not supported" message instead of garbled or broken output
|
|
45
|
+
- On mobile, **Export gameplay** moved out of the **More** menu into the session header next to the theme toggle; on desktop the button order is now reset / delete / export / more
|
|
46
|
+
- Sidebar search now waits until you stop typing for 250 ms and scans messages in a single pass, so the list no longer stutters while typing
|
|
47
|
+
- Opening the file manager no longer sends a duplicate probe request to the local file service, so file listings appear faster
|
|
48
|
+
- The site icon now uses a higher-resolution image that looks sharper on high-DPI screens and in bookmarks
|
|
49
|
+
|
|
50
|
+
#### Fixed
|
|
51
|
+
|
|
52
|
+
- Deleting a skill now also clears its stale entry from every character that had it enabled, instead of leaving a reference to a skill that no longer exists
|
|
53
|
+
- Testing a provider no longer rewrites the API base URL into a proxy URL; duplicate-model checks now only consider enabled models, so disabled models no longer trigger false conflicts
|
|
54
|
+
- Sandbox scripts now validate the whole pipeline before running: a malformed redirect can no longer let earlier commands run and cause side effects, `2>` and `2>>` work on any pipeline segment, and redirect files are fully flushed before the result is returned
|
|
55
|
+
|
|
56
|
+
#### Internal
|
|
57
|
+
|
|
58
|
+
- The tavern handbook now states that HTML skills should initialize their data and page files first
|
|
59
|
+
|
|
6
60
|
## [0.1.29] - 2026-10-28
|
|
7
61
|
|
|
8
62
|
### 中文
|
|
@@ -826,6 +880,7 @@
|
|
|
826
880
|
- Vite + React + Zustand + Dexie stack with browser-side local persistence
|
|
827
881
|
|
|
828
882
|
<!-- 链接定义 -->
|
|
883
|
+
[0.1.30]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.29...v0.1.30
|
|
829
884
|
[0.1.29]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.28...v0.1.29
|
|
830
885
|
[0.1.28]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.27...v0.1.28
|
|
831
886
|
[0.1.27]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.26...v0.1.27
|
package/README.md
CHANGED
|
@@ -102,7 +102,7 @@ The tavern is designed around AI assistance to make creating and playing games e
|
|
|
102
102
|
|
|
103
103
|
Characters are more than chat partners: they are **agents with extensible skills**. Equip each character with the abilities needed for richer gameplay.
|
|
104
104
|
|
|
105
|
-
The tavern includes essential skills such as dice rolling. You can also ask the Tavern Keeper to **create any skill you need**.
|
|
105
|
+
The tavern includes essential skills such as dice rolling and a default, editable **Memory** skill. Memory lets characters keep important preferences, world details, plot progress, and agreements in a local long-term-memory store. You can also ask the Tavern Keeper to **create any skill you need**.
|
|
106
106
|
|
|
107
107
|
### 💬 Multi-Character Group Chats
|
|
108
108
|
|
package/README.zh.md
CHANGED
|
@@ -103,7 +103,7 @@ npm run dev # 开发服务器默认端口 5173,被占用时自动顺延
|
|
|
103
103
|
|
|
104
104
|
所有的角色不再是只能对话的角色,而是**具备技能拓展的智能体**:为每一个角色装备需要的技能,实现更高级的玩法。
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
酒馆已经提供了一些必要的技能(例如掷骰子和默认提供的、可编辑的**记忆**技能)。记忆技能会把重要的偏好、世界设定、剧情进展和约定保存到本地长期记忆中;同时你也可以拜托酒馆老板,**创建任意你想要的技能**~
|
|
107
107
|
|
|
108
108
|
### 💬 多角色群聊
|
|
109
109
|
|
package/package.json
CHANGED
package/public/favicon.png
CHANGED
|
Binary file
|
package/sandbox-server.mjs
CHANGED
|
@@ -1075,6 +1075,13 @@ function validateScript(script, sessionBase) {
|
|
|
1075
1075
|
for (const commands of groups) {
|
|
1076
1076
|
commandCount += commands.length;
|
|
1077
1077
|
if (commandCount > MAX_LINES) return 'Script contains more than 20 commands';
|
|
1078
|
+
// 管道形状错误必须在执行任何命令之前拒绝,否则前面的命令已产生副作用。
|
|
1079
|
+
if (commands.some((command, index) => command.input && index !== 0)) {
|
|
1080
|
+
return 'Input redirection is only supported for the first pipeline command';
|
|
1081
|
+
}
|
|
1082
|
+
if (commands.some((command, index) => command.output && index !== commands.length - 1)) {
|
|
1083
|
+
return 'Output redirection is only supported for the last pipeline command';
|
|
1084
|
+
}
|
|
1078
1085
|
for (const { cmd, args, input, output, errorOutput } of commands) {
|
|
1079
1086
|
if (!SHELL_ALLOWED_REAL.has(cmd)) hasNonAllowlistedCommand = true;
|
|
1080
1087
|
const commandWriteTargets = shellWriteTargets(cmd, args);
|
|
@@ -1124,16 +1131,8 @@ function killChild(child) {
|
|
|
1124
1131
|
}
|
|
1125
1132
|
|
|
1126
1133
|
function runCommandGroup(group, sessionBase) {
|
|
1134
|
+
// 管道形状(< 只能在首段、> 只能在末段)已由 validateScript 保证。
|
|
1127
1135
|
const commands = parseCommandGroup(group);
|
|
1128
|
-
if (commands.some((command, index) => command.input && index !== 0)) {
|
|
1129
|
-
return Promise.reject(new Error('Input redirection is only supported for the first pipeline command'));
|
|
1130
|
-
}
|
|
1131
|
-
if (commands.some((command, index) => command.output && index !== commands.length - 1)) {
|
|
1132
|
-
return Promise.reject(new Error('Output redirection is only supported for the last pipeline command'));
|
|
1133
|
-
}
|
|
1134
|
-
if (commands.some((command, index) => command.errorOutput && index !== commands.length - 1)) {
|
|
1135
|
-
return Promise.reject(new Error('Standard error file redirection is only supported for the last pipeline command'));
|
|
1136
|
-
}
|
|
1137
1136
|
|
|
1138
1137
|
return new Promise((resolve, reject) => {
|
|
1139
1138
|
const children = [];
|
|
@@ -1144,20 +1143,21 @@ function runCommandGroup(group, sessionBase) {
|
|
|
1144
1143
|
const output = last.output
|
|
1145
1144
|
? createWriteStream(redirectWritePath(last.output.path, sessionBase), { flags: last.output.append ? 'a' : 'w' })
|
|
1146
1145
|
: null;
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
: null;
|
|
1146
|
+
/** 每段各自的 2> / 2>> 目标;中间段的 stderr 写入文件后不再进入后续段。 */
|
|
1147
|
+
const errorSinks = new Set();
|
|
1150
1148
|
let stdout = '';
|
|
1151
1149
|
let stderr = '';
|
|
1152
1150
|
let settled = false;
|
|
1153
|
-
let
|
|
1151
|
+
let childrenClosed = 0;
|
|
1152
|
+
/** 尚未写完的重定向目标(stdout 或某段的 stderr 文件)。 */
|
|
1153
|
+
let pendingWrites = 0;
|
|
1154
1154
|
const finish = (error) => {
|
|
1155
1155
|
if (settled) return;
|
|
1156
1156
|
settled = true;
|
|
1157
1157
|
clearTimeout(timer);
|
|
1158
1158
|
input?.destroy();
|
|
1159
1159
|
output?.destroy();
|
|
1160
|
-
|
|
1160
|
+
for (const sink of errorSinks) sink.destroy();
|
|
1161
1161
|
if (error) {
|
|
1162
1162
|
children.forEach(killChild);
|
|
1163
1163
|
reject(error);
|
|
@@ -1169,15 +1169,34 @@ function runCommandGroup(group, sessionBase) {
|
|
|
1169
1169
|
});
|
|
1170
1170
|
}
|
|
1171
1171
|
};
|
|
1172
|
+
/** 所有子进程退出且重定向文件都写完后再返回,避免读到只落盘一半的结果。 */
|
|
1173
|
+
const resolveAfterFlush = () => {
|
|
1174
|
+
if (!settled && childrenClosed === commands.length && pendingWrites === 0) finish(null);
|
|
1175
|
+
};
|
|
1176
|
+
const trackWrite = (stream) => {
|
|
1177
|
+
if (!stream) return;
|
|
1178
|
+
pendingWrites += 1;
|
|
1179
|
+
stream.on('finish', () => {
|
|
1180
|
+
pendingWrites -= 1;
|
|
1181
|
+
resolveAfterFlush();
|
|
1182
|
+
});
|
|
1183
|
+
};
|
|
1172
1184
|
const timer = setTimeout(() => finish(new Error(`Command timed out (${CMD_TIMEOUT_MS}ms)`)), CMD_TIMEOUT_MS);
|
|
1173
1185
|
|
|
1174
1186
|
input?.on('error', (error) => finish(new Error(`Failed to read input: ${error.message}`)));
|
|
1175
1187
|
output?.on('error', (error) => finish(new Error(`Failed to write output: ${error.message}`)));
|
|
1176
|
-
|
|
1188
|
+
trackWrite(output);
|
|
1177
1189
|
|
|
1178
1190
|
try {
|
|
1179
|
-
for (const
|
|
1180
|
-
const
|
|
1191
|
+
for (const command of commands) {
|
|
1192
|
+
const stderrSink = command.errorOutput
|
|
1193
|
+
? createWriteStream(redirectWritePath(command.errorOutput.path, sessionBase), { flags: command.errorOutput.append ? 'a' : 'w' })
|
|
1194
|
+
: null;
|
|
1195
|
+
if (stderrSink) {
|
|
1196
|
+
stderrSink.on('error', (error) => finish(new Error(`Failed to write standard error: ${error.message}`)));
|
|
1197
|
+
errorSinks.add(stderrSink);
|
|
1198
|
+
trackWrite(stderrSink);
|
|
1199
|
+
}
|
|
1181
1200
|
const child = spawn(commandPath(command.cmd), command.args, {
|
|
1182
1201
|
cwd: sessionBase,
|
|
1183
1202
|
shell: false,
|
|
@@ -1187,8 +1206,8 @@ function runCommandGroup(group, sessionBase) {
|
|
|
1187
1206
|
});
|
|
1188
1207
|
children.push(child);
|
|
1189
1208
|
child.on('error', (error) => finish(new Error(`Failed to execute ${command.cmd}: ${error.message}`)));
|
|
1190
|
-
if (
|
|
1191
|
-
child.stderr.pipe(
|
|
1209
|
+
if (stderrSink) {
|
|
1210
|
+
child.stderr.pipe(stderrSink);
|
|
1192
1211
|
} else if (!command.mergeErrorToOutput) {
|
|
1193
1212
|
child.stderr.on('data', (data) => {
|
|
1194
1213
|
stderr += data;
|
|
@@ -1196,8 +1215,8 @@ function runCommandGroup(group, sessionBase) {
|
|
|
1196
1215
|
});
|
|
1197
1216
|
}
|
|
1198
1217
|
child.on('close', () => {
|
|
1199
|
-
|
|
1200
|
-
|
|
1218
|
+
childrenClosed += 1;
|
|
1219
|
+
resolveAfterFlush();
|
|
1201
1220
|
});
|
|
1202
1221
|
}
|
|
1203
1222
|
for (let index = 0; index < children.length - 1; index++) {
|
package/src/App.tsx
CHANGED
|
@@ -386,12 +386,12 @@ function SessionHeader({
|
|
|
386
386
|
<button className="btn-ghost icon-tooltip" title={t('header.resetSessionTip')} onClick={() => setConfirmReset(true)}>
|
|
387
387
|
<Icon name="refresh" size={17} />
|
|
388
388
|
</button>
|
|
389
|
-
<button className="btn-ghost icon-tooltip" title={t('header.exportGameplayTip')} onClick={() => setExportOpen(true)}>
|
|
390
|
-
<Icon name="share" size={17} />
|
|
391
|
-
</button>
|
|
392
389
|
<button className="btn-ghost icon-tooltip danger" title={t('header.deleteTip')} onClick={() => setConfirmDelete(true)}>
|
|
393
390
|
<Icon name="trash" size={17} />
|
|
394
391
|
</button>
|
|
392
|
+
<button className="btn-ghost icon-tooltip chat-actions-export" title={t('header.exportGameplayTip')} onClick={() => setExportOpen(true)}>
|
|
393
|
+
<Icon name="share" size={17} />
|
|
394
|
+
</button>
|
|
395
395
|
<button
|
|
396
396
|
className="btn-ghost icon-tooltip chat-actions-more"
|
|
397
397
|
type="button"
|
|
@@ -422,9 +422,6 @@ function SessionHeader({
|
|
|
422
422
|
<button onClick={() => { setConfirmReset(true); setShowMobileActions(false); }}>
|
|
423
423
|
<Icon name="refresh" size={15} /> {t('header.resetSessionTip')}
|
|
424
424
|
</button>
|
|
425
|
-
<button onClick={() => { setExportOpen(true); setShowMobileActions(false); }}>
|
|
426
|
-
<Icon name="share" size={15} /> {t('nav.exportGameplay')}
|
|
427
|
-
</button>
|
|
428
425
|
<button className="danger" onClick={() => { setConfirmDelete(true); setShowMobileActions(false); }}>
|
|
429
426
|
<Icon name="trash" size={15} /> {t('header.deleteTip')}
|
|
430
427
|
</button>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "memory",
|
|
3
|
+
"tool": {
|
|
4
|
+
"type": "function",
|
|
5
|
+
"function": {
|
|
6
|
+
"name": "memory",
|
|
7
|
+
"description": "跨对话长期记忆:用本地文件 memory/memories.jsonl 存储/检索/更新记忆,自带条数上限与 LRU 淘汰(最近触发的排在前面,超限时自动遗忘最久未触发的末尾记忆),并能自建 HTML 看板。记忆一旦发生写入变化(store/touch/update/forget/search命中)会自动打开看板;config.json 里 autoShow=false 可关闭。只记录关键信息(玩家称呼/偏好/人物关系/重要事件),不记琐碎细节。action=load 开场读取;store 保存;touch 提升排序;search 检索(命中即触发);list 概况;update 按 id 修改;forget 遗忘;init 初始化;show 唤出看板。",
|
|
8
|
+
"parameters": {
|
|
9
|
+
"properties": {
|
|
10
|
+
"action": {
|
|
11
|
+
"description": "操作类型。load=读取记忆(默认,开场时调用);store=保存一条新记忆(自动置顶,超限则淘汰最久未触发的);touch=手动把某条/某些记忆提到最前面(相当于一次触发);search=按关键词检索(命中即视为触发,自动置顶);list=查看概况;update=按 id 修改(同时置顶);forget=遗忘(按 id/关键词/分类);init=初始化记忆存储与看板文件;show=自建(如缺失)并打开记忆看板。",
|
|
12
|
+
"enum": [
|
|
13
|
+
"load",
|
|
14
|
+
"store",
|
|
15
|
+
"touch",
|
|
16
|
+
"search",
|
|
17
|
+
"list",
|
|
18
|
+
"update",
|
|
19
|
+
"forget",
|
|
20
|
+
"init",
|
|
21
|
+
"show"
|
|
22
|
+
],
|
|
23
|
+
"type": "string"
|
|
24
|
+
},
|
|
25
|
+
"content": {
|
|
26
|
+
"description": "action=store/update 时必填。一句话概括、具体可独立理解的记忆内容。",
|
|
27
|
+
"type": "string"
|
|
28
|
+
},
|
|
29
|
+
"id": {
|
|
30
|
+
"description": "action=update 时必填的记忆 id;forget/touch 时也可用 id。",
|
|
31
|
+
"type": "string"
|
|
32
|
+
},
|
|
33
|
+
"ids": {
|
|
34
|
+
"description": "action=touch 时的记忆 id 列表(数组或逗号分隔字符串)。",
|
|
35
|
+
"type": "string"
|
|
36
|
+
},
|
|
37
|
+
"keyword": {
|
|
38
|
+
"description": "action=forget 时的关键词:记忆 id、内容关键词或分类名,匹配到的记忆会被删除。",
|
|
39
|
+
"type": "string"
|
|
40
|
+
},
|
|
41
|
+
"limit": {
|
|
42
|
+
"description": "load/search 返回条数上限,默认 load=100、search=20。",
|
|
43
|
+
"type": "integer"
|
|
44
|
+
},
|
|
45
|
+
"query": {
|
|
46
|
+
"description": "action=search 时的检索关键词(匹配内容、分类或 id);touch 时也可用 query 按内容匹配。",
|
|
47
|
+
"type": "string"
|
|
48
|
+
},
|
|
49
|
+
"topic": {
|
|
50
|
+
"description": "记忆分类,默认 general:user_preference=用户偏好(口味/喜好/称呼),world=世界设定(世界观/角色/地点),plot=剧情进展,agreement=约定与规则,general=其他。",
|
|
51
|
+
"enum": [
|
|
52
|
+
"user_preference",
|
|
53
|
+
"world",
|
|
54
|
+
"plot",
|
|
55
|
+
"agreement",
|
|
56
|
+
"general"
|
|
57
|
+
],
|
|
58
|
+
"type": "string"
|
|
59
|
+
},
|
|
60
|
+
"touch": {
|
|
61
|
+
"description": "action=load 时是否把读到的记忆算作一次触发(默认 false,纯读取不改变排序)。",
|
|
62
|
+
"type": "boolean"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"required": [
|
|
66
|
+
"action"
|
|
67
|
+
],
|
|
68
|
+
"type": "object"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"execution": {
|
|
73
|
+
"code": "const FILE = 'memory/memories.jsonl';\nconst DATA = 'memory/data.js';\nconst CONFIG = 'memory/config.json';\nconst BOARD = 'memory_board.html';\nconst MARK = '<!--MEMORY_BOARD v3-->';\nconst DEFAULT_MAX = 80;\nconst LABEL = { user_preference: '用户偏好', world: '世界设定', plot: '剧情进展', agreement: '约定', general: '其他' };\nconst ARG = (typeof input === 'object' && input) ? input : {};\nconst nowISO = () => new Date().toISOString();\nconst newId = () => 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);\n\nconst BOARD_HTML = `<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>记忆看板 · Memory Board</title>\n<!--MEMORY_BOARD v3-->\n<style>\n:root{--bg:#0b0a08;--p:#15120e;--p2:#1d1812;--ln:#3a2f22;--am:#e8b465;--dm:#a8843f;--tx:#efe6d8;--mu:#9a8b76;--rd:#d9756b}\n*{box-sizing:border-box}html,body{margin:0;min-height:100%}\nbody{background:radial-gradient(1200px 600px at 50% -10%,#241b10,transparent 60%),radial-gradient(900px 500px at 90% 110%,#1a1610,transparent 55%),var(--bg);color:var(--tx);font-family:\"PingFang SC\",\"Microsoft YaHei\",-apple-system,system-ui,sans-serif;padding:26px 18px 60px}\n.w{max-width:940px;margin:0 auto}\nheader{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;padding-bottom:14px;border-bottom:1px solid var(--ln);margin-bottom:20px;flex-wrap:wrap}\nh1{margin:0;font-size:1.42rem;letter-spacing:.16em;font-weight:600;color:var(--am)}\nh1 small{display:block;font-size:.6rem;letter-spacing:.3em;color:var(--mu);margin-top:5px;font-weight:400}\n.st{font-size:.7rem;color:var(--mu);text-align:right;line-height:1.7;font-variant-numeric:tabular-nums}\n.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:10px;margin-bottom:18px}\n.sc{background:linear-gradient(160deg,var(--p2),var(--p));border:1px solid var(--ln);border-radius:11px;padding:13px 15px}\n.sc .n{font-size:1.45rem;font-weight:700;color:var(--am);font-variant-numeric:tabular-nums;line-height:1.1}\n.sc .n small{font-size:.78rem;color:var(--mu);font-weight:400}\n.sc .l{font-size:.7rem;color:var(--mu);margin-top:5px;letter-spacing:.06em}\n.bar{height:5px;border-radius:9px;background:#241c14;margin-top:9px;overflow:hidden}\n.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--dm),var(--am));border-radius:9px;transition:width .4s}\ninput[type=search]{width:100%;background:var(--p);border:1px solid var(--ln);color:var(--tx);border-radius:9px;padding:10px 13px;font-size:.85rem;outline:none;margin-bottom:12px}\ninput[type=search]:focus{border-color:var(--dm)}input[type=search]::placeholder{color:#6d6152}\n.chips{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:16px}\n.chip{background:var(--p);border:1px solid var(--ln);color:var(--mu);border-radius:99px;padding:6px 13px;font-size:.76rem;cursor:pointer;user-select:none;white-space:nowrap;transition:.18s}\n.chip:hover{border-color:var(--dm);color:var(--tx)}\n.chip.on{background:rgba(232,180,101,.14);border-color:var(--dm);color:var(--am)}\n.list{display:flex;flex-direction:column;gap:9px}\n.card{background:linear-gradient(160deg,var(--p2),var(--p));border:1px solid var(--ln);border-left:3px solid var(--dm);border-radius:11px;padding:12px 15px;display:flex;gap:11px;animation:in .3s ease both;transition:.16s}\n.card:hover{transform:translateX(2px);border-color:#4a3b28}\n.card.risk{border-left-color:var(--rd)}\n@keyframes in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}\n.rk{font-family:\"SF Mono\",Consolas,monospace;font-size:.68rem;color:#6d6152;min-width:20px;padding-top:3px;text-align:right}\n.bd{flex:1;min-width:0}\n.top{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin-bottom:6px}\n.badge{font-size:.64rem;padding:2px 8px;border-radius:99px;border:1px solid}\n.t-user_preference{color:#7dc98a;border-color:rgba(125,201,138,.35);background:rgba(125,201,138,.1)}\n.t-world{color:#7fa8d9;border-color:rgba(127,168,217,.35);background:rgba(127,168,217,.1)}\n.t-plot{color:var(--am);border-color:rgba(232,180,101,.35);background:rgba(232,180,101,.1)}\n.t-agreement{color:#b394d9;border-color:rgba(179,148,217,.35);background:rgba(179,148,217,.1)}\n.t-general{color:var(--mu);border-color:var(--ln);background:rgba(154,139,118,.08)}\n.rt{font-size:.6rem;padding:2px 7px;border-radius:99px;color:var(--rd);border:1px solid rgba(217,117,107,.4);background:rgba(217,117,107,.1)}\n.mt{font-size:.66rem;color:var(--mu);font-variant-numeric:tabular-nums}\n.id{margin-left:auto;font-size:.62rem;color:#6d6152;font-family:\"SF Mono\",Consolas,monospace;cursor:pointer}\n.id:hover{color:var(--am)}\n.ct{font-size:.88rem;line-height:1.68;white-space:pre-wrap;word-break:break-word}\nmark{background:rgba(232,180,101,.3);color:#fff2d8;border-radius:3px}\n.empty{text-align:center;padding:56px 18px;color:var(--mu);border:1px dashed var(--ln);border-radius:13px;font-size:.88rem;line-height:2}\n.empty .big{font-size:1.9rem;display:block;margin-bottom:9px;opacity:.6}\nfooter{margin-top:22px;text-align:center;font-size:.66rem;color:#5d5346;line-height:1.9}\n.toast{position:fixed;left:50%;bottom:26px;transform:translate(-50%,18px);background:var(--am);color:#20170c;font-size:.76rem;font-weight:600;padding:8px 16px;border-radius:99px;opacity:0;pointer-events:none;transition:.25s}\n.toast.on{opacity:1;transform:translate(-50%,0)}\n</style>\n</head>\n<body>\n<div class=\"w\">\n<header><h1>记忆看板<small>MEMORY BOARD</small></h1><div class=\"st\" id=\"stamp\">—</div></header>\n<div class=\"stats\">\n<div class=\"sc\"><div class=\"n\" id=\"sTotal\">0<small id=\"sMax\"> / 0</small></div><div class=\"l\">记忆条数 / 上限</div><div class=\"bar\"><i id=\"sBar\" style=\"width:0\"></i></div></div>\n<div class=\"sc\"><div class=\"n\" id=\"sTopic\">0</div><div class=\"l\">涉及分类</div></div>\n<div class=\"sc\"><div class=\"n\" id=\"sLast\">—</div><div class=\"l\">最近触发</div></div>\n</div>\n<input type=\"search\" id=\"q\" placeholder=\"检索记忆内容或分类…\" autocomplete=\"off\">\n<div class=\"chips\" id=\"chips\"></div>\n<div class=\"list\" id=\"list\"></div>\n<footer>按最近触发时间排序 · 上方最新,下方最旧<br>达到上限后,新记忆会把最下方最久未触发的记忆挤出去</footer>\n</div>\n<div class=\"toast\" id=\"toast\">已复制</div>\n<script src=\"memory/data.js\"></script>\n<script>\n(function(){\nvar L={user_preference:\"用户偏好\",world:\"世界设定\",plot:\"剧情进展\",agreement:\"约定\",general:\"其他\"};\nvar d=(window.MEMORY_DATA&&Array.isArray(window.MEMORY_DATA.memories))?window.MEMORY_DATA:{memories:[],updatedAt:null,max:0};\nvar S={topic:\"all\",q:\"\"};\nfunction $(i){return document.getElementById(i)}\nfunction lb(t){return L[t]||t||\"其他\"}\nfunction esc(s){return String(s).replace(/[&<>\"']/g,function(c){return {\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]})}\nfunction hl(s,q){\n var safe=esc(s);if(!q)return safe;\n var lq=q.toLowerCase(),ls=safe.toLowerCase(),o=\"\",i=0,j;\n while((j=ls.indexOf(lq,i))>=0){o+=safe.slice(i,j)+\"<mark>\"+safe.slice(j,j+lq.length)+\"</mark>\";i=j+lq.length}\n return o+safe.slice(i)\n}\nfunction ft(iso){\n if(!iso)return \"—\";var x=new Date(iso);if(isNaN(x.getTime()))return \"—\";\n function p(n){return String(n).padStart(2,\"0\")}\n return x.getFullYear()+\"-\"+p(x.getMonth()+1)+\"-\"+p(x.getDate())+\" \"+p(x.getHours())+\":\"+p(x.getMinutes())\n}\nfunction ago(iso){\n if(!iso)return \"\";var t=new Date(iso).getTime();if(isNaN(t))return \"\";\n var s=Math.floor((Date.now()-t)/1000);\n if(s<60)return \"刚刚\";\n if(s<3600)return Math.floor(s/60)+\" 分钟前\";\n if(s<86400)return Math.floor(s/3600)+\" 小时前\";\n return Math.floor(s/86400)+\" 天前\"\n}\nfunction tps(){var seen={},out=[];d.memories.forEach(function(m){var t=m.topic||\"general\";if(!seen[t]){seen[t]=1;out.push(t)}});return out}\nfunction stats(){\n var n=d.memories.length,mx=d.max||0;\n $(\"sTotal\").innerHTML=n+'<small id=\"sMax\"> / '+mx+\"</small>\";\n $(\"sTopic\").textContent=tps().length;\n var pct=mx?Math.min(100,Math.round(n/mx*100)):0,b=$(\"sBar\");\n b.style.width=pct+\"%\";\n b.style.background=pct>=90?\"linear-gradient(90deg,#a8553f,#d9756b)\":\"linear-gradient(90deg,#a8843f,#e8b465)\";\n $(\"sLast\").textContent=n?ago(d.memories[0].used):\"—\";\n $(\"stamp\").innerHTML=\"同步时间 \"+ft(d.updatedAt)+\"<br>\"+n+\" / \"+mx+\" 条 · 按触发时间排序\"\n}\nfunction chips(){\n var box=$(\"chips\"),all=[{k:\"all\",n:\"全部 (\"+d.memories.length+\")\"}];\n tps().forEach(function(t){\n var c=d.memories.filter(function(m){return (m.topic||\"general\")===t}).length;\n all.push({k:t,n:lb(t)+\" (\"+c+\")\"})\n });\n box.innerHTML=all.map(function(x){\n return '<div class=\"chip'+(S.topic===x.k?\" on\":\"\")+'\" data-topic=\"'+esc(x.k)+'\">'+esc(x.n)+\"</div>\"\n }).join(\"\");\n Array.prototype.forEach.call(box.children,function(el){\n el.onclick=function(){S.topic=el.getAttribute(\"data-topic\");chips();list()}\n })\n}\nfunction list(){\n var q=S.q.trim().toLowerCase(),mx=d.max||0,n=d.memories.length;\n var from=Math.max(0,n-5),near=mx>0&&n>=mx*0.8;\n var items=d.memories.filter(function(m){\n if(S.topic!==\"all\"&&(m.topic||\"general\")!==S.topic)return false;\n if(!q)return true;\n return (String(m.content||\"\")+\" \"+lb(m.topic)+\" \"+String(m.id||\"\")).toLowerCase().indexOf(q)>=0\n });\n var box=$(\"list\");\n if(!items.length){\n box.innerHTML='<div class=\"empty\"><span class=\"big\">✦</span>'+(n?\"没有匹配的记忆\":\"还没有任何记忆<br>对话中一旦有了值得记住的事,它就会出现在这里\")+\"</div>\";\n return\n }\n box.innerHTML=items.map(function(m,i){\n var t=m.topic||\"general\",idx=d.memories.indexOf(m),rk=idx+1;\n var risk=near&&idx>=from;\n return '<div class=\"card'+(risk?\" risk\":\"\")+'\" style=\"animation-delay:'+Math.min(i*22,260)+'ms\">'+\n '<div class=\"rk\">'+rk+'</div><div class=\"bd\"><div class=\"top\">'+\n '<span class=\"badge t-'+esc(t)+'\">'+esc(lb(t))+\"</span>\"+\n (risk?'<span class=\"rt\">最久未触发</span>':\"\")+\n '<span class=\"mt\">触发 '+esc(ago(m.used))+\"</span>\"+\n '<span class=\"id\" data-id=\"'+esc(m.id||\"\")+'\" title=\"点击复制 ID\">'+esc(m.id||\"\")+\"</span></div>\"+\n '<div class=\"ct\">'+hl(m.content||\"\",S.q.trim())+\"</div></div></div>\"\n }).join(\"\");\n Array.prototype.forEach.call(box.querySelectorAll(\".id\"),function(el){\n el.onclick=function(){\n var id=el.getAttribute(\"data-id\");if(!id)return;\n function done(){var t=$(\"toast\");t.classList.add(\"on\");setTimeout(function(){t.classList.remove(\"on\")},1200)}\n if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(id).then(done,done)}else{done()}\n }\n })\n}\n$(\"q\").addEventListener(\"input\",function(e){S.q=e.target.value;list()});\nstats();chips();list()\n})();\n</script>\n</body>\n</html>`;\n\nasync function rd(p) { try { const t = await $read(p); return t == null ? '' : String(t); } catch (e) { return null; } }\n\nasync function getCfg() {\n const raw = await rd(CONFIG);\n let max = DEFAULT_MAX, autoShow = true;\n if (raw) {\n try {\n const o = JSON.parse(raw);\n const n = Number(o.max);\n if (isFinite(n) && n >= 5 && n <= 1000) max = Math.floor(n);\n if (o.autoShow === false) autoShow = false;\n } catch (e) {}\n }\n return { max, autoShow };\n}\n\nfunction sortByUsed(list) {\n list.sort((a, b) => {\n const ua = a.used || a.time || '', ub = b.used || b.time || '';\n if (ua === ub) return String(a.id).localeCompare(String(b.id));\n return ua < ub ? 1 : -1;\n });\n return list;\n}\n\nasync function loadAll() {\n const raw = await rd(FILE);\n if (!raw) return [];\n const out = [];\n raw.split('\\n').forEach(line => {\n line = line.trim();\n if (!line) return;\n try {\n const o = JSON.parse(line);\n if (o && o.content) {\n if (!o.used) o.used = o.time || nowISO();\n out.push(o);\n }\n } catch (e) {}\n });\n return sortByUsed(out);\n}\n\nasync function sync(list, max) {\n await $write(DATA, 'window.MEMORY_DATA = ' + JSON.stringify({ updatedAt: nowISO(), count: list.length, max: max, memories: list }) + ';\\n');\n}\n\n// 排序后从末尾淘汰超限记忆:末尾 = 最久未触发\nasync function save(list) {\n sortByUsed(list);\n const { max } = await getCfg();\n const evicted = [];\n while (list.length > max) evicted.push(list.pop());\n await $write(FILE, list.map(m => JSON.stringify(m)).join('\\n') + (list.length ? '\\n' : ''));\n await sync(list, max);\n return evicted;\n}\n\nasync function ensureBoard(list) {\n const cur = await rd(BOARD);\n let wrote = false;\n if (cur == null || cur.indexOf(MARK) < 0) { await $write(BOARD, BOARD_HTML + '\\n'); wrote = true; }\n if (!(await rd(DATA))) { await sync(list || [], (await getCfg()).max); wrote = true; }\n if (!(await rd(CONFIG))) { await $write(CONFIG, JSON.stringify({ max: DEFAULT_MAX, autoShow: true }) + '\\n'); wrote = true; }\n return wrote;\n}\n\nconst fmt = m => '· [' + (m.id || '?') + '] (' + (LABEL[m.topic] || m.topic || '其他') + ') ' + m.content + (m.used ? ' @' + String(m.used).slice(0, 16).replace('T', ' ') : '');\nconst evt = a => a.length ? '\\n因超出上限淘汰最久未触发的 ' + a.length + ' 条:\\n' + a.map(fmt).join('\\n') : '';\n\nlet MEM_OUT;\ntry {\n const action = String(ARG.action || 'load');\n let list = await loadAll();\n const cfg = await getCfg();\n const max = cfg.max;\n let WROTE = false;\n\n if (action === 'load') {\n const lim = Math.max(1, Number(ARG.limit) || 100);\n const top = list.slice(0, lim);\n let touched = 0;\n // load 默认只读、不改排序;传 touch=true 才把读到的算作触发\n if (ARG.touch === true) {\n const t = Date.now();\n top.forEach((m, i) => { m.used = new Date(t - i).toISOString(); });\n touched = top.length;\n await save(list);\n WROTE = true;\n }\n MEM_OUT = {\n ok: true, action, count: list.length, max, status: list.length ? 'ok' : 'empty', touched, memories: top,\n text: list.length\n ? '记忆 ' + list.length + '/' + max + ' 条,以下为最近触发的 ' + top.length + ' 条(新→旧,末尾最易被淘汰):\\n' + top.map(fmt).join('\\n')\n : '(记忆为空,还没有任何记录)'\n };\n } else if (action === 'store') {\n const content = String(ARG.content || '').trim();\n if (!content) throw new Error('store 需要 content');\n const dup = list.find(m => String(m.content || '').trim() === content);\n if (dup) {\n dup.used = nowISO();\n const ev = await save(list);\n MEM_OUT = { ok: true, action, duplicate: true, id: dup.id, count: list.length, max, evicted: ev, text: '已有相同记忆 [' + dup.id + '],仅刷新触发时间,未重复写入。' + evt(ev) };\n } else {\n const t = String(ARG.topic || 'general');\n const topic = LABEL[t] ? t : 'general';\n const m = { id: newId(), topic, content, time: nowISO(), used: nowISO() };\n list.unshift(m);\n const ev = await save(list);\n MEM_OUT = {\n ok: true, action, id: m.id, topic, count: list.length, max, evicted: ev,\n text: '已记住 [' + m.id + '](' + LABEL[topic] + '):' + content + '\\n当前 ' + list.length + '/' + max + ' 条' + evt(ev)\n };\n }\n WROTE = true;\n } else if (action === 'touch') {\n const raw = ARG.ids || ARG.id || ARG.query || '';\n const keys = Array.isArray(raw) ? raw.map(String) : String(raw).split(',').map(s => s.trim()).filter(Boolean);\n if (!keys.length) throw new Error('touch 需要 ids(id,可逗号分隔)或 query');\n const hit = list.filter(m => keys.some(k => m.id === k || String(m.content || '').toLowerCase().includes(k.toLowerCase())));\n if (!hit.length) {\n MEM_OUT = { ok: true, action, touched: [], count: list.length, max, text: '没有找到要触发的记忆,未做改动。' };\n } else {\n const t = Date.now();\n hit.forEach((m, i) => { m.used = new Date(t - i).toISOString(); });\n const ev = await save(list);\n MEM_OUT = { ok: true, action, touched: hit, count: list.length, max, evicted: ev, text: '已把 ' + hit.length + ' 条记忆提到最前面:\\n' + hit.map(fmt).join('\\n') + evt(ev) };\n WROTE = true;\n }\n } else if (action === 'update') {\n const id = String(ARG.id || '').trim();\n const i = list.findIndex(m => m.id === id);\n if (i < 0) throw new Error('找不到记忆 id:' + id);\n const content = String(ARG.content || '').trim();\n if (content) list[i].content = content;\n if (ARG.topic && LABEL[ARG.topic]) list[i].topic = ARG.topic;\n list[i].updated = nowISO();\n list[i].used = nowISO();\n const ev = await save(list);\n MEM_OUT = { ok: true, action, id, count: list.length, max, memory: list[i], evicted: ev, text: '已更新 [' + id + '](并提到最前面):' + list[i].content + evt(ev) };\n WROTE = true;\n } else if (action === 'forget') {\n const key = String(ARG.keyword || ARG.id || '').trim();\n if (!key) throw new Error('forget 需要 keyword(记忆 id 或关键词)');\n const low = key.toLowerCase();\n const del = list.filter(m => m.id === key || String(m.content || '').toLowerCase().includes(low) || m.topic === key || (LABEL[m.topic] || '') === key);\n if (!del.length) {\n MEM_OUT = { ok: true, action, removed: [], count: list.length, max, text: '没有找到匹配「' + key + '」的记忆,未做改动。' };\n } else {\n const ids = new Set(del.map(m => m.id));\n list = list.filter(m => !ids.has(m.id));\n await save(list);\n MEM_OUT = { ok: true, action, removed: del, count: list.length, max, text: '已遗忘 ' + del.length + ' 条记忆:\\n' + del.map(fmt).join('\\n') };\n WROTE = true;\n }\n } else if (action === 'search') {\n const q = String(ARG.query || '').trim();\n if (!q) throw new Error('search 需要 query');\n const low = q.toLowerCase();\n const lim = Math.max(1, Number(ARG.limit) || 20);\n const hit = list.filter(m => String(m.content || '').toLowerCase().includes(low) || String(m.id || '').toLowerCase().includes(low) || m.topic === q || (LABEL[m.topic] || '').includes(q)).slice(0, lim);\n // 检索命中即视为触发:提到最前,避免常用记忆被淘汰\n if (hit.length && ARG.touch !== false) {\n const t = Date.now();\n hit.forEach((m, i) => { m.used = new Date(t - i).toISOString(); });\n await save(list);\n WROTE = true;\n }\n MEM_OUT = {\n ok: true, action, query: q, count: hit.length, max, memories: hit,\n text: hit.length ? '找到 ' + hit.length + ' 条与「' + q + '」相关的记忆(已提到最前面):\\n' + hit.map(fmt).join('\\n') : '没有找到与「' + q + '」相关的记忆。'\n };\n } else if (action === 'list') {\n const byTopic = {};\n list.forEach(m => { const t = m.topic || 'general'; byTopic[t] = (byTopic[t] || 0) + 1; });\n const rows = Object.keys(byTopic).map(t => (LABEL[t] || t) + ':' + byTopic[t] + ' 条');\n MEM_OUT = {\n ok: true, action, count: list.length, max, byTopic, status: list.length ? 'ok' : 'empty',\n text: list.length\n ? '记忆概况:' + list.length + '/' + max + ' 条(' + rows.join(',') + ')\\n最近触发:' + String(list[0].used || '').slice(0, 16).replace('T', ' ') + '\\n最久未触发(下次写入时最先被淘汰):\\n' + list.slice(-3).reverse().map(fmt).join('\\n')\n : '记忆概况:暂无任何记忆(上限 ' + max + ' 条)'\n };\n } else if (action === 'init') {\n const wrote = await ensureBoard(list);\n await $write(CONFIG, JSON.stringify({ max: max, autoShow: cfg.autoShow }) + '\\n');\n const ev = await save(list);\n MEM_OUT = { ok: true, action, count: list.length, max, board: BOARD, data: DATA, config: CONFIG, rebuilt: wrote, evicted: ev, text: '已初始化记忆存储与看板:' + BOARD + (wrote ? '(本次重建)' : '(已是最新)') + ',上限 ' + max + ' 条。' };\n WROTE = ev.length > 0;\n } else if (action === 'show') {\n const rebuilt = await ensureBoard(list);\n await save(list);\n if (!(await rd(BOARD))) throw new Error('看板文件 ' + BOARD + ' 无法创建');\n await $display(BOARD, { title: '记忆看板' });\n MEM_OUT = { ok: true, action, count: list.length, max, rebuilt, text: '已打开记忆看板(' + list.length + '/' + max + ' 条)' + (rebuilt ? ',看板文件本次已自动初始化。' : '。') };\n } else {\n throw new Error('未知 action:' + action + '(可用 load / store / touch / update / search / list / forget / init / show)');\n }\n\n // 记忆有变化时主动打开看板;config.autoShow=false 可关闭\n if (WROTE && MEM_OUT.ok && cfg.autoShow) {\n await ensureBoard(list);\n await $display(BOARD, { title: '记忆看板' });\n MEM_OUT.shown = true;\n MEM_OUT.text += '\\n(记忆有更新,已打开看板)';\n }\n} catch (e) {\n MEM_OUT = { ok: false, error: String((e && e.message) || e) };\n}\nresult = MEM_OUT;\n",
|
|
74
|
+
"type": "javascript"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -40,3 +40,4 @@
|
|
|
40
40
|
|
|
41
41
|
### Skill Creation
|
|
42
42
|
1. **Separate data from presentation in HTML-based skills**: HTML is generally generated once as a static page. When data needs to be updated, the skill should write only the data file, while the page reads that data at runtime.
|
|
43
|
+
2. **Initialize HTML files in HTML-based skills**: Character working directories are isolated and cannot access each other's files. Each HTML-based skill must be able to create and initialize the HTML files it requires in its own working directory.
|
|
@@ -14,8 +14,6 @@ import { CSS } from '@dnd-kit/utilities';
|
|
|
14
14
|
import { useSortableSensors } from '../core/sortableSensors';
|
|
15
15
|
import { useStore } from '../store/store';
|
|
16
16
|
import {
|
|
17
|
-
BUILTIN_TAVERN_HANDBOOK_CONTENT,
|
|
18
|
-
builtinTavernHandbookName,
|
|
19
17
|
db,
|
|
20
18
|
isBuiltinTavernHandbook,
|
|
21
19
|
} from '../db/database';
|
|
@@ -523,6 +521,7 @@ function WorldBookList({ books, onChanged }: {
|
|
|
523
521
|
characterBook: knowledge || undefined,
|
|
524
522
|
};
|
|
525
523
|
if (editing && 'id' in editing && editing.id != null) {
|
|
524
|
+
if (isBuiltinTavernHandbook(editing)) return;
|
|
526
525
|
await db.worldBooks.update(editing.id, data);
|
|
527
526
|
addToast(t('toast.wbUpdated'));
|
|
528
527
|
} else {
|
|
@@ -686,20 +685,14 @@ function WorldBookForm({
|
|
|
686
685
|
onClose: () => void;
|
|
687
686
|
}) {
|
|
688
687
|
const t = useT();
|
|
688
|
+
const isBuiltin = isBuiltinTavernHandbook(initial);
|
|
689
689
|
const [name, setName] = useState(initial.name);
|
|
690
690
|
const [content, setContent] = useState(initial.content);
|
|
691
691
|
const [characterBook, setCharacterBook] = useState(initial.characterBook ?? '');
|
|
692
|
-
const [renderMarkdown, setRenderMarkdown] = useState(
|
|
693
|
-
const isBuiltin = isBuiltinTavernHandbook(initial);
|
|
692
|
+
const [renderMarkdown, setRenderMarkdown] = useState(isBuiltin);
|
|
694
693
|
const knowledge = useMemo(() => characterBookEntryStats(characterBook), [characterBook]);
|
|
695
694
|
const isEditing = 'id' in initial && initial.id != null;
|
|
696
695
|
|
|
697
|
-
const resetToDefault = () => {
|
|
698
|
-
setName(builtinTavernHandbookName());
|
|
699
|
-
setContent(BUILTIN_TAVERN_HANDBOOK_CONTENT);
|
|
700
|
-
setCharacterBook('');
|
|
701
|
-
};
|
|
702
|
-
|
|
703
696
|
/** 批量启用/停用知识库条目(SillyTavern 导入的条目可能默认停用)。 */
|
|
704
697
|
const setAllEntriesEnabled = (enabled: boolean) => {
|
|
705
698
|
try {
|
|
@@ -735,8 +728,13 @@ function WorldBookForm({
|
|
|
735
728
|
{tab === 'worldbook' && (
|
|
736
729
|
<div className="raw-wrap-toggle">
|
|
737
730
|
<span className="raw-wrap-label">{t('workshop.wbMarkdownRender')}</span>
|
|
738
|
-
<label className=
|
|
739
|
-
<input
|
|
731
|
+
<label className={`switch${isBuiltin ? ' disabled' : ''}`}>
|
|
732
|
+
<input
|
|
733
|
+
type="checkbox"
|
|
734
|
+
checked={renderMarkdown}
|
|
735
|
+
disabled={isBuiltin}
|
|
736
|
+
onChange={(e) => setRenderMarkdown(e.target.checked)}
|
|
737
|
+
/>
|
|
740
738
|
<span className="switch-slider" />
|
|
741
739
|
</label>
|
|
742
740
|
</div>
|
|
@@ -760,7 +758,9 @@ function WorldBookForm({
|
|
|
760
758
|
<div className="field" style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
761
759
|
<label>{t('workshop.wbContentLabel')}</label>
|
|
762
760
|
{!renderMarkdown ? (
|
|
763
|
-
|
|
761
|
+
isBuiltin
|
|
762
|
+
? <div className="bubble preview-scroll wb-render-pane"><Markdown text={content} /></div>
|
|
763
|
+
: <textarea className="textarea grow-textarea" value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('workshop.wbContentPh')} />
|
|
764
764
|
) : (
|
|
765
765
|
<div className="bubble preview-scroll wb-render-pane">
|
|
766
766
|
{content ? <Markdown text={content} /> : <span className="typo-label text-muted">{t('workshop.wbContentPh')}</span>}
|
|
@@ -771,7 +771,7 @@ function WorldBookForm({
|
|
|
771
771
|
{tab === 'knowledge' && (
|
|
772
772
|
<div className="field prompt-field character-book-field">
|
|
773
773
|
<label>{t('workshop.wbKnowledgeLabel')}</label>
|
|
774
|
-
{knowledge.total > 0 && (
|
|
774
|
+
{knowledge.total > 0 && !isBuiltin && (
|
|
775
775
|
<div className="knowledge-bulk-actions">
|
|
776
776
|
<span>{t('workshop.wbKnowledgeMeta', { enabled: knowledge.enabled, total: knowledge.total })}</span>
|
|
777
777
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setAllEntriesEnabled(true)}>
|
|
@@ -785,21 +785,25 @@ function WorldBookForm({
|
|
|
785
785
|
<Suspense fallback={<div className="character-book-editor-loading" />}>
|
|
786
786
|
<CharacterBookEditor
|
|
787
787
|
value={characterBook}
|
|
788
|
-
onChange={setCharacterBook}
|
|
788
|
+
onChange={isBuiltin ? undefined : setCharacterBook}
|
|
789
789
|
placeholder={t('workshop.wbKnowledgePh')}
|
|
790
|
+
readOnly={isBuiltin}
|
|
790
791
|
/>
|
|
791
792
|
</Suspense>
|
|
792
793
|
</div>
|
|
793
794
|
)}
|
|
794
795
|
</div>
|
|
795
796
|
<div className="modal-foot">
|
|
796
|
-
{isBuiltin && (
|
|
797
|
-
<button className="btn" type="button" onClick={resetToDefault} title={t('workshop.resetWbTip')}>
|
|
798
|
-
<Icon name="refresh" size={12} /> {t('workshop.resetWb')}
|
|
799
|
-
</button>
|
|
800
|
-
)}
|
|
801
797
|
<button className="btn" onClick={onClose}>{t('common.cancel')}</button>
|
|
802
|
-
<button
|
|
798
|
+
<button
|
|
799
|
+
className="btn btn-primary"
|
|
800
|
+
type="button"
|
|
801
|
+
disabled={isBuiltin}
|
|
802
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
803
|
+
onClick={() => onSave(name, content, characterBook)}
|
|
804
|
+
>
|
|
805
|
+
{t('common.save')}
|
|
806
|
+
</button>
|
|
803
807
|
</div>
|
|
804
808
|
</>
|
|
805
809
|
);
|
|
@@ -818,6 +822,7 @@ function SkillList() {
|
|
|
818
822
|
const addToast = useStore((s) => s.addToast);
|
|
819
823
|
const t = useT();
|
|
820
824
|
const refresh = useStore((s) => s.refreshTools);
|
|
825
|
+
const refreshNpcs = useStore((s) => s.refreshNpcs);
|
|
821
826
|
const [pendingDelete, setPendingDelete] = useState<McpTool | null>(null);
|
|
822
827
|
const [detail, setDetail] = useState<McpTool | null>(null);
|
|
823
828
|
|
|
@@ -844,8 +849,16 @@ function SkillList() {
|
|
|
844
849
|
const sensors = useSortableSensors();
|
|
845
850
|
|
|
846
851
|
const deleteTool = async (tt: McpTool) => {
|
|
847
|
-
await db.tools.
|
|
848
|
-
|
|
852
|
+
await db.transaction('rw', db.tools, db.npcs, async () => {
|
|
853
|
+
await db.tools.delete(tt.id!);
|
|
854
|
+
const npcs = await db.npcs.toArray();
|
|
855
|
+
await Promise.all(npcs
|
|
856
|
+
.filter((npc) => npc.enabledToolNames.includes(tt.name))
|
|
857
|
+
.map((npc) => db.npcs.update(npc.id!, {
|
|
858
|
+
enabledToolNames: npc.enabledToolNames.filter((toolName) => toolName !== tt.name),
|
|
859
|
+
})));
|
|
860
|
+
});
|
|
861
|
+
await Promise.all([refresh(), refreshNpcs()]);
|
|
849
862
|
addToast(t('toast.skillDeleted'));
|
|
850
863
|
};
|
|
851
864
|
|