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
portable: url.searchParams.get('portable') === 'true', // to skip AssemblyScript compilation,
mobile: detectedBrowser.mobile,
mobile: detectedBrowser.mobile || url.searchParams.get('mobile') === 'true',
}
);
if (html) {

View File

@ -2,6 +2,7 @@ import { useEffect, useRef } from "preact/hooks";
import type { JSX } from "preact/jsx-runtime"
import { useIsVisible } from '@common/hooks/useIsVisible';
import { DOMTools } from "../dom";
export const AutoTextarea = (props: JSX.HTMLAttributes<HTMLTextAreaElement>) => {
const { value } = props;
@ -12,12 +13,10 @@ export const AutoTextarea = (props: JSX.HTMLAttributes<HTMLTextAreaElement>) =>
if (ref.current && isVisible) {
const area = ref.current;
area.style.height = '0'; // reset
area.style.height = `${area.scrollHeight}px`;
const { height } = DOMTools.calculateNodeHeight(area);
area.style.height = `${height}px`;
}
}, [value, isVisible]);
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 { Message } from "./message/message";
import { MessageTools } from "../messages";
@ -15,16 +15,19 @@ export const Chat = () => {
const lastAssistantId = messages.findLastIndex(m => m.role === 'assistant');
useEffect(() => {
setTimeout(() => DOMTools.scrollDown(chatRef.current, false), 100);
DOMTools.scrollDown(chatRef.current);
}, [messages.length, lastMessageContent]);
const handleScroll = useCallback(() => DOMTools.scrollDown(chatRef.current, false), []);
return (
<div class="chat" ref={chatRef}>
{messages.map((m, i) => (
{messages.map((m, i, ms) => (
<Message
message={m}
key={i} index={i}
isLastUser={i === lastUserId} isLastAssistant={i === lastAssistantId}
onNeedScroll={i === ms.length - 1 ? handleScroll : undefined}
/>
))}
</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 { 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 { useInputCallback } from '@common/hooks/useInputCallback';
import { Huggingface } from '../../huggingface';
@ -17,7 +17,7 @@ export const ConnectionEditor = ({ connection, setConnection }: IProps) => {
const [apiKey, setApiKey] = useInputState(HORDE_ANON_KEY);
const [modelName, setModelName] = useInputState('');
const [modelTemplate, setModelTemplate] = useInputState(Instruct.CHATML);
const [modelTemplate, setModelTemplate] = useInputState('');
const [hordeModels, setHordeModels] = useState<IHordeModel[]>([]);
const [contextLength, setContextLength] = useState<number>(0);

View File

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

View File

@ -79,12 +79,12 @@ Continue the story forward.
{%- endif %}
{% 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 %}
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.`,
summarizePrompt: 'Shrink following text down to one paragraph, keeping all important details:\n\n{{ message }}\n\nAnswer with shortened text only.',
summaryEnabled: false,
summarizePrompt: 'Summarize following text in one paragraph:\n\n{{ message }}\n\nAnswer with shortened text only.',
summaryEnabled: true,
bannedWords: [],
messages: [],
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;
}
}