import type { ChatMessageFeedback } from "@/components/chat-message-actions"; /** * Client side of 回复评价 persistence (compliance round 2026-09-30). The key * the transcript uses is `${sessionId}:message-${index}`; the server keys a * rating by session + index and keeps a short hash of the reply text, so a * rating is not shown on a different (regenerated) answer at the same index. */ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export function parseFeedbackKey(feedbackKey: string): { sessionId: string; messageIndex: number } | null { const match = /^(.+):message-(\d+)$/.exec(feedbackKey); if (!match || !UUID.test(match[1]!)) return null; return { sessionId: match[1]!, messageIndex: Number(match[2]) }; } export function feedbackKeyFor(sessionId: string, messageIndex: number): string { return `${sessionId}:message-${messageIndex}`; } /** First 16 hex characters of SHA-256 over the reply text. */ export async function answerHash(text: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); return [...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join(""); } /** The latest rating save per reply, so a 不满意原因 lands after its 👎 (and snapshot) exists. */ const pendingRatingSaves = new Map>(); /** Fire-and-forget; a failed save never disturbs the conversation. */ export function persistReplyRating(feedbackKey: string, rating: ChatMessageFeedback | undefined, text: string): Promise { const key = parseFeedbackKey(feedbackKey); if (!key) return Promise.resolve(); const saved = answerHash(text) .then((answerSha256) => fetch("/api/reply-ratings", { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...key, rating: rating ?? null, answerSha256 }), })) .then(() => undefined, () => undefined); pendingRatingSaves.set(feedbackKey, saved); return saved; } /** * 不满意原因 for a 👎: optional reasons and a short note, added to the snapshot * the rating save kept. Resolves false when it could not be saved. */ export async function saveReplyQualityReason( feedbackKey: string, reasons: readonly string[], note: string, ): Promise { const key = parseFeedbackKey(feedbackKey); if (!key) return false; await pendingRatingSaves.get(feedbackKey); try { const response = await fetch("/api/reply-ratings/reason", { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...key, reasons, ...(note.trim() ? { note: note.trim() } : {}) }), }); return response.ok; } catch { return false; } } type StoredRating = { messageIndex: number; rating: ChatMessageFeedback; answerSha256: string }; /** * Stored ratings for one session, keeping only those whose reply text still * hashes the same. `texts[i]` is the i-th message's text ("" for user turns). */ export async function loadReplyRatings(sessionId: string, texts: readonly string[]): Promise> { if (!UUID.test(sessionId)) return {}; const response = await fetch(`/api/reply-ratings?sessionId=${encodeURIComponent(sessionId)}`, { credentials: "same-origin" }); if (!response.ok) return {}; const json = await response.json().catch(() => null) as { ratings?: StoredRating[] } | null; const result: Record = {}; for (const stored of json?.ratings ?? []) { const text = texts[stored.messageIndex]; if (!text) continue; if (await answerHash(text) === stored.answerSha256) result[feedbackKeyFor(sessionId, stored.messageIndex)] = stored.rating; } return result; }