@ai-gui/plugin-progress 0.14.0
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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/index.cjs +203 -0
- package/dist/index.d.cts +63 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +197 -0
- package/package.json +61 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Liang Li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @ai-gui/plugin-progress
|
|
2
|
+
|
|
3
|
+
Live progress for a long turn, for [AIGUI](../../README.md). The plugin claims one `progress` fence and draws the steps it declares, several per request, each updated in place.
|
|
4
|
+
|
|
5
|
+
A model that is going to search, read three sources, then draft spends a long time saying nothing. A host-level "thinking…" covers that, but it is one line for the whole turn: it cannot say which of the four things is happening, which have finished, or that the third one failed.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @ai-gui/plugin-progress
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { progress, progressCss } from "@ai-gui/plugin-progress"
|
|
17
|
+
import { AIRenderer } from "@ai-gui/react"
|
|
18
|
+
|
|
19
|
+
<style>{progressCss}</style>
|
|
20
|
+
<AIRenderer plugins={[progress()]} />
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
One step, or several:
|
|
24
|
+
|
|
25
|
+
```progress
|
|
26
|
+
{"version":1,"steps":[
|
|
27
|
+
{"id":"search","label":"检索资料","state":"done"},
|
|
28
|
+
{"id":"read","label":"阅读来源","state":"running","detail":"第 2/5 篇","percent":40},
|
|
29
|
+
{"id":"draft","label":"撰写讲解","state":"pending"}
|
|
30
|
+
]}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Updating a step
|
|
34
|
+
|
|
35
|
+
Emit it again with the same `id`:
|
|
36
|
+
|
|
37
|
+
```progress
|
|
38
|
+
{"version":1,"id":"read","label":"阅读来源","state":"done","detail":"5 篇已读"}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The later block replaces the earlier one, so restating the whole list is fine and does not duplicate rows. This is the part that makes it usable: a streamed answer is append-only, so an update *is* a second block, and without superseding a turn that reported four steps three times would render twelve rows.
|
|
42
|
+
|
|
43
|
+
Ownership is decided in `onASTCommit`, from the whole node list, so it is right however the host re-parses the turn.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
- `progress(options?)` creates the AIGUI plugin.
|
|
48
|
+
- `progressPromptSpec(options?)` returns the model-facing protocol description.
|
|
49
|
+
- `parseProgress(source, options?)` strictly parses a block.
|
|
50
|
+
- `renderProgressHTML(steps)` renders parsed steps.
|
|
51
|
+
- `progressCss` contains the package styling.
|
|
52
|
+
|
|
53
|
+
## Options
|
|
54
|
+
|
|
55
|
+
- `maxSteps?: number`: per block, default `24`.
|
|
56
|
+
- `maxSourceBytes?: number`: default 8 KiB.
|
|
57
|
+
|
|
58
|
+
States are `pending`, `running`, `done`, `failed` and `skipped`, defaulting to `running`. A percentage is clamped to 0–100 and omitted when the work has no measurable fraction.
|
|
59
|
+
|
|
60
|
+
Colours come from `currentColor` and `--aigui-progress-*` custom properties. The running marker stops spinning under `prefers-reduced-motion`. Output is marked `trusted` because it is built from parsed data rather than model markup, and labels are escaped.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
maxSourceBytes: 8 * 1024,
|
|
6
|
+
maxSteps: 24
|
|
7
|
+
};
|
|
8
|
+
const STATES = [
|
|
9
|
+
"pending",
|
|
10
|
+
"running",
|
|
11
|
+
"done",
|
|
12
|
+
"failed",
|
|
13
|
+
"skipped"
|
|
14
|
+
];
|
|
15
|
+
function escapeHtml(value) {
|
|
16
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read the steps out of a block.
|
|
20
|
+
*
|
|
21
|
+
* Accepts one step or a list, because both are how a model writes this: a single step when it starts
|
|
22
|
+
* something, the whole list when it restates where it is. Rejecting either shape would mean progress
|
|
23
|
+
* that silently stops rendering halfway through a turn.
|
|
24
|
+
*/
|
|
25
|
+
function parseProgress(source, options = {}) {
|
|
26
|
+
const limits = {
|
|
27
|
+
...DEFAULTS,
|
|
28
|
+
...options
|
|
29
|
+
};
|
|
30
|
+
if (new TextEncoder().encode(source).byteLength > limits.maxSourceBytes) return {
|
|
31
|
+
valid: false,
|
|
32
|
+
issues: ["Progress block is too large."]
|
|
33
|
+
};
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(source);
|
|
37
|
+
} catch {
|
|
38
|
+
return {
|
|
39
|
+
valid: false,
|
|
40
|
+
issues: ["Progress must be valid JSON."]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
|
|
44
|
+
valid: false,
|
|
45
|
+
issues: ["Progress must be a JSON object."]
|
|
46
|
+
};
|
|
47
|
+
const value = parsed;
|
|
48
|
+
if (value.version !== 1) return {
|
|
49
|
+
valid: false,
|
|
50
|
+
issues: ["Progress must declare \"version\": 1."]
|
|
51
|
+
};
|
|
52
|
+
const raw = Array.isArray(value.steps) ? value.steps : [value];
|
|
53
|
+
if (raw.length === 0) return {
|
|
54
|
+
valid: false,
|
|
55
|
+
issues: ["Progress must carry at least one step."]
|
|
56
|
+
};
|
|
57
|
+
if (raw.length > limits.maxSteps) return {
|
|
58
|
+
valid: false,
|
|
59
|
+
issues: [`Progress carries more than ${limits.maxSteps} steps.`]
|
|
60
|
+
};
|
|
61
|
+
const issues = [];
|
|
62
|
+
const steps = [];
|
|
63
|
+
raw.forEach((item, index) => {
|
|
64
|
+
const path = `$.steps[${index}]`;
|
|
65
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
66
|
+
issues.push(`${path} must be an object.`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const step = item;
|
|
70
|
+
if (typeof step.id !== "string" || step.id.trim() === "") {
|
|
71
|
+
issues.push(`${path}.id must be a non-empty string.`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (typeof step.label !== "string" || step.label.trim() === "") {
|
|
75
|
+
issues.push(`${path}.label must be a non-empty string.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const state = step.state === void 0 ? "running" : step.state;
|
|
79
|
+
if (typeof state !== "string" || !STATES.includes(state)) {
|
|
80
|
+
issues.push(`${path}.state must be one of ${STATES.join(", ")}.`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const percent = typeof step.percent === "number" && Number.isFinite(step.percent) ? Math.min(100, Math.max(0, step.percent)) : void 0;
|
|
84
|
+
steps.push({
|
|
85
|
+
id: step.id.trim(),
|
|
86
|
+
label: step.label,
|
|
87
|
+
state,
|
|
88
|
+
...typeof step.detail === "string" && step.detail.trim() !== "" ? { detail: step.detail } : {},
|
|
89
|
+
...percent === void 0 ? {} : { percent }
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
if (issues.length > 0) return {
|
|
93
|
+
valid: false,
|
|
94
|
+
issues
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
valid: true,
|
|
98
|
+
steps
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Draw the steps. */
|
|
102
|
+
function renderProgressHTML(steps) {
|
|
103
|
+
if (steps.length === 0) return "";
|
|
104
|
+
const rows = steps.map((step) => {
|
|
105
|
+
const state = step.state ?? "running";
|
|
106
|
+
const bar = step.percent === void 0 ? "" : `<span class="aigui-progress-bar" role="progressbar" aria-valuenow="${Math.round(step.percent)}" aria-valuemin="0" aria-valuemax="100"><i style="width:${Math.round(step.percent)}%"></i></span>`;
|
|
107
|
+
const detail = step.detail ? `<small>${escapeHtml(step.detail)}</small>` : "";
|
|
108
|
+
return [
|
|
109
|
+
`<li data-aigui-progress-step="${escapeHtml(state)}">`,
|
|
110
|
+
`<span class="aigui-progress-mark" aria-hidden="true"></span>`,
|
|
111
|
+
`<span class="aigui-progress-body"><b>${escapeHtml(step.label)}</b>${detail}${bar}</span>`,
|
|
112
|
+
"</li>"
|
|
113
|
+
].join("");
|
|
114
|
+
}).join("");
|
|
115
|
+
return `<ul data-aigui-progress="steps" aria-live="polite">${rows}</ul>`;
|
|
116
|
+
}
|
|
117
|
+
function progressPromptSpec(options = {}) {
|
|
118
|
+
const limits = {
|
|
119
|
+
...DEFAULTS,
|
|
120
|
+
...options
|
|
121
|
+
};
|
|
122
|
+
return [
|
|
123
|
+
"Progress for a long turn (fenced): ```progress <strict JSON>```.",
|
|
124
|
+
"One step: {\"version\":1,\"id\":\"search\",\"label\":\"检索资料\",\"state\":\"running\",\"detail\":\"...\"?,\"percent\":40?}.",
|
|
125
|
+
"Several at once: {\"version\":1,\"steps\":[{...},{...}]}. No unknown fields.",
|
|
126
|
+
"State is one of pending, running, done, failed, skipped; it defaults to running.",
|
|
127
|
+
"To update a step, emit it again with the same id — the later block replaces the earlier one, so restating the whole list is fine and does not duplicate rows.",
|
|
128
|
+
`At most ${limits.maxSteps} steps per block, ${limits.maxSourceBytes} UTF-8 bytes.`,
|
|
129
|
+
"Use this only when a turn does several things that take real time — searching, reading sources, running a long计算 — and say what each step is for. Do not narrate ordinary prose with it, and do not leave a step running once it has finished."
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
const progressCss = `
|
|
133
|
+
[data-aigui-progress] { margin: 12px 0; padding: 0; list-style: none; display: grid; gap: 7px; }
|
|
134
|
+
[data-aigui-progress] li { display: grid; grid-template-columns: 16px minmax(0, 1fr); align-items: start; gap: 9px; }
|
|
135
|
+
[data-aigui-progress] .aigui-progress-mark { width: 12px; height: 12px; margin-top: 3px; border: 2px solid var(--aigui-progress-idle, currentColor); border-radius: 50%; opacity: .35; }
|
|
136
|
+
[data-aigui-progress] .aigui-progress-body { min-width: 0; display: grid; gap: 3px; }
|
|
137
|
+
[data-aigui-progress] b { font-size: 13px; font-weight: 500; }
|
|
138
|
+
[data-aigui-progress] small { font-size: 11px; opacity: .7; }
|
|
139
|
+
[data-aigui-progress] .aigui-progress-bar { height: 3px; margin-top: 2px; border-radius: 999px; background: color-mix(in srgb, currentColor 14%, transparent); overflow: hidden; }
|
|
140
|
+
[data-aigui-progress] .aigui-progress-bar > i { display: block; height: 100%; background: var(--aigui-progress-running, currentColor); transition: width .3s ease; }
|
|
141
|
+
[data-aigui-progress-step="running"] .aigui-progress-mark { border-color: var(--aigui-progress-running, currentColor); border-right-color: transparent; opacity: 1; animation: aigui-progress-spin .7s linear infinite; }
|
|
142
|
+
[data-aigui-progress-step="done"] .aigui-progress-mark { border-color: var(--aigui-progress-done, currentColor); background: var(--aigui-progress-done, currentColor); opacity: 1; }
|
|
143
|
+
[data-aigui-progress-step="failed"] .aigui-progress-mark { border-color: var(--aigui-progress-failed, currentColor); background: var(--aigui-progress-failed, currentColor); opacity: 1; }
|
|
144
|
+
[data-aigui-progress-step="skipped"] .aigui-progress-mark { border-style: dashed; opacity: .4; }
|
|
145
|
+
[data-aigui-progress-step="done"] b, [data-aigui-progress-step="skipped"] b { opacity: .65; }
|
|
146
|
+
@keyframes aigui-progress-spin { to { transform: rotate(360deg); } }
|
|
147
|
+
/* A reader who asked for less motion gets a filled marker instead of a spinning one. */
|
|
148
|
+
@media (prefers-reduced-motion: reduce) {
|
|
149
|
+
[data-aigui-progress-step="running"] .aigui-progress-mark { animation: none; border-right-color: var(--aigui-progress-running, currentColor); }
|
|
150
|
+
[data-aigui-progress] .aigui-progress-bar > i { transition: none; }
|
|
151
|
+
}
|
|
152
|
+
`;
|
|
153
|
+
function errorHtml(issue) {
|
|
154
|
+
return {
|
|
155
|
+
kind: "html",
|
|
156
|
+
html: `<pre data-aigui-progress-error>${escapeHtml(issue)}</pre>`
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function progress(options = {}) {
|
|
160
|
+
let latest = new Map();
|
|
161
|
+
const render = (node) => {
|
|
162
|
+
if (!node.complete) return {
|
|
163
|
+
kind: "html",
|
|
164
|
+
html: "<div data-aigui-block-loading data-block-type=\"progress\"></div>"
|
|
165
|
+
};
|
|
166
|
+
const parsed = parseProgress(node.content ?? "", options);
|
|
167
|
+
if (!parsed.valid) return errorHtml(parsed.issues[0] ?? "Invalid progress block.");
|
|
168
|
+
const own = parsed.steps.filter((step) => (latest.get(step.id) ?? node) === node);
|
|
169
|
+
if (own.length === 0) return {
|
|
170
|
+
kind: "html",
|
|
171
|
+
html: "",
|
|
172
|
+
trusted: true
|
|
173
|
+
};
|
|
174
|
+
return {
|
|
175
|
+
kind: "html",
|
|
176
|
+
html: renderProgressHTML(own),
|
|
177
|
+
trusted: true
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
name: "progress",
|
|
182
|
+
nodeRenderers: { progress: render },
|
|
183
|
+
onASTCommit: (nodes) => {
|
|
184
|
+
const next = new Map();
|
|
185
|
+
for (const node of nodes) {
|
|
186
|
+
if (node.type !== "progress" || !node.complete) continue;
|
|
187
|
+
const parsed = parseProgress(node.content ?? "", options);
|
|
188
|
+
if (!parsed.valid) continue;
|
|
189
|
+
for (const step of parsed.steps) next.set(step.id, node);
|
|
190
|
+
}
|
|
191
|
+
latest = next;
|
|
192
|
+
},
|
|
193
|
+
promptSpec: progressPromptSpec(options),
|
|
194
|
+
css: progressCss
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
//#endregion
|
|
199
|
+
exports.parseProgress = parseProgress
|
|
200
|
+
exports.progress = progress
|
|
201
|
+
exports.progressCss = progressCss
|
|
202
|
+
exports.progressPromptSpec = progressPromptSpec
|
|
203
|
+
exports.renderProgressHTML = renderProgressHTML
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AIGuiPlugin } from "@ai-gui/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Progress for work that takes a while inside one turn.
|
|
6
|
+
*
|
|
7
|
+
* A model that is going to search, read three sources, then draft, spends a long time saying nothing.
|
|
8
|
+
* A host-level "thinking…" covers that, but it is one line for the whole turn and it cannot say which
|
|
9
|
+
* of the four things is happening, which have finished, or that the third one failed.
|
|
10
|
+
*
|
|
11
|
+
* So progress is something the model writes, several steps per turn, each updated in place: emitting a
|
|
12
|
+
* step again with the same `id` replaces it. That last part is the whole design. A streamed answer is
|
|
13
|
+
* append-only, so an update *is* a second block — without superseding, a turn that reported four steps
|
|
14
|
+
* three times would render twelve rows and read as chaos.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Progress for work that takes a while inside one turn.
|
|
18
|
+
*
|
|
19
|
+
* A model that is going to search, read three sources, then draft, spends a long time saying nothing.
|
|
20
|
+
* A host-level "thinking…" covers that, but it is one line for the whole turn and it cannot say which
|
|
21
|
+
* of the four things is happening, which have finished, or that the third one failed.
|
|
22
|
+
*
|
|
23
|
+
* So progress is something the model writes, several steps per turn, each updated in place: emitting a
|
|
24
|
+
* step again with the same `id` replaces it. That last part is the whole design. A streamed answer is
|
|
25
|
+
* append-only, so an update *is* a second block — without superseding, a turn that reported four steps
|
|
26
|
+
* three times would render twelve rows and read as chaos.
|
|
27
|
+
*/
|
|
28
|
+
type StepState = "pending" | "running" | "done" | "failed" | "skipped";
|
|
29
|
+
interface ProgressStep {
|
|
30
|
+
/** Stable within a turn. Emitting the same id again updates that step rather than adding one. */
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
state?: StepState;
|
|
34
|
+
/** A short line under the label: what it is doing, or why it failed. */
|
|
35
|
+
detail?: string;
|
|
36
|
+
/** 0–100, when the work has a measurable fraction. Omit when it does not. */
|
|
37
|
+
percent?: number;
|
|
38
|
+
}
|
|
39
|
+
interface ProgressOptions {
|
|
40
|
+
maxSourceBytes?: number;
|
|
41
|
+
/** How many steps one block may carry. */
|
|
42
|
+
maxSteps?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the steps out of a block.
|
|
46
|
+
*
|
|
47
|
+
* Accepts one step or a list, because both are how a model writes this: a single step when it starts
|
|
48
|
+
* something, the whole list when it restates where it is. Rejecting either shape would mean progress
|
|
49
|
+
* that silently stops rendering halfway through a turn.
|
|
50
|
+
*/
|
|
51
|
+
declare function parseProgress(source: string, options?: ProgressOptions): {
|
|
52
|
+
valid: true;
|
|
53
|
+
steps: ProgressStep[];
|
|
54
|
+
} | {
|
|
55
|
+
valid: false;
|
|
56
|
+
issues: string[];
|
|
57
|
+
};
|
|
58
|
+
/** Draw the steps. */
|
|
59
|
+
declare function renderProgressHTML(steps: ProgressStep[]): string;
|
|
60
|
+
declare function progressPromptSpec(options?: ProgressOptions): string;
|
|
61
|
+
declare const progressCss = "\n[data-aigui-progress] { margin: 12px 0; padding: 0; list-style: none; display: grid; gap: 7px; }\n[data-aigui-progress] li { display: grid; grid-template-columns: 16px minmax(0, 1fr); align-items: start; gap: 9px; }\n[data-aigui-progress] .aigui-progress-mark { width: 12px; height: 12px; margin-top: 3px; border: 2px solid var(--aigui-progress-idle, currentColor); border-radius: 50%; opacity: .35; }\n[data-aigui-progress] .aigui-progress-body { min-width: 0; display: grid; gap: 3px; }\n[data-aigui-progress] b { font-size: 13px; font-weight: 500; }\n[data-aigui-progress] small { font-size: 11px; opacity: .7; }\n[data-aigui-progress] .aigui-progress-bar { height: 3px; margin-top: 2px; border-radius: 999px; background: color-mix(in srgb, currentColor 14%, transparent); overflow: hidden; }\n[data-aigui-progress] .aigui-progress-bar > i { display: block; height: 100%; background: var(--aigui-progress-running, currentColor); transition: width .3s ease; }\n[data-aigui-progress-step=\"running\"] .aigui-progress-mark { border-color: var(--aigui-progress-running, currentColor); border-right-color: transparent; opacity: 1; animation: aigui-progress-spin .7s linear infinite; }\n[data-aigui-progress-step=\"done\"] .aigui-progress-mark { border-color: var(--aigui-progress-done, currentColor); background: var(--aigui-progress-done, currentColor); opacity: 1; }\n[data-aigui-progress-step=\"failed\"] .aigui-progress-mark { border-color: var(--aigui-progress-failed, currentColor); background: var(--aigui-progress-failed, currentColor); opacity: 1; }\n[data-aigui-progress-step=\"skipped\"] .aigui-progress-mark { border-style: dashed; opacity: .4; }\n[data-aigui-progress-step=\"done\"] b, [data-aigui-progress-step=\"skipped\"] b { opacity: .65; }\n@keyframes aigui-progress-spin { to { transform: rotate(360deg); } }\n/* A reader who asked for less motion gets a filled marker instead of a spinning one. */\n@media (prefers-reduced-motion: reduce) {\n [data-aigui-progress-step=\"running\"] .aigui-progress-mark { animation: none; border-right-color: var(--aigui-progress-running, currentColor); }\n [data-aigui-progress] .aigui-progress-bar > i { transition: none; }\n}\n";
|
|
62
|
+
declare function progress(options?: ProgressOptions): AIGuiPlugin; //#endregion
|
|
63
|
+
export { ProgressOptions, ProgressStep, StepState, parseProgress, progress, progressCss, progressPromptSpec, renderProgressHTML };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AIGuiPlugin } from "@ai-gui/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Progress for work that takes a while inside one turn.
|
|
6
|
+
*
|
|
7
|
+
* A model that is going to search, read three sources, then draft, spends a long time saying nothing.
|
|
8
|
+
* A host-level "thinking…" covers that, but it is one line for the whole turn and it cannot say which
|
|
9
|
+
* of the four things is happening, which have finished, or that the third one failed.
|
|
10
|
+
*
|
|
11
|
+
* So progress is something the model writes, several steps per turn, each updated in place: emitting a
|
|
12
|
+
* step again with the same `id` replaces it. That last part is the whole design. A streamed answer is
|
|
13
|
+
* append-only, so an update *is* a second block — without superseding, a turn that reported four steps
|
|
14
|
+
* three times would render twelve rows and read as chaos.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Progress for work that takes a while inside one turn.
|
|
18
|
+
*
|
|
19
|
+
* A model that is going to search, read three sources, then draft, spends a long time saying nothing.
|
|
20
|
+
* A host-level "thinking…" covers that, but it is one line for the whole turn and it cannot say which
|
|
21
|
+
* of the four things is happening, which have finished, or that the third one failed.
|
|
22
|
+
*
|
|
23
|
+
* So progress is something the model writes, several steps per turn, each updated in place: emitting a
|
|
24
|
+
* step again with the same `id` replaces it. That last part is the whole design. A streamed answer is
|
|
25
|
+
* append-only, so an update *is* a second block — without superseding, a turn that reported four steps
|
|
26
|
+
* three times would render twelve rows and read as chaos.
|
|
27
|
+
*/
|
|
28
|
+
type StepState = "pending" | "running" | "done" | "failed" | "skipped";
|
|
29
|
+
interface ProgressStep {
|
|
30
|
+
/** Stable within a turn. Emitting the same id again updates that step rather than adding one. */
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
state?: StepState;
|
|
34
|
+
/** A short line under the label: what it is doing, or why it failed. */
|
|
35
|
+
detail?: string;
|
|
36
|
+
/** 0–100, when the work has a measurable fraction. Omit when it does not. */
|
|
37
|
+
percent?: number;
|
|
38
|
+
}
|
|
39
|
+
interface ProgressOptions {
|
|
40
|
+
maxSourceBytes?: number;
|
|
41
|
+
/** How many steps one block may carry. */
|
|
42
|
+
maxSteps?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the steps out of a block.
|
|
46
|
+
*
|
|
47
|
+
* Accepts one step or a list, because both are how a model writes this: a single step when it starts
|
|
48
|
+
* something, the whole list when it restates where it is. Rejecting either shape would mean progress
|
|
49
|
+
* that silently stops rendering halfway through a turn.
|
|
50
|
+
*/
|
|
51
|
+
declare function parseProgress(source: string, options?: ProgressOptions): {
|
|
52
|
+
valid: true;
|
|
53
|
+
steps: ProgressStep[];
|
|
54
|
+
} | {
|
|
55
|
+
valid: false;
|
|
56
|
+
issues: string[];
|
|
57
|
+
};
|
|
58
|
+
/** Draw the steps. */
|
|
59
|
+
declare function renderProgressHTML(steps: ProgressStep[]): string;
|
|
60
|
+
declare function progressPromptSpec(options?: ProgressOptions): string;
|
|
61
|
+
declare const progressCss = "\n[data-aigui-progress] { margin: 12px 0; padding: 0; list-style: none; display: grid; gap: 7px; }\n[data-aigui-progress] li { display: grid; grid-template-columns: 16px minmax(0, 1fr); align-items: start; gap: 9px; }\n[data-aigui-progress] .aigui-progress-mark { width: 12px; height: 12px; margin-top: 3px; border: 2px solid var(--aigui-progress-idle, currentColor); border-radius: 50%; opacity: .35; }\n[data-aigui-progress] .aigui-progress-body { min-width: 0; display: grid; gap: 3px; }\n[data-aigui-progress] b { font-size: 13px; font-weight: 500; }\n[data-aigui-progress] small { font-size: 11px; opacity: .7; }\n[data-aigui-progress] .aigui-progress-bar { height: 3px; margin-top: 2px; border-radius: 999px; background: color-mix(in srgb, currentColor 14%, transparent); overflow: hidden; }\n[data-aigui-progress] .aigui-progress-bar > i { display: block; height: 100%; background: var(--aigui-progress-running, currentColor); transition: width .3s ease; }\n[data-aigui-progress-step=\"running\"] .aigui-progress-mark { border-color: var(--aigui-progress-running, currentColor); border-right-color: transparent; opacity: 1; animation: aigui-progress-spin .7s linear infinite; }\n[data-aigui-progress-step=\"done\"] .aigui-progress-mark { border-color: var(--aigui-progress-done, currentColor); background: var(--aigui-progress-done, currentColor); opacity: 1; }\n[data-aigui-progress-step=\"failed\"] .aigui-progress-mark { border-color: var(--aigui-progress-failed, currentColor); background: var(--aigui-progress-failed, currentColor); opacity: 1; }\n[data-aigui-progress-step=\"skipped\"] .aigui-progress-mark { border-style: dashed; opacity: .4; }\n[data-aigui-progress-step=\"done\"] b, [data-aigui-progress-step=\"skipped\"] b { opacity: .65; }\n@keyframes aigui-progress-spin { to { transform: rotate(360deg); } }\n/* A reader who asked for less motion gets a filled marker instead of a spinning one. */\n@media (prefers-reduced-motion: reduce) {\n [data-aigui-progress-step=\"running\"] .aigui-progress-mark { animation: none; border-right-color: var(--aigui-progress-running, currentColor); }\n [data-aigui-progress] .aigui-progress-bar > i { transition: none; }\n}\n";
|
|
62
|
+
declare function progress(options?: ProgressOptions): AIGuiPlugin; //#endregion
|
|
63
|
+
export { ProgressOptions, ProgressStep, StepState, parseProgress, progress, progressCss, progressPromptSpec, renderProgressHTML };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
const DEFAULTS = {
|
|
3
|
+
maxSourceBytes: 8 * 1024,
|
|
4
|
+
maxSteps: 24
|
|
5
|
+
};
|
|
6
|
+
const STATES = [
|
|
7
|
+
"pending",
|
|
8
|
+
"running",
|
|
9
|
+
"done",
|
|
10
|
+
"failed",
|
|
11
|
+
"skipped"
|
|
12
|
+
];
|
|
13
|
+
function escapeHtml(value) {
|
|
14
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read the steps out of a block.
|
|
18
|
+
*
|
|
19
|
+
* Accepts one step or a list, because both are how a model writes this: a single step when it starts
|
|
20
|
+
* something, the whole list when it restates where it is. Rejecting either shape would mean progress
|
|
21
|
+
* that silently stops rendering halfway through a turn.
|
|
22
|
+
*/
|
|
23
|
+
function parseProgress(source, options = {}) {
|
|
24
|
+
const limits = {
|
|
25
|
+
...DEFAULTS,
|
|
26
|
+
...options
|
|
27
|
+
};
|
|
28
|
+
if (new TextEncoder().encode(source).byteLength > limits.maxSourceBytes) return {
|
|
29
|
+
valid: false,
|
|
30
|
+
issues: ["Progress block is too large."]
|
|
31
|
+
};
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(source);
|
|
35
|
+
} catch {
|
|
36
|
+
return {
|
|
37
|
+
valid: false,
|
|
38
|
+
issues: ["Progress must be valid JSON."]
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
|
|
42
|
+
valid: false,
|
|
43
|
+
issues: ["Progress must be a JSON object."]
|
|
44
|
+
};
|
|
45
|
+
const value = parsed;
|
|
46
|
+
if (value.version !== 1) return {
|
|
47
|
+
valid: false,
|
|
48
|
+
issues: ["Progress must declare \"version\": 1."]
|
|
49
|
+
};
|
|
50
|
+
const raw = Array.isArray(value.steps) ? value.steps : [value];
|
|
51
|
+
if (raw.length === 0) return {
|
|
52
|
+
valid: false,
|
|
53
|
+
issues: ["Progress must carry at least one step."]
|
|
54
|
+
};
|
|
55
|
+
if (raw.length > limits.maxSteps) return {
|
|
56
|
+
valid: false,
|
|
57
|
+
issues: [`Progress carries more than ${limits.maxSteps} steps.`]
|
|
58
|
+
};
|
|
59
|
+
const issues = [];
|
|
60
|
+
const steps = [];
|
|
61
|
+
raw.forEach((item, index) => {
|
|
62
|
+
const path = `$.steps[${index}]`;
|
|
63
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
64
|
+
issues.push(`${path} must be an object.`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const step = item;
|
|
68
|
+
if (typeof step.id !== "string" || step.id.trim() === "") {
|
|
69
|
+
issues.push(`${path}.id must be a non-empty string.`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (typeof step.label !== "string" || step.label.trim() === "") {
|
|
73
|
+
issues.push(`${path}.label must be a non-empty string.`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const state = step.state === void 0 ? "running" : step.state;
|
|
77
|
+
if (typeof state !== "string" || !STATES.includes(state)) {
|
|
78
|
+
issues.push(`${path}.state must be one of ${STATES.join(", ")}.`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const percent = typeof step.percent === "number" && Number.isFinite(step.percent) ? Math.min(100, Math.max(0, step.percent)) : void 0;
|
|
82
|
+
steps.push({
|
|
83
|
+
id: step.id.trim(),
|
|
84
|
+
label: step.label,
|
|
85
|
+
state,
|
|
86
|
+
...typeof step.detail === "string" && step.detail.trim() !== "" ? { detail: step.detail } : {},
|
|
87
|
+
...percent === void 0 ? {} : { percent }
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
if (issues.length > 0) return {
|
|
91
|
+
valid: false,
|
|
92
|
+
issues
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
valid: true,
|
|
96
|
+
steps
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Draw the steps. */
|
|
100
|
+
function renderProgressHTML(steps) {
|
|
101
|
+
if (steps.length === 0) return "";
|
|
102
|
+
const rows = steps.map((step) => {
|
|
103
|
+
const state = step.state ?? "running";
|
|
104
|
+
const bar = step.percent === void 0 ? "" : `<span class="aigui-progress-bar" role="progressbar" aria-valuenow="${Math.round(step.percent)}" aria-valuemin="0" aria-valuemax="100"><i style="width:${Math.round(step.percent)}%"></i></span>`;
|
|
105
|
+
const detail = step.detail ? `<small>${escapeHtml(step.detail)}</small>` : "";
|
|
106
|
+
return [
|
|
107
|
+
`<li data-aigui-progress-step="${escapeHtml(state)}">`,
|
|
108
|
+
`<span class="aigui-progress-mark" aria-hidden="true"></span>`,
|
|
109
|
+
`<span class="aigui-progress-body"><b>${escapeHtml(step.label)}</b>${detail}${bar}</span>`,
|
|
110
|
+
"</li>"
|
|
111
|
+
].join("");
|
|
112
|
+
}).join("");
|
|
113
|
+
return `<ul data-aigui-progress="steps" aria-live="polite">${rows}</ul>`;
|
|
114
|
+
}
|
|
115
|
+
function progressPromptSpec(options = {}) {
|
|
116
|
+
const limits = {
|
|
117
|
+
...DEFAULTS,
|
|
118
|
+
...options
|
|
119
|
+
};
|
|
120
|
+
return [
|
|
121
|
+
"Progress for a long turn (fenced): ```progress <strict JSON>```.",
|
|
122
|
+
"One step: {\"version\":1,\"id\":\"search\",\"label\":\"检索资料\",\"state\":\"running\",\"detail\":\"...\"?,\"percent\":40?}.",
|
|
123
|
+
"Several at once: {\"version\":1,\"steps\":[{...},{...}]}. No unknown fields.",
|
|
124
|
+
"State is one of pending, running, done, failed, skipped; it defaults to running.",
|
|
125
|
+
"To update a step, emit it again with the same id — the later block replaces the earlier one, so restating the whole list is fine and does not duplicate rows.",
|
|
126
|
+
`At most ${limits.maxSteps} steps per block, ${limits.maxSourceBytes} UTF-8 bytes.`,
|
|
127
|
+
"Use this only when a turn does several things that take real time — searching, reading sources, running a long计算 — and say what each step is for. Do not narrate ordinary prose with it, and do not leave a step running once it has finished."
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
const progressCss = `
|
|
131
|
+
[data-aigui-progress] { margin: 12px 0; padding: 0; list-style: none; display: grid; gap: 7px; }
|
|
132
|
+
[data-aigui-progress] li { display: grid; grid-template-columns: 16px minmax(0, 1fr); align-items: start; gap: 9px; }
|
|
133
|
+
[data-aigui-progress] .aigui-progress-mark { width: 12px; height: 12px; margin-top: 3px; border: 2px solid var(--aigui-progress-idle, currentColor); border-radius: 50%; opacity: .35; }
|
|
134
|
+
[data-aigui-progress] .aigui-progress-body { min-width: 0; display: grid; gap: 3px; }
|
|
135
|
+
[data-aigui-progress] b { font-size: 13px; font-weight: 500; }
|
|
136
|
+
[data-aigui-progress] small { font-size: 11px; opacity: .7; }
|
|
137
|
+
[data-aigui-progress] .aigui-progress-bar { height: 3px; margin-top: 2px; border-radius: 999px; background: color-mix(in srgb, currentColor 14%, transparent); overflow: hidden; }
|
|
138
|
+
[data-aigui-progress] .aigui-progress-bar > i { display: block; height: 100%; background: var(--aigui-progress-running, currentColor); transition: width .3s ease; }
|
|
139
|
+
[data-aigui-progress-step="running"] .aigui-progress-mark { border-color: var(--aigui-progress-running, currentColor); border-right-color: transparent; opacity: 1; animation: aigui-progress-spin .7s linear infinite; }
|
|
140
|
+
[data-aigui-progress-step="done"] .aigui-progress-mark { border-color: var(--aigui-progress-done, currentColor); background: var(--aigui-progress-done, currentColor); opacity: 1; }
|
|
141
|
+
[data-aigui-progress-step="failed"] .aigui-progress-mark { border-color: var(--aigui-progress-failed, currentColor); background: var(--aigui-progress-failed, currentColor); opacity: 1; }
|
|
142
|
+
[data-aigui-progress-step="skipped"] .aigui-progress-mark { border-style: dashed; opacity: .4; }
|
|
143
|
+
[data-aigui-progress-step="done"] b, [data-aigui-progress-step="skipped"] b { opacity: .65; }
|
|
144
|
+
@keyframes aigui-progress-spin { to { transform: rotate(360deg); } }
|
|
145
|
+
/* A reader who asked for less motion gets a filled marker instead of a spinning one. */
|
|
146
|
+
@media (prefers-reduced-motion: reduce) {
|
|
147
|
+
[data-aigui-progress-step="running"] .aigui-progress-mark { animation: none; border-right-color: var(--aigui-progress-running, currentColor); }
|
|
148
|
+
[data-aigui-progress] .aigui-progress-bar > i { transition: none; }
|
|
149
|
+
}
|
|
150
|
+
`;
|
|
151
|
+
function errorHtml(issue) {
|
|
152
|
+
return {
|
|
153
|
+
kind: "html",
|
|
154
|
+
html: `<pre data-aigui-progress-error>${escapeHtml(issue)}</pre>`
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function progress(options = {}) {
|
|
158
|
+
let latest = new Map();
|
|
159
|
+
const render = (node) => {
|
|
160
|
+
if (!node.complete) return {
|
|
161
|
+
kind: "html",
|
|
162
|
+
html: "<div data-aigui-block-loading data-block-type=\"progress\"></div>"
|
|
163
|
+
};
|
|
164
|
+
const parsed = parseProgress(node.content ?? "", options);
|
|
165
|
+
if (!parsed.valid) return errorHtml(parsed.issues[0] ?? "Invalid progress block.");
|
|
166
|
+
const own = parsed.steps.filter((step) => (latest.get(step.id) ?? node) === node);
|
|
167
|
+
if (own.length === 0) return {
|
|
168
|
+
kind: "html",
|
|
169
|
+
html: "",
|
|
170
|
+
trusted: true
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
kind: "html",
|
|
174
|
+
html: renderProgressHTML(own),
|
|
175
|
+
trusted: true
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
name: "progress",
|
|
180
|
+
nodeRenderers: { progress: render },
|
|
181
|
+
onASTCommit: (nodes) => {
|
|
182
|
+
const next = new Map();
|
|
183
|
+
for (const node of nodes) {
|
|
184
|
+
if (node.type !== "progress" || !node.complete) continue;
|
|
185
|
+
const parsed = parseProgress(node.content ?? "", options);
|
|
186
|
+
if (!parsed.valid) continue;
|
|
187
|
+
for (const step of parsed.steps) next.set(step.id, node);
|
|
188
|
+
}
|
|
189
|
+
latest = next;
|
|
190
|
+
},
|
|
191
|
+
promptSpec: progressPromptSpec(options),
|
|
192
|
+
css: progressCss
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
//#endregion
|
|
197
|
+
export { parseProgress, progress, progressCss, progressPromptSpec, renderProgressHTML };
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ai-gui/plugin-progress",
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "Live progress steps for a long turn — several per request, updated in place.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"llm",
|
|
7
|
+
"ai",
|
|
8
|
+
"progress",
|
|
9
|
+
"steps",
|
|
10
|
+
"streaming",
|
|
11
|
+
"status",
|
|
12
|
+
"plugin",
|
|
13
|
+
"aigui"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Liang Li <ll_faw@hotmail.com>",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/liliang-cn/aigui.git",
|
|
20
|
+
"directory": "packages/plugin-progress"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/liliang-cn/aigui#readme",
|
|
23
|
+
"bugs": "https://github.com/liliang-cn/aigui/issues",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"main": "./dist/index.cjs",
|
|
30
|
+
"module": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"import": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"default": "./dist/index.js"
|
|
37
|
+
},
|
|
38
|
+
"require": {
|
|
39
|
+
"types": "./dist/index.d.cts",
|
|
40
|
+
"default": "./dist/index.cjs"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"CHANGELOG.md"
|
|
49
|
+
],
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@ai-gui/core": "0.14.0"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsdown",
|
|
58
|
+
"test": "pnpm --dir ../.. exec vitest run --project plugin-progress",
|
|
59
|
+
"typecheck": "tsc --noEmit"
|
|
60
|
+
}
|
|
61
|
+
}
|