@meistrari/chat-nuxt 3.7.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 (35) hide show
  1. package/README.md +26 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +2 -2
  4. package/dist/runtime/citations/citation-links.d.ts +32 -0
  5. package/dist/runtime/citations/citation-links.js +56 -0
  6. package/dist/runtime/citations/components/chat-citation.d.vue.ts +9 -0
  7. package/dist/runtime/citations/components/chat-citation.vue +186 -0
  8. package/dist/runtime/citations/components/chat-citation.vue.d.ts +9 -0
  9. package/dist/runtime/citations/components/citation-document-panel.d.vue.ts +3 -0
  10. package/dist/runtime/citations/components/citation-document-panel.vue +217 -0
  11. package/dist/runtime/citations/components/citation-document-panel.vue.d.ts +3 -0
  12. package/dist/runtime/citations/composables/citation-panel.d.ts +8 -0
  13. package/dist/runtime/citations/composables/citation-panel.js +17 -0
  14. package/dist/runtime/citations/composables/citations.d.ts +17 -0
  15. package/dist/runtime/citations/composables/citations.js +44 -0
  16. package/dist/runtime/citations/document-highlight.d.ts +13 -0
  17. package/dist/runtime/citations/document-highlight.js +35 -0
  18. package/dist/runtime/citations/types.d.ts +57 -0
  19. package/dist/runtime/citations/types.js +0 -0
  20. package/dist/runtime/conversations/components/chat-conversation-list.vue +2 -2
  21. package/dist/runtime/embed/components/chat-embed-inner.vue +15 -0
  22. package/dist/runtime/embed/components/chat-embed.vue +2 -0
  23. package/dist/runtime/embed/components/meistrari-chat-embed.vue +3 -0
  24. package/dist/runtime/embed/components/mobile/chat-mobile-drawer.vue +2 -2
  25. package/dist/runtime/embed/composables/embed-config.d.ts +3 -0
  26. package/dist/runtime/embed/composables/embed-config.js +9 -0
  27. package/dist/runtime/embed/types.d.ts +12 -1
  28. package/dist/runtime/files/components/chat-vault-link.vue +13 -0
  29. package/dist/runtime/files/content-detection.d.ts +9 -1
  30. package/dist/runtime/files/content-detection.js +29 -8
  31. package/dist/runtime/messages/components/chat-message-bubble.vue +7 -3
  32. package/dist/runtime/types/citations.d.ts +1 -0
  33. package/dist/runtime/types/citations.js +0 -0
  34. package/dist/runtime/types/embed.d.ts +1 -0
  35. 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.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
3
  "configKey": "chatNuxt",
4
- "version": "3.7.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
@@ -151,8 +151,8 @@ function resolveWorkspaceSettingsResolverEntry(srcDir, fallbackResolverBase) {
151
151
  const _chatNuxtRequire = createRequire(import.meta.url);
152
152
  const _chatNuxtPackageVersion = _chatNuxtRequire("../package.json").version;
153
153
  const VUE_COMPONENT_EXTENSIONS = [".vue"];
154
- const FEATURE_DIRS = ["conversations", "embed", "files", "messages", "rendering", "shared", "usage", "workspace"];
155
- 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"];
156
156
  const PATH_PREFIX_COMPONENT_DIRS = ["rendering", "shared"];
157
157
  const DYNAMIC_ICON_SAFELIST = [
158
158
  "i-ph-folder-minus",
@@ -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
+ };
@@ -0,0 +1,44 @@
1
+ import { citationCacheKey } from "../citation-links.js";
2
+ export function useCitations() {
3
+ const nuxtApp = useNuxtApp();
4
+ const caches = nuxtApp._chatCitationEntries ??= /* @__PURE__ */ new WeakMap();
5
+ function entriesFor(config) {
6
+ let entries = caches.get(config);
7
+ if (!entries) {
8
+ entries = /* @__PURE__ */ new Map();
9
+ caches.set(config, entries);
10
+ }
11
+ return entries;
12
+ }
13
+ function resolveInto(entry, config, citation) {
14
+ entry.status.value = "loading";
15
+ config.resolve(citation).then((meta) => {
16
+ entry.meta.value = meta;
17
+ entry.status.value = meta ? "resolved" : "unresolved";
18
+ }).catch(() => {
19
+ entry.status.value = "unresolved";
20
+ });
21
+ }
22
+ function resolveCitation(config, citation) {
23
+ const entries = entriesFor(config);
24
+ const key = citationCacheKey(citation);
25
+ const existing = entries.get(key);
26
+ if (existing)
27
+ return existing;
28
+ const entry = {
29
+ status: ref("loading"),
30
+ meta: ref(null)
31
+ };
32
+ entries.set(key, entry);
33
+ resolveInto(entry, config, citation);
34
+ return entry;
35
+ }
36
+ function retryCitation(config, citation) {
37
+ const existing = entriesFor(config).get(citationCacheKey(citation));
38
+ if (!existing)
39
+ return resolveCitation(config, citation);
40
+ resolveInto(existing, config, citation);
41
+ return existing;
42
+ }
43
+ return { resolveCitation, retryCitation };
44
+ }
@@ -0,0 +1,13 @@
1
+ import type { CitationOccurrence } from './types.js';
2
+ export type HighlightSegment = {
3
+ text: string;
4
+ /** 1-based occurrence number when this segment is a highlighted match. */
5
+ occurrence?: number;
6
+ };
7
+ /**
8
+ * Fallback when the host provides no `locate` callback. Plain case-insensitive matching:
9
+ * accent variants won't match, which degrades to "term not found" rather than breaking.
10
+ */
11
+ export declare function locateTermClientSide(content: string, term: string): CitationOccurrence[];
12
+ /** Occurrence ranges are host data: invalid, out-of-bounds and overlapping ranges are dropped. */
13
+ export declare function splitContentByOccurrences(content: string, occurrences: CitationOccurrence[]): HighlightSegment[];
@@ -0,0 +1,35 @@
1
+ export function locateTermClientSide(content, term) {
2
+ const needle = term.toLowerCase();
3
+ if (!needle)
4
+ return [];
5
+ const haystack = content.toLowerCase();
6
+ const occurrences = [];
7
+ let cursor = 0;
8
+ while (true) {
9
+ const index = haystack.indexOf(needle, cursor);
10
+ if (index === -1)
11
+ break;
12
+ occurrences.push({ offset: index, end: index + needle.length });
13
+ cursor = index + needle.length;
14
+ }
15
+ return occurrences;
16
+ }
17
+ export function splitContentByOccurrences(content, occurrences) {
18
+ const ordered = [...occurrences].filter((occurrence) => Number.isInteger(occurrence.offset) && Number.isInteger(occurrence.end) && occurrence.offset >= 0 && occurrence.end > occurrence.offset && occurrence.offset < content.length).sort((a, b) => a.offset - b.offset);
19
+ const segments = [];
20
+ let lastEnd = 0;
21
+ let count = 0;
22
+ for (const occurrence of ordered) {
23
+ if (occurrence.offset < lastEnd)
24
+ continue;
25
+ const end = Math.min(occurrence.end, content.length);
26
+ if (occurrence.offset > lastEnd)
27
+ segments.push({ text: content.slice(lastEnd, occurrence.offset) });
28
+ count += 1;
29
+ segments.push({ text: content.slice(occurrence.offset, end), occurrence: count });
30
+ lastEnd = end;
31
+ }
32
+ if (lastEnd < content.length)
33
+ segments.push({ text: content.slice(lastEnd) });
34
+ return segments.length > 0 ? segments : [{ text: content }];
35
+ }
@@ -0,0 +1,57 @@
1
+ import type { Component } from 'vue';
2
+ import type { CitationRef } from './citation-links.js';
3
+ export type { CitationLocator, CitationRef } from './citation-links.js';
4
+ export type CitationMeta = {
5
+ /** Human-readable document title. Hosts must never surface internal ids here. */
6
+ title: string;
7
+ /** Display name of the base/source the document belongs to. */
8
+ sourceLabel?: string;
9
+ /** Identifier of the document at its origin (case number, súmula number, ...). */
10
+ identifier?: string;
11
+ /** Link to the document at its original source, when one exists. */
12
+ url?: string;
13
+ /** ISO date associated with the document. */
14
+ date?: string;
15
+ /** What `date` refers to (julgamento, aprovação, publicação, ...). */
16
+ dateLabel?: string;
17
+ /** Short excerpt shown in the citation popover. */
18
+ snippet?: string;
19
+ /** Status badge, e.g. a súmula that is no longer in force. */
20
+ badge?: {
21
+ label: string;
22
+ tone?: 'neutral' | 'warning' | 'danger';
23
+ };
24
+ };
25
+ export type CitationDocument = {
26
+ /** Plain-text document content as stored by the host. */
27
+ content: string;
28
+ /** True when the host cut `content` short; the panel discloses it. */
29
+ truncated?: boolean;
30
+ };
31
+ export type CitationOccurrence = {
32
+ /** Inclusive char offset of the occurrence in the document content. */
33
+ offset: number;
34
+ /** Exclusive end offset of the occurrence. */
35
+ end: number;
36
+ };
37
+ /**
38
+ * Host-provided citation behavior. The lib owns detection, rendering and interaction
39
+ * states; the host owns data access (and with it, authorization: every callback runs
40
+ * against the host's own backend with the viewing user's identity).
41
+ *
42
+ * A `resolve` that returns `null` renders the citation as unverified — deliberately
43
+ * indistinguishable between "does not exist" and "not visible to this viewer".
44
+ */
45
+ export type ChatCitationsConfig = {
46
+ /** Resolve a citation to display metadata; `null` marks it unverified. */
47
+ resolve: (ref: CitationRef) => Promise<CitationMeta | null>;
48
+ /** Fetch the document body; providing this enables the built-in document panel. */
49
+ getDocument?: (ref: CitationRef) => Promise<CitationDocument | null>;
50
+ /** Locate a term's occurrences for highlighting; absent = client-side best effort. */
51
+ locate?: (ref: CitationRef, term: string) => Promise<CitationOccurrence[] | null>;
52
+ /** Replaces the built-in citation chip. */
53
+ component?: Component;
54
+ /** Takes over the click entirely; the built-in panel is not opened. */
55
+ onOpen?: (ref: CitationRef, meta: CitationMeta | null) => void;
56
+ };
57
+ export type CitationStatus = 'loading' | 'resolved' | 'unresolved';
File without changes
@@ -84,7 +84,7 @@ const {
84
84
 
85
85
  <template v-else>
86
86
  <div
87
- v-if="showGroupCreationPrompt"
87
+ v-if="showGroupCreationPrompt && !hideFolderHint"
88
88
  mx-8px
89
89
  flex="~ col" gap-10px
90
90
  >
@@ -113,7 +113,7 @@ const {
113
113
  <span h-10px w-84px rounded-full bg-neutral-100 />
114
114
  </div>
115
115
  </div>
116
- <p v-if="!hideFolderHint" px-4px text-14px text-neutral-500 leading-20px>
116
+ <p px-4px text-14px text-neutral-500 leading-20px>
117
117
  Suas pastas aparecem aqui. Crie uma para começar a agrupar conversas.
118
118
  </p>
119
119
  <ChatConversationNewGroupButton label="Criar pasta" variant="empty-state" />
@@ -81,6 +81,7 @@ const effectiveWorkspaceSettings = computed(() => {
81
81
  });
82
82
  const { closePanel: closeFilesPanel } = useConversationFilesPanel();
83
83
  const { isOpen: isPreviewPanelOpen, panelWidth: previewPanelWidth, currentFile: currentPreviewFile, closePreview } = useFilePreviewPanel();
84
+ const { isOpen: isCitationPanelOpen } = useCitationPanel();
84
85
  const activeConversationId = ref(
85
86
  normalizeConversationId(props.conversationId) ?? normalizeConversationId(props.initialConversationId)
86
87
  );
@@ -975,6 +976,20 @@ async function handleDelete() {
975
976
  >
976
977
  <FilePreviewPanel />
977
978
  </div>
979
+
980
+ <div
981
+ v-if="isCitationPanelOpen && !isMobile"
982
+ h-full flex-shrink-0 pt-64px
983
+ w-440px
984
+ >
985
+ <CitationDocumentPanel />
986
+ </div>
987
+ <div
988
+ v-else-if="isCitationPanelOpen && isMobile"
989
+ fixed inset-0 z-50 bg-neutral-50
990
+ >
991
+ <CitationDocumentPanel />
992
+ </div>
978
993
  </div>
979
994
  </template>
980
995
 
@@ -20,6 +20,7 @@ const props = defineProps({
20
20
  loadingMessagesMode: { type: [String, null], required: false },
21
21
  customComponents: { type: Object, required: false },
22
22
  customHtmlTags: { type: Array, required: false },
23
+ citations: { type: [Object, null], required: false },
23
24
  feedbackConfig: { type: [Object, null], required: false },
24
25
  messageFeedback: { type: [Object, null], required: false },
25
26
  telaAgentId: { type: null, required: false },
@@ -49,6 +50,7 @@ provideEmbedConfig({
49
50
  loadingMessagesMode: computed(() => props.loadingMessagesMode),
50
51
  customComponents: computed(() => props.customComponents),
51
52
  customHtmlTags: computed(() => props.customHtmlTags),
53
+ citations: computed(() => props.citations),
52
54
  feedbackConfig: computed(() => props.feedbackConfig),
53
55
  messageFeedback: computed(() => props.messageFeedback)
54
56
  }, { syncGlobal: !parentEmbedConfig });
@@ -13,6 +13,7 @@ const props = defineProps({
13
13
  loadingMessagesMode: { type: [String, null], required: false },
14
14
  customComponents: { type: Object, required: false },
15
15
  customHtmlTags: { type: Array, required: false },
16
+ citations: { type: [Object, null], required: false },
16
17
  feedbackConfig: { type: [Object, null], required: false },
17
18
  messageFeedback: { type: [Object, null], required: false },
18
19
  telaAgentId: { type: null, required: false },
@@ -44,6 +45,7 @@ const hideSettings = computed(() => props.hideSettings ?? false);
44
45
  :loading-messages-mode="loadingMessagesMode"
45
46
  :custom-components="customComponents"
46
47
  :custom-html-tags="customHtmlTags"
48
+ :citations="citations"
47
49
  :feedback-config="feedbackConfig"
48
50
  :message-feedback="messageFeedback"
49
51
  @update:conversation-id="$emit('update:conversationId', $event)"
@@ -72,6 +74,7 @@ const hideSettings = computed(() => props.hideSettings ?? false);
72
74
  :loading-messages-mode="loadingMessagesMode"
73
75
  :custom-components="customComponents"
74
76
  :custom-html-tags="customHtmlTags"
77
+ :citations="citations"
75
78
  :feedback-config="feedbackConfig"
76
79
  :message-feedback="messageFeedback"
77
80
  @update:conversation-id="$emit('update:conversationId', $event)"
@@ -310,7 +310,7 @@ async function handleConversationDrop(event, groupId) {
310
310
  </div>
311
311
  <div v-else flex="~ col" gap-8px>
312
312
  <div
313
- v-if="showGroupCreationPrompt"
313
+ v-if="showGroupCreationPrompt && !hideFolderHint"
314
314
  mx-4px
315
315
  flex="~ col" gap-10px
316
316
  >
@@ -339,7 +339,7 @@ async function handleConversationDrop(event, groupId) {
339
339
  <span h-10px w-84px rounded-full bg-neutral-100 />
340
340
  </div>
341
341
  </div>
342
- <p v-if="!hideFolderHint" px-4px text-14px text-neutral-500 leading-20px>
342
+ <p px-4px text-14px text-neutral-500 leading-20px>
343
343
  Suas pastas aparecem aqui. Crie uma para começar a agrupar conversas.
344
344
  </p>
345
345
  <ChatConversationNewGroupButton label="Criar pasta" variant="empty-state" />
@@ -3,6 +3,7 @@ import type { ConversationCreatorFilter } from '../../types/schemas/chat/primiti
3
3
  import type { WorkspaceSettingsResponse as WorkspaceSettings } from '../../types/schemas/chat/workspace-settings.js';
4
4
  import type { TelaAgentExecutionInput } from '../../types/schemas/integrations/tela-agent-api.js';
5
5
  import type { ChatLoadingMessageMode } from '../loading-messages.js';
6
+ import type { ChatCitationsConfig } from '../../citations/types.js';
6
7
  import type { MessageFeedbackConfig, MessageFeedbackRating } from '../../messages/composables/chat-feedback.js';
7
8
  type ConfiguredTelaAgentInputs = DeepReadonly<TelaAgentExecutionInput[]> | null | undefined;
8
9
  type ConfiguredMessageFeedback = Readonly<Record<string, MessageFeedbackRating>> | null | undefined;
@@ -19,6 +20,7 @@ export type EmbedConfig = {
19
20
  loadingMessagesMode: Ref<ChatLoadingMessageMode | null | undefined>;
20
21
  customComponents: Ref<Record<string, Component> | undefined>;
21
22
  customHtmlTags: Ref<readonly string[] | undefined>;
23
+ citations: Ref<ChatCitationsConfig | null | undefined>;
22
24
  feedbackConfig: Ref<MessageFeedbackConfig | null | undefined>;
23
25
  messageFeedback: Ref<ConfiguredMessageFeedback>;
24
26
  };
@@ -35,6 +37,7 @@ type EmbedConfigInput = {
35
37
  loadingMessagesMode?: MaybeRefOrGetter<ChatLoadingMessageMode | null | undefined>;
36
38
  customComponents?: MaybeRefOrGetter<Record<string, Component> | undefined>;
37
39
  customHtmlTags?: MaybeRefOrGetter<readonly string[] | undefined>;
40
+ citations?: MaybeRefOrGetter<ChatCitationsConfig | null | undefined>;
38
41
  feedbackConfig?: MaybeRefOrGetter<MessageFeedbackConfig | null | undefined>;
39
42
  messageFeedback?: MaybeRefOrGetter<ConfiguredMessageFeedback>;
40
43
  };
@@ -5,6 +5,11 @@ function useGlobalCustomComponentsState() {
5
5
  nuxtApp._chatEmbedConfigCustomComponents ??= shallowRef(void 0);
6
6
  return nuxtApp._chatEmbedConfigCustomComponents;
7
7
  }
8
+ function useGlobalCitationsState() {
9
+ const nuxtApp = useNuxtApp();
10
+ nuxtApp._chatEmbedConfigCitations ??= shallowRef(void 0);
11
+ return nuxtApp._chatEmbedConfigCitations;
12
+ }
8
13
  function useGlobalEmbedConfigState() {
9
14
  return {
10
15
  provided: useState("chat-embed-config-provided", () => false),
@@ -20,6 +25,7 @@ function useGlobalEmbedConfigState() {
20
25
  loadingMessagesMode: useState("chat-embed-config-loading-messages-mode", () => void 0),
21
26
  customComponents: useGlobalCustomComponentsState(),
22
27
  customHtmlTags: useState("chat-embed-config-custom-html-tags", () => void 0),
28
+ citations: useGlobalCitationsState(),
23
29
  feedbackConfig: useState("chat-embed-config-feedback-config", () => void 0),
24
30
  messageFeedback: useState("chat-embed-config-message-feedback", () => void 0)
25
31
  };
@@ -38,6 +44,7 @@ export function provideEmbedConfig(input, options = {}) {
38
44
  loadingMessagesMode: computed(() => toValue(input.loadingMessagesMode)),
39
45
  customComponents: computed(() => toValue(input.customComponents)),
40
46
  customHtmlTags: computed(() => toValue(input.customHtmlTags)),
47
+ citations: computed(() => toValue(input.citations)),
41
48
  feedbackConfig: computed(() => toValue(input.feedbackConfig)),
42
49
  messageFeedback: computed(() => toValue(input.messageFeedback))
43
50
  };
@@ -57,6 +64,7 @@ export function provideEmbedConfig(input, options = {}) {
57
64
  globalConfig.loadingMessagesMode.value = config.loadingMessagesMode.value;
58
65
  globalConfig.customComponents.value = config.customComponents.value;
59
66
  globalConfig.customHtmlTags.value = config.customHtmlTags.value;
67
+ globalConfig.citations.value = config.citations.value;
60
68
  globalConfig.feedbackConfig.value = config.feedbackConfig.value;
61
69
  globalConfig.messageFeedback.value = config.messageFeedback.value;
62
70
  });
@@ -83,6 +91,7 @@ export function useEmbedConfig() {
83
91
  loadingMessagesMode: computed(() => globalConfig.loadingMessagesMode.value),
84
92
  customComponents: computed(() => globalConfig.customComponents.value),
85
93
  customHtmlTags: computed(() => globalConfig.customHtmlTags.value),
94
+ citations: computed(() => globalConfig.citations.value),
86
95
  feedbackConfig: computed(() => globalConfig.feedbackConfig.value),
87
96
  messageFeedback: computed(() => globalConfig.messageFeedback.value)
88
97
  };
@@ -4,6 +4,7 @@ import type { TelaAgentExecutionInput } from '../types/schemas/integrations/tela
4
4
  import type { ConversationCreatorFilter } from '../types/schemas/chat/primitives.js';
5
5
  import type { ChatLoadingMessageMode } from './loading-messages.js';
6
6
  import type { ChatFeatureConfig } from './feature-config.js';
7
+ import type { ChatCitationsConfig } from '../citations/types.js';
7
8
  import type { MessageFeedbackConfig, MessageFeedbackRating } from '../messages/composables/chat-feedback.js';
8
9
  export type ChatActor = {
9
10
  name?: string | null;
@@ -44,7 +45,11 @@ export type ChatEmbedSharedProps = {
44
45
  sidebar?: ChatSidebarConfig;
45
46
  /** Hide workspace settings actions while keeping the chat header and content unchanged. */
46
47
  hideSettings?: boolean;
47
- /** Hide the folder onboarding hint while keeping folder creation actions available. */
48
+ /**
49
+ * Hide the empty-state folder onboarding block (PASTAS header, ghost rows, hint
50
+ * text and inline create button). Folder creation stays available through the
51
+ * new-group button in the sidebar header.
52
+ */
48
53
  hideFolderHint?: boolean;
49
54
  /** Custom pending-response messages shown while the assistant is working. */
50
55
  loadingMessages?: readonly string[] | null;
@@ -54,6 +59,12 @@ export type ChatEmbedSharedProps = {
54
59
  customComponents?: Record<string, Component>;
55
60
  /** Extra HTML tags allowed by the markdown renderer. */
56
61
  customHtmlTags?: readonly string[];
62
+ /**
63
+ * Enables `cite://` citations in assistant messages. The host resolves each
64
+ * citation against its own backend (carrying the viewing user's authorization);
65
+ * the lib renders chips, the metadata popover and the document panel.
66
+ */
67
+ citations?: ChatCitationsConfig | null;
57
68
  /**
58
69
  * Enables 👍/👎 feedback on assistant messages and configures the reason
59
70
  * popovers. Providing the prop (even `{}`) shows the controls; votes reach
@@ -1,11 +1,19 @@
1
1
  <script setup>
2
+ import { parseCitationUrl } from "../../citations/citation-links";
3
+ import { useEmbedConfig } from "../../embed/composables/embed-config";
2
4
  import { detectFileFromUrl } from "../content-detection";
3
5
  const props = defineProps({
4
6
  node: { type: Object, required: true },
5
7
  indexKey: { type: [Number, String], required: true },
6
8
  customId: { type: String, required: false }
7
9
  });
10
+ const embedConfig = useEmbedConfig();
8
11
  const detectedFile = computed(() => detectFileFromUrl(props.node.href));
12
+ const detectedCitation = computed(() => {
13
+ if (!embedConfig?.citations.value)
14
+ return null;
15
+ return parseCitationUrl(props.node.href);
16
+ });
9
17
  const linkText = computed(() => {
10
18
  if (props.node.children?.length > 0) {
11
19
  return props.node.children.map((c) => c.raw).join("");
@@ -23,6 +31,11 @@ const linkText = computed(() => {
23
31
  w-full
24
32
  my-4px
25
33
  />
34
+ <ChatCitation
35
+ v-else-if="detectedCitation"
36
+ :citation="detectedCitation"
37
+ :label="linkText"
38
+ />
26
39
  <a
27
40
  v-else
28
41
  :href="node.href"
@@ -1,3 +1,4 @@
1
+ import { type CitationRef } from '../citations/citation-links.js';
1
2
  import { type VaultHashParams } from './vault-links.js';
2
3
  export type DetectedFile = {
3
4
  type: 'vault' | 'url';
@@ -12,7 +13,14 @@ export type ContentSegment = {
12
13
  } | {
13
14
  type: 'file';
14
15
  file: DetectedFile;
16
+ } | {
17
+ type: 'citation';
18
+ citation: CitationRef;
19
+ };
20
+ export type ParseContentOptions = {
21
+ /** Detect bare `cite://` tokens as citation segments. Off by default: only hosts that configured citations opt in. */
22
+ detectCitations?: boolean;
15
23
  };
16
24
  export declare function detectFilesInText(text: string): DetectedFile[];
17
- export declare function parseContentWithFiles(text: string): ContentSegment[];
25
+ export declare function parseContentWithFiles(text: string, options?: ParseContentOptions): ContentSegment[];
18
26
  export declare function detectFileFromUrl(url: string): DetectedFile | null;
@@ -1,3 +1,4 @@
1
+ import { CITE_REGEX, parseCitationUrl } from "../citations/citation-links.js";
1
2
  import { getExtensionFromUrl, getMimeTypeFromExtension, isFileUrl } from "./mime.js";
2
3
  import {
3
4
  getPermalinkId,
@@ -128,7 +129,7 @@ export function detectFilesInText(text) {
128
129
  }
129
130
  return files;
130
131
  }
131
- export function parseContentWithFiles(text) {
132
+ export function parseContentWithFiles(text, options) {
132
133
  const segments = [];
133
134
  const fileMatches = collectMarkdownFileLinkMatches(text);
134
135
  for (const match of text.matchAll(VAULT_REGEX)) {
@@ -198,18 +199,38 @@ export function parseContentWithFiles(text) {
198
199
  });
199
200
  }
200
201
  }
201
- if (fileMatches.length === 0)
202
+ const matches = fileMatches.map((fm) => ({
203
+ start: fm.start,
204
+ end: fm.end,
205
+ segment: { type: "file", file: fm.file }
206
+ }));
207
+ if (options?.detectCitations) {
208
+ for (const match of text.matchAll(CITE_REGEX)) {
209
+ const url = stripTrailingPunctuation(match[0]);
210
+ if (overlapsFileMatch(match.index, match.index + url.length, fileMatches))
211
+ continue;
212
+ const citation = parseCitationUrl(url);
213
+ if (!citation)
214
+ continue;
215
+ matches.push({
216
+ start: match.index,
217
+ end: match.index + url.length,
218
+ segment: { type: "citation", citation }
219
+ });
220
+ }
221
+ }
222
+ if (matches.length === 0)
202
223
  return [{ type: "text", content: text }];
203
- fileMatches.sort((a, b) => a.start - b.start);
224
+ matches.sort((a, b) => a.start - b.start);
204
225
  let lastEnd = 0;
205
- for (const fm of fileMatches) {
206
- if (fm.start > lastEnd) {
207
- const textContent = text.slice(lastEnd, fm.start);
226
+ for (const m of matches) {
227
+ if (m.start > lastEnd) {
228
+ const textContent = text.slice(lastEnd, m.start);
208
229
  if (textContent)
209
230
  segments.push({ type: "text", content: textContent });
210
231
  }
211
- segments.push({ type: "file", file: fm.file });
212
- lastEnd = fm.end;
232
+ segments.push(m.segment);
233
+ lastEnd = m.end;
213
234
  }
214
235
  if (lastEnd < text.length) {
215
236
  const textContent = text.slice(lastEnd);
@@ -98,9 +98,9 @@ const contentSegments = computed(() => {
98
98
  const content = displayContent.value;
99
99
  if (!content)
100
100
  return [];
101
- return parseContentWithFiles(content);
101
+ return parseContentWithFiles(content, { detectCitations: Boolean(embedConfig?.citations.value) });
102
102
  });
103
- const hasDetectedFiles = computed(() => contentSegments.value.some((s) => s.type === "file"));
103
+ const hasDetectedFiles = computed(() => contentSegments.value.some((s) => s.type !== "text"));
104
104
  </script>
105
105
 
106
106
  <template>
@@ -258,13 +258,17 @@ const hasDetectedFiles = computed(() => contentSegments.value.some((s) => s.type
258
258
  />
259
259
  </ClientOnly>
260
260
  <ChatFilePreview
261
- v-else
261
+ v-else-if="segment.type === 'file'"
262
262
  :file="segment.file"
263
263
  inline-block
264
264
  align-middle
265
265
  w-full
266
266
  my-4px
267
267
  />
268
+ <ChatCitation
269
+ v-else
270
+ :citation="segment.citation"
271
+ />
268
272
  </template>
269
273
  </div>
270
274
  <ClientOnly v-else>
@@ -0,0 +1 @@
1
+ export type { ChatCitationsConfig, CitationDocument, CitationLocator, CitationMeta, CitationOccurrence, CitationRef, CitationStatus, } from '../citations/types.js';
File without changes
@@ -1,3 +1,4 @@
1
+ export type { ChatCitationsConfig, CitationMeta, CitationRef, } from '../citations/types.js';
1
2
  export type { ChatFeatureConfig } from '../embed/feature-config.js';
2
3
  export type { ChatActor, ChatEmbedSharedProps, ChatSidebarConfig, DefaultChatEmbedProps, MeistrariChatEmbedProps, TelaAgentChatEmbedProps, } from '../embed/types.js';
3
4
  export type { ChatActionPayload } from '../messages/composables/chat-action.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {