diff --git a/js/components/input-area.js b/js/components/input-area.js
index 96dd74d..77c1a10 100644
--- a/js/components/input-area.js
+++ b/js/components/input-area.js
@@ -345,15 +345,24 @@ export async function sendMessage() {
msgsToAdd.push(msg);
}
- // 更新会话标题
- if (currentSession.messages.length === 0) {
- const titleText = text
- || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]');
- currentSession.title = truncate(titleText, 30);
- currentSession.model = model;
- }
+ // 更新会话标题 + 追加消息(通过 update 确保引用变化,触发浅比较)
+ const isFirstMsg = currentSession.messages.length === 0;
+ state.update(KEYS.CURRENT_SESSION, session => ({
+ ...session,
+ ...(isFirstMsg && {
+ title: truncate(
+ text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'),
+ 30
+ ),
+ model
+ }),
+ messages: [...session.messages, ...msgsToAdd],
+ updatedAt: Date.now()
+ }));
+
+ // update() 产生新引用,后续必须从 state 读取最新值
+ const freshSession = state.get(KEYS.CURRENT_SESSION);
- msgsToAdd.forEach(m => currentSession.messages.push(m));
enableAutoScroll();
renderMessages();
@@ -391,7 +400,7 @@ export async function sendMessage() {
let modelName = '';
try {
- const apiMessages = buildApiMessages(currentSession.messages);
+ const apiMessages = buildApiMessages(freshSession.messages);
const chatParams = {
model,
@@ -502,7 +511,11 @@ ${ragContext}
...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }),
...(ragSources && { ragSources })
};
- currentSession.messages.push(assistantMsg);
+ state.update(KEYS.CURRENT_SESSION, session => ({
+ ...session,
+ messages: [...session.messages, assistantMsg],
+ updatedAt: Date.now()
+ }));
if (finalStats) {
updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || null);
@@ -549,7 +562,11 @@ ${ragContext}
...(thinkContent && { think: thinkContent }),
stopped: true
};
- currentSession.messages.push(partialMsg);
+ state.update(KEYS.CURRENT_SESSION, session => ({
+ ...session,
+ messages: [...session.messages, partialMsg],
+ updatedAt: Date.now()
+ }));
if (contentDiv) {
let html = '';
@@ -584,7 +601,11 @@ ${ragContext}
content: assistantContent + '\n\n`[已中断]`',
timestamp: Date.now(),
};
- currentSession.messages.push(partialMsg);
+ state.update(KEYS.CURRENT_SESSION, session => ({
+ ...session,
+ messages: [...session.messages, partialMsg],
+ updatedAt: Date.now()
+ }));
contentDiv.innerHTML = safeMarkdown(assistantContent) + '
[已中断]
';
await saveCurrentSession();
} else {
diff --git a/js/components/model-bar.js b/js/components/model-bar.js
index aba4375..df6c601 100644
--- a/js/components/model-bar.js
+++ b/js/components/model-bar.js
@@ -24,8 +24,7 @@ export function initModelBar() {
const model = modelSelectEl.value;
if (db()) await db().saveSetting('selectedModel', model);
state.set('_defaultModel', model);
- const session = state.get(KEYS.CURRENT_SESSION);
- if (session) session.model = model;
+ state.update(KEYS.CURRENT_SESSION, session => session ? ({ ...session, model }) : session);
if (model) {
await checkModelCapability(model);
} else {
diff --git a/js/state.js b/js/state.js
index 46b82c9..c2855a6 100644
--- a/js/state.js
+++ b/js/state.js
@@ -1,34 +1,166 @@
/**
* State - 轻量级响应式状态管理
- * 使用发布-订阅模式,状态变更时通知所有订阅者
+ *
+ * 优化点:
+ * 1. 结构化浅比较:对象按 key 数量 + 逐 key 严格比较,数组按长度 + 逐元素
+ * 2. update():函数式更新,自动比较
+ * 3. setIn():路径式嵌套更新,沿途浅拷贝确保引用变化
+ * 4. batch():批量更新,合并通知
*/
+// ── 结构化浅比较 ──
+function shallowEqual(a, b) {
+ if (a === b) return true;
+ if (a == null || b == null) return a === b;
+ if (typeof a !== 'object' || typeof b !== 'object') return false;
+
+ // 数组:长度 + 逐元素
+ if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+ }
+
+ // 混合类型
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+
+ // 对象:key 数量 + 逐 key
+ const keysA = Object.keys(a);
+ const keysB = Object.keys(b);
+ if (keysA.length !== keysB.length) return false;
+ for (const key of keysA) {
+ if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) return false;
+ }
+ return true;
+}
+
class AppState {
constructor() {
this._state = {};
- this._listeners = new Map(); // key → Set
+ this._listeners = new Map();
+ this._batching = false;
+ this._batchQueue = null;
}
/**
- * 设置状态值并通知订阅者
- * @param {string} key - 状态键名
- * @param {*} value - 状态值
+ * 设置状态值,使用结构化浅比较判断变更
*/
set(key, value) {
const old = this._state[key];
this._state[key] = value;
- if (old !== value && this._listeners.has(key)) {
- for (const cb of this._listeners.get(key)) {
- try { cb(value, old); } catch (e) { console.error(`[State] 监听器错误 (${key}):`, e); }
+
+ if (!shallowEqual(old, value)) {
+ if (this._batching) {
+ this._batchQueue.add(key);
+ } else {
+ this._notify(key, value, old);
+ }
+ }
+ }
+
+ /**
+ * 函数式更新:基于当前值计算新值,自动比较
+ *
+ * state.update('currentSession', session => ({
+ * ...session,
+ * messages: [...session.messages, newMsg],
+ * updatedAt: Date.now()
+ * }));
+ */
+ update(key, updater) {
+ const old = this._state[key];
+ const value = updater(old);
+ this._state[key] = value;
+
+ if (!shallowEqual(old, value)) {
+ if (this._batching) {
+ this._batchQueue.add(key);
+ } else {
+ this._notify(key, value, old);
+ }
+ }
+ }
+
+ /**
+ * 路径式嵌套更新:按 path 更新嵌套属性,沿途浅拷贝确保引用变化
+ *
+ * state.setIn('currentSession', ['messages', 0, 'content'], '新内容');
+ */
+ setIn(key, path, value) {
+ if (!path || path.length === 0) {
+ this.set(key, value);
+ return;
+ }
+
+ const old = this._state[key];
+ if (old == null || typeof old !== 'object') {
+ console.warn(`[State] setIn 失败: ${key} 不是对象`);
+ return;
+ }
+
+ const newRoot = Array.isArray(old) ? [...old] : { ...old };
+ let current = newRoot;
+
+ for (let i = 0; i < path.length - 1; i++) {
+ const segment = path[i];
+ const next = current[segment];
+ if (next == null || typeof next !== 'object') {
+ const isIndex = typeof path[i + 1] === 'number';
+ current[segment] = isIndex ? [] : {};
+ } else {
+ current[segment] = Array.isArray(next) ? [...next] : { ...next };
+ }
+ current = current[segment];
+ }
+
+ current[path[path.length - 1]] = value;
+
+ this._state[key] = newRoot;
+ if (!shallowEqual(old, newRoot)) {
+ if (this._batching) {
+ this._batchQueue.add(key);
+ } else {
+ this._notify(key, newRoot, old);
+ }
+ }
+ }
+
+ /**
+ * 批量更新:合并多次 set 为一次通知
+ *
+ * state.batch(() => {
+ * state.set('isStreaming', false);
+ * state.update('currentSession', s => ({ ...s, updatedAt: Date.now() }));
+ * });
+ */
+ batch(fn) {
+ this._batching = true;
+ this._batchQueue = new Set();
+ try {
+ fn();
+ } finally {
+ const keys = this._batchQueue;
+ this._batching = false;
+ this._batchQueue = null;
+ for (const k of keys) {
+ this._notify(k, this._state[k], undefined);
+ }
+ }
+ }
+
+ _notify(key, value, old) {
+ if (!this._listeners.has(key)) return;
+ for (const cb of this._listeners.get(key)) {
+ try { cb(value, old); } catch (e) {
+ console.error(`[State] 监听器错误 (${key}):`, e);
}
}
}
/**
* 获取状态值
- * @param {string} key - 状态键名
- * @param {*} [defaultValue] - 默认值
- * @returns {*}
*/
get(key, defaultValue = undefined) {
return key in this._state ? this._state[key] : defaultValue;
@@ -36,8 +168,6 @@ class AppState {
/**
* 订阅状态变更
- * @param {string} key - 状态键名
- * @param {function} callback - 回调函数 (newValue, oldValue) => void
* @returns {function} 取消订阅函数
*/
on(key, callback) {
@@ -50,7 +180,6 @@ class AppState {
/**
* 批量设置初始状态
- * @param {Object} initial - 初始状态对象
*/
init(initial) {
Object.assign(this._state, initial);
@@ -60,7 +189,6 @@ class AppState {
// 单例
export const state = new AppState();
-// ── 状态键名常量 ──
export const KEYS = {
DB: 'db',
API: 'api',