tavern-harness 0.1.0

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.
Files changed (56) hide show
  1. package/README.md +161 -0
  2. package/README.zh.md +123 -0
  3. package/bin/cli.js +48 -0
  4. package/index.html +35 -0
  5. package/package.json +50 -0
  6. package/public/favicon.png +0 -0
  7. package/public/tavern-harness-logo.png +0 -0
  8. package/sandbox-server.mjs +997 -0
  9. package/src/App.tsx +268 -0
  10. package/src/assets/tavern-handbook.md +39 -0
  11. package/src/components/AchievementModal.tsx +158 -0
  12. package/src/components/CharactersView.tsx +904 -0
  13. package/src/components/ChatView.tsx +1639 -0
  14. package/src/components/ConfirmationDialog.tsx +56 -0
  15. package/src/components/Dashboard.tsx +180 -0
  16. package/src/components/DeleteConfirmDialog.tsx +48 -0
  17. package/src/components/FileDisplayModal.tsx +556 -0
  18. package/src/components/GameplayDialogs.tsx +191 -0
  19. package/src/components/NewSessionMenu.tsx +362 -0
  20. package/src/components/SettingsView.tsx +517 -0
  21. package/src/components/Sidebar.tsx +392 -0
  22. package/src/components/StatsView.tsx +189 -0
  23. package/src/components/Toasts.tsx +20 -0
  24. package/src/components/WorkspaceFileManagerModal.tsx +216 -0
  25. package/src/components/shared.tsx +369 -0
  26. package/src/core/achievements.ts +85 -0
  27. package/src/core/fileDownload.ts +67 -0
  28. package/src/core/gameplay.ts +521 -0
  29. package/src/core/horizontalWheel.ts +93 -0
  30. package/src/core/i18n.ts +1403 -0
  31. package/src/core/linkedScroll.ts +236 -0
  32. package/src/core/markdown.ts +170 -0
  33. package/src/core/openai.ts +451 -0
  34. package/src/core/openingGreeting.ts +41 -0
  35. package/src/core/prompts.ts +254 -0
  36. package/src/core/proxy.ts +58 -0
  37. package/src/core/sillyTavernImporter.ts +317 -0
  38. package/src/core/stats.ts +91 -0
  39. package/src/core/strings.ts +19 -0
  40. package/src/core/toolDefinitions.ts +400 -0
  41. package/src/core/tools/builtinTools.ts +38 -0
  42. package/src/core/tools/generatedSkillExecutor.ts +706 -0
  43. package/src/core/tools/generatedWorkspace.ts +13 -0
  44. package/src/core/tools/toolExecutor.ts +718 -0
  45. package/src/core/turnLoop.ts +211 -0
  46. package/src/db/database.ts +647 -0
  47. package/src/main.tsx +23 -0
  48. package/src/store/store.ts +2419 -0
  49. package/src/theme/chat.css +1227 -0
  50. package/src/theme/global.css +1377 -0
  51. package/src/theme/theme.ts +143 -0
  52. package/src/theme/views.css +1430 -0
  53. package/src/types/models.ts +364 -0
  54. package/src/vite-env.d.ts +1 -0
  55. package/tsconfig.json +21 -0
  56. package/vite.config.ts +273 -0
@@ -0,0 +1,56 @@
1
+ import { useStore } from '../store/store';
2
+ import { Icon } from './shared';
3
+ import { useT } from '../core/i18n';
4
+ import { prettyJson } from '../core/strings';
5
+
6
+ // ============================================================
7
+ // 工具确认门控弹窗(更新/删除类操作)
8
+ // ============================================================
9
+
10
+ export function ConfirmationDialog() {
11
+ const pending = useStore((s) => s.pendingConfirmation);
12
+ const resolve = useStore((s) => s.resolveConfirmation);
13
+ const t = useT();
14
+
15
+ if (!pending) return null;
16
+ const isLimit = pending.kind === 'limit';
17
+
18
+ const argsText = prettyJson(pending.argsJson);
19
+
20
+ return (
21
+ <>
22
+ <div className="overlay" />
23
+ <div className="modal-root">
24
+ <div className="modal card confirm fade-up">
25
+ <div className="modal-head">
26
+ <span style={{ fontWeight: 800, fontSize: 14, display: 'flex', alignItems: 'center', gap: 8 }}>
27
+ <Icon name="settings" size={15} /> {isLimit ? pending.title : t('confirm.title')}
28
+ </span>
29
+ </div>
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>}
32
+ {!pending.inlineCode && <pre className="confirm-args mono">{argsText}</pre>}
33
+ <div className={isLimit ? undefined : 'confirm-reason'}>
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' }}>
36
+ {pending.inlineCode && <code className="confirm-inline-code">{pending.inlineCode}</code>}
37
+ {pending.message || (isLimit ? '' : t('confirm.modify'))}
38
+ </div>
39
+ </div>
40
+ </div>
41
+ <div className="modal-foot">
42
+ <button
43
+ className="btn"
44
+ onClick={() => resolve(false)}
45
+ >
46
+ {isLimit ? t('confirm.stop') : t('confirm.reject')}
47
+ </button>
48
+ <button className="btn btn-primary" onClick={() => resolve(true)}>
49
+ {isLimit ? t('confirm.continue') : t('confirm.allow')}
50
+ </button>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </>
55
+ );
56
+ }
@@ -0,0 +1,180 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { createSession, useStore } from '../store/store';
3
+ import { db } from '../db/database';
4
+ import { Icon, MasonryGrid, SessionVisual } from './shared';
5
+ import { NewSessionMenu } from './NewSessionMenu';
6
+ import { useT, currentLocale } from '../core/i18n';
7
+ import type { SessionMember } from './shared';
8
+ import type { NpcCharacter } from '../types/models';
9
+
10
+ // ============================================================
11
+ // 仪表盘(无活动会话时的起始页)
12
+ // ============================================================
13
+
14
+ export function Dashboard() {
15
+ const npcs = useStore((s) => s.npcs);
16
+ const sessions = useStore((s) => s.sessions);
17
+ const t = useT();
18
+ const [showNew, setShowNew] = useState(false);
19
+
20
+ const startNpc = async (id: number) => {
21
+ const sid = await createSession('NPC', { associatedId: id });
22
+ await useStore.getState().refreshSessions();
23
+ useStore.getState().setActiveSession(sid);
24
+ };
25
+
26
+ // 群聊会话 → 参与者中的 NPC 角色(用于群头像拼贴,每个会话一次)
27
+ const [groupMembers, setGroupMembers] = useState<Record<number, NpcCharacter[]>>({});
28
+ useEffect(() => {
29
+ const groupSessions = sessions.filter((s) => s.mode === 'GROUP');
30
+ if (groupSessions.length === 0) return;
31
+ let cancelled = false;
32
+ (async () => {
33
+ const loaded: Record<number, NpcCharacter[]> = {};
34
+ await Promise.all(
35
+ groupSessions.map(async (s) => {
36
+ const parts = await db.participants.where('sessionId').equals(s.id!).toArray();
37
+ const npcs1 = parts
38
+ .filter((p) => p.kind === 'NPC' && p.npcId != null)
39
+ .map((p) => npcs.find((n) => n.id === p.npcId))
40
+ .filter((n): n is NpcCharacter => Boolean(n));
41
+ loaded[s.id!] = npcs1;
42
+ })
43
+ );
44
+ if (!cancelled) setGroupMembers(loaded);
45
+ })();
46
+ return () => {
47
+ cancelled = true;
48
+ };
49
+ }, [sessions, npcs]);
50
+
51
+ // 与角色继续对话:最近按 updatedAt 排序的 3 个有实际内容的会话(最新在前)
52
+ const recentSessions = useMemo(() => {
53
+ return sessions
54
+ .filter((s) => s.mode !== 'STANDARD')
55
+ .sort((a, b) => b.updatedAt - a.updatedAt)
56
+ .slice(0, 3);
57
+ }, [sessions]);
58
+
59
+ // 会话缩略标题:使用最后一条消息的首行,截断到 42 字符
60
+ const sessionPreview = (s: (typeof sessions)[number]) => {
61
+ const text = s.lastMessage || (s.mode === 'GROUP' ? t('dash.groupSession') : t('dash.newSession'));
62
+ const line = text.trim().split('\n').find(Boolean) ?? '';
63
+ return line.length > 42 ? `${line.slice(0, 42)}…` : line;
64
+ };
65
+
66
+ // 会话相对时间(刚刚 / N 分钟前 / N 小时前 / N 天前),与界面语言一致
67
+ const sessionTime = (ts: number) => {
68
+ const diff = Date.now() - ts;
69
+ const min = Math.floor(diff / 60000);
70
+ const hour = Math.floor(diff / 3600000);
71
+ const day = Math.floor(diff / 86400000);
72
+ if (min < 1) return t('dash.timeJustNow');
73
+ if (min < 60) return t('dash.timeMinAgo', { n: min });
74
+ if (hour < 24) return t('dash.timeHourAgo', { n: hour });
75
+ if (day < 30) return t('dash.timeDayAgo', { n: day });
76
+ return new Date(ts).toLocaleDateString(currentLocale());
77
+ };
78
+
79
+ return (
80
+ <div className="dash-scroll">
81
+ <div className="dash">
82
+ <div className="dash-hero fade-up">
83
+ <h1>{t('dash.welcome')}</h1>
84
+ <p>
85
+ {t('dash.hero')}
86
+ <span className="dash-hero-accent">{t('dash.heroAccent')}</span>
87
+ </p>
88
+ <div className="actions">
89
+ <button className="btn btn-primary" onClick={() => setShowNew(true)}>
90
+ <Icon name="plus" size={14} /> {t('dash.newChat')}
91
+ </button>
92
+ <button
93
+ className="btn"
94
+ onClick={() => {
95
+ useStore.getState().setActiveView('characters');
96
+ }}
97
+ >
98
+ <Icon name="users" size={14} /> {t('dash.goCharacters')}
99
+ </button>
100
+ <a className="btn" href="https://rannichan.github.io/Tavern-Harness/wiki.html" target="_blank" rel="noreferrer">
101
+ <Icon name="book" size={14} /> {t('dash.openWiki')}
102
+ </a>
103
+ </div>
104
+ </div>
105
+
106
+ <div>
107
+ <span className="section-title">{t('dash.continue')}</span>
108
+ {recentSessions.length === 0 ? (
109
+ <div className="card empty-state">
110
+ <div className="big">🍺</div>
111
+ <span>{t('dash.noCharConv')}</span>
112
+ </div>
113
+ ) : (
114
+ <div className="recent-list">
115
+ {recentSessions.map((s) => {
116
+ const npc = npcs.find((n) => n.id === s.associatedId);
117
+ const members: SessionMember[] | undefined =
118
+ s.mode === 'GROUP'
119
+ ? (groupMembers[s.id!] ?? []).slice(0, 4).map((n) => ({
120
+ name: n.name,
121
+ colorOrdinal: n.avatarColorOrdinal,
122
+ imageUrl: n.avatarDataUrl,
123
+ }))
124
+ : undefined;
125
+ return (
126
+ <button
127
+ key={s.id}
128
+ className="recent-item"
129
+ onClick={() => useStore.getState().setActiveSession(s.id!)}
130
+ >
131
+ <SessionVisual
132
+ mode={s.mode}
133
+ npcName={npc?.name}
134
+ hue={npc?.avatarColorOrdinal}
135
+ imageUrl={npc?.avatarDataUrl}
136
+ members={members}
137
+ />
138
+ <div className="recent-meta">
139
+ <div className="recent-title">
140
+ <span className="recent-title-text">{s.title}</span>
141
+ <span className="recent-time">{sessionTime(s.updatedAt)}</span>
142
+ </div>
143
+ <div className="recent-preview">{sessionPreview(s)}</div>
144
+ </div>
145
+ <span className="recent-go">
146
+ <Icon name="send" size={13} />
147
+ </span>
148
+ </button>
149
+ );
150
+ })}
151
+ </div>
152
+ )}
153
+ </div>
154
+
155
+ <div>
156
+ <span className="section-title">{t('dash.regulars')}</span>
157
+ <MasonryGrid>
158
+ {npcs.map((n) => (
159
+ <div key={n.id} className="char-card">
160
+ <div className="cname">
161
+ <span className="avatar xs" style={{ background: `linear-gradient(135deg, var(--grad-from), var(--grad-to))` }}>
162
+ {n.name.slice(0, 1)}
163
+ </span>
164
+ {n.name}
165
+ {n.isBuiltIn && <span className="tag">{t('common.builtin')}</span>}
166
+ </div>
167
+ <div className="cgreet">{n.greeting}</div>
168
+ <div className="cactions">
169
+ <button className="btn btn-sm btn-primary" onClick={() => startNpc(n.id!)}>{t('dash.startChat')}</button>
170
+ </div>
171
+ </div>
172
+ ))}
173
+ </MasonryGrid>
174
+ </div>
175
+ </div>
176
+
177
+ {showNew && <NewSessionMenu onClose={() => setShowNew(false)} />}
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,48 @@
1
+ import { Icon } from './shared';
2
+ import { Modal } from './shared';
3
+ import { useT } from '../core/i18n';
4
+
5
+ // ============================================================
6
+ // 通用删除确认弹窗(与对话删除弹窗样式一致)
7
+ // 用法:把「待删除项」放进 state,渲染本组件,onConfirm 里执行删除
8
+ // ============================================================
9
+
10
+ export function DeleteConfirmDialog({
11
+ title,
12
+ itemName,
13
+ onCancel,
14
+ onConfirm,
15
+ dangerText,
16
+ }: {
17
+ title: string;
18
+ itemName: string;
19
+ onCancel: () => void;
20
+ onConfirm: () => void | Promise<void>;
21
+ dangerText?: string;
22
+ }) {
23
+ const t = useT();
24
+ const danger = dangerText ?? t('common.delete');
25
+ return (
26
+ <Modal onClose={onCancel} width="min(400px, calc(100vw - 32px))">
27
+ <div className="modal-head">
28
+ <span style={{ fontWeight: 800, fontSize: 15 }}>{title}</span>
29
+ <button className="icon-btn" onClick={onCancel}><Icon name="x" /></button>
30
+ </div>
31
+ <div className="modal-body">
32
+ {t('common.confirmDelete', { name: itemName })}
33
+ </div>
34
+ <div className="modal-foot">
35
+ <button className="btn" onClick={onCancel}>{t('common.cancel')}</button>
36
+ <button
37
+ className="btn btn-danger"
38
+ onClick={async () => {
39
+ await onConfirm();
40
+ onCancel();
41
+ }}
42
+ >
43
+ <Icon name="trash" size={13} /> {danger}
44
+ </button>
45
+ </div>
46
+ </Modal>
47
+ );
48
+ }