feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { Card, Empty, Tag, Typography } from 'antd';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ArtifactPanelProps {
|
||||
artifact?: {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
format: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ArtifactPanel({ artifact }: ArtifactPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!artifact) {
|
||||
return <Empty description={t('chat.artifacts.empty')} style={{ marginTop: 32 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileText size={16} />
|
||||
<span>{artifact.title}</span>
|
||||
<Tag>{artifact.kind}</Tag>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
<Typography.Paragraph ellipsis={{ rows: 6, expandable: true, symbol: '...' }}>
|
||||
{artifact.content}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('chat.artifacts.comingSoon')}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, Input, Button, Space, Typography, Checkbox, theme } from 'antd';
|
||||
import { MessageCircleQuestion, CheckCircle2 } from 'lucide-react';
|
||||
import { useAgentStore } from '@/stores';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface AskUserCardProps {
|
||||
askId: string;
|
||||
conversationId: string;
|
||||
question: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
const AskUserCard: React.FC<AskUserCardProps> = ({ askId, question, options }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [answer, setAnswer] = useState('');
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const respondAskUser = useAgentStore((s) => s.respondAskUser);
|
||||
|
||||
const hasOptions = options && options.length > 0;
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (hasOptions) return selectedOptions.length > 0 || answer.trim().length > 0;
|
||||
return answer.trim().length > 0;
|
||||
}, [hasOptions, selectedOptions, answer]);
|
||||
|
||||
const handleToggleOption = (opt: string) => {
|
||||
setSelectedOptions((prev) =>
|
||||
prev.includes(opt) ? prev.filter((o) => o !== opt) : [...prev, opt],
|
||||
);
|
||||
};
|
||||
|
||||
const buildAnswer = (): string => {
|
||||
const parts: string[] = [];
|
||||
if (selectedOptions.length > 0) {
|
||||
parts.push(selectedOptions.join(', '));
|
||||
}
|
||||
if (answer.trim()) {
|
||||
parts.push(answer.trim());
|
||||
}
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const finalAnswer = buildAnswer();
|
||||
if (!finalAnswer || submitting || submitted) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await respondAskUser(askId, finalAnswer);
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const questionLines = question.split('\n');
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginTop: 8,
|
||||
borderColor: token.colorPrimary,
|
||||
opacity: submitted ? 0.7 : 1,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
styles={{ body: { padding: '12px 16px' } }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
||||
<Space size={8} align="start">
|
||||
<MessageCircleQuestion
|
||||
size={16}
|
||||
style={{ color: token.colorPrimary, flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{questionLines.map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{hasOptions && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingLeft: 24 }}>
|
||||
{options!.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt}
|
||||
checked={selectedOptions.includes(opt)}
|
||||
disabled={submitting || submitted}
|
||||
onChange={() => handleToggleOption(opt)}
|
||||
style={{ marginLeft: 0 }}
|
||||
>
|
||||
{opt}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextArea
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder={
|
||||
hasOptions
|
||||
? t('agent.askUserSupplementPlaceholder', 'Additional input (optional)...')
|
||||
: t('agent.askUserPlaceholder', 'Type your answer...')
|
||||
}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
disabled={submitting || submitted}
|
||||
style={{ borderColor: token.colorPrimary }}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{submitted ? (
|
||||
<Space size={4} style={{ color: token.colorSuccess }}>
|
||||
<CheckCircle2 size={14} />
|
||||
<Text style={{ color: token.colorSuccess, fontSize: 13 }}>
|
||||
{t('agent.askUserSubmitted', 'Submitted')}
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{t('agent.askUserSubmit', 'Submit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AskUserCard;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Card, Row, Col, Typography, Tag, Empty } from 'antd';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface BranchMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
model?: string;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
interface BranchComparePanelProps {
|
||||
leftMessage?: BranchMessage;
|
||||
rightMessage?: BranchMessage;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function MessageCard({ message, side }: { message?: BranchMessage; side: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<Card size="small" style={{ height: '100%' }}>
|
||||
<Empty description={t('common.noData')} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Tag>{side}</Tag>
|
||||
{message.model && <Typography.Text>{message.model}</Typography.Text>}
|
||||
{message.created_at && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatTimestamp(message.created_at)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
|
||||
{message.content}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchComparePanel({ leftMessage, rightMessage }: BranchComparePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!leftMessage && !rightMessage) {
|
||||
return <Empty description={t('chat.branch.compare')} style={{ marginTop: 32 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<GitBranch size={16} />
|
||||
<Typography.Text>{t('chat.branch.compare')}</Typography.Text>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<MessageCard message={leftMessage} side={t('chat.branch.left')} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<MessageCard message={rightMessage} side={t('chat.branch.right')} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Modal, Input, Avatar, theme, Divider, Typography } from 'antd';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEditor } from '@/components/shared/IconEditor';
|
||||
import { ModelSelect, parseModelValue } from '@/components/shared/ModelSelect';
|
||||
import { ModelParamSliders } from '@/components/common/ModelParamSliders';
|
||||
import { useProviderStore, useSettingsStore } from '@/stores';
|
||||
import { findModelByIds } from '@/lib/modelCapabilities';
|
||||
import { resolveModelParamDefaults } from '@/lib/modelParams';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface CategoryEditFormData {
|
||||
name: string;
|
||||
icon_type: string | null;
|
||||
icon_value: string | null;
|
||||
system_prompt: string | null;
|
||||
default_provider_id: string | null;
|
||||
default_model_id: string | null;
|
||||
default_temperature: number | null;
|
||||
default_max_tokens: number | null;
|
||||
default_top_p: number | null;
|
||||
default_frequency_penalty: number | null;
|
||||
}
|
||||
|
||||
interface CategoryEditModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onOk: (data: CategoryEditFormData) => void;
|
||||
initialName?: string;
|
||||
initialIconType?: string | null;
|
||||
initialIconValue?: string | null;
|
||||
initialSystemPrompt?: string | null;
|
||||
initialDefaultProviderId?: string | null;
|
||||
initialDefaultModelId?: string | null;
|
||||
initialDefaultTemperature?: number | null;
|
||||
initialDefaultMaxTokens?: number | null;
|
||||
initialDefaultTopP?: number | null;
|
||||
initialDefaultFrequencyPenalty?: number | null;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function CategoryEditModal({
|
||||
open,
|
||||
onClose,
|
||||
onOk,
|
||||
initialName = '',
|
||||
initialIconType = null,
|
||||
initialIconValue = null,
|
||||
initialSystemPrompt = null,
|
||||
initialDefaultProviderId = null,
|
||||
initialDefaultModelId = null,
|
||||
initialDefaultTemperature = null,
|
||||
initialDefaultMaxTokens = null,
|
||||
initialDefaultTopP = null,
|
||||
initialDefaultFrequencyPenalty = null,
|
||||
title,
|
||||
}: CategoryEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
const [name, setName] = useState(initialName);
|
||||
const [iconType, setIconType] = useState<string | null>(initialIconType);
|
||||
const [iconValue, setIconValue] = useState<string | null>(initialIconValue);
|
||||
const [systemPrompt, setSystemPrompt] = useState(initialSystemPrompt ?? '');
|
||||
const [defaultProviderId, setDefaultProviderId] = useState<string | null>(initialDefaultProviderId);
|
||||
const [defaultModelId, setDefaultModelId] = useState<string | null>(initialDefaultModelId);
|
||||
const [defaultTemperature, setDefaultTemperature] = useState<number | null>(initialDefaultTemperature);
|
||||
const [defaultMaxTokens, setDefaultMaxTokens] = useState<number | null>(initialDefaultMaxTokens);
|
||||
const [defaultTopP, setDefaultTopP] = useState<number | null>(initialDefaultTopP);
|
||||
const [defaultFrequencyPenalty, setDefaultFrequencyPenalty] = useState<number | null>(initialDefaultFrequencyPenalty);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialName);
|
||||
setIconType(initialIconType ?? null);
|
||||
setIconValue(initialIconValue ?? null);
|
||||
setSystemPrompt(initialSystemPrompt ?? '');
|
||||
setDefaultProviderId(initialDefaultProviderId ?? null);
|
||||
setDefaultModelId(initialDefaultModelId ?? null);
|
||||
setDefaultTemperature(initialDefaultTemperature ?? null);
|
||||
setDefaultMaxTokens(initialDefaultMaxTokens ?? null);
|
||||
setDefaultTopP(initialDefaultTopP ?? null);
|
||||
setDefaultFrequencyPenalty(initialDefaultFrequencyPenalty ?? null);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
initialName,
|
||||
initialIconType,
|
||||
initialIconValue,
|
||||
initialSystemPrompt,
|
||||
initialDefaultProviderId,
|
||||
initialDefaultModelId,
|
||||
initialDefaultTemperature,
|
||||
initialDefaultMaxTokens,
|
||||
initialDefaultTopP,
|
||||
initialDefaultFrequencyPenalty,
|
||||
]);
|
||||
|
||||
const selectedModelValue = defaultProviderId && defaultModelId
|
||||
? `${defaultProviderId}::${defaultModelId}`
|
||||
: undefined;
|
||||
const modelParamDefaults = useMemo(() => {
|
||||
const model = findModelByIds(providers, defaultProviderId, defaultModelId);
|
||||
return resolveModelParamDefaults(model, settings);
|
||||
}, [providers, defaultProviderId, defaultModelId, settings]);
|
||||
|
||||
const handleDefaultModelChange = (value: string | undefined) => {
|
||||
const parsed = parseModelValue(value);
|
||||
setDefaultProviderId(parsed?.providerId ?? null);
|
||||
setDefaultModelId(parsed?.modelId ?? null);
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
if (!name.trim()) return;
|
||||
onOk({
|
||||
name: name.trim(),
|
||||
icon_type: iconType,
|
||||
icon_value: iconValue,
|
||||
system_prompt: systemPrompt.trim() || null,
|
||||
default_provider_id: defaultProviderId,
|
||||
default_model_id: defaultModelId,
|
||||
default_temperature: defaultTemperature,
|
||||
default_max_tokens: defaultMaxTokens,
|
||||
default_top_p: defaultTopP,
|
||||
default_frequency_penalty: defaultFrequencyPenalty,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title ?? t('chat.createCategory')}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
okButtonProps={{ disabled: !name.trim() }}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
mask={{ enabled: true, blur: true }}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 py-3">
|
||||
<IconEditor
|
||||
iconType={iconType}
|
||||
iconValue={iconValue}
|
||||
onChange={(type, value) => { setIconType(type); setIconValue(value); }}
|
||||
size={40}
|
||||
defaultIcon={
|
||||
<Avatar
|
||||
size={40}
|
||||
icon={<FolderOpen size={18} />}
|
||||
style={{ cursor: 'pointer', backgroundColor: token.colorFillSecondary, color: token.colorTextSecondary }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder={t('chat.categoryNamePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
autoFocus
|
||||
style={{ maxWidth: 340 }}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
placeholder={t('chat.categorySystemPromptPlaceholder', 'System Prompt(分类下的对话将继承此提示词)')}
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||
style={{ maxWidth: 340 }}
|
||||
/>
|
||||
|
||||
<Divider style={{ margin: '4px 0 0' }} />
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 420 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('settings.defaultConversationModel')}
|
||||
</Typography.Text>
|
||||
<ModelSelect
|
||||
value={selectedModelValue}
|
||||
onChange={handleDefaultModelChange}
|
||||
placeholder={t('settings.useActiveModel')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 420 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('settings.modelParams')}
|
||||
</Typography.Text>
|
||||
<ModelParamSliders
|
||||
values={{
|
||||
temperature: defaultTemperature,
|
||||
topP: defaultTopP,
|
||||
maxTokens: defaultMaxTokens,
|
||||
frequencyPenalty: defaultFrequencyPenalty,
|
||||
}}
|
||||
onChange={(values) => {
|
||||
if ('temperature' in values) setDefaultTemperature(values.temperature ?? null);
|
||||
if ('topP' in values) setDefaultTopP(values.topP ?? null);
|
||||
if ('maxTokens' in values) setDefaultMaxTokens(values.maxTokens ?? null);
|
||||
if ('frequencyPenalty' in values) setDefaultFrequencyPenalty(values.frequencyPenalty ?? null);
|
||||
}}
|
||||
defaults={{
|
||||
temperature: modelParamDefaults.temperature,
|
||||
topP: modelParamDefaults.topP,
|
||||
maxTokens: modelParamDefaults.maxTokens,
|
||||
frequencyPenalty: modelParamDefaults.frequencyPenalty,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, App, Button, Image as AntImage, Spin, Tooltip, Typography, theme } from 'antd';
|
||||
import { Check, Copy, Download } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
copyChatImage,
|
||||
getDefaultImageFilename,
|
||||
saveChatImage,
|
||||
} from '@/lib/chatImageActions';
|
||||
|
||||
type ChatImageNodeData = {
|
||||
type: 'image' | 'img';
|
||||
src?: string;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
raw?: string;
|
||||
attrs?:
|
||||
| Record<string, string | boolean>
|
||||
| [string, string][]
|
||||
| Array<{ name: string; value: string | boolean }>;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type MarkdownImageProps = {
|
||||
node: ChatImageNodeData;
|
||||
};
|
||||
|
||||
type HtmlImageProps = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ChatImageNodeProps = MarkdownImageProps | HtmlImageProps;
|
||||
|
||||
function isMarkdownImageProps(props: ChatImageNodeProps): props is MarkdownImageProps {
|
||||
return 'node' in props;
|
||||
}
|
||||
|
||||
function getNodeAttr(node: ChatImageNodeData, name: string) {
|
||||
const attrs = node.attrs;
|
||||
if (!attrs) return undefined;
|
||||
|
||||
if (Array.isArray(attrs)) {
|
||||
for (const attr of attrs) {
|
||||
if (Array.isArray(attr)) {
|
||||
const [attrName, value] = attr;
|
||||
if (attrName === name) return value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attr.name === name && typeof attr.value === 'string') {
|
||||
return attr.value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = attrs[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeImageProps(props: ChatImageNodeProps) {
|
||||
if (isMarkdownImageProps(props)) {
|
||||
const src = props.node.src ?? getNodeAttr(props.node, 'src') ?? '';
|
||||
const alt = props.node.alt ?? getNodeAttr(props.node, 'alt') ?? '';
|
||||
const title = props.node.title ?? getNodeAttr(props.node, 'title') ?? alt;
|
||||
return {
|
||||
src,
|
||||
alt,
|
||||
title,
|
||||
raw: props.node.raw ?? '',
|
||||
loading: Boolean(props.node.loading),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
src: props.src ?? '',
|
||||
alt: props.alt ?? '',
|
||||
title: props.title ?? props.alt ?? '',
|
||||
raw: props.src ?? '',
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatImageNode(props: ChatImageNodeProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { src, alt, title, raw, loading } = normalizeImageProps(props);
|
||||
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>(() => (
|
||||
src || loading ? 'loading' : 'error'
|
||||
));
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(src || loading ? 'loading' : 'error');
|
||||
setCopied(false);
|
||||
}, [src, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timer = window.setTimeout(() => setCopied(false), 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copied]);
|
||||
|
||||
const filename = useMemo(() => getDefaultImageFilename(src, alt), [alt, src]);
|
||||
const sourceLabel = src || raw || t('chat.imageMissingSource', '图片链接为空');
|
||||
const altText = alt || title || t('chat.imagePreview', '图片预览');
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!src || status !== 'loaded') return;
|
||||
setCopying(true);
|
||||
try {
|
||||
await copyChatImage(src);
|
||||
setCopied(true);
|
||||
message.success(t('chat.imageCopySuccess', '图片已复制'));
|
||||
} catch (error) {
|
||||
console.error('copy chat image failed:', error);
|
||||
message.error(t('chat.imageCopyFailed', '复制图片失败'));
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
}, [message, src, status, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!src || status !== 'loaded') return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveChatImage(src, filename);
|
||||
if (saved) {
|
||||
message.success(t('chat.imageSaveSuccess', '图片已保存'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('save chat image failed:', error);
|
||||
message.error(t('chat.imageSaveFailed', '保存图片失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [filename, message, src, status, t]);
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
verticalAlign: 'top',
|
||||
margin: '6px 0',
|
||||
};
|
||||
|
||||
const loadingStyle: React.CSSProperties = {
|
||||
minWidth: 220,
|
||||
minHeight: 132,
|
||||
maxWidth: '100%',
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadius,
|
||||
background: token.colorFillQuaternary,
|
||||
color: token.colorTextSecondary,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
};
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<span style={shellStyle}>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('chat.imageLoadFailed', '图片加载失败')}
|
||||
description={(
|
||||
<Typography.Text
|
||||
copyable={src ? { text: src } : false}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: 520,
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{sourceLabel}
|
||||
</Typography.Text>
|
||||
)}
|
||||
style={{ maxWidth: 560, textAlign: 'left' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={shellStyle}>
|
||||
{status === 'loading' && (
|
||||
<span style={loadingStyle}>
|
||||
<Spin size="small" />
|
||||
<Typography.Text type="secondary">
|
||||
{t('chat.imageLoading', '图片加载中...')}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{status === 'loading' && src && (
|
||||
<img
|
||||
src={src}
|
||||
alt={altText}
|
||||
onLoad={() => setStatus('loaded')}
|
||||
onError={() => setStatus('error')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'loaded' && (
|
||||
<>
|
||||
<AntImage
|
||||
src={src}
|
||||
alt={altText}
|
||||
title={title || undefined}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: 520,
|
||||
borderRadius: token.borderRadius,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
preview={{ mask: { blur: true }, scaleStep: 0.5 }}
|
||||
onError={() => setStatus('error')}
|
||||
/>
|
||||
<span
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'inline-flex',
|
||||
gap: 4,
|
||||
padding: 3,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
background: token.colorBgElevated,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={copied ? t('common.copied') : t('chat.copyImage', '复制图片')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={t('chat.copyImage', '复制图片')}
|
||||
icon={copied ? <Check size={14} style={{ color: token.colorSuccess }} /> : <Copy size={14} />}
|
||||
loading={copying}
|
||||
onClick={handleCopy}
|
||||
style={{ width: 26, height: 26, padding: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('chat.saveImage', '保存图片')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={t('chat.saveImage', '保存图片')}
|
||||
icon={<Download size={14} />}
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
style={{ width: 26, height: 26, padding: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tabs, Empty, List, Descriptions, Tag, Typography, theme } from 'antd';
|
||||
import { Search, Wrench, Paperclip, Info, FileText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConversationStore, useArtifactStore } from '@/stores';
|
||||
|
||||
interface ChatInspectorProps {
|
||||
visible: boolean;
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
conversationId: string | null;
|
||||
}
|
||||
|
||||
export function ChatInspector({
|
||||
visible,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
conversationId,
|
||||
}: ChatInspectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const conversation = useConversationStore((s) =>
|
||||
s.conversations.find((c) => c.id === s.activeConversationId),
|
||||
);
|
||||
const workspaceSnapshot = useConversationStore((s) => s.workspaceSnapshot);
|
||||
const messages = useConversationStore((s) => s.messages);
|
||||
const { artifacts } = useArtifactStore();
|
||||
|
||||
const contextSources = useMemo(() => {
|
||||
if (!workspaceSnapshot) return [];
|
||||
const sources: { type: string; title: string }[] = [];
|
||||
if (workspaceSnapshot.knowledgeBinding?.knowledgeBaseIds?.length) {
|
||||
workspaceSnapshot.knowledgeBinding.knowledgeBaseIds.forEach((id) =>
|
||||
sources.push({ type: 'knowledge', title: id }),
|
||||
);
|
||||
}
|
||||
if (workspaceSnapshot.searchPolicy?.enabled) {
|
||||
sources.push({ type: 'search', title: workspaceSnapshot.searchPolicy.searchProviderId ?? 'search' });
|
||||
}
|
||||
if (workspaceSnapshot.memoryPolicy?.enabled) {
|
||||
sources.push({ type: 'memory', title: workspaceSnapshot.memoryPolicy.namespaceId ?? 'memory' });
|
||||
}
|
||||
if (workspaceSnapshot.toolBinding?.serverIds?.length) {
|
||||
workspaceSnapshot.toolBinding.serverIds.forEach((id) =>
|
||||
sources.push({ type: 'tool', title: id }),
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}, [workspaceSnapshot]);
|
||||
|
||||
const toolCalls = useMemo(() => {
|
||||
return messages
|
||||
.filter((m) => m.role === 'assistant' && m.content)
|
||||
.flatMap((m) => {
|
||||
const calls: { name: string; messageId: string }[] = [];
|
||||
const regex = /tool_call|function_call|<tool>(.*?)<\/tool>/g;
|
||||
if (regex.test(m.content)) {
|
||||
calls.push({ name: 'tool_call', messageId: m.id });
|
||||
}
|
||||
return calls;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const conversationArtifacts = useMemo(() => {
|
||||
if (!conversationId) return [];
|
||||
return artifacts.filter((a) => a.conversationId === conversationId);
|
||||
}, [artifacts, conversationId]);
|
||||
|
||||
const tabItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'sources',
|
||||
label: t('chat.inspector.sources'),
|
||||
icon: <Search size={14} />,
|
||||
children: contextSources.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={contextSources}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={<Typography.Text>{item.title}</Typography.Text>}
|
||||
description={<Tag>{item.type}</Tag>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={conversationId ? t('common.noData') : t('common.noData')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
label: t('chat.inspector.tools'),
|
||||
icon: <Wrench size={14} />,
|
||||
children: toolCalls.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={toolCalls}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Typography.Text code>{item.name}</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={t('chat.inspector.tools')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
label: t('chat.inspector.attachments'),
|
||||
icon: <Paperclip size={14} />,
|
||||
children: (() => {
|
||||
const attachments = messages.flatMap((m) => m.attachments ?? []);
|
||||
return attachments.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={attachments}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Typography.Text ellipsis>{item.file_name}</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={t('chat.inspector.attachments')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
);
|
||||
})(),
|
||||
},
|
||||
{
|
||||
key: 'session',
|
||||
label: t('chat.inspector.session'),
|
||||
icon: <Info size={14} />,
|
||||
children: conversation ? (
|
||||
<Descriptions column={1} size="small" style={{ padding: '8px 0' }}>
|
||||
<Descriptions.Item label={t('chat.inspector.session')}>
|
||||
<Typography.Text copyable={{ text: conversation.id }}>
|
||||
{conversation.id.slice(0, 8)}…
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('gateway.defaultProvider')}>
|
||||
{conversation.provider_id || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('gateway.defaultModel')}>
|
||||
{conversation.model_id || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('gateway.created')}>
|
||||
{new Date(conversation.created_at).toLocaleString()}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('chat.inspector.tools')}>
|
||||
{conversation.message_count}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Empty
|
||||
description={t('common.noData')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'artifacts',
|
||||
label: t('chat.inspector.artifacts'),
|
||||
icon: <FileText size={14} />,
|
||||
children: conversationArtifacts.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={conversationArtifacts}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={<Typography.Text>{item.title}</Typography.Text>}
|
||||
description={<Tag>{item.kind}</Tag>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={conversationId ? t('common.noData') : t('common.noData')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, conversationId, contextSources, toolCalls, messages, conversation, conversationArtifacts],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: visible ? 360 : 0,
|
||||
minWidth: visible ? 360 : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'width 0.2s ease, min-width 0.2s ease',
|
||||
borderLeft: visible ? '1px solid var(--border-color)' : 'none',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{visible && (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={onTabChange}
|
||||
items={tabItems}
|
||||
size="small"
|
||||
style={{ flex: 1, padding: '0 12px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
type ChatMessageRenderBoundaryProps = {
|
||||
children: React.ReactNode;
|
||||
fallback: React.ReactNode;
|
||||
};
|
||||
|
||||
type ChatMessageRenderBoundaryState = {
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
export class ChatMessageRenderBoundary extends React.Component<
|
||||
ChatMessageRenderBoundaryProps,
|
||||
ChatMessageRenderBoundaryState
|
||||
> {
|
||||
state: ChatMessageRenderBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): ChatMessageRenderBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
console.error('Failed to render chat message:', error);
|
||||
}
|
||||
|
||||
componentDidUpdate(previousProps: ChatMessageRenderBoundaryProps) {
|
||||
if (this.state.hasError && previousProps.children !== this.props.children) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import React, { useMemo, useState, useRef, useEffect, createContext, useContext, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Avatar, Typography, theme } from 'antd';
|
||||
import { ChevronDown, User } from 'lucide-react';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { useConversationStore, useProviderStore, useSettingsStore } from '@/stores';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore';
|
||||
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
|
||||
import { stripBoosapiTags } from '@/lib/chatMarkdown';
|
||||
import type { Message } from '@/types';
|
||||
|
||||
// ── Scroll context — provided by ChatView ──
|
||||
|
||||
type ScrollToFn = (messageId: string) => void;
|
||||
interface MinimapContextValue {
|
||||
scrollTo: ScrollToFn;
|
||||
scrollBoxRef: React.RefObject<HTMLElement | null>;
|
||||
/** Set by programmatic scroll — suppresses detection updates */
|
||||
scrollLockRef: React.MutableRefObject<number>;
|
||||
/** Forced active ID set by click — overrides detection during lock */
|
||||
forcedActiveRef: React.MutableRefObject<string | null>;
|
||||
}
|
||||
const MinimapScrollContext = createContext<MinimapContextValue | null>(null);
|
||||
|
||||
export function MinimapScrollProvider({
|
||||
children,
|
||||
scrollTo,
|
||||
scrollBoxRef,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
scrollTo: ScrollToFn;
|
||||
scrollBoxRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const scrollLockRef = useRef(0);
|
||||
const forcedActiveRef = useRef<string | null>(null);
|
||||
const wrappedScrollTo = useCallback<ScrollToFn>((messageId) => {
|
||||
// Lock detection for 800ms after programmatic scroll
|
||||
scrollLockRef.current = Date.now() + 800;
|
||||
forcedActiveRef.current = messageId;
|
||||
scrollTo(messageId);
|
||||
}, [scrollTo]);
|
||||
const value = useMemo(() => ({ scrollTo: wrappedScrollTo, scrollBoxRef, scrollLockRef, forcedActiveRef }), [wrappedScrollTo, scrollBoxRef]);
|
||||
return <MinimapScrollContext.Provider value={value}>{children}</MinimapScrollContext.Provider>;
|
||||
}
|
||||
|
||||
function useMinimapContext(): MinimapContextValue {
|
||||
const ctx = useContext(MinimapScrollContext);
|
||||
return ctx ?? { scrollTo: () => {}, scrollBoxRef: { current: null }, scrollLockRef: { current: 0 }, forcedActiveRef: { current: null } };
|
||||
}
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface MinimapEntry {
|
||||
index: number;
|
||||
msg: Message;
|
||||
role: 'user' | 'assistant';
|
||||
summary: string;
|
||||
modelId?: string | null;
|
||||
providerId?: string | null;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function summarize(content: string, maxLen: number, role: 'user' | 'assistant'): string {
|
||||
const stripped = stripBoosapiTags(content, { stripThink: role === 'assistant' })
|
||||
.replace(/```[\s\S]*?```/g, '[code]')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
return stripped.length > maxLen ? stripped.slice(0, maxLen) + '…' : stripped;
|
||||
}
|
||||
|
||||
function useEntries(): MinimapEntry[] {
|
||||
const messages = useConversationStore((s) => s.messages);
|
||||
const hasOlderMessages = useConversationStore((s) => s.hasOlderMessages);
|
||||
const loadingOlder = useConversationStore((s) => s.loadingOlder);
|
||||
|
||||
// Eagerly load all older messages so the minimap shows the full conversation
|
||||
useEffect(() => {
|
||||
if (hasOlderMessages && !loadingOlder) {
|
||||
useConversationStore.getState().loadOlderMessages();
|
||||
}
|
||||
}, [hasOlderMessages, loadingOlder, messages]);
|
||||
|
||||
return useMemo(() => {
|
||||
const active = messages.filter((m) => m.is_active !== false);
|
||||
const entries: MinimapEntry[] = [];
|
||||
// Track assistant dedup: parentKey → index in entries array
|
||||
// Keep the LAST assistant per parent (matches ChatView's assistantByParentId behavior)
|
||||
const parentToIdx = new Map<string, number>();
|
||||
let idx = 0;
|
||||
|
||||
for (const msg of active) {
|
||||
if (msg.role === 'user') {
|
||||
entries.push({
|
||||
index: idx++,
|
||||
msg,
|
||||
role: 'user',
|
||||
summary: summarize(msg.content, 30, 'user'),
|
||||
});
|
||||
} else if (msg.role === 'assistant') {
|
||||
const parentKey = msg.parent_message_id || msg.id;
|
||||
const existing = parentToIdx.get(parentKey);
|
||||
const entry: MinimapEntry = {
|
||||
index: existing !== undefined ? entries[existing].index : idx++,
|
||||
msg,
|
||||
role: 'assistant',
|
||||
summary: summarize(msg.content, 30, 'assistant'),
|
||||
modelId: msg.model_id,
|
||||
providerId: msg.provider_id,
|
||||
};
|
||||
if (existing !== undefined) {
|
||||
entries[existing] = entry;
|
||||
} else {
|
||||
parentToIdx.set(parentKey, entries.length);
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}, [messages]);
|
||||
}
|
||||
|
||||
/** Find the bubble wrapper element for a data-boosapi-msg marker */
|
||||
function findBubbleEl(marker: Element, scrollBox: HTMLElement): Element {
|
||||
let el: Element = marker;
|
||||
// Walk up until we find an element whose parent is the scroll box or its first child container
|
||||
for (;;) {
|
||||
const parent: Element | null = el.parentElement;
|
||||
if (!parent || parent === scrollBox) return el;
|
||||
if (parent.parentElement === scrollBox) return el;
|
||||
el = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/** Track which message is currently most visible in the viewport */
|
||||
function useActiveMessageId(entries: MinimapEntry[]): string | null {
|
||||
const { scrollBoxRef, scrollLockRef, forcedActiveRef } = useMinimapContext();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// Set default to last entry initially
|
||||
useEffect(() => {
|
||||
if (entries.length > 0 && !activeId) {
|
||||
setActiveId(entries[entries.length - 1].msg.id);
|
||||
}
|
||||
}, [entries, activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const updateActive = () => {
|
||||
// During programmatic scroll lock, use the forced active ID
|
||||
if (Date.now() < scrollLockRef.current) {
|
||||
if (forcedActiveRef.current) {
|
||||
setActiveId(forcedActiveRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Clear forced active once lock expires
|
||||
forcedActiveRef.current = null;
|
||||
const scrollBox = scrollBoxRef.current;
|
||||
if (!scrollBox) return;
|
||||
const boxRect = scrollBox.getBoundingClientRect();
|
||||
|
||||
// Collect rects for first and last entries to detect scroll extremes
|
||||
const firstMarker = scrollBox.querySelector(`[data-boosapi-msg="${entries[0].msg.id}"]`);
|
||||
const lastMarker = scrollBox.querySelector(`[data-boosapi-msg="${entries[entries.length - 1].msg.id}"]`);
|
||||
|
||||
// Edge case: scrolled to top — first entry's top is at or below viewport top
|
||||
if (firstMarker) {
|
||||
const firstEl = findBubbleEl(firstMarker, scrollBox);
|
||||
const firstRect = firstEl.getBoundingClientRect();
|
||||
if (firstRect.top >= boxRect.top - 5) {
|
||||
setActiveId(entries[0].msg.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Edge case: scrolled to bottom — last entry's bottom is at or above viewport bottom
|
||||
if (lastMarker) {
|
||||
const lastEl = findBubbleEl(lastMarker, scrollBox);
|
||||
const lastRect = lastEl.getBoundingClientRect();
|
||||
if (lastRect.bottom <= boxRect.bottom + 5) {
|
||||
setActiveId(entries[entries.length - 1].msg.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal: find entry whose bubble is closest to detection line (25% from top)
|
||||
const detectY = boxRect.top + boxRect.height * 0.25;
|
||||
let best: { id: string; dist: number } | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
const marker = scrollBox.querySelector(`[data-boosapi-msg="${entry.msg.id}"]`);
|
||||
if (!marker) continue;
|
||||
const el = findBubbleEl(marker, scrollBox);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.bottom < boxRect.top || rect.top > boxRect.bottom) continue;
|
||||
const dist = Math.abs(rect.top - detectY);
|
||||
if (!best || dist < best.dist) {
|
||||
best = { id: entry.msg.id, dist };
|
||||
}
|
||||
}
|
||||
if (best) setActiveId(best.id);
|
||||
};
|
||||
|
||||
// Wait for scroll box to be available, then attach listener
|
||||
let scrollBox: HTMLElement | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const attach = () => {
|
||||
scrollBox = scrollBoxRef.current;
|
||||
if (!scrollBox) {
|
||||
retryTimer = setTimeout(attach, 200);
|
||||
return;
|
||||
}
|
||||
scrollBox.addEventListener('scroll', updateActive, { passive: true });
|
||||
updateActive();
|
||||
};
|
||||
|
||||
attach();
|
||||
|
||||
return () => {
|
||||
clearTimeout(retryTimer);
|
||||
scrollBox?.removeEventListener('scroll', updateActive);
|
||||
};
|
||||
}, [scrollBoxRef, entries]);
|
||||
|
||||
return activeId;
|
||||
}
|
||||
|
||||
function useModelName(modelId?: string | null, providerId?: string | null): string {
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
return useMemo(() => {
|
||||
if (!modelId) return '';
|
||||
for (const p of providers) {
|
||||
if (providerId && p.id !== providerId) continue;
|
||||
const model = p.models?.find((m) => m.model_id === modelId);
|
||||
if (model) return model.name || model.model_id;
|
||||
}
|
||||
const parts = modelId.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}, [modelId, providerId, providers]);
|
||||
}
|
||||
|
||||
function ModelName({ modelId, providerId }: { modelId?: string | null; providerId?: string | null }) {
|
||||
const name = useModelName(modelId, providerId);
|
||||
return <>{name}</>;
|
||||
}
|
||||
|
||||
/** Renders the user avatar matching the chat bubble style */
|
||||
function UserAvatarIcon({ size }: { size: number }) {
|
||||
const { token } = theme.useToken();
|
||||
const profile = useUserProfileStore((s) => s.profile);
|
||||
const resolvedSrc = useResolvedAvatarSrc(profile.avatarType, profile.avatarValue);
|
||||
|
||||
if (profile.avatarType === 'emoji' && profile.avatarValue) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: Math.round(size * 0.55),
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{profile.avatarValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if ((profile.avatarType === 'url' || profile.avatarType === 'file') && profile.avatarValue) {
|
||||
const src = profile.avatarType === 'file' ? resolvedSrc : profile.avatarValue;
|
||||
return <Avatar size={size} src={src} />;
|
||||
}
|
||||
return <Avatar size={size} icon={<User size={Math.round(size * 0.5)} />} style={{ backgroundColor: token.colorPrimary }} />;
|
||||
}
|
||||
|
||||
// ── Plan C: FAQ Index ──
|
||||
|
||||
function FaqIndex({ entries }: { entries: MinimapEntry[] }) {
|
||||
const { token } = theme.useToken();
|
||||
const activeId = useActiveMessageId(entries);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: 260,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Scrollable dots column */}
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
maxHeight: '100%',
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<FaqItem key={entry.msg.id} entry={entry} isActive={activeId === entry.msg.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqItem({ entry, isActive, token }: {
|
||||
entry: MinimapEntry;
|
||||
isActive: boolean;
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { scrollTo } = useMinimapContext();
|
||||
const dotRef = useRef<HTMLDivElement>(null);
|
||||
const isUser = entry.role === 'user';
|
||||
|
||||
// Calculate fixed position for hover card based on dot's position
|
||||
const getCardStyle = (): React.CSSProperties => {
|
||||
if (!dotRef.current) return { display: 'none' };
|
||||
const rect = dotRef.current.getBoundingClientRect();
|
||||
return {
|
||||
position: 'fixed',
|
||||
right: window.innerWidth - rect.left + 6,
|
||||
top: rect.top + rect.height / 2,
|
||||
transform: 'translateY(-50%)',
|
||||
background: token.colorBgElevated,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadius,
|
||||
padding: '4px 8px',
|
||||
minWidth: 140,
|
||||
maxWidth: 220,
|
||||
zIndex: 1000,
|
||||
cursor: 'pointer',
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
pointerEvents: 'auto' as const,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Dot */}
|
||||
<div
|
||||
ref={dotRef}
|
||||
onClick={() => scrollTo(entry.msg.id)}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 8,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
color: isActive ? '#fff' : token.colorTextSecondary,
|
||||
backgroundColor: isActive ? token.colorPrimary : token.colorFillQuaternary,
|
||||
border: `1px solid ${isActive ? token.colorPrimary : token.colorBorderSecondary}`,
|
||||
transition: 'all 0.2s',
|
||||
margin: '0 auto',
|
||||
overflow: 'hidden',
|
||||
...(hovered && !isActive ? {
|
||||
backgroundColor: token.colorPrimaryBg,
|
||||
borderColor: token.colorPrimary,
|
||||
color: token.colorPrimary,
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
{isUser ? (
|
||||
<UserAvatarIcon size={14} />
|
||||
) : entry.modelId ? (
|
||||
<ModelIcon model={entry.modelId} size={12} type="avatar" />
|
||||
) : (
|
||||
entry.index + 1
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover card — fixed position to avoid clipping */}
|
||||
{hovered && createPortal(
|
||||
<div
|
||||
onClick={() => scrollTo(entry.msg.id)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={getCardStyle()}
|
||||
>
|
||||
<div style={{ fontSize: 10, color: isUser ? token.colorTextSecondary : token.colorPrimary }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
{!isUser && entry.modelId && <ModelIcon model={entry.modelId} size={10} type="avatar" />}
|
||||
{isUser ? 'Q' : <ModelName modelId={entry.modelId} providerId={entry.providerId} />}
|
||||
</span>
|
||||
</div>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
ellipsis
|
||||
style={{ fontSize: 10, display: 'block', marginTop: 1 }}
|
||||
>
|
||||
{entry.summary}
|
||||
</Typography.Text>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plan D: Sticky Header ──
|
||||
|
||||
function StickyHeader({ entries }: { entries: MinimapEntry[] }) {
|
||||
const { token } = theme.useToken();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const activeId = useActiveMessageId(entries);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const activeIdx = entries.findIndex((e) => e.msg.id === activeId);
|
||||
const current = entries[Math.max(0, activeIdx)];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setExpanded(true)}
|
||||
onMouseLeave={() => setExpanded(false)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(12px)',
|
||||
background: `${token.colorBgContainer}e6`,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '6px 16px',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span style={{ color: token.colorPrimary, fontWeight: 600, flexShrink: 0 }}>
|
||||
{(activeIdx >= 0 ? activeIdx : 0) + 1} / {entries.length}
|
||||
</span>
|
||||
{current.role === 'assistant' && current.modelId && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<ModelIcon model={current.modelId} size={14} type="avatar" />
|
||||
<StickyModelName modelId={current.modelId} providerId={current.providerId} />
|
||||
</span>
|
||||
)}
|
||||
{current.role === 'user' && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<UserAvatarIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text
|
||||
ellipsis
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 12, color: token.colorTextSecondary }}
|
||||
>
|
||||
{current.summary}
|
||||
</Typography.Text>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
color: token.colorTextSecondary,
|
||||
transition: 'transform 0.2s',
|
||||
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<StickyDropdownItem
|
||||
key={entry.msg.id}
|
||||
entry={entry}
|
||||
isActive={entry.msg.id === activeId}
|
||||
token={token}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickyModelName({ modelId, providerId }: { modelId?: string | null; providerId?: string | null }) {
|
||||
const name = useModelName(modelId, providerId);
|
||||
const { token } = theme.useToken();
|
||||
return <span style={{ fontSize: 12, color: token.colorTextSecondary }}>{name}</span>;
|
||||
}
|
||||
|
||||
function StickyDropdownItem({ entry, isActive, token }: {
|
||||
entry: MinimapEntry;
|
||||
isActive: boolean;
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
}) {
|
||||
const { scrollTo } = useMinimapContext();
|
||||
const isUser = entry.role === 'user';
|
||||
return (
|
||||
<div
|
||||
onClick={() => scrollTo(entry.msg.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '6px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
transition: 'background 0.15s',
|
||||
backgroundColor: isActive ? token.colorPrimaryBg : 'transparent',
|
||||
borderLeft: isActive ? `2px solid ${token.colorPrimary}` : '2px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = token.colorFillQuaternary; }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = isActive ? token.colorPrimaryBg : 'transparent'; }}
|
||||
>
|
||||
<span style={{ width: 24, textAlign: 'right', fontSize: 11, color: token.colorTextQuaternary, flexShrink: 0 }}>
|
||||
#{entry.index + 1}
|
||||
</span>
|
||||
<span style={{ flexShrink: 0 }}>
|
||||
{isUser ? (
|
||||
<UserAvatarIcon size={16} />
|
||||
) : entry.modelId ? (
|
||||
<ModelIcon model={entry.modelId} size={16} type="avatar" />
|
||||
) : (
|
||||
<Avatar size={16} style={{ backgroundColor: token.colorPrimary, fontSize: 10 }}>AI</Avatar>
|
||||
)}
|
||||
</span>
|
||||
<Typography.Text
|
||||
ellipsis
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 12, color: token.colorTextSecondary }}
|
||||
>
|
||||
{entry.summary}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
|
||||
export function ChatMinimap() {
|
||||
const enabled = useSettingsStore((s) => s.settings.chat_minimap_enabled);
|
||||
const style = useSettingsStore((s) => s.settings.chat_minimap_style ?? 'faq');
|
||||
const entries = useEntries();
|
||||
|
||||
if (!enabled || entries.length < 2) return null;
|
||||
|
||||
switch (style) {
|
||||
case 'faq':
|
||||
return <FaqIndex entries={entries} />;
|
||||
case 'sticky':
|
||||
return <StickyHeader entries={entries} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useRef, useState, useCallback, memo, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
const SCROLLBAR_HIDE_DELAY = 1200;
|
||||
|
||||
interface ThumbState {
|
||||
top: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
canScroll: boolean;
|
||||
}
|
||||
|
||||
interface ScrollMetrics {
|
||||
top: number;
|
||||
height: number;
|
||||
maxScroll: number;
|
||||
isReversed: boolean;
|
||||
}
|
||||
|
||||
interface ChatScrollIndicatorProps {
|
||||
onUserScrollIntent?: () => void;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function measureThumb(el: HTMLElement): ScrollMetrics | null {
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
if (scrollHeight <= clientHeight + 1) return null;
|
||||
|
||||
const maxScroll = scrollHeight - clientHeight;
|
||||
const height = Math.max((clientHeight / scrollHeight) * clientHeight, 24);
|
||||
const isReversed = getComputedStyle(el).flexDirection === 'column-reverse';
|
||||
const rawProgress = isReversed ? 1 + scrollTop / maxScroll : scrollTop / maxScroll;
|
||||
const progress = clamp(rawProgress, 0, 1);
|
||||
const top = progress * (clientHeight - height);
|
||||
|
||||
return { top, height, maxScroll, isReversed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, draggable scroll position indicator for the chat
|
||||
* message area (antd Bubble.List).
|
||||
*
|
||||
* antd's Bubble.List uses `flex-direction: column-reverse` for auto-scroll,
|
||||
* which inverts the scroll coordinate system (scrollTop=0 = bottom of content).
|
||||
* OverlayScrollbars cannot handle this, so we render a small custom indicator
|
||||
* that correctly accounts for the reversed layout.
|
||||
*
|
||||
* The indicator auto-shows on hover/scroll and fades out after inactivity.
|
||||
*/
|
||||
function ChatScrollIndicatorInner({ onUserScrollIntent }: ChatScrollIndicatorProps) {
|
||||
const [thumb, setThumb] = useState<ThumbState>({ top: 0, height: 0, opacity: 0, canScroll: false });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragOffsetRef = useRef(0);
|
||||
const draggingRef = useRef(false);
|
||||
const hoveringRef = useRef(false);
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
clearTimeout(hideTimer.current);
|
||||
}, []);
|
||||
|
||||
const scheduleHide = useCallback(() => {
|
||||
if (hoveringRef.current || draggingRef.current) return;
|
||||
clearHideTimer();
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setThumb((state) => ({ ...state, opacity: 0 }));
|
||||
}, SCROLLBAR_HIDE_DELAY);
|
||||
}, [clearHideTimer]);
|
||||
|
||||
const updateThumb = useCallback((visible = hoveringRef.current || draggingRef.current) => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const metrics = measureThumb(el);
|
||||
if (!metrics) {
|
||||
setThumb((state) => (state.canScroll ? { ...state, opacity: 0, canScroll: false } : state));
|
||||
return;
|
||||
}
|
||||
|
||||
setThumb({
|
||||
top: metrics.top,
|
||||
height: metrics.height,
|
||||
opacity: visible ? 1 : 0,
|
||||
canScroll: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
updateThumb(true);
|
||||
scheduleHide();
|
||||
}, [scheduleHide, updateThumb]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
hoveringRef.current = true;
|
||||
clearHideTimer();
|
||||
updateThumb(true);
|
||||
}, [clearHideTimer, updateThumb]);
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
hoveringRef.current = false;
|
||||
scheduleHide();
|
||||
}, [scheduleHide]);
|
||||
|
||||
const scrollToPointer = useCallback((clientY: number, thumbOffset: number) => {
|
||||
const el = elRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!el || !track) return;
|
||||
|
||||
const metrics = measureThumb(el);
|
||||
if (!metrics) return;
|
||||
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const maxThumbTop = Math.max(0, trackRect.height - metrics.height);
|
||||
const nextTop = clamp(clientY - trackRect.top - thumbOffset, 0, maxThumbTop);
|
||||
const progress = maxThumbTop === 0 ? 1 : nextTop / maxThumbTop;
|
||||
el.scrollTop = metrics.isReversed
|
||||
? (progress - 1) * metrics.maxScroll
|
||||
: progress * metrics.maxScroll;
|
||||
updateThumb(true);
|
||||
}, [updateThumb]);
|
||||
|
||||
const beginDrag = useCallback(() => {
|
||||
onUserScrollIntent?.();
|
||||
draggingRef.current = true;
|
||||
setDragging(true);
|
||||
clearHideTimer();
|
||||
updateThumb(true);
|
||||
}, [clearHideTimer, onUserScrollIntent, updateThumb]);
|
||||
|
||||
const handleThumbPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!thumb.canScroll) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
dragOffsetRef.current = event.clientY - rect.top;
|
||||
beginDrag();
|
||||
}, [beginDrag, thumb.canScroll]);
|
||||
|
||||
const handleTrackPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!thumb.canScroll || event.target !== event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
dragOffsetRef.current = thumb.height / 2;
|
||||
beginDrag();
|
||||
scrollToPointer(event.clientY, dragOffsetRef.current);
|
||||
}, [beginDrag, scrollToPointer, thumb.canScroll, thumb.height]);
|
||||
|
||||
useEffect(() => {
|
||||
const attach = () => {
|
||||
const el = document.querySelector<HTMLElement>('.ant-bubble-list-scroll-box');
|
||||
if (!el || el === elRef.current) return;
|
||||
elRef.current?.removeEventListener('scroll', handleScroll);
|
||||
elRef.current?.removeEventListener('pointerenter', handlePointerEnter);
|
||||
elRef.current?.removeEventListener('pointerleave', handlePointerLeave);
|
||||
elRef.current = el;
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
el.addEventListener('pointerenter', handlePointerEnter);
|
||||
el.addEventListener('pointerleave', handlePointerLeave);
|
||||
handleScroll();
|
||||
};
|
||||
|
||||
const raf = requestAnimationFrame(attach);
|
||||
const observer = new MutationObserver(attach);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
observer.disconnect();
|
||||
elRef.current?.removeEventListener('scroll', handleScroll);
|
||||
elRef.current?.removeEventListener('pointerenter', handlePointerEnter);
|
||||
elRef.current?.removeEventListener('pointerleave', handlePointerLeave);
|
||||
clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [handlePointerEnter, handlePointerLeave, handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
onUserScrollIntent?.();
|
||||
event.preventDefault();
|
||||
scrollToPointer(event.clientY, dragOffsetRef.current);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
draggingRef.current = false;
|
||||
setDragging(false);
|
||||
updateThumb(hoveringRef.current);
|
||||
scheduleHide();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
};
|
||||
}, [dragging, onUserScrollIntent, scheduleHide, scrollToPointer, updateThumb]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={`chat-scroll-indicator-track${dragging ? ' is-dragging' : ''}`}
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
pointerEvents: thumb.canScroll ? 'auto' : 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="chat-scroll-indicator"
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 2,
|
||||
top: thumb.top,
|
||||
width: 5,
|
||||
height: thumb.height,
|
||||
borderRadius: 3,
|
||||
opacity: thumb.opacity,
|
||||
transition: dragging ? 'background-color 0.15s ease' : 'opacity 0.2s ease, background-color 0.15s ease',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatScrollIndicator = memo(ChatScrollIndicatorInner);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
import { List, Typography, Tag, Empty } from 'antd';
|
||||
import { Link } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Citation {
|
||||
id: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
snippet: string;
|
||||
source: 'search' | 'knowledge' | 'memory';
|
||||
}
|
||||
|
||||
interface CitationPanelProps {
|
||||
citations: Citation[];
|
||||
}
|
||||
|
||||
const sourceColorMap: Record<Citation['source'], string> = {
|
||||
search: 'blue',
|
||||
knowledge: 'green',
|
||||
memory: 'purple',
|
||||
};
|
||||
|
||||
export function CitationPanel({ citations }: CitationPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (citations.length === 0) {
|
||||
return <Empty description={t('common.noData')} style={{ marginTop: 32 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
dataSource={citations}
|
||||
renderItem={(item) => (
|
||||
<List.Item key={item.id}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<Typography.Text>{item.title}</Typography.Text>
|
||||
<Tag color={sourceColorMap[item.source]}>{item.source}</Tag>
|
||||
</div>
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ marginBottom: 4, fontSize: 13 }}
|
||||
>
|
||||
{item.snippet}
|
||||
</Typography.Paragraph>
|
||||
{item.url && (
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||
<Link size={14} style={{ marginRight: 4 }} />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.url}
|
||||
</Typography.Text>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useMemo, useCallback, useState } from 'react';
|
||||
import { Dropdown, Tooltip, theme } from 'antd';
|
||||
import { Copy, Check, ChevronRight, Maximize2, Minimize2, Minus, Plus, RotateCcw, Eye, Palette } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CodeBlockActionContext } from 'markstream-react';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useSettingsStore } from '@/stores';
|
||||
import { useResolvedDarkMode } from '@/hooks/useResolvedDarkMode';
|
||||
import { SHIKI_LIGHT_THEMES, SHIKI_DARK_THEMES, formatThemeName } from '@/constants/codeThemes';
|
||||
|
||||
interface Props {
|
||||
ctx: CodeBlockActionContext;
|
||||
}
|
||||
|
||||
export const CodeBlockHeaderActions: React.FC<Props> = ({ ctx }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const saveSettings = useSettingsStore((s) => s.saveSettings);
|
||||
const isDark = useResolvedDarkMode(settings.theme_mode);
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
const currentTheme = isDark ? (settings.code_theme || 'poimandres') : (settings.code_theme_light || 'github-light');
|
||||
const themeList = isDark ? SHIKI_DARK_THEMES : SHIKI_LIGHT_THEMES;
|
||||
const settingsKey = isDark ? 'code_theme' : 'code_theme_light';
|
||||
|
||||
const themeMenuItems: MenuProps['items'] = useMemo(() =>
|
||||
themeList.map((id) => ({
|
||||
key: id,
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{id === currentTheme && <Check size={12} style={{ color: token.colorPrimary, flexShrink: 0 }} />}
|
||||
<span style={{ marginLeft: id === currentTheme ? 0 : 18 }}>{formatThemeName(id)}</span>
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[themeList, currentTheme, token.colorPrimary],
|
||||
);
|
||||
|
||||
const handleThemeSelect = useCallback<NonNullable<MenuProps['onClick']>>(
|
||||
({ key }) => { saveSettings({ [settingsKey]: key }); },
|
||||
[saveSettings, settingsKey],
|
||||
);
|
||||
|
||||
const getBtnStyle = useCallback((idx: number, disabled?: boolean): React.CSSProperties => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
background: !disabled && hoveredIdx === idx ? (token.colorFillSecondary || 'rgba(255,255,255,0.1)') : 'transparent',
|
||||
color: !disabled && hoveredIdx === idx ? token.colorText : 'inherit',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'color 0.2s, background 0.2s',
|
||||
opacity: disabled ? 0.3 : (hoveredIdx === idx ? 1 : 0.7),
|
||||
}), [hoveredIdx, token]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* Collapse/Expand */}
|
||||
<Tooltip title={ctx.collapsed ? t('common.expand') : t('common.collapse')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(0)}
|
||||
onClick={ctx.toggleCollapse}
|
||||
onMouseEnter={() => setHoveredIdx(0)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{
|
||||
transform: ctx.collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Font Decrease */}
|
||||
<Tooltip title={t('common.decrease', { defaultValue: 'Decrease' })} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(1, ctx.fontSize <= 10)}
|
||||
disabled={ctx.fontSize <= 10}
|
||||
onClick={ctx.decreaseFontSize}
|
||||
onMouseEnter={() => setHoveredIdx(1)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Font Reset */}
|
||||
<Tooltip title={t('common.reset', { defaultValue: 'Reset' })} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(2, ctx.fontSize === ctx.defaultFontSize)}
|
||||
disabled={ctx.fontSize === ctx.defaultFontSize}
|
||||
onClick={ctx.resetFontSize}
|
||||
onMouseEnter={() => setHoveredIdx(2)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Font Increase */}
|
||||
<Tooltip title={t('common.increase', { defaultValue: 'Increase' })} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(3, ctx.fontSize >= 36)}
|
||||
disabled={ctx.fontSize >= 36}
|
||||
onClick={ctx.increaseFontSize}
|
||||
onMouseEnter={() => setHoveredIdx(3)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Copy */}
|
||||
<Tooltip title={ctx.copied ? t('common.copied') : t('common.copy')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(4)}
|
||||
onClick={() => ctx.copy()}
|
||||
onMouseEnter={() => setHoveredIdx(4)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
{ctx.copied
|
||||
? <Check size={14} style={{ color: token.colorSuccess }} />
|
||||
: <Copy size={14} />
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<Tooltip title={ctx.expanded ? t('common.collapse') : t('settings.fullscreen')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(5)}
|
||||
onClick={ctx.toggleExpand}
|
||||
onMouseEnter={() => setHoveredIdx(5)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
{ctx.expanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Theme Picker */}
|
||||
<Dropdown
|
||||
menu={{ items: themeMenuItems, onClick: handleThemeSelect, style: { maxHeight: 320, overflowY: 'auto' } }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Tooltip title={t('settings.codeTheme', { defaultValue: 'Code Theme' })} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(6)}
|
||||
onMouseEnter={() => setHoveredIdx(6)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Palette size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
|
||||
{/* Preview (only for HTML/SVG) */}
|
||||
{ctx.isPreviewable && (
|
||||
<Tooltip title={t('common.preview')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(7)}
|
||||
onClick={ctx.previewCode}
|
||||
onMouseEnter={() => setHoveredIdx(7)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Modal, theme } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CodeBlockPreviewPayload } from 'markstream-react';
|
||||
|
||||
interface Props {
|
||||
payload: CodeBlockPreviewPayload | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CodeBlockPreviewModal: React.FC<Props> = ({ payload, open, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const srcdoc = useMemo(() => {
|
||||
if (!payload) return '';
|
||||
const base = payload.node.code ?? '';
|
||||
const lowered = base.trim().toLowerCase();
|
||||
if (lowered.startsWith('<!doctype') || lowered.startsWith('<html') || lowered.startsWith('<body'))
|
||||
return base;
|
||||
|
||||
const bg = token.colorBgElevated;
|
||||
const fg = token.colorText;
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background-color: ${bg};
|
||||
color: ${fg};
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', ui-sans-serif, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${base}
|
||||
</body>
|
||||
</html>`;
|
||||
}, [payload, token.colorBgElevated, token.colorText]);
|
||||
|
||||
const title = (() => {
|
||||
const raw = payload?.artifactTitle;
|
||||
if (!raw) return t('common.preview');
|
||||
const type = payload?.artifactType;
|
||||
if (type === 'text/html') return `HTML ${t('common.preview')}`;
|
||||
if (type === 'image/svg+xml') return `SVG ${t('common.preview')}`;
|
||||
return raw;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width="80vw"
|
||||
style={{ top: 40 }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
borderRadius: token.borderRadiusLG,
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
{payload && (
|
||||
<iframe
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
src="about:blank"
|
||||
srcDoc={srcdoc}
|
||||
title={title}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(80vh - 80px)',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
borderRadius: token.borderRadiusLG,
|
||||
backgroundColor: token.colorBgElevated,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Tag, Space, Tooltip, theme } from 'antd';
|
||||
import { Bot, Search, Wrench, BookOpen, Lightbulb } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ContextBarProps {
|
||||
modelName?: string;
|
||||
searchEnabled?: boolean;
|
||||
toolCount?: number;
|
||||
knowledgeCount?: number;
|
||||
memoryEnabled?: boolean;
|
||||
onChipClick?: (type: string) => void;
|
||||
}
|
||||
|
||||
export function ContextBar({
|
||||
modelName,
|
||||
searchEnabled = false,
|
||||
toolCount = 0,
|
||||
knowledgeCount = 0,
|
||||
memoryEnabled = false,
|
||||
onChipClick,
|
||||
}: ContextBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(type: string) => {
|
||||
onChipClick?.(type);
|
||||
},
|
||||
[onChipClick],
|
||||
);
|
||||
|
||||
const chips = useMemo(
|
||||
() => [
|
||||
...(modelName
|
||||
? [
|
||||
{
|
||||
key: 'model',
|
||||
icon: <Bot size={14} />,
|
||||
label: modelName,
|
||||
color: 'purple' as const,
|
||||
tooltip: t('chat.context.model'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'search',
|
||||
icon: <Search size={14} />,
|
||||
label: searchEnabled ? t('chat.context.enabled') : t('chat.context.disabled'),
|
||||
color: (searchEnabled ? 'green' : 'default') as string,
|
||||
tooltip: t('chat.context.search'),
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
icon: <Wrench size={14} />,
|
||||
label: t('chat.context.count', { count: toolCount }),
|
||||
color: (toolCount > 0 ? 'blue' : 'default') as string,
|
||||
tooltip: t('chat.context.tools'),
|
||||
},
|
||||
{
|
||||
key: 'knowledge',
|
||||
icon: <BookOpen size={14} />,
|
||||
label: t('chat.context.count', { count: knowledgeCount }),
|
||||
color: (knowledgeCount > 0 ? 'blue' : 'default') as string,
|
||||
tooltip: t('chat.context.knowledge'),
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
icon: <Lightbulb size={14} />,
|
||||
label: memoryEnabled ? t('chat.context.enabled') : t('chat.context.disabled'),
|
||||
color: (memoryEnabled ? 'green' : 'default') as string,
|
||||
tooltip: t('chat.context.memory'),
|
||||
},
|
||||
],
|
||||
[modelName, searchEnabled, toolCount, knowledgeCount, memoryEnabled, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<Space size={[4, 4]} wrap>
|
||||
{chips.map((chip) => (
|
||||
<Tooltip key={chip.key} title={chip.tooltip}>
|
||||
<Tag
|
||||
icon={chip.icon}
|
||||
color={chip.color}
|
||||
style={{ cursor: onChipClick ? 'pointer' : 'default', margin: 0 }}
|
||||
onClick={() => handleClick(chip.key)}
|
||||
>
|
||||
{chip.label}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { memo } from 'react';
|
||||
import { theme } from 'antd';
|
||||
import { ModelIcon, modelMappings } from '@lobehub/icons';
|
||||
import { Brain } from 'lucide-react';
|
||||
|
||||
type ConversationModelIconProps = {
|
||||
model: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
const FALLBACK_MODEL_IDS = new Set(['unknown-model', 'unknown', 'default', 'kelivo']);
|
||||
|
||||
function normalizeModelName(model: string): string {
|
||||
return model.trim().toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function hasKnownModelIcon(model: string): boolean {
|
||||
const normalized = normalizeModelName(model);
|
||||
return modelMappings.some((mapping: { keywords: string[] }) =>
|
||||
mapping.keywords.some((keyword) => {
|
||||
try {
|
||||
return new RegExp(keyword, 'i').test(normalized);
|
||||
} catch {
|
||||
return normalized.includes(keyword.toLowerCase());
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseFallbackIcon(model: string): boolean {
|
||||
const normalized = normalizeModelName(model);
|
||||
return !normalized || FALLBACK_MODEL_IDS.has(normalized) || !hasKnownModelIcon(model);
|
||||
}
|
||||
|
||||
export const ConversationModelIcon = memo(function ConversationModelIcon({
|
||||
model,
|
||||
size,
|
||||
}: ConversationModelIconProps) {
|
||||
const { token } = theme.useToken();
|
||||
if (shouldUseFallbackIcon(model)) {
|
||||
return (
|
||||
<span
|
||||
className="boosapi-conversation-model-icon"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
minWidth: size,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
lineHeight: 0,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
<Brain size={Math.round(size * 0.58)} strokeWidth={1.8} style={{ display: 'block' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="boosapi-conversation-model-icon"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
minWidth: size,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
lineHeight: 0,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ModelIcon model={model} size={size} type="avatar" />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input, Slider, Button, Tooltip, Card, theme } from 'antd';
|
||||
import { Info, Undo2, Bot } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConversationStore, useProviderStore, useSettingsStore } from '@/stores';
|
||||
import { CONV_ICON_KEY, type ConvIconType, type ConvIcon } from '@/lib/convIcon';
|
||||
import { IconEditor } from '@/components/shared/IconEditor';
|
||||
import { ModelParamSliders } from '@/components/common/ModelParamSliders';
|
||||
import { findModelByIds } from '@/lib/modelCapabilities';
|
||||
import { resolveModelParamDefaults } from '@/lib/modelParams';
|
||||
import { ConversationModelIcon } from './ConversationModelIcon';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
interface ConversationSettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CONTEXT_LIMIT_KEY = (id: string) => `boosapi_context_limit_${id}`;
|
||||
|
||||
export function ConversationSettingsModal({ open, onClose }: ConversationSettingsModalProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const conversations = useConversationStore((s) => s.conversations);
|
||||
const activeConversationId = useConversationStore((s) => s.activeConversationId);
|
||||
const updateConversation = useConversationStore((s) => s.updateConversation);
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
|
||||
const conversation = conversations.find((c) => c.id === activeConversationId);
|
||||
const modelParamDefaults = React.useMemo(() => {
|
||||
const model = findModelByIds(providers, conversation?.provider_id, conversation?.model_id);
|
||||
return resolveModelParamDefaults(model, settings);
|
||||
}, [providers, conversation?.provider_id, conversation?.model_id, settings]);
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [contextLimit, setContextLimit] = useState(50);
|
||||
const [temperature, setTemperature] = useState<number | null>(null);
|
||||
const [topP, setTopP] = useState<number | null>(null);
|
||||
const [maxTokens, setMaxTokens] = useState<number | null>(null);
|
||||
const [frequencyPenalty, setFrequencyPenalty] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Icon state
|
||||
const [iconType, setIconType] = useState<ConvIconType>('model');
|
||||
const [iconValue, setIconValue] = useState('');
|
||||
|
||||
// Initialize form when modal opens
|
||||
useEffect(() => {
|
||||
if (open && conversation) {
|
||||
setTitle(conversation.title);
|
||||
setSystemPrompt(conversation.system_prompt ?? '');
|
||||
setTemperature(conversation.temperature ?? null);
|
||||
setTopP(conversation.top_p ?? null);
|
||||
setMaxTokens(conversation.max_tokens ?? null);
|
||||
setFrequencyPenalty(conversation.frequency_penalty ?? null);
|
||||
|
||||
const stored = localStorage.getItem(CONTEXT_LIMIT_KEY(conversation.id));
|
||||
setContextLimit(stored ? Number(stored) : 50);
|
||||
|
||||
// Load icon
|
||||
const iconStored = localStorage.getItem(CONV_ICON_KEY(conversation.id));
|
||||
if (iconStored) {
|
||||
try {
|
||||
const parsed: ConvIcon = JSON.parse(iconStored);
|
||||
setIconType(parsed.type);
|
||||
setIconValue(parsed.value);
|
||||
} catch {
|
||||
setIconType('model');
|
||||
setIconValue('');
|
||||
}
|
||||
} else {
|
||||
setIconType('model');
|
||||
setIconValue('');
|
||||
}
|
||||
}
|
||||
}, [open, conversation]);
|
||||
|
||||
if (!conversation) return null;
|
||||
|
||||
|
||||
const handleReset = () => {
|
||||
setTemperature(null);
|
||||
setTopP(null);
|
||||
setMaxTokens(null);
|
||||
setFrequencyPenalty(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateConversation(conversation.id, {
|
||||
title,
|
||||
system_prompt: systemPrompt,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
top_p: topP,
|
||||
frequency_penalty: frequencyPenalty,
|
||||
});
|
||||
localStorage.setItem(CONTEXT_LIMIT_KEY(conversation.id), String(contextLimit));
|
||||
// Save icon
|
||||
if (iconType === 'model') {
|
||||
localStorage.removeItem(CONV_ICON_KEY(conversation.id));
|
||||
} else {
|
||||
localStorage.setItem(CONV_ICON_KEY(conversation.id), JSON.stringify({ type: iconType, value: iconValue }));
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const useModelIconMenuItem: MenuProps['items'] = [
|
||||
{
|
||||
key: 'use_model',
|
||||
icon: <Bot size={14} />,
|
||||
label: t('settings.useModelIcon'),
|
||||
onClick: () => { setIconType('model'); setIconValue(''); },
|
||||
},
|
||||
];
|
||||
|
||||
const sliderRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: token.colorText,
|
||||
marginBottom: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.conversationSettings')}
|
||||
open={open}
|
||||
mask={{ enabled: true, blur: true }}
|
||||
onCancel={onClose}
|
||||
width={520}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-os-scrollbar style={{ maxHeight: '70vh', overflowY: 'auto', paddingRight: 4 }}>
|
||||
{/* Avatar with IconEditor */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', margin: '8px 0 16px' }}>
|
||||
<IconEditor
|
||||
iconType={iconType === 'model' ? null : iconType}
|
||||
iconValue={iconType === 'model' ? null : iconValue}
|
||||
onChange={(type, value) => {
|
||||
if (type && value) {
|
||||
setIconType(type as ConvIconType);
|
||||
setIconValue(value);
|
||||
} else {
|
||||
setIconType('model');
|
||||
setIconValue('');
|
||||
}
|
||||
}}
|
||||
size={64}
|
||||
defaultIcon={<ConversationModelIcon model={conversation.model_id} size={64} />}
|
||||
prependMenuItems={useModelIconMenuItem}
|
||||
showClear={iconType !== 'model'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>{t('common.name')}</div>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>{t('settings.systemPromptLabel')}</div>
|
||||
<Input.TextArea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t('settings.systemPromptPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model Settings Card */}
|
||||
<Card
|
||||
title={t('settings.modelSettings')}
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<Undo2 size={14} />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('common.reset')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
||||
{/* Context Message Limit */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={labelStyle}>
|
||||
{t('settings.contextMessageLimit')}
|
||||
<Tooltip title={t('settings.contextMessageLimitTooltip')}>
|
||||
<Info size={14} style={{ color: token.colorTextSecondary, cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
<span style={{ marginLeft: 'auto', color: token.colorTextSecondary, fontSize: 12 }}>
|
||||
{contextLimit >= 50 ? t('common.unlimited') : contextLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div style={sliderRowStyle}>
|
||||
<Slider
|
||||
style={{ flex: 1 }}
|
||||
min={1}
|
||||
max={50}
|
||||
value={contextLimit}
|
||||
onChange={setContextLimit}
|
||||
marks={{ 1: '1', 10: '10', 25: '25', 50: '50' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature / Top P / Max Tokens / Frequency Penalty */}
|
||||
<ModelParamSliders
|
||||
values={{
|
||||
temperature,
|
||||
topP,
|
||||
maxTokens,
|
||||
frequencyPenalty,
|
||||
}}
|
||||
onChange={(v) => {
|
||||
if ('temperature' in v) setTemperature(v.temperature!);
|
||||
if ('topP' in v) setTopP(v.topP!);
|
||||
if ('maxTokens' in v) setMaxTokens(v.maxTokens!);
|
||||
if ('frequencyPenalty' in v) setFrequencyPenalty(v.frequencyPenalty!);
|
||||
}}
|
||||
defaults={{
|
||||
temperature: modelParamDefaults.temperature,
|
||||
topP: modelParamDefaults.topP,
|
||||
maxTokens: modelParamDefaults.maxTokens,
|
||||
frequencyPenalty: modelParamDefaults.frequencyPenalty,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Segmented, theme } from 'antd';
|
||||
import { Eye, Code2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
showSource: boolean;
|
||||
onSwitchMode: (mode: 'preview' | 'source') => void;
|
||||
}
|
||||
|
||||
export const DiagramModeToggle: React.FC<Props> = ({ showSource, onSwitchMode }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
return (
|
||||
<Segmented
|
||||
size="small"
|
||||
value={showSource ? 'source' : 'preview'}
|
||||
onChange={(value) => onSwitchMode(value as 'preview' | 'source')}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Eye size={12} />
|
||||
{t('common.preview')}
|
||||
</span>
|
||||
),
|
||||
value: 'preview',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Code2 size={12} />
|
||||
{t('common.source')}
|
||||
</span>
|
||||
),
|
||||
value: 'source',
|
||||
},
|
||||
]}
|
||||
style={{ fontSize: token.fontSizeSM }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
import { sanitizeHtmlContent } from 'stream-markdown-parser';
|
||||
import { getHtmlRenderInnerContent } from '@/lib/chatHtmlRender';
|
||||
|
||||
type HtmlRenderNodeData = {
|
||||
type: 'html-render';
|
||||
content?: string;
|
||||
raw?: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type HtmlRenderNodeProps =
|
||||
| NodeComponentProps<HtmlRenderNodeData>
|
||||
| { node: HtmlRenderNodeData; isDark?: boolean; ctx?: { isDark?: boolean } };
|
||||
|
||||
const HTML_RENDER_CACHE_LIMIT = 80;
|
||||
const htmlRenderCache = new Map<string, string>();
|
||||
const STYLE_TAG_RE = /<style\b[^>]*>[\s\S]*?<\/style\s*>/gi;
|
||||
const STYLE_ATTR_RE = /\sstyle=("([^"]*)"|'([^']*)')/gi;
|
||||
|
||||
type Rgb = {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
};
|
||||
|
||||
function getIsDark(props: HtmlRenderNodeProps) {
|
||||
return Boolean(props.isDark ?? props.ctx?.isDark);
|
||||
}
|
||||
|
||||
function stripStyleTags(html: string) {
|
||||
return html.replace(STYLE_TAG_RE, '');
|
||||
}
|
||||
|
||||
function rememberCachedHtml(key: string, value: string) {
|
||||
if (htmlRenderCache.size >= HTML_RENDER_CACHE_LIMIT) {
|
||||
const firstKey = htmlRenderCache.keys().next().value;
|
||||
if (firstKey) htmlRenderCache.delete(firstKey);
|
||||
}
|
||||
htmlRenderCache.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseHexColor(value: string): Rgb | null {
|
||||
const hex = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
|
||||
if (!hex) return null;
|
||||
const full = hex.length === 3
|
||||
? hex.split('').map((ch) => `${ch}${ch}`).join('')
|
||||
: hex;
|
||||
return {
|
||||
r: Number.parseInt(full.slice(0, 2), 16),
|
||||
g: Number.parseInt(full.slice(2, 4), 16),
|
||||
b: Number.parseInt(full.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRgbColor(value: string): Rgb | null {
|
||||
const match = value.trim().match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i);
|
||||
if (!match) return null;
|
||||
return {
|
||||
r: Math.min(255, Number(match[1])),
|
||||
g: Math.min(255, Number(match[2])),
|
||||
b: Math.min(255, Number(match[3])),
|
||||
};
|
||||
}
|
||||
|
||||
function parseColor(value: string): Rgb | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'white') return { r: 255, g: 255, b: 255 };
|
||||
if (normalized === 'black') return { r: 0, g: 0, b: 0 };
|
||||
if (normalized === 'whitesmoke') return { r: 245, g: 245, b: 245 };
|
||||
if (normalized === 'gainsboro') return { r: 220, g: 220, b: 220 };
|
||||
if (normalized === 'lightgray' || normalized === 'lightgrey') return { r: 211, g: 211, b: 211 };
|
||||
return parseHexColor(normalized) ?? parseRgbColor(normalized);
|
||||
}
|
||||
|
||||
function luminance(color: Rgb) {
|
||||
const channel = (value: number) => {
|
||||
const normalized = value / 255;
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
|
||||
}
|
||||
|
||||
function isNeutral(color: Rgb) {
|
||||
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b) <= 32;
|
||||
}
|
||||
|
||||
function isLightNeutral(value: string) {
|
||||
const color = parseColor(value);
|
||||
return Boolean(color && isNeutral(color) && luminance(color) >= 0.72);
|
||||
}
|
||||
|
||||
function isDarkNeutral(value: string) {
|
||||
const color = parseColor(value);
|
||||
return Boolean(color && isNeutral(color) && luminance(color) <= 0.18);
|
||||
}
|
||||
|
||||
function replaceNeutralColors(value: string, replacement: string) {
|
||||
return value.replace(/#[0-9a-f]{3,6}\b|rgba?\([^)]+\)|\b(?:white|whitesmoke|gainsboro|lightgr[ae]y|black)\b/gi, (match) => (
|
||||
isLightNeutral(match) || isDarkNeutral(match) ? replacement : match
|
||||
));
|
||||
}
|
||||
|
||||
function adaptDarkStyle(style: string) {
|
||||
return style
|
||||
.split(';')
|
||||
.map((part) => {
|
||||
const separatorIndex = part.indexOf(':');
|
||||
if (separatorIndex === -1) return part.trim();
|
||||
|
||||
const property = part.slice(0, separatorIndex).trim();
|
||||
const lowerProperty = property.toLowerCase();
|
||||
const value = part.slice(separatorIndex + 1).trim();
|
||||
if (!property || !value) return '';
|
||||
|
||||
if (lowerProperty === 'color' && isDarkNeutral(value)) {
|
||||
return `${property}:var(--boosapi-html-fg)`;
|
||||
}
|
||||
if ((lowerProperty === 'background' || lowerProperty === 'background-color') && isLightNeutral(value)) {
|
||||
return `${property}:rgba(255,255,255,0.06)`;
|
||||
}
|
||||
if (lowerProperty.includes('border')) {
|
||||
return `${property}:${replaceNeutralColors(value, 'rgba(255,255,255,0.18)')}`;
|
||||
}
|
||||
|
||||
return `${property}:${value}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function escapeStyleAttribute(value: string, quote: '"' | "'") {
|
||||
return quote === '"'
|
||||
? value.replace(/"/g, '"')
|
||||
: value.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function adaptHtmlForDarkMode(html: string) {
|
||||
return html.replace(STYLE_ATTR_RE, (_match, quoted: string, doubleQuoted?: string, singleQuoted?: string) => {
|
||||
const quote = quoted.startsWith('"') ? '"' : "'";
|
||||
const style = doubleQuoted ?? singleQuoted ?? '';
|
||||
return ` style=${quote}${escapeStyleAttribute(adaptDarkStyle(style), quote)}${quote}`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderSafeHtml(html: string, isDark: boolean) {
|
||||
const key = `${isDark ? 'dark' : 'light'}\u0000${html}`;
|
||||
const cached = htmlRenderCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
|
||||
const sanitized = stripStyleTags(sanitizeHtmlContent(stripStyleTags(html)));
|
||||
const themed = isDark ? adaptHtmlForDarkMode(sanitized) : sanitized;
|
||||
return rememberCachedHtml(key, themed);
|
||||
}
|
||||
|
||||
function createContainerStyle(isDark: boolean): CSSProperties & Record<string, string> {
|
||||
return {
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
contain: 'layout paint style',
|
||||
colorScheme: isDark ? 'dark' : 'light',
|
||||
'--boosapi-html-fg': isDark ? '#f5f5f5' : 'inherit',
|
||||
'--boosapi-html-bg': isDark ? 'rgba(255,255,255,0.06)' : 'transparent',
|
||||
'--boosapi-html-muted': isDark ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)',
|
||||
'--boosapi-html-border': isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.14)',
|
||||
'--boosapi-html-accent': isDark ? '#8ab4f8' : '#1677ff',
|
||||
color: 'var(--boosapi-html-fg)',
|
||||
background: 'transparent',
|
||||
};
|
||||
}
|
||||
|
||||
function getNode(props: HtmlRenderNodeProps) {
|
||||
return props.node;
|
||||
}
|
||||
|
||||
export function HtmlRenderNode(props: HtmlRenderNodeProps) {
|
||||
const node = getNode(props);
|
||||
const isDark = getIsDark(props);
|
||||
const isLoading = Boolean(node.loading);
|
||||
const html = useMemo(() => getHtmlRenderInnerContent(node), [node]);
|
||||
const renderKey = `${isDark ? 'dark' : 'light'}\u0000${html}`;
|
||||
const [safeHtml, setSafeHtml] = useState(() => renderSafeHtml(html, isDark));
|
||||
const latestRenderRef = useRef({ html, isDark, key: renderKey });
|
||||
const renderedKeyRef = useRef(renderKey);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
latestRenderRef.current = { html, isDark, key: renderKey };
|
||||
|
||||
const cancelPendingFrame = () => {
|
||||
if (frameRef.current !== null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
frameRef.current = null;
|
||||
};
|
||||
|
||||
if (!isLoading) {
|
||||
cancelPendingFrame();
|
||||
if (renderedKeyRef.current !== renderKey) {
|
||||
renderedKeyRef.current = renderKey;
|
||||
setSafeHtml(renderSafeHtml(html, isDark));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (renderedKeyRef.current === renderKey || frameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
||||
renderedKeyRef.current = renderKey;
|
||||
setSafeHtml(renderSafeHtml(html, isDark));
|
||||
return;
|
||||
}
|
||||
|
||||
frameRef.current = window.requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
const latest = latestRenderRef.current;
|
||||
renderedKeyRef.current = latest.key;
|
||||
setSafeHtml(renderSafeHtml(latest.html, latest.isDark));
|
||||
});
|
||||
}, [html, isDark, isLoading, renderKey]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (frameRef.current !== null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="boosapi-html-render"
|
||||
data-testid="html-render-content"
|
||||
style={createContainerStyle(isDark)}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default HtmlRenderNode;
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Tooltip, theme } from 'antd';
|
||||
import { Copy, Check, ChevronRight, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { InfographicBlockActionContext } from 'markstream-react';
|
||||
|
||||
interface Props {
|
||||
ctx: InfographicBlockActionContext;
|
||||
}
|
||||
|
||||
export const InfographicBlockHeaderActions: React.FC<Props> = ({ ctx }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
const getBtnStyle = useCallback((idx: number, disabled?: boolean): React.CSSProperties => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
border: 'none',
|
||||
background: !disabled && hoveredIdx === idx ? (token.colorFillSecondary || 'rgba(255,255,255,0.1)') : 'transparent',
|
||||
color: !disabled && hoveredIdx === idx ? token.colorText : token.colorTextSecondary,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
padding: 0,
|
||||
transition: 'color 0.2s, background 0.2s',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
}), [hoveredIdx, token]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* Collapse */}
|
||||
<Tooltip title={ctx.collapsed ? t('common.expand') : t('common.collapse')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(0)} onClick={ctx.toggleCollapse}
|
||||
onMouseEnter={() => setHoveredIdx(0)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{
|
||||
transform: ctx.collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Copy */}
|
||||
<Tooltip title={ctx.copied ? t('common.copied') : t('common.copy')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(1)} onClick={ctx.copy}
|
||||
onMouseEnter={() => setHoveredIdx(1)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
{ctx.copied
|
||||
? <Check size={14} style={{ color: token.colorSuccess }} />
|
||||
: <Copy size={14} />
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Export / Download */}
|
||||
<Tooltip title={t('common.export')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
style={getBtnStyle(2, ctx.isExportDisabled)}
|
||||
disabled={ctx.isExportDisabled}
|
||||
onClick={ctx.exportSvg}
|
||||
onMouseEnter={() => setHoveredIdx(2)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<Tooltip title={ctx.modalOpen ? t('settings.exitFullscreen') : t('settings.fullscreen')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
style={getBtnStyle(3, ctx.isExportDisabled)}
|
||||
disabled={ctx.isExportDisabled}
|
||||
onClick={ctx.toggleFullscreen}
|
||||
onMouseEnter={() => setHoveredIdx(3)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
{ctx.modalOpen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
import { useState } from 'react';
|
||||
import { theme } from 'antd';
|
||||
import { BookOpen, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MemorySourceResult, MemoryRetrievedItem } from '@/lib/memoryUtils';
|
||||
|
||||
type KnowledgeRetrievalNodeData = {
|
||||
type: 'knowledge-retrieval';
|
||||
content?: string;
|
||||
attrs?: Record<string, string> | [string, string][];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function getAttrValue(
|
||||
attrs: KnowledgeRetrievalNodeData['attrs'],
|
||||
key: string,
|
||||
): string | undefined {
|
||||
if (!attrs) return undefined;
|
||||
if (Array.isArray(attrs)) {
|
||||
const entry = attrs.find(([name]) => name === key);
|
||||
return entry?.[1];
|
||||
}
|
||||
return attrs[key];
|
||||
}
|
||||
|
||||
function truncateContent(text: string, maxLen = 120): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '…';
|
||||
}
|
||||
|
||||
function formatRetrievalScore(item: MemoryRetrievedItem, digits: number): string {
|
||||
if (typeof item.rerankScore === 'number') {
|
||||
return item.rerankScore.toFixed(digits);
|
||||
}
|
||||
return (1 / (1 + item.score)).toFixed(digits);
|
||||
}
|
||||
|
||||
export function KnowledgeRetrievalNode(props: NodeComponentProps<KnowledgeRetrievalNodeData>) {
|
||||
const { node } = props;
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const status = getAttrValue(node.attrs, 'status') ?? (node.loading ? 'searching' : 'done');
|
||||
|
||||
let sources: MemorySourceResult[] = [];
|
||||
if (node.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(node.content);
|
||||
if (Array.isArray(parsed)) sources = parsed;
|
||||
} catch {
|
||||
// invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const totalItems = sources.reduce((sum, s) => sum + s.items.length, 0);
|
||||
|
||||
// Searching state
|
||||
if (status === 'searching') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{ display: 'inline-flex', width: 16, height: 16 }}
|
||||
>
|
||||
<BookOpen size={16} style={{ color: token.colorPrimary }} />
|
||||
</span>
|
||||
<span style={{ color: token.colorTextSecondary, fontSize: 13 }}>
|
||||
{t('chat.knowledgeRetrieval.searching')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorErrorBg,
|
||||
color: token.colorError,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
<span>{node.content || t('chat.knowledgeRetrieval.error')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'empty') {
|
||||
const reason = getAttrValue(node.attrs, 'reason');
|
||||
const messageKey = reason === 'threshold_filtered'
|
||||
? 'chat.knowledgeRetrieval.emptyThreshold'
|
||||
: 'chat.knowledgeRetrieval.empty';
|
||||
const defaultValue = reason === 'threshold_filtered'
|
||||
? '未找到符合检索阈值的知识库内容,可适当降低检索阈值。'
|
||||
: '未找到相关知识库内容。';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorWarningBg,
|
||||
color: token.colorWarningText,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
<span>{t(messageKey, { defaultValue })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Done state — no results
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<BookOpen size={14} style={{ color: token.colorPrimary }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{t('chat.knowledgeRetrieval.resultsCount', {
|
||||
count: totalItems,
|
||||
defaultValue: '检索到 {{count}} 条知识',
|
||||
})}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', color: token.colorTextTertiary }}>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Per-item overview */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
padding: '6px 12px',
|
||||
flexWrap: 'wrap',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{sources.flatMap((src, si) =>
|
||||
src.items.map((item, ii) => (
|
||||
<span
|
||||
key={`${si}-${ii}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
borderRadius: 4,
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={10} style={{ flexShrink: 0 }} />
|
||||
<span style={{ maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.document_name || item.document_id?.slice(0, 8) || '—'}
|
||||
</span>
|
||||
{item.id && <span style={{ opacity: 0.5 }}>#{item.id.slice(0, 6)}</span>}
|
||||
<span style={{ color: token.colorPrimary, fontFamily: 'monospace' }}>
|
||||
{formatRetrievalScore(item, 3)}
|
||||
</span>
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{sources.map((src, si) =>
|
||||
src.items.map((item: MemoryRetrievedItem, ii: number) => (
|
||||
<div
|
||||
key={`${si}-${ii}`}
|
||||
style={{
|
||||
marginBottom: ii < src.items.length - 1 || si < sources.length - 1 ? 8 : 0,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={12} style={{ color: token.colorPrimary, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 500, color: token.colorText }}>
|
||||
{item.document_name || item.document_id?.slice(0, 8) || '—'}
|
||||
</span>
|
||||
{item.id && (
|
||||
<span style={{ fontSize: 10, color: token.colorTextQuaternary }}>
|
||||
#{item.id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 10,
|
||||
color: token.colorTextQuaternary,
|
||||
}}
|
||||
>
|
||||
{formatRetrievalScore(item, 4)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: '2px 0 0 0',
|
||||
color: token.colorTextSecondary,
|
||||
lineHeight: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{truncateContent(item.content, 200)}
|
||||
</p>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Collapse, Tag } from 'antd';
|
||||
import { CheckCircle, Loader, XCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
|
||||
const MCP_NODE_TEXT_CHAR_LIMIT = 20_000;
|
||||
const MCP_NODE_TRUNCATED_NOTICE = 'MCP output truncated for rendering';
|
||||
|
||||
function safeGetAttr(attrs: any, key: string): string | undefined {
|
||||
if (!attrs) return undefined;
|
||||
|
||||
if (Array.isArray(attrs)) {
|
||||
for (const item of attrs) {
|
||||
if (Array.isArray(item)) {
|
||||
const [k, v] = item;
|
||||
if (k === key || k === `data-${key}`) {
|
||||
if (v == null) return undefined;
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
} else if (item && typeof item === 'object' && 'name' in item) {
|
||||
if (item.name === key || item.name === `data-${key}`) {
|
||||
const v = item.value;
|
||||
if (v == null) return undefined;
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof attrs === 'object') {
|
||||
for (const k of [key, `data-${key}`]) {
|
||||
const v = attrs[k];
|
||||
if (v == null) continue;
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function appendBoundedText(current: string, next: string, limit: number): { text: string; truncated: boolean } {
|
||||
if (!next) return { text: current, truncated: false };
|
||||
|
||||
let text = current;
|
||||
let remaining = limit - Array.from(current).length;
|
||||
if (remaining <= 0) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
|
||||
for (const char of next) {
|
||||
if (remaining <= 0) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
text += char;
|
||||
remaining -= 1;
|
||||
}
|
||||
|
||||
return { text, truncated: false };
|
||||
}
|
||||
|
||||
function extractText(children: any[] | undefined, limit = MCP_NODE_TEXT_CHAR_LIMIT): { text: string; truncated: boolean } {
|
||||
if (!children || children.length === 0) return { text: '', truncated: false };
|
||||
|
||||
let text = '';
|
||||
for (const child of children) {
|
||||
let next = '';
|
||||
if (typeof child === 'string') {
|
||||
next = child;
|
||||
} else if (child?.content != null) {
|
||||
next = typeof child.content === 'object' ? JSON.stringify(child.content) : String(child.content);
|
||||
} else if (child?.children) {
|
||||
const nested = extractText(child.children, limit - Array.from(text).length);
|
||||
text += nested.text;
|
||||
if (nested.truncated) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const appended = appendBoundedText(text, next, limit);
|
||||
text = appended.text;
|
||||
if (appended.truncated) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { text, truncated: false };
|
||||
}
|
||||
|
||||
export function McpContainerNode(props: NodeComponentProps<any>) {
|
||||
const { node, ctx, renderNode, indexKey } = props;
|
||||
|
||||
if (node.name !== 'mcp') {
|
||||
return (
|
||||
<div className={`vmr-container vmr-container-${node.name ?? 'unknown'}`}>
|
||||
{Array.isArray(node.children) && ctx && renderNode
|
||||
? node.children.map((child: any, i: number) => (
|
||||
<React.Fragment key={`${String(indexKey ?? 'vmr-container')}-${i}`}>
|
||||
{renderNode(child, `${String(indexKey ?? 'vmr-container')}-${i}`, ctx)}
|
||||
</React.Fragment>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <McpToolCard node={node} />;
|
||||
}
|
||||
|
||||
const monoStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
padding: '4px 0',
|
||||
};
|
||||
|
||||
function McpToolCard({ node }: { node: any }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const serverName = safeGetAttr(node.attrs, 'name') ?? 'MCP';
|
||||
const toolName = safeGetAttr(node.attrs, 'tool') ?? 'unknown';
|
||||
const rawArgs = safeGetAttr(node.attrs, 'arguments');
|
||||
const isLoading = Boolean(node.loading);
|
||||
|
||||
const [activeKey, setActiveKey] = useState<string[]>(isLoading ? ['1'] : []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setActiveKey(['1']);
|
||||
} else {
|
||||
setActiveKey([]);
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const resultText = useMemo(() => {
|
||||
if (isLoading) return '';
|
||||
const result = extractText(node.children);
|
||||
if (!result.truncated) return result.text;
|
||||
return `${result.text}\n\n[${MCP_NODE_TRUNCATED_NOTICE}]`;
|
||||
}, [isLoading, node.children]);
|
||||
|
||||
const isError = useMemo(() => {
|
||||
if (isLoading) return false;
|
||||
const trimmed = resultText.trim();
|
||||
return (
|
||||
trimmed.startsWith('Error:') ||
|
||||
trimmed.startsWith('Error executing tool:') ||
|
||||
trimmed.startsWith('错误')
|
||||
);
|
||||
}, [isLoading, resultText]);
|
||||
|
||||
const status = isLoading ? 'running' : isError ? 'error' : 'success';
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
if (status === 'running') return <Loader size={12} className="animate-spin" />;
|
||||
if (status === 'error') return <XCircle size={12} />;
|
||||
return <CheckCircle size={12} />;
|
||||
}, [status]);
|
||||
|
||||
const statusColor = status === 'running' ? 'processing' : status === 'error' ? 'error' : 'success';
|
||||
const statusLabel = status === 'running'
|
||||
? t('chat.tool.running')
|
||||
: status === 'error'
|
||||
? t('chat.tool.error')
|
||||
: t('chat.tool.success');
|
||||
|
||||
const decodedArgs = useMemo(() => {
|
||||
if (!rawArgs) return null;
|
||||
try {
|
||||
const decoded = rawArgs.includes('%') ? decodeURIComponent(rawArgs) : rawArgs;
|
||||
return JSON.stringify(JSON.parse(decoded), null, 2);
|
||||
} catch {
|
||||
return rawArgs;
|
||||
}
|
||||
}, [rawArgs]);
|
||||
|
||||
const headerLabel = (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tag icon={statusIcon} color={statusColor} style={{ margin: 0 }}>{statusLabel}</Tag>
|
||||
<Tag style={{ margin: 0 }}>{serverName}</Tag>
|
||||
<span style={{ fontSize: 13 }}>{toolName}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const hasContent = Boolean(decodedArgs) || Boolean(!isLoading && resultText);
|
||||
|
||||
return (
|
||||
<span style={{ display: 'block', margin: '8px 0' }}>
|
||||
<Collapse
|
||||
size="small"
|
||||
activeKey={activeKey}
|
||||
onChange={(keys) => setActiveKey(keys as string[])}
|
||||
items={hasContent ? [{
|
||||
key: '1',
|
||||
label: headerLabel,
|
||||
children: (
|
||||
<>
|
||||
{decodedArgs && (
|
||||
<div style={{ marginBottom: resultText ? 8 : 0 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>
|
||||
{t('chat.tool.input')}
|
||||
</div>
|
||||
<span style={{ ...monoStyle, maxHeight: 200 }}>{decodedArgs}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && resultText && (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>
|
||||
{t('chat.tool.output')}
|
||||
</div>
|
||||
<span style={monoStyle}>{resultText}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}] : [{
|
||||
key: '1',
|
||||
label: headerLabel,
|
||||
children: null,
|
||||
}]}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState } from 'react';
|
||||
import { theme } from 'antd';
|
||||
import { Brain, ChevronDown, ChevronRight, AlertCircle, Database } from 'lucide-react';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MemorySourceResult, MemoryRetrievedItem } from '@/lib/memoryUtils';
|
||||
|
||||
type MemoryRetrievalNodeData = {
|
||||
type: 'memory-retrieval';
|
||||
content?: string;
|
||||
attrs?: Record<string, string> | [string, string][];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function getAttrValue(
|
||||
attrs: MemoryRetrievalNodeData['attrs'],
|
||||
key: string,
|
||||
): string | undefined {
|
||||
if (!attrs) return undefined;
|
||||
if (Array.isArray(attrs)) {
|
||||
const entry = attrs.find(([name]) => name === key);
|
||||
return entry?.[1];
|
||||
}
|
||||
return attrs[key];
|
||||
}
|
||||
|
||||
function truncateContent(text: string, maxLen = 120): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '…';
|
||||
}
|
||||
|
||||
export function MemoryRetrievalNode(props: NodeComponentProps<MemoryRetrievalNodeData>) {
|
||||
const { node } = props;
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Guard against undefined node (e.g. from malformed HTML comment prefixes in old data)
|
||||
if (!node) return null;
|
||||
|
||||
const status = getAttrValue(node.attrs, 'status') ?? (node.loading ? 'searching' : 'done');
|
||||
|
||||
let sources: MemorySourceResult[] = [];
|
||||
if (node.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(node.content);
|
||||
if (Array.isArray(parsed)) sources = parsed;
|
||||
} catch {
|
||||
// invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const totalItems = sources.reduce((sum, s) => sum + s.items.length, 0);
|
||||
|
||||
// Searching state
|
||||
if (status === 'searching') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{ display: 'inline-flex', width: 16, height: 16 }}
|
||||
>
|
||||
<Brain size={16} style={{ color: token.colorPrimary }} />
|
||||
</span>
|
||||
<span style={{ color: token.colorTextSecondary, fontSize: 13 }}>
|
||||
{t('chat.memoryRetrieval.searching')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorErrorBg,
|
||||
color: token.colorError,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
<span>{node.content || t('chat.memoryRetrieval.error')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'empty') {
|
||||
const reason = getAttrValue(node.attrs, 'reason');
|
||||
const messageKey = reason === 'threshold_filtered'
|
||||
? 'chat.memoryRetrieval.emptyThreshold'
|
||||
: 'chat.memoryRetrieval.empty';
|
||||
const defaultValue = reason === 'threshold_filtered'
|
||||
? '未找到符合检索阈值的记忆内容,可适当降低检索阈值。'
|
||||
: '未找到相关记忆内容。';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorWarningBg,
|
||||
color: token.colorWarningText,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Brain size={16} />
|
||||
<span>{t(messageKey, { defaultValue })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Done state — no results
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Brain size={14} style={{ color: token.colorPrimary }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{t('chat.memoryRetrieval.resultsCount', {
|
||||
count: totalItems,
|
||||
defaultValue: '检索到 {{count}} 条记忆',
|
||||
})}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', color: token.colorTextTertiary }}>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Per-item overview */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
padding: '6px 12px',
|
||||
flexWrap: 'wrap',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{sources.flatMap((src, si) =>
|
||||
src.items.map((item, ii) => (
|
||||
<span
|
||||
key={`${si}-${ii}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
borderRadius: 4,
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
<Database size={10} style={{ flexShrink: 0 }} />
|
||||
{item.id ? (
|
||||
<span style={{ opacity: 0.5 }}>#{item.id.slice(0, 6)}</span>
|
||||
) : (
|
||||
<span style={{ opacity: 0.5 }}>{item.document_id?.slice(0, 8) || '—'}</span>
|
||||
)}
|
||||
<span style={{ color: token.colorPrimary, fontFamily: 'monospace' }}>
|
||||
{(1 / (1 + item.score)).toFixed(3)}
|
||||
</span>
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{sources.map((src, si) =>
|
||||
src.items.map((item: MemoryRetrievedItem, ii: number) => (
|
||||
<div
|
||||
key={`${si}-${ii}`}
|
||||
style={{
|
||||
marginBottom: ii < src.items.length - 1 || si < sources.length - 1 ? 8 : 0,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<Database size={12} style={{ color: token.colorPrimary, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 500, color: token.colorText }}>
|
||||
{t('chat.memoryRetrieval.label')}
|
||||
</span>
|
||||
{item.id && (
|
||||
<span style={{ fontSize: 10, color: token.colorTextQuaternary }}>
|
||||
#{item.id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 10,
|
||||
color: token.colorTextQuaternary,
|
||||
}}
|
||||
>
|
||||
{(1 / (1 + item.score)).toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: '2px 0 0 0',
|
||||
color: token.colorTextSecondary,
|
||||
lineHeight: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{truncateContent(item.content, 200)}
|
||||
</p>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Tooltip, theme } from 'antd';
|
||||
import { Copy, Check, ChevronRight, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MermaidBlockActionContext } from 'markstream-react';
|
||||
|
||||
interface Props {
|
||||
ctx: MermaidBlockActionContext;
|
||||
}
|
||||
|
||||
export const MermaidBlockHeaderActions: React.FC<Props> = ({ ctx }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
const getBtnStyle = useCallback((idx: number, disabled?: boolean): React.CSSProperties => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
border: 'none',
|
||||
background: !disabled && hoveredIdx === idx ? (token.colorFillSecondary || 'rgba(255,255,255,0.1)') : 'transparent',
|
||||
color: !disabled && hoveredIdx === idx ? token.colorText : token.colorTextSecondary,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
padding: 0,
|
||||
transition: 'color 0.2s, background 0.2s',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
}), [hoveredIdx, token]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* Collapse */}
|
||||
<Tooltip title={ctx.collapsed ? t('common.expand') : t('common.collapse')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(0)} onClick={ctx.toggleCollapse}
|
||||
onMouseEnter={() => setHoveredIdx(0)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{
|
||||
transform: ctx.collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Copy */}
|
||||
<Tooltip title={ctx.copied ? t('common.copied') : t('common.copy')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(1)} onClick={ctx.copy}
|
||||
onMouseEnter={() => setHoveredIdx(1)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
{ctx.copied
|
||||
? <Check size={14} style={{ color: token.colorSuccess }} />
|
||||
: <Copy size={14} />
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Export / Download */}
|
||||
{ctx.mermaidAvailable && (
|
||||
<Tooltip title={t('common.export')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
style={getBtnStyle(2, ctx.isExportDisabled)}
|
||||
disabled={ctx.isExportDisabled}
|
||||
onClick={ctx.exportSvg}
|
||||
onMouseEnter={() => setHoveredIdx(2)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Fullscreen */}
|
||||
{ctx.mermaidAvailable && (
|
||||
<Tooltip title={ctx.modalOpen ? t('settings.exitFullscreen') : t('settings.fullscreen')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
style={getBtnStyle(3, ctx.isExportDisabled)}
|
||||
disabled={ctx.isExportDisabled}
|
||||
onClick={ctx.toggleFullscreen}
|
||||
onMouseEnter={() => setHoveredIdx(3)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
{ctx.modalOpen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Tooltip, theme } from 'antd';
|
||||
import { ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MermaidBlockActionContext } from 'markstream-react';
|
||||
|
||||
interface Props {
|
||||
ctx: MermaidBlockActionContext;
|
||||
}
|
||||
|
||||
export const MermaidZoomControls: React.FC<Props> = ({ ctx }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
const getBtnStyle = useCallback((idx: number): React.CSSProperties => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 28,
|
||||
height: 28,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
border: 'none',
|
||||
background: hoveredIdx === idx ? (token.colorFillSecondary || 'rgba(255,255,255,0.1)') : 'transparent',
|
||||
color: hoveredIdx === idx ? token.colorText : token.colorTextSecondary,
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
transition: 'color 0.2s, background 0.2s',
|
||||
whiteSpace: 'nowrap',
|
||||
}), [hoveredIdx, token]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
backdropFilter: 'blur(8px)',
|
||||
backgroundColor: token.colorBgElevated + 'cc',
|
||||
padding: '2px 4px',
|
||||
}}
|
||||
>
|
||||
<Tooltip title={t('common.increase')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(0)} onClick={ctx.zoomIn}
|
||||
onMouseEnter={() => setHoveredIdx(0)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<ZoomIn size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.decrease')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(1)} onClick={ctx.zoomOut}
|
||||
onMouseEnter={() => setHoveredIdx(1)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<ZoomOut size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.reset')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(2)} onClick={ctx.resetZoom}
|
||||
onMouseEnter={() => setHoveredIdx(2)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500 }}>{Math.round(ctx.zoom * 100)}%</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,647 @@
|
||||
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Tag, Modal, Input, theme, Tooltip, Button, Checkbox } from 'antd';
|
||||
import { Search, Settings, Pin, PinOff, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Eye, Wrench, Lightbulb, Mic, MessageSquare, Check, GitCompareArrows } from 'lucide-react';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useProviderStore, useConversationStore, useSettingsStore, useUIStore } from '@/stores';
|
||||
import { SmartProviderIcon } from '@/lib/providerIcons';
|
||||
import { getShortcutBinding, formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { getVisibleModelCapabilities } from '@/lib/modelCapabilities';
|
||||
import type { ModelCapability, Model } from '@/types';
|
||||
import { ConversationModelIcon } from './ConversationModelIcon';
|
||||
|
||||
const PINNED_MODELS_KEY = 'boosapi_pinned_models';
|
||||
|
||||
const CAPABILITY_COLORS: Record<ModelCapability, string> = {
|
||||
TextChat: 'blue', Vision: 'green', FunctionCalling: 'purple', Reasoning: 'orange', RealtimeVoice: 'red',
|
||||
};
|
||||
const CAPABILITY_ICONS: Record<ModelCapability, React.ReactNode> = {
|
||||
TextChat: <MessageSquare size={11} />, Vision: <Eye size={11} />, FunctionCalling: <Wrench size={11} />,
|
||||
Reasoning: <Lightbulb size={11} />, RealtimeVoice: <Mic size={11} />,
|
||||
};
|
||||
|
||||
function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1000000) { const m = tokens / 1000000; return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M`; }
|
||||
if (tokens >= 1000) { const k = tokens / 1000; return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K`; }
|
||||
return `${tokens}`;
|
||||
}
|
||||
|
||||
function loadPinnedModels(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(PINNED_MODELS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function savePinnedModels(ids: string[]) {
|
||||
localStorage.setItem(PINNED_MODELS_KEY, JSON.stringify(ids));
|
||||
}
|
||||
|
||||
function isSelectableConversationModel(model: Pick<Model, 'model_type'>, includeImageModels: boolean) {
|
||||
return model.model_type === 'Chat' || (includeImageModels && model.model_type === 'Image');
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
style?: React.CSSProperties;
|
||||
/** Custom select callback. When provided, overrides the default conversation/settings update. */
|
||||
onSelect?: (providerId: string, modelId: string) => void;
|
||||
/** Override which model is highlighted as current (e.g. for per-message model switching). */
|
||||
overrideCurrentModel?: { providerId: string; modelId: string } | null;
|
||||
/** Custom trigger element. When provided, renders this instead of the default Tag. */
|
||||
children?: React.ReactNode;
|
||||
/** Controlled open state */
|
||||
open?: boolean;
|
||||
/** Callback when open state changes */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Enable multi-select mode (checkboxes + confirm button) */
|
||||
multiSelect?: boolean;
|
||||
/** Callback for multi-select mode. Returns array of selected models. */
|
||||
onMultiSelect?: (models: Array<{ providerId: string; modelId: string }>) => void;
|
||||
/** Pre-selected models for multi-select mode */
|
||||
defaultSelectedModels?: Array<{ providerId: string; modelId: string }>;
|
||||
/** Model keys to exclude from the list (format: "providerId::modelId") */
|
||||
excludeModelKeys?: string[];
|
||||
}
|
||||
|
||||
export function ModelSelector({ style, onSelect, overrideCurrentModel, children, open: controlledOpen, onOpenChange, multiSelect, onMultiSelect, defaultSelectedModels, excludeModelKeys }: ModelSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const { providers } = useProviderStore();
|
||||
const { activeConversationId, conversations, updateConversation } =
|
||||
useConversationStore();
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const saveSettings = useSettingsStore((s) => s.saveSettings);
|
||||
const showImageModels = settings.show_image_models_in_model_selector ?? false;
|
||||
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
if (onOpenChange) onOpenChange(v);
|
||||
if (!isControlled) setInternalOpen(v);
|
||||
}, [isControlled, onOpenChange]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [pinnedModels, setPinnedModels] = useState<string[]>(loadPinnedModels);
|
||||
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const listParentRef = useRef<HTMLDivElement>(null);
|
||||
const setActivePage = useUIStore((s) => s.setActivePage);
|
||||
const setSettingsSection = useUIStore((s) => s.setSettingsSection);
|
||||
const setSelectedProviderId = useUIStore((s) => s.setSelectedProviderId);
|
||||
|
||||
// Multi-select state
|
||||
const [multiSelectedKeys, setMultiSelectedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
// Reset multi-select state when modal opens with default selections
|
||||
useEffect(() => {
|
||||
if (open && multiSelect) {
|
||||
const initial = new Set(
|
||||
(defaultSelectedModels ?? []).map((m) => `${m.providerId}::${m.modelId}`),
|
||||
);
|
||||
setMultiSelectedKeys(initial);
|
||||
}
|
||||
}, [open, multiSelect, defaultSelectedModels]);
|
||||
|
||||
const activeConversation = useMemo(
|
||||
() => conversations.find((c) => c.id === activeConversationId),
|
||||
[conversations, activeConversationId],
|
||||
);
|
||||
|
||||
const currentModel = useMemo(() => {
|
||||
let pid: string | undefined;
|
||||
let mid: string | undefined;
|
||||
if (activeConversation) {
|
||||
pid = activeConversation.provider_id;
|
||||
mid = activeConversation.model_id;
|
||||
} else if (settings.default_provider_id && settings.default_model_id) {
|
||||
pid = settings.default_provider_id;
|
||||
mid = settings.default_model_id;
|
||||
} else {
|
||||
for (const p of providers) {
|
||||
if (!p.enabled) continue;
|
||||
const m = p.models.find((m) => m.enabled && isSelectableConversationModel(m, showImageModels));
|
||||
if (m) { pid = p.id; mid = m.model_id; break; }
|
||||
}
|
||||
}
|
||||
if (!pid || !mid) return null;
|
||||
const provider = providers.find((p) => p.id === pid);
|
||||
const model = provider?.models.find((m) => m.model_id === mid);
|
||||
return { pid, mid, name: model?.name ?? mid, providerName: provider?.name ?? '' };
|
||||
}, [activeConversation, settings.default_provider_id, settings.default_model_id, providers, showImageModels]);
|
||||
|
||||
const currentValue = overrideCurrentModel
|
||||
? `${overrideCurrentModel.providerId}::${overrideCurrentModel.modelId}`
|
||||
: currentModel ? `${currentModel.pid}::${currentModel.mid}` : undefined;
|
||||
|
||||
// All enabled models flat list (for pinned section)
|
||||
const allEnabledModels = useMemo(() => {
|
||||
const result: { pid: string; mid: string; name: string; providerName: string; model: Model }[] = [];
|
||||
for (const p of providers) {
|
||||
if (!p.enabled) continue;
|
||||
for (const m of p.models) {
|
||||
if (!m.enabled) continue;
|
||||
if (!isSelectableConversationModel(m, showImageModels)) continue;
|
||||
const key = `${p.id}::${m.model_id}`;
|
||||
if (excludeModelKeys?.includes(key)) continue;
|
||||
result.push({ pid: p.id, mid: m.model_id, name: m.name, providerName: p.name, model: m });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [providers, excludeModelKeys, showImageModels]);
|
||||
|
||||
// Pinned models resolved with search filter
|
||||
const pinnedItems = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return pinnedModels
|
||||
.map((key) => {
|
||||
const model = allEnabledModels.find((m) => `${m.pid}::${m.mid}` === key);
|
||||
return model ? { ...model, key } : null;
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> =>
|
||||
item !== null && (!q || item.name.toLowerCase().includes(q) || item.mid.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [pinnedModels, allEnabledModels, search]);
|
||||
|
||||
// Filtered providers and models (excluding search)
|
||||
const filteredProviders = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return providers
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => ({
|
||||
...p,
|
||||
models: p.models.filter(
|
||||
(m) => {
|
||||
if (!m.enabled) return false;
|
||||
if (!isSelectableConversationModel(m, showImageModels)) return false;
|
||||
if (excludeModelKeys?.includes(`${p.id}::${m.model_id}`)) return false;
|
||||
if (!q) return true;
|
||||
return m.name.toLowerCase().includes(q) || m.model_id.toLowerCase().includes(q) || p.name.toLowerCase().includes(q);
|
||||
},
|
||||
),
|
||||
}))
|
||||
.filter((p) => p.models.length > 0);
|
||||
}, [providers, search, excludeModelKeys, showImageModels]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(providerId: string, modelId: string) => {
|
||||
if (multiSelect) {
|
||||
// In multi-select mode, toggle the selection
|
||||
const key = `${providerId}::${modelId}`;
|
||||
setMultiSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (onSelect) {
|
||||
onSelect(providerId, modelId);
|
||||
} else if (activeConversationId) {
|
||||
updateConversation(activeConversationId, {
|
||||
provider_id: providerId,
|
||||
model_id: modelId,
|
||||
});
|
||||
} else {
|
||||
saveSettings({ default_provider_id: providerId, default_model_id: modelId });
|
||||
}
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
},
|
||||
[activeConversationId, updateConversation, saveSettings, onSelect, setOpen, multiSelect],
|
||||
);
|
||||
|
||||
const handleMultiConfirm = useCallback(() => {
|
||||
if (!onMultiSelect) return;
|
||||
const models = Array.from(multiSelectedKeys).map((key) => {
|
||||
const [providerId, modelId] = key.split('::');
|
||||
return { providerId, modelId };
|
||||
});
|
||||
onMultiSelect(models);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}, [multiSelectedKeys, onMultiSelect, setOpen]);
|
||||
|
||||
const togglePin = useCallback((key: string) => {
|
||||
setPinnedModels((prev) => {
|
||||
const next = prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key];
|
||||
savePinnedModels(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only the standalone header ModelSelector responds to the shortcut.
|
||||
// Bubble-action instances (onSelect) and multi-select (multiSelect) should not.
|
||||
if (multiSelect || onSelect) return;
|
||||
const onToggle = () => setOpen(!open);
|
||||
window.addEventListener('boosapi:toggle-model-selector', onToggle);
|
||||
return () => {
|
||||
window.removeEventListener('boosapi:toggle-model-selector', onToggle);
|
||||
};
|
||||
}, [open, setOpen, multiSelect, onSelect]);
|
||||
|
||||
// Auto-expand all groups when providers change or modal opens
|
||||
const providerIds = useMemo(() => filteredProviders.map((p) => p.id), [filteredProviders]);
|
||||
useEffect(() => {
|
||||
if (open) setExpandedGroups(providerIds);
|
||||
}, [open, providerIds]);
|
||||
|
||||
const allGroupsExpanded = expandedGroups.length >= providerIds.length;
|
||||
|
||||
const toggleGroupExpand = useCallback((providerId: string) => {
|
||||
setExpandedGroups((prev) =>
|
||||
prev.includes(providerId) ? prev.filter((id) => id !== providerId) : [...prev, providerId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleAllGroups = useCallback(() => {
|
||||
setExpandedGroups((prev) => (prev.length >= providerIds.length ? [] : [...providerIds]));
|
||||
listParentRef.current?.scrollTo({ top: 0 });
|
||||
}, [providerIds]);
|
||||
|
||||
// Flatten into virtualizable rows
|
||||
type ListRow =
|
||||
| { type: 'pinned-header' }
|
||||
| { type: 'pinned-model'; pid: string; mid: string; name: string; providerName: string; key: string; model: Model }
|
||||
| { type: 'pinned-divider' }
|
||||
| { type: 'group'; provider: typeof filteredProviders[number] }
|
||||
| { type: 'model'; providerId: string; model: typeof filteredProviders[number]['models'][number]; providerName: string };
|
||||
|
||||
const hasSearchQuery = search.trim().length > 0;
|
||||
const flatRows = useMemo<ListRow[]>(() => {
|
||||
const rows: ListRow[] = [];
|
||||
if (pinnedItems.length > 0) {
|
||||
rows.push({ type: 'pinned-header' });
|
||||
for (const item of pinnedItems) {
|
||||
rows.push({ type: 'pinned-model', ...item });
|
||||
}
|
||||
rows.push({ type: 'pinned-divider' });
|
||||
}
|
||||
for (const provider of filteredProviders) {
|
||||
rows.push({ type: 'group', provider });
|
||||
// When searching, always expand all groups to avoid timing issues with expandedGroups state
|
||||
if (hasSearchQuery || expandedGroups.includes(provider.id)) {
|
||||
for (const model of provider.models) {
|
||||
rows.push({ type: 'model', providerId: provider.id, model, providerName: provider.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [pinnedItems, filteredProviders, expandedGroups, hasSearchQuery]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatRows.length,
|
||||
getScrollElement: () => listParentRef.current,
|
||||
estimateSize: (index) => {
|
||||
const row = flatRows[index];
|
||||
if (row.type === 'pinned-divider') return 12;
|
||||
if (row.type === 'pinned-header') return 32;
|
||||
if (row.type === 'group') return 40;
|
||||
return 36;
|
||||
},
|
||||
getItemKey: (index) => {
|
||||
const row = flatRows[index];
|
||||
switch (row.type) {
|
||||
case 'pinned-header': return 'ph';
|
||||
case 'pinned-divider': return 'pd';
|
||||
case 'pinned-model': return `pm-${row.key}`;
|
||||
case 'group': return `g-${row.provider.id}`;
|
||||
case 'model': return `m-${row.providerId}::${row.model.model_id}`;
|
||||
}
|
||||
},
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
// Reset scroll to top when search changes so filtered results are visible
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
virtualizer.scrollToIndex(0);
|
||||
}
|
||||
}, [search, virtualizer]);
|
||||
|
||||
// Indices of selectable (model/pinned-model) rows for keyboard navigation
|
||||
const selectableIndices = useMemo(
|
||||
() => flatRows.reduce<number[]>((acc, row, i) => {
|
||||
if (row.type === 'model' || row.type === 'pinned-model') acc.push(i);
|
||||
return acc;
|
||||
}, []),
|
||||
[flatRows],
|
||||
);
|
||||
|
||||
// Reset activeIndex when search or flatRows change
|
||||
useEffect(() => {
|
||||
setActiveIndex(selectableIndices.length > 0 ? selectableIndices[0] : -1);
|
||||
}, [search, selectableIndices]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (selectableIndices.length === 0) return;
|
||||
const curPos = selectableIndices.indexOf(activeIndex);
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = curPos < selectableIndices.length - 1 ? selectableIndices[curPos + 1] : selectableIndices[0];
|
||||
setActiveIndex(next);
|
||||
virtualizer.scrollToIndex(next, { align: 'auto' });
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = curPos > 0 ? selectableIndices[curPos - 1] : selectableIndices[selectableIndices.length - 1];
|
||||
setActiveIndex(prev);
|
||||
virtualizer.scrollToIndex(prev, { align: 'auto' });
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < flatRows.length) {
|
||||
const row = flatRows[activeIndex];
|
||||
if (row.type === 'model') handleSelect(row.providerId, row.model.model_id);
|
||||
else if (row.type === 'pinned-model') handleSelect(row.pid, row.mid);
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectableIndices, activeIndex, flatRows, virtualizer, handleSelect],
|
||||
);
|
||||
|
||||
const renderModelItem = (
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
modelName: string,
|
||||
providerName: string,
|
||||
isPinned: boolean,
|
||||
showProviderTag: boolean,
|
||||
model?: Model,
|
||||
isKeyboardActive?: boolean,
|
||||
) => {
|
||||
const key = `${providerId}::${modelId}`;
|
||||
const isActive = multiSelect ? multiSelectedKeys.has(key) : currentValue === key;
|
||||
const isHovered = hoveredKey === key || isKeyboardActive;
|
||||
const visibleCaps = model ? getVisibleModelCapabilities(model) : [];
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: isActive ? token.colorPrimaryBg : isHovered ? token.colorFillSecondary : undefined,
|
||||
borderRadius: 6,
|
||||
margin: '0 6px',
|
||||
padding: '5px 10px',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onClick={() => handleSelect(providerId, modelId)}
|
||||
onMouseEnter={() => { setHoveredKey(key); setActiveIndex(-1); }}
|
||||
onMouseLeave={() => setHoveredKey(null)}
|
||||
>
|
||||
{multiSelect && (
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)}
|
||||
<ModelIcon model={modelId} size={20} type="avatar" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{showProviderTag && providerName && (
|
||||
<Tag style={{ fontSize: 11, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0, color: token.colorPrimary, backgroundColor: token.colorPrimaryBg, border: 'none' }}>{providerName}</Tag>
|
||||
)}
|
||||
<span style={{ fontSize: 13, color: isActive ? token.colorPrimary : undefined, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{modelName}</span>
|
||||
{visibleCaps.map((cap) => (
|
||||
<Tooltip key={cap} title={t(`settings.capability.${cap}`, cap)}>
|
||||
<Tag color={CAPABILITY_COLORS[cap]} bordered={false} style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', margin: 0 }}>
|
||||
{CAPABILITY_ICONS[cap]}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
{model?.max_tokens != null && model.max_tokens > 0 && (
|
||||
<Tag bordered={false} color="default" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', margin: 0 }}>
|
||||
{formatTokenCount(model.max_tokens)}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!multiSelect && (
|
||||
<div className="flex items-center gap-1" style={{ flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
|
||||
<span
|
||||
style={{ cursor: 'pointer', color: isPinned ? token.colorPrimary : token.colorTextQuaternary, fontSize: 14 }}
|
||||
onClick={() => togglePin(key)}
|
||||
>
|
||||
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{multiSelect && isActive && (
|
||||
<Check size={14} style={{ color: token.colorPrimary, flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
<span onClick={() => setOpen(true)}>{children}</span>
|
||||
) : (
|
||||
<Tooltip title={`${t('chat.switchModel')} (${formatShortcutForDisplay(getShortcutBinding(settings, 'toggleModelSelector'))})`} placement="bottom">
|
||||
<Tag
|
||||
onClick={() => setOpen(true)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '2px 10px',
|
||||
fontSize: 13,
|
||||
borderRadius: 6,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{currentModel && (
|
||||
<>
|
||||
<ConversationModelIcon model={currentModel.mid} size={16} />
|
||||
{currentModel.providerName && (
|
||||
<Tag style={{ fontSize: 11, margin: 0, padding: '0 4px', lineHeight: '16px', color: token.colorPrimary, backgroundColor: token.colorPrimaryBg, border: 'none' }}>{currentModel.providerName}</Tag>
|
||||
)}
|
||||
<span>{currentModel.name}</span>
|
||||
</>
|
||||
)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setSearch(''); }}
|
||||
mask={{ enabled: true, blur: true }}
|
||||
footer={multiSelect ? (
|
||||
<div className="flex items-center justify-between" style={{ padding: '8px 12px' }}>
|
||||
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{t('chat.multiModel.selectedCount').replace('{{count}}', String(multiSelectedKeys.size))}
|
||||
</span>
|
||||
<Button type="primary" size="small" onClick={handleMultiConfirm}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
title={multiSelect ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, paddingLeft: 4 }}>
|
||||
<GitCompareArrows size={16} />
|
||||
{t('chat.multiModel.selectTitle')}
|
||||
</span>
|
||||
) : null}
|
||||
closable={false}
|
||||
width={420}
|
||||
styles={{
|
||||
body: { padding: 0, maxHeight: '60vh', display: 'flex', flexDirection: 'column' },
|
||||
}}
|
||||
rootClassName="model-selector-modal"
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
{/* Search + Expand/Collapse */}
|
||||
<div className="flex items-center gap-2" style={{ padding: '8px 8px 4px' }}>
|
||||
<Input
|
||||
prefix={<Search size={14} style={{ color: token.colorTextSecondary }} />}
|
||||
placeholder={t('chat.searchModelOrProvider')}
|
||||
variant="borderless"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillTertiary,
|
||||
}}
|
||||
/>
|
||||
<Tooltip title={allGroupsExpanded ? t('common.collapseAll') : t('common.expandAll')}>
|
||||
<span
|
||||
style={{ cursor: 'pointer', color: token.colorTextSecondary, flexShrink: 0, display: 'flex', alignItems: 'center', padding: 4 }}
|
||||
onClick={toggleAllGroups}
|
||||
>
|
||||
{allGroupsExpanded ? <ChevronsDownUp size={16} /> : <ChevronsUpDown size={16} />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Virtualized model list */}
|
||||
<div
|
||||
ref={listParentRef}
|
||||
data-os-scrollbar
|
||||
style={{ overflowY: 'auto', flex: 1, padding: '2px 0 4px' }}
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = flatRows[virtualRow.index];
|
||||
|
||||
if (row.type === 'pinned-header') {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="flex items-center px-3 pt-2 pb-0.5"
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
color: token.colorTextSecondary, fontSize: 12, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<PinOff size={11} style={{ marginRight: 4 }} />
|
||||
<span>{t('chat.pinnedModels')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'pinned-model') {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderModelItem(row.pid, row.mid, row.name, row.providerName, true, true, row.model, virtualRow.index === activeIndex)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'pinned-divider') {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
padding: '4px 14px',
|
||||
}}
|
||||
>
|
||||
<div style={{ borderTop: `1px solid ${token.colorBorderSecondary}` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'group') {
|
||||
const isExpanded = hasSearchQuery || expandedGroups.includes(row.provider.id);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
padding: '2px 8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer"
|
||||
style={{ userSelect: 'none', background: 'var(--ant-color-fill-quaternary, rgba(0,0,0,0.02))' }}
|
||||
onClick={() => toggleGroupExpand(row.provider.id)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<SmartProviderIcon provider={row.provider} size={20} type="avatar" />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{row.provider.name}</span>
|
||||
<Tag style={{ fontSize: 11, lineHeight: '18px', padding: '0 6px', margin: 0 }}>{row.provider.models.length}</Tag>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Settings
|
||||
size={14}
|
||||
style={{ cursor: 'pointer', color: token.colorTextQuaternary }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setActivePage('settings');
|
||||
setSettingsSection('providers');
|
||||
setSelectedProviderId(row.provider.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// type === 'model'
|
||||
const isPinned = pinnedModels.includes(`${row.providerId}::${row.model.model_id}`);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderModelItem(row.providerId, row.model.model_id, row.model.name, row.providerName, isPinned, false, row.model, virtualRow.index === activeIndex)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import { Alert, Button, Dropdown, Popconfirm, Tag, Tooltip, Typography, theme } from 'antd';
|
||||
import { ArrowLeftRight, Check, ChevronLeft, ChevronRight, Columns2, GitBranch, LayoutList, Pencil, RotateCcw, Rows3, Trash2 } from 'lucide-react';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OverlayScrollbars } from 'overlayscrollbars';
|
||||
import type { Message } from '@/types';
|
||||
import { CopyButton } from '@/components/common/CopyButton';
|
||||
import { stripBoosapiTags } from '@/lib/chatMarkdown';
|
||||
import { getMessageVersionGroupKey, selectDisplayVersionsByModel } from '@/lib/chatMultiModel';
|
||||
import {
|
||||
getLiveStreamContent,
|
||||
subscribeLiveStreamContent,
|
||||
useConversationStore,
|
||||
} from '@/stores';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
|
||||
export type MultiModelDisplayMode = 'tabs' | 'side-by-side' | 'stacked';
|
||||
|
||||
function useLiveStreamContent(messageId: string | null | undefined, enabled: boolean): string | undefined {
|
||||
const subscribedMessageId = enabled ? messageId : null;
|
||||
return useSyncExternalStore(
|
||||
useCallback(
|
||||
(listener) => subscribeLiveStreamContent(subscribedMessageId, listener),
|
||||
[subscribedMessageId],
|
||||
),
|
||||
useCallback(
|
||||
() => getLiveStreamContent(subscribedMessageId),
|
||||
[subscribedMessageId],
|
||||
),
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function MultiModelVersionContent({
|
||||
message,
|
||||
isVersionStreaming,
|
||||
renderContent,
|
||||
}: {
|
||||
message: Message;
|
||||
isVersionStreaming: boolean;
|
||||
renderContent: (msg: Message, isVersionStreaming: boolean) => React.ReactNode;
|
||||
}) {
|
||||
const liveContent = useLiveStreamContent(message.id, isVersionStreaming);
|
||||
const renderMessage = liveContent === undefined ? message : { ...message, content: liveContent };
|
||||
return <>{renderContent(renderMessage, isVersionStreaming)}</>;
|
||||
}
|
||||
|
||||
/** Error boundary to prevent white-screen crashes in multi-model display */
|
||||
class MultiModelErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; fallback?: React.ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? (
|
||||
<Alert type="warning" message="Multi-model display error" showIcon />
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MultiModelDisplayProps {
|
||||
versions: Message[];
|
||||
activeMessageId: string;
|
||||
mode: 'side-by-side' | 'stacked';
|
||||
conversationId: string;
|
||||
onSwitchVersion: (parentMessageId: string, messageId: string) => void;
|
||||
onDeleteVersion?: (messageId: string) => void;
|
||||
onRegenerateVersion?: (message: Message) => void | Promise<void>;
|
||||
onEditVersion?: (message: Message) => void;
|
||||
onBranchVersion?: (message: Message, asChild: boolean) => void;
|
||||
onSwitchModelVersion?: (message: Message, providerId: string, modelId: string) => void | Promise<void>;
|
||||
onSetContextVersion?: (message: Message) => void;
|
||||
onDisplayVersionChange?: (parentMessageId: string, modelKey: string, messageId: string) => void;
|
||||
displayVersionIdsByModelKey?: ReadonlyMap<string, string>;
|
||||
renderContent: (msg: Message, isVersionStreaming: boolean) => React.ReactNode;
|
||||
getModelDisplayInfo: (
|
||||
modelId?: string | null,
|
||||
providerId?: string | null,
|
||||
) => { modelName: string; providerName: string };
|
||||
streamingMessageId?: string | null;
|
||||
multiModelDoneMessageIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders multiple model versions side-by-side or stacked.
|
||||
* Used when multi_model_display_mode is not 'tabs'.
|
||||
*/
|
||||
export const MultiModelDisplay = React.memo(function MultiModelDisplay({
|
||||
versions,
|
||||
activeMessageId,
|
||||
mode,
|
||||
conversationId,
|
||||
onSwitchVersion,
|
||||
onDeleteVersion,
|
||||
onRegenerateVersion,
|
||||
onEditVersion,
|
||||
onBranchVersion,
|
||||
onSwitchModelVersion,
|
||||
onSetContextVersion,
|
||||
onDisplayVersionChange,
|
||||
displayVersionIdsByModelKey,
|
||||
renderContent,
|
||||
getModelDisplayInfo,
|
||||
streamingMessageId,
|
||||
}: MultiModelDisplayProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Safety: if versions is empty or invalid, render nothing
|
||||
if (!versions || versions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<MultiModelErrorBoundary>
|
||||
<MultiModelDisplayInner
|
||||
versions={versions}
|
||||
activeMessageId={activeMessageId}
|
||||
mode={mode}
|
||||
conversationId={conversationId}
|
||||
onSwitchVersion={onSwitchVersion}
|
||||
onDeleteVersion={onDeleteVersion}
|
||||
onRegenerateVersion={onRegenerateVersion}
|
||||
onEditVersion={onEditVersion}
|
||||
onBranchVersion={onBranchVersion}
|
||||
onSwitchModelVersion={onSwitchModelVersion}
|
||||
onSetContextVersion={onSetContextVersion}
|
||||
onDisplayVersionChange={onDisplayVersionChange}
|
||||
displayVersionIdsByModelKey={displayVersionIdsByModelKey}
|
||||
renderContent={renderContent}
|
||||
getModelDisplayInfo={getModelDisplayInfo}
|
||||
streamingMessageId={streamingMessageId}
|
||||
token={token}
|
||||
t={t}
|
||||
/>
|
||||
</MultiModelErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
interface MultiModelDisplayInnerProps extends Omit<MultiModelDisplayProps, 'multiModelDoneMessageIds'> {
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
}
|
||||
|
||||
function MultiModelDisplayInner({
|
||||
versions,
|
||||
activeMessageId,
|
||||
mode,
|
||||
conversationId,
|
||||
onSwitchVersion,
|
||||
onDeleteVersion,
|
||||
onRegenerateVersion,
|
||||
onEditVersion,
|
||||
onBranchVersion,
|
||||
onSwitchModelVersion,
|
||||
onSetContextVersion,
|
||||
onDisplayVersionChange,
|
||||
displayVersionIdsByModelKey,
|
||||
renderContent,
|
||||
getModelDisplayInfo,
|
||||
streamingMessageId,
|
||||
token,
|
||||
t,
|
||||
}: MultiModelDisplayInnerProps) {
|
||||
const parentMessageId = versions[0]?.parent_message_id;
|
||||
const storeMessages = useConversationStore((state) => state.messages);
|
||||
const storeStreaming = useConversationStore((state) => state.streaming);
|
||||
const streamingConversationId = useConversationStore((state) => state.streamingConversationId);
|
||||
const liveVersions = useMemo(() => {
|
||||
if (!parentMessageId) return [];
|
||||
return storeMessages.filter((message) =>
|
||||
message.parent_message_id === parentMessageId && message.role === 'assistant'
|
||||
);
|
||||
}, [parentMessageId, storeMessages]);
|
||||
const renderVersions = liveVersions.length > 0 ? liveVersions : versions;
|
||||
const displayVersions = useMemo(
|
||||
() => selectDisplayVersionsByModel(renderVersions, activeMessageId, displayVersionIdsByModelKey),
|
||||
[activeMessageId, displayVersionIdsByModelKey, renderVersions],
|
||||
);
|
||||
const isDisplayStreaming = storeStreaming && streamingConversationId === conversationId;
|
||||
|
||||
// For side-by-side mode, force the .ant-bubble ancestor to take full width
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (mode !== 'side-by-side') return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const modified: Array<{ el: HTMLElement; prev: string }> = [];
|
||||
let cur: HTMLElement | null = el;
|
||||
while (cur) {
|
||||
if (cur.classList.contains('ant-bubble')) {
|
||||
modified.push({ el: cur, prev: cur.style.cssText });
|
||||
cur.style.width = '100%';
|
||||
cur.style.boxSizing = 'border-box';
|
||||
break;
|
||||
}
|
||||
if (cur.classList.contains('ant-bubble-body') || cur.classList.contains('ant-bubble-content')) {
|
||||
modified.push({ el: cur, prev: cur.style.cssText });
|
||||
cur.style.overflow = 'hidden';
|
||||
cur.style.minWidth = '0';
|
||||
cur.style.width = '100%';
|
||||
}
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const item of modified) {
|
||||
item.el.style.cssText = item.prev;
|
||||
}
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
// Initialize OverlayScrollbars for persistent horizontal scrollbar
|
||||
useEffect(() => {
|
||||
if (mode !== 'side-by-side') return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const inst = OverlayScrollbars(
|
||||
{ target: el, elements: { viewport: el } },
|
||||
{
|
||||
scrollbars: {
|
||||
theme: 'os-theme-boosapi',
|
||||
autoHide: 'never',
|
||||
clickScroll: true,
|
||||
},
|
||||
overflow: { x: 'scroll', y: 'hidden' },
|
||||
},
|
||||
);
|
||||
|
||||
return () => inst.destroy();
|
||||
}, [mode]);
|
||||
|
||||
if (displayVersions.length <= 1) {
|
||||
const msg = displayVersions[0];
|
||||
if (!msg) return null;
|
||||
const isVersionStreaming = isDisplayStreaming && (msg.id === streamingMessageId || msg.status === 'partial');
|
||||
return (
|
||||
<MultiModelVersionContent
|
||||
message={msg}
|
||||
isVersionStreaming={isVersionStreaming}
|
||||
renderContent={renderContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: React.CSSProperties =
|
||||
mode === 'side-by-side'
|
||||
? {
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
overflowX: 'auto',
|
||||
paddingBottom: 8,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
alignItems: 'stretch',
|
||||
}
|
||||
: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties =
|
||||
mode === 'side-by-side'
|
||||
? {
|
||||
minWidth: 300,
|
||||
flex: '0 0 auto',
|
||||
width: `calc((100% - ${(displayVersions.length - 1) * 12}px) / ${displayVersions.length})`,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
: {
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} style={containerStyle} className={mode === 'side-by-side' ? 'boosapi-multi-model-scroll' : undefined}>
|
||||
{displayVersions.map((vMsg) => {
|
||||
const isActive = vMsg.id === activeMessageId;
|
||||
const isVersionStreaming = isDisplayStreaming && (
|
||||
vMsg.id === streamingMessageId || vMsg.status === 'partial'
|
||||
);
|
||||
const { modelName, providerName } = getModelDisplayInfo(
|
||||
vMsg.model_id,
|
||||
vMsg.provider_id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={vMsg.id}
|
||||
data-testid={`multi-model-card-${vMsg.id}`}
|
||||
style={{
|
||||
...cardStyle,
|
||||
borderColor: isActive ? token.colorPrimary : token.colorBorderSecondary,
|
||||
}}
|
||||
>
|
||||
{/* Card header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={vMsg.model_id ?? ''} size={20} type="avatar" />
|
||||
{providerName && (
|
||||
<Tag
|
||||
style={{
|
||||
fontSize: 11,
|
||||
margin: 0,
|
||||
padding: '0 4px',
|
||||
lineHeight: '18px',
|
||||
color: token.colorPrimary,
|
||||
backgroundColor: token.colorPrimaryBg,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{providerName}
|
||||
</Tag>
|
||||
)}
|
||||
<Typography.Text style={{ fontSize: 13 }}>{modelName}</Typography.Text>
|
||||
{isVersionStreaming && (
|
||||
<span className="boosapi-streaming-dots" aria-hidden="true" style={{ marginLeft: 4 }}>
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="multi-model-card-header-actions" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<MultiModelContextButton
|
||||
message={vMsg}
|
||||
isActive={isActive}
|
||||
parentMessageId={parentMessageId}
|
||||
token={token}
|
||||
t={t}
|
||||
onSwitchVersion={onSwitchVersion}
|
||||
onSetContextVersion={onSetContextVersion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Card content — key includes mode to force re-mount on layout switch */}
|
||||
<div
|
||||
key={`content-${mode}`}
|
||||
data-testid={`multi-model-card-content-${vMsg.id}`}
|
||||
style={{
|
||||
padding: '12px',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<MultiModelVersionContent
|
||||
message={vMsg}
|
||||
isVersionStreaming={isVersionStreaming}
|
||||
renderContent={renderContent}
|
||||
/>
|
||||
</div>
|
||||
<MultiModelCardActions
|
||||
message={vMsg}
|
||||
renderVersions={renderVersions}
|
||||
displayVersions={displayVersions}
|
||||
isVersionStreaming={isVersionStreaming}
|
||||
parentMessageId={parentMessageId}
|
||||
token={token}
|
||||
t={t}
|
||||
onDeleteVersion={onDeleteVersion}
|
||||
onRegenerateVersion={onRegenerateVersion}
|
||||
onEditVersion={onEditVersion}
|
||||
onBranchVersion={onBranchVersion}
|
||||
onSwitchModelVersion={onSwitchModelVersion}
|
||||
onDisplayVersionChange={onDisplayVersionChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiModelContextButton({
|
||||
message,
|
||||
isActive,
|
||||
parentMessageId,
|
||||
token,
|
||||
t,
|
||||
onSwitchVersion,
|
||||
onSetContextVersion,
|
||||
}: {
|
||||
message: Message;
|
||||
isActive: boolean;
|
||||
parentMessageId?: string | null;
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
onSwitchVersion: (parentMessageId: string, messageId: string) => void;
|
||||
onSetContextVersion?: (message: Message) => void;
|
||||
}) {
|
||||
const setContext = () => {
|
||||
if (isActive || !parentMessageId) return;
|
||||
if (onSetContextVersion) {
|
||||
onSetContextVersion(message);
|
||||
return;
|
||||
}
|
||||
onSwitchVersion(parentMessageId, message.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={isActive ? t('chat.currentContext', 'Current context') : t('chat.useAsContext', 'Use as context')}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`multi-model-set-context-${message.id}`}
|
||||
disabled={isActive || !parentMessageId}
|
||||
onClick={setContext}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
cursor: isActive ? 'default' : 'pointer',
|
||||
backgroundColor: isActive ? token.colorPrimary : 'transparent',
|
||||
color: isActive ? '#fff' : token.colorTextSecondary,
|
||||
border: isActive ? 'none' : `1px solid ${token.colorBorder}`,
|
||||
transition: 'all 0.2s',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiModelCardActions({
|
||||
message,
|
||||
renderVersions,
|
||||
displayVersions,
|
||||
isVersionStreaming,
|
||||
parentMessageId,
|
||||
token,
|
||||
t,
|
||||
onDeleteVersion,
|
||||
onRegenerateVersion,
|
||||
onEditVersion,
|
||||
onBranchVersion,
|
||||
onSwitchModelVersion,
|
||||
onDisplayVersionChange,
|
||||
}: {
|
||||
message: Message;
|
||||
renderVersions: Message[];
|
||||
displayVersions: Message[];
|
||||
isVersionStreaming: boolean;
|
||||
parentMessageId?: string | null;
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
onDeleteVersion?: (messageId: string) => void;
|
||||
onRegenerateVersion?: (message: Message) => void | Promise<void>;
|
||||
onEditVersion?: (message: Message) => void;
|
||||
onBranchVersion?: (message: Message, asChild: boolean) => void;
|
||||
onSwitchModelVersion?: (message: Message, providerId: string, modelId: string) => void | Promise<void>;
|
||||
onDisplayVersionChange?: (parentMessageId: string, modelKey: string, messageId: string) => void;
|
||||
}) {
|
||||
const modelKey = getMessageVersionGroupKey(message);
|
||||
const sameModelVersions = useMemo(
|
||||
() => renderVersions
|
||||
.filter((version) => getMessageVersionGroupKey(version) === modelKey)
|
||||
.sort((left, right) =>
|
||||
left.version_index - right.version_index
|
||||
|| left.created_at - right.created_at
|
||||
|| left.id.localeCompare(right.id)
|
||||
),
|
||||
[modelKey, renderVersions],
|
||||
);
|
||||
const currentVersionIndex = sameModelVersions.findIndex((version) => version.id === message.id);
|
||||
const canUseVersionPagination = Boolean(parentMessageId && sameModelVersions.length > 1 && onDisplayVersionChange);
|
||||
const actionsDisabled = isVersionStreaming || message.status === 'partial';
|
||||
const currentModelOverride = message.provider_id && message.model_id
|
||||
? { providerId: message.provider_id, modelId: message.model_id }
|
||||
: null;
|
||||
|
||||
const switchDisplayedVersion = (nextIndex: number) => {
|
||||
if (!canUseVersionPagination || !parentMessageId) return;
|
||||
const next = sameModelVersions[nextIndex];
|
||||
if (!next) return;
|
||||
onDisplayVersionChange?.(parentMessageId, modelKey, next.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="multi-model-card-footer-actions"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
padding: '6px 10px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, minWidth: 58 }}>
|
||||
{sameModelVersions.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ChevronLeft size={14} />}
|
||||
disabled={!canUseVersionPagination || currentVersionIndex <= 0}
|
||||
data-testid={`multi-model-version-prev-${message.id}`}
|
||||
onClick={() => switchDisplayedVersion(currentVersionIndex - 1)}
|
||||
style={{ minWidth: 20, padding: '0 2px' }}
|
||||
/>
|
||||
<Typography.Text style={{ fontSize: 11, color: token.colorTextSecondary }}>
|
||||
{Math.max(currentVersionIndex, 0) + 1}/{sameModelVersions.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ChevronRight size={14} />}
|
||||
disabled={!canUseVersionPagination || currentVersionIndex >= sameModelVersions.length - 1}
|
||||
data-testid={`multi-model-version-next-${message.id}`}
|
||||
onClick={() => switchDisplayedVersion(currentVersionIndex + 1)}
|
||||
style={{ minWidth: 20, padding: '0 2px' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<CopyButton
|
||||
text={() => stripBoosapiTags(message.content ?? '')}
|
||||
size={13}
|
||||
timeout={3000}
|
||||
/>
|
||||
<Tooltip title={t('chat.regenerate')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<RotateCcw size={13} />}
|
||||
disabled={actionsDisabled || !onRegenerateVersion}
|
||||
data-testid={`multi-model-regenerate-${message.id}`}
|
||||
onClick={() => onRegenerateVersion?.(message)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('chat.editMessage')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<Pencil size={13} />}
|
||||
disabled={actionsDisabled || !onEditVersion}
|
||||
data-testid={`multi-model-edit-${message.id}`}
|
||||
onClick={() => onEditVersion?.(message)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ModelSelector
|
||||
onSelect={(providerId, modelId) => onSwitchModelVersion?.(message, providerId, modelId)}
|
||||
overrideCurrentModel={currentModelOverride}
|
||||
>
|
||||
<Tooltip title={t('chat.switchModel')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowLeftRight size={13} />}
|
||||
disabled={actionsDisabled || !onSwitchModelVersion}
|
||||
data-testid={`multi-model-switch-model-${message.id}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ModelSelector>
|
||||
<Dropdown
|
||||
disabled={actionsDisabled || !onBranchVersion}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'independent',
|
||||
label: t('chat.branchIndependent'),
|
||||
onClick: () => onBranchVersion?.(message, false),
|
||||
},
|
||||
{
|
||||
key: 'child',
|
||||
label: t('chat.branchChild'),
|
||||
onClick: () => onBranchVersion?.(message, true),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
placement="bottom"
|
||||
>
|
||||
<Tooltip title={t('chat.branchConversation')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<GitBranch size={13} />}
|
||||
disabled={actionsDisabled || !onBranchVersion}
|
||||
data-testid={`multi-model-branch-${message.id}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
{onDeleteVersion && displayVersions.length > 1 && (
|
||||
<Popconfirm
|
||||
title={t('chat.deleteConfirm')}
|
||||
onConfirm={() => onDeleteVersion(message.id)}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
disabled={actionsDisabled}
|
||||
icon={<Trash2 size={13} />}
|
||||
data-testid={`multi-model-delete-${message.id}`}
|
||||
/>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Layout switcher row — rendered below ModelTags.
|
||||
* Lets users temporarily override the display mode for a specific message.
|
||||
*/
|
||||
export function LayoutSwitcher({
|
||||
currentMode,
|
||||
onModeChange,
|
||||
}: {
|
||||
currentMode: MultiModelDisplayMode;
|
||||
onModeChange: (mode: MultiModelDisplayMode) => void;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modes: { key: MultiModelDisplayMode; icon: React.ReactNode; label: string }[] = [
|
||||
{ key: 'tabs', icon: <LayoutList size={14} />, label: t('settings.multiModelDisplayModeTabs') },
|
||||
{ key: 'side-by-side', icon: <Columns2 size={14} />, label: t('settings.multiModelDisplayModeSideBySide') },
|
||||
{ key: 'stacked', icon: <Rows3 size={14} />, label: t('settings.multiModelDisplayModeStacked') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{modes.map(({ key, icon, label }) => (
|
||||
<Tooltip key={key} title={label} mouseEnterDelay={0.3}>
|
||||
<div
|
||||
onClick={() => onModeChange(key)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
cursor: currentMode === key ? 'default' : 'pointer',
|
||||
backgroundColor: currentMode === key ? token.colorPrimaryBg : 'transparent',
|
||||
color: currentMode === key ? token.colorPrimary : token.colorTextQuaternary,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, Space, Tag, Typography, theme } from 'antd';
|
||||
import { Shield, ShieldCheck, ShieldX, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useAgentStore } from '@/stores';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface PermissionCardProps {
|
||||
conversationId: string;
|
||||
toolUseId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
status: 'pending' | 'approved' | 'denied' | 'expired';
|
||||
}
|
||||
|
||||
const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
conversationId,
|
||||
toolUseId,
|
||||
toolName,
|
||||
input,
|
||||
status,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const approveToolUse = useAgentStore((state) => state.approveToolUse);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
const handleApprove = async (decision: string) => {
|
||||
setLoading(decision);
|
||||
try {
|
||||
await approveToolUse(conversationId, toolUseId, decision);
|
||||
} catch (e) {
|
||||
console.error('[PermissionCard] handleApprove failed:', e);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const inputStr = JSON.stringify(input, null, 2);
|
||||
|
||||
const borderColor =
|
||||
status === 'pending'
|
||||
? token.colorWarningBorder
|
||||
: status === 'approved'
|
||||
? token.colorSuccessBorder
|
||||
: status === 'denied'
|
||||
? token.colorErrorBorder
|
||||
: token.colorBorderSecondary;
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
margin: '8px 0',
|
||||
borderColor,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
||||
{/* Header */}
|
||||
<Space align="center">
|
||||
<Shield size={16} />
|
||||
<Text strong>{t('common.permissionRequired', 'Permission Required')}</Text>
|
||||
<Tag>{toolName}</Tag>
|
||||
</Space>
|
||||
|
||||
{/* Input preview */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('common.toolInput', 'Tool Input')}
|
||||
</Text>
|
||||
</div>
|
||||
{expanded && (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 8,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: token.colorBgTextHover,
|
||||
borderRadius: token.borderRadius,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{inputStr}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Action buttons or result */}
|
||||
{status === 'pending' ? (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<ShieldCheck size={14} />}
|
||||
loading={loading === 'allow_once'}
|
||||
onClick={() => handleApprove('allow_once')}
|
||||
>
|
||||
{t('common.allowOnce', 'Allow Once')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ShieldCheck size={14} />}
|
||||
loading={loading === 'allow_always'}
|
||||
onClick={() => handleApprove('allow_always')}
|
||||
>
|
||||
{t('common.allowAlways', 'Always Allow')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<ShieldX size={14} />}
|
||||
loading={loading === 'deny'}
|
||||
onClick={() => handleApprove('deny')}
|
||||
>
|
||||
{t('common.deny', 'Deny')}
|
||||
</Button>
|
||||
</Space>
|
||||
) : status === 'approved' ? (
|
||||
<Space>
|
||||
<ShieldCheck size={14} style={{ color: token.colorSuccess }} />
|
||||
<Text type="success">{t('common.approved', 'Approved')}</Text>
|
||||
</Space>
|
||||
) : status === 'denied' ? (
|
||||
<Space>
|
||||
<ShieldX size={14} style={{ color: token.colorError }} />
|
||||
<Text type="danger">{t('common.denied', 'Denied')}</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Text type="warning">⚠️ {t('common.expired', 'Expired (Agent disconnected)')}</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionCard;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useMemo } from 'react';
|
||||
import { theme, Typography } from 'antd';
|
||||
import { ThoughtChain, type ThoughtChainItemType } from '@ant-design/x';
|
||||
import { Terminal, FileEdit, Search, Wrench } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ToolCallItem {
|
||||
toolUseId: string;
|
||||
toolName: string;
|
||||
status: 'queued' | 'running' | 'success' | 'error' | 'cancelled';
|
||||
input?: string;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface ToolCallChainProps {
|
||||
toolCalls: ToolCallItem[];
|
||||
}
|
||||
|
||||
const statusMap: Record<string, ThoughtChainItemType['status']> = {
|
||||
queued: 'loading',
|
||||
running: 'loading',
|
||||
success: 'success',
|
||||
error: 'error',
|
||||
cancelled: 'abort',
|
||||
};
|
||||
|
||||
const toolIcons: Record<string, React.ReactNode> = {
|
||||
bash: <Terminal size={14} />,
|
||||
write: <FileEdit size={14} />,
|
||||
read: <Search size={14} />,
|
||||
edit: <FileEdit size={14} />,
|
||||
glob: <Search size={14} />,
|
||||
grep: <Search size={14} />,
|
||||
ls: <Search size={14} />,
|
||||
};
|
||||
|
||||
function getToolIcon(toolName: string): React.ReactNode {
|
||||
const lower = toolName.toLowerCase();
|
||||
for (const [key, icon] of Object.entries(toolIcons)) {
|
||||
if (lower.includes(key)) return icon;
|
||||
}
|
||||
return <Wrench size={14} />;
|
||||
}
|
||||
|
||||
function getInputSummary(input?: string): string | undefined {
|
||||
if (!input) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(input);
|
||||
if (parsed.command) return parsed.command;
|
||||
if (parsed.path) return parsed.path;
|
||||
if (parsed.file_path) return parsed.file_path;
|
||||
if (parsed.pattern) return parsed.pattern;
|
||||
const firstVal = Object.values(parsed)[0];
|
||||
if (typeof firstVal === 'string') return firstVal.length > 80 ? firstVal.slice(0, 80) + '…' : firstVal;
|
||||
} catch {
|
||||
// not json
|
||||
}
|
||||
return input.length > 80 ? input.slice(0, 80) + '…' : input;
|
||||
}
|
||||
|
||||
export function ToolCallCard({ toolCalls }: ToolCallChainProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const chainItems: ThoughtChainItemType[] = useMemo(() => {
|
||||
return toolCalls.map((tc) => {
|
||||
const contentParts: React.ReactNode[] = [];
|
||||
|
||||
if (tc.input) {
|
||||
contentParts.push(
|
||||
<details key="input" style={{ margin: 0 }}>
|
||||
<summary style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer', userSelect: 'none' }}>
|
||||
{t('chat.inspector.toolInput', '输入参数')}
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
padding: 8,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: token.colorBgTextHover,
|
||||
borderRadius: token.borderRadius,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{tc.input}
|
||||
</pre>
|
||||
</details>,
|
||||
);
|
||||
}
|
||||
|
||||
if (tc.output) {
|
||||
contentParts.push(
|
||||
<details key="output" style={{ margin: 0 }}>
|
||||
<summary style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer', userSelect: 'none' }}>
|
||||
{t('chat.inspector.toolOutput', '执行结果')}
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
padding: 8,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: token.colorBgTextHover,
|
||||
borderRadius: token.borderRadius,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
color: tc.isError ? token.colorError : undefined,
|
||||
}}
|
||||
>
|
||||
{tc.output}
|
||||
</pre>
|
||||
</details>,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
key: tc.toolUseId,
|
||||
icon: getToolIcon(tc.toolName),
|
||||
title: tc.toolName,
|
||||
description: (
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, fontFamily: 'monospace' }}
|
||||
ellipsis
|
||||
>
|
||||
{getInputSummary(tc.input)}
|
||||
</Typography.Text>
|
||||
),
|
||||
status: statusMap[tc.status] || 'loading',
|
||||
collapsible: tc.status === 'success' || tc.status === 'error' || tc.status === 'cancelled',
|
||||
content: contentParts.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{contentParts}
|
||||
</div>
|
||||
) : undefined,
|
||||
} satisfies ThoughtChainItemType;
|
||||
});
|
||||
}, [toolCalls, token, t]);
|
||||
|
||||
if (chainItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ margin: '4px 0 8px' }}>
|
||||
<ThoughtChain
|
||||
items={chainItems}
|
||||
line="dashed"
|
||||
styles={{
|
||||
item: { padding: '4px 0' },
|
||||
itemContent: { fontSize: 12 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Typography, Spin } from 'antd';
|
||||
import { Mic, MicOff, Phone, Loader, Volume2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVoiceChat } from '@/hooks/useVoiceChat';
|
||||
import type { RealtimeConfig, VoiceSessionState } from '@/types';
|
||||
|
||||
interface VoiceCallProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
port?: number;
|
||||
config: RealtimeConfig;
|
||||
}
|
||||
|
||||
function StatusDisplay({ state }: { state: VoiceSessionState }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (state) {
|
||||
case 'Connecting':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Spin indicator={<Loader size={48} style={{ color: '#fff', animation: 'spin 1s linear infinite' }} />} />
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.connecting')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
case 'Connected':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Mic size={48} style={{ color: '#52c41a' }} />
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.connected')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
case 'Speaking':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="voice-waveform">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="voice-bar"
|
||||
style={{
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.speaking')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
case 'Listening':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Volume2 size={48} style={{ color: '#1677ff' }} className="animate-pulse" />
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.listening')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
case 'Disconnecting':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Spin indicator={<Loader size={48} style={{ color: '#fff', animation: 'spin 1s linear infinite' }} />} />
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.disconnecting')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [state, t]);
|
||||
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
export function VoiceCall({ visible, onClose, port, config }: VoiceCallProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, isMuted, start, stop, toggleMute } = useVoiceChat({ port, config });
|
||||
|
||||
// Auto-start when overlay becomes visible
|
||||
if (visible && state === 'Idle') {
|
||||
start();
|
||||
}
|
||||
|
||||
const handleEndCall = () => {
|
||||
stop();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex flex-col items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,0.85)' }}
|
||||
>
|
||||
{/* Status display */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<StatusDisplay state={state} />
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-8 pb-16">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={isMuted ? <MicOff size={20} /> : <Mic size={20} />}
|
||||
onClick={toggleMute}
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
background: isMuted ? '#ff4d4f' : 'rgba(255,255,255,0.2)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
}}
|
||||
title={t('voice.toggleMute')}
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<Phone size={24} style={{ transform: 'rotate(225deg)' }} />}
|
||||
onClick={handleEndCall}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
background: '#ff4d4f',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
}}
|
||||
title={t('voice.endCall')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Waveform CSS */}
|
||||
<style>{`
|
||||
.voice-waveform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 60px;
|
||||
}
|
||||
.voice-bar {
|
||||
width: 6px;
|
||||
height: 20px;
|
||||
background: #52c41a;
|
||||
border-radius: 3px;
|
||||
animation: voiceWave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes voiceWave {
|
||||
0% { height: 12px; }
|
||||
100% { height: 48px; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, theme } from 'antd';
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import { Search, ChevronDown, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Think from '@ant-design/x/es/think';
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
type WebSearchNodeData = {
|
||||
type: 'web-search';
|
||||
content?: string;
|
||||
attrs?: Record<string, string> | [string, string][];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type WebSearchQueryNodeData = {
|
||||
type: 'web-search-query';
|
||||
content?: string;
|
||||
attrs?: Record<string, string> | [string, string][];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function getAttrValue(
|
||||
attrs: WebSearchNodeData['attrs'],
|
||||
key: string,
|
||||
): string | undefined {
|
||||
if (!attrs) return undefined;
|
||||
if (Array.isArray(attrs)) {
|
||||
const entry = attrs.find(([name]) => name === key);
|
||||
return entry?.[1];
|
||||
}
|
||||
return attrs[key];
|
||||
}
|
||||
|
||||
function decodeAttrValue(value: string): string {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function getFavicon(url: string) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function SearchQuerySummaryPanel({
|
||||
status,
|
||||
query,
|
||||
error,
|
||||
}: {
|
||||
status: string;
|
||||
query?: string;
|
||||
error?: string;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const isLoading = status === 'summarizing';
|
||||
const isError = status === 'error';
|
||||
const [expanded, setExpanded] = useState(isError);
|
||||
const title = isLoading
|
||||
? t('chat.search.summarizingQuery')
|
||||
: isError
|
||||
? t('chat.search.querySummaryFailed')
|
||||
: t('chat.search.querySummary');
|
||||
const hasBody = Boolean(error || query);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Think
|
||||
title={(
|
||||
<span
|
||||
data-boosapi-search-query-status={status}
|
||||
style={{ color: isError ? token.colorError : undefined }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
blink={isLoading}
|
||||
loading={isLoading ? (
|
||||
<SyncOutlined style={{ fontSize: 12, animation: 'boosapi-think-spin 1s linear infinite' }} />
|
||||
) : false}
|
||||
icon={isError ? <AlertCircle size={14} /> : <Search size={14} />}
|
||||
expanded={expanded}
|
||||
onExpand={(next) => {
|
||||
if (hasBody) setExpanded(next);
|
||||
}}
|
||||
>
|
||||
{isError && error && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={error}
|
||||
style={{ marginBottom: query ? 8 : 0 }}
|
||||
/>
|
||||
)}
|
||||
{query && (
|
||||
<div
|
||||
style={{
|
||||
color: token.colorText,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{query}
|
||||
</div>
|
||||
)}
|
||||
</Think>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebSearchQueryNode(props: NodeComponentProps<WebSearchQueryNodeData>) {
|
||||
const { node } = props;
|
||||
|
||||
const status = getAttrValue(node.attrs, 'status') ?? (node.loading ? 'summarizing' : 'done');
|
||||
const queryAttr = getAttrValue(node.attrs, 'query');
|
||||
const query = queryAttr ? decodeAttrValue(queryAttr).trim() : '';
|
||||
const error = node.content ? decodeAttrValue(node.content).trim() : '';
|
||||
|
||||
return (
|
||||
<SearchQuerySummaryPanel
|
||||
status={status}
|
||||
query={query}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebSearchNode(props: NodeComponentProps<WebSearchNodeData>) {
|
||||
const { node } = props;
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const status = getAttrValue(node.attrs, 'status') ?? (node.loading ? 'searching' : 'done');
|
||||
const searchQuery = getAttrValue(node.attrs, 'query');
|
||||
const displayQuery = searchQuery ? decodeAttrValue(searchQuery).trim() : '';
|
||||
const queryStatus = getAttrValue(node.attrs, 'query-status');
|
||||
const queryErrorAttr = getAttrValue(node.attrs, 'query-error');
|
||||
const queryError = queryErrorAttr ? decodeAttrValue(queryErrorAttr).trim() : '';
|
||||
|
||||
// Parse results from node content
|
||||
let results: SearchResult[] = [];
|
||||
if (node.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(node.content);
|
||||
if (Array.isArray(parsed)) results = parsed;
|
||||
} catch {
|
||||
// invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowQuerySummary = status === 'summarizing' || !!queryStatus || !!displayQuery;
|
||||
const querySummary = shouldShowQuerySummary
|
||||
? (
|
||||
<SearchQuerySummaryPanel
|
||||
status={status === 'summarizing' ? 'summarizing' : queryStatus ?? 'done'}
|
||||
query={displayQuery}
|
||||
error={queryError}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
|
||||
// Summarizing/searching states
|
||||
if (status === 'summarizing' || status === 'searching') {
|
||||
if (status === 'summarizing') {
|
||||
return querySummary;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{querySummary}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{ display: 'inline-flex', width: 16, height: 16 }}
|
||||
>
|
||||
<Search size={16} style={{ color: token.colorPrimary }} />
|
||||
</span>
|
||||
<span style={{ color: token.colorTextSecondary, fontSize: 13 }}>
|
||||
{t('chat.search.searching')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<>
|
||||
{querySummary}
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={node.content || t('chat.search.error')}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Done state — show an explicit empty state instead of silently disappearing.
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{querySummary}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Search size={16} style={{ color: token.colorTextTertiary }} />
|
||||
<span style={{ flex: 1 }}>{t('chat.search.noResults', '未找到相关搜索结果')}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{querySummary}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Search size={14} style={{ color: token.colorPrimary }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{t('chat.search.resultsCount', { count: results.length })}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', color: token.colorTextTertiary }}>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Source icons row (always visible) */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
padding: '6px 12px',
|
||||
flexWrap: 'wrap',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={r.title}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
borderRadius: 4,
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
textDecoration: 'none',
|
||||
maxWidth: 160,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFavicon(r.url)}
|
||||
width={12}
|
||||
height={12}
|
||||
alt=""
|
||||
style={{ flexShrink: 0 }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{r.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
marginBottom: i < results.length - 1 ? 8 : 0,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
color: token.colorPrimary,
|
||||
fontWeight: 500,
|
||||
textDecoration: 'none',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFavicon(r.url)}
|
||||
width={14}
|
||||
height={14}
|
||||
alt=""
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{r.title}
|
||||
<ExternalLink size={10} style={{ opacity: 0.5 }} />
|
||||
</a>
|
||||
{r.content && (
|
||||
<p
|
||||
style={{
|
||||
margin: '2px 0 0 0',
|
||||
color: token.colorTextSecondary,
|
||||
lineHeight: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{r.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { App } from 'antd';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatImageNode } from '../ChatImageNode';
|
||||
|
||||
const imageActions = vi.hoisted(() => ({
|
||||
copyChatImage: vi.fn(async (_src: string) => undefined),
|
||||
saveChatImage: vi.fn(async (_src: string, _defaultName?: string) => true),
|
||||
getDefaultImageFilename: vi.fn((_src: string, _alt?: string | null) => 'boosapi-image.png'),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/chatImageActions', () => ({
|
||||
copyChatImage: (src: string) => imageActions.copyChatImage(src),
|
||||
saveChatImage: (src: string, defaultName?: string) => imageActions.saveChatImage(src, defaultName),
|
||||
getDefaultImageFilename: (src: string, alt?: string | null) => imageActions.getDefaultImageFilename(src, alt),
|
||||
}));
|
||||
|
||||
function renderNode(src = 'data:image/png;base64,aGVsbG8=') {
|
||||
return render(
|
||||
<App>
|
||||
<ChatImageNode
|
||||
node={{
|
||||
type: 'image',
|
||||
src,
|
||||
alt: 'generated preview',
|
||||
title: null,
|
||||
raw: ``,
|
||||
}}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ChatImageNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
imageActions.copyChatImage.mockResolvedValue(undefined);
|
||||
imageActions.saveChatImage.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('shows a loading region until the image finishes loading', () => {
|
||||
renderNode();
|
||||
|
||||
expect(screen.getByText('图片加载中...')).toBeInTheDocument();
|
||||
|
||||
fireEvent.load(screen.getByAltText('generated preview'));
|
||||
|
||||
expect(screen.queryByText('图片加载中...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an antd error alert with the image link when loading fails', () => {
|
||||
const src = 'https://example.com/missing.png';
|
||||
renderNode(src);
|
||||
|
||||
fireEvent.error(screen.getByAltText('generated preview'));
|
||||
|
||||
expect(screen.getByText('图片加载失败')).toBeInTheDocument();
|
||||
expect(screen.getByText(src)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads image details from html img node attributes', () => {
|
||||
const src = 'https://example.com/generated.webp';
|
||||
render(
|
||||
<App>
|
||||
<ChatImageNode
|
||||
node={{
|
||||
type: 'img',
|
||||
attrs: [
|
||||
['src', src],
|
||||
['alt', 'html preview'],
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('图片加载中...')).toBeInTheDocument();
|
||||
|
||||
fireEvent.load(screen.getByAltText('html preview'));
|
||||
|
||||
expect(screen.queryByText('图片加载中...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies and saves a loaded image through the image actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const src = 'data:image/png;base64,aGVsbG8=';
|
||||
renderNode(src);
|
||||
|
||||
fireEvent.load(screen.getByAltText('generated preview'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '复制图片' }));
|
||||
await waitFor(() => expect(imageActions.copyChatImage).toHaveBeenCalledWith(src));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存图片' }));
|
||||
await waitFor(() => expect(imageActions.saveChatImage).toHaveBeenCalledWith(src, 'boosapi-image.png'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatMessageRenderBoundary } from '../ChatMessageRenderBoundary';
|
||||
|
||||
function ThrowingMessage(): ReactElement {
|
||||
throw new Error('render failed');
|
||||
}
|
||||
|
||||
describe('ChatMessageRenderBoundary', () => {
|
||||
it('falls back to plain text for a single failed message render', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<div>
|
||||
<div>previous message</div>
|
||||
<ChatMessageRenderBoundary fallback={<div style={{ whiteSpace: 'pre-wrap' }}>plain fallback text</div>}>
|
||||
<ThrowingMessage />
|
||||
</ChatMessageRenderBoundary>
|
||||
<div>next message</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('previous message')).toBeInTheDocument();
|
||||
expect(screen.getByText('plain fallback text')).toBeInTheDocument();
|
||||
expect(screen.getByText('next message')).toBeInTheDocument();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatScrollIndicator } from '../ChatScrollIndicator';
|
||||
|
||||
function createScrollableBubbleList() {
|
||||
const scrollBox = document.createElement('div');
|
||||
scrollBox.className = 'ant-bubble-list-scroll-box';
|
||||
scrollBox.style.flexDirection = 'column-reverse';
|
||||
|
||||
let scrollTop = 0;
|
||||
Object.defineProperties(scrollBox, {
|
||||
clientHeight: { configurable: true, value: 800 },
|
||||
scrollHeight: { configurable: true, value: 2000 },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => {
|
||||
scrollTop = value;
|
||||
scrollBox.dispatchEvent(new Event('scroll'));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
document.body.appendChild(scrollBox);
|
||||
return scrollBox;
|
||||
}
|
||||
|
||||
describe('ChatScrollIndicator', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports user scroll intent before dragging the custom scrollbar thumb', async () => {
|
||||
createScrollableBubbleList();
|
||||
const onUserScrollIntent = vi.fn();
|
||||
|
||||
render(<ChatScrollIndicator onUserScrollIntent={onUserScrollIntent} />);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.chat-scroll-indicator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const thumb = document.querySelector('.chat-scroll-indicator') as HTMLElement;
|
||||
fireEvent.pointerDown(thumb, { clientY: 10 });
|
||||
|
||||
expect(onUserScrollIntent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports user scroll intent before jumping from the custom scrollbar track', async () => {
|
||||
createScrollableBubbleList();
|
||||
const onUserScrollIntent = vi.fn();
|
||||
|
||||
render(<ChatScrollIndicator onUserScrollIntent={onUserScrollIntent} />);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.chat-scroll-indicator-track')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const track = document.querySelector('.chat-scroll-indicator-track') as HTMLElement;
|
||||
fireEvent.pointerDown(track, { clientY: 400 });
|
||||
|
||||
expect(onUserScrollIntent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatSidebar } from '../ChatSidebar';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
deleteConversation: vi.fn(),
|
||||
setActiveConversation: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
updateConversation: vi.fn(),
|
||||
togglePin: vi.fn(),
|
||||
toggleArchive: vi.fn(),
|
||||
fetchArchivedConversations: vi.fn(),
|
||||
batchDelete: vi.fn(),
|
||||
batchArchive: vi.fn(),
|
||||
regenerateTitle: vi.fn(),
|
||||
saveSettings: vi.fn(),
|
||||
fetchCategories: vi.fn(),
|
||||
createCategory: vi.fn(),
|
||||
updateCategory: vi.fn(),
|
||||
deleteCategory: vi.fn(),
|
||||
setCollapsed: vi.fn(),
|
||||
}));
|
||||
|
||||
const conversationState: any = {
|
||||
conversations: [
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: '快捷删除测试',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
message_count: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
],
|
||||
activeConversationId: 'conv-1',
|
||||
setActiveConversation: mocks.setActiveConversation,
|
||||
createConversation: mocks.createConversation,
|
||||
deleteConversation: mocks.deleteConversation,
|
||||
updateConversation: mocks.updateConversation,
|
||||
togglePin: mocks.togglePin,
|
||||
toggleArchive: mocks.toggleArchive,
|
||||
archivedConversations: [],
|
||||
fetchArchivedConversations: mocks.fetchArchivedConversations,
|
||||
batchDelete: mocks.batchDelete,
|
||||
batchArchive: mocks.batchArchive,
|
||||
streamingConversationId: null,
|
||||
titleGeneratingConversationId: null,
|
||||
regenerateTitle: mocks.regenerateTitle,
|
||||
};
|
||||
|
||||
const providerState = {
|
||||
providers: [
|
||||
{
|
||||
id: 'provider-1',
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
enabled: true,
|
||||
model_type: 'Chat',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const settingsState = {
|
||||
settings: {
|
||||
default_provider_id: 'provider-1',
|
||||
default_model_id: 'model-1',
|
||||
last_selected_conversation_id: null,
|
||||
},
|
||||
loading: false,
|
||||
saveSettings: mocks.saveSettings,
|
||||
};
|
||||
|
||||
const categoryState: any = {
|
||||
categories: [],
|
||||
fetchCategories: mocks.fetchCategories,
|
||||
createCategory: mocks.createCategory,
|
||||
updateCategory: mocks.updateCategory,
|
||||
deleteCategory: mocks.deleteCategory,
|
||||
setCollapsed: mocks.setCollapsed,
|
||||
};
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, string>) => ({
|
||||
'chat.delete': '删除',
|
||||
'chat.directDeleteHint': '按住 Ctrl 可直接删除',
|
||||
'chat.deleteConfirm': '确定删除此对话?',
|
||||
'chat.searchPlaceholder': '搜索对话...',
|
||||
'chat.archived': '已归档',
|
||||
'chat.createCategory': '新建分类',
|
||||
'chat.newConversation': '新建对话',
|
||||
'chat.newConversationInCurrentCategory': `在 ${options?.category ?? '当前分类'} 下新建`,
|
||||
'chat.newStandaloneConversation': '独立新建',
|
||||
'chat.multiSelect': '多选',
|
||||
'chat.noConversations': '暂无对话',
|
||||
'chat.today': '今天',
|
||||
'chat.yesterday': '昨天',
|
||||
'chat.thisWeek': '本周',
|
||||
'chat.thisMonth': '本月',
|
||||
'chat.earlier': '更早',
|
||||
'chat.pin': '置顶',
|
||||
'chat.unpin': '取消置顶',
|
||||
'chat.pinned': '已置顶',
|
||||
'chat.archive': '归档',
|
||||
'chat.rename': '重命名',
|
||||
'chat.generateTitle': '生成标题',
|
||||
'chat.generatingTitle': '正在生成标题',
|
||||
'chat.export': '导出',
|
||||
}[key] ?? key),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
message: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
|
||||
modal: { confirm: mocks.confirm },
|
||||
}),
|
||||
},
|
||||
Button: ({ children, icon, onClick, 'aria-label': ariaLabel, title, disabled }: any) => (
|
||||
<button type="button" aria-label={ariaLabel ?? title} disabled={disabled} onClick={onClick}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Input: (props: any) => <input {...props} />,
|
||||
Tooltip: ({ children, title }: any) => (
|
||||
<span title={typeof title === 'string' ? title : undefined}>{children}</span>
|
||||
),
|
||||
Checkbox: ({ checked, onChange, onClick }: any) => (
|
||||
<input type="checkbox" checked={checked} onChange={onChange} onClick={onClick} readOnly />
|
||||
),
|
||||
Dropdown: ({ children, menu }: any) => (
|
||||
<div>
|
||||
{children}
|
||||
{menu?.items?.map((item: any) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
aria-label={typeof item.label === 'string' ? item.label : undefined}
|
||||
disabled={item.disabled}
|
||||
onClick={() => menu.onClick?.({ key: item.key, domEvent: { stopPropagation: vi.fn() } })}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Empty: ({ description }: any) => <div>{description}</div>,
|
||||
Avatar: () => null,
|
||||
theme: {
|
||||
useToken: () => ({
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
colorPrimaryBg: '#e6f4ff',
|
||||
colorBgContainer: '#fff',
|
||||
colorFillContent: '#f5f5f5',
|
||||
colorTextSecondary: '#666',
|
||||
colorTextQuaternary: '#aaa',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/x/es/conversations', () => ({
|
||||
default: ({ items, menu }: any) => (
|
||||
<ul>
|
||||
{items.map((item: any) => {
|
||||
const menuConfig = typeof menu === 'function' ? menu(item) : menu;
|
||||
const trigger = menuConfig?.trigger
|
||||
? menuConfig.trigger(item, { originNode: <button type="button" aria-label="更多" /> })
|
||||
: <button type="button" aria-label="更多" />;
|
||||
|
||||
return (
|
||||
<li key={item.key} data-conv-id={item['data-conv-id']}>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{trigger}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="菜单删除"
|
||||
onClick={() => menuConfig?.onClick?.({ key: 'delete', domEvent: {} })}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: any) => <>{children}</>,
|
||||
DragOverlay: ({ children }: any) => <>{children}</>,
|
||||
closestCenter: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
useDraggable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
isDragging: false,
|
||||
}),
|
||||
useDroppable: () => ({
|
||||
setNodeRef: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => null,
|
||||
modelMappings: [],
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useConversationStore: Object.assign(
|
||||
(selector: (state: typeof conversationState) => unknown) => selector(conversationState),
|
||||
{ getState: () => ({ ...conversationState, fetchConversations: vi.fn() }) },
|
||||
),
|
||||
useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState),
|
||||
useSettingsStore: Object.assign(
|
||||
(selector: (state: typeof settingsState) => unknown) => selector(settingsState),
|
||||
{ getState: () => settingsState },
|
||||
),
|
||||
useCategoryStore: Object.assign(
|
||||
(selector: (state: typeof categoryState) => unknown) => selector(categoryState),
|
||||
{ setState: vi.fn(), getState: () => categoryState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useResolvedAvatarSrc', () => ({
|
||||
useResolvedAvatarSrc: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/convIcon', () => ({
|
||||
getConvIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/exportChat', () => ({
|
||||
exportAsMarkdown: vi.fn(),
|
||||
exportAsText: vi.fn(),
|
||||
exportAsPNG: vi.fn(),
|
||||
exportAsJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/invoke', () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/shortcuts', () => ({
|
||||
getShortcutBinding: () => 'CmdOrCtrl+N',
|
||||
formatShortcutForDisplay: () => 'Ctrl+N',
|
||||
}));
|
||||
|
||||
vi.mock('../CategoryEditModal', () => ({
|
||||
CategoryEditModal: () => null,
|
||||
}));
|
||||
|
||||
describe('ChatSidebar direct delete shortcut', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
conversationState.conversations = [
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: '快捷删除测试',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
message_count: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
conversationState.activeConversationId = 'conv-1';
|
||||
conversationState.titleGeneratingConversationId = null;
|
||||
categoryState.categories = [];
|
||||
mocks.regenerateTitle.mockResolvedValue(undefined);
|
||||
mocks.createConversation.mockResolvedValue({
|
||||
id: 'conv-new',
|
||||
title: '新建对话',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
message_count: 0,
|
||||
created_at: 2,
|
||||
updated_at: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the confirmation dialog for a normal menu delete click', () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '更多' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '删除' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '菜单删除' }));
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.deleteConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders long conversation titles inside a constrained truncation element', () => {
|
||||
const longTitle = '这是一个非常长的用户首条消息标题,用来模拟标题总结模型失败时回退到用户输入导致侧边栏被撑高的问题';
|
||||
conversationState.conversations[0].title = longTitle;
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const title = screen.getByText(longTitle);
|
||||
expect(title).toHaveClass('boosapi-chat-conversation-title');
|
||||
expect(title).toHaveAttribute('title', longTitle);
|
||||
expect(title).toHaveStyle({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
});
|
||||
|
||||
it('turns the more trigger into direct delete while Ctrl is held', async () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Control', ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }), { ctrlKey: true });
|
||||
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteConversation).toHaveBeenCalledWith('conv-1');
|
||||
});
|
||||
|
||||
it('turns the more trigger into direct delete while Cmd is held', async () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Meta', metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }), { metaKey: true });
|
||||
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteConversation).toHaveBeenCalledWith('conv-1');
|
||||
});
|
||||
|
||||
it('does not add a separate row delete action and hides the delete trigger on active rows until hover', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(process.cwd(), 'src/components/chat/ChatSidebar.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(source).not.toContain('boosapi-chat-conversation-direct-delete');
|
||||
expect(source).not.toContain('PanelLeftClose');
|
||||
expect(source).not.toContain('onCollapse');
|
||||
expect(source).not.toContain('chat.collapseSidebar');
|
||||
expect(source).toContain('.ant-conversations .ant-conversations-item-active .boosapi-chat-conversation-menu-delete');
|
||||
expect(source).toContain('opacity: 0;');
|
||||
});
|
||||
|
||||
it('offers current-category and standalone choices when creating from a categorized conversation', async () => {
|
||||
conversationState.conversations[0].category_id = 'cat-work';
|
||||
categoryState.categories = [
|
||||
{
|
||||
id: 'cat-work',
|
||||
name: '工作',
|
||||
icon_type: null,
|
||||
icon_value: null,
|
||||
system_prompt: '工作分类提示词',
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
default_temperature: null,
|
||||
default_max_tokens: null,
|
||||
default_top_p: null,
|
||||
default_frequency_penalty: null,
|
||||
sort_order: 0,
|
||||
is_collapsed: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '在 工作 下新建' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createConversation).toHaveBeenCalledWith(
|
||||
'新建对话',
|
||||
'model-1',
|
||||
'provider-1',
|
||||
{ categoryId: 'cat-work' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets users create a standalone conversation from a categorized conversation', async () => {
|
||||
conversationState.conversations[0].category_id = 'cat-work';
|
||||
categoryState.categories = [
|
||||
{
|
||||
id: 'cat-work',
|
||||
name: '工作',
|
||||
icon_type: null,
|
||||
icon_value: null,
|
||||
system_prompt: '工作分类提示词',
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
default_temperature: null,
|
||||
default_max_tokens: null,
|
||||
default_top_p: null,
|
||||
default_frequency_penalty: null,
|
||||
sort_order: 0,
|
||||
is_collapsed: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '独立新建' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createConversation).toHaveBeenCalledWith(
|
||||
'新建对话',
|
||||
'model-1',
|
||||
'provider-1',
|
||||
{ categoryId: null },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps long sidebar conversation titles in a truncation target with the full title available', () => {
|
||||
const longTitle = '这是一个非常长的会话标题,用于验证侧边栏不会因为标题过长而撑高或者挤压操作按钮';
|
||||
conversationState.conversations[0].title = longTitle;
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const title = screen.getByText(longTitle);
|
||||
expect(title).toHaveClass('boosapi-chat-conversation-title');
|
||||
expect(title).toHaveAttribute('title', longTitle);
|
||||
});
|
||||
|
||||
it('shows an inline loading status on the conversation row while generating its title', () => {
|
||||
conversationState.titleGeneratingConversationId = 'conv-1';
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const status = screen.getByRole('status', { name: '正在生成标题' });
|
||||
expect(status).toHaveClass('boosapi-chat-conversation-title-generating');
|
||||
});
|
||||
|
||||
it('keeps long title truncation separate from the title generation loading indicator', () => {
|
||||
const longTitle = '这是一个非常长的会话标题,用于验证侧边栏不会因为标题过长而撑高或者挤压操作按钮';
|
||||
conversationState.conversations[0].title = longTitle;
|
||||
conversationState.titleGeneratingConversationId = 'conv-1';
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const title = screen.getByText(longTitle);
|
||||
expect(title).toHaveClass('boosapi-chat-conversation-title');
|
||||
expect(title).toHaveAttribute('title', longTitle);
|
||||
expect(title).not.toHaveTextContent('正在生成标题');
|
||||
expect(screen.getByRole('status', { name: '正在生成标题' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds generate title after rename in the right-click menu and calls the title regeneration action', async () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('快捷删除测试'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '生成标题' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const menuLabels = screen
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label') ?? button.textContent)
|
||||
.filter(Boolean);
|
||||
expect(menuLabels.indexOf('生成标题')).toBeGreaterThan(menuLabels.indexOf('重命名'));
|
||||
expect(menuLabels.indexOf('生成标题')).toBeLessThan(menuLabels.indexOf('导出'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成标题' }));
|
||||
|
||||
expect(mocks.regenerateTitle).toHaveBeenCalledWith('conv-1');
|
||||
});
|
||||
|
||||
it('disables generate title while the conversation title is already generating', async () => {
|
||||
conversationState.titleGeneratingConversationId = 'conv-1';
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('快捷删除测试'));
|
||||
|
||||
const generateTitleButton = await screen.findByRole('button', { name: '生成标题' });
|
||||
expect(generateTitleButton).toBeDisabled();
|
||||
|
||||
fireEvent.click(generateTitleButton);
|
||||
|
||||
expect(mocks.regenerateTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('ChatView sidebar collapse control', () => {
|
||||
const readSource = () => fs.readFileSync(
|
||||
path.resolve(process.cwd(), 'src/components/chat/ChatView.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
it('renders the sidebar toggle before the active conversation icon in the top bar', () => {
|
||||
const source = readSource();
|
||||
const topBarIndex = source.indexOf('{/* Top Bar */}');
|
||||
const toggleIndex = source.indexOf('{renderChatSidebarToggle()}', topBarIndex);
|
||||
const iconIndex = source.indexOf('{renderConvIconForChat(24)}', topBarIndex);
|
||||
|
||||
expect(toggleIndex).toBeGreaterThan(topBarIndex);
|
||||
expect(toggleIndex).toBeLessThan(iconIndex);
|
||||
});
|
||||
|
||||
it('renders the sidebar toggle before the welcome title when no conversation is active', () => {
|
||||
const source = readSource();
|
||||
const activeIconIndex = source.indexOf('{renderConvIconForChat(24)}');
|
||||
const inactiveToggleIndex = source.indexOf('{renderChatSidebarToggle()}', activeIconIndex + 1);
|
||||
const welcomeIndex = source.indexOf("<Typography.Text type=\"secondary\">{t('chat.welcome')}</Typography.Text>");
|
||||
|
||||
expect(inactiveToggleIndex).toBeGreaterThan(activeIconIndex);
|
||||
expect(inactiveToggleIndex).toBeLessThan(welcomeIndex);
|
||||
});
|
||||
|
||||
it('persists the inverse chat_sidebar_collapsed setting from the title bar button', () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('PanelLeftClose');
|
||||
expect(source).toContain('PanelLeftOpen');
|
||||
expect(source).toContain('const saveSettings = useSettingsStore((s) => s.saveSettings);');
|
||||
expect(source).toContain('saveSettings({ chat_sidebar_collapsed: !chatSidebarCollapsed })');
|
||||
});
|
||||
|
||||
it('does not render a tooltip for the title bar sidebar toggle', () => {
|
||||
const source = readSource();
|
||||
const toggleStart = source.indexOf('const renderChatSidebarToggle = useCallback(() => {');
|
||||
const toggleEnd = source.indexOf('// ── User avatar helper', toggleStart);
|
||||
const toggleSource = source.slice(toggleStart, toggleEnd);
|
||||
|
||||
expect(toggleSource).not.toContain('<Tooltip');
|
||||
expect(toggleSource).toContain('aria-label={label}');
|
||||
});
|
||||
|
||||
it('constrains long active conversation titles in the top bar', () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('boosapi-chat-title-shell');
|
||||
expect(source).toContain('boosapi-chat-title-text');
|
||||
expect(source).toContain('ellipsis={{ tooltip: activeConversation.title }}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import type { Message } from '@/types';
|
||||
import {
|
||||
buildAssistantDisplayContent,
|
||||
splitAssistantErrorDisplayContent,
|
||||
} from '../toolCallDisplay';
|
||||
import {
|
||||
resolveAssistantMessageForBubbleKey,
|
||||
} from '../chatMessageLookup';
|
||||
|
||||
function makeMessage(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
provider_id: null,
|
||||
model_id: null,
|
||||
token_count: null,
|
||||
attachments: [],
|
||||
thinking: null,
|
||||
tool_calls_json: null,
|
||||
tool_call_id: null,
|
||||
created_at: 1,
|
||||
parent_message_id: null,
|
||||
version_index: 0,
|
||||
is_active: true,
|
||||
status: 'complete',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ChatView assistant display policy', () => {
|
||||
it('does not mount AssistantFooter while an assistant message is streaming', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
const footerBranch = source.match(/footer:\s*msg && activeConversationId \? \([\s\S]*?\) : footerLoading \?/);
|
||||
|
||||
expect(footerBranch?.[0]).toContain('{!isStreaming && (');
|
||||
expect(footerBranch?.[0]).not.toContain('isStreaming={isStreaming}');
|
||||
});
|
||||
|
||||
it('temporarily closes live streaming think content before rendering', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
|
||||
expect(source).toMatch(/renderContentNode\(\s*closeStreamingThinkBlock\(\s*liveContent,/);
|
||||
});
|
||||
|
||||
it('uses a finite live markdown window instead of placeholder batching', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
const propsBlock = source.match(/const CHAT_RENDER_BATCH_PROPS = \{[\s\S]*?\} as const;/)?.[0] ?? '';
|
||||
|
||||
expect(propsBlock).toContain('deferNodesUntilVisible: false');
|
||||
expect(propsBlock).toMatch(/maxLiveNodes:\s*[1-9]\d*,/);
|
||||
expect(propsBlock).not.toMatch(/maxLiveNodes:\s*(?:0|Infinity),/);
|
||||
});
|
||||
|
||||
it('routes plain text and markdown messages through chat typography classes', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('className="boosapi-chat-text"');
|
||||
expect(source).toContain('className="boosapi-chat-markdown"');
|
||||
});
|
||||
|
||||
it('defines chat typography CSS without overriding code font settings', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
|
||||
const chatBlock = source.match(/\.boosapi-chat-text,[\s\S]*?\.boosapi-chat-markdown \.markdown-renderer \{[\s\S]*?\}/)?.[0] ?? '';
|
||||
const codeBlock = source.match(/\.boosapi-chat-markdown (?:pre|code)[\s\S]*?\}/)?.[0] ?? '';
|
||||
|
||||
expect(chatBlock).toContain('font-family: var(--chat-font-family');
|
||||
expect(chatBlock).toContain('font-size: var(--chat-font-size');
|
||||
expect(chatBlock).toContain('line-height: var(--chat-line-height');
|
||||
expect(chatBlock).toContain('font-weight: var(--chat-font-weight');
|
||||
expect(codeBlock).toContain('font-family: var(--code-font-family');
|
||||
expect(codeBlock).not.toContain('--chat-font-family');
|
||||
});
|
||||
|
||||
it('resolves stable ai bubble keys through their parent message id', () => {
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: 'final answer',
|
||||
});
|
||||
const assistantByParentId = new Map<string, Message>([['user-1', assistant]]);
|
||||
const messageById = new Map<string, Message>([
|
||||
['user-1', makeMessage({ id: 'user-1', role: 'user', content: 'question' })],
|
||||
['assistant-1', assistant],
|
||||
]);
|
||||
|
||||
expect(resolveAssistantMessageForBubbleKey(
|
||||
'ai:user-1',
|
||||
assistantByParentId,
|
||||
messageById,
|
||||
)).toBe(assistant);
|
||||
});
|
||||
|
||||
it('injects a web-search display card for normal assistant replies with searched parent messages', () => {
|
||||
const user = makeMessage({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '<!-- search:{"sources":[{"title":"A","url":"https://example.com"}],"query":"BoosApi 下载"} -->\nsearch\n---\n\n用户问题',
|
||||
});
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: 'final answer',
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [user, assistant]);
|
||||
|
||||
expect(content).toContain('<web-search status="done" data-boosapi="1"');
|
||||
expect(content).toContain('query="BoosApi 下载"');
|
||||
expect(content).toContain('final answer');
|
||||
});
|
||||
|
||||
it('still injects web-search display when streaming thinking content is present', () => {
|
||||
const user = makeMessage({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '<!-- search:{"sources":[{"title":"A","url":"https://example.com"}],"query":"BoosApi 下载"} -->\nsearch\n---\n\n用户问题',
|
||||
});
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: '<think data-boosapi="1">\n正在分析搜索结果',
|
||||
status: 'partial',
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [user, assistant]);
|
||||
|
||||
expect(content).toContain('<web-search status="done" data-boosapi="1"');
|
||||
expect(content).toContain('<think data-boosapi="1">');
|
||||
});
|
||||
|
||||
it('does not inject a web-search display card into assistant error text', () => {
|
||||
const user = makeMessage({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '<!-- search:{"sources":[{"title":"A","url":"https://example.com"}]} -->\nsearch\n---\n\n用户问题',
|
||||
});
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: '模型首包超时',
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [user, assistant]);
|
||||
|
||||
expect(content).toBe('模型首包超时');
|
||||
expect(content).not.toContain('<web-search');
|
||||
});
|
||||
|
||||
it('splits display-only prefixes away from assistant error messages', () => {
|
||||
const display = splitAssistantErrorDisplayContent(
|
||||
'<web-search status="done" data-boosapi="1">[{"title":"A"}]</web-search>\n\n模型首包超时',
|
||||
);
|
||||
|
||||
expect(display.prefix).toContain('<web-search status="done" data-boosapi="1">');
|
||||
expect(display.message).toBe('模型首包超时');
|
||||
expect(display.message).not.toContain('[{"title":"A"}]');
|
||||
});
|
||||
|
||||
it('splits partial streamed content away from stream error messages', () => {
|
||||
const display = splitAssistantErrorDisplayContent(
|
||||
'已生成的前半段\n\n<!-- boosapi-stream-error -->\n模型响应空闲超时',
|
||||
);
|
||||
|
||||
expect(display.prefix).toBe('已生成的前半段');
|
||||
expect(display.message).toBe('模型响应空闲超时');
|
||||
});
|
||||
|
||||
it('truncates persisted MCP blocks before markdown rendering', () => {
|
||||
const longOutput = 'x'.repeat(25_000);
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
content: `before\n\n:::mcp {"name":"server","tool":"fetch_url"}\n${longOutput}\n:::\n\nafter`,
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [assistant]);
|
||||
|
||||
expect(content).toContain('before');
|
||||
expect(content).toContain('after');
|
||||
expect(content).toContain('MCP output truncated for rendering');
|
||||
expect(content.length).toBeLessThan(longOutput.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationModelIcon } from '../ConversationModelIcon';
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
modelMappings: [{ keywords: ['deepseek'] }],
|
||||
ModelIcon: ({ model, size }: { model: string; size: number }) => (
|
||||
<span data-testid="model-icon" data-model={model} data-size={size} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Brain: ({ size, strokeWidth }: { size: number; strokeWidth: number }) => (
|
||||
<span data-testid="brain-icon" data-size={size} data-stroke-width={strokeWidth} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ConversationModelIcon', () => {
|
||||
it('wraps model avatars in a fixed centered box', () => {
|
||||
render(<ConversationModelIcon model="deepseek-chat" size={20} />);
|
||||
|
||||
const wrapper = screen.getByTestId('model-icon').parentElement;
|
||||
expect(wrapper).toHaveClass('boosapi-conversation-model-icon');
|
||||
expect(wrapper).toHaveStyle({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a centered local fallback for unknown model avatars', () => {
|
||||
render(<ConversationModelIcon model="unknown-model" size={20} />);
|
||||
|
||||
expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument();
|
||||
|
||||
const avatar = screen.getByTestId('brain-icon').closest('.boosapi-conversation-model-icon');
|
||||
expect(avatar).toHaveClass('boosapi-conversation-model-icon');
|
||||
expect(avatar).toHaveStyle({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HtmlRenderNode } from '../HtmlRenderNode';
|
||||
|
||||
describe('HtmlRenderNode', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders sanitized html directly', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
raw: '<html-render><div style="color:red" onclick="bad()">safe</div><script>alert(1)</script></html-render>',
|
||||
content: '<div style="color:red" onclick="bad()">safe</div><script>alert(1)</script>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content.innerHTML).toContain('style="color:red"');
|
||||
expect(content.innerHTML).toContain('safe');
|
||||
expect(content.innerHTML).not.toContain('onclick');
|
||||
expect(content.innerHTML).not.toContain('<script');
|
||||
});
|
||||
|
||||
it('removes unsafe urls from rendered links', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
raw: '<html-render><a href="javascript:alert(1)">bad</a></html-render>',
|
||||
content: '<a href="javascript:alert(1)">bad</a>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByText('bad');
|
||||
expect(link).not.toHaveAttribute('href');
|
||||
});
|
||||
|
||||
it('does not render display chrome around the html fragment', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
raw: '<html-render><section><h1>Title</h1></section></html-render>',
|
||||
content: '<section><h1>Title</h1></section>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content).toHaveTextContent('Title');
|
||||
expect(screen.queryByText('HTML Render')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '预览' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '源码' })).toBeNull();
|
||||
});
|
||||
|
||||
it('coalesces streaming html updates until the next animation frame', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
|
||||
const { rerender } = render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>first</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('first');
|
||||
|
||||
rerender(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>second</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>third</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('first');
|
||||
expect(frameCallbacks).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
frameCallbacks[0]?.(16);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('third');
|
||||
});
|
||||
|
||||
it('renders final html updates immediately without waiting for animation frame', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
|
||||
const { rerender } = render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>draft</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: false,
|
||||
content: '<div>final</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('final');
|
||||
});
|
||||
|
||||
it('adapts common light inline colors for dark mode', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
{...({
|
||||
isDark: true,
|
||||
node: {
|
||||
type: 'html-render',
|
||||
content: '<div style="color:#111;background:#fff;border:1px solid #eee">dark friendly</div>',
|
||||
},
|
||||
} as any)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content).toHaveStyle({ colorScheme: 'dark' });
|
||||
expect(content.innerHTML).toContain('var(--boosapi-html-fg)');
|
||||
expect(content.innerHTML).toContain('rgba(255,255,255,0.06)');
|
||||
expect(content.innerHTML).toContain('rgba(255,255,255,0.18)');
|
||||
});
|
||||
|
||||
it('removes style tags from html render fragments', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
content: '<style>.x{color:red}</style><div>safe</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content).toHaveTextContent('safe');
|
||||
expect(content.innerHTML).not.toContain('<style');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { App } from 'antd';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AppSettings } from '@/types';
|
||||
import { InputArea } from '../InputArea';
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
const createConversation = vi.fn();
|
||||
const setSearchEnabled = vi.fn();
|
||||
const setSearchProviderId = vi.fn();
|
||||
const loadSearchProviders = vi.fn();
|
||||
const loadMcpServers = vi.fn();
|
||||
const toggleMcpServer = vi.fn();
|
||||
const loadKnowledgeBases = vi.fn();
|
||||
const toggleKnowledgeBase = vi.fn();
|
||||
const loadMemoryNamespaces = vi.fn();
|
||||
const toggleMemoryNamespace = vi.fn();
|
||||
const setThinkingBudget = vi.fn();
|
||||
const setThinkingLevel = vi.fn();
|
||||
const insertContextClear = vi.fn();
|
||||
const getContextUsage = vi.fn();
|
||||
const setActivePage = vi.fn();
|
||||
const setSettingsSection = vi.fn();
|
||||
|
||||
const conversationState = {
|
||||
streaming: false,
|
||||
compressingConversationId: null as string | null,
|
||||
activeConversationId: 'conv-1',
|
||||
sendMessage,
|
||||
createConversation,
|
||||
messages: [],
|
||||
conversations: [
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: 'Test',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
},
|
||||
],
|
||||
searchEnabled: true,
|
||||
searchProviderId: 'search-1',
|
||||
setSearchEnabled,
|
||||
setSearchProviderId,
|
||||
enabledMcpServerIds: [] as string[],
|
||||
toggleMcpServer,
|
||||
enabledKnowledgeBaseIds: [] as string[],
|
||||
toggleKnowledgeBase,
|
||||
enabledMemoryNamespaceIds: [] as string[],
|
||||
toggleMemoryNamespace,
|
||||
thinkingBudget: null as number | null,
|
||||
thinkingLevel: null as string | null,
|
||||
setThinkingBudget,
|
||||
setThinkingLevel,
|
||||
insertContextClear,
|
||||
getContextUsage,
|
||||
};
|
||||
|
||||
const providerState = {
|
||||
providers: [
|
||||
{
|
||||
id: 'provider-1',
|
||||
provider_type: 'gemini',
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
name: 'model-1',
|
||||
model_type: 'Chat',
|
||||
enabled: true,
|
||||
capabilities: [] as string[],
|
||||
max_tokens: 128000,
|
||||
param_overrides: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const settingsState: { settings: Partial<AppSettings> } = {
|
||||
settings: {
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
document_attachment_reading_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const searchState = {
|
||||
providers: [
|
||||
{
|
||||
id: 'search-1',
|
||||
name: 'Test Search',
|
||||
providerType: 'tavily',
|
||||
},
|
||||
],
|
||||
loadProviders: loadSearchProviders,
|
||||
};
|
||||
|
||||
const mcpState = {
|
||||
servers: [],
|
||||
loadServers: loadMcpServers,
|
||||
};
|
||||
|
||||
const knowledgeState = {
|
||||
bases: [],
|
||||
loadBases: loadKnowledgeBases,
|
||||
};
|
||||
|
||||
const memoryState = {
|
||||
namespaces: [],
|
||||
loadNamespaces: loadMemoryNamespaces,
|
||||
};
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useConversationStore: Object.assign(
|
||||
(selector: (state: typeof conversationState) => unknown) => selector(conversationState),
|
||||
{ getState: () => conversationState },
|
||||
),
|
||||
useProviderStore: Object.assign(
|
||||
(selector: (state: typeof providerState) => unknown) => selector(providerState),
|
||||
{ getState: () => providerState },
|
||||
),
|
||||
useSettingsStore: (selector: (state: typeof settingsState) => unknown) => selector(settingsState),
|
||||
useSearchStore: (selector: (state: typeof searchState) => unknown) => selector(searchState),
|
||||
useMcpStore: (selector: (state: typeof mcpState) => unknown) => selector(mcpState),
|
||||
useKnowledgeStore: (selector: (state: typeof knowledgeState) => unknown) => selector(knowledgeState),
|
||||
useMemoryStore: (selector: (state: typeof memoryState) => unknown) => selector(memoryState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/uiStore', () => ({
|
||||
useUIStore: (selector: (state: { setActivePage: typeof setActivePage; setSettingsSection: typeof setSettingsSection }) => unknown) =>
|
||||
selector({ setActivePage, setSettingsSection }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/modelCapabilities', () => ({
|
||||
findModelByIds: (providers: typeof providerState.providers, providerId: string, modelId: string) =>
|
||||
providers.find((provider) => provider.id === providerId)?.models.find((model) => model.model_id === modelId) ?? null,
|
||||
supportsReasoning: (model: { capabilities?: string[] } | null | undefined) => model?.capabilities?.includes('Reasoning') ?? false,
|
||||
modelHasCapability: (model: { capabilities?: string[] } | null | undefined, capability: string) =>
|
||||
model?.capabilities?.includes(capability) ?? false,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/SearchProviderIcon', () => ({
|
||||
SearchProviderTypeIcon: () => null,
|
||||
PROVIDER_TYPE_LABELS: {
|
||||
tavily: 'Tavily',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../VoiceCall', () => ({
|
||||
VoiceCall: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../ConversationSettingsModal', () => ({
|
||||
ConversationSettingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSelector', () => ({
|
||||
ModelSelector: () => null,
|
||||
}));
|
||||
|
||||
describe('InputArea', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
providerState.providers[0].provider_type = 'gemini';
|
||||
providerState.providers[0].models[0].model_id = 'model-1';
|
||||
providerState.providers[0].models[0].name = 'model-1';
|
||||
providerState.providers[0].models[0].capabilities = [];
|
||||
providerState.providers[0].models[0].param_overrides = null;
|
||||
conversationState.conversations[0].model_id = 'model-1';
|
||||
conversationState.thinkingBudget = null;
|
||||
conversationState.thinkingLevel = null;
|
||||
conversationState.compressingConversationId = null;
|
||||
getContextUsage.mockResolvedValue(null);
|
||||
settingsState.settings.document_attachment_reading_enabled = false;
|
||||
});
|
||||
|
||||
const waitForNextFrame = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
|
||||
it('focuses the chat textarea when the window regains focus without another active input', async () => {
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('chat.inputPlaceholder') as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await waitForNextFrame();
|
||||
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
});
|
||||
|
||||
it('does not steal focus from another focused input when the window regains focus', async () => {
|
||||
render(
|
||||
<>
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>
|
||||
<input aria-label="external-input" />
|
||||
</>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('chat.inputPlaceholder') as HTMLTextAreaElement;
|
||||
const externalInput = screen.getByLabelText('external-input') as HTMLInputElement;
|
||||
externalInput.focus();
|
||||
expect(document.activeElement).toBe(externalInput);
|
||||
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await waitForNextFrame();
|
||||
|
||||
expect(document.activeElement).toBe(externalInput);
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
});
|
||||
|
||||
it('clears the textarea immediately after sending even while search-backed send is still pending', async () => {
|
||||
let resolveSend!: () => void;
|
||||
sendMessage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('chat.inputPlaceholder') as HTMLTextAreaElement;
|
||||
await userEvent.type(textarea, 'search me');
|
||||
|
||||
expect(textarea.value).toBe('search me');
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith('search me', undefined, 'search-1');
|
||||
expect(textarea.value).toBe('');
|
||||
|
||||
resolveSend();
|
||||
});
|
||||
|
||||
it('renders model-specific reasoning options for Gemini 3.1 models', async () => {
|
||||
providerState.providers[0].provider_type = 'gemini';
|
||||
providerState.providers[0].models[0].model_id = 'gemini-3.1-flash';
|
||||
providerState.providers[0].models[0].name = 'Gemini 3.1 Flash';
|
||||
providerState.providers[0].models[0].capabilities = ['Reasoning'];
|
||||
conversationState.conversations[0].model_id = 'gemini-3.1-flash';
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('chat.thinkingIntensity'));
|
||||
|
||||
expect(await screen.findByText('Minimal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Low')).toBeInTheDocument();
|
||||
expect(screen.getByText('Medium')).toBeInTheDocument();
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
expect(screen.queryByText('XHigh')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders DeepSeek V4 reasoning options from the provider profile', async () => {
|
||||
providerState.providers[0].provider_type = 'deepseek';
|
||||
providerState.providers[0].models[0].model_id = 'deepseek-v4-flash';
|
||||
providerState.providers[0].models[0].name = 'DeepSeek v4 Flash';
|
||||
providerState.providers[0].models[0].capabilities = ['Reasoning'];
|
||||
conversationState.conversations[0].model_id = 'deepseek-v4-flash';
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('chat.thinkingIntensity'));
|
||||
|
||||
expect(await screen.findByText('High')).toBeInTheDocument();
|
||||
expect(screen.getByText('Max')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Low')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Medium')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('XHigh')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows backend context usage instead of a loaded-message estimate', async () => {
|
||||
getContextUsage.mockResolvedValueOnce({
|
||||
used_tokens: 720000,
|
||||
max_tokens: 1000000,
|
||||
threshold_tokens: 700000,
|
||||
has_summary: true,
|
||||
compressed_until_message_id: 'msg-1',
|
||||
messages_after_boundary: 3,
|
||||
});
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getContextUsage).toHaveBeenCalledWith('conv-1'));
|
||||
await userEvent.hover(screen.getByLabelText('上下文 tokens'));
|
||||
|
||||
expect(await screen.findByText('720,000 / 1,000,000 tokens (72%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows document attachment controls for non-vision models when document reading is enabled', () => {
|
||||
settingsState.settings.document_attachment_reading_enabled = true;
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('chat.attachFile')).toBeInTheDocument();
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input?.accept).toContain('.pdf');
|
||||
expect(input?.accept).toContain('.doc');
|
||||
expect(input?.accept).toContain('.docx');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import type React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Conversation, ProviderConfig } from '@/types';
|
||||
import { ModelSelector } from '../ModelSelector';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
updateConversation: vi.fn(),
|
||||
saveSettings: vi.fn(),
|
||||
setActivePage: vi.fn(),
|
||||
setSettingsSection: vi.fn(),
|
||||
setSelectedProviderId: vi.fn(),
|
||||
}));
|
||||
|
||||
let providers: ProviderConfig[] = [];
|
||||
let conversations: Conversation[] = [];
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: 'provider-1',
|
||||
name: 'OpenAI Compatible',
|
||||
provider_type: 'openai',
|
||||
api_host: 'https://api.example.com',
|
||||
api_path: '/v1/chat/completions',
|
||||
enabled: true,
|
||||
models: [],
|
||||
keys: [],
|
||||
proxy_config: null,
|
||||
custom_headers: null,
|
||||
icon: null,
|
||||
builtin_id: null,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeConversation(overrides: Partial<Conversation> = {}): Conversation {
|
||||
return {
|
||||
id: 'conv-1',
|
||||
title: 'Test',
|
||||
model_id: 'gpt-5.4',
|
||||
provider_id: 'provider-1',
|
||||
system_prompt: null,
|
||||
temperature: null,
|
||||
max_tokens: null,
|
||||
top_p: null,
|
||||
frequency_penalty: null,
|
||||
search_enabled: false,
|
||||
search_provider_id: null,
|
||||
thinking_budget: null,
|
||||
thinking_level: null,
|
||||
enabled_mcp_server_ids: [],
|
||||
enabled_knowledge_base_ids: [],
|
||||
enabled_memory_namespace_ids: [],
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
context_compression: false,
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
mode: 'chat',
|
||||
message_count: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => <span data-testid="model-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('../ConversationModelIcon', () => ({
|
||||
ConversationModelIcon: ({ model, size }: { model: string; size: number }) => (
|
||||
<span data-testid="conversation-model-icon" data-model={model} data-size={size} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/providerIcons', () => ({
|
||||
SmartProviderIcon: () => <span data-testid="provider-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/shortcuts', () => ({
|
||||
getShortcutBinding: () => null,
|
||||
formatShortcutForDisplay: () => '',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/modelCapabilities', () => ({
|
||||
getVisibleModelCapabilities: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({ count, getItemKey }: { count: number; getItemKey: (index: number) => React.Key }) => ({
|
||||
getTotalSize: () => count * 40,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
key: getItemKey(index),
|
||||
start: index * 40,
|
||||
})),
|
||||
measureElement: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Tag: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
|
||||
Modal: ({ open, children, footer, title }: { open?: boolean; children?: React.ReactNode; footer?: React.ReactNode; title?: React.ReactNode }) => (
|
||||
open ? <div>{title}{children}{footer}</div> : null
|
||||
),
|
||||
Input: ({ prefix, placeholder, value, onChange, onKeyDown }: {
|
||||
prefix?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||
}) => (
|
||||
<label>
|
||||
{prefix}
|
||||
<input placeholder={placeholder} value={value} onChange={onChange} onKeyDown={onKeyDown} />
|
||||
</label>
|
||||
),
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: React.MouseEventHandler<HTMLButtonElement> }) => (
|
||||
<button onClick={onClick}>{children}</button>
|
||||
),
|
||||
Checkbox: ({ checked }: { checked?: boolean }) => <input type="checkbox" readOnly checked={checked} />,
|
||||
theme: {
|
||||
useToken: () => ({
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
colorPrimaryBg: '#e6f4ff',
|
||||
colorFillSecondary: '#f5f5f5',
|
||||
colorFillTertiary: '#fafafa',
|
||||
colorTextSecondary: '#666',
|
||||
colorTextQuaternary: '#999',
|
||||
colorBorderSecondary: '#eee',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useProviderStore: (selector?: (state: { providers: ProviderConfig[] }) => unknown) => {
|
||||
const state = { providers };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
useConversationStore: (selector?: (state: { activeConversationId: string; conversations: Conversation[]; updateConversation: typeof mocks.updateConversation }) => unknown) => {
|
||||
const state = {
|
||||
activeConversationId: 'conv-1',
|
||||
conversations,
|
||||
updateConversation: mocks.updateConversation,
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
useSettingsStore: (selector: (state: { settings: Record<string, unknown>; saveSettings: typeof mocks.saveSettings }) => unknown) =>
|
||||
selector({
|
||||
settings,
|
||||
saveSettings: mocks.saveSettings,
|
||||
}),
|
||||
useUIStore: (selector: (state: {
|
||||
setActivePage: typeof mocks.setActivePage;
|
||||
setSettingsSection: typeof mocks.setSettingsSection;
|
||||
setSelectedProviderId: typeof mocks.setSelectedProviderId;
|
||||
}) => unknown) =>
|
||||
selector({
|
||||
setActivePage: mocks.setActivePage,
|
||||
setSettingsSection: mocks.setSettingsSection,
|
||||
setSelectedProviderId: mocks.setSelectedProviderId,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ModelSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
providers = [
|
||||
makeProvider({
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-5.4',
|
||||
name: 'GPT 5.4',
|
||||
group_name: null,
|
||||
model_type: 'Chat',
|
||||
capabilities: ['TextChat'],
|
||||
max_tokens: 128000,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-image-2',
|
||||
name: 'GPT Image 2',
|
||||
group_name: null,
|
||||
model_type: 'Image',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'text-embedding-3-large',
|
||||
name: 'Text Embedding 3 Large',
|
||||
group_name: null,
|
||||
model_type: 'Embedding',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'jina-reranker-v3',
|
||||
name: 'Jina Reranker v3',
|
||||
group_name: null,
|
||||
model_type: 'Rerank',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
conversations = [makeConversation()];
|
||||
settings = {
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
show_image_models_in_model_selector: false,
|
||||
};
|
||||
});
|
||||
|
||||
it('shows only enabled chat models in the conversation model selector', () => {
|
||||
render(<ModelSelector open onOpenChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText('GPT 5.4').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('GPT Image 2')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Jina Reranker v3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show pinned non-chat models', () => {
|
||||
localStorage.setItem('boosapi_pinned_models', JSON.stringify(['provider-1::gpt-image-2']));
|
||||
|
||||
render(
|
||||
<ModelSelector
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
excludeModelKeys={['provider-1::gpt-5.4']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('chat.pinnedModels')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('GPT Image 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows enabled image models when the setting is enabled', () => {
|
||||
settings = {
|
||||
...settings,
|
||||
show_image_models_in_model_selector: true,
|
||||
};
|
||||
|
||||
render(<ModelSelector open onOpenChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText('GPT 5.4').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('GPT Image 2').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Jina Reranker v3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pinned image models when the setting is enabled', () => {
|
||||
settings = {
|
||||
...settings,
|
||||
show_image_models_in_model_selector: true,
|
||||
};
|
||||
localStorage.setItem('boosapi_pinned_models', JSON.stringify(['provider-1::gpt-image-2']));
|
||||
|
||||
render(
|
||||
<ModelSelector
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
excludeModelKeys={['provider-1::gpt-5.4']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('chat.pinnedModels')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('GPT Image 2').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses the centered conversation model fallback in the current model trigger', () => {
|
||||
conversations = [makeConversation({ model_id: 'unknown-model', provider_id: 'missing-provider' })];
|
||||
|
||||
render(<ModelSelector />);
|
||||
|
||||
const icon = screen.getByTestId('conversation-model-icon');
|
||||
expect(icon).toHaveAttribute('data-model', 'unknown-model');
|
||||
expect(icon).toHaveAttribute('data-size', '16');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { App } from 'antd';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type React from 'react';
|
||||
import type { Message } from '@/types';
|
||||
import { clearLiveStreamContent, setLiveStreamContent, useConversationStore } from '@/stores';
|
||||
import { MultiModelDisplay } from '../MultiModelDisplay';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: ({ model }: { model: string }) => <span data-testid="model-icon">{model}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('overlayscrollbars', () => ({
|
||||
OverlayScrollbars: vi.fn(() => ({ destroy: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSelector', () => ({
|
||||
ModelSelector: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
function makeMessage(overrides: Partial<Message> & Pick<Message, 'id' | 'model_id' | 'content'>): Message {
|
||||
return {
|
||||
id: overrides.id,
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: overrides.content,
|
||||
provider_id: overrides.provider_id ?? 'provider-1',
|
||||
model_id: overrides.model_id,
|
||||
token_count: null,
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
attachments: [],
|
||||
thinking: null,
|
||||
tool_calls_json: null,
|
||||
tool_call_id: null,
|
||||
created_at: overrides.created_at ?? 1,
|
||||
parent_message_id: overrides.parent_message_id ?? 'user-1',
|
||||
version_index: overrides.version_index ?? 0,
|
||||
is_active: overrides.is_active ?? true,
|
||||
status: overrides.status ?? 'complete',
|
||||
tokens_per_second: null,
|
||||
first_token_latency_ms: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDisplay(
|
||||
versions: Message[],
|
||||
activeMessageId = versions[0]?.id ?? '',
|
||||
mode: 'side-by-side' | 'stacked' = 'side-by-side',
|
||||
props: Partial<React.ComponentProps<typeof MultiModelDisplay>> = {},
|
||||
) {
|
||||
return (
|
||||
<App>
|
||||
<MultiModelDisplay
|
||||
versions={versions}
|
||||
activeMessageId={activeMessageId}
|
||||
mode={mode}
|
||||
conversationId="conv-1"
|
||||
onSwitchVersion={vi.fn()}
|
||||
onDeleteVersion={vi.fn()}
|
||||
streamingMessageId={null}
|
||||
multiModelDoneMessageIds={[]}
|
||||
getModelDisplayInfo={(modelId) => ({ modelName: modelId ?? '', providerName: '' })}
|
||||
renderContent={(message) => <div>{message.content}</div>}
|
||||
{...props}
|
||||
/>
|
||||
</App>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDisplayWithStreamingLabel(versions: Message[], streamingMessageId: string | null) {
|
||||
return (
|
||||
<App>
|
||||
<MultiModelDisplay
|
||||
versions={versions}
|
||||
activeMessageId={versions[0]?.id ?? ''}
|
||||
mode="side-by-side"
|
||||
conversationId="conv-1"
|
||||
onSwitchVersion={vi.fn()}
|
||||
onDeleteVersion={vi.fn()}
|
||||
streamingMessageId={streamingMessageId}
|
||||
multiModelDoneMessageIds={[]}
|
||||
getModelDisplayInfo={(modelId) => ({ modelName: modelId ?? '', providerName: '' })}
|
||||
renderContent={(message, isStreaming) => (
|
||||
<div data-testid={`content-${message.id}`}>
|
||||
{isStreaming ? 'streaming' : 'stable'}:{message.content}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</App>
|
||||
);
|
||||
}
|
||||
|
||||
describe('MultiModelDisplay', () => {
|
||||
beforeEach(() => {
|
||||
useConversationStore.setState({
|
||||
messages: [],
|
||||
activeConversationId: 'conv-1',
|
||||
streaming: false,
|
||||
streamingConversationId: null,
|
||||
streamingMessageId: null,
|
||||
});
|
||||
clearLiveStreamContent('assistant-a');
|
||||
clearLiveStreamContent('assistant-b');
|
||||
});
|
||||
|
||||
it('does not fall back to the error boundary when deleting down to one model', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: 'beta', is_active: false, version_index: 1 });
|
||||
|
||||
const { rerender } = render(renderDisplay([modelA, modelB]));
|
||||
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument();
|
||||
expect(screen.getByText('beta')).toBeInTheDocument();
|
||||
|
||||
rerender(renderDisplay([modelA]));
|
||||
|
||||
expect(screen.queryByText('Multi-model display error')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates an inactive streaming card from the store without rerendering the parent bubble item', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: '', is_active: false, status: 'partial', version_index: 1 });
|
||||
useConversationStore.setState({ messages: [modelA, modelB] });
|
||||
|
||||
render(renderDisplay([modelA, modelB]));
|
||||
|
||||
expect(screen.queryByText('streamed token')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useConversationStore.setState({
|
||||
messages: [modelA, { ...modelB, content: 'streamed token' }],
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText('streamed token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates an inactive streaming card from live stream content without replacing store messages', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: '', is_active: false, status: 'partial', version_index: 1 });
|
||||
useConversationStore.setState({
|
||||
messages: [modelA, modelB],
|
||||
streaming: true,
|
||||
streamingConversationId: 'conv-1',
|
||||
streamingMessageId: null,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelA, modelB]));
|
||||
|
||||
act(() => {
|
||||
setLiveStreamContent('assistant-b', 'streamed token');
|
||||
});
|
||||
|
||||
expect(screen.getByText('streamed token')).toBeInTheDocument();
|
||||
expect(useConversationStore.getState().messages.find((message) => message.id === 'assistant-b')?.content).toBe('');
|
||||
});
|
||||
|
||||
it('shows the active same-model version in side-by-side mode', () => {
|
||||
const modelAOld = makeMessage({
|
||||
id: 'assistant-a-old',
|
||||
model_id: 'model-a',
|
||||
content: 'old same-model answer',
|
||||
is_active: true,
|
||||
version_index: 0,
|
||||
created_at: 1,
|
||||
});
|
||||
const modelALatest = makeMessage({
|
||||
id: 'assistant-a-latest',
|
||||
model_id: 'model-a',
|
||||
content: 'latest same-model answer',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
created_at: 2,
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'other model answer',
|
||||
is_active: false,
|
||||
version_index: 0,
|
||||
created_at: 3,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelAOld, modelALatest, modelB], modelAOld.id, 'side-by-side'));
|
||||
|
||||
expect(screen.getByText('old same-model answer')).toBeInTheDocument();
|
||||
expect(screen.queryByText('latest same-model answer')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('other model answer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the active same-model version in stacked mode', () => {
|
||||
const modelAOld = makeMessage({
|
||||
id: 'assistant-a-old',
|
||||
model_id: 'model-a',
|
||||
content: 'old stacked answer',
|
||||
is_active: true,
|
||||
version_index: 0,
|
||||
created_at: 1,
|
||||
});
|
||||
const modelALatest = makeMessage({
|
||||
id: 'assistant-a-latest',
|
||||
model_id: 'model-a',
|
||||
content: 'latest stacked answer',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
created_at: 2,
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'stacked other model answer',
|
||||
is_active: false,
|
||||
version_index: 0,
|
||||
created_at: 3,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelAOld, modelALatest, modelB], modelAOld.id, 'stacked'));
|
||||
|
||||
expect(screen.getByText('old stacked answer')).toBeInTheDocument();
|
||||
expect(screen.queryByText('latest stacked answer')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('stacked other model answer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('treats partial cards as streaming while their conversation is streaming even without a matching streamingMessageId', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: '```ts\nconst token = 1;',
|
||||
is_active: false,
|
||||
status: 'partial',
|
||||
version_index: 1,
|
||||
});
|
||||
useConversationStore.setState({
|
||||
messages: [modelA, modelB],
|
||||
streaming: true,
|
||||
streamingConversationId: 'conv-1',
|
||||
streamingMessageId: null,
|
||||
});
|
||||
|
||||
render(renderDisplayWithStreamingLabel([modelA, modelB], null));
|
||||
|
||||
expect(screen.getByTestId('content-assistant-b')).toHaveTextContent('streaming:```ts');
|
||||
});
|
||||
|
||||
it('routes per-card actions to the displayed message without switching context', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: 'beta', is_active: false, version_index: 1 });
|
||||
const onRegenerateVersion = vi.fn();
|
||||
const onSetContextVersion = vi.fn();
|
||||
const onSwitchVersion = vi.fn();
|
||||
|
||||
render(renderDisplay([modelA, modelB], modelA.id, 'side-by-side', {
|
||||
onRegenerateVersion,
|
||||
onSetContextVersion,
|
||||
onSwitchVersion,
|
||||
}));
|
||||
|
||||
fireEvent.click(screen.getByTestId('multi-model-regenerate-assistant-b'));
|
||||
expect(onRegenerateVersion).toHaveBeenCalledWith(modelB);
|
||||
expect(onSwitchVersion).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('multi-model-set-context-assistant-b'));
|
||||
expect(onSetContextVersion).toHaveBeenCalledWith(modelB);
|
||||
});
|
||||
|
||||
it('keeps context selection in the card header while operations stay in the footer', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: 'beta', is_active: false, version_index: 1 });
|
||||
|
||||
render(renderDisplay([modelA, modelB], modelA.id, 'side-by-side', {
|
||||
onRegenerateVersion: vi.fn(),
|
||||
onSetContextVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
expect(screen.getByTestId('multi-model-set-context-assistant-b').closest('.multi-model-card-header-actions')).not.toBeNull();
|
||||
expect(screen.getByTestId('multi-model-set-context-assistant-b').closest('.multi-model-card-footer-actions')).toBeNull();
|
||||
expect(screen.getByTestId('multi-model-regenerate-assistant-b').closest('.multi-model-card-footer-actions')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stretches card content so footer actions stay pinned to the bottom', () => {
|
||||
const modelA = makeMessage({
|
||||
id: 'assistant-a',
|
||||
model_id: 'model-a',
|
||||
content: 'alpha\n\nalpha\n\nalpha\n\nalpha',
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'beta',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelA, modelB], modelA.id, 'side-by-side', {
|
||||
onRegenerateVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
const shortCard = screen.getByTestId('multi-model-card-assistant-b');
|
||||
const shortContent = screen.getByTestId('multi-model-card-content-assistant-b');
|
||||
|
||||
expect(shortCard).toHaveStyle({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
expect(shortContent.getAttribute('style')).toContain('flex: 1');
|
||||
expect(shortContent).toHaveStyle({ minHeight: '0' });
|
||||
expect(screen.getByTestId('multi-model-regenerate-assistant-b').closest('.multi-model-card-footer-actions')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('switches the displayed same-model version locally without setting context', () => {
|
||||
const modelAOld = makeMessage({
|
||||
id: 'assistant-a-old',
|
||||
model_id: 'model-a',
|
||||
content: 'old same-model answer',
|
||||
is_active: true,
|
||||
version_index: 0,
|
||||
created_at: 1,
|
||||
});
|
||||
const modelALatest = makeMessage({
|
||||
id: 'assistant-a-latest',
|
||||
model_id: 'model-a',
|
||||
content: 'latest same-model answer',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
created_at: 2,
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'other model answer',
|
||||
is_active: false,
|
||||
version_index: 0,
|
||||
created_at: 3,
|
||||
});
|
||||
const onDisplayVersionChange = vi.fn();
|
||||
const onSwitchVersion = vi.fn();
|
||||
|
||||
render(renderDisplay([modelAOld, modelALatest, modelB], modelAOld.id, 'side-by-side', {
|
||||
onDisplayVersionChange,
|
||||
onSwitchVersion,
|
||||
}));
|
||||
|
||||
fireEvent.click(screen.getByTestId('multi-model-version-next-assistant-a-old'));
|
||||
|
||||
expect(onDisplayVersionChange).toHaveBeenCalledWith(
|
||||
modelAOld.parent_message_id,
|
||||
'provider-1:model-a',
|
||||
modelALatest.id,
|
||||
);
|
||||
expect(onSwitchVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WebSearchNode, WebSearchQueryNode } from '../WebSearchNode';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => ({
|
||||
'chat.search.searching': '正在联网搜索...',
|
||||
'chat.search.summarizingQuery': '正在总结搜索语句...',
|
||||
'chat.search.querySummary': '搜索语句总结完成',
|
||||
'chat.search.querySummaryFailed': '搜索语句总结失败',
|
||||
'chat.search.resultsCount': '1个搜索结果',
|
||||
'chat.search.noResults': '未找到相关搜索结果',
|
||||
'chat.search.query': '搜索语句',
|
||||
'chat.search.error': '搜索失败',
|
||||
})[key] ?? fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('WebSearchNode', () => {
|
||||
it('shows the searching state when attrs come from tuple pairs', () => {
|
||||
render(
|
||||
<WebSearchNode
|
||||
node={{
|
||||
type: 'web-search',
|
||||
attrs: [['status', 'searching']],
|
||||
content: '',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('正在联网搜索...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the query summarization state', () => {
|
||||
render(
|
||||
<WebSearchQueryNode
|
||||
node={{
|
||||
type: 'web-search-query',
|
||||
attrs: { status: 'summarizing' },
|
||||
content: '',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('正在总结搜索语句...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals the summarized search query when expanded', () => {
|
||||
render(
|
||||
<WebSearchQueryNode
|
||||
node={{
|
||||
type: 'web-search-query',
|
||||
attrs: {
|
||||
status: 'done',
|
||||
query: 'BoosApi Desktop 0.0.76 Windows 下载地址',
|
||||
},
|
||||
content: '',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('BoosApi Desktop 0.0.76 Windows 下载地址')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('搜索语句总结完成'));
|
||||
expect(screen.getByText('搜索语句总结完成')).toBeInTheDocument();
|
||||
expect(screen.getByText('BoosApi Desktop 0.0.76 Windows 下载地址')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows query summary errors below the fold title with an Ant alert', () => {
|
||||
const { container } = render(
|
||||
<WebSearchQueryNode
|
||||
node={{
|
||||
type: 'web-search-query',
|
||||
attrs: {
|
||||
status: 'error',
|
||||
query: 'BoosApi 产品详情',
|
||||
},
|
||||
content: '模型返回空搜索语句',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = screen.getByText('搜索语句总结失败');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title.closest('[data-boosapi-search-query-status="error"]')).toBeInTheDocument();
|
||||
expect(title.closest('[data-boosapi-search-query-status="error"]')).toHaveStyle({
|
||||
color: '#ff4d4f',
|
||||
});
|
||||
expect(screen.getByText('模型返回空搜索语句')).toBeInTheDocument();
|
||||
expect(screen.getByText('BoosApi 产品详情')).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-alert-error')).toBeInTheDocument();
|
||||
|
||||
const errorNode = screen.getByText('模型返回空搜索语句');
|
||||
expect(title.compareDocumentPosition(errorNode) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an empty result state instead of rendering nothing', () => {
|
||||
render(
|
||||
<WebSearchNode
|
||||
node={{
|
||||
type: 'web-search',
|
||||
attrs: { status: 'done' },
|
||||
content: '[]',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('未找到相关搜索结果')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the concrete search error message', () => {
|
||||
const { container } = render(
|
||||
<WebSearchNode
|
||||
node={{
|
||||
type: 'web-search',
|
||||
attrs: { status: 'error' },
|
||||
content: 'Tavily request failed',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Tavily request failed')).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-alert-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD,
|
||||
CHAT_SCROLL_IS_REVERSED,
|
||||
getDistanceToHistoryTop,
|
||||
getScrollTopAfterPrepend,
|
||||
hasMeasuredScrollLayoutChanged,
|
||||
hasScrollLayoutMetricsChanged,
|
||||
resolveChatScrollElements,
|
||||
shouldIgnoreScrollDepartureFromBottom,
|
||||
shouldKeepAutoScroll,
|
||||
shouldStickToBottomOnLayoutChange,
|
||||
shouldShowScrollToBottom,
|
||||
} from '../chatScroll';
|
||||
|
||||
describe('chat scroll helpers', () => {
|
||||
it('exposes the chat bubble list as a normal scroll container when Bubble.List autoScroll is disabled', () => {
|
||||
expect(CHAT_SCROLL_IS_REVERSED).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the app scroll mode to show the bottom button when the user leaves the latest message area', () => {
|
||||
expect(shouldShowScrollToBottom(2000, 1200, 800, CHAT_SCROLL_IS_REVERSED)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, 1040, 800, CHAT_SCROLL_IS_REVERSED)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, 900, 800, CHAT_SCROLL_IS_REVERSED)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats reversed bubble scroll near zero as the latest-message position', () => {
|
||||
expect(shouldShowScrollToBottom(2000, 0, 800, true)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, -80, 800, true)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, -240, 800, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('measures distance to the logical history top for auto-loading older pages', () => {
|
||||
expect(getDistanceToHistoryTop(2000, -1200, 800, true)).toBe(0);
|
||||
expect(getDistanceToHistoryTop(2000, 0, 800, true)).toBe(1200);
|
||||
expect(getDistanceToHistoryTop(2000, 0, 800, false)).toBe(0);
|
||||
});
|
||||
|
||||
it('stops auto-scroll as soon as the user meaningfully leaves the bottom', () => {
|
||||
expect(CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD).toBe(8);
|
||||
expect(shouldKeepAutoScroll(2000, 0, 800, true)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, -7, 800, true)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, -12, 800, true)).toBe(false);
|
||||
expect(shouldKeepAutoScroll(2000, 1200, 800, false)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, 1193, 800, false)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, 1180, 800, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects layout changes that happen after content finishes rendering', () => {
|
||||
expect(hasScrollLayoutMetricsChanged(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1248, clientHeight: 800 },
|
||||
)).toBe(true);
|
||||
|
||||
expect(hasScrollLayoutMetricsChanged(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1200, clientHeight: 760 },
|
||||
)).toBe(true);
|
||||
|
||||
expect(hasScrollLayoutMetricsChanged(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1200.5, clientHeight: 800 },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat the first unknown scroll measurement as a layout departure', () => {
|
||||
const hasLayoutChanged = hasMeasuredScrollLayoutChanged(
|
||||
{ scrollHeight: 0, clientHeight: 0 },
|
||||
{ scrollHeight: 2000, clientHeight: 800 },
|
||||
);
|
||||
|
||||
expect(hasLayoutChanged).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, false, hasLayoutChanged)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the chat scroll elements from the message area before the Bubble.List ref is ready', () => {
|
||||
const root = document.createElement('div');
|
||||
const scrollBox = document.createElement('div');
|
||||
const scrollContent = document.createElement('div');
|
||||
scrollBox.className = 'ant-bubble-list-scroll-box';
|
||||
scrollContent.className = 'ant-bubble-list-scroll-content';
|
||||
scrollBox.appendChild(scrollContent);
|
||||
root.appendChild(scrollBox);
|
||||
|
||||
expect(resolveChatScrollElements(root, null)).toEqual({ scrollBox, scrollContent });
|
||||
});
|
||||
|
||||
it('keeps bottom lock on post-render layout changes only when the user was pinned', () => {
|
||||
expect(shouldStickToBottomOnLayoutChange(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1280, clientHeight: 800 },
|
||||
true,
|
||||
)).toBe(true);
|
||||
|
||||
expect(shouldStickToBottomOnLayoutChange(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1280, clientHeight: 800 },
|
||||
false,
|
||||
)).toBe(false);
|
||||
|
||||
expect(shouldStickToBottomOnLayoutChange(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1280, clientHeight: 800 },
|
||||
true,
|
||||
true,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores non-user scroll departures only when layout size changed while pinned', () => {
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, false, true)).toBe(true);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, false, false)).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, true, true)).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(true, true, false, true)).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, false, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves the viewport anchor when older messages are prepended in reversed chat mode', () => {
|
||||
expect(getScrollTopAfterPrepend(0, 1200, 1600, true)).toBe(-400);
|
||||
expect(getScrollTopAfterPrepend(-240, 1200, 1600, true)).toBe(-640);
|
||||
});
|
||||
|
||||
it('preserves the viewport anchor when older messages are prepended in regular scroll mode', () => {
|
||||
expect(getScrollTopAfterPrepend(0, 1200, 1600, false)).toBe(400);
|
||||
expect(getScrollTopAfterPrepend(240, 1200, 1600, false)).toBe(640);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getStreamingLoadingState,
|
||||
getStreamingStatusPresentation,
|
||||
hasBoosapiDisplayContent,
|
||||
hasModelVisibleContent,
|
||||
THINKING_LOADING_MARKER,
|
||||
closeStreamingThinkBlock,
|
||||
isAssistantStreamingForRender,
|
||||
shouldRenderAssistantMarkdownFromContent,
|
||||
shouldShowInitialStreamingDots,
|
||||
shouldShowInlineStreamingStatus,
|
||||
splitLeadingBoosapiDisplayContent,
|
||||
stripLeadingBoosapiDisplayTags,
|
||||
} from '../chatStreaming';
|
||||
|
||||
describe('chat streaming helpers', () => {
|
||||
it('derives bubble and footer loading state from stream progress and content presence', () => {
|
||||
expect(getStreamingLoadingState(true, '')).toEqual({
|
||||
bubbleLoading: true,
|
||||
footerLoading: false,
|
||||
});
|
||||
|
||||
expect(getStreamingLoadingState(true, 'hello')).toEqual({
|
||||
bubbleLoading: false,
|
||||
footerLoading: true,
|
||||
});
|
||||
|
||||
expect(getStreamingLoadingState(false, 'hello')).toEqual({
|
||||
bubbleLoading: false,
|
||||
footerLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps streamed assistant messages on the content renderer after completion', () => {
|
||||
expect(shouldRenderAssistantMarkdownFromContent(true, false)).toBe(true);
|
||||
expect(shouldRenderAssistantMarkdownFromContent(false, true)).toBe(true);
|
||||
expect(shouldRenderAssistantMarkdownFromContent(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows initial streaming dots for empty model content', () => {
|
||||
const stripDisplayTags = (content: string) => content
|
||||
.replace(/<knowledge-retrieval [^>]*data-boosapi="1"[^>]*>[\s\S]*?<\/knowledge-retrieval>\s*/g, '')
|
||||
.trim();
|
||||
|
||||
expect(shouldShowInitialStreamingDots(true, '', stripDisplayTags)).toBe(true);
|
||||
expect(shouldShowInitialStreamingDots(true, '<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>', stripDisplayTags)).toBe(true);
|
||||
expect(shouldShowInitialStreamingDots(true, 'answer', stripDisplayTags)).toBe(false);
|
||||
expect(shouldShowInitialStreamingDots(false, '', stripDisplayTags)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps inline streaming status visible while only thinking content is streaming', () => {
|
||||
expect(shouldShowInlineStreamingStatus({
|
||||
isStreaming: true,
|
||||
hasDisplayContent: false,
|
||||
hasActiveThinkingOnly: true,
|
||||
hasRenderedModelText: false,
|
||||
})).toBe(true);
|
||||
|
||||
expect(shouldShowInlineStreamingStatus({
|
||||
isStreaming: true,
|
||||
hasDisplayContent: false,
|
||||
hasActiveThinkingOnly: true,
|
||||
hasRenderedModelText: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('describes waiting before the first model chunk', () => {
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: null,
|
||||
lastChunkAt: null,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'waiting_first_packet',
|
||||
},
|
||||
now: 5_000,
|
||||
hasModelText: false,
|
||||
})!.labelKey).toBe('chat.streamingStatus.waitingFirstPacket');
|
||||
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: null,
|
||||
lastChunkAt: null,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'waiting_first_packet',
|
||||
},
|
||||
now: 12_000,
|
||||
hasModelText: false,
|
||||
})!.labelKey).toBe('chat.streamingStatus.waitingProvider');
|
||||
});
|
||||
|
||||
it('describes active and idle generation after model chunks arrive', () => {
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: 2_000,
|
||||
lastChunkAt: 9_000,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'streaming',
|
||||
},
|
||||
now: 12_000,
|
||||
hasModelText: true,
|
||||
})!.labelKey).toBe('chat.streamingStatus.generating');
|
||||
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: 2_000,
|
||||
lastChunkAt: 3_000,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'streaming',
|
||||
},
|
||||
now: 19_000,
|
||||
hasModelText: true,
|
||||
})!.labelKey).toBe('chat.streamingStatus.waitingNextChunk');
|
||||
});
|
||||
|
||||
it('ignores display-only tags when deciding whether model text exists', () => {
|
||||
const stripDisplayTags = (content: string) => content
|
||||
.replace(/<knowledge-retrieval [^>]*data-boosapi="1"[^>]*>[\s\S]*?<\/knowledge-retrieval>\s*/g, '')
|
||||
.replace(/<think[^>]*>[\s\S]*?<\/think>\s*/g, '')
|
||||
.trim();
|
||||
|
||||
expect(hasModelVisibleContent(
|
||||
'<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>',
|
||||
stripDisplayTags,
|
||||
)).toBe(false);
|
||||
expect(hasModelVisibleContent(
|
||||
'<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>\n\nanswer',
|
||||
stripDisplayTags,
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects BoosApi display tags independently from model text', () => {
|
||||
expect(hasBoosapiDisplayContent(
|
||||
'<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>',
|
||||
)).toBe(true);
|
||||
expect(hasBoosapiDisplayContent('answer')).toBe(false);
|
||||
});
|
||||
|
||||
it('splits leading BoosApi display tags from streamed model text', () => {
|
||||
const knowledge = '<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>\n\n';
|
||||
const memory = '<memory-retrieval status="done" data-boosapi="1">[]</memory-retrieval>\n\n';
|
||||
|
||||
expect(splitLeadingBoosapiDisplayContent(`${knowledge}${memory}answer`)).toEqual({
|
||||
prefix: `${knowledge}${memory}`,
|
||||
body: 'answer',
|
||||
});
|
||||
expect(splitLeadingBoosapiDisplayContent(`answer\n${knowledge}`)).toEqual({
|
||||
prefix: '',
|
||||
body: `answer\n${knowledge}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips selected leading display tags while preserving other display prefixes', () => {
|
||||
const web = '<web-search status="done" data-boosapi="1">[]</web-search>\n\n';
|
||||
const query = '<web-search-query status="done" data-boosapi="1">query</web-search-query>\n\n';
|
||||
const knowledge = '<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>\n\n';
|
||||
|
||||
expect(stripLeadingBoosapiDisplayTags(
|
||||
`${query}${web}${knowledge}answer`,
|
||||
['knowledge-retrieval', 'memory-retrieval'],
|
||||
)).toBe(`${query}${web}answer`);
|
||||
});
|
||||
|
||||
it('temporarily closes streamed native think blocks without dropping leading text', () => {
|
||||
const content = '<think>好的,用户让我讲个笑话。';
|
||||
|
||||
expect(closeStreamingThinkBlock(content, true)).toBe(
|
||||
`<think>\n好的,用户让我讲个笑话。${THINKING_LOADING_MARKER}\n</think>\n\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not alter completed, incomplete, or non-streaming think content', () => {
|
||||
expect(closeStreamingThinkBlock('<think>done</think>\n\nanswer', true)).toBe('<think>done</think>\n\nanswer');
|
||||
expect(closeStreamingThinkBlock('<thi', true)).toBe('<thi');
|
||||
expect(closeStreamingThinkBlock('<think>still thinking', false)).toBe('<think>still thinking');
|
||||
});
|
||||
|
||||
it('temporarily closes streamed think blocks with attributes', () => {
|
||||
expect(closeStreamingThinkBlock('<think data-boosapi="1">\nreasoning', true)).toBe(
|
||||
`<think data-boosapi="1">\nreasoning${THINKING_LOADING_MARKER}\n</think>\n\n`,
|
||||
);
|
||||
expect(closeStreamingThinkBlock('<think totalMs="123">done</think>\n\nanswer', true)).toBe(
|
||||
'<think totalMs="123">done</think>\n\nanswer',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps partial assistant messages on the streaming renderer even when ids differ', () => {
|
||||
expect(isAssistantStreamingForRender({
|
||||
isStreaming: true,
|
||||
messageId: 'real-message-id',
|
||||
streamingMessageId: 'temp-assistant-id',
|
||||
status: 'partial',
|
||||
})).toBe(true);
|
||||
expect(isAssistantStreamingForRender({
|
||||
isStreaming: true,
|
||||
messageId: 'real-message-id',
|
||||
streamingMessageId: 'temp-assistant-id',
|
||||
status: 'complete',
|
||||
})).toBe(false);
|
||||
expect(isAssistantStreamingForRender({
|
||||
isStreaming: false,
|
||||
messageId: 'real-message-id',
|
||||
streamingMessageId: 'temp-assistant-id',
|
||||
status: 'partial',
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatChatTime, normalizeChatTimestamp } from '../chatTime';
|
||||
|
||||
describe('chatTime', () => {
|
||||
it('formats backend unix-second timestamps the same as millisecond timestamps', () => {
|
||||
const timestampMs = Date.UTC(2026, 3, 30, 13, 0, 0);
|
||||
const timestampSeconds = Math.floor(timestampMs / 1000);
|
||||
|
||||
expect(normalizeChatTimestamp(timestampSeconds)).toBe(timestampMs);
|
||||
expect(formatChatTime(timestampSeconds, timestampMs)).toBe(formatChatTime(timestampMs, timestampMs));
|
||||
});
|
||||
|
||||
it('keeps frontend millisecond timestamps unchanged', () => {
|
||||
const timestampMs = Date.UTC(2026, 3, 30, 13, 45, 0);
|
||||
|
||||
expect(normalizeChatTimestamp(timestampMs)).toBe(timestampMs);
|
||||
});
|
||||
|
||||
it('shows only the time for messages from today', () => {
|
||||
const messageTime = new Date(2026, 3, 30, 21, 0, 5).getTime();
|
||||
const now = new Date(2026, 3, 30, 21, 30, 0).getTime();
|
||||
|
||||
expect(formatChatTime(messageTime, now)).toBe('21:00:05');
|
||||
});
|
||||
|
||||
it('shows the local date and time for messages outside today', () => {
|
||||
const messageTime = new Date(2026, 3, 29, 21, 0, 5).getTime();
|
||||
const now = new Date(2026, 3, 30, 21, 30, 0).getTime();
|
||||
|
||||
expect(formatChatTime(messageTime, now)).toBe('2026-04-29 21:00:05');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function readSource(...segments: string[]) {
|
||||
return fs.readFileSync(path.resolve(process.cwd(), ...segments), 'utf8');
|
||||
}
|
||||
|
||||
describe('Phase A feedback regressions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps chat scroll handling aligned with the shared Bubble.List scroll mode', () => {
|
||||
const chatViewSource = readSource('src/components/chat/ChatView.tsx');
|
||||
const chatScrollSource = readSource('src/components/chat/chatScroll.ts');
|
||||
|
||||
expect(chatScrollSource).toContain('CHAT_SCROLL_IS_REVERSED = false');
|
||||
expect(chatViewSource).toContain('CHAT_SCROLL_IS_REVERSED');
|
||||
expect(chatViewSource).not.toContain('shouldShowScrollToBottom(\n target.scrollHeight,\n target.scrollTop,\n target.clientHeight,\n false,');
|
||||
expect(chatViewSource).not.toContain('shouldKeepAutoScroll(\n target.scrollHeight,\n target.scrollTop,\n target.clientHeight,\n false,');
|
||||
expect(chatViewSource).not.toContain('getDistanceToHistoryTop(\n target.scrollHeight,\n target.scrollTop,\n target.clientHeight,\n false,');
|
||||
});
|
||||
|
||||
it('gives the chat textarea a dedicated visible-scrollbar hook instead of relying on globally hidden scrollbars', () => {
|
||||
const inputAreaSource = readSource('src/components/chat/InputArea.tsx');
|
||||
const cssSource = readSource('src/index.css');
|
||||
|
||||
expect(inputAreaSource).toContain('className="boosapi-input-textarea"');
|
||||
expect(cssSource).toContain('.boosapi-input-textarea');
|
||||
expect(cssSource).toMatch(/\.boosapi-input-textarea[\s\S]*scrollbar-width:\s*thin/i);
|
||||
});
|
||||
|
||||
it('feeds normalized assistant markdown content into the stream renderer', () => {
|
||||
const chatViewSource = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(chatViewSource).toContain('const rendererContent = displaySplit.body;');
|
||||
expect(chatViewSource).not.toContain('const rendererContent = displaySplit.prefix ? displaySplit.body : content;');
|
||||
});
|
||||
|
||||
it('keeps a null conversation max tokens override visually off instead of hydrating it from the global default', () => {
|
||||
const modalSource = readSource('src/components/chat/ConversationSettingsModal.tsx');
|
||||
|
||||
expect(modalSource).toContain('setMaxTokens(conversation.max_tokens ?? null)');
|
||||
expect(modalSource).not.toContain('setMaxTokens(conversation.max_tokens ?? settings.default_max_tokens ?? 4096)');
|
||||
});
|
||||
|
||||
it('treats max tokens clearing as an explicit nullable contract from modal to TypeScript types to Rust persistence', () => {
|
||||
const modalSource = readSource('src/components/chat/ConversationSettingsModal.tsx');
|
||||
const typeSource = readSource('src/types/index.ts');
|
||||
const rustTypeSource = readSource('src-tauri/crates/core/src/types.rs');
|
||||
const repoSource = readSource('src-tauri/crates/core/src/repo/conversation.rs');
|
||||
|
||||
expect(modalSource).toContain('max_tokens: maxTokens,');
|
||||
expect(typeSource).toMatch(/max_tokens\?: number \| null;/);
|
||||
expect(rustTypeSource).toMatch(/deserialize_double_option"\)\]\s*pub max_tokens: Option<Option<i64>>/);
|
||||
expect(repoSource).toContain('if let Some(max_tokens) = input.max_tokens {');
|
||||
expect(repoSource).toContain('am.max_tokens = Set(max_tokens);');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(...segments: string[]) {
|
||||
return fs.readFileSync(path.resolve(process.cwd(), ...segments), 'utf8');
|
||||
}
|
||||
|
||||
describe('Phase B category template regressions', () => {
|
||||
it('extends category contracts with default model and parameter template fields', () => {
|
||||
const typeSource = readSource('src/types/index.ts');
|
||||
const rustTypeSource = readSource('src-tauri/crates/core/src/types.rs');
|
||||
const entitySource = readSource('src-tauri/crates/core/src/entity/conversation_categories.rs');
|
||||
|
||||
expect(typeSource).toMatch(/default_provider_id: string \| null;/);
|
||||
expect(typeSource).toMatch(/default_model_id: string \| null;/);
|
||||
expect(typeSource).toMatch(/default_temperature: number \| null;/);
|
||||
expect(typeSource).toMatch(/default_max_tokens: number \| null;/);
|
||||
expect(typeSource).toMatch(/default_top_p: number \| null;/);
|
||||
expect(typeSource).toMatch(/default_frequency_penalty: number \| null;/);
|
||||
|
||||
expect(rustTypeSource).toMatch(/pub default_provider_id: Option<String>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_model_id: Option<String>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_temperature: Option<f64>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_max_tokens: Option<i64>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_top_p: Option<f64>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_frequency_penalty: Option<f64>/);
|
||||
|
||||
expect(entitySource).toMatch(/pub default_provider_id: Option<String>/);
|
||||
expect(entitySource).toMatch(/pub default_model_id: Option<String>/);
|
||||
});
|
||||
|
||||
it('lets the category editor configure a default model plus model params', () => {
|
||||
const modalSource = readSource('src/components/chat/CategoryEditModal.tsx');
|
||||
|
||||
expect(modalSource).toContain('ModelSelect');
|
||||
expect(modalSource).toContain('ModelParamSliders');
|
||||
expect(modalSource).toContain('default_provider_id');
|
||||
expect(modalSource).toContain('default_model_id');
|
||||
expect(modalSource).toContain('default_temperature');
|
||||
expect(modalSource).toContain('default_max_tokens');
|
||||
expect(modalSource).toContain('default_top_p');
|
||||
expect(modalSource).toContain('default_frequency_penalty');
|
||||
});
|
||||
|
||||
it('adds a category-scoped new conversation action instead of forcing users to create then move', () => {
|
||||
const sidebarSource = readSource('src/components/chat/ChatSidebar.tsx');
|
||||
|
||||
expect(sidebarSource).toContain('onCreateConversation');
|
||||
expect(sidebarSource).toContain("key: 'new'");
|
||||
expect(sidebarSource).toContain('handleNewConversation(cat.id)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(...segments: string[]) {
|
||||
return fs.readFileSync(path.resolve(process.cwd(), ...segments), 'utf8');
|
||||
}
|
||||
|
||||
describe('Phase C output control regressions', () => {
|
||||
it('lets assistant replies enter the shared edit flow instead of restricting edits to user prompts', () => {
|
||||
const source = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(source).toContain('editingMessageRole');
|
||||
expect(source).toContain("msg.role === 'assistant'");
|
||||
expect(source).toContain("key: 'edit'");
|
||||
expect(source).toContain("editingMessageRole === 'assistant'");
|
||||
});
|
||||
|
||||
it('shows a per-turn total token summary alongside prompt and completion counts', () => {
|
||||
const source = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(source).toContain('const totalTokens = (msg.prompt_tokens ?? 0) + (msg.completion_tokens ?? 0);');
|
||||
expect(source).toContain('{formatTokenCount(totalTokens)} tokens');
|
||||
expect(source).not.toContain("t('chat.totalTokens'");
|
||||
});
|
||||
|
||||
it('adds transcript copy and no-thinking export variants at chat level', () => {
|
||||
const source = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(source).toContain("key: 'copy-md'");
|
||||
expect(source).toContain("key: 'export-md-no-thinking'");
|
||||
expect(source).toContain("key: 'export-json-no-thinking'");
|
||||
});
|
||||
|
||||
it('lets export helpers optionally strip thinking content before saving or copying', () => {
|
||||
const source = readSource('src/lib/exportChat.ts');
|
||||
|
||||
expect(source).toContain('includeThinking');
|
||||
expect(source).toContain('stripBoosapiTags');
|
||||
expect(source).toContain('copyTranscript');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Message } from '@/types';
|
||||
import { buildAssistantDisplayContent, shouldHideAssistantBubble } from '../toolCallDisplay';
|
||||
|
||||
function makeMessage(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
provider_id: null,
|
||||
model_id: null,
|
||||
token_count: null,
|
||||
attachments: [],
|
||||
thinking: null,
|
||||
tool_calls_json: null,
|
||||
tool_call_id: null,
|
||||
created_at: 1,
|
||||
parent_message_id: null,
|
||||
version_index: 0,
|
||||
is_active: true,
|
||||
status: 'complete',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAssistantDisplayContent', () => {
|
||||
it('returns content as-is for assistant messages', () => {
|
||||
const msg = makeMessage({ content: 'Hello world' });
|
||||
expect(buildAssistantDisplayContent(msg, [])).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns content with :::mcp containers unchanged', () => {
|
||||
const content = ':::mcp {"name":"@boosapi/fetch","tool":"fetch_url"}\nresult\n:::\n';
|
||||
const msg = makeMessage({ content });
|
||||
expect(buildAssistantDisplayContent(msg, [])).toBe(content);
|
||||
});
|
||||
|
||||
it('does not hide an empty normal assistant placeholder bubble while streaming', () => {
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-streaming',
|
||||
content: '',
|
||||
tool_calls_json: null,
|
||||
});
|
||||
|
||||
expect(shouldHideAssistantBubble(assistant, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('hides assistant bubble with empty content but present tool_calls_json', () => {
|
||||
const assistant = makeMessage({
|
||||
content: '',
|
||||
tool_calls_json: '[{"id":"call-1","type":"function","function":{"name":"fetch","arguments":"{}"}}]',
|
||||
});
|
||||
|
||||
expect(shouldHideAssistantBubble(assistant, '')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Message } from '@/types';
|
||||
|
||||
const ASSISTANT_BUBBLE_KEY_PREFIX = 'ai:';
|
||||
|
||||
export function normalizeAssistantBubbleParentKey(key: unknown): string {
|
||||
const rawKey = String(key ?? '');
|
||||
return rawKey.startsWith(ASSISTANT_BUBBLE_KEY_PREFIX)
|
||||
? rawKey.slice(ASSISTANT_BUBBLE_KEY_PREFIX.length)
|
||||
: rawKey;
|
||||
}
|
||||
|
||||
export function resolveAssistantMessageForBubbleKey(
|
||||
key: unknown,
|
||||
assistantByParentId: Map<string, Message>,
|
||||
messageById: Map<string, Message>,
|
||||
): Message | undefined {
|
||||
const rawKey = String(key ?? '');
|
||||
const parentKey = normalizeAssistantBubbleParentKey(rawKey);
|
||||
return assistantByParentId.get(parentKey)
|
||||
?? assistantByParentId.get(rawKey)
|
||||
?? messageById.get(rawKey)
|
||||
?? messageById.get(parentKey);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export const CHAT_SCROLL_IS_REVERSED = false;
|
||||
export const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 8;
|
||||
export const CHAT_SCROLL_TO_BOTTOM_VISIBILITY_THRESHOLD = 160;
|
||||
export const CHAT_SCROLL_BOX_SELECTOR = '.ant-bubble-list-scroll-box';
|
||||
export const CHAT_SCROLL_CONTENT_SELECTOR = '.ant-bubble-list-scroll-content';
|
||||
|
||||
export type ChatScrollElements = {
|
||||
scrollBox: HTMLElement | null;
|
||||
scrollContent: HTMLElement | null;
|
||||
};
|
||||
|
||||
export function resolveChatScrollElements(
|
||||
root: ParentNode | null | undefined,
|
||||
scrollBoxCandidate?: HTMLElement | null,
|
||||
): ChatScrollElements {
|
||||
const rootNode = root as Node | null | undefined;
|
||||
const candidateBelongsToRoot = Boolean(
|
||||
scrollBoxCandidate
|
||||
&& (!rootNode || rootNode === scrollBoxCandidate || rootNode.contains(scrollBoxCandidate)),
|
||||
);
|
||||
const scrollBox: HTMLElement | null = candidateBelongsToRoot && scrollBoxCandidate
|
||||
? scrollBoxCandidate
|
||||
: root?.querySelector<HTMLElement>(CHAT_SCROLL_BOX_SELECTOR) ?? null;
|
||||
const scrollContent = scrollBox?.querySelector<HTMLElement>(CHAT_SCROLL_CONTENT_SELECTOR)
|
||||
?? (scrollBox?.firstElementChild as HTMLElement | null)
|
||||
?? null;
|
||||
|
||||
return { scrollBox, scrollContent };
|
||||
}
|
||||
|
||||
export function getDistanceToHistoryTop(
|
||||
scrollHeight: number,
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
isReversed: boolean,
|
||||
) {
|
||||
return isReversed ? scrollHeight + scrollTop - clientHeight : scrollTop;
|
||||
}
|
||||
|
||||
export function getScrollTopAfterPrepend(
|
||||
previousScrollTop: number,
|
||||
previousScrollHeight: number,
|
||||
nextScrollHeight: number,
|
||||
isReversed: boolean,
|
||||
) {
|
||||
const heightDelta = Math.max(0, nextScrollHeight - previousScrollHeight);
|
||||
return isReversed
|
||||
? previousScrollTop - heightDelta
|
||||
: previousScrollTop + heightDelta;
|
||||
}
|
||||
|
||||
export type ScrollLayoutMetrics = {
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
};
|
||||
|
||||
export function hasScrollLayoutMetricsChanged(
|
||||
previous: ScrollLayoutMetrics,
|
||||
next: ScrollLayoutMetrics,
|
||||
threshold = 1,
|
||||
) {
|
||||
return Math.abs(next.scrollHeight - previous.scrollHeight) > threshold
|
||||
|| Math.abs(next.clientHeight - previous.clientHeight) > threshold;
|
||||
}
|
||||
|
||||
export function hasMeasuredScrollLayout(metrics: ScrollLayoutMetrics) {
|
||||
return metrics.scrollHeight > 0 && metrics.clientHeight > 0;
|
||||
}
|
||||
|
||||
export function hasMeasuredScrollLayoutChanged(
|
||||
previous: ScrollLayoutMetrics,
|
||||
next: ScrollLayoutMetrics,
|
||||
threshold = 1,
|
||||
) {
|
||||
return hasMeasuredScrollLayout(previous)
|
||||
&& hasScrollLayoutMetricsChanged(previous, next, threshold);
|
||||
}
|
||||
|
||||
export function shouldStickToBottomOnLayoutChange(
|
||||
previous: ScrollLayoutMetrics,
|
||||
next: ScrollLayoutMetrics,
|
||||
wasStickingToBottom: boolean,
|
||||
hadRecentUserScrollIntent = false,
|
||||
threshold = 1,
|
||||
) {
|
||||
return wasStickingToBottom
|
||||
&& !hadRecentUserScrollIntent
|
||||
&& hasMeasuredScrollLayoutChanged(previous, next, threshold);
|
||||
}
|
||||
|
||||
export function shouldIgnoreScrollDepartureFromBottom(
|
||||
keepAutoScroll: boolean,
|
||||
wasStickingToBottom: boolean,
|
||||
hadRecentUserScrollIntent: boolean,
|
||||
hasLayoutChanged: boolean,
|
||||
) {
|
||||
return !keepAutoScroll && wasStickingToBottom && !hadRecentUserScrollIntent && hasLayoutChanged;
|
||||
}
|
||||
|
||||
export function shouldShowScrollToBottom(
|
||||
scrollHeight: number,
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
isReversed: boolean,
|
||||
threshold = CHAT_SCROLL_TO_BOTTOM_VISIBILITY_THRESHOLD,
|
||||
) {
|
||||
if (isReversed) {
|
||||
return scrollTop < -threshold;
|
||||
}
|
||||
return scrollHeight - clientHeight - scrollTop > threshold;
|
||||
}
|
||||
|
||||
export function shouldKeepAutoScroll(
|
||||
scrollHeight: number,
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
isReversed: boolean,
|
||||
threshold = CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD,
|
||||
) {
|
||||
if (isReversed) {
|
||||
return scrollTop >= -threshold;
|
||||
}
|
||||
return scrollHeight - clientHeight - scrollTop <= threshold;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { normalizeThinkTagsForMarkdown } from '@/lib/thinkTags';
|
||||
export {
|
||||
getStreamingStatusPresentation,
|
||||
type StreamActivity,
|
||||
type StreamingStatusPresentation,
|
||||
} from '@/lib/streamStatus';
|
||||
|
||||
export function getStreamingLoadingState(
|
||||
isStreaming: boolean,
|
||||
content: unknown,
|
||||
): { bubbleLoading: boolean; footerLoading: boolean } {
|
||||
const hasContent = typeof content === 'string'
|
||||
? content.trim().length > 0
|
||||
: Boolean(content);
|
||||
|
||||
return {
|
||||
bubbleLoading: isStreaming && !hasContent,
|
||||
footerLoading: isStreaming && hasContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRenderAssistantMarkdownFromContent(
|
||||
isStreaming: boolean,
|
||||
streamedInCurrentSession: boolean,
|
||||
): boolean {
|
||||
return isStreaming || streamedInCurrentSession;
|
||||
}
|
||||
|
||||
export const THINKING_LOADING_MARKER = '<!--boosapi-thinking-loading-->';
|
||||
|
||||
export function closeStreamingThinkBlock(content: string, isStreaming: boolean): string {
|
||||
if (!isStreaming || content.includes(THINKING_LOADING_MARKER)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let lastOpenIndex = -1;
|
||||
for (const match of content.matchAll(/<think\b[^>]*>/gi)) {
|
||||
lastOpenIndex = match.index ?? -1;
|
||||
}
|
||||
if (lastOpenIndex < 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let lastCloseIndex = -1;
|
||||
for (const match of content.matchAll(/<\/think\s*>/gi)) {
|
||||
lastCloseIndex = match.index ?? -1;
|
||||
}
|
||||
if (lastCloseIndex > lastOpenIndex) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return normalizeThinkTagsForMarkdown(`${content}${THINKING_LOADING_MARKER}\n</think>\n\n`);
|
||||
}
|
||||
|
||||
export function isAssistantStreamingForRender(input: {
|
||||
isStreaming: boolean;
|
||||
messageId?: string | null;
|
||||
streamingMessageId?: string | null;
|
||||
status?: string | null;
|
||||
}): boolean {
|
||||
if (!input.isStreaming || !input.messageId) {
|
||||
return false;
|
||||
}
|
||||
return input.messageId === input.streamingMessageId || input.status === 'partial';
|
||||
}
|
||||
|
||||
export function hasModelVisibleContent(content: unknown, stripDisplayTags: (content: string) => string): boolean {
|
||||
if (typeof content !== 'string') {
|
||||
return Boolean(content);
|
||||
}
|
||||
return stripDisplayTags(content).trim().length > 0;
|
||||
}
|
||||
|
||||
export function shouldShowInitialStreamingDots(
|
||||
isStreaming: boolean,
|
||||
content: unknown,
|
||||
stripDisplayTags: (content: string) => string,
|
||||
): boolean {
|
||||
return isStreaming && !hasModelVisibleContent(content, stripDisplayTags);
|
||||
}
|
||||
|
||||
export function shouldShowInlineStreamingStatus(input: {
|
||||
isStreaming: boolean;
|
||||
hasDisplayContent: boolean;
|
||||
hasActiveThinkingOnly: boolean;
|
||||
hasRenderedModelText: boolean;
|
||||
}): boolean {
|
||||
return input.isStreaming
|
||||
&& !input.hasRenderedModelText
|
||||
&& (input.hasDisplayContent || input.hasActiveThinkingOnly);
|
||||
}
|
||||
|
||||
export function hasBoosapiDisplayContent(content: unknown): boolean {
|
||||
return typeof content === 'string'
|
||||
&& /<(?:knowledge-retrieval|memory-retrieval|web-search-query|web-search)\b[^>]*data-boosapi=["']1["'][^>]*>/i.test(content);
|
||||
}
|
||||
|
||||
const LEADING_BOOSAPI_DISPLAY_TAG_RE = /^\s*<(knowledge-retrieval|memory-retrieval|web-search-query|web-search)\b[^>]*data-boosapi=["']1["'][^>]*>[\s\S]*?<\/\1>\s*/i;
|
||||
|
||||
export function splitLeadingBoosapiDisplayContent(content: string): { prefix: string; body: string } {
|
||||
let body = content;
|
||||
let prefix = '';
|
||||
|
||||
for (;;) {
|
||||
const match = body.match(LEADING_BOOSAPI_DISPLAY_TAG_RE);
|
||||
if (!match) break;
|
||||
prefix += match[0];
|
||||
body = body.slice(match[0].length);
|
||||
}
|
||||
|
||||
return { prefix, body };
|
||||
}
|
||||
|
||||
export function stripLeadingBoosapiDisplayTags(content: string, tagNames: string[]): string {
|
||||
const tagSet = new Set(tagNames);
|
||||
let body = content;
|
||||
let keptPrefix = '';
|
||||
|
||||
for (;;) {
|
||||
const match = body.match(LEADING_BOOSAPI_DISPLAY_TAG_RE);
|
||||
if (!match) break;
|
||||
const tagName = match[1]?.toLowerCase();
|
||||
if (!tagName || !tagSet.has(tagName)) {
|
||||
keptPrefix += match[0];
|
||||
}
|
||||
body = body.slice(match[0].length);
|
||||
}
|
||||
|
||||
return keptPrefix + body;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const UNIX_MILLISECONDS_THRESHOLD = 1_000_000_000_000;
|
||||
|
||||
export function normalizeChatTimestamp(timestamp: number): number {
|
||||
return Math.abs(timestamp) < UNIX_MILLISECONDS_THRESHOLD ? timestamp * 1000 : timestamp;
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function isSameLocalDate(left: Date, right: Date): boolean {
|
||||
return left.getFullYear() === right.getFullYear()
|
||||
&& left.getMonth() === right.getMonth()
|
||||
&& left.getDate() === right.getDate();
|
||||
}
|
||||
|
||||
export function formatChatTime(timestamp: number, nowTimestamp: number = Date.now()): string {
|
||||
const date = new Date(normalizeChatTimestamp(timestamp));
|
||||
const now = new Date(normalizeChatTimestamp(nowTimestamp));
|
||||
const time = formatTime(date);
|
||||
|
||||
if (isSameLocalDate(date, now)) {
|
||||
return time;
|
||||
}
|
||||
|
||||
return `${formatDate(date)} ${time}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { ChatSidebar } from './ChatSidebar';
|
||||
export { ChatView } from './ChatView';
|
||||
export { InputArea } from './InputArea';
|
||||
export { ModelSelector } from './ModelSelector';
|
||||
export { CitationPanel } from './CitationPanel';
|
||||
export { ToolCallCard } from './ToolCallCard';
|
||||
export { ArtifactPanel } from './ArtifactPanel';
|
||||
export { BranchComparePanel } from './BranchComparePanel';
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Message } from '@/types';
|
||||
import { stripBoosapiTags } from '@/lib/chatMarkdown';
|
||||
import { buildSearchQueryTag, buildSearchTag, parseSearchContent } from '@/lib/searchUtils';
|
||||
import { splitLeadingBoosapiDisplayContent } from './chatStreaming';
|
||||
import { splitStreamErrorContent } from '@/lib/streamStatus';
|
||||
|
||||
const MCP_RENDER_BLOCK_CHAR_LIMIT = 20_000;
|
||||
const MCP_RENDER_TRUNCATED_NOTICE = 'MCP output truncated for rendering';
|
||||
|
||||
function hasPersistedDisplayTag(content: string): boolean {
|
||||
return /<(?:web-search-query|web-search|knowledge-retrieval|memory-retrieval)\b[^>]*data-boosapi=["']1["'][^>]*>/i.test(content);
|
||||
}
|
||||
|
||||
function sliceCodePoints(content: string, limit: number): string {
|
||||
let count = 0;
|
||||
let end = 0;
|
||||
for (const char of content) {
|
||||
if (count >= limit) break;
|
||||
end += char.length;
|
||||
count += 1;
|
||||
}
|
||||
return content.slice(0, end);
|
||||
}
|
||||
|
||||
function truncatePersistedMcpBlocks(content: string): string {
|
||||
let result = '';
|
||||
let offset = 0;
|
||||
|
||||
while (offset < content.length) {
|
||||
const opener = content.indexOf(':::mcp', offset);
|
||||
if (opener === -1) {
|
||||
result += content.slice(offset);
|
||||
break;
|
||||
}
|
||||
|
||||
const headerEnd = content.indexOf('\n', opener);
|
||||
if (headerEnd === -1) {
|
||||
result += content.slice(offset);
|
||||
break;
|
||||
}
|
||||
|
||||
const closer = content.indexOf('\n:::', headerEnd + 1);
|
||||
if (closer === -1) {
|
||||
result += content.slice(offset);
|
||||
break;
|
||||
}
|
||||
|
||||
const bodyStart = headerEnd + 1;
|
||||
const body = content.slice(bodyStart, closer);
|
||||
result += content.slice(offset, bodyStart);
|
||||
|
||||
if (Array.from(body).length > MCP_RENDER_BLOCK_CHAR_LIMIT) {
|
||||
result += sliceCodePoints(body, MCP_RENDER_BLOCK_CHAR_LIMIT);
|
||||
result += `\n\n[${MCP_RENDER_TRUNCATED_NOTICE}: showing first ${MCP_RENDER_BLOCK_CHAR_LIMIT} characters]`;
|
||||
} else {
|
||||
result += body;
|
||||
}
|
||||
|
||||
offset = closer;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildAssistantDisplayContent(message: Message, messages: Message[]): string {
|
||||
if (message.role !== 'assistant') {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
let content = truncatePersistedMcpBlocks(message.content);
|
||||
if (message.status === 'error' || hasPersistedDisplayTag(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const parent = message.parent_message_id
|
||||
? messages.find((item) => item.id === message.parent_message_id && item.role === 'user')
|
||||
: undefined;
|
||||
if (!parent) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const parentSearch = parseSearchContent(parent.content);
|
||||
if (!parentSearch.hasSearch) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const queryTag = parentSearch.queryStatus || parentSearch.query
|
||||
? buildSearchQueryTag(parentSearch.queryStatus ?? 'done', parentSearch.query ?? undefined, parentSearch.queryError ?? undefined)
|
||||
: '';
|
||||
const searchTag = buildSearchTag(
|
||||
parentSearch.status ?? 'done',
|
||||
parentSearch.sources.map((source) => ({ ...source, content: '' })),
|
||||
parentSearch.error ?? undefined,
|
||||
);
|
||||
content = `${queryTag}${searchTag}${content}`;
|
||||
return content;
|
||||
}
|
||||
|
||||
export function splitAssistantErrorDisplayContent(content: string): { prefix: string; message: string } {
|
||||
const streamError = splitStreamErrorContent(content);
|
||||
if (streamError) {
|
||||
return {
|
||||
prefix: streamError.prefix,
|
||||
message: stripBoosapiTags(streamError.error).trim() || streamError.error,
|
||||
};
|
||||
}
|
||||
|
||||
const split = splitLeadingBoosapiDisplayContent(content);
|
||||
const cleanBody = stripBoosapiTags(split.body).trim();
|
||||
const cleanFullContent = stripBoosapiTags(content).trim();
|
||||
|
||||
return {
|
||||
prefix: split.prefix,
|
||||
message: cleanBody || cleanFullContent || split.body.trim() || content.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldHideAssistantBubble(message: Message, displayContent: string): boolean {
|
||||
if (message.role !== 'assistant') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (displayContent.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !message.content.trim() && Boolean(message.tool_calls_json);
|
||||
}
|
||||
Reference in New Issue
Block a user