refactor: state 管理优化 - 结构化浅比较 + update/setIn/batch API
- state.js: 新增 shallowEqual、update()、setIn()、batch() 方法 - input-area.js: messages.push() 全部改为 state.update(),确保引用变化触发通知 - model-bar.js: 直接属性赋值改为 state.update(),修复 model 切换不触发变更检测
This commit is contained in:
+33
-12
@@ -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) + '<p><code>[已中断]</code></p>';
|
||||
await saveCurrentSession();
|
||||
} else {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+143
-15
@@ -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<callback>
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user