feat: Improve SD list data and API integration

This commit is contained in:
licoy 2024-07-03 15:37:34 +08:00
parent 54401162bd
commit a16725ac17
14 changed files with 508 additions and 33 deletions

View File

@ -337,7 +337,7 @@ function ClearContextDivider() {
);
}
function ChatAction(props: {
export function ChatAction(props: {
text: string;
icon: JSX.Element;
onClick: () => void;

View File

@ -1,7 +1,5 @@
"use client";
import { Sd } from "@/app/components/sd";
require("../polyfill");
import { useState, useEffect } from "react";
@ -32,6 +30,7 @@ import { getClientConfig } from "../config/client";
import { ClientApi } from "../client/api";
import { useAccessStore } from "../store";
import { identifyDefaultClaudeModel } from "../utils/checkers";
import { initDB } from "react-indexed-db-hook";
export function Loading(props: { noLogo?: boolean }) {
return (
@ -58,6 +57,14 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
loading: () => <Loading noLogo />,
});
const Sd = dynamic(async () => (await import("./sd")).Sd, {
loading: () => <Loading noLogo />,
});
const SdPanel = dynamic(async () => (await import("./sd-panel")).SdPanel, {
loading: () => <Loading noLogo />,
});
export function useSwitchTheme() {
const config = useAppConfig();
@ -128,7 +135,8 @@ const loadAsyncGoogleFont = () => {
function Screen() {
const config = useAppConfig();
const location = useLocation();
const isHome = location.pathname === Path.Home;
const isHome =
location.pathname === Path.Home || location.pathname === Path.SdPanel;
const isAuth = location.pathname === Path.Auth;
const isMobileScreen = useMobileScreen();
const shouldTightBorder =
@ -137,7 +145,6 @@ function Screen() {
useEffect(() => {
loadAsyncGoogleFont();
}, []);
return (
<div
className={
@ -154,7 +161,6 @@ function Screen() {
) : (
<>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<div className={styles["window-content"]} id={SlotID.AppBody}>
<Routes>
<Route path={Path.Home} element={<Chat />} />
@ -162,6 +168,7 @@ function Screen() {
<Route path={Path.Masks} element={<MaskPage />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Sd} element={<Sd />} />
<Route path={Path.SdPanel} element={<Sd />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</div>
@ -173,7 +180,6 @@ function Screen() {
export function useLoadData() {
const config = useAppConfig();
var api: ClientApi;
if (config.modelConfig.model.startsWith("gemini")) {
api = new ClientApi(ModelProvider.GeminiPro);

View File

@ -1,8 +1,14 @@
import styles from "./sd-panel.module.scss";
import React, { useState } from "react";
import { Select } from "@/app/components/ui-lib";
import { Select, showToast } from "@/app/components/ui-lib";
import { IconButton } from "@/app/components/button";
import locales from "@/app/locales";
import { nanoid } from "nanoid";
import { useIndexedDB } from "react-indexed-db-hook";
import { StoreKey } from "@/app/constant";
import { SdDbInit, sendSdTask, useSdStore } from "@/app/store/sd";
SdDbInit();
const sdCommonParams = (model: string, data: any) => {
return [
@ -89,7 +95,7 @@ const sdCommonParams = (model: string, data: any) => {
name: locales.SdPanel.OutFormat,
value: "output_format",
type: "select",
default: 0,
default: "png",
options: [
{ name: "PNG", value: "png" },
{ name: "JPEG", value: "jpeg" },
@ -128,6 +134,7 @@ const models = [
export function ControlParamItem(props: {
title: string;
subTitle?: string;
required?: boolean;
children?: JSX.Element | JSX.Element[];
className?: string;
}) {
@ -135,7 +142,10 @@ export function ControlParamItem(props: {
<div className={styles["ctrl-param-item"] + ` ${props.className || ""}`}>
<div className={styles["ctrl-param-item-header"]}>
<div className={styles["ctrl-param-item-title"]}>
<div>{props.title}</div>
<div>
{props.title}
{props.required && <span style={{ color: "red" }}>*</span>}
</div>
</div>
</div>
{props.children}
@ -160,7 +170,11 @@ export function ControlParam(props: {
switch (item.type) {
case "textarea":
element = (
<ControlParamItem title={item.name} subTitle={item.sub}>
<ControlParamItem
title={item.name}
subTitle={item.sub}
required={item.required}
>
<textarea
rows={item.rows || 3}
style={{ maxWidth: "100%", width: "100%", padding: "10px" }}
@ -175,7 +189,11 @@ export function ControlParam(props: {
break;
case "select":
element = (
<ControlParamItem title={item.name} subTitle={item.sub}>
<ControlParamItem
title={item.name}
subTitle={item.sub}
required={item.required}
>
<Select
value={props.data[item.value]}
onChange={(e) => {
@ -195,7 +213,11 @@ export function ControlParam(props: {
break;
case "number":
element = (
<ControlParamItem title={item.name} subTitle={item.sub}>
<ControlParamItem
title={item.name}
subTitle={item.sub}
required={item.required}
>
<input
type="number"
min={item.min}
@ -210,7 +232,11 @@ export function ControlParam(props: {
break;
default:
element = (
<ControlParamItem title={item.name} subTitle={item.sub}>
<ControlParamItem
title={item.name}
subTitle={item.sub}
required={item.required}
>
<input
type="text"
value={props.data[item.value]}
@ -260,14 +286,43 @@ export function SdPanel() {
setCurrentModel(model);
setParams(getModelParamBasicData(model.params({}), params));
};
const sdListDb = useIndexedDB(StoreKey.SdList);
const { execCountInc } = useSdStore();
const handleSubmit = () => {
const columns = currentModel.params(params);
const reqData: any = {};
columns.forEach((item: any) => {
reqData[item.value] = params[item.value] ?? null;
});
console.log(JSON.stringify(reqData, null, 4));
const reqParams: any = {};
for (let i = 0; i < columns.length; i++) {
const item = columns[i];
reqParams[item.value] = params[item.value] ?? null;
if (item.required) {
if (!reqParams[item.value]) {
showToast(locales.SdPanel.ParamIsRequired(item.name));
return;
}
}
}
// console.log(JSON.stringify(reqParams, null, 4));
let data: any = {
model: currentModel.value,
model_name: currentModel.name,
status: "wait",
params: reqParams,
created_at: new Date().toISOString(),
img_data: "",
};
sdListDb.add(data).then(
(id) => {
data = { ...data, id, status: "running" };
sdListDb.update(data);
execCountInc();
sendSdTask(data, sdListDb, execCountInc);
setParams(getModelParamBasicData(columns, params, true));
},
(error) => {
console.error(error);
showToast(`error: ` + error.message);
},
);
};
return (
<>

View File

@ -0,0 +1,53 @@
.sd-img-list{
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.sd-img-item{
width: 48%;
.sd-img-item-info{
flex:1;
width: 100%;
overflow: hidden;
user-select: text;
p{
margin: 6px;
font-size: 12px;
}
.line-1{
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.pre-img{
display: flex;
width: 130px;
justify-content: center;
align-items: center;
background-color: var(--second);
border-radius: 10px;
}
.img{
width: 130px;
height: 130px;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: all .3s;
&:hover{
opacity: .7;
}
}
&:not(:last-child){
margin-bottom: 20px;
}
}
}
@media only screen and (max-width: 600px) {
.sd-img-list{
.sd-img-item{
width: 100%;
}
}
}

View File

@ -1,3 +1,232 @@
export function Sd() {
return <div>sd</div>;
import chatStyles from "@/app/components/chat.module.scss";
import styles from "@/app/components/sd.module.scss";
import { IconButton } from "@/app/components/button";
import ReturnIcon from "@/app/icons/return.svg";
import Locale from "@/app/locales";
import { Path, StoreKey } from "@/app/constant";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
copyToClipboard,
getMessageTextContent,
useMobileScreen,
useWindowSize,
} from "@/app/utils";
import { useNavigate } from "react-router-dom";
import { useAppConfig } from "@/app/store";
import MinIcon from "@/app/icons/min.svg";
import MaxIcon from "@/app/icons/max.svg";
import { getClientConfig } from "@/app/config/client";
import { ChatAction } from "@/app/components/chat";
import DeleteIcon from "@/app/icons/clear.svg";
import CopyIcon from "@/app/icons/copy.svg";
import PromptIcon from "@/app/icons/prompt.svg";
import ResetIcon from "@/app/icons/reload.svg";
import { useIndexedDB } from "react-indexed-db-hook";
import { useSdStore } from "@/app/store/sd";
import locales from "@/app/locales";
import LoadingIcon from "../icons/three-dots.svg";
import ErrorIcon from "../icons/delete.svg";
import { Property } from "csstype";
import { showConfirm } from "@/app/components/ui-lib";
function openBase64ImgUrl(base64Data: string, contentType: string) {
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: contentType });
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl);
}
function getSdTaskStatus(item: any) {
let s: string;
let color: Property.Color | undefined = undefined;
switch (item.status) {
case "success":
s = Locale.Sd.Status.Success;
color = "green";
break;
case "error":
s = Locale.Sd.Status.Error;
color = "red";
break;
case "wait":
s = Locale.Sd.Status.Wait;
color = "yellow";
break;
case "running":
s = Locale.Sd.Status.Running;
color = "blue";
break;
default:
s = item.status.toUpperCase();
}
return (
<p className={styles["line-1"]} title={item.error} style={{ color: color }}>
<span>
{locales.Sd.Status.Name}: {s}
</span>
{item.status === "error" && <span> - {item.error}</span>}
</p>
);
}
export function Sd() {
const isMobileScreen = useMobileScreen();
const navigate = useNavigate();
const clientConfig = useMemo(() => getClientConfig(), []);
const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
const config = useAppConfig();
const scrollRef = useRef<HTMLDivElement>(null);
const sdListDb = useIndexedDB(StoreKey.SdList);
const [sdImages, setSdImages] = useState([]);
const { execCount } = useSdStore();
useEffect(() => {
sdListDb.getAll().then((data) => {
setSdImages(((data as never[]) || []).reverse());
});
}, [execCount]);
return (
<div className={chatStyles.chat} key={"1"}>
<div className="window-header" data-tauri-drag-region>
{isMobileScreen && (
<div className="window-actions">
<div className={"window-action-button"}>
<IconButton
icon={<ReturnIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={() => navigate(Path.SdPanel)}
/>
</div>
</div>
)}
<div className={`window-header-title ${chatStyles["chat-body-title"]}`}>
<div className={`window-header-main-title`}>Stability AI</div>
<div className="window-header-sub-title">
{Locale.Sd.SubTitle(sdImages.length || 0)}
</div>
</div>
<div className="window-actions">
{showMaxIcon && (
<div className="window-action-button">
<IconButton
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
onClick={() => {
config.update(
(config) => (config.tightBorder = !config.tightBorder),
);
}}
/>
</div>
)}
</div>
</div>
<div className={chatStyles["chat-body"]} ref={scrollRef}>
<div className={styles["sd-img-list"]}>
{sdImages.length > 0 ? (
sdImages.map((item: any) => {
return (
<div
key={item.id}
style={{ display: "flex" }}
className={styles["sd-img-item"]}
>
{item.status === "success" ? (
<img
className={styles["img"]}
src={`data:image/png;base64,${item.img_data}`}
alt={`${item.id}`}
onClick={(e) => {
openBase64ImgUrl(item.img_data, "image/png");
}}
/>
) : item.status === "error" ? (
<div className={styles["pre-img"]}>
<ErrorIcon />
</div>
) : (
<div className={styles["pre-img"]}>
<LoadingIcon />
</div>
)}
<div
style={{ marginLeft: "10px" }}
className={styles["sd-img-item-info"]}
>
<p className={styles["line-1"]}>
{locales.SdPanel.Prompt}:{" "}
<span title={item.params.prompt}>
{item.params.prompt}
</span>
</p>
<p>
{locales.SdPanel.AIModel}: {item.model_name}
</p>
{getSdTaskStatus(item)}
<p>{item.created_at}</p>
<div className={chatStyles["chat-message-actions"]}>
<div className={chatStyles["chat-input-actions"]}>
<ChatAction
text={Locale.Sd.Actions.Params}
icon={<PromptIcon />}
onClick={() => console.log(1)}
/>
<ChatAction
text={Locale.Sd.Actions.Copy}
icon={<CopyIcon />}
onClick={() =>
copyToClipboard(
getMessageTextContent({
role: "user",
content: item.params.prompt,
}),
)
}
/>
<ChatAction
text={Locale.Sd.Actions.Retry}
icon={<ResetIcon />}
onClick={() => console.log(1)}
/>
<ChatAction
text={Locale.Sd.Actions.Delete}
icon={<DeleteIcon />}
onClick={async () => {
if (await showConfirm(Locale.Sd.Danger.Delete)) {
sdListDb.deleteRecord(item.id).then(
() => {
setSdImages(
sdImages.filter(
(i: any) => i.id !== item.id,
),
);
},
(error) => {
console.error(error);
},
);
}
}}
/>
</div>
</div>
</div>
</div>
);
})
) : (
<div>{locales.Sd.EmptyRecord}</div>
)}
</div>
</div>
</div>
);
}

View File

@ -155,6 +155,7 @@ export function SideBar(props: { className?: string }) {
let isChat: boolean = false;
switch (location.pathname) {
case Path.Sd:
case Path.SdPanel:
bodyComponent = <SdPanel />;
break;
default:
@ -220,6 +221,7 @@ export function SideBar(props: { className?: string }) {
<div className={styles["sidebar-tail"]}>
<div className={styles["sidebar-actions"]}>
{isChat && (
<div className={styles["sidebar-action"] + " " + styles.mobile}>
<IconButton
icon={<DeleteIcon />}
@ -230,6 +232,7 @@ export function SideBar(props: { className?: string }) {
}}
/>
</div>
)}
<div className={styles["sidebar-action"]}>
<Link to={Path.Settings}>
<IconButton icon={<SettingsIcon />} shadow />

View File

@ -3,7 +3,7 @@ import { BuildConfig, getBuildConfig } from "./build";
export function getClientConfig() {
if (typeof document !== "undefined") {
// client side
return JSON.parse(queryMeta("config")) as BuildConfig;
return JSON.parse(queryMeta("config") || "{}") as BuildConfig;
}
if (typeof process !== "undefined") {

View File

@ -22,6 +22,7 @@ export enum Path {
Masks = "/masks",
Auth = "/auth",
Sd = "/sd",
SdPanel = "/sd-panel",
}
export enum ApiPath {
@ -48,6 +49,7 @@ export enum StoreKey {
Prompt = "prompt-store",
Update = "chat-update",
Sync = "sync",
SdList = "sd-list",
}
export const DEFAULT_SIDEBAR_WIDTH = 300;

View File

@ -494,6 +494,7 @@ const cn = {
AIModel: "AI模型",
ModelVersion: "模型版本",
Submit: "提交生成",
ParamIsRequired: (name: string) => `${name}不能为空`,
Styles: {
D3Model: "3D模型",
AnalogFilm: "模拟电影",
@ -514,6 +515,26 @@ const cn = {
TileTexture: "贴图",
},
},
Sd: {
SubTitle: (count: number) => `${count} 条绘画`,
Actions: {
Params: "查看参数",
Copy: "复制提示词",
Delete: "删除",
Retry: "重试",
},
EmptyRecord: "暂无绘画记录",
Status: {
Name: "状态",
Success: "成功",
Error: "失败",
Wait: "等待中",
Running: "运行中",
},
Danger: {
Delete: "确认删除?",
},
},
};
type DeepPartial<T> = T extends object

View File

@ -500,6 +500,7 @@ const en: LocaleType = {
AIModel: "AI Model",
ModelVersion: "Model Version",
Submit: "Submit",
ParamIsRequired: (name: string) => `${name} is required`,
Styles: {
D3Model: "3d-model",
AnalogFilm: "analog-film",
@ -520,6 +521,26 @@ const en: LocaleType = {
TileTexture: "tile-texture",
},
},
Sd: {
SubTitle: (count: number) => `${count} images`,
Actions: {
Params: "See Params",
Copy: "Copy Prompt",
Delete: "Delete",
Retry: "Retry",
},
EmptyRecord: "No images yet",
Status: {
Name: "Status",
Success: "Success",
Error: "Error",
Wait: "Waiting",
Running: "Running",
},
Danger: {
Delete: "Confirm to delete?",
},
},
};
export default en;

View File

@ -3,6 +3,7 @@ import { Analytics } from "@vercel/analytics/react";
import { Home } from "./components/home";
import { getServerSideConfig } from "./config/server";
import { SdDbInit } from "@/app/store/sd";
const serverConfig = getServerSideConfig();

78
app/store/sd.ts Normal file
View File

@ -0,0 +1,78 @@
import { initDB, useIndexedDB } from "react-indexed-db-hook";
import { StoreKey } from "@/app/constant";
import { create, StoreApi } from "zustand";
export const SdDbConfig = {
name: "@chatgpt-next-web/sd",
version: 1,
objectStoresMeta: [
{
store: StoreKey.SdList,
storeConfig: { keyPath: "id", autoIncrement: true },
storeSchema: [
{ name: "model", keypath: "model", options: { unique: false } },
{
name: "model_name",
keypath: "model_name",
options: { unique: false },
},
{ name: "status", keypath: "status", options: { unique: false } },
{ name: "params", keypath: "params", options: { unique: false } },
{ name: "img_data", keypath: "img_data", options: { unique: false } },
{ name: "error", keypath: "error", options: { unique: false } },
{
name: "created_at",
keypath: "created_at",
options: { unique: false },
},
],
},
],
};
export function SdDbInit() {
initDB(SdDbConfig);
}
type SdStore = {
execCount: number;
execCountInc: () => void;
};
export const useSdStore = create<SdStore>()((set) => ({
execCount: 1,
execCountInc: () => set((state) => ({ execCount: state.execCount + 1 })),
}));
export function sendSdTask(data: any, db: any, inc: any) {
const formData = new FormData();
for (let paramsKey in data.params) {
formData.append(paramsKey, data.params[paramsKey]);
}
fetch("https://api.stability.ai/v2beta/stable-image/generate/" + data.model, {
method: "POST",
headers: {
Accept: "application/json",
},
body: formData,
})
.then((response) => response.json())
.then((resData) => {
if (resData.errors && resData.errors.length > 0) {
db.update({ ...data, status: "error", error: resData.errors[0] });
inc();
return;
}
if (resData.finish_reason === "SUCCESS") {
db.update({ ...data, status: "success", img_data: resData.image });
} else {
db.update({ ...data, status: "error", error: JSON.stringify(resData) });
}
inc();
})
.catch((error) => {
db.update({ ...data, status: "error", error: error.message });
console.error("Error:", error);
inc();
});
}

View File

@ -32,6 +32,7 @@
"node-fetch": "^3.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-indexed-db-hook": "^1.0.14",
"react-markdown": "^8.0.7",
"react-router-dom": "^6.15.0",
"rehype-highlight": "^6.0.0",

View File

@ -5110,6 +5110,11 @@ react-dom@^18.2.0:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-indexed-db-hook@^1.0.14:
version "1.0.14"
resolved "https://registry.npmmirror.com/react-indexed-db-hook/-/react-indexed-db-hook-1.0.14.tgz#a29cd732d592735b6a68dfc94316b7a4a091e6be"
integrity sha512-tQ6rWofgXUCBhZp9pRpWzthzPbjqcll5uXMo07lbQTKl47VyL9nw9wfVswRxxzS5yj5Sq/VHUkNUjamWbA/M/w==
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"