diff --git a/app/main_server.py b/app/main_server.py index ff6db93b04..ad280f20a3 100644 --- a/app/main_server.py +++ b/app/main_server.py @@ -1491,6 +1491,7 @@ async def get_response(self, path, scope): from main_routers.workshop_router import router as workshop_router # noqa from main_routers.cookies_login_router import router as cookies_login_router # noqa from main_routers.game_router import router as game_router # noqa +from main_routers.actions_proxy_router import router as actions_proxy_router # noqa from main_routers.shared_state import init_shared_state # noqa @@ -1541,6 +1542,7 @@ async def beacon_shutdown(): app.include_router(galgame_router) app.include_router(game_router) app.include_router(cookies_login_router) # Cookies登录相关路由,放在最后以避免与其他API路由冲突 +app.include_router(actions_proxy_router) # Quick Actions Panel: 代理到插件服务器 app.include_router(pages_router) # 兜底路由需最后挂载 # 后台预加载任务 diff --git a/frontend/react-neko-chat/src/App.test.tsx b/frontend/react-neko-chat/src/App.test.tsx index 0531fb27b4..b9b85ae479 100644 --- a/frontend/react-neko-chat/src/App.test.tsx +++ b/frontend/react-neko-chat/src/App.test.tsx @@ -1,7 +1,28 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; -import App from './App'; +import App, { resolveQuickActionNavigationTarget } from './App'; import { parseChatMessage } from './message-schema'; +describe('resolveQuickActionNavigationTarget', () => { + it('rewrites loopback plugin UI links to the current remote host', () => { + expect(resolveQuickActionNavigationTarget( + 'http://127.0.0.1:48916/plugin/demo/ui/', + { hostname: '192.168.1.20' }, + )).toBe('http://192.168.1.20:48916/plugin/demo/ui/'); + }); + + it('keeps local and non-plugin quick action links unchanged', () => { + expect(resolveQuickActionNavigationTarget( + 'http://127.0.0.1:48916/plugin/demo/ui/', + { hostname: 'localhost' }, + )).toBe('http://127.0.0.1:48916/plugin/demo/ui/'); + expect(resolveQuickActionNavigationTarget( + 'http://127.0.0.1:48916/admin', + { hostname: '192.168.1.20' }, + )).toBe('http://127.0.0.1:48916/admin'); + expect(resolveQuickActionNavigationTarget('/plugin/demo/ui/')).toBe('/plugin/demo/ui/'); + }); +}); + describe('App', () => { it('renders the empty state when there are no messages', () => { render(); diff --git a/frontend/react-neko-chat/src/App.tsx b/frontend/react-neko-chat/src/App.tsx index a7fca5235b..11957164df 100644 --- a/frontend/react-neko-chat/src/App.tsx +++ b/frontend/react-neko-chat/src/App.tsx @@ -14,6 +14,7 @@ import { type ChoicePrompt, type ChoicePromptSource, } from './message-schema'; +import CommandPalette, { type CommandItem, type UserPreferences } from './CommandPalette'; export type ChatWindowProps = ChatWindowSchemaProps & { onMessageAction?: (message: ChatMessage, action: MessageAction) => void; @@ -27,6 +28,12 @@ export type ChatWindowProps = ChatWindowSchemaProps & { onTranslateToggle?: () => void; onGalgameModeToggle?: () => void; onGalgameOptionSelect?: (option: GalgameOption) => void; + quickActions?: CommandItem[]; + quickActionsPreferences?: UserPreferences; + quickActionsLoading?: boolean; + onQuickActionExecute?: (actionId: string, value: unknown) => Promise; + onQuickActionsRequest?: () => void; + onQuickActionsPreferencesChange?: (prefs: UserPreferences) => void; // Generic ChoicePrompt(mini-game invite 等通用三选项框架)。 // galgame mode 现有路径继续走 galgameOptions / onGalgameOptionSelect(BC); // 本框架先只承载 mini_game_invite,未来可把 galgame 也迁过来。 @@ -107,6 +114,45 @@ const hammerOverlayTransformOrigin = { y: 118, }; +type NavigationLocation = Pick; + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ''); + if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') { + return true; + } + return /^127(?:\.\d{1,3}){3}$/.test(normalized); +} + +export function resolveQuickActionNavigationTarget( + target: string, + currentLocation: NavigationLocation = window.location, +): string | null { + if (!target) return null; + if (target.startsWith('//')) return null; + const isRelative = target.startsWith('/') || target.startsWith('./') || target.startsWith('../'); + const isHttp = /^https?:\/\//i.test(target); + if (!isRelative && !isHttp) return null; + if (!isHttp) return target; + + try { + const targetUrl = new URL(target); + const currentHostname = currentLocation.hostname; + if ( + targetUrl.pathname.startsWith('/plugin/') + && currentHostname + && isLoopbackHostname(targetUrl.hostname) + && !isLoopbackHostname(currentHostname) + ) { + targetUrl.hostname = currentHostname; + return targetUrl.toString(); + } + return target; + } catch { + return null; + } +} + const avatarToolSoundPaths = { lollipopBite: '/static/sounds/avatar-tools/lollipop-bite.mp3', coinDrop: '/static/sounds/avatar-tools/coin-drop.mp3', @@ -608,6 +654,12 @@ export default function App({ onTranslateToggle, onGalgameModeToggle, onGalgameOptionSelect, + quickActions, + quickActionsPreferences, + quickActionsLoading, + onQuickActionExecute, + onQuickActionsRequest, + onQuickActionsPreferencesChange, choicePrompt = null, onChoiceSelect, rollbackDraft, @@ -616,6 +668,8 @@ export default function App({ }: ChatWindowProps) { const [draft, setDraft] = useState(''); const [toolMenuOpen, setToolMenuOpen] = useState(false); + const [quickActionsPanelOpen, setQuickActionsPanelOpen] = useState(false); + const [quickActionsSlashMode, setQuickActionsSlashMode] = useState(false); // 当 composer-bottom-bar 宽度 < 阈值时,把右侧 4 个工具按钮折叠成 ··· 菜单。 // 用四态机让进出过渡都跑完动画再切稳态: // expanded → collapsing (右→左级联收起) → compact (··· 入场) @@ -669,6 +723,7 @@ export default function App({ const [floatingHearts, setFloatingHearts] = useState([]); const [floatingFistDrops, setFloatingFistDrops] = useState([]); const submittingRef = useRef(false); + const composerTextareaRef = useRef(null); const lastRollbackKeyRef = useRef(''); const lastToolCursorResetKeyRef = useRef(''); const canSubmit = !composerDisabled && (draft.trim().length > 0 || composerAttachments.length > 0); @@ -1516,6 +1571,8 @@ export default function App({ useEffect(() => { if (composerHidden || composerDisabled) { clearActiveCursorToolSelection(); + setQuickActionsPanelOpen(false); + setQuickActionsSlashMode(false); } }, [clearActiveCursorToolSelection, composerHidden, composerDisabled]); @@ -1531,6 +1588,40 @@ export default function App({ clearGlobalToolCursorState(); }, []); + const handleQuickActionInjectText = useCallback((text: string) => { + setDraft(prev => { + if (!prev || !prev.trim()) return text; + return `${prev} ${text}`; + }); + setQuickActionsPanelOpen(false); + setQuickActionsSlashMode(false); + requestAnimationFrame(() => { + composerTextareaRef.current?.focus(); + }); + }, []); + + const handleQuickActionNavigate = useCallback((target: string, openIn: string) => { + const resolvedTarget = resolveQuickActionNavigationTarget(target); + if (!resolvedTarget) return; + if (openIn === 'same_tab') { + window.location.href = resolvedTarget; + } else { + window.open(resolvedTarget, '_blank', 'noopener,noreferrer'); + } + }, []); + + const handleQuickActionExecute = useCallback(async ( + actionId: string, + value: unknown, + ): Promise => { + if (!onQuickActionExecute) return null; + return onQuickActionExecute(actionId, value); + }, [onQuickActionExecute]); + + const handleQuickActionsPreferencesChange = useCallback((prefs: UserPreferences) => { + onQuickActionsPreferencesChange?.(prefs); + }, [onQuickActionsPreferencesChange]); + function submitDraft() { if (composerDisabled) return; if (submittingRef.current) return; @@ -1545,8 +1636,32 @@ export default function App({ } } - // 右侧 3 个工具按钮:在 compact 与 normal 两种布局中复用同一份 JSX, + // 右侧工具按钮:在 compact 与 normal 两种布局中复用同一份 JSX, // 既避免重复,也保证 ref/事件绑定在两种模式下行为一致。 + const quickActionsButtonNode = ( + + ); + const translateButtonNode = (