feat: v0.4.0 主题切换 + LLM 全面优化 + 登录会话 + 用户管理/LDAP
主题: - 深色/浅色/跟随系统(useTheme:本地缓存先行+远端同步+matchMedia), 侧边栏快捷切换,设置页三档即选即生效 LLM(参考 opsxdb/aiops): - llm.Client.ChatStream 流式(SSE 增量解析,透传 reasoning_content, ctx 控制超时);POST /api/ai/chat/stream(平台实时数据注入降幻觉, 未配 LLM 降级规则 JSON,指令解析 action 不变) - test-llm 支持草稿直测(保存前验证,apiKey 空/掩码回退已存) - 发布失败自动 AI 分析日志尾部(结论/根因/建议),发布记录与发布 Tab 展示分析卡片 - 设置页 Provider 预设(DeepSeek/GLM/GLM编码/Qwen/Kimi/Ollama/自定义) 自动填 endpoint+模型+获取 Key 直达链接 登录与会话: - HMAC 签名 token(7 天,密钥派生自 encryption.key); authMiddleware 保护 /api/*(豁免登录/健康/SSE日志流/入站webhook) - 登录页 /login + 未登录跳转 + 登出;401 自动清理跳登录 用户管理与 LDAP(借鉴 LokiStack): - /users 页面:本地用户 CRUD、一次性密码、角色切换、来源徽章 (本地·蓝 | LDAP导入·紫);LDAP 导入按钮仅在启用且配置齐备时显示 - LDAP 配置卡(未启用仅提示语)、测试连接草稿直测+可选测试账号 - go-ldap:管理 Bind/用户搜索(防注入)/用户 DN 直连验证; 批量导入冲突策略(已LDAP跳过/本地同名转LDAP/新建); 登录分流本地 argon2id 或 LDAP 目录验证 + 首登 JIT 建号
This commit is contained in:
1 parent
555934cc64
commit
97193eb090
24 files changed
+2422
-98
No files matched your search
@@ -1,14 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket } from 'lucide-react';
|
||||
import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { apiClient, type PipelineRunDTO } from '@/lib/api';
|
||||
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
|
||||
|
||||
interface Props {
|
||||
pipelineId: number;
|
||||
}
|
||||
|
||||
// 列表行:PipelineRunDTO + 失败时自动生成的 AI 分析字段
|
||||
type RunRow = PipelineRunDTO & { analysis?: string };
|
||||
|
||||
const statusMap: Record<string, { color: string; bg: string; label: string }> = {
|
||||
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||
@@ -32,7 +35,7 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
||||
|
||||
const { data: runs = [], isLoading } = useQuery({
|
||||
queryKey: ['runs', { pipelineId }],
|
||||
queryFn: () => apiClient.listRuns({ pipelineId }),
|
||||
queryFn: () => apiClient.listRuns({ pipelineId }) as Promise<RunRow[]>,
|
||||
});
|
||||
|
||||
const { data: detail } = useQuery({
|
||||
@@ -132,6 +135,19 @@ export default function ReleasePanel({ pipelineId }: Props) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* AI 失败分析(紧凑版,日志区上方) */}
|
||||
{r.analysis ? (
|
||||
<div className="mb-2 rounded-md border px-3 py-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||
<div className="flex items-center gap-1 text-[11px] font-semibold mb-1" style={{ color: '#8250df' }}>
|
||||
<Sparkles className="w-3 h-3" />AI 失败分析
|
||||
</div>
|
||||
<div className="text-[11px] leading-[18px] whitespace-pre-wrap" style={{ color: 'var(--text-primary)' }}>{r.analysis}</div>
|
||||
</div>
|
||||
) : ['running', 'pending', 'queued'].includes(r.status) ? (
|
||||
<div className="mb-2 flex items-center gap-1 text-[10px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />失败后将自动生成 AI 分析
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)' }}>
|
||||
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
|
||||
|
||||
@@ -13,18 +13,24 @@ import {
|
||||
Webhook,
|
||||
CloudSync,
|
||||
Puzzle,
|
||||
Settings
|
||||
Settings,
|
||||
Users,
|
||||
Sun,
|
||||
Moon,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { useTheme } from '@/providers/theme';
|
||||
|
||||
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
|
||||
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'users' | 'settings';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: ModuleType;
|
||||
onModuleChange: (module: ModuleType) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch; badge: string | null }[] }[] = [
|
||||
@@ -57,18 +63,20 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
|
||||
label: '系统',
|
||||
items: [
|
||||
{ id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' },
|
||||
{ id: 'users', label: '用户管理', icon: Users, badge: null },
|
||||
{ id: 'settings', label: '系统设置', icon: Settings, badge: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse }: SidebarProps) {
|
||||
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse, onLogout }: SidebarProps) {
|
||||
// 徽标数来自后端真实统计
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['summary'],
|
||||
queryFn: apiClient.getSummary,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const { toggle: themeToggle, isDark } = useTheme();
|
||||
|
||||
const badgeFor = (id: ModuleType): string | null => {
|
||||
if (id === 'build-nodes') return summary?.buildNodes != null ? String(summary.buildNodes) : null;
|
||||
@@ -167,11 +175,27 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div
|
||||
className="px-2 py-2 border-t flex justify-center"
|
||||
{/* Bottom bar: theme toggle + collapse */}
|
||||
<div
|
||||
className="px-2 py-2 border-t flex items-center justify-center gap-1"
|
||||
style={{ borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<button
|
||||
onClick={themeToggle}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title="切换深色/浅色主题"
|
||||
>
|
||||
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Sparkles, Send, X, Loader2, CheckCircle2, XCircle, History, Bot } from 'lucide-react';
|
||||
import { Sparkles, Send, X, Loader2, CheckCircle2, XCircle, History, Bot, Square } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { apiClient, auth } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
onNavigate?: (module: string) => void;
|
||||
@@ -11,6 +11,9 @@ interface Props {
|
||||
interface ChatItem {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
streaming?: boolean;
|
||||
stopped?: boolean;
|
||||
action?: any; // {type:'release', jobs, branch, env}
|
||||
actionState?: 'pending' | 'done' | 'cancelled';
|
||||
result?: { triggered: any[]; failed: any[] };
|
||||
@@ -21,6 +24,21 @@ const suggestions = [
|
||||
'把后端两个服务发到 pre',
|
||||
];
|
||||
|
||||
// 解析一个 SSE 事件块(不含结尾空行):event: xxx / data: {...}
|
||||
function parseSSEChunk(chunk: string): { event: string; data: string } | null {
|
||||
let event = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (!line || line.startsWith(':')) continue; // 注释/空行
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
return { event, data: dataLines.join('\n') };
|
||||
}
|
||||
|
||||
const isJsonInstruction = (s: string) => s.trimStart().startsWith('{');
|
||||
|
||||
export default function AiAssistant({ onNavigate }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
@@ -29,6 +47,7 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
{ role: 'assistant', content: '你好,我是 FlowStack 智能发布助手。告诉我你要发布哪些服务(支持自然语言),例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。' },
|
||||
]);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
@@ -49,20 +68,122 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
if (!content || sending) return;
|
||||
setInput('');
|
||||
const userMsg: ChatItem = { role: 'user', content };
|
||||
const next = [...messages, userMsg];
|
||||
setMessages(next);
|
||||
const assistantIndex = messages.length + 1; // 占位 assistant 气泡下标
|
||||
const history = [...messages, userMsg].slice(-10).map((m) => ({ role: m.role, content: m.content }));
|
||||
setMessages((prev) => [...prev, userMsg, { role: 'assistant', content: '', streaming: true }]);
|
||||
setSending(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const patch = (updater: (m: ChatItem) => ChatItem) => {
|
||||
setMessages((prev) => prev.map((m, i) => (i === assistantIndex ? updater(m) : m)));
|
||||
};
|
||||
|
||||
let accContent = '';
|
||||
let accReasoning = '';
|
||||
let gotDone = false;
|
||||
|
||||
const applyDone = (data: { reply?: string; action?: any }) => {
|
||||
gotDone = true;
|
||||
const reply = typeof data?.reply === 'string' && data.reply ? data.reply : accContent;
|
||||
patch((m) => ({
|
||||
...m,
|
||||
content: reply,
|
||||
action: data?.action,
|
||||
actionState: data?.action ? 'pending' : undefined,
|
||||
streaming: false,
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
const history = next.slice(-10).map((m) => ({ role: m.role, content: m.content }));
|
||||
const res = await apiClient.aiChat(history);
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: res.reply, action: res.action, actionState: res.action ? 'pending' : undefined }]);
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = auth.getToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/ai/chat/stream', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ messages: history }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
auth.clear();
|
||||
if (!window.location.pathname.startsWith('/login')) window.location.href = '/login';
|
||||
throw new Error('登录已失效');
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = `请求失败 (HTTP ${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error) message = body.error;
|
||||
} catch {
|
||||
// 非 JSON 响应体
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const ctype = res.headers.get('content-type') || '';
|
||||
if (!ctype.includes('text/event-stream')) {
|
||||
// 后端未配置 LLM:直接返回普通 JSON {reply, viaAI:false, action}
|
||||
const data = await res.json();
|
||||
applyDone(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 手写 SSE 解析:事件以空行分隔
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
const handleChunk = (chunk: string) => {
|
||||
const parsed = parseSSEChunk(chunk);
|
||||
if (!parsed) return;
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = JSON.parse(parsed.data);
|
||||
} catch {
|
||||
payload = { content: parsed.data };
|
||||
}
|
||||
if (parsed.event === 'delta') {
|
||||
if (typeof payload?.content === 'string' && payload.content) accContent += payload.content;
|
||||
if (typeof payload?.reasoning === 'string' && payload.reasoning) accReasoning += payload.reasoning;
|
||||
patch((m) => ({ ...m, content: accContent, reasoning: accReasoning }));
|
||||
} else if (parsed.event === 'done') {
|
||||
applyDone(payload);
|
||||
}
|
||||
// start / 其他事件:无需处理
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
||||
const chunk = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
if (chunk.trim()) handleChunk(chunk);
|
||||
}
|
||||
}
|
||||
if (buf.trim()) handleChunk(buf.trim()); // 冲刷结尾未跟空行的残包
|
||||
if (!gotDone) patch((m) => ({ ...m, streaming: false }));
|
||||
} catch (err) {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: '请求失败:' + (err as Error).message }]);
|
||||
if ((err as any)?.name === 'AbortError') {
|
||||
// 用户主动停止:保留已生成内容
|
||||
patch((m) => ({ ...m, content: accContent || m.content, streaming: false, stopped: true }));
|
||||
} else if (accContent) {
|
||||
// 中途断流:保留已有内容并标记
|
||||
patch((m) => ({ ...m, content: accContent, streaming: false, stopped: true }));
|
||||
} else {
|
||||
patch(() => ({ role: 'assistant', content: '请求失败:' + (err as Error).message }));
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopStream = () => abortRef.current?.abort();
|
||||
|
||||
const confirmRelease = async (index: number) => {
|
||||
const msg = messages[index];
|
||||
if (!msg?.action) return;
|
||||
@@ -106,7 +227,7 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
{/* 对话面板 */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed bottom-22 right-6 z-50 w-[400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
|
||||
className="fixed right-6 z-50 w-[400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
|
||||
style={{ bottom: 84, height: 520, background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
{/* 头部 */}
|
||||
@@ -139,9 +260,36 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
: { background: 'var(--panel-bg-secondary)', color: 'var(--text-primary)', borderBottomLeftRadius: 4 }
|
||||
}
|
||||
>
|
||||
{m.content}
|
||||
{/* reasoning 增量:灰色小字(仅推理过程中展示,最近 120 字符) */}
|
||||
{m.role === 'assistant' && m.streaming && !!m.reasoning && (
|
||||
<div className="italic text-[10px] leading-4 mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{m.reasoning.length > 120 ? '…' : ''}{m.reasoning.slice(-120)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
if (m.role === 'assistant' && m.streaming && isJsonInstruction(m.content)) {
|
||||
// 指令解析 JSON:不展示原始流
|
||||
return <span className="inline-flex items-center gap-1 animate-pulse">📋 解析发布指令中…</span>;
|
||||
}
|
||||
if (m.role === 'assistant' && !m.streaming && isJsonInstruction(m.content) && m.action) {
|
||||
return <span>已识别发布指令,请确认:</span>;
|
||||
}
|
||||
if (m.role === 'assistant' && m.streaming && !m.content && !m.reasoning) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />思考中…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return m.content;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{m.stopped && (
|
||||
<div className="mt-1 text-[10px] italic" style={{ color: 'var(--text-tertiary)' }}>已停止</div>
|
||||
)}
|
||||
|
||||
{/* 发布计划卡片 */}
|
||||
{m.action && m.action.type === 'release' && (
|
||||
<div className="mt-2 rounded-lg border p-3" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
|
||||
@@ -215,14 +363,6 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sending && (
|
||||
<div className="flex justify-start">
|
||||
<div className="px-3 py-2 rounded-xl flex items-center gap-1.5" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--accent-blue)' }} />
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>思考中…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 快捷示例 */}
|
||||
@@ -251,14 +391,25 @@ export default function AiAssistant({ onNavigate }: Props) {
|
||||
className="flex-1 px-3 py-2 text-[12px] rounded-lg border outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => send()}
|
||||
disabled={sending || !input.trim()}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white disabled:opacity-40"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{sending ? (
|
||||
<button
|
||||
onClick={stopStream}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white flex-shrink-0"
|
||||
style={{ background: '#cf222e' }}
|
||||
title="停止生成"
|
||||
>
|
||||
<Square className="w-3 h-3" fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => send()}
|
||||
disabled={!input.trim()}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white disabled:opacity-40"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user