Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
118 changes: 107 additions & 11 deletions src/app/settings/tabs/model-settings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<ModelInfo[]>([]);
const [loaded, setLoaded] = useState(false);
const [showCreateModelDialog, setShowCreateModelDialog] = useState(false);
const [modelToDuplicate, setModelToDuplicate] = useState<ModelInfo | undefined>(undefined);
const [modelToModify, setModelToModify] = useState<string | undefined>(undefined);
const [modelToDelete, setModelToDelete] = useState<ModelInfo | undefined>(undefined);
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
const contextMenuRef = useRef<HTMLDivElement | null>(null);

const loadModels = useCallback(async () => {
setLoaded(false);
Expand All @@ -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 (
<div className="flex w-full h-48 items-center justify-center">
Expand All @@ -63,7 +102,10 @@ export function ModelSettings() {
<button
type="button"
className="group relative flex h-20 flex-col items-center justify-center rounded-lg border border-dashed border-border/70 bg-background/40 hover:border-primary/60 hover:bg-accent/40 transition-colors p-2"
onClick={() => setShowCreateModelDialog(true)}
onClick={() => {
setModelToDuplicate(undefined);
setShowCreateModelDialog(true);
}}
aria-label="新增模型"
>
<div className="text-3xl leading-none text-muted-foreground group-hover:text-primary">+</div>
Expand All @@ -73,15 +115,11 @@ export function ModelSettings() {
key={model.id}
className="relative flex h-20 overflow-hidden rounded-lg border bg-card/50 p-2 shadow-xs hover:shadow-sm transition-all"
onClick={() => setModelToModify(model.id)}
onContextMenu={event => {
event.preventDefault();
setContextMenu({ model, x: event.clientX, y: event.clientY });
}}
>
<button
type="button"
aria-label="删除模型"
className="absolute top-1 right-1 z-10 inline-flex h-5 w-5 items-center justify-center rounded-full bg-background/80 text-muted-foreground hover:text-foreground hover:bg-destructive/20 hover:border-destructive/50 border border-border/60 text-xs font-semibold transition-colors"
onClick={(e) => { e.stopPropagation(); setModelToDelete(model); }}
>
×
</button>
<div className="flex w-full items-center">
<div className="relative mr-3 aspect-square h-full max-h-20 shrink-0">
<Image
Expand All @@ -103,11 +141,69 @@ export function ModelSettings() {
</div>
))}
</div>
{contextMenu && (
<div
className="fixed inset-0 z-50"
onClick={() => setContextMenu(null)}
onContextMenu={event => {
event.preventDefault();
setContextMenu(null);
}}
>
<div
role="menu"
aria-label="模型操作"
className="absolute min-w-[170px] overflow-hidden rounded-md border border-border bg-background text-sm shadow-lg"
ref={contextMenuRef}
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={event => event.stopPropagation()}
onKeyDown={event => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('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();
}}
>
<button
type="button"
role="menuitem"
autoFocus
disabled={!AVAILABLE_PROVIDERS.some(provider => provider === contextMenu.model.providerName)}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:outline-none disabled:opacity-50"
onClick={() => {
setModelToDuplicate(contextMenu.model);
setContextMenu(null);
setShowCreateModelDialog(true);
}}
>
<Copy className="h-4 w-4" aria-hidden="true" />
复制
</button>
<button
type="button"
role="menuitem"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-destructive hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
onClick={() => {
setModelToDelete(contextMenu.model);
setContextMenu(null);
}}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
删除
</button>
</div>
</div>
)}
{
showCreateModelDialog && (
<CreateModelDialog
initialProvider={AVAILABLE_PROVIDERS.find(provider => provider === modelToDuplicate?.providerName)}
initialSettings={modelToDuplicate?.providerParams}
onComplete={() => {
setShowCreateModelDialog(false);
setModelToDuplicate(undefined);
loadModels().catch(console.error);
}}
/>
Expand Down
8 changes: 6 additions & 2 deletions src/app/settings/tabs/model/create-model-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderKey | undefined>(undefined);
export const CreateModelDialog = ({ onComplete, initialProvider, initialSettings }: CreateModelDialogProps) => {
const [selectedProvider, setSelectedProvider] = useState<ProviderKey | undefined>(initialProvider);
const [saving, setSaving] = useState(false);

const createModelAsync = useCallback(async (name: string, settings: unknown) => {
Expand Down Expand Up @@ -76,11 +78,13 @@ export const CreateModelDialog = ({ onComplete }: CreateModelDialogProps) => {
)}
{selectedProvider === 'AzureOpenAI' && (!saving) && (
<AzureOpenAIForm
initialSettings={initialSettings}
onSubmit={createModelAsync}
/>
)}
{selectedProvider === 'OpenAI' && (!saving) && (
<OpenAIForm
initialSettings={initialSettings}
onSubmit={createModelAsync}
/>
)}
Expand Down
2 changes: 2 additions & 0 deletions test/auto/app/chat/side.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ function renderSide(override: Partial<React.ComponentProps<typeof Side>> = {}) {
onSwitchChat: jest.fn(),
requestChatListUpdateAsync: async () => {},
onChatDisplayRangeChange: jest.fn(),
onSetChatTitle: jest.fn(),
onDeleteChat: jest.fn(),
chatList: makeChats(3),
activeChatId: undefined,
onHideSidebar: jest.fn(),
Expand Down
Loading
Loading