feat: support file viewer

This commit is contained in:
Zhang Minghan 2024-01-29 16:51:34 +08:00
parent 898fdd3b66
commit c627e5edec
11 changed files with 135 additions and 20 deletions

View File

@ -32,14 +32,14 @@ func (c *ChatInstance) CreateImageRequest(props ImageProps) (string, error) {
N: 1,
})
if err != nil || res == nil {
return "", fmt.Errorf("chatgpt error: %s", err.Error())
return "", fmt.Errorf(err.Error())
}
data := utils.MapToStruct[ImageResponse](res)
if data == nil {
return "", fmt.Errorf("chatgpt error: cannot parse response")
} else if data.Error.Message != "" {
return "", fmt.Errorf("chatgpt error: %s", data.Error.Message)
return "", fmt.Errorf(data.Error.Message)
}
return data.Data[0].Url, nil

View File

@ -0,0 +1,87 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { useTranslation } from "react-i18next";
import React from "react";
import { Heading2, Paperclip, Text } from "lucide-react";
import { Textarea } from "@/components/ui/textarea.tsx";
import { CodeMarkdown } from "@/components/Markdown.tsx";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group.tsx";
type FileViewerProps = {
filename: string;
content: string;
children: React.ReactNode;
asChild?: boolean;
};
enum viewerType {
Text = "text",
Image = "image",
}
function FileViewer({ filename, content, children, asChild }: FileViewerProps) {
const { t } = useTranslation();
const [renderedType, setRenderedType] = React.useState(viewerType.Text);
return (
<Dialog>
<DialogTrigger asChild={asChild}>{children}</DialogTrigger>
<DialogContent className={`flex-dialog`}>
<DialogHeader>
<DialogTitle className={`flex flex-row items-center select-none`}>
<Paperclip className={`h-4 w-4 mr-2`} />
{filename ?? t("file.file")}
</DialogTitle>
</DialogHeader>
<div className={`file-viewer-action`}>
<ToggleGroup
variant={`outline`}
type={`single`}
value={renderedType}
onValueChange={console.log}
>
<ToggleGroupItem
value={viewerType.Text}
onClick={() => setRenderedType(viewerType.Text)}
>
<Text className={`h-4 w-4`} />
</ToggleGroupItem>
<ToggleGroupItem
value={viewerType.Image}
onClick={() => setRenderedType(viewerType.Image)}
>
<Heading2 className={`h-4 w-4`} />
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className={`file-viewer-content`}>
{renderedType === viewerType.Text ? (
<Textarea
className={`file-viewer-textarea thin-scrollbar`}
value={content}
rows={15}
readOnly
/>
) : (
<div>
<CodeMarkdown
filename={filename}
codeStyle={`overflow-auto max-h-[60vh] thin-scrollbar`}
>
{content}
</CodeMarkdown>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
export default FileViewer;

View File

@ -31,6 +31,7 @@ type MarkdownProps = {
children: string;
className?: string;
acceptHtml?: boolean;
codeStyle?: string;
};
function doAction(dispatch: AppDispatch, url: string): boolean {
@ -72,7 +73,12 @@ function getSocialIcon(url: string) {
}
}
function MarkdownContent({ children, className, acceptHtml }: MarkdownProps) {
function MarkdownContent({
children,
className,
acceptHtml,
codeStyle,
}: MarkdownProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
const { toast } = useToast();
@ -144,11 +150,11 @@ function MarkdownContent({ children, className, acceptHtml }: MarkdownProps) {
PreTag="div"
wrapLongLines={true}
wrapLines={true}
className={`code-block`}
className={cn("code-block", codeStyle)}
/>
</div>
) : (
<code className={`code-inline ${className}`} {...props}>
<code className={cn("code-inline", className)} {...props}>
{children}
</code>
);
@ -158,17 +164,27 @@ function MarkdownContent({ children, className, acceptHtml }: MarkdownProps) {
);
}
function Markdown(props: MarkdownProps) {
function Markdown({ children, ...props }: MarkdownProps) {
// memoize the component
const { children, className, acceptHtml } = props;
return useMemo(
() => (
<MarkdownContent className={className} acceptHtml={acceptHtml}>
{children}
</MarkdownContent>
),
[props.children, props.className, props.acceptHtml],
() => <MarkdownContent {...props}>{children}</MarkdownContent>,
[props, children],
);
}
type CodeMarkdownProps = MarkdownProps & {
filename: string;
};
export function CodeMarkdown({ filename, ...props }: CodeMarkdownProps) {
const suffix = filename.includes(".") ? filename.split(".").pop() : "";
const children = useMemo(() => {
const content = props.children.toString();
return `\`\`\`${suffix}\n${content}\n\`\`\``;
}, [props.children]);
return <Markdown {...props}>{children}</Markdown>;
}
export default Markdown;

View File

@ -1,7 +1,8 @@
import { Download, File } from "lucide-react";
import { Download, Eye, File } from "lucide-react";
import { saveAsFile, saveBlobAsFile } from "@/utils/dom.ts";
import { useMemo } from "react";
import { Button } from "@/components/ui/button.tsx";
import FileViewer from "@/components/FileViewer.tsx";
/**
* file format:
@ -37,7 +38,7 @@ export function parseFile(data: string, acceptDownload?: boolean) {
<File className={`mr-1`} />
<span className={`name`}>{filename}</span>
<div className={`grow`} />
{image && (
{image ? (
<Button
variant={`ghost`}
size={`icon`}
@ -49,6 +50,10 @@ export function parseFile(data: string, acceptDownload?: boolean) {
>
<Download className={`cursor-pointer`} />
</Button>
) : (
<FileViewer filename={filename} content={content}>
<Eye className={`cursor-pointer`} />
</FileViewer>
)}
</div>
{image && <img src={image} className={`file-image`} alt={""} />}

View File

@ -241,6 +241,7 @@
"confirm": "确认",
"percent": "{{cent}}折",
"file": {
"file": "文件",
"upload": "上传文件",
"type": "支持 pdf, docx, pptx, xlsx, 图像, 文本等格式",
"drop": "拖拽文件到此处或点击上传",

View File

@ -207,7 +207,8 @@
"empty-file": "Empty File",
"empty-file-prompt": "File content is empty, has been automatically ignored",
"large-file-success": "Parsing Successful",
"large-file-success-prompt": "Large file parsed successfully in {{time}} seconds"
"large-file-success-prompt": "Large file parsed successfully in {{time}} seconds",
"file": "Files"
},
"generate": {
"title": "AI Project Generator",

View File

@ -207,7 +207,8 @@
"empty-file": "コンテンツファイルがありません",
"empty-file-prompt": "ファイルの内容が空で、自動的に無視されます",
"large-file-success": "解析に成功しました",
"large-file-success-prompt": "{{time}}秒で大きなファイルが正常に解析されました"
"large-file-success-prompt": "{{time}}秒で大きなファイルが正常に解析されました",
"file": "ファイル"
},
"generate": {
"title": "AIプロジェクトビルダー",

View File

@ -207,7 +207,8 @@
"empty-file": "Пустой файл",
"empty-file-prompt": "Содержимое файла пустое, автоматически проигнорировано",
"large-file-success": "Синтаксический анализ успешно завершен",
"large-file-success-prompt": "Большой файл успешно проанализирован за {{time}} секунды"
"large-file-success-prompt": "Большой файл успешно проанализирован за {{time}} секунды",
"file": "Документ"
},
"generate": {
"title": "Генератор AI проектов",

View File

@ -39,6 +39,10 @@ services:
links:
- mysql
- redis
ulimits:
nofile:
soft: 65535
hard: 65535
environment:
MYSQL_HOST: mysql
MYSQL_USER: chatnio

View File

@ -147,8 +147,6 @@ func (c *Connection) Process(handler func(*conversation.FormMessage) error) {
}
func (c *Connection) Handle(handler func(*conversation.FormMessage) error) {
defer c.conn.DeferClose()
go c.Process(handler)
c.ReadWorker()
}

View File

@ -58,6 +58,7 @@ func ChatAPI(c *gin.Context) {
if conn = utils.NewWebsocket(c, false); conn == nil {
return
}
defer conn.DeferClose()
db := utils.GetDBFromContext(c)