@cat-factory/app 0.87.3 → 0.87.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/common/MarkdownProse.vue +109 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +8 -5
- package/app/components/panels/GenericStructuredResultView.vue +12 -8
- package/app/components/panels/MergerResultView.vue +9 -8
- package/app/utils/agentOutput.spec.ts +44 -1
- package/app/utils/agentOutput.ts +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shared inline markdown renderer for agent prose (a rationale, a synthesis, a summary).
|
|
3
|
+
// Routes text through the secure `renderMarkdown` reader (markdown-it, `html: false`, links
|
|
4
|
+
// decorated to open safely in a new tab) instead of the `whitespace-pre-wrap` plain-text
|
|
5
|
+
// dumps several result views used to show (UX-43) — so `**bold**`, lists, code, and links in
|
|
6
|
+
// an agent's output read as formatted prose, consistently with `AgentStepDetail`'s reader.
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import { renderMarkdown } from '~/utils/agentOutput'
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
/** The agent's raw markdown text. */
|
|
12
|
+
text: string | null | undefined
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const html = computed(() => renderMarkdown(props.text))
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<!-- eslint-disable-next-line vue/no-v-html (sanitized by renderMarkdown, html: false) -->
|
|
20
|
+
<div class="cf-prose" v-html="html" />
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<style scoped>
|
|
24
|
+
/* Prose styling for the sanitized markdown injected via v-html (out of scoped reach
|
|
25
|
+
without :deep), mirroring the inspector reader's prose styling. */
|
|
26
|
+
.cf-prose :deep(p) {
|
|
27
|
+
margin: 0.5rem 0;
|
|
28
|
+
}
|
|
29
|
+
.cf-prose :deep(p:first-child) {
|
|
30
|
+
margin-top: 0;
|
|
31
|
+
}
|
|
32
|
+
.cf-prose :deep(p:last-child) {
|
|
33
|
+
margin-bottom: 0;
|
|
34
|
+
}
|
|
35
|
+
.cf-prose :deep(ul),
|
|
36
|
+
.cf-prose :deep(ol) {
|
|
37
|
+
margin: 0.5rem 0;
|
|
38
|
+
padding-left: 1.25rem;
|
|
39
|
+
}
|
|
40
|
+
.cf-prose :deep(ul) {
|
|
41
|
+
list-style: disc;
|
|
42
|
+
}
|
|
43
|
+
.cf-prose :deep(ol) {
|
|
44
|
+
list-style: decimal;
|
|
45
|
+
}
|
|
46
|
+
.cf-prose :deep(li) {
|
|
47
|
+
margin: 0.2rem 0;
|
|
48
|
+
}
|
|
49
|
+
.cf-prose :deep(strong) {
|
|
50
|
+
font-weight: 600;
|
|
51
|
+
color: rgb(226 232 240);
|
|
52
|
+
}
|
|
53
|
+
.cf-prose :deep(em) {
|
|
54
|
+
font-style: italic;
|
|
55
|
+
}
|
|
56
|
+
.cf-prose :deep(code) {
|
|
57
|
+
border-radius: 0.25rem;
|
|
58
|
+
background: rgb(30 41 59 / 0.8);
|
|
59
|
+
padding: 0.1rem 0.3rem;
|
|
60
|
+
font-family: ui-monospace, monospace;
|
|
61
|
+
font-size: 0.85em;
|
|
62
|
+
color: rgb(199 210 254);
|
|
63
|
+
}
|
|
64
|
+
.cf-prose :deep(pre) {
|
|
65
|
+
margin: 0.6rem 0;
|
|
66
|
+
overflow: auto;
|
|
67
|
+
border-radius: 0.5rem;
|
|
68
|
+
background: rgb(2 6 23 / 0.6);
|
|
69
|
+
padding: 0.75rem 0.9rem;
|
|
70
|
+
}
|
|
71
|
+
.cf-prose :deep(pre code) {
|
|
72
|
+
background: transparent;
|
|
73
|
+
padding: 0;
|
|
74
|
+
color: rgb(203 213 225);
|
|
75
|
+
}
|
|
76
|
+
.cf-prose :deep(blockquote) {
|
|
77
|
+
margin: 0.6rem 0;
|
|
78
|
+
border-left: 3px solid rgb(99 102 241 / 0.5);
|
|
79
|
+
padding-left: 0.75rem;
|
|
80
|
+
color: rgb(148 163 184);
|
|
81
|
+
}
|
|
82
|
+
.cf-prose :deep(h1),
|
|
83
|
+
.cf-prose :deep(h2),
|
|
84
|
+
.cf-prose :deep(h3),
|
|
85
|
+
.cf-prose :deep(h4) {
|
|
86
|
+
margin: 0.7rem 0 0.4rem;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
color: rgb(226 232 240);
|
|
89
|
+
}
|
|
90
|
+
.cf-prose :deep(table) {
|
|
91
|
+
margin: 0.6rem 0;
|
|
92
|
+
border-collapse: collapse;
|
|
93
|
+
font-size: 0.95em;
|
|
94
|
+
}
|
|
95
|
+
.cf-prose :deep(th),
|
|
96
|
+
.cf-prose :deep(td) {
|
|
97
|
+
border: 1px solid rgb(51 65 85);
|
|
98
|
+
padding: 0.3rem 0.6rem;
|
|
99
|
+
}
|
|
100
|
+
.cf-prose :deep(th) {
|
|
101
|
+
background: rgb(30 41 59 / 0.6);
|
|
102
|
+
font-weight: 600;
|
|
103
|
+
}
|
|
104
|
+
.cf-prose :deep(hr) {
|
|
105
|
+
margin: 1rem 0;
|
|
106
|
+
border: none;
|
|
107
|
+
border-top: 1px solid rgb(51 65 85);
|
|
108
|
+
}
|
|
109
|
+
</style>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { computed } from 'vue'
|
|
10
10
|
import type { ConsensusContribution, ConsensusSession } from '~/types/consensus'
|
|
11
11
|
import CopyButton from '~/components/common/CopyButton.vue'
|
|
12
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
12
13
|
import { agentKindMeta } from '~/utils/catalog'
|
|
13
14
|
|
|
14
15
|
const { t, n } = useI18n()
|
|
@@ -171,11 +172,12 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
|
|
|
171
172
|
class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-xs text-emerald-300"
|
|
172
173
|
>{{ t('consensus.confidence', { pct: pct(session.confidence) }) }}</span
|
|
173
174
|
>
|
|
175
|
+
<CopyButton :text="session.synthesis" class="ms-auto -my-1" />
|
|
174
176
|
</div>
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
<MarkdownProse
|
|
178
|
+
:text="session.synthesis"
|
|
179
|
+
class="rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3 text-sm text-slate-200"
|
|
180
|
+
/>
|
|
179
181
|
<ul v-if="session.dissent?.length" class="mt-2 space-y-1">
|
|
180
182
|
<li
|
|
181
183
|
v-for="(d, i) in session.dissent"
|
|
@@ -237,8 +239,9 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
|
|
|
237
239
|
})
|
|
238
240
|
}}</span
|
|
239
241
|
>
|
|
242
|
+
<CopyButton :text="c.text" :class="topScore(c) ? '-my-1' : 'ms-auto -my-1'" />
|
|
240
243
|
</div>
|
|
241
|
-
<
|
|
244
|
+
<MarkdownProse :text="c.text" class="text-sm text-slate-300" />
|
|
242
245
|
<div v-if="c.scores?.length" class="mt-2 flex flex-wrap gap-1.5">
|
|
243
246
|
<span
|
|
244
247
|
v-for="s in c.scores"
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { computed } from 'vue'
|
|
10
10
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
11
11
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
12
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
13
|
+
import CopyButton from '~/components/common/CopyButton.vue'
|
|
12
14
|
|
|
13
15
|
const board = useBoardStore()
|
|
14
16
|
const execution = useExecutionStore()
|
|
@@ -94,17 +96,19 @@ const customJson = computed<string | null>(() => {
|
|
|
94
96
|
<div class="flex min-h-0 flex-1">
|
|
95
97
|
<!-- Main: prose summary + structured JSON -->
|
|
96
98
|
<div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
|
|
97
|
-
<
|
|
99
|
+
<MarkdownProse
|
|
98
100
|
v-if="step?.output"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
</p>
|
|
101
|
+
:text="step.output"
|
|
102
|
+
class="mb-4 text-[13px] leading-relaxed text-slate-300"
|
|
103
|
+
/>
|
|
103
104
|
|
|
104
105
|
<template v-if="customJson">
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
<div class="mb-2 flex items-center gap-2">
|
|
107
|
+
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
108
|
+
{{ t('panels.structuredResult.structuredOutput') }}
|
|
109
|
+
</h3>
|
|
110
|
+
<CopyButton :text="customJson" class="-my-1" />
|
|
111
|
+
</div>
|
|
108
112
|
<pre
|
|
109
113
|
class="overflow-x-auto rounded-lg border border-slate-800 bg-slate-950/60 p-3 text-[12px] leading-relaxed text-slate-200"
|
|
110
114
|
><code>{{ customJson }}</code></pre>
|
|
@@ -10,6 +10,7 @@ import { computed } from 'vue'
|
|
|
10
10
|
import type { MergeAxis, MergeDecision } from '@cat-factory/contracts'
|
|
11
11
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
12
12
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
13
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
13
14
|
|
|
14
15
|
const board = useBoardStore()
|
|
15
16
|
const execution = useExecutionStore()
|
|
@@ -221,9 +222,10 @@ const reasonText = computed(() => {
|
|
|
221
222
|
>
|
|
222
223
|
{{ t('panels.mergerResult.rationale') }}
|
|
223
224
|
</h3>
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
<MarkdownProse
|
|
226
|
+
:text="decision.assessment.rationale"
|
|
227
|
+
class="text-[13px] leading-relaxed text-slate-300"
|
|
228
|
+
/>
|
|
227
229
|
</template>
|
|
228
230
|
<p v-else class="text-[13px] italic leading-relaxed text-slate-500">
|
|
229
231
|
{{ t('panels.mergerResult.noAssessment') }}
|
|
@@ -231,12 +233,11 @@ const reasonText = computed(() => {
|
|
|
231
233
|
</template>
|
|
232
234
|
|
|
233
235
|
<!-- Pre-structured runs kept only the raw prose output. -->
|
|
234
|
-
<
|
|
236
|
+
<MarkdownProse
|
|
235
237
|
v-else-if="step?.output"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
</p>
|
|
238
|
+
:text="step.output"
|
|
239
|
+
class="text-[13px] leading-relaxed text-slate-300"
|
|
240
|
+
/>
|
|
240
241
|
<div
|
|
241
242
|
v-else
|
|
242
243
|
class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { parseOutputOutline, sliceSource } from '~/utils/agentOutput'
|
|
2
|
+
import { parseOutputOutline, renderMarkdown, sliceSource } from '~/utils/agentOutput'
|
|
3
3
|
|
|
4
4
|
describe('parseOutputOutline', () => {
|
|
5
5
|
it('splits on headings and builds a ToC', () => {
|
|
@@ -75,6 +75,49 @@ describe('parseOutputOutline', () => {
|
|
|
75
75
|
})
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
+
describe('renderMarkdown', () => {
|
|
79
|
+
it('renders inline marks and preserves single newlines as breaks', () => {
|
|
80
|
+
const html = renderMarkdown('see **bold** and _em_\nnext line')
|
|
81
|
+
expect(html).toContain('<strong>bold</strong>')
|
|
82
|
+
expect(html).toContain('<em>em</em>')
|
|
83
|
+
expect(html).toContain('<br')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('renders lists and fenced code', () => {
|
|
87
|
+
const html = renderMarkdown(['- a', '- b', '', '```', 'const x = 1', '```'].join('\n'))
|
|
88
|
+
expect(html).toContain('<ul')
|
|
89
|
+
expect(html).toContain('<li>a</li>')
|
|
90
|
+
expect(html).toContain('<pre>')
|
|
91
|
+
expect(html).toContain('const x = 1')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('escapes raw HTML rather than injecting it (html: false)', () => {
|
|
95
|
+
const html = renderMarkdown('<img src=x onerror=alert(1)>')
|
|
96
|
+
expect(html).not.toContain('<img')
|
|
97
|
+
expect(html).toContain('<img')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('decorates links to open safely in a new tab', () => {
|
|
101
|
+
const html = renderMarkdown('[site](https://example.com)')
|
|
102
|
+
expect(html).toContain('href="https://example.com"')
|
|
103
|
+
expect(html).toContain('target="_blank"')
|
|
104
|
+
expect(html).toContain('rel="noopener noreferrer"')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not create a link for javascript: URLs', () => {
|
|
108
|
+
const html = renderMarkdown('[x](javascript:alert(1))')
|
|
109
|
+
expect(html).not.toContain('<a ')
|
|
110
|
+
expect(html).not.toContain('href')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('renders empty string for empty / nullish input', () => {
|
|
114
|
+
expect(renderMarkdown('')).toBe('')
|
|
115
|
+
expect(renderMarkdown(' ')).toBe('')
|
|
116
|
+
expect(renderMarkdown(null)).toBe('')
|
|
117
|
+
expect(renderMarkdown(undefined)).toBe('')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
78
121
|
describe('sliceSource', () => {
|
|
79
122
|
it('returns the verbatim line range (0-based, end-exclusive)', () => {
|
|
80
123
|
const text = ['line0', 'line1', 'line2', 'line3'].join('\n')
|
package/app/utils/agentOutput.ts
CHANGED
|
@@ -78,6 +78,43 @@ const md = new MarkdownIt({
|
|
|
78
78
|
const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6'])
|
|
79
79
|
const LINK_CLASS = 'text-indigo-300 underline decoration-indigo-500/40 hover:text-indigo-200'
|
|
80
80
|
|
|
81
|
+
// A second markdown-it instance for INLINE prose rendering (a rationale, a synthesis,
|
|
82
|
+
// a summary) — the same secure config as `md` (html: false, so raw HTML is escaped and
|
|
83
|
+
// dangerous link schemes are blocked by `validateLink`), but without the segmentation
|
|
84
|
+
// plugin: these surfaces render one continuous document, not a ToC-navigable outline.
|
|
85
|
+
// Link attributes are set through a renderer rule (not DOM post-processing) so the
|
|
86
|
+
// function returns ready-to-inject HTML without depending on `document`.
|
|
87
|
+
const proseMd = new MarkdownIt({
|
|
88
|
+
html: false, // secure by default: escape raw HTML rather than render it
|
|
89
|
+
linkify: true, // turn bare URLs into links
|
|
90
|
+
breaks: true, // single newlines → <br>, matching how agents lay out prose
|
|
91
|
+
typographer: true,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Make every rendered link open safely in a new tab and pick up the prose link style,
|
|
95
|
+
// mirroring `decorateLinks` but at the token level so no DOM is required.
|
|
96
|
+
const defaultLinkOpen =
|
|
97
|
+
proseMd.renderer.rules.link_open ??
|
|
98
|
+
((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options))
|
|
99
|
+
proseMd.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
|
100
|
+
const token = tokens[idx]!
|
|
101
|
+
token.attrSet('target', '_blank')
|
|
102
|
+
token.attrSet('rel', 'noopener noreferrer')
|
|
103
|
+
token.attrSet('class', LINK_CLASS)
|
|
104
|
+
return defaultLinkOpen(tokens, idx, options, env, self)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render an agent's prose (markdown) to safe HTML for inline display. Escapes raw HTML,
|
|
109
|
+
* blocks dangerous link schemes, and decorates links to open safely in a new tab — the
|
|
110
|
+
* same guarantees as the reader overlay, minus the heading segmentation. Empty/nullish
|
|
111
|
+
* input renders to an empty string.
|
|
112
|
+
*/
|
|
113
|
+
export function renderMarkdown(text: string | null | undefined): string {
|
|
114
|
+
const source = text ?? ''
|
|
115
|
+
return source.trim() ? proseMd.render(source) : ''
|
|
116
|
+
}
|
|
117
|
+
|
|
81
118
|
function slugify(title: string, used: Set<string>): string {
|
|
82
119
|
const base =
|
|
83
120
|
title
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.87.
|
|
3
|
+
"version": "0.87.4",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|