feat(ai): 助手浮窗聚焦日志查询页,携带当前查询结果对话
- 浮球只在 /logs 渲染(产品定位:辅助分析正在查看的查询结果,其它页面不打扰) - /logs 查询成功后写入 AI 上下文:LogQL、时间范围、结果条数、error 行数、 最新 20 条样本(去 CRI 包装);对话请求自动携带 - 后端 /ai/chat 接收 context:截断 6000 字 + RedactTextOnly 统一脱敏后并入 system (ECHO 假模型实测注入完整);空态文案改为查询导向
This commit is contained in:
1 parent
2efbed882a
commit
c216bdfdd4
8 files changed
+59
-6
No files matched your search
@@ -161,6 +161,7 @@ func (ctrl *AIController) Chat(c *gin.Context) {
|
|||||||
History []services.ChatMessage `json:"history" binding:"required,min=1,max=40"`
|
History []services.ChatMessage `json:"history" binding:"required,min=1,max=40"`
|
||||||
Page string `json:"page"`
|
Page string `json:"page"`
|
||||||
RangeMinutes int `json:"rangeMinutes"`
|
RangeMinutes int `json:"rangeMinutes"`
|
||||||
|
Context string `json:"context"`
|
||||||
}
|
}
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
return
|
return
|
||||||
@@ -174,7 +175,7 @@ func (ctrl *AIController) Chat(c *gin.Context) {
|
|||||||
if rangeMinutes <= 0 || rangeMinutes > 120 {
|
if rangeMinutes <= 0 || rangeMinutes > 120 {
|
||||||
rangeMinutes = 15
|
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 {
|
if err != nil {
|
||||||
utils.Error(c, 400, err.Error())
|
utils.Error(c, 400, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ type ChatMessage struct {
|
|||||||
|
|
||||||
// RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。
|
// RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。
|
||||||
// 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。
|
// 与整页分析共享配置/配额/脱敏/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 {
|
if !cfg.Enabled {
|
||||||
return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存")
|
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}}
|
msgs := []aiMessage{{Role: "system", Content: system}}
|
||||||
for _, m := range history {
|
for _, m := range history {
|
||||||
msgs = append(msgs, aiMessage{Role: m.Role, Content: m.Content})
|
msgs = append(msgs, aiMessage{Role: m.Role, Content: m.Content})
|
||||||
|
|||||||
@@ -92,6 +92,6 @@ export const aiApi = {
|
|||||||
),
|
),
|
||||||
run: (id: number) => http.get<AIRun>(`/ai/runs/${id}`),
|
run: (id: number) => http.get<AIRun>(`/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),
|
http.post<{ reply: string }>('/ai/chat', data),
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Textarea } from '@/components/ui/textarea'
|
|||||||
import { aiApi } from '@/api/ai'
|
import { aiApi } from '@/api/ai'
|
||||||
import { getUser } from '@/lib/auth-storage'
|
import { getUser } from '@/lib/auth-storage'
|
||||||
import { useLicense } from '@/hooks/useLicense'
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
|
import { buildLogsContextText } from '@/lib/ai-assistant-context'
|
||||||
|
|
||||||
interface Msg { role: 'user' | 'assistant'; content: string }
|
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
|
if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight
|
||||||
}, [messages, sending])
|
}, [messages, sending])
|
||||||
|
|
||||||
if (!isAdmin || !pro) return null
|
// 按产品定位:助手服务于日志查询 —— 只在日志查询页出现,带着当前查询结果对话
|
||||||
|
if (!isAdmin || !pro || location.pathname !== '/logs') return null
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
@@ -44,6 +46,7 @@ export const AiAssistant = memo(function AiAssistant() {
|
|||||||
history: next.slice(-20),
|
history: next.slice(-20),
|
||||||
page: location.pathname,
|
page: location.pathname,
|
||||||
rangeMinutes: 15,
|
rangeMinutes: 15,
|
||||||
|
context: buildLogsContextText() || undefined,
|
||||||
})
|
})
|
||||||
setMessages(current => [...current, { role: 'assistant', content: res.reply }])
|
setMessages(current => [...current, { role: 'assistant', content: res.reply }])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default {
|
|||||||
thinking: 'Thinking…',
|
thinking: 'Thinking…',
|
||||||
error: 'Request failed',
|
error: 'Request failed',
|
||||||
placeholder: 'Ask about logs, e.g. anything unusual now?',
|
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',
|
title: 'AIOps',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default {
|
|||||||
thinking: '思考中…',
|
thinking: '思考中…',
|
||||||
error: '请求失败',
|
error: '请求失败',
|
||||||
placeholder: '问问日志的事,如:当前有什么异常?',
|
placeholder: '问问日志的事,如:当前有什么异常?',
|
||||||
emptyHint: '直接提问;我会参考你当前正在查看的页面与近 15 分钟日志概况。',
|
emptyHint: '刚查到的日志就在我手上——直接问,例如:「这些报错的共同原因是什么」「哪个应用的问题最大」',
|
||||||
},
|
},
|
||||||
|
|
||||||
title: '智能运维',
|
title: '智能运维',
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import { SourcesManager } from './components/SourcesManager'
|
|||||||
import { LogContextDialog, type LogContext } from './components/LogContextDialog'
|
import { LogContextDialog, type LogContext } from './components/LogContextDialog'
|
||||||
import { LogResultsPanel } from './components/LogResultsPanel'
|
import { LogResultsPanel } from './components/LogResultsPanel'
|
||||||
import { literalFilters } from './query-utils'
|
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 { readQueryHistory, rememberQuery } from './query-history'
|
||||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||||
|
|
||||||
@@ -173,6 +175,14 @@ export default function LogsPage() {
|
|||||||
if (version !== requestVersion.current) return
|
if (version !== requestVersion.current) return
|
||||||
if (paging && result.entries.length === 0) { setCursor(''); return }
|
if (paging && result.entries.length === 0) { setCursor(''); return }
|
||||||
if (!paging) setQueryHistory(rememberQuery(data.query))
|
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)
|
setExecutedQuery(data.query)
|
||||||
if (!paging) setExecutedRange(r)
|
if (!paging) setExecutedRange(r)
|
||||||
setSearched(true)
|
setSearched(true)
|
||||||
|
|||||||
Reference in new issue
Block a user