mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-06 23:42:44 +00:00
Compare commits
19 Commits
main
...
multi-mode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e8f8a793d | ||
|
|
5e69df3a06 | ||
|
|
c0e450d2b0 | ||
|
|
d2e343d8ba | ||
|
|
85324f5969 | ||
|
|
2ac403bcbc | ||
|
|
3b110eb48d | ||
|
|
84b69ba25f | ||
|
|
fdeadc8950 | ||
|
|
e7ab5a0dd0 | ||
|
|
1a82817244 | ||
|
|
68e99a5873 | ||
|
|
a690464bf1 | ||
|
|
9a6c949ac0 | ||
|
|
b9b071683d | ||
|
|
facf27c28f | ||
|
|
a291dca550 | ||
|
|
164796708d | ||
|
|
350569258d |
2
.github/workflows/deployment.yml
vendored
2
.github/workflows/deployment.yml
vendored
@@ -228,7 +228,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create-release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # ratchet:softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # ratchet:softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.release-tag.outputs.tag }}
|
||||
name: ${{ steps.release-tag.outputs.tag }}
|
||||
|
||||
2
.github/workflows/helm-chart-releases.yml
vendored
2
.github/workflows/helm-chart-releases.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Helm CLI
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # ratchet:azure/setup-helm@v5.0.0
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # ratchet:azure/setup-helm@v4
|
||||
with:
|
||||
version: v3.12.1
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # ratchet:actions/stale@v10
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # ratchet:actions/stale@v10
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open 75 days with no activity. Remove stale label or comment or this will be closed in 15 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 75 days with no activity. Remove stale label or comment or this will be closed in 15 days.'
|
||||
|
||||
2
.github/workflows/pr-helm-chart-testing.yml
vendored
2
.github/workflows/pr-helm-chart-testing.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # ratchet:azure/setup-helm@v5.0.0
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # ratchet:azure/setup-helm@v4.3.1
|
||||
with:
|
||||
version: v3.19.0
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -59,6 +59,3 @@ node_modules
|
||||
|
||||
# plans
|
||||
plans/
|
||||
|
||||
# Added context for LLMs
|
||||
onyx-llm-context/
|
||||
|
||||
@@ -996,6 +996,7 @@ def _run_models(
|
||||
|
||||
def _run_model(model_idx: int) -> None:
|
||||
"""Run one LLM loop inside a worker thread, writing packets to ``merged_queue``."""
|
||||
|
||||
model_emitter = Emitter(
|
||||
model_idx=model_idx,
|
||||
merged_queue=merged_queue,
|
||||
@@ -1102,33 +1103,33 @@ def _run_models(
|
||||
finally:
|
||||
merged_queue.put((model_idx, _MODEL_DONE))
|
||||
|
||||
def _delete_orphaned_message(model_idx: int, context: str) -> None:
|
||||
"""Delete a reserved ChatMessage that was never populated due to a model error."""
|
||||
def _save_errored_message(model_idx: int, context: str) -> None:
|
||||
"""Save an error message to a reserved ChatMessage that failed during execution."""
|
||||
try:
|
||||
orphaned = db_session.get(
|
||||
ChatMessage, setup.reserved_messages[model_idx].id
|
||||
)
|
||||
if orphaned is not None:
|
||||
db_session.delete(orphaned)
|
||||
msg = db_session.get(ChatMessage, setup.reserved_messages[model_idx].id)
|
||||
if msg is not None:
|
||||
error_text = f"Error from {setup.model_display_names[model_idx]}: model encountered an error during generation."
|
||||
msg.message = error_text
|
||||
msg.error = error_text
|
||||
db_session.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"%s orphan cleanup failed for model %d (%s)",
|
||||
"%s error save failed for model %d (%s)",
|
||||
context,
|
||||
model_idx,
|
||||
setup.model_display_names[model_idx],
|
||||
)
|
||||
|
||||
# Copy contextvars before submitting futures — ThreadPoolExecutor does NOT
|
||||
# auto-propagate contextvars in Python 3.11; threads would inherit a blank context.
|
||||
worker_context = contextvars.copy_context()
|
||||
# Each worker thread needs its own Context copy — a single Context object
|
||||
# cannot be entered concurrently by multiple threads (RuntimeError).
|
||||
executor = ThreadPoolExecutor(
|
||||
max_workers=n_models, thread_name_prefix="multi-model"
|
||||
)
|
||||
completion_persisted: bool = False
|
||||
try:
|
||||
for i in range(n_models):
|
||||
executor.submit(worker_context.run, _run_model, i)
|
||||
ctx = contextvars.copy_context()
|
||||
executor.submit(ctx.run, _run_model, i)
|
||||
|
||||
# ── Main thread: merge and yield packets ────────────────────────────
|
||||
models_remaining = n_models
|
||||
@@ -1145,7 +1146,7 @@ def _run_models(
|
||||
# save "stopped by user" for a model that actually threw an exception.
|
||||
for i in range(n_models):
|
||||
if model_errored[i]:
|
||||
_delete_orphaned_message(i, "stop-button")
|
||||
_save_errored_message(i, "stop-button")
|
||||
continue
|
||||
try:
|
||||
succeeded = model_succeeded[i]
|
||||
@@ -1211,7 +1212,7 @@ def _run_models(
|
||||
for i in range(n_models):
|
||||
if not model_succeeded[i]:
|
||||
# Model errored — delete its orphaned reserved message.
|
||||
_delete_orphaned_message(i, "normal")
|
||||
_save_errored_message(i, "normal")
|
||||
continue
|
||||
try:
|
||||
llm_loop_completion_handle(
|
||||
@@ -1264,7 +1265,7 @@ def _run_models(
|
||||
setup.model_display_names[i],
|
||||
)
|
||||
elif model_errored[i]:
|
||||
_delete_orphaned_message(i, "disconnect")
|
||||
_save_errored_message(i, "disconnect")
|
||||
# 4. Drain buffered packets from memory — no consumer is running.
|
||||
while not merged_queue.empty():
|
||||
try:
|
||||
|
||||
@@ -379,14 +379,6 @@ POSTGRES_HOST = os.environ.get("POSTGRES_HOST") or "127.0.0.1"
|
||||
POSTGRES_PORT = os.environ.get("POSTGRES_PORT") or "5432"
|
||||
POSTGRES_DB = os.environ.get("POSTGRES_DB") or "postgres"
|
||||
AWS_REGION_NAME = os.environ.get("AWS_REGION_NAME") or "us-east-2"
|
||||
# Comma-separated replica / multi-host list. If unset, defaults to POSTGRES_HOST
|
||||
# only.
|
||||
_POSTGRES_HOSTS_STR = os.environ.get("POSTGRES_HOSTS", "").strip()
|
||||
POSTGRES_HOSTS: list[str] = (
|
||||
[h.strip() for h in _POSTGRES_HOSTS_STR.split(",") if h.strip()]
|
||||
if _POSTGRES_HOSTS_STR
|
||||
else [POSTGRES_HOST]
|
||||
)
|
||||
|
||||
POSTGRES_API_SERVER_POOL_SIZE = int(
|
||||
os.environ.get("POSTGRES_API_SERVER_POOL_SIZE") or 40
|
||||
|
||||
@@ -6,7 +6,6 @@ from onyx.configs.app_configs import MCP_SERVER_ENABLED
|
||||
from onyx.configs.app_configs import MCP_SERVER_HOST
|
||||
from onyx.configs.app_configs import MCP_SERVER_PORT
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.variable_functionality import set_is_ee_based_on_env_variable
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -17,7 +16,6 @@ def main() -> None:
|
||||
logger.info("MCP server is disabled (MCP_SERVER_ENABLED=false)")
|
||||
return
|
||||
|
||||
set_is_ee_based_on_env_variable()
|
||||
logger.info(f"Starting MCP server on {MCP_SERVER_HOST}:{MCP_SERVER_PORT}")
|
||||
|
||||
from onyx.mcp_server.api import mcp_app
|
||||
|
||||
@@ -186,7 +186,7 @@ class TestDocumentIndexNew:
|
||||
)
|
||||
document_index.index(chunks=[pre_chunk], indexing_metadata=pre_metadata)
|
||||
|
||||
time.sleep(2)
|
||||
time.sleep(1)
|
||||
|
||||
# Now index a batch with the existing doc and a new doc.
|
||||
chunks = [
|
||||
|
||||
@@ -19,6 +19,6 @@ dependencies:
|
||||
version: 5.4.0
|
||||
- name: code-interpreter
|
||||
repository: https://onyx-dot-app.github.io/python-sandbox/
|
||||
version: 0.3.2
|
||||
digest: sha256:74908ea45ace2b4be913ff762772e6d87e40bab64e92c6662aa51730eaeb9d87
|
||||
generated: "2026-04-06T15:34:02.597166-07:00"
|
||||
version: 0.3.1
|
||||
digest: sha256:4965b6ea3674c37163832a2192cd3bc8004f2228729fca170af0b9f457e8f987
|
||||
generated: "2026-03-02T15:29:39.632344-08:00"
|
||||
|
||||
@@ -5,7 +5,7 @@ home: https://www.onyx.app/
|
||||
sources:
|
||||
- "https://github.com/onyx-dot-app/onyx"
|
||||
type: application
|
||||
version: 0.4.40
|
||||
version: 0.4.39
|
||||
appVersion: latest
|
||||
annotations:
|
||||
category: Productivity
|
||||
@@ -45,6 +45,6 @@ dependencies:
|
||||
repository: https://charts.min.io/
|
||||
condition: minio.enabled
|
||||
- name: code-interpreter
|
||||
version: 0.3.2
|
||||
version: 0.3.1
|
||||
repository: https://onyx-dot-app.github.io/python-sandbox/
|
||||
condition: codeInterpreter.enabled
|
||||
|
||||
@@ -67,9 +67,6 @@ spec:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
{{- if .Values.api.runUpdateCaCertificates }}
|
||||
update-ca-certificates &&
|
||||
{{- end }}
|
||||
alembic upgrade head &&
|
||||
echo "Starting Onyx Api Server" &&
|
||||
uvicorn onyx.main:app --host {{ .Values.global.host }} --port {{ .Values.api.containerPorts.server }}
|
||||
|
||||
@@ -504,18 +504,6 @@ api:
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
# Run update-ca-certificates before starting the server.
|
||||
# Useful when mounting custom CA certificates via volumes/volumeMounts.
|
||||
# NOTE: Requires the container to run as root (runAsUser: 0).
|
||||
# CA certificate files must be mounted under /usr/local/share/ca-certificates/
|
||||
# with a .crt extension (e.g. /usr/local/share/ca-certificates/my-ca.crt).
|
||||
# NOTE: Python HTTP clients (requests, httpx) use certifi's bundle by default
|
||||
# and will not pick up the system CA store automatically. Set the following
|
||||
# environment variables via configMap values (loaded through envFrom) to make them use the updated system bundle:
|
||||
# REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
|
||||
# SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||
runUpdateCaCertificates: false
|
||||
|
||||
|
||||
######################################################################
|
||||
#
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { Card } from "@opal/components/cards/card/components";
|
||||
import { Content, SizePreset } from "@opal/layouts";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { SvgEmpty } from "@opal/icons";
|
||||
import type {
|
||||
IconFunctionComponent,
|
||||
PaddingVariants,
|
||||
RichStr,
|
||||
} from "@opal/types";
|
||||
import type { IconFunctionComponent, PaddingVariants } from "@opal/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EmptyMessageCardBaseProps = {
|
||||
type EmptyMessageCardProps = {
|
||||
/** Icon displayed alongside the title. */
|
||||
icon?: IconFunctionComponent;
|
||||
|
||||
/** Primary message text. */
|
||||
title: string | RichStr;
|
||||
title: string;
|
||||
|
||||
/** Padding preset for the card. @default "md" */
|
||||
padding?: PaddingVariants;
|
||||
@@ -25,30 +21,16 @@ type EmptyMessageCardBaseProps = {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
};
|
||||
|
||||
type EmptyMessageCardProps =
|
||||
| (EmptyMessageCardBaseProps & {
|
||||
/** @default "secondary" */
|
||||
sizePreset?: "secondary";
|
||||
})
|
||||
| (EmptyMessageCardBaseProps & {
|
||||
sizePreset: "main-ui";
|
||||
/** Description text. Only supported when `sizePreset` is `"main-ui"`. */
|
||||
description?: string | RichStr;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmptyMessageCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyMessageCard(props: EmptyMessageCardProps) {
|
||||
const {
|
||||
sizePreset = "secondary",
|
||||
icon = SvgEmpty,
|
||||
title,
|
||||
padding = "md",
|
||||
ref,
|
||||
} = props;
|
||||
|
||||
function EmptyMessageCard({
|
||||
icon = SvgEmpty,
|
||||
title,
|
||||
padding = "md",
|
||||
ref,
|
||||
}: EmptyMessageCardProps) {
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
@@ -57,23 +39,13 @@ function EmptyMessageCard(props: EmptyMessageCardProps) {
|
||||
padding={padding}
|
||||
rounding="md"
|
||||
>
|
||||
{sizePreset === "secondary" ? (
|
||||
<Content
|
||||
icon={icon}
|
||||
title={title}
|
||||
sizePreset="secondary"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
/>
|
||||
) : (
|
||||
<Content
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={"description" in props ? props.description : undefined}
|
||||
sizePreset={sizePreset}
|
||||
variant="section"
|
||||
/>
|
||||
)}
|
||||
<Content
|
||||
icon={icon}
|
||||
title={title}
|
||||
sizePreset="secondary"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -18122,9 +18122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import FrostedDiv from "@/refresh-components/FrostedDiv";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
|
||||
export interface WelcomeMessageProps {
|
||||
agent?: MinimalPersonaSnapshot;
|
||||
@@ -39,26 +40,33 @@ export default function WelcomeMessage({
|
||||
|
||||
if (isDefaultAgent) {
|
||||
content = (
|
||||
<div data-testid="onyx-logo" className="flex flex-row items-center gap-4">
|
||||
<Section
|
||||
data-testid="onyx-logo"
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
gap={0.5}
|
||||
width="fit"
|
||||
>
|
||||
<Logo folded size={32} />
|
||||
<Text as="p" headingH2>
|
||||
{greeting}
|
||||
</Text>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
} else if (agent) {
|
||||
content = (
|
||||
<>
|
||||
<div
|
||||
data-testid="agent-name-display"
|
||||
className="flex flex-row items-center gap-3"
|
||||
>
|
||||
<AgentAvatar agent={agent} size={36} />
|
||||
<Text as="p" headingH2>
|
||||
{agent.name}
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
<Section
|
||||
data-testid="agent-name-display"
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
gap={0.5}
|
||||
width="fit"
|
||||
>
|
||||
<AgentAvatar agent={agent} size={36} />
|
||||
<Text as="p" headingH2>
|
||||
{agent.name}
|
||||
</Text>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,8 @@ export interface BackendMessage {
|
||||
// Multi-model answer generation
|
||||
preferred_response_id: number | null;
|
||||
model_display_name: string | null;
|
||||
// Non-null when the model errored during generation
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface MessageResponseIDInfo {
|
||||
|
||||
154
web/src/app/app/message/MultiModelPanel.tsx
Normal file
154
web/src/app/app/message/MultiModelPanel.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { Text } from "@opal/components";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { SvgEyeOff, SvgX } from "@opal/icons";
|
||||
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
|
||||
import AgentMessage, {
|
||||
AgentMessageProps,
|
||||
} from "@/app/app/message/messageComponents/AgentMessage";
|
||||
import { ErrorBanner } from "@/app/app/message/Resubmit";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markdown } from "@opal/utils";
|
||||
|
||||
export interface MultiModelPanelProps {
|
||||
/** Provider name for icon lookup */
|
||||
provider: string;
|
||||
/** Model name for icon lookup and display */
|
||||
modelName: string;
|
||||
/** Display-friendly model name */
|
||||
displayName: string;
|
||||
/** Whether this panel is the preferred/selected response */
|
||||
isPreferred: boolean;
|
||||
/** Whether this panel is currently hidden */
|
||||
isHidden: boolean;
|
||||
/** Whether this is a non-preferred panel in selection mode (pushed off-screen) */
|
||||
isNonPreferredInSelection: boolean;
|
||||
/** Callback when user clicks this panel to select as preferred */
|
||||
onSelect: () => void;
|
||||
/** Callback to hide/show this panel */
|
||||
onToggleVisibility: () => void;
|
||||
/** Props to pass through to AgentMessage */
|
||||
agentMessageProps: AgentMessageProps;
|
||||
/** Error message when this model failed */
|
||||
errorMessage?: string | null;
|
||||
/** Error code for display */
|
||||
errorCode?: string | null;
|
||||
/** Whether the error is retryable */
|
||||
isRetryable?: boolean;
|
||||
/** Stack trace for debugging */
|
||||
errorStackTrace?: string | null;
|
||||
/** Additional error details */
|
||||
errorDetails?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single model's response panel within the multi-model view.
|
||||
*
|
||||
* Renders in two states:
|
||||
* - **Hidden** — compact header strip only (provider icon + strikethrough name + show button).
|
||||
* - **Visible** — full header plus `AgentMessage` body. Clicking anywhere on a
|
||||
* visible non-preferred panel marks it as preferred.
|
||||
*
|
||||
* The `isNonPreferredInSelection` flag disables pointer events on the body and
|
||||
* hides the footer so the panel acts as a passive comparison surface.
|
||||
*/
|
||||
export default function MultiModelPanel({
|
||||
provider,
|
||||
modelName,
|
||||
displayName,
|
||||
isPreferred,
|
||||
isHidden,
|
||||
isNonPreferredInSelection,
|
||||
onSelect,
|
||||
onToggleVisibility,
|
||||
agentMessageProps,
|
||||
errorMessage,
|
||||
errorCode,
|
||||
isRetryable,
|
||||
errorStackTrace,
|
||||
errorDetails,
|
||||
}: MultiModelPanelProps) {
|
||||
const ProviderIcon = getProviderIcon(provider, modelName);
|
||||
|
||||
const handlePanelClick = useCallback(() => {
|
||||
if (!isHidden && !isPreferred) onSelect();
|
||||
}, [isHidden, isPreferred, onSelect]);
|
||||
|
||||
const header = (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-12",
|
||||
isPreferred ? "bg-background-tint-02" : "bg-background-tint-00"
|
||||
)}
|
||||
>
|
||||
<ContentAction
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
paddingVariant="lg"
|
||||
icon={ProviderIcon}
|
||||
title={isHidden ? markdown(`~~${displayName}~~`) : displayName}
|
||||
rightChildren={
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
{isPreferred && (
|
||||
<span className="text-action-link-05 shrink-0">
|
||||
<Text font="secondary-body" color="inherit" nowrap>
|
||||
Preferred Response
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
{!isPreferred && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={isHidden ? SvgEyeOff : SvgX}
|
||||
size="md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility();
|
||||
}}
|
||||
tooltip={isHidden ? "Show response" : "Hide response"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Hidden/collapsed panel — just the header row
|
||||
if (isHidden) {
|
||||
return header;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 min-w-0 rounded-16 transition-colors",
|
||||
!isPreferred && "cursor-pointer hover:bg-background-tint-02"
|
||||
)}
|
||||
onClick={handlePanelClick}
|
||||
>
|
||||
{header}
|
||||
{errorMessage ? (
|
||||
<div className="p-4">
|
||||
<ErrorBanner
|
||||
error={errorMessage}
|
||||
errorCode={errorCode || undefined}
|
||||
isRetryable={isRetryable ?? true}
|
||||
details={errorDetails || undefined}
|
||||
stackTrace={errorStackTrace}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(isNonPreferredInSelection && "pointer-events-none")}>
|
||||
<AgentMessage
|
||||
{...agentMessageProps}
|
||||
hideFooter={isNonPreferredInSelection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
416
web/src/app/app/message/MultiModelResponseView.tsx
Normal file
416
web/src/app/app/message/MultiModelResponseView.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { FullChatState } from "@/app/app/message/messageComponents/interfaces";
|
||||
import { Message } from "@/app/app/interfaces";
|
||||
import { LlmManager } from "@/lib/hooks";
|
||||
import { RegenerationFactory } from "@/app/app/message/messageComponents/AgentMessage";
|
||||
import MultiModelPanel from "@/app/app/message/MultiModelPanel";
|
||||
import { MultiModelResponse } from "@/app/app/message/interfaces";
|
||||
import { setPreferredResponse } from "@/app/app/services/lib";
|
||||
import { useChatSessionStore } from "@/app/app/stores/useChatSessionStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MultiModelResponseViewProps {
|
||||
responses: MultiModelResponse[];
|
||||
chatState: FullChatState;
|
||||
llmManager: LlmManager | null;
|
||||
onRegenerate?: RegenerationFactory;
|
||||
parentMessage?: Message | null;
|
||||
otherMessagesCanSwitchTo?: number[];
|
||||
onMessageSelection?: (nodeId: number) => void;
|
||||
/** Called whenever the set of hidden panel indices changes */
|
||||
onHiddenPanelsChange?: (hidden: Set<number>) => void;
|
||||
}
|
||||
|
||||
// How many pixels of a non-preferred panel are visible at the viewport edge
|
||||
const PEEK_W = 64;
|
||||
// Uniform panel width used in the selection-mode carousel
|
||||
const SELECTION_PANEL_W = 400;
|
||||
// Compact width for hidden panels in the carousel track
|
||||
const HIDDEN_PANEL_W = 220;
|
||||
// Generation-mode panel widths (from Figma)
|
||||
const GEN_PANEL_W_2 = 640; // 2 panels side-by-side
|
||||
const GEN_PANEL_W_3 = 436; // 3 panels side-by-side
|
||||
// Gap between panels — matches CSS gap-6 (24px)
|
||||
const PANEL_GAP = 24;
|
||||
// Minimum panel width before horizontal scroll kicks in
|
||||
const MIN_PANEL_W = 300;
|
||||
|
||||
/**
|
||||
* Renders N model responses side-by-side with two layout modes:
|
||||
*
|
||||
* **Generation mode** — equal-width panels in a horizontally-scrollable row.
|
||||
* Panel width is determined by the number of visible (non-hidden) panels.
|
||||
*
|
||||
* **Selection mode** — activated when the user clicks a panel to mark it as
|
||||
* preferred. All panels (including hidden ones) sit in a fixed-width carousel
|
||||
* track. A CSS `translateX` transform slides the track so the preferred panel
|
||||
* is centered in the viewport; the other panels peek in from the edges through
|
||||
* a mask gradient. Non-preferred visible panels are height-capped to the
|
||||
* preferred panel's measured height, dimmed at 50% opacity, and receive a
|
||||
* bottom fade-out overlay.
|
||||
*
|
||||
* Hidden panels render as a compact header-only strip at `HIDDEN_PANEL_W` in
|
||||
* both modes and are excluded from layout width calculations.
|
||||
*/
|
||||
export default function MultiModelResponseView({
|
||||
responses,
|
||||
chatState,
|
||||
llmManager,
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
otherMessagesCanSwitchTo,
|
||||
onMessageSelection,
|
||||
onHiddenPanelsChange,
|
||||
}: MultiModelResponseViewProps) {
|
||||
const [preferredIndex, setPreferredIndex] = useState<number | null>(null);
|
||||
const [hiddenPanels, setHiddenPanels] = useState<Set<number>>(new Set());
|
||||
// Controls animation: false = panels at start position, true = panels at peek position
|
||||
const [selectionEntered, setSelectionEntered] = useState(false);
|
||||
// Measures the overflow-hidden carousel container for responsive preferred-panel sizing.
|
||||
const [trackContainerW, setTrackContainerW] = useState(0);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const trackContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (roRef.current) {
|
||||
roRef.current.disconnect();
|
||||
roRef.current = null;
|
||||
}
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setTrackContainerW(entry?.contentRect.width ?? 0);
|
||||
});
|
||||
ro.observe(el);
|
||||
setTrackContainerW(el.offsetWidth);
|
||||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
// Measures the preferred panel's height to cap non-preferred panels in selection mode.
|
||||
const [preferredPanelHeight, setPreferredPanelHeight] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const preferredRoRef = useRef<ResizeObserver | null>(null);
|
||||
// Tracks which non-preferred panels overflow the preferred height cap
|
||||
const [overflowingPanels, setOverflowingPanels] = useState<Set<number>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
const preferredPanelRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (preferredRoRef.current) {
|
||||
preferredRoRef.current.disconnect();
|
||||
preferredRoRef.current = null;
|
||||
}
|
||||
if (!el) {
|
||||
setPreferredPanelHeight(null);
|
||||
return;
|
||||
}
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setPreferredPanelHeight(entry?.contentRect.height ?? 0);
|
||||
});
|
||||
ro.observe(el);
|
||||
setPreferredPanelHeight(el.offsetHeight);
|
||||
preferredRoRef.current = ro;
|
||||
}, []);
|
||||
|
||||
const isGenerating = useMemo(
|
||||
() => responses.some((r) => r.isGenerating),
|
||||
[responses]
|
||||
);
|
||||
|
||||
// Non-hidden responses — used for layout width decisions and selection-mode gating
|
||||
const visibleResponses = useMemo(
|
||||
() => responses.filter((r) => !hiddenPanels.has(r.modelIndex)),
|
||||
[responses, hiddenPanels]
|
||||
);
|
||||
|
||||
const toggleVisibility = useCallback(
|
||||
(modelIndex: number) => {
|
||||
setHiddenPanels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(modelIndex)) {
|
||||
next.delete(modelIndex);
|
||||
} else {
|
||||
// Don't hide the last visible panel
|
||||
const visibleCount = responses.length - next.size;
|
||||
if (visibleCount <= 1) return prev;
|
||||
next.add(modelIndex);
|
||||
}
|
||||
onHiddenPanelsChange?.(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[responses.length, onHiddenPanelsChange]
|
||||
);
|
||||
|
||||
const updateSessionMessageTree = useChatSessionStore(
|
||||
(state) => state.updateSessionMessageTree
|
||||
);
|
||||
const currentSessionId = useChatSessionStore(
|
||||
(state) => state.currentSessionId
|
||||
);
|
||||
|
||||
const handleSelectPreferred = useCallback(
|
||||
(modelIndex: number) => {
|
||||
if (isGenerating) return;
|
||||
setPreferredIndex(modelIndex);
|
||||
const response = responses.find((r) => r.modelIndex === modelIndex);
|
||||
if (!response) return;
|
||||
if (onMessageSelection) {
|
||||
onMessageSelection(response.nodeId);
|
||||
}
|
||||
|
||||
// Persist preferred response to backend + update local tree so the
|
||||
// input bar unblocks (awaitingPreferredSelection clears).
|
||||
if (parentMessage?.messageId && response.messageId && currentSessionId) {
|
||||
setPreferredResponse(parentMessage.messageId, response.messageId).catch(
|
||||
(err) => console.error("Failed to persist preferred response:", err)
|
||||
);
|
||||
|
||||
const tree = useChatSessionStore
|
||||
.getState()
|
||||
.sessions.get(currentSessionId)?.messageTree;
|
||||
if (tree) {
|
||||
const userMsg = tree.get(parentMessage.nodeId);
|
||||
if (userMsg) {
|
||||
const updated = new Map(tree);
|
||||
updated.set(parentMessage.nodeId, {
|
||||
...userMsg,
|
||||
preferredResponseId: response.messageId,
|
||||
});
|
||||
updateSessionMessageTree(currentSessionId, updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
isGenerating,
|
||||
responses,
|
||||
onMessageSelection,
|
||||
parentMessage,
|
||||
currentSessionId,
|
||||
updateSessionMessageTree,
|
||||
]
|
||||
);
|
||||
|
||||
// Clear preferred selection when generation starts
|
||||
useEffect(() => {
|
||||
if (isGenerating) {
|
||||
setPreferredIndex(null);
|
||||
}
|
||||
}, [isGenerating]);
|
||||
|
||||
// Find preferred panel position — used for both the selection guard and carousel layout
|
||||
const preferredIdx = responses.findIndex(
|
||||
(r) => r.modelIndex === preferredIndex
|
||||
);
|
||||
|
||||
// Selection mode when preferred is set, found in responses, not generating, and at least 2 visible panels
|
||||
const showSelectionMode =
|
||||
preferredIndex !== null &&
|
||||
preferredIdx !== -1 &&
|
||||
!isGenerating &&
|
||||
visibleResponses.length > 1;
|
||||
|
||||
// Trigger the slide-out animation one frame after entering selection mode
|
||||
useEffect(() => {
|
||||
if (!showSelectionMode) {
|
||||
setSelectionEntered(false);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => setSelectionEntered(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [showSelectionMode]);
|
||||
|
||||
// Build panel props — isHidden reflects actual hidden state
|
||||
const buildPanelProps = useCallback(
|
||||
(response: MultiModelResponse, isNonPreferred: boolean) => ({
|
||||
provider: response.provider,
|
||||
modelName: response.modelName,
|
||||
displayName: response.displayName,
|
||||
isPreferred: preferredIndex === response.modelIndex,
|
||||
isHidden: hiddenPanels.has(response.modelIndex),
|
||||
isNonPreferredInSelection: isNonPreferred,
|
||||
onSelect: () => handleSelectPreferred(response.modelIndex),
|
||||
onToggleVisibility: () => toggleVisibility(response.modelIndex),
|
||||
agentMessageProps: {
|
||||
rawPackets: response.packets,
|
||||
packetCount: response.packetCount,
|
||||
chatState,
|
||||
nodeId: response.nodeId,
|
||||
messageId: response.messageId,
|
||||
currentFeedback: response.currentFeedback,
|
||||
llmManager,
|
||||
otherMessagesCanSwitchTo,
|
||||
onMessageSelection,
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
},
|
||||
errorMessage: response.errorMessage,
|
||||
errorCode: response.errorCode,
|
||||
isRetryable: response.isRetryable,
|
||||
errorStackTrace: response.errorStackTrace,
|
||||
errorDetails: response.errorDetails,
|
||||
}),
|
||||
[
|
||||
preferredIndex,
|
||||
hiddenPanels,
|
||||
handleSelectPreferred,
|
||||
toggleVisibility,
|
||||
chatState,
|
||||
llmManager,
|
||||
otherMessagesCanSwitchTo,
|
||||
onMessageSelection,
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
]
|
||||
);
|
||||
|
||||
if (showSelectionMode) {
|
||||
// ── Selection Layout (transform-based carousel) ──
|
||||
//
|
||||
// All panels (including hidden) sit in the track at their original A/B/C positions.
|
||||
// Hidden panels use HIDDEN_PANEL_W; non-preferred use SELECTION_PANEL_W;
|
||||
// preferred uses dynamicPrefW (up to GEN_PANEL_W_2).
|
||||
const n = responses.length;
|
||||
|
||||
const dynamicPrefW =
|
||||
trackContainerW > 0
|
||||
? Math.min(trackContainerW - 2 * (PEEK_W + PANEL_GAP), GEN_PANEL_W_2)
|
||||
: GEN_PANEL_W_2;
|
||||
|
||||
const selectionWidths = responses.map((r, i) => {
|
||||
if (hiddenPanels.has(r.modelIndex)) return HIDDEN_PANEL_W;
|
||||
if (i === preferredIdx) return dynamicPrefW;
|
||||
return SELECTION_PANEL_W;
|
||||
});
|
||||
|
||||
const panelLeftEdges = selectionWidths.reduce<number[]>((acc, w, i) => {
|
||||
acc.push(i === 0 ? 0 : acc[i - 1]! + selectionWidths[i - 1]! + PANEL_GAP);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const preferredCenterInTrack =
|
||||
panelLeftEdges[preferredIdx]! + selectionWidths[preferredIdx]! / 2;
|
||||
|
||||
// Start position: hidden panels at HIDDEN_PANEL_W, visible at SELECTION_PANEL_W
|
||||
const uniformTrackW =
|
||||
responses.reduce(
|
||||
(sum, r) =>
|
||||
sum +
|
||||
(hiddenPanels.has(r.modelIndex) ? HIDDEN_PANEL_W : SELECTION_PANEL_W),
|
||||
0
|
||||
) +
|
||||
(n - 1) * PANEL_GAP;
|
||||
|
||||
const trackTransform = selectionEntered
|
||||
? `translateX(${trackContainerW / 2 - preferredCenterInTrack}px)`
|
||||
: `translateX(${(trackContainerW - uniformTrackW) / 2}px)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackContainerRef}
|
||||
className="w-full overflow-hidden"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
|
||||
WebkitMaskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-start"
|
||||
style={{
|
||||
gap: `${PANEL_GAP}px`,
|
||||
transition: selectionEntered
|
||||
? "transform 0.45s cubic-bezier(0.2, 0, 0, 1)"
|
||||
: "none",
|
||||
transform: trackTransform,
|
||||
}}
|
||||
>
|
||||
{responses.map((r, i) => {
|
||||
const isHidden = hiddenPanels.has(r.modelIndex);
|
||||
const isPref = r.modelIndex === preferredIndex;
|
||||
const isNonPref = !isHidden && !isPref;
|
||||
const finalW = selectionWidths[i]!;
|
||||
const startW = isHidden ? HIDDEN_PANEL_W : SELECTION_PANEL_W;
|
||||
const capped = isNonPref && preferredPanelHeight != null;
|
||||
const overflows = capped && overflowingPanels.has(r.modelIndex);
|
||||
return (
|
||||
<div
|
||||
key={r.modelIndex}
|
||||
ref={(el) => {
|
||||
if (isPref) preferredPanelRef(el);
|
||||
if (capped && el) {
|
||||
const doesOverflow = el.scrollHeight > el.clientHeight;
|
||||
setOverflowingPanels((prev) => {
|
||||
const had = prev.has(r.modelIndex);
|
||||
if (doesOverflow === had) return prev;
|
||||
const next = new Set(prev);
|
||||
if (doesOverflow) next.add(r.modelIndex);
|
||||
else next.delete(r.modelIndex);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: `${selectionEntered ? finalW : startW}px`,
|
||||
flexShrink: 0,
|
||||
transition: selectionEntered
|
||||
? "width 0.45s cubic-bezier(0.2, 0, 0, 1)"
|
||||
: "none",
|
||||
maxHeight: capped ? preferredPanelHeight : undefined,
|
||||
overflow: capped ? "hidden" : undefined,
|
||||
position: capped ? "relative" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className={cn(isNonPref && "opacity-50")}>
|
||||
<MultiModelPanel {...buildPanelProps(r, isNonPref)} />
|
||||
</div>
|
||||
{overflows && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-24 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, var(--background-tint-01) 0%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generation Layout (equal panels side-by-side) ──
|
||||
// Panel width based on number of visible (non-hidden) panels.
|
||||
const panelWidth =
|
||||
visibleResponses.length <= 2 ? GEN_PANEL_W_2 : GEN_PANEL_W_3;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex gap-6 items-start w-fit mx-auto">
|
||||
{responses.map((r) => {
|
||||
const isHidden = hiddenPanels.has(r.modelIndex);
|
||||
return (
|
||||
<div
|
||||
key={r.modelIndex}
|
||||
style={
|
||||
isHidden
|
||||
? {
|
||||
width: HIDDEN_PANEL_W,
|
||||
minWidth: HIDDEN_PANEL_W,
|
||||
maxWidth: HIDDEN_PANEL_W,
|
||||
flexShrink: 0,
|
||||
overflow: "hidden" as const,
|
||||
}
|
||||
: { width: panelWidth, minWidth: MIN_PANEL_W }
|
||||
}
|
||||
>
|
||||
<MultiModelPanel {...buildPanelProps(r, false)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
web/src/app/app/message/interfaces.ts
Normal file
23
web/src/app/app/message/interfaces.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Packet } from "@/app/app/services/streamingModels";
|
||||
import { FeedbackType } from "@/app/app/interfaces";
|
||||
|
||||
export interface MultiModelResponse {
|
||||
modelIndex: number;
|
||||
provider: string;
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
packets: Packet[];
|
||||
packetCount: number;
|
||||
nodeId: number;
|
||||
messageId?: number;
|
||||
|
||||
currentFeedback?: FeedbackType | null;
|
||||
isGenerating?: boolean;
|
||||
|
||||
// Error state (when this model failed)
|
||||
errorMessage?: string | null;
|
||||
errorCode?: string | null;
|
||||
isRetryable?: boolean;
|
||||
errorStackTrace?: string | null;
|
||||
errorDetails?: Record<string, any> | null;
|
||||
}
|
||||
@@ -49,6 +49,8 @@ export interface AgentMessageProps {
|
||||
parentMessage?: Message | null;
|
||||
// Duration in seconds for processing this message (agent messages only)
|
||||
processingDurationSeconds?: number;
|
||||
/** Hide the feedback/toolbar footer (used in multi-model non-preferred panels) */
|
||||
hideFooter?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Consider more robust comparisons:
|
||||
@@ -76,7 +78,8 @@ function arePropsEqual(
|
||||
prev.parentMessage?.messageId === next.parentMessage?.messageId &&
|
||||
prev.llmManager?.isLoadingProviders ===
|
||||
next.llmManager?.isLoadingProviders &&
|
||||
prev.processingDurationSeconds === next.processingDurationSeconds
|
||||
prev.processingDurationSeconds === next.processingDurationSeconds &&
|
||||
prev.hideFooter === next.hideFooter
|
||||
// Skip: chatState.regenerate, chatState.setPresentingDocument,
|
||||
// most of llmManager, onMessageSelection (function/object props)
|
||||
);
|
||||
@@ -95,6 +98,7 @@ const AgentMessage = React.memo(function AgentMessage({
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
processingDurationSeconds,
|
||||
hideFooter,
|
||||
}: AgentMessageProps) {
|
||||
const markdownRef = useRef<HTMLDivElement>(null);
|
||||
const finalAnswerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -326,7 +330,7 @@ const AgentMessage = React.memo(function AgentMessage({
|
||||
</div>
|
||||
|
||||
{/* Feedback buttons - only show when streaming and rendering complete */}
|
||||
{isComplete && (
|
||||
{isComplete && !hideFooter && (
|
||||
<MessageToolbar
|
||||
nodeId={nodeId}
|
||||
messageId={messageId}
|
||||
|
||||
@@ -222,8 +222,10 @@ export const MessageTextRenderer: MessageRenderer<
|
||||
const content = useMemo(() => {
|
||||
const wasUserCancelled = stopReason === StopReason.USER_CANCELLED;
|
||||
|
||||
// On user cancel, freeze at exactly what was already visible.
|
||||
if (wasUserCancelled) {
|
||||
// On user cancel during live streaming, freeze at exactly what was already
|
||||
// visible to prevent flicker. On history reload (animate=false), the ref
|
||||
// starts empty so we must use computedContent directly.
|
||||
if (wasUserCancelled && animate) {
|
||||
return lastVisibleContentRef.current;
|
||||
}
|
||||
|
||||
@@ -248,7 +250,13 @@ export const MessageTextRenderer: MessageRenderer<
|
||||
|
||||
// For normal completed responses, allow final full content.
|
||||
return computedContent;
|
||||
}, [computedContent, shouldUseAutoPlaybackSync, stopPacketSeen, stopReason]);
|
||||
}, [
|
||||
computedContent,
|
||||
shouldUseAutoPlaybackSync,
|
||||
stopPacketSeen,
|
||||
stopReason,
|
||||
animate,
|
||||
]);
|
||||
|
||||
// Sync the stable ref outside of useMemo to avoid side effects during render.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -337,11 +337,22 @@ export function usePacedTurnGroups(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [toolTurnGroups, revealTrigger, shouldBypassPacing]);
|
||||
|
||||
// Only return display groups when tool pacing is complete (or bypassing)
|
||||
// Only return display groups when tool pacing is complete (or bypassing).
|
||||
// Also bypass when stop packet is already seen (e.g. history reload of stopped messages)
|
||||
// to avoid the display staying blank while waiting for pacing to complete.
|
||||
const pacedDisplayGroups = useMemo(
|
||||
() => (shouldBypassPacing || state.toolPacingComplete ? displayGroups : []),
|
||||
() =>
|
||||
shouldBypassPacing || state.toolPacingComplete || stopPacketSeen
|
||||
? displayGroups
|
||||
: [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[state.toolPacingComplete, displayGroups, revealTrigger, shouldBypassPacing]
|
||||
[
|
||||
state.toolPacingComplete,
|
||||
displayGroups,
|
||||
revealTrigger,
|
||||
shouldBypassPacing,
|
||||
stopPacketSeen,
|
||||
]
|
||||
);
|
||||
|
||||
// Paced signals for header state consistency
|
||||
|
||||
@@ -361,7 +361,9 @@ export function processRawChatHistory(
|
||||
nodeId: messageInfo.message_id,
|
||||
messageId: messageInfo.message_id,
|
||||
message: messageInfo.message,
|
||||
type: messageInfo.message_type as "user" | "assistant",
|
||||
type: messageInfo.error
|
||||
? "error"
|
||||
: (messageInfo.message_type as "user" | "assistant"),
|
||||
files: messageInfo.files,
|
||||
alternateAgentID:
|
||||
messageInfo.alternate_assistant_id !== null
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
FileDescriptor,
|
||||
Message,
|
||||
MessageResponseIDInfo,
|
||||
MultiModelMessageResponseIDInfo,
|
||||
RegenerationState,
|
||||
RetrievalType,
|
||||
StreamingError,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
useCurrentMessageHistory,
|
||||
} from "@/app/app/stores/useChatSessionStore";
|
||||
import { Packet, MessageStart } from "@/app/app/services/streamingModels";
|
||||
import { SelectedModel } from "@/refresh-components/popovers/ModelSelector";
|
||||
import useAgentPreferences from "@/hooks/useAgentPreferences";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import { ProjectFile, useProjectsContext } from "@/providers/ProjectsContext";
|
||||
@@ -94,6 +96,8 @@ export interface OnSubmitProps {
|
||||
regenerationRequest?: RegenerationRequest | null;
|
||||
// Additional context injected into the LLM call but not stored/shown in chat.
|
||||
additionalContext?: string;
|
||||
/** When 2+ models, triggers multi-model parallel generation via backend. */
|
||||
selectedModels?: SelectedModel[];
|
||||
}
|
||||
|
||||
interface RegenerationRequest {
|
||||
@@ -370,7 +374,10 @@ export default function useChatController({
|
||||
modelOverride,
|
||||
regenerationRequest,
|
||||
additionalContext,
|
||||
selectedModels,
|
||||
}: OnSubmitProps) => {
|
||||
const isMultiModel =
|
||||
!regenerationRequest && (selectedModels?.length ?? 0) >= 2;
|
||||
const projectId = params(SEARCH_PARAM_NAMES.PROJECT_ID);
|
||||
{
|
||||
const params = new URLSearchParams(searchParams?.toString() || "");
|
||||
@@ -601,6 +608,7 @@ export default function useChatController({
|
||||
// immediately reflects the user message
|
||||
let initialUserNode: Message;
|
||||
let initialAgentNode: Message;
|
||||
let initialAssistantNodes: Message[] = [];
|
||||
|
||||
if (regenerationRequest) {
|
||||
// For regeneration: keep the existing user message, only create new agent
|
||||
@@ -623,12 +631,33 @@ export default function useChatController({
|
||||
);
|
||||
initialUserNode = result.initialUserNode;
|
||||
initialAgentNode = result.initialAgentNode;
|
||||
|
||||
// In multi-model mode, create N assistant placeholder nodes (one per model).
|
||||
// Set modelDisplayName/overridden_model immediately so ChatUI detects
|
||||
// multi-model from the first render (before any packets arrive).
|
||||
if (isMultiModel && selectedModels) {
|
||||
initialAssistantNodes = selectedModels.map((model, i) => {
|
||||
const node = buildEmptyMessage({
|
||||
messageType: "assistant",
|
||||
parentNodeId: initialUserNode.nodeId,
|
||||
nodeIdOffset: i + 1,
|
||||
});
|
||||
node.modelDisplayName = model.displayName;
|
||||
node.overridden_model = model.modelName;
|
||||
return node;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// make messages appear + clear input bar
|
||||
const messagesToUpsert = regenerationRequest
|
||||
? [initialAgentNode] // Only upsert the new agent for regeneration
|
||||
: [initialUserNode, initialAgentNode]; // Upsert both for normal/edit flow
|
||||
let messagesToUpsert: Message[];
|
||||
if (regenerationRequest) {
|
||||
messagesToUpsert = [initialAgentNode];
|
||||
} else if (isMultiModel) {
|
||||
messagesToUpsert = [initialUserNode, ...initialAssistantNodes];
|
||||
} else {
|
||||
messagesToUpsert = [initialUserNode, initialAgentNode];
|
||||
}
|
||||
currentMessageTreeLocal = upsertToCompleteMessageTree({
|
||||
messages: messagesToUpsert,
|
||||
completeMessageTreeOverride: currentMessageTreeLocal,
|
||||
@@ -662,6 +691,67 @@ export default function useChatController({
|
||||
let newUserMessageId: number | null = null;
|
||||
let newAgentMessageId: number | null = null;
|
||||
|
||||
// Multi-model per-model state (indexed by model_index from backend)
|
||||
const numModels = selectedModels?.length ?? 0;
|
||||
const assistantMessageIds: (number | null)[] = isMultiModel
|
||||
? Array(numModels).fill(null)
|
||||
: [];
|
||||
const packetsPerModel: Packet[][] = isMultiModel
|
||||
? Array.from({ length: numModels }, () => [])
|
||||
: [];
|
||||
const documentsPerModel: OnyxDocument[][] = isMultiModel
|
||||
? Array.from({ length: numModels }, () => [])
|
||||
: [];
|
||||
const citationsPerModel: (CitationMap | null)[] = isMultiModel
|
||||
? Array(numModels).fill(null)
|
||||
: [];
|
||||
// Track which models have errored so the bottom-of-loop upsert skips them
|
||||
const erroredModelIndices = new Set<number>();
|
||||
let modelDisplayNames: string[] = isMultiModel
|
||||
? selectedModels?.map((m) => m.displayName) ?? []
|
||||
: [];
|
||||
|
||||
/** Build a non-errored multi-model assistant node for upsert. */
|
||||
function buildAssistantNodeUpdate(
|
||||
idx: number,
|
||||
overrides?: Partial<Message>
|
||||
): Message {
|
||||
return {
|
||||
...initialAssistantNodes[idx]!,
|
||||
messageId: assistantMessageIds[idx] ?? undefined,
|
||||
message: "",
|
||||
type: "assistant" as const,
|
||||
retrievalType,
|
||||
query,
|
||||
documents: documentsPerModel[idx] || [],
|
||||
citations: citationsPerModel[idx] || {},
|
||||
files: [] as FileDescriptor[],
|
||||
toolCall: null,
|
||||
stackTrace: null,
|
||||
overridden_model: selectedModels?.[idx]?.modelName,
|
||||
modelDisplayName:
|
||||
modelDisplayNames[idx] ||
|
||||
selectedModels?.[idx]?.displayName ||
|
||||
null,
|
||||
stopReason,
|
||||
packets: packetsPerModel[idx] || [],
|
||||
packetCount: packetsPerModel[idx]?.length || 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build updated nodes for all non-errored models. */
|
||||
function buildNonErroredNodes(overrides?: Partial<Message>): Message[] {
|
||||
const nodes: Message[] = [];
|
||||
for (let idx = 0; idx < initialAssistantNodes.length; idx++) {
|
||||
if (erroredModelIndices.has(idx)) continue;
|
||||
nodes.push(buildAssistantNodeUpdate(idx, overrides));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
let streamSucceeded = false;
|
||||
|
||||
try {
|
||||
const lastSuccessfulMessageId = getLastSuccessfulMessageId(
|
||||
currentMessageTreeLocal
|
||||
@@ -710,13 +800,15 @@ export default function useChatController({
|
||||
filterManager.timeRange,
|
||||
filterManager.selectedTags
|
||||
),
|
||||
modelProvider:
|
||||
modelOverride?.name || llmManager.currentLlm.name || undefined,
|
||||
modelVersion:
|
||||
modelOverride?.modelName ||
|
||||
llmManager.currentLlm.modelName ||
|
||||
searchParams?.get(SEARCH_PARAM_NAMES.MODEL_VERSION) ||
|
||||
undefined,
|
||||
modelProvider: isMultiModel
|
||||
? undefined
|
||||
: modelOverride?.name || llmManager.currentLlm.name || undefined,
|
||||
modelVersion: isMultiModel
|
||||
? undefined
|
||||
: modelOverride?.modelName ||
|
||||
llmManager.currentLlm.modelName ||
|
||||
searchParams?.get(SEARCH_PARAM_NAMES.MODEL_VERSION) ||
|
||||
undefined,
|
||||
temperature: llmManager.temperature || undefined,
|
||||
deepResearch,
|
||||
enabledToolIds:
|
||||
@@ -728,6 +820,13 @@ export default function useChatController({
|
||||
forcedToolId: effectiveForcedToolId,
|
||||
origin: messageOrigin,
|
||||
additionalContext,
|
||||
llmOverrides: isMultiModel
|
||||
? selectedModels!.map((m) => ({
|
||||
model_provider: m.name,
|
||||
model_version: m.modelName,
|
||||
display_name: m.displayName,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const delay = (ms: number) => {
|
||||
@@ -780,6 +879,28 @@ export default function useChatController({
|
||||
.reserved_assistant_message_id;
|
||||
}
|
||||
|
||||
// Multi-model: handle reserved IDs for N parallel model responses.
|
||||
// This packet is metadata-only — skip the content-processing chain below.
|
||||
if (
|
||||
isMultiModel &&
|
||||
Object.hasOwn(packet, "responses") &&
|
||||
Array.isArray(
|
||||
(packet as MultiModelMessageResponseIDInfo).responses
|
||||
)
|
||||
) {
|
||||
const multiPacket = packet as MultiModelMessageResponseIDInfo;
|
||||
newUserMessageId =
|
||||
multiPacket.user_message_id ?? newUserMessageId;
|
||||
for (let mi = 0; mi < multiPacket.responses.length; mi++) {
|
||||
const slot = multiPacket.responses[mi]!;
|
||||
assistantMessageIds[mi] = slot.message_id;
|
||||
if (slot.model_name) {
|
||||
modelDisplayNames[mi] = slot.model_name;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.hasOwn(packet, "user_files")) {
|
||||
const userFiles = (packet as UserKnowledgeFilePacket).user_files;
|
||||
// Ensure files are unique by id
|
||||
@@ -804,17 +925,59 @@ export default function useChatController({
|
||||
(packet as any).error != null
|
||||
) {
|
||||
const streamingError = packet as StreamingError;
|
||||
error = streamingError.error;
|
||||
stackTrace = streamingError.stack_trace || null;
|
||||
errorCode = streamingError.error_code || null;
|
||||
isRetryable = streamingError.is_retryable ?? true;
|
||||
errorDetails = streamingError.details || null;
|
||||
|
||||
setUncaughtError(frozenSessionId, streamingError.error);
|
||||
updateChatStateAction(frozenSessionId, "input");
|
||||
updateSubmittedMessage(getCurrentSessionId(), "");
|
||||
// In multi-model mode, route per-model errors to the specific model's
|
||||
// node instead of killing the entire stream. Other models keep streaming.
|
||||
if (isMultiModel && streamingError.details?.model_index != null) {
|
||||
const errorModelIndex = streamingError.details
|
||||
.model_index as number;
|
||||
if (
|
||||
errorModelIndex >= 0 &&
|
||||
errorModelIndex < initialAssistantNodes.length
|
||||
) {
|
||||
const errorNode = initialAssistantNodes[errorModelIndex]!;
|
||||
erroredModelIndices.add(errorModelIndex);
|
||||
currentMessageTreeLocal = upsertToCompleteMessageTree({
|
||||
messages: [
|
||||
{
|
||||
...errorNode,
|
||||
message: streamingError.error,
|
||||
type: "error",
|
||||
stackTrace: streamingError.stack_trace || null,
|
||||
errorCode: streamingError.error_code || null,
|
||||
isRetryable: streamingError.is_retryable ?? true,
|
||||
errorDetails: streamingError.details || null,
|
||||
overridden_model:
|
||||
selectedModels?.[errorModelIndex]?.modelName,
|
||||
modelDisplayName:
|
||||
modelDisplayNames[errorModelIndex] ||
|
||||
selectedModels?.[errorModelIndex]?.displayName ||
|
||||
null,
|
||||
packets: [],
|
||||
packetCount: 0,
|
||||
is_generating: false,
|
||||
},
|
||||
],
|
||||
completeMessageTreeOverride: currentMessageTreeLocal,
|
||||
chatSessionId: frozenSessionId!,
|
||||
});
|
||||
}
|
||||
// Skip the normal per-packet upsert — we already upserted the error node
|
||||
continue;
|
||||
} else {
|
||||
// Single-model: kill the stream
|
||||
error = streamingError.error;
|
||||
stackTrace = streamingError.stack_trace || null;
|
||||
errorCode = streamingError.error_code || null;
|
||||
isRetryable = streamingError.is_retryable ?? true;
|
||||
errorDetails = streamingError.details || null;
|
||||
|
||||
throw new Error(streamingError.error);
|
||||
setUncaughtError(frozenSessionId, streamingError.error);
|
||||
updateChatStateAction(frozenSessionId, "input");
|
||||
updateSubmittedMessage(getCurrentSessionId(), "");
|
||||
|
||||
throw new Error(streamingError.error);
|
||||
}
|
||||
} else if (Object.hasOwn(packet, "message_id")) {
|
||||
finalMessage = packet as BackendMessage;
|
||||
} else if (Object.hasOwn(packet, "stop_reason")) {
|
||||
@@ -823,32 +986,86 @@ export default function useChatController({
|
||||
updateCanContinue(true, frozenSessionId);
|
||||
}
|
||||
} else if (Object.hasOwn(packet, "obj")) {
|
||||
packets.push(packet as Packet);
|
||||
packetsVersion++;
|
||||
const typedPacket = packet as Packet;
|
||||
const packetObj = typedPacket.obj;
|
||||
|
||||
// Check if the packet contains document information
|
||||
const packetObj = (packet as Packet).obj;
|
||||
if (isMultiModel) {
|
||||
// Multi-model: route packet by placement.model_index.
|
||||
// OverallStop (type "stop") has model_index=null — it's a global
|
||||
// terminal packet that must be delivered to ALL models so each
|
||||
// panel's AgentMessage sees the stop and exits "Thinking..." state.
|
||||
const isGlobalStop =
|
||||
packetObj.type === "stop" &&
|
||||
typedPacket.placement?.model_index == null;
|
||||
|
||||
if (packetObj.type === "citation_info") {
|
||||
// Individual citation packet from backend streaming
|
||||
const citationInfo = packetObj as {
|
||||
type: "citation_info";
|
||||
citation_number: number;
|
||||
document_id: string;
|
||||
};
|
||||
// Incrementally build citations map
|
||||
citations = {
|
||||
...(citations || {}),
|
||||
[citationInfo.citation_number]: citationInfo.document_id,
|
||||
};
|
||||
} else if (packetObj.type === "message_start") {
|
||||
const messageStart = packetObj as MessageStart;
|
||||
if (messageStart.final_documents) {
|
||||
documents = messageStart.final_documents;
|
||||
updateSelectedNodeForDocDisplay(
|
||||
frozenSessionId,
|
||||
initialAgentNode.nodeId
|
||||
);
|
||||
if (isGlobalStop) {
|
||||
for (let mi = 0; mi < packetsPerModel.length; mi++) {
|
||||
packetsPerModel[mi] = [
|
||||
...packetsPerModel[mi]!,
|
||||
typedPacket,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const modelIndex = typedPacket.placement?.model_index ?? 0;
|
||||
if (
|
||||
!isGlobalStop &&
|
||||
modelIndex >= 0 &&
|
||||
modelIndex < packetsPerModel.length
|
||||
) {
|
||||
packetsPerModel[modelIndex] = [
|
||||
...packetsPerModel[modelIndex]!,
|
||||
typedPacket,
|
||||
];
|
||||
|
||||
if (packetObj.type === "citation_info") {
|
||||
const citationInfo = packetObj as {
|
||||
type: "citation_info";
|
||||
citation_number: number;
|
||||
document_id: string;
|
||||
};
|
||||
citationsPerModel[modelIndex] = {
|
||||
...(citationsPerModel[modelIndex] || {}),
|
||||
[citationInfo.citation_number]: citationInfo.document_id,
|
||||
};
|
||||
} else if (packetObj.type === "message_start") {
|
||||
const messageStart = packetObj as MessageStart;
|
||||
if (messageStart.final_documents) {
|
||||
documentsPerModel[modelIndex] =
|
||||
messageStart.final_documents;
|
||||
if (modelIndex === 0 && initialAssistantNodes[0]) {
|
||||
updateSelectedNodeForDocDisplay(
|
||||
frozenSessionId,
|
||||
initialAssistantNodes[0].nodeId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single-model
|
||||
packets.push(typedPacket);
|
||||
packetsVersion++;
|
||||
|
||||
if (packetObj.type === "citation_info") {
|
||||
const citationInfo = packetObj as {
|
||||
type: "citation_info";
|
||||
citation_number: number;
|
||||
document_id: string;
|
||||
};
|
||||
citations = {
|
||||
...(citations || {}),
|
||||
[citationInfo.citation_number]: citationInfo.document_id,
|
||||
};
|
||||
} else if (packetObj.type === "message_start") {
|
||||
const messageStart = packetObj as MessageStart;
|
||||
if (messageStart.final_documents) {
|
||||
documents = messageStart.final_documents;
|
||||
updateSelectedNodeForDocDisplay(
|
||||
frozenSessionId,
|
||||
initialAgentNode.nodeId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -860,8 +1077,26 @@ export default function useChatController({
|
||||
parentMessage =
|
||||
parentMessage || currentMessageTreeLocal?.get(SYSTEM_NODE_ID)!;
|
||||
|
||||
currentMessageTreeLocal = upsertToCompleteMessageTree({
|
||||
messages: [
|
||||
// Build the messages to upsert based on single vs multi-model mode
|
||||
let messagesToUpsertInLoop: Message[];
|
||||
|
||||
if (isMultiModel) {
|
||||
// Read the current user node from the tree to preserve childrenNodeIds
|
||||
// (initialUserNode has stale/empty children from creation time).
|
||||
const currentUserNode =
|
||||
currentMessageTreeLocal.get(initialUserNode.nodeId) ||
|
||||
initialUserNode;
|
||||
const updatedUserNode: Message = {
|
||||
...currentUserNode,
|
||||
messageId: newUserMessageId ?? undefined,
|
||||
files: files,
|
||||
};
|
||||
messagesToUpsertInLoop = [
|
||||
updatedUserNode,
|
||||
...buildNonErroredNodes(),
|
||||
];
|
||||
} else {
|
||||
messagesToUpsertInLoop = [
|
||||
{
|
||||
...initialUserNode,
|
||||
messageId: newUserMessageId ?? undefined,
|
||||
@@ -894,8 +1129,11 @@ export default function useChatController({
|
||||
: undefined;
|
||||
})(),
|
||||
},
|
||||
],
|
||||
// Pass the latest map state
|
||||
];
|
||||
}
|
||||
|
||||
currentMessageTreeLocal = upsertToCompleteMessageTree({
|
||||
messages: messagesToUpsertInLoop,
|
||||
completeMessageTreeOverride: currentMessageTreeLocal,
|
||||
chatSessionId: frozenSessionId!,
|
||||
});
|
||||
@@ -906,41 +1144,70 @@ export default function useChatController({
|
||||
if (stack.error) {
|
||||
throw new Error(stack.error);
|
||||
}
|
||||
streamSucceeded = true;
|
||||
} catch (e: any) {
|
||||
console.log("Error:", e);
|
||||
const errorMsg = e.message;
|
||||
currentMessageTreeLocal = upsertToCompleteMessageTree({
|
||||
messages: [
|
||||
{
|
||||
nodeId: initialUserNode.nodeId,
|
||||
message: currMessage,
|
||||
type: "user",
|
||||
files: effectiveFileDescriptors,
|
||||
toolCall: null,
|
||||
parentNodeId: parentMessage?.nodeId || SYSTEM_NODE_ID,
|
||||
packets: [],
|
||||
packetCount: 0,
|
||||
},
|
||||
{
|
||||
nodeId: initialAgentNode.nodeId,
|
||||
const userErrorNode: Message = {
|
||||
nodeId: initialUserNode.nodeId,
|
||||
message: currMessage,
|
||||
type: "user",
|
||||
files: effectiveFileDescriptors,
|
||||
toolCall: null,
|
||||
parentNodeId: parentMessage?.nodeId || SYSTEM_NODE_ID,
|
||||
packets: [],
|
||||
packetCount: 0,
|
||||
};
|
||||
|
||||
// In multi-model mode, mark non-errored assistant nodes as errors.
|
||||
// Skip models that already have their own per-model error state.
|
||||
// In single-model mode, mark the one agent node.
|
||||
const errorAssistantNodes: Message[] = isMultiModel
|
||||
? buildNonErroredNodes({
|
||||
message: errorMsg,
|
||||
type: "error",
|
||||
files: aiMessageImages || [],
|
||||
toolCall: null,
|
||||
parentNodeId: initialUserNode.nodeId,
|
||||
type: "error" as const,
|
||||
packets: [],
|
||||
packetCount: 0,
|
||||
stackTrace: stackTrace,
|
||||
errorCode: errorCode,
|
||||
isRetryable: isRetryable,
|
||||
errorDetails: errorDetails,
|
||||
},
|
||||
],
|
||||
stackTrace,
|
||||
errorCode,
|
||||
isRetryable,
|
||||
errorDetails,
|
||||
})
|
||||
: [
|
||||
{
|
||||
nodeId: initialAgentNode.nodeId,
|
||||
message: errorMsg,
|
||||
type: "error" as const,
|
||||
files: aiMessageImages || [],
|
||||
toolCall: null,
|
||||
parentNodeId: initialUserNode.nodeId,
|
||||
packets: [],
|
||||
packetCount: 0,
|
||||
stackTrace: stackTrace,
|
||||
errorCode: errorCode,
|
||||
isRetryable: isRetryable,
|
||||
errorDetails: errorDetails,
|
||||
},
|
||||
];
|
||||
|
||||
currentMessageTreeLocal = upsertToCompleteMessageTree({
|
||||
messages: [userErrorNode, ...errorAssistantNodes],
|
||||
completeMessageTreeOverride: currentMessageTreeLocal,
|
||||
chatSessionId: frozenSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// After streaming completes (normal or stop), mark all non-errored
|
||||
// multi-model assistant nodes as done generating so panels exit
|
||||
// "Thinking..." state.
|
||||
if (isMultiModel && initialAssistantNodes.length > 0 && streamSucceeded) {
|
||||
upsertToCompleteMessageTree({
|
||||
messages: buildNonErroredNodes({ is_generating: false }),
|
||||
completeMessageTreeOverride: currentMessageTreeLocal,
|
||||
chatSessionId: frozenSessionId!,
|
||||
});
|
||||
}
|
||||
|
||||
resetRegenerationState(frozenSessionId);
|
||||
setStreamingStartTime(frozenSessionId, null);
|
||||
updateChatStateAction(frozenSessionId, "input");
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@/refresh-components/popovers/ModelSelector";
|
||||
import { LLMOverride } from "@/app/app/services/lib";
|
||||
import { LlmManager } from "@/lib/hooks";
|
||||
import { buildLlmOptions } from "@/refresh-components/popovers/LLMPopover";
|
||||
import { buildLlmOptions } from "@/refresh-components/popovers/llmUtils";
|
||||
|
||||
export interface UseMultiModelChatReturn {
|
||||
/** Currently selected models for multi-model comparison. */
|
||||
@@ -172,7 +172,7 @@ export default function useMultiModelChat(
|
||||
|
||||
const buildLlmOverrides = useCallback((): LLMOverride[] => {
|
||||
return selectedModels.map((m) => ({
|
||||
model_provider: m.provider,
|
||||
model_provider: m.name,
|
||||
model_version: m.modelName,
|
||||
display_name: m.displayName,
|
||||
}));
|
||||
|
||||
@@ -89,6 +89,18 @@ export const KeyWideLayout: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<KeyValueInput
|
||||
keyTitle="Key"
|
||||
valueTitle="Value"
|
||||
items={[{ key: "LOCKED", value: "cannot-edit" }]}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const EmptyLineMode: Story = {
|
||||
render: function EmptyStory() {
|
||||
const [items, setItems] = React.useState<KeyValue[]>([]);
|
||||
|
||||
@@ -68,13 +68,21 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useId,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import InputTypeIn from "./InputTypeIn";
|
||||
import { Button, EmptyMessageCard } from "@opal/components";
|
||||
import type { WithoutStyles } from "@opal/types";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { ErrorTextLayout } from "@/layouts/input-layouts";
|
||||
import { FieldContext } from "../form/FieldContext";
|
||||
import { FieldMessage } from "../messages/FieldMessage";
|
||||
import { SvgMinusCircle, SvgPlusCircle } from "@opal/icons";
|
||||
|
||||
export type KeyValue = { key: string; value: string };
|
||||
@@ -99,50 +107,82 @@ const GRID_COLS = {
|
||||
interface KeyValueInputItemProps {
|
||||
item: KeyValue;
|
||||
onChange: (next: KeyValue) => void;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
error?: KeyValueError;
|
||||
canRemove: boolean;
|
||||
index: number;
|
||||
fieldId: string;
|
||||
}
|
||||
|
||||
function KeyValueInputItem({
|
||||
item,
|
||||
onChange,
|
||||
disabled,
|
||||
onRemove,
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
error,
|
||||
canRemove,
|
||||
index,
|
||||
fieldId,
|
||||
}: KeyValueInputItemProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<InputTypeIn
|
||||
placeholder={keyPlaceholder}
|
||||
placeholder={keyPlaceholder || "Key"}
|
||||
value={item.key}
|
||||
onChange={(e) => onChange({ ...item, key: e.target.value })}
|
||||
aria-label={`${keyPlaceholder || "Key"} ${index + 1}`}
|
||||
aria-invalid={!!error?.key}
|
||||
aria-describedby={
|
||||
error?.key ? `${fieldId}-key-error-${index}` : undefined
|
||||
}
|
||||
variant={disabled ? "disabled" : undefined}
|
||||
showClearButton={false}
|
||||
/>
|
||||
{error?.key && <ErrorTextLayout>{error.key}</ErrorTextLayout>}
|
||||
{error?.key && (
|
||||
<FieldMessage variant="error" className="ml-0.5">
|
||||
<FieldMessage.Content
|
||||
id={`${fieldId}-key-error-${index}`}
|
||||
role="alert"
|
||||
className="ml-0.5"
|
||||
>
|
||||
{error.key}
|
||||
</FieldMessage.Content>
|
||||
</FieldMessage>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<InputTypeIn
|
||||
placeholder={valuePlaceholder}
|
||||
placeholder={valuePlaceholder || "Value"}
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ ...item, value: e.target.value })}
|
||||
aria-label={`${valuePlaceholder || "Value"} ${index + 1}`}
|
||||
aria-invalid={!!error?.value}
|
||||
aria-describedby={
|
||||
error?.value ? `${fieldId}-value-error-${index}` : undefined
|
||||
}
|
||||
variant={disabled ? "disabled" : undefined}
|
||||
showClearButton={false}
|
||||
/>
|
||||
{error?.value && <ErrorTextLayout>{error.value}</ErrorTextLayout>}
|
||||
{error?.value && (
|
||||
<FieldMessage variant="error" className="ml-0.5">
|
||||
<FieldMessage.Content
|
||||
id={`${fieldId}-value-error-${index}`}
|
||||
role="alert"
|
||||
className="ml-0.5"
|
||||
>
|
||||
{error.value}
|
||||
</FieldMessage.Content>
|
||||
</FieldMessage>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
disabled={!canRemove}
|
||||
disabled={disabled || !canRemove}
|
||||
prominence="tertiary"
|
||||
icon={SvgMinusCircle}
|
||||
onClick={onRemove}
|
||||
@@ -158,31 +198,46 @@ export interface KeyValueInputProps
|
||||
> {
|
||||
/** Title for the key column */
|
||||
keyTitle?: string;
|
||||
|
||||
/** Title for the value column */
|
||||
valueTitle?: string;
|
||||
|
||||
/** Placeholder for the key input */
|
||||
keyPlaceholder?: string;
|
||||
|
||||
/** Placeholder for the value input */
|
||||
valuePlaceholder?: string;
|
||||
|
||||
/** Array of key-value pairs */
|
||||
items: KeyValue[];
|
||||
|
||||
/** Callback when items change */
|
||||
onChange: (nextItems: KeyValue[]) => void;
|
||||
|
||||
/** Custom add handler */
|
||||
onAdd?: () => void;
|
||||
/** Custom remove handler */
|
||||
onRemove?: (index: number) => void;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Mode: 'line' allows removing all items, 'fixed-line' requires at least one item */
|
||||
mode?: "line" | "fixed-line";
|
||||
|
||||
/** Layout: 'equal' - both inputs same width, 'key-wide' - key input is wider (60/40 split) */
|
||||
layout?: "equal" | "key-wide";
|
||||
|
||||
/** Callback when validation state changes */
|
||||
onValidationChange?: (isValid: boolean, errors: KeyValueError[]) => void;
|
||||
/** Callback to handle validation errors - integrates with Formik or custom error handling. Called with error message when invalid, null when valid */
|
||||
onValidationError?: (errorMessage: string | null) => void;
|
||||
|
||||
/** Optional custom validator for the key field. Return { isValid, message } */
|
||||
onKeyValidate?: (
|
||||
key: string,
|
||||
index: number,
|
||||
item: KeyValue,
|
||||
items: KeyValue[]
|
||||
) => { isValid: boolean; message?: string };
|
||||
/** Optional custom validator for the value field. Return { isValid, message } */
|
||||
onValueValidate?: (
|
||||
value: string,
|
||||
index: number,
|
||||
item: KeyValue,
|
||||
items: KeyValue[]
|
||||
) => { isValid: boolean; message?: string };
|
||||
/** Whether to validate for duplicate keys */
|
||||
validateDuplicateKeys?: boolean;
|
||||
/** Whether to validate for empty keys */
|
||||
validateEmptyKeys?: boolean;
|
||||
/** Optional name for the field (for accessibility) */
|
||||
name?: string;
|
||||
/** Custom label for the add button (defaults to "Add Line") */
|
||||
addButtonLabel?: string;
|
||||
}
|
||||
@@ -190,16 +245,26 @@ export interface KeyValueInputProps
|
||||
export default function KeyValueInput({
|
||||
keyTitle = "Key",
|
||||
valueTitle = "Value",
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
items = [],
|
||||
onChange,
|
||||
onAdd,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
mode = "line",
|
||||
layout = "equal",
|
||||
onValidationChange,
|
||||
onValidationError,
|
||||
onKeyValidate,
|
||||
onValueValidate,
|
||||
validateDuplicateKeys = true,
|
||||
validateEmptyKeys = true,
|
||||
name,
|
||||
addButtonLabel = "Add Line",
|
||||
...rest
|
||||
}: KeyValueInputProps) {
|
||||
// Try to get field context if used within FormField (safe access)
|
||||
const fieldContext = useContext(FieldContext);
|
||||
|
||||
// Validation logic
|
||||
const errors = useMemo((): KeyValueError[] => {
|
||||
if (!items || items.length === 0) return [];
|
||||
@@ -208,8 +273,12 @@ export default function KeyValueInput({
|
||||
const keyCount = new Map<string, number[]>();
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// Validate empty keys
|
||||
if (item.key.trim() === "" && item.value.trim() !== "") {
|
||||
// Validate empty keys - only if value is filled (user is actively working on this row)
|
||||
if (
|
||||
validateEmptyKeys &&
|
||||
item.key.trim() === "" &&
|
||||
item.value.trim() !== ""
|
||||
) {
|
||||
const error = errorsList[index];
|
||||
if (error) {
|
||||
error.key = "Key cannot be empty";
|
||||
@@ -222,22 +291,56 @@ export default function KeyValueInput({
|
||||
existing.push(index);
|
||||
keyCount.set(item.key, existing);
|
||||
}
|
||||
});
|
||||
|
||||
// Validate duplicate keys
|
||||
keyCount.forEach((indices, key) => {
|
||||
if (indices.length > 1) {
|
||||
indices.forEach((index) => {
|
||||
// Custom key validation
|
||||
if (onKeyValidate) {
|
||||
const result = onKeyValidate(item.key, index, item, items);
|
||||
if (result && result.isValid === false) {
|
||||
const error = errorsList[index];
|
||||
if (error) {
|
||||
error.key = "Duplicate key";
|
||||
error.key = result.message || "Invalid key";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Custom value validation
|
||||
if (onValueValidate) {
|
||||
const result = onValueValidate(item.value, index, item, items);
|
||||
if (result && result.isValid === false) {
|
||||
const error = errorsList[index];
|
||||
if (error) {
|
||||
error.value = result.message || "Invalid value";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Validate duplicate keys
|
||||
if (validateDuplicateKeys) {
|
||||
keyCount.forEach((indices, key) => {
|
||||
if (indices.length > 1) {
|
||||
indices.forEach((index) => {
|
||||
const error = errorsList[index];
|
||||
if (error) {
|
||||
error.key = "Duplicate key";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return errorsList;
|
||||
}, [items]);
|
||||
}, [
|
||||
items,
|
||||
validateDuplicateKeys,
|
||||
validateEmptyKeys,
|
||||
onKeyValidate,
|
||||
onValueValidate,
|
||||
]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
return errors.every((error) => !error.key && !error.value);
|
||||
}, [errors]);
|
||||
|
||||
const hasAnyError = useMemo(() => {
|
||||
return errors.some((error) => error.key || error.value);
|
||||
@@ -268,12 +371,21 @@ export default function KeyValueInput({
|
||||
}, [hasAnyError, errors]);
|
||||
|
||||
// Notify parent of validation changes
|
||||
const onValidationChangeRef = useRef(onValidationChange);
|
||||
const onValidationErrorRef = useRef(onValidationError);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChangeRef.current = onValidationChange;
|
||||
}, [onValidationChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationErrorRef.current = onValidationError;
|
||||
}, [onValidationError]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChangeRef.current?.(isValid, errors);
|
||||
}, [isValid, errors]);
|
||||
|
||||
// Notify parent of error state for form library integration
|
||||
useEffect(() => {
|
||||
onValidationErrorRef.current?.(errorMessage);
|
||||
@@ -282,17 +394,25 @@ export default function KeyValueInput({
|
||||
const canRemoveItems = mode === "line" || items.length > 1;
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
if (onAdd) {
|
||||
onAdd();
|
||||
return;
|
||||
}
|
||||
onChange([...(items || []), { key: "", value: "" }]);
|
||||
}, [onChange, items]);
|
||||
}, [onAdd, onChange, items]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
if (!canRemoveItems && items.length === 1) return;
|
||||
|
||||
if (onRemove) {
|
||||
onRemove(index);
|
||||
return;
|
||||
}
|
||||
const next = (items || []).filter((_, i) => i !== index);
|
||||
onChange(next);
|
||||
},
|
||||
[canRemoveItems, items, onChange]
|
||||
[canRemoveItems, items, onRemove, onChange]
|
||||
);
|
||||
|
||||
const handleItemChange = useCallback(
|
||||
@@ -311,6 +431,8 @@ export default function KeyValueInput({
|
||||
}
|
||||
}, [mode]); // Only run on mode change
|
||||
|
||||
const autoId = useId();
|
||||
const fieldId = fieldContext?.baseId || name || `key-value-input-${autoId}`;
|
||||
const gridCols = GRID_COLS[layout];
|
||||
|
||||
return (
|
||||
@@ -338,24 +460,23 @@ export default function KeyValueInput({
|
||||
key={index}
|
||||
item={item}
|
||||
onChange={(next) => handleItemChange(index, next)}
|
||||
disabled={disabled}
|
||||
onRemove={() => handleRemove(index)}
|
||||
keyPlaceholder={keyPlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
keyPlaceholder={keyTitle}
|
||||
valuePlaceholder={valueTitle}
|
||||
error={errors[index]}
|
||||
canRemove={canRemoveItems}
|
||||
index={index}
|
||||
fieldId={fieldId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyMessageCard
|
||||
title="No items added yet."
|
||||
padding="sm"
|
||||
sizePreset="secondary"
|
||||
/>
|
||||
<EmptyMessageCard title="No items added yet." />
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={disabled}
|
||||
prominence="secondary"
|
||||
onClick={handleAdd}
|
||||
icon={SvgPlusCircle}
|
||||
|
||||
@@ -26,54 +26,7 @@ export interface LLMPopoverProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function buildLlmOptions(
|
||||
llmProviders: LLMProviderDescriptor[] | undefined,
|
||||
currentModelName?: string
|
||||
): LLMOption[] {
|
||||
if (!llmProviders) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Track seen combinations of provider + exact model name to avoid true duplicates
|
||||
// (same model appearing from multiple LLM provider configs with same provider type)
|
||||
const seenKeys = new Set<string>();
|
||||
const options: LLMOption[] = [];
|
||||
|
||||
llmProviders.forEach((llmProvider) => {
|
||||
llmProvider.model_configurations
|
||||
.filter(
|
||||
(modelConfiguration) =>
|
||||
modelConfiguration.is_visible ||
|
||||
modelConfiguration.name === currentModelName
|
||||
)
|
||||
.forEach((modelConfiguration) => {
|
||||
// Deduplicate by exact provider + model name combination
|
||||
const key = `${llmProvider.provider}:${modelConfiguration.name}`;
|
||||
if (seenKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
|
||||
options.push({
|
||||
name: llmProvider.name,
|
||||
provider: llmProvider.provider,
|
||||
providerDisplayName:
|
||||
llmProvider.provider_display_name || llmProvider.provider,
|
||||
modelName: modelConfiguration.name,
|
||||
displayName:
|
||||
modelConfiguration.display_name || modelConfiguration.name,
|
||||
vendor: modelConfiguration.vendor || null,
|
||||
maxInputTokens: modelConfiguration.max_input_tokens,
|
||||
region: modelConfiguration.region || null,
|
||||
version: modelConfiguration.version || null,
|
||||
supportsReasoning: modelConfiguration.supports_reasoning || false,
|
||||
supportsImageInput: modelConfiguration.supports_image_input || false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
export { buildLlmOptions } from "./llmUtils";
|
||||
|
||||
export function groupLlmOptions(
|
||||
filteredOptions: LLMOption[]
|
||||
|
||||
@@ -144,10 +144,10 @@ export default function ModelSelector({
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
paddingXRem={0.5}
|
||||
paddingYRem={0.5}
|
||||
className="h-5"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center shrink-0">
|
||||
{selectedModels.map((model, index) => {
|
||||
const ProviderIcon = getProviderIcon(
|
||||
model.provider,
|
||||
|
||||
56
web/src/refresh-components/popovers/llmUtils.ts
Normal file
56
web/src/refresh-components/popovers/llmUtils.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { LLMProviderDescriptor } from "@/interfaces/llm";
|
||||
import { LLMOption } from "./interfaces";
|
||||
|
||||
/**
|
||||
* Build a flat list of LLM options from provider descriptors.
|
||||
* Pure utility — no React dependencies. Used by ModelSelector,
|
||||
* useMultiModelChat, ChatUI, and LLMPopover.
|
||||
*/
|
||||
export function buildLlmOptions(
|
||||
llmProviders: LLMProviderDescriptor[] | undefined,
|
||||
currentModelName?: string
|
||||
): LLMOption[] {
|
||||
if (!llmProviders) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Track seen combinations of provider + exact model name to avoid true duplicates
|
||||
// (same model appearing from multiple LLM provider configs with same provider type)
|
||||
const seenKeys = new Set<string>();
|
||||
const options: LLMOption[] = [];
|
||||
|
||||
llmProviders.forEach((llmProvider) => {
|
||||
llmProvider.model_configurations
|
||||
.filter(
|
||||
(modelConfiguration) =>
|
||||
modelConfiguration.is_visible ||
|
||||
modelConfiguration.name === currentModelName
|
||||
)
|
||||
.forEach((modelConfiguration) => {
|
||||
// Deduplicate by exact provider + model name combination
|
||||
const key = `${llmProvider.provider}:${modelConfiguration.name}`;
|
||||
if (seenKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
|
||||
options.push({
|
||||
name: llmProvider.name,
|
||||
provider: llmProvider.provider,
|
||||
providerDisplayName:
|
||||
llmProvider.provider_display_name || llmProvider.provider,
|
||||
modelName: modelConfiguration.name,
|
||||
displayName:
|
||||
modelConfiguration.display_name || modelConfiguration.name,
|
||||
vendor: modelConfiguration.vendor || null,
|
||||
maxInputTokens: modelConfiguration.max_input_tokens,
|
||||
region: modelConfiguration.region || null,
|
||||
version: modelConfiguration.version || null,
|
||||
supportsReasoning: modelConfiguration.supports_reasoning || false,
|
||||
supportsImageInput: modelConfiguration.supports_image_input || false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -33,6 +33,8 @@ import { SourceMetadata } from "@/lib/search/interfaces";
|
||||
import { FederatedConnectorDetail, UserRole, ValidSources } from "@/lib/types";
|
||||
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
|
||||
import useChatController from "@/hooks/useChatController";
|
||||
import useMultiModelChat from "@/hooks/useMultiModelChat";
|
||||
import ModelSelector from "@/refresh-components/popovers/ModelSelector";
|
||||
import useAgentController from "@/hooks/useAgentController";
|
||||
import useChatSessionController from "@/hooks/useChatSessionController";
|
||||
import useDeepResearchToggle from "@/hooks/useDeepResearchToggle";
|
||||
@@ -41,6 +43,7 @@ import AgentDescription from "@/app/app/components/AgentDescription";
|
||||
import {
|
||||
useChatSessionStore,
|
||||
useCurrentMessageHistory,
|
||||
useCurrentMessageTree,
|
||||
} from "@/app/app/stores/useChatSessionStore";
|
||||
import {
|
||||
useCurrentChatState,
|
||||
@@ -68,6 +71,7 @@ import SvgNotFound from "@opal/illustrations/not-found";
|
||||
import SvgNoAccess from "@opal/illustrations/no-access";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { useSidebarState } from "@/layouts/sidebar-layouts";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
|
||||
import ChatUI from "@/sections/chat/ChatUI";
|
||||
@@ -357,6 +361,33 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
(state) => state.updateCurrentDocumentSidebarVisible
|
||||
);
|
||||
const messageHistory = useCurrentMessageHistory();
|
||||
const messageTree = useCurrentMessageTree();
|
||||
|
||||
// Block input when the last turn is multi-model and the user hasn't
|
||||
// selected a preferred response yet. Without a selection, it's ambiguous
|
||||
// which model's response should be used as context for the next message.
|
||||
const awaitingPreferredSelection = useMemo(() => {
|
||||
if (!messageTree || currentChatState !== "input") return false;
|
||||
// Find the last user message in the history
|
||||
const lastUserMsg = [...messageHistory]
|
||||
.reverse()
|
||||
.find((m) => m.type === "user");
|
||||
if (!lastUserMsg) return false;
|
||||
const childIds = lastUserMsg.childrenNodeIds ?? [];
|
||||
if (childIds.length < 2) return false;
|
||||
// Check if children are multi-model (have modelDisplayName)
|
||||
const multiModelChildren = childIds
|
||||
.map((id) => messageTree.get(id))
|
||||
.filter(
|
||||
(m) =>
|
||||
m &&
|
||||
(m.type === "assistant" || m.type === "error") &&
|
||||
(m.modelDisplayName || m.overridden_model)
|
||||
);
|
||||
if (multiModelChildren.length < 2) return false;
|
||||
// Check if a preferred response has been set on this user message
|
||||
return lastUserMsg.preferredResponseId == null;
|
||||
}, [messageHistory, messageTree, currentChatState]);
|
||||
|
||||
// Determine anchor: second-to-last message (last user message before current response)
|
||||
const anchorMessage = messageHistory.at(-2) ?? messageHistory[0];
|
||||
@@ -367,6 +398,30 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
const autoScrollEnabled = user?.preferences?.auto_scroll !== false;
|
||||
const isStreaming = currentChatState === "streaming";
|
||||
|
||||
const multiModel = useMultiModelChat(llmManager);
|
||||
|
||||
// Auto-fold sidebar when multi-model is active (panels need full width)
|
||||
const { folded: sidebarFolded, setFolded: setSidebarFolded } =
|
||||
useSidebarState();
|
||||
const preMultiModelFoldedRef = useRef<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
multiModel.isMultiModelActive &&
|
||||
preMultiModelFoldedRef.current === null
|
||||
) {
|
||||
preMultiModelFoldedRef.current = sidebarFolded;
|
||||
setSidebarFolded(true);
|
||||
} else if (
|
||||
!multiModel.isMultiModelActive &&
|
||||
preMultiModelFoldedRef.current !== null
|
||||
) {
|
||||
setSidebarFolded(preMultiModelFoldedRef.current);
|
||||
preMultiModelFoldedRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [multiModel.isMultiModelActive]);
|
||||
|
||||
const {
|
||||
onSubmit,
|
||||
stopGenerating,
|
||||
@@ -464,19 +519,26 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
const onChat = useCallback(
|
||||
(message: string) => {
|
||||
resetInputBar();
|
||||
onSubmit({
|
||||
message,
|
||||
currentMessageFiles,
|
||||
deepResearch: deepResearchEnabledForCurrentWorkflow,
|
||||
selectedModels: multiModel.isMultiModelActive
|
||||
? multiModel.selectedModels
|
||||
: undefined,
|
||||
});
|
||||
if (showOnboarding || !onboardingDismissed) {
|
||||
finishOnboarding();
|
||||
}
|
||||
},
|
||||
[
|
||||
resetInputBar,
|
||||
onSubmit,
|
||||
currentMessageFiles,
|
||||
deepResearchEnabledForCurrentWorkflow,
|
||||
multiModel.isMultiModelActive,
|
||||
multiModel.selectedModels,
|
||||
showOnboarding,
|
||||
onboardingDismissed,
|
||||
finishOnboarding,
|
||||
@@ -510,10 +572,14 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
// If we're in an existing chat session, always use chat mode
|
||||
// (appMode only applies to new sessions)
|
||||
if (currentChatSessionId) {
|
||||
resetInputBar();
|
||||
onSubmit({
|
||||
message,
|
||||
currentMessageFiles,
|
||||
deepResearch: deepResearchEnabledForCurrentWorkflow,
|
||||
selectedModels: multiModel.isMultiModelActive
|
||||
? multiModel.selectedModels
|
||||
: undefined,
|
||||
});
|
||||
if (showOnboarding || !onboardingDismissed) {
|
||||
finishOnboarding();
|
||||
@@ -522,7 +588,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
}
|
||||
|
||||
// For new sessions, let the query controller handle routing.
|
||||
// resetInputBar is called inside useChatController.onSubmit for chat-routed queries.
|
||||
// resetInputBar is called inside onChat for chat-routed queries.
|
||||
// For search-routed queries, the input bar is intentionally kept
|
||||
// so the user can see and refine their search query.
|
||||
await submitQuery(message, onChat);
|
||||
@@ -531,12 +597,15 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
currentChatSessionId,
|
||||
submitQuery,
|
||||
onChat,
|
||||
resetInputBar,
|
||||
onSubmit,
|
||||
currentMessageFiles,
|
||||
deepResearchEnabledForCurrentWorkflow,
|
||||
showOnboarding,
|
||||
onboardingDismissed,
|
||||
finishOnboarding,
|
||||
multiModel.isMultiModelActive,
|
||||
multiModel.selectedModels,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -709,6 +778,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
stopGenerating={stopGenerating}
|
||||
onResubmit={handleResubmitLastMessage}
|
||||
anchorNodeId={anchorNodeId}
|
||||
selectedModels={multiModel.selectedModels}
|
||||
/>
|
||||
</ChatScrollContainer>
|
||||
</Fade>
|
||||
@@ -771,10 +841,24 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
}
|
||||
className="w-full flex-1 flex flex-col items-center justify-end"
|
||||
>
|
||||
<WelcomeMessage
|
||||
agent={liveAgent}
|
||||
isDefaultAgent={isDefaultAgent}
|
||||
/>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="end"
|
||||
className="max-w-[var(--app-page-main-content-width)]"
|
||||
>
|
||||
<WelcomeMessage
|
||||
agent={liveAgent}
|
||||
isDefaultAgent={isDefaultAgent}
|
||||
/>
|
||||
<ModelSelector
|
||||
llmManager={llmManager}
|
||||
selectedModels={multiModel.selectedModels}
|
||||
onAdd={multiModel.addModel}
|
||||
onRemove={multiModel.removeModel}
|
||||
onReplace={multiModel.replaceModel}
|
||||
/>
|
||||
</Section>
|
||||
<Spacer rem={1.5} />
|
||||
</Fade>
|
||||
</div>
|
||||
@@ -839,6 +923,17 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
isSearch ? "h-[14px]" : "h-0"
|
||||
)}
|
||||
/>
|
||||
{appFocus.isChat() && (
|
||||
<div className="pb-1">
|
||||
<ModelSelector
|
||||
llmManager={llmManager}
|
||||
selectedModels={multiModel.selectedModels}
|
||||
onAdd={multiModel.addModel}
|
||||
onRemove={multiModel.removeModel}
|
||||
onReplace={multiModel.replaceModel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AppInputBar
|
||||
ref={chatInputBarRef}
|
||||
deepResearchEnabled={
|
||||
@@ -866,6 +961,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
// Intentionally enabled during name-only onboarding (showOnboarding=false)
|
||||
// since LLM providers are already configured and the user can chat.
|
||||
disabled={
|
||||
awaitingPreferredSelection ||
|
||||
(!llmManager.isLoadingProviders &&
|
||||
llmManager.hasAnyProvider === false) ||
|
||||
(showOnboarding &&
|
||||
|
||||
@@ -8,7 +8,10 @@ import { ErrorBanner } from "@/app/app/message/Resubmit";
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
|
||||
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
|
||||
import AgentMessage from "@/app/app/message/messageComponents/AgentMessage";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import MultiModelResponseView from "@/app/app/message/MultiModelResponseView";
|
||||
import { MultiModelResponse } from "@/app/app/message/interfaces";
|
||||
import { SelectedModel } from "@/refresh-components/popovers/ModelSelector";
|
||||
import { buildLlmOptions } from "@/refresh-components/popovers/llmUtils";
|
||||
import DynamicBottomSpacer from "@/components/chat/DynamicBottomSpacer";
|
||||
import {
|
||||
useCurrentMessageHistory,
|
||||
@@ -17,6 +20,9 @@ import {
|
||||
useUncaughtError,
|
||||
} from "@/app/app/stores/useChatSessionStore";
|
||||
|
||||
/** Width constraint for normal (non-multi-model) messages. */
|
||||
const MSG_MAX_W = "max-w-[720px] min-w-[400px]";
|
||||
|
||||
export interface ChatUIProps {
|
||||
liveAgent: MinimalPersonaSnapshot;
|
||||
llmManager: LlmManager;
|
||||
@@ -37,6 +43,7 @@ export interface ChatUIProps {
|
||||
forceSearch?: boolean;
|
||||
};
|
||||
forceSearch?: boolean;
|
||||
selectedModels?: SelectedModel[];
|
||||
}) => Promise<void>;
|
||||
deepResearchEnabled: boolean;
|
||||
currentMessageFiles: any[];
|
||||
@@ -48,6 +55,9 @@ export interface ChatUIProps {
|
||||
* Used by DynamicBottomSpacer to position the push-up effect.
|
||||
*/
|
||||
anchorNodeId?: number;
|
||||
|
||||
/** Currently selected models for multi-model comparison. */
|
||||
selectedModels?: SelectedModel[];
|
||||
}
|
||||
|
||||
const ChatUI = React.memo(
|
||||
@@ -62,6 +72,7 @@ const ChatUI = React.memo(
|
||||
currentMessageFiles,
|
||||
onResubmit,
|
||||
anchorNodeId,
|
||||
selectedModels,
|
||||
}: ChatUIProps) => {
|
||||
// Get messages and error state from store
|
||||
const messages = useCurrentMessageHistory();
|
||||
@@ -72,13 +83,27 @@ const ChatUI = React.memo(
|
||||
const emptyDocs = useMemo<OnyxDocument[]>(() => [], []);
|
||||
const emptyChildrenIds = useMemo<number[]>(() => [], []);
|
||||
|
||||
// Lookup: model identifier → provider slug (for icon resolution).
|
||||
// Indexes by both raw model name ("claude-sonnet-4-6") and display name
|
||||
// ("Claude Sonnet 4.6") so it works for live streaming AND history reload.
|
||||
const modelProviderLookup = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const opt of buildLlmOptions(llmManager.llmProviders)) {
|
||||
map.set(opt.modelName, opt.provider);
|
||||
map.set(opt.displayName, opt.provider);
|
||||
}
|
||||
return map;
|
||||
}, [llmManager.llmProviders]);
|
||||
|
||||
// Use refs to keep callbacks stable while always using latest values
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
const deepResearchEnabledRef = useRef(deepResearchEnabled);
|
||||
const currentMessageFilesRef = useRef(currentMessageFiles);
|
||||
const selectedModelsRef = useRef(selectedModels);
|
||||
onSubmitRef.current = onSubmit;
|
||||
deepResearchEnabledRef.current = deepResearchEnabled;
|
||||
currentMessageFilesRef.current = currentMessageFiles;
|
||||
selectedModelsRef.current = selectedModels;
|
||||
|
||||
const createRegenerator = useCallback(
|
||||
(regenerationRequest: {
|
||||
@@ -103,19 +128,84 @@ const ChatUI = React.memo(
|
||||
|
||||
const handleEditWithMessageId = useCallback(
|
||||
(editedContent: string, msgId: number) => {
|
||||
const models = selectedModelsRef.current;
|
||||
onSubmitRef.current({
|
||||
message: editedContent,
|
||||
messageIdToResend: msgId,
|
||||
currentMessageFiles: [],
|
||||
deepResearch: deepResearchEnabledRef.current,
|
||||
selectedModels: models && models.length >= 2 ? models : undefined,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Detect multi-model responses: a user message whose children are 2+
|
||||
* assistant messages each carrying modelDisplayName or overridden_model.
|
||||
* Distinguishes from regeneration (which also creates sibling assistants)
|
||||
* because regenerated messages don't have model display metadata.
|
||||
*/
|
||||
// Use ref to avoid modelProviderLookup triggering callback recreation
|
||||
const modelProviderLookupRef = useRef(modelProviderLookup);
|
||||
modelProviderLookupRef.current = modelProviderLookup;
|
||||
|
||||
const getMultiModelResponses = useCallback(
|
||||
(userMessage: Message): MultiModelResponse[] | null => {
|
||||
if (!messageTree) return null;
|
||||
|
||||
const childIds = userMessage.childrenNodeIds ?? [];
|
||||
if (childIds.length < 2) return null;
|
||||
|
||||
const assistantChildren = childIds
|
||||
.map((id) => messageTree.get(id))
|
||||
.filter(
|
||||
(msg): msg is Message =>
|
||||
msg !== undefined &&
|
||||
(msg.type === "assistant" || msg.type === "error")
|
||||
);
|
||||
|
||||
// Multi-model messages have modelDisplayName or overridden_model set.
|
||||
// Regenerations don't — that's how we distinguish them.
|
||||
const multiModelChildren = assistantChildren.filter(
|
||||
(msg) => msg.modelDisplayName || msg.overridden_model
|
||||
);
|
||||
if (multiModelChildren.length < 2) return null;
|
||||
|
||||
const lookup = modelProviderLookupRef.current;
|
||||
return multiModelChildren.map((msg, idx): MultiModelResponse => {
|
||||
const modelVersion =
|
||||
msg.overridden_model || msg.modelDisplayName || "Model";
|
||||
const provider = lookup.get(modelVersion) ?? "";
|
||||
const displayName = msg.modelDisplayName || modelVersion;
|
||||
const isError = msg.type === "error";
|
||||
return {
|
||||
modelIndex: idx,
|
||||
provider,
|
||||
modelName: modelVersion,
|
||||
displayName,
|
||||
packets: msg.packets || [],
|
||||
packetCount: msg.packetCount || msg.packets?.length || 0,
|
||||
nodeId: msg.nodeId,
|
||||
messageId: msg.messageId,
|
||||
currentFeedback: msg.currentFeedback,
|
||||
isGenerating: msg.is_generating || false,
|
||||
errorMessage: isError ? msg.message : null,
|
||||
errorCode: isError ? msg.errorCode : null,
|
||||
isRetryable: isError ? msg.isRetryable : undefined,
|
||||
errorStackTrace: isError ? msg.stackTrace : null,
|
||||
errorDetails: isError ? msg.errorDetails : null,
|
||||
};
|
||||
});
|
||||
},
|
||||
[messageTree]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col w-full max-w-[var(--app-page-main-content-width)] h-full pt-4 pb-8 pr-1 gap-12">
|
||||
{/* No max-width on container — individual messages control their own width.
|
||||
Multi-model responses use full width while normal messages stay centered. */}
|
||||
<div className="flex flex-col w-full h-full pt-4 pb-8 pr-1 gap-12">
|
||||
{messages.map((message, i) => {
|
||||
const messageReactComponentKey = `message-${message.nodeId}`;
|
||||
const parentMessage = message.parentNodeId
|
||||
@@ -124,33 +214,62 @@ const ChatUI = React.memo(
|
||||
if (message.type === "user") {
|
||||
const nextMessage =
|
||||
messages.length > i + 1 ? messages[i + 1] : null;
|
||||
const multiModelResponses = getMultiModelResponses(message);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={messageReactComponentKey}
|
||||
key={messageReactComponentKey}
|
||||
className="flex flex-col gap-12 w-full"
|
||||
>
|
||||
<HumanMessage
|
||||
disableSwitchingForStreaming={
|
||||
(nextMessage && nextMessage.is_generating) || false
|
||||
}
|
||||
stopGenerating={stopGenerating}
|
||||
content={message.message}
|
||||
files={message.files}
|
||||
messageId={message.messageId}
|
||||
nodeId={message.nodeId}
|
||||
onEdit={handleEditWithMessageId}
|
||||
otherMessagesCanSwitchTo={
|
||||
parentMessage?.childrenNodeIds ?? emptyChildrenIds
|
||||
}
|
||||
onMessageSelection={onMessageSelection}
|
||||
/>
|
||||
{/* Human message stays at normal chat width */}
|
||||
<div className={`w-full ${MSG_MAX_W} self-center`}>
|
||||
<HumanMessage
|
||||
disableSwitchingForStreaming={
|
||||
(nextMessage && nextMessage.is_generating) || false
|
||||
}
|
||||
stopGenerating={stopGenerating}
|
||||
content={message.message}
|
||||
files={message.files}
|
||||
messageId={message.messageId}
|
||||
nodeId={message.nodeId}
|
||||
onEdit={handleEditWithMessageId}
|
||||
otherMessagesCanSwitchTo={
|
||||
parentMessage?.childrenNodeIds ?? emptyChildrenIds
|
||||
}
|
||||
onMessageSelection={onMessageSelection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Multi-model responses use full width */}
|
||||
{multiModelResponses && (
|
||||
<MultiModelResponseView
|
||||
responses={multiModelResponses}
|
||||
chatState={{
|
||||
agent: liveAgent,
|
||||
docs: emptyDocs,
|
||||
citations: undefined,
|
||||
setPresentingDocument,
|
||||
overriddenModel: llmManager.currentLlm?.modelName,
|
||||
}}
|
||||
llmManager={llmManager}
|
||||
onRegenerate={createRegenerator}
|
||||
parentMessage={message}
|
||||
otherMessagesCanSwitchTo={
|
||||
parentMessage?.childrenNodeIds ?? emptyChildrenIds
|
||||
}
|
||||
onMessageSelection={onMessageSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (message.type === "assistant") {
|
||||
if ((error || loadError) && i === messages.length - 1) {
|
||||
return (
|
||||
<div key={`error-${message.nodeId}`} className="p-4">
|
||||
<div
|
||||
key={`error-${message.nodeId}`}
|
||||
className={`p-4 w-full ${MSG_MAX_W} self-center`}
|
||||
>
|
||||
<ErrorBanner
|
||||
resubmit={onResubmit}
|
||||
error={error || loadError || ""}
|
||||
@@ -164,6 +283,15 @@ const ChatUI = React.memo(
|
||||
}
|
||||
|
||||
const previousMessage = i !== 0 ? messages[i - 1] : null;
|
||||
|
||||
// Skip assistant messages already rendered in MultiModelResponseView
|
||||
if (
|
||||
previousMessage?.type === "user" &&
|
||||
getMultiModelResponses(previousMessage)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chatStateData = {
|
||||
agent: liveAgent,
|
||||
docs: message.documents ?? emptyDocs,
|
||||
@@ -177,6 +305,7 @@ const ChatUI = React.memo(
|
||||
<div
|
||||
id={`message-${message.nodeId}`}
|
||||
key={messageReactComponentKey}
|
||||
className={`w-full ${MSG_MAX_W} self-center`}
|
||||
>
|
||||
<AgentMessage
|
||||
rawPackets={message.packets}
|
||||
@@ -206,7 +335,7 @@ const ChatUI = React.memo(
|
||||
{(((error !== null || loadError !== null) &&
|
||||
messages[messages.length - 1]?.type === "user") ||
|
||||
messages[messages.length - 1]?.type === "error") && (
|
||||
<div className="p-4">
|
||||
<div className={`p-4 w-full ${MSG_MAX_W} self-center`}>
|
||||
<ErrorBanner
|
||||
resubmit={onResubmit}
|
||||
error={error || loadError || ""}
|
||||
|
||||
@@ -10,7 +10,6 @@ import React, {
|
||||
} from "react";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
|
||||
import LLMPopover from "@/refresh-components/popovers/LLMPopover";
|
||||
import { InputPrompt } from "@/app/app/interfaces";
|
||||
import { FilterManager, LlmManager, useFederatedConnectors } from "@/lib/hooks";
|
||||
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
|
||||
@@ -21,7 +20,7 @@ import { ChatState } from "@/app/app/interfaces";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { getPastedFilesIfNoText } from "@/lib/clipboard";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import {
|
||||
@@ -414,11 +413,6 @@ const AppInputBar = React.memo(
|
||||
return currentMessageFiles.length > 1;
|
||||
}, [currentMessageFiles]);
|
||||
|
||||
const hasImageFiles = useMemo(
|
||||
() => currentMessageFiles.some((f) => isImageFile(f.name)),
|
||||
[currentMessageFiles]
|
||||
);
|
||||
|
||||
// Check if the agent has search tools available (internal search or web search)
|
||||
// AND if deep research is globally enabled in admin settings
|
||||
const showDeepResearch = useMemo(() => {
|
||||
@@ -603,16 +597,6 @@ const AppInputBar = React.memo(
|
||||
|
||||
{/* Bottom right controls */}
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div
|
||||
data-testid="AppInputBar/llm-popover-trigger"
|
||||
className={cn(controlsLoading && "invisible")}
|
||||
>
|
||||
<LLMPopover
|
||||
llmManager={llmManager}
|
||||
requiresImageInput={hasImageFiles}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{showMicButton &&
|
||||
(sttEnabled ? (
|
||||
<MicrophoneButton
|
||||
|
||||
@@ -508,10 +508,8 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
await user.click(addLineButton);
|
||||
|
||||
// Fill in custom config key-value pair
|
||||
const keyInputs = screen.getAllByPlaceholderText(
|
||||
"e.g. api_base, api_version, api_key"
|
||||
);
|
||||
const valueInputs = screen.getAllByRole("textbox", { name: /Value \d+/ });
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
|
||||
await user.type(keyInputs[0]!, "CLOUDFLARE_ACCOUNT_ID");
|
||||
await user.type(valueInputs[0]!, "my-account-id-123");
|
||||
|
||||
@@ -30,7 +30,6 @@ import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Button, Card, EmptyMessageCard } from "@opal/components";
|
||||
import { SvgMinusCircle, SvgPlusCircle } from "@opal/icons";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
@@ -182,28 +181,12 @@ function ModelConfigurationList({ formikProps }: ModelConfigurationListProps) {
|
||||
|
||||
// ─── Custom Config Processing ─────────────────────────────────────────────────
|
||||
|
||||
// Keys that the backend accepts as dedicated fields on the LLM provider model
|
||||
// (alongside `name`, `provider`, etc.) rather than inside the freeform
|
||||
// `custom_config` dict. When the user enters one of these in the key-value
|
||||
// list, we pull it out and send it as a top-level field in the upsert request
|
||||
// so the backend stores and validates it properly.
|
||||
const FIRST_CLASS_KEYS = ["api_key", "api_base", "api_version"] as const;
|
||||
|
||||
function extractFirstClassFields(items: KeyValue[]) {
|
||||
const firstClass: Record<string, string | undefined> = {};
|
||||
const remaining: { [key: string]: string } = {};
|
||||
|
||||
for (const { key, value } of items) {
|
||||
if ((FIRST_CLASS_KEYS as readonly string[]).includes(key)) {
|
||||
if (value.trim() !== "") {
|
||||
firstClass[key] = value;
|
||||
}
|
||||
} else {
|
||||
remaining[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { firstClass, customConfig: remaining };
|
||||
function customConfigProcessing(items: KeyValue[]) {
|
||||
const customConfig: { [key: string]: string } = {};
|
||||
items.forEach(({ key, value }) => {
|
||||
customConfig[key] = value;
|
||||
});
|
||||
return customConfig;
|
||||
}
|
||||
|
||||
export default function CustomModal({
|
||||
@@ -247,16 +230,11 @@ export default function CustomModal({
|
||||
supports_image_input: false,
|
||||
},
|
||||
],
|
||||
custom_config_list: [
|
||||
...FIRST_CLASS_KEYS.filter(
|
||||
(k) => existingLlmProvider?.[k] != null && existingLlmProvider[k] !== ""
|
||||
).map((k) => ({ key: k, value: String(existingLlmProvider![k]) })),
|
||||
...(existingLlmProvider?.custom_config
|
||||
? Object.entries(existingLlmProvider.custom_config).map(
|
||||
([key, value]) => ({ key, value: String(value) })
|
||||
)
|
||||
: []),
|
||||
],
|
||||
custom_config_list: existingLlmProvider?.custom_config
|
||||
? Object.entries(existingLlmProvider.custom_config).map(
|
||||
([key, value]) => ({ key, value: String(value) })
|
||||
)
|
||||
: [],
|
||||
};
|
||||
|
||||
const modelConfigurationSchema = Yup.object({
|
||||
@@ -305,18 +283,13 @@ export default function CustomModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const { firstClass, customConfig } = extractFirstClassFields(
|
||||
values.custom_config_list
|
||||
);
|
||||
|
||||
if (isOnboarding && onboardingState && onboardingActions) {
|
||||
await submitOnboardingProvider({
|
||||
providerName: values.provider,
|
||||
payload: {
|
||||
...values,
|
||||
...firstClass,
|
||||
model_configurations: modelConfigurations,
|
||||
custom_config: customConfig,
|
||||
custom_config: customConfigProcessing(values.custom_config_list),
|
||||
},
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
@@ -329,23 +302,18 @@ export default function CustomModal({
|
||||
(config) => config.name
|
||||
);
|
||||
|
||||
const {
|
||||
firstClass: initialFirstClass,
|
||||
customConfig: initialCustomConfig,
|
||||
} = extractFirstClassFields(initialValues.custom_config_list);
|
||||
|
||||
await submitLLMProvider({
|
||||
providerName: values.provider,
|
||||
values: {
|
||||
...values,
|
||||
...firstClass,
|
||||
selected_model_names: selectedModelNames,
|
||||
custom_config: customConfig,
|
||||
custom_config: customConfigProcessing(values.custom_config_list),
|
||||
},
|
||||
initialValues: {
|
||||
...initialValues,
|
||||
...initialFirstClass,
|
||||
custom_config: initialCustomConfig,
|
||||
custom_config: customConfigProcessing(
|
||||
initialValues.custom_config_list
|
||||
),
|
||||
},
|
||||
modelConfigurations,
|
||||
existingLlmProvider,
|
||||
@@ -376,9 +344,7 @@ export default function CustomModal({
|
||||
<InputLayouts.Vertical
|
||||
name="provider"
|
||||
title="Provider Name"
|
||||
subDescription={markdown(
|
||||
"Should be one of the providers listed at [LiteLLM](https://docs.litellm.ai/docs/providers)."
|
||||
)}
|
||||
subDescription="Should be one of the providers listed at https://docs.litellm.ai/docs/providers."
|
||||
>
|
||||
<InputTypeInField
|
||||
name="provider"
|
||||
@@ -396,9 +362,7 @@ export default function CustomModal({
|
||||
<Section gap={0.75}>
|
||||
<Content
|
||||
title="Provider Configs"
|
||||
description={markdown(
|
||||
"Add properties as needed by the model provider. This is passed to LiteLLM's `completion()` call as [arguments](https://docs.litellm.ai/docs/completion/input#input-params-1) (e.g. API base URL, API version, API key). See [documentation](https://docs.onyx.app/admins/ai_models/custom_inference_provider) for more instructions."
|
||||
)}
|
||||
description="Add properties as needed by the model provider. This is passed to LiteLLM completion() call as arguments in the environment variable. See LiteLLM documentation for more instructions."
|
||||
widthVariant="full"
|
||||
variant="section"
|
||||
sizePreset="main-content"
|
||||
@@ -409,7 +373,6 @@ export default function CustomModal({
|
||||
onChange={(items) =>
|
||||
formikProps.setFieldValue("custom_config_list", items)
|
||||
}
|
||||
keyPlaceholder="e.g. api_base, api_version, api_key"
|
||||
addButtonLabel="Add Line"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
8
widget/package-lock.json
generated
8
widget/package-lock.json
generated
@@ -17,7 +17,7 @@
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.3.2"
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -1258,9 +1258,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -26,6 +26,6 @@
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.3.2"
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user