gadget/gadget-code/frontend/src/components/ChatTurn.tsx
Rob Colbert 56f35a2fdf feat: Qdrant semantic search over chat history
Adds vector-based semantic search across all chat sessions using Qdrant.
When a ChatTurn finishes, its content is chunked, embedded, and upserted
to a Qdrant collection. A search API and UI components enable searching
at user, project, and session scope.

Phase 1 — Configuration & Dependencies
- Add port/apiKey to GadgetCodeConfig.qdrant type
- Uncomment and update qdrant section in YAML config example
- Add qdrant config passthrough in env.ts
- Add @qdrant/js-client-rest dependency

Phase 2 — AI Embedding API (@gadget/ai)
- Add IAiEmbeddingResponse interface and abstract embeddings() to AiApi
- Implement embeddings() in OllamaAiApi (client.embeddings)
- Implement embeddings() in OpenAiApi (client.embeddings.create)
- Export IAiEmbeddingResponse from package index

Phase 3 — Backend Vector Store Service
- Create VectorStoreService (ingestTurn, search, removeTurnPoints)
- Hook fire-and-forget ingest after turn.save() in drone-session
- Register VectorStoreService in service startup/shutdown

Phase 4 — Backend Search API
- Create POST /api/v1/search controller with userId enforcement
- Batch-hydrate results from MongoDB (user, project, session, turn)
- Register search route in v1 API router

Phase 5 — Frontend Search Components
- SearchInput: debounced input with lucide-react icons
- ChatSearchResults: modal with score badges, metadata, loading states
- DroneSelectionModal: drone picker for sessions without a drone
- Add searchApi and ISearchResult to API client
- Add search to Home (global), ProjectManager (project), ChatSessionView (session)
- Add id=turn-{turnId} to ChatTurn for scroll targeting
- Scroll-to-turn from search result selection and router state
- Show DroneSelectionModal when no drone available
- Add Select Drone button in ChatSessionView sidebar
2026-05-19 14:28:30 -04:00

287 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, useState, useCallback } from "react";
import { marked } from "marked";
import type { ChatTurn as ChatTurnType, ChatTurnBlockTool } from "../lib/api";
import ToolCallDisplay from "./ToolCallDisplay";
interface ChatTurnProps {
turn: ChatTurnType;
}
// Configure marked with breaks enabled
marked.use({
breaks: true,
});
const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
const seconds = (ms / 1000).toFixed(1);
return `${seconds}s`;
};
const formatTokenCount = (count: number): string => {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`;
}
return count.toString();
};
const modelId = turn.llm || "Unknown";
const modeLabel =
turn.mode?.charAt(0).toUpperCase() + turn.mode?.slice(1) || "Unknown";
const statusLabel =
turn.status?.charAt(0).toUpperCase() + turn.status?.slice(1) || "Unknown";
const startedAt = new Date(turn.createdAt);
return (
<div id={`turn-${turn._id}`} className="border-b border-border-subtle pb-4 last:border-b-0">
{/* Turn Header */}
<div className="flex items-center gap-4 text-xs text-text-muted mb-3 px-4">
<div>{startedAt.toLocaleTimeString()}</div>
<div className="flex items-center gap-2">
<span className="font-mono">{modelId}</span>
</div>
<div className="w-px h-3 bg-border-subtle" />
<div>{modeLabel}</div>
<div className="w-px h-3 bg-border-subtle" />
<div
className={
turn.status === "finished"
? "text-green-500"
: turn.status === "error"
? "text-red-500"
: turn.status === "aborted"
? "text-yellow-500"
: "text-yellow-500"
}
>
{statusLabel}
</div>
<div className="w-px h-3 bg-border-subtle" />
{turn.stats && (
<>
<div className="w-px h-3 bg-border-subtle" />
<div>{formatDuration(turn.stats.durationMs)}</div>
<div className="w-px h-3 bg-border-subtle" />
<div>
{turn.stats.inputTokens
? formatTokenCount(turn.stats.inputTokens)
: "0"}{" "}
in /{" "}
{turn.stats.responseTokens
? formatTokenCount(turn.stats.responseTokens)
: "0"}{" "}
out
</div>
</>
)}
</div>
{/* Error Alert */}
{turn.errorMessage && turn.status === "error" && (
<div className="max-w-[80%] ml-0 mb-2">
<div className="bg-red-600 text-white rounded p-3 border-2 border-red-400">
<div className="text-xs font-semibold mb-1 flex items-center gap-2">
<span></span> Error
</div>
<div className="whitespace-pre-wrap text-sm">
{turn.errorMessage}
</div>
</div>
</div>
)}
{/* Aborted notice */}
{turn.status === "aborted" && (
<div className="max-w-[80%] ml-0 mb-2">
<div className="bg-bg-tertiary border border-border-default rounded p-3">
<div className="whitespace-pre-wrap text-sm text-text-secondary">
The turn was aborted by you.
</div>
</div>
</div>
)}
{/* User Prompt */}
<div className="max-w-[80%] ml-0 mb-4">
<div className="bg-[#4a0000] text-white rounded p-4">
<div className="text-xs font-semibold mb-2">You</div>
<div className="whitespace-pre-wrap text-sm">
{turn.prompts?.user || ""}
</div>
</div>
</div>
{/* Agent Response */}
{turn.blocks && turn.blocks.length > 0 && (
<div className="max-w-[80%] ml-auto mb-4 overflow-x-hidden">
<div className="bg-bg-tertiary border border-border-default rounded p-3 overflow-x-auto">
<div className="text-xs font-semibold mb-4 text-text-secondary">
Gadget
</div>
{/* Render blocks in order */}
{turn.blocks.map((block, idx) => {
if (block.mode === "thinking") {
return (
<div key={idx} className="mb-4">
<div className="text-xs text-text-muted mb-1 font-mono">
Thinking
</div>
<div
className="gadget-markdown bg-bg-secondary rounded p-2 text-xs text-text-muted font-mono"
dangerouslySetInnerHTML={{
__html: marked.parse(block.content) as string,
}}
/>
</div>
);
} else if (block.mode === "responding") {
return (
<div key={idx} className="mb-4 overflow-x-auto">
<div
className="gadget-markdown text-[13px]"
dangerouslySetInnerHTML={{
__html: marked.parse(block.content) as string,
}}
/>
</div>
);
} else if (block.mode === "tool") {
const toolCall = block.content;
const subagent = toolCall.subagent;
return (
<div key={idx} className="tool-call">
<ToolCallDisplay
callId={toolCall.callId}
name={toolCall.name}
parameters={toolCall.parameters}
response={toolCall.response}
createdAt={block.createdAt}
/>
{subagent && <SubagentDisplay subagent={subagent} />}
</div>
);
}
return null;
})}
<div className="mt-1 text-xs text-text-muted">
{startedAt.toLocaleTimeString()}
</div>
</div>
</div>
)}
</div>
);
});
function SubagentDisplay({
subagent,
}: {
subagent: NonNullable<ChatTurnBlockTool["content"]["subagent"]>;
}) {
const [expanded, setExpanded] = useState(true);
const toggle = useCallback(() => setExpanded((p) => !p), []);
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
const seconds = (ms / 1000).toFixed(1);
return `${seconds}s`;
};
const formatTokenCount = (count: number): string => {
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return count.toString();
};
return (
<div className="ml-3 border-l-2 border-brand/20 pl-1.5 mt-1">
<button
onClick={toggle}
className="flex items-center gap-1.5 text-xs font-mono text-text-muted hover:text-text-primary transition-colors w-full text-left"
>
<span className="text-brand"></span>
<span>Subagent</span>
{subagent.stats && (
<span className="text-text-muted">
({subagent.toolCalls.length} calls ·{" "}
{formatDuration(subagent.stats.durationMs)})
</span>
)}
</button>
{expanded && (
<div className="space-y-1">
{/* Thinking */}
{subagent.thinking && (
<div>
<div className="text-xs text-text-muted font-mono mb-0.5">
Thinking
</div>
<div
className="gadget-markdown bg-bg-secondary rounded p-1.5 text-xs text-text-muted font-mono"
dangerouslySetInnerHTML={{
__html: marked.parse(subagent.thinking) as string,
}}
/>
</div>
)}
{/* Subagent Tool Calls */}
{subagent.toolCalls.length > 0 && (
<div>
<div className="text-xs text-text-muted font-mono mb-0.5">
Tool Calls ({subagent.toolCalls.length})
</div>
<div className="space-y-0.5">
{subagent.toolCalls.map((tc, i) => (
<ToolCallDisplay
key={tc.callId || i}
callId={tc.callId}
name={tc.name}
parameters={tc.parameters}
response={tc.response}
defaultExpanded={false}
/>
))}
</div>
</div>
)}
{/* Response */}
{subagent.response && (
<div>
<div className="text-xs text-text-muted font-mono mb-0.5">
Response
</div>
<div
className="gadget-markdown text-xs"
dangerouslySetInnerHTML={{
__html: marked.parse(subagent.response) as string,
}}
/>
</div>
)}
{/* Stats */}
{subagent.stats && (
<div className="flex gap-3 text-xs text-text-muted font-mono pt-1 border-t border-border-subtle">
<span>{formatDuration(subagent.stats.durationMs)}</span>
<span>{formatTokenCount(subagent.stats.inputTokens)} in</span>
<span>{formatTokenCount(subagent.stats.responseTokens)} out</span>
{subagent.stats.thinkingTokenCount > 0 && (
<span>
{formatTokenCount(subagent.stats.thinkingTokenCount)} thinking
</span>
)}
</div>
)}
</div>
)}
</div>
);
}
export default ChatTurn;