add sharing manage feature

This commit is contained in:
Zhang Minghan 2023-10-23 16:04:40 +08:00
parent 248e2043a6
commit 3bc8b892fb
19 changed files with 594 additions and 50 deletions

View File

@ -10,6 +10,7 @@ import {
Boxes,
CalendarPlus,
Cloud,
ListStart,
Menu,
Plug,
} from "lucide-react";
@ -39,9 +40,11 @@ import { openDialog as openQuotaDialog, quotaSelector } from "./store/quota.ts";
import { openDialog as openPackageDialog } from "./store/package.ts";
import { openDialog as openSub } from "./store/subscription.ts";
import { openDialog as openApiDialog } from "./store/api.ts";
import { openDialog as openSharingDialog } from "./store/sharing.ts";
import Package from "./routes/Package.tsx";
import Subscription from "./routes/Subscription.tsx";
import ApiKey from "./routes/ApiKey.tsx";
import ShareManagement from "./routes/ShareManagement.tsx";
function Settings() {
const { t } = useTranslation();
@ -78,6 +81,10 @@ function Settings() {
<Boxes className={`h-4 w-4 mr-1`} />
{t("pkg.title")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => dispatch(openSharingDialog())}>
<ListStart className={`h-4 w-4 mr-1`} />
{t("share.manage")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => dispatch(openApiDialog())}>
<Plug className={`h-4 w-4 mr-1`} />
{t("api.title")}
@ -149,6 +156,7 @@ function App() {
<ApiKey />
<Package />
<Subscription />
<ShareManagement />
</Provider>
);
}

View File

@ -20,7 +20,7 @@
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 240 4.8% 95.9%;
--muted: 60 26% 92%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 37 26% 90%;

View File

@ -0,0 +1,11 @@
.share-table {
max-height: 60vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
padding: 0 0.5rem;
&::-webkit-scrollbar {
width: 0.5rem;
}
}

View File

@ -1,11 +1,11 @@
import {useEffect, useRef, useState} from "react";
import {Message} from "../../conversation/types.ts";
import {useSelector} from "react-redux";
import {selectCurrent, selectMessages} from "../../store/chat.ts";
import {Button} from "../ui/button.tsx";
import {ChevronDown} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Message } from "../../conversation/types.ts";
import { useSelector } from "react-redux";
import { selectCurrent, selectMessages } from "../../store/chat.ts";
import { Button } from "../ui/button.tsx";
import { ChevronDown } from "lucide-react";
import MessageSegment from "../Message.tsx";
import {connectionEvent} from "../../events/connection.ts";
import { connectionEvent } from "../../events/connection.ts";
function ChatInterface() {
const ref = useRef(null);

View File

@ -1,18 +1,28 @@
import {useTranslation} from "react-i18next";
import React, {useEffect, useRef, useState} from "react";
import FileProvider, {FileObject} from "../FileProvider.tsx";
import {useDispatch, useSelector} from "react-redux";
import {selectAuthenticated, selectInit} from "../../store/auth.ts";
import {selectMessages, selectModel, selectWeb, setWeb} from "../../store/chat.ts";
import {manager} from "../../conversation/manager.ts";
import {formatMessage} from "../../utils.ts";
import { useTranslation } from "react-i18next";
import React, { useEffect, useRef, useState } from "react";
import FileProvider, { FileObject } from "../FileProvider.tsx";
import { useDispatch, useSelector } from "react-redux";
import { selectAuthenticated, selectInit } from "../../store/auth.ts";
import {
selectMessages,
selectModel,
selectWeb,
setWeb,
} from "../../store/chat.ts";
import { manager } from "../../conversation/manager.ts";
import { formatMessage } from "../../utils.ts";
import ChatInterface from "./ChatInterface.tsx";
import {Button} from "../ui/button.tsx";
import { Button } from "../ui/button.tsx";
import router from "../../router.ts";
import {BookMarked, ChevronRight, FolderKanban, Globe} from "lucide-react";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "../ui/tooltip.tsx";
import {Toggle} from "../ui/toggle.tsx";
import {Input} from "../ui/input.tsx";
import { BookMarked, ChevronRight, FolderKanban, Globe } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip.tsx";
import { Toggle } from "../ui/toggle.tsx";
import { Input } from "../ui/input.tsx";
import EditorProvider from "../EditorProvider.tsx";
import ModelSelector from "./ModelSelector.tsx";

View File

@ -1,27 +1,41 @@
import {useTranslation} from "react-i18next";
import {useDispatch, useSelector} from "react-redux";
import {RootState} from "../../store";
import {selectAuthenticated} from "../../store/auth.ts";
import {selectCurrent, selectHistory} from "../../store/chat.ts";
import {useRef, useState} from "react";
import {ConversationInstance} from "../../conversation/types.ts";
import {useToast} from "../ui/use-toast.ts";
import {copyClipboard, extractMessage, filterMessage, mobile, useAnimation, useEffectAsync} from "../../utils.ts";
import {deleteConversation, toggleConversation, updateConversationList} from "../../conversation/history.ts";
import {Button} from "../ui/button.tsx";
import {setMenu} from "../../store/menu.ts";
import {Copy, LogIn, Plus, RotateCw} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../../store";
import { selectAuthenticated } from "../../store/auth.ts";
import { selectCurrent, selectHistory } from "../../store/chat.ts";
import { useRef, useState } from "react";
import { ConversationInstance } from "../../conversation/types.ts";
import { useToast } from "../ui/use-toast.ts";
import {
copyClipboard,
extractMessage,
filterMessage,
mobile,
useAnimation,
useEffectAsync,
} from "../../utils.ts";
import {
deleteConversation,
toggleConversation,
updateConversationList,
} from "../../conversation/history.ts";
import { Button } from "../ui/button.tsx";
import { setMenu } from "../../store/menu.ts";
import { Copy, LogIn, Plus, RotateCw } from "lucide-react";
import ConversationSegment from "./ConversationSegment.tsx";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription, AlertDialogFooter,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
AlertDialogTitle,
} from "../ui/alert-dialog.tsx";
import {shareConversation} from "../../conversation/sharing.ts";
import {Input} from "../ui/input.tsx";
import {login} from "../../conf.ts";
import {getSharedLink, shareConversation} from "../../conversation/sharing.ts";
import { Input } from "../ui/input.tsx";
import { login } from "../../conf.ts";
function SideBar() {
const { t } = useTranslation();
@ -185,7 +199,7 @@ function SideBar() {
operateConversation?.target?.id || -1,
);
if (resp.status)
setShared(`${location.origin}/share/${resp.data}`);
setShared(getSharedLink(resp.data));
else
toast({
title: t("share.failed"),

View File

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "./lib/utils.ts"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-primary font-medium text-primary-foreground", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -1,7 +1,7 @@
import axios from "axios";
import { Model } from "./conversation/types.ts";
export const version = "3.4.6";
export const version = "3.5.0";
export const deploy: boolean = true;
export let rest_api: string = "http://localhost:8094";
export let ws_api: string = "ws://localhost:8094";

View File

@ -7,6 +7,13 @@ export type SharingForm = {
data: string;
};
export type SharingPreviewForm = {
name: string;
conversation_id: number;
hash: string;
time: string;
};
export type ViewData = {
name: string;
username: string;
@ -20,6 +27,17 @@ export type ViewForm = {
data: ViewData | null;
};
export type ListSharingResponse = {
status: boolean;
message: string;
data?: SharingPreviewForm[];
};
export type DeleteSharingResponse = {
status: boolean;
message: string;
};
export async function shareConversation(
id: number,
refs: number[] = [-1],
@ -44,3 +62,33 @@ export async function viewConversation(hash: string): Promise<ViewForm> {
};
}
}
export async function listSharing(): Promise<ListSharingResponse> {
try {
const resp = await axios.get("/conversation/share/list");
return resp.data as ListSharingResponse;
} catch (e) {
return {
status: false,
message: (e as Error).message,
};
}
}
export async function deleteSharing(
hash: string,
): Promise<DeleteSharingResponse> {
try {
const resp = await axios.get(`/conversation/share/delete?hash=${hash}`);
return resp.data as DeleteSharingResponse;
} catch (e) {
return {
status: false,
message: (e as Error).message,
};
}
}
export function getSharedLink(hash: string): string {
return `${location.origin}/share/${hash}`;
}

View File

@ -186,10 +186,15 @@ const resources = {
"not-found": "Conversation not found",
"not-found-description":
"Conversation not found, please check if the link is correct or the conversation has been deleted",
manage: "Manage Share",
"sync-error": "Sync Error",
name: "Conversation Title",
time: "Time",
action: "Action",
},
docs: {
title: "Open Docs",
}
},
},
},
cn: {
@ -362,10 +367,15 @@ const resources = {
"not-found": "对话未找到",
"not-found-description":
"对话未找到,请检查链接是否正确或对话是否已被删除",
manage: "分享管理",
"sync-error": "同步失败",
name: "对话标题",
time: "时间",
action: "操作",
},
docs: {
title: "开放文档",
}
},
},
},
ru: {
@ -549,10 +559,15 @@ const resources = {
"not-found": "Разговор не найден",
"not-found-description":
"Разговор не найден, пожалуйста, проверьте, правильная ли ссылка или разговор был удален",
manage: "Управление обменом",
"sync-error": "Ошибка синхронизации",
name: "Название разговора",
time: "Время",
action: "Действие",
},
docs: {
title: "Открыть документы",
}
},
},
},
};

View File

@ -173,7 +173,7 @@ function Generation() {
console.debug(
`[generation] create generation request (prompt: ${prompt}, model: ${model})`,
);
return manager.generateWithBlock(prompt, model,);
return manager.generateWithBlock(prompt, model);
}}
/>
</div>

View File

@ -0,0 +1,137 @@
import "../assets/share-manager.less";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import {dialogSelector, dataSelector, syncData, deleteData} from "../store/sharing.ts";
import { useToast } from "../components/ui/use-toast.ts";
import { selectInit } from "../store/auth.ts";
import {useEffectAsync} from "../utils.ts";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "../components/ui/dialog.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../components/ui/table.tsx";
import {closeDialog, setDialog} from "../store/sharing.ts";
import {Button} from "../components/ui/button.tsx";
import {useMemo} from "react";
import {Eye, MoreHorizontal, Trash2} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "../components/ui/dropdown-menu.tsx";
import {getSharedLink, SharingPreviewForm} from "../conversation/sharing.ts";
type ShareTableProps = {
data: SharingPreviewForm[];
}
function ShareTable({ data }: ShareTableProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const time = useMemo(() => {
return data.map((row) => {
const date = new Date(row.time);
return `${date.getMonth()}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`;
});
}, [data]);
return (
<Table className={`mt-5`}>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>{t('share.name')}</TableHead>
<TableHead>{t('share.time')}</TableHead>
<TableHead>{t('share.action')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, idx) => (
<TableRow key={idx}>
<TableCell>{row.conversation_id}</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell className={`whitespace-nowrap`}>{time[idx]}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={`outline`} size={`icon`}>
<MoreHorizontal className={`h-4 w-4`} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={`center`}>
<DropdownMenuItem onClick={() => {
window.open(getSharedLink(row.hash), '_blank');
}}>
<Eye className={`h-4 w-4 mr-1`} />
{t("share.view")}
</DropdownMenuItem>
<DropdownMenuItem onClick={async () => {
await deleteData(dispatch, row.hash);
}}>
<Trash2 className={`h-4 w-4 mr-1`} />
{t("conversation.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function ShareManagement() {
const { t } = useTranslation();
const dispatch = useDispatch();
const open = useSelector(dialogSelector);
const data = useSelector(dataSelector);
const { toast } = useToast();
const init = useSelector(selectInit);
useEffectAsync(async () => {
if (init) {
const resp = await syncData(dispatch);
if (resp) {
toast({
title: t("share.sync-error"),
description: resp,
});
}
}
}, [init]);
return (
<Dialog open={open} onOpenChange={(open) => dispatch(setDialog(open))}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("share.manage")}</DialogTitle>
<DialogDescription className={`share-table`}>
<ShareTable data={data} />
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant={`outline`} onClick={() => dispatch(closeDialog())}>
{t("close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default ShareManagement;

View File

@ -116,7 +116,9 @@ function Sharing() {
<HelpCircle className={`w-12 h-12 mb-2.5`} />
<p className={`title`}>{t("share.not-found")}</p>
<p className={`description`}>{t("share.not-found-description")}</p>
<Button className={`mt-4`} onClick={() => router.navigate("/")}>{t("home")}</Button>
<Button className={`mt-4`} onClick={() => router.navigate("/")}>
{t("home")}
</Button>
</div>
)}
</div>

View File

@ -1,5 +1,5 @@
import { createSlice } from "@reduxjs/toolkit";
import {ConversationInstance, Model} from "../conversation/types.ts";
import { ConversationInstance, Model } from "../conversation/types.ts";
import { Message } from "../conversation/types.ts";
import { insertStart } from "../utils.ts";
import { RootState } from "./index.ts";
@ -14,8 +14,10 @@ type initialStateType = {
};
function GetModel(model: string | undefined | null): string {
return model && supportModels.filter((item: Model) => item.id === model).length
? model : supportModels[0].id;
return model &&
supportModels.filter((item: Model) => item.id === model).length
? model
: supportModels[0].id;
}
const chatSlice = createSlice({

View File

@ -6,6 +6,7 @@ import quotaReducer from "./quota";
import packageReducer from "./package";
import subscriptionReducer from "./subscription";
import apiReducer from "./api";
import sharingReducer from "./sharing";
const store = configureStore({
reducer: {
@ -16,6 +17,7 @@ const store = configureStore({
package: packageReducer,
subscription: subscriptionReducer,
api: apiReducer,
sharing: sharingReducer,
},
});

69
app/src/store/sharing.ts Normal file
View File

@ -0,0 +1,69 @@
import { createSlice } from "@reduxjs/toolkit";
import { AppDispatch, RootState } from "./index.ts";
import {
deleteSharing,
listSharing,
SharingPreviewForm,
} from "../conversation/sharing.ts";
export const sharingSlice = createSlice({
name: "sharing",
initialState: {
dialog: false,
data: [] as SharingPreviewForm[],
},
reducers: {
toggleDialog: (state) => {
state.dialog = !state.dialog;
},
setDialog: (state, action) => {
state.dialog = action.payload as boolean;
},
openDialog: (state) => {
state.dialog = true;
},
closeDialog: (state) => {
state.dialog = false;
},
setData: (state, action) => {
state.data = action.payload as SharingPreviewForm[];
},
removeData: (state, action) => {
const hash = action.payload as string;
state.data = state.data.filter((item) => item.hash !== hash);
},
},
});
export const {
toggleDialog,
setDialog,
openDialog,
closeDialog,
setData,
removeData,
} = sharingSlice.actions;
export default sharingSlice.reducer;
export const dialogSelector = (state: RootState): boolean =>
state.sharing.dialog;
export const dataSelector = (state: RootState): SharingPreviewForm[] =>
state.sharing.data;
export const syncData = async (dispatch: AppDispatch): Promise<string> => {
const response = await listSharing();
if (response.status) dispatch(setData(response.data));
return response.status ? "" : response.message;
};
export const deleteData = async (
dispatch: AppDispatch,
hash: string,
): Promise<string> => {
const response = await deleteSharing(hash);
if (response.status) dispatch(removeData(hash));
return response.status ? "" : response.message;
};

View File

@ -162,3 +162,56 @@ func ViewAPI(c *gin.Context) {
"data": shared,
})
}
func ListSharingAPI(c *gin.Context) {
user := auth.GetUser(c)
if user == nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "user not found",
})
return
}
db := utils.GetDBFromContext(c)
data := ListSharedConversation(db, user)
c.JSON(http.StatusOK, gin.H{
"status": true,
"message": "",
"data": data,
})
}
func DeleteSharingAPI(c *gin.Context) {
user := auth.GetUser(c)
if user == nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "user not found",
})
return
}
db := utils.GetDBFromContext(c)
hash := strings.TrimSpace(c.Query("hash"))
if hash == "" {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "invalid hash",
})
return
}
if err := DeleteSharedConversation(db, user, hash); err != nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": true,
"message": "",
})
}

View File

@ -8,7 +8,11 @@ func Register(app *gin.Engine) {
router.GET("/list", ListAPI)
router.GET("/load", LoadAPI)
router.GET("/delete", DeleteAPI)
// share
router.POST("/share", ShareAPI)
router.GET("/view", ViewAPI)
router.GET("/share/list", ListSharingAPI)
router.GET("/share/delete", DeleteSharingAPI)
}
}

View File

@ -10,6 +10,13 @@ import (
"time"
)
type SharedPreviewForm struct {
Name string `json:"name"`
ConversationId int64 `json:"conversation_id"`
Time time.Time `json:"time"`
Hash string `json:"hash"`
}
type SharedForm struct {
Username string `json:"username"`
Name string `json:"name"`
@ -73,6 +80,54 @@ func GetSharedMessages(db *sql.DB, userId int64, conversationId int64, refs []st
return messages
}
func ListSharedConversation(db *sql.DB, user *auth.User) []SharedPreviewForm {
if user == nil {
return nil
}
id := user.GetID(db)
rows, err := db.Query(`
SELECT conversation.conversation_name, conversation.conversation_id, sharing.updated_at, sharing.hash
FROM sharing
INNER JOIN conversation
ON conversation.conversation_id = sharing.conversation_id
AND conversation.user_id = sharing.user_id
WHERE sharing.user_id = ?
ORDER BY sharing.updated_at DESC
LIMIT 100
`, id)
if err != nil {
return nil
}
result := make([]SharedPreviewForm, 0)
for rows.Next() {
var updated []uint8
var form SharedPreviewForm
if err := rows.Scan(&form.Name, &form.ConversationId, &updated, &form.Hash); err != nil {
continue
}
form.Time = *utils.ConvertTime(updated)
result = append(result, form)
}
return result
}
func DeleteSharedConversation(db *sql.DB, user *auth.User, hash string) error {
if user == nil {
return nil
}
id := user.GetID(db)
if _, err := db.Exec(`
DELETE FROM sharing WHERE user_id = ? AND hash = ?
`, id, hash); err != nil {
return err
}
return nil
}
func GetSharedConversation(db *sql.DB, hash string) (*SharedForm, error) {
var shared SharedForm
var (