Merge branch 'main' into ci/workflow-permissions-and-fewer-skips
This commit is contained in:
commit
0823562525
4 changed files with 124 additions and 8 deletions
1
studio/frontend/package-lock.json
generated
1
studio/frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
||||||
"@assistant-ui/react": "0.12.28",
|
"@assistant-ui/react": "0.12.28",
|
||||||
"@assistant-ui/react-markdown": "0.12.11",
|
"@assistant-ui/react-markdown": "0.12.11",
|
||||||
"@assistant-ui/react-streamdown": "0.1.11",
|
"@assistant-ui/react-streamdown": "0.1.11",
|
||||||
|
"@assistant-ui/tap": "0.5.10",
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@dagrejs/graphlib": "^3.0.4",
|
"@dagrejs/graphlib": "^3.0.4",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@assistant-ui/react": "0.12.28",
|
"@assistant-ui/react": "0.12.28",
|
||||||
"@assistant-ui/react-markdown": "0.12.11",
|
"@assistant-ui/react-markdown": "0.12.11",
|
||||||
"@assistant-ui/react-streamdown": "0.1.11",
|
"@assistant-ui/react-streamdown": "0.1.11",
|
||||||
|
"@assistant-ui/tap": "0.5.10",
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@dagrejs/graphlib": "^3.0.4",
|
"@dagrejs/graphlib": "^3.0.4",
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ import {
|
||||||
useAuiEvent,
|
useAuiEvent,
|
||||||
useAuiState,
|
useAuiState,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
|
import { flushResourcesSync } from "@assistant-ui/tap";
|
||||||
import {
|
import {
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
|
|
@ -72,6 +73,8 @@ import {
|
||||||
import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
|
type CompositionEvent,
|
||||||
type FC,
|
type FC,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -282,13 +285,15 @@ const PendingAudioChip: FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||||
|
const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers();
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(event: FormEvent<HTMLFormElement>) => {
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
if (disabled) {
|
if (disabled || isComposingRef.current) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[disabled],
|
[disabled, isComposingRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const composerContent = (
|
const composerContent = (
|
||||||
|
|
@ -304,8 +309,12 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||||
autoFocus={!disabled}
|
autoFocus={!disabled}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label="Message input"
|
aria-label="Message input"
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
<ComposerAction
|
||||||
|
disabled={disabled || isComposing}
|
||||||
|
blockSend={() => isComposingRef.current}
|
||||||
/>
|
/>
|
||||||
<ComposerAction disabled={disabled} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -330,6 +339,64 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isNativeComposing(event: Event) {
|
||||||
|
return "isComposing" in event && (event as InputEvent).isComposing === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useImeComposerInputHandlers() {
|
||||||
|
const aui = useAui();
|
||||||
|
const composingRef = useRef(false);
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
|
|
||||||
|
const setCompositionState = useCallback((next: boolean) => {
|
||||||
|
composingRef.current = next;
|
||||||
|
setIsComposing(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setComposerText = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const composer = aui.composer();
|
||||||
|
if (!composer.getState().isEditing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushResourcesSync(() => {
|
||||||
|
composer.setText(value);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[aui],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCompositionStart = useCallback(() => {
|
||||||
|
setCompositionState(true);
|
||||||
|
}, [setCompositionState]);
|
||||||
|
|
||||||
|
const onCompositionEnd = useCallback(
|
||||||
|
(e: CompositionEvent<HTMLTextAreaElement>) => {
|
||||||
|
setCompositionState(false);
|
||||||
|
setComposerText(e.currentTarget.value);
|
||||||
|
},
|
||||||
|
[setComposerText, setCompositionState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setCompositionState(isNativeComposing(e.nativeEvent));
|
||||||
|
setComposerText(e.target.value);
|
||||||
|
},
|
||||||
|
[setComposerText, setCompositionState],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputProps: {
|
||||||
|
onCompositionStart,
|
||||||
|
onCompositionEnd,
|
||||||
|
onChange,
|
||||||
|
},
|
||||||
|
isComposing,
|
||||||
|
isComposingRef: composingRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ComposerAudioUpload: FC = () => {
|
const ComposerAudioUpload: FC = () => {
|
||||||
const audioInputRef = useRef<HTMLInputElement>(null);
|
const audioInputRef = useRef<HTMLInputElement>(null);
|
||||||
const setPendingAudio = useChatRuntimeStore((s) => s.setPendingAudio);
|
const setPendingAudio = useChatRuntimeStore((s) => s.setPendingAudio);
|
||||||
|
|
@ -607,7 +674,10 @@ const ToolStatusDisplay: FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
const ComposerAction: FC<{ disabled?: boolean; blockSend?: () => boolean }> = ({
|
||||||
|
disabled,
|
||||||
|
blockSend,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="aui-composer-action-wrapper composer-action-wrapper">
|
<div className="aui-composer-action-wrapper composer-action-wrapper">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -650,6 +720,11 @@ const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||||
variant="default"
|
variant="default"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (blockSend?.()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="aui-composer-send size-8 rounded-full"
|
className="aui-composer-send size-8 rounded-full"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
>
|
>
|
||||||
|
|
@ -903,6 +978,7 @@ const UserActionBar: FC = () => {
|
||||||
|
|
||||||
const EditComposer: FC = () => {
|
const EditComposer: FC = () => {
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
|
const { inputProps, isComposingRef } = useImeComposerInputHandlers();
|
||||||
const resendAfterCancelRef = useRef(false);
|
const resendAfterCancelRef = useRef(false);
|
||||||
|
|
||||||
useAuiEvent("thread.runEnd", () => {
|
useAuiEvent("thread.runEnd", () => {
|
||||||
|
|
@ -919,16 +995,22 @@ const EditComposer: FC = () => {
|
||||||
<ComposerPrimitive.Input
|
<ComposerPrimitive.Input
|
||||||
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm font-[450] outline-none"
|
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm font-[450] outline-none"
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
|
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
|
||||||
<ComposerPrimitive.Cancel asChild={true}>
|
<ComposerPrimitive.Cancel asChild={true}>
|
||||||
<Button variant="ghost" size="sm">
|
<Button type="button" variant="ghost" size="sm">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</ComposerPrimitive.Cancel>
|
</ComposerPrimitive.Cancel>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
|
if (isComposingRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newText = aui.composer().getState().text;
|
const newText = aui.composer().getState().text;
|
||||||
const originalText = aui.message().getCopyText();
|
const originalText = aui.message().getCopyText();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { toast } from "sonner";
|
||||||
import { loadModel, validateModel } from "./api/chat-api";
|
import { loadModel, validateModel } from "./api/chat-api";
|
||||||
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||||
import {
|
import {
|
||||||
|
type CompositionEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
|
|
@ -52,6 +53,10 @@ export interface CompareHandle {
|
||||||
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
|
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
|
||||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
function isNativeComposing(event: Event) {
|
||||||
|
return "isComposing" in event && (event as InputEvent).isComposing === true;
|
||||||
|
}
|
||||||
|
|
||||||
function fileToBase64DataURL(file: File): Promise<string> {
|
function fileToBase64DataURL(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|
@ -238,7 +243,9 @@ export function SharedComposer({
|
||||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||||
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
|
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const composingRef = useRef(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const audioInputRef = useRef<HTMLInputElement>(null);
|
const audioInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -323,7 +330,13 @@ export function SharedComposer({
|
||||||
setPendingImages((prev) => prev.filter((p) => p.id !== id));
|
setPendingImages((prev) => prev.filter((p) => p.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function setCompositionState(next: boolean) {
|
||||||
|
composingRef.current = next;
|
||||||
|
setIsComposing(next);
|
||||||
|
}
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
|
if (composingRef.current) return;
|
||||||
const msg = text.trim();
|
const msg = text.trim();
|
||||||
if (!msg && pendingImages.length === 0 && !pendingAudio) return;
|
if (!msg && pendingImages.length === 0 && !pendingAudio) return;
|
||||||
|
|
||||||
|
|
@ -482,6 +495,9 @@ export function SharedComposer({
|
||||||
const busy = running || comparing;
|
const busy = running || comparing;
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
|
||||||
|
// Don't hijack it. See issue #5318.
|
||||||
|
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
|
|
@ -490,7 +506,7 @@ export function SharedComposer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy;
|
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -538,7 +554,23 @@ export function SharedComposer({
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => {
|
||||||
|
// ALWAYS mirror the DOM value into React state, even during IME
|
||||||
|
// composition. The controlled `value` prop must match the DOM at
|
||||||
|
// all times, otherwise any unrelated parent re-render reconciles
|
||||||
|
// the textarea back to the stored value mid-composition — wiping
|
||||||
|
// the IME preedit AND prior committed text (e.g. Tab cycling
|
||||||
|
// candidates erases earlier words). Issue #5318.
|
||||||
|
setCompositionState(isNativeComposing(e.nativeEvent));
|
||||||
|
setText(e.target.value);
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
setCompositionState(true);
|
||||||
|
}}
|
||||||
|
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
|
||||||
|
setCompositionState(false);
|
||||||
|
setText(e.currentTarget.value);
|
||||||
|
}}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Send to both models..."
|
placeholder="Send to both models..."
|
||||||
className="composer-input"
|
className="composer-input"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue