feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -20,7 +20,17 @@ import {
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
@@ -108,7 +118,9 @@ type AgentCommandPanelProps = {
|
||||
approvalMode?: AgentApprovalMode;
|
||||
uiResults?: AgentUiResult[];
|
||||
workspaceContext?: AgentWorkspaceContextItem[];
|
||||
prompt: string;
|
||||
onCollapse: () => void;
|
||||
onPromptChange: Dispatch<SetStateAction<string>>;
|
||||
onRefreshHistory?: () => Promise<void> | void;
|
||||
onStartNewSession?: () => Promise<void> | void;
|
||||
onLoadHistorySession?: (sessionId: string) => Promise<void> | void;
|
||||
@@ -160,7 +172,9 @@ export function AgentCommandPanel({
|
||||
approvalMode = "request",
|
||||
uiResults = [],
|
||||
workspaceContext = [],
|
||||
prompt,
|
||||
onCollapse,
|
||||
onPromptChange,
|
||||
onRefreshHistory,
|
||||
onStartNewSession,
|
||||
onLoadHistorySession,
|
||||
@@ -174,13 +188,14 @@ export function AgentCommandPanel({
|
||||
onReplyQuestion,
|
||||
onRejectQuestion
|
||||
}: AgentCommandPanelProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [scrollRequestId, setScrollRequestId] = useState(0);
|
||||
const historyPanelId = useId();
|
||||
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const historyPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const promptRestoreGenerationRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
|
||||
const hasConversation = messages.length > 0 || streaming;
|
||||
@@ -195,8 +210,9 @@ export function AgentCommandPanel({
|
||||
isSupported: isSpeechSupported
|
||||
} = useAgentSpeechSynthesis();
|
||||
const appendRecognitionResult = useCallback((text: string) => {
|
||||
setPrompt((current) => `${current}${current.trim() ? " " : ""}${text}`);
|
||||
}, []);
|
||||
promptRestoreGenerationRef.current += 1;
|
||||
onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`);
|
||||
}, [onPromptChange]);
|
||||
const showRecognitionError = useCallback((message: string) => {
|
||||
showMapNotice({
|
||||
id: "agent-speech-recognition-error",
|
||||
@@ -219,6 +235,14 @@ export function AgentCommandPanel({
|
||||
}
|
||||
}, [historyOpen, onRefreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
promptRestoreGenerationRef.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyOpen) {
|
||||
return;
|
||||
@@ -271,11 +295,16 @@ export function AgentCommandPanel({
|
||||
if (!nextPrompt || streaming) {
|
||||
return;
|
||||
}
|
||||
setPrompt("");
|
||||
const restoreGeneration = promptRestoreGenerationRef.current + 1;
|
||||
promptRestoreGenerationRef.current = restoreGeneration;
|
||||
onPromptChange("");
|
||||
stopListening();
|
||||
setScrollRequestId((requestId) => requestId + 1);
|
||||
void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
|
||||
setPrompt(nextPrompt);
|
||||
if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) {
|
||||
return;
|
||||
}
|
||||
onPromptChange((current) => current || nextPrompt);
|
||||
});
|
||||
};
|
||||
const historyPanel = (
|
||||
@@ -286,6 +315,7 @@ export function AgentCommandPanel({
|
||||
sessions={sessionHistory}
|
||||
onRefresh={onRefreshHistory}
|
||||
onSelectSession={(nextSessionId) => {
|
||||
promptRestoreGenerationRef.current += 1;
|
||||
setLoadingSessionId(nextSessionId);
|
||||
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
|
||||
.then(() => setHistoryOpen(false))
|
||||
@@ -369,8 +399,9 @@ export function AgentCommandPanel({
|
||||
aria-label="新建 Agent 对话"
|
||||
title="新建 Agent 对话"
|
||||
onClick={() => {
|
||||
promptRestoreGenerationRef.current += 1;
|
||||
setHistoryOpen(false);
|
||||
setPrompt("");
|
||||
onPromptChange("");
|
||||
void Promise.resolve(onStartNewSession?.());
|
||||
}}
|
||||
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600"
|
||||
@@ -436,7 +467,9 @@ export function AgentCommandPanel({
|
||||
{workspaceContext.length > 0 ? (
|
||||
<div className="mx-3 mb-2 flex shrink-0 items-center gap-2 overflow-x-auto rounded-lg bg-slate-900/[0.045] px-2.5 py-2 text-[10px] text-slate-500">
|
||||
<Clock3 size={13} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">当前上下文</span>
|
||||
<span className="shrink-0 font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
当前上下文
|
||||
</span>
|
||||
{workspaceContext.map((item) => (
|
||||
<span
|
||||
key={item.id}
|
||||
@@ -532,7 +565,12 @@ export function AgentCommandPanel({
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
className="flex flex-1 items-center justify-center px-4 py-8"
|
||||
className={cn(
|
||||
"flex flex-1 px-4",
|
||||
presentation === "mobile-sheet"
|
||||
? "items-start justify-center pb-0 pt-5"
|
||||
: "items-center justify-center py-8"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
@@ -605,7 +643,10 @@ export function AgentCommandPanel({
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
onChange={(event) => {
|
||||
promptRestoreGenerationRef.current += 1;
|
||||
onPromptChange(event.currentTarget.value);
|
||||
}}
|
||||
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
|
||||
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
|
||||
/>
|
||||
@@ -976,13 +1017,15 @@ function AgentModelSelect({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-24 shrink-0 justify-between gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
|
||||
className="w-12 shrink-0 justify-between gap-1 border-transparent bg-transparent px-1 text-xs text-slate-600 shadow-none hover:bg-slate-50 sm:w-24 sm:gap-1.5 sm:px-1.5"
|
||||
disabled={!onValueChange}
|
||||
aria-label="选择 Agent 模型"
|
||||
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
|
||||
title={selectedModel?.label ?? "模型"}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<ModelIcon model={selectedModel} size={selectedModel?.icon === "bolt" ? 15 : 14} />
|
||||
<span className="shrink-0">{selectedModel?.label ?? "模型"}</span>
|
||||
<span className="hidden shrink-0 sm:inline">{selectedModel?.label ?? "模型"}</span>
|
||||
</span>
|
||||
<ChevronsUpDown size={12} className="shrink-0 opacity-50" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -1104,41 +1147,53 @@ function AgentReadyMark() {
|
||||
}
|
||||
|
||||
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
if (compact) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="agent-empty-state-title"
|
||||
className="mx-auto flex w-full max-w-[360px] items-center gap-3 px-1 py-1"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<div className="surface-control grid size-9 shrink-0 place-items-center rounded-lg shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
|
||||
<AgentReadyMark />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<h2
|
||||
id="agent-empty-state-title"
|
||||
className="text-sm font-semibold leading-5 text-slate-950"
|
||||
>
|
||||
我已就绪,请描述任务
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-600">
|
||||
描述调查目标,我会整理证据并带回地图。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="agent-empty-state-title"
|
||||
className="mx-auto w-full max-w-[360px]"
|
||||
lang="zh-CN"
|
||||
>
|
||||
<div className={cn("px-2", compact ? "py-2" : "py-4")}>
|
||||
<div className="px-2 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"surface-control grid shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]",
|
||||
compact ? "h-14 w-14" : "h-[72px] w-[72px]"
|
||||
)}
|
||||
>
|
||||
<div className="surface-control grid h-[72px] w-[72px] shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
|
||||
<AgentReadyMark />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<h2
|
||||
id="agent-empty-state-title"
|
||||
className={cn(
|
||||
"text-balance font-semibold text-slate-950",
|
||||
compact ? "text-base leading-6" : "text-lg leading-7"
|
||||
)}
|
||||
className="text-balance text-lg font-semibold leading-7 text-slate-950"
|
||||
>
|
||||
我已就绪,请描述任务
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-pretty text-left text-sm leading-6 text-[#334155]",
|
||||
compact ? "mt-3" : "mt-5"
|
||||
)}
|
||||
>
|
||||
<p className="mt-5 text-pretty text-left text-sm leading-6 text-[#334155]">
|
||||
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user