diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 720f1be6..63fa936a 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -4,7 +4,7 @@ on: push: branches: - main - - feat/docs + - feat/new-docs paths: - 'docs/**' - '.github/workflows/deploy-docs.yaml' @@ -16,35 +16,60 @@ permissions: pages: write id-token: write +# Allow one concurrent deployment; cancel in-progress runs. +concurrency: + group: pages + cancel-in-progress: true + jobs: - deploy: + build: runs-on: ubuntu-latest - environment: - name: github-pages + defaults: + run: + working-directory: docs steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up Node.js + uses: actions/setup-node@v4 with: - python-version: '3.12' + node-version: 22 - - name: Install dependencies + # pnpm via corepack (bundled with Node) — avoids third-party setup actions + # that are blocked by the org's allowed-actions policy. + - name: Enable pnpm run: | - cd docs - pip install -r requirements.txt + corepack enable + corepack prepare pnpm@10.16.1 --activate - - name: Build MkDocs site - run: | - cd docs - mkdocs build + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build static site (Fumadocs) + run: pnpm build:static + env: + # GitHub Pages project site is served from /. Without this base + # path, assets and routes 404 under the sub-path. (The pitfall.) + NEXT_PUBLIC_BASE_PATH: /${{ github.event.repository.name }} + # Optional: external Ask-AI endpoint (e.g. a VeFaaS function). + NEXT_PUBLIC_AI_CHAT_URL: ${{ vars.AI_CHAT_URL }} - name: Upload artifact for GitHub Pages uses: actions/upload-pages-artifact@v3 with: - path: docs/site/ + path: docs/out + deploy: + needs: build + runs-on: ubuntu-latest + # The github-pages environment restricts deployments to the default branch. + # Branches/PRs run the build job above to validate; deploy happens on main. + if: github.ref == 'refs/heads/main' + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: - name: Deploy to GitHub Pages id: deploy - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index bb0c80b8..d48b3d08 100644 --- a/.gitignore +++ b/.gitignore @@ -201,4 +201,6 @@ cython_debug/ *.mp3 *.pcm -.trae \ No newline at end of file +.trae +.claude +.agents \ No newline at end of file diff --git a/.gitleaks.toml b/.gitleaks.toml index 0f097484..d69d326f 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -73,4 +73,10 @@ description = "Empty environment variables with KEY pattern" regex = '''os\.environ\[".*?KEY"\]\s*=\s*".+"''' [allowlist] -paths = ["requirements.txt", "tests", "veadk/realtime/client.py", "veadk/realtime/live.py"] \ No newline at end of file +paths = ["requirements.txt", "tests", "veadk/realtime/client.py", "veadk/realtime/live.py"] +# False positives: the docs search config references the @orama/tokenizers +# package and its createTokenizer() helper. The word "tokenizer" trips +# token-transformer-id-pattern, but these are library identifiers, not secrets. +regexes = [ + '''tokenizer''', +] \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore index 151c3b9b..b58ad377 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,172 +1,32 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ - -# vscode -.vscode/ -# Windsurf -.windsurf - -# python package -uv.lock - -# VitePress related files (security concern - keep Markdown docs in repo) -docs/package.json -docs/package-lock.json -docs/node_modules/ +# deps +/node_modules + +# generated content +.source + +# test & build +/coverage +/.next/ +/out/ +/build +*.tsbuildinfo + +# misc +.DS_Store +*.pem +/.pnp +.pnp.js +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# others +.env*.local +.vercel +next-env.d.ts + +# The repo root .gitignore is a Python template that ignores `lib/` and +# `build/`. This is a JS project — re-include our source dirs. +!/lib +!/lib/** +!/build \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..301980ab --- /dev/null +++ b/docs/README.md @@ -0,0 +1,47 @@ +# veadk-docs-scaffold + +This is a Next.js application generated with +[Create Fumadocs](https://github.com/fuma-nama/fumadocs). + +It is a Next.js app with [Static Export](https://nextjs.org/docs/app/guides/static-exports) configured. + +Run development server: + +```bash +npm run dev +# or +pnpm dev +# or +yarn dev +``` + +Open http://localhost:3000 with your browser to see the result. + +## Explore + +In the project, you can see: + +- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content. +- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep. + +| Route | Description | +| ------------------------- | ------------------------------------------------------ | +| `app/(home)` | The route group for your landing page and other pages. | +| `app/docs` | The documentation layout and pages. | +| `app/api/search/route.ts` | The Route Handler for search. | + +### Fumadocs MDX + +A `source.config.ts` config file has been included, you can customise different options like frontmatter schema. + +Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details. + +## Learn More + +To learn more about Next.js and Fumadocs, take a look at the following +resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js + features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs diff --git a/docs/app/[lang]/(home)/layout.tsx b/docs/app/[lang]/(home)/layout.tsx new file mode 100644 index 00000000..5187d9f1 --- /dev/null +++ b/docs/app/[lang]/(home)/layout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { baseOptions } from '@/lib/layout.shared'; + +export default async function Layout({ + params, + children, +}: { + params: Promise<{ lang: string }>; + children: ReactNode; +}) { + const { lang } = await params; + return {children}; +} diff --git a/docs/app/[lang]/(home)/page.tsx b/docs/app/[lang]/(home)/page.tsx new file mode 100644 index 00000000..22c1296f --- /dev/null +++ b/docs/app/[lang]/(home)/page.tsx @@ -0,0 +1,37 @@ +import Link from 'next/link'; + +const copy = { + cn: { + title: 'Volcengine Agent Development Kit', + subtitle: '火山引擎智能体开发套件', + desc: '构建、部署、观测、评测企业级 AI 智能体的一站式云原生框架。', + cta: '阅读文档', + }, + en: { + title: 'Volcengine Agent Development Kit', + subtitle: 'Build production-ready AI agents', + desc: 'A cloud-native framework to build, deploy, observe, and evaluate enterprise-grade AI agents.', + cta: 'Read the docs', + }, +} as const; + +export default async function HomePage({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + const t = copy[lang as keyof typeof copy] ?? copy.cn; + + return ( +
+

+ {t.title} +

+

{t.subtitle}

+

{t.desc}

+ + {t.cta} + +
+ ); +} diff --git a/docs/app/[lang]/docs/[[...slug]]/page.tsx b/docs/app/[lang]/docs/[[...slug]]/page.tsx new file mode 100644 index 00000000..e3e5081d --- /dev/null +++ b/docs/app/[lang]/docs/[[...slug]]/page.tsx @@ -0,0 +1,71 @@ +import { getPageImage, getPageMarkdownUrl, source } from '@/lib/source'; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, + MarkdownCopyButton, + ViewOptionsPopover, +} from 'fumadocs-ui/layouts/docs/page'; +import { notFound } from 'next/navigation'; +import { getMDXComponents } from '@/components/mdx'; +import type { Metadata } from 'next'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { gitConfig } from '@/lib/shared'; + +export default async function Page({ + params, +}: { + params: Promise<{ lang: string; slug?: string[] }>; +}) { + const { lang, slug } = await params; + const page = source.getPage(slug, lang); + if (!page) notFound(); + + const MDX = page.data.body; + const markdownUrl = getPageMarkdownUrl(page).url; + + return ( + + {page.data.title} + {page.data.description} +
+ + +
+ + + +
+ ); +} + +export async function generateStaticParams() { + return source.generateParams(); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ lang: string; slug?: string[] }>; +}): Promise { + const { lang, slug } = await params; + const page = source.getPage(slug, lang); + if (!page) notFound(); + + return { + title: page.data.title, + description: page.data.description, + openGraph: { + images: getPageImage(page).url, + }, + }; +} diff --git a/docs/app/[lang]/docs/layout.tsx b/docs/app/[lang]/docs/layout.tsx new file mode 100644 index 00000000..98c900a2 --- /dev/null +++ b/docs/app/[lang]/docs/layout.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; +import { source } from '@/lib/source'; +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { baseOptions } from '@/lib/layout.shared'; +import { BookMarked, LayoutGrid, Terminal } from 'lucide-react'; + +export default async function Layout({ + params, + children, +}: { + params: Promise<{ lang: string }>; + children: ReactNode; +}) { + const { lang } = await params; + const zh = lang === 'cn'; + + // Root dropdown (sidebar RootToggle): Framework / CLI / Reference. + const tabs = [ + { + title: zh ? '框架' : 'Framework', + description: zh ? 'SDK 与核心概念' : 'SDK & core concepts', + url: `/${lang}/docs/framework`, + icon: , + }, + { + title: zh ? '命令行工具' : 'CLI', + description: zh ? '命令行参考' : 'Command-line reference', + url: `/${lang}/docs/cli`, + icon: , + }, + { + title: zh ? '参考' : 'Reference', + description: zh ? 'API · 贡献 · 许可' : 'API · Contributing · License', + url: `/${lang}/docs/references`, + icon: , + }, + ]; + + return ( + + {children} + + ); +} diff --git a/docs/app/[lang]/layout.tsx b/docs/app/[lang]/layout.tsx new file mode 100644 index 00000000..b9183110 --- /dev/null +++ b/docs/app/[lang]/layout.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; +import { RootProvider } from 'fumadocs-ui/provider/next'; +import { i18nProvider } from 'fumadocs-ui/i18n'; +import SearchDialog from '@/components/search'; +import { i18n } from '@/lib/i18n'; +import { translations } from '@/lib/i18n-ui'; +import { HtmlLang } from '@/components/html-lang'; + +export function generateStaticParams() { + return i18n.languages.map((lang) => ({ lang })); +} + +export default async function LangLayout({ + params, + children, +}: { + params: Promise<{ lang: string }>; + children: ReactNode; +}) { + const { lang } = await params; + + return ( + + + {children} + + ); +} diff --git a/docs/app/api/chat/route.ts b/docs/app/api/chat/route.ts new file mode 100644 index 00000000..e53b2c09 --- /dev/null +++ b/docs/app/api/chat/route.ts @@ -0,0 +1,108 @@ +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { convertToModelMessages, stepCountIs, streamText, tool } from 'ai'; +import { z } from 'zod'; +import { source } from '@/lib/source'; +import { Document, type DocumentData } from 'flexsearch'; +import type { ChatUIMessage } from '@/lib/chat-types'; + +export type { ChatUIMessage }; + +interface CustomDocument extends DocumentData { + url: string; + title: string; + description: string; + content: string; +} + +const searchServer = createSearchServer(); + +async function createSearchServer() { + const search = new Document({ + document: { + id: 'url', + index: ['title', 'description', 'content'], + store: true, + }, + }); + + const docs = await chunkedAll( + source.getPages().map(async (page) => { + if (!('getText' in page.data)) return null; + + return { + title: page.data.title, + description: page.data.description, + url: page.url, + content: await page.data.getText('processed'), + } as CustomDocument; + }), + ); + + for (const doc of docs) { + if (doc) search.add(doc); + } + + return search; +} + +async function chunkedAll(promises: Promise[]): Promise { + const SIZE = 50; + const out: O[] = []; + for (let i = 0; i < promises.length; i += SIZE) { + out.push(...(await Promise.all(promises.slice(i, i + SIZE)))); + } + return out; +} + +const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, +}); + +/** System prompt, you can update it to provide more specific information */ +const systemPrompt = [ + 'You are an AI assistant for a documentation site.', + 'Use the `search` tool to retrieve relevant docs context before answering when needed.', + 'The `search` tool returns raw JSON results from documentation. Use those results to ground your answer and cite sources as markdown links using the document `url` field when available.', + 'If you cannot find the answer in search results, say you do not know and suggest a better search query.', +].join('\n'); + +export async function POST(req: Request, ctx: RouteContext<"/api/chat">) { + const reqJson = await req.json(); + + const result = streamText({ + model: openrouter.chat(process.env.OPENROUTER_MODEL ?? 'anthropic/claude-3.5-sonnet'), + stopWhen: stepCountIs(5), + tools: { + search: searchTool, + }, + messages: [ + { role: 'system', content: systemPrompt }, + ...(await convertToModelMessages(reqJson.messages ?? [], { + convertDataPart(part) { + if (part.type === 'data-client') + return { + type: 'text', + text: `[Client Context: ${JSON.stringify(part.data)}]`, + }; + }, + })), + ], + toolChoice: 'auto', + }); + + return result.toUIMessageStreamResponse(); +} + +export type SearchTool = typeof searchTool; + +const searchTool = tool({ + description: 'Search the docs content and return raw JSON results.', + inputSchema: z.object({ + query: z.string(), + limit: z.number().int().min(1).max(100).default(10), + }), + async execute({ query, limit }) { + const search = await searchServer; + return await search.searchAsync(query, { limit, merge: true, enrich: true }); + }, +}); \ No newline at end of file diff --git a/docs/app/api/search/route.ts b/docs/app/api/search/route.ts new file mode 100644 index 00000000..3db2c02b --- /dev/null +++ b/docs/app/api/search/route.ts @@ -0,0 +1,17 @@ +import { source } from '@/lib/source'; +import { createFromSource } from 'fumadocs-core/search/server'; +import { createTokenizer } from '@orama/tokenizers/mandarin'; + +export const revalidate = false; + +// Static (build-time) search index, one per locale. +// Chinese uses Mandarin word segmentation so CJK terms are indexed correctly. +export const { staticGET: GET } = createFromSource(source, { + localeMap: { + cn: { + components: { tokenizer: createTokenizer() }, + search: { threshold: 0, tolerance: 0 }, + }, + en: { language: 'english' }, + }, +}); diff --git a/docs/app/global.css b/docs/app/global.css new file mode 100644 index 00000000..f86f3c9d --- /dev/null +++ b/docs/app/global.css @@ -0,0 +1,12 @@ +@import 'tailwindcss'; +@import 'fumadocs-ui/css/neutral.css'; +@import 'fumadocs-ui/css/preset.css'; + +html { + scrollbar-gutter: stable; +} + +html > body[data-scroll-locked] { + margin-right: 0px !important; + --removed-body-scroll-bar-size: 0px !important; +} diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx new file mode 100644 index 00000000..003da0b2 --- /dev/null +++ b/docs/app/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react'; +import './global.css'; + +// Root layout. The locale-aware provider lives in `app/[lang]/layout.tsx`. +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/docs/app/llms-full.txt/route.ts b/docs/app/llms-full.txt/route.ts new file mode 100644 index 00000000..d355fa4d --- /dev/null +++ b/docs/app/llms-full.txt/route.ts @@ -0,0 +1,11 @@ +import { getLLMText, source } from '@/lib/source'; +import { i18n } from '@/lib/i18n'; + +export const revalidate = false; + +export async function GET() { + const scan = source.getPages(i18n.defaultLanguage).map(getLLMText); + const scanned = await Promise.all(scan); + + return new Response(scanned.join('\n\n')); +} diff --git a/docs/app/llms.mdx/docs/[[...slug]]/route.ts b/docs/app/llms.mdx/docs/[[...slug]]/route.ts new file mode 100644 index 00000000..1a45520d --- /dev/null +++ b/docs/app/llms.mdx/docs/[[...slug]]/route.ts @@ -0,0 +1,24 @@ +import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; +import { i18n } from '@/lib/i18n'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) { + const { slug } = await params; + // remove the appended "content.md" + const page = source.getPage(slug?.slice(0, -1)); + if (!page) notFound(); + + return new Response(await getLLMText(page), { + headers: { + 'Content-Type': 'text/markdown', + }, + }); +} + +export function generateStaticParams() { + return source.getPages(i18n.defaultLanguage).map((page) => ({ + slug: getPageMarkdownUrl(page).segments, + })); +} diff --git a/docs/app/llms.txt/route.ts b/docs/app/llms.txt/route.ts new file mode 100644 index 00000000..fc80cb65 --- /dev/null +++ b/docs/app/llms.txt/route.ts @@ -0,0 +1,8 @@ +import { source } from '@/lib/source'; +import { llms } from 'fumadocs-core/source'; + +export const revalidate = false; + +export function GET() { + return new Response(llms(source).index()); +} diff --git a/docs/app/og/docs/[...slug]/route.tsx b/docs/app/og/docs/[...slug]/route.tsx new file mode 100644 index 00000000..9668951c --- /dev/null +++ b/docs/app/og/docs/[...slug]/route.tsx @@ -0,0 +1,28 @@ +import { getPageImage, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; +import { ImageResponse } from 'next/og'; +import { generate as DefaultImage } from 'fumadocs-ui/og'; +import { appName } from '@/lib/shared'; +import { i18n } from '@/lib/i18n'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { + const { slug } = await params; + const page = source.getPage(slug.slice(0, -1)); + if (!page) notFound(); + + return new ImageResponse( + , + { + width: 1200, + height: 630, + }, + ); +} + +export function generateStaticParams() { + return source.getPages(i18n.defaultLanguage).map((page) => ({ + slug: getPageImage(page).segments, + })); +} diff --git a/docs/app/page.tsx b/docs/app/page.tsx new file mode 100644 index 00000000..a378c00b --- /dev/null +++ b/docs/app/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useEffect } from 'react'; + +// Static-export friendly root redirect to the default locale. +// Uses a relative path so it works under a GitHub Pages base path. +export default function RootRedirect() { + useEffect(() => { + window.location.replace('cn/'); + }, []); + + return ( + + ); +} diff --git a/docs/biome.json b/docs/biome.json new file mode 100644 index 00000000..64ae1e3b --- /dev/null +++ b/docs/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!node_modules", + "!.next", + "!dist", + "!build", + "!.source" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + }, + "domains": { + "next": "recommended", + "react": "recommended" + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} \ No newline at end of file diff --git a/docs/components/ai/search.tsx b/docs/components/ai/search.tsx new file mode 100644 index 00000000..1ce008c2 --- /dev/null +++ b/docs/components/ai/search.tsx @@ -0,0 +1,477 @@ +'use client'; +import { + type ComponentProps, + createContext, + type ReactNode, + type SyntheticEvent, + use, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from 'react'; +import { Loader2, MessageCircleIcon, RefreshCw, SearchIcon, Send, X } from 'lucide-react'; +import { cn } from '../../lib/cn'; +import { buttonVariants } from '../ui/button'; +import { useChat, type UseChatHelpers } from '@ai-sdk/react'; +import { DefaultChatTransport, type Tool, type UIToolInvocation } from 'ai'; +import { Markdown } from '../markdown'; +import { Presence } from '@radix-ui/react-presence'; +import type { ChatUIMessage, SearchTool } from '../../lib/chat-types'; + +const Context = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; + chat: UseChatHelpers; +} | null>(null); + +export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) { + const { setOpen } = useAISearchContext(); + + return ( +
+
+

AI Chat

+

+ AI can be inaccurate, please verify the answers. +

+
+ + +
+ ); +} + +export function AISearchInputActions() { + const { messages, status, setMessages, regenerate } = useChatContext(); + const isLoading = status === 'streaming'; + + if (messages.length === 0) return null; + + return ( + <> + {!isLoading && messages.at(-1)?.role === 'assistant' && ( + + )} + + + ); +} + +const StorageKeyInput = '__ai_search_input'; +export function AISearchInput(props: ComponentProps<'form'>) { + const { status, sendMessage, stop } = useChatContext(); + const [input, setInput] = useState(() => localStorage.getItem(StorageKeyInput) ?? ''); + const isLoading = status === 'streaming' || status === 'submitted'; + const onStart = (e?: SyntheticEvent) => { + e?.preventDefault(); + const message = input.trim(); + if (message.length === 0) return; + + void sendMessage({ + role: 'user', + parts: [ + { + type: 'data-client', + data: { + location: location.href, + }, + }, + { + type: 'text', + text: message, + }, + ], + }); + setInput(''); + localStorage.removeItem(StorageKeyInput); + }; + + useEffect(() => { + if (isLoading) document.getElementById('nd-ai-input')?.focus(); + }, [isLoading]); + + return ( +
+ { + setInput(e.target.value); + localStorage.setItem(StorageKeyInput, e.target.value); + }} + onKeyDown={(event) => { + if (!event.shiftKey && event.key === 'Enter') { + onStart(event); + } + }} + /> + {isLoading ? ( + + ) : ( + + )} +
+ ); +} + +function List(props: Omit, 'dir'>) { + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + function callback() { + const container = containerRef.current; + if (!container) return; + + container.scrollTo({ + top: container.scrollHeight, + behavior: 'instant', + }); + } + + const observer = new ResizeObserver(callback); + callback(); + + const element = containerRef.current?.firstElementChild; + + if (element) { + observer.observe(element); + } + + return () => { + observer.disconnect(); + }; + }, []); + + return ( +
+ {props.children} +
+ ); +} + +function Input(props: ComponentProps<'textarea'>) { + const ref = useRef(null); + const shared = cn('col-start-1 row-start-1', props.className); + + return ( +
+