@meistrari/chat-nuxt 4.0.1 → 4.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,8 @@ Runtime internals under `src/runtime/**`, generated Nuxt aliases, and workspace-
28
28
  | [Citations](docs/guides/citations.md) | `cite://` chips, metadata popover, document panel |
29
29
  | [Feature flags](docs/guides/feature-flags.md) | Usage tab, debug entry, cancel button, PostHog wiring |
30
30
  | [Message feedback](docs/guides/message-feedback.md) | 👍/👎 controls and host-owned persistence |
31
- | [Composer extension](docs/guides/composer-extension.md) | Host actions, shared attachment strip, outbound content and retry lifecycle |
31
+ | [Composer extension](docs/guides/composer-extension.md) | Host actions, shared attachment strip, outbound content and retry lifecycle, "/" commands |
32
+ | [Interactive message widgets](docs/guides/message-widgets.md) | Message context and programmatic send for `customComponents` widgets |
32
33
  | [Workspace settings](docs/guides/workspace-settings.md) | Settings overrides, agent runtime ownership, `<ChatConfigurationModal>` |
33
34
  | [Upgrading to 4.0](docs/guides/upgrading-to-4.md) | Required Chat API version for v2 sends, upgrade order, vue-i18n removal |
34
35
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
3
  "configKey": "chatNuxt",
4
- "version": "4.0.0-rc.1",
4
+ "version": "4.1.0-rc.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -190,7 +190,9 @@ const FULL_NAME_COMPONENT_DIRS = ["citations", "conversations", "embed", "files"
190
190
  const PATH_PREFIX_COMPONENT_DIRS = ["rendering", "shared"];
191
191
  const DYNAMIC_ICON_SAFELIST = [
192
192
  "i-ph-folder-minus",
193
- "i-ph-folder-open"
193
+ "i-ph-folder-open",
194
+ // Neutral pill icon for host-defined user mentions (user-mentions.ts).
195
+ "i-ph-paperclip"
194
196
  ];
195
197
  function resolveChatAuthMode(runtimeConfig) {
196
198
  const publicConfig = runtimeConfig.public;
@@ -1 +1 @@
1
- .markstream-vue{font-family:inherit;--at-apply:"body-16-regular leading-24px text-primary";--list-item-marker:#a3a3a3;--list-item-counter-marker:#262626}.markstream-vue code,.markstream-vue kbd,.markstream-vue pre,.markstream-vue samp{font-family:Geist Mono Variable,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:13px;font-variation-settings:normal;letter-spacing:0;line-height:1.5}.markstream-vue ol,.markstream-vue ul{margin:8px 0;padding-left:1.5em}.markstream-vue .hr-node{border-color:#e5e5e5;border-top-width:.5px;margin-bottom:24px;margin-top:24px}.markstream-vue .strong-node{--at-apply:"font-580"}.markstream-vue .list-node.list-node{margin-bottom:8px;margin-left:4px;margin-top:8px;padding-left:1em}.markstream-vue ol.list-node.list-node{padding-left:3em}.markstream-vue ul{list-style:disc;list-style-position:outside}.markstream-vue ol{list-style:decimal;list-style-position:outside}.markstream-vue li{margin:4px 0}.markstream-vue .paragraph-node{--at-apply:"body-16-regular leading-24px";margin:0}.markstream-vue>.node-slot[data-node-type=paragraph]:not(:first-child){margin-top:12px}@keyframes chat-stream-chunk-in{0%{opacity:0}to{opacity:1}}.markstream-vue .chat-stream-chunk{animation:chat-stream-chunk-in var(--chat-stream-fade-duration,.32s) cubic-bezier(.2,0,0,1) both}@media (prefers-reduced-motion:reduce){.markstream-vue .chat-stream-chunk{animation:none}}.markstream-vue .typewriter-cursor,.markstream-vue .typewriter-simple-cursor .typewriter-simple-cursor-target:after{display:none}.markstream-vue .my-8{margin-bottom:8px;margin-top:8px}.markstream-vue .heading-node.heading-1,.markstream-vue h1{--at-apply:"heading-h2-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-2,.markstream-vue h2{--at-apply:"heading-h3-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-3,.markstream-vue h3{--at-apply:"heading-h4-semibold text-primary";margin:20px 0 8px}.markstream-vue .heading-node.heading-4,.markstream-vue .heading-node.heading-5,.markstream-vue .heading-node.heading-6,.markstream-vue h4,.markstream-vue h5,.markstream-vue h6{--at-apply:"heading-h5-semibold text-primary";margin:16px 0 8px}.markstream-vue>.node-slot:first-child .heading-node{margin-top:0}.markstream-vue [class*=tooltip],[class*=tooltip-element]{background-color:var(--neutral-900,#1a1a1a)!important;border-radius:4px;color:#fff!important;font-size:12px;padding:4px 8px}.mention-tag{align-items:center;border-radius:9999px;display:inline-flex;font-size:.875em;font-weight:500;gap:4px;padding:2px 8px;vertical-align:middle;white-space:nowrap}.mention-tag-file{background-color:rgba(127,189,247,.12);color:#7fbdf7}.mention-tag-skill{background-color:rgba(155,111,222,.12);color:#9b6fde}.mention-tag-canvas{background-color:rgba(234,161,65,.12);color:#d4882e}.mention-tag>span[class*=i-ph]{flex-shrink:0}
1
+ .markstream-vue{font-family:inherit;--at-apply:"body-16-regular leading-24px text-primary";--list-item-marker:#a3a3a3;--list-item-counter-marker:#262626}.markstream-vue code,.markstream-vue kbd,.markstream-vue pre,.markstream-vue samp{font-family:Geist Mono Variable,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:13px;font-variation-settings:normal;letter-spacing:0;line-height:1.5}.markstream-vue ol,.markstream-vue ul{margin:8px 0;padding-left:1.5em}.markstream-vue .hr-node{border-color:#e5e5e5;border-top-width:.5px;margin-bottom:24px;margin-top:24px}.markstream-vue .strong-node{--at-apply:"font-580"}.markstream-vue .list-node.list-node{margin-bottom:8px;margin-left:4px;margin-top:8px;padding-left:1em}.markstream-vue ol.list-node.list-node{padding-left:3em}.markstream-vue ul{list-style:disc;list-style-position:outside}.markstream-vue ol{list-style:decimal;list-style-position:outside}.markstream-vue li{margin:4px 0}.markstream-vue .paragraph-node{--at-apply:"body-16-regular leading-24px";margin:0}.markstream-vue>.node-slot[data-node-type=paragraph]:not(:first-child){margin-top:12px}@keyframes chat-stream-chunk-in{0%{opacity:0}to{opacity:1}}.markstream-vue .chat-stream-chunk{animation:chat-stream-chunk-in var(--chat-stream-fade-duration,.32s) cubic-bezier(.2,0,0,1) both}@media (prefers-reduced-motion:reduce){.markstream-vue .chat-stream-chunk{animation:none}}.markstream-vue .typewriter-cursor,.markstream-vue .typewriter-simple-cursor .typewriter-simple-cursor-target:after{display:none}.markstream-vue .my-8{margin-bottom:8px;margin-top:8px}.markstream-vue .heading-node.heading-1,.markstream-vue h1{--at-apply:"heading-h2-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-2,.markstream-vue h2{--at-apply:"heading-h3-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-3,.markstream-vue h3{--at-apply:"heading-h4-semibold text-primary";margin:20px 0 8px}.markstream-vue .heading-node.heading-4,.markstream-vue .heading-node.heading-5,.markstream-vue .heading-node.heading-6,.markstream-vue h4,.markstream-vue h5,.markstream-vue h6{--at-apply:"heading-h5-semibold text-primary";margin:16px 0 8px}.markstream-vue>.node-slot:first-child .heading-node{margin-top:0}.markstream-vue [class*=tooltip],[class*=tooltip-element]{background-color:var(--neutral-900,#1a1a1a)!important;border-radius:4px;color:#fff!important;font-size:12px;padding:4px 8px}.mention-tag{align-items:center;border-radius:9999px;display:inline-flex;font-size:.875em;font-weight:500;gap:4px;padding:2px 8px;vertical-align:middle;white-space:nowrap}.mention-tag-file{background-color:rgba(127,189,247,.12);color:#7fbdf7}.mention-tag-skill{background-color:rgba(155,111,222,.12);color:#9b6fde}.mention-tag-canvas{background-color:rgba(234,161,65,.12);color:#d4882e}.mention-tag-generic{background-color:rgba(148,163,184,.14);color:#64748b}.mention-tag>span[class*=i-ph]{flex-shrink:0}
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
- import { inject } from "vue";
2
+ import { inject, provide } from "vue";
3
3
  import { CHAT_COMPOSER_EXTENSION_KEY } from "../../types/composer";
4
+ import { CHAT_MESSAGE_SEND_KEY } from "../../types/messages";
4
5
  import { useChatAuth } from "#chat-auth";
5
6
  import { useBreakpoints } from "@vueuse/core";
6
7
  import { useCitationPanel } from "../../citations/composables/citation-panel";
@@ -537,6 +538,13 @@ async function handleSend(content, files, agentInputs) {
537
538
  const prepared = composerExtension?.prepareSend?.(content);
538
539
  const outbound = prepared?.content ?? content;
539
540
  const resolvedAgentInputs = resolveTelaAgentInputs(agentInputs, embedConfig?.telaAgentInputs?.value);
541
+ const sent = await dispatchMessage(outbound, files, resolvedAgentInputs);
542
+ if (sent)
543
+ prepared?.commit?.();
544
+ if (!sent)
545
+ restoreQueuedSend({ content: prepared?.retryContent?.() ?? content, files, agentInputs: resolvedAgentInputs });
546
+ }
547
+ async function dispatchMessage(outbound, files, resolvedAgentInputs) {
540
548
  let claimedWakeSessionId;
541
549
  const sent = await sendMessage(outbound, files.length > 0 ? files : void 0, resolvedAgentInputs, async (signal) => {
542
550
  if (!activeConversationId.value || !currentConversation.value) {
@@ -586,10 +594,7 @@ async function handleSend(content, files, agentInputs) {
586
594
  });
587
595
  if (sent && claimedWakeSessionId)
588
596
  agentWake.consume();
589
- if (sent)
590
- prepared?.commit?.();
591
- if (!sent)
592
- restoreQueuedSend({ content: prepared?.retryContent?.() ?? content, files, agentInputs: resolvedAgentInputs });
597
+ return sent;
593
598
  }
594
599
  watch(
595
600
  [outboundOccupied, agentWake.state],
@@ -614,6 +619,14 @@ watch(
614
619
  void agentWake.wake(sessionId, selectedModel.value);
615
620
  }
616
621
  );
622
+ const sendChatMessageFromContent = async (content) => {
623
+ const trimmed = content.trim();
624
+ if (!trimmed || props.composerDisabled || composerLocked.value)
625
+ return false;
626
+ const resolvedAgentInputs = resolveTelaAgentInputs(void 0, embedConfig?.telaAgentInputs?.value);
627
+ return Boolean(await dispatchMessage(trimmed, [], resolvedAgentInputs));
628
+ };
629
+ provide(CHAT_MESSAGE_SEND_KEY, sendChatMessageFromContent);
617
630
  async function handleCancel() {
618
631
  await cancelConversation();
619
632
  }
@@ -1,9 +1,13 @@
1
1
  import type { MentionItem } from '../../types/schemas/chat/ui.js';
2
+ import type { ChatComposerCommand } from '../../types/composer.js';
2
3
  type MentionDropdownItem = MentionItem & {
3
4
  iconUrl?: string;
4
5
  };
5
6
  type __VLS_Props = {
6
7
  items: MentionDropdownItem[];
8
+ /** Host commands listed before the mention sections on the "/" trigger. */
9
+ commands?: readonly ChatComposerCommand[];
10
+ /** Indexes the combined [commands, items] list. */
7
11
  highlightedIndex: number;
8
12
  trigger: '@' | '/';
9
13
  query: string;
@@ -12,8 +16,10 @@ type __VLS_Props = {
12
16
  };
13
17
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
14
18
  select: (item: MentionItem) => any;
19
+ selectCommand: (command: ChatComposerCommand) => any;
15
20
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
16
21
  onSelect?: ((item: MentionItem) => any) | undefined;
22
+ onSelectCommand?: ((command: ChatComposerCommand) => any) | undefined;
17
23
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
18
24
  declare const _default: typeof __VLS_export;
19
25
  export default _default;
@@ -1,12 +1,13 @@
1
1
  <script setup>
2
2
  const props = defineProps({
3
3
  items: { type: Array, required: true },
4
+ commands: { type: Array, required: false },
4
5
  highlightedIndex: { type: Number, required: true },
5
6
  trigger: { type: String, required: true },
6
7
  query: { type: String, required: true },
7
8
  caretRect: { type: null, required: false }
8
9
  });
9
- const emit = defineEmits(["select"]);
10
+ const emit = defineEmits(["select", "selectCommand"]);
10
11
  const listRef = ref(null);
11
12
  const panelStyle = computed(() => {
12
13
  const rect = props.caretRect;
@@ -18,20 +19,24 @@ const panelStyle = computed(() => {
18
19
  bottom: `${window.innerHeight - rect.top + 4}px`
19
20
  };
20
21
  });
22
+ const commandEntries = computed(
23
+ () => (props.commands ?? []).map((command, i) => ({ command, globalIndex: i }))
24
+ );
21
25
  const sections = computed(() => {
26
+ const offset = commandEntries.value.length;
22
27
  if (props.trigger === "@") {
23
28
  return [{
24
29
  title: "Arquivos",
25
- items: props.items.map((item, i) => ({ item, globalIndex: i }))
30
+ items: props.items.map((item, i) => ({ item, globalIndex: i + offset }))
26
31
  }];
27
32
  }
28
33
  const canvasItems = [];
29
34
  const skillItems = [];
30
35
  props.items.forEach((item, i) => {
31
36
  if (item.category === "canvas")
32
- canvasItems.push({ item, globalIndex: i });
37
+ canvasItems.push({ item, globalIndex: i + offset });
33
38
  else
34
- skillItems.push({ item, globalIndex: i });
39
+ skillItems.push({ item, globalIndex: i + offset });
35
40
  });
36
41
  const result = [];
37
42
  if (canvasItems.length > 0)
@@ -40,6 +45,7 @@ const sections = computed(() => {
40
45
  result.push({ title: "Skills", items: skillItems });
41
46
  return result;
42
47
  });
48
+ const isEmpty = computed(() => props.items.length === 0 && commandEntries.value.length === 0);
43
49
  const emptyMessage = computed(() => {
44
50
  if (props.query)
45
51
  return "Nenhum resultado encontrado";
@@ -67,11 +73,37 @@ watch(() => props.highlightedIndex, () => {
67
73
  bg
68
74
  p-4px
69
75
  >
70
- <div v-if="items.length === 0" px-12px py-6px>
76
+ <div v-if="isEmpty" px-12px py-6px>
71
77
  <span body-14-regular text-tertiary>{{ emptyMessage }}</span>
72
78
  </div>
73
79
 
74
80
  <div v-else ref="listRef" flex="~ col">
81
+ <template v-if="commandEntries.length > 0">
82
+ <div px-12px pb-2px>
83
+ <span body-12-medium text-tertiary>Comandos</span>
84
+ </div>
85
+ <button
86
+ v-for="entry in commandEntries"
87
+ :key="entry.command.id"
88
+ type="button"
89
+ :data-highlighted="entry.globalIndex === highlightedIndex"
90
+ flex items-center gap-8px
91
+ w-full min-w-0
92
+ rounded-10px
93
+ px-12px py-4px
94
+ text-left
95
+ cursor-pointer select-none
96
+ outline-none
97
+ :class="entry.globalIndex === highlightedIndex ? 'bg-muted' : 'hover:bg-muted'"
98
+ @mousedown.prevent="emit('selectCommand', entry.command)"
99
+ >
100
+ <TelaIcon :name="entry.command.icon ?? 'i-ph-terminal-window'" size="18px" color="icon-secondary" shrink-0 />
101
+ <div flex="~ col" min-w-0>
102
+ <span body-12-medium text-primary truncate>/{{ entry.command.id }}</span>
103
+ <span body-10-regular leading-12px text-tertiary truncate>{{ entry.command.description ?? entry.command.label }}</span>
104
+ </div>
105
+ </button>
106
+ </template>
75
107
  <template v-for="section in sections" :key="section.title">
76
108
  <div px-12px pb-2px>
77
109
  <span body-12-medium text-tertiary>{{ section.title }}</span>
@@ -1,9 +1,13 @@
1
1
  import type { MentionItem } from '../../types/schemas/chat/ui.js';
2
+ import type { ChatComposerCommand } from '../../types/composer.js';
2
3
  type MentionDropdownItem = MentionItem & {
3
4
  iconUrl?: string;
4
5
  };
5
6
  type __VLS_Props = {
6
7
  items: MentionDropdownItem[];
8
+ /** Host commands listed before the mention sections on the "/" trigger. */
9
+ commands?: readonly ChatComposerCommand[];
10
+ /** Indexes the combined [commands, items] list. */
7
11
  highlightedIndex: number;
8
12
  trigger: '@' | '/';
9
13
  query: string;
@@ -12,8 +16,10 @@ type __VLS_Props = {
12
16
  };
13
17
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
14
18
  select: (item: MentionItem) => any;
19
+ selectCommand: (command: ChatComposerCommand) => any;
15
20
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
16
21
  onSelect?: ((item: MentionItem) => any) | undefined;
22
+ onSelectCommand?: ((command: ChatComposerCommand) => any) | undefined;
17
23
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
18
24
  declare const _default: typeof __VLS_export;
19
25
  export default _default;
@@ -2,6 +2,8 @@ import type { Message } from '../../types/schemas/chat/conversations.js';
2
2
  type __VLS_Props = {
3
3
  message: Message;
4
4
  streamActive?: boolean;
5
+ /** True while this is the newest message in the conversation. */
6
+ isLatestMessage?: boolean;
5
7
  /**
6
8
  * Stand-in steps shown while this turn is still working towards its first
7
9
  * output. They yield as soon as the run emits real reasoning or content.
@@ -1,14 +1,17 @@
1
1
  <script setup>
2
2
  import { useChatAuth } from "#chat-auth";
3
3
  import { useEmbedConfig } from "../../embed/composables/embed-config";
4
+ import { CHAT_MESSAGE_CONTEXT_KEY } from "../../types/messages";
4
5
  import { useWorkspaceMembers } from "../../workspace/composables/workspace-members";
5
6
  import { isPromptTooLongFailure } from "../../types/schemas/chat/messages.js";
6
7
  import { linkifyBareCitations } from "../../citations/citation-links";
7
8
  import { parseContentWithFiles } from "../../files/content-detection";
9
+ import { hasUserMentionTags, renderEscapedUserMentions } from "../user-mentions";
8
10
  import ChatVaultLink from "../../files/components/chat-vault-link.vue";
9
11
  const props = defineProps({
10
12
  message: { type: null, required: true },
11
13
  streamActive: { type: Boolean, required: false },
14
+ isLatestMessage: { type: Boolean, required: false },
12
15
  preparingSteps: { type: [Array, null], required: false },
13
16
  showTtft: { type: Boolean, required: false, default: true }
14
17
  });
@@ -17,6 +20,12 @@ defineSlots();
17
20
  const { user } = useChatAuth();
18
21
  const { getMemberByEmail } = useWorkspaceMembers();
19
22
  const embedConfig = useEmbedConfig();
23
+ provide(CHAT_MESSAGE_CONTEXT_KEY, {
24
+ messageId: props.message.id,
25
+ role: props.message.role,
26
+ isLatestMessage: computed(() => props.isLatestMessage === true),
27
+ streaming: computed(() => props.streamActive === true)
28
+ });
20
29
  const mergedCustomComponents = computed(() => ({
21
30
  link: ChatVaultLink,
22
31
  ...embedConfig?.customComponents.value ?? {}
@@ -67,24 +76,11 @@ const displayContent = computed(() => {
67
76
  const userContentHtml = computed(() => {
68
77
  if (props.message.role !== "user")
69
78
  return "";
70
- const raw = displayContent.value;
71
- const escaped = raw.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
72
- return escaped.replace(/&lt;file_mention name=&quot;((?:[^&]|&(?!quot;))+)&quot;[\s\S]*?&lt;\/file_mention&gt;/g, (_match, encodedName) => {
73
- const name = encodedName.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
74
- const safeName = name.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
75
- return `<span class="mention-tag mention-tag-file"><span class="i-ph-note-blank" aria-hidden="true"></span>${safeName}</span>`;
76
- }).replace(/&lt;canvas_mention name=&quot;((?:[^&]|&(?!quot;))+)&quot;[\s\S]*?&lt;\/canvas_mention&gt;/g, (_match, encodedName) => {
77
- const name = encodedName.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
78
- const safeName = name.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
79
- return `<span class="mention-tag mention-tag-canvas"><span class="i-ph-wrench" aria-hidden="true"></span>${safeName}</span>`;
80
- }).replace(/&lt;skill_mention name=&quot;((?:[^&]|&(?!quot;))+)&quot;[\s\S]*?&lt;\/skill_mention&gt;/g, (_match, encodedName) => {
81
- const name = encodedName.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
82
- const safeName = name.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
83
- return `<span class="mention-tag mention-tag-skill"><span class="i-ph-hexagon" aria-hidden="true"></span>${safeName}</span>`;
84
- });
79
+ const escaped = displayContent.value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
80
+ return renderEscapedUserMentions(escaped);
85
81
  });
86
82
  const hasUserMentions = computed(
87
- () => props.message.role === "user" && (displayContent.value.includes("<file_mention") || displayContent.value.includes("<canvas_mention") || displayContent.value.includes("<skill_mention"))
83
+ () => props.message.role === "user" && hasUserMentionTags(displayContent.value)
88
84
  );
89
85
  const formattedTtft = computed(() => {
90
86
  if (props.message.role !== "assistant" || !props.message.ttft)
@@ -2,6 +2,8 @@ import type { Message } from '../../types/schemas/chat/conversations.js';
2
2
  type __VLS_Props = {
3
3
  message: Message;
4
4
  streamActive?: boolean;
5
+ /** True while this is the newest message in the conversation. */
6
+ isLatestMessage?: boolean;
5
7
  /**
6
8
  * Stand-in steps shown while this turn is still working towards its first
7
9
  * output. They yield as soon as the run emits real reasoning or content.
@@ -115,10 +115,12 @@ const skillItems = computed(() => {
115
115
  }
116
116
  return items;
117
117
  });
118
+ const commandItems = computed(() => composerExtension?.commands ?? []);
118
119
  const mentionAutocomplete = useMentionAutocomplete({
119
120
  editorRef,
120
121
  fileItems,
121
122
  skillItems,
123
+ commandItems,
122
124
  disabled: isLocked,
123
125
  onInsert: handleInput
124
126
  });
@@ -657,11 +659,13 @@ defineExpose({
657
659
  <ChatMentionDropdown
658
660
  v-if="mentionAutocomplete.isOpen"
659
661
  :items="mentionAutocomplete.filteredItems"
662
+ :commands="mentionAutocomplete.filteredCommands"
660
663
  :highlighted-index="mentionAutocomplete.highlightedIndex"
661
664
  :trigger="mentionAutocomplete.activeTrigger"
662
665
  :query="mentionAutocomplete.query"
663
666
  :caret-rect="mentionAutocomplete.triggerCaretRect"
664
667
  @select="mentionAutocomplete.selectItem"
668
+ @select-command="mentionAutocomplete.runCommand"
665
669
  />
666
670
 
667
671
  <TelaChatPromptInput :locked="promptShellLocked" :class="centered && 'bg-white'">
@@ -40,6 +40,7 @@ const skeletonMessages = [
40
40
  { id: "assistant-2", align: "start", width: "84%", height: "92px" }
41
41
  ];
42
42
  const timelineItems = computed(() => mergeConversationTimeline(props.messages, props.events));
43
+ const latestMessageId = computed(() => props.messages.at(-1)?.id ?? null);
43
44
  const latestOutputSignal = computed(() => {
44
45
  const latestMessage = props.messages.at(-1);
45
46
  const latestEvent = props.events?.at(-1);
@@ -104,6 +105,7 @@ defineExpose({
104
105
  v-else
105
106
  :message="item.value"
106
107
  :stream-active="item.id === streamingActiveMessageId"
108
+ :is-latest-message="item.id === latestMessageId"
107
109
  :preparing-steps="item.value.role === 'assistant' && item.value.status === 'pending' ? preparingSteps : null"
108
110
  :show-ttft="showTtft"
109
111
  @retry="emit('retry', $event)"
@@ -1,8 +1,10 @@
1
1
  import type { MentionItem } from '../../types/schemas/chat/ui.js';
2
+ import type { ChatComposerCommand } from '../../types/composer.js';
2
3
  type UseMentionAutocompleteOptions = {
3
4
  editorRef: Ref<HTMLElement | null>;
4
5
  fileItems: ComputedRef<MentionItem[]>;
5
6
  skillItems: ComputedRef<MentionItem[]>;
7
+ commandItems?: ComputedRef<readonly ChatComposerCommand[]>;
6
8
  disabled?: Ref<boolean | undefined>;
7
9
  onInsert?: () => void;
8
10
  };
@@ -29,7 +29,7 @@ export function appendAttachmentMentions(content, files) {
29
29
  return [content, ...tags].filter(Boolean).join("\n");
30
30
  }
31
31
  export function useMentionAutocomplete(options) {
32
- const { editorRef, fileItems, skillItems, disabled, onInsert } = options;
32
+ const { editorRef, fileItems, skillItems, commandItems, disabled, onInsert } = options;
33
33
  const activeTrigger = ref(null);
34
34
  const query = ref("");
35
35
  const highlightedIndex = ref(-1);
@@ -48,6 +48,17 @@ export function useMentionAutocomplete(options) {
48
48
  (item) => item.label.toLowerCase().includes(q) || item.description?.toLowerCase().includes(q)
49
49
  );
50
50
  });
51
+ const filteredCommands = computed(() => {
52
+ if (activeTrigger.value !== "/")
53
+ return [];
54
+ const commands = commandItems?.value ?? [];
55
+ if (!query.value)
56
+ return commands;
57
+ const q = query.value.toLowerCase();
58
+ return commands.filter(
59
+ (command) => command.id.toLowerCase().includes(q) || command.label.toLowerCase().includes(q) || command.description?.toLowerCase().includes(q)
60
+ );
61
+ });
51
62
  function getTextBeforeCursor() {
52
63
  const sel = window.getSelection();
53
64
  if (!sel || sel.rangeCount === 0 || !editorRef.value)
@@ -100,14 +111,15 @@ export function useMentionAutocomplete(options) {
100
111
  triggerCaretRect.value = null;
101
112
  return;
102
113
  }
114
+ const wasOpen = activeTrigger.value !== null;
115
+ const queryChanged = mentionTrigger.query !== lastQuery.value;
103
116
  activeTrigger.value = mentionTrigger.trigger;
104
117
  triggerTextOffset = mentionTrigger.offset;
105
118
  triggerCaretRect.value = getCaretRangeAtOffset(triggerTextOffset)?.getBoundingClientRect() ?? null;
106
- if (mentionTrigger.query !== lastQuery.value) {
107
- highlightedIndex.value = -1;
108
- lastQuery.value = mentionTrigger.query;
109
- }
119
+ lastQuery.value = mentionTrigger.query;
110
120
  query.value = mentionTrigger.query;
121
+ if (!wasOpen || queryChanged)
122
+ highlightedIndex.value = filteredCommands.value.length > 0 ? 0 : -1;
111
123
  }
112
124
  function getCaretRangeAtOffset(offset) {
113
125
  const editor = editorRef.value;
@@ -151,23 +163,35 @@ export function useMentionAutocomplete(options) {
151
163
  }
152
164
  return walkNode(editor);
153
165
  }
154
- function selectItem(item) {
166
+ function consumeTriggerText() {
155
167
  if (!editorRef.value || triggerTextOffset < 0)
156
- return;
168
+ return null;
157
169
  const sel = window.getSelection();
158
170
  if (!sel || sel.rangeCount === 0)
159
- return;
160
- const textBeforeCursor = getTextBeforeCursor();
161
- const charBeforeTrigger = triggerTextOffset > 0 ? textBeforeCursor[triggerTextOffset - 1] : null;
162
- const needsSpaceBefore = charBeforeTrigger !== null && charBeforeTrigger !== " " && charBeforeTrigger !== "\n" && charBeforeTrigger !== "\xA0";
171
+ return null;
172
+ const trigger = findMentionTrigger(getTextBeforeCursor());
173
+ if (!trigger || trigger.offset !== triggerTextOffset)
174
+ return null;
163
175
  const triggerRange = getCaretRangeAtOffset(triggerTextOffset);
164
176
  if (!triggerRange)
165
- return;
177
+ return null;
166
178
  const currentRange = sel.getRangeAt(0);
167
179
  const deleteRange = document.createRange();
168
180
  deleteRange.setStart(triggerRange.startContainer, triggerRange.startOffset);
169
181
  deleteRange.setEnd(currentRange.startContainer, currentRange.startOffset);
170
182
  deleteRange.deleteContents();
183
+ return deleteRange;
184
+ }
185
+ function selectItem(item) {
186
+ const textBeforeCursor = getTextBeforeCursor();
187
+ const charBeforeTrigger = triggerTextOffset > 0 ? textBeforeCursor[triggerTextOffset - 1] : null;
188
+ const needsSpaceBefore = charBeforeTrigger !== null && charBeforeTrigger !== " " && charBeforeTrigger !== "\n" && charBeforeTrigger !== "\xA0";
189
+ const deleteRange = consumeTriggerText();
190
+ const sel = window.getSelection();
191
+ if (!deleteRange || !sel) {
192
+ dismiss();
193
+ return;
194
+ }
171
195
  const span = document.createElement("span");
172
196
  span.className = `mention-tag mention-tag-${item.category}`;
173
197
  span.contentEditable = "false";
@@ -202,6 +226,27 @@ export function useMentionAutocomplete(options) {
202
226
  triggerTextOffset = -1;
203
227
  onInsert?.();
204
228
  }
229
+ function runCommand(command) {
230
+ const deleteRange = consumeTriggerText();
231
+ if (!deleteRange) {
232
+ dismiss();
233
+ return;
234
+ }
235
+ const sel = window.getSelection();
236
+ if (sel) {
237
+ const caret = document.createRange();
238
+ caret.setStart(deleteRange.startContainer, deleteRange.startOffset);
239
+ caret.collapse(true);
240
+ sel.removeAllRanges();
241
+ sel.addRange(caret);
242
+ }
243
+ activeTrigger.value = null;
244
+ lastQuery.value = "";
245
+ highlightedIndex.value = -1;
246
+ triggerTextOffset = -1;
247
+ onInsert?.();
248
+ command.run();
249
+ }
205
250
  function buildTaggedContent() {
206
251
  if (!editorRef.value)
207
252
  return "";
@@ -343,7 +388,8 @@ ${varLines}
343
388
  dismiss();
344
389
  return;
345
390
  }
346
- const count = filteredItems.value.length;
391
+ const commandCount = filteredCommands.value.length;
392
+ const count = commandCount + filteredItems.value.length;
347
393
  if (count === 0)
348
394
  return;
349
395
  if (e.key === "ArrowDown") {
@@ -357,14 +403,16 @@ ${varLines}
357
403
  return;
358
404
  }
359
405
  if (e.key === "Enter") {
360
- const item = filteredItems.value[highlightedIndex.value];
361
- if (item) {
362
- e.preventDefault();
406
+ const index = highlightedIndex.value;
407
+ const command = index >= 0 ? filteredCommands.value[index] : void 0;
408
+ const item = index >= commandCount ? filteredItems.value[index - commandCount] : void 0;
409
+ e.preventDefault();
410
+ if (command)
411
+ runCommand(command);
412
+ else if (item)
363
413
  selectItem(item);
364
- } else {
365
- e.preventDefault();
414
+ else
366
415
  dismiss();
367
- }
368
416
  }
369
417
  }
370
418
  return reactive({
@@ -373,9 +421,11 @@ ${varLines}
373
421
  query: readonly(query),
374
422
  triggerCaretRect: readonly(triggerCaretRect),
375
423
  filteredItems,
424
+ filteredCommands,
376
425
  highlightedIndex,
377
426
  handleKeydown,
378
427
  selectItem,
428
+ runCommand,
379
429
  dismiss,
380
430
  detectTrigger,
381
431
  buildTaggedContent,
@@ -0,0 +1,8 @@
1
+ export declare function hasUserMentionTags(content: string): boolean;
2
+ /**
3
+ * Replaces `<*_mention name="...">...</...>` tags in already HTML-escaped user
4
+ * content with display pills, hiding the agent-facing instruction body. Known
5
+ * categories keep their icon and color; any other mention renders as a neutral
6
+ * pill, so host-defined mentions (e.g. template_mention) degrade gracefully.
7
+ */
8
+ export declare function renderEscapedUserMentions(escaped: string): string;
@@ -0,0 +1,20 @@
1
+ const MENTION_ICONS = {
2
+ file: "i-ph-note-blank",
3
+ canvas: "i-ph-wrench",
4
+ skill: "i-ph-hexagon"
5
+ };
6
+ const DEFAULT_MENTION_ICON = "i-ph-paperclip";
7
+ const ESCAPED_MENTION_TAG = /&lt;([a-z][a-z0-9-]*)_mention name=&quot;((?:[^&]|&(?!quot;))+)&quot;[\s\S]*?&lt;\/\1_mention&gt;/g;
8
+ export function hasUserMentionTags(content) {
9
+ return /<[a-z][a-z0-9-]*_mention\s/.test(content);
10
+ }
11
+ export function renderEscapedUserMentions(escaped) {
12
+ return escaped.replace(ESCAPED_MENTION_TAG, (_match, category, encodedName) => {
13
+ const name = encodedName.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
14
+ const safeName = name.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
15
+ const known = category in MENTION_ICONS;
16
+ const icon = known ? MENTION_ICONS[category] : DEFAULT_MENTION_ICON;
17
+ const categoryClass = known ? `mention-tag-${category}` : "mention-tag-generic";
18
+ return `<span class="mention-tag ${categoryClass}"><span class="${icon}" aria-hidden="true"></span>${safeName}</span>`;
19
+ });
20
+ }
@@ -9,6 +9,17 @@ export interface ChatPreparedSend {
9
9
  /** Read after rejection. Defaults to the original composer text when omitted. */
10
10
  retryContent?: () => string;
11
11
  }
12
+ /** Local action listed under "Comandos" in the composer's "/" dropdown. */
13
+ export interface ChatComposerCommand {
14
+ /** Name matched against the text typed after "/", e.g. `templates`. */
15
+ id: string;
16
+ label: string;
17
+ description?: string;
18
+ /** Icon class rendered in the dropdown. Defaults to `i-ph-terminal-window`. */
19
+ icon?: string;
20
+ /** Runs after the typed "/query" is removed from the draft. */
21
+ run: () => void;
22
+ }
12
23
  /** Provide above MeistrariChatEmbed to extend its composer without replacing it. */
13
24
  export interface ChatComposerExtension {
14
25
  /** Action next to the file menu. Receives the composer's `disabled` state. */
@@ -19,4 +30,6 @@ export interface ChatComposerExtension {
19
30
  hasAttachments?: MaybeRef<boolean>;
20
31
  /** Prepare without consuming host state; commit after the send is accepted. */
21
32
  prepareSend?: (content: string) => ChatPreparedSend | null | undefined;
33
+ /** Host commands offered alongside skills when the user types "/". */
34
+ commands?: readonly ChatComposerCommand[];
22
35
  }
@@ -0,0 +1,21 @@
1
+ import type { Ref } from 'vue';
2
+ /** Stable string keys, also compatible with hosts injecting the literal key. */
3
+ export declare const CHAT_MESSAGE_SEND_KEY = "chat-message-send";
4
+ export declare const CHAT_MESSAGE_CONTEXT_KEY = "chat-message-context";
5
+ /**
6
+ * Provided by the embed to components rendered inside message content
7
+ * (`customComponents`). Sends `content` as the user through the same path as
8
+ * the composer, resolving the conversation's configured agent inputs — a
9
+ * direct `useChat().sendMessage` call would skip that resolution. Resolves
10
+ * `true` once the message is accepted (including into the outbound queue).
11
+ */
12
+ export type ChatMessageSend = (content: string) => Promise<boolean>;
13
+ /** Provided per message bubble to components rendered inside its content. */
14
+ export interface ChatMessageContext {
15
+ messageId: string;
16
+ role: 'user' | 'assistant';
17
+ /** True while this is the newest message in the conversation. */
18
+ isLatestMessage: Readonly<Ref<boolean>>;
19
+ /** True while this message's turn is still streaming. */
20
+ streaming: Readonly<Ref<boolean>>;
21
+ }
@@ -0,0 +1,2 @@
1
+ export const CHAT_MESSAGE_SEND_KEY = "chat-message-send";
2
+ export const CHAT_MESSAGE_CONTEXT_KEY = "chat-message-context";
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
- "version": "4.0.1",
4
- "gitHead": "37731629442568e674b877312c60beb9502d583f",
3
+ "version": "4.1.0-rc.0",
5
4
  "type": "module",
6
5
  "exports": {
7
6
  ".": {