mirror of
https://github.com/coaidev/coai.git
synced 2025-05-28 17:30:15 +09:00
feat: loading spinner and fix offline models
This commit is contained in:
parent
1884a9484a
commit
90345076be
@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@tanem/react-nprogress": "^5.0.51",
|
||||
"axios": "^1.5.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
15
app/pnpm-lock.yaml
generated
15
app/pnpm-lock.yaml
generated
@ -62,6 +62,9 @@ dependencies:
|
||||
'@reduxjs/toolkit':
|
||||
specifier: ^1.9.5
|
||||
version: 1.9.7(react-redux@8.1.3)(react@18.2.0)
|
||||
'@tanem/react-nprogress':
|
||||
specifier: ^5.0.51
|
||||
version: 5.0.51(react-dom@18.2.0)(react@18.2.0)
|
||||
axios:
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.1
|
||||
@ -1961,6 +1964,18 @@ packages:
|
||||
resolution: {integrity: sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==}
|
||||
dev: true
|
||||
|
||||
/@tanem/react-nprogress@5.0.51(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-YxNUCpznuBVA+PhjEzFmxaa1czXgU+5Ojchw5JBK7DQS6SHIgNudpFohWpNBWMu2KWByGJ2OLH2OwbM/XyP18Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.2
|
||||
hoist-non-react-statics: 3.3.2
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@tauri-apps/cli-darwin-arm64@1.5.6:
|
||||
resolution: {integrity: sha512-NNvG3XLtciCMsBahbDNUEvq184VZmOveTGOuy0So2R33b/6FDkuWaSgWZsR1mISpOuP034htQYW0VITCLelfqg==}
|
||||
engines: {node: '>= 10'}
|
||||
|
@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "chatnio",
|
||||
"version": "3.8.2"
|
||||
"version": "3.8.3"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
@ -3,12 +3,14 @@ import store from "./store/index.ts";
|
||||
import AppProvider from "./components/app/AppProvider.tsx";
|
||||
import { AppRouter } from "./router.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import Spinner from "@/spinner.tsx";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AppProvider />
|
||||
<Toaster />
|
||||
<Spinner />
|
||||
<AppProvider />
|
||||
<AppRouter />
|
||||
</Provider>
|
||||
);
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
UserResponse,
|
||||
} from "@/admin/types.ts";
|
||||
import axios from "axios";
|
||||
import { getErrorMessage } from "@/utils/base.ts";
|
||||
|
||||
export async function getAdminInfo(): Promise<InfoResponse> {
|
||||
const response = await axios.get("/admin/analytics/info");
|
||||
@ -65,17 +66,17 @@ export async function getErrorChart(): Promise<ErrorChartResponse> {
|
||||
export async function getInvitationList(
|
||||
page: number,
|
||||
): Promise<InvitationResponse> {
|
||||
const response = await axios.get(`/admin/invitation/list?page=${page}`);
|
||||
if (response.status !== 200) {
|
||||
try {
|
||||
const response = await axios.get(`/admin/invitation/list?page=${page}`);
|
||||
return response.data as InvitationResponse;
|
||||
} catch (e) {
|
||||
return {
|
||||
status: false,
|
||||
message: "",
|
||||
message: getErrorMessage(e),
|
||||
data: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return response.data as InvitationResponse;
|
||||
}
|
||||
|
||||
export async function generateInvitation(
|
||||
|
@ -2,6 +2,20 @@
|
||||
color: hsl(var(--gold)) !important;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: hsl(var(--text));
|
||||
transition: 0.25s linear;
|
||||
transition-property: width, opacity;
|
||||
z-index: 1024;
|
||||
user-select: none;
|
||||
border-top-right-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -19,7 +19,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { InvitationForm, InvitationResponse } from "@/admin/types.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { ChevronLeft, ChevronRight, Download, RotateCw } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Loader2,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
import { useEffectAsync } from "@/utils/hook.ts";
|
||||
import { generateInvitation, getInvitationList } from "@/admin/api/chart.ts";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
@ -138,10 +144,13 @@ function InvitationTable() {
|
||||
total: 0,
|
||||
data: [],
|
||||
});
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [page, setPage] = useState<number>(0);
|
||||
|
||||
async function update() {
|
||||
setLoading(true);
|
||||
const resp = await getInvitationList(page);
|
||||
setLoading(false);
|
||||
if (resp.status) setData(resp as InvitationResponse);
|
||||
else
|
||||
toast({
|
||||
@ -201,7 +210,11 @@ function InvitationTable() {
|
||||
</>
|
||||
) : (
|
||||
<div className={`empty`}>
|
||||
<p>{t("admin.empty")}</p>
|
||||
{loading ? (
|
||||
<Loader2 className={`w-6 h-6 inline-block mr-1 animate-spin`} />
|
||||
) : (
|
||||
<p>{t("admin.empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`invitation-action`}>
|
||||
|
@ -18,7 +18,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { extractMessage } from "@/utils/processor.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Plus, RotateCcw } from "lucide-react";
|
||||
import { Loader2, Plus, RotateCcw } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast.ts";
|
||||
import {
|
||||
Dialog,
|
||||
@ -100,9 +100,14 @@ function BroadcastTable() {
|
||||
const { t } = useTranslation();
|
||||
const init = useSelector(selectInit);
|
||||
const [data, setData] = useState<BroadcastInfo[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
useEffectAsync(async () => {
|
||||
if (!init) return;
|
||||
|
||||
setLoading(true);
|
||||
setData(await getBroadcastList());
|
||||
setLoading(false);
|
||||
}, [init]);
|
||||
|
||||
return (
|
||||
@ -122,26 +127,36 @@ function BroadcastTable() {
|
||||
onCreated={async () => setData(await getBroadcastList())}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className={`select-none whitespace-nowrap`}>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t("admin.broadcast-content")}</TableHead>
|
||||
<TableHead>{t("admin.poster")}</TableHead>
|
||||
<TableHead>{t("admin.post-at")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((user, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{user.index}</TableCell>
|
||||
<TableCell>{extractMessage(user.content, 25)}</TableCell>
|
||||
<TableCell>{user.poster}</TableCell>
|
||||
<TableCell>{user.created_at}</TableCell>
|
||||
{data.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className={`select-none whitespace-nowrap`}>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t("admin.broadcast-content")}</TableHead>
|
||||
<TableHead>{t("admin.poster")}</TableHead>
|
||||
<TableHead>{t("admin.post-at")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((user, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{user.index}</TableCell>
|
||||
<TableCell>{extractMessage(user.content, 25)}</TableCell>
|
||||
<TableCell>{user.poster}</TableCell>
|
||||
<TableCell>{user.created_at}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className={`text-center select-none my-8`}>
|
||||
{loading ? (
|
||||
<Loader2 className={`w-6 h-6 inline-block mr-1 animate-spin`} />
|
||||
) : (
|
||||
t("admin.empty")
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ export function Combobox({
|
||||
<PopoverContent className="w-[320px] max-w-[60vw] p-0" align={align}>
|
||||
<Command>
|
||||
<CommandInput placeholder={placeholder} />
|
||||
<CommandEmpty>{t("conversation.empty")}</CommandEmpty>
|
||||
<CommandEmpty>{t("admin.empty")}</CommandEmpty>
|
||||
<CommandList>
|
||||
{valueList.map((key) => (
|
||||
<CommandItem
|
||||
|
@ -14,7 +14,7 @@ import React from "react";
|
||||
import { syncSiteInfo } from "@/admin/api/info.ts";
|
||||
import { getOfflineModels, loadPreferenceModels } from "@/utils/storage.ts";
|
||||
|
||||
export const version = "3.8.2";
|
||||
export const version = "3.8.3";
|
||||
export const dev: boolean = getDev();
|
||||
export const deploy: boolean = true;
|
||||
export let rest_api: string = getRestApi(deploy);
|
||||
|
13
app/src/events/spinner.ts
Normal file
13
app/src/events/spinner.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { EventCommitter } from "@/events/struct.ts";
|
||||
|
||||
export type SpinnerEvent = {
|
||||
id: number;
|
||||
type: boolean;
|
||||
};
|
||||
|
||||
export const openSpinnerType = true;
|
||||
export const closeSpinnerType = false;
|
||||
|
||||
export const spinnerEvent = new EventCommitter<SpinnerEvent>({
|
||||
name: "spinner",
|
||||
});
|
@ -2,23 +2,24 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import Home from "./routes/Home.tsx";
|
||||
import NotFound from "./routes/NotFound.tsx";
|
||||
import Auth from "./routes/Auth.tsx";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useDeeptrain } from "@/utils/env.ts";
|
||||
import Register from "@/routes/Register.tsx";
|
||||
import Forgot from "@/routes/Forgot.tsx";
|
||||
import { lazyFactor } from "@/utils/loader.tsx";
|
||||
|
||||
const Generation = lazy(() => import("@/routes/Generation.tsx"));
|
||||
const Sharing = lazy(() => import("@/routes/Sharing.tsx"));
|
||||
const Article = lazy(() => import("@/routes/Article.tsx"));
|
||||
const Generation = lazyFactor(() => import("@/routes/Generation.tsx"));
|
||||
const Sharing = lazyFactor(() => import("@/routes/Sharing.tsx"));
|
||||
const Article = lazyFactor(() => import("@/routes/Article.tsx"));
|
||||
|
||||
const Admin = lazy(() => import("@/routes/Admin.tsx"));
|
||||
const Dashboard = lazy(() => import("@/routes/admin/DashBoard.tsx"));
|
||||
const Market = lazy(() => import("@/routes/admin/Market.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"));
|
||||
const Admin = lazyFactor(() => import("@/routes/Admin.tsx"));
|
||||
const Dashboard = lazyFactor(() => import("@/routes/admin/DashBoard.tsx"));
|
||||
const Market = lazyFactor(() => import("@/routes/admin/Market.tsx"));
|
||||
const Channel = lazyFactor(() => import("@/routes/admin/Channel.tsx"));
|
||||
const System = lazyFactor(() => import("@/routes/admin/System.tsx"));
|
||||
const Charge = lazyFactor(() => import("@/routes/admin/Charge.tsx"));
|
||||
const Users = lazyFactor(() => import("@/routes/admin/Users.tsx"));
|
||||
const Broadcast = lazyFactor(() => import("@/routes/admin/Broadcast.tsx"));
|
||||
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
|
@ -40,18 +40,20 @@ type MarketForm = Model[];
|
||||
|
||||
const generateSeed = () => generateRandomChar(8);
|
||||
|
||||
const initialState: MarketForm = [{
|
||||
id: "",
|
||||
name: "",
|
||||
free: false,
|
||||
auth: false,
|
||||
description: "",
|
||||
high_context: false,
|
||||
default: false,
|
||||
tag: [],
|
||||
avatar: modelImages[0],
|
||||
seed: generateSeed(),
|
||||
}];
|
||||
const initialState: MarketForm = [
|
||||
{
|
||||
id: "",
|
||||
name: "",
|
||||
free: false,
|
||||
auth: false,
|
||||
description: "",
|
||||
high_context: false,
|
||||
default: false,
|
||||
tag: [],
|
||||
avatar: modelImages[0],
|
||||
seed: generateSeed(),
|
||||
},
|
||||
];
|
||||
|
||||
function reducer(state: MarketForm, action: any): MarketForm {
|
||||
switch (action.type) {
|
||||
|
45
app/src/spinner.tsx
Normal file
45
app/src/spinner.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { NProgress } from "@tanem/react-nprogress";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { decreaseTask, increaseTask, selectIsTasking } from "@/store/auth.ts";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
closeSpinnerType,
|
||||
openSpinnerType,
|
||||
spinnerEvent,
|
||||
} from "@/events/spinner.ts";
|
||||
|
||||
function Spinner() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
spinnerEvent.bind((event) => {
|
||||
switch (event.type) {
|
||||
case openSpinnerType:
|
||||
dispatch(increaseTask(event.id));
|
||||
break;
|
||||
case closeSpinnerType:
|
||||
dispatch(decreaseTask(event.id));
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isAnimating = useSelector(selectIsTasking);
|
||||
|
||||
return (
|
||||
<NProgress isAnimating={isAnimating}>
|
||||
{({ animationDuration, isFinished, progress }) => (
|
||||
<div
|
||||
className={`spinner`}
|
||||
style={{
|
||||
opacity: isFinished ? 0 : 1,
|
||||
transitionDuration: `${animationDuration}ms`,
|
||||
width: `${progress * 100}vw`,
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</NProgress>
|
||||
);
|
||||
}
|
||||
|
||||
export default Spinner;
|
@ -13,6 +13,7 @@ export const authSlice = createSlice({
|
||||
authenticated: false,
|
||||
admin: false,
|
||||
username: "",
|
||||
tasks: [] as number[],
|
||||
},
|
||||
reducers: {
|
||||
setToken: (state, action) => {
|
||||
@ -39,6 +40,15 @@ export const authSlice = createSlice({
|
||||
state.username = action.payload.username as string;
|
||||
state.admin = action.payload.admin as boolean;
|
||||
},
|
||||
increaseTask: (state, action) => {
|
||||
state.tasks.push(action.payload as number);
|
||||
},
|
||||
decreaseTask: (state, action) => {
|
||||
state.tasks = state.tasks.filter((v) => v !== (action.payload as number));
|
||||
},
|
||||
clearTask: (state) => {
|
||||
state.tasks = [];
|
||||
},
|
||||
logout: (state) => {
|
||||
state.token = "";
|
||||
state.authenticated = false;
|
||||
@ -93,6 +103,10 @@ export const selectAuthenticated = (state: RootState) =>
|
||||
export const selectUsername = (state: RootState) => state.auth.username;
|
||||
export const selectInit = (state: RootState) => state.auth.init;
|
||||
export const selectAdmin = (state: RootState) => state.auth.admin;
|
||||
export const selectTasks = (state: RootState) => state.auth.tasks;
|
||||
export const selectTasksLength = (state: RootState) => state.auth.tasks.length;
|
||||
export const selectIsTasking = (state: RootState) =>
|
||||
state.auth.tasks.length > 0;
|
||||
|
||||
export const {
|
||||
setToken,
|
||||
@ -102,5 +116,8 @@ export const {
|
||||
setInit,
|
||||
setAdmin,
|
||||
updateData,
|
||||
increaseTask,
|
||||
decreaseTask,
|
||||
clearTask,
|
||||
} = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
|
@ -5,7 +5,6 @@ import { login } from "@/conf.ts";
|
||||
export let event: BeforeInstallPromptEvent | undefined;
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e: Event) => {
|
||||
// e.preventDefault();
|
||||
console.debug(`[service] catch event from app install prompt`);
|
||||
event = e as BeforeInstallPromptEvent;
|
||||
});
|
||||
|
@ -61,6 +61,14 @@ export function generateRandomChar(n: number): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function generateInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
export function generateListNumber(n: number): number {
|
||||
return generateInt(Math.pow(10, n - 1), Math.pow(10, n) - 1);
|
||||
}
|
||||
|
||||
export function isUrl(value: string): boolean {
|
||||
value = value.trim();
|
||||
if (!value.length) return false;
|
||||
|
47
app/src/utils/loader.tsx
Normal file
47
app/src/utils/loader.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import {
|
||||
closeSpinnerType,
|
||||
openSpinnerType,
|
||||
spinnerEvent,
|
||||
} from "@/events/spinner.ts";
|
||||
import { generateListNumber } from "@/utils/base.ts";
|
||||
|
||||
export function lazyFactor<T extends React.ComponentType<any>>(
|
||||
factor: () => Promise<{ default: T }>,
|
||||
): React.LazyExoticComponent<T> {
|
||||
/**
|
||||
* Lazy load factor
|
||||
* @see https://reactjs.org/docs/code-splitting.html#reactlazy
|
||||
*
|
||||
* @example
|
||||
* lazyFactor(() => import("./factor.tsx"));
|
||||
*/
|
||||
|
||||
return React.lazy(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const task = generateListNumber(6);
|
||||
const id = setTimeout(
|
||||
() =>
|
||||
spinnerEvent.emit({
|
||||
id: task,
|
||||
type: openSpinnerType,
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
|
||||
factor()
|
||||
.then((module) => {
|
||||
clearTimeout(id);
|
||||
spinnerEvent.emit({
|
||||
id: task,
|
||||
type: closeSpinnerType,
|
||||
});
|
||||
resolve(module);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(`[factor] cannot load factor: ${error}`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -30,7 +30,28 @@ export function setOfflineModels(models: Model[]): void {
|
||||
setMemory("model_offline", JSON.stringify(models));
|
||||
}
|
||||
|
||||
export function parseOfflineModels(models: string): Model[] {
|
||||
const parsed = JSON.parse(models);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((item): Model | null => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
return {
|
||||
id: item.id || "",
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
free: item.free || false,
|
||||
auth: item.auth || false,
|
||||
default: item.default || false,
|
||||
high_context: item.high_context || false,
|
||||
avatar: item.avatar || "",
|
||||
tag: item.tag || [],
|
||||
} as Model;
|
||||
})
|
||||
.filter((item): item is Model => item !== null);
|
||||
}
|
||||
|
||||
export function getOfflineModels(): Model[] {
|
||||
const memory = getMemory("model_offline");
|
||||
return memory.length ? (JSON.parse(memory) as Model[]) : [];
|
||||
return memory.length ? parseOfflineModels(memory) : [];
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user