Commit 1488c6e6 authored by Andy-bubu's avatar Andy-bubu

fix: refine ai chat assistant cards and model controls

parent aa343ada
import {
BulbOutlined,
CloseOutlined,
CopyOutlined,
DownOutlined,
PaperClipOutlined,
ReloadOutlined,
SearchOutlined,
SettingOutlined,
} from '@ant-design/icons';
import {
......@@ -14,7 +18,28 @@ import { ChatMessage } from '@heroui-pro/react/chat-message';
import { Markdown, StreamMarkdown } from '@heroui-pro/react/markdown';
import { PromptInput } from '@heroui-pro/react/prompt-input';
import { PromptSuggestion } from '@heroui-pro/react/prompt-suggestion';
import { Disclosure, Spinner, Surface, toast } from '@heroui/react';
import {
Autocomplete,
Chip,
Disclosure,
ListBox,
SearchField,
Spinner,
Surface,
toast,
} from '@heroui/react';
function filterModel(textValue, inputValue) {
const query = String(inputValue || '')
.trim()
.toLocaleLowerCase();
return (
!query ||
String(textValue || '')
.toLocaleLowerCase()
.includes(query)
);
}
function MessageAttachments({ files = [] }) {
if (!files.length) return null;
......@@ -162,7 +187,7 @@ function PromptGroups({ groups = [], onSelect }) {
return (
<PromptSuggestion className="rc-chat-suggestions" variant="card">
{groups.map((group) => (
<PromptSuggestion.Group key={group.key} label={group.label}>
<PromptSuggestion.Group key={group.key}>
<PromptSuggestion.Items
className={`rc-chat-suggestion-items${
group.children?.length === 1
......@@ -183,6 +208,14 @@ function PromptGroups({ groups = [], onSelect }) {
}
}}
>
{item.icon ? (
<Surface
className="rc-chat-agent-icon grid size-14 place-items-center rounded-xl border border-border"
variant="default"
>
{item.icon}
</Surface>
) : null}
<PromptSuggestion.ItemTitle>
{item.label || item.description || item.title}
</PromptSuggestion.ItemTitle>
......@@ -201,6 +234,7 @@ function PromptGroups({ groups = [], onSelect }) {
}
export default function ChatWorkspace({
activeMode,
canConfigure,
children,
description,
......@@ -209,9 +243,12 @@ export default function ChatWorkspace({
isLoading,
knowledgeBases = [],
messages = [],
modelListLoading = false,
models = [],
onConfigure,
onFilesSelected,
onInputChange,
onModelChange,
onQuestion,
onRegenerate,
onRemoveFile,
......@@ -221,6 +258,7 @@ export default function ChatWorkspace({
questions = [],
renderAssistantExtras,
renderAssistantContent,
selectedModel,
suggestionGroups = [],
title,
userInfo,
......@@ -228,6 +266,10 @@ export default function ChatWorkspace({
const visibleMessages = messages.filter(
(item) => !item.message?.metadata?.hidden,
);
const selectedModelLabel =
models.find((model) => String(model.id) === String(selectedModel))?.label ||
selectedModel ||
(modelListLoading ? '正在加载模型' : '选择模型');
return (
<div className="rc-chat-workspace">
......@@ -273,7 +315,24 @@ export default function ChatWorkspace({
);
})}
{!visibleMessages.length ? (
<>
{!title ? (
<section
aria-labelledby="chat-welcome-title"
className="rc-chat-empty-state flex flex-col items-center text-center"
>
<Surface
className="grid size-16 place-items-center rounded-full text-accent"
variant="tertiary"
>
<BulbOutlined className="text-2xl" />
</Surface>
<h1 id="chat-welcome-title">今天想处理什么?</h1>
<p>选择一个助手,或直接在下方输入问题。</p>
</section>
) : null}
<PromptGroups groups={suggestionGroups} onSelect={onSuggestion} />
</>
) : null}
{children}
<ChatConversation.ScrollAnchor />
......@@ -329,7 +388,7 @@ export default function ChatWorkspace({
</PromptInput.Attachments>
) : null}
<PromptInput.Content>
<PromptInput.TextArea placeholder="请输入消息内容" />
<PromptInput.TextArea placeholder="你想了解什么?" />
</PromptInput.Content>
<PromptInput.Toolbar>
<PromptInput.ToolbarStart>
......@@ -354,6 +413,77 @@ export default function ChatWorkspace({
)}
/>
</ChatAttachmentInput>
<Chip
className="rc-chat-agent-chip"
size="md"
variant="secondary"
>
{activeMode || '通用问答'}
</Chip>
<Autocomplete
aria-label="选择模型"
className="rc-chat-model-select"
isDisabled={
isLoading || modelListLoading || models.length === 0
}
selectedKey={selectedModel || null}
variant="secondary"
onSelectionChange={onModelChange}
>
<Autocomplete.Trigger title={selectedModelLabel}>
<Autocomplete.Value>
<span className="block min-w-0 truncate">
{selectedModelLabel}
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<DownOutlined />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement="top start">
<Autocomplete.Filter filter={filterModel}>
<SearchField
aria-label="搜索模型"
fullWidth
variant="secondary"
>
<SearchField.Group>
<SearchField.SearchIcon>
<SearchOutlined />
</SearchField.SearchIcon>
<SearchField.Input
autoFocus
placeholder="输入模型名称搜索"
/>
<SearchField.ClearButton aria-label="清空搜索">
<CloseOutlined />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<ListBox
aria-label="可用模型"
items={models}
renderEmptyState={() => (
<div className="grid min-h-20 place-items-center px-3 py-4 text-sm text-muted">
暂无可用模型
</div>
)}
>
{(model) => (
<ListBox.Item id={model.id} textValue={model.label}>
<span className="min-w-0 flex-1 truncate">
{model.label}
</span>
{model.isDefault ? (
<span className="text-xs text-muted">默认</span>
) : null}
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
</PromptInput.ToolbarStart>
<PromptInput.ToolbarEnd>
{files.some((file) => file.status === 'uploading') ? (
......
......@@ -2,9 +2,9 @@ import { useNavbarActions } from '@/components/AppShell';
import ChatWorkspace from '@/components/ChatWorkspace';
import { createSseStream, useChat, useChatAgent } from '@/utils/chatRuntime';
import {
CommentOutlined,
DatabaseOutlined,
FileDoneOutlined,
FileSearchOutlined,
} from '@ant-design/icons';
import { toast } from '@heroui/react';
import { useQuery } from '@umijs/max';
......@@ -47,6 +47,7 @@ import {
getApplicationRuntimeConfig,
imageAnalyze,
llmChat,
llmList,
queryMcps,
queryRecords,
queryTopics,
......@@ -63,7 +64,17 @@ import InvoiceAssistant from './invoice';
const SYSTEM_PROMPT = `你是一个有用的的智能助手。
如果用户提供了上下文,请根据上下文回答问题,不要试图创造一个答案。如果你不知道答案,请回答你只能根据上下文的内容回答问题。`;
const SELECTED_MODEL_STORAGE_KEY = 'ai_chat.selected_model';
const AGENT_DEFINITIONS = [
{
key: 'general-chat',
icon: <CommentOutlined />,
label: '通用问答',
description: '自由交流、分析问题或协助写作,不读取业务知识库。',
prompt:
'你是通用 AI 助手。根据用户的问题直接回答,不引用或假设任何未提供的业务知识库内容。',
},
{
key: 'knowledge-copilot',
icon: <DatabaseOutlined />,
......@@ -75,7 +86,7 @@ const AGENT_DEFINITIONS = [
{
key: 'audit-checklist',
icon: <FileDoneOutlined />,
label: '审计检查清单生成',
label: '审计检查清单',
description: '基于审计风险点知识库,生成可执行的审计检查清单。',
prompt:
'你是审计检查清单生成智能体。只能依据系统提供的审计风险点数据库原文组织检查清单,不得添加原文没有的风险、法规、事实或建议;知识库依据不足时必须明确说明。',
......@@ -120,7 +131,7 @@ function normalizeTopic(topic) {
function buildAgentPromptGroup(topics = []) {
return {
key: 'agents',
label: '智能体',
label: '选择功能',
children: AGENT_DEFINITIONS.map((agent) => {
const topic =
topics.find((item) => item.name === agent.topicName) ||
......@@ -128,34 +139,12 @@ function buildAgentPromptGroup(topics = []) {
return {
...agent,
initialContent:
agent.topicName === AUDIT_TOPIC.name ? undefined : agent.description,
topic,
};
}),
};
}
function buildTopicPromptGroup(topics = []) {
const normalizedTopics = topics.length > 0 ? topics : FALLBACK_TOPICS;
const firstTopic = normalizedTopics[0];
return {
key: 'topics',
label: '业务话题',
children: firstTopic
? [
{
key: firstTopic.name,
description: firstTopic.title,
icon: <FileSearchOutlined />,
topic: firstTopic,
},
]
: [],
};
}
function getKnowledgeBaseId(kb) {
return kb?._id || kb?.id || kb?.name;
}
......@@ -225,6 +214,14 @@ function getConversationTopic(metadata = {}) {
return null;
}
function getStoredLlmModel() {
try {
return localStorage.getItem(SELECTED_MODEL_STORAGE_KEY) || '';
} catch {
return '';
}
}
export default forwardRef((props, ref) => {
const { userInfo, conversation, onAddConversation } = props;
const conversationAgent = useMemo(
......@@ -261,6 +258,7 @@ export default forwardRef((props, ref) => {
const [attachedFiles, setAttachedFiles] = useState([]);
const [inputValue, setInputValue] = useState('');
const [selectedModel, setSelectedModel] = useState(getStoredLlmModel);
const [auditKnowledgeOpen, setAuditKnowledgeOpen] = useState(false);
const [auditPromptOpen, setAuditPromptOpen] = useState(false);
const [auditKnowledgeLoading, setAuditKnowledgeLoading] = useState(false);
......@@ -280,11 +278,54 @@ export default forwardRef((props, ref) => {
[configuredTopics],
);
const promptGroups = useMemo(() => {
return [buildAgentPromptGroup(configuredTopics)];
}, [configuredTopics]);
const { data: availableModels = [], isLoading: modelListLoading } = useQuery({
queryKey: ['llm-models'],
enabled: Boolean(userInfo),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
try {
return await llmList();
} catch (error) {
const fallbackModel = await resolveLlmModel({ thinking: true });
if (!fallbackModel) throw error;
return [
buildTopicPromptGroup(configuredTopics),
buildAgentPromptGroup(configuredTopics),
{
id: fallbackModel,
label: fallbackModel,
isDefault: true,
unavailable: true,
},
];
}, [configuredTopics]);
}
},
});
useEffect(() => {
if (!availableModels.length) return;
setSelectedModel((current) => {
const next = availableModels.some((model) => model.id === current)
? current
: availableModels.find((model) => model.isDefault)?.id ||
availableModels[0].id;
try {
localStorage.setItem(SELECTED_MODEL_STORAGE_KEY, next);
} catch {}
return next;
});
}, [availableModels]);
const selectModel = (key) => {
if (key === null || key === undefined) return;
const next = String(key);
setSelectedModel(next);
try {
localStorage.setItem(SELECTED_MODEL_STORAGE_KEY, next);
} catch {}
};
const loadAuditRecords = useCallback(
async (force = false) => {
......@@ -787,10 +828,12 @@ export default forwardRef((props, ref) => {
const content = JSON.stringify(requestMessages);
const long = content.length > 900000;
const model = await resolveLlmModel({
const model =
selectedModel ||
(await resolveLlmModel({
long,
thinking: true,
});
}));
const enable_search = conversationTopic?.metadata?.enable_search;
const enable_thinking = true;
const requestContext = {
......@@ -1030,9 +1073,20 @@ export default forwardRef((props, ref) => {
console.error(error);
toast.danger(error.message || '应用模型配置读取失败');
});
const usesAuditKnowledge =
conversationAgent?.key === 'knowledge-copilot' ||
conversationAgent?.key === 'audit-checklist' ||
conversationTopic?.name === AUDIT_TOPIC.name;
if (usesAuditKnowledge) {
loadAuditRecords().catch(() => {});
}
}, [loadAuditRecords, userInfo]);
}
}, [
conversationAgent?.key,
conversationTopic?.name,
loadAuditRecords,
userInfo,
]);
const navbarActions = useMemo(
() =>
......@@ -1241,6 +1295,7 @@ export default forwardRef((props, ref) => {
return (
<>
<ChatWorkspace
activeMode={conversationAgent?.label || DEFAULT_AGENT.label}
canConfigure={
Boolean(conversation) &&
(conversationAgent?.key === 'audit-checklist' ||
......@@ -1256,6 +1311,8 @@ export default forwardRef((props, ref) => {
isLoading={loading}
knowledgeBases={conversation?.metadata?.knowledgeBases || []}
messages={messages}
modelListLoading={modelListLoading}
models={availableModels}
questions={conversationTopic?.metadata?.questions || []}
renderAssistantExtras={renderAssistantExtras}
suggestionGroups={!conversation ? promptGroups : []}
......@@ -1264,6 +1321,7 @@ export default forwardRef((props, ref) => {
? conversationAgent?.label || conversationTopic?.title || '智能助手'
: undefined
}
selectedModel={selectedModel}
userInfo={userInfo}
onConfigure={() => setAuditPromptOpen(true)}
onFilesSelected={handleFilesSelected}
......@@ -1275,6 +1333,7 @@ export default forwardRef((props, ref) => {
current.filter((item) => item.uid !== file.uid),
)
}
onModelChange={selectModel}
onStop={() => abortController.current?.abort()}
onSubmit={() => {
const content = inputValue.trim();
......
......@@ -340,6 +340,50 @@ export async function getApplicationRuntimeConfig({ force = false } = {}) {
return applicationConfigPromise;
}
function normalizeLlmList(payload) {
const candidates = [
payload,
payload?.data,
payload?.models,
payload?.records,
payload?.result,
payload?.result?.data,
];
const items = candidates.find(Array.isArray) || [];
const models = items
.map((item) => {
if (typeof item === 'string' && item.trim()) {
return { id: item.trim(), label: item.trim() };
}
if (!item || typeof item !== 'object') return null;
const id = firstNonEmptyString(
item.id,
item.model,
item.name,
item.value,
item.key,
);
if (!id) return null;
return {
...item,
id,
label:
firstNonEmptyString(
item.display_name,
item.displayName,
item.label,
item.title,
item.name,
) || id,
};
})
.filter(Boolean);
return [...new Map(models.map((model) => [model.id, model])).values()];
}
function resolveLlmUrl(llmConfig = {}) {
const envBaseUrl = firstNonEmptyString(
process.env.UMI_APP_LLM_API_BASE,
......@@ -747,6 +791,23 @@ export async function llmChat(data, signal = undefined) {
return response;
}
export async function llmList(signal = undefined) {
const payload = await request('/api/llm/list', {
method: 'get',
signal,
});
const models = normalizeLlmList(payload);
if (!models.length) {
throw new Error('模型列表为空');
}
const defaultModel = await resolveLlmModel().catch(() => null);
return models.map((model) => ({
...model,
isDefault: model.id === defaultModel,
}));
}
export async function resolveLlmModel(options = {}) {
const runtimeConfig = await getApplicationRuntimeConfig();
const models = runtimeConfig.llm?.models || {};
......
......@@ -522,14 +522,16 @@ body [data-theme='dark'] .login-page {
}
.rc-chat-conversation-content {
width: min(100%, 56rem);
width: min(100%, 64rem);
margin-inline: auto;
gap: 1rem;
padding-block: 2rem 1rem;
}
.rc-chat-conversation-content--empty {
width: min(100%, 48rem);
width: min(100%, 64rem);
gap: 2rem;
padding-block-start: 0.75rem;
}
.rc-chat-context {
......@@ -547,12 +549,19 @@ body [data-theme='dark'] .login-page {
}
.rc-chat-suggestions {
width: 100%;
width: min(100%, 62rem);
margin-inline: auto;
}
.rc-chat-suggestion-items {
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.25rem;
}
.rc-chat-suggestion-items [data-slot='prompt-suggestion-item'] {
min-height: 12rem;
align-content: start;
}
.rc-chat-suggestion-items--single {
......@@ -561,13 +570,51 @@ body [data-theme='dark'] .login-page {
.rc-chat-composer-stack {
display: grid;
width: min(100%, 48rem);
width: min(100%, 64rem);
min-height: 0;
flex: none;
margin-inline: auto;
gap: 0.75rem;
}
.rc-chat-empty-state {
gap: 0.75rem;
}
.rc-chat-empty-state h1 {
margin: 0.5rem 0 0;
font-size: 1.75rem;
font-weight: 600;
line-height: 2.25rem;
}
.rc-chat-empty-state p {
margin: 0;
color: var(--muted);
font-size: 1rem;
line-height: 1.5rem;
}
.rc-chat-agent-icon {
color: var(--accent);
}
.rc-chat-agent-icon .anticon {
display: inline-flex;
font-size: 1.5rem;
line-height: 1;
}
.rc-chat-model-select {
width: 12rem;
}
.rc-chat-agent-chip {
height: 100%;
min-height: 2.25rem;
align-self: center;
}
.rc-invoice-workspace {
display: grid;
height: 100%;
......@@ -602,4 +649,12 @@ body [data-theme='dark'] .login-page {
.rc-chat-suggestion-items {
grid-template-columns: minmax(0, 1fr);
}
.rc-chat-agent-chip {
min-height: 2.5rem;
}
.rc-chat-conversation-content--empty {
padding-block-start: 2rem;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment