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
287 lines
9.5 KiB
TypeScript
287 lines
9.5 KiB
TypeScript
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;
|