feat: extend agent-driven map workbench
This commit is contained in:
@@ -8,13 +8,23 @@ import {
|
||||
DatabaseZap,
|
||||
GitMerge,
|
||||
History,
|
||||
LoaderCircle,
|
||||
MapPinned,
|
||||
Mic,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
SendHorizontal
|
||||
SendHorizontal,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Volume2
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
|
||||
import {
|
||||
Conversation,
|
||||
@@ -52,7 +62,15 @@ import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-detai
|
||||
import { AgentOperationalBrief } from "./agent-operational-brief";
|
||||
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
|
||||
import { StreamingTokenResponse } from "./streaming-token-response";
|
||||
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
|
||||
import {
|
||||
findSpeechSelectionStartOffset,
|
||||
stripSpeechMarkdown,
|
||||
type AgentSpeakOptions,
|
||||
type AgentSpeechState
|
||||
} from "../speech";
|
||||
import type {
|
||||
AgentApprovalMode,
|
||||
AgentChatMessage,
|
||||
AgentModelOption,
|
||||
AgentPermissionReply,
|
||||
@@ -75,6 +93,7 @@ type AgentCommandPanelProps = {
|
||||
streamRenderState?: AgentStreamRenderState;
|
||||
modelOptions?: AgentModelOption[];
|
||||
selectedModel?: string;
|
||||
approvalMode?: AgentApprovalMode;
|
||||
uiResults?: AgentUiResult[];
|
||||
onCollapse: () => void;
|
||||
onRefreshHistory?: () => Promise<void> | void;
|
||||
@@ -83,6 +102,7 @@ type AgentCommandPanelProps = {
|
||||
onRenameHistorySession?: (sessionId: string, title: string) => Promise<void> | void;
|
||||
onDeleteHistorySession?: (sessionId: string) => Promise<void> | void;
|
||||
onSelectModel?: (model: string) => void;
|
||||
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
|
||||
onSubmitPrompt: (prompt: string) => Promise<void> | void;
|
||||
onStopPrompt?: () => void;
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
@@ -116,6 +136,7 @@ export function AgentCommandPanel({
|
||||
streamRenderState = {},
|
||||
modelOptions = [],
|
||||
selectedModel = "",
|
||||
approvalMode = "request",
|
||||
uiResults = [],
|
||||
onCollapse,
|
||||
onRefreshHistory,
|
||||
@@ -124,6 +145,7 @@ export function AgentCommandPanel({
|
||||
onRenameHistorySession,
|
||||
onDeleteHistorySession,
|
||||
onSelectModel,
|
||||
onApprovalModeChange,
|
||||
onSubmitPrompt,
|
||||
onStopPrompt,
|
||||
onReplyPermission,
|
||||
@@ -135,6 +157,33 @@ export function AgentCommandPanel({
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [scrollRequestId, setScrollRequestId] = useState(0);
|
||||
const trimmedPrompt = prompt.trim();
|
||||
const {
|
||||
speechState,
|
||||
speakingMessageId,
|
||||
speak,
|
||||
pause: pauseSpeech,
|
||||
resume: resumeSpeech,
|
||||
stop: stopSpeech,
|
||||
isSupported: isSpeechSupported
|
||||
} = useAgentSpeechSynthesis();
|
||||
const appendRecognitionResult = useCallback((text: string) => {
|
||||
setPrompt((current) => `${current}${current.trim() ? " " : ""}${text}`);
|
||||
}, []);
|
||||
const showRecognitionError = useCallback((message: string) => {
|
||||
showMapNotice({
|
||||
id: "agent-speech-recognition-error",
|
||||
tone: "error",
|
||||
title: "语音输入不可用",
|
||||
message,
|
||||
duration: 5000
|
||||
});
|
||||
}, []);
|
||||
const {
|
||||
isListening,
|
||||
start: startListening,
|
||||
stop: stopListening,
|
||||
isSupported: isRecognitionSupported
|
||||
} = useAgentSpeechRecognition(appendRecognitionResult);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyOpen) {
|
||||
@@ -142,11 +191,20 @@ export function AgentCommandPanel({
|
||||
}
|
||||
}, [historyOpen, onRefreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
stopSpeech();
|
||||
}, [activeSessionId, stopSpeech]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming) stopListening();
|
||||
}, [stopListening, streaming]);
|
||||
|
||||
const submitPrompt = (nextPrompt: string) => {
|
||||
if (!nextPrompt || streaming) {
|
||||
return;
|
||||
}
|
||||
setPrompt("");
|
||||
stopListening();
|
||||
setScrollRequestId((requestId) => requestId + 1);
|
||||
void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
|
||||
setPrompt(nextPrompt);
|
||||
@@ -166,7 +224,7 @@ export function AgentCommandPanel({
|
||||
)}
|
||||
>
|
||||
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
||||
<header className="agent-panel-header acrylic-panel">
|
||||
<header className="agent-panel-header">
|
||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona
|
||||
@@ -289,6 +347,13 @@ export function AgentCommandPanel({
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
isSpeechSupported={isSpeechSupported}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={speak}
|
||||
onPauseSpeech={pauseSpeech}
|
||||
onResumeSpeech={resumeSpeech}
|
||||
onStopSpeech={stopSpeech}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
@@ -349,13 +414,27 @@ export function AgentCommandPanel({
|
||||
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="justify-end">
|
||||
<PromptInputFooter className="justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ApprovalModeSelect
|
||||
value={approvalMode}
|
||||
disabled={streaming}
|
||||
onValueChange={onApprovalModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<AgentModelSelect
|
||||
models={modelOptions}
|
||||
value={selectedModel}
|
||||
onValueChange={onSelectModel}
|
||||
/>
|
||||
{isRecognitionSupported ? (
|
||||
<VoiceInputButton
|
||||
isListening={isListening}
|
||||
disabled={streaming}
|
||||
onClick={isListening ? stopListening : () => startListening(showRecognitionError)}
|
||||
/>
|
||||
) : null}
|
||||
<PromptInputSubmit
|
||||
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||||
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
|
||||
@@ -376,6 +455,177 @@ export function AgentCommandPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const VOICE_WAVE_BARS = [
|
||||
{ height: 5, duration: 0.72, delay: 0.08 },
|
||||
{ height: 10, duration: 0.58, delay: 0 },
|
||||
{ height: 15, duration: 0.66, delay: 0.12 },
|
||||
{ height: 9, duration: 0.62, delay: 0.04 },
|
||||
{ height: 4, duration: 0.7, delay: 0.16 }
|
||||
] as const;
|
||||
|
||||
function VoiceInputButton({
|
||||
isListening,
|
||||
disabled,
|
||||
onClick
|
||||
}: {
|
||||
isListening: boolean;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const label = isListening ? "停止语音输入" : "语音输入";
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={isListening}
|
||||
title={label}
|
||||
className={cn(
|
||||
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border",
|
||||
"border-transparent bg-transparent text-blue-600 shadow-none",
|
||||
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
||||
"hover:bg-slate-50 hover:text-blue-700",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
|
||||
"disabled:cursor-not-allowed disabled:opacity-45",
|
||||
isListening &&
|
||||
"bg-red-50 text-red-600 hover:bg-red-100 hover:text-red-700"
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
|
||||
>
|
||||
{isListening ? (
|
||||
<motion.span
|
||||
data-slot="voice-input-pulse"
|
||||
className="pointer-events-none absolute inset-0 rounded-lg bg-red-400/20"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }}
|
||||
transition={reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<span className="relative grid h-5 w-5 place-items-center overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isListening ? (
|
||||
<motion.span
|
||||
key="voice-wave"
|
||||
className="flex h-4 items-center gap-0.5"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0, scale: 0.72 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.72 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
>
|
||||
{VOICE_WAVE_BARS.map((bar) => (
|
||||
<motion.span
|
||||
key={bar.height}
|
||||
className="w-px rounded-full bg-red-500"
|
||||
style={{ height: bar.height }}
|
||||
animate={
|
||||
reduceMotion
|
||||
? { scaleY: 0.72 }
|
||||
: { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
|
||||
}
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0.16 }
|
||||
: { duration: bar.duration, delay: bar.delay, ease: "easeInOut", repeat: Infinity }
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="voice-microphone"
|
||||
className="grid place-items-center"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0, scale: 0.72, y: 2 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.72, y: -2 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
>
|
||||
<Mic
|
||||
size={15}
|
||||
strokeWidth={2.15}
|
||||
className="transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalModeSelect({
|
||||
value,
|
||||
disabled,
|
||||
onValueChange
|
||||
}: {
|
||||
value: AgentApprovalMode;
|
||||
disabled: boolean;
|
||||
onValueChange?: (mode: AgentApprovalMode) => void;
|
||||
}) {
|
||||
const requestApproval = value === "request";
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
disabled={disabled || !onValueChange}
|
||||
aria-label="权限批准模式"
|
||||
>
|
||||
{requestApproval ? <Shield size={14} aria-hidden="true" /> : <ShieldCheck size={14} aria-hidden="true" />}
|
||||
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
|
||||
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
|
||||
>
|
||||
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">权限批准模式</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||||
requestApproval && "border-blue-200 bg-blue-50 focus:bg-blue-50"
|
||||
)}
|
||||
onSelect={() => onValueChange?.("request")}
|
||||
>
|
||||
<Shield className="mt-0.5 text-blue-600" aria-hidden="true" />
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
|
||||
<span className="block text-xs leading-5 text-slate-500">工具权限逐次确认</span>
|
||||
</span>
|
||||
{requestApproval ? <CheckCircle2 className="ml-auto mt-0.5 text-blue-600" aria-hidden="true" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
|
||||
!requestApproval && "border-emerald-200 bg-emerald-50 focus:bg-emerald-50"
|
||||
)}
|
||||
onSelect={() => onValueChange?.("always")}
|
||||
>
|
||||
<ShieldCheck className="mt-0.5 text-emerald-600" aria-hidden="true" />
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
|
||||
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span>
|
||||
</span>
|
||||
{!requestApproval ? <CheckCircle2 className="ml-auto mt-0.5 text-emerald-600" aria-hidden="true" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type AgentConversationScrollSnapshot = {
|
||||
activeSessionId?: string | null;
|
||||
lastUserMessageId: string | null;
|
||||
@@ -593,27 +843,46 @@ function AgentEmptyState() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="agent-empty-state-title"
|
||||
className="surface-reading mx-auto w-full max-w-sm rounded-2xl border border-slate-200/80 px-4 pb-4 pt-5 text-center shadow-sm"
|
||||
className="surface-reading mx-auto w-full max-w-[360px] overflow-hidden rounded-2xl border border-slate-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.06),0_12px_32px_rgba(15,23,42,0.07)]"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full border border-violet-100 bg-white shadow-[0_10px_28px_rgba(126,34,206,0.12)]">
|
||||
<AgentReadyMark />
|
||||
<div className="px-4 py-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid h-[72px] w-[72px] shrink-0 place-items-center rounded-2xl bg-[oklch(0.97_0.018_292)] shadow-[inset_0_0_0_1px_oklch(0.91_0.03_292),0_12px_30px_rgba(88,71,120,0.10)]">
|
||||
<AgentReadyMark />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<h2
|
||||
id="agent-empty-state-title"
|
||||
className="text-balance text-lg font-semibold leading-7 text-slate-900"
|
||||
>
|
||||
我已就绪,请描述任务
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-pretty text-left text-sm leading-6 text-slate-500">
|
||||
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
|
||||
</p>
|
||||
</div>
|
||||
<h2 id="agent-empty-state-title" className="mt-4 text-lg font-semibold leading-7 text-slate-900">
|
||||
我已就绪,请描述任务
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-[30ch] text-sm leading-6 text-slate-500">
|
||||
使用自然语言下达指令,我会整理监测与空间证据,并在地图上呈现分析结果。
|
||||
</p>
|
||||
<ul className="mt-5 grid grid-cols-2 gap-2" aria-label="Agent 分析能力">
|
||||
{AGENT_CAPABILITIES.map(({ icon: Icon, label }) => (
|
||||
|
||||
<ul
|
||||
className="grid grid-cols-2 border-t border-slate-200/70 bg-slate-50/55"
|
||||
aria-label="Agent 分析能力"
|
||||
>
|
||||
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
|
||||
<li
|
||||
key={label}
|
||||
className="surface-control flex min-h-16 flex-col items-center justify-center gap-1.5 rounded-xl border border-slate-200/70 px-2 py-2.5 text-center shadow-[0_6px_18px_rgba(15,23,42,0.04)] sm:flex-row sm:gap-2 sm:px-2.5 sm:text-left"
|
||||
className={cn(
|
||||
"flex min-h-[72px] items-center gap-3 px-3.5 py-3 text-left",
|
||||
index % 2 === 0 && "border-r border-slate-200/70",
|
||||
index < 2 && "border-b border-slate-200/70"
|
||||
)}
|
||||
>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
|
||||
<Icon size={17} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600">
|
||||
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-xs font-semibold leading-5 text-slate-800">{label}</span>
|
||||
<span className="text-xs font-medium leading-5 text-slate-700">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -627,7 +896,14 @@ function BackendMessageList({
|
||||
streamRenderState,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
onRejectQuestion,
|
||||
isSpeechSupported,
|
||||
speakingMessageId,
|
||||
speechState,
|
||||
onSpeak,
|
||||
onPauseSpeech,
|
||||
onResumeSpeech,
|
||||
onStopSpeech
|
||||
}: {
|
||||
messages: AgentChatMessage[];
|
||||
streaming: boolean;
|
||||
@@ -635,6 +911,13 @@ function BackendMessageList({
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
isSpeechSupported: boolean;
|
||||
speakingMessageId: string | null;
|
||||
speechState: AgentSpeechState;
|
||||
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
|
||||
onPauseSpeech: () => void;
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.div className="flex w-full flex-col gap-4" variants={agentPanelListVariants} animate="animate">
|
||||
@@ -667,13 +950,25 @@ function BackendMessageList({
|
||||
) : null}
|
||||
{message.content ? (
|
||||
message.role === "assistant" ? (
|
||||
<StreamingTokenResponse
|
||||
className="leading-6"
|
||||
content={message.content}
|
||||
<AgentSpeechMessage
|
||||
messageId={message.id}
|
||||
streamDone={streamRenderState[message.id]?.done ?? !streaming}
|
||||
streaming={streaming && index === messages.length - 1}
|
||||
/>
|
||||
content={message.content}
|
||||
disabled={streaming && index === messages.length - 1}
|
||||
isSupported={isSpeechSupported}
|
||||
speechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
onStop={onStopSpeech}
|
||||
>
|
||||
<StreamingTokenResponse
|
||||
className="leading-6"
|
||||
content={message.content}
|
||||
messageId={message.id}
|
||||
streamDone={streamRenderState[message.id]?.done ?? !streaming}
|
||||
streaming={streaming && index === messages.length - 1}
|
||||
/>
|
||||
</AgentSpeechMessage>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
)
|
||||
@@ -697,3 +992,235 @@ function BackendMessageList({
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
type SpeechSelection = {
|
||||
startOffset: number;
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
function AgentSpeechMessage({
|
||||
messageId,
|
||||
content,
|
||||
disabled,
|
||||
isSupported,
|
||||
speechState,
|
||||
onSpeak,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
children
|
||||
}: {
|
||||
messageId: string;
|
||||
content: string;
|
||||
disabled: boolean;
|
||||
isSupported: boolean;
|
||||
speechState: AgentSpeechState;
|
||||
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStop: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastSelectionRef = useRef<SpeechSelection | null>(null);
|
||||
const [selection, setSelection] = useState<SpeechSelection | null>(null);
|
||||
const speechText = useMemo(() => stripSpeechMarkdown(content), [content]);
|
||||
const selectionAnchor = selection ?? lastSelectionRef.current;
|
||||
|
||||
if (selection) {
|
||||
lastSelectionRef.current = selection;
|
||||
}
|
||||
|
||||
const captureSelection = useCallback(() => {
|
||||
if (!isSupported || disabled) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const browserSelection = window.getSelection();
|
||||
const container = contentRef.current;
|
||||
if (!browserSelection || browserSelection.rangeCount === 0 || browserSelection.isCollapsed || !container) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = browserSelection.getRangeAt(0);
|
||||
if (!container.contains(range.commonAncestorContainer)) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const startOffset = findSpeechSelectionStartOffset(speechText, browserSelection.toString());
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (startOffset === null || (!rect.width && !rect.height)) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelection({
|
||||
startOffset,
|
||||
left: Math.min(Math.max(rect.left + rect.width / 2, 88), window.innerWidth - 88),
|
||||
top: Math.max(rect.top - 10, 48)
|
||||
});
|
||||
}, [disabled, isSupported, speechText]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelection(null);
|
||||
}, [messageId, speechText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) return;
|
||||
const close = () => setSelection(null);
|
||||
const closeCollapsedSelection = () => {
|
||||
if (window.getSelection()?.isCollapsed) close();
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") close();
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", closeCollapsedSelection);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
window.addEventListener("resize", close);
|
||||
window.addEventListener("scroll", close, true);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", closeCollapsedSelection);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
window.removeEventListener("resize", close);
|
||||
window.removeEventListener("scroll", close, true);
|
||||
};
|
||||
}, [selection]);
|
||||
|
||||
const speakFromSelection = () => {
|
||||
if (!selection) return;
|
||||
void Promise.resolve(onSpeak(messageId, speechText, { startOffset: selection.startOffset }));
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSelection(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={contentRef}
|
||||
onPointerUp={() => window.requestAnimationFrame(captureSelection)}
|
||||
onKeyUp={() => window.requestAnimationFrame(captureSelection)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{selectionAnchor && typeof document !== "undefined" ? createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
|
||||
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{selection ? (
|
||||
<motion.button
|
||||
key="speech-selection-action"
|
||||
type="button"
|
||||
className="glass-transient pointer-events-auto inline-flex h-10 origin-bottom items-center gap-2 rounded-xl border px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.15, ease: [0.16, 1, 0.3, 1] }
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: 0,
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.1, ease: [0.4, 0, 1, 1] }
|
||||
}}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={speakFromSelection}
|
||||
>
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-blue-50/75 text-blue-600" aria-hidden="true">
|
||||
<Volume2 size={15} />
|
||||
</span>
|
||||
从这里开始朗读
|
||||
</motion.button>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>,
|
||||
document.body
|
||||
) : null}
|
||||
|
||||
{isSupported && !disabled ? (
|
||||
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5">
|
||||
{speechState === "idle" ? (
|
||||
<SpeechIconButton label="语音播放" onClick={() => void Promise.resolve(onSpeak(messageId, speechText))}>
|
||||
<Volume2 size={15} aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
) : null}
|
||||
{speechState === "loading" ? (
|
||||
<>
|
||||
<SpeechIconButton label="正在生成语音" disabled>
|
||||
<LoaderCircle size={15} className="animate-spin text-blue-600" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||||
<Square size={14} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{speechState === "playing" ? (
|
||||
<>
|
||||
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
|
||||
<Pause size={15} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||||
<Square size={14} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{speechState === "paused" ? (
|
||||
<>
|
||||
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
|
||||
<Play size={15} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||||
<Square size={14} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
</>
|
||||
) : null}
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{speechState === "idle" ? "语音播放" : speechState === "loading" ? "正在生成" : speechState === "playing" ? "播放中" : "已暂停"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeechIconButton({
|
||||
label,
|
||||
tone = "default",
|
||||
disabled = false,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
tone?: "default" | "primary" | "danger";
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500",
|
||||
"transition-[color,background-color,transform] hover:bg-slate-100 active:scale-95 disabled:cursor-wait",
|
||||
tone === "primary" && "text-blue-600",
|
||||
tone === "danger" && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ export function AgentPersona({
|
||||
|
||||
return (
|
||||
<Persona
|
||||
className={cn("shrink-0", className)}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
variant === "obsidian" && "scale-110",
|
||||
className
|
||||
)}
|
||||
onLoadError={() => setFailed(true)}
|
||||
state={personaState}
|
||||
variant={variant}
|
||||
|
||||
Reference in New Issue
Block a user