diff --git a/app/src/assets/admin/channel.less b/app/src/assets/admin/channel.less index 27f426e..efadc49 100644 --- a/app/src/assets/admin/channel.less +++ b/app/src/assets/admin/channel.less @@ -12,18 +12,9 @@ } .channel-table { - border-radius: var(--radius); - overflow: hidden; - border: 1px solid hsl(var(--border)); - padding-bottom: 0.5rem; - .channel-id { color: hsl(var(--text-secondary)); } - - &:hover { - border-color: hsl(var(--border-hover)); - } } } diff --git a/app/src/components/admin/ChannelSettings.tsx b/app/src/components/admin/ChannelSettings.tsx index 5ce5d10..281b517 100644 --- a/app/src/components/admin/ChannelSettings.tsx +++ b/app/src/components/admin/ChannelSettings.tsx @@ -1,15 +1,110 @@ -import { useState } from "react"; +import { useReducer, useState } from "react"; import ChannelTable from "@/components/admin/assemblies/ChannelTable.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() { const [enabled, setEnabled] = useState(false); const [id, setId] = useState(-1); + const [edit, dispatch] = useReducer(reducer, { ...initialState }); + return ( <> - - + + ); } diff --git a/app/src/components/admin/ChargeWidget.tsx b/app/src/components/admin/ChargeWidget.tsx index 2662d10..ed7f783 100644 --- a/app/src/components/admin/ChargeWidget.tsx +++ b/app/src/components/admin/ChargeWidget.tsx @@ -434,7 +434,7 @@ function ChargeTable({ data, dispatch, onRefresh, loading }: ChargeTableProps) { {charge.id} - {charge.type.split("-")[0]} + {t(`admin.charge.${charge.type}`)}
{charge.models.join("\n")}
diff --git a/app/src/components/admin/assemblies/ChannelEditor.tsx b/app/src/components/admin/assemblies/ChannelEditor.tsx index 01f9ae9..783e6e3 100644 --- a/app/src/components/admin/assemblies/ChannelEditor.tsx +++ b/app/src/components/admin/assemblies/ChannelEditor.tsx @@ -20,7 +20,7 @@ import { Textarea } from "@/components/ui/textarea.tsx"; import { NumberInput } from "@/components/ui/number-input.tsx"; import { Button } from "@/components/ui/button.tsx"; import { useTranslation } from "react-i18next"; -import { useMemo, useReducer, useState } from "react"; +import { useMemo, useState } from "react"; import Required from "@/components/Require.tsx"; import { Loader2, Plus, Search, X } from "lucide-react"; import { @@ -48,21 +48,6 @@ import Paragraph, { } from "@/components/Paragraph.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 = { 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 { return ( state.name.trim() !== "" && @@ -202,11 +121,18 @@ type ChannelEditorProps = { display: boolean; id: number; 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 [edit, dispatch] = useReducer(reducer, { ...initialState }); const info = useMemo(() => getChannelInfo(edit.type), [edit.type]); const unusedModels = useMemo(() => { return channelModels.filter( diff --git a/app/src/components/admin/assemblies/ChannelTable.tsx b/app/src/components/admin/assemblies/ChannelTable.tsx index 994895c..195a2e0 100644 --- a/app/src/components/admin/assemblies/ChannelTable.tsx +++ b/app/src/components/admin/assemblies/ChannelTable.tsx @@ -6,10 +6,18 @@ import { TableRow, } from "@/components/ui/table.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 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 { toastState } from "@/admin/utils.ts"; import { useTranslation } from "react-i18next"; @@ -22,9 +30,13 @@ import { } from "@/admin/api/channel.ts"; import { useToast } from "@/components/ui/use-toast.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 = { display: boolean; + dispatch: Dispatch; setId: (id: number) => void; setEnabled: (enabled: boolean) => void; }; @@ -43,11 +55,75 @@ function TypeBadge({ type }: TypeBadgeProps) { ); } -function ChannelTable({ display, setId, setEnabled }: ChannelTableProps) { +type SyncDialogProps = { + dispatch: Dispatch; + open: boolean; + setOpen: (open: boolean) => void; +}; + +function SyncDialog({ dispatch, open, setOpen }: SyncDialogProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + + const submit = async (endpoint: string): Promise => { + 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 ( + + ); +} + +function ChannelTable({ + display, + dispatch, + setId, + setEnabled, +}: ChannelTableProps) { const { t } = useTranslation(); const { toast } = useToast(); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); const refresh = async () => { setLoading(true); @@ -65,8 +141,46 @@ function ChannelTable({ display, setId, setEnabled }: ChannelTableProps) { return ( display && ( -
- +
+ { + dispatch(action); + setEnabled(true); + setId(-1); + }} + /> +
+ + +
+ +
+
{t("admin.channels.id")} @@ -147,26 +261,6 @@ function ChannelTable({ display, setId, setEnabled }: ChannelTableProps) { ))}
-
-
- - -
) ); diff --git a/app/src/resources/i18n/cn.json b/app/src/resources/i18n/cn.json index 7d56ba2..ba44818 100644 --- a/app/src/resources/i18n/cn.json +++ b/app/src/resources/i18n/cn.json @@ -493,6 +493,15 @@ "disable": "禁用渠道", "delete": "删除渠道", "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": "搜索模型", "fill-template-models": "填入模板模型 ({{number}} 个)", "add-custom-model": "添加自定义模型 (多个模型用空格分隔)", diff --git a/app/src/resources/i18n/en.json b/app/src/resources/i18n/en.json index 0cd5a6a..9f1e588 100644 --- a/app/src/resources/i18n/en.json +++ b/app/src/resources/i18n/en.json @@ -392,7 +392,16 @@ "basic": "Basic Subscribers", "standard": "Standard 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": { "id": "ID", diff --git a/app/src/resources/i18n/ja.json b/app/src/resources/i18n/ja.json index e31cd91..518f541 100644 --- a/app/src/resources/i18n/ja.json +++ b/app/src/resources/i18n/ja.json @@ -392,7 +392,16 @@ "basic": "ベーシックサブスクライバー", "standard": "標準サブスクライバー", "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": { "id": "ID", diff --git a/app/src/resources/i18n/ru.json b/app/src/resources/i18n/ru.json index 15bbf7f..77a3e1a 100644 --- a/app/src/resources/i18n/ru.json +++ b/app/src/resources/i18n/ru.json @@ -392,7 +392,16 @@ "basic": "Базовые подписчики", "standard": "Стандартные подписчики", "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": { "id": "ID", diff --git a/app/src/utils/base.ts b/app/src/utils/base.ts index 9dcd059..fe73c0a 100644 --- a/app/src/utils/base.ts +++ b/app/src/utils/base.ts @@ -95,3 +95,11 @@ export function getSizeUnit(size: number): string { if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} MB`; return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`; } + +export function getHostName(url: string): string { + try { + return new URL(url).hostname; + } catch { + return ""; + } +}