tavern-harness 0.1.12 → 0.1.13
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 +345 -0
- package/README.md +12 -4
- package/README.zh.md +12 -4
- package/bin/cli.js +16 -2
- package/index.html +3 -1
- package/package.json +18 -5
- package/persistence-server.mjs +303 -0
- package/public/sillytavern-logo.png +0 -0
- package/src/App.tsx +72 -8
- package/src/components/CharactersView.tsx +221 -68
- package/src/components/ChatView.tsx +98 -69
- package/src/components/FileDisplayModal.tsx +28 -17
- package/src/components/NewSessionMenu.tsx +2 -9
- package/src/components/PopoverMenu.tsx +54 -0
- package/src/components/SettingsView.tsx +21 -1
- package/src/components/Sidebar.tsx +133 -53
- package/src/components/VersionHistoryModal.tsx +114 -0
- package/src/components/shared.tsx +12 -3
- package/src/core/browserPreferences.ts +45 -0
- package/src/core/changelog.ts +43 -0
- package/src/core/characterBook.ts +32 -0
- package/src/core/clipboard.ts +32 -0
- package/src/core/editable.ts +26 -0
- package/src/core/gameplay.ts +2 -0
- package/src/core/i18n.ts +141 -10
- package/src/core/longPress.ts +209 -0
- package/src/core/longPressGesture.ts +110 -0
- package/src/core/mobileViewport.ts +362 -0
- package/src/core/openai.ts +39 -7
- package/src/core/sillyTavernWorldInfo.ts +159 -0
- package/src/core/sortableSensors.ts +21 -0
- package/src/core/stickyBottom.ts +45 -0
- package/src/core/tools/generatedSkillExecutor.ts +4 -20
- package/src/core/version.ts +46 -0
- package/src/core/viewport.ts +81 -0
- package/src/core/wechatTouch.ts +145 -0
- package/src/db/database.ts +313 -2
- package/src/generated/changelog.json +510 -0
- package/src/main.tsx +18 -2
- package/src/store/store.ts +99 -33
- package/src/theme/chat.css +174 -49
- package/src/theme/global.css +391 -10
- package/src/theme/theme.ts +9 -2
- package/src/theme/views.css +341 -8
- package/src/types/models.ts +9 -0
- package/src/vite-env.d.ts +4 -1
- package/vite.config.ts +184 -17
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Server-side SQLite persistence service. It is intentionally reachable only
|
|
3
|
+
// through Vite's same-origin proxy; this process itself listens on loopback.
|
|
4
|
+
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { mkdirSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
11
|
+
|
|
12
|
+
const PORT = Number(process.argv[2] || process.env.PERSISTENCE_PORT || 17892);
|
|
13
|
+
// 记录读写(图片/附件)走大上限;锁和健康检查的请求体很小,单独收紧。
|
|
14
|
+
const MAX_RECORD_BODY_BYTES = 30 * 1024 * 1024;
|
|
15
|
+
const MAX_CONTROL_BODY_BYTES = 1024 * 1024;
|
|
16
|
+
const SERVICE_TOKEN = process.env.PERSISTENCE_SERVICE_TOKEN;
|
|
17
|
+
if (!SERVICE_TOKEN) {
|
|
18
|
+
console.error('[persistence-service] missing PERSISTENCE_SERVICE_TOKEN');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dataDir = resolve(process.env.TAVERN_HARNESS_DATA_DIR || join(homedir(), '.tavern-harness'));
|
|
23
|
+
mkdirSync(dataDir, { recursive: true });
|
|
24
|
+
const databasePath = join(dataDir, 'tavern-harness.sqlite');
|
|
25
|
+
const database = new DatabaseSync(databasePath);
|
|
26
|
+
database.exec(`
|
|
27
|
+
PRAGMA journal_mode = WAL;
|
|
28
|
+
PRAGMA foreign_keys = ON;
|
|
29
|
+
PRAGMA busy_timeout = 5000;
|
|
30
|
+
CREATE TABLE IF NOT EXISTS app_records (
|
|
31
|
+
collection TEXT NOT NULL,
|
|
32
|
+
record_key TEXT NOT NULL,
|
|
33
|
+
value_json TEXT NOT NULL,
|
|
34
|
+
updated_at INTEGER NOT NULL,
|
|
35
|
+
PRIMARY KEY (collection, record_key)
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_app_records_collection ON app_records(collection);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS record_sequences (
|
|
39
|
+
collection TEXT PRIMARY KEY,
|
|
40
|
+
next_id INTEGER NOT NULL
|
|
41
|
+
);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS service_state (
|
|
43
|
+
state_key TEXT PRIMARY KEY,
|
|
44
|
+
value_json TEXT NOT NULL
|
|
45
|
+
);
|
|
46
|
+
`);
|
|
47
|
+
|
|
48
|
+
const getServiceState = database.prepare('SELECT value_json FROM service_state WHERE state_key = ?');
|
|
49
|
+
const saveServiceState = database.prepare(`
|
|
50
|
+
INSERT INTO service_state (state_key, value_json) VALUES (?, ?)
|
|
51
|
+
ON CONFLICT(state_key) DO UPDATE SET value_json = excluded.value_json
|
|
52
|
+
`);
|
|
53
|
+
const deleteServiceState = database.prepare('DELETE FROM service_state WHERE state_key = ?');
|
|
54
|
+
|
|
55
|
+
function loadWriteLock() {
|
|
56
|
+
const row = getServiceState.get('write_lock');
|
|
57
|
+
if (!row) return null;
|
|
58
|
+
try {
|
|
59
|
+
const lock = JSON.parse(row.value_json);
|
|
60
|
+
return typeof lock?.clientId === 'string' && typeof lock?.leaseId === 'string' ? lock : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let writeLock = loadWriteLock();
|
|
67
|
+
|
|
68
|
+
function setWriteLock(lock) {
|
|
69
|
+
writeLock = lock;
|
|
70
|
+
if (lock) saveServiceState.run('write_lock', JSON.stringify(lock));
|
|
71
|
+
else deleteServiceState.run('write_lock');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function json(res, status, body) {
|
|
75
|
+
res.writeHead(status, {
|
|
76
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
77
|
+
'Cache-Control': 'no-store',
|
|
78
|
+
});
|
|
79
|
+
res.end(JSON.stringify(body));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readJson(req, maxBytes) {
|
|
83
|
+
return new Promise((resolveBody, reject) => {
|
|
84
|
+
let raw = '';
|
|
85
|
+
let bytes = 0;
|
|
86
|
+
let tooLarge = false;
|
|
87
|
+
req.setEncoding('utf8');
|
|
88
|
+
req.on('data', (chunk) => {
|
|
89
|
+
if (tooLarge) return; // 拒绝后继续排空请求体,但不再累计内存
|
|
90
|
+
bytes += Buffer.byteLength(chunk, 'utf8');
|
|
91
|
+
if (bytes > maxBytes) {
|
|
92
|
+
tooLarge = true;
|
|
93
|
+
const error = new Error('Request body is too large');
|
|
94
|
+
error.status = 413;
|
|
95
|
+
reject(error);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
raw += chunk;
|
|
99
|
+
});
|
|
100
|
+
req.on('end', () => {
|
|
101
|
+
if (tooLarge) return;
|
|
102
|
+
if (!raw) return resolveBody({});
|
|
103
|
+
try {
|
|
104
|
+
resolveBody(JSON.parse(raw));
|
|
105
|
+
} catch {
|
|
106
|
+
reject(new Error('Invalid JSON request body'));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
req.on('error', reject);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function tokenMatches(value) {
|
|
114
|
+
if (typeof value !== 'string') return false;
|
|
115
|
+
const expected = Buffer.from(SERVICE_TOKEN);
|
|
116
|
+
const actual = Buffer.from(value);
|
|
117
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validCollection(value) {
|
|
121
|
+
return typeof value === 'string' && /^[a-z][a-z0-9_]{0,63}$/i.test(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validKey(value) {
|
|
125
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 255;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function lockState(clientId) {
|
|
129
|
+
return {
|
|
130
|
+
writable: Boolean(writeLock && writeLock.clientId === clientId),
|
|
131
|
+
held: Boolean(writeLock),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function requireWriter(clientId) {
|
|
136
|
+
if (!writeLock || writeLock.clientId !== clientId) {
|
|
137
|
+
const error = new Error(writeLock ? 'Another browser currently holds the write lock' : 'Write lock required');
|
|
138
|
+
error.status = 423;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function saveRecord(collection, key, value) {
|
|
144
|
+
database.prepare(`
|
|
145
|
+
INSERT INTO app_records (collection, record_key, value_json, updated_at)
|
|
146
|
+
VALUES (?, ?, ?, ?)
|
|
147
|
+
ON CONFLICT(collection, record_key) DO UPDATE SET
|
|
148
|
+
value_json = excluded.value_json,
|
|
149
|
+
updated_at = excluded.updated_at
|
|
150
|
+
`).run(collection, key, JSON.stringify(value), Date.now());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const getRecord = database.prepare('SELECT value_json, updated_at FROM app_records WHERE collection = ? AND record_key = ?');
|
|
154
|
+
const listRecords = database.prepare('SELECT record_key, value_json, updated_at FROM app_records WHERE collection = ? ORDER BY record_key');
|
|
155
|
+
const deleteRecord = database.prepare('DELETE FROM app_records WHERE collection = ? AND record_key = ?');
|
|
156
|
+
const clearCollection = database.prepare('DELETE FROM app_records WHERE collection = ?');
|
|
157
|
+
const nextNumericRecordKey = database.prepare(`
|
|
158
|
+
SELECT COALESCE(MAX(CAST(record_key AS INTEGER)), 0) + 1 AS next_key
|
|
159
|
+
FROM app_records
|
|
160
|
+
WHERE collection = ? AND record_key GLOB '[0-9]*'
|
|
161
|
+
`);
|
|
162
|
+
const getRecordSequence = database.prepare('SELECT next_id FROM record_sequences WHERE collection = ?');
|
|
163
|
+
const saveRecordSequence = database.prepare(`
|
|
164
|
+
INSERT INTO record_sequences (collection, next_id) VALUES (?, ?)
|
|
165
|
+
ON CONFLICT(collection) DO UPDATE SET next_id = excluded.next_id
|
|
166
|
+
`);
|
|
167
|
+
|
|
168
|
+
function allocateNumericRecordId(collection) {
|
|
169
|
+
database.exec('BEGIN IMMEDIATE');
|
|
170
|
+
try {
|
|
171
|
+
const sequence = getRecordSequence.get(collection);
|
|
172
|
+
const initialId = Number(nextNumericRecordKey.get(collection).next_key);
|
|
173
|
+
const id = Math.max(Number(sequence?.next_id) || 0, initialId);
|
|
174
|
+
saveRecordSequence.run(collection, id + 1);
|
|
175
|
+
database.exec('COMMIT');
|
|
176
|
+
return id;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
database.exec('ROLLBACK');
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const server = createServer(async (req, res) => {
|
|
184
|
+
if (!tokenMatches(req.headers['x-persistence-service-token'])) {
|
|
185
|
+
json(res, 401, { ok: false, message: 'Unauthorized persistence service request' });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
191
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
192
|
+
if (req.method === 'GET' && pathname === '/health') {
|
|
193
|
+
json(res, 200, { ok: true, databasePath, lock: lockState(null) });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (req.method !== 'POST') {
|
|
197
|
+
json(res, 405, { ok: false, message: 'Method not allowed' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const bodyLimit = pathname.startsWith('/records/') ? MAX_RECORD_BODY_BYTES : MAX_CONTROL_BODY_BYTES;
|
|
202
|
+
const body = await readJson(req, bodyLimit);
|
|
203
|
+
const clientId = typeof body.clientId === 'string' ? body.clientId : '';
|
|
204
|
+
|
|
205
|
+
if (pathname === '/lock/status') {
|
|
206
|
+
json(res, 200, { ok: true, lock: lockState(clientId) });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (pathname === '/lock/acquire' || pathname === '/lock/takeover') {
|
|
210
|
+
const existing = writeLock;
|
|
211
|
+
if (!clientId || clientId.length > 255) {
|
|
212
|
+
json(res, 400, { ok: false, message: 'A valid clientId is required' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (existing && existing.clientId !== clientId && pathname !== '/lock/takeover') {
|
|
216
|
+
json(res, 423, { ok: false, message: 'Another browser currently holds the write lock', lock: lockState(clientId) });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
setWriteLock({ clientId, leaseId: randomBytes(24).toString('base64url') });
|
|
220
|
+
json(res, 200, { ok: true, leaseId: writeLock.leaseId, lock: lockState(clientId) });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!pathname.startsWith('/records/')) {
|
|
224
|
+
json(res, 404, { ok: false, message: `Unknown persistence endpoint: ${pathname}` });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const collection = body.collection;
|
|
229
|
+
if (!validCollection(collection)) {
|
|
230
|
+
json(res, 400, { ok: false, message: `Invalid collection for ${pathname}` });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (pathname === '/records/list') {
|
|
235
|
+
const records = listRecords.all(collection).map((row) => ({
|
|
236
|
+
key: row.record_key,
|
|
237
|
+
value: JSON.parse(row.value_json),
|
|
238
|
+
updatedAt: row.updated_at,
|
|
239
|
+
}));
|
|
240
|
+
json(res, 200, { ok: true, records });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (pathname === '/records/get') {
|
|
244
|
+
if (!validKey(body.key)) {
|
|
245
|
+
json(res, 400, { ok: false, message: 'Invalid record key' });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const record = getRecord.get(collection, body.key);
|
|
249
|
+
json(res, 200, {
|
|
250
|
+
ok: true,
|
|
251
|
+
record: record ? { key: body.key, value: JSON.parse(record.value_json), updatedAt: record.updated_at } : null,
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (pathname === '/records/allocate-id') {
|
|
256
|
+
requireWriter(clientId);
|
|
257
|
+
json(res, 200, { ok: true, id: allocateNumericRecordId(collection) });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (pathname === '/records/put') {
|
|
261
|
+
if (!validKey(body.key)) {
|
|
262
|
+
json(res, 400, { ok: false, message: 'Invalid record key' });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
requireWriter(clientId);
|
|
266
|
+
saveRecord(collection, body.key, body.value);
|
|
267
|
+
json(res, 200, { ok: true });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (pathname === '/records/delete') {
|
|
271
|
+
if (!validKey(body.key)) {
|
|
272
|
+
json(res, 400, { ok: false, message: 'Invalid record key' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
requireWriter(clientId);
|
|
276
|
+
deleteRecord.run(collection, body.key);
|
|
277
|
+
json(res, 200, { ok: true });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (pathname === '/records/clear') {
|
|
281
|
+
requireWriter(clientId);
|
|
282
|
+
clearCollection.run(collection);
|
|
283
|
+
json(res, 200, { ok: true });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
json(res, 404, { ok: false, message: 'Unknown persistence endpoint' });
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const status = typeof error?.status === 'number' ? error.status : 500;
|
|
290
|
+
json(res, status, { ok: false, message: error instanceof Error ? error.message : 'Unexpected persistence service error' });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
295
|
+
console.log(`[persistence-service] listening on 127.0.0.1:${PORT}; data: ${databasePath}`);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
function shutdown() {
|
|
299
|
+
database.close();
|
|
300
|
+
server.close(() => process.exit(0));
|
|
301
|
+
}
|
|
302
|
+
process.once('SIGINT', shutdown);
|
|
303
|
+
process.once('SIGTERM', shutdown);
|
|
Binary file
|
package/src/App.tsx
CHANGED
|
@@ -21,8 +21,14 @@ import './theme/chat.css';
|
|
|
21
21
|
import './theme/views.css';
|
|
22
22
|
|
|
23
23
|
export default function App() {
|
|
24
|
-
const
|
|
24
|
+
const [takingControl, setTakingControl] = useState(false);
|
|
25
|
+
const initStatus = useStore((s) => s.initStatus);
|
|
26
|
+
const initError = useStore((s) => s.initError);
|
|
27
|
+
const addToast = useStore((s) => s.addToast);
|
|
28
|
+
const readOnly = useStore((s) => s.readOnly);
|
|
25
29
|
const init = useStore((s) => s.init);
|
|
30
|
+
const syncControlState = useStore((s) => s.syncControlState);
|
|
31
|
+
const takeOverControl = useStore((s) => s.takeOverControl);
|
|
26
32
|
const activeView = useStore((s) => s.activeView);
|
|
27
33
|
const activeSessionId = useStore((s) => s.activeSessionId);
|
|
28
34
|
const sessions = useStore((s) => s.sessions);
|
|
@@ -30,6 +36,7 @@ export default function App() {
|
|
|
30
36
|
const contextCompressions = useStore((s) => s.contextCompressions);
|
|
31
37
|
const participants = useStore((s) => s.participants);
|
|
32
38
|
const isStreaming = useStore((s) => s.streaming.sessionId != null);
|
|
39
|
+
const t = useT();
|
|
33
40
|
|
|
34
41
|
useEffect(() => {
|
|
35
42
|
registerUnlockDispatcher(async (achievement, total) => {
|
|
@@ -43,6 +50,12 @@ export default function App() {
|
|
|
43
50
|
init();
|
|
44
51
|
}, [init]);
|
|
45
52
|
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (readOnly) return;
|
|
55
|
+
const timer = window.setInterval(() => void syncControlState(), 1_000);
|
|
56
|
+
return () => window.clearInterval(timer);
|
|
57
|
+
}, [readOnly, syncControlState]);
|
|
58
|
+
|
|
46
59
|
useEffect(() => {
|
|
47
60
|
if (!isStreaming) return;
|
|
48
61
|
const confirmLeave = (event: BeforeUnloadEvent) => {
|
|
@@ -58,13 +71,64 @@ export default function App() {
|
|
|
58
71
|
const sessionParticipants = activeSessionId != null ? (participants[activeSessionId] ?? []) : [];
|
|
59
72
|
const isStreamingSession = useStore((s) => s.streaming.sessionId === activeSessionId);
|
|
60
73
|
|
|
61
|
-
|
|
74
|
+
const handleTakeOverControl = async () => {
|
|
75
|
+
setTakingControl(true);
|
|
76
|
+
try {
|
|
77
|
+
await takeOverControl();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error('Failed to take over control', error);
|
|
80
|
+
addToast(t('control.takeOverFailed'), 'error');
|
|
81
|
+
} finally {
|
|
82
|
+
setTakingControl(false);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (initStatus === 'loading') {
|
|
62
87
|
return <div className="tav-bg" />;
|
|
63
88
|
}
|
|
64
89
|
|
|
90
|
+
if (initStatus === 'error') {
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<div className="tav-bg" />
|
|
94
|
+
<div className="init-error" role="alertdialog" aria-modal="true" aria-labelledby="init-error-title">
|
|
95
|
+
<div className="init-error-inner">
|
|
96
|
+
<h1 id="init-error-title">{t('init.failed')}</h1>
|
|
97
|
+
<p className="init-error-hint">{t('init.failedHint')}</p>
|
|
98
|
+
<p className="init-error-detail">{initError}</p>
|
|
99
|
+
{/* 焦点直接落在唯一可操作项上,键盘 / 读屏用户不必先找 */}
|
|
100
|
+
<button type="button" className="init-error-retry" autoFocus onClick={() => void init()}>
|
|
101
|
+
{t('init.retry')}
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
65
109
|
return (
|
|
66
110
|
<>
|
|
67
111
|
<div className="tav-bg" />
|
|
112
|
+
{readOnly && (
|
|
113
|
+
<div
|
|
114
|
+
role="dialog"
|
|
115
|
+
aria-modal="true"
|
|
116
|
+
aria-label={t('control.dialogLabel')}
|
|
117
|
+
style={{ position: 'fixed', inset: 0, zIndex: 10000, display: 'grid', placeItems: 'center', backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)' }}
|
|
118
|
+
>
|
|
119
|
+
<main style={{ textAlign: 'center' }}>
|
|
120
|
+
<h1>{t('control.activeElsewhere')}</h1>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
disabled={takingControl}
|
|
124
|
+
onClick={() => void handleTakeOverControl()}
|
|
125
|
+
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)' }}
|
|
126
|
+
>
|
|
127
|
+
{t('control.takeOverHere')}
|
|
128
|
+
</button>
|
|
129
|
+
</main>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
68
132
|
<div className="app-shell">
|
|
69
133
|
<Sidebar />
|
|
70
134
|
<main className="main-area">
|
|
@@ -92,12 +156,12 @@ export default function App() {
|
|
|
92
156
|
{activeView === 'stats' && <StatsView />}
|
|
93
157
|
</main>
|
|
94
158
|
</div>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
159
|
+
<ConfirmationDialog />
|
|
160
|
+
<MessageMenu />
|
|
161
|
+
<Toasts />
|
|
162
|
+
<AchievementModal />
|
|
163
|
+
<FileDisplayModal />
|
|
164
|
+
{/* 游戏导出 / 导入的全局入口(会话右键菜单 / 侧边栏导入按钮共用) */}
|
|
101
165
|
<GameplayEntryDialogs />
|
|
102
166
|
</>
|
|
103
167
|
);
|