@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.
- package/examples/conversation/App.vue +62 -10
- package/examples/conversation/README.md +29 -4
- package/examples/conversation/attachments.js +45 -0
- package/examples/conversation/integrations.json +28 -0
- package/examples/conversation/package.json +6 -4
- package/examples/conversation/server.js +61 -10
- package/fixtures/responsive-assistant/ConversationFixture.vue +54 -1
- package/package.json +21 -7
- package/src/client/conversation/AssistantAttachmentPreview.vue +38 -0
- package/src/client/conversation/AssistantAttachmentQueue.vue +563 -0
- package/src/client/conversation/AssistantComposerSupport.vue +235 -0
- package/src/client/conversation/AssistantConversationElement.vue +99 -8
- package/src/client/conversation/AssistantGoalControl.vue +81 -0
- package/src/client/conversation/AssistantMessageAttachments.vue +114 -0
- package/src/client/conversation/AssistantModelControl.vue +334 -0
- package/src/client/conversation/AssistantQuestionInputs.vue +124 -0
- package/src/client/conversation/AssistantTranscript.vue +2 -1
- package/src/client/conversation/index.js +9 -0
- package/src/client/conversation/useAssistantAttachments.js +991 -0
- package/src/client/conversation/useAssistantSuggestions.js +99 -0
- package/src/server/index.js +1 -0
- package/src/server/lib/aiConnectionClient.js +90 -0
- package/src/server/lib/providers/anthropicClient.js +6 -21
- package/src/shared/assistantResource.js +7 -0
- package/src/shared/conversation/attachments.js +13 -0
- package/src/shared/conversation/index.js +1 -0
- package/test/aiConnectionClient.test.js +159 -0
- package/test/assistantScroll.browser.test.js +144 -0
- 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="
|
|
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="
|
|
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
|
|
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 (!
|
|
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>
|