Compare commits

...

20 Commits

Author SHA1 Message Date
手瓜一十雪
7e1fe31085 fix: http支持json5 2025-01-27 19:25:51 +08:00
Mlikiowa
c3cba8ba4e release: v4.4.12 2025-01-27 10:44:43 +00:00
手瓜一十雪
ba619986c9 fix: #739 2025-01-27 18:42:26 +08:00
bietiaop
dcef3f3c3b fix: 终端字符宽度&微调样式&路由切换动画 2025-01-27 15:58:27 +08:00
bietiaop
823faa2790 fix: 质量检查 2025-01-26 21:58:04 +08:00
bietiaop
ef4248d2a3 fix: 依赖缺失 2025-01-26 21:52:35 +08:00
bietiaop
3917cb0dc9 feat: webui检查更新&修复日志字体渲染 2025-01-26 21:48:45 +08:00
Mlikiowa
520cec0eaa release: v4.4.11 2025-01-26 13:03:34 +00:00
手瓜一十雪
e7655e0ff6 Merge pull request #737 from NapNeko/ffmpeg
feat: no spawn ffmpeg
2025-01-26 21:02:57 +08:00
手瓜一十雪
350ced55c0 fix 2025-01-26 21:00:47 +08:00
手瓜一十雪
2ca6d0a00e feat: 移除测试代码 2025-01-26 20:44:27 +08:00
手瓜一十雪
844abad0d0 fix: 彻底完成迁移 2025-01-26 20:42:05 +08:00
手瓜一十雪
d278e9d8bc feat: ffmpeg 2025-01-26 16:26:21 +08:00
手瓜一十雪
6e261f30c2 fix: typo 2025-01-26 13:29:47 +08:00
bietiaop
84f0e43369 feat: debug 配置记忆 2025-01-26 10:44:31 +08:00
Mlikiowa
3223a06983 release: v4.4.10 2025-01-25 12:08:25 +00:00
手瓜一十雪
1b874a0264 fix: defaultHttpUrl 2025-01-25 20:00:24 +08:00
Mlikiowa
26525a0ff9 release: v4.4.9 2025-01-25 10:59:08 +00:00
手瓜一十雪
49234ea5c7 fix: music proxy 2025-01-25 18:34:11 +08:00
Mlikiowa
c474158a09 release: v4.4.8 2025-01-25 05:40:09 +00:00
41 changed files with 729 additions and 368 deletions

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ",
"slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现",
"version": "4.4.7",
"version": "4.4.12",
"icon": "./logo.png",
"authors": [
{

View File

@@ -10,8 +10,6 @@
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@heroui/avatar": "2.2.7",
"@heroui/breadcrumbs": "2.2.7",
"@heroui/button": "2.2.10",
@@ -38,6 +36,8 @@
"@heroui/tabs": "2.2.8",
"@heroui/theme": "2.4.6",
"@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",
"@reduxjs/toolkit": "^2.5.0",
"@uidotdev/usehooks": "^2.4.1",
@@ -62,11 +62,13 @@
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "7.1.0",
"react-use-websocket": "^4.11.1",
"react-window": "^1.8.11",
"remark-gfm": "^4.0.0",
"tailwind-variants": "0.3.0",
"tailwindcss": "3.4.17",
"zod": "^3.24.1"

View File

@@ -187,7 +187,7 @@ export default function AudioPlayer(props: AudioPlayerProps) {
return (
<div
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',
isCollapsed && 'md:hover:!translate-x-80'
)}

View File

@@ -19,12 +19,6 @@ loader.config({
}
})
loader.config({
'vs/nls': {
availableLanguages: { '*': 'zh-cn' }
}
})
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
test?: string
}

View File

@@ -1125,7 +1125,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1173,7 +1173,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1220,7 +1220,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1270,7 +1270,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1320,7 +1320,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1372,7 +1372,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1422,7 +1422,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1469,7 +1469,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1519,7 +1519,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1566,7 +1566,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1613,7 +1613,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1660,7 +1660,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{
@@ -1707,7 +1707,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
>
<g
id="svgGroup"
stroke-linecap="round"
strokeLinecap="round"
stroke="#000"
fill="transparent"
style={{

View File

@@ -47,7 +47,6 @@ const RealTimeLogs = () => {
}
return logLevel.has(log.level)
})
.slice(-100)
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
.join('\r\n')
Xterm.current?.clear()
@@ -65,7 +64,6 @@ const RealTimeLogs = () => {
useEffect(() => {
const subscribeLogs = () => {
try {
console.log('subscribeLogs')
const source = WebUIManager.getRealTimeLogs((data) => {
setDataArr((prev) => {
const newData = [...prev, ...data]

View File

@@ -13,12 +13,15 @@ export interface ModalProps {
content: React.ReactNode
title?: React.ReactNode
size?: React.ComponentProps<typeof NextUIModal>['size']
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
onClose?: () => void
onConfirm?: () => void
onCancel?: () => void
backdrop?: 'opaque' | 'blur' | 'transparent'
showCancel?: boolean
dismissible?: boolean
confirmText?: string
cancelText?: string
}
const Modal: React.FC<ModalProps> = React.memo((props) => {
@@ -26,12 +29,14 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
backdrop = 'blur',
title,
content,
size = 'md',
showCancel = true,
dismissible,
confirmText = '确定',
cancelText = '取消',
onClose,
onConfirm,
onCancel
onCancel,
...rest
} = props
const { onClose: onNativeClose } = useDisclosure()
@@ -44,11 +49,11 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
onClose?.()
onNativeClose()
}}
size={size}
classNames={{
backdrop: 'z-[99999999]',
wrapper: 'z-[999999999]'
backdrop: 'z-[99]',
wrapper: 'z-[99]'
}}
{...rest}
>
<ModalContent>
{(nativeClose) => (
@@ -56,7 +61,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
{title && (
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
)}
<ModalBody>{content}</ModalBody>
<ModalBody className="break-all">{content}</ModalBody>
<ModalFooter>
{showCancel && (
<Button
@@ -67,17 +72,17 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
nativeClose()
}}
>
{cancelText}
</Button>
)}
<Button
color="primary"
color="danger"
onPress={() => {
onConfirm?.()
nativeClose()
}}
>
{confirmText}
</Button>
</ModalFooter>
</>

View File

@@ -2,12 +2,14 @@ import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card'
import { Input } from '@heroui/input'
import { Snippet } from '@heroui/snippet'
import { useLocalStorage } from '@uidotdev/usehooks'
import { motion } from 'motion/react'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { IoLink, IoSend } from 'react-icons/io5'
import { PiCatDuotone } from 'react-icons/pi'
import key from '@/const/key'
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'
import ChatInputModal from '@/components/chat_input/modal'
@@ -27,9 +29,10 @@ export interface OneBotApiDebugProps {
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const { path, data } = props
const url = new URL(window.location.origin).href
const defaultHttpUrl = url.replace(':6099', ':3000')
const [httpConfig, setHttpConfig] = useState({
const currentURL = new URL(window.location.origin)
currentURL.port = '3000'
const defaultHttpUrl = currentURL.href
const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, {
url: defaultHttpUrl,
token: ''
})
@@ -38,6 +41,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false)
const [isResponseOpen, setIsResponseOpen] = useState(false)
const [isFetching, setIsFetching] = useState(false)
const responseRef = useRef<HTMLDivElement>(null)
const parsedRequest = parse(data.request)
const parsedResponse = parse(data.response)
@@ -47,8 +51,10 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
const r = toast.loading('正在发送请求...')
try {
const parsedRequestBody = JSON.parse(requestBody)
const requestURL = new URL(httpConfig.url)
requestURL.pathname = path
request
.post(httpConfig.url + path, parsedRequestBody, {
.post(requestURL.href, parsedRequestBody, {
headers: {
Authorization: `Bearer ${httpConfig.token}`
},
@@ -64,6 +70,11 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
})
.finally(() => {
setIsFetching(false)
setIsResponseOpen(true)
responseRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
toast.dismiss(r)
})
} catch (error) {
@@ -79,8 +90,8 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
}, [path])
return (
<div className="flex-1 overflow-y-auto p-4 rounded-lg shadow-md">
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-danger-400 ">
<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">
<PiCatDuotone />
{data.description}
</h1>
@@ -88,6 +99,9 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<Snippet
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
symbol={<IoLink size={18} className="inline-block mr-1" />}
tooltipProps={{
content: '点击复制地址'
}}
>
{path}
</Snippet>
@@ -120,7 +134,10 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<IoSend />
</Button>
</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">
<span className="mr-2"></span>
<Button
@@ -135,7 +152,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
</CardHeader>
<CardBody>
<motion.div
className="overflow-hidden"
ref={responseRef}
initial={{ opacity: 0, height: 0 }}
animate={{
opacity: isCodeEditorOpen ? 1 : 0,
@@ -218,7 +235,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
<h2 className="text-xl font-semibold mt-4 mb-2"></h2>
<DisplayStruct schema={parsedResponse} />
</div>
</div>
</section>
)
}

View File

@@ -19,9 +19,8 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
return (
<motion.div
className={clsx(
'flex-shrink-0 absolute md:!top-0 md:bottom-0 left-0 !overflow-hidden md:relative md:w-auto z-20',
openSideBar &&
'bottom-8 z-10 bg-background bg-opacity-20 backdrop-blur-md top-14'
'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 && 'bg-background bg-opacity-20 backdrop-blur-md'
)}
initial={{ width: 0 }}
transition={{
@@ -32,7 +31,7 @@ const OneBotApiNavList: React.FC<OneBotApiNavListProps> = (props) => {
animate={{ width: openSideBar ? '16rem' : '0rem' }}
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
className="sticky top-0 z-10 text-danger-600"
classNames={{

View File

@@ -10,6 +10,7 @@ import {
import { useCallback, useRef } from 'react'
import toast from 'react-hot-toast'
import ChatInputModal from '@/components/chat_input/modal'
import CodeEditor from '@/components/code_editor'
import type { CodeEditorRef } from '@/components/code_editor'
@@ -72,6 +73,8 @@ const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
</div>
</ModalBody>
<ModalFooter>
<ChatInputModal />
<Button color="danger" variant="flat" onPress={onClose}>
</Button>

View File

@@ -1,45 +1,189 @@
import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card'
import { Chip } from '@heroui/chip'
import { Spinner } from '@heroui/spinner'
import { Tooltip } from '@heroui/tooltip'
import { useRequest } from 'ahooks'
import { FaCircleInfo } from 'react-icons/fa6'
import { FaQq } from 'react-icons/fa6'
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
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 { GithubRelease } from '@/types/github'
import packageJson from '../../package.json'
import TailwindMarkdown from './tailwind_markdown'
export interface SystemInfoItemProps {
title: string
icon?: React.ReactNode
value?: React.ReactNode
endContent?: React.ReactNode
}
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
title,
value = '--',
icon
icon,
endContent
}) => {
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">
{icon}
<div className="w-24">{title}</div>
<div className="text-danger-200">{value}</div>
<div className="ml-auto">{endContent}</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 {
archInfo?: string
}
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
const { archInfo } = props
const {
data: packageData,
loading: packageLoading,
error: packageError
} = useRequest(WebUIManager.getPackageInfo)
const {
data: qqVersionData,
loading: qqVersionLoading,
@@ -53,19 +197,7 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
</CardHeader>
<CardBody className="flex-1">
<div className="flex flex-col justify-between h-full">
<SystemInfoItem
title="NapCat 版本"
icon={<IoLogoOctocat className="text-xl" />}
value={
packageError ? (
`错误:${packageError.message}`
) : packageLoading ? (
<Spinner size="sm" />
) : (
packageData?.version
)
}
/>
<NapCatVersion />
<SystemInfoItem
title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />}

View 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

View File

@@ -21,6 +21,7 @@ export type XTermRef = {
writelnAsync: (data: Parameters<Terminal['writeln']>[0]) => Promise<void>
clear: () => void
}
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
(props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
@@ -33,15 +34,24 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
}
const terminal = new Terminal({
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
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(new WebglAddon())
terminal.open(domRef.current)
fitAddon.fit()
terminal.writeln(
gradientText(
@@ -57,16 +67,16 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
})
resizeObserver.observe(domRef.current)
const handleFontLoad = () => {
terminal.refresh(0, terminal.rows - 1)
}
document.fonts.addEventListener('loadingdone', handleFontLoad)
// 字体加载完成后重新调整终端大小
document.fonts.ready.then(() => {
fitAddon.fit()
resizeObserver.observe(domRef.current!)
})
return () => {
resizeObserver.disconnect()
document.fonts.removeEventListener('loadingdone', handleFontLoad)
setTimeout(() => {
terminal.dispose()
}, 0)
@@ -76,14 +86,20 @@ const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme = {
background:
theme === 'dark' ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)',
background: theme === 'dark' ? '#00000000' : '#ffffff00',
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',
cursorAccent: theme === 'dark' ? '#000' : '#fff',
black: theme === 'dark' ? '#fff' : '#000'
}
terminalRef.current.options.fontWeight =
theme === 'dark' ? 'normal' : '600'
terminalRef.current.options.fontWeightBold =
theme === 'dark' ? 'bold' : '900'
}
}, [theme])

View File

@@ -7,7 +7,9 @@ enum key {
autoPlay = 'auto-play',
customIcons = 'custom-icons',
isCollapsedMusicPlayer = 'is-collapsed-music-player',
sideBarOpen = 'side-bar-open'
sideBarOpen = 'side-bar-open',
httpDebugConfig = 'http-debug-config',
wsDebugConfig = 'ws-debug-config'
}
export default key

View File

@@ -1,22 +1,15 @@
// Dialog Context
import React from 'react'
import type { ModalProps } from '@/components/modal'
import Modal from '@/components/modal'
import type { ModalProps } from '@/components/modal'
export interface AlertProps {
title?: React.ReactNode
content: React.ReactNode
size?: ModalProps['size']
dismissible?: boolean
export interface AlertProps
extends Omit<ModalProps, 'onCancel' | 'showCancel' | 'cancelText'> {
onConfirm?: () => void
}
export interface ConfirmProps {
title?: React.ReactNode
content: React.ReactNode
size?: ModalProps['size']
dismissible?: boolean
export interface ConfirmProps extends ModalProps {
onConfirm?: () => void
onCancel?: () => void
}
@@ -42,7 +35,7 @@ export const DialogContext = React.createContext<DialogContextProps>({
const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
const [dialogs, setDialogs] = React.useState<ModalItem[]>([])
const alert = (config: AlertProps) => {
const { title, content, dismissible, onConfirm, size = 'md' } = config
const { onConfirm, size = 'md', ...rest } = config
setDialogs((before) => {
const id = before[before.length - 1]?.id + 1 || 0
@@ -51,32 +44,23 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
...before,
{
id,
content,
size,
title,
backdrop: 'blur',
showCancel: false,
dismissible: dismissible,
onConfirm: () => {
onConfirm?.()
setTimeout(() => {
setDialogs((before) => before.filter((item) => item.id !== id))
}, 300)
}
},
...rest
}
]
})
}
const confirm = (config: ConfirmProps) => {
const {
title,
content,
dismissible,
onConfirm,
onCancel,
size = 'md'
} = config
const { onConfirm, onCancel, size = 'md', ...rest } = config
setDialogs((before) => {
const id = before[before.length - 1]?.id + 1 || 0
@@ -85,12 +69,9 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
...before,
{
id,
content,
size,
title,
backdrop: 'blur',
showCancel: true,
dismissible: dismissible,
onConfirm: () => {
onConfirm?.()
setTimeout(() => {
@@ -102,7 +83,8 @@ const DialogProvider: React.FC<DialogProviderProps> = ({ children }) => {
setTimeout(() => {
setDialogs((before) => before.filter((item) => item.id !== id))
}, 300)
}
},
...rest
}
]
})

View File

@@ -24,6 +24,14 @@ export default class WebUIManager {
return data.data.Credential
}
public static async proxy<T>(url = '') {
const data = await serverRequest.get<ServerResponse<string>>(
'/base/proxy?url=' + encodeURIComponent(url)
)
data.data.data = JSON.parse(data.data.data)
return data.data as ServerResponse<T>
}
public static async getPackageInfo() {
const { data } =
await serverRequest.get<ServerResponse<PackageInfo>>('/base/PackageInfo')

View File

@@ -2,7 +2,7 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
import { Button } from '@heroui/button'
import { useLocalStorage } from '@uidotdev/usehooks'
import clsx from 'clsx'
import { motion } from 'motion/react'
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useMemo, useRef } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { MdMenu, MdMenuOpen } from 'react-icons/md'
@@ -79,7 +79,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
}, [location.pathname])
return (
<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={{
backgroundImage: `url(${b64img})`,
backgroundSize: 'cover'
@@ -89,13 +89,22 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
<div
ref={contentRef}
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',
!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
className={clsx(
'mr-1 ease-in-out ml-0 md:relative',
@@ -117,7 +126,19 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
</motion.div>
<Breadcrumbs isDisabled size="lg">
{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>
</div>

View File

@@ -1,12 +1,10 @@
import { Chip } from '@heroui/chip'
import { Image } from '@heroui/image'
import { Link } from '@heroui/link'
import { Spinner } from '@heroui/spinner'
import { Tooltip } from '@heroui/tooltip'
import { useRequest } from 'ahooks'
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 { title } from '@/components/primitives'
@@ -43,11 +41,6 @@ function VersionInfo() {
data?.version
)}
</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>
)
}

View File

@@ -27,41 +27,36 @@ export default function HttpDebug() {
return (
<>
<title>HTTP调试 - NapCat WebUI</title>
<div className="w-full h-[calc(100%-3.6rem)] flex items-stretch">
<OneBotApiNavList
data={oneBotHttpApi}
selectedApi={selectedApi}
onSelect={setSelectedApi}
openSideBar={openSideBar}
/>
<div
ref={contentRef}
className="flex-1 h-full overflow-x-hidden relative"
<OneBotApiNavList
data={oneBotHttpApi}
selectedApi={selectedApi}
onSelect={setSelectedApi}
openSideBar={openSideBar}
/>
<div ref={contentRef} className="flex-1 h-full overflow-x-hidden">
<motion.div
className="absolute top-16 z-30 md:!ml-4"
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
>
<motion.div
className="sticky top-0 z-20 md:!ml-4"
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
<Button
isIconOnly
color="danger"
radius="md"
variant="shadow"
size="sm"
onPress={() => setOpenSideBar(!openSideBar)}
>
<Button
isIconOnly
color="danger"
radius="md"
variant="shadow"
size="sm"
onPress={() => setOpenSideBar(!openSideBar)}
>
<TbSquareRoundedChevronLeftFilled
size={24}
className={clsx(
'transition-transform',
openSideBar ? '' : 'transform rotate-180'
)}
/>
</Button>
</motion.div>
<OneBotApiDebug path={selectedApi} data={data} />
</div>
<TbSquareRoundedChevronLeftFilled
size={24}
className={clsx(
'transition-transform',
openSideBar ? '' : 'transform rotate-180'
)}
/>
</Button>
</motion.div>
<OneBotApiDebug path={selectedApi} data={data} />
</div>
</>
)

View File

@@ -1,10 +1,12 @@
import { Button } from '@heroui/button'
import { Card, CardBody } from '@heroui/card'
import { Input } from '@heroui/input'
import { useLocalStorage } from '@uidotdev/usehooks'
import { useCallback, useState } from 'react'
import toast from 'react-hot-toast'
import ChatInputModal from '@/components/chat_input/modal'
import key from '@/const/key'
import OneBotMessageList from '@/components/onebot/message_list'
import OneBotSendModal from '@/components/onebot/send_modal'
import WSStatus from '@/components/onebot/ws_status'
@@ -12,9 +14,11 @@ import WSStatus from '@/components/onebot/ws_status'
import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
export default function WSDebug() {
const url = new URL(window.location.origin).href
const defaultWsUrl = url.replace('http', 'ws').replace(':6099', ':3000')
const [socketConfig, setSocketConfig] = useState({
const url = new URL(window.location.origin)
url.port = '3000'
url.protocol = 'ws:'
const defaultWsUrl = url.href
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {
url: defaultWsUrl,
token: ''
})
@@ -77,7 +81,6 @@ export default function WSDebug() {
{FilterMessagesType}
</div>
<OneBotSendModal sendMessage={sendMessage} />
<ChatInputModal />
</div>
</div>
</CardBody>

View File

@@ -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'
@@ -12,19 +13,30 @@ import LogsPage from './dashboard/logs'
import NetworkPage from './dashboard/network'
export default function IndexPage() {
const location = useLocation()
return (
<DefaultLayout>
<Routes>
<Route element={<DashboardIndexPage />} path="/" />
<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>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
transition={{ duration: 0.3 }}
>
<Routes location={location} key={location.pathname}>
<Route element={<DashboardIndexPage />} path="/" />
<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>
)
}

View File

@@ -44,6 +44,7 @@ body {
-moz-border-radius: 2em;
border-radius: 2em;
}
.monaco-editor {
outline: none !important;
border-radius: 5px !important;
@@ -75,6 +76,10 @@ body {
border-radius: 5px !important;
}
.context-view.monaco-menu-container * {
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
}
.ql-hidden {
@apply hidden;
}

View File

@@ -6,17 +6,17 @@ import type {
Music163URLResponse
} from '@/types/music'
import { request } from './request'
import WebUIManager from '@/controllers/webui_manager'
/**
* 获取网易云音乐歌单
* @param id 歌单id
* @returns 歌单信息
*/
export const get163MusicList = async (id: string) => {
const res = await request.get<Music163ListResponse>(
`https://wavesgame.top/playlist/track/all?id=${id}`
)
let res = await WebUIManager.proxy<Music163ListResponse>('https://wavesgame.top/playlist/track/all?id=' + id);
// const res = await request.get<Music163ListResponse>(
// `https://wavesgame.top/playlist/track/all?id=${id}`
// )
if (res?.data?.code !== 200) {
throw new Error('获取歌曲列表失败')
}
@@ -39,7 +39,7 @@ export const getSongsURL = async (ids: number[]) => {
}, [] as number[][])
const res = await Promise.all(
_ids.map(async (id) => {
const res = await request.get<Music163URLResponse>(
const res = await WebUIManager.proxy<Music163URLResponse>(
`https://wavesgame.top/song/url?id=${id.join(',')}`
)
if (res?.data?.code !== 200) {

View 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
}

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.4.7",
"version": "4.4.12",
"scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
@@ -30,7 +30,6 @@
"@types/cors": "^2.8.17",
"@sinclair/typebox": "^0.34.9",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/node": "^22.0.1",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.5.12",
@@ -55,8 +54,9 @@
"winston": "^3.17.0"
},
"dependencies": {
"@ffmpeg.wasm/core-mt": "^0.13.2",
"@ffmpeg.wasm/main": "^0.13.1",
"express": "^5.0.0",
"fluent-ffmpeg": "^2.1.2",
"piscina": "^4.7.0",
"qrcode-terminal": "^0.12.0",
"silk-wasm": "^3.6.1",

View File

@@ -2,14 +2,12 @@ import Piscina from 'piscina';
import fsPromise from 'fs/promises';
import path from 'node:path';
import { randomUUID } from 'crypto';
import { spawn } from 'node:child_process';
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
import { LogWrapper } from '@/common/log';
import { EncodeArgs } from "@/common/audio-worker";
import { FFmpegService } from "@/common/ffmpeg";
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
const EXIT_CODES = [0, 255];
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
async function getWorkerPath() {
return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
@@ -26,30 +24,6 @@ async function guessDuration(pttPath: string, logger: LogWrapper) {
return duration;
}
async function convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const cp = spawn(FFMPEG_PATH, ['-y', '-i', filePath, '-ar', '24000', '-ac', '1', '-f', 's16le', pcmPath]);
cp.on('error', (err: Error) => {
logger.log('FFmpeg处理转换出错: ', err.message);
reject(err);
});
cp.on('exit', async (code, signal) => {
if (code == null || EXIT_CODES.includes(code)) {
try {
const data = await fsPromise.readFile(pcmPath);
await fsPromise.unlink(pcmPath);
resolve(data);
} catch (err) {
reject(err);
}
} else {
logger.log(`FFmpeg exit: code=${code ?? 'unknown'} sig=${signal ?? 'unknown'}`);
reject(new Error('FFmpeg处理转换失败'));
}
});
});
}
async function handleWavFile(
file: Buffer,
filePath: string,
@@ -58,7 +32,7 @@ async function handleWavFile(
): Promise<{ input: Buffer; sampleRate: number }> {
const { fmt } = getWavFileInfo(file);
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
return { input: await convert(filePath, pcmPath, logger), sampleRate: 24000 };
return { input: await FFmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
}
return { input: file, sampleRate: fmt.sampleRate };
}
@@ -72,7 +46,7 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
const pcmPath = `${pttPath}.pcm`;
const { input, sampleRate } = isWav(file)
? (await handleWavFile(file, filePath, pcmPath, logger))
: { input: await convert(filePath, pcmPath, logger), sampleRate: 24000 };
: { input: await FFmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
const silk = await piscina.run({ input: input, sampleRate: sampleRate });
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);

151
src/common/ffmpeg-worker.ts Normal file
View 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}`);
}
}

50
src/common/ffmpeg.ts Normal file
View File

@@ -0,0 +1,50 @@
import Piscina from "piscina";
import { VideoInfo } from "./video";
import type { LogWrapper } from "./log";
type EncodeArgs = {
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
args: any[];
};
type EncodeResult = any;
async function getWorkerPath() {
return new URL(/* @vite-ignore */ './ffmpeg-worker.mjs', import.meta.url).href;
}
export class FFmpegService {
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
await piscina.run({ method: 'extractThumbnail', args: [videoPath, thumbnailPath] });
await piscina.destroy();
}
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
await piscina.run({ method: 'convertFile', args: [inputFile, outputFile, format] });
await piscina.destroy();
}
public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath, logger] });
await piscina.destroy();
return result;
}
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
const result = await piscina.run({ method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
await piscina.destroy();
return result;
}
}

View File

@@ -1 +1 @@
export const napCatVersion = '4.4.7';
export const napCatVersion = '4.4.12';

File diff suppressed because one or more lines are too long

View File

@@ -22,11 +22,11 @@ import { ISizeCalculationResult } from 'image-size/dist/types/interface';
import { RkeyManager } from '@/core/helper/rkey';
import { calculateFileMD5 } from '@/common/file';
import pathLib from 'node:path';
import { defaultVideoThumbB64, getVideoInfo } from '@/common/video';
import ffmpeg from 'fluent-ffmpeg';
import { defaultVideoThumbB64 } from '@/common/video';
import { encodeSilk } from '@/common/audio';
import { SendMessageContext } from '@/onebot/api';
import { getFileTypeForSendType } from '../helper/msg';
import { FFmpegService } from '@/common/ffmpeg';
export class NTQQFileApi {
context: InstanceContext;
@@ -40,7 +40,7 @@ export class NTQQFileApi {
this.rkeyManager = new RkeyManager([
'https://rkey.napneko.icu/rkeys'
],
this.context.logger
this.context.logger
);
}
@@ -149,12 +149,6 @@ export class NTQQFileApi {
size: 0,
filePath,
};
try {
videoInfo = await getVideoInfo(filePath, this.context.logger);
} catch (e) {
this.context.logger.logError('获取视频信息失败,将使用默认值', e);
}
let fileExt = 'mp4';
try {
const tempExt = (await fileTypeFromFile(filePath))?.ext;
@@ -162,53 +156,29 @@ export class NTQQFileApi {
} catch (e) {
this.context.logger.logError('获取文件类型失败', e);
}
const newFilePath = filePath + '.' + fileExt;
const newFilePath = `${filePath}.${fileExt}`;
fs.copyFileSync(filePath, newFilePath);
context.deleteAfterSentFiles.push(newFilePath);
filePath = newFilePath;
const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO);
if (fileSize === 0) {
throw new Error('文件异常大小为0');
}
videoInfo.size = fileSize;
let thumb = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
thumb = pathLib.dirname(thumb);
const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true });
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
} catch (error) {
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
const thumbPath = new Map();
const _thumbPath = await new Promise<string | undefined>((resolve, reject) => {
const thumbFileName = `${md5}_0.png`;
const thumbPath = pathLib.join(thumb, thumbFileName);
ffmpeg(filePath)
.on('error', (err) => {
try {
this.context.logger.logDebug('获取视频封面失败,使用默认封面', err);
if (diyThumbPath) {
fsPromises.copyFile(diyThumbPath, thumbPath).then(() => {
resolve(thumbPath);
}).catch(reject);
} else {
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
resolve(thumbPath);
}
} catch (error) {
this.context.logger.logError('获取视频封面失败,使用默认封面失败', error);
}
})
.screenshots({
timestamps: [0],
filename: thumbFileName,
folder: thumb,
size: videoInfo.width + 'x' + videoInfo.height,
})
.on('end', () => {
resolve(thumbPath);
});
});
const thumbSize = _thumbPath ? (await fsPromises.stat(_thumbPath)).size : 0;
thumbPath.set(0, _thumbPath);
const thumbMd5 = _thumbPath ? await calculateFileMD5(_thumbPath) : '';
const thumbSize = (await fsPromises.stat(thumbPath)).size;
const thumbMd5 = await calculateFileMD5(thumbPath);
context.deleteAfterSentFiles.push(path);
const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith('.' + fileExt.toLocaleLowerCase()) ? (fileName || _fileName) : (fileName || _fileName) + '.' + fileExt;
const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith(`.${fileExt.toLocaleLowerCase()}`) ? (fileName || _fileName) : `${fileName || _fileName}.${fileExt}`;
return {
elementType: ElementType.VIDEO,
elementId: '',
@@ -218,15 +188,14 @@ export class NTQQFileApi {
videoMd5: md5,
thumbMd5,
fileTime: videoInfo.time,
thumbPath: thumbPath,
thumbPath: new Map([[0, thumbPath]]),
thumbSize,
thumbWidth: videoInfo.width,
thumbHeight: videoInfo.height,
fileSize: '' + fileSize,
fileSize: fileSize.toString(),
},
};
}
async createValidSendPttElement(pttPath: string): Promise<SendPttElement> {
const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger);
@@ -305,18 +274,18 @@ export class NTQQFileApi {
element.elementType === ElementType.FILE
) {
switch (element.elementType) {
case ElementType.PIC:
case ElementType.PIC:
element.picElement!.sourcePath = elementResults[elementIndex];
break;
case ElementType.VIDEO:
break;
case ElementType.VIDEO:
element.videoElement!.filePath = elementResults[elementIndex];
break;
case ElementType.PTT:
break;
case ElementType.PTT:
element.pttElement!.filePath = elementResults[elementIndex];
break;
case ElementType.FILE:
break;
case ElementType.FILE:
element.fileElement!.filePath = elementResults[elementIndex];
break;
break;
}
elementIndex++;
}

View File

@@ -1,9 +1,8 @@
import { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile';
import { ActionName } from '@/onebot/action/router';
import { spawn } from 'node:child_process';
import { promises as fs } from 'fs';
import { decode } from 'silk-wasm';
const FFMPEG_PATH = process.env.FFMPEG_PATH || 'ffmpeg';
import { FFmpegService } from '@/common/ffmpeg';
const out_format = ['mp3' , 'amr' , 'wma' , 'm4a' , 'spx' , 'ogg' , 'wav' , 'flac'];
@@ -30,7 +29,7 @@ export default class GetRecord extends GetFileBase {
await fs.access(outputFile);
} catch (error) {
await this.decodeFile(inputFile, pcmFile);
await this.convertFile(pcmFile, outputFile, payload.out_format);
await FFmpegService.convertFile(pcmFile, outputFile, payload.out_format);
}
const base64Data = await fs.readFile(outputFile, { encoding: 'base64' });
res.file = outputFile;
@@ -54,23 +53,4 @@ export default class GetRecord extends GetFileBase {
throw error; // 重新抛出错误以便调用者可以处理
}
}
private convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
return new Promise((resolve, reject) => {
const params = format === 'amr' ? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFile, '-ar', '8000', '-b:a', '12.2k', outputFile] : ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFile, outputFile];
const ffmpeg = spawn(FFMPEG_PATH, params);
ffmpeg.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`ffmpeg process exited with code ${code}`));
}
});
ffmpeg.on('error', (error: Error) => {
reject(error);
});
});
}
}

View File

@@ -7,6 +7,7 @@ import { RequestUtil } from '@/common/request';
import { HttpClientConfig } from '@/onebot/config/config';
import { ActionMap } from '@/onebot/action';
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
import json5 from 'json5';
export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig> {
constructor(
@@ -34,7 +35,7 @@ export class OB11HttpClientAdapter extends IOB11NetworkAdapter<HttpClientConfig>
}
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);
}

View File

@@ -8,6 +8,7 @@ import cors from 'cors';
import { HttpServerConfig } from '@/onebot/config/config';
import { NapCatOneBot11Adapter } from "@/onebot";
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
import json5 from 'json5';
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
private app: Express | undefined;
@@ -52,12 +53,17 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
this.app.use((req, res, next) => {
// 兼容处理没有带content-type的请求
req.headers['content-type'] = 'application/json';
const originalJson = express.json({ limit: '5000mb' });
originalJson(req, res, (err) => {
if (err) {
let rawData = '';
req.on('data', (chunk) => {
rawData += chunk;
});
req.on('end', () => {
try {
req.body = json5.parse(rawData);
next();
} catch (err) {
return res.status(400).send('Invalid JSON');
}
next();
});
});
@@ -91,7 +97,7 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
}
if (req.path === '' || req.path === '/') {
const hello = OB11Response.ok({});
hello.message = 'NapCat4 Ss Running';
hello.message = 'NapCat4 Is Running';
return res.json(hello);
}
const actionName = req.path.split('/')[1];

View File

@@ -9,6 +9,7 @@ import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11Li
import { WebsocketClientConfig } from '@/onebot/config/config';
import { NapCatOneBot11Adapter } from "@/onebot";
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
import json5 from 'json5';
export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketClientConfig> {
private connection: WebSocket | null = null;
@@ -129,7 +130,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
let echo = undefined;
try {
receiveData = JSON.parse(message.toString());
receiveData = json5.parse(message.toString());
echo = receiveData.echo;
this.logger.logDebug('[OneBot] [WebSocket Client] 收到正向Websocket消息', receiveData);
} catch (e) {

View File

@@ -12,6 +12,7 @@ import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11Li
import { WebsocketServerConfig } from '@/onebot/config/config';
import { NapCatOneBot11Adapter } from "@/onebot";
import { IOB11NetworkAdapter } from "@/onebot/network/adapter";
import json5 from 'json5';
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
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 echo = undefined;
try {
receiveData = JSON.parse(message.toString());
receiveData = json5.parse(message.toString());
echo = receiveData.echo;
//this.logger.logDebug('收到正向Websocket消息', receiveData);
} catch (e) {

View File

@@ -29,6 +29,7 @@ import { InitWebUi } from '@/webui';
import { WebUiDataRuntime } from '@/webui/src/helper/Data';
import { napCatVersion } from '@/common/version';
import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
import { FFmpegService } from '@/common/ffmpeg';
// NapCat Shell App ES 入口文件
async function handleUncaughtExceptions(logger: LogWrapper) {
process.on('uncaughtException', (err) => {

View File

@@ -1,3 +1,3 @@
import { NCoreInitShell } from "./base";
NCoreInitShell();
NCoreInitShell();

View File

@@ -0,0 +1,14 @@
import { RequestHandler } from "express";
import { RequestUtil } from "@/common/request";
import { sendError, sendSuccess } from "../utils/response";
export const GetProxyHandler: RequestHandler = async (req, res) => {
let { url } = req.query;
if (url && typeof url === "string") {
url = decodeURIComponent(url);
const responseText = await RequestUtil.HttpGetText(url);
res.send(sendSuccess(res, responseText));
} else {
res.send(sendError(res, 'url参数不合法'));
}
};

View File

@@ -1,11 +1,12 @@
import { Router } from 'express';
import { PackageInfoHandler, QQVersionHandler } from '../api/BaseInfo';
import { StatusRealTimeHandler } from "@webapi/api/Status";
import { GetProxyHandler } from '../api/Proxy';
const router = Router();
// router: 获取nc的package.json信息
router.get('/QQVersion', QQVersionHandler);
router.get('/PackageInfo', PackageInfoHandler);
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
router.get('/proxy', GetProxyHandler);
export { router as BaseRouter };

View File

@@ -4,7 +4,7 @@ import { resolve } from 'path';
import nodeResolve from '@rollup/plugin-node-resolve';
import { builtinModules } from 'module';
//依赖排除
const external = ['silk-wasm', 'ws', 'express', 'qrcode-terminal', 'fluent-ffmpeg', 'piscina'];
const external = ['silk-wasm', 'ws', 'express', 'qrcode-terminal', 'piscina', '@ffmpeg.wasm/core-mt', "@ffmpeg.wasm/main"];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
let startScripts: string[] | undefined = undefined;
@@ -79,7 +79,6 @@ const UniversalBaseConfig = () =>
alias: {
'@/core': resolve(__dirname, './src/core'),
'@': resolve(__dirname, './src'),
'./lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg',
'@webapi': resolve(__dirname, './src/webui/src'),
},
},
@@ -91,6 +90,7 @@ const UniversalBaseConfig = () =>
entry: {
napcat: 'src/universal/napcat.ts',
'audio-worker': 'src/common/audio-worker.ts',
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
},
formats: ['es'],
fileName: (_, entryName) => `${entryName}.mjs`,
@@ -109,7 +109,6 @@ const ShellBaseConfig = () =>
alias: {
'@/core': resolve(__dirname, './src/core'),
'@': resolve(__dirname, './src'),
'./lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg',
'@webapi': resolve(__dirname, './src/webui/src'),
},
},
@@ -121,6 +120,7 @@ const ShellBaseConfig = () =>
entry: {
napcat: 'src/shell/napcat.ts',
'audio-worker': 'src/common/audio-worker.ts',
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
},
formats: ['es'],
fileName: (_, entryName) => `${entryName}.mjs`,
@@ -138,7 +138,6 @@ const FrameworkBaseConfig = () =>
alias: {
'@/core': resolve(__dirname, './src/core'),
'@': resolve(__dirname, './src'),
'./lib-cov/fluent-ffmpeg': './lib/fluent-ffmpeg',
'@webapi': resolve(__dirname, './src/webui/src'),
},
},
@@ -150,6 +149,7 @@ const FrameworkBaseConfig = () =>
entry: {
napcat: 'src/framework/napcat.ts',
'audio-worker': 'src/common/audio-worker.ts',
'ffmpeg-worker': 'src/common/ffmpeg-worker.ts',
},
formats: ['es'],
fileName: (_, entryName) => `${entryName}.mjs`,