feat: support model market sync upstream (#50)

This commit is contained in:
Zhang Minghan 2024-01-22 20:14:46 +08:00
parent 2d41885cd6
commit 9a501d5d2b
9 changed files with 454 additions and 195 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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>

View File

@ -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"}

View File

@ -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": "更新成功",

View File

@ -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",

View File

@ -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": "サブスクリプション管理",

View File

@ -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": "Управление подписками",

View File

@ -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`} />