@linto-ai/transcript-ui-plugin-chat 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @linto-ai/transcript-ui-plugin-chat
2
+
3
+ A chat panel for talking with an LLM assistant about the transcript — sessions, streaming responses, message history. No network calls: the UI emits intents (send, load session, …) and the host performs the actual requests, pushing results back in.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import { createChatPlugin } from "@linto-ai/transcript-ui-plugin-chat"
9
+
10
+ core.use(createChatPlugin())
11
+
12
+ core.on("chat:loadSessions", async () => {
13
+ core.chat!.setSessions(await api.listSessions())
14
+ })
15
+
16
+ core.on("chat:loadSession", async ({ sessionId }) => {
17
+ core.chat!.setActiveSession(sessionId)
18
+ core.chat!.setLoadingSession(true)
19
+ core.chat!.setMessages(await api.fetchMessages(sessionId))
20
+ core.chat!.setLoadingSession(false)
21
+ })
22
+
23
+ core.on("chat:send", async ({ content }) => {
24
+ const userMessage = { id: crypto.randomUUID(), role: "user" as const, content }
25
+ core.chat!.addMessage(userMessage)
26
+
27
+ core.chat!.streamStart()
28
+ let fullReply = ""
29
+ for await (const token of api.streamReply(content)) {
30
+ fullReply += token
31
+ core.chat!.streamAppend(token)
32
+ }
33
+ core.chat!.streamEnd(fullReply)
34
+ })
35
+ ```
36
+
37
+ The header's "ask" button already opens the drawer on its own once this plugin is active — call `core.chat!.setDrawerOpen(true)` yourself only if you're triggering it from your own UI instead.
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@linto-ai/transcript-ui-plugin-chat",
3
+ "version": "0.9.0",
4
+ "description": "Chat panel plugin for @linto-ai/transcript-ui — talk with an LLM assistant about the transcript",
5
+ "keywords": [
6
+ "vue",
7
+ "vue3",
8
+ "transcript",
9
+ "chat",
10
+ "llm",
11
+ "linto-plugin"
12
+ ],
13
+ "sideEffects": [
14
+ "**/*.vue"
15
+ ],
16
+ "homepage": "https://github.com/linto-ai/linto-studio",
17
+ "bugs": "https://github.com/linto-ai/linto-studio/issues",
18
+ "author": "Tom Darboux <tdarboux@linagora.com>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/linto-ai/linto-studio.git",
22
+ "directory": "studio-sdk/components/transcript-ui/packages/plugin-chat"
23
+ },
24
+ "type": "module",
25
+ "license": "AGPL-3.0",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "main": "./src/index.ts",
33
+ "types": "./src/index.ts",
34
+ "exports": {
35
+ ".": "./src/index.ts"
36
+ },
37
+ "peerDependencies": {
38
+ "vue": "^3.5.0"
39
+ },
40
+ "dependencies": {
41
+ "@linto-ai/transcript-ui-core": "0.9.0",
42
+ "@linto-ai/transcript-ui-i18n": "0.9.0",
43
+ "@linto-ai/transcript-ui-ui": "0.9.0",
44
+ "vue-stick-to-bottom": "^1.0.0"
45
+ }
46
+ }
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ import { Button } from "@linto-ai/transcript-ui-ui"
3
+ import { ref, useId, onMounted, useTemplateRef } from "vue"
4
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
5
+
6
+ const props = defineProps<{
7
+ disabled?: boolean
8
+ }>()
9
+
10
+ const emit = defineEmits<{
11
+ send: [content: string]
12
+ }>()
13
+
14
+ const textarea = useTemplateRef<HTMLTextAreaElement>("chat-composer__textarea")
15
+
16
+ const { t } = useI18n()
17
+ const text = ref("")
18
+ const textareaId = useId()
19
+
20
+ function submit(): void {
21
+ const content = text.value.trim()
22
+ if (!content || props.disabled) return
23
+ text.value = ""
24
+ emit("send", content)
25
+ }
26
+
27
+ function onKeydown(event: KeyboardEvent): void {
28
+ // Let Escape bubble up so the drawer can close; keep every other key from
29
+ // reaching the global editor shortcuts.
30
+ if (event.key === "Escape") return
31
+ event.stopPropagation()
32
+ if (event.key === "Enter" && !event.shiftKey) {
33
+ event.preventDefault()
34
+ submit()
35
+ }
36
+ }
37
+
38
+ onMounted(() => {
39
+ textarea.value?.focus()
40
+ })
41
+ </script>
42
+
43
+ <template>
44
+ <div class="chat-composer">
45
+ <label :for="textareaId" class="sr-only">{{ t("chat.placeholder") }}</label>
46
+ <textarea
47
+ :id="textareaId"
48
+ v-model="text"
49
+ class="chat-composer__textarea"
50
+ :placeholder="t('chat.placeholder')"
51
+ :disabled="disabled"
52
+ rows="2"
53
+ ref="chat-composer__textarea"
54
+ @keydown="onKeydown" />
55
+ <Button
56
+ icon="send"
57
+ variant="primary"
58
+ size="md"
59
+ :disabled="!text.trim() || disabled"
60
+ :aria-label="t('chat.send')"
61
+ @click="submit" />
62
+ </div>
63
+ </template>
64
+
65
+ <style scoped>
66
+ .chat-composer {
67
+ display: flex;
68
+ align-items: flex-end;
69
+ gap: var(--spacing-sm);
70
+ padding: var(--spacing-md);
71
+ border-top: 1px solid var(--color-border);
72
+ flex-shrink: 0;
73
+ /* Match the message column width when the panel is expanded. */
74
+ width: 100%;
75
+ max-width: var(--chat-content-max-width, 760px);
76
+ margin-inline: auto;
77
+ }
78
+
79
+ .chat-composer__textarea {
80
+ flex: 1;
81
+ resize: none;
82
+ border: 1px solid var(--color-border);
83
+ border-radius: var(--radius-md);
84
+ padding: var(--spacing-sm) var(--spacing-md);
85
+ font-family: inherit;
86
+ font-size: var(--font-size-sm);
87
+ line-height: var(--line-height);
88
+ color: var(--color-text-primary);
89
+ background-color: var(--color-surface);
90
+ outline: none;
91
+ }
92
+
93
+ .chat-composer__textarea:focus {
94
+ border-color: var(--color-primary);
95
+ }
96
+
97
+ .chat-composer__textarea:disabled {
98
+ opacity: 0.6;
99
+ cursor: not-allowed;
100
+ }
101
+ </style>
@@ -0,0 +1,234 @@
1
+ <script setup lang="ts">
2
+ import { Button, EditorIcon } from "@linto-ai/transcript-ui-ui"
3
+ import { ref, watch, onUnmounted, useId } from "vue"
4
+ import { useCore } from "@linto-ai/transcript-ui-core"
5
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
6
+ import ChatSessionList from "./ChatSessionList.vue"
7
+ import ChatMessageList from "./ChatMessageList.vue"
8
+ import ChatComposer from "./ChatComposer.vue"
9
+
10
+ const core = useCore()
11
+ const { t } = useI18n()
12
+ // ChatDrawer is only rendered when the chat plugin is installed.
13
+ const chat = core.chat!
14
+ const titleId = useId()
15
+
16
+ // Local-only: widens the panel to a near-full-width modal (desktop). Resets
17
+ // every time the drawer is reopened.
18
+ const expanded = ref(false)
19
+
20
+ function close(): void {
21
+ chat.setDrawerOpen(false)
22
+ }
23
+
24
+ function toggleExpanded(): void {
25
+ expanded.value = !expanded.value
26
+ }
27
+
28
+ function onKeydown(event: KeyboardEvent): void {
29
+ if (event.key === "Escape" && chat.drawerOpen.value) close()
30
+ }
31
+
32
+ watch(
33
+ () => chat.drawerOpen.value,
34
+ (open) => {
35
+ if (open) {
36
+ expanded.value = false
37
+ core.emit("chat:loadSessions", undefined)
38
+ window.addEventListener("keydown", onKeydown)
39
+ } else {
40
+ window.removeEventListener("keydown", onKeydown)
41
+ }
42
+ },
43
+ )
44
+
45
+ onUnmounted(() => window.removeEventListener("keydown", onKeydown))
46
+
47
+ function onSelect(sessionId: string): void {
48
+ core.emit("chat:loadSession", { sessionId })
49
+ }
50
+ function onCreate(): void {
51
+ core.emit("chat:createSession", undefined)
52
+ }
53
+ function onRename(sessionId: string, title: string): void {
54
+ core.emit("chat:renameSession", { sessionId, title })
55
+ }
56
+ function onDelete(sessionId: string): void {
57
+ core.emit("chat:deleteSession", { sessionId })
58
+ }
59
+ function onSend(content: string): void {
60
+ core.emit("chat:send", { content })
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <Transition name="chat-drawer">
66
+ <div
67
+ v-if="chat.drawerOpen.value"
68
+ class="chat-overlay"
69
+ @click.self="close">
70
+ <aside
71
+ class="chat-drawer"
72
+ :class="{ 'chat-drawer--expanded': expanded }"
73
+ role="dialog"
74
+ aria-modal="true"
75
+ :aria-labelledby="titleId">
76
+ <header class="chat-drawer__header">
77
+ <h2 :id="titleId" class="chat-drawer__title">
78
+ <EditorIcon name="sparkles" :size="18" />
79
+ {{ t("chat.title") }}
80
+ </h2>
81
+ <div class="chat-drawer__actions">
82
+ <Button
83
+ class="chat-drawer__expand"
84
+ :icon="expanded ? 'minimize' : 'maximize'"
85
+ variant="tertiary"
86
+ size="sm"
87
+ :aria-label="expanded ? t('chat.collapse') : t('chat.expand')"
88
+ @click="toggleExpanded" />
89
+ <Button
90
+ icon="x"
91
+ variant="tertiary"
92
+ size="sm"
93
+ :aria-label="t('chat.close')"
94
+ @click="close" />
95
+ </div>
96
+ </header>
97
+
98
+ <div class="chat-drawer__body">
99
+ <ChatSessionList
100
+ :sessions="chat.sessions.value"
101
+ :active-session-id="chat.activeSessionId.value"
102
+ @select="onSelect"
103
+ @create="onCreate"
104
+ @rename="onRename"
105
+ @delete="onDelete" />
106
+
107
+ <div class="chat-drawer__main">
108
+ <ChatMessageList
109
+ :messages="chat.allMessages.value"
110
+ :has-active-session="chat.activeSessionId.value !== null"
111
+ :is-loading="chat.isLoadingSession.value" />
112
+ <ChatComposer
113
+ :disabled="chat.isStreaming.value || chat.isLoadingSession.value"
114
+ @send="onSend" />
115
+ </div>
116
+ </div>
117
+ </aside>
118
+ </div>
119
+ </Transition>
120
+ </template>
121
+
122
+ <style scoped>
123
+ .chat-overlay {
124
+ position: fixed;
125
+ inset: 0;
126
+ z-index: var(--z-drawer);
127
+ background-color: rgba(0, 0, 0, 0.4);
128
+ display: flex;
129
+ justify-content: flex-end;
130
+ }
131
+
132
+ .chat-drawer {
133
+ /* Centered reading column for messages + composer; cascades to the child
134
+ components through the DOM regardless of scoped styles. */
135
+ --chat-content-max-width: 760px;
136
+ --chat-session-list-width: 200px;
137
+ width: min(620px, 100vw);
138
+ height: 100%;
139
+ display: flex;
140
+ flex-direction: column;
141
+ background-color: var(--color-surface);
142
+ box-shadow: var(--shadow-md);
143
+ transition: width 0.2s ease;
144
+ }
145
+
146
+ /* Near-full-width: keeps a backdrop strip on the left so click-outside still
147
+ closes the panel. */
148
+ .chat-drawer--expanded {
149
+ width: min(1400px, 96vw);
150
+ --chat-session-list-width: 300px;
151
+ }
152
+
153
+ .chat-drawer__header {
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: space-between;
157
+ gap: var(--spacing-sm);
158
+ padding: var(--spacing-sm) var(--spacing-md);
159
+ min-height: var(--header-height);
160
+ border-bottom: 1px solid var(--color-border);
161
+ flex-shrink: 0;
162
+ }
163
+
164
+ .chat-drawer__title {
165
+ margin: 0;
166
+ display: inline-flex;
167
+ align-items: center;
168
+ gap: var(--spacing-sm);
169
+ font-size: var(--font-size-base);
170
+ font-weight: 600;
171
+ color: var(--color-text-primary);
172
+ }
173
+
174
+ .chat-drawer__title :deep(.editor-icon) {
175
+ color: var(--color-primary);
176
+ }
177
+
178
+ .chat-drawer__actions {
179
+ display: flex;
180
+ align-items: center;
181
+ gap: var(--spacing-xs);
182
+ }
183
+
184
+ /* The expand toggle is desktop-only: on phones the panel is already full-width. */
185
+ @media (max-width: 640px) {
186
+ .chat-drawer__expand {
187
+ display: none;
188
+ }
189
+ }
190
+
191
+ .chat-drawer__body {
192
+ flex: 1;
193
+ min-height: 0;
194
+ display: flex;
195
+ }
196
+
197
+ .chat-drawer__main {
198
+ flex: 1;
199
+ min-width: 0;
200
+ display: flex;
201
+ flex-direction: column;
202
+ }
203
+
204
+ /* ── Slide + fade transition ── */
205
+ .chat-drawer-enter-active,
206
+ .chat-drawer-leave-active {
207
+ transition: opacity 0.2s ease;
208
+ }
209
+
210
+ .chat-drawer-enter-active .chat-drawer,
211
+ .chat-drawer-leave-active .chat-drawer {
212
+ transition: transform 0.25s ease;
213
+ }
214
+
215
+ .chat-drawer-enter-from,
216
+ .chat-drawer-leave-to {
217
+ opacity: 0;
218
+ }
219
+
220
+ .chat-drawer-enter-from .chat-drawer,
221
+ .chat-drawer-leave-to .chat-drawer {
222
+ transform: translateX(100%);
223
+ }
224
+
225
+ @media (prefers-reduced-motion: reduce) {
226
+ .chat-drawer,
227
+ .chat-drawer-enter-active,
228
+ .chat-drawer-leave-active,
229
+ .chat-drawer-enter-active .chat-drawer,
230
+ .chat-drawer-leave-active .chat-drawer {
231
+ transition: none;
232
+ }
233
+ }
234
+ </style>
@@ -0,0 +1,166 @@
1
+ <script setup lang="ts">
2
+ import { MarkdownView, EditorIcon, CopyButton } from "@linto-ai/transcript-ui-ui"
3
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
4
+ import type { ChatMessage } from "@linto-ai/transcript-ui-core"
5
+
6
+ const props = defineProps<{
7
+ message: ChatMessage
8
+ }>()
9
+
10
+ const { t } = useI18n()
11
+
12
+ function copyContent() {
13
+ return navigator.clipboard.writeText(props.message.content)
14
+ }
15
+ </script>
16
+
17
+ <template>
18
+ <!-- User: compact bubble aligned right -->
19
+ <div v-if="message.role === 'user'" class="chat-message chat-message--user">
20
+ <div class="chat-message__bubble">{{ message.content }}</div>
21
+ </div>
22
+
23
+ <!-- Assistant: flat full-width markdown -->
24
+ <div v-else class="chat-message chat-message--assistant">
25
+ <span class="chat-message__marker" aria-hidden="true">
26
+ <EditorIcon name="sparkles" :size="16" />
27
+ </span>
28
+ <div class="chat-message__body">
29
+ <MarkdownView
30
+ v-if="message.content"
31
+ :source="message.content"
32
+ :streaming="message.streaming" />
33
+ <div
34
+ v-if="message.streaming"
35
+ class="chat-message__typing"
36
+ aria-hidden="true">
37
+ <span></span><span></span><span></span>
38
+ </div>
39
+
40
+ <!-- Action bar -->
41
+ <div
42
+ v-if="!message.streaming && message.content"
43
+ class="chat-message__actions">
44
+ <CopyButton
45
+ variant="secondary"
46
+ size="sm"
47
+ :copy-fn="copyContent"
48
+ :aria-label="t('chat.copy')">
49
+ {{ t("chat.copy") }}
50
+ </CopyButton>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </template>
55
+
56
+ <style scoped>
57
+ .chat-message {
58
+ display: flex;
59
+ padding: 0 var(--spacing-lg);
60
+ }
61
+
62
+ .chat-message:first-child {
63
+ padding-top: var(--spacing-lg);
64
+ }
65
+
66
+ .chat-message:last-child {
67
+ padding-bottom: var(--spacing-lg);
68
+ }
69
+
70
+ /* ── User ── */
71
+ .chat-message--user {
72
+ justify-content: flex-end;
73
+ }
74
+
75
+ .chat-message__bubble {
76
+ max-width: 80%;
77
+ padding: var(--spacing-sm) var(--spacing-md);
78
+ background-color: var(--color-primary);
79
+ color: var(--color-white);
80
+ border-radius: var(--radius-lg) var(--radius-lg) var(--radius-sm)
81
+ var(--radius-lg);
82
+ font-size: var(--font-size-sm);
83
+ line-height: var(--line-height);
84
+ white-space: pre-wrap;
85
+ word-break: break-word;
86
+ }
87
+
88
+ /* ── Assistant ── */
89
+ .chat-message--assistant {
90
+ gap: var(--spacing-sm);
91
+ align-items: flex-start;
92
+ }
93
+
94
+ .chat-message__marker {
95
+ display: inline-flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ flex-shrink: 0;
99
+ width: 24px;
100
+ height: 24px;
101
+ margin-top: 2px;
102
+ border-radius: var(--radius-md);
103
+ color: var(--color-primary);
104
+ background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
105
+ }
106
+
107
+ .chat-message__body {
108
+ min-width: 0;
109
+ flex: 1;
110
+ }
111
+
112
+ .chat-message__body :deep(.markdown-view) {
113
+ font-size: var(--font-size-sm);
114
+ }
115
+
116
+ /* ── Action bar ── */
117
+ .chat-message__actions {
118
+ display: flex;
119
+ gap: var(--spacing-xs);
120
+ margin-top: var(--spacing-xs);
121
+ margin-left: calc(var(--spacing-sm) * -1);
122
+ flex-direction: row-reverse;
123
+ }
124
+
125
+ /* ── Streaming typing indicator ── */
126
+ .chat-message__typing {
127
+ display: inline-flex;
128
+ gap: 3px;
129
+ padding-top: var(--spacing-xs);
130
+ }
131
+
132
+ .chat-message__typing span {
133
+ width: 6px;
134
+ height: 6px;
135
+ border-radius: 50%;
136
+ background-color: var(--color-primary);
137
+ animation: chat-typing 1.2s infinite;
138
+ }
139
+
140
+ .chat-message__typing span:nth-child(2) {
141
+ animation-delay: 0.2s;
142
+ }
143
+
144
+ .chat-message__typing span:nth-child(3) {
145
+ animation-delay: 0.4s;
146
+ }
147
+
148
+ @keyframes chat-typing {
149
+ 0%,
150
+ 60%,
151
+ 100% {
152
+ opacity: 0.3;
153
+ transform: scale(0.8);
154
+ }
155
+ 30% {
156
+ opacity: 1;
157
+ transform: scale(1);
158
+ }
159
+ }
160
+
161
+ @media (prefers-reduced-motion: reduce) {
162
+ .chat-message__typing span {
163
+ animation: none;
164
+ }
165
+ }
166
+ </style>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import { EditorIcon } from "@linto-ai/transcript-ui-ui"
3
+ import { StickToBottom } from "vue-stick-to-bottom"
4
+ import ChatMessage from "./ChatMessage.vue"
5
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
6
+ import type { ChatMessage as ChatMessageType } from "@linto-ai/transcript-ui-core"
7
+
8
+ defineProps<{
9
+ messages: ChatMessageType[]
10
+ hasActiveSession: boolean
11
+ isLoading: boolean
12
+ }>()
13
+
14
+ const { t } = useI18n()
15
+ </script>
16
+
17
+ <template>
18
+ <div class="chat-message-list">
19
+ <!-- Loading the session history -->
20
+ <div v-if="isLoading" class="chat-message-list__state" role="status">
21
+ <EditorIcon name="spinner" :size="28" spin />
22
+ <span class="sr-only">{{ t("editor.loading") }}</span>
23
+ </div>
24
+
25
+ <!-- No session selected yet -->
26
+ <div v-else-if="!hasActiveSession" class="chat-message-list__state">
27
+ <p>{{ t("chat.emptyState") }}</p>
28
+ </div>
29
+
30
+ <!-- Active session but empty -->
31
+ <div v-else-if="messages.length === 0" class="chat-message-list__state">
32
+ <p>{{ t("chat.emptyChat") }}</p>
33
+ </div>
34
+
35
+ <!-- Messages -->
36
+ <StickToBottom
37
+ v-else
38
+ class="chat-message-list__scroll"
39
+ resize="smooth"
40
+ :initial="true">
41
+ <div class="chat-message-list__items">
42
+ <ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
43
+ </div>
44
+ </StickToBottom>
45
+ </div>
46
+ </template>
47
+
48
+ <style scoped>
49
+ .chat-message-list {
50
+ flex: 1;
51
+ min-height: 0;
52
+ display: flex;
53
+ flex-direction: column;
54
+ }
55
+
56
+ .chat-message-list__state {
57
+ flex: 1;
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: center;
61
+ justify-content: center;
62
+ gap: var(--spacing-md);
63
+ padding: var(--spacing-lg);
64
+ color: var(--color-text-muted);
65
+ font-size: var(--font-size-sm);
66
+ text-align: center;
67
+ }
68
+
69
+ .chat-message-list__state :deep(.editor-icon) {
70
+ color: var(--color-primary);
71
+ }
72
+
73
+ .chat-message-list__scroll {
74
+ flex: 1;
75
+ min-height: 0;
76
+ overflow-y: auto;
77
+ }
78
+
79
+ .chat-message-list__items {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: var(--spacing-lg);
83
+ width: 100%;
84
+ max-width: var(--chat-content-max-width, 760px);
85
+ margin-inline: auto;
86
+ }
87
+ </style>