|
@@ -13,12 +13,7 @@ interface DetailPanelProps {
|
|
|
onClose: () => void;
|
|
onClose: () => void;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export const DetailPanel = ({
|
|
|
|
|
- node,
|
|
|
|
|
- edge,
|
|
|
|
|
- messages = [],
|
|
|
|
|
- onClose,
|
|
|
|
|
-}: DetailPanelProps) => {
|
|
|
|
|
|
|
+export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelProps) => {
|
|
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
|
|
|
|
|
|
console.log("DetailPanel - node:", node);
|
|
console.log("DetailPanel - node:", node);
|
|
@@ -43,12 +38,42 @@ export const DetailPanel = ({
|
|
|
);
|
|
);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ const renderResultImages = (imageUrls: string[]) => {
|
|
|
|
|
+ if (!imageUrls || imageUrls.length === 0) return null;
|
|
|
|
|
+ const normalized = imageUrls
|
|
|
|
|
+ .map((raw) => String(raw))
|
|
|
|
|
+ .map((raw) => raw.replace(/^[\s`'"]+|[\s`'"]+$/g, ""))
|
|
|
|
|
+ .filter((url) => url.startsWith("http") || url.startsWith("data:"));
|
|
|
|
|
+ if (normalized.length === 0) return null;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="grid grid-cols-4 gap-2 mt-2">
|
|
|
|
|
+ {normalized.map((url, idx) => (
|
|
|
|
|
+ <img
|
|
|
|
|
+ key={`${url}-${idx}`}
|
|
|
|
|
+ src={url}
|
|
|
|
|
+ alt={`Image ${idx + 1}`}
|
|
|
|
|
+ className="w-full h-16 object-cover rounded border border-gray-200 cursor-pointer hover:opacity-80 transition-opacity bg-gray-50"
|
|
|
|
|
+ onClick={() => setPreviewImage(url)}
|
|
|
|
|
+ onError={(e) => {
|
|
|
|
|
+ (e.target as HTMLImageElement).style.display = "none";
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const normalizeImageUrl = (raw: unknown) => String(raw).replace(/^[\s`'"]+|[\s`'"]+$/g, "");
|
|
|
|
|
+
|
|
|
|
|
+ const getValidImageUrls = (values: unknown[]): string[] => {
|
|
|
|
|
+ return values.map((v) => normalizeImageUrl(v)).filter((url) => url.startsWith("http") || url.startsWith("data:"));
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
const title = node ? "节点详情" : edge ? "连线详情" : "详情";
|
|
const title = node ? "节点详情" : edge ? "连线详情" : "详情";
|
|
|
|
|
|
|
|
const renderMessageContent = (content: Message["content"]) => {
|
|
const renderMessageContent = (content: Message["content"]) => {
|
|
|
if (!content) return "";
|
|
if (!content) return "";
|
|
|
- if (typeof content === "string")
|
|
|
|
|
- return <ReactMarkdown>{content}</ReactMarkdown>;
|
|
|
|
|
|
|
+ if (typeof content === "string") return <ReactMarkdown>{content}</ReactMarkdown>;
|
|
|
|
|
|
|
|
const hasText = !!content.text;
|
|
const hasText = !!content.text;
|
|
|
const hasToolCalls = content.tool_calls && content.tool_calls.length > 0;
|
|
const hasToolCalls = content.tool_calls && content.tool_calls.length > 0;
|
|
@@ -62,14 +87,11 @@ export const DetailPanel = ({
|
|
|
<div className={styles.toolCalls}>
|
|
<div className={styles.toolCalls}>
|
|
|
{content.tool_calls!.map((call, idx) => {
|
|
{content.tool_calls!.map((call, idx) => {
|
|
|
const anyCall = call as unknown as Record<string, unknown>;
|
|
const anyCall = call as unknown as Record<string, unknown>;
|
|
|
- const fn = anyCall.function as
|
|
|
|
|
- | Record<string, unknown>
|
|
|
|
|
- | undefined;
|
|
|
|
|
|
|
+ const fn = anyCall.function as Record<string, unknown> | undefined;
|
|
|
const name =
|
|
const name =
|
|
|
(fn && (fn.name as string)) ||
|
|
(fn && (fn.name as string)) ||
|
|
|
(anyCall.name as string) ||
|
|
(anyCall.name as string) ||
|
|
|
- ((content as unknown as Record<string, unknown>)
|
|
|
|
|
- .tool_name as string) ||
|
|
|
|
|
|
|
+ ((content as unknown as Record<string, unknown>).tool_name as string) ||
|
|
|
`tool_${idx}`;
|
|
`tool_${idx}`;
|
|
|
let args: unknown =
|
|
let args: unknown =
|
|
|
(fn && fn.arguments) ||
|
|
(fn && fn.arguments) ||
|
|
@@ -84,11 +106,12 @@ export const DetailPanel = ({
|
|
|
}
|
|
}
|
|
|
const key = (anyCall.id as string) || `${name}-${idx}`;
|
|
const key = (anyCall.id as string) || `${name}-${idx}`;
|
|
|
return (
|
|
return (
|
|
|
- <div key={key} className={styles.toolCall}>
|
|
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={key}
|
|
|
|
|
+ className={styles.toolCall}
|
|
|
|
|
+ >
|
|
|
<div className={styles.toolName}>工具调用: {name}</div>
|
|
<div className={styles.toolName}>工具调用: {name}</div>
|
|
|
- <pre className={styles.toolArgs}>
|
|
|
|
|
- {JSON.stringify(args, null, 2)}
|
|
|
|
|
- </pre>
|
|
|
|
|
|
|
+ <pre className={styles.toolArgs}>{JSON.stringify(args, null, 2)}</pre>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|
|
@@ -98,11 +121,100 @@ export const DetailPanel = ({
|
|
|
<div className={styles.toolResult}>
|
|
<div className={styles.toolResult}>
|
|
|
<div className={styles.toolName}>工具: {content.tool_name}</div>
|
|
<div className={styles.toolName}>工具: {content.tool_name}</div>
|
|
|
<div className={styles.resultContent}>
|
|
<div className={styles.resultContent}>
|
|
|
- {typeof content.result === "string" ? (
|
|
|
|
|
- <ReactMarkdown>{content.result}</ReactMarkdown>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <pre>{JSON.stringify(content.result, null, 2)}</pre>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ let resultData: unknown = content.result;
|
|
|
|
|
+
|
|
|
|
|
+ const tryParseEmbeddedJson = (text: string): unknown | null => {
|
|
|
|
|
+ const firstArray = text.indexOf("[");
|
|
|
|
|
+ const lastArray = text.lastIndexOf("]");
|
|
|
|
|
+ if (firstArray !== -1 && lastArray !== -1 && lastArray > firstArray) {
|
|
|
|
|
+ const sliced = text.slice(firstArray, lastArray + 1);
|
|
|
|
|
+ try {
|
|
|
|
|
+ return JSON.parse(sliced);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const firstObj = text.indexOf("{");
|
|
|
|
|
+ const lastObj = text.lastIndexOf("}");
|
|
|
|
|
+ if (firstObj !== -1 && lastObj !== -1 && lastObj > firstObj) {
|
|
|
|
|
+ const sliced = text.slice(firstObj, lastObj + 1);
|
|
|
|
|
+ try {
|
|
|
|
|
+ return JSON.parse(sliced);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof resultData === "string") {
|
|
|
|
|
+ const raw = resultData;
|
|
|
|
|
+ try {
|
|
|
|
|
+ resultData = JSON.parse(raw);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ const embedded = tryParseEmbeddedJson(raw);
|
|
|
|
|
+ if (embedded !== null) {
|
|
|
|
|
+ resultData = embedded;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return <ReactMarkdown>{raw}</ReactMarkdown>;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const renderStructuredResult = (value: unknown, field?: string): JSX.Element => {
|
|
|
|
|
+ if (Array.isArray(value)) {
|
|
|
|
|
+ if (field === "images") {
|
|
|
|
|
+ const urls = getValidImageUrls(value);
|
|
|
|
|
+ if (urls.length > 0) {
|
|
|
|
|
+ return <div>{renderResultImages(urls)}</div>;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ {value.map((item, idx) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={`${field || "arr"}-${idx}`}
|
|
|
|
|
+ className="pl-2 border-l border-slate-200"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="text-xs text-slate-500 mb-1">[{idx}]</div>
|
|
|
|
|
+ {renderStructuredResult(item)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (value && typeof value === "object") {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ {Object.entries(value as Record<string, unknown>).map(([k, v]) => (
|
|
|
|
|
+ <div key={k}>
|
|
|
|
|
+ <div className="text-xs text-slate-500">{k}</div>
|
|
|
|
|
+ <div className="pl-2">{renderStructuredResult(v, k)}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof value === "string") {
|
|
|
|
|
+ return <span className="whitespace-pre-wrap break-all">{value}</span>;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof value === "number" || typeof value === "boolean") {
|
|
|
|
|
+ return <span>{String(value)}</span>;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (value === null) {
|
|
|
|
|
+ return <span>null</span>;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return <span>{String(value)}</span>;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return renderStructuredResult(resultData);
|
|
|
|
|
+ })()}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
@@ -120,9 +232,7 @@ export const DetailPanel = ({
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const isMessageNode = (node: Goal | Message): node is Message =>
|
|
const isMessageNode = (node: Goal | Message): node is Message =>
|
|
|
- "message_id" in node ||
|
|
|
|
|
- "role" in node ||
|
|
|
|
|
- "sequence" in node;
|
|
|
|
|
|
|
+ "message_id" in node || "role" in node || "sequence" in node;
|
|
|
|
|
|
|
|
const renderKnowledge = (knowledge: Goal["knowledge"]) => {
|
|
const renderKnowledge = (knowledge: Goal["knowledge"]) => {
|
|
|
if (!knowledge || knowledge.length === 0) return null;
|
|
if (!knowledge || knowledge.length === 0) return null;
|
|
@@ -130,34 +240,32 @@ export const DetailPanel = ({
|
|
|
return (
|
|
return (
|
|
|
<div className={styles.knowledgeList}>
|
|
<div className={styles.knowledgeList}>
|
|
|
{knowledge.map((item) => (
|
|
{knowledge.map((item) => (
|
|
|
- <div key={item.id} className={styles.knowledgeItem}>
|
|
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={item.id}
|
|
|
|
|
+ className={styles.knowledgeItem}
|
|
|
|
|
+ >
|
|
|
<div className={styles.knowledgeHeader}>
|
|
<div className={styles.knowledgeHeader}>
|
|
|
<span className={styles.knowledgeId}>{item.id}</span>
|
|
<span className={styles.knowledgeId}>{item.id}</span>
|
|
|
<div className={styles.knowledgeMetrics}>
|
|
<div className={styles.knowledgeMetrics}>
|
|
|
- {item.score !== undefined && (
|
|
|
|
|
- <span className={styles.metricScore}>⭐ {item.score}</span>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {item.score !== undefined && <span className={styles.metricScore}>⭐ {item.score}</span>}
|
|
|
{item.quality_score !== undefined && (
|
|
{item.quality_score !== undefined && (
|
|
|
- <span className={styles.metricQuality}>
|
|
|
|
|
- ✨ {item.quality_score.toFixed(1)}
|
|
|
|
|
- </span>
|
|
|
|
|
|
|
+ <span className={styles.metricQuality}>✨ {item.quality_score.toFixed(1)}</span>
|
|
|
)}
|
|
)}
|
|
|
{item.metrics?.helpful !== undefined && (
|
|
{item.metrics?.helpful !== undefined && (
|
|
|
- <span className={styles.metricHelpful}>
|
|
|
|
|
- 👍 {item.metrics.helpful}
|
|
|
|
|
- </span>
|
|
|
|
|
|
|
+ <span className={styles.metricHelpful}>👍 {item.metrics.helpful}</span>
|
|
|
)}
|
|
)}
|
|
|
{item.metrics?.harmful !== undefined && (
|
|
{item.metrics?.harmful !== undefined && (
|
|
|
- <span className={styles.metricHarmful}>
|
|
|
|
|
- 👎 {item.metrics.harmful}
|
|
|
|
|
- </span>
|
|
|
|
|
|
|
+ <span className={styles.metricHarmful}>👎 {item.metrics.harmful}</span>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
{item.tags?.type && item.tags.type.length > 0 && (
|
|
{item.tags?.type && item.tags.type.length > 0 && (
|
|
|
<div className={styles.knowledgeTags}>
|
|
<div className={styles.knowledgeTags}>
|
|
|
{item.tags.type.map((tag) => (
|
|
{item.tags.type.map((tag) => (
|
|
|
- <span key={tag} className={styles.tag}>
|
|
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={tag}
|
|
|
|
|
+ className={styles.tag}
|
|
|
|
|
+ >
|
|
|
{tag}
|
|
{tag}
|
|
|
</span>
|
|
</span>
|
|
|
))}
|
|
))}
|
|
@@ -179,7 +287,11 @@ export const DetailPanel = ({
|
|
|
<aside className={styles.panel}>
|
|
<aside className={styles.panel}>
|
|
|
<div className={styles.header}>
|
|
<div className={styles.header}>
|
|
|
<div className={styles.title}>{title}</div>
|
|
<div className={styles.title}>{title}</div>
|
|
|
- <button className={styles.close} onClick={onClose} aria-label="关闭">
|
|
|
|
|
|
|
+ <button
|
|
|
|
|
+ className={styles.close}
|
|
|
|
|
+ onClick={onClose}
|
|
|
|
|
+ aria-label="关闭"
|
|
|
|
|
+ >
|
|
|
×
|
|
×
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
@@ -189,17 +301,14 @@ export const DetailPanel = ({
|
|
|
<div className={styles.sectionTitle}>节点</div>
|
|
<div className={styles.sectionTitle}>节点</div>
|
|
|
<div className={styles.section}>
|
|
<div className={styles.section}>
|
|
|
<div className={styles.label}>ID</div>
|
|
<div className={styles.label}>ID</div>
|
|
|
- <div className={styles.value}>
|
|
|
|
|
- {isMessageNode(node) ? node.message_id || node.id : node.id}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <div className={styles.value}>{isMessageNode(node) ? node.message_id || node.id : node.id}</div>
|
|
|
</div>
|
|
</div>
|
|
|
- {isMessageNode(node) &&
|
|
|
|
|
- extractImagesFromMessage(node).length > 0 && (
|
|
|
|
|
- <div className={styles.section}>
|
|
|
|
|
- <div className={styles.label}>图片</div>
|
|
|
|
|
- {renderImages(node)}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {isMessageNode(node) && extractImagesFromMessage(node).length > 0 && (
|
|
|
|
|
+ <div className={styles.section}>
|
|
|
|
|
+ <div className={styles.label}>图片</div>
|
|
|
|
|
+ {renderImages(node)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
|
|
|
{isGoal(node) ? (
|
|
{isGoal(node) ? (
|
|
|
<>
|
|
<>
|
|
@@ -246,9 +355,7 @@ export const DetailPanel = ({
|
|
|
)}
|
|
)}
|
|
|
<div className={styles.section}>
|
|
<div className={styles.section}>
|
|
|
<div className={styles.label}>内容</div>
|
|
<div className={styles.label}>内容</div>
|
|
|
- <div className={styles.value}>
|
|
|
|
|
- {node.content && renderMessageContent(node.content)}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <div className={styles.value}>{node.content && renderMessageContent(node.content)}</div>
|
|
|
</div>
|
|
</div>
|
|
|
{node.goal_id && (
|
|
{node.goal_id && (
|
|
|
<div className={styles.section}>
|
|
<div className={styles.section}>
|
|
@@ -270,16 +377,17 @@ export const DetailPanel = ({
|
|
|
<div className={styles.messages}>
|
|
<div className={styles.messages}>
|
|
|
<div className={styles.sectionTitle}>边</div>
|
|
<div className={styles.sectionTitle}>边</div>
|
|
|
{messages.map((msg, idx) => (
|
|
{messages.map((msg, idx) => (
|
|
|
- <div key={msg.id || idx} className={styles.messageItem}>
|
|
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={msg.id || idx}
|
|
|
|
|
+ className={styles.messageItem}
|
|
|
|
|
+ >
|
|
|
<div className={styles.section}>
|
|
<div className={styles.section}>
|
|
|
<div className={styles.label}>描述</div>
|
|
<div className={styles.label}>描述</div>
|
|
|
<div className={styles.value}>{msg.description || "-"}</div>
|
|
<div className={styles.value}>{msg.description || "-"}</div>
|
|
|
</div>
|
|
</div>
|
|
|
<div className={styles.section}>
|
|
<div className={styles.section}>
|
|
|
<div className={styles.label}>内容</div>
|
|
<div className={styles.label}>内容</div>
|
|
|
- <div className={styles.value}>
|
|
|
|
|
- {msg.content && renderMessageContent(msg.content)}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <div className={styles.value}>{msg.content && renderMessageContent(msg.content)}</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
))}
|
|
))}
|