Per-conv streaming state, immediate message_count commit, auto-new-chat on open
stores/chat.ts: - Replace 7 global stream refs with convStreams: Record<number, ConvStreamState> - Each conversation tracks its own stream state; 7 names become computed getters - Add isStreamingConv(id) public helper - Increment message_count +2 immediately after POST 202 (not at done) so the cleanup watcher never deletes a mid-generation conversation - SSE reconnect retries now run for background conversations regardless of which conv is currently viewed - error toasts only shown when the erroring conv is currently viewed - deleteConversation cleans up orphaned stream state ChatView.vue: - Remove invalid write to store.lastContextMeta (now a read-only computed) - Add !store.isStreamingConv(newId) guard to watch(convId) fetch so navigating back to a generating conversation shows accumulated content without stale refetch - Add startNewConversation() helper; opening /chat with no ID auto-creates a new conversation and redirects to it (on mount and on navigation) - Deleting a conversation also auto-creates a new one Also committing previous-session changes (keyboard nav + task routing): - App.vue: e shortcut only on note-view (not task-view, which is removed) - TaskCard.vue: route directly to /tasks/:id (viewer), remove View buttons - router/index.ts: remove task-view route; /tasks/:id now maps to TaskEditorView - TasksListView.vue: Enter key pushes to /tasks/:id Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+99
-60
@@ -20,23 +20,53 @@ import type {
|
||||
ToolPendingRecord,
|
||||
} from "@/types/chat";
|
||||
|
||||
interface ConvStreamState {
|
||||
streaming: boolean;
|
||||
content: string;
|
||||
thinking: string;
|
||||
toolCalls: ToolCallRecord[];
|
||||
status: string;
|
||||
pendingTool: ToolPendingRecord | null;
|
||||
contextMeta: ContextMeta | 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 streaming = ref(false);
|
||||
const streamingContent = ref("");
|
||||
const streamingThinking = ref("");
|
||||
const streamingToolCalls = ref<ToolCallRecord[]>([]);
|
||||
const streamingStatus = ref("");
|
||||
const streamingPendingTool = ref<ToolPendingRecord | null>(null);
|
||||
const lastContextMeta = ref<ContextMeta | null>(null);
|
||||
const convStreams = ref<Record<number, ConvStreamState>>({});
|
||||
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;
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -92,6 +122,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value = null;
|
||||
}
|
||||
delete convStreams.value[id];
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete conversation", "error");
|
||||
throw e;
|
||||
@@ -128,11 +159,12 @@ export const useChatStore = defineStore("chat", () => {
|
||||
if (!currentConversation.value) return;
|
||||
|
||||
const convId = currentConversation.value.id;
|
||||
lastContextMeta.value = null;
|
||||
const s = _getOrInitStream(convId);
|
||||
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 (streamingPendingTool.value) {
|
||||
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"]);
|
||||
@@ -158,8 +190,8 @@ export const useChatStore = defineStore("chat", () => {
|
||||
};
|
||||
currentConversation.value.messages.push(userMsg);
|
||||
|
||||
streaming.value = true;
|
||||
streamingContent.value = "";
|
||||
s.streaming = true;
|
||||
s.content = "";
|
||||
|
||||
// Phase 1: POST to start generation
|
||||
let assistantMessageId: number;
|
||||
@@ -176,12 +208,16 @@ export const useChatStore = defineStore("chat", () => {
|
||||
);
|
||||
assistantMessageId = resp.assistant_message_id;
|
||||
} catch (e) {
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
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);
|
||||
}
|
||||
@@ -201,31 +237,30 @@ export const useChatStore = defineStore("chat", () => {
|
||||
(lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""),
|
||||
(event) => {
|
||||
lastEventId = event.id;
|
||||
const s = convStreams.value[convId];
|
||||
if (!s) return;
|
||||
|
||||
switch (event.event) {
|
||||
case "context":
|
||||
lastContextMeta.value = event.data.context as ContextMeta;
|
||||
s.contextMeta = event.data.context as ContextMeta;
|
||||
break;
|
||||
case "status":
|
||||
streamingStatus.value = event.data.status as string;
|
||||
s.status = event.data.status as string;
|
||||
break;
|
||||
case "thinking_chunk":
|
||||
streamingThinking.value += event.data.chunk as string;
|
||||
s.thinking += event.data.chunk as string;
|
||||
break;
|
||||
case "chunk":
|
||||
streamingContent.value += event.data.chunk as string;
|
||||
streamingStatus.value = "";
|
||||
s.content += event.data.chunk as string;
|
||||
s.status = "";
|
||||
break;
|
||||
case "tool_pending":
|
||||
streamingPendingTool.value = event.data.tool_pending as ToolPendingRecord;
|
||||
s.pendingTool = event.data.tool_pending as ToolPendingRecord;
|
||||
break;
|
||||
case "tool_call":
|
||||
// Receiving a tool_call clears the pending confirmation card
|
||||
streamingPendingTool.value = null;
|
||||
streamingToolCalls.value = [
|
||||
...streamingToolCalls.value,
|
||||
event.data.tool_call as ToolCallRecord,
|
||||
];
|
||||
s.pendingTool = null;
|
||||
s.toolCalls = [...s.toolCalls, event.data.tool_call as ToolCallRecord];
|
||||
break;
|
||||
case "done":
|
||||
gotDone = true;
|
||||
@@ -234,45 +269,44 @@ export const useChatStore = defineStore("chat", () => {
|
||||
id: event.data.message_id as number,
|
||||
conversation_id: convId,
|
||||
role: "assistant",
|
||||
content: streamingContent.value,
|
||||
content: s.content,
|
||||
context_note_id: null,
|
||||
tool_calls: streamingToolCalls.value.length
|
||||
? [...streamingToolCalls.value]
|
||||
: null,
|
||||
tool_calls: s.toolCalls.length ? [...s.toolCalls] : null,
|
||||
created_at: new Date().toISOString(),
|
||||
timing: event.data.timing as GenerationTiming | undefined,
|
||||
thinking: streamingThinking.value || undefined,
|
||||
thinking: s.thinking || undefined,
|
||||
};
|
||||
if (currentConversation.value?.id === convId) {
|
||||
currentConversation.value.messages.push(assistantMsg);
|
||||
}
|
||||
streamingContent.value = "";
|
||||
streamingThinking.value = "";
|
||||
streamingToolCalls.value = [];
|
||||
streamingStatus.value = "";
|
||||
streamingPendingTool.value = null;
|
||||
streaming.value = false;
|
||||
|
||||
// Update conversation in list
|
||||
// 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].message_count += 2;
|
||||
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;
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
streamingThinking.value = "";
|
||||
streamingToolCalls.value = [];
|
||||
streamingStatus.value = "";
|
||||
streamingPendingTool.value = null;
|
||||
useToastStore().show(
|
||||
"Chat error: " + (event.data.error as string),
|
||||
"error",
|
||||
);
|
||||
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;
|
||||
}
|
||||
},
|
||||
@@ -281,16 +315,15 @@ export const useChatStore = defineStore("chat", () => {
|
||||
// Wait for the stream to close (events are processed via callback above)
|
||||
await handle.done;
|
||||
|
||||
// If stream ended without done/error, attempt reconnection
|
||||
// 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));
|
||||
if (currentConversation.value?.id === convId) {
|
||||
return _streamGeneration(convId, assistantMessageId, lastEventId, attempt + 1);
|
||||
}
|
||||
return _streamGeneration(convId, assistantMessageId, lastEventId, attempt + 1);
|
||||
}
|
||||
|
||||
// Recovery fallback: re-fetch conversation from DB
|
||||
// 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",
|
||||
@@ -303,19 +336,24 @@ export const useChatStore = defineStore("chat", () => {
|
||||
}
|
||||
}
|
||||
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
streamingThinking.value = "";
|
||||
streamingToolCalls.value = [];
|
||||
streamingStatus.value = "";
|
||||
streamingPendingTool.value = null;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTool(confirmed: boolean) {
|
||||
const convId = currentConversation.value?.id;
|
||||
if (!convId) return;
|
||||
// Optimistically clear so buttons disappear immediately
|
||||
streamingPendingTool.value = null;
|
||||
const s = convStreams.value[convId];
|
||||
if (s) s.pendingTool = null;
|
||||
try {
|
||||
await apiPost(`/api/chat/conversations/${convId}/generation/confirm`, { confirmed });
|
||||
} catch {
|
||||
@@ -416,6 +454,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
modelStatus,
|
||||
defaultModel,
|
||||
chatReady,
|
||||
isStreamingConv,
|
||||
fetchConversations,
|
||||
createConversation,
|
||||
fetchConversation,
|
||||
|
||||
Reference in New Issue
Block a user