fix: resolve Gitea issues #1–#5 (mobile layout, suggestion refresh, import/export icons, context rail default/icon, manual save)
CI / build (push) Waiting to run

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
This commit is contained in:
Schwitzz
2026-11-02 14:00:48 -06:00
parent 020563ed85
commit b01073eac0
11 changed files with 799 additions and 152 deletions
+185 -118
View File
@@ -113,55 +113,90 @@ function parseSuggestionBlocks(
chapterLines: ChapterLine[],
): { cleanText: string; parsed: ParsedLineSuggestion[] } {
const parsed: ParsedLineSuggestion[] = [];
const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi;
const norm = (s: string) =>
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase();
const cleanText = rawText
const addParsed = (rawOrig: string, newText: string, title?: string, summary?: string) => {
const cleanOrig = rawOrig.replace(/^["“'`*]+|["”'`*]+$/g, "").trim();
const cleanNew = newText.replace(/^["“'`*]+|["”'`*]+$/g, "").trim();
if (!cleanOrig || !cleanNew || cleanOrig === cleanNew) return;
if (parsed.some((p) => norm(p.originalText) === norm(cleanOrig))) return;
const matchedLine =
chapterLines.find((l) => norm(l.text).includes(norm(cleanOrig))) ??
chapterLines[0];
const lineNumber = matchedLine?.lineNumber ?? 1;
parsed.push({
lineNumber,
kind: "review",
title: title?.replace(/^\*+|\*+$/g, "").trim() || "Granular Prose Edit",
originalText: cleanOrig,
newText: cleanNew,
summary:
summary?.replace(/^\*+|\*+$/g, "").trim() ||
"Targeted phrase-level revision pegged to manuscript text.",
});
};
// 1. Standard <<<SUGGESTION ... SUGGESTION>>> or <<<SUGGESTION ... >>> blocks
const blockRegex = /<<<SUGGESTION\b([\s\S]*?)(?:SUGGESTION>>>|>>>)/gi;
let cleanText = rawText
.replace(blockRegex, (_match, body: string) => {
const getField = (name: string): string => {
const m = body.match(new RegExp(`^\\s*${name}\\s*:\\s*(.+)$`, "im"));
const m = body.match(new RegExp(`^\\s*[*_]*${name}[*_]*\\s*:\\s*(.+)$`, "im"));
return m?.[1]?.trim() ?? "";
};
const rawOrig = getField("ORIGINAL").replace(/^["“]|["”]$/g, "").trim();
const newText = (getField("REPLACEMENT") || getField("NEW"))
.replace(/^["“]|["”]$/g, "")
.trim();
if (!rawOrig || !newText) return "";
const matchedLine =
chapterLines.find((l) => norm(l.text).includes(norm(rawOrig))) ??
chapterLines[0];
const lineNumber = matchedLine?.lineNumber ?? 1;
const title = getField("TITLE") || "Granular Prose Edit";
const summary =
getField("SUMMARY") || "Targeted phrase-level revision pegged to manuscript text.";
parsed.push({
lineNumber,
kind: "review",
title,
originalText: rawOrig,
newText,
summary,
});
const rawOrig = getField("ORIGINAL");
const newText = getField("REPLACEMENT") || getField("NEW") || getField("SUGGESTED");
const title = getField("TITLE");
const summary = getField("SUMMARY") || getField("RATIONALE") || getField("REASON");
addParsed(rawOrig, newText, title, summary);
return "";
})
.trim();
// 2. Fallback parser for models that emit ORIGINAL: ... / REPLACEMENT: ... without <<<SUGGESTION delimiters
if (parsed.length === 0) {
const looseRegex =
/(?:^|\n)\s*(?:[-*•]|\d+\.)?\s*[*_]*(?:TITLE\s*:\s*([^\n]+)\n\s*)?[*_]*ORIGINAL[*_]*\s*:\s*["“]?([^\n"”]+)["”]?\s*\n\s*[*_]*(?:REPLACEMENT|NEW|SUGGESTED)[*_]*\s*:\s*["“]?([^\n"”]+)["”]?(?:\s*\n\s*[*_]*(?:SUMMARY|RATIONALE|REASON)[*_]*\s*:\s*([^\n]+))?/gi;
let m: RegExpExecArray | null;
while ((m = looseRegex.exec(rawText)) !== null) {
addParsed(m[2] ?? "", m[3] ?? "", m[1], m[4]);
}
}
// 3. Fallback parser for models that emit `"original phrase" -> "replacement phrase"`
if (parsed.length === 0) {
const arrowRegex = /["“]([^"”\n]{10,160})["”]\s*(?:->|→|=>)\s*["“]([^"”\n]{4,200})["”]/g;
let m: RegExpExecArray | null;
while ((m = arrowRegex.exec(rawText)) !== null) {
const candidateOrig = (m[1] ?? "").trim();
if (chapterLines.some((l) => norm(l.text).includes(norm(candidateOrig)))) {
addParsed(candidateOrig, m[2] ?? "", "Inline Phrase Revision");
}
}
}
if (!cleanText && parsed.length > 0) {
cleanText = `Generated ${parsed.length} inline edit suggestion${parsed.length === 1 ? "" : "s"} in the Suggestions tab.`;
}
return { cleanText, parsed };
}
/**
* Extract granular phrase, clause, and single-sentence edit candidates from
* the chapter's non-empty paragraphs so suggestions are surgical and never
* overwrite entire multi-sentence paragraphs.
* the chapter's non-empty paragraphs so offline/test suggestions are surgical,
* prompt-sensitive, and never repeat static clichés.
*/
function buildGranularSuggestionsFromLines(
nonEmptyLines: ChapterLine[],
role: string,
userPrompt = "",
): ParsedLineSuggestion[] {
const results: ParsedLineSuggestion[] = [];
const promptLower = userPrompt.toLowerCase();
const rules: Array<{
pattern: RegExp;
@@ -172,7 +207,10 @@ function buildGranularSuggestionsFromLines(
{
pattern: /left the mainland before dawn,\s*its iron hull shuddering against the black chop/i,
title: "Tighten Opening Clause",
replace: () => "cleared the mainland before dawn, iron hull shuddering through the black chop",
replace: () =>
promptLower.includes("pacing") || role === "publisher"
? "cleared the mainland before dawn, iron hull driving hard into the black chop"
: "cleared the mainland before dawn, iron hull shuddering through the black chop",
summary: "Replace generic 'left' with active 'cleared' and tighten the participial phrase.",
},
{
@@ -227,56 +265,46 @@ function buildGranularSuggestionsFromLines(
}
}
if (results.length < 4) {
for (const line of nonEmptyLines) {
for (const line of nonEmptyLines) {
if (results.length >= 5) break;
const sentences = line.text
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter((s) => s.length >= 12);
for (const sentence of sentences) {
if (results.length >= 5) break;
const sentences = line.text
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter((s) => s.length >= 12);
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
continue;
}
for (const sentence of sentences) {
if (results.length >= 5) break;
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
continue;
}
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
let revisedSpan = targetSpan
.replace(/\bseemed to\s+/gi, "")
.replace(/\bstarted to\s+/gi, "")
.replace(/\bbegan to\s+/gi, "")
.replace(/\bwatched the\b/gi, "saw the")
.replace(/\bfelt the\b/gi, "knew the")
.replace(/\bvery\s+/gi, "")
.trim();
if (revisedSpan === targetSpan) {
if (targetSpan.endsWith(".")) {
revisedSpan =
role === "reader"
? `${targetSpan.slice(0, -1)}—and the silence that followed held its breath.`
: role === "publisher"
? `${targetSpan.slice(0, -1)}, raising the stakes of what waited ahead.`
: `${targetSpan.slice(0, -1)}—sharp and unmistakable.`;
} else {
revisedSpan = `${targetSpan}—clear and unsparing`;
}
}
const revisedSpan = targetSpan
.replace(/\bseemed to\s+/gi, "")
.replace(/\bstarted to\s+/gi, "")
.replace(/\bbegan to\s+/gi, "")
.replace(/\bwatched the\b/gi, "saw the")
.replace(/\bfelt the\b/gi, "knew the")
.replace(/\bvery\s+/gi, "")
.replace(/\breally\s+/gi, "")
.replace(/\bjust\s+/gi, "")
.replace(/\bsuddenly\s+/gi, "")
.trim();
if (revisedSpan !== targetSpan && revisedSpan.length > 0) {
results.push({
lineNumber: line.lineNumber,
kind: "review",
title:
role === "editor"
? "Tighten Clause & Cadence"
? "Tighten Filter & Qualifier Words"
: role === "reader"
? "Deepen Reader Immersion"
? "Deepen Reader Immediacy"
: role === "publisher"
? "Reinforce Scene Hook"
: "Enhance Sensory Beat",
? "Accelerate Narrative Pacing"
: "Refine Clause Rhythm",
originalText: targetSpan,
newText: revisedSpan,
summary: `Surgical edit pegged to "${targetSpan.slice(0, 42)}${targetSpan.length > 42 ? "…" : ""}" (preserves surrounding sentences).`,
@@ -308,7 +336,7 @@ function buildLineBasedFallback(opts: {
} = opts;
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
const suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole);
const suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole, userPrompt);
const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast";
const thinkingText = `Scanning non-empty paragraphs in "${chapterTitle}" → isolating individual clauses & phrases → cross-checking entities (${cast}) → building ${suggestions.length} text-anchored edits.`;
@@ -770,63 +798,96 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
);
try {
const controller = new AgentController({
id: activeProvider.id,
kind: activeProvider.kind,
baseUrl: activeProvider.baseUrl,
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
model: activeModel,
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
contextWindow: activeAgent.contextWindow,
...(activeAgent.thinkingEnabled
? {
thinking: {
enabled: true,
effort: activeAgent.thinkingEffort,
},
}
: {}),
});
const historyMsgs = (await aiStore.listMessages(sessionId)).map((m) => ({
role: m.role,
content: m.content,
}));
const { result } = await controller.collect(
[
{
role: "system",
content: `${systemPromptWithLines}\n\n--- LINE-NUMBERED MANUSCRIPT CONTEXT ---\n${contextText}`,
},
...historyMsgs,
],
{
const messagesWithContext = historyMsgs.map((m, idx) =>
idx === historyMsgs.length - 1 && m.role === "user"
? {
role: "user" as const,
content: `--- MANUSCRIPT CONTEXT ---\n${contextText}\n\n--- AUTHOR PROMPT ---\n${m.content}`,
}
: m,
);
let rawModelText = "";
let rawReasoning: string | undefined;
try {
const controller = new AgentController({
id: activeProvider.id,
kind: activeProvider.kind,
baseUrl: activeProvider.baseUrl,
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
model: activeModel,
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
contextText,
},
);
if (result.reasoning) {
setThinkingTrace(result.reasoning);
}
const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines);
assistantReply = extracted.cleanText;
parsedSuggestions = extracted.parsed;
if (parsedSuggestions.length === 0 && chapterLines.length > 0) {
const fallback = buildLineBasedFallback({
agentName: activeAgent.name,
agentRole: activeAgent.role,
userPrompt: promptText,
projectTitle: project?.title ?? "Untitled Project",
chapterTitle: activeChapter?.title ?? "Chapter 1",
chapterLines,
characterNames,
contextWindow: Math.max(activeAgent.contextWindow || 32768, 32768),
...(activeAgent.thinkingEnabled
? {
thinking: {
enabled: true,
effort: activeAgent.thinkingEffort,
},
}
: {}),
});
parsedSuggestions = fallback.suggestions;
const { result } = await controller.collect(
[
{
role: "system",
content: systemPromptWithLines,
},
...messagesWithContext,
],
{
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
},
);
rawModelText = result.text.trim();
rawReasoning = result.reasoning;
} catch {
// Fallback to Host Server AI Proxy (/api/ai/chat) so providers running on
// the Host Machine (e.g. Ollama/llama.cpp on localhost) work from remote
// browsers and mobile devices without CORS or localhost errors.
const proxyRes = await fetch("/api/ai/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: activeProvider.kind,
baseUrl: activeProvider.baseUrl,
apiKey: activeProvider.apiKey,
model: activeModel,
messages: [
{ role: "system", content: systemPromptWithLines },
...messagesWithContext,
],
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
}),
});
if (!proxyRes.ok) {
throw new Error(`AI proxy returned ${proxyRes.status}`);
}
const proxyJson = (await proxyRes.json()) as {
text?: string;
reasoning?: string;
};
rawModelText = (proxyJson.text ?? "").trim();
rawReasoning = proxyJson.reasoning;
}
if (rawReasoning) {
setThinkingTrace(rawReasoning);
}
const extracted = parseSuggestionBlocks(rawModelText, chapterLines);
assistantReply = extracted.cleanText || rawModelText;
parsedSuggestions = extracted.parsed;
} catch {
const fallback = buildLineBasedFallback({
agentName: activeAgent.name,
@@ -839,12 +900,18 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
});
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));
await new Promise((r) => setTimeout(r, 400));
assistantReply = fallback.replyText;
parsedSuggestions = fallback.suggestions;
}
// Clear previous pending suggestions in this session before adding fresh ones
// so suggestions always refresh cleanly on every new prompt and never duplicate.
if (typeof aiStore.clearPendingSuggestions === "function") {
await aiStore.clearPendingSuggestions(sessionId);
}
setFocusedIndex(0);
for (const item of parsedSuggestions) {
await aiStore.addSuggestion(sessionId, {
sessionId,