"use client";
import { useState, useEffect } from "react";
import { Table, message, Tooltip } from "antd";
import {
DownloadOutlined,
ExperimentOutlined,
CheckOutlined,
CloseOutlined,
InfoCircleOutlined,
UpOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import { Progress } from "antd";
import { toast, Toaster } from "sonner";
import { EditableCell } from "@/components/editable-cell";
interface ModelResponse {
id: string;
name: string;
base_model_id: string;
system_prompt: string;
imageUrl: string;
input_price: number;
output_price: number;
per_msg_price: number;
}
interface Model {
id: string;
name: string;
base_model_id: string;
system_prompt: string;
imageUrl: string;
input_price: number;
output_price: number;
per_msg_price: number;
testStatus?: "success" | "error" | "testing";
syncStatus?: "syncing" | "success" | "error";
}
const TestStatusIndicator = ({ status }: { status: Model["testStatus"] }) => {
if (!status) return null;
const variants = {
testing: {
container: "bg-blue-100",
icon: "w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin",
},
success: {
container: "bg-green-100",
icon: "text-[10px] text-green-500",
},
error: {
container: "bg-red-100",
icon: "text-[10px] text-red-500",
},
};
const variant = variants[status];
return (
{status === "testing" ? (
) : status === "success" ? (
) : (
)}
);
};
const TestProgressPanel = ({
isVisible,
models,
isComplete,
t,
}: {
isVisible: boolean;
models: Model[];
isComplete: boolean;
t: (key: string) => string;
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const successCount = models.filter((m) => m.testStatus === "success").length;
const errorCount = models.filter((m) => m.testStatus === "error").length;
const testingCount = models.filter((m) => m.testStatus === "testing").length;
const totalCount = models.length;
const progress = Math.round(((successCount + errorCount) / totalCount) * 100);
useEffect(() => {
if (testingCount > 0) {
setIsExpanded(true);
}
}, [testingCount]);
return (
{isVisible && (
setIsExpanded(!isExpanded)}
>
{isComplete
? t("models.testComplete")
: t("models.testingModels")}
0
? "testing"
: isComplete
? "success"
: "error"
}
/>
{isExpanded && (
{successCount}
{t("models.testSuccess")}
{errorCount}
{t("models.testFailed")}
{testingCount}
{t("models.testing")}
{models.map((model) => (
))}
)}
)}
);
};
const LoadingState = ({ t }: { t: (key: string) => string }) => (
);
export default function ModelsPage() {
const { t } = useTranslation("common");
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingCell, setEditingCell] = useState<{
id: string;
field: "input_price" | "output_price" | "per_msg_price";
} | null>(null);
const [testing, setTesting] = useState(false);
const [apiKey, setApiKey] = useState(null);
const [isTestComplete, setIsTestComplete] = useState(false);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
const fetchModels = async () => {
try {
const token = localStorage.getItem("access_token");
const response = await fetch("/api/v1/models", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(t("error.model.failToFetchModels"));
}
const data = (await response.json()) as ModelResponse[];
setModels(
data.map((model: ModelResponse) => ({
...model,
input_price: model.input_price ?? 60,
output_price: model.output_price ?? 60,
per_msg_price: model.per_msg_price ?? -1,
}))
);
} catch (err) {
setError(
err instanceof Error ? err.message : t("error.model.unknownError")
);
} finally {
setLoading(false);
}
};
fetchModels();
}, []);
useEffect(() => {
const fetchApiKey = async () => {
try {
const token = localStorage.getItem("access_token");
const response = await fetch("/api/v1/config/key", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(
`${t("error.model.failToFetchApiKey")}: ${response.status}`
);
}
const data = await response.json();
if (!data.apiKey) {
throw new Error(t("error.model.ApiKeyNotConfigured"));
}
setApiKey(data.apiKey);
} catch (error) {
console.error(t("error.model.failToFetchApiKey"), error);
message.error(
error instanceof Error
? error.message
: t("error.model.failToFetchApiKey")
);
}
};
fetchApiKey();
}, []);
const handlePriceUpdate = async (
id: string,
field: "input_price" | "output_price" | "per_msg_price",
value: number
): Promise => {
try {
const model = models.find((m) => m.id === id);
if (!model) return;
const validValue = Number(value);
if (
field !== "per_msg_price" &&
(!isFinite(validValue) || validValue < 0)
) {
throw new Error(t("error.model.nonePositiveNumber"));
}
if (field === "per_msg_price" && !isFinite(validValue)) {
throw new Error(t("error.model.invalidNumber"));
}
const input_price =
field === "input_price" ? validValue : model.input_price;
const output_price =
field === "output_price" ? validValue : model.output_price;
const per_msg_price =
field === "per_msg_price" ? validValue : model.per_msg_price;
const token = localStorage.getItem("access_token");
const response = await fetch("/api/v1/models/price", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
updates: [
{
id,
input_price: Number(input_price),
output_price: Number(output_price),
per_msg_price: Number(per_msg_price),
},
],
}),
});
const data = await response.json();
if (!response.ok)
throw new Error(data.error || t("error.model.priceUpdateFail"));
if (data.results && data.results[0]?.success) {
setModels((prevModels) =>
prevModels.map((model) =>
model.id === id
? {
...model,
input_price: Number(data.results[0].data.input_price),
output_price: Number(data.results[0].data.output_price),
per_msg_price: Number(data.results[0].data.per_msg_price),
}
: model
)
);
toast.success(t("error.model.priceUpdateSuccess"));
} else {
throw new Error(
data.results[0]?.error || t("error.model.priceUpdateFail")
);
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : t("error.model.priceUpdateFail")
);
throw err;
}
};
const handleTestSingleModel = async (model: Model) => {
try {
setModels((prev) =>
prev.map((m) =>
m.id === model.id ? { ...m, testStatus: "testing" } : m
)
);
const result = await testModel(model);
setModels((prev) =>
prev.map((m) =>
m.id === model.id
? { ...m, testStatus: result.success ? "success" : "error" }
: m
)
);
} catch (error) {
setModels((prev) =>
prev.map((m) => (m.id === model.id ? { ...m, testStatus: "error" } : m))
);
}
};
const handleSyncAllDerivedModels = async () => {
try {
setSyncing(true);
const token = localStorage.getItem("access_token");
const response = await fetch("/api/v1/models/sync-all-prices", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t("models.syncFail"));
}
if (data.syncedModels && data.syncedModels.length > 0) {
setModels((prev) =>
prev.map((model) => {
const syncedModel = data.syncedModels.find(
(m: any) => m.id === model.id && m.success
);
if (syncedModel) {
return {
...model,
input_price: syncedModel.input_price,
output_price: syncedModel.output_price,
per_msg_price: syncedModel.per_msg_price,
};
}
return model;
})
);
if (data.syncedModels.every((m: any) => m.success)) {
toast.success(t("models.syncAllSuccess"));
} else {
toast.warning(t("models.syncAllFail"));
}
} else {
toast.info(t("models.noDerivedModels"));
}
} catch (error) {
console.error("Sync all derived models failed:", error);
toast.error(
error instanceof Error ? error.message : t("models.syncFail")
);
} finally {
setSyncing(false);
}
};
const columns: ColumnsType = [
{
title: t("models.table.name"),
key: "model",
width: 200,
render: (_, record) => (
handleTestSingleModel(record)}
>
{record.imageUrl && (
)}
{record.testStatus && (
{record.testStatus === "testing" && (
)}
{record.testStatus === "success" && (
)}
{record.testStatus === "error" && (
)}
)}
{record.name}
{record.id}
),
},
{
title: t("models.table.inputPrice"),
key: "input_price",
width: 150,
dataIndex: "input_price",
sorter: (a, b) => a.input_price - b.input_price,
sortDirections: ["descend", "ascend", "descend"],
render: (_, record) => renderPriceCell("input_price", record, true),
},
{
title: t("models.table.outputPrice"),
key: "output_price",
width: 150,
dataIndex: "output_price",
sorter: (a, b) => a.output_price - b.output_price,
sortDirections: ["descend", "ascend", "descend"],
render: (_, record) => renderPriceCell("output_price", record, true),
},
{
title: (
{t("models.table.perMsgPrice")}{" "}
),
key: "per_msg_price",
width: 150,
dataIndex: "per_msg_price",
sorter: (a, b) => a.per_msg_price - b.per_msg_price,
sortDirections: ["descend", "ascend", "descend"],
render: (_, record) => renderPriceCell("per_msg_price", record, true),
},
];
const handleExportPrices = () => {
const priceData = models.map((model) => ({
id: model.id,
input_price: model.input_price,
output_price: model.output_price,
per_msg_price: model.per_msg_price,
}));
const blob = new Blob([JSON.stringify(priceData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `model_prices_${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleImportPrices = (file: File) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const importedData = JSON.parse(e.target?.result as string);
if (!Array.isArray(importedData)) {
throw new Error(t("error.model.invalidImportFormat"));
}
const validUpdates = importedData.filter((item) =>
models.some((model) => model.id === item.id)
);
const response = await fetch("/api/v1/models/price", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
updates: validUpdates,
}),
});
if (!response.ok) {
throw new Error(t("error.model.batchPriceUpdateFail"));
}
const data = await response.json();
console.log(t("error.model.serverResponse"), data);
if (data.results) {
setModels((prevModels) =>
prevModels.map((model) => {
const update = data.results.find(
(r: any) => r.id === model.id && r.success && r.data
);
if (update) {
return {
...model,
input_price: Number(update.data.input_price),
output_price: Number(update.data.output_price),
per_msg_price: Number(update.data.per_msg_price),
};
}
return model;
})
);
}
message.success(
`${t("error.model.updateSuccess")} ${
data.results.filter((r: any) => r.success).length
} ${t("error.model.numberOfModelPrice")}`
);
} catch (err) {
console.error(t("error.model.failToImport"), err);
message.error(
err instanceof Error ? err.message : t("error.model.failToImport")
);
}
};
reader.readAsText(file);
return false;
};
const testModel = async (
model: Model
): Promise<{
id: string;
success: boolean;
error?: string;
}> => {
if (!apiKey) {
return {
id: model.id,
success: false,
error: t("error.model.ApiKeyNotFetched"),
};
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const token = localStorage.getItem("access_token");
const response = await fetch("/api/v1/models/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
modelId: model.id,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || t("error.model.failToTest"));
}
return {
id: model.id,
success: true,
};
} catch (error) {
clearTimeout(timeoutId);
return {
id: model.id,
success: false,
error:
error instanceof Error
? error.message
: t("error.model.unknownError"),
};
}
};
const handleTestModels = async () => {
if (!apiKey) {
message.error(t("error.model.failToTestWithoutApiKey"));
return;
}
try {
setModels((prev) => prev.map((m) => ({ ...m, testStatus: "testing" })));
setTesting(true);
setIsTestComplete(false);
const testPromises = models.map((model) =>
testModel(model).then((result) => {
setModels((prev) =>
prev.map((m) =>
m.id === model.id
? { ...m, testStatus: result.success ? "success" : "error" }
: m
)
);
return result;
})
);
await Promise.all(testPromises);
setIsTestComplete(true);
} catch (error) {
console.error(t("error.model.failToTest"), error);
message.error(t("error.model.failToTest"));
} finally {
setTesting(false);
}
};
const tableClassName = `
[&_.ant-table]:!border-b-0
[&_.ant-table-container]:!rounded-xl
[&_.ant-table-container]:!border-hidden
[&_.ant-table-cell]:!border-border/40
[&_.ant-table-thead_.ant-table-cell]:!bg-muted/30
[&_.ant-table-thead_.ant-table-cell]:!text-muted-foreground
[&_.ant-table-thead_.ant-table-cell]:!font-medium
[&_.ant-table-thead_.ant-table-cell]:!text-sm
[&_.ant-table-thead]:!border-b
[&_.ant-table-thead]:border-border/40
[&_.ant-table-row]:!transition-colors
[&_.ant-table-row:hover>*]:!bg-muted/60
[&_.ant-table-tbody_.ant-table-row]:!cursor-pointer
[&_.ant-table-tbody_.ant-table-cell]:!py-4
[&_.ant-table-row:last-child>td]:!border-b-0
[&_.ant-table-cell:first-child]:!pl-6
[&_.ant-table-cell:last-child]:!pr-6
`;
const MobileCard = ({ record }: { record: Model }) => {
const isPerMsgEnabled = record.per_msg_price >= 0;
return (
handleTestSingleModel(record)}
>
{record.testStatus && (
)}
{record.name}
{record.id}
{[
{
label: t("models.table.mobile.inputPrice"),
field: "input_price" as const,
disabled: isPerMsgEnabled,
},
{
label: t("models.table.mobile.outputPrice"),
field: "output_price" as const,
disabled: isPerMsgEnabled,
},
{
label: t("models.table.mobile.perMsgPrice"),
field: "per_msg_price" as const,
disabled: false,
},
].map(({ label, field, disabled }) => (
{label}
{renderPriceCell(field, record, false)}
))}
);
};
const renderPriceCell = (
field: "input_price" | "output_price" | "per_msg_price",
record: Model,
showTooltip: boolean = true
) => {
const isEditing =
editingCell?.id === record.id && editingCell?.field === field;
const currentValue = Number(record[field]);
const isDisabled = field !== "per_msg_price" && record.per_msg_price >= 0;
return (
setEditingCell({ id: record.id, field })}
onSubmit={async (value) => {
try {
await handlePriceUpdate(record.id, field, value);
setEditingCell(null);
} catch {}
}}
t={t}
disabled={isDisabled}
onCancel={() => setEditingCell(null)}
tooltipText={
showTooltip && isDisabled
? t("models.table.priceOverriddenByPerMsg")
: undefined
}
placeholder={t("models.table.enterPrice")}
validateValue={(value) => ({
isValid:
field === "per_msg_price"
? isFinite(value)
: isFinite(value) && value >= 0,
errorMessage:
field === "per_msg_price"
? t("models.table.invalidNumber")
: t("models.table.nonePositiveNumber"),
})}
isPerMsgPrice={field === "per_msg_price"}
/>
);
};
if (error) {
return 错误: {error}
;
}
return (
{t("models.title")}
{t("models.description")}
{loading ? (
) : (
"group"}
/>
)}
{loading ? (
) : (
{models.map((model) => (
))}
)}
);
}