@cat-factory/app 0.87.3 → 0.87.5
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/components/panels/inspector/ServiceFragments.vue +11 -15
- package/app/components/panels/inspector/TaskStructure.vue +15 -15
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +3 -9
- package/app/utils/agentOutput.spec.ts +44 -1
- package/app/utils/agentOutput.ts +37 -0
- package/app/utils/fragmentPicker.spec.ts +60 -0
- package/app/utils/fragmentPicker.ts +32 -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,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
2
3
|
import type { Block } from '~/types/domain'
|
|
3
4
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
5
|
+
import { buildFragmentPickerGroups } from '~/utils/fragmentPicker'
|
|
4
6
|
|
|
5
7
|
// Service-level best-practice fragments (frame blocks). These are the programming
|
|
6
8
|
// standards/guidelines for the whole service; at run time their bodies are folded
|
|
@@ -19,8 +21,6 @@ const { t } = useI18n()
|
|
|
19
21
|
|
|
20
22
|
onMounted(() => fragments.ensureLoaded())
|
|
21
23
|
|
|
22
|
-
type MenuItem = { label: string; icon?: string; onSelect: () => void }
|
|
23
|
-
|
|
24
24
|
// An id the catalog no longer resolves (removed/suppressed after selection) still
|
|
25
25
|
// renders — labelled by its raw id — so it stays visible and removable.
|
|
26
26
|
const selectedFragments = computed(() =>
|
|
@@ -32,8 +32,8 @@ const selectedFragments = computed(() =>
|
|
|
32
32
|
// A trailing group that jumps from "attach a fragment" to authoring/editing the
|
|
33
33
|
// library itself (board tier always; account tier when accounts are enabled).
|
|
34
34
|
// Open to every member — managing fragments is not an admin-only action.
|
|
35
|
-
const manageItems = computed<
|
|
36
|
-
const items:
|
|
35
|
+
const manageItems = computed<DropdownMenuItem[]>(() => {
|
|
36
|
+
const items: DropdownMenuItem[] = [
|
|
37
37
|
{
|
|
38
38
|
label: t('inspector.fragments.manageBoard'),
|
|
39
39
|
icon: 'i-lucide-book-marked',
|
|
@@ -50,18 +50,14 @@ const manageItems = computed<MenuItem[]>(() => {
|
|
|
50
50
|
return items
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
-
// Picker menu: every pool fragment not already selected, grouped
|
|
54
|
-
// the management links appended as the final group.
|
|
55
|
-
const fragmentMenu = computed<
|
|
53
|
+
// Picker menu: every pool fragment not already selected, grouped into labelled
|
|
54
|
+
// per-category sections, with the management links appended as the final group.
|
|
55
|
+
const fragmentMenu = computed<DropdownMenuItem[][]>(() => {
|
|
56
56
|
const selected = new Set(props.block.serviceFragmentIds ?? [])
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
items.push({ label: f.title, onSelect: () => addFragment(f.id) })
|
|
62
|
-
groups.set(f.category, items)
|
|
63
|
-
}
|
|
64
|
-
return [...groups.values(), manageItems.value]
|
|
57
|
+
return [
|
|
58
|
+
...buildFragmentPickerGroups(fragments.fragments, (id) => selected.has(id), addFragment),
|
|
59
|
+
manageItems.value,
|
|
60
|
+
]
|
|
65
61
|
})
|
|
66
62
|
|
|
67
63
|
function addFragment(id: string) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
2
3
|
import type { Block } from '~/types/domain'
|
|
3
4
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
5
|
+
import { buildFragmentPickerGroups } from '~/utils/fragmentPicker'
|
|
4
6
|
|
|
5
7
|
const props = defineProps<{ block: Block }>()
|
|
6
8
|
|
|
@@ -14,8 +16,6 @@ const { t } = useI18n()
|
|
|
14
16
|
// task inspector mounts — mirrors ServiceFragments; ensureLoaded is a no-op while current.
|
|
15
17
|
onMounted(() => fragments.ensureLoaded())
|
|
16
18
|
|
|
17
|
-
type MenuItem = { label: string; icon?: string; onSelect: () => void }
|
|
18
|
-
|
|
19
19
|
// ---- best-practice prompt fragments ----------------------------------------
|
|
20
20
|
// Selected fragments, resolved against the catalog. An id the catalog no longer
|
|
21
21
|
// resolves (removed/suppressed after selection) still renders — labelled by its
|
|
@@ -29,8 +29,8 @@ const selectedFragments = computed(() =>
|
|
|
29
29
|
// A trailing group that jumps from "attach a fragment" to authoring/editing the
|
|
30
30
|
// library itself (board tier always; account tier when accounts are enabled).
|
|
31
31
|
// Open to every member — managing fragments is not an admin-only action.
|
|
32
|
-
const manageItems = computed<
|
|
33
|
-
const items:
|
|
32
|
+
const manageItems = computed<DropdownMenuItem[]>(() => {
|
|
33
|
+
const items: DropdownMenuItem[] = [
|
|
34
34
|
{
|
|
35
35
|
label: t('inspector.fragments.manageBoard'),
|
|
36
36
|
icon: 'i-lucide-book-marked',
|
|
@@ -48,18 +48,18 @@ const manageItems = computed<MenuItem[]>(() => {
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
// Picker menu: fragments suitable for this block's type, not already selected,
|
|
51
|
-
// grouped
|
|
52
|
-
// links appended as the final group.
|
|
53
|
-
const fragmentMenu = computed<
|
|
51
|
+
// grouped into labelled per-category sections so the dropdown reads like the catalog,
|
|
52
|
+
// with the management links appended as the final group.
|
|
53
|
+
const fragmentMenu = computed<DropdownMenuItem[][]>(() => {
|
|
54
54
|
const selected = new Set(props.block.fragmentIds ?? [])
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
return [
|
|
56
|
+
...buildFragmentPickerGroups(
|
|
57
|
+
fragments.forBlockType(props.block.type),
|
|
58
|
+
(id) => selected.has(id),
|
|
59
|
+
addFragment,
|
|
60
|
+
),
|
|
61
|
+
manageItems.value,
|
|
62
|
+
]
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
function addFragment(id: string) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// services — each owns its selection from creation. Persisted via the
|
|
7
7
|
// serviceFragmentDefaults store (the backend replaces the whole list on each change).
|
|
8
8
|
import { onMounted, ref } from 'vue'
|
|
9
|
+
import { buildFragmentPickerGroups } from '~/utils/fragmentPicker'
|
|
9
10
|
|
|
10
11
|
const { t } = useI18n()
|
|
11
12
|
const fragments = useFragmentsStore()
|
|
@@ -24,17 +25,10 @@ const selected = computed(() =>
|
|
|
24
25
|
defaults.fragmentIds.map((id) => fragments.getFragment(id) ?? { id, title: id, summary: '' }),
|
|
25
26
|
)
|
|
26
27
|
|
|
27
|
-
// Pool fragments not already in the default set, grouped
|
|
28
|
+
// Pool fragments not already in the default set, grouped into labelled per-category sections.
|
|
28
29
|
const menu = computed(() => {
|
|
29
30
|
const chosen = new Set(defaults.fragmentIds)
|
|
30
|
-
|
|
31
|
-
for (const f of fragments.fragments) {
|
|
32
|
-
if (chosen.has(f.id)) continue
|
|
33
|
-
const items = groups.get(f.category) ?? []
|
|
34
|
-
items.push({ label: f.title, onSelect: () => add(f.id) })
|
|
35
|
-
groups.set(f.category, items)
|
|
36
|
-
}
|
|
37
|
-
return [...groups.values()]
|
|
31
|
+
return buildFragmentPickerGroups(fragments.fragments, (id) => chosen.has(id), add)
|
|
38
32
|
})
|
|
39
33
|
|
|
40
34
|
async function save(ids: string[]) {
|
|
@@ -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
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { PromptFragment } from '~/types/domain'
|
|
3
|
+
import { buildFragmentPickerGroups } from './fragmentPicker'
|
|
4
|
+
|
|
5
|
+
const frag = (id: string, category: string, title = id): PromptFragment =>
|
|
6
|
+
({ id, version: '1.0.0', title, category, summary: '', body: '' }) as PromptFragment
|
|
7
|
+
|
|
8
|
+
const pool: PromptFragment[] = [
|
|
9
|
+
frag('node.best-practices', 'Node', 'Node best practices'),
|
|
10
|
+
frag('node.performance', 'Node', 'Node performance'),
|
|
11
|
+
frag('style.anti-llmisms', 'Writing style', 'Avoid LLM tells'),
|
|
12
|
+
frag('style.concise-actionable', 'Writing style', 'Concise and actionable'),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
describe('buildFragmentPickerGroups', () => {
|
|
16
|
+
it('buckets fragments into one labelled group per category (technical + writing-style tracks)', () => {
|
|
17
|
+
const groups = buildFragmentPickerGroups(
|
|
18
|
+
pool,
|
|
19
|
+
() => false,
|
|
20
|
+
() => {},
|
|
21
|
+
)
|
|
22
|
+
// One group per category, each led by a non-interactive `type: 'label'` heading.
|
|
23
|
+
expect(groups.map((g) => g[0])).toEqual([
|
|
24
|
+
{ type: 'label', label: 'Node' },
|
|
25
|
+
{ type: 'label', label: 'Writing style' },
|
|
26
|
+
])
|
|
27
|
+
// The heading is followed by that category's item labels, in pool order.
|
|
28
|
+
expect(groups[0]!.slice(1).map((i) => i.label)).toEqual([
|
|
29
|
+
'Node best practices',
|
|
30
|
+
'Node performance',
|
|
31
|
+
])
|
|
32
|
+
expect(groups[1]!.slice(1).map((i) => i.label)).toEqual([
|
|
33
|
+
'Avoid LLM tells',
|
|
34
|
+
'Concise and actionable',
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('omits already-selected fragments, dropping a category that empties out', () => {
|
|
39
|
+
const selected = new Set(['style.anti-llmisms', 'style.concise-actionable'])
|
|
40
|
+
const groups = buildFragmentPickerGroups(
|
|
41
|
+
pool,
|
|
42
|
+
(id) => selected.has(id),
|
|
43
|
+
() => {},
|
|
44
|
+
)
|
|
45
|
+
// Writing style fully selected → its category disappears entirely (no empty labelled group).
|
|
46
|
+
expect(groups.map((g) => g[0])).toEqual([{ type: 'label', label: 'Node' }])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('invokes onSelect with the fragment id when an item is chosen', () => {
|
|
50
|
+
const picked: string[] = []
|
|
51
|
+
const groups = buildFragmentPickerGroups(
|
|
52
|
+
pool,
|
|
53
|
+
() => false,
|
|
54
|
+
(id) => picked.push(id),
|
|
55
|
+
)
|
|
56
|
+
// Fire the first real item under the first category (index 1, past the label heading).
|
|
57
|
+
;(groups[0]![1] as { onSelect: () => void }).onSelect()
|
|
58
|
+
expect(picked).toEqual(['node.best-practices'])
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
2
|
+
import type { PromptFragment } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build the category-grouped groups for a fragment "add" dropdown: the pool fragments not
|
|
6
|
+
* already selected, bucketed by `category`, each bucket prefixed with a non-interactive
|
|
7
|
+
* `type: 'label'` heading so the catalog reads as labelled sections instead of one flat,
|
|
8
|
+
* undifferentiated list. This matters now that the catalog spans distinct tracks that a
|
|
9
|
+
* single block can pin together — the technical collections (Node / React / …) AND the
|
|
10
|
+
* document Writing-style fragments — so the headings keep the longer, mixed list navigable.
|
|
11
|
+
*
|
|
12
|
+
* Category order follows first appearance in `pool`; empty categories are dropped. Each
|
|
13
|
+
* inner array is one Nuxt UI menu group (rendered divider-separated); callers append their
|
|
14
|
+
* own trailing groups (e.g. the library-management links) after the returned groups.
|
|
15
|
+
*/
|
|
16
|
+
export function buildFragmentPickerGroups(
|
|
17
|
+
pool: PromptFragment[],
|
|
18
|
+
isSelected: (id: string) => boolean,
|
|
19
|
+
onSelect: (id: string) => void,
|
|
20
|
+
): DropdownMenuItem[][] {
|
|
21
|
+
const groups = new Map<string, DropdownMenuItem[]>()
|
|
22
|
+
for (const f of pool) {
|
|
23
|
+
if (isSelected(f.id)) continue
|
|
24
|
+
const items = groups.get(f.category) ?? []
|
|
25
|
+
items.push({ label: f.title, onSelect: () => onSelect(f.id) })
|
|
26
|
+
groups.set(f.category, items)
|
|
27
|
+
}
|
|
28
|
+
return [...groups.entries()].map(([category, items]): DropdownMenuItem[] => [
|
|
29
|
+
{ type: 'label', label: category },
|
|
30
|
+
...items,
|
|
31
|
+
])
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.87.
|
|
3
|
+
"version": "0.87.5",
|
|
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",
|