u
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - UI / Settings: Mutating actions (`upsert`, `remove`, `addImage`, `deleteImage`).
|
||||
* - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
@@ -50,11 +50,11 @@ const actions = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Upsert a brand (create or update by name).
|
||||
* Create a brand (insert). 409 Brand already exists on duplicate name.
|
||||
* @param {{ name: string, gsprpinfo?: string }} body
|
||||
*/
|
||||
async upsert(body) {
|
||||
const res = await brandApi.upsert(body);
|
||||
async create(body) {
|
||||
const res = await brandApi.create(body);
|
||||
const row = res.brand;
|
||||
const brands = sortBrands([
|
||||
...brandAutomat.state.brands.filter((b) => b.id !== row.id),
|
||||
@@ -64,6 +64,21 @@ const actions = {
|
||||
return row;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename a brand. Returns `{ id, name, gsprpinfo, touchedArticleCount }`.
|
||||
* @param {number} id
|
||||
* @param {{ name?: string, gsprpinfo?: string }} body
|
||||
*/
|
||||
async rename(id, body) {
|
||||
const res = await brandApi.rename(id, body);
|
||||
const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo };
|
||||
const brands = sortBrands(
|
||||
brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)),
|
||||
);
|
||||
brandAutomat.setState({ brands });
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a brand by id.
|
||||
* @param {number} id
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { Automat } from 'react-automat';
|
||||
import { modelconversation as modelconversationApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import { getSseClientId } from './sseAutomat';
|
||||
import {
|
||||
attachUserEnv,
|
||||
getCurrentRoute,
|
||||
@@ -110,6 +111,23 @@ let companyReconnectTimer = null;
|
||||
let conversationStream = null;
|
||||
let activeStreamConversationId = null;
|
||||
|
||||
/**
|
||||
* Close the company-wide SSE stream (/api/events). Called on logout so no
|
||||
* stale stream survives; reconnected on relogin via `ensureCompanyEventsConnected()`.
|
||||
*/
|
||||
function disconnectCompanyEvents() {
|
||||
if (companyReconnectTimer) {
|
||||
clearTimeout(companyReconnectTimer);
|
||||
companyReconnectTimer = null;
|
||||
}
|
||||
if (companyEventSource) {
|
||||
try {
|
||||
companyEventSource.close();
|
||||
} catch {}
|
||||
companyEventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to company-wide SSE stream (/api/events) to listen for 'modelconversation' events.
|
||||
*/
|
||||
@@ -128,7 +146,10 @@ function ensureCompanyEventsConnected() {
|
||||
companyReconnectTimer = null;
|
||||
}
|
||||
|
||||
const es = new EventSource('/api/events', { withCredentials: true });
|
||||
const url = new URL('/api/events', window.location.origin);
|
||||
const cid = getSseClientId();
|
||||
if (cid) url.searchParams.set('cid', cid);
|
||||
const es = new EventSource(url.href, { withCredentials: true });
|
||||
companyEventSource = es;
|
||||
|
||||
es.addEventListener('modelconversation', () => {
|
||||
@@ -1362,28 +1383,24 @@ const actions = {
|
||||
*/
|
||||
reset() {
|
||||
closeConversationStream();
|
||||
if (companyEventSource) {
|
||||
try {
|
||||
companyEventSource.close();
|
||||
} catch {}
|
||||
companyEventSource = null;
|
||||
}
|
||||
if (companyReconnectTimer) {
|
||||
clearTimeout(companyReconnectTimer);
|
||||
companyReconnectTimer = null;
|
||||
}
|
||||
disconnectCompanyEvents();
|
||||
chatAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
chatAutomat = new Automat(initialState, actions, { name: 'chat' });
|
||||
|
||||
// Listen to auth changes: reset on logout or company change
|
||||
// Listen to auth changes: disconnect on logout, reconnect on relogin/company change.
|
||||
let chatWasAuthed = false;
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
const isAuthed = auth.status === 'authed' && companyId != null;
|
||||
const relogin = isAuthed && !chatWasAuthed;
|
||||
chatWasAuthed = isAuthed;
|
||||
if (auth.status === 'anon' || !companyId) {
|
||||
disconnectCompanyEvents();
|
||||
chatAutomat.actions.reset();
|
||||
} else if (auth.status === 'authed' && companyId) {
|
||||
} else if (relogin || (auth.status === 'authed' && companyId)) {
|
||||
if (chatAutomat.state.sseEstablished) {
|
||||
ensureCompanyEventsConnected();
|
||||
void chatAutomat.actions.loadConversations(true);
|
||||
|
||||
@@ -37,15 +37,51 @@ import supplierAutomat from './supplierAutomat';
|
||||
* @typedef {Object} SseState
|
||||
* @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state.
|
||||
* @property {string|null} companyId Company the stream is bound to.
|
||||
* @property {string|null} clientId Tab-scoped random session id sent as `?cid=` to `/api/events`.
|
||||
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
|
||||
*/
|
||||
|
||||
const EVENT_HISTORY_LIMIT = 200;
|
||||
|
||||
const SSE_CLIENT_ID_KEY = 'sse.cid';
|
||||
|
||||
function createClientId() {
|
||||
try {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
} catch {}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tab-scoped SSE client id, creating and persisting it on first use.
|
||||
* Created when the automat module is evaluated so every `EventSource` in this
|
||||
* tab shares the same `cid`.
|
||||
*
|
||||
* @returns {string} Client id.
|
||||
*/
|
||||
export function getSseClientId() {
|
||||
try {
|
||||
const existing = window.sessionStorage.getItem(SSE_CLIENT_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const id = createClientId();
|
||||
window.sessionStorage.setItem(SSE_CLIENT_ID_KEY, id);
|
||||
return id;
|
||||
} catch {
|
||||
if (!getSseClientId.fallback) getSseClientId.fallback = createClientId();
|
||||
return getSseClientId.fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// Created once per tab at automat creation time and stored to sessionStorage.
|
||||
const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null;
|
||||
|
||||
/** @type {SseState} */
|
||||
const initialState = {
|
||||
status: 'disconnected',
|
||||
companyId: null,
|
||||
clientId: sseClientId,
|
||||
events: [],
|
||||
};
|
||||
|
||||
@@ -97,10 +133,16 @@ function addTrackedListener(es, type, handler) {
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (!eventSource) return;
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
sseAutomat.setState({ status: 'disconnected' });
|
||||
if (eventSource) {
|
||||
console.log('[sse] disconnect');
|
||||
try {
|
||||
eventSource.close();
|
||||
} catch {}
|
||||
eventSource = null;
|
||||
}
|
||||
if (sseAutomat.state.status !== 'disconnected') {
|
||||
sseAutomat.setState({ status: 'disconnected' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,10 +189,12 @@ function connect() {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL('/api/events', window.location.origin).href;
|
||||
const url = new URL('/api/events', window.location.origin);
|
||||
if (sseClientId) url.searchParams.set('cid', sseClientId);
|
||||
console.log(`[sse] connecting`);
|
||||
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
||||
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
const es = new EventSource(url.href, { withCredentials: true });
|
||||
|
||||
// Category tree changed: refresh the tree and every open category view.
|
||||
addTrackedListener(es, 'category', () => {
|
||||
@@ -314,12 +358,19 @@ export function invalidateArticle(sku) {
|
||||
}
|
||||
|
||||
// Reconnect when the selected company changes; disconnect on logout.
|
||||
// On logout the stream must close; on relogin (anon -> authed with a company)
|
||||
// the stream is re-established even if the company id matches the previous one.
|
||||
let wasAuthed = false;
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId =
|
||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
if (companyId === sseAutomat.state.companyId) return;
|
||||
const isAuthed = companyId != null;
|
||||
const relogin = isAuthed && !wasAuthed;
|
||||
wasAuthed = isAuthed;
|
||||
|
||||
if (!relogin && companyId === sseAutomat.state.companyId) return;
|
||||
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
@@ -327,7 +378,7 @@ authAutomat.subscribe((auth) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// A different company means a different stream scope.
|
||||
// A different company (or a fresh login) means a different stream scope.
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId });
|
||||
connect();
|
||||
|
||||
Reference in New Issue
Block a user