Spaces:
Runtime error
Runtime error
File size: 13,852 Bytes
0649f1b 66de9c8 0649f1b 66de9c8 0649f1b 66de9c8 0649f1b 66de9c8 0649f1b 66de9c8 0649f1b 66de9c8 0649f1b 66de9c8 0649f1b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { videosApi, type GenerateBody } from "../api/endpoints";
import type { ChatSummary, VideoFocusSource, VideoFocusTopic } from "../api/types";
import { CheckIcon, SparkleIcon } from "./icons/Icons";
import { useArtifactLimits } from "../hooks/useArtifactLimits";
import { SourcePicker, type VideoSource } from "./video/SourcePicker";
/** Most chats one generated thing reads (the video wizard's cap too). Posts are capped at MAX_VIDEO_POSTS, as for a video. */
export const MAX_CHATS = 5;
export const LANGUAGES = ["English", "Spanish", "French", "German", "Italian", "Portuguese", "Dutch", "Polish", "Hindi",
"Arabic", "Japanese", "Korean", "Chinese", "Turkish", "Russian", "Indonesian"];
/** Suggestions already fetched, by source choice: reopening a dialog or ticking back to an earlier choice is instant. */
const topicCache = new Map<string, VideoFocusTopic[]>();
const topicWaiting = new Map<string, Promise<VideoFocusTopic[]>>();
/** The suggestions read the posts when there are any, else the chats: ticking a chat beside posts changes nothing. */
const focusKey = (postIds: string[], chatIds: string[]) =>
postIds.length ? `p:${[...postIds].sort().join(",")}` : `c:${[...chatIds].sort().join(",")}`;
function fetchTopics(key: string, fields: VideoFocusSource): Promise<VideoFocusTopic[]> {
const hit = topicCache.get(key);
if (hit) return Promise.resolve(hit);
let wait = topicWaiting.get(key);
if (!wait) {
wait = videosApi.focusTopics(fields).then((r) => {
if (r.topics.length) topicCache.set(key, r.topics);
return r.topics;
}).finally(() => topicWaiting.delete(key));
topicWaiting.set(key, wait);
}
return wait;
}
/** Ask for a notebook's suggestions ahead of time (the posts a dialog starts with ticked), so the dialog finds them waiting. */
export function prefetchFocus(notebookId: string, postIds: string[]) {
if (!postIds.length) return;
fetchTopics(`${notebookId}|${focusKey(postIds, [])}`, { document_ids: postIds, notebook_id: notebookId }).catch(() => undefined);
}
/** Most posts the topic suggestions are asked about (the server reads at most this many; the topics come from a few anyway). */
const MAX_FOCUS_POSTS = 200;
export type SourceMode = "chats" | "posts" | "mixed";
/** The source and focus fields of a generate request: what to read, and what to centre on. */
export type SourceRequest = Pick<GenerateBody, "chat_ids" | "document_id" | "document_ids" | "notebook_id" | "topic">;
/** What the fields report upward: whether the source is complete, whether the focus cards are still loading, and the
* request fields to send. */
export type SourceSelection = {
ready: boolean;
/** The focus cards are still on their way: Generate waits for them, as "Go to step 2" does in the video wizard. */
loading: boolean;
mode: SourceMode;
request: SourceRequest;
/** The chosen source in words, for a prompt ("3 posts", "Pricing chat"). */
label: string;
};
/** "4 posts", "1 chat" or "4 posts + 1 chat": what a quiz, a card set or a report was made from. */
export function sourceSummary(source: { document_ids?: string[]; chat_ids?: string[]; count?: number } | undefined): string {
const posts = source?.document_ids?.length ?? 0;
const chats = source?.chat_ids?.length ?? 0;
const parts = [posts ? `${posts} post${posts === 1 ? "" : "s"}` : "", chats ? `${chats} chat${chats === 1 ? "" : "s"}` : ""];
return parts.filter(Boolean).join(" + ") || `${source?.count ?? 0} posts`;
}
/** One-of-several pills, the video wizard's "Video style" look. */
export function Pills<T extends string>({ label, value, options, onChange }: {
label: string;
value: T;
options: readonly { id: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div className="vw-chips" role="radiogroup" aria-label={label}>
{options.map((o) => (
<button key={o.id} type="button" role="radio" aria-checked={value === o.id} className={`vw-chip${value === o.id ? " on" : ""}`}
onClick={() => onChange(o.id)}>
{o.label}
</button>
))}
</div>
);
}
/** What it is made from (chats, or a notebook's posts, or a single post) and what to focus on (suggested cards or your
* own topic): the video wizard's first step, shared by the quiz, flashcard and report dialogs. Holds its own state
* and reports the choice through onChange. */
export function SourceFocusFields({ notebookId, notebookTitle, chats, currentChatId, preselectChat = false, withPosts = false, largeSelection = false, what, columns = false, extra, below, noFocus = false, onChange }: {
notebookId: string;
/** The notebook's name, shown as the fixed source of its posts. */
notebookTitle?: string;
chats: ChatSummary[];
/** The chat open beside the dialog. */
currentChatId?: string | null;
/** Tick the open chat to start with (otherwise chats are added by choice). */
preselectChat?: boolean;
/** With preselectChat: the notebook's first posts are ticked as well, not just the chat. */
withPosts?: boolean;
/** A quiz or flashcard set: as many posts as the plan indexes can be picked (a sample of their extracted ideas is read). A dialog still ticks only
* the first few to start with. */
largeSelection?: boolean;
/** What is being made ("quiz", "flashcard set", "report"), for the wording. */
what: string;
/** Source and focus side by side, for a wide dialog. */
columns?: boolean;
/** With columns: a third column (the dialog's settings and its button). */
extra?: ReactNode;
/** Under the source fields (the first column): settings that go with the source. */
below?: ReactNode;
/** No focus cards and no custom topic (the report's own instructions say what it should be about). */
noFocus?: boolean;
onChange: (s: SourceSelection) => void;
}) {
const limits = useArtifactLimits();
const [source, setSource] = useState<VideoSource | null>({ kind: "notebook", id: notebookId });
const [postIds, setPostIds] = useState<string[]>([]);
// Every dialog starts with all of the notebook's posts ticked and no chat; chats are added by choice.
const [chatIds, setChatIds] = useState<string[]>(() =>
preselectChat && currentChatId && chats.some((c) => c.id === currentChatId) ? [currentChatId] : []);
// Opened from a chat: just that chat to start with, no posts (they can still be added), unless withPosts
const [chatFirst] = useState(() => chatIds.length > 0 && !withPosts);
const [topics, setTopics] = useState<VideoFocusTopic[] | null>(null);
const topicsFor = useRef(""); // the key of the suggestions shown or on their way
const [focus, setFocus] = useState<VideoFocusTopic | null>(null);
const [custom, setCustom] = useState("");
// Posts, chats, or both together.
const sourceReady = chatIds.length > 0 || postIds.length > 0;
const mode: SourceMode = postIds.length && chatIds.length ? "mixed" : chatIds.length ? "chats" : "posts";
const sourceFields = (): VideoFocusSource | null => !sourceReady ? null
: postIds.length ? { document_ids: postIds.slice(0, MAX_FOCUS_POSTS), notebook_id: source?.id } // the topics come from the posts
: { chat_ids: chatIds };
const sourceKey = !sourceReady ? "" : `p:${[...postIds].sort().join(",")}|c:${[...chatIds].sort().join(",")}`;
// The focus cards belong to one source choice: asked for once it is complete (a run of ticks settles into one
// request), and a new choice asks again. A failure counts as no cards.
useEffect(() => {
if (noFocus || !sourceReady || sourceKey === topicsFor.current) return;
const fields = sourceFields();
if (!fields) return;
const cacheKey = `${notebookId}|${focusKey(postIds, chatIds)}`;
const hit = topicCache.get(cacheKey);
if (hit) {
topicsFor.current = sourceKey;
setTopics(hit);
setFocus(null);
return;
}
const timer = setTimeout(() => {
topicsFor.current = sourceKey;
setTopics(null);
setFocus(null);
fetchTopics(cacheKey, fields)
.then((t) => topicsFor.current === sourceKey && setTopics(t))
.catch(() => topicsFor.current === sourceKey && setTopics([]));
}, 600);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKey, sourceReady]);
const shownTopics = topicsFor.current === sourceKey ? topics : null;
const topic = focus ? `${focus.title}. ${focus.description}`.trim() : custom.trim();
const selection = useMemo<SourceSelection>(() => {
const request: SourceRequest = {};
if (postIds.length) {
request.document_ids = postIds;
request.notebook_id = source?.id;
}
if (chatIds.length) request.chat_ids = chatIds;
if (topic) request.topic = topic;
const parts = [
postIds.length ? `${postIds.length} post${postIds.length === 1 ? "" : "s"}` : "",
chatIds.length ? `${chatIds.length} chat${chatIds.length === 1 ? "" : "s"}` : "",
].filter(Boolean);
const label = parts.join(" + ") || "no sources";
return { ready: sourceReady, loading: !noFocus && sourceReady && shownTopics === null, mode, request, label };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKey, sourceReady, mode, topic, shownTopics === null]);
const report = useRef(onChange);
report.current = onChange;
useEffect(() => report.current(selection), [selection]);
const sourceBlock = (
<div className="field">
<span className="vw-label">Made from</span>
<SourcePicker source={source} onSource={setSource} postIds={postIds} onPostIds={setPostIds}
max={largeSelection ? limits.selectable : limits.posts} autoCount={limits.posts} what={what} autoSelect={!chatFirst} compact notebookTitle={notebookTitle}
chats={chats} chatIds={chatIds} onChatIds={setChatIds} />
<small className="muted vw-hint">
{!sourceReady ? "Pick at least one post or chat. You can use both together."
: `Made from ${[postIds.length ? `${postIds.length} post${postIds.length === 1 ? "" : "s"}` : "", chatIds.length ? `${chatIds.length} chat${chatIds.length === 1 ? "" : "s"}` : ""].filter(Boolean).join(" and ")}.`}
</small>
</div>
);
const belowBlock = below ? <div className="sf-below">{below}</div> : null;
const focusFields = (
<>
{!sourceReady && !noFocus && (
<p className="muted sf-empty">Pick at least one post or chat to see topic suggestions.</p>
)}
{sourceReady && !noFocus && (
<div className="field vw-focus">
<span className="vw-label">What should the {what} focus on?</span>
{shownTopics?.length !== 0 && (
<div className="vw-focus-grid" aria-busy={shownTopics === null}>
{shownTopics === null
? [0, 1, 2].map((i) => (
<div key={i} className="vw-focus-card wait" aria-hidden="true">
<SparkleIcon size={16} />
<span className="vw-focus-text">
<span className="vw-focus-bar" />
<span className="vw-focus-bar short" />
</span>
</div>
))
: shownTopics.map((t) => {
const on = focus?.title === t.title;
return (
<button key={t.title} type="button" className={`vw-focus-card${on ? " on" : ""}`} aria-pressed={on}
onClick={() => {
setFocus(on ? null : t);
setCustom("");
}}>
<SparkleIcon size={16} />
<span className="vw-focus-text">
<strong>{t.title}</strong>
{t.description && <small className="vw-focus-desc">{t.description}</small>}
</span>
{on && <CheckIcon size={16} />}
</button>
);
})}
</div>
)}
<label className={`vw-focus-custom${custom.trim() ? " on" : ""}`}>
<span className="vw-focus-custom-head">Custom topic</span>
<textarea rows={columns ? 2 : 5} maxLength={500} value={custom}
placeholder="Describe your own, e.g. how rebalancing protects a long-term portfolio"
onFocus={() => setFocus(null)} // choosing the custom topic unpicks a suggested one
onChange={(e) => {
setCustom(e.target.value);
setFocus(null);
}} />
<span className="vw-focus-custom-foot">
<small className="muted">Keep it to what the selected material covers; anything it doesn't cover is left out.</small>
<small className="muted mono">{custom.length}/500</small>
</span>
</label>
{shownTopics && (
<small className="muted vw-hint">
{focus ? `The ${what} is mainly about this topic.`
: custom.trim() ? `The ${what} follows your topic, as far as the material covers it.`
: `None picked: the ${what} covers the material as a whole.`}
</small>
)}
</div>
)}
</>
);
// In columns the source is on the left and the focus on the right, so a wide dialog fits without scrolling.
return columns ? (
<div className={`sf-grid${extra ? " sf-3" : ""}`}>
<div className="sf-col">{sourceBlock}{belowBlock}</div>
<div className="sf-col">{focusFields}</div>
{extra && <div className="sf-col">{extra}</div>}
</div>
) : (
<>
{sourceBlock}
{focusFields}
</>
);
}
|