diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml
index 5d1faa3e0..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
@@ -2347,6 +2354,23 @@ 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.
+ 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 f16ed9fad..390a4e0b4 100644
--- a/i18n/zh_CN.yaml
+++ b/i18n/zh_CN.yaml
@@ -853,9 +853,18 @@ 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: 提问
+ thinking: 思考中…
+ thoughts: 思考过程
notifications:
title: 通知
inbox: 收件箱
@@ -2305,6 +2314,23 @@ ui:
label: AI 已启用
check: 启用AI功能
text: AI 模型必须正确配置才能使用。
+ home_chat:
+ label: 首页展示 AI 助手
+ tip: 开启后将在网站首页右下角展示 AI 助手悬浮入口。
+ 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/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..ffe9200e0 100644
--- a/internal/controller/siteinfo_controller.go
+++ b/internal/controller/siteinfo_controller.go
@@ -112,6 +112,10 @@ 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
+ resp.AIWelcomeText = aiConf.WelcomeText
+ resp.AIInitialMessages = aiConf.InitialMessages
}
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..1c85f56bb 100644
--- a/internal/schema/siteinfo_schema.go
+++ b/internal/schema/siteinfo_schema.go
@@ -269,10 +269,23 @@ 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"`
+ // 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 {
@@ -385,8 +398,12 @@ 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"`
+ AIWelcomeText string `json:"ai_welcome_text"`
+ AIInitialMessages string `json:"ai_initial_messages"`
+ 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..77a1b193a 100644
--- a/ui/src/common/interface.ts
+++ b/ui/src/common/interface.ts
@@ -428,6 +428,10 @@ export interface SiteSettings {
revision: string;
site_security: AdminSettingsSecurity;
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 {
@@ -828,7 +832,15 @@ export interface AddOrEditApiKeyParams {
export interface AiConfig {
enabled: boolean;
+ 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
new file mode 100644
index 000000000..77208cfd3
--- /dev/null
+++ b/ui/src/components/AnswerChatBot/index.tsx
@@ -0,0 +1,465 @@
+/*
+ * 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, useState } 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';
+// 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';
+
+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;
+ height?: number | string;
+ style?: React.CSSProperties;
+ /** Children are forwarded to the underlying ChatBot for slot customization
+ * (e.g.
…
). */
+ children?: React.ReactNode;
+}
+
+type StreamChunk = {
+ id?: string;
+ choices?: Array<{
+ delta?: { role?: string; content?: string; reasoning_content?: string };
+ finish_reason?: string | null;
+ }>;
+};
+
+/** 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 {
+ Authorization: token,
+ 'X-Requested-With': 'XMLHttpRequest',
+ };
+};
+
+/**
+ * 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,
+ * 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) => 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('');
+ // 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[] = [];
+ 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',
+ id: cid ? `${cid}-answer` : undefined,
+ data: r.content || '',
+ status: 'complete',
+ } as AIMessageContent);
+ return {
+ id: cid || String(Math.random()),
+ role: r.role === 'user' ? 'user' : 'assistant',
+ status: 'complete',
+ content,
+ } as ChatMessagesData;
+ };
+
+ // 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) {
+ return;
+ }
+ if (!props.initialRecords?.length) {
+ 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, 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. 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 || '';
+ const prev = (existing?.data as { text?: string }) || {};
+ return {
+ ...(existing || {}),
+ type: 'thinking',
+ 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[])
+ .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 || []) as AIMessageContent[]) || [])
+ .filter((c) => c.type === 'text' || c.type === 'markdown')
+ .map((c) => (c.data as string) || '')
+ .join(''),
+ }))
+ .filter((m) => m.content);
+ 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);
+ }
+ return {
+ headers: buildGuestHeaders(),
+ body: JSON.stringify({
+ conversation_id: cid,
+ messages,
+ }),
+ };
+ },
+ onMessage: (chunk: SSEChunkData): AIMessageContent | null => {
+ const res = (chunk.data || {}) as StreamChunk;
+ 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: () => {
+ 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(() => {
+ 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, userInfo?.avatar, userInfo?.display_name, userInfo?.username]);
+
+ return (
+
+ {props.children}
+
+ );
+};
+
+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..4b04cc651
--- /dev/null
+++ b/ui/src/components/HomeChatWidget/index.tsx
@@ -0,0 +1,172 @@
+/*
+ * 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 (
+