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.
454 lines
14 KiB
454 lines
14 KiB
import { ref } from "vue";
|
|
import { parseJsonEventStream, uiMessageChunkSchema } from "ai";
|
|
import { useAuthSession } from "./useAuthSession";
|
|
import { useAgentRateLimit } from "./useAgentRateLimit";
|
|
|
|
export type MessagePartType = "text" | "reasoning" | "tool-call" | "tool-result" | "tool-approval";
|
|
|
|
export interface MessagePart {
|
|
id: string;
|
|
type: MessagePartType;
|
|
text?: string;
|
|
toolName?: string;
|
|
toolCallId?: string;
|
|
args?: unknown;
|
|
result?: unknown;
|
|
state?: "call" | "result" | "approval-requested" | "approval-responded";
|
|
approvalId?: string;
|
|
approved?: boolean;
|
|
approvalReason?: string;
|
|
isAutomaticApproval?: boolean;
|
|
reasoningLoading?: boolean;
|
|
reasoningDuration?: number;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function useAgentChat(options: UseAgentChatOptions) {
|
|
const { sessionId, modelId, enableThinking, enableTools } = options;
|
|
const auth = useAuthSession();
|
|
const rateLimit = useAgentRateLimit();
|
|
|
|
const messages = ref<AgentMessage[]>([]);
|
|
const isLoading = ref(false);
|
|
const errorMessage = ref("");
|
|
const isStopped = ref(false);
|
|
|
|
let abortController: AbortController | null = null;
|
|
|
|
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 = [];
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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 hasPendingApproval = finalMsg.parts?.some(
|
|
(p) => p.state === "approval-requested" && !p.isAutomaticApproval,
|
|
);
|
|
if (hasPendingApproval) return;
|
|
if (!hasText && !hasToolCall) {
|
|
errorMessage.value = "模型未返回任何内容(可能已达到工具调用次数上限或模型无响应)";
|
|
messages.value.splice(assistantIdx, 1);
|
|
} else if (!hasText && hasToolCall) {
|
|
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;
|
|
|
|
errorMessage.value = "";
|
|
isStopped.value = false;
|
|
|
|
let userMsgIdx = -1;
|
|
if (!opts?.editMessageId && !opts?.regenerate) {
|
|
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);
|
|
} 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);
|
|
}
|
|
}
|
|
} 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;
|
|
|
|
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("", { regenerate: false })),
|
|
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));
|
|
} 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;
|
|
try {
|
|
await $fetch("/api/agent/feedback", {
|
|
method: "POST",
|
|
body: { messageId, feedback },
|
|
});
|
|
const msg = messages.value.find((m) => m.id === messageId);
|
|
if (msg) {
|
|
msg.feedback = msg.feedback === feedback ? null : feedback;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
function stopGeneration() {
|
|
if (abortController) {
|
|
abortController.abort();
|
|
abortController = null;
|
|
}
|
|
}
|
|
|
|
function clear() {
|
|
messages.value = [];
|
|
errorMessage.value = "";
|
|
isStopped.value = false;
|
|
}
|
|
|
|
return {
|
|
messages,
|
|
isLoading,
|
|
errorMessage,
|
|
isStopped,
|
|
rateLimit,
|
|
send,
|
|
stopGeneration,
|
|
clear,
|
|
loadMessages,
|
|
respondToApproval,
|
|
sendFeedback,
|
|
};
|
|
}
|
|
|