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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -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
- if (resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
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
- style={{ padding: '10px 18px', border: 0, borderRadius: 8, cursor: takingControl ? 'wait' : 'pointer', fontWeight: 700, background: 'color-mix(in srgb, var(--primary) 25%, transparent)', color: 'var(--primary)' }}
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 style={{ fontWeight: 800, fontSize: 15 }}>{t('header.resetSessionTitle')}</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 style={{ fontWeight: 800, fontSize: 15 }}>{t('header.deleteTitle')}</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
- await deleteSession(session.id!);
487
- setConfirmDelete(false);
488
- addToast(t('toast.sessionDeleted'));
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 style={{ fontWeight: 800, fontSize: 15 }}>{isNew ? t('workshop.newCharTitle') : t('workshop.editCharTitle')}</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 style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('workshop.promptPh')}</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 style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('workshop.greetingPh')}</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 style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('workshop.noSkills')}</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 style={{ fontSize: 16 }}>📖</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 style={{ fontWeight: 800, fontSize: 15 }}>
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 style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('workshop.wbContentPh')}</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 style={{ fontSize: 18 }}>{tool.isBuiltIn ? '🧰' : '⚙️'}</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" style={{ marginLeft: 8 }}>{t('common.builtin')}</span>}
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 style={{ fontSize: 10.5, marginTop: 2, color: 'var(--warn)' }}>{t('workshop.implType', { t: impl })}</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 style={{ fontWeight: 800, fontSize: 15 }}>
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" style={{ color: 'var(--text-faint)' }}>{t('workshop.skillNoParams')}</div>}
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" style={{ color: 'var(--text-faint)' }}>{t('workshop.skillNoExecution')}</div>}
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" style={{ color: 'var(--danger)' }}>{error}</div>}
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 style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('chat.noActions')}</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 style={{ fontWeight: 800, fontSize: 14, display: 'flex', alignItems: 'center', gap: 8 }}>
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 style={{ fontSize: 13, fontWeight: 700 }}>{t('confirm.tool')}<span className="mono" style={{ color: 'var(--primary)' }}>{pending.toolName}</span></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 style={{ fontSize: 13, color: 'var(--text-dim)', lineHeight: 1.6, whiteSpace: 'pre-line' }}>
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 style={{ fontWeight: 800, fontSize: 15 }}>{title}</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" style={{ fontSize: 12.5, color: 'var(--text-dim)' }}>{active.path}</span>
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 style={{ fontWeight: 800, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
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 style={{ fontWeight: 800, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
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
- const finalTitle = title.trim() || selectedNpcIds.map((id) => npcs.find((n) => n.id === id)?.name).filter(Boolean).join('、');
85
- if (isEditing && editingSession?.id) {
86
- await updateSessionSettings(editingSession.id, {
87
- title: finalTitle,
88
- npcIds: selectedNpcIds,
89
- worldBookId,
90
- userPersonaNpcId,
91
- turnOrderMode,
92
- participantOrder,
93
- mentionOnlyMode,
94
- enableGreeting,
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(editingSession.id);
98
- addToast(t('toast.sessionUpdated'));
126
+ useStore.getState().setActiveSession(sid);
99
127
  onClose();
100
- return;
101
- }
102
- let sid: number;
103
- if (selectedNpcIds.length === 1) {
104
- sid = await createSession('NPC', {
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 style={{ fontWeight: 800, fontSize: 15 }}>{isEditing ? t('nav.editSession') : t('newSession.title')}</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 style={{ fontSize: 12.5, color: 'var(--warn)', background: 'var(--warn-soft)', padding: '8px 12px', borderRadius: 9 }}>
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 style={{ fontWeight: 800, fontSize: 15 }}>{t('settings.resetConfirmTitle')}</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 style={{ color: 'var(--text-faint)', fontSize: 12.5 }}>{t('settings.noProvider')}</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 style={{ fontSize: 11.5, color: 'var(--text-faint)', maxHeight: 60, overflow: 'auto' }}>
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 style={{ fontWeight: 800, fontSize: 15 }}>{editing.id != null ? t('settings.editProvider') : t('settings.addProvider')}</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
- const sid = confirmDelete.id!;
183
- await deleteSession(sid);
184
- addToast(t('toast.sessionDeleted'));
185
- setConfirmDelete(null);
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 style={{ fontWeight: 800, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
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 style={{ fontSize: 13.5, lineHeight: 1.7 }}>
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 style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
138
- <div style={{ fontSize: 26 }}>🎭</div>
137
+ <div className="stats-most-active">
138
+ <div className="stats-most-active-icon">🎭</div>
139
139
  <div>
140
- <div style={{ fontWeight: 800 }}>{mostActive.npcName}</div>
141
- <div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('stats.roundsCount', { n: mostActive.rounds })}</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 style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('stats.noData')}</div>
148
+ <div className="stats-no-data">{t('stats.noData')}</div>
149
149
  )}
150
150
  </div>
151
151