feat(archive): show real channel/thread names in semantic search UI

Gateway archive embedder now parses metadata.channel.{channelName,threadName}
from each message and stores channel_name/thread_name in the Qdrant payload.
Backend exposes them; the semantic results card renders the thread name (or
channel name) instead of a raw #snowflake, with the ID as a last-resort
fallback for legacy points. Matches the message feed's channel-label logic
(getMessageChannelLabel).
This commit is contained in:
asepharyana
2026-09-01 19:27:56 +07:00
parent 26a690943d
commit fa72fe03cd
6 changed files with 56 additions and 1 deletions
@@ -132,6 +132,8 @@ function mapSearchHit(hit: {
channel_id?: string; channel_id?: string;
guild_id?: string; guild_id?: string;
thread_id?: string | null; thread_id?: string | null;
channel_name?: string | null;
thread_name?: string | null;
created_at?: number; created_at?: number;
}; };
}) { }) {
@@ -146,6 +148,8 @@ function mapSearchHit(hit: {
channel_id: hit.payload.channel_id ?? null, channel_id: hit.payload.channel_id ?? null,
guild_id: hit.payload.guild_id ?? null, guild_id: hit.payload.guild_id ?? null,
thread_id: hit.payload.thread_id ?? null, thread_id: hit.payload.thread_id ?? null,
channel_name: hit.payload.channel_name ?? null,
thread_name: hit.payload.thread_name ?? null,
}; };
} }
@@ -14,6 +14,8 @@ export interface ArchiveHit {
channel_id?: string; channel_id?: string;
guild_id?: string; guild_id?: string;
thread_id?: string | null; thread_id?: string | null;
channel_name?: string | null;
thread_name?: string | null;
created_at?: number; created_at?: number;
}; };
} }
@@ -44,6 +44,8 @@ export interface QdrantVerdictPayload {
channel_id?: string; channel_id?: string;
guild_id?: string; guild_id?: string;
thread_id?: string | null; thread_id?: string | null;
channel_name?: string | null;
thread_name?: string | null;
created_at?: number; created_at?: number;
} }
@@ -20,12 +20,42 @@ export interface ArchiveMessage {
guild_id: string; guild_id: string;
thread_id: string | null; thread_id: string | null;
created_at: number; created_at: number;
/** JSON string of RichMessageMetadata (parsed for channel/thread names). */
metadata?: string | null;
/** True when the message came from an age-restricted (NSFW) channel. NSFW /** True when the message came from an age-restricted (NSFW) channel. NSFW
* content is deliberately NOT embedded into the public archive so it can't * content is deliberately NOT embedded into the public archive so it can't
* be found via public semantic search. Defaults to false. */ * be found via public semantic search. Defaults to false. */
isAgeRestricted?: boolean; isAgeRestricted?: boolean;
} }
/**
* Extract a human-readable channel label from the message's metadata JSON.
* The gateway captures `metadata.channel.{channelName,threadName}` per message;
* prefer the thread name (thread messages read better by their thread title),
* then the channel name. Returns null when unavailable (old messages without
* the metadata field, or malformed JSON).
*/
export function extractChannelLabel(metadata: string | null | undefined): {
channel_name: string | null;
thread_name: string | null;
} {
if (!metadata) return { channel_name: null, thread_name: null };
try {
const m = JSON.parse(metadata) as {
channel?: {
channelName?: string | null;
threadName?: string | null;
};
};
return {
channel_name: m?.channel?.channelName ?? null,
thread_name: m?.channel?.threadName ?? null,
};
} catch {
return { channel_name: null, thread_name: null };
}
}
/** /**
* Fire-and-forget: embed a captured message and upsert it into the persistent * Fire-and-forget: embed a captured message and upsert it into the persistent
* archive collection so the public web can semantic-search the corpus. * archive collection so the public web can semantic-search the corpus.
@@ -52,6 +82,9 @@ export function archiveMessageEmbedded(message: ArchiveMessage): void {
if (!normalized) return; // nothing meaningful left after cleanup if (!normalized) return; // nothing meaningful left after cleanup
const vector = await embedText(normalized); const vector = await embedText(normalized);
if (!vector) return; if (!vector) return;
const { channel_name, thread_name } = extractChannelLabel(
message.metadata,
);
const ok = await upsertQdrantPointV2( const ok = await upsertQdrantPointV2(
ARCHIVE_COLLECTION, ARCHIVE_COLLECTION,
qdrantPointId(`archive:${message.id}`), qdrantPointId(`archive:${message.id}`),
@@ -65,6 +98,8 @@ export function archiveMessageEmbedded(message: ArchiveMessage): void {
channel_id: message.channel_id ?? "", channel_id: message.channel_id ?? "",
guild_id: message.guild_id ?? "", guild_id: message.guild_id ?? "",
thread_id: message.thread_id ?? null, thread_id: message.thread_id ?? null,
channel_name: channel_name ?? null,
thread_name: thread_name ?? null,
created_at: message.created_at ?? Date.now(), created_at: message.created_at ?? Date.now(),
analyzed_at: Date.now(), analyzed_at: Date.now(),
// 5-year persistent window (archive is NOT a TTL cache). // 5-year persistent window (archive is NOT a TTL cache).
@@ -366,8 +366,18 @@ export function MessagesView({
{r.username} {r.username}
</span> </span>
)} )}
{r.channel_id && ( {r.thread_name && (
<span className="font-mono text-[10px] text-ink-muted"> <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} #{r.channel_id}
</span> </span>
)} )}
@@ -177,6 +177,8 @@ export interface SemanticSearchResult {
channel_id: string | null; channel_id: string | null;
guild_id: string | null; guild_id: string | null;
thread_id: string | null; thread_id: string | null;
channel_name: string | null;
thread_name: string | null;
} }
export interface MessageActivityBucket { export interface MessageActivityBucket {