@meistrari/chat-nuxt 3.17.0-rc.3 → 3.17.0-rc.5
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/dist/module.d.mts +7 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +2 -1
- package/dist/runtime/citations/components/chat-url-citation.vue +18 -3
- package/dist/runtime/citations/site-name.d.ts +5 -0
- package/dist/runtime/citations/site-name.js +36 -0
- package/dist/runtime/server/api/citation-metadata.get.d.ts +8 -0
- package/dist/runtime/server/api/citation-metadata.get.js +12 -0
- package/dist/runtime/server/utils/chat-api-proxy.js +4 -1
- package/dist/runtime/server/utils/citation-metadata.d.ts +16 -0
- package/dist/runtime/server/utils/citation-metadata.js +187 -0
- package/dist/runtime/types/schemas/chat/chat-config.d.ts +1 -0
- package/dist/runtime/types/schemas/chat/chat-config.js +1 -0
- package/package.json +3 -1
package/dist/module.d.mts
CHANGED
|
@@ -34,6 +34,13 @@ interface ChatNuxtModuleOptions {
|
|
|
34
34
|
* `NUXT_CHAT_NUXT_SECRETLESS_EXECUTION`.
|
|
35
35
|
*/
|
|
36
36
|
secretlessExecution?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Opt this deployment into the experimental agent-api v5 snapshot path: the
|
|
39
|
+
* server proxy adds `"agent-api-v5": true` to the `x-chat-config` header, so
|
|
40
|
+
* wake and execute prefer the published snapshot (with v4 fallback). Defaults
|
|
41
|
+
* to `false`; also settable via `NUXT_CHAT_NUXT_AGENT_API_V5`.
|
|
42
|
+
*/
|
|
43
|
+
agentApiV5?: boolean;
|
|
37
44
|
}
|
|
38
45
|
declare const _default: any;
|
|
39
46
|
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -451,7 +451,8 @@ const module$1 = defineNuxtModule({
|
|
|
451
451
|
authTokenCookie: hostAuthComposablePath ? options.authTokenCookie || existingChatNuxtRuntimeConfig?.authTokenCookie : void 0,
|
|
452
452
|
// Explicit boolean default keeps the key in runtime config so the
|
|
453
453
|
// NUXT_CHAT_NUXT_SECRETLESS_EXECUTION env override can apply.
|
|
454
|
-
secretlessExecution: options.secretlessExecution ?? existingChatNuxtRuntimeConfig?.secretlessExecution ?? false
|
|
454
|
+
secretlessExecution: options.secretlessExecution ?? existingChatNuxtRuntimeConfig?.secretlessExecution ?? false,
|
|
455
|
+
agentApiV5: options.agentApiV5 ?? existingChatNuxtRuntimeConfig?.agentApiV5 ?? false
|
|
455
456
|
};
|
|
456
457
|
if (hostAuthComposablePath && !rc.chatNuxt.authTokenCookie) {
|
|
457
458
|
console.warn(
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { loadCitationSiteName } from "../site-name";
|
|
2
3
|
const props = defineProps({
|
|
3
4
|
citation: { type: Object, required: true }
|
|
4
5
|
});
|
|
5
6
|
const faviconFailed = ref(false);
|
|
6
|
-
const
|
|
7
|
+
const siteName = ref(null);
|
|
8
|
+
const sourceLabel = computed(() => siteName.value || props.citation.sourceLabel);
|
|
9
|
+
onMounted(() => {
|
|
10
|
+
watch(() => props.citation.href, async (href, _previous, onCleanup) => {
|
|
11
|
+
let active = true;
|
|
12
|
+
onCleanup(() => {
|
|
13
|
+
active = false;
|
|
14
|
+
});
|
|
15
|
+
siteName.value = null;
|
|
16
|
+
const name = await loadCitationSiteName(href);
|
|
17
|
+
if (active)
|
|
18
|
+
siteName.value = name;
|
|
19
|
+
}, { immediate: true });
|
|
20
|
+
});
|
|
21
|
+
const accessibleLabel = computed(() => `${sourceLabel.value}: ${props.citation.title}`);
|
|
7
22
|
</script>
|
|
8
23
|
|
|
9
24
|
<template>
|
|
@@ -32,7 +47,7 @@ const accessibleLabel = computed(() => `${props.citation.sourceLabel}: ${props.c
|
|
|
32
47
|
@error="faviconFailed = true"
|
|
33
48
|
>
|
|
34
49
|
<span v-else i-ph-globe-simple class="url-citation-fallback" aria-hidden="true" />
|
|
35
|
-
<span>{{
|
|
50
|
+
<span>{{ sourceLabel }}</span>
|
|
36
51
|
</a>
|
|
37
52
|
|
|
38
53
|
<template #content>
|
|
@@ -48,7 +63,7 @@ const accessibleLabel = computed(() => `${props.citation.sourceLabel}: ${props.c
|
|
|
48
63
|
>
|
|
49
64
|
<span v-else i-ph-globe-simple class="url-citation-card__fallback" aria-hidden="true" />
|
|
50
65
|
<span body-12-medium text-secondary truncate>
|
|
51
|
-
{{
|
|
66
|
+
{{ sourceLabel }}
|
|
52
67
|
</span>
|
|
53
68
|
</div>
|
|
54
69
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function createCitationSiteNameLoader(fetchMetadata: (url: string) => Promise<{
|
|
2
|
+
siteName?: string | null;
|
|
3
|
+
retryable?: boolean;
|
|
4
|
+
}>, now?: () => number): (href: string) => Promise<string | null>;
|
|
5
|
+
export declare const loadCitationSiteName: (href: string) => Promise<string | null>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function createCitationSiteNameLoader(fetchMetadata, now = Date.now) {
|
|
2
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3
|
+
return (href) => {
|
|
4
|
+
let key;
|
|
5
|
+
try {
|
|
6
|
+
const url = new URL(href);
|
|
7
|
+
url.hash = "";
|
|
8
|
+
key = url.href;
|
|
9
|
+
} catch {
|
|
10
|
+
return Promise.resolve(null);
|
|
11
|
+
}
|
|
12
|
+
const cached = cache.get(key);
|
|
13
|
+
if (cached && cached.expires > now())
|
|
14
|
+
return cached.result;
|
|
15
|
+
const result = Promise.resolve().then(() => fetchMetadata(key)).then((value) => {
|
|
16
|
+
if (value.retryable)
|
|
17
|
+
throw new Error("Citation metadata temporarily unavailable");
|
|
18
|
+
return typeof value.siteName === "string" && value.siteName.trim() ? value.siteName.trim() : null;
|
|
19
|
+
}).catch(() => {
|
|
20
|
+
if (cache.get(key)?.result === result)
|
|
21
|
+
cache.delete(key);
|
|
22
|
+
return null;
|
|
23
|
+
});
|
|
24
|
+
cache.delete(key);
|
|
25
|
+
if (cache.size >= 512)
|
|
26
|
+
cache.delete(cache.keys().next().value);
|
|
27
|
+
cache.set(key, { expires: now() + 60 * 60 * 1e3, result });
|
|
28
|
+
return result;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export const loadCitationSiteName = createCitationSiteNameLoader(async (url) => {
|
|
32
|
+
const response = await fetch(`/api/citation-metadata?${new URLSearchParams({ url })}`);
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
throw new Error("Citation metadata unavailable");
|
|
35
|
+
return response.json();
|
|
36
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineEventHandler, getQuery, setResponseHeaders } from "h3";
|
|
2
|
+
import { createSiteNameCache } from "../utils/citation-metadata.js";
|
|
3
|
+
const getSiteName = createSiteNameCache();
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
setResponseHeaders(event, { "cache-control": "private, no-store" });
|
|
6
|
+
const url = getQuery(event).url;
|
|
7
|
+
try {
|
|
8
|
+
return { siteName: typeof url === "string" ? await getSiteName(url) : null };
|
|
9
|
+
} catch {
|
|
10
|
+
return { siteName: null, retryable: true };
|
|
11
|
+
}
|
|
12
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PREPARED_AGENT_RUNTIME_CONFIG_KEY, SECRETLESS_EXECUTION_CONFIG_KEY } from "../../types/schemas/chat/chat-config.js";
|
|
1
|
+
import { AGENT_API_V5_CONFIG_KEY, PREPARED_AGENT_RUNTIME_CONFIG_KEY, SECRETLESS_EXECUTION_CONFIG_KEY } from "../../types/schemas/chat/chat-config.js";
|
|
2
2
|
import { createError, defineEventHandler, getHeader, getRequestURL, getRouterParam, proxyRequest } from "h3";
|
|
3
3
|
import { useRuntimeConfig } from "nitropack/runtime";
|
|
4
4
|
import { resolveAuthToken } from "./auth-token.js";
|
|
@@ -64,6 +64,9 @@ function buildProxyHeaders(event) {
|
|
|
64
64
|
if (config.chatNuxt?.secretlessExecution) {
|
|
65
65
|
chatConfig[SECRETLESS_EXECUTION_CONFIG_KEY] = true;
|
|
66
66
|
}
|
|
67
|
+
if (config.chatNuxt?.agentApiV5) {
|
|
68
|
+
chatConfig[AGENT_API_V5_CONFIG_KEY] = true;
|
|
69
|
+
}
|
|
67
70
|
const headers = {
|
|
68
71
|
"authorization": `Bearer ${token}`,
|
|
69
72
|
"x-chat-client": "chat-nuxt",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const MAX_HTML_BYTES: number;
|
|
2
|
+
export declare function publicCitationUrl(value: unknown): URL;
|
|
3
|
+
export declare function isPublicAddress(address: string): boolean;
|
|
4
|
+
export declare function parseCitationSiteName(html: string): string | null;
|
|
5
|
+
interface Address {
|
|
6
|
+
address: string;
|
|
7
|
+
family: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function resolvePublicAddress(hostname: string, resolve: (host: string) => Promise<Address[]>): Promise<Address>;
|
|
10
|
+
declare function requestPage(url: URL, signal: AbortSignal): Promise<{
|
|
11
|
+
redirect?: string;
|
|
12
|
+
html?: string;
|
|
13
|
+
}>;
|
|
14
|
+
export declare function fetchCitationSiteName(input: string, request?: typeof requestPage): Promise<string | null>;
|
|
15
|
+
export declare function createSiteNameCache(load?: typeof fetchCitationSiteName, now?: () => number): (input: string) => Promise<string | null>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { Parser } from "htmlparser2";
|
|
2
|
+
import ipaddr from "ipaddr.js";
|
|
3
|
+
export const MAX_HTML_BYTES = 256 * 1024;
|
|
4
|
+
const TIMEOUT_MS = 5e3;
|
|
5
|
+
const MAX_REDIRECTS = 3;
|
|
6
|
+
class PermanentMetadataError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export function publicCitationUrl(value) {
|
|
9
|
+
if (typeof value !== "string" || value.length > 2048)
|
|
10
|
+
throw new PermanentMetadataError("Invalid citation URL");
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(value);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new PermanentMetadataError("Invalid citation URL");
|
|
16
|
+
}
|
|
17
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port)
|
|
18
|
+
throw new PermanentMetadataError("Invalid citation URL");
|
|
19
|
+
url.hash = "";
|
|
20
|
+
return url;
|
|
21
|
+
}
|
|
22
|
+
export function isPublicAddress(address) {
|
|
23
|
+
try {
|
|
24
|
+
return ipaddr.process(address.replace(/^\[|\]$/g, "")).range() === "unicast";
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function parseCitationSiteName(html) {
|
|
30
|
+
let siteName = null;
|
|
31
|
+
const parser = new Parser({
|
|
32
|
+
onopentag(name, attributes) {
|
|
33
|
+
if (name !== "meta" || siteName || (attributes.property || attributes.name || "").toLowerCase() !== "og:site_name")
|
|
34
|
+
return;
|
|
35
|
+
const value = (attributes.content || "").replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim();
|
|
36
|
+
if (value && value.length <= 100)
|
|
37
|
+
siteName = value;
|
|
38
|
+
}
|
|
39
|
+
}, { decodeEntities: true });
|
|
40
|
+
parser.end(html);
|
|
41
|
+
return siteName;
|
|
42
|
+
}
|
|
43
|
+
export async function resolvePublicAddress(hostname, resolve) {
|
|
44
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
45
|
+
const addresses = ipaddr.isValid(host) ? [{ address: host, family: ipaddr.parse(host).kind() === "ipv4" ? 4 : 6 }] : await resolve(host);
|
|
46
|
+
if (!addresses.length || addresses.some((record) => !isPublicAddress(record.address)))
|
|
47
|
+
throw new PermanentMetadataError("Non-public citation address");
|
|
48
|
+
return addresses[0];
|
|
49
|
+
}
|
|
50
|
+
async function requestPage(url, signal) {
|
|
51
|
+
const [{ lookup }, http, https, { Buffer }] = await Promise.all([
|
|
52
|
+
import("node:dns/promises"),
|
|
53
|
+
import("node:http"),
|
|
54
|
+
import("node:https"),
|
|
55
|
+
import("node:buffer")
|
|
56
|
+
]);
|
|
57
|
+
const address = await resolvePublicAddress(url.hostname, (host) => lookup(host, { all: true }));
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const request = (url.protocol === "https:" ? https.request : http.request)(url, {
|
|
61
|
+
agent: false,
|
|
62
|
+
family: address.family,
|
|
63
|
+
signal,
|
|
64
|
+
// Preserve the original Host header and TLS server name. Only the
|
|
65
|
+
// socket destination is pinned; no cookies or auth are forwarded.
|
|
66
|
+
lookup: (_hostname, _options, callback) => callback(null, address.address, address.family),
|
|
67
|
+
headers: { "accept": "text/html,application/xhtml+xml", "accept-encoding": "identity" }
|
|
68
|
+
}, (response) => {
|
|
69
|
+
const status = response.statusCode || 0;
|
|
70
|
+
if ([301, 302, 303, 307, 308].includes(status)) {
|
|
71
|
+
response.destroy();
|
|
72
|
+
resolve({ redirect: response.headers.location });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (status < 200 || status >= 300) {
|
|
76
|
+
response.destroy();
|
|
77
|
+
const retryable = status >= 500 || [408, 425, 429].includes(status);
|
|
78
|
+
reject(retryable ? new Error("Citation page unavailable") : new PermanentMetadataError("Citation page unavailable"));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!/^(?:text\/html|application\/xhtml\+xml)(?:;|$)/i.test(response.headers["content-type"] || "") || response.headers["content-encoding"] && response.headers["content-encoding"] !== "identity") {
|
|
82
|
+
response.destroy();
|
|
83
|
+
reject(new PermanentMetadataError("Unsupported citation content"));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let size = 0;
|
|
88
|
+
response.on("data", (chunk) => {
|
|
89
|
+
size += chunk.length;
|
|
90
|
+
if (size > MAX_HTML_BYTES) {
|
|
91
|
+
const remaining = MAX_HTML_BYTES - (size - chunk.length);
|
|
92
|
+
chunks.push(chunk.subarray(0, remaining));
|
|
93
|
+
resolve({ html: Buffer.concat(chunks).toString("utf8") });
|
|
94
|
+
response.destroy();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
chunks.push(chunk);
|
|
98
|
+
});
|
|
99
|
+
response.on("end", () => resolve({ html: Buffer.concat(chunks).toString("utf8") }));
|
|
100
|
+
response.on("error", reject);
|
|
101
|
+
});
|
|
102
|
+
request.on("error", reject);
|
|
103
|
+
request.end();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
export async function fetchCitationSiteName(input, request = requestPage) {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
let timer;
|
|
109
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
110
|
+
timer = setTimeout(() => {
|
|
111
|
+
controller.abort();
|
|
112
|
+
reject(new Error("Citation metadata timed out"));
|
|
113
|
+
}, TIMEOUT_MS);
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
return await Promise.race([timeout, (async () => {
|
|
117
|
+
let url = publicCitationUrl(input);
|
|
118
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
119
|
+
const response = await request(url, controller.signal);
|
|
120
|
+
if (!response.redirect)
|
|
121
|
+
return response.html ? parseCitationSiteName(response.html) : null;
|
|
122
|
+
let target;
|
|
123
|
+
try {
|
|
124
|
+
target = new URL(response.redirect, url);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new PermanentMetadataError("Invalid citation redirect");
|
|
127
|
+
}
|
|
128
|
+
url = publicCitationUrl(target.href);
|
|
129
|
+
}
|
|
130
|
+
throw new PermanentMetadataError("Too many citation redirects");
|
|
131
|
+
})()]);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof PermanentMetadataError)
|
|
134
|
+
return null;
|
|
135
|
+
throw error;
|
|
136
|
+
} finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
controller.abort();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function createSiteNameCache(load = fetchCitationSiteName, now = Date.now) {
|
|
142
|
+
const cache = /* @__PURE__ */ new Map();
|
|
143
|
+
const queue = [];
|
|
144
|
+
let active = 0;
|
|
145
|
+
return (input) => {
|
|
146
|
+
let key;
|
|
147
|
+
try {
|
|
148
|
+
key = publicCitationUrl(input).href;
|
|
149
|
+
} catch {
|
|
150
|
+
return Promise.resolve(null);
|
|
151
|
+
}
|
|
152
|
+
const cached = cache.get(key);
|
|
153
|
+
if (cached && cached.expires > now())
|
|
154
|
+
return cached.result;
|
|
155
|
+
if (active >= 8 && queue.length >= 64)
|
|
156
|
+
return Promise.reject(new Error("Citation metadata queue full"));
|
|
157
|
+
const entry = { expires: Number.POSITIVE_INFINITY, result: Promise.resolve(null) };
|
|
158
|
+
entry.result = new Promise((resolve, reject) => {
|
|
159
|
+
const start = () => {
|
|
160
|
+
active++;
|
|
161
|
+
void Promise.resolve().then(() => load(key)).then((name) => {
|
|
162
|
+
entry.expires = now() + 60 * 60 * 1e3;
|
|
163
|
+
resolve(name);
|
|
164
|
+
}, (error) => {
|
|
165
|
+
if (cache.get(key) === entry)
|
|
166
|
+
cache.delete(key);
|
|
167
|
+
reject(error);
|
|
168
|
+
}).finally(() => {
|
|
169
|
+
active--;
|
|
170
|
+
queue.shift()?.();
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
if (active < 8)
|
|
174
|
+
start();
|
|
175
|
+
else
|
|
176
|
+
queue.push(start);
|
|
177
|
+
});
|
|
178
|
+
cache.delete(key);
|
|
179
|
+
if (cache.size >= 512) {
|
|
180
|
+
const oldestCompleted = [...cache].find(([, value]) => Number.isFinite(value.expires));
|
|
181
|
+
if (oldestCompleted)
|
|
182
|
+
cache.delete(oldestCompleted[0]);
|
|
183
|
+
}
|
|
184
|
+
cache.set(key, entry);
|
|
185
|
+
return entry.result;
|
|
186
|
+
};
|
|
187
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meistrari/chat-nuxt",
|
|
3
|
-
"version": "3.17.0-rc.
|
|
3
|
+
"version": "3.17.0-rc.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"@vueuse/components": "^12.8.0",
|
|
52
52
|
"@vueuse/core": "^12.8.0",
|
|
53
53
|
"h3": "^1.15.0",
|
|
54
|
+
"htmlparser2": "12.0.0",
|
|
55
|
+
"ipaddr.js": "2.5.0",
|
|
54
56
|
"markstream-vue": "2.0.7",
|
|
55
57
|
"mermaid": "^11.13.0",
|
|
56
58
|
"motion": "^11.13.0",
|