增加AI页面在其它页面的展示
This commit is contained in:
+11
-1
@@ -20,6 +20,12 @@ import VUPage from "./pages/VUPage";
|
|||||||
import SelectPage from "./pages/SelectPage";
|
import SelectPage from "./pages/SelectPage";
|
||||||
import AIPage from "./pages/AIPage";
|
import AIPage from "./pages/AIPage";
|
||||||
import CommunityPage from "./pages/CommunityPage";
|
import CommunityPage from "./pages/CommunityPage";
|
||||||
|
import { AIDrawerProvider } from "./contexts/AIDrawerContext";
|
||||||
|
import DesktopAIShell from "./components/DesktopAIShell";
|
||||||
|
|
||||||
|
function AIPageRoute() {
|
||||||
|
return <AIPage />;
|
||||||
|
}
|
||||||
|
|
||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +42,7 @@ function AppRouter() {
|
|||||||
<Route path="/io" component={IOPage} />
|
<Route path="/io" component={IOPage} />
|
||||||
<Route path="/vu" component={VUPage} />
|
<Route path="/vu" component={VUPage} />
|
||||||
<Route path="/select" component={SelectPage} />
|
<Route path="/select" component={SelectPage} />
|
||||||
<Route path="/ai" component={AIPage} />
|
<Route path="/ai" component={AIPageRoute} />
|
||||||
<Route path="/community" component={CommunityPage} />
|
<Route path="/community" component={CommunityPage} />
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
@@ -52,12 +58,16 @@ function App() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Router hook={useHashLocation}>
|
<Router hook={useHashLocation}>
|
||||||
|
<AIDrawerProvider>
|
||||||
{/* Desktop: narrow shell centered in the browser with side gutters */}
|
{/* Desktop: narrow shell centered in the browser with side gutters */}
|
||||||
<div className="min-h-dvh bg-[#0d1117]">
|
<div className="min-h-dvh bg-[#0d1117]">
|
||||||
<div className="mx-auto min-h-dvh w-full max-w-full lg:max-w-[min(1180px,calc(100vw-3rem))]">
|
<div className="mx-auto min-h-dvh w-full max-w-full lg:max-w-[min(1180px,calc(100vw-3rem))]">
|
||||||
|
<DesktopAIShell>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
|
</DesktopAIShell>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AIDrawerProvider>
|
||||||
</Router>
|
</Router>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</DeviceProvider>
|
</DeviceProvider>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
- Inactive: muted icon + label
|
- Inactive: muted icon + label
|
||||||
============================================================ */
|
============================================================ */
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAIDrawer } from "@/contexts/AIDrawerContext";
|
||||||
|
import { useIsLgUp } from "@/hooks/useIsLgUp";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -31,6 +33,8 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
|
|
||||||
export default function BottomNav() {
|
export default function BottomNav() {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
|
const isLgUp = useIsLgUp();
|
||||||
|
const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -46,13 +50,15 @@ export default function BottomNav() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-stretch justify-around">
|
<div className="flex items-stretch justify-around">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isActive = location === item.path ||
|
const isActive =
|
||||||
|
item.path === "/ai"
|
||||||
|
? location === "/ai" || (isLgUp && aiDrawerOpen)
|
||||||
|
: location === item.path ||
|
||||||
(item.path === "/" && location === "/") ||
|
(item.path === "/" && location === "/") ||
|
||||||
(item.path !== "/" && location.startsWith(item.path));
|
(item.path !== "/" && location.startsWith(item.path));
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
|
||||||
return (
|
const inner = (
|
||||||
<Link key={item.path} href={item.path}>
|
|
||||||
<div className="flex flex-col items-center justify-center relative pt-2.5 pb-1.5 px-3">
|
<div className="flex flex-col items-center justify-center relative pt-2.5 pb-1.5 px-3">
|
||||||
{/* Active indicator bar */}
|
{/* Active indicator bar */}
|
||||||
<div
|
<div
|
||||||
@@ -99,6 +105,41 @@ export default function BottomNav() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item.path === "/ai") {
|
||||||
|
if (!isLgUp) {
|
||||||
|
return (
|
||||||
|
<Link key={item.path} href={item.path}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.path}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="cursor-pointer select-none"
|
||||||
|
onClick={() => {
|
||||||
|
if (location === "/ai") return;
|
||||||
|
setAiDrawerOpen((o) => !o);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (location === "/ai") return;
|
||||||
|
setAiDrawerOpen((o) => !o);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={item.path} href={item.path}>
|
||||||
|
{inner}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import AIPage from "@/pages/AIPage";
|
||||||
|
import { useAIDrawer } from "@/contexts/AIDrawerContext";
|
||||||
|
import { useIsLgUp } from "@/hooks/useIsLgUp";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
function SplitDivider({
|
||||||
|
containerRef,
|
||||||
|
onResize,
|
||||||
|
}: {
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
onResize: (aiPercent: number) => void;
|
||||||
|
}) {
|
||||||
|
const dragging = useRef(false);
|
||||||
|
|
||||||
|
const onMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!dragging.current || !containerRef.current) return;
|
||||||
|
const r = containerRef.current.getBoundingClientRect();
|
||||||
|
const w = r.width;
|
||||||
|
if (w <= 0) return;
|
||||||
|
const aiPct = ((r.right - e.clientX) / w) * 100;
|
||||||
|
onResize(aiPct);
|
||||||
|
},
|
||||||
|
[containerRef, onResize],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onUp = () => {
|
||||||
|
dragging.current = false;
|
||||||
|
};
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
};
|
||||||
|
}, [onMove]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative z-10 w-2 shrink-0 cursor-col-resize bg-transparent",
|
||||||
|
"hover:bg-[#00FFF6]/10",
|
||||||
|
)}
|
||||||
|
onMouseDown={() => {
|
||||||
|
dragging.current = true;
|
||||||
|
}}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize panels"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white/15 transition-colors group-hover:bg-[#00FFF6]/45" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lg+: optional side-by-side AI panel with draggable divider. Mobile unchanged (router only).
|
||||||
|
*/
|
||||||
|
export default function DesktopAIShell({ children }: { children: ReactNode }) {
|
||||||
|
const isLgUp = useIsLgUp();
|
||||||
|
const [location] = useLocation();
|
||||||
|
const { open, setOpen, splitPercent, setSplitPercent } = useAIDrawer();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const showSplit = Boolean(isLgUp && open && location !== "/ai");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location === "/ai" && isLgUp) setOpen(false);
|
||||||
|
}, [location, isLgUp, setOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"w-full min-w-0",
|
||||||
|
showSplit
|
||||||
|
? "flex h-dvh max-h-dvh flex-row overflow-hidden"
|
||||||
|
: "min-h-dvh",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0",
|
||||||
|
showSplit
|
||||||
|
? "h-full min-h-0 overflow-y-auto overflow-x-hidden"
|
||||||
|
: "min-h-dvh overflow-x-hidden",
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
showSplit
|
||||||
|
? { flex: `${100 - splitPercent} 1 0%`, minWidth: 0 }
|
||||||
|
: { width: "100%" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{showSplit && (
|
||||||
|
<>
|
||||||
|
<SplitDivider
|
||||||
|
containerRef={containerRef}
|
||||||
|
onResize={(aiPct) => setSplitPercent(aiPct)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden border-l border-white/[0.08] bg-[#0d1117]"
|
||||||
|
style={{ flex: `${splitPercent} 1 0%` }}
|
||||||
|
>
|
||||||
|
<AIPage variant="split" onClose={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type Dispatch,
|
||||||
|
type ReactNode,
|
||||||
|
type SetStateAction,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
const SPLIT_STORAGE_KEY = "luxsin-ai-split-pct";
|
||||||
|
const SPLIT_MIN = 28;
|
||||||
|
const SPLIT_MAX = 72;
|
||||||
|
|
||||||
|
function clampPct(n: number) {
|
||||||
|
return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSplitPct(): number {
|
||||||
|
if (typeof window === "undefined") return 50;
|
||||||
|
try {
|
||||||
|
const v = sessionStorage.getItem(SPLIT_STORAGE_KEY);
|
||||||
|
if (v) {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!Number.isNaN(n)) return clampPct(n);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIDrawerContextValue = {
|
||||||
|
/** Desktop split panel visible (only used when lg+ and route !== /ai) */
|
||||||
|
open: boolean;
|
||||||
|
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
toggle: () => void;
|
||||||
|
/** Width share of the AI column (percent), used when split view is shown */
|
||||||
|
splitPercent: number;
|
||||||
|
setSplitPercent: (pct: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AIDrawerContext = createContext<AIDrawerContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AIDrawerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [splitPercent, setSplitPercentState] = useState(loadSplitPct);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => setOpen((o) => !o), []);
|
||||||
|
|
||||||
|
const setSplitPercent = useCallback((pct: number) => {
|
||||||
|
const next = clampPct(pct);
|
||||||
|
setSplitPercentState(next);
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SPLIT_STORAGE_KEY, String(next));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ open, setOpen, toggle, splitPercent, setSplitPercent }),
|
||||||
|
[open, splitPercent, setSplitPercent, toggle],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIDrawerContext.Provider value={value}>{children}</AIDrawerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAIDrawer() {
|
||||||
|
const ctx = useContext(AIDrawerContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAIDrawer must be used within AIDrawerProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Matches Tailwind `lg:` (1024px). Used for desktop-only UI such as the AI split panel. */
|
||||||
|
export function useIsLgUp() {
|
||||||
|
const [lgUp, setLgUp] = useState(
|
||||||
|
() =>
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(min-width: 1024px)").matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia("(min-width: 1024px)");
|
||||||
|
const sync = () => setLgUp(mql.matches);
|
||||||
|
mql.addEventListener("change", sync);
|
||||||
|
sync();
|
||||||
|
return () => mql.removeEventListener("change", sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return lgUp;
|
||||||
|
}
|
||||||
@@ -506,6 +506,8 @@
|
|||||||
"ariaNewChat": "New chat",
|
"ariaNewChat": "New chat",
|
||||||
"ariaHistory": "History",
|
"ariaHistory": "History",
|
||||||
"ariaClose": "Close",
|
"ariaClose": "Close",
|
||||||
|
"ariaFullPage": "Open full screen",
|
||||||
|
"ariaHidePanel": "Hide AI panel",
|
||||||
"clearCurrent": "Clear this chat",
|
"clearCurrent": "Clear this chat",
|
||||||
"cleared": "Current chat cleared",
|
"cleared": "Current chat cleared",
|
||||||
"clearFailed": "Clear failed: {message}",
|
"clearFailed": "Clear failed: {message}",
|
||||||
|
|||||||
@@ -320,6 +320,8 @@
|
|||||||
"ariaNewChat": "新增會話",
|
"ariaNewChat": "新增會話",
|
||||||
"ariaHistory": "歷史會話",
|
"ariaHistory": "歷史會話",
|
||||||
"ariaClose": "關閉",
|
"ariaClose": "關閉",
|
||||||
|
"ariaFullPage": "全螢幕開啟",
|
||||||
|
"ariaHidePanel": "收起 AI 面板",
|
||||||
"clearCurrent": "清空目前會話",
|
"clearCurrent": "清空目前會話",
|
||||||
"cleared": "已清空目前會話",
|
"cleared": "已清空目前會話",
|
||||||
"clearFailed": "清空失敗:{message}",
|
"clearFailed": "清空失敗:{message}",
|
||||||
|
|||||||
@@ -321,6 +321,8 @@
|
|||||||
"ariaNewChat": "新建会话",
|
"ariaNewChat": "新建会话",
|
||||||
"ariaHistory": "历史会话",
|
"ariaHistory": "历史会话",
|
||||||
"ariaClose": "关闭",
|
"ariaClose": "关闭",
|
||||||
|
"ariaFullPage": "全屏打开",
|
||||||
|
"ariaHidePanel": "收起 AI 面板",
|
||||||
"clearCurrent": "清空当前会话",
|
"clearCurrent": "清空当前会话",
|
||||||
"cleared": "已清空当前会话",
|
"cleared": "已清空当前会话",
|
||||||
"clearFailed": "清空失败:{message}",
|
"clearFailed": "清空失败:{message}",
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Loader2,
|
Loader2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Maximize2,
|
||||||
|
ChevronsRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -134,7 +137,17 @@ function makeId() {
|
|||||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AIPage() {
|
export type AIPageVariant = "page" | "split";
|
||||||
|
|
||||||
|
export interface AIPageProps {
|
||||||
|
variant?: AIPageVariant;
|
||||||
|
/** Hide side panel (desktop split) */
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) {
|
||||||
|
const isEmbedded = variant === "split";
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
const { isConnected, deviceState, api } = useDevice();
|
const { isConnected, deviceState, api } = useDevice();
|
||||||
const mac = deviceState?.mac ?? "";
|
const mac = deviceState?.mac ?? "";
|
||||||
const language = deviceState?.language ?? 2;
|
const language = deviceState?.language ?? 2;
|
||||||
@@ -610,11 +623,41 @@ export default function AIPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-[100dvh] bg-black flex flex-col overflow-hidden pb-[calc(env(safe-area-inset-bottom,0px)+56px)]"
|
className={cn(
|
||||||
|
"flex flex-col overflow-hidden bg-black",
|
||||||
|
/* Same clearance as Layout/main above fixed BottomNav */
|
||||||
|
"pb-[calc(4rem+env(safe-area-inset-bottom,0px))]",
|
||||||
|
isEmbedded ? "h-full min-h-0" : "h-[100dvh]",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="page-header flex-shrink-0">
|
<div className="page-header flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 mr-auto">
|
<div className="flex items-center gap-2 mr-auto">
|
||||||
|
{isEmbedded && onClose && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="mr-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-white/70 transition-colors active:text-white"
|
||||||
|
style={{ background: "rgba(44,44,46,0.6)" }}
|
||||||
|
aria-label={aiText.ariaHidePanel}
|
||||||
|
>
|
||||||
|
<ChevronsRight size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLocation("/ai");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="mr-1 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-white/70 transition-colors active:text-white"
|
||||||
|
style={{ background: "rgba(44,44,46,0.6)" }}
|
||||||
|
aria-label={aiText.ariaFullPage}
|
||||||
|
>
|
||||||
|
<Maximize2 size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="w-7 h-7 rounded-full flex items-center justify-center"
|
className="w-7 h-7 rounded-full flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
@@ -790,7 +833,7 @@ export default function AIPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<BottomNav />
|
{!isEmbedded && <BottomNav />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user