Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions containers/kr0ki-storyb00k-agent/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ def render_recommendation_samples(recommendations):
return panels


def panel_from_tool_result(name, arguments, content_type, result):
def panel_from_tool_result(name, arguments, content_type, result, tool_call_id=None):
"""Preserve binary render output rather than corrupting PNG bytes as UTF-8."""
# Tool-name → editor format/route mapping: the k8s tools take a `manifest`
# arg and have no `format` argument, so without this the EDIT button would
Expand All @@ -486,6 +486,7 @@ def panel_from_tool_result(name, arguments, content_type, result):
panel = {
"kind": "render" if name.startswith("render_") else "query-result",
"toolName": name,
"toolCallId": tool_call_id, # lets the UI tie this picture to the tool call that made it
"source": {
"text": arguments.get("source") or arguments.get("manifest"),
"format": panel_format,
Expand Down Expand Up @@ -984,14 +985,14 @@ def _run(self, stream, payload, thread_id, draft, run_log):
if diagram_context and diagram_context.get("source"):
ctx = diagram_context
context_msg = (
f"DIAGRAM CONTEXT (from Code Editor handoff):\n"
f"CURRENT DIAGRAM (the user's working state; the Code Editor handoff or the revision they restored or last edited):\n"
f"Title: {ctx.get('title', 'Untitled')}\n"
f"Format: {ctx.get('format', 'unknown')}\n"
f"Detected Type: {ctx.get('detectedType', 'unknown')}\n"
f"Output: {ctx.get('output', 'svg')}\n"
f"\nDiagram Source Code:\n```\n{ctx['source']}\n```\n"
f"\nThe user wants you to review this diagram and suggest improvements. "
f"Analyze both the code structure and suggest specific improvements."
f"\nApply the user's next request to THIS diagram: start from this source and change only what they ask for. "
f"If they ask for a review, analyze the code structure and suggest specific improvements."
)
messages.append({"role": "user", "content": context_msg})
# Clear the context after injecting it (one-time use)
Expand Down Expand Up @@ -1301,7 +1302,7 @@ def _run(self, stream, payload, thread_id, draft, run_log):
content_type, result = http_call(method, url, body)
if call_key:
completed_calls.add(call_key)
panel = panel_from_tool_result(name, arguments, content_type, result)
panel = panel_from_tool_result(name, arguments, content_type, result, tool_call_id)
tool_content = panel["content"] or "Rendered binary image."
tool_image_data_url = panel.get("imageDataUrl")
tool_ok = True
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ confidence: recalled (SysML v2 textual notation; always confirm with /sysml/vali
2. Run `/sysml/validate` (the Validate button in Projects). Fix every syntax error before presenting; semantic notes such as missing documentation are advice.
3. Say what the validator cannot tell you: the sidecar does not surface `satisfy` relations or short names, and does not parse pure KerML.

## Reserved words are not names
SysML v2 keywords cannot be used as element names. Verified against the validator (2026-10-07): `part render : T;` and `attribute subject : String;` fail with
`no viable alternative at input 'partrender'` / `'attributesubject'`. The message joins the two tokens without a space (that is how the parser prints it): it
does NOT mean whitespace was lost. Read it as "the name after this keyword is reserved". Rename (`renderer`, `subj`) or quote the name: `'render'`.
Also verified as rejected names: `view`, `viewpoint`, `verify`, `state`, `action`, `item`, `port`, `flow`. Quoting (`'render'`) is accepted.

## Do not
- Invent library types. If you are unsure a type exists, model it as a local `part def`.
- Present unvalidated text as correct.
18 changes: 18 additions & 0 deletions containers/kr0ki-storyb00k-agent/test_panels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import unittest

import server


class PanelTest(unittest.TestCase):
def test_a_render_panel_names_the_tool_call_that_made_it(self):
panel = server.panel_from_tool_result("render_diagram", {"format": "d2", "source": "a -> b"}, "image/svg+xml", b"<svg/>", "call-7")
self.assertEqual(panel["toolCallId"], "call-7")
self.assertEqual(panel["source"]["text"], "a -> b")

def test_the_tool_call_id_is_optional(self):
panel = server.panel_from_tool_result("render_diagram", {"format": "d2", "source": "x"}, "image/svg+xml", b"<svg/>")
self.assertIsNone(panel["toolCallId"])


if __name__ == "__main__":
unittest.main()
1 change: 1 addition & 0 deletions playbook/src/App.assurance.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const AssuranceStub = {
}

beforeEach(() => {
window.history.replaceState(null, '', '#/gallery') // deep links persist across tests in jsdom
sessionStorage.clear()
localStorage.clear()
Element.prototype.scrollIntoView = vi.fn()
Expand Down
15 changes: 15 additions & 0 deletions playbook/src/App.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const StoryStub = { name: 'StoryB00k', props: { planner: Boolean, threadId: Stri

let renders
beforeEach(() => {
window.history.replaceState(null, '', '#/gallery') // deep links persist across tests in jsdom
renders = []
Element.prototype.scrollIntoView = vi.fn()
vi.stubGlobal('CSS', { escape: (s) => s })
Expand Down Expand Up @@ -42,6 +43,20 @@ describe('App navigation', () => {
w.unmount()
})

it('makes each tab a history entry, so Back returns to the previous tab instead of leaving kr0ki', async () => {
const w = await mountApp()
await w.find('[data-testid="tab-editor"]').trigger('click')
await w.find('[data-testid="tab-agent"]').trigger('click')
expect(window.location.hash).toBe('#/agent')
expect(w.find('[data-testid="tab-agent"]').classes()).toContain('active')
window.history.back()
await new Promise((r) => setTimeout(r, 50)) // jsdom fires popstate asynchronously
await flushPromises()
expect(window.location.hash).toBe('#/editor')
expect(w.find('[data-testid="tab-editor"]').classes()).toContain('active')
w.unmount()
})

it('shows the diagram-type tree in the sidebar, and selecting a type opens the editor and renders it', async () => {
const w = await mountApp()
expect(w.find('[data-testid="catalog-tree"]').exists()).toBe(true)
Expand Down
10 changes: 9 additions & 1 deletion playbook/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ProjectsView from './components/ProjectsView.vue'
import AssuranceView from './components/AssuranceView.vue'
import { connectUiBridge, plannerSessionId } from './lib/uiBridge.js'
import { loadLspUrls, saveLspUrls } from './lib/lsp.js'
import { bindViewHistory } from './lib/viewHistory.js'

// Version from Cargo.toml (injected at build time by Vite)
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'
Expand All @@ -23,6 +24,10 @@ const viewMode = ref('gallery')
// The planner chat stays mounted once opened, so its conversation survives switching tabs.
const plannerMounted = ref(false)
watch(viewMode, (v) => { if (v === 'planner') plannerMounted.value = true })
// Each tab is a browser history entry (#/agent, #/editor, ...), so Back returns to the previous tab instead of leaving kr0ki.
const viewHistory = bindViewHistory({ getView: () => viewMode.value, setView: (v) => { viewMode.value = v } })
watch(viewMode, (v) => viewHistory.push(v))
onBeforeUnmount(() => viewHistory.stop())
// Source handed over from StoryB00k's EDIT button (agent-rendered diagram).
const editedSource = ref('')
const editedRoute = ref(null)
Expand Down Expand Up @@ -364,7 +369,10 @@ onMounted(async () => {
@open-in-editor="openInEditor"
@agent-handoff="agentHandoff"
/>
<StoryB00k v-else-if="viewMode === 'storyb00k'" :prefill="agentPrefill" :locked-type="agentTypeId" :llm-url="llmUrl" :llm-key="llmKey" :llm-model="llmModel" :agent-url="agentUrl" :editor-handoff="editorHandoff" @edit-in-editor="editInEditor" />
<!-- Kept alive so leaving the tab (or pressing Back) does not throw away the transcript, panels and revisions. -->
<KeepAlive v-else-if="viewMode === 'storyb00k'">
<StoryB00k :prefill="agentPrefill" :locked-type="agentTypeId" :llm-url="llmUrl" :llm-key="llmKey" :llm-model="llmModel" :agent-url="agentUrl" :renderer-url="rendererUrl" :editor-handoff="editorHandoff" @edit-in-editor="editInEditor" />
</KeepAlive>
<RendererPanel
v-else-if="selectedExample"
:example="selectedExample"
Expand Down
90 changes: 90 additions & 0 deletions playbook/src/components/CheckpointDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script setup>
// "Save to project": commit a diagram into one of the local projects (or a new one) as a checkpoint, so it can be recovered later
// from Projects -> History. The store is opened only when the person acts, so rendering this panel never touches storage.
import { computed, ref } from 'vue'
import { LocalStorage } from 'quasar'
import { createProjectStore } from '../lib/projects.js'
import { checkpointPath, saveCheckpoint } from '../lib/checkpoint.js'

const props = defineProps({
source: { type: String, default: '' },
format: { type: String, default: 'd2' },
title: { type: String, default: 'diagram' },
/** injected in tests */
store: { type: Object, default: null },
})
const emit = defineEmits(['saved'])

let store = props.store
const open = ref(false)
const projects = ref([])
const choice = ref('__new__')
const newName = ref('')
const path = ref('')
const message = ref('')
const notice = ref(null)

function ensureStore() {
store = store || createProjectStore({ storage: LocalStorage })
return store
}
function toggle() {
open.value = !open.value
if (!open.value) return
projects.value = ensureStore().listProjects()
choice.value = projects.value[0]?.id || '__new__'
newName.value = newName.value || props.title || 'Diagrams'
path.value = checkpointPath(props.format, props.title)
message.value = `Checkpoint: ${props.title}`
notice.value = null
}
const creating = computed(() => choice.value === '__new__')

function save() {
try {
const r = saveCheckpoint(ensureStore(), {
projectId: creating.value ? '' : choice.value,
newProjectName: newName.value,
path: path.value.trim(),
source: props.source,
message: message.value.trim(),
})
notice.value = r.unchanged
? { kind: 'ok', text: `Nothing to save: ${path.value} already has exactly this code in "${r.project.name}".` }
: { kind: 'ok', text: `Saved checkpoint ${r.commit.id.slice(0, 8)} to "${r.project.name}". Recover it in Projects → History → Restore.` }
projects.value = ensureStore().listProjects()
if (!r.unchanged) emit('saved', { project: r.project, commit: r.commit, path: path.value })
} catch (err) {
notice.value = { kind: 'error', text: err.message }
}
}
</script>

<template>
<div class="cp" data-testid="checkpoint">
<button type="button" data-testid="cp-toggle" :disabled="!source.trim()" title="Commit this diagram's code to a project so you can come back to it" @click="toggle">📌 Save to project…</button>
<form v-if="open" class="cp__form" @submit.prevent="save">
<label>Project
<select v-model="choice" data-testid="cp-project">
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
<option value="__new__">+ New project…</option>
</select>
</label>
<label v-if="creating">New project name <input v-model="newName" data-testid="cp-name" maxlength="80" /></label>
<label>File <input v-model="path" data-testid="cp-path" spellcheck="false" /></label>
<label>Note <input v-model="message" data-testid="cp-message" /></label>
<button type="submit" data-testid="cp-save">Save checkpoint</button>
<p v-if="notice" class="cp__notice" :data-kind="notice.kind" data-testid="cp-notice" role="status">{{ notice.text }}</p>
</form>
</div>
</template>

<style scoped>
.cp { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; }
.cp__form { display: flex; flex-direction: column; gap: .35rem; padding: .5rem .6rem; border: 1px solid #c8ced8; border-radius: 6px; font-size: .82rem; width: min(100%, 28rem); box-sizing: border-box; }
.cp__form label { display: flex; flex-direction: column; gap: .1rem; }
.cp__form input, .cp__form select { font: inherit; padding: .15rem .3rem; }
.cp__notice { margin: 0; }
.cp__notice[data-kind='ok'] { color: #15803d; }
.cp__notice[data-kind='error'] { color: #b91c1c; }
</style>
30 changes: 29 additions & 1 deletion playbook/src/components/RendererPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { computed, onMounted, ref, watch } from 'vue'
import { fetchSkill } from '../lib/skills.js'
import { LANGUAGES, lspCapable } from '../lib/lsp.js'
import CodeEditor from './CodeEditor.vue'
import ZoomPan from './ZoomPan.vue'
import { svgSize } from '../lib/zoomPan.js'
import CheckpointDialog from './CheckpointDialog.vue'
import { downloadText, sourceFilename } from '../lib/renderSource.js'

const props = defineProps({
example: { type: Object, required: true },
Expand Down Expand Up @@ -41,6 +45,7 @@ const getFallbackUrl = () => {
}
const localRendererUrl = ref(getFallbackUrl())
const artifactUrl = ref('')
const artifactSize = ref(null) // natural size of an svg artifact, read from its text (an <img> of a viewBox-only svg reports none)
const result = ref('Ready')
const busy = ref(false)
const autoRender = ref(true)
Expand Down Expand Up @@ -184,6 +189,7 @@ async function render() {
})
const bytes = await response.blob()
if (!response.ok) throw new Error(await bytes.text())
artifactSize.value = output.value === 'png' ? null : svgSize(await bytes.text())
artifactUrl.value = URL.createObjectURL(bytes)
result.value = `${response.headers.get('x-kr0ki-cache') || 'miss'} · ${response.headers.get('x-kr0ki-key') || 'no cache key'}`
} catch (error) {
Expand All @@ -193,6 +199,20 @@ async function render() {
}
}

function downloadCode() {
downloadText(sourceFilename(props.example.format, props.example.id || 'diagram'), source.value)
}
// The rendered artifact is a blob: URL already; download it under a name that says what it is.
function downloadImage() {
if (!artifactUrl.value) return
const a = document.createElement('a')
a.href = artifactUrl.value
a.download = `${props.example.id || 'diagram'}.${output.value === 'png' ? 'png' : 'svg'}`
document.body.appendChild(a)
a.click()
a.remove()
}

// Diagram type detection (best-guess regex)
function detectDiagramType(code) {
if (!code) return 'unknown'
Expand Down Expand Up @@ -349,8 +369,15 @@ async function sendToAgent() {
</div>
<section class="preview" aria-live="polite">
<p class="status">{{ result }}</p>
<img v-if="artifactUrl" :src="artifactUrl" :alt="`${example.title} rendered output`" />
<ZoomPan v-if="artifactUrl" :content-key="artifactUrl" :size="artifactSize" height="30rem">
<img :src="artifactUrl" :alt="`${example.title} rendered output`" />
</ZoomPan>
<p v-else class="empty">Render the fixture to inspect its artifact here.</p>
<div class="export-row" data-testid="export-row">
<button type="button" data-testid="export-code" :disabled="!source.trim()" title="Download the diagram code" @click="downloadCode">⬇ Code</button>
<button type="button" data-testid="export-image" :disabled="!artifactUrl" title="Download the rendered image" @click="downloadImage">⬇ {{ output.toUpperCase() }}</button>
<CheckpointDialog :source="source" :format="example.format" :title="example.title" />
</div>
</section>
</div>
</article>
Expand All @@ -374,5 +401,6 @@ async function sendToAgent() {
.toast-leave-active { transition: opacity .4s ease; }
.toast-enter-from { opacity: 0; transform: translateY(-4px); }
.toast-leave-to { opacity: 0; }
.export-row { display: flex; flex-wrap: wrap; gap: .5rem; align-items: flex-start; margin-top: .5rem; }
</style>

Loading
Loading