@cat-factory/app 0.6.0 → 0.7.3
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/LICENSE +21 -21
- package/app/components/board/ContextPicker.vue +367 -367
- package/app/components/gates/GateResultView.vue +90 -12
- package/app/components/layout/SideBar.vue +11 -0
- package/app/components/observability/StepMetricsBar.vue +102 -102
- package/app/components/observability/StepModelActivity.vue +49 -0
- package/app/components/panels/ObservabilityPanel.vue +1 -1
- package/app/components/panels/StepMetadataCard.vue +4 -16
- package/app/components/panels/StepRunMeta.vue +105 -0
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -178
- package/app/components/panels/inspector/TaskRunSettings.vue +77 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -124
- package/app/components/settings/IssueTrackerWritebackPanel.vue +103 -0
- package/app/components/testing/TestReportWindow.vue +17 -8
- package/app/composables/useBlockQueries.ts +154 -154
- package/app/composables/useContextLinking.ts +65 -65
- package/app/composables/useFrameResize.ts +54 -54
- package/app/pages/index.vue +2 -0
- package/app/stores/documents.ts +176 -176
- package/app/stores/services.ts +87 -87
- package/app/stores/tracker.ts +39 -27
- package/app/stores/ui.ts +12 -0
- package/app/types/documents.ts +104 -104
- package/app/types/domain.ts +5 -1
- package/app/types/execution.ts +18 -0
- package/app/types/github.ts +173 -173
- package/app/types/services.ts +27 -27
- package/app/types/tasks.ts +82 -82
- package/app/types/tracker.ts +27 -18
- package/app/utils/agentOutput.spec.ts +128 -128
- package/app/utils/agentOutput.ts +173 -173
- package/app/utils/observability.ts +52 -52
- package/package.json +6 -1
package/app/utils/agentOutput.ts
CHANGED
|
@@ -1,173 +1,173 @@
|
|
|
1
|
-
// Turn an agent's prose output (markdown) into a heading-delimited outline so it
|
|
2
|
-
// can be read in the dedicated reader overlay: a navigable table of contents on
|
|
3
|
-
// one side, collapsible sections on the other.
|
|
4
|
-
//
|
|
5
|
-
// Markdown → HTML is done by `markdown-it` (a mature CommonMark parser) with
|
|
6
|
-
// `html: false`, so it is secure by default: any raw HTML in the agent's output
|
|
7
|
-
// is escaped rather than injected, and its `validateLink` blocks dangerous URL
|
|
8
|
-
// schemes — no separate sanitizer needed for the LLM-generated text we feed it.
|
|
9
|
-
// This module only adds the one thing markdown-it doesn't: SEGMENTATION — split
|
|
10
|
-
// the rendered document at each heading into sections we can collapse
|
|
11
|
-
// independently and link from a ToC. That split is done over the parsed DOM, so
|
|
12
|
-
// it is independent of markdown-it's token internals.
|
|
13
|
-
import MarkdownIt from 'markdown-it'
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Stamp every TOP-LEVEL block element with its source line range
|
|
17
|
-
* (`data-src-start`/`data-src-end`, 0-based, end-exclusive — straight from
|
|
18
|
-
* markdown-it's `token.map`). The approval-mode reader uses these to let a human
|
|
19
|
-
* comment on a specific block and quote that block's verbatim raw markdown back to
|
|
20
|
-
* the agent on a "request changes" re-run. Only top-level blocks are tagged (depth
|
|
21
|
-
* tracked over the flat token stream) so a comment targets a whole paragraph/list/
|
|
22
|
-
* heading rather than a nested fragment.
|
|
23
|
-
*/
|
|
24
|
-
function sourceLinePlugin(md: MarkdownIt): void {
|
|
25
|
-
md.core.ruler.push('source_lines', (state) => {
|
|
26
|
-
let depth = 0
|
|
27
|
-
for (const token of state.tokens) {
|
|
28
|
-
const atTopLevel = depth === 0
|
|
29
|
-
// Annotate a top-level block's opening token (nesting 1) or a self-contained
|
|
30
|
-
// block token (nesting 0, e.g. fence/hr/code_block/html_block).
|
|
31
|
-
if (atTopLevel && token.block && token.type !== 'inline' && token.map && token.nesting >= 0) {
|
|
32
|
-
token.attrSet('data-src-start', String(token.map[0]))
|
|
33
|
-
token.attrSet('data-src-end', String(token.map[1]))
|
|
34
|
-
}
|
|
35
|
-
if (token.nesting === 1) depth++
|
|
36
|
-
else if (token.nesting === -1) depth--
|
|
37
|
-
}
|
|
38
|
-
return true
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* The verbatim raw-markdown source of a block, given the original output text and a
|
|
44
|
-
* 0-based, end-exclusive line range (as captured from `data-src-start/end`).
|
|
45
|
-
*/
|
|
46
|
-
export function sliceSource(output: string, start: number, end: number): string {
|
|
47
|
-
return (output ?? '').split('\n').slice(start, end).join('\n')
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** One heading-delimited section. `depth` 0 / empty `title` is the preamble that
|
|
51
|
-
* precedes the first heading (rendered, but never shown in the ToC). */
|
|
52
|
-
export interface OutputSection {
|
|
53
|
-
id: string
|
|
54
|
-
depth: number
|
|
55
|
-
/** Plain-text heading, for the ToC. */
|
|
56
|
-
title: string
|
|
57
|
-
/** Inline-rendered heading HTML (code/bold/… preserved), for the section header. */
|
|
58
|
-
titleHtml: string
|
|
59
|
-
/** HTML of everything under this heading up to the next one. */
|
|
60
|
-
bodyHtml: string
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface OutputOutline {
|
|
64
|
-
sections: OutputSection[]
|
|
65
|
-
/** True once there is at least one real heading worth a ToC entry. */
|
|
66
|
-
hasToc: boolean
|
|
67
|
-
/** Shallowest heading depth present, so the ToC can indent relative to it. */
|
|
68
|
-
minDepth: number
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const md = new MarkdownIt({
|
|
72
|
-
html: false, // secure by default: escape raw HTML rather than render it
|
|
73
|
-
linkify: true, // turn bare URLs into links
|
|
74
|
-
breaks: true, // single newlines → <br>, matching how agents lay out prose
|
|
75
|
-
typographer: true,
|
|
76
|
-
}).use(sourceLinePlugin)
|
|
77
|
-
|
|
78
|
-
const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6'])
|
|
79
|
-
const LINK_CLASS = 'text-indigo-300 underline decoration-indigo-500/40 hover:text-indigo-200'
|
|
80
|
-
|
|
81
|
-
function slugify(title: string, used: Set<string>): string {
|
|
82
|
-
const base =
|
|
83
|
-
title
|
|
84
|
-
.toLowerCase()
|
|
85
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
86
|
-
.replace(/^-+|-+$/g, '') || 'section'
|
|
87
|
-
let slug = base
|
|
88
|
-
let n = 2
|
|
89
|
-
while (used.has(slug)) slug = `${base}-${n++}`
|
|
90
|
-
used.add(slug)
|
|
91
|
-
return slug
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Make every link open safely in a new tab and pick up the reader's link style. */
|
|
95
|
-
function decorateLinks(root: HTMLElement): void {
|
|
96
|
-
for (const a of Array.from(root.querySelectorAll('a'))) {
|
|
97
|
-
a.setAttribute('target', '_blank')
|
|
98
|
-
a.setAttribute('rel', 'noopener noreferrer')
|
|
99
|
-
a.setAttribute('class', LINK_CLASS)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Build the heading-based outline for an agent's prose output. */
|
|
104
|
-
export function parseOutputOutline(text: string): OutputOutline {
|
|
105
|
-
const source = text ?? ''
|
|
106
|
-
if (!source.trim()) return { sections: [], hasToc: false, minDepth: 1 }
|
|
107
|
-
|
|
108
|
-
const html = md.render(source)
|
|
109
|
-
|
|
110
|
-
// No DOM (SSR / non-browser): fall back to one un-segmented section. The reader
|
|
111
|
-
// overlay is client-only, so this path is effectively dead outside any runtime
|
|
112
|
-
// without `document`.
|
|
113
|
-
if (typeof document === 'undefined') {
|
|
114
|
-
return {
|
|
115
|
-
sections: [{ id: 'overview', depth: 0, title: '', titleHtml: '', bodyHtml: html }],
|
|
116
|
-
hasToc: false,
|
|
117
|
-
minDepth: 1,
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const root = document.createElement('div')
|
|
122
|
-
root.innerHTML = html
|
|
123
|
-
decorateLinks(root)
|
|
124
|
-
|
|
125
|
-
const used = new Set<string>()
|
|
126
|
-
const sections: OutputSection[] = []
|
|
127
|
-
let current: OutputSection | null = null
|
|
128
|
-
let body: HTMLElement | null = null
|
|
129
|
-
|
|
130
|
-
const flush = () => {
|
|
131
|
-
if (current && body) current.bodyHtml = body.innerHTML.trim()
|
|
132
|
-
if (current) sections.push(current)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
for (const node of Array.from(root.childNodes)) {
|
|
136
|
-
const el = node.nodeType === 1 ? (node as HTMLElement) : null
|
|
137
|
-
if (el && HEADINGS.has(el.tagName)) {
|
|
138
|
-
flush()
|
|
139
|
-
const title = (el.textContent ?? '').trim()
|
|
140
|
-
current = {
|
|
141
|
-
id: slugify(title, used),
|
|
142
|
-
depth: Number(el.tagName[1]),
|
|
143
|
-
title,
|
|
144
|
-
titleHtml: el.innerHTML,
|
|
145
|
-
bodyHtml: '',
|
|
146
|
-
}
|
|
147
|
-
body = document.createElement('div')
|
|
148
|
-
} else {
|
|
149
|
-
if (!current) {
|
|
150
|
-
// Content before the first heading → untitled preamble section.
|
|
151
|
-
current = {
|
|
152
|
-
id: slugify('overview', used),
|
|
153
|
-
depth: 0,
|
|
154
|
-
title: '',
|
|
155
|
-
titleHtml: '',
|
|
156
|
-
bodyHtml: '',
|
|
157
|
-
}
|
|
158
|
-
body = document.createElement('div')
|
|
159
|
-
}
|
|
160
|
-
body!.appendChild(node.cloneNode(true))
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
flush()
|
|
164
|
-
|
|
165
|
-
// A preamble that turned out to hold nothing renderable is noise — drop it.
|
|
166
|
-
const cleaned = sections.filter((s) => s.title || s.bodyHtml)
|
|
167
|
-
const headed = cleaned.filter((s) => s.depth > 0)
|
|
168
|
-
return {
|
|
169
|
-
sections: cleaned,
|
|
170
|
-
hasToc: headed.length > 0,
|
|
171
|
-
minDepth: headed.length ? Math.min(...headed.map((s) => s.depth)) : 1,
|
|
172
|
-
}
|
|
173
|
-
}
|
|
1
|
+
// Turn an agent's prose output (markdown) into a heading-delimited outline so it
|
|
2
|
+
// can be read in the dedicated reader overlay: a navigable table of contents on
|
|
3
|
+
// one side, collapsible sections on the other.
|
|
4
|
+
//
|
|
5
|
+
// Markdown → HTML is done by `markdown-it` (a mature CommonMark parser) with
|
|
6
|
+
// `html: false`, so it is secure by default: any raw HTML in the agent's output
|
|
7
|
+
// is escaped rather than injected, and its `validateLink` blocks dangerous URL
|
|
8
|
+
// schemes — no separate sanitizer needed for the LLM-generated text we feed it.
|
|
9
|
+
// This module only adds the one thing markdown-it doesn't: SEGMENTATION — split
|
|
10
|
+
// the rendered document at each heading into sections we can collapse
|
|
11
|
+
// independently and link from a ToC. That split is done over the parsed DOM, so
|
|
12
|
+
// it is independent of markdown-it's token internals.
|
|
13
|
+
import MarkdownIt from 'markdown-it'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stamp every TOP-LEVEL block element with its source line range
|
|
17
|
+
* (`data-src-start`/`data-src-end`, 0-based, end-exclusive — straight from
|
|
18
|
+
* markdown-it's `token.map`). The approval-mode reader uses these to let a human
|
|
19
|
+
* comment on a specific block and quote that block's verbatim raw markdown back to
|
|
20
|
+
* the agent on a "request changes" re-run. Only top-level blocks are tagged (depth
|
|
21
|
+
* tracked over the flat token stream) so a comment targets a whole paragraph/list/
|
|
22
|
+
* heading rather than a nested fragment.
|
|
23
|
+
*/
|
|
24
|
+
function sourceLinePlugin(md: MarkdownIt): void {
|
|
25
|
+
md.core.ruler.push('source_lines', (state) => {
|
|
26
|
+
let depth = 0
|
|
27
|
+
for (const token of state.tokens) {
|
|
28
|
+
const atTopLevel = depth === 0
|
|
29
|
+
// Annotate a top-level block's opening token (nesting 1) or a self-contained
|
|
30
|
+
// block token (nesting 0, e.g. fence/hr/code_block/html_block).
|
|
31
|
+
if (atTopLevel && token.block && token.type !== 'inline' && token.map && token.nesting >= 0) {
|
|
32
|
+
token.attrSet('data-src-start', String(token.map[0]))
|
|
33
|
+
token.attrSet('data-src-end', String(token.map[1]))
|
|
34
|
+
}
|
|
35
|
+
if (token.nesting === 1) depth++
|
|
36
|
+
else if (token.nesting === -1) depth--
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The verbatim raw-markdown source of a block, given the original output text and a
|
|
44
|
+
* 0-based, end-exclusive line range (as captured from `data-src-start/end`).
|
|
45
|
+
*/
|
|
46
|
+
export function sliceSource(output: string, start: number, end: number): string {
|
|
47
|
+
return (output ?? '').split('\n').slice(start, end).join('\n')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One heading-delimited section. `depth` 0 / empty `title` is the preamble that
|
|
51
|
+
* precedes the first heading (rendered, but never shown in the ToC). */
|
|
52
|
+
export interface OutputSection {
|
|
53
|
+
id: string
|
|
54
|
+
depth: number
|
|
55
|
+
/** Plain-text heading, for the ToC. */
|
|
56
|
+
title: string
|
|
57
|
+
/** Inline-rendered heading HTML (code/bold/… preserved), for the section header. */
|
|
58
|
+
titleHtml: string
|
|
59
|
+
/** HTML of everything under this heading up to the next one. */
|
|
60
|
+
bodyHtml: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface OutputOutline {
|
|
64
|
+
sections: OutputSection[]
|
|
65
|
+
/** True once there is at least one real heading worth a ToC entry. */
|
|
66
|
+
hasToc: boolean
|
|
67
|
+
/** Shallowest heading depth present, so the ToC can indent relative to it. */
|
|
68
|
+
minDepth: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const md = new MarkdownIt({
|
|
72
|
+
html: false, // secure by default: escape raw HTML rather than render it
|
|
73
|
+
linkify: true, // turn bare URLs into links
|
|
74
|
+
breaks: true, // single newlines → <br>, matching how agents lay out prose
|
|
75
|
+
typographer: true,
|
|
76
|
+
}).use(sourceLinePlugin)
|
|
77
|
+
|
|
78
|
+
const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6'])
|
|
79
|
+
const LINK_CLASS = 'text-indigo-300 underline decoration-indigo-500/40 hover:text-indigo-200'
|
|
80
|
+
|
|
81
|
+
function slugify(title: string, used: Set<string>): string {
|
|
82
|
+
const base =
|
|
83
|
+
title
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
86
|
+
.replace(/^-+|-+$/g, '') || 'section'
|
|
87
|
+
let slug = base
|
|
88
|
+
let n = 2
|
|
89
|
+
while (used.has(slug)) slug = `${base}-${n++}`
|
|
90
|
+
used.add(slug)
|
|
91
|
+
return slug
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Make every link open safely in a new tab and pick up the reader's link style. */
|
|
95
|
+
function decorateLinks(root: HTMLElement): void {
|
|
96
|
+
for (const a of Array.from(root.querySelectorAll('a'))) {
|
|
97
|
+
a.setAttribute('target', '_blank')
|
|
98
|
+
a.setAttribute('rel', 'noopener noreferrer')
|
|
99
|
+
a.setAttribute('class', LINK_CLASS)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Build the heading-based outline for an agent's prose output. */
|
|
104
|
+
export function parseOutputOutline(text: string): OutputOutline {
|
|
105
|
+
const source = text ?? ''
|
|
106
|
+
if (!source.trim()) return { sections: [], hasToc: false, minDepth: 1 }
|
|
107
|
+
|
|
108
|
+
const html = md.render(source)
|
|
109
|
+
|
|
110
|
+
// No DOM (SSR / non-browser): fall back to one un-segmented section. The reader
|
|
111
|
+
// overlay is client-only, so this path is effectively dead outside any runtime
|
|
112
|
+
// without `document`.
|
|
113
|
+
if (typeof document === 'undefined') {
|
|
114
|
+
return {
|
|
115
|
+
sections: [{ id: 'overview', depth: 0, title: '', titleHtml: '', bodyHtml: html }],
|
|
116
|
+
hasToc: false,
|
|
117
|
+
minDepth: 1,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const root = document.createElement('div')
|
|
122
|
+
root.innerHTML = html
|
|
123
|
+
decorateLinks(root)
|
|
124
|
+
|
|
125
|
+
const used = new Set<string>()
|
|
126
|
+
const sections: OutputSection[] = []
|
|
127
|
+
let current: OutputSection | null = null
|
|
128
|
+
let body: HTMLElement | null = null
|
|
129
|
+
|
|
130
|
+
const flush = () => {
|
|
131
|
+
if (current && body) current.bodyHtml = body.innerHTML.trim()
|
|
132
|
+
if (current) sections.push(current)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const node of Array.from(root.childNodes)) {
|
|
136
|
+
const el = node.nodeType === 1 ? (node as HTMLElement) : null
|
|
137
|
+
if (el && HEADINGS.has(el.tagName)) {
|
|
138
|
+
flush()
|
|
139
|
+
const title = (el.textContent ?? '').trim()
|
|
140
|
+
current = {
|
|
141
|
+
id: slugify(title, used),
|
|
142
|
+
depth: Number(el.tagName[1]),
|
|
143
|
+
title,
|
|
144
|
+
titleHtml: el.innerHTML,
|
|
145
|
+
bodyHtml: '',
|
|
146
|
+
}
|
|
147
|
+
body = document.createElement('div')
|
|
148
|
+
} else {
|
|
149
|
+
if (!current) {
|
|
150
|
+
// Content before the first heading → untitled preamble section.
|
|
151
|
+
current = {
|
|
152
|
+
id: slugify('overview', used),
|
|
153
|
+
depth: 0,
|
|
154
|
+
title: '',
|
|
155
|
+
titleHtml: '',
|
|
156
|
+
bodyHtml: '',
|
|
157
|
+
}
|
|
158
|
+
body = document.createElement('div')
|
|
159
|
+
}
|
|
160
|
+
body!.appendChild(node.cloneNode(true))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
flush()
|
|
164
|
+
|
|
165
|
+
// A preamble that turned out to hold nothing renderable is noise — drop it.
|
|
166
|
+
const cleaned = sections.filter((s) => s.title || s.bodyHtml)
|
|
167
|
+
const headed = cleaned.filter((s) => s.depth > 0)
|
|
168
|
+
return {
|
|
169
|
+
sections: cleaned,
|
|
170
|
+
hasToc: headed.length > 0,
|
|
171
|
+
minDepth: headed.length ? Math.min(...headed.map((s) => s.depth)) : 1,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
// Formatting + derivation helpers for the LLM observability surfaces (inline step
|
|
2
|
-
// rollups + the drill-down panel). Kept here so the components stay declarative and
|
|
3
|
-
// the number-crunching is unit-testable.
|
|
4
|
-
|
|
5
|
-
import type { StepMetrics } from '~/types/execution'
|
|
6
|
-
|
|
7
|
-
/** Compact token count: 1234 → "1.2k", 980 → "980", 2_500_000 → "2.5M". */
|
|
8
|
-
export function formatTokens(n: number): string {
|
|
9
|
-
if (n < 1000) return `${n}`
|
|
10
|
-
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
|
|
11
|
-
return `${(n / 1_000_000).toFixed(1)}M`
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Compact duration: 850 → "850ms", 1500 → "1.5s", 90_000 → "1m 30s". */
|
|
15
|
-
export function formatMs(ms: number): string {
|
|
16
|
-
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
17
|
-
const totalSec = ms / 1000
|
|
18
|
-
if (totalSec < 60) return `${totalSec.toFixed(totalSec < 10 ? 1 : 0)}s`
|
|
19
|
-
const m = Math.floor(totalSec / 60)
|
|
20
|
-
const sec = Math.round(totalSec % 60)
|
|
21
|
-
return sec ? `${m}m ${sec}s` : `${m}m`
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** A ratio (0..1) as a whole-number percentage. */
|
|
25
|
-
export function pct(ratio: number): number {
|
|
26
|
-
return Math.round(ratio * 100)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Output-limit headroom for a step's rollup: the fraction of the output ceiling the
|
|
31
|
-
* closest call consumed (0..1), or null when the ceiling is unknown. 1 (or any
|
|
32
|
-
* truncated call) means a call hit the limit and was cut short.
|
|
33
|
-
*/
|
|
34
|
-
export function headroomRatio(
|
|
35
|
-
m: Pick<StepMetrics, 'peakCompletionTokens' | 'maxOutputTokens'>,
|
|
36
|
-
): number | null {
|
|
37
|
-
if (m.maxOutputTokens == null || m.maxOutputTokens <= 0) return null
|
|
38
|
-
return Math.min(1, m.peakCompletionTokens / m.maxOutputTokens)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Share of a step's latency spent in transport/proxy overhead (0..1), or null. */
|
|
42
|
-
export function transportRatio(m: Pick<StepMetrics, 'upstreamMs' | 'overheadMs'>): number | null {
|
|
43
|
-
const total = m.upstreamMs + m.overheadMs
|
|
44
|
-
return total > 0 ? m.overheadMs / total : null
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Tailwind text/bg colour for an output-headroom level (green → amber → red). */
|
|
48
|
-
export function headroomColor(ratio: number | null, truncated: boolean): string {
|
|
49
|
-
if (truncated || (ratio != null && ratio >= 0.98)) return 'text-rose-400'
|
|
50
|
-
if (ratio != null && ratio >= 0.8) return 'text-amber-400'
|
|
51
|
-
return 'text-emerald-400'
|
|
52
|
-
}
|
|
1
|
+
// Formatting + derivation helpers for the LLM observability surfaces (inline step
|
|
2
|
+
// rollups + the drill-down panel). Kept here so the components stay declarative and
|
|
3
|
+
// the number-crunching is unit-testable.
|
|
4
|
+
|
|
5
|
+
import type { StepMetrics } from '~/types/execution'
|
|
6
|
+
|
|
7
|
+
/** Compact token count: 1234 → "1.2k", 980 → "980", 2_500_000 → "2.5M". */
|
|
8
|
+
export function formatTokens(n: number): string {
|
|
9
|
+
if (n < 1000) return `${n}`
|
|
10
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
|
|
11
|
+
return `${(n / 1_000_000).toFixed(1)}M`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Compact duration: 850 → "850ms", 1500 → "1.5s", 90_000 → "1m 30s". */
|
|
15
|
+
export function formatMs(ms: number): string {
|
|
16
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
17
|
+
const totalSec = ms / 1000
|
|
18
|
+
if (totalSec < 60) return `${totalSec.toFixed(totalSec < 10 ? 1 : 0)}s`
|
|
19
|
+
const m = Math.floor(totalSec / 60)
|
|
20
|
+
const sec = Math.round(totalSec % 60)
|
|
21
|
+
return sec ? `${m}m ${sec}s` : `${m}m`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A ratio (0..1) as a whole-number percentage. */
|
|
25
|
+
export function pct(ratio: number): number {
|
|
26
|
+
return Math.round(ratio * 100)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Output-limit headroom for a step's rollup: the fraction of the output ceiling the
|
|
31
|
+
* closest call consumed (0..1), or null when the ceiling is unknown. 1 (or any
|
|
32
|
+
* truncated call) means a call hit the limit and was cut short.
|
|
33
|
+
*/
|
|
34
|
+
export function headroomRatio(
|
|
35
|
+
m: Pick<StepMetrics, 'peakCompletionTokens' | 'maxOutputTokens'>,
|
|
36
|
+
): number | null {
|
|
37
|
+
if (m.maxOutputTokens == null || m.maxOutputTokens <= 0) return null
|
|
38
|
+
return Math.min(1, m.peakCompletionTokens / m.maxOutputTokens)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Share of a step's latency spent in transport/proxy overhead (0..1), or null. */
|
|
42
|
+
export function transportRatio(m: Pick<StepMetrics, 'upstreamMs' | 'overheadMs'>): number | null {
|
|
43
|
+
const total = m.upstreamMs + m.overheadMs
|
|
44
|
+
return total > 0 ? m.overheadMs / total : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Tailwind text/bg colour for an output-headroom level (green → amber → red). */
|
|
48
|
+
export function headroomColor(ratio: number | null, truncated: boolean): string {
|
|
49
|
+
if (truncated || (ratio != null && ratio >= 0.98)) return 'text-rose-400'
|
|
50
|
+
if (ratio != null && ratio >= 0.8) return 'text-amber-400'
|
|
51
|
+
return 'text-emerald-400'
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.3",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/kibertoad/cat-factory.git",
|
|
7
|
+
"directory": "frontend/app"
|
|
8
|
+
},
|
|
4
9
|
"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
10
|
"files": [
|
|
6
11
|
"app",
|