feat: v0.5.4 多模态增强 — 多轮图片记忆 + 多模态总开关 + DeepSeek vision 模型支持
多轮图片记忆:
- 此前历史轮次的图片不回传 LLM(attachments 仅存压缩 preview,历史组装
时被丢弃)— 跨轮对话中模型对图片内容"失忆"
- 修复:SessionSummaryService.buildHistoryMessages 从持久化的 attachments
恢复 images(type=image 的 preview base64),历史图片随上下文回传
- Token 控制:最多注入最近 10 张(MAX_HISTORY_IMAGES,从最新消息向前
收集)— 每张 1024px 压缩图约数百至千余 token,无上限会吃满上下文
- 摘要区间(summarizedUntilRowid 之前)的图片不恢复,符合滚动摘要语义
多模态总开关 llm.multimodalEnabled(默认关闭):
- 新配置项:CONFIG_DEFAULTS 种子 + 设置弹框 LLM 配置 Switch +
首次引导向导 LLM 步骤 Switch(含说明文案)
- 上传入口双重判断:总开关 × 模型能力 — 未开启时即使模型支持多模态
也不能上传图片(ChatInput 的选择/拖拽/粘贴统一拦截,Toast 区分
"开关未开启"与"当前模型不支持"两种原因)
- 保存成功后同步 Agent Store 立即生效;App 启动时随 setProvider 加载
DeepSeek vision 模型支持:
- 新增 deepseek-v4-flash-vision-exp(OpenAI image_url content parts 格式,
128K 上下文 / 8K 输出)
- adapter 按 isVisionModel() 判断:vision 模型将带 images 的消息转换为
[{type:'text'},{type:'image_url'}] parts;非 vision 模型保持 images
静默丢弃(防 API 400)
测试(236 → 243 用例):
- 多轮图片记忆 ×3(session-summary.test.ts):历史 attachments 恢复
images / 上限 10 张从最新向前 / 摘要区间图片不恢复
- DeepSeek vision 请求格式 ×4(deepseek-vision.test.ts,契约级 mock
fetch 断言请求体):image_url parts 转换 / 非 vision 模型丢弃 /
max_tokens 钳制 8192 / 无图不转换
- 测试顺序修正:多轮图片用例置于 describe 末尾(插入新行消耗全局自增
rowid,插在中间会破坏既有用例对 rowid 数值的断言)
文档: README 同步(DeepSeek 模型表 + vision 多模态列、llm.multimodalEnabled
配置项、多轮图片记忆特性行、243 用例数)
验证: lint 0 / typecheck 双工程 0 / test:electron 243 全过 / build 成功
This commit is contained in:
+241
-116
@@ -39,8 +39,8 @@ export interface AttachmentInfo {
|
||||
name: string;
|
||||
type: 'image' | 'text' | 'other';
|
||||
size: number;
|
||||
preview?: string; // 图片 base64 data URL
|
||||
textContent?: string; // 文本文件内容
|
||||
preview?: string; // 图片 base64 data URL
|
||||
textContent?: string; // 文本文件内容
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -133,13 +133,19 @@ interface AgentState {
|
||||
provider: string;
|
||||
model: string;
|
||||
contextWindow: number;
|
||||
// v0.5.4: 多模态总开关(llm.multimodalEnabled)— 未开启时禁止上传图片
|
||||
multimodalEnabled: boolean;
|
||||
|
||||
// Actions
|
||||
setCurrentSession: (id: string | null) => void;
|
||||
setMessages: (messages: ChatMessage[]) => void;
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
updateMessage: (id: string, updates: Partial<ChatMessage>) => void;
|
||||
sendMessage: (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => void;
|
||||
sendMessage: (
|
||||
content: string,
|
||||
images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>,
|
||||
attachments?: AttachmentInfo[],
|
||||
) => void;
|
||||
updateLastAssistantMessage: (delta: string) => void;
|
||||
setAgentStatus: (status: AgentStatus) => void;
|
||||
setStreaming: (streaming: boolean) => void;
|
||||
@@ -159,6 +165,8 @@ interface AgentState {
|
||||
*/
|
||||
applyCompression: (savedTokens: number) => void;
|
||||
setProvider: (provider: string, model: string) => void;
|
||||
// v0.5.4: 多模态开关 setter(App 初始化与设置保存时同步)
|
||||
setMultimodalEnabled: (enabled: boolean) => void;
|
||||
setMaxIterations: (max: number) => void;
|
||||
setCurrentIteration: (n: number) => void;
|
||||
saveTraceData: () => void;
|
||||
@@ -183,7 +191,13 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
maxIterations: 20,
|
||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
traceSteps: [],
|
||||
isStreaming: false,
|
||||
configLoaded: false,
|
||||
@@ -192,104 +206,158 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
provider: '',
|
||||
model: '',
|
||||
contextWindow: 0,
|
||||
// v0.5.4: 多模态总开关(默认关闭,setProvider 时从配置加载)
|
||||
multimodalEnabled: false,
|
||||
|
||||
// ===== Actions =====
|
||||
|
||||
setCurrentSession: (id) => {
|
||||
set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null });
|
||||
set({
|
||||
currentSessionId: id,
|
||||
messages: [],
|
||||
traceSteps: [],
|
||||
currentIteration: 0,
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
agentStatus: 'idle',
|
||||
isStreaming: false,
|
||||
currentRunId: null,
|
||||
});
|
||||
|
||||
// 从数据库加载该会话的消息
|
||||
if (id && window.metona?.sessions?.getMessages) {
|
||||
window.metona.sessions.getMessages(id).then((msgs) => {
|
||||
// v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息
|
||||
if (get().currentSessionId !== id) return;
|
||||
const messages = (msgs as Array<{
|
||||
id: string; role: string; content: string;
|
||||
reasoningContent?: string; toolCalls?: unknown[];
|
||||
toolResult?: unknown;
|
||||
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
|
||||
iteration?: number;
|
||||
timestamp: number;
|
||||
}>)
|
||||
// v0.3.0 修复: 过滤掉 tool 消息 — tool 结果已包含在 assistant 消息的 toolCalls 中
|
||||
// 独立的 tool 消息只用于 LLM API 上下文,不需要在前端显示为独立卡片
|
||||
.filter((m) => m.role !== 'tool')
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as ChatMessage['role'],
|
||||
content: m.content,
|
||||
reasoningContent: m.reasoningContent,
|
||||
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
|
||||
attachments: m.attachments as AttachmentInfo[] | undefined,
|
||||
iteration: m.iteration,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
set({ messages });
|
||||
}).catch((err) => { console.error('[AgentStore]', err); });
|
||||
window.metona.sessions
|
||||
.getMessages(id)
|
||||
.then((msgs) => {
|
||||
// v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息
|
||||
if (get().currentSessionId !== id) return;
|
||||
const messages = (
|
||||
msgs as Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: unknown[];
|
||||
toolResult?: unknown;
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
preview?: string;
|
||||
textContent?: string;
|
||||
}>;
|
||||
iteration?: number;
|
||||
timestamp: number;
|
||||
}>
|
||||
)
|
||||
// v0.3.0 修复: 过滤掉 tool 消息 — tool 结果已包含在 assistant 消息的 toolCalls 中
|
||||
// 独立的 tool 消息只用于 LLM API 上下文,不需要在前端显示为独立卡片
|
||||
.filter((m) => m.role !== 'tool')
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as ChatMessage['role'],
|
||||
content: m.content,
|
||||
reasoningContent: m.reasoningContent,
|
||||
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
|
||||
attachments: m.attachments as AttachmentInfo[] | undefined,
|
||||
iteration: m.iteration,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
set({ messages });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[AgentStore]', err);
|
||||
});
|
||||
}
|
||||
|
||||
// 从数据库加载该会话的 trace 步骤和 token 用量
|
||||
if (id && window.metona?.sessions?.getTrace) {
|
||||
window.metona.sessions.getTrace(id).then((data) => {
|
||||
// v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话 trace
|
||||
if (get().currentSessionId !== id) return;
|
||||
if (data) {
|
||||
if (data.traceSteps) {
|
||||
// 兼容旧数据:为缺少 id/states 的 trace 步骤补全
|
||||
const steps = (data.traceSteps as TraceStep[]).map((t, i) => ({
|
||||
...t,
|
||||
id: t.id ?? `trace_legacy_${t.iteration}_${t.state}_${i}`,
|
||||
states: t.states ?? [t.state],
|
||||
}));
|
||||
set({ traceSteps: steps });
|
||||
window.metona.sessions
|
||||
.getTrace(id)
|
||||
.then((data) => {
|
||||
// v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话 trace
|
||||
if (get().currentSessionId !== id) return;
|
||||
if (data) {
|
||||
if (data.traceSteps) {
|
||||
// 兼容旧数据:为缺少 id/states 的 trace 步骤补全
|
||||
const steps = (data.traceSteps as TraceStep[]).map((t, i) => ({
|
||||
...t,
|
||||
id: t.id ?? `trace_legacy_${t.iteration}_${t.state}_${i}`,
|
||||
states: t.states ?? [t.state],
|
||||
}));
|
||||
set({ traceSteps: steps });
|
||||
}
|
||||
// v0.3.18 修复: 兼容旧数据 — 旧 tokenUsage 没有 lastInputTokens/lastCompressedSaved 字段
|
||||
if (data.tokenUsage) {
|
||||
const old = data.tokenUsage as Partial<TokenUsage>;
|
||||
set({
|
||||
tokenUsage: {
|
||||
inputTokens: old.inputTokens ?? 0,
|
||||
outputTokens: old.outputTokens ?? 0,
|
||||
totalTokens: old.totalTokens ?? 0,
|
||||
lastInputTokens: old.lastInputTokens ?? 0,
|
||||
lastCompressedSaved: old.lastCompressedSaved ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// v0.3.18 修复: 兼容旧数据 — 旧 tokenUsage 没有 lastInputTokens/lastCompressedSaved 字段
|
||||
if (data.tokenUsage) {
|
||||
const old = data.tokenUsage as Partial<TokenUsage>;
|
||||
set({
|
||||
tokenUsage: {
|
||||
inputTokens: old.inputTokens ?? 0,
|
||||
outputTokens: old.outputTokens ?? 0,
|
||||
totalTokens: old.totalTokens ?? 0,
|
||||
lastInputTokens: old.lastInputTokens ?? 0,
|
||||
lastCompressedSaved: old.lastCompressedSaved ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}).catch((err) => { console.error('[AgentStore]', err); });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[AgentStore]', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((s) => ({ messages: [...s.messages, message] })),
|
||||
addMessage: (message) => set((s) => ({ messages: [...s.messages, message] })),
|
||||
|
||||
updateMessage: (id, updates) =>
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) =>
|
||||
m.id === id ? { ...m, ...updates } : m,
|
||||
),
|
||||
messages: s.messages.map((m) => (m.id === id ? { ...m, ...updates } : m)),
|
||||
})),
|
||||
|
||||
sendMessage: async (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => {
|
||||
sendMessage: async (
|
||||
content: string,
|
||||
images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>,
|
||||
attachments?: AttachmentInfo[],
|
||||
) => {
|
||||
let sessionId = get().currentSessionId;
|
||||
|
||||
// v0.3.18 修复: 工具未就绪时阻止发送,避免 MCP 工具不可用
|
||||
if (!get().toolsReady) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.warning('工具正在加载中,请稍候...')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.warning('工具正在加载中,请稍候...'))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有当前会话时自动创建
|
||||
if (!sessionId && window.metona?.sessions?.create) {
|
||||
try {
|
||||
const session = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean };
|
||||
const session = (await window.metona.sessions.create()) as {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
};
|
||||
useSessionStore.getState().addSession({
|
||||
id: session.id, title: session.title, createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt, messageCount: session.messageCount,
|
||||
pinned: session.pinned, archived: session.archived,
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
messageCount: session.messageCount,
|
||||
pinned: session.pinned,
|
||||
archived: session.archived,
|
||||
});
|
||||
sessionId = session.id;
|
||||
set({ currentSessionId: sessionId });
|
||||
@@ -305,7 +373,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error(`无法创建会话:${(err as Error).message}`)).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`无法创建会话:${(err as Error).message}`))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -313,7 +383,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
const userMessage: ChatMessage = {
|
||||
id: genMsgId('user'),
|
||||
role: 'user',
|
||||
content, // 用户可见内容(纯文本)
|
||||
content, // 用户可见内容(纯文本)
|
||||
timestamp: Date.now(),
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
@@ -327,19 +397,30 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
// 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖
|
||||
// TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps
|
||||
// 历史 trace 仍在 DB 中,切换会话回来可恢复
|
||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
// 自动更新会话标题为用户第一条消息
|
||||
if (sessionId && window.metona?.sessions?.getMessages) {
|
||||
window.metona.sessions.getMessages(sessionId).then((msgs) => {
|
||||
if (msgs.length <= 1) {
|
||||
// 这是第一条消息,更新标题
|
||||
const title = content.length > 30 ? content.slice(0, 30) + '...' : content;
|
||||
window.metona?.sessions?.rename(sessionId, title);
|
||||
useSessionStore.getState().updateSession(sessionId, { title });
|
||||
}
|
||||
}).catch((err) => { console.error('[AgentStore]', err); });
|
||||
window.metona.sessions
|
||||
.getMessages(sessionId)
|
||||
.then((msgs) => {
|
||||
if (msgs.length <= 1) {
|
||||
// 这是第一条消息,更新标题
|
||||
const title = content.length > 30 ? content.slice(0, 30) + '...' : content;
|
||||
window.metona?.sessions?.rename(sessionId, title);
|
||||
useSessionStore.getState().updateSession(sessionId, { title });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[AgentStore]', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (sessionId && window.metona?.agent?.sendMessage) {
|
||||
@@ -360,15 +441,16 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
// 非图片文件:JSON 结构化,原始文本内容(不编码)
|
||||
const ext = att.name.split('.').pop() ?? 'unknown';
|
||||
const rawContent = att.textContent ?? '';
|
||||
parts.push(JSON.stringify({
|
||||
file_name: att.name,
|
||||
file_type: ext,
|
||||
content: rawContent,
|
||||
}));
|
||||
parts.push(
|
||||
JSON.stringify({
|
||||
file_name: att.name,
|
||||
file_type: ext,
|
||||
content: rawContent,
|
||||
}),
|
||||
);
|
||||
}
|
||||
llmContent = parts.length > 0
|
||||
? (content ? content + '\n\n' : '') + parts.join('\n')
|
||||
: content;
|
||||
llmContent =
|
||||
parts.length > 0 ? (content ? content + '\n\n' : '') + parts.join('\n') : content;
|
||||
}
|
||||
|
||||
const messageWithImages = {
|
||||
@@ -386,7 +468,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`)).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`))
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -422,22 +506,17 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
|
||||
setCurrentRunId: (runId) => set({ currentRunId: runId }),
|
||||
|
||||
addTraceStep: (step) =>
|
||||
set((s) => ({ traceSteps: [...s.traceSteps, step] })),
|
||||
addTraceStep: (step) => set((s) => ({ traceSteps: [...s.traceSteps, step] })),
|
||||
|
||||
updateTraceStep: (iteration, updates) =>
|
||||
set((s) => ({
|
||||
traceSteps: s.traceSteps.map((t) =>
|
||||
t.iteration === iteration ? { ...t, ...updates } : t,
|
||||
),
|
||||
traceSteps: s.traceSteps.map((t) => (t.iteration === iteration ? { ...t, ...updates } : t)),
|
||||
})),
|
||||
|
||||
// L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞)
|
||||
updateTraceStepById: (id, updates) =>
|
||||
set((s) => ({
|
||||
traceSteps: s.traceSteps.map((t) =>
|
||||
t.id === id ? { ...t, ...updates } : t,
|
||||
),
|
||||
traceSteps: s.traceSteps.map((t) => (t.id === id ? { ...t, ...updates } : t)),
|
||||
})),
|
||||
|
||||
updateLastTraceStep: (updates) =>
|
||||
@@ -448,8 +527,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
return { traceSteps: steps };
|
||||
}),
|
||||
|
||||
updateTokenUsage: (usage) =>
|
||||
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
|
||||
updateTokenUsage: (usage) => set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
|
||||
|
||||
// v0.3.18 修复: 应用上下文压缩事件,记录节省的 token 数
|
||||
// 压缩后下一轮 LLM 调用的 inputTokens 会大幅下降,lastInputTokens 会自动反映
|
||||
@@ -459,23 +537,43 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
setProvider: (provider, model) => {
|
||||
// L-16 修复: 使用命名常量替代魔法数字
|
||||
// v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取
|
||||
const initialCtx = provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW;
|
||||
const initialCtx =
|
||||
provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW;
|
||||
set({ provider, model, contextWindow: initialCtx });
|
||||
// v0.5.4: 多模态开关随 provider/model 状态一并加载(App 初始化/设置保存共用此路径)
|
||||
if (window.metona?.config?.get) {
|
||||
window.metona.config
|
||||
.get('llm.multimodalEnabled')
|
||||
.then((v) => {
|
||||
set({ multimodalEnabled: v === true });
|
||||
})
|
||||
.catch(() => {
|
||||
/* 读取失败保持默认 false */
|
||||
});
|
||||
}
|
||||
// v0.3.1: 异步读取实际配置(所有 Provider)
|
||||
if (window.metona?.config?.get) {
|
||||
// Ollama 用 ollama.numCtx,DeepSeek 用 deepseek.contextWindow,Agnes 用 agnes.contextWindow
|
||||
const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`;
|
||||
// P2-12 修复: 竞态保护 — 快速切换 provider 时,旧 Promise resolve 不覆盖新值
|
||||
const expectedProvider = provider;
|
||||
window.metona.config.get(configKey).then((v) => {
|
||||
if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果
|
||||
if (v != null && typeof v === 'number' && v > 0) {
|
||||
set({ contextWindow: v });
|
||||
}
|
||||
}).catch((err) => { console.error('[AgentStore]', err); });
|
||||
window.metona.config
|
||||
.get(configKey)
|
||||
.then((v) => {
|
||||
if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果
|
||||
if (v != null && typeof v === 'number' && v > 0) {
|
||||
set({ contextWindow: v });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[AgentStore]', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// v0.5.4: 多模态开关 setter — 设置保存/开关切换时同步(立即生效,ChatInput 消费)
|
||||
setMultimodalEnabled: (enabled) => set({ multimodalEnabled: enabled }),
|
||||
|
||||
setMaxIterations: (max) => set({ maxIterations: max }),
|
||||
|
||||
setCurrentIteration: (currentIteration) => set({ currentIteration }),
|
||||
@@ -483,7 +581,11 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
saveTraceData: () => {
|
||||
const { currentSessionId, traceSteps, tokenUsage } = get();
|
||||
if (currentSessionId && window.metona?.sessions?.saveTrace) {
|
||||
window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch((err) => { console.error('[AgentStore]', err); });
|
||||
window.metona.sessions
|
||||
.saveTrace(currentSessionId, { traceSteps, tokenUsage })
|
||||
.catch((err) => {
|
||||
console.error('[AgentStore]', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -492,7 +594,13 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
messages: [],
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
|
||||
tokenUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
lastInputTokens: 0,
|
||||
lastCompressedSaved: 0,
|
||||
},
|
||||
traceSteps: [],
|
||||
isStreaming: false,
|
||||
currentRunId: null,
|
||||
@@ -504,7 +612,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
// 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖
|
||||
set({ agentStatus: 'idle', isStreaming: false });
|
||||
if (sessionId && window.metona?.agent?.abortSession) {
|
||||
window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); });
|
||||
window.metona.agent.abortSession(sessionId).catch((err) => {
|
||||
console.error('[AgentStore]', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -512,7 +622,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
editAndResend: async (messageId, newContent) => {
|
||||
const { messages, currentSessionId, isStreaming } = get();
|
||||
if (isStreaming) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.warning('Agent 正在回复中,请先中断再编辑重发')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.warning('Agent 正在回复中,请先中断再编辑重发'))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
@@ -526,7 +638,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
await window.metona.sessions.truncateAfter(currentSessionId, messageId, true);
|
||||
} catch (err) {
|
||||
console.error('[AgentStore] truncateAfter failed:', err);
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error('消息截断失败,无法重发')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('消息截断失败,无法重发'))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -556,13 +670,22 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
regenerate: async () => {
|
||||
const { messages, currentSessionId, isStreaming } = get();
|
||||
if (isStreaming) {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.warning('Agent 正在回复中,无法重新生成')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.warning('Agent 正在回复中,无法重新生成'))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
// 找最后一条用户消息
|
||||
const lastUserIdx = messages.length - 1 - [...messages].reverse().findIndex((m) => m.role === 'user');
|
||||
if (lastUserIdx < 0 || lastUserIdx >= messages.length || messages[lastUserIdx].role !== 'user') {
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.info('没有可重新生成的用户消息')).catch(() => {});
|
||||
const lastUserIdx =
|
||||
messages.length - 1 - [...messages].reverse().findIndex((m) => m.role === 'user');
|
||||
if (
|
||||
lastUserIdx < 0 ||
|
||||
lastUserIdx >= messages.length ||
|
||||
messages[lastUserIdx].role !== 'user'
|
||||
) {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.info('没有可重新生成的用户消息'))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
const lastUser = messages[lastUserIdx];
|
||||
@@ -573,7 +696,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
await window.metona.sessions.truncateAfter(currentSessionId, lastUser.id, true);
|
||||
} catch (err) {
|
||||
console.error('[AgentStore] truncateAfter failed:', err);
|
||||
import('@metona-team/metona-toast').then((mod) => mod.default.error('消息截断失败,无法重新生成')).catch(() => {});
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error('消息截断失败,无法重新生成'))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user