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.
- package/README.md +161 -0
- package/README.zh.md +123 -0
- package/bin/cli.js +48 -0
- package/index.html +35 -0
- package/package.json +50 -0
- package/public/favicon.png +0 -0
- package/public/tavern-harness-logo.png +0 -0
- package/sandbox-server.mjs +997 -0
- package/src/App.tsx +268 -0
- package/src/assets/tavern-handbook.md +39 -0
- package/src/components/AchievementModal.tsx +158 -0
- package/src/components/CharactersView.tsx +904 -0
- package/src/components/ChatView.tsx +1639 -0
- package/src/components/ConfirmationDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +180 -0
- package/src/components/DeleteConfirmDialog.tsx +48 -0
- package/src/components/FileDisplayModal.tsx +556 -0
- package/src/components/GameplayDialogs.tsx +191 -0
- package/src/components/NewSessionMenu.tsx +362 -0
- package/src/components/SettingsView.tsx +517 -0
- package/src/components/Sidebar.tsx +392 -0
- package/src/components/StatsView.tsx +189 -0
- package/src/components/Toasts.tsx +20 -0
- package/src/components/WorkspaceFileManagerModal.tsx +216 -0
- package/src/components/shared.tsx +369 -0
- package/src/core/achievements.ts +85 -0
- package/src/core/fileDownload.ts +67 -0
- package/src/core/gameplay.ts +521 -0
- package/src/core/horizontalWheel.ts +93 -0
- package/src/core/i18n.ts +1403 -0
- package/src/core/linkedScroll.ts +236 -0
- package/src/core/markdown.ts +170 -0
- package/src/core/openai.ts +451 -0
- package/src/core/openingGreeting.ts +41 -0
- package/src/core/prompts.ts +254 -0
- package/src/core/proxy.ts +58 -0
- package/src/core/sillyTavernImporter.ts +317 -0
- package/src/core/stats.ts +91 -0
- package/src/core/strings.ts +19 -0
- package/src/core/toolDefinitions.ts +400 -0
- package/src/core/tools/builtinTools.ts +38 -0
- package/src/core/tools/generatedSkillExecutor.ts +706 -0
- package/src/core/tools/generatedWorkspace.ts +13 -0
- package/src/core/tools/toolExecutor.ts +718 -0
- package/src/core/turnLoop.ts +211 -0
- package/src/db/database.ts +647 -0
- package/src/main.tsx +23 -0
- package/src/store/store.ts +2419 -0
- package/src/theme/chat.css +1227 -0
- package/src/theme/global.css +1377 -0
- package/src/theme/theme.ts +143 -0
- package/src/theme/views.css +1430 -0
- package/src/types/models.ts +364 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +273 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useStore } from '../store/store';
|
|
3
|
+
import { Icon, Modal } from './shared';
|
|
4
|
+
import { useT } from '../core/i18n';
|
|
5
|
+
import { exportGameplay, importGameplay } from '../core/gameplay';
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// 游戏导出 / 导入
|
|
9
|
+
// 三种打开方式:
|
|
10
|
+
// - <GameplayExportModal sessionId onClose includeHistory defaultIncludeHistory>
|
|
11
|
+
// 导出游戏(含「是否包含对话历史」开关)
|
|
12
|
+
// - <GameplayImportModal onClose onImported>
|
|
13
|
+
// 从游戏 JSON 文件完全重建
|
|
14
|
+
// - 从会话右键菜单「导出游戏」:走 App 级全局弹窗(ExportSessionDialog)
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
export function GameplayExportModal({
|
|
18
|
+
sessionId,
|
|
19
|
+
onClose,
|
|
20
|
+
defaultIncludeHistory,
|
|
21
|
+
}: {
|
|
22
|
+
sessionId: number;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
defaultIncludeHistory: boolean;
|
|
25
|
+
}) {
|
|
26
|
+
const t = useT();
|
|
27
|
+
const addToast = useStore((s) => s.addToast);
|
|
28
|
+
const [includeHistory, setIncludeHistory] = useState(defaultIncludeHistory);
|
|
29
|
+
const [busy, setBusy] = useState(false);
|
|
30
|
+
|
|
31
|
+
const doExport = async () => {
|
|
32
|
+
setBusy(true);
|
|
33
|
+
try {
|
|
34
|
+
const result = await exportGameplay({ sessionId, includeHistory });
|
|
35
|
+
if (result === 'canceled') {
|
|
36
|
+
addToast(t('toast.exportCanceled'));
|
|
37
|
+
} else {
|
|
38
|
+
addToast(t('toast.gameplayExported'));
|
|
39
|
+
}
|
|
40
|
+
onClose();
|
|
41
|
+
} catch (e) {
|
|
42
|
+
addToast(t('toast.exportFailed', { msg: (e as Error).message }), 'error');
|
|
43
|
+
} finally {
|
|
44
|
+
setBusy(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Modal onClose={onClose} width={420}>
|
|
50
|
+
<div className="modal-head">
|
|
51
|
+
<span style={{ fontWeight: 800, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
52
|
+
<Icon name="share" size={15} /> {t('gameplay.exportTitle')}
|
|
53
|
+
</span>
|
|
54
|
+
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
57
|
+
<div className="gameplay-history-row">
|
|
58
|
+
<div className="field" style={{ gap: 3 }}>
|
|
59
|
+
<label>{t('gameplay.includeHistory')}</label>
|
|
60
|
+
<span className="field-hint">{t('gameplay.includeHistoryHint')}</span>
|
|
61
|
+
</div>
|
|
62
|
+
<label className="switch">
|
|
63
|
+
<input
|
|
64
|
+
type="checkbox"
|
|
65
|
+
checked={includeHistory}
|
|
66
|
+
onChange={(e) => setIncludeHistory(e.target.checked)}
|
|
67
|
+
/>
|
|
68
|
+
<span className="switch-slider" />
|
|
69
|
+
</label>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="gameplay-summary">
|
|
72
|
+
{t('gameplay.exportSummary')}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="modal-foot">
|
|
76
|
+
<button className="btn" onClick={onClose}>{t('common.cancel')}</button>
|
|
77
|
+
<button className="btn btn-primary" disabled={busy} onClick={doExport}>
|
|
78
|
+
<Icon name="upload" size={13} /> {busy ? t('gameplay.busy') : t('gameplay.exportAction')}
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</Modal>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function GameplayImportModal({
|
|
86
|
+
onClose,
|
|
87
|
+
onImported,
|
|
88
|
+
}: {
|
|
89
|
+
onClose: () => void;
|
|
90
|
+
onImported?: (sessionId: number) => void;
|
|
91
|
+
}) {
|
|
92
|
+
const t = useT();
|
|
93
|
+
const addToast = useStore((s) => s.addToast);
|
|
94
|
+
const setActiveSession = useStore((s) => s.setActiveSession);
|
|
95
|
+
const refreshNpcs = useStore((s) => s.refreshNpcs);
|
|
96
|
+
const refreshWorldBooks = useStore((s) => s.refreshWorldBooks);
|
|
97
|
+
const refreshTools = useStore((s) => s.refreshTools);
|
|
98
|
+
const refreshSessions = useStore((s) => s.refreshSessions);
|
|
99
|
+
const loadMessages = useStore((s) => s.loadMessages);
|
|
100
|
+
const refreshLiveQueue = useStore((s) => s.refreshLiveQueue);
|
|
101
|
+
const [busy, setBusy] = useState(false);
|
|
102
|
+
const [selectedName, setSelectedName] = useState<string | null>(null);
|
|
103
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
104
|
+
const pendingData = useRef<unknown | null>(null);
|
|
105
|
+
|
|
106
|
+
const openPicker = () => fileRef.current?.click();
|
|
107
|
+
|
|
108
|
+
const readFile = (file: File) => {
|
|
109
|
+
const reader = new FileReader();
|
|
110
|
+
reader.onload = () => {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(String(reader.result));
|
|
113
|
+
pendingData.current = parsed;
|
|
114
|
+
setSelectedName(file.name.replace(/\.json$/i, ''));
|
|
115
|
+
} catch {
|
|
116
|
+
addToast(t('toast.importFailed', { msg: t('gameplay.invalidFile') }), 'error');
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
reader.onerror = () => addToast(t('toast.importFailed', { msg: t('gameplay.invalidFile') }), 'error');
|
|
120
|
+
reader.readAsText(file);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const doImport = async () => {
|
|
124
|
+
if (!pendingData.current) return;
|
|
125
|
+
setBusy(true);
|
|
126
|
+
try {
|
|
127
|
+
const result = await importGameplay(pendingData.current);
|
|
128
|
+
pendingData.current = null;
|
|
129
|
+
setSelectedName(null);
|
|
130
|
+
addToast(
|
|
131
|
+
t('toast.gameplayImported', {
|
|
132
|
+
title: result.sessionTitle,
|
|
133
|
+
npc: String(result.createdNpcs),
|
|
134
|
+
wb: result.importedWorldBook ? '1' : '0',
|
|
135
|
+
tools: String(result.importedTools),
|
|
136
|
+
msgs: String(result.importedMessages),
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
await Promise.all([refreshNpcs(), refreshWorldBooks(), refreshTools(), refreshSessions()]);
|
|
140
|
+
await loadMessages(result.sessionId);
|
|
141
|
+
await refreshLiveQueue(result.sessionId);
|
|
142
|
+
setActiveSession(result.sessionId);
|
|
143
|
+
onImported?.(result.sessionId);
|
|
144
|
+
onClose();
|
|
145
|
+
} catch (e) {
|
|
146
|
+
addToast(t('toast.importFailed', { msg: (e as Error).message }), 'error');
|
|
147
|
+
} finally {
|
|
148
|
+
setBusy(false);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<Modal onClose={onClose} width={420}>
|
|
154
|
+
<div className="modal-head">
|
|
155
|
+
<span style={{ fontWeight: 800, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
156
|
+
<Icon name="import" size={15} /> {t('gameplay.importTitle')}
|
|
157
|
+
</span>
|
|
158
|
+
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
161
|
+
<button
|
|
162
|
+
className="btn gameplay-file-btn"
|
|
163
|
+
onClick={openPicker}
|
|
164
|
+
disabled={busy}
|
|
165
|
+
style={{ justifyContent: 'center', padding: '14px 12px' }}
|
|
166
|
+
>
|
|
167
|
+
<Icon name="file" size={14} />
|
|
168
|
+
{selectedName ? selectedName : t('gameplay.chooseFile')}
|
|
169
|
+
</button>
|
|
170
|
+
<div className="gameplay-summary">{t('gameplay.importSummary')}</div>
|
|
171
|
+
</div>
|
|
172
|
+
<div className="modal-foot">
|
|
173
|
+
<button className="btn" onClick={onClose}>{t('common.cancel')}</button>
|
|
174
|
+
<button className="btn btn-primary" disabled={busy || !selectedName} onClick={doImport}>
|
|
175
|
+
<Icon name="import" size={13} /> {busy ? t('gameplay.busy') : t('gameplay.importAction')}
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
<input
|
|
179
|
+
ref={fileRef}
|
|
180
|
+
type="file"
|
|
181
|
+
accept="application/json,.json"
|
|
182
|
+
hidden
|
|
183
|
+
onChange={(e) => {
|
|
184
|
+
const f = e.target.files?.[0];
|
|
185
|
+
if (f) readFile(f);
|
|
186
|
+
e.target.value = '';
|
|
187
|
+
}}
|
|
188
|
+
/>
|
|
189
|
+
</Modal>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
DndContext,
|
|
4
|
+
PointerSensor,
|
|
5
|
+
KeyboardSensor,
|
|
6
|
+
closestCenter,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
type Modifier,
|
|
10
|
+
type DragEndEvent,
|
|
11
|
+
} from '@dnd-kit/core';
|
|
12
|
+
import {
|
|
13
|
+
SortableContext,
|
|
14
|
+
arrayMove,
|
|
15
|
+
sortableKeyboardCoordinates,
|
|
16
|
+
verticalListSortingStrategy,
|
|
17
|
+
useSortable,
|
|
18
|
+
} from '@dnd-kit/sortable';
|
|
19
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
20
|
+
import { useStore, createSession } from '../store/store';
|
|
21
|
+
import { Avatar, Icon, Modal } from './shared';
|
|
22
|
+
import { useT } from '../core/i18n';
|
|
23
|
+
import { db } from '../db/database';
|
|
24
|
+
import type { ChatSession } from '../types/models';
|
|
25
|
+
|
|
26
|
+
// ============================================================
|
|
27
|
+
// 新建对话弹窗(侧边栏「新建」与仪表盘「新建对话」共用)
|
|
28
|
+
// ============================================================
|
|
29
|
+
|
|
30
|
+
/** 拖拽约束:只允许上下拖动,且不超出排序列表(表单)的垂直范围。
|
|
31
|
+
* 替代官方 @dnd-kit/modifiers(未安装),语义等同
|
|
32
|
+
* restrictToVerticalAxis + restrictToParentElement。 */
|
|
33
|
+
const restrictToSortList: Modifier = ({ transform, activeNodeRect, containerNodeRect }) => {
|
|
34
|
+
let { x, y } = transform;
|
|
35
|
+
x = 0;
|
|
36
|
+
if (activeNodeRect && containerNodeRect) {
|
|
37
|
+
const containerTop = containerNodeRect.top - activeNodeRect.top;
|
|
38
|
+
const containerBottom = containerNodeRect.bottom - activeNodeRect.bottom;
|
|
39
|
+
y = Math.min(Math.max(y, containerTop), containerBottom);
|
|
40
|
+
}
|
|
41
|
+
return { ...transform, x, y };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function NewSessionMenu({ onClose, editingSession }: { onClose: () => void; editingSession?: ChatSession | null }) {
|
|
45
|
+
const npcs = useStore((s) => s.npcs);
|
|
46
|
+
const worldBooks = useStore((s) => s.worldBooks);
|
|
47
|
+
const addToast = useStore((s) => s.addToast);
|
|
48
|
+
const updateSessionSettings = useStore((s) => s.updateSessionSettings);
|
|
49
|
+
const t = useT();
|
|
50
|
+
const isEditing = Boolean(editingSession?.id);
|
|
51
|
+
|
|
52
|
+
const [participantOrder, setParticipantOrder] = useState<number[]>([-1]);
|
|
53
|
+
const [title, setTitle] = useState(editingSession?.title ?? '');
|
|
54
|
+
const [userPersonaNpcId, setUserPersonaNpcId] = useState<number | null>(editingSession?.userPersonaNpcId ?? null);
|
|
55
|
+
const [worldBookId, setWorldBookId] = useState<number | null>(editingSession?.worldBookId ?? null);
|
|
56
|
+
const [turnOrderMode, setTurnOrderMode] = useState<'PRESET' | 'RANDOM'>(editingSession?.turnOrderMode ?? 'PRESET');
|
|
57
|
+
const [mentionOnlyMode, setMentionOnlyMode] = useState(editingSession?.mentionOnlyMode === true);
|
|
58
|
+
const [enableGreeting, setEnableGreeting] = useState(editingSession?.enableGreeting !== false);
|
|
59
|
+
const selectedNpcIds = participantOrder.filter((id) => id !== -1);
|
|
60
|
+
const modeLabel = selectedNpcIds.length === 0 ? t('newSession.notSelected') : selectedNpcIds.length === 1 ? t('newSession.npcChat') : t('newSession.groupChat', { n: selectedNpcIds.length });
|
|
61
|
+
const sensors = useSensors(
|
|
62
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
63
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!editingSession?.id) return;
|
|
68
|
+
let cancelled = false;
|
|
69
|
+
(async () => {
|
|
70
|
+
const participants = (await db.participants.where('sessionId').equals(editingSession.id!).toArray()).sort((a, b) => a.seatOrder - b.seatOrder);
|
|
71
|
+
if (!cancelled) {
|
|
72
|
+
setTitle(editingSession.title);
|
|
73
|
+
setUserPersonaNpcId(editingSession.userPersonaNpcId ?? null);
|
|
74
|
+
setWorldBookId(editingSession.worldBookId ?? null);
|
|
75
|
+
setTurnOrderMode(editingSession.turnOrderMode);
|
|
76
|
+
setMentionOnlyMode(editingSession.mentionOnlyMode === true);
|
|
77
|
+
setEnableGreeting(editingSession.enableGreeting !== false);
|
|
78
|
+
setParticipantOrder(participants.map((p) => p.participantId));
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
};
|
|
84
|
+
}, [editingSession?.id]);
|
|
85
|
+
|
|
86
|
+
const create = async () => {
|
|
87
|
+
if (selectedNpcIds.length === 0) {
|
|
88
|
+
addToast(t('toast.pickOneNpc'), 'error');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const finalTitle = title.trim() || selectedNpcIds.map((id) => npcs.find((n) => n.id === id)?.name).filter(Boolean).join('、');
|
|
92
|
+
if (isEditing && editingSession?.id) {
|
|
93
|
+
await updateSessionSettings(editingSession.id, {
|
|
94
|
+
title: finalTitle,
|
|
95
|
+
npcIds: selectedNpcIds,
|
|
96
|
+
worldBookId,
|
|
97
|
+
userPersonaNpcId,
|
|
98
|
+
turnOrderMode,
|
|
99
|
+
participantOrder,
|
|
100
|
+
mentionOnlyMode,
|
|
101
|
+
enableGreeting,
|
|
102
|
+
});
|
|
103
|
+
await useStore.getState().refreshSessions();
|
|
104
|
+
useStore.getState().setActiveSession(editingSession.id);
|
|
105
|
+
addToast(t('toast.sessionUpdated'));
|
|
106
|
+
onClose();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let sid: number;
|
|
110
|
+
if (selectedNpcIds.length === 1) {
|
|
111
|
+
sid = await createSession('NPC', {
|
|
112
|
+
associatedId: selectedNpcIds[0],
|
|
113
|
+
title: finalTitle,
|
|
114
|
+
worldBookId,
|
|
115
|
+
userPersonaNpcId,
|
|
116
|
+
turnOrderMode,
|
|
117
|
+
participantOrder,
|
|
118
|
+
mentionOnlyMode,
|
|
119
|
+
enableGreeting,
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
sid = await createSession('GROUP', {
|
|
123
|
+
npcIds: selectedNpcIds,
|
|
124
|
+
title: finalTitle,
|
|
125
|
+
worldBookId,
|
|
126
|
+
userPersonaNpcId,
|
|
127
|
+
turnOrderMode,
|
|
128
|
+
participantOrder,
|
|
129
|
+
mentionOnlyMode,
|
|
130
|
+
enableGreeting,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
await useStore.getState().refreshSessions();
|
|
134
|
+
useStore.getState().setActiveSession(sid);
|
|
135
|
+
onClose();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const addParticipant = (npcId: number) => {
|
|
139
|
+
if (selectedNpcIds.includes(npcId)) {
|
|
140
|
+
addToast(t('toast.dupNpc'), 'error');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (selectedNpcIds.length >= 5) return;
|
|
144
|
+
setParticipantOrder((prev) => [...prev, npcId]);
|
|
145
|
+
};
|
|
146
|
+
const removeParticipant = (npcId: number) => {
|
|
147
|
+
setParticipantOrder((prev) => prev.filter((id) => id !== npcId));
|
|
148
|
+
};
|
|
149
|
+
const onDragEnd = (event: DragEndEvent) => {
|
|
150
|
+
const { active, over } = event;
|
|
151
|
+
if (!over || active.id === over.id) return;
|
|
152
|
+
setParticipantOrder((prev) => {
|
|
153
|
+
const from = prev.findIndex((id) => id === active.id);
|
|
154
|
+
const to = prev.findIndex((id) => id === over.id);
|
|
155
|
+
if (from < 0 || to < 0) return prev;
|
|
156
|
+
return arrayMove(prev, from, to);
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const canCreate = selectedNpcIds.length > 0;
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Modal onClose={onClose} width={460} className="new-session-modal">
|
|
164
|
+
<div className="modal-head">
|
|
165
|
+
<span style={{ fontWeight: 800, fontSize: 15 }}>{isEditing ? t('nav.editSession') : t('newSession.title')}</span>
|
|
166
|
+
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
|
167
|
+
</div>
|
|
168
|
+
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
169
|
+
{/* 标题 */}
|
|
170
|
+
<div className="field">
|
|
171
|
+
<label>{t('newSession.name')}</label>
|
|
172
|
+
<input
|
|
173
|
+
className="input"
|
|
174
|
+
value={title}
|
|
175
|
+
onChange={(e) => setTitle(e.target.value.slice(0, 60))}
|
|
176
|
+
placeholder={selectedNpcIds.length > 0 ? selectedNpcIds.map((id) => npcs.find((n) => n.id === id)?.name).join('、') : t('newSession.unnamed')}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* 参与者与顺序 */}
|
|
181
|
+
<div>
|
|
182
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
183
|
+
<label className="new-session-section-label">{t('newSession.participants')}</label>
|
|
184
|
+
<span className="tag">{modeLabel}</span>
|
|
185
|
+
</div>
|
|
186
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
187
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} modifiers={[restrictToSortList]} onDragEnd={onDragEnd}>
|
|
188
|
+
<SortableContext items={participantOrder} strategy={verticalListSortingStrategy}>
|
|
189
|
+
<div className="sort-list">
|
|
190
|
+
{participantOrder.map((id) => {
|
|
191
|
+
const npc = id === -1 ? null : npcs.find((n) => n.id === id) ?? null;
|
|
192
|
+
return (
|
|
193
|
+
<ParticipantSortItem
|
|
194
|
+
key={id}
|
|
195
|
+
participantId={id}
|
|
196
|
+
name={id === -1 ? t('common.user') : npc?.name ?? t('newSession.notSelected')}
|
|
197
|
+
avatarColorOrdinal={npc?.avatarColorOrdinal ?? 0}
|
|
198
|
+
avatarDataUrl={npc?.avatarDataUrl ?? null}
|
|
199
|
+
removable={id !== -1}
|
|
200
|
+
onRemove={() => removeParticipant(id)}
|
|
201
|
+
/>
|
|
202
|
+
);
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
</SortableContext>
|
|
206
|
+
</DndContext>
|
|
207
|
+
{npcs.length === 0 ? (
|
|
208
|
+
<div style={{ fontSize: 12.5, color: 'var(--warn)', background: 'var(--warn-soft)', padding: '8px 12px', borderRadius: 9 }}>
|
|
209
|
+
{t('newSession.noNpcWarn')}
|
|
210
|
+
</div>
|
|
211
|
+
) : (
|
|
212
|
+
selectedNpcIds.length < 5 && <SlotPicker excluded={selectedNpcIds} onPick={addParticipant} />
|
|
213
|
+
)}
|
|
214
|
+
{selectedNpcIds.length >= 2 && (
|
|
215
|
+
<>
|
|
216
|
+
<div className="new-session-random-row">
|
|
217
|
+
<div className="field" style={{ gap: 3 }}>
|
|
218
|
+
<label>{t('chat.sortRandom')}</label>
|
|
219
|
+
<span className="field-hint">{t('newSession.orderHint')}</span>
|
|
220
|
+
</div>
|
|
221
|
+
<label className="switch">
|
|
222
|
+
<input
|
|
223
|
+
type="checkbox"
|
|
224
|
+
checked={turnOrderMode === 'RANDOM'}
|
|
225
|
+
onChange={(e) => setTurnOrderMode(e.target.checked ? 'RANDOM' : 'PRESET')}
|
|
226
|
+
/>
|
|
227
|
+
<span className="switch-slider" />
|
|
228
|
+
</label>
|
|
229
|
+
</div>
|
|
230
|
+
<div className="new-session-random-row">
|
|
231
|
+
<div className="field" style={{ gap: 3 }}>
|
|
232
|
+
<label>{t('newSession.mentionOnly')}</label>
|
|
233
|
+
<span className="field-hint">{t('newSession.mentionOnlyHint')}</span>
|
|
234
|
+
</div>
|
|
235
|
+
<label className="switch">
|
|
236
|
+
<input type="checkbox" checked={mentionOnlyMode} onChange={(e) => setMentionOnlyMode(e.target.checked)} />
|
|
237
|
+
<span className="switch-slider" />
|
|
238
|
+
</label>
|
|
239
|
+
</div>
|
|
240
|
+
</>
|
|
241
|
+
)}
|
|
242
|
+
<div className="new-session-random-row">
|
|
243
|
+
<div className="field" style={{ gap: 3 }}>
|
|
244
|
+
<label>{t('newSession.greeting')}</label>
|
|
245
|
+
<span className="field-hint">{t('newSession.greetingHint')}</span>
|
|
246
|
+
</div>
|
|
247
|
+
<label className="switch">
|
|
248
|
+
<input
|
|
249
|
+
type="checkbox"
|
|
250
|
+
checked={enableGreeting}
|
|
251
|
+
onChange={(e) => setEnableGreeting(e.target.checked)}
|
|
252
|
+
/>
|
|
253
|
+
<span className="switch-slider" />
|
|
254
|
+
</label>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* 用户人设 */}
|
|
260
|
+
<div className="field">
|
|
261
|
+
<label>{t('newSession.persona')}</label>
|
|
262
|
+
<select className="select" value={userPersonaNpcId ?? ''} onChange={(e) => setUserPersonaNpcId(e.target.value ? Number(e.target.value) : null)}>
|
|
263
|
+
<option value="">{t('newSession.noPersona')}</option>
|
|
264
|
+
{npcs.filter((n) => n.id != null && !selectedNpcIds.includes(n.id!)).map((n) => (
|
|
265
|
+
<option key={n.id} value={n.id!}>{n.name}</option>
|
|
266
|
+
))}
|
|
267
|
+
</select>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* Lorebook */}
|
|
271
|
+
<div className="field">
|
|
272
|
+
<label>{t('newSession.worldbook')}</label>
|
|
273
|
+
<select className="select" value={worldBookId ?? ''} onChange={(e) => setWorldBookId(e.target.value ? Number(e.target.value) : null)}>
|
|
274
|
+
<option value="">{t('newSession.noWorldbook')}</option>
|
|
275
|
+
{worldBooks.map((b) => (
|
|
276
|
+
<option key={b.id} value={b.id!}>{b.name}</option>
|
|
277
|
+
))}
|
|
278
|
+
</select>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
<div className="modal-foot">
|
|
282
|
+
<button className="btn" onClick={onClose}>{t('common.cancel')}</button>
|
|
283
|
+
<button className="btn btn-primary" disabled={!canCreate} onClick={create}>{isEditing ? t('common.save') : t('common.ok')}</button>
|
|
284
|
+
</div>
|
|
285
|
+
</Modal>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function ParticipantSortItem({
|
|
290
|
+
participantId,
|
|
291
|
+
name,
|
|
292
|
+
avatarColorOrdinal,
|
|
293
|
+
avatarDataUrl,
|
|
294
|
+
removable,
|
|
295
|
+
onRemove,
|
|
296
|
+
}: {
|
|
297
|
+
participantId: number;
|
|
298
|
+
name: string;
|
|
299
|
+
avatarColorOrdinal: number;
|
|
300
|
+
avatarDataUrl: string | null;
|
|
301
|
+
removable: boolean;
|
|
302
|
+
onRemove: () => void;
|
|
303
|
+
}) {
|
|
304
|
+
const t = useT();
|
|
305
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: participantId });
|
|
306
|
+
return (
|
|
307
|
+
<div
|
|
308
|
+
ref={setNodeRef}
|
|
309
|
+
style={{
|
|
310
|
+
transform: CSS.Translate.toString(transform),
|
|
311
|
+
transition,
|
|
312
|
+
zIndex: isDragging ? 10 : undefined,
|
|
313
|
+
opacity: isDragging ? 0.85 : undefined,
|
|
314
|
+
position: 'relative',
|
|
315
|
+
}}
|
|
316
|
+
className={`sort-item ${isDragging ? 'dragging' : ''}`}
|
|
317
|
+
{...attributes}
|
|
318
|
+
{...listeners}
|
|
319
|
+
>
|
|
320
|
+
<span className="sort-grip">⠿</span>
|
|
321
|
+
<Avatar name={name} colorOrdinal={avatarColorOrdinal} imageUrl={avatarDataUrl} size="xs" />
|
|
322
|
+
<span className="sort-name">{name}</span>
|
|
323
|
+
{!removable && <span className="sort-tag">{t('common.you')}</span>}
|
|
324
|
+
{removable && (
|
|
325
|
+
<button className="npc-slot-x" onClick={(e) => { e.stopPropagation(); onRemove(); }}>
|
|
326
|
+
<Icon name="x" size={11} />
|
|
327
|
+
</button>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 角色的「+」添加按钮:点击后弹出角色选择(内联小列表) */
|
|
334
|
+
function SlotPicker({ excluded, onPick }: { excluded: number[]; onPick: (npcId: number) => void }) {
|
|
335
|
+
const npcs = useStore((s) => s.npcs);
|
|
336
|
+
const t = useT();
|
|
337
|
+
const [open, setOpen] = useState(false);
|
|
338
|
+
const available = npcs.filter((n) => n.id != null && !excluded.includes(n.id!));
|
|
339
|
+
|
|
340
|
+
if (available.length === 0) return null;
|
|
341
|
+
return (
|
|
342
|
+
<div className="slot-picker">
|
|
343
|
+
<button
|
|
344
|
+
className="npc-slot-add"
|
|
345
|
+
title={t('newSession.addCharTip')}
|
|
346
|
+
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
|
347
|
+
>
|
|
348
|
+
+ {t('newSession.addCharTip')}
|
|
349
|
+
</button>
|
|
350
|
+
{open && (
|
|
351
|
+
<div className="slot-picker-menu card">
|
|
352
|
+
{available.map((n) => (
|
|
353
|
+
<button key={n.id} className="slot-picker-item" onClick={() => { onPick(n.id!); setOpen(false); }}>
|
|
354
|
+
<Avatar name={n.name} colorOrdinal={n.avatarColorOrdinal} imageUrl={n.avatarDataUrl} size="xs" />
|
|
355
|
+
<span>{n.name}</span>
|
|
356
|
+
</button>
|
|
357
|
+
))}
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|