AIStory: fix textarea jump
This commit is contained in:
parent
277b315795
commit
e82eaed944
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue