You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
570 lines
18 KiB
570 lines
18 KiB
import { ref, computed } from "vue";
|
|
import { parseJsonEventStream, uiMessageChunkSchema } from "ai";
|
|
import { useAuthSession } from "./useAuthSession";
|
|
import { useAgentRateLimit } from "./useAgentRateLimit";
|
|
import type { MessagePart, MessagePartType } from "../types/chat";
|
|
|
|
export interface AgentMessage {
|
|
id: string;
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
parts?: MessagePart[];
|
|
modelId?: number | null;
|
|
inputTokens?: number | null;
|
|
outputTokens?: number | null;
|
|
createdAt?: string;
|
|
feedback?: "like" | "dislike" | null;
|
|
}
|
|
|
|
export interface UseAgentChatOptions {
|
|
sessionId: () => string | null;
|
|
modelId: () => number | null;
|
|
enableThinking: () => boolean;
|
|
enableTools: () => boolean;
|
|
onStreamComplete?: () => void;
|
|
}
|
|
|
|
export function useAgentChat(options: UseAgentChatOptions) {
|
|
const { sessionId, modelId, enableThinking, enableTools, onStreamComplete } = options;
|
|
const auth = useAuthSession();
|
|
const rateLimit = useAgentRateLimit();
|
|
|
|
const messages = ref<AgentMessage[]>([]);
|
|
const isLoading = ref(false);
|
|
const errorMessage = ref("");
|
|
const isStopped = ref(false);
|
|
|
|
const hasPendingApproval = computed(() =>
|
|
messages.value.some((m) =>
|
|
m.parts?.some((p) => p.state === "approval-requested" && !p.isAutomaticApproval),
|
|
),
|
|
);
|
|
|
|
let abortController: AbortController | null = null;
|
|
let staleApprovalToolCallIds: Set<string> = new Set();
|
|
|
|
function generateId(): string {
|
|
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
|
}
|
|
|
|
function getOrCreateLastPart(msg: AgentMessage, type: MessagePartType): MessagePart | null {
|
|
if (!msg.parts) msg.parts = [];
|
|
const last = msg.parts[msg.parts.length - 1];
|
|
if (last && last.type === type) return last;
|
|
return null;
|
|
}
|
|
|
|
function appendPart(msg: AgentMessage, part: MessagePart) {
|
|
if (!msg.parts) msg.parts = [];
|
|
msg.parts.push(part);
|
|
}
|
|
|
|
function updateLastReasoningDuration(msg: AgentMessage) {
|
|
if (!msg.parts) return;
|
|
for (let i = msg.parts.length - 1; i >= 0; i--) {
|
|
const p = msg.parts[i];
|
|
if (!p) continue;
|
|
if (p.type === "reasoning" && p.reasoningLoading) {
|
|
p.reasoningLoading = false;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function cleanupInProgressToolCalls(msg: AgentMessage) {
|
|
if (!msg.parts) return;
|
|
msg.parts = msg.parts.filter(
|
|
(p) => p.type !== "tool-call" || p.state === "result" || p.state === "approval-responded",
|
|
);
|
|
}
|
|
|
|
async function loadMessages(sid: string) {
|
|
messages.value = [];
|
|
errorMessage.value = "";
|
|
try {
|
|
const res = await $fetch<{ code: number; data: { messages: AgentMessage[] } }>(
|
|
`/api/agent/sessions/${sid}/messages`,
|
|
{ method: "GET" },
|
|
);
|
|
messages.value = (res.data?.messages ?? []).map((m) => ({
|
|
...m,
|
|
parts: m.parts ? (typeof m.parts === "string" ? JSON.parse(m.parts) : m.parts) : undefined,
|
|
}));
|
|
} catch {
|
|
messages.value = [];
|
|
}
|
|
}
|
|
|
|
async function cleanupStaleApprovals() {
|
|
const staleMessages: { msg: AgentMessage; parts: MessagePart[] }[] = [];
|
|
for (const msg of messages.value) {
|
|
if (msg.role !== "assistant" || !msg.parts) continue;
|
|
const hasText = msg.parts.some((p) => p.type === "text" && p.text);
|
|
if (!hasText) continue;
|
|
const hasStale = msg.parts.some((p) => p.state === "approval-requested" && !p.isAutomaticApproval);
|
|
if (!hasStale) continue;
|
|
staleMessages.push({ msg, parts: msg.parts });
|
|
}
|
|
|
|
if (staleMessages.length === 0) return;
|
|
|
|
for (const { msg, parts } of staleMessages) {
|
|
for (const p of parts) {
|
|
if (p.state === "approval-requested" && !p.isAutomaticApproval) {
|
|
p.state = "result";
|
|
p.result = "(此工具调用已被跳过——AI 已给出最终回复)";
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
await $fetch("/api/agent/chat/cleanup-approvals", {
|
|
method: "POST",
|
|
body: { sessionId: sessionId() },
|
|
});
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
function buildRequestBody(content: string, opts?: { editMessageId?: string; regenerate?: boolean }) {
|
|
return {
|
|
sessionId: sessionId(),
|
|
content,
|
|
modelId: modelId(),
|
|
enableThinking: enableThinking(),
|
|
enableTools: enableTools(),
|
|
...opts,
|
|
};
|
|
}
|
|
|
|
async function processStream(res: Response, assistantIdx: number) {
|
|
if (!res.body) throw new Error("响应体为空");
|
|
|
|
let reasoningStartTime: number | null = null;
|
|
|
|
const chunkStream = parseJsonEventStream({
|
|
stream: res.body,
|
|
schema: uiMessageChunkSchema,
|
|
});
|
|
|
|
const reader = chunkStream.getReader();
|
|
for (;;) {
|
|
const { done, value: parsed } = await reader.read();
|
|
if (done) break;
|
|
if (!parsed.success) continue;
|
|
const chunk = parsed.value;
|
|
|
|
switch (chunk.type) {
|
|
case "reasoning-start": {
|
|
const msg = messages.value[assistantIdx];
|
|
if (!msg) break;
|
|
if (reasoningStartTime === null) reasoningStartTime = Date.now();
|
|
appendPart(msg, { id: generateId(), type: "reasoning", text: "", reasoningLoading: true });
|
|
break;
|
|
}
|
|
case "reasoning-delta": {
|
|
const msg = messages.value[assistantIdx];
|
|
if (!msg) break;
|
|
const part = getOrCreateLastPart(msg, "reasoning");
|
|
if (part) {
|
|
part.text = (part.text ?? "") + chunk.delta;
|
|
}
|
|
break;
|
|
}
|
|
case "reasoning-end": {
|
|
const msg = messages.value[assistantIdx];
|
|
if (msg) updateLastReasoningDuration(msg);
|
|
break;
|
|
}
|
|
case "text-delta": {
|
|
const msg = messages.value[assistantIdx];
|
|
if (!msg) break;
|
|
updateLastReasoningDuration(msg);
|
|
let part = getOrCreateLastPart(msg, "text");
|
|
if (!part) {
|
|
part = { id: generateId(), type: "text", text: "" };
|
|
appendPart(msg, part);
|
|
}
|
|
part.text = (part.text ?? "") + chunk.delta;
|
|
msg.content += chunk.delta;
|
|
break;
|
|
}
|
|
case "error": {
|
|
errorMessage.value = chunk.errorText || "流式响应出错";
|
|
break;
|
|
}
|
|
case "tool-input-available": {
|
|
const msg = messages.value[assistantIdx];
|
|
if (!msg) break;
|
|
updateLastReasoningDuration(msg);
|
|
appendPart(msg, {
|
|
id: generateId(),
|
|
type: "tool-call",
|
|
toolName: chunk.toolName,
|
|
toolCallId: chunk.toolCallId,
|
|
args: chunk.input,
|
|
state: "call",
|
|
});
|
|
break;
|
|
}
|
|
case "tool-output-available": {
|
|
const msg = messages.value[assistantIdx];
|
|
if (!msg || !msg.parts) break;
|
|
const callPart = msg.parts.find((p) => p.type === "tool-call" && p.toolCallId === chunk.toolCallId);
|
|
if (callPart) {
|
|
callPart.result = chunk.output;
|
|
callPart.state = "result";
|
|
}
|
|
break;
|
|
}
|
|
case "tool-output-denied": {
|
|
const msg = messages.value[assistantIdx];
|
|
if (!msg || !msg.parts) break;
|
|
const callPart = msg.parts.find((p) => p.type === "tool-call" && p.toolCallId === chunk.toolCallId);
|
|
if (callPart) {
|
|
callPart.state = "result";
|
|
callPart.result = "工具执行被拒绝";
|
|
}
|
|
break;
|
|
}
|
|
case "tool-approval-request": {
|
|
const msg = messages.value[assistantIdx];
|
|
if (!msg) break;
|
|
updateLastReasoningDuration(msg);
|
|
const isAutomatic = !!(chunk as any).isAutomatic;
|
|
const existingPart = msg.parts?.find((p) => p.type === "tool-call" && p.toolCallId === chunk.toolCallId);
|
|
if (existingPart) {
|
|
existingPart.state = "approval-requested";
|
|
existingPart.approvalId = chunk.approvalId;
|
|
existingPart.isAutomaticApproval = isAutomatic;
|
|
} else {
|
|
appendPart(msg, {
|
|
id: generateId(),
|
|
type: "tool-call",
|
|
toolName: (chunk as any).toolName,
|
|
toolCallId: chunk.toolCallId,
|
|
args: (chunk as any).input,
|
|
state: "approval-requested",
|
|
approvalId: chunk.approvalId,
|
|
isAutomaticApproval: isAutomatic,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "tool-approval-response": {
|
|
const msg = messages.value[assistantIdx];
|
|
if (!msg || !msg.parts) break;
|
|
const part = msg.parts.find((p) => p.type === "tool-call" && p.approvalId === chunk.approvalId);
|
|
if (part) {
|
|
part.state = "approval-responded";
|
|
part.approved = chunk.approved;
|
|
if (chunk.reason) part.approvalReason = chunk.reason;
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
const msg = messages.value[assistantIdx];
|
|
if (msg) updateLastReasoningDuration(msg);
|
|
}
|
|
|
|
function validateAssistantContent(assistantIdx: number, isApprovalContinue = false) {
|
|
const finalMsg = messages.value[assistantIdx];
|
|
if (finalMsg && !errorMessage.value) {
|
|
const hasText = finalMsg.parts?.some((p) => p.type === "text" && p.text);
|
|
const hasToolCall = finalMsg.parts?.some((p) => p.type === "tool-call");
|
|
const pendingApprovalParts = finalMsg.parts?.filter(
|
|
(p) => p.state === "approval-requested" && !p.isAutomaticApproval,
|
|
);
|
|
const hasPendingApproval = pendingApprovalParts && pendingApprovalParts.length > 0;
|
|
if (hasPendingApproval && isApprovalContinue && hasText) {
|
|
pendingApprovalParts!.forEach((p) => {
|
|
if (staleApprovalToolCallIds.has(p.toolCallId ?? "")) {
|
|
p.state = "result";
|
|
p.result = "(此工具调用已被跳过——AI 已给出最终回复)";
|
|
}
|
|
});
|
|
}
|
|
const hasReasoning = finalMsg.parts?.some((p) => p.type === "reasoning" && p.text);
|
|
if (!hasText && !hasToolCall && !hasReasoning) {
|
|
errorMessage.value = "模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)";
|
|
messages.value.splice(assistantIdx, 1);
|
|
} else if (!hasText && !hasToolCall && hasReasoning) {
|
|
finalMsg.parts?.push({
|
|
id: generateId(),
|
|
type: "text",
|
|
text: "(模型仅返回了思考过程,未生成正式回复。请尝试更换模型或调整问题后重试。)",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function send(
|
|
content: string,
|
|
opts?: { editMessageId?: string; regenerate?: boolean },
|
|
) {
|
|
const trimmed = content.trim();
|
|
const sid = sessionId();
|
|
if (!trimmed || sid === null || isLoading.value) return;
|
|
|
|
if (!modelId()) {
|
|
errorMessage.value = "请先选择模型后再发送消息";
|
|
const { $toast } = useNuxtApp();
|
|
$toast?.error?.("请先选择模型后再发送消息");
|
|
return;
|
|
}
|
|
|
|
errorMessage.value = "";
|
|
isStopped.value = false;
|
|
staleApprovalToolCallIds = new Set();
|
|
|
|
await cleanupStaleApprovals();
|
|
|
|
let userMsgIdx = -1;
|
|
|
|
if (opts?.editMessageId) {
|
|
const editIdx = messages.value.findIndex((m) => m.id === opts.editMessageId);
|
|
if (editIdx === -1) {
|
|
errorMessage.value = "编辑的消息不存在";
|
|
const { $toast } = useNuxtApp();
|
|
$toast?.error?.("编辑的消息不存在");
|
|
return;
|
|
}
|
|
const editMsg = messages.value[editIdx];
|
|
if (editMsg) {
|
|
editMsg.content = trimmed;
|
|
}
|
|
messages.value = messages.value.slice(0, editIdx + 1);
|
|
userMsgIdx = editIdx;
|
|
} else if (opts?.regenerate) {
|
|
const lastAssistantIdx = messages.value.map((m) => m.role).lastIndexOf("assistant");
|
|
if (lastAssistantIdx !== -1) {
|
|
messages.value.splice(lastAssistantIdx, 1);
|
|
}
|
|
const lastUserIdx = messages.value.map((m) => m.role).lastIndexOf("user");
|
|
if (lastUserIdx !== -1) {
|
|
const lastUserMsg = messages.value[lastUserIdx];
|
|
if (lastUserMsg) {
|
|
lastUserMsg.content = trimmed;
|
|
}
|
|
userMsgIdx = lastUserIdx;
|
|
}
|
|
} else {
|
|
messages.value.push({
|
|
id: generateId(),
|
|
role: "user",
|
|
content: trimmed,
|
|
});
|
|
userMsgIdx = messages.value.length - 1;
|
|
}
|
|
|
|
const assistantMsg: AgentMessage = {
|
|
id: generateId(),
|
|
role: "assistant",
|
|
content: "",
|
|
parts: [],
|
|
};
|
|
messages.value.push(assistantMsg);
|
|
const assistantIdx = messages.value.length - 1;
|
|
|
|
isLoading.value = true;
|
|
abortController = new AbortController();
|
|
|
|
try {
|
|
const res = await fetch("/api/agent/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildRequestBody(trimmed, opts)),
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
if (!auth.loggedIn.value) {
|
|
rateLimit.updateFromHeaders(res.headers);
|
|
}
|
|
|
|
const dbUserMessageId = res.headers.get("X-User-Message-Id");
|
|
if (dbUserMessageId && userMsgIdx >= 0) {
|
|
const userMsg = messages.value[userMsgIdx];
|
|
if (userMsg) {
|
|
userMsg.id = dbUserMessageId;
|
|
}
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const errText = await res.text();
|
|
throw new Error(errText || `请求失败 (${res.status})`);
|
|
}
|
|
|
|
await processStream(res, assistantIdx);
|
|
validateAssistantContent(assistantIdx);
|
|
onStreamComplete?.();
|
|
} catch (err: any) {
|
|
if (err.name === "AbortError") {
|
|
isStopped.value = true;
|
|
const msg = messages.value[assistantIdx];
|
|
if (msg) {
|
|
cleanupInProgressToolCalls(msg);
|
|
}
|
|
} else {
|
|
errorMessage.value = err.message || "请求失败";
|
|
const msg = messages.value[assistantIdx];
|
|
if (msg && !msg.content && (!msg.parts || msg.parts.length === 0)) {
|
|
messages.value.splice(assistantIdx, 1);
|
|
}
|
|
if (!opts?.editMessageId && !opts?.regenerate && userMsgIdx >= 0) {
|
|
const currentUserMsg = messages.value[userMsgIdx];
|
|
if (currentUserMsg && currentUserMsg.role === "user" && currentUserMsg.id.startsWith("am_") === false) {
|
|
const hasDbId = currentUserMsg.id.startsWith("am_");
|
|
if (!hasDbId) {
|
|
messages.value.splice(userMsgIdx, 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
isLoading.value = false;
|
|
abortController = null;
|
|
}
|
|
}
|
|
|
|
async function respondToApproval(toolCallId: string, approved: boolean, reason?: string) {
|
|
if (isLoading.value) return;
|
|
|
|
const assistantMsg = messages.value.find((m) =>
|
|
m.parts?.some((p) => p.toolCallId === toolCallId && p.state === "approval-requested"),
|
|
);
|
|
if (!assistantMsg) return;
|
|
|
|
const approvalPart = assistantMsg.parts?.find(
|
|
(p) => p.toolCallId === toolCallId && p.state === "approval-requested",
|
|
);
|
|
if (!approvalPart) return;
|
|
|
|
try {
|
|
await $fetch("/api/agent/chat/tool-approve", {
|
|
method: "POST",
|
|
body: {
|
|
sessionId: sessionId(),
|
|
toolCallId,
|
|
approved,
|
|
reason,
|
|
},
|
|
});
|
|
} catch {
|
|
}
|
|
|
|
approvalPart.state = "approval-responded";
|
|
approvalPart.approved = approved;
|
|
approvalPart.approvalReason = reason;
|
|
|
|
if (!approved) {
|
|
approvalPart.result = "工具执行被拒绝";
|
|
}
|
|
|
|
const hasOtherPendingApprovals = messages.value.some((m) =>
|
|
m.parts?.some(
|
|
(p) =>
|
|
p.state === "approval-requested" &&
|
|
!p.isAutomaticApproval &&
|
|
p.toolCallId !== toolCallId,
|
|
),
|
|
);
|
|
if (hasOtherPendingApprovals) return;
|
|
|
|
staleApprovalToolCallIds = new Set(
|
|
messages.value.flatMap((m) =>
|
|
(m.parts ?? [])
|
|
.filter((p) => p.state === "approval-requested" && !p.isAutomaticApproval)
|
|
.map((p) => p.toolCallId ?? ""),
|
|
),
|
|
);
|
|
|
|
isLoading.value = true;
|
|
abortController = new AbortController();
|
|
|
|
try {
|
|
const res = await fetch("/api/agent/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
sessionId: sessionId(),
|
|
content: "",
|
|
modelId: modelId(),
|
|
enableThinking: enableThinking(),
|
|
enableTools: enableTools(),
|
|
continueAfterApproval: true,
|
|
approvalToolCallId: toolCallId,
|
|
approved,
|
|
approvalReason: reason,
|
|
}),
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errText = await res.text();
|
|
throw new Error(errText || `请求失败 (${res.status})`);
|
|
}
|
|
|
|
await processStream(res, messages.value.indexOf(assistantMsg));
|
|
validateAssistantContent(messages.value.indexOf(assistantMsg), true);
|
|
onStreamComplete?.();
|
|
} catch (err: any) {
|
|
if (err.name !== "AbortError") {
|
|
errorMessage.value = err.message || "请求失败";
|
|
}
|
|
} finally {
|
|
isLoading.value = false;
|
|
abortController = null;
|
|
}
|
|
}
|
|
|
|
async function sendFeedback(messageId: string, feedback: "like" | "dislike") {
|
|
if (!auth.loggedIn.value) return;
|
|
const msg = messages.value.find((m) => m.id === messageId);
|
|
const newFeedback = msg?.feedback === feedback ? null : feedback;
|
|
try {
|
|
const res = await $fetch<{ code: number; data: { messageId: string; feedback: "like" | "dislike" | null } }>(
|
|
"/api/agent/feedback",
|
|
{
|
|
method: "POST",
|
|
body: { messageId, feedback: newFeedback },
|
|
},
|
|
);
|
|
if (res?.code === 0 && msg) {
|
|
msg.feedback = res.data?.feedback ?? newFeedback;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
function stopGeneration() {
|
|
if (abortController) {
|
|
abortController.abort();
|
|
abortController = null;
|
|
}
|
|
}
|
|
|
|
function clear() {
|
|
messages.value = [];
|
|
errorMessage.value = "";
|
|
isStopped.value = false;
|
|
}
|
|
|
|
return {
|
|
messages,
|
|
isLoading,
|
|
hasPendingApproval,
|
|
errorMessage,
|
|
isStopped,
|
|
rateLimit,
|
|
send,
|
|
stopGeneration,
|
|
clear,
|
|
loadMessages,
|
|
respondToApproval,
|
|
sendFeedback,
|
|
};
|
|
}
|
|
|