diff --git a/ui/homepage/components/AskAIDialog.tsx b/ui/homepage/components/AskAIDialog.tsx index 4e30268..6e093b1 100644 --- a/ui/homepage/components/AskAIDialog.tsx +++ b/ui/homepage/components/AskAIDialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { marked } from 'marked' import DOMPurify from 'dompurify' import { ChatBubble } from './ChatBubble' @@ -9,14 +9,35 @@ import { SourceHint } from './SourceHint' const MAX_MESSAGES = 20 const MAX_CACHE_SIZE = 50 +function normalizeInput(text: string) { + return text + .trim() + .replace(/[\s.,!?;:,。!?;:]+$/u, '') + .replace(/```[\s\S]*?```/g, '') +} + +function renderMarkdown(text: string) { + // marked.parse has a return type of string | Promise + // but in our usage it executes synchronously. Cast to string to + // satisfy the DOMPurify.sanitize type expectations. + return DOMPurify.sanitize(marked.parse(text) as string) +} + +type InitialQuestionPayload = { + key: number + text: string +} + export function AskAIDialog({ open, onMinimize, - onEnd + onEnd, + initialQuestion }: { open: boolean onMinimize: () => void onEnd: () => void + initialQuestion?: InitialQuestionPayload }) { const [question, setQuestion] = useState('') const [messages, setMessages] = useState<{ sender: 'user' | 'ai'; text: string }[]>([]) @@ -27,6 +48,7 @@ export function AskAIDialog({ new Map() ) const requestIdRef = useRef(0) + const processedInitialRef = useRef(null) useEffect(() => { return () => { @@ -35,26 +57,12 @@ export function AskAIDialog({ } }, []) - function normalizeInput(text: string) { - return text - .trim() - .replace(/[\s.,!?;:,。!?;:]+$/u, '') - .replace(/```[\s\S]*?```/g, '') - } - - function renderMarkdown(text: string) { - // marked.parse has a return type of string | Promise - // but in our usage it executes synchronously. Cast to string to - // satisfy the DOMPurify.sanitize type expectations. - return DOMPurify.sanitize(marked.parse(text) as string) - } - - async function streamChat( + const streamChat = useCallback(async ( url: string, body: any, update: (text: string, src?: any[]) => void, timeout = 15000 - ) { + ) => { abortRef.current?.abort() const controller = new AbortController() let timer: NodeJS.Timeout | null = null @@ -130,10 +138,11 @@ export function AskAIDialog({ if (timer) clearTimeout(timer) if (abortRef.current === controller) abortRef.current = null } - } + }, []) - async function performAsk() { - const normalized = normalizeInput(question) + const performAsk = useCallback(async (override?: string) => { + const inputValue = override ?? question + const normalized = normalizeInput(inputValue) if (!normalized) return const now = Date.now() const cached = cacheRef.current.get(normalized) @@ -262,21 +271,36 @@ export function AskAIDialog({ else if (err.message) message = err.message updateAI(message) } - } + }, [messages, question, streamChat]) function handleAsk() { abortRef.current?.abort() if (debounceRef.current) clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(performAsk, 300) + debounceRef.current = setTimeout(() => performAsk(), 300) } function handleEnd() { setMessages([]) setQuestion('') setSources([]) + processedInitialRef.current = null onEnd() } + useEffect(() => { + if (!open) { + processedInitialRef.current = null + return + } + if (!initialQuestion) return + if (processedInitialRef.current === initialQuestion.key) return + const normalizedInitial = normalizeInput(initialQuestion.text) + if (!normalizedInitial) return + processedInitialRef.current = initialQuestion.key + setQuestion(normalizedInitial) + performAsk(normalizedInitial) + }, [initialQuestion, open, performAsk]) + if (!open) return null return ( diff --git a/ui/homepage/components/Navbar.tsx b/ui/homepage/components/Navbar.tsx index c85af1f..523d042 100644 --- a/ui/homepage/components/Navbar.tsx +++ b/ui/homepage/components/Navbar.tsx @@ -1,12 +1,15 @@ 'use client' +import Link from 'next/link' import Image from 'next/image' -import { useEffect, useMemo, useRef, useState } from 'react' +import { FormEvent, useEffect, useMemo, useRef, useState } from 'react' +import { Search } from 'lucide-react' import { useLanguage } from '../i18n/LanguageProvider' import { translations } from '../i18n/translations' import LanguageToggle from './LanguageToggle' import ReleaseChannelSelector, { ReleaseChannel } from './ReleaseChannelSelector' import { getFeatureToggleInfo } from '@lib/featureToggles' import { useUser } from '@lib/userStore' +import { AskAIDialog } from './AskAIDialog' const CHANNEL_ORDER: ReleaseChannel[] = ['stable', 'beta', 'develop'] const DEFAULT_CHANNELS: ReleaseChannel[] = ['stable'] @@ -21,17 +24,9 @@ type NavSubItem = { enabled?: boolean } -type NavItem = { - key: string - label: string - children: NavSubItem[] -} - export default function Navbar() { const [menuOpen, setMenuOpen] = useState(false) - const [openSection, setOpenSection] = useState(null) - const [activeMenu, setActiveMenu] = useState(null) - const [activeItem, setActiveItem] = useState(null) + const [mobileServicesOpen, setMobileServicesOpen] = useState(false) const [selectedChannels, setSelectedChannels] = useState(['stable']) const { language } = useLanguage() const { user } = useUser() @@ -145,100 +140,61 @@ export default function Navbar() { const accountLabel = nav.account.title - const navItems: NavItem[] = [ - { - key: 'openSource', - label: nav.openSource.title, - children: [ - { - key: 'features', - label: nav.openSource.features, - href: '#features', - }, - { - key: 'projects', - label: nav.openSource.projects, - href: '#open-sources', - }, - { - key: 'download', - label: nav.openSource.download, - href: '#download', - }, - ], - }, - { - key: 'services', - label: nav.services.title, - children: [ - { - key: 'artifact', - label: nav.services.artifact, - href: '/download', - togglePath: '/download', - }, - { - key: 'cloudIac', - label: nav.services.cloudIac, - href: '/cloud_iac', - togglePath: '/cloud_iac', - }, - { - key: 'insight', - label: nav.services.insight, - href: '/insight', - togglePath: '/insight', - }, - { - key: 'docs', - label: nav.services.docs, - href: '/docs', - togglePath: '/docs', - }, - ], - }, - ...(!user - ? [ - { - key: 'account', - label: accountLabel, - children: accountChildren, - }, - ] - : []), - ] + const serviceItems: NavSubItem[] = useMemo(() => { + const rawItems: NavSubItem[] = [ + { + key: 'artifact', + label: nav.services.artifact, + href: '/download', + togglePath: '/download', + }, + { + key: 'cloudIac', + label: nav.services.cloudIac, + href: '/cloud_iac', + togglePath: '/cloud_iac', + }, + { + key: 'insight', + label: nav.services.insight, + href: '/insight', + togglePath: '/insight', + }, + { + key: 'docs', + label: nav.services.docs, + href: '/docs', + togglePath: '/docs', + }, + ] - const visibleNavItems: NavItem[] = navItems - .map((item) => ({ - ...item, - children: item.children - .map((child) => { - if (!child.togglePath) { - return { ...child, enabled: true } - } + return rawItems + .map((child) => { + if (!child.togglePath) { + return { ...child, enabled: true } + } - const { enabled, channel } = getFeatureToggleInfo('globalNavigation', child.togglePath) - const derivedChannels = child.channels ?? (channel ? [channel] : undefined) + const { enabled, channel } = getFeatureToggleInfo('globalNavigation', child.togglePath) + const derivedChannels = child.channels ?? (channel ? [channel] : undefined) - return { - ...child, - enabled, - channels: derivedChannels, - } - }) - .filter((child) => { - if (child.enabled === false) { - return false - } + return { + ...child, + enabled, + channels: derivedChannels, + } + }) + .filter((child) => { + if (child.enabled === false) { + return false + } - const childChannels: ReleaseChannel[] = child.channels?.length - ? child.channels - : DEFAULT_CHANNELS - return childChannels.some((channel) => selectedChannelSet.has(channel)) - }) - .map(({ enabled: _enabled, ...child }) => child), - })) - .filter((item) => item.children.length > 0) + const childChannels: ReleaseChannel[] = child.channels?.length + ? child.channels + : DEFAULT_CHANNELS + return childChannels.some((channel) => selectedChannelSet.has(channel)) + }) + .map(({ enabled: _enabled, ...child }) => child) + }, [nav.services.artifact, nav.services.cloudIac, nav.services.docs, nav.services.insight, selectedChannelSet]) const toggleChannel = (channel: ReleaseChannel) => { if (channel === 'stable') return @@ -264,227 +220,346 @@ export default function Navbar() { ) } - const toggleSection = (section: string) => { - setOpenSection((prev) => (prev === section ? null : section)) + const isChinese = language === 'zh' + const labels = { + home: isChinese ? '首页' : 'Home', + docs: isChinese ? '文档' : 'Docs', + download: isChinese ? '下载' : 'Download', + moreServices: isChinese ? '更多服务' : 'More services', + searchPlaceholder: isChinese ? '请输入关键字搜索内容' : 'Ask anything about your docs', + } + + const [searchValue, setSearchValue] = useState('') + const [askDialogOpen, setAskDialogOpen] = useState(false) + const [pendingQuestion, setPendingQuestion] = useState<{ key: number; text: string } | null>(null) + + const mainLinks = [ + { key: 'home', label: labels.home, href: '/' }, + { key: 'docs', label: labels.docs, href: '/docs' }, + { key: 'download', label: labels.download, href: '/download' }, + ] + + const handleSearchSubmit = (event: FormEvent) => { + event.preventDefault() + const trimmed = searchValue.trim() + if (!trimmed) return + setPendingQuestion({ key: Date.now(), text: trimmed }) + setAskDialogOpen(true) + setSearchValue('') } return ( - + + setAskDialogOpen(false)} + onEnd={() => { + setAskDialogOpen(false) + setPendingQuestion(null) + }} + initialQuestion={pendingQuestion ?? undefined} + /> + ) }