blume 1.4.0 → 1.4.1
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 +22 -0
- package/dist/cli/index.js +322 -644
- package/dist/cli/index.js.map +34 -34
- package/package.json +22 -1
- package/src/ai/component-markdown.ts +7 -6
- package/src/astro/generate.ts +4 -13
- package/src/astro/islands.ts +4 -1
- package/src/astro/templates.ts +3 -4
- package/src/audit/checks/indexability.ts +3 -6
- package/src/audit/checks/robots.ts +18 -37
- package/src/audit/crawl.ts +49 -49
- package/src/audit/image-size.ts +13 -53
- package/src/audit/report.ts +22 -33
- package/src/audit/types.ts +6 -2
- package/src/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/env.ts +6 -52
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- package/src/components/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- package/src/components/layout/RootLayout.astro +37 -109
- package/src/components/layout/Search.astro +3 -5
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/component-overrides.ts +0 -7
- package/src/core/config.ts +3 -3
- package/src/core/diagnostics.ts +10 -20
- package/src/core/fs-atomic.ts +22 -0
- package/src/core/sources/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/notion.ts +17 -23
- package/src/core/tsconfig-aliases.ts +39 -172
- package/src/deploy/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -1
- package/src/eval/report.ts +20 -28
- package/src/markdown/directives.ts +6 -18
- package/src/markdown/index.ts +1 -6
- package/src/markdown/package-commands.ts +0 -4
- package/src/openapi/parse.ts +11 -9
- package/src/search/popular-icon.ts +3 -3
- package/src/translate/ledger.ts +5 -11
- package/src/translate/report.ts +22 -28
- package/src/translate/run.ts +5 -24
- package/src/translate/work-list.ts +0 -0
- package/src/deploy/xml.ts +0 -8
package/src/cli/commands/dev.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { watch } from "node:fs";
|
|
2
|
-
|
|
3
1
|
import { dev } from "astro";
|
|
2
|
+
import { watch } from "chokidar";
|
|
4
3
|
import { defineCommand } from "citty";
|
|
5
|
-
import { basename, dirname } from "pathe";
|
|
6
4
|
|
|
7
5
|
import { generateRuntime } from "../../astro/generate.ts";
|
|
8
6
|
import { showBlumeErrorOverlay } from "../../astro/integration.ts";
|
|
@@ -212,26 +210,16 @@ export const devCommand = defineCommand({
|
|
|
212
210
|
project.context.componentsFile,
|
|
213
211
|
].filter((target) => target !== null);
|
|
214
212
|
|
|
213
|
+
// chokidar handles what raw fs.watch made us hand-roll: recursive
|
|
214
|
+
// directory watching on every platform, and single files surviving a
|
|
215
|
+
// rename-replace save (vim and most "atomic save" editors), which orphans
|
|
216
|
+
// an inode-tracking fs.watch watcher after the first write.
|
|
217
|
+
const projectWatcher = watch([...dirTargets, ...fileTargets], {
|
|
218
|
+
ignoreInitial: true,
|
|
219
|
+
}).on("all", regenerate);
|
|
215
220
|
const disposers = [
|
|
216
221
|
...project.sources.map((source) => source.watch?.(regenerate)),
|
|
217
|
-
|
|
218
|
-
const watcher = watch(target, { recursive: true }, regenerate);
|
|
219
|
-
return () => watcher.close();
|
|
220
|
-
}),
|
|
221
|
-
// Single files are watched via their parent directory: fs.watch on the
|
|
222
|
-
// file itself tracks the inode, so a rename-replace save (vim and most
|
|
223
|
-
// "atomic save" editors) orphans the watcher after the first write and
|
|
224
|
-
// every later edit is silently ignored.
|
|
225
|
-
...fileTargets.map((target) => {
|
|
226
|
-
const name = basename(target);
|
|
227
|
-
const watcher = watch(dirname(target), (_event, filename) => {
|
|
228
|
-
// A null filename (some platforms) can't be filtered — regenerate.
|
|
229
|
-
if (!filename || filename === name) {
|
|
230
|
-
regenerate();
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
return () => watcher.close();
|
|
234
|
-
}),
|
|
222
|
+
() => void projectWatcher.close(),
|
|
235
223
|
].filter((dispose) => dispose !== undefined);
|
|
236
224
|
|
|
237
225
|
const shutdown = async () => {
|
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { join } from "pathe";
|
|
5
|
+
import { satisfies } from "semver";
|
|
5
6
|
|
|
6
7
|
import { BlumeError } from "../../core/diagnostics.ts";
|
|
7
8
|
import { packageRoot } from "../../core/package-root.ts";
|
|
@@ -16,35 +17,21 @@ import {
|
|
|
16
17
|
reportDiagnosticsJson,
|
|
17
18
|
} from "../log.ts";
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
-
const LEADING_RANGE = /^[^\d]*/u;
|
|
20
|
+
const FALLBACK_NODE_RANGE = ">=22.12.0";
|
|
21
21
|
|
|
22
|
-
/** The
|
|
22
|
+
/** The supported Node range, read from the package's own `engines` field so
|
|
23
23
|
* doctor can never drift from what the package actually declares. */
|
|
24
|
-
const
|
|
24
|
+
const supportedNodeRange = (): string => {
|
|
25
25
|
try {
|
|
26
26
|
const pkg = JSON.parse(
|
|
27
27
|
readFileSync(join(packageRoot(), "package.json"), "utf-8")
|
|
28
28
|
) as { engines?: { node?: string } };
|
|
29
|
-
|
|
30
|
-
return range.replace(LEADING_RANGE, "") || FALLBACK_MIN_NODE;
|
|
29
|
+
return pkg.engines?.node || FALLBACK_NODE_RANGE;
|
|
31
30
|
} catch {
|
|
32
|
-
return
|
|
31
|
+
return FALLBACK_NODE_RANGE;
|
|
33
32
|
}
|
|
34
33
|
};
|
|
35
34
|
|
|
36
|
-
const versionBelow = (current: string, minimum: string): boolean => {
|
|
37
|
-
const a = current.split(".").map((part) => Math.trunc(Number(part)));
|
|
38
|
-
const b = minimum.split(".").map((part) => Math.trunc(Number(part)));
|
|
39
|
-
for (let i = 0; i < 3; i += 1) {
|
|
40
|
-
const delta = (a[i] ?? 0) - (b[i] ?? 0);
|
|
41
|
-
if (delta !== 0) {
|
|
42
|
-
return delta < 0;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return false;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
35
|
export const doctorCommand = defineCommand({
|
|
49
36
|
args: {
|
|
50
37
|
json: {
|
|
@@ -60,11 +47,11 @@ export const doctorCommand = defineCommand({
|
|
|
60
47
|
const root = process.cwd();
|
|
61
48
|
const diagnostics: Diagnostic[] = [];
|
|
62
49
|
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
50
|
+
const nodeRange = supportedNodeRange();
|
|
51
|
+
if (!satisfies(process.versions.node, nodeRange)) {
|
|
65
52
|
diagnostics.push({
|
|
66
53
|
code: "BLUME_NODE_VERSION",
|
|
67
|
-
message: `Node ${process.versions.node} is
|
|
54
|
+
message: `Node ${process.versions.node} is outside the supported range (${nodeRange}).`,
|
|
68
55
|
severity: "warning",
|
|
69
56
|
});
|
|
70
57
|
}
|
package/src/cli/env.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
|
+
import { parse } from "dotenv";
|
|
3
4
|
import { dirname, join, resolve } from "pathe";
|
|
4
5
|
|
|
5
6
|
// Blume's remote sources (GitHub Releases, mdx-remote, Sanity, Notion…) read
|
|
@@ -8,57 +9,6 @@ import { dirname, join, resolve } from "pathe";
|
|
|
8
9
|
// that gap: it cascades `.env`/`.env.local` from the working dir up to the repo
|
|
9
10
|
// root, so a monorepo can keep one `.env` at the root and every app picks it up.
|
|
10
11
|
|
|
11
|
-
const ENV_LINE =
|
|
12
|
-
/^\s*(?:export\s+)?(?<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?<value>.*?)\s*$/u;
|
|
13
|
-
const DOUBLE_QUOTED = /^"(?<body>[\s\S]*)"$/u;
|
|
14
|
-
const SINGLE_QUOTED = /^'(?<body>[\s\S]*)'$/u;
|
|
15
|
-
|
|
16
|
-
const ESCAPE = /\\(?<char>[\\nt"])/gu;
|
|
17
|
-
const UNESCAPED: Record<string, string> = {
|
|
18
|
-
'"': '"',
|
|
19
|
-
"\\": "\\",
|
|
20
|
-
n: "\n",
|
|
21
|
-
t: "\t",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
/** Unquote a value, expanding `\n`/`\t`/escapes inside double quotes only. */
|
|
25
|
-
const unquote = (raw: string): string => {
|
|
26
|
-
const double = raw.match(DOUBLE_QUOTED)?.groups?.body;
|
|
27
|
-
if (double !== undefined) {
|
|
28
|
-
// A single pass so each backslash is consumed exactly once — sequential
|
|
29
|
-
// replaceAll calls would expand the `n` in `\\n` (an escaped backslash
|
|
30
|
-
// followed by a literal `n`) into a newline.
|
|
31
|
-
return double.replaceAll(
|
|
32
|
-
ESCAPE,
|
|
33
|
-
(match, char: string) => UNESCAPED[char] ?? match
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
const single = raw.match(SINGLE_QUOTED)?.groups?.body;
|
|
37
|
-
if (single !== undefined) {
|
|
38
|
-
return single;
|
|
39
|
-
}
|
|
40
|
-
// dotenv/Vite treat an unquoted `#` as the start of an inline comment (a
|
|
41
|
-
// value containing `#` must be quoted) — keeping the comment would hand
|
|
42
|
-
// consumers a silently corrupted value.
|
|
43
|
-
const hash = raw.indexOf("#");
|
|
44
|
-
return (hash === -1 ? raw : raw.slice(0, hash)).trim();
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/** Parse `.env` text into key/value pairs, skipping blanks and `#` comments. */
|
|
48
|
-
export const parseEnv = (content: string): Record<string, string> => {
|
|
49
|
-
const env: Record<string, string> = {};
|
|
50
|
-
for (const line of content.split(/\r?\n/u)) {
|
|
51
|
-
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
const groups = line.match(ENV_LINE)?.groups;
|
|
55
|
-
if (groups?.key !== undefined && groups.value !== undefined) {
|
|
56
|
-
env[groups.key] = unquote(groups.value);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return env;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
12
|
/** Apply parsed vars without clobbering anything already in `process.env`. */
|
|
63
13
|
const applyEnv = (parsed: Record<string, string>): void => {
|
|
64
14
|
for (const [key, value] of Object.entries(parsed)) {
|
|
@@ -71,7 +21,11 @@ const applyEnv = (parsed: Record<string, string>): void => {
|
|
|
71
21
|
const loadFile = (path: string): void => {
|
|
72
22
|
try {
|
|
73
23
|
if (existsSync(path)) {
|
|
74
|
-
|
|
24
|
+
// dotenv is the same parser Vite runs over these files at build time,
|
|
25
|
+
// so a value means the same thing to the pre-boot content scan and the
|
|
26
|
+
// built site — including multi-line double-quoted values (PEM keys),
|
|
27
|
+
// which a line-based parser silently truncates.
|
|
28
|
+
applyEnv(parse(readFileSync(path, "utf-8")));
|
|
75
29
|
}
|
|
76
30
|
} catch {
|
|
77
31
|
// Env files are best-effort; a read/parse failure must not abort a build.
|
package/src/cli/init/scaffold.ts
CHANGED
|
@@ -195,28 +195,29 @@ const hasRemoteSource = (sources: SourceKind[]): boolean =>
|
|
|
195
195
|
* Config snippets for each remote source kind, with placeholder values to
|
|
196
196
|
* replace and comments naming the env var each source authenticates with.
|
|
197
197
|
*/
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
case "github-releases": {
|
|
201
|
-
return ` // Changelog entries from GitHub Releases. Private repos read
|
|
198
|
+
const SOURCE_SNIPPETS: Record<Exclude<SourceKind, "filesystem">, string> = {
|
|
199
|
+
"github-releases": ` // Changelog entries from GitHub Releases. Private repos read
|
|
202
200
|
// GITHUB_TOKEN from the environment.
|
|
203
201
|
{
|
|
204
202
|
type: "github-releases",
|
|
205
203
|
owner: "your-org",
|
|
206
204
|
repo: "your-repo",
|
|
207
205
|
prefix: "changelog",
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
206
|
+
},`,
|
|
207
|
+
"mdx-remote": ` // MDX fetched from a GitHub repo. Private repos read GITHUB_TOKEN
|
|
208
|
+
// from the environment.
|
|
209
|
+
{
|
|
210
|
+
type: "mdx-remote",
|
|
211
|
+
github: { owner: "your-org", repo: "your-repo", path: "docs" },
|
|
212
|
+
prefix: "remote",
|
|
213
|
+
},`,
|
|
214
|
+
notion: ` // Pages from a Notion database. Reads NOTION_TOKEN from the environment.
|
|
212
215
|
{
|
|
213
216
|
type: "notion",
|
|
214
217
|
database: "your-database-id",
|
|
215
218
|
prefix: "notion",
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
case "sanity": {
|
|
219
|
-
return ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
|
|
219
|
+
},`,
|
|
220
|
+
sanity: ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
|
|
220
221
|
// from the environment.
|
|
221
222
|
{
|
|
222
223
|
type: "sanity",
|
|
@@ -224,21 +225,7 @@ const sourceSnippetFor = (kind: Exclude<SourceKind, "filesystem">): string => {
|
|
|
224
225
|
dataset: "production",
|
|
225
226
|
query: \`*[_type == "doc"]\`,
|
|
226
227
|
prefix: "sanity",
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
case "mdx-remote": {
|
|
230
|
-
return ` // MDX fetched from a GitHub repo. Private repos read GITHUB_TOKEN
|
|
231
|
-
// from the environment.
|
|
232
|
-
{
|
|
233
|
-
type: "mdx-remote",
|
|
234
|
-
github: { owner: "your-org", repo: "your-repo", path: "docs" },
|
|
235
|
-
prefix: "remote",
|
|
236
|
-
},`;
|
|
237
|
-
}
|
|
238
|
-
default: {
|
|
239
|
-
return kind satisfies never;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
228
|
+
},`,
|
|
242
229
|
};
|
|
243
230
|
|
|
244
231
|
/**
|
|
@@ -263,7 +250,7 @@ const contentBlockFor = (answers: InitAnswers): string => {
|
|
|
263
250
|
(kind) =>
|
|
264
251
|
kind === "filesystem"
|
|
265
252
|
? ` { type: "filesystem", root: ${JSON.stringify(answers.contentDir)} },`
|
|
266
|
-
:
|
|
253
|
+
: SOURCE_SNIPPETS[kind]
|
|
267
254
|
);
|
|
268
255
|
return `
|
|
269
256
|
content: {
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { colors } from "consola/utils";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
const DIM = `${ESC}[2m`;
|
|
5
|
-
const RED = `${ESC}[31m`;
|
|
6
|
-
const BOLD = `${ESC}[1m`;
|
|
7
|
-
const RESET = `${ESC}[0m`;
|
|
3
|
+
import { getBlumeVersion } from "../core/version.ts";
|
|
8
4
|
|
|
9
5
|
const ISSUES_URL = "https://github.com/haydenbleasel/blume/issues";
|
|
10
6
|
|
|
@@ -40,7 +36,7 @@ export const remapBlumeStack = (stack: string): string =>
|
|
|
40
36
|
export const reportInternalError = (error: unknown): void => {
|
|
41
37
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
42
38
|
const lines = [
|
|
43
|
-
`${
|
|
39
|
+
`${colors.red(colors.bold("BLUME_INTERNAL"))} An unexpected error occurred.`,
|
|
44
40
|
` ${err.message}`,
|
|
45
41
|
];
|
|
46
42
|
|
|
@@ -52,15 +48,19 @@ export const reportInternalError = (error: unknown): void => {
|
|
|
52
48
|
.map((line) => line.trim())
|
|
53
49
|
.filter(Boolean);
|
|
54
50
|
if (stack.length > 0) {
|
|
55
|
-
lines.push("",
|
|
51
|
+
lines.push("", colors.dim(stack.join("\n")));
|
|
56
52
|
}
|
|
57
53
|
|
|
58
54
|
lines.push(
|
|
59
55
|
"",
|
|
60
56
|
"This is likely a bug in Blume. Please report it with the details below:",
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
colors.dim(
|
|
58
|
+
[
|
|
59
|
+
` Blume: ${getBlumeVersion()}`,
|
|
60
|
+
` Node: ${process.version}`,
|
|
61
|
+
` Platform: ${process.platform} ${process.arch}`,
|
|
62
|
+
].join("\n")
|
|
63
|
+
),
|
|
64
64
|
` ${ISSUES_URL}`
|
|
65
65
|
);
|
|
66
66
|
|
|
@@ -5,13 +5,8 @@ import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
|
5
5
|
import { createPortal } from "react-dom";
|
|
6
6
|
|
|
7
7
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
8
|
-
import { joinBase, prefixBase
|
|
9
|
-
|
|
10
|
-
interface ChatMessage {
|
|
11
|
-
content: string;
|
|
12
|
-
id: number;
|
|
13
|
-
role: "assistant" | "user";
|
|
14
|
-
}
|
|
8
|
+
import { joinBase, prefixBase } from "./base-path.ts";
|
|
9
|
+
import { useAskAI } from "./hooks.ts";
|
|
15
10
|
|
|
16
11
|
/** A resolved empty-state prompt; `icon` is ready-to-inline SVG (or null). */
|
|
17
12
|
interface Suggestion {
|
|
@@ -57,20 +52,10 @@ const DEFAULT_ASK: UIStrings["ask"] = {
|
|
|
57
52
|
you: "You",
|
|
58
53
|
};
|
|
59
54
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
idCounter += 1;
|
|
63
|
-
return idCounter;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// The endpoint and page path both honor the deployment `base` so grounding works
|
|
67
|
-
// under a non-root base path (the server matches base-less document routes).
|
|
55
|
+
// The endpoint honors the deployment `base` so grounding works under a
|
|
56
|
+
// non-root base path (the server matches base-less document routes).
|
|
68
57
|
const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
69
58
|
|
|
70
|
-
/** The current route with the deployment base stripped, for page-context lookup. */
|
|
71
|
-
const currentPath = (): string =>
|
|
72
|
-
stripBase(import.meta.env.BASE_URL, window.location.pathname);
|
|
73
|
-
|
|
74
59
|
// GitHub-flavored markdown with soft line breaks, matching how the docs read.
|
|
75
60
|
marked.setOptions({ breaks: true, gfm: true });
|
|
76
61
|
|
|
@@ -149,20 +134,20 @@ const AskAI = ({
|
|
|
149
134
|
const [mounted, setMounted] = useState(false);
|
|
150
135
|
const [open, setOpen] = useState(false);
|
|
151
136
|
const [input, setInput] = useState("");
|
|
152
|
-
|
|
153
|
-
|
|
137
|
+
// The streaming client — request shaping, optimistic assistant bubble,
|
|
138
|
+
// stale-stream/abort guards, error-body handling — is the public useAskAI
|
|
139
|
+
// hook, so the built-in panel and custom UIs share one implementation.
|
|
140
|
+
const {
|
|
141
|
+
ask,
|
|
142
|
+
loading: busy,
|
|
143
|
+
messages,
|
|
144
|
+
reset,
|
|
145
|
+
} = useAskAI({ endpoint, errorMessage: t.error });
|
|
154
146
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
155
147
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
156
148
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
157
149
|
// Where focus came from when the panel opened, restored on close.
|
|
158
150
|
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
159
|
-
// The stream writes into the conversation via functional updates, so "Clear
|
|
160
|
-
// conversation" mid-answer must revoke the in-flight stream's right to write
|
|
161
|
-
// — otherwise its next chunk re-appends the assistant bubble onto the
|
|
162
|
-
// emptied list as an orphaned answer. Clearing bumps the generation (stale
|
|
163
|
-
// streams stop writing) and aborts the request (the stream stops arriving).
|
|
164
|
-
const abortRef = useRef<AbortController | null>(null);
|
|
165
|
-
const generationRef = useRef(0);
|
|
166
151
|
|
|
167
152
|
// Portal target (document.body) only exists after mount; guards SSR. The
|
|
168
153
|
// one-time false→true flip is deliberate, so the initial `false` is required.
|
|
@@ -245,86 +230,22 @@ const AskAI = ({
|
|
|
245
230
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
246
231
|
}, [messages]);
|
|
247
232
|
|
|
248
|
-
const runQuestion =
|
|
233
|
+
const runQuestion = (raw: string) => {
|
|
249
234
|
const question = raw.trim();
|
|
250
235
|
if (!question || busy) {
|
|
251
236
|
return;
|
|
252
237
|
}
|
|
253
|
-
|
|
254
|
-
const userMessage: ChatMessage = {
|
|
255
|
-
content: question,
|
|
256
|
-
id: nextId(),
|
|
257
|
-
role: "user",
|
|
258
|
-
};
|
|
259
|
-
const history = [...messages, userMessage];
|
|
260
|
-
const assistant: ChatMessage = {
|
|
261
|
-
content: "",
|
|
262
|
-
id: nextId(),
|
|
263
|
-
role: "assistant",
|
|
264
|
-
};
|
|
265
|
-
setMessages([...history, assistant]);
|
|
238
|
+
void ask(question);
|
|
266
239
|
setInput("");
|
|
267
|
-
setBusy(true);
|
|
268
|
-
const generation = generationRef.current;
|
|
269
|
-
const controller = new AbortController();
|
|
270
|
-
abortRef.current = controller;
|
|
271
|
-
|
|
272
|
-
try {
|
|
273
|
-
const response = await fetch(endpoint, {
|
|
274
|
-
body: JSON.stringify({
|
|
275
|
-
messages: history.map((m) => ({ content: m.content, role: m.role })),
|
|
276
|
-
page: { path: currentPath() },
|
|
277
|
-
}),
|
|
278
|
-
headers: { "content-type": "application/json" },
|
|
279
|
-
method: "POST",
|
|
280
|
-
signal: controller.signal,
|
|
281
|
-
});
|
|
282
|
-
// A 4xx/5xx still has a body; without this guard its error text would be
|
|
283
|
-
// decoded and shown as the assistant's answer instead of the error notice.
|
|
284
|
-
if (!(response.ok && response.body)) {
|
|
285
|
-
throw new Error(`Ask AI request failed (${response.status}).`);
|
|
286
|
-
}
|
|
287
|
-
const reader = response.body.getReader();
|
|
288
|
-
const decoder = new TextDecoder();
|
|
289
|
-
let done = false;
|
|
290
|
-
while (!done) {
|
|
291
|
-
// oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential stream consumption; iterations are not independent
|
|
292
|
-
const chunk = await reader.read();
|
|
293
|
-
({ done } = chunk);
|
|
294
|
-
if (chunk.value) {
|
|
295
|
-
// Streaming mode: a multi-byte UTF-8 sequence split across chunks
|
|
296
|
-
// must not flush as U+FFFD garbage.
|
|
297
|
-
// oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
|
|
298
|
-
assistant.content += decoder.decode(chunk.value, { stream: true });
|
|
299
|
-
if (generationRef.current === generation) {
|
|
300
|
-
setMessages((current) => [
|
|
301
|
-
...current.slice(0, -1),
|
|
302
|
-
{ ...assistant },
|
|
303
|
-
]);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
} catch {
|
|
308
|
-
// A cleared (aborted) stream must not resurrect its bubble as an error.
|
|
309
|
-
if (generationRef.current === generation) {
|
|
310
|
-
// oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
|
|
311
|
-
assistant.content = t.error;
|
|
312
|
-
setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
|
|
313
|
-
}
|
|
314
|
-
} finally {
|
|
315
|
-
setBusy(false);
|
|
316
|
-
}
|
|
317
240
|
};
|
|
318
241
|
|
|
319
242
|
const clearConversation = () => {
|
|
320
|
-
|
|
321
|
-
abortRef.current?.abort();
|
|
322
|
-
setMessages([]);
|
|
243
|
+
reset();
|
|
323
244
|
};
|
|
324
245
|
|
|
325
246
|
const onSubmit = (event: FormEvent) => {
|
|
326
247
|
event.preventDefault();
|
|
327
|
-
|
|
248
|
+
runQuestion(input);
|
|
328
249
|
};
|
|
329
250
|
|
|
330
251
|
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
@@ -336,7 +257,7 @@ const AskAI = ({
|
|
|
336
257
|
!event.nativeEvent.isComposing
|
|
337
258
|
) {
|
|
338
259
|
event.preventDefault();
|
|
339
|
-
|
|
260
|
+
runQuestion(input);
|
|
340
261
|
}
|
|
341
262
|
};
|
|
342
263
|
|
|
@@ -398,16 +319,20 @@ const AskAI = ({
|
|
|
398
319
|
>
|
|
399
320
|
{hasMessages ? (
|
|
400
321
|
<div className="flex flex-col gap-4 p-4">
|
|
401
|
-
{
|
|
322
|
+
{/* Index keys are safe here: the list only appends, mutates its
|
|
323
|
+
last entry while streaming, or clears wholesale on reset. */}
|
|
324
|
+
{messages.map((message, index) =>
|
|
402
325
|
message.role === "user" ? (
|
|
403
326
|
<div
|
|
404
327
|
className="max-w-[85%] self-end whitespace-pre-wrap rounded-blume bg-muted px-3 py-2 text-foreground text-sm"
|
|
405
|
-
key
|
|
328
|
+
// oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
|
|
329
|
+
key={index}
|
|
406
330
|
>
|
|
407
331
|
{message.content}
|
|
408
332
|
</div>
|
|
409
333
|
) : (
|
|
410
|
-
|
|
334
|
+
// oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
|
|
335
|
+
<div className={ANSWER_CLASS} key={index}>
|
|
411
336
|
{message.content ? (
|
|
412
337
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized above
|
|
413
338
|
<div
|
|
@@ -144,6 +144,12 @@ const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
|
144
144
|
export interface UseAskAIOptions {
|
|
145
145
|
/** Existing Ask AI endpoint; defaults to Blume's generated `/api/ask`. */
|
|
146
146
|
endpoint?: string;
|
|
147
|
+
/**
|
|
148
|
+
* Shown as the assistant's answer when the request fails or throws.
|
|
149
|
+
* Defaults to an English notice; the built-in island passes its localized
|
|
150
|
+
* dictionary string.
|
|
151
|
+
*/
|
|
152
|
+
errorMessage?: string;
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
/** Shown as the assistant's answer when the request fails or throws. */
|
|
@@ -159,6 +165,7 @@ const currentPath = (): string =>
|
|
|
159
165
|
*/
|
|
160
166
|
export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
161
167
|
const endpoint = options.endpoint ?? DEFAULT_ASK_ENDPOINT;
|
|
168
|
+
const errorMessage = options.errorMessage ?? ASK_ERROR;
|
|
162
169
|
const [messages, setMessages] = useState<AskMessage[]>([]);
|
|
163
170
|
const [loading, setLoading] = useState(false);
|
|
164
171
|
// The stream writes into the conversation via state updates, so `reset()`
|
|
@@ -205,7 +212,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
205
212
|
// An error body (JSON, HTML error page) must not stream in as the
|
|
206
213
|
// assistant's answer.
|
|
207
214
|
if (live()) {
|
|
208
|
-
assistant.content =
|
|
215
|
+
assistant.content = errorMessage;
|
|
209
216
|
setMessages([...history, { ...assistant }]);
|
|
210
217
|
}
|
|
211
218
|
return;
|
|
@@ -236,7 +243,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
236
243
|
// pre-appended empty assistant message as a stuck placeholder. A
|
|
237
244
|
// reset's abort lands here too — the guard keeps it silent.
|
|
238
245
|
if (live()) {
|
|
239
|
-
assistant.content =
|
|
246
|
+
assistant.content = errorMessage;
|
|
240
247
|
setMessages([...history, { ...assistant }]);
|
|
241
248
|
}
|
|
242
249
|
} finally {
|
|
@@ -245,7 +252,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
245
252
|
}
|
|
246
253
|
}
|
|
247
254
|
},
|
|
248
|
-
[endpoint, loading, messages]
|
|
255
|
+
[endpoint, errorMessage, loading, messages]
|
|
249
256
|
);
|
|
250
257
|
|
|
251
258
|
// Retained for the compiler-off opt-out path (`react: { compiler: false }`):
|