Add brief editing and character record updates
This commit is contained in:
164
prototype/app.py
164
prototype/app.py
@@ -81,6 +81,7 @@ def text_to_rtf(value: str) -> str:
|
||||
class ScrivenerReader:
|
||||
def __init__(self, project: Path):
|
||||
self.project = project
|
||||
self.bindings_path = project / "Files" / "authoring-system-bindings.json"
|
||||
project_files = list(project.glob("*.scrivx"))
|
||||
if len(project_files) != 1:
|
||||
raise ValueError("Project must contain exactly one .scrivx file.")
|
||||
@@ -88,6 +89,16 @@ class ScrivenerReader:
|
||||
self.root_items = self._load_binder()
|
||||
self.by_uuid = {item.uuid: item for item in self.walk()}
|
||||
|
||||
def bindings(self) -> dict[str, str]:
|
||||
if not self.bindings_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(self.bindings_path.read_text(encoding="utf-8"))
|
||||
bindings = data.get("scene_to_brief", {})
|
||||
return {str(scene_uuid): str(brief_uuid) for scene_uuid, brief_uuid in bindings.items()} if isinstance(bindings, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
def _load_binder(self) -> list[Item]:
|
||||
root = ET.parse(self.project_file).getroot()
|
||||
binder = root.find("Binder")
|
||||
@@ -172,6 +183,8 @@ class ScrivenerReader:
|
||||
manuscript = self.manuscript_scenes()
|
||||
briefs = self.records(briefs_folder, self)
|
||||
brief_by_title = {brief["title"]: brief for brief in briefs}
|
||||
brief_by_uuid = {brief["uuid"]: brief for brief in briefs}
|
||||
bindings = self.bindings()
|
||||
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] = {}
|
||||
@@ -190,18 +203,26 @@ class ScrivenerReader:
|
||||
package_context[title] = {"character_uuids": character_uuids, "notes": self._package_section(text, "NOTES")}
|
||||
|
||||
paired = []
|
||||
bound_brief_uuids: set[str] = set()
|
||||
for scene in manuscript:
|
||||
brief = brief_by_uuid.get(bindings.get(scene["uuid"], ""))
|
||||
binding_state = "stable" if brief else ""
|
||||
if brief is None:
|
||||
brief = brief_by_title.get(scene["title"])
|
||||
binding_state = "legacy-title-match" if brief else "unpaired"
|
||||
if brief:
|
||||
bound_brief_uuids.add(brief["uuid"])
|
||||
paired.append(
|
||||
{
|
||||
**scene,
|
||||
"brief": brief_by_title.get(scene["title"], {}).get("text", ""),
|
||||
"brief_uuid": brief_by_title.get(scene["title"], {}).get("uuid"),
|
||||
"brief": (brief or {}).get("text", ""),
|
||||
"brief_uuid": (brief or {}).get("uuid"),
|
||||
"binding_state": binding_state,
|
||||
"summary": summary_by_title.get(scene["title"], {}).get("text", ""),
|
||||
"package_context": package_context.get(scene["title"], {"character_uuids": [], "notes": ""}),
|
||||
}
|
||||
)
|
||||
|
||||
manuscript_titles = {scene["title"] for scene in manuscript}
|
||||
return {
|
||||
"project": str(self.project),
|
||||
"locked": (self.project / "Files" / "user.lock").exists(),
|
||||
@@ -212,8 +233,13 @@ class ScrivenerReader:
|
||||
"scenes": paired,
|
||||
"characters": self.records(self.top_level("Characters"), self),
|
||||
"places": self.records(self.top_level("Places"), self),
|
||||
"unmatched_briefs": [brief for brief in briefs if brief["title"] not in manuscript_titles],
|
||||
"unmatched_scenes": [scene for scene in manuscript if scene["title"] not in brief_by_title],
|
||||
"unmatched_briefs": [brief for brief in briefs if brief["uuid"] not in bound_brief_uuids],
|
||||
"unmatched_scenes": [scene for scene in paired if not scene["brief_uuid"]],
|
||||
"binding_counts": {
|
||||
"stable": sum(scene["binding_state"] == "stable" for scene in paired),
|
||||
"legacy": sum(scene["binding_state"] == "legacy-title-match" for scene in paired),
|
||||
"unpaired": sum(scene["binding_state"] == "unpaired" for scene in paired),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -233,6 +259,30 @@ class ScrivenerWriter:
|
||||
if (self.project / "Files" / "user.lock").exists():
|
||||
raise ValueError("Scrivener is open (Files/user.lock exists). Close the project before writing.")
|
||||
|
||||
def _write_bindings(self, bindings: dict[str, str]) -> None:
|
||||
payload = {
|
||||
"version": 1,
|
||||
"updated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"scene_to_brief": bindings,
|
||||
}
|
||||
temporary = self.reader.bindings_path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.replace(temporary, self.reader.bindings_path)
|
||||
|
||||
def reconcile_scene_bindings(self) -> dict:
|
||||
"""Persist currently resolved scene/BRIEF pairs without relying on their titles."""
|
||||
self._assert_unlocked()
|
||||
data = self.reader.summary()
|
||||
briefs_folder = self.reader.child(self.reader.top_level("Authoring System"), "Scene Briefs")
|
||||
valid_brief_uuids = {item.uuid for item in (briefs_folder.children if briefs_folder else []) if item.item_type == "Text"}
|
||||
bindings = {scene_uuid: brief_uuid for scene_uuid, brief_uuid in self.reader.bindings().items() if scene_uuid in self.reader.by_uuid and brief_uuid in valid_brief_uuids}
|
||||
for scene in data["scenes"]:
|
||||
if scene["brief_uuid"]:
|
||||
bindings[scene["uuid"]] = scene["brief_uuid"]
|
||||
self._write_bindings(bindings)
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
return self.reader.summary()["binding_counts"]
|
||||
|
||||
def _tree_and_binder(self) -> tuple[ET.ElementTree, ET.Element]:
|
||||
tree = ET.parse(self.reader.project_file)
|
||||
binder = tree.getroot().find("Binder")
|
||||
@@ -311,6 +361,35 @@ class ScrivenerWriter:
|
||||
def _save_tree(self, tree: ET.ElementTree) -> None:
|
||||
tree.write(self.reader.project_file, encoding="UTF-8", xml_declaration=True)
|
||||
|
||||
def save_brief(self, scene_uuid: str, text: str) -> dict:
|
||||
"""Update an existing BRIEF or create and bind one for a planned scene."""
|
||||
self._assert_unlocked()
|
||||
scene = self.reader.by_uuid.get(scene_uuid)
|
||||
if scene is None:
|
||||
raise ValueError("The selected Manuscript scene no longer exists.")
|
||||
paired = next((item for item in self.reader.summary()["scenes"] if item["uuid"] == scene_uuid), None)
|
||||
if paired and paired["brief_uuid"]:
|
||||
brief_uuid = paired["brief_uuid"]
|
||||
path = self.project / "Files" / "Data" / brief_uuid / "content.rtf"
|
||||
content = text_to_rtf(text).encode("utf-8")
|
||||
path.write_bytes(content)
|
||||
self._update_checksum(brief_uuid, content)
|
||||
bindings = self.reader.bindings()
|
||||
bindings[scene_uuid] = brief_uuid
|
||||
self._write_bindings(bindings)
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
return {"created": False, "brief_uuid": brief_uuid}
|
||||
self._backup_binder()
|
||||
tree, binder = self._tree_and_binder()
|
||||
briefs_folder = self._folder(binder, ["Authoring System", "Scene Briefs"])
|
||||
brief_uuid = self._add_text(briefs_folder, scene.title, text)
|
||||
self._save_tree(tree)
|
||||
bindings = self.reader.bindings()
|
||||
bindings[scene_uuid] = brief_uuid
|
||||
self._write_bindings(bindings)
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
return {"created": True, "brief_uuid": brief_uuid}
|
||||
|
||||
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():
|
||||
@@ -392,12 +471,23 @@ class ScrivenerWriter:
|
||||
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:
|
||||
def _replace_item(self, item: Item, text: str) -> bool:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("A Character record cannot be replaced with empty text.")
|
||||
path = self.project / "Files" / "Data" / item.uuid / "content.rtf"
|
||||
if self.reader.content(item).strip() == text:
|
||||
return False
|
||||
content = text_to_rtf(text + "\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], new_characters: 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 = []
|
||||
@@ -405,15 +495,34 @@ class ScrivenerWriter:
|
||||
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()}"):
|
||||
if self._replace_item(item, update.get("text", "")):
|
||||
saved_characters.append(item.title)
|
||||
stamp = f"Continuity update from {scene.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}
|
||||
existing_titles = {item.title.casefold() for item in characters.values()}
|
||||
created_characters = []
|
||||
candidates = []
|
||||
for character in new_characters:
|
||||
name = str(character.get("name", "")).strip()
|
||||
description = str(character.get("text", "")).strip()
|
||||
if name and description and name.casefold() not in existing_titles:
|
||||
candidates.append((name, description))
|
||||
existing_titles.add(name.casefold())
|
||||
if candidates:
|
||||
self._backup_binder()
|
||||
tree, binder = self._tree_and_binder()
|
||||
characters_folder = self._folder(binder, ["Characters"])
|
||||
for name, description in candidates:
|
||||
self._add_text(characters_folder, name, description)
|
||||
created_characters.append(name)
|
||||
self._save_tree(tree)
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
return {"characters": saved_characters, "new_characters": created_characters, "lexicon": lexicon_saved, "world_notes": world_saved}
|
||||
|
||||
|
||||
def section(name: str, value: str) -> str:
|
||||
@@ -535,7 +644,7 @@ def propose_continuity_updates(project: ScrivenerReader, payload: dict) -> dict:
|
||||
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."
|
||||
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), new_characters (array of objects with name 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. Put a newly introduced named person in new_characters, never in lexicon_additions; name is the character's display name and text is an initial concise factual Character record. Do not create a new character for a passing unnamed person. 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")
|
||||
@@ -548,16 +657,35 @@ def propose_continuity_updates(project: ScrivenerReader, payload: dict) -> dict:
|
||||
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)
|
||||
# Frontier models occasionally add a one-line introduction despite the JSON-only
|
||||
# instruction. Accept one complete object embedded in that formatting, but do
|
||||
# not attempt to repair malformed JSON.
|
||||
first_brace = raw.find("{")
|
||||
last_brace = raw.rfind("}")
|
||||
if first_brace >= 0 and last_brace > first_brace:
|
||||
raw = raw[first_brace:last_brace + 1]
|
||||
try:
|
||||
proposals = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.") from error
|
||||
if not isinstance(proposals, dict):
|
||||
raise ValueError("OpenRouter returned proposals in an unexpected format; please try again.")
|
||||
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)}
|
||||
existing_names = {character["title"].casefold() for character in data["characters"]}
|
||||
new_characters = []
|
||||
for character in proposals.get("new_characters", []):
|
||||
if not isinstance(character, dict):
|
||||
continue
|
||||
name = str(character.get("name", "")).strip()
|
||||
text = str(character.get("text", "")).strip()
|
||||
if name and text and name.casefold() not in existing_names:
|
||||
new_characters.append({"name": name, "text": text})
|
||||
existing_names.add(name.casefold())
|
||||
return {"character_updates": updates, "new_characters": new_characters, "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):
|
||||
@@ -593,7 +721,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", "/api/propose-summary", "/api/save-summary", "/api/propose-continuity-updates", "/api/save-continuity-updates"}:
|
||||
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", "/api/reconcile-bindings", "/api/save-brief"}:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
try:
|
||||
@@ -635,9 +763,19 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
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", ""))
|
||||
result = writer.save_continuity_updates(payload.get("scene_uuid", ""), payload.get("character_updates", []), payload.get("new_characters", []), payload.get("lexicon_additions", ""), payload.get("world_note_additions", ""))
|
||||
AppHandler.reader = ScrivenerReader(self.reader.project)
|
||||
self.send_json(result)
|
||||
elif path == "/api/reconcile-bindings":
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
counts = writer.reconcile_scene_bindings()
|
||||
AppHandler.reader = writer.reader
|
||||
self.send_json({"binding_counts": counts})
|
||||
elif path == "/api/save-brief":
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
result = writer.save_brief(payload.get("scene_uuid", ""), payload.get("text", ""))
|
||||
AppHandler.reader = writer.reader
|
||||
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:
|
||||
|
||||
59
prototype/static/app.js
vendored
59
prototype/static/app.js
vendored
@@ -43,7 +43,7 @@ function paragraphTail(text, targetWords) {
|
||||
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('story').value = selectedSummaries.map((scene) => scene.summary).join('\n\n');
|
||||
byId('new-thread').checked = selectedSummaries.length === 0;
|
||||
}
|
||||
|
||||
@@ -85,9 +85,7 @@ function addRecall() {
|
||||
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;
|
||||
byId('recall').value = byId('recall').value.trim() ? `${byId('recall').value.trim()}\n\n${value}` : value;
|
||||
}
|
||||
|
||||
function populateContinuity(defaults = false) {
|
||||
@@ -117,8 +115,12 @@ function escapeHtml(value) {
|
||||
|
||||
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.';
|
||||
const binding = scene?.binding_state === 'stable' ? 'stable UUID binding' : scene?.binding_state === 'legacy-title-match' ? 'legacy title match' : 'BRIEF missing';
|
||||
byId('scene-state').textContent = scene ? `${scene.chapter} · ${scene.state} · ${binding}` : '';
|
||||
byId('brief').value = scene?.brief || '';
|
||||
byId('brief').disabled = project.locked;
|
||||
byId('save-brief').disabled = project.locked || !scene;
|
||||
byId('brief-status').textContent = project.locked ? 'Close Scrivener before saving a BRIEF.' : scene?.brief_uuid ? 'Editing the paired BRIEF.' : 'Saving will create and stably bind a new BRIEF for this scene.';
|
||||
byId('propose-summary').disabled = !(scene && scene.state === 'canonical');
|
||||
byId('propose-updates').disabled = !(scene && scene.state === 'canonical');
|
||||
}
|
||||
@@ -146,8 +148,10 @@ function render(data) {
|
||||
byId('status').className = `status ${data.locked ? 'warning' : 'ok'}`;
|
||||
byId('project-check').innerHTML = [
|
||||
['Scenes', data.scenes.length], ['Briefs without scene', data.unmatched_briefs.length],
|
||||
['Scenes without brief', data.unmatched_scenes.length], ['Characters', data.characters.length], ['Places', data.places.length],
|
||||
['Scenes without brief', data.unmatched_scenes.length], ['Stable scene bindings', data.binding_counts.stable], ['Legacy title matches', data.binding_counts.legacy], ['Characters', data.characters.length], ['Places', data.places.length],
|
||||
].map(([label, value]) => `<dt>${label}</dt><dd>${value}</dd>`).join('');
|
||||
byId('reconcile-bindings').disabled = data.locked;
|
||||
byId('binding-status').textContent = data.locked ? 'Close Scrivener before creating or updating bindings.' : data.binding_counts.legacy ? 'Create stable bindings before renaming or moving scenes.' : 'Scene/BRIEF bindings are stored by UUID.';
|
||||
byId('scene').innerHTML = data.scenes.map((scene) => `<option value="${scene.uuid}">${escapeHtml(scene.title)} — ${escapeHtml(scene.chapter)}</option>`).join('');
|
||||
if ([...byId('scene').options].some((option) => option.value === priorSceneUuid)) byId('scene').value = priorSceneUuid;
|
||||
checks(byId('characters'), data.characters);
|
||||
@@ -175,6 +179,24 @@ async function request(path, body) {
|
||||
return data;
|
||||
}
|
||||
|
||||
async function reconcileBindings() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
const data = await request('/api/reconcile-bindings', {});
|
||||
byId('binding-status').textContent = `${data.binding_counts.stable} stable binding${data.binding_counts.stable === 1 ? '' : 's'} saved.`;
|
||||
await load();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function saveBrief() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
const data = await request('/api/save-brief', { scene_uuid: byId('scene').value, text: byId('brief').value });
|
||||
byId('brief-status').textContent = data.created ? 'New paired BRIEF created and bound by UUID.' : 'BRIEF saved.';
|
||||
await load();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function build() {
|
||||
byId('error').textContent = ''; byId('warnings').textContent = '';
|
||||
try {
|
||||
@@ -266,6 +288,7 @@ async function saveSummary() {
|
||||
function resetUpdates() {
|
||||
updatesSceneUuid = '';
|
||||
byId('character-updates').innerHTML = '';
|
||||
byId('new-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;
|
||||
@@ -275,10 +298,20 @@ function resetUpdates() {
|
||||
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>`;
|
||||
const updateHeading = `Continuity update from ${sceneByUuid(updatesSceneUuid).title}:`;
|
||||
const completeRecord = `${character ? character.text.trim() : ''}\n\n${updateHeading}\n${update.text}`.trim();
|
||||
return `<label class="proposal"><input type="checkbox" data-uuid="${update.uuid}" checked> <span>${escapeHtml(character ? character.title : 'Unknown character')} — complete editable record</span><textarea rows="8">${escapeHtml(completeRecord)}</textarea></label>`;
|
||||
}).join('') || '<p class="muted">No character updates proposed.</p>';
|
||||
}
|
||||
|
||||
function renderNewCharacterUpdates(characters) {
|
||||
byId('new-character-updates').innerHTML = characters.map((character, index) => `
|
||||
<label class="proposal"><input type="checkbox" data-new-character="${index}" checked> <span>New Character</span>
|
||||
<input class="new-character-name" value="${escapeHtml(character.name)}" aria-label="New character name">
|
||||
<textarea class="new-character-text" rows="3">${escapeHtml(character.text)}</textarea>
|
||||
</label>`).join('') || '<p class="muted">No new Characters proposed.</p>';
|
||||
}
|
||||
|
||||
async function proposeUpdates() {
|
||||
byId('error').textContent = '';
|
||||
const scene = sceneByUuid(byId('scene').value);
|
||||
@@ -286,27 +319,31 @@ async function proposeUpdates() {
|
||||
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 || []);
|
||||
renderNewCharacterUpdates(data.new_characters || []);
|
||||
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.';
|
||||
byId('updates-status').textContent = 'Each selected Character field is a complete replacement record: trim or revise it before saving. Uncheck 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 }));
|
||||
const newCharacters = [...byId('new-character-updates').querySelectorAll('input:checked')].map((input) => ({ name: input.parentElement.querySelector('.new-character-name').value, text: input.parentElement.querySelector('.new-character-text').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);
|
||||
const data = await request('/api/save-continuity-updates', { scene_uuid: updatesSceneUuid, character_updates: characterUpdates, new_characters: newCharacters, 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.new_characters || []).map((name) => `new Character: ${name}`), 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('reconcile-bindings').addEventListener('click', reconcileBindings);
|
||||
byId('save-brief').addEventListener('click', saveBrief);
|
||||
byId('load-story').addEventListener('click', loadStory);
|
||||
byId('load-previous').addEventListener('click', loadPrevious);
|
||||
byId('add-recall').addEventListener('click', addRecall);
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<aside>
|
||||
<h2>Project check</h2>
|
||||
<dl id="project-check"></dl>
|
||||
<button id="reconcile-bindings" type="button">Create/update stable scene bindings</button>
|
||||
<p id="binding-status" class="muted"></p>
|
||||
<h2>Scene</h2>
|
||||
<label for="scene">Paired Manuscript scene</label>
|
||||
<select id="scene"></select>
|
||||
@@ -29,8 +31,10 @@
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<section class="brief-panel">
|
||||
<h2>Author BRIEF <span class="read-only">read-only</span></h2>
|
||||
<pre id="brief">Select a scene to view its paired BRIEF.</pre>
|
||||
<h2>Author BRIEF <span class="read-only">editable</span></h2>
|
||||
<textarea id="brief" rows="10" placeholder="Write the BRIEF that drives this scene."></textarea>
|
||||
<button id="save-brief" type="button">Save BRIEF</button>
|
||||
<p id="brief-status" class="muted"></p>
|
||||
</section>
|
||||
<section class="brief-panel">
|
||||
<h2>World Notes <span class="read-only">read-only reference</span></h2>
|
||||
@@ -124,6 +128,7 @@
|
||||
<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>
|
||||
<div id="new-character-updates" class="proposal-list"></div>
|
||||
<label>LEXICON additions <span class="read-only">editable</span>
|
||||
<textarea id="lexicon-additions" rows="4" disabled></textarea>
|
||||
</label>
|
||||
|
||||
@@ -11,9 +11,9 @@ button { margin: 1rem 0; border: 0; border-radius: 4px; padding: .65rem 1rem; ba
|
||||
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; }
|
||||
.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, .proposal input:not([type]) { 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; }
|
||||
.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; } .brief-panel textarea { min-height: 12rem; font: .95rem/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; } .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, .continuity-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
Reference in New Issue
Block a user