feat: initialize TJWater WebGIS frontend

This commit is contained in:
2026-07-21 18:31:08 +08:00
commit ad5e5d3133
191 changed files with 37963 additions and 0 deletions
@@ -0,0 +1,718 @@
"use client";
import {
CheckCircle2,
ChevronDown,
HelpCircle,
ListChecks,
ListTree,
LockKeyhole,
ShieldCheck,
XCircle,
type LucideIcon
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/shared/ui/button";
import {
agentEase,
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import type {
AgentChatMessage,
AgentChatProgress,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionInfo,
AgentQuestionRequest,
AgentTodoUpdate
} from "../types";
type ProgressStatus = AgentChatProgress["status"];
type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
type PermissionStatus = AgentPermissionRequest["status"];
type QuestionStatus = AgentQuestionRequest["status"];
const STREAMING_PROGRESS_LIMIT = 3;
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
running: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
},
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-100"
},
error: {
label: "失败",
className: "bg-red-100 text-red-700",
dotClassName: "border-red-500 bg-red-100"
}
};
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-500"
},
in_progress: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
},
pending: {
label: "待办",
className: "bg-slate-100 text-slate-600",
dotClassName: "border-slate-300 bg-white"
},
cancelled: {
label: "取消",
className: "bg-slate-200 text-slate-500",
dotClassName: "border-slate-300 bg-slate-200"
}
};
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
};
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
};
export function AgentMessageDetails({
message,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: {
message: AgentChatMessage;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const hasDetails = Boolean(
message.todos?.todos.length ||
message.permissions?.length ||
message.questions?.length
);
if (!hasDetails) {
return null;
}
return (
<motion.div className="space-y-2" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{message.todos ? (
<motion.div key="todos" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<TodoCard todoUpdate={message.todos} />
</motion.div>
) : null}
{message.permissions?.length ? (
<motion.div key="permissions" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<PermissionList permissions={message.permissions} onReplyPermission={onReplyPermission} />
</motion.div>
) : null}
{message.questions?.length ? (
<motion.div key="questions" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<QuestionList
questions={message.questions}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
);
}
export function AgentMessageProgress({
progress,
running
}: {
progress?: AgentChatProgress[];
running: boolean;
}) {
if (!progress?.length) {
return null;
}
return <ProgressList progress={progress} running={running} />;
}
function AgentNestedBlock({
icon: Icon,
iconClassName,
title,
children,
floating = false
}: {
icon: LucideIcon;
iconClassName: string;
title: string;
children: ReactNode;
floating?: boolean;
}) {
return (
<div className={cn(floating ? "px-0 py-1" : "agent-panel-nested rounded-xl p-2.5")}>
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<Icon size={14} className={iconClassName} aria-hidden="true" />
{title}
</div>
{children}
</div>
);
}
function AnimatedDetailItem({ children }: { children: ReactNode }) {
return (
<motion.div
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
{children}
</motion.div>
);
}
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false);
const compact = running && !expanded;
const visibleProgress = expanded
? progress
: running
? progress.slice(-STREAMING_PROGRESS_LIMIT)
: [];
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
return (
<div className="agent-panel-nested rounded-xl p-2.5">
<button
type="button"
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
<span>执行进度</span>
<span className="flex-1" />
<span className="shrink-0 font-normal text-slate-400">
{expanded
? `全部 ${progress.length} 条`
: running
? `最近 ${Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} 条`
: `共 ${progress.length} 条`}
</span>
<ChevronDown
size={14}
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
aria-hidden="true"
/>
</button>
<AnimatePresence initial={false} mode="wait">
{visibleProgress.length ? (
<motion.ol
key={listMode}
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={agentLayoutTransition}
>
<AnimatePresence initial={false} mode="popLayout">
{visibleProgress.map((item) => (
<motion.li
key={item.id}
className={cn(
"grid grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 text-xs",
compact && "h-12 overflow-hidden"
)}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.16, ease: agentEase }}
>
<motion.span
aria-hidden="true"
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border transition-colors duration-150",
progressStatusMeta[item.status].dotClassName
)}
animate={
item.status === "running"
? { opacity: [0.45, 1, 0.45], scale: 1 }
: item.status === "error"
? { opacity: [0.55, 1, 1], scale: [1, 1.28, 1] }
: { opacity: 1, scale: 1 }
}
transition={
item.status === "running"
? { duration: 1.2, ease: "easeInOut", repeat: Infinity }
: item.status === "error"
? { duration: 0.28, ease: agentEase }
: { duration: 0.14 }
}
/>
<span className="min-w-0">
<span
className={cn(
"block font-semibold text-slate-800",
compact ? "truncate" : "break-words"
)}
title={item.title}
>
{item.title}
</span>
{item.detail ? (
<span
className={cn(
"block leading-5 text-slate-500",
compact ? "truncate" : "break-words"
)}
title={item.detail}
>
{item.detail}
</span>
) : null}
</span>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={item.status}
data-progress-status={item.status}
className={cn(
"min-w-12 rounded-full px-2 py-0.5 text-center font-semibold",
progressStatusMeta[item.status].className
)}
initial={{ opacity: 0 }}
animate={
item.status === "error"
? { opacity: [0, 1, 1], x: [0, -2, 2, -1, 0] }
: { opacity: 1, x: 0 }
}
exit={{ opacity: 0 }}
transition={
item.status === "error"
? { duration: 0.24, ease: agentEase }
: { duration: 0.14 }
}
>
{progressStatusMeta[item.status].label}
</motion.span>
</AnimatePresence>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
) : null}
</AnimatePresence>
</div>
);
}
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
return (
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{todoUpdate.todos.slice(0, 6).map((todo) => (
<motion.li
key={todo.id}
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
todoStatusMeta[todo.status].dotClassName
)}
>
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
</span>
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
{todo.content}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
{todoStatusMeta[todo.status].label}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
</AgentNestedBlock>
);
}
function PermissionList({
permissions,
onReplyPermission
}: {
permissions: AgentPermissionRequest[];
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{permissions.slice(-4).map((permission) => (
<AnimatedDetailItem key={permission.requestId}>
<PermissionRequestCard
permission={permission}
onReplyPermission={onReplyPermission}
/>
</AnimatedDetailItem>
))}
</AnimatePresence>
</motion.div>
</AgentNestedBlock>
);
}
function PermissionRequestCard({
permission,
onReplyPermission
}: {
permission: AgentPermissionRequest;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
const actionable = permission.status === "pending" || permission.status === "error";
const submitting = permission.status === "submitting";
const canApproveAlways = permission.always.length > 0;
const disabled = !onReplyPermission || submitting;
const target = permission.target ?? (permission.patterns.join(" ") || permission.permission);
const reply = (nextReply: AgentPermissionReply) => {
if (!onReplyPermission || submitting) {
return;
}
void Promise.resolve(onReplyPermission(permission, nextReply));
};
return (
<div className="surface-reading rounded-lg px-2.5 py-2 text-xs">
<div className="grid grid-cols-[1fr_auto] gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
{getPermissionTitle(permission)}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}>
{target}
</p>
{permission.error ? (
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
) : null}
</div>
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
{permissionStatusMeta[permission.status].label}
</span>
</div>
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="permission-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
disabled={disabled}
onClick={() => reply("once")}
>
<CheckCircle2 size={13} aria-hidden="true" />
允许一次
</Button>
{canApproveAlways ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
disabled={disabled}
onClick={() => reply("always")}
>
<ShieldCheck size={13} aria-hidden="true" />
始终允许
</Button>
) : null}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
disabled={disabled}
onClick={() => reply("reject")}
>
<XCircle size={13} aria-hidden="true" />
拒绝
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
function QuestionList({
questions,
onReplyQuestion,
onRejectQuestion
}: {
questions: AgentQuestionRequest[];
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating>
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{questions.slice(-3).map((request) => (
<AnimatedDetailItem key={request.requestId}>
<QuestionRequestCard
request={request}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</AnimatedDetailItem>
))}
</AnimatePresence>
</motion.div>
</AgentNestedBlock>
);
}
function QuestionRequestCard({
request,
onReplyQuestion,
onRejectQuestion
}: {
request: AgentQuestionRequest;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const [draftAnswers, setDraftAnswers] = useState(() => createInitialQuestionAnswers(request));
const actionable = request.status === "pending" || request.status === "error";
const submitting = request.status === "submitting";
const disabled = submitting || !onReplyQuestion;
const answers = normalizeDraftAnswers(draftAnswers);
const canSubmit = actionable && answers.length === request.questions.length && answers.every((answer) => answer.length > 0);
const submit = () => {
if (!onReplyQuestion || !canSubmit) {
return;
}
void Promise.resolve(onReplyQuestion(request, answers));
};
const reject = () => {
if (!onRejectQuestion || submitting) {
return;
}
void Promise.resolve(onRejectQuestion(request));
};
return (
<div className="px-0 py-1 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
{questionStatusMeta[request.status].label}
</span>
</div>
<div className="space-y-3">
{request.questions.map((question, questionIndex) => (
<QuestionInput
key={`${request.requestId}-${questionIndex}`}
question={question}
questionIndex={questionIndex}
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
disabled={!actionable || submitting}
onChange={(nextValue) =>
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
}
/>
))}
</div>
{request.error ? (
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
) : null}
{request.answers?.length ? (
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}>
已答:{request.answers.map((answer) => answer.join("、")).join(";")}
</p>
) : null}
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="question-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
disabled={disabled || !canSubmit}
onClick={submit}
>
<CheckCircle2 size={13} aria-hidden="true" />
提交回答
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
disabled={submitting || !onRejectQuestion}
onClick={reject}
>
<XCircle size={13} aria-hidden="true" />
跳过
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
type QuestionDraftAnswer = {
selected: string[];
custom: string;
};
function QuestionInput({
question,
questionIndex,
value,
disabled,
onChange
}: {
question: AgentQuestionInfo;
questionIndex: number;
value: QuestionDraftAnswer;
disabled: boolean;
onChange: (value: QuestionDraftAnswer) => void;
}) {
const name = `agent-question-${questionIndex}-${question.question}`;
const showCustomInput = question.custom || question.options.length === 0;
const toggleOption = (label: string, checked: boolean) => {
if (question.multiple) {
onChange({
...value,
selected: checked ? [...value.selected, label] : value.selected.filter((item) => item !== label)
});
return;
}
onChange({
...value,
selected: checked ? [label] : []
});
};
return (
<div className="space-y-1.5">
<p className="leading-5 text-slate-700">{question.question}</p>
{question.options.length ? (
<div className="space-y-1">
{question.options.map((option) => {
const checked = value.selected.includes(option.label);
return (
<label
key={option.label}
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
>
<input
type={question.multiple ? "checkbox" : "radio"}
name={name}
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
checked={checked}
disabled={disabled}
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
/>
<span className="min-w-0">
<span className="block font-semibold text-slate-700">{option.label}</span>
{option.description ? (
<span className="block leading-4 text-slate-500">{option.description}</span>
) : null}
</span>
</label>
);
})}
</div>
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
value={value.custom}
disabled={disabled}
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
/>
) : null}
</div>
);
}
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
return request.questions.map((question, index) => ({
selected: request.answers?.[index] ?? [],
custom: ""
}));
}
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
return draftAnswers.map((answer) => {
const custom = answer.custom.trim();
return custom ? [...answer.selected, custom] : answer.selected;
});
}
function getPermissionTitle(permission: AgentPermissionRequest) {
if (permission.permission === "bash") return "执行终端命令";
if (permission.permission === "edit") return "修改文件内容";
if (permission.permission === "external_directory") return "访问工作区外目录";
return permission.permission || "工具权限请求";
}