Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

162 lines
5.2 KiB
TypeScript

"use client";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import {
VoiceOrb,
VoiceConnectButton,
VoiceMuteButton,
VoiceDisconnectButton,
deriveVoiceOrbState,
} from "@/components/assistant-ui/voice";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import {
AuiIf,
MessagePrimitive,
ThreadPrimitive,
useAuiState,
useVoiceState,
useVoiceVolume,
} from "@assistant-ui/react";
import { ArrowDownIcon } from "lucide-react";
import type { FC } from "react";
export const VoiceThread: FC = () => {
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root bg-background flex h-full flex-col"
style={{ ["--thread-max-width" as string]: "44rem" }}
>
<ThreadPrimitive.Viewport className="aui-thread-viewport relative flex flex-1 flex-col overflow-y-scroll scroll-smooth px-4 pt-4">
<AuiIf condition={(s) => s.thread.isEmpty}>
<VoiceWelcome />
</AuiIf>
<ThreadPrimitive.Messages>
{() => <ThreadMessage />}
</ThreadPrimitive.Messages>
<ThreadPrimitive.ViewportFooter className="bg-background sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col items-center pt-4 pb-6">
<ThreadScrollToBottom />
<VoiceControlCenter />
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
};
const VoiceWelcome: FC = () => {
return (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center justify-center gap-2">
<p className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-2xl font-semibold duration-200">
Voice Conversation
</p>
<p className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground delay-75 duration-200">
Tap connect to start speaking
</p>
</div>
);
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="dark:border-border dark:bg-background dark:hover:bg-accent absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
const VoiceControlCenter: FC = () => {
return (
<div className="aui-voice-control-center flex flex-col items-center gap-4">
<VoiceOrb variant="blue" className="size-20" />
<VoiceWaveform />
<div className="flex items-center gap-3">
<AuiIf
condition={(s) =>
s.thread.voice == null || s.thread.voice.status.type === "ended"
}
>
<VoiceConnectButton />
</AuiIf>
<AuiIf condition={(s) => s.thread.voice?.status.type === "starting"}>
<span className="text-muted-foreground text-sm">Connecting...</span>
</AuiIf>
<AuiIf condition={(s) => s.thread.voice?.status.type === "running"}>
<VoiceMuteButton />
<VoiceDisconnectButton />
</AuiIf>
</div>
</div>
);
};
const BAR_WEIGHTS = [0.4, 0.65, 0.85, 1.0, 0.9, 0.75, 0.5];
const VoiceWaveform: FC = () => {
const voiceState = useVoiceState();
const state = deriveVoiceOrbState(voiceState);
const volume = useVoiceVolume();
const isActive = state === "listening" || state === "speaking";
return (
<div className="aui-voice-waveform flex h-8 items-center justify-center gap-[3px]">
{BAR_WEIGHTS.map((weight, i) => {
const scale = isActive ? 0.1 + volume * 0.9 * weight : 0.1;
return (
<span
key={i}
className="aui-voice-waveform-bar bg-foreground/50 h-full w-[3px] origin-center rounded-full transition-transform duration-100"
style={{ transform: `scaleY(${scale})` }}
/>
);
})}
</div>
);
};
const ThreadMessage: FC = () => {
const role = useAuiState((s) => s.message.role);
if (role === "user") return <UserMessage />;
return <AssistantMessage />;
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 animate-in relative mx-auto w-full max-w-(--thread-max-width) py-3 duration-150"
data-role="assistant"
>
<div className="aui-assistant-message-content text-foreground px-2 leading-relaxed wrap-break-word">
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === "text") return <MarkdownText />;
return null;
}}
</MessagePrimitive.Parts>
</div>
</MessagePrimitive.Root>
);
};
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-user-message-root fade-in slide-in-from-bottom-1 animate-in mx-auto flex w-full max-w-(--thread-max-width) justify-end px-2 py-3 duration-150"
data-role="user"
>
<div className="aui-user-message-content bg-muted text-foreground rounded-2xl px-4 py-2.5 wrap-break-word">
<MessagePrimitive.Parts />
</div>
</MessagePrimitive.Root>
);
};