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);
|
msgsToAdd.push(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新会话标题
|
// 更新会话标题 + 追加消息(通过 update 确保引用变化,触发浅比较)
|
||||||
if (currentSession.messages.length === 0) {
|
const isFirstMsg = currentSession.messages.length === 0;
|
||||||
const titleText = text
|
state.update(KEYS.CURRENT_SESSION, session => ({
|
||||||
|| (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]');
|
...session,
|
||||||
currentSession.title = truncate(titleText, 30);
|
...(isFirstMsg && {
|
||||||
currentSession.model = model;
|
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();
|
enableAutoScroll();
|
||||||
renderMessages();
|
renderMessages();
|
||||||
|
|
||||||
@@ -391,7 +400,7 @@ export async function sendMessage() {
|
|||||||
let modelName = '';
|
let modelName = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiMessages = buildApiMessages(currentSession.messages);
|
const apiMessages = buildApiMessages(freshSession.messages);
|
||||||
|
|
||||||
const chatParams = {
|
const chatParams = {
|
||||||
model,
|
model,
|
||||||
@@ -502,7 +511,11 @@ ${ragContext}
|
|||||||
...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }),
|
...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }),
|
||||||
...(ragSources && { ragSources })
|
...(ragSources && { ragSources })
|
||||||
};
|
};
|
||||||
currentSession.messages.push(assistantMsg);
|
state.update(KEYS.CURRENT_SESSION, session => ({
|
||||||
|
...session,
|
||||||
|
messages: [...session.messages, assistantMsg],
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}));
|
||||||
|
|
||||||
if (finalStats) {
|
if (finalStats) {
|
||||||
updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || null);
|
updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || null);
|
||||||
@@ -549,7 +562,11 @@ ${ragContext}
|
|||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
stopped: true
|
stopped: true
|
||||||
};
|
};
|
||||||
currentSession.messages.push(partialMsg);
|
state.update(KEYS.CURRENT_SESSION, session => ({
|
||||||
|
...session,
|
||||||
|
messages: [...session.messages, partialMsg],
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}));
|
||||||
|
|
||||||
if (contentDiv) {
|
if (contentDiv) {
|
||||||
let html = '';
|
let html = '';
|
||||||
@@ -584,7 +601,11 @@ ${ragContext}
|
|||||||
content: assistantContent + '\n\n`[已中断]`',
|
content: assistantContent + '\n\n`[已中断]`',
|
||||||
timestamp: Date.now(),
|
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>';
|
contentDiv.innerHTML = safeMarkdown(assistantContent) + '<p><code>[已中断]</code></p>';
|
||||||
await saveCurrentSession();
|
await saveCurrentSession();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ export function initModelBar() {
|
|||||||
const model = modelSelectEl.value;
|
const model = modelSelectEl.value;
|
||||||
if (db()) await db().saveSetting('selectedModel', model);
|
if (db()) await db().saveSetting('selectedModel', model);
|
||||||
state.set('_defaultModel', model);
|
state.set('_defaultModel', model);
|
||||||
const session = state.get(KEYS.CURRENT_SESSION);
|
state.update(KEYS.CURRENT_SESSION, session => session ? ({ ...session, model }) : session);
|
||||||
if (session) session.model = model;
|
|
||||||
if (model) {
|
if (model) {
|
||||||
await checkModelCapability(model);
|
await checkModelCapability(model);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+143
-15
@@ -1,34 +1,166 @@
|
|||||||
/**
|
/**
|
||||||
* State - 轻量级响应式状态管理
|
* 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 {
|
class AppState {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._state = {};
|
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) {
|
set(key, value) {
|
||||||
const old = this._state[key];
|
const old = this._state[key];
|
||||||
this._state[key] = value;
|
this._state[key] = value;
|
||||||
if (old !== value && this._listeners.has(key)) {
|
|
||||||
for (const cb of this._listeners.get(key)) {
|
if (!shallowEqual(old, value)) {
|
||||||
try { cb(value, old); } catch (e) { console.error(`[State] 监听器错误 (${key}):`, e); }
|
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) {
|
get(key, defaultValue = undefined) {
|
||||||
return key in this._state ? this._state[key] : defaultValue;
|
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} 取消订阅函数
|
* @returns {function} 取消订阅函数
|
||||||
*/
|
*/
|
||||||
on(key, callback) {
|
on(key, callback) {
|
||||||
@@ -50,7 +180,6 @@ class AppState {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量设置初始状态
|
* 批量设置初始状态
|
||||||
* @param {Object} initial - 初始状态对象
|
|
||||||
*/
|
*/
|
||||||
init(initial) {
|
init(initial) {
|
||||||
Object.assign(this._state, initial);
|
Object.assign(this._state, initial);
|
||||||
@@ -60,7 +189,6 @@ class AppState {
|
|||||||
// 单例
|
// 单例
|
||||||
export const state = new AppState();
|
export const state = new AppState();
|
||||||
|
|
||||||
// ── 状态键名常量 ──
|
|
||||||
export const KEYS = {
|
export const KEYS = {
|
||||||
DB: 'db',
|
DB: 'db',
|
||||||
API: 'api',
|
API: 'api',
|
||||||
|
|||||||
Reference in New Issue
Block a user