refactor(canvas): 优化模型选择器和定时同步配置
- 将模型选择器替换为统一的 shadcn 风格下拉组件 - 移除原生数字输入框的步进箭头样式 - 更新默认定时同步配置为每5分钟执行 - 修复画布助手和节点配置中的模型选择逻辑 - 统一各页面中的模型选择器样式和交互行为
This commit is contained in:
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## v0.0.7 - 2026-05-23
|
||||||
|
|
||||||
+ [新增] 管理后台提示词管理支持多选批量删除。
|
+ [新增] 管理后台提示词管理支持多选批量删除。
|
||||||
+ [新增] 新增定义拉取GitHub提示词源功能。
|
+ [新增] 新增定义拉取GitHub提示词源功能。
|
||||||
+ [新增] 新增awesome-gpt-image2-prompts提示词来源。
|
+ [新增] 新增awesome-gpt-image2-prompts提示词来源。
|
||||||
|
+ [优化] 优化模型下拉选择样式、优化生图编辑设置
|
||||||
|
|
||||||
## v0.0.6 - 2026-05-22
|
## v0.0.6 - 2026-05-22
|
||||||
|
|
||||||
|
|||||||
@@ -148,8 +148,8 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `enabled` | bool | 是否开启定时同步 |
|
| `enabled` | bool | 是否开启定时同步,默认开启 |
|
||||||
| `cron` | string | Cron 表达式,默认每天 03:00 |
|
| `cron` | string | Cron 表达式,默认每 5 分钟 |
|
||||||
|
|
||||||
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
|
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
- 模型选择器会按模型名称模糊匹配并显示 OpenAI、Claude、Gemini 图标,且下拉滚动条改为更窄更轻的样式,需要确认生图页、画布助手、节点配置和配置弹窗中的模型下拉项及选中态显示正常,搜索仍可按模型名称过滤。
|
- 模型选择器改为共享的 shadcn 风格下拉,按模型名称显示 OpenAI、Claude、Gemini 图标,并把画布节点下方、右侧助手和配置弹窗统一到同一套组件;右侧助手的模型和模式按钮会随工具栏宽度自动在文字态和图标态之间切换,需要确认生图页、画布助手、节点配置和配置弹窗中的选中态和下拉列表显示正常。
|
||||||
|
- 画布图像设置里的尺寸输入去掉了原生数字步进箭头,需要确认宽高仍可直接输入、禁用态正常、设置值回写正常。
|
||||||
|
- 后台提示词定时同步默认改为开启且默认每 5 分钟执行一次,需要确认新环境未保存过设置时开关默认打开、Cron 默认值正确,用户手动关闭或修改 Cron 后能保持配置。
|
||||||
- 画布右侧助手和节点下方对话框的生图设置弹层改为紧凑图像设置面板,面板只包含质量、尺寸、宽高比和生成数量选择,模型选择保留在对话框底部工具栏;需要确认助手内设置面板不会被右侧面板裁切,节点内质量可正常选择并参与生成、尺寸可编辑、宽高比预览显示正常且为直角矩形、auto 不重复显示、张数每行 4 个且自定义输入框能显示当前值、实际生图参数正常;节点设置面板打开时会隐藏节点悬浮工具条,避免遮挡质量选择。
|
- 画布右侧助手和节点下方对话框的生图设置弹层改为紧凑图像设置面板,面板只包含质量、尺寸、宽高比和生成数量选择,模型选择保留在对话框底部工具栏;需要确认助手内设置面板不会被右侧面板裁切,节点内质量可正常选择并参与生成、尺寸可编辑、宽高比预览显示正常且为直角矩形、auto 不重复显示、张数每行 4 个且自定义输入框能显示当前值、实际生图参数正常;节点设置面板打开时会隐藏节点悬浮工具条,避免遮挡质量选择。
|
||||||
- 管理后台 `/admin/prompts` 新增提示词批量删除,需要确认多选、确认弹窗、删除后列表刷新和筛选条件下删除行为。
|
- 管理后台 `/admin/prompts` 新增提示词批量删除,需要确认多选、确认弹窗、删除后列表刷新和筛选条件下删除行为。
|
||||||
- 管理后台 `/admin/settings` 的私有配置新增提示词定时同步开关和 Cron 表达式;开启后后端会按配置同步内置 GitHub 远程提示词源,需要确认保存配置和到点同步行为。
|
- 管理后台 `/admin/settings` 的私有配置新增提示词定时同步开关和 Cron 表达式;开启后后端会按配置同步内置 GitHub 远程提示词源,需要确认保存配置和到点同步行为。
|
||||||
|
|||||||
@@ -61,8 +61,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"promptSync": {
|
"promptSync": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"cron": "0 3 * * *"
|
"cron": "*/5 * * * *"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -91,5 +91,5 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `enabled` | boolean | 是否开启定时同步 |
|
| `enabled` | boolean | 是否开启定时同步,默认开启 |
|
||||||
| `cron` | string | Cron 表达式,默认每天 03:00 |
|
| `cron` | string | Cron 表达式,默认每 5 分钟 |
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ type PrivateSetting struct {
|
|||||||
|
|
||||||
// PromptSyncSetting 提示词定时同步配置。
|
// PromptSyncSetting 提示词定时同步配置。
|
||||||
type PromptSyncSetting struct {
|
type PromptSyncSetting struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Cron string `json:"cron"`
|
Cron string `json:"cron"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultPromptSyncCron = "0 3 * * *"
|
const defaultPromptSyncCron = "*/5 * * * *"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
promptSyncCron *cron.Cron
|
promptSyncCron *cron.Cron
|
||||||
@@ -40,7 +40,7 @@ func RefreshPromptSyncScheduler() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setting := normalizePromptSyncSetting(settings.Private.PromptSync)
|
setting := normalizePromptSyncSetting(settings.Private.PromptSync)
|
||||||
if !setting.Enabled {
|
if setting.Enabled == nil || !*setting.Enabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := promptSyncCron.AddFunc(setting.Cron, SyncRemotePromptCategories); err != nil {
|
if _, err := promptSyncCron.AddFunc(setting.Cron, SyncRemotePromptCategories); err != nil {
|
||||||
@@ -66,5 +66,9 @@ func normalizePromptSyncSetting(setting model.PromptSyncSetting) model.PromptSyn
|
|||||||
if setting.Cron == "" {
|
if setting.Cron == "" {
|
||||||
setting.Cron = defaultPromptSyncCron
|
setting.Cron = defaultPromptSyncCron
|
||||||
}
|
}
|
||||||
|
if setting.Enabled == nil {
|
||||||
|
enabled := true
|
||||||
|
setting.Enabled = &enabled
|
||||||
|
}
|
||||||
return setting
|
return setting
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const emptySettings: AdminSettings = {
|
|||||||
allowCustomChannel: true,
|
allowCustomChannel: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
private: { channels: [], promptSync: { enabled: false, cron: "0 3 * * *" } },
|
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" } },
|
||||||
};
|
};
|
||||||
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
|
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
|
||||||
|
|
||||||
@@ -367,8 +367,8 @@ export default function AdminSettingsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={16}>
|
<Col xs={24} md={16}>
|
||||||
<Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每天 03:00 同步内置 GitHub 远程提示词源">
|
<Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每 5 分钟同步内置 GitHub 远程提示词源">
|
||||||
<Input placeholder="0 3 * * *" />
|
<Input placeholder="*/5 * * * *" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -601,8 +601,8 @@ function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}
|
|||||||
return {
|
return {
|
||||||
channels: (setting.channels || []).map(normalizeChannel),
|
channels: (setting.channels || []).map(normalizeChannel),
|
||||||
promptSync: {
|
promptSync: {
|
||||||
enabled: setting.promptSync?.enabled === true,
|
enabled: setting.promptSync?.enabled !== false,
|
||||||
cron: setting.promptSync?.cron || "0 3 * * *",
|
cron: setting.promptSync?.cron || "*/5 * * * *",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
|
||||||
import { Button, Dropdown, Modal, Tooltip } from "antd";
|
import { Button, Modal, Tooltip } from "antd";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
import { ImageGenerationPending } from "@/components/image-generation-pending";
|
import { ImageGenerationPending } from "@/components/image-generation-pending";
|
||||||
|
import { ModelPicker } from "@/components/model-picker";
|
||||||
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -421,11 +422,11 @@ function AssistantComposer({
|
|||||||
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
|
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
|
||||||
{mode === "image" ? (
|
{mode === "image" ? (
|
||||||
<>
|
<>
|
||||||
<ComposerModelPill config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
|
<ModelPicker className="h-8 shrink-0" 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} />
|
<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} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ComposerModelPill config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
<ModelPicker className="h-8 shrink-0" config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -443,45 +444,6 @@ function AssistantComposer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
|
||||||
<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()}
|
|
||||||
>
|
|
||||||
<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 }) {
|
function AssistantModeSwitch({ mode, theme, onChange }: { mode: AssistantMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (mode: AssistantMode) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="canvas-composer-mode-switch flex h-8 shrink-0 items-center rounded-full p-0.5" style={{ background: theme.node.fill }}>
|
<div className="canvas-composer-mode-switch flex h-8 shrink-0 items-center rounded-full p-0.5" style={{ background: theme.node.fill }}>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<SettingTitle color={theme.node.muted}>生成数量</SettingTitle>
|
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => (
|
{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => (
|
||||||
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
|
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
|
||||||
@@ -165,7 +165,7 @@ function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix:
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="min-w-0 flex-1 bg-transparent px-2 outline-none"
|
className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(event) => onChange(Number(event.target.value) || null)}
|
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
@@ -181,7 +181,7 @@ function CountInput({ value, theme, onChange }: { value: number; theme: (typeof
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={15}
|
max={15}
|
||||||
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none"
|
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }}
|
style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }}
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(event) => onChange(Number(event.target.value) || null)}
|
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||||
|
|||||||
+23
-90
@@ -319,65 +319,46 @@
|
|||||||
line-height: 1 !important;
|
line-height: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-composer-tools {
|
.canvas-composer-icon.ant-btn {
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-composer-icon.ant-btn,
|
|
||||||
.canvas-composer-model-pill {
|
|
||||||
flex: 0 0 32px;
|
flex: 0 0 32px;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvas-composer-tools {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
.canvas-composer-mode-button {
|
.canvas-composer-mode-button {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-composer-model-text,
|
|
||||||
.canvas-composer-model-arrow,
|
|
||||||
.canvas-composer-mode-button span,
|
.canvas-composer-mode-button span,
|
||||||
|
.canvas-model-picker-text,
|
||||||
|
.canvas-select-chevron,
|
||||||
.canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
|
.canvas-composer-icon.ant-btn > span:not(.ant-btn-icon) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-composer-model-pill {
|
.canvas-composer-model-picker {
|
||||||
align-items: center;
|
width: 32px;
|
||||||
border: 0;
|
min-width: 32px;
|
||||||
border-radius: 999px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
gap: 7px;
|
|
||||||
height: 32px;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-inline: 8px;
|
gap: 0;
|
||||||
transition: opacity 0.15s ease;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-composer-model-pill:hover {
|
@container (min-width: 440px) {
|
||||||
opacity: 0.82;
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (min-width: 400px) {
|
|
||||||
.canvas-composer-mode-button {
|
.canvas-composer-mode-button {
|
||||||
padding-inline: 8px;
|
padding-inline: 8px;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-composer-mode-button span,
|
.canvas-composer-mode-button span {
|
||||||
.canvas-composer-model-text,
|
|
||||||
.canvas-composer-model-arrow {
|
|
||||||
display: inline;
|
display: inline;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
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 {
|
.canvas-composer-icon.ant-btn {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
max-width: 170px;
|
max-width: 170px;
|
||||||
@@ -388,69 +369,21 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@container (min-width: 560px) {
|
.canvas-composer-model-picker {
|
||||||
.canvas-composer-model-pill {
|
width: auto;
|
||||||
flex-basis: 240px;
|
min-width: 9rem;
|
||||||
max-width: 240px;
|
justify-content: flex-start;
|
||||||
width: 240px !important;
|
gap: 0.5rem;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-composer-icon.ant-btn {
|
.canvas-model-picker-text,
|
||||||
flex: 0 1 auto;
|
.canvas-select-chevron {
|
||||||
max-width: 220px;
|
display: inline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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-select.ant-select-focused .ant-select-selector,
|
||||||
.canvas-control-number.ant-input-number-focused {
|
.canvas-control-number.ant-input-number-focused {
|
||||||
border-color: #a8a29e !important;
|
border-color: #a8a29e !important;
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
.dropdown :global(.ant-select-item) {
|
|
||||||
font-weight: 400;
|
|
||||||
min-height: 34px;
|
|
||||||
padding-inline: 10px 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown :global(.ant-select-item-option-selected) {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown :global(.rc-virtual-list-holder) {
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown :global(.rc-virtual-list-holder)::-webkit-scrollbar,
|
|
||||||
.dropdown :global(.rc-virtual-list-holder)::-webkit-scrollbar-button {
|
|
||||||
height: 0;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown :global(.rc-virtual-list-scrollbar) {
|
|
||||||
right: 4px !important;
|
|
||||||
width: 2px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown :global(.rc-virtual-list-scrollbar-thumb) {
|
|
||||||
background: rgba(120, 113, 108, 0.42) !important;
|
|
||||||
border-radius: 999px !important;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Select } from "antd";
|
import { useMemo, useState } from "react";
|
||||||
import { Cpu } from "lucide-react";
|
import { Cpu } from "lucide-react";
|
||||||
|
|
||||||
import styles from "./model-picker.module.css";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { AiConfig } from "@/stores/use-config-store";
|
import type { AiConfig } from "@/stores/use-config-store";
|
||||||
|
|
||||||
type ModelPickerProps = {
|
type ModelPickerProps = {
|
||||||
@@ -17,50 +18,68 @@ type ModelPickerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
|
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
|
||||||
const options = Array.from(new Set([value, ...config.models].filter(Boolean))).map((model) => ({ value: model, label: <ModelLabel model={model} /> }));
|
const [open, setOpen] = useState(false);
|
||||||
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
|
const options = useMemo(() => Array.from(new Set([value, ...config.models].filter(Boolean))), [config.models, value]);
|
||||||
|
const current = value || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
open={open}
|
||||||
className={`canvas-control-select ${className || ""}`}
|
value={current}
|
||||||
classNames={{ popup: { root: styles.dropdown } }}
|
onOpenChange={(nextOpen) => {
|
||||||
popupMatchSelectWidth
|
if (nextOpen && !options.length) {
|
||||||
popupRender={(menu) => (
|
onMissingConfig?.();
|
||||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
return;
|
||||||
{menu}
|
}
|
||||||
</div>
|
setOpen(nextOpen);
|
||||||
)}
|
|
||||||
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
|
|
||||||
value={value || undefined}
|
|
||||||
placeholder={placeholder}
|
|
||||||
options={options}
|
|
||||||
notFoundContent="请先到配置里拉取模型列表"
|
|
||||||
onChange={onChange}
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
onClick={() => {
|
|
||||||
if (!options.length) onMissingConfig?.();
|
|
||||||
}}
|
}}
|
||||||
filterOption={(input, option) =>
|
onValueChange={onChange}
|
||||||
String(option?.value || "")
|
>
|
||||||
.toLowerCase()
|
<SelectTrigger
|
||||||
.includes(input.toLowerCase())
|
className={cn(
|
||||||
}
|
"canvas-composer-model-picker h-8 w-fit max-w-full gap-2 rounded-full border border-input bg-transparent px-3 text-sm font-normal shadow-sm transition-colors",
|
||||||
/>
|
fullWidth ? "w-full min-w-0 justify-start" : "min-w-[9rem] justify-start",
|
||||||
|
"data-[state=open]:border-ring data-[state=open]:ring-2 data-[state=open]:ring-ring/20",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
title={current || placeholder}
|
||||||
|
>
|
||||||
|
<ModelIcon model={current} />
|
||||||
|
<span className="canvas-model-picker-text min-w-0 flex-1 truncate text-left">{current || placeholder}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-50 w-80 max-w-[calc(100vw-24px)] rounded-xl border border-border/70 bg-popover p-1 shadow-xl" position="popper" align="start">
|
||||||
|
{options.length ? (
|
||||||
|
options.map((model) => (
|
||||||
|
<SelectItem key={model} value={model} textValue={model}>
|
||||||
|
<ModelLabel model={model} />
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value="__empty__" disabled>
|
||||||
|
请先到配置里拉取模型列表
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelLabel({ model }: { model: string }) {
|
function ModelLabel({ model }: { model: string }) {
|
||||||
const icon = resolveModelIcon(model);
|
|
||||||
return (
|
return (
|
||||||
<span className="model-picker-label flex min-w-0 items-center gap-2">
|
<span className="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" />}
|
<ModelIcon model={model} />
|
||||||
<span className="model-picker-label-text truncate">{model}</span>
|
<span className="truncate">{model}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModelIcon({ model }: { model: string }) {
|
||||||
|
const icon = resolveModelIcon(model);
|
||||||
|
return icon ? <img src={icon} alt="" className="size-4 shrink-0" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveModelIcon(model: string) {
|
function resolveModelIcon(model: string) {
|
||||||
const name = model.toLowerCase();
|
const name = model.toLowerCase();
|
||||||
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
hideChevron = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
hideChevron?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{!hideChevron ? (
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="canvas-select-chevron pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
) : null}
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={position === "item-aligned"}
|
||||||
|
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
data-position={position}
|
||||||
|
className={cn(
|
||||||
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
|
position === "popper" && ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user