1714 lines
72 KiB
TypeScript
1714 lines
72 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { Link, useNavigate } from "react-router-dom";
|
||
import {
|
||
Settings as SettingsIcon, Loader2, CheckCircle2, AlertCircle, AlertTriangle,
|
||
MessageSquare, Activity, Github, Globe,
|
||
ChevronRight, Info, Sparkles, Building2, Upload, Link2, RefreshCw, Trash2,
|
||
Files, LayoutTemplate, History, ShieldCheck, Settings2
|
||
} from "lucide-react";
|
||
import { apiBaseURL, getAuthToken } from "@/lib/api";
|
||
import {
|
||
Card, CardContent, CardDescription, CardHeader, CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import PromptRoleSettings from "@/components/ai-steward/PromptRoleSettings";
|
||
import {
|
||
FEISHU_EVENT_MODE_OPTIONS,
|
||
type FeishuEventMode,
|
||
getFeishuEventModeLabel,
|
||
} from "@/lib/feishuEventMode";
|
||
|
||
// ==================== Types ====================
|
||
|
||
interface IntegrationSource {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
icon: any;
|
||
status: "available" | "configured" | "needs_setup" | "unavailable";
|
||
priority: "high" | "medium" | "low";
|
||
estimated_benefit: string;
|
||
setup_time: string;
|
||
auto_detected: boolean;
|
||
config_fields?: ConfigField[];
|
||
setup_guide?: string[];
|
||
}
|
||
|
||
interface ConfigField {
|
||
key: string;
|
||
label: string;
|
||
type: "text" | "password" | "url" | "toggle";
|
||
placeholder?: string;
|
||
required: boolean;
|
||
description?: string;
|
||
}
|
||
|
||
interface AIConfigSuggestion {
|
||
recommended_sources: string[];
|
||
reasoning: string;
|
||
estimated_impact: string;
|
||
}
|
||
|
||
interface ConfigStatus {
|
||
feishu: {
|
||
configured: boolean;
|
||
app_id?: string;
|
||
event_mode?: FeishuEventMode;
|
||
};
|
||
wecom: {
|
||
configured: boolean;
|
||
corp_id?: string;
|
||
agent_id?: string;
|
||
token_preview?: string;
|
||
encoding_aes_key_preview?: string;
|
||
};
|
||
github: { configured: boolean; repos?: string[] };
|
||
browser: { installed: boolean };
|
||
hn_monitor: { active: boolean; last_run?: string };
|
||
}
|
||
|
||
interface KnowledgeDoc {
|
||
id: number;
|
||
title: string;
|
||
file_type: string;
|
||
source_type: string;
|
||
file_url?: string | null;
|
||
status: "processing" | "complete" | "failed";
|
||
created_at?: string | null;
|
||
last_fetched_at?: string | null;
|
||
error_message?: string | null;
|
||
}
|
||
|
||
export default function AIConfigPage() {
|
||
const navigate = useNavigate();
|
||
|
||
const [, setLoading] = useState(true);
|
||
const [checking, setChecking] = useState<string | null>(null);
|
||
const [status, setStatus] = useState<ConfigStatus | null>(null);
|
||
const [suggestion, setSuggestion] = useState<AIConfigSuggestion | null>(null);
|
||
const [activeTab, setActiveTab] = useState("overview");
|
||
const [scrollToSection, setScrollToSection] = useState<string | null>(null);
|
||
|
||
// Feishu config form
|
||
const [feishuConfig, setFeishuConfig] = useState({
|
||
app_id: "",
|
||
app_secret: "",
|
||
event_mode: "long_connection" as FeishuEventMode
|
||
});
|
||
|
||
// WeChat Work config form
|
||
const [wecomConfig, setWecomConfig] = useState({
|
||
corp_id: "",
|
||
agent_id: "",
|
||
app_secret: "",
|
||
token: "",
|
||
encoding_aes_key: ""
|
||
});
|
||
|
||
// GitHub config form
|
||
const [githubConfig, setGithubConfig] = useState({
|
||
repo_url: "",
|
||
webhook_url: "",
|
||
access_token: ""
|
||
});
|
||
|
||
// Monitor settings
|
||
const [monitorSettings, setMonitorSettings] = useState({
|
||
hn_enabled: false,
|
||
hn_interval: 30,
|
||
browser_enabled: false
|
||
});
|
||
|
||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||
const [wecomCopyTip, setWecomCopyTip] = useState("");
|
||
const [knowledgeDocs, setKnowledgeDocs] = useState<KnowledgeDoc[]>([]);
|
||
const [knowledgeFile, setKnowledgeFile] = useState<File | null>(null);
|
||
const [knowledgeURL, setKnowledgeURL] = useState("");
|
||
const [knowledgeURLTitle, setKnowledgeURLTitle] = useState("");
|
||
|
||
// Guide expansion state
|
||
const [showFeishuGuide, setShowFeishuGuide] = useState(false);
|
||
const [showWecomGuide, setShowWecomGuide] = useState(false);
|
||
const [editingFeishu, setEditingFeishu] = useState(false);
|
||
const [editingWecom, setEditingWecom] = useState(false);
|
||
const [editingGithub, setEditingGithub] = useState(false);
|
||
|
||
const governanceTools = [
|
||
{
|
||
id: "brief-center",
|
||
name: "增量任务包中心",
|
||
description: "生成与查看任务包卡片",
|
||
to: "/ai-config/task-brief-center",
|
||
icon: Files,
|
||
},
|
||
{
|
||
id: "brief-template",
|
||
name: "任务包模板管理",
|
||
description: "统一维护模板与规范",
|
||
to: "/ai-config/task-brief-templates",
|
||
icon: LayoutTemplate,
|
||
},
|
||
{
|
||
id: "brief-runs",
|
||
name: "任务包生成记录",
|
||
description: "查看生成历史与状态",
|
||
to: "/ai-config/task-brief-runs",
|
||
icon: History,
|
||
},
|
||
{
|
||
id: "contract-board",
|
||
name: "契约同步看板",
|
||
description: "检查契约文档同步状态",
|
||
to: "/ai-config/contract-sync-board",
|
||
icon: ShieldCheck,
|
||
},
|
||
{
|
||
id: "model-routing",
|
||
name: "模型路由配置",
|
||
description: "动态切换函数模型路由",
|
||
to: "/ai-config/model-routing-config",
|
||
icon: Settings2,
|
||
},
|
||
];
|
||
|
||
// ==================== Integration Sources ====================
|
||
|
||
const integrationSources: IntegrationSource[] = [
|
||
{
|
||
id: "feishu",
|
||
name: "飞书群聊",
|
||
description: "自动捕获群聊中的需求讨论,AI 分析价值",
|
||
icon: MessageSquare,
|
||
status: status?.feishu?.configured ? "configured" : "needs_setup",
|
||
priority: "high",
|
||
estimated_benefit: "每天自动捕获 5-10 个需求信号",
|
||
setup_time: "5 分钟",
|
||
auto_detected: true,
|
||
config_fields: [
|
||
{ key: "app_id", label: "应用编号", type: "text", placeholder: "cli_xxxxxxxxxxxxx", required: true },
|
||
{ key: "app_secret", label: "应用密钥", type: "password", placeholder: "应用密钥", required: true },
|
||
],
|
||
setup_guide: [
|
||
"访问 https://open.feishu.cn/ 创建自建应用",
|
||
"在「凭证与基础信息」获取应用编号和应用密钥",
|
||
"飞书接入已统一为长连接模式,无需再配置回调地址",
|
||
"添加权限:im:message, im:message:group_at_msg, im:chat",
|
||
"订阅事件:接收消息 v1 (message.receive_v1)",
|
||
"保存后后端会自动重启飞书长连接"
|
||
]
|
||
},
|
||
{
|
||
id: "wecom",
|
||
name: "企业微信",
|
||
description: "自动捕获企业微信群消息,AI 分析需求价值",
|
||
icon: Building2,
|
||
status: status?.wecom?.configured ? "configured" : "needs_setup",
|
||
priority: "high",
|
||
estimated_benefit: "每天自动捕获 5-10 个需求信号",
|
||
setup_time: "5 分钟",
|
||
auto_detected: true,
|
||
config_fields: [
|
||
{ key: "corp_id", label: "企业编号", type: "text", placeholder: "wwxxxxxxxxxxxxxx", required: true },
|
||
{ key: "agent_id", label: "应用编号", type: "text", placeholder: "1000002", required: true },
|
||
{ key: "app_secret", label: "应用密钥", type: "password", placeholder: "应用密钥", required: true },
|
||
{ key: "token", label: "验证令牌", type: "text", placeholder: "回调验证令牌", required: true },
|
||
{ key: "encoding_aes_key", label: "消息加密密钥", type: "text", placeholder: "消息加密密钥(43位)", required: true }
|
||
],
|
||
setup_guide: [
|
||
"访问 https://work.weixin.qq.com/ 创建应用",
|
||
"在「应用管理」获取应用编号和应用密钥",
|
||
"在「我的企业」获取企业编号",
|
||
"在「应用管理」-「接收消息」设置验证令牌和消息加密密钥",
|
||
"添加权限:通讯录权限、消息权限",
|
||
"点击保存后,复制回调地址到企业微信配置"
|
||
]
|
||
},
|
||
{
|
||
id: "hn",
|
||
name: "技术资讯监控(HN)",
|
||
description: "定时抓取技术前沿,分析行业趋势",
|
||
icon: Activity,
|
||
status: status?.hn_monitor?.active ? "configured" : "available",
|
||
priority: "medium",
|
||
estimated_benefit: "每周捕获 2-3 个高价值技术趋势",
|
||
setup_time: "1 分钟",
|
||
auto_detected: true,
|
||
config_fields: [
|
||
{ key: "hn_interval", label: "抓取间隔(分钟)", type: "text", placeholder: "30", required: false }
|
||
]
|
||
},
|
||
{
|
||
id: "github",
|
||
name: "代码仓监控",
|
||
description: "跟踪竞品仓库、上游依赖更新",
|
||
icon: Github,
|
||
status: status?.github?.configured ? "configured" : "available",
|
||
priority: "medium",
|
||
estimated_benefit: "及时获知竞品动态和依赖更新",
|
||
setup_time: "10 分钟",
|
||
auto_detected: true,
|
||
config_fields: [
|
||
{ key: "repo_url", label: "仓库地址", type: "url", placeholder: "https://github.com/user/repo", required: true },
|
||
{ key: "access_token", label: "访问令牌", type: "password", placeholder: "ghp_xxxxxxxxxxxx", required: false }
|
||
]
|
||
},
|
||
{
|
||
id: "browser",
|
||
name: "浏览器扩展",
|
||
description: "监控访问的技术文档、竞品页面",
|
||
icon: Globe,
|
||
status: status?.browser?.installed ? "configured" : "needs_setup",
|
||
priority: "low",
|
||
estimated_benefit: "自动捕获浏览时的有价值内容",
|
||
setup_time: "2 分钟",
|
||
auto_detected: false,
|
||
config_fields: [],
|
||
setup_guide: [
|
||
"从 Chrome 应用商店安装 PMFlow 扩展",
|
||
"登录您的 PMFlow 账号",
|
||
"扩展会自动监控技术文档和竞品页面"
|
||
]
|
||
}
|
||
];
|
||
|
||
// ==================== API Calls ====================
|
||
|
||
const loadStatus = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const token = getAuthToken();
|
||
if (!token) {
|
||
navigate("/login");
|
||
return;
|
||
}
|
||
|
||
// Check Feishu config
|
||
const feishuRes = await fetch(`${apiBaseURL}/feishu/config`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (feishuRes.ok) {
|
||
const feishuData = await feishuRes.json();
|
||
const mode = "long_connection" as FeishuEventMode;
|
||
setStatus(prev => ({
|
||
...prev!,
|
||
feishu: {
|
||
configured: feishuData.is_configured,
|
||
app_id: feishuData.app_id,
|
||
event_mode: mode,
|
||
},
|
||
}));
|
||
setFeishuConfig({
|
||
app_id: feishuData.app_id || "",
|
||
app_secret: feishuData.app_secret || "",
|
||
event_mode: mode,
|
||
});
|
||
}
|
||
|
||
// Check WeChat Work config
|
||
const wecomRes = await fetch(`${apiBaseURL}/wecom/config`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (wecomRes.ok) {
|
||
const wecomData = await wecomRes.json();
|
||
setStatus(prev => ({
|
||
...prev!,
|
||
wecom: {
|
||
configured: wecomData.is_configured,
|
||
corp_id: wecomData.corp_id,
|
||
agent_id: wecomData.agent_id,
|
||
token_preview: wecomData.token_preview || "",
|
||
encoding_aes_key_preview: wecomData.encoding_aes_key_preview || "",
|
||
},
|
||
}));
|
||
setWecomConfig({
|
||
corp_id: wecomData.corp_id || "",
|
||
agent_id: wecomData.agent_id || "",
|
||
app_secret: wecomData.app_secret || "",
|
||
token: wecomData.token || "",
|
||
encoding_aes_key: wecomData.encoding_aes_key || "",
|
||
});
|
||
}
|
||
|
||
// Get AI suggestions
|
||
const suggestRes = await fetch(`${apiBaseURL}/ai-steward/config/suggest`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (suggestRes.ok) {
|
||
const suggestData = await suggestRes.json();
|
||
setSuggestion(suggestData);
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error("Failed to load status:", err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const saveFeishuConfig = async () => {
|
||
setChecking("feishu");
|
||
setMessage(null);
|
||
if (!feishuConfig.app_id || !feishuConfig.app_secret) {
|
||
setMessage({ type: "error", text: "请填写应用编号和应用密钥" });
|
||
setChecking(null);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify(feishuConfig)
|
||
});
|
||
|
||
if (response.ok) {
|
||
setMessage({ type: "success", text: "飞书配置已保存" });
|
||
setEditingFeishu(false);
|
||
await loadStatus();
|
||
} else {
|
||
const error = await response.json();
|
||
setMessage({ type: "error", text: error.detail || "保存失败" });
|
||
}
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "网络错误,请重试" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const testFeishuConnection = async () => {
|
||
setChecking("feishu-test");
|
||
setMessage(null);
|
||
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/feishu/test-connection`, {
|
||
method: "POST",
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === "success") {
|
||
setMessage({ type: "success", text: "飞书连接测试成功!" });
|
||
} else {
|
||
setMessage({ type: "error", text: data.message || "连接测试失败" });
|
||
}
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "测试失败" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const deleteFeishuConfig = async () => {
|
||
if (!confirm("确定要删除飞书配置吗?删除后将无法从飞书群聊捕获信号。")) return;
|
||
|
||
setChecking("feishu-delete");
|
||
setMessage(null);
|
||
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/feishu/config`, {
|
||
method: "DELETE",
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
if (response.ok) {
|
||
setMessage({ type: "success", text: "配置已删除" });
|
||
setFeishuConfig({ app_id: "", app_secret: "", event_mode: "long_connection" });
|
||
setEditingFeishu(false);
|
||
await loadStatus();
|
||
} else {
|
||
setMessage({ type: "error", text: "删除失败" });
|
||
}
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "网络错误" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const saveWecomConfig = async () => {
|
||
setChecking("wecom");
|
||
setMessage(null);
|
||
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/wecom/config`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify(wecomConfig)
|
||
});
|
||
|
||
if (response.ok) {
|
||
setMessage({ type: "success", text: "企业微信配置已保存" });
|
||
setEditingWecom(false);
|
||
await loadStatus();
|
||
} else {
|
||
const error = await response.json();
|
||
setMessage({ type: "error", text: error.detail || "保存失败" });
|
||
}
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "网络错误,请重试" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const testWecomConnection = async () => {
|
||
setChecking("wecom-test");
|
||
setMessage(null);
|
||
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/wecom/test-connection`, {
|
||
method: "POST",
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.status === "success") {
|
||
setMessage({ type: "success", text: "企业微信连接测试成功!" });
|
||
} else {
|
||
setMessage({ type: "error", text: data.message || "连接测试失败" });
|
||
}
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "测试失败" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const deleteWecomConfig = async () => {
|
||
if (!confirm("确定要删除企业微信配置吗?删除后将无法从企业微信群聊捕获信号。")) return;
|
||
|
||
setChecking("wecom-delete");
|
||
setMessage(null);
|
||
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/wecom/config`, {
|
||
method: "DELETE",
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
if (response.ok) {
|
||
setMessage({ type: "success", text: "配置已删除" });
|
||
setWecomConfig({ corp_id: "", agent_id: "", app_secret: "", token: "", encoding_aes_key: "" });
|
||
setEditingWecom(false);
|
||
await loadStatus();
|
||
} else {
|
||
setMessage({ type: "error", text: "删除失败" });
|
||
}
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "网络错误" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const getPublicBaseUrl = () => {
|
||
const normalized = apiBaseURL.replace(/\/api\/?$/, "");
|
||
if (/^https?:\/\//i.test(normalized)) {
|
||
return normalized.replace(/\/$/, "");
|
||
}
|
||
return window.location.origin.replace(/\/$/, "");
|
||
};
|
||
|
||
const getWecomWebhookUrl = () => `${getPublicBaseUrl()}/wecom/webhook`;
|
||
|
||
const startHNMonitor = async () => {
|
||
setChecking("hn");
|
||
setMessage(null);
|
||
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/ai-steward/signal/start-hn-monitor?interval_minutes=${monitorSettings.hn_interval}`, {
|
||
method: "POST",
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
if (response.ok) {
|
||
setMessage({ type: "success", text: "技术资讯监控已启动" });
|
||
setMonitorSettings(prev => ({ ...prev!, hn_enabled: true }));
|
||
} else {
|
||
setMessage({ type: "error", text: "启动失败" });
|
||
}
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "网络错误" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const copyText = async (text: string) => {
|
||
try {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
}
|
||
} catch {
|
||
// fallback below
|
||
}
|
||
|
||
try {
|
||
const input = document.createElement("textarea");
|
||
input.value = text;
|
||
input.style.position = "fixed";
|
||
input.style.opacity = "0";
|
||
input.style.left = "-9999px";
|
||
document.body.appendChild(input);
|
||
input.focus();
|
||
input.select();
|
||
const ok = document.execCommand("copy");
|
||
document.body.removeChild(input);
|
||
return ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const handleCopyWebhook = async (text: string) => {
|
||
const ok = await copyText(text);
|
||
if (ok) {
|
||
setMessage({ type: "success", text: "已复制到剪切板" });
|
||
setWecomCopyTip("已复制到剪切板");
|
||
setTimeout(() => setWecomCopyTip(""), 1500);
|
||
return;
|
||
}
|
||
setMessage({ type: "error", text: "复制失败,请手动复制地址" });
|
||
setWecomCopyTip("复制失败");
|
||
setTimeout(() => setWecomCopyTip(""), 1500);
|
||
};
|
||
|
||
const loadKnowledgeDocs = async () => {
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/docs`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (!response.ok) return;
|
||
const payload = await response.json();
|
||
const docs = (payload?.data ?? payload) || [];
|
||
setKnowledgeDocs(Array.isArray(docs) ? docs : []);
|
||
} catch (err) {
|
||
console.error("Failed to load knowledge docs:", err);
|
||
}
|
||
};
|
||
|
||
const uploadKnowledgeFile = async () => {
|
||
if (!knowledgeFile) {
|
||
setMessage({ type: "error", text: "请先选择文件" });
|
||
return;
|
||
}
|
||
setChecking("knowledge-upload");
|
||
try {
|
||
const token = getAuthToken();
|
||
const formData = new FormData();
|
||
formData.append("file", knowledgeFile);
|
||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/upload`, {
|
||
method: "POST",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
body: formData,
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
setMessage({ type: "error", text: payload?.detail || "上传失败" });
|
||
return;
|
||
}
|
||
setKnowledgeFile(null);
|
||
setMessage({ type: "success", text: "文件已提交,正在向量化处理" });
|
||
await loadKnowledgeDocs();
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "上传失败,请检查网络" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const addKnowledgeURL = async () => {
|
||
if (!knowledgeURL.trim()) {
|
||
setMessage({ type: "error", text: "请输入 URL" });
|
||
return;
|
||
}
|
||
setChecking("knowledge-url");
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/urls`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({
|
||
url: knowledgeURL.trim(),
|
||
title: knowledgeURLTitle.trim() || undefined,
|
||
}),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
setMessage({ type: "error", text: payload?.detail || "URL 添加失败" });
|
||
return;
|
||
}
|
||
setKnowledgeURL("");
|
||
setKnowledgeURLTitle("");
|
||
setMessage({ type: "success", text: "URL 已加入知识库,正在抓取与向量化" });
|
||
await loadKnowledgeDocs();
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "URL 添加失败,请稍后重试" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const refreshKnowledgeDoc = async (docId: number) => {
|
||
setChecking(`knowledge-refresh-${docId}`);
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/docs/${docId}/refresh`, {
|
||
method: "POST",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (!response.ok) {
|
||
const payload = await response.json().catch(() => ({}));
|
||
setMessage({ type: "error", text: payload?.detail || "刷新失败" });
|
||
return;
|
||
}
|
||
setMessage({ type: "success", text: "已触发刷新,正在处理" });
|
||
await loadKnowledgeDocs();
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "刷新失败,请稍后重试" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
const deleteKnowledgeDoc = async (docId: number) => {
|
||
if (!confirm("确认删除该知识库条目及其向量数据?")) return;
|
||
setChecking(`knowledge-delete-${docId}`);
|
||
try {
|
||
const token = getAuthToken();
|
||
const response = await fetch(`${apiBaseURL}/rag/knowledge-base/docs/${docId}`, {
|
||
method: "DELETE",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (!response.ok) {
|
||
const payload = await response.json().catch(() => ({}));
|
||
setMessage({ type: "error", text: payload?.detail || "删除失败" });
|
||
return;
|
||
}
|
||
setMessage({ type: "success", text: "已删除知识库条目" });
|
||
await loadKnowledgeDocs();
|
||
} catch (err) {
|
||
setMessage({ type: "error", text: "删除失败,请稍后重试" });
|
||
} finally {
|
||
setChecking(null);
|
||
}
|
||
};
|
||
|
||
// ==================== Initial Load ====================
|
||
|
||
// Scroll to section when tab changes to configure
|
||
useEffect(() => {
|
||
if (activeTab === "configure" && scrollToSection) {
|
||
// Small delay to ensure the tab content is rendered
|
||
setTimeout(() => {
|
||
const element = document.getElementById(`config-${scrollToSection}`);
|
||
if (element) {
|
||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
setScrollToSection(null);
|
||
}, 100);
|
||
}
|
||
}, [activeTab, scrollToSection]);
|
||
|
||
const handleConfigureClick = (sourceId: string) => {
|
||
setScrollToSection(sourceId);
|
||
setActiveTab("configure");
|
||
};
|
||
|
||
useEffect(() => {
|
||
setStatus({
|
||
feishu: { configured: false },
|
||
wecom: { configured: false },
|
||
github: { configured: false },
|
||
browser: { installed: false },
|
||
hn_monitor: { active: false }
|
||
});
|
||
loadStatus();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (activeTab !== "knowledge") return;
|
||
loadKnowledgeDocs();
|
||
}, [activeTab]);
|
||
|
||
useEffect(() => {
|
||
if (activeTab !== "knowledge") return;
|
||
if (!knowledgeDocs.some((item) => item.status === "processing")) return;
|
||
const timer = setInterval(() => {
|
||
loadKnowledgeDocs();
|
||
}, 3000);
|
||
return () => clearInterval(timer);
|
||
}, [activeTab, knowledgeDocs]);
|
||
|
||
// ==================== Helper Components ====================
|
||
|
||
const StatusBadge = ({ status }: { status: IntegrationSource["status"] }) => {
|
||
const config = {
|
||
configured: { label: "已配置", variant: "default" as const, icon: CheckCircle2, color: "text-green-600" },
|
||
available: { label: "可用", variant: "secondary" as const, icon: Sparkles, color: "text-blue-600" },
|
||
needs_setup: { label: "待配置", variant: "outline" as const, icon: AlertTriangle, color: "text-orange-600" },
|
||
unavailable: { label: "不可用", variant: "secondary" as const, icon: AlertCircle, color: "text-slate-400" }
|
||
};
|
||
const { label, variant, icon: Icon, color } = config[status];
|
||
return (
|
||
<Badge variant={variant} className="gap-1">
|
||
<Icon className={`w-3 h-3 ${color}`} />
|
||
{label}
|
||
</Badge>
|
||
);
|
||
};
|
||
|
||
const PriorityBadge = ({ priority }: { priority: IntegrationSource["priority"] }) => {
|
||
const config = {
|
||
high: { label: "高优先级", className: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" },
|
||
medium: { label: "中优先级", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" },
|
||
low: { label: "低优先级", className: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300" }
|
||
};
|
||
const { label, className } = config[priority];
|
||
return <span className={`px-2 py-0.5 rounded text-xs ${className}`}>{label}</span>;
|
||
};
|
||
|
||
// ==================== 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-6xl mx-auto">
|
||
{/* Header */}
|
||
<div className="text-center mb-8">
|
||
<div className="flex items-center justify-center gap-3 mb-4">
|
||
<SettingsIcon className="w-10 h-10 text-violet-500" />
|
||
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||
AI 智能配置中心
|
||
</h1>
|
||
</div>
|
||
<p className="text-slate-600 dark:text-slate-400">
|
||
AI 自动识别可配置的集成源,一键启用主动捕获能力
|
||
</p>
|
||
</div>
|
||
|
||
{/* AI Suggestion Card */}
|
||
{suggestion && (
|
||
<Card className="mb-6 border-violet-200 dark:border-violet-800 bg-violet-50 dark:bg-violet-900/20">
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-start gap-3">
|
||
<Sparkles className="w-5 h-5 text-violet-500 mt-0.5" />
|
||
<div>
|
||
<h3 className="font-semibold text-violet-900 dark:text-violet-100">AI 建议配置方案</h3>
|
||
<p className="text-sm text-violet-700 dark:text-violet-300 mt-1">{suggestion.reasoning}</p>
|
||
<p className="text-xs text-violet-600 dark:text-violet-400 mt-2">预期影响: {suggestion.estimated_impact}</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Message */}
|
||
{message && (
|
||
<div className={`mb-4 p-3 rounded-lg flex items-center gap-2 ${
|
||
message.type === "success"
|
||
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
||
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
|
||
}`}>
|
||
{message.type === "success" ? (
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
) : (
|
||
<AlertCircle className="w-4 h-4" />
|
||
)}
|
||
{message.text}
|
||
</div>
|
||
)}
|
||
|
||
<Card className="mb-6 border-slate-200/80 bg-white/80 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70">
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">配置治理工具</CardTitle>
|
||
<CardDescription>新增治理页面统一收敛到智能配置中心入口,避免侧栏信息过载。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||
{governanceTools.map(({ id, name, description, to, icon: Icon }) => (
|
||
<Link
|
||
key={id}
|
||
to={to}
|
||
className="group rounded-2xl border border-slate-200 bg-white p-3 transition-all hover:-translate-y-0.5 hover:border-blue-300 hover:shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-800"
|
||
>
|
||
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||
<Icon className="h-4 w-4" />
|
||
</div>
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{name}</p>
|
||
<p className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">{description}</p>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Main Content */}
|
||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-2 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">集成源概览</TabsTrigger>
|
||
<TabsTrigger value="configure" className="shrink-0 whitespace-nowrap">快速配置</TabsTrigger>
|
||
<TabsTrigger value="knowledge" className="shrink-0 whitespace-nowrap">知识库</TabsTrigger>
|
||
<TabsTrigger value="advanced" className="shrink-0 whitespace-nowrap">高级设置</TabsTrigger>
|
||
<TabsTrigger value="prompts" className="shrink-0 whitespace-nowrap">Prompt 设置</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* Overview Tab */}
|
||
<TabsContent value="overview" className="mt-6">
|
||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||
{integrationSources.map((source) => {
|
||
const Icon = source.icon;
|
||
return (
|
||
<Card key={source.id} className="hover:shadow-lg transition-shadow">
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||
source.status === "configured" ? "bg-green-100 dark:bg-green-900/30" :
|
||
source.status === "needs_setup" ? "bg-orange-100 dark:bg-orange-900/30" :
|
||
"bg-slate-100 dark:bg-slate-800"
|
||
}`}>
|
||
<Icon className={`w-5 h-5 ${
|
||
source.status === "configured" ? "text-green-600 dark:text-green-400" :
|
||
source.status === "needs_setup" ? "text-orange-600 dark:text-orange-400" :
|
||
"text-slate-600 dark:text-slate-400"
|
||
}`} />
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-lg">{source.name}</CardTitle>
|
||
<CardDescription className="text-sm">{source.description}</CardDescription>
|
||
</div>
|
||
</div>
|
||
<StatusBadge status={source.status} />
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-slate-500">优先级</span>
|
||
<PriorityBadge priority={source.priority} />
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-slate-500">预计收益</span>
|
||
<span className="text-xs text-slate-700 dark:text-slate-300">{source.estimated_benefit}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-slate-500">配置耗时</span>
|
||
<span className="text-xs text-slate-700 dark:text-slate-300">{source.setup_time}</span>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full mt-2"
|
||
onClick={() => handleConfigureClick(source.id)}
|
||
>
|
||
{source.status === "configured" ? "查看配置" : "开始配置"}
|
||
<ChevronRight className="w-4 h-4 ml-1" />
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* Quick Configure Tab */}
|
||
<TabsContent value="configure" className="mt-6">
|
||
<div className="space-y-6">
|
||
{/* Feishu Config */}
|
||
<Card id="config-feishu" className={status?.feishu?.configured ? "border-green-200 dark:border-green-800" : ""}>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||
<CardTitle>飞书群聊集成</CardTitle>
|
||
{status?.feishu?.configured && (
|
||
<Badge className="ml-2" variant="default">已配置</Badge>
|
||
)}
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setShowFeishuGuide(!showFeishuGuide)}
|
||
>
|
||
{showFeishuGuide ? "收起指南" : "配置指南"}
|
||
<ChevronRight className={`w-4 h-4 ml-1 transition-transform ${showFeishuGuide ? "rotate-90" : ""}`} />
|
||
</Button>
|
||
</div>
|
||
<CardDescription>
|
||
AI 自动从飞书群聊捕获需求信号,无需手动录入
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* Setup Guide */}
|
||
{showFeishuGuide && (
|
||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-3 flex items-center gap-2">
|
||
<Info className="w-4 h-4" />
|
||
飞书配置指南(6 步完成)
|
||
</h4>
|
||
<div className="space-y-2">
|
||
{integrationSources.find(s => s.id === "feishu")?.setup_guide?.map((step, index) => (
|
||
<div key={index} className="flex items-start gap-2">
|
||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center">
|
||
{index + 1}
|
||
</div>
|
||
<span className="text-sm text-blue-800 dark:text-blue-200">{step}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-800 dark:text-amber-200">
|
||
⚠️ 配置完成后,请点击下方"测试连接"按钮验证
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{status?.feishu?.configured && !editingFeishu ? (
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span className="font-medium">飞书已配置</span>
|
||
{status.feishu.app_id && (
|
||
<span className="text-xs ml-2 text-green-600 dark:text-green-400">
|
||
(应用编号: {status.feishu.app_id})
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-green-700/80 dark:text-green-300/80 mt-2">
|
||
当前接收模式:{getFeishuEventModeLabel(status.feishu.event_mode || "long_connection")}
|
||
</p>
|
||
<div className="mt-3 flex gap-2">
|
||
<Button onClick={testFeishuConnection} disabled={checking === "feishu-test"} size="sm">
|
||
{checking === "feishu-test" ? <Loader2 className="w-4 h-4 animate-spin" /> : "测试连接"}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setEditingFeishu(true);
|
||
void loadStatus();
|
||
}}
|
||
>
|
||
修改配置
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={deleteFeishuConfig}
|
||
disabled={checking === "feishu-delete"}
|
||
className="text-red-600 hover:text-red-700"
|
||
>
|
||
{checking === "feishu-delete" ? <Loader2 className="w-4 h-4 animate-spin" /> : "删除配置"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{status?.feishu?.configured && (
|
||
<div className="p-2 rounded bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-800 dark:text-amber-200">
|
||
正在修改已有配置:已自动回填当前配置,可直接核对并修改后保存。
|
||
</div>
|
||
)}
|
||
<div>
|
||
<Label className="mb-2 block">事件接收方式 *</Label>
|
||
<p className="text-xs text-slate-500 mb-2">
|
||
飞书接入已统一为 <code>long_connection</code>
|
||
</p>
|
||
<div className="grid grid-cols-1 gap-2">
|
||
{FEISHU_EVENT_MODE_OPTIONS.map((option) => (
|
||
<label key={option.value} className="flex items-center gap-2 rounded border px-3 py-2 text-sm cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="feishu_event_mode"
|
||
value={option.value}
|
||
checked={feishuConfig.event_mode === option.value}
|
||
onChange={() => setFeishuConfig({ ...feishuConfig, event_mode: option.value })}
|
||
/>
|
||
<span>{option.label}</span>
|
||
<code className="text-[11px] text-slate-500">({option.value})</code>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<Label htmlFor="feishu_app_id">应用编号 *</Label>
|
||
<Input
|
||
id="feishu_app_id"
|
||
placeholder="cli_xxxxxxxxxxxxx"
|
||
value={feishuConfig.app_id}
|
||
onChange={(e) => setFeishuConfig({ ...feishuConfig, app_id: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="feishu_app_secret">应用密钥 *</Label>
|
||
<Input
|
||
id="feishu_app_secret"
|
||
type="password"
|
||
placeholder="应用密钥"
|
||
value={feishuConfig.app_secret}
|
||
onChange={(e) => setFeishuConfig({ ...feishuConfig, app_secret: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||
已切换到飞书长连接模式。无需配置 Webhook、Encrypt Key 或 Verification Token。
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={saveFeishuConfig}
|
||
disabled={checking === "feishu" || !feishuConfig.app_id || !feishuConfig.app_secret}
|
||
className="w-full"
|
||
>
|
||
{checking === "feishu" ? (
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
) : (
|
||
status?.feishu?.configured ? "更新配置" : "保存配置"
|
||
)}
|
||
</Button>
|
||
{status?.feishu?.configured && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setEditingFeishu(false)}
|
||
disabled={checking === "feishu"}
|
||
>
|
||
取消
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="p-3 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs text-slate-600 dark:text-slate-300">
|
||
当前选择的是「长连接」模式,无需配置 Webhook 请求地址。
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* WeChat Work Config */}
|
||
<Card id="config-wecom" className={status?.wecom?.configured ? "border-green-200 dark:border-green-800" : ""}>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Building2 className="w-5 h-5 text-emerald-500" />
|
||
<CardTitle>企业微信集成</CardTitle>
|
||
{status?.wecom?.configured && (
|
||
<Badge className="ml-2" variant="default">已配置</Badge>
|
||
)}
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setShowWecomGuide(!showWecomGuide)}
|
||
>
|
||
{showWecomGuide ? "收起指南" : "配置指南"}
|
||
<ChevronRight className={`w-4 h-4 ml-1 transition-transform ${showWecomGuide ? "rotate-90" : ""}`} />
|
||
</Button>
|
||
</div>
|
||
<CardDescription>
|
||
AI 自动从企业微信群聊捕获需求信号,无需手动录入
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* Setup Guide */}
|
||
{showWecomGuide && (
|
||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg border border-emerald-200 dark:border-emerald-800">
|
||
<h4 className="font-semibold text-emerald-900 dark:text-emerald-100 mb-3 flex items-center gap-2">
|
||
<Info className="w-4 h-4" />
|
||
企业微信配置指南(6 步完成)
|
||
</h4>
|
||
<div className="space-y-2">
|
||
{integrationSources.find(s => s.id === "wecom")?.setup_guide?.map((step, index) => (
|
||
<div key={index} className="flex items-start gap-2">
|
||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-emerald-500 text-white text-xs flex items-center justify-center">
|
||
{index + 1}
|
||
</div>
|
||
<span className="text-sm text-emerald-800 dark:text-emerald-200">{step}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-800 dark:text-amber-200">
|
||
⚠️ 配置完成后,请点击下方"测试连接"按钮验证
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{status?.wecom?.configured && !editingWecom ? (
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span className="font-medium">企业微信已配置</span>
|
||
{status.wecom.corp_id && (
|
||
<span className="text-xs ml-2 text-green-600 dark:text-green-400">
|
||
(企业编号: {status.wecom.corp_id})
|
||
</span>
|
||
)}
|
||
</div>
|
||
{status.wecom.agent_id && (
|
||
<p className="text-xs text-green-700/80 dark:text-green-300/80 mt-2">
|
||
应用编号:{status.wecom.agent_id}
|
||
</p>
|
||
)}
|
||
<p className="text-xs text-green-700/80 dark:text-green-300/80 mt-1">
|
||
Token: {status.wecom.token_preview || "未设置"},EncodingAESKey: {status.wecom.encoding_aes_key_preview || "未设置"}
|
||
</p>
|
||
<div className="mt-3 flex gap-2">
|
||
<Button onClick={testWecomConnection} disabled={checking === "wecom-test"} size="sm">
|
||
{checking === "wecom-test" ? <Loader2 className="w-4 h-4 animate-spin" /> : "测试连接"}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setEditingWecom(true);
|
||
void loadStatus();
|
||
}}
|
||
>
|
||
修改配置
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={deleteWecomConfig}
|
||
disabled={checking === "wecom-delete"}
|
||
className="text-red-600 hover:text-red-700"
|
||
>
|
||
{checking === "wecom-delete" ? <Loader2 className="w-4 h-4 animate-spin" /> : "删除配置"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{status?.wecom?.configured && (
|
||
<div className="p-2 rounded bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-800 dark:text-amber-200">
|
||
正在修改已有配置:已自动回填当前配置,可直接核对并修改后保存。
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<Label htmlFor="wecom_corp_id">企业编号 *</Label>
|
||
<Input
|
||
id="wecom_corp_id"
|
||
placeholder="wwxxxxxxxxxxxxxx"
|
||
value={wecomConfig.corp_id}
|
||
onChange={(e) => setWecomConfig({ ...wecomConfig, corp_id: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="wecom_agent_id">应用编号 *</Label>
|
||
<Input
|
||
id="wecom_agent_id"
|
||
placeholder="1000002"
|
||
value={wecomConfig.agent_id}
|
||
onChange={(e) => setWecomConfig({ ...wecomConfig, agent_id: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="wecom_app_secret">应用密钥 *</Label>
|
||
<Input
|
||
id="wecom_app_secret"
|
||
type="password"
|
||
placeholder="应用密钥"
|
||
value={wecomConfig.app_secret}
|
||
onChange={(e) => setWecomConfig({ ...wecomConfig, app_secret: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<Label htmlFor="wecom_token">验证令牌 *</Label>
|
||
<Input
|
||
id="wecom_token"
|
||
placeholder="回调验证令牌"
|
||
value={wecomConfig.token}
|
||
onChange={(e) => setWecomConfig({ ...wecomConfig, token: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="wecom_encoding_aes_key">消息加密密钥 *</Label>
|
||
<Input
|
||
id="wecom_encoding_aes_key"
|
||
placeholder="消息加密密钥(43位)"
|
||
value={wecomConfig.encoding_aes_key}
|
||
onChange={(e) => setWecomConfig({ ...wecomConfig, encoding_aes_key: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={saveWecomConfig}
|
||
disabled={checking === "wecom" || !wecomConfig.corp_id || !wecomConfig.agent_id || !wecomConfig.app_secret}
|
||
className="w-full"
|
||
>
|
||
{checking === "wecom" ? (
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
) : (
|
||
status?.wecom?.configured ? "更新配置" : "保存配置"
|
||
)}
|
||
</Button>
|
||
{status?.wecom?.configured && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setEditingWecom(false);
|
||
void loadStatus();
|
||
}}
|
||
disabled={checking === "wecom"}
|
||
>
|
||
取消
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 回调地址 */}
|
||
<div className="p-3 bg-slate-100 dark:bg-slate-800 rounded-lg">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Label className="text-sm">回调地址</Label>
|
||
<div className="flex items-center gap-2">
|
||
{wecomCopyTip && <span className="text-xs text-green-600">{wecomCopyTip}</span>}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleCopyWebhook(getWecomWebhookUrl())}
|
||
>
|
||
复制
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<code className="text-xs break-all block">{getWecomWebhookUrl()}</code>
|
||
<p className="text-xs text-slate-500 mt-2">
|
||
复制此回调地址到企业微信管理后台 - 应用管理 - 接收消息 - 请求地址
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* HN Monitor */}
|
||
<Card id="config-hn">
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Activity className="w-5 h-5 text-purple-500" />
|
||
<CardTitle>技术资讯监控(HN)</CardTitle>
|
||
{status?.hn_monitor?.active && (
|
||
<Badge className="ml-auto" variant="default">运行中</Badge>
|
||
)}
|
||
</div>
|
||
<CardDescription>
|
||
定时抓取 HN 首页,AI 分析技术趋势
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<Label>启用监控</Label>
|
||
<p className="text-xs text-slate-500">每 {monitorSettings.hn_interval} 分钟抓取一次</p>
|
||
</div>
|
||
<Switch
|
||
checked={monitorSettings.hn_enabled}
|
||
onCheckedChange={(checked) => {
|
||
if (checked) startHNMonitor();
|
||
setMonitorSettings({ ...monitorSettings, hn_enabled: checked });
|
||
}}
|
||
/>
|
||
</div>
|
||
{!monitorSettings.hn_enabled && (
|
||
<Button
|
||
onClick={startHNMonitor}
|
||
disabled={checking === "hn"}
|
||
className="w-full"
|
||
>
|
||
{checking === "hn" ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : "启动监控"}
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* GitHub */}
|
||
<Card id="config-github" className={status?.github?.configured ? "border-green-200 dark:border-green-800" : ""}>
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Github className="w-5 h-5 text-slate-700 dark:text-slate-300" />
|
||
<CardTitle>代码仓监控</CardTitle>
|
||
{status?.github?.configured && (
|
||
<Badge className="ml-2" variant="default">已配置</Badge>
|
||
)}
|
||
</div>
|
||
<CardDescription>
|
||
跟踪竞品仓库更新、上游依赖变更
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{status?.github?.configured && !editingGithub ? (
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span className="font-medium">代码仓监控已配置</span>
|
||
{status.github.repos && (
|
||
<span className="text-xs ml-2 text-green-600 dark:text-green-400">
|
||
({status.github.repos.length} 个仓库)
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setGithubConfig((prev) => ({
|
||
...prev,
|
||
repo_url: status?.github?.repos?.join(",") || prev.repo_url,
|
||
}));
|
||
setEditingGithub(true);
|
||
}}
|
||
>
|
||
修改配置
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setGithubConfig({ repo_url: "", webhook_url: "", access_token: "" });
|
||
setStatus(prev => ({ ...prev!, github: { configured: false } }));
|
||
setEditingGithub(false);
|
||
}}
|
||
className="text-red-600 hover:text-red-700"
|
||
>
|
||
删除配置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label htmlFor="github_repo_url">仓库地址 *</Label>
|
||
<Input
|
||
id="github_repo_url"
|
||
placeholder="https://github.com/user/repo"
|
||
value={githubConfig.repo_url}
|
||
onChange={(e) => setGithubConfig({ ...githubConfig, repo_url: e.target.value })}
|
||
/>
|
||
<p className="text-xs text-slate-500 mt-1">支持输入多个仓库,用逗号分隔</p>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="github_webhook_url">回调地址</Label>
|
||
<Input
|
||
id="github_webhook_url"
|
||
placeholder="https://your-domain.com/api/github/webhook"
|
||
value={githubConfig.webhook_url}
|
||
onChange={(e) => setGithubConfig({ ...githubConfig, webhook_url: e.target.value })}
|
||
/>
|
||
<p className="text-xs text-slate-500 mt-1">仓库侧配置的回调地址</p>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="github_token">访问令牌</Label>
|
||
<Input
|
||
id="github_token"
|
||
type="password"
|
||
placeholder="ghp_xxxxxxxxxxxx"
|
||
value={githubConfig.access_token}
|
||
onChange={(e) => setGithubConfig({ ...githubConfig, access_token: e.target.value })}
|
||
/>
|
||
<p className="text-xs text-slate-500 mt-1">用于获取仓库信息的个人访问令牌</p>
|
||
</div>
|
||
<Button
|
||
onClick={() => {
|
||
setMessage({ type: "success", text: "代码仓监控配置已保存" });
|
||
setStatus(prev => ({ ...prev!, github: { configured: true, repos: githubConfig.repo_url.split(",") } }));
|
||
setEditingGithub(false);
|
||
}}
|
||
disabled={!githubConfig.repo_url}
|
||
className="w-full"
|
||
>
|
||
{status?.github?.configured ? "更新配置" : "保存配置"}
|
||
</Button>
|
||
{status?.github?.configured && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setEditingGithub(false)}
|
||
className="w-full"
|
||
>
|
||
取消
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Browser Extension */}
|
||
<Card id="config-browser">
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Globe className="w-5 h-5 text-green-500" />
|
||
<CardTitle>浏览器扩展</CardTitle>
|
||
<Badge className="ml-auto" variant="secondary">敬请期待</Badge>
|
||
</div>
|
||
<CardDescription>
|
||
自动监控访问的技术文档、竞品页面
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="p-4 bg-slate-100 dark:bg-slate-800 rounded-lg text-center">
|
||
<Globe className="w-8 h-8 text-slate-400 mx-auto mb-2" />
|
||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||
浏览器扩展正在开发中
|
||
</p>
|
||
<p className="text-xs text-slate-500 mt-1">
|
||
支持功能:自动捕获页面内容、智能分析价值
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* Knowledge Base Tab */}
|
||
<TabsContent value="knowledge" className="mt-6">
|
||
<div className="space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Upload className="w-5 h-5 text-violet-500" />
|
||
文件上传
|
||
</CardTitle>
|
||
<CardDescription>支持 PDF / Word / Markdown / TXT,上传后自动分块与向量化。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<Input
|
||
type="file"
|
||
accept=".pdf,.doc,.docx,.md,.markdown,.txt"
|
||
onChange={(e) => setKnowledgeFile(e.target.files?.[0] || null)}
|
||
/>
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-xs text-slate-500">
|
||
{knowledgeFile ? `已选择: ${knowledgeFile.name}` : "未选择文件"}
|
||
</div>
|
||
<Button onClick={uploadKnowledgeFile} disabled={!knowledgeFile || checking === "knowledge-upload"}>
|
||
{checking === "knowledge-upload" ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
上传中…
|
||
</>
|
||
) : (
|
||
"上传并处理"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Link2 className="w-5 h-5 text-blue-500" />
|
||
URL 知识源
|
||
</CardTitle>
|
||
<CardDescription>添加公开文档 URL,系统自动抓取并向量化,可手动刷新。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<Input
|
||
placeholder="URL 标题(可选)"
|
||
value={knowledgeURLTitle}
|
||
onChange={(e) => setKnowledgeURLTitle(e.target.value)}
|
||
/>
|
||
<Input
|
||
placeholder="https://example.com/docs"
|
||
value={knowledgeURL}
|
||
onChange={(e) => setKnowledgeURL(e.target.value)}
|
||
/>
|
||
<div className="flex justify-end">
|
||
<Button onClick={addKnowledgeURL} disabled={!knowledgeURL.trim() || checking === "knowledge-url"}>
|
||
{checking === "knowledge-url" ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
添加中…
|
||
</>
|
||
) : (
|
||
"Add URL"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>知识库列表</CardTitle>
|
||
<CardDescription>处理中状态会每 3 秒自动刷新。</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{knowledgeDocs.length === 0 ? (
|
||
<div className="text-sm text-slate-500">暂无知识库条目</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{knowledgeDocs.map((doc) => (
|
||
<div key={doc.id} className="p-3 rounded-lg border border-slate-200 dark:border-slate-800">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="space-y-1 min-w-0">
|
||
<div className="font-medium truncate">{doc.title}</div>
|
||
<div className="text-xs text-slate-500">
|
||
类型: {doc.file_type} · 来源: {doc.source_type}
|
||
</div>
|
||
<div className="text-xs text-slate-500">
|
||
上传时间: {doc.created_at ? new Date(doc.created_at).toLocaleString() : "-"}
|
||
{doc.last_fetched_at ? ` · 最近抓取: ${new Date(doc.last_fetched_at).toLocaleString()}` : ""}
|
||
</div>
|
||
{doc.file_url && (
|
||
<div className="text-xs text-blue-600 truncate">{doc.file_url}</div>
|
||
)}
|
||
{doc.error_message && (
|
||
<div className="text-xs text-red-600">{doc.error_message}</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Badge variant={doc.status === "complete" ? "default" : doc.status === "failed" ? "destructive" : "secondary"}>
|
||
{doc.status === "processing" ? "processing" : doc.status === "complete" ? "complete" : "failed"}
|
||
</Badge>
|
||
{doc.source_type === "url" && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => refreshKnowledgeDoc(doc.id)}
|
||
disabled={checking === `knowledge-refresh-${doc.id}`}
|
||
>
|
||
{checking === `knowledge-refresh-${doc.id}` ? (
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="w-3 h-3" />
|
||
)}
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={() => deleteKnowledgeDoc(doc.id)}
|
||
disabled={checking === `knowledge-delete-${doc.id}`}
|
||
>
|
||
{checking === `knowledge-delete-${doc.id}` ? (
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
) : (
|
||
<Trash2 className="w-3 h-3" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* Advanced Tab */}
|
||
<TabsContent value="advanced" className="mt-6">
|
||
<div className="space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>自动检测规则</CardTitle>
|
||
<CardDescription>
|
||
AI 自动判断信号价值的阈值设置
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Label>信号捕获阈值</Label>
|
||
<span className="text-sm text-slate-500">影响分 ≥ 0.7</span>
|
||
</div>
|
||
<p className="text-xs text-slate-500">
|
||
低于此阈值的信号将被忽略,减少噪音
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Label>冲突检测灵敏度</Label>
|
||
<span className="text-sm text-slate-500">中等</span>
|
||
</div>
|
||
<p className="text-xs text-slate-500">
|
||
控制认知冲突检测的严格程度
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Label>简报推送时间</Label>
|
||
<span className="text-sm text-slate-500">每天 9:00</span>
|
||
</div>
|
||
<p className="text-xs text-slate-500">
|
||
个性化每日简报的推送时间
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>数据同步设置</CardTitle>
|
||
<CardDescription>
|
||
控制数据同步频率和范围
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<Label>实时同步</Label>
|
||
<p className="text-xs text-slate-500">重要事件立即推送</p>
|
||
</div>
|
||
<Switch defaultChecked />
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<Label>历史数据导入</Label>
|
||
<p className="text-xs text-slate-500">导入过去 30 天的聊天记录</p>
|
||
</div>
|
||
<Switch />
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<Label>自动关联项目</Label>
|
||
<p className="text-xs text-slate-500">新信号自动关联到活跃项目</p>
|
||
</div>
|
||
<Switch defaultChecked />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* Prompt Profiles Tab */}
|
||
<TabsContent value="prompts" className="mt-6">
|
||
<PromptRoleSettings />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|