diff --git a/backend/controllers/ai.go b/backend/controllers/ai.go index 8b8deec..74b67fa 100644 --- a/backend/controllers/ai.go +++ b/backend/controllers/ai.go @@ -161,6 +161,7 @@ func (ctrl *AIController) Chat(c *gin.Context) { History []services.ChatMessage `json:"history" binding:"required,min=1,max=40"` Page string `json:"page"` RangeMinutes int `json:"rangeMinutes"` + Context string `json:"context"` } if !utils.BindAndValidate(c, &req) { return @@ -174,7 +175,7 @@ func (ctrl *AIController) Chat(c *gin.Context) { if rangeMinutes <= 0 || rangeMinutes > 120 { rangeMinutes = 15 } - reply, err := services.RunAIChat(c.Request.Context(), cfg, src, c.GetUint("userId"), c.GetString("role") == "admin", req.History, req.Page, rangeMinutes) + reply, err := services.RunAIChat(c.Request.Context(), cfg, src, c.GetUint("userId"), c.GetString("role") == "admin", req.History, req.Page, rangeMinutes, req.Context) if err != nil { utils.Error(c, 400, err.Error()) return diff --git a/backend/services/ai_provider.go b/backend/services/ai_provider.go index 03f0fc8..9bbca36 100644 --- a/backend/services/ai_provider.go +++ b/backend/services/ai_provider.go @@ -155,7 +155,7 @@ type ChatMessage struct { // RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。 // 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。 -func RunAIChat(ctx context.Context, cfg models.AIConfig, src *models.LogSource, userID uint, admin bool, history []ChatMessage, page string, rangeMinutes int) (string, error) { +func RunAIChat(ctx context.Context, cfg models.AIConfig, src *models.LogSource, userID uint, admin bool, history []ChatMessage, page string, rangeMinutes int, pageContext string) (string, error) { if !cfg.Enabled { return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存") } @@ -191,6 +191,13 @@ func RunAIChat(ctx context.Context, cfg models.AIConfig, src *models.LogSource, } } + // 页面上下文(如 /logs 刚执行的查询与样本日志):截断并统一脱敏后并入 system + if trimmed := strings.TrimSpace(pageContext); trimmed != "" { + if len([]rune(trimmed)) > 6000 { + trimmed = string([]rune(trimmed)[:6000]) + } + system += "\n\n" + RedactTextOnly(trimmed, cfg.RedactExtra) + } msgs := []aiMessage{{Role: "system", Content: system}} for _, m := range history { msgs = append(msgs, aiMessage{Role: m.Role, Content: m.Content}) diff --git a/frontend/src/api/ai/index.ts b/frontend/src/api/ai/index.ts index c65849f..0ed9487 100644 --- a/frontend/src/api/ai/index.ts +++ b/frontend/src/api/ai/index.ts @@ -92,6 +92,6 @@ export const aiApi = { ), run: (id: number) => http.get(`/ai/runs/${id}`), - chat: (data: { sourceId?: number; history: Array<{ role: 'user' | 'assistant'; content: string }>; page?: string; rangeMinutes?: number }) => + chat: (data: { sourceId?: number; history: Array<{ role: 'user' | 'assistant'; content: string }>; page?: string; rangeMinutes?: number; context?: string }) => http.post<{ reply: string }>('/ai/chat', data), } diff --git a/frontend/src/components/AiAssistant.tsx b/frontend/src/components/AiAssistant.tsx index 5326e69..86041e2 100644 --- a/frontend/src/components/AiAssistant.tsx +++ b/frontend/src/components/AiAssistant.tsx @@ -7,6 +7,7 @@ import { Textarea } from '@/components/ui/textarea' import { aiApi } from '@/api/ai' import { getUser } from '@/lib/auth-storage' import { useLicense } from '@/hooks/useLicense' +import { buildLogsContextText } from '@/lib/ai-assistant-context' interface Msg { role: 'user' | 'assistant'; content: string } @@ -30,7 +31,8 @@ export const AiAssistant = memo(function AiAssistant() { if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight }, [messages, sending]) - if (!isAdmin || !pro) return null + // 按产品定位:助手服务于日志查询 —— 只在日志查询页出现,带着当前查询结果对话 + if (!isAdmin || !pro || location.pathname !== '/logs') return null const send = async () => { const text = input.trim() @@ -44,6 +46,7 @@ export const AiAssistant = memo(function AiAssistant() { history: next.slice(-20), page: location.pathname, rangeMinutes: 15, + context: buildLogsContextText() || undefined, }) setMessages(current => [...current, { role: 'assistant', content: res.reply }]) } catch (err) { diff --git a/frontend/src/i18n/locales/en/aiops.ts b/frontend/src/i18n/locales/en/aiops.ts index 59b86b0..e9f7579 100644 --- a/frontend/src/i18n/locales/en/aiops.ts +++ b/frontend/src/i18n/locales/en/aiops.ts @@ -7,7 +7,7 @@ export default { thinking: 'Thinking…', error: 'Request failed', placeholder: 'Ask about logs, e.g. anything unusual now?', - emptyHint: 'Just ask; I can see the page you are viewing and the last 15 minutes of logs.', + emptyHint: 'I can see the logs you just queried — ask away, e.g. "common cause of these errors?"', }, title: 'AIOps', diff --git a/frontend/src/i18n/locales/zh/aiops.ts b/frontend/src/i18n/locales/zh/aiops.ts index 8129aa2..d73d7d5 100644 --- a/frontend/src/i18n/locales/zh/aiops.ts +++ b/frontend/src/i18n/locales/zh/aiops.ts @@ -7,7 +7,7 @@ export default { thinking: '思考中…', error: '请求失败', placeholder: '问问日志的事,如:当前有什么异常?', - emptyHint: '直接提问;我会参考你当前正在查看的页面与近 15 分钟日志概况。', + emptyHint: '刚查到的日志就在我手上——直接问,例如:「这些报错的共同原因是什么」「哪个应用的问题最大」', }, title: '智能运维', diff --git a/frontend/src/lib/ai-assistant-context.ts b/frontend/src/lib/ai-assistant-context.ts new file mode 100644 index 0000000..cf06c08 --- /dev/null +++ b/frontend/src/lib/ai-assistant-context.ts @@ -0,0 +1,32 @@ +// AI 助手页面上下文 — 模块级单例(避免为一个字符串加全局 Provider): +// /logs 页在查询成功后把当前查询与结果摘要写入,AiAssistant 发送时带上, +// 后端注入 system,让"刚才查到的结果有什么问题"有真实数据依据。 +export interface LogsAIContext { + query: string + rangeMinutes: number + totalEntries: number + errorLines: number + samples: string[] +} + +let logsContext: LogsAIContext | null = null + +export function setLogsAIContext(ctx: LogsAIContext | null) { + logsContext = ctx +} + +export function getLogsAIContext() { + return logsContext +} + +/** 组装给后端的上下文摘要(截断保护:样本行与总量都封顶) */ +export function buildLogsContextText(): string { + if (!logsContext) return '' + const { query, rangeMinutes, totalEntries, errorLines, samples } = logsContext + const lines = samples.slice(0, 20).map(s => s.slice(0, 300)) + return [ + `用户刚执行的 LogQL 查询: ${query}`, + `时间范围: 近 ${rangeMinutes} 分钟;加载 ${totalEntries} 条(首页),其中含 error 关键字约 ${errorLines} 条`, + lines.length ? `样本日志(最新在前,已截断):\n${lines.join('\n')}` : '', + ].filter(Boolean).join('\n') +} diff --git a/frontend/src/pages/logs/index.tsx b/frontend/src/pages/logs/index.tsx index 58c032b..0408573 100644 --- a/frontend/src/pages/logs/index.tsx +++ b/frontend/src/pages/logs/index.tsx @@ -18,6 +18,8 @@ import { SourcesManager } from './components/SourcesManager' import { LogContextDialog, type LogContext } from './components/LogContextDialog' import { LogResultsPanel } from './components/LogResultsPanel' import { literalFilters } from './query-utils' +import { setLogsAIContext } from '@/lib/ai-assistant-context' +import { stripLogEnvelope } from '@/lib/ingress-access-log' import { readQueryHistory, rememberQuery } from './query-history' import { useDebouncedValue } from '@/hooks/useDebouncedValue' @@ -173,6 +175,14 @@ export default function LogsPage() { if (version !== requestVersion.current) return if (paging && result.entries.length === 0) { setCursor(''); return } if (!paging) setQueryHistory(rememberQuery(data.query)) + // AI 助手上下文:当前查询 + 首页结果摘要(样本原文,服务端统一脱敏) + setLogsAIContext({ + query: data.query, + rangeMinutes: Math.max(1, Math.round((r.end - r.start) / 6e10)), + totalEntries: result.entries.length, + errorLines: result.entries.filter(e => e.level === 'error').length, + samples: result.entries.slice(0, 20).map(e => stripLogEnvelope(e.message)), + }) setExecutedQuery(data.query) if (!paging) setExecutedRange(r) setSearched(true)