Files
FabledScribe/frontend/src/stores/chat.ts
T
bvandeusen d7e1fe6aab UX: queue bubbles, dashboard queuing, duplicate confirm buttons, short-note fix
- Dashboard chat: allow typing/sending during streaming (queued in store)
  placeholder updates to "Type to queue…" during generation
- ChatView/WorkspaceView: replace queued chip with actual pending message
  bubbles — light grey, dashed border, "Queued" badge above content; clear
  button below the stack
- ToolCallCard: when tool returns requires_confirmation=True, show
  "Similar content found" label (not "Error"), link to the similar note,
  and "Create Anyway" / "Don't Create" buttons that auto-send the reply
- tools.py: fuzzy title match now returns requires_confirmation=True with
  similar_note data instead of a hard error, so numbered-series notes
  (Lore: X 0, Lore: X 1) can be created with one button click; semantic
  match responses also include similar_note for the link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 23:32:21 -04:00

584 lines
18 KiB
TypeScript

import { ref, computed } from "vue";
import { defineStore } from "pinia";
import {
apiGet,
apiPost,
apiPatch,
apiDelete,
apiSSEStream,
} from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type {
Conversation,
ConversationDetail,
ContextMeta,
GenerationTiming,
Message,
OllamaStatus,
SendMessageResponse,
ToolCallRecord,
ToolPendingRecord,
} from "@/types/chat";
interface ConvStreamState {
streaming: boolean;
content: string;
thinking: string;
toolCalls: ToolCallRecord[];
status: string;
pendingTool: ToolPendingRecord | null;
contextMeta: ContextMeta | null;
}
interface QueuedMessage {
content: string;
contextNoteId?: number | null;
includeNoteIds?: number[];
think: boolean;
contextNoteTitle?: string;
excludeNoteIds?: number[];
ragProjectId?: number | null;
workspaceProjectId?: number | null;
}
export const useChatStore = defineStore("chat", () => {
const conversations = ref<Conversation[]>([]);
const currentConversation = ref<ConversationDetail | null>(null);
const total = ref(0);
const loading = ref(false);
const convStreams = ref<Record<number, ConvStreamState>>({});
const convQueues = ref<Record<number, QueuedMessage[]>>({});
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
const defaultModel = ref("");
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
function _getOrInitStream(id: number): ConvStreamState {
if (!convStreams.value[id]) {
convStreams.value[id] = {
streaming: false, content: "", thinking: "", toolCalls: [],
status: "", pendingTool: null, contextMeta: null,
};
}
return convStreams.value[id];
}
// Per-conversation computed getters — same names as old refs, zero callers change
const streaming = computed(() => {
const id = currentConversation.value?.id;
return !!id && (convStreams.value[id]?.streaming ?? false);
});
const streamingContent = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.content ?? "");
const streamingThinking = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.thinking ?? "");
const streamingToolCalls = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.toolCalls ?? []);
const streamingStatus = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.status ?? "");
const streamingPendingTool = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.pendingTool ?? null);
const lastContextMeta = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.contextMeta ?? null);
function isStreamingConv(id: number): boolean {
return convStreams.value[id]?.streaming ?? false;
}
const queuedCount = computed(() => {
const id = currentConversation.value?.id;
return id ? (convQueues.value[id]?.length ?? 0) : 0;
});
const queuedMessages = computed(() => {
const id = currentConversation.value?.id;
return id ? (convQueues.value[id] ?? []) : [];
});
function clearQueue() {
const id = currentConversation.value?.id;
if (id) convQueues.value[id] = [];
}
// Chat is usable when Ollama is up and model is at least installed (cold starts still work)
const chatReady = computed(
() => ollamaStatus.value === "available" && (modelStatus.value === "loaded" || modelStatus.value === "cold")
);
async function fetchConversations(limit = 50, offset = 0) {
loading.value = true;
try {
const data = await apiGet<{ conversations: Conversation[]; total: number }>(
`/api/chat/conversations?limit=${limit}&offset=${offset}`
);
conversations.value = data.conversations;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load conversations", "error");
throw e;
} finally {
loading.value = false;
}
}
async function createConversation(title = ""): Promise<Conversation> {
try {
const conv = await apiPost<Conversation>("/api/chat/conversations", {
title,
});
conversations.value.unshift(conv);
return conv;
} catch (e) {
useToastStore().show("Failed to create conversation", "error");
throw e;
}
}
async function fetchConversation(id: number) {
loading.value = true;
try {
currentConversation.value = await apiGet<ConversationDetail>(
`/api/chat/conversations/${id}`
);
} catch (e) {
useToastStore().show("Failed to load conversation", "error");
throw e;
} finally {
loading.value = false;
}
}
async function bulkDeleteConversations(ids: number[]) {
if (!ids.length) return;
await apiPost("/api/chat/conversations/bulk-delete", { ids });
const idSet = new Set(ids);
conversations.value = conversations.value.filter((c) => !idSet.has(c.id));
if (currentConversation.value && idSet.has(currentConversation.value.id)) {
currentConversation.value = null;
}
for (const id of ids) {
delete convStreams.value[id];
delete convQueues.value[id];
}
}
async function deleteConversation(id: number) {
try {
await apiDelete(`/api/chat/conversations/${id}`);
conversations.value = conversations.value.filter((c) => c.id !== id);
if (currentConversation.value?.id === id) {
currentConversation.value = null;
}
delete convStreams.value[id];
delete convQueues.value[id];
} catch (e) {
useToastStore().show("Failed to delete conversation", "error");
throw e;
}
}
async function updateTitle(id: number, title: string) {
try {
const updated = await apiPatch<Conversation>(
`/api/chat/conversations/${id}`,
{ title }
);
const idx = conversations.value.findIndex((c) => c.id === id);
if (idx !== -1) {
conversations.value[idx] = { ...conversations.value[idx], ...updated };
}
if (currentConversation.value?.id === id) {
currentConversation.value.title = updated.title;
}
} catch (e) {
useToastStore().show("Failed to update title", "error");
throw e;
}
}
async function sendMessage(
content: string,
contextNoteId?: number | null,
includeNoteIds?: number[],
think = false,
contextNoteTitle?: string,
excludeNoteIds?: number[],
ragProjectId?: number | null,
workspaceProjectId?: number | null,
) {
if (!currentConversation.value) return;
const convId = currentConversation.value.id;
const s = _getOrInitStream(convId);
// If already streaming, queue the message for after the current stream ends.
if (s.streaming) {
if (!convQueues.value[convId]) convQueues.value[convId] = [];
convQueues.value[convId].push({
content, contextNoteId, includeNoteIds, think, contextNoteTitle,
excludeNoteIds, ragProjectId, workspaceProjectId,
});
return;
}
s.contextMeta = null;
// If a write tool is waiting for confirmation, intercept single-word yes/no
// responses rather than sending them as a new message.
if (s.pendingTool) {
const lower = content.trim().toLowerCase();
const YES = new Set(["yes", "y", "ok", "sure", "proceed", "accept", "confirm", "do it", "go ahead", "yeah", "yep"]);
const NO = new Set(["no", "n", "cancel", "decline", "stop", "nope", "abort", "don't"]);
if (YES.has(lower)) {
await confirmTool(true);
return;
}
if (NO.has(lower)) {
await confirmTool(false);
return;
}
}
// Add optimistic user message
const userMsg: Message = {
id: -Date.now(), // Temporary ID
conversation_id: convId,
role: "user",
content,
context_note_id: contextNoteId ?? null,
context_note_title: contextNoteTitle ?? null,
created_at: new Date().toISOString(),
};
currentConversation.value.messages.push(userMsg);
s.streaming = true;
s.content = "";
// Phase 1: POST to start generation
let assistantMessageId: number;
try {
const resp = await apiPost<SendMessageResponse>(
`/api/chat/conversations/${convId}/messages`,
{
content,
context_note_id: contextNoteId,
include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined,
excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
think,
rag_project_id: ragProjectId ?? undefined,
workspace_project_id: workspaceProjectId ?? undefined,
},
);
assistantMessageId = resp.assistant_message_id;
} catch (e) {
s.streaming = false;
s.content = "";
useToastStore().show("Failed to send message", "error");
return;
}
// Immediately commit the message count so cleanup watcher never sees it as empty
const convInList = conversations.value.find((c) => c.id === convId);
if (convInList) convInList.message_count += 2;
// Phase 2: Connect to SSE stream with reconnection
await _streamGeneration(convId, assistantMessageId);
}
async function _streamGeneration(
convId: number,
assistantMessageId: number,
initialLastEventId = -1,
attempt = 0,
) {
const MAX_RETRIES = 5;
let lastEventId = initialLastEventId;
let gotDone = false;
const handle = apiSSEStream(
`/api/chat/conversations/${convId}/generation/stream` +
(lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""),
(event) => {
lastEventId = event.id;
const s = convStreams.value[convId];
if (!s) return;
switch (event.event) {
case "context":
s.contextMeta = event.data.context as ContextMeta;
break;
case "status":
s.status = event.data.status as string;
break;
case "thinking_chunk":
s.thinking += event.data.chunk as string;
break;
case "chunk":
s.content += event.data.chunk as string;
s.status = "";
break;
case "tool_pending":
s.pendingTool = event.data.tool_pending as ToolPendingRecord;
break;
case "tool_call":
// Receiving a tool_call clears the pending confirmation card
s.pendingTool = null;
s.toolCalls = [...s.toolCalls, event.data.tool_call as ToolCallRecord];
break;
case "done":
gotDone = true;
{
const assistantMsg: Message = {
id: event.data.message_id as number,
conversation_id: convId,
role: "assistant",
content: s.content,
context_note_id: null,
tool_calls: s.toolCalls.length ? [...s.toolCalls] : null,
created_at: new Date().toISOString(),
timing: event.data.timing as GenerationTiming | undefined,
thinking: s.thinking || undefined,
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
}
// Update updated_at only — message_count was already incremented at send time
const idx = conversations.value.findIndex((c) => c.id === convId);
if (idx !== -1) {
conversations.value[idx].updated_at = new Date().toISOString();
}
// Clear stream state
s.content = "";
s.thinking = "";
s.toolCalls = [];
s.status = "";
s.pendingTool = null;
s.streaming = false;
}
break;
case "error":
gotDone = true;
s.streaming = false;
s.content = "";
s.thinking = "";
s.toolCalls = [];
s.status = "";
s.pendingTool = null;
if (currentConversation.value?.id === convId) {
useToastStore().show(
"Chat error: " + (event.data.error as string),
"error",
);
}
break;
}
},
);
// Wait for the stream to close (events are processed via callback above)
await handle.done;
// If stream ended without done/error, attempt reconnection regardless of which
// conv is currently viewed — background streams retry independently
if (!gotDone && attempt < MAX_RETRIES) {
const delay = Math.min(1000 * 2 ** attempt, 10_000);
await new Promise((r) => setTimeout(r, delay));
return _streamGeneration(convId, assistantMessageId, lastEventId, attempt + 1);
}
// Recovery fallback: re-fetch conversation from DB only if currently viewing it
if (!gotDone && currentConversation.value?.id === convId) {
useToastStore().show(
"Connection lost — response may be incomplete",
"error",
);
try {
await fetchConversation(convId);
} catch {
// Re-fetch failed — partial content visible on next page load
}
}
// Final cleanup (guards against done/error not having fired)
const s = convStreams.value[convId];
if (s) {
s.streaming = false;
s.content = "";
s.thinking = "";
s.toolCalls = [];
s.status = "";
s.pendingTool = null;
}
// Process next queued message, if any.
// Use setTimeout so this frame resolves before the next send begins.
const queue = convQueues.value[convId];
if (queue?.length && currentConversation.value?.id === convId) {
const next = queue.shift()!;
setTimeout(() => sendMessage(
next.content,
next.contextNoteId,
next.includeNoteIds,
next.think,
next.contextNoteTitle,
next.excludeNoteIds,
next.ragProjectId,
next.workspaceProjectId,
), 0);
}
}
async function reconnectIfGenerating(convId: number): Promise<void> {
// Skip if a stream is already active for this conversation
if (isStreamingConv(convId)) return;
const conv = currentConversation.value;
if (!conv || conv.id !== convId) return;
// Find the last assistant message still in generating state
const lastMsg = [...conv.messages].reverse().find(
(m) => m.role === "assistant" && m.status === "generating"
);
if (!lastMsg) return;
// Remove the partial message — the done event will re-add the final version
conv.messages = conv.messages.filter((m) => m.id !== lastMsg.id);
const s = _getOrInitStream(convId);
s.streaming = true;
s.content = "";
s.thinking = "";
s.toolCalls = [];
s.status = "Reconnecting...";
s.pendingTool = null;
s.contextMeta = null;
// Reconnect from the beginning — the buffer replays all buffered events.
// If the buffer is gone (>60s stale), _streamGeneration will exhaust retries
// then fall back to fetchConversation to show the final saved content.
await _streamGeneration(convId, lastMsg.id);
}
async function confirmTool(confirmed: boolean) {
const convId = currentConversation.value?.id;
if (!convId) return;
// Optimistically clear so buttons disappear immediately
const s = convStreams.value[convId];
if (s) s.pendingTool = null;
try {
await apiPost(`/api/chat/conversations/${convId}/generation/confirm`, { confirmed });
} catch {
// Server will auto-decline on timeout — no action needed
}
}
async function cancelGeneration() {
if (!currentConversation.value) return;
try {
await apiPost(
`/api/chat/conversations/${currentConversation.value.id}/generation/cancel`,
{}
);
} catch {
// Generation may have already finished — ignore
}
}
async function saveMessageAsNote(messageId: number) {
return await apiPost<Record<string, unknown>>(
`/api/chat/messages/${messageId}/save-as-note`,
{}
);
}
async function summarizeAsNote(conversationId: number) {
return await apiPost<Record<string, unknown>>(
`/api/chat/conversations/${conversationId}/summarize`,
{}
);
}
async function fetchStatus() {
try {
const data = await apiGet<OllamaStatus>("/api/chat/status");
ollamaStatus.value = data.ollama;
modelStatus.value = data.model;
defaultModel.value = data.default_model;
} catch {
ollamaStatus.value = "unavailable";
modelStatus.value = "not_found";
}
}
function startStatusPolling() {
fetchStatus();
stopStatusPolling();
statusPollTimer = setInterval(fetchStatus, 30_000);
}
function stopStatusPolling() {
if (statusPollTimer !== null) {
clearInterval(statusPollTimer);
statusPollTimer = null;
}
}
async function warmModel(model: string) {
try {
await apiPost("/api/chat/warm", { model });
// Poll faster after a warm request so the indicator updates promptly
_pollUntilLoaded();
} catch {
// Warming is best-effort
}
}
function _pollUntilLoaded() {
let attempts = 0;
const MAX = 12; // up to ~60s of fast polling
const timer = setInterval(async () => {
try {
await fetchStatus();
} catch {
// best-effort polling
}
attempts++;
if (modelStatus.value === "loaded" || attempts >= MAX) {
clearInterval(timer);
}
}, 5_000);
}
return {
conversations,
currentConversation,
total,
loading,
streaming,
streamingContent,
streamingThinking,
streamingToolCalls,
streamingStatus,
streamingPendingTool,
lastContextMeta,
ollamaStatus,
modelStatus,
defaultModel,
chatReady,
isStreamingConv,
queuedCount,
queuedMessages,
clearQueue,
fetchConversations,
createConversation,
fetchConversation,
deleteConversation,
bulkDeleteConversations,
updateTitle,
sendMessage,
reconnectIfGenerating,
confirmTool,
cancelGeneration,
saveMessageAsNote,
summarizeAsNote,
warmModel,
fetchStatus,
startStatusPolling,
stopStatusPolling,
};
});