feat(FN-2017): merge fusion/fn-2017

This commit is contained in:
gsxdsm
2026-04-18 23:22:20 -07:00
parent e77226099f
commit 49fea9ede7
14 changed files with 1095 additions and 90 deletions

View File

@@ -371,6 +371,10 @@ export function getRateLimitResetTime(ip: string): Date | null {
*/
export class ChatManager {
private agentStoreReady?: Promise<void>;
private activeGenerations = new Map<string, {
abortController: AbortController;
agentResult?: AgentResult;
}>();
constructor(
private chatStore: ChatStore,
@@ -507,72 +511,73 @@ export class ChatManager {
modelProvider?: string,
modelId?: string,
): Promise<void> {
// Validate session exists
const abortController = new AbortController();
this.activeGenerations.set(sessionId, { abortController });
const session = this.chatStore.getSession(sessionId);
if (!session) {
chatStreamManager.broadcast(sessionId, {
type: "error",
data: `Chat session ${sessionId} not found`,
});
return;
}
const hasMentionCandidates = /@[\w-]+/.test(content);
const mentionAgents = hasMentionCandidates ? await this.listAgentsForMentions() : [];
const mentions = hasMentionCandidates ? await this.parseMentions(content, mentionAgents) : [];
// Persist user message
let _userMessageId: string;
try {
const userMessage = this.chatStore.addMessage(sessionId, {
role: "user",
content,
metadata: mentions.length > 0 ? { mentions } : undefined,
});
_userMessageId = userMessage.id;
} catch (err) {
chatStreamManager.broadcast(sessionId, {
type: "error",
data: `Failed to save message: ${err instanceof Error ? err.message : "Unknown error"}`,
});
return;
}
// Use model from session if not overridden (needed for both AI response and title generation)
const effectiveModelProvider = modelProvider ?? session.modelProvider ?? undefined;
const effectiveModelId = modelId ?? session.modelId ?? undefined;
// Auto-generate chat title on first message if session has no title
const needsTitle = session.title === null || session.title === undefined || session.title.trim() === "";
if (needsTitle) {
// Fire-and-forget title generation (non-blocking)
(async () => {
try {
const generated = await summarizeTitle(
content.trim(),
this.rootDir,
effectiveModelProvider,
effectiveModelId,
);
const title = generated ?? content.trim().slice(0, 60).trim();
if (title) {
this.chatStore.updateSession(sessionId, { title });
}
} catch {
// Fallback on any error
const fallback = content.trim().slice(0, 60).trim();
if (fallback) {
this.chatStore.updateSession(sessionId, { title: fallback });
}
}
})();
}
let agentResult: AgentResult | undefined;
let accumulatedThinking = "";
let accumulatedText = "";
try {
// Validate session exists
if (!session) {
chatStreamManager.broadcast(sessionId, {
type: "error",
data: `Chat session ${sessionId} not found`,
});
return;
}
const hasMentionCandidates = /@[\w-]+/.test(content);
const mentionAgents = hasMentionCandidates ? await this.listAgentsForMentions() : [];
const mentions = hasMentionCandidates ? await this.parseMentions(content, mentionAgents) : [];
// Persist user message
try {
this.chatStore.addMessage(sessionId, {
role: "user",
content,
metadata: mentions.length > 0 ? { mentions } : undefined,
});
} catch (err) {
chatStreamManager.broadcast(sessionId, {
type: "error",
data: `Failed to save message: ${err instanceof Error ? err.message : "Unknown error"}`,
});
return;
}
// Use model from session if not overridden (needed for both AI response and title generation)
const effectiveModelProvider = modelProvider ?? session.modelProvider ?? undefined;
const effectiveModelId = modelId ?? session.modelId ?? undefined;
// Auto-generate chat title on first message if session has no title
const needsTitle = session.title === null || session.title === undefined || session.title.trim() === "";
if (needsTitle) {
// Fire-and-forget title generation (non-blocking)
(async () => {
try {
const generated = await summarizeTitle(
content.trim(),
this.rootDir,
effectiveModelProvider,
effectiveModelId,
);
const title = generated ?? content.trim().slice(0, 60).trim();
if (title) {
this.chatStore.updateSession(sessionId, { title });
}
} catch {
// Fallback on any error
const fallback = content.trim().slice(0, 60).trim();
if (fallback) {
this.chatStore.updateSession(sessionId, { title: fallback });
}
}
})();
}
// Ensure engine is loaded
await ensureEngineReady();
@@ -666,10 +671,20 @@ export class ChatManager {
});
},
});
this.activeGenerations.set(sessionId, { abortController, agentResult });
if (abortController.signal.aborted) {
agentResult.session.dispose?.();
return;
}
// Send user message and get response
await agentResult.session.prompt(promptContent);
if (abortController.signal.aborted) {
return;
}
// Extract response text from agent state
let responseText = "";
interface AgentMessage {
@@ -707,6 +722,14 @@ export class ChatManager {
data: { messageId: assistantMessage.id },
});
} catch (err) {
if (abortController.signal.aborted) {
chatStreamManager.broadcast(sessionId, {
type: "error",
data: "Generation cancelled",
});
return;
}
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
console.error(`[chat] Error in sendMessage for session ${sessionId}:`, err);
@@ -728,6 +751,8 @@ export class ChatManager {
data: errorMessage,
});
} finally {
this.activeGenerations.delete(sessionId);
// Always dispose agent session
if (agentResult) {
try {
@@ -738,6 +763,30 @@ export class ChatManager {
}
}
}
cancelGeneration(sessionId: string): boolean {
const entry = this.activeGenerations.get(sessionId);
if (!entry) {
return false;
}
entry.abortController.abort();
if (entry.agentResult) {
try {
entry.agentResult.session.dispose?.();
} catch (err) {
console.error(`[chat] Error disposing agent session during cancellation:`, err);
}
}
chatStreamManager.broadcast(sessionId, {
type: "error",
data: "Generation cancelled",
});
return true;
}
}
// ── Test Helpers ────────────────────────────────────────────────────────────