Skip to content

Commit 28d07cc

Browse files
enekesabeljpr5
authored andcommitted
feat(vue): implement chat components with React parity
Full chat UI component suite: CopilotChat, CopilotPopup, CopilotSidebar, message views, suggestion pills, toggle buttons, audio recorder, tool calls view, modal headers, welcome screens, and auto-scroll normalization.
1 parent 63b23f9 commit 28d07cc

78 files changed

Lines changed: 27550 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/vue/src/v2/components/chat/CopilotChat.vue

Lines changed: 777 additions & 0 deletions
Large diffs are not rendered by default.

packages/vue/src/v2/components/chat/CopilotChatAssistantMessage.vue

Lines changed: 891 additions & 0 deletions
Large diffs are not rendered by default.

packages/vue/src/v2/components/chat/CopilotChatAttachmentQueue.vue

Lines changed: 411 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<script setup lang="ts">
2+
import { computed, ref } from "vue";
3+
import { getDocumentIcon, getSourceUrl } from "@copilotkit/shared";
4+
import type { CopilotChatAttachmentRendererProps } from "./types";
5+
6+
const props = withDefaults(defineProps<CopilotChatAttachmentRendererProps>(), {
7+
filename: undefined,
8+
className: "",
9+
});
10+
11+
const imageLoadFailed = ref(false);
12+
const sourceUrl = computed(() => getSourceUrl(props.source));
13+
const documentLabel = computed(
14+
() => props.filename || props.source.mimeType || "Unknown type",
15+
);
16+
</script>
17+
18+
<template>
19+
<img
20+
v-if="props.type === 'image' && !imageLoadFailed"
21+
:src="sourceUrl"
22+
alt="Image attachment"
23+
class="cpk:max-w-full cpk:h-auto cpk:rounded-lg"
24+
:class="props.className"
25+
data-testid="copilot-chat-attachment-renderer-image"
26+
@error="imageLoadFailed = true"
27+
/>
28+
<div
29+
v-else-if="props.type === 'image'"
30+
class="cpk:flex cpk:flex-col cpk:items-center cpk:justify-center cpk:rounded-lg cpk:bg-muted cpk:p-4 cpk:text-sm cpk:text-muted-foreground"
31+
:class="props.className"
32+
data-testid="copilot-chat-attachment-renderer-image-fallback"
33+
>
34+
<span>Failed to load image</span>
35+
</div>
36+
37+
<div
38+
v-else-if="props.type === 'audio'"
39+
class="cpk:flex cpk:flex-col cpk:gap-1"
40+
:class="props.className"
41+
data-testid="copilot-chat-attachment-renderer-audio"
42+
>
43+
<audio
44+
:src="sourceUrl"
45+
controls
46+
preload="metadata"
47+
class="cpk:max-w-[300px] cpk:w-full cpk:h-10"
48+
/>
49+
<span
50+
v-if="props.filename"
51+
class="cpk:text-xs cpk:text-muted-foreground cpk:truncate cpk:max-w-[300px]"
52+
data-testid="copilot-chat-attachment-renderer-audio-filename"
53+
>
54+
{{ props.filename }}
55+
</span>
56+
</div>
57+
58+
<video
59+
v-else-if="props.type === 'video'"
60+
:src="sourceUrl"
61+
controls
62+
preload="metadata"
63+
class="cpk:max-w-[400px] cpk:w-full cpk:rounded-lg"
64+
:class="props.className"
65+
data-testid="copilot-chat-attachment-renderer-video"
66+
/>
67+
68+
<div
69+
v-else
70+
class="cpk:inline-flex cpk:items-center cpk:gap-2 cpk:px-3 cpk:py-2 cpk:border cpk:border-border cpk:rounded-lg cpk:bg-muted"
71+
:class="props.className"
72+
data-testid="copilot-chat-attachment-renderer-document"
73+
>
74+
<span
75+
class="cpk:text-xs cpk:font-bold cpk:uppercase"
76+
data-testid="copilot-chat-attachment-renderer-document-icon"
77+
>
78+
{{ getDocumentIcon(props.source.mimeType ?? "") }}
79+
</span>
80+
<span
81+
class="cpk:text-sm cpk:text-muted-foreground cpk:truncate"
82+
data-testid="copilot-chat-attachment-renderer-document-label"
83+
>
84+
{{ documentLabel }}
85+
</span>
86+
</div>
87+
</template>
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
<script setup lang="ts">
2+
import { onBeforeUnmount, ref, useAttrs, watch } from "vue";
3+
import { AudioRecorderError } from "./audioRecorder";
4+
import type { AudioRecorderState } from "./audioRecorder";
5+
6+
defineOptions({ inheritAttrs: false });
7+
8+
const attrs = useAttrs();
9+
const canvasRef = ref<HTMLCanvasElement | null>(null);
10+
const recorderState = ref<AudioRecorderState>("idle");
11+
const mediaRecorderRef = ref<MediaRecorder | null>(null);
12+
const streamRef = ref<MediaStream | null>(null);
13+
const analyserRef = ref<AnalyserNode | null>(null);
14+
const audioContextRef = ref<AudioContext | null>(null);
15+
const animationIdRef = ref<number | null>(null);
16+
const audioChunksRef = ref<Blob[]>([]);
17+
const amplitudeHistoryRef = ref<number[]>([]);
18+
const scrollOffsetRef = ref(0);
19+
const smoothedAmplitudeRef = ref(0);
20+
const fadeOpacityRef = ref(0);
21+
22+
function cleanup() {
23+
if (animationIdRef.value !== null) {
24+
cancelAnimationFrame(animationIdRef.value);
25+
animationIdRef.value = null;
26+
}
27+
28+
const recorder = mediaRecorderRef.value;
29+
if (recorder && recorder.state !== "inactive") {
30+
try {
31+
recorder.stop();
32+
} catch {
33+
// ignore cleanup stop failures
34+
}
35+
}
36+
37+
if (streamRef.value) {
38+
streamRef.value.getTracks().forEach((track) => track.stop());
39+
streamRef.value = null;
40+
}
41+
42+
const audioContext = audioContextRef.value;
43+
if (audioContext && audioContext.state !== "closed") {
44+
audioContext.close().catch(() => {
45+
// ignore close errors
46+
});
47+
}
48+
49+
mediaRecorderRef.value = null;
50+
analyserRef.value = null;
51+
audioContextRef.value = null;
52+
audioChunksRef.value = [];
53+
amplitudeHistoryRef.value = [];
54+
scrollOffsetRef.value = 0;
55+
smoothedAmplitudeRef.value = 0;
56+
fadeOpacityRef.value = 0;
57+
}
58+
59+
async function start() {
60+
if (recorderState.value !== "idle") {
61+
throw new AudioRecorderError("Recorder is already active");
62+
}
63+
64+
try {
65+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
66+
streamRef.value = stream;
67+
68+
const audioContext = new AudioContext();
69+
audioContextRef.value = audioContext;
70+
const source = audioContext.createMediaStreamSource(stream);
71+
const analyser = audioContext.createAnalyser();
72+
analyser.fftSize = 2048;
73+
source.connect(analyser);
74+
analyserRef.value = analyser;
75+
76+
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
77+
? "audio/webm;codecs=opus"
78+
: MediaRecorder.isTypeSupported("audio/webm")
79+
? "audio/webm"
80+
: MediaRecorder.isTypeSupported("audio/mp4")
81+
? "audio/mp4"
82+
: "";
83+
84+
const options = mimeType ? { mimeType } : {};
85+
const recorder = new MediaRecorder(stream, options);
86+
mediaRecorderRef.value = recorder;
87+
audioChunksRef.value = [];
88+
89+
recorder.ondataavailable = (event) => {
90+
if (event.data.size > 0) {
91+
audioChunksRef.value.push(event.data);
92+
}
93+
};
94+
95+
recorder.start(100);
96+
recorderState.value = "recording";
97+
} catch (error) {
98+
cleanup();
99+
if (error instanceof Error && error.name === "NotAllowedError") {
100+
throw new AudioRecorderError("Microphone permission denied");
101+
}
102+
if (error instanceof Error && error.name === "NotFoundError") {
103+
throw new AudioRecorderError("No microphone found");
104+
}
105+
throw new AudioRecorderError(
106+
error instanceof Error ? error.message : "Failed to start recording",
107+
);
108+
}
109+
}
110+
111+
function stop() {
112+
return new Promise<Blob>((resolve, reject) => {
113+
const recorder = mediaRecorderRef.value;
114+
if (!recorder || recorderState.value !== "recording") {
115+
reject(new AudioRecorderError("No active recording"));
116+
return;
117+
}
118+
119+
recorderState.value = "processing";
120+
recorder.onstop = () => {
121+
const audioBlob = new Blob(audioChunksRef.value, {
122+
type: recorder.mimeType || "audio/webm",
123+
});
124+
cleanup();
125+
recorderState.value = "idle";
126+
resolve(audioBlob);
127+
};
128+
recorder.onerror = () => {
129+
cleanup();
130+
recorderState.value = "idle";
131+
reject(new AudioRecorderError("Recording failed"));
132+
};
133+
recorder.stop();
134+
});
135+
}
136+
137+
function calculateAmplitude(dataArray: Uint8Array) {
138+
let sum = 0;
139+
for (let index = 0; index < dataArray.length; index += 1) {
140+
const sample = (dataArray[index] ?? 128) / 128 - 1;
141+
sum += sample * sample;
142+
}
143+
return Math.sqrt(sum / dataArray.length);
144+
}
145+
146+
function drawFrame() {
147+
const canvas = canvasRef.value;
148+
if (!canvas) {
149+
return;
150+
}
151+
152+
const context = canvas.getContext("2d");
153+
if (!context) {
154+
return;
155+
}
156+
157+
const rect = canvas.getBoundingClientRect();
158+
const dpr = window.devicePixelRatio || 1;
159+
if (
160+
canvas.width !== rect.width * dpr ||
161+
canvas.height !== rect.height * dpr
162+
) {
163+
canvas.width = rect.width * dpr;
164+
canvas.height = rect.height * dpr;
165+
context.scale(dpr, dpr);
166+
}
167+
168+
const barWidth = 2;
169+
const barGap = 1;
170+
const barSpacing = barWidth + barGap;
171+
const maxBars = Math.floor(rect.width / barSpacing) + 2;
172+
173+
if (analyserRef.value && recorderState.value === "recording") {
174+
if (amplitudeHistoryRef.value.length === 0) {
175+
amplitudeHistoryRef.value = Array.from({ length: maxBars }, () => 0);
176+
}
177+
178+
if (fadeOpacityRef.value < 1) {
179+
fadeOpacityRef.value = Math.min(1, fadeOpacityRef.value + 0.03);
180+
}
181+
182+
scrollOffsetRef.value += 1 / 3;
183+
const bufferLength = analyserRef.value.fftSize;
184+
const dataArray = new Uint8Array(bufferLength);
185+
analyserRef.value.getByteTimeDomainData(dataArray);
186+
const rawAmplitude = calculateAmplitude(dataArray);
187+
const attackSpeed = 0.12;
188+
const decaySpeed = 0.08;
189+
const speed =
190+
rawAmplitude > smoothedAmplitudeRef.value ? attackSpeed : decaySpeed;
191+
smoothedAmplitudeRef.value +=
192+
(rawAmplitude - smoothedAmplitudeRef.value) * speed;
193+
194+
if (scrollOffsetRef.value >= barSpacing) {
195+
scrollOffsetRef.value -= barSpacing;
196+
amplitudeHistoryRef.value.push(smoothedAmplitudeRef.value);
197+
if (amplitudeHistoryRef.value.length > maxBars) {
198+
amplitudeHistoryRef.value = amplitudeHistoryRef.value.slice(-maxBars);
199+
}
200+
}
201+
}
202+
203+
context.clearRect(0, 0, rect.width, rect.height);
204+
const computedStyle = window.getComputedStyle(canvas);
205+
context.fillStyle = computedStyle.color;
206+
context.globalAlpha = fadeOpacityRef.value;
207+
208+
const centerY = rect.height / 2;
209+
const maxAmplitude = rect.height / 2 - 2;
210+
const edgeFadeWidth = 12;
211+
212+
for (let index = 0; index < amplitudeHistoryRef.value.length; index += 1) {
213+
const amplitude = amplitudeHistoryRef.value[index] ?? 0;
214+
const scaledAmplitude = Math.min(amplitude * 4, 1);
215+
const barHeight = Math.max(2, scaledAmplitude * maxAmplitude * 2);
216+
const x =
217+
rect.width -
218+
(amplitudeHistoryRef.value.length - index) * barSpacing -
219+
scrollOffsetRef.value;
220+
const y = centerY - barHeight / 2;
221+
if (x + barWidth <= 0 || x >= rect.width) {
222+
continue;
223+
}
224+
225+
let edgeOpacity = 1;
226+
if (x < edgeFadeWidth) {
227+
edgeOpacity = Math.max(0, x / edgeFadeWidth);
228+
} else if (x > rect.width - edgeFadeWidth) {
229+
edgeOpacity = Math.max(0, (rect.width - x) / edgeFadeWidth);
230+
}
231+
232+
context.globalAlpha = fadeOpacityRef.value * edgeOpacity;
233+
context.fillRect(x, y, barWidth, barHeight);
234+
}
235+
236+
animationIdRef.value = requestAnimationFrame(drawFrame);
237+
}
238+
239+
watch(
240+
recorderState,
241+
() => {
242+
if (animationIdRef.value !== null) {
243+
cancelAnimationFrame(animationIdRef.value);
244+
animationIdRef.value = null;
245+
}
246+
animationIdRef.value = requestAnimationFrame(drawFrame);
247+
},
248+
{ immediate: true },
249+
);
250+
251+
onBeforeUnmount(() => {
252+
cleanup();
253+
});
254+
255+
defineExpose({
256+
get state() {
257+
return recorderState.value;
258+
},
259+
start,
260+
stop,
261+
dispose: cleanup,
262+
});
263+
</script>
264+
265+
<template>
266+
<div data-copilotkit class="cpk:w-full cpk:px-5 cpk:py-3" v-bind="attrs">
267+
<canvas ref="canvasRef" class="cpk:block cpk:h-[26px] cpk:w-full" />
268+
</div>
269+
</template>

0 commit comments

Comments
 (0)