mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7e1fe31085 | ||
![]() |
c3cba8ba4e | ||
![]() |
ba619986c9 | ||
![]() |
dcef3f3c3b | ||
![]() |
823faa2790 | ||
![]() |
ef4248d2a3 | ||
![]() |
3917cb0dc9 | ||
![]() |
520cec0eaa | ||
![]() |
e7655e0ff6 |
@@ -4,7 +4,7 @@
|
|||||||
"name": "NapCatQQ",
|
"name": "NapCatQQ",
|
||||||
"slug": "NapCat.Framework",
|
"slug": "NapCat.Framework",
|
||||||
"description": "高性能的 OneBot 11 协议实现",
|
"description": "高性能的 OneBot 11 协议实现",
|
||||||
"version": "4.4.10",
|
"version": "4.4.12",
|
||||||
"icon": "./logo.png",
|
"icon": "./logo.png",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
|
@@ -10,8 +10,6 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/loader": "^1.4.0",
|
|
||||||
"@monaco-editor/react": "4.7.0-rc.0",
|
|
||||||
"@heroui/avatar": "2.2.7",
|
"@heroui/avatar": "2.2.7",
|
||||||
"@heroui/breadcrumbs": "2.2.7",
|
"@heroui/breadcrumbs": "2.2.7",
|
||||||
"@heroui/button": "2.2.10",
|
"@heroui/button": "2.2.10",
|
||||||
@@ -38,6 +36,8 @@
|
|||||||
"@heroui/tabs": "2.2.8",
|
"@heroui/tabs": "2.2.8",
|
||||||
"@heroui/theme": "2.4.6",
|
"@heroui/theme": "2.4.6",
|
||||||
"@heroui/tooltip": "2.2.8",
|
"@heroui/tooltip": "2.2.8",
|
||||||
|
"@monaco-editor/loader": "^1.4.0",
|
||||||
|
"@monaco-editor/react": "4.7.0-rc.0",
|
||||||
"@react-aria/visually-hidden": "3.8.18",
|
"@react-aria/visually-hidden": "3.8.18",
|
||||||
"@reduxjs/toolkit": "^2.5.0",
|
"@reduxjs/toolkit": "^2.5.0",
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
@@ -62,11 +62,13 @@
|
|||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
|
"react-markdown": "^9.0.3",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-responsive": "^10.0.0",
|
"react-responsive": "^10.0.0",
|
||||||
"react-router-dom": "7.1.0",
|
"react-router-dom": "7.1.0",
|
||||||
"react-use-websocket": "^4.11.1",
|
"react-use-websocket": "^4.11.1",
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
"tailwind-variants": "0.3.0",
|
"tailwind-variants": "0.3.0",
|
||||||
"tailwindcss": "3.4.17",
|
"tailwindcss": "3.4.17",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
@@ -187,7 +187,7 @@ export default function AudioPlayer(props: AudioPlayerProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fixed right-0 bottom-0 z-[9999] w-full md:w-96',
|
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
|
||||||
!translateX && !translateY && 'transition-transform',
|
!translateX && !translateY && 'transition-transform',
|
||||||
isCollapsed && 'md:hover:!translate-x-80'
|
isCollapsed && 'md:hover:!translate-x-80'
|
||||||
)}
|
)}
|
||||||
|
@@ -19,12 +19,6 @@ loader.config({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
loader.config({
|
|
||||||
'vs/nls': {
|
|
||||||
availableLanguages: { '*': 'zh-cn' }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
|
||||||
test?: string
|
test?: string
|
||||||
}
|
}
|
||||||
|
@@ -1125,7 +1125,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1173,7 +1173,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1220,7 +1220,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1270,7 +1270,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1320,7 +1320,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1372,7 +1372,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1422,7 +1422,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1469,7 +1469,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1519,7 +1519,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1566,7 +1566,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1613,7 +1613,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1660,7 +1660,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
@@ -1707,7 +1707,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
|
|||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
id="svgGroup"
|
id="svgGroup"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke="#000"
|
stroke="#000"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
style={{
|
style={{
|
||||||
|
@@ -47,7 +47,6 @@ const RealTimeLogs = () => {
|
|||||||
}
|
}
|
||||||
return logLevel.has(log.level)
|
return logLevel.has(log.level)
|
||||||
})
|
})
|
||||||
.slice(-100)
|
|
||||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||||
.join('\r\n')
|
.join('\r\n')
|
||||||
Xterm.current?.clear()
|
Xterm.current?.clear()
|
||||||
@@ -65,7 +64,6 @@ const RealTimeLogs = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscribeLogs = () => {
|
const subscribeLogs = () => {
|
||||||
try {
|
try {
|
||||||
console.log('subscribeLogs')
|
|
||||||
const source = WebUIManager.getRealTimeLogs((data) => {
|
const source = WebUIManager.getRealTimeLogs((data) => {
|
||||||
setDataArr((prev) => {
|
setDataArr((prev) => {
|
||||||
const newData = [...prev, ...data]
|
const newData = [...prev, ...data]
|
||||||
|
@@ -13,12 +13,15 @@ export interface ModalProps {
|
|||||||
content: React.ReactNode
|
content: React.ReactNode
|
||||||
title?: React.ReactNode
|
title?: React.ReactNode
|
||||||
size?: React.ComponentProps<typeof NextUIModal>['size']
|
size?: React.ComponentProps<typeof NextUIModal>['size']
|
||||||
|
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
onConfirm?: () => void
|
onConfirm?: () => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
backdrop?: 'opaque' | 'blur' | 'transparent'
|
backdrop?: 'opaque' | 'blur' | 'transparent'
|
||||||
showCancel?: boolean
|
showCancel?: boolean
|
||||||
dismissible?: boolean
|
dismissible?: boolean
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<ModalProps> = React.memo((props) => {
|
const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||||
@@ -26,12 +29,14 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
|||||||
backdrop = 'blur',
|
backdrop = 'blur',
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
size = 'md',
|
|
||||||
showCancel = true,
|
showCancel = true,
|
||||||
dismissible,
|
dismissible,
|
||||||
|
confirmText = '确定',
|
||||||
|
cancelText = '取消',
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel
|
onCancel,
|
||||||
|
...rest
|
||||||
} = props
|
} = props
|
||||||
const { onClose: onNativeClose } = useDisclosure()
|
const { onClose: onNativeClose } = useDisclosure()
|
||||||
|
|
||||||
@@ -44,11 +49,11 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
|||||||
onClose?.()
|
onClose?.()
|
||||||
onNativeClose()
|
onNativeClose()
|
||||||
}}
|
}}
|
||||||
size={size}
|
|
||||||
classNames={{
|
classNames={{
|
||||||
backdrop: 'z-[99999999]',
|
backdrop: 'z-[99]',
|
||||||
wrapper: 'z-[999999999]'
|
wrapper: 'z-[99]'
|
||||||
}}
|
}}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(nativeClose) => (
|
{(nativeClose) => (
|
||||||
@@ -56,7 +61,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
|||||||
{title && (
|
{title && (
|
||||||
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
|
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
|
||||||
)}
|
)}
|
||||||
<ModalBody>{content}</ModalBody>
|
<ModalBody className="break-all">{content}</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
{showCancel && (
|
{showCancel && (
|
||||||
<Button
|
<Button
|
||||||
@@ -67,17 +72,17 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
|
|||||||
nativeClose()
|
nativeClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
取消
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="danger"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onConfirm?.()
|
onConfirm?.()
|
||||||
nativeClose()
|
nativeClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
确定
|
{confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
|
@@ -4,7 +4,7 @@ import { Input } from '@heroui/input'
|
|||||||
import { Snippet } from '@heroui/snippet'
|
import { Snippet } from '@heroui/snippet'
|
||||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { IoLink, IoSend } from 'react-icons/io5'
|
import { IoLink, IoSend } from 'react-icons/io5'
|
||||||
import { PiCatDuotone } from 'react-icons/pi'
|
import { PiCatDuotone } from 'react-icons/pi'
|
||||||
@@ -41,6 +41,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false)
|
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false)
|
||||||
const [isResponseOpen, setIsResponseOpen] = useState(false)
|
const [isResponseOpen, setIsResponseOpen] = useState(false)
|
||||||
const [isFetching, setIsFetching] = useState(false)
|
const [isFetching, setIsFetching] = useState(false)
|
||||||
|
const responseRef = useRef<HTMLDivElement>(null)
|
||||||
const parsedRequest = parse(data.request)
|
const parsedRequest = parse(data.request)
|
||||||
const parsedResponse = parse(data.response)
|
const parsedResponse = parse(data.response)
|
||||||
|
|
||||||
@@ -69,6 +70,11 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsFetching(false)
|
setIsFetching(false)
|
||||||
|
setIsResponseOpen(true)
|
||||||
|
responseRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
})
|
||||||
toast.dismiss(r)
|
toast.dismiss(r)
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -84,8 +90,8 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
}, [path])
|
}, [path])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-4 rounded-lg shadow-md">
|
<section className="p-4 pt-14 rounded-lg shadow-md">
|
||||||
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-danger-400 ">
|
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-danger-400">
|
||||||
<PiCatDuotone />
|
<PiCatDuotone />
|
||||||
{data.description}
|
{data.description}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -93,6 +99,9 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
<Snippet
|
<Snippet
|
||||||
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
|
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
|
||||||
symbol={<IoLink size={18} className="inline-block mr-1" />}
|
symbol={<IoLink size={18} className="inline-block mr-1" />}
|
||||||
|
tooltipProps={{
|
||||||
|
content: '点击复制地址'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{path}
|
{path}
|
||||||
</Snippet>
|
</Snippet>
|
||||||
@@ -125,7 +134,10 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
<IoSend />
|
<IoSend />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card shadow="sm" className="my-4 bg-opacity-50 backdrop-blur-md">
|
<Card
|
||||||
|
shadow="sm"
|
||||||
|
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
|
||||||
|
>
|
||||||
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
<CardHeader className="font-noto-serif font-bold text-lg gap-1 pb-0">
|
||||||
<span className="mr-2">请求体</span>
|
<span className="mr-2">请求体</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -140,7 +152,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="overflow-hidden"
|
ref={responseRef}
|
||||||
initial={{ opacity: 0, height: 0 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
animate={{
|
animate={{
|
||||||
opacity: isCodeEditorOpen ? 1 : 0,
|
opacity: isCodeEditorOpen ? 1 : 0,
|
||||||
@@ -223,7 +235,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
|||||||
<h2 className="text-xl font-semibold mt-4 mb-2">响应体结构</h2>
|
<h2 className="text-xl font-semibold mt-4 mb-2">响应体结构</h2>
|
||||||
<DisplayStruct schema={parsedResponse} />
|
<DisplayStruct schema={parsedResponse} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -19,9 +19,8 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex-shrink-0 absolute md:!top-0 md:bottom-0 left-0 !overflow-hidden md:relative md:w-auto z-20',
|
'h-[calc(100vh-3.5rem)] left-0 !overflow-hidden md:w-auto z-20 top-[3.3rem] md:top-[3rem] absolute md:sticky md:float-start',
|
||||||
openSideBar &&
|
openSideBar && 'bg-background bg-opacity-20 backdrop-blur-md'
|
||||||
'bottom-8 z-10 bg-background bg-opacity-20 backdrop-blur-md top-14'
|
|
||||||
)}
|
)}
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
transition={{
|
transition={{
|
||||||
@@ -32,7 +31,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
|
|||||||
animate={{ width: openSideBar ? '16rem' : '0rem' }}
|
animate={{ width: openSideBar ? '16rem' : '0rem' }}
|
||||||
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
|
style={{ overflowY: openSideBar ? 'auto' : 'hidden' }}
|
||||||
>
|
>
|
||||||
<div className="w-64 h-full overflow-y-auto px-2 float-right">
|
<div className="w-64 h-full overflow-y-auto px-2 pt-2 pb-10 md:pb-0">
|
||||||
<Input
|
<Input
|
||||||
className="sticky top-0 z-10 text-danger-600"
|
className="sticky top-0 z-10 text-danger-600"
|
||||||
classNames={{
|
classNames={{
|
||||||
|
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useCallback, useRef } from 'react'
|
import { useCallback, useRef } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
import ChatInputModal from '@/components/chat_input/modal'
|
||||||
import CodeEditor from '@/components/code_editor'
|
import CodeEditor from '@/components/code_editor'
|
||||||
import type { CodeEditorRef } from '@/components/code_editor'
|
import type { CodeEditorRef } from '@/components/code_editor'
|
||||||
|
|
||||||
@@ -72,6 +73,8 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
<ChatInputModal />
|
||||||
|
|
||||||
<Button color="danger" variant="flat" onPress={onClose}>
|
<Button color="danger" variant="flat" onPress={onClose}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
|
@@ -1,45 +1,189 @@
|
|||||||
|
import { Button } from '@heroui/button'
|
||||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||||
|
import { Chip } from '@heroui/chip'
|
||||||
import { Spinner } from '@heroui/spinner'
|
import { Spinner } from '@heroui/spinner'
|
||||||
|
import { Tooltip } from '@heroui/tooltip'
|
||||||
import { useRequest } from 'ahooks'
|
import { useRequest } from 'ahooks'
|
||||||
import { FaCircleInfo } from 'react-icons/fa6'
|
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
|
||||||
import { FaQq } from 'react-icons/fa6'
|
|
||||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
|
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
|
||||||
import { RiMacFill } from 'react-icons/ri'
|
import { RiMacFill } from 'react-icons/ri'
|
||||||
|
|
||||||
|
import useDialog from '@/hooks/use-dialog'
|
||||||
|
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { compareVersion } from '@/utils/version'
|
||||||
|
|
||||||
import WebUIManager from '@/controllers/webui_manager'
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
|
import { GithubRelease } from '@/types/github'
|
||||||
|
|
||||||
import packageJson from '../../package.json'
|
import packageJson from '../../package.json'
|
||||||
|
import TailwindMarkdown from './tailwind_markdown'
|
||||||
|
|
||||||
export interface SystemInfoItemProps {
|
export interface SystemInfoItemProps {
|
||||||
title: string
|
title: string
|
||||||
icon?: React.ReactNode
|
icon?: React.ReactNode
|
||||||
value?: React.ReactNode
|
value?: React.ReactNode
|
||||||
|
endContent?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||||
title,
|
title,
|
||||||
value = '--',
|
value = '--',
|
||||||
icon
|
icon,
|
||||||
|
endContent
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-danger-50 dark:shadow-danger-100 rounded text-danger-400">
|
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-danger-50 dark:shadow-danger-100 rounded text-danger-400">
|
||||||
{icon}
|
{icon}
|
||||||
<div className="w-24">{title}</div>
|
<div className="w-24">{title}</div>
|
||||||
<div className="text-danger-200">{value}</div>
|
<div className="text-danger-200">{value}</div>
|
||||||
|
<div className="ml-auto">{endContent}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NewVersionTipProps {
|
||||||
|
currentVersion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewVersionTip = (props: NewVersionTipProps) => {
|
||||||
|
const { currentVersion } = props
|
||||||
|
const dialog = useDialog()
|
||||||
|
const { data: releaseData, error } = useRequest(() =>
|
||||||
|
request.get<GithubRelease[]>(
|
||||||
|
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Tooltip content="检查新版本失败">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
radius="full"
|
||||||
|
color="danger"
|
||||||
|
variant="shadow"
|
||||||
|
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||||
|
onPress={() => {
|
||||||
|
dialog.alert({
|
||||||
|
title: '检查新版本失败',
|
||||||
|
content: error.message
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaInfo />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersion = releaseData?.data?.[0]?.tag_name
|
||||||
|
|
||||||
|
if (!latestVersion || !currentVersion) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compareVersion(latestVersion, currentVersion) <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const middleVersions: GithubRelease[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < releaseData.data.length; i++) {
|
||||||
|
const versionInfo = releaseData.data[i]
|
||||||
|
if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
|
||||||
|
middleVersions.push(versionInfo)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content="有新版本可用">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
radius="full"
|
||||||
|
color="danger"
|
||||||
|
variant="shadow"
|
||||||
|
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||||
|
onPress={() => {
|
||||||
|
dialog.confirm({
|
||||||
|
title: '有新版本可用',
|
||||||
|
content: (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm space-x-2">
|
||||||
|
<span>当前版本</span>
|
||||||
|
<Chip color="primary" variant="flat">
|
||||||
|
v{currentVersion}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm space-x-2">
|
||||||
|
<span>最新版本</span>
|
||||||
|
<Chip color="primary">{latestVersion}</Chip>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm space-y-2 !mt-4">
|
||||||
|
{middleVersions.map((versionInfo) => (
|
||||||
|
<div
|
||||||
|
key={versionInfo.tag_name}
|
||||||
|
className="p-4 bg-content1 rounded-md shadow-small"
|
||||||
|
>
|
||||||
|
<TailwindMarkdown content={versionInfo.body} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
scrollBehavior: 'inside',
|
||||||
|
size: '3xl',
|
||||||
|
confirmText: '前往下载',
|
||||||
|
onConfirm() {
|
||||||
|
window.open(
|
||||||
|
'https://github.com/NapNeko/NapCatQQ/releases',
|
||||||
|
'_blank',
|
||||||
|
'noopener'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaInfo />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NapCatVersion = () => {
|
||||||
|
const {
|
||||||
|
data: packageData,
|
||||||
|
loading: packageLoading,
|
||||||
|
error: packageError
|
||||||
|
} = useRequest(WebUIManager.getPackageInfo)
|
||||||
|
|
||||||
|
const currentVersion = packageData?.version
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SystemInfoItem
|
||||||
|
title="NapCat 版本"
|
||||||
|
icon={<IoLogoOctocat className="text-xl" />}
|
||||||
|
value={
|
||||||
|
packageError ? (
|
||||||
|
`错误:${packageError.message}`
|
||||||
|
) : packageLoading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
currentVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
endContent={<NewVersionTip currentVersion={currentVersion} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemInfoProps {
|
export interface SystemInfoProps {
|
||||||
archInfo?: string
|
archInfo?: string
|
||||||
}
|
}
|
||||||
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||||
const { archInfo } = props
|
const { archInfo } = props
|
||||||
const {
|
|
||||||
data: packageData,
|
|
||||||
loading: packageLoading,
|
|
||||||
error: packageError
|
|
||||||
} = useRequest(WebUIManager.getPackageInfo)
|
|
||||||
const {
|
const {
|
||||||
data: qqVersionData,
|
data: qqVersionData,
|
||||||
loading: qqVersionLoading,
|
loading: qqVersionLoading,
|
||||||
@@ -53,19 +197,7 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="flex-1">
|
<CardBody className="flex-1">
|
||||||
<div className="flex flex-col justify-between h-full">
|
<div className="flex flex-col justify-between h-full">
|
||||||
<SystemInfoItem
|
<NapCatVersion />
|
||||||
title="NapCat 版本"
|
|
||||||
icon={<IoLogoOctocat className="text-xl" />}
|
|
||||||
value={
|
|
||||||
packageError ? (
|
|
||||||
`错误:${packageError.message}`
|
|
||||||
) : packageLoading ? (
|
|
||||||
<Spinner size="sm" />
|
|
||||||
) : (
|
|
||||||
packageData?.version
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SystemInfoItem
|
<SystemInfoItem
|
||||||
title="WebUI 版本"
|
title="WebUI 版本"
|
||||||
icon={<IoLogoChrome className="text-xl" />}
|
icon={<IoLogoChrome className="text-xl" />}
|
||||||
|
49
napcat.webui/src/components/tailwind_markdown.tsx
Normal file
49
napcat.webui/src/components/tailwind_markdown.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Markdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
||||||
|
return (
|
||||||
|
<Markdown
|
||||||
|
className="prose prose-sm sm:prose lg:prose-lg xl:prose-xl"
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
h1: ({ node, ...props }) => (
|
||||||
|
<h1 className="text-2xl font-bold" {...props} />
|
||||||
|
),
|
||||||
|
h2: ({ node, ...props }) => (
|
||||||
|
<h2 className="text-xl font-bold" {...props} />
|
||||||
|
),
|
||||||
|
h3: ({ node, ...props }) => (
|
||||||
|
<h3 className="text-lg font-bold" {...props} />
|
||||||
|
),
|
||||||
|
p: ({ node, ...props }) => <p className="m-0" {...props} />,
|
||||||
|
a: ({ node, ...props }) => (
|
||||||
|
<a
|
||||||
|
className="text-blue-500 hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
ul: ({ node, ...props }) => (
|
||||||
|
<ul className="list-disc list-inside" {...props} />
|
||||||
|
),
|
||||||
|
ol: ({ node, ...props }) => (
|
||||||
|
<ol className="list-decimal list-inside" {...props} />
|
||||||
|
),
|
||||||
|
blockquote: ({ node, ...props }) => (
|
||||||
|
<blockquote
|
||||||
|
className="border-l-4 border-gray-300 pl-4 italic"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
code: ({ node, ...props }) => (
|
||||||
|
<code className="bg-gray-100 p-1 rounded" {...props} />
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Markdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TailwindMarkdown
|
@@ -21,6 +21,7 @@ export type XTermRef = {
|
|||||||
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
|
||||||
clear: () => void
|
clear: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const domRef = useRef<HTMLDivElement>(null)
|
const domRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -33,15 +34,24 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
|||||||
}
|
}
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace'
|
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
|
||||||
|
cursorInactiveStyle: 'outline',
|
||||||
|
drawBoldTextInBrightColors: false,
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 1.0
|
||||||
})
|
})
|
||||||
terminalRef.current = terminal
|
terminalRef.current = terminal
|
||||||
const fitAddon = new FitAddon()
|
const fitAddon = new FitAddon()
|
||||||
terminal.loadAddon(new WebLinksAddon())
|
terminal.loadAddon(
|
||||||
|
new WebLinksAddon((event, uri) => {
|
||||||
|
if (event.ctrlKey) {
|
||||||
|
window.open(uri, '_blank')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
terminal.loadAddon(fitAddon)
|
terminal.loadAddon(fitAddon)
|
||||||
terminal.loadAddon(new WebglAddon())
|
terminal.loadAddon(new WebglAddon())
|
||||||
terminal.open(domRef.current)
|
terminal.open(domRef.current)
|
||||||
fitAddon.fit()
|
|
||||||
|
|
||||||
terminal.writeln(
|
terminal.writeln(
|
||||||
gradientText(
|
gradientText(
|
||||||
@@ -57,16 +67,16 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
|||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
})
|
})
|
||||||
resizeObserver.observe(domRef.current)
|
|
||||||
|
|
||||||
const handleFontLoad = () => {
|
// 字体加载完成后重新调整终端大小
|
||||||
terminal.refresh(0, terminal.rows - 1)
|
document.fonts.ready.then(() => {
|
||||||
}
|
fitAddon.fit()
|
||||||
document.fonts.addEventListener('loadingdone', handleFontLoad)
|
|
||||||
|
resizeObserver.observe(domRef.current!)
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect()
|
resizeObserver.disconnect()
|
||||||
document.fonts.removeEventListener('loadingdone', handleFontLoad)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
terminal.dispose()
|
terminal.dispose()
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -76,14 +86,20 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
terminalRef.current.options.theme = {
|
terminalRef.current.options.theme = {
|
||||||
background:
|
background: theme === 'dark' ? '#00000000' : '#ffffff00',
|
||||||
theme === 'dark' ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)',
|
|
||||||
foreground: theme === 'dark' ? '#fff' : '#000',
|
foreground: theme === 'dark' ? '#fff' : '#000',
|
||||||
selectionBackground: theme === 'dark' ? '#666' : '#ddd',
|
selectionBackground:
|
||||||
|
theme === 'dark'
|
||||||
|
? 'rgba(179, 0, 0, 0.3)'
|
||||||
|
: 'rgba(255, 167, 167, 0.3)',
|
||||||
cursor: theme === 'dark' ? '#fff' : '#000',
|
cursor: theme === 'dark' ? '#fff' : '#000',
|
||||||
cursorAccent: theme === 'dark' ? '#000' : '#fff',
|
cursorAccent: theme === 'dark' ? '#000' : '#fff',
|
||||||
black: theme === 'dark' ? '#fff' : '#000'
|
black: theme === 'dark' ? '#fff' : '#000'
|
||||||
}
|
}
|
||||||
|
terminalRef.current.options.fontWeight =
|
||||||
|
theme === 'dark' ? 'normal' : '600'
|
||||||
|
terminalRef.current.options.fontWeightBold =
|
||||||
|
theme === 'dark' ? 'bold' : '900'
|
||||||
}
|
}
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
|
@@ -1,22 +1,15 @@
|
|||||||
// Dialog Context
|
// Dialog Context
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import type { ModalProps } from '@/components/modal'
|
|
||||||
import Modal from '@/components/modal'
|
import Modal from '@/components/modal'
|
||||||
|
import type { ModalProps } from '@/components/modal'
|
||||||
|
|
||||||
export interface AlertProps {
|
export interface AlertProps
|
||||||
title?: React.ReactNode
|
extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
|
||||||
content: React.ReactNode
|
|
||||||
size?: ModalProps['size']
|
|
||||||
dismissible?: boolean
|
|
||||||
onConfirm?: () => void
|
onConfirm?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmProps {
|
export interface ConfirmProps extends ModalProps {
|
||||||
title?: React.ReactNode
|
|
||||||
content: React.ReactNode
|
|
||||||
size?: ModalProps['size']
|
|
||||||
dismissible?: boolean
|
|
||||||
onConfirm?: () => void
|
onConfirm?: () => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
}
|
}
|
||||||
@@ -42,7 +35,7 @@ export const DialogContext = React.createContext<DialogContextProps>({
|
|||||||
const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
||||||
const [dialogs, setDialogs] = React.useState<ModalItem[]>([])
|
const [dialogs, setDialogs] = React.useState<ModalItem[]>([])
|
||||||
const alert = (config: AlertProps) => {
|
const alert = (config: AlertProps) => {
|
||||||
const { title, content, dismissible, onConfirm, size = 'md' } = config
|
const { onConfirm, size = 'md', ...rest } = config
|
||||||
|
|
||||||
setDialogs((before) => {
|
setDialogs((before) => {
|
||||||
const id = before[before.length - 1]?.id + 1 || 0
|
const id = before[before.length - 1]?.id + 1 || 0
|
||||||
@@ -51,32 +44,23 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
|||||||
...before,
|
...before,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
content,
|
|
||||||
size,
|
size,
|
||||||
title,
|
|
||||||
backdrop: 'blur',
|
backdrop: 'blur',
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
dismissible: dismissible,
|
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onConfirm?.()
|
onConfirm?.()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDialogs((before) => before.filter((item) => item.id !== id))
|
setDialogs((before) => before.filter((item) => item.id !== id))
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
},
|
||||||
|
...rest
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirm = (config: ConfirmProps) => {
|
const confirm = (config: ConfirmProps) => {
|
||||||
const {
|
const { onConfirm, onCancel, size = 'md', ...rest } = config
|
||||||
title,
|
|
||||||
content,
|
|
||||||
dismissible,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
size = 'md'
|
|
||||||
} = config
|
|
||||||
|
|
||||||
setDialogs((before) => {
|
setDialogs((before) => {
|
||||||
const id = before[before.length - 1]?.id + 1 || 0
|
const id = before[before.length - 1]?.id + 1 || 0
|
||||||
@@ -85,12 +69,9 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
|||||||
...before,
|
...before,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
content,
|
|
||||||
size,
|
size,
|
||||||
title,
|
|
||||||
backdrop: 'blur',
|
backdrop: 'blur',
|
||||||
showCancel: true,
|
showCancel: true,
|
||||||
dismissible: dismissible,
|
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onConfirm?.()
|
onConfirm?.()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -102,7 +83,8 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDialogs((before) => before.filter((item) => item.id !== id))
|
setDialogs((before) => before.filter((item) => item.id !== id))
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
},
|
||||||
|
...rest
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@@ -2,7 +2,7 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
|
|||||||
import { Button } from '@heroui/button'
|
import { Button } from '@heroui/button'
|
||||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { ErrorBoundary } from 'react-error-boundary'
|
import { ErrorBoundary } from 'react-error-boundary'
|
||||||
import { MdMenu, MdMenuOpen } from 'react-icons/md'
|
import { MdMenu, MdMenuOpen } from 'react-icons/md'
|
||||||
@@ -79,7 +79,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
}, [location.pathname])
|
}, [location.pathname])
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-screen relative flex bg-danger-50 dark:bg-black"
|
className="h-screen relative flex bg-danger-50 dark:bg-black items-stretch"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${b64img})`,
|
backgroundImage: `url(${b64img})`,
|
||||||
backgroundSize: 'cover'
|
backgroundSize: 'cover'
|
||||||
@@ -89,13 +89,22 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'overflow-y-auto relative flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
|
'overflow-y-auto flex-1 rounded-md m-1 bg-content1 pb-10 md:pb-0',
|
||||||
openSideBar ? 'ml-0' : 'ml-1',
|
openSideBar ? 'ml-0' : 'ml-1',
|
||||||
!b64img && 'shadow-inner',
|
!b64img && 'shadow-inner',
|
||||||
b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background'
|
b64img && '!bg-opacity-50 backdrop-blur-none dark:bg-background',
|
||||||
|
'overflow-x-hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="h-10 flex items-center hm-medium text-xl sticky top-2 left-0 backdrop-blur-lg z-20 shadow-sm bg-background dark:bg-background shadow-danger-50 dark:shadow-danger-100 m-2 rounded-full !bg-opacity-50">
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'h-10 flex items-center hm-medium text-xl backdrop-blur-lg rounded-full',
|
||||||
|
'dark:bg-background dark:shadow-danger-100',
|
||||||
|
'bg-background !bg-opacity-50',
|
||||||
|
'shadow-sm shadow-danger-50',
|
||||||
|
'z-30 m-2 mb-0 sticky top-2 left-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'mr-1 ease-in-out ml-0 md:relative',
|
'mr-1 ease-in-out ml-0 md:relative',
|
||||||
@@ -117,7 +126,19 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
<Breadcrumbs isDisabled size="lg">
|
<Breadcrumbs isDisabled size="lg">
|
||||||
{title?.map((item, index) => (
|
{title?.map((item, index) => (
|
||||||
<BreadcrumbItem key={index}>{item}</BreadcrumbItem>
|
<BreadcrumbItem key={index}>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={item}
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 10 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</BreadcrumbItem>
|
||||||
))}
|
))}
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,12 +1,10 @@
|
|||||||
import { Chip } from '@heroui/chip'
|
import { Chip } from '@heroui/chip'
|
||||||
import { Image } from '@heroui/image'
|
import { Image } from '@heroui/image'
|
||||||
import { Link } from '@heroui/link'
|
|
||||||
import { Spinner } from '@heroui/spinner'
|
import { Spinner } from '@heroui/spinner'
|
||||||
import { Tooltip } from '@heroui/tooltip'
|
|
||||||
import { useRequest } from 'ahooks'
|
import { useRequest } from 'ahooks'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { BietiaopIcon, GithubIcon, WebUIIcon } from '@/components/icons'
|
import { BietiaopIcon, WebUIIcon } from '@/components/icons'
|
||||||
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
||||||
import { title } from '@/components/primitives'
|
import { title } from '@/components/primitives'
|
||||||
|
|
||||||
@@ -43,11 +41,6 @@ function VersionInfo() {
|
|||||||
data?.version
|
data?.version
|
||||||
)}
|
)}
|
||||||
</Chip>
|
</Chip>
|
||||||
<Tooltip content="查看WebUI源码" placement="bottom" showArrow>
|
|
||||||
<Link isExternal href="https://github.com/bietiaop/NextNapCatWebUI">
|
|
||||||
<GithubIcon className="text-default-900 hover:text-default-600 w-8 h-8 hover:drop-shadow-lg transition-all" />
|
|
||||||
</Link>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -27,41 +27,36 @@ export default function HttpDebug() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>HTTP调试 - NapCat WebUI</title>
|
<title>HTTP调试 - NapCat WebUI</title>
|
||||||
<div className="w-full h-[calc(100%-3.6rem)] flex items-stretch">
|
<OneBotApiNavList
|
||||||
<OneBotApiNavList
|
data={oneBotHttpApi}
|
||||||
data={oneBotHttpApi}
|
selectedApi={selectedApi}
|
||||||
selectedApi={selectedApi}
|
onSelect={setSelectedApi}
|
||||||
onSelect={setSelectedApi}
|
openSideBar={openSideBar}
|
||||||
openSideBar={openSideBar}
|
/>
|
||||||
/>
|
<div ref={contentRef} className="flex-1 h-full overflow-x-hidden">
|
||||||
<div
|
<motion.div
|
||||||
ref={contentRef}
|
className="absolute top-16 z-30 md:!ml-4"
|
||||||
className="flex-1 h-full overflow-x-hidden relative"
|
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
|
||||||
|
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<Button
|
||||||
className="sticky top-0 z-20 md:!ml-4"
|
isIconOnly
|
||||||
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
|
color="danger"
|
||||||
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
|
radius="md"
|
||||||
|
variant="shadow"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => setOpenSideBar(!openSideBar)}
|
||||||
>
|
>
|
||||||
<Button
|
<TbSquareRoundedChevronLeftFilled
|
||||||
isIconOnly
|
size={24}
|
||||||
color="danger"
|
className={clsx(
|
||||||
radius="md"
|
'transition-transform',
|
||||||
variant="shadow"
|
openSideBar ? '' : 'transform rotate-180'
|
||||||
size="sm"
|
)}
|
||||||
onPress={() => setOpenSideBar(!openSideBar)}
|
/>
|
||||||
>
|
</Button>
|
||||||
<TbSquareRoundedChevronLeftFilled
|
</motion.div>
|
||||||
size={24}
|
<OneBotApiDebug path={selectedApi} data={data} />
|
||||||
className={clsx(
|
|
||||||
'transition-transform',
|
|
||||||
openSideBar ? '' : 'transform rotate-180'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
<OneBotApiDebug path={selectedApi} data={data} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@@ -7,7 +7,6 @@ import toast from 'react-hot-toast'
|
|||||||
|
|
||||||
import key from '@/const/key'
|
import key from '@/const/key'
|
||||||
|
|
||||||
import ChatInputModal from '@/components/chat_input/modal'
|
|
||||||
import OneBotMessageList from '@/components/onebot/message_list'
|
import OneBotMessageList from '@/components/onebot/message_list'
|
||||||
import OneBotSendModal from '@/components/onebot/send_modal'
|
import OneBotSendModal from '@/components/onebot/send_modal'
|
||||||
import WSStatus from '@/components/onebot/ws_status'
|
import WSStatus from '@/components/onebot/ws_status'
|
||||||
@@ -82,7 +81,6 @@ export default function WSDebug() {
|
|||||||
{FilterMessagesType}
|
{FilterMessagesType}
|
||||||
</div>
|
</div>
|
||||||
<OneBotSendModal sendMessage={sendMessage} />
|
<OneBotSendModal sendMessage={sendMessage} />
|
||||||
<ChatInputModal />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { Route, Routes } from 'react-router-dom'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
|
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import DefaultLayout from '@/layouts/default'
|
import DefaultLayout from '@/layouts/default'
|
||||||
|
|
||||||
@@ -12,19 +13,30 @@ import LogsPage from './dashboard/logs'
|
|||||||
import NetworkPage from './dashboard/network'
|
import NetworkPage from './dashboard/network'
|
||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
|
const location = useLocation()
|
||||||
return (
|
return (
|
||||||
<DefaultLayout>
|
<DefaultLayout>
|
||||||
<Routes>
|
<AnimatePresence mode="wait">
|
||||||
<Route element={<DashboardIndexPage />} path="/" />
|
<motion.div
|
||||||
<Route element={<NetworkPage />} path="/network" />
|
key={location.pathname}
|
||||||
<Route element={<ConfigPage />} path="/config" />
|
initial={{ opacity: 0, y: 50 }}
|
||||||
<Route element={<LogsPage />} path="/logs" />
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<Route element={<DebugPage />} path="/debug">
|
exit={{ opacity: 0, y: -50 }}
|
||||||
<Route path="ws" element={<WSDebug />} />
|
transition={{ duration: 0.3 }}
|
||||||
<Route path="http" element={<HttpDebug />} />
|
>
|
||||||
</Route>
|
<Routes location={location} key={location.pathname}>
|
||||||
<Route element={<AboutPage />} path="/about" />
|
<Route element={<DashboardIndexPage />} path="/" />
|
||||||
</Routes>
|
<Route element={<NetworkPage />} path="/network" />
|
||||||
|
<Route element={<ConfigPage />} path="/config" />
|
||||||
|
<Route element={<LogsPage />} path="/logs" />
|
||||||
|
<Route element={<DebugPage />} path="/debug">
|
||||||
|
<Route path="ws" element={<WSDebug />} />
|
||||||
|
<Route path="http" element={<HttpDebug />} />
|
||||||
|
</Route>
|
||||||
|
<Route element={<AboutPage />} path="/about" />
|
||||||
|
</Routes>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -44,6 +44,7 @@ body {
|
|||||||
-moz-border-radius: 2em;
|
-moz-border-radius: 2em;
|
||||||
border-radius: 2em;
|
border-radius: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.monaco-editor {
|
.monaco-editor {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
border-radius: 5px !important;
|
border-radius: 5px !important;
|
||||||
@@ -75,6 +76,10 @@ body {
|
|||||||
border-radius: 5px !important;
|
border-radius: 5px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-view.monaco-menu-container * {
|
||||||
|
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
.ql-hidden {
|
.ql-hidden {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
36
napcat.webui/src/utils/version.ts
Normal file
36
napcat.webui/src/utils/version.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 版本号转为数字
|
||||||
|
* @param version 版本号
|
||||||
|
* @returns 版本号数字
|
||||||
|
*/
|
||||||
|
export const versionToNumber = (version: string): number => {
|
||||||
|
const finalVersionString = version.replace(/^v/, '')
|
||||||
|
|
||||||
|
const versionArray = finalVersionString.split('.')
|
||||||
|
const versionNumber =
|
||||||
|
parseInt(versionArray[2]) +
|
||||||
|
parseInt(versionArray[1]) * 100 +
|
||||||
|
parseInt(versionArray[0]) * 10000
|
||||||
|
|
||||||
|
return versionNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较版本号
|
||||||
|
* @param version1 版本号1
|
||||||
|
* @param version2 版本号2
|
||||||
|
* @returns 比较结果
|
||||||
|
* 0: 相等
|
||||||
|
* 1: version1 > version2
|
||||||
|
* -1: version1 < version2
|
||||||
|
*/
|
||||||
|
export const compareVersion = (version1: string, version2: string): number => {
|
||||||
|
const versionNumber1 = versionToNumber(version1)
|
||||||
|
const versionNumber2 = versionToNumber(version2)
|
||||||
|
|
||||||
|
if (versionNumber1 === versionNumber2) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return versionNumber1 > versionNumber2 ? 1 : -1
|
||||||
|
}
|
@@ -2,7 +2,7 @@
|
|||||||
"name": "napcat",
|
"name": "napcat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "4.4.10",
|
"version": "4.4.12",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
||||||
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
||||||
|
151
src/common/ffmpeg-worker.ts
Normal file
151
src/common/ffmpeg-worker.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { FFmpeg } from '@ffmpeg.wasm/main';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { readFileSync, statSync, writeFileSync } from 'fs';
|
||||||
|
import type { LogWrapper } from './log';
|
||||||
|
import type { VideoInfo } from './video';
|
||||||
|
import { fileTypeFromFile } from 'file-type';
|
||||||
|
import imageSize from 'image-size';
|
||||||
|
class FFmpegService {
|
||||||
|
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||||
|
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||||
|
const videoFileName = `${randomUUID()}.mp4`;
|
||||||
|
const outputFileName = `${randomUUID()}.jpg`;
|
||||||
|
try {
|
||||||
|
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
|
||||||
|
let code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
||||||
|
}
|
||||||
|
const thumbnail = ffmpegInstance.fs.readFile(outputFileName);
|
||||||
|
writeFileSync(thumbnailPath, thumbnail);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting thumbnail:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
ffmpegInstance.fs.unlink(outputFileName);
|
||||||
|
} catch (unlinkError) {
|
||||||
|
console.error('Error unlinking output file:', unlinkError);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ffmpegInstance.fs.unlink(videoFileName);
|
||||||
|
} catch (unlinkError) {
|
||||||
|
console.error('Error unlinking video file:', unlinkError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||||
|
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||||
|
const inputFileName = `${randomUUID()}.pcm`;
|
||||||
|
const outputFileName = `${randomUUID()}.${format}`;
|
||||||
|
try {
|
||||||
|
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(inputFile));
|
||||||
|
const params = format === 'amr'
|
||||||
|
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
|
||||||
|
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
|
||||||
|
let code = await ffmpegInstance.run(...params);
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
||||||
|
}
|
||||||
|
const outputData = ffmpegInstance.fs.readFile(outputFileName);
|
||||||
|
writeFileSync(outputFile, outputData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting file:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
ffmpegInstance.fs.unlink(outputFileName);
|
||||||
|
} catch (unlinkError) {
|
||||||
|
console.error('Error unlinking output file:', unlinkError);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ffmpegInstance.fs.unlink(inputFileName);
|
||||||
|
} catch (unlinkError) {
|
||||||
|
console.error('Error unlinking input file:', unlinkError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
|
||||||
|
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||||
|
const inputFileName = `${randomUUID()}.input`;
|
||||||
|
const outputFileName = `${randomUUID()}.pcm`;
|
||||||
|
try {
|
||||||
|
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath));
|
||||||
|
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
|
||||||
|
let code = await ffmpegInstance.run(...params);
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error('FFmpeg process exited with code ' + code);
|
||||||
|
}
|
||||||
|
const outputData = ffmpegInstance.fs.readFile(outputFileName);
|
||||||
|
writeFileSync(pcmPath, outputData);
|
||||||
|
return Buffer.from(outputData);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error('FFmpeg处理转换出错: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
ffmpegInstance.fs.unlink(outputFileName);
|
||||||
|
} catch (unlinkError) {
|
||||||
|
logger.log('Error unlinking output file:', unlinkError);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ffmpegInstance.fs.unlink(inputFileName);
|
||||||
|
} catch (unlinkError) {
|
||||||
|
logger.log('Error unlinking input file:', unlinkError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||||
|
await FFmpegService.extractThumbnail(videoPath, thumbnailPath);
|
||||||
|
let fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
|
||||||
|
const inputFileName = `${randomUUID()}.${fileType}`;
|
||||||
|
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
||||||
|
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
|
||||||
|
ffmpegInstance.setLogging(true);
|
||||||
|
let duration = 60;
|
||||||
|
ffmpegInstance.setLogger((level, ...msg) => {
|
||||||
|
const message = msg.join(' ');
|
||||||
|
const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
|
||||||
|
if (durationMatch) {
|
||||||
|
const hours = parseInt(durationMatch[1], 10);
|
||||||
|
const minutes = parseInt(durationMatch[2], 10);
|
||||||
|
const seconds = parseFloat(durationMatch[3]);
|
||||||
|
duration = hours * 3600 + minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await ffmpegInstance.run('-i', inputFileName);
|
||||||
|
let image = imageSize(thumbnailPath);
|
||||||
|
ffmpegInstance.fs.unlink(inputFileName);
|
||||||
|
const fileSize = statSync(videoPath).size;
|
||||||
|
return {
|
||||||
|
width: image.width ?? 100,
|
||||||
|
height: image.height ?? 100,
|
||||||
|
time: duration,
|
||||||
|
format: fileType,
|
||||||
|
size: fileSize,
|
||||||
|
filePath: videoPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
||||||
|
|
||||||
|
interface FFmpegTask {
|
||||||
|
method: FFmpegMethod;
|
||||||
|
args: any[];
|
||||||
|
}
|
||||||
|
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
|
||||||
|
switch (method) {
|
||||||
|
case 'extractThumbnail':
|
||||||
|
return await FFmpegService.extractThumbnail(...args as [string, string]);
|
||||||
|
case 'convertFile':
|
||||||
|
return await FFmpegService.convertFile(...args as [string, string, string]);
|
||||||
|
case 'convert':
|
||||||
|
return await FFmpegService.convert(...args as [string, string, LogWrapper]);
|
||||||
|
case 'getVideoInfo':
|
||||||
|
return await FFmpegService.getVideoInfo(...args as [string, string]);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown method: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,131 +1,50 @@
|
|||||||
import { FFmpeg } from '@ffmpeg.wasm/main';
|
import Piscina from "piscina";
|
||||||
import { randomUUID } from 'crypto';
|
import { VideoInfo } from "./video";
|
||||||
import { readFileSync, statSync, writeFileSync } from 'fs';
|
import type { LogWrapper } from "./log";
|
||||||
import { LogWrapper } from './log';
|
|
||||||
import { VideoInfo } from './video';
|
type EncodeArgs = {
|
||||||
import { fileTypeFromFile } from 'file-type';
|
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
||||||
import imageSize from 'image-size';
|
args: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type EncodeResult = any;
|
||||||
|
|
||||||
|
async function getWorkerPath() {
|
||||||
|
return new URL(/* @vite-ignore */ './ffmpeg-worker.mjs', import.meta.url).href;
|
||||||
|
}
|
||||||
|
|
||||||
export class FFmpegService {
|
export class FFmpegService {
|
||||||
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
||||||
const videoFileName = `${randomUUID()}.mp4`;
|
filename: await getWorkerPath(),
|
||||||
const outputFileName = `${randomUUID()}.jpg`;
|
});
|
||||||
try {
|
await piscina.run({ method: 'extractThumbnail', args: [videoPath, thumbnailPath] });
|
||||||
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
|
await piscina.destroy();
|
||||||
let code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
|
|
||||||
if (code !== 0) {
|
|
||||||
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
|
||||||
}
|
|
||||||
const thumbnail = ffmpegInstance.fs.readFile(outputFileName);
|
|
||||||
writeFileSync(thumbnailPath, thumbnail);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error extracting thumbnail:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
ffmpegInstance.fs.unlink(outputFileName);
|
|
||||||
} catch (unlinkError) {
|
|
||||||
console.error('Error unlinking output file:', unlinkError);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ffmpegInstance.fs.unlink(videoFileName);
|
|
||||||
} catch (unlinkError) {
|
|
||||||
console.error('Error unlinking video file:', unlinkError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
||||||
const inputFileName = `${randomUUID()}.pcm`;
|
filename: await getWorkerPath(),
|
||||||
const outputFileName = `${randomUUID()}.${format}`;
|
});
|
||||||
try {
|
await piscina.run({ method: 'convertFile', args: [inputFile, outputFile, format] });
|
||||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(inputFile));
|
await piscina.destroy();
|
||||||
const params = format === 'amr'
|
|
||||||
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
|
|
||||||
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
|
|
||||||
let code = await ffmpegInstance.run(...params);
|
|
||||||
if (code !== 0) {
|
|
||||||
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
|
|
||||||
}
|
|
||||||
const outputData = ffmpegInstance.fs.readFile(outputFileName);
|
|
||||||
writeFileSync(outputFile, outputData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error converting file:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
ffmpegInstance.fs.unlink(outputFileName);
|
|
||||||
} catch (unlinkError) {
|
|
||||||
console.error('Error unlinking output file:', unlinkError);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ffmpegInstance.fs.unlink(inputFileName);
|
|
||||||
} catch (unlinkError) {
|
|
||||||
console.error('Error unlinking input file:', unlinkError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
|
public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
|
||||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
||||||
const inputFileName = `${randomUUID()}.input`;
|
filename: await getWorkerPath(),
|
||||||
const outputFileName = `${randomUUID()}.pcm`;
|
});
|
||||||
try {
|
const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath, logger] });
|
||||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath));
|
await piscina.destroy();
|
||||||
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
|
return result;
|
||||||
let code = await ffmpegInstance.run(...params);
|
|
||||||
if (code !== 0) {
|
|
||||||
throw new Error('FFmpeg process exited with code ' + code);
|
|
||||||
}
|
|
||||||
const outputData = ffmpegInstance.fs.readFile(outputFileName);
|
|
||||||
writeFileSync(pcmPath, outputData);
|
|
||||||
return Buffer.from(outputData);
|
|
||||||
} catch (error: any) {
|
|
||||||
throw new Error('FFmpeg处理转换出错: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
ffmpegInstance.fs.unlink(outputFileName);
|
|
||||||
} catch (unlinkError) {
|
|
||||||
logger.log('Error unlinking output file:', unlinkError);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ffmpegInstance.fs.unlink(inputFileName);
|
|
||||||
} catch (unlinkError) {
|
|
||||||
logger.log('Error unlinking input file:', unlinkError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||||
await FFmpegService.extractThumbnail(videoPath, thumbnailPath);
|
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
||||||
let fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
|
filename: await getWorkerPath(),
|
||||||
const inputFileName = `${randomUUID()}.${fileType}`;
|
|
||||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
|
||||||
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
|
|
||||||
ffmpegInstance.setLogging(true);
|
|
||||||
let duration = 60;
|
|
||||||
ffmpegInstance.setLogger((level, ...msg) => {
|
|
||||||
const message = msg.join(' ');
|
|
||||||
const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
|
|
||||||
if (durationMatch) {
|
|
||||||
const hours = parseInt(durationMatch[1], 10);
|
|
||||||
const minutes = parseInt(durationMatch[2], 10);
|
|
||||||
const seconds = parseFloat(durationMatch[3]);
|
|
||||||
duration = hours * 3600 + minutes * 60 + seconds;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
await ffmpegInstance.run('-i', inputFileName);
|
const result = await piscina.run({ method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
|
||||||
let image = imageSize(thumbnailPath);
|
await piscina.destroy();
|
||||||
ffmpegInstance.fs.unlink(inputFileName);
|
return result;
|
||||||
const fileSize = statSync(videoPath).size;
|
|
||||||
return {
|
|
||||||
width: image.width ?? 100,
|
|
||||||
height: image.height ?? 100,
|
|
||||||
time: duration,
|
|
||||||
format: fileType,
|
|
||||||
size: fileSize,
|
|
||||||
filePath: videoPath
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1 +1 @@
|
|||||||
export const napCatVersion = '4.4.10';
|
export const napCatVersion = '4.4.12';
|
||||||
|
@@ -7,6 +7,7 @@ import { RequestUtil } from '@/common/request';
|
|||||||
import { HttpClientConfig } from '@/onebot/config/config';
|
import { HttpClientConfig } from '@/onebot/config/config';
|
||||||
import { ActionMap } from '@/onebot/action';
|
import { ActionMap } from '@/onebot/action';
|
||||||
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
||||||
|
import json5 from 'json5';
|
||||||
|
|
||||||
export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig> {
|
export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -34,7 +35,7 @@ export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await RequestUtil.HttpGetText(this.config.url, 'POST', msgStr, headers);
|
const data = await RequestUtil.HttpGetText(this.config.url, 'POST', msgStr, headers);
|
||||||
const resJson: QuickAction = data ? JSON.parse(data) : {};
|
const resJson: QuickAction = data ? json5.parse(data) : {};
|
||||||
|
|
||||||
await this.obContext.apis.QuickActionApi.handleQuickOperation(event as QuickActionEvent, resJson);
|
await this.obContext.apis.QuickActionApi.handleQuickOperation(event as QuickActionEvent, resJson);
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,7 @@ import cors from 'cors';
|
|||||||
import { HttpServerConfig } from '@/onebot/config/config';
|
import { HttpServerConfig } from '@/onebot/config/config';
|
||||||
import { NapCatOneBot11Adapter } from "@/onebot";
|
import { NapCatOneBot11Adapter } from "@/onebot";
|
||||||
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
||||||
|
import json5 from 'json5';
|
||||||
|
|
||||||
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
|
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
|
||||||
private app: Express | undefined;
|
private app: Express | undefined;
|
||||||
@@ -52,12 +53,17 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
|||||||
this.app.use((req, res, next) => {
|
this.app.use((req, res, next) => {
|
||||||
// 兼容处理没有带content-type的请求
|
// 兼容处理没有带content-type的请求
|
||||||
req.headers['content-type'] = 'application/json';
|
req.headers['content-type'] = 'application/json';
|
||||||
const originalJson = express.json({ limit: '5000mb' });
|
let rawData = '';
|
||||||
originalJson(req, res, (err) => {
|
req.on('data', (chunk) => {
|
||||||
if (err) {
|
rawData += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
req.body = json5.parse(rawData);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
return res.status(400).send('Invalid JSON');
|
return res.status(400).send('Invalid JSON');
|
||||||
}
|
}
|
||||||
next();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -9,6 +9,7 @@ import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11Li
|
|||||||
import { WebsocketClientConfig } from '@/onebot/config/config';
|
import { WebsocketClientConfig } from '@/onebot/config/config';
|
||||||
import { NapCatOneBot11Adapter } from "@/onebot";
|
import { NapCatOneBot11Adapter } from "@/onebot";
|
||||||
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
||||||
|
import json5 from 'json5';
|
||||||
|
|
||||||
export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketClientConfig> {
|
export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketClientConfig> {
|
||||||
private connection: WebSocket | null = null;
|
private connection: WebSocket | null = null;
|
||||||
@@ -129,7 +130,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
|||||||
let echo = undefined;
|
let echo = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
receiveData = JSON.parse(message.toString());
|
receiveData = json5.parse(message.toString());
|
||||||
echo = receiveData.echo;
|
echo = receiveData.echo;
|
||||||
this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData);
|
this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@@ -12,6 +12,7 @@ import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11Li
|
|||||||
import { WebsocketServerConfig } from '@/onebot/config/config';
|
import { WebsocketServerConfig } from '@/onebot/config/config';
|
||||||
import { NapCatOneBot11Adapter } from "@/onebot";
|
import { NapCatOneBot11Adapter } from "@/onebot";
|
||||||
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
|
||||||
|
import json5 from 'json5';
|
||||||
|
|
||||||
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
||||||
wsServer: WebSocketServer;
|
wsServer: WebSocketServer;
|
||||||
@@ -162,7 +163,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
|||||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any } = { action: ActionName.Unknown, params: {} };
|
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any } = { action: ActionName.Unknown, params: {} };
|
||||||
let echo = undefined;
|
let echo = undefined;
|
||||||
try {
|
try {
|
||||||
receiveData = JSON.parse(message.toString());
|
receiveData = json5.parse(message.toString());
|
||||||
echo = receiveData.echo;
|
echo = receiveData.echo;
|
||||||
//this.logger.logDebug('收到正向Websocket消息', receiveData);
|
//this.logger.logDebug('收到正向Websocket消息', receiveData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@@ -90,6 +90,7 @@ const UniversalBaseConfig = () =>
|
|||||||
entry: {
|
entry: {
|
||||||
napcat: 'src/universal/napcat.ts',
|
napcat: 'src/universal/napcat.ts',
|
||||||
'audio-worker': 'src/common/audio-worker.ts',
|
'audio-worker': 'src/common/audio-worker.ts',
|
||||||
|
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
|
||||||
},
|
},
|
||||||
formats: ['es'],
|
formats: ['es'],
|
||||||
fileName: (_, entryName) => `${entryName}.mjs`,
|
fileName: (_, entryName) => `${entryName}.mjs`,
|
||||||
@@ -119,6 +120,7 @@ const ShellBaseConfig = () =>
|
|||||||
entry: {
|
entry: {
|
||||||
napcat: 'src/shell/napcat.ts',
|
napcat: 'src/shell/napcat.ts',
|
||||||
'audio-worker': 'src/common/audio-worker.ts',
|
'audio-worker': 'src/common/audio-worker.ts',
|
||||||
|
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
|
||||||
},
|
},
|
||||||
formats: ['es'],
|
formats: ['es'],
|
||||||
fileName: (_, entryName) => `${entryName}.mjs`,
|
fileName: (_, entryName) => `${entryName}.mjs`,
|
||||||
@@ -147,6 +149,7 @@ const FrameworkBaseConfig = () =>
|
|||||||
entry: {
|
entry: {
|
||||||
napcat: 'src/framework/napcat.ts',
|
napcat: 'src/framework/napcat.ts',
|
||||||
'audio-worker': 'src/common/audio-worker.ts',
|
'audio-worker': 'src/common/audio-worker.ts',
|
||||||
|
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
|
||||||
},
|
},
|
||||||
formats: ['es'],
|
formats: ['es'],
|
||||||
fileName: (_, entryName) => `${entryName}.mjs`,
|
fileName: (_, entryName) => `${entryName}.mjs`,
|
||||||
|
Reference in New Issue
Block a user