fix: format error
This commit is contained in:
@@ -11,4 +11,5 @@
|
||||
|
||||
# Ignore auto generated routeTree.gen.ts
|
||||
/src/components/magicui/*.tsx
|
||||
/src/components/ui/*.tsx
|
||||
/src/components/ui/*.tsx
|
||||
/src/types/openapi.d.ts
|
||||
@@ -1,60 +1,60 @@
|
||||
import globals from "globals";
|
||||
import jsLint from "@eslint/js";
|
||||
import tsLint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import a11yLint from "eslint-plugin-jsx-a11y";
|
||||
import nextLint from "@next/eslint-plugin-next";
|
||||
import globals from 'globals'
|
||||
import jsLint from '@eslint/js'
|
||||
import tsLint from 'typescript-eslint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import a11yLint from 'eslint-plugin-jsx-a11y'
|
||||
import nextLint from '@next/eslint-plugin-next'
|
||||
|
||||
export default tsLint.config(
|
||||
{
|
||||
ignores: [
|
||||
"src/components/ui",
|
||||
"src/components/magicui",
|
||||
"src/types/openapi.d.ts",
|
||||
'src/components/ui',
|
||||
'src/components/magicui',
|
||||
'src/types/openapi.d.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
extends: [jsLint.configs.recommended, ...tsLint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
ecmaVersion: 'latest',
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
"@next/next": nextLint,
|
||||
"jsx-a11y": a11yLint,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
'@next/next': nextLint,
|
||||
'jsx-a11y': a11yLint,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"no-console": "warn",
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
'no-console': 'warn',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
args: "all",
|
||||
argsIgnorePattern: "^_",
|
||||
caughtErrors: "all",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
destructuredArrayIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
args: 'all',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'all',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
// Enforce type-only imports for TypeScript types
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "inline-type-imports",
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports',
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export default function AboutLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
|
||||
<div className="inline-block max-w-lg text-center justify-center">
|
||||
<section className='flex flex-col items-center justify-center gap-4 py-8 md:py-10'>
|
||||
<div className='inline-block max-w-lg justify-center text-center'>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { title } from "@/components/primitives";
|
||||
import { title } from '@/components/primitives'
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className={title()}>About</h1>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default as Contact } from "./page";
|
||||
export { default as Contact } from './page'
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import Image from "next/image";
|
||||
import GzhImage from "@public/gzh.jpg";
|
||||
import Image from 'next/image'
|
||||
import GzhImage from '@public/gzh.jpg'
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-[calc(100dvh-64px)]",
|
||||
"bg-[url(/background-opacity.png)] bg-center bg-cover",
|
||||
"flex flex-col items-center justify-center"
|
||||
'h-[calc(100dvh-64px)] w-full',
|
||||
'bg-[url(/background-opacity.png)] bg-cover bg-center',
|
||||
'flex flex-col items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full max-w-7xl px-6",
|
||||
"flex flex-col justify-center items-center gap-8"
|
||||
'h-full w-full max-w-7xl px-6',
|
||||
'flex flex-col items-center justify-center gap-8'
|
||||
)}
|
||||
>
|
||||
<h1 className="text-3xl font-bold text-primary text-center">
|
||||
<h1 className='text-primary text-center text-3xl font-bold'>
|
||||
联系我们
|
||||
</h1>
|
||||
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-lg p-8 max-w-2xl w-full shadow-lg">
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-8">
|
||||
<div className='w-full max-w-2xl rounded-lg bg-white/90 p-8 shadow-lg backdrop-blur-sm'>
|
||||
<div className='flex flex-col items-center justify-center gap-8 md:flex-row'>
|
||||
<Image
|
||||
alt="红河州工商业联合会二维码"
|
||||
className="object-contain border-2 border-gray-200 rounded-lg"
|
||||
alt='红河州工商业联合会二维码'
|
||||
className='rounded-lg border-2 border-gray-200 object-contain'
|
||||
height={160}
|
||||
src={GzhImage}
|
||||
width={160}
|
||||
/>
|
||||
|
||||
<div className="text-primary text-lg font-bold leading-8 text-center md:text-left">
|
||||
<p className="text-xl mb-2">红河州工商业联合会</p>
|
||||
<p className="mb-2">地址:红河州蒙自市五项中心州老年宫A座二楼</p>
|
||||
<div className='text-primary text-center text-lg leading-8 font-bold md:text-left'>
|
||||
<p className='mb-2 text-xl'>红河州工商业联合会</p>
|
||||
<p className='mb-2'>地址:红河州蒙自市五项中心州老年宫A座二楼</p>
|
||||
<p>服务热线:0873—3053626</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-gray-600 text-sm">
|
||||
<div className='mt-6 text-center text-sm text-gray-600'>
|
||||
<p>扫描上方二维码关注我们的微信公众号</p>
|
||||
<p className="mt-1">获取最新资讯和服务信息</p>
|
||||
<p className='mt-1'>获取最新资讯和服务信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
||||
export default function DifyLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<section className="h-[calc(100vh-64px)] w-full overflow-y-scroll">
|
||||
<section className='h-[calc(100vh-64px)] w-full overflow-y-scroll'>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,91 +1,91 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Input, Textarea } from "@heroui/react";
|
||||
import { createParser, type EventSourceMessage } from "eventsource-parser";
|
||||
|
||||
import { title } from "@/components/primitives";
|
||||
import useDifyCmd from "@/lib/useDifyCmd";
|
||||
import { useState } from 'react'
|
||||
import { createParser, type EventSourceMessage } from 'eventsource-parser'
|
||||
import { title } from '@/components/primitives'
|
||||
import useDifyCmd from '@/lib/useDifyCmd'
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input, Textarea } from '@heroui/input'
|
||||
|
||||
export default function DifyPage() {
|
||||
const { sendQuestion } = useDifyCmd();
|
||||
const { sendQuestion } = useDifyCmd()
|
||||
|
||||
const [question, setQuestion] = useState<string>("工商联的主要职责有哪些");
|
||||
const [question, setQuestion] = useState<string>('工商联的主要职责有哪些')
|
||||
|
||||
const [conversationId, setConversationId] = useState<string>("");
|
||||
const [id, setId] = useState<string>("");
|
||||
const [messageId, setMessageId] = useState<string>("");
|
||||
const [taskId, setTaskId] = useState<string>("");
|
||||
const [answer, setAnswer] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [conversationId, setConversationId] = useState<string>('')
|
||||
const [id, setId] = useState<string>('')
|
||||
const [messageId, setMessageId] = useState<string>('')
|
||||
const [taskId, setTaskId] = useState<string>('')
|
||||
const [answer, setAnswer] = useState<string>('')
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
|
||||
const parser = createParser({
|
||||
onEvent(event: EventSourceMessage) {
|
||||
const data = JSON.parse(event.data);
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
setConversationId(data.conversation_id);
|
||||
setId(data.id);
|
||||
setMessageId(data.message_id);
|
||||
setTaskId(data.task_id);
|
||||
setConversationId(data.conversation_id)
|
||||
setId(data.id)
|
||||
setMessageId(data.message_id)
|
||||
setTaskId(data.task_id)
|
||||
|
||||
if (data.answer) {
|
||||
setAnswer((prev) => {
|
||||
return prev + data.answer;
|
||||
});
|
||||
return prev + data.answer
|
||||
})
|
||||
}
|
||||
},
|
||||
onRetry(retryMs) {
|
||||
console.log("服务器建议重连间隔:", retryMs);
|
||||
console.log('服务器建议重连间隔:', retryMs)
|
||||
},
|
||||
onError(err) {
|
||||
console.error("解析错误", err);
|
||||
console.error('解析错误', err)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
async function sendMessage() {
|
||||
setAnswer("");
|
||||
setConversationId("");
|
||||
setId("");
|
||||
setMessageId("");
|
||||
setTaskId("");
|
||||
setAnswer('')
|
||||
setConversationId('')
|
||||
setId('')
|
||||
setMessageId('')
|
||||
setTaskId('')
|
||||
|
||||
setLoading(true);
|
||||
const resp = await sendQuestion(question);
|
||||
setLoading(true)
|
||||
const resp = await sendQuestion(question)
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}`);
|
||||
throw new Error(`HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
const reader = resp.body?.pipeThrough(new TextDecoderStream()).getReader();
|
||||
const reader = resp.body?.pipeThrough(new TextDecoderStream()).getReader()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const result = await reader?.read();
|
||||
const result = await reader?.read()
|
||||
|
||||
if (result?.done) {
|
||||
setLoading(false);
|
||||
break;
|
||||
setLoading(false)
|
||||
break
|
||||
}
|
||||
|
||||
if (result?.value) {
|
||||
parser.feed(result.value);
|
||||
parser.feed(result.value)
|
||||
}
|
||||
}
|
||||
parser.reset({ consume: true });
|
||||
parser.reset({ consume: true })
|
||||
} finally {
|
||||
reader?.releaseLock();
|
||||
reader?.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full max-w-xl mx-auto flex flex-col items-center gap-2">
|
||||
<div className='mx-auto flex h-full w-full max-w-xl flex-col items-center gap-2'>
|
||||
<h1 className={title()}>Dify 对话测试</h1>
|
||||
<div className="w-full flex flex-row gap-2">
|
||||
<div className='flex w-full flex-row gap-2'>
|
||||
<Input
|
||||
isDisabled={loading}
|
||||
value={question}
|
||||
onValueChange={setQuestion}
|
||||
size="md"
|
||||
size='md'
|
||||
/>
|
||||
<Button isDisabled={loading} isLoading={loading} onPress={sendMessage}>
|
||||
发送
|
||||
@@ -93,39 +93,39 @@ export default function DifyPage() {
|
||||
</div>
|
||||
|
||||
{answer.length > 0 && (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<div className='flex w-full flex-col gap-2'>
|
||||
<Textarea
|
||||
readOnly
|
||||
label="conversation_id"
|
||||
label='conversation_id'
|
||||
minRows={1}
|
||||
size="sm"
|
||||
size='sm'
|
||||
value={conversationId}
|
||||
/>
|
||||
<Textarea readOnly label="id" minRows={1} size="sm" value={id} />
|
||||
<Textarea readOnly label='id' minRows={1} size='sm' value={id} />
|
||||
<Textarea
|
||||
readOnly
|
||||
label="message_id"
|
||||
label='message_id'
|
||||
minRows={1}
|
||||
size="sm"
|
||||
size='sm'
|
||||
value={messageId}
|
||||
/>
|
||||
<Textarea
|
||||
readOnly
|
||||
label="task_id"
|
||||
label='task_id'
|
||||
minRows={1}
|
||||
size="sm"
|
||||
size='sm'
|
||||
value={taskId}
|
||||
/>
|
||||
<Textarea
|
||||
readOnly
|
||||
label="Answer"
|
||||
label='Answer'
|
||||
maxRows={100}
|
||||
minRows={1}
|
||||
size="sm"
|
||||
size='sm'
|
||||
value={answer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
error: Error
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
/* eslint-disable no-console */
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -27,5 +27,5 @@ export default function Error({
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,181 +1,179 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { FileText } from 'lucide-react'
|
||||
import {
|
||||
industryChainData,
|
||||
type PDFFile,
|
||||
type IndustryChain,
|
||||
} from "../data/industryChains";
|
||||
import { toInlinePdfUrl } from "@/lib/oss";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
} from '../data/industryChains'
|
||||
import { toInlinePdfUrl } from '@/lib/oss'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export const IndustryChainList = () => {
|
||||
const [selectedChain, setSelectedChain] = useState<IndustryChain | null>(
|
||||
industryChainData[0]
|
||||
);
|
||||
const [loadingChains, setLoadingChains] = useState<Set<string>>(new Set());
|
||||
const [visiblePdfFiles, setVisiblePdfFiles] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
)
|
||||
const [loadingChains, setLoadingChains] = useState<Set<string>>(new Set())
|
||||
const [visiblePdfFiles, setVisiblePdfFiles] = useState<Set<string>>(new Set())
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleChainClick = (chain: IndustryChain) => {
|
||||
setSelectedChain(chain);
|
||||
setVisiblePdfFiles(new Set()); // 重置可见的PDF文件
|
||||
setLoadingChains((prev) => new Set(prev).add(chain.id));
|
||||
setSelectedChain(chain)
|
||||
setVisiblePdfFiles(new Set()) // 重置可见的PDF文件
|
||||
setLoadingChains((prev) => new Set(prev).add(chain.id))
|
||||
|
||||
// 模拟数据加载延迟
|
||||
setTimeout(() => {
|
||||
setLoadingChains((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(chain.id);
|
||||
return newSet;
|
||||
});
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(chain.id)
|
||||
return newSet
|
||||
})
|
||||
|
||||
// 初始显示前5个PDF文件
|
||||
const initialFiles = chain.pdfFiles.slice(0, 5).map((f) => f.id);
|
||||
setVisiblePdfFiles(new Set(initialFiles));
|
||||
}, 300);
|
||||
};
|
||||
const initialFiles = chain.pdfFiles.slice(0, 5).map((f) => f.id)
|
||||
setVisiblePdfFiles(new Set(initialFiles))
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handlePDFClick = (pdfFile: PDFFile) => {
|
||||
const url = toInlinePdfUrl(pdfFile.filePath);
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
const url = toInlinePdfUrl(pdfFile.filePath)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 懒加载更多PDF文件
|
||||
useEffect(() => {
|
||||
if (!selectedChain || !loadMoreRef.current) return;
|
||||
if (!selectedChain || !loadMoreRef.current) return
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !isLoadingMore) {
|
||||
loadMorePdfFiles();
|
||||
loadMorePdfFiles()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
)
|
||||
|
||||
observerRef.current.observe(loadMoreRef.current);
|
||||
observerRef.current.observe(loadMoreRef.current)
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current.disconnect()
|
||||
}
|
||||
};
|
||||
}, [selectedChain, isLoadingMore]);
|
||||
}
|
||||
}, [selectedChain, isLoadingMore])
|
||||
|
||||
const loadMorePdfFiles = () => {
|
||||
if (!selectedChain || isLoadingMore) return;
|
||||
if (!selectedChain || isLoadingMore) return
|
||||
|
||||
const currentVisibleCount = visiblePdfFiles.size;
|
||||
const totalFiles = selectedChain.pdfFiles.length;
|
||||
const currentVisibleCount = visiblePdfFiles.size
|
||||
const totalFiles = selectedChain.pdfFiles.length
|
||||
|
||||
if (currentVisibleCount >= totalFiles) return;
|
||||
if (currentVisibleCount >= totalFiles) return
|
||||
|
||||
setIsLoadingMore(true);
|
||||
setIsLoadingMore(true)
|
||||
|
||||
// 模拟加载延迟
|
||||
setTimeout(() => {
|
||||
const nextBatch = selectedChain.pdfFiles.slice(
|
||||
currentVisibleCount,
|
||||
currentVisibleCount + 5
|
||||
);
|
||||
const newVisibleFiles = new Set(visiblePdfFiles);
|
||||
nextBatch.forEach((file) => newVisibleFiles.add(file.id));
|
||||
)
|
||||
const newVisibleFiles = new Set(visiblePdfFiles)
|
||||
nextBatch.forEach((file) => newVisibleFiles.add(file.id))
|
||||
|
||||
setVisiblePdfFiles(newVisibleFiles);
|
||||
setIsLoadingMore(false);
|
||||
}, 500);
|
||||
};
|
||||
setVisiblePdfFiles(newVisibleFiles)
|
||||
setIsLoadingMore(false)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const panelHeight = "h-[750px]";
|
||||
const panelHeight = 'h-[750px]'
|
||||
|
||||
return (
|
||||
<div className="bg-white py-8 flex justify-center">
|
||||
<div className="w-full max-w-[2200px] mx-auto">
|
||||
<div className='flex justify-center bg-white py-8'>
|
||||
<div className='mx-auto w-full max-w-[2200px]'>
|
||||
{/* 移动端横向标签+文件列表 */}
|
||||
<div className="block md:hidden w-full">
|
||||
<div className="flex overflow-x-auto no-scrollbar gap-2 px-2 pb-1">
|
||||
<div className='block w-full md:hidden'>
|
||||
<div className='no-scrollbar flex gap-2 overflow-x-auto px-2 pb-1'>
|
||||
{industryChainData.map((chain) => {
|
||||
const isSelected = selectedChain?.id === chain.id;
|
||||
const isLoading = loadingChains.has(chain.id);
|
||||
const isSelected = selectedChain?.id === chain.id
|
||||
const isLoading = loadingChains.has(chain.id)
|
||||
return (
|
||||
<div
|
||||
key={chain.id}
|
||||
className={`whitespace-nowrap text-base font-bold px-4 py-2 rounded-lg cursor-pointer transition-colors border-b-2 ${
|
||||
className={`cursor-pointer rounded-lg border-b-2 px-4 py-2 text-base font-bold whitespace-nowrap transition-colors ${
|
||||
isSelected
|
||||
? "text-[#BD1A2D] border-[#BD1A2D] bg-red-50"
|
||||
: "text-black border-transparent bg-white hover:text-[#BD1A2D] hover:bg-gray-50"
|
||||
? 'border-[#BD1A2D] bg-red-50 text-[#BD1A2D]'
|
||||
: 'border-transparent bg-white text-black hover:bg-gray-50 hover:text-[#BD1A2D]'
|
||||
}`}
|
||||
onClick={() => handleChainClick(chain)}
|
||||
role="button"
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
handleChainClick(chain);
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
handleChainClick(chain)
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-gray-300 rounded-full animate-pulse"></div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='h-4 w-4 animate-pulse rounded-full bg-gray-300'></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
chain.name
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="w-full mt-1 px-2">
|
||||
<div className="w-full h-full bg-[#F2F2F2] rounded-lg p-4 overflow-y-auto">
|
||||
<div className='mt-1 w-full px-2'>
|
||||
<div className='h-full w-full overflow-y-auto rounded-lg bg-[#F2F2F2] p-4'>
|
||||
{selectedChain ? (
|
||||
<div>
|
||||
<div className="mb-4 pb-2 border-b border-gray-300">
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-1">
|
||||
<div className='mb-4 border-b border-gray-300 pb-2'>
|
||||
<h3 className='mb-1 text-lg font-bold text-gray-800'>
|
||||
{selectedChain.name}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
<p className='text-sm text-gray-600'>
|
||||
{selectedChain.pdfFiles.length > 0
|
||||
? `共 ${selectedChain.pdfFiles.length} 个招商项目文档`
|
||||
: "暂无项目文档"}
|
||||
: '暂无项目文档'}
|
||||
</p>
|
||||
</div>
|
||||
{selectedChain.pdfFiles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
{selectedChain.pdfFiles.map((pdfFile, index) => (
|
||||
<div
|
||||
key={pdfFile.id}
|
||||
className="flex items-center gap-3 p-3 bg-white rounded-lg shadow-sm cursor-pointer hover:shadow-md hover:bg-gray-50 transition-all group border border-gray-200"
|
||||
className='group flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:bg-gray-50 hover:shadow-md'
|
||||
onClick={() => handlePDFClick(pdfFile)}
|
||||
role="button"
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
handlePDFClick(pdfFile);
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
handlePDFClick(pdfFile)
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center group-hover:bg-red-200 transition-colors">
|
||||
<FileText className="w-5 h-5 text-[#BD1A2D]" />
|
||||
<div className='flex-shrink-0'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 transition-colors group-hover:bg-red-200'>
|
||||
<FileText className='h-5 w-5 text-[#BD1A2D]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-1 flex items-center gap-2'>
|
||||
<span className='text-xs font-medium text-gray-500'>
|
||||
项目 {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-gray-800 group-hover:text-[#BD1A2D] transition-colors line-clamp-2">
|
||||
<h4 className='line-clamp-2 text-base font-semibold text-gray-800 transition-colors group-hover:text-[#BD1A2D]'>
|
||||
{pdfFile.name}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="text-xs text-gray-500 group-hover:text-[#BD1A2D] transition-colors">
|
||||
<div className='flex-shrink-0'>
|
||||
<div className='text-xs text-gray-500 transition-colors group-hover:text-[#BD1A2D]'>
|
||||
点击预览 →
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,12 +181,12 @@ export const IndustryChainList = () => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-2" />
|
||||
<h4 className="text-base font-medium text-gray-600 mb-1">
|
||||
<div className='py-8 text-center'>
|
||||
<FileText className='mx-auto mb-2 h-12 w-12 text-gray-400' />
|
||||
<h4 className='mb-1 text-base font-medium text-gray-600'>
|
||||
暂无项目文档
|
||||
</h4>
|
||||
<p className="text-gray-500 text-xs">
|
||||
<p className='text-xs text-gray-500'>
|
||||
该产业链暂时没有可用的招商项目文档
|
||||
</p>
|
||||
</div>
|
||||
@@ -200,35 +198,35 @@ export const IndustryChainList = () => {
|
||||
</div>
|
||||
|
||||
{/* PC端布局 - 类似优化 */}
|
||||
<div className="hidden md:flex flex-col md:flex-row w-full justify-center items-center">
|
||||
<div className='hidden w-full flex-col items-center justify-center md:flex md:flex-row'>
|
||||
{/* 左侧产业链列表 */}
|
||||
<div
|
||||
className={`w-full md:w-[300px] pl-0 md:pl-12 pt-6 md:pt-9 flex-shrink-0 h-[750px] flex justify-center`}
|
||||
className={`flex h-[750px] w-full flex-shrink-0 justify-center pt-6 pl-0 md:w-[300px] md:pt-9 md:pl-12`}
|
||||
>
|
||||
<div className={`space-y-3 h-full overflow-y-auto pr-2`}>
|
||||
<div className={`h-full space-y-3 overflow-y-auto pr-2`}>
|
||||
{industryChainData.map((chain) => {
|
||||
const isSelected = selectedChain?.id === chain.id;
|
||||
const isLoading = loadingChains.has(chain.id);
|
||||
const isSelected = selectedChain?.id === chain.id
|
||||
const isLoading = loadingChains.has(chain.id)
|
||||
return (
|
||||
<div
|
||||
key={chain.id}
|
||||
className={`text-xl font-bold leading-6 cursor-pointer transition-colors py-3 px-4 rounded-lg ${
|
||||
className={`cursor-pointer rounded-lg px-4 py-3 text-xl leading-6 font-bold transition-colors ${
|
||||
isSelected
|
||||
? "text-[#BD1A2D] bg-red-50 border-l-4 border-[#BD1A2D]"
|
||||
: "text-black hover:text-[#BD1A2D] hover:bg-gray-50"
|
||||
? 'border-l-4 border-[#BD1A2D] bg-red-50 text-[#BD1A2D]'
|
||||
: 'text-black hover:bg-gray-50 hover:text-[#BD1A2D]'
|
||||
}`}
|
||||
onClick={() => handleChainClick(chain)}
|
||||
role="button"
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
handleChainClick(chain);
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
handleChainClick(chain)
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className='flex items-center justify-between'>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-gray-300 rounded-full animate-pulse"></div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='h-4 w-4 animate-pulse rounded-full bg-gray-300'></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -236,60 +234,60 @@ export const IndustryChainList = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧PDF文件列表 - 类似移动端优化 */}
|
||||
<div
|
||||
className={`flex-1 w-full md:w-[70%] pl-0 md:pl-8 pr-0 md:pr-12 mt-7 h-[750px] flex justify-center`}
|
||||
className={`mt-7 flex h-[750px] w-full flex-1 justify-center pr-0 pl-0 md:w-[70%] md:pr-12 md:pl-8`}
|
||||
>
|
||||
<div className="w-full h-full bg-[#F2F2F2] rounded-lg p-4 md:p-6 overflow-y-auto">
|
||||
<div className='h-full w-full overflow-y-auto rounded-lg bg-[#F2F2F2] p-4 md:p-6'>
|
||||
{selectedChain ? (
|
||||
<div>
|
||||
<div className="mb-6 pb-4 border-b border-gray-300">
|
||||
<h3 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
<div className='mb-6 border-b border-gray-300 pb-4'>
|
||||
<h3 className='mb-2 text-2xl font-bold text-gray-800'>
|
||||
{selectedChain.name}
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<p className='text-gray-600'>
|
||||
{selectedChain.pdfFiles.length > 0
|
||||
? `共 ${selectedChain.pdfFiles.length} 个招商项目文档`
|
||||
: "暂无项目文档"}
|
||||
: '暂无项目文档'}
|
||||
</p>
|
||||
</div>
|
||||
{/* PDF文件列表 */}
|
||||
{selectedChain.pdfFiles.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
{selectedChain.pdfFiles.map((pdfFile, index) => (
|
||||
<div
|
||||
key={pdfFile.id}
|
||||
className="flex items-center gap-4 p-4 bg-white rounded-lg shadow-sm cursor-pointer hover:shadow-md hover:bg-gray-50 transition-all group border border-gray-200"
|
||||
className='group flex cursor-pointer items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-all hover:bg-gray-50 hover:shadow-md'
|
||||
onClick={() => handlePDFClick(pdfFile)}
|
||||
role="button"
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ")
|
||||
handlePDFClick(pdfFile);
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
handlePDFClick(pdfFile)
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center group-hover:bg-red-200 transition-colors">
|
||||
<FileText className="w-6 h-6 text-[#BD1A2D]" />
|
||||
<div className='flex-shrink-0'>
|
||||
<div className='flex h-12 w-12 items-center justify-center rounded-lg bg-red-100 transition-colors group-hover:bg-red-200'>
|
||||
<FileText className='h-6 w-6 text-[#BD1A2D]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-500">
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-1 flex items-center gap-2'>
|
||||
<span className='text-sm font-medium text-gray-500'>
|
||||
项目 {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-gray-800 group-hover:text-[#BD1A2D] transition-colors line-clamp-2">
|
||||
<h4 className='line-clamp-2 text-lg font-semibold text-gray-800 transition-colors group-hover:text-[#BD1A2D]'>
|
||||
{pdfFile.name}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="text-sm text-gray-500 group-hover:text-[#BD1A2D] transition-colors">
|
||||
<div className='flex-shrink-0'>
|
||||
<div className='text-sm text-gray-500 transition-colors group-hover:text-[#BD1A2D]'>
|
||||
点击预览 →
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,26 +295,26 @@ export const IndustryChainList = () => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h4 className="text-lg font-medium text-gray-600 mb-2">
|
||||
<div className='py-12 text-center'>
|
||||
<FileText className='mx-auto mb-4 h-16 w-16 text-gray-400' />
|
||||
<h4 className='mb-2 text-lg font-medium text-gray-600'>
|
||||
暂无项目文档
|
||||
</h4>
|
||||
<p className="text-gray-500">
|
||||
<p className='text-gray-500'>
|
||||
该产业链暂时没有可用的招商项目文档
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<div className="space-y-4">
|
||||
<FileText className="w-20 h-20 text-gray-400 mx-auto" />
|
||||
<div className='py-16 text-center'>
|
||||
<div className='space-y-4'>
|
||||
<FileText className='mx-auto h-20 w-20 text-gray-400' />
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-600 mb-2">
|
||||
<h3 className='mb-2 text-xl font-bold text-gray-600'>
|
||||
产业链项目文档
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
<p className='text-gray-500'>
|
||||
请从左侧选择一个产业链查看相关项目文档
|
||||
</p>
|
||||
</div>
|
||||
@@ -328,5 +326,5 @@ export const IndustryChainList = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import Image from "next/image";
|
||||
import Image from 'next/image'
|
||||
|
||||
export const InvestmentHero = () => {
|
||||
return (
|
||||
<div className="relative w-full aspect-[12/5]">
|
||||
<div className='relative aspect-[12/5] w-full'>
|
||||
<Image
|
||||
src="https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xhzs3.png"
|
||||
alt="红河州产业链招商 共筑产业新生态"
|
||||
src='https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xhzs3.png'
|
||||
alt='红河州产业链招商 共筑产业新生态'
|
||||
fill
|
||||
className="object-cover"
|
||||
className='object-cover'
|
||||
priority
|
||||
sizes="100vw"
|
||||
sizes='100vw'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,151 +1,178 @@
|
||||
"use client";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Pagination } from 'swiper/modules';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
import { toInlinePdfUrl } from "@/lib/oss";
|
||||
'use client'
|
||||
import { ProjectCard } from './ProjectCard'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import { Autoplay, Pagination } from 'swiper/modules'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/pagination'
|
||||
import { toInlinePdfUrl } from '@/lib/oss'
|
||||
|
||||
const projectsData = [
|
||||
{
|
||||
id: 1,
|
||||
title: "绿色铝精深加工产业链",
|
||||
description: "红河州个旧市高精度铝挤压生产线项目",
|
||||
imageUrl: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm1.png",
|
||||
pdfFile: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色铝精深加工产业链/1.红河州个旧市高精度铝挤压生产线项目.pdf",
|
||||
title: '绿色铝精深加工产业链',
|
||||
description: '红河州个旧市高精度铝挤压生产线项目',
|
||||
imageUrl:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm1.png',
|
||||
pdfFile:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色铝精深加工产业链/1.红河州个旧市高精度铝挤压生产线项目.pdf',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "绿色食品精深加工产业链",
|
||||
description: "蒙自经开区红河综保区面向东盟特色食品加工园项目",
|
||||
imageUrl: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm2.png",
|
||||
pdfFile: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/2.红河州石屏县果蔬加工项目.pdf",
|
||||
title: '绿色食品精深加工产业链',
|
||||
description: '蒙自经开区红河综保区面向东盟特色食品加工园项目',
|
||||
imageUrl:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm2.png',
|
||||
pdfFile:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/2.红河州石屏县果蔬加工项目.pdf',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "文旅产业链",
|
||||
description: "红河州蒙自市大屯海国际康养度假区项目",
|
||||
imageUrl: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm3.png",
|
||||
pdfFile: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/1.红河州蒙自市大屯海国际康养度假区项目.pdf",
|
||||
title: '文旅产业链',
|
||||
description: '红河州蒙自市大屯海国际康养度假区项目',
|
||||
imageUrl:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm3.png',
|
||||
pdfFile:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/1.红河州蒙自市大屯海国际康养度假区项目.pdf',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "电子信息制造产业链",
|
||||
description: "蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目",
|
||||
imageUrl: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm4.png",
|
||||
pdfFile: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/电子信息制造产业链/1-蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目.pdf",
|
||||
title: '电子信息制造产业链',
|
||||
description:
|
||||
'蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目',
|
||||
imageUrl:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm4.png',
|
||||
pdfFile:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/电子信息制造产业链/1-蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "生物医药产业链",
|
||||
description: "红河州黄精产业建设项目",
|
||||
imageUrl: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm5.png",
|
||||
pdfFile: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/生物医药产业链/1-红河州黄精产业建设项目.pdf",
|
||||
title: '生物医药产业链',
|
||||
description: '红河州黄精产业建设项目',
|
||||
imageUrl:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm5.png',
|
||||
pdfFile:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/生物医药产业链/1-红河州黄精产业建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "有色金属新材料产业链",
|
||||
description: "蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目",
|
||||
imageUrl: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm6.png",
|
||||
pdfFile: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/有色金属新材料产业链/1.蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目.pdf",
|
||||
title: '有色金属新材料产业链',
|
||||
description: '蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目',
|
||||
imageUrl:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm6.png',
|
||||
pdfFile:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/有色金属新材料产业链/1.蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "新能源储能产业链",
|
||||
description: "蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目",
|
||||
imageUrl: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm7.png",
|
||||
pdfFile: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/新能源储能产业链/1-云南自贸试验区红河片区高性能新能源电池生产制造项目.pdf",
|
||||
title: '新能源储能产业链',
|
||||
description: '蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目',
|
||||
imageUrl:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/xm7.png',
|
||||
pdfFile:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/新能源储能产业链/1-云南自贸试验区红河片区高性能新能源电池生产制造项目.pdf',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
function useIsOverflow(threshold = 0) {
|
||||
const [isOverflow, setIsOverflow] = useState(false);
|
||||
const [isOverflow, setIsOverflow] = useState(false)
|
||||
useEffect(() => {
|
||||
function check() {
|
||||
const width = window.innerWidth;
|
||||
setIsOverflow(width < (286 * projectsData.length + 20 * (projectsData.length - 1) + threshold));
|
||||
const width = window.innerWidth
|
||||
setIsOverflow(
|
||||
width <
|
||||
286 * projectsData.length + 20 * (projectsData.length - 1) + threshold
|
||||
)
|
||||
}
|
||||
check();
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
return isOverflow;
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
return isOverflow
|
||||
}
|
||||
|
||||
// 添加图片预加载组件
|
||||
const ImageWithLoading = ({ src, alt, className, onLoad }: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
onLoad?: () => void;
|
||||
const ImageWithLoading = ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
onLoad,
|
||||
}: {
|
||||
src: string
|
||||
alt: string
|
||||
className?: string
|
||||
onLoad?: () => void
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className='relative'>
|
||||
{isLoading && (
|
||||
<div className={`absolute inset-0 bg-gray-200 animate-pulse rounded-lg ${className}`} />
|
||||
<div
|
||||
className={`absolute inset-0 animate-pulse rounded-lg bg-gray-200 ${className}`}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${className} ${isLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`}
|
||||
onLoad={() => {
|
||||
setIsLoading(false);
|
||||
onLoad?.();
|
||||
setIsLoading(false)
|
||||
onLoad?.()
|
||||
}}
|
||||
onError={() => {
|
||||
setIsLoading(false);
|
||||
setHasError(true);
|
||||
setIsLoading(false)
|
||||
setHasError(true)
|
||||
}}
|
||||
/>
|
||||
{hasError && (
|
||||
<div className={`absolute inset-0 bg-gray-300 flex items-center justify-center rounded-lg ${className}`}>
|
||||
<span className="text-gray-500 text-sm">图片加载失败</span>
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center rounded-lg bg-gray-300 ${className}`}
|
||||
>
|
||||
<span className='text-sm text-gray-500'>图片加载失败</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const KeyProjectsSection = () => {
|
||||
const isOverflow = useIsOverflow();
|
||||
const [loadingImages, setLoadingImages] = useState<Set<number>>(new Set());
|
||||
|
||||
const isOverflow = useIsOverflow()
|
||||
const [loadingImages, setLoadingImages] = useState<Set<number>>(new Set())
|
||||
|
||||
const handleImageLoad = (projectId: number) => {
|
||||
setLoadingImages(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(projectId);
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
setLoadingImages((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(projectId)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const handleClick = (pdfFile?: string) => {
|
||||
if (pdfFile) {
|
||||
const url = toInlinePdfUrl(pdfFile);
|
||||
window.open(url, '_blank');
|
||||
const url = toInlinePdfUrl(pdfFile)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// 初始化时设置所有图片为加载状态
|
||||
useEffect(() => {
|
||||
const initialLoadingSet = new Set(projectsData.map(p => p.id));
|
||||
setLoadingImages(initialLoadingSet);
|
||||
}, []);
|
||||
|
||||
const initialLoadingSet = new Set(projectsData.map((p) => p.id))
|
||||
setLoadingImages(initialLoadingSet)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white py-12">
|
||||
<div className="max-w-7xl mx-auto flex flex-col items-center">
|
||||
<div className="mb-12">
|
||||
<h2 className="text-4xl font-bold text-[#BD1A2D] text-center">
|
||||
<div className='w-full bg-white py-12'>
|
||||
<div className='mx-auto flex max-w-7xl flex-col items-center'>
|
||||
<div className='mb-12'>
|
||||
<h2 className='text-center text-4xl font-bold text-[#BD1A2D]'>
|
||||
重点招商项目
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
{isOverflow ? (
|
||||
<div className="w-full mb-2">
|
||||
<div className='mb-2 w-full'>
|
||||
<Swiper
|
||||
spaceBetween={0}
|
||||
slidesPerView={1}
|
||||
@@ -153,22 +180,24 @@ export const KeyProjectsSection = () => {
|
||||
autoplay={{ delay: 3000, disableOnInteraction: false }}
|
||||
pagination={{ clickable: true }}
|
||||
modules={[Autoplay, Pagination]}
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
>
|
||||
{projectsData.map((project) => (
|
||||
<SwiperSlide key={project.id}>
|
||||
<div
|
||||
className="relative w-full h-[220px] sm:h-[260px] md:h-[320px] cursor-pointer"
|
||||
className='relative h-[220px] w-full cursor-pointer sm:h-[260px] md:h-[320px]'
|
||||
onClick={() => handleClick(project.pdfFile)}
|
||||
>
|
||||
<ImageWithLoading
|
||||
src={project.imageUrl}
|
||||
alt={project.title}
|
||||
className="w-full h-full object-cover rounded-2xl"
|
||||
className='h-full w-full rounded-2xl object-cover'
|
||||
onLoad={() => handleImageLoad(project.id)}
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 w-full bg-gradient-to-t from-black/70 to-transparent p-4 rounded-b-2xl">
|
||||
<p className="text-white text-lg font-bold line-clamp-2">{project.description}</p>
|
||||
<div className='absolute bottom-0 left-0 w-full rounded-b-2xl bg-gradient-to-t from-black/70 to-transparent p-4'>
|
||||
<p className='line-clamp-2 text-lg font-bold text-white'>
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
@@ -176,9 +205,9 @@ export const KeyProjectsSection = () => {
|
||||
</Swiper>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center gap-4 w-full no-scrollbar whitespace-nowrap">
|
||||
<div className='no-scrollbar flex w-full justify-center gap-4 whitespace-nowrap'>
|
||||
{projectsData.map((project, index) => (
|
||||
<div key={project.id} className="shrink-0">
|
||||
<div key={project.id} className='shrink-0'>
|
||||
<ProjectCard
|
||||
title={project.title}
|
||||
description={project.description}
|
||||
@@ -195,5 +224,5 @@ export const KeyProjectsSection = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
import { Card, CardBody } from "@heroui/react";
|
||||
import Image from "next/image";
|
||||
import { toInlinePdfUrl } from "@/lib/oss";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import Image from 'next/image'
|
||||
import { toInlinePdfUrl } from '@/lib/oss'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface ProjectCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
imageAlt?: string;
|
||||
className?: string;
|
||||
pdfFile?: string;
|
||||
isLoading?: boolean;
|
||||
onImageLoad?: () => void;
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
imageAlt?: string
|
||||
className?: string
|
||||
pdfFile?: string
|
||||
isLoading?: boolean
|
||||
onImageLoad?: () => void
|
||||
}
|
||||
|
||||
export const ProjectCard = ({
|
||||
title,
|
||||
description,
|
||||
imageUrl,
|
||||
imageAlt = "",
|
||||
className = "",
|
||||
export const ProjectCard = ({
|
||||
title,
|
||||
description,
|
||||
imageUrl,
|
||||
imageAlt = '',
|
||||
className = '',
|
||||
pdfFile,
|
||||
isLoading = false,
|
||||
onImageLoad
|
||||
onImageLoad,
|
||||
}: ProjectCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
className={`w-[286px] h-[355px] shadow-lg rounded-2xl bg-white ${className} hover:scale-105 transition-transform duration-300`}
|
||||
<Card
|
||||
className={`h-[355px] w-[286px] rounded-2xl bg-white shadow-lg ${className} transition-transform duration-300 hover:scale-105`}
|
||||
isPressable
|
||||
onClick={() => {
|
||||
if (pdfFile) {
|
||||
const url = toInlinePdfUrl(pdfFile);
|
||||
window.open(url, "_blank");
|
||||
const url = toInlinePdfUrl(pdfFile)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardBody className="p-0 flex flex-col">
|
||||
<div className="relative w-full h-[222px] overflow-hidden rounded-t-2xl bg-gray-50">
|
||||
<CardBody className='flex flex-col p-0'>
|
||||
<div className='relative h-[222px] w-full overflow-hidden rounded-t-2xl bg-gray-50'>
|
||||
{isLoading ? (
|
||||
<Skeleton className="w-full h-full" />
|
||||
<Skeleton className='h-full w-full' />
|
||||
) : (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="286px"
|
||||
className='object-contain'
|
||||
sizes='286px'
|
||||
onLoad={onImageLoad}
|
||||
priority={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-[22px] font-bold text-black leading-6 line-clamp-2">
|
||||
<div className='flex flex-1 flex-col justify-between p-4'>
|
||||
<div className='space-y-3'>
|
||||
<h3 className='line-clamp-2 text-[22px] leading-6 font-bold text-black'>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-lg font-medium text-black leading-5 line-clamp-2">
|
||||
<p className='line-clamp-2 text-lg leading-5 font-medium text-black'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { ProjectCard } from "./ProjectCard";
|
||||
export { InvestmentHero } from "./InvestmentHero";
|
||||
export { KeyProjectsSection } from "./KeyProjectsSection";
|
||||
export { IndustryChainList } from "./IndustryChainList";
|
||||
export { ProjectCard } from './ProjectCard'
|
||||
export { InvestmentHero } from './InvestmentHero'
|
||||
export { KeyProjectsSection } from './KeyProjectsSection'
|
||||
export { IndustryChainList } from './IndustryChainList'
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./industryChains";
|
||||
export * from './industryChains'
|
||||
|
||||
@@ -1,326 +1,375 @@
|
||||
export interface PDFFile {
|
||||
id: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
id: string
|
||||
name: string
|
||||
fileName: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface IndustryChain {
|
||||
id: string;
|
||||
name: string;
|
||||
folderName: string;
|
||||
pdfFiles: PDFFile[];
|
||||
id: string
|
||||
name: string
|
||||
folderName: string
|
||||
pdfFiles: PDFFile[]
|
||||
}
|
||||
|
||||
export const industryChainData: IndustryChain[] = [
|
||||
{
|
||||
id: "green-food",
|
||||
name: "绿色食品精深加工产业链",
|
||||
folderName: "绿色食品精深加工产业链",
|
||||
id: 'green-food',
|
||||
name: '绿色食品精深加工产业链',
|
||||
folderName: '绿色食品精深加工产业链',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "green-food-1",
|
||||
name: "蒙自经开区红河综保区面向东盟特色食品加工园项目",
|
||||
fileName: "1.蒙自经开区红河综保区面向东盟特色食品加工园项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/1.蒙自经开区红河综保区面向东盟特色食品加工园项目.pdf"
|
||||
id: 'green-food-1',
|
||||
name: '蒙自经开区红河综保区面向东盟特色食品加工园项目',
|
||||
fileName: '1.蒙自经开区红河综保区面向东盟特色食品加工园项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/1.蒙自经开区红河综保区面向东盟特色食品加工园项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "green-food-2",
|
||||
name: "红河州石屏县果蔬加工项目",
|
||||
fileName: "2.红河州石屏县果蔬加工项目.pdf",
|
||||
filePath: "/PDFfiles/绿色食品精深加工产业链/2.红河州石屏县果蔬加工项目.pdf"
|
||||
id: 'green-food-2',
|
||||
name: '红河州石屏县果蔬加工项目',
|
||||
fileName: '2.红河州石屏县果蔬加工项目.pdf',
|
||||
filePath:
|
||||
'/PDFfiles/绿色食品精深加工产业链/2.红河州石屏县果蔬加工项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "green-food-3",
|
||||
name: "红河州石屏县白萝卜精深加工项目",
|
||||
fileName: "3.红河州石屏县白萝卜精深加工项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/3.红河州石屏县白萝卜精深加工项目.pdf"
|
||||
id: 'green-food-3',
|
||||
name: '红河州石屏县白萝卜精深加工项目',
|
||||
fileName: '3.红河州石屏县白萝卜精深加工项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/3.红河州石屏县白萝卜精深加工项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "green-food-4",
|
||||
name: "红河州元阳县酸食品产业园区开发项目",
|
||||
fileName: "4.红河州元阳县酸食品产业园区开发项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/4.红河州元阳县酸食品产业园区开发项目.pdf"
|
||||
id: 'green-food-4',
|
||||
name: '红河州元阳县酸食品产业园区开发项目',
|
||||
fileName: '4.红河州元阳县酸食品产业园区开发项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/4.红河州元阳县酸食品产业园区开发项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "green-food-5",
|
||||
name: "红河州屏边县砂仁产业集群建设项目",
|
||||
fileName: "5.红河州屏边县砂仁产业集群建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/5.红河州屏边县砂仁产业集群建设项目.pdf"
|
||||
id: 'green-food-5',
|
||||
name: '红河州屏边县砂仁产业集群建设项目',
|
||||
fileName: '5.红河州屏边县砂仁产业集群建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/5.红河州屏边县砂仁产业集群建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "green-food-6",
|
||||
name: "红河州河口县东盟胡椒产业园项目",
|
||||
fileName: "6.红河州河口县东盟胡椒产业园项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/6.红河州河口县东盟胡椒产业园项目.pdf"
|
||||
id: 'green-food-6',
|
||||
name: '红河州河口县东盟胡椒产业园项目',
|
||||
fileName: '6.红河州河口县东盟胡椒产业园项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/6.红河州河口县东盟胡椒产业园项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "green-food-7",
|
||||
name: "红河州河口县边民互市进口商品落地加工产业园",
|
||||
fileName: "7.红河州河口县边民互市进口商品落地加工产业园.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/7.红河州河口县边民互市进口商品落地加工产业园.pdf"
|
||||
id: 'green-food-7',
|
||||
name: '红河州河口县边民互市进口商品落地加工产业园',
|
||||
fileName: '7.红河州河口县边民互市进口商品落地加工产业园.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/7.红河州河口县边民互市进口商品落地加工产业园.pdf',
|
||||
},
|
||||
{
|
||||
id: "green-food-8",
|
||||
name: "云南自贸试验区红河片区河口沿边产业园一东盟特色商品加工产业园(一期)",
|
||||
fileName: "8.云南自贸试验区红河片区河口沿边产业园一东盟特色商品加工产业园(一期).pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/8.云南自贸试验区红河片区河口沿边产业园一东盟特色商品加工产业园(一期).pdf"
|
||||
id: 'green-food-8',
|
||||
name: '云南自贸试验区红河片区河口沿边产业园一东盟特色商品加工产业园(一期)',
|
||||
fileName:
|
||||
'8.云南自贸试验区红河片区河口沿边产业园一东盟特色商品加工产业园(一期).pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/8.云南自贸试验区红河片区河口沿边产业园一东盟特色商品加工产业园(一期).pdf',
|
||||
},
|
||||
{
|
||||
id: "green-food-9",
|
||||
name: "红河州泸西县灯盏花标准化基地建设及精深加工项目",
|
||||
fileName: "9.红河州泸西县灯盏花标准化基地建设及精深加工项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/9.红河州泸西县灯盏花标准化基地建设及精深加工项目.pdf"
|
||||
}
|
||||
]
|
||||
id: 'green-food-9',
|
||||
name: '红河州泸西县灯盏花标准化基地建设及精深加工项目',
|
||||
fileName: '9.红河州泸西县灯盏花标准化基地建设及精深加工项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色食品精深加工产业链/9.红河州泸西县灯盏花标准化基地建设及精深加工项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "green-aluminum",
|
||||
name: "绿色铝精深加工产业链",
|
||||
folderName: "绿色铝精深加工产业链",
|
||||
id: 'green-aluminum',
|
||||
name: '绿色铝精深加工产业链',
|
||||
folderName: '绿色铝精深加工产业链',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "green-aluminum-1",
|
||||
name: "红河州个旧市高精度铝挤压生产线项目",
|
||||
fileName: "1.红河州个旧市高精度铝挤压生产线项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色铝精深加工产业链/1.红河州个旧市高精度铝挤压生产线项目.pdf"
|
||||
id: 'green-aluminum-1',
|
||||
name: '红河州个旧市高精度铝挤压生产线项目',
|
||||
fileName: '1.红河州个旧市高精度铝挤压生产线项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色铝精深加工产业链/1.红河州个旧市高精度铝挤压生产线项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "green-aluminum-2",
|
||||
name: "红河州泸西县高端铝精深加工制造项目",
|
||||
fileName: "2.红河州泸西县高端铝精深加工制造项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色铝精深加工产业链/2.红河州泸西县高端铝精深加工制造项目.pdf"
|
||||
}
|
||||
]
|
||||
id: 'green-aluminum-2',
|
||||
name: '红河州泸西县高端铝精深加工制造项目',
|
||||
fileName: '2.红河州泸西县高端铝精深加工制造项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色铝精深加工产业链/2.红河州泸西县高端铝精深加工制造项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "new-energy-storage",
|
||||
name: "新能源储能产业链",
|
||||
folderName: "新能源储能产业链",
|
||||
id: 'new-energy-storage',
|
||||
name: '新能源储能产业链',
|
||||
folderName: '新能源储能产业链',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "new-energy-storage-1",
|
||||
name: "云南自贸试验区红河片区高性能新能源电池生产制造项目",
|
||||
fileName: "1-云南自贸试验区红河片区高性能新能源电池生产制造项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/新能源储能产业链/1-云南自贸试验区红河片区高性能新能源电池生产制造项目.pdf"
|
||||
}
|
||||
]
|
||||
id: 'new-energy-storage-1',
|
||||
name: '云南自贸试验区红河片区高性能新能源电池生产制造项目',
|
||||
fileName: '1-云南自贸试验区红河片区高性能新能源电池生产制造项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/新能源储能产业链/1-云南自贸试验区红河片区高性能新能源电池生产制造项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tourism",
|
||||
name: "文旅产业链",
|
||||
folderName: "文旅产业链",
|
||||
id: 'tourism',
|
||||
name: '文旅产业链',
|
||||
folderName: '文旅产业链',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "tourism-1",
|
||||
name: "红河州蒙自市大屯海国际康养度假区项目",
|
||||
fileName: "1.红河州蒙自市大屯海国际康养度假区项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/1.红河州蒙自市大屯海国际康养度假区项目.pdf"
|
||||
id: 'tourism-1',
|
||||
name: '红河州蒙自市大屯海国际康养度假区项目',
|
||||
fileName: '1.红河州蒙自市大屯海国际康养度假区项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/1.红河州蒙自市大屯海国际康养度假区项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "tourism-2",
|
||||
name: "红河州弥勒市锦屏后海康旅项目",
|
||||
fileName: "2.红河州弥勒市锦屏后海康旅项目 - 副本.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/2.红河州弥勒市锦屏后海康旅项目 - 副本.pdf"
|
||||
id: 'tourism-2',
|
||||
name: '红河州弥勒市锦屏后海康旅项目',
|
||||
fileName: '2.红河州弥勒市锦屏后海康旅项目 - 副本.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/2.红河州弥勒市锦屏后海康旅项目 - 副本.pdf',
|
||||
},
|
||||
{
|
||||
id: "tourism-3",
|
||||
name: "红河州弥勒市康旅综合体项目",
|
||||
fileName: "3.红河州弥勒市康旅综合体项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/3.红河州弥勒市康旅综合体项目.pdf"
|
||||
id: 'tourism-3',
|
||||
name: '红河州弥勒市康旅综合体项目',
|
||||
fileName: '3.红河州弥勒市康旅综合体项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/3.红河州弥勒市康旅综合体项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "tourism-4",
|
||||
name: "红河州弥勒市湖泉健康生态圈项目",
|
||||
fileName: "4.红河州弥勒市湖泉健康生态圈项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/4.红河州弥勒市湖泉健康生态圈项目.pdf"
|
||||
id: 'tourism-4',
|
||||
name: '红河州弥勒市湖泉健康生态圈项目',
|
||||
fileName: '4.红河州弥勒市湖泉健康生态圈项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/4.红河州弥勒市湖泉健康生态圈项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "tourism-5",
|
||||
name: "红河州建水县古城南城门片区历史风貌保护恢复暨旅游开发项目",
|
||||
fileName: "5.红河州建水县古城南城门片区历史风貌保护恢复暨旅游开发项目.pdf",
|
||||
filePath: "/PDFfiles/文旅产业链/5.红河州建水县古城南城门片区历史风貌保护恢复暨旅游开发项目.pdf"
|
||||
id: 'tourism-5',
|
||||
name: '红河州建水县古城南城门片区历史风貌保护恢复暨旅游开发项目',
|
||||
fileName:
|
||||
'5.红河州建水县古城南城门片区历史风貌保护恢复暨旅游开发项目.pdf',
|
||||
filePath:
|
||||
'/PDFfiles/文旅产业链/5.红河州建水县古城南城门片区历史风貌保护恢复暨旅游开发项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "tourism-6",
|
||||
name: "红河州泸西县江西街古建筑群旅居综合体建设项目(一期)",
|
||||
fileName: "6.红河州泸西县江西街古建筑群旅居综合体建设项目(一期).pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/6.红河州泸西县江西街古建筑群旅居综合体建设项目(一期).pdf"
|
||||
id: 'tourism-6',
|
||||
name: '红河州泸西县江西街古建筑群旅居综合体建设项目(一期)',
|
||||
fileName: '6.红河州泸西县江西街古建筑群旅居综合体建设项目(一期).pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/6.红河州泸西县江西街古建筑群旅居综合体建设项目(一期).pdf',
|
||||
},
|
||||
{
|
||||
id: "tourism-7",
|
||||
name: "红河州屏边县城市旅游综合体验建设项目",
|
||||
fileName: "7.红河州屏边县城市旅游综合体验建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/7.红河州屏边县城市旅游综合体验建设项目.pdf"
|
||||
id: 'tourism-7',
|
||||
name: '红河州屏边县城市旅游综合体验建设项目',
|
||||
fileName: '7.红河州屏边县城市旅游综合体验建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/7.红河州屏边县城市旅游综合体验建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "tourism-8",
|
||||
name: "红河州河口县中越特色文化旅游水上康养城(龙沙谷)建设项目",
|
||||
fileName: "8.红河州河口县中越特色文化旅游水上康养城(龙沙谷)建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/8.红河州河口县中越特色文化旅游水上康养城(龙沙谷)建设项目.pdf"
|
||||
}
|
||||
]
|
||||
id: 'tourism-8',
|
||||
name: '红河州河口县中越特色文化旅游水上康养城(龙沙谷)建设项目',
|
||||
fileName:
|
||||
'8.红河州河口县中越特色文化旅游水上康养城(龙沙谷)建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/文旅产业链/8.红河州河口县中越特色文化旅游水上康养城(龙沙谷)建设项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "biomedicine",
|
||||
name: "生物医药产业链",
|
||||
folderName: "生物医药产业链",
|
||||
id: 'biomedicine',
|
||||
name: '生物医药产业链',
|
||||
folderName: '生物医药产业链',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "biomedicine-1",
|
||||
name: "红河州黄精产业建设项目",
|
||||
fileName: "1-红河州黄精产业建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/生物医药产业链/1-红河州黄精产业建设项目.pdf"
|
||||
id: 'biomedicine-1',
|
||||
name: '红河州黄精产业建设项目',
|
||||
fileName: '1-红河州黄精产业建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/生物医药产业链/1-红河州黄精产业建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "biomedicine-2",
|
||||
name: "红河州屏边县中医药保健饮品开发项目",
|
||||
fileName: "2-红河州屏边县中医药保健饮品开发项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/生物医药产业链/2-红河州屏边县中医药保健饮品开发项目.pdf"
|
||||
}
|
||||
]
|
||||
id: 'biomedicine-2',
|
||||
name: '红河州屏边县中医药保健饮品开发项目',
|
||||
fileName: '2-红河州屏边县中医药保健饮品开发项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/生物医药产业链/2-红河州屏边县中医药保健饮品开发项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "green-building-materials",
|
||||
name: "绿色新型建材产业链",
|
||||
folderName: "绿色新型建材产业链",
|
||||
id: 'green-building-materials',
|
||||
name: '绿色新型建材产业链',
|
||||
folderName: '绿色新型建材产业链',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "green-building-materials-1",
|
||||
name: "红河州屏边县大理石余料综合利用项目",
|
||||
fileName: "1-红河州屏边县大理石余料综合利用项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色新型建材产业链/1-红河州屏边县大理石余料综合利用项目.pdf"
|
||||
id: 'green-building-materials-1',
|
||||
name: '红河州屏边县大理石余料综合利用项目',
|
||||
fileName: '1-红河州屏边县大理石余料综合利用项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色新型建材产业链/1-红河州屏边县大理石余料综合利用项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "green-building-materials-2",
|
||||
name: "红河州屏边县天然大理石精深加工项目",
|
||||
fileName: "2-红河州屏边县天然大理石精深加工项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色新型建材产业链/2-红河州屏边县天然大理石精深加工项目.pdf"
|
||||
}
|
||||
]
|
||||
id: 'green-building-materials-2',
|
||||
name: '红河州屏边县天然大理石精深加工项目',
|
||||
fileName: '2-红河州屏边县天然大理石精深加工项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/绿色新型建材产业链/2-红河州屏边县天然大理石精深加工项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "electronic-manufacturing",
|
||||
name: "电子信息制造产业链",
|
||||
folderName: "电子信息制造产业链",
|
||||
id: 'electronic-manufacturing',
|
||||
name: '电子信息制造产业链',
|
||||
folderName: '电子信息制造产业链',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "electronic-manufacturing-1",
|
||||
name: "蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目",
|
||||
fileName: "1-蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/电子信息制造产业链/1-蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目.pdf"
|
||||
id: 'electronic-manufacturing-1',
|
||||
name: '蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目',
|
||||
fileName:
|
||||
'1-蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/电子信息制造产业链/1-蒙自经开区红河综保区年产100万平方米的印制电路板(PCB)生产建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "electronic-manufacturing-2",
|
||||
name: "蒙自经开区红河综保区新型显示生产基地建设项目",
|
||||
fileName: "2-蒙自经开区红河综保区新型显示生产基地建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/电子信息制造产业链/2-蒙自经开区红河综保区新型显示生产基地建设项目.pdf"
|
||||
}
|
||||
]
|
||||
id: 'electronic-manufacturing-2',
|
||||
name: '蒙自经开区红河综保区新型显示生产基地建设项目',
|
||||
fileName: '2-蒙自经开区红河综保区新型显示生产基地建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/电子信息制造产业链/2-蒙自经开区红河综保区新型显示生产基地建设项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "modern-logistics",
|
||||
name: "现代物流产业链",
|
||||
folderName: "现代物流产业链",
|
||||
id: 'modern-logistics',
|
||||
name: '现代物流产业链',
|
||||
folderName: '现代物流产业链',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "modern-logistics-1",
|
||||
name: "红河州蒙自市高原特色农产品仓储贸易产业园项目",
|
||||
fileName: "1.红河州蒙自市高原特色农产品仓储贸易产业园项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/1.红河州蒙自市高原特色农产品仓储贸易产业园项目.pdf"
|
||||
id: 'modern-logistics-1',
|
||||
name: '红河州蒙自市高原特色农产品仓储贸易产业园项目',
|
||||
fileName: '1.红河州蒙自市高原特色农产品仓储贸易产业园项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/1.红河州蒙自市高原特色农产品仓储贸易产业园项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "modern-logistics-2",
|
||||
name: "红河州开远市农产品集散中心(中央仓储、冷链物流、电商交易)建设项目",
|
||||
fileName: "2.红河州开远市农产品集散中心(中央仓储、冷链物流、电商交易)建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/2.红河州开远市农产品集散中心(中央仓储、冷链物流、电商交易)建设项目.pdf"
|
||||
id: 'modern-logistics-2',
|
||||
name: '红河州开远市农产品集散中心(中央仓储、冷链物流、电商交易)建设项目',
|
||||
fileName:
|
||||
'2.红河州开远市农产品集散中心(中央仓储、冷链物流、电商交易)建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/2.红河州开远市农产品集散中心(中央仓储、冷链物流、电商交易)建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "modern-logistics-3",
|
||||
name: "红河州金平县金水河口岸物流冷链投资项目",
|
||||
fileName: "3.红河州金平县金水河口岸物流冷链投资项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/3.红河州金平县金水河口岸物流冷链投资项目.pdf"
|
||||
id: 'modern-logistics-3',
|
||||
name: '红河州金平县金水河口岸物流冷链投资项目',
|
||||
fileName: '3.红河州金平县金水河口岸物流冷链投资项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/3.红河州金平县金水河口岸物流冷链投资项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "modern-logistics-4",
|
||||
name: "红河州绿春县现代物流产业园建设项目",
|
||||
fileName: "4.红河州绿春县现代物流产业园建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/4.红河州绿春县现代物流产业园建设项目.pdf"
|
||||
id: 'modern-logistics-4',
|
||||
name: '红河州绿春县现代物流产业园建设项目',
|
||||
fileName: '4.红河州绿春县现代物流产业园建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/4.红河州绿春县现代物流产业园建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "modern-logistics-5",
|
||||
name: "云南自贸试验区红河片区东南亚水果交易及冷链物流分拣中心建设项目",
|
||||
fileName: "5.云南自贸试验区红河片区东南亚水果交易及冷链物流分拣中心建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/5.云南自贸试验区红河片区东南亚水果交易及冷链物流分拣中心建设项目.pdf"
|
||||
id: 'modern-logistics-5',
|
||||
name: '云南自贸试验区红河片区东南亚水果交易及冷链物流分拣中心建设项目',
|
||||
fileName:
|
||||
'5.云南自贸试验区红河片区东南亚水果交易及冷链物流分拣中心建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/5.云南自贸试验区红河片区东南亚水果交易及冷链物流分拣中心建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "modern-logistics-6",
|
||||
name: "云南自贸试验区红河片区京东云仓物流供应链(河口)乡村振兴示范园项目",
|
||||
fileName: "6.云南自贸试验区红河片区京东云仓物流供应链(河口)乡村振兴示范园项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/6.云南自贸试验区红河片区京东云仓物流供应链(河口)乡村振兴示范园项目.pdf"
|
||||
}
|
||||
]
|
||||
id: 'modern-logistics-6',
|
||||
name: '云南自贸试验区红河片区京东云仓物流供应链(河口)乡村振兴示范园项目',
|
||||
fileName:
|
||||
'6.云南自贸试验区红河片区京东云仓物流供应链(河口)乡村振兴示范园项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/现代物流产业链/6.云南自贸试验区红河片区京东云仓物流供应链(河口)乡村振兴示范园项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "nonferrous-metal-materials",
|
||||
name: "有色金属新材料产业链",
|
||||
folderName: "有色金属新材料产业链",
|
||||
id: 'nonferrous-metal-materials',
|
||||
name: '有色金属新材料产业链',
|
||||
folderName: '有色金属新材料产业链',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "nonferrous-metal-materials-1",
|
||||
name: "蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目",
|
||||
fileName: "1.蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/有色金属新材料产业链/1.蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目.pdf"
|
||||
id: 'nonferrous-metal-materials-1',
|
||||
name: '蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目',
|
||||
fileName:
|
||||
'1.蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/有色金属新材料产业链/1.蒙自经开区红河综保区年产2万吨高性能锂电铜箔生产线建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "nonferrous-metal-materials-2",
|
||||
name: "蒙自经开区红河综保区年产2000吨高纯溅射靶材生产项目",
|
||||
fileName: "2.蒙自经开区红河综保区年产2000吨高纯溅射靶材生产项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/有色金属新材料产业链/2.蒙自经开区红河综保区年产2000吨高纯溅射靶材生产项目.pdf"
|
||||
id: 'nonferrous-metal-materials-2',
|
||||
name: '蒙自经开区红河综保区年产2000吨高纯溅射靶材生产项目',
|
||||
fileName: '2.蒙自经开区红河综保区年产2000吨高纯溅射靶材生产项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/有色金属新材料产业链/2.蒙自经开区红河综保区年产2000吨高纯溅射靶材生产项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "nonferrous-metal-materials-3",
|
||||
name: "红河州个旧市年产100吨纳米级ITO粉体、300万片TP-ITO导电玻璃生产线项目",
|
||||
fileName: "3.红河州个旧市年产100吨纳米级ITO粉体、300万片TP-ITO导电玻璃生产线项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/有色金属新材料产业链/3.红河州个旧市年产100吨纳米级ITO粉体、300万片TP-ITO导电玻璃生产线项目.pdf"
|
||||
}
|
||||
]
|
||||
id: 'nonferrous-metal-materials-3',
|
||||
name: '红河州个旧市年产100吨纳米级ITO粉体、300万片TP-ITO导电玻璃生产线项目',
|
||||
fileName:
|
||||
'3.红河州个旧市年产100吨纳米级ITO粉体、300万片TP-ITO导电玻璃生产线项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/有色金属新材料产业链/3.红河州个旧市年产100吨纳米级ITO粉体、300万片TP-ITO导电玻璃生产线项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
name: "其他产业链",
|
||||
folderName: "其他",
|
||||
id: 'other',
|
||||
name: '其他产业链',
|
||||
folderName: '其他',
|
||||
pdfFiles: [
|
||||
{
|
||||
id: "other-1",
|
||||
name: "蒙自经开区红河综保区年产300台高端注塑机产品生产项目",
|
||||
fileName: "1.蒙自经开区红河综保区年产300台高端注塑机产品生产项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/1.蒙自经开区红河综保区年产300台高端注塑机产品生产项目.pdf"
|
||||
id: 'other-1',
|
||||
name: '蒙自经开区红河综保区年产300台高端注塑机产品生产项目',
|
||||
fileName: '1.蒙自经开区红河综保区年产300台高端注塑机产品生产项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/1.蒙自经开区红河综保区年产300台高端注塑机产品生产项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "other-2",
|
||||
name: "红河州蒙自市包装包材建设项目",
|
||||
fileName: "2.红河州蒙自市包装包材建设项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/2.红河州蒙自市包装包材建设项目.pdf"
|
||||
id: 'other-2',
|
||||
name: '红河州蒙自市包装包材建设项目',
|
||||
fileName: '2.红河州蒙自市包装包材建设项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/2.红河州蒙自市包装包材建设项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "other-3",
|
||||
name: "红河州石屏县豆腐小镇运营项目",
|
||||
fileName: "3.红河州石屏县豆腐小镇运营项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/3.红河州石屏县豆腐小镇运营项目.pdf"
|
||||
id: 'other-3',
|
||||
name: '红河州石屏县豆腐小镇运营项目',
|
||||
fileName: '3.红河州石屏县豆腐小镇运营项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/3.红河州石屏县豆腐小镇运营项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "other-4",
|
||||
name: "红河州红河县棕榈产品研发及品牌推广中心项目",
|
||||
fileName: "4.红河州红河县棕榈产品研发及品牌推广中心项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/4.红河州红河县棕榈产品研发及品牌推广中心项目.pdf"
|
||||
id: 'other-4',
|
||||
name: '红河州红河县棕榈产品研发及品牌推广中心项目',
|
||||
fileName: '4.红河州红河县棕榈产品研发及品牌推广中心项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/4.红河州红河县棕榈产品研发及品牌推广中心项目.pdf',
|
||||
},
|
||||
{
|
||||
id: "other-5",
|
||||
name: "红河州高性能橡胶制品跨境精深加工基地项目",
|
||||
fileName: "5.红河州高性能橡胶制品跨境精深加工基地项目.pdf",
|
||||
filePath: "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/5.红河州高性能橡胶制品跨境精深加工基地项目.pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
id: 'other-5',
|
||||
name: '红河州高性能橡胶制品跨境精深加工基地项目',
|
||||
fileName: '5.红河州高性能橡胶制品跨境精深加工基地项目.pdf',
|
||||
filePath:
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/PDFfiles/其他/5.红河州高性能橡胶制品跨境精深加工基地项目.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export default function InvestmentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0 top-16 bg-white overflow-auto">
|
||||
<div className='absolute inset-0 top-16 overflow-auto bg-white'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,123 +1,125 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
InvestmentHero,
|
||||
KeyProjectsSection,
|
||||
IndustryChainList
|
||||
} from "./components";
|
||||
import { PageLoading } from "@/components/PageLoading";
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
InvestmentHero,
|
||||
KeyProjectsSection,
|
||||
IndustryChainList,
|
||||
} from './components'
|
||||
import { PageLoading } from '@/components/PageLoading'
|
||||
|
||||
// 缓存配置
|
||||
const CACHE_KEY = 'investment_page_cache';
|
||||
const CACHE_EXPIRY_TIME = 5 * 60 * 1000; // 5分钟缓存过期时间
|
||||
const CACHE_KEY = 'investment_page_cache'
|
||||
const CACHE_EXPIRY_TIME = 5 * 60 * 1000 // 5分钟缓存过期时间
|
||||
|
||||
interface CacheData {
|
||||
timestamp: number;
|
||||
loaded: boolean;
|
||||
timestamp: number
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
export default function InvestmentPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadingProgress, setLoadingProgress] = useState(0);
|
||||
const [shouldShowLoading, setShouldShowLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [shouldShowLoading, setShouldShowLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// 检查缓存
|
||||
const checkCache = () => {
|
||||
try {
|
||||
const cachedData = localStorage.getItem(CACHE_KEY);
|
||||
const cachedData = localStorage.getItem(CACHE_KEY)
|
||||
if (cachedData) {
|
||||
const cache: CacheData = JSON.parse(cachedData);
|
||||
const now = Date.now();
|
||||
|
||||
const cache: CacheData = JSON.parse(cachedData)
|
||||
const now = Date.now()
|
||||
|
||||
// 检查缓存是否过期
|
||||
if (now - cache.timestamp < CACHE_EXPIRY_TIME && cache.loaded) {
|
||||
// 缓存有效,直接显示页面
|
||||
setShouldShowLoading(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
setShouldShowLoading(false)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 缓存无效或不存在,显示加载动画
|
||||
setShouldShowLoading(true);
|
||||
startLoading();
|
||||
setShouldShowLoading(true)
|
||||
startLoading()
|
||||
} catch (error) {
|
||||
console.error('读取缓存失败:', error);
|
||||
setShouldShowLoading(true);
|
||||
startLoading();
|
||||
console.error('读取缓存失败:', error)
|
||||
setShouldShowLoading(true)
|
||||
startLoading()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const startLoading = () => {
|
||||
// 模拟页面加载进度
|
||||
const loadingTimer = setInterval(() => {
|
||||
setLoadingProgress(prev => {
|
||||
setLoadingProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(loadingTimer);
|
||||
|
||||
clearInterval(loadingTimer)
|
||||
|
||||
// 加载完成,更新缓存
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
saveCache();
|
||||
}, 300);
|
||||
return 100;
|
||||
setIsLoading(false)
|
||||
saveCache()
|
||||
}, 300)
|
||||
return 100
|
||||
}
|
||||
return prev + Math.random() * 30;
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
return prev + Math.random() * 30
|
||||
})
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const saveCache = () => {
|
||||
try {
|
||||
const cacheData: CacheData = {
|
||||
timestamp: Date.now(),
|
||||
loaded: true
|
||||
};
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
|
||||
loaded: true,
|
||||
}
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData))
|
||||
} catch (error) {
|
||||
console.error('保存缓存失败:', error);
|
||||
console.error('保存缓存失败:', error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
checkCache();
|
||||
}, []);
|
||||
checkCache()
|
||||
}, [])
|
||||
|
||||
// 如果不需要显示加载动画,直接返回页面内容
|
||||
if (!shouldShowLoading) {
|
||||
return (
|
||||
<div className="w-full bg-white">
|
||||
<div className='w-full bg-white'>
|
||||
<InvestmentHero />
|
||||
<KeyProjectsSection />
|
||||
<IndustryChainList />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// 显示加载动画
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full bg-white min-h-screen flex flex-col items-center justify-center">
|
||||
<div className="text-center space-y-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">正在加载页面...</h2>
|
||||
<div className="w-64 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-[#BD1A2D] h-2 rounded-full transition-all duration-300"
|
||||
<div className='flex min-h-screen w-full flex-col items-center justify-center bg-white'>
|
||||
<div className='space-y-6 text-center'>
|
||||
<h2 className='text-2xl font-bold text-gray-800'>正在加载页面...</h2>
|
||||
<div className='h-2 w-64 rounded-full bg-gray-200'>
|
||||
<div
|
||||
className='h-2 rounded-full bg-[#BD1A2D] transition-all duration-300'
|
||||
style={{ width: `${loadingProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">首次加载需要一些时间,后续访问将更快</p>
|
||||
<p className='text-sm text-gray-500'>
|
||||
首次加载需要一些时间,后续访问将更快
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white">
|
||||
<div className='w-full bg-white'>
|
||||
<InvestmentHero />
|
||||
<KeyProjectsSection />
|
||||
<IndustryChainList />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import "@/styles/globals.css";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import '@/styles/globals.css'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
|
||||
import { Providers } from "./providers";
|
||||
import { Providers } from './providers'
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { fontSans } from "@/config/fonts";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { siteConfig } from '@/config/site'
|
||||
import { fontSans } from '@/config/fonts'
|
||||
import { Navbar } from '@/components/navbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type Locale, routing } from '@/i18n/routing'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -16,29 +19,39 @@ export const metadata: Metadata = {
|
||||
},
|
||||
description: siteConfig.description,
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||
{ media: '(prefers-color-scheme: light)', color: 'white' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: 'black' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
type Params = { params: Promise<{ locale: string }> }
|
||||
export default async function RootLayout(props: PropsWithChildren<Params>) {
|
||||
const { children, params } = props
|
||||
const { locale } = await params
|
||||
|
||||
if (!routing.locales.includes(locale as Locale)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
setRequestLocale(locale as Locale)
|
||||
|
||||
export default async function RootLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<html suppressHydrationWarning lang="zh">
|
||||
<html suppressHydrationWarning lang='zh'>
|
||||
<head />
|
||||
<body className={cn("max-h-svh overflow-hidden", fontSans.variable)}>
|
||||
<body className={cn('max-h-svh overflow-hidden', fontSans.variable)}>
|
||||
<Providers>
|
||||
<div className="relative flex flex-col">
|
||||
<div className='relative flex flex-col'>
|
||||
<Navbar />
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import type { AgentState } from "@/lib/useAgentStore";
|
||||
import type { AgentState } from '@/lib/useAgentStore'
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@heroui/react";
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@heroui/button'
|
||||
import { MessagesSquare } from 'lucide-react'
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import useDeviceStore from "@/lib/useDeviceStore";
|
||||
import useAgentStore from "@/lib/useAgentStore";
|
||||
import { getAgent } from "@/lib/paths";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AgentPortrait } from "@/components/agent-portrait";
|
||||
import { siteConfig } from '@/config/site'
|
||||
import useDeviceStore from '@/lib/useDeviceStore'
|
||||
import useAgentStore from '@/lib/useAgentStore'
|
||||
import { getAgent } from '@/lib/paths'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentPortrait } from '@/components/agent-portrait'
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { agent, setAgent } = useAgentStore();
|
||||
const { wavStreamPlayer } = useDeviceStore();
|
||||
const router = useRouter()
|
||||
const { agent, setAgent } = useAgentStore()
|
||||
const { wavStreamPlayer } = useDeviceStore()
|
||||
|
||||
const goodFirstQuestion = "你好!";
|
||||
const goodFirstQuestion = '你好!'
|
||||
|
||||
const questionList = [
|
||||
"什么是红河州工商联?",
|
||||
"如何获取工商联最新通知?",
|
||||
"企业如何申报政策扶持?",
|
||||
"如何关注红河州工商联公众号?",
|
||||
"如何加入红河州工商联?",
|
||||
"红河州工商联的服务内容有哪些?",
|
||||
"介绍一下政策优势",
|
||||
"有哪些重点产业链?",
|
||||
];
|
||||
'什么是红河州工商联?',
|
||||
'如何获取工商联最新通知?',
|
||||
'企业如何申报政策扶持?',
|
||||
'如何关注红河州工商联公众号?',
|
||||
'如何加入红河州工商联?',
|
||||
'红河州工商联的服务内容有哪些?',
|
||||
'介绍一下政策优势',
|
||||
'有哪些重点产业链?',
|
||||
]
|
||||
|
||||
async function handleQuestionClick(question: string) {
|
||||
await wavStreamPlayer.connect();
|
||||
const params = new URLSearchParams({ question });
|
||||
const stringifyParams = params.toString();
|
||||
await wavStreamPlayer.connect()
|
||||
const params = new URLSearchParams({ question })
|
||||
const stringifyParams = params.toString()
|
||||
|
||||
router.push(siteConfig.routes.playground.href + "?" + stringifyParams);
|
||||
router.push(siteConfig.routes.playground.href + '?' + stringifyParams)
|
||||
}
|
||||
|
||||
const agentIdByEnv = Number(process.env.NEXT_PUBLIC_AGENT_ID);
|
||||
const isValidAgentId = !Number.isNaN(agentIdByEnv) && agentIdByEnv > 0;
|
||||
const agentIdByEnv = Number(process.env.NEXT_PUBLIC_AGENT_ID)
|
||||
const isValidAgentId = !Number.isNaN(agentIdByEnv) && agentIdByEnv > 0
|
||||
|
||||
async function queryAgent(agentId: number) {
|
||||
const resp = await getAgent({ agent_id: agentId.toString() });
|
||||
const agentData = resp.data as AgentState;
|
||||
const resp = await getAgent({ agent_id: agentId.toString() })
|
||||
const agentData = resp.data as AgentState
|
||||
|
||||
setAgent(agentData);
|
||||
setAgent(agentData)
|
||||
}
|
||||
|
||||
function resetAgent() {
|
||||
setAgent(undefined);
|
||||
setAgent(undefined)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidAgentId) {
|
||||
queryAgent(agentIdByEnv);
|
||||
queryAgent(agentIdByEnv)
|
||||
} else {
|
||||
resetAgent();
|
||||
resetAgent()
|
||||
}
|
||||
}, [isValidAgentId]);
|
||||
}, [isValidAgentId])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-[calc(100dvh-64px)] w-full",
|
||||
"bg-[url(/background-opacity.png)] bg-center bg-cover",
|
||||
"flex flex-col justify-center items-center gap-4"
|
||||
'h-[calc(100dvh-64px)] w-full',
|
||||
'bg-[url(/background-opacity.png)] bg-cover bg-center',
|
||||
'flex flex-col items-center justify-center gap-4'
|
||||
)}
|
||||
>
|
||||
<AgentPortrait />
|
||||
<p className={cn("max-w-md", "font-medium text-lg text-center")}>
|
||||
<p className={cn('max-w-md', 'text-center text-lg font-medium')}>
|
||||
你好!我是红河州工商联小红,欢迎咨询红河州工商联,请问有什么可以帮到你吗?
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-4xl flex justify-center flex-wrap gap-2">
|
||||
<div className='flex w-full max-w-4xl flex-wrap justify-center gap-2'>
|
||||
{questionList.map((item, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
className="bg-white font-semibold border-0"
|
||||
color="primary"
|
||||
className='border-0 bg-white font-semibold'
|
||||
color='primary'
|
||||
isDisabled={!agent}
|
||||
radius="full"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='bordered'
|
||||
onPress={() => handleQuestionClick(item)}
|
||||
>
|
||||
{item}
|
||||
@@ -93,17 +93,17 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-80 text-white text-lg font-semibold"
|
||||
color="primary"
|
||||
className='w-80 text-lg font-semibold text-white'
|
||||
color='primary'
|
||||
isDisabled={!agent}
|
||||
radius="full"
|
||||
size="lg"
|
||||
radius='full'
|
||||
size='lg'
|
||||
startContent={<MessagesSquare />}
|
||||
variant="shadow"
|
||||
variant='shadow'
|
||||
onPress={() => handleQuestionClick(goodFirstQuestion)}
|
||||
>
|
||||
开始对话
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface DesktopAIVideoPlayerProps {
|
||||
isAudioPlaying: boolean;
|
||||
onVideoClick: () => void;
|
||||
isAudioPlaying: boolean
|
||||
onVideoClick: () => void
|
||||
}
|
||||
|
||||
function DesktopAIVideoPlayer({
|
||||
@@ -13,87 +13,98 @@ function DesktopAIVideoPlayer({
|
||||
}: DesktopAIVideoPlayerProps) {
|
||||
// 可用的视频文件数组
|
||||
const videoOptions = [
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx1.mp4",
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx3.mp4",
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx4.mp4",
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx5.mp4"
|
||||
];
|
||||
|
||||
const [videoSrc, setVideoSrc] = useState("https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/jz.mp4");
|
||||
const [isClicked, setIsClicked] = useState(false);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx1.mp4',
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx3.mp4',
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx4.mp4',
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx5.mp4',
|
||||
]
|
||||
|
||||
const [videoSrc, setVideoSrc] = useState(
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/jz.mp4'
|
||||
)
|
||||
const [isClicked, setIsClicked] = useState(false)
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
// 随机选择视频文件的函数
|
||||
const getRandomVideo = () => {
|
||||
const randomIndex = Math.floor(Math.random() * videoOptions.length);
|
||||
return videoOptions[randomIndex];
|
||||
};
|
||||
const randomIndex = Math.floor(Math.random() * videoOptions.length)
|
||||
return videoOptions[randomIndex]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log("DesktopAIVideoPlayer - isAudioPlaying:", isAudioPlaying, "isClicked:", isClicked);
|
||||
|
||||
// 切换视频时重置加载状态
|
||||
setIsVideoLoaded(false);
|
||||
|
||||
if (isClicked) {
|
||||
console.log("设置视频源为: hd.mp4");
|
||||
setVideoSrc("https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/hd.mp4");
|
||||
const timer = setTimeout(() => {
|
||||
setIsClicked(false);
|
||||
}, 5000);
|
||||
console.log(
|
||||
'DesktopAIVideoPlayer - isAudioPlaying:',
|
||||
isAudioPlaying,
|
||||
'isClicked:',
|
||||
isClicked
|
||||
)
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// 切换视频时重置加载状态
|
||||
setIsVideoLoaded(false)
|
||||
|
||||
if (isClicked) {
|
||||
console.log('设置视频源为: hd.mp4')
|
||||
setVideoSrc(
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/hd.mp4'
|
||||
)
|
||||
const timer = setTimeout(() => {
|
||||
setIsClicked(false)
|
||||
}, 5000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isAudioPlaying) {
|
||||
// 播放音频时随机选择视频
|
||||
const randomVideo = getRandomVideo();
|
||||
console.log("设置随机视频源为:", randomVideo);
|
||||
setVideoSrc(randomVideo);
|
||||
const randomVideo = getRandomVideo()
|
||||
console.log('设置随机视频源为:', randomVideo)
|
||||
setVideoSrc(randomVideo)
|
||||
} else {
|
||||
console.log("设置视频源为: jz.mp4");
|
||||
setVideoSrc("https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/jz.mp4");
|
||||
console.log('设置视频源为: jz.mp4')
|
||||
setVideoSrc(
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/jz.mp4'
|
||||
)
|
||||
}
|
||||
}, [isAudioPlaying, isClicked]);
|
||||
}, [isAudioPlaying, isClicked])
|
||||
|
||||
const handleVideoClick = () => {
|
||||
setIsClicked(true);
|
||||
onVideoClick();
|
||||
};
|
||||
setIsClicked(true)
|
||||
onVideoClick()
|
||||
}
|
||||
|
||||
// 桌面端兜底:确保加载后播放、结束后重播(与移动端一致,保证逻辑统一)
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const el = videoRef.current
|
||||
if (!el) return
|
||||
|
||||
const tryPlay = () => {
|
||||
el.play().catch(() => {});
|
||||
};
|
||||
|
||||
el.play().catch(() => {})
|
||||
}
|
||||
|
||||
const handleLoadedData = () => {
|
||||
setIsVideoLoaded(true);
|
||||
tryPlay();
|
||||
};
|
||||
|
||||
const handleCanPlay = () => tryPlay();
|
||||
setIsVideoLoaded(true)
|
||||
tryPlay()
|
||||
}
|
||||
|
||||
const handleCanPlay = () => tryPlay()
|
||||
const handleEnded = () => {
|
||||
el.currentTime = 0;
|
||||
tryPlay();
|
||||
};
|
||||
|
||||
el.addEventListener("loadeddata", handleLoadedData);
|
||||
el.addEventListener("canplay", handleCanPlay);
|
||||
el.addEventListener("ended", handleEnded);
|
||||
|
||||
el.currentTime = 0
|
||||
tryPlay()
|
||||
}
|
||||
|
||||
el.addEventListener('loadeddata', handleLoadedData)
|
||||
el.addEventListener('canplay', handleCanPlay)
|
||||
el.addEventListener('ended', handleEnded)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("loadeddata", handleLoadedData);
|
||||
el.removeEventListener("canplay", handleCanPlay);
|
||||
el.removeEventListener("ended", handleEnded);
|
||||
};
|
||||
}, [videoSrc]);
|
||||
el.removeEventListener('loadeddata', handleLoadedData)
|
||||
el.removeEventListener('canplay', handleCanPlay)
|
||||
el.removeEventListener('ended', handleEnded)
|
||||
}
|
||||
}, [videoSrc])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[160px] h-[160px] rounded-2xl overflow-hidden shadow-lg bg-white flex items-center justify-center border-2 border-[#BD1A2D] relative z-10 cursor-pointer hover:border-[#A0162A] transition-colors"
|
||||
className='relative z-10 flex h-[160px] w-[160px] cursor-pointer items-center justify-center overflow-hidden rounded-2xl border-2 border-[#BD1A2D] bg-white shadow-lg transition-colors hover:border-[#A0162A]'
|
||||
onClick={handleVideoClick}
|
||||
style={{
|
||||
backgroundImage: 'url("/xiaohong.jpg")',
|
||||
@@ -104,16 +115,16 @@ function DesktopAIVideoPlayer({
|
||||
<video
|
||||
key={videoSrc}
|
||||
autoPlay
|
||||
preload="auto"
|
||||
preload='auto'
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${isVideoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${isVideoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
src={videoSrc}
|
||||
ref={videoRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopAIVideoPlayer;
|
||||
export default DesktopAIVideoPlayer
|
||||
|
||||
@@ -1,66 +1,68 @@
|
||||
import Image from "next/image";
|
||||
import { Avatar, Button, Textarea } from "@heroui/react";
|
||||
import { Copy } from "lucide-react";
|
||||
import Image from 'next/image'
|
||||
import { Copy } from 'lucide-react'
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DifyMessage } from "@/lib/useDifyCmd";
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DifyMessage } from '@/lib/useDifyCmd'
|
||||
import { Avatar } from '@heroui/avatar'
|
||||
import { Textarea } from '@heroui/input'
|
||||
import { Button } from '@heroui/button'
|
||||
|
||||
type Props = {
|
||||
msg: DifyMessage;
|
||||
};
|
||||
msg: DifyMessage
|
||||
}
|
||||
|
||||
function DifyBubble(props: Props) {
|
||||
const { msg } = props;
|
||||
const { msg } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col mt-8 transition-opacity duration-300",
|
||||
"message-item"
|
||||
'mt-8 flex flex-col transition-opacity duration-300',
|
||||
'message-item'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
"flex",
|
||||
msg.role === "assistant" && "flex-row",
|
||||
msg.role === "user" && "flex-row-reverse",
|
||||
"justify-start items-start gap-[8px]"
|
||||
'w-full',
|
||||
'flex',
|
||||
msg.role === 'assistant' && 'flex-row',
|
||||
msg.role === 'user' && 'flex-row-reverse',
|
||||
'items-start justify-start gap-[8px]'
|
||||
)}
|
||||
>
|
||||
{msg.role === "assistant" && (
|
||||
{msg.role === 'assistant' && (
|
||||
<Image
|
||||
alt="avatar"
|
||||
className="mt-[3px]"
|
||||
src="/xh.png"
|
||||
alt='avatar'
|
||||
className='mt-[3px]'
|
||||
src='/xh.png'
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
)}
|
||||
|
||||
{msg.role === "user" && (
|
||||
<div className="mt-[4px]">
|
||||
{msg.role === 'user' && (
|
||||
<div className='mt-[4px]'>
|
||||
<Avatar
|
||||
className="text-white text-md"
|
||||
color="primary"
|
||||
name="我"
|
||||
size="sm"
|
||||
className='text-md text-white'
|
||||
color='primary'
|
||||
name='我'
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex flex-col gap-1",
|
||||
msg.role === "assistant" && "pr-[calc(8px+32px)]",
|
||||
msg.role === "user" && "pl-[calc(8px+32px)]"
|
||||
'flex w-full flex-col gap-1',
|
||||
msg.role === 'assistant' && 'pr-[calc(8px+32px)]',
|
||||
msg.role === 'user' && 'pl-[calc(8px+32px)]'
|
||||
)}
|
||||
>
|
||||
{msg.role === "user" && (
|
||||
{msg.role === 'user' && (
|
||||
<Textarea
|
||||
isReadOnly
|
||||
classNames={{
|
||||
base: cn("w-full", "md:w-2/3", "self-end"),
|
||||
base: cn('w-full', 'md:w-2/3', 'self-end'),
|
||||
}}
|
||||
fullWidth={false}
|
||||
maxRows={100}
|
||||
@@ -68,11 +70,11 @@ function DifyBubble(props: Props) {
|
||||
value={msg.question}
|
||||
/>
|
||||
)}
|
||||
{msg.role === "assistant" && (
|
||||
{msg.role === 'assistant' && (
|
||||
<Textarea
|
||||
isReadOnly
|
||||
classNames={{
|
||||
base: cn("w-full", "md:w-2/3", "self-start"),
|
||||
base: cn('w-full', 'md:w-2/3', 'self-start'),
|
||||
}}
|
||||
fullWidth={false}
|
||||
maxRows={100}
|
||||
@@ -82,18 +84,18 @@ function DifyBubble(props: Props) {
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
msg.role === "assistant" && "self-start",
|
||||
msg.role === "user" && "self-end"
|
||||
msg.role === 'assistant' && 'self-start',
|
||||
msg.role === 'user' && 'self-end'
|
||||
)}
|
||||
>
|
||||
<Button isIconOnly size="sm" variant="light">
|
||||
<Button isIconOnly size='sm' variant='light'>
|
||||
<Copy size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default DifyBubble;
|
||||
export default DifyBubble
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface MobileAIVideoPlayerProps {
|
||||
isAudioPlaying: boolean;
|
||||
onVideoClick: () => void;
|
||||
isAudioPlaying: boolean
|
||||
onVideoClick: () => void
|
||||
}
|
||||
|
||||
function MobileAIVideoPlayer({
|
||||
@@ -11,142 +11,142 @@ function MobileAIVideoPlayer({
|
||||
}: MobileAIVideoPlayerProps) {
|
||||
// 可用的视频文件数组
|
||||
const videoOptions = [
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx1.mp4",
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx3.mp4",
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx4.mp4",
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx5.mp4",
|
||||
];
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx1.mp4',
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx3.mp4',
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx4.mp4',
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/kx5.mp4',
|
||||
]
|
||||
|
||||
const [videoSrc, setVideoSrc] = useState(
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/jz.mp4"
|
||||
);
|
||||
const [isClicked, setIsClicked] = useState(false);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/jz.mp4'
|
||||
)
|
||||
const [isClicked, setIsClicked] = useState(false)
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
// 随机选择视频文件的函数
|
||||
const getRandomVideo = () => {
|
||||
const randomIndex = Math.floor(Math.random() * videoOptions.length);
|
||||
return videoOptions[randomIndex];
|
||||
};
|
||||
const randomIndex = Math.floor(Math.random() * videoOptions.length)
|
||||
return videoOptions[randomIndex]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
"MobileAIVideoPlayer - isAudioPlaying:",
|
||||
'MobileAIVideoPlayer - isAudioPlaying:',
|
||||
isAudioPlaying,
|
||||
"isClicked:",
|
||||
'isClicked:',
|
||||
isClicked
|
||||
);
|
||||
)
|
||||
|
||||
// 切换视频时重置加载状态
|
||||
setIsVideoLoaded(false);
|
||||
setIsVideoLoaded(false)
|
||||
|
||||
if (isClicked) {
|
||||
console.log("设置视频源为: hd.mp4");
|
||||
console.log('设置视频源为: hd.mp4')
|
||||
setVideoSrc(
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/hd.mp4"
|
||||
);
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/hd.mp4'
|
||||
)
|
||||
const timer = setTimeout(() => {
|
||||
setIsClicked(false);
|
||||
}, 5000);
|
||||
setIsClicked(false)
|
||||
}, 5000)
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isAudioPlaying) {
|
||||
// 播放音频时随机选择视频
|
||||
const randomVideo = getRandomVideo();
|
||||
console.log("设置随机视频源为:", randomVideo);
|
||||
setVideoSrc(randomVideo);
|
||||
const randomVideo = getRandomVideo()
|
||||
console.log('设置随机视频源为:', randomVideo)
|
||||
setVideoSrc(randomVideo)
|
||||
} else {
|
||||
console.log("设置视频源为: jz.mp4");
|
||||
console.log('设置视频源为: jz.mp4')
|
||||
setVideoSrc(
|
||||
"https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/jz.mp4"
|
||||
);
|
||||
'https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/uploads/honghe-acfic-chat/public/video/jz.mp4'
|
||||
)
|
||||
}
|
||||
}, [isAudioPlaying, isClicked]);
|
||||
}, [isAudioPlaying, isClicked])
|
||||
|
||||
// 设置移动端内联播放兼容性相关自定义属性,避免在 JSX 中直接写非常规属性触发 ESLint
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const el = videoRef.current
|
||||
if (!el) return
|
||||
|
||||
el.setAttribute("playsinline", "true");
|
||||
el.setAttribute("webkit-playsinline", "true");
|
||||
el.setAttribute('playsinline', 'true')
|
||||
el.setAttribute('webkit-playsinline', 'true')
|
||||
// 腾讯 X5 内核相关属性
|
||||
el.setAttribute("x5-playsinline", "true");
|
||||
el.setAttribute("x5-video-player-type", "h5");
|
||||
el.setAttribute("x5-video-player-fullscreen", "false");
|
||||
}, [videoSrc]);
|
||||
el.setAttribute('x5-playsinline', 'true')
|
||||
el.setAttribute('x5-video-player-type', 'h5')
|
||||
el.setAttribute('x5-video-player-fullscreen', 'false')
|
||||
}, [videoSrc])
|
||||
|
||||
const handleVideoClick = () => {
|
||||
setIsClicked(true);
|
||||
onVideoClick();
|
||||
};
|
||||
setIsClicked(true)
|
||||
onVideoClick()
|
||||
}
|
||||
|
||||
// 兜底:在部分安卓浏览器上,确保加载后能播放、结束后能自动重播
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const el = videoRef.current
|
||||
if (!el) return
|
||||
|
||||
const tryPlay = () => {
|
||||
el.play().catch(() => {});
|
||||
};
|
||||
el.play().catch(() => {})
|
||||
}
|
||||
|
||||
const handleLoadedData = () => {
|
||||
setIsVideoLoaded(true);
|
||||
tryPlay();
|
||||
};
|
||||
setIsVideoLoaded(true)
|
||||
tryPlay()
|
||||
}
|
||||
|
||||
const handleCanPlay = () => tryPlay();
|
||||
const handleCanPlay = () => tryPlay()
|
||||
const handleEnded = () => {
|
||||
el.currentTime = 0;
|
||||
tryPlay();
|
||||
};
|
||||
el.currentTime = 0
|
||||
tryPlay()
|
||||
}
|
||||
|
||||
el.addEventListener("loadeddata", handleLoadedData);
|
||||
el.addEventListener("canplay", handleCanPlay);
|
||||
el.addEventListener("ended", handleEnded);
|
||||
el.addEventListener('loadeddata', handleLoadedData)
|
||||
el.addEventListener('canplay', handleCanPlay)
|
||||
el.addEventListener('ended', handleEnded)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("loadeddata", handleLoadedData);
|
||||
el.removeEventListener("canplay", handleCanPlay);
|
||||
el.removeEventListener("ended", handleEnded);
|
||||
};
|
||||
}, [videoSrc]);
|
||||
el.removeEventListener('loadeddata', handleLoadedData)
|
||||
el.removeEventListener('canplay', handleCanPlay)
|
||||
el.removeEventListener('ended', handleEnded)
|
||||
}
|
||||
}, [videoSrc])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[180px] h-[180px] mx-auto rounded-2xl overflow-hidden shadow-lg bg-white flex items-center justify-center border-2 border-[#BD1A2D] relative cursor-pointer hover:border-[#A0162A] transition-colors"
|
||||
className='relative mx-auto flex h-[180px] w-[180px] cursor-pointer items-center justify-center overflow-hidden rounded-2xl border-2 border-[#BD1A2D] bg-white shadow-lg transition-colors hover:border-[#A0162A]'
|
||||
onClick={handleVideoClick}
|
||||
style={{
|
||||
backgroundImage: 'url("/xiaohong.jpg")',
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
autoPlay
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${isVideoLoaded ? "opacity-100" : "opacity-0"}`}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${isVideoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
controls={false}
|
||||
controlsList="nodownload noplaybackrate noremoteplayback nofullscreen"
|
||||
controlsList='nodownload noplaybackrate noremoteplayback nofullscreen'
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
key={videoSrc}
|
||||
preload="auto"
|
||||
preload='auto'
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
touchAction: "none", // 阻止触摸相关事件
|
||||
overflow: "hidden", // 防止控件溢出显示
|
||||
pointerEvents: 'none',
|
||||
touchAction: 'none', // 阻止触摸相关事件
|
||||
overflow: 'hidden', // 防止控件溢出显示
|
||||
}}
|
||||
// 额外添加的属性
|
||||
onContextMenu={(e) => e.preventDefault()} // 阻止右键菜单
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileAIVideoPlayer;
|
||||
export default MobileAIVideoPlayer
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import { SessionSidebar } from "../session-sidebar";
|
||||
import { SessionContent } from "../session-content";
|
||||
import { VoiceActionGroup } from "../voice-action-group";
|
||||
import { SessionSidebar } from '../session-sidebar'
|
||||
import { SessionContent } from '../session-content'
|
||||
import { VoiceActionGroup } from '../voice-action-group'
|
||||
|
||||
import DesktopAIVideoPlayer from "./DesktopAIVideoPlayer";
|
||||
import RealtimeBubble from "./RealtimeBubble";
|
||||
import DesktopAIVideoPlayer from './DesktopAIVideoPlayer'
|
||||
import RealtimeBubble from './RealtimeBubble'
|
||||
|
||||
import useDeviceStore from "@/lib/useDeviceStore";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useDeviceStore from '@/lib/useDeviceStore'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
// import { TerraceBg } from "@/components/terrace-bg";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
|
||||
export function PlaygroundDesktopLayout() {
|
||||
// const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
||||
const [isAudioPlaying, setIsAudioPlaying] = useState(false)
|
||||
|
||||
const { wavStreamPlayer } = useDeviceStore();
|
||||
const { currentSessionId, sessionList } = useConversationStore();
|
||||
const { wavStreamPlayer } = useDeviceStore()
|
||||
const { currentSessionId, sessionList } = useConversationStore()
|
||||
|
||||
return (
|
||||
// <section className="w-full">
|
||||
@@ -57,9 +57,9 @@ export function PlaygroundDesktopLayout() {
|
||||
// </SidebarProvider>
|
||||
// </section>
|
||||
|
||||
<section className="w-full h-full overflow-y-auto">
|
||||
<section className='h-full w-full overflow-y-auto'>
|
||||
{/* <TerraceBg /> */}
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className='flex w-full flex-col items-center'>
|
||||
{/* <div className="absolute top-4 left-14 z-10">
|
||||
<DesktopAIVideoPlayer
|
||||
isAudioPlaying={isAudioPlaying}
|
||||
@@ -67,23 +67,23 @@ export function PlaygroundDesktopLayout() {
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
<div className={cn("w-full h-full", "flex flex-col items-center pt-8")}>
|
||||
<div className="w-full max-w-4xl px-2 pb-24 self-center">
|
||||
<div className={cn('h-full w-full', 'flex flex-col items-center pt-8')}>
|
||||
<div className='w-full max-w-4xl self-center px-2 pb-24'>
|
||||
{sessionList
|
||||
.filter((session) => session.id === currentSessionId)
|
||||
.at(0)
|
||||
?.message.map((item) => {
|
||||
if (item.msgType === "realtime") {
|
||||
return <RealtimeBubble key={item.itemId} msg={item} />;
|
||||
if (item.msgType === 'realtime') {
|
||||
return <RealtimeBubble key={item.itemId} msg={item} />
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-4xl px-8 absolute bottom-2">
|
||||
<div className='absolute bottom-2 w-full max-w-4xl px-8'>
|
||||
<VoiceActionGroup />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PhoneOff, Volume2, VolumeX } from "lucide-react";
|
||||
import { ReadyState } from "react-use-websocket";
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PhoneOff, Volume2, VolumeX } from 'lucide-react'
|
||||
import { ReadyState } from 'react-use-websocket'
|
||||
|
||||
import useConversationAction from "../useConversationAction";
|
||||
import useConversationAction from '../useConversationAction'
|
||||
|
||||
import { HistoryIcon } from "@/components/icons";
|
||||
import useDeviceStore from "@/lib/useDeviceStore";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import { HistoryIcon } from '@/components/icons'
|
||||
import useDeviceStore from '@/lib/useDeviceStore'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
|
||||
interface PlaygroundMobileHeaderProps {
|
||||
toggleSidebar: () => void;
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
export function PlaygroundMobileHeader({
|
||||
toggleSidebar,
|
||||
}: PlaygroundMobileHeaderProps) {
|
||||
const router = useRouter();
|
||||
const { wavRecorder, wavStreamPlayer } = useDeviceStore();
|
||||
const { wsInstance } = useConversationStore();
|
||||
const { hangupConversation } = useConversationAction();
|
||||
const router = useRouter()
|
||||
const { wavRecorder, wavStreamPlayer } = useDeviceStore()
|
||||
const { wsInstance } = useConversationStore()
|
||||
const { hangupConversation } = useConversationAction()
|
||||
|
||||
async function endConversation() {
|
||||
hangupConversation();
|
||||
hangupConversation()
|
||||
|
||||
const resp = wavRecorder.getStatus();
|
||||
const resp = wavRecorder.getStatus()
|
||||
|
||||
if (resp !== "ended") {
|
||||
await wavRecorder.end();
|
||||
if (resp !== 'ended') {
|
||||
await wavRecorder.end()
|
||||
}
|
||||
wavStreamPlayer.interrupt();
|
||||
router.replace("/");
|
||||
wavStreamPlayer.interrupt()
|
||||
router.replace('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed top-0 left-0 w-full h-16 bg-[#BD1A2D] flex items-center px-4 z-[60]">
|
||||
<div className='fixed top-0 left-0 z-[60] flex h-16 w-full items-center bg-[#BD1A2D] px-4 md:hidden'>
|
||||
<button
|
||||
aria-label="历史对话"
|
||||
className="mr-2 focus:outline-none text-white"
|
||||
aria-label='历史对话'
|
||||
className='mr-2 text-white focus:outline-none'
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<HistoryIcon size={24} />
|
||||
</button>
|
||||
<span className="text-white font-bold text-lg flex-1 text-center">
|
||||
<span className='flex-1 text-center text-lg font-bold text-white'>
|
||||
AI小红
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* <button
|
||||
aria-label="静音/取消静音"
|
||||
className="focus:outline-none text-white disabled:opacity-50 rounded-full p-1"
|
||||
@@ -64,5 +64,5 @@ export function PlaygroundMobileHeader({
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,56 +1,62 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { VoiceActionGroup } from "../voice-action-group";
|
||||
import { VoiceActionGroup } from '../voice-action-group'
|
||||
|
||||
import { PlaygroundMobileHeader } from "./PlaygroundMobileHeader";
|
||||
import { PlaygroundMobileSidebar } from "./PlaygroundMobileSidebar";
|
||||
import MobileAIVideoPlayer from "./MobileAIVideoPlayer";
|
||||
import RealtimeBubble from "./RealtimeBubble";
|
||||
import { PlaygroundMobileHeader } from './PlaygroundMobileHeader'
|
||||
import { PlaygroundMobileSidebar } from './PlaygroundMobileSidebar'
|
||||
import MobileAIVideoPlayer from './MobileAIVideoPlayer'
|
||||
import RealtimeBubble from './RealtimeBubble'
|
||||
|
||||
|
||||
import useDeviceStore from "@/lib/useDeviceStore";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import useDeviceStore from '@/lib/useDeviceStore'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
|
||||
export function PlaygroundMobileLayout() {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||
const lastAudioTimeRef = useRef<number>(0);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isAudioPlaying, setIsAudioPlaying] = useState(false)
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false)
|
||||
const lastAudioTimeRef = useRef<number>(0)
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false)
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
|
||||
const { wavStreamPlayer } = useDeviceStore();
|
||||
const { currentSessionId, sessionList } = useConversationStore();
|
||||
const { wavStreamPlayer } = useDeviceStore()
|
||||
const { currentSessionId, sessionList } = useConversationStore()
|
||||
|
||||
// 检测音频是否在播放 - 基于音频播放器的实际播放状态
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
let interval: NodeJS.Timeout
|
||||
|
||||
if (wavStreamPlayer) {
|
||||
interval = setInterval(async () => {
|
||||
try {
|
||||
// 尝试获取音频播放器的状态 - 处理Promise
|
||||
const trackOffset = await wavStreamPlayer.getTrackSampleOffset();
|
||||
|
||||
const trackOffset = await wavStreamPlayer.getTrackSampleOffset()
|
||||
|
||||
// console.log("音频播放器状态检测:", {
|
||||
// trackOffset: trackOffset,
|
||||
// trackOffsetOffset: trackOffset?.offset,
|
||||
// isAudioPlaying,
|
||||
// timeSinceLastAudio: Date.now() - lastAudioTimeRef.current
|
||||
// });
|
||||
|
||||
|
||||
// 检查是否有音频在播放
|
||||
if (trackOffset && typeof trackOffset.offset === 'number' && trackOffset.offset > 0) {
|
||||
if (
|
||||
trackOffset &&
|
||||
typeof trackOffset.offset === 'number' &&
|
||||
trackOffset.offset > 0
|
||||
) {
|
||||
if (!isAudioPlaying) {
|
||||
// console.log("检测到音频播放器正在播放 - 切换到kx.mp4");
|
||||
setIsAudioPlaying(true);
|
||||
setIsAudioPlaying(true)
|
||||
}
|
||||
lastAudioTimeRef.current = Date.now();
|
||||
lastAudioTimeRef.current = Date.now()
|
||||
} else {
|
||||
// 如果trackOffset无效或为0,检查是否已经超过一定时间没有音频
|
||||
if (isAudioPlaying && Date.now() - lastAudioTimeRef.current > 1500) {
|
||||
if (
|
||||
isAudioPlaying &&
|
||||
Date.now() - lastAudioTimeRef.current > 1500
|
||||
) {
|
||||
// console.log("音频播放器停止播放 - 切换到jz.mp4");
|
||||
setIsAudioPlaying(false);
|
||||
setIsAudioPlaying(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -58,78 +64,80 @@ export function PlaygroundMobileLayout() {
|
||||
// 发生错误时,使用时间延迟来停止视频
|
||||
if (isAudioPlaying && Date.now() - lastAudioTimeRef.current > 2000) {
|
||||
// console.log("音频播放器检测出错,停止视频 - 切换到jz.mp4");
|
||||
setIsAudioPlaying(false);
|
||||
setIsAudioPlaying(false)
|
||||
}
|
||||
}
|
||||
}, 200); // 每200ms检测一次
|
||||
}, 200) // 每200ms检测一次
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [wavStreamPlayer, isAudioPlaying]);
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [wavStreamPlayer, isAudioPlaying])
|
||||
|
||||
// 侧边栏控制
|
||||
const toggleSidebar = () => setSidebarVisible(!sidebarVisible);
|
||||
const toggleSidebar = () => setSidebarVisible(!sidebarVisible)
|
||||
|
||||
// 自动滚动到底部的函数
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollContainerRef.current && !isUserScrolling) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}, [isUserScrolling]);
|
||||
}, [isUserScrolling])
|
||||
|
||||
// 检测用户手动滚动
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
setIsUserScrolling(true);
|
||||
|
||||
if (!scrollContainerRef.current) return
|
||||
|
||||
setIsUserScrolling(true)
|
||||
|
||||
// 清除之前的定时器
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
clearTimeout(scrollTimeoutRef.current)
|
||||
}
|
||||
|
||||
|
||||
// 检查是否已经滚动到底部(允许一些误差)
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
|
||||
|
||||
// 如果用户滚动到底部,1秒后重新启用自动滚动
|
||||
if (isAtBottom) {
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
setIsUserScrolling(false);
|
||||
}, 1000);
|
||||
setIsUserScrolling(false)
|
||||
}, 1000)
|
||||
} else {
|
||||
// 如果不在底部,3秒后重新启用自动滚动
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
setIsUserScrolling(false);
|
||||
}, 3000);
|
||||
setIsUserScrolling(false)
|
||||
}, 3000)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
// 监听消息变化,自动滚动到底部
|
||||
useEffect(() => {
|
||||
const currentSession = sessionList.find(session => session.id === currentSessionId);
|
||||
const currentSession = sessionList.find(
|
||||
(session) => session.id === currentSessionId
|
||||
)
|
||||
if (currentSession?.message.length) {
|
||||
// 延迟一下让DOM更新完成再滚动
|
||||
setTimeout(scrollToBottom, 100);
|
||||
setTimeout(scrollToBottom, 100)
|
||||
}
|
||||
}, [sessionList, currentSessionId, scrollToBottom]);
|
||||
}, [sessionList, currentSessionId, scrollToBottom])
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
clearTimeout(scrollTimeoutRef.current)
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="md:hidden flex flex-col min-h-0 h-[100dvh] bg-[linear-gradient(180deg,rgba(244,244,244,1)_0%,rgba(189,26,45,0.69)_100%)] relative">
|
||||
<div className='relative flex h-[100dvh] min-h-0 flex-col bg-[linear-gradient(180deg,rgba(244,244,244,1)_0%,rgba(189,26,45,0.69)_100%)] md:hidden'>
|
||||
{/* 移动端头部导航栏 */}
|
||||
<PlaygroundMobileHeader toggleSidebar={toggleSidebar} />
|
||||
|
||||
@@ -140,9 +148,9 @@ export function PlaygroundMobileLayout() {
|
||||
/>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 min-h-0 flex flex-col pt-16 pb-20 overflow-hidden">
|
||||
<div className='flex min-h-0 flex-1 flex-col overflow-hidden pt-16 pb-20'>
|
||||
{/* AI视频区域 */}
|
||||
<div className="flex-none flex items-center justify-center mt-4 mb-4">
|
||||
<div className='mt-4 mb-4 flex flex-none items-center justify-center'>
|
||||
<MobileAIVideoPlayer
|
||||
isAudioPlaying={isAudioPlaying}
|
||||
onVideoClick={() => {}}
|
||||
@@ -164,17 +172,17 @@ export function PlaygroundMobileLayout() {
|
||||
{/* 聊天消息区域 */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto touch-pan-y px-4"
|
||||
style={{ scrollBehavior: "smooth" }}
|
||||
className='min-h-0 flex-1 touch-pan-y overflow-y-auto px-4'
|
||||
style={{ scrollBehavior: 'smooth' }}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<div className='min-h-0'>
|
||||
{sessionList
|
||||
.filter((session) => session.id === currentSessionId)
|
||||
.at(0)
|
||||
?.message.map((item) => {
|
||||
if (item.msgType === "realtime") {
|
||||
return <RealtimeBubble key={item.itemId} msg={item} />;
|
||||
if (item.msgType === 'realtime') {
|
||||
return <RealtimeBubble key={item.itemId} msg={item} />
|
||||
}
|
||||
// Dify深度思考功能已移除
|
||||
// if (item.msgType === "dify") {
|
||||
@@ -182,15 +190,15 @@ export function PlaygroundMobileLayout() {
|
||||
// }
|
||||
})}
|
||||
{/* 底部留白确保最后一条消息不被底部按钮遮挡 */}
|
||||
<div className="h-4" />
|
||||
<div className='h-4' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部输入控制区 - 固定在底部 */}
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 bg-[linear-gradient(180deg,rgba(244,244,244,0.9)_0%,rgba(189,26,45,0.9)_100%)] backdrop-blur-sm z-20">
|
||||
<VoiceActionGroup className="w-full" />
|
||||
<div className='fixed right-0 bottom-0 left-0 z-20 bg-[linear-gradient(180deg,rgba(244,244,244,0.9)_0%,rgba(189,26,45,0.9)_100%)] p-4 backdrop-blur-sm'>
|
||||
<VoiceActionGroup className='w-full' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import Image from "next/image";
|
||||
import { Button } from "@heroui/react";
|
||||
import { MessageSquarePlus, X } from "lucide-react";
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@heroui/button'
|
||||
import { MessageSquarePlus, X } from 'lucide-react'
|
||||
|
||||
import useConversationAction from "../useConversationAction";
|
||||
import useConversationAction from '../useConversationAction'
|
||||
|
||||
import useAgentStore from "@/lib/useAgentStore";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import xiaohongAvatar from "@/assets/image/xiaohong-avatar.png";
|
||||
import useAgentStore from '@/lib/useAgentStore'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
import xiaohongAvatar from '@/assets/image/xiaohong-avatar.png'
|
||||
|
||||
interface PlaygroundMobileSidebarProps {
|
||||
sidebarVisible: boolean;
|
||||
toggleSidebar: () => void;
|
||||
sidebarVisible: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
export function PlaygroundMobileSidebar({
|
||||
sidebarVisible,
|
||||
toggleSidebar,
|
||||
}: PlaygroundMobileSidebarProps) {
|
||||
const { agent } = useAgentStore();
|
||||
const { agent } = useAgentStore()
|
||||
const { sessionList, currentSessionId, setCurrentSessionId } =
|
||||
useConversationStore();
|
||||
const { renewConversation, hangupConversation } = useConversationAction();
|
||||
useConversationStore()
|
||||
const { renewConversation, hangupConversation } = useConversationAction()
|
||||
|
||||
const selectTargetSession = async (id: string) => {
|
||||
await hangupConversation();
|
||||
setCurrentSessionId(id);
|
||||
};
|
||||
await hangupConversation()
|
||||
setCurrentSessionId(id)
|
||||
}
|
||||
|
||||
if (!sidebarVisible) return null;
|
||||
if (!sidebarVisible) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
<div
|
||||
className="md:hidden fixed inset-0 bg-black/50 z-[55]"
|
||||
className='fixed inset-0 z-[55] bg-black/50 md:hidden'
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className="md:hidden fixed left-0 top-0 h-full w-80 bg-white z-[65] transform transition-transform duration-300 pt-16">
|
||||
<div className='fixed top-0 left-0 z-[65] h-full w-80 transform bg-white pt-16 transition-transform duration-300 md:hidden'>
|
||||
{/* 头部 */}
|
||||
<div className="bg-stone-100 p-4">
|
||||
<div className="flex flex-row justify-between items-center mb-4">
|
||||
<div className="flex flex-row justify-start items-center gap-2">
|
||||
<Image alt="avatar" src={xiaohongAvatar} width={32} />
|
||||
<span className="text-md font-semibold opacity-70">
|
||||
<div className='bg-stone-100 p-4'>
|
||||
<div className='mb-4 flex flex-row items-center justify-between'>
|
||||
<div className='flex flex-row items-center justify-start gap-2'>
|
||||
<Image alt='avatar' src={xiaohongAvatar} width={32} />
|
||||
<span className='text-md font-semibold opacity-70'>
|
||||
{agent?.card_info}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="primary"
|
||||
variant="light"
|
||||
color='primary'
|
||||
variant='light'
|
||||
onPress={toggleSidebar}
|
||||
>
|
||||
<X />
|
||||
@@ -60,14 +60,14 @@ export function PlaygroundMobileSidebar({
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
className="bg-primary/20 text-lg font-semibold"
|
||||
color="primary"
|
||||
size="lg"
|
||||
className='bg-primary/20 text-lg font-semibold'
|
||||
color='primary'
|
||||
size='lg'
|
||||
startContent={<MessageSquarePlus />}
|
||||
variant="bordered"
|
||||
variant='bordered'
|
||||
onPress={() => {
|
||||
renewConversation("你好!");
|
||||
toggleSidebar();
|
||||
renewConversation('你好!')
|
||||
toggleSidebar()
|
||||
}}
|
||||
>
|
||||
开启新对话
|
||||
@@ -75,31 +75,31 @@ export function PlaygroundMobileSidebar({
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="p-4">
|
||||
<div className='p-4'>
|
||||
{sessionList.map((session) => {
|
||||
const firstMessage = session.message.at(0);
|
||||
let title = "";
|
||||
const firstMessage = session.message.at(0)
|
||||
let title = ''
|
||||
|
||||
if (firstMessage?.msgType === "realtime") {
|
||||
title = firstMessage.textDelta ?? firstMessage.textFinal ?? "";
|
||||
if (firstMessage?.msgType === 'realtime') {
|
||||
title = firstMessage.textDelta ?? firstMessage.textFinal ?? ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={session.id}
|
||||
fullWidth
|
||||
color={currentSessionId === session.id ? "primary" : "default"}
|
||||
variant={currentSessionId === session.id ? "flat" : "light"}
|
||||
color={currentSessionId === session.id ? 'primary' : 'default'}
|
||||
variant={currentSessionId === session.id ? 'flat' : 'light'}
|
||||
onPress={() => selectTargetSession(session.id)}
|
||||
>
|
||||
<p className="w-full text-left overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<p className='w-full overflow-hidden text-left text-ellipsis whitespace-nowrap'>
|
||||
{title}
|
||||
</p>
|
||||
</Button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,102 +1,103 @@
|
||||
import { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { Avatar, Textarea } from "@heroui/react";
|
||||
import { ReadyState } from "react-use-websocket";
|
||||
import { useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { ReadyState } from 'react-use-websocket'
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import useDeviceStore from "@/lib/useDeviceStore";
|
||||
import type { RealtimeMessage } from "@/lib/useRealtimeConnEffect";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import { cn } from '@/lib/utils'
|
||||
import useDeviceStore from '@/lib/useDeviceStore'
|
||||
import type { RealtimeMessage } from '@/lib/useRealtimeConnEffect'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
import { Avatar } from '@heroui/avatar'
|
||||
import { Textarea } from '@heroui/input'
|
||||
|
||||
type Props = {
|
||||
msg: RealtimeMessage;
|
||||
};
|
||||
msg: RealtimeMessage
|
||||
}
|
||||
|
||||
function RealtimeBubble(props: Props) {
|
||||
const { msg } = props;
|
||||
const { wavStreamPlayer } = useDeviceStore();
|
||||
const { wsInstance } = useConversationStore();
|
||||
const { msg } = props
|
||||
const { wavStreamPlayer } = useDeviceStore()
|
||||
const { wsInstance } = useConversationStore()
|
||||
|
||||
// 播放音频
|
||||
async function add16BitPCM(
|
||||
itemId: string,
|
||||
audioDelta: Int16Array<ArrayBuffer>
|
||||
) {
|
||||
wavStreamPlayer.add16BitPCM(audioDelta, itemId);
|
||||
wavStreamPlayer.add16BitPCM(audioDelta, itemId)
|
||||
}
|
||||
|
||||
// 音频回调立即播放
|
||||
useEffect(() => {
|
||||
if (msg.audioDelta && msg.eventName === "response.audio.delta") {
|
||||
add16BitPCM(msg.itemId, msg.audioDelta);
|
||||
if (msg.audioDelta && msg.eventName === 'response.audio.delta') {
|
||||
add16BitPCM(msg.itemId, msg.audioDelta)
|
||||
}
|
||||
}, [msg]);
|
||||
}, [msg])
|
||||
|
||||
// WebSocket 断开后立即中断音频
|
||||
useEffect(() => {
|
||||
if (wsInstance?.readyState === ReadyState.CLOSED) {
|
||||
wavStreamPlayer.interrupt();
|
||||
wavStreamPlayer.interrupt()
|
||||
}
|
||||
}, [wsInstance?.readyState]);
|
||||
}, [wsInstance?.readyState])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mt-8">
|
||||
<div className='mt-8 flex flex-col'>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
"flex",
|
||||
msg.role === "assistant" && "flex-row",
|
||||
msg.role === "user" && "flex-row-reverse",
|
||||
"justify-start items-start gap-[8px]"
|
||||
'w-full',
|
||||
'flex',
|
||||
msg.role === 'assistant' && 'flex-row',
|
||||
msg.role === 'user' && 'flex-row-reverse',
|
||||
'items-start justify-start gap-[8px]'
|
||||
)}
|
||||
>
|
||||
{msg.role === "assistant" && (
|
||||
{msg.role === 'assistant' && (
|
||||
<Image
|
||||
alt="avatar"
|
||||
className="mt-[3px]"
|
||||
alt='avatar'
|
||||
className='mt-[3px]'
|
||||
height={32}
|
||||
src="/xh.png"
|
||||
src='/xh.png'
|
||||
width={32}
|
||||
/>
|
||||
)}
|
||||
|
||||
{msg.role === "user" && (
|
||||
<div className="mt-[4px]">
|
||||
{msg.role === 'user' && (
|
||||
<div className='mt-[4px]'>
|
||||
<Avatar
|
||||
className="text-white text-md"
|
||||
color="primary"
|
||||
name="我"
|
||||
size="sm"
|
||||
className='text-md text-white'
|
||||
color='primary'
|
||||
name='我'
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex flex-col gap-1",
|
||||
msg.role === "assistant" && "pr-[calc(8px+32px)]",
|
||||
msg.role === "user" && "pl-[calc(8px+32px)]"
|
||||
'flex w-full flex-col gap-1',
|
||||
msg.role === 'assistant' && 'pr-[calc(8px+32px)]',
|
||||
msg.role === 'user' && 'pl-[calc(8px+32px)]'
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
isReadOnly
|
||||
classNames={{
|
||||
base: cn(
|
||||
"w-full",
|
||||
"md:w-2/3",
|
||||
msg.role === "assistant" && "self-start",
|
||||
msg.role === "user" && "self-end"
|
||||
'w-full',
|
||||
'md:w-2/3',
|
||||
msg.role === 'assistant' && 'self-start',
|
||||
msg.role === 'user' && 'self-end'
|
||||
),
|
||||
}}
|
||||
fullWidth={false}
|
||||
maxRows={100}
|
||||
minRows={1}
|
||||
value={msg.textDelta ?? msg.textFinal ?? ""}
|
||||
value={msg.textDelta ?? msg.textFinal ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default RealtimeBubble;
|
||||
export default RealtimeBubble
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { ReadyState } from 'react-use-websocket'
|
||||
|
||||
import useConversationAction from '../useConversationAction'
|
||||
|
||||
import useDeviceStore from '@/lib/useDeviceStore'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@heroui/react";
|
||||
import { ReadyState } from "react-use-websocket";
|
||||
|
||||
import useConversationAction from "../useConversationAction";
|
||||
|
||||
import useDeviceStore from "@/lib/useDeviceStore";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from '@heroui/modal'
|
||||
|
||||
function RealtimeModal() {
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
|
||||
const { wavRecorder, wavStreamPlayer } = useDeviceStore();
|
||||
const { wsInstance, isManualHangup, setIsManualHangup } = useConversationStore();
|
||||
const { hangupConversation } = useConversationAction();
|
||||
const { wavRecorder, wavStreamPlayer } = useDeviceStore()
|
||||
const { wsInstance, isManualHangup, setIsManualHangup } =
|
||||
useConversationStore()
|
||||
const { hangupConversation } = useConversationAction()
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasCheckedManualHangup, setHasCheckedManualHangup] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [hasCheckedManualHangup, setHasCheckedManualHangup] = useState(false)
|
||||
|
||||
const endConversation = async () => {
|
||||
// 标记为手动挂断
|
||||
setIsManualHangup(true);
|
||||
setIsManualHangup(true)
|
||||
// 使用localStorage持久化状态
|
||||
localStorage.setItem('manualHangup', 'true');
|
||||
|
||||
hangupConversation();
|
||||
localStorage.setItem('manualHangup', 'true')
|
||||
|
||||
const resp = wavRecorder.getStatus();
|
||||
hangupConversation()
|
||||
|
||||
if (resp !== "ended") {
|
||||
await wavRecorder.end();
|
||||
const resp = wavRecorder.getStatus()
|
||||
|
||||
if (resp !== 'ended') {
|
||||
await wavRecorder.end()
|
||||
}
|
||||
wavStreamPlayer.interrupt();
|
||||
wavStreamPlayer.interrupt()
|
||||
// 刷新页面
|
||||
window.location.reload();
|
||||
};
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// const continueConversation = async () => {
|
||||
// reconnectConversation();
|
||||
@@ -49,42 +51,50 @@ function RealtimeModal() {
|
||||
|
||||
useEffect(() => {
|
||||
// 检查localStorage中是否有手动挂断标记
|
||||
const manualHangupFromStorage = localStorage.getItem('manualHangup');
|
||||
const manualHangupFromStorage = localStorage.getItem('manualHangup')
|
||||
if (manualHangupFromStorage === 'true') {
|
||||
setIsManualHangup(true);
|
||||
setIsManualHangup(true)
|
||||
// 清除localStorage中的标记,避免下次进入时仍然生效
|
||||
localStorage.removeItem('manualHangup');
|
||||
localStorage.removeItem('manualHangup')
|
||||
}
|
||||
setHasCheckedManualHangup(true);
|
||||
}, [setIsManualHangup]);
|
||||
setHasCheckedManualHangup(true)
|
||||
}, [setIsManualHangup])
|
||||
|
||||
useEffect(() => {
|
||||
// 只有在检查完localStorage状态后才判断是否显示弹窗
|
||||
if (!hasCheckedManualHangup) return;
|
||||
|
||||
if (!hasCheckedManualHangup) return
|
||||
|
||||
// 检查是否正在开启新对话
|
||||
const isStartingNewConversation = localStorage.getItem('startingNewConversation') === 'true';
|
||||
|
||||
const isStartingNewConversation =
|
||||
localStorage.getItem('startingNewConversation') === 'true'
|
||||
|
||||
// 只有在非手动挂断、非开启新对话的情况下,才显示断开提示
|
||||
if (wsInstance?.readyState === ReadyState.CLOSED && !isManualHangup && !isStartingNewConversation) {
|
||||
setIsOpen(true);
|
||||
} else if (wsInstance?.readyState === ReadyState.OPEN && isStartingNewConversation) {
|
||||
if (
|
||||
wsInstance?.readyState === ReadyState.CLOSED &&
|
||||
!isManualHangup &&
|
||||
!isStartingNewConversation
|
||||
) {
|
||||
setIsOpen(true)
|
||||
} else if (
|
||||
wsInstance?.readyState === ReadyState.OPEN &&
|
||||
isStartingNewConversation
|
||||
) {
|
||||
// 连接成功时清除开启新对话的标记
|
||||
localStorage.removeItem('startingNewConversation');
|
||||
localStorage.removeItem('startingNewConversation')
|
||||
}
|
||||
}, [wsInstance?.readyState, isManualHangup, hasCheckedManualHangup]);
|
||||
}, [wsInstance?.readyState, isManualHangup, hasCheckedManualHangup])
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
|
||||
<ModalContent>
|
||||
<ModalHeader>对话已断开</ModalHeader>
|
||||
<ModalBody className="mb-4">
|
||||
<ModalBody className='mb-4'>
|
||||
由于长时间未使用对话,服务端自动断开了连接。
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
className="text-white"
|
||||
color="primary"
|
||||
className='text-white'
|
||||
color='primary'
|
||||
onPress={endConversation}
|
||||
>
|
||||
回到首页
|
||||
@@ -99,7 +109,7 @@ function RealtimeModal() {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { RealtimeModal };
|
||||
export { RealtimeModal }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { PlaygroundMobileHeader } from "./PlaygroundMobileHeader";
|
||||
export { PlaygroundMobileSidebar } from "./PlaygroundMobileSidebar";
|
||||
export { PlaygroundMobileLayout } from "./PlaygroundMobileLayout";
|
||||
export { PlaygroundDesktopLayout } from "./PlaygroundDesktopLayout";
|
||||
export { RealtimeModal } from "./RealtimeModal";
|
||||
export { PlaygroundMobileHeader } from './PlaygroundMobileHeader'
|
||||
export { PlaygroundMobileSidebar } from './PlaygroundMobileSidebar'
|
||||
export { PlaygroundMobileLayout } from './PlaygroundMobileLayout'
|
||||
export { PlaygroundDesktopLayout } from './PlaygroundDesktopLayout'
|
||||
export { RealtimeModal } from './RealtimeModal'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
||||
// import useDeviceStore from "@/lib/useDeviceStore";
|
||||
// import useConversationAction from "./useConversationAction";
|
||||
@@ -77,5 +77,5 @@ export default function PlaygroundLayout({ children }: PropsWithChildren) {
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
return children;
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useLayoutEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import useConversationAction from "./useConversationAction";
|
||||
import { PlaygroundDesktopLayout } from "./components";
|
||||
import useConversationAction from './useConversationAction'
|
||||
import { PlaygroundDesktopLayout } from './components'
|
||||
|
||||
import useAgentStore from "@/lib/useAgentStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useAgentStore from '@/lib/useAgentStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const goodFirstQuestion = searchParams.get("question");
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const goodFirstQuestion = searchParams.get('question')
|
||||
|
||||
const { agent } = useAgentStore();
|
||||
const { renewConversation } = useConversationAction();
|
||||
const { agent } = useAgentStore()
|
||||
const { renewConversation } = useConversationAction()
|
||||
|
||||
useEffect(() => {
|
||||
if (goodFirstQuestion) {
|
||||
renewConversation(goodFirstQuestion);
|
||||
renewConversation(goodFirstQuestion)
|
||||
}
|
||||
}, [goodFirstQuestion]);
|
||||
}, [goodFirstQuestion])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!agent) {
|
||||
router.replace("/");
|
||||
router.replace('/')
|
||||
}
|
||||
}, [agent]);
|
||||
}, [agent])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-[calc(100dvh-64px)]",
|
||||
"bg-[url(/background-opacity.png)] bg-center bg-cover"
|
||||
'h-[calc(100dvh-64px)] w-full',
|
||||
'bg-[url(/background-opacity.png)] bg-cover bg-center'
|
||||
)}
|
||||
>
|
||||
<div className="h-full bg-gradient-to-b from-orange-800/20 to-black/20">
|
||||
<div className='h-full bg-gradient-to-b from-orange-800/20 to-black/20'>
|
||||
<PlaygroundDesktopLayout />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
||||
import { Button } from "@heroui/button";
|
||||
import { Button } from '@heroui/button'
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { SidebarExpandIcon } from "@/components/icons";
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { SidebarExpandIcon } from '@/components/icons'
|
||||
|
||||
export function SessionContent({ children }: PropsWithChildren) {
|
||||
const { state, toggleSidebar } = useSidebar();
|
||||
const { state, toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full mx-auto",
|
||||
"bg-gradient-to-t from-primary/60 to-white/60 z-10"
|
||||
'mx-auto w-full',
|
||||
'from-primary/60 z-10 bg-gradient-to-t to-white/60'
|
||||
)}
|
||||
>
|
||||
{state === "collapsed" && (
|
||||
{state === 'collapsed' && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className="absolute m-2 text-primary z-20"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
className='text-primary absolute z-20 m-2'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={toggleSidebar}
|
||||
>
|
||||
<SidebarExpandIcon />
|
||||
@@ -31,5 +31,5 @@ export function SessionContent({ children }: PropsWithChildren) {
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,49 +1,48 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import Image from "next/image";
|
||||
import { Button } from "@heroui/react";
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@heroui/button'
|
||||
import { MessageSquarePlus } from 'lucide-react'
|
||||
|
||||
import useConversationAction from "./useConversationAction";
|
||||
import useConversationAction from './useConversationAction'
|
||||
|
||||
import useAgentStore from "@/lib/useAgentStore";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import useAgentStore from '@/lib/useAgentStore'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import xiaohongAvatar from "@/assets/image/xiaohong-avatar.png";
|
||||
import { SidebarLogoIcon } from "@/components/icons";
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { SidebarLogoIcon } from '@/components/icons'
|
||||
|
||||
export function SessionSidebar() {
|
||||
const { agent } = useAgentStore();
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { agent } = useAgentStore()
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { sessionList, currentSessionId, setCurrentSessionId } =
|
||||
useConversationStore();
|
||||
const { hangupConversation, renewConversation } = useConversationAction();
|
||||
useConversationStore()
|
||||
const { hangupConversation, renewConversation } = useConversationAction()
|
||||
|
||||
const selectTargetSession = async (id: string) => {
|
||||
await hangupConversation();
|
||||
setCurrentSessionId(id);
|
||||
};
|
||||
await hangupConversation()
|
||||
setCurrentSessionId(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar className="bg-white pt-[64px] border-none">
|
||||
<SidebarHeader className="bg-stone-100">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center gap-2 ml-2">
|
||||
<Image alt="avatar" src="/xh.png" width={32} height={32} />
|
||||
<span className="text-md font-semibold opacity-70">
|
||||
<Sidebar className='border-none bg-white pt-[64px]'>
|
||||
<SidebarHeader className='bg-stone-100'>
|
||||
<div className='flex flex-row items-center justify-between'>
|
||||
<div className='ml-2 flex flex-row items-center justify-start gap-2'>
|
||||
<Image alt='avatar' src='/xh.png' width={32} height={32} />
|
||||
<span className='text-md font-semibold opacity-70'>
|
||||
{agent?.card_info}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="primary"
|
||||
variant="light"
|
||||
color='primary'
|
||||
variant='light'
|
||||
onPress={toggleSidebar}
|
||||
>
|
||||
<SidebarLogoIcon />
|
||||
@@ -51,41 +50,41 @@ export function SessionSidebar() {
|
||||
</div>
|
||||
<Button
|
||||
fullWidth
|
||||
className="bg-primary/20 text-lg font-semibold mb-1"
|
||||
color="primary"
|
||||
size="lg"
|
||||
className='bg-primary/20 mb-1 text-lg font-semibold'
|
||||
color='primary'
|
||||
size='lg'
|
||||
startContent={<MessageSquarePlus />}
|
||||
variant="bordered"
|
||||
onPress={() => renewConversation("你好!")}
|
||||
variant='bordered'
|
||||
onPress={() => renewConversation('你好!')}
|
||||
>
|
||||
开启新对话
|
||||
</Button>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="mt-4 px-2">
|
||||
<SidebarContent className='mt-4 px-2'>
|
||||
{sessionList.map((session) => {
|
||||
const firstMessage = session.message.at(0);
|
||||
let title = "";
|
||||
const firstMessage = session.message.at(0)
|
||||
let title = ''
|
||||
|
||||
if (firstMessage?.msgType === "realtime") {
|
||||
title = firstMessage.textDelta ?? firstMessage.textFinal ?? "";
|
||||
if (firstMessage?.msgType === 'realtime') {
|
||||
title = firstMessage.textDelta ?? firstMessage.textFinal ?? ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={session.id}
|
||||
fullWidth
|
||||
color={currentSessionId === session.id ? "primary" : "default"}
|
||||
variant={currentSessionId === session.id ? "flat" : "light"}
|
||||
color={currentSessionId === session.id ? 'primary' : 'default'}
|
||||
variant={currentSessionId === session.id ? 'flat' : 'light'}
|
||||
onPress={() => selectTargetSession(session.id)}
|
||||
>
|
||||
<p className="w-full text-left overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<p className='w-full overflow-hidden text-left text-ellipsis whitespace-nowrap'>
|
||||
{title}
|
||||
</p>
|
||||
</Button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import useDeviceStore from "@/lib/useDeviceStore";
|
||||
import useRealtimeCmd from "@/lib/useRealtimeCmd";
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
import useDeviceStore from '@/lib/useDeviceStore'
|
||||
import useRealtimeCmd from '@/lib/useRealtimeCmd'
|
||||
|
||||
const useConversationAction = () => {
|
||||
const { wavStreamPlayer } = useDeviceStore();
|
||||
const { setWsConnected } = useConversationStore();
|
||||
const { sendPrompt, createHello, createResponse } = useRealtimeCmd();
|
||||
const { wavStreamPlayer } = useDeviceStore()
|
||||
const { setWsConnected } = useConversationStore()
|
||||
const { sendPrompt, createHello, createResponse } = useRealtimeCmd()
|
||||
|
||||
// 初始化 Agent
|
||||
function initAgent(question: string) {
|
||||
sendPrompt();
|
||||
createHello(question);
|
||||
createResponse();
|
||||
sendPrompt()
|
||||
createHello(question)
|
||||
createResponse()
|
||||
}
|
||||
|
||||
// 开启新对话
|
||||
async function renewConversation(question: string) {
|
||||
setWsConnected(false);
|
||||
wavStreamPlayer.interrupt();
|
||||
setWsConnected(false)
|
||||
wavStreamPlayer.interrupt()
|
||||
// await wavStreamPlayer.connect();
|
||||
setWsConnected(true);
|
||||
initAgent(question);
|
||||
setWsConnected(true)
|
||||
initAgent(question)
|
||||
}
|
||||
|
||||
// 重连当前对话
|
||||
@@ -33,11 +33,11 @@ const useConversationAction = () => {
|
||||
|
||||
// 挂断当前对话
|
||||
async function hangupConversation() {
|
||||
setWsConnected(false);
|
||||
wavStreamPlayer.interrupt();
|
||||
setWsConnected(false)
|
||||
wavStreamPlayer.interrupt()
|
||||
}
|
||||
|
||||
return { renewConversation, hangupConversation };
|
||||
};
|
||||
return { renewConversation, hangupConversation }
|
||||
}
|
||||
|
||||
export default useConversationAction;
|
||||
export default useConversationAction
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { addToast, Button } from "@heroui/react";
|
||||
import { Mic } from "lucide-react";
|
||||
import { Visualizer } from "react-sound-visualizer";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Mic } from 'lucide-react'
|
||||
import { Visualizer } from 'react-sound-visualizer'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import useConversationAction from "./useConversationAction";
|
||||
import useConversationAction from './useConversationAction'
|
||||
|
||||
import useDeviceStore from "@/lib/useDeviceStore";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import { useRealtimeCmd } from "@/lib/useRealtimeCmd";
|
||||
import { arrayBufferToBase64 } from "@/lib/audioUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useDeviceStore from '@/lib/useDeviceStore'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
import { useRealtimeCmd } from '@/lib/useRealtimeCmd'
|
||||
import { arrayBufferToBase64 } from '@/lib/audioUtils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { addToast } from '@heroui/toast'
|
||||
import { Button } from '@heroui/button'
|
||||
|
||||
type VoiceActionGroupProps = {
|
||||
className?: string;
|
||||
};
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function VoiceActionGroup({ className }: VoiceActionGroupProps) {
|
||||
const { wavRecorder, wavStreamPlayer } = useDeviceStore();
|
||||
const { wavRecorder, wavStreamPlayer } = useDeviceStore()
|
||||
const { wsInstance, currentSessionId, sessionList, setCurrentSessionList } =
|
||||
useConversationStore();
|
||||
const { hangupConversation } = useConversationAction();
|
||||
const router = useRouter();
|
||||
useConversationStore()
|
||||
const { hangupConversation } = useConversationAction()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
createResponse,
|
||||
@@ -31,113 +32,113 @@ export function VoiceActionGroup({ className }: VoiceActionGroupProps) {
|
||||
commitUserVoice,
|
||||
clearAudioBuffer,
|
||||
cancelResponse,
|
||||
} = useRealtimeCmd();
|
||||
} = useRealtimeCmd()
|
||||
|
||||
const [isRealTimeActive, setIsRealTimeActive] = useState(false);
|
||||
const [isRealTimeActive, setIsRealTimeActive] = useState(false)
|
||||
|
||||
async function endConversation() {
|
||||
// 标记为手动挂断
|
||||
useConversationStore.getState().setIsManualHangup(true);
|
||||
useConversationStore.getState().setIsManualHangup(true)
|
||||
// 使用localStorage持久化状态
|
||||
localStorage.setItem("manualHangup", "true");
|
||||
localStorage.setItem('manualHangup', 'true')
|
||||
|
||||
hangupConversation();
|
||||
hangupConversation()
|
||||
|
||||
setIsRealTimeActive(false);
|
||||
setIsRealTimeActive(false)
|
||||
|
||||
try {
|
||||
const recorderStatus = wavRecorder.getStatus();
|
||||
const recorderStatus = wavRecorder.getStatus()
|
||||
|
||||
if (recorderStatus && recorderStatus !== "ended") {
|
||||
await wavRecorder.end();
|
||||
if (recorderStatus && recorderStatus !== 'ended') {
|
||||
await wavRecorder.end()
|
||||
}
|
||||
clearAudioBuffer();
|
||||
cancelResponse();
|
||||
wavStreamPlayer.interrupt();
|
||||
clearAudioBuffer()
|
||||
cancelResponse()
|
||||
wavStreamPlayer.interrupt()
|
||||
} catch (error) {
|
||||
console.error("Error ending conversation:", error);
|
||||
console.error('Error ending conversation:', error)
|
||||
}
|
||||
|
||||
router.replace("/");
|
||||
router.replace('/')
|
||||
}
|
||||
|
||||
async function startRealTime() {
|
||||
if (isRealTimeActive) {
|
||||
setIsRealTimeActive(false);
|
||||
setIsRealTimeActive(false)
|
||||
try {
|
||||
const recorderStatus = wavRecorder.getStatus();
|
||||
const recorderStatus = wavRecorder.getStatus()
|
||||
|
||||
if (recorderStatus && recorderStatus !== "ended") {
|
||||
await wavRecorder.end();
|
||||
if (recorderStatus && recorderStatus !== 'ended') {
|
||||
await wavRecorder.end()
|
||||
}
|
||||
clearAudioBuffer();
|
||||
cancelResponse();
|
||||
clearAudioBuffer()
|
||||
cancelResponse()
|
||||
} catch (error) {
|
||||
console.error("Error ending recorder:", error);
|
||||
console.error('Error ending recorder:', error)
|
||||
}
|
||||
} else {
|
||||
wavStreamPlayer.interrupt();
|
||||
wavStreamPlayer.interrupt()
|
||||
|
||||
const permissions = window.navigator.permissions;
|
||||
const permissions = window.navigator.permissions
|
||||
|
||||
if (permissions) {
|
||||
const microphonePermission = await permissions.query({
|
||||
name: "microphone",
|
||||
});
|
||||
name: 'microphone',
|
||||
})
|
||||
|
||||
if (microphonePermission.state === "denied") {
|
||||
if (microphonePermission.state === 'denied') {
|
||||
addToast({
|
||||
title: "已拒绝授予麦克风使用权限",
|
||||
description: "请检查浏览器权限",
|
||||
color: "warning",
|
||||
title: '已拒绝授予麦克风使用权限',
|
||||
description: '请检查浏览器权限',
|
||||
color: 'warning',
|
||||
timeout: 0,
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const recorderStatus = wavRecorder.getStatus();
|
||||
const recorderStatus = wavRecorder.getStatus()
|
||||
|
||||
if (recorderStatus && recorderStatus !== "ended") {
|
||||
await wavRecorder.end();
|
||||
if (recorderStatus && recorderStatus !== 'ended') {
|
||||
await wavRecorder.end()
|
||||
}
|
||||
|
||||
await wavRecorder.begin();
|
||||
setIsRealTimeActive(true);
|
||||
await wavRecorder.begin()
|
||||
setIsRealTimeActive(true)
|
||||
wavRecorder.record(({ mono }) => {
|
||||
const audioBase64 = arrayBufferToBase64(mono);
|
||||
appendUserVoice(audioBase64);
|
||||
});
|
||||
const audioBase64 = arrayBufferToBase64(mono)
|
||||
appendUserVoice(audioBase64)
|
||||
})
|
||||
} catch (error) {
|
||||
addToast({
|
||||
title: "录音启动失败",
|
||||
description: "请刷新页面后重试",
|
||||
color: "danger",
|
||||
title: '录音启动失败',
|
||||
description: '请刷新页面后重试',
|
||||
color: 'danger',
|
||||
timeout: 5000,
|
||||
});
|
||||
setIsRealTimeActive(false);
|
||||
})
|
||||
setIsRealTimeActive(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [isStreaming, setIsStreaming] = useState(true);
|
||||
const [isStreaming, setIsStreaming] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
sessionList
|
||||
.filter((session) => session.id === currentSessionId)
|
||||
.at(0)
|
||||
?.message.forEach((item) => {
|
||||
if (item.msgType === "realtime") {
|
||||
setIsStreaming(item.isStreaming);
|
||||
if (item.msgType === 'realtime') {
|
||||
setIsStreaming(item.isStreaming)
|
||||
}
|
||||
});
|
||||
})
|
||||
}, [
|
||||
sessionList
|
||||
.filter((session) => session.id === currentSessionId)
|
||||
.at(0)
|
||||
?.message.at(-1),
|
||||
]);
|
||||
])
|
||||
|
||||
// 自动开始实时对话的useEffect
|
||||
// useEffect(() => {
|
||||
@@ -180,28 +181,28 @@ export function VoiceActionGroup({ className }: VoiceActionGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full p-3 rounded-full bg-primary",
|
||||
"flex flex-row flex-1 items-center justify-between gap-2",
|
||||
'bg-primary w-full rounded-full p-3',
|
||||
'flex flex-1 flex-row items-center justify-between gap-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex flex-row items-center justify-center gap-2">
|
||||
<div className='flex w-full flex-row items-center justify-center gap-2'>
|
||||
<Button
|
||||
className="bg-zinc-800 text-white"
|
||||
radius="full"
|
||||
size="md"
|
||||
variant="flat"
|
||||
className='bg-zinc-800 text-white'
|
||||
radius='full'
|
||||
size='md'
|
||||
variant='flat'
|
||||
onPress={endConversation}
|
||||
>
|
||||
挂断
|
||||
</Button>
|
||||
{isRealTimeActive && (
|
||||
<div className="flex flex-row items-center justify-end gap-2 w-full">
|
||||
<div className='flex w-full flex-row items-center justify-end gap-2'>
|
||||
<Visualizer
|
||||
autoStart
|
||||
audio={wavRecorder.stream}
|
||||
mode="continuous"
|
||||
strokeColor="#fff"
|
||||
mode='continuous'
|
||||
strokeColor='#fff'
|
||||
>
|
||||
{({ canvasRef }) => (
|
||||
<canvas ref={canvasRef} height={40} width={200} />
|
||||
@@ -211,13 +212,13 @@ export function VoiceActionGroup({ className }: VoiceActionGroupProps) {
|
||||
)}
|
||||
|
||||
{!isRealTimeActive && (
|
||||
<div className="flex flex-row items-center gap-2 flex-1">
|
||||
<div className='flex flex-1 flex-row items-center gap-2'>
|
||||
<Button
|
||||
className={cn("bg-zinc-800 text-white flex-1")}
|
||||
radius="full"
|
||||
size="md"
|
||||
className={cn('flex-1 bg-zinc-800 text-white')}
|
||||
radius='full'
|
||||
size='md'
|
||||
startContent={<Mic />}
|
||||
variant="flat"
|
||||
variant='flat'
|
||||
onPress={startRealTime}
|
||||
>
|
||||
点击说话
|
||||
@@ -226,5 +227,5 @@ export function VoiceActionGroup({ className }: VoiceActionGroupProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { useRouter } from "next/navigation";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { HeroUIProvider, ToastProvider } from "@heroui/react";
|
||||
import { getLocale, getMessages, getTimeZone } from "next-intl/server";
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { useRouter } from 'next/navigation'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { HeroUIProvider } from '@heroui/system'
|
||||
import { ToastProvider } from '@heroui/toast'
|
||||
import { getLocale, getMessages, getTimeZone } from 'next-intl/server'
|
||||
|
||||
declare module "@react-types/shared" {
|
||||
declare module '@react-types/shared' {
|
||||
interface RouterConfig {
|
||||
routerOptions: NonNullable<
|
||||
Parameters<ReturnType<typeof useRouter>["push"]>[1]
|
||||
>;
|
||||
Parameters<ReturnType<typeof useRouter>['push']>[1]
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
export async function Providers({ children }: PropsWithChildren) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
const timeZone = await getTimeZone();
|
||||
const locale = await getLocale()
|
||||
const messages = await getMessages()
|
||||
const timeZone = await getTimeZone()
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider
|
||||
@@ -24,12 +25,12 @@ export async function Providers({ children }: PropsWithChildren) {
|
||||
messages={messages}
|
||||
timeZone={timeZone}
|
||||
>
|
||||
<ThemeProvider attribute="class" forcedTheme="light">
|
||||
<ThemeProvider attribute='class' forcedTheme='light'>
|
||||
<HeroUIProvider locale={locale}>
|
||||
<ToastProvider />
|
||||
{children}
|
||||
</HeroUIProvider>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,104 +3,104 @@
|
||||
Usage: /api/pdf?url=https%3A%2F%2F<oss-host>%2Fpath%2Fto%2Ffile.pdf
|
||||
*/
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
function encodeRFC5987ValueChars(str: string): string {
|
||||
return encodeURIComponent(str)
|
||||
.replace(/['()]/g, escape)
|
||||
.replace(/\*/g, "%2A")
|
||||
.replace(/%(7C|60|5E)/g, "%25$1");
|
||||
.replace(/\*/g, '%2A')
|
||||
.replace(/%(7C|60|5E)/g, '%25$1')
|
||||
}
|
||||
|
||||
const ALLOWED_HOSTS = new Set<string>([
|
||||
"tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com",
|
||||
]);
|
||||
'tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com',
|
||||
])
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawUrl = searchParams.get("url");
|
||||
const { searchParams } = new URL(request.url)
|
||||
const rawUrl = searchParams.get('url')
|
||||
if (!rawUrl) {
|
||||
return new Response("Missing url", { status: 400 });
|
||||
return new Response('Missing url', { status: 400 })
|
||||
}
|
||||
|
||||
let target: URL;
|
||||
let target: URL
|
||||
try {
|
||||
target = new URL(rawUrl);
|
||||
target = new URL(rawUrl)
|
||||
} catch {
|
||||
return new Response("Invalid url", { status: 400 });
|
||||
return new Response('Invalid url', { status: 400 })
|
||||
}
|
||||
|
||||
const isPdf = target.pathname.toLowerCase().endsWith(".pdf");
|
||||
const isPdf = target.pathname.toLowerCase().endsWith('.pdf')
|
||||
if (!isPdf) {
|
||||
return new Response("Only PDF is allowed", { status: 400 });
|
||||
return new Response('Only PDF is allowed', { status: 400 })
|
||||
}
|
||||
|
||||
if (!ALLOWED_HOSTS.has(target.hostname)) {
|
||||
return new Response("Host not allowed", { status: 403 });
|
||||
return new Response('Host not allowed', { status: 403 })
|
||||
}
|
||||
|
||||
const forwardHeaders: HeadersInit = {};
|
||||
const range = request.headers.get("range");
|
||||
if (range) forwardHeaders["range"] = range;
|
||||
const origin = request.headers.get("origin") ?? "http://localhost:3000";
|
||||
const referer = request.headers.get("referer") ?? origin;
|
||||
forwardHeaders["origin"] = origin;
|
||||
forwardHeaders["referer"] = referer;
|
||||
forwardHeaders["user-agent"] =
|
||||
request.headers.get("user-agent") ?? "Mozilla/5.0";
|
||||
const forwardHeaders: HeadersInit = {}
|
||||
const range = request.headers.get('range')
|
||||
if (range) forwardHeaders['range'] = range
|
||||
const origin = request.headers.get('origin') ?? 'http://localhost:3000'
|
||||
const referer = request.headers.get('referer') ?? origin
|
||||
forwardHeaders['origin'] = origin
|
||||
forwardHeaders['referer'] = referer
|
||||
forwardHeaders['user-agent'] =
|
||||
request.headers.get('user-agent') ?? 'Mozilla/5.0'
|
||||
|
||||
const ossResponse = await fetch(target.toString(), {
|
||||
method: "GET",
|
||||
method: 'GET',
|
||||
headers: forwardHeaders,
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
});
|
||||
cache: 'no-store',
|
||||
redirect: 'follow',
|
||||
})
|
||||
|
||||
// If upstream error, pass through
|
||||
if (!ossResponse.ok && ossResponse.status !== 206) {
|
||||
const text = await ossResponse.text();
|
||||
return new Response(text, { status: ossResponse.status });
|
||||
const text = await ossResponse.text()
|
||||
return new Response(text, { status: ossResponse.status })
|
||||
}
|
||||
|
||||
// Build safe headers for buffered response
|
||||
const responseHeaders = new Headers();
|
||||
const responseHeaders = new Headers()
|
||||
const contentType =
|
||||
ossResponse.headers.get("content-type") ?? "application/pdf";
|
||||
responseHeaders.set("content-type", contentType);
|
||||
const acceptRanges = ossResponse.headers.get("accept-ranges") ?? "bytes";
|
||||
responseHeaders.set("accept-ranges", acceptRanges);
|
||||
const contentRange = ossResponse.headers.get("content-range");
|
||||
if (contentRange) responseHeaders.set("content-range", contentRange);
|
||||
ossResponse.headers.get('content-type') ?? 'application/pdf'
|
||||
responseHeaders.set('content-type', contentType)
|
||||
const acceptRanges = ossResponse.headers.get('accept-ranges') ?? 'bytes'
|
||||
responseHeaders.set('accept-ranges', acceptRanges)
|
||||
const contentRange = ossResponse.headers.get('content-range')
|
||||
if (contentRange) responseHeaders.set('content-range', contentRange)
|
||||
const rawFilename = decodeURIComponent(
|
||||
target.pathname.split("/").pop() || "document.pdf"
|
||||
);
|
||||
const asciiFallback = "document.pdf";
|
||||
const utf8Encoded = encodeRFC5987ValueChars(rawFilename);
|
||||
target.pathname.split('/').pop() || 'document.pdf'
|
||||
)
|
||||
const asciiFallback = 'document.pdf'
|
||||
const utf8Encoded = encodeRFC5987ValueChars(rawFilename)
|
||||
responseHeaders.set(
|
||||
"content-disposition",
|
||||
'content-disposition',
|
||||
`inline; filename="${asciiFallback}"; filename*=UTF-8''${utf8Encoded}`
|
||||
);
|
||||
)
|
||||
const cacheControl =
|
||||
ossResponse.headers.get("cache-control") ?? "public, max-age=3600";
|
||||
responseHeaders.set("cache-control", cacheControl);
|
||||
ossResponse.headers.get('cache-control') ?? 'public, max-age=3600'
|
||||
responseHeaders.set('cache-control', cacheControl)
|
||||
|
||||
const buffer = await ossResponse.arrayBuffer();
|
||||
responseHeaders.set("content-length", String(buffer.byteLength));
|
||||
const buffer = await ossResponse.arrayBuffer()
|
||||
responseHeaders.set('content-length', String(buffer.byteLength))
|
||||
|
||||
return new Response(buffer, {
|
||||
status: ossResponse.status,
|
||||
statusText: ossResponse.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
// const message = error?.message || "Unknown error";
|
||||
// const stack = error?.stack || "";
|
||||
// console.error("/api/pdf proxy error", message, stack);
|
||||
return new Response(JSON.stringify({ error: error }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren } from 'react'
|
||||
// Since we have a root `not-found.tsx` page, a layout file
|
||||
// is required, even if it's just passing children through.
|
||||
export default function RootLayout({ children }: PropsWithChildren) {
|
||||
return children;
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import Error from "next/error";
|
||||
import Error from 'next/error'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang='en'>
|
||||
<body>
|
||||
<Error statusCode={404} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface PageLoadingProps {
|
||||
message?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
message?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export const PageLoading = ({
|
||||
message = "加载中...",
|
||||
size = "md"
|
||||
export const PageLoading = ({
|
||||
message = '加载中...',
|
||||
size = 'md',
|
||||
}: PageLoadingProps) => {
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8"
|
||||
};
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 space-y-4">
|
||||
<div className='flex flex-col items-center justify-center space-y-4 p-8'>
|
||||
<Loader2 className={`animate-spin text-[#BD1A2D] ${sizeClasses[size]}`} />
|
||||
<p className="text-gray-600 text-sm">{message}</p>
|
||||
<p className='text-sm text-gray-600'>{message}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Avatar } from "@heroui/react";
|
||||
import { Avatar } from '@heroui/avatar'
|
||||
|
||||
import useAgentStore from "@/lib/useAgentStore";
|
||||
import useAgentStore from '@/lib/useAgentStore'
|
||||
|
||||
export function AgentAvatarLarge() {
|
||||
const { agent } = useAgentStore();
|
||||
const { agent } = useAgentStore()
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
alt="avatar"
|
||||
className="w-[180px] h-[180px] md:w-[240px] md:h-[240px]"
|
||||
radius="lg"
|
||||
alt='avatar'
|
||||
className='h-[180px] w-[180px] md:h-[240px] md:w-[240px]'
|
||||
radius='lg'
|
||||
src={agent?.agent_avatar_url}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Avatar } from "@heroui/react";
|
||||
import { Avatar } from '@heroui/avatar'
|
||||
|
||||
import useAgentStore from "@/lib/useAgentStore";
|
||||
import useAgentStore from '@/lib/useAgentStore'
|
||||
|
||||
function AgentAvatar() {
|
||||
const { agent } = useAgentStore();
|
||||
const { agent } = useAgentStore()
|
||||
|
||||
return <Avatar alt="avatar" size="sm" src={agent?.agent_avatar_url} />;
|
||||
return <Avatar alt='avatar' size='sm' src={agent?.agent_avatar_url} />
|
||||
}
|
||||
|
||||
export default AgentAvatar;
|
||||
export { AgentAvatar };
|
||||
export default AgentAvatar
|
||||
export { AgentAvatar }
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useWindowSize } from "react-haiku";
|
||||
import Image from "next/image";
|
||||
import XiaohongImage from "@public/xiaohong.jpg";
|
||||
import { useWindowSize } from 'react-haiku'
|
||||
import Image from 'next/image'
|
||||
import XiaohongImage from '@public/xiaohong.jpg'
|
||||
|
||||
function AgentPortrait() {
|
||||
const { height } = useWindowSize();
|
||||
const { height } = useWindowSize()
|
||||
|
||||
return (
|
||||
<Image
|
||||
priority
|
||||
alt="小红形象"
|
||||
alt='小红形象'
|
||||
height={height / 4}
|
||||
src={XiaohongImage}
|
||||
width={height / 4}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentPortrait;
|
||||
export { AgentPortrait };
|
||||
export default AgentPortrait
|
||||
export { AgentPortrait }
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@heroui/button";
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@heroui/button'
|
||||
|
||||
export const Counter = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<Button radius="full" onPress={() => setCount(count + 1)}>
|
||||
<Button radius='full' onPress={() => setCount(count + 1)}>
|
||||
Count is {count}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Link } from "@heroui/link";
|
||||
import { Link } from '@heroui/link'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="w-full flex items-center justify-center py-3">
|
||||
<footer className='flex w-full items-center justify-center py-3'>
|
||||
<Link
|
||||
isExternal
|
||||
className="flex items-center gap-1 text-current"
|
||||
href="https://heroui.com?utm_source=next-app-template"
|
||||
title="heroui.com homepage"
|
||||
className='flex items-center gap-1 text-current'
|
||||
href='https://heroui.com?utm_source=next-app-template'
|
||||
title='heroui.com homepage'
|
||||
>
|
||||
<span className="text-default-600">Powered by</span>
|
||||
<p className="text-primary">HeroUI</p>
|
||||
<span className='text-default-600'>Powered by</span>
|
||||
<p className='text-primary'>HeroUI</p>
|
||||
</Link>
|
||||
</footer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from "react";
|
||||
import * as React from 'react'
|
||||
|
||||
import type { IconSvgProps } from "@/types";
|
||||
import type { IconSvgProps } from '@/types'
|
||||
|
||||
export type IconImgProps = React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
size?: number;
|
||||
};
|
||||
size?: number
|
||||
}
|
||||
|
||||
export const Logo: React.FC<IconSvgProps> = ({
|
||||
size = 36,
|
||||
@@ -13,20 +13,20 @@ export const Logo: React.FC<IconSvgProps> = ({
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
fill="none"
|
||||
fill='none'
|
||||
height={size || height}
|
||||
viewBox="0 0 32 32"
|
||||
viewBox='0 0 32 32'
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule='evenodd'
|
||||
d='M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z'
|
||||
fill='currentColor'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const DiscordIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
@@ -37,17 +37,17 @@ export const DiscordIcon: React.FC<IconSvgProps> = ({
|
||||
return (
|
||||
<svg
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
viewBox='0 0 24 24'
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M14.82 4.26a10.14 10.14 0 0 0-.53 1.1 14.66 14.66 0 0 0-4.58 0 10.14 10.14 0 0 0-.53-1.1 16 16 0 0 0-4.13 1.3 17.33 17.33 0 0 0-3 11.59 16.6 16.6 0 0 0 5.07 2.59A12.89 12.89 0 0 0 8.23 18a9.65 9.65 0 0 1-1.71-.83 3.39 3.39 0 0 0 .42-.33 11.66 11.66 0 0 0 10.12 0q.21.18.42.33a10.84 10.84 0 0 1-1.71.84 12.41 12.41 0 0 0 1.08 1.78 16.44 16.44 0 0 0 5.06-2.59 17.22 17.22 0 0 0-3-11.59 16.09 16.09 0 0 0-4.09-1.35zM8.68 14.81a1.94 1.94 0 0 1-1.8-2 1.93 1.93 0 0 1 1.8-2 1.93 1.93 0 0 1 1.8 2 1.93 1.93 0 0 1-1.8 2zm6.64 0a1.94 1.94 0 0 1-1.8-2 1.93 1.93 0 0 1 1.8-2 1.92 1.92 0 0 1 1.8 2 1.92 1.92 0 0 1-1.8 2z"
|
||||
fill="currentColor"
|
||||
d='M14.82 4.26a10.14 10.14 0 0 0-.53 1.1 14.66 14.66 0 0 0-4.58 0 10.14 10.14 0 0 0-.53-1.1 16 16 0 0 0-4.13 1.3 17.33 17.33 0 0 0-3 11.59 16.6 16.6 0 0 0 5.07 2.59A12.89 12.89 0 0 0 8.23 18a9.65 9.65 0 0 1-1.71-.83 3.39 3.39 0 0 0 .42-.33 11.66 11.66 0 0 0 10.12 0q.21.18.42.33a10.84 10.84 0 0 1-1.71.84 12.41 12.41 0 0 0 1.08 1.78 16.44 16.44 0 0 0 5.06-2.59 17.22 17.22 0 0 0-3-11.59 16.09 16.09 0 0 0-4.09-1.35zM8.68 14.81a1.94 1.94 0 0 1-1.8-2 1.93 1.93 0 0 1 1.8-2 1.93 1.93 0 0 1 1.8 2 1.93 1.93 0 0 1-1.8 2zm6.64 0a1.94 1.94 0 0 1-1.8-2 1.93 1.93 0 0 1 1.8-2 1.92 1.92 0 0 1 1.8 2 1.92 1.92 0 0 1-1.8 2z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const TwitterIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
@@ -58,17 +58,17 @@ export const TwitterIcon: React.FC<IconSvgProps> = ({
|
||||
return (
|
||||
<svg
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
viewBox='0 0 24 24'
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M19.633 7.997c.013.175.013.349.013.523 0 5.325-4.053 11.461-11.46 11.461-2.282 0-4.402-.661-6.186-1.809.324.037.636.05.973.05a8.07 8.07 0 0 0 5.001-1.721 4.036 4.036 0 0 1-3.767-2.793c.249.037.499.062.761.062.361 0 .724-.05 1.061-.137a4.027 4.027 0 0 1-3.23-3.953v-.05c.537.299 1.16.486 1.82.511a4.022 4.022 0 0 1-1.796-3.354c0-.748.199-1.434.548-2.032a11.457 11.457 0 0 0 8.306 4.215c-.062-.3-.1-.611-.1-.923a4.026 4.026 0 0 1 4.028-4.028c1.16 0 2.207.486 2.943 1.272a7.957 7.957 0 0 0 2.556-.973 4.02 4.02 0 0 1-1.771 2.22 8.073 8.073 0 0 0 2.319-.624 8.645 8.645 0 0 1-2.019 2.083z"
|
||||
fill="currentColor"
|
||||
d='M19.633 7.997c.013.175.013.349.013.523 0 5.325-4.053 11.461-11.46 11.461-2.282 0-4.402-.661-6.186-1.809.324.037.636.05.973.05a8.07 8.07 0 0 0 5.001-1.721 4.036 4.036 0 0 1-3.767-2.793c.249.037.499.062.761.062.361 0 .724-.05 1.061-.137a4.027 4.027 0 0 1-3.23-3.953v-.05c.537.299 1.16.486 1.82.511a4.022 4.022 0 0 1-1.796-3.354c0-.748.199-1.434.548-2.032a11.457 11.457 0 0 0 8.306 4.215c-.062-.3-.1-.611-.1-.923a4.026 4.026 0 0 1 4.028-4.028c1.16 0 2.207.486 2.943 1.272a7.957 7.957 0 0 0 2.556-.973 4.02 4.02 0 0 1-1.771 2.22 8.073 8.073 0 0 0 2.319-.624 8.645 8.645 0 0 1-2.019 2.083z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const GithubIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
@@ -79,19 +79,19 @@ export const GithubIcon: React.FC<IconSvgProps> = ({
|
||||
return (
|
||||
<svg
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
viewBox='0 0 24 24'
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule='evenodd'
|
||||
d='M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z'
|
||||
fill='currentColor'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const MoonFilledIcon = ({
|
||||
size = 24,
|
||||
@@ -100,20 +100,20 @@ export const MoonFilledIcon = ({
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
aria-hidden='true'
|
||||
focusable='false'
|
||||
height={size || height}
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
role='presentation'
|
||||
viewBox='0 0 24 24'
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z"
|
||||
fill="currentColor"
|
||||
d='M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const SunFilledIcon = ({
|
||||
size = 24,
|
||||
@@ -122,20 +122,20 @@ export const SunFilledIcon = ({
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
aria-hidden='true'
|
||||
focusable='false'
|
||||
height={size || height}
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
role='presentation'
|
||||
viewBox='0 0 24 24'
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
|
||||
<path d="M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z" />
|
||||
<g fill='currentColor'>
|
||||
<path d='M19 12a7 7 0 11-7-7 7 7 0 017 7z' />
|
||||
<path d='M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z' />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const HeartFilledIcon = ({
|
||||
size = 24,
|
||||
@@ -144,51 +144,51 @@ export const HeartFilledIcon = ({
|
||||
...props
|
||||
}: IconSvgProps) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
aria-hidden='true'
|
||||
focusable='false'
|
||||
height={size || height}
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
role='presentation'
|
||||
viewBox='0 0 24 24'
|
||||
width={size || width}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M12.62 20.81c-.34.12-.9.12-1.24 0C8.48 19.82 2 15.69 2 8.69 2 5.6 4.49 3.1 7.56 3.1c1.82 0 3.43.88 4.44 2.24a5.53 5.53 0 0 1 4.44-2.24C19.51 3.1 22 5.6 22 8.69c0 7-6.48 11.13-9.38 12.12Z"
|
||||
fill="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d='M12.62 20.81c-.34.12-.9.12-1.24 0C8.48 19.82 2 15.69 2 8.69 2 5.6 4.49 3.1 7.56 3.1c1.82 0 3.43.88 4.44 2.24a5.53 5.53 0 0 1 4.44-2.24C19.51 3.1 22 5.6 22 8.69c0 7-6.48 11.13-9.38 12.12Z'
|
||||
fill='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
export const SearchIcon = (props: IconSvgProps) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
role="presentation"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
aria-hidden='true'
|
||||
fill='none'
|
||||
focusable='false'
|
||||
height='1em'
|
||||
role='presentation'
|
||||
viewBox='0 0 24 24'
|
||||
width='1em'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d='M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path
|
||||
d="M22 22L20 20"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d='M22 22L20 20'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
// 收起侧边栏图标
|
||||
export const SidebarLogoIcon: React.FC<IconSvgProps> = ({
|
||||
size = 33,
|
||||
@@ -197,20 +197,20 @@ export const SidebarLogoIcon: React.FC<IconSvgProps> = ({
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
className="icon"
|
||||
className='icon'
|
||||
height={size || height}
|
||||
version="1.1"
|
||||
viewBox="0 0 1024 1024"
|
||||
version='1.1'
|
||||
viewBox='0 0 1024 1024'
|
||||
width={size || width}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M910.1 361.3L779.8 491.6c-8.1 8.1-8.1 22.4 0 31.6l130.3 130.3c14.3 14.3 37.7 4.1 37.7-15.3V376.6c0-19.4-23.4-29.5-37.7-15.3z m-785-105.9h773.8c33.6 0 61.1-27.5 61.1-61.1s-27.5-61.1-61.1-61.1H125.1c-33.6 0-61.1 27.5-61.1 61.1s27.5 61.1 61.1 61.1z m0 309.5h505c33.6 0 61.1-27.5 61.1-61.1s-27.5-61.1-61.1-61.1h-505c-33.6 0-61.1 27.5-61.1 61.1s27.5 61.1 61.1 61.1z m773.8 203.7H125.1c-33.6 0-61.1 27.5-61.1 61.1s27.5 61.1 61.1 61.1h773.8c33.6 0 61.1-27.5 61.1-61.1s-27.5-61.1-61.1-61.1z"
|
||||
fill="#BD1A2D"
|
||||
d='M910.1 361.3L779.8 491.6c-8.1 8.1-8.1 22.4 0 31.6l130.3 130.3c14.3 14.3 37.7 4.1 37.7-15.3V376.6c0-19.4-23.4-29.5-37.7-15.3z m-785-105.9h773.8c33.6 0 61.1-27.5 61.1-61.1s-27.5-61.1-61.1-61.1H125.1c-33.6 0-61.1 27.5-61.1 61.1s27.5 61.1 61.1 61.1z m0 309.5h505c33.6 0 61.1-27.5 61.1-61.1s-27.5-61.1-61.1-61.1h-505c-33.6 0-61.1 27.5-61.1 61.1s27.5 61.1 61.1 61.1z m773.8 203.7H125.1c-33.6 0-61.1 27.5-61.1 61.1s27.5 61.1 61.1 61.1h773.8c33.6 0 61.1-27.5 61.1-61.1s-27.5-61.1-61.1-61.1z'
|
||||
fill='#BD1A2D'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
// 展开侧边栏图标
|
||||
export const SidebarExpandIcon: React.FC<IconSvgProps> = ({
|
||||
size = 33,
|
||||
@@ -219,37 +219,37 @@ export const SidebarExpandIcon: React.FC<IconSvgProps> = ({
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
className="icon"
|
||||
className='icon'
|
||||
height={size || height}
|
||||
version="1.1"
|
||||
viewBox="0 0 1024 1024"
|
||||
version='1.1'
|
||||
viewBox='0 0 1024 1024'
|
||||
width={size || width}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M127.6 259h768.9c35.4 0 64.1-28.7 64.1-64.1s-28.7-64.1-64.1-64.1H127.6c-35.4 0-64.1 28.7-64.1 64.1S92.2 259 127.6 259zM896.4 765H127.6c-35.4 0-64.1 28.7-64.1 64.1s28.7 64.1 64.1 64.1h768.9c35.4 0 64.1-28.7 64.1-64.1S931.8 765 896.4 765zM127.6 576.1H512c35.4 0 64.1-28.7 64.1-64.1s-28.7-64-64.1-64H127.6c-35.4 0-64.1 28.7-64.1 64.1s28.7 64 64.1 64zM938.8 477l-159.1-88.4c-28.2-15.6-62.8 4.7-62.7 36.9v176.7c0 32.2 34.6 52.6 62.8 36.9l159.1-88.4c28.8-15.9 28.8-57.6-0.1-73.7z"
|
||||
fill="#BD1A2D"
|
||||
d='M127.6 259h768.9c35.4 0 64.1-28.7 64.1-64.1s-28.7-64.1-64.1-64.1H127.6c-35.4 0-64.1 28.7-64.1 64.1S92.2 259 127.6 259zM896.4 765H127.6c-35.4 0-64.1 28.7-64.1 64.1s28.7 64.1 64.1 64.1h768.9c35.4 0 64.1-28.7 64.1-64.1S931.8 765 896.4 765zM127.6 576.1H512c35.4 0 64.1-28.7 64.1-64.1s-28.7-64-64.1-64H127.6c-35.4 0-64.1 28.7-64.1 64.1s28.7 64 64.1 64zM938.8 477l-159.1-88.4c-28.2-15.6-62.8 4.7-62.7 36.9v176.7c0 32.2 34.6 52.6 62.8 36.9l159.1-88.4c28.8-15.9 28.8-57.6-0.1-73.7z'
|
||||
fill='#BD1A2D'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
// 首页静态小红
|
||||
export const XiaohongAvatarIcon: React.FC<IconImgProps> = ({
|
||||
className = "",
|
||||
className = '',
|
||||
size = 240,
|
||||
width,
|
||||
height,
|
||||
...props
|
||||
}) => (
|
||||
<img
|
||||
alt="AI小红"
|
||||
alt='AI小红'
|
||||
className={className}
|
||||
height={size || height}
|
||||
src="/xiaohong.jpg"
|
||||
src='/xiaohong.jpg'
|
||||
width={size || width}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
// 静音图标
|
||||
export const VolumeIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
@@ -258,20 +258,20 @@ export const VolumeIcon: React.FC<IconSvgProps> = ({
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
className="icon"
|
||||
className='icon'
|
||||
height={size || height}
|
||||
version="1.1"
|
||||
viewBox="0 0 1024 1024"
|
||||
version='1.1'
|
||||
viewBox='0 0 1024 1024'
|
||||
width={size || width}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M670.293333 752.213333a42.453333 42.453333 0 0 1-36.266666-64.426666C665.6 634.026667 682.666667 573.44 682.666667 512s-17.066667-122.026667-49.066667-175.36c-11.946667-20.053333-5.546667-46.506667 14.506667-58.453333a42.453333 42.453333 0 0 1 58.453333 14.506666C746.666667 359.253333 768 435.2 768 512s-21.333333 152.746667-61.013333 219.306667c-8.106667 13.226667-22.186667 20.906667-36.693334 20.906666zM816.64 839.68a42.453333 42.453333 0 0 1-36.266667-64.426667c48.213333-79.786667 73.386667-170.666667 73.386667-263.253333s-25.173333-183.04-73.386667-263.253333a42.410667 42.410667 0 1 1 72.533334-43.946667C909.226667 298.24 938.666667 404.48 938.666667 512s-29.44 213.76-85.76 307.2c-7.68 13.226667-21.76 20.48-36.266667 20.48zM499.626667 921.6c-14.08 0-28.16-5.546667-38.826667-16.213333l-168.96-168.96a36.437333 36.437333 0 0 0-26.453333-11.093334H170.666667c-70.4 0-128-57.6-128-128v-170.666666c0-70.4 57.6-128 128-128h94.72c10.24 0 19.626667-3.84 26.88-11.093334l168.96-168.96c15.786667-15.786667 39.253333-20.48 59.733333-11.946666 20.48 8.533333 33.706667 28.586667 33.706667 50.773333v709.12c0 22.186667-13.226667 42.24-33.706667 50.773333-6.826667 2.56-14.08 4.266667-21.333333 4.266667z m21.76-76.373333zM170.666667 384c-23.466667 0-42.666667 19.2-42.666667 42.666667v170.666666c0 23.466667 19.2 42.666667 42.666667 42.666667h94.72c32.853333 0 64 12.8 87.466666 36.266667L469.333333 793.173333V230.826667L352.426667 347.733333C328.96 371.2 298.24 384 265.386667 384H170.666667z"
|
||||
fill="#ffffff"
|
||||
d='M670.293333 752.213333a42.453333 42.453333 0 0 1-36.266666-64.426666C665.6 634.026667 682.666667 573.44 682.666667 512s-17.066667-122.026667-49.066667-175.36c-11.946667-20.053333-5.546667-46.506667 14.506667-58.453333a42.453333 42.453333 0 0 1 58.453333 14.506666C746.666667 359.253333 768 435.2 768 512s-21.333333 152.746667-61.013333 219.306667c-8.106667 13.226667-22.186667 20.906667-36.693334 20.906666zM816.64 839.68a42.453333 42.453333 0 0 1-36.266667-64.426667c48.213333-79.786667 73.386667-170.666667 73.386667-263.253333s-25.173333-183.04-73.386667-263.253333a42.410667 42.410667 0 1 1 72.533334-43.946667C909.226667 298.24 938.666667 404.48 938.666667 512s-29.44 213.76-85.76 307.2c-7.68 13.226667-21.76 20.48-36.266667 20.48zM499.626667 921.6c-14.08 0-28.16-5.546667-38.826667-16.213333l-168.96-168.96a36.437333 36.437333 0 0 0-26.453333-11.093334H170.666667c-70.4 0-128-57.6-128-128v-170.666666c0-70.4 57.6-128 128-128h94.72c10.24 0 19.626667-3.84 26.88-11.093334l168.96-168.96c15.786667-15.786667 39.253333-20.48 59.733333-11.946666 20.48 8.533333 33.706667 28.586667 33.706667 50.773333v709.12c0 22.186667-13.226667 42.24-33.706667 50.773333-6.826667 2.56-14.08 4.266667-21.333333 4.266667z m21.76-76.373333zM170.666667 384c-23.466667 0-42.666667 19.2-42.666667 42.666667v170.666666c0 23.466667 19.2 42.666667 42.666667 42.666667h94.72c32.853333 0 64 12.8 87.466666 36.266667L469.333333 793.173333V230.826667L352.426667 347.733333C328.96 371.2 298.24 384 265.386667 384H170.666667z'
|
||||
fill='#ffffff'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
// 取消静音图标
|
||||
export const VolumeXIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
@@ -280,20 +280,20 @@ export const VolumeXIcon: React.FC<IconSvgProps> = ({
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
className="icon"
|
||||
className='icon'
|
||||
height={size || height}
|
||||
version="1.1"
|
||||
viewBox="0 0 1024 1024"
|
||||
version='1.1'
|
||||
viewBox='0 0 1024 1024'
|
||||
width={size || width}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M170.666667 298.666667l85.162666-0.213334L455.253333 139.093333q31.914667-20.906667 65.493334-2.730666 33.578667 18.133333 34.048 56.32v638.677333q0 38.144-33.536 56.32-33.578667 18.133333-65.493334-2.773333l-199.466666-158.976-85.162667 0.256q-35.370667 0-60.330667-25.002667-25.002667-25.002667-25.514666-60.330667V384q0-35.328 25.002666-60.330667Q135.338667 298.709333 170.666667 298.666667z m110.592 85.12L170.666667 384l0.512 256.853333 110.592-0.256 187.733333 151.338667-0.512-559.829333-187.733333 151.68z m403.541333-42.709334a42.666667 42.666667 0 1 0-60.330667 60.330667l90.496 90.496-90.453333 90.538667a42.666667 42.666667 0 0 0 60.288 60.330666l90.538667-90.538666 90.496 90.538666a42.666667 42.666667 0 0 0 60.330666-60.330666l-90.496-90.538667 90.496-90.496a42.666667 42.666667 0 0 0-60.330666-60.330667l-90.496 90.496-90.538667-90.496z"
|
||||
fill="#ffffff"
|
||||
d='M170.666667 298.666667l85.162666-0.213334L455.253333 139.093333q31.914667-20.906667 65.493334-2.730666 33.578667 18.133333 34.048 56.32v638.677333q0 38.144-33.536 56.32-33.578667 18.133333-65.493334-2.773333l-199.466666-158.976-85.162667 0.256q-35.370667 0-60.330667-25.002667-25.002667-25.002667-25.514666-60.330667V384q0-35.328 25.002666-60.330667Q135.338667 298.709333 170.666667 298.666667z m110.592 85.12L170.666667 384l0.512 256.853333 110.592-0.256 187.733333 151.338667-0.512-559.829333-187.733333 151.68z m403.541333-42.709334a42.666667 42.666667 0 1 0-60.330667 60.330667l90.496 90.496-90.453333 90.538667a42.666667 42.666667 0 0 0 60.288 60.330666l90.538667-90.538666 90.496 90.538666a42.666667 42.666667 0 0 0 60.330666-60.330666l-90.496-90.538667 90.496-90.496a42.666667 42.666667 0 0 0-60.330666-60.330667l-90.496 90.496-90.538667-90.496z'
|
||||
fill='#ffffff'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 历史记录图标
|
||||
export const HistoryIcon: React.FC<IconSvgProps> = ({
|
||||
@@ -303,20 +303,20 @@ export const HistoryIcon: React.FC<IconSvgProps> = ({
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
className="icon"
|
||||
className='icon'
|
||||
height={size || height}
|
||||
version="1.1"
|
||||
viewBox="0 0 1117 1024"
|
||||
version='1.1'
|
||||
viewBox='0 0 1117 1024'
|
||||
width={size || width}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M607.837091 0C888.645818 0 1117.090909 228.445091 1117.090909 509.253818s-228.445091 509.253818-509.253818 509.253818c-184.413091 0-354.816-100.072727-444.881455-261.073454l66.466909-37.096727a433.943273 433.943273 0 0 0 378.414546 222.114909c238.778182 0 433.198545-194.373818 433.198545-433.198546 0-238.778182-194.280727-433.198545-433.198545-433.198545-210.664727 0-386.56 151.086545-425.285818 350.487272l36.538182-34.816 54.644363 52.13091-136.797091 130.56L0 443.857455l54.784-52.13091 48.733091 46.545455C138.146909 190.882909 351.138909 0 607.837091 0z m-6.190546 229.981091v279.226182l219.741091 192.139636-52.317091 54.318546-244.82909-213.969455V229.934545h77.40509z"
|
||||
fill="#ffffff"
|
||||
d='M607.837091 0C888.645818 0 1117.090909 228.445091 1117.090909 509.253818s-228.445091 509.253818-509.253818 509.253818c-184.413091 0-354.816-100.072727-444.881455-261.073454l66.466909-37.096727a433.943273 433.943273 0 0 0 378.414546 222.114909c238.778182 0 433.198545-194.373818 433.198545-433.198546 0-238.778182-194.280727-433.198545-433.198545-433.198545-210.664727 0-386.56 151.086545-425.285818 350.487272l36.538182-34.816 54.644363 52.13091-136.797091 130.56L0 443.857455l54.784-52.13091 48.733091 46.545455C138.146909 190.882909 351.138909 0 607.837091 0z m-6.190546 229.981091v279.226182l219.741091 192.139636-52.317091 54.318546-244.82909-213.969455V229.934545h77.40509z'
|
||||
fill='#ffffff'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
// 开启新对话图标
|
||||
export const NewIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
@@ -325,20 +325,20 @@ export const NewIcon: React.FC<IconSvgProps> = ({
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
className="icon"
|
||||
className='icon'
|
||||
height={size || height}
|
||||
version="1.1"
|
||||
viewBox="0 0 1024 1024"
|
||||
version='1.1'
|
||||
viewBox='0 0 1024 1024'
|
||||
width={size || width}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M864.03701 62.537a47.982 47.982 0 0 0-96.037 0V174.52H656.01801a47.982 47.982 0 0 0 0 96.036H768.00001v111.982a47.982 47.982 0 1 0 96.037 0V270.63h111.908a47.982 47.982 0 0 0 0-96.037H864.03701V62.61zM0.00001 270.63A175.982 175.982 0 0 1 175.98201 94.647h288.036a47.982 47.982 0 0 1 0 95.963H175.98201c-44.179 0-80.019 35.84-80.019 80.019v351.817c0 44.178 35.84 80.018 80.019 80.018a175.982 175.982 0 0 1 175.981 175.982v12.141l136.631-136.484a175.982 175.982 0 0 1 124.416-51.566h202.972c44.178 0 80.018-35.84 80.018-80.018v-95.89a47.982 47.982 0 1 1 96.037 0v95.817A175.982 175.982 0 0 1 815.98201 798.5H613.01001a79.94 79.94 0 0 0-56.54 23.405l-163.84 163.84c-50.468 50.469-136.63 14.775-136.63-56.54v-50.76c0-44.179-35.84-79.945-80.018-79.945A175.982 175.982 0 0 1 0.00001 622.446V270.629z"
|
||||
fill="#BD1A2D"
|
||||
d='M864.03701 62.537a47.982 47.982 0 0 0-96.037 0V174.52H656.01801a47.982 47.982 0 0 0 0 96.036H768.00001v111.982a47.982 47.982 0 1 0 96.037 0V270.63h111.908a47.982 47.982 0 0 0 0-96.037H864.03701V62.61zM0.00001 270.63A175.982 175.982 0 0 1 175.98201 94.647h288.036a47.982 47.982 0 0 1 0 95.963H175.98201c-44.179 0-80.019 35.84-80.019 80.019v351.817c0 44.178 35.84 80.018 80.019 80.018a175.982 175.982 0 0 1 175.981 175.982v12.141l136.631-136.484a175.982 175.982 0 0 1 124.416-51.566h202.972c44.178 0 80.018-35.84 80.018-80.018v-95.89a47.982 47.982 0 1 1 96.037 0v95.817A175.982 175.982 0 0 1 815.98201 798.5H613.01001a79.94 79.94 0 0 0-56.54 23.405l-163.84 163.84c-50.468 50.469-136.63 14.775-136.63-56.54v-50.76c0-44.179-35.84-79.945-80.018-79.945A175.982 175.982 0 0 1 0.00001 622.446V270.629z'
|
||||
fill='#BD1A2D'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
// 结束对话图标
|
||||
export const EndIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
@@ -347,20 +347,20 @@ export const EndIcon: React.FC<IconSvgProps> = ({
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
className="icon"
|
||||
className='icon'
|
||||
height={size || height}
|
||||
version="1.1"
|
||||
viewBox="0 0 1024 1024"
|
||||
version='1.1'
|
||||
viewBox='0 0 1024 1024'
|
||||
width={size || width}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M470.947608 714.141034l37.529508-36.960878c39.178531 24.905946 85.123746 41.623635 145.512135 42.078538l57.715833-56.919752c26.896147-26.611832 64.198203-22.347116 96.894365-0.625492l192.878922 142.384676c32.354984 21.949076 26.327518 69.088411-0.625491 95.700244l-97.519857 96.325735c-9.496103 9.325514-246.728079 65.733501-542.187655-173.772991l48.560908-47.878553m-323.322392 239.222177a33.890282 33.890282 0 0 1-47.253062 0 32.582436 32.582436 0 0 1 0-46.513843L759.128735 257.816343a33.719694 33.719694 0 0 1 47.196198 0 32.582436 32.582436 0 0 1 0 46.513844L86.383884 1013.694738z m187.988714-357.667577l-43.898151 43.215796C-60.492961 384.222547 2.738573 126.235616 12.689579 116.398336L110.209435 20.186326A69.145274 69.145274 0 0 1 207.103799 19.503971l144.147426 190.490681c22.005938 32.298121 26.327518 69.145274-0.625492 95.757107l-59.706034 58.909954c9.666691 86.090415 40.429515 141.929773 84.441392 191.855391l-43.784426 43.10207"
|
||||
fill="#ffffff"
|
||||
d='M470.947608 714.141034l37.529508-36.960878c39.178531 24.905946 85.123746 41.623635 145.512135 42.078538l57.715833-56.919752c26.896147-26.611832 64.198203-22.347116 96.894365-0.625492l192.878922 142.384676c32.354984 21.949076 26.327518 69.088411-0.625491 95.700244l-97.519857 96.325735c-9.496103 9.325514-246.728079 65.733501-542.187655-173.772991l48.560908-47.878553m-323.322392 239.222177a33.890282 33.890282 0 0 1-47.253062 0 32.582436 32.582436 0 0 1 0-46.513843L759.128735 257.816343a33.719694 33.719694 0 0 1 47.196198 0 32.582436 32.582436 0 0 1 0 46.513844L86.383884 1013.694738z m187.988714-357.667577l-43.898151 43.215796C-60.492961 384.222547 2.738573 126.235616 12.689579 116.398336L110.209435 20.186326A69.145274 69.145274 0 0 1 207.103799 19.503971l144.147426 190.490681c22.005938 32.298121 26.327518 69.145274-0.625492 95.757107l-59.706034 58.909954c9.666691 86.090415 40.429515 141.929773 84.441392 191.855391l-43.784426 43.10207'
|
||||
fill='#ffffff'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
// 键盘图标
|
||||
export const KeyboardIcon: React.FC<IconSvgProps> = ({
|
||||
size = 24,
|
||||
@@ -369,36 +369,36 @@ export const KeyboardIcon: React.FC<IconSvgProps> = ({
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
className="icon"
|
||||
className='icon'
|
||||
height={size || height}
|
||||
version="1.1"
|
||||
viewBox="0 0 1024 1024"
|
||||
version='1.1'
|
||||
viewBox='0 0 1024 1024'
|
||||
width={size || width}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M512 64A448 448 0 1 0 960 512 448.5 448.5 0 0 0 512 64z m0 832a384 384 0 1 1 384-384 384.5 384.5 0 0 1-384 384z"
|
||||
fill="#BD1A2D"
|
||||
d='M512 64A448 448 0 1 0 960 512 448.5 448.5 0 0 0 512 64z m0 832a384 384 0 1 1 384-384 384.5 384.5 0 0 1-384 384z'
|
||||
fill='#BD1A2D'
|
||||
/>
|
||||
<path
|
||||
d="M320 400m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"
|
||||
fill="#BD1A2D"
|
||||
d='M320 400m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z'
|
||||
fill='#BD1A2D'
|
||||
/>
|
||||
<path
|
||||
d="M448 448A48 48 0 1 0 400 400a48 48 0 0 0 48 48zM576 352a48 48 0 1 0 48 48 48 48 0 0 0-48-48zM704 352a48 48 0 1 0 48 48 48 48 0 0 0-48-48z"
|
||||
fill="#BD1A2D"
|
||||
d='M448 448A48 48 0 1 0 400 400a48 48 0 0 0 48 48zM576 352a48 48 0 1 0 48 48 48 48 0 0 0-48-48zM704 352a48 48 0 1 0 48 48 48 48 0 0 0-48-48z'
|
||||
fill='#BD1A2D'
|
||||
/>
|
||||
<path
|
||||
d="M320 528m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z"
|
||||
fill="#BD1A2D"
|
||||
d='M320 528m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z'
|
||||
fill='#BD1A2D'
|
||||
/>
|
||||
<path
|
||||
d="M448 576a48 48 0 1 0-48-48 48 48 0 0 0 48 48zM576 640H448a48 48 0 0 0 0 96h128a48 48 0 1 0 0-96zM576 480a48 48 0 1 0 48 48 48 48 0 0 0-48-48zM704 480a48 48 0 1 0 48 48 48 48 0 0 0-48-48z"
|
||||
fill="#BD1A2D"
|
||||
d='M448 576a48 48 0 1 0-48-48 48 48 0 0 0 48 48zM576 640H448a48 48 0 0 0 0 96h128a48 48 0 1 0 0-96zM576 480a48 48 0 1 0 48 48 48 48 0 0 0-48-48zM704 480a48 48 0 1 0 48 48 48 48 0 0 0-48-48z'
|
||||
fill='#BD1A2D'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 音量关闭图标
|
||||
|
||||
@@ -412,18 +412,18 @@ export const SendIcon: React.FC<IconSvgProps> = ({
|
||||
<svg
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
<path d='m22 2-7 20-4-9-9-4Z' />
|
||||
<path d='M22 2 11 13' />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 麦克风图标
|
||||
export const MicIcon: React.FC<IconSvgProps> = ({
|
||||
@@ -435,19 +435,19 @@ export const MicIcon: React.FC<IconSvgProps> = ({
|
||||
<svg
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" x2="12" y1="19" y2="22" />
|
||||
<path d='M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z' />
|
||||
<path d='M19 10v2a7 7 0 0 1-14 0v-2' />
|
||||
<line x1='12' x2='12' y1='19' y2='22' />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 麦克风关闭图标
|
||||
export const MicOffIcon: React.FC<IconSvgProps> = ({
|
||||
@@ -459,22 +459,22 @@ export const MicOffIcon: React.FC<IconSvgProps> = ({
|
||||
<svg
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<line x1="2" x2="22" y1="2" y2="22" />
|
||||
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
|
||||
<path d="M5 10v2a7 7 0 0 0 12 5" />
|
||||
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
|
||||
<line x1="12" x2="12" y1="19" y2="22" />
|
||||
<line x1='2' x2='22' y1='2' y2='22' />
|
||||
<path d='M18.89 13.23A7.12 7.12 0 0 0 19 12v-2' />
|
||||
<path d='M5 10v2a7 7 0 0 0 12 5' />
|
||||
<path d='M15 9.34V5a3 3 0 0 0-5.68-1.33' />
|
||||
<path d='M9 9v3a3 3 0 0 0 5.12 2.12' />
|
||||
<line x1='12' x2='12' y1='19' y2='22' />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 电话挂断图标
|
||||
export const PhoneOffIcon: React.FC<IconSvgProps> = ({
|
||||
@@ -486,18 +486,18 @@ export const PhoneOffIcon: React.FC<IconSvgProps> = ({
|
||||
<svg
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91" />
|
||||
<line x1="22" x2="2" y1="2" y2="22" />
|
||||
<path d='M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91' />
|
||||
<line x1='22' x2='2' y1='2' y2='22' />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 更多操作图标(三个点)
|
||||
export const MoreIcon: React.FC<IconSvgProps> = ({
|
||||
@@ -509,19 +509,19 @@ export const MoreIcon: React.FC<IconSvgProps> = ({
|
||||
<svg
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
<circle cx='12' cy='12' r='1' />
|
||||
<circle cx='19' cy='12' r='1' />
|
||||
<circle cx='5' cy='12' r='1' />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 箭头向左图标
|
||||
export const ArrowLeftIcon: React.FC<IconSvgProps> = ({
|
||||
@@ -533,18 +533,18 @@ export const ArrowLeftIcon: React.FC<IconSvgProps> = ({
|
||||
<svg
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
<path d='m12 19-7-7 7-7' />
|
||||
<path d='M19 12H5' />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 删除图标
|
||||
export const DeleteIcon: React.FC<IconSvgProps> = ({
|
||||
@@ -556,21 +556,21 @@ export const DeleteIcon: React.FC<IconSvgProps> = ({
|
||||
<svg
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
<path d='M3 6h18' />
|
||||
<path d='M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6' />
|
||||
<path d='M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2' />
|
||||
<line x1='10' x2='10' y1='11' y2='17' />
|
||||
<line x1='14' x2='14' y1='11' y2='17' />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 重连按钮
|
||||
export const ReconnectIcon: React.FC<IconSvgProps> = ({
|
||||
@@ -582,18 +582,18 @@ export const ReconnectIcon: React.FC<IconSvgProps> = ({
|
||||
<svg
|
||||
width={size || width}
|
||||
height={size || height}
|
||||
viewBox="0 0 1066 1024"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox='0 0 1066 1024'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M468.693333 687.317333l-63.701333-63.744 254.890667-254.890666 63.701333 63.744z"
|
||||
fill="#ffffff"
|
||||
d='M468.693333 687.317333l-63.701333-63.744 254.890667-254.890666 63.701333 63.744z'
|
||||
fill='#ffffff'
|
||||
/>
|
||||
<path
|
||||
d="M691.797333 740.48l-0.170666-0.170667a44.970667 44.970667 0 0 0-63.573334 0l-53.162666 53.162667a187.733333 187.733333 0 0 1-265.386667-265.472L362.666667 474.88a44.928 44.928 0 0 0 0-63.573333l-0.170667-0.170667a44.970667 44.970667 0 0 0-63.573333 0l-53.248 53.205333a277.888 277.888 0 0 0 392.96 392.96l53.162666-53.205333a44.928 44.928 0 0 0 0-63.573333z m201.813334-531.072a277.845333 277.845333 0 0 0-393.002667 0l-53.248 53.162667a44.970667 44.970667 0 0 0 0 63.573333l0.170667 0.170667a44.928 44.928 0 0 0 63.573333 0l53.205333-53.12a187.733333 187.733333 0 1 1 265.472 265.514666l-53.162666 53.205334a44.970667 44.970667 0 0 0 0 63.530666l0.170666 0.170667a44.928 44.928 0 0 0 63.573334 0l53.205333-53.290667a277.845333 277.845333 0 0 0 0-392.96z"
|
||||
fill="#ffffff"
|
||||
d='M691.797333 740.48l-0.170666-0.170667a44.970667 44.970667 0 0 0-63.573334 0l-53.162666 53.162667a187.733333 187.733333 0 0 1-265.386667-265.472L362.666667 474.88a44.928 44.928 0 0 0 0-63.573333l-0.170667-0.170667a44.970667 44.970667 0 0 0-63.573333 0l-53.248 53.205333a277.888 277.888 0 0 0 392.96 392.96l53.162666-53.205333a44.928 44.928 0 0 0 0-63.573333z m201.813334-531.072a277.845333 277.845333 0 0 0-393.002667 0l-53.248 53.162667a44.970667 44.970667 0 0 0 0 63.573333l0.170667 0.170667a44.928 44.928 0 0 0 63.573333 0l53.205333-53.12a187.733333 187.733333 0 1 1 265.472 265.514666l-53.162666 53.205334a44.970667 44.970667 0 0 0 0 63.530666l0.170666 0.170667a44.928 44.928 0 0 0 63.573334 0l53.205333-53.290667a277.845333 277.845333 0 0 0 0-392.96z'
|
||||
fill='#ffffff'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Navbar as HeroUINavbar,
|
||||
NavbarContent,
|
||||
NavbarItem,
|
||||
} from "@heroui/navbar";
|
||||
import NextLink from "next/link";
|
||||
import { Button, Chip } from "@heroui/react";
|
||||
} from '@heroui/navbar'
|
||||
import NextLink from 'next/link'
|
||||
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { connectionColor, connectionStatus } from "@/lib/useRealtimeConnEffect";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import useRealtimeConnEffect from "@/lib/useRealtimeConnEffect";
|
||||
import { usePathname } from '@/i18n/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { connectionColor, connectionStatus } from '@/lib/useRealtimeConnEffect'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
import useRealtimeConnEffect from '@/lib/useRealtimeConnEffect'
|
||||
import { Button } from '@heroui/button'
|
||||
import { Chip } from '@heroui/chip'
|
||||
|
||||
export const Navbar = () => {
|
||||
const { wsInstance } = useConversationStore();
|
||||
const { wsInstance } = useConversationStore()
|
||||
|
||||
const pathname = usePathname();
|
||||
const pathname = usePathname()
|
||||
|
||||
// useRealtimeConn 这个 hook 用于实例化 WebSocket,必须放置在路由之外
|
||||
useRealtimeConnEffect();
|
||||
useRealtimeConnEffect()
|
||||
|
||||
return (
|
||||
<HeroUINavbar className="bg-primary" maxWidth="full" position="sticky">
|
||||
<NavbarContent className="w-full" justify="start">
|
||||
<ul className="flex justify-center items-center gap-x-4">
|
||||
<HeroUINavbar className='bg-primary' maxWidth='full' position='sticky'>
|
||||
<NavbarContent className='w-full' justify='start'>
|
||||
<ul className='flex items-center justify-center gap-x-4'>
|
||||
<NavbarItem>
|
||||
<Button
|
||||
as={NextLink}
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
pathname === "/"
|
||||
? "bg-white text-primary"
|
||||
: "bg-transparent text-white"
|
||||
'font-semibold',
|
||||
pathname === '/'
|
||||
? 'text-primary bg-white'
|
||||
: 'bg-transparent text-white'
|
||||
)}
|
||||
href={"/"}
|
||||
variant="solid"
|
||||
href={'/'}
|
||||
variant='solid'
|
||||
>
|
||||
AI 小红
|
||||
</Button>
|
||||
@@ -45,13 +46,13 @@ export const Navbar = () => {
|
||||
<Button
|
||||
as={NextLink}
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
pathname === "/investment"
|
||||
? "bg-white text-primary"
|
||||
: "bg-transparent text-white"
|
||||
'font-semibold',
|
||||
pathname === '/investment'
|
||||
? 'text-primary bg-white'
|
||||
: 'bg-transparent text-white'
|
||||
)}
|
||||
href="/investment"
|
||||
variant="solid"
|
||||
href='/investment'
|
||||
variant='solid'
|
||||
>
|
||||
招商成果
|
||||
</Button>
|
||||
@@ -60,26 +61,26 @@ export const Navbar = () => {
|
||||
<Button
|
||||
as={NextLink}
|
||||
className={cn(
|
||||
"text-white font-semibold",
|
||||
pathname === "/contact"
|
||||
? "bg-white text-primary"
|
||||
: "bg-transparent text-white"
|
||||
'font-semibold text-white',
|
||||
pathname === '/contact'
|
||||
? 'text-primary bg-white'
|
||||
: 'bg-transparent text-white'
|
||||
)}
|
||||
href="/contact"
|
||||
variant="solid"
|
||||
href='/contact'
|
||||
variant='solid'
|
||||
>
|
||||
联系我们
|
||||
</Button>
|
||||
</NavbarItem>
|
||||
</ul>
|
||||
</NavbarContent>
|
||||
<NavbarContent className="w-full" justify="end">
|
||||
<NavbarContent className='w-full' justify='end'>
|
||||
{wsInstance && (
|
||||
<NavbarItem>
|
||||
<Chip
|
||||
color={connectionColor[wsInstance.readyState]}
|
||||
size="sm"
|
||||
variant="solid"
|
||||
size='sm'
|
||||
variant='solid'
|
||||
>
|
||||
调试:{connectionStatus[wsInstance?.readyState]}
|
||||
</Chip>
|
||||
@@ -87,5 +88,5 @@ export const Navbar = () => {
|
||||
)}
|
||||
</NavbarContent>
|
||||
</HeroUINavbar>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import { tv } from "tailwind-variants";
|
||||
import { tv } from 'tailwind-variants'
|
||||
|
||||
export const title = tv({
|
||||
base: "tracking-tight inline font-semibold",
|
||||
base: 'tracking-tight inline font-semibold',
|
||||
variants: {
|
||||
color: {
|
||||
violet: "from-[#FF1CF7] to-[#b249f8]",
|
||||
yellow: "from-[#FF705B] to-[#FFB457]",
|
||||
blue: "from-[#5EA2EF] to-[#0072F5]",
|
||||
cyan: "from-[#00b7fa] to-[#01cfea]",
|
||||
green: "from-[#6FEE8D] to-[#17c964]",
|
||||
pink: "from-[#FF72E1] to-[#F54C7A]",
|
||||
foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]",
|
||||
violet: 'from-[#FF1CF7] to-[#b249f8]',
|
||||
yellow: 'from-[#FF705B] to-[#FFB457]',
|
||||
blue: 'from-[#5EA2EF] to-[#0072F5]',
|
||||
cyan: 'from-[#00b7fa] to-[#01cfea]',
|
||||
green: 'from-[#6FEE8D] to-[#17c964]',
|
||||
pink: 'from-[#FF72E1] to-[#F54C7A]',
|
||||
foreground: 'dark:from-[#FFFFFF] dark:to-[#4B4B4B]',
|
||||
},
|
||||
size: {
|
||||
sm: "text-3xl lg:text-4xl",
|
||||
md: "text-[2.3rem] lg:text-5xl leading-9",
|
||||
lg: "text-4xl lg:text-6xl",
|
||||
sm: 'text-3xl lg:text-4xl',
|
||||
md: 'text-[2.3rem] lg:text-5xl leading-9',
|
||||
lg: 'text-4xl lg:text-6xl',
|
||||
},
|
||||
fullWidth: {
|
||||
true: "w-full block",
|
||||
true: 'w-full block',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
size: 'md',
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
color: [
|
||||
"violet",
|
||||
"yellow",
|
||||
"blue",
|
||||
"cyan",
|
||||
"green",
|
||||
"pink",
|
||||
"foreground",
|
||||
'violet',
|
||||
'yellow',
|
||||
'blue',
|
||||
'cyan',
|
||||
'green',
|
||||
'pink',
|
||||
'foreground',
|
||||
],
|
||||
class: "bg-clip-text text-transparent bg-gradient-to-b",
|
||||
class: 'bg-clip-text text-transparent bg-gradient-to-b',
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
export const subtitle = tv({
|
||||
base: "w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full",
|
||||
base: 'w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full',
|
||||
variants: {
|
||||
fullWidth: {
|
||||
true: "!w-full",
|
||||
true: '!w-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
fullWidth: true,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import type { FC } from "react";
|
||||
import { VisuallyHidden } from "@react-aria/visually-hidden";
|
||||
import { type SwitchProps, useSwitch } from "@heroui/switch";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useIsSSR } from "@react-aria/ssr";
|
||||
import type { FC } from 'react'
|
||||
import { VisuallyHidden } from '@react-aria/visually-hidden'
|
||||
import { type SwitchProps, useSwitch } from '@heroui/switch'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useIsSSR } from '@react-aria/ssr'
|
||||
|
||||
import { SunFilledIcon, MoonFilledIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SunFilledIcon, MoonFilledIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ThemeSwitchProps {
|
||||
className?: string;
|
||||
classNames?: SwitchProps["classNames"];
|
||||
className?: string
|
||||
classNames?: SwitchProps['classNames']
|
||||
}
|
||||
|
||||
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
|
||||
className,
|
||||
classNames,
|
||||
}) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isSSR = useIsSSR();
|
||||
const { theme, setTheme } = useTheme()
|
||||
const isSSR = useIsSSR()
|
||||
|
||||
const onChange = () => {
|
||||
return theme === "light" ? setTheme("dark") : setTheme("light");
|
||||
};
|
||||
return theme === 'light' ? setTheme('dark') : setTheme('light')
|
||||
}
|
||||
|
||||
const {
|
||||
Component,
|
||||
@@ -33,16 +33,16 @@ export const ThemeSwitch: FC<ThemeSwitchProps> = ({
|
||||
getInputProps,
|
||||
getWrapperProps,
|
||||
} = useSwitch({
|
||||
isSelected: theme === "light" || isSSR,
|
||||
"aria-label": `Switch to ${theme === "light" || isSSR ? "dark" : "light"} mode`,
|
||||
isSelected: theme === 'light' || isSSR,
|
||||
'aria-label': `Switch to ${theme === 'light' || isSSR ? 'dark' : 'light'} mode`,
|
||||
onChange,
|
||||
});
|
||||
})
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...getBaseProps({
|
||||
className: cn(
|
||||
"px-px transition-opacity hover:opacity-80 cursor-pointer",
|
||||
'px-px transition-opacity hover:opacity-80 cursor-pointer',
|
||||
className,
|
||||
classNames?.base
|
||||
),
|
||||
@@ -56,15 +56,15 @@ export const ThemeSwitch: FC<ThemeSwitchProps> = ({
|
||||
className={slots.wrapper({
|
||||
class: cn(
|
||||
[
|
||||
"w-auto h-auto",
|
||||
"bg-transparent",
|
||||
"rounded-lg",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[selected=true]:bg-transparent",
|
||||
"!text-default-500",
|
||||
"pt-px",
|
||||
"px-0",
|
||||
"mx-0",
|
||||
'h-auto w-auto',
|
||||
'bg-transparent',
|
||||
'rounded-lg',
|
||||
'flex items-center justify-center',
|
||||
'group-data-[selected=true]:bg-transparent',
|
||||
'!text-default-500',
|
||||
'pt-px',
|
||||
'px-0',
|
||||
'mx-0',
|
||||
],
|
||||
classNames?.wrapper
|
||||
),
|
||||
@@ -77,5 +77,5 @@ export const ThemeSwitch: FC<ThemeSwitchProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Fira_Code as FontMono, Inter as FontSans } from "next/font/google";
|
||||
import { Fira_Code as FontMono, Inter as FontSans } from 'next/font/google'
|
||||
|
||||
export const fontSans = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
})
|
||||
|
||||
export const fontMono = FontMono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
});
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
})
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
export type SiteConfig = typeof siteConfig;
|
||||
export type SiteConfig = typeof siteConfig
|
||||
|
||||
export const siteConfig = {
|
||||
name: "红河州工商联政务通",
|
||||
description: "红河州工商联政务通",
|
||||
name: '红河州工商联政务通',
|
||||
description: '红河州工商联政务通',
|
||||
routes: {
|
||||
chat: {
|
||||
label: "Chat",
|
||||
href: "/chat",
|
||||
label: 'Chat',
|
||||
href: '/chat',
|
||||
},
|
||||
about: {
|
||||
label: "About",
|
||||
href: "/about",
|
||||
label: 'About',
|
||||
href: '/about',
|
||||
},
|
||||
privacyPolicy: {
|
||||
label: "Privacy Policy",
|
||||
href: "/privacy",
|
||||
label: 'Privacy Policy',
|
||||
href: '/privacy',
|
||||
},
|
||||
termsOfUse: {
|
||||
label: "Terms of Use",
|
||||
href: "/terms",
|
||||
label: 'Terms of Use',
|
||||
href: '/terms',
|
||||
},
|
||||
playground: {
|
||||
label: "Playground",
|
||||
href: "/playground",
|
||||
label: 'Playground',
|
||||
href: '/playground',
|
||||
},
|
||||
svenDebug: {
|
||||
label: "SvenDebug",
|
||||
href: "/svenDebug",
|
||||
label: 'SvenDebug',
|
||||
href: '/svenDebug',
|
||||
},
|
||||
},
|
||||
links: {
|
||||
github: "https://github.com/heroui-inc/heroui",
|
||||
twitter: "https://twitter.com/hero_ui",
|
||||
docs: "https://heroui.com",
|
||||
discord: "https://discord.gg/9b6yyZKmH4",
|
||||
sponsor: "https://patreon.com/jrgarciadev",
|
||||
github: 'https://github.com/heroui-inc/heroui',
|
||||
twitter: 'https://twitter.com/hero_ui',
|
||||
docs: 'https://heroui.com',
|
||||
discord: 'https://discord.gg/9b6yyZKmH4',
|
||||
sponsor: 'https://patreon.com/jrgarciadev',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
@@ -10,9 +10,9 @@ export function useIsMobile() {
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
mql.addEventListener('change', onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createNavigation } from "next-intl/navigation";
|
||||
import { createNavigation } from 'next-intl/navigation'
|
||||
|
||||
import { routing } from "./routing";
|
||||
import { routing } from './routing'
|
||||
|
||||
export const { Link, getPathname, redirect, usePathname, useRouter } =
|
||||
createNavigation(routing);
|
||||
createNavigation(routing)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { hasLocale } from "next-intl";
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
import { hasLocale } from 'next-intl'
|
||||
|
||||
import { routing } from "./routing";
|
||||
import { routing } from './routing'
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// Typically corresponds to the `[locale]` segment
|
||||
const requested = await requestLocale;
|
||||
const requested = await requestLocale
|
||||
const locale = hasLocale(routing.locales, requested)
|
||||
? requested
|
||||
: routing.defaultLocale;
|
||||
: routing.defaultLocale
|
||||
|
||||
return {
|
||||
locale,
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
import { defineRouting } from 'next-intl/routing'
|
||||
|
||||
export const routing = defineRouting({
|
||||
// A list of all locales that are supported
|
||||
locales: ["en", "zh-CN"],
|
||||
locales: ['en', 'zh-CN'],
|
||||
|
||||
// Used when no locale matches
|
||||
defaultLocale: "en",
|
||||
defaultLocale: 'en',
|
||||
|
||||
localePrefix: "always",
|
||||
});
|
||||
localePrefix: 'always',
|
||||
})
|
||||
|
||||
export type Locale = (typeof routing.locales)[number]
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
function arrayBufferToBase64(arrayBuffer: Int16Array<ArrayBufferLike>) {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
const chunkSize = 0x8000; // 32KB chunk size
|
||||
let binary = ''
|
||||
const bytes = new Uint8Array(arrayBuffer)
|
||||
const chunkSize = 0x8000 // 32KB chunk size
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
const chunk = bytes.subarray(i, i + chunkSize)
|
||||
|
||||
binary += Array.from(chunk)
|
||||
.map((number) => String.fromCharCode(number))
|
||||
.join("");
|
||||
.join('')
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string) {
|
||||
const binaryString = atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
const binaryString = atob(base64)
|
||||
const len = binaryString.length
|
||||
const bytes = new Uint8Array(len)
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function mergeInt16Arrays(
|
||||
@@ -31,63 +31,63 @@ function mergeInt16Arrays(
|
||||
right: Int16Array<ArrayBuffer>
|
||||
) {
|
||||
if (left instanceof ArrayBuffer) {
|
||||
left = new Int16Array(left);
|
||||
left = new Int16Array(left)
|
||||
}
|
||||
if (right instanceof ArrayBuffer) {
|
||||
right = new Int16Array(right);
|
||||
right = new Int16Array(right)
|
||||
}
|
||||
if (!(left instanceof Int16Array) || !(right instanceof Int16Array)) {
|
||||
throw new Error(`Both items must be Int16Array`);
|
||||
throw new Error(`Both items must be Int16Array`)
|
||||
}
|
||||
const newValues = new Int16Array(left.length + right.length);
|
||||
const newValues = new Int16Array(left.length + right.length)
|
||||
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
newValues[i] = left[i];
|
||||
newValues[i] = left[i]
|
||||
}
|
||||
for (let j = 0; j < right.length; j++) {
|
||||
newValues[left.length + j] = right[j];
|
||||
newValues[left.length + j] = right[j]
|
||||
}
|
||||
|
||||
return newValues;
|
||||
return newValues
|
||||
}
|
||||
|
||||
function float32ToInt16(float32Array: Float32Array): Int16Array {
|
||||
const int16Array = new Int16Array(float32Array.length);
|
||||
const int16Array = new Int16Array(float32Array.length)
|
||||
|
||||
for (let i = 0; i < float32Array.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, float32Array[i]));
|
||||
const s = Math.max(-1, Math.min(1, float32Array[i]))
|
||||
|
||||
int16Array[i] = s < 0 ? s * 32768 : s * 32767;
|
||||
int16Array[i] = s < 0 ? s * 32768 : s * 32767
|
||||
}
|
||||
|
||||
return int16Array;
|
||||
return int16Array
|
||||
}
|
||||
|
||||
function splitInt16Array(input: Int16Array, chunkSize: number): Int16Array[] {
|
||||
const result: Int16Array[] = [];
|
||||
const totalLength = input.length;
|
||||
const result: Int16Array[] = []
|
||||
const totalLength = input.length
|
||||
|
||||
for (let i = 0; i < totalLength; i += chunkSize) {
|
||||
const chunk = input.slice(i, i + chunkSize);
|
||||
result.push(chunk);
|
||||
const chunk = input.slice(i, i + chunkSize)
|
||||
result.push(chunk)
|
||||
}
|
||||
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
function splitFloat32Array(
|
||||
input: Float32Array,
|
||||
chunkSize: number
|
||||
): Float32Array[] {
|
||||
const result: Float32Array[] = [];
|
||||
const totalLength = input.length;
|
||||
const result: Float32Array[] = []
|
||||
const totalLength = input.length
|
||||
|
||||
for (let i = 0; i < totalLength; i += chunkSize) {
|
||||
const chunk = input.slice(i, i + chunkSize);
|
||||
result.push(chunk);
|
||||
const chunk = input.slice(i, i + chunkSize)
|
||||
result.push(chunk)
|
||||
}
|
||||
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,8 +99,8 @@ function generateSilencePCM(
|
||||
durationMs: number,
|
||||
sampleRate: number = 24000
|
||||
): Int16Array {
|
||||
const numSamples = Math.max(1, Math.floor((durationMs * sampleRate) / 1000));
|
||||
return new Int16Array(numSamples);
|
||||
const numSamples = Math.max(1, Math.floor((durationMs * sampleRate) / 1000))
|
||||
return new Int16Array(numSamples)
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -111,4 +111,4 @@ export {
|
||||
splitInt16Array,
|
||||
splitFloat32Array,
|
||||
generateSilencePCM,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { paths } from "@/types/openapi";
|
||||
import type { paths } from '@/types/openapi'
|
||||
|
||||
import { Fetcher } from "openapi-typescript-fetch";
|
||||
import { Fetcher } from 'openapi-typescript-fetch'
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_AGENT_ENDPOINT;
|
||||
const xApiKey = process.env.NEXT_PUBLIC_X_API_KEY;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_AGENT_ENDPOINT
|
||||
const xApiKey = process.env.NEXT_PUBLIC_X_API_KEY
|
||||
|
||||
const fetcher = Fetcher.for<paths>();
|
||||
const headers: HeadersInit = {};
|
||||
const fetcher = Fetcher.for<paths>()
|
||||
const headers: HeadersInit = {}
|
||||
|
||||
if (xApiKey) {
|
||||
headers["x-api-key"] = xApiKey;
|
||||
headers['x-api-key'] = xApiKey
|
||||
}
|
||||
|
||||
fetcher.configure({ baseUrl, init: { headers } });
|
||||
fetcher.configure({ baseUrl, init: { headers } })
|
||||
|
||||
export default fetcher;
|
||||
export { fetcher };
|
||||
export default fetcher
|
||||
export { fetcher }
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
export function toInlinePdfUrl(originalUrl: string): string {
|
||||
try {
|
||||
const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
||||
const url = new URL(originalUrl, base);
|
||||
const base =
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: 'http://localhost'
|
||||
const url = new URL(originalUrl, base)
|
||||
|
||||
const isPdfPath = url.pathname.toLowerCase().endsWith(".pdf");
|
||||
const isPdfPath = url.pathname.toLowerCase().endsWith('.pdf')
|
||||
if (!isPdfPath) {
|
||||
return originalUrl;
|
||||
return originalUrl
|
||||
}
|
||||
|
||||
const isAliOss = url.hostname.includes("aliyuncs.com");
|
||||
const isAliOss = url.hostname.includes('aliyuncs.com')
|
||||
if (!isAliOss) {
|
||||
return originalUrl;
|
||||
return originalUrl
|
||||
}
|
||||
|
||||
// Use app proxy to avoid OSS anonymous override limitation
|
||||
const proxied = new URL("/api/pdf", base);
|
||||
proxied.searchParams.set("url", url.toString());
|
||||
return proxied.toString();
|
||||
const proxied = new URL('/api/pdf', base)
|
||||
proxied.searchParams.set('url', url.toString())
|
||||
return proxied.toString()
|
||||
} catch {
|
||||
return originalUrl;
|
||||
return originalUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fetcher } from "./fetcher";
|
||||
import { fetcher } from './fetcher'
|
||||
|
||||
const getAgent = fetcher.path("/agent_id/{agent_id}").method("get").create();
|
||||
const getAgent = fetcher.path('/agent_id/{agent_id}').method('get').create()
|
||||
|
||||
export { getAgent };
|
||||
export { getAgent }
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { create } from "zustand";
|
||||
import { devtools } from "zustand/middleware";
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
|
||||
export interface AgentState {
|
||||
card_id: number;
|
||||
phone_number: string;
|
||||
card_info: string;
|
||||
agent_avatar_url: string;
|
||||
agent_prompt: string;
|
||||
agent_name: string;
|
||||
is_publish: boolean;
|
||||
create_date: string;
|
||||
voice_type: string;
|
||||
temperature: number;
|
||||
card_id: number
|
||||
phone_number: string
|
||||
card_info: string
|
||||
agent_avatar_url: string
|
||||
agent_prompt: string
|
||||
agent_name: string
|
||||
is_publish: boolean
|
||||
create_date: string
|
||||
voice_type: string
|
||||
temperature: number
|
||||
}
|
||||
|
||||
type AgentStore = {
|
||||
agent: Partial<AgentState> | undefined;
|
||||
setAgent: (agent?: AgentState) => void;
|
||||
};
|
||||
agent: Partial<AgentState> | undefined
|
||||
setAgent: (agent?: AgentState) => void
|
||||
}
|
||||
|
||||
const useAgentStore = create<AgentStore>()(
|
||||
devtools((set) => ({
|
||||
agent: undefined,
|
||||
setAgent: (agent) => set({ agent }),
|
||||
}))
|
||||
);
|
||||
)
|
||||
|
||||
export default useAgentStore;
|
||||
export default useAgentStore
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// 对话消息类型
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
// 对话会话类型
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
id: string
|
||||
title: string
|
||||
messages: ChatMessage[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// 对话状态类型
|
||||
export interface ChatState {
|
||||
// 连接状态
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean
|
||||
isConnecting: boolean
|
||||
|
||||
// 语音状态
|
||||
isRecording: boolean;
|
||||
isPlaying: boolean;
|
||||
isMuted: boolean;
|
||||
isRecording: boolean
|
||||
isPlaying: boolean
|
||||
isMuted: boolean
|
||||
|
||||
// 界面状态
|
||||
inputMode: "voice" | "text";
|
||||
sidebarVisible: boolean;
|
||||
inputMode: 'voice' | 'text'
|
||||
sidebarVisible: boolean
|
||||
|
||||
// 会话状态
|
||||
currentSession: ChatSession | null;
|
||||
sessions: ChatSession[];
|
||||
currentSession: ChatSession | null
|
||||
sessions: ChatSession[]
|
||||
|
||||
// 消息状态
|
||||
messages: ChatMessage[];
|
||||
isTyping: boolean;
|
||||
messages: ChatMessage[]
|
||||
isTyping: boolean
|
||||
}
|
||||
|
||||
// Hook配置选项
|
||||
export interface UseChatSessionOptions {
|
||||
autoConnect?: boolean;
|
||||
persistSessions?: boolean;
|
||||
autoConnect?: boolean
|
||||
persistSessions?: boolean
|
||||
}
|
||||
|
||||
export const useChatSession = (options: UseChatSessionOptions = {}) => {
|
||||
const router = useRouter();
|
||||
const { autoConnect = false, persistSessions = true } = options;
|
||||
const router = useRouter()
|
||||
const { autoConnect = false, persistSessions = true } = options
|
||||
|
||||
// 基础状态
|
||||
const [state, setState] = useState<ChatState>({
|
||||
@@ -60,19 +60,19 @@ export const useChatSession = (options: UseChatSessionOptions = {}) => {
|
||||
isRecording: false,
|
||||
isPlaying: false,
|
||||
isMuted: false,
|
||||
inputMode: "voice",
|
||||
inputMode: 'voice',
|
||||
sidebarVisible: false,
|
||||
currentSession: null,
|
||||
sessions: [],
|
||||
messages: [],
|
||||
isTyping: false,
|
||||
});
|
||||
})
|
||||
|
||||
// refs
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const websocketRef = useRef<WebSocket | null>(null)
|
||||
|
||||
// 本地存储键名
|
||||
// const STORAGE_KEYS = {
|
||||
@@ -505,4 +505,4 @@ export const useChatSession = (options: UseChatSessionOptions = {}) => {
|
||||
// deleteSession,
|
||||
// endConversation,
|
||||
// };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { create } from "zustand";
|
||||
import { devtools } from "zustand/middleware";
|
||||
import type { WebSocketHook } from "react-use-websocket/dist/lib/types";
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import type { WebSocketHook } from 'react-use-websocket/dist/lib/types'
|
||||
|
||||
import type { RealtimeMessage } from "./useRealtimeConnEffect";
|
||||
import type { DifyMessage } from "./useDifyCmd";
|
||||
import type { RealtimeMessage } from './useRealtimeConnEffect'
|
||||
import type { DifyMessage } from './useDifyCmd'
|
||||
|
||||
export type ConversationSession = {
|
||||
id: string;
|
||||
message: Array<RealtimeMessage | DifyMessage>;
|
||||
};
|
||||
id: string
|
||||
message: Array<RealtimeMessage | DifyMessage>
|
||||
}
|
||||
|
||||
type ConversationStore = {
|
||||
wsConnected: boolean;
|
||||
setWsConnected: (bool: boolean) => void;
|
||||
wsConnected: boolean
|
||||
setWsConnected: (bool: boolean) => void
|
||||
|
||||
wsInstance: WebSocketHook | undefined;
|
||||
setWsInstance: (instance: WebSocketHook) => void;
|
||||
wsInstance: WebSocketHook | undefined
|
||||
setWsInstance: (instance: WebSocketHook) => void
|
||||
|
||||
currentSessionId: string;
|
||||
setCurrentSessionId: (id: string) => void;
|
||||
currentSessionId: string
|
||||
setCurrentSessionId: (id: string) => void
|
||||
|
||||
sessionList: Array<ConversationSession>;
|
||||
getCurrentSession: () => ConversationSession | undefined;
|
||||
sessionList: Array<ConversationSession>
|
||||
getCurrentSession: () => ConversationSession | undefined
|
||||
setCurrentSessionList: (
|
||||
setSessionAction: (session: ConversationSession) => ConversationSession
|
||||
) => void;
|
||||
) => void
|
||||
|
||||
// 添加手动挂断标记
|
||||
isManualHangup: boolean;
|
||||
setIsManualHangup: (bool: boolean) => void;
|
||||
};
|
||||
isManualHangup: boolean
|
||||
setIsManualHangup: (bool: boolean) => void
|
||||
}
|
||||
|
||||
const useConversationStore = create<ConversationStore>()(
|
||||
devtools((set, get) => ({
|
||||
@@ -39,34 +39,34 @@ const useConversationStore = create<ConversationStore>()(
|
||||
wsInstance: undefined,
|
||||
setWsInstance: (instance) => set({ wsInstance: instance }),
|
||||
|
||||
currentSessionId: "",
|
||||
currentSessionId: '',
|
||||
setCurrentSessionId: (id) => set({ currentSessionId: id }),
|
||||
|
||||
sessionList: [],
|
||||
getCurrentSession: () => {
|
||||
const id = get().currentSessionId;
|
||||
const session = get().sessionList.find((session) => session.id === id);
|
||||
const id = get().currentSessionId
|
||||
const session = get().sessionList.find((session) => session.id === id)
|
||||
|
||||
return session;
|
||||
return session
|
||||
},
|
||||
setCurrentSessionList: (setSessionAction) => {
|
||||
const _currentSession = get().getCurrentSession();
|
||||
const _currentSessionList = get().sessionList;
|
||||
const _currentSession = get().getCurrentSession()
|
||||
const _currentSessionList = get().sessionList
|
||||
|
||||
if (!_currentSession) {
|
||||
const firstSession = setSessionAction({ id: "", message: [] });
|
||||
const firstSession = setSessionAction({ id: '', message: [] })
|
||||
|
||||
set({ sessionList: [..._currentSessionList, firstSession] });
|
||||
set({ sessionList: [..._currentSessionList, firstSession] })
|
||||
} else {
|
||||
const newSessionList = _currentSessionList.map((session) => {
|
||||
if (session.id === _currentSession?.id) {
|
||||
return setSessionAction(session);
|
||||
return setSessionAction(session)
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
return session
|
||||
})
|
||||
|
||||
set({ sessionList: newSessionList });
|
||||
set({ sessionList: newSessionList })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -74,6 +74,6 @@ const useConversationStore = create<ConversationStore>()(
|
||||
isManualHangup: false,
|
||||
setIsManualHangup: (bool) => set({ isManualHangup: bool }),
|
||||
}))
|
||||
);
|
||||
)
|
||||
|
||||
export default useConversationStore;
|
||||
export default useConversationStore
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
import { devtools } from "zustand/middleware";
|
||||
import { WavRecorder, WavStreamPlayer } from "wavtools";
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { WavRecorder, WavStreamPlayer } from 'wavtools'
|
||||
|
||||
type RealtimeStore = {
|
||||
wavRecorder: WavRecorder;
|
||||
wavStreamPlayer: WavStreamPlayer;
|
||||
};
|
||||
wavRecorder: WavRecorder
|
||||
wavStreamPlayer: WavStreamPlayer
|
||||
}
|
||||
|
||||
const useDeviceStore = create<RealtimeStore>()(
|
||||
devtools(() => ({
|
||||
wavRecorder: new WavRecorder({ sampleRate: 24000 }),
|
||||
wavStreamPlayer: new WavStreamPlayer({ sampleRate: 24000 }),
|
||||
}))
|
||||
);
|
||||
)
|
||||
|
||||
export default useDeviceStore;
|
||||
export default useDeviceStore
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import useConversationStore from "./useConversationStore";
|
||||
import useConversationStore from './useConversationStore'
|
||||
|
||||
const ENDPOINT = "https://dify.tangledup-ai.com/v1";
|
||||
const APIKEY = "app-SUFCBRBqD7CTmadxfFd90CTh";
|
||||
const ENDPOINT = 'https://dify.tangledup-ai.com/v1'
|
||||
const APIKEY = 'app-SUFCBRBqD7CTmadxfFd90CTh'
|
||||
|
||||
export type DifyMessage = {
|
||||
msgType: "dify";
|
||||
role: "assistant" | "user";
|
||||
event?: "agent_thought" | "agent_message" | "message_end";
|
||||
conversationId?: string;
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
taskId?: string;
|
||||
created_at?: string;
|
||||
question?: string;
|
||||
answer?: string;
|
||||
};
|
||||
msgType: 'dify'
|
||||
role: 'assistant' | 'user'
|
||||
event?: 'agent_thought' | 'agent_message' | 'message_end'
|
||||
conversationId?: string
|
||||
id?: string
|
||||
messageId?: string
|
||||
taskId?: string
|
||||
created_at?: string
|
||||
question?: string
|
||||
answer?: string
|
||||
}
|
||||
|
||||
const useDifyCmd = () => {
|
||||
const { setCurrentSessionList } = useConversationStore();
|
||||
const { setCurrentSessionList } = useConversationStore()
|
||||
|
||||
function saveQuestion(question: string) {
|
||||
setCurrentSessionList((prev) => ({
|
||||
@@ -27,42 +27,42 @@ const useDifyCmd = () => {
|
||||
message: [
|
||||
...prev.message,
|
||||
{
|
||||
msgType: "dify",
|
||||
role: "user",
|
||||
msgType: 'dify',
|
||||
role: 'user',
|
||||
messageId: uuidv4(),
|
||||
question,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
async function sendQuestion(query?: string) {
|
||||
const headers = new Headers();
|
||||
const headers = new Headers()
|
||||
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("Authorization", `Bearer ${APIKEY}`);
|
||||
headers.set('Content-Type', 'application/json')
|
||||
headers.set('Authorization', `Bearer ${APIKEY}`)
|
||||
|
||||
const body = JSON.stringify({
|
||||
inputs: {},
|
||||
query: query ?? "你好",
|
||||
response_mode: "streaming",
|
||||
conversation_id: "",
|
||||
user: "abc-123",
|
||||
});
|
||||
query: query ?? '你好',
|
||||
response_mode: 'streaming',
|
||||
conversation_id: '',
|
||||
user: 'abc-123',
|
||||
})
|
||||
|
||||
const requestOptions = {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
const resp = await fetch(ENDPOINT + "/chat-messages", requestOptions);
|
||||
const resp = await fetch(ENDPOINT + '/chat-messages', requestOptions)
|
||||
|
||||
return resp;
|
||||
return resp
|
||||
}
|
||||
|
||||
return { saveQuestion, sendQuestion };
|
||||
};
|
||||
return { saveQuestion, sendQuestion }
|
||||
}
|
||||
|
||||
export default useDifyCmd;
|
||||
export { useDifyCmd };
|
||||
export default useDifyCmd
|
||||
export { useDifyCmd }
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import useConversationStore from "./useConversationStore";
|
||||
import useDeviceStore from "./useDeviceStore";
|
||||
import useConversationStore from './useConversationStore'
|
||||
import useDeviceStore from './useDeviceStore'
|
||||
|
||||
import useAgentStore from "@/lib/useAgentStore";
|
||||
import useAgentStore from '@/lib/useAgentStore'
|
||||
|
||||
const useRealtimeCmd = () => {
|
||||
const { agent } = useAgentStore();
|
||||
const { wsInstance } = useConversationStore();
|
||||
const { wavStreamPlayer } = useDeviceStore();
|
||||
const { agent } = useAgentStore()
|
||||
const { wsInstance } = useConversationStore()
|
||||
const { wavStreamPlayer } = useDeviceStore()
|
||||
|
||||
/** @description 创建/更新 Session - 支持实时对话 */
|
||||
function sendPrompt() {
|
||||
wsInstance?.sendJsonMessage({
|
||||
event_id: uuidv4(),
|
||||
type: "session.update",
|
||||
type: 'session.update',
|
||||
session: {
|
||||
modalities: ["text", "audio"],
|
||||
modalities: ['text', 'audio'],
|
||||
instructions: agent?.agent_prompt,
|
||||
voice: agent?.voice_type,
|
||||
input_audio_format: "pcm16",
|
||||
output_audio_format: "pcm16",
|
||||
input_audio_format: 'pcm16',
|
||||
output_audio_format: 'pcm16',
|
||||
input_audio_transcription: {
|
||||
model: "whisper-1",
|
||||
model: 'whisper-1',
|
||||
},
|
||||
turn_detection: {
|
||||
type: "server_vad",
|
||||
type: 'server_vad',
|
||||
threshold: 0.5,
|
||||
prefix_padding_ms: 300,
|
||||
silence_duration_ms: 200,
|
||||
},
|
||||
tool_choice: "auto",
|
||||
tool_choice: 'auto',
|
||||
temperature: 0.8,
|
||||
max_response_output_tokens: 4096,
|
||||
tools: [
|
||||
{
|
||||
type: "retrieval",
|
||||
type: 'retrieval',
|
||||
function: {
|
||||
description:
|
||||
"本知识库可以回答用户【2023-3-27,红联发〔2023〕5号,红河州工商业联合会 红河州总商会关于印发《红河州工商业联合会(总商会)会员管理办法》等七个办法和制度的通知】【2023-4-17,红联党组发〔2023〕9号,关于印发《红河州工商业联合会(总商会)民营经济人士政治安排工作办法》等三个办法的通知】【2023-7-26,红联发〔2023〕12号,关于印发《红河州工商业联合会(总商会)商会工作办法》的通知】【2023-7-26,红联发〔2023〕13号,关于印发《红河州工商联“四好”商会建设实施办法(试行)》的通知】【2023-7-26,红联发〔2023〕14号,关于印发《红河州“五好”县级工商联建设实施办法(试行)》的通知】相关问题",
|
||||
'本知识库可以回答用户【2023-3-27,红联发〔2023〕5号,红河州工商业联合会 红河州总商会关于印发《红河州工商业联合会(总商会)会员管理办法》等七个办法和制度的通知】【2023-4-17,红联党组发〔2023〕9号,关于印发《红河州工商业联合会(总商会)民营经济人士政治安排工作办法》等三个办法的通知】【2023-7-26,红联发〔2023〕12号,关于印发《红河州工商业联合会(总商会)商会工作办法》的通知】【2023-7-26,红联发〔2023〕13号,关于印发《红河州工商联“四好”商会建设实施办法(试行)》的通知】【2023-7-26,红联发〔2023〕14号,关于印发《红河州“五好”县级工商联建设实施办法(试行)》的通知】相关问题',
|
||||
options: {
|
||||
// honghe_acfic_chat_main
|
||||
// 主要
|
||||
vector_store_id: "271916997457137664",
|
||||
vector_store_id: '271916997457137664',
|
||||
prompt_template:
|
||||
"从文档{{knowledge}}中找到问题{{query}}的答案。根据文档内容中的语句找到答案, 如果文档中没用答案则告诉用户找不到",
|
||||
'从文档{{knowledge}}中找到问题{{query}}的答案。根据文档内容中的语句找到答案, 如果文档中没用答案则告诉用户找不到',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -190,30 +190,30 @@ const useRealtimeCmd = () => {
|
||||
// },
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** @description 添加会话消息 */
|
||||
function createHello(text: string) {
|
||||
wsInstance?.sendJsonMessage({
|
||||
event_id: uuidv4(),
|
||||
type: "conversation.item.create",
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
id: uuidv4(),
|
||||
// previous_item_id: previousItemId,
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text }],
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** @description 创建推理 */
|
||||
function createResponse() {
|
||||
wsInstance?.sendJsonMessage({
|
||||
event_id: uuidv4(),
|
||||
type: "response.create",
|
||||
});
|
||||
type: 'response.create',
|
||||
})
|
||||
}
|
||||
|
||||
/** @description 追加音频内容 */
|
||||
@@ -221,37 +221,37 @@ const useRealtimeCmd = () => {
|
||||
wsInstance?.sendJsonMessage({
|
||||
audio: audioBase64,
|
||||
event_id: uuidv4(),
|
||||
type: "input_audio_buffer.append",
|
||||
});
|
||||
type: 'input_audio_buffer.append',
|
||||
})
|
||||
}
|
||||
|
||||
/** @description 提交音频内容 */
|
||||
function commitUserVoice() {
|
||||
wsInstance?.sendJsonMessage({
|
||||
event_id: uuidv4(),
|
||||
type: "input_audio_buffer.commit",
|
||||
});
|
||||
type: 'input_audio_buffer.commit',
|
||||
})
|
||||
}
|
||||
|
||||
/** @description 清空音频缓冲区 - 用于实时对话模式 */
|
||||
function clearAudioBuffer() {
|
||||
// 清空音频缓冲区的同时,也中断当前音频播放
|
||||
wavStreamPlayer.interrupt();
|
||||
wavStreamPlayer.interrupt()
|
||||
wsInstance?.sendJsonMessage({
|
||||
event_id: uuidv4(),
|
||||
type: "input_audio_buffer.clear",
|
||||
});
|
||||
type: 'input_audio_buffer.clear',
|
||||
})
|
||||
}
|
||||
|
||||
/** @description 取消当前响应 - 用于中断AI回答 */
|
||||
function cancelResponse() {
|
||||
// 立即中断音频播放
|
||||
wavStreamPlayer.interrupt();
|
||||
wavStreamPlayer.interrupt()
|
||||
// 发送取消响应的命令
|
||||
wsInstance?.sendJsonMessage({
|
||||
event_id: uuidv4(),
|
||||
type: "response.cancel",
|
||||
});
|
||||
type: 'response.cancel',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -262,8 +262,8 @@ const useRealtimeCmd = () => {
|
||||
commitUserVoice,
|
||||
clearAudioBuffer,
|
||||
cancelResponse,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default useRealtimeCmd;
|
||||
export { useRealtimeCmd };
|
||||
export default useRealtimeCmd
|
||||
export { useRealtimeCmd }
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import { useEffect } from "react";
|
||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||
import { useEffect } from 'react'
|
||||
import useWebSocket, { ReadyState } from 'react-use-websocket'
|
||||
|
||||
import useRealtimeMsgEffect from "./useRealtimeMsgEffect";
|
||||
import useRealtimeMsgEffect from './useRealtimeMsgEffect'
|
||||
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
import useConversationStore from "@/lib/useConversationStore";
|
||||
import { usePathname } from '@/i18n/navigation'
|
||||
import useConversationStore from '@/lib/useConversationStore'
|
||||
|
||||
export const connectionStatus = {
|
||||
[ReadyState.CONNECTING]: "Connecting",
|
||||
[ReadyState.OPEN]: "Open",
|
||||
[ReadyState.CLOSING]: "Closing",
|
||||
[ReadyState.CLOSED]: "Closed",
|
||||
[ReadyState.UNINSTANTIATED]: "Uninstantiated",
|
||||
} as const;
|
||||
[ReadyState.CONNECTING]: 'Connecting',
|
||||
[ReadyState.OPEN]: 'Open',
|
||||
[ReadyState.CLOSING]: 'Closing',
|
||||
[ReadyState.CLOSED]: 'Closed',
|
||||
[ReadyState.UNINSTANTIATED]: 'Uninstantiated',
|
||||
} as const
|
||||
|
||||
export const connectionColor = {
|
||||
[ReadyState.CONNECTING]: "warning",
|
||||
[ReadyState.OPEN]: "success",
|
||||
[ReadyState.CLOSING]: "warning",
|
||||
[ReadyState.CLOSED]: "danger",
|
||||
[ReadyState.UNINSTANTIATED]: "default",
|
||||
} as const;
|
||||
[ReadyState.CONNECTING]: 'warning',
|
||||
[ReadyState.OPEN]: 'success',
|
||||
[ReadyState.CLOSING]: 'warning',
|
||||
[ReadyState.CLOSED]: 'danger',
|
||||
[ReadyState.UNINSTANTIATED]: 'default',
|
||||
} as const
|
||||
|
||||
export type RealtimeMessage = {
|
||||
msgType: "realtime";
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
itemId: string;
|
||||
role?: "user" | "assistant";
|
||||
isStreaming: boolean;
|
||||
textDelta?: string;
|
||||
textFinal?: string;
|
||||
audioDelta?: Int16Array<ArrayBuffer>;
|
||||
audioFinal?: Int16Array<ArrayBuffer>;
|
||||
};
|
||||
msgType: 'realtime'
|
||||
eventId: string
|
||||
eventName: string
|
||||
itemId: string
|
||||
role?: 'user' | 'assistant'
|
||||
isStreaming: boolean
|
||||
textDelta?: string
|
||||
textFinal?: string
|
||||
audioDelta?: Int16Array<ArrayBuffer>
|
||||
audioFinal?: Int16Array<ArrayBuffer>
|
||||
}
|
||||
|
||||
const stepApiKey = process.env.NEXT_PUBLIC_STEP_API_KEY;
|
||||
const stepApiKey = process.env.NEXT_PUBLIC_STEP_API_KEY
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_REALTIME_ENDPOINT;
|
||||
const url = new URL(baseUrl ?? "");
|
||||
const baseUrl = process.env.NEXT_PUBLIC_REALTIME_ENDPOINT
|
||||
const url = new URL(baseUrl ?? '')
|
||||
|
||||
url.searchParams.set("wsUrl", "wss://api.stepfun.com/v1/realtime");
|
||||
url.searchParams.set("model", "step-1o-audio");
|
||||
url.searchParams.set('wsUrl', 'wss://api.stepfun.com/v1/realtime')
|
||||
url.searchParams.set('model', 'step-1o-audio')
|
||||
|
||||
url.searchParams.set("apiKey", stepApiKey ?? "");
|
||||
url.searchParams.set('apiKey', stepApiKey ?? '')
|
||||
|
||||
const useRealtimeConnEffect = () => {
|
||||
const pathname = usePathname();
|
||||
const pathname = usePathname()
|
||||
|
||||
const { wsConnected, setWsConnected, setWsInstance } = useConversationStore();
|
||||
const { wsConnected, setWsConnected, setWsInstance } = useConversationStore()
|
||||
const wsInstance = useWebSocket(
|
||||
url.href,
|
||||
{
|
||||
share: true,
|
||||
heartbeat: {
|
||||
message: "ping" + Date.now(),
|
||||
message: 'ping' + Date.now(),
|
||||
interval: 15000,
|
||||
},
|
||||
onClose: () => {
|
||||
setWsConnected(false);
|
||||
setWsConnected(false)
|
||||
},
|
||||
},
|
||||
wsConnected
|
||||
);
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== "playground") setWsConnected(false);
|
||||
}, [pathname]);
|
||||
if (pathname !== 'playground') setWsConnected(false)
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setWsInstance(wsInstance);
|
||||
}, [wsInstance.readyState]);
|
||||
setWsInstance(wsInstance)
|
||||
}, [wsInstance.readyState])
|
||||
|
||||
useRealtimeMsgEffect(wsInstance);
|
||||
};
|
||||
useRealtimeMsgEffect(wsInstance)
|
||||
}
|
||||
|
||||
export default useRealtimeConnEffect;
|
||||
export default useRealtimeConnEffect
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
import type { WebSocketHook } from "react-use-websocket/dist/lib/types";
|
||||
import { useEffect } from 'react'
|
||||
import type { WebSocketHook } from 'react-use-websocket/dist/lib/types'
|
||||
|
||||
import useConversationStore from "./useConversationStore";
|
||||
import useDeviceStore from "./useDeviceStore";
|
||||
import { base64ToArrayBuffer, mergeInt16Arrays } from "./audioUtils";
|
||||
import useConversationStore from './useConversationStore'
|
||||
import useDeviceStore from './useDeviceStore'
|
||||
import { base64ToArrayBuffer, mergeInt16Arrays } from './audioUtils'
|
||||
|
||||
const useRealtimeMsgEffect = (wsInstance: WebSocketHook) => {
|
||||
const { setCurrentSessionId, setCurrentSessionList } = useConversationStore();
|
||||
const { wavStreamPlayer } = useDeviceStore();
|
||||
const { setCurrentSessionId, setCurrentSessionList } = useConversationStore()
|
||||
const { wavStreamPlayer } = useDeviceStore()
|
||||
|
||||
useEffect(() => {
|
||||
const parsedData = JSON.parse(wsInstance.lastMessage?.data ?? "{}");
|
||||
const parsedData = JSON.parse(wsInstance.lastMessage?.data ?? '{}')
|
||||
// console.debug(parsedData);
|
||||
|
||||
if (parsedData.type === "session.created") {
|
||||
setCurrentSessionId(parsedData.session.id);
|
||||
if (parsedData.type === 'session.created') {
|
||||
setCurrentSessionId(parsedData.session.id)
|
||||
setCurrentSessionList(() => {
|
||||
return { id: parsedData.session.id, message: [] };
|
||||
});
|
||||
return { id: parsedData.session.id, message: [] }
|
||||
})
|
||||
}
|
||||
|
||||
if (parsedData.type === "conversation.item.created") {
|
||||
const currentEventId = parsedData.event_id;
|
||||
const currentEventName = parsedData.type;
|
||||
const currentItemId = parsedData.item.id;
|
||||
const currentRole = parsedData.item.role;
|
||||
const userContent = parsedData.item.content?.at(0).text;
|
||||
const currentPreviousItemId = parsedData.previous_item_id;
|
||||
if (parsedData.type === 'conversation.item.created') {
|
||||
const currentEventId = parsedData.event_id
|
||||
const currentEventName = parsedData.type
|
||||
const currentItemId = parsedData.item.id
|
||||
const currentRole = parsedData.item.role
|
||||
const userContent = parsedData.item.content?.at(0).text
|
||||
const currentPreviousItemId = parsedData.previous_item_id
|
||||
|
||||
if (currentRole === "assistant") {
|
||||
if (currentRole === 'assistant') {
|
||||
setCurrentSessionList((prev) => ({
|
||||
...prev,
|
||||
message: [
|
||||
...prev.message,
|
||||
{
|
||||
msgType: "realtime",
|
||||
msgType: 'realtime',
|
||||
eventId: currentEventId,
|
||||
eventName: currentEventName,
|
||||
itemId: currentItemId,
|
||||
@@ -43,16 +43,16 @@ const useRealtimeMsgEffect = (wsInstance: WebSocketHook) => {
|
||||
isStreaming: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
if (currentRole === "user") {
|
||||
if (currentRole === 'user') {
|
||||
setCurrentSessionList((prev) => ({
|
||||
...prev,
|
||||
message: [
|
||||
...prev.message,
|
||||
{
|
||||
msgType: "realtime",
|
||||
msgType: 'realtime',
|
||||
eventId: currentEventId,
|
||||
eventName: currentEventName,
|
||||
itemId: currentItemId,
|
||||
@@ -62,44 +62,44 @@ const useRealtimeMsgEffect = (wsInstance: WebSocketHook) => {
|
||||
textFinal: userContent,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedData.type === "response.audio_transcript.delta") {
|
||||
const currentEventId = parsedData.event_id;
|
||||
const currentEventName = parsedData.type;
|
||||
const currentItemId = parsedData.item_id;
|
||||
const currentDelta = parsedData.delta;
|
||||
if (parsedData.type === 'response.audio_transcript.delta') {
|
||||
const currentEventId = parsedData.event_id
|
||||
const currentEventName = parsedData.type
|
||||
const currentItemId = parsedData.item_id
|
||||
const currentDelta = parsedData.delta
|
||||
|
||||
setCurrentSessionList((prev) => ({
|
||||
...prev,
|
||||
message: prev.message.map((item) => {
|
||||
if (item.msgType === "realtime" && item.itemId === currentItemId) {
|
||||
if (item.msgType === 'realtime' && item.itemId === currentItemId) {
|
||||
return {
|
||||
...item,
|
||||
eventId: currentEventId,
|
||||
eventName: currentEventName,
|
||||
textDelta: (item.textDelta ?? "") + currentDelta,
|
||||
textDelta: (item.textDelta ?? '') + currentDelta,
|
||||
isStreaming: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
return item
|
||||
}),
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
if (parsedData.type === "response.audio_transcript.done") {
|
||||
const currentEventId = parsedData.event_id;
|
||||
const currentEventName = parsedData.type;
|
||||
const currentItemId = parsedData.item_id;
|
||||
const currentTranscript = parsedData.transcript;
|
||||
if (parsedData.type === 'response.audio_transcript.done') {
|
||||
const currentEventId = parsedData.event_id
|
||||
const currentEventName = parsedData.type
|
||||
const currentItemId = parsedData.item_id
|
||||
const currentTranscript = parsedData.transcript
|
||||
|
||||
setCurrentSessionList((prev) => ({
|
||||
...prev,
|
||||
message: prev.message.map((item) => {
|
||||
if (item.msgType === "realtime" && item.itemId === currentItemId) {
|
||||
if (item.msgType === 'realtime' && item.itemId === currentItemId) {
|
||||
return {
|
||||
...item,
|
||||
eventId: currentEventId,
|
||||
@@ -107,29 +107,29 @@ const useRealtimeMsgEffect = (wsInstance: WebSocketHook) => {
|
||||
textDelta: undefined,
|
||||
textFinal: currentTranscript,
|
||||
isStreaming: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
return item
|
||||
}),
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
if (parsedData.type === "response.audio.delta") {
|
||||
const currentEventId = parsedData.event_id;
|
||||
const currentEventName = parsedData.type;
|
||||
const currentItemId = parsedData.item_id;
|
||||
const currentDelta = parsedData.delta;
|
||||
if (parsedData.type === 'response.audio.delta') {
|
||||
const currentEventId = parsedData.event_id
|
||||
const currentEventName = parsedData.type
|
||||
const currentItemId = parsedData.item_id
|
||||
const currentDelta = parsedData.delta
|
||||
|
||||
console.log("收到音频数据 - 设置isStreaming为true", { currentItemId });
|
||||
console.log('收到音频数据 - 设置isStreaming为true', { currentItemId })
|
||||
|
||||
const audioArrayBuffer = base64ToArrayBuffer(currentDelta);
|
||||
const audioInt16ArrayBuffer = new Int16Array(audioArrayBuffer);
|
||||
const audioArrayBuffer = base64ToArrayBuffer(currentDelta)
|
||||
const audioInt16ArrayBuffer = new Int16Array(audioArrayBuffer)
|
||||
|
||||
setCurrentSessionList((prev) => ({
|
||||
...prev,
|
||||
message: prev.message.map((item) => {
|
||||
if (item.msgType === "realtime" && item.itemId === currentItemId) {
|
||||
if (item.msgType === 'realtime' && item.itemId === currentItemId) {
|
||||
return {
|
||||
...item,
|
||||
eventId: currentEventId,
|
||||
@@ -140,90 +140,90 @@ const useRealtimeMsgEffect = (wsInstance: WebSocketHook) => {
|
||||
audioInt16ArrayBuffer
|
||||
),
|
||||
isStreaming: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
return item
|
||||
}),
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
if (parsedData.type === "response.audio.done") {
|
||||
const currentEventId = parsedData.event_id;
|
||||
const currentItemId = parsedData.item_id;
|
||||
const currentEventName = parsedData.type;
|
||||
if (parsedData.type === 'response.audio.done') {
|
||||
const currentEventId = parsedData.event_id
|
||||
const currentItemId = parsedData.item_id
|
||||
const currentEventName = parsedData.type
|
||||
|
||||
console.log("音频播放完成 - 设置isStreaming为false", { currentItemId });
|
||||
console.log('音频播放完成 - 设置isStreaming为false', { currentItemId })
|
||||
|
||||
setCurrentSessionList((prev) => ({
|
||||
...prev,
|
||||
message: prev.message.map((item) => {
|
||||
if (item.msgType === "realtime" && item.itemId === currentItemId) {
|
||||
if (item.msgType === 'realtime' && item.itemId === currentItemId) {
|
||||
return {
|
||||
...item,
|
||||
eventId: currentEventId,
|
||||
eventName: currentEventName,
|
||||
audioDelta: undefined,
|
||||
isStreaming: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
return item
|
||||
}),
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
if (
|
||||
parsedData.type ===
|
||||
"conversation.item.input_audio_transcription.completed"
|
||||
'conversation.item.input_audio_transcription.completed'
|
||||
) {
|
||||
const currentEventId = parsedData.event_id;
|
||||
const currentItemId = parsedData.item_id;
|
||||
const currentEventName = parsedData.type;
|
||||
const currenttranscript = parsedData.transcript;
|
||||
const currentEventId = parsedData.event_id
|
||||
const currentItemId = parsedData.item_id
|
||||
const currentEventName = parsedData.type
|
||||
const currenttranscript = parsedData.transcript
|
||||
|
||||
setCurrentSessionList((prev) => ({
|
||||
...prev,
|
||||
message: prev.message.map((item) => {
|
||||
if (item.msgType === "realtime" && item.itemId === currentItemId) {
|
||||
if (item.msgType === 'realtime' && item.itemId === currentItemId) {
|
||||
return {
|
||||
...item,
|
||||
eventId: currentEventId,
|
||||
eventName: currentEventName,
|
||||
textFinal: currenttranscript,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
return item
|
||||
}),
|
||||
}));
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理语音活动检测事件 - 开始说话
|
||||
if (parsedData.type === "input_audio_buffer.speech_started") {
|
||||
console.log("检测到用户开始说话 - 中断当前音频播放");
|
||||
if (parsedData.type === 'input_audio_buffer.speech_started') {
|
||||
console.log('检测到用户开始说话 - 中断当前音频播放')
|
||||
// 立即中断当前音频播放,避免与用户新的语音输入冲突
|
||||
wavStreamPlayer.interrupt();
|
||||
wavStreamPlayer.interrupt()
|
||||
}
|
||||
|
||||
// 处理语音活动检测事件 - 停止说话
|
||||
if (parsedData.type === "input_audio_buffer.speech_stopped") {
|
||||
console.log("检测到用户停止说话");
|
||||
if (parsedData.type === 'input_audio_buffer.speech_stopped') {
|
||||
console.log('检测到用户停止说话')
|
||||
// 可以在这里添加UI反馈,比如隐藏"正在说话"指示器
|
||||
}
|
||||
|
||||
// 处理响应创建事件
|
||||
if (parsedData.type === "response.created") {
|
||||
console.log("AI开始生成响应 - 确保之前的音频已停止");
|
||||
if (parsedData.type === 'response.created') {
|
||||
console.log('AI开始生成响应 - 确保之前的音频已停止')
|
||||
// 确保在AI开始新响应时,之前的音频播放已完全停止
|
||||
wavStreamPlayer.interrupt();
|
||||
wavStreamPlayer.interrupt()
|
||||
}
|
||||
|
||||
// 处理响应完成事件
|
||||
if (parsedData.type === "response.done") {
|
||||
console.log("AI响应完成");
|
||||
if (parsedData.type === 'response.done') {
|
||||
console.log('AI响应完成')
|
||||
}
|
||||
}, [wsInstance.lastMessage?.data]);
|
||||
};
|
||||
}, [wsInstance.lastMessage?.data])
|
||||
}
|
||||
|
||||
export default useRealtimeMsgEffect;
|
||||
export default useRealtimeMsgEffect
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import createMiddleware from 'next-intl/middleware'
|
||||
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { routing } from '@/i18n/routing'
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
const prefix = routing.locales.join("|");
|
||||
export default createMiddleware(routing)
|
||||
|
||||
export const config = {
|
||||
// Match only internationalized pathnames
|
||||
matcher: ["/", "/(en|zh-CN)/:path*", "/((?!api|_next|_vercel|.*\\..*).*)"],
|
||||
};
|
||||
matcher: ['/', '/(en|zh-CN)/:path*', '/((?!api|_next|_vercel|.*\\..*).*)'],
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SVGProps } from "react";
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export type IconSvgProps = SVGProps<SVGSVGElement> & {
|
||||
size?: number;
|
||||
};
|
||||
size?: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user