Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit c2591f8bcf4847863f706f56a6cac124bd0747ff
parent 2f6cbc930b94f917179c25505ea68a197a807a95
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 16 Jul 2026 16:33:16 -0400

Ask chat: regenerate, edit & resend, per-answer attribution

- Regenerate a completed answer (relabelled Retry, reuses gathered excerpts).
- Edit any earlier user turn: pulls it back into the composer and truncates
  the thread so the next send re-asks from there.
- Store + show which model produced each answer (attribution when the
  provider/model changes mid-conversation).
- Context panel shows a rough ~token estimate instead of only a char count.
- A hand-typed model survives switching providers and back within a session.
- Surface a "couldn't load" note when a Load-context fetch fails, instead of
  the spinner silently stopping.

Verified: tsc + lint clean; 15 unit + 13 ask-chat e2e green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Mexport/CHANGELOG.md | 1+
Mexport/app/ask/AskChat.tsx | 4++++
Mexport/app/ask/ContextPanel.tsx | 7+++++--
Mexport/app/ask/MessageBubble.tsx | 39++++++++++++++++++++++++++++++++-------
Mexport/app/ask/PinnedResultsPanel.tsx | 8++++++++
Mexport/app/ask/useAskChat.ts | 44+++++++++++++++++++++++++++++++++++++++++---
Mexport/app/lib/askConversation.ts | 3+++
7 files changed, 94 insertions(+), 12 deletions(-)

diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **"Ask AI" chat — regenerate, edit & resend, and per-answer attribution.** You can now **Regenerate** a completed answer (reusing the excerpts it already gathered), **Edit** any earlier question to pull it back into the composer and re-ask from that point, and see **which model** produced each answer — useful when you switch providers mid-conversation. The Context panel now shows a rough **token estimate** (not just a character count) as a cost cue, a hand-typed model name survives switching providers and back within a session, and a failed "Load context" fetch now says so on that result instead of the spinner just quietly stopping. See `export/app/ask/{MessageBubble,AskChat,PinnedResultsPanel,ContextPanel,useAskChat}.tsx` and `export/app/lib/askConversation.ts`. - **"Ask AI" chat polish — fewer dead ends, clearer feedback.** Several first-use rough edges are fixed: the provider settings panel no longer collapses out from under you the moment you start typing your API key; **Enter** now sends (with **Shift+Enter** for a new line); a message that stops or fails part-way through streaming now keeps its actions (Copy, Retry) and still shows what was searched, instead of freezing as raw text with no way forward; a request that errors mid-answer now shows *what* went wrong appended to whatever streamed, rather than silently dropping the error; an empty answer says so (with a Retry) instead of rendering nothing; the pre-answer "writing…" indicator no longer double-renders with an empty bubble; a fast double-press can no longer fire two turns at once; and the streaming answer is announced to screen readers. See `export/app/ask/{ProviderSettings,Composer,MessageBubble,useAskChat}.tsx`. - **The "Ask AI" chat no longer balloons its own context.** A long conversation — especially one grounded in a big set of search results — used to re-send *every* previous turn's excerpts inside *every* new turn, so the context grew quadratically until answers stalled or a provider rejected the request. Excerpts are now carried forward once, in a deduplicated pool (each video's excerpts merged across turns and sent a single time), while the replayed history is just the questions and answers. Three related fixes ride along: a "context length exceeded" error from your provider is now recognised and shown as a clear, actionable message instead of being mistaken for "this model doesn't support tools" (which pointlessly retried the oversized request); a conversation that outgrows your browser's storage quota now trims its oldest turns and warns you, instead of silently failing to save so newer turns vanished on reload; and reloading the page mid-answer no longer leaves a message spinning forever — the interrupted turn shows a **Retry**. See `export/app/lib/{askConversation,searchAgent,askProvider}.ts`, `export/app/lib/nativeTools/shared.ts`, and `export/app/ask/{useAskChat.ts,AskChat.tsx}`. diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx @@ -125,6 +125,7 @@ export default function AskChat() { strictGrounding={s.strictGrounding} busy={busy} expanding={s.expanding} + expandError={s.expandError} onSetStrict={s.setStrictGrounding} onClear={s.clearPinned} onExpandVideo={s.expandPinnedVideo} @@ -190,6 +191,9 @@ export default function AskChat() { message={m} markdownOn={markdownOn} onRetry={m.role === "assistant" ? () => s.retry(i) : undefined} + onEdit={ + m.role === "user" && !busy ? () => s.editUserMessage(i) : undefined + } /> )) )} diff --git a/export/app/ask/ContextPanel.tsx b/export/app/ask/ContextPanel.tsx @@ -52,8 +52,11 @@ export function ContextPanel({ edited </span> )} - <span className="ml-auto font-mono text-xs text-muted-foreground/70"> - {contextText.length.toLocaleString()} chars + <span + className="ml-auto font-mono text-xs text-muted-foreground/70" + title="Rough estimate — actual token count and cost depend on your provider" + > + ~{Math.round(contextText.length / 4).toLocaleString()} tokens </span> </summary> {open && ( diff --git a/export/app/ask/MessageBubble.tsx b/export/app/ask/MessageBubble.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { CheckIcon, CopyIcon, RotateCwIcon } from "lucide-react"; +import { CheckIcon, CopyIcon, PencilIcon, RotateCwIcon } from "lucide-react"; import { Markdown } from "yt-dlp-transcript-common/components/Markdown"; import type { UiMessage } from "../lib/askConversation"; import { PipelineStatus } from "./PipelineStatus"; @@ -41,10 +41,12 @@ export function MessageBubble({ message, markdownOn, onRetry, + onEdit, }: { message: UiMessage; markdownOn: boolean; onRetry?: () => void; + onEdit?: () => void; }) { const isUser = message.role === "user"; // Only true streaming shows the caret bubble; the pre-token "answering" state is @@ -57,8 +59,16 @@ export function MessageBubble({ // Keep the "Searched:" recap on stopped/errored turns too, so stopping or a // failure after searching doesn't erase what was looked up. const showSearched = !isUser && (settled || message.error) && nonFetchSteps.length > 0; - const showRetry = - !isUser && !!onRetry && (message.error || message.phase === "stopped" || emptyAnswer); + // A completed answer can be regenerated; a failed/stopped/empty one retried. + const canRerun = + !isUser && + !!onRetry && + (message.error || + message.phase === "stopped" || + emptyAnswer || + (message.phase === "done" && !!message.content)); + const rerunLabel = + message.phase === "done" && message.content ? "Regenerate" : "Retry"; return ( <div className="flex flex-col gap-2 animate-in fade-in slide-in-from-bottom-2 motion-reduce:animate-none"> @@ -99,22 +109,37 @@ export function MessageBubble({ </p> )} + {isUser && onEdit && ( + <button + type="button" + onClick={onEdit} + className="inline-flex items-center gap-1 self-end rounded px-1.5 py-0.5 text-xs text-muted-foreground/70 transition-colors hover:text-foreground" + > + <PencilIcon className="size-3" /> Edit + </button> + )} + {!isUser && settled && message.content && ( - <div className="flex items-center"> + <div className="flex items-center gap-2"> <CopyButton text={message.content} /> + {message.model && ( + <span className="font-mono text-xs text-muted-foreground/50"> + {message.model} + </span> + )} </div> )} - {showRetry && ( + {canRerun && ( <div className="flex items-center gap-2 text-xs"> <button type="button" onClick={onRetry} className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-muted-foreground transition-colors hover:text-foreground" > - <RotateCwIcon className="size-3.5" /> Retry + <RotateCwIcon className="size-3.5" /> {rerunLabel} </button> - {(message.sources?.length ?? 0) > 0 && ( + {rerunLabel === "Retry" && (message.sources?.length ?? 0) > 0 && ( <span className="text-muted-foreground/70"> reuses the excerpts already found </span> diff --git a/export/app/ask/PinnedResultsPanel.tsx b/export/app/ask/PinnedResultsPanel.tsx @@ -19,6 +19,7 @@ export function PinnedResultsPanel({ strictGrounding, busy, expanding, + expandError, onSetStrict, onClear, onExpandVideo, @@ -27,6 +28,7 @@ export function PinnedResultsPanel({ strictGrounding: boolean; busy: boolean; expanding: Record<string, boolean>; + expandError: Record<string, boolean>; onSetStrict: (on: boolean) => void; onClear: () => void; onExpandVideo: (key: string, aroundSeconds?: number) => void; @@ -92,6 +94,7 @@ export function PinnedResultsPanel({ <ul className="mt-2 flex flex-col gap-3 border-t border-border pt-2"> {pinned.videos.map((v) => { const loading = !!expanding[v.key]; + const failed = !!expandError[v.key]; return ( <li key={v.key} className="flex flex-col gap-1 text-xs"> <div className="flex items-start gap-2"> @@ -119,6 +122,11 @@ export function PinnedResultsPanel({ context </button> </div> + {failed && ( + <span className="text-warning/90"> + Couldn&apos;t load this transcript — try again. + </span> + )} {v.snippets.length > 0 && ( <ul className="flex flex-col gap-0.5 border-l border-border pl-2 font-mono text-muted-foreground/80"> {v.snippets.map((sn) => ( diff --git a/export/app/ask/useAskChat.ts b/export/app/ask/useAskChat.ts @@ -100,6 +100,9 @@ export function useAskChat() { const [strictGrounding, setStrictGroundingState] = useState(true); // Per-video key → true while its "Load context" fetch is in flight. const [expanding, setExpanding] = useState<Record<string, boolean>>({}); + // Per-video key → true when its last "Load context" fetch failed (so the panel + // can say so, instead of the spinner just quietly stopping). + const [expandError, setExpandError] = useState<Record<string, boolean>>({}); // Set when the conversation outgrew the storage quota and older turns had to be // dropped from what's saved — surfaced so a reload's missing history isn't a // silent surprise. @@ -111,6 +114,9 @@ export function useAskChat() { // runTurn, so two fast Enter presses could both pass the `!busy` check before // it settled. This ref is set synchronously in send() and cleared in runTurn. const sendingRef = useRef(false); + // Per-provider models the user typed this session (survives provider switches + // even when "remember" is off). + const sessionModels = useRef<Partial<Record<Provider, string>>>({}); // Always-current mirrors so send()/runTurn() never act on a stale snapshot // (e.g. sending immediately after applying an edited context). @@ -225,16 +231,23 @@ export function useAskChat() { }, [messages, contextOverride, pinned, strictGrounding]); function loadProviderCreds(p: Provider, rememberOn: boolean) { + // Prefer a model the user typed for this provider earlier this session, so a + // hand-typed model isn't discarded by a round-trip through the dropdown. + const session = sessionModels.current[p]; if (rememberOn) { setApiKey(localStorage.getItem(keyFor(p)) ?? ""); - setModel(localStorage.getItem(modelFor(p)) ?? PROVIDERS[p].defaultModel); + setModel( + session ?? localStorage.getItem(modelFor(p)) ?? PROVIDERS[p].defaultModel, + ); } else { setApiKey(""); - setModel(PROVIDERS[p].defaultModel); + setModel(session ?? PROVIDERS[p].defaultModel); } } function setProvider(p: Provider) { + // Stash the current (possibly hand-typed) model before switching away. + sessionModels.current[provider] = model; setProviderState(p); try { localStorage.setItem(K_PROVIDER, p); @@ -404,6 +417,7 @@ export function useAskChat() { truncated: result.truncated, error: false, phase: "done", + model: model.trim() || PROVIDERS[provider].defaultModel, })); } catch (e) { const err = e as Error; @@ -523,6 +537,20 @@ export function useAskChat() { [busy, apiKey, summariesReady, runTurn], ); + // Edit a prior user turn: pull its text back into the composer and drop it (and + // every turn after it) so the next send re-asks from that point. + const editUserMessage = useCallback( + (userIndex: number) => { + if (busy) return; + const current = messagesRef.current; + const msg = current[userIndex]; + if (!msg || msg.role !== "user") return; + setInput(msg.content); + setMessages(current.slice(0, userIndex)); + }, + [busy], + ); + const stop = () => abortRef.current?.abort(); const reset = () => { @@ -556,6 +584,12 @@ export function useAskChat() { ? aroundSeconds : v.snippets[0]?.seconds ?? 0; setExpanding((e) => ({ ...e, [key]: true })); + setExpandError((e) => { + if (!e[key]) return e; + const next = { ...e }; + delete next[key]; + return next; + }); try { const detail = await fetchTranscript(key); const snips = cuesToSnippets( @@ -578,7 +612,9 @@ export function useAskChat() { : prev, ); } catch { - /* transcript unavailable — leave the pin untouched */ + // Transcript unavailable — leave the pin untouched but flag it so the + // panel can surface "couldn't load" instead of silently stopping. + setExpandError((e) => ({ ...e, [key]: true })); } finally { setExpanding((e) => { const next = { ...e }; @@ -647,6 +683,7 @@ export function useAskChat() { stop, reset, retry, + editUserMessage, storageWarning, // context editing contextText, @@ -660,6 +697,7 @@ export function useAskChat() { clearPinned, expandPinnedVideo, expanding, + expandError, }; } diff --git a/export/app/lib/askConversation.ts b/export/app/lib/askConversation.ts @@ -41,6 +41,9 @@ export type UiMessage = { phase?: AssistantPhase; truncated?: boolean; error?: boolean; + // Assistant turns: which model produced this answer (shown as a per-message + // attribution, since the provider/model can change mid-conversation). + model?: string; }; // Replay completed prior turns for the model as BARE text — user turns are the