stunning-md 0.1.0 → 0.2.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/README.md +106 -14
- package/dist/chunk-MKH2VK55.mjs +377 -0
- package/dist/chunk-MKH2VK55.mjs.map +1 -0
- package/dist/core.d.mts +178 -6
- package/dist/core.mjs +174 -87
- package/dist/core.mjs.map +1 -1
- package/dist/index.d.mts +206 -7
- package/dist/index.mjs +3449 -2290
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +21 -1
- package/dist/server.mjs +48 -0
- package/dist/server.mjs.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Markdown in. A beautifully designed website out.
|
|
4
4
|
|
|
5
|
+
[](https://serrynaimo.github.io/stunning-md/?sample=roastery)
|
|
6
|
+
|
|
7
|
+
*[This markdown file](public/samples/roastery.md), rendered. [Open it live.](https://serrynaimo.github.io/stunning-md/?sample=roastery)*
|
|
8
|
+
|
|
5
9
|
`stunning-md` is a React component. Give it a markdown string and it lays the document out section by section — from its structure, the size of its images and the shape of its tables — then themes it to suit what it says. A small classifier model can be consulted for the judgement calls structure cannot settle; without one, rules decide everything.
|
|
6
10
|
|
|
11
|
+
The markdown can be a file, or a model's answer [as it streams in](#streaming-a-models-answer) — and with a chat model attached, the page itself [takes requests](#adding-chat-optional) and lays each answer out as it is written.
|
|
12
|
+
|
|
7
13
|
**[Try the live demo](https://serrynaimo.github.io/stunning-md/)** with your own file or one of the samples.
|
|
8
14
|
|
|
9
15
|
## Install
|
|
@@ -86,20 +92,96 @@ export function Document({ markdown }: { markdown: string }) {
|
|
|
86
92
|
|
|
87
93
|
Any endpoint that accepts `{ state, questions }` with `choice`, `noul` and `score` question types and returns `{ answers }` will work, and `classifier` can be any function of the `Classify` type if you would rather call something else.
|
|
88
94
|
|
|
89
|
-
What is sent: the title, section headings and the first 400 characters of prose; up to eight tables (header and first twelve rows each); and the leading image's alt text, file name and dimensions. The full document is never sent.
|
|
95
|
+
What is sent: the title, section headings and the first 400 characters of prose; up to eight tables (header and first twelve rows each); and the leading image's alt text, file name and dimensions. The full document is never sent. With chat (below), the reader's message and single paragraphs of the model's reply are sent as well — up to a few hundred characters each — to tell conversation from content.
|
|
96
|
+
|
|
97
|
+
### Streaming a model's answer
|
|
98
|
+
|
|
99
|
+
In an AI interface the markdown arrives a little at a time. Pass what you have so far on every update and say that more is coming:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
"use client"
|
|
103
|
+
import { StunningMarkdown } from "stunning-md"
|
|
104
|
+
|
|
105
|
+
export function Answer({ text, done }: { text: string; done: boolean }) {
|
|
106
|
+
return <StunningMarkdown markdown={text} streaming={!done} classifier={classifier} />
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`text` is whatever your stream has produced so far — from the AI SDK's `useChat`, a `fetch` reader, anything. While `streaming` is set:
|
|
111
|
+
|
|
112
|
+
- the page is laid out as the text arrives, **a section at a time**: a section appears when the next one starts, so its layout is decided once and never shifts under the reader. The opening — title and first paragraphs — appears block by block;
|
|
113
|
+
- nothing half-written is shown: not the line in progress, not an open code fence or table, not unfinished frontmatter. A line under the page says which section is being written;
|
|
114
|
+
- the theme is chosen **once**, from the first few hundred characters, and kept. That is less to go on than a whole document, so the choice can differ from the one the finished file would get — set `theme` (or `theme:` in frontmatter) if the look should be fixed;
|
|
115
|
+
- there is no full-page loader, and updates do not reset the page. A text that starts over — a new answer in the same component — does.
|
|
116
|
+
|
|
117
|
+
When `streaming` goes back to `false`, the rest of the text is laid out and the page is final.
|
|
118
|
+
|
|
119
|
+
To do the same outside the component, `settledMarkdown(text)` returns the part of a growing text that is ready, and the heading being written. If your model wraps its answer in remarks ("Sure, here is…"), `sortReply` separates those from the content as it streams — that is what the chat below is built on.
|
|
120
|
+
|
|
121
|
+
It helps to tell the model what it is writing for. `CHAT_INSTRUCTIONS` is a short system prompt that does: answer in markdown with a title and sections, keep remarks to the reader apart from the content, and — since the page draws charts itself — write data as a plain markdown table rather than describing or drawing a chart.
|
|
122
|
+
|
|
123
|
+
### Adding chat (optional)
|
|
124
|
+
|
|
125
|
+
Give the component a chat function and the page takes requests. A floating input at the bottom sends them to any OpenAI-compatible chat model; each answer is laid out below as its own designed section of the page, in a theme chosen for it, while the conversation itself lives in the sidebar.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// app/api/chat/route.ts
|
|
129
|
+
import { createChatHandler } from "stunning-md/server"
|
|
130
|
+
|
|
131
|
+
export const POST = createChatHandler({
|
|
132
|
+
url: process.env.STUNNING_MD_CHAT_URL, // …/v1, or the full …/chat/completions address
|
|
133
|
+
model: process.env.STUNNING_MD_CHAT_MODEL,
|
|
134
|
+
apiKey: process.env.STUNNING_MD_CHAT_KEY, // optional — a local model needs none
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
"use client"
|
|
140
|
+
import { StunningMarkdown, createChat, createClassifier } from "stunning-md"
|
|
141
|
+
|
|
142
|
+
const chat = createChat({ endpoint: "/api/chat" })
|
|
143
|
+
const classifier = createClassifier({ endpoint: "/api/classify" })
|
|
144
|
+
|
|
145
|
+
export function Document({ markdown }: { markdown: string }) {
|
|
146
|
+
return <StunningMarkdown markdown={markdown} chat={chat} classifier={classifier} />
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`markdown` may be an empty string: the page then starts blank and fills as you ask.
|
|
151
|
+
|
|
152
|
+
A model's reply is sorted as it streams, block by block, into two kinds of text:
|
|
153
|
+
|
|
154
|
+
- **Content** — the thing that was asked for — is rendered on the page by the usual rules. It appears a section at a time, as each section is completed, so a section's layout is decided once and does not shift. Turns are set apart by a band of bare page, black or white with the mode.
|
|
155
|
+
- **Commentary** — the model talking about its answer ("Sure, here is…", "Let me know if…") — is shown briefly above the input, then fades. It stays in the sidebar, where the whole conversation is kept in order: your messages, the model's remarks, and, in place of each answer, a short list of the headings it put on the page, which jump to them.
|
|
156
|
+
|
|
157
|
+
Not every message asks for a page. Before the reply arrives, the message itself is judged (`wantsContent`): a greeting, thanks or small talk gets its reply in the conversation and the page makes no room for it — unless the reply turns out to carry content after all. The classifier is asked this; without one, only messages that are plainly small talk count.
|
|
158
|
+
|
|
159
|
+
Likewise, a reply that is not an answer — the model does not know, cannot help, or asks a question back — puts nothing on the page: all of it is commentary, the place made for the turn is taken away again, and the reader is returned to where they were. The classifier is asked this of a reply's first paragraph, with the request beside it; without a classifier, the paragraph's opening words decide.
|
|
160
|
+
|
|
161
|
+
The conversation sits beside the page on a wide screen and in a sheet on a narrower one. It stays shut until the first reply starts to arrive, then opens by itself where there is room for it; after that the reader's own choice stands. The input is centred on the whole window and floats above the conversation, sheet included, so you can keep writing with the conversation in view — and while it is in view, remarks are not shown a second time above the input.
|
|
162
|
+
|
|
163
|
+
Each answer chooses its theme once, from its opening and the request, and keeps it; a new answer starts with a full window to itself, so it can be brought to the top before it is written. Until that choice is made the page keeps the chat's own plain, neutral look — the one its sidebar and input wear whatever the turns are wearing. While nothing has come back yet, the new turn shows the request itself beside a spinner; it fades as the answer starts, and the spinner stays until the turn is done. On a page with nothing on it yet, the request is shown this way at once, whatever kind of message it turns out to be.
|
|
164
|
+
|
|
165
|
+
Headings, lists, tables, code and images are always content. A plain paragraph is judged by where it sits and how it reads: remarks come at the start or the end of a reply and usually announce themselves. The classifier is asked about the unclear ones — on its own it is not a reliable judge of this, so it never overrules both position and wording. Without a classifier, the first paragraph of a reply is taken as commentary, and so is the last.
|
|
166
|
+
|
|
167
|
+
The open document and the conversation so far are sent to the chat model with each request, after `CHAT_INSTRUCTIONS` as the system prompt.
|
|
90
168
|
|
|
91
169
|
### Props
|
|
92
170
|
|
|
93
171
|
| Prop | Type | Default | |
|
|
94
172
|
| --- | --- | --- | --- |
|
|
95
173
|
| `markdown` | `string` | — | The document. |
|
|
174
|
+
| `streaming` | `boolean` | `false` | The text is still being written; lay it out as it grows. |
|
|
96
175
|
| `classifier` | `Classify` | — | Answers judgement calls; omit for rules only. |
|
|
176
|
+
| `chat` | `Chat` | — | Lets the reader ask for content; answers are laid out on the page. |
|
|
97
177
|
| `theme` | `Partial<ThemeChoice>` | — | Fix `palette`, `fonts` or `formality` (corner style). |
|
|
178
|
+
| `autoTheme` | `boolean` | `true` | Choose a theme to suit each document and answer. `false` stays on one theme. |
|
|
98
179
|
| `appearance` | `"auto" \| "light" \| "dark"` | `"auto"` | `auto` follows the system setting. |
|
|
99
180
|
| `resolveUrl` | `(url: string) => string` | identity | Map URLs in the markdown to loadable ones. |
|
|
100
181
|
| `controls` | `boolean` | `true` | Show the view switch and the theme, layout and chart pickers. |
|
|
101
182
|
| `editable` | `boolean` | `false` | Let the reader edit the text in the markdown view. |
|
|
102
183
|
| `onMarkdownChange` | `(markdown: string) => void` | — | Called when the reader's edits are applied. |
|
|
184
|
+
| `chatAccessory` | `ReactNode` | — | A button or link of your own, shown as a round button left of the chat input. |
|
|
103
185
|
| `loadFonts` | `boolean` | `true` | Load the theme's typefaces from Google Fonts. |
|
|
104
186
|
| `settleMs` | `number` | `2500` | Longest wait for an image to report its size. |
|
|
105
187
|
| `maxWaitMs` | `number` | `8000` | Longest the loader waits for the classifier and fonts. |
|
|
@@ -108,13 +190,15 @@ What is sent: the title, section headings and the first 400 characters of prose;
|
|
|
108
190
|
|
|
109
191
|
A theme can also be set per document, in frontmatter: `theme: midnight`.
|
|
110
192
|
|
|
193
|
+
To keep your own look throughout, switch the choosing off: `<StunningMarkdown markdown={text} autoTheme={false} theme={{ palette: "ocean" }} />` wears that one theme for the document and for everything a chat or a stream adds to it, and asks the classifier nothing about themes. Without a `palette` it stays on `paper`. Readers have the same switch — "Match the content", at the top of the theme menu: with it off, the theme they are looking at stays, and any theme they pick applies to the whole page.
|
|
194
|
+
|
|
111
195
|
### Entry points
|
|
112
196
|
|
|
113
197
|
| Import | Contents | Runs |
|
|
114
198
|
| --- | --- | --- |
|
|
115
|
-
| `stunning-md` | `StunningMarkdown`, `createClassifier`, themes, and everything in `core` | In the browser |
|
|
116
|
-
| `stunning-md/core` | `parseMarkdown`, `planDocument`, table inference, `judgeDocument`, theme data | Anywhere — no React |
|
|
117
|
-
| `stunning-md/server` | `createClassifierHandler` | On the server |
|
|
199
|
+
| `stunning-md` | `StunningMarkdown`, `createClassifier`, `createChat`, themes, and everything in `core` | In the browser |
|
|
200
|
+
| `stunning-md/core` | `parseMarkdown`, `planDocument`, table inference, `judgeDocument`, `settledMarkdown`, `sortReply`, `wantsContent`, `CHAT_INSTRUCTIONS`, theme data | Anywhere — no React |
|
|
201
|
+
| `stunning-md/server` | `createClassifierHandler`, `createChatHandler` | On the server |
|
|
118
202
|
| `stunning-md/styles.css` | All styles for the component | — |
|
|
119
203
|
|
|
120
204
|
The analysis is plain TypeScript and useful on its own:
|
|
@@ -130,7 +214,7 @@ plan.sections.map((s) => [s.titleText, s.layout, s.reason])
|
|
|
130
214
|
|
|
131
215
|
| Content | Becomes |
|
|
132
216
|
| --- | --- |
|
|
133
|
-
| Leading `# Title`, short opening paragraphs | Hero with title and lead |
|
|
217
|
+
| Leading `# Title`, short opening paragraphs | Hero with title and lead — on the page, or as a cover in the theme's colour |
|
|
134
218
|
| Leading image, ≥ 1200 px wide and landscape | Full-bleed banner behind the title |
|
|
135
219
|
| Leading image that is small, square or an SVG | Logo above a centred title |
|
|
136
220
|
| Badge images (shields.io and similar) | A badge row in the hero |
|
|
@@ -145,7 +229,8 @@ plan.sections.map((s) => [s.titleText, s.layout, s.reason])
|
|
|
145
229
|
| `> [!NOTE]` and friends | Callouts |
|
|
146
230
|
| Table: periods × measures | Line, area or bar chart |
|
|
147
231
|
| Table: categories × one measure | Bar chart, donut (parts of a whole) or stat tiles |
|
|
148
|
-
| Table:
|
|
232
|
+
| Table: categories with long names × one measure | Ranked bars, each name on its own line above its bar |
|
|
233
|
+
| Table: dates × descriptions — or descriptions beside a column of years in order | Timeline |
|
|
149
234
|
| Table: short key–value pairs | Fact sheet |
|
|
150
235
|
| Any other table | A table, with horizontal scroll on small screens |
|
|
151
236
|
| Three or more titled sections | Sticky bar with reading progress and a contents list — pinned beside the page when it is at least 1400 px wide, in a slide-over menu otherwise |
|
|
@@ -156,7 +241,7 @@ The rest of markdown works as you would expect: GFM tables, task lists, striketh
|
|
|
156
241
|
|
|
157
242
|
A hero is only built when there is something to build it from: a title, or a leading image large or logo-like enough to carry one. A document that opens with plain paragraphs simply starts with its text.
|
|
158
243
|
|
|
159
|
-
Readers stay in control. A three-position switch in the top bar moves between the markdown text, a plain conventional rendering, and the designed page; with `editable`, the text can be changed there and is laid out afresh on switching back. A theme picker and light/dark switch sit beside it, each section has a `⋯` menu offering only the layouts its content can fill, and each chart can be switched between the forms its data supports. `controls={false}` hides all of these.
|
|
244
|
+
Readers stay in control. A three-position switch in the top bar moves between the markdown text, a plain conventional rendering, and the designed page; with `editable`, the text can be changed there and is laid out afresh on switching back. A theme picker — arranged by subject, with a switch for whether themes should match the content at all — and a light/dark switch sit beside it, each section has a `⋯` menu offering only the layouts its content can fill, and each chart can be switched between the forms its data supports. `controls={false}` hides all of these.
|
|
160
245
|
|
|
161
246
|
## How decisions are made
|
|
162
247
|
|
|
@@ -164,7 +249,7 @@ Readers stay in control. A three-position switch in the top bar moves between th
|
|
|
164
249
|
2. **Measure** — each image is loaded in the browser to learn its natural size. An image that cannot be measured is never promoted to a hero or full-screen layout.
|
|
165
250
|
3. **Plan** — `planDocument` turns tree + image sizes into a `DocumentPlan`. It is a pure function: the same input always gives the same plan, and every section records the `reason` for its layout.
|
|
166
251
|
4. **Judge** *(optional)* — `judgeDocument` asks the classifier a handful of questions and each answer is fed back into the plan as it lands:
|
|
167
|
-
- which theme suits the content — each option tells the classifier
|
|
252
|
+
- which theme suits the content — each option tells the classifier the subjects the theme is for;
|
|
168
253
|
- whether a table's numbers are measurements to compare or reference values to look up;
|
|
169
254
|
- whether rows are parts of a whole (donut) or independent (bar);
|
|
170
255
|
- whether the leading image is fit to be the hero.
|
|
@@ -181,14 +266,18 @@ There are 21 themes, each a complete look — light and dark colours, a typeface
|
|
|
181
266
|
|
|
182
267
|
`paper` · `ink` · `ocean` · `forest` · `sunset` · `violet` · `terminal` · `chambers` · `academia` · `blueprint` · `midnight` · `rose` · `sand` · `citrus` · `crimson` · `slate` · `lagoon` · `plum` · `poster` · `espresso` · `console`
|
|
183
268
|
|
|
184
|
-
|
|
269
|
+
The themes are meant to look unlike one another. Pages are coloured rather than tinted — a sunny yellow, an aqua, a blueprint blue, a wine red at night — with cards a clear step lighter or darker than the page. And a hero led by its text opens one of three ways, set per theme as `hero`: `wash` (a soft glow of the accent on the page), `block` (a cover in the accent colour) or `ink` (a cover in the theme's darkest tone, the page's colours reversed). A hero with a photograph or a logo keeps the page as it is.
|
|
270
|
+
|
|
271
|
+
About half the themes also carry a second colour (`highlight`) — amber beside corporate blue, lime beside purple, coral beside teal. It runs through the page in small things, so that it never turns up just once: a stripe under the cover, a short rule over every section heading, the reading progress in the bar. Quotations are set in it — a band across the page for one that is a section of its own, a block for one inside an article. Themes without a second colour reverse into ink for those instead.
|
|
272
|
+
|
|
273
|
+
There are also 14 typeface pairings (`editorial`, `modern`, `technical`, `elegant`, `friendly`, `classic`, `scholarly`, `geometric`, `luxe`, `rounded`, `gazette`, `slab`, `poster`, `mono`), loaded from Google Fonts.
|
|
185
274
|
|
|
186
275
|
Charts follow the theme too:
|
|
187
276
|
|
|
188
277
|
- **Colours** — each theme's series colours are grown from its accent. The accent leads; every further colour is the candidate that stays furthest from those before it, at the accent's own intensity, so a muted theme gets muted charts and a vivid one vivid charts. Every palette must keep neighbouring series apart for readers with red-green colour-vision deficiency as well as full colour vision, sit inside a legible lightness band, and hold 3:1 contrast against the page — a unit test enforces this for all 21 themes in both modes.
|
|
189
278
|
- **Drawing style** — each theme names one of four chart styles: `linework` (monochrome ink with dashes and hatching, monospaced labels), `instrument` (rounded, saturated marks on a dotted grid), `soft` (gradients and depth) or `flat` (plain solid colour).
|
|
190
279
|
|
|
191
|
-
Themes are defined in `src/stunning-md/theme/themes.ts
|
|
280
|
+
Themes are defined in `src/stunning-md/theme/themes.ts`, and fall into six families by subject — writing, business and official, technology, places and lifestyle, nature and health, culture and play — which is how the picker arranges them. A theme's `description`, the subjects it suits, is all the classifier reads when choosing, so adding a theme means adding one entry there. Name subjects, not moods or colours, and keep it short: on a set of documents of known subject, subjects alone picked a fitting theme more often than subjects with colours and typefaces beside them, in half the time — classifier latency grows with the total length of these strings.
|
|
192
281
|
|
|
193
282
|
Themes are applied as CSS variables scoped to the component, so the page around it is unaffected.
|
|
194
283
|
|
|
@@ -204,9 +293,11 @@ cp .env.example .env.local # optional: add a classifier address and key
|
|
|
204
293
|
npm run dev
|
|
205
294
|
```
|
|
206
295
|
|
|
207
|
-
Samples live in `public/samples/` and open directly with `?sample=annual-report`, `kyoto`, `readme`, `essay`, `after-dark` or `roastery`.
|
|
296
|
+
Samples live in `public/samples/` and open directly with `?sample=annual-report`, `kyoto`, `readme`, `essay`, `after-dark` or `roastery`. Add `&stream` — `?sample=roastery&stream` — to have the sample played out a little at a time, as a model would write it, and see the `streaming` prop at work.
|
|
297
|
+
|
|
298
|
+
To try the chat, add `STUNNING_MD_CHAT_URL` and `STUNNING_MD_CHAT_MODEL` (and `STUNNING_MD_CHAT_KEY` if the provider needs one) to `.env.local`. Without a model to hand, `node scripts/mock-chat.mjs` runs a stand-in that streams canned answers at `http://localhost:3490/v1/chat/completions`. With chat available, the landing page also offers to start from a blank page.
|
|
208
299
|
|
|
209
|
-
If the server has no classifier configured, the landing page offers a form for the visitor's own endpoint and key. Those are checked with one test question, kept only in that browser's `localStorage`, and sent straight from the browser to the endpoint — never through this site's server. The endpoint therefore has to allow cross-origin requests.
|
|
300
|
+
If the server has no classifier or chat model configured, the landing page offers a form for the visitor's own — an endpoint and key for the classifier; an address, model name and optional key for chat. Those are checked with one test question, kept only in that browser's `localStorage`, and sent straight from the browser to the endpoint — never through this site's server. The endpoint therefore has to allow cross-origin requests.
|
|
210
301
|
|
|
211
302
|
### Hosting it on GitHub Pages
|
|
212
303
|
|
|
@@ -225,6 +316,7 @@ src/stunning-md/ the library
|
|
|
225
316
|
parse.ts markdown → mdast, HTML clean-up, frontmatter
|
|
226
317
|
analyze/ layout planning, table inference, image probing
|
|
227
318
|
classifier.ts questions, client, applying answers
|
|
319
|
+
chat.ts chat client, and sorting a reply into commentary and content
|
|
228
320
|
theme/ palettes, typefaces, chart colours and styles
|
|
229
321
|
components/ React rendering
|
|
230
322
|
stunning.css typography and layouts, scoped to .smd
|
|
@@ -236,7 +328,7 @@ scripts/ browser checks used during development
|
|
|
236
328
|
```
|
|
237
329
|
|
|
238
330
|
```bash
|
|
239
|
-
npm test # parsing, planning, table inference, classifier logic, theme and chart colours
|
|
331
|
+
npm test # parsing, planning, table inference, classifier logic, chat and streaming, theme and chart colours
|
|
240
332
|
npm run typecheck
|
|
241
333
|
npm run lint
|
|
242
334
|
npm run build # the demo site
|
|
@@ -256,7 +348,7 @@ npm publish # runs typecheck, tests and build:lib first
|
|
|
256
348
|
git push --follow-tags
|
|
257
349
|
```
|
|
258
350
|
|
|
259
|
-
`npm pack --dry-run` shows exactly what would be published: `dist/`, this README, the licence and `package.json`.
|
|
351
|
+
`npm pack --dry-run` shows exactly what would be published: `dist/`, this README, the licence and `package.json`. What changed in each version is in [CHANGELOG.md](CHANGELOG.md).
|
|
260
352
|
|
|
261
353
|
## Built with
|
|
262
354
|
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// src/stunning-md/chat.ts
|
|
2
|
+
function createChat(options) {
|
|
3
|
+
return async function* (messages, signal) {
|
|
4
|
+
const response = await fetch(options.endpoint, {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: { "content-type": "application/json", accept: "text/event-stream", ...options.headers },
|
|
7
|
+
body: JSON.stringify({ ...options.model ? { model: options.model } : {}, messages, stream: true }),
|
|
8
|
+
signal
|
|
9
|
+
});
|
|
10
|
+
if (!response.ok || !response.body) {
|
|
11
|
+
const detail = await response.text().catch(() => "");
|
|
12
|
+
throw new Error(`chat responded ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
|
|
13
|
+
}
|
|
14
|
+
if ((response.headers.get("content-type") ?? "").includes("application/json")) {
|
|
15
|
+
const data = await response.json();
|
|
16
|
+
const failure = errorIn(data);
|
|
17
|
+
if (failure) throw new Error(failure);
|
|
18
|
+
const whole = data?.choices?.[0]?.message?.content;
|
|
19
|
+
if (typeof whole === "string") yield whole;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const reader = response.body.getReader();
|
|
23
|
+
const decoder = new TextDecoder();
|
|
24
|
+
let buffer = "";
|
|
25
|
+
let stray = "";
|
|
26
|
+
let yielded = false;
|
|
27
|
+
for (; ; ) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
|
30
|
+
const lines = buffer.split("\n");
|
|
31
|
+
buffer = done ? "" : lines.pop() ?? "";
|
|
32
|
+
for (const raw of lines) {
|
|
33
|
+
const line = raw.trim();
|
|
34
|
+
if (!line.startsWith("data:")) {
|
|
35
|
+
if (line && !line.startsWith(":") && stray.length < 4e3) stray += raw;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const payload = line.slice(5).trim();
|
|
39
|
+
if (payload === "[DONE]") return;
|
|
40
|
+
let event;
|
|
41
|
+
try {
|
|
42
|
+
event = JSON.parse(payload);
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const failure = errorIn(event);
|
|
47
|
+
if (failure) throw new Error(failure);
|
|
48
|
+
const delta = event?.choices?.[0]?.delta?.content;
|
|
49
|
+
if (typeof delta === "string" && delta) {
|
|
50
|
+
yielded = true;
|
|
51
|
+
yield delta;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (done) break;
|
|
55
|
+
}
|
|
56
|
+
if (!yielded && stray.trim()) {
|
|
57
|
+
let failure = null;
|
|
58
|
+
try {
|
|
59
|
+
failure = errorIn(JSON.parse(stray));
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
throw new Error(failure ?? "the chat model returned nothing");
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function errorIn(body) {
|
|
67
|
+
const first = Array.isArray(body) ? body[0] : body;
|
|
68
|
+
const error = first?.error;
|
|
69
|
+
if (!error) return null;
|
|
70
|
+
if (typeof error === "string") return error;
|
|
71
|
+
const message = error.message;
|
|
72
|
+
return typeof message === "string" && message ? message : "the chat model reported an error";
|
|
73
|
+
}
|
|
74
|
+
function chatCompletionsUrl(url) {
|
|
75
|
+
const trimmed = url.trim().replace(/\/+$/, "");
|
|
76
|
+
return /\/chat\/completions$/.test(trimmed) ? trimmed : `${trimmed}/chat/completions`;
|
|
77
|
+
}
|
|
78
|
+
var HEADING = /^(#{1,6})\s+\S/;
|
|
79
|
+
var FENCE = /^\s{0,3}(```+|~~~+)/;
|
|
80
|
+
var NOT_PROSE = /^(\s{0,3}([-*+]|\d+[.)])\s|\s{0,3}>|\s{0,3}\||\s{0,3}(-{3,}|\*{3,}|_{3,})\s*$|\s{0,3}<|\s{2,}\S|!\[[^\]]*\]\([^)]*\)\s*$|\[!\[)/;
|
|
81
|
+
function describe(text) {
|
|
82
|
+
const first = text.split("\n")[0];
|
|
83
|
+
const heading = HEADING.exec(first);
|
|
84
|
+
if (heading) return { text, kind: "heading", depth: heading[1].length };
|
|
85
|
+
const table = /^\s{0,3}\|?\s*:?-{3,}/.test(text.split("\n")[1] ?? "");
|
|
86
|
+
const boldLine = /^\*\*[^*\n]+\*\*:?$/.test(text);
|
|
87
|
+
if (FENCE.test(first) || first.startsWith("$$") || NOT_PROSE.test(first) || table || boldLine) return { text, kind: "other" };
|
|
88
|
+
return { text, kind: "paragraph" };
|
|
89
|
+
}
|
|
90
|
+
var BlockSplitter = class {
|
|
91
|
+
constructor() {
|
|
92
|
+
this.pending = "";
|
|
93
|
+
this.lines = [];
|
|
94
|
+
this.fence = null;
|
|
95
|
+
this.math = false;
|
|
96
|
+
}
|
|
97
|
+
flush(out) {
|
|
98
|
+
const text = this.lines.join("\n").trim();
|
|
99
|
+
this.lines = [];
|
|
100
|
+
if (text) out.push(describe(text));
|
|
101
|
+
}
|
|
102
|
+
take(line, out) {
|
|
103
|
+
const fence = FENCE.exec(line);
|
|
104
|
+
if (this.fence) {
|
|
105
|
+
this.lines.push(line);
|
|
106
|
+
if (fence && fence[1].startsWith(this.fence[0]) && fence[1].length >= this.fence.length && !line.trim().slice(fence[1].length).trim()) {
|
|
107
|
+
this.fence = null;
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (this.math) {
|
|
112
|
+
this.lines.push(line);
|
|
113
|
+
if (line.trim().endsWith("$$")) this.math = false;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (fence) {
|
|
117
|
+
this.lines.push(line);
|
|
118
|
+
this.fence = fence[1];
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (line.trim().startsWith("$$") && this.lines.length === 0) {
|
|
122
|
+
this.lines.push(line);
|
|
123
|
+
this.math = !(line.trim().length > 2 && line.trim().endsWith("$$"));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!line.trim()) return this.flush(out);
|
|
127
|
+
if (HEADING.test(line)) {
|
|
128
|
+
this.flush(out);
|
|
129
|
+
this.lines.push(line);
|
|
130
|
+
return this.flush(out);
|
|
131
|
+
}
|
|
132
|
+
this.lines.push(line);
|
|
133
|
+
}
|
|
134
|
+
/** Feed more text; returns the blocks that are now complete. */
|
|
135
|
+
push(text) {
|
|
136
|
+
const out = [];
|
|
137
|
+
this.pending += text;
|
|
138
|
+
const lines = this.pending.split("\n");
|
|
139
|
+
this.pending = lines.pop() ?? "";
|
|
140
|
+
for (const line of lines) this.take(line.replace(/\r$/, ""), out);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/** The stream is over; returns whatever was still open. */
|
|
144
|
+
end() {
|
|
145
|
+
const out = [];
|
|
146
|
+
if (this.pending) this.take(this.pending, out);
|
|
147
|
+
this.pending = "";
|
|
148
|
+
this.fence = null;
|
|
149
|
+
this.math = false;
|
|
150
|
+
this.flush(out);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
var FRONTMATTER_OPEN = /^---[ \t]*\r?\n/;
|
|
155
|
+
function settledMarkdown(text) {
|
|
156
|
+
let start = 0;
|
|
157
|
+
if (FRONTMATTER_OPEN.test(text)) {
|
|
158
|
+
const opening = FRONTMATTER_OPEN.exec(text)[0].length;
|
|
159
|
+
if (text.length === opening) return { markdown: "", writing: null };
|
|
160
|
+
if (/\S/.test(text[opening])) {
|
|
161
|
+
const close = /\n(---|\.\.\.)[ \t]*\r?\n/.exec(text.slice(opening - 1));
|
|
162
|
+
if (!close) return { markdown: "", writing: null };
|
|
163
|
+
start = opening - 1 + close.index + close[0].length;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
let settled = start;
|
|
167
|
+
let writing = null;
|
|
168
|
+
let open = null;
|
|
169
|
+
let fence = null;
|
|
170
|
+
let math = false;
|
|
171
|
+
let inBlock = false;
|
|
172
|
+
for (let at = start; ; ) {
|
|
173
|
+
const end = text.indexOf("\n", at);
|
|
174
|
+
if (end < 0) break;
|
|
175
|
+
const line = text.slice(at, end).replace(/\r$/, "");
|
|
176
|
+
const lineStart = at;
|
|
177
|
+
at = end + 1;
|
|
178
|
+
const marker = FENCE.exec(line);
|
|
179
|
+
if (fence) {
|
|
180
|
+
if (marker && marker[1].startsWith(fence[0]) && marker[1].length >= fence.length && !line.trim().slice(marker[1].length).trim()) fence = null;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (math) {
|
|
184
|
+
if (line.trim().endsWith("$$")) math = false;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (marker) {
|
|
188
|
+
fence = marker[1];
|
|
189
|
+
inBlock = true;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (line.trim().startsWith("$$") && !inBlock) {
|
|
193
|
+
math = !(line.trim().length > 2 && line.trim().endsWith("$$"));
|
|
194
|
+
inBlock = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!line.trim()) {
|
|
198
|
+
if (open === null) settled = at;
|
|
199
|
+
else if (open === 1 && inBlock) {
|
|
200
|
+
settled = at;
|
|
201
|
+
open = null;
|
|
202
|
+
}
|
|
203
|
+
inBlock = false;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const heading = HEADING.exec(line);
|
|
207
|
+
if (heading) {
|
|
208
|
+
const depth = heading[1].length;
|
|
209
|
+
writing = line.replace(/^#{1,6}\s+/, "").replace(/\s+#+\s*$/, "");
|
|
210
|
+
if (!(open !== null && open !== 1 && depth > open)) {
|
|
211
|
+
settled = lineStart;
|
|
212
|
+
open = depth;
|
|
213
|
+
}
|
|
214
|
+
inBlock = false;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
inBlock = true;
|
|
218
|
+
}
|
|
219
|
+
return { markdown: text.slice(0, settled), writing };
|
|
220
|
+
}
|
|
221
|
+
var COMMENTARY_QUESTION = {
|
|
222
|
+
type: "noul",
|
|
223
|
+
criteria: {
|
|
224
|
+
true: "the assistant is addressing the user directly about its answer (for example 'Here is\u2026', 'Sure', 'Let me know\u2026', 'I have\u2026', 'Want me to\u2026')",
|
|
225
|
+
false: "a passage of the document itself, giving information to its readers"
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
var ADDRESSES_USER = /^(sure|certainly|of course|absolutely|great|okay|ok|alright|got it|no problem|here[’']?s|here (is|are)|below (is|are|you)|i[’'](ve|ll|d)\b|i (have|will|can|kept|made|added|drafted|wrote|put|used|assumed|left)\b|let me\b|hope (this|that)|want me to|would you like|do you want|should i\b|feel free|happy to|if you[’']?d like|if you would like|is there anything|anything else)|\blet me know\b|\bwant me to\b|\bwould you like me to\b/i;
|
|
229
|
+
var ANSWERS_QUESTION = {
|
|
230
|
+
type: "noul",
|
|
231
|
+
criteria: {
|
|
232
|
+
true: "the assistant knows the answer and is providing it",
|
|
233
|
+
false: "the assistant does not know the answer, cannot help, or asks a question back"
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
var NO_ANSWER = /^(sorry|apologies|unfortunately|i[’']?m (not sure|sorry|afraid|unable|not able)|i (don[’']?t|do not|can[’']?t|cannot|couldn[’']?t|am not able|am unable)\b|(could|can|would) you (please )?(clarify|tell|share|provide|say|explain|rephrase|give)|what (do you mean|would you like|exactly))/i;
|
|
237
|
+
var WANTS_CONTENT_QUESTION = {
|
|
238
|
+
type: "noul",
|
|
239
|
+
criteria: {
|
|
240
|
+
true: "the user asks for something to be written, explained, listed, compared or added to the page",
|
|
241
|
+
false: "the user is only making conversation: a greeting, thanks, small talk, feedback, or a question about the assistant itself"
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
var CONVERSATION_BELOW = 0.42;
|
|
245
|
+
var SMALL_TALK = /^(h+i+|he+y+|hello+|hiya|howdy|yo|good (morning|afternoon|evening|night)|thanks?( you)?|thx|ty|cheers|ok(ay)?|cool|nice( (work|one|job))?|great( (work|job))?|good (work|job)|well done|awesome|perfect|lovely|love it|(that |this |it )?looks? (good|nice|great)|lol|ha(ha)+|bye|goodbye|see you|never ?mind|how are you|how('?s| is) it going|what'?s up|who are you|what are you|what can you do|what (model|llm) are you|are you (there|real|a bot|an ai|ok)|can you hear me|(are )?you there|test(ing)?)\b(?:[\s,!.?]+(there|again|everyone|all|you|so much|a lot|very much|today|then|cool|nice|great|thanks?( you)?))*[\s!.?…]*$/i;
|
|
246
|
+
function wantsContent(request, classify, signal) {
|
|
247
|
+
const text = request.trim();
|
|
248
|
+
const byWording = !SMALL_TALK.test(text);
|
|
249
|
+
if (!classify) return Promise.resolve(byWording);
|
|
250
|
+
return classify({ state: `User: ${text.replace(/\s+/g, " ").slice(0, 400)}`, questions: { wants: WANTS_CONTENT_QUESTION } }, signal).then(
|
|
251
|
+
(result) => result.wants?.type === "noul" ? result.wants.noul >= CONVERSATION_BELOW : byWording,
|
|
252
|
+
() => byWording
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
var headingText = (block) => block.text.replace(/^#{1,6}\s+/, "").replace(/\s+#+\s*$/, "");
|
|
256
|
+
async function sortReply(options) {
|
|
257
|
+
const { stream, request, smallTalk, classify, signal, onEvent } = options;
|
|
258
|
+
const splitter = new BlockSplitter();
|
|
259
|
+
const released = [];
|
|
260
|
+
let raw = "";
|
|
261
|
+
let index = 0;
|
|
262
|
+
let lastHeading = null;
|
|
263
|
+
let structured = false;
|
|
264
|
+
let open = null;
|
|
265
|
+
let chain = Promise.resolve();
|
|
266
|
+
let settlePosition = null;
|
|
267
|
+
let unanswered = false;
|
|
268
|
+
const answers = async (text) => {
|
|
269
|
+
if (await smallTalk) return false;
|
|
270
|
+
if (!classify) return !NO_ANSWER.test(text);
|
|
271
|
+
const state = `${request ? `User: ${request.replace(/\s+/g, " ").slice(0, 400)}
|
|
272
|
+
` : ""}Assistant: ${text.slice(0, 700)}`;
|
|
273
|
+
return classify({ state, questions: { answers: ANSWERS_QUESTION } }, signal).then(
|
|
274
|
+
(result) => result.answers?.type === "noul" ? result.answers.noul >= 0.5 : !NO_ANSWER.test(text),
|
|
275
|
+
() => !NO_ANSWER.test(text)
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
const emitContent = () => onEvent({ type: "content", markdown: released.join("\n\n") });
|
|
279
|
+
const flush = () => {
|
|
280
|
+
if (!open) return;
|
|
281
|
+
released.push(...open.blocks);
|
|
282
|
+
open = null;
|
|
283
|
+
emitContent();
|
|
284
|
+
};
|
|
285
|
+
const ask = (text) => classify({ state: text.slice(0, 700), questions: { commentary: COMMENTARY_QUESTION } }, signal).then(
|
|
286
|
+
(answers2) => answers2.commentary?.type === "noul" ? answers2.commentary.noul : null,
|
|
287
|
+
() => null
|
|
288
|
+
);
|
|
289
|
+
const judge = async (text, first, position) => {
|
|
290
|
+
if (!classify) return first || position === "closing";
|
|
291
|
+
const cue = ADDRESSES_USER.test(text);
|
|
292
|
+
if (position === "inside") return cue ? (await ask(text) ?? 0) >= 0.5 : false;
|
|
293
|
+
if (cue) return true;
|
|
294
|
+
const score = await ask(text);
|
|
295
|
+
return score === null ? first || position === "closing" : score >= 0.3;
|
|
296
|
+
};
|
|
297
|
+
const isCommentary = (block, first) => {
|
|
298
|
+
if (block.kind !== "paragraph") return Promise.resolve(false);
|
|
299
|
+
if (first || classify && !structured) return judge(block.text, first, "opening");
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
settlePosition = resolve;
|
|
302
|
+
}).then((position) => judge(block.text, first, position));
|
|
303
|
+
};
|
|
304
|
+
const handle = (block, commentary) => {
|
|
305
|
+
if (unanswered) {
|
|
306
|
+
if (block.kind === "paragraph") return onEvent({ type: "commentary", text: block.text });
|
|
307
|
+
unanswered = false;
|
|
308
|
+
onEvent({ type: "answer", answered: true });
|
|
309
|
+
}
|
|
310
|
+
if (commentary) return onEvent({ type: "commentary", text: block.text });
|
|
311
|
+
if (block.kind === "heading") {
|
|
312
|
+
const depth = block.depth ?? 2;
|
|
313
|
+
if (open && open.depth !== 1 && depth > open.depth) {
|
|
314
|
+
open.blocks.push(block.text);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
flush();
|
|
318
|
+
open = { depth, blocks: [block.text] };
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (open) {
|
|
322
|
+
open.blocks.push(block.text);
|
|
323
|
+
if (open.depth === 1) flush();
|
|
324
|
+
} else {
|
|
325
|
+
released.push(block.text);
|
|
326
|
+
emitContent();
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const accept = (blocks) => {
|
|
330
|
+
for (const block of blocks) {
|
|
331
|
+
settlePosition?.("inside");
|
|
332
|
+
settlePosition = null;
|
|
333
|
+
const first = index++ === 0;
|
|
334
|
+
const verdict = isCommentary(block, first);
|
|
335
|
+
const answered = first && block.kind === "paragraph" ? answers(block.text) : null;
|
|
336
|
+
if (block.kind === "heading") lastHeading = headingText(block);
|
|
337
|
+
if (block.kind !== "paragraph") structured = true;
|
|
338
|
+
chain = chain.then(async () => {
|
|
339
|
+
if (answered && !await answered) {
|
|
340
|
+
unanswered = true;
|
|
341
|
+
onEvent({ type: "answer", answered: false });
|
|
342
|
+
}
|
|
343
|
+
handle(block, unanswered && block.kind === "paragraph" ? true : await verdict);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
let writing;
|
|
348
|
+
for await (const delta of stream) {
|
|
349
|
+
if (signal?.aborted) break;
|
|
350
|
+
raw += delta;
|
|
351
|
+
accept(splitter.push(delta));
|
|
352
|
+
if (lastHeading !== writing) onEvent({ type: "writing", heading: writing = lastHeading });
|
|
353
|
+
}
|
|
354
|
+
accept(splitter.end());
|
|
355
|
+
settlePosition?.("closing");
|
|
356
|
+
await chain;
|
|
357
|
+
flush();
|
|
358
|
+
return { raw, content: released.join("\n\n") };
|
|
359
|
+
}
|
|
360
|
+
var CHAT_INSTRUCTIONS = [
|
|
361
|
+
"You are writing for a page that turns markdown into a designed website.",
|
|
362
|
+
"Answer in markdown. When asked for a document, start with a `# Title`, add a short opening paragraph, and use `##` sections; use `###` for short sub-points.",
|
|
363
|
+
"Use tables for figures, schedules and comparisons, lists for short points, and blockquotes for quotations.",
|
|
364
|
+
"The page draws charts by itself: a markdown table of numbers becomes a bar, line or area chart, shares of a whole become a donut, a row of key figures becomes stat tiles, and dated events become a timeline. So when a chart or graph is wanted, just write the data as a plain markdown table \u2014 one row per category or period, a header row, units in the header or the cells \u2014 and never draw one in text, link an image of one, or write chart code.",
|
|
365
|
+
"Keep any remarks to the user \u2014 acknowledgements, caveats, questions, offers of more help \u2014 in their own short paragraphs, separate from the content."
|
|
366
|
+
].join(" ");
|
|
367
|
+
|
|
368
|
+
export {
|
|
369
|
+
createChat,
|
|
370
|
+
chatCompletionsUrl,
|
|
371
|
+
BlockSplitter,
|
|
372
|
+
settledMarkdown,
|
|
373
|
+
wantsContent,
|
|
374
|
+
sortReply,
|
|
375
|
+
CHAT_INSTRUCTIONS
|
|
376
|
+
};
|
|
377
|
+
//# sourceMappingURL=chunk-MKH2VK55.mjs.map
|