diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 9c451fd..8c945d7 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -75,6 +75,20 @@ function scrollLatest(): void { messages.scrollTop = messages.scrollHeight; } function setPresence(value: string): void { presence.textContent = value; } function addSystem(message: string): void { const item = document.createElement("p"); item.className = "system"; item.textContent = message; messages.append(item); scrollLatest(); } +function newLocalID(prefix: string): string { + // `crypto.randomUUID` is absent from older Safari / WKWebView versions. + // A local item id needs uniqueness within this browser store, not a + // transport identity, so the timestamp-and-random fallback is sufficient + // and must not prevent the form submit handler from reaching HTTP. + const uuid = globalThis.crypto?.randomUUID?.(); + return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`; +} + +function storageFailureMessage(reason: unknown): string { + const detail = reason instanceof Error && reason.message ? `(${reason.message})` : ""; + return `本地对话历史暂时无法保存${detail};消息仍会尝试发送,刷新后将从服务端重新同步。`; +} + function normalizeAPIAddress(value: string): string { const candidate = value.trim(); if (!candidate) throw new Error("请输入 AppServer 地址。"); @@ -278,24 +292,38 @@ $("#login-form").addEventListener("submit", async event => { $("#message-form").addEventListener("submit", async event => { event.preventDefault(); const input = $("#message-input"); const send = $("#send"); const text = input.value.trim(); - if (!text || !session.uid) return; + if (!text) return; + if (!session.uid) { + addSystem("当前会话尚未初始化,无法发送消息;请退出后重新登录。"); + input.focus(); + return; + } input.value = ""; send.disabled = true; - const localID = `local_${crypto.randomUUID()}`; + const localID = newLocalID("local"); const createdAt = new Date().toISOString(); - conversationStore.appendLocalText(localID, text, createdAt); - const row = appendBubble("human", text, false, "发送中"); const meta = row.querySelector(".meta"); + // A browser storage failure must never prevent the user from seeing or + // submitting their message. The Store remains the durable source when it + // is available; the server sync is the recovery path when it is not. + const row = appendBubble("human", text, false, "发送中"); + const meta = row.querySelector(".meta"); + let storedLocally = true; + try { conversationStore.appendLocalText(localID, text, createdAt); } + catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); } try { const sequence = await sendPayload(text); - if (sequence) { - session.lastSeq = Math.max(session.lastSeq, sequence); - conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); + if (sequence && storedLocally) { + try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); } + catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); } } - if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 已发送"`; + if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 已发送`; } catch (reason) { const message = reason instanceof Error ? reason.message : "发送失败"; - conversationStore.markFailed(localID, message, new Date().toISOString()); - if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败"`; + if (storedLocally) { + try { conversationStore.markFailed(localID, message, new Date().toISOString()); } + catch (storageReason) { addSystem(storageFailureMessage(storageReason)); } + } + if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败`; addSystem(message); } finally { send.disabled = false; input.focus(); } @@ -312,7 +340,7 @@ export function makeProtocolEvent( ): Envelope { return { v: 1, - id: `msg_${crypto.randomUUID()}`, + id: newLocalID("msg"), type, conversation_id: conversationID, sender, diff --git a/tauri/src/runtime/conversation-store.ts b/tauri/src/runtime/conversation-store.ts index 79eca90..cdce253 100644 --- a/tauri/src/runtime/conversation-store.ts +++ b/tauri/src/runtime/conversation-store.ts @@ -21,7 +21,7 @@ export type ConversationSnapshot = { }; type PersistedConversation = { - version: 1; + version: 3; cursor: number; items: StoredConversationItem[]; outbox: OutboxEntry[]; @@ -38,7 +38,7 @@ const STORE_PREFIX = "lineup.conversation.v1"; */ export class ConversationStore { private activeConversationID: string | undefined; - private state: PersistedConversation = { version: 1, cursor: 0, items: [], outbox: [] }; + private state: PersistedConversation = { version: 3, cursor: 0, items: [], outbox: [] }; public constructor(private readonly storage: Storage) {} @@ -50,7 +50,7 @@ export class ConversationStore { public close(): void { this.activeConversationID = undefined; - this.state = { version: 1, cursor: 0, items: [], outbox: [] }; + this.state = { version: 3, cursor: 0, items: [], outbox: [] }; } public snapshot(): ConversationSnapshot { @@ -85,7 +85,11 @@ export class ConversationStore { delivery: { status: "submitted", updated_at: updatedAt, transport_id: String(messageSeq) }, }; this.state.outbox = this.state.outbox.filter(entry => entry.local_id !== localID); - this.state.cursor = Math.max(this.state.cursor, messageSeq); + // A send acknowledgement is not a sync acknowledgement. The legacy + // `/messages/sync` cursor is inclusive, so advancing it here would make a + // refresh start at `messageSeq + 1` and permanently skip this user's echo. + // Only a message that has actually passed through the sync path advances + // the cursor (recordSyncedUserText / recordIncoming / advanceCursor). this.persist(); } @@ -153,14 +157,23 @@ export class ConversationStore { private load(conversationID: string): PersistedConversation { try { const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { version: 1, cursor: 0, items: [], outbox: [] }; - const candidate = parsed as Partial; - if (candidate.version !== 1 || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { - return { version: 1, cursor: 0, items: [], outbox: [] }; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { version: 3, cursor: 0, items: [], outbox: [] }; + const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown }; + if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { + return { version: 3, cursor: 0, items: [], outbox: [] }; } - return { version: 1, cursor: Math.max(0, candidate.cursor), items: candidate.items, outbox: candidate.outbox }; + + if (candidate.version !== 3) { + // v1 advanced its cursor from a send acknowledgement. Some browsers + // had already been upgraded to v2 before that correction, retaining a + // high cursor with holes for their own echoes. Preserve every valid + // local item but replay the server history once to repair those holes; + // the next persist upgrades this record to v3. + return { version: 3, cursor: 0, items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[] }; + } + return { version: 3, cursor: Math.max(0, candidate.cursor), items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[] }; } catch { - return { version: 1, cursor: 0, items: [], outbox: [] }; + return { version: 3, cursor: 0, items: [], outbox: [] }; } }