feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
@@ -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>