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,1038 @@
|
|
|
1
|
+
// components/ui/PluginProvider.tsx - 插件系统 React Context Provider
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
|
|
5
|
+
import { PluginManifest, PluginBinding, PluginInstance, GameAttribute, ChatCommand, SidebarPanelRegistration, MessageRenderer, UISlotId, SlotRegistration, ModalRegistration, ConfirmOptions, DependencyStatus } from '@/lib/plugin-types';
|
|
6
|
+
import { onPluginBindingChange, PluginBindingChangeEvent, onPluginConfigChange, PluginConfigChangeEvent, broadcastPluginConfigChange } from '@/lib/plugin-events';
|
|
7
|
+
|
|
8
|
+
/** 输入框控制 API 类型 */
|
|
9
|
+
interface InputControlAPI {
|
|
10
|
+
getContent: () => string | null;
|
|
11
|
+
setContent: (text: string) => void;
|
|
12
|
+
appendContent: (text: string) => void;
|
|
13
|
+
clearContent: () => void;
|
|
14
|
+
setPlaceholder: (text: string) => void;
|
|
15
|
+
setDisabled: (disabled: boolean) => void;
|
|
16
|
+
focus: () => void;
|
|
17
|
+
blur: () => void;
|
|
18
|
+
setStyle: (style: Record<string, string>) => void;
|
|
19
|
+
injectStyle: (css: string) => string;
|
|
20
|
+
removeStyle: (styleId: string) => void;
|
|
21
|
+
onInput: (handler: (value: string) => void) => () => void;
|
|
22
|
+
onSend: (handler: (content: string) => void) => () => void;
|
|
23
|
+
onKeyDown: (handler: (event: { key: string; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; preventDefault: () => void }) => void) => () => void;
|
|
24
|
+
getSelection: () => { start: number; end: number; text: string } | null;
|
|
25
|
+
setSelection: (start: number, end: number) => void;
|
|
26
|
+
insertAtCursor: (text: string) => void;
|
|
27
|
+
setMaxLength: (length: number) => void;
|
|
28
|
+
getLength: () => number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** PluginProvider Context 值 */
|
|
32
|
+
interface PluginContextValue {
|
|
33
|
+
/** 所有已加载的插件 */
|
|
34
|
+
plugins: PluginInstance[];
|
|
35
|
+
/** 已启用的插件 */
|
|
36
|
+
enabledPlugins: PluginInstance[];
|
|
37
|
+
/** 游戏属性 */
|
|
38
|
+
attributes: GameAttribute[];
|
|
39
|
+
/** 快捷指令 */
|
|
40
|
+
commands: ChatCommand[];
|
|
41
|
+
/** 侧边栏面板 */
|
|
42
|
+
sidebarPanels: SidebarPanelRegistration[];
|
|
43
|
+
/** 消息渲染器 */
|
|
44
|
+
messageRenderers: MessageRenderer[];
|
|
45
|
+
/** 执行 onRenderMessage 管道(多个插件依次修改消息内容,返回 null 表示跳过渲染,支持 async handler) */
|
|
46
|
+
applyRenderMessagePipeline: (message: { role: string; content: string }) => Promise<string | null>;
|
|
47
|
+
/** 游戏状态 */
|
|
48
|
+
gameState: Record<string, unknown>;
|
|
49
|
+
/** 设置游戏状态 */
|
|
50
|
+
setGameState: (partial: Record<string, unknown>) => void;
|
|
51
|
+
/** 重置游戏状态 */
|
|
52
|
+
resetGameState: () => void;
|
|
53
|
+
/** 注册快捷指令(供外部组件使用) */
|
|
54
|
+
registerCommand: (cmd: ChatCommand) => void;
|
|
55
|
+
/** 执行快捷指令 */
|
|
56
|
+
executeCommand: (rawInput: string) => Promise<string | void>;
|
|
57
|
+
/** 插入系统消息回调 */
|
|
58
|
+
onInsertSystemMessage?: (content: string, metadata?: Record<string, unknown>) => void;
|
|
59
|
+
/** 设置插入系统消息回调 */
|
|
60
|
+
setInsertSystemMessageCallback: (cb: (content: string, metadata?: Record<string, unknown>) => void) => void;
|
|
61
|
+
/** 插入通知消息回调(不发给 AI,持久化到聊天记录) */
|
|
62
|
+
onInsertNotice?: (html: string) => void;
|
|
63
|
+
/** 设置插入通知消息回调 */
|
|
64
|
+
setInsertNoticeCallback: (cb: (html: string) => void) => void;
|
|
65
|
+
/** Toast 通知回调 */
|
|
66
|
+
onToast?: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void;
|
|
67
|
+
/** 设置 Toast 回调 */
|
|
68
|
+
setToastCallback: (cb: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void) => void;
|
|
69
|
+
/** Confirm 对话框回调 */
|
|
70
|
+
onConfirm?: (title: string, message: string, options?: ConfirmOptions) => Promise<boolean>;
|
|
71
|
+
/** 设置 Confirm 回调 */
|
|
72
|
+
setConfirmCallback: (cb: (title: string, message: string, options?: ConfirmOptions) => Promise<boolean>) => void;
|
|
73
|
+
/** 发送消息回调 */
|
|
74
|
+
onSendMessage?: (content: string) => Promise<void>;
|
|
75
|
+
/** 设置发送消息回调 */
|
|
76
|
+
setSendMessageCallback: (cb: (content: string) => Promise<void>) => void;
|
|
77
|
+
/** 获取消息列表回调 */
|
|
78
|
+
onGetMessages?: () => { role: string; content: string }[];
|
|
79
|
+
/** 设置获取消息列表回调 */
|
|
80
|
+
setGetMessagesCallback: (cb: () => { role: string; content: string }[]) => void;
|
|
81
|
+
/** 获取世界设定回调 */
|
|
82
|
+
onGetWorldSetting?: () => Record<string, unknown>;
|
|
83
|
+
/** 设置获取世界设定回调 */
|
|
84
|
+
setGetWorldSettingCallback: (cb: () => Record<string, unknown>) => void;
|
|
85
|
+
/** 设置世界设定回调 */
|
|
86
|
+
onSetWorldSetting?: (data: Record<string, unknown>) => void;
|
|
87
|
+
/** 设置设置世界设定回调 */
|
|
88
|
+
setSetWorldSettingCallback: (cb: (data: Record<string, unknown>) => void) => void;
|
|
89
|
+
/** 获取会话 ID 回调 */
|
|
90
|
+
onGetSessionId?: () => string;
|
|
91
|
+
/** 设置获取会话 ID 回调 */
|
|
92
|
+
setGetSessionIdCallback: (cb: () => string) => void;
|
|
93
|
+
/** 设置输入框元素回调(由 GamePage 调用,传入 textarea 元素) */
|
|
94
|
+
setInputElementCallback: (cb: (el: HTMLTextAreaElement | null) => void) => void;
|
|
95
|
+
/** 输入框控制 API */
|
|
96
|
+
inputControl: InputControlAPI;
|
|
97
|
+
/** 重新加载所有插件 */
|
|
98
|
+
reloadPlugins: () => Promise<void>;
|
|
99
|
+
/** 重新加载单个插件(不影响其他插件) */
|
|
100
|
+
reloadSinglePlugin: (pluginId: string, bindingOverride?: PluginBinding) => Promise<void>;
|
|
101
|
+
/** 直接更新插件 binding 并 reload(不重新 fetch DB,最可靠的路径) */
|
|
102
|
+
updateAndReload: (pluginId: string, newBinding: PluginBinding) => Promise<void>;
|
|
103
|
+
/** 轻量级更新插件运行时配置(触发 onConfigChange hook + 持久化 + 广播) */
|
|
104
|
+
updatePluginConfig: (pluginId: string, config: Record<string, unknown>, options?: { persist?: boolean; broadcast?: boolean; triggerHook?: boolean }) => void;
|
|
105
|
+
/** 刷新 UI 状态(不重新加载插件,只从 runtime 同步到 React state) */
|
|
106
|
+
refreshUI: () => void;
|
|
107
|
+
/** 更新 toast 设置(设置页保存时调用) */
|
|
108
|
+
updateToastSettings: (enabled: boolean, levels: string[]) => void;
|
|
109
|
+
/** 是否已初始化 */
|
|
110
|
+
initialized: boolean;
|
|
111
|
+
/** 获取指定插槽的注册内容 */
|
|
112
|
+
getSlotRegistrations: (slotId: UISlotId) => SlotRegistration[];
|
|
113
|
+
/** slot 版本号,变化时触发 re-render */
|
|
114
|
+
slotVersion: number;
|
|
115
|
+
/** 所有模态框 */
|
|
116
|
+
modals: ModalRegistration[];
|
|
117
|
+
/** 关闭模态框 */
|
|
118
|
+
closeModal: (modalId: string) => void;
|
|
119
|
+
/** 更新模态框内容/样式(不关闭重开) */
|
|
120
|
+
updateModal: (modalId: string, options: { title?: string; content?: string; width?: string; style?: Record<string, unknown> }) => void;
|
|
121
|
+
/** 触发游戏初始化钩子(进入游戏且无聊天记录时调用,支持 async handler) */
|
|
122
|
+
triggerGameInit: () => Promise<void>;
|
|
123
|
+
/** 触发插件绑定世界钩子(支持 async handler) */
|
|
124
|
+
triggerPluginBindingWorld: (worldId: string) => Promise<void>;
|
|
125
|
+
/** 所有插件的依赖状态 */
|
|
126
|
+
dependencyStatus: Map<string, DependencyStatus[]>;
|
|
127
|
+
/** 权限冲突信息 */
|
|
128
|
+
permissionConflicts: Array<{
|
|
129
|
+
manifest: PluginManifest;
|
|
130
|
+
conflicts: Array<{
|
|
131
|
+
permission: string;
|
|
132
|
+
conflictingPluginId: string;
|
|
133
|
+
conflictingPluginName: string;
|
|
134
|
+
conflictType: 'exclusive-vs-any' | 'common-vs-exclusive';
|
|
135
|
+
}>;
|
|
136
|
+
missingRequired: string[];
|
|
137
|
+
}>;
|
|
138
|
+
/** 解决权限冲突(移除权限或禁用插件) */
|
|
139
|
+
resolvePermissionConflict: (conflictingPluginId: string, permission: string, action: 'remove-permission' | 'disable-plugin') => Promise<void>;
|
|
140
|
+
/** 关闭权限冲突弹窗 */
|
|
141
|
+
dismissPermissionConflicts: () => void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 创建空操作的 inputControl 默认值 */
|
|
145
|
+
function createDefaultInputControl(): InputControlAPI {
|
|
146
|
+
return {
|
|
147
|
+
getContent: () => null,
|
|
148
|
+
setContent: () => {},
|
|
149
|
+
appendContent: () => {},
|
|
150
|
+
clearContent: () => {},
|
|
151
|
+
setPlaceholder: () => {},
|
|
152
|
+
setDisabled: () => {},
|
|
153
|
+
focus: () => {},
|
|
154
|
+
blur: () => {},
|
|
155
|
+
setStyle: () => {},
|
|
156
|
+
injectStyle: () => '',
|
|
157
|
+
removeStyle: () => {},
|
|
158
|
+
onInput: () => () => {},
|
|
159
|
+
onSend: () => () => {},
|
|
160
|
+
onKeyDown: () => () => {},
|
|
161
|
+
getSelection: () => null,
|
|
162
|
+
setSelection: () => {},
|
|
163
|
+
insertAtCursor: () => {},
|
|
164
|
+
setMaxLength: () => {},
|
|
165
|
+
getLength: () => 0,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 创建安全的默认上下文值(SSR 或无 Provider 时使用) */
|
|
170
|
+
function createDefaultContext(): PluginContextValue {
|
|
171
|
+
return {
|
|
172
|
+
plugins: [],
|
|
173
|
+
enabledPlugins: [],
|
|
174
|
+
attributes: [],
|
|
175
|
+
commands: [],
|
|
176
|
+
sidebarPanels: [],
|
|
177
|
+
messageRenderers: [],
|
|
178
|
+
applyRenderMessagePipeline: async (msg) => msg.content,
|
|
179
|
+
gameState: {},
|
|
180
|
+
setGameState: () => {},
|
|
181
|
+
resetGameState: () => {},
|
|
182
|
+
registerCommand: () => {},
|
|
183
|
+
executeCommand: async () => undefined,
|
|
184
|
+
setInsertSystemMessageCallback: () => {},
|
|
185
|
+
setInsertNoticeCallback: () => {},
|
|
186
|
+
setToastCallback: () => {},
|
|
187
|
+
setConfirmCallback: () => {},
|
|
188
|
+
setSendMessageCallback: () => {},
|
|
189
|
+
setGetMessagesCallback: () => {},
|
|
190
|
+
setGetWorldSettingCallback: () => {},
|
|
191
|
+
setGetSessionIdCallback: () => {},
|
|
192
|
+
setInputElementCallback: () => {},
|
|
193
|
+
inputControl: createDefaultInputControl(),
|
|
194
|
+
reloadPlugins: async () => {},
|
|
195
|
+
reloadSinglePlugin: async () => {},
|
|
196
|
+
updateAndReload: async () => {},
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
198
|
+
updatePluginConfig: (_pluginId: string, _config: Record<string, unknown>, _options?: { persist?: boolean; broadcast?: boolean; triggerHook?: boolean }) => {},
|
|
199
|
+
refreshUI: () => {},
|
|
200
|
+
updateToastSettings: () => {},
|
|
201
|
+
initialized: false,
|
|
202
|
+
getSlotRegistrations: () => [],
|
|
203
|
+
slotVersion: 0,
|
|
204
|
+
modals: [],
|
|
205
|
+
closeModal: () => {},
|
|
206
|
+
updateModal: () => {},
|
|
207
|
+
triggerGameInit: async () => {},
|
|
208
|
+
triggerPluginBindingWorld: async () => {},
|
|
209
|
+
dependencyStatus: new Map(),
|
|
210
|
+
permissionConflicts: [],
|
|
211
|
+
resolvePermissionConflict: async () => {},
|
|
212
|
+
dismissPermissionConflicts: () => {},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const PluginContext = createContext<PluginContextValue>(createDefaultContext());
|
|
217
|
+
|
|
218
|
+
export function usePluginContext(): PluginContextValue {
|
|
219
|
+
return useContext(PluginContext);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface PluginProviderProps {
|
|
223
|
+
children: React.ReactNode;
|
|
224
|
+
worldId?: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function PluginProvider({ children, worldId }: PluginProviderProps) {
|
|
228
|
+
const [plugins, setPlugins] = useState<PluginInstance[]>([]);
|
|
229
|
+
const [attributes, setAttributes] = useState<GameAttribute[]>([]);
|
|
230
|
+
const [commands, setCommands] = useState<ChatCommand[]>([]);
|
|
231
|
+
const [sidebarPanels, setSidebarPanels] = useState<SidebarPanelRegistration[]>([]);
|
|
232
|
+
const [messageRenderers, setMessageRenderers] = useState<MessageRenderer[]>([]);
|
|
233
|
+
const [gameState, setGameStateInternal] = useState<Record<string, unknown>>({});
|
|
234
|
+
const [initialized, setInitialized] = useState(false);
|
|
235
|
+
const loadingRef = useRef(false);
|
|
236
|
+
const initializedRef = useRef(false);
|
|
237
|
+
// 用 ref 追踪 worldId,避免 loadPlugins 因 worldId 变化而重建导致重复触发
|
|
238
|
+
const worldIdRef = useRef(worldId);
|
|
239
|
+
worldIdRef.current = worldId;
|
|
240
|
+
// 新增:模态框状态
|
|
241
|
+
const [modals, setModals] = useState<ModalRegistration[]>([]);
|
|
242
|
+
// 新增:依赖状态
|
|
243
|
+
const [dependencyStatus, setDependencyStatus] = useState<Map<string, DependencyStatus[]>>(new Map());
|
|
244
|
+
// 新增:slot 版本号,变化时触发使用 getSlotRegistrations 的组件 re-render
|
|
245
|
+
const [slotVersion, setSlotVersion] = useState(0);
|
|
246
|
+
// 新增:权限冲突信息
|
|
247
|
+
const [permissionConflicts, setPermissionConflicts] = useState<Array<{
|
|
248
|
+
manifest: PluginManifest;
|
|
249
|
+
conflicts: Array<{
|
|
250
|
+
permission: string;
|
|
251
|
+
conflictingPluginId: string;
|
|
252
|
+
conflictingPluginName: string;
|
|
253
|
+
conflictType: 'exclusive-vs-any' | 'common-vs-exclusive';
|
|
254
|
+
}>;
|
|
255
|
+
missingRequired: string[];
|
|
256
|
+
}>>([]);
|
|
257
|
+
|
|
258
|
+
const runtimeRef = useRef<import('@/lib/plugin-runtime').PluginRuntimeEngine | null>(null);
|
|
259
|
+
const insertSystemMessageRef = useRef<((content: string, metadata?: Record<string, unknown>) => void) | undefined>();
|
|
260
|
+
const insertNoticeRef = useRef<(html: string) => void | undefined>();
|
|
261
|
+
const toastRef = useRef<((message: string, type?: 'info' | 'success' | 'warning' | 'error') => void) | undefined>();
|
|
262
|
+
const toastSettingsRef = useRef<{ enabled: boolean; levels: string[] }>({ enabled: true, levels: ['info', 'success', 'warning', 'error'] });
|
|
263
|
+
const confirmRef = useRef<((title: string, message: string, options?: ConfirmOptions) => Promise<boolean>) | undefined>();
|
|
264
|
+
const sendMessageRef = useRef<((content: string) => Promise<void>) | undefined>();
|
|
265
|
+
const getMessagesRef = useRef<(() => { role: string; content: string }[]) | undefined>();
|
|
266
|
+
const getWorldSettingRef = useRef<(() => Record<string, unknown>) | undefined>();
|
|
267
|
+
const setWorldSettingRef = useRef<((data: Record<string, unknown>) => void) | undefined>();
|
|
268
|
+
const getSessionIdRef = useRef<(() => string) | undefined>();
|
|
269
|
+
|
|
270
|
+
// 输入框元素引用
|
|
271
|
+
const inputElementRef = useRef<HTMLTextAreaElement | null>(null);
|
|
272
|
+
// 输入框事件监听器
|
|
273
|
+
const inputListenersRef = useRef<Array<(value: string) => void>>([]);
|
|
274
|
+
const sendListenersRef = useRef<Array<(content: string) => void>>([]);
|
|
275
|
+
const keyDownListenersRef = useRef<Array<(event: { key: string; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; preventDefault: () => void }) => void>>([]);
|
|
276
|
+
// 注入的样式 ID 计数器
|
|
277
|
+
const inputStyleCounterRef = useRef(0);
|
|
278
|
+
|
|
279
|
+
const setInsertSystemMessageCallback = useCallback((cb: (content: string, metadata?: Record<string, unknown>) => void) => {
|
|
280
|
+
insertSystemMessageRef.current = cb;
|
|
281
|
+
}, []);
|
|
282
|
+
|
|
283
|
+
const setInsertNoticeCallback = useCallback((cb: (html: string) => void) => {
|
|
284
|
+
insertNoticeRef.current = cb;
|
|
285
|
+
}, []);
|
|
286
|
+
|
|
287
|
+
const setToastCallback = useCallback((cb: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void) => {
|
|
288
|
+
toastRef.current = cb;
|
|
289
|
+
}, []);
|
|
290
|
+
|
|
291
|
+
const setConfirmCallback = useCallback((cb: (title: string, message: string, options?: ConfirmOptions) => Promise<boolean>) => {
|
|
292
|
+
confirmRef.current = cb;
|
|
293
|
+
}, []);
|
|
294
|
+
|
|
295
|
+
const setSendMessageCallback = useCallback((cb: (content: string) => Promise<void>) => {
|
|
296
|
+
sendMessageRef.current = cb;
|
|
297
|
+
}, []);
|
|
298
|
+
|
|
299
|
+
const setGetMessagesCallback = useCallback((cb: () => { role: string; content: string }[]) => {
|
|
300
|
+
getMessagesRef.current = cb;
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
const setGetWorldSettingCallback = useCallback((cb: () => Record<string, unknown>) => {
|
|
304
|
+
getWorldSettingRef.current = cb;
|
|
305
|
+
}, []);
|
|
306
|
+
|
|
307
|
+
const setSetWorldSettingCallback = useCallback((cb: (data: Record<string, unknown>) => void) => {
|
|
308
|
+
setWorldSettingRef.current = cb;
|
|
309
|
+
}, []);
|
|
310
|
+
|
|
311
|
+
const setGetSessionIdCallback = useCallback((cb: () => string) => {
|
|
312
|
+
getSessionIdRef.current = cb;
|
|
313
|
+
}, []);
|
|
314
|
+
|
|
315
|
+
/** 设置输入框元素回调(由 GamePage 调用) */
|
|
316
|
+
const setInputElementCallback = useCallback((cb: (el: HTMLTextAreaElement | null) => void) => {
|
|
317
|
+
// 先调用回调设置当前元素
|
|
318
|
+
cb(inputElementRef.current);
|
|
319
|
+
// 保存回调以便后续元素变化时通知
|
|
320
|
+
// (实际上我们直接用 ref,这里 cb 会被立即调用一次)
|
|
321
|
+
}, []);
|
|
322
|
+
|
|
323
|
+
/** 输入框控制 API 实现 */
|
|
324
|
+
const inputControl: InputControlAPI = {
|
|
325
|
+
getContent: () => {
|
|
326
|
+
return inputElementRef.current?.value ?? null;
|
|
327
|
+
},
|
|
328
|
+
setContent: (text: string) => {
|
|
329
|
+
if (!inputElementRef.current) return;
|
|
330
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
|
331
|
+
if (nativeInputValueSetter) {
|
|
332
|
+
nativeInputValueSetter.call(inputElementRef.current, text);
|
|
333
|
+
} else {
|
|
334
|
+
inputElementRef.current.value = text;
|
|
335
|
+
}
|
|
336
|
+
inputElementRef.current.dispatchEvent(new Event('input', { bubbles: true }));
|
|
337
|
+
},
|
|
338
|
+
appendContent: (text: string) => {
|
|
339
|
+
const el = inputElementRef.current;
|
|
340
|
+
if (!el) return;
|
|
341
|
+
const start = el.selectionStart ?? el.value.length;
|
|
342
|
+
const end = el.selectionEnd ?? el.value.length;
|
|
343
|
+
const before = el.value.substring(0, start);
|
|
344
|
+
const after = el.value.substring(end);
|
|
345
|
+
const newValue = before + text + after;
|
|
346
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
|
347
|
+
if (nativeInputValueSetter) {
|
|
348
|
+
nativeInputValueSetter.call(el, newValue);
|
|
349
|
+
} else {
|
|
350
|
+
el.value = newValue;
|
|
351
|
+
}
|
|
352
|
+
const newCursorPos = start + text.length;
|
|
353
|
+
el.setSelectionRange(newCursorPos, newCursorPos);
|
|
354
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
355
|
+
},
|
|
356
|
+
clearContent: () => {
|
|
357
|
+
if (!inputElementRef.current) return;
|
|
358
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
|
359
|
+
if (nativeInputValueSetter) {
|
|
360
|
+
nativeInputValueSetter.call(inputElementRef.current, '');
|
|
361
|
+
} else {
|
|
362
|
+
inputElementRef.current.value = '';
|
|
363
|
+
}
|
|
364
|
+
inputElementRef.current.dispatchEvent(new Event('input', { bubbles: true }));
|
|
365
|
+
},
|
|
366
|
+
setPlaceholder: (text: string) => {
|
|
367
|
+
if (!inputElementRef.current) return;
|
|
368
|
+
inputElementRef.current.placeholder = text;
|
|
369
|
+
},
|
|
370
|
+
setDisabled: (disabled: boolean) => {
|
|
371
|
+
if (!inputElementRef.current) return;
|
|
372
|
+
inputElementRef.current.disabled = disabled;
|
|
373
|
+
},
|
|
374
|
+
focus: () => {
|
|
375
|
+
inputElementRef.current?.focus();
|
|
376
|
+
},
|
|
377
|
+
blur: () => {
|
|
378
|
+
inputElementRef.current?.blur();
|
|
379
|
+
},
|
|
380
|
+
setStyle: (style: Record<string, string>) => {
|
|
381
|
+
if (!inputElementRef.current) return;
|
|
382
|
+
Object.assign(inputElementRef.current.style, style);
|
|
383
|
+
},
|
|
384
|
+
injectStyle: (css: string) => {
|
|
385
|
+
const styleId = `xinyu-input-style-${++inputStyleCounterRef.current}`;
|
|
386
|
+
const styleEl = document.createElement('style');
|
|
387
|
+
styleEl.id = styleId;
|
|
388
|
+
styleEl.textContent = css.replace(/#xinyu-chat-input/g, `#xinyu-chat-input`);
|
|
389
|
+
document.head.appendChild(styleEl);
|
|
390
|
+
return styleId;
|
|
391
|
+
},
|
|
392
|
+
removeStyle: (styleId: string) => {
|
|
393
|
+
const el = document.getElementById(styleId);
|
|
394
|
+
if (el) el.remove();
|
|
395
|
+
},
|
|
396
|
+
onInput: (handler: (value: string) => void) => {
|
|
397
|
+
inputListenersRef.current.push(handler);
|
|
398
|
+
return () => {
|
|
399
|
+
inputListenersRef.current = inputListenersRef.current.filter(h => h !== handler);
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
onSend: (handler: (content: string) => void) => {
|
|
403
|
+
sendListenersRef.current.push(handler);
|
|
404
|
+
return () => {
|
|
405
|
+
sendListenersRef.current = sendListenersRef.current.filter(h => h !== handler);
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
onKeyDown: (handler: (event: { key: string; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; preventDefault: () => void }) => void) => {
|
|
409
|
+
keyDownListenersRef.current.push(handler);
|
|
410
|
+
return () => {
|
|
411
|
+
keyDownListenersRef.current = keyDownListenersRef.current.filter(h => h !== handler);
|
|
412
|
+
};
|
|
413
|
+
},
|
|
414
|
+
getSelection: () => {
|
|
415
|
+
const el = inputElementRef.current;
|
|
416
|
+
if (!el) return null;
|
|
417
|
+
const start = el.selectionStart ?? 0;
|
|
418
|
+
const end = el.selectionEnd ?? 0;
|
|
419
|
+
return { start, end, text: el.value.substring(start, end) };
|
|
420
|
+
},
|
|
421
|
+
setSelection: (start: number, end: number) => {
|
|
422
|
+
if (!inputElementRef.current) return;
|
|
423
|
+
inputElementRef.current.setSelectionRange(start, end);
|
|
424
|
+
},
|
|
425
|
+
insertAtCursor: (text: string) => {
|
|
426
|
+
const el = inputElementRef.current;
|
|
427
|
+
if (!el) return;
|
|
428
|
+
const start = el.selectionStart ?? el.value.length;
|
|
429
|
+
const end = el.selectionEnd ?? el.value.length;
|
|
430
|
+
const before = el.value.substring(0, start);
|
|
431
|
+
const after = el.value.substring(end);
|
|
432
|
+
const newValue = before + text + after;
|
|
433
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
|
434
|
+
if (nativeInputValueSetter) {
|
|
435
|
+
nativeInputValueSetter.call(el, newValue);
|
|
436
|
+
} else {
|
|
437
|
+
el.value = newValue;
|
|
438
|
+
}
|
|
439
|
+
const newCursorPos = start + text.length;
|
|
440
|
+
el.setSelectionRange(newCursorPos, newCursorPos);
|
|
441
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
442
|
+
},
|
|
443
|
+
setMaxLength: (length: number) => {
|
|
444
|
+
if (!inputElementRef.current) return;
|
|
445
|
+
inputElementRef.current.maxLength = length;
|
|
446
|
+
},
|
|
447
|
+
getLength: () => {
|
|
448
|
+
return inputElementRef.current?.value.length ?? 0;
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/** 加载并初始化所有插件 */
|
|
453
|
+
const loadPlugins = useCallback(async () => {
|
|
454
|
+
// 防止并发重入
|
|
455
|
+
if (loadingRef.current) return;
|
|
456
|
+
loadingRef.current = true;
|
|
457
|
+
try {
|
|
458
|
+
const { getPluginRuntime } = await import('@/lib/plugin-runtime');
|
|
459
|
+
const runtime = getPluginRuntime();
|
|
460
|
+
runtimeRef.current = runtime;
|
|
461
|
+
|
|
462
|
+
// 重置运行时
|
|
463
|
+
runtime.reset();
|
|
464
|
+
|
|
465
|
+
// 注入 UI 回调(让插件能调用 chat.send、insertSystemMessage、toast 等)
|
|
466
|
+
runtime.setUICallbacks({
|
|
467
|
+
sendMessage: async (content: string) => {
|
|
468
|
+
if (sendMessageRef.current) {
|
|
469
|
+
await sendMessageRef.current(content);
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
insertSystemMessage: (content: string) => {
|
|
473
|
+
if (insertSystemMessageRef.current) {
|
|
474
|
+
insertSystemMessageRef.current(content);
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
insertNotice: (html: string) => {
|
|
478
|
+
if (insertNoticeRef.current) {
|
|
479
|
+
insertNoticeRef.current(html);
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
toast: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => {
|
|
483
|
+
if (toastRef.current) {
|
|
484
|
+
const { enabled, levels } = toastSettingsRef.current;
|
|
485
|
+
if (enabled && levels.includes(type || 'info')) {
|
|
486
|
+
toastRef.current(message, type);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
// 新增:模态框回调
|
|
491
|
+
showModal: (modal: ModalRegistration) => {
|
|
492
|
+
setModals(prev => [...prev, modal]);
|
|
493
|
+
},
|
|
494
|
+
closeModal: (modalId: string) => {
|
|
495
|
+
setModals(prev => prev.filter(m => m.id !== modalId));
|
|
496
|
+
},
|
|
497
|
+
updateModal: (modalId: string, options: { title?: string; content?: string; width?: string; style?: Record<string, unknown> }) => {
|
|
498
|
+
setModals(prev => prev.map(m => m.id === modalId ? { ...m, ...options, style: options.style ? options.style as React.CSSProperties : m.style } : m));
|
|
499
|
+
},
|
|
500
|
+
confirm: async (title: string, message: string, options?: ConfirmOptions) => {
|
|
501
|
+
if (confirmRef.current) {
|
|
502
|
+
return confirmRef.current(title, message, options);
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
505
|
+
},
|
|
506
|
+
// 配置变更广播(跨标签页同步)
|
|
507
|
+
onConfigChange: (pluginId: string, config: Record<string, unknown>) => {
|
|
508
|
+
broadcastPluginConfigChange({ pluginId, config, worldId: worldIdRef.current || undefined });
|
|
509
|
+
},
|
|
510
|
+
// 插槽内容变更时刷新 UI
|
|
511
|
+
onSlotChange: () => {
|
|
512
|
+
setSlotVersion(v => v + 1);
|
|
513
|
+
},
|
|
514
|
+
// 输入框控制回调
|
|
515
|
+
inputControl: {
|
|
516
|
+
setInputElement: (el: HTMLTextAreaElement | null) => {
|
|
517
|
+
inputElementRef.current = el;
|
|
518
|
+
},
|
|
519
|
+
getInputListeners: () => inputListenersRef.current,
|
|
520
|
+
getSendListeners: () => sendListenersRef.current,
|
|
521
|
+
getKeyDownListeners: () => keyDownListenersRef.current,
|
|
522
|
+
},
|
|
523
|
+
// 游戏数据回调(供插件 xinyu.game API 使用)
|
|
524
|
+
getWorldSetting: () => getWorldSettingRef.current?.() || {},
|
|
525
|
+
setWorldSetting: (data: Record<string, unknown>) => setWorldSettingRef.current?.(data),
|
|
526
|
+
getSessionId: () => getSessionIdRef.current?.() || '',
|
|
527
|
+
getMessages: () => getMessagesRef.current?.() || [],
|
|
528
|
+
getWorldId: () => worldIdRef.current,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// 设置获取插槽容器的回调
|
|
532
|
+
runtime.setGetSlotContainerCallback((slotId: UISlotId) => {
|
|
533
|
+
return document.querySelector(`[data-plugin-slot="${slotId}"]`);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// 获取所有插件
|
|
537
|
+
const pluginsRes = await fetch('/api/plugins');
|
|
538
|
+
if (!pluginsRes.ok) return;
|
|
539
|
+
const manifests: PluginManifest[] = await pluginsRes.json();
|
|
540
|
+
|
|
541
|
+
// 获取绑定列表(全局 + 当前世界的)
|
|
542
|
+
const bindingParams = new URLSearchParams();
|
|
543
|
+
bindingParams.set('scope', 'global');
|
|
544
|
+
const globalBindingsRes = await fetch(`/api/plugins/bindings?${bindingParams.toString()}`);
|
|
545
|
+
const globalBindings: PluginBinding[] = globalBindingsRes.ok ? await globalBindingsRes.json() : [];
|
|
546
|
+
|
|
547
|
+
let worldBindings: PluginBinding[] = [];
|
|
548
|
+
if (worldIdRef.current) {
|
|
549
|
+
const worldBindingParams = new URLSearchParams();
|
|
550
|
+
worldBindingParams.set('scope', 'world');
|
|
551
|
+
worldBindingParams.set('worldId', worldIdRef.current);
|
|
552
|
+
const worldBindingsRes = await fetch(`/api/plugins/bindings?${worldBindingParams.toString()}`);
|
|
553
|
+
worldBindings = worldBindingsRes.ok ? await worldBindingsRes.json() : [];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// 合并绑定:全局禁用优先,全局启用不传播到运行中的游戏
|
|
557
|
+
// 规则:
|
|
558
|
+
// - 全局 enabled=false → 最终结果一定是禁用(局外关闭覆盖局内)
|
|
559
|
+
// - 有世界级绑定 → 以世界级绑定为准(局内用户主动操作过)
|
|
560
|
+
// - 无世界级绑定 + 有 worldId(运行中的游戏)→ 不加载(全局启用不影响运行中游戏)
|
|
561
|
+
// - 无世界级绑定 + 无 worldId(非游戏场景)→ 使用全局默认值
|
|
562
|
+
const globalBindingMap = new Map<string, PluginBinding>();
|
|
563
|
+
for (const b of globalBindings) {
|
|
564
|
+
globalBindingMap.set(b.extensionId, b);
|
|
565
|
+
}
|
|
566
|
+
const worldBindingMap = new Map<string, PluginBinding>();
|
|
567
|
+
for (const b of worldBindings) {
|
|
568
|
+
worldBindingMap.set(b.extensionId, b);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const effectiveBindings = new Map<string, PluginBinding>();
|
|
572
|
+
for (const manifest of manifests) {
|
|
573
|
+
const global = globalBindingMap.get(manifest.id);
|
|
574
|
+
const world = worldBindingMap.get(manifest.id);
|
|
575
|
+
|
|
576
|
+
// 全局禁用 → 强制禁用(无论世界级设置如何)
|
|
577
|
+
if (global && !global.enabled) {
|
|
578
|
+
effectiveBindings.set(manifest.id, global);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 有世界级绑定 → 以世界级为准
|
|
583
|
+
if (world) {
|
|
584
|
+
effectiveBindings.set(manifest.id, world);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 无世界级绑定
|
|
589
|
+
if (worldIdRef.current) {
|
|
590
|
+
// 运行中的游戏:全局启用不传播,只有用户在游戏内主动启用才生效
|
|
591
|
+
// 不添加到 effectiveBindings → 该插件不会被加载
|
|
592
|
+
} else {
|
|
593
|
+
// 非游戏场景(如 extensions 页面):使用全局默认值
|
|
594
|
+
if (global) {
|
|
595
|
+
effectiveBindings.set(manifest.id, global);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 加载每个插件(按拓扑排序,被依赖的先加载)
|
|
601
|
+
const sortedManifests = runtime.getLoadOrder(manifests);
|
|
602
|
+
const allConflicts: Array<{
|
|
603
|
+
manifest: PluginManifest;
|
|
604
|
+
conflicts: Array<{
|
|
605
|
+
permission: string;
|
|
606
|
+
conflictingPluginId: string;
|
|
607
|
+
conflictingPluginName: string;
|
|
608
|
+
conflictType: 'exclusive-vs-any' | 'common-vs-exclusive';
|
|
609
|
+
}>;
|
|
610
|
+
missingRequired: string[];
|
|
611
|
+
}> = [];
|
|
612
|
+
|
|
613
|
+
for (const manifest of sortedManifests) {
|
|
614
|
+
const binding = effectiveBindings.get(manifest.id);
|
|
615
|
+
if (binding) {
|
|
616
|
+
const result = runtime.loadPlugin(manifest, binding);
|
|
617
|
+
if (!result.success) {
|
|
618
|
+
allConflicts.push({
|
|
619
|
+
manifest,
|
|
620
|
+
conflicts: result.conflicts,
|
|
621
|
+
missingRequired: result.missingRequired,
|
|
622
|
+
});
|
|
623
|
+
} else if (binding.scope === 'world' && binding.worldId) {
|
|
624
|
+
// 插件被绑定到世界时触发 onPluginBindingWorld 钩子
|
|
625
|
+
runtime.triggerPluginBindingWorld(binding.worldId);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// 处理冲突:弹出 toast + 显示冲突弹窗
|
|
631
|
+
for (const conflict of allConflicts) {
|
|
632
|
+
if (conflict.conflicts.length > 0) {
|
|
633
|
+
const conflictPerms = Array.from(new Set(conflict.conflicts.map(c => c.permission))).join(', ');
|
|
634
|
+
toastRef.current?.(
|
|
635
|
+
`插件 "${conflict.manifest.name}" 启动失败:权限冲突 (${conflictPerms})`,
|
|
636
|
+
'warning'
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
if (conflict.missingRequired.length > 0) {
|
|
640
|
+
toastRef.current?.(
|
|
641
|
+
`插件 "${conflict.manifest.name}" 启动失败:必要权限未声明 (${conflict.missingRequired.join(', ')})`,
|
|
642
|
+
'warning'
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (allConflicts.length > 0) {
|
|
647
|
+
setPermissionConflicts(allConflicts);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 更新状态
|
|
651
|
+
setPlugins(runtime.getInstances());
|
|
652
|
+
setAttributes(runtime.getAttributes());
|
|
653
|
+
setCommands(runtime.getCommands());
|
|
654
|
+
setSidebarPanels(runtime.getSidebarPanels());
|
|
655
|
+
setMessageRenderers(runtime.getMessageRenderers());
|
|
656
|
+
setModals(runtime.getModals());
|
|
657
|
+
setDependencyStatus(runtime.getDependencyStatus());
|
|
658
|
+
setInitialized(true);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
console.error('[PluginProvider] 加载插件失败:', e);
|
|
661
|
+
setInitialized(true);
|
|
662
|
+
} finally {
|
|
663
|
+
loadingRef.current = false;
|
|
664
|
+
}
|
|
665
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
666
|
+
}, []);
|
|
667
|
+
|
|
668
|
+
/** 重新加载所有插件 */
|
|
669
|
+
const reloadPlugins = useCallback(async () => {
|
|
670
|
+
await loadPlugins();
|
|
671
|
+
}, [loadPlugins]);
|
|
672
|
+
|
|
673
|
+
/** 刷新 UI 状态(不重新加载插件,只从 runtime 同步到 React state) */
|
|
674
|
+
const refreshUI = useCallback(() => {
|
|
675
|
+
const runtime = runtimeRef.current;
|
|
676
|
+
if (!runtime) return;
|
|
677
|
+
setPlugins(runtime.getInstances());
|
|
678
|
+
setAttributes(runtime.getAttributes());
|
|
679
|
+
setCommands(runtime.getCommands());
|
|
680
|
+
setSidebarPanels(runtime.getSidebarPanels());
|
|
681
|
+
setMessageRenderers(runtime.getMessageRenderers());
|
|
682
|
+
setModals(runtime.getModals());
|
|
683
|
+
setDependencyStatus(runtime.getDependencyStatus());
|
|
684
|
+
setSlotVersion(v => v + 1);
|
|
685
|
+
}, []);
|
|
686
|
+
|
|
687
|
+
/** 重新加载单个插件(不影响其他插件) */
|
|
688
|
+
const reloadSinglePlugin = useCallback(async (pluginId: string, bindingOverride?: PluginBinding) => {
|
|
689
|
+
const runtime = runtimeRef.current;
|
|
690
|
+
if (!runtime) return;
|
|
691
|
+
|
|
692
|
+
// 1. 卸载旧实例
|
|
693
|
+
runtime.unloadPlugin(pluginId);
|
|
694
|
+
|
|
695
|
+
// 2. 立即更新 UI 状态(卸载后 UI 应立即消失)
|
|
696
|
+
setPlugins(runtime.getInstances());
|
|
697
|
+
setAttributes(runtime.getAttributes());
|
|
698
|
+
setCommands(runtime.getCommands());
|
|
699
|
+
setSidebarPanels(runtime.getSidebarPanels());
|
|
700
|
+
setMessageRenderers(runtime.getMessageRenderers());
|
|
701
|
+
setModals(runtime.getModals());
|
|
702
|
+
setDependencyStatus(runtime.getDependencyStatus());
|
|
703
|
+
setSlotVersion(v => v + 1);
|
|
704
|
+
|
|
705
|
+
// 3. 如果提供了 bindingOverride,直接使用;否则从 DB 获取
|
|
706
|
+
try {
|
|
707
|
+
let manifest: PluginManifest | undefined;
|
|
708
|
+
let binding: PluginBinding | undefined = bindingOverride;
|
|
709
|
+
|
|
710
|
+
if (!binding) {
|
|
711
|
+
// 从 DB 获取绑定
|
|
712
|
+
const pluginRes = await fetch('/api/plugins');
|
|
713
|
+
const allPlugins: PluginManifest[] = pluginRes.ok ? await pluginRes.json() : [];
|
|
714
|
+
manifest = allPlugins.find(p => p.id === pluginId);
|
|
715
|
+
if (!manifest) return;
|
|
716
|
+
|
|
717
|
+
if (worldIdRef.current) {
|
|
718
|
+
const params = new URLSearchParams({ scope: 'world', worldId: worldIdRef.current });
|
|
719
|
+
const worldRes = await fetch(`/api/plugins/bindings?${params}`);
|
|
720
|
+
const worldBindings: PluginBinding[] = worldRes.ok ? await worldRes.json() : [];
|
|
721
|
+
const worldBinding = worldBindings.find(b => b.extensionId === pluginId);
|
|
722
|
+
const globalRes = await fetch('/api/plugins/bindings?scope=global');
|
|
723
|
+
const globalBindings: PluginBinding[] = globalRes.ok ? await globalRes.json() : [];
|
|
724
|
+
const globalBinding = globalBindings.find(b => b.extensionId === pluginId);
|
|
725
|
+
if (globalBinding && !globalBinding.enabled) {
|
|
726
|
+
return;
|
|
727
|
+
} else if (worldBinding && worldBinding.enabled) {
|
|
728
|
+
binding = worldBinding;
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
const globalRes = await fetch('/api/plugins/bindings?scope=global');
|
|
732
|
+
const globalBindings: PluginBinding[] = globalRes.ok ? await globalRes.json() : [];
|
|
733
|
+
binding = globalBindings.find(b => b.extensionId === pluginId);
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
// 从所有插件中找到 manifest
|
|
737
|
+
const pluginRes = await fetch('/api/plugins');
|
|
738
|
+
const allPlugins: PluginManifest[] = pluginRes.ok ? await pluginRes.json() : [];
|
|
739
|
+
manifest = allPlugins.find(p => p.id === pluginId);
|
|
740
|
+
if (!manifest) return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 如果有有效绑定且启用,重新加载
|
|
744
|
+
if (binding && binding.enabled) {
|
|
745
|
+
runtime.loadPlugin(manifest!, binding);
|
|
746
|
+
setPlugins(runtime.getInstances());
|
|
747
|
+
setAttributes(runtime.getAttributes());
|
|
748
|
+
setCommands(runtime.getCommands());
|
|
749
|
+
setSidebarPanels(runtime.getSidebarPanels());
|
|
750
|
+
setMessageRenderers(runtime.getMessageRenderers());
|
|
751
|
+
setModals(runtime.getModals());
|
|
752
|
+
setDependencyStatus(runtime.getDependencyStatus());
|
|
753
|
+
setSlotVersion(v => v + 1);
|
|
754
|
+
}
|
|
755
|
+
} catch (e) {
|
|
756
|
+
console.error(`[PluginProvider] 重新加载插件 ${pluginId} 失败:`, e);
|
|
757
|
+
}
|
|
758
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
759
|
+
}, []);
|
|
760
|
+
/** 直接更新插件 binding 并 reload(不重新 fetch DB,最可靠的路径) */
|
|
761
|
+
const updateAndReload = useCallback(async (pluginId: string, newBinding: PluginBinding) => {
|
|
762
|
+
const runtime = runtimeRef.current;
|
|
763
|
+
if (!runtime) return;
|
|
764
|
+
|
|
765
|
+
// 1. 卸载旧实例
|
|
766
|
+
runtime.unloadPlugin(pluginId);
|
|
767
|
+
|
|
768
|
+
// 2. 更新 UI(卸载后立即消失)
|
|
769
|
+
setPlugins(runtime.getInstances());
|
|
770
|
+
setAttributes(runtime.getAttributes());
|
|
771
|
+
setCommands(runtime.getCommands());
|
|
772
|
+
setSidebarPanels(runtime.getSidebarPanels());
|
|
773
|
+
setMessageRenderers(runtime.getMessageRenderers());
|
|
774
|
+
setModals(runtime.getModals());
|
|
775
|
+
setDependencyStatus(runtime.getDependencyStatus());
|
|
776
|
+
setSlotVersion(v => v + 1);
|
|
777
|
+
|
|
778
|
+
// 3. 获取 manifest 并加载
|
|
779
|
+
try {
|
|
780
|
+
const pluginRes = await fetch('/api/plugins');
|
|
781
|
+
const allPlugins: PluginManifest[] = pluginRes.ok ? await pluginRes.json() : [];
|
|
782
|
+
const manifest = allPlugins.find(p => p.id === pluginId);
|
|
783
|
+
if (!manifest) {
|
|
784
|
+
console.error(`[PluginProvider] updateAndReload: 找不到插件 ${pluginId} 的 manifest`);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (newBinding.enabled) {
|
|
789
|
+
runtime.loadPlugin(manifest, newBinding);
|
|
790
|
+
setPlugins(runtime.getInstances());
|
|
791
|
+
setAttributes(runtime.getAttributes());
|
|
792
|
+
setCommands(runtime.getCommands());
|
|
793
|
+
setSidebarPanels(runtime.getSidebarPanels());
|
|
794
|
+
setMessageRenderers(runtime.getMessageRenderers());
|
|
795
|
+
setModals(runtime.getModals());
|
|
796
|
+
setDependencyStatus(runtime.getDependencyStatus());
|
|
797
|
+
setSlotVersion(v => v + 1);
|
|
798
|
+
}
|
|
799
|
+
} catch (e) {
|
|
800
|
+
console.error(`[PluginProvider] updateAndReload 失败:`, e);
|
|
801
|
+
}
|
|
802
|
+
}, []);
|
|
803
|
+
|
|
804
|
+
/** 轻量级更新插件运行时配置(触发 onConfigChange hook + 持久化 + 广播) */
|
|
805
|
+
const updatePluginConfig = useCallback((pluginId: string, config: Record<string, unknown>, options?: { persist?: boolean; broadcast?: boolean; triggerHook?: boolean }) => {
|
|
806
|
+
const runtime = runtimeRef.current;
|
|
807
|
+
if (!runtime) return;
|
|
808
|
+
runtime.updatePluginConfig(pluginId, config, options);
|
|
809
|
+
}, []);
|
|
810
|
+
|
|
811
|
+
/** 更新 toast 设置(设置页保存时调用) */
|
|
812
|
+
const updateToastSettings = useCallback((enabled: boolean, levels: string[]) => {
|
|
813
|
+
toastSettingsRef.current = { enabled, levels };
|
|
814
|
+
}, []);
|
|
815
|
+
|
|
816
|
+
// 初始化时从 API 加载 toast 设置
|
|
817
|
+
useEffect(() => {
|
|
818
|
+
fetch('/api/settings')
|
|
819
|
+
.then(res => res.ok ? res.json() : null)
|
|
820
|
+
.then(data => {
|
|
821
|
+
if (data) {
|
|
822
|
+
toastSettingsRef.current = {
|
|
823
|
+
enabled: data.pluginToastEnabled !== false,
|
|
824
|
+
levels: data.pluginToastLevels || ['info', 'success', 'warning', 'error'],
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
})
|
|
828
|
+
.catch(() => {});
|
|
829
|
+
}, []);
|
|
830
|
+
|
|
831
|
+
useEffect(() => {
|
|
832
|
+
if (initializedRef.current) return;
|
|
833
|
+
initializedRef.current = true;
|
|
834
|
+
loadPlugins();
|
|
835
|
+
}, [loadPlugins]);
|
|
836
|
+
|
|
837
|
+
// 监听跨标签页插件绑定变更(设置页禁用插件时实时同步到游戏页)
|
|
838
|
+
useEffect(() => {
|
|
839
|
+
const unsubscribe = onPluginBindingChange((event: PluginBindingChangeEvent) => {
|
|
840
|
+
if (event.source === 'global' && !event.enabled && worldIdRef.current) {
|
|
841
|
+
console.log(`[PluginProvider] 收到全局禁用指令: ${event.pluginId},重新加载插件`);
|
|
842
|
+
loadPlugins();
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
return unsubscribe;
|
|
846
|
+
}, [loadPlugins]);
|
|
847
|
+
|
|
848
|
+
// 监听跨标签页插件配置变更
|
|
849
|
+
useEffect(() => {
|
|
850
|
+
const unsubscribe = onPluginConfigChange((event: PluginConfigChangeEvent) => {
|
|
851
|
+
if (event.worldId === worldIdRef.current) {
|
|
852
|
+
const runtime = runtimeRef.current;
|
|
853
|
+
if (!runtime) return;
|
|
854
|
+
// 跨标签页配置变更:仅更新内存,不持久化(其他标签页已持久化),不广播(避免循环)
|
|
855
|
+
runtime.updatePluginConfig(event.pluginId, event.config, {
|
|
856
|
+
persist: false,
|
|
857
|
+
broadcast: false,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
return unsubscribe;
|
|
862
|
+
}, []);
|
|
863
|
+
|
|
864
|
+
const setGameState = useCallback((partial: Record<string, unknown>) => {
|
|
865
|
+
setGameStateInternal(prev => {
|
|
866
|
+
const next = { ...prev, ...partial };
|
|
867
|
+
runtimeRef.current?.setGameState(next);
|
|
868
|
+
return next;
|
|
869
|
+
});
|
|
870
|
+
}, []);
|
|
871
|
+
|
|
872
|
+
const resetGameState = useCallback(() => {
|
|
873
|
+
setGameStateInternal({});
|
|
874
|
+
runtimeRef.current?.resetGameState();
|
|
875
|
+
// 重新获取属性
|
|
876
|
+
if (runtimeRef.current) {
|
|
877
|
+
setAttributes(runtimeRef.current.getAttributes());
|
|
878
|
+
}
|
|
879
|
+
}, []);
|
|
880
|
+
|
|
881
|
+
const registerCommand = useCallback((cmd: ChatCommand) => {
|
|
882
|
+
setCommands(prev => {
|
|
883
|
+
const exists = prev.find(c => c.name === cmd.name);
|
|
884
|
+
if (exists) return prev;
|
|
885
|
+
return [...prev, cmd];
|
|
886
|
+
});
|
|
887
|
+
}, []);
|
|
888
|
+
|
|
889
|
+
const executeCommand = useCallback(async (rawInput: string): Promise<string | void> => {
|
|
890
|
+
if (!runtimeRef.current) return undefined;
|
|
891
|
+
return runtimeRef.current.executeCommand(rawInput);
|
|
892
|
+
}, []);
|
|
893
|
+
|
|
894
|
+
/** 获取指定插槽的注册内容 */
|
|
895
|
+
const getSlotRegistrations = useCallback((slotId: UISlotId): SlotRegistration[] => {
|
|
896
|
+
if (!runtimeRef.current) return [];
|
|
897
|
+
return runtimeRef.current.getSlotRegistrations(slotId);
|
|
898
|
+
}, []);
|
|
899
|
+
|
|
900
|
+
/** 关闭模态框 */
|
|
901
|
+
const closeModal = useCallback((modalId: string) => {
|
|
902
|
+
if (runtimeRef.current) {
|
|
903
|
+
runtimeRef.current.closeModal(modalId);
|
|
904
|
+
}
|
|
905
|
+
setModals(prev => prev.filter(m => m.id !== modalId));
|
|
906
|
+
}, []);
|
|
907
|
+
|
|
908
|
+
/** 触发游戏初始化钩子 */
|
|
909
|
+
const triggerGameInit = useCallback(async () => {
|
|
910
|
+
if (runtimeRef.current) {
|
|
911
|
+
await runtimeRef.current.triggerGameInit();
|
|
912
|
+
}
|
|
913
|
+
}, []);
|
|
914
|
+
|
|
915
|
+
/** 触发插件绑定世界钩子 */
|
|
916
|
+
const triggerPluginBindingWorld = useCallback(async (worldId: string) => {
|
|
917
|
+
if (runtimeRef.current) {
|
|
918
|
+
await runtimeRef.current.triggerPluginBindingWorld(worldId);
|
|
919
|
+
}
|
|
920
|
+
}, []);
|
|
921
|
+
|
|
922
|
+
const enabledPlugins = plugins.filter(p => p.enabled);
|
|
923
|
+
|
|
924
|
+
const value: PluginContextValue = {
|
|
925
|
+
plugins,
|
|
926
|
+
enabledPlugins,
|
|
927
|
+
attributes,
|
|
928
|
+
commands,
|
|
929
|
+
sidebarPanels,
|
|
930
|
+
messageRenderers,
|
|
931
|
+
applyRenderMessagePipeline: (message: { role: string; content: string }) => runtimeRef.current?.applyRenderMessagePipeline(message) ?? Promise.resolve(message.content),
|
|
932
|
+
gameState,
|
|
933
|
+
setGameState,
|
|
934
|
+
resetGameState,
|
|
935
|
+
registerCommand,
|
|
936
|
+
executeCommand,
|
|
937
|
+
onInsertSystemMessage: insertSystemMessageRef.current,
|
|
938
|
+
setInsertSystemMessageCallback,
|
|
939
|
+
onInsertNotice: insertNoticeRef.current,
|
|
940
|
+
setInsertNoticeCallback,
|
|
941
|
+
onToast: toastRef.current,
|
|
942
|
+
setToastCallback,
|
|
943
|
+
onConfirm: confirmRef.current,
|
|
944
|
+
setConfirmCallback,
|
|
945
|
+
onSendMessage: sendMessageRef.current,
|
|
946
|
+
setSendMessageCallback,
|
|
947
|
+
onGetMessages: getMessagesRef.current,
|
|
948
|
+
setGetMessagesCallback,
|
|
949
|
+
onGetWorldSetting: getWorldSettingRef.current,
|
|
950
|
+
setGetWorldSettingCallback,
|
|
951
|
+
onSetWorldSetting: setWorldSettingRef.current,
|
|
952
|
+
setSetWorldSettingCallback,
|
|
953
|
+
onGetSessionId: getSessionIdRef.current,
|
|
954
|
+
setGetSessionIdCallback,
|
|
955
|
+
setInputElementCallback,
|
|
956
|
+
inputControl,
|
|
957
|
+
reloadPlugins,
|
|
958
|
+
reloadSinglePlugin,
|
|
959
|
+
updateAndReload,
|
|
960
|
+
updatePluginConfig,
|
|
961
|
+
refreshUI,
|
|
962
|
+
updateToastSettings,
|
|
963
|
+
initialized,
|
|
964
|
+
getSlotRegistrations,
|
|
965
|
+
slotVersion,
|
|
966
|
+
modals,
|
|
967
|
+
closeModal,
|
|
968
|
+
updateModal: (modalId: string, options: { title?: string; content?: string; width?: string; style?: Record<string, unknown> }) => {
|
|
969
|
+
const runtime = runtimeRef.current;
|
|
970
|
+
if (runtime) runtime.updateModal(modalId, options);
|
|
971
|
+
},
|
|
972
|
+
triggerGameInit,
|
|
973
|
+
triggerPluginBindingWorld,
|
|
974
|
+
dependencyStatus,
|
|
975
|
+
permissionConflicts,
|
|
976
|
+
resolvePermissionConflict: async (conflictingPluginId: string, permission: string, action: 'remove-permission' | 'disable-plugin') => {
|
|
977
|
+
const runtime = runtimeRef.current;
|
|
978
|
+
if (!runtime) return;
|
|
979
|
+
|
|
980
|
+
if (action === 'disable-plugin') {
|
|
981
|
+
// 禁用冲突插件
|
|
982
|
+
await fetch('/api/plugins/bindings', {
|
|
983
|
+
method: 'PUT',
|
|
984
|
+
headers: { 'Content-Type': 'application/json' },
|
|
985
|
+
body: JSON.stringify({ extensionId: conflictingPluginId, scope: 'global', enabled: false }),
|
|
986
|
+
});
|
|
987
|
+
runtime.unloadPlugin(conflictingPluginId);
|
|
988
|
+
} else if (action === 'remove-permission') {
|
|
989
|
+
// 从冲突插件中移除该权限
|
|
990
|
+
runtime.removePluginPermission(conflictingPluginId, permission);
|
|
991
|
+
// 更新数据库中的 manifest
|
|
992
|
+
const instance = runtime.getInstances().find(i => i.manifest.id === conflictingPluginId);
|
|
993
|
+
if (instance) {
|
|
994
|
+
const manifest = instance.manifest;
|
|
995
|
+
const updatePerms = (perms: string[] | undefined) => (perms || []).filter(p => p !== permission);
|
|
996
|
+
await fetch('/api/plugins', {
|
|
997
|
+
method: 'PUT',
|
|
998
|
+
headers: { 'Content-Type': 'application/json' },
|
|
999
|
+
body: JSON.stringify({
|
|
1000
|
+
id: conflictingPluginId,
|
|
1001
|
+
commonPermissions: updatePerms(manifest.commonPermissions),
|
|
1002
|
+
exclusivePermissions: updatePerms(manifest.exclusivePermissions),
|
|
1003
|
+
}),
|
|
1004
|
+
});
|
|
1005
|
+
// 更新内存中的 manifest
|
|
1006
|
+
manifest.commonPermissions = updatePerms(manifest.commonPermissions) as typeof manifest.commonPermissions;
|
|
1007
|
+
manifest.exclusivePermissions = updatePerms(manifest.exclusivePermissions) as typeof manifest.exclusivePermissions;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// 从冲突列表中移除已解决的项
|
|
1012
|
+
setPermissionConflicts(prev => {
|
|
1013
|
+
const next = prev.map(c => ({
|
|
1014
|
+
...c,
|
|
1015
|
+
conflicts: c.conflicts.filter(cf => !(cf.conflictingPluginId === conflictingPluginId && cf.permission === permission)),
|
|
1016
|
+
missingRequired: c.missingRequired,
|
|
1017
|
+
})).filter(c => c.conflicts.length > 0 || c.missingRequired.length > 0);
|
|
1018
|
+
|
|
1019
|
+
// 如果所有冲突都解决了,尝试重新加载失败的插件
|
|
1020
|
+
if (next.length === 0 && prev.length > 0) {
|
|
1021
|
+
// 延迟执行,让 UI 先更新
|
|
1022
|
+
setTimeout(() => {
|
|
1023
|
+
reloadPlugins();
|
|
1024
|
+
}, 300);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return next;
|
|
1028
|
+
});
|
|
1029
|
+
},
|
|
1030
|
+
dismissPermissionConflicts: () => setPermissionConflicts([]),
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
return (
|
|
1034
|
+
<PluginContext.Provider value={value}>
|
|
1035
|
+
{children}
|
|
1036
|
+
</PluginContext.Provider>
|
|
1037
|
+
);
|
|
1038
|
+
}
|