@meistrari/chat-nuxt 3.6.0 → 3.8.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.
Files changed (42) hide show
  1. package/README.md +26 -0
  2. package/dist/module.d.mts +2 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +6 -3
  5. package/dist/runtime/citations/citation-links.d.ts +32 -0
  6. package/dist/runtime/citations/citation-links.js +56 -0
  7. package/dist/runtime/citations/components/chat-citation.d.vue.ts +9 -0
  8. package/dist/runtime/citations/components/chat-citation.vue +186 -0
  9. package/dist/runtime/citations/components/chat-citation.vue.d.ts +9 -0
  10. package/dist/runtime/citations/components/citation-document-panel.d.vue.ts +3 -0
  11. package/dist/runtime/citations/components/citation-document-panel.vue +217 -0
  12. package/dist/runtime/citations/components/citation-document-panel.vue.d.ts +3 -0
  13. package/dist/runtime/citations/composables/citation-panel.d.ts +8 -0
  14. package/dist/runtime/citations/composables/citation-panel.js +17 -0
  15. package/dist/runtime/citations/composables/citations.d.ts +17 -0
  16. package/dist/runtime/citations/composables/citations.js +44 -0
  17. package/dist/runtime/citations/document-highlight.d.ts +13 -0
  18. package/dist/runtime/citations/document-highlight.js +35 -0
  19. package/dist/runtime/citations/types.d.ts +57 -0
  20. package/dist/runtime/citations/types.js +0 -0
  21. package/dist/runtime/conversations/components/chat-conversation-list.vue +2 -2
  22. package/dist/runtime/conversations/composables/conversations.js +5 -0
  23. package/dist/runtime/embed/components/chat-embed-inner.vue +53 -2
  24. package/dist/runtime/embed/components/chat-embed.vue +2 -0
  25. package/dist/runtime/embed/components/meistrari-chat-embed.vue +3 -0
  26. package/dist/runtime/embed/components/mobile/chat-mobile-drawer.vue +2 -2
  27. package/dist/runtime/embed/composables/embed-config.d.ts +3 -0
  28. package/dist/runtime/embed/composables/embed-config.js +9 -0
  29. package/dist/runtime/embed/types.d.ts +12 -1
  30. package/dist/runtime/files/components/chat-vault-link.vue +13 -0
  31. package/dist/runtime/files/content-detection.d.ts +9 -1
  32. package/dist/runtime/files/content-detection.js +29 -8
  33. package/dist/runtime/messages/components/chat-message-bubble.vue +7 -3
  34. package/dist/runtime/server/utils/chat-api-proxy.js +10 -1
  35. package/dist/runtime/shared/composables/feature-flags.d.ts +8 -1
  36. package/dist/runtime/shared/composables/feature-flags.js +10 -1
  37. package/dist/runtime/types/citations.d.ts +1 -0
  38. package/dist/runtime/types/citations.js +0 -0
  39. package/dist/runtime/types/embed.d.ts +1 -0
  40. package/dist/runtime/types/schemas/chat/usage.d.ts +2 -0
  41. package/dist/runtime/types/schemas/chat/usage.js +1 -0
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -211,6 +211,7 @@ import type {
211
211
  | `loadingMessagesMode` | `'ordered' \| 'random' \| null` | `'ordered'` | Show custom messages sequentially or randomly |
212
212
  | `customComponents` | `Record<string, Component>` | `undefined` | Custom markdown renderers keyed by markdown node or tag name |
213
213
  | `customHtmlTags` | `readonly string[]` | `undefined` | Extra HTML tags allowed by the markdown renderer |
214
+ | `citations` | `ChatCitationsConfig \| null` | `undefined` | Enables `cite://` citations in assistant messages (see Citations below) |
214
215
  | `feedbackConfig` | `MessageFeedbackConfig \| null` | `undefined` | Enables 👍/👎 feedback on assistant messages; providing the prop (even `{}`) shows the controls |
215
216
  | `messageFeedback` | `Record<string, MessageFeedbackRating> \| null` | `undefined` | Host-persisted votes keyed by message id; when provided, it is the single source of truth for the selected thumbs |
216
217
 
@@ -232,6 +233,31 @@ Scoped embeds only see conversations created with the same scope. Unscoped embed
232
233
  />
233
234
  ```
234
235
 
236
+ **Citations (`cite://`):**
237
+
238
+ Hosts whose agents cite retrievable documents can teach them (via prompt) the notation
239
+
240
+ ```
241
+ [visible text](cite://<namespace>/<id>[#q=<term>&n=<nth>&p=<page>])
242
+ ```
243
+
244
+ and pass `citations` to enable it. The lib detects the notation in assistant messages (markdown links and bare tokens), renders each citation as a chip with a metadata popover, and — when `getDocument` is provided — opens a built-in side panel that shows the document with the cited term highlighted (`#q`, nth occurrence via `#n`). What a namespace means, and every data access, belongs to the host: callbacks run against the host's own backend, carrying the viewing user's authorization. A citation that fails to resolve renders as "unverified" — an agent cannot fabricate a trusted-looking reference, and a viewer without access to a document learns nothing from a citation to it.
245
+
246
+ ```vue
247
+ <MeistrariChatEmbed
248
+ tela-agent-id="agent_123"
249
+ :citations="{
250
+ resolve: ref => $fetch(`/api/citations/${ref.namespace}/${encodeURIComponent(ref.id)}`).catch(() => null),
251
+ getDocument: ref => $fetch(`/api/citations/${ref.namespace}/${encodeURIComponent(ref.id)}/document`).catch(() => null),
252
+ locate: (ref, term) => $fetch(`/api/citations/${ref.namespace}/${encodeURIComponent(ref.id)}/occurrences`, { query: { term } }).catch(() => null),
253
+ }"
254
+ />
255
+ ```
256
+
257
+ `ref.id` arrives percent-decoded (natural identifiers may carry pipes, spaces, accents or slashes) — re-encode it when building URLs, as above.
258
+
259
+ `ChatCitationsConfig` (exported from `@meistrari/chat-nuxt/types/citations`): `resolve` maps a citation to display metadata (`null` = unverified); `getDocument` enables the document panel; `locate` returns precise term offsets for highlighting (omit it for a client-side, accent-sensitive fallback); `component` replaces the built-in chip; `onOpen` takes over the click entirely. Without the `citations` prop nothing changes: `cite://` hrefs keep rendering as plain links.
260
+
235
261
  **Events:**
236
262
 
237
263
  | Event | Payload | Description |
package/dist/module.d.mts CHANGED
@@ -6,6 +6,8 @@ type ChatNuxtConfigValidationMode = false | 'warn' | 'error';
6
6
  interface ChatNuxtModuleOptions {
7
7
  /** Dedicated Chat API base URL used by Nuxt server routes. */
8
8
  chatApiUrl?: string;
9
+ /** Host app identifier reported to the chat-api (e.g. `'chat-app'`). */
10
+ source?: string;
9
11
  /**
10
12
  * Whether to fail fast (`'error'`), warn (`'warn'`), or skip (`false`) when
11
13
  * required runtime config keys are unset.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
3
  "configKey": "chatNuxt",
4
- "version": "3.6.0",
4
+ "version": "3.8.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -149,9 +149,10 @@ function resolveWorkspaceSettingsResolverEntry(srcDir, fallbackResolverBase) {
149
149
  }
150
150
 
151
151
  const _chatNuxtRequire = createRequire(import.meta.url);
152
+ const _chatNuxtPackageVersion = _chatNuxtRequire("../package.json").version;
152
153
  const VUE_COMPONENT_EXTENSIONS = [".vue"];
153
- const FEATURE_DIRS = ["conversations", "embed", "files", "messages", "rendering", "shared", "usage", "workspace"];
154
- const FULL_NAME_COMPONENT_DIRS = ["conversations", "embed", "files", "messages", "usage"];
154
+ const FEATURE_DIRS = ["citations", "conversations", "embed", "files", "messages", "rendering", "shared", "usage", "workspace"];
155
+ const FULL_NAME_COMPONENT_DIRS = ["citations", "conversations", "embed", "files", "messages", "usage"];
155
156
  const PATH_PREFIX_COMPONENT_DIRS = ["rendering", "shared"];
156
157
  const DYNAMIC_ICON_SAFELIST = [
157
158
  "i-ph-folder-minus",
@@ -358,7 +359,9 @@ const module$1 = defineNuxtModule({
358
359
  rc.public = rc.public || {};
359
360
  const existingChatNuxtRuntimeConfig = rc.chatNuxt;
360
361
  rc.chatNuxt = {
361
- chatApiUrl: existingChatNuxtRuntimeConfig?.chatApiUrl || rc.chatApiUrl
362
+ chatApiUrl: existingChatNuxtRuntimeConfig?.chatApiUrl || rc.chatApiUrl,
363
+ source: options.source || existingChatNuxtRuntimeConfig?.source,
364
+ clientVersion: _chatNuxtPackageVersion
362
365
  };
363
366
  validateRuntimeConfig(rc, options.validateConfig ?? false);
364
367
  nuxt.options.css = nuxt.options.css || [];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The `cite://` notation: `[visible text](cite://<namespace>/<id>[#q=<term>&n=<nth>&p=<page>])`.
3
+ *
4
+ * Agents are taught (via prompt) to emit this scheme when citing a document they actually
5
+ * retrieved. The lib only parses the shape — what a namespace means and how a citation is
6
+ * resolved belongs to the host app through `ChatCitationsConfig`. The id is expected to be
7
+ * an identifier the agent could only know from a real retrieval, so a citation that fails
8
+ * to resolve is rendered as unverified instead of trusted.
9
+ */
10
+ /**
11
+ * Bare cite tokens in prose; markdown link targets (`](cite://...`) are left to the link
12
+ * renderer. Unlike VAULT_REGEX, `_` is allowed: source namespaces use it (`stf_sumulas`).
13
+ */
14
+ export declare const CITE_REGEX: RegExp;
15
+ export type CitationLocator = {
16
+ /** Exact term whose occurrence the citation points at, for in-document highlight. */
17
+ term?: string;
18
+ /** 1-based nth occurrence of `term`; defaults to the first when absent. */
19
+ occurrence?: number;
20
+ /** 1-based page at the original source; only meaningful for paginated originals. */
21
+ page?: number;
22
+ };
23
+ export type CitationRef = {
24
+ namespace: string;
25
+ id: string;
26
+ locator?: CitationLocator;
27
+ raw: string;
28
+ };
29
+ export declare function isCitationUrl(url: string): boolean;
30
+ export declare function parseCitationUrl(url: string): CitationRef | null;
31
+ /** Cache key for resolution state: the locator addresses a spot inside the same document. */
32
+ export declare function citationCacheKey(ref: Pick<CitationRef, 'namespace' | 'id'>): string;
@@ -0,0 +1,56 @@
1
+ const CITE_SCHEME = "cite://";
2
+ export const CITE_REGEX = /(?<!\]\()cite:\/\/[^\s"'<>`\[\]()*~]+/gi;
3
+ const NAMESPACE_PATTERN = /^[a-z0-9_-]+$/i;
4
+ export function isCitationUrl(url) {
5
+ return url.toLowerCase().startsWith(CITE_SCHEME);
6
+ }
7
+ export function parseCitationUrl(url) {
8
+ if (!isCitationUrl(url))
9
+ return null;
10
+ const hashIndex = url.indexOf("#");
11
+ const body = hashIndex === -1 ? url.slice(CITE_SCHEME.length) : url.slice(CITE_SCHEME.length, hashIndex);
12
+ const separatorIndex = body.indexOf("/");
13
+ if (separatorIndex === -1)
14
+ return null;
15
+ const namespace = body.slice(0, separatorIndex);
16
+ const id = safeDecodeURIComponent(body.slice(separatorIndex + 1));
17
+ if (!namespace || !NAMESPACE_PATTERN.test(namespace) || !id)
18
+ return null;
19
+ const locator = hashIndex === -1 ? void 0 : parseCitationLocator(url.slice(hashIndex + 1));
20
+ return {
21
+ namespace: namespace.toLowerCase(),
22
+ id,
23
+ ...locator ? { locator } : {},
24
+ raw: url
25
+ };
26
+ }
27
+ function parseCitationLocator(hash) {
28
+ const params = new URLSearchParams(hash);
29
+ const locator = {};
30
+ const term = params.get("q")?.trim();
31
+ if (term)
32
+ locator.term = term;
33
+ const occurrence = parsePositiveInt(params.get("n"));
34
+ if (occurrence !== void 0)
35
+ locator.occurrence = occurrence;
36
+ const page = parsePositiveInt(params.get("p"));
37
+ if (page !== void 0)
38
+ locator.page = page;
39
+ return Object.keys(locator).length > 0 ? locator : void 0;
40
+ }
41
+ function safeDecodeURIComponent(value) {
42
+ try {
43
+ return decodeURIComponent(value);
44
+ } catch {
45
+ return value;
46
+ }
47
+ }
48
+ function parsePositiveInt(value) {
49
+ if (!value || !/^\d+$/.test(value))
50
+ return void 0;
51
+ const parsed = Number(value);
52
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
53
+ }
54
+ export function citationCacheKey(ref) {
55
+ return `${ref.namespace}/${ref.id}`;
56
+ }
@@ -0,0 +1,9 @@
1
+ import type { CitationRef } from '../citation-links.js';
2
+ type __VLS_Props = {
3
+ citation: CitationRef;
4
+ /** Visible text when the citation came from a markdown link. */
5
+ label?: string;
6
+ };
7
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
8
+ declare const _default: typeof __VLS_export;
9
+ export default _default;
@@ -0,0 +1,186 @@
1
+ <script setup>
2
+ import { onClickOutside } from "@vueuse/core";
3
+ import { useEmbedConfig } from "../../embed/composables/embed-config";
4
+ import { useCitationPanel } from "../composables/citation-panel";
5
+ import { useCitations } from "../composables/citations";
6
+ const props = defineProps({
7
+ citation: { type: Object, required: true },
8
+ label: { type: String, required: false }
9
+ });
10
+ const embedConfig = useEmbedConfig();
11
+ const config = computed(() => embedConfig?.citations.value);
12
+ const { resolveCitation, retryCitation } = useCitations();
13
+ const { openCitationDocument } = useCitationPanel();
14
+ const entry = shallowRef(null);
15
+ watchEffect(() => {
16
+ if (!entry.value && config.value)
17
+ entry.value = resolveCitation(config.value, props.citation);
18
+ });
19
+ const status = computed(() => entry.value?.status.value ?? "loading");
20
+ const meta = computed(() => entry.value?.meta.value ?? null);
21
+ const displayLabel = computed(() => props.label?.trim() || meta.value?.identifier || meta.value?.title || "Documento");
22
+ const rootEl = ref(null);
23
+ const popoverOpen = ref(false);
24
+ onClickOutside(rootEl, () => {
25
+ popoverOpen.value = false;
26
+ });
27
+ function togglePopover() {
28
+ if (status.value === "loading")
29
+ return;
30
+ popoverOpen.value = !popoverOpen.value;
31
+ }
32
+ function retry() {
33
+ if (!config.value)
34
+ return;
35
+ entry.value = retryCitation(config.value, props.citation);
36
+ popoverOpen.value = false;
37
+ }
38
+ const canOpenDocument = computed(() => Boolean(config.value?.onOpen || config.value?.getDocument));
39
+ function openDocument() {
40
+ popoverOpen.value = false;
41
+ const activeConfig = config.value;
42
+ if (!activeConfig)
43
+ return;
44
+ if (activeConfig.onOpen) {
45
+ activeConfig.onOpen(props.citation, meta.value);
46
+ return;
47
+ }
48
+ openCitationDocument(props.citation, meta.value);
49
+ }
50
+ const safeSourceUrl = computed(() => {
51
+ const url = meta.value?.url;
52
+ return url && /^https?:\/\//i.test(url) ? url : null;
53
+ });
54
+ const formattedDate = computed(() => {
55
+ if (!meta.value?.date)
56
+ return null;
57
+ const parsed = new Date(meta.value.date);
58
+ if (Number.isNaN(parsed.getTime()))
59
+ return meta.value.date;
60
+ return parsed.toLocaleDateString("pt-BR", { timeZone: "UTC" });
61
+ });
62
+ const chipIcon = computed(() => {
63
+ if (status.value === "loading")
64
+ return "i-ph-spinner animate-spin text-neutral-400";
65
+ if (status.value === "unresolved")
66
+ return "i-ph-warning-circle text-amber-600";
67
+ return "i-ph-file-text text-neutral-500";
68
+ });
69
+ const BADGE_TONE_CLASSES = {
70
+ neutral: "bg-neutral-100 text-neutral-700",
71
+ warning: "bg-amber-100 text-amber-800",
72
+ danger: "bg-red-100 text-red-700"
73
+ };
74
+ const badgeClasses = computed(() => BADGE_TONE_CLASSES[meta.value?.badge?.tone ?? "neutral"]);
75
+ </script>
76
+
77
+ <template>
78
+ <component
79
+ :is="config.component"
80
+ v-if="config?.component"
81
+ :citation="citation"
82
+ :label="label"
83
+ :status="status"
84
+ :meta="meta"
85
+ />
86
+ <span
87
+ v-else
88
+ ref="rootEl"
89
+ class="citation-ref"
90
+ >
91
+ <button
92
+ type="button"
93
+ class="citation-ref-anchor"
94
+ :class="{
95
+ 'citation-ref-anchor--loading': status === 'loading',
96
+ 'citation-ref-anchor--unresolved': status === 'unresolved'
97
+ }"
98
+ :aria-expanded="popoverOpen"
99
+ @click="togglePopover"
100
+ ><span :class="chipIcon" class="citation-ref-icon" />{{ displayLabel }}</button>
101
+
102
+ <div
103
+ v-if="popoverOpen"
104
+ absolute bottom-full left-0 mb-6px z-50
105
+ w-300px
106
+ bg-white
107
+ border-0.5px border-solid border-neutral-300
108
+ rounded-12px p-12px
109
+ shadow-lg
110
+ cursor-default
111
+ flex="~ col" gap-8px
112
+ >
113
+ <template v-if="status === 'unresolved'">
114
+ <div flex items-start gap-6px>
115
+ <span i-ph-warning-circle text-14px text-amber-600 mt-2px flex-shrink-0 />
116
+ <p text-12px leading-16px text-neutral-700>
117
+ Não foi possível verificar esta referência. Ela pode não existir ou não estar disponível para o seu perfil.
118
+ </p>
119
+ </div>
120
+ <button
121
+ type="button"
122
+ self-start text-12px leading-16px text-neutral-700 underline cursor-pointer
123
+ @click="retry"
124
+ >
125
+ Tentar novamente
126
+ </button>
127
+ </template>
128
+
129
+ <template v-else-if="meta">
130
+ <div flex="~ col" gap-2px>
131
+ <p text-13px leading-18px font-580 text-neutral-900>
132
+ {{ meta.title }}
133
+ </p>
134
+ <p v-if="meta.sourceLabel || meta.identifier" text-12px leading-16px text-neutral-600>
135
+ {{ [meta.sourceLabel, meta.identifier].filter(Boolean).join(" \xB7 ") }}
136
+ </p>
137
+ <p v-if="formattedDate" text-12px leading-16px text-neutral-500>
138
+ {{ meta.dateLabel ? `${meta.dateLabel}: ` : "" }}{{ formattedDate }}
139
+ </p>
140
+ </div>
141
+
142
+ <span
143
+ v-if="meta.badge"
144
+ self-start px-6px py-1px rounded-4px text-11px leading-15px font-580
145
+ :class="badgeClasses"
146
+ >
147
+ {{ meta.badge.label }}
148
+ </span>
149
+
150
+ <p v-if="meta.snippet" text-12px leading-17px text-neutral-600 line-clamp-3>
151
+ {{ meta.snippet }}
152
+ </p>
153
+
154
+ <div v-if="canOpenDocument || safeSourceUrl" flex items-center gap-12px pt-2px>
155
+ <button
156
+ v-if="canOpenDocument"
157
+ type="button"
158
+ inline-flex items-center gap-4px
159
+ text-12px leading-16px font-580 text-neutral-900
160
+ cursor-pointer hover:underline
161
+ @click="openDocument"
162
+ >
163
+ <span i-ph-file-magnifying-glass text-13px />
164
+ Ver documento
165
+ </button>
166
+ <a
167
+ v-if="safeSourceUrl"
168
+ :href="safeSourceUrl"
169
+ target="_blank"
170
+ rel="noopener noreferrer"
171
+ inline-flex items-center gap-4px
172
+ text-12px leading-16px text-neutral-600
173
+ hover:underline
174
+ >
175
+ <span i-ph-arrow-square-out text-13px />
176
+ Abrir na fonte
177
+ </a>
178
+ </div>
179
+ </template>
180
+ </div>
181
+ </span>
182
+ </template>
183
+
184
+ <style scoped>
185
+ .citation-ref{display:inline;position:relative}.citation-ref-anchor{background:transparent;border:0;border-radius:5px;color:inherit;cursor:pointer;display:inline;font:inherit;margin:0 -2px;padding:1px 3px;text-decoration:underline;text-decoration-color:#a3a3a3;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:3px;transition:background-color .15s ease,text-decoration-color .15s ease}.citation-ref-anchor:hover{background-color:#f5f5f5;text-decoration-color:#525252}.citation-ref-anchor--loading{cursor:default;opacity:.6;text-decoration-color:#d4d4d4}.citation-ref-anchor--unresolved{text-decoration-color:#f59e0b}.citation-ref-anchor--unresolved:hover{background-color:#fffbeb}.citation-ref-icon{display:inline-block;font-size:.85em;margin-right:.25em;vertical-align:-.08em}
186
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { CitationRef } from '../citation-links.js';
2
+ type __VLS_Props = {
3
+ citation: CitationRef;
4
+ /** Visible text when the citation came from a markdown link. */
5
+ label?: string;
6
+ };
7
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
8
+ declare const _default: typeof __VLS_export;
9
+ export default _default;
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,217 @@
1
+ <script setup>
2
+ import { useMagicKeys } from "@vueuse/core";
3
+ import { useEmbedConfig } from "../../embed/composables/embed-config";
4
+ import { useCitationPanel } from "../composables/citation-panel";
5
+ import { locateTermClientSide, splitContentByOccurrences } from "../document-highlight";
6
+ const { isOpen, active, closeCitationDocument } = useCitationPanel();
7
+ const embedConfig = useEmbedConfig();
8
+ const config = computed(() => embedConfig?.citations.value);
9
+ const isLoading = ref(false);
10
+ const loadFailed = ref(false);
11
+ const documentData = ref(null);
12
+ const occurrences = ref([]);
13
+ const contentEl = ref(null);
14
+ let loadToken = 0;
15
+ const { escape } = useMagicKeys();
16
+ watch(() => escape?.value, (pressed) => {
17
+ if (pressed && isOpen.value)
18
+ closeCitationDocument();
19
+ });
20
+ watch([isOpen, active], () => {
21
+ void load();
22
+ }, { immediate: true });
23
+ async function load() {
24
+ if (!isOpen.value || !active.value)
25
+ return;
26
+ const activeConfig = config.value;
27
+ if (!activeConfig?.getDocument)
28
+ return;
29
+ const token = ++loadToken;
30
+ const citation = active.value.citation;
31
+ isLoading.value = true;
32
+ loadFailed.value = false;
33
+ documentData.value = null;
34
+ occurrences.value = [];
35
+ try {
36
+ const loaded = await activeConfig.getDocument(citation);
37
+ if (token !== loadToken)
38
+ return;
39
+ documentData.value = loaded;
40
+ loadFailed.value = loaded === null;
41
+ const term = citation.locator?.term;
42
+ if (loaded && term) {
43
+ let located = null;
44
+ if (activeConfig.locate) {
45
+ try {
46
+ located = await activeConfig.locate(citation, term);
47
+ } catch {
48
+ located = null;
49
+ }
50
+ }
51
+ if (token !== loadToken)
52
+ return;
53
+ occurrences.value = located ?? locateTermClientSide(loaded.content, term);
54
+ }
55
+ } catch {
56
+ if (token === loadToken)
57
+ loadFailed.value = true;
58
+ } finally {
59
+ if (token === loadToken)
60
+ isLoading.value = false;
61
+ }
62
+ if (token !== loadToken)
63
+ return;
64
+ await nextTick();
65
+ scrollToTargetOccurrence();
66
+ }
67
+ const highlightSegments = computed(() => {
68
+ if (!documentData.value)
69
+ return [];
70
+ return splitContentByOccurrences(documentData.value.content, occurrences.value);
71
+ });
72
+ const totalOccurrences = computed(() => highlightSegments.value.filter((segment) => segment.occurrence !== void 0).length);
73
+ const targetOccurrence = computed(() => {
74
+ if (totalOccurrences.value === 0)
75
+ return 0;
76
+ const wanted = active.value?.citation.locator?.occurrence ?? 1;
77
+ return Math.min(wanted, totalOccurrences.value);
78
+ });
79
+ const termNotFound = computed(() => Boolean(
80
+ !isLoading.value && documentData.value && active.value?.citation.locator?.term && totalOccurrences.value === 0
81
+ ));
82
+ function scrollToTargetOccurrence() {
83
+ if (targetOccurrence.value === 0)
84
+ return;
85
+ const target = contentEl.value?.querySelector(`[data-occurrence="${targetOccurrence.value}"]`);
86
+ target?.scrollIntoView({ block: "center" });
87
+ }
88
+ const meta = computed(() => active.value?.meta ?? null);
89
+ const subtitle = computed(() => [meta.value?.sourceLabel, meta.value?.identifier].filter(Boolean).join(" \xB7 "));
90
+ const safeSourceUrl = computed(() => {
91
+ const url = meta.value?.url;
92
+ return url && /^https?:\/\//i.test(url) ? url : null;
93
+ });
94
+ </script>
95
+
96
+ <template>
97
+ <Transition name="slide-panel">
98
+ <div
99
+ v-if="isOpen && active"
100
+ class="h-full w-full flex flex-col bg-neutral-50 b-l='.5px neutral-300'"
101
+ style="box-shadow: 0px 12px 20px 0px rgba(163, 163, 163, 0.06);"
102
+ >
103
+ <div
104
+ relative flex items-start gap-8px
105
+ pl-18px pr-56px py-12px
106
+ flex-shrink-0
107
+ >
108
+ <span i-ph-file-text text-16px text-neutral-600 mt-2px flex-shrink-0 />
109
+ <div flex="~ col" gap-2px min-w-0>
110
+ <p
111
+ text-13px font-580 text-secondary leading-17px
112
+ :title="meta?.title ?? 'Documento'"
113
+ >
114
+ {{ meta?.title ?? "Documento" }}
115
+ </p>
116
+ <p v-if="subtitle" text-12px leading-16px text-neutral-500 truncate>
117
+ {{ subtitle }}
118
+ </p>
119
+ </div>
120
+
121
+ <div class="absolute top-8px right-12px flex items-center gap-4px">
122
+ <a
123
+ v-if="safeSourceUrl"
124
+ :href="safeSourceUrl"
125
+ target="_blank"
126
+ rel="noopener noreferrer"
127
+ title="Abrir na fonte original"
128
+ class="flex items-center justify-center w-28px h-28px rounded-8px hover:bg-neutral-200 transition-colors"
129
+ >
130
+ <span class="i-ph-arrow-square-out text-16px text-gray-700" />
131
+ </a>
132
+ <button
133
+ type="button"
134
+ title="Fechar"
135
+ class="flex items-center justify-center w-28px h-28px rounded-8px hover:bg-neutral-200 transition-colors"
136
+ @click="closeCitationDocument"
137
+ >
138
+ <span class="i-ph-x text-16px text-gray-700" />
139
+ </button>
140
+ </div>
141
+ </div>
142
+
143
+ <div
144
+ v-if="documentData?.truncated || termNotFound"
145
+ flex="~ col" gap-6px px-18px pb-8px flex-shrink-0
146
+ >
147
+ <div
148
+ v-if="termNotFound"
149
+ flex items-start gap-6px
150
+ px-10px py-8px rounded-8px
151
+ bg-amber-50 border-0.5px border-solid border-amber-200
152
+ >
153
+ <span i-ph-warning-circle text-13px text-amber-600 mt-1px flex-shrink-0 />
154
+ <p text-12px leading-16px text-amber-800>
155
+ Trecho citado não localizado neste documento.
156
+ </p>
157
+ </div>
158
+ <div
159
+ v-if="documentData?.truncated"
160
+ flex items-start gap-6px
161
+ px-10px py-8px rounded-8px
162
+ bg-neutral-100 border-0.5px border-solid border-neutral-200
163
+ >
164
+ <span i-ph-info text-13px text-neutral-500 mt-1px flex-shrink-0 />
165
+ <p text-12px leading-16px text-neutral-600>
166
+ Documento exibido parcialmente.
167
+ </p>
168
+ </div>
169
+ </div>
170
+
171
+ <div
172
+ v-if="isLoading"
173
+ flex-1 flex items-center justify-center
174
+ >
175
+ <span i-ph-spinner animate-spin text-24px text-neutral-400 />
176
+ </div>
177
+
178
+ <div
179
+ v-else-if="loadFailed"
180
+ flex-1 flex="~ col" items-center justify-center gap-4px px-24px
181
+ >
182
+ <span i-ph-file-x text-24px text-neutral-400 />
183
+ <p text-13px leading-18px text-neutral-700 text-center>
184
+ Documento não disponível.
185
+ </p>
186
+ <p text-12px leading-16px text-neutral-500 text-center>
187
+ Ele pode não existir ou não estar acessível para o seu perfil.
188
+ </p>
189
+ </div>
190
+
191
+ <div
192
+ v-else-if="documentData"
193
+ ref="contentEl"
194
+ flex-1 overflow-y-auto
195
+ px-18px py-14px
196
+ >
197
+ <p text-13px leading-20px text-neutral-800 whitespace-pre-wrap break-words>
198
+ <template v-for="(segment, index) in highlightSegments" :key="index">
199
+ <mark
200
+ v-if="segment.occurrence !== void 0"
201
+ :data-occurrence="segment.occurrence"
202
+ rounded-2px
203
+ :class="segment.occurrence === targetOccurrence ? 'bg-amber-300' : 'bg-amber-100'"
204
+ >{{ segment.text }}</mark>
205
+ <template v-else>
206
+ {{ segment.text }}
207
+ </template>
208
+ </template>
209
+ </p>
210
+ </div>
211
+ </div>
212
+ </Transition>
213
+ </template>
214
+
215
+ <style scoped>
216
+ .slide-panel-enter-active,.slide-panel-leave-active{transition:all .2s ease}.slide-panel-enter-from,.slide-panel-leave-to{opacity:0;transform:translateX(100%)}
217
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import type { CitationRef } from '../citation-links.js';
2
+ import type { CitationMeta } from '../types.js';
3
+ export declare function useCitationPanel(): {
4
+ isOpen: any;
5
+ active: any;
6
+ openCitationDocument: (citation: CitationRef, meta: CitationMeta | null) => void;
7
+ closeCitationDocument: () => void;
8
+ };
@@ -0,0 +1,17 @@
1
+ const isOpen = ref(false);
2
+ const active = ref(null);
3
+ export function useCitationPanel() {
4
+ function openCitationDocument(citation, meta) {
5
+ active.value = { citation, meta };
6
+ isOpen.value = true;
7
+ }
8
+ function closeCitationDocument() {
9
+ isOpen.value = false;
10
+ }
11
+ return {
12
+ isOpen: readonly(isOpen),
13
+ active: readonly(active),
14
+ openCitationDocument,
15
+ closeCitationDocument
16
+ };
17
+ }
@@ -0,0 +1,17 @@
1
+ import type { Ref } from 'vue';
2
+ import { type CitationRef } from '../citation-links.js';
3
+ import type { ChatCitationsConfig, CitationMeta, CitationStatus } from '../types.js';
4
+ export type CitationEntry = {
5
+ status: Ref<CitationStatus>;
6
+ meta: Ref<CitationMeta | null>;
7
+ };
8
+ /**
9
+ * Resolution cache shared by every chip in the session: one `resolve` call per document,
10
+ * no matter how many times (or with which locators) it is cited. Keyed by config identity
11
+ * so embeds with different resolvers (different backends/authorization scopes) never
12
+ * reuse each other's results.
13
+ */
14
+ export declare function useCitations(): {
15
+ resolveCitation: (config: ChatCitationsConfig, citation: CitationRef) => CitationEntry;
16
+ retryCitation: (config: ChatCitationsConfig, citation: CitationRef) => CitationEntry;
17
+ };