fix: format error

This commit is contained in:
SvenFE
2025-08-31 01:59:51 +08:00
parent 25640a2ced
commit dd3167b211
72 changed files with 2342 additions and 2215 deletions

View File

@@ -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

View File

@@ -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,
},
],
},
}
);
)

View File

@@ -1,5 +1,5 @@
import { notFound } from "next/navigation";
import { notFound } from 'next/navigation'
export default function CatchAllPage() {
notFound();
notFound()
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -1 +1 @@
export { default as Contact } from "./page";
export { default as Contact } from './page'

View File

@@ -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>线08733053626</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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
};
)
}

View File

@@ -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>
);
};
)
}

View File

@@ -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>
);
};
)
}

View File

@@ -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>
);
};
)
}

View File

@@ -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'

View File

@@ -1 +1 @@
export * from "./industryChains";
export * from './industryChains'

View File

@@ -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',
},
],
},
]

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
</>
);
)
}

View File

@@ -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

View File

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

View File

@@ -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'

View File

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

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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' },
})
}
}

View File

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

View File

@@ -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>
);
)
}

View File

@@ -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>
);
};
)
}

View File

@@ -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}
/>
);
)
}

View File

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

View File

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

View File

@@ -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>
);
};
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)

View File

@@ -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>
);
};
)
}

View File

@@ -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,
},
});
})

View File

@@ -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>
);
};
)
}

View File

@@ -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',
})

View File

@@ -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',
},
};
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
};
});
}
})

View File

@@ -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]

View File

@@ -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,
};
}

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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,
// };
};
}

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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红联发20235号红河州工商业联合会 红河州总商会关于印发《红河州工商业联合会总商会会员管理办法》等七个办法和制度的通知】【2023-4-17红联党组发20239号关于印发《红河州工商业联合会总商会民营经济人士政治安排工作办法》等三个办法的通知】【2023-7-26红联发202312号关于印发《红河州工商业联合会总商会商会工作办法》的通知】【2023-7-26红联发202313号关于印发《红河州工商联“四好”商会建设实施办法(试行)》的通知】【2023-7-26红联发202314号关于印发《红河州“五好”县级工商联建设实施办法(试行)》的通知】相关问题",
'本知识库可以回答用户【2023-3-27红联发20235号红河州工商业联合会 红河州总商会关于印发《红河州工商业联合会总商会会员管理办法》等七个办法和制度的通知】【2023-4-17红联党组发20239号关于印发《红河州工商业联合会总商会民营经济人士政治安排工作办法》等三个办法的通知】【2023-7-26红联发202312号关于印发《红河州工商业联合会总商会商会工作办法》的通知】【2023-7-26红联发202313号关于印发《红河州工商联“四好”商会建设实施办法(试行)》的通知】【2023-7-26红联发202314号关于印发《红河州“五好”县级工商联建设实施办法(试行)》的通知】相关问题',
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 }

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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|.*\\..*).*)'],
}

View File

@@ -1,5 +1,5 @@
import type { SVGProps } from "react";
import type { SVGProps } from 'react'
export type IconSvgProps = SVGProps<SVGSVGElement> & {
size?: number;
};
size?: number
}