mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
saving
This commit is contained in:
parent
e8c7a4f6e0
commit
ffd97375a8
160 changed files with 6704 additions and 1895 deletions
|
|
@ -22,6 +22,7 @@
|
|||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
|
|
|
|||
|
|
@ -61,10 +61,10 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
|||
|
||||
// Track container width for the force graph
|
||||
useEffect(() => {
|
||||
if (!expanded || !containerRef.current) return;
|
||||
if (!expanded || containerRef.current === null) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setContainerWidth(Math.floor(entry.contentRect.width));
|
||||
if (entry !== undefined) setContainerWidth(Math.floor(entry.contentRect.width));
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
|
|
@ -83,7 +83,10 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
|||
}
|
||||
|
||||
// Fall back to fetching from named graph
|
||||
const graphUris = explainEvents.filter((ev) => ev.explainGraph);
|
||||
const graphUris = explainEvents.filter(
|
||||
(ev): ev is ExplainEvent & { explainGraph: string } =>
|
||||
ev.explainGraph !== undefined && ev.explainGraph.length > 0,
|
||||
);
|
||||
if (graphUris.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
|
|
@ -117,7 +120,11 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
|||
// Auto-fit once data loads
|
||||
const hasAutoFit = useRef(false);
|
||||
useEffect(() => {
|
||||
if (graphData.nodes.length > 0 && fgRef.current && !hasAutoFit.current) {
|
||||
if (
|
||||
graphData.nodes.length > 0 &&
|
||||
fgRef.current !== undefined &&
|
||||
hasAutoFit.current === false
|
||||
) {
|
||||
hasAutoFit.current = true;
|
||||
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 20), 500);
|
||||
return () => clearTimeout(timer);
|
||||
|
|
@ -155,7 +162,14 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
|||
if (globalScale < 1.5) return;
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (!src.x || !tgt.x) return;
|
||||
if (
|
||||
src.x === undefined ||
|
||||
src.y === undefined ||
|
||||
tgt.x === undefined ||
|
||||
tgt.y === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
|
@ -210,13 +224,13 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{error !== null && (
|
||||
<p className="px-3 py-3 text-xs text-error">
|
||||
Failed to load graph: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && graphData.nodes.length === 0 && (
|
||||
{!loading && error === null && graphData.nodes.length === 0 && (
|
||||
<p className="px-3 py-4 text-center text-xs text-fg-subtle">
|
||||
No graph data available for this query.
|
||||
</p>
|
||||
|
|
@ -278,7 +292,7 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
|||
linkDirectionalArrowLength={3}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
backgroundColor="transparent"
|
||||
width={containerWidth || undefined}
|
||||
{...(containerWidth > 0 ? { width: containerWidth } : {})}
|
||||
height={280}
|
||||
/>
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("[ErrorBoundary]", error, info.componentStack);
|
||||
}
|
||||
|
||||
|
|
@ -30,9 +30,9 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
if (this.props.fallback !== undefined) return this.props.fallback;
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
|
|
@ -42,7 +42,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
Something went wrong
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-fg-muted">
|
||||
{this.state.error?.message || "An unexpected error occurred."}
|
||||
{this.state.error?.message ?? "An unexpected error occurred."}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function Dialog({
|
|||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement as HTMLElement | null;
|
||||
} else if (triggerRef.current) {
|
||||
} else if (triggerRef.current !== null) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
|
|
@ -58,14 +58,14 @@ export function Dialog({
|
|||
|
||||
// Auto-focus first focusable element when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || !dialogRef.current) return;
|
||||
if (!open || dialogRef.current === null) return;
|
||||
const focusable = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter(
|
||||
(el) =>
|
||||
!el.hidden &&
|
||||
el.hidden === false &&
|
||||
!(el as HTMLButtonElement).disabled &&
|
||||
el.offsetParent !== null &&
|
||||
window.getComputedStyle(el).display !== "none",
|
||||
|
|
@ -79,7 +79,7 @@ export function Dialog({
|
|||
|
||||
// Focus trap — keep Tab within the dialog
|
||||
useEffect(() => {
|
||||
if (!open || !dialogRef.current) return;
|
||||
if (!open || dialogRef.current === null) return;
|
||||
const dialog = dialogRef.current;
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
|
|
@ -90,7 +90,7 @@ export function Dialog({
|
|||
),
|
||||
).filter(
|
||||
(el) =>
|
||||
!el.hidden &&
|
||||
el.hidden === false &&
|
||||
!(el as HTMLButtonElement).disabled &&
|
||||
el.offsetParent !== null &&
|
||||
window.getComputedStyle(el).display !== "none",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function AutoTextarea({
|
|||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (el === null) return;
|
||||
|
||||
// Reset height so scrollHeight is recalculated
|
||||
el.style.height = "auto";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,23 @@ import { useProgressStore } from "./use-progress-store";
|
|||
import { useSettings } from "@/providers/settings-provider";
|
||||
import type { StreamingMetadata, ExplainEvent } from "@trustgraph/client";
|
||||
|
||||
function metadataFrom(metadata: StreamingMetadata | undefined): ChatMessage["metadata"] | undefined {
|
||||
if (metadata === undefined) return undefined;
|
||||
|
||||
const result: NonNullable<ChatMessage["metadata"]> = {};
|
||||
if (metadata.model !== undefined) result.model = metadata.model;
|
||||
if (metadata.in_token !== undefined) result.inTokens = metadata.in_token;
|
||||
if (metadata.out_token !== undefined) result.outTokens = metadata.out_token;
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function withoutActivePhase(message: ChatMessage): ChatMessage {
|
||||
const next = { ...message };
|
||||
delete next.activePhase;
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -38,25 +55,26 @@ export function useChat(): UseChatReturn {
|
|||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
if (abortControllerRef.current !== null) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
content: prev.content || "(Cancelled)",
|
||||
isStreaming: false,
|
||||
activePhase: undefined,
|
||||
}));
|
||||
updateLastMessage((prev) =>
|
||||
withoutActivePhase({
|
||||
...prev,
|
||||
content: prev.content.length > 0 ? prev.content : "(Cancelled)",
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
removeActivity("Chat request");
|
||||
}, [updateLastMessage, removeActivity]);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
({ input }: { input: string }) => {
|
||||
if (!input.trim()) return;
|
||||
if (input.trim().length === 0) return;
|
||||
|
||||
// Abort any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
if (abortControllerRef.current !== null) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
|
@ -116,20 +134,17 @@ export function useChat(): UseChatReturn {
|
|||
complete: boolean,
|
||||
metadata?: StreamingMetadata,
|
||||
) => {
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
content: prev.content + chunk,
|
||||
isStreaming: !complete,
|
||||
...(complete && metadata
|
||||
? {
|
||||
metadata: {
|
||||
model: metadata.model,
|
||||
inTokens: metadata.in_token,
|
||||
outTokens: metadata.out_token,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
updateLastMessage((prev) => {
|
||||
const next: ChatMessage = {
|
||||
...prev,
|
||||
content: prev.content + chunk,
|
||||
isStreaming: !complete,
|
||||
};
|
||||
const finalMetadata = complete ? metadataFrom(metadata) : undefined;
|
||||
return finalMetadata !== undefined
|
||||
? { ...next, metadata: finalMetadata }
|
||||
: next;
|
||||
});
|
||||
|
||||
if (complete) {
|
||||
attachExplainEvents();
|
||||
|
|
@ -138,12 +153,13 @@ export function useChat(): UseChatReturn {
|
|||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
content: prev.content || `Error: ${error}`,
|
||||
isStreaming: false,
|
||||
activePhase: undefined,
|
||||
}));
|
||||
updateLastMessage((prev) =>
|
||||
withoutActivePhase({
|
||||
...prev,
|
||||
content: prev.content.length > 0 ? prev.content : `Error: ${error}`,
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
removeActivity(activityLabel);
|
||||
};
|
||||
|
||||
|
|
@ -172,14 +188,14 @@ export function useChat(): UseChatReturn {
|
|||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
think: phases.think + chunk,
|
||||
},
|
||||
activePhase: complete ? prev.activePhase : "think",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
think: phases.think + chunk,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "think" as const }),
|
||||
};
|
||||
});
|
||||
},
|
||||
// observe
|
||||
|
|
@ -190,14 +206,14 @@ export function useChat(): UseChatReturn {
|
|||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
observe: phases.observe + chunk,
|
||||
},
|
||||
activePhase: complete ? prev.activePhase : "observe",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
observe: phases.observe + chunk,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "observe" as const }),
|
||||
};
|
||||
});
|
||||
},
|
||||
// answer
|
||||
|
|
@ -208,27 +224,23 @@ export function useChat(): UseChatReturn {
|
|||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
const newAnswer = phases.answer + chunk;
|
||||
return {
|
||||
...prev,
|
||||
content: newAnswer,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
answer: newAnswer,
|
||||
},
|
||||
activePhase: complete ? undefined : "answer",
|
||||
isStreaming: !complete,
|
||||
...(complete && metadata
|
||||
? {
|
||||
metadata: {
|
||||
model: metadata.model,
|
||||
inTokens: metadata.in_token,
|
||||
outTokens: metadata.out_token,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
const newAnswer = phases.answer + chunk;
|
||||
const next: ChatMessage = {
|
||||
...prev,
|
||||
content: newAnswer,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
answer: newAnswer,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "answer" as const }),
|
||||
isStreaming: !complete,
|
||||
};
|
||||
const finalMetadata = complete ? metadataFrom(metadata) : undefined;
|
||||
const withMetadata = finalMetadata !== undefined
|
||||
? { ...next, metadata: finalMetadata }
|
||||
: next;
|
||||
return complete ? withoutActivePhase(withMetadata) : withMetadata;
|
||||
});
|
||||
if (complete) {
|
||||
attachExplainEvents();
|
||||
removeActivity(activityLabel);
|
||||
|
|
@ -259,11 +271,11 @@ export function useChat(): UseChatReturn {
|
|||
);
|
||||
|
||||
const regenerateLastMessage = useCallback(() => {
|
||||
const msgs = useConversation.getState().messages;
|
||||
const lastAssistant = [...msgs].reverse().find((m) => m.role === "assistant");
|
||||
const lastUser = [...msgs].reverse().find((m) => m.role === "user");
|
||||
if (lastAssistant && lastUser) {
|
||||
useConversation.getState().deleteMessage(lastAssistant.id);
|
||||
const msgs = useConversation.getState().messages;
|
||||
const lastAssistant = [...msgs].reverse().find((m) => m.role === "assistant");
|
||||
const lastUser = [...msgs].reverse().find((m) => m.role === "user");
|
||||
if (lastAssistant !== undefined && lastUser !== undefined) {
|
||||
useConversation.getState().deleteMessage(lastAssistant.id);
|
||||
submitMessage({ input: lastUser.content });
|
||||
}
|
||||
}, [submitMessage]);
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ export function nextMessageId(): string {
|
|||
export const useConversation = create<ConversationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
messages: [],
|
||||
messages: [] as ChatMessage[],
|
||||
input: "",
|
||||
chatMode: "graph-rag",
|
||||
chatMode: "graph-rag" as ChatMode,
|
||||
|
||||
setInput: (value) => set({ input: value }),
|
||||
setChatMode: (mode) => set({ chatMode: mode }),
|
||||
|
|
@ -107,7 +107,7 @@ export const useConversation = create<ConversationState>()(
|
|||
// Only persist messages and chatMode, not input or transient state
|
||||
partialize: (state) => {
|
||||
const MAX_PERSISTED_MESSAGES = 200;
|
||||
const filtered = state.messages.filter((m) => !m.isStreaming);
|
||||
const filtered = state.messages.filter((m) => m.isStreaming !== true);
|
||||
return {
|
||||
messages: filtered.slice(-MAX_PERSISTED_MESSAGES),
|
||||
chatMode: state.chatMode,
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function triplesToGraph(triples: Triple[]): {
|
|||
nodeMap.set(uri, {
|
||||
id: uri,
|
||||
label: labelMap.get(uri) ?? localName(uri),
|
||||
color: type ? hashColor(localName(type)) : "#5b80ff",
|
||||
color: type !== undefined ? hashColor(localName(type)) : "#5b80ff",
|
||||
degree: 0,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ function AppRoot() {
|
|||
return (
|
||||
<SocketProvider
|
||||
user={settings.user}
|
||||
apiKey={settings.apiKey || undefined}
|
||||
socketUrl={settings.gatewayUrl || undefined}
|
||||
{...(settings.apiKey.length > 0 ? { apiKey: settings.apiKey } : {})}
|
||||
{...(settings.gatewayUrl.length > 0 ? { socketUrl: settings.gatewayUrl } : {})}
|
||||
>
|
||||
<App />
|
||||
</SocketProvider>
|
||||
|
|
@ -38,7 +38,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||
// Dismiss splash screen once React has mounted
|
||||
requestAnimationFrame(() => {
|
||||
const splash = document.getElementById("splash");
|
||||
if (splash) {
|
||||
if (splash !== null) {
|
||||
splash.classList.add("fade-out");
|
||||
splash.addEventListener("transitionend", () => splash.remove());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function AgentPhaseBlock({
|
|||
}) {
|
||||
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
|
||||
|
||||
if (!content && !isActive) return null;
|
||||
if (content.length === 0 && !isActive) return null;
|
||||
|
||||
// Auto-expand while actively streaming; user can override
|
||||
const expanded = manualToggle ?? isActive;
|
||||
|
|
@ -104,12 +104,12 @@ function AgentPhaseBlock({
|
|||
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && (content || isActive) && (
|
||||
{expanded && (content.length > 0 || isActive) && (
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
|
||||
<p className="whitespace-pre-wrap">
|
||||
{content || (isActive ? "..." : "")}
|
||||
{content.length > 0 ? content : isActive ? "..." : ""}
|
||||
</p>
|
||||
{isActive && content && (
|
||||
{isActive && content.length > 0 && (
|
||||
<span className="mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -124,7 +124,7 @@ function AgentPhaseBlock({
|
|||
|
||||
function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: string }) {
|
||||
const isUser = msg.role === "user";
|
||||
const hasAgentPhases = msg.agentPhases != null;
|
||||
const agentPhases = msg.agentPhases;
|
||||
const isError = !isUser && msg.content.startsWith("Error:");
|
||||
|
||||
return (
|
||||
|
|
@ -139,23 +139,23 @@ function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: stri
|
|||
)}
|
||||
>
|
||||
{/* Agent phase blocks (only for agent messages) */}
|
||||
{hasAgentPhases && msg.agentPhases && (
|
||||
{agentPhases !== undefined && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={msg.agentPhases.think}
|
||||
content={agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={msg.agentPhases.observe}
|
||||
content={agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{msg.agentPhases.answer && (
|
||||
{agentPhases.answer.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span className="font-medium">Answer</span>
|
||||
|
|
@ -174,19 +174,19 @@ function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: stri
|
|||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content || (msg.isStreaming ? "" : "(empty)")}</Markdown>
|
||||
<Markdown>{msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{msg.isStreaming && (
|
||||
{msg.isStreaming === true && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
|
||||
{/* Token metadata */}
|
||||
{msg.metadata && (
|
||||
{msg.metadata !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.inTokens != null && (
|
||||
<span>in: {msg.metadata.inTokens}</span>
|
||||
)}
|
||||
|
|
@ -197,7 +197,7 @@ function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: stri
|
|||
)}
|
||||
|
||||
{/* Explainability graph */}
|
||||
{!isUser && !isError && !msg.isStreaming && msg.explainEvents && msg.explainEvents.length > 0 && (
|
||||
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -239,7 +239,7 @@ export default function ChatPage() {
|
|||
}, [messages]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (input.trim()) {
|
||||
if (input.trim().length > 0) {
|
||||
submitMessage({ input });
|
||||
}
|
||||
}, [input, submitMessage]);
|
||||
|
|
@ -317,12 +317,12 @@ export default function ChatPage() {
|
|||
|
||||
return (
|
||||
<div key={msg.id} className="group relative">
|
||||
{!msg.isStreaming && (
|
||||
{msg.isStreaming !== true && (
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
onRegenerate={isLastAssistant ? regenerateLastMessage : undefined}
|
||||
{...(isLastAssistant ? { onRegenerate: regenerateLastMessage } : {})}
|
||||
/>
|
||||
)}
|
||||
<MessageBubble msg={msg} collection={collection} />
|
||||
|
|
@ -359,7 +359,7 @@ export default function ChatPage() {
|
|||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isLoading}
|
||||
disabled={input.trim().length === 0 || isLoading}
|
||||
aria-label="Send message"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-600 text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Workflow,
|
||||
Plus,
|
||||
|
|
@ -59,7 +59,7 @@ function StartFlowDialog({
|
|||
.then((names) => {
|
||||
const list = names ?? [];
|
||||
setBlueprints(list);
|
||||
if (list.length > 0 && !blueprint) {
|
||||
if (list.length > 0 && blueprint.length === 0) {
|
||||
setBlueprint(list[0]!);
|
||||
}
|
||||
})
|
||||
|
|
@ -70,7 +70,7 @@ function StartFlowDialog({
|
|||
|
||||
// Fetch blueprint definition when selection changes
|
||||
useEffect(() => {
|
||||
if (!blueprint) {
|
||||
if (blueprint.length === 0) {
|
||||
setBlueprintDef(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -86,11 +86,11 @@ function StartFlowDialog({
|
|||
// Pre-populate parameters with defaults from the definition
|
||||
const paramsDef =
|
||||
def?.parameters ?? def?.params ?? def?.["parameters"] ?? def?.["params"];
|
||||
if (paramsDef && typeof paramsDef === "object") {
|
||||
if (paramsDef !== undefined && paramsDef !== null && typeof paramsDef === "object") {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
const params = paramsDef as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (val && typeof val === "object" && "default" in (val as Record<string, unknown>)) {
|
||||
if (val !== null && typeof val === "object" && "default" in (val as Record<string, unknown>)) {
|
||||
defaults[key] = (val as Record<string, unknown>).default;
|
||||
}
|
||||
}
|
||||
|
|
@ -100,10 +100,10 @@ function StartFlowDialog({
|
|||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBlueprintDef(null);
|
||||
if (cancelled === false) setBlueprintDef(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingDef(false);
|
||||
if (cancelled === false) setLoadingDef(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
|
@ -195,7 +195,7 @@ function StartFlowDialog({
|
|||
placeholder="my-flow-id"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{submitted && !id.trim() && (
|
||||
{submitted && id.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -226,7 +226,7 @@ function StartFlowDialog({
|
|||
))}
|
||||
</select>
|
||||
)}
|
||||
{submitted && !blueprint && (
|
||||
{submitted && blueprint.length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
|
||||
)}
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ function StartFlowDialog({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{blueprintDef && !loadingDef && (
|
||||
{blueprintDef !== null && !loadingDef && (
|
||||
<div className="mt-2 rounded-lg border border-border bg-surface-50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<Info className="h-3.5 w-3.5 text-brand-400" />
|
||||
|
|
@ -245,7 +245,7 @@ function StartFlowDialog({
|
|||
</div>
|
||||
|
||||
{/* Description from definition */}
|
||||
{!!(blueprintDef.description || blueprintDef.desc) && (
|
||||
{(blueprintDef.description !== undefined || blueprintDef.desc !== undefined) && (
|
||||
<p className="mt-1.5 text-xs text-fg-muted">
|
||||
{String(blueprintDef.description ?? blueprintDef.desc)}
|
||||
</p>
|
||||
|
|
@ -258,7 +258,9 @@ function StartFlowDialog({
|
|||
blueprintDef.params ??
|
||||
blueprintDef["parameters"] ??
|
||||
blueprintDef["params"];
|
||||
if (!paramsDef || typeof paramsDef !== "object") return null;
|
||||
if (paramsDef === undefined || paramsDef === null || typeof paramsDef !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entries = Object.entries(paramsDef as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
|
|
@ -267,16 +269,16 @@ function StartFlowDialog({
|
|||
<div className="mt-1 space-y-1">
|
||||
{entries.map(([name, schema]) => {
|
||||
const s = schema as Record<string, unknown> | null;
|
||||
const type = s?.type ? String(s.type) : undefined;
|
||||
const defaultVal = s && "default" in s ? s.default : undefined;
|
||||
const desc = s?.description ? String(s.description) : undefined;
|
||||
const type = s?.type !== undefined ? String(s.type) : undefined;
|
||||
const defaultVal = s !== null && "default" in s ? s.default : undefined;
|
||||
const desc = s?.description !== undefined ? String(s.description) : undefined;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex flex-wrap items-baseline gap-x-2 text-xs"
|
||||
>
|
||||
<span className="font-mono font-medium text-fg">{name}</span>
|
||||
{type && (
|
||||
{type !== undefined && (
|
||||
<span className="rounded bg-surface-200 px-1 py-0.5 text-[10px] text-fg-subtle">
|
||||
{type}
|
||||
</span>
|
||||
|
|
@ -286,7 +288,7 @@ function StartFlowDialog({
|
|||
default: <span className="font-mono">{JSON.stringify(defaultVal)}</span>
|
||||
</span>
|
||||
)}
|
||||
{desc && <span className="text-fg-subtle">- {desc}</span>}
|
||||
{desc !== undefined && <span className="text-fg-subtle">- {desc}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -330,7 +332,7 @@ function StartFlowDialog({
|
|||
placeholder="Human-readable description"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{submitted && !description.trim() && (
|
||||
{submitted && description.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Description is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -350,12 +352,12 @@ function StartFlowDialog({
|
|||
rows={4}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-lg border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus:ring-1",
|
||||
paramsError
|
||||
paramsError !== null
|
||||
? "border-error focus:border-error focus:ring-error"
|
||||
: "border-border focus:border-brand-500 focus:ring-brand-500",
|
||||
)}
|
||||
/>
|
||||
{paramsError && (
|
||||
{paramsError !== null && (
|
||||
<p className="text-xs text-error">{paramsError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -462,7 +464,7 @@ function FlowRow({
|
|||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-fg-muted">
|
||||
{flow.description || "--"}
|
||||
{(flow.description ?? "").length > 0 ? flow.description : "--"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="success">Running</Badge>
|
||||
|
|
@ -550,7 +552,7 @@ export default function FlowsPage() {
|
|||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!stopTarget) return;
|
||||
if (stopTarget === null || stopTarget.length === 0) return;
|
||||
try {
|
||||
await stopFlow(stopTarget);
|
||||
notify.success("Flow stopped", `Flow "${stopTarget}" has been stopped.`);
|
||||
|
|
@ -602,13 +604,13 @@ export default function FlowsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{error !== null && (
|
||||
<p role="alert" className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && flows.length === 0 && (
|
||||
{!loading && error === null && flows.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<Workflow className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No flows configured.</p>
|
||||
|
|
@ -650,7 +652,7 @@ export default function FlowsPage() {
|
|||
/>
|
||||
|
||||
<StopFlowDialog
|
||||
open={stopTarget != null}
|
||||
open={stopTarget !== null}
|
||||
flowId={stopTarget ?? ""}
|
||||
onClose={() => setStopTarget(null)}
|
||||
onConfirm={handleStop}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ import {
|
|||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
|
|
@ -193,7 +191,10 @@ export default function GraphPage() {
|
|||
const [objectFilter, setObjectFilter] = useState("");
|
||||
const [tripleLimit, setTripleLimit] = useState(2000);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const hasActiveFilters = subjectFilter || predicateFilter || objectFilter;
|
||||
const hasActiveFilters =
|
||||
subjectFilter.length > 0 ||
|
||||
predicateFilter.length > 0 ||
|
||||
objectFilter.length > 0;
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
|
|
@ -210,14 +211,14 @@ export default function GraphPage() {
|
|||
// Ref callback — attaches ResizeObserver when the container mounts
|
||||
const containerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
// Disconnect previous observer
|
||||
if (roRef.current) {
|
||||
if (roRef.current !== null) {
|
||||
roRef.current.disconnect();
|
||||
roRef.current = null;
|
||||
}
|
||||
if (!el) return;
|
||||
if (el === null) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
if (entry !== undefined) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setContainerSize({ width: Math.floor(width), height: Math.floor(height) });
|
||||
}
|
||||
|
|
@ -236,9 +237,9 @@ export default function GraphPage() {
|
|||
hasAutoFit.current = false;
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
const s: Term | undefined = subjectFilter ? { t: "i", i: subjectFilter } : undefined;
|
||||
const p: Term | undefined = predicateFilter ? { t: "i", i: predicateFilter } : undefined;
|
||||
const o: Term | undefined = objectFilter ? { t: "i", i: objectFilter } : undefined;
|
||||
const s: Term | undefined = subjectFilter.length > 0 ? { t: "i", i: subjectFilter } : undefined;
|
||||
const p: Term | undefined = predicateFilter.length > 0 ? { t: "i", i: predicateFilter } : undefined;
|
||||
const o: Term | undefined = objectFilter.length > 0 ? { t: "i", i: objectFilter } : undefined;
|
||||
|
||||
const result = await flow.triplesQuery(
|
||||
s,
|
||||
|
|
@ -281,7 +282,7 @@ export default function GraphPage() {
|
|||
// Search filter -- highlight matching nodes
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchingIds = useMemo(() => {
|
||||
if (!searchLower) return new Set<string>();
|
||||
if (searchLower.length === 0) return new Set<string>();
|
||||
return new Set(
|
||||
graphData.nodes
|
||||
.filter(
|
||||
|
|
@ -293,13 +294,17 @@ export default function GraphPage() {
|
|||
);
|
||||
}, [graphData.nodes, searchLower]);
|
||||
|
||||
const selectedLabel = selectedNode
|
||||
const selectedLabel = selectedNode !== null
|
||||
? labelMap.get(selectedNode) ?? localName(selectedNode)
|
||||
: "";
|
||||
|
||||
// Auto-fit graph to view once data loads
|
||||
useEffect(() => {
|
||||
if (graphData.nodes.length > 0 && fgRef.current && !hasAutoFit.current) {
|
||||
if (
|
||||
graphData.nodes.length > 0 &&
|
||||
fgRef.current !== undefined &&
|
||||
hasAutoFit.current === false
|
||||
) {
|
||||
hasAutoFit.current = true;
|
||||
// Wait for force simulation to settle briefly before fitting
|
||||
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 40), 500);
|
||||
|
|
@ -387,7 +392,14 @@ export default function GraphPage() {
|
|||
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (!src.x || !tgt.x) return;
|
||||
if (
|
||||
src.x === undefined ||
|
||||
src.y === undefined ||
|
||||
tgt.x === undefined ||
|
||||
tgt.y === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
|
@ -427,7 +439,7 @@ export default function GraphPage() {
|
|||
aria-label="Search nodes"
|
||||
className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{searchTerm && (
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
|
|
@ -610,7 +622,7 @@ export default function GraphPage() {
|
|||
)}
|
||||
|
||||
{/* Content */}
|
||||
{error && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
@ -672,13 +684,14 @@ export default function GraphPage() {
|
|||
backgroundColor="transparent"
|
||||
cooldownTicks={100}
|
||||
warmupTicks={30}
|
||||
width={containerSize?.width}
|
||||
height={containerSize?.height}
|
||||
{...(containerSize !== null
|
||||
? { width: containerSize.width, height: containerSize.height }
|
||||
: {})}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* Search results badge overlay */}
|
||||
{searchTerm && matchingIds.size > 0 && (
|
||||
{searchTerm.length > 0 && matchingIds.size > 0 && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<Badge variant="success">
|
||||
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
|
||||
|
|
@ -708,7 +721,7 @@ export default function GraphPage() {
|
|||
)}
|
||||
|
||||
{/* Detail panel -- positioned absolutely so it overlays the graph */}
|
||||
{selectedNode && (
|
||||
{selectedNode !== null && (
|
||||
<div className="absolute inset-y-0 right-0 z-10">
|
||||
<NodeDetailPanel
|
||||
nodeId={selectedNode}
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export default function KnowledgeCoresPage() {
|
|||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (deleteTarget === null || deleteTarget.length === 0) return;
|
||||
setActionInProgress(deleteTarget);
|
||||
try {
|
||||
await socket.knowledge().deleteKgCore(deleteTarget);
|
||||
|
|
@ -179,13 +179,13 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{error !== null && error.length > 0 && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && cores.length === 0 && (
|
||||
{!loading && error === null && cores.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<BrainCircuit className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No knowledge cores available.</p>
|
||||
|
|
|
|||
|
|
@ -79,26 +79,27 @@ function UploadDialog({
|
|||
|
||||
const handleFile = useCallback((f: File) => {
|
||||
setFile(f);
|
||||
if (!titleRef.current) setTitle(f.name.replace(/\.[^/.]+$/, ""));
|
||||
if (titleRef.current.length === 0) setTitle(f.name.replace(/\.[^/.]+$/, ""));
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) handleFile(f);
|
||||
if (f !== undefined) handleFile(f);
|
||||
}, [handleFile]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) return;
|
||||
if (file === null) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const tagList = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const mimeType = file.type || "application/octet-stream";
|
||||
.filter((tag) => tag.length > 0);
|
||||
const mimeType =
|
||||
file.type.length > 0 ? file.type : "application/octet-stream";
|
||||
|
||||
if (base64.length > CHUNKED_UPLOAD_THRESHOLD) {
|
||||
await onUploadChunked(base64, mimeType, title, comments, tagList, setProgress);
|
||||
|
|
@ -115,6 +116,7 @@ function UploadDialog({
|
|||
};
|
||||
|
||||
const progressPercent = progress
|
||||
!== null
|
||||
? Math.round((progress.chunksUploaded / Math.max(progress.chunksTotal, 1)) * 100)
|
||||
: 0;
|
||||
|
||||
|
|
@ -142,7 +144,7 @@ function UploadDialog({
|
|||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!file || !title.trim() || uploading}
|
||||
disabled={file === null || title.trim().length === 0 || uploading}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{uploading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
|
|
@ -177,7 +179,7 @@ function UploadDialog({
|
|||
)}
|
||||
>
|
||||
<Upload className="mb-2 h-8 w-8 text-fg-subtle" />
|
||||
{file ? (
|
||||
{file !== null ? (
|
||||
<div className="flex items-center gap-2 text-sm text-fg">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>{file.name}</span>
|
||||
|
|
@ -207,15 +209,15 @@ function UploadDialog({
|
|||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.txt,.md,.csv,.json,.xml,.html"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f !== undefined) handleFile(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload progress bar */}
|
||||
{uploading && progress && (
|
||||
{uploading && progress !== null && (
|
||||
<div className="mb-4 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-fg-muted">
|
||||
<span>
|
||||
|
|
@ -294,11 +296,11 @@ function DocumentDetailDialog({
|
|||
loading?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!doc) return null;
|
||||
if (doc === null) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title="Document Details" className="max-w-xl">
|
||||
{loadingMeta && (
|
||||
{loadingMeta === true && (
|
||||
<div className="mb-3 flex items-center gap-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading full metadata...
|
||||
|
|
@ -308,7 +310,9 @@ function DocumentDetailDialog({
|
|||
{/* Title */}
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Title</h3>
|
||||
<p className="text-sm text-fg">{doc.title || "Untitled"}</p>
|
||||
<p className="text-sm text-fg">
|
||||
{(doc.title ?? "").length > 0 ? doc.title : "Untitled"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ID */}
|
||||
|
|
@ -326,7 +330,7 @@ function DocumentDetailDialog({
|
|||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
{doc.comments && (
|
||||
{(doc.comments ?? "").length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Comments</h3>
|
||||
<p className="text-sm text-fg-muted">{doc.comments}</p>
|
||||
|
|
@ -334,13 +338,13 @@ function DocumentDetailDialog({
|
|||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{doc.tags && doc.tags.length > 0 && (
|
||||
{(doc.tags ?? []).length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-subtle">
|
||||
<Tag className="h-3 w-3" /> Tags
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{doc.tags.map((tag) => (
|
||||
{(doc.tags ?? []).map((tag) => (
|
||||
<Badge key={tag} variant="info">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -360,7 +364,7 @@ function DocumentDetailDialog({
|
|||
)}
|
||||
|
||||
{/* User */}
|
||||
{doc.user && (
|
||||
{(doc.user ?? "").length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">Uploaded by</h3>
|
||||
<p className="text-sm text-fg-muted">{doc.user}</p>
|
||||
|
|
@ -368,7 +372,7 @@ function DocumentDetailDialog({
|
|||
)}
|
||||
|
||||
{/* Raw metadata (if any RDF triples) */}
|
||||
{doc.metadata && doc.metadata.length > 0 && (
|
||||
{doc.metadata !== undefined && doc.metadata.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Metadata ({doc.metadata.length} triples)
|
||||
|
|
@ -424,7 +428,9 @@ function ConfirmDeleteDialog({
|
|||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-medium text-fg">{docTitle || "this document"}</span>?
|
||||
<span className="font-medium text-fg">
|
||||
{docTitle.length > 0 ? docTitle : "this document"}
|
||||
</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -500,12 +506,14 @@ export default function LibraryPage() {
|
|||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget?.id) {
|
||||
const target = deleteTarget;
|
||||
const targetId = target?.id ?? "";
|
||||
if (target === null || targetId.length === 0) {
|
||||
setDeleteTarget(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await removeDocument(deleteTarget.id, collection);
|
||||
await removeDocument(targetId, collection);
|
||||
notify.success("Document deleted");
|
||||
} catch {
|
||||
notify.error("Delete failed");
|
||||
|
|
@ -517,10 +525,11 @@ export default function LibraryPage() {
|
|||
async (doc: DocumentMetadata) => {
|
||||
setDetailDoc(doc);
|
||||
setDetailOpen(true);
|
||||
if (doc.id) {
|
||||
const id = doc.id ?? "";
|
||||
if (id.length > 0) {
|
||||
setLoadingDetail(true);
|
||||
const fullMeta = await getDocumentMetadata(doc.id);
|
||||
if (fullMeta) setDetailDoc(fullMeta);
|
||||
const fullMeta = await getDocumentMetadata(id);
|
||||
if (fullMeta !== null) setDetailDoc(fullMeta);
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
},
|
||||
|
|
@ -538,13 +547,13 @@ export default function LibraryPage() {
|
|||
if (kind.includes("text") || kind.includes("plain")) return "Text";
|
||||
if (kind.includes("html")) return "HTML";
|
||||
if (kind.includes("json")) return "JSON";
|
||||
return kind || "--";
|
||||
return kind.length > 0 ? kind : "--";
|
||||
};
|
||||
|
||||
// Search/filter
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const filteredDocuments = useMemo(() => {
|
||||
if (!searchLower) return documents;
|
||||
if (searchLower.length === 0) return documents;
|
||||
return documents.filter((doc) => {
|
||||
const title = (doc.title ?? "").toLowerCase();
|
||||
const id = (doc.id ?? "").toLowerCase();
|
||||
|
|
@ -603,7 +612,7 @@ export default function LibraryPage() {
|
|||
aria-label="Search documents"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 py-2 pl-9 pr-9 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{searchTerm && (
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
|
|
@ -626,9 +635,11 @@ export default function LibraryPage() {
|
|||
{processing.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<FileType2 className="h-3 w-3" />
|
||||
<span className="truncate">{p["document-id"] || p.id}</span>
|
||||
<span className="truncate">
|
||||
{(p["document-id"] ?? "").length > 0 ? p["document-id"] : p.id}
|
||||
</span>
|
||||
<Badge variant="info" className="ml-auto">
|
||||
{p.flow || "processing"}
|
||||
{p.flow.length > 0 ? p.flow : "processing"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -644,11 +655,11 @@ export default function LibraryPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{error !== null && (
|
||||
<p className="py-8 text-center text-error">Error: {error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && documents.length === 0 && (
|
||||
{!loading && error === null && documents.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<LibraryBig className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">
|
||||
|
|
@ -658,7 +669,7 @@ export default function LibraryPage() {
|
|||
)}
|
||||
|
||||
{/* Search results info */}
|
||||
{searchTerm && documents.length > 0 && (
|
||||
{searchTerm.length > 0 && documents.length > 0 && (
|
||||
<p className="mb-2 text-xs text-fg-subtle">
|
||||
{filteredDocuments.length} of {documents.length} documents match
|
||||
</p>
|
||||
|
|
@ -677,12 +688,12 @@ export default function LibraryPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredDocuments.map((doc) => (
|
||||
<tr key={doc.id} className="hover:bg-surface-100/50">
|
||||
{filteredDocuments.map((doc, index) => (
|
||||
<tr key={doc.id ?? `${doc.title ?? "document"}-${index}`} className="hover:bg-surface-100/50">
|
||||
<td className="px-4 py-3 text-fg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-fg-subtle" />
|
||||
{doc.title || "Untitled"}
|
||||
{(doc.title ?? "").length > 0 ? doc.title : "Untitled"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
|
@ -693,7 +704,7 @@ export default function LibraryPage() {
|
|||
{(doc.tags ?? []).map((tag) => (
|
||||
<Badge key={tag} variant="info">{tag}</Badge>
|
||||
))}
|
||||
{(!doc.tags || doc.tags.length === 0) && (
|
||||
{(doc.tags ?? []).length === 0 && (
|
||||
<span className="text-fg-subtle">--</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -729,7 +740,7 @@ export default function LibraryPage() {
|
|||
)}
|
||||
|
||||
{/* Empty search results */}
|
||||
{searchTerm && filteredDocuments.length === 0 && documents.length > 0 && (
|
||||
{searchTerm.length > 0 && filteredDocuments.length === 0 && documents.length > 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-12">
|
||||
<Search className="mb-3 h-8 w-8 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No documents match "{searchTerm}"</p>
|
||||
|
|
@ -746,7 +757,7 @@ export default function LibraryPage() {
|
|||
/>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteTarget != null}
|
||||
open={deleteTarget !== null}
|
||||
docTitle={deleteTarget?.title ?? deleteTarget?.id ?? ""}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Plug,
|
||||
Server,
|
||||
|
|
@ -55,7 +55,7 @@ function McpServerDialog({
|
|||
initial?: McpServerEntry;
|
||||
existingKeys: string[];
|
||||
}) {
|
||||
const isEditing = initial != null;
|
||||
const isEditing = initial !== undefined;
|
||||
const [key, setKey] = useState(initial?.key ?? "");
|
||||
const [url, setUrl] = useState(initial?.config.url ?? "");
|
||||
const [remoteName, setRemoteName] = useState(
|
||||
|
|
@ -81,14 +81,14 @@ function McpServerDialog({
|
|||
}, [open, initial]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!key.trim() || !url.trim()) return;
|
||||
if (key.trim().length === 0 || url.trim().length === 0) return;
|
||||
if (!isEditing && existingKeys.includes(key.trim())) {
|
||||
setKeyError("A server with this key already exists");
|
||||
return;
|
||||
}
|
||||
const config: McpServerConfig = { url: url.trim() };
|
||||
if (remoteName.trim()) config["remote-name"] = remoteName.trim();
|
||||
if (authToken.trim()) config["auth-token"] = authToken.trim();
|
||||
if (remoteName.trim().length > 0) config["remote-name"] = remoteName.trim();
|
||||
if (authToken.trim().length > 0) config["auth-token"] = authToken.trim();
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(key.trim(), config);
|
||||
|
|
@ -113,7 +113,7 @@ function McpServerDialog({
|
|||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!key.trim() || !url.trim() || saving}
|
||||
disabled={key.trim().length === 0 || url.trim().length === 0 || saving}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
|
|
@ -139,7 +139,7 @@ function McpServerDialog({
|
|||
placeholder="brave-search"
|
||||
className={cn(INPUT_CLASS, isEditing && "opacity-60")}
|
||||
/>
|
||||
{keyError && (
|
||||
{keyError.length > 0 && (
|
||||
<p className="mt-1 text-xs text-error">{keyError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -220,7 +220,7 @@ function McpToolDialog({
|
|||
existingKeys: string[];
|
||||
serverKeys: string[];
|
||||
}) {
|
||||
const isEditing = initial != null;
|
||||
const isEditing = initial !== undefined;
|
||||
const [key, setKey] = useState(initial?.key ?? "");
|
||||
const [name, setName] = useState(initial?.config.name ?? "");
|
||||
const [description, setDescription] = useState(
|
||||
|
|
@ -259,7 +259,11 @@ function McpToolDialog({
|
|||
setArgs((prev) => prev.filter((_, j) => j !== i));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!key.trim() || !name.trim() || !mcpTool.trim()) return;
|
||||
if (
|
||||
key.trim().length === 0 ||
|
||||
name.trim().length === 0 ||
|
||||
mcpTool.trim().length === 0
|
||||
) return;
|
||||
if (!isEditing && existingKeys.includes(key.trim())) {
|
||||
setKeyError("A tool with this key already exists");
|
||||
return;
|
||||
|
|
@ -272,8 +276,8 @@ function McpToolDialog({
|
|||
group: group
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean),
|
||||
arguments: args.filter((a) => a.name.trim()),
|
||||
.filter((g) => g.length > 0),
|
||||
arguments: args.filter((a) => a.name.trim().length > 0),
|
||||
};
|
||||
setSaving(true);
|
||||
try {
|
||||
|
|
@ -300,7 +304,12 @@ function McpToolDialog({
|
|||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!key.trim() || !name.trim() || !mcpTool.trim() || saving}
|
||||
disabled={
|
||||
key.trim().length === 0 ||
|
||||
name.trim().length === 0 ||
|
||||
mcpTool.trim().length === 0 ||
|
||||
saving
|
||||
}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
|
|
@ -327,7 +336,7 @@ function McpToolDialog({
|
|||
placeholder="brave-search"
|
||||
className={cn(INPUT_CLASS, isEditing && "opacity-60")}
|
||||
/>
|
||||
{keyError && (
|
||||
{keyError.length > 0 && (
|
||||
<p className="mt-1 text-xs text-error">{keyError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -740,7 +749,11 @@ export default function McpToolsPage() {
|
|||
referencingTools.length > 0
|
||||
? `The following tools reference this server and will stop working: ${referencingTools.join(", ")}`
|
||||
: undefined;
|
||||
setDeleteTarget({ type: "server", key, warning });
|
||||
setDeleteTarget({
|
||||
type: "server",
|
||||
key,
|
||||
...(warning !== undefined ? { warning } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const openAddTool = () => {
|
||||
|
|
@ -774,7 +787,7 @@ export default function McpToolsPage() {
|
|||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (deleteTarget === null) return;
|
||||
try {
|
||||
if (deleteTarget.type === "server") {
|
||||
await deleteServer(deleteTarget.key);
|
||||
|
|
@ -901,7 +914,7 @@ export default function McpToolsPage() {
|
|||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
{error !== null && error.length > 0 && (
|
||||
<p role="alert" className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
@ -988,24 +1001,24 @@ export default function McpToolsPage() {
|
|||
open={serverDialogOpen}
|
||||
onClose={() => setServerDialogOpen(false)}
|
||||
onSave={handleSaveServer}
|
||||
initial={editingServer}
|
||||
existingKeys={serverKeys}
|
||||
{...(editingServer !== undefined ? { initial: editingServer } : {})}
|
||||
/>
|
||||
<McpToolDialog
|
||||
open={toolDialogOpen}
|
||||
onClose={() => setToolDialogOpen(false)}
|
||||
onSave={handleSaveTool}
|
||||
initial={editingTool}
|
||||
existingKeys={toolKeys}
|
||||
serverKeys={serverKeys}
|
||||
{...(editingTool !== undefined ? { initial: editingTool } : {})}
|
||||
/>
|
||||
<DeleteConfirmDialog
|
||||
open={deleteTarget != null}
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
entityType={deleteTarget?.type === "server" ? "MCP Server" : "Tool"}
|
||||
entityKey={deleteTarget?.key ?? ""}
|
||||
warning={deleteTarget?.warning}
|
||||
{...(deleteTarget?.warning !== undefined ? { warning: deleteTarget.warning } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
{error !== null && error.length > 0 && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
@ -157,7 +157,7 @@ export default function PromptsPage() {
|
|||
|
||||
{/* Prompt detail */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
{selectedPromptId ? (
|
||||
{selectedPromptId !== null && selectedPromptId.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-border bg-surface-100 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-fg">
|
||||
|
|
@ -179,9 +179,9 @@ export default function PromptsPage() {
|
|||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : promptDetail && typeof promptDetail === "object" ? (
|
||||
) : promptDetail !== null && typeof promptDetail === "object" ? (
|
||||
<div className="space-y-4">
|
||||
{promptDetail.system && (
|
||||
{promptDetail.system !== undefined && promptDetail.system.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg-subtle">
|
||||
System
|
||||
|
|
@ -191,7 +191,7 @@ export default function PromptsPage() {
|
|||
</pre>
|
||||
</section>
|
||||
)}
|
||||
{promptDetail.prompt && (
|
||||
{promptDetail.prompt !== undefined && promptDetail.prompt.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg-subtle">
|
||||
Prompt
|
||||
|
|
@ -234,7 +234,7 @@ export default function PromptsPage() {
|
|||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : systemPrompt ? (
|
||||
) : systemPrompt.length > 0 ? (
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-fg-muted">
|
||||
{systemPrompt}
|
||||
</pre>
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export default function SettingsPage() {
|
|||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
const saved = localStorage.getItem("tg-theme");
|
||||
if (saved) return saved === "dark";
|
||||
if (saved !== null) return saved === "dark";
|
||||
return !document.documentElement.classList.contains("light");
|
||||
});
|
||||
|
||||
|
|
@ -164,21 +164,21 @@ export default function SettingsPage() {
|
|||
// Create a new collection
|
||||
const handleCreateCollection = useCallback(async () => {
|
||||
const trimmedId = newId.trim();
|
||||
if (!trimmedId) return;
|
||||
if (trimmedId.length === 0) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const tags = newTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
.filter((tag) => tag.length > 0);
|
||||
|
||||
await socket
|
||||
.collectionManagement()
|
||||
.updateCollection(
|
||||
trimmedId,
|
||||
newName.trim() || undefined,
|
||||
newDescription.trim() || undefined,
|
||||
newName.trim().length > 0 ? newName.trim() : undefined,
|
||||
newDescription.trim().length > 0 ? newDescription.trim() : undefined,
|
||||
tags.length > 0 ? tags : undefined,
|
||||
);
|
||||
|
||||
|
|
@ -205,7 +205,7 @@ export default function SettingsPage() {
|
|||
// Delete the current collection
|
||||
const handleDeleteCollection = useCallback(async () => {
|
||||
const currentId = settings.collection;
|
||||
if (!currentId) return;
|
||||
if (currentId.length === 0) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
|
|
@ -432,7 +432,7 @@ export default function SettingsPage() {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!newId.trim() || creating}
|
||||
disabled={newId.trim().length === 0 || creating}
|
||||
onClick={handleCreateCollection}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
|
@ -561,7 +561,7 @@ export default function SettingsPage() {
|
|||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.id}
|
||||
{f.description ? ` -- ${f.description}` : ""}
|
||||
{f.description !== undefined && f.description.length > 0 ? ` -- ${f.description}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -621,28 +621,31 @@ export default function SettingsPage() {
|
|||
title="Feature Switches"
|
||||
icon={<SettingsIcon className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
{Object.entries(settings.featureSwitches).map(([key, enabled]) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-fg">{featureLabel(key)}</p>
|
||||
{Object.entries(settings.featureSwitches).map(([key, enabled]) => {
|
||||
const isEnabled = enabled === true;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-fg">{featureLabel(key)}</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
aria-label={featureLabel(key)}
|
||||
onClick={() => updateFeatureSwitches({ [key]: !isEnabled })}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isEnabled ? "bg-brand-600" : "bg-fg-subtle",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isEnabled ? "translate-x-6" : "translate-x-1",
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={featureLabel(key)}
|
||||
onClick={() => updateFeatureSwitches({ [key]: !enabled })}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
enabled ? "bg-brand-600" : "bg-fg-subtle",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
enabled ? "translate-x-6" : "translate-x-1",
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
|
||||
{/* About */}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export default function TokenCostPage() {
|
|||
}, [connectionState.status, loadCosts]);
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
if (price == null) return "--";
|
||||
if (!Number.isFinite(price)) return "--";
|
||||
return `$${price.toFixed(2)}`;
|
||||
};
|
||||
|
||||
|
|
@ -96,13 +96,13 @@ export default function TokenCostPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{error !== null && error.length > 0 && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && costs.length === 0 && (
|
||||
{!loading && error === null && costs.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<Coins className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No token cost data available.</p>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,12 @@ export const useNotification = create<NotificationState>()((set, get) => {
|
|||
description,
|
||||
) => {
|
||||
const id = nextId();
|
||||
const notification: Notification = { id, type, title, description };
|
||||
const notification: Notification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
notifications: [...state.notifications, notification],
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const useSettings = create<SettingsState>()(
|
|||
persist(
|
||||
(set) => ({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
isLoaded: true,
|
||||
isLoaded: true as boolean,
|
||||
|
||||
setSettings: (settings) => set({ settings }),
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ export const useSettings = create<SettingsState>()(
|
|||
name: "trustgraph-settings",
|
||||
// Mark loaded once rehydration completes
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) state.isLoaded = true;
|
||||
if (state !== undefined) state.isLoaded = true;
|
||||
},
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export function SocketProvider({
|
|||
}, [user, apiKey, socketUrl]);
|
||||
|
||||
// Don't render children until the first API instance is ready
|
||||
if (!apiRef.current) return null;
|
||||
if (apiRef.current === null) return null;
|
||||
|
||||
return (
|
||||
<SocketContext.Provider
|
||||
|
|
@ -93,7 +93,7 @@ export function SocketProvider({
|
|||
*/
|
||||
export function useSocket(): BaseApi {
|
||||
const ctx = useContext(SocketContext);
|
||||
if (!ctx) {
|
||||
if (ctx === null) {
|
||||
throw new Error("useSocket must be used within a <SocketProvider>");
|
||||
}
|
||||
return ctx.api;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue