mirror of
https://github.com/coaidev/coai.git
synced 2025-05-30 10:20:21 +09:00
feat: support model market sync upstream (#50)
This commit is contained in:
parent
2d41885cd6
commit
9a501d5d2b
@ -2,9 +2,20 @@ import axios from "axios";
|
||||
import { Model, Plan } from "@/api/types.ts";
|
||||
import { ChargeProps } from "@/admin/charge.ts";
|
||||
|
||||
export async function getApiModels(): Promise<string[]> {
|
||||
type v1Options = {
|
||||
endpoint?: string;
|
||||
};
|
||||
|
||||
export function getV1Path(path: string, options?: v1Options): string {
|
||||
let endpoint = options && options.endpoint ? options.endpoint : "";
|
||||
if (endpoint.endsWith("/")) endpoint = endpoint.slice(0, -1);
|
||||
|
||||
return endpoint + path;
|
||||
}
|
||||
|
||||
export async function getApiModels(options?: v1Options): Promise<string[]> {
|
||||
try {
|
||||
const res = await axios.get("/v1/models");
|
||||
const res = await axios.get(getV1Path("/v1/models", options));
|
||||
return res.data as string[];
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
@ -12,9 +23,9 @@ export async function getApiModels(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApiPlans(): Promise<Plan[]> {
|
||||
export async function getApiPlans(options?: v1Options): Promise<Plan[]> {
|
||||
try {
|
||||
const res = await axios.get("/v1/plans");
|
||||
const res = await axios.get(getV1Path("/v1/plans", options));
|
||||
const plans = res.data as Plan[];
|
||||
return plans.filter((plan: Plan) => plan.level !== 0);
|
||||
} catch (e) {
|
||||
@ -23,19 +34,21 @@ export async function getApiPlans(): Promise<Plan[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApiMarket(): Promise<Model[]> {
|
||||
export async function getApiMarket(options?: v1Options): Promise<Model[]> {
|
||||
try {
|
||||
const res = await axios.get("/v1/market");
|
||||
return res.data as Model[];
|
||||
const res = await axios.get(getV1Path("/v1/market", options));
|
||||
return (res.data || []) as Model[];
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApiCharge(): Promise<ChargeProps[]> {
|
||||
export async function getApiCharge(
|
||||
options?: v1Options,
|
||||
): Promise<ChargeProps[]> {
|
||||
try {
|
||||
const res = await axios.get("/v1/charge");
|
||||
const res = await axios.get(getV1Path("/v1/charge", options));
|
||||
return res.data as ChargeProps[];
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
|
@ -43,6 +43,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
color: hsl(var(--text-secondary));
|
||||
user-select: none;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
.market-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -22,6 +22,9 @@ export type PopupDialogProps = {
|
||||
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
};
|
||||
|
||||
export type PopupFieldProps<T> = {
|
||||
@ -51,6 +54,8 @@ function PopupDialog({
|
||||
onSubmit,
|
||||
open,
|
||||
setOpen,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
}: PopupDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState<string>(defaultValue || "");
|
||||
@ -78,7 +83,7 @@ function PopupDialog({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant={`outline`} onClick={() => setOpen(false)}>
|
||||
{t("cancel")}
|
||||
{cancelLabel || t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={true}
|
||||
@ -92,7 +97,7 @@ function PopupDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("confirm")}
|
||||
{confirmLabel || t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
@ -27,7 +27,7 @@ function Announcement() {
|
||||
open={announcement !== ""}
|
||||
onOpenChange={() => setAnnouncement("")}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent className={`flex-dialog`}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle
|
||||
className={"flex flex-row items-center select-none"}
|
||||
|
@ -422,10 +422,15 @@
|
||||
"update": "更新",
|
||||
"migrate": "提交",
|
||||
"sync": "同步上游",
|
||||
"sync-option": "同步选项",
|
||||
"sync-site": "上游地址",
|
||||
"sync-tip": "同步上游模型市场",
|
||||
"sync-placeholder": "请输入上游 Chat Nio 的 API 地址,如:https://api.chatnio.net",
|
||||
"sync-items": "共发现 {{length}} 个模型,已有模型 {{exist}} 个(不会覆盖),新增模型 {{new}} 个,本站点渠道已支持模型 {{support}} 个",
|
||||
"sync-failed": "同步失败",
|
||||
"sync-failed-prompt": "地址无法请求或者模型市场模型为空\n(端点:{{endpoint}})",
|
||||
"sync-success": "同步成功",
|
||||
"sync-success-prompt": "已从上游同步,添加 {{length}} 个模型,请检查后点击更新以保存。",
|
||||
"sync-items": "共发现 {{length}} 个模型,已有模型 {{exist}} 个(不会覆盖),新增模型 {{new}} 个(同步全部),本站点渠道已支持模型 {{support}} 个(同步已支持模型)",
|
||||
"sync-all": "同步全部 ({{length}} 个)",
|
||||
"sync-self": "同步已支持模型 ({{length}} 个)",
|
||||
"update-success": "更新成功",
|
||||
|
@ -486,10 +486,15 @@
|
||||
"sync": "Sync upstream",
|
||||
"sync-tip": "Synchronize upstream model markets",
|
||||
"sync-placeholder": "Please enter the API address of the upstream Chat Nio, for example: https://api.chatnio.net",
|
||||
"sync-items": "A total of {{length}} models have been found, {{exist}} models have been found (will not be overwritten), {{new}} models have been added, {{support}} models have been supported in this site channel",
|
||||
"sync-all": "Sync all ({{length}})",
|
||||
"sync-self": "Sync supported models ({{length}})",
|
||||
"sync-site": "Upstream address"
|
||||
"sync-site": "Upstream address",
|
||||
"sync-option": "Synchronization Options",
|
||||
"sync-failed": "Sync Failed",
|
||||
"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."
|
||||
},
|
||||
"model-chart-tip": "Token usage",
|
||||
"subscription": "Subscription Management",
|
||||
|
@ -486,10 +486,15 @@
|
||||
"sync": "アップストリームを同期",
|
||||
"sync-tip": "アップストリームモデル市場の同期",
|
||||
"sync-placeholder": "アップストリームのChat NioのAPIアドレスを入力してください。例: https://api.chatnio.net",
|
||||
"sync-items": "合計{{length}}個のモデルが見つかりました。{{exist}}個のモデルが見つかりました(上書きされません)。{{new}}個のモデルが追加されました。{{support}}個のモデルがこのサイトチャンネルでサポートされています",
|
||||
"sync-all": "すべて同期({{length}})",
|
||||
"sync-self": "サポートされているモデルを同期({{ length }})",
|
||||
"sync-site": "アップストリームアドレス"
|
||||
"sync-site": "アップストリームアドレス",
|
||||
"sync-option": "同期のオプション",
|
||||
"sync-failed": "同期に失敗しました",
|
||||
"sync-failed-prompt": "住所をリクエストできなかったか、モデルマーケットモデルが空です\n(エンドポイント:{{ endpoint }})",
|
||||
"sync-items": "合計{{length}}個のモデルが見つかりました。{{exist}}個のモデルが見つかりました(上書きされません)。{{new}}個のモデルが追加されました(すべて同期済み)。{{support}}個のモデルがこのサイトチャネルでサポートされています(同期対応モデル)",
|
||||
"sync-success": "同期成功",
|
||||
"sync-success-prompt": "アップストリームから同期され、{{length}}モデルが追加されました。確認して[更新]をクリックして保存してください。"
|
||||
},
|
||||
"model-chart-tip": "トークンの使用状況",
|
||||
"subscription": "サブスクリプション管理",
|
||||
|
@ -486,10 +486,15 @@
|
||||
"sync": "Синхронизация выше ПО потоку",
|
||||
"sync-tip": "Синхронизация рынков восходящих моделей",
|
||||
"sync-placeholder": "Введите API-адрес вышестоящего Chat Nio, например: https://api.chatnio.net",
|
||||
"sync-items": "Всего было найдено {{length}} моделей, {{exist}} моделей было найдено (не будет перезаписано), {{new}} моделей было добавлено, {{support}} моделей было поддержано в этом канале сайта",
|
||||
"sync-all": "Синхронизировать все ({{length}})",
|
||||
"sync-self": "Синхронизация поддерживаемых моделей ({{length}})",
|
||||
"sync-site": "Адрес выше по потоку"
|
||||
"sync-site": "Адрес выше по потоку",
|
||||
"sync-option": "Параметры синхронизации",
|
||||
"sync-failed": "Не удалось синхронизировать",
|
||||
"sync-failed-prompt": "Адрес не может быть запрошен или модель рынка пуста\n(Конечная точка: {{endpoint}})",
|
||||
"sync-items": "Всего найдено {{length}} моделей, {{exist}} моделей найдено (не будет перезаписано), {{new}} моделей добавлено (все синхронизировано), {{support}} моделей поддерживается этим каналом сайта (синхронизированные поддерживаемые модели)",
|
||||
"sync-success": "Успешная синхронизация",
|
||||
"sync-success-prompt": "Синхронизировано с вышестоящими, добавлено {{length}} моделей, проверьте и нажмите «Обновить», чтобы сохранить."
|
||||
},
|
||||
"model-chart-tip": "Использование токенов",
|
||||
"subscription": "Управление подписками",
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dispatch, useMemo, useReducer, useState } from "react";
|
||||
import { Model as RawModel } from "@/api/types.ts";
|
||||
import { supportModels } from "@/conf";
|
||||
import { allModels, supportModels } from "@/conf";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import {
|
||||
ChevronDown,
|
||||
@ -17,7 +17,7 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { generateRandomChar, isUrl } from "@/utils/base.ts";
|
||||
import { generateRandomChar, isUrl, resetJsArray } from "@/utils/base.ts";
|
||||
import Require from "@/components/Require.tsx";
|
||||
import { Textarea } from "@/components/ui/textarea.tsx";
|
||||
import { toast } from "sonner";
|
||||
@ -33,6 +33,25 @@ import { channelModels } from "@/admin/channel.ts";
|
||||
import { cn } from "@/components/ui/lib/utils.ts";
|
||||
import { marketEvent } from "@/events/market.ts";
|
||||
import PopupDialog from "@/components/PopupDialog.tsx";
|
||||
import {
|
||||
getApiCharge,
|
||||
getApiMarket,
|
||||
getApiModels,
|
||||
getV1Path,
|
||||
} from "@/api/v1.ts";
|
||||
import { useToast } from "@/components/ui/use-toast.ts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { ChargeProps, nonBilling } from "@/admin/charge.ts";
|
||||
import { loadPreferenceModels } from "@/conf/storage.ts";
|
||||
import { selectModel, setModel, setModelList } from "@/store/chat.ts";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
type Model = RawModel & {
|
||||
seed?: string;
|
||||
@ -59,6 +78,22 @@ function reducer(state: MarketForm, action: any): MarketForm {
|
||||
seed: generateSeed(),
|
||||
},
|
||||
];
|
||||
case "add-multiple":
|
||||
return [
|
||||
...state,
|
||||
...action.payload.map((model: RawModel) => ({
|
||||
id: model.id || "",
|
||||
name: model.name || "",
|
||||
free: false,
|
||||
auth: false,
|
||||
description: model.description || "",
|
||||
high_context: model.high_context || false,
|
||||
default: model.default || false,
|
||||
tag: model.tag || [],
|
||||
avatar: model.avatar || modelImages[0],
|
||||
seed: generateSeed(),
|
||||
})),
|
||||
];
|
||||
case "new":
|
||||
return [
|
||||
...state,
|
||||
@ -320,11 +355,324 @@ function MarketImage({ image, idx, dispatch }: MarketImageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type MarketItemProps = {
|
||||
model: Model;
|
||||
form: MarketForm;
|
||||
dispatch: Dispatch<any>;
|
||||
index: number;
|
||||
};
|
||||
|
||||
function MarketItem({ model, form, dispatch, index }: MarketItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const checked = useMemo(
|
||||
(): boolean => model.id.trim().length > 0 && model.name.trim().length > 0,
|
||||
[model],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("market-item", !checked && "error")}>
|
||||
<div className={`model-wrapper`}>
|
||||
<div className={`market-row`}>
|
||||
<span>
|
||||
<Require />
|
||||
{t("admin.market.model-name")}
|
||||
</span>
|
||||
<Input
|
||||
value={model.name}
|
||||
placeholder={t("admin.market.model-name-placeholder")}
|
||||
onChange={(e) => {
|
||||
dispatch({
|
||||
type: "update-name",
|
||||
payload: {
|
||||
idx: index,
|
||||
name: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>
|
||||
<Require />
|
||||
{t("admin.market.model-id")}
|
||||
</span>
|
||||
<Combobox
|
||||
value={model.id}
|
||||
onChange={(id: string) => {
|
||||
dispatch({
|
||||
type: "update-id",
|
||||
payload: { idx: index, id },
|
||||
});
|
||||
}}
|
||||
className={`model-combobox`}
|
||||
list={channelModels}
|
||||
placeholder={t("admin.market.model-id-placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>{t("admin.market.model-description")}</span>
|
||||
<Textarea
|
||||
value={model.description || ""}
|
||||
placeholder={t("admin.market.model-description-placeholder")}
|
||||
onChange={(e) => {
|
||||
dispatch({
|
||||
type: "update-description",
|
||||
payload: {
|
||||
idx: index,
|
||||
description: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>
|
||||
{t("admin.market.model-context")}
|
||||
<Tips content={t("admin.market.model-context-tip")} />
|
||||
</span>
|
||||
<Switch
|
||||
className={`ml-auto`}
|
||||
checked={model.high_context}
|
||||
onCheckedChange={(state) => {
|
||||
dispatch({
|
||||
type: "update-context",
|
||||
payload: {
|
||||
idx: index,
|
||||
context: state,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>
|
||||
{t("admin.market.model-is-default")}
|
||||
<Tips content={t("admin.market.model-is-default-tip")} />
|
||||
</span>
|
||||
<Switch
|
||||
className={`ml-auto`}
|
||||
checked={model.default}
|
||||
onCheckedChange={(state) => {
|
||||
dispatch({
|
||||
type: "update-default",
|
||||
payload: {
|
||||
idx: index,
|
||||
default: state,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>{t("admin.market.model-tag")}</span>
|
||||
<MarketTags tag={model.tag} idx={index} dispatch={dispatch} />
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<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: "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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SyncDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (state: boolean) => void;
|
||||
onConfirm: (form: MarketForm) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function SyncDialog({ open, setOpen, onConfirm }: SyncDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<MarketForm>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
const siteModels = useMemo(
|
||||
(): string[] => form.map((model) => model.id),
|
||||
[form],
|
||||
);
|
||||
const existModels = useMemo(
|
||||
(): string[] =>
|
||||
supportModels
|
||||
.filter((model) => siteModels.includes(model.id))
|
||||
.map((model) => model.id),
|
||||
[siteModels, supportModels],
|
||||
);
|
||||
const newModels = useMemo(
|
||||
(): string[] => siteModels.filter((model) => !existModels.includes(model)),
|
||||
[siteModels, existModels],
|
||||
);
|
||||
const newSupportedModels = useMemo(
|
||||
(): string[] => newModels.filter((model) => allModels.includes(model)),
|
||||
[newModels, allModels],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={form.length > 0}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (open) return;
|
||||
setOpen(false);
|
||||
setForm([]);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("admin.market.sync-option")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("admin.market.sync-items", {
|
||||
length: siteModels.length,
|
||||
exist: existModels.length,
|
||||
new: newModels.length,
|
||||
support: newSupportedModels.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant={`outline`}
|
||||
loading={true}
|
||||
onClick={async () => {
|
||||
const target = form.filter((model) =>
|
||||
newModels.includes(model.id),
|
||||
);
|
||||
if (await onConfirm(target)) {
|
||||
setForm([]);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
disabled={newModels.length === 0}
|
||||
>
|
||||
{t("admin.market.sync-all", { length: newModels.length })}
|
||||
</Button>
|
||||
<Button
|
||||
loading={true}
|
||||
onClick={async () => {
|
||||
const target = form.filter((model) =>
|
||||
newSupportedModels.includes(model.id),
|
||||
);
|
||||
if (await onConfirm(target)) {
|
||||
setForm([]);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
disabled={newSupportedModels.length === 0}
|
||||
>
|
||||
{t("admin.market.sync-self", {
|
||||
length: newSupportedModels.length,
|
||||
})}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PopupDialog
|
||||
title={t("admin.market.sync")}
|
||||
name={t("admin.market.sync-site")}
|
||||
placeholder={t("admin.market.sync-placeholder")}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
defaultValue={"https://api.chatnio.net"}
|
||||
onSubmit={async (endpoint: string) => {
|
||||
const raw = getV1Path("/v1/market", { endpoint });
|
||||
const resp = await getApiMarket({ endpoint });
|
||||
if (resp.length === 0) {
|
||||
toast({
|
||||
title: t("admin.market.sync-failed"),
|
||||
description: t("admin.market.sync-failed-prompt", {
|
||||
endpoint: raw,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setForm(resp);
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Market() {
|
||||
const { t } = useTranslation();
|
||||
const [form, dispatch] = useReducer(reducer, supportModels);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const model = useSelector(selectModel);
|
||||
|
||||
const globalDispatch = useDispatch();
|
||||
|
||||
const sync = async (): Promise<void> => {
|
||||
const market = await getApiMarket();
|
||||
const charge = await getApiCharge();
|
||||
|
||||
market.forEach((item: Model) => {
|
||||
const obj = charge.find((i: ChargeProps) => i.models.includes(item.id));
|
||||
if (!obj) return;
|
||||
|
||||
item.free = obj.type === nonBilling;
|
||||
item.auth = !item.free || !obj.anonymous;
|
||||
});
|
||||
|
||||
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 models = await getApiModels();
|
||||
models.forEach((model: string) => {
|
||||
if (!allModels.includes(model)) allModels.push(model);
|
||||
if (!channelModels.includes(model)) channelModels.push(model);
|
||||
});
|
||||
};
|
||||
|
||||
const update = async (): Promise<void> => {
|
||||
const preflight = form.filter(
|
||||
(model) => model.id.trim().length > 0 && model.name.trim().length > 0,
|
||||
@ -343,6 +691,12 @@ function Market() {
|
||||
toast(t("admin.market.update-success"), {
|
||||
description: t("admin.market.update-success-prompt"),
|
||||
});
|
||||
await sync();
|
||||
};
|
||||
|
||||
const migrate = async (data: RawModel[]): Promise<void> => {
|
||||
if (data.length === 0) return;
|
||||
dispatch({ type: "add-multiple", payload: [...data] });
|
||||
};
|
||||
|
||||
marketEvent.addEventListener((state: boolean) => {
|
||||
@ -350,199 +704,58 @@ function Market() {
|
||||
!state && dispatch({ type: "set", payload: [...supportModels] });
|
||||
});
|
||||
|
||||
const checked = (index: number) => {
|
||||
return useMemo((): boolean => {
|
||||
const model = form[index];
|
||||
|
||||
return model.id.trim().length > 0 && model.name.trim().length > 0;
|
||||
}, [form, index]);
|
||||
};
|
||||
|
||||
const [popupOpen, setPopupOpen] = useState<boolean>(false);
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className={`market`}>
|
||||
<PopupDialog
|
||||
title={t("admin.market.sync")}
|
||||
name={t("admin.market.sync-site")}
|
||||
placeholder={t("admin.market.sync-placeholder")}
|
||||
open={popupOpen}
|
||||
setOpen={setPopupOpen}
|
||||
defaultValue={"https://api.chatnio.net"}
|
||||
/>
|
||||
<SyncDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onConfirm={async (data: MarketForm) => {
|
||||
await migrate(data);
|
||||
toast(t("admin.market.sync-success"), {
|
||||
description: t("admin.market.sync-success-prompt", {
|
||||
length: data.length,
|
||||
}),
|
||||
});
|
||||
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
<Card className={`admin-card market-card`}>
|
||||
<CardHeader className={`flex flex-row items-center select-none`}>
|
||||
<CardTitle>
|
||||
{t("admin.market.title")}
|
||||
{loading && <Loader2 className={`h-4 w-4 ml-2 animate-spin`} />}
|
||||
{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={() => setPopupOpen(true)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{t("admin.market.sync")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`market-list`}>
|
||||
{form.map((model, index) => (
|
||||
<div className={cn("market-item", !checked(index) && "error")}>
|
||||
<div className={`model-wrapper`}>
|
||||
<div className={`market-row`}>
|
||||
<span>
|
||||
<Require />
|
||||
{t("admin.market.model-name")}
|
||||
</span>
|
||||
<Input
|
||||
value={model.name}
|
||||
placeholder={t("admin.market.model-name-placeholder")}
|
||||
onChange={(e) => {
|
||||
dispatch({
|
||||
type: "update-name",
|
||||
payload: {
|
||||
idx: index,
|
||||
name: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>
|
||||
<Require />
|
||||
{t("admin.market.model-id")}
|
||||
</span>
|
||||
<Combobox
|
||||
value={model.id}
|
||||
onChange={(id: string) => {
|
||||
dispatch({
|
||||
type: "update-id",
|
||||
payload: { idx: index, id },
|
||||
});
|
||||
}}
|
||||
className={`model-combobox`}
|
||||
list={channelModels}
|
||||
placeholder={t("admin.market.model-id-placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>{t("admin.market.model-description")}</span>
|
||||
<Textarea
|
||||
value={model.description || ""}
|
||||
placeholder={t(
|
||||
"admin.market.model-description-placeholder",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
dispatch({
|
||||
type: "update-description",
|
||||
payload: {
|
||||
idx: index,
|
||||
description: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>
|
||||
{t("admin.market.model-context")}
|
||||
<Tips content={t("admin.market.model-context-tip")} />
|
||||
</span>
|
||||
<Switch
|
||||
className={`ml-auto`}
|
||||
checked={model.high_context}
|
||||
onCheckedChange={(state) => {
|
||||
dispatch({
|
||||
type: "update-context",
|
||||
payload: {
|
||||
idx: index,
|
||||
context: state,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>
|
||||
{t("admin.market.model-is-default")}
|
||||
<Tips content={t("admin.market.model-is-default-tip")} />
|
||||
</span>
|
||||
<Switch
|
||||
className={`ml-auto`}
|
||||
checked={model.default}
|
||||
onCheckedChange={(state) => {
|
||||
dispatch({
|
||||
type: "update-default",
|
||||
payload: {
|
||||
idx: index,
|
||||
default: state,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<span>{t("admin.market.model-tag")}</span>
|
||||
<MarketTags
|
||||
tag={model.tag}
|
||||
idx={index}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
</div>
|
||||
<div className={`market-row`}>
|
||||
<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: "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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{form.length > 0 ? (
|
||||
form.map((model, index) => (
|
||||
<MarketItem
|
||||
key={index}
|
||||
model={model}
|
||||
form={form}
|
||||
dispatch={dispatch}
|
||||
index={index}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className={`align-center text-sm empty`}>{t("admin.empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`market-footer flex flex-row items-center mt-4`}>
|
||||
<div className={`grow`} />
|
||||
|
Loading…
Reference in New Issue
Block a user