@jskit-ai/assistant-core 0.1.171 → 0.1.173

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 (31) 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/fixtures/responsive-assistant/ProgressFixture.vue +61 -0
  9. package/fixtures/responsive-assistant/main.js +3 -1
  10. package/package.json +21 -7
  11. package/src/client/conversation/AssistantAttachmentPreview.vue +38 -0
  12. package/src/client/conversation/AssistantAttachmentQueue.vue +563 -0
  13. package/src/client/conversation/AssistantComposerSupport.vue +235 -0
  14. package/src/client/conversation/AssistantConversationElement.vue +103 -9
  15. package/src/client/conversation/AssistantGoalControl.vue +81 -0
  16. package/src/client/conversation/AssistantMessageAttachments.vue +114 -0
  17. package/src/client/conversation/AssistantModelControl.vue +334 -0
  18. package/src/client/conversation/AssistantQuestionInputs.vue +124 -0
  19. package/src/client/conversation/AssistantTranscript.vue +139 -174
  20. package/src/client/conversation/index.js +9 -0
  21. package/src/client/conversation/useAssistantAttachments.js +991 -0
  22. package/src/client/conversation/useAssistantSuggestions.js +99 -0
  23. package/src/server/index.js +1 -0
  24. package/src/server/lib/aiConnectionClient.js +90 -0
  25. package/src/server/lib/providers/anthropicClient.js +6 -21
  26. package/src/shared/assistantResource.js +7 -0
  27. package/src/shared/conversation/attachments.js +13 -0
  28. package/src/shared/conversation/index.js +1 -0
  29. package/test/aiConnectionClient.test.js +159 -0
  30. package/test/assistantScroll.browser.test.js +217 -0
  31. 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>
@@ -1,25 +1,71 @@
1
1
  <template>
2
2
  <section class="assistant-conversation" :aria-label="label">
3
3
  <AssistantTranscript
4
- v-bind="adapter.conversation" class="assistant-conversation__transcript"
4
+ v-bind="adapter.conversation" :working="working" class="assistant-conversation__transcript"
5
5
  @load-more="adapter.actions?.loadMore?.($event)" @reload="adapter.actions?.reload?.()"
6
6
  @resend-turn="adapter.actions?.resend?.($event)" @cancel-turn="adapter.actions?.cancel?.($event)"
7
7
  @edit-turn="adapter.actions?.edit?.($event)" @link-click="adapter.actions?.openLink?.($event)"
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,47 @@ 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 working = computed(() => props.adapter.conversation.working ?? Boolean(
119
+ props.adapter.composer?.canStop || props.adapter.conversation.turns?.some(turn => turn.pending)
120
+ ));
121
+ const activity = computed(() => props.adapter.activity ?? {
122
+ label: props.adapter.composer?.stopPending ? "Stopping…"
123
+ : working.value
124
+ ? "Assistant is working…" : props.adapter.composer?.pending ? "Sending to assistant…" : ""
125
+ });
126
+ const suggestionPreview = computed(() => !props.adapter.composer?.draft && props.adapter.suggestions?.visible
127
+ ? props.adapter.suggestions.preview : "");
128
+ const attachmentsEnabled = computed(() => props.adapter.attachments && props.adapter.attachments.enabled !== false);
129
+ const attachmentState = computed(() => ({
130
+ count: attachmentsEnabled.value ? props.adapter.attachments.queueItems?.length || 0 : 0,
131
+ canSubmit: !attachmentsEnabled.value || props.adapter.attachments.canSubmit !== false
132
+ }));
133
+ const canSend = computed(() => props.adapter.composer?.canSend && attachmentState.value.canSubmit);
134
+ function attachmentEvent(method, event) {
135
+ if (attachmentsEnabled.value) return props.adapter.attachments[method]?.(event);
136
+ }
137
+ function attachSelectedFiles(event) {
138
+ const files = Array.from(event.target.files || []);
139
+ event.target.value = "";
140
+ if (attachmentsEnabled.value && props.adapter.attachments.canAddFiles) return props.adapter.attachments.uploadFiles(files);
141
+ }
142
+ async function applyModel() {
143
+ if (await props.adapter.models.apply() !== false) modelsOpen.value = false;
144
+ }
145
+ function selectSuggestion(value) {
146
+ if (props.adapter.suggestions?.select(value) !== false) input.value?.focus();
147
+ }
148
+ function dismissSuggestions() {
149
+ props.adapter.suggestions?.dismiss();
150
+ input.value?.focus();
151
+ }
60
152
  function submit() {
61
- if (!props.adapter.composer?.canSend) return;
62
- return props.adapter.actions.submit({ configuration: props.configuration });
153
+ if (!canSend.value) return;
154
+ return props.adapter.actions.submit({ configuration: props.configuration, attachments: attachmentsEnabled.value ? props.adapter.attachments.attachments.map(item => ({ ...item })) : [] });
63
155
  }
64
156
  function stop() {
65
157
  const state = props.adapter.composer;
@@ -73,8 +165,10 @@ function updateConfiguration(value) {
73
165
  defineExpose({ focus: () => input.value?.focus(), submit, stop });
74
166
  </script>
75
167
  <style scoped>
76
- .assistant-conversation { display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; }
168
+ .assistant-conversation { display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
169
+ .assistant-conversation__goal { display: flex; justify-content: flex-end; flex: 0 0 auto; }
77
170
  .assistant-conversation__transcript { flex: 1 1 auto; min-height: 0; }
78
171
  .assistant-conversation__composer { flex: 0 0 auto; }
172
+ .assistant-conversation__attachment-error { flex-basis: 100%; margin: 0; }
79
173
  .assistant-conversation__setting { min-width: 8rem; max-width: 16rem; }
80
174
  </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>