PMFlow/frontend/src/pages/ai-steward/AIStewardPage.tsx

680 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Sparkles } from "lucide-react";
import { apiBaseURL, getAuthToken } from "@/lib/api";
import {
Card, CardContent,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuthStore } from "@/stores/auth";
import { QAHistoryPanel } from "@/components/ai-steward/QAHistoryPanel";
import { parseUserRoles } from "@/api/users";
import {
aiStewardApi,
type LLMEngine,
type LLMEngineUpsertRequest,
} from "@/api/aiSteward";
import {
DEFAULT_LLM_ENGINE_SETTINGS,
ENGINE_PROVIDER_FALLBACK_PRESETS,
} from "./constants";
import type {
Blocker,
CognitiveConflict,
DailyBriefing,
FeedbackCenterSourceGroup,
FeedbackCenterSection,
FeedbackCenterTrendWindowDays,
EngineDataState,
EngineForm,
EngineMessage,
EngineProviderPresets,
ProjectFromTextResponse,
AIStewardTab,
} from "./types";
import FeatureOverviewCards from "./components/FeatureOverviewCards";
import BlockersTab from "./sections/BlockersTab";
import BriefingTab from "./sections/BriefingTab";
import ConflictsTab from "./sections/ConflictsTab";
import CreateProjectTab from "./sections/CreateProjectTab";
import EngineTab from "./sections/EngineTab";
import FeedbackCenterTab from "./sections/FeedbackCenterTab";
import OverviewTab from "./sections/OverviewTab";
import SignalsTab from "./sections/SignalsTab";
const AIS_STEWARD_TABS: AIStewardTab[] = [
"overview",
"create",
"signals",
"conflicts",
"blockers",
"briefing",
"qa",
"feedback-center",
"engine",
];
const FEEDBACK_SECTIONS: FeedbackCenterSection[] = ["overview", "metrics", "trend", "task", "qa", "signal"];
function normalizeTab(value: string | null): AIStewardTab {
if (value && AIS_STEWARD_TABS.includes(value as AIStewardTab)) {
return value as AIStewardTab;
}
return "overview";
}
function normalizeSection(value: string | null): FeedbackCenterSection {
if (value && FEEDBACK_SECTIONS.includes(value as FeedbackCenterSection)) {
return value as FeedbackCenterSection;
}
return "overview";
}
function normalizeTrendWindow(value: string | null): FeedbackCenterTrendWindowDays {
return value === "30" ? 30 : 7;
}
function normalizeTrendSourceGroup(value: string | null): FeedbackCenterSourceGroup {
if (value === "task" || value === "qa" || value === "signal") {
return value;
}
return "all";
}
export default function AIStewardPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const user = useAuthStore((s) => s.user);
const canViewFeedbackCenter = parseUserRoles(user?.role).some((role) => role === "ADMIN" || role === "BOSS");
// Project creation state
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [projectResult, setProjectResult] = useState<ProjectFromTextResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// AI Steward features state
const [activeTab, setActiveTab] = useState<AIStewardTab>(() => normalizeTab(searchParams.get("tab")));
const [feedbackSection, setFeedbackSection] = useState<FeedbackCenterSection>(() =>
normalizeSection(searchParams.get("section"))
);
const [trendWindowDays, setTrendWindowDays] = useState<FeedbackCenterTrendWindowDays>(() =>
normalizeTrendWindow(searchParams.get("window"))
);
const [trendSourceGroup, setTrendSourceGroup] = useState<FeedbackCenterSourceGroup>(() =>
normalizeTrendSourceGroup(searchParams.get("scope"))
);
const [conflicts, setConflicts] = useState<CognitiveConflict[]>([]);
const [blockers, setBlockers] = useState<Blocker[]>([]);
const [briefing, setBriefing] = useState<DailyBriefing | null>(null);
const [briefingError, setBriefingError] = useState<string | null>(null);
const [featureLoading, setFeatureLoading] = useState<string | null>(null);
const [engineData, setEngineData] = useState<EngineDataState>({
active_engine_id: null,
engines: [],
settings: DEFAULT_LLM_ENGINE_SETTINGS,
});
const [engineProviderPresets, setEngineProviderPresets] = useState<EngineProviderPresets>(
ENGINE_PROVIDER_FALLBACK_PRESETS
);
const [engineForm, setEngineForm] = useState<EngineForm>({
name: "",
provider: "glm",
api_key: "",
model: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.model,
base_url: ENGINE_PROVIDER_FALLBACK_PRESETS.glm.base_url,
});
const [engineEditingId, setEngineEditingId] = useState<string | null>(null);
const [engineMessage, setEngineMessage] = useState<EngineMessage | null>(null);
// ==================== API Calls ====================
const loadConflicts = async () => {
setFeatureLoading("conflicts");
try {
const token = getAuthToken();
const response = await fetch(`${apiBaseURL}/ai-steward/cognitive-conflicts`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
setConflicts(data);
}
} catch (err) {
console.error("Failed to load conflicts:", err);
} finally {
setFeatureLoading(null);
}
};
const loadBlockers = async () => {
setFeatureLoading("blockers");
try {
const token = getAuthToken();
const response = await fetch(`${apiBaseURL}/ai-steward/blockers/active`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
setBlockers(data.blockers || []);
}
} catch (err) {
console.error("Failed to load blockers:", err);
} finally {
setFeatureLoading(null);
}
};
const loadBriefing = async () => {
setFeatureLoading("briefing");
setBriefingError(null);
try {
const token = getAuthToken();
const response = await fetch(`${apiBaseURL}/ai-steward/daily-briefing`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
setBriefing(data);
} else {
const payload = await response.json().catch(() => null);
const detail = typeof payload?.detail === "string" ? payload.detail : typeof payload?.message === "string" ? payload.message : "";
setBriefing(null);
setBriefingError(detail || `获取简报失败 (${response.status})`);
}
} catch (err) {
console.error("Failed to load briefing:", err);
setBriefing(null);
setBriefingError(err instanceof Error ? err.message : "获取简报失败");
} finally {
setFeatureLoading(null);
}
};
const detectConflicts = async (projectId: number = 1) => {
setFeatureLoading("detect-conflicts");
try {
const token = getAuthToken();
const response = await fetch(`${apiBaseURL}/ai-steward/conflicts/detect`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ project_id: projectId, detect_type: "all" }),
});
if (response.ok) {
await loadConflicts();
return true;
}
} catch (err) {
console.error("Failed to detect conflicts:", err);
} finally {
setFeatureLoading(null);
}
return false;
};
const detectTaskBlockers = async () => {
setFeatureLoading("detect-blockers");
try {
const token = getAuthToken();
const response = await fetch(`${apiBaseURL}/ai-steward/blockers/detect-tasks`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
await loadBlockers();
return true;
}
} catch (err) {
console.error("Failed to detect blockers:", err);
} finally {
setFeatureLoading(null);
}
return false;
};
const startHNMonitor = async () => {
setFeatureLoading("hn-monitor");
try {
const token = getAuthToken();
const response = await fetch(`${apiBaseURL}/ai-steward/signal/start-hn-monitor?interval_minutes=30`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
return true;
}
} catch (err) {
console.error("Failed to start HN monitor:", err);
} finally {
setFeatureLoading(null);
}
return false;
};
const loadEngineConfigs = async () => {
setFeatureLoading("engine-list");
try {
const data = await aiStewardApi.listLlmEngines();
setEngineData({
active_engine_id: data?.active_engine_id ?? null,
engines: data?.engines ?? [],
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
});
} catch (err) {
console.error("Failed to load engine configs:", err);
} finally {
setFeatureLoading(null);
}
};
const loadEngineProviderPresets = async () => {
try {
const data = await aiStewardApi.getLlmProviderPresets();
const providers = data?.providers ?? {};
const nextPresets: EngineProviderPresets = {
deepseek: {
model: providers.deepseek?.model || ENGINE_PROVIDER_FALLBACK_PRESETS.deepseek.model,
base_url: providers.deepseek?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.deepseek.base_url,
},
glm: {
model: providers.glm?.model || data?.low || ENGINE_PROVIDER_FALLBACK_PRESETS.glm.model,
base_url: providers.glm?.base_url || data?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.glm.base_url,
},
openai: {
model: providers.openai?.model || data?.high || ENGINE_PROVIDER_FALLBACK_PRESETS.openai.model,
base_url: providers.openai?.base_url || data?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.openai.base_url,
},
ollama: {
model: providers.ollama?.model || ENGINE_PROVIDER_FALLBACK_PRESETS.ollama.model,
base_url: providers.ollama?.base_url || ENGINE_PROVIDER_FALLBACK_PRESETS.ollama.base_url,
},
};
setEngineProviderPresets(nextPresets);
setEngineForm((prev) => {
if (engineEditingId) return prev;
const preset = nextPresets[prev.provider];
return {
...prev,
model: preset.model,
base_url: preset.base_url,
};
});
} catch (err) {
console.error("Failed to load provider presets:", err);
}
};
const resetEngineForm = () => {
setEngineEditingId(null);
setEngineForm({
name: "",
provider: "glm",
api_key: "",
model: engineProviderPresets.glm.model,
base_url: engineProviderPresets.glm.base_url,
});
};
const saveEngineConfig = async () => {
setFeatureLoading("engine-save");
setEngineMessage(null);
try {
const isEdit = Boolean(engineEditingId);
const body: LLMEngineUpsertRequest = {
name: engineForm.name || `${engineForm.provider.toUpperCase()} 引擎`,
provider: engineForm.provider,
model: engineForm.model || undefined,
base_url: engineForm.base_url || undefined,
};
if (engineForm.api_key.trim()) {
body.api_key = engineForm.api_key.trim();
}
if (!isEdit) {
body.set_active = true;
}
const data = isEdit
? await aiStewardApi.updateLlmEngine(String(engineEditingId), body)
: await aiStewardApi.createLlmEngine(body);
setEngineData({
active_engine_id: data?.active_engine_id ?? null,
engines: data?.engines ?? [],
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
});
setEngineMessage({ type: "success", text: isEdit ? "引擎配置已更新" : "引擎配置已新增" });
resetEngineForm();
} catch (err) {
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,保存失败" });
} finally {
setFeatureLoading(null);
}
};
const activateEngine = async (engineId: string) => {
setFeatureLoading(`engine-activate-${engineId}`);
setEngineMessage(null);
try {
const data = await aiStewardApi.activateLlmEngine(engineId);
setEngineData({
active_engine_id: data?.active_engine_id ?? null,
engines: data?.engines ?? [],
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
});
setEngineMessage({ type: "success", text: "已切换激活引擎" });
} catch (err) {
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,切换失败" });
} finally {
setFeatureLoading(null);
}
};
const deleteEngine = async (engineId: string) => {
if (!confirm("确定删除该引擎配置吗?")) return;
setFeatureLoading(`engine-delete-${engineId}`);
setEngineMessage(null);
try {
const data = await aiStewardApi.deleteLlmEngine(engineId);
setEngineData({
active_engine_id: data?.active_engine_id ?? null,
engines: data?.engines ?? [],
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
});
if (engineEditingId === engineId) {
resetEngineForm();
}
setEngineMessage({ type: "success", text: "引擎配置已删除" });
} catch (err) {
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,删除失败" });
} finally {
setFeatureLoading(null);
}
};
const testEngineConnection = async (engineId?: string) => {
const key = engineId ? `engine-test-${engineId}` : "engine-test-active";
setFeatureLoading(key);
setEngineMessage(null);
try {
const data = engineId ? await aiStewardApi.testLlmEngine(engineId) : await aiStewardApi.testActiveLlmEngine();
const name = data?.name || "未知引擎";
const latency = typeof data?.latency_ms === "number" ? `${data.latency_ms}ms` : "-";
const preview = String(data?.response_preview || "").slice(0, 60);
setEngineMessage({
type: "success",
text: `连通成功:${name}(延迟 ${latency})${preview ? `,响应:${preview}` : ""}`,
});
} catch (err) {
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,连通测试失败" });
} finally {
setFeatureLoading(null);
}
};
const updateLocalKbQaSetting = async (checked: boolean) => {
setFeatureLoading("engine-settings");
setEngineMessage(null);
try {
const data = await aiStewardApi.updateLlmEngineSettings({ enable_local_kb_qa: checked });
setEngineData({
active_engine_id: data?.active_engine_id ?? null,
engines: data?.engines ?? [],
settings: data?.settings ?? DEFAULT_LLM_ENGINE_SETTINGS,
});
setEngineMessage({
type: "success",
text: checked ? "已启用本地 LLM,仅知识库问答可使用" : "已禁用本地 LLM",
});
} catch (err) {
setEngineMessage({ type: "error", text: err instanceof Error ? err.message : "更新开关失败" });
} finally {
setFeatureLoading(null);
}
};
const startEditEngine = (engine: LLMEngine) => {
setEngineEditingId(engine.id);
setEngineForm({
name: engine.name || "",
provider: engine.provider,
api_key: "",
model: engine.model || "",
base_url: engine.base_url || "",
});
setEngineMessage(null);
};
// ==================== Project Creation ====================
const handleProjectSubmit = async () => {
if (!input.trim()) {
setError("请输入项目描述");
return;
}
setLoading(true);
setError(null);
setProjectResult(null);
try {
const token = getAuthToken();
if (!token) {
navigate("/login");
return;
}
const response = await fetch(`${apiBaseURL}/ai-steward/project-from-text`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ input_text: input }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "项目创建失败");
}
const data: ProjectFromTextResponse = await response.json();
setProjectResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : "未知错误");
} finally {
setLoading(false);
}
};
const syncUrlParams = (
tab: AIStewardTab,
section: FeedbackCenterSection,
nextWindowDays: FeedbackCenterTrendWindowDays = trendWindowDays,
nextSourceGroup: FeedbackCenterSourceGroup = trendSourceGroup
) => {
const next = new URLSearchParams(searchParams);
next.set("tab", tab);
next.set("section", section);
next.set("window", String(nextWindowDays));
next.set("scope", nextSourceGroup);
setSearchParams(next, { replace: true });
};
const handleTabChange = (value: string) => {
const nextTab = normalizeTab(value);
if (nextTab === "feedback-center" && !canViewFeedbackCenter) {
setActiveTab("overview");
syncUrlParams("overview", "overview");
return;
}
setActiveTab(nextTab);
syncUrlParams(nextTab, feedbackSection);
};
const handleFeedbackSectionChange = (nextSection: FeedbackCenterSection) => {
setFeedbackSection(nextSection);
syncUrlParams(activeTab, nextSection);
};
const handleTrendWindowDaysChange = (nextWindowDays: FeedbackCenterTrendWindowDays) => {
setTrendWindowDays(nextWindowDays);
syncUrlParams(activeTab, feedbackSection, nextWindowDays, trendSourceGroup);
};
const handleTrendSourceGroupChange = (nextSourceGroup: FeedbackCenterSourceGroup) => {
setTrendSourceGroup(nextSourceGroup);
syncUrlParams(activeTab, feedbackSection, trendWindowDays, nextSourceGroup);
};
// ==================== Initial Load ====================
useEffect(() => {
const urlTab = normalizeTab(searchParams.get("tab"));
const urlSection = normalizeSection(searchParams.get("section"));
const safeTab = urlTab === "feedback-center" && !canViewFeedbackCenter ? "overview" : urlTab;
if (safeTab !== activeTab) {
setActiveTab(safeTab);
}
if (urlSection !== feedbackSection) {
setFeedbackSection(urlSection);
}
const urlWindow = normalizeTrendWindow(searchParams.get("window"));
if (urlWindow !== trendWindowDays) {
setTrendWindowDays(urlWindow);
}
const urlSourceGroup = normalizeTrendSourceGroup(searchParams.get("scope"));
if (urlSourceGroup !== trendSourceGroup) {
setTrendSourceGroup(urlSourceGroup);
}
}, [searchParams, activeTab, feedbackSection, trendWindowDays, trendSourceGroup, canViewFeedbackCenter]);
useEffect(() => {
if (activeTab === "conflicts") loadConflicts();
if (activeTab === "blockers") loadBlockers();
if (activeTab === "briefing") loadBriefing();
if (activeTab === "engine") {
loadEngineProviderPresets();
loadEngineConfigs();
}
}, [activeTab]);
// ==================== Render ====================
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<Sparkles className="w-10 h-10 text-violet-500" />
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100">
AI 管家
</h1>
</div>
<p className="text-slate-600 dark:text-slate-400 text-lg">
主动式 AI 管家 - 7x24 小时自动守护项目
</p>
</div>
<FeatureOverviewCards onSelectTab={(tab) => handleTabChange(tab)} />
{/* Main Content */}
<Card>
<CardContent className="pt-6">
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
<TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
<TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
<TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger>
<TabsTrigger value="briefing" className="shrink-0 whitespace-nowrap">每日简报</TabsTrigger>
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
{canViewFeedbackCenter && (
<TabsTrigger value="feedback-center" className="shrink-0 whitespace-nowrap">反馈中心</TabsTrigger>
)}
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
</TabsList>
<OverviewTab />
<CreateProjectTab
input={input}
loading={loading}
error={error}
projectResult={projectResult}
onInputChange={setInput}
onSubmit={handleProjectSubmit}
onResetProject={() => setProjectResult(null)}
/>
<SignalsTab
featureLoading={featureLoading}
onStartHNMonitor={() => void startHNMonitor()}
onOpenFeishuConfig={() => navigate("/feishu-config")}
/>
<ConflictsTab
featureLoading={featureLoading}
conflicts={conflicts}
onDetectConflicts={() => void detectConflicts()}
/>
<BlockersTab
featureLoading={featureLoading}
blockers={blockers}
onDetectTaskBlockers={() => void detectTaskBlockers()}
/>
<BriefingTab
featureLoading={featureLoading}
briefing={briefing}
briefingError={briefingError}
username={user?.username}
onRefreshBriefing={() => void loadBriefing()}
/>
{/* QA Records Tab */}
<TabsContent value="qa" className="mt-6">
<QAHistoryPanel />
</TabsContent>
{canViewFeedbackCenter && (
<FeedbackCenterTab
section={feedbackSection}
projectId={Number(searchParams.get("project_id") || "0") || null}
onSectionChange={handleFeedbackSectionChange}
trendWindowDays={trendWindowDays}
trendSourceGroup={trendSourceGroup}
onTrendWindowDaysChange={handleTrendWindowDaysChange}
onTrendSourceGroupChange={handleTrendSourceGroupChange}
/>
)}
<EngineTab
featureLoading={featureLoading}
engineData={engineData}
engineMessage={engineMessage}
engineForm={engineForm}
engineEditingId={engineEditingId}
engineProviderPresets={engineProviderPresets}
setEngineForm={setEngineForm}
onTestActiveEngine={() => void testEngineConnection()}
onRefreshEngines={() => void loadEngineConfigs()}
onUpdateLocalKbQaSetting={(checked) => void updateLocalKbQaSetting(checked)}
onActivateEngine={(engineId) => void activateEngine(engineId)}
onTestEngine={(engineId) => void testEngineConnection(engineId)}
onStartEditEngine={startEditEngine}
onDeleteEngine={(engineId) => void deleteEngine(engineId)}
onSaveEngineConfig={() => void saveEngineConfig()}
onResetEngineForm={resetEngineForm}
/>
</Tabs>
</CardContent>
</Card>
</div>
</div>
);
}