feat: 智能体详情新增「最近对话」列表,完善推荐问题与任务深链配置

- 新增 AgentRecentChats 组件,在智能体详情面板底部按 agent_id 展示最近对话
- 调整问答/智能体侧边栏与最近对话列表展示逻辑
- 补充 dashboard-input、task-deeplink 相关配置与线程工具方法
- 后端推荐问题接口标签处理及 position_sync 微调,新增相关测试

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-22 14:47:43 +08:00
1 parent 16ba5deb0e
commit 32476c7ad4
18 files changed
+457 -60

No files matched your search

@@ -70,6 +70,8 @@ import {
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
bootstrapResumePath,
isBootstrapThread,
isThreadFavorite,
isThreadPinned,
sortThreadsPinnedFirst,
@@ -338,14 +340,14 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
// 排除调度/引导/会商类会话,与首页一致;置顶优先。
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
// (带机器人图标、点击回创建页);置顶优先。
const visibleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
),
),
@@ -450,7 +452,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
</div>
) : (
filteredThreads.map((thread) => {
const href = pathOf(thread);
const isBoot = isBootstrapThread(thread);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
const isActive = href === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
@@ -468,12 +471,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<MessageSquare
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
{isBoot ? (
<Bot
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
aria-label="智能体创建会话"
/>
) : (
<MessageSquare
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
{pinned && (
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
)}
@@ -43,6 +43,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
@@ -133,6 +134,7 @@ export function AgentDetailPanel({
onAskQuestion,
disabled,
defaultCollapsed,
showRecentChats,
}: {
agent: Agent | null | undefined;
agentName: string;
@@ -140,6 +142,8 @@ export function AgentDetailPanel({
disabled?: boolean;
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
defaultCollapsed?: boolean;
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
showRecentChats?: boolean;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
@@ -794,6 +798,9 @@ export function AgentDetailPanel({
onViewDetail={setDetailSkillName}
/>
</Section>
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div>
);
@@ -0,0 +1,79 @@
"use client";
import { MessageSquareIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useThreadsPage } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const RECENT_LIMIT = 8;
/**
* 「有关此智能体的最近对话」—— 渲染在智能体详情面板底部(右下角)。
* 按 `metadata.agent_id` 过滤出该智能体的会话,点击直接打开对应对话。
*/
export function AgentRecentChats({ agentId }: { agentId: string }) {
const pathname = usePathname();
const { data } = useThreadsPage({
page: 1,
pageSize: RECENT_LIMIT,
metadata: { agent_id: agentId },
});
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
const threads = (data?.threads ?? []).filter(
(th) =>
th.metadata?.thread_type !== "bootstrap" &&
th.metadata?.thread_type !== "scheduler" &&
th.metadata?.thread_type !== "roundtable",
);
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
<MessageSquareIcon className="size-3.5" />
最近对话
</h4>
<span className="text-muted-foreground text-xs tabular-nums">
{threads.length} 个
</span>
</div>
{threads.length === 0 ? (
<p className="text-muted-foreground/60 text-sm">暂无对话</p>
) : (
<div className="flex flex-col gap-1">
{threads.map((thread) => {
const href = pathOfThread(thread);
const isActive = href === pathname;
return (
<Link
key={thread.thread_id}
href={href}
title={desensitize(titleOfThread(thread, "新对话"))}
className={cn(
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
isActive
? "bg-primary/5 text-primary font-medium"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<MessageSquareIcon
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
<span className="truncate">
{desensitize(titleOfThread(thread, "新对话"))}
</span>
</Link>
);
})}
</div>
)}
</section>
);
}
@@ -75,8 +75,10 @@ import {
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
pathOfThread,
sortThreadsPinnedFirst,
titleOfThread,
@@ -213,11 +215,12 @@ export function GeneralQaSidebar() {
const visibleThreads = useMemo(
() =>
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
),
),
@@ -249,9 +252,9 @@ export function GeneralQaSidebar() {
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) {
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
}
}
void router.push(nextThreadPath);
@@ -417,7 +420,9 @@ export function GeneralQaSidebar() {
</div>
) : (
filteredThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const href = listPathOfThread(thread);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
@@ -426,7 +431,7 @@ export function GeneralQaSidebar() {
className="group/item relative flex items-center"
>
<Link
href={pathOfThread(thread)}
href={href}
className={cn(
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
isActive
@@ -434,12 +439,22 @@ export function GeneralQaSidebar() {
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<MessageSquare
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
{isBoot ? (
<Bot
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
aria-label="智能体创建会话"
/>
) : (
<MessageSquare
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
)}
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
@@ -1,6 +1,7 @@
"use client";
import {
Bot,
ChevronDown,
ChevronRight,
Download,
@@ -64,8 +65,10 @@ import {
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
pathOfThread,
sortThreadsPinnedFirst,
titleOfThread,
@@ -130,9 +133,9 @@ export function RecentChatList({
});
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) {
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
}
}
void router.push(nextThreadPath);
@@ -189,11 +192,12 @@ export function RecentChatList({
[t],
);
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
const visibleThreads = sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
),
);
@@ -239,7 +243,9 @@ export function RecentChatList({
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{shownThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const href = listPathOfThread(thread);
const isActive = href === pathname;
const isBoot = isBootstrapThread(thread);
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
@@ -261,17 +267,29 @@ export function RecentChatList({
>
<Link
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
href={href}
>
{compact && (
<MessageSquare
{isBoot ? (
<Bot
className={
"size-3.5 shrink-0 " +
(isActive
? "text-[var(--primary)]"
: "text-muted-foreground/70")
}
aria-label="智能体创建会话"
/>
) : (
compact && (
<MessageSquare
className={
"size-3.5 shrink-0 " +
(isActive
? "text-[var(--primary)]"
: "text-muted-foreground/70")
}
/>
)
)}
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
@@ -18,6 +18,7 @@
import { requestCrudService } from "@/service/request-axios-service";
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */
export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson";
@@ -221,3 +222,43 @@ export async function fetchDashboardInputJson(
if (resolved) persistDashboardInputJson(resolved);
return resolved;
}
/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */
/**
* 「3q(研判情报报告)是否完成」状态:
* - ``complete`` = selectSqReport 数组里有有效内容;
* - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成);
* - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。
*/
export type SqReportStatus = "complete" | "incomplete" | "unknown";
/**
* rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport``
* 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。
* - 有可用内容 → ``complete``(正常);
* - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx);
* - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。
*
* **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时):
* - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求**
* (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]);
* - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。
*/
export async function fetchSqReportStatus(taskId: string): Promise<SqReportStatus> {
if (!taskId) return "unknown";
const cfg = await fetchTaskDeeplinkConfig();
// test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。
if (cfg.test) {
return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete";
}
// 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。
const url = sqReportUrl();
if (!url) return "unknown";
try {
const body = await requestCrudService.get(url, { taskId });
return extractSqReport(body, taskId) ? "complete" : "incomplete";
} catch {
return "unknown";
}
}
@@ -34,11 +34,13 @@ export interface TaskDeeplinkConfig {
/** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */
detail3qUrl: string;
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success};
* actionDetail = 原始 {state,msg,data:{taskId,...}}。 */
* actionDetail = 原始 {state,msg,data:{taskId,...}};sqReport = 原始 {state,msg,data:[...]}
* (rwfx 3q 预检用,test 为真时生效)。 */
mock: {
purposeDetail?: unknown;
delete?: { message?: string; success?: boolean };
actionDetail?: unknown;
sqReport?: unknown;
};
}
@@ -81,6 +83,7 @@ function normalize(body: unknown): TaskDeeplinkConfig {
? (mock.delete as { message?: string; success?: boolean })
: undefined,
actionDetail: mock.action_detail,
sqReport: mock.sq_report,
},
};
}
+26
View File
@@ -81,6 +81,32 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
return fallback;
}
/**
* 智能体创建过程产生的「引导会话」(bootstrap)。这类会话用 metadata.thread_type
* === "bootstrap" 标记,点击后应回到「创建智能体」页继续,而不是普通问答页。
*/
export function isBootstrapThread(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
return thread.metadata?.thread_type === "bootstrap";
}
/**
* bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在
* /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定,
* 不随当前 route base 变化。
*/
export function bootstrapResumePath(threadId: string): string {
return `/page/workspace/agents/new/${threadId}`;
}
/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */
export function listPathOfThread(thread: AgentThread): string {
return isBootstrapThread(thread)
? bootstrapResumePath(thread.thread_id)
: pathOfThread(thread);
}
export function isThreadPinned(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
}
+1
View File
@@ -469,6 +469,7 @@ export default function AgentChatPage({
disabled={thread.isLoading || isDemoMode}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
defaultCollapsed={taskCtxOn}
showRecentChats={!taskCtxOn}
/>
</div>
</ThreadContext.Provider>
+8 -3
View File
@@ -7,7 +7,7 @@ import {
SaveIcon,
SearchIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -76,7 +76,11 @@ function getCreateAgentErrorMessage(
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
const threadId = useMemo(() => uuid(), []);
// 从「常用对话」点击某条智能体创建(bootstrap)会话回到本页时,URL 带 thread_id,
// 用它恢复该会话(加载历史消息、继续对话);正常进入则新开一个会话。
const { thread_id: resumeThreadId } = useParams<{ thread_id?: string }>();
const generatedThreadId = useMemo(() => uuid(), []);
const threadId = resumeThreadId || generatedThreadId;
const [nameInput, setNameInput] = useState("");
const [descriptionInput, setDescriptionInput] = useState("");
@@ -93,7 +97,8 @@ export default function NewAgentPage() {
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [skillSearch, setSkillSearch] = useState("");
const [threadPrepared, setThreadPrepared] = useState(false);
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
const [threadPrepared, setThreadPrepared] = useState(!!resumeThreadId);
const { skills, isLoading: isSkillsLoading } = useSkills();
const { models, isLoading: isModelsLoading } = useModels();
@@ -57,6 +57,12 @@ function NotebookDetailPageWithKey() {
return <StudioNotebookDetailPage key={notebookId} />;
}
// 恢复某条 bootstrap 会话时按 thread_id 重挂,切换会话不串台。
function NewAgentPageWithKey() {
const { thread_id } = useParams<{ thread_id: string }>();
return <NewAgentPage key={thread_id} />;
}
function isInIframe(): boolean {
try {
return window.self !== window.top;
@@ -279,6 +285,16 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* 从「常用对话」点击智能体创建(bootstrap)会话回到创建页并恢复该会话。
key 绑定 thread_id —— 切换不同 bootstrap 会话时重挂一个干净的页面。 */}
<Route
path="agents/new/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<NewAgentPageWithKey />
</WorkspaceLayout>
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/workspace/agents" replace />}
@@ -30,7 +30,13 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
import { fetchActionDetail, readCopTaskDetail, type ActionDetail } from "@/core/auth/task-deeplink";
import {
fetchActionDetail,
readCopTaskDetail,
resolveTaskDeeplinkTarget,
type ActionDetail,
} from "@/core/auth/task-deeplink";
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons";
import { useTaskThreads } from "@/core/threads/use-task-threads";
import { titleOfThread } from "@/core/threads/utils";
@@ -276,6 +282,79 @@ function TaskClearButton({ taskId }: { taskId: string }) {
);
}
/**
* rwfx 专用「3q 完成预检」闸门:进入任务分析工作区时调研判情报报告接口
* (``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``)。若该任务的报告为空
* (数组为空 / 内容全为 null,即 3q 未完成)→ 弹框提示「3q 未完成,是否进行 3qfx」,
* 并提供「前往 3qfx」按钮切换到**同一任务**的三情分析工作区(复用深链解析重绑任务上下文)。
* 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。见 [[dashboard-input]] / [[task-deeplink]]。
*/
function RwfxSqReportGate({ taskId }: { taskId: string }) {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [jumping, setJumping] = useState(false);
useEffect(() => {
if (!taskId) return;
let alive = true;
void fetchSqReportStatus(taskId).then((status) => {
// 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。
if (alive && status === "incomplete") setOpen(true);
});
return () => {
alive = false;
};
}, [taskId]);
const handleGo3qfx = async () => {
const detail = readCopTaskDetail();
if (!detail || !taskId) {
toast.error("缺少任务信息,无法跳转");
return;
}
setJumping(true);
try {
// 复用深链解析:会清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。
const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId);
if (target) {
setOpen(false);
navigate(target.path, { state: target.state });
} else {
toast.error("未配置三情分析(3qfx)对应的业务智能体");
}
} catch {
toast.error("跳转失败,请稍后再试");
} finally {
setJumping(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !jumping && setOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>3q 未完成</DialogTitle>
<DialogDescription>
未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={jumping}>
暂不
</Button>
<Button
className="bg-[var(--color-main)] text-white hover:bg-[var(--brand-950)]"
onClick={handleGo3qfx}
disabled={jumping}
>
{jumping ? <Loader2 className="h-4 w-4 animate-spin" /> : "前往 3qfx"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* 任务工作区布局:左列表 + 右 Outlet。
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
@@ -305,6 +384,8 @@ export function TaskFxWorkspace({
</div>
</div>
<TaskJumpBar business={business} taskId={taskId} />
{/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */}
{business === "rwfx" && <RwfxSqReportGate taskId={taskId} />}
</div>
);
}