refactor(web-ai): split chat and settings pages
This commit is contained in:
+74
-10
@@ -17,8 +17,11 @@ import {
|
|||||||
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AiChatPage } from "@/pages/ai-chat-page";
|
||||||
import { EmailLoginPage } from "@/pages/email-login-page";
|
import { EmailLoginPage } from "@/pages/email-login-page";
|
||||||
import { OAuthCallbackPage } from "@/pages/oauth-callback-page";
|
import { OAuthCallbackPage } from "@/pages/oauth-callback-page";
|
||||||
|
import { PlaceholderPage } from "@/pages/placeholder-page";
|
||||||
|
import { SettingsPage } from "@/pages/settings-page";
|
||||||
import { TodoShellPage } from "@/pages/todo-shell-page";
|
import { TodoShellPage } from "@/pages/todo-shell-page";
|
||||||
import { revokeRefreshToken, type EmailLoginResult } from "@/services/auth-api";
|
import { revokeRefreshToken, type EmailLoginResult } from "@/services/auth-api";
|
||||||
import {
|
import {
|
||||||
@@ -38,17 +41,18 @@ type SidebarItem = {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIDEBAR_ITEMS: SidebarItem[] = [
|
const SIDEBAR_ITEMS: SidebarItem[] = [
|
||||||
{ key: "dashboard", label: "概览面板", icon: LayoutDashboard },
|
{ key: "dashboard", label: "概览面板", icon: LayoutDashboard, path: "/dashboard" },
|
||||||
{ key: "todo", label: "待办事项", icon: ListTodo },
|
{ key: "todo", label: "待办事项", icon: ListTodo, path: "/todo" },
|
||||||
{ key: "ai", label: "AI 建议", icon: Sparkles },
|
{ key: "ai", label: "AI 助手", icon: Sparkles, path: "/ai" },
|
||||||
{ key: "notice", label: "提醒中心", icon: Bell },
|
{ key: "notice", label: "提醒中心", icon: Bell, path: "/notice" },
|
||||||
{ key: "settings", label: "系统设置", icon: Settings }
|
{ key: "settings", label: "系统设置", icon: Settings, path: "/settings" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const READY_SIDEBAR_KEYS = new Set(["todo", "ai"]);
|
const READY_SIDEBAR_KEYS = new Set(["todo", "ai", "settings"]);
|
||||||
|
|
||||||
function toWebSession(payload: EmailLoginResult): WebSession {
|
function toWebSession(payload: EmailLoginResult): WebSession {
|
||||||
return {
|
return {
|
||||||
@@ -106,7 +110,7 @@ function App() {
|
|||||||
saveSession(nextSession);
|
saveSession(nextSession);
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
navigate("/", { replace: true });
|
navigate("/todo", { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBootstrapSession(nextSession: WebSession): void {
|
function handleBootstrapSession(nextSession: WebSession): void {
|
||||||
@@ -138,14 +142,21 @@ function App() {
|
|||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
{SIDEBAR_ITEMS.map((item) => {
|
{SIDEBAR_ITEMS.map((item) => {
|
||||||
const ItemIcon = item.icon;
|
const ItemIcon = item.icon;
|
||||||
|
const isActive =
|
||||||
|
location.pathname === item.path || location.pathname.startsWith(`${item.path}/`);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex w-full items-center rounded-xl border border-transparent px-3 py-2.5 text-left transition-colors",
|
"group flex w-full items-center rounded-xl border border-transparent px-3 py-2.5 text-left transition-colors",
|
||||||
"gap-3 hover:border-primary/25 hover:bg-primary/10"
|
"gap-3 hover:border-primary/25 hover:bg-primary/10",
|
||||||
|
isActive ? "border-primary/25 bg-primary/10" : null
|
||||||
)}
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(item.path);
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ItemIcon className="size-5 shrink-0 text-primary" />
|
<ItemIcon className="size-5 shrink-0 text-primary" />
|
||||||
{collapsed ? null : (
|
{collapsed ? null : (
|
||||||
@@ -212,7 +223,10 @@ function App() {
|
|||||||
path="/auth/callback/:provider"
|
path="/auth/callback/:provider"
|
||||||
element={<OAuthCallbackPage onBootstrapSession={handleBootstrapSession} />}
|
element={<OAuthCallbackPage onBootstrapSession={handleBootstrapSession} />}
|
||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to={session ? "/" : "/login/email"} replace />} />
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<Navigate to={session ? "/todo" : "/login/email"} replace />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -298,6 +312,23 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
|
element={<Navigate to={session ? "/todo" : "/login/email"} replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
session ? (
|
||||||
|
<PlaceholderPage
|
||||||
|
title="概览面板正在整理"
|
||||||
|
description="这里后续会放任务统计、今日重点、AI 使用概况和提醒概览。当前先把导航和页面结构拆清楚。"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login/email" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/todo"
|
||||||
element={
|
element={
|
||||||
session ? (
|
session ? (
|
||||||
<TodoShellPage session={session} />
|
<TodoShellPage session={session} />
|
||||||
@@ -306,9 +337,42 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/ai"
|
||||||
|
element={
|
||||||
|
session ? (
|
||||||
|
<AiChatPage session={session} />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login/email" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/notice"
|
||||||
|
element={
|
||||||
|
session ? (
|
||||||
|
<PlaceholderPage
|
||||||
|
title="提醒中心即将接入"
|
||||||
|
description="邮件提醒、Web Push 推送、任务到期前通知都会独立收敛到这里,而不是继续堆在任务页里。"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login/email" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
session ? (
|
||||||
|
<SettingsPage session={session} />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login/email" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<Navigate to={session ? "/" : "/login/email"} replace />}
|
element={<Navigate to={session ? "/todo" : "/login/email"} replace />}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,811 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Bot,
|
|
||||||
CheckCircle2,
|
|
||||||
CircleAlert,
|
|
||||||
Globe2,
|
|
||||||
KeyRound,
|
|
||||||
LoaderCircle,
|
|
||||||
PlugZap,
|
|
||||||
RefreshCw,
|
|
||||||
SendHorizontal,
|
|
||||||
Settings2,
|
|
||||||
Sparkles
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
chatWithAi,
|
|
||||||
listAiBindings,
|
|
||||||
upsertAiBinding,
|
|
||||||
type UpsertWebAiBindingInput,
|
|
||||||
type WebAiBindingSummary,
|
|
||||||
type WebAiBindingsResponse,
|
|
||||||
type WebAiChannel,
|
|
||||||
WebAiApiError
|
|
||||||
} from "@/services/ai-api";
|
|
||||||
import type { WebSession } from "@/services/session-storage";
|
|
||||||
|
|
||||||
type AiAssistantPanelProps = {
|
|
||||||
session: WebSession;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AiBindingFormState = {
|
|
||||||
providerName: string;
|
|
||||||
model: string;
|
|
||||||
endpoint: string;
|
|
||||||
apiKey: string;
|
|
||||||
configId: string;
|
|
||||||
configName: string;
|
|
||||||
isEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AiMessageRecord = {
|
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant" | "system";
|
|
||||||
content: string;
|
|
||||||
meta?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PanelNotice = {
|
|
||||||
tone: "success" | "error";
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CHANNEL_ORDER: WebAiChannel[] = ["USER_KEY", "ASTRBOT", "PUBLIC_POOL"];
|
|
||||||
|
|
||||||
const CHANNEL_META: Record<
|
|
||||||
WebAiChannel,
|
|
||||||
{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: typeof KeyRound;
|
|
||||||
accentClassName: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
USER_KEY: {
|
|
||||||
title: "自备厂商",
|
|
||||||
description: "用户自己接入厂商接口",
|
|
||||||
icon: KeyRound,
|
|
||||||
accentClassName: "from-sky-500/15 via-transparent to-sky-500/5"
|
|
||||||
},
|
|
||||||
ASTRBOT: {
|
|
||||||
title: "AstrBot",
|
|
||||||
description: "复用 AstrBot 内已接入模型",
|
|
||||||
icon: PlugZap,
|
|
||||||
accentClassName: "from-amber-500/15 via-transparent to-amber-500/5"
|
|
||||||
},
|
|
||||||
PUBLIC_POOL: {
|
|
||||||
title: "公共 AI",
|
|
||||||
description: "使用站点管理员开放的公共通道",
|
|
||||||
icon: Globe2,
|
|
||||||
accentClassName: "from-emerald-500/15 via-transparent to-emerald-500/5"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function createFormState(binding?: WebAiBindingSummary | null): AiBindingFormState {
|
|
||||||
return {
|
|
||||||
providerName: binding?.providerName ?? "",
|
|
||||||
model: binding?.model ?? "",
|
|
||||||
endpoint: binding?.endpoint ?? "",
|
|
||||||
apiKey: "",
|
|
||||||
configId: binding?.configId ?? "",
|
|
||||||
configName: binding?.configName ?? "",
|
|
||||||
isEnabled: binding?.isEnabled ?? true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmptyMessages(): Record<WebAiChannel, AiMessageRecord[]> {
|
|
||||||
return {
|
|
||||||
USER_KEY: [],
|
|
||||||
ASTRBOT: [],
|
|
||||||
PUBLIC_POOL: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmptySessionIds(): Partial<Record<WebAiChannel, string>> {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimeLabel(date = new Date()): string {
|
|
||||||
return date.toLocaleTimeString("zh-CN", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimOptionalValue(value: string): string | undefined {
|
|
||||||
const normalized = value.trim();
|
|
||||||
return normalized.length > 0 ? normalized : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBindingPayload(
|
|
||||||
channel: Exclude<WebAiChannel, "PUBLIC_POOL">,
|
|
||||||
formState: AiBindingFormState,
|
|
||||||
currentBinding: WebAiBindingSummary | null
|
|
||||||
): UpsertWebAiBindingInput {
|
|
||||||
return {
|
|
||||||
channel,
|
|
||||||
providerName: trimOptionalValue(formState.providerName),
|
|
||||||
model: trimOptionalValue(formState.model),
|
|
||||||
endpoint: trimOptionalValue(formState.endpoint),
|
|
||||||
configId: trimOptionalValue(formState.configId),
|
|
||||||
configName: trimOptionalValue(formState.configName),
|
|
||||||
apiKey: trimOptionalValue(formState.apiKey) ?? undefined,
|
|
||||||
isEnabled: formState.isEnabled ?? currentBinding?.isEnabled ?? true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendMessage(
|
|
||||||
records: Record<WebAiChannel, AiMessageRecord[]>,
|
|
||||||
channel: WebAiChannel,
|
|
||||||
message: AiMessageRecord
|
|
||||||
): Record<WebAiChannel, AiMessageRecord[]> {
|
|
||||||
return {
|
|
||||||
...records,
|
|
||||||
[channel]: [...records[channel], message]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiAssistantPanel({ session }: AiAssistantPanelProps) {
|
|
||||||
const [bindingsResponse, setBindingsResponse] = useState<WebAiBindingsResponse | null>(null);
|
|
||||||
const [loadingBindings, setLoadingBindings] = useState(true);
|
|
||||||
const [refreshingBindings, setRefreshingBindings] = useState(false);
|
|
||||||
const [activeChannel, setActiveChannel] = useState<WebAiChannel>("USER_KEY");
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(true);
|
|
||||||
const [userKeyForm, setUserKeyForm] = useState<AiBindingFormState>(() => createFormState());
|
|
||||||
const [astrbotForm, setAstrbotForm] = useState<AiBindingFormState>(() => createFormState());
|
|
||||||
const [savingChannel, setSavingChannel] = useState<WebAiChannel | null>(null);
|
|
||||||
const [panelNotice, setPanelNotice] = useState<PanelNotice | null>(null);
|
|
||||||
const [messagesByChannel, setMessagesByChannel] = useState<
|
|
||||||
Record<WebAiChannel, AiMessageRecord[]>
|
|
||||||
>(() => createEmptyMessages());
|
|
||||||
const [sessionIds, setSessionIds] = useState<Partial<Record<WebAiChannel, string>>>(() =>
|
|
||||||
createEmptySessionIds()
|
|
||||||
);
|
|
||||||
const [draftMessage, setDraftMessage] = useState("");
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const bindingMap = useMemo(() => {
|
|
||||||
const map = new Map<WebAiChannel, WebAiBindingSummary>();
|
|
||||||
for (const binding of bindingsResponse?.bindings ?? []) {
|
|
||||||
map.set(binding.channel, binding);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [bindingsResponse]);
|
|
||||||
|
|
||||||
const currentBinding =
|
|
||||||
activeChannel === "PUBLIC_POOL" ? null : (bindingMap.get(activeChannel) ?? null);
|
|
||||||
const currentMessages = messagesByChannel[activeChannel];
|
|
||||||
const publicPool = bindingsResponse?.publicPool ?? null;
|
|
||||||
|
|
||||||
const loadBindings = useCallback(
|
|
||||||
async (mode: "initial" | "refresh" = "refresh"): Promise<void> => {
|
|
||||||
if (mode === "initial") {
|
|
||||||
setLoadingBindings(true);
|
|
||||||
} else {
|
|
||||||
setRefreshingBindings(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await listAiBindings(session);
|
|
||||||
setBindingsResponse(response);
|
|
||||||
setUserKeyForm(
|
|
||||||
createFormState(response.bindings.find((item) => item.channel === "USER_KEY"))
|
|
||||||
);
|
|
||||||
setAstrbotForm(
|
|
||||||
createFormState(response.bindings.find((item) => item.channel === "ASTRBOT"))
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
setPanelNotice({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof Error ? error.message : "AI ??????"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoadingBindings(false);
|
|
||||||
setRefreshingBindings(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[session]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadBindings("initial");
|
|
||||||
}, [loadBindings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({
|
|
||||||
block: "end",
|
|
||||||
behavior: "smooth"
|
|
||||||
});
|
|
||||||
}, [activeChannel, currentMessages.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!panelNotice) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
setPanelNotice(null);
|
|
||||||
}, 2800);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [panelNotice]);
|
|
||||||
|
|
||||||
const sendBlockedReason = useMemo(() => {
|
|
||||||
if (activeChannel === "PUBLIC_POOL") {
|
|
||||||
if (!publicPool?.enabled) {
|
|
||||||
return "管理员尚未开放公共 AI。";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentBinding) {
|
|
||||||
return activeChannel === "USER_KEY" ? "请先保存自备厂商配置。" : "请先保存 AstrBot 配置。";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentBinding.isEnabled) {
|
|
||||||
return "当前渠道已关闭,请先启用后再发起对话。";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [activeChannel, currentBinding, publicPool]);
|
|
||||||
|
|
||||||
const channelStatusText = useMemo(() => {
|
|
||||||
if (activeChannel === "PUBLIC_POOL") {
|
|
||||||
return publicPool?.enabled ? "管理员已开放" : "当前不可用";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentBinding) {
|
|
||||||
return "尚未配置";
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentBinding.isEnabled ? "已配置并启用" : "已配置但停用";
|
|
||||||
}, [activeChannel, currentBinding, publicPool]);
|
|
||||||
|
|
||||||
async function handleSaveChannel(channel: Exclude<WebAiChannel, "PUBLIC_POOL">): Promise<void> {
|
|
||||||
const formState = channel === "USER_KEY" ? userKeyForm : astrbotForm;
|
|
||||||
const binding = bindingMap.get(channel) ?? null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSavingChannel(channel);
|
|
||||||
await upsertAiBinding(session, buildBindingPayload(channel, formState, binding));
|
|
||||||
setPanelNotice({
|
|
||||||
tone: "success",
|
|
||||||
message: channel === "USER_KEY" ? "自备厂商配置已保存。" : "AstrBot 配置已保存。"
|
|
||||||
});
|
|
||||||
if (channel === "USER_KEY") {
|
|
||||||
setUserKeyForm((current) => ({
|
|
||||||
...current,
|
|
||||||
apiKey: ""
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setAstrbotForm((current) => ({
|
|
||||||
...current,
|
|
||||||
apiKey: ""
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
await loadBindings("refresh");
|
|
||||||
} catch (error) {
|
|
||||||
setPanelNotice({
|
|
||||||
tone: "error",
|
|
||||||
message: error instanceof Error ? error.message : "AI 配置保存失败"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSavingChannel(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSendMessage(): Promise<void> {
|
|
||||||
const message = draftMessage.trim();
|
|
||||||
if (!message || sendBlockedReason || sending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = activeChannel;
|
|
||||||
setSending(true);
|
|
||||||
setDraftMessage("");
|
|
||||||
setMessagesByChannel((current) =>
|
|
||||||
appendMessage(current, channel, {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: "user",
|
|
||||||
content: message,
|
|
||||||
meta: formatTimeLabel()
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await chatWithAi(session, {
|
|
||||||
channel,
|
|
||||||
message,
|
|
||||||
sessionId: sessionIds[channel]
|
|
||||||
});
|
|
||||||
|
|
||||||
setSessionIds((current) => ({
|
|
||||||
...current,
|
|
||||||
[channel]: response.sessionId ?? current[channel]
|
|
||||||
}));
|
|
||||||
setMessagesByChannel((current) =>
|
|
||||||
appendMessage(current, channel, {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: "assistant",
|
|
||||||
content: response.content,
|
|
||||||
meta: `${CHANNEL_META[response.channel].title} · ${response.providerName}${response.model ? ` · ${response.model}` : ""}`
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const apiError =
|
|
||||||
error instanceof WebAiApiError
|
|
||||||
? error
|
|
||||||
: new WebAiApiError(error instanceof Error ? error.message : "AI 请求失败");
|
|
||||||
const firstFailedAttempt = apiError.attempts?.find((item) => item.reasonMessage);
|
|
||||||
const content =
|
|
||||||
firstFailedAttempt?.reasonMessage && firstFailedAttempt.reasonMessage !== apiError.message
|
|
||||||
? `${apiError.message}\n${firstFailedAttempt.reasonMessage}`
|
|
||||||
: apiError.message;
|
|
||||||
|
|
||||||
setMessagesByChannel((current) =>
|
|
||||||
appendMessage(current, channel, {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
role: "system",
|
|
||||||
content,
|
|
||||||
meta: "调用失败"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChannelButton(channel: WebAiChannel) {
|
|
||||||
const channelMeta = CHANNEL_META[channel];
|
|
||||||
const ChannelIcon = channelMeta.icon;
|
|
||||||
const selected = activeChannel === channel;
|
|
||||||
const binding = channel === "PUBLIC_POOL" ? null : (bindingMap.get(channel) ?? null);
|
|
||||||
const enabled =
|
|
||||||
channel === "PUBLIC_POOL" ? Boolean(publicPool?.enabled) : Boolean(binding?.isEnabled);
|
|
||||||
const statusLabel =
|
|
||||||
channel === "PUBLIC_POOL"
|
|
||||||
? publicPool?.enabled
|
|
||||||
? "可使用"
|
|
||||||
: "未开放"
|
|
||||||
: binding
|
|
||||||
? enabled
|
|
||||||
? "已启用"
|
|
||||||
: "已停用"
|
|
||||||
: "未配置";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={channel}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"rounded-2xl border px-3 py-3 text-left transition-all",
|
|
||||||
"bg-gradient-to-br shadow-sm",
|
|
||||||
channelMeta.accentClassName,
|
|
||||||
selected
|
|
||||||
? "border-primary/50 bg-primary/8 ring-2 ring-primary/20"
|
|
||||||
: "border-border/70 hover:border-primary/25 hover:bg-muted/40"
|
|
||||||
)}
|
|
||||||
onClick={() => setActiveChannel(channel)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="rounded-xl bg-background/85 p-2 text-primary shadow-sm">
|
|
||||||
<ChannelIcon className="size-4" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-foreground">{channelMeta.title}</div>
|
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">{channelMeta.description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
|
||||||
enabled
|
|
||||||
? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
|
||||||
: "border-border bg-background/70 text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNotice() {
|
|
||||||
if (!panelNotice) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-start gap-2 rounded-2xl border px-3 py-2 text-sm",
|
|
||||||
panelNotice.tone === "success"
|
|
||||||
? "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
|
||||||
: "border-destructive/20 bg-destructive/10 text-destructive"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{panelNotice.tone === "success" ? (
|
|
||||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<CircleAlert className="mt-0.5 size-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span>{panelNotice.message}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPrivateConfigForm(channel: Exclude<WebAiChannel, "PUBLIC_POOL">) {
|
|
||||||
const formState = channel === "USER_KEY" ? userKeyForm : astrbotForm;
|
|
||||||
const setFormState = channel === "USER_KEY" ? setUserKeyForm : setAstrbotForm;
|
|
||||||
const binding = bindingMap.get(channel) ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<label className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">服务商标识</span>
|
|
||||||
<input
|
|
||||||
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
|
||||||
value={formState.providerName}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
providerName: event.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={channel === "USER_KEY" ? "如 openai / dashscope / deepseek" : "可选"}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">模型</span>
|
|
||||||
<input
|
|
||||||
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
|
||||||
value={formState.model}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
model: event.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={channel === "USER_KEY" ? "如 gpt-4o-mini" : "可选"}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
{channel === "USER_KEY" ? "接口地址" : "AstrBot 地址"}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
|
||||||
value={formState.endpoint}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
endpoint: event.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
channel === "USER_KEY" ? "如 https://api.openai.com/v1" : "如 http://100.64.0.21:6185"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{channel === "ASTRBOT" ? (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<label className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">configId</span>
|
|
||||||
<input
|
|
||||||
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
|
||||||
value={formState.configId}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
configId: event.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="如 default"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">configName</span>
|
|
||||||
<input
|
|
||||||
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
|
||||||
value={formState.configName}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
configName: event.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="可选"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<label className="space-y-1.5">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
{channel === "USER_KEY" ? "API Key" : "AstrBot API Key"}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
|
||||||
value={formState.apiKey}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
apiKey: event.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={binding?.hasApiKey ? "留空则保持当前密钥不变" : "请输入密钥"}
|
|
||||||
/>
|
|
||||||
{binding?.maskedApiKey ? (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
当前已保存密钥:{binding.maskedApiKey}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 rounded-2xl border border-border/70 bg-background/70 px-3 py-2 text-sm text-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formState.isEnabled}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
isEnabled: event.target.checked
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>保存后立即启用该渠道</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
|
||||||
{channel === "USER_KEY"
|
|
||||||
? "当前自备厂商通道按用户单独保存,适合个人独享密钥。"
|
|
||||||
: "AstrBot 通道按用户单独保存,可直接复用你在 AstrBot 中维护的模型配置。"}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => void handleSaveChannel(channel)}
|
|
||||||
disabled={savingChannel === channel}
|
|
||||||
>
|
|
||||||
{savingChannel === channel ? (
|
|
||||||
<>
|
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
|
||||||
保存中
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"保存配置"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPublicPoolCard() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-foreground">
|
|
||||||
{publicPool?.providerName || "公共 AI"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{publicPool?.model ? `默认模型:${publicPool.model}` : "管理员尚未设置默认模型"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
|
||||||
publicPool?.enabled
|
|
||||||
? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
|
||||||
: "border-border bg-background text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{publicPool?.enabled ? "可用" : "不可用"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-xs leading-5 text-muted-foreground">
|
|
||||||
公共 AI 由管理后台统一维护,普通用户仅可选择使用,不可查看或修改密钥。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="flex min-h-[720px] flex-col overflow-hidden rounded-[2rem] border border-border/70 bg-card/92 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)] xl:sticky xl:top-0 xl:max-h-[calc(100vh-7rem)] xl:min-h-0">
|
|
||||||
<div className="border-b border-border/70 bg-gradient-to-br from-primary/12 via-background to-background px-5 py-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-primary">
|
|
||||||
<Sparkles className="size-4" />
|
|
||||||
AI 助手
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-1 text-xl font-semibold tracking-tight text-foreground">
|
|
||||||
三路通道,按用户独立配置
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
你可以随时切换 AstrBot、自备厂商与公共 AI 进行问答和任务统筹。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="outline"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => void loadBindings("refresh")}
|
|
||||||
disabled={refreshingBindings}
|
|
||||||
aria-label="刷新 AI 配置"
|
|
||||||
>
|
|
||||||
{refreshingBindings ? (
|
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
|
|
||||||
{renderNotice()}
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-1">
|
|
||||||
{CHANNEL_ORDER.map((channel) => renderChannelButton(channel))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border/70 bg-background/80 p-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="rounded-xl bg-primary/10 p-2 text-primary">
|
|
||||||
<Bot className="size-4" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-foreground">
|
|
||||||
{CHANNEL_META[activeChannel].title}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{channelStatusText}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => setSettingsOpen((current) => !current)}
|
|
||||||
>
|
|
||||||
<Settings2 className="size-4" />
|
|
||||||
{settingsOpen ? "收起配置" : "配置渠道"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{settingsOpen ? (
|
|
||||||
<div className="rounded-2xl border border-border/70 bg-card/75 p-4">
|
|
||||||
{loadingBindings ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
|
||||||
正在加载 AI 配置...
|
|
||||||
</div>
|
|
||||||
) : activeChannel === "PUBLIC_POOL" ? (
|
|
||||||
renderPublicPoolCard()
|
|
||||||
) : (
|
|
||||||
renderPrivateConfigForm(activeChannel)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/70 bg-background/90">
|
|
||||||
<div className="border-b border-border/70 px-4 py-3">
|
|
||||||
<div className="text-sm font-semibold text-foreground">对话记录</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
当前渠道:{CHANNEL_META[activeChannel].title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
|
||||||
{currentMessages.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-dashed border-border bg-muted/35 p-4 text-sm leading-6 text-muted-foreground">
|
|
||||||
<div className="font-medium text-foreground">还没有对话记录。</div>
|
|
||||||
<div className="mt-1">
|
|
||||||
发送一句话试试看,例如“帮我根据当前未完成任务安排今天下午的执行顺序”。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
currentMessages.map((message) => (
|
|
||||||
<div
|
|
||||||
key={message.id}
|
|
||||||
className={cn(
|
|
||||||
"max-w-[92%] rounded-2xl px-4 py-3 text-sm leading-6 shadow-sm",
|
|
||||||
message.role === "user"
|
|
||||||
? "ml-auto bg-primary text-primary-foreground"
|
|
||||||
: message.role === "assistant"
|
|
||||||
? "border border-border/70 bg-card text-foreground"
|
|
||||||
: "border border-destructive/15 bg-destructive/8 text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
|
||||||
{message.meta ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mt-2 text-[11px]",
|
|
||||||
message.role === "user"
|
|
||||||
? "text-primary-foreground/80"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{message.meta}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border/70 p-4">
|
|
||||||
{sendBlockedReason ? (
|
|
||||||
<div className="mb-3 rounded-2xl border border-amber-500/15 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-300">
|
|
||||||
{sendBlockedReason}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
value={draftMessage}
|
|
||||||
onChange={(event) => setDraftMessage(event.target.value)}
|
|
||||||
placeholder="输入你的问题,例如:结合我当前待办,帮我排一下今天的优先级。"
|
|
||||||
className="min-h-[120px] w-full rounded-2xl border border-border bg-background px-4 py-3 text-sm leading-6 outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
{activeChannel === "PUBLIC_POOL" ? (
|
|
||||||
<Globe2 className="size-4" />
|
|
||||||
) : activeChannel === "ASTRBOT" ? (
|
|
||||||
<PlugZap className="size-4" />
|
|
||||||
) : (
|
|
||||||
<KeyRound className="size-4" />
|
|
||||||
)}
|
|
||||||
<span>发送时将自动附带当前未完成任务摘要。</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => void handleSendMessage()}
|
|
||||||
disabled={sending || draftMessage.trim().length === 0 || sendBlockedReason !== null}
|
|
||||||
>
|
|
||||||
{sending ? (
|
|
||||||
<>
|
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
|
||||||
发送中
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SendHorizontal className="size-4" />
|
|
||||||
发送
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { UpsertWebAiBindingInput, WebAiBindingSummary, WebAiChannel } from "@/services/ai-api";
|
||||||
|
|
||||||
|
export type AiBindingFormState = {
|
||||||
|
providerName: string;
|
||||||
|
model: string;
|
||||||
|
endpoint: string;
|
||||||
|
apiKey: string;
|
||||||
|
configId: string;
|
||||||
|
configName: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHANNEL_ORDER: WebAiChannel[] = ["USER_KEY", "ASTRBOT", "PUBLIC_POOL"];
|
||||||
|
|
||||||
|
export const CHANNEL_META: Record<
|
||||||
|
WebAiChannel,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
accentClassName: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
USER_KEY: {
|
||||||
|
title: "自备厂商",
|
||||||
|
description: "用户自行接入 OpenAI-Compatible 服务",
|
||||||
|
accentClassName: "from-sky-500/15 via-transparent to-sky-500/5"
|
||||||
|
},
|
||||||
|
ASTRBOT: {
|
||||||
|
title: "AstrBot",
|
||||||
|
description: "复用你在 AstrBot 中维护的模型配置",
|
||||||
|
accentClassName: "from-amber-500/15 via-transparent to-amber-500/5"
|
||||||
|
},
|
||||||
|
PUBLIC_POOL: {
|
||||||
|
title: "公共 AI",
|
||||||
|
description: "使用管理员开放的站点公共通道",
|
||||||
|
accentClassName: "from-emerald-500/15 via-transparent to-emerald-500/5"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAiBindingFormState(binding?: WebAiBindingSummary | null): AiBindingFormState {
|
||||||
|
return {
|
||||||
|
providerName: binding?.providerName ?? "",
|
||||||
|
model: binding?.model ?? "",
|
||||||
|
endpoint: binding?.endpoint ?? "",
|
||||||
|
apiKey: "",
|
||||||
|
configId: binding?.configId ?? "",
|
||||||
|
configName: binding?.configName ?? "",
|
||||||
|
isEnabled: binding?.isEnabled ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimAiOptionalValue(value: string): string | undefined {
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized.length > 0 ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAiBindingPayload(
|
||||||
|
channel: Exclude<WebAiChannel, "PUBLIC_POOL">,
|
||||||
|
formState: AiBindingFormState,
|
||||||
|
currentBinding: WebAiBindingSummary | null
|
||||||
|
): UpsertWebAiBindingInput {
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
providerName: trimAiOptionalValue(formState.providerName),
|
||||||
|
model: trimAiOptionalValue(formState.model),
|
||||||
|
endpoint: trimAiOptionalValue(formState.endpoint),
|
||||||
|
configId: trimAiOptionalValue(formState.configId),
|
||||||
|
configName: trimAiOptionalValue(formState.configName),
|
||||||
|
apiKey: trimAiOptionalValue(formState.apiKey) ?? undefined,
|
||||||
|
isEnabled: formState.isEnabled ?? currentBinding?.isEnabled ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
CircleAlert,
|
||||||
|
Globe2,
|
||||||
|
KeyRound,
|
||||||
|
LoaderCircle,
|
||||||
|
PlugZap,
|
||||||
|
SendHorizontal
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
chatWithAi,
|
||||||
|
listAiBindings,
|
||||||
|
type WebAiBindingSummary,
|
||||||
|
type WebAiBindingsResponse,
|
||||||
|
type WebAiChannel,
|
||||||
|
WebAiApiError
|
||||||
|
} from "@/services/ai-api";
|
||||||
|
import type { WebSession } from "@/services/session-storage";
|
||||||
|
import { CHANNEL_META, CHANNEL_ORDER } from "@/components/ai/ai-shared";
|
||||||
|
|
||||||
|
type AiChatPageProps = {
|
||||||
|
session: WebSession;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AiMessageRecord = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string;
|
||||||
|
meta?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createEmptyMessages(): Record<WebAiChannel, AiMessageRecord[]> {
|
||||||
|
return {
|
||||||
|
USER_KEY: [],
|
||||||
|
ASTRBOT: [],
|
||||||
|
PUBLIC_POOL: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptySessionIds(): Partial<Record<WebAiChannel, string>> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeLabel(date = new Date()): string {
|
||||||
|
return date.toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(
|
||||||
|
records: Record<WebAiChannel, AiMessageRecord[]>,
|
||||||
|
channel: WebAiChannel,
|
||||||
|
message: AiMessageRecord
|
||||||
|
): Record<WebAiChannel, AiMessageRecord[]> {
|
||||||
|
return {
|
||||||
|
...records,
|
||||||
|
[channel]: [...records[channel], message]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiChatPage({ session }: AiChatPageProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [bindingsResponse, setBindingsResponse] = useState<WebAiBindingsResponse | null>(null);
|
||||||
|
const [loadingBindings, setLoadingBindings] = useState(true);
|
||||||
|
const [refreshingBindings, setRefreshingBindings] = useState(false);
|
||||||
|
const [activeChannel, setActiveChannel] = useState<WebAiChannel>("USER_KEY");
|
||||||
|
const [messagesByChannel, setMessagesByChannel] = useState<
|
||||||
|
Record<WebAiChannel, AiMessageRecord[]>
|
||||||
|
>(() => createEmptyMessages());
|
||||||
|
const [sessionIds, setSessionIds] = useState<Partial<Record<WebAiChannel, string>>>(() =>
|
||||||
|
createEmptySessionIds()
|
||||||
|
);
|
||||||
|
const [draftMessage, setDraftMessage] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const bindingMap = useMemo(() => {
|
||||||
|
const map = new Map<WebAiChannel, WebAiBindingSummary>();
|
||||||
|
for (const binding of bindingsResponse?.bindings ?? []) {
|
||||||
|
map.set(binding.channel, binding);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [bindingsResponse]);
|
||||||
|
|
||||||
|
const currentBinding =
|
||||||
|
activeChannel === "PUBLIC_POOL" ? null : (bindingMap.get(activeChannel) ?? null);
|
||||||
|
const publicPool = bindingsResponse?.publicPool ?? null;
|
||||||
|
const currentMessages = messagesByChannel[activeChannel];
|
||||||
|
|
||||||
|
const loadBindings = useCallback(async (): Promise<void> => {
|
||||||
|
setRefreshingBindings(true);
|
||||||
|
setLoadError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await listAiBindings(session);
|
||||||
|
setBindingsResponse(response);
|
||||||
|
} catch (error) {
|
||||||
|
setLoadError(error instanceof Error ? error.message : "AI 配置加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoadingBindings(false);
|
||||||
|
setRefreshingBindings(false);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBindings();
|
||||||
|
}, [loadBindings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({
|
||||||
|
block: "end",
|
||||||
|
behavior: "smooth"
|
||||||
|
});
|
||||||
|
}, [activeChannel, currentMessages.length]);
|
||||||
|
|
||||||
|
const sendBlockedReason = useMemo(() => {
|
||||||
|
if (activeChannel === "PUBLIC_POOL") {
|
||||||
|
if (!publicPool?.enabled) {
|
||||||
|
return "管理员尚未开放公共 AI。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentBinding) {
|
||||||
|
return activeChannel === "USER_KEY"
|
||||||
|
? "你还没有配置自备厂商,请先前往系统设置 > AI 配置。"
|
||||||
|
: "你还没有配置 AstrBot,请先前往系统设置 > AI 配置。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentBinding.isEnabled) {
|
||||||
|
return "当前渠道已关闭,请先在系统设置 > AI 配置中启用。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [activeChannel, currentBinding, publicPool]);
|
||||||
|
|
||||||
|
async function handleSendMessage(): Promise<void> {
|
||||||
|
const message = draftMessage.trim();
|
||||||
|
if (!message || sendBlockedReason || sending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = activeChannel;
|
||||||
|
setSending(true);
|
||||||
|
setDraftMessage("");
|
||||||
|
setMessagesByChannel((current) =>
|
||||||
|
appendMessage(current, channel, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: "user",
|
||||||
|
content: message,
|
||||||
|
meta: formatTimeLabel()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chatWithAi(session, {
|
||||||
|
channel,
|
||||||
|
message,
|
||||||
|
sessionId: sessionIds[channel]
|
||||||
|
});
|
||||||
|
|
||||||
|
setSessionIds((current) => ({
|
||||||
|
...current,
|
||||||
|
[channel]: response.sessionId ?? current[channel]
|
||||||
|
}));
|
||||||
|
setMessagesByChannel((current) =>
|
||||||
|
appendMessage(current, channel, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: "assistant",
|
||||||
|
content: response.content,
|
||||||
|
meta: `${CHANNEL_META[response.channel].title} · ${response.providerName}${response.model ? ` · ${response.model}` : ""}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const apiError =
|
||||||
|
error instanceof WebAiApiError
|
||||||
|
? error
|
||||||
|
: new WebAiApiError(error instanceof Error ? error.message : "AI 请求失败");
|
||||||
|
const firstAttempt = apiError.attempts?.find((item) => item.reasonMessage);
|
||||||
|
const content =
|
||||||
|
firstAttempt?.reasonMessage && firstAttempt.reasonMessage !== apiError.message
|
||||||
|
? `${apiError.message}\n${firstAttempt.reasonMessage}`
|
||||||
|
: apiError.message;
|
||||||
|
|
||||||
|
setMessagesByChannel((current) =>
|
||||||
|
appendMessage(current, channel, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: "system",
|
||||||
|
content,
|
||||||
|
meta: "调用失败"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[2rem] border border-border/70 bg-card/92 p-6 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)]">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-primary">
|
||||||
|
<Bot className="size-4" />
|
||||||
|
AI 助手
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
|
||||||
|
在独立页面中发起 AI 对话
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-muted-foreground">
|
||||||
|
聊天页面只负责问答和任务统筹。所有渠道配置统一放在系统设置中的 AI 配置页面。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="button" variant="outline" onClick={() => navigate("/settings")}>
|
||||||
|
前往 AI 配置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void loadBindings()}
|
||||||
|
disabled={refreshingBindings}
|
||||||
|
>
|
||||||
|
{refreshingBindings ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
刷新中
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"刷新状态"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
<aside className="space-y-4 rounded-[2rem] border border-border/70 bg-card/92 p-4 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)]">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-foreground">选择渠道</div>
|
||||||
|
<div className="mt-1 text-xs leading-6 text-muted-foreground">
|
||||||
|
前端只会使用你当前明确选中的那一个渠道。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{CHANNEL_ORDER.map((channel) => {
|
||||||
|
const selected = activeChannel === channel;
|
||||||
|
const binding = channel === "PUBLIC_POOL" ? null : (bindingMap.get(channel) ?? null);
|
||||||
|
const enabled =
|
||||||
|
channel === "PUBLIC_POOL"
|
||||||
|
? Boolean(publicPool?.enabled)
|
||||||
|
: Boolean(binding?.isEnabled);
|
||||||
|
const statusLabel =
|
||||||
|
channel === "PUBLIC_POOL"
|
||||||
|
? publicPool?.enabled
|
||||||
|
? "可使用"
|
||||||
|
: "未开放"
|
||||||
|
: binding
|
||||||
|
? enabled
|
||||||
|
? "已启用"
|
||||||
|
: "已停用"
|
||||||
|
: "未配置";
|
||||||
|
const Icon =
|
||||||
|
channel === "PUBLIC_POOL" ? Globe2 : channel === "ASTRBOT" ? PlugZap : KeyRound;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-2xl border bg-gradient-to-br px-3 py-3 text-left transition-all",
|
||||||
|
CHANNEL_META[channel].accentClassName,
|
||||||
|
selected
|
||||||
|
? "border-primary/45 ring-2 ring-primary/15"
|
||||||
|
: "border-border/70 hover:border-primary/25 hover:bg-muted/35"
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveChannel(channel)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="rounded-xl bg-background/85 p-2 text-primary shadow-sm">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-foreground">
|
||||||
|
{CHANNEL_META[channel].title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
|
{CHANNEL_META[channel].description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||||
|
enabled
|
||||||
|
? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "border-border bg-background text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<div className="rounded-2xl border border-destructive/15 bg-destructive/8 px-3 py-2 text-sm text-destructive">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background/80 px-3 py-3 text-xs leading-6 text-muted-foreground">
|
||||||
|
<div className="font-medium text-foreground">当前渠道状态</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
{loadingBindings
|
||||||
|
? "正在加载配置..."
|
||||||
|
: activeChannel === "PUBLIC_POOL"
|
||||||
|
? publicPool?.enabled
|
||||||
|
? "公共 AI 已开放,可直接发送。"
|
||||||
|
: "公共 AI 未开放。"
|
||||||
|
: currentBinding
|
||||||
|
? currentBinding.isEnabled
|
||||||
|
? "已配置并启用。"
|
||||||
|
: "已配置,但当前关闭。"
|
||||||
|
: "尚未配置。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-h-[720px] flex-col overflow-hidden rounded-[2rem] border border-border/70 bg-card/92 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)]">
|
||||||
|
<div className="border-b border-border/70 px-5 py-4">
|
||||||
|
<div className="text-sm font-semibold text-foreground">
|
||||||
|
{CHANNEL_META[activeChannel].title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
发送消息时会自动附带你当前未完成任务的摘要。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||||
|
{currentMessages.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-border bg-muted/35 p-4 text-sm leading-7 text-muted-foreground">
|
||||||
|
<div className="font-medium text-foreground">暂无对话记录。</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
你可以输入“帮我根据当前未完成任务安排今天下午的执行顺序”直接开始。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
currentMessages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={cn(
|
||||||
|
"max-w-[92%] rounded-2xl px-4 py-3 text-sm leading-7 shadow-sm",
|
||||||
|
message.role === "user"
|
||||||
|
? "ml-auto bg-primary text-primary-foreground"
|
||||||
|
: message.role === "assistant"
|
||||||
|
? "border border-border/70 bg-background text-foreground"
|
||||||
|
: "border border-destructive/15 bg-destructive/8 text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||||
|
{message.meta ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-2 text-[11px]",
|
||||||
|
message.role === "user"
|
||||||
|
? "text-primary-foreground/80"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.meta}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/70 p-5">
|
||||||
|
{sendBlockedReason ? (
|
||||||
|
<div className="mb-3 rounded-2xl border border-amber-500/15 bg-amber-500/10 px-3 py-2 text-sm leading-6 text-amber-700 dark:text-amber-300">
|
||||||
|
{sendBlockedReason}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={draftMessage}
|
||||||
|
onChange={(event) => setDraftMessage(event.target.value)}
|
||||||
|
placeholder="输入你的问题,例如:结合我当前待办,帮我排一下今天的优先级。"
|
||||||
|
className="min-h-[140px] w-full rounded-2xl border border-border bg-background px-4 py-3 text-sm leading-7 outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<CircleAlert className="size-4" />
|
||||||
|
<span>当前只会使用你选中的渠道,不会在前端静默切换。</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{sendBlockedReason ? (
|
||||||
|
<Button type="button" variant="outline" onClick={() => navigate("/settings")}>
|
||||||
|
去系统设置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSendMessage()}
|
||||||
|
disabled={
|
||||||
|
sending || draftMessage.trim().length === 0 || sendBlockedReason !== null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
发送中
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SendHorizontal className="size-4" />
|
||||||
|
发送
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Construction } from "lucide-react";
|
||||||
|
|
||||||
|
type PlaceholderPageProps = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlaceholderPage({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon = Construction
|
||||||
|
}: PlaceholderPageProps) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[2rem] border border-border/70 bg-card/92 p-8 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)]">
|
||||||
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Icon className="size-7" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { CheckCircle2, Globe2, KeyRound, LoaderCircle, PlugZap, Settings2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
listAiBindings,
|
||||||
|
upsertAiBinding,
|
||||||
|
type WebAiBindingSummary,
|
||||||
|
type WebAiBindingsResponse,
|
||||||
|
type WebAiChannel
|
||||||
|
} from "@/services/ai-api";
|
||||||
|
import type { WebSession } from "@/services/session-storage";
|
||||||
|
import {
|
||||||
|
buildAiBindingPayload,
|
||||||
|
createAiBindingFormState,
|
||||||
|
type AiBindingFormState
|
||||||
|
} from "@/components/ai/ai-shared";
|
||||||
|
|
||||||
|
type SettingsPageProps = {
|
||||||
|
session: WebSession;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SettingsTab = "ai" | "general";
|
||||||
|
|
||||||
|
type NoticeState = {
|
||||||
|
tone: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AiConfigCard({
|
||||||
|
channel,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
formState,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
saving,
|
||||||
|
binding
|
||||||
|
}: {
|
||||||
|
channel: Exclude<WebAiChannel, "PUBLIC_POOL">;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof KeyRound;
|
||||||
|
formState: AiBindingFormState;
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<AiBindingFormState>>;
|
||||||
|
onSave: () => Promise<void>;
|
||||||
|
saving: boolean;
|
||||||
|
binding: WebAiBindingSummary | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[2rem] border border-border/70 bg-card/92 p-5 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)]">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="rounded-2xl bg-primary/10 p-3 text-primary">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-foreground">{title}</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||||
|
formState.isEnabled
|
||||||
|
? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "border-border bg-background text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formState.isEnabled ? "已启用" : "已停用"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">服务商标识</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
||||||
|
value={formState.providerName}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
providerName: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={channel === "USER_KEY" ? "如 openai / deepseek / dashscope" : "可选"}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">模型</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
||||||
|
value={formState.model}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
model: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={channel === "USER_KEY" ? "如 gpt-4o-mini" : "可选"}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{channel === "USER_KEY" ? "接口地址" : "AstrBot 地址"}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
||||||
|
value={formState.endpoint}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
endpoint: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
channel === "USER_KEY" ? "如 https://api.openai.com/v1" : "如 http://100.64.0.21:6185"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{channel === "ASTRBOT" ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">configId</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
||||||
|
value={formState.configId}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
configId: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="如 default"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">configName</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
||||||
|
value={formState.configName}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
configName: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="可选"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{channel === "USER_KEY" ? "API Key" : "AstrBot API Key"}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm outline-none transition-colors focus:border-primary/40"
|
||||||
|
value={formState.apiKey}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
apiKey: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={binding?.hasApiKey ? "留空则保持当前密钥不变" : "请输入密钥"}
|
||||||
|
/>
|
||||||
|
{binding?.maskedApiKey ? (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
当前已保存密钥:{binding.maskedApiKey}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 flex items-center gap-2 rounded-2xl border border-border/70 bg-background/70 px-3 py-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formState.isEnabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
isEnabled: event.target.checked
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>保存后立即启用该渠道</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs leading-6 text-muted-foreground">
|
||||||
|
{channel === "USER_KEY"
|
||||||
|
? "该配置按用户单独保存,适合接入你自己的服务商密钥。"
|
||||||
|
: "该配置按用户单独保存,适合直接复用 AstrBot 中已有的模型能力。"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button type="button" onClick={() => void onSave()} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
保存中
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"保存配置"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage({ session }: SettingsPageProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>("ai");
|
||||||
|
const [bindingsResponse, setBindingsResponse] = useState<WebAiBindingsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<NoticeState | null>(null);
|
||||||
|
const [savingChannel, setSavingChannel] = useState<WebAiChannel | null>(null);
|
||||||
|
const [userKeyForm, setUserKeyForm] = useState<AiBindingFormState>(() =>
|
||||||
|
createAiBindingFormState()
|
||||||
|
);
|
||||||
|
const [astrbotForm, setAstrbotForm] = useState<AiBindingFormState>(() =>
|
||||||
|
createAiBindingFormState()
|
||||||
|
);
|
||||||
|
|
||||||
|
const bindingMap = useMemo(() => {
|
||||||
|
const map = new Map<WebAiChannel, WebAiBindingSummary>();
|
||||||
|
for (const binding of bindingsResponse?.bindings ?? []) {
|
||||||
|
map.set(binding.channel, binding);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [bindingsResponse]);
|
||||||
|
|
||||||
|
const loadBindings = useCallback(async (): Promise<void> => {
|
||||||
|
setRefreshing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await listAiBindings(session);
|
||||||
|
setBindingsResponse(response);
|
||||||
|
setUserKeyForm(
|
||||||
|
createAiBindingFormState(response.bindings.find((item) => item.channel === "USER_KEY"))
|
||||||
|
);
|
||||||
|
setAstrbotForm(
|
||||||
|
createAiBindingFormState(response.bindings.find((item) => item.channel === "ASTRBOT"))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "AI 配置加载失败"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBindings();
|
||||||
|
}, [loadBindings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setNotice(null);
|
||||||
|
}, 2800);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [notice]);
|
||||||
|
|
||||||
|
async function handleSaveChannel(channel: Exclude<WebAiChannel, "PUBLIC_POOL">): Promise<void> {
|
||||||
|
const formState = channel === "USER_KEY" ? userKeyForm : astrbotForm;
|
||||||
|
const binding = bindingMap.get(channel) ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSavingChannel(channel);
|
||||||
|
await upsertAiBinding(session, buildAiBindingPayload(channel, formState, binding));
|
||||||
|
setNotice({
|
||||||
|
tone: "success",
|
||||||
|
message: channel === "USER_KEY" ? "自备厂商配置已保存。" : "AstrBot 配置已保存。"
|
||||||
|
});
|
||||||
|
if (channel === "USER_KEY") {
|
||||||
|
setUserKeyForm((current) => ({
|
||||||
|
...current,
|
||||||
|
apiKey: ""
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setAstrbotForm((current) => ({
|
||||||
|
...current,
|
||||||
|
apiKey: ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await loadBindings();
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({
|
||||||
|
tone: "error",
|
||||||
|
message: error instanceof Error ? error.message : "AI 配置保存失败"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingChannel(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-[2rem] border border-border/70 bg-card/92 p-6 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)]">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-primary">
|
||||||
|
<Settings2 className="size-4" />
|
||||||
|
系统设置
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
|
||||||
|
把配置和功能页面彻底拆开
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-7 text-muted-foreground">
|
||||||
|
AI 问答在独立的 AI 助手页面中使用,渠道配置统一维护在这里,不再悬挂在任务页右侧。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void loadBindings()}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
{refreshing ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
刷新中
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"刷新配置"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={activeTab === "ai" ? "default" : "outline"}
|
||||||
|
onClick={() => setActiveTab("ai")}
|
||||||
|
>
|
||||||
|
AI 配置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={activeTab === "general" ? "default" : "outline"}
|
||||||
|
onClick={() => setActiveTab("general")}
|
||||||
|
>
|
||||||
|
其他设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 rounded-2xl border px-3 py-2 text-sm",
|
||||||
|
notice.tone === "success"
|
||||||
|
? "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "border-destructive/20 bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>{notice.message}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "ai" ? (
|
||||||
|
loading ? (
|
||||||
|
<div className="rounded-[2rem] border border-border/70 bg-card/92 p-6 text-sm text-muted-foreground shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)]">
|
||||||
|
正在加载 AI 配置...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AiConfigCard
|
||||||
|
channel="USER_KEY"
|
||||||
|
title="自备厂商"
|
||||||
|
description="当前支持 OpenAI-Compatible 接口。Google 原生协议、阿里云原生协议将单独适配。"
|
||||||
|
icon={KeyRound}
|
||||||
|
formState={userKeyForm}
|
||||||
|
onChange={setUserKeyForm}
|
||||||
|
onSave={() => handleSaveChannel("USER_KEY")}
|
||||||
|
saving={savingChannel === "USER_KEY"}
|
||||||
|
binding={bindingMap.get("USER_KEY") ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AiConfigCard
|
||||||
|
channel="ASTRBOT"
|
||||||
|
title="AstrBot"
|
||||||
|
description="填写 AstrBot 地址与 API Key 后,即可在 AI 助手页面中使用你的 AstrBot 渠道。"
|
||||||
|
icon={PlugZap}
|
||||||
|
formState={astrbotForm}
|
||||||
|
onChange={setAstrbotForm}
|
||||||
|
onSave={() => handleSaveChannel("ASTRBOT")}
|
||||||
|
saving={savingChannel === "ASTRBOT"}
|
||||||
|
binding={bindingMap.get("ASTRBOT") ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="rounded-[2rem] border border-border/70 bg-card/92 p-5 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)]">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="rounded-2xl bg-primary/10 p-3 text-primary">
|
||||||
|
<Globe2 className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-foreground">
|
||||||
|
公共 AI
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
|
该渠道由管理员统一维护,普通用户仅可查看状态和使用,不可修改。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||||
|
bindingsResponse?.publicPool?.enabled
|
||||||
|
? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "border-border bg-background text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bindingsResponse?.publicPool?.enabled ? "已开放" : "未开放"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-2xl border border-border/70 bg-background/80 p-4 text-sm leading-7 text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
提供商:
|
||||||
|
<span className="ml-2 text-foreground">
|
||||||
|
{bindingsResponse?.publicPool?.providerName || "未设置"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
模型:
|
||||||
|
<span className="ml-2 text-foreground">
|
||||||
|
{bindingsResponse?.publicPool?.model || "未设置"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
接口地址:
|
||||||
|
<span className="ml-2 break-all text-foreground">
|
||||||
|
{bindingsResponse?.publicPool?.endpoint || "未设置"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<section className="rounded-[2rem] border border-border/70 bg-card/92 p-8 shadow-[0_24px_80px_-48px_rgba(15,23,42,0.55)]">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight text-foreground">其他设置</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-muted-foreground">
|
||||||
|
这里后续会接入站点外观、提醒偏好、存储配额展示等系统设置项。当前先把 AI
|
||||||
|
配置独立出来,避免继续堆在任务页面。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
ServerCrash
|
ServerCrash
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSyncEngine, type SyncEngineStatus } from "@/hooks/use-sync-engine";
|
import { useSyncEngine, type SyncEngineStatus } from "@/hooks/use-sync-engine";
|
||||||
import { AiAssistantPanel } from "@/components/ai/ai-assistant-panel";
|
|
||||||
import { TaskRichEditor } from "@/components/task-rich-editor";
|
import { TaskRichEditor } from "@/components/task-rich-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -921,7 +920,7 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SyncStatusCard syncStatus={syncStatus} onTriggerSync={triggerSync} />
|
<SyncStatusCard syncStatus={syncStatus} onTriggerSync={triggerSync} />
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)_360px]">
|
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
<TaskListPanel
|
<TaskListPanel
|
||||||
tasks={taskList}
|
tasks={taskList}
|
||||||
selectedTaskId={selectedTaskId}
|
selectedTaskId={selectedTaskId}
|
||||||
@@ -947,8 +946,6 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
|
|||||||
onDdlChange={handleDdlChange}
|
onDdlChange={handleDdlChange}
|
||||||
onEditorChange={handleEditorChange}
|
onEditorChange={handleEditorChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AiAssistantPanel session={session} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user