server : (webui) Enable communication with parent html (if webui is in iframe) (#11940)

* Webui: Enable communication with parent html (if webui is in iframe):
- Listens for "setText" command from parent with "text" and "context" fields. "text" is set in inputMsg, "context" is used as hidden context on the following requests to the llama.cpp server
- On pressing na Escape button sends command "escapePressed" to the parent

Example handling from the parent html side:
- Send command "setText" from parent html to webui in iframe:
const iframe = document.getElementById('askAiIframe');
if (iframe) {
	iframe.contentWindow.postMessage({ command: 'setText', text: text, context: context }, '*');
}

- Listen for Escape key from webui on parent html:
// Listen for escape key event in the iframe
window.addEventListener('keydown', (event) => {
	if (event.key === 'Escape') {
		// Process case when Escape is pressed inside webui
	}
});

* Move the extraContext from storage to app.context.

* Fix formatting.

* add Message.extra

* format + build

* MessageExtraContext

* build

* fix display

* rm console.log

---------

Co-authored-by: igardev <ivailo.gardev@akros.ch>
Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
igardev
2025-02-19 00:01:44 +02:00
committed by GitHub
parent 63e489c025
commit b58934c183
7 changed files with 147 additions and 3 deletions

Binary file not shown.

View File

@ -159,6 +159,35 @@ export default function ChatMessage({
</div> </div>
</details> </details>
)} )}
{msg.extra && msg.extra.length > 0 && (
<details
className={classNames({
'collapse collapse-arrow mb-4 bg-base-200': true,
'bg-opacity-10': msg.role !== 'assistant',
})}
>
<summary className="collapse-title">
Extra content
</summary>
<div className="collapse-content">
{msg.extra.map(
(extra, i) =>
extra.type === 'textFile' ? (
<div key={extra.name}>
<b>{extra.name}</b>
<pre>{extra.content}</pre>
</div>
) : extra.type === 'context' ? (
<div key={i}>
<pre>{extra.content}</pre>
</div>
) : null // TODO: support other extra types
)}
</div>
</details>
)}
<MarkdownDisplay <MarkdownDisplay
content={content} content={content}
isGenerating={isPending} isGenerating={isPending}

View File

@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context'; import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context';
import ChatMessage from './ChatMessage'; import ChatMessage from './ChatMessage';
import { CanvasType, Message, PendingMessage } from '../utils/types'; import { CanvasType, Message, PendingMessage } from '../utils/types';
import { classNames, throttle } from '../utils/misc'; import { classNames, throttle } from '../utils/misc';
import CanvasPyInterpreter from './CanvasPyInterpreter'; import CanvasPyInterpreter from './CanvasPyInterpreter';
import StorageUtils from '../utils/storage'; import StorageUtils from '../utils/storage';
import { useVSCodeContext } from '../utils/llama-vscode';
/** /**
* A message display is a message node with additional information for rendering. * A message display is a message node with additional information for rendering.
@ -81,6 +82,14 @@ export default function ChatScreen() {
replaceMessageAndGenerate, replaceMessageAndGenerate,
} = useAppContext(); } = useAppContext();
const [inputMsg, setInputMsg] = useState(''); const [inputMsg, setInputMsg] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
const { extraContext, clearExtraContext } = useVSCodeContext(
inputRef,
setInputMsg
);
// TODO: improve this when we have "upload file" feature
const currExtra: Message['extra'] = extraContext ? [extraContext] : undefined;
// keep track of leaf node for rendering // keep track of leaf node for rendering
const [currNodeId, setCurrNodeId] = useState<number>(-1); const [currNodeId, setCurrNodeId] = useState<number>(-1);
@ -115,10 +124,20 @@ export default function ChatScreen() {
setCurrNodeId(-1); setCurrNodeId(-1);
// get the last message node // get the last message node
const lastMsgNodeId = messages.at(-1)?.msg.id ?? null; const lastMsgNodeId = messages.at(-1)?.msg.id ?? null;
if (!(await sendMessage(currConvId, lastMsgNodeId, inputMsg, onChunk))) { if (
!(await sendMessage(
currConvId,
lastMsgNodeId,
inputMsg,
currExtra,
onChunk
))
) {
// restore the input message if failed // restore the input message if failed
setInputMsg(lastInpMsg); setInputMsg(lastInpMsg);
} }
// OK
clearExtraContext();
}; };
const handleEditMessage = async (msg: Message, content: string) => { const handleEditMessage = async (msg: Message, content: string) => {
@ -129,6 +148,7 @@ export default function ChatScreen() {
viewingChat.conv.id, viewingChat.conv.id,
msg.parent, msg.parent,
content, content,
msg.extra,
onChunk onChunk
); );
setCurrNodeId(-1); setCurrNodeId(-1);
@ -143,6 +163,7 @@ export default function ChatScreen() {
viewingChat.conv.id, viewingChat.conv.id,
msg.parent, msg.parent,
null, null,
msg.extra,
onChunk onChunk
); );
setCurrNodeId(-1); setCurrNodeId(-1);
@ -203,6 +224,7 @@ export default function ChatScreen() {
<textarea <textarea
className="textarea textarea-bordered w-full" className="textarea textarea-bordered w-full"
placeholder="Type a message (Shift+Enter to add a new line)" placeholder="Type a message (Shift+Enter to add a new line)"
ref={inputRef}
value={inputMsg} value={inputMsg}
onChange={(e) => setInputMsg(e.target.value)} onChange={(e) => setInputMsg(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {

View File

@ -25,6 +25,7 @@ interface AppContextValue {
convId: string | null, convId: string | null,
leafNodeId: Message['id'] | null, leafNodeId: Message['id'] | null,
content: string, content: string,
extra: Message['extra'],
onChunk: CallbackGeneratedChunk onChunk: CallbackGeneratedChunk
) => Promise<boolean>; ) => Promise<boolean>;
stopGenerating: (convId: string) => void; stopGenerating: (convId: string) => void;
@ -32,6 +33,7 @@ interface AppContextValue {
convId: string, convId: string,
parentNodeId: Message['id'], // the parent node of the message to be replaced parentNodeId: Message['id'], // the parent node of the message to be replaced
content: string | null, content: string | null,
extra: Message['extra'],
onChunk: CallbackGeneratedChunk onChunk: CallbackGeneratedChunk
) => Promise<void>; ) => Promise<void>;
@ -274,6 +276,7 @@ export const AppContextProvider = ({
convId: string | null, convId: string | null,
leafNodeId: Message['id'] | null, leafNodeId: Message['id'] | null,
content: string, content: string,
extra: Message['extra'],
onChunk: CallbackGeneratedChunk onChunk: CallbackGeneratedChunk
): Promise<boolean> => { ): Promise<boolean> => {
if (isGenerating(convId ?? '') || content.trim().length === 0) return false; if (isGenerating(convId ?? '') || content.trim().length === 0) return false;
@ -298,6 +301,7 @@ export const AppContextProvider = ({
convId, convId,
role: 'user', role: 'user',
content, content,
extra,
parent: leafNodeId, parent: leafNodeId,
children: [], children: [],
}, },
@ -324,6 +328,7 @@ export const AppContextProvider = ({
convId: string, convId: string,
parentNodeId: Message['id'], // the parent node of the message to be replaced parentNodeId: Message['id'], // the parent node of the message to be replaced
content: string | null, content: string | null,
extra: Message['extra'],
onChunk: CallbackGeneratedChunk onChunk: CallbackGeneratedChunk
) => { ) => {
if (isGenerating(convId)) return; if (isGenerating(convId)) return;
@ -339,6 +344,7 @@ export const AppContextProvider = ({
convId, convId,
role: 'user', role: 'user',
content, content,
extra,
parent: parentNodeId, parent: parentNodeId,
children: [], children: [],
}, },

View File

@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { MessageExtraContext } from './types';
// Extra context when using llama.cpp WebUI from llama-vscode, inside an iframe
// Ref: https://github.com/ggml-org/llama.cpp/pull/11940
interface SetTextEvData {
text: string;
context: string;
}
/**
* To test it:
* window.postMessage({ command: 'setText', text: 'Spot the syntax error', context: 'def test()\n return 123' }, '*');
*/
export const useVSCodeContext = (
inputRef: React.RefObject<HTMLTextAreaElement>,
setInputMsg: (text: string) => void
) => {
const [extraContext, setExtraContext] = useState<MessageExtraContext | null>(
null
);
// Accept setText message from a parent window and set inputMsg and extraContext
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.command === 'setText') {
const data: SetTextEvData = event.data;
setInputMsg(data?.text);
if (data?.context && data.context.length > 0) {
setExtraContext({
type: 'context',
content: data.context,
});
}
inputRef.current?.focus();
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
// Add a keydown listener that sends the "escapePressed" message to the parent window
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
window.parent.postMessage({ command: 'escapePressed' }, '*');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return {
extraContext,
// call once the user message is sent, to clear the extra context
clearExtraContext: () => setExtraContext(null),
};
};

View File

@ -53,12 +53,23 @@ export const copyStr = (textToCopy: string) => {
/** /**
* filter out redundant fields upon sending to API * filter out redundant fields upon sending to API
* also format extra into text
*/ */
export function normalizeMsgsForAPI(messages: Readonly<Message[]>) { export function normalizeMsgsForAPI(messages: Readonly<Message[]>) {
return messages.map((msg) => { return messages.map((msg) => {
let newContent = '';
for (const extra of msg.extra ?? []) {
if (extra.type === 'context') {
newContent += `${extra.content}\n\n`;
}
}
newContent += msg.content;
return { return {
role: msg.role, role: msg.role,
content: msg.content, content: newContent,
}; };
}) as APIMessage[]; }) as APIMessage[];
} }

View File

@ -42,11 +42,25 @@ export interface Message {
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
timings?: TimingReport; timings?: TimingReport;
extra?: MessageExtra[];
// node based system for branching // node based system for branching
parent: Message['id']; parent: Message['id'];
children: Message['id'][]; children: Message['id'][];
} }
type MessageExtra = MessageExtraTextFile | MessageExtraContext; // TODO: will add more in the future
export interface MessageExtraTextFile {
type: 'textFile';
name: string;
content: string;
}
export interface MessageExtraContext {
type: 'context';
content: string;
}
export type APIMessage = Pick<Message, 'role' | 'content'>; export type APIMessage = Pick<Message, 'role' | 'content'>;
export interface Conversation { export interface Conversation {