|
|
|
@@ -1,13 +1,14 @@
|
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
|
import { AIStore, AgentController, createIndexedDBPersistence } from "@pubscript/ai";
|
|
|
|
|
import type {
|
|
|
|
|
AIMessage,
|
|
|
|
|
AINote,
|
|
|
|
|
AISession,
|
|
|
|
|
AISuggestion,
|
|
|
|
|
NoteKind,
|
|
|
|
|
SuggestionKind,
|
|
|
|
|
import {
|
|
|
|
|
countTokens,
|
|
|
|
|
type AIMessage,
|
|
|
|
|
type AINote,
|
|
|
|
|
type AISession,
|
|
|
|
|
type AISuggestion,
|
|
|
|
|
type NoteKind,
|
|
|
|
|
type SuggestionKind,
|
|
|
|
|
} from "@pubscript/core";
|
|
|
|
|
import { Badge, Button, toast } from "@pubscript/ui";
|
|
|
|
|
import { useProjectStore } from "../../store/project-store.js";
|
|
|
|
@@ -15,6 +16,11 @@ import { useSettings, LINE_SUGGESTION_PROMPT_RULES } from "../../store/settings.
|
|
|
|
|
|
|
|
|
|
type SidebarTab = "chat" | "suggestions" | "notes";
|
|
|
|
|
|
|
|
|
|
export const DEFAULT_AI_SIDEBAR_WIDTH = 370;
|
|
|
|
|
const MIN_AI_SIDEBAR_WIDTH = 280;
|
|
|
|
|
const MAX_AI_SIDEBAR_WIDTH = 760;
|
|
|
|
|
const SNAP_THRESHOLD_PX = 18;
|
|
|
|
|
|
|
|
|
|
const NOTE_KINDS: { value: NoteKind; label: string }[] = [
|
|
|
|
|
{ value: "character", label: "Character" },
|
|
|
|
|
{ value: "world", label: "World" },
|
|
|
|
@@ -50,6 +56,105 @@ function extractChapterLines(
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Word-level similarity (0..1) to match an edited block back to a suggestion. */
|
|
|
|
|
function wordSimilarity(a: string, b: string): number {
|
|
|
|
|
const wordsA = a
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.map((w) => w.replace(/[^a-z0-9]/g, ""))
|
|
|
|
|
.filter((w) => w.length > 2);
|
|
|
|
|
const wordsB = b
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.map((w) => w.replace(/[^a-z0-9]/g, ""))
|
|
|
|
|
.filter((w) => w.length > 2);
|
|
|
|
|
if (wordsA.length === 0 || wordsB.length === 0) return 0;
|
|
|
|
|
const setB = new Set(wordsB);
|
|
|
|
|
let shared = 0;
|
|
|
|
|
for (const w of wordsA) {
|
|
|
|
|
if (setB.has(w)) shared += 1;
|
|
|
|
|
}
|
|
|
|
|
return shared / Math.max(wordsA.length, wordsB.length);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Re-anchor a suggestion's 1-based lineNumber when blocks are inserted,
|
|
|
|
|
* deleted, or edited in the inline editor.
|
|
|
|
|
*/
|
|
|
|
|
function resolveAdjustedLine(
|
|
|
|
|
sg: AISuggestion,
|
|
|
|
|
currentLines: ChapterLine[],
|
|
|
|
|
prevLines: ChapterLine[],
|
|
|
|
|
): { lineNumber: number; originalText?: string } | null {
|
|
|
|
|
if (currentLines.length === 0) return null;
|
|
|
|
|
const oldLine = sg.lineNumber ?? 1;
|
|
|
|
|
const orig = (sg.originalText ?? "").trim();
|
|
|
|
|
|
|
|
|
|
if (orig) {
|
|
|
|
|
// 1. Exact match closest to oldLine
|
|
|
|
|
const exactMatches = currentLines.filter((l) => l.text === orig);
|
|
|
|
|
if (exactMatches.length > 0) {
|
|
|
|
|
exactMatches.sort(
|
|
|
|
|
(a, b) => Math.abs(a.lineNumber - oldLine) - Math.abs(b.lineNumber - oldLine),
|
|
|
|
|
);
|
|
|
|
|
const best = exactMatches[0]!;
|
|
|
|
|
if (best.lineNumber !== oldLine) {
|
|
|
|
|
return { lineNumber: best.lineNumber };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Substring or high word-similarity match (if the user edited the line itself or shifted it)
|
|
|
|
|
let bestCandidate: { line: ChapterLine; score: number } | null = null;
|
|
|
|
|
for (const l of currentLines) {
|
|
|
|
|
if (!l.text) continue;
|
|
|
|
|
const contains =
|
|
|
|
|
(orig.length >= 12 && l.text.includes(orig)) ||
|
|
|
|
|
(l.text.length >= 12 && orig.includes(l.text));
|
|
|
|
|
const sim = contains ? 0.92 : wordSimilarity(orig, l.text);
|
|
|
|
|
if (sim >= 0.6) {
|
|
|
|
|
const distancePenalty = Math.min(0.15, Math.abs(l.lineNumber - oldLine) * 0.02);
|
|
|
|
|
const score = sim - distancePenalty;
|
|
|
|
|
if (!bestCandidate || score > bestCandidate.score) {
|
|
|
|
|
bestCandidate = { line: l, score };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (bestCandidate) {
|
|
|
|
|
const changedLine = bestCandidate.line.lineNumber !== oldLine;
|
|
|
|
|
const changedText = bestCandidate.line.text !== orig;
|
|
|
|
|
if (changedLine || changedText) {
|
|
|
|
|
return {
|
|
|
|
|
lineNumber: bestCandidate.line.lineNumber,
|
|
|
|
|
originalText: bestCandidate.line.text,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Fallback: if block count changed above oldLine, shift by delta
|
|
|
|
|
const delta = currentLines.length - prevLines.length;
|
|
|
|
|
if (delta !== 0 && prevLines.length > 0) {
|
|
|
|
|
let firstDiffLine = prevLines.length + 1;
|
|
|
|
|
const minLen = Math.min(currentLines.length, prevLines.length);
|
|
|
|
|
for (let i = 0; i < minLen; i++) {
|
|
|
|
|
if (currentLines[i]!.text !== prevLines[i]!.text) {
|
|
|
|
|
firstDiffLine = i + 1;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (oldLine >= firstDiffLine) {
|
|
|
|
|
const shifted = Math.max(1, Math.min(currentLines.length, oldLine + delta));
|
|
|
|
|
if (shifted !== oldLine) {
|
|
|
|
|
return { lineNumber: shifted };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ParsedLineSuggestion {
|
|
|
|
|
lineNumber: number;
|
|
|
|
|
kind: SuggestionKind;
|
|
|
|
@@ -100,10 +205,6 @@ function parseSuggestionBlocks(
|
|
|
|
|
return { cleanText, parsed };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Polish a line of prose according to the agent role so every generated line
|
|
|
|
|
* suggestion offers a concrete, meaningful edit for that exact line number.
|
|
|
|
|
*/
|
|
|
|
|
function craftRevisedLine(original: string, role: string, lineNumber: number): {
|
|
|
|
|
title: string;
|
|
|
|
|
newText: string;
|
|
|
|
@@ -118,7 +219,6 @@ function craftRevisedLine(original: string, role: string, lineNumber: number): {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tighten common filter phrases if present
|
|
|
|
|
let tightened = trimmed
|
|
|
|
|
.replace(/\bShe pressed her palm against the cold window and watched the lighthouse appear out of the mist\b/i, "Her palm met the icy glass as the lighthouse cut through the mist")
|
|
|
|
|
.replace(/\bit seemed to have shrunk, or perhaps she had simply grown too large for the memory of it\b/i, "the tower had shrunk—or she had finally outgrown the memory of it")
|
|
|
|
@@ -181,7 +281,7 @@ function buildLineBasedFallback(opts: {
|
|
|
|
|
chapterTitle: string;
|
|
|
|
|
chapterLines: ChapterLine[];
|
|
|
|
|
characterNames: string[];
|
|
|
|
|
}): { replyText: string; suggestions: ParsedLineSuggestion[] } {
|
|
|
|
|
}): { replyText: string; suggestions: ParsedLineSuggestion[]; thinkingText: string } {
|
|
|
|
|
const {
|
|
|
|
|
agentName,
|
|
|
|
|
agentRole,
|
|
|
|
@@ -193,7 +293,6 @@ function buildLineBasedFallback(opts: {
|
|
|
|
|
} = opts;
|
|
|
|
|
|
|
|
|
|
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
|
|
|
|
|
// Check if user asked about a specific line number, e.g. "line 3"
|
|
|
|
|
const requestedLineMatch = userPrompt.match(/\bline\s*(\d+)\b/i);
|
|
|
|
|
const requestedLineNum = requestedLineMatch ? parseInt(requestedLineMatch[1]!, 10) : null;
|
|
|
|
|
|
|
|
|
@@ -219,6 +318,8 @@ function buildLineBasedFallback(opts: {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast";
|
|
|
|
|
const thinkingText = `Scanning ${chapterLines.length} numbered lines in "${chapterTitle}" → cross-checking entities (${cast}) → evaluating ${targetLines.map((l) => `[Line ${l.lineNumber}]`).join(", ")} for ${agentRole} improvements.`;
|
|
|
|
|
|
|
|
|
|
const bulletLines = suggestions
|
|
|
|
|
.map(
|
|
|
|
|
(s) =>
|
|
|
|
@@ -237,7 +338,7 @@ function buildLineBasedFallback(opts: {
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join("\n\n");
|
|
|
|
|
|
|
|
|
|
return { replyText, suggestions };
|
|
|
|
|
return { replyText, suggestions, thinkingText };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AISidebarProps {
|
|
|
|
@@ -285,10 +386,25 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
const [draftEdits, setDraftEdits] = useState<Record<string, string>>({});
|
|
|
|
|
const [input, setInput] = useState("");
|
|
|
|
|
const [sending, setSending] = useState(false);
|
|
|
|
|
const [thinkingStage, setThinkingStage] = useState<string>("");
|
|
|
|
|
const [thinkingTrace, setThinkingTrace] = useState<string>("");
|
|
|
|
|
const [thinkingElapsedMs, setThinkingElapsedMs] = useState<number>(0);
|
|
|
|
|
const [reloading, setReloading] = useState(false);
|
|
|
|
|
const [newNoteKind, setNewNoteKind] = useState<NoteKind>("general");
|
|
|
|
|
const [newNoteBody, setNewNoteBody] = useState("");
|
|
|
|
|
|
|
|
|
|
// Resizable sidebar state with snap-to-default (370px)
|
|
|
|
|
const storedWidth = s.aiSidebarWidth || DEFAULT_AI_SIDEBAR_WIDTH;
|
|
|
|
|
const [sidebarWidth, setSidebarWidth] = useState<number>(storedWidth);
|
|
|
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
|
|
|
const [isSnapped, setIsSnapped] = useState(
|
|
|
|
|
Math.abs(storedWidth - DEFAULT_AI_SIDEBAR_WIDTH) <= 1,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Telemetry stats: tokens/sec (t/s) & last output token count
|
|
|
|
|
const [tokensPerSec, setTokensPerSec] = useState<number | null>(null);
|
|
|
|
|
const [lastOutputTokens, setLastOutputTokens] = useState<number>(0);
|
|
|
|
|
|
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
const activeChapter = useMemo(
|
|
|
|
@@ -301,6 +417,95 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
[activeChapter?.doc],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Keep previous lines snapshot per chapter to adjust suggestion line numbers when inline edits happen
|
|
|
|
|
const prevChapterLinesRef = useRef<{ chapterId: string | null; lines: ChapterLine[] }>({
|
|
|
|
|
chapterId: null,
|
|
|
|
|
lines: [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const currentChapterId = activeChapter?.id ?? null;
|
|
|
|
|
const prev = prevChapterLinesRef.current;
|
|
|
|
|
|
|
|
|
|
if (prev.chapterId === currentChapterId && prev.lines.length > 0 && suggestions.length > 0) {
|
|
|
|
|
let anyChanged = false;
|
|
|
|
|
const updatedList = suggestions.map((sg) => {
|
|
|
|
|
if (sg.status !== "pending") return sg;
|
|
|
|
|
if (sg.chapterId && sg.chapterId !== currentChapterId) return sg;
|
|
|
|
|
const adjusted = resolveAdjustedLine(sg, chapterLines, prev.lines);
|
|
|
|
|
if (!adjusted) return sg;
|
|
|
|
|
anyChanged = true;
|
|
|
|
|
const nextSg: AISuggestion = {
|
|
|
|
|
...sg,
|
|
|
|
|
lineNumber: adjusted.lineNumber,
|
|
|
|
|
...(adjusted.originalText ? { originalText: adjusted.originalText } : {}),
|
|
|
|
|
};
|
|
|
|
|
if (aiStore && activeSessionId) {
|
|
|
|
|
void aiStore.updateSuggestion(activeSessionId, sg.id, {
|
|
|
|
|
lineNumber: adjusted.lineNumber,
|
|
|
|
|
...(adjusted.originalText ? { originalText: adjusted.originalText } : {}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (highlightedLine != null && highlightedLine === sg.lineNumber) {
|
|
|
|
|
setHighlightedLine(adjusted.lineNumber);
|
|
|
|
|
}
|
|
|
|
|
return nextSg;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (anyChanged) {
|
|
|
|
|
setSuggestions(updatedList);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
prevChapterLinesRef.current = {
|
|
|
|
|
chapterId: currentChapterId,
|
|
|
|
|
lines: chapterLines,
|
|
|
|
|
};
|
|
|
|
|
}, [chapterLines, activeChapter?.id, suggestions, aiStore, activeSessionId, highlightedLine, setHighlightedLine]);
|
|
|
|
|
|
|
|
|
|
// Sidebar drag-resize handler with snap-to-default (370px)
|
|
|
|
|
const handleStartResize = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
|
|
|
if (fullPage) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const startX = e.clientX;
|
|
|
|
|
const startWidth = sidebarWidth;
|
|
|
|
|
setIsResizing(true);
|
|
|
|
|
|
|
|
|
|
let latestWidth = startWidth;
|
|
|
|
|
const onMouseMove = (moveEvt: MouseEvent) => {
|
|
|
|
|
const deltaX = startX - moveEvt.clientX;
|
|
|
|
|
const rawWidth = Math.max(
|
|
|
|
|
MIN_AI_SIDEBAR_WIDTH,
|
|
|
|
|
Math.min(MAX_AI_SIDEBAR_WIDTH, startWidth + deltaX),
|
|
|
|
|
);
|
|
|
|
|
if (Math.abs(rawWidth - DEFAULT_AI_SIDEBAR_WIDTH) <= SNAP_THRESHOLD_PX) {
|
|
|
|
|
latestWidth = DEFAULT_AI_SIDEBAR_WIDTH;
|
|
|
|
|
setIsSnapped(true);
|
|
|
|
|
} else {
|
|
|
|
|
latestWidth = rawWidth;
|
|
|
|
|
setIsSnapped(false);
|
|
|
|
|
}
|
|
|
|
|
setSidebarWidth(latestWidth);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onMouseUp = () => {
|
|
|
|
|
setIsResizing(false);
|
|
|
|
|
setSettings({ aiSidebarWidth: latestWidth });
|
|
|
|
|
window.removeEventListener("mousemove", onMouseMove);
|
|
|
|
|
window.removeEventListener("mouseup", onMouseUp);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener("mousemove", onMouseMove);
|
|
|
|
|
window.addEventListener("mouseup", onMouseUp);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleResetWidth = () => {
|
|
|
|
|
setSidebarWidth(DEFAULT_AI_SIDEBAR_WIDTH);
|
|
|
|
|
setIsSnapped(true);
|
|
|
|
|
setSettings({ aiSidebarWidth: DEFAULT_AI_SIDEBAR_WIDTH });
|
|
|
|
|
toast.info(`Snapped AI sidebar to default width (${DEFAULT_AI_SIDEBAR_WIDTH}px)`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const activeProvider = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
s.aiProviders.find((p) => p.id === s.activeProviderId) ??
|
|
|
|
@@ -318,6 +523,38 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
const activeModel =
|
|
|
|
|
s.activeModel || activeProvider?.defaultModel || "llama3.2";
|
|
|
|
|
|
|
|
|
|
// Live context token calculation
|
|
|
|
|
const contextStats = useMemo(() => {
|
|
|
|
|
const sysTokens = countTokens(activeAgent.systemPrompt);
|
|
|
|
|
const chapterTokens = countTokens(
|
|
|
|
|
chapterLines.map((l) => `[Line ${l.lineNumber}] ${l.text}`).join("\n"),
|
|
|
|
|
);
|
|
|
|
|
const entitiesTokens = countTokens(
|
|
|
|
|
entities.map((ent) => ("name" in ent ? ent.name : ent.body)).join(", "),
|
|
|
|
|
);
|
|
|
|
|
const historyTokens = countTokens(
|
|
|
|
|
messages.map((m) => m.content).join("\n"),
|
|
|
|
|
);
|
|
|
|
|
const draftInputTokens = countTokens(input);
|
|
|
|
|
const used = sysTokens + chapterTokens + entitiesTokens + historyTokens + draftInputTokens;
|
|
|
|
|
const total = activeAgent.contextWindow || 32768;
|
|
|
|
|
const pct = Math.min(100, (used / total) * 100);
|
|
|
|
|
return { used, total, pct };
|
|
|
|
|
}, [activeAgent.systemPrompt, activeAgent.contextWindow, chapterLines, entities, messages, input]);
|
|
|
|
|
|
|
|
|
|
// Thinking elapsed timer while sending
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!sending) {
|
|
|
|
|
setThinkingElapsedMs(0);
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
const started = performance.now();
|
|
|
|
|
const timer = window.setInterval(() => {
|
|
|
|
|
setThinkingElapsedMs(Math.round(performance.now() - started));
|
|
|
|
|
}, 100);
|
|
|
|
|
return () => window.clearInterval(timer);
|
|
|
|
|
}, [sending]);
|
|
|
|
|
|
|
|
|
|
const visibleSessions = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
sessions.filter((sess) =>
|
|
|
|
@@ -344,7 +581,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
[suggestions],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Keep focusedIndex in bounds of pendingSuggestions
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (focusedIndex >= pendingSuggestions.length) {
|
|
|
|
|
setFocusedIndex(Math.max(0, pendingSuggestions.length - 1));
|
|
|
|
@@ -353,14 +589,12 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
|
|
|
|
|
const focusedSuggestion = pendingSuggestions[focusedIndex] ?? null;
|
|
|
|
|
|
|
|
|
|
// Automatically highlight the focused suggestion's line when viewing the Suggestions tab
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (activeTab === "suggestions" && focusedSuggestion?.lineNumber) {
|
|
|
|
|
setHighlightedLine(focusedSuggestion.lineNumber);
|
|
|
|
|
}
|
|
|
|
|
}, [activeTab, focusedSuggestion?.id, focusedSuggestion?.lineNumber, setHighlightedLine]);
|
|
|
|
|
|
|
|
|
|
// Clear highlight when unmounting
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => setHighlightedLine(null);
|
|
|
|
|
}, [setHighlightedLine]);
|
|
|
|
@@ -402,7 +636,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
top: scrollRef.current.scrollHeight,
|
|
|
|
|
behavior: "smooth",
|
|
|
|
|
});
|
|
|
|
|
}, [messages]);
|
|
|
|
|
}, [messages, sending, thinkingStage]);
|
|
|
|
|
|
|
|
|
|
const handleLoadReload = async () => {
|
|
|
|
|
setReloading(true);
|
|
|
|
@@ -474,7 +708,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
setActiveSessionId(sessionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const startTime = performance.now();
|
|
|
|
|
setSending(true);
|
|
|
|
|
setThinkingStage(
|
|
|
|
|
`Reading ${chapterLines.length} numbered line${chapterLines.length === 1 ? "" : "s"} in "${activeChapter?.title ?? "Chapter"}"…`,
|
|
|
|
|
);
|
|
|
|
|
setThinkingTrace("");
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await aiStore.addMessage(sessionId, {
|
|
|
|
|
role: "user",
|
|
|
|
@@ -509,6 +749,10 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
let assistantReply = "";
|
|
|
|
|
let parsedSuggestions: ParsedLineSuggestion[] = [];
|
|
|
|
|
|
|
|
|
|
setThinkingStage(
|
|
|
|
|
`${activeAgent.name} (${activeModel}) is analyzing lines & reasoning through prompt…`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const controller = new AgentController({
|
|
|
|
|
id: activeProvider.id,
|
|
|
|
@@ -548,12 +792,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
contextText,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
if (result.reasoning) {
|
|
|
|
|
setThinkingTrace(result.reasoning);
|
|
|
|
|
}
|
|
|
|
|
const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines);
|
|
|
|
|
assistantReply = extracted.cleanText;
|
|
|
|
|
parsedSuggestions = extracted.parsed;
|
|
|
|
|
|
|
|
|
|
// If the model responded without explicit <<<SUGGESTION blocks, still
|
|
|
|
|
// generate concrete line-numbered suggestions for any [Line N] it cited!
|
|
|
|
|
if (parsedSuggestions.length === 0 && chapterLines.length > 0) {
|
|
|
|
|
const fallback = buildLineBasedFallback({
|
|
|
|
|
agentName: activeAgent.name,
|
|
|
|
@@ -576,6 +821,10 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
chapterLines,
|
|
|
|
|
characterNames,
|
|
|
|
|
});
|
|
|
|
|
setThinkingTrace(fallback.thinkingText);
|
|
|
|
|
setThinkingStage("Formulating line-numbered suggestions…");
|
|
|
|
|
// Ensure a visible thinking phase so the user sees the model working through the prompt
|
|
|
|
|
await new Promise((r) => setTimeout(r, 650));
|
|
|
|
|
assistantReply = fallback.replyText;
|
|
|
|
|
parsedSuggestions = fallback.suggestions;
|
|
|
|
|
}
|
|
|
|
@@ -602,6 +851,14 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const elapsedSec = Math.max(0.08, (performance.now() - startTime) / 1000);
|
|
|
|
|
const outTok = countTokens(
|
|
|
|
|
assistantReply +
|
|
|
|
|
parsedSuggestions.map((s) => `${s.title} ${s.newText} ${s.summary}`).join(" "),
|
|
|
|
|
);
|
|
|
|
|
setLastOutputTokens(outTok);
|
|
|
|
|
setTokensPerSec(Number((outTok / elapsedSec).toFixed(1)));
|
|
|
|
|
|
|
|
|
|
setMessages(await aiStore.listMessages(sessionId));
|
|
|
|
|
const updatedSuggestions = await aiStore.listSuggestions(sessionId);
|
|
|
|
|
setSuggestions(updatedSuggestions);
|
|
|
|
@@ -612,6 +869,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setSending(false);
|
|
|
|
|
setThinkingStage("");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
@@ -631,7 +889,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
setFocusedIndex(0);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Accept: applies the edit to the line on the writing screen and removes the suggestion from pending
|
|
|
|
|
const handleAcceptSuggestion = async (sg: AISuggestion) => {
|
|
|
|
|
if (!aiStore || !activeSessionId) return;
|
|
|
|
|
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
|
|
|
@@ -655,7 +912,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Deny: removes the suggestion from the suggestions list
|
|
|
|
|
const handleDenySuggestion = async (sg: AISuggestion) => {
|
|
|
|
|
if (!aiStore || !activeSessionId) return;
|
|
|
|
|
await aiStore.deleteSuggestion(activeSessionId, sg.id);
|
|
|
|
@@ -667,7 +923,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
toast.info(`Denied suggestion for Line ${sg.lineNumber ?? "?"}`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Copy to Note: places the suggestion into the Notes section of the chat
|
|
|
|
|
const handleCopySuggestionToNote = async (sg: AISuggestion) => {
|
|
|
|
|
if (!aiStore || !activeSessionId) return;
|
|
|
|
|
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
|
|
|
@@ -712,7 +967,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Render message text with clickable [Line N] badges that highlight that line in the editor
|
|
|
|
|
const renderMessageWithLineLinks = (content: string) => {
|
|
|
|
|
const parts = content.split(/(\[Line\s+\d+\])/gi);
|
|
|
|
|
return parts.map((part, i) => {
|
|
|
|
@@ -737,10 +991,24 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<aside className={fullPage ? "ps-ai-fullpage" : "ps-ai-sidebar"} aria-label="AI Co-Writing Sidebar">
|
|
|
|
|
<aside
|
|
|
|
|
className={fullPage ? "ps-ai-fullpage" : `ps-ai-sidebar${isResizing ? " is-resizing" : ""}`}
|
|
|
|
|
style={!fullPage ? { width: `${sidebarWidth}px` } : undefined}
|
|
|
|
|
aria-label="AI Co-Writing Sidebar"
|
|
|
|
|
>
|
|
|
|
|
{/* Left-edge drag handle with snap-to-default (370px) */}
|
|
|
|
|
{!fullPage && (
|
|
|
|
|
<div
|
|
|
|
|
className={`ps-ai-resize-handle${isSnapped ? " is-snapped" : ""}`}
|
|
|
|
|
title={`Drag to resize AI sidebar · Snaps to ${DEFAULT_AI_SIDEBAR_WIDTH}px default · Double-click to reset`}
|
|
|
|
|
onMouseDown={handleStartResize}
|
|
|
|
|
onDoubleClick={handleResetWidth}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="ps-ai-sidebar-head">
|
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
|
|
|
|
<span style={{ fontSize: 16 }}>✨</span>
|
|
|
|
|
<strong style={{ fontSize: 14 }}>AI Studio</strong>
|
|
|
|
|
<Badge variant="accent">{activeAgent.name}</Badge>
|
|
|
|
@@ -980,7 +1248,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
{activeTab === "chat" && (
|
|
|
|
|
<div className="ps-ai-chat-body">
|
|
|
|
|
<div ref={scrollRef} className="ps-ai-messages">
|
|
|
|
|
{messages.length === 0 ? (
|
|
|
|
|
{messages.length === 0 && !sending ? (
|
|
|
|
|
<div className="ps-ai-empty">
|
|
|
|
|
<div style={{ fontSize: 24, marginBottom: 6 }}>✨</div>
|
|
|
|
|
<div style={{ fontWeight: 600, color: "var(--ps-text)" }}>
|
|
|
|
@@ -1013,6 +1281,29 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Live Thinking Indicator */}
|
|
|
|
|
{sending && (
|
|
|
|
|
<div className="ps-ai-thinking" role="status" aria-live="polite">
|
|
|
|
|
<div className="ps-ai-thinking-head">
|
|
|
|
|
<span className="ps-ai-thinking-dots" aria-hidden="true">
|
|
|
|
|
<span />
|
|
|
|
|
<span />
|
|
|
|
|
<span />
|
|
|
|
|
</span>
|
|
|
|
|
<strong>{activeAgent.name} is thinking…</strong>
|
|
|
|
|
<span className="ps-ai-thinking-timer">
|
|
|
|
|
{(thinkingElapsedMs / 1000).toFixed(1)}s
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="ps-ai-thinking-stage">
|
|
|
|
|
{thinkingStage || "Working through prompt…"}
|
|
|
|
|
</div>
|
|
|
|
|
{thinkingTrace && (
|
|
|
|
|
<div className="ps-ai-thinking-trace">{thinkingTrace}</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={(e) => void handleSendMessage(e)} className="ps-ai-composer">
|
|
|
|
@@ -1064,7 +1355,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* SUGGESTIONS TAB: Step through each line suggestion with Accept / Deny / Copy to Note */}
|
|
|
|
|
{/* SUGGESTIONS TAB */}
|
|
|
|
|
{activeTab === "suggestions" && (
|
|
|
|
|
<div className="ps-ai-chat-body">
|
|
|
|
|
{pendingSuggestions.length > 0 && (
|
|
|
|
@@ -1103,7 +1394,26 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="ps-ai-messages">
|
|
|
|
|
{pendingSuggestions.length === 0 ? (
|
|
|
|
|
{sending && (
|
|
|
|
|
<div className="ps-ai-thinking" role="status" aria-live="polite">
|
|
|
|
|
<div className="ps-ai-thinking-head">
|
|
|
|
|
<span className="ps-ai-thinking-dots" aria-hidden="true">
|
|
|
|
|
<span />
|
|
|
|
|
<span />
|
|
|
|
|
<span />
|
|
|
|
|
</span>
|
|
|
|
|
<strong>{activeAgent.name} is thinking…</strong>
|
|
|
|
|
<span className="ps-ai-thinking-timer">
|
|
|
|
|
{(thinkingElapsedMs / 1000).toFixed(1)}s
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="ps-ai-thinking-stage">
|
|
|
|
|
{thinkingStage || "Generating line-by-line suggestions…"}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{pendingSuggestions.length === 0 && !sending ? (
|
|
|
|
|
<div className="ps-ai-empty">
|
|
|
|
|
<div style={{ marginBottom: 8 }}>No pending line suggestions yet.</div>
|
|
|
|
|
<Button
|
|
|
|
@@ -1112,7 +1422,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
onClick={() => void handleGenerateLineSuggestions()}
|
|
|
|
|
disabled={sending}
|
|
|
|
|
>
|
|
|
|
|
{sending ? "Analyzing Lines…" : "✨ Generate Line Suggestions"}
|
|
|
|
|
✨ Generate Line Suggestions
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
@@ -1270,6 +1580,38 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Non-intrusive Context & t/s Telemetry Footer */}
|
|
|
|
|
<div className="ps-ai-telemetry-bar">
|
|
|
|
|
<div
|
|
|
|
|
className="ps-ai-telemetry-ctx"
|
|
|
|
|
title={`Context usage: ${contextStats.used.toLocaleString()} of ${contextStats.total.toLocaleString()} tokens (${contextStats.pct.toFixed(1)}%)`}
|
|
|
|
|
>
|
|
|
|
|
<span>
|
|
|
|
|
ctx {(contextStats.used / 1000).toFixed(1)}k / {(contextStats.total / 1024).toFixed(0)}k ({contextStats.pct.toFixed(0)}%)
|
|
|
|
|
</span>
|
|
|
|
|
<span className="ps-ai-telemetry-meter">
|
|
|
|
|
<span
|
|
|
|
|
className="ps-ai-telemetry-meter-fill"
|
|
|
|
|
style={{ width: `${Math.max(3, contextStats.pct)}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className="ps-ai-telemetry-speed"
|
|
|
|
|
title={
|
|
|
|
|
tokensPerSec != null
|
|
|
|
|
? `Throughput: ${tokensPerSec} tokens/sec (${lastOutputTokens} output tokens)`
|
|
|
|
|
: "Throughput (tokens/sec) measured on generation"
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{isResizing
|
|
|
|
|
? `${sidebarWidth}px${isSnapped ? " · snapped" : ""}`
|
|
|
|
|
: tokensPerSec != null
|
|
|
|
|
? `${tokensPerSec} t/s · ${lastOutputTokens} tok`
|
|
|
|
|
: "— t/s"}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|