- 知识图谱画布、侧边栏、详情面板交互优化,新增图谱相关 API 与 hooks - Agent 卡片与详情面板调整,对话页面重构 - 线程元数据持久化增强(base/memory/sql),knowledge 仓储与导出补充 - 网关路由 agents/knowledge/threads 同步更新 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
288 lines
8.7 KiB
TypeScript
288 lines
8.7 KiB
TypeScript
import {
|
|
Ban,
|
|
Compass,
|
|
Crosshair,
|
|
EyeOff,
|
|
FileText,
|
|
Loader2,
|
|
MousePointerClick,
|
|
Waypoints,
|
|
X,
|
|
} from "lucide-react";
|
|
|
|
import type { KnowledgeNote } from "@/core/knowledge/types";
|
|
|
|
import type { GraphNode } from "./graphTypes";
|
|
import {
|
|
getEdgeInfo,
|
|
getNodeKind,
|
|
getNodeTitle,
|
|
NODE_KIND_META,
|
|
} from "./graphUtils";
|
|
|
|
const SOURCE_TYPE_ZH: Record<string, string> = {
|
|
thread: "对话沉淀",
|
|
search: "搜索沉淀",
|
|
tool: "工具结果",
|
|
file: "文件导入",
|
|
manual: "手动创建",
|
|
report: "报告",
|
|
};
|
|
|
|
const STATUS_ZH: Record<string, string> = {
|
|
draft: "草稿",
|
|
approved: "已审核",
|
|
archived: "已归档",
|
|
};
|
|
|
|
export interface NeighborItem {
|
|
node: GraphNode;
|
|
relation: string;
|
|
}
|
|
|
|
interface GraphDetailPanelProps {
|
|
node: GraphNode | null;
|
|
degree: number;
|
|
neighbors: NeighborItem[];
|
|
/** Note detail (only for note-type nodes; undefined while absent/loading). */
|
|
note: KnowledgeNote | undefined;
|
|
noteLoading: boolean;
|
|
/** Explore-mode context; null when in full mode. */
|
|
explore: {
|
|
/** Depth of this node in the explore graph; null = not yet visible. */
|
|
depth: number | null;
|
|
expanded: boolean;
|
|
isRoot: boolean;
|
|
} | null;
|
|
onViewSource: (noteId: string) => void;
|
|
onLocate: (id: string) => void;
|
|
onHide: (id: string) => void;
|
|
onBlacklist: (id: string) => void;
|
|
onPickNeighbor: (id: string) => void;
|
|
onExpandNode: (id: string) => void;
|
|
onSetExploreRoot: (id: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function formatTime(value: string | null | undefined): string {
|
|
if (!value) return "—";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString("zh-CN", { hour12: false });
|
|
}
|
|
|
|
export function GraphDetailPanel(props: GraphDetailPanelProps) {
|
|
const { node } = props;
|
|
|
|
return (
|
|
<aside className="kg-panel kg-detail">
|
|
<div className="kg-panel-header">
|
|
<span className="kg-panel-title">节点详情</span>
|
|
<button
|
|
type="button"
|
|
className="kg-panel-close-btn"
|
|
title="收起详情面板"
|
|
onClick={props.onClose}
|
|
>
|
|
<X size={15} />
|
|
</button>
|
|
</div>
|
|
{!node ? (
|
|
<div className="kg-detail-empty">
|
|
<MousePointerClick size={28} />
|
|
<div className="kg-detail-empty-title">选择一个节点查看详情</div>
|
|
<div>
|
|
点击图谱中的文档、主题或实体节点,
|
|
<br />
|
|
查看摘要、标签、关联关系和相邻节点。
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<DetailBody {...props} node={node} />
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
|
|
const { node, note } = props;
|
|
const kind = getNodeKind(node);
|
|
const kindMeta = NODE_KIND_META[kind];
|
|
const isNote = (node.type || "").toLowerCase() === "note";
|
|
|
|
return (
|
|
<div className="kg-panel-content">
|
|
<h2 className="kg-detail-title">{getNodeTitle(node)}</h2>
|
|
<div>
|
|
<span
|
|
className="kg-detail-type"
|
|
style={{
|
|
background: `${kindMeta.color}26`,
|
|
color: kindMeta.color,
|
|
}}
|
|
>
|
|
<span className="kg-legend-dot" style={{ background: kindMeta.color }} />
|
|
{kindMeta.label}
|
|
{node.category && node.category !== kindMeta.label
|
|
? ` · ${node.category}`
|
|
: ""}
|
|
</span>
|
|
<span className="kg-detail-type kg-detail-type-neutral">
|
|
度数 {props.degree}
|
|
</span>
|
|
{props.explore && props.explore.depth !== null ? (
|
|
<>
|
|
<span className="kg-detail-type kg-detail-type-primary">
|
|
{props.explore.isRoot
|
|
? "探索起点"
|
|
: `第 ${props.explore.depth} 层`}
|
|
</span>
|
|
<span
|
|
className={`kg-detail-type ${
|
|
props.explore.expanded
|
|
? "kg-detail-type-success"
|
|
: "kg-detail-type-warn"
|
|
}`}
|
|
>
|
|
{props.explore.expanded ? "已展开" : "可展开"}
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
{props.noteLoading ? (
|
|
<div className="kg-detail-summary" style={{ display: "flex", gap: 8 }}>
|
|
<Loader2 size={14} className="animate-spin" /> 加载笔记详情...
|
|
</div>
|
|
) : null}
|
|
|
|
{note?.summary ? <p className="kg-detail-summary">{note.summary}</p> : null}
|
|
|
|
{note?.tags?.length ? (
|
|
<div className="kg-tag-list">
|
|
{note.tags.map((tag) => (
|
|
<span key={tag} className="kg-tag">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{note ? (
|
|
<div className="kg-detail-meta">
|
|
<span className="kg-detail-meta-label">来源</span>
|
|
<span className="kg-detail-meta-value">
|
|
{SOURCE_TYPE_ZH[note.source_type] ?? note.source_type}
|
|
</span>
|
|
<span className="kg-detail-meta-label">状态</span>
|
|
<span className="kg-detail-meta-value">
|
|
{STATUS_ZH[note.status] ?? note.status}
|
|
</span>
|
|
<span className="kg-detail-meta-label">创建时间</span>
|
|
<span className="kg-detail-meta-value">{formatTime(note.created_at)}</span>
|
|
<span className="kg-detail-meta-label">更新时间</span>
|
|
<span className="kg-detail-meta-value">{formatTime(note.updated_at)}</span>
|
|
{note.created_by_name ? (
|
|
<>
|
|
<span className="kg-detail-meta-label">创建人</span>
|
|
<span className="kg-detail-meta-value">{note.created_by_name}</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="kg-detail-actions">
|
|
{isNote ? (
|
|
<button
|
|
type="button"
|
|
className="kg-icon-button"
|
|
onClick={() => props.onViewSource(node.id)}
|
|
>
|
|
<FileText size={14} /> 查看原文
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="kg-icon-button"
|
|
onClick={() => props.onLocate(node.id)}
|
|
>
|
|
<Crosshair size={14} /> 定位节点
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="kg-icon-button"
|
|
onClick={() => props.onHide(node.id)}
|
|
>
|
|
<EyeOff size={14} /> 隐藏节点
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="kg-icon-button"
|
|
title="加入黑名单后该节点将不再出现在图谱中,可在左侧「图谱黑名单」中移除"
|
|
onClick={() => props.onBlacklist(node.id)}
|
|
>
|
|
<Ban size={14} /> 加入黑名单
|
|
</button>
|
|
{props.explore ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="kg-icon-button"
|
|
disabled={props.explore.depth === null || props.explore.expanded}
|
|
title={
|
|
props.explore.expanded
|
|
? "该节点的一跳邻居已全部展开"
|
|
: "展开该节点的一跳邻居"
|
|
}
|
|
onClick={() => props.onExpandNode(node.id)}
|
|
>
|
|
<Waypoints size={14} /> 展开一跳邻居
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="kg-icon-button"
|
|
title="清空当前探索路径,以此节点为新起点"
|
|
onClick={() => props.onSetExploreRoot(node.id)}
|
|
>
|
|
<Compass size={14} /> 以此为新起点
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<h3 className="kg-detail-section-title">
|
|
关联节点({props.neighbors.length})
|
|
</h3>
|
|
{props.neighbors.length === 0 ? (
|
|
<div className="kg-search-hint">该节点暂无可见的关联节点</div>
|
|
) : (
|
|
<div className="kg-neighbor-list">
|
|
{props.neighbors.map(({ node: neighbor, relation }) => {
|
|
const neighborKind = NODE_KIND_META[getNodeKind(neighbor)];
|
|
const relationInfo = getEdgeInfo(relation);
|
|
return (
|
|
<div
|
|
key={`${neighbor.id}:${relation}`}
|
|
className="kg-neighbor-item"
|
|
onClick={() => props.onPickNeighbor(neighbor.id)}
|
|
>
|
|
<span
|
|
className="kg-legend-dot"
|
|
style={{ background: neighborKind.color, flexShrink: 0 }}
|
|
/>
|
|
<span className="kg-neighbor-title">{getNodeTitle(neighbor)}</span>
|
|
<span
|
|
className="kg-neighbor-relation"
|
|
style={{ color: relationInfo.color }}
|
|
>
|
|
{relationInfo.zh}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|