feat: support import model from admin market and market editor stacked feature

This commit is contained in:
Zhang Minghan 2024-02-28 23:27:03 +08:00
parent 7ba52d62d3
commit 57d45005f4
18 changed files with 427 additions and 209 deletions

View File

@ -47,9 +47,9 @@ _🚀 **Next Generation AI One-Stop Solution**_
13. **支持偏好设置**, i18n 多语言支持, 自定义最大携带会话数, 最大回复 tokens 数, 模型参数自定义, 重置设置等
![偏好设置](/screenshot/settings.png)
14. **附加功能** _(可通过后台系统设置设置附加功能的用户分组权限来开启和关闭)_
- 🍎 **AI 项目生成器功能**, 支持生成过程查看, 支持 TAR / ZIP 格式下载 *(原理为预设实现, 可能不稳定)*
- 📂 **批量文章生成功能**, 支持生成进度条, 一键生成 DOCX 文档的 TAR / ZIP 格式下载 *(需要生成数量高于上游该模型的最高并发数)*
- 🥪 **AI 卡片功能** (已废弃), AI 的问题和答案以卡片形式展现, 可直接以图片 url 形式嵌入。*(原理为动态生成 SVG, 模型的相应时间可能较长而导致 TIMEOUT, 当前已放弃支持)*
- *[停止支持]* 🍎 **AI 项目生成器功能**, 支持生成过程查看, 支持 TAR / ZIP 格式下载 *(原理为预设实现, 可能不稳定)*
- *[停止支持]* 📂 **批量文章生成功能**, 支持生成进度条, 一键生成 DOCX 文档的 TAR / ZIP 格式下载 *(需要生成数量高于上游该模型的最高并发数)*
- *[已弃用]* 🥪 **AI 卡片功能** (已废弃), AI 的问题和答案以卡片形式展现, 可直接以图片 url 形式嵌入。*(原理为动态生成 SVG)*
- 🔔 丰富用户管理和计费体系
1. **丰富且美观的仪表盘**, 包含本日和当月入账信息, 订阅人数, 模型使用统计折线图, 饼状图分析, 收入统计, 用户类型统计, 模型使用统计, 请求次数和模型错误数量统计图表等
![仪表盘](/screenshot/admin.png)

View File

@ -136,6 +136,10 @@ func (c *ChatInstance) CreateStreamChatRequest(props *ChatProps, callback global
if err != nil {
if form := processChatErrorResponse(err.Body); form != nil {
if form.Error.Type == "" {
form.Error.Type = "unknown"
}
msg := fmt.Sprintf("%s (type: %s)", form.Error.Message, form.Error.Type)
return errors.New(hideRequestId(msg))
}

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "chatnio",
"version": "3.9.2"
"version": "3.10.0"
},
"tauri": {
"allowlist": {

View File

@ -213,10 +213,6 @@ export const defaultChannelModels: string[] = getUniqueList(
Object.values(ChannelInfos).flatMap((info) => info.models),
);
export const channelModels: string[] = getUniqueList(
Object.values(ChannelInfos).flatMap((info) => info.models),
);
export const channelGroups: string[] = [
AnonymousType,
NormalType,

View File

@ -1,15 +1,55 @@
import { useMemo, useState } from "react";
import { getUniqueList } from "@/utils/base.ts";
import { defaultChannelModels } from "@/admin/channel.ts";
import { getApiModels } from "@/api/v1.ts";
import { getApiMarket, getApiModels } from "@/api/v1.ts";
import { useEffectAsync } from "@/utils/hook.ts";
import { Model } from "@/api/types.ts";
export const useSupportModels = () => {
const [supportModels, setSupportModels] = useState<string[]>([]);
export type onStateChange<T> = (state: boolean, data?: T) => void;
export const useAllModels = (onStateChange?: onStateChange<string[]>) => {
const [allModels, setAllModels] = useState<string[]>([]);
const update = async () => {
onStateChange?.(false, allModels);
const models = await getApiModels();
setSupportModels(models.data);
onStateChange?.(true, models.data);
setAllModels(models.data);
};
useEffectAsync(update, []);
return {
allModels,
update,
};
};
export const useChannelModels = (onStateChange?: onStateChange<string[]>) => {
const { allModels, update } = useAllModels(onStateChange);
const channelModels = useMemo(
() => getUniqueList([...allModels, ...defaultChannelModels]),
[allModels],
);
return {
channelModels,
allModels,
update,
};
};
export const useSupportModels = (onStateChange?: onStateChange<Model[]>) => {
const [supportModels, setSupportModels] = useState<Model[]>([]);
const update = async () => {
onStateChange?.(false, supportModels);
const market = await getApiMarket();
onStateChange?.(true, market);
setSupportModels(market);
};
useEffectAsync(update, []);
@ -19,17 +59,3 @@ export const useSupportModels = () => {
update,
};
};
export const useChannelModels = () => {
const { supportModels, update } = useSupportModels();
const channelModels = useMemo(
() => getUniqueList([...supportModels, ...defaultChannelModels]),
[supportModels],
);
return {
channelModels,
update,
};
};

View File

@ -31,6 +31,22 @@
}
}
.market-alert {
display: flex;
flex-direction: column;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
margin-bottom: 1rem;
padding: 0.75rem;
.market-alert-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
}
}
.market-list {
display: flex;
flex-direction: column;
@ -68,6 +84,19 @@
border-color: hsl(var(--error));
}
&.stacked {
border-color: transparent !important;
padding: 0.25rem 0.5rem;
margin-bottom: 0.25rem;
.market-row {
width: max-content;
flex-wrap: nowrap;
flex-shrink: 0;
gap: 0.5rem;
}
}
.market-tags {
display: flex;
flex-direction: row;

View File

@ -77,7 +77,7 @@ import {
import { getUniqueList, parseNumber } from "@/utils/base.ts";
import { defaultChannelModels } from "@/admin/channel.ts";
import { getPricing } from "@/admin/datasets/charge.ts";
import { useSupportModels } from "@/admin/hook.tsx";
import { useAllModels } from "@/admin/hook.tsx";
const initialState: ChargeProps = {
id: -1,
@ -370,7 +370,7 @@ type ChargeEditorProps = {
dispatch: (action: any) => void;
onRefresh: () => void;
usedModels: string[];
supportModels: string[];
allModels: string[];
};
function ChargeEditor({
@ -378,7 +378,7 @@ function ChargeEditor({
dispatch,
onRefresh,
usedModels,
supportModels,
allModels,
}: ChargeEditorProps) {
const { t } = useTranslation();
const { toast } = useToast();
@ -386,8 +386,8 @@ function ChargeEditor({
const [model, setModel] = useState("");
const channelModels = useMemo(
() => getUniqueList([...supportModels, ...defaultChannelModels]),
[supportModels],
() => getUniqueList([...allModels, ...defaultChannelModels]),
[allModels],
);
const unusedModels = useMemo(() => {
@ -719,7 +719,7 @@ function ChargeWidget() {
const [form, dispatch] = useReducer(reducer, initialState);
const [loading, setLoading] = useState(false);
const { supportModels, update } = useSupportModels();
const { allModels, update } = useAllModels();
const currentModels = useMemo(() => {
return data.flatMap((charge) => charge.models);
@ -731,10 +731,10 @@ function ChargeWidget() {
const unusedModels = useMemo(() => {
if (loading) return [];
return supportModels.filter(
return allModels.filter(
(model) => !usedModels.includes(model) && model.trim() !== "",
);
}, [loading, supportModels, usedModels]);
}, [loading, allModels, usedModels]);
async function refresh(ignoreUpdate?: boolean) {
setLoading(true);
@ -763,7 +763,7 @@ function ChargeWidget() {
onRefresh={refresh}
form={form}
dispatch={dispatch}
supportModels={supportModels}
allModels={allModels}
usedModels={usedModels}
/>
<ChargeTable data={data} dispatch={dispatch} onRefresh={refresh} />

View File

@ -11,7 +11,6 @@ import {
import {
Channel,
channelGroups,
channelModels,
ChannelTypes,
getChannelInfo,
} from "@/admin/channel.ts";
@ -47,6 +46,7 @@ import Paragraph, {
ParagraphItem,
} from "@/components/Paragraph.tsx";
import { MultiCombobox } from "@/components/ui/multi-combobox.tsx";
import { useChannelModels } from "@/admin/hook.tsx";
type CustomActionProps = {
onPost: (model: string) => void;
@ -136,11 +136,12 @@ function ChannelEditor({
}: ChannelEditorProps) {
const { t } = useTranslation();
const info = useMemo(() => getChannelInfo(edit.type), [edit.type]);
const { channelModels } = useChannelModels();
const unusedModels = useMemo(() => {
return channelModels.filter(
(model) => !edit.models.includes(model) && model !== "",
);
}, [edit.models]);
}, [channelModels, edit.models]);
const enabled = useMemo(() => validator(edit), [edit]);
const [loading, setLoading] = useState(false);

View File

@ -3,14 +3,8 @@ import { ThemeProvider } from "@/components/ThemeProvider.tsx";
import DialogManager from "@/dialogs";
import Broadcast from "@/components/Broadcast.tsx";
import { useEffectAsync } from "@/utils/hook.ts";
import { allModels, supportModels } from "@/conf";
import { channelModels } from "@/admin/channel.ts";
import {
getApiCharge,
getApiMarket,
getApiModels,
getApiPlans,
} from "@/api/v1.ts";
import { supportModels } from "@/conf";
import { getApiCharge, getApiMarket, getApiPlans } from "@/api/v1.ts";
import { loadPreferenceModels } from "@/conf/storage.ts";
import { resetJsArray } from "@/utils/base.ts";
import { useDispatch } from "react-redux";
@ -18,7 +12,6 @@ import { initChatModels, updateMasks } from "@/store/chat.ts";
import { Model } from "@/api/types.ts";
import { ChargeProps, nonBilling } from "@/admin/charge.ts";
import { dispatchSubscriptionData, setTheme } from "@/store/globals.ts";
import { marketEvent } from "@/events/market.ts";
import { infoEvent } from "@/events/info.ts";
import { setForm } from "@/store/info.ts";
import { themeEvent } from "@/events/theme.ts";
@ -34,8 +27,6 @@ function AppProvider() {
}, []);
useEffectAsync(async () => {
marketEvent.emit(false);
const market = await getApiMarket();
const charge = await getApiCharge();
@ -51,22 +42,10 @@ function AppProvider() {
});
resetJsArray(supportModels, loadPreferenceModels(market));
resetJsArray(
allModels,
supportModels.map((model) => model.id),
);
initChatModels(dispatch);
const models = await getApiModels();
models.data.forEach((model: string) => {
if (!allModels.includes(model)) allModels.push(model);
if (!channelModels.includes(model)) channelModels.push(model);
});
dispatchSubscriptionData(dispatch, await getApiPlans());
marketEvent.emit(true);
}, [allModels]);
}, []);
return (
<>

View File

@ -9,7 +9,7 @@ import { syncSiteInfo } from "@/admin/api/info.ts";
import { getOfflineModels, loadPreferenceModels } from "@/conf/storage.ts";
import { setAxiosConfig } from "@/conf/api.ts";
export const version = "3.9.2"; // version of the current build
export const version = "3.10.0"; // version of the current build
export const dev: boolean = getDev(); // is in development mode (for debugging, in localhost origin)
export const deploy: boolean = true; // is production environment (for api endpoint)
export const tokenField = getTokenField(deploy); // token field name for storing token
@ -18,7 +18,6 @@ export let apiEndpoint: string = getRestApi(deploy); // api endpoint for rest ap
export let websocketEndpoint: string = getWebsocketApi(deploy); // api endpoint for websocket calls
export let supportModels: Model[] = loadPreferenceModels(getOfflineModels()); // support models in model market of the current site
export let allModels: string[] = supportModels.map((model) => model.id); // all support model id list of the current site
setAxiosConfig({
endpoint: apiEndpoint,

View File

@ -1,5 +0,0 @@
import { EventCommitter } from "@/events/struct.ts";
export const marketEvent = new EventCommitter<boolean>({
name: "market",
});

View File

@ -494,6 +494,8 @@
"market": {
"title": "模型市场",
"model-name": "模型名称",
"not-use": "部分模型未使用",
"import-all": "导入全部",
"new-model": "新建模型",
"model-name-placeholder": "请输入模型昵称 GPT-4",
"model-id": "模型 ID",
@ -518,12 +520,12 @@
"sync-failed": "同步失败",
"sync-failed-prompt": "地址无法请求或者模型市场模型为空\n端点{{endpoint}}",
"sync-success": "同步成功",
"sync-success-prompt": "已从上游同步,添加 {{length}} 个模型,请检查后点击更新以保存。",
"sync-success-prompt": "已从上游同步,添加 {{length}} 个模型,请检查后点击提交方可生效,否则将不会保存",
"sync-items": "共发现 {{length}} 个模型,已有模型 {{exist}} 个(不会覆盖),新增模型 {{new}} 个(同步全部),本站点渠道已支持模型 {{support}} 个(同步已支持模型)",
"sync-all": "同步全部 ({{length}} 个)",
"sync-self": "同步已支持模型 ({{length}} 个)",
"update-success": "更新成功",
"update-success-prompt": "模型市场设置已成功提交更新至服务器。",
"update-success-prompt": "模型市场已成功更新(刷新浏览器即可立即应用)",
"update-failed": "更新失败",
"update-failed-prompt": "更新请求失败,原因:{{reason}}"
},

View File

@ -579,7 +579,7 @@
"model-is-default-tip": "Whether the model is added to the default model list (models not added to the default model list will not appear in the home model list by default)",
"model-tag": "Model label",
"update-success": "Upgrade successful",
"update-success-prompt": "Model Marketplace settings were successfully submitted for update to the server.",
"update-success-prompt": "Model Marketplace updated successfully (refresh your browser to apply now)",
"update-failed": "Update failed",
"update-failed-prompt": "Update request failed for {{reason}}",
"model-image": "Model Picture",
@ -599,7 +599,9 @@
"sync-failed-prompt": "Address could not be requested or model market model is empty\n(Endpoint: {{endpoint}})",
"sync-items": "A total of {{length}} models have been found, {{exist}} models have been found (will not be overwritten), {{new}} models have been added (all synchronized), {{support}} models have been supported by this site channel (synchronized supported models)",
"sync-success": "Sync successfully.",
"sync-success-prompt": "Synced from upstream, added {{length}} models, please check and click Update to save."
"sync-success-prompt": "Synced from upstream, added {{length}} models, please check and click submit to take effect, otherwise it will not be saved",
"not-use": "Some models are not used",
"import-all": "Import Full"
},
"model-chart-tip": "Token usage",
"subscription": "Subscription Management",

View File

@ -579,7 +579,7 @@
"model-is-default-tip": "モデルがデフォルトモデルリストに追加されるかどうか(デフォルトでは、デフォルトモデルリストに追加されていないモデルはホームモデルリストに表示されません)",
"model-tag": "モデルラベル",
"update-success": "正常に更新されました",
"update-success-prompt": "モデルマーケットプレイスの設定は、サーバーへの更新のために正常に送信されました。",
"update-success-prompt": "モデルマーケットプレイスが正常に更新されました(今すぐ適用するにはブラウザを更新してください)",
"update-failed": "更新に失敗",
"update-failed-prompt": "{{reason}}の更新リクエストが失敗しました",
"model-image": "モデル写真",
@ -599,7 +599,9 @@
"sync-failed-prompt": "住所をリクエストできなかったか、モデルマーケットモデルが空です\nエンドポイント{{ endpoint }}",
"sync-items": "合計{{length}}個のモデルが見つかりました。{{exist}}個のモデルが見つかりました(上書きされません)。{{new}}個のモデルが追加されました(すべて同期済み)。{{support}}個のモデルがこのサイトチャネルでサポートされています(同期対応モデル)",
"sync-success": "同期成功",
"sync-success-prompt": "アップストリームから同期され、{{length}}モデルが追加されました。確認して[更新]をクリックして保存してください。"
"sync-success-prompt": "アップストリームから同期され、{{length}}モデルが追加されました。確認して送信をクリックして有効にしてください。有効にしないと保存されません",
"not-use": "一部のモデルは使用されていません",
"import-all": "すべてインポート..."
},
"model-chart-tip": "トークンの使用状況",
"subscription": "サブスクリプション管理",

View File

@ -579,7 +579,7 @@
"model-is-default-tip": "Добавлена ли модель в список моделей по умолчанию (модели, не добавленные в список моделей по умолчанию, не будут отображаться в списке моделей дома по умолчанию)",
"model-tag": "Этикетка модели",
"update-success": "Успешно обновлено",
"update-success-prompt": "Настройки Model Marketplace успешно отправлены на обновление на сервер.",
"update-success-prompt": "Модель Marketplace успешно обновлена (обновите браузер, чтобы подать заявку сейчас)",
"update-failed": "Ошибка обновления",
"update-failed-prompt": "Запрос на обновление не выполнен по {{reason}}",
"model-image": "Изображение модели",
@ -599,7 +599,9 @@
"sync-failed-prompt": "Адрес не может быть запрошен или модель рынка пуста\n(Конечная точка: {{endpoint}})",
"sync-items": "Всего найдено {{length}} моделей, {{exist}} моделей найдено (не будет перезаписано), {{new}} моделей добавлено (все синхронизировано), {{support}} моделей поддерживается этим каналом сайта (синхронизированные поддерживаемые модели)",
"sync-success": "Успешная синхронизация",
"sync-success-prompt": "Синхронизировано с вышестоящими, добавлено {{length}} моделей, проверьте и нажмите «Обновить», чтобы сохранить."
"sync-success-prompt": "Синхронизировано с вышестоящими, добавлено {{length}} моделей, проверьте и нажмите «Отправить», чтобы вступить в силу, иначе оно не будет сохранено",
"not-use": "Некоторые модели не используются",
"import-all": "Импортировать все..."
},
"model-chart-tip": "Использование токенов",
"subscription": "Управление подписками",

View File

@ -7,17 +7,22 @@ import {
import { useTranslation } from "react-i18next";
import { Dispatch, useMemo, useReducer, useState } from "react";
import { Model as RawModel } from "@/api/types.ts";
import { allModels, supportModels } from "@/conf";
import { Input } from "@/components/ui/input.tsx";
import {
Activity,
AlertCircle,
ChevronDown,
ChevronUp,
HelpCircle,
Loader2,
Import,
Maximize,
Minimize,
Plus,
RotateCw,
Save,
Trash2,
} from "lucide-react";
import { generateRandomChar, isUrl, resetJsArray } from "@/utils/base.ts";
import { generateRandomChar, isUrl } from "@/utils/base.ts";
import Require from "@/components/Require.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";
import Tips from "@/components/Tips.tsx";
@ -27,7 +32,6 @@ import { marketEditableTags, modelImages } from "@/admin/market.ts";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Combobox } from "@/components/ui/combo-box.tsx";
import { channelModels } from "@/admin/channel.ts";
import { cn } from "@/components/ui/lib/utils.ts";
import PopupDialog, { popupTypes } from "@/components/PopupDialog.tsx";
import { useToast } from "@/components/ui/use-toast.ts";
@ -39,19 +43,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import {
getApiCharge,
getApiMarket,
getApiModels,
getV1Path,
} from "@/api/v1.ts";
import { ChargeProps, nonBilling } from "@/admin/charge.ts";
import { useDispatch, useSelector } from "react-redux";
import { selectModel, setModel, setModelList } from "@/store/chat.ts";
import { loadPreferenceModels } from "@/conf/storage.ts";
import { getApiMarket, getV1Path } from "@/api/v1.ts";
import { updateMarket } from "@/admin/api/market.ts";
import { marketEvent } from "@/events/market.ts";
import { toast } from "sonner";
import { useChannelModels, useSupportModels } from "@/admin/hook.tsx";
import Icon from "@/components/utils/Icon.tsx";
type Model = RawModel & {
seed?: string;
@ -110,6 +106,38 @@ function reducer(state: MarketForm, action: any): MarketForm {
seed: generateSeed(),
},
];
case "new-template":
return [
...state,
{
id: action.payload.id,
name: action.payload.name,
free: false,
auth: false,
description: "",
high_context: false,
default: false,
tag: [],
avatar: modelImages[0],
seed: generateSeed(),
},
];
case "batch-new-template":
return [
...state,
...action.payload.map((model: { id: string; name: string }) => ({
id: model.id,
name: model.name,
free: false,
auth: false,
description: "",
high_context: false,
default: false,
tag: [],
avatar: modelImages[0],
seed: generateSeed(),
})),
];
case "remove":
let { idx } = action.payload;
return [...state.slice(0, idx), ...state.slice(idx + 1)];
@ -377,9 +405,18 @@ type MarketItemProps = {
form: MarketForm;
dispatch: Dispatch<any>;
index: number;
stacked: boolean;
channelModels: string[];
};
function MarketItem({ model, form, dispatch, index }: MarketItemProps) {
function MarketItem({
model,
form,
stacked,
dispatch,
index,
channelModels,
}: MarketItemProps) {
const { t } = useTranslation();
const checked = useMemo(
@ -387,7 +424,63 @@ function MarketItem({ model, form, dispatch, index }: MarketItemProps) {
[model],
);
return (
const Actions = () => (
<div className={`market-row`}>
{!stacked && <div className={`grow`} />}
<Button
size={`icon`}
variant={`outline`}
onClick={() =>
dispatch({
type: "add-below",
payload: { idx: index },
})
}
>
<Plus className={`h-4 w-4`} />
</Button>
<Button
size={`icon`}
variant={`outline`}
onClick={() =>
dispatch({
type: "upward",
payload: { idx: index },
})
}
disabled={index === 0}
>
<ChevronUp className={`h-4 w-4`} />
</Button>
<Button
size={`icon`}
variant={`outline`}
onClick={() =>
dispatch({
type: "downward",
payload: { idx: index },
})
}
disabled={index === form.length - 1}
>
<ChevronDown className={`h-4 w-4`} />
</Button>
<Button
size={`icon`}
onClick={() =>
dispatch({
type: "remove",
payload: { idx: index },
})
}
>
<Trash2 className={`h-4 w-4`} />
</Button>
</div>
);
return !stacked ? (
<div className={cn("market-item", !checked && "error")}>
<div className={`model-wrapper`}>
<div className={`market-row`}>
@ -489,61 +582,27 @@ function MarketItem({ model, form, dispatch, index }: MarketItemProps) {
<span>{t("admin.market.model-image")}</span>
<MarketImage image={model.avatar} idx={index} dispatch={dispatch} />
</div>
<div className={`market-row`}>
<div className={`grow`} />
<Button
size={`icon`}
variant={`outline`}
onClick={() =>
dispatch({
type: "add-below",
payload: { idx: index },
})
}
>
<Plus className={`h-4 w-4`} />
</Button>
<Button
size={`icon`}
variant={`outline`}
onClick={() =>
dispatch({
type: "upward",
payload: { idx: index },
})
}
disabled={index === 0}
>
<ChevronUp className={`h-4 w-4`} />
</Button>
<Button
size={`icon`}
variant={`outline`}
onClick={() =>
dispatch({
type: "downward",
payload: { idx: index },
})
}
disabled={index === form.length - 1}
>
<ChevronDown className={`h-4 w-4`} />
</Button>
<Button
size={`icon`}
onClick={() =>
dispatch({
type: "remove",
payload: { idx: index },
})
}
>
<Trash2 className={`h-4 w-4`} />
</Button>
</div>
<Actions />
</div>
</div>
) : (
<div className={cn("market-item stacked", !checked && "error")}>
<Input
value={model.name}
placeholder={t("admin.market.model-name-placeholder")}
className={`grow mr-2`}
onChange={(e) => {
dispatch({
type: "update-name",
payload: {
idx: index,
name: e.target.value,
},
});
}}
/>
<Actions />
</div>
);
}
@ -551,9 +610,17 @@ type SyncDialogProps = {
open: boolean;
setOpen: (state: boolean) => void;
onConfirm: (form: MarketForm) => Promise<boolean>;
allModels: string[];
supportModels: Model[];
};
function SyncDialog({ open, setOpen, onConfirm }: SyncDialogProps) {
function SyncDialog({
open,
setOpen,
allModels,
supportModels,
onConfirm,
}: SyncDialogProps) {
const { t } = useTranslation();
const [form, setForm] = useState<MarketForm>([]);
const { toast } = useToast();
@ -666,48 +733,118 @@ function SyncDialog({ open, setOpen, onConfirm }: SyncDialogProps) {
);
}
type MarketAlertProps = {
open: boolean;
models: string[];
onImport: (model: string) => void;
onImportAll: () => void;
};
function MarketAlert({
open,
models,
onImport,
onImportAll,
}: MarketAlertProps) {
const { t } = useTranslation();
return (
open &&
models.length > 0 && (
<div className={`market-alert`}>
<div
className={`flex flex-row items-center mb-2 whitespace-nowrap select-none`}
>
<AlertCircle className={`h-4 w-4 mr-2 translate-y-[1px]`} />
<span>{t("admin.market.not-use")}</span>
<Button
variant={`outline`}
size={`sm`}
className={`ml-auto`}
onClick={onImportAll}
>
<Import className={`h-4 w-4 mr-2`} />
{t("admin.market.import-all")}
</Button>
</div>
<div className={`market-alert-wrapper`}>
{models.map((model, index) => (
<Button
key={index}
variant={`outline`}
size={`sm`}
className={`text-sm`}
onClick={() => onImport(model)}
>
{model}
</Button>
))}
</div>
</div>
)
);
}
function getModelName(id: string): string {
// replace all `-` to ` ` except first `-` keep it
let begin = true;
return id
.replace(/-/g, (l) => {
if (begin) {
begin = false;
return l;
}
return " ";
})
.replace(/\b\w/g, (l) => l.toUpperCase())
.replace(/Gpt/g, "GPT")
.replace(/Tts/g, "TTS")
.replace(/Dall-E/g, "DALL-E")
.replace(/Dalle/g, "DALLE")
.replace(/Glm/g, "GLM")
.trim();
}
function Market() {
const { t } = useTranslation();
const [form, dispatch] = useReducer(reducer, supportModels);
const [loading, setLoading] = useState<boolean>(false);
const model = useSelector(selectModel);
const [stepSupport, setStepSupport] = useState<boolean>(false);
const [stepAll, setStepAll] = useState<boolean>(false);
const globalDispatch = useDispatch();
const [stacked, setStacked] = useState<boolean>(false);
const sync = async (): Promise<void> => {
const market = await getApiMarket();
const charge = await getApiCharge();
const [form, dispatch] = useReducer(reducer, []);
const [open, setOpen] = useState<boolean>(false);
market.forEach((item: Model) => {
const instance = charge.find((i: ChargeProps) =>
i.models.includes(item.id),
);
if (!instance) return;
const { supportModels, update: updateSuppportModels } = useSupportModels(
(state, data) => {
setStepSupport(!state);
state && dispatch({ type: "set", payload: data });
},
);
item.free = instance.type === nonBilling;
item.auth = !item.free || !instance.anonymous;
item.price = { ...instance };
});
const {
allModels,
channelModels,
update: updateAllModels,
} = useChannelModels((state) => setStepAll(!state));
resetJsArray(supportModels, loadPreferenceModels(market));
resetJsArray(
allModels,
supportModels.map((model) => model.id),
);
globalDispatch(setModelList(supportModels));
allModels.length > 0 &&
!allModels.includes(model) &&
globalDispatch(setModel(allModels[0]));
const unusedModels = useMemo(
(): string[] =>
allModels.filter((model) => !form.map((m) => m.id).includes(model)),
[form, allModels],
);
const models = await getApiModels();
models.data.forEach((model: string) => {
if (!allModels.includes(model)) allModels.push(model);
if (!channelModels.includes(model)) channelModels.push(model);
});
const loading = stepSupport || stepAll;
const update = async () => {
await updateSuppportModels();
await updateAllModels();
};
const update = async (): Promise<void> => {
const sync = async (): Promise<void> => {};
const submit = async (): Promise<void> => {
const preflight = form.filter(
(model) => model.id.trim().length > 0 && model.name.trim().length > 0,
);
@ -733,18 +870,13 @@ function Market() {
dispatch({ type: "add-multiple", payload: [...data] });
};
marketEvent.addEventListener((state: boolean) => {
setLoading(!state);
!state && dispatch({ type: "set", payload: [...supportModels] });
});
const [open, setOpen] = useState<boolean>(false);
return (
<div className={`market`}>
<SyncDialog
open={open}
setOpen={setOpen}
allModels={allModels}
supportModels={supportModels}
onConfirm={async (data: MarketForm) => {
await migrate(data);
toast(t("admin.market.sync-success"), {
@ -757,25 +889,70 @@ function Market() {
}}
/>
<Card className={`admin-card market-card`}>
<CardHeader className={`flex flex-row items-center select-none`}>
<CardTitle>
{t("admin.market.title")}
{loading && (
<Loader2
className={`inline-block h-4 w-4 ml-2 animate-spin relative top-[-2px]`}
/>
)}
</CardTitle>
<Button
className={`ml-auto mt-0 whitespace-nowrap`}
size={`sm`}
style={{ marginTop: 0 }}
onClick={() => setOpen(true)}
>
{t("admin.market.sync")}
</Button>
<CardHeader>
<CardTitle>{t("admin.market.title")}</CardTitle>
</CardHeader>
<CardContent>
<div className={`market-actions flex flex-row items-center mb-4`}>
<Button
variant={`outline`}
className={`whitespace-nowrap`}
onClick={() => setOpen(true)}
>
<Activity className={`h-4 w-4 mr-2`} />
{t("admin.market.sync")}
</Button>
<div className={`grow`} />
<Button
variant={`outline`}
size={`icon`}
className={`mr-2`}
onClick={() => setStacked(!stacked)}
>
<Icon
icon={stacked ? <Minimize /> : <Maximize />}
className={`h-4 w-4`}
/>
</Button>
<Button
size={`icon`}
variant={`outline`}
className={`mr-2`}
onClick={update}
>
<RotateCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
<Button
size={`icon`}
className={`mr-2`}
loading={true}
onClick={submit}
>
<Save className={`h-4 w-4`} />
</Button>
</div>
<MarketAlert
open={!loading}
models={unusedModels}
onImport={(model: string) => {
dispatch({
type: "new-template",
payload: {
id: model,
name: getModelName(model),
},
});
}}
onImportAll={() => {
dispatch({
type: "batch-new-template",
payload: unusedModels.map((model) => ({
id: model,
name: getModelName(model),
})),
});
}}
/>
<div className={`market-list`}>
{form.length > 0 ? (
form.map((model, index) => (
@ -783,8 +960,10 @@ function Market() {
key={index}
model={model}
form={form}
stacked={stacked}
dispatch={dispatch}
index={index}
channelModels={channelModels}
/>
))
) : (
@ -802,7 +981,7 @@ function Market() {
<Plus className={`h-4 w-4 mr-2`} />
{t("admin.market.new-model")}
</Button>
<Button size={`sm`} onClick={update} loading={true}>
<Button size={`sm`} onClick={submit} loading={true}>
{t("admin.market.migrate")}
</Button>
</div>

View File

@ -442,6 +442,11 @@ function PlanConfig() {
}}
/>
<div className={`plan-config-row pb-2`}>
<Button variant={`outline`} onClick={() => setOpen(true)}>
<Activity className={`h-4 w-4 mr-2`} />
{t("admin.plan.sync")}
</Button>
<div className={`grow`} />
<Button
variant={`outline`}
size={`icon`}
@ -453,11 +458,6 @@ function PlanConfig() {
className={`h-4 w-4`}
/>
</Button>
<Button variant={`outline`} onClick={() => setOpen(true)}>
<Activity className={`h-4 w-4 mr-2`} />
{t("admin.plan.sync")}
</Button>
<div className={`grow`} />
<Button
variant={`outline`}
className={`mr-2`}

View File

@ -51,8 +51,8 @@ import { cn } from "@/components/ui/lib/utils.ts";
import { Switch } from "@/components/ui/switch.tsx";
import { MultiCombobox } from "@/components/ui/multi-combobox.tsx";
import { allGroups } from "@/utils/groups.ts";
import { channelModels } from "@/admin/channel.ts";
import { supportModels } from "@/conf";
import { useChannelModels } from "@/admin/hook.tsx";
type CompProps<T> = {
data: T;
@ -593,6 +593,8 @@ function Site({ data, dispatch, onChange }: CompProps<SiteState>) {
function Common({ data, dispatch, onChange }: CompProps<CommonState>) {
const { t } = useTranslation();
const { channelModels } = useChannelModels();
return (
<Paragraph
title={t("admin.system.common")}