Skip to content

Commit 50ffe55

Browse files
christian-byrnegithub-actions
and
github-actions
committed
Add LLM chat history widget (#3907)
Co-authored-by: github-actions <[email protected]>
1 parent 5f1cb95 commit 50ffe55

File tree

18 files changed

+413
-7
lines changed

18 files changed

+413
-7
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<template>
2+
<ScrollPanel
3+
ref="scrollPanelRef"
4+
class="w-full min-h-[400px] rounded-lg px-2 py-2 text-xs"
5+
:pt="{ content: { id: 'chat-scroll-content' } }"
6+
>
7+
<div v-for="(item, i) in parsedHistory" :key="i" class="mb-4">
8+
<!-- Prompt (user, right) -->
9+
<span
10+
:class="{
11+
'opacity-40 pointer-events-none': editIndex !== null && i > editIndex
12+
}"
13+
>
14+
<div class="flex justify-end mb-1">
15+
<div
16+
class="bg-gray-300 dark-theme:bg-gray-800 rounded-xl px-4 py-1 max-w-[80%] text-right"
17+
>
18+
<div class="break-words text-[12px]">{{ item.prompt }}</div>
19+
</div>
20+
</div>
21+
<div class="flex justify-end mb-2 mr-1">
22+
<CopyButton :text="item.prompt" />
23+
<Button
24+
v-tooltip="
25+
editIndex === i ? $t('chatHistory.cancelEditTooltip') : null
26+
"
27+
text
28+
rounded
29+
class="!p-1 !h-4 !w-4 text-gray-400 hover:text-gray-600 dark-theme:hover:text-gray-200 transition"
30+
pt:icon:class="!text-xs"
31+
:icon="editIndex === i ? 'pi pi-times' : 'pi pi-pencil'"
32+
:aria-label="
33+
editIndex === i ? $t('chatHistory.cancelEdit') : $t('g.edit')
34+
"
35+
@click="editIndex === i ? handleCancelEdit() : handleEdit(i)"
36+
/>
37+
</div>
38+
</span>
39+
<!-- Response (LLM, left) -->
40+
<ResponseBlurb
41+
:text="item.response"
42+
:class="{
43+
'opacity-25 pointer-events-none': editIndex !== null && i >= editIndex
44+
}"
45+
>
46+
<div v-html="nl2br(linkifyHtml(item.response))" />
47+
</ResponseBlurb>
48+
</div>
49+
</ScrollPanel>
50+
</template>
51+
52+
<script setup lang="ts">
53+
import Button from 'primevue/button'
54+
import ScrollPanel from 'primevue/scrollpanel'
55+
import { computed, nextTick, ref, watch } from 'vue'
56+
57+
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
58+
import ResponseBlurb from '@/components/graph/widgets/chatHistory/ResponseBlurb.vue'
59+
import { ComponentWidget } from '@/scripts/domWidget'
60+
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
61+
62+
const { widget, history = '[]' } = defineProps<{
63+
widget?: ComponentWidget<string>
64+
history: string
65+
}>()
66+
67+
const editIndex = ref<number | null>(null)
68+
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
69+
70+
const parsedHistory = computed(() => JSON.parse(history || '[]'))
71+
72+
const findPromptInput = () =>
73+
widget?.node.widgets?.find((w) => w.name === 'prompt')
74+
let promptInput = findPromptInput()
75+
const previousPromptInput = ref<string | null>(null)
76+
77+
const getPreviousResponseId = (index: number) =>
78+
index > 0 ? parsedHistory.value[index - 1]?.response_id ?? '' : ''
79+
80+
const storePromptInput = () => {
81+
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
82+
if (!promptInput) return
83+
84+
previousPromptInput.value = String(promptInput.value)
85+
}
86+
87+
const setPromptInput = (text: string, previousResponseId?: string | null) => {
88+
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
89+
if (!promptInput) return
90+
91+
if (previousResponseId !== null) {
92+
promptInput.value = `<starting_point_id:${previousResponseId}>\n\n${text}`
93+
} else {
94+
promptInput.value = text
95+
}
96+
}
97+
98+
const handleEdit = (index: number) => {
99+
if (!promptInput) return
100+
101+
editIndex.value = index
102+
const prevResponseId = index === 0 ? 'start' : getPreviousResponseId(index)
103+
const promptText = parsedHistory.value[index]?.prompt ?? ''
104+
105+
storePromptInput()
106+
setPromptInput(promptText, prevResponseId)
107+
}
108+
109+
const resetEditingState = () => {
110+
editIndex.value = null
111+
}
112+
const handleCancelEdit = () => {
113+
resetEditingState()
114+
if (promptInput) {
115+
promptInput.value = previousPromptInput.value ?? ''
116+
}
117+
}
118+
119+
const scrollChatToBottom = () => {
120+
const content = document.getElementById('chat-scroll-content')
121+
if (content) {
122+
content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' })
123+
}
124+
}
125+
126+
const onHistoryChanged = () => {
127+
resetEditingState()
128+
void nextTick(() => scrollChatToBottom())
129+
}
130+
131+
watch(() => parsedHistory.value, onHistoryChanged, {
132+
immediate: true,
133+
deep: true
134+
})
135+
</script>

src/components/graph/widgets/DomWidget.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
v-if="isComponentWidget(widget)"
1212
:model-value="widget.value"
1313
:widget="widget"
14+
v-bind="widget.props"
1415
@update:model-value="emit('update:widgetValue', $event)"
1516
/>
1617
</div>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<template>
2+
<Button
3+
v-tooltip="
4+
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
5+
"
6+
text
7+
rounded
8+
class="!p-1 !h-4 !w-6 text-gray-400 hover:text-gray-600 dark-theme:hover:text-gray-200 transition"
9+
pt:icon:class="!text-xs"
10+
:icon="copied ? 'pi pi-check' : 'pi pi-copy'"
11+
:aria-label="
12+
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
13+
"
14+
@click="handleCopy"
15+
/>
16+
</template>
17+
18+
<script setup lang="ts">
19+
import Button from 'primevue/button'
20+
import { ref } from 'vue'
21+
22+
const { text } = defineProps<{
23+
text: string
24+
}>()
25+
26+
const copied = ref(false)
27+
28+
const handleCopy = async () => {
29+
if (!text) return
30+
await navigator.clipboard.writeText(text)
31+
copied.value = true
32+
setTimeout(() => {
33+
copied.value = false
34+
}, 1024)
35+
}
36+
</script>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<template>
2+
<span>
3+
<div class="flex justify-start mb-1">
4+
<div class="rounded-xl px-4 py-1 max-w-[80%]">
5+
<div class="break-words text-[12px]">
6+
<slot />
7+
</div>
8+
</div>
9+
</div>
10+
<div class="flex justify-start ml-1">
11+
<CopyButton :text="text" />
12+
</div>
13+
</span>
14+
</template>
15+
16+
<script setup lang="ts">
17+
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
18+
19+
defineProps<{
20+
text: string
21+
}>()
22+
</script>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { LGraphNode } from '@comfyorg/litegraph'
2+
3+
import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
4+
import { useChatHistoryWidget } from '@/composables/widgets/useChatHistoryWidget'
5+
6+
const CHAT_HISTORY_WIDGET_NAME = '$$node-chat-history'
7+
8+
/**
9+
* Composable for handling node text previews
10+
*/
11+
export function useNodeChatHistory(
12+
options: {
13+
minHeight?: number
14+
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
15+
} = {}
16+
) {
17+
const chatHistoryWidget = useChatHistoryWidget(options)
18+
19+
const findChatHistoryWidget = (node: LGraphNode) =>
20+
node.widgets?.find((w) => w.name === CHAT_HISTORY_WIDGET_NAME)
21+
22+
const addChatHistoryWidget = (node: LGraphNode) =>
23+
chatHistoryWidget(node, {
24+
name: CHAT_HISTORY_WIDGET_NAME,
25+
type: 'chatHistory'
26+
})
27+
28+
/**
29+
* Shows chat history for a node
30+
* @param node The graph node to show the chat history for
31+
*/
32+
function showChatHistory(node: LGraphNode) {
33+
if (!findChatHistoryWidget(node)) {
34+
addChatHistoryWidget(node)
35+
}
36+
node.setDirtyCanvas?.(true)
37+
}
38+
39+
/**
40+
* Removes chat history from a node
41+
* @param node The graph node to remove the chat history from
42+
*/
43+
function removeChatHistory(node: LGraphNode) {
44+
if (!node.widgets) return
45+
46+
const widgetIdx = node.widgets.findIndex(
47+
(w) => w.name === CHAT_HISTORY_WIDGET_NAME
48+
)
49+
50+
if (widgetIdx > -1) {
51+
node.widgets[widgetIdx].onRemove?.()
52+
node.widgets.splice(widgetIdx, 1)
53+
}
54+
}
55+
56+
return {
57+
showChatHistory,
58+
removeChatHistory
59+
}
60+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { LGraphNode } from '@comfyorg/litegraph'
2+
import { ref } from 'vue'
3+
4+
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
5+
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
6+
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
7+
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
8+
9+
const PADDING = 16
10+
11+
export const useChatHistoryWidget = (
12+
options: {
13+
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
14+
} = {}
15+
) => {
16+
const widgetConstructor: ComfyWidgetConstructorV2 = (
17+
node: LGraphNode,
18+
inputSpec: InputSpec
19+
) => {
20+
const widgetValue = ref<string>('')
21+
const widget = new ComponentWidgetImpl<
22+
string | object,
23+
InstanceType<typeof ChatHistoryWidget>['$props']
24+
>({
25+
node,
26+
name: inputSpec.name,
27+
component: ChatHistoryWidget,
28+
props: options.props,
29+
inputSpec,
30+
options: {
31+
getValue: () => widgetValue.value,
32+
setValue: (value: string | object) => {
33+
widgetValue.value = typeof value === 'string' ? value : String(value)
34+
},
35+
getMinHeight: () => 400 + PADDING
36+
}
37+
})
38+
addWidget(node, widget)
39+
return widget
40+
}
41+
42+
return widgetConstructor
43+
}

src/composables/widgets/useProgressTextWidget.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
88

99
const PADDING = 16
1010

11-
export const useTextPreviewWidget = () => {
11+
export const useTextPreviewWidget = (
12+
options: {
13+
minHeight?: number
14+
} = {}
15+
) => {
1216
const widgetConstructor: ComfyWidgetConstructorV2 = (
1317
node: LGraphNode,
1418
inputSpec: InputSpec
@@ -24,7 +28,7 @@ export const useTextPreviewWidget = () => {
2428
setValue: (value: string | object) => {
2529
widgetValue.value = typeof value === 'string' ? value : String(value)
2630
},
27-
getMinHeight: () => 42 + PADDING
31+
getMinHeight: () => options.minHeight ?? 42 + PADDING
2832
}
2933
})
3034
addWidget(node, widget)

src/locales/en/main.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@
116116
"learnMore": "Learn more",
117117
"amount": "Amount",
118118
"unknownError": "Unknown error",
119-
"title": "Title"
119+
"title": "Title",
120+
"edit": "Edit",
121+
"copy": "Copy"
120122
},
121123
"manager": {
122124
"title": "Custom Nodes Manager",
@@ -1389,5 +1391,12 @@
13891391
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
13901392
"disabledTooltip": "No output nodes selected"
13911393
}
1394+
},
1395+
"chatHistory": {
1396+
"cancelEdit": "Cancel",
1397+
"editTooltip": "Edit message",
1398+
"cancelEditTooltip": "Cancel edit",
1399+
"copiedTooltip": "Copied",
1400+
"copyTooltip": "Copy message to clipboard"
13921401
}
13931402
}

src/locales/es/main.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@
8282
"title": "Crea una cuenta"
8383
}
8484
},
85+
"chatHistory": {
86+
"cancelEdit": "Cancelar",
87+
"cancelEditTooltip": "Cancelar edición",
88+
"copiedTooltip": "Copiado",
89+
"copyTooltip": "Copiar mensaje al portapapeles",
90+
"editTooltip": "Editar mensaje"
91+
},
8592
"clipboard": {
8693
"errorMessage": "Error al copiar al portapapeles",
8794
"errorNotSupported": "API del portapapeles no soportada en su navegador",
@@ -257,6 +264,7 @@
257264
"continue": "Continuar",
258265
"control_after_generate": "control después de generar",
259266
"control_before_generate": "control antes de generar",
267+
"copy": "Copiar",
260268
"copyToClipboard": "Copiar al portapapeles",
261269
"currentUser": "Usuario actual",
262270
"customize": "Personalizar",
@@ -268,6 +276,7 @@
268276
"disableAll": "Deshabilitar todo",
269277
"disabling": "Deshabilitando",
270278
"download": "Descargar",
279+
"edit": "Editar",
271280
"empty": "Vacío",
272281
"enableAll": "Habilitar todo",
273282
"enabled": "Habilitado",

0 commit comments

Comments
 (0)