Berkkirik commited on
Commit
e6ed1f4
Β·
1 Parent(s): 91516de

feat: persist chat history to backend (debounced PUT, hydrate on mount)

Browse files
components/chat/AppShell.tsx CHANGED
@@ -4,24 +4,32 @@ import { Sidebar } from "./Sidebar";
4
  import { TopBar } from "./TopBar";
5
  import { Aurora } from "./Aurora";
6
  import { useChatStore } from "@/lib/chatStore";
 
7
  import clsx from "clsx";
8
  import { usePathname } from "next/navigation";
9
  import { useEffect } from "react";
10
 
11
  export function AppShell({ children }: { children: React.ReactNode }) {
12
  const sidebarOpen = useChatStore((s) => s.sidebarOpen);
 
13
  const pathname = usePathname();
14
  const conversations = useChatStore((s) => s.conversations);
15
  const activeId = useChatStore((s) => s.activeId);
16
  const newConversation = useChatStore((s) => s.newConversation);
17
 
18
- // Ensure there is always at least one conversation when on chat route
 
 
 
 
 
19
  useEffect(() => {
20
  if (pathname !== "/") return;
 
21
  const hasAny = Object.keys(conversations).length > 0;
22
  const hasActive = activeId && conversations[activeId];
23
  if (!hasAny || !hasActive) newConversation();
24
- }, [pathname, conversations, activeId, newConversation]);
25
 
26
  return (
27
  <div className="relative min-h-screen flex flex-col">
 
4
  import { TopBar } from "./TopBar";
5
  import { Aurora } from "./Aurora";
6
  import { useChatStore } from "@/lib/chatStore";
7
+ import { useBackendSync } from "@/lib/useBackendSync";
8
  import clsx from "clsx";
9
  import { usePathname } from "next/navigation";
10
  import { useEffect } from "react";
11
 
12
  export function AppShell({ children }: { children: React.ReactNode }) {
13
  const sidebarOpen = useChatStore((s) => s.sidebarOpen);
14
+ const hydrated = useChatStore((s) => s.hydrated);
15
  const pathname = usePathname();
16
  const conversations = useChatStore((s) => s.conversations);
17
  const activeId = useChatStore((s) => s.activeId);
18
  const newConversation = useChatStore((s) => s.newConversation);
19
 
20
+ // Backend hydration + auto-save (debounced PUT to /conversations)
21
+ useBackendSync();
22
+
23
+ // Ensure there is always at least one conversation when on chat route.
24
+ // Wait until hydration completes so we don't create a placeholder before
25
+ // the remote state arrives (which would cause a flash + redundant save).
26
  useEffect(() => {
27
  if (pathname !== "/") return;
28
+ if (!hydrated) return;
29
  const hasAny = Object.keys(conversations).length > 0;
30
  const hasActive = activeId && conversations[activeId];
31
  if (!hasAny || !hasActive) newConversation();
32
+ }, [pathname, hydrated, conversations, activeId, newConversation]);
33
 
34
  return (
35
  <div className="relative min-h-screen flex flex-col">
components/chat/TopBar.tsx CHANGED
@@ -10,6 +10,8 @@ export function TopBar() {
10
  const [pulse, setPulse] = useState<"alive" | "down" | "loading">("loading");
11
  const [pingMs, setPingMs] = useState<number | null>(null);
12
  const toggleSidebar = useChatStore((s) => s.toggleSidebar);
 
 
13
 
14
  useEffect(() => {
15
  let alive = true;
@@ -70,8 +72,9 @@ export function TopBar() {
70
  </Link>
71
  </div>
72
 
73
- {/* Right: status pulse */}
74
- <div className="flex items-center gap-3">
 
75
  <span
76
  className="hidden sm:flex items-center gap-2 rounded-pill px-3 py-1.5 bg-glass"
77
  style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.08)" }}
@@ -101,6 +104,49 @@ export function TopBar() {
101
  );
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  function Mark() {
105
  return (
106
  <span
 
10
  const [pulse, setPulse] = useState<"alive" | "down" | "loading">("loading");
11
  const [pingMs, setPingMs] = useState<number | null>(null);
12
  const toggleSidebar = useChatStore((s) => s.toggleSidebar);
13
+ const syncStatus = useChatStore((s) => s.syncStatus);
14
+ const hydrated = useChatStore((s) => s.hydrated);
15
 
16
  useEffect(() => {
17
  let alive = true;
 
72
  </Link>
73
  </div>
74
 
75
+ {/* Right: sync + status pulse */}
76
+ <div className="flex items-center gap-2">
77
+ <SyncBadge status={syncStatus} hydrated={hydrated} />
78
  <span
79
  className="hidden sm:flex items-center gap-2 rounded-pill px-3 py-1.5 bg-glass"
80
  style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.08)" }}
 
104
  );
105
  }
106
 
107
+ function SyncBadge({
108
+ status,
109
+ hydrated,
110
+ }: {
111
+ status: "idle" | "saving" | "error";
112
+ hydrated: boolean;
113
+ }) {
114
+ // Hide once steady-state idle to keep the chrome quiet β€” show only when
115
+ // there's something to communicate (loading, saving, error).
116
+ const visible = !hydrated || status === "saving" || status === "error";
117
+ if (!visible) return null;
118
+
119
+ const label =
120
+ !hydrated ? "Loading"
121
+ : status === "saving" ? "Saving"
122
+ : "Sync error";
123
+ const tone =
124
+ status === "error" ? "text-status-err" : "text-ink-50";
125
+ return (
126
+ <span
127
+ className={clsx(
128
+ "hidden sm:inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-micro font-mono uppercase tracking-[0.14em] transition-opacity",
129
+ tone
130
+ )}
131
+ title={
132
+ status === "error"
133
+ ? "Couldn't save chat history to backend"
134
+ : status === "saving"
135
+ ? "Saving chat history…"
136
+ : "Loading chat history…"
137
+ }
138
+ >
139
+ <span
140
+ className={clsx(
141
+ "inline-block w-1 h-1 rounded-full",
142
+ status === "error" ? "bg-status-err" : "bg-amber animate-pulse-soft"
143
+ )}
144
+ />
145
+ {label}
146
+ </span>
147
+ );
148
+ }
149
+
150
  function Mark() {
151
  return (
152
  <span
lib/chatStore.ts CHANGED
@@ -42,6 +42,11 @@ type ChatStore = {
42
  settings: Settings;
43
  sidebarOpen: boolean;
44
 
 
 
 
 
 
45
  // selectors
46
  active: () => Conversation | null;
47
  list: () => Conversation[];
@@ -59,6 +64,14 @@ type ChatStore = {
59
  resetSettings: () => void;
60
  toggleSidebar: () => void;
61
  setSidebarOpen: (open: boolean) => void;
 
 
 
 
 
 
 
 
62
  };
63
 
64
  const newId = () =>
@@ -81,6 +94,9 @@ export const useChatStore = create<ChatStore>()(
81
  activeId: null,
82
  settings: DEFAULT_SETTINGS,
83
  sidebarOpen: true,
 
 
 
84
 
85
  active: () => {
86
  const { activeId, conversations } = get();
@@ -185,6 +201,53 @@ export const useChatStore = create<ChatStore>()(
185
  resetSettings: () => set({ settings: DEFAULT_SETTINGS }),
186
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
187
  setSidebarOpen: (open) => set({ sidebarOpen: open }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  }),
189
  {
190
  name: "etiya-d2l-chat",
 
42
  settings: Settings;
43
  sidebarOpen: boolean;
44
 
45
+ // sync state
46
+ hydrated: boolean;
47
+ syncStatus: "idle" | "saving" | "error";
48
+ lastSavedAt: number;
49
+
50
  // selectors
51
  active: () => Conversation | null;
52
  list: () => Conversation[];
 
64
  resetSettings: () => void;
65
  toggleSidebar: () => void;
66
  setSidebarOpen: (open: boolean) => void;
67
+
68
+ // sync helpers (used by useBackendSync hook)
69
+ _setHydrated: (v: boolean) => void;
70
+ _setSyncStatus: (v: "idle" | "saving" | "error") => void;
71
+ _hydrateFromRemote: (payload: {
72
+ conversations: Record<string, Conversation>;
73
+ activeId: string | null;
74
+ }) => void;
75
  };
76
 
77
  const newId = () =>
 
94
  activeId: null,
95
  settings: DEFAULT_SETTINGS,
96
  sidebarOpen: true,
97
+ hydrated: false,
98
+ syncStatus: "idle",
99
+ lastSavedAt: 0,
100
 
101
  active: () => {
102
  const { activeId, conversations } = get();
 
201
  resetSettings: () => set({ settings: DEFAULT_SETTINGS }),
202
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
203
  setSidebarOpen: (open) => set({ sidebarOpen: open }),
204
+
205
+ // ── sync helpers ─────────────────────────────────────────────
206
+ _setHydrated: (v) => set({ hydrated: v }),
207
+ _setSyncStatus: (v) => set({ syncStatus: v }),
208
+ _hydrateFromRemote: ({ conversations, activeId }) =>
209
+ set((s) => {
210
+ // Merge remote into local: remote wins per-id (server is the
211
+ // source of truth on hydrate). If remote is empty but local has
212
+ // pending in-flight turns, keep local β€” avoids losing a turn
213
+ // that's mid-flight when hydration races with the request.
214
+ const remoteIds = Object.keys(conversations);
215
+ if (remoteIds.length === 0) {
216
+ // Server is empty β€” keep local; the next save will populate it.
217
+ return { hydrated: true };
218
+ }
219
+ // Preserve any locally-pending turns that aren't yet on server.
220
+ const merged: Record<string, Conversation> = { ...conversations };
221
+ for (const [id, local] of Object.entries(s.conversations)) {
222
+ if (!merged[id]) {
223
+ merged[id] = local;
224
+ continue;
225
+ }
226
+ const localPending = local.turns.filter((t) => t.pending);
227
+ if (localPending.length === 0) continue;
228
+ const serverTurnIds = new Set(merged[id].turns.map((t) => t.id));
229
+ const extras = localPending.filter(
230
+ (t) => !serverTurnIds.has(t.id)
231
+ );
232
+ if (extras.length > 0) {
233
+ merged[id] = {
234
+ ...merged[id],
235
+ turns: [...merged[id].turns, ...extras],
236
+ };
237
+ }
238
+ }
239
+ const nextActive =
240
+ activeId && merged[activeId]
241
+ ? activeId
242
+ : s.activeId && merged[s.activeId]
243
+ ? s.activeId
244
+ : Object.keys(merged)[0] ?? null;
245
+ return {
246
+ conversations: merged,
247
+ activeId: nextActive,
248
+ hydrated: true,
249
+ };
250
+ }),
251
  }),
252
  {
253
  name: "etiya-d2l-chat",
lib/sync.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Backend persistence for chat history.
3
+ *
4
+ * Single shared store at /data/conversations.json on the backend Space.
5
+ * Frontend hydrates on mount and writes back on changes (debounced).
6
+ * Last-write-wins; no auth beyond the Space-level Bearer.
7
+ */
8
+ import type { Conversation } from "./chatStore";
9
+
10
+ const PROXY = "/api/proxy/conversations";
11
+
12
+ export type RemoteConversationsPayload = {
13
+ conversations: Record<string, Conversation>;
14
+ activeId: string | null;
15
+ updatedAt?: number;
16
+ version?: number;
17
+ };
18
+
19
+ export async function loadConversations(): Promise<RemoteConversationsPayload | null> {
20
+ try {
21
+ const res = await fetch(PROXY, {
22
+ method: "GET",
23
+ headers: { "Content-Type": "application/json" },
24
+ });
25
+ if (!res.ok) {
26
+ console.warn("[sync] load failed", res.status);
27
+ return null;
28
+ }
29
+ const data = (await res.json()) as RemoteConversationsPayload;
30
+ return data;
31
+ } catch (err) {
32
+ console.warn("[sync] load error", err);
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export async function saveConversations(
38
+ payload: RemoteConversationsPayload,
39
+ signal?: AbortSignal
40
+ ): Promise<boolean> {
41
+ try {
42
+ const res = await fetch(PROXY, {
43
+ method: "PUT",
44
+ headers: { "Content-Type": "application/json" },
45
+ body: JSON.stringify(payload),
46
+ signal,
47
+ });
48
+ if (!res.ok) {
49
+ console.warn("[sync] save failed", res.status);
50
+ return false;
51
+ }
52
+ return true;
53
+ } catch (err) {
54
+ if ((err as { name?: string })?.name === "AbortError") return false;
55
+ console.warn("[sync] save error", err);
56
+ return false;
57
+ }
58
+ }
lib/useBackendSync.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /**
4
+ * Drives hydration of the chat store from the backend on mount and
5
+ * pushes changes back up via debounced PUT.
6
+ *
7
+ * Behavior:
8
+ * - On first mount: GET /conversations β†’ merge into store β†’ mark hydrated.
9
+ * - On any change after hydration: schedule a PUT (debounced 1500ms).
10
+ * - In-flight saves are aborted by the next save to avoid stale writes.
11
+ * - Pending turns are excluded from the saved snapshot β€” the assistant
12
+ * response lands as a normal change after pending=false flips.
13
+ */
14
+ import { useEffect, useRef } from "react";
15
+ import { useChatStore } from "./chatStore";
16
+ import { loadConversations, saveConversations } from "./sync";
17
+
18
+ const SAVE_DEBOUNCE_MS = 1500;
19
+
20
+ export function useBackendSync() {
21
+ const hydrated = useChatStore((s) => s.hydrated);
22
+ const conversations = useChatStore((s) => s.conversations);
23
+ const activeId = useChatStore((s) => s.activeId);
24
+
25
+ const initialFetchStarted = useRef(false);
26
+ const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
27
+ const inFlight = useRef<AbortController | null>(null);
28
+
29
+ // ── 1) Hydrate on mount ───────────────────────────────────────────
30
+ useEffect(() => {
31
+ if (initialFetchStarted.current) return;
32
+ initialFetchStarted.current = true;
33
+
34
+ let cancelled = false;
35
+ (async () => {
36
+ const remote = await loadConversations();
37
+ if (cancelled) return;
38
+ const store = useChatStore.getState();
39
+ if (remote && remote.conversations) {
40
+ store._hydrateFromRemote({
41
+ conversations: remote.conversations,
42
+ activeId: remote.activeId ?? null,
43
+ });
44
+ } else {
45
+ // No remote / fetch failed β€” proceed with whatever localStorage had.
46
+ store._setHydrated(true);
47
+ }
48
+ })();
49
+
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ }, []);
54
+
55
+ // ── 2) Debounced save on change (only after hydrated) ─────────────
56
+ useEffect(() => {
57
+ if (!hydrated) return;
58
+
59
+ if (saveTimer.current) clearTimeout(saveTimer.current);
60
+ saveTimer.current = setTimeout(() => {
61
+ // Abort any in-flight save before starting a new one
62
+ if (inFlight.current) inFlight.current.abort();
63
+ const ctrl = new AbortController();
64
+ inFlight.current = ctrl;
65
+
66
+ // Strip in-flight pending turns from the saved snapshot β€” those
67
+ // resolve when the request returns and trigger their own save.
68
+ const sanitized: Record<string, typeof conversations[string]> = {};
69
+ for (const [id, conv] of Object.entries(conversations)) {
70
+ sanitized[id] = {
71
+ ...conv,
72
+ turns: conv.turns.filter((t) => !t.pending),
73
+ };
74
+ }
75
+
76
+ const setStatus = useChatStore.getState()._setSyncStatus;
77
+ setStatus("saving");
78
+ saveConversations(
79
+ {
80
+ conversations: sanitized,
81
+ activeId,
82
+ },
83
+ ctrl.signal
84
+ ).then((ok) => {
85
+ if (ctrl.signal.aborted) return;
86
+ setStatus(ok ? "idle" : "error");
87
+ if (ok) {
88
+ useChatStore.setState({ lastSavedAt: Date.now() });
89
+ }
90
+ });
91
+ }, SAVE_DEBOUNCE_MS);
92
+
93
+ return () => {
94
+ if (saveTimer.current) clearTimeout(saveTimer.current);
95
+ };
96
+ }, [hydrated, conversations, activeId]);
97
+ }