@jskit-ai/assistant-core 0.1.171 → 0.1.172

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 (29) hide show
  1. package/examples/conversation/App.vue +62 -10
  2. package/examples/conversation/README.md +29 -4
  3. package/examples/conversation/attachments.js +45 -0
  4. package/examples/conversation/integrations.json +28 -0
  5. package/examples/conversation/package.json +6 -4
  6. package/examples/conversation/server.js +61 -10
  7. package/fixtures/responsive-assistant/ConversationFixture.vue +54 -1
  8. package/package.json +21 -7
  9. package/src/client/conversation/AssistantAttachmentPreview.vue +38 -0
  10. package/src/client/conversation/AssistantAttachmentQueue.vue +563 -0
  11. package/src/client/conversation/AssistantComposerSupport.vue +235 -0
  12. package/src/client/conversation/AssistantConversationElement.vue +99 -8
  13. package/src/client/conversation/AssistantGoalControl.vue +81 -0
  14. package/src/client/conversation/AssistantMessageAttachments.vue +114 -0
  15. package/src/client/conversation/AssistantModelControl.vue +334 -0
  16. package/src/client/conversation/AssistantQuestionInputs.vue +124 -0
  17. package/src/client/conversation/AssistantTranscript.vue +2 -1
  18. package/src/client/conversation/index.js +9 -0
  19. package/src/client/conversation/useAssistantAttachments.js +991 -0
  20. package/src/client/conversation/useAssistantSuggestions.js +99 -0
  21. package/src/server/index.js +1 -0
  22. package/src/server/lib/aiConnectionClient.js +90 -0
  23. package/src/server/lib/providers/anthropicClient.js +6 -21
  24. package/src/shared/assistantResource.js +7 -0
  25. package/src/shared/conversation/attachments.js +13 -0
  26. package/src/shared/conversation/index.js +1 -0
  27. package/test/aiConnectionClient.test.js +159 -0
  28. package/test/assistantScroll.browser.test.js +144 -0
  29. package/test/suggestions.test.js +114 -0
@@ -0,0 +1,235 @@
1
+ <template>
2
+ <section
3
+ class="assistant-composer-support"
4
+ :class="`assistant-composer-support--${mode}`"
5
+ data-assistant-composer-support
6
+ @focusout="$emit('focusout', $event)"
7
+ @keydown.esc.stop.prevent="$emit('dismiss')"
8
+ >
9
+ <span
10
+ :id="statusId || undefined"
11
+ aria-atomic="true"
12
+ aria-live="polite"
13
+ class="assistant-composer-support__sr-status"
14
+ role="status"
15
+ >{{ statusAnnouncement }}</span>
16
+
17
+ <div
18
+ v-if="mode === 'assistant'"
19
+ aria-hidden="true"
20
+ class="assistant-composer-support__assistant-status"
21
+ >
22
+ <slot name="activity" :activity="activity">
23
+ <span v-if="activity.animated !== false" class="assistant-composer-support__assistant-mark" />
24
+ <span>{{ activity.label }}</span>
25
+ </slot>
26
+ </div>
27
+
28
+ <template v-else-if="mode !== 'hidden'">
29
+ <v-icon
30
+ aria-hidden="true"
31
+ class="assistant-composer-support__marker"
32
+ :icon="mdiLightbulbOnOutline"
33
+ size="16"
34
+ />
35
+
36
+ <div
37
+ v-if="mode === 'loading'"
38
+ aria-hidden="true"
39
+ class="assistant-composer-support__loading"
40
+ >
41
+ <span>Thinking of a few ideas</span>
42
+ </div>
43
+
44
+ <div
45
+ v-else
46
+ class="assistant-composer-support__options"
47
+ aria-label="Suggested prompts"
48
+ aria-orientation="horizontal"
49
+ role="group"
50
+ >
51
+ <v-btn
52
+ v-for="suggestion in suggestions"
53
+ :key="suggestion.prompt"
54
+ :aria-label="`Use suggestion: ${suggestion.prompt}`"
55
+ class="assistant-composer-support__option"
56
+ rounded="xl"
57
+ size="small"
58
+ type="button"
59
+ variant="tonal"
60
+ @blur="$emit('preview', null)"
61
+ @mousedown.prevent
62
+ @focus="$emit('preview', suggestion)"
63
+ @mouseenter="$emit('preview', suggestion)"
64
+ @mouseleave="$emit('preview', null)"
65
+ @click="$emit('select', suggestion)"
66
+ >
67
+ <span>{{ suggestion.label }}</span>
68
+ </v-btn>
69
+ </div>
70
+ </template>
71
+ </section>
72
+ </template>
73
+
74
+ <script setup>
75
+ import { computed } from "vue";
76
+ import { mdiLightbulbOnOutline } from "@mdi/js";
77
+
78
+ defineEmits(["dismiss", "focusout", "preview", "select"]);
79
+ const props = defineProps({
80
+ activity: { type: Object, default: () => ({}) },
81
+ loading: {
82
+ default: false,
83
+ type: Boolean
84
+ },
85
+ statusId: {
86
+ default: "",
87
+ type: String
88
+ },
89
+ suggestions: {
90
+ default: () => [],
91
+ type: Array
92
+ }
93
+ });
94
+
95
+ const mode = computed(() => {
96
+ if (String(props.activity.label || "").trim()) {
97
+ return "assistant";
98
+ }
99
+ if (props.loading) {
100
+ return "loading";
101
+ }
102
+ return props.suggestions.length > 0 ? "ready" : "hidden";
103
+ });
104
+ const statusAnnouncement = computed(() => {
105
+ if (mode.value === "assistant") {
106
+ return String(props.activity.label || "").trim();
107
+ }
108
+ if (mode.value === "loading") {
109
+ return "Thinking of a few ideas.";
110
+ }
111
+ if (mode.value === "ready") {
112
+ return `${props.suggestions.length} suggested prompts are available before the message controls.`;
113
+ }
114
+ return "";
115
+ });
116
+ </script>
117
+
118
+ <style scoped>
119
+ .assistant-composer-support {
120
+ align-items: center;
121
+ box-sizing: border-box;
122
+ color: rgba(var(--v-theme-on-surface), 0.68);
123
+ display: grid;
124
+ grid-template-columns: 1.25rem minmax(0, 1fr);
125
+ height: 2.25rem;
126
+ max-width: 100%;
127
+ min-height: 2.25rem;
128
+ min-width: 0;
129
+ overflow: hidden;
130
+ padding-inline: 0.2rem;
131
+ width: 100%;
132
+ }
133
+
134
+ .assistant-composer-support__sr-status {
135
+ block-size: 1px;
136
+ clip-path: inset(50%);
137
+ inline-size: 1px;
138
+ overflow: hidden;
139
+ position: absolute;
140
+ white-space: nowrap;
141
+ }
142
+
143
+ .assistant-composer-support__marker {
144
+ justify-self: center;
145
+ }
146
+
147
+ .assistant-composer-support__assistant-status,
148
+ .assistant-composer-support__loading {
149
+ align-items: center;
150
+ display: flex;
151
+ }
152
+
153
+ .assistant-composer-support__assistant-status {
154
+ font-size: 0.78rem;
155
+ gap: 0.45rem;
156
+ grid-column: 1 / -1;
157
+ line-height: 1.35;
158
+ min-width: 0;
159
+ overflow-wrap: anywhere;
160
+ padding-inline: 0.55rem;
161
+ }
162
+
163
+ .assistant-composer-support__assistant-mark {
164
+ animation: assistant-composer-support-pulse 1.2s ease-in-out infinite;
165
+ background: rgb(var(--v-theme-primary));
166
+ border-radius: 50%;
167
+ height: 0.42rem;
168
+ width: 0.42rem;
169
+ }
170
+
171
+ .assistant-composer-support__loading {
172
+ font-size: 0.76rem;
173
+ grid-column: 2;
174
+ min-width: 0;
175
+ padding-inline: 0.5rem;
176
+ }
177
+
178
+ .assistant-composer-support__options {
179
+ align-items: center;
180
+ box-sizing: border-box;
181
+ display: flex;
182
+ gap: 0.35rem;
183
+ grid-column: 2;
184
+ height: 100%;
185
+ min-width: 0;
186
+ overflow-x: auto;
187
+ overflow-y: hidden;
188
+ overscroll-behavior-inline: contain;
189
+ padding: 0.1rem 0.2rem;
190
+ scroll-snap-type: inline proximity;
191
+ scrollbar-width: none;
192
+ width: 100%;
193
+ }
194
+
195
+ .assistant-composer-support__options::-webkit-scrollbar {
196
+ display: none;
197
+ }
198
+
199
+ .assistant-composer-support__option {
200
+ color: rgb(var(--v-theme-on-surface));
201
+ flex: 0 0 auto;
202
+ font-size: 0.75rem;
203
+ font-weight: 500;
204
+ letter-spacing: 0;
205
+ line-height: 1.25;
206
+ min-width: 0;
207
+ padding-inline: 0.65rem;
208
+ scroll-snap-align: start;
209
+ text-transform: none;
210
+ }
211
+
212
+ .assistant-composer-support__option span {
213
+ white-space: nowrap;
214
+ }
215
+
216
+ @keyframes assistant-composer-support-pulse {
217
+ 0%,
218
+ 100% {
219
+ opacity: 0.35;
220
+ transform: scale(0.8);
221
+ }
222
+ 50% {
223
+ opacity: 1;
224
+ transform: scale(1);
225
+ }
226
+ }
227
+
228
+ @media (prefers-reduced-motion: reduce) {
229
+ .assistant-composer-support__assistant-mark {
230
+ animation: none;
231
+ opacity: 1;
232
+ transform: none;
233
+ }
234
+ }
235
+ </style>
@@ -8,18 +8,64 @@
8
8
  >
9
9
  <template v-for="name in transcriptSlots" #[name]="scope"><slot :name="name" v-bind="scope" /></template>
10
10
  </AssistantTranscript>
11
+ <div v-if="adapter.goal && adapter.goal.enabled !== false" class="assistant-conversation__goal">
12
+ <AssistantGoalControl :state="adapter.goal" />
13
+ </div>
14
+ <AssistantComposerSupport
15
+ v-if="!$slots.hints && (adapter.composer || activity.label || adapter.suggestions)"
16
+ :activity="activity" :status-id="statusId"
17
+ :loading="adapter.suggestions?.visible && adapter.suggestions.loading"
18
+ :suggestions="adapter.suggestions?.visible ? adapter.suggestions.items : []"
19
+ @preview="adapter.suggestions?.previewSuggestion($event)"
20
+ @select="selectSuggestion" @dismiss="dismissSuggestions"
21
+ >
22
+ <template v-if="$slots.activity" #activity="scope"><slot name="activity" v-bind="scope" /></template>
23
+ </AssistantComposerSupport>
11
24
  <slot name="hints" :adapter="adapter" />
12
25
  <slot name="composer" :adapter="adapter">
13
26
  <AssistantPromptInput
14
27
  v-if="adapter.composer" ref="input" v-bind="adapter.composer" class="assistant-conversation__composer"
15
- :model-value="adapter.composer.draft || ''" :submit-enabled="adapter.composer.canSend"
28
+ :model-value="adapter.composer.draft || ''" :submit-enabled="canSend"
16
29
  :aria-label="adapter.composer.ariaLabel || 'Message AI assistant'" :rows="adapter.composer.rows || 2"
30
+ :described-by="activity.label || adapter.suggestions?.visible ? statusId : adapter.composer.describedBy"
31
+ :placeholder="suggestionPreview || adapter.composer.placeholder" :placeholder-affects-height="!suggestionPreview"
32
+ :attachment-state="attachmentState" :dragging="attachmentsEnabled && adapter.attachments.dragActive"
17
33
  tab-to-submit @update:model-value="adapter.actions.setDraft($event)"
18
34
  @submit="submit" @tab-to-submit="delivery?.focus()"
35
+ @focus="adapter.suggestions?.focus()" @blur="adapter.suggestions?.blur()"
36
+ @escape="dismissSuggestions"
37
+ @dragenter="attachmentEvent('handleDragEnter', $event)" @dragover="attachmentEvent('handleDragOver', $event)"
38
+ @dragleave="attachmentEvent('handleDragLeave', $event)" @drop="attachmentEvent('handleDrop', $event)"
39
+ @paste="attachmentEvent('handlePaste', $event)"
19
40
  >
20
- <template v-if="$slots['input-start']" #input-start><slot name="input-start" :adapter="adapter" /></template>
41
+ <template v-if="attachmentsEnabled" #attachments>
42
+ <input ref="fileInput" hidden multiple type="file" :accept="adapter.attachments.accept" :disabled="!adapter.attachments.canAddFiles" @change="attachSelectedFiles">
43
+ <AssistantAttachmentQueue
44
+ :items="adapter.attachments.queueItems" :preview-enabled="Boolean(adapter.attachments.open)"
45
+ :maximum-files="adapter.attachments.maxItems"
46
+ @cancel="adapter.attachments.cancelAttachment($event)" @remove="adapter.attachments.removeAttachment($event)"
47
+ @retry="adapter.attachments.retryAttachment($event)" @preview="adapter.attachments.open($event)" @focus-input="input?.focus()"
48
+ />
49
+ </template>
50
+ <template v-if="adapter.questions || $slots['input-start']" #input-start>
51
+ <slot name="input-start" :adapter="adapter">
52
+ <AssistantQuestionInputs
53
+ v-bind="adapter.questions"
54
+ @update:answers="adapter.questions.setAnswers($event)" @update:choice="adapter.questions.setChoice($event)" @dismiss="adapter.questions.dismiss()"
55
+ />
56
+ </slot>
57
+ </template>
21
58
  <template #footer>
22
- <AssistantComposerActions ref="delivery" :state="adapter.composer" @submit="submit" @stop="stop">
59
+ <AssistantComposerActions ref="delivery" :state="{ ...adapter.composer, canSend }" @submit="submit" @stop="stop">
60
+ <AssistantModelControl
61
+ v-if="adapter.models && adapter.models.enabled !== false" v-model="modelsOpen" v-bind="adapter.models"
62
+ @select-provider="adapter.models.selectProvider?.($event)" @select-model="adapter.models.selectModel?.($event)"
63
+ @select-variant="adapter.models.selectVariant?.($event)" @apply="applyModel" @reload="adapter.models.reload?.()"
64
+ />
65
+ <v-btn
66
+ v-if="attachmentsEnabled" aria-label="Attach files" title="Attach files" size="small" variant="text" :icon="mdiPaperclip"
67
+ :disabled="!adapter.attachments.canAddFiles" @click="fileInput?.click()"
68
+ />
23
69
  <slot
24
70
  v-if="configurationMode !== 'hidden'" name="configuration"
25
71
  :configuration="configuration" :disabled="configurationMode !== 'editable' || adapter.composer.pending"
@@ -34,7 +80,10 @@
34
80
  />
35
81
  </slot>
36
82
  <slot name="composer-tools" :adapter="adapter" />
37
- <template #feedback><slot name="composer-feedback" :adapter="adapter" /></template>
83
+ <template #feedback>
84
+ <p v-if="attachmentsEnabled && adapter.attachments.status" role="alert" class="assistant-conversation__attachment-error">{{ adapter.attachments.status }}</p>
85
+ <slot name="composer-feedback" :adapter="adapter" />
86
+ </template>
38
87
  </AssistantComposerActions>
39
88
  </template>
40
89
  </AssistantPromptInput>
@@ -42,10 +91,16 @@
42
91
  </section>
43
92
  </template>
44
93
  <script setup>
45
- import { computed, ref, useSlots } from "vue";
94
+ import { computed, ref, useId, useSlots } from "vue";
95
+ import { mdiPaperclip } from "@mdi/js";
46
96
  import AssistantTranscript from "./AssistantTranscript.vue";
47
97
  import AssistantPromptInput from "./AssistantPromptInput.vue";
48
98
  import AssistantComposerActions from "./AssistantComposerActions.vue";
99
+ import AssistantComposerSupport from "./AssistantComposerSupport.vue";
100
+ import AssistantGoalControl from "./AssistantGoalControl.vue";
101
+ import AssistantAttachmentQueue from "./AssistantAttachmentQueue.vue";
102
+ import AssistantQuestionInputs from "./AssistantQuestionInputs.vue";
103
+ import AssistantModelControl from "./AssistantModelControl.vue";
49
104
  const props = defineProps({
50
105
  adapter: { type: Object, required: true },
51
106
  label: { type: String, default: "Assistant conversation" },
@@ -56,10 +111,44 @@ const props = defineProps({
56
111
  const slots = useSlots();
57
112
  const transcriptSlots = computed(() => ["welcome", "attachments", "system-message", "message-actions"].filter((name) => slots[name]));
58
113
  const input = ref(null);
114
+ const fileInput = ref(null);
115
+ const modelsOpen = ref(false);
59
116
  const delivery = ref(null);
117
+ const statusId = `assistant-status-${useId()}`;
118
+ const activity = computed(() => props.adapter.activity ?? {
119
+ label: props.adapter.composer?.stopPending ? "Stopping…"
120
+ : props.adapter.composer?.canStop || props.adapter.conversation.turns?.some(turn => turn.pending)
121
+ ? "Assistant is working…" : props.adapter.composer?.pending ? "Sending to assistant…" : ""
122
+ });
123
+ const suggestionPreview = computed(() => !props.adapter.composer?.draft && props.adapter.suggestions?.visible
124
+ ? props.adapter.suggestions.preview : "");
125
+ const attachmentsEnabled = computed(() => props.adapter.attachments && props.adapter.attachments.enabled !== false);
126
+ const attachmentState = computed(() => ({
127
+ count: attachmentsEnabled.value ? props.adapter.attachments.queueItems?.length || 0 : 0,
128
+ canSubmit: !attachmentsEnabled.value || props.adapter.attachments.canSubmit !== false
129
+ }));
130
+ const canSend = computed(() => props.adapter.composer?.canSend && attachmentState.value.canSubmit);
131
+ function attachmentEvent(method, event) {
132
+ if (attachmentsEnabled.value) return props.adapter.attachments[method]?.(event);
133
+ }
134
+ function attachSelectedFiles(event) {
135
+ const files = Array.from(event.target.files || []);
136
+ event.target.value = "";
137
+ if (attachmentsEnabled.value && props.adapter.attachments.canAddFiles) return props.adapter.attachments.uploadFiles(files);
138
+ }
139
+ async function applyModel() {
140
+ if (await props.adapter.models.apply() !== false) modelsOpen.value = false;
141
+ }
142
+ function selectSuggestion(value) {
143
+ if (props.adapter.suggestions?.select(value) !== false) input.value?.focus();
144
+ }
145
+ function dismissSuggestions() {
146
+ props.adapter.suggestions?.dismiss();
147
+ input.value?.focus();
148
+ }
60
149
  function submit() {
61
- if (!props.adapter.composer?.canSend) return;
62
- return props.adapter.actions.submit({ configuration: props.configuration });
150
+ if (!canSend.value) return;
151
+ return props.adapter.actions.submit({ configuration: props.configuration, attachments: attachmentsEnabled.value ? props.adapter.attachments.attachments.map(item => ({ ...item })) : [] });
63
152
  }
64
153
  function stop() {
65
154
  const state = props.adapter.composer;
@@ -73,8 +162,10 @@ function updateConfiguration(value) {
73
162
  defineExpose({ focus: () => input.value?.focus(), submit, stop });
74
163
  </script>
75
164
  <style scoped>
76
- .assistant-conversation { display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; }
165
+ .assistant-conversation { display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
166
+ .assistant-conversation__goal { display: flex; justify-content: flex-end; flex: 0 0 auto; }
77
167
  .assistant-conversation__transcript { flex: 1 1 auto; min-height: 0; }
78
168
  .assistant-conversation__composer { flex: 0 0 auto; }
169
+ .assistant-conversation__attachment-error { flex-basis: 100%; margin: 0; }
79
170
  .assistant-conversation__setting { min-width: 8rem; max-width: 16rem; }
80
171
  </style>
@@ -0,0 +1,81 @@
1
+ <script setup>
2
+ import { computed, onMounted, onScopeDispose, ref, watch } from "vue";
3
+
4
+ const props = defineProps({ state: { type: Object, default: null } });
5
+ const open = ref(false);
6
+ const objective = ref("");
7
+ const tokenBudget = ref("");
8
+ const now = ref(Date.now());
9
+ const goal = computed(() => props.state?.goal);
10
+ const running = computed(() => goal.value?.status === "active");
11
+ const label = computed(() => ({
12
+ active: "Goal running", paused: "Goal paused", blocked: "Goal blocked",
13
+ usageLimited: "Goal waiting for allowance", budgetLimited: "Goal budget reached", complete: "Goal complete"
14
+ })[goal.value?.status] || "Set goal");
15
+ const elapsed = computed(() => {
16
+ if (!Number.isFinite(goal.value?.elapsedSeconds)) return "";
17
+ const sinceSample = running.value && Number.isFinite(goal.value.sampledAt)
18
+ ? Math.max(0, now.value - goal.value.sampledAt) / 1000 : 0;
19
+ const total = Math.max(0, Math.floor(goal.value.elapsedSeconds + sinceSample));
20
+ const seconds = String(total % 60).padStart(2, "0");
21
+ const minutes = Math.floor(total / 60);
22
+ return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${seconds}`;
23
+ });
24
+ const canSet = computed(() => typeof props.state?.set === "function" && (!goal.value || goal.value.status === "complete"));
25
+ const validBudget = computed(() => tokenBudget.value === "" || (Number.isSafeInteger(Number(tokenBudget.value)) && Number(tokenBudget.value) > 0));
26
+ let timer;
27
+ onMounted(() => watch(() => [props.state?.enabled, running.value, goal.value?.elapsedSeconds, goal.value?.sampledAt], () => {
28
+ clearInterval(timer);
29
+ now.value = Date.now();
30
+ if (props.state?.enabled !== false && running.value && Number.isFinite(goal.value?.sampledAt)) {
31
+ timer = setInterval(() => { now.value = Date.now(); }, 1000);
32
+ }
33
+ }, { immediate: true, flush: "sync" }));
34
+ onScopeDispose(() => clearInterval(timer));
35
+ watch(() => props.state?.enabled, enabled => { if (enabled === false) open.value = false; });
36
+ async function setGoal() {
37
+ if (!canSet.value || props.state.pending || !objective.value.trim() || !validBudget.value) return;
38
+ const result = await props.state.set({ objective: objective.value.trim(), ...(tokenBudget.value === "" ? {} : { tokenBudget: Number(tokenBudget.value) }) });
39
+ if (result !== false && !props.state.error) { objective.value = ""; tokenBudget.value = ""; }
40
+ }
41
+ </script>
42
+
43
+ <template>
44
+ <v-menu v-if="state && state.enabled !== false" v-model="open" location="top" :close-on-content-click="false">
45
+ <template #activator="{ props: menuProps }">
46
+ <v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsed && `${elapsed} running time`, goal?.objective].filter(Boolean).join('\n')">
47
+ <span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
48
+ <span class="assistant-goal__label">{{ goal ? 'Goal' : 'Set goal' }}</span>
49
+ <span v-if="elapsed" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
50
+ </v-btn>
51
+ </template>
52
+ <v-card class="assistant-goal__details pa-3" max-width="360">
53
+ <strong role="status">{{ label }}</strong>
54
+ <p v-if="goal" class="my-2">{{ goal.objective }}</p>
55
+ <p v-if="elapsed" class="text-body-small my-2">Running time: {{ elapsed }}</p>
56
+ <v-btn v-if="running && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.pending ? 'Pausing…' : 'Pause goal' }}</v-btn>
57
+ <v-btn v-else-if="['paused', 'blocked', 'usageLimited'].includes(goal?.status) && state.resume" size="small" :disabled="state.pending" @click="state.resume()">{{ state.pending ? 'Resuming…' : 'Resume goal' }}</v-btn>
58
+ <p v-if="running || goal?.status === 'paused'" class="text-body-small mt-2">Pausing prevents further automatic turns. Use Stop to interrupt the current turn.</p>
59
+ <form v-if="canSet" class="assistant-goal__form mt-2" @submit.prevent="setGoal">
60
+ <v-textarea v-model="objective" label="Goal objective" rows="2" auto-grow :disabled="state.pending" hide-details />
61
+ <v-text-field v-model="tokenBudget" label="Token budget (optional)" type="number" min="1" step="1" :disabled="state.pending" :error="!validBudget" hide-details />
62
+ <v-btn type="submit" size="small" :disabled="state.pending || !objective.trim() || !validBudget">{{ state.pending ? 'Starting…' : 'Start goal' }}</v-btn>
63
+ </form>
64
+ <p v-if="state.error" role="alert" class="text-error text-body-small mt-2">{{ state.error }}</p>
65
+ <slot :goal="goal" />
66
+ </v-card>
67
+ </v-menu>
68
+ </template>
69
+
70
+ <style scoped>
71
+ .assistant-goal { text-transform: none; flex-shrink: 0; font-variant-numeric: tabular-nums; }
72
+ .assistant-goal__light { width: 0.625rem; height: 0.625rem; border-radius: 50%; margin-inline-end: 0.45rem; background: currentColor; }
73
+ .assistant-goal__light--running { background: #d32f2f; box-shadow: 0 0 0 3px #d32f2f20; animation: assistant-goal-flash 1.5s ease-in-out infinite; }
74
+ .assistant-goal__light--paused { background: #ef6c00; box-shadow: 0 0 0 3px #ef6c0020; }
75
+ .assistant-goal__elapsed { margin-inline-start: 0.45rem; }
76
+ .assistant-goal__details { overflow-wrap: anywhere; }
77
+ .assistant-goal__form { display: grid; gap: 0.75rem; }
78
+ @keyframes assistant-goal-flash { 50% { opacity: 0.3; } }
79
+ @container assistant-conversation (max-width: 26rem) { .assistant-goal__elapsed { display: none; } }
80
+ @media (prefers-reduced-motion: reduce) { .assistant-goal__light--running { animation: none; } }
81
+ </style>
@@ -0,0 +1,114 @@
1
+ <template>
2
+ <ul
3
+ v-if="attachments.length"
4
+ aria-label="Attached files"
5
+ class="assistant-conversation-attachments"
6
+ >
7
+ <li
8
+ v-for="(attachment, index) in attachments"
9
+ :key="attachmentKey(attachment, index)"
10
+ class="assistant-conversation-attachments__item"
11
+ >
12
+ <component
13
+ :is="previewEnabled ? 'button' : 'span'"
14
+ :type="previewEnabled ? 'button' : undefined"
15
+ class="assistant-conversation-attachments__open"
16
+ @click="previewEnabled && emit('preview', attachment)"
17
+ >
18
+ <v-icon
19
+ aria-hidden="true"
20
+ class="assistant-conversation-attachments__icon"
21
+ :icon="attachmentIcon(attachment)"
22
+ size="19"
23
+ />
24
+ <span class="assistant-conversation-attachments__details">
25
+ <span
26
+ class="assistant-conversation-attachments__name text-body-small font-weight-medium"
27
+ :title="attachment.fileName"
28
+ >
29
+ {{ attachment.reference }} {{ attachment.fileName }}
30
+ </span>
31
+ <span
32
+ v-if="attachmentSizeLabel(attachment.size)"
33
+ class="assistant-conversation-attachments__size text-label-small"
34
+ >
35
+ {{ attachmentSizeLabel(attachment.size) }}
36
+ </span>
37
+ </span>
38
+ </component>
39
+ </li>
40
+ </ul>
41
+ </template>
42
+
43
+ <script setup>
44
+ import { mdiFileOutline, mdiImageOutline } from "@mdi/js";
45
+ import { attachmentSizeLabel } from "../../shared/conversation/attachments.js";
46
+
47
+ defineProps({
48
+ attachments: { type: Array, default: () => [] },
49
+ previewEnabled: Boolean
50
+ });
51
+ const emit = defineEmits(["preview"]);
52
+ function attachmentIcon(attachment) {
53
+ return /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/iu.test(attachment.fileName) ? mdiImageOutline : mdiFileOutline;
54
+ }
55
+ function attachmentKey(attachment, index) {
56
+ return attachment.attachmentId || `${index}:${attachment.fileName}:${attachment.size ?? ""}`;
57
+ }
58
+ </script>
59
+
60
+ <style scoped>
61
+ .assistant-conversation-attachments {
62
+ display: grid;
63
+ gap: 0.35rem;
64
+ list-style: none;
65
+ margin: 0;
66
+ max-width: 100%;
67
+ padding: 0;
68
+ width: min(19rem, 100%);
69
+ }
70
+
71
+ .assistant-conversation-attachments__item {
72
+ align-items: center;
73
+ background: rgba(var(--v-theme-surface), 0.82);
74
+ border: 1px solid rgba(var(--v-theme-outline), 0.2);
75
+ border-radius: 10px;
76
+ color: rgb(var(--v-theme-on-surface));
77
+ min-height: 2.8rem;
78
+ min-width: 0;
79
+ padding: 0.42rem 0.65rem;
80
+ }
81
+
82
+ .assistant-conversation-attachments__icon {
83
+ color: rgb(var(--v-theme-primary));
84
+ }
85
+
86
+ .assistant-conversation-attachments__details {
87
+ display: grid;
88
+ min-width: 0;
89
+ }
90
+
91
+ .assistant-conversation-attachments__open {
92
+ align-items: center;
93
+ background: transparent;
94
+ border: 0;
95
+ color: inherit;
96
+ gap: 0.55rem;
97
+ grid-template-columns: auto minmax(0, 1fr);
98
+ padding: 0;
99
+ text-align: left;
100
+ display: grid;
101
+ min-width: 0;
102
+ width: 100%;
103
+ }
104
+
105
+ .assistant-conversation-attachments__name {
106
+ overflow: hidden;
107
+ text-overflow: ellipsis;
108
+ white-space: nowrap;
109
+ }
110
+
111
+ .assistant-conversation-attachments__size {
112
+ color: rgba(var(--v-theme-on-surface), 0.62);
113
+ }
114
+ </style>