feat: 支持發送文件

* 目前還只能拖拽到輸入框
This commit is contained in:
CrescentLeaf
2025-09-24 21:33:16 +08:00
parent 276ce5cae8
commit d5fbc490ea
3 changed files with 72 additions and 22 deletions

View File

@@ -15,7 +15,9 @@ export type CallMethod =
"Chat.getInfo" | "Chat.getInfo" |
"Chat.sendMessage" | "Chat.sendMessage" |
"Chat.getMessageHistory" "Chat.getMessageHistory" |
"Chat.uploadFile"
export type ClientEvent = export type ClientEvent =
"Client.onMessage" "Client.onMessage"

View File

@@ -8,10 +8,11 @@ import Client from "../../api/Client.ts"
import Message from "../../api/client_data/Message.ts" import Message from "../../api/client_data/Message.ts"
import Chat from "../../api/client_data/Chat.ts" import Chat from "../../api/client_data/Chat.ts"
import data from "../../Data.ts" import data from "../../Data.ts"
import { checkApiSuccessOrSncakbar } from "../snackbar.ts" import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import useAsyncEffect from "../useAsyncEffect.ts" import useAsyncEffect from "../useAsyncEffect.ts"
import * as marked from 'marked' import * as marked from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import randomUUID from "../../randomUUID.ts"
interface Args extends React.HTMLAttributes<HTMLElement> { interface Args extends React.HTMLAttributes<HTMLElement> {
target: string target: string
@@ -25,12 +26,14 @@ const markedInstance = new marked.Marked({
const text = this.parser.parseInline(tokens) const text = this.parser.parseInline(tokens)
return `<span>${text}</span>` return `<span>${text}</span>`
}, },
paragraph({ tokens, depth: _depth }) { paragraph({ tokens }) {
const text = this.parser.parseInline(tokens) const text = this.parser.parseInline(tokens)
return `<span>${text}</span>` return `<span>${text}</span>`
}, },
image({ title, href }) { image({ text, href }) {
return `<chat-image src="${href}"></chat-image>` if (/uploaded_files\/[A-Za-z0-9]+$/.test(href))
return `<chat-image src="${href}" alt="${text}"></chat-image>`
return ``
} }
} }
}) })
@@ -85,12 +88,6 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
} }
setMessagesList(returnMsgs.concat(messagesList)) setMessagesList(returnMsgs.concat(messagesList))
if (page.current == 0)
setTimeout(() => chatPanelRef.current!.scrollTo({
top: 10000000000,
behavior: "smooth",
}), 100)
page.current++ page.current++
} }
@@ -119,8 +116,21 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
const [showLoadingMoreMessagesTip, setShowLoadingMoreMessagesTip] = React.useState(false) const [showLoadingMoreMessagesTip, setShowLoadingMoreMessagesTip] = React.useState(false)
const [showNoMoreMessagesTip, setShowNoMoreMessagesTip] = React.useState(false) const [showNoMoreMessagesTip, setShowNoMoreMessagesTip] = React.useState(false)
const cachedFiles = React.useRef({} as { [fileName: string]: ArrayBuffer })
async function sendMessage() { async function sendMessage() {
const text = inputRef.current!.value let text = inputRef.current!.value
for (const fileName of Object.keys(cachedFiles.current)) {
if (text.indexOf(fileName) != -1) {
const re = await Client.invoke("Chat.uploadFile", {
token: data.access_token,
file_name: fileName,
target,
data: cachedFiles.current[fileName],
}, 5000)
if (checkApiSuccessOrSncakbar(re, `文件[${fileName}] 上傳失敗`)) return
text = text.replaceAll(fileName, re.data!.file_path as string)
}
}
const re = await Client.invoke("Chat.sendMessage", { const re = await Client.invoke("Chat.sendMessage", {
token: data.access_token, token: data.access_token,
@@ -129,6 +139,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
}, 5000) }, 5000)
if (checkApiSuccessOrSncakbar(re, "發送失敗")) return if (checkApiSuccessOrSncakbar(re, "發送失敗")) return
inputRef.current!.value = '' inputRef.current!.value = ''
cachedFiles.current = {}
} }
return ( return (
@@ -203,14 +214,14 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
<Element_Message <Element_Message
key={msg.id} key={msg.id}
userId={msg.user_id}> userId={msg.user_id}>
<div dangerouslySetInnerHTML={{ <div dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(markedInstance.parse(msg.text) as string, { __html: DOMPurify.sanitize(markedInstance.parse(msg.text) as string, {
ALLOWED_TAGS: [ ALLOWED_TAGS: [
"chat-image", "chat-image",
"span", "span",
"chat-link", "chat-link",
] ]
}) })
}}></div> }}></div>
</Element_Message> </Element_Message>
) )
@@ -230,18 +241,55 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
paddingRight: '4px', paddingRight: '4px',
backgroundColor: 'rgb(var(--mdui-color-surface))', backgroundColor: 'rgb(var(--mdui-color-surface))',
}} onDrop={(e) => { }} onDrop={(e) => {
if (e.dataTransfer.files) { const input = inputRef.current!.shadowRoot!.querySelector('[part=input]') as HTMLTextAreaElement
const files = e.dataTransfer.files function insertText(text: string) {
inputRef.current!.value = input.value!.substring(0, input.selectionStart as number) + text + input.value!.substring(input.selectionEnd as number, input.value.length)
}
async function addFile(type: string, name: string, data: Blob | Response) {
cachedFiles.current![name] = await data.arrayBuffer()
if (type.startsWith('image/'))
insertText(`![圖片](${name})`)
else
insertText(`![File=${name}](${name})`)
}
function getFileNameOrRandom(urlString: string) {
const url = new URL(urlString)
let filename = url.pathname.substring(url.pathname.lastIndexOf('/') + 1).trim()
if (filename == '')
filename = 'file_' + randomUUID()
return filename
}
if (e.dataTransfer.items.length > 0) {
// 基于当前的实现, 浏览器不会读取文件的字节流来确定其媒体类型, 其根据文件扩展名进行假设 // 基于当前的实现, 浏览器不会读取文件的字节流来确定其媒体类型, 其根据文件扩展名进行假设
// https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/type // https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/type
for (const file of files) { for (const item of e.dataTransfer.items) {
if (file.type.startsWith("image/")) { if (item.type == 'text/uri-list') {
item.getAsString(async (url) => {
try {
// 即便是 no-cors 還是殘廢, 因此暫時沒有什麽想法
const re = await fetch(url)
const type = re.headers.get("Content-Type")
if (type?.startsWith("image/"))
addFile(type as string, getFileNameOrRandom(url), re)
} catch (e) {
snackbar({
message: '無法解析連結: ' + (e as Error).message,
placement: 'top',
})
}
})
} else if (item.kind == 'file') {
e.preventDefault()
const file = item.getAsFile() as File
addFile(item.type, file.name, file)
} }
} }
} }
}}> }}>
<mdui-text-field variant="outlined" placeholder="喵呜~" autosize ref={inputRef as any} max-rows={6} onKeyDown={(event) => { <mdui-text-field variant="outlined" placeholder="喵呜~" autosize ref={inputRef as any} max-rows={6} onChange={() => {
if (inputRef.current?.value.trim() == '')
cachedFiles.current = {}
}} onKeyDown={(event) => {
if (event.ctrlKey && event.key == 'Enter') if (event.ctrlKey && event.key == 'Enter')
sendMessage() sendMessage()
}} style={{ }} style={{

View File

@@ -182,13 +182,13 @@ export default class ChatApi extends BaseApi {
msg: "用戶無權訪問該對話", msg: "用戶無權訪問該對話",
} }
const file = await FileManager.uploadFile(args.file_name as string, args.data as Buffer<ArrayBufferLike>) const file = await FileManager.uploadFile(args.file_name as string, args.data as Buffer<ArrayBufferLike>, args.target as string)
return { return {
code: 200, code: 200,
msg: "成功", msg: "成功",
data: { data: {
messages: MessagesManager.getInstanceForChat(chat).getMessagesWithPage(15, args.page as number), file_path: 'uploaded_files/' + file.getHash()
}, },
} }
}) })