From a4aa860d04f1b459b1c22c9a586aba0d9cae995a Mon Sep 17 00:00:00 2001 From: lb1038678031 <1038678031@qq.com> Date: Fri, 28 Aug 2026 16:45:04 +0800 Subject: [PATCH 1/2] feat: replace AI chat UI with TDesign React Chat and add homepage chat switches - New AnswerChatBot adapter component wraps @tdesign-react/chat ChatBot with the Answer backend contract: custom SSE parsing (markdown + thinking blocks from reasoning_content), vote actions bridged to the conversation vote API, conversation history rehydration. - The AI assistant page now renders AnswerChatBot; the legacy Sender/Bubble components stay for other pages. - New site AI switches: home_chat_enabled (floating AI entry on the homepage) and home_chat_guest_enabled (logged-out visitors can chat; guest sessions stay client-side and are never persisted, voting off). - POST /chat/completions moves to the unauthenticated route group; the handler enforces the guest switch itself and runs guest sessions fully in memory with a system prompt prepended. - zh/en translations for the new switches and chat strings. --- i18n/en_US.yaml | 6 + i18n/zh_CN.yaml | 8 + internal/controller/ai_controller.go | 36 +++ internal/controller/siteinfo_controller.go | 2 + internal/router/answer_api_router.go | 10 +- internal/schema/siteinfo_schema.go | 18 +- ui/.npmrc | 3 +- ui/package.json | 3 + ui/src/common/interface.ts | 4 + ui/src/components/AnswerChatBot/index.tsx | 259 +++++++++++++++++++++ ui/src/components/HomeChatWidget/index.tsx | 167 +++++++++++++ ui/src/pages/Admin/AiSettings/index.tsx | 66 ++++++ ui/src/pages/AiAssistant/index.tsx | 222 ++---------------- ui/src/pages/Questions/index.tsx | 2 + ui/src/stores/aiControl.ts | 23 +- ui/src/utils/guard.ts | 2 + 16 files changed, 620 insertions(+), 211 deletions(-) create mode 100644 ui/src/components/AnswerChatBot/index.tsx create mode 100644 ui/src/components/HomeChatWidget/index.tsx diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5d1faa3e0..d312e2dee 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -2347,6 +2347,12 @@ ui: label: AI enabled check: Enable AI features text: The AI model must be configured correctly before it can be used. + home_chat: + label: Show AI assistant on homepage + tip: Shows a floating AI assistant entry at the bottom-right of the homepage. + home_chat_guest: + label: Allow guests to use the homepage AI assistant + tip: Logged-out visitors can chat with the homepage AI assistant; guest conversations stay in the browser and are never saved. provider: label: Provider api_host: diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index f16ed9fad..812f6ba21 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -856,6 +856,8 @@ ui: copy: 复制 ask_a_follow_up: 提出后续问题 ask_placeholder: 提问 + thinking: 思考中… + thoughts: 思考过程 notifications: title: 通知 inbox: 收件箱 @@ -2305,6 +2307,12 @@ ui: label: AI 已启用 check: 启用AI功能 text: AI 模型必须正确配置才能使用。 + home_chat: + label: 首页展示 AI 助手 + tip: 开启后将在网站首页右下角展示 AI 助手悬浮入口。 + home_chat_guest: + label: 允许游客使用首页 AI 助手 + tip: 开启后未登录访客也可使用首页 AI 助手;游客会话仅保留在浏览器中,不会被保存。 provider: label: 提供商 api_host: diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go index e7495253b..6d01b78b2 100644 --- a/internal/controller/ai_controller.go +++ b/internal/controller/ai_controller.go @@ -211,6 +211,14 @@ func (c *AIController) ChatCompletions(ctx *gin.Context) { } req.UserID = middleware.GetLoginUserIDFromContext(ctx) + // The route is registered as unauthenticated so logged-out visitors can + // use the homepage AI chat; guests are only served when the admin enables + // guest access, and their conversations are never persisted. + if req.UserID == "" && !aiConfig.HomeChatGuestEnabled { + handler.HandleResponse(ctx, errors.Unauthorized("guest access to AI chat is not enabled"), nil) + return + } + data, _ := json.Marshal(req) log.Infof("ai chat request data: %s", string(data)) @@ -351,6 +359,34 @@ func (c *AIController) initializeConversationContext(ctx *gin.Context, model str Model: model, } + // Guest session: storage is never touched. The client sends the whole + // page-side history in the request, and a system prompt is prepended for + // the assistant to work with MCP tools. + if req.UserID == "" { + conversationCtx.IsNewConversation = true + question := "" + for i := len(req.Messages) - 1; i >= 0; i-- { + if req.Messages[i].Role == openai.ChatMessageRoleUser { + question = req.Messages[i].Content + break + } + } + conversationCtx.UserQuestion = question + currentLang := handler.GetLangByCtx(ctx) + prompt := c.getPromptByLanguage(currentLang, question) + conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{ + Role: openai.ChatMessageRoleSystem, + Content: prompt, + }) + for _, m := range req.Messages { + conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{ + Role: m.Role, + Content: m.Content, + }) + } + return conversationCtx + } + conversationDetail, exist, err := c.aiConversationService.GetConversationDetail(ctx, &schema.AIConversationDetailReq{ ConversationID: req.ConversationID, UserID: req.UserID, diff --git a/internal/controller/siteinfo_controller.go b/internal/controller/siteinfo_controller.go index a5dde0234..3d049cd98 100644 --- a/internal/controller/siteinfo_controller.go +++ b/internal/controller/siteinfo_controller.go @@ -112,6 +112,8 @@ func (sc *SiteInfoController) GetSiteInfo(ctx *gin.Context) { } if aiConf, err := sc.siteInfoService.GetSiteAI(ctx); err == nil { resp.AIEnabled = aiConf.Enabled + resp.AIHomeChatEnabled = aiConf.HomeChatEnabled + resp.AIHomeChatGuestEnabled = aiConf.HomeChatGuestEnabled } if mcpConf, err := sc.siteInfoService.GetSiteMCP(ctx); err == nil { diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 84b8b4e1c..3504a9238 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -171,6 +171,10 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/user/ranking", a.userController.UserRanking) r.GET("/user/staff", a.userController.UserStaff) + // AI chat (guest-capable: the handler enforces the admin switches; logged + // in users always work, guests only when home chat guest access is on) + r.POST("/chat/completions", a.aiController.ChatCompletions) + // answer r.GET("/answer/info", a.answerController.GetAnswerInfo) r.GET("/answer/page", a.answerController.AnswerList) @@ -324,10 +328,10 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { // meta r.PUT("/meta/reaction", a.metaController.AddOrUpdateReaction) - // AI chat - r.POST("/chat/completions", a.aiController.ChatCompletions) - // AI conversation + // NOTE: /chat/completions is registered in RegisterUnAuthAnswerAPIRouter + // so logged-out visitors can use the homepage AI chat when the admin + // enables guest access; the handler enforces that switch itself. r.GET("/ai/conversation/page", a.aiConversationController.GetConversationList) r.GET("/ai/conversation", a.aiConversationController.GetConversationDetail) r.POST("/ai/conversation/vote", a.aiConversationController.VoteRecord) diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index 1d0b27ff6..83281a0dd 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -269,10 +269,16 @@ type AIPromptConfig struct { // SiteAIReq AI configuration request type SiteAIReq struct { - Enabled bool `validate:"omitempty" form:"enabled" json:"enabled"` - ChosenProvider string `validate:"omitempty,lte=50" form:"chosen_provider" json:"chosen_provider"` + Enabled bool `validate:"omitempty" form:"enabled" json:"enabled"` + ChosenProvider string `validate:"omitempty,lte=50" form:"chosen_provider" json:"chosen_provider"` SiteAIProviders []*SiteAIProvider `validate:"omitempty,dive" form:"ai_providers" json:"ai_providers"` - PromptConfig *AIPromptConfig `validate:"omitempty" form:"prompt_config" json:"prompt_config,omitempty"` + PromptConfig *AIPromptConfig `validate:"omitempty" form:"prompt_config" json:"prompt_config,omitempty"` + // HomeChatEnabled shows the AI chat entry on the homepage. + HomeChatEnabled bool `validate:"omitempty" form:"home_chat_enabled" json:"home_chat_enabled"` + // HomeChatGuestEnabled lets logged-out visitors use the homepage AI chat. + // Guest conversations are kept in memory client-side only and are never + // persisted, and voting is disabled for them. + HomeChatGuestEnabled bool `validate:"omitempty" form:"home_chat_guest_enabled" json:"home_chat_guest_enabled"` } func (s *SiteAIResp) GetProvider() *SiteAIProvider { @@ -385,8 +391,10 @@ type SiteInfoResp struct { Security *SiteSecurityResp `json:"site_security"` Version string `json:"version"` Revision string `json:"revision"` - AIEnabled bool `json:"ai_enabled"` - MCPEnabled bool `json:"mcp_enabled"` + AIEnabled bool `json:"ai_enabled"` + AIHomeChatEnabled bool `json:"ai_home_chat_enabled"` + AIHomeChatGuestEnabled bool `json:"ai_home_chat_guest_enabled"` + MCPEnabled bool `json:"mcp_enabled"` } type TemplateSiteInfoResp struct { diff --git a/ui/.npmrc b/ui/.npmrc index 885ffc35a..7549542d7 100644 --- a/ui/.npmrc +++ b/ui/.npmrc @@ -1,2 +1 @@ -strict-peer-dependencies = true -auto-install-peers = true +registry=https://registry.npmmirror.com diff --git a/ui/package.json b/ui/package.json index 5abd241ac..87a793bd3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,6 +21,7 @@ "@codemirror/language-data": "^6.5.0", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.26.1", + "@tdesign-react/chat": "^1.0.2", "axios": "^1.7.7", "bootstrap": "^5.3.2", "bootstrap-icons": "^1.10.5", @@ -46,6 +47,8 @@ "react-router-dom": "^7.0.2", "semver": "^7.3.8", "swr": "^1.3.0", + "tdesign-icons-react": "^0.6.10", + "tdesign-react": "^1.18.2", "uuid": "13.0.0", "zustand": "^5.0.2" }, diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 8ab714230..45021b904 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -428,6 +428,8 @@ export interface SiteSettings { revision: string; site_security: AdminSettingsSecurity; ai_enabled: boolean; + ai_home_chat_enabled?: boolean; + ai_home_chat_guest_enabled?: boolean; } export interface AdminSettingBranding { @@ -828,6 +830,8 @@ export interface AddOrEditApiKeyParams { export interface AiConfig { enabled: boolean; + home_chat_enabled?: boolean; + home_chat_guest_enabled?: boolean; chosen_provider: string; ai_providers: Array<{ provider: string; diff --git a/ui/src/components/AnswerChatBot/index.tsx b/ui/src/components/AnswerChatBot/index.tsx new file mode 100644 index 000000000..11dc22b06 --- /dev/null +++ b/ui/src/components/AnswerChatBot/index.tsx @@ -0,0 +1,259 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ChatBot } from '@tdesign-react/chat'; +import type { + AIMessageContent, + ChatMessagesData, + ChatServiceConfig, + SSEChunkData, +} from '@tdesign-react/chat'; +import 'tdesign-react/dist/tdesign.css'; + +import { voteConversation } from '@/services'; +import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants'; +import Storage from '@/utils/storage'; + +export interface ConversationRecordLike { + role: string; + content: string; + reasoning_content?: string; + chat_completion_id?: string; +} + +interface IProps { + conversationId: string; + initialRecords?: ConversationRecordLike[]; + guest?: boolean; + onConversationCreated?: (id: string) => void; + onSend?: (prompt: string) => void; + height?: number | string; + style?: React.CSSProperties; +} + +type StreamChunk = { + id?: string; + choices?: Array<{ + delta?: { role?: string; content?: string; reasoning_content?: string }; + finish_reason?: string | null; + }>; +}; + +const buildGuestHeaders = () => { + const token = Storage.get(LOGGED_TOKEN_STORAGE_KEY) || ''; + return { + Authorization: token, + 'X-Requested-With': 'XMLHttpRequest', + }; +}; + +/** + * AnswerChatBot wraps TDesign React Chat with the Answer backend contract: + * custom SSE framing, reasoning_content -> thinking blocks, guest mode and + * conversation voting. + */ +const AnswerChatBot: React.FC = (props) => { + const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' }); + const chatRef = useRef void; + registerMergeStrategy?: (type: string, handler: (chunk: SSEChunkData, existing?: AIMessageContent) => AIMessageContent) => void; + chatMessageValue?: ChatMessagesData[]; + }>(null); + + const historyLoadedRef = useRef(''); + const lastCompletionIdRef = useRef(''); + // Effective conversation id: falls back to an internally generated one so + // the first send of a brand-new conversation still carries a stable id even + // if the parent page has not updated its route yet. + const internalIdRef = useRef(''); + const effectiveConversationId = () => { + if (props.conversationId) { + return props.conversationId; + } + if (!internalIdRef.current) { + internalIdRef.current = + 'chatcmpl-' + Math.random().toString(36).slice(2) + Date.now().toString(36); + } + return internalIdRef.current; + }; + + const mapRecord = (r: ConversationRecordLike): ChatMessagesData => { + const content: AIMessageContent[] = []; + if (r.reasoning_content) { + content.push({ + type: 'thinking', + data: { text: r.reasoning_content, title: t('thoughts') || 'Thoughts' }, + status: 'complete', + } as AIMessageContent); + } + content.push({ type: 'markdown', data: r.content || '', status: 'complete' }); + return { + id: r.chat_completion_id || String(Math.random()), + role: r.role === 'user' ? 'user' : 'assistant', + status: 'complete', + content, + } as ChatMessagesData; + }; + + // Re-hydrate the chat when switching conversations. + useEffect(() => { + if (!chatRef.current?.setMessages || !props.initialRecords) { + return; + } + const key = JSON.stringify(props.initialRecords.map((r) => r.chat_completion_id)); + if (historyLoadedRef.current === key) { + return; + } + historyLoadedRef.current = key; + chatRef.current.setMessages(props.initialRecords.map(mapRecord), 'replace'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.initialRecords]); + + // Merge streaming thinking chunks, whose default merge appends a new block + // per delta instead of growing the text. + useEffect(() => { + chatRef.current?.registerMergeStrategy?.('thinking', (chunk, existing) => { + const incoming = (chunk.data as { text?: string })?.text || ''; + const prev = (existing?.data as { text?: string }) || {}; + return { + ...(existing || {}), + type: 'thinking', + data: { ...prev, text: (prev.text || '') + incoming }, + } as AIMessageContent; + }); + }, []); + + const chatServiceConfig = useMemo(() => { + const config: ChatServiceConfig = { + endpoint: '/answer/api/v1/chat/completions', + stream: true, + onRequest: (params) => { + const history = (chatRef.current?.chatMessageValue || []) as ChatMessagesData[]; + const historyMessages = history + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ + role: m.role, + content: (m.content || []) + .filter((c) => c.type === 'text' || c.type === 'markdown') + .map((c) => (c.data as string) || '') + .join(''), + })) + .filter((m) => m.content); + const messages = props.guest + ? [...historyMessages, { role: 'user', content: params.prompt }] + : [{ role: 'user', content: params.prompt }]; + const cid = effectiveConversationId(); + if (!props.conversationId && props.onConversationCreated) { + props.onConversationCreated(cid); + } + return { + headers: buildGuestHeaders(), + body: JSON.stringify({ + conversation_id: cid, + messages, + }), + }; + }, + onMessage: (chunk: SSEChunkData): AIMessageContent | null => { + const res = (chunk.data || {}) as StreamChunk; + if (res.id) { + lastCompletionIdRef.current = res.id; + } + const delta = res?.choices?.[0]?.delta; + if (!delta) { + return null; + } + if (delta.reasoning_content) { + return { + type: 'thinking', + data: { text: delta.reasoning_content, title: t('thinking') || 'Thinking…' }, + status: 'streaming', + id: `${res.id}-thinking`, + } as AIMessageContent; + } + if (delta.content) { + return { + type: 'markdown', + data: delta.content, + status: 'streaming', + strategy: 'merge', + id: `${res.id}-answer`, + } as AIMessageContent; + } + return null; + }, + onComplete: () => {}, + onAbort: async () => {}, + onError: (err) => { + console.error('AI chat error:', err); + }, + }; + return config; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.conversationId, props.guest]); + + const messageProps = useMemo(() => { + return { + user: { variant: 'base', placement: 'right' }, + assistant: { + placement: 'left', + actions: props.guest ? ['copy', 'replay'] : ['copy', 'good', 'bad', 'replay'], + handleActions: { + good: () => { + if (!props.guest && lastCompletionIdRef.current) { + voteConversation({ + cancel: false, + vote_type: 'helpful', + chat_completion_id: lastCompletionIdRef.current, + }).catch(() => {}); + } + }, + bad: () => { + if (!props.guest && lastCompletionIdRef.current) { + voteConversation({ + cancel: false, + vote_type: 'unhelpful', + chat_completion_id: lastCompletionIdRef.current, + }).catch(() => {}); + } + }, + }, + }, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.guest]); + + return ( + { + // noop: keep engine flow; page-level hooks run through onRequest + }} + /> + ); +}; + +export default AnswerChatBot; diff --git a/ui/src/components/HomeChatWidget/index.tsx b/ui/src/components/HomeChatWidget/index.tsx new file mode 100644 index 000000000..0defed132 --- /dev/null +++ b/ui/src/components/HomeChatWidget/index.tsx @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { Suspense, lazy, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { loggedUserInfoStore, aiControlStore } from '@/stores'; +import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants'; +import { Storage } from '@/utils'; + +const AnswerChatBot = lazy(() => import('@/components/AnswerChatBot')); + +/** + * Floating AI chat entry on the homepage. Rendered only when the admin turns + * on the homepage chat; logged-out visitors need the guest switch as well. + * The panel mounts the same AnswerChatBot used by the assistant page, in a + * lightweight single-conversation mode. + */ +const HomeChatWidget: React.FC = () => { + const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' }); + const { t: tPage } = useTranslation('translation', { keyPrefix: 'page_title' }); + const { ai_enabled, ai_home_chat_enabled, ai_home_chat_guest_enabled } = + aiControlStore((s) => s); + const { user } = loggedUserInfoStore((s) => s); + const [open, setOpen] = useState(false); + const [conversationId, setConversationId] = useState(''); + + if (!ai_enabled || !ai_home_chat_enabled) { + return null; + } + const isLogged = !!user?.id && !!Storage.get(LOGGED_TOKEN_STORAGE_KEY); + if (!isLogged && !ai_home_chat_guest_enabled) { + return null; + } + + const newConversation = () => { + setConversationId( + 'chatcmpl-' + Math.random().toString(36).slice(2) + Date.now().toString(36), + ); + }; + + return ( +
+ {open && ( +
+
+ {tPage('ai_assistant')} +
+ + +
+
+
+
+ }> + { + if (!conversationId) { + setConversationId(id); + } + }} + guest={!isLogged} + height="100%" + /> + +
+
+ )} + + + ); +}; + +export default HomeChatWidget; diff --git a/ui/src/pages/Admin/AiSettings/index.tsx b/ui/src/pages/Admin/AiSettings/index.tsx index 2270aa5c5..8f0a48e5f 100644 --- a/ui/src/pages/Admin/AiSettings/index.tsx +++ b/ui/src/pages/Admin/AiSettings/index.tsx @@ -47,6 +47,16 @@ const Index = () => { isInvalid: false, errorMsg: '', }, + home_chat_enabled: { + value: false, + isInvalid: false, + errorMsg: '', + }, + home_chat_guest_enabled: { + value: false, + isInvalid: false, + errorMsg: '', + }, provider: { value: '', isInvalid: false, @@ -225,6 +235,8 @@ const Index = () => { const params = { enabled: formData.enabled.value, + home_chat_enabled: !!formData.home_chat_enabled.value, + home_chat_guest_enabled: !!formData.home_chat_guest_enabled.value, chosen_provider: formData.provider.value, ai_providers: newProviders, }; @@ -232,6 +244,8 @@ const Index = () => { .then(() => { aiControlStore.getState().update({ ai_enabled: formData.enabled.value, + ai_home_chat_enabled: !!formData.home_chat_enabled.value, + ai_home_chat_guest_enabled: !!formData.home_chat_guest_enabled.value, }); historyConfigRef.current = { @@ -274,6 +288,16 @@ const Index = () => { isInvalid: false, errorMsg: '', }, + home_chat_enabled: { + value: !!aiConfig.home_chat_enabled, + isInvalid: false, + errorMsg: '', + }, + home_chat_guest_enabled: { + value: !!aiConfig.home_chat_guest_enabled, + isInvalid: false, + errorMsg: '', + }, provider: { value: currentAiConfig?.provider || '', isInvalid: false, @@ -350,6 +374,48 @@ const Index = () => { + + + handleValueChange({ + home_chat_enabled: { + value: e.target.checked, + errorMsg: '', + isInvalid: false, + }, + }) + } + /> + + {t('home_chat.tip')} + + + + + + handleValueChange({ + home_chat_guest_enabled: { + value: e.target.checked, + errorMsg: '', + isInvalid: false, + }, + }) + } + /> + + {t('home_chat_guest.tip')} + + + {t('provider.label')} { const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' }); const [isShowConversationList, setIsShowConversationList] = useState(false); - const [isGenerate, setIsGenerate] = useState(false); - const [isLoading, setIsLoading] = useState(false); const [recentNewItem, setRecentNewItem] = useState(null); const [conversions, setConversions] = useState({ records: [], @@ -54,7 +52,6 @@ const Index = () => { }); const navigate = useNavigate(); const { id = '' } = useParams<{ id: string }>(); - const [temporaryBottomSpace, setTemporaryBottomSpace] = useState(0); const [conversationsPage, setConversationsPage] = useState(1); const [conversationsList, setConversationsList] = useState<{ @@ -65,17 +62,7 @@ const Index = () => { list: [], }); - const calculateTemporarySpace = () => { - const viewportHeight = window.innerHeight; - const navHeight = 64; - const senderHeight = (document.querySelector('.sender-wrap') as HTMLElement) - ?.offsetHeight; - const neededSpace = viewportHeight - senderHeight - navHeight - 120; - const height = neededSpace; - console.log('lasMsgHeight', height); - - setTemporaryBottomSpace(height); - }; + const guest = !Storage.get(LOGGED_TOKEN_STORAGE_KEY); const resetPageState = () => { setConversions({ @@ -85,7 +72,6 @@ const Index = () => { topic: '', updated_at: 0, }); - setIsGenerate(false); setRecentNewItem(null); }; @@ -100,138 +86,12 @@ const Index = () => { }); }; - const handleSubmit = async (userMsg) => { - setIsLoading(true); - if (conversions?.records.length === 0) { - setRecentNewItem({ - conversation_id: id, - topic: userMsg, - }); - } - const chatId = Date.now(); - setConversions((prev) => ({ - ...prev, - topic: userMsg, - conversation_id: id, - records: [ - ...prev.records, - { - id: chatId, - role: 'user', - content: userMsg, - chat_completion_id: String(chatId), // Add required properties - helpful: 0, - unhelpful: 0, - created_at: chatId, - }, - ], - })); - - // scroll to user message after the page height is stable - requestAnimationFrame(() => { - const userBubbles = document.querySelectorAll('.bubble-user-wrap'); - const lastUserBubble = userBubbles[userBubbles.length - 1]; - if (lastUserBubble) { - lastUserBubble.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - } - }); - - calculateTemporarySpace(); - - const params = { - conversation_id: id, - messages: [ - { - role: 'user', - content: userMsg, - }, - ], - }; - - await requestAi('/answer/api/v1/chat/completions', { - body: JSON.stringify(params), - onMessage: (res) => { - const delta = res.choices[0]?.delta; - const deltaContent = delta?.content || ''; - const deltaReasoning = delta?.reasoning_content || ''; - if (!deltaContent && !deltaReasoning) { - return; - } - setIsLoading(false); - setIsGenerate(true); - setConversions((prev) => { - const updatedRecords = [...prev.records]; - const lastConversion = updatedRecords[updatedRecords.length - 1]; - if (lastConversion?.chat_completion_id === res?.chat_completion_id) { - updatedRecords[updatedRecords.length - 1] = { - ...lastConversion, - content: (lastConversion.content || '') + deltaContent, - reasoning_content: - (lastConversion.reasoning_content || '') + deltaReasoning, - }; - } else { - updatedRecords.push({ - chat_completion_id: res.chat_completion_id, - role: delta?.role || 'assistant', - content: deltaContent, - reasoning_content: deltaReasoning, - helpful: 0, - unhelpful: 0, - created_at: Date.now(), - }); - } - return { - ...prev, - conversation_id: params.conversation_id, - records: updatedRecords, - }; - }); - }, - onError: (error) => { - setIsLoading(false); - setIsGenerate(false); - console.error('Error:', error); - }, - onComplete: () => { - setIsGenerate(false); - setIsLoading(false); - }, - }); - }; - - const handleSender = (userMsg) => { - if (conversions?.records.length <= 0) { - const newConversationId = uuidv4(); - navigate(`/ai-assistant/${newConversationId}`); - Storage.set('_a_once_msg', userMsg); - } else { - handleSubmit(userMsg); - } - }; - - const handleCancel = () => { - if (cancelCurrentRequest()) { - setIsGenerate(false); - } - }; - usePageTags({ title: conversions?.topic || t('ai_assistant', { keyPrefix: 'page_title' }), }); useEffect(() => { if (id) { - const msg = Storage.get('_a_once_msg'); - Storage.remove('_a_once_msg'); - if (msg) { - if (msg) { - handleSubmit(msg); - } - return; - } fetchDetail(); } else { resetPageState(); @@ -317,62 +177,28 @@ const Index = () => { !conversions?.conversation_id ? 'justify-content-center' : '', )} style={{ maxWidth: '772px' }}> - {conversions?.records.length > 0 && ( -
- {conversions?.records.map((item, index) => { - const isLastMessage = - index === Number(conversions?.records.length) - 1; - return ( -
- {item.role === 'user' ? ( - - ) : ( - - )} -
- ); - })} - - {temporaryBottomSpace > 0 && isLoading && ( -
- {isLoading && ( - - )} -
- )} -
- )} {conversions?.conversation_id ? null : (
{t('description')}
)} - +
+ { + if (id !== cid) { + navigate(`/ai-assistant/${cid}`, { replace: true }); + } + }} + height="100%" + /> +
{isShowConversationList && ( diff --git a/ui/src/pages/Questions/index.tsx b/ui/src/pages/Questions/index.tsx index 7239feb66..06f1c8dab 100644 --- a/ui/src/pages/Questions/index.tsx +++ b/ui/src/pages/Questions/index.tsx @@ -29,6 +29,7 @@ import { HotQuestions, CustomSidebar, } from '@/components'; +import HomeChatWidget from '@/components/HomeChatWidget'; import { siteInfoStore, loggedUserInfoStore, @@ -113,6 +114,7 @@ const Questions: FC = () => { {loggedUser.access_token && } + ); }; diff --git a/ui/src/stores/aiControl.ts b/ui/src/stores/aiControl.ts index c9f0afbc7..46852930f 100644 --- a/ui/src/stores/aiControl.ts +++ b/ui/src/stores/aiControl.ts @@ -21,20 +21,37 @@ import { create } from 'zustand'; interface AiControlStore { ai_enabled: boolean; - update: (params: { ai_enabled: boolean }) => void; + ai_home_chat_enabled: boolean; + ai_home_chat_guest_enabled: boolean; + update: (params: { + ai_enabled: boolean; + ai_home_chat_enabled?: boolean; + ai_home_chat_guest_enabled?: boolean; + }) => void; reset: () => void; } const aiControlStore = create((set) => ({ ai_enabled: false, - update: (params: { ai_enabled: boolean }) => + ai_home_chat_enabled: false, + ai_home_chat_guest_enabled: false, + update: (params: { + ai_enabled: boolean; + ai_home_chat_enabled?: boolean; + ai_home_chat_guest_enabled?: boolean; + }) => set((state) => { return { ...state, ...params, }; }), - reset: () => set({ ai_enabled: false }), + reset: () => + set({ + ai_enabled: false, + ai_home_chat_enabled: false, + ai_home_chat_guest_enabled: false, + }), })); export default aiControlStore; diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts index fc78fa122..da0006d7a 100644 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@ -389,6 +389,8 @@ export const initAppSettingsStore = async () => { }); aiControlStore.getState().update({ ai_enabled: appSettings.ai_enabled, + ai_home_chat_enabled: appSettings.ai_home_chat_enabled ?? false, + ai_home_chat_guest_enabled: appSettings.ai_home_chat_guest_enabled ?? false, }); siteSecurityStore.getState().update(appSettings.site_security); } From 10aa3464b7d8b620bcb0f92c20ceed4541a59230 Mon Sep 17 00:00:00 2001 From: lb1038678031 <1038678031@qq.com> Date: Sat, 29 Aug 2026 11:23:28 +0800 Subject: [PATCH 2/2] feat(ai): admin-configurable welcome text, suggested questions and system prompt - Expose the assistant welcome text and a suggested-questions list (one per line) as site AI settings; both render inside the single welcome message as tappable chips, matching the TDesign reference layout. - Surface the existing prompt_config (zh/en system prompt) in the admin AI settings form, and always round-trip it on save so the backend no longer silently resets configured prompts to the built-in defaults. - Localize new admin labels and assistant strings (zh_CN/en_US). - apply refreshed language packs via i18next.addResourceBundle as soon as they are fetched instead of waiting for the next full page load. - fix new-chat reset: remount the ChatBot instead of swapping the omi message store in place, which crashed the action-bar re-render for conversations with completed messages; abort in-flight streams on reset and unmount. - fix suggestion chips: deliver clicks through per-message handleActions (the installed tdesign-web-components has no directSend support). --- i18n/en_US.yaml | 18 ++ i18n/zh_CN.yaml | 18 ++ internal/controller/siteinfo_controller.go | 2 + internal/schema/siteinfo_schema.go | 9 + ui/src/common/interface.ts | 8 + ui/src/components/AnswerChatBot/index.tsx | 316 +++++++++++++++++---- ui/src/components/HomeChatWidget/index.tsx | 9 +- ui/src/pages/Admin/AiSettings/index.tsx | 82 ++++++ ui/src/pages/AiAssistant/index.tsx | 13 +- ui/src/stores/aiControl.ts | 10 + ui/src/utils/guard.ts | 2 + ui/src/utils/localize.ts | 3 + 12 files changed, 427 insertions(+), 63 deletions(-) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index d312e2dee..bb2803027 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -864,6 +864,13 @@ ui: show_more: Show more new: New chat ai_generate: AI-generated from posts and may not be accurate. + ai_disclaimer: Generated by AI, for reference only. Please verify critical information. + ai_name: AI Assistant + request_failed: Request failed, please try again later. + suggestion_1: What is this website for? + suggestion_2: How do I ask a new question? + suggestion_3: How do I search for content I'm interested in? + suggestion_4: How do I follow or bookmark a question? copy: Copy ask_a_follow_up: Ask a follow-up ask_placeholder: Ask a question @@ -2353,6 +2360,17 @@ ui: home_chat_guest: label: Allow guests to use the homepage AI assistant tip: Logged-out visitors can chat with the homepage AI assistant; guest conversations stay in the browser and are never saved. + welcome_text: + label: AI assistant welcome text + tip: The description shown at the start of the AI assistant. Leave empty to use the default. + initial_messages: + label: Suggested questions + tip: One question per line, shown as tappable suggestion chips below the welcome text. Leave empty to use the built-in suggestions. + prompt_config: + label: System prompt + zh_placeholder: Chinese system prompt (leave empty for default) + en_placeholder: English system prompt (leave empty for default) + tip: The system prompt sent to the AI model. Use %s as the user question placeholder; leave empty to use the built-in default. provider: label: Provider api_host: diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index 812f6ba21..390a4e0b4 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -853,6 +853,13 @@ ui: show_more: 显示更多 new: 新聊天 ai_generate: 来自帖子的 AI,可能不准确。 + ai_disclaimer: 内容由 AI 生成,仅供参考,请自行甄别 + ai_name: AI 助手 + request_failed: 请求失败,请稍后重试 + suggestion_1: 这个网站是做什么的? + suggestion_2: 如何提出一个新问题? + suggestion_3: 如何搜索我感兴趣的内容? + suggestion_4: 如何关注或收藏问题? copy: 复制 ask_a_follow_up: 提出后续问题 ask_placeholder: 提问 @@ -2313,6 +2320,17 @@ ui: home_chat_guest: label: 允许游客使用首页 AI 助手 tip: 开启后未登录访客也可使用首页 AI 助手;游客会话仅保留在浏览器中,不会被保存。 + welcome_text: + label: AI 助手欢迎语 + tip: 显示在 AI 助手开场的提示语,留空使用默认文案。 + initial_messages: + label: 建议问题列表 + tip: 每行一条,展示为欢迎语下方的建议问题,点击即可发送;留空使用默认建议问题。 + prompt_config: + label: 系统提示词 + zh_placeholder: 中文系统提示词(留空使用默认) + en_placeholder: English system prompt (leave empty for default) + tip: 发送给 AI 模型的系统提示词,可用 %s 代表用户问题;留空使用内置默认提示词。 provider: label: 提供商 api_host: diff --git a/internal/controller/siteinfo_controller.go b/internal/controller/siteinfo_controller.go index 3d049cd98..ffe9200e0 100644 --- a/internal/controller/siteinfo_controller.go +++ b/internal/controller/siteinfo_controller.go @@ -114,6 +114,8 @@ func (sc *SiteInfoController) GetSiteInfo(ctx *gin.Context) { resp.AIEnabled = aiConf.Enabled resp.AIHomeChatEnabled = aiConf.HomeChatEnabled resp.AIHomeChatGuestEnabled = aiConf.HomeChatGuestEnabled + resp.AIWelcomeText = aiConf.WelcomeText + resp.AIInitialMessages = aiConf.InitialMessages } if mcpConf, err := sc.siteInfoService.GetSiteMCP(ctx); err == nil { diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index 83281a0dd..1c85f56bb 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -279,6 +279,13 @@ type SiteAIReq struct { // Guest conversations are kept in memory client-side only and are never // persisted, and voting is disabled for them. HomeChatGuestEnabled bool `validate:"omitempty" form:"home_chat_guest_enabled" json:"home_chat_guest_enabled"` + // WelcomeText overrides the assistant welcome description shown in a + // fresh conversation. Empty keeps the built-in i18n text. + WelcomeText string `validate:"omitempty,lte=500" form:"ai_welcome_text" json:"ai_welcome_text"` + // InitialMessages holds admin-configured assistant messages (one per + // line, markdown allowed) rendered in a fresh conversation. When set, + // the built-in suggestion chips are hidden. + InitialMessages string `validate:"omitempty,lte=5000" form:"ai_initial_messages" json:"ai_initial_messages"` } func (s *SiteAIResp) GetProvider() *SiteAIProvider { @@ -394,6 +401,8 @@ type SiteInfoResp struct { AIEnabled bool `json:"ai_enabled"` AIHomeChatEnabled bool `json:"ai_home_chat_enabled"` AIHomeChatGuestEnabled bool `json:"ai_home_chat_guest_enabled"` + AIWelcomeText string `json:"ai_welcome_text"` + AIInitialMessages string `json:"ai_initial_messages"` MCPEnabled bool `json:"mcp_enabled"` } diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 45021b904..77a1b193a 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -430,6 +430,8 @@ export interface SiteSettings { ai_enabled: boolean; ai_home_chat_enabled?: boolean; ai_home_chat_guest_enabled?: boolean; + ai_welcome_text?: string; + ai_initial_messages?: string; } export interface AdminSettingBranding { @@ -833,6 +835,12 @@ export interface AiConfig { home_chat_enabled?: boolean; home_chat_guest_enabled?: boolean; chosen_provider: string; + ai_welcome_text?: string; + ai_initial_messages?: string; + prompt_config?: { + zh_cn?: string; + en_us?: string; + }; ai_providers: Array<{ provider: string; api_host: string; diff --git a/ui/src/components/AnswerChatBot/index.tsx b/ui/src/components/AnswerChatBot/index.tsx index 11dc22b06..77208cfd3 100644 --- a/ui/src/components/AnswerChatBot/index.tsx +++ b/ui/src/components/AnswerChatBot/index.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChatBot } from '@tdesign-react/chat'; @@ -28,8 +28,13 @@ import type { SSEChunkData, } from '@tdesign-react/chat'; import 'tdesign-react/dist/tdesign.css'; +// Base + chat design tokens (--td-* and --td-chat-*). Without it every chat +// style that reads a var() falls back to its initial value (no borders, +// transparent backgrounds) and the whole chat renders unstyled. +import '@tdesign-react/chat/es/style/index.js'; import { voteConversation } from '@/services'; +import { loggedUserInfoStore, aiControlStore } from '@/stores'; import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants'; import Storage from '@/utils/storage'; @@ -45,9 +50,11 @@ interface IProps { initialRecords?: ConversationRecordLike[]; guest?: boolean; onConversationCreated?: (id: string) => void; - onSend?: (prompt: string) => void; height?: number | string; style?: React.CSSProperties; + /** Children are forwarded to the underlying ChatBot for slot customization + * (e.g.
). */ + children?: React.ReactNode; } type StreamChunk = { @@ -58,6 +65,14 @@ type StreamChunk = { }>; }; +/** Inline SVG avatar for the assistant (no extra asset bundling needed). */ +const ASSISTANT_AVATAR = `data:image/svg+xml;utf8,${encodeURIComponent( + '', +)}`; + +/** Welcome message id — excluded from request history and action bars. */ +const WELCOME_ID = 'welcome'; + const buildGuestHeaders = () => { const token = Storage.get(LOGGED_TOKEN_STORAGE_KEY) || ''; return { @@ -66,21 +81,42 @@ const buildGuestHeaders = () => { }; }; +/** + * The completion id is carried on every streaming content block id as + * `-answer` / `-thinking`, so each + * message can be voted/replayed on its own instead of only the latest one. + */ +const extractCompletionId = (msg?: ChatMessagesData): string => { + const content = ((msg?.content || []) as AIMessageContent[]) || []; + const block = content.find( + (c) => + typeof (c as { id?: string })?.id === 'string' && + /-(answer|thinking)$/.test((c as { id?: string }).id as string), + ); + const blockId = (block as { id?: string } | undefined)?.id || ''; + return blockId.replace(/-(answer|thinking)$/, ''); +}; + /** * AnswerChatBot wraps TDesign React Chat with the Answer backend contract: - * custom SSE framing, reasoning_content -> thinking blocks, guest mode and - * conversation voting. + * custom SSE framing, reasoning_content -> thinking blocks, guest mode, + * per-message voting/replay and a suggestion-driven welcome screen. */ const AnswerChatBot: React.FC = (props) => { const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' }); + const userInfo = loggedUserInfoStore((s) => s.user); const chatRef = useRef void; - registerMergeStrategy?: (type: string, handler: (chunk: SSEChunkData, existing?: AIMessageContent) => AIMessageContent) => void; + registerMergeStrategy?: (type: string, handler: (chunk: SSEChunkData, existing?: AIMessageContent) => AIMessageContent) => AIMessageContent; chatMessageValue?: ChatMessagesData[]; + sendSystemMessage?: (msg: string) => void; + sendUserMessage?: (params: { prompt: string }) => Promise; + abortChat?: () => Promise; + regenerate?: (keepVersion?: boolean) => Promise; }>(null); + const voteMapRef = useRef>({}); const historyLoadedRef = useRef(''); - const lastCompletionIdRef = useRef(''); // Effective conversation id: falls back to an internally generated one so // the first send of a brand-new conversation still carries a stable id even // if the parent page has not updated its route yet. @@ -98,25 +134,80 @@ const AnswerChatBot: React.FC = (props) => { const mapRecord = (r: ConversationRecordLike): ChatMessagesData => { const content: AIMessageContent[] = []; + const cid = r.chat_completion_id || ''; if (r.reasoning_content) { content.push({ type: 'thinking', + id: cid ? `${cid}-thinking` : undefined, data: { text: r.reasoning_content, title: t('thoughts') || 'Thoughts' }, status: 'complete', } as AIMessageContent); } - content.push({ type: 'markdown', data: r.content || '', status: 'complete' }); + content.push({ + type: 'markdown', + id: cid ? `${cid}-answer` : undefined, + data: r.content || '', + status: 'complete', + } as AIMessageContent); return { - id: r.chat_completion_id || String(Math.random()), + id: cid || String(Math.random()), role: r.role === 'user' ? 'user' : 'assistant', status: 'complete', content, } as ChatMessagesData; }; - // Re-hydrate the chat when switching conversations. + // Welcome screen, rendered as ONE assistant message (like the official + // TDesign demo): description text followed by suggestion chips. Chip data + // comes from the admin "initial messages" lines; falls back to the + // built-in i18n suggestions. + const welcomeText = aiControlStore((s) => s.ai_welcome_text); + const initialMessages = aiControlStore((s) => s.ai_initial_messages); + const welcomeMessages = useMemo(() => { + const chips = (initialMessages || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => ({ title: line, prompt: line })); + const suggestions = chips.length + ? chips + : [1, 2, 3, 4] + .map((i) => { + const title = t(`suggestion_${i}`); + return title ? { title, prompt: title } : null; + }) + .filter((s): s is { title: string; prompt: string } => !!s); + const content: AIMessageContent[] = [ + { type: 'markdown', data: welcomeText || t('description') || '', status: 'complete' }, + ]; + if (suggestions.length) { + content.push({ + type: 'suggestion', + data: suggestions, + status: 'complete', + } as AIMessageContent); + } + return [ + { + id: WELCOME_ID, + role: 'assistant', + status: 'complete', + content, + }, + ] as ChatMessagesData[]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [welcomeText, initialMessages]); + + // Remount counter for the ChatBot element (bumped on "new chat" resets). + const [mountKey, setMountKey] = useState(0); + + // Re-hydrate the chat when switching conversations or after a remount. The + // welcome message is only replaced when the conversation has records. useEffect(() => { - if (!chatRef.current?.setMessages || !props.initialRecords) { + if (!chatRef.current?.setMessages) { + return; + } + if (!props.initialRecords?.length) { return; } const key = JSON.stringify(props.initialRecords.map((r) => r.chat_completion_id)); @@ -126,10 +217,42 @@ const AnswerChatBot: React.FC = (props) => { historyLoadedRef.current = key; chatRef.current.setMessages(props.initialRecords.map(mapRecord), 'replace'); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.initialRecords]); + }, [props.initialRecords, mountKey]); + + // Reset to the welcome screen when the parent starts a fresh conversation + // ("new chat": id -> '' in the page, or a regenerated id in the widget). + // A '' -> id transition is the first send assigning its id — keep the chat. + // + // The reset REMOUNTS the ChatBot (key bump) instead of calling + // setMessages(): swapping the omi message store in place crashes the + // action-bar re-render for conversations with completed messages. + const prevCidRef = useRef(props.conversationId); + useEffect(() => { + const prev = prevCidRef.current; + prevCidRef.current = props.conversationId; + const isFreshStart = + !props.conversationId || (prev && prev !== props.conversationId); + if (!isFreshStart) { + return; + } + internalIdRef.current = ''; + voteMapRef.current = {}; + historyLoadedRef.current = ''; + chatRef.current?.abortChat?.()?.catch?.(() => {}); + setMountKey((k) => k + 1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.conversationId]); + + // Never leave a stream running after the chat unmounts. + useEffect(() => { + return () => { + chatRef.current?.abortChat?.()?.catch?.(() => {}); + }; + }, []); // Merge streaming thinking chunks, whose default merge appends a new block - // per delta instead of growing the text. + // per delta instead of growing the text. Re-registered on every remount + // because the ChatBot element (and its engine) is brand new. useEffect(() => { chatRef.current?.registerMergeStrategy?.('thinking', (chunk, existing) => { const incoming = (chunk.data as { text?: string })?.text || ''; @@ -140,27 +263,91 @@ const AnswerChatBot: React.FC = (props) => { data: { ...prev, text: (prev.text || '') + incoming }, } as AIMessageContent; }); - }, []); + }, [mountKey]); + + // Suggestion chips click -> send as a normal user message. The installed + // web-components version has no directSend support: the click only reaches + // this handler with { event, content }, so the send is driven here. + const handleSuggestionClick = (data?: unknown) => { + const content = (data as { content?: { prompt?: string; title?: string } }) + ?.content; + const prompt = content?.prompt || content?.title || ''; + if (prompt) { + chatRef.current?.sendUserMessage?.({ prompt })?.catch?.(() => {}); + } + }; + + const handleVote = (msg: ChatMessagesData, voteType: 'helpful' | 'unhelpful') => { + if (props.guest) { + return; + } + const cid = extractCompletionId(msg); + if (!cid) { + return; + } + const cancel = voteMapRef.current[cid] === voteType; + voteConversation({ + cancel, + vote_type: voteType, + chat_completion_id: cid, + }) + .then(() => { + voteMapRef.current = { + ...voteMapRef.current, + [cid]: cancel ? '' : voteType, + }; + }) + .catch(() => {}); + }; + + const handleReplay = (msg: ChatMessagesData) => { + const list = (chatRef.current?.chatMessageValue || []) as ChatMessagesData[]; + if (!list.length) { + return; + } + const last = list[list.length - 1]; + const isLastAssistant = + last?.id === msg?.id || extractCompletionId(last) === extractCompletionId(msg); + if (isLastAssistant && last?.role === 'assistant') { + chatRef.current?.regenerate?.(false)?.catch?.(() => {}); + } + }; const chatServiceConfig = useMemo(() => { const config: ChatServiceConfig = { endpoint: '/answer/api/v1/chat/completions', stream: true, onRequest: (params) => { - const history = (chatRef.current?.chatMessageValue || []) as ChatMessagesData[]; - const historyMessages = history - .filter((m) => m.role === 'user' || m.role === 'assistant') + const history = ((chatRef.current?.chatMessageValue || []) as ChatMessagesData[]) + .filter( + (m) => + !(typeof m.id === 'string' && m.id.startsWith(WELCOME_ID)) && + (m.role === 'user' || m.role === 'assistant'), + ) .map((m) => ({ role: m.role, - content: (m.content || []) + content: (((m.content || []) as AIMessageContent[]) || []) .filter((c) => c.type === 'text' || c.type === 'markdown') .map((c) => (c.data as string) || '') .join(''), })) .filter((m) => m.content); - const messages = props.guest - ? [...historyMessages, { role: 'user', content: params.prompt }] - : [{ role: 'user', content: params.prompt }]; + const prompt = params.prompt || ''; + let messages = [...history]; + if (props.guest) { + // Replays resubmit the last question which is already in history — + // avoid appending it twice. + const lastUser = [...history].reverse().find((m) => m.role === 'user'); + const isReplay = lastUser?.content === prompt; + if (isReplay && messages.length && messages[messages.length - 1].role === 'assistant') { + messages = messages.slice(0, -1); + } + if (!isReplay) { + messages = [...messages, { role: 'user', content: prompt }]; + } + } else { + messages = [{ role: 'user', content: prompt }]; + } const cid = effectiveConversationId(); if (!props.conversationId && props.onConversationCreated) { props.onConversationCreated(cid); @@ -175,9 +362,6 @@ const AnswerChatBot: React.FC = (props) => { }, onMessage: (chunk: SSEChunkData): AIMessageContent | null => { const res = (chunk.data || {}) as StreamChunk; - if (res.id) { - lastCompletionIdRef.current = res.id; - } const delta = res?.choices?.[0]?.delta; if (!delta) { return null; @@ -203,56 +387,78 @@ const AnswerChatBot: React.FC = (props) => { }, onComplete: () => {}, onAbort: async () => {}, - onError: (err) => { - console.error('AI chat error:', err); + onError: () => { + chatRef.current?.sendSystemMessage?.( + t('request_failed') || 'Request failed, please try again later.', + ); }, }; return config; // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.conversationId, props.guest]); + // Per-message config: avatars/names per role, per-message votes and replay + // (the completion id is recovered from the message's own content blocks). const messageProps = useMemo(() => { - return { - user: { variant: 'base', placement: 'right' }, - assistant: { - placement: 'left', - actions: props.guest ? ['copy', 'replay'] : ['copy', 'good', 'bad', 'replay'], - handleActions: { - good: () => { - if (!props.guest && lastCompletionIdRef.current) { - voteConversation({ - cancel: false, - vote_type: 'helpful', - chat_completion_id: lastCompletionIdRef.current, - }).catch(() => {}); - } - }, - bad: () => { - if (!props.guest && lastCompletionIdRef.current) { - voteConversation({ - cancel: false, - vote_type: 'unhelpful', - chat_completion_id: lastCompletionIdRef.current, - }).catch(() => {}); - } + const userAvatar = props.guest ? '' : userInfo?.avatar || ''; + const userName = props.guest ? '' : userInfo?.display_name || userInfo?.username || ''; + + return (msg: ChatMessagesData) => { + if (typeof msg?.id === 'string' && msg.id.startsWith(WELCOME_ID)) { + return { + actions: false as const, + avatar: ASSISTANT_AVATAR, + name: t('ai_name') || 'AI', + // The suggestion content block delivers its clicks through the + // per-message handleActions — without this the chips do nothing. + handleActions: { suggestion: handleSuggestionClick }, + }; + } + if (msg?.role === 'user') { + return { + actions: false as const, + variant: 'base' as const, + placement: 'right' as const, + avatar: userAvatar, + name: userName, + }; + } + return { + placement: 'left' as const, + avatar: ASSISTANT_AVATAR, + name: t('ai_name') || 'AI', + actions: props.guest + ? (['copy', 'replay'] as const) + : (['copy', 'good', 'bad', 'replay'] as const), + chatContentProps: { + thinking: { maxHeight: 220, layout: 'border' as const }, + suggestion: { directSend: true }, + markdown: { + options: { themeSettings: { codeBlockTheme: 'light' as const } }, }, }, - }, + handleActions: { + good: () => handleVote(msg, 'helpful'), + bad: () => handleVote(msg, 'unhelpful'), + replay: () => handleReplay(msg), + suggestion: handleSuggestionClick, + }, + }; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.guest]); + }, [props.guest, userInfo?.avatar, userInfo?.display_name, userInfo?.username]); return ( { - // noop: keep engine flow; page-level hooks run through onRequest - }} - /> + chatServiceConfig={chatServiceConfig as never}> + {props.children} + ); }; diff --git a/ui/src/components/HomeChatWidget/index.tsx b/ui/src/components/HomeChatWidget/index.tsx index 0defed132..4b04cc651 100644 --- a/ui/src/components/HomeChatWidget/index.tsx +++ b/ui/src/components/HomeChatWidget/index.tsx @@ -131,8 +131,13 @@ const HomeChatWidget: React.FC = () => { } }} guest={!isLogged} - height="100%" - /> + height="100%"> +
+ {t('ai_disclaimer')} +
+ diff --git a/ui/src/pages/Admin/AiSettings/index.tsx b/ui/src/pages/Admin/AiSettings/index.tsx index 8f0a48e5f..28751d50d 100644 --- a/ui/src/pages/Admin/AiSettings/index.tsx +++ b/ui/src/pages/Admin/AiSettings/index.tsx @@ -78,6 +78,10 @@ const Index = () => { isInvalid: false, errorMsg: '', }, + welcome_text: '', + initial_messages: '', + prompt_zh: '', + prompt_en: '', }); const [apiHostPlaceholder, setApiHostPlaceholder] = useState(''); const [modelsData, setModels] = useState<{ id: string }[]>([]); @@ -239,6 +243,14 @@ const Index = () => { home_chat_guest_enabled: !!formData.home_chat_guest_enabled.value, chosen_provider: formData.provider.value, ai_providers: newProviders, + ai_welcome_text: formData.welcome_text, + ai_initial_messages: formData.initial_messages, + // Always round-trip the prompts: an omitted prompt_config resets the + // stored prompts to the built-in defaults on the backend. + prompt_config: { + zh_cn: formData.prompt_zh, + en_us: formData.prompt_en, + }, }; saveAiConfig(params) .then(() => { @@ -246,6 +258,8 @@ const Index = () => { ai_enabled: formData.enabled.value, ai_home_chat_enabled: !!formData.home_chat_enabled.value, ai_home_chat_guest_enabled: !!formData.home_chat_guest_enabled.value, + ai_welcome_text: formData.welcome_text, + ai_initial_messages: formData.initial_messages, }); historyConfigRef.current = { @@ -319,6 +333,10 @@ const Index = () => { isInvalid: false, errorMsg: '', }, + welcome_text: aiConfig.ai_welcome_text || '', + initial_messages: aiConfig.ai_initial_messages || '', + prompt_zh: aiConfig.prompt_config?.zh_cn || '', + prompt_en: aiConfig.prompt_config?.en_us || '', }); }; @@ -416,6 +434,70 @@ const Index = () => {
+ + {t('welcome_text.label')} + + handleValueChange({ + welcome_text: e.target.value, + }) + } + /> + + {t('welcome_text.tip')} + + + + + {t('initial_messages.label')} + + handleValueChange({ + initial_messages: e.target.value, + }) + } + /> + + {t('initial_messages.tip')} + + + + + {t('prompt_config.label')} + + handleValueChange({ + prompt_zh: e.target.value, + }) + } + /> + + handleValueChange({ + prompt_en: e.target.value, + }) + } + /> + + {t('prompt_config.tip')} + + + {t('provider.label')} { - {conversions?.conversation_id ? null : ( -
{t('description')}
- )}
{ navigate(`/ai-assistant/${cid}`, { replace: true }); } }} - height="100%" - /> + height="100%"> +
+ {t('ai_disclaimer')} +
+
{isShowConversationList && ( diff --git a/ui/src/stores/aiControl.ts b/ui/src/stores/aiControl.ts index 46852930f..5720d9fb1 100644 --- a/ui/src/stores/aiControl.ts +++ b/ui/src/stores/aiControl.ts @@ -23,10 +23,14 @@ interface AiControlStore { ai_enabled: boolean; ai_home_chat_enabled: boolean; ai_home_chat_guest_enabled: boolean; + ai_welcome_text: string; + ai_initial_messages: string; update: (params: { ai_enabled: boolean; ai_home_chat_enabled?: boolean; ai_home_chat_guest_enabled?: boolean; + ai_welcome_text?: string; + ai_initial_messages?: string; }) => void; reset: () => void; } @@ -35,10 +39,14 @@ const aiControlStore = create((set) => ({ ai_enabled: false, ai_home_chat_enabled: false, ai_home_chat_guest_enabled: false, + ai_welcome_text: '', + ai_initial_messages: '', update: (params: { ai_enabled: boolean; ai_home_chat_enabled?: boolean; ai_home_chat_guest_enabled?: boolean; + ai_welcome_text?: string; + ai_initial_messages?: string; }) => set((state) => { return { @@ -51,6 +59,8 @@ const aiControlStore = create((set) => ({ ai_enabled: false, ai_home_chat_enabled: false, ai_home_chat_guest_enabled: false, + ai_welcome_text: '', + ai_initial_messages: '', }), })); diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts index da0006d7a..3a77b64e8 100644 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@ -391,6 +391,8 @@ export const initAppSettingsStore = async () => { ai_enabled: appSettings.ai_enabled, ai_home_chat_enabled: appSettings.ai_home_chat_enabled ?? false, ai_home_chat_guest_enabled: appSettings.ai_home_chat_guest_enabled ?? false, + ai_welcome_text: appSettings.ai_welcome_text ?? '', + ai_initial_messages: appSettings.ai_initial_messages ?? '', }); siteSecurityStore.getState().update(appSettings.site_security); } diff --git a/ui/src/utils/localize.ts b/ui/src/utils/localize.ts index 0b45a2222..480b75cbc 100644 --- a/ui/src/utils/localize.ts +++ b/ui/src/utils/localize.ts @@ -75,6 +75,9 @@ const pullLanguageConf = (res) => { if (langConf && langConf.ui) { res.resources = langConf.ui; Storage.set(LANG_RESOURCE_STORAGE_KEY, res); + // The stale cached bundle was already added at boot; merge the fresh + // one in place so new/changed keys apply without a second page load. + i18next.addResourceBundle(res.lng, 'translation', langConf.ui, true, true); } }); };