| 1 | import type { |
| 2 | MemoryReview, |
| 3 | MemoryReviewStatus, |
| 4 | MemorySelection, |
| 5 | UIMessage, |
| 6 | WorkplaceData, |
| 7 | } from "@/lib/types"; |
| 8 | |
| 9 | const STATUSES = new Set<MemoryReviewStatus>([ |
| 10 | "awaiting_method", |
| 11 | "selecting", |
| 12 | "awaiting_review", |
| 13 | "reselecting", |
| 14 | "approved", |
| 15 | "error", |
| 16 | ]); |
| 17 | |
| 18 | function isSelection(value: unknown): value is MemorySelection { |
| 19 | if (!value || typeof value !== "object") return false; |
| 20 | const item = value as Partial<MemorySelection>; |
| 21 | return ( |
| 22 | typeof item.memory_id === "string" && |
| 23 | (item.kind === "character" || item.kind === "previous_shot") && |
| 24 | typeof item.candidate_index === "number" && |
| 25 | typeof item.frame_index === "number" && |
| 26 | typeof item.timestamp_sec === "number" && |
| 27 | typeof item.confidence === "number" && |
| 28 | typeof item.reasoning === "string" && |
| 29 | Boolean(item.image && typeof item.image.url === "string") |
| 30 | ); |
| 31 | } |
| 32 | |
| 33 | function normalizeReview( |
| 34 | shotId: number, |
| 35 | value: unknown, |
| 36 | ): MemoryReview | null { |
| 37 | if (!value || typeof value !== "object") return null; |
| 38 | const review = value as Partial<MemoryReview>; |
| 39 | if ( |
| 40 | typeof review.review_id !== "string" || |
| 41 | !review.review_id || |
| 42 | typeof review.status !== "string" || |
| 43 | !STATUSES.has(review.status as MemoryReviewStatus) || |
| 44 | typeof review.attempt !== "number" || |
| 45 | !Array.isArray(review.selections) || |
| 46 | !review.selections.every(isSelection) |
| 47 | ) { |
| 48 | return null; |
| 49 | } |
| 50 | return { |
| 51 | review_id: review.review_id, |
| 52 | shot_id: shotId, |
| 53 | status: review.status as MemoryReviewStatus, |
| 54 | attempt: review.attempt, |
| 55 | candidate_count: Number(review.candidate_count ?? 0), |
| 56 | rejected_candidate_indices: Array.isArray( |
| 57 | review.rejected_candidate_indices, |
| 58 | ) |
| 59 | ? review.rejected_candidate_indices.filter( |
| 60 | (item): item is number => typeof item === "number", |
| 61 | ) |
| 62 | : [], |
| 63 | selections: review.selections, |
| 64 | selection_mode: |
| 65 | review.selection_mode === "manual" || review.selection_mode === "vlm" |
| 66 | ? review.selection_mode |
| 67 | : null, |
| 68 | required_memory_ids: Array.isArray(review.required_memory_ids) |
| 69 | ? review.required_memory_ids.filter((item): item is string => typeof item === "string") |
| 70 | : [], |
| 71 | manual_selected_ids: Array.isArray(review.manual_selected_ids) |
| 72 | ? review.manual_selected_ids.filter((item): item is string => typeof item === "string") |
| 73 | : [], |
| 74 | source_video: |
| 75 | review.source_video && typeof review.source_video.url === "string" |
| 76 | ? review.source_video |
| 77 | : null, |
| 78 | history: Array.isArray(review.history) ? review.history : [], |
| 79 | error: typeof review.error === "string" ? review.error : null, |
| 80 | updated_at: |
| 81 | typeof review.updated_at === "string" ? review.updated_at : "", |
| 82 | }; |
| 83 | } |
| 84 | |
| 85 | function pendingSelectingReview(shotId: number): MemoryReview { |
| 86 | return { |
| 87 | review_id: `pending:${shotId}`, |
| 88 | shot_id: shotId, |
| 89 | status: "selecting", |
| 90 | attempt: 1, |
| 91 | candidate_count: 0, |
| 92 | rejected_candidate_indices: [], |
| 93 | selections: [], |
| 94 | history: [], |
| 95 | error: null, |
| 96 | updated_at: "", |
| 97 | }; |
| 98 | } |
| 99 | |
| 100 | function toMessage( |
| 101 | workplace: WorkplaceData, |
| 102 | shotId: number, |
| 103 | review: MemoryReview, |
| 104 | ): UIMessage { |
| 105 | const parsedTime = Date.parse(review.updated_at); |
| 106 | return { |
| 107 | id: `memory-review:${workplace.work_id}:${shotId}:${review.review_id}`, |
| 108 | role: "assistant", |
| 109 | content: "", |
| 110 | createdAt: Number.isFinite(parsedTime) ? parsedTime : shotId, |
| 111 | memoryReview: review, |
| 112 | }; |
| 113 | } |
| 114 | |
| 115 | export function memoryReviewMessages(workplace: WorkplaceData): UIMessage[] { |
| 116 | if (!workplace.work_id) return []; |
| 117 | return [...(workplace.shots ?? [])] |
| 118 | .sort((left, right) => left.shot_id - right.shot_id) |
| 119 | .flatMap((shot) => { |
| 120 | // Only surface Memory review after the user accepts the shot video. |
| 121 | if (shot.status !== "approved") return []; |
| 122 | const review = normalizeReview(shot.shot_id, shot.memory_review); |
| 123 | if ( |
| 124 | workplace.auto_generate && |
| 125 | review?.status !== "awaiting_method" && |
| 126 | review?.status !== "error" && |
| 127 | review?.selection_mode !== "manual" |
| 128 | ) { |
| 129 | return []; |
| 130 | } |
| 131 | if (review) return [toMessage(workplace, shot.shot_id, review)]; |
| 132 | if (workplace.auto_generate) return []; |
| 133 | return [ |
| 134 | toMessage(workplace, shot.shot_id, pendingSelectingReview(shot.shot_id)), |
| 135 | ]; |
| 136 | }); |
| 137 | } |
| 138 |