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
CI / build (push) Waiting to run
Closes #1 Closes #2 Closes #3 Closes #4 Closes #5
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user