xinyu-pro 0.21.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/.env.example +21 -0
- package/README.md +36 -0
- package/app/api/chat/route.ts +84 -0
- package/app/api/generate-svg/route.ts +171 -0
- package/app/api/generate-theme/route.ts +137 -0
- package/app/api/plugins/bindings/route.ts +173 -0
- package/app/api/plugins/export/route.ts +122 -0
- package/app/api/plugins/export-xye/route.ts +156 -0
- package/app/api/plugins/files/route.ts +146 -0
- package/app/api/plugins/files-list/route.ts +168 -0
- package/app/api/plugins/files-upload/route.ts +101 -0
- package/app/api/plugins/files-write/route.ts +272 -0
- package/app/api/plugins/import/route.ts +140 -0
- package/app/api/plugins/import-package/route.ts +231 -0
- package/app/api/plugins/resources/route.ts +109 -0
- package/app/api/plugins/route.ts +308 -0
- package/app/api/plugins/scan/route.ts +280 -0
- package/app/api/plugins/storage/route.ts +146 -0
- package/app/api/sessions/route.ts +165 -0
- package/app/api/settings/route.ts +40 -0
- package/app/api/suggest-fields/route.ts +129 -0
- package/app/api/templates/route.ts +159 -0
- package/app/api/test-api/route.ts +63 -0
- package/app/editor/page.tsx +1466 -0
- package/app/extensions/create/page.tsx +1422 -0
- package/app/extensions/edit/[id]/page.tsx +2342 -0
- package/app/extensions/page.tsx +1572 -0
- package/app/extensions/tutorial/page.tsx +4258 -0
- package/app/favicon.ico +0 -0
- package/app/fonts/GeistMonoVF.woff +0 -0
- package/app/fonts/GeistVF.woff +0 -0
- package/app/game/[id]/page.tsx +996 -0
- package/app/globals.css +3 -0
- package/app/layout.tsx +26 -0
- package/app/loading.tsx +26 -0
- package/app/page.tsx +345 -0
- package/app/settings/page.tsx +1490 -0
- package/bin/cli.js +262 -0
- package/components/ChatInput.tsx +106 -0
- package/components/ChatWindow.tsx +52 -0
- package/components/FullPageLoader.tsx +107 -0
- package/components/LoadingDots.tsx +20 -0
- package/components/MathCurveLoader.tsx +173 -0
- package/components/MessageBubble.tsx +147 -0
- package/components/WorldCardPreview.tsx +98 -0
- package/components/WorldCardUploader.tsx +58 -0
- package/components/ui/ConfirmDialog.tsx +135 -0
- package/components/ui/PageHeader.tsx +99 -0
- package/components/ui/PermissionConflictDialog.tsx +206 -0
- package/components/ui/PluginConfigForm.tsx +192 -0
- package/components/ui/PluginFloatingLayer.tsx +52 -0
- package/components/ui/PluginIcon.tsx +53 -0
- package/components/ui/PluginModalRenderer.tsx +185 -0
- package/components/ui/PluginProvider.tsx +1038 -0
- package/components/ui/PluginSlotRenderer.tsx +76 -0
- package/components/ui/ThemeCustomizer.tsx +174 -0
- package/components/ui/ThemeProvider.tsx +125 -0
- package/components/ui/ThemeSwitcher.tsx +140 -0
- package/components/ui/ToastProvider.tsx +141 -0
- package/lib/builtin-plugins.ts +11 -0
- package/lib/db-init.ts +35 -0
- package/lib/db.ts +244 -0
- package/lib/manifest-parser.ts +185 -0
- package/lib/parseWorldCard.ts +110 -0
- package/lib/plugin-dom-sandbox.ts +327 -0
- package/lib/plugin-events.ts +88 -0
- package/lib/plugin-files.ts +186 -0
- package/lib/plugin-html-sanitizer.ts +79 -0
- package/lib/plugin-resource-tracker.ts +175 -0
- package/lib/plugin-runtime.ts +2287 -0
- package/lib/plugin-security.ts +151 -0
- package/lib/plugin-types.ts +416 -0
- package/lib/prompt-builder.ts +55 -0
- package/lib/router-history.ts +119 -0
- package/lib/storage.ts +381 -0
- package/lib/themes.ts +129 -0
- package/lib/types.ts +117 -0
- package/lib/version.ts +55 -0
- package/next.config.mjs +43 -0
- package/package.json +56 -0
- package/plugins/xinyu.bag-system.xye +0 -0
- package/plugins/xinyu.cache-optimizer.xye +0 -0
- package/plugins/xinyu.dice-arbiter.xye +0 -0
- package/plugins/xinyu.game-auto-start-choices.xye +0 -0
- package/plugins/xinyu.markdown-render.xye +0 -0
- package/plugins/xinyu.slot-ui-beautify.xye +0 -0
- package/plugins/xinyu.world-info.xye +0 -0
- package/postcss.config.mjs +8 -0
- package/public/templates/atlantis.svg +63 -0
- package/public/templates/cyber-city.svg +68 -0
- package/public/templates/jianghu.svg +69 -0
- package/public/templates//351/255/224/346/263/225/344/270/226/347/225/214.svg +137 -0
- package/styles/themes.css +111 -0
- package/tailwind.config.ts +18 -0
- package/tsconfig.json +26 -0
- package/version.json +6 -0
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
/* eslint-disable react-hooks/exhaustive-deps */
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
5
|
+
import { useParams } from 'next/navigation';
|
|
6
|
+
import { useRouterHistory } from '@/lib/router-history';
|
|
7
|
+
import { ChatMessage, WorldSetting } from '@/lib/types';
|
|
8
|
+
import { ChatWindow } from '@/components/ChatWindow';
|
|
9
|
+
import { ChatInput } from '@/components/ChatInput';
|
|
10
|
+
import { ThemeSwitcher } from '@/components/ui/ThemeSwitcher';
|
|
11
|
+
import { PluginProvider, usePluginContext } from '@/components/ui/PluginProvider';
|
|
12
|
+
import { PluginSlotRenderer } from '@/components/ui/PluginSlotRenderer';
|
|
13
|
+
import { PluginModalRenderer } from '@/components/ui/PluginModalRenderer';
|
|
14
|
+
import { PermissionConflictDialog } from '@/components/ui/PermissionConflictDialog';
|
|
15
|
+
import { PluginFloatingLayer } from '@/components/ui/PluginFloatingLayer';
|
|
16
|
+
import { PluginConfigForm } from '@/components/ui/PluginConfigForm';
|
|
17
|
+
import { PluginIcon } from '@/components/ui/PluginIcon';
|
|
18
|
+
import { getGameSession, saveGameSession, getAppSettings, StoredSession } from '@/lib/storage';
|
|
19
|
+
import { getPluginRuntime } from '@/lib/plugin-runtime';
|
|
20
|
+
import { buildSystemPrompt } from '@/lib/prompt-builder';
|
|
21
|
+
import { PluginManifest, PluginBinding, PLUGIN_TYPE_LABELS } from '@/lib/plugin-types';
|
|
22
|
+
import { getPlugins, getPluginBindings, upsertPluginBinding } from '@/lib/storage';
|
|
23
|
+
import { useToast } from '@/components/ui/ToastProvider';
|
|
24
|
+
import { broadcastPluginBindingChange } from '@/lib/plugin-events';
|
|
25
|
+
import FullPageLoader from '@/components/FullPageLoader';
|
|
26
|
+
import MathCurveLoader from '@/components/MathCurveLoader';
|
|
27
|
+
|
|
28
|
+
// ==================== 插件侧边栏面板 ====================
|
|
29
|
+
|
|
30
|
+
function PluginDrawer({
|
|
31
|
+
open,
|
|
32
|
+
onClose,
|
|
33
|
+
gameId,
|
|
34
|
+
}: {
|
|
35
|
+
open: boolean;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
gameId: string;
|
|
38
|
+
}) {
|
|
39
|
+
const { navigate } = useRouterHistory();
|
|
40
|
+
const { toast } = useToast();
|
|
41
|
+
const pluginCtx = usePluginContext();
|
|
42
|
+
const [plugins, setPlugins] = useState<PluginManifest[]>([]);
|
|
43
|
+
const [bindings, setBindings] = useState<Map<string, PluginBinding>>(new Map());
|
|
44
|
+
const [loading, setLoading] = useState(true);
|
|
45
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
46
|
+
const [drawerWidth, setDrawerWidth] = useState(320);
|
|
47
|
+
const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
|
|
48
|
+
const isDraggingRef = useRef(false);
|
|
49
|
+
|
|
50
|
+
const loadData = useCallback(async () => {
|
|
51
|
+
try {
|
|
52
|
+
const [pluginList, globalBindingList, worldBindingList] = await Promise.all([
|
|
53
|
+
getPlugins(),
|
|
54
|
+
getPluginBindings('global'),
|
|
55
|
+
getPluginBindings('world', gameId),
|
|
56
|
+
]);
|
|
57
|
+
setPlugins(pluginList);
|
|
58
|
+
|
|
59
|
+
// 合并:全局禁用优先,全局启用不传播到运行中的游戏
|
|
60
|
+
const globalMap = new Map<string, PluginBinding>();
|
|
61
|
+
for (const b of globalBindingList) globalMap.set(b.extensionId, b);
|
|
62
|
+
const worldMap = new Map<string, PluginBinding>();
|
|
63
|
+
for (const b of worldBindingList) worldMap.set(b.extensionId, b);
|
|
64
|
+
|
|
65
|
+
const map = new Map<string, PluginBinding>();
|
|
66
|
+
for (const plugin of pluginList) {
|
|
67
|
+
const global = globalMap.get(plugin.id);
|
|
68
|
+
const world = worldMap.get(plugin.id);
|
|
69
|
+
if (global && !global.enabled) {
|
|
70
|
+
// 全局禁用 → 强制禁用
|
|
71
|
+
map.set(plugin.id, global);
|
|
72
|
+
} else if (world) {
|
|
73
|
+
// 有世界级绑定 → 以世界级为准
|
|
74
|
+
map.set(plugin.id, world);
|
|
75
|
+
}
|
|
76
|
+
// 无世界级绑定 → 不加载(全局启用不影响运行中游戏)
|
|
77
|
+
}
|
|
78
|
+
setBindings(map);
|
|
79
|
+
} catch {
|
|
80
|
+
toast('加载插件数据失败', 'error');
|
|
81
|
+
} finally {
|
|
82
|
+
setLoading(false);
|
|
83
|
+
}
|
|
84
|
+
}, [toast, gameId]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (open) loadData();
|
|
88
|
+
}, [open, loadData]);
|
|
89
|
+
|
|
90
|
+
const handleToggle = async (pluginId: string) => {
|
|
91
|
+
const binding = bindings.get(pluginId);
|
|
92
|
+
const newEnabled = !binding?.enabled;
|
|
93
|
+
try {
|
|
94
|
+
// 局内切换:操作世界级绑定
|
|
95
|
+
const ok = await upsertPluginBinding({
|
|
96
|
+
extensionId: pluginId,
|
|
97
|
+
scope: 'world',
|
|
98
|
+
worldId: gameId,
|
|
99
|
+
enabled: newEnabled,
|
|
100
|
+
config: binding?.config || {},
|
|
101
|
+
});
|
|
102
|
+
if (ok) {
|
|
103
|
+
const plugin = plugins.find(p => p.id === pluginId);
|
|
104
|
+
toast(newEnabled ? `已启用「${plugin?.name || pluginId}」` : `已禁用「${plugin?.name || pluginId}」`, 'success');
|
|
105
|
+
|
|
106
|
+
// 局内启用时:同步写回全局默认值为启用(覆盖局外默认值)
|
|
107
|
+
if (newEnabled) {
|
|
108
|
+
await upsertPluginBinding({
|
|
109
|
+
extensionId: pluginId,
|
|
110
|
+
scope: 'global',
|
|
111
|
+
worldId: '',
|
|
112
|
+
enabled: true,
|
|
113
|
+
config: binding?.config || {},
|
|
114
|
+
});
|
|
115
|
+
broadcastPluginBindingChange({ pluginId, enabled: true, source: 'world', worldId: gameId });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 更新本地状态
|
|
119
|
+
setBindings(prev => {
|
|
120
|
+
const next = new Map(prev);
|
|
121
|
+
next.set(pluginId, {
|
|
122
|
+
id: binding?.id || 0,
|
|
123
|
+
extensionId: pluginId,
|
|
124
|
+
scope: 'world',
|
|
125
|
+
worldId: gameId,
|
|
126
|
+
enabled: newEnabled,
|
|
127
|
+
config: binding?.config || {},
|
|
128
|
+
sortOrder: binding?.sortOrder || 0,
|
|
129
|
+
});
|
|
130
|
+
return next;
|
|
131
|
+
});
|
|
132
|
+
// 只重新加载被操作的插件,不影响其他插件
|
|
133
|
+
await pluginCtx.updateAndReload(pluginId, {
|
|
134
|
+
id: binding?.id || 0,
|
|
135
|
+
extensionId: pluginId,
|
|
136
|
+
scope: 'world',
|
|
137
|
+
worldId: gameId,
|
|
138
|
+
enabled: newEnabled,
|
|
139
|
+
config: binding?.config || {},
|
|
140
|
+
sortOrder: binding?.sortOrder || 0,
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
toast('操作失败', 'error');
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
toast('操作失败', 'error');
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const typeLabel = (type: string) => {
|
|
151
|
+
const info = PLUGIN_TYPE_LABELS[type as keyof typeof PLUGIN_TYPE_LABELS];
|
|
152
|
+
return info ? `${info.icon} ${info.label}` : type;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// 配置变更处理(仅更新本地 state,持久化和运行时更新由 PluginConfigForm 负责)
|
|
156
|
+
const handleConfigChange = useCallback((_pluginId: string, key: string, value: unknown) => {
|
|
157
|
+
setConfigValues(prev => ({ ...prev, [key]: value }));
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
// 展开插件时初始化配置值
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (expandedId) {
|
|
163
|
+
const binding = bindings.get(expandedId);
|
|
164
|
+
setConfigValues(binding?.config ? { ...binding.config } : {});
|
|
165
|
+
}
|
|
166
|
+
}, [expandedId, bindings]);
|
|
167
|
+
|
|
168
|
+
// 抽屉宽度拖拽
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
171
|
+
if (!isDraggingRef.current) return;
|
|
172
|
+
const newWidth = window.innerWidth - e.clientX;
|
|
173
|
+
setDrawerWidth(Math.max(240, Math.min(600, newWidth)));
|
|
174
|
+
};
|
|
175
|
+
const handleMouseUp = () => {
|
|
176
|
+
if (isDraggingRef.current) {
|
|
177
|
+
isDraggingRef.current = false;
|
|
178
|
+
document.body.style.userSelect = '';
|
|
179
|
+
document.body.style.cursor = '';
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
183
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
184
|
+
return () => {
|
|
185
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
186
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
187
|
+
};
|
|
188
|
+
}, []);
|
|
189
|
+
|
|
190
|
+
const handleResizeMouseDown = (e: React.MouseEvent) => {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
isDraggingRef.current = true;
|
|
193
|
+
document.body.style.userSelect = 'none';
|
|
194
|
+
document.body.style.cursor = 'col-resize';
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// 遮罩层 + 侧边栏
|
|
198
|
+
return (
|
|
199
|
+
<>
|
|
200
|
+
{/* 遮罩 */}
|
|
201
|
+
{open && (
|
|
202
|
+
<div
|
|
203
|
+
className="fixed inset-0 z-40 transition-opacity"
|
|
204
|
+
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
|
205
|
+
onClick={onClose}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{/* 侧边栏 */}
|
|
210
|
+
<div
|
|
211
|
+
className="fixed top-0 right-0 z-50 h-full flex flex-col transition-transform duration-200"
|
|
212
|
+
style={{
|
|
213
|
+
width: drawerWidth + 'px',
|
|
214
|
+
backgroundColor: 'var(--color-bg-secondary)',
|
|
215
|
+
borderLeft: '1px solid var(--color-border)',
|
|
216
|
+
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{/* 拖拽调节宽度手柄 */}
|
|
220
|
+
<div
|
|
221
|
+
onMouseDown={handleResizeMouseDown}
|
|
222
|
+
style={{
|
|
223
|
+
position: 'absolute',
|
|
224
|
+
left: 0,
|
|
225
|
+
top: 0,
|
|
226
|
+
bottom: 0,
|
|
227
|
+
width: '4px',
|
|
228
|
+
cursor: 'col-resize',
|
|
229
|
+
backgroundColor: 'transparent',
|
|
230
|
+
zIndex: 10,
|
|
231
|
+
}}
|
|
232
|
+
className="hover:bg-[var(--color-accent)] transition-colors"
|
|
233
|
+
/>
|
|
234
|
+
{/* 头部 */}
|
|
235
|
+
<div
|
|
236
|
+
className="shrink-0 px-4 py-3 flex items-center justify-between"
|
|
237
|
+
style={{ borderBottom: '1px solid var(--color-border)' }}
|
|
238
|
+
>
|
|
239
|
+
<h2 className="text-sm font-bold" style={{ color: 'var(--color-text-primary)' }}>
|
|
240
|
+
🧩 插件管理
|
|
241
|
+
</h2>
|
|
242
|
+
<button
|
|
243
|
+
onClick={onClose}
|
|
244
|
+
className="p-1 rounded-lg transition-colors"
|
|
245
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
246
|
+
>
|
|
247
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
248
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
249
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
250
|
+
</svg>
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{/* 操作按钮 */}
|
|
255
|
+
<div
|
|
256
|
+
className="shrink-0 px-4 py-2 flex gap-2"
|
|
257
|
+
style={{ borderBottom: '1px solid var(--color-border)' }}
|
|
258
|
+
>
|
|
259
|
+
<button
|
|
260
|
+
onClick={() => { onClose(); navigate('/extensions'); }}
|
|
261
|
+
className="flex-1 px-3 py-1.5 rounded-lg text-xs border transition-colors flex items-center justify-center gap-1.5"
|
|
262
|
+
style={{
|
|
263
|
+
borderColor: 'var(--color-border)',
|
|
264
|
+
color: 'var(--color-text-secondary)',
|
|
265
|
+
backgroundColor: 'transparent',
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
269
|
+
<rect x="3" y="3" width="7" height="7" />
|
|
270
|
+
<rect x="14" y="3" width="7" height="7" />
|
|
271
|
+
<rect x="14" y="14" width="7" height="7" />
|
|
272
|
+
<rect x="3" y="14" width="7" height="7" />
|
|
273
|
+
</svg>
|
|
274
|
+
管理插件
|
|
275
|
+
</button>
|
|
276
|
+
<button
|
|
277
|
+
onClick={() => { onClose(); navigate('/extensions/create'); }}
|
|
278
|
+
className="flex-1 px-3 py-1.5 rounded-lg text-xs border transition-colors flex items-center justify-center gap-1.5"
|
|
279
|
+
style={{
|
|
280
|
+
borderColor: 'var(--color-accent)',
|
|
281
|
+
color: 'var(--color-accent)',
|
|
282
|
+
backgroundColor: 'transparent',
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
286
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
287
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
288
|
+
</svg>
|
|
289
|
+
创建插件
|
|
290
|
+
</button>
|
|
291
|
+
<button
|
|
292
|
+
onClick={async () => {
|
|
293
|
+
await pluginCtx.reloadPlugins();
|
|
294
|
+
await loadData();
|
|
295
|
+
toast('插件刷新成功', 'success');
|
|
296
|
+
}}
|
|
297
|
+
className="px-3 py-1.5 rounded-lg text-xs border transition-colors flex items-center justify-center gap-1.5"
|
|
298
|
+
style={{
|
|
299
|
+
borderColor: 'var(--color-border)',
|
|
300
|
+
color: 'var(--color-text-secondary)',
|
|
301
|
+
backgroundColor: 'transparent',
|
|
302
|
+
}}
|
|
303
|
+
title="刷新插件"
|
|
304
|
+
>
|
|
305
|
+
<i className="fa-solid fa-arrows-rotate" style={{ fontSize: '11px' }} />
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
{/* 插件列表 */}
|
|
310
|
+
<div className="flex-1 overflow-y-auto">
|
|
311
|
+
{loading ? (
|
|
312
|
+
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
|
313
|
+
<MathCurveLoader size={48} strokeWidthScale={0.7} />
|
|
314
|
+
</div>
|
|
315
|
+
) : plugins.length === 0 ? (
|
|
316
|
+
<div className="flex flex-col items-center justify-center py-10 gap-2">
|
|
317
|
+
<span className="text-2xl opacity-40">🧩</span>
|
|
318
|
+
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>暂无插件</span>
|
|
319
|
+
</div>
|
|
320
|
+
) : (
|
|
321
|
+
<div className="p-3 space-y-2">
|
|
322
|
+
{plugins.map(plugin => {
|
|
323
|
+
const binding = bindings.get(plugin.id);
|
|
324
|
+
const enabled = binding?.enabled ?? false;
|
|
325
|
+
const expanded = expandedId === plugin.id;
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<div
|
|
329
|
+
key={plugin.id}
|
|
330
|
+
className="rounded-lg border overflow-hidden"
|
|
331
|
+
style={{
|
|
332
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
333
|
+
borderColor: 'var(--color-border)',
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
{/* 插件卡片头部 */}
|
|
337
|
+
<div className="px-3 py-2.5 flex items-center justify-between">
|
|
338
|
+
<div
|
|
339
|
+
className="flex-1 min-w-0 cursor-pointer"
|
|
340
|
+
onClick={() => setExpandedId(expanded ? null : plugin.id)}
|
|
341
|
+
>
|
|
342
|
+
<div className="flex items-center gap-2">
|
|
343
|
+
<span className="text-sm"><PluginIcon icon={plugin.icon} pluginId={plugin.id} fallback="🧩" size={16} /></span>
|
|
344
|
+
<span
|
|
345
|
+
className="text-xs font-bold truncate"
|
|
346
|
+
style={{ color: 'var(--color-text-primary)' }}
|
|
347
|
+
>
|
|
348
|
+
{plugin.name}
|
|
349
|
+
</span>
|
|
350
|
+
<span
|
|
351
|
+
className="text-xs px-1.5 py-0.5 rounded shrink-0"
|
|
352
|
+
style={{
|
|
353
|
+
backgroundColor: 'var(--color-bg-primary)',
|
|
354
|
+
color: 'var(--color-text-muted)',
|
|
355
|
+
fontSize: '10px',
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
{typeLabel(plugin.type)}
|
|
359
|
+
</span>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
{/* 启用/禁用开关 */}
|
|
364
|
+
<button
|
|
365
|
+
onClick={(e) => { e.stopPropagation(); handleToggle(plugin.id); }}
|
|
366
|
+
className="ml-2 shrink-0 relative w-9 h-5 rounded-full transition-colors"
|
|
367
|
+
style={{
|
|
368
|
+
backgroundColor: enabled ? 'var(--color-accent)' : 'var(--color-bg-primary)',
|
|
369
|
+
border: `1px solid ${enabled ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
|
370
|
+
}}
|
|
371
|
+
title={enabled ? '点击禁用' : '点击启用'}
|
|
372
|
+
>
|
|
373
|
+
<div
|
|
374
|
+
className="absolute top-0.5 w-3.5 h-3.5 rounded-full transition-all"
|
|
375
|
+
style={{
|
|
376
|
+
backgroundColor: enabled ? '#fff' : 'var(--color-text-muted)',
|
|
377
|
+
left: enabled ? '18px' : '2px',
|
|
378
|
+
}}
|
|
379
|
+
/>
|
|
380
|
+
</button>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{/* 展开详情 */}
|
|
384
|
+
{expanded && (
|
|
385
|
+
<div
|
|
386
|
+
className="px-3 pb-2.5 space-y-2"
|
|
387
|
+
style={{ borderTop: '1px solid var(--color-border)' }}
|
|
388
|
+
>
|
|
389
|
+
<p className="text-xs leading-relaxed pt-2" style={{ color: 'var(--color-text-secondary)' }}>
|
|
390
|
+
{plugin.description || '暂无描述'}
|
|
391
|
+
</p>
|
|
392
|
+
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
393
|
+
<span>{plugin.id}</span>
|
|
394
|
+
<span>·</span>
|
|
395
|
+
<span>v{plugin.version}</span>
|
|
396
|
+
<span>·</span>
|
|
397
|
+
<span>{plugin.author || '未知'}</span>
|
|
398
|
+
</div>
|
|
399
|
+
{/* 配置项编辑 */}
|
|
400
|
+
{plugin.configSchema && plugin.configSchema.length > 0 && (
|
|
401
|
+
<PluginConfigForm
|
|
402
|
+
pluginId={plugin.id}
|
|
403
|
+
configSchema={plugin.configSchema}
|
|
404
|
+
configValues={configValues}
|
|
405
|
+
onConfigChange={(key, value) => handleConfigChange(plugin.id, key, value)}
|
|
406
|
+
binding={binding}
|
|
407
|
+
gameId={gameId}
|
|
408
|
+
/>
|
|
409
|
+
)}
|
|
410
|
+
<div className="flex gap-2 pt-1">
|
|
411
|
+
<button
|
|
412
|
+
onClick={() => { onClose(); navigate(`/extensions/edit/${plugin.id}`); }}
|
|
413
|
+
className="px-3 py-1 rounded-md text-xs border transition-colors"
|
|
414
|
+
style={{
|
|
415
|
+
borderColor: 'var(--color-border)',
|
|
416
|
+
color: 'var(--color-text-secondary)',
|
|
417
|
+
backgroundColor: 'transparent',
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
编辑
|
|
421
|
+
</button>
|
|
422
|
+
<button
|
|
423
|
+
onClick={() => { onClose(); navigate('/extensions'); }}
|
|
424
|
+
className="px-3 py-1 rounded-md text-xs border transition-colors"
|
|
425
|
+
style={{
|
|
426
|
+
borderColor: 'var(--color-accent)',
|
|
427
|
+
color: 'var(--color-accent)',
|
|
428
|
+
backgroundColor: 'transparent',
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
更多
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
})}
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
</>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ==================== 游戏页面内部组件 ====================
|
|
448
|
+
|
|
449
|
+
function GamePageInner() {
|
|
450
|
+
const params = useParams();
|
|
451
|
+
const { navigate, back } = useRouterHistory();
|
|
452
|
+
const gameId = params.id as string;
|
|
453
|
+
const pluginCtx = usePluginContext();
|
|
454
|
+
const { toast } = useToast();
|
|
455
|
+
const [pluginDrawerOpen, setPluginDrawerOpen] = useState(false);
|
|
456
|
+
const [sidebarLeftWidth, setSidebarLeftWidth] = useState(240);
|
|
457
|
+
const chatInputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
458
|
+
|
|
459
|
+
const [worldSetting, setWorldSetting] = useState<WorldSetting | null>(null);
|
|
460
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
461
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
462
|
+
const [error, setError] = useState<string | null>(null);
|
|
463
|
+
const [sessionLoading, setSessionLoading] = useState(true);
|
|
464
|
+
const createdAtRef = useRef<string>(new Date().toISOString());
|
|
465
|
+
|
|
466
|
+
// 注册插件回调
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
pluginCtx.setInsertSystemMessageCallback((content: string) => {
|
|
469
|
+
const sysMsg: ChatMessage = { role: 'system', content };
|
|
470
|
+
setMessages(prev => [...prev, sysMsg]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
pluginCtx.setInsertNoticeCallback((html: string) => {
|
|
474
|
+
const noticeMsg: ChatMessage = { role: 'notice', content: html };
|
|
475
|
+
setMessages(prev => [...prev, noticeMsg]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
pluginCtx.setSendMessageCallback(async (content: string) => {
|
|
479
|
+
await handleSend(content);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
pluginCtx.setGetMessagesCallback(() => {
|
|
483
|
+
return messages.map(m => ({ role: m.role, content: m.content }));
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
pluginCtx.setGetWorldSettingCallback(() => {
|
|
487
|
+
return (worldSetting || {}) as Record<string, unknown>;
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
pluginCtx.setSetWorldSettingCallback(async (data: Record<string, unknown>) => {
|
|
491
|
+
setWorldSetting(data);
|
|
492
|
+
const session: StoredSession = {
|
|
493
|
+
id: gameId,
|
|
494
|
+
worldSetting: data,
|
|
495
|
+
messages: messages.filter(m => m.role !== 'notice'),
|
|
496
|
+
createdAt: createdAtRef.current,
|
|
497
|
+
updatedAt: new Date().toISOString(),
|
|
498
|
+
};
|
|
499
|
+
await saveGameSession(session);
|
|
500
|
+
toast('世界设定已更新', 'success');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
pluginCtx.setGetSessionIdCallback(() => gameId);
|
|
504
|
+
|
|
505
|
+
pluginCtx.setToastCallback((message: string, type?: 'info' | 'success' | 'warning' | 'error') => {
|
|
506
|
+
toast(message, type);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
pluginCtx.setInputElementCallback((el: HTMLTextAreaElement | null) => {
|
|
510
|
+
chatInputRef.current = el;
|
|
511
|
+
});
|
|
512
|
+
}, [pluginCtx, worldSetting, messages, gameId, toast]);
|
|
513
|
+
|
|
514
|
+
// 加载游戏会话
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
const loadSession = async () => {
|
|
517
|
+
try {
|
|
518
|
+
const session = await getGameSession(gameId);
|
|
519
|
+
if (!session) {
|
|
520
|
+
setError('未找到该游戏会话,可能已被删除');
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
setWorldSetting(session.worldSetting);
|
|
524
|
+
setMessages(session.messages as ChatMessage[]);
|
|
525
|
+
createdAtRef.current = session.createdAt || new Date().toISOString();
|
|
526
|
+
} catch {
|
|
527
|
+
setError('加载游戏会话失败');
|
|
528
|
+
} finally {
|
|
529
|
+
setSessionLoading(false);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
loadSession();
|
|
533
|
+
}, [gameId]);
|
|
534
|
+
|
|
535
|
+
// 游戏初始化:自动启用世界卡片关联插件 + 触发 onGameInit(仅首次进入、无聊天记录时执行)
|
|
536
|
+
const gameInitDoneRef = useRef(false);
|
|
537
|
+
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
if (sessionLoading || !pluginCtx.initialized || messages.length > 0 || !worldSetting || gameInitDoneRef.current) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const pluginIds = (worldSetting as unknown as Record<string, unknown>)._plugins as string[] | undefined;
|
|
544
|
+
|
|
545
|
+
if (!pluginIds || pluginIds.length === 0 || !gameId) {
|
|
546
|
+
gameInitDoneRef.current = true;
|
|
547
|
+
pluginCtx.triggerGameInit();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
gameInitDoneRef.current = true;
|
|
552
|
+
|
|
553
|
+
(async () => {
|
|
554
|
+
try {
|
|
555
|
+
// 获取所有已安装插件的 manifest
|
|
556
|
+
const allPlugins = await getPlugins();
|
|
557
|
+
const manifestMap = new Map(allPlugins.map(p => [p.id, p]));
|
|
558
|
+
const missingPlugins: string[] = [];
|
|
559
|
+
|
|
560
|
+
// 获取当前世界已有的绑定记录(保留已有配置,避免覆盖)
|
|
561
|
+
const existingBindings = await getPluginBindings('world', gameId);
|
|
562
|
+
const existingBindingMap = new Map<string, import('@/lib/plugin-types').PluginBinding>();
|
|
563
|
+
for (const b of existingBindings) {
|
|
564
|
+
existingBindingMap.set(b.extensionId, b);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 获取全局绑定,作为新建世界绑定的配置默认值
|
|
568
|
+
const globalBindings = await getPluginBindings('global');
|
|
569
|
+
const globalBindingMap = new Map<string, import('@/lib/plugin-types').PluginBinding>();
|
|
570
|
+
for (const b of globalBindings) {
|
|
571
|
+
globalBindingMap.set(b.extensionId, b);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 仅创建绑定记录,然后逐个加载新绑定的插件(不重新加载全部)
|
|
575
|
+
for (const pid of pluginIds) {
|
|
576
|
+
const manifest = manifestMap.get(pid);
|
|
577
|
+
if (!manifest) {
|
|
578
|
+
missingPlugins.push(pid);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
// 保留已有绑定的配置;新建时使用全局绑定的配置作为默认值
|
|
582
|
+
const existing = existingBindingMap.get(pid);
|
|
583
|
+
const globalBinding = globalBindingMap.get(pid);
|
|
584
|
+
const bindingConfig = existing?.config || globalBinding?.config || {};
|
|
585
|
+
const binding: import('@/lib/plugin-types').PluginBinding = {
|
|
586
|
+
extensionId: pid, scope: 'world', worldId: gameId,
|
|
587
|
+
enabled: true, config: bindingConfig, sortOrder: 0,
|
|
588
|
+
};
|
|
589
|
+
await upsertPluginBinding(binding);
|
|
590
|
+
// 只加载之前未绑定的插件(已有绑定的在 loadPlugins 中已加载)
|
|
591
|
+
if (!existing) {
|
|
592
|
+
await pluginCtx.reloadSinglePlugin(pid, binding);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 刷新 UI 状态
|
|
597
|
+
pluginCtx.refreshUI();
|
|
598
|
+
|
|
599
|
+
// 提示缺失插件
|
|
600
|
+
if (missingPlugins.length > 0) {
|
|
601
|
+
const sysMsg: ChatMessage = {
|
|
602
|
+
role: 'system',
|
|
603
|
+
content: `以下插件未安装,已跳过启用:${missingPlugins.join('、')}。请安装后重新开始游戏。`,
|
|
604
|
+
};
|
|
605
|
+
setMessages(prev => [...prev, sysMsg]);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 触发 onGameInit
|
|
609
|
+
pluginCtx.triggerGameInit();
|
|
610
|
+
} catch {
|
|
611
|
+
pluginCtx.triggerGameInit();
|
|
612
|
+
}
|
|
613
|
+
})();
|
|
614
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
615
|
+
}, [sessionLoading, pluginCtx.initialized, messages.length, worldSetting, gameId]);
|
|
616
|
+
|
|
617
|
+
// 保存会话
|
|
618
|
+
const persistSession = useCallback(
|
|
619
|
+
async (msgs: ChatMessage[]) => {
|
|
620
|
+
if (!worldSetting) return;
|
|
621
|
+
const session: StoredSession = {
|
|
622
|
+
id: gameId,
|
|
623
|
+
worldSetting,
|
|
624
|
+
// 过滤掉 notice 通知(notice 由插件 onLoad 动态插入,不应持久化)
|
|
625
|
+
messages: msgs.filter(m => m.role !== 'notice'),
|
|
626
|
+
createdAt: createdAtRef.current,
|
|
627
|
+
updatedAt: new Date().toISOString(),
|
|
628
|
+
};
|
|
629
|
+
await saveGameSession(session);
|
|
630
|
+
},
|
|
631
|
+
[gameId, worldSetting]
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
// 发送消息
|
|
635
|
+
const handleSend = async (content: string) => {
|
|
636
|
+
if (!worldSetting || isLoading) return;
|
|
637
|
+
|
|
638
|
+
// 检查是否为快捷指令(支持 /command、pluginId/command 格式)
|
|
639
|
+
if (content.startsWith('/')) {
|
|
640
|
+
const cmdResult = await pluginCtx.executeCommand(content);
|
|
641
|
+
if (cmdResult !== undefined) {
|
|
642
|
+
if (typeof cmdResult === 'string' && cmdResult !== 'handled') {
|
|
643
|
+
const userMessage: ChatMessage = { role: 'user', content: cmdResult };
|
|
644
|
+
const newMessages = [...messages, userMessage];
|
|
645
|
+
setMessages(newMessages);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
return; // 命令已处理(静默或指定插件未找到)
|
|
649
|
+
}
|
|
650
|
+
// cmdResult === undefined → 未匹配到命令,继续作为普通消息发送
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const userMessage: ChatMessage = { role: 'user', content };
|
|
654
|
+
const newMessages = [...messages, userMessage];
|
|
655
|
+
setMessages(newMessages);
|
|
656
|
+
setError(null);
|
|
657
|
+
setIsLoading(true);
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
const appSettings = await getAppSettings();
|
|
661
|
+
const runtime = getPluginRuntime();
|
|
662
|
+
|
|
663
|
+
// Hook: 应用消息预处理(支持 async handler)
|
|
664
|
+
const processedMessages = await runtime.applyBeforeSendHandlers(
|
|
665
|
+
newMessages.map(m => ({ role: m.role, content: m.content }))
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
// Hook: 应用 prompt 修改器(支持 async handler)
|
|
669
|
+
const worldSettingRecord = (worldSetting || {}) as unknown as Record<string, unknown>;
|
|
670
|
+
const modifiedPromptExtra = await runtime.applyPromptModifiers(
|
|
671
|
+
appSettings.systemPromptExtra || '',
|
|
672
|
+
worldSettingRecord
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// Hook: 应用请求配置修改器(支持 async handler)
|
|
676
|
+
const modifiedConfig = await runtime.applyRequestConfigModifiers({
|
|
677
|
+
temperature: appSettings.aiTemperature,
|
|
678
|
+
maxTokens: appSettings.aiMaxTokens,
|
|
679
|
+
model: appSettings.aiModel,
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// 构建完整消息数组(system prompt + 用户/助手消息,过滤掉 notice 通知)
|
|
683
|
+
const systemPrompt = buildSystemPrompt(worldSettingRecord, modifiedPromptExtra);
|
|
684
|
+
const completeMessages = [
|
|
685
|
+
{ role: 'system', content: systemPrompt },
|
|
686
|
+
...processedMessages.filter(m => m.role !== 'notice'),
|
|
687
|
+
];
|
|
688
|
+
|
|
689
|
+
// Hook: 应用完整消息数组预处理(角色合并、重排等,支持 async handler)
|
|
690
|
+
const finalMessages = await runtime.applyBeforeCompleteHandlers(completeMessages);
|
|
691
|
+
|
|
692
|
+
const response = await fetch('/api/chat', {
|
|
693
|
+
method: 'POST',
|
|
694
|
+
headers: { 'Content-Type': 'application/json' },
|
|
695
|
+
body: JSON.stringify({
|
|
696
|
+
worldSetting,
|
|
697
|
+
messages: processedMessages,
|
|
698
|
+
prebuiltMessages: finalMessages,
|
|
699
|
+
apiConfig: {
|
|
700
|
+
apiKey: appSettings.aiApiKey,
|
|
701
|
+
apiBase: appSettings.aiApiBase,
|
|
702
|
+
model: modifiedConfig.model,
|
|
703
|
+
temperature: modifiedConfig.temperature,
|
|
704
|
+
maxTokens: modifiedConfig.maxTokens,
|
|
705
|
+
},
|
|
706
|
+
}),
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
const data = await response.json();
|
|
710
|
+
|
|
711
|
+
if (!response.ok) {
|
|
712
|
+
throw new Error(data.error || 'AI 回复失败');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Hook: 应用响应后处理(支持 async)
|
|
716
|
+
const processedContent = await runtime.applyAfterReceiveHandlers(data.content);
|
|
717
|
+
|
|
718
|
+
const assistantMessage: ChatMessage = {
|
|
719
|
+
role: 'assistant',
|
|
720
|
+
content: processedContent,
|
|
721
|
+
};
|
|
722
|
+
const allMessages = [...newMessages, assistantMessage];
|
|
723
|
+
setMessages(allMessages);
|
|
724
|
+
persistSession(allMessages);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
setError(err instanceof Error ? err.message : '发送消息失败');
|
|
727
|
+
setMessages(messages);
|
|
728
|
+
} finally {
|
|
729
|
+
setIsLoading(false);
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
const handleBack = () => {
|
|
734
|
+
back();
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
if (sessionLoading || !pluginCtx.initialized) {
|
|
738
|
+
return <FullPageLoader />;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (error && !worldSetting) {
|
|
742
|
+
return (
|
|
743
|
+
<div
|
|
744
|
+
className="min-h-screen flex flex-col items-center justify-center gap-4 px-4"
|
|
745
|
+
style={{ backgroundColor: 'var(--color-bg-primary)' }}
|
|
746
|
+
>
|
|
747
|
+
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
748
|
+
{error}
|
|
749
|
+
</p>
|
|
750
|
+
<button
|
|
751
|
+
onClick={handleBack}
|
|
752
|
+
className="px-4 py-2 rounded-lg text-sm border"
|
|
753
|
+
style={{
|
|
754
|
+
borderColor: 'var(--color-border)',
|
|
755
|
+
color: 'var(--color-text-secondary)',
|
|
756
|
+
}}
|
|
757
|
+
>
|
|
758
|
+
返回首页
|
|
759
|
+
</button>
|
|
760
|
+
</div>
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const sidebarLeftRegistrations = pluginCtx.getSlotRegistrations('sidebar-left');
|
|
765
|
+
const sidebarRightRegistrations = pluginCtx.getSlotRegistrations('sidebar-right');
|
|
766
|
+
const hasSidebar = sidebarLeftRegistrations.length > 0 || sidebarRightRegistrations.length > 0;
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<div
|
|
770
|
+
className="h-screen flex flex-col"
|
|
771
|
+
style={{ backgroundColor: 'var(--color-bg-primary)' }}
|
|
772
|
+
>
|
|
773
|
+
{/* 顶部栏 */}
|
|
774
|
+
<header
|
|
775
|
+
className="flex items-center justify-between px-4 py-2.5 border-b shrink-0"
|
|
776
|
+
style={{
|
|
777
|
+
borderColor: 'var(--color-border)',
|
|
778
|
+
backgroundColor: 'var(--color-bg-secondary)',
|
|
779
|
+
}}
|
|
780
|
+
>
|
|
781
|
+
<div className="flex items-center gap-3">
|
|
782
|
+
<button
|
|
783
|
+
onClick={handleBack}
|
|
784
|
+
className="p-1.5 rounded-lg transition-colors"
|
|
785
|
+
style={{ color: 'var(--color-text-secondary)' }}
|
|
786
|
+
title="返回首页"
|
|
787
|
+
>
|
|
788
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
789
|
+
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
790
|
+
</svg>
|
|
791
|
+
</button>
|
|
792
|
+
<PluginSlotRenderer key={`header-left-${pluginCtx.slotVersion}`} slotId="header-left" direction="horizontal" registrations={pluginCtx.getSlotRegistrations('header-left')} />
|
|
793
|
+
<div>
|
|
794
|
+
<h1
|
|
795
|
+
className="text-sm font-bold"
|
|
796
|
+
style={{ color: 'var(--color-accent)' }}
|
|
797
|
+
>
|
|
798
|
+
{typeof worldSetting?.title === 'string'
|
|
799
|
+
? worldSetting.title
|
|
800
|
+
: '加载中...'}
|
|
801
|
+
</h1>
|
|
802
|
+
</div>
|
|
803
|
+
</div>
|
|
804
|
+
<div className="flex items-center gap-2">
|
|
805
|
+
<PluginSlotRenderer key={`header-right-${pluginCtx.slotVersion}`} slotId="header-right" direction="horizontal" registrations={pluginCtx.getSlotRegistrations('header-right')} />
|
|
806
|
+
{/* 插件管理按钮 */}
|
|
807
|
+
<button
|
|
808
|
+
onClick={() => setPluginDrawerOpen(true)}
|
|
809
|
+
className="p-1.5 rounded-lg transition-colors relative"
|
|
810
|
+
style={{ color: 'var(--color-text-secondary)' }}
|
|
811
|
+
title="插件管理"
|
|
812
|
+
>
|
|
813
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
814
|
+
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
|
815
|
+
</svg>
|
|
816
|
+
{/* 已启用插件数量角标 */}
|
|
817
|
+
{pluginCtx.enabledPlugins.length > 0 && (
|
|
818
|
+
<span
|
|
819
|
+
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 flex items-center justify-center rounded-full text-xs font-bold"
|
|
820
|
+
style={{
|
|
821
|
+
backgroundColor: 'var(--color-accent)',
|
|
822
|
+
color: 'var(--color-bg-primary)',
|
|
823
|
+
fontSize: '10px',
|
|
824
|
+
padding: '0 4px',
|
|
825
|
+
}}
|
|
826
|
+
>
|
|
827
|
+
{pluginCtx.enabledPlugins.length}
|
|
828
|
+
</span>
|
|
829
|
+
)}
|
|
830
|
+
</button>
|
|
831
|
+
<button
|
|
832
|
+
onClick={() => navigate('/settings')}
|
|
833
|
+
className="p-1.5 rounded-lg transition-colors"
|
|
834
|
+
style={{ color: 'var(--color-text-secondary)' }}
|
|
835
|
+
title="设置"
|
|
836
|
+
>
|
|
837
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
838
|
+
<circle cx="12" cy="12" r="3" />
|
|
839
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
840
|
+
</svg>
|
|
841
|
+
</button>
|
|
842
|
+
<ThemeSwitcher />
|
|
843
|
+
</div>
|
|
844
|
+
</header>
|
|
845
|
+
|
|
846
|
+
{/* 主体区域:三栏布局(左 sidebar + 中间内容 + 右 sidebar) */}
|
|
847
|
+
<div className={`flex-1 flex flex-row overflow-hidden ${hasSidebar ? '' : ''}`}>
|
|
848
|
+
{/* 左侧边栏 */}
|
|
849
|
+
{sidebarLeftRegistrations.length > 0 && (
|
|
850
|
+
<aside
|
|
851
|
+
className="shrink-0 overflow-y-auto border-r relative"
|
|
852
|
+
style={{
|
|
853
|
+
width: sidebarLeftWidth + 'px',
|
|
854
|
+
minWidth: '180px',
|
|
855
|
+
maxWidth: '480px',
|
|
856
|
+
borderColor: 'var(--color-border)',
|
|
857
|
+
backgroundColor: 'var(--color-bg-secondary)',
|
|
858
|
+
}}
|
|
859
|
+
>
|
|
860
|
+
<PluginSlotRenderer key={`sidebar-left-${pluginCtx.slotVersion}`} slotId="sidebar-left" direction="vertical" gap="0px" registrations={sidebarLeftRegistrations} />
|
|
861
|
+
{/* 拖拽调整宽度手柄 */}
|
|
862
|
+
<div
|
|
863
|
+
className="absolute top-0 right-0 h-full cursor-col-resize hover:bg-[var(--color-accent)] active:bg-[var(--color-accent)] transition-colors"
|
|
864
|
+
style={{ width: '4px', zIndex: 10 }}
|
|
865
|
+
onMouseDown={(e) => {
|
|
866
|
+
e.preventDefault();
|
|
867
|
+
const startX = e.clientX;
|
|
868
|
+
const startWidth = sidebarLeftWidth;
|
|
869
|
+
const onMove = (ev: MouseEvent) => {
|
|
870
|
+
const delta = ev.clientX - startX;
|
|
871
|
+
const newWidth = Math.max(180, Math.min(480, startWidth + delta));
|
|
872
|
+
setSidebarLeftWidth(newWidth);
|
|
873
|
+
};
|
|
874
|
+
const onUp = () => {
|
|
875
|
+
document.removeEventListener('mousemove', onMove);
|
|
876
|
+
document.removeEventListener('mouseup', onUp);
|
|
877
|
+
document.body.style.cursor = '';
|
|
878
|
+
document.body.style.userSelect = '';
|
|
879
|
+
};
|
|
880
|
+
document.body.style.cursor = 'col-resize';
|
|
881
|
+
document.body.style.userSelect = 'none';
|
|
882
|
+
document.addEventListener('mousemove', onMove);
|
|
883
|
+
document.addEventListener('mouseup', onUp);
|
|
884
|
+
}}
|
|
885
|
+
/>
|
|
886
|
+
</aside>
|
|
887
|
+
)}
|
|
888
|
+
|
|
889
|
+
{/* 中间主内容区 */}
|
|
890
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
891
|
+
|
|
892
|
+
{/* 错误提示 */}
|
|
893
|
+
{error && (
|
|
894
|
+
<div
|
|
895
|
+
className="px-4 py-2 text-sm text-center shrink-0"
|
|
896
|
+
style={{
|
|
897
|
+
backgroundColor: 'rgba(220, 38, 38, 0.1)',
|
|
898
|
+
color: '#ef4444',
|
|
899
|
+
}}
|
|
900
|
+
>
|
|
901
|
+
{error}
|
|
902
|
+
</div>
|
|
903
|
+
)}
|
|
904
|
+
|
|
905
|
+
{/* 游戏属性面板(插件注册的属性) */}
|
|
906
|
+
{pluginCtx.attributes.length > 0 && (
|
|
907
|
+
<div
|
|
908
|
+
className="px-4 py-2 border-b shrink-0 flex flex-wrap gap-3"
|
|
909
|
+
style={{
|
|
910
|
+
borderColor: 'var(--color-border)',
|
|
911
|
+
backgroundColor: 'var(--color-bg-secondary)',
|
|
912
|
+
}}
|
|
913
|
+
>
|
|
914
|
+
{pluginCtx.attributes.map(attr => (
|
|
915
|
+
<div
|
|
916
|
+
key={attr.key}
|
|
917
|
+
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs"
|
|
918
|
+
style={{
|
|
919
|
+
backgroundColor: 'var(--color-bg-tertiary)',
|
|
920
|
+
color: 'var(--color-text-primary)',
|
|
921
|
+
}}
|
|
922
|
+
>
|
|
923
|
+
<span>{attr.icon || '📊'}</span>
|
|
924
|
+
<span style={{ color: 'var(--color-text-secondary)' }}>{attr.label}:</span>
|
|
925
|
+
<span className="font-medium">{String(attr.value)}</span>
|
|
926
|
+
</div>
|
|
927
|
+
))}
|
|
928
|
+
</div>
|
|
929
|
+
)}
|
|
930
|
+
|
|
931
|
+
{/* 插件插槽:消息区域上方 */}
|
|
932
|
+
<PluginSlotRenderer key={`message-top-${pluginCtx.slotVersion}`} slotId="message-top" direction="vertical" registrations={pluginCtx.getSlotRegistrations('message-top')} />
|
|
933
|
+
|
|
934
|
+
{/* 对话区域 */}
|
|
935
|
+
<ChatWindow messages={messages} isLoading={isLoading} />
|
|
936
|
+
|
|
937
|
+
{/* 插件插槽:消息区域下方 */}
|
|
938
|
+
<PluginSlotRenderer key={`message-bottom-${pluginCtx.slotVersion}`} slotId="message-bottom" direction="vertical" registrations={pluginCtx.getSlotRegistrations('message-bottom')} />
|
|
939
|
+
|
|
940
|
+
{/* 插件插槽:输入框正上方 */}
|
|
941
|
+
<PluginSlotRenderer key={`input-above-${pluginCtx.slotVersion}`} slotId="input-above" direction="horizontal" registrations={pluginCtx.getSlotRegistrations('input-above')} />
|
|
942
|
+
|
|
943
|
+
{/* 插件插槽:输入工具栏(包含 registerInputToolbarButton 注册的按钮) */}
|
|
944
|
+
<PluginSlotRenderer key={`input-toolbar-${pluginCtx.slotVersion}`} slotId="input-toolbar" direction="horizontal" registrations={pluginCtx.getSlotRegistrations('input-toolbar')} />
|
|
945
|
+
|
|
946
|
+
{/* 输入区域 */}
|
|
947
|
+
<ChatInput onSend={handleSend} disabled={isLoading} inputRef={chatInputRef} />
|
|
948
|
+
|
|
949
|
+
{/* 插件插槽:底部状态栏 */}
|
|
950
|
+
<PluginSlotRenderer key={`status-bar-${pluginCtx.slotVersion}`} slotId="status-bar" direction="horizontal" registrations={pluginCtx.getSlotRegistrations('status-bar')} />
|
|
951
|
+
|
|
952
|
+
{/* 插件浮动层 */}
|
|
953
|
+
<PluginFloatingLayer key={`floating-${pluginCtx.slotVersion}`} registrations={pluginCtx.getSlotRegistrations('floating')} />
|
|
954
|
+
|
|
955
|
+
{/* 插件模态框渲染器 */}
|
|
956
|
+
<PluginModalRenderer modals={pluginCtx.modals} onClose={pluginCtx.closeModal} />
|
|
957
|
+
<PermissionConflictDialog />
|
|
958
|
+
|
|
959
|
+
</div>{/* 结束中间主内容区 */}
|
|
960
|
+
|
|
961
|
+
{/* 右侧边栏 */}
|
|
962
|
+
{sidebarRightRegistrations.length > 0 && (
|
|
963
|
+
<aside
|
|
964
|
+
className="shrink-0 overflow-y-auto border-l"
|
|
965
|
+
style={{
|
|
966
|
+
width: '240px',
|
|
967
|
+
borderColor: 'var(--color-border)',
|
|
968
|
+
backgroundColor: 'var(--color-bg-secondary)',
|
|
969
|
+
}}
|
|
970
|
+
>
|
|
971
|
+
<PluginSlotRenderer key={`sidebar-right-${pluginCtx.slotVersion}`} slotId="sidebar-right" direction="vertical" gap="0px" registrations={sidebarRightRegistrations} />
|
|
972
|
+
</aside>
|
|
973
|
+
)}
|
|
974
|
+
</div>{/* 结束三栏布局 */}
|
|
975
|
+
|
|
976
|
+
{/* 插件侧边栏抽屉 */}
|
|
977
|
+
<PluginDrawer
|
|
978
|
+
open={pluginDrawerOpen}
|
|
979
|
+
onClose={() => setPluginDrawerOpen(false)}
|
|
980
|
+
gameId={gameId}
|
|
981
|
+
/>
|
|
982
|
+
</div>
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** 游戏页面(包裹 PluginProvider) */
|
|
987
|
+
export default function GamePage() {
|
|
988
|
+
const params = useParams();
|
|
989
|
+
const gameId = params.id as string;
|
|
990
|
+
|
|
991
|
+
return (
|
|
992
|
+
<PluginProvider worldId={gameId}>
|
|
993
|
+
<GamePageInner />
|
|
994
|
+
</PluginProvider>
|
|
995
|
+
);
|
|
996
|
+
}
|