mirror of
https://github.com/coaidev/coai.git
synced 2025-05-29 01:40:17 +09:00
feat: system settings page and add event source max timeout
This commit is contained in:
parent
d496635ccf
commit
8990c7cf2b
@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "chatnio",
|
||||
"version": "3.7.5"
|
||||
"version": "3.7.6"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
@ -4,6 +4,7 @@
|
||||
@import "broadcast";
|
||||
@import "channel";
|
||||
@import "charge";
|
||||
@import "system";
|
||||
|
||||
.admin-page {
|
||||
position: relative;
|
||||
|
13
app/src/assets/admin/system.less
Normal file
13
app/src/assets/admin/system.less
Normal file
@ -0,0 +1,13 @@
|
||||
.system {
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.system-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 20vh;
|
||||
}
|
||||
}
|
@ -123,3 +123,144 @@ input[type="number"] {
|
||||
background: hsl(var(--selection));
|
||||
}
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0.5rem 0;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--text));
|
||||
border: 1px solid hsl(var(--border));
|
||||
transition: .25s;
|
||||
cursor: pointer;
|
||||
|
||||
&.collapsable {
|
||||
.paragraph-content {
|
||||
max-height: var(--max-height);
|
||||
will-change: max-height;
|
||||
overflow: hidden;
|
||||
transition: .5s;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
padding-bottom: 1rem;
|
||||
|
||||
.paragraph-content {
|
||||
max-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.paragraph-content {
|
||||
& > * {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.paragraph-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
margin-bottom: 1.5rem;
|
||||
align-items: center;
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
|
||||
.paragraph-title {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: 1.05rem;
|
||||
user-select: none;
|
||||
line-height: 1.1rem;
|
||||
color: hsl(var(--text-secondary));
|
||||
transition: .25s;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
margin-right: 0.5rem;
|
||||
height: 1.25rem;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: hsl(var(--text-secondary));
|
||||
transition: .25s;
|
||||
}
|
||||
}
|
||||
|
||||
.paragraph-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
& > * {
|
||||
margin-right: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.paragraph-description {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
color: hsl(var(--text-secondary));
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.75rem 0;
|
||||
|
||||
svg {
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.paragraph-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 1rem;
|
||||
|
||||
& > * {
|
||||
margin-right: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--border-hover));
|
||||
|
||||
.paragraph-title {
|
||||
color: hsl(var(--text));
|
||||
|
||||
&:before {
|
||||
background: hsl(var(--text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.config-paragraph {
|
||||
.paragraph-content {
|
||||
input {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
86
app/src/components/Paragraph.tsx
Normal file
86
app/src/components/Paragraph.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, Info } from "lucide-react";
|
||||
import { cn } from "@/components/ui/lib/utils.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import Markdown from "@/components/Markdown.tsx";
|
||||
|
||||
export type ParagraphProps = {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
configParagraph?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onCollapse?: () => void;
|
||||
defaultCollapsed?: boolean;
|
||||
};
|
||||
|
||||
function Paragraph({
|
||||
title,
|
||||
children,
|
||||
configParagraph,
|
||||
isCollapsed,
|
||||
onCollapse,
|
||||
defaultCollapsed,
|
||||
}: ParagraphProps) {
|
||||
const [collapsed, setCollapsed] = React.useState(defaultCollapsed ?? false);
|
||||
|
||||
React.useEffect(() => onCollapse && onCollapse(), [collapsed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`paragraph`,
|
||||
configParagraph && `config-paragraph`,
|
||||
isCollapsed && `collapsable`,
|
||||
collapsed && `collapsed`,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`paragraph-header`}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
{title && <div className={`paragraph-title`}>{title}</div>}
|
||||
<div className={`grow`} />
|
||||
{isCollapsed && (
|
||||
<Button size={`icon`} variant={`ghost`} className={`w-8 h-8`}>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
`w-4 h-4 transition-transform duration-300`,
|
||||
collapsed && `transform rotate-180`,
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`paragraph-content`}
|
||||
style={
|
||||
{
|
||||
"--max-height": collapsed ? "0px" : "1000px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParagraphItem({ children }: { children: React.ReactNode }) {
|
||||
return <div className={`paragraph-item`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ParagraphDescription({ children }: { children: string }) {
|
||||
return (
|
||||
<div className={`paragraph-description`}>
|
||||
<Info size={16} />
|
||||
<Markdown children={children} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParagraphFooter({ children }: { children: React.ReactNode }) {
|
||||
return <div className={`paragraph-footer`}>{children}</div>;
|
||||
}
|
||||
|
||||
export default Paragraph;
|
||||
export { ParagraphItem, ParagraphFooter };
|
@ -399,7 +399,7 @@ function ChargeTable({ data, dispatch, onRefresh }: ChargeTableProps) {
|
||||
<TableCell>
|
||||
<Badge>{charge.type.split("-")[0]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={`select-none`}>
|
||||
<TableCell>
|
||||
<pre>{charge.models.join("\n")}</pre>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
@ -3,6 +3,7 @@ import { closeMenu, selectMenu } from "@/store/menu.ts";
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
CandlestickChart,
|
||||
GitFork,
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
Settings,
|
||||
@ -60,7 +61,7 @@ function MenuBar() {
|
||||
/>
|
||||
<MenuItem
|
||||
title={t("admin.channel")}
|
||||
icon={<Settings />}
|
||||
icon={<GitFork />}
|
||||
path={"/channel"}
|
||||
/>
|
||||
<MenuItem
|
||||
@ -68,6 +69,11 @@ function MenuBar() {
|
||||
icon={<CandlestickChart />}
|
||||
path={"/charge"}
|
||||
/>
|
||||
<MenuItem
|
||||
title={t("admin.settings")}
|
||||
icon={<Settings />}
|
||||
path={"/system"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -88,7 +88,7 @@ function CreateBroadcastDialog(props: CreateBroadcastDialogProps) {
|
||||
<DialogClose asChild>
|
||||
<Button variant={`outline`}>{t("admin.cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button variant={`default`} onClick={postBroadcast}>
|
||||
<Button variant={`default`} onClick={postBroadcast} loading={true}>
|
||||
{t("admin.post")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
@ -35,6 +35,7 @@ import ActionButton from "@/components/home/assemblies/ActionButton.tsx";
|
||||
import ChatInput from "@/components/home/assemblies/ChatInput.tsx";
|
||||
import ScrollAction from "@/components/home/assemblies/ScrollAction.tsx";
|
||||
import { connectionEvent } from "@/events/connection.ts";
|
||||
import { chatEvent } from "@/events/chat.ts";
|
||||
|
||||
type InterfaceProps = {
|
||||
setWorking: (working: boolean) => void;
|
||||
@ -68,6 +69,7 @@ function ChatWrapper() {
|
||||
const [instance, setInstance] = useState<HTMLElement | null>(null);
|
||||
|
||||
manager.setDispatch(dispatch);
|
||||
chatEvent.addEventListener(() => setWorking(false));
|
||||
|
||||
function clearFile() {
|
||||
setFiles([]);
|
||||
|
@ -29,8 +29,8 @@ export const ChatAction = React.forwardRef<HTMLDivElement, ChatActionProps>(
|
||||
<div
|
||||
className={`action chat-action ${className}`}
|
||||
onClick={onClick}
|
||||
ref={ref} // @ts-ignore
|
||||
style={{ "--width": `${labelWidth}px` }}
|
||||
ref={ref}
|
||||
style={{ "--width": `${labelWidth}px` } as React.CSSProperties}
|
||||
>
|
||||
{children}
|
||||
<div className="text" ref={label}>
|
||||
|
@ -3,6 +3,8 @@ import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "./lib/utils";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
@ -37,17 +39,58 @@ export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
asChild = false,
|
||||
loading = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const [working, setWorking] = useState<boolean>(false);
|
||||
const onTrigger =
|
||||
loading && onClick
|
||||
? (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (working) return;
|
||||
setWorking(true);
|
||||
|
||||
// execute the onClick handler (detecting if it's a promise or not)
|
||||
const result: Promise<any> | any = onClick && onClick(e);
|
||||
if (result instanceof Promise)
|
||||
result.finally(() => setWorking(false));
|
||||
else setWorking(false);
|
||||
}
|
||||
: onClick;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
onClick={onTrigger}
|
||||
disabled={disabled || working}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{loading && working && (
|
||||
<Loader2 className={`animate-spin w-4 h-4 mr-2`} />
|
||||
)}
|
||||
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
53
app/src/components/ui/tabs.tsx
Normal file
53
app/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/components/ui/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
@ -10,7 +10,7 @@ import {
|
||||
} from "@/utils/env.ts";
|
||||
import { getMemory } from "@/utils/memory.ts";
|
||||
|
||||
export const version = "3.7.5";
|
||||
export const version = "3.7.6";
|
||||
export const dev: boolean = getDev();
|
||||
export const deploy: boolean = true;
|
||||
export let rest_api: string = getRestApi(deploy);
|
||||
|
@ -29,7 +29,7 @@ const resources = {
|
||||
"login-failed-prompt": "登录失败!原因: {{reason}}",
|
||||
"login-success": "登录成功",
|
||||
"login-success-prompt": "您已成功登录。",
|
||||
"server-error": "服务器错误",
|
||||
"server-error": "登录错误",
|
||||
"server-error-prompt": "登录出错,请重试。",
|
||||
error: "请求失败",
|
||||
"request-failed": "请求失败,请检查您的网络并重试。",
|
||||
@ -313,6 +313,7 @@ const resources = {
|
||||
users: "用户管理",
|
||||
broadcast: "公告管理",
|
||||
channel: "渠道设置",
|
||||
settings: "系统设置",
|
||||
prize: "价格设定",
|
||||
"billing-today": "今日入账",
|
||||
"billing-month": "本月入账",
|
||||
@ -423,6 +424,24 @@ const resources = {
|
||||
"add-rule": "添加规则",
|
||||
"update-rule": "更新规则",
|
||||
},
|
||||
system: {
|
||||
general: "常规设置",
|
||||
search: "联网搜索",
|
||||
mail: "SMTP 发件设置",
|
||||
save: "保存",
|
||||
backend: "后端域名",
|
||||
backendTip:
|
||||
"后端回调域名(docker 安装默认路径为 /api),接收回调参数。",
|
||||
mailHost: "发件域名",
|
||||
mailPort: "SMTP 端口",
|
||||
mailUser: "用户名",
|
||||
mailPass: "密码",
|
||||
searchEndpoint: "搜索接入点",
|
||||
searchQuery: "最大搜索结果数",
|
||||
searchTip:
|
||||
"DuckDuckGo 搜索接入点,如不填写自动使用 WebPilot 和 New Bing 逆向进行搜索功能。\n" +
|
||||
"DuckDuckGo API 项目搭建:[duckduckgo-api](https://github.com/binjie09/duckduckgo-api)。",
|
||||
},
|
||||
},
|
||||
mask: {
|
||||
title: "预设设置",
|
||||
@ -748,6 +767,7 @@ const resources = {
|
||||
users: "User Management",
|
||||
broadcast: "Broadcast Management",
|
||||
channel: "Channel Settings",
|
||||
settings: "System Settings",
|
||||
prize: "Price Settings",
|
||||
"billing-today": "Billing Today",
|
||||
"billing-month": "Billing Month",
|
||||
@ -865,6 +885,24 @@ const resources = {
|
||||
"add-rule": "Add Rule",
|
||||
"update-rule": "Update Rule",
|
||||
},
|
||||
system: {
|
||||
general: "General Settings",
|
||||
search: "Web Search",
|
||||
mail: "SMTP Settings",
|
||||
save: "Save",
|
||||
backend: "Backend Domain",
|
||||
backendTip:
|
||||
"Backend callback domain (docker installation default path is /api), receive callback parameters.",
|
||||
mailHost: "Mail Host",
|
||||
mailPort: "SMTP Port",
|
||||
mailUser: "Username",
|
||||
mailPass: "Password",
|
||||
searchEndpoint: "Search Endpoint",
|
||||
searchQuery: "Max Search Results",
|
||||
searchTip:
|
||||
"DuckDuckGo search endpoint, if not filled in, use WebPilot and New Bing reverse search function by default.\n" +
|
||||
"DuckDuckGo API project build: [duckduckgo-api](https://github.com/binjie09/duckduckgo-api).",
|
||||
},
|
||||
},
|
||||
mask: {
|
||||
title: "Mask Settings",
|
||||
@ -1191,6 +1229,7 @@ const resources = {
|
||||
users: "Управление пользователями",
|
||||
broadcast: "Управление объявлениями",
|
||||
channel: "Настройки канала",
|
||||
settings: "Настройки системы",
|
||||
prize: "Настройки цен",
|
||||
"billing-today": "Сегодняшний доход",
|
||||
"billing-month": "Доход за месяц",
|
||||
@ -1310,6 +1349,24 @@ const resources = {
|
||||
"add-rule": "Добавить правило",
|
||||
"update-rule": "Обновить правило",
|
||||
},
|
||||
system: {
|
||||
general: "Общие настройки",
|
||||
search: "Веб-поиск",
|
||||
mail: "SMTP Настройки почты",
|
||||
save: "Сохранить",
|
||||
backend: "Домен бэкэнда",
|
||||
backendTip:
|
||||
"Домен обратного вызова бэкэнда (путь по умолчанию для установки docker - /api), получает параметры обратного вызова.",
|
||||
mailHost: "Почтовый хост",
|
||||
mailPort: "Порт SMTP",
|
||||
mailUser: "Имя пользователя",
|
||||
mailPass: "Пароль",
|
||||
searchEndpoint: "Конечная точка поиска",
|
||||
searchQuery: "Максимальное количество результатов поиска",
|
||||
searchTip:
|
||||
"Конечная точка поиска DuckDuckGo, если она не заполнена, по умолчанию используется функция обратного поиска WebPilot и New Bing.\n" +
|
||||
"Сборка проекта DuckDuckGo API: [duckduckgo-api](https://github.com/binjie09/duckduckgo-api).",
|
||||
},
|
||||
},
|
||||
mask: {
|
||||
title: "Настройки маски",
|
||||
|
@ -11,6 +11,7 @@ const Article = lazy(() => import("@/routes/Article.tsx"));
|
||||
const Admin = lazy(() => import("@/routes/Admin.tsx"));
|
||||
const Dashboard = lazy(() => import("@/routes/admin/DashBoard.tsx"));
|
||||
const Channel = lazy(() => import("@/routes/admin/Channel.tsx"));
|
||||
const System = lazy(() => import("@/routes/admin/System.tsx"));
|
||||
const Charge = lazy(() => import("@/routes/admin/Charge.tsx"));
|
||||
const Users = lazy(() => import("@/routes/admin/Users.tsx"));
|
||||
const Broadcast = lazy(() => import("@/routes/admin/Broadcast.tsx"));
|
||||
@ -94,6 +95,15 @@ const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "admin-system",
|
||||
path: "system",
|
||||
element: (
|
||||
<Suspense>
|
||||
<System />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "admin-charge",
|
||||
path: "charge",
|
||||
|
@ -63,7 +63,7 @@ function Auth() {
|
||||
console.debug(err);
|
||||
toast({
|
||||
title: t("server-error"),
|
||||
description: t("server-error-prompt"),
|
||||
description: `${t("server-error-prompt")}\n${err.message}`,
|
||||
action: (
|
||||
<ToastAction altText={t("try-again")} onClick={login}>
|
||||
{t("try-again")}
|
||||
|
218
app/src/routes/admin/System.tsx
Normal file
218
app/src/routes/admin/System.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card.tsx";
|
||||
import Paragraph, {
|
||||
ParagraphDescription,
|
||||
ParagraphFooter,
|
||||
ParagraphItem,
|
||||
} from "@/components/Paragraph.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { useReducer } from "react";
|
||||
import { formReducer } from "@/utils/form.ts";
|
||||
import { NumberInput } from "@/components/ui/number-input.tsx";
|
||||
|
||||
type GeneralState = {
|
||||
backend: string;
|
||||
};
|
||||
|
||||
function General() {
|
||||
const { t } = useTranslation();
|
||||
const [general, generalDispatch] = useReducer(formReducer<GeneralState>(), {
|
||||
backend: "",
|
||||
} as GeneralState);
|
||||
|
||||
return (
|
||||
<Paragraph
|
||||
title={t("admin.system.general")}
|
||||
configParagraph={true}
|
||||
isCollapsed={true}
|
||||
>
|
||||
<ParagraphItem>
|
||||
<Label>{t("admin.system.backend")}</Label>
|
||||
<Input
|
||||
value={general.backend}
|
||||
onChange={(e) =>
|
||||
generalDispatch({
|
||||
type: "update:backend",
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={`${window.location.protocol}//${window.location.host}/api`}
|
||||
/>
|
||||
</ParagraphItem>
|
||||
<ParagraphDescription>
|
||||
{t("admin.system.backendTip")}
|
||||
</ParagraphDescription>
|
||||
<ParagraphFooter>
|
||||
<div className={`grow`} />
|
||||
<Button size={`sm`} loading={true}>
|
||||
{t("admin.system.save")}
|
||||
</Button>
|
||||
</ParagraphFooter>
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
type MailState = {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
function Mail() {
|
||||
const { t } = useTranslation();
|
||||
const [mail, mailDispatch] = useReducer(formReducer<MailState>(), {
|
||||
host: "",
|
||||
port: 465,
|
||||
username: "",
|
||||
password: "",
|
||||
} as MailState);
|
||||
|
||||
return (
|
||||
<Paragraph
|
||||
title={t("admin.system.mail")}
|
||||
configParagraph={true}
|
||||
isCollapsed={true}
|
||||
>
|
||||
<ParagraphItem>
|
||||
<Label>{t("admin.system.mailHost")}</Label>
|
||||
<Input
|
||||
value={mail.host}
|
||||
onChange={(e) =>
|
||||
mailDispatch({
|
||||
type: "update:host",
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={`smtp.qcloudmail.com`}
|
||||
/>
|
||||
</ParagraphItem>
|
||||
<ParagraphItem>
|
||||
<Label>{t("admin.system.mailPort")}</Label>
|
||||
<NumberInput
|
||||
value={mail.port}
|
||||
onValueChange={(value) =>
|
||||
mailDispatch({ type: "update:port", value })
|
||||
}
|
||||
placeholder={`465`}
|
||||
min={0}
|
||||
max={65535}
|
||||
/>
|
||||
</ParagraphItem>
|
||||
<ParagraphItem>
|
||||
<Label>{t("admin.system.mailUser")}</Label>
|
||||
<Input
|
||||
value={mail.username}
|
||||
onChange={(e) =>
|
||||
mailDispatch({
|
||||
type: "update:username",
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("admin.system.mailUser")}
|
||||
/>
|
||||
</ParagraphItem>
|
||||
<ParagraphItem>
|
||||
<Label>{t("admin.system.mailPass")}</Label>
|
||||
<Input
|
||||
value={mail.password}
|
||||
onChange={(e) =>
|
||||
mailDispatch({
|
||||
type: "update:password",
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("admin.system.mailPass")}
|
||||
/>
|
||||
</ParagraphItem>
|
||||
<ParagraphFooter>
|
||||
<div className={`grow`} />
|
||||
<Button size={`sm`} loading={true}>
|
||||
{t("admin.system.save")}
|
||||
</Button>
|
||||
</ParagraphFooter>
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchState = {
|
||||
endpoint: string;
|
||||
query: number;
|
||||
};
|
||||
|
||||
function Search() {
|
||||
const { t } = useTranslation();
|
||||
const [search, searchDispatch] = useReducer(formReducer<SearchState>(), {
|
||||
endpoint: "https://duckduckgo-api.vercel.app",
|
||||
query: 5,
|
||||
} as SearchState);
|
||||
|
||||
return (
|
||||
<Paragraph
|
||||
title={t("admin.system.search")}
|
||||
configParagraph={true}
|
||||
isCollapsed={true}
|
||||
>
|
||||
<ParagraphItem>
|
||||
<Label>{t("admin.system.searchEndpoint")}</Label>
|
||||
<Input
|
||||
value={search.endpoint}
|
||||
onChange={(e) =>
|
||||
searchDispatch({
|
||||
type: "update:endpoint",
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={`DuckDuckGo API Endpoint`}
|
||||
/>
|
||||
</ParagraphItem>
|
||||
<ParagraphItem>
|
||||
<Label>{t("admin.system.searchQuery")}</Label>
|
||||
<NumberInput
|
||||
value={search.query}
|
||||
onValueChange={(value) =>
|
||||
searchDispatch({ type: "update:query", value })
|
||||
}
|
||||
placeholder={`5`}
|
||||
min={0}
|
||||
max={50}
|
||||
/>
|
||||
</ParagraphItem>
|
||||
<ParagraphDescription>{t("admin.system.searchTip")}</ParagraphDescription>
|
||||
<ParagraphFooter>
|
||||
<div className={`grow`} />
|
||||
<Button size={`sm`} loading={true}>
|
||||
{t("admin.system.save")}
|
||||
</Button>
|
||||
</ParagraphFooter>
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
function System() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={`system`}>
|
||||
<Card className={`system-card`}>
|
||||
<CardHeader className={`select-none`}>
|
||||
<CardTitle>{t("admin.settings")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className={`flex flex-col gap-1`}>
|
||||
<General />
|
||||
<Mail />
|
||||
<Search />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default System;
|
17
app/src/utils/form.ts
Normal file
17
app/src/utils/form.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const formReducer = <T>() => {
|
||||
return (state: T, action: any) => {
|
||||
action.payload = action.payload ?? action.value;
|
||||
|
||||
switch (action.type) {
|
||||
case "update":
|
||||
return { ...state, ...action.payload };
|
||||
case "reset":
|
||||
return { ...action.payload };
|
||||
default:
|
||||
if (action.type.startsWith("update:")) {
|
||||
const key = action.type.slice(7);
|
||||
return { ...state, [key]: action.payload };
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
@ -20,9 +20,10 @@ const defaultQuotaMessage = "You don't have enough quota to use this model. plea
|
||||
func CollectQuota(c *gin.Context, user *auth.User, buffer *utils.Buffer, uncountable bool) {
|
||||
db := utils.GetDBFromContext(c)
|
||||
quota := buffer.GetQuota()
|
||||
if buffer.IsEmpty() {
|
||||
if buffer.IsEmpty() || buffer.GetCharge().IsBillingType(globals.TimesBilling) {
|
||||
return
|
||||
}
|
||||
|
||||
if !uncountable && quota > 0 && user != nil {
|
||||
user.UseQuota(db, quota)
|
||||
}
|
||||
|
@ -93,6 +93,14 @@ func (b *Buffer) IsEmpty() bool {
|
||||
return b.Cursor == 0 && !b.IsFunctionCalling()
|
||||
}
|
||||
|
||||
func (b *Buffer) GetModel() string {
|
||||
return b.Model
|
||||
}
|
||||
|
||||
func (b *Buffer) GetCharge() Charge {
|
||||
return b.Charge
|
||||
}
|
||||
|
||||
func (b *Buffer) Read() string {
|
||||
return b.Data
|
||||
}
|
||||
|
@ -72,17 +72,20 @@ func DecrInt(cache *redis.Client, key string, delta int64) bool {
|
||||
}
|
||||
|
||||
func IncrIP(cache *redis.Client, ip string) int64 {
|
||||
val, err := Incr(cache, fmt.Sprintf(":ip-rate:%s", ip), 1)
|
||||
if err != nil && err == redis.Nil {
|
||||
cache.Set(context.Background(), fmt.Sprintf(":ip-rate:%s", ip), 1, time.Minute*20)
|
||||
key := fmt.Sprintf(":ip-rate:%s", ip)
|
||||
val, err := Incr(cache, key, 1)
|
||||
if err != nil && errors.Is(err, redis.Nil) {
|
||||
cache.Set(context.Background(), key, 1, time.Minute*20)
|
||||
return 1
|
||||
}
|
||||
|
||||
cache.Expire(context.Background(), key, time.Minute*20)
|
||||
return val
|
||||
}
|
||||
|
||||
func IncrWithExpire(cache *redis.Client, key string, delta int64, expiration time.Duration) {
|
||||
_, err := Incr(cache, key, delta)
|
||||
if err != nil && err == redis.Nil {
|
||||
if err != nil && errors.Is(err, redis.Nil) {
|
||||
cache.Set(context.Background(), key, delta, expiration)
|
||||
}
|
||||
}
|
||||
|
17
utils/net.go
17
utils/net.go
@ -11,8 +11,17 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var maxTimeout = 30 * time.Minute
|
||||
|
||||
func newClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: maxTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func Http(uri string, method string, ptr interface{}, headers map[string]string, body io.Reader) (err error) {
|
||||
req, err := http.NewRequest(method, uri, body)
|
||||
if err != nil {
|
||||
@ -22,7 +31,7 @@ func Http(uri string, method string, ptr interface{}, headers map[string]string,
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
client := newClient()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -45,7 +54,7 @@ func HttpRaw(uri string, method string, headers map[string]string, body io.Reade
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
client := newClient()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -85,7 +94,7 @@ func ConvertBody(body interface{}) (form io.Reader) {
|
||||
}
|
||||
|
||||
func PostForm(uri string, body map[string]interface{}) (data map[string]interface{}, err error) {
|
||||
client := &http.Client{}
|
||||
client := newClient()
|
||||
form := make(url.Values)
|
||||
for key, value := range body {
|
||||
form[key] = []string{value.(string)}
|
||||
@ -116,7 +125,7 @@ func EventSource(method string, uri string, headers map[string]string, body inte
|
||||
|
||||
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
|
||||
client := &http.Client{}
|
||||
client := newClient()
|
||||
req, err := http.NewRequest(method, uri, ConvertBody(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
|
92
utils/smtp.go
Normal file
92
utils/smtp.go
Normal file
@ -0,0 +1,92 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
type SmtpPoster struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
func NewSmtpPoster(host string, port int, username string, password string, from string) *SmtpPoster {
|
||||
return &SmtpPoster{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
From: from,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SmtpPoster) SendMail(to string, subject string, body string) {
|
||||
addr := fmt.Sprintf("%s:%d", s.Host, s.Port)
|
||||
auth := smtp.PlainAuth("", s.From, s.Password, s.Host)
|
||||
err := smtpRequestWithTLS(addr, auth, s.From, []string{to},
|
||||
[]byte(formatMail(map[string]string{
|
||||
"From": fmt.Sprintf("%s <%s>", s.Username, s.From),
|
||||
"To": to,
|
||||
"Subject": subject,
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
}, body)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func dial(addr string) (*smtp.Client, error) {
|
||||
conn, err := tls.Dial("tcp", addr, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
return smtp.NewClient(conn, host)
|
||||
}
|
||||
|
||||
func formatMail(headers map[string]string, body string) (result string) {
|
||||
for k, v := range headers {
|
||||
result += fmt.Sprintf("%s: %s\r\n", k, v)
|
||||
}
|
||||
return fmt.Sprintf("%s\r\n%s", result, body)
|
||||
}
|
||||
|
||||
func smtpRequestWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) (err error) {
|
||||
client, err := dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, addr := range to {
|
||||
if err = client.Rcpt(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = writer.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
@ -86,7 +86,7 @@ func NewEndEvent() StreamEvent {
|
||||
func SSEClient(method string, uri string, headers map[string]string, body interface{}, callback func(string) error) error {
|
||||
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
|
||||
client := &http.Client{}
|
||||
client := newClient()
|
||||
req, err := http.NewRequest(method, uri, ConvertBody(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
@ -99,12 +99,13 @@ func SSEClient(method string, uri string, headers map[string]string, body interf
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 400 {
|
||||
return fmt.Errorf("request failed with status: %s", res.Status)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
events, err := CreateSSEInstance(res)
|
||||
if err != nil {
|
||||
return err
|
||||
|
Loading…
Reference in New Issue
Block a user