Files
TodoList/apps/web/src/pages/ai-chat-page.tsx
T

540 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } 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,
type WebAiLocalTaskContextItem,
WebAiApiError
} from "@/services/ai-api";
import {
deleteLocalAiChatSession,
listLocalAiChatSessions,
saveLocalAiChatSession,
type LocalAiChatMessageRecord
} from "@/services/local-ai-chat-repo";
import { listLocalTasksByUser } from "@/services/local-task-repo";
import type { WebSession } from "@/services/session-storage";
import { CHANNEL_META, CHANNEL_ORDER } from "@/components/ai/ai-shared";
type AiChatPageProps = {
session: WebSession;
};
type AiMessageRecord = LocalAiChatMessageRecord;
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]
};
}
function buildLocalTaskContext(
items: Awaited<ReturnType<typeof listLocalTasksByUser>>
): WebAiLocalTaskContextItem[] {
return items
.filter((item) => item.status === "TODO" || item.status === "IN_PROGRESS")
.slice(0, 20)
.map((item) => ({
id: item.id,
title: item.title,
priority: item.priority,
status: item.status,
ddlAt: item.ddlAt,
contentText: item.contentText,
updatedAt: item.updatedAt
}));
}
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 [historyLoaded, setHistoryLoaded] = 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 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(() => {
let cancelled = false;
async function loadLocalHistory(): Promise<void> {
try {
const records = await listLocalAiChatSessions(session.user.id);
if (cancelled) {
return;
}
const nextMessages = createEmptyMessages();
const nextSessionIds = createEmptySessionIds();
for (const record of records) {
nextMessages[record.channel] = record.messages;
if (record.sessionId) {
nextSessionIds[record.channel] = record.sessionId;
}
}
setMessagesByChannel(nextMessages);
setSessionIds(nextSessionIds);
} finally {
if (!cancelled) {
setHistoryLoaded(true);
}
}
}
setHistoryLoaded(false);
void loadLocalHistory();
return () => {
cancelled = true;
};
}, [session.user.id]);
useEffect(() => {
if (!historyLoaded) {
return;
}
void Promise.all(
CHANNEL_ORDER.map(async (channel) => {
const messages = messagesByChannel[channel];
const sessionId = sessionIds[channel] ?? null;
if (messages.length === 0 && sessionId === null) {
await deleteLocalAiChatSession(session.user.id, channel);
return;
}
await saveLocalAiChatSession({
userId: session.user.id,
channel,
sessionId,
messages
});
})
);
}, [historyLoaded, messagesByChannel, session.user.id, sessionIds]);
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 localTasks = buildLocalTaskContext(await listLocalTasksByUser(session.user.id));
const response = await chatWithAi(session, {
channel,
message,
sessionId: sessionIds[channel],
localTasks
});
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);
}
}
function handleDraftKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) {
return;
}
event.preventDefault();
void handleSendMessage();
}
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)}
onKeyDown={handleDraftKeyDown}
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>
);
}