Skip to content

Commit 6bd11e3

Browse files
committed
fix: render the story prose instead of its Markdown source
The editable paragraphs showed the raw Markdown the model writes, so the story read as source rather than prose. Render each paragraph and swap to the source only while it is being edited, rendering again on blur, using the same Markdown engine the Markdown pane uses.
1 parent 7487007 commit 6bd11e3

4 files changed

Lines changed: 71 additions & 13 deletions

File tree

lumen/ai/agents/story.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,10 @@ class StoryAgent(LLMUser):
7575
"template": PROMPTS_DIR / "StoryAgent" / "main.jinja2",
7676
"response_model": Story,
7777
},
78-
"edit": {
79-
"template": PROMPTS_DIR / "StoryAgent" / "edit.jinja2",
78+
# Named "rewrite" rather than "edit": model_kwargs maps an "edit"
79+
# spec to a pricier model on some providers, and this is a small job.
80+
"rewrite": {
81+
"template": PROMPTS_DIR / "StoryAgent" / "rewrite.jinja2",
8082
"response_model": ProseEdit,
8183
},
8284
}
@@ -85,7 +87,7 @@ class StoryAgent(LLMUser):
8587
async def rewrite_prose(self, prose: str, instruction: str, catalog: str = "") -> ProseEdit:
8688
"""Rewrite a single paragraph of the story following the user's instruction."""
8789
return await self._invoke_prompt(
88-
"edit",
90+
"rewrite",
8991
messages=[{"role": "user", "content": "Rewrite the paragraph."}],
9092
context={},
9193
prose=prose,
File renamed without changes.

lumen/ai/report.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,29 +86,52 @@ def _export_header(section, status_source, variant="h3"):
8686

8787
class EditableProse(JSComponent):
8888
"""
89-
A paragraph the user can edit in place, like a document editor.
89+
A paragraph of Markdown the user can edit in place, like a document editor.
9090
91-
Typing syncs the text back to ``value``. ``value`` is only written back into
92-
the element when it differs from what is already there, because assigning to
93-
a contenteditable node resets the caret to the start, which would make it
91+
It shows the rendered Markdown until it is clicked, then swaps to the raw
92+
text to edit and renders again on blur, so the story reads as prose rather
93+
than as Markdown source. ``value`` is only written back into the element
94+
when it differs from what is already there, because assigning to a
95+
contenteditable node resets the caret to the start, which would make it
9496
impossible to type.
9597
"""
9698

9799
value = param.String(default="", doc="""
98-
The edited text.""")
100+
The Markdown source of the paragraph.""")
101+
102+
_rendered = param.String(default="", doc="""
103+
``value`` rendered to HTML, shown while the paragraph is not edited.""")
99104

100105
_esm = """
101106
export function render({ model }) {
102107
const div = document.createElement('div')
103-
div.contentEditable = 'true'
104108
div.className = 'editable-prose'
105-
div.textContent = model.value
109+
div.title = 'Click to edit'
110+
111+
const show = () => { div.innerHTML = model._rendered }
112+
const edit = () => {
113+
div.textContent = model.value
114+
div.contentEditable = 'true'
115+
div.classList.add('editing')
116+
div.focus()
117+
}
118+
119+
div.addEventListener('click', () => { if (div.contentEditable !== 'true') edit() })
106120
div.addEventListener('input', () => { model.value = div.textContent })
121+
div.addEventListener('blur', () => {
122+
div.contentEditable = 'false'
123+
div.classList.remove('editing')
124+
show()
125+
})
126+
model.on('_rendered', () => { if (div.contentEditable !== 'true') show() })
107127
model.on('value', () => {
108-
if (div.textContent !== model.value) {
128+
// Only while editing, and only if it really changed, or the caret jumps.
129+
if (div.contentEditable === 'true' && div.textContent !== model.value) {
109130
div.textContent = model.value
110131
}
111132
})
133+
134+
show()
112135
return div
113136
}
114137
"""
@@ -119,13 +142,30 @@ class EditableProse(JSComponent):
119142
outline: none;
120143
padding: 4px 6px;
121144
border-radius: 4px;
122-
white-space: pre-wrap;
145+
cursor: text;
123146
}
147+
.editable-prose > :first-child { margin-top: 0; }
148+
.editable-prose > :last-child { margin-bottom: 0; }
124149
.editable-prose:hover { background: rgba(127, 127, 127, 0.08); }
125-
.editable-prose:focus { background: rgba(127, 127, 127, 0.12); }
150+
.editable-prose.editing {
151+
background: rgba(127, 127, 127, 0.12);
152+
white-space: pre-wrap;
153+
}
126154
"""
127155
]
128156

157+
def __init__(self, **params):
158+
super().__init__(**params)
159+
self._render_markdown()
160+
161+
@param.depends("value", watch=True)
162+
def _render_markdown(self):
163+
# Rendered with the same engine as Panel's Markdown pane, so the story
164+
# reads identically on screen and in the exports.
165+
from markdown_it import MarkdownIt
166+
167+
self._rendered = MarkdownIt("gfm-like").render(self.value)
168+
129169

130170
class Task(Viewer):
131171
"""

lumen/tests/ai/test_story.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,22 @@ async def test_story_prose_is_editable_and_edits_reach_the_exports(llm, tiny_sou
308308
assert _prose_editors(report)[0].value == "Edited by hand."
309309

310310

311+
def test_editable_prose_renders_markdown():
312+
from lumen.ai.report import EditableProse
313+
314+
prose = EditableProse(value="**Pop** leads with *1,252*")
315+
316+
# The story shows rendered prose, not Markdown source.
317+
assert "<strong>Pop</strong>" in prose._rendered
318+
assert "<em>1,252</em>" in prose._rendered
319+
320+
# Editing the text re-renders it.
321+
prose.value = "## Heading"
322+
assert "<h2>" in prose._rendered
323+
# The source is kept as written, so it exports as Markdown.
324+
assert prose.value == "## Heading"
325+
326+
311327
async def test_story_prose_rewritten_by_ai(llm, tiny_source):
312328
from lumen.ai.agents.story import ProseEdit, Story, StoryBlock
313329

0 commit comments

Comments
 (0)