diff --git a/src/app/settings/tabs/model-settings.tsx b/src/app/settings/tabs/model-settings.tsx index 7608f8d..60625bb 100644 --- a/src/app/settings/tabs/model-settings.tsx +++ b/src/app/settings/tabs/model-settings.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useLayoutEffect, useRef } from "react"; +import { Copy, Trash2 } from "lucide-react"; import { TUIClientSingleton } from "@/lib/tui-client-singleton"; import Image from "next/image"; -import { getProviderDisplayName, getProviderIcon } from "./model/provider-registry"; +import { AVAILABLE_PROVIDERS, getProviderDisplayName, getProviderIcon } from "./model/provider-registry"; import { CreateModelDialog } from "./model/create-model-dialog"; import { ModifyModelDialog } from "./model/modify-model-dialog"; import { DeleteModelDialog } from "./model/delete-model-dialog"; @@ -12,15 +13,24 @@ type ModelInfo = { id: string; name: string; providerName: string; + providerParams: unknown; }; +type ContextMenuState = { + model: ModelInfo; + x: number; + y: number; +} | null; export function ModelSettings() { const [models, setModels] = useState([]); const [loaded, setLoaded] = useState(false); const [showCreateModelDialog, setShowCreateModelDialog] = useState(false); + const [modelToDuplicate, setModelToDuplicate] = useState(undefined); const [modelToModify, setModelToModify] = useState(undefined); const [modelToDelete, setModelToDelete] = useState(undefined); + const [contextMenu, setContextMenu] = useState(null); + const contextMenuRef = useRef(null); const loadModels = useCallback(async () => { setLoaded(false); @@ -46,6 +56,35 @@ export function ModelSettings() { loadModels().catch(console.error); }, [loadModels]); + useEffect(() => { + if (!contextMenu) return; + const closeContextMenu = () => setContextMenu(null); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" || event.key === "Tab") { + closeContextMenu(); + } + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("scroll", closeContextMenu, true); + window.addEventListener("resize", closeContextMenu); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("scroll", closeContextMenu, true); + window.removeEventListener("resize", closeContextMenu); + }; + }, [contextMenu]); + + useLayoutEffect(() => { + if (!contextMenu || !contextMenuRef.current) return; + const rect = contextMenuRef.current.getBoundingClientRect(); + const padding = 8; + const clampedX = Math.max(padding, Math.min(contextMenu.x, window.innerWidth - rect.width - padding)); + const clampedY = Math.max(padding, Math.min(contextMenu.y, window.innerHeight - rect.height - padding)); + if (clampedX !== contextMenu.x || clampedY !== contextMenu.y) { + setContextMenu({ ...contextMenu, x: clampedX, y: clampedY }); + } + }, [contextMenu]); + if (!loaded) { return (
@@ -63,7 +102,10 @@ export function ModelSettings() {
))}
+ {contextMenu && ( +
setContextMenu(null)} + onContextMenu={event => { + event.preventDefault(); + setContextMenu(null); + }} + > +
event.stopPropagation()} + onKeyDown={event => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + const items = Array.from(event.currentTarget.querySelectorAll('button[role="menuitem"]:not(:disabled)')); + const activeIndex = items.findIndex(item => item === event.target); + const direction = event.key === "ArrowDown" ? 1 : -1; + items[(activeIndex + direction + items.length) % items.length]?.focus(); + }} + > + + +
+
+ )} { showCreateModelDialog && ( provider === modelToDuplicate?.providerName)} + initialSettings={modelToDuplicate?.providerParams} onComplete={() => { setShowCreateModelDialog(false); + setModelToDuplicate(undefined); loadModels().catch(console.error); }} /> diff --git a/src/app/settings/tabs/model/create-model-dialog.tsx b/src/app/settings/tabs/model/create-model-dialog.tsx index 598462f..70f529d 100644 --- a/src/app/settings/tabs/model/create-model-dialog.tsx +++ b/src/app/settings/tabs/model/create-model-dialog.tsx @@ -10,10 +10,12 @@ import { TUIClientSingleton } from '@/lib/tui-client-singleton'; export interface CreateModelDialogProps { onComplete: () => void; // UI-only for now + initialProvider?: ProviderKey; + initialSettings?: unknown; } -export const CreateModelDialog = ({ onComplete }: CreateModelDialogProps) => { - const [selectedProvider, setSelectedProvider] = useState(undefined); +export const CreateModelDialog = ({ onComplete, initialProvider, initialSettings }: CreateModelDialogProps) => { + const [selectedProvider, setSelectedProvider] = useState(initialProvider); const [saving, setSaving] = useState(false); const createModelAsync = useCallback(async (name: string, settings: unknown) => { @@ -76,11 +78,13 @@ export const CreateModelDialog = ({ onComplete }: CreateModelDialogProps) => { )} {selectedProvider === 'AzureOpenAI' && (!saving) && ( )} {selectedProvider === 'OpenAI' && (!saving) && ( )} diff --git a/test/auto/app/chat/side.test.tsx b/test/auto/app/chat/side.test.tsx index 1b76f55..816453e 100644 --- a/test/auto/app/chat/side.test.tsx +++ b/test/auto/app/chat/side.test.tsx @@ -16,6 +16,8 @@ function renderSide(override: Partial> = {}) { onSwitchChat: jest.fn(), requestChatListUpdateAsync: async () => {}, onChatDisplayRangeChange: jest.fn(), + onSetChatTitle: jest.fn(), + onDeleteChat: jest.fn(), chatList: makeChats(3), activeChatId: undefined, onHideSidebar: jest.fn(), diff --git a/test/auto/app/settings/model-settings.test.tsx b/test/auto/app/settings/model-settings.test.tsx new file mode 100644 index 0000000..c9741a9 --- /dev/null +++ b/test/auto/app/settings/model-settings.test.tsx @@ -0,0 +1,266 @@ +/// +import React from 'react'; +import { jest } from '@jest/globals'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const client = { + getModelListAsync: jest.fn<() => Promise<{ id: string; metadata: { name: string } }[]>>(), + getModelAsync: jest.fn<(modelId: string) => Promise<{ providerName: string; providerParams: unknown }>>(), + getMetadataAsync: jest.fn<(params: unknown) => Promise>>(), + newModelAsync: jest.fn<(settings: unknown) => Promise>(), + modifyModelAsync: jest.fn<(params: unknown) => Promise>(), + deleteModelAsync: jest.fn<(modelId: string) => Promise>(), + setMetadataAsync: jest.fn<(params: unknown) => Promise>(), +}; + +jest.unstable_mockModule('@/lib/tui-client-singleton', () => ({ + TUIClientSingleton: { get: () => client }, +})); + +jest.unstable_mockModule('next/image', () => ({ + default: ({ alt }: { alt: string }) => , +})); + +const { CreateModelDialog } = await import('@/app/settings/tabs/model/create-model-dialog'); +const { ModelSettings } = await import('@/app/settings/tabs/model-settings'); + +const sourceModels = [ + { + id: 'source-openai', + name: 'Existing OpenAI', + providerName: 'OpenAI' as const, + providerParams: { + url: 'https://openai.example.com/v1', + apiKey: 'openai-test-key', + model: 'gpt-4o', + temperature: 0, + reasoningEffort: 'high', + }, + urlLabel: 'Base URL', + }, + { + id: 'source-azure', + name: 'Existing Azure OpenAI', + providerName: 'AzureOpenAI' as const, + providerParams: { + url: 'https://azure.example.com/openai/deployments/test/chat/completions?api-version=2025-01-01', + apiKey: 'azure-test-key', + model: 'azure-deployment', + temperature: 0.5, + }, + urlLabel: 'Full URL', + }, +]; + +beforeEach(() => { + jest.clearAllMocks(); + client.getModelListAsync.mockResolvedValue(sourceModels.map(source => ({ + id: source.id, + metadata: { name: source.name }, + }))); + client.getModelAsync.mockImplementation(async modelId => { + const source = sourceModels.find(model => model.id === modelId); + if (!source) throw new Error(`Unexpected model: ${modelId}`); + return { providerName: source.providerName, providerParams: source.providerParams }; + }); + client.getMetadataAsync.mockResolvedValue({ name: sourceModels[0].name }); + client.newModelAsync.mockResolvedValue('new-model'); + client.deleteModelAsync.mockResolvedValue(undefined); + client.setMetadataAsync.mockResolvedValue(undefined); +}); + +describe('Model creation presets', () => { + test.each(sourceModels)('prefills $providerName settings but requires a new name', async (source) => { + const onComplete = jest.fn(); + render( + + ); + + expect(screen.getByLabelText('模型名称')).toHaveValue(''); + expect(screen.getByLabelText(source.urlLabel)).toHaveValue(source.providerParams.url); + expect(screen.getByLabelText('API Key')).toHaveValue(source.providerParams.apiKey); + expect(screen.getByDisplayValue(source.providerParams.model)).toBeInTheDocument(); + expect(screen.getByRole('spinbutton')).toHaveValue(source.providerParams.temperature); + if (source.providerName === 'OpenAI') { + expect(screen.getByRole('combobox')).toHaveTextContent('high'); + } + expect(screen.getByRole('button', { name: '确认' })).toBeDisabled(); + expect(client.newModelAsync).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('模型名称'), { target: { value: 'My copied model' } }); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(client.newModelAsync).toHaveBeenCalledWith({ + providerName: source.providerName, + providerParams: source.providerParams, + }); + expect(client.setMetadataAsync).toHaveBeenCalledWith({ + path: ['model', 'new-model'], + entries: { name: 'My copied model' }, + }); + }); + + test('still starts with provider selection and empty fields when no preset is supplied', () => { + render(); + + expect(screen.getByText('选择模型提供商')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^OpenAI/ })); + + expect(screen.getByLabelText('模型名称')).toHaveValue(''); + expect(screen.getByLabelText('Base URL')).toHaveValue(''); + expect(screen.getByLabelText('API Key')).toHaveValue(''); + expect(screen.getByLabelText('Model id')).toHaveValue(''); + expect(screen.getByRole('spinbutton')).toHaveValue(null); + expect(screen.getByRole('button', { name: '确认' })).toBeDisabled(); + }); +}); + +describe('Model context menu', () => { + test.each(sourceModels)('duplicates $providerName through the creation dialog without modifying the source', async (source) => { + const originalSettings = { ...source.providerParams }; + render(); + + fireEvent.contextMenu(await screen.findByText(source.name), { clientX: 100, clientY: 120 }); + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.queryByLabelText('模型名称')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.queryByText('修改模型配置')).not.toBeInTheDocument(); + expect(screen.getByLabelText('模型名称')).toHaveValue(''); + expect(screen.getByLabelText(source.urlLabel)).toHaveValue(source.providerParams.url); + expect(screen.getByLabelText('API Key')).toHaveValue(source.providerParams.apiKey); + expect(screen.getByDisplayValue(source.providerParams.model)).toBeInTheDocument(); + expect(screen.getByRole('spinbutton')).toHaveValue(source.providerParams.temperature); + expect(screen.getByRole('button', { name: '确认' })).toBeDisabled(); + expect(client.newModelAsync).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('模型名称'), { target: { value: 'New model' } }); + fireEvent.change(screen.getByLabelText(source.urlLabel), { target: { value: 'https://copy.example.com' } }); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await screen.findByText(source.name); + expect(client.newModelAsync).toHaveBeenCalledTimes(1); + expect(client.newModelAsync).toHaveBeenCalledWith({ + providerName: source.providerName, + providerParams: { ...originalSettings, url: 'https://copy.example.com' }, + }); + expect(client.setMetadataAsync).toHaveBeenCalledWith({ + path: ['model', 'new-model'], + entries: { name: 'New model' }, + }); + expect(client.modifyModelAsync).not.toHaveBeenCalled(); + expect(source.providerParams).toEqual(originalSettings); + expect(client.getModelListAsync).toHaveBeenCalledTimes(2); + }); + + test('cancelling a duplicate leaves subsequent blank creation empty', async () => { + render(); + fireEvent.contextMenu(await screen.findByText(sourceModels[0].name)); + fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); + fireEvent.change(screen.getByLabelText('模型名称'), { target: { value: 'Discarded copy' } }); + fireEvent.click(screen.getByRole('button', { name: '' })); + + fireEvent.click(await screen.findByRole('button', { name: '新增模型' })); + expect(screen.getByText('选择模型提供商')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^OpenAI/ })); + + expect(screen.getByLabelText('模型名称')).toHaveValue(''); + expect(screen.getByLabelText('Base URL')).toHaveValue(''); + expect(screen.getByLabelText('API Key')).toHaveValue(''); + expect(screen.getByLabelText('Model id')).toHaveValue(''); + expect(screen.getByRole('spinbutton')).toHaveValue(null); + expect(client.newModelAsync).not.toHaveBeenCalled(); + expect(client.setMetadataAsync).not.toHaveBeenCalled(); + }); + + test.each(['Escape', 'Tab', 'scroll', 'resize', 'outside click'])('dismisses the menu on %s', async (action) => { + render(); + fireEvent.contextMenu(await screen.findByText(sourceModels[0].name)); + const menu = screen.getByRole('menu'); + expect(screen.getByRole('menuitem', { name: '复制' })).toHaveFocus(); + + if (action === 'scroll') { + fireEvent.scroll(window); + } else if (action === 'resize') { + fireEvent.resize(window); + } else if (action === 'outside click') { + fireEvent.click(menu.parentElement!); + } else { + fireEvent.keyDown(menu, { key: action }); + } + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('模型名称')).not.toBeInTheDocument(); + expect(client.newModelAsync).not.toHaveBeenCalled(); + }); + + test('arrow keys navigate between duplicate and delete', async () => { + render(); + fireEvent.contextMenu(await screen.findByText(sourceModels[0].name)); + const duplicateItem = screen.getByRole('menuitem', { name: '复制' }); + const deleteItem = screen.getByRole('menuitem', { name: '删除' }); + + expect(duplicateItem).toHaveFocus(); + fireEvent.keyDown(duplicateItem, { key: 'ArrowDown' }); + expect(deleteItem).toHaveFocus(); + fireEvent.keyDown(deleteItem, { key: 'ArrowUp' }); + expect(duplicateItem).toHaveFocus(); + expect(client.deleteModelAsync).not.toHaveBeenCalled(); + }); + + test('left-click still edits the existing model', async () => { + render(); + fireEvent.click(await screen.findByText(sourceModels[0].name)); + + expect(screen.getByText('修改模型配置')).toBeInTheDocument(); + expect(await screen.findByLabelText('模型名称')).toHaveValue(sourceModels[0].name); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(client.newModelAsync).not.toHaveBeenCalled(); + }); + + test.each(sourceModels)('deletes the selected $providerName model only after confirmation', async (source) => { + render(); + const model = await screen.findByText(source.name); + expect(screen.queryByRole('button', { name: '删除模型' })).not.toBeInTheDocument(); + + fireEvent.contextMenu(model); + fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.queryByText('修改模型配置')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '删除模型' })).toBeInTheDocument(); + expect(screen.getByText(`“${source.name}”`)).toBeInTheDocument(); + expect(client.deleteModelAsync).not.toHaveBeenCalled(); + + client.getModelListAsync.mockResolvedValueOnce(sourceModels + .filter(model => model.id !== source.id) + .map(model => ({ id: model.id, metadata: { name: model.name } }))); + fireEvent.click(screen.getByRole('button', { name: '删除' })); + + await waitFor(() => expect(client.getModelListAsync).toHaveBeenCalledTimes(2)); + await screen.findByRole('button', { name: '新增模型' }); + expect(client.deleteModelAsync).toHaveBeenCalledTimes(1); + expect(client.deleteModelAsync).toHaveBeenCalledWith(source.id); + expect(screen.queryByRole('heading', { name: '删除模型' })).not.toBeInTheDocument(); + expect(screen.queryByText(source.name)).not.toBeInTheDocument(); + }); + + test('cancelling deletion keeps the model without calling delete', async () => { + render(); + fireEvent.contextMenu(await screen.findByText(sourceModels[0].name)); + fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + await waitFor(() => expect(client.getModelListAsync).toHaveBeenCalledTimes(2)); + expect(await screen.findByText(sourceModels[0].name)).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '删除模型' })).not.toBeInTheDocument(); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(client.deleteModelAsync).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file