coai/app/src/components/admin/ChargeWidget.tsx
2024-01-11 12:34:11 +08:00

540 lines
16 KiB
TypeScript

import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group.tsx";
import { Label } from "@/components/ui/label.tsx";
import {
ChargeProps,
chargeTypes,
defaultChargeType,
nonBilling,
timesBilling,
tokenBilling,
} from "@/admin/charge.ts";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input.tsx";
import { useMemo, useReducer, useState } from "react";
import { Button } from "@/components/ui/button.tsx";
import {
AlertCircle,
Cloud,
DownloadCloud,
Eraser,
EyeOff,
Minus,
PencilLine,
Plus,
RotateCw,
Search,
Settings2,
Trash,
UploadCloud,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import {
Command,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command.tsx";
import { channelModels } from "@/admin/channel.ts";
import { toastState } from "@/admin/utils.ts";
import { Switch } from "@/components/ui/switch.tsx";
import { NumberInput } from "@/components/ui/number-input.tsx";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import OperationAction from "@/components/OperationAction.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import { useToast } from "@/components/ui/use-toast";
import { deleteCharge, listCharge, setCharge } from "@/admin/api/charge.ts";
import { useEffectAsync } from "@/utils/hook.ts";
import { cn } from "@/components/ui/lib/utils.ts";
import { allModels } from "@/conf.ts";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import Tips from "@/components/Tips.tsx";
const initialState: ChargeProps = {
id: -1,
type: defaultChargeType,
models: [],
anonymous: false,
input: 0,
output: 0,
};
function reducer(state: ChargeProps, action: any): ChargeProps {
switch (action.type) {
case "set":
return { ...action.payload };
case "set-models":
return { ...state, models: action.payload };
case "add-model":
const model = action.payload.trim();
if (model.length === 0 || state.models.includes(model)) return state;
return { ...state, models: [...state.models, model] };
case "remove-model":
return {
...state,
models: state.models.filter((model) => model !== action.payload),
};
case "set-type":
return { ...state, type: action.payload };
case "set-anonymous":
return { ...state, anonymous: action.payload };
case "set-input":
return { ...state, input: action.payload };
case "set-output":
return { ...state, output: action.payload };
case "clear":
return initialState;
case "clear-param":
return { ...initialState, id: state.id };
default:
return state;
}
}
function preflight(state: ChargeProps): ChargeProps {
state.models = state.models
.map((model) => model.trim())
.filter((model) => model.length > 0);
switch (state.type) {
case nonBilling:
state.input = 0;
state.output = 0;
break;
case timesBilling:
state.input = 0;
state.anonymous = false;
break;
case tokenBilling:
state.anonymous = false;
break;
}
if (state.input < 0) state.input = 0;
if (state.output < 0) state.output = 0;
return state;
}
type ChargeAlertProps = {
models: string[];
};
function ChargeAlert({ models }: ChargeAlertProps) {
const { t } = useTranslation();
return (
models.length > 0 && (
<Alert className={`charge-alert`}>
<AlertTitle className={`flex flex-row items-center select-none`}>
<AlertCircle className="h-4 w-4 mr-2" />
<p>{t("admin.charge.unused-model")}</p>
<Tips content={t("admin.charge.unused-model-tip")} />
</AlertTitle>
<AlertDescription className={`model-list`}>
{models.map((model, index) => (
<div key={index} className={`model`}>
{model}
</div>
))}
</AlertDescription>
</Alert>
)
);
}
type ChargeEditorProps = {
form: ChargeProps;
dispatch: (action: any) => void;
onRefresh: () => void;
usedModels: string[];
};
function ChargeEditor({
form,
dispatch,
onRefresh,
usedModels,
}: ChargeEditorProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [model, setModel] = useState("");
const unusedModels = useMemo(() => {
return channelModels.filter(
(model) =>
!form.models.includes(model) &&
!usedModels.includes(model) &&
model.trim() !== "",
);
}, [form.models, usedModels]);
const disabled = useMemo(() => {
return form.models.length === 0;
}, [form.models]);
const [loading, setLoading] = useState(false);
async function post() {
const resp = await setCharge(preflight({ ...form }));
toastState(toast, t, resp, true);
if (resp.status) clear();
onRefresh();
}
function clear() {
dispatch({ type: "clear" });
setModel("");
}
return (
<div className={`charge-editor`}>
<div className={`w-full h-max mb-5`}>
<RadioGroup
value={form.type}
onValueChange={(value) =>
dispatch({ type: "set-type", payload: value })
}
className={`flex flex-row gap-5 whitespace-nowrap flex-wrap`}
>
{chargeTypes.map((chargeType, index) => (
<div
className="flex items-center space-x-2 cursor-pointer"
key={index}
>
<RadioGroupItem
className={`transition-all duration-200`}
value={chargeType}
id={chargeType}
/>
<Label htmlFor={chargeType} className={`cursor-pointer`}>
{t(`admin.charge.${chargeType}`)}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className={`flex flex-row w-full h-max mb-4`}>
<Button
onClick={() => {
dispatch({ type: "add-model", payload: model });
setModel("");
}}
size={`icon`}
className={`mr-2 shrink-0`}
>
<Plus className={`w-4 h-4`} />
</Button>
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={t("admin.channels.model")}
onKeyDown={(e) => {
if (e.key === "Enter") {
dispatch({ type: "add-model", payload: model });
setModel("");
}
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={`icon`} className={`ml-2 shrink-0`}>
<Search className={`w-4 h-4`} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={`end`} asChild>
<Command>
<CommandInput placeholder={t("admin.channels.search-model")} />
<CommandList className={`thin-scrollbar`}>
{unusedModels.map((model, idx) => (
<CommandItem
key={idx}
value={model}
onSelect={(value) =>
dispatch({ type: "add-model", payload: value })
}
className={`px-2`}
>
{model}
</CommandItem>
))}
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className={`flex flex-col w-full h-max mb-2`}>
{form.models.map((model, index) => (
<div
className={`flex flex-row w-full h-max shrink-0 mb-2 select-none`}
key={index}
>
<Input value={model} readOnly />
<Button
onClick={() => dispatch({ type: "remove-model", payload: model })}
size={`icon`}
variant={`outline`}
className={`ml-2 shrink-0`}
>
<Minus className={`w-4 h-4`} />
</Button>
</div>
))}
</div>
{form.type === nonBilling && (
<div className={`flex flex-row w-full h-max items-center mt-4 mb-6`}>
<EyeOff className={`w-4 h-4 mr-2`} />
<Label className={`grow`}>{t("admin.charge.anonymous")}</Label>
<Switch
checked={form.anonymous}
onCheckedChange={(checked) =>
dispatch({ type: "set-anonymous", payload: checked })
}
/>
</div>
)}
{form.type === timesBilling && (
<div className={`flex flex-row w-full h-max items-center`}>
<Cloud className={`w-4 h-4 mr-2`} />
<Label className={`grow`}>{t("admin.charge.time-count")}</Label>
<NumberInput
value={form.output}
onValueChange={(value) =>
dispatch({ type: "set-output", payload: value })
}
acceptNegative={false}
className={`w-20`}
min={0}
max={99999}
/>
</div>
)}
{form.type === tokenBilling && (
<div className={`flex flex-col w-full h-max gap-2`}>
<div className={`flex flex-row w-full h-max items-center`}>
<UploadCloud className={`w-4 h-4 mr-2`} />
<Label className={`grow`}>
{t("admin.charge.input-count")}
<span className={`token`}> / 1k tokens</span>
</Label>
<NumberInput
value={form.input}
onValueChange={(value) =>
dispatch({ type: "set-input", payload: value })
}
acceptNegative={false}
className={`w-20`}
min={0}
max={99999}
/>
</div>
<div className={`flex flex-row w-full h-max items-center`}>
<DownloadCloud className={`w-4 h-4 mr-2`} />
<Label className={`grow`}>
{t("admin.charge.output-count")}
<span className={`token`}> / 1k tokens</span>
</Label>
<NumberInput
value={form.output}
onValueChange={(value) =>
dispatch({ type: "set-output", payload: value })
}
acceptNegative={false}
className={`w-20`}
min={0}
max={99999}
/>
</div>
</div>
)}
<div
className={`flex flex-row w-full h-max mt-5 gap-2 items-center flex-wrap`}
>
<div className={`object-id`}>
<span className={`mr-2`}>ID</span>
{form.id === -1 ? (
<Plus className={`w-3 h-3`} />
) : (
<span className={`id`}>{form.id}</span>
)}
</div>
<div className={`grow`} />
<Button
variant={`outline`}
size={`icon`}
className={`shrink-0`}
onClick={clear}
>
<Eraser className={`w-4 h-4`} />
</Button>
<Button
disabled={disabled}
onClick={post}
loading={true}
onLoadingChange={setLoading}
className={`whitespace-nowrap shrink-0`}
>
{form.id === -1 ? (
<>
{!loading && <Plus className={`w-4 h-4 mr-2`} />}
{t("admin.charge.add-rule")}
</>
) : (
<>
{!loading && <PencilLine className={`w-4 h-4 mr-2`} />}
{t("admin.charge.update-rule")}
</>
)}
</Button>
</div>
</div>
);
}
type ChargeTableProps = {
data: ChargeProps[];
dispatch: (action: any) => void;
onRefresh: () => void;
loading: boolean;
};
function ChargeTable({ data, dispatch, onRefresh, loading }: ChargeTableProps) {
const { t } = useTranslation();
const { toast } = useToast();
return (
<div className={`charge-table`}>
<Table>
<TableHeader>
<TableRow className={`select-none whitespace-nowrap`}>
<TableCell>{t("admin.charge.id")}</TableCell>
<TableCell>{t("admin.charge.type")}</TableCell>
<TableCell>{t("admin.charge.model")}</TableCell>
<TableCell>{t("admin.charge.input")}</TableCell>
<TableCell>{t("admin.charge.output")}</TableCell>
<TableCell>{t("admin.charge.support-anonymous")}</TableCell>
<TableCell>{t("admin.charge.action")}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{data.map((charge, idx) => (
<TableRow key={idx}>
<TableCell>{charge.id}</TableCell>
<TableCell>
<Badge>{charge.type.split("-")[0]}</Badge>
</TableCell>
<TableCell>
<pre>{charge.models.join("\n")}</pre>
</TableCell>
<TableCell>
{charge.input === 0 ? 0 : charge.input.toFixed(3)}
</TableCell>
<TableCell>
{charge.output === 0 ? 0 : charge.output.toFixed(3)}
</TableCell>
<TableCell>{t(String(charge.anonymous))}</TableCell>
<TableCell>
<div className={`inline-flex flex-row flex-wrap gap-2`}>
<OperationAction
tooltip={t("admin.channels.edit")}
onClick={async () => {
const props: ChargeProps = { ...charge };
dispatch({ type: "set", payload: props });
}}
>
<Settings2 className={`h-4 w-4`} />
</OperationAction>
<OperationAction
tooltip={t("admin.channels.delete")}
variant={`destructive`}
onClick={async () => {
const resp = await deleteCharge(charge.id);
toastState(toast, t, resp, true);
onRefresh();
}}
>
<Trash className={`h-4 w-4`} />
</OperationAction>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</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={onRefresh}
>
<RotateCw className={cn("w-4 h-4", loading && "animate-spin")} />
</Button>
</div>
</div>
);
}
function ChargeWidget() {
const { t } = useTranslation();
const { toast } = useToast();
const [data, setData] = useState<ChargeProps[]>([]);
const [form, dispatch] = useReducer(reducer, initialState);
const [loading, setLoading] = useState(false);
const usedModels = useMemo((): string[] => {
return data.flatMap((charge) => charge.models);
}, [data]);
const unusedModels = useMemo(() => {
if (loading) return [];
return allModels.filter(
(model) => !usedModels.includes(model) && model.trim() !== "",
);
}, [loading, allModels, usedModels]);
async function refresh() {
setLoading(true);
const resp = await listCharge();
setLoading(false);
toastState(toast, t, resp);
setData(resp.data);
}
useEffectAsync(refresh, []);
return (
<div className={`charge-widget`}>
<ChargeAlert models={unusedModels} />
<ChargeEditor
onRefresh={refresh}
form={form}
dispatch={dispatch}
usedModels={usedModels}
/>
<ChargeTable
data={data}
onRefresh={refresh}
dispatch={dispatch}
loading={loading}
/>
</div>
);
}
export default ChargeWidget;