tavern-harness 0.1.23 → 0.1.25
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 +62 -0
- package/package.json +1 -1
- package/sandbox-server.mjs +3 -2
- package/src/App.tsx +17 -6
- package/src/components/CharactersView.tsx +16 -16
- package/src/components/ChatView.tsx +1 -1
- package/src/components/ConfirmationDialog.tsx +3 -3
- package/src/components/DeleteConfirmDialog.tsx +1 -1
- package/src/components/FileDisplayModal.tsx +1 -1
- package/src/components/GameplayDialogs.tsx +2 -2
- package/src/components/NewSessionMenu.tsx +50 -45
- package/src/components/SettingsView.tsx +4 -4
- package/src/components/Sidebar.tsx +20 -12
- package/src/components/StatsView.tsx +5 -5
- package/src/components/VersionHistoryModal.tsx +1 -1
- package/src/components/WorkspaceFileManagerModal.tsx +20 -22
- package/src/core/i18n.ts +12 -6
- package/src/core/tools/builtinTools.ts +3 -5
- package/src/core/tools/generatedSkillExecutor.ts +3 -3
- package/src/core/tools/toolExecutor.ts +13 -14
- package/src/generated/changelog.json +89 -1
- package/src/store/store.ts +42 -32
- package/src/theme/chat.css +172 -153
- package/src/theme/global.css +289 -140
- package/src/theme/views.css +257 -201
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,66 @@
|
|
|
3
3
|
> 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
|
|
4
4
|
> 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
|
|
5
5
|
|
|
6
|
+
## [0.1.25] - 2026-10-27
|
|
7
|
+
|
|
8
|
+
### 中文
|
|
9
|
+
|
|
10
|
+
#### 内部
|
|
11
|
+
|
|
12
|
+
- 统一了标题、对话框、工具卡片等界面文字的字体排印样式,改用共享的排版类和样式变量,后续调整字号、字重只需改一处
|
|
13
|
+
|
|
14
|
+
### English
|
|
15
|
+
|
|
16
|
+
#### Internal
|
|
17
|
+
|
|
18
|
+
- Unified the typography of headings, dialogs, tool cards, and other interface text behind shared classes and style variables, so future font-size and weight changes happen in one place
|
|
19
|
+
|
|
20
|
+
## [0.1.24] - 2026-10-27
|
|
21
|
+
|
|
22
|
+
### 中文
|
|
23
|
+
|
|
24
|
+
#### 改进
|
|
25
|
+
|
|
26
|
+
- 工作区文件管理器现在可以直接进入 public 目录,并预览其中的网页、图片等文件
|
|
27
|
+
- 文件管理器的路径导航移到文件列表上方,窄屏下标题栏不再换行,右侧按钮也不会被挤掉
|
|
28
|
+
- 手机上点击顶部导航的「聊天 / 角色 / 成就 / 设置」或返回首页后,侧边栏会自动收起
|
|
29
|
+
- 手机端的工具调用卡片改用等宽字体并加粗,长工具名和参数更容易看清
|
|
30
|
+
- 输入框提示精简为「输入消息...」,不再把指令说明塞进占位文字
|
|
31
|
+
- 技能列表与原文编辑器的字体、名称截断和「内置」标签对齐统一
|
|
32
|
+
- 工具返回给模型的结果统一使用英文,切换界面语言不再影响模型读取;工具卡片中的结果行也随之显示英文
|
|
33
|
+
|
|
34
|
+
#### 修复
|
|
35
|
+
|
|
36
|
+
- 删除会话与会话设置保存改为事务写入,中途出错不会再留下只删了一半的数据
|
|
37
|
+
- 连续点击「删除」「保存」不会再重复提交,也不会卡在加载状态
|
|
38
|
+
- 创建、编辑、删除会话失败时会提示具体原因,而不是静默失败
|
|
39
|
+
|
|
40
|
+
#### 内部
|
|
41
|
+
|
|
42
|
+
- 清理工具执行路径中不再需要的界面文案依赖
|
|
43
|
+
|
|
44
|
+
### English
|
|
45
|
+
|
|
46
|
+
#### Changed
|
|
47
|
+
|
|
48
|
+
- The workspace file manager can now browse into `public` and preview pages, images, and other files stored there
|
|
49
|
+
- The file manager's path breadcrumb now sits above the file list, and the header no longer wraps on narrow screens or pushes its buttons aside
|
|
50
|
+
- On mobile, tapping a top navigation tab (Chat, Characters, Achievements, Settings) or going home now closes the sidebar
|
|
51
|
+
- Tool call cards on mobile use a monospace, bolder typeface, so long tool names and arguments are easier to read
|
|
52
|
+
- The composer placeholder is now simply "Type a message..." instead of packing command hints into it
|
|
53
|
+
- The skill list and raw log editor now share consistent typography, name truncation, and Built-in tag alignment
|
|
54
|
+
- Tool results returned to the model are always English now, so switching the UI language no longer affects what the model reads; the result rows in tool cards show English as well
|
|
55
|
+
|
|
56
|
+
#### Fixed
|
|
57
|
+
|
|
58
|
+
- Deleting a session and saving session settings are now transactional, so a failure can no longer leave half-deleted data behind
|
|
59
|
+
- Repeatedly clicking delete or save no longer sends duplicate requests or leaves the dialog stuck loading
|
|
60
|
+
- Creating, editing, or deleting a session now reports the actual error instead of failing silently
|
|
61
|
+
|
|
62
|
+
#### Internal
|
|
63
|
+
|
|
64
|
+
- Removed the no-longer-needed UI string dependency from the tool execution path
|
|
65
|
+
|
|
6
66
|
## [0.1.23] - 2026-10-26
|
|
7
67
|
|
|
8
68
|
### 中文
|
|
@@ -656,6 +716,8 @@
|
|
|
656
716
|
- Vite + React + Zustand + Dexie stack with browser-side local persistence
|
|
657
717
|
|
|
658
718
|
<!-- 链接定义 -->
|
|
719
|
+
[0.1.25]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.24...v0.1.25
|
|
720
|
+
[0.1.24]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.23...v0.1.24
|
|
659
721
|
[0.1.23]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.22...v0.1.23
|
|
660
722
|
[0.1.22]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.21...v0.1.22
|
|
661
723
|
[0.1.21]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.20...v0.1.21
|
package/package.json
CHANGED
package/sandbox-server.mjs
CHANGED
|
@@ -596,10 +596,11 @@ const server = createServer(async (req, res) => {
|
|
|
596
596
|
const payload = JSON.parse(body);
|
|
597
597
|
const base = resolveSessionBase(payload?.session);
|
|
598
598
|
const rel = sanitizeWorkspaceRelativePath(payload?.path);
|
|
599
|
-
|
|
599
|
+
const readingPublicLink = base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'));
|
|
600
|
+
if (!readingPublicLink && resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
|
|
600
601
|
const file = resolve(base, rel);
|
|
601
602
|
if (!existsSync(file) || !lstatSync(file).isFile()) throw new Error('File not found');
|
|
602
|
-
assertInside(base, realpathSync(file));
|
|
603
|
+
assertInside(readingPublicLink ? PUBLIC_WORKSPACE : base, realpathSync(file));
|
|
603
604
|
const content = await readFilePromise(file);
|
|
604
605
|
if (content.length > 8 * 1024 * 1024) throw new Error('Preview file exceeds the 8MB limit');
|
|
605
606
|
res.end(JSON.stringify({ ok: true, content: content.toString('base64') }));
|
package/src/App.tsx
CHANGED
|
@@ -165,7 +165,8 @@ export default function App() {
|
|
|
165
165
|
type="button"
|
|
166
166
|
disabled={takingControl}
|
|
167
167
|
onClick={() => void handleTakeOverControl()}
|
|
168
|
-
|
|
168
|
+
className="control-takeover-btn"
|
|
169
|
+
style={{ cursor: takingControl ? 'wait' : 'pointer' }}
|
|
169
170
|
>
|
|
170
171
|
{t('control.takeOverHere')}
|
|
171
172
|
</button>
|
|
@@ -325,6 +326,7 @@ function SessionHeader({
|
|
|
325
326
|
const [showEdit, setShowEdit] = useState(false);
|
|
326
327
|
const [confirmReset, setConfirmReset] = useState(false);
|
|
327
328
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
329
|
+
const [deletingSession, setDeletingSession] = useState(false);
|
|
328
330
|
const [exportOpen, setExportOpen] = useState(false);
|
|
329
331
|
const [showWorkspace, setShowWorkspace] = useState(false);
|
|
330
332
|
const [showMobileActions, setShowMobileActions] = useState(false);
|
|
@@ -447,7 +449,7 @@ function SessionHeader({
|
|
|
447
449
|
{confirmReset && (
|
|
448
450
|
<Modal onClose={() => setConfirmReset(false)} size="compact">
|
|
449
451
|
<div className="modal-head">
|
|
450
|
-
<span
|
|
452
|
+
<span className="typo-dialog-title">{t('header.resetSessionTitle')}</span>
|
|
451
453
|
<button className="icon-btn" onClick={() => setConfirmReset(false)}><Icon name="x" /></button>
|
|
452
454
|
</div>
|
|
453
455
|
<div className="modal-body">
|
|
@@ -472,7 +474,7 @@ function SessionHeader({
|
|
|
472
474
|
{confirmDelete && (
|
|
473
475
|
<Modal onClose={() => setConfirmDelete(false)} size="compact">
|
|
474
476
|
<div className="modal-head">
|
|
475
|
-
<span
|
|
477
|
+
<span className="typo-dialog-title">{t('header.deleteTitle')}</span>
|
|
476
478
|
<button className="icon-btn" onClick={() => setConfirmDelete(false)}><Icon name="x" /></button>
|
|
477
479
|
</div>
|
|
478
480
|
<div className="modal-body">
|
|
@@ -482,10 +484,19 @@ function SessionHeader({
|
|
|
482
484
|
<button className="btn" onClick={() => setConfirmDelete(false)}>{t('common.cancel')}</button>
|
|
483
485
|
<button
|
|
484
486
|
className="btn btn-danger"
|
|
487
|
+
disabled={deletingSession}
|
|
485
488
|
onClick={async () => {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
+
if (deletingSession) return;
|
|
490
|
+
setDeletingSession(true);
|
|
491
|
+
try {
|
|
492
|
+
await deleteSession(session.id!);
|
|
493
|
+
setConfirmDelete(false);
|
|
494
|
+
addToast(t('toast.sessionDeleted'));
|
|
495
|
+
} catch (error) {
|
|
496
|
+
addToast(t('toast.sessionDeleteFailed', { msg: (error as Error).message }), 'error');
|
|
497
|
+
} finally {
|
|
498
|
+
setDeletingSession(false);
|
|
499
|
+
}
|
|
489
500
|
}}
|
|
490
501
|
>
|
|
491
502
|
<Icon name="trash" size={13} /> {t('common.delete')}
|
|
@@ -316,7 +316,7 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
316
316
|
return (
|
|
317
317
|
<Modal onClose={onClose} className="modal-editor editor-modal-with-tabs character-editor-modal">
|
|
318
318
|
<div className="modal-head">
|
|
319
|
-
<span
|
|
319
|
+
<span className="typo-dialog-title">{isNew ? t('workshop.newCharTitle') : t('workshop.editCharTitle')}</span>
|
|
320
320
|
<div className="raw-tabs">
|
|
321
321
|
<button className={`raw-tab ${editorTab === 'character' ? 'active' : ''}`} onClick={() => setEditorTab('character')}>{t('workshop.characterTab')}</button>
|
|
322
322
|
<button
|
|
@@ -371,7 +371,7 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
371
371
|
<textarea className="textarea grow-textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder={t('workshop.promptPh')} />
|
|
372
372
|
) : (
|
|
373
373
|
<div className="bubble preview-scroll wb-render-pane char-prompt-render-pane">
|
|
374
|
-
{prompt ? <Markdown text={prompt} /> : <span
|
|
374
|
+
{prompt ? <Markdown text={prompt} /> : <span className="typo-label text-muted">{t('workshop.promptPh')}</span>}
|
|
375
375
|
</div>
|
|
376
376
|
)}
|
|
377
377
|
</div>
|
|
@@ -391,7 +391,7 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
391
391
|
/>
|
|
392
392
|
) : (
|
|
393
393
|
<div className="bubble preview-scroll wb-render-pane greeting-render-pane">
|
|
394
|
-
{g ? <Markdown text={g} /> : <span
|
|
394
|
+
{g ? <Markdown text={g} /> : <span className="typo-label text-muted">{t('workshop.greetingPh')}</span>}
|
|
395
395
|
</div>
|
|
396
396
|
)}
|
|
397
397
|
{!renderMarkdown && (
|
|
@@ -415,7 +415,7 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
415
415
|
<div className="field" style={{ flexShrink: 0 }}>
|
|
416
416
|
<label>{t('workshop.enableSkills', { a: enabledToolNames.filter((s) => tools.some((t) => t.name === s)).length, b: tools.length })}</label>
|
|
417
417
|
{tools.length === 0 ? (
|
|
418
|
-
<div
|
|
418
|
+
<div className="typo-label text-muted">{t('workshop.noSkills')}</div>
|
|
419
419
|
) : (
|
|
420
420
|
<div className="skill-list-scroll">
|
|
421
421
|
{tools.map((t) => (
|
|
@@ -556,7 +556,7 @@ function WorldBookList({ books, onChanged }: {
|
|
|
556
556
|
|
|
557
557
|
return (
|
|
558
558
|
<div>
|
|
559
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
559
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
560
560
|
<span className="group-label" style={{ margin: 0 }}>{t('workshop.wbCount', { n: books.length })}</span>
|
|
561
561
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
562
562
|
<button className="btn btn-sm" onClick={() => jsonFileRef.current?.click()} title={t('workshop.jsonImportTip')}>
|
|
@@ -595,7 +595,7 @@ function WorldBookList({ books, onChanged }: {
|
|
|
595
595
|
return (
|
|
596
596
|
<div key={b.id} className="char-card wb-card fade-up">
|
|
597
597
|
<div className="cname">
|
|
598
|
-
<span
|
|
598
|
+
<span className="character-book-icon">📖</span>
|
|
599
599
|
<ScrollingCardName name={b.name} />
|
|
600
600
|
{isBuiltin && <span className="tag">{t('common.builtin')}</span>}
|
|
601
601
|
</div>
|
|
@@ -712,7 +712,7 @@ function WorldBookForm({
|
|
|
712
712
|
return (
|
|
713
713
|
<>
|
|
714
714
|
<div className="modal-head">
|
|
715
|
-
<span
|
|
715
|
+
<span className="typo-dialog-title">
|
|
716
716
|
{isEditing ? t('workshop.editWbTitle') : t('workshop.newWbTitle')}
|
|
717
717
|
</span>
|
|
718
718
|
<div className="raw-tabs">
|
|
@@ -755,7 +755,7 @@ function WorldBookForm({
|
|
|
755
755
|
<textarea className="textarea grow-textarea" value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('workshop.wbContentPh')} />
|
|
756
756
|
) : (
|
|
757
757
|
<div className="bubble preview-scroll wb-render-pane">
|
|
758
|
-
{content ? <Markdown text={content} /> : <span
|
|
758
|
+
{content ? <Markdown text={content} /> : <span className="typo-label text-muted">{t('workshop.wbContentPh')}</span>}
|
|
759
759
|
</div>
|
|
760
760
|
)}
|
|
761
761
|
</div>
|
|
@@ -960,14 +960,14 @@ function SkillSortItem({
|
|
|
960
960
|
{...attributes}
|
|
961
961
|
{...listeners}
|
|
962
962
|
>
|
|
963
|
-
<div
|
|
963
|
+
<div className="tool-type-icon">{tool.isBuiltIn ? '🧰' : '⚙️'}</div>
|
|
964
964
|
<div className="l-main">
|
|
965
965
|
<div className="l-title">
|
|
966
|
-
{tool.name}
|
|
967
|
-
{tool.isBuiltIn && <span className="tag"
|
|
966
|
+
<span className="skill-card-name">{tool.name}</span>
|
|
967
|
+
{tool.isBuiltIn && <span className="tag">{t('common.builtin')}</span>}
|
|
968
968
|
</div>
|
|
969
969
|
<div className="l-sub">{desc}</div>
|
|
970
|
-
{tool.isBuiltIn && impl !== 'native' && <div
|
|
970
|
+
{tool.isBuiltIn && impl !== 'native' && <div className="tool-implementation-type">{t('workshop.implType', { t: impl })}</div>}
|
|
971
971
|
</div>
|
|
972
972
|
<div className="skill-item-actions">
|
|
973
973
|
<button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); onDetail(); }} title={t('workshop.viewDetails')}>
|
|
@@ -1063,7 +1063,7 @@ function SkillDetailModal({ tool, onClose }: { tool: McpTool; onClose: () => voi
|
|
|
1063
1063
|
return (
|
|
1064
1064
|
<Modal onClose={onClose}>
|
|
1065
1065
|
<div className="modal-head">
|
|
1066
|
-
<span
|
|
1066
|
+
<span className="typo-dialog-title">
|
|
1067
1067
|
{t('workshop.skillDetails')}
|
|
1068
1068
|
{tool.isBuiltIn && <span className="tag" style={{ marginLeft: 8 }}>{t('common.builtin')}</span>}
|
|
1069
1069
|
{!tool.isBuiltIn && execType !== 'native' && <span className="tag" style={{ marginLeft: 8 }}>{t('workshop.implType', { t: execType })}</span>}
|
|
@@ -1081,17 +1081,17 @@ function SkillDetailModal({ tool, onClose }: { tool: McpTool; onClose: () => voi
|
|
|
1081
1081
|
</div>
|
|
1082
1082
|
<div className="field">
|
|
1083
1083
|
<label>{t('workshop.skillParameters')}</label>
|
|
1084
|
-
{editable ? <textarea className="textarea mono skill-detail-textarea" value={parametersText} wrap="soft" spellCheck={false} onChange={(e) => setParametersText(e.target.value)} /> : parametersText ? <pre className="skill-detail-json">{parametersText}</pre> : <div className="skill-detail-text
|
|
1084
|
+
{editable ? <textarea className="textarea mono skill-detail-textarea" value={parametersText} wrap="soft" spellCheck={false} onChange={(e) => setParametersText(e.target.value)} /> : parametersText ? <pre className="skill-detail-json">{parametersText}</pre> : <div className="skill-detail-text text-muted">{t('workshop.skillNoParams')}</div>}
|
|
1085
1085
|
</div>
|
|
1086
1086
|
<div className="field">
|
|
1087
1087
|
<label>{t('workshop.skillExecution')}</label>
|
|
1088
|
-
{exec ? <pre className="skill-detail-json">{JSON.stringify(exec, null, 2)}</pre> : <div className="skill-detail-text
|
|
1088
|
+
{exec ? <pre className="skill-detail-json">{JSON.stringify(exec, null, 2)}</pre> : <div className="skill-detail-text text-muted">{t('workshop.skillNoExecution')}</div>}
|
|
1089
1089
|
</div>
|
|
1090
1090
|
<div className="field">
|
|
1091
1091
|
<label>{t('workshop.skillRawJson')}</label>
|
|
1092
1092
|
<pre className={`skill-detail-json${rawJson ? '' : ' err'}`}>{rawJson ?? schema.error}</pre>
|
|
1093
1093
|
</div>
|
|
1094
|
-
{error && <div className="field-hint
|
|
1094
|
+
{error && <div className="field-hint text-danger">{error}</div>}
|
|
1095
1095
|
</div>
|
|
1096
1096
|
<div className="modal-foot">
|
|
1097
1097
|
<button className="btn" onClick={onClose}>{t('common.cancel')}</button>
|
|
@@ -1465,7 +1465,7 @@ export function MessageMenu() {
|
|
|
1465
1465
|
<Icon name={a.icon} size={13} /> {a.label}
|
|
1466
1466
|
</button>
|
|
1467
1467
|
))}
|
|
1468
|
-
{actions.length === 0 && <div
|
|
1468
|
+
{actions.length === 0 && <div className="chat-no-actions">{t('chat.noActions')}</div>}
|
|
1469
1469
|
</PopoverMenu>
|
|
1470
1470
|
);
|
|
1471
1471
|
}
|
|
@@ -23,16 +23,16 @@ export function ConfirmationDialog() {
|
|
|
23
23
|
<div className="modal-root">
|
|
24
24
|
<div className="modal card confirm fade-up">
|
|
25
25
|
<div className="modal-head">
|
|
26
|
-
<span
|
|
26
|
+
<span className="typo-dialog-title dialog-title-inline dialog-title-compact">
|
|
27
27
|
<Icon name="settings" size={15} /> {isLimit ? pending.title : t('confirm.title')}
|
|
28
28
|
</span>
|
|
29
29
|
</div>
|
|
30
30
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
31
|
-
{!isLimit && <div
|
|
31
|
+
{!isLimit && <div className="confirmation-tool-name">{t('confirm.tool')}<span className="mono text-accent">{pending.toolName}</span></div>}
|
|
32
32
|
{!pending.inlineCode && <pre className="confirm-args mono">{argsText}</pre>}
|
|
33
33
|
<div className={isLimit ? undefined : 'confirm-reason'}>
|
|
34
34
|
{!isLimit && <div className="confirm-reason-title">{pending.title}</div>}
|
|
35
|
-
<div
|
|
35
|
+
<div className="typo-confirm-reason">
|
|
36
36
|
{pending.inlineCode && <code className="confirm-inline-code">{pending.inlineCode}</code>}
|
|
37
37
|
{pending.message || (isLimit ? '' : t('confirm.modify'))}
|
|
38
38
|
</div>
|
|
@@ -25,7 +25,7 @@ export function DeleteConfirmDialog({
|
|
|
25
25
|
return (
|
|
26
26
|
<Modal onClose={onCancel} size="compact">
|
|
27
27
|
<div className="modal-head">
|
|
28
|
-
<span
|
|
28
|
+
<span className="typo-dialog-title">{title}</span>
|
|
29
29
|
<button className="icon-btn" onClick={onCancel}><Icon name="x" /></button>
|
|
30
30
|
</div>
|
|
31
31
|
<div className="modal-body">
|
|
@@ -466,7 +466,7 @@ export function FileDisplayModal() {
|
|
|
466
466
|
>
|
|
467
467
|
<span className="display-modal-title">
|
|
468
468
|
<Icon name="file" size={15} />
|
|
469
|
-
<span className="mono
|
|
469
|
+
<span className="mono typo-body-sm text-secondary">{active.path}</span>
|
|
470
470
|
<span className="display-title-text">{title}</span>
|
|
471
471
|
</span>
|
|
472
472
|
<div className="display-head-actions">
|
|
@@ -50,7 +50,7 @@ export function GameplayExportModal({
|
|
|
50
50
|
return (
|
|
51
51
|
<Modal onClose={onClose} size="compact">
|
|
52
52
|
<div className="modal-head">
|
|
53
|
-
<span
|
|
53
|
+
<span className="typo-dialog-title dialog-title-inline">
|
|
54
54
|
<Icon name="share" size={15} /> {t('gameplay.exportTitle')}
|
|
55
55
|
</span>
|
|
56
56
|
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
|
@@ -155,7 +155,7 @@ export function GameplayImportModal({
|
|
|
155
155
|
return (
|
|
156
156
|
<Modal onClose={onClose} size="compact">
|
|
157
157
|
<div className="modal-head">
|
|
158
|
-
<span
|
|
158
|
+
<span className="typo-dialog-title dialog-title-inline">
|
|
159
159
|
<Icon name="import" size={15} /> {t('gameplay.importTitle')}
|
|
160
160
|
</span>
|
|
161
161
|
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
|
@@ -52,6 +52,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
|
|
|
52
52
|
const [turnOrderMode, setTurnOrderMode] = useState<'PRESET' | 'RANDOM'>(editingSession?.turnOrderMode ?? 'PRESET');
|
|
53
53
|
const [mentionOnlyMode, setMentionOnlyMode] = useState(editingSession?.mentionOnlyMode === true);
|
|
54
54
|
const [enableGreeting, setEnableGreeting] = useState(editingSession?.enableGreeting !== false);
|
|
55
|
+
const [saving, setSaving] = useState(false);
|
|
55
56
|
const selectedNpcIds = participantOrder.filter((id) => id !== -1);
|
|
56
57
|
const modeLabel = selectedNpcIds.length === 0 ? t('newSession.notSelected') : selectedNpcIds.length === 1 ? t('newSession.npcChat') : t('newSession.groupChat', { n: selectedNpcIds.length });
|
|
57
58
|
const sensors = useSortableSensors();
|
|
@@ -77,55 +78,59 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
|
|
|
77
78
|
}, [editingSession?.id]);
|
|
78
79
|
|
|
79
80
|
const create = async () => {
|
|
81
|
+
if (saving) return;
|
|
80
82
|
if (selectedNpcIds.length === 0) {
|
|
81
83
|
addToast(t('toast.pickOneNpc'), 'error');
|
|
82
84
|
return;
|
|
83
85
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
86
|
+
setSaving(true);
|
|
87
|
+
try {
|
|
88
|
+
const finalTitle = title.trim() || selectedNpcIds.map((id) => npcs.find((n) => n.id === id)?.name).filter(Boolean).join('、');
|
|
89
|
+
if (isEditing && editingSession?.id) {
|
|
90
|
+
await updateSessionSettings(editingSession.id, {
|
|
91
|
+
title: finalTitle,
|
|
92
|
+
npcIds: selectedNpcIds,
|
|
93
|
+
worldBookId,
|
|
94
|
+
userPersonaNpcId,
|
|
95
|
+
turnOrderMode,
|
|
96
|
+
participantOrder,
|
|
97
|
+
mentionOnlyMode,
|
|
98
|
+
enableGreeting,
|
|
99
|
+
});
|
|
100
|
+
addToast(t('toast.sessionUpdated'));
|
|
101
|
+
onClose();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const sid = selectedNpcIds.length === 1
|
|
105
|
+
? await createSession('NPC', {
|
|
106
|
+
associatedId: selectedNpcIds[0],
|
|
107
|
+
title: finalTitle,
|
|
108
|
+
worldBookId,
|
|
109
|
+
userPersonaNpcId,
|
|
110
|
+
turnOrderMode,
|
|
111
|
+
participantOrder,
|
|
112
|
+
mentionOnlyMode,
|
|
113
|
+
enableGreeting,
|
|
114
|
+
})
|
|
115
|
+
: await createSession('GROUP', {
|
|
116
|
+
npcIds: selectedNpcIds,
|
|
117
|
+
title: finalTitle,
|
|
118
|
+
worldBookId,
|
|
119
|
+
userPersonaNpcId,
|
|
120
|
+
turnOrderMode,
|
|
121
|
+
participantOrder,
|
|
122
|
+
mentionOnlyMode,
|
|
123
|
+
enableGreeting,
|
|
124
|
+
});
|
|
96
125
|
await useStore.getState().refreshSessions();
|
|
97
|
-
useStore.getState().setActiveSession(
|
|
98
|
-
addToast(t('toast.sessionUpdated'));
|
|
126
|
+
useStore.getState().setActiveSession(sid);
|
|
99
127
|
onClose();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
associatedId: selectedNpcIds[0],
|
|
106
|
-
title: finalTitle,
|
|
107
|
-
worldBookId,
|
|
108
|
-
userPersonaNpcId,
|
|
109
|
-
turnOrderMode,
|
|
110
|
-
participantOrder,
|
|
111
|
-
mentionOnlyMode,
|
|
112
|
-
enableGreeting,
|
|
113
|
-
});
|
|
114
|
-
} else {
|
|
115
|
-
sid = await createSession('GROUP', {
|
|
116
|
-
npcIds: selectedNpcIds,
|
|
117
|
-
title: finalTitle,
|
|
118
|
-
worldBookId,
|
|
119
|
-
userPersonaNpcId,
|
|
120
|
-
turnOrderMode,
|
|
121
|
-
participantOrder,
|
|
122
|
-
mentionOnlyMode,
|
|
123
|
-
enableGreeting,
|
|
124
|
-
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (isEditing) addToast(t('toast.sessionSaveFailed', { msg: (error as Error).message }), 'error');
|
|
130
|
+
else addToast(t('toast.genFailed', { msg: (error as Error).message }), 'error');
|
|
131
|
+
} finally {
|
|
132
|
+
setSaving(false);
|
|
125
133
|
}
|
|
126
|
-
await useStore.getState().refreshSessions();
|
|
127
|
-
useStore.getState().setActiveSession(sid);
|
|
128
|
-
onClose();
|
|
129
134
|
};
|
|
130
135
|
|
|
131
136
|
const addParticipant = (npcId: number) => {
|
|
@@ -155,7 +160,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
|
|
|
155
160
|
return (
|
|
156
161
|
<Modal onClose={onClose} className="new-session-modal">
|
|
157
162
|
<div className="modal-head">
|
|
158
|
-
<span
|
|
163
|
+
<span className="typo-dialog-title">{isEditing ? t('nav.editSession') : t('newSession.title')}</span>
|
|
159
164
|
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
|
160
165
|
</div>
|
|
161
166
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
@@ -198,7 +203,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
|
|
|
198
203
|
</SortableContext>
|
|
199
204
|
</DndContext>
|
|
200
205
|
{npcs.length === 0 ? (
|
|
201
|
-
<div
|
|
206
|
+
<div className="new-session-warning">
|
|
202
207
|
{t('newSession.noNpcWarn')}
|
|
203
208
|
</div>
|
|
204
209
|
) : (
|
|
@@ -273,7 +278,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
|
|
|
273
278
|
</div>
|
|
274
279
|
<div className="modal-foot">
|
|
275
280
|
<button className="btn" onClick={onClose}>{t('common.cancel')}</button>
|
|
276
|
-
<button className="btn btn-primary" disabled={!canCreate} onClick={create}>{isEditing ? t('common.save') : t('common.ok')}</button>
|
|
281
|
+
<button className="btn btn-primary" disabled={!canCreate || saving} onClick={create}>{isEditing ? t('common.save') : t('common.ok')}</button>
|
|
277
282
|
</div>
|
|
278
283
|
</Modal>
|
|
279
284
|
);
|
|
@@ -323,7 +323,7 @@ export const SettingsView = memo(function SettingsView() {
|
|
|
323
323
|
{showResetConfirm && (
|
|
324
324
|
<Modal onClose={() => !resetting && setShowResetConfirm(false)} size="compact">
|
|
325
325
|
<div className="modal-head">
|
|
326
|
-
<span
|
|
326
|
+
<span className="typo-dialog-title">{t('settings.resetConfirmTitle')}</span>
|
|
327
327
|
<button className="icon-btn" disabled={resetting} onClick={() => setShowResetConfirm(false)}><Icon name="x" /></button>
|
|
328
328
|
</div>
|
|
329
329
|
<div className="modal-body">{t('settings.resetConfirm')}</div>
|
|
@@ -435,7 +435,7 @@ function ProviderManager({ providers, onChanged }: { providers: ApiProvider[]; o
|
|
|
435
435
|
<div className="set-section">
|
|
436
436
|
<h3>{t('settings.providerTitle')}</h3>
|
|
437
437
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
438
|
-
{providers.length === 0 && <div
|
|
438
|
+
{providers.length === 0 && <div className="typo-body-sm text-muted">{t('settings.noProvider')}</div>}
|
|
439
439
|
{providers.map((p) => {
|
|
440
440
|
const aliases = modelAliases(p);
|
|
441
441
|
const enabledModels = enabledModelIds(p);
|
|
@@ -457,7 +457,7 @@ function ProviderManager({ providers, onChanged }: { providers: ApiProvider[]; o
|
|
|
457
457
|
</div>
|
|
458
458
|
</div>
|
|
459
459
|
<div className="p-url">{p.baseUrl}</div>
|
|
460
|
-
<div
|
|
460
|
+
<div className="provider-inline-detail">
|
|
461
461
|
<b>{t('settings.models')}</b>
|
|
462
462
|
{visibleModels.length > 0
|
|
463
463
|
? visibleModels.slice(0, 12).map((model) => aliases[model]?.trim() || model).join(', ')
|
|
@@ -475,7 +475,7 @@ function ProviderManager({ providers, onChanged }: { providers: ApiProvider[]; o
|
|
|
475
475
|
{editing && (
|
|
476
476
|
<Modal onClose={() => setEditing(null)}>
|
|
477
477
|
<div className="modal-head">
|
|
478
|
-
<span
|
|
478
|
+
<span className="typo-dialog-title">{editing.id != null ? t('settings.editProvider') : t('settings.addProvider')}</span>
|
|
479
479
|
<button className="icon-btn" onClick={() => setEditing(null)}><Icon name="x" /></button>
|
|
480
480
|
</div>
|
|
481
481
|
<ProviderForm
|
|
@@ -62,6 +62,7 @@ export function Sidebar() {
|
|
|
62
62
|
|
|
63
63
|
const [showNew, setShowNew] = useState(false);
|
|
64
64
|
const [confirmDelete, setConfirmDelete] = useState<ChatSession | null>(null);
|
|
65
|
+
const [deletingSession, setDeletingSession] = useState(false);
|
|
65
66
|
const [editTarget, setEditTarget] = useState<ChatSession | null>(null);
|
|
66
67
|
const [sessionMenu, setSessionMenu] = useState<{ session: ChatSession; x: number; y: number } | null>(null);
|
|
67
68
|
const [query, setQuery] = useState('');
|
|
@@ -178,11 +179,17 @@ export function Sidebar() {
|
|
|
178
179
|
const npcById = (id: number | null) => (id != null ? npcs.find((n) => n.id === id) : null);
|
|
179
180
|
|
|
180
181
|
const handleDelete = async () => {
|
|
181
|
-
if (!confirmDelete) return;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
if (!confirmDelete || deletingSession) return;
|
|
183
|
+
setDeletingSession(true);
|
|
184
|
+
try {
|
|
185
|
+
await deleteSession(confirmDelete.id!);
|
|
186
|
+
addToast(t('toast.sessionDeleted'));
|
|
187
|
+
setConfirmDelete(null);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
addToast(t('toast.sessionDeleteFailed', { msg: (error as Error).message }), 'error');
|
|
190
|
+
} finally {
|
|
191
|
+
setDeletingSession(false);
|
|
192
|
+
}
|
|
186
193
|
};
|
|
187
194
|
|
|
188
195
|
const openEdit = (session: ChatSession) => {
|
|
@@ -281,6 +288,7 @@ export function Sidebar() {
|
|
|
281
288
|
}));
|
|
282
289
|
|
|
283
290
|
const returnHome = () => {
|
|
291
|
+
setMobileOpen(false);
|
|
284
292
|
const now = Date.now();
|
|
285
293
|
homeClickTimes.current = [...homeClickTimes.current, now]
|
|
286
294
|
.filter((time) => now - time <= SHAREHOLDER_CLICK_WINDOW_MS)
|
|
@@ -315,16 +323,16 @@ export function Sidebar() {
|
|
|
315
323
|
>
|
|
316
324
|
<img src="/tavern-harness-logo.png" alt="" />
|
|
317
325
|
</button>
|
|
318
|
-
<button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>
|
|
326
|
+
<button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => { setActiveView('chat'); setMobileOpen(false); }}>
|
|
319
327
|
<Icon name="send" size={15} /> <span>{t('nav.chat')}</span>
|
|
320
328
|
</button>
|
|
321
|
-
<button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => setActiveView('characters')}>
|
|
329
|
+
<button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => { setActiveView('characters'); setMobileOpen(false); }}>
|
|
322
330
|
<Icon name="users" size={15} /> <span>{t('nav.characters')}</span>
|
|
323
331
|
</button>
|
|
324
|
-
<button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => setActiveView('stats')}>
|
|
332
|
+
<button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => { setActiveView('stats'); setMobileOpen(false); }}>
|
|
325
333
|
<Icon name="trophy" size={15} /> <span>{t('nav.achievements')}</span>
|
|
326
334
|
</button>
|
|
327
|
-
<button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => setActiveView('settings')}>
|
|
335
|
+
<button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => { setActiveView('settings'); setMobileOpen(false); }}>
|
|
328
336
|
<Icon name="settings" size={15} /> <span>{t('nav.settings')}</span>
|
|
329
337
|
{hasUnseenVersion && <span className="unseen-dot" title={t('changelog.unseen')} />}
|
|
330
338
|
</button>
|
|
@@ -519,19 +527,19 @@ export function Sidebar() {
|
|
|
519
527
|
{confirmDelete && (
|
|
520
528
|
<Modal onClose={() => setConfirmDelete(null)} size="compact">
|
|
521
529
|
<div className="modal-head">
|
|
522
|
-
<span
|
|
530
|
+
<span className="typo-dialog-title dialog-title-inline">
|
|
523
531
|
<Icon name="trash" size={15} /> {t('nav.deleteSession')}
|
|
524
532
|
</span>
|
|
525
533
|
<button className="icon-btn" onClick={() => setConfirmDelete(null)}><Icon name="x" /></button>
|
|
526
534
|
</div>
|
|
527
535
|
<div className="modal-body">
|
|
528
|
-
<div
|
|
536
|
+
<div className="sidebar-import-description">
|
|
529
537
|
{t('nav.deleteSessionConfirm', { title: confirmDelete.title })}
|
|
530
538
|
</div>
|
|
531
539
|
</div>
|
|
532
540
|
<div className="modal-foot">
|
|
533
541
|
<button className="btn" onClick={() => setConfirmDelete(null)}>{t('common.cancel')}</button>
|
|
534
|
-
<button className="btn btn-danger" onClick={handleDelete}>
|
|
542
|
+
<button className="btn btn-danger" disabled={deletingSession} onClick={handleDelete}>
|
|
535
543
|
<Icon name="trash" size={13} /> {t('common.delete')}
|
|
536
544
|
</button>
|
|
537
545
|
</div>
|
|
@@ -134,18 +134,18 @@ function StatCards({ data }: { data: StatsData }) {
|
|
|
134
134
|
{(() => {
|
|
135
135
|
const mostActive = [...data.npcStats].sort((a, b) => b.rounds - a.rounds)[0];
|
|
136
136
|
return (
|
|
137
|
-
<div
|
|
138
|
-
<div
|
|
137
|
+
<div className="stats-most-active">
|
|
138
|
+
<div className="stats-most-active-icon">🎭</div>
|
|
139
139
|
<div>
|
|
140
|
-
<div
|
|
141
|
-
<div
|
|
140
|
+
<div className="stats-most-active-name">{mostActive.npcName}</div>
|
|
141
|
+
<div className="stats-most-active-rounds">{t('stats.roundsCount', { n: mostActive.rounds })}</div>
|
|
142
142
|
</div>
|
|
143
143
|
</div>
|
|
144
144
|
);
|
|
145
145
|
})()}
|
|
146
146
|
</>
|
|
147
147
|
) : (
|
|
148
|
-
<div
|
|
148
|
+
<div className="stats-no-data">{t('stats.noData')}</div>
|
|
149
149
|
)}
|
|
150
150
|
</div>
|
|
151
151
|
|