@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/LICENSE +661 -0
- package/README.md +37 -0
- package/package.json +46 -0
- package/src/ChatComposer.vue +101 -0
- package/src/ChatDrawer.vue +234 -0
- package/src/ChatMessage.vue +166 -0
- package/src/ChatMessageList.vue +87 -0
- package/src/ChatSessionList.vue +208 -0
- package/src/index.ts +127 -0
|
@@ -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
|
+
}
|