Add draft archive and acceptance workflow
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
# Read-only Package Preview Prototype
|
||||
# Local Authoring-System Prototype
|
||||
|
||||
This is the first authoring-system vertical slice. It reads a Scrivener project, checks the project structure, and builds an on-screen package preview. It does **not** call a model and it does **not** write to Scrivener.
|
||||
This prototype reads a Scrivener project, checks the project structure, builds an exact package preview, and can send a confirmed package to the temporary OpenRouter drafting model.
|
||||
|
||||
When Scrivener is closed, accepting a generated draft for editing creates three records in the project:
|
||||
|
||||
- an immutable initial draft in `Authoring System → Drafting Archive → Accepted for Editing`;
|
||||
- an editable working copy in the same folder; and
|
||||
- the exact package snapshot in `Authoring System → Drafting Archive → Package Snapshots`.
|
||||
|
||||
An outright rejected draft writes nothing. A separate, explicit final-accept action copies the saved working copy into its paired Manuscript scene. Every write checks `Files/user.lock`; close the project in Scrivener first. Before a structural archive write, the prototype places a recoverable binder/checksum backup in `Files/authoring-backups`.
|
||||
|
||||
Run from `Authoring_System`:
|
||||
|
||||
@@ -16,4 +24,4 @@ To point it at another project:
|
||||
python3 prototype/app.py --project /absolute/path/to/Book.scriv
|
||||
```
|
||||
|
||||
The app binds scene/brief pairs by the visible `Scene X` labels for this read-only prototype. The next phase will persist UUID bindings in a sidecar, so later renaming does not break relationships.
|
||||
The app currently binds scene/brief pairs by their visible `Scene X` labels. A later phase will persist UUID bindings in a sidecar, so later renaming does not break relationships.
|
||||
|
||||
238
prototype/app.py
238
prototype/app.py
@@ -1,18 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only local package-preview prototype for a Scrivener project.
|
||||
"""Local authoring-system prototype for a Scrivener project.
|
||||
|
||||
This deliberately has no model call and no Scrivener write path. It is a
|
||||
small standard-library server so the first workflow test has no dependency
|
||||
installation step.
|
||||
It previews and sends packages, and makes carefully scoped, lock-guarded
|
||||
writes for accepted drafts. It remains a standard-library server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from http import HTTPStatus
|
||||
@@ -22,6 +27,7 @@ from urllib.parse import urlparse
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
DEFAULT_PROJECT = APP_DIR.parent / "hapagirlsauthoringsystemv1.scriv"
|
||||
OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -54,6 +60,24 @@ def rtf_to_text(path: Path) -> str:
|
||||
return re.sub(r"\n{3,}", "\n\n", raw).strip()
|
||||
|
||||
|
||||
def text_to_rtf(value: str) -> str:
|
||||
"""Create deliberately simple RTF suitable for Scrivener text documents."""
|
||||
escaped = []
|
||||
for character in value.replace("\r\n", "\n").replace("\r", "\n"):
|
||||
if character == "\n":
|
||||
escaped.append("\\par\n")
|
||||
elif character in "\\{}":
|
||||
escaped.append("\\" + character)
|
||||
elif ord(character) > 127:
|
||||
codepoint = ord(character)
|
||||
if codepoint > 32767:
|
||||
codepoint -= 65536
|
||||
escaped.append(f"\\u{codepoint}?")
|
||||
else:
|
||||
escaped.append(character)
|
||||
return "{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0\\fnil Helvetica;}}\n\\f0\\fs24 " + "".join(escaped) + "\n}"
|
||||
|
||||
|
||||
class ScrivenerReader:
|
||||
def __init__(self, project: Path):
|
||||
self.project = project
|
||||
@@ -175,11 +199,163 @@ class ScrivenerReader:
|
||||
}
|
||||
|
||||
|
||||
class ScrivenerWriter:
|
||||
"""The small, guarded subset of Scrivener writes needed by this prototype."""
|
||||
|
||||
def __init__(self, project: Path):
|
||||
self.project = project
|
||||
self.reader = ScrivenerReader(project)
|
||||
|
||||
def _assert_unlocked(self) -> None:
|
||||
if (self.project / "Files" / "user.lock").exists():
|
||||
raise ValueError("Scrivener is open (Files/user.lock exists). Close the project before writing.")
|
||||
|
||||
def _tree_and_binder(self) -> tuple[ET.ElementTree, ET.Element]:
|
||||
tree = ET.parse(self.reader.project_file)
|
||||
binder = tree.getroot().find("Binder")
|
||||
if binder is None:
|
||||
raise ValueError("Scrivener project has no Binder.")
|
||||
return tree, binder
|
||||
|
||||
@staticmethod
|
||||
def _find_child(element: ET.Element, title: str) -> ET.Element | None:
|
||||
children = element.find("Children")
|
||||
if children is None:
|
||||
return None
|
||||
return next((item for item in children.findall("BinderItem") if (item.findtext("Title") or "").strip() == title), None)
|
||||
|
||||
def _folder(self, binder: ET.Element, path: list[str]) -> ET.Element:
|
||||
parent: ET.Element | None = None
|
||||
children = binder
|
||||
for title in path:
|
||||
parent = next((item for item in children.findall("BinderItem") if (item.findtext("Title") or "").strip() == title), None)
|
||||
if parent is None:
|
||||
raise ValueError("Required Scrivener folder is missing: " + " → ".join(path))
|
||||
children = parent.find("Children")
|
||||
if children is None and title != path[-1]:
|
||||
raise ValueError("Required Scrivener folder has no children: " + title)
|
||||
assert parent is not None
|
||||
return parent
|
||||
|
||||
def _backup_binder(self) -> None:
|
||||
backup_dir = self.project / "Files" / "authoring-backups"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
shutil.copy2(self.reader.project_file, backup_dir / f"{stamp}.scrivx")
|
||||
checksum = self.project / "Files" / "Data" / "docs.checksum"
|
||||
if checksum.exists():
|
||||
shutil.copy2(checksum, backup_dir / f"{stamp}.docs.checksum")
|
||||
|
||||
def _update_checksum(self, uuid: str, content: bytes) -> None:
|
||||
checksum_path = self.project / "Files" / "Data" / "docs.checksum"
|
||||
digest = hashlib.sha1(content).hexdigest()
|
||||
key = f"{uuid.lower()}/content.rtf"
|
||||
lines = checksum_path.read_text(encoding="utf-8", errors="replace").splitlines() if checksum_path.exists() else []
|
||||
updated = False
|
||||
output = []
|
||||
for line in lines:
|
||||
if line.startswith(key + "="):
|
||||
output.append(f"{key}={digest}")
|
||||
updated = True
|
||||
else:
|
||||
output.append(line)
|
||||
if not updated:
|
||||
output.append(f"{key}={digest}")
|
||||
checksum_path.write_text("\n".join(output) + "\n", encoding="utf-8")
|
||||
|
||||
def _write_rtf(self, uuid: str, text: str) -> None:
|
||||
content = text_to_rtf(text).encode("utf-8")
|
||||
data_dir = self.project / "Files" / "Data" / uuid
|
||||
data_dir.mkdir(parents=True, exist_ok=False)
|
||||
(data_dir / "content.rtf").write_bytes(content)
|
||||
self._update_checksum(uuid, content)
|
||||
|
||||
def _add_text(self, folder: ET.Element, title: str, text: str) -> str:
|
||||
children = folder.find("Children")
|
||||
if children is None:
|
||||
children = ET.SubElement(folder, "Children")
|
||||
doc_uuid = str(uuid.uuid4()).upper()
|
||||
stamp = dt.datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
item = ET.SubElement(children, "BinderItem", {"UUID": doc_uuid, "Type": "Text", "Created": stamp, "Modified": stamp})
|
||||
ET.SubElement(item, "Title").text = title
|
||||
metadata = ET.SubElement(item, "MetaData")
|
||||
ET.SubElement(metadata, "IncludeInCompile").text = "No"
|
||||
settings = ET.SubElement(item, "TextSettings")
|
||||
ET.SubElement(settings, "TextSelection").text = "0,0"
|
||||
self._write_rtf(doc_uuid, text)
|
||||
return doc_uuid
|
||||
|
||||
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:
|
||||
self._assert_unlocked()
|
||||
if not draft_text.strip() or not package.strip():
|
||||
raise ValueError("A non-empty draft and package are required for acceptance.")
|
||||
scene = self.reader.by_uuid.get(scene_uuid)
|
||||
if scene is None:
|
||||
raise ValueError("The selected scene no longer exists.")
|
||||
self._backup_binder()
|
||||
tree, binder = self._tree_and_binder()
|
||||
accepted = self._folder(binder, ["Authoring System", "Drafting Archive", "Accepted for Editing"])
|
||||
snapshots = self._folder(binder, ["Authoring System", "Drafting Archive", "Package Snapshots"])
|
||||
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_uuid = self._add_text(snapshots, f"{scene.title} — package — {stamp}", package_header + package)
|
||||
self._save_tree(tree)
|
||||
self.reader = ScrivenerReader(self.project)
|
||||
return {"initial_uuid": initial_uuid, "working_uuid": working_uuid, "package_uuid": package_uuid}
|
||||
|
||||
def save_working_copy(self, working_uuid: str, text: str) -> None:
|
||||
self._assert_unlocked()
|
||||
item = self.reader.by_uuid.get(working_uuid)
|
||||
if item is None or item.item_type != "Text":
|
||||
raise ValueError("The working-copy archive record no longer exists.")
|
||||
path = self.project / "Files" / "Data" / working_uuid / "content.rtf"
|
||||
if not path.exists():
|
||||
raise ValueError("The working-copy archive record has no text file.")
|
||||
content = text_to_rtf(text).encode("utf-8")
|
||||
path.write_bytes(content)
|
||||
self._update_checksum(working_uuid, content)
|
||||
|
||||
def accept_into_book(self, scene_uuid: str, text: str) -> None:
|
||||
self._assert_unlocked()
|
||||
item = self.reader.by_uuid.get(scene_uuid)
|
||||
if item is None or item.item_type != "Text":
|
||||
raise ValueError("The selected Manuscript scene no longer exists.")
|
||||
manuscript = self.reader.top_level("Manuscript")
|
||||
if manuscript is None or not any(candidate.uuid == scene_uuid for candidate in self.reader.walk(manuscript.children)):
|
||||
raise ValueError("The selected item is not a Manuscript scene.")
|
||||
path = self.project / "Files" / "Data" / scene_uuid / "content.rtf"
|
||||
content = text_to_rtf(text).encode("utf-8")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
self._update_checksum(scene_uuid, content)
|
||||
|
||||
|
||||
def section(name: str, value: str) -> str:
|
||||
return f"### {name}\n{value.strip() or '(none)'}"
|
||||
|
||||
|
||||
def preflight(project: ScrivenerReader, payload: dict) -> list[str]:
|
||||
data = project.summary()
|
||||
scene = next((item for item in data["scenes"] if item["uuid"] == payload.get("scene_uuid")), None)
|
||||
if scene is None or not scene["brief"].strip():
|
||||
raise ValueError("Choose a scene with a paired BRIEF.")
|
||||
if not data["style"].strip():
|
||||
raise ValueError("STYLE is empty.")
|
||||
directive = payload.get("directive", "").strip()
|
||||
if not directive:
|
||||
raise ValueError("DIRECTIVE is required.")
|
||||
if not re.search(r"\b(maximum|max|not exceed)\b", directive, re.IGNORECASE):
|
||||
raise ValueError("DIRECTIVE needs a word maximum.")
|
||||
return ["No scene-specific NOTES or Place records selected; verify that no physical or time continuity constraint is needed."] if not payload.get("notes", "").strip() and not payload.get("place_uuids", []) else []
|
||||
|
||||
|
||||
def build_package(project: ScrivenerReader, payload: dict) -> str:
|
||||
preflight(project, payload)
|
||||
data = project.summary()
|
||||
scene = next((scene for scene in data["scenes"] if scene["uuid"] == payload.get("scene_uuid")), None)
|
||||
if scene is None:
|
||||
@@ -220,6 +396,27 @@ def build_package(project: ScrivenerReader, payload: dict) -> str:
|
||||
) + "\n"
|
||||
|
||||
|
||||
def draft(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.")
|
||||
system = project.summary()["frontier_instructions"].strip()
|
||||
package = build_package(project, payload)
|
||||
body = json.dumps({"model": OPENROUTER_MODEL, "messages": [{"role": "system", "content": system}, {"role": "user", "content": package}], "max_tokens": int(payload.get("max_tokens", 3000))}).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
|
||||
text = result.get("choices", [{}])[0].get("message", {}).get("content")
|
||||
if not isinstance(text, str):
|
||||
raise ValueError("OpenRouter response did not contain draft text.")
|
||||
return {"draft": text, "usage": result.get("usage", {}), "model": result.get("model", OPENROUTER_MODEL)}
|
||||
|
||||
|
||||
class AppHandler(SimpleHTTPRequestHandler):
|
||||
reader: ScrivenerReader
|
||||
|
||||
@@ -240,7 +437,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
self.send_json(self.reader.summary())
|
||||
return
|
||||
if path == "/api/health":
|
||||
self.send_json({"status": "ok", "mode": "read-only prototype"})
|
||||
self.send_json({"status": "ok", "mode": "local prototype"})
|
||||
return
|
||||
if path == "/":
|
||||
self.path = "/static/index.html"
|
||||
@@ -252,13 +449,36 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if urlparse(self.path).path != "/api/package-preview":
|
||||
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"}:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
self.send_json({"package": build_package(self.reader, payload)})
|
||||
if path == "/api/draft":
|
||||
self.send_json(draft(self.reader, payload))
|
||||
elif path == "/api/accept-for-editing":
|
||||
if not payload.get("accept_confirmation"):
|
||||
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))
|
||||
AppHandler.reader = writer.reader
|
||||
self.send_json(result)
|
||||
elif path == "/api/save-working-copy":
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
writer.save_working_copy(payload.get("working_uuid", ""), payload.get("text", ""))
|
||||
self.send_json({"saved": True})
|
||||
elif path == "/api/accept-into-book":
|
||||
if not payload.get("final_confirmation"):
|
||||
raise ValueError("Confirm final acceptance before writing canonical Manuscript text.")
|
||||
writer = ScrivenerWriter(self.reader.project)
|
||||
writer.accept_into_book(payload.get("scene_uuid", ""), payload.get("text", ""))
|
||||
AppHandler.reader = ScrivenerReader(self.reader.project)
|
||||
self.send_json({"accepted": True})
|
||||
else:
|
||||
self.send_json({"package": build_package(self.reader, payload), "warnings": preflight(self.reader, payload)})
|
||||
except (ValueError, json.JSONDecodeError) as error:
|
||||
self.send_json({"error": str(error)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
@@ -267,7 +487,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run the read-only authoring-system prototype.")
|
||||
parser = argparse.ArgumentParser(description="Run the local authoring-system prototype.")
|
||||
parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
args = parser.parse_args()
|
||||
@@ -276,7 +496,7 @@ def main() -> None:
|
||||
parser.error(f"Project not found: {project}")
|
||||
AppHandler.reader = ScrivenerReader(project)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", args.port), AppHandler)
|
||||
print(f"Read-only authoring prototype: http://127.0.0.1:{args.port}")
|
||||
print(f"Local authoring prototype: http://127.0.0.1:{args.port}")
|
||||
print(f"Project: {project}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
117
prototype/static/app.js
vendored
117
prototype/static/app.js
vendored
@@ -1,4 +1,9 @@
|
||||
let project;
|
||||
let initialDraft = '';
|
||||
let generatedPackage = '';
|
||||
let generatedModel = '';
|
||||
let workingUuid = '';
|
||||
let acceptedSceneUuid = '';
|
||||
|
||||
const byId = (id) => document.getElementById(id);
|
||||
const selected = (container) => [...container.querySelectorAll('input:checked')].map((input) => input.value);
|
||||
@@ -21,18 +26,18 @@ function updateSceneState() {
|
||||
|
||||
function render(data) {
|
||||
project = data;
|
||||
byId('status').textContent = data.locked ? 'Scrivener lock detected — read-only' : 'Project not locked — app remains read-only';
|
||||
const priorSceneUuid = acceptedSceneUuid || byId('scene').value;
|
||||
byId('status').textContent = data.locked ? 'Scrivener lock detected — archive and manuscript writes are disabled' : 'Project closed — accepted-draft writes are available';
|
||||
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', 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],
|
||||
].map(([label, value]) => `<dt>${label}</dt><dd>${value}</dd>`).join('');
|
||||
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);
|
||||
checks(byId('places'), data.places);
|
||||
byId('world-notes').textContent = data.world_notes || 'No project-level World Notes recorded.';
|
||||
updateSceneState();
|
||||
}
|
||||
|
||||
@@ -42,31 +47,87 @@ async function load() {
|
||||
render(await response.json());
|
||||
}
|
||||
|
||||
async function build() {
|
||||
byId('error').textContent = '';
|
||||
const payload = {
|
||||
scene_uuid: byId('scene').value,
|
||||
character_uuids: selected(byId('characters')),
|
||||
place_uuids: selected(byId('places')),
|
||||
lexicon: byId('lexicon').value,
|
||||
story: byId('story').value,
|
||||
recall: byId('recall').value,
|
||||
previous: byId('previous').value,
|
||||
notes: byId('notes').value,
|
||||
directive: byId('directive').value,
|
||||
new_thread: byId('new-thread').checked,
|
||||
};
|
||||
const response = await fetch('/api/package-preview', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||||
});
|
||||
function payload() {
|
||||
return { scene_uuid: byId('scene').value, character_uuids: selected(byId('characters')), place_uuids: selected(byId('places')), lexicon: byId('lexicon').value, story: byId('story').value, recall: byId('recall').value, previous: byId('previous').value, notes: byId('notes').value, directive: byId('directive').value, new_thread: byId('new-thread').checked };
|
||||
}
|
||||
|
||||
async function request(path, body) {
|
||||
const response = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
byId('error').textContent = data.error || 'Could not build package.';
|
||||
return;
|
||||
}
|
||||
byId('package').textContent = data.package;
|
||||
if (!response.ok) throw new Error(data.error || 'The request could not be completed.');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function build() {
|
||||
byId('error').textContent = ''; byId('warnings').textContent = '';
|
||||
try {
|
||||
const data = await request('/api/package-preview', payload());
|
||||
generatedPackage = data.package;
|
||||
byId('package').textContent = data.package;
|
||||
byId('warnings').textContent = (data.warnings || []).join(' ');
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
function resetDraftState(message = 'An outright rejection writes nothing to Scrivener.') {
|
||||
initialDraft = ''; generatedPackage = ''; generatedModel = ''; workingUuid = ''; acceptedSceneUuid = '';
|
||||
byId('working-draft').value = ''; byId('working-draft').disabled = true;
|
||||
byId('accept-confirmation').checked = false; byId('final-confirmation').checked = false;
|
||||
['accept-editing', 'reject-draft', 'save-working', 'accept-book'].forEach((id) => { byId(id).disabled = true; });
|
||||
byId('archive-status').textContent = message;
|
||||
}
|
||||
|
||||
async function generateDraft() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
const data = await request('/api/draft', { ...payload(), send_confirmation: byId('send-confirmation').checked, max_tokens: 3000 });
|
||||
initialDraft = data.draft; generatedModel = data.model;
|
||||
const preview = await request('/api/package-preview', payload());
|
||||
generatedPackage = preview.package;
|
||||
byId('working-draft').value = data.draft;
|
||||
byId('working-draft').disabled = true;
|
||||
byId('accept-editing').disabled = false; byId('reject-draft').disabled = false;
|
||||
byId('archive-status').textContent = 'This generated text is not yet saved in Scrivener.';
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function acceptForEditing() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
const data = await request('/api/accept-for-editing', { ...payload(), draft: initialDraft, model: generatedModel, accept_confirmation: byId('accept-confirmation').checked });
|
||||
workingUuid = data.working_uuid;
|
||||
acceptedSceneUuid = byId('scene').value;
|
||||
byId('working-draft').value = initialDraft;
|
||||
byId('working-draft').disabled = false;
|
||||
byId('accept-editing').disabled = true; byId('reject-draft').disabled = true;
|
||||
byId('save-working').disabled = false; byId('accept-book').disabled = false;
|
||||
byId('archive-status').textContent = 'Initial draft and exact package saved. You may now edit the working copy.';
|
||||
await load();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function saveWorkingCopy() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
await request('/api/save-working-copy', { working_uuid: workingUuid, text: byId('working-draft').value });
|
||||
byId('archive-status').textContent = 'Working copy saved in Scrivener.';
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
async function acceptIntoBook() {
|
||||
byId('error').textContent = '';
|
||||
try {
|
||||
await request('/api/save-working-copy', { working_uuid: workingUuid, text: byId('working-draft').value });
|
||||
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();
|
||||
} catch (error) { byId('error').textContent = error.message; }
|
||||
}
|
||||
|
||||
byId('scene').addEventListener('change', updateSceneState);
|
||||
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);
|
||||
load().catch((error) => { byId('status').textContent = error.message; byId('status').className = 'status warning'; });
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">READ-ONLY PROTOTYPE</p>
|
||||
<p class="eyebrow">LOCAL PROTOTYPE</p>
|
||||
<h1>Package Preview</h1>
|
||||
</div>
|
||||
<div id="status" class="status">Loading project…</div>
|
||||
@@ -32,6 +32,10 @@
|
||||
<h2>Author BRIEF <span class="read-only">read-only</span></h2>
|
||||
<pre id="brief">Select a scene to view its paired BRIEF.</pre>
|
||||
</section>
|
||||
<section class="brief-panel">
|
||||
<h2>World Notes <span class="read-only">read-only reference</span></h2>
|
||||
<pre id="world-notes">Loading World Notes…</pre>
|
||||
</section>
|
||||
<div class="grid">
|
||||
<label>LEXICON (manual subset)
|
||||
<textarea id="lexicon" rows="4" placeholder="Term — usage note"></textarea>
|
||||
@@ -54,9 +58,25 @@
|
||||
</div>
|
||||
<label class="checkbox"><input id="new-thread" type="checkbox" checked> Treat empty STORY/PREVIOUS as a new narrative thread</label>
|
||||
<button id="build">Build package preview</button>
|
||||
<label class="checkbox"><input id="send-confirmation" type="checkbox"> I understand this sends the package to OpenRouter.</label>
|
||||
<button id="draft">Generate temporary draft — no Scrivener write</button>
|
||||
<p id="error" class="error"></p>
|
||||
<p id="warnings" class="warnings"></p>
|
||||
<h2>Exact package</h2>
|
||||
<pre id="package">Select a scene, choose context, then build a preview.</pre>
|
||||
<h2>Temporary draft</h2>
|
||||
<textarea id="working-draft" rows="20" disabled placeholder="A generated draft will appear here."></textarea>
|
||||
<div class="draft-actions">
|
||||
<label class="checkbox"><input id="accept-confirmation" type="checkbox"> I accept this initial draft for editing and archival.</label>
|
||||
<button id="accept-editing" disabled>Accept draft for editing</button>
|
||||
<button id="reject-draft" disabled>Reject draft — discard it</button>
|
||||
<button id="save-working" disabled>Save working copy</button>
|
||||
</div>
|
||||
<p id="archive-status" class="muted">An outright rejection writes nothing to Scrivener.</p>
|
||||
<div class="final-actions">
|
||||
<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>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
@@ -8,7 +8,10 @@ aside { padding: 1.2rem 1.5rem; border-right: 1px solid #d8d2c5; background: #fc
|
||||
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; }
|
||||
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; }
|
||||
#working-draft { min-height: 28rem; max-height: 65vh; resize: vertical; background: #fffefb; color: #17212b; font: .9rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
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; } }
|
||||
|
||||
Reference in New Issue
Block a user