@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.
@@ -0,0 +1,208 @@
1
+ <script setup lang="ts">
2
+ import { Button, SelectableListItem, FormInput, type FormField } from "@linto-ai/transcript-ui-ui"
3
+ import { ref, computed } from "vue"
4
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
5
+ import type { ChatSession } from "@linto-ai/transcript-ui-core"
6
+
7
+ const props = defineProps<{
8
+ sessions: ChatSession[]
9
+ activeSessionId: string | null
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ select: [sessionId: string]
14
+ create: []
15
+ rename: [sessionId: string, title: string]
16
+ delete: [sessionId: string]
17
+ }>()
18
+
19
+ const { t } = useI18n()
20
+
21
+ const renamingId = ref<string | null>(null)
22
+ const renameValue = ref("")
23
+ const deleteTargetId = ref<string | null>(null)
24
+
25
+ const renameField = computed<FormField>(() => ({
26
+ customParams: { "aria-label": t("chat.rename") },
27
+ }))
28
+
29
+ function startRename(session: ChatSession): void {
30
+ deleteTargetId.value = null
31
+ renameValue.value = session.title
32
+ renamingId.value = session.id
33
+ }
34
+
35
+ function confirmRename(): void {
36
+ const id = renamingId.value
37
+ if (!id) return
38
+ renamingId.value = null
39
+ const title = renameValue.value.trim()
40
+ const current = props.sessions.find((s) => s.id === id)
41
+ if (title && title !== current?.title) emit("rename", id, title)
42
+ }
43
+
44
+ function cancelRename(): void {
45
+ renamingId.value = null
46
+ }
47
+
48
+ function onRenameKeydown(event: KeyboardEvent): void {
49
+ // Keep keystrokes inside the drawer: don't let them reach global editor
50
+ // shortcuts, and don't let Escape bubble up to the drawer close handler.
51
+ event.stopPropagation()
52
+ if (event.key === "Enter") confirmRename()
53
+ else if (event.key === "Escape") cancelRename()
54
+ }
55
+
56
+ function requestDelete(sessionId: string): void {
57
+ deleteTargetId.value = sessionId
58
+ }
59
+
60
+ function cancelDelete(): void {
61
+ deleteTargetId.value = null
62
+ }
63
+
64
+ function confirmDelete(): void {
65
+ const id = deleteTargetId.value
66
+ if (!id) return
67
+ deleteTargetId.value = null
68
+ emit("delete", id)
69
+ }
70
+ </script>
71
+
72
+ <template>
73
+ <nav class="chat-session-list" :aria-label="t('chat.history')">
74
+ <header class="chat-session-list__header">
75
+ <h3 class="chat-session-list__title">{{ t("chat.history") }}</h3>
76
+ <Button
77
+ icon="plus"
78
+ variant="transparent"
79
+ size="sm"
80
+ :aria-label="t('chat.newChat')"
81
+ @click="emit('create')" />
82
+ </header>
83
+
84
+ <ul class="chat-session-list__items">
85
+ <li
86
+ v-for="session in sessions"
87
+ :key="session.id"
88
+ class="chat-session-item">
89
+ <!-- Rename mode -->
90
+ <FormInput
91
+ v-if="renamingId === session.id"
92
+ v-model="renameValue"
93
+ :field="renameField"
94
+ :focus="true"
95
+ full-width
96
+ size="sm"
97
+ @keydown="onRenameKeydown"
98
+ @blur="confirmRename" />
99
+
100
+ <!-- Delete confirmation -->
101
+ <div
102
+ v-else-if="deleteTargetId === session.id"
103
+ class="chat-session-confirm">
104
+ <span class="chat-session-confirm__text">
105
+ {{ t("chat.deleteConfirm") }}
106
+ </span>
107
+ <Button
108
+ icon="x"
109
+ variant="transparent"
110
+ size="sm"
111
+ :aria-label="t('chat.cancel')"
112
+ @click="cancelDelete" />
113
+ <Button
114
+ icon="check"
115
+ variant="transparent"
116
+ intent="destructive"
117
+ size="sm"
118
+ :aria-label="t('chat.confirmDelete')"
119
+ @click="confirmDelete" />
120
+ </div>
121
+
122
+ <!-- Normal display -->
123
+ <SelectableListItem
124
+ v-else
125
+ :current="session.id === activeSessionId"
126
+ :label="session.title"
127
+ :title="session.title"
128
+ @select="emit('select', session.id)">
129
+ <template #actions>
130
+ <Button
131
+ icon="pencil"
132
+ variant="transparent"
133
+ size="sm"
134
+ :aria-label="t('chat.rename')"
135
+ @click="startRename(session)" />
136
+ <Button
137
+ icon="trash"
138
+ variant="transparent"
139
+ intent="destructive"
140
+ size="sm"
141
+ :aria-label="t('chat.deleteSession')"
142
+ @click="requestDelete(session.id)" />
143
+ </template>
144
+ </SelectableListItem>
145
+ </li>
146
+ </ul>
147
+ </nav>
148
+ </template>
149
+
150
+ <style scoped>
151
+ .chat-session-list {
152
+ width: var(--chat-session-list-width, 200px);
153
+ flex-shrink: 0;
154
+ display: flex;
155
+ flex-direction: column;
156
+ border-right: 1px solid var(--color-border);
157
+ background-color: var(--color-surface-hover);
158
+ transition: width 0.2s ease;
159
+ }
160
+
161
+ @media (prefers-reduced-motion: reduce) {
162
+ .chat-session-list {
163
+ transition: none;
164
+ }
165
+ }
166
+
167
+ .chat-session-list__header {
168
+ display: flex;
169
+ align-items: center;
170
+ justify-content: space-between;
171
+ padding: var(--spacing-sm) var(--spacing-md);
172
+ }
173
+
174
+ .chat-session-list__title {
175
+ margin: 0;
176
+ font-size: var(--font-size-xs);
177
+ font-weight: 600;
178
+ text-transform: uppercase;
179
+ letter-spacing: 0.05em;
180
+ color: var(--color-text-muted);
181
+ }
182
+
183
+ .chat-session-list__items {
184
+ flex: 1;
185
+ margin: 0;
186
+ padding: 0;
187
+ list-style: none;
188
+ overflow-y: auto;
189
+ }
190
+
191
+ /* Delete confirmation row */
192
+ .chat-session-confirm {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 2px;
196
+ padding: var(--spacing-xs) var(--spacing-sm);
197
+ }
198
+
199
+ .chat-session-confirm__text {
200
+ flex: 1;
201
+ min-width: 0;
202
+ font-size: var(--font-size-xs);
203
+ color: var(--color-danger);
204
+ white-space: nowrap;
205
+ overflow: hidden;
206
+ text-overflow: ellipsis;
207
+ }
208
+ </style>
package/src/index.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { ref, computed } from "vue"
2
+ import type {
3
+ Core,
4
+ CorePlugin,
5
+ ChatMessage,
6
+ ChatSession,
7
+ ChatPluginApi,
8
+ } from "@linto-ai/transcript-ui-core"
9
+ import ChatDrawer from "./ChatDrawer.vue"
10
+
11
+ export type { ChatMessage, ChatSession, ChatPluginApi }
12
+ export { ChatDrawer }
13
+
14
+ const STREAMING_MESSAGE_ID = "__streaming__"
15
+
16
+ /**
17
+ * Chat plugin — state container only, no network.
18
+ *
19
+ * The UI emits intents (`chat:send`, `chat:loadSession`, …) via `core.emit`,
20
+ * the host listens, performs the HTTP/SSE calls, and pushes results back
21
+ * through the setters below. Mirrors the `llmServices` plugin design.
22
+ */
23
+ export function createChatPlugin(): CorePlugin {
24
+ return {
25
+ name: "chat",
26
+ components: { chatDrawer: ChatDrawer },
27
+
28
+ install(core: Core) {
29
+ const drawerOpen = ref(false)
30
+ const sessions = ref<ChatSession[]>([])
31
+ const activeSessionId = ref<string | null>(null)
32
+ const messages = ref<ChatMessage[]>([])
33
+ const isStreaming = ref(false)
34
+ const streamingContent = ref("")
35
+ const isLoadingSession = ref(false)
36
+
37
+ // Local counter for client-side message ids (host messages carry their own).
38
+ let seq = 0
39
+ const nextId = (): string => `local-${++seq}`
40
+
41
+ const allMessages = computed<ChatMessage[]>(() => {
42
+ if (!isStreaming.value) return messages.value
43
+ return [
44
+ ...messages.value,
45
+ {
46
+ id: STREAMING_MESSAGE_ID,
47
+ role: "assistant",
48
+ content: streamingContent.value,
49
+ streaming: true,
50
+ },
51
+ ]
52
+ })
53
+
54
+ const api: ChatPluginApi = {
55
+ drawerOpen,
56
+ sessions,
57
+ activeSessionId,
58
+ messages,
59
+ isStreaming,
60
+ streamingContent,
61
+ isLoadingSession,
62
+ allMessages,
63
+
64
+ setDrawerOpen(open) {
65
+ drawerOpen.value = open
66
+ },
67
+ setSessions(next) {
68
+ sessions.value = next
69
+ },
70
+ setActiveSession(sessionId) {
71
+ activeSessionId.value = sessionId
72
+ },
73
+ setMessages(next) {
74
+ messages.value = next
75
+ },
76
+ addMessage(message) {
77
+ messages.value = [...messages.value, message]
78
+ },
79
+ updateSessionTitle(sessionId, title) {
80
+ const session = sessions.value.find((s) => s.id === sessionId)
81
+ if (session) session.title = title
82
+ },
83
+ setLoadingSession(loading) {
84
+ isLoadingSession.value = loading
85
+ },
86
+
87
+ streamStart() {
88
+ isStreaming.value = true
89
+ streamingContent.value = ""
90
+ },
91
+ streamAppend(token) {
92
+ streamingContent.value += token
93
+ },
94
+ streamEnd(content, meta) {
95
+ messages.value = [
96
+ ...messages.value,
97
+ {
98
+ id: nextId(),
99
+ role: "assistant",
100
+ content,
101
+ tokenCount: meta?.tokenCount,
102
+ },
103
+ ]
104
+ isStreaming.value = false
105
+ streamingContent.value = ""
106
+ },
107
+ streamAbort() {
108
+ isStreaming.value = false
109
+ streamingContent.value = ""
110
+ },
111
+ }
112
+
113
+ core.chat = api
114
+
115
+ return () => {
116
+ sessions.value = []
117
+ messages.value = []
118
+ activeSessionId.value = null
119
+ isStreaming.value = false
120
+ streamingContent.value = ""
121
+ isLoadingSession.value = false
122
+ drawerOpen.value = false
123
+ core.chat = undefined
124
+ }
125
+ },
126
+ }
127
+ }