Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a10868a
feat(sdk): add PluginSettings base class + @quick_action decorator
May 12, 2026
f58b73b
feat(actions): add chat actions backend
May 12, 2026
402032b
feat(actions): proxy chat actions through main server
May 12, 2026
205c781
feat(chat): add Command Palette (⌘K) actions panel
May 12, 2026
9aebd3f
test(actions): add unit and integration tests
May 12, 2026
62c4881
Merge remote-tracking branch 'upstream/main' into topic/chat-actions
May 12, 2026
3d6125a
feat(sdk): add user language, attachments and image export to NekoPlu…
May 12, 2026
0b3de9c
fix: address PR #1323 review comments
May 12, 2026
2b9fc1f
fix: address Codex review comments on PR #1323
May 12, 2026
df806d4
fix: address second-round review comments (code quality)
May 12, 2026
0143e3a
fix: address third-round review comments
May 12, 2026
4a0bcb9
fix: address fourth-round Codex review comments
May 12, 2026
7144b1f
fix: address fifth-round Codex review comments
May 12, 2026
2c20738
fix: address sixth-round Codex review comments
May 12, 2026
eb505a3
test(builtin_provider): guard test_plugin_id_filter against empty result
May 12, 2026
aefc3a3
fix: address seventh-round Codex review comments
May 12, 2026
00f9d53
test(setup): match real ResizeObserver(callback) signature in stub
May 12, 2026
148a94a
fix: address eighth-round review comments
May 12, 2026
bf27618
fix: lock action payload + fix settings/lifecycle precedence in rever…
May 12, 2026
ce43de8
fix: surface reload failure on profile switch + tighten test assertions
May 12, 2026
03c8c8a
test: drop unused fake_set_profile in profile reload regression test
May 12, 2026
b52a65e
style(chat): unify quick actions button with toolbar icon style
May 13, 2026
d9b1727
Merge branch 'topic/chat-actions' of https://github.com/Project-N-E-K…
May 13, 2026
a231cc3
Fix grouped command palette keyboard order
May 13, 2026
0b36633
Fix plugin UI quick action host resolution
May 13, 2026
d5495ea
Fix action provider error handling edge cases
May 13, 2026
4afbf0c
Merge branch 'main' into topic/chat-actions
wislap May 16, 2026
cbb1826
Merge branch 'main' into topic/chat-actions
wislap May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/main_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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) # 兜底路由需最后挂载

# 后台预加载任务
Expand Down
23 changes: 22 additions & 1 deletion frontend/react-neko-chat/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<App />);
Expand Down
148 changes: 146 additions & 2 deletions frontend/react-neko-chat/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<CommandItem | null>;
onQuickActionsRequest?: () => void;
onQuickActionsPreferencesChange?: (prefs: UserPreferences) => void;
// Generic ChoicePrompt(mini-game invite 等通用三选项框架)。
// galgame mode 现有路径继续走 galgameOptions / onGalgameOptionSelect(BC);
// 本框架先只承载 mini_game_invite,未来可把 galgame 也迁过来。
Expand Down Expand Up @@ -107,6 +114,45 @@ const hammerOverlayTransformOrigin = {
y: 118,
};

type NavigationLocation = Pick<Location, 'hostname'>;

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',
Expand Down Expand Up @@ -608,6 +654,12 @@ export default function App({
onTranslateToggle,
onGalgameModeToggle,
onGalgameOptionSelect,
quickActions,
quickActionsPreferences,
quickActionsLoading,
onQuickActionExecute,
onQuickActionsRequest,
onQuickActionsPreferencesChange,
choicePrompt = null,
onChoiceSelect,
rollbackDraft,
Expand All @@ -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 (··· 入场)
Expand Down Expand Up @@ -669,6 +723,7 @@ export default function App({
const [floatingHearts, setFloatingHearts] = useState<FloatingHeart[]>([]);
const [floatingFistDrops, setFloatingFistDrops] = useState<FloatingFistDrop[]>([]);
const submittingRef = useRef(false);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const lastRollbackKeyRef = useRef('');
const lastToolCursorResetKeyRef = useRef('');
const canSubmit = !composerDisabled && (draft.trim().length > 0 || composerAttachments.length > 0);
Expand Down Expand Up @@ -1516,6 +1571,8 @@ export default function App({
useEffect(() => {
if (composerHidden || composerDisabled) {
clearActiveCursorToolSelection();
setQuickActionsPanelOpen(false);
setQuickActionsSlashMode(false);
}
}, [clearActiveCursorToolSelection, composerHidden, composerDisabled]);

Expand All @@ -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<CommandItem | null> => {
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;
Expand All @@ -1545,8 +1636,32 @@ export default function App({
}
}

// 右侧 3 个工具按钮:在 compact 与 normal 两种布局中复用同一份 JSX,
// 右侧工具按钮:在 compact 与 normal 两种布局中复用同一份 JSX,
// 既避免重复,也保证 ref/事件绑定在两种模式下行为一致。
const quickActionsButtonNode = (
<button
className={`composer-tool-btn composer-quick-actions-btn${quickActionsPanelOpen ? ' is-active' : ''}`}
type="button"
aria-label={i18n('chat.quickActionsAriaLabel', '快捷操作')}
title={i18n('chat.quickActionsLabel', '快捷操作')}
aria-pressed={quickActionsPanelOpen}
aria-expanded={quickActionsPanelOpen}
disabled={composerDisabled}
onClick={() => {
if (composerDisabled) return;
const willOpen = !quickActionsPanelOpen;
setQuickActionsPanelOpen(willOpen);
setOverflowMenuOpen(false);
if (willOpen) {
setQuickActionsSlashMode(false);
onQuickActionsRequest?.();
}
}}
>
<span className="composer-quick-actions-glyph" aria-hidden="true">⌘</span>
</button>
);

const translateButtonNode = (
<button
className={`composer-tool-btn composer-translate-btn${translateEnabled ? ' is-active' : ''}`}
Expand Down Expand Up @@ -1849,20 +1964,46 @@ export default function App({
))}
</div>
) : null}
{quickActionsPanelOpen ? (
<CommandPalette
items={quickActions ?? []}
preferences={quickActionsPreferences ?? { pinned: [], hidden: [], recent: [] }}
loading={quickActionsLoading}
slashMode={quickActionsSlashMode}
onExecute={handleQuickActionExecute}
onInjectText={handleQuickActionInjectText}
onNavigate={handleQuickActionNavigate}
onPreferencesChange={handleQuickActionsPreferencesChange}
onClose={() => {
setQuickActionsPanelOpen(false);
setQuickActionsSlashMode(false);
}}
/>
) : null}
<form className="composer" onSubmit={(event) => {
event.preventDefault();
submitDraft();
}}>
<div className="composer-input-shell">
<textarea
ref={composerTextareaRef}
className="composer-input"
placeholder={inputPlaceholder}
aria-label={inputPlaceholder}
rows={1}
value={draft}
readOnly={composerDisabled}
disabled={composerDisabled}
onChange={(event) => { setDraft(event.target.value); }}
onChange={(event) => {
const nextDraft = event.target.value;
if (nextDraft === '/' && !draft && !quickActionsPanelOpen && !composerDisabled) {
setQuickActionsSlashMode(true);
setQuickActionsPanelOpen(true);
onQuickActionsRequest?.();
return;
}
setDraft(nextDraft);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === 'Enter' && !event.shiftKey) {
Expand Down Expand Up @@ -2011,6 +2152,8 @@ export default function App({
<span className="composer-tool-divider" aria-hidden="true">|</span>
{translateButtonNode}
<span className="composer-tool-divider" aria-hidden="true">|</span>
{quickActionsButtonNode}
<span className="composer-tool-divider" aria-hidden="true">|</span>
{jukeboxButtonNode}
<span className="composer-tool-divider" aria-hidden="true">|</span>
{emojiToolMenuNode}
Expand Down Expand Up @@ -2052,6 +2195,7 @@ export default function App({
>
{galgameToggleButtonNode}
{translateButtonNode}
{quickActionsButtonNode}
{jukeboxButtonNode}
{emojiToolMenuNode}
</div>
Expand Down
Loading
Loading