diff --git a/frontend/src/api/knowledge.ts b/frontend/src/api/knowledge.ts new file mode 100644 index 0000000..5fb095c --- /dev/null +++ b/frontend/src/api/knowledge.ts @@ -0,0 +1,210 @@ +/** + * Knowledge API client — Wiki articles/categories/versions, knowledge graph, + * and Ask Knowledge. + * + * All requests use the shared `apiClient` (`baseURL: '/api/v1'`). + */ + +import { apiDelete, apiGet, apiPatch, apiPost } from './client'; + +// ─── Wiki types ──────────────────────────────────────────────────────────── + +export type WikiArticleStatus = 'draft' | 'published' | 'archived'; + +export interface WikiArticle { + id: string; + title: string; + slug: string; + content: string; + content_html: string | null; + summary: string | null; + category_id: string | null; + tags: string[]; + status: WikiArticleStatus; + entity_links: Record[]; + version: number; + published_at: string | null; + owner_id: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface WikiArticleListResult { + items: WikiArticle[]; + total: number; + page: number; + page_size: number; +} + +export interface WikiArticleCreate { + title: string; + slug?: string; + content?: string; + summary?: string | null; + category_id?: string | null; + tags?: string[]; + status?: WikiArticleStatus; + entity_links?: Record[]; +} + +export interface WikiArticleUpdate { + title?: string; + content?: string; + summary?: string | null; + category_id?: string | null; + tags?: string[]; + status?: WikiArticleStatus; + entity_links?: Record[]; + edit_comment?: string | null; +} + +export interface WikiCategory { + id: string; + name: string; + slug: string; + description: string | null; + parent_id: string | null; + sort_order: number; +} + +export interface WikiCategoryCreate { + name: string; + slug?: string; + description?: string | null; + parent_id?: string | null; + sort_order?: number; +} + +export interface WikiVersion { + id: string; + version: number; + title: string; + content: string; + edited_by: string | null; + edit_comment: string | null; + created_at: string | null; +} + +// ─── Graph types ─────────────────────────────────────────────────────────── + +export interface GraphRelationship { + id: string; + source_type: string; + source_id: string; + target_type: string; + target_id: string; + relationship_type: string; + metadata: Record | null; + owner_id: string | null; + created_at: string | null; +} + +export interface GraphRelationshipsResult { + items: GraphRelationship[]; + total: number; +} + +// ─── Ask Knowledge types ─────────────────────────────────────────────────── + +export interface KnowledgeAskRequest { + query: string; + source_types?: string[]; +} + +export interface KnowledgeEvidence { + id: string; + source_type: string; + source_id: string; + title: string; + snippet: string; + score?: number; + url?: string | null; +} + +export interface KnowledgeAskResponse { + answer: string; + evidence: KnowledgeEvidence[]; + sources_used?: string[]; +} + +// ─── Wiki API ────────────────────────────────────────────────────────────── + +export async function fetchWikiArticles(params?: { + page?: number; + page_size?: number; + category_id?: string | null; + status?: string | null; + search?: string | null; +}): Promise { + const query = new URLSearchParams(); + if (params?.page) query.set('page', String(params.page)); + if (params?.page_size) query.set('page_size', String(params.page_size)); + if (params?.category_id) query.set('category_id', params.category_id); + if (params?.status) query.set('status', params.status); + if (params?.search) query.set('search', params.search); + const qs = query.toString(); + return apiGet(`/wiki/articles${qs ? `?${qs}` : ''}`); +} + +export async function fetchWikiArticle(articleId: string): Promise { + return apiGet(`/wiki/articles/${articleId}`); +} + +export async function createWikiArticle(data: WikiArticleCreate): Promise { + return apiPost('/wiki/articles', data); +} + +export async function updateWikiArticle(articleId: string, data: WikiArticleUpdate): Promise { + return apiPatch(`/wiki/articles/${articleId}`, data); +} + +export async function deleteWikiArticle(articleId: string): Promise { + await apiDelete(`/wiki/articles/${articleId}`); +} + +export async function fetchWikiVersions(articleId: string): Promise { + const result = await apiGet<{ items: WikiVersion[] }>(`/wiki/articles/${articleId}/versions`); + return result.items; +} + +export async function restoreWikiVersion(articleId: string, version: number): Promise { + return apiPost(`/wiki/articles/${articleId}/versions/${version}/restore`); +} + +export async function fetchWikiCategories(): Promise { + const result = await apiGet<{ items: WikiCategory[] }>('/wiki/categories'); + return result.items; +} + +export async function createWikiCategory(data: WikiCategoryCreate): Promise { + return apiPost('/wiki/categories', data); +} + +// ─── Graph API ───────────────────────────────────────────────────────────── + +export async function fetchGraphRelationships(params?: { + source_type?: string | null; + source_id?: string | null; + target_type?: string | null; + target_id?: string | null; + relationship_type?: string | null; + page?: number; + page_size?: number; +}): Promise { + const query = new URLSearchParams(); + if (params?.source_type) query.set('source_type', params.source_type); + if (params?.source_id) query.set('source_id', params.source_id); + if (params?.target_type) query.set('target_type', params.target_type); + if (params?.target_id) query.set('target_id', params.target_id); + if (params?.relationship_type) query.set('relationship_type', params.relationship_type); + if (params?.page) query.set('page', String(params.page)); + if (params?.page_size) query.set('page_size', String(params.page_size)); + const qs = query.toString(); + return apiGet(`/graph/relationships${qs ? `?${qs}` : ''}`); +} + +// ─── Ask Knowledge API ───────────────────────────────────────────────────── + +export async function askKnowledge(body: KnowledgeAskRequest): Promise { + return apiPost('/knowledge/ask', body); +} diff --git a/frontend/src/components/knowledge/AskKnowledge.tsx b/frontend/src/components/knowledge/AskKnowledge.tsx new file mode 100644 index 0000000..ba397c1 --- /dev/null +++ b/frontend/src/components/knowledge/AskKnowledge.tsx @@ -0,0 +1,139 @@ +/** + * AskKnowledge — query input + answer + evidence cards. + * + * Calls POST /api/v1/knowledge/ask with `{ query, source_types }` and renders + * the returned answer plus evidence cards. + */ + +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, Search, Sparkles } from 'lucide-react'; +import { askKnowledge, type KnowledgeAskResponse } from '@/api/knowledge'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Badge'; +import { EmptyState } from '@/components/ui/EmptyState'; + +const SOURCE_TYPES = ['contact', 'company', 'wiki', 'dms_file', 'email', 'task']; + +export function AskKnowledge() { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + const [sourceTypes, setSourceTypes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const toggleSource = useCallback((type: string) => { + setSourceTypes((prev) => + prev.includes(type) ? prev.filter((s) => s !== type) : [...prev, type] + ); + }, []); + + const handleAsk = useCallback(async () => { + if (!query.trim()) return; + setLoading(true); + setError(null); + try { + const response = await askKnowledge({ + query: query.trim(), + source_types: sourceTypes.length > 0 ? sourceTypes : undefined, + }); + setResult(response); + } catch (err) { + setError(err instanceof Error ? err.message : t('knowledge.ask.error')); + } finally { + setLoading(false); + } + }, [query, sourceTypes, t]); + + return ( + +
+
+
+
+ +
+ +
+ {t('knowledge.ask.sourceTypes')} + {SOURCE_TYPES.map((type) => { + const active = sourceTypes.includes(type); + return ( + + ); + })} +
+ + {error &&

{error}

} + + {loading && ( +
+
+ )} + + {!loading && result && ( +
+
+

{t('knowledge.ask.answer')}

+

{result.answer}

+
+ +
+

{t('knowledge.ask.evidence')}

+ {result.evidence.length === 0 ? ( + + ) : ( +
    + {result.evidence.map((evidence) => ( +
  • +
    + {evidence.title} + {evidence.source_type} +
    +

    {evidence.snippet}

    + {typeof evidence.score === 'number' && ( +

    + {t('knowledge.ask.score')}: {Math.round(evidence.score * 100)}% +

    + )} +
  • + ))} +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/knowledge/KnowledgeGraph.tsx b/frontend/src/components/knowledge/KnowledgeGraph.tsx new file mode 100644 index 0000000..7104e16 --- /dev/null +++ b/frontend/src/components/knowledge/KnowledgeGraph.tsx @@ -0,0 +1,369 @@ +/** + * KnowledgeGraph — SVG-based visualization of entity relationships. + * + * Fetches relationships from GET /api/v1/graph/relationships and renders + * Contacts/Companies as nodes (circles) connected by labeled edges. + * Supports pan/zoom and click-to-inspect details. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; +import { fetchGraphRelationships, type GraphRelationship } from '@/api/knowledge'; +import { Card } from '@/components/ui/Card'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Button } from '@/components/ui/Button'; + +interface GraphNode { + id: string; + entity_type: string; + label: string; + x: number; + y: number; +} + +interface GraphEdge { + id: string; + source: string; + target: string; + label: string; +} + +interface SelectedNode { + id: string; + entity_type: string; + label: string; + relationships: GraphRelationship[]; +} + +const NODE_RADIUS = 24; +const WIDTH = 900; +const HEIGHT = 560; + +const TYPE_COLORS: Record = { + contact: '#3b82f6', + company: '#10b981', + email: '#8b5cf6', + task: '#f59e0b', + dms_file: '#ef4444', + default: '#64748b', +}; + +function typeColor(type: string): string { + return TYPE_COLORS[type] ?? TYPE_COLORS.default; +} + +function typeLabel(type: string): string { + return type.replace(/_/g, ' '); +} + +/** + * Simple force-directed layout: nodes repel, edges attract. + * Deterministic initial placement avoids layout jumps. + */ +function layoutNodes(relationships: GraphRelationship[]): { nodes: GraphNode[]; edges: GraphEdge[] } { + const nodeMap = new Map(); + const edges: GraphEdge[] = []; + + for (const rel of relationships) { + const sourceKey = `${rel.source_type}:${rel.source_id}`; + const targetKey = `${rel.target_type}:${rel.target_id}`; + if (!nodeMap.has(sourceKey)) { + nodeMap.set(sourceKey, { entity_type: rel.source_type, label: rel.source_type }); + } + if (!nodeMap.has(targetKey)) { + nodeMap.set(targetKey, { entity_type: rel.target_type, label: rel.target_type }); + } + edges.push({ + id: rel.id, + source: sourceKey, + target: targetKey, + label: rel.relationship_type, + }); + } + + const keys = Array.from(nodeMap.keys()); + const nodes: GraphNode[] = keys.map((key, i) => { + const angle = (i / Math.max(keys.length, 1)) * Math.PI * 2; + const radius = Math.min(220, 80 + (i % 5) * 40); + return { + id: key, + entity_type: nodeMap.get(key)?.entity_type ?? 'unknown', + label: nodeMap.get(key)?.label ?? key, + x: WIDTH / 2 + Math.cos(angle) * radius, + y: HEIGHT / 2 + Math.sin(angle) * radius, + }; + }); + + // Simple repulsion/attraction relaxation + for (let iter = 0; iter < 80; iter++) { + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const dx = nodes[j].x - nodes[i].x; + const dy = nodes[j].y - nodes[i].y; + const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + const force = 40 / (dist * dist); + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + nodes[i].x -= fx; + nodes[i].y -= fy; + nodes[j].x += fx; + nodes[j].y += fy; + } + } + for (const edge of edges) { + const s = nodes.find((n) => n.id === edge.source); + const t = nodes.find((n) => n.id === edge.target); + if (!s || !t) continue; + const dx = t.x - s.x; + const dy = t.y - s.y; + const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + const force = (dist - 160) * 0.02; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + s.x += fx; + s.y += fy; + t.x -= fx; + t.y -= fy; + } + } + + // Clamp to viewport with padding + for (const n of nodes) { + n.x = Math.min(Math.max(n.x, 60), WIDTH - 60); + n.y = Math.min(Math.max(n.y, 60), HEIGHT - 60); + } + + return { nodes, edges }; +} + +export function KnowledgeGraph() { + const { t } = useTranslation(); + const [relationships, setRelationships] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); + const [zoom, setZoom] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const dragStart = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await fetchGraphRelationships({ page_size: 200 }); + setRelationships(result.items); + } catch (err) { + setError(err instanceof Error ? err.message : t('knowledge.graph.loadError')); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + const { nodes, edges } = useMemo(() => layoutNodes(relationships), [relationships]); + + const selectedRelationships = useMemo(() => { + if (!selected) return []; + return relationships.filter( + (r) => + `${r.source_type}:${r.source_id}` === selected.id || + `${r.target_type}:${r.target_id}` === selected.id + ); + }, [selected, relationships]); + + const handleNodeClick = useCallback((node: GraphNode) => { + setSelected(node); + }, []); + + const handleWheel = useCallback((e: React.WheelEvent) => { + const factor = e.deltaY > 0 ? 0.9 : 1.1; + setZoom((z) => Math.min(Math.max(z * factor, 0.4), 3)); + }, []); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + setDragging(true); + dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; + }, [pan]); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!dragging || !dragStart.current) return; + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy }); + }, + [dragging] + ); + + const handleMouseUp = useCallback(() => { + setDragging(false); + dragStart.current = null; + }, []); + + const resetView = useCallback(() => { + setZoom(1); + setPan({ x: 0, y: 0 }); + }, []); + + return ( + + + + + + } + > + {loading && ( +
+
+ )} + {!loading && error && ( +
+

{error}

+ +
+ )} + {!loading && !error && relationships.length === 0 && ( + + )} + {!loading && !error && relationships.length > 0 && ( +
+
+ + + {/* Edges */} + {edges.map((edge) => { + const source = nodes.find((n) => n.id === edge.source); + const target = nodes.find((n) => n.id === edge.target); + if (!source || !target) return null; + const mx = (source.x + target.x) / 2; + const my = (source.y + target.y) / 2; + return ( + + + + {edge.label} + + + ); + })} + {/* Nodes */} + {nodes.map((node) => { + const isSelected = selected?.id === node.id; + return ( + { + e.stopPropagation(); + handleNodeClick(node); + }} + className="cursor-pointer" + role="button" + aria-label={`${node.label} ${node.entity_type}`} + > + + + {node.label.length > 12 ? `${node.label.slice(0, 11)}…` : node.label} + + + ); + })} + + +
+
+ {selected ? ( +
+

{selected.label}

+

{typeLabel(selected.entity_type)}

+

{t('knowledge.graph.relationships')}

+ {selectedRelationships.length === 0 && ( +

{t('knowledge.graph.noRelationships')}

+ )} +
    + {selectedRelationships.map((rel) => { + const isSource = `${rel.source_type}:${rel.source_id}` === selected.id; + const otherType = isSource ? rel.target_type : rel.source_type; + return ( +
  • + {rel.relationship_type} + + {typeLabel(otherType)} +
  • + ); + })} +
+
+ ) : ( +
+ {t('knowledge.graph.selectHint')} +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/wiki/WikiBrowser.tsx b/frontend/src/components/wiki/WikiBrowser.tsx new file mode 100644 index 0000000..da158b6 --- /dev/null +++ b/frontend/src/components/wiki/WikiBrowser.tsx @@ -0,0 +1,250 @@ +/** + * WikiBrowser — category tree + article list + search. + * + * Renders categories as a tree (with parent/child nesting) and the matching + * articles below. Search filters articles by title via the backend `search` + * parameter. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, ChevronRight, Folder, FolderOpen, Loader2, Search } from 'lucide-react'; +import clsx from 'clsx'; +import { fetchWikiArticles, fetchWikiCategories, type WikiArticle, type WikiCategory } from '@/api/knowledge'; +import { Input } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Badge'; +import { EmptyState } from '@/components/ui/EmptyState'; + +interface WikiBrowserProps { + selectedArticleId: string | null; + onSelectArticle: (article: WikiArticle) => void; +} + +interface CategoryNode { + category: WikiCategory; + children: CategoryNode[]; +} + +function buildTree(categories: WikiCategory[]): CategoryNode[] { + const map = new Map(); + for (const c of categories) { + map.set(c.id, { category: c, children: [] }); + } + const roots: CategoryNode[] = []; + for (const node of map.values()) { + const parentId = node.category.parent_id; + if (parentId && map.has(parentId)) { + map.get(parentId)?.children.push(node); + } else { + roots.push(node); + } + } + return roots; +} + +function CategoryNodeItem({ + node, + depth, + expanded, + onToggle, + onSelectCategory, +}: { + node: CategoryNode; + depth: number; + expanded: Set; + onToggle: (id: string) => void; + onSelectCategory: (id: string | null) => void; +}) { + const { t } = useTranslation(); + const hasChildren = node.children.length > 0; + const isOpen = expanded.has(node.category.id); + + return ( +
  • +
    onSelectCategory(node.category.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelectCategory(node.category.id); + } + }} + > + {hasChildren ? ( + + ) : ( +
    + {hasChildren && isOpen && ( +
      + {node.children.map((child) => ( + + ))} +
    + )} +
  • + ); +} + +export function WikiBrowser({ selectedArticleId, onSelectArticle }: WikiBrowserProps) { + const { t } = useTranslation(); + const [categories, setCategories] = useState([]); + const [articles, setArticles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [catResult, articleResult] = await Promise.all([ + fetchWikiCategories(), + fetchWikiArticles({ page_size: 100, category_id: selectedCategoryId, search: search || null }), + ]); + setCategories(catResult); + setArticles(articleResult.items); + } catch (err) { + setError(err instanceof Error ? err.message : t('wiki.browser.loadError')); + } finally { + setLoading(false); + } + }, [selectedCategoryId, search, t]); + + useEffect(() => { + void load(); + }, [load]); + + const tree = useMemo(() => buildTree(categories), [categories]); + + const toggle = useCallback((id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const selectCategory = useCallback((id: string | null) => { + setSelectedCategoryId(id); + }, []); + + return ( +
    +
    +
    +
    +
    + +
    + {loading && ( +
    +
    + )} + {!loading && error &&

    {error}

    } + {!loading && !error && ( + <> + +
      + {tree.map((node) => ( + + ))} +
    + +
    +

    + {t('wiki.browser.articles')} +

    + {articles.length === 0 ? ( + + ) : ( +
      + {articles.map((article) => ( +
    • + +
    • + ))} +
    + )} +
    + + )} +
    +
    + ); +} diff --git a/frontend/src/components/wiki/WikiEditor.tsx b/frontend/src/components/wiki/WikiEditor.tsx new file mode 100644 index 0000000..677f86d --- /dev/null +++ b/frontend/src/components/wiki/WikiEditor.tsx @@ -0,0 +1,79 @@ +/** + * WikiEditor — Markdown editor with live preview. + * + * Uses a textarea for Markdown input and react-markdown + remark-gfm for + * the live preview pane. Content is stored as Markdown in the backend. + */ + +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import clsx from 'clsx'; +import { Eye, PencilLine, Columns2 } from 'lucide-react'; + +export interface WikiEditorProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + minHeight?: string; +} + +type EditorMode = 'write' | 'preview' | 'split'; + +export function WikiEditor({ value, onChange, placeholder, minHeight = 'min-h-72' }: WikiEditorProps) { + const { t } = useTranslation(); + const [mode, setMode] = useState('split'); + + const preview = useMemo( + () => ( +
    + {value || t('wiki.editor.emptyPreview')} +
    + ), + [value, t] + ); + + const modeButton = (m: EditorMode, label: string, Icon: React.ComponentType<{ className?: string }>) => ( + + ); + + return ( +
    +
    + {modeButton('write', t('editor.write'), PencilLine)} + {modeButton('preview', t('editor.preview'), Eye)} + {modeButton('split', t('editor.split'), Columns2)} +
    +
    + {(mode === 'write' || mode === 'split') && ( +