Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
golang.org/x/text v0.30.0
golang.org/x/text v0.34.0
)

require (
Expand All @@ -26,10 +26,10 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
20 changes: 10 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
Expand All @@ -53,15 +53,15 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
Expand Down
160 changes: 142 additions & 18 deletions ui/src/bichat/components/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
Copy,
ArrowsClockwise,
CaretRight,
Lightning,
Brain,
} from "@phosphor-icons/react";
import { formatRelativeTime } from "../utils/dateFormatting";
import CodeOutputsPanel from "./CodeOutputsPanel";
Expand Down Expand Up @@ -95,8 +97,10 @@ export interface AssistantMessageArtifactsSlotProps {
export interface AssistantMessageActionsSlotProps {
/** Copy content to clipboard */
onCopy: () => void;
/** Regenerate response */
onRegenerate?: () => void;
/** Regenerate response, optionally with a specific model id */
onRegenerate?: (model?: string) => void;
/** Available models that can be picked for regenerate */
regenerateModels?: RegenerateModelOption[];
/** Formatted timestamp */
timestamp: string;
/** Whether copy action is available */
Expand All @@ -105,6 +109,13 @@ export interface AssistantMessageActionsSlotProps {
canRegenerate: boolean;
}

export interface RegenerateModelOption {
/** Model id passed to onRegenerate */
id: string;
/** Translation key (or label) shown in the picker */
label: string;
}

export interface AssistantMessageExplanationSlotProps {
/** Explanation content (markdown) */
explanation: string;
Expand Down Expand Up @@ -197,8 +208,13 @@ export interface AssistantMessageProps {
classNames?: AssistantMessageClassNames;
/** Copy handler */
onCopy?: (content: string) => Promise<void> | void;
/** Regenerate handler */
onRegenerate?: (turnId: string) => Promise<void> | void;
/** Regenerate handler. The optional `model` argument is the id chosen from the
* Fast/Deep picker; when omitted the current session model is used. */
onRegenerate?: (turnId: string, model?: string) => Promise<void> | void;
/** Models offered when the user clicks the regenerate button. When two or more
* options are provided a Fast/Deep picker is shown; otherwise regenerate is
* triggered immediately with the active model. */
regenerateModels?: RegenerateModelOption[];
/** Send message handler (for markdown links) */
onSendMessage?: (content: string) => void;
/** Whether sending is disabled */
Expand Down Expand Up @@ -241,8 +257,7 @@ const defaultClassNames: Required<AssistantMessageClassNames> = {
artifacts: "mb-1 flex flex-wrap gap-2",
sources: "",
explanation: "mt-4 border-t border-gray-100 dark:border-gray-700 pt-4",
actions:
"flex items-center gap-1 transition-opacity duration-150 group-focus-within:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100",
actions: "flex items-center gap-1",
actionButton:
"cursor-pointer p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-500 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 rounded-md transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50",
timestamp: "text-xs text-gray-400 dark:text-gray-500 mr-1",
Expand Down Expand Up @@ -286,6 +301,7 @@ export function AssistantMessage({
classNames: classNameOverrides,
onCopy,
onRegenerate,
regenerateModels,
onSendMessage,
sendDisabled = false,
hideAvatar = false,
Expand All @@ -296,6 +312,8 @@ export function AssistantMessage({
const { t } = useTranslation();
const [explanationExpanded, setExplanationExpanded] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [showRegenPicker, setShowRegenPicker] = useState(false);
const regenPickerRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
Expand Down Expand Up @@ -392,11 +410,62 @@ export function AssistantMessage({
}
}, [onCopy, turn.content]);

const hasRegenChoices = (regenerateModels?.length ?? 0) >= 2;

const handleRegenerateClick = useCallback(async () => {
if (onRegenerate && turnId) {
await onRegenerate(turnId);
if (!onRegenerate || !turnId) {
return;
}
}, [onRegenerate, turnId]);
if (hasRegenChoices) {
setShowRegenPicker((prev) => !prev);
return;
}
await onRegenerate(turnId);
}, [onRegenerate, turnId, hasRegenChoices]);

const handleRegenerateWithModel = useCallback(
async (modelId: string) => {
setShowRegenPicker(false);
if (onRegenerate && turnId) {
await onRegenerate(turnId, modelId);
}
},
[onRegenerate, turnId],
);

// Close picker on outside click / Escape.
useEffect(() => {
if (!showRegenPicker) {
return;
}
const handlePointer = (e: MouseEvent | TouchEvent) => {
const root = regenPickerRef.current;
if (!root) {return;}
// Use composedPath so the check works across shadow DOM boundaries
// (BiChat is hosted inside a shadow root; e.target gets retargeted to
// the shadow host and `root.contains(target)` is always false).
const path =
typeof e.composedPath === "function" ? e.composedPath() : [];
const insideViaPath = path.includes(root);
const insideViaContains = root.contains(e.target as Node);
if (!insideViaPath && !insideViaContains) {
setShowRegenPicker(false);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setShowRegenPicker(false);
}
};
document.addEventListener("mousedown", handlePointer);
document.addEventListener("touchstart", handlePointer);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handlePointer);
document.removeEventListener("touchstart", handlePointer);
document.removeEventListener("keydown", handleKey);
};
}, [showRegenPicker]);

const timestamp = formatRelativeTime(turn.createdAt, t);

Expand Down Expand Up @@ -426,7 +495,19 @@ export function AssistantMessage({
};
const actionsSlotProps: AssistantMessageActionsSlotProps = {
onCopy: handleCopyClick,
onRegenerate: canRegenerate ? handleRegenerateClick : undefined,
onRegenerate: canRegenerate
? (model) => {
if (!onRegenerate || !turnId) {
return;
}
if (model) {
void handleRegenerateWithModel(model);
} else {
void handleRegenerateClick();
}
}
: undefined,
regenerateModels,
timestamp,
canCopy: hasContent,
canRegenerate,
Expand Down Expand Up @@ -707,14 +788,57 @@ export function AssistantMessage({
</button>

{canRegenerate && (
<button
onClick={handleRegenerateClick}
className={`cursor-pointer ${classes.actionButton}`}
aria-label={t("BiChat.Message.Regenerate")}
title={t("BiChat.Message.Regenerate")}
>
<ArrowsClockwise size={14} weight="regular" />
</button>
<div ref={regenPickerRef} className="relative inline-flex">
<button
onClick={handleRegenerateClick}
className={`cursor-pointer ${classes.actionButton} ${
showRegenPicker
? "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200"
: ""
}`}
aria-label={t("BiChat.Message.Regenerate")}
title={t("BiChat.Message.Regenerate")}
aria-haspopup={hasRegenChoices ? "menu" : undefined}
aria-expanded={
hasRegenChoices ? showRegenPicker : undefined
}
>
<ArrowsClockwise size={14} weight="regular" />
</button>
{hasRegenChoices && showRegenPicker && (
<div
role="menu"
aria-label={t("BiChat.Message.Regenerate")}
className="animate-slide-up absolute left-0 top-full z-20 mt-1 flex flex-col gap-0.5 rounded-lg border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
{regenerateModels!.map((m, i) => {
const isFast = i === 0;
const Icon = isFast ? Lightning : Brain;
const accent = isFast
? "text-amber-600 dark:text-amber-400"
: "text-blue-600 dark:text-blue-400";
return (
<button
key={m.id}
role="menuitem"
type="button"
onClick={() => {
void handleRegenerateWithModel(m.id);
}}
className="flex items-center gap-2 whitespace-nowrap rounded-md px-2.5 py-1.5 text-xs font-medium text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<Icon
size={14}
weight="fill"
className={accent}
/>
<span>{t(m.label)}</span>
</button>
);
})}
</div>
)}
</div>
)}
</>,
)}
Expand Down
16 changes: 15 additions & 1 deletion ui/src/bichat/components/AssistantTurnView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@
* For more customization, use the AssistantMessage component directly with slots.
*/

import { useMemo } from 'react';
import { useChatSession, useChatMessaging } from '../context/ChatContext';
import { AssistantMessage, type AssistantMessageSlots, type AssistantMessageClassNames } from './AssistantMessage';
import { useIotaContext } from '../context/IotaContext';
import {
AssistantMessage,
type AssistantMessageSlots,
type AssistantMessageClassNames,
type RegenerateModelOption,
} from './AssistantMessage';
import { SystemMessage } from './SystemMessage';
import type { ConversationTurn } from '../types';

Expand Down Expand Up @@ -47,6 +54,12 @@ export function AssistantTurnView({
}: AssistantTurnViewProps) {
const { debugMode } = useChatSession();
const { handleCopy, handleRegenerate, pendingQuestion, sendMessage, loading } = useChatMessaging();
const iotaContext = useIotaContext();
const regenerateModels = useMemo<RegenerateModelOption[] | undefined>(() => {
const models = iotaContext.extensions?.llm?.models;
if (!models || models.length < 2) {return undefined;}
return models.map((m) => ({ id: m.id, label: m.label }));
}, [iotaContext.extensions?.llm?.models]);

const assistantTurn = turn.assistantTurn;
if (!assistantTurn) {return null;}
Expand Down Expand Up @@ -74,6 +87,7 @@ export function AssistantTurnView({
classNames={classNames}
onCopy={handleCopy}
onRegenerate={allowRegenerate ? handleRegenerate : undefined}
regenerateModels={allowRegenerate ? regenerateModels : undefined}
onSendMessage={sendMessage}
sendDisabled={loading || isStreaming}
hideAvatar={hideAvatar}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/bichat/components/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ const defaultClassNames: Required<UserMessageClassNames> = {
bubble: 'bg-primary-600 text-white rounded-2xl rounded-br-sm px-4 py-3 shadow-sm',
content: 'text-sm whitespace-pre-wrap break-words leading-relaxed',
attachments: 'mb-2 w-full',
actions: 'flex items-center gap-1 mt-2 transition-opacity duration-150 group-focus-within:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100',
actions: 'flex items-center gap-1 mt-2',
actionButton: 'cursor-pointer p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-500 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 rounded-md transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
timestamp: 'text-xs text-gray-400 dark:text-gray-500 mr-1',
};
Expand Down
1 change: 1 addition & 0 deletions ui/src/bichat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export {
type AssistantMessageArtifactsSlotProps,
type AssistantMessageActionsSlotProps,
type AssistantMessageExplanationSlotProps,
type RegenerateModelOption,
} from './components/AssistantMessage';

// =============================================================================
Expand Down
11 changes: 9 additions & 2 deletions ui/src/bichat/machine/ChatMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export class ChatMachine {
content: string,
attachments?: Attachment[],
) => Promise<void>;
readonly handleRegenerate: (turnId: string) => Promise<void>;
readonly handleRegenerate: (turnId: string, model?: string) => Promise<void>;
readonly handleEdit: (turnId: string, newContent: string) => Promise<void>;
readonly handleCopy: (text: string) => Promise<void>;
readonly handleSubmitQuestionAnswers: (answers: QuestionAnswers) => void;
Expand Down Expand Up @@ -1570,7 +1570,10 @@ export class ChatMachine {

// ── Regenerate / Edit ───────────────────────────────────────────────────

private async _handleRegenerate(turnId: string): Promise<void> {
private async _handleRegenerate(
turnId: string,
model?: string,
): Promise<void> {
const curSessionId = this.state.session.currentSessionId;
if (!curSessionId || curSessionId === "new") {
return;
Expand All @@ -1581,6 +1584,10 @@ export class ChatMachine {
return;
}

if (model && model !== this.state.session.model) {
this._setModel(model);
}

this._updateSession({ error: null, errorRetryable: false });
// _sendMessageDirect delegates to _sendMessageCore which handles all errors internally
await this._sendMessageDirect(
Expand Down
2 changes: 1 addition & 1 deletion ui/src/bichat/machine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export interface MessagingSnapshot {
showActivityTrace: boolean
showTypingIndicator: boolean
sendMessage: (content: string, attachments?: Attachment[]) => Promise<void>
handleRegenerate?: (turnId: string) => Promise<void>
handleRegenerate?: (turnId: string, model?: string) => Promise<void>
handleEdit?: (turnId: string, newContent: string) => Promise<void>
handleCopy: (text: string) => Promise<void>
handleSubmitQuestionAnswers: (answers: QuestionAnswers) => void
Expand Down
2 changes: 1 addition & 1 deletion ui/src/bichat/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ export interface ChatMessagingStateValue {
showActivityTrace: boolean;
showTypingIndicator: boolean;
sendMessage: (content: string, attachments?: Attachment[]) => Promise<void>;
handleRegenerate?: (turnId: string) => Promise<void>;
handleRegenerate?: (turnId: string, model?: string) => Promise<void>;
handleEdit?: (turnId: string, newContent: string) => Promise<void>;
handleCopy: (text: string) => Promise<void>;
handleSubmitQuestionAnswers: (answers: QuestionAnswers) => void;
Expand Down
Loading