feat: knowledge base first OK

This commit is contained in:
qixinbo
2026-03-29 00:20:53 +08:00
parent bd7776d1b7
commit 92e8c40826
17 changed files with 3357 additions and 10 deletions
+191 -2
View File
@@ -31,6 +31,7 @@ interface Message {
total_tokens: number;
};
artifacts?: MessageArtifact[];
kbCitations?: KnowledgeCitation[];
}
interface MessageViz {
@@ -51,6 +52,14 @@ interface MessageArtifact {
preview_url?: string;
}
interface KnowledgeCitation {
doc_id: string;
title: string;
score: number;
chunk: string;
metadata?: Record<string, unknown>;
}
interface ArtifactPreviewTarget {
name: string;
mimeType: string;
@@ -105,6 +114,11 @@ interface Skill {
type: string;
}
interface KnowledgeBaseOption {
id: string;
name: string;
}
const dedupeSkillsById = (skills: Skill[]): Skill[] => {
const map = new Map<string, Skill>();
for (const skill of skills) {
@@ -120,6 +134,7 @@ interface SessionData {
metadata?: {
active_data_file?: DataFileContext | null;
selected_data_source?: string | null;
selected_knowledge_base_id?: string | null;
[key: string]: any;
};
messages: Array<{
@@ -179,12 +194,34 @@ const normalizeArtifacts = (raw: unknown): MessageArtifact[] => {
}, []);
};
const normalizeKnowledgeCitations = (raw: unknown): KnowledgeCitation[] => {
if (!Array.isArray(raw)) return [];
return raw.reduce<KnowledgeCitation[]>((acc, item) => {
if (!item || typeof item !== "object") return acc;
const source = item as Record<string, unknown>;
const title = typeof source.title === "string" ? source.title : "";
const chunk = typeof source.chunk === "string" ? source.chunk : "";
const score = typeof source.score === "number" ? source.score : Number(source.score || 0);
if (!title || !chunk) return acc;
acc.push({
doc_id: typeof source.doc_id === "string" ? source.doc_id : "",
title,
score: Number.isFinite(score) ? score : 0,
chunk,
metadata: source.metadata && typeof source.metadata === "object" ? source.metadata as Record<string, unknown> : undefined,
});
return acc;
}, []);
};
export function ChatInterface() {
const { t } = useTranslation();
const [messagesBySession, setMessagesBySession] = useState<Record<string, Message[]>>({});
const [input, setInput] = useState("");
const [selectedDataSource, setSelectedDataSource] = useState<string>("");
const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState<string>("");
const [availableSkills, setAvailableSkills] = useState<Skill[]>([]);
const [availableKnowledgeBases, setAvailableKnowledgeBases] = useState<KnowledgeBaseOption[]>([]);
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [artifactPreview, setArtifactPreview] = useState<ArtifactPreviewTarget | null>(null);
@@ -443,6 +480,10 @@ export function ChatInterface() {
useEffect(() => {
if (currentProject) {
fetchDataSources();
fetchKnowledgeBases();
} else {
setAvailableKnowledgeBases([]);
setSelectedKnowledgeBaseId("");
}
}, [currentProject]);
@@ -461,9 +502,25 @@ export function ChatInterface() {
}
};
const fetchKnowledgeBases = async () => {
if (!currentProject) return;
try {
const data = await api.get<Array<{ id: string; name: string }>>(`/api/v1/knowledge-bases?project_id=${currentProject.id}`);
const projectKnowledgeBases = (data || []).map((item) => ({ id: item.id, name: item.name }));
setAvailableKnowledgeBases(projectKnowledgeBases);
if (selectedKnowledgeBaseId && !projectKnowledgeBases.find((item) => item.id === selectedKnowledgeBaseId)) {
setSelectedKnowledgeBaseId("");
void syncSessionContext({ selected_knowledge_base_id: null });
}
} catch (e) {
console.error("Failed to fetch knowledge bases", e);
}
};
const syncSessionContext = async (payload: {
active_data_file?: DataFileContext | null;
selected_data_source?: string | null;
selected_knowledge_base_id?: string | null;
}) => {
try {
await api.put(`/nanobot/sessions/${encodeURIComponent(activeSessionKey)}/context-file`, payload);
@@ -472,6 +529,16 @@ export function ChatInterface() {
}
};
const handleSelectKnowledgeBase = async (knowledgeBaseId: string) => {
setSelectedKnowledgeBaseId(knowledgeBaseId);
await syncSessionContext({ selected_knowledge_base_id: knowledgeBaseId });
};
const handleClearKnowledgeBase = async () => {
setSelectedKnowledgeBaseId("");
await syncSessionContext({ selected_knowledge_base_id: null });
};
const handleSelectDataSource = async (sourceId: string) => {
setSelectedDataSource(sourceId);
await syncSessionContext({ selected_data_source: sourceId });
@@ -516,6 +583,7 @@ export function ChatInterface() {
reasoningContent: typeof m.reasoning_content === "string" ? m.reasoning_content : undefined,
usage: m.usage,
artifacts: normalizeArtifacts(m.artifacts),
kbCitations: normalizeKnowledgeCitations(m.kb_citations),
};
});
setMessagesForSession(activeSessionKey, formattedMessages);
@@ -524,14 +592,17 @@ export function ChatInterface() {
}
const restoredFile = data.metadata?.active_data_file || null;
const restoredSource = data.metadata?.selected_data_source || "";
const restoredKnowledgeBaseId = data.metadata?.selected_knowledge_base_id || "";
setActiveDataFile(restoredFile);
setSelectedDataSource(restoredSource);
setSelectedKnowledgeBaseId(restoredKnowledgeBaseId);
setAttachedFile(null);
} catch (e) {
console.error("Failed to fetch session messages", e);
setMessagesForSession(activeSessionKey, []);
setActiveDataFile(null);
setSelectedDataSource("");
setSelectedKnowledgeBaseId("");
setAttachedFile(null);
} finally {
setIsLoadingForSession(activeSessionKey, false);
@@ -631,6 +702,7 @@ export function ChatInterface() {
};
const selectedDataSourceName = availableDataSources.find(ds => ds.id === selectedDataSource)?.name || "";
const selectedKnowledgeBaseName = availableKnowledgeBases.find((item) => item.id === selectedKnowledgeBaseId)?.name || "";
const selectedSkills = availableSkills.filter(skill => selectedSkillIds.includes(skill.id));
const isThinkingCollapsed = (messageId: string) => collapsedThinkingByMessage[messageId] ?? true;
const toggleThinkingCollapsed = (messageId: string) => {
@@ -650,7 +722,7 @@ export function ChatInterface() {
};
const renderActiveSelections = () => {
if (!selectedDataSource && selectedSkills.length === 0) return null;
if (!selectedDataSource && !selectedKnowledgeBaseId && selectedSkills.length === 0) return null;
return (
<div className="px-2 pt-2">
<div className="flex flex-wrap gap-2">
@@ -660,6 +732,12 @@ export function ChatInterface() {
{`${t('dataSource')}${selectedDataSourceName}`}
</div>
) : null}
{selectedKnowledgeBaseId ? (
<div className="px-3 py-1.5 rounded-full text-xs border flex items-center gap-1.5 bg-violet-50 text-violet-700 border-violet-200">
<Database className="h-3.5 w-3.5" />
{`${t('knowledgeBase')}${selectedKnowledgeBaseName || selectedKnowledgeBaseId}`}
</div>
) : null}
{selectedSkills.map((skill) => (
<div
key={skill.id}
@@ -812,6 +890,7 @@ export function ChatInterface() {
prefer_sql_chart: preferSqlChart,
file_url: fileUrl,
route_mode: "auto",
knowledge_base_id: selectedKnowledgeBaseId || undefined,
}),
signal: controller.signal,
});
@@ -917,6 +996,7 @@ export function ChatInterface() {
completion_tokens: number;
total_tokens: number;
};
kb_citations?: unknown;
};
if (payload.type === "delta" && payload.content) {
@@ -963,9 +1043,10 @@ export function ChatInterface() {
flushAssistant(true);
pushProgressLog(t('answerGenerationCompleted'));
const messageArtifacts = normalizeArtifacts(payload.artifacts);
const messageCitations = normalizeKnowledgeCitations(payload.kb_citations);
setMessagesForSession(targetSessionKey, (prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: typeof payload.content === "string" ? payload.content : msg.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz, usage: payload.usage, artifacts: messageArtifacts.length > 0 ? messageArtifacts : msg.artifacts } : msg
msg.id === assistantId ? { ...msg, content: typeof payload.content === "string" ? payload.content : msg.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz, usage: payload.usage, artifacts: messageArtifacts.length > 0 ? messageArtifacts : msg.artifacts, kbCitations: messageCitations.length > 0 ? messageCitations : msg.kbCitations } : msg
)
);
}
@@ -1146,6 +1227,52 @@ export function ChatInterface() {
</div>
)}
</div>
<div className="mt-3 pt-3 border-t border-border">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
<Database className="h-3 w-3" />
{t('knowledgeBase')}
</div>
<div className="space-y-0.5">
{availableKnowledgeBases.length > 0 ? (
availableKnowledgeBases.map((kb) => (
<button
key={kb.id}
onClick={() => {
void handleSelectKnowledgeBase(kb.id);
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
selectedKnowledgeBaseId === kb.id
? "bg-background text-foreground shadow-sm ring-1 ring-border"
: "text-muted-foreground hover:bg-background hover:shadow-sm"
)}
>
<div className="flex items-center gap-2.5">
<Database className={cn("h-4 w-4", selectedKnowledgeBaseId === kb.id ? "text-violet-500" : "text-muted-foreground")} />
<span className="font-medium">{kb.name}</span>
</div>
{selectedKnowledgeBaseId === kb.id && <CheckCircle2 className="h-4 w-4 text-violet-500" />}
</button>
))
) : (
<div className="px-3 py-3 text-xs text-muted-foreground">
{t('noKnowledgeBases')}
</div>
)}
{selectedKnowledgeBaseId ? (
<div className="mt-2 pt-2 border-t border-border">
<button
onClick={() => {
void handleClearKnowledgeBase();
}}
className="w-full py-1.5 text-[11px] text-muted-foreground hover:text-muted-foreground transition-colors flex items-center justify-center gap-1"
>
{t('clearSelected')}
</button>
</div>
) : null}
</div>
</div>
</div>
{/* Right Column: Skills */}
@@ -1501,6 +1628,22 @@ export function ChatInterface() {
))}
</div>
) : null}
{msg.kbCitations && msg.kbCitations.length > 0 ? (
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/60 p-3">
<div className="text-xs font-semibold text-violet-700 uppercase tracking-wider mb-2">{t('knowledgeCitations')}</div>
<div className="space-y-2">
{msg.kbCitations.map((citation, citationIndex) => (
<div key={`${msg.id}-citation-${citationIndex}`} className="rounded-lg border border-violet-200 bg-white/80 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-violet-900 truncate">{citation.title}</div>
<div className="text-[11px] text-violet-700 shrink-0">{t('matchScore', { score: citation.score.toFixed(3) })}</div>
</div>
<div className="mt-1 text-xs text-violet-800 line-clamp-3 whitespace-pre-wrap break-words">{citation.chunk}</div>
</div>
))}
</div>
</div>
) : null}
{msg.viz ? (
<div className="mt-3 pt-3 border-t border-border">
<InlineVisualizationCard viz={msg.viz} />
@@ -1581,6 +1724,52 @@ export function ChatInterface() {
</div>
)}
</div>
<div className="mt-3 pt-3 border-t border-border">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
<Database className="h-3 w-3" />
{t('knowledgeBase')}
</div>
<div className="space-y-0.5">
{availableKnowledgeBases.length > 0 ? (
availableKnowledgeBases.map((kb) => (
<button
key={kb.id}
onClick={() => {
void handleSelectKnowledgeBase(kb.id);
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
selectedKnowledgeBaseId === kb.id
? "bg-background text-foreground shadow-sm ring-1 ring-border"
: "text-muted-foreground hover:bg-background hover:shadow-sm"
)}
>
<div className="flex items-center gap-2.5">
<Database className={cn("h-4 w-4", selectedKnowledgeBaseId === kb.id ? "text-violet-500" : "text-muted-foreground")} />
<span className="font-medium">{kb.name}</span>
</div>
{selectedKnowledgeBaseId === kb.id && <CheckCircle2 className="h-4 w-4 text-violet-500" />}
</button>
))
) : (
<div className="px-3 py-3 text-xs text-muted-foreground">
{t('noKnowledgeBases')}
</div>
)}
{selectedKnowledgeBaseId ? (
<div className="mt-2 pt-2 border-t border-border">
<button
onClick={() => {
void handleClearKnowledgeBase();
}}
className="w-full py-1.5 text-[11px] text-muted-foreground hover:text-muted-foreground transition-colors flex items-center justify-center gap-1"
>
{t('clearSelected')}
</button>
</div>
) : null}
</div>
</div>
</div>
{/* Right Column: Skills */}