diff --git a/app/src/App.tsx b/app/src/App.tsx index ed45dd69..525273fb 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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() { {t("pkg.title")} + dispatch(openSharingDialog())}> + + {t("share.manage")} + dispatch(openApiDialog())}> {t("api.title")} @@ -149,6 +156,7 @@ function App() { + ); } diff --git a/app/src/assets/globals.less b/app/src/assets/globals.less index af0e37ee..14ddd16a 100644 --- a/app/src/assets/globals.less +++ b/app/src/assets/globals.less @@ -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%; diff --git a/app/src/assets/share-manager.less b/app/src/assets/share-manager.less new file mode 100644 index 00000000..9d77da31 --- /dev/null +++ b/app/src/assets/share-manager.less @@ -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; + } +} diff --git a/app/src/components/home/ChatInterface.tsx b/app/src/components/home/ChatInterface.tsx index 4eaeeeb8..3d7851de 100644 --- a/app/src/components/home/ChatInterface.tsx +++ b/app/src/components/home/ChatInterface.tsx @@ -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); diff --git a/app/src/components/home/ChatWrapper.tsx b/app/src/components/home/ChatWrapper.tsx index d0d9c909..8c269e54 100644 --- a/app/src/components/home/ChatWrapper.tsx +++ b/app/src/components/home/ChatWrapper.tsx @@ -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"; diff --git a/app/src/components/home/SideBar.tsx b/app/src/components/home/SideBar.tsx index b374c355..501bd528 100644 --- a/app/src/components/home/SideBar.tsx +++ b/app/src/components/home/SideBar.tsx @@ -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"), diff --git a/app/src/components/ui/table.tsx b/app/src/components/ui/table.tsx new file mode 100644 index 00000000..346b5341 --- /dev/null +++ b/app/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "./lib/utils.ts" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/app/src/conf.ts b/app/src/conf.ts index 813629ab..077126f5 100644 --- a/app/src/conf.ts +++ b/app/src/conf.ts @@ -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"; diff --git a/app/src/conversation/sharing.ts b/app/src/conversation/sharing.ts index d8e1d7a1..0cdfbf6a 100644 --- a/app/src/conversation/sharing.ts +++ b/app/src/conversation/sharing.ts @@ -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 { }; } } + +export async function listSharing(): Promise { + 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 { + 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}`; +} diff --git a/app/src/i18n.ts b/app/src/i18n.ts index adf9d72e..0425f00b 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -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: "Открыть документы", - } + }, }, }, }; diff --git a/app/src/routes/Generation.tsx b/app/src/routes/Generation.tsx index 269fe985..8f9e3321 100644 --- a/app/src/routes/Generation.tsx +++ b/app/src/routes/Generation.tsx @@ -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); }} /> diff --git a/app/src/routes/ShareManagement.tsx b/app/src/routes/ShareManagement.tsx new file mode 100644 index 00000000..90f2b2a2 --- /dev/null +++ b/app/src/routes/ShareManagement.tsx @@ -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 ( + + + + ID + {t('share.name')} + {t('share.time')} + {t('share.action')} + + + + {data.map((row, idx) => ( + + {row.conversation_id} + {row.name} + {time[idx]} + + + + + + + { + window.open(getSharedLink(row.hash), '_blank'); + }}> + + {t("share.view")} + + { + await deleteData(dispatch, row.hash); + }}> + + {t("conversation.delete")} + + + + + + ))} + +
+ ) +} + +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 ( + dispatch(setDialog(open))}> + + + {t("share.manage")} + + + + + + + + + + ); +} + +export default ShareManagement; diff --git a/app/src/routes/Sharing.tsx b/app/src/routes/Sharing.tsx index abe5cb6c..97187c48 100644 --- a/app/src/routes/Sharing.tsx +++ b/app/src/routes/Sharing.tsx @@ -116,7 +116,9 @@ function Sharing() {

{t("share.not-found")}

{t("share.not-found-description")}

- + )} diff --git a/app/src/store/chat.ts b/app/src/store/chat.ts index d4267363..8e34e53b 100644 --- a/app/src/store/chat.ts +++ b/app/src/store/chat.ts @@ -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({ diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 297e8d00..d558532f 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -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, }, }); diff --git a/app/src/store/sharing.ts b/app/src/store/sharing.ts new file mode 100644 index 00000000..4810e903 --- /dev/null +++ b/app/src/store/sharing.ts @@ -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 => { + 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 => { + const response = await deleteSharing(hash); + if (response.status) dispatch(removeData(hash)); + + return response.status ? "" : response.message; +}; diff --git a/manager/conversation/api.go b/manager/conversation/api.go index a344f805..d754755d 100644 --- a/manager/conversation/api.go +++ b/manager/conversation/api.go @@ -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": "", + }) +} diff --git a/manager/conversation/router.go b/manager/conversation/router.go index 881d3926..64706ab0 100644 --- a/manager/conversation/router.go +++ b/manager/conversation/router.go @@ -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) } } diff --git a/manager/conversation/shared.go b/manager/conversation/shared.go index 25c1a47b..7cf365cb 100644 --- a/manager/conversation/shared.go +++ b/manager/conversation/shared.go @@ -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 (