1
0
Fork 0

AIStory: fix textarea jump

This commit is contained in:
Pabloader 2024-11-12 18:33:34 +00:00
parent 277b315795
commit e82eaed944
7 changed files with 165 additions and 47 deletions

View File

@ -22,7 +22,7 @@ Bun.serve({
{ {
production: url.searchParams.get('production') === 'true', // to debug production builds production: url.searchParams.get('production') === 'true', // to debug production builds
portable: url.searchParams.get('portable') === 'true', // to skip AssemblyScript compilation, portable: url.searchParams.get('portable') === 'true', // to skip AssemblyScript compilation,
mobile: detectedBrowser.mobile, mobile: detectedBrowser.mobile || url.searchParams.get('mobile') === 'true',
} }
); );
if (html) { if (html) {

View File

@ -2,6 +2,7 @@ import { useEffect, useRef } from "preact/hooks";
import type { JSX } from "preact/jsx-runtime" import type { JSX } from "preact/jsx-runtime"
import { useIsVisible } from '@common/hooks/useIsVisible'; import { useIsVisible } from '@common/hooks/useIsVisible';
import { DOMTools } from "../dom";
export const AutoTextarea = (props: JSX.HTMLAttributes<HTMLTextAreaElement>) => { export const AutoTextarea = (props: JSX.HTMLAttributes<HTMLTextAreaElement>) => {
const { value } = props; const { value } = props;
@ -12,12 +13,10 @@ export const AutoTextarea = (props: JSX.HTMLAttributes<HTMLTextAreaElement>) =>
if (ref.current && isVisible) { if (ref.current && isVisible) {
const area = ref.current; const area = ref.current;
area.style.height = '0'; // reset const { height } = DOMTools.calculateNodeHeight(area);
area.style.height = `${area.scrollHeight}px`; area.style.height = `${height}px`;
} }
}, [value, isVisible]); }, [value, isVisible]);
return <textarea {...props} ref={ref} /> return <textarea {...props} ref={ref} />
}; };

View File

@ -1,4 +1,4 @@
import { useContext, useEffect, useRef } from "preact/hooks"; import { useCallback, useContext, useEffect, useRef } from "preact/hooks";
import { StateContext } from "../contexts/state"; import { StateContext } from "../contexts/state";
import { Message } from "./message/message"; import { Message } from "./message/message";
import { MessageTools } from "../messages"; import { MessageTools } from "../messages";
@ -15,16 +15,19 @@ export const Chat = () => {
const lastAssistantId = messages.findLastIndex(m => m.role === 'assistant'); const lastAssistantId = messages.findLastIndex(m => m.role === 'assistant');
useEffect(() => { useEffect(() => {
setTimeout(() => DOMTools.scrollDown(chatRef.current, false), 100); DOMTools.scrollDown(chatRef.current);
}, [messages.length, lastMessageContent]); }, [messages.length, lastMessageContent]);
const handleScroll = useCallback(() => DOMTools.scrollDown(chatRef.current, false), []);
return ( return (
<div class="chat" ref={chatRef}> <div class="chat" ref={chatRef}>
{messages.map((m, i) => ( {messages.map((m, i, ms) => (
<Message <Message
message={m} message={m}
key={i} index={i} key={i} index={i}
isLastUser={i === lastUserId} isLastAssistant={i === lastAssistantId} isLastUser={i === lastUserId} isLastAssistant={i === lastAssistantId}
onNeedScroll={i === ms.length - 1 ? handleScroll : undefined}
/> />
))} ))}
</div> </div>

View File

@ -1,8 +1,8 @@
import { useCallback, useContext, useEffect, useMemo, useState } from 'preact/hooks'; import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import styles from './header.module.css'; import styles from './header.module.css';
import { Connection, HORDE_ANON_KEY, isHordeConnection, isKoboldConnection, type IConnection, type IHordeModel } from '../../connection'; import { Connection, HORDE_ANON_KEY, isHordeConnection, isKoboldConnection, type IConnection, type IHordeModel } from '../../connection';
import { Instruct, StateContext } from '../../contexts/state'; import { Instruct } from '../../contexts/state';
import { useInputState } from '@common/hooks/useInputState'; import { useInputState } from '@common/hooks/useInputState';
import { useInputCallback } from '@common/hooks/useInputCallback'; import { useInputCallback } from '@common/hooks/useInputCallback';
import { Huggingface } from '../../huggingface'; import { Huggingface } from '../../huggingface';
@ -17,7 +17,7 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => {
const [apiKey, setApiKey] = useInputState(HORDE_ANON_KEY); const [apiKey, setApiKey] = useInputState(HORDE_ANON_KEY);
const [modelName, setModelName] = useInputState(''); const [modelName, setModelName] = useInputState('');
const [modelTemplate, setModelTemplate] = useInputState(Instruct.CHATML); const [modelTemplate, setModelTemplate] = useInputState('');
const [hordeModels, setHordeModels] = useState<IHordeModel[]>([]); const [hordeModels, setHordeModels] = useState<IHordeModel[]>([]);
const [contextLength, setContextLength] = useState<number>(0); const [contextLength, setContextLength] = useState<number>(0);

View File

@ -5,18 +5,20 @@ import { DOMTools } from "../../dom";
import styles from './message.module.css'; import styles from './message.module.css';
import { AutoTextarea } from "../autoTextarea"; import { AutoTextarea } from "../autoTextarea";
import { useInputState } from "@common/hooks/useInputState";
interface IProps { interface IProps {
message: IMessage; message: IMessage;
index: number; index: number;
isLastUser: boolean; isLastUser: boolean;
isLastAssistant: boolean; isLastAssistant: boolean;
onNeedScroll?: () => void;
} }
export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps) => { export const Message = ({ message, index, isLastUser, isLastAssistant, onNeedScroll }: IProps) => {
const { messages, editMessage, editSummary, deleteMessage, setCurrentSwipe, setMessages } = useContext(StateContext); const { messages, editMessage, editSummary, deleteMessage, setCurrentSwipe, setMessages } = useContext(StateContext);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editedMessage, setEditedMessage] = useState(''); const [editedMessage, setEditedMessage] = useInputState('');
const textRef = useRef<HTMLDivElement>(null); const textRef = useRef<HTMLDivElement>(null);
const swipe = useMemo(() => MessageTools.getSwipe(message), [message]); const swipe = useMemo(() => MessageTools.getSwipe(message), [message]);
@ -25,10 +27,14 @@ export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps)
const summary = swipe?.summary; const summary = swipe?.summary;
const htmlContent = useMemo(() => MessageTools.format(content ?? ''), [content]); const htmlContent = useMemo(() => MessageTools.format(content ?? ''), [content]);
useEffect(() => {
setTimeout(() => onNeedScroll?.(), 50);
}, [editedMessage, editing]);
const handleEnableEdit = useCallback(() => { const handleEnableEdit = useCallback(() => {
setEditing(true); setEditing(true);
setEditedMessage(content ?? ''); setEditedMessage(content ?? '');
}, [content]); }, [content, onNeedScroll]);
const handleSaveEdit = useCallback(() => { const handleSaveEdit = useCallback(() => {
editMessage(index, editedMessage.trim()); editMessage(index, editedMessage.trim());
@ -54,13 +60,6 @@ export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps)
} }
}, [messages, setMessages, index]); }, [messages, setMessages, index]);
const handleEdit = useCallback((e: InputEvent) => {
if (e.target instanceof HTMLTextAreaElement) {
const newContent = e.target.value;
setEditedMessage(newContent);
}
}, []);
const handleSwipeLeft = useCallback(() => { const handleSwipeLeft = useCallback(() => {
setCurrentSwipe(index, message.currentSwipe - 1); setCurrentSwipe(index, message.currentSwipe - 1);
DOMTools.animate(textRef.current, 'swipe-from-left'); DOMTools.animate(textRef.current, 'swipe-from-left');
@ -75,34 +74,32 @@ export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps)
<div class={`${styles.message} ${styles[message.role]} ${isLastUser ? styles.lastUser : ''}`}> <div class={`${styles.message} ${styles[message.role]} ${isLastUser ? styles.lastUser : ''}`}>
<div class={styles.content}> <div class={styles.content}>
{editing {editing
? <AutoTextarea onInput={handleEdit} value={editedMessage} /> ? <AutoTextarea onInput={setEditedMessage} value={editedMessage} />
: <> : <>
<div class={styles.text} dangerouslySetInnerHTML={{ __html: htmlContent }} ref={textRef} /> <div class={styles.text} dangerouslySetInnerHTML={{ __html: htmlContent }} ref={textRef} />
{summary && <small class={styles.summary}>{summary}</small>} {summary && <small class={styles.summary}>{summary}</small>}
</> </>
} }
{(isLastUser || message.role === 'assistant') && <div class={styles.buttons}>
<div class={styles.buttons}> {editing
{editing ? <>
? <> <button class='icon' onClick={handleSaveEdit} title='Save'></button>
<button class='icon' onClick={handleSaveEdit} title='Save'></button> <button class='icon' onClick={handleDeleteMessage} title='Delete'>🗑</button>
<button class='icon' onClick={handleDeleteMessage} title='Delete'>🗑</button> <button class='icon' onClick={handleStopHere} title='Stop here'></button>
<button class='icon' onClick={handleStopHere} title='Stop here'></button> <button class='icon' onClick={handleCancelEdit} title='Cancel'></button>
<button class='icon' onClick={handleCancelEdit} title='Cancel'></button> </>
</> : <>
: <> {isLastAssistant &&
{isLastAssistant && <div class={styles.swipes}>
<div class={styles.swipes}> <div onClick={handleSwipeLeft}></div>
<div onClick={handleSwipeLeft}></div> <div>{message.currentSwipe + 1}/{message.swipes.length}</div>
<div>{message.currentSwipe + 1}/{message.swipes.length}</div> <div onClick={handleSwipeRight}></div>
<div onClick={handleSwipeRight}></div> </div>
</div> }
} <button class='icon' onClick={handleEnableEdit} title="Edit">🖊</button>
<button class='icon' onClick={handleEnableEdit} title="Edit">🖊</button> </>
</> }
} </div>
</div>
}
</div> </div>
</div> </div>
); );

View File

@ -79,12 +79,12 @@ Continue the story forward.
{%- endif %} {%- endif %}
{% if prompt -%} {% if prompt -%}
What should happen next in your answer: {{ prompt | trim }} This is the description of What should happen next in your answer: {{ prompt | trim }}
{% endif %} {% endif %}
Remember that this story should be infinite and go forever. Remember that this story should be infinite and go forever.
Make sure to follow the world description and rules exactly. Avoid cliffhangers and pauses, be creative.`, Make sure to follow the world description and rules exactly. Avoid cliffhangers and pauses, be creative.`,
summarizePrompt: 'Shrink following text down to one paragraph, keeping all important details:\n\n{{ message }}\n\nAnswer with shortened text only.', summarizePrompt: 'Summarize following text in one paragraph:\n\n{{ message }}\n\nAnswer with shortened text only.',
summaryEnabled: false, summaryEnabled: true,
bannedWords: [], bannedWords: [],
messages: [], messages: [],
triggerNext: false, triggerNext: false,

View File

@ -20,4 +20,123 @@ export namespace DOMTools {
}); });
} }
} }
const HIDDEN_TEXTAREA_STYLE = `
min-height:0 !important;
max-height:none !important;
height:0 !important;
visibility:hidden !important;
overflow:hidden !important;
position:absolute !important;
z-index:-1000 !important;
top:0 !important;
right:0 !important
`;
const SIZING_STYLE = [
'letter-spacing',
'line-height',
'padding-top',
'padding-bottom',
'font-family',
'font-weight',
'font-size',
'text-rendering',
'text-transform',
'width',
'text-indent',
'padding-left',
'padding-right',
'border-width',
'box-sizing'
];
let hiddenTextarea: HTMLTextAreaElement;
export const calculateNodeHeight = (uiTextNode: HTMLTextAreaElement, minRows = null, maxRows = null) => {
if (!hiddenTextarea) {
hiddenTextarea = document.createElement('textarea');
document.body.appendChild(hiddenTextarea);
}
// Copy all CSS properties that have an impact on the height of the content in
// the textbox
let {
paddingSize, borderSize,
boxSizing, sizingStyle
} = calculateNodeStyling(uiTextNode);
// Need to have the overflow attribute to hide the scrollbar otherwise
// text-lines will not calculated properly as the shadow will technically be
// narrower for content
hiddenTextarea.setAttribute('style', sizingStyle + ';' + HIDDEN_TEXTAREA_STYLE);
hiddenTextarea.value = uiTextNode.value || uiTextNode.placeholder || 'x';
let minHeight = -Infinity;
let maxHeight = Infinity;
let height = hiddenTextarea.scrollHeight;
if (boxSizing === 'border-box') {
// border-box: add border, since height = content + padding + border
height = height + borderSize;
} else if (boxSizing === 'content-box') {
// remove padding, since height = content
height = height - paddingSize;
}
if (minRows !== null || maxRows !== null) {
// measure height of a textarea with a single row
hiddenTextarea.value = 'x';
let singleRowHeight = hiddenTextarea.scrollHeight - paddingSize;
if (minRows !== null) {
minHeight = singleRowHeight * minRows;
if (boxSizing === 'border-box') {
minHeight = minHeight + paddingSize + borderSize;
}
height = Math.max(minHeight, height);
}
if (maxRows !== null) {
maxHeight = singleRowHeight * maxRows;
if (boxSizing === 'border-box') {
maxHeight = maxHeight + paddingSize + borderSize;
}
height = Math.min(maxHeight, height);
}
}
return { height, minHeight, maxHeight };
}
function calculateNodeStyling(node: HTMLElement) {
let style = window.getComputedStyle(node);
let boxSizing = (
style.getPropertyValue('box-sizing') ||
style.getPropertyValue('-moz-box-sizing') ||
style.getPropertyValue('-webkit-box-sizing')
);
let paddingSize = (
parseFloat(style.getPropertyValue('padding-bottom')) +
parseFloat(style.getPropertyValue('padding-top'))
);
let borderSize = (
parseFloat(style.getPropertyValue('border-bottom-width')) +
parseFloat(style.getPropertyValue('border-top-width'))
);
let sizingStyle = SIZING_STYLE
.map(name => `${name}:${style.getPropertyValue(name)}`)
.join(';');
let nodeInfo = {
sizingStyle,
paddingSize,
borderSize,
boxSizing
};
return nodeInfo;
}
} }