refactor(canvas): 重构画布助手面板组件结构
- 移除 Segmented 组件并替换为自定义模式切换按钮 - 替换 ModelPicker 为 ComposerModelPill 组件 - 添加下拉菜单实现模型选择功能 - 优化 CSS 样式类和响应式布局 - 更新图标引入和工具栏组件结构 - 简化提示词库按钮样式配置
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { Button, ConfigProvider, Modal, Segmented, Tooltip } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowUp, ChevronDown, Cpu, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { Button, Dropdown, Modal, Tooltip } from "antd";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { ImageGenerationPending } from "@/components/image-generation-pending";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -417,41 +416,16 @@ function AssistantComposer({
|
||||
placeholder={mode === "image" ? "描述你想生成或修改的图片" : "输入你想问的问题"}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div className="canvas-composer-tools flex min-w-0 flex-1 items-center gap-1">
|
||||
<CanvasPromptLibrary onSelect={onPromptChange} />
|
||||
<CanvasThemeProvider theme={theme}>
|
||||
<Segmented
|
||||
size="small"
|
||||
className="canvas-composer-mode !rounded-full !p-0.5"
|
||||
value={mode}
|
||||
onChange={(value) => onModeChange(value as AssistantMode)}
|
||||
options={[
|
||||
{
|
||||
value: "ask",
|
||||
label: (
|
||||
<Tooltip title="文本">
|
||||
<MessageSquare className="size-4" />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "image",
|
||||
label: (
|
||||
<Tooltip title="生图">
|
||||
<ImageIcon className="size-4" />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CanvasThemeProvider>
|
||||
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
|
||||
<CanvasImageSettingsPopover config={config} placement="topRight" getPopupContainer={() => document.body} onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
|
||||
<ComposerModelPill config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
|
||||
<CanvasImageSettingsPopover config={config} placement="topRight" getPopupContainer={() => document.body} buttonClassName="canvas-composer-settings canvas-composer-icon !h-8 !min-w-8 !rounded-full !px-2" onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
||||
<ComposerModelPill config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -469,21 +443,66 @@ function AssistantComposer({
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasThemeProvider({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
|
||||
function ComposerModelPill({ config, value, onChange, onMissingConfig }: { config: AiConfig; value: string; onChange: (model: string) => void; onMissingConfig: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const options = Array.from(new Set([value, ...config.models].filter(Boolean)));
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel },
|
||||
components: {
|
||||
Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text },
|
||||
Input: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke },
|
||||
InputNumber: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke },
|
||||
Segmented: { itemColor: theme.node.text, itemHoverBg: theme.node.fill, itemSelectedBg: theme.node.activeStroke, itemSelectedColor: theme.node.panel, trackBg: theme.toolbar.panel, trackPadding: 2 },
|
||||
},
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
overlayClassName="canvas-model-dropdown"
|
||||
menu={{
|
||||
items: options.map((model) => ({ key: model, label: <ModelMenuLabel model={model} /> })),
|
||||
onClick: ({ key }) => onChange(String(key)),
|
||||
selectable: true,
|
||||
selectedKeys: value ? [value] : [],
|
||||
}}
|
||||
onOpenChange={(open) => open && !options.length && onMissingConfig()}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
<button type="button" className="canvas-composer-model-pill" style={{ background: theme.node.fill, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<ModelIcon model={value} />
|
||||
<span className="canvas-composer-model-text truncate">{value || "模型"}</span>
|
||||
<ChevronDown className="canvas-composer-model-arrow size-3.5 opacity-55" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelMenuLabel({ model }: { model: string }) {
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ModelIcon model={model} />
|
||||
<span className="truncate">{model}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelIcon({ model }: { model: string }) {
|
||||
const name = model.toLowerCase();
|
||||
const icon = name.includes("claude") || name.includes("anthropic") ? "/icons/claude.svg" : name.includes("gemini") || name.includes("google") ? "/icons/gemini.svg" : name.includes("gpt") || name.includes("openai") ? "/icons/openai.svg" : "";
|
||||
return icon ? <img src={icon} alt="" className="size-4 shrink-0" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
|
||||
}
|
||||
|
||||
function AssistantModeSwitch({ mode, theme, onChange }: { mode: AssistantMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (mode: AssistantMode) => void }) {
|
||||
return (
|
||||
<div className="canvas-composer-mode-switch flex h-8 shrink-0 items-center rounded-full p-0.5" style={{ background: theme.node.fill }}>
|
||||
{[
|
||||
{ value: "ask" as const, title: "对话", icon: <MessageSquare className="size-4" /> },
|
||||
{ value: "image" as const, title: "生图", icon: <ImageIcon className="size-4" /> },
|
||||
].map((item) => (
|
||||
<Tooltip key={item.value} title={item.title}>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-composer-mode-button flex h-7 cursor-pointer items-center justify-center gap-1 rounded-full border-0 bg-transparent transition"
|
||||
style={{ background: mode === item.value ? theme.node.activeStroke : "transparent", color: mode === item.value ? theme.node.panel : theme.node.text }}
|
||||
onClick={() => onChange(item.value)}
|
||||
aria-label={item.title}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) =
|
||||
<>
|
||||
<Tooltip title="提示词库">
|
||||
<Button
|
||||
shape="circle"
|
||||
className="!h-8 !w-8 !min-w-8 shrink-0 !bg-transparent"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
type="text"
|
||||
className="!h-8 !w-8 !min-w-8 shrink-0 !rounded-full !bg-transparent !p-0"
|
||||
style={{ color: theme.node.text }}
|
||||
icon={<BookOpen className="size-3.5" />}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="提示词库"
|
||||
|
||||
+132
-8
@@ -319,6 +319,138 @@
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.canvas-composer-tools {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.canvas-composer-icon.ant-btn,
|
||||
.canvas-composer-model-pill {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.canvas-composer-mode-button {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.canvas-composer-model-text,
|
||||
.canvas-composer-model-arrow,
|
||||
.canvas-composer-mode-button span,
|
||||
.canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.canvas-composer-model-pill {
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
padding-inline: 8px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.canvas-composer-model-pill:hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.canvas-composer-mode-button {
|
||||
padding-inline: 8px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.canvas-composer-mode-button span,
|
||||
.canvas-composer-model-text,
|
||||
.canvas-composer-model-arrow {
|
||||
display: inline;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.canvas-composer-model-pill {
|
||||
flex-basis: 180px;
|
||||
max-width: 180px;
|
||||
width: 180px !important;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.canvas-composer-icon.ant-btn {
|
||||
flex: 0 1 auto;
|
||||
max-width: 170px;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 560px) {
|
||||
.canvas-composer-model-pill {
|
||||
flex-basis: 240px;
|
||||
max-width: 240px;
|
||||
width: 240px !important;
|
||||
}
|
||||
|
||||
.canvas-composer-icon.ant-btn {
|
||||
flex: 0 1 auto;
|
||||
max-width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-model-dropdown .ant-dropdown-menu {
|
||||
border: 1px solid rgba(120, 113, 108, 0.18);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 42px rgba(28, 25, 23, 0.18);
|
||||
min-width: 220px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.canvas-model-dropdown .ant-dropdown-menu-item {
|
||||
border-radius: 8px;
|
||||
color: #292524;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
margin: 1px 0;
|
||||
min-height: 32px;
|
||||
padding: 7px 10px !important;
|
||||
}
|
||||
|
||||
.canvas-model-dropdown .ant-dropdown-menu-item:hover {
|
||||
background: rgba(28, 25, 23, 0.06) !important;
|
||||
}
|
||||
|
||||
.canvas-model-dropdown .ant-dropdown-menu-item-selected,
|
||||
.canvas-model-dropdown .ant-dropdown-menu-item-selected:hover {
|
||||
background: rgba(28, 25, 23, 0.1) !important;
|
||||
color: #1c1917 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .canvas-model-dropdown .ant-dropdown-menu {
|
||||
background: #1f1d1a;
|
||||
border-color: rgba(214, 211, 209, 0.14);
|
||||
}
|
||||
|
||||
.dark .canvas-model-dropdown .ant-dropdown-menu-item {
|
||||
color: #f5f5f4;
|
||||
}
|
||||
|
||||
.dark .canvas-model-dropdown .ant-dropdown-menu-item:hover {
|
||||
background: rgba(245, 245, 244, 0.08) !important;
|
||||
}
|
||||
|
||||
.dark .canvas-model-dropdown .ant-dropdown-menu-item-selected,
|
||||
.dark .canvas-model-dropdown .ant-dropdown-menu-item-selected:hover {
|
||||
background: rgba(245, 245, 244, 0.14) !important;
|
||||
color: #fafaf9 !important;
|
||||
}
|
||||
|
||||
.canvas-control-select.ant-select-focused .ant-select-selector,
|
||||
.canvas-control-number.ant-input-number-focused {
|
||||
border-color: #a8a29e !important;
|
||||
@@ -334,14 +466,6 @@
|
||||
border-color: #78716c !important;
|
||||
}
|
||||
|
||||
.canvas-composer-mode.ant-segmented .ant-segmented-item-label {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
|
||||
.canvas-image-settings-popover .ant-popover-inner {
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 54px rgba(28, 25, 23, 0.16);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Select } from "antd";
|
||||
import { Cpu } from "lucide-react";
|
||||
|
||||
import styles from "./model-picker.module.css";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
@@ -53,9 +54,9 @@ export function ModelPicker({ config, value, onChange, className, fullWidth = fa
|
||||
function ModelLabel({ model }: { model: string }) {
|
||||
const icon = resolveModelIcon(model);
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{icon && <img src={icon} alt="" className="size-4 shrink-0" />}
|
||||
<span className="truncate">{model}</span>
|
||||
<span className="model-picker-label flex min-w-0 items-center gap-2">
|
||||
{icon ? <img src={icon} alt="" className="size-4 shrink-0" /> : <Cpu className="size-4 shrink-0 opacity-70" />}
|
||||
<span className="model-picker-label-text truncate">{model}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user