@ai-gui/plugin-citation 0.4.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 +18 -0
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/index.cjs +246 -0
- package/dist/index.d.cts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +240 -0
- package/package.json +60 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# @ai-gui/plugin-citation
|
|
2
|
+
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add plugin authoring helpers, secure source citation blocks, revisioned artifacts, bounded declarative AI-generated UI trees, molecular structures, and interactive maps.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @ai-gui/core@0.4.0
|
|
13
|
+
|
|
14
|
+
## 0.3.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- Add a dependency-free `sources` fenced JSON plugin with strict validation, secure URL policy, synchronous framework-neutral rendering, prompt guidance, CSS, and serialization helpers.
|
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-citation
|
|
2
|
+
|
|
3
|
+
Dependency-free, framework-neutral source citations for AIGUI. The plugin claims a complete `sources` fenced JSON block, validates it strictly, and renders a safe element descriptor.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @ai-gui/core @ai-gui/plugin-citation
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { citation } from "@ai-gui/plugin-citation"
|
|
15
|
+
|
|
16
|
+
const plugins = [citation()]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The default URL policy permits HTTPS only:
|
|
20
|
+
|
|
21
|
+
````markdown
|
|
22
|
+
```sources
|
|
23
|
+
{
|
|
24
|
+
"sources": [
|
|
25
|
+
{
|
|
26
|
+
"id": "spec-1",
|
|
27
|
+
"title": "AIGUI specification",
|
|
28
|
+
"url": "https://example.com/spec",
|
|
29
|
+
"citedText": "Optional exact supporting text"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
````
|
|
35
|
+
|
|
36
|
+
Development HTTP origins can be allowlisted by host. A hostname permits any port; `host:port` permits only that exact port.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
citation({ allowedHttpHosts: ["localhost", "dev.example.test:4317"] })
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Security
|
|
43
|
+
|
|
44
|
+
- Closed-fence and renderer-level completion gating.
|
|
45
|
+
- Strict JSON schema with unknown-key rejection.
|
|
46
|
+
- Maximum 64 KiB UTF-8 input, 100 sources, and bounded field lengths.
|
|
47
|
+
- Unique IDs restricted to letters, numbers, underscores, and hyphens, starting with a letter.
|
|
48
|
+
- HTTPS-only by default; HTTP requires an exact allowlist match.
|
|
49
|
+
- URL credentials, relative URLs, model HTML, and model actions are not supported.
|
|
50
|
+
- Invalid input renders one generic fallback without reflecting source data or validation details.
|
|
51
|
+
- Output is synchronous and framework-neutral.
|
|
52
|
+
|
|
53
|
+
## Exports
|
|
54
|
+
|
|
55
|
+
- `citation(options?)`
|
|
56
|
+
- `citationPromptSpec()`
|
|
57
|
+
- `parseSourcesDefinition(source, options?)`
|
|
58
|
+
- `serializeSourcesFence(definition, options?)`
|
|
59
|
+
- `citationCss`
|
|
60
|
+
- `CitationOptions`, `CitationSource`, `SourcesDefinition`, and `SourcesParseResult`
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
const MAX_SOURCE_BYTES = 64 * 1024;
|
|
5
|
+
const MAX_SOURCES = 100;
|
|
6
|
+
const MAX_ID_LENGTH = 128;
|
|
7
|
+
const MAX_TITLE_LENGTH = 256;
|
|
8
|
+
const MAX_URL_LENGTH = 2048;
|
|
9
|
+
const MAX_CITED_TEXT_LENGTH = 4096;
|
|
10
|
+
const DEFINITION_KEYS = new Set(["sources"]);
|
|
11
|
+
const SOURCE_KEYS = new Set([
|
|
12
|
+
"id",
|
|
13
|
+
"title",
|
|
14
|
+
"url",
|
|
15
|
+
"citedText"
|
|
16
|
+
]);
|
|
17
|
+
const SAFE_ID = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
|
|
18
|
+
const citationCss = [
|
|
19
|
+
"[data-aigui-citations]{margin:1rem 0;padding:1rem;border:1px solid color-mix(in srgb,currentColor 18%,transparent);border-radius:.75rem}",
|
|
20
|
+
"[data-aigui-citations] h2{margin:0 0 .75rem;font-size:1rem}",
|
|
21
|
+
"[data-aigui-citations] ol{margin:0;padding-inline-start:1.5rem}",
|
|
22
|
+
"[data-aigui-citations] li+li{margin-top:.75rem}",
|
|
23
|
+
"[data-aigui-citations] blockquote{margin:.35rem 0 0;color:color-mix(in srgb,currentColor 72%,transparent)}",
|
|
24
|
+
"[data-aigui-citations-invalid]{margin:1rem 0;color:color-mix(in srgb,currentColor 72%,transparent)}"
|
|
25
|
+
].join("\n");
|
|
26
|
+
function citationPromptSpec() {
|
|
27
|
+
return [
|
|
28
|
+
"Sources (fenced JSON): ```sources {\"sources\":[{\"id\":\"safe-id\",\"title\":\"Source title\",\"url\":\"https://example.com/page\",\"citedText\":\"Optional exact supporting text\"}]} ```.",
|
|
29
|
+
"Use 1-100 sources. IDs must be unique and contain only letters, numbers, underscores, or hyphens, starting with a letter.",
|
|
30
|
+
"Use HTTPS URLs unless the application explicitly permits an HTTP host. Never emit HTML, actions, scripts, handlers, credentials, or extra fields."
|
|
31
|
+
].join("\n");
|
|
32
|
+
}
|
|
33
|
+
function citation(options = {}) {
|
|
34
|
+
const policy = normalizePolicy(options);
|
|
35
|
+
const render = (node) => {
|
|
36
|
+
if (node.complete !== true) return loadingOutput();
|
|
37
|
+
const parsed = parseWithPolicy(node.content ?? "", policy);
|
|
38
|
+
return parsed.valid ? renderSources(parsed.data) : invalidOutput();
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
name: "citation",
|
|
42
|
+
nodeRenderers: { sources: render },
|
|
43
|
+
css: citationCss,
|
|
44
|
+
promptSpec: citationPromptSpec()
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function parseSourcesDefinition(source, options = {}) {
|
|
48
|
+
return parseWithPolicy(source, normalizePolicy(options));
|
|
49
|
+
}
|
|
50
|
+
function serializeSourcesFence(definition, options = {}) {
|
|
51
|
+
let source;
|
|
52
|
+
try {
|
|
53
|
+
source = JSON.stringify(definition);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new TypeError("Sources definition must be JSON-serializable.");
|
|
56
|
+
}
|
|
57
|
+
if (source === void 0) throw new TypeError("Sources definition must be JSON-serializable.");
|
|
58
|
+
const parsed = parseSourcesDefinition(source, options);
|
|
59
|
+
if (!parsed.valid) throw new TypeError("Invalid sources definition.");
|
|
60
|
+
return `\`\`\`sources\n${JSON.stringify(parsed.data, null, 2)}\n\`\`\``;
|
|
61
|
+
}
|
|
62
|
+
function normalizePolicy(options) {
|
|
63
|
+
if (!isPlainObject(options)) throw new TypeError("Citation options must be an object.");
|
|
64
|
+
for (const key of Object.keys(options)) if (key !== "allowedHttpHosts") throw new TypeError(`Unknown citation option: ${key}`);
|
|
65
|
+
if (options.allowedHttpHosts !== void 0 && !Array.isArray(options.allowedHttpHosts)) throw new TypeError("allowedHttpHosts must be an array.");
|
|
66
|
+
const anyPortHosts = new Set();
|
|
67
|
+
const exactEndpoints = new Set();
|
|
68
|
+
for (const entry of options.allowedHttpHosts ?? []) {
|
|
69
|
+
if (typeof entry !== "string" || entry.length === 0 || entry.trim() !== entry || /[/?#@]/.test(entry)) throw new TypeError("Each allowed HTTP host must be a hostname or hostname:port.");
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = new URL(`http://${entry}`);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new TypeError("Each allowed HTTP host must be a hostname or hostname:port.");
|
|
75
|
+
}
|
|
76
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || !parsed.hostname) throw new TypeError("Each allowed HTTP host must be a hostname or hostname:port.");
|
|
77
|
+
const explicitPort = readExplicitPort(entry);
|
|
78
|
+
if (explicitPort) exactEndpoints.add(`${parsed.hostname.toLowerCase()}:${explicitPort}`);
|
|
79
|
+
else anyPortHosts.add(parsed.hostname.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
anyPortHosts,
|
|
83
|
+
exactEndpoints
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function parseWithPolicy(source, policy) {
|
|
87
|
+
if (utf8Length(source) > MAX_SOURCE_BYTES) return invalid("Sources definition is too large.");
|
|
88
|
+
let value;
|
|
89
|
+
try {
|
|
90
|
+
value = JSON.parse(source);
|
|
91
|
+
} catch {
|
|
92
|
+
return invalid("Sources definition must be valid JSON.");
|
|
93
|
+
}
|
|
94
|
+
if (!isPlainObject(value)) return invalid("Sources definition must be an object.");
|
|
95
|
+
const issues = [];
|
|
96
|
+
rejectUnknownKeys(value, DEFINITION_KEYS, "$", issues);
|
|
97
|
+
if (!Array.isArray(value.sources)) {
|
|
98
|
+
issues.push("$.sources must be an array.");
|
|
99
|
+
return {
|
|
100
|
+
valid: false,
|
|
101
|
+
issues
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (value.sources.length === 0 || value.sources.length > MAX_SOURCES) issues.push(`$.sources must contain between 1 and ${MAX_SOURCES} sources.`);
|
|
105
|
+
const ids = new Set();
|
|
106
|
+
const sources = [];
|
|
107
|
+
value.sources.forEach((candidate, index) => {
|
|
108
|
+
const path = `$.sources[${index}]`;
|
|
109
|
+
if (!isPlainObject(candidate)) {
|
|
110
|
+
issues.push(`${path} must be an object.`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
rejectUnknownKeys(candidate, SOURCE_KEYS, path, issues);
|
|
114
|
+
const id = readRequiredString(candidate.id, `${path}.id`, MAX_ID_LENGTH, issues);
|
|
115
|
+
const title = readRequiredString(candidate.title, `${path}.title`, MAX_TITLE_LENGTH, issues);
|
|
116
|
+
const rawUrl = readRequiredString(candidate.url, `${path}.url`, MAX_URL_LENGTH, issues);
|
|
117
|
+
const citedText = candidate.citedText === void 0 ? void 0 : readRequiredString(candidate.citedText, `${path}.citedText`, MAX_CITED_TEXT_LENGTH, issues);
|
|
118
|
+
if (id !== void 0) {
|
|
119
|
+
if (!SAFE_ID.test(id)) issues.push(`${path}.id is not a safe ID.`);
|
|
120
|
+
if (ids.has(id)) issues.push(`${path}.id must be unique.`);
|
|
121
|
+
ids.add(id);
|
|
122
|
+
}
|
|
123
|
+
const url = rawUrl === void 0 ? void 0 : normalizeUrl(rawUrl, path, policy, issues);
|
|
124
|
+
if (id !== void 0 && title !== void 0 && url !== void 0 && (candidate.citedText === void 0 || citedText !== void 0)) sources.push({
|
|
125
|
+
id,
|
|
126
|
+
title,
|
|
127
|
+
url,
|
|
128
|
+
...citedText === void 0 ? {} : { citedText }
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
return issues.length === 0 ? {
|
|
132
|
+
valid: true,
|
|
133
|
+
data: { sources }
|
|
134
|
+
} : {
|
|
135
|
+
valid: false,
|
|
136
|
+
issues
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function normalizeUrl(raw, path, policy, issues) {
|
|
140
|
+
let url;
|
|
141
|
+
try {
|
|
142
|
+
url = new URL(raw);
|
|
143
|
+
} catch {
|
|
144
|
+
issues.push(`${path}.url must be an absolute URL.`);
|
|
145
|
+
return void 0;
|
|
146
|
+
}
|
|
147
|
+
if (url.username || url.password) {
|
|
148
|
+
issues.push(`${path}.url must not contain credentials.`);
|
|
149
|
+
return void 0;
|
|
150
|
+
}
|
|
151
|
+
const protocol = url.protocol.toLowerCase();
|
|
152
|
+
const endpoint = `${url.hostname.toLowerCase()}:${url.port || (protocol === "http:" ? "80" : "443")}`;
|
|
153
|
+
const httpAllowed = protocol === "http:" && (policy.anyPortHosts.has(url.hostname.toLowerCase()) || policy.exactEndpoints.has(endpoint));
|
|
154
|
+
if (protocol !== "https:" && !httpAllowed) {
|
|
155
|
+
issues.push(`${path}.url must use HTTPS or an allowed HTTP host.`);
|
|
156
|
+
return void 0;
|
|
157
|
+
}
|
|
158
|
+
const normalized = url.toString();
|
|
159
|
+
if (normalized.length > MAX_URL_LENGTH) {
|
|
160
|
+
issues.push(`${path}.url must contain at most ${MAX_URL_LENGTH} characters.`);
|
|
161
|
+
return void 0;
|
|
162
|
+
}
|
|
163
|
+
return normalized;
|
|
164
|
+
}
|
|
165
|
+
function renderSources(definition) {
|
|
166
|
+
return element("section", {
|
|
167
|
+
"data-aigui-citations": "",
|
|
168
|
+
"aria-label": "Sources"
|
|
169
|
+
}, [element("h2", void 0, [text("Sources")]), element("ol", void 0, definition.sources.map((source) => element("li", { "data-source-id": source.id }, [element("a", {
|
|
170
|
+
href: source.url,
|
|
171
|
+
target: "_blank",
|
|
172
|
+
rel: "noopener noreferrer nofollow"
|
|
173
|
+
}, [text(source.title)]), ...source.citedText === void 0 ? [] : [element("blockquote", void 0, [text(source.citedText)])]])))]);
|
|
174
|
+
}
|
|
175
|
+
function loadingOutput() {
|
|
176
|
+
return element("div", {
|
|
177
|
+
"data-aigui-block-loading": "",
|
|
178
|
+
"data-block-type": "sources"
|
|
179
|
+
}, []);
|
|
180
|
+
}
|
|
181
|
+
function invalidOutput() {
|
|
182
|
+
return element("div", {
|
|
183
|
+
"data-aigui-citations-invalid": "",
|
|
184
|
+
role: "status"
|
|
185
|
+
}, [text("Sources unavailable.")]);
|
|
186
|
+
}
|
|
187
|
+
function element(tag, props, children) {
|
|
188
|
+
return {
|
|
189
|
+
kind: "element",
|
|
190
|
+
tag,
|
|
191
|
+
props,
|
|
192
|
+
children
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function text(value) {
|
|
196
|
+
return {
|
|
197
|
+
kind: "html",
|
|
198
|
+
html: escapeHtml(value)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function escapeHtml(value) {
|
|
202
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """).replace(/'/g, "'");
|
|
203
|
+
}
|
|
204
|
+
function readRequiredString(value, path, maxLength, issues) {
|
|
205
|
+
if (typeof value !== "string") {
|
|
206
|
+
issues.push(`${path} must be a string.`);
|
|
207
|
+
return void 0;
|
|
208
|
+
}
|
|
209
|
+
if (value.length === 0 || value.length > maxLength) {
|
|
210
|
+
issues.push(`${path} must contain between 1 and ${maxLength} characters.`);
|
|
211
|
+
return void 0;
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
function rejectUnknownKeys(value, allowed, path, issues) {
|
|
216
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) issues.push(`${path}.${key} is not supported.`);
|
|
217
|
+
}
|
|
218
|
+
function invalid(issue) {
|
|
219
|
+
return {
|
|
220
|
+
valid: false,
|
|
221
|
+
issues: [issue]
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function isPlainObject(value) {
|
|
225
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
226
|
+
const prototype = Object.getPrototypeOf(value);
|
|
227
|
+
return prototype === Object.prototype || prototype === null;
|
|
228
|
+
}
|
|
229
|
+
function utf8Length(value) {
|
|
230
|
+
return new TextEncoder().encode(value).byteLength;
|
|
231
|
+
}
|
|
232
|
+
function readExplicitPort(host) {
|
|
233
|
+
if (host.startsWith("[")) {
|
|
234
|
+
const match$1 = host.match(/^\[[^\]]+\]:(\d+)$/);
|
|
235
|
+
return match$1?.[1];
|
|
236
|
+
}
|
|
237
|
+
const match = host.match(/:(\d+)$/);
|
|
238
|
+
return match?.[1];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
//#endregion
|
|
242
|
+
exports.citation = citation
|
|
243
|
+
exports.citationCss = citationCss
|
|
244
|
+
exports.citationPromptSpec = citationPromptSpec
|
|
245
|
+
exports.parseSourcesDefinition = parseSourcesDefinition
|
|
246
|
+
exports.serializeSourcesFence = serializeSourcesFence
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { AIGuiPlugin } from "@ai-gui/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
interface CitationSource {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
citedText?: string;
|
|
9
|
+
}
|
|
10
|
+
interface SourcesDefinition {
|
|
11
|
+
sources: CitationSource[];
|
|
12
|
+
}
|
|
13
|
+
interface CitationOptions {
|
|
14
|
+
/** HTTP hosts allowed in addition to HTTPS. A hostname allows any port; host:port allows only that port. */
|
|
15
|
+
allowedHttpHosts?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
type SourcesParseResult = {
|
|
18
|
+
valid: true;
|
|
19
|
+
data: SourcesDefinition;
|
|
20
|
+
} | {
|
|
21
|
+
valid: false;
|
|
22
|
+
issues: string[];
|
|
23
|
+
};
|
|
24
|
+
declare const citationCss: string;
|
|
25
|
+
declare function citationPromptSpec(): string;
|
|
26
|
+
declare function citation(options?: CitationOptions): AIGuiPlugin;
|
|
27
|
+
declare function parseSourcesDefinition(source: string, options?: CitationOptions): SourcesParseResult;
|
|
28
|
+
declare function serializeSourcesFence(definition: unknown, options?: CitationOptions): string; //#endregion
|
|
29
|
+
export { CitationOptions, CitationSource, SourcesDefinition, SourcesParseResult, citation, citationCss, citationPromptSpec, parseSourcesDefinition, serializeSourcesFence };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { AIGuiPlugin } from "@ai-gui/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
interface CitationSource {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
citedText?: string;
|
|
9
|
+
}
|
|
10
|
+
interface SourcesDefinition {
|
|
11
|
+
sources: CitationSource[];
|
|
12
|
+
}
|
|
13
|
+
interface CitationOptions {
|
|
14
|
+
/** HTTP hosts allowed in addition to HTTPS. A hostname allows any port; host:port allows only that port. */
|
|
15
|
+
allowedHttpHosts?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
type SourcesParseResult = {
|
|
18
|
+
valid: true;
|
|
19
|
+
data: SourcesDefinition;
|
|
20
|
+
} | {
|
|
21
|
+
valid: false;
|
|
22
|
+
issues: string[];
|
|
23
|
+
};
|
|
24
|
+
declare const citationCss: string;
|
|
25
|
+
declare function citationPromptSpec(): string;
|
|
26
|
+
declare function citation(options?: CitationOptions): AIGuiPlugin;
|
|
27
|
+
declare function parseSourcesDefinition(source: string, options?: CitationOptions): SourcesParseResult;
|
|
28
|
+
declare function serializeSourcesFence(definition: unknown, options?: CitationOptions): string; //#endregion
|
|
29
|
+
export { CitationOptions, CitationSource, SourcesDefinition, SourcesParseResult, citation, citationCss, citationPromptSpec, parseSourcesDefinition, serializeSourcesFence };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
const MAX_SOURCE_BYTES = 64 * 1024;
|
|
3
|
+
const MAX_SOURCES = 100;
|
|
4
|
+
const MAX_ID_LENGTH = 128;
|
|
5
|
+
const MAX_TITLE_LENGTH = 256;
|
|
6
|
+
const MAX_URL_LENGTH = 2048;
|
|
7
|
+
const MAX_CITED_TEXT_LENGTH = 4096;
|
|
8
|
+
const DEFINITION_KEYS = new Set(["sources"]);
|
|
9
|
+
const SOURCE_KEYS = new Set([
|
|
10
|
+
"id",
|
|
11
|
+
"title",
|
|
12
|
+
"url",
|
|
13
|
+
"citedText"
|
|
14
|
+
]);
|
|
15
|
+
const SAFE_ID = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
|
|
16
|
+
const citationCss = [
|
|
17
|
+
"[data-aigui-citations]{margin:1rem 0;padding:1rem;border:1px solid color-mix(in srgb,currentColor 18%,transparent);border-radius:.75rem}",
|
|
18
|
+
"[data-aigui-citations] h2{margin:0 0 .75rem;font-size:1rem}",
|
|
19
|
+
"[data-aigui-citations] ol{margin:0;padding-inline-start:1.5rem}",
|
|
20
|
+
"[data-aigui-citations] li+li{margin-top:.75rem}",
|
|
21
|
+
"[data-aigui-citations] blockquote{margin:.35rem 0 0;color:color-mix(in srgb,currentColor 72%,transparent)}",
|
|
22
|
+
"[data-aigui-citations-invalid]{margin:1rem 0;color:color-mix(in srgb,currentColor 72%,transparent)}"
|
|
23
|
+
].join("\n");
|
|
24
|
+
function citationPromptSpec() {
|
|
25
|
+
return [
|
|
26
|
+
"Sources (fenced JSON): ```sources {\"sources\":[{\"id\":\"safe-id\",\"title\":\"Source title\",\"url\":\"https://example.com/page\",\"citedText\":\"Optional exact supporting text\"}]} ```.",
|
|
27
|
+
"Use 1-100 sources. IDs must be unique and contain only letters, numbers, underscores, or hyphens, starting with a letter.",
|
|
28
|
+
"Use HTTPS URLs unless the application explicitly permits an HTTP host. Never emit HTML, actions, scripts, handlers, credentials, or extra fields."
|
|
29
|
+
].join("\n");
|
|
30
|
+
}
|
|
31
|
+
function citation(options = {}) {
|
|
32
|
+
const policy = normalizePolicy(options);
|
|
33
|
+
const render = (node) => {
|
|
34
|
+
if (node.complete !== true) return loadingOutput();
|
|
35
|
+
const parsed = parseWithPolicy(node.content ?? "", policy);
|
|
36
|
+
return parsed.valid ? renderSources(parsed.data) : invalidOutput();
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
name: "citation",
|
|
40
|
+
nodeRenderers: { sources: render },
|
|
41
|
+
css: citationCss,
|
|
42
|
+
promptSpec: citationPromptSpec()
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function parseSourcesDefinition(source, options = {}) {
|
|
46
|
+
return parseWithPolicy(source, normalizePolicy(options));
|
|
47
|
+
}
|
|
48
|
+
function serializeSourcesFence(definition, options = {}) {
|
|
49
|
+
let source;
|
|
50
|
+
try {
|
|
51
|
+
source = JSON.stringify(definition);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new TypeError("Sources definition must be JSON-serializable.");
|
|
54
|
+
}
|
|
55
|
+
if (source === void 0) throw new TypeError("Sources definition must be JSON-serializable.");
|
|
56
|
+
const parsed = parseSourcesDefinition(source, options);
|
|
57
|
+
if (!parsed.valid) throw new TypeError("Invalid sources definition.");
|
|
58
|
+
return `\`\`\`sources\n${JSON.stringify(parsed.data, null, 2)}\n\`\`\``;
|
|
59
|
+
}
|
|
60
|
+
function normalizePolicy(options) {
|
|
61
|
+
if (!isPlainObject(options)) throw new TypeError("Citation options must be an object.");
|
|
62
|
+
for (const key of Object.keys(options)) if (key !== "allowedHttpHosts") throw new TypeError(`Unknown citation option: ${key}`);
|
|
63
|
+
if (options.allowedHttpHosts !== void 0 && !Array.isArray(options.allowedHttpHosts)) throw new TypeError("allowedHttpHosts must be an array.");
|
|
64
|
+
const anyPortHosts = new Set();
|
|
65
|
+
const exactEndpoints = new Set();
|
|
66
|
+
for (const entry of options.allowedHttpHosts ?? []) {
|
|
67
|
+
if (typeof entry !== "string" || entry.length === 0 || entry.trim() !== entry || /[/?#@]/.test(entry)) throw new TypeError("Each allowed HTTP host must be a hostname or hostname:port.");
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = new URL(`http://${entry}`);
|
|
71
|
+
} catch {
|
|
72
|
+
throw new TypeError("Each allowed HTTP host must be a hostname or hostname:port.");
|
|
73
|
+
}
|
|
74
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || !parsed.hostname) throw new TypeError("Each allowed HTTP host must be a hostname or hostname:port.");
|
|
75
|
+
const explicitPort = readExplicitPort(entry);
|
|
76
|
+
if (explicitPort) exactEndpoints.add(`${parsed.hostname.toLowerCase()}:${explicitPort}`);
|
|
77
|
+
else anyPortHosts.add(parsed.hostname.toLowerCase());
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
anyPortHosts,
|
|
81
|
+
exactEndpoints
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function parseWithPolicy(source, policy) {
|
|
85
|
+
if (utf8Length(source) > MAX_SOURCE_BYTES) return invalid("Sources definition is too large.");
|
|
86
|
+
let value;
|
|
87
|
+
try {
|
|
88
|
+
value = JSON.parse(source);
|
|
89
|
+
} catch {
|
|
90
|
+
return invalid("Sources definition must be valid JSON.");
|
|
91
|
+
}
|
|
92
|
+
if (!isPlainObject(value)) return invalid("Sources definition must be an object.");
|
|
93
|
+
const issues = [];
|
|
94
|
+
rejectUnknownKeys(value, DEFINITION_KEYS, "$", issues);
|
|
95
|
+
if (!Array.isArray(value.sources)) {
|
|
96
|
+
issues.push("$.sources must be an array.");
|
|
97
|
+
return {
|
|
98
|
+
valid: false,
|
|
99
|
+
issues
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (value.sources.length === 0 || value.sources.length > MAX_SOURCES) issues.push(`$.sources must contain between 1 and ${MAX_SOURCES} sources.`);
|
|
103
|
+
const ids = new Set();
|
|
104
|
+
const sources = [];
|
|
105
|
+
value.sources.forEach((candidate, index) => {
|
|
106
|
+
const path = `$.sources[${index}]`;
|
|
107
|
+
if (!isPlainObject(candidate)) {
|
|
108
|
+
issues.push(`${path} must be an object.`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
rejectUnknownKeys(candidate, SOURCE_KEYS, path, issues);
|
|
112
|
+
const id = readRequiredString(candidate.id, `${path}.id`, MAX_ID_LENGTH, issues);
|
|
113
|
+
const title = readRequiredString(candidate.title, `${path}.title`, MAX_TITLE_LENGTH, issues);
|
|
114
|
+
const rawUrl = readRequiredString(candidate.url, `${path}.url`, MAX_URL_LENGTH, issues);
|
|
115
|
+
const citedText = candidate.citedText === void 0 ? void 0 : readRequiredString(candidate.citedText, `${path}.citedText`, MAX_CITED_TEXT_LENGTH, issues);
|
|
116
|
+
if (id !== void 0) {
|
|
117
|
+
if (!SAFE_ID.test(id)) issues.push(`${path}.id is not a safe ID.`);
|
|
118
|
+
if (ids.has(id)) issues.push(`${path}.id must be unique.`);
|
|
119
|
+
ids.add(id);
|
|
120
|
+
}
|
|
121
|
+
const url = rawUrl === void 0 ? void 0 : normalizeUrl(rawUrl, path, policy, issues);
|
|
122
|
+
if (id !== void 0 && title !== void 0 && url !== void 0 && (candidate.citedText === void 0 || citedText !== void 0)) sources.push({
|
|
123
|
+
id,
|
|
124
|
+
title,
|
|
125
|
+
url,
|
|
126
|
+
...citedText === void 0 ? {} : { citedText }
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
return issues.length === 0 ? {
|
|
130
|
+
valid: true,
|
|
131
|
+
data: { sources }
|
|
132
|
+
} : {
|
|
133
|
+
valid: false,
|
|
134
|
+
issues
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function normalizeUrl(raw, path, policy, issues) {
|
|
138
|
+
let url;
|
|
139
|
+
try {
|
|
140
|
+
url = new URL(raw);
|
|
141
|
+
} catch {
|
|
142
|
+
issues.push(`${path}.url must be an absolute URL.`);
|
|
143
|
+
return void 0;
|
|
144
|
+
}
|
|
145
|
+
if (url.username || url.password) {
|
|
146
|
+
issues.push(`${path}.url must not contain credentials.`);
|
|
147
|
+
return void 0;
|
|
148
|
+
}
|
|
149
|
+
const protocol = url.protocol.toLowerCase();
|
|
150
|
+
const endpoint = `${url.hostname.toLowerCase()}:${url.port || (protocol === "http:" ? "80" : "443")}`;
|
|
151
|
+
const httpAllowed = protocol === "http:" && (policy.anyPortHosts.has(url.hostname.toLowerCase()) || policy.exactEndpoints.has(endpoint));
|
|
152
|
+
if (protocol !== "https:" && !httpAllowed) {
|
|
153
|
+
issues.push(`${path}.url must use HTTPS or an allowed HTTP host.`);
|
|
154
|
+
return void 0;
|
|
155
|
+
}
|
|
156
|
+
const normalized = url.toString();
|
|
157
|
+
if (normalized.length > MAX_URL_LENGTH) {
|
|
158
|
+
issues.push(`${path}.url must contain at most ${MAX_URL_LENGTH} characters.`);
|
|
159
|
+
return void 0;
|
|
160
|
+
}
|
|
161
|
+
return normalized;
|
|
162
|
+
}
|
|
163
|
+
function renderSources(definition) {
|
|
164
|
+
return element("section", {
|
|
165
|
+
"data-aigui-citations": "",
|
|
166
|
+
"aria-label": "Sources"
|
|
167
|
+
}, [element("h2", void 0, [text("Sources")]), element("ol", void 0, definition.sources.map((source) => element("li", { "data-source-id": source.id }, [element("a", {
|
|
168
|
+
href: source.url,
|
|
169
|
+
target: "_blank",
|
|
170
|
+
rel: "noopener noreferrer nofollow"
|
|
171
|
+
}, [text(source.title)]), ...source.citedText === void 0 ? [] : [element("blockquote", void 0, [text(source.citedText)])]])))]);
|
|
172
|
+
}
|
|
173
|
+
function loadingOutput() {
|
|
174
|
+
return element("div", {
|
|
175
|
+
"data-aigui-block-loading": "",
|
|
176
|
+
"data-block-type": "sources"
|
|
177
|
+
}, []);
|
|
178
|
+
}
|
|
179
|
+
function invalidOutput() {
|
|
180
|
+
return element("div", {
|
|
181
|
+
"data-aigui-citations-invalid": "",
|
|
182
|
+
role: "status"
|
|
183
|
+
}, [text("Sources unavailable.")]);
|
|
184
|
+
}
|
|
185
|
+
function element(tag, props, children) {
|
|
186
|
+
return {
|
|
187
|
+
kind: "element",
|
|
188
|
+
tag,
|
|
189
|
+
props,
|
|
190
|
+
children
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function text(value) {
|
|
194
|
+
return {
|
|
195
|
+
kind: "html",
|
|
196
|
+
html: escapeHtml(value)
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function escapeHtml(value) {
|
|
200
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """).replace(/'/g, "'");
|
|
201
|
+
}
|
|
202
|
+
function readRequiredString(value, path, maxLength, issues) {
|
|
203
|
+
if (typeof value !== "string") {
|
|
204
|
+
issues.push(`${path} must be a string.`);
|
|
205
|
+
return void 0;
|
|
206
|
+
}
|
|
207
|
+
if (value.length === 0 || value.length > maxLength) {
|
|
208
|
+
issues.push(`${path} must contain between 1 and ${maxLength} characters.`);
|
|
209
|
+
return void 0;
|
|
210
|
+
}
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
function rejectUnknownKeys(value, allowed, path, issues) {
|
|
214
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) issues.push(`${path}.${key} is not supported.`);
|
|
215
|
+
}
|
|
216
|
+
function invalid(issue) {
|
|
217
|
+
return {
|
|
218
|
+
valid: false,
|
|
219
|
+
issues: [issue]
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function isPlainObject(value) {
|
|
223
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
224
|
+
const prototype = Object.getPrototypeOf(value);
|
|
225
|
+
return prototype === Object.prototype || prototype === null;
|
|
226
|
+
}
|
|
227
|
+
function utf8Length(value) {
|
|
228
|
+
return new TextEncoder().encode(value).byteLength;
|
|
229
|
+
}
|
|
230
|
+
function readExplicitPort(host) {
|
|
231
|
+
if (host.startsWith("[")) {
|
|
232
|
+
const match$1 = host.match(/^\[[^\]]+\]:(\d+)$/);
|
|
233
|
+
return match$1?.[1];
|
|
234
|
+
}
|
|
235
|
+
const match = host.match(/:(\d+)$/);
|
|
236
|
+
return match?.[1];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
//#endregion
|
|
240
|
+
export { citation, citationCss, citationPromptSpec, parseSourcesDefinition, serializeSourcesFence };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ai-gui/plugin-citation",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Secure framework-neutral source citation fences for AIGUI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"llm",
|
|
7
|
+
"ai",
|
|
8
|
+
"citations",
|
|
9
|
+
"sources",
|
|
10
|
+
"markdown",
|
|
11
|
+
"plugin",
|
|
12
|
+
"aigui"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Liang Li <ll_faw@hotmail.com>",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/liliang-cn/aigui.git",
|
|
19
|
+
"directory": "packages/plugin-citation"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/liliang-cn/aigui#readme",
|
|
22
|
+
"bugs": "https://github.com/liliang-cn/aigui/issues",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.cjs",
|
|
29
|
+
"module": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"import": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"require": {
|
|
38
|
+
"types": "./dist/index.d.cts",
|
|
39
|
+
"default": "./dist/index.cjs"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"CHANGELOG.md"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@ai-gui/core": "0.4.0"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsdown",
|
|
57
|
+
"test": "pnpm --dir ../.. exec vitest run --project plugin-citation",
|
|
58
|
+
"typecheck": "tsc --noEmit"
|
|
59
|
+
}
|
|
60
|
+
}
|