refactor(ai): remove semantic embedding cache + Qdrant vector store
Hapus seluruh fitur embedding/Qdrant (tidak dipakai lagi): - gateway: drop embeddingClient.ts, qdrantClient.ts, archiveEmbedder.ts dan tes qdrantEnsure.test.ts; moderationOrchestrator kembali ke exact-hash cache -> LLM (tanpa phase-2 semantic lookup); textCacheStore kehilangan findSimilarTextModeration / parseQdrantVerdict / isSemanticBandAccepted / upsertBareKeyToQdrant; cache-prune hanya menyapu Postgres. - backend: drop embed.ts + qdrant.ts, endpoint messages.semanticSearch dan schema/type terkait; kolom embedding dilepas dari schema text_analysis_cache. - frontend: hapus toggle EXACT/SEMANTIC, hook useSemanticSearch, API client + tipe SemanticSearchResult. - config: buang AI_LLM_EMBEDDING_* dan QDRANT_* (env + .env.example). - docs: ARCHITECTURE.md / AGENTS.md / README.md / diagram arsitektur disesuaikan (LLM caller - vision, cache = exact-hash saja). Verifikasi: tsc 0 (backend, gateway, frontend); bun test 135 pass + 37 pass, 0 fail; biome 0 error.
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
||||
Paperclip,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
useMessagesWsSync,
|
||||
useRecentEdits,
|
||||
useReviewWsSync,
|
||||
useSemanticSearch,
|
||||
} from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
@@ -85,9 +83,6 @@ export function MessagesView({
|
||||
const [channelId, setChannelId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
// Search mode: "exact" (substring match over captured messages) or
|
||||
// "semantic" (vector similarity over the persistent Qdrant archive).
|
||||
const [semanticMode, setSemanticMode] = useState(false);
|
||||
// feed | timeline: "timeline" groups messages into date-grouped cards.
|
||||
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
|
||||
// Guard against loading the entire history on a long scroll: cap how many
|
||||
@@ -123,15 +118,7 @@ export function MessagesView({
|
||||
const loadMore = useLoadMore();
|
||||
useMessagesWsSync(ws, guildId ?? "");
|
||||
useReviewWsSync(ws);
|
||||
const search = useMessageSearch(
|
||||
query,
|
||||
query.trim().length >= 2 && !semanticMode,
|
||||
);
|
||||
const semantic = useSemanticSearch(
|
||||
query,
|
||||
query.trim().length >= 2 && semanticMode,
|
||||
guildId,
|
||||
);
|
||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||
const activity = useMessageActivity(30);
|
||||
const edits = useRecentEdits(50, undefined, initialEdits);
|
||||
const detail = useMessageDetail(selected);
|
||||
@@ -161,8 +148,7 @@ export function MessagesView({
|
||||
ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages");
|
||||
}, [query, ambient]);
|
||||
|
||||
const searching = query.trim().length >= 2 && !semanticMode;
|
||||
const semanticSearching = query.trim().length >= 2 && semanticMode;
|
||||
const searching = query.trim().length >= 2;
|
||||
const list = searching ? (search.data ?? []) : (messages ?? []);
|
||||
// Discord-style order: oldest at the top, newest at the bottom. The backend
|
||||
// returns DESC (newest first); reverse so the feed reads top→bottom like DC.
|
||||
@@ -283,32 +269,6 @@ export function MessagesView({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSemanticMode(false)}
|
||||
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||
!semanticMode
|
||||
? "bg-surface text-ink border border-hairline-focus shadow-xs"
|
||||
: "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
EXACT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSemanticMode(true)}
|
||||
className={`flex items-center gap-1 rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
|
||||
semanticMode
|
||||
? "bg-signal/20 text-signal border border-signal/40 shadow-xs"
|
||||
: "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="size-3" />
|
||||
SEMANTIC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -336,76 +296,6 @@ export function MessagesView({
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-5">
|
||||
{semanticSearching && (
|
||||
<GlassPanel className="lg:col-span-5">
|
||||
<SectionHeader
|
||||
eyebrow="semantic archive"
|
||||
title={`Vector Matches for “${query}”`}
|
||||
action={
|
||||
<span className="mono text-xs text-[#8a8f98]">
|
||||
{semantic.data?.length ?? 0} matches
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{semantic.isLoading ? (
|
||||
<SkeletonRows rows={4} />
|
||||
) : semantic.data && semantic.data.length > 0 ? (
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{semantic.data.map((r, i) => (
|
||||
<div
|
||||
key={r.message_id ?? i}
|
||||
className="hud-card animate-stagger flex items-start gap-3 p-3"
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] font-semibold text-signal">
|
||||
{(r.score * 100).toFixed(0)}% RELEVANCE
|
||||
</span>
|
||||
{r.username && (
|
||||
<span className="font-mono text-[10px] font-medium text-ink">
|
||||
{r.username}
|
||||
</span>
|
||||
)}
|
||||
{r.thread_name && (
|
||||
<span className="font-mono text-[10px] text-ink-muted">
|
||||
▶ {r.thread_name}
|
||||
</span>
|
||||
)}
|
||||
{!r.thread_name && r.channel_name && (
|
||||
<span className="font-mono text-[10px] text-ink-muted">
|
||||
#{r.channel_name}
|
||||
</span>
|
||||
)}
|
||||
{!r.thread_name && !r.channel_name && r.channel_id && (
|
||||
<span className="font-mono text-[10px] text-ink-faint">
|
||||
#{r.channel_id}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="ml-auto font-mono text-[10px] text-ink-muted"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{formatRelativeTime(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-ink-soft leading-relaxed">
|
||||
{r.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Search className="size-7" />}
|
||||
title="No semantic matches"
|
||||
description="Try different phrasing — vector search inspects contextual semantics."
|
||||
/>
|
||||
)}
|
||||
</GlassPanel>
|
||||
)}
|
||||
|
||||
{/* Message Stream Deck */}
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
|
||||
@@ -25,7 +25,6 @@ export {
|
||||
useRecentEdits,
|
||||
useReview,
|
||||
useReviewWsSync,
|
||||
useSemanticSearch,
|
||||
useTextChannels,
|
||||
} from "./use-messages";
|
||||
export {
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
EditHistoryRow,
|
||||
MessageActivityBucket,
|
||||
MessageRecord,
|
||||
SemanticSearchResult,
|
||||
} from "@/lib/types";
|
||||
import type { WsHook } from "@/lib/ws-hook";
|
||||
|
||||
@@ -210,29 +209,6 @@ export function useMessageSearch(query: string, enabled: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Semantic Search (public archive, Qdrant) ──────
|
||||
|
||||
export function useSemanticSearch(
|
||||
query: string,
|
||||
enabled: boolean,
|
||||
guildId?: string | null,
|
||||
) {
|
||||
return useSWR<SemanticSearchResult[]>(
|
||||
enabled && query.trim().length >= 2
|
||||
? ["semantic-search", query.trim(), guildId ?? ""]
|
||||
: null,
|
||||
async () => {
|
||||
const res = await messagesApi.semanticSearch(
|
||||
query.trim(),
|
||||
10,
|
||||
guildId ?? undefined,
|
||||
);
|
||||
return res.results;
|
||||
},
|
||||
{ keepPreviousData: true },
|
||||
);
|
||||
}
|
||||
|
||||
// ── WS sync helpers ──────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
Guild,
|
||||
MessageActivityBucket,
|
||||
MessageRecord,
|
||||
SemanticSearchResult,
|
||||
} from "@/lib/types";
|
||||
|
||||
export const messagesApi = {
|
||||
@@ -76,17 +75,6 @@ export const messagesApi = {
|
||||
results: MessageRecord[];
|
||||
}>,
|
||||
|
||||
// Public semantic search over the persistent message archive (Qdrant).
|
||||
semanticSearch: (query: string, limit?: number, guildId?: string) =>
|
||||
orpc.messages.semanticSearch({
|
||||
query,
|
||||
limit,
|
||||
...(guildId ? { guildId } : {}),
|
||||
}) as unknown as Promise<{
|
||||
results: SemanticSearchResult[];
|
||||
nextCursor: null;
|
||||
}>,
|
||||
|
||||
// Public, read-only activity heatmap data (per-hour volume by channel).
|
||||
getActivity: (days = 30) =>
|
||||
orpc.messages.activity({ days }) as unknown as Promise<
|
||||
|
||||
@@ -166,29 +166,9 @@ export interface AttachmentRecord {
|
||||
uploaded_at?: number | null;
|
||||
}
|
||||
|
||||
// ── Semantic Search (read-only public archive search) ──────────
|
||||
|
||||
export interface SemanticSearchResult {
|
||||
message_id: string | null;
|
||||
content: string;
|
||||
score: number;
|
||||
created_at: number;
|
||||
username: string | null;
|
||||
channel_id: string | null;
|
||||
guild_id: string | null;
|
||||
thread_id: string | null;
|
||||
channel_name: string | null;
|
||||
thread_name: string | null;
|
||||
}
|
||||
|
||||
export interface MessageActivityBucket {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
hour: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SemanticSearchResponse {
|
||||
results: SemanticSearchResult[];
|
||||
nextCursor: null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user