mirror of
https://github.com/coaidev/coai.git
synced 2025-05-21 22:10:12 +09:00
feat: support channel one-click sync upstream (#52)
This commit is contained in:
parent
e2b2cd2325
commit
5e8d05a9f5
@ -12,18 +12,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-table {
|
.channel-table {
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
|
|
||||||
.channel-id {
|
.channel-id {
|
||||||
color: hsl(var(--text-secondary));
|
color: hsl(var(--text-secondary));
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: hsl(var(--border-hover));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,15 +1,110 @@
|
|||||||
import { useState } from "react";
|
import { useReducer, useState } from "react";
|
||||||
import ChannelTable from "@/components/admin/assemblies/ChannelTable.tsx";
|
import ChannelTable from "@/components/admin/assemblies/ChannelTable.tsx";
|
||||||
import ChannelEditor from "@/components/admin/assemblies/ChannelEditor.tsx";
|
import ChannelEditor from "@/components/admin/assemblies/ChannelEditor.tsx";
|
||||||
|
import { Channel, getChannelInfo } from "@/admin/channel.ts";
|
||||||
|
|
||||||
|
const initialState: Channel = {
|
||||||
|
id: -1,
|
||||||
|
type: "openai",
|
||||||
|
name: "",
|
||||||
|
models: [],
|
||||||
|
priority: 0,
|
||||||
|
weight: 1,
|
||||||
|
retry: 3,
|
||||||
|
secret: "",
|
||||||
|
endpoint: getChannelInfo().endpoint,
|
||||||
|
mapper: "",
|
||||||
|
state: true,
|
||||||
|
group: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: Channel, action: any): Channel {
|
||||||
|
switch (action.type) {
|
||||||
|
case "type":
|
||||||
|
const isChanged =
|
||||||
|
getChannelInfo(state.type).endpoint !== state.endpoint &&
|
||||||
|
state.endpoint.trim() !== "";
|
||||||
|
const endpoint = isChanged
|
||||||
|
? state.endpoint
|
||||||
|
: getChannelInfo(action.value).endpoint;
|
||||||
|
return { ...state, endpoint, type: action.value };
|
||||||
|
case "name":
|
||||||
|
return { ...state, name: action.value };
|
||||||
|
case "models":
|
||||||
|
return { ...state, models: action.value };
|
||||||
|
case "add-model":
|
||||||
|
if (state.models.includes(action.value) || action.value === "") {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return { ...state, models: [...state.models, action.value] };
|
||||||
|
case "add-models":
|
||||||
|
const models = action.value.filter(
|
||||||
|
(model: string) => !state.models.includes(model) && model !== "",
|
||||||
|
);
|
||||||
|
return { ...state, models: [...state.models, ...models] };
|
||||||
|
case "remove-model":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
models: state.models.filter((model) => model !== action.value),
|
||||||
|
};
|
||||||
|
case "clear-models":
|
||||||
|
return { ...state, models: [] };
|
||||||
|
case "priority":
|
||||||
|
return { ...state, priority: action.value };
|
||||||
|
case "weight":
|
||||||
|
return { ...state, weight: action.value };
|
||||||
|
case "secret":
|
||||||
|
return { ...state, secret: action.value };
|
||||||
|
case "endpoint":
|
||||||
|
return { ...state, endpoint: action.value };
|
||||||
|
case "mapper":
|
||||||
|
return { ...state, mapper: action.value };
|
||||||
|
case "retry":
|
||||||
|
return { ...state, retry: action.value };
|
||||||
|
case "clear":
|
||||||
|
return { ...initialState };
|
||||||
|
case "add-group":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
group: state.group ? [...state.group, action.value] : [action.value],
|
||||||
|
};
|
||||||
|
case "remove-group":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
group: state.group
|
||||||
|
? state.group.filter((group) => group !== action.value)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
case "set-group":
|
||||||
|
return { ...state, group: action.value };
|
||||||
|
case "set":
|
||||||
|
return { ...state, ...action.value };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ChannelSettings() {
|
function ChannelSettings() {
|
||||||
const [enabled, setEnabled] = useState<boolean>(false);
|
const [enabled, setEnabled] = useState<boolean>(false);
|
||||||
const [id, setId] = useState<number>(-1);
|
const [id, setId] = useState<number>(-1);
|
||||||
|
|
||||||
|
const [edit, dispatch] = useReducer(reducer, { ...initialState });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ChannelTable setEnabled={setEnabled} setId={setId} display={!enabled} />
|
<ChannelTable
|
||||||
<ChannelEditor setEnabled={setEnabled} id={id} display={enabled} />
|
setEnabled={setEnabled}
|
||||||
|
setId={setId}
|
||||||
|
display={!enabled}
|
||||||
|
dispatch={dispatch}
|
||||||
|
/>
|
||||||
|
<ChannelEditor
|
||||||
|
setEnabled={setEnabled}
|
||||||
|
id={id}
|
||||||
|
display={enabled}
|
||||||
|
edit={edit}
|
||||||
|
dispatch={dispatch}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -434,7 +434,7 @@ function ChargeTable({ data, dispatch, onRefresh, loading }: ChargeTableProps) {
|
|||||||
<TableRow key={idx}>
|
<TableRow key={idx}>
|
||||||
<TableCell>{charge.id}</TableCell>
|
<TableCell>{charge.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge>{charge.type.split("-")[0]}</Badge>
|
<Badge>{t(`admin.charge.${charge.type}`)}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<pre>{charge.models.join("\n")}</pre>
|
<pre>{charge.models.join("\n")}</pre>
|
||||||
|
@ -20,7 +20,7 @@ import { Textarea } from "@/components/ui/textarea.tsx";
|
|||||||
import { NumberInput } from "@/components/ui/number-input.tsx";
|
import { NumberInput } from "@/components/ui/number-input.tsx";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMemo, useReducer, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Required from "@/components/Require.tsx";
|
import Required from "@/components/Require.tsx";
|
||||||
import { Loader2, Plus, Search, X } from "lucide-react";
|
import { Loader2, Plus, Search, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@ -48,21 +48,6 @@ import Paragraph, {
|
|||||||
} from "@/components/Paragraph.tsx";
|
} from "@/components/Paragraph.tsx";
|
||||||
import { MultiCombobox } from "@/components/ui/multi-combobox.tsx";
|
import { MultiCombobox } from "@/components/ui/multi-combobox.tsx";
|
||||||
|
|
||||||
const initialState: Channel = {
|
|
||||||
id: -1,
|
|
||||||
type: "openai",
|
|
||||||
name: "",
|
|
||||||
models: [],
|
|
||||||
priority: 0,
|
|
||||||
weight: 1,
|
|
||||||
retry: 3,
|
|
||||||
secret: "",
|
|
||||||
endpoint: getChannelInfo().endpoint,
|
|
||||||
mapper: "",
|
|
||||||
state: true,
|
|
||||||
group: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
type CustomActionProps = {
|
type CustomActionProps = {
|
||||||
onPost: (model: string) => void;
|
onPost: (model: string) => void;
|
||||||
};
|
};
|
||||||
@ -95,72 +80,6 @@ function CustomAction({ onPost }: CustomActionProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reducer(state: Channel, action: any) {
|
|
||||||
switch (action.type) {
|
|
||||||
case "type":
|
|
||||||
const isChanged =
|
|
||||||
getChannelInfo(state.type).endpoint !== state.endpoint &&
|
|
||||||
state.endpoint.trim() !== "";
|
|
||||||
const endpoint = isChanged
|
|
||||||
? state.endpoint
|
|
||||||
: getChannelInfo(action.value).endpoint;
|
|
||||||
return { ...state, endpoint, type: action.value };
|
|
||||||
case "name":
|
|
||||||
return { ...state, name: action.value };
|
|
||||||
case "models":
|
|
||||||
return { ...state, models: action.value };
|
|
||||||
case "add-model":
|
|
||||||
if (state.models.includes(action.value) || action.value === "") {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return { ...state, models: [...state.models, action.value] };
|
|
||||||
case "add-models":
|
|
||||||
const models = action.value.filter(
|
|
||||||
(model: string) => !state.models.includes(model) && model !== "",
|
|
||||||
);
|
|
||||||
return { ...state, models: [...state.models, ...models] };
|
|
||||||
case "remove-model":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
models: state.models.filter((model) => model !== action.value),
|
|
||||||
};
|
|
||||||
case "clear-models":
|
|
||||||
return { ...state, models: [] };
|
|
||||||
case "priority":
|
|
||||||
return { ...state, priority: action.value };
|
|
||||||
case "weight":
|
|
||||||
return { ...state, weight: action.value };
|
|
||||||
case "secret":
|
|
||||||
return { ...state, secret: action.value };
|
|
||||||
case "endpoint":
|
|
||||||
return { ...state, endpoint: action.value };
|
|
||||||
case "mapper":
|
|
||||||
return { ...state, mapper: action.value };
|
|
||||||
case "retry":
|
|
||||||
return { ...state, retry: action.value };
|
|
||||||
case "clear":
|
|
||||||
return { ...initialState };
|
|
||||||
case "add-group":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
group: state.group ? [...state.group, action.value] : [action.value],
|
|
||||||
};
|
|
||||||
case "remove-group":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
group: state.group
|
|
||||||
? state.group.filter((group) => group !== action.value)
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
case "set-group":
|
|
||||||
return { ...state, group: action.value };
|
|
||||||
case "set":
|
|
||||||
return { ...state, ...action.value };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validator(state: Channel): boolean {
|
function validator(state: Channel): boolean {
|
||||||
return (
|
return (
|
||||||
state.name.trim() !== "" &&
|
state.name.trim() !== "" &&
|
||||||
@ -202,11 +121,18 @@ type ChannelEditorProps = {
|
|||||||
display: boolean;
|
display: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
setEnabled: (enabled: boolean) => void;
|
setEnabled: (enabled: boolean) => void;
|
||||||
|
edit: Channel;
|
||||||
|
dispatch: (action: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ChannelEditor({ display, id, setEnabled }: ChannelEditorProps) {
|
function ChannelEditor({
|
||||||
|
display,
|
||||||
|
id,
|
||||||
|
edit,
|
||||||
|
dispatch,
|
||||||
|
setEnabled,
|
||||||
|
}: ChannelEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [edit, dispatch] = useReducer(reducer, { ...initialState });
|
|
||||||
const info = useMemo(() => getChannelInfo(edit.type), [edit.type]);
|
const info = useMemo(() => getChannelInfo(edit.type), [edit.type]);
|
||||||
const unusedModels = useMemo(() => {
|
const unusedModels = useMemo(() => {
|
||||||
return channelModels.filter(
|
return channelModels.filter(
|
||||||
|
@ -6,10 +6,18 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table.tsx";
|
} from "@/components/ui/table.tsx";
|
||||||
import { Badge } from "@/components/ui/badge.tsx";
|
import { Badge } from "@/components/ui/badge.tsx";
|
||||||
import { Check, Plus, RotateCw, Settings2, Trash, X } from "lucide-react";
|
import {
|
||||||
|
Activity,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
RotateCw,
|
||||||
|
Settings2,
|
||||||
|
Trash,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import OperationAction from "@/components/OperationAction.tsx";
|
import OperationAction from "@/components/OperationAction.tsx";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { Dispatch, useEffect, useMemo, useState } from "react";
|
||||||
import { Channel, getChannelType } from "@/admin/channel.ts";
|
import { Channel, getChannelType } from "@/admin/channel.ts";
|
||||||
import { toastState } from "@/admin/utils.ts";
|
import { toastState } from "@/admin/utils.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -22,9 +30,13 @@ import {
|
|||||||
} from "@/admin/api/channel.ts";
|
} from "@/admin/api/channel.ts";
|
||||||
import { useToast } from "@/components/ui/use-toast.ts";
|
import { useToast } from "@/components/ui/use-toast.ts";
|
||||||
import { cn } from "@/components/ui/lib/utils.ts";
|
import { cn } from "@/components/ui/lib/utils.ts";
|
||||||
|
import PopupDialog from "@/components/PopupDialog.tsx";
|
||||||
|
import { getApiModels, getV1Path } from "@/api/v1.ts";
|
||||||
|
import { getHostName } from "@/utils/base.ts";
|
||||||
|
|
||||||
type ChannelTableProps = {
|
type ChannelTableProps = {
|
||||||
display: boolean;
|
display: boolean;
|
||||||
|
dispatch: Dispatch<any>;
|
||||||
setId: (id: number) => void;
|
setId: (id: number) => void;
|
||||||
setEnabled: (enabled: boolean) => void;
|
setEnabled: (enabled: boolean) => void;
|
||||||
};
|
};
|
||||||
@ -43,11 +55,75 @@ function TypeBadge({ type }: TypeBadgeProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelTable({ display, setId, setEnabled }: ChannelTableProps) {
|
type SyncDialogProps = {
|
||||||
|
dispatch: Dispatch<any>;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SyncDialog({ dispatch, open, setOpen }: SyncDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const submit = async (endpoint: string): Promise<boolean> => {
|
||||||
|
endpoint = endpoint.trim();
|
||||||
|
endpoint.endsWith("/") && (endpoint = endpoint.slice(0, -1));
|
||||||
|
|
||||||
|
const path = getV1Path("/v1/models", { endpoint });
|
||||||
|
const models = await getApiModels({ endpoint });
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: t("admin.channels.sync-failed"),
|
||||||
|
description: t("admin.channels.sync-failed-prompt", { endpoint: path }),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = getHostName(endpoint).replace(/\./g, "-");
|
||||||
|
const data: Channel = {
|
||||||
|
id: -1,
|
||||||
|
name,
|
||||||
|
type: "openai",
|
||||||
|
models,
|
||||||
|
priority: 0,
|
||||||
|
weight: 1,
|
||||||
|
retry: 3,
|
||||||
|
secret: "",
|
||||||
|
endpoint,
|
||||||
|
mapper: "",
|
||||||
|
state: true,
|
||||||
|
group: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch({ type: "set", value: data });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopupDialog
|
||||||
|
title={t("admin.channels.joint")}
|
||||||
|
name={t("admin.channels.joint-endpoint")}
|
||||||
|
placeholder={t("admin.channels.joint-endpoint-placeholder")}
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
defaultValue={"https://api.chatnio.net"}
|
||||||
|
onSubmit={submit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelTable({
|
||||||
|
display,
|
||||||
|
dispatch,
|
||||||
|
setId,
|
||||||
|
setEnabled,
|
||||||
|
}: ChannelTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [data, setData] = useState<Channel[]>([]);
|
const [data, setData] = useState<Channel[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -65,8 +141,46 @@ function ChannelTable({ display, setId, setEnabled }: ChannelTableProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
display && (
|
display && (
|
||||||
<div className={`channel-table`}>
|
<div>
|
||||||
<Table>
|
<SyncDialog
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
dispatch={(action) => {
|
||||||
|
dispatch(action);
|
||||||
|
setEnabled(true);
|
||||||
|
setId(-1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={`flex flex-row w-full h-max`}>
|
||||||
|
<Button
|
||||||
|
className={`mr-2`}
|
||||||
|
onClick={() => {
|
||||||
|
setEnabled(true);
|
||||||
|
setId(-1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className={`h-4 w-4 mr-1`} />
|
||||||
|
{t("admin.channels.create")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={`mr-2`}
|
||||||
|
variant={`outline`}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Activity className={`h-4 w-4 mr-1`} />
|
||||||
|
{t("admin.channels.joint")}
|
||||||
|
</Button>
|
||||||
|
<div className={`grow`} />
|
||||||
|
<Button
|
||||||
|
variant={`outline`}
|
||||||
|
size={`icon`}
|
||||||
|
className={`mr-2`}
|
||||||
|
onClick={refresh}
|
||||||
|
>
|
||||||
|
<RotateCw className={cn(`h-4 w-4`, loading && `animate-spin`)} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table className={`channel-table mt-4`}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className={`select-none whitespace-nowrap`}>
|
<TableRow className={`select-none whitespace-nowrap`}>
|
||||||
<TableCell>{t("admin.channels.id")}</TableCell>
|
<TableCell>{t("admin.channels.id")}</TableCell>
|
||||||
@ -147,26 +261,6 @@ function ChannelTable({ display, setId, setEnabled }: ChannelTableProps) {
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<div className={`mt-6 pr-2 flex flex-row w-full h-max`}>
|
|
||||||
<div className={`grow`} />
|
|
||||||
<Button
|
|
||||||
variant={`outline`}
|
|
||||||
size={`icon`}
|
|
||||||
className={`mr-2`}
|
|
||||||
onClick={refresh}
|
|
||||||
>
|
|
||||||
<RotateCw className={cn(`h-4 w-4`, loading && `animate-spin`)} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setEnabled(true);
|
|
||||||
setId(-1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className={`h-4 w-4 mr-1`} />
|
|
||||||
{t("admin.channels.create")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -493,6 +493,15 @@
|
|||||||
"disable": "禁用渠道",
|
"disable": "禁用渠道",
|
||||||
"delete": "删除渠道",
|
"delete": "删除渠道",
|
||||||
"create": "创建渠道",
|
"create": "创建渠道",
|
||||||
|
"joint": "对接上游",
|
||||||
|
"joint-endpoint": "上游地址",
|
||||||
|
"joint-endpoint-placeholder": "请输入上游 Chat Nio 的 API 地址,如:https://api.chatnio.net",
|
||||||
|
"joint-secret": "API 秘钥",
|
||||||
|
"joint-secret-placeholder": "请输入上游 Chat Nio 的 API 秘钥",
|
||||||
|
"sync-failed": "同步失败",
|
||||||
|
"sync-failed-prompt": "地址无法请求或者模型市场模型为空\n(端点:{{endpoint}})",
|
||||||
|
"sync-success": "同步成功",
|
||||||
|
"sync-success-prompt": "已从上游同步添加 {{length}} 个模型。",
|
||||||
"search-model": "搜索模型",
|
"search-model": "搜索模型",
|
||||||
"fill-template-models": "填入模板模型 ({{number}} 个)",
|
"fill-template-models": "填入模板模型 ({{number}} 个)",
|
||||||
"add-custom-model": "添加自定义模型 (多个模型用空格分隔)",
|
"add-custom-model": "添加自定义模型 (多个模型用空格分隔)",
|
||||||
|
@ -392,7 +392,16 @@
|
|||||||
"basic": "Basic Subscribers",
|
"basic": "Basic Subscribers",
|
||||||
"standard": "Standard Subscribers",
|
"standard": "Standard Subscribers",
|
||||||
"pro": "Pro Subscribers"
|
"pro": "Pro Subscribers"
|
||||||
}
|
},
|
||||||
|
"joint": "Dock upstream",
|
||||||
|
"joint-endpoint": "Upstream address",
|
||||||
|
"joint-endpoint-placeholder": "Please enter the API address of the upstream Chat Nio, for example: https://api.chatnio.net",
|
||||||
|
"joint-secret": "API keys",
|
||||||
|
"joint-secret-placeholder": "Please enter the API key for upstream Chat Nio",
|
||||||
|
"sync-failed": "Sync Failed",
|
||||||
|
"sync-failed-prompt": "Address could not be requested or model market model is empty\n(Endpoint: {{endpoint}})",
|
||||||
|
"sync-success": "Sync successfully.",
|
||||||
|
"sync-success-prompt": "{{length}} models were added from upstream synchronization."
|
||||||
},
|
},
|
||||||
"charge": {
|
"charge": {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
|
@ -392,7 +392,16 @@
|
|||||||
"basic": "ベーシックサブスクライバー",
|
"basic": "ベーシックサブスクライバー",
|
||||||
"standard": "標準サブスクライバー",
|
"standard": "標準サブスクライバー",
|
||||||
"pro": "Pro Subscribers"
|
"pro": "Pro Subscribers"
|
||||||
}
|
},
|
||||||
|
"joint": "上流にドッキング",
|
||||||
|
"joint-endpoint": "アップストリームアドレス",
|
||||||
|
"joint-endpoint-placeholder": "アップストリームのChat NioのAPIアドレスを入力してください。例: https://api.chatnio.net",
|
||||||
|
"joint-secret": "APIキー",
|
||||||
|
"joint-secret-placeholder": "アップストリームのチャットNioのAPIキーを入力してください",
|
||||||
|
"sync-failed": "同期に失敗しました",
|
||||||
|
"sync-failed-prompt": "住所をリクエストできなかったか、モデルマーケットモデルが空です\n(エンドポイント:{{ endpoint }})",
|
||||||
|
"sync-success": "同期成功",
|
||||||
|
"sync-success-prompt": "{{length}}モデルがアップストリーム同期から追加されました。"
|
||||||
},
|
},
|
||||||
"charge": {
|
"charge": {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
|
@ -392,7 +392,16 @@
|
|||||||
"basic": "Базовые подписчики",
|
"basic": "Базовые подписчики",
|
||||||
"standard": "Стандартные подписчики",
|
"standard": "Стандартные подписчики",
|
||||||
"pro": "Подписчики Pro"
|
"pro": "Подписчики Pro"
|
||||||
}
|
},
|
||||||
|
"joint": "Док-станция выше по течению",
|
||||||
|
"joint-endpoint": "Адрес выше по потоку",
|
||||||
|
"joint-endpoint-placeholder": "Введите API-адрес вышестоящего Chat Nio, например: https://api.chatnio.net",
|
||||||
|
"joint-secret": "Ключ API",
|
||||||
|
"joint-secret-placeholder": "Пожалуйста, введите ключ API для восходящего чата Nio",
|
||||||
|
"sync-failed": "Не удалось синхронизировать",
|
||||||
|
"sync-failed-prompt": "Адрес не может быть запрошен или модель рынка пуста\n(Конечная точка: {{endpoint}})",
|
||||||
|
"sync-success": "Успешная синхронизация",
|
||||||
|
"sync-success-prompt": "{{length}} модели были добавлены из синхронизации восходящего потока."
|
||||||
},
|
},
|
||||||
"charge": {
|
"charge": {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
|
@ -95,3 +95,11 @@ export function getSizeUnit(size: number): string {
|
|||||||
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} MB`;
|
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} MB`;
|
||||||
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getHostName(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user