Add continuity context and update proposals
This commit is contained in:
169
prototype/app.py
169
prototype/app.py
@@ -163,6 +163,8 @@ class ScrivenerReader:
|
||||
continuity = self.child(authoring, "Continuity")
|
||||
briefs_folder = self.child(authoring, "Scene Briefs")
|
||||
summaries_folder = self.child(continuity, "Scene Summaries")
|
||||
archive = self.child(authoring, "Drafting Archive")
|
||||
snapshots_folder = self.child(archive, "Package Snapshots")
|
||||
style = self.child(setup, "STYLE")
|
||||
instructions = self.child(setup, "Frontier-Model Instructions")
|
||||
lexicon = self.child(continuity, "Master Lexicon")
|
||||
@@ -171,6 +173,21 @@ class ScrivenerReader:
|
||||
briefs = self.records(briefs_folder, self)
|
||||
brief_by_title = {brief["title"]: brief for brief in briefs}
|
||||
summary_by_title = {record["title"]: record for record in self.records(summaries_folder, self)}
|
||||
character_by_title = {character["title"]: character["uuid"] for character in self.records(self.top_level("Characters"), self)}
|
||||
package_context: dict[str, dict] = {}
|
||||
for snapshot in self.records(snapshots_folder, self):
|
||||
scene_title = re.search(r"^Scene:\s*(.+)$", snapshot["text"], re.MULTILINE)
|
||||
if not scene_title:
|
||||
continue
|
||||
title = scene_title.group(1).strip()
|
||||
text = snapshot["text"]
|
||||
uuid_line = re.search(r"^Character UUIDs:\s*(.*)$", text, re.MULTILINE)
|
||||
if uuid_line:
|
||||
character_uuids = [value for value in uuid_line.group(1).split(",") if value]
|
||||
else:
|
||||
character_block = self._package_section(text, "CHARACTERS")
|
||||
character_uuids = [character_by_title[line.strip()] for line in re.findall(r"^(.+?)\n", character_block) if line.strip() in character_by_title]
|
||||
package_context[title] = {"character_uuids": character_uuids, "notes": self._package_section(text, "NOTES")}
|
||||
|
||||
paired = []
|
||||
for scene in manuscript:
|
||||
@@ -180,6 +197,7 @@ class ScrivenerReader:
|
||||
"brief": brief_by_title.get(scene["title"], {}).get("text", ""),
|
||||
"brief_uuid": brief_by_title.get(scene["title"], {}).get("uuid"),
|
||||
"summary": summary_by_title.get(scene["title"], {}).get("text", ""),
|
||||
"package_context": package_context.get(scene["title"], {"character_uuids": [], "notes": ""}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -198,6 +216,11 @@ class ScrivenerReader:
|
||||
"unmatched_scenes": [scene for scene in manuscript if scene["title"] not in brief_by_title],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _package_section(package: str, name: str) -> str:
|
||||
match = re.search(rf"^### {re.escape(name)}\n(.*?)(?=^### |\Z)", package, re.MULTILINE | re.DOTALL)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
|
||||
class ScrivenerWriter:
|
||||
"""The small, guarded subset of Scrivener writes needed by this prototype."""
|
||||
@@ -288,7 +311,7 @@ class ScrivenerWriter:
|
||||
def _save_tree(self, tree: ET.ElementTree) -> None:
|
||||
tree.write(self.reader.project_file, encoding="UTF-8", xml_declaration=True)
|
||||
|
||||
def accept_for_editing(self, scene_uuid: str, draft_text: str, package: str, model: str) -> dict:
|
||||
def accept_for_editing(self, scene_uuid: str, draft_text: str, package: str, model: str, character_uuids: list[str]) -> dict:
|
||||
self._assert_unlocked()
|
||||
if not draft_text.strip() or not package.strip():
|
||||
raise ValueError("A non-empty draft and package are required for acceptance.")
|
||||
@@ -302,7 +325,7 @@ class ScrivenerWriter:
|
||||
stamp = dt.datetime.now().strftime("%Y-%m-%d %H%M")
|
||||
initial_uuid = self._add_text(accepted, f"{scene.title} — initial draft — {stamp}", draft_text)
|
||||
working_uuid = self._add_text(accepted, f"{scene.title} — working copy — {stamp}", draft_text)
|
||||
package_header = f"Scene: {scene.title}\nModel: {model}\nAccepted for editing: {dt.datetime.now().astimezone().isoformat(timespec='seconds')}\n\n"
|
||||
package_header = f"Scene: {scene.title}\nModel: {model}\nCharacter UUIDs: {','.join(character_uuids)}\nAccepted for editing: {dt.datetime.now().astimezone().isoformat(timespec='seconds')}\n\n"
|
||||
package_uuid = self._add_text(snapshots, f"{scene.title} — package — {stamp}", package_header + package)
|
||||
self._save_tree(tree)
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
@@ -334,6 +357,64 @@ class ScrivenerWriter:
|
||||
path.write_bytes(content)
|
||||
self._update_checksum(scene_uuid, content)
|
||||
|
||||
def save_scene_summary(self, scene_uuid: str, summary: str) -> str:
|
||||
"""Create or update the one author-approved dense summary for a scene."""
|
||||
self._assert_unlocked()
|
||||
scene = self.reader.by_uuid.get(scene_uuid)
|
||||
if scene is None or not summary.strip():
|
||||
raise ValueError("A scene and non-empty summary are required.")
|
||||
tree, binder = self._tree_and_binder()
|
||||
summaries = self._folder(binder, ["Authoring System", "Continuity", "Scene Summaries"])
|
||||
existing = self._find_child(summaries, scene.title)
|
||||
if existing is not None:
|
||||
existing_uuid = existing.attrib.get("UUID", "")
|
||||
path = self.project / "Files" / "Data" / existing_uuid / "content.rtf"
|
||||
if not existing_uuid or not path.exists():
|
||||
raise ValueError("The existing scene summary record is incomplete.")
|
||||
content = text_to_rtf(summary).encode("utf-8")
|
||||
path.write_bytes(content)
|
||||
self._update_checksum(existing_uuid, content)
|
||||
return existing_uuid
|
||||
self._backup_binder()
|
||||
summary_uuid = self._add_text(summaries, scene.title, summary)
|
||||
self._save_tree(tree)
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
return summary_uuid
|
||||
|
||||
def _append_to_item(self, item: Item, addition: str) -> bool:
|
||||
path = self.project / "Files" / "Data" / item.uuid / "content.rtf"
|
||||
existing = self.reader.content(item)
|
||||
addition = addition.strip()
|
||||
if not addition or addition in existing:
|
||||
return False
|
||||
content = text_to_rtf((existing.rstrip() + "\n\n" + addition).strip() + "\n").encode("utf-8")
|
||||
path.write_bytes(content)
|
||||
self._update_checksum(item.uuid, content)
|
||||
return True
|
||||
|
||||
def save_continuity_updates(self, scene_uuid: str, character_updates: list[dict], lexicon_additions: str, world_note_additions: str) -> dict:
|
||||
self._assert_unlocked()
|
||||
scene = self.reader.by_uuid.get(scene_uuid)
|
||||
if scene is None:
|
||||
raise ValueError("The selected scene no longer exists.")
|
||||
stamp = f"Continuity update from {scene.title}:"
|
||||
characters_folder = self.reader.top_level("Characters")
|
||||
characters = {item.uuid: item for item in (characters_folder.children if characters_folder else []) if item.item_type == "Text"}
|
||||
saved_characters = []
|
||||
for update in character_updates:
|
||||
item = characters.get(update.get("uuid", ""))
|
||||
if item is None:
|
||||
raise ValueError("A proposed character update does not target a current Character record.")
|
||||
if self._append_to_item(item, f"{stamp}\n{update.get('text', '').strip()}"):
|
||||
saved_characters.append(item.title)
|
||||
authoring = self.reader.top_level("Authoring System")
|
||||
continuity = self.reader.child(authoring, "Continuity")
|
||||
lexicon = self.reader.child(continuity, "Master Lexicon")
|
||||
world_notes = self.reader.child(continuity, "World Notes")
|
||||
lexicon_saved = self._append_to_item(lexicon, f"{stamp}\n{lexicon_additions.strip()}") if lexicon and lexicon_additions.strip() else False
|
||||
world_saved = self._append_to_item(world_notes, f"{stamp}\n{world_note_additions.strip()}") if world_notes and world_note_additions.strip() else False
|
||||
return {"characters": saved_characters, "lexicon": lexicon_saved, "world_notes": world_saved}
|
||||
|
||||
|
||||
def section(name: str, value: str) -> str:
|
||||
return f"### {name}\n{value.strip() or '(none)'}"
|
||||
@@ -417,6 +498,68 @@ def draft(project: ScrivenerReader, payload: dict) -> dict:
|
||||
return {"draft": text, "usage": result.get("usage", {}), "model": result.get("model", OPENROUTER_MODEL)}
|
||||
|
||||
|
||||
def propose_summary(project: ScrivenerReader, payload: dict) -> dict:
|
||||
key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not key:
|
||||
raise ValueError("OPENROUTER_API_KEY is not set in this server's Terminal session.")
|
||||
if not payload.get("send_confirmation"):
|
||||
raise ValueError("Confirm the OpenRouter send before generating a summary.")
|
||||
scene = project.by_uuid.get(payload.get("scene_uuid", ""))
|
||||
text = payload.get("text", "").strip()
|
||||
if scene is None or not text:
|
||||
raise ValueError("A canonical scene is required to generate a summary.")
|
||||
system = "You create continuity records for a fiction-authoring system. Return exactly one dense, factual sentence that captures the scene's viewpoint, material plot movement, character or relationship changes, discoveries, and unresolved state. Do not add a title, explanation, or invented facts."
|
||||
user = f"Scene label: {scene.title}\n\nCanonical scene:\n{text}"
|
||||
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "max_tokens": 500}).encode()
|
||||
request = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", data=body, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=300) as response:
|
||||
result = json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as error:
|
||||
raise ValueError(f"OpenRouter returned HTTP {error.code}: {error.read().decode(errors='replace')}") from error
|
||||
summary = result.get("choices", [{}])[0].get("message", {}).get("content")
|
||||
if not isinstance(summary, str) or not summary.strip():
|
||||
raise ValueError("OpenRouter response did not contain a scene summary.")
|
||||
return {"summary": summary.strip(), "model": result.get("model", OPENROUTER_MODEL)}
|
||||
|
||||
|
||||
def propose_continuity_updates(project: ScrivenerReader, payload: dict) -> dict:
|
||||
key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not key:
|
||||
raise ValueError("OPENROUTER_API_KEY is not set in this server's Terminal session.")
|
||||
if not payload.get("send_confirmation"):
|
||||
raise ValueError("Confirm the OpenRouter send before generating continuity proposals.")
|
||||
scene = project.by_uuid.get(payload.get("scene_uuid", ""))
|
||||
text = payload.get("text", "").strip()
|
||||
if scene is None or not text:
|
||||
raise ValueError("A canonical scene is required to propose continuity updates.")
|
||||
data = project.summary()
|
||||
context = {"characters": data["characters"], "master_lexicon": data["master_lexicon"], "world_notes": data["world_notes"]}
|
||||
system = "You propose factual continuity updates for a fiction-authoring system. Return JSON only, with exactly these keys: character_updates (array of objects with character_uuid and text), lexicon_additions (string), world_note_additions (string). Suggest only information newly established or materially changed by the canonical scene and absent from the supplied records. Character text must be a concise factual update, without a heading. Use a supplied UUID only when updating an existing character; do not create characters here. Use empty arrays or empty strings when there is no justified addition."
|
||||
user = "Existing continuity records:\n" + json.dumps(context, ensure_ascii=False) + f"\n\nCanonical scene ({scene.title}):\n{text}"
|
||||
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "max_tokens": 1200}).encode()
|
||||
request = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", data=body, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=300) as response:
|
||||
result = json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as error:
|
||||
raise ValueError(f"OpenRouter returned HTTP {error.code}: {error.read().decode(errors='replace')}") from error
|
||||
raw = result.get("choices", [{}])[0].get("message", {}).get("content")
|
||||
if not isinstance(raw, str):
|
||||
raise ValueError("OpenRouter response did not contain continuity proposals.")
|
||||
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip(), flags=re.IGNORECASE)
|
||||
try:
|
||||
proposals = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.") from error
|
||||
valid_uuids = {character["uuid"] for character in data["characters"]}
|
||||
updates = [
|
||||
{"uuid": update.get("character_uuid", ""), "text": str(update.get("text", "")).strip()}
|
||||
for update in proposals.get("character_updates", []) if isinstance(update, dict) and update.get("character_uuid") in valid_uuids and str(update.get("text", "")).strip()
|
||||
]
|
||||
return {"character_updates": updates, "lexicon_additions": str(proposals.get("lexicon_additions", "")).strip(), "world_note_additions": str(proposals.get("world_note_additions", "")).strip(), "model": result.get("model", OPENROUTER_MODEL)}
|
||||
|
||||
|
||||
class AppHandler(SimpleHTTPRequestHandler):
|
||||
reader: ScrivenerReader
|
||||
|
||||
@@ -450,7 +593,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path not in {"/api/package-preview", "/api/draft", "/api/accept-for-editing", "/api/save-working-copy", "/api/accept-into-book"}:
|
||||
if path not in {"/api/package-preview", "/api/draft", "/api/accept-for-editing", "/api/save-working-copy", "/api/accept-into-book", "/api/propose-summary", "/api/save-summary", "/api/propose-continuity-updates", "/api/save-continuity-updates"}:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
try:
|
||||
@@ -463,7 +606,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
raise ValueError("Confirm acceptance before creating archive records.")
|
||||
package = build_package(self.reader, payload)
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
result = writer.accept_for_editing(payload.get("scene_uuid", ""), payload.get("draft", ""), package, payload.get("model", OPENROUTER_MODEL))
|
||||
result = writer.accept_for_editing(payload.get("scene_uuid", ""), payload.get("draft", ""), package, payload.get("model", OPENROUTER_MODEL), payload.get("character_uuids", []))
|
||||
AppHandler.reader = writer.reader
|
||||
self.send_json(result)
|
||||
elif path == "/api/save-working-copy":
|
||||
@@ -477,6 +620,24 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
writer.accept_into_book(payload.get("scene_uuid", ""), payload.get("text", ""))
|
||||
AppHandler.reader = ScrivenerReader(self.reader.project)
|
||||
self.send_json({"accepted": True})
|
||||
elif path == "/api/propose-summary":
|
||||
self.send_json(propose_summary(self.reader, payload))
|
||||
elif path == "/api/save-summary":
|
||||
if not payload.get("summary_confirmation"):
|
||||
raise ValueError("Confirm the summary before saving it to Scrivener.")
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
summary_uuid = writer.save_scene_summary(payload.get("scene_uuid", ""), payload.get("summary", ""))
|
||||
AppHandler.reader = writer.reader
|
||||
self.send_json({"saved": True, "summary_uuid": summary_uuid})
|
||||
elif path == "/api/propose-continuity-updates":
|
||||
self.send_json(propose_continuity_updates(self.reader, payload))
|
||||
elif path == "/api/save-continuity-updates":
|
||||
if not payload.get("updates_confirmation"):
|
||||
raise ValueError("Confirm the proposed continuity updates before saving them to Scrivener.")
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
result = writer.save_continuity_updates(payload.get("scene_uuid", ""), payload.get("character_updates", []), payload.get("lexicon_additions", ""), payload.get("world_note_additions", ""))
|
||||
AppHandler.reader = ScrivenerReader(self.reader.project)
|
||||
self.send_json(result)
|
||||
else:
|
||||
self.send_json({"package": build_package(self.reader, payload), "warnings": preflight(self.reader, payload)})
|
||||
except (ValueError, json.JSONDecodeError) as error:
|
||||
|
||||
210
prototype/static/app.js
vendored
210
prototype/static/app.js
vendored
@@ -4,6 +4,9 @@ let generatedPackage = '';
|
||||
let generatedModel = '';
|
||||
let workingUuid = '';
|
||||
let acceptedSceneUuid = '';
|
||||
let summarySceneUuid = '';
|
||||
let contextSceneUuid = '';
|
||||
let updatesSceneUuid = '';
|
||||
|
||||
const byId = (id) => document.getElementById(id);
|
||||
const selected = (container) => [...container.querySelectorAll('input:checked')].map((input) => input.value);
|
||||
@@ -14,6 +17,100 @@ function checks(container, records) {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function sceneByUuid(uuid) { return project.scenes.find((scene) => scene.uuid === uuid); }
|
||||
|
||||
function earlierCanonicalScenes(sceneUuid) {
|
||||
const index = project.scenes.findIndex((scene) => scene.uuid === sceneUuid);
|
||||
return project.scenes.slice(0, Math.max(0, index)).filter((scene) => scene.state === 'canonical');
|
||||
}
|
||||
|
||||
function setOptions(id, scenes, emptyLabel) {
|
||||
byId(id).innerHTML = `<option value="">${emptyLabel}</option>${scenes.map((scene) => `<option value="${scene.uuid}">${escapeHtml(scene.title)} — ${escapeHtml(scene.chapter)}</option>`).join('')}`;
|
||||
}
|
||||
|
||||
function paragraphTail(text, targetWords) {
|
||||
const paragraphs = text.trim().split(/\n\s*\n/).filter(Boolean);
|
||||
const tail = [];
|
||||
let count = 0;
|
||||
while (paragraphs.length && count < targetWords) {
|
||||
const paragraph = paragraphs.pop();
|
||||
tail.unshift(paragraph);
|
||||
count += paragraph.trim().split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
return tail.join('\n\n');
|
||||
}
|
||||
|
||||
function loadStory() {
|
||||
const selectedSummaries = [...byId('story-summaries').querySelectorAll('input:checked')]
|
||||
.map((input) => sceneByUuid(input.value)).filter(Boolean);
|
||||
byId('story').value = selectedSummaries.map((scene) => `${scene.title}\n${scene.summary}`).join('\n\n');
|
||||
byId('new-thread').checked = selectedSummaries.length === 0;
|
||||
}
|
||||
|
||||
function loadPrevious() {
|
||||
const source = sceneByUuid(byId('previous-source').value);
|
||||
if (!source) { byId('previous').value = ''; return; }
|
||||
byId('previous').value = paragraphTail(source.text, Number(byId('previous-words').value) || 400);
|
||||
byId('new-thread').checked = false;
|
||||
}
|
||||
|
||||
function priorPackageContext() {
|
||||
const source = sceneByUuid(byId('previous-source').value);
|
||||
return source ? source.package_context : null;
|
||||
}
|
||||
|
||||
function carryCharacters() {
|
||||
const context = priorPackageContext();
|
||||
if (!context || !context.character_uuids.length) {
|
||||
byId('carry-status').textContent = 'The selected prior scene has no archived character roster to carry forward.';
|
||||
return;
|
||||
}
|
||||
const selectedUuids = new Set(context.character_uuids);
|
||||
byId('characters').querySelectorAll('input').forEach((input) => { input.checked = selectedUuids.has(input.value); });
|
||||
byId('carry-status').textContent = `Carried forward ${selectedUuids.size} character selection${selectedUuids.size === 1 ? '' : 's'}; adjust them freely.`;
|
||||
}
|
||||
|
||||
function appendNotes(value, label) {
|
||||
const text = value.trim();
|
||||
if (!text || text === '(none)') {
|
||||
byId('carry-status').textContent = `No ${label} are available to insert.`;
|
||||
return;
|
||||
}
|
||||
byId('notes').value = byId('notes').value.trim() ? `${byId('notes').value.trim()}\n\n${text}` : text;
|
||||
byId('carry-status').textContent = `${label[0].toUpperCase()}${label.slice(1)} inserted into NOTES; review and edit them for this scene.`;
|
||||
}
|
||||
|
||||
function addRecall() {
|
||||
const source = sceneByUuid(byId('recall-source').value);
|
||||
if (!source) return;
|
||||
const value = byId('recall-mode').value === 'full' ? source.text : source.summary;
|
||||
if (!value) { byId('error').textContent = `${source.title} has no approved dense summary yet; choose Full canonical scene instead.`; return; }
|
||||
const form = byId('recall-mode').value === 'full' ? 'full canonical scene' : 'approved dense summary';
|
||||
const block = `${source.title} (${form})\n${value}`;
|
||||
byId('recall').value = byId('recall').value.trim() ? `${byId('recall').value.trim()}\n\n${block}` : block;
|
||||
}
|
||||
|
||||
function populateContinuity(defaults = false) {
|
||||
const scene = sceneByUuid(byId('scene').value);
|
||||
if (!scene) return;
|
||||
const earlier = earlierCanonicalScenes(scene.uuid);
|
||||
const withSummaries = earlier.filter((candidate) => candidate.summary);
|
||||
const latest = earlier.at(-1);
|
||||
byId('story-summaries').innerHTML = withSummaries.map((candidate) => `<label><input type="checkbox" value="${candidate.uuid}"> ${escapeHtml(candidate.title)} — ${escapeHtml(candidate.summary)}</label>`).join('') || '<p class="muted">No earlier approved scene summaries are available.</p>';
|
||||
setOptions('previous-source', earlier, 'None — new narrative thread');
|
||||
setOptions('recall-source', project.scenes.filter((candidate) => candidate.state === 'canonical' && candidate.uuid !== scene.uuid), 'Choose a canonical scene to recall');
|
||||
if (latest) byId('previous-source').value = latest.uuid;
|
||||
if (latest && latest.summary) {
|
||||
const check = byId('story-summaries').querySelector(`input[value="${latest.uuid}"]`);
|
||||
if (check) check.checked = true;
|
||||
}
|
||||
if (defaults) {
|
||||
loadStory();
|
||||
loadPrevious();
|
||||
if (latest && latest.package_context.character_uuids.length) carryCharacters();
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value.replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character]));
|
||||
}
|
||||
@@ -22,6 +119,24 @@ function updateSceneState() {
|
||||
const scene = project.scenes.find((candidate) => candidate.uuid === byId('scene').value);
|
||||
byId('scene-state').textContent = scene ? `${scene.chapter} · ${scene.state} · ${scene.brief ? 'BRIEF paired' : 'BRIEF missing'}` : '';
|
||||
byId('brief').textContent = scene && scene.brief ? scene.brief : 'No paired BRIEF exists for this scene.';
|
||||
byId('propose-summary').disabled = !(scene && scene.state === 'canonical');
|
||||
byId('propose-updates').disabled = !(scene && scene.state === 'canonical');
|
||||
}
|
||||
|
||||
function changeScene() {
|
||||
updateSceneState();
|
||||
if (contextSceneUuid !== byId('scene').value) {
|
||||
contextSceneUuid = byId('scene').value;
|
||||
populateContinuity(true);
|
||||
}
|
||||
if (summarySceneUuid && summarySceneUuid !== byId('scene').value) {
|
||||
summarySceneUuid = '';
|
||||
byId('scene-summary').value = '';
|
||||
byId('scene-summary').disabled = true;
|
||||
byId('save-summary').disabled = true;
|
||||
byId('summary-status').textContent = '';
|
||||
}
|
||||
if (updatesSceneUuid && updatesSceneUuid !== byId('scene').value) resetUpdates();
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
@@ -39,6 +154,8 @@ function render(data) {
|
||||
checks(byId('places'), data.places);
|
||||
byId('world-notes').textContent = data.world_notes || 'No project-level World Notes recorded.';
|
||||
updateSceneState();
|
||||
contextSceneUuid = byId('scene').value;
|
||||
populateContinuity(true);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -120,14 +237,105 @@ async function acceptIntoBook() {
|
||||
await request('/api/accept-into-book', { scene_uuid: acceptedSceneUuid, text: byId('working-draft').value, final_confirmation: byId('final-confirmation').checked });
|
||||
byId('archive-status').textContent = 'Working copy saved and canonical prose written into Manuscript.';
|
||||
await load();
|
||||
byId('propose-summary').disabled = false;
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
byId('scene').addEventListener('change', updateSceneState);
|
||||
async function proposeSummary() {
|
||||
byId('error').textContent = '';
|
||||
const scene = project.scenes.find((candidate) => candidate.uuid === byId('scene').value);
|
||||
try {
|
||||
const data = await request('/api/propose-summary', { scene_uuid: scene.uuid, text: scene.text, send_confirmation: byId('summary-send-confirmation').checked });
|
||||
summarySceneUuid = scene.uuid;
|
||||
byId('scene-summary').value = data.summary;
|
||||
byId('scene-summary').disabled = false;
|
||||
byId('save-summary').disabled = false;
|
||||
byId('summary-status').textContent = 'Review and edit the proposed summary before approving it.';
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function saveSummary() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
await request('/api/save-summary', { scene_uuid: summarySceneUuid, summary: byId('scene-summary').value, summary_confirmation: byId('summary-confirmation').checked });
|
||||
byId('summary-status').textContent = 'Approved summary saved in Continuity → Scene Summaries.';
|
||||
await load();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
function resetUpdates() {
|
||||
updatesSceneUuid = '';
|
||||
byId('character-updates').innerHTML = '';
|
||||
byId('lexicon-additions').value = ''; byId('lexicon-additions').disabled = true;
|
||||
byId('world-note-additions').value = ''; byId('world-note-additions').disabled = true;
|
||||
byId('updates-confirmation').checked = false; byId('save-updates').disabled = true;
|
||||
byId('updates-status').textContent = '';
|
||||
}
|
||||
|
||||
function renderCharacterUpdates(updates) {
|
||||
byId('character-updates').innerHTML = updates.map((update) => {
|
||||
const character = project.characters.find((candidate) => candidate.uuid === update.uuid);
|
||||
return `<label class="proposal"><input type="checkbox" data-uuid="${update.uuid}" checked> <span>${escapeHtml(character ? character.title : 'Unknown character')}</span><textarea rows="3">${escapeHtml(update.text)}</textarea></label>`;
|
||||
}).join('') || '<p class="muted">No character updates proposed.</p>';
|
||||
}
|
||||
|
||||
async function proposeUpdates() {
|
||||
byId('error').textContent = '';
|
||||
const scene = sceneByUuid(byId('scene').value);
|
||||
try {
|
||||
const data = await request('/api/propose-continuity-updates', { scene_uuid: scene.uuid, text: scene.text, send_confirmation: byId('updates-send-confirmation').checked });
|
||||
updatesSceneUuid = scene.uuid;
|
||||
renderCharacterUpdates(data.character_updates || []);
|
||||
byId('lexicon-additions').value = data.lexicon_additions || '';
|
||||
byId('lexicon-additions').disabled = false;
|
||||
byId('world-note-additions').value = data.world_note_additions || '';
|
||||
byId('world-note-additions').disabled = false;
|
||||
byId('save-updates').disabled = false;
|
||||
byId('updates-status').textContent = 'Review each proposal. Uncheck a character update to exclude it.';
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function saveUpdates() {
|
||||
byId('error').textContent = '';
|
||||
const characterUpdates = [...byId('character-updates').querySelectorAll('input:checked')].map((input) => ({ uuid: input.dataset.uuid, text: input.parentElement.querySelector('textarea').value }));
|
||||
try {
|
||||
const data = await request('/api/save-continuity-updates', { scene_uuid: updatesSceneUuid, character_updates: characterUpdates, lexicon_additions: byId('lexicon-additions').value, world_note_additions: byId('world-note-additions').value, updates_confirmation: byId('updates-confirmation').checked });
|
||||
const saved = [...data.characters, data.lexicon ? 'Master Lexicon' : '', data.world_notes ? 'World Notes' : ''].filter(Boolean);
|
||||
byId('updates-status').textContent = saved.length ? `Saved updates to: ${saved.join(', ')}.` : 'No new continuity additions were saved.';
|
||||
await load();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
byId('scene').addEventListener('change', changeScene);
|
||||
byId('load-story').addEventListener('click', loadStory);
|
||||
byId('load-previous').addEventListener('click', loadPrevious);
|
||||
byId('add-recall').addEventListener('click', addRecall);
|
||||
byId('carry-characters').addEventListener('click', carryCharacters);
|
||||
byId('insert-prior-notes').addEventListener('click', () => appendNotes(priorPackageContext()?.notes || '', 'prior package NOTES'));
|
||||
byId('insert-world-notes').addEventListener('click', () => appendNotes(project.world_notes || '', 'project World Notes'));
|
||||
byId('story-summaries').addEventListener('change', (event) => {
|
||||
const chosen = byId('story-summaries').querySelectorAll('input:checked');
|
||||
if (chosen.length > 5) {
|
||||
event.target.checked = false;
|
||||
byId('error').textContent = 'STORY supports at most five selected scene summaries.';
|
||||
}
|
||||
});
|
||||
byId('new-thread').addEventListener('change', () => {
|
||||
if (byId('new-thread').checked) {
|
||||
byId('story').value = '';
|
||||
byId('previous').value = '';
|
||||
byId('story-summaries').querySelectorAll('input:checked').forEach((input) => { input.checked = false; });
|
||||
byId('previous-source').value = '';
|
||||
}
|
||||
});
|
||||
byId('build').addEventListener('click', build);
|
||||
byId('draft').addEventListener('click', generateDraft);
|
||||
byId('accept-editing').addEventListener('click', acceptForEditing);
|
||||
byId('reject-draft').addEventListener('click', () => resetDraftState('Draft rejected and discarded. Nothing was written to Scrivener.'));
|
||||
byId('save-working').addEventListener('click', saveWorkingCopy);
|
||||
byId('accept-book').addEventListener('click', acceptIntoBook);
|
||||
byId('propose-summary').addEventListener('click', proposeSummary);
|
||||
byId('save-summary').addEventListener('click', saveSummary);
|
||||
byId('propose-updates').addEventListener('click', proposeUpdates);
|
||||
byId('save-updates').addEventListener('click', saveUpdates);
|
||||
load().catch((error) => { byId('status').textContent = error.message; byId('status').className = 'status warning'; });
|
||||
|
||||
@@ -36,17 +36,48 @@
|
||||
<h2>World Notes <span class="read-only">read-only reference</span></h2>
|
||||
<pre id="world-notes">Loading World Notes…</pre>
|
||||
</section>
|
||||
<section class="continuity-panel">
|
||||
<h2>Continuity context</h2>
|
||||
<p class="muted">The default follows the latest earlier canonical scene. Change the selections freely for a different narrative thread.</p>
|
||||
<div class="continuity-grid">
|
||||
<div>
|
||||
<label>STORY summaries for this thread</label>
|
||||
<div id="story-summaries" class="checks context-list"></div>
|
||||
<button id="load-story" type="button">Load selected summaries into STORY</button>
|
||||
</div>
|
||||
<div>
|
||||
<label for="previous-source">PREVIOUS source scene</label>
|
||||
<select id="previous-source"></select>
|
||||
<label for="previous-words">Target words (complete paragraphs only)</label>
|
||||
<input id="previous-words" type="number" min="1" value="400">
|
||||
<button id="load-previous" type="button">Load paragraph-boundary tail</button>
|
||||
</div>
|
||||
<div>
|
||||
<label for="recall-source">RECALL source scene</label>
|
||||
<select id="recall-source"></select>
|
||||
<label for="recall-mode">RECALL form</label>
|
||||
<select id="recall-mode"><option value="summary">Approved dense summary</option><option value="full">Full canonical scene</option></select>
|
||||
<button id="add-recall" type="button">Add selected scene to RECALL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="carry-actions">
|
||||
<button id="carry-characters" type="button">Use prior scene’s character roster</button>
|
||||
<button id="insert-prior-notes" type="button">Insert prior package NOTES</button>
|
||||
<button id="insert-world-notes" type="button">Insert project World Notes</button>
|
||||
<p id="carry-status" class="muted"></p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="grid">
|
||||
<label>LEXICON (manual subset)
|
||||
<textarea id="lexicon" rows="4" placeholder="Term — usage note"></textarea>
|
||||
</label>
|
||||
<label>STORY (manual preview)
|
||||
<label>STORY <span class="read-only">editable selection</span>
|
||||
<textarea id="story" rows="4" placeholder="Leave empty for a new thread in this prototype."></textarea>
|
||||
</label>
|
||||
<label>RECALL (manual preview)
|
||||
<label>RECALL <span class="read-only">editable selection</span>
|
||||
<textarea id="recall" rows="4" placeholder="(Book 1 — summary) …"></textarea>
|
||||
</label>
|
||||
<label>PREVIOUS (manual preview)
|
||||
<label>PREVIOUS <span class="read-only">editable selection</span>
|
||||
<textarea id="previous" rows="4" placeholder="Verbatim paragraph-boundary tail."></textarea>
|
||||
</label>
|
||||
<label>Additional NOTES
|
||||
@@ -77,6 +108,32 @@
|
||||
<label class="checkbox"><input id="final-confirmation" type="checkbox"> I confirm the edited working copy is the canonical scene.</label>
|
||||
<button id="accept-book" disabled>Accept working copy into Manuscript</button>
|
||||
</div>
|
||||
<section class="summary-panel">
|
||||
<h2>Dense scene summary</h2>
|
||||
<p class="muted">Generated only after canonical acceptance; edit it before saving it as the continuity record for this scene.</p>
|
||||
<label class="checkbox"><input id="summary-send-confirmation" type="checkbox"> I understand this sends the canonical scene text to OpenRouter to create a summary.</label>
|
||||
<button id="propose-summary" disabled>Propose dense summary</button>
|
||||
<textarea id="scene-summary" rows="4" disabled placeholder="An editable continuity summary will appear here."></textarea>
|
||||
<label class="checkbox"><input id="summary-confirmation" type="checkbox"> I approve this summary for continuity use.</label>
|
||||
<button id="save-summary" disabled>Save approved summary</button>
|
||||
<p id="summary-status" class="muted"></p>
|
||||
</section>
|
||||
<section class="updates-panel">
|
||||
<h2>Proposed continuity updates</h2>
|
||||
<p class="muted">These are suggestions only. Edit or deselect them before adding the approved facts to their existing Scrivener records.</p>
|
||||
<label class="checkbox"><input id="updates-send-confirmation" type="checkbox"> I understand this sends the canonical scene and current continuity records to OpenRouter.</label>
|
||||
<button id="propose-updates" disabled>Propose continuity updates</button>
|
||||
<div id="character-updates" class="proposal-list"></div>
|
||||
<label>LEXICON additions <span class="read-only">editable</span>
|
||||
<textarea id="lexicon-additions" rows="4" disabled></textarea>
|
||||
</label>
|
||||
<label>World Notes additions <span class="read-only">editable</span>
|
||||
<textarea id="world-note-additions" rows="4" disabled></textarea>
|
||||
</label>
|
||||
<label class="checkbox"><input id="updates-confirmation" type="checkbox"> I approve the selected and edited continuity updates.</label>
|
||||
<button id="save-updates" disabled>Save approved continuity updates</button>
|
||||
<p id="updates-status" class="muted"></p>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
@@ -5,13 +5,15 @@ header { display: flex; justify-content: space-between; align-items: center; pad
|
||||
h1 { margin: 0; font-size: 1.8rem; } h2 { margin: 1.5rem 0 .6rem; font-size: 1rem; } .eyebrow { margin: 0 0 .25rem; font-size: .7rem; letter-spacing: .12em; color: #c7ded6; }
|
||||
main { display: grid; grid-template-columns: 300px minmax(0, 1fr); min-height: calc(100vh - 84px); }
|
||||
aside { padding: 1.2rem 1.5rem; border-right: 1px solid #d8d2c5; background: #fcfaf5; } .workspace { padding: 1.5rem 2rem; }
|
||||
label { display: block; font-size: .85rem; font-weight: 650; } select, textarea { width: 100%; margin-top: .35rem; border: 1px solid #bab2a5; border-radius: 4px; background: #fffefb; padding: .55rem; font: .9rem ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
label { display: block; font-size: .85rem; font-weight: 650; } select, textarea, input[type="number"] { width: 100%; margin-top: .35rem; border: 1px solid #bab2a5; border-radius: 4px; background: #fffefb; padding: .55rem; font: .9rem ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
select { font-family: inherit; } .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } .checks label { padding: .2rem 0; font-weight: 400; } .checks input { margin-right: .35rem; }
|
||||
button { margin: 1rem 0; border: 0; border-radius: 4px; padding: .65rem 1rem; background: #b54c2d; color: white; font-weight: 700; cursor: pointer; } button:hover { background: #923b22; }
|
||||
button:disabled { cursor: not-allowed; opacity: .5; } .draft-actions, .final-actions { margin-top: .5rem; padding: .75rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .draft-actions button, .final-actions button { margin: .6rem .5rem 0 0; }
|
||||
.continuity-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #eef4f0; } .continuity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; } .continuity-panel button { margin: .6rem 0 0; } .carry-actions { margin-top: 1rem; padding-top: .25rem; border-top: 1px solid #cbd8d0; } .carry-actions button { margin-right: .5rem; } .context-list { min-height: 6rem; max-height: 12rem; overflow: auto; margin-top: .35rem; padding: .45rem; border: 1px solid #bab2a5; background: #fffefb; } .context-list label { padding: .22rem 0; font-weight: 400; }
|
||||
#working-draft { min-height: 28rem; max-height: 65vh; resize: vertical; background: #fffefb; color: #17212b; font: .9rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.summary-panel, .updates-panel { margin-top: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .proposal-list { margin: .75rem 0; } .proposal { margin: .6rem 0; padding: .6rem; border: 1px solid #d8d2c5; background: #fffefb; font-weight: 650; } .proposal textarea { margin-top: .55rem; font-weight: 400; }
|
||||
pre { min-height: 28rem; max-height: 65vh; overflow: auto; margin: 0; padding: 1rem; border: 1px solid #d8d2c5; background: #202525; color: #e5f3ed; white-space: pre-wrap; font: .82rem/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.brief-panel { margin-bottom: 1.25rem; padding: 0 1rem 1rem; border: 1px solid #d8d2c5; background: #fcfaf5; } .brief-panel h2 { margin-top: 1rem; } .brief-panel pre { min-height: 0; max-height: 16rem; background: #fffefb; color: #17212b; font-family: inherit; font-size: .95rem; } .read-only { font-size: .68rem; font-weight: 600; color: #665f54; text-transform: uppercase; letter-spacing: .08em; }
|
||||
dl { display: grid; grid-template-columns: 1fr auto; margin: 0; font-size: .9rem; } dt, dd { margin: 0; padding: .25rem 0; border-bottom: 1px solid #ece6da; } dd { font-weight: 700; } .status { border-radius: 999px; padding: .45rem .75rem; font-size: .78rem; } .ok { background: #d9eee4; color: #164d38; } .warning { background: #ffe2d6; color: #7f2c19; } .muted { color: #665f54; font-size: .8rem; } .error { color: #aa2717; font-weight: 700; } .checkbox { margin-top: 1rem; font-weight: 400; }
|
||||
.warnings { color: #805215; font-weight: 650; }
|
||||
@media (max-width: 800px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #d8d2c5; } .grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 800px) { main { grid-template-columns: 1fr; } aside { border-right: 0; border-bottom: 1px solid #d8d2c5; } .grid, .continuity-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
Reference in New Issue
Block a user