fix: 修复问股和首页进行中状态残留 (#1461)

* fix: clear stale active task state

* fix: guard active task pruning against stale snapshots

---------

Co-authored-by: mumu <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
Alfred
2026-05-25 20:32:08 +08:00
committed by GitHub
parent 3aa26ba7d8
commit fcfcd3a5b1
10 changed files with 380 additions and 36 deletions

View File

@@ -30,11 +30,13 @@ describe('useDashboardLifecycle', () => {
it('loads history, refreshes on interval, and reacts to visibility changes', () => {
const loadInitialHistory = vi.fn().mockResolvedValue(undefined);
const refreshHistory = vi.fn().mockResolvedValue(undefined);
const refreshActiveTasks = vi.fn().mockResolvedValue(undefined);
renderHook(() =>
useDashboardLifecycle({
loadInitialHistory,
refreshHistory,
refreshActiveTasks,
syncTaskCreated: vi.fn(),
syncTaskUpdated: vi.fn(),
syncTaskFailed: vi.fn(),
@@ -43,11 +45,13 @@ describe('useDashboardLifecycle', () => {
);
expect(loadInitialHistory).toHaveBeenCalledTimes(1);
expect(refreshActiveTasks).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(refreshHistory).toHaveBeenCalledWith(true);
expect(refreshActiveTasks).toHaveBeenCalledTimes(2);
act(() => {
Object.defineProperty(document, 'visibilityState', {
@@ -58,6 +62,7 @@ describe('useDashboardLifecycle', () => {
});
expect(refreshHistory).toHaveBeenCalledTimes(2);
expect(refreshActiveTasks).toHaveBeenCalledTimes(3);
});
it('cleans pending task removal timers on unmount', () => {
@@ -67,6 +72,7 @@ describe('useDashboardLifecycle', () => {
useDashboardLifecycle({
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
refreshHistory: vi.fn().mockResolvedValue(undefined),
refreshActiveTasks: vi.fn().mockResolvedValue(undefined),
syncTaskCreated: vi.fn(),
syncTaskUpdated: vi.fn(),
syncTaskFailed: vi.fn(),
@@ -99,6 +105,7 @@ describe('useDashboardLifecycle', () => {
useDashboardLifecycle({
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
refreshHistory,
refreshActiveTasks: vi.fn().mockResolvedValue(undefined),
syncTaskCreated: vi.fn(),
syncTaskUpdated,
syncTaskFailed: vi.fn(),
@@ -130,6 +137,7 @@ describe('useDashboardLifecycle', () => {
useDashboardLifecycle({
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
refreshHistory: vi.fn().mockResolvedValue(undefined),
refreshActiveTasks: vi.fn().mockResolvedValue(undefined),
syncTaskCreated: vi.fn(),
syncTaskUpdated,
syncTaskFailed: vi.fn(),
@@ -160,6 +168,7 @@ describe('useDashboardLifecycle', () => {
useDashboardLifecycle({
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
refreshHistory: vi.fn().mockResolvedValue(undefined),
refreshActiveTasks: vi.fn().mockResolvedValue(undefined),
syncTaskCreated: vi.fn(),
syncTaskUpdated: vi.fn(),
syncTaskFailed,
@@ -186,4 +195,28 @@ describe('useDashboardLifecycle', () => {
expect(removeTask).toHaveBeenCalledWith(failedTask.taskId);
});
it('reconciles active tasks when the SSE stream connects', () => {
const refreshActiveTasks = vi.fn().mockResolvedValue(undefined);
renderHook(() =>
useDashboardLifecycle({
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
refreshHistory: vi.fn().mockResolvedValue(undefined),
refreshActiveTasks,
syncTaskCreated: vi.fn(),
syncTaskUpdated: vi.fn(),
syncTaskFailed: vi.fn(),
removeTask: vi.fn(),
}),
);
const taskStreamOptions = vi.mocked(useTaskStream).mock.calls[0]?.[0];
act(() => {
taskStreamOptions?.onConnected?.();
});
expect(refreshActiveTasks).toHaveBeenCalledTimes(2);
});
});

View File

@@ -5,6 +5,7 @@ import { useTaskStream } from './useTaskStream';
type UseDashboardLifecycleOptions = {
loadInitialHistory: () => Promise<void>;
refreshHistory: (silent?: boolean) => Promise<void>;
refreshActiveTasks: () => Promise<void>;
syncTaskCreated: (task: TaskInfo) => void;
syncTaskUpdated: (task: TaskInfo) => void;
syncTaskFailed: (task: TaskInfo) => void;
@@ -15,6 +16,7 @@ type UseDashboardLifecycleOptions = {
export function useDashboardLifecycle({
loadInitialHistory,
refreshHistory,
refreshActiveTasks,
syncTaskCreated,
syncTaskUpdated,
syncTaskFailed,
@@ -29,7 +31,8 @@ export function useDashboardLifecycle({
}
void loadInitialHistory();
}, [enabled, loadInitialHistory]);
void refreshActiveTasks();
}, [enabled, loadInitialHistory, refreshActiveTasks]);
useEffect(() => {
if (!enabled) {
@@ -38,10 +41,11 @@ export function useDashboardLifecycle({
const intervalId = window.setInterval(() => {
void refreshHistory(true);
void refreshActiveTasks();
}, 30_000);
return () => window.clearInterval(intervalId);
}, [enabled, refreshHistory]);
}, [enabled, refreshHistory, refreshActiveTasks]);
useEffect(() => {
if (!enabled) {
@@ -51,12 +55,13 @@ export function useDashboardLifecycle({
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void refreshHistory(true);
void refreshActiveTasks();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [enabled, refreshHistory]);
}, [enabled, refreshHistory, refreshActiveTasks]);
useEffect(() => {
return () => {
@@ -78,6 +83,9 @@ export function useDashboardLifecycle({
onTaskCreated: syncTaskCreated,
onTaskStarted: syncTaskUpdated,
onTaskProgress: syncTaskUpdated,
onConnected: () => {
void refreshActiveTasks();
},
onTaskCompleted: (task) => {
syncTaskUpdated(task);
void refreshHistory(true);

View File

@@ -39,6 +39,7 @@ export function useHomeDashboardState() {
syncTaskCreated: state.syncTaskCreated,
syncTaskUpdated: state.syncTaskUpdated,
syncTaskFailed: state.syncTaskFailed,
refreshActiveTasks: state.refreshActiveTasks,
removeTask: state.removeTask,
openMarkdownDrawer: state.openMarkdownDrawer,
closeMarkdownDrawer: state.closeMarkdownDrawer,

View File

@@ -97,6 +97,7 @@ const HomePage: React.FC = () => {
syncTaskCreated,
syncTaskUpdated,
syncTaskFailed,
refreshActiveTasks,
removeTask,
openMarkdownDrawer,
closeMarkdownDrawer,
@@ -291,6 +292,7 @@ const HomePage: React.FC = () => {
syncTaskCreated,
syncTaskUpdated,
syncTaskFailed,
refreshActiveTasks,
removeTask,
});

View File

@@ -37,6 +37,7 @@ vi.mock('../../api/analysis', async () => {
analyzeAsync: vi.fn(),
triggerMarketReview: vi.fn(),
getStatus: vi.fn(),
getTasks: vi.fn(),
},
};
});
@@ -117,6 +118,12 @@ describe('HomePage', () => {
vi.clearAllMocks();
navigateMock.mockReset();
useStockPoolStore.getState().resetDashboardState();
vi.mocked(analysisApi.getTasks).mockResolvedValue({
total: 0,
pending: 0,
processing: 0,
tasks: [],
});
vi.mocked(agentApi.getSkills).mockResolvedValue({ skills: [], default_skill_id: '' });
vi.mocked(systemConfigApi.getSetupStatus).mockResolvedValue({
isComplete: true,
@@ -487,26 +494,31 @@ describe('HomePage', () => {
});
it('renders active task panel content from dashboard state', async () => {
const activeTask = {
taskId: 'task-1',
stockCode: '600519',
stockName: '贵州茅台',
status: 'processing' as const,
progress: 45,
message: '正在抓取最新行情',
reportType: 'detailed',
createdAt: '2026-03-18T08:00:00Z',
};
vi.mocked(historyApi.getList).mockResolvedValue({
total: 0,
page: 1,
limit: 20,
items: [],
});
vi.mocked(analysisApi.getTasks).mockResolvedValue({
total: 1,
pending: 0,
processing: 1,
tasks: [activeTask],
});
useStockPoolStore.setState({
activeTasks: [
{
taskId: 'task-1',
stockCode: '600519',
stockName: '贵州茅台',
status: 'processing',
progress: 45,
message: '正在抓取最新行情',
reportType: 'detailed',
createdAt: '2026-03-18T08:00:00Z',
},
],
activeTasks: [activeTask],
});
render(

View File

@@ -28,25 +28,35 @@ function createStreamResponse(lines: string[]) {
);
}
describe('agentChatStore.startStream', () => {
beforeEach(() => {
localStorage.clear();
useAgentChatStore.setState({
messages: [],
loading: false,
progressSteps: [],
sessionId: 'session-test',
sessions: [],
sessionsLoading: false,
chatError: null,
currentRoute: '/chat',
completionBadge: false,
hasInitialLoad: true,
abortController: null,
});
vi.clearAllMocks();
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
beforeEach(() => {
localStorage.clear();
useAgentChatStore.setState({
messages: [],
loading: false,
progressSteps: [],
sessionId: 'session-test',
sessions: [],
sessionsLoading: false,
chatError: null,
currentRoute: '/chat',
completionBadge: false,
hasInitialLoad: true,
abortController: null,
});
vi.clearAllMocks();
});
describe('agentChatStore.startStream', () => {
it('appends the user message and final assistant message from the SSE stream', async () => {
vi.mocked(agentApi.chatStream).mockResolvedValue(
createStreamResponse([
@@ -183,3 +193,66 @@ describe('agentChatStore.startStream', () => {
});
});
});
describe('agentChatStore.switchSession', () => {
it('clears transient loading state when switching sessions during a stream', async () => {
const ac = new AbortController();
vi.mocked(agentApi.getChatSessionMessages).mockResolvedValue([
{ id: 'msg-2', role: 'assistant', content: '历史回复', created_at: null },
]);
useAgentChatStore.setState({
loading: true,
progressSteps: [{ type: 'thinking', message: '正在制定分析路径...' }],
abortController: ac,
chatError: {
title: '请求失败',
message: '旧错误',
category: 'unknown',
rawMessage: '旧错误',
},
});
await useAgentChatStore.getState().switchSession('session-2');
const state = useAgentChatStore.getState();
expect(ac.signal.aborted).toBe(true);
expect(state.sessionId).toBe('session-2');
expect(state.loading).toBe(false);
expect(state.progressSteps).toEqual([]);
expect(state.abortController).toBeNull();
expect(state.chatError).toBeNull();
expect(state.messages).toEqual([
{ id: 'msg-2', role: 'assistant', content: '历史回复' },
]);
});
it('does not let a late session history response overwrite the current session', async () => {
const sessionA = createDeferred<
Array<{ id: string; role: 'user' | 'assistant'; content: string; created_at: string | null }>
>();
const sessionB = createDeferred<
Array<{ id: string; role: 'user' | 'assistant'; content: string; created_at: string | null }>
>();
vi.mocked(agentApi.getChatSessionMessages).mockImplementation((targetSessionId: string) => {
if (targetSessionId === 'session-a') return sessionA.promise;
if (targetSessionId === 'session-b') return sessionB.promise;
return Promise.resolve([]);
});
const switchToA = useAgentChatStore.getState().switchSession('session-a');
const switchToB = useAgentChatStore.getState().switchSession('session-b');
sessionB.resolve([{ id: 'msg-b', role: 'assistant', content: 'B 回复', created_at: null }]);
await switchToB;
sessionA.resolve([{ id: 'msg-a', role: 'assistant', content: 'A 回复', created_at: null }]);
await switchToA;
const state = useAgentChatStore.getState();
expect(state.sessionId).toBe('session-b');
expect(state.messages).toEqual([
{ id: 'msg-b', role: 'assistant', content: 'B 回复' },
]);
});
});

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { analysisApi, DuplicateTaskError } from '../../api/analysis';
import { historyApi } from '../../api/history';
import type { TaskInfo, TaskListResponse } from '../../types/analysis';
import { useStockPoolStore } from '../stockPoolStore';
vi.mock('../../api/history', () => ({
@@ -17,6 +18,7 @@ vi.mock('../../api/analysis', async () => {
...actual,
analysisApi: {
analyzeAsync: vi.fn(),
getTasks: vi.fn(),
},
};
});
@@ -48,6 +50,33 @@ const historyReport = {
},
};
function createTask(overrides: Partial<TaskInfo> = {}): TaskInfo {
return {
taskId: 'task-1',
stockCode: '600519',
stockName: '贵州茅台',
status: 'processing',
progress: 50,
reportType: 'detailed',
createdAt: '2026-03-18T08:00:00Z',
...overrides,
};
}
function createTaskListResponse(
tasks: TaskInfo[],
counts: Partial<Pick<TaskListResponse, 'pending' | 'processing' | 'total'>> = {},
): TaskListResponse {
const pending = counts.pending ?? tasks.filter((task) => task.status === 'pending').length;
const processing = counts.processing ?? tasks.filter((task) => task.status === 'processing').length;
return {
total: counts.total ?? tasks.length,
pending,
processing,
tasks,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -62,6 +91,7 @@ describe('stockPoolStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useStockPoolStore.getState().resetDashboardState();
vi.mocked(analysisApi.getTasks).mockResolvedValue(createTaskListResponse([]));
});
it('loads initial history and auto-selects the first report', async () => {
@@ -367,6 +397,126 @@ describe('stockPoolStore', () => {
expect(state.error).toBeTruthy();
});
it('reconciles active tasks from a complete empty backend snapshot without dismissing them', async () => {
const staleTask = createTask();
useStockPoolStore.getState().syncTaskCreated(staleTask);
vi.mocked(analysisApi.getTasks).mockResolvedValue(createTaskListResponse([]));
await useStockPoolStore.getState().refreshActiveTasks();
expect(analysisApi.getTasks).toHaveBeenCalledWith({
status: 'pending,processing',
limit: 100,
});
expect(useStockPoolStore.getState().activeTasks).toHaveLength(0);
useStockPoolStore.getState().syncTaskCreated(staleTask);
expect(useStockPoolStore.getState().activeTasks).toEqual([staleTask]);
});
it('does not prune tasks created after an active-task refresh request started', async () => {
const emptySnapshot = createDeferred<TaskListResponse>();
const createdTask = createTask({
taskId: 'task-created-after-request',
status: 'pending',
progress: 0,
});
const updatedTask = {
...createdTask,
status: 'processing' as const,
progress: 35,
};
vi.mocked(analysisApi.getTasks).mockReturnValue(emptySnapshot.promise);
const refreshPromise = useStockPoolStore.getState().refreshActiveTasks();
useStockPoolStore.getState().syncTaskCreated(createdTask);
emptySnapshot.resolve(createTaskListResponse([]));
await refreshPromise;
expect(useStockPoolStore.getState().activeTasks).toEqual([createdTask]);
useStockPoolStore.getState().syncTaskUpdated(updatedTask);
expect(useStockPoolStore.getState().activeTasks).toEqual([updatedTask]);
});
it('upserts pending and processing tasks from the backend snapshot', async () => {
const existingTask = createTask({ taskId: 'task-existing', progress: 30 });
const updatedTask = createTask({ taskId: 'task-existing', progress: 80, message: 'LLM 正在生成分析结果' });
const newTask = createTask({
taskId: 'task-new',
stockCode: '000001',
stockName: '平安银行',
status: 'pending',
progress: 0,
});
useStockPoolStore.getState().syncTaskCreated(existingTask);
vi.mocked(analysisApi.getTasks).mockResolvedValue(
createTaskListResponse([updatedTask, newTask]),
);
await useStockPoolStore.getState().refreshActiveTasks();
expect(useStockPoolStore.getState().activeTasks).toEqual([updatedTask, newTask]);
});
it('does not re-add dismissed tasks from backend reconciliation', async () => {
const dismissedTask = createTask();
useStockPoolStore.getState().syncTaskCreated(dismissedTask);
useStockPoolStore.getState().removeTask(dismissedTask.taskId);
vi.mocked(analysisApi.getTasks).mockResolvedValue(
createTaskListResponse([dismissedTask]),
);
await useStockPoolStore.getState().refreshActiveTasks();
expect(useStockPoolStore.getState().activeTasks).toHaveLength(0);
});
it('ignores late active-task snapshots from older refreshes', async () => {
const staleSnapshot = createDeferred<TaskListResponse>();
const freshSnapshot = createDeferred<TaskListResponse>();
const staleTask = createTask({ taskId: 'task-stale' });
const freshTask = createTask({ taskId: 'task-fresh', stockCode: '000001', stockName: '平安银行' });
vi.mocked(analysisApi.getTasks)
.mockReturnValueOnce(staleSnapshot.promise)
.mockReturnValueOnce(freshSnapshot.promise);
const staleRefresh = useStockPoolStore.getState().refreshActiveTasks();
const freshRefresh = useStockPoolStore.getState().refreshActiveTasks();
freshSnapshot.resolve(createTaskListResponse([freshTask]));
await freshRefresh;
expect(useStockPoolStore.getState().activeTasks).toEqual([freshTask]);
staleSnapshot.resolve(createTaskListResponse([staleTask]));
await staleRefresh;
expect(useStockPoolStore.getState().activeTasks).toEqual([freshTask]);
});
it('does not prune local tasks when the backend active-task snapshot is incomplete', async () => {
const localTask = createTask({ taskId: 'task-local' });
const remoteTask = createTask({ taskId: 'task-remote', stockCode: '000001', stockName: '平安银行' });
useStockPoolStore.getState().syncTaskCreated(localTask);
vi.mocked(analysisApi.getTasks).mockResolvedValue(
createTaskListResponse([remoteTask], { processing: 2, total: 2 }),
);
await useStockPoolStore.getState().refreshActiveTasks();
expect(useStockPoolStore.getState().activeTasks).toEqual([localTask, remoteTask]);
});
it('keeps active tasks unchanged when backend reconciliation fails', async () => {
const activeTask = createTask();
useStockPoolStore.getState().syncTaskCreated(activeTask);
vi.mocked(analysisApi.getTasks).mockRejectedValue(new Error('network failed'));
await useStockPoolStore.getState().refreshActiveTasks();
expect(useStockPoolStore.getState().activeTasks).toEqual([activeTask]);
});
it('triggers an analysis with the forceRefresh flag', async () => {
vi.mocked(analysisApi.analyzeAsync).mockResolvedValue({
taskId: 'task-force-1',

View File

@@ -178,13 +178,21 @@ export const useAgentChatStore = create<AgentChatState & AgentChatActions>((set,
if (targetSessionId === sessionId && messages.length > 0) return;
abortController?.abort();
set({ abortController: null });
set({ messages: [], sessionId: targetSessionId });
set({
messages: [],
sessionId: targetSessionId,
loading: false,
progressSteps: [],
chatError: null,
abortController: null,
});
localStorage.setItem(STORAGE_KEY_SESSION, targetSessionId);
try {
const msgs = await agentApi.getChatSessionMessages(targetSessionId);
if (get().sessionId !== targetSessionId) {
return;
}
set({
messages: msgs.map((m) => ({
id: m.id,

View File

@@ -30,6 +30,8 @@ type SubmitAnalysisOptions = {
let reportRequestSeq = 0;
let analyzeRequestSeq = 0;
let historyRequestSeq = 0;
let activeTaskRequestSeq = 0;
let activeTaskLocalRevision = 0;
const dismissedTaskIds = new Set<string>();
export interface StockPoolState {
@@ -68,6 +70,7 @@ export interface StockPoolState {
syncTaskCreated: (task: TaskInfo) => void;
syncTaskUpdated: (task: TaskInfo) => void;
syncTaskFailed: (task: TaskInfo) => void;
refreshActiveTasks: () => Promise<void>;
removeTask: (taskId: string) => void;
resetDashboardState: () => void;
}
@@ -388,6 +391,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
if (get().activeTasks.some((item) => item.taskId === task.taskId)) {
return;
}
activeTaskLocalRevision += 1;
set({ activeTasks: [...get().activeTasks, task] });
},
@@ -399,6 +403,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
const index = nextTasks.findIndex((item) => item.taskId === task.taskId);
if (index >= 0) {
nextTasks[index] = task;
activeTaskLocalRevision += 1;
set({ activeTasks: nextTasks });
}
},
@@ -408,15 +413,66 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
set({ error: getParsedApiError(task.error || '分析失败') });
},
refreshActiveTasks: async () => {
const requestId = ++activeTaskRequestSeq;
const localRevisionAtRequest = activeTaskLocalRevision;
try {
const response = await analysisApi.getTasks({
status: 'pending,processing',
limit: 100,
});
if (requestId !== activeTaskRequestSeq) {
return;
}
const remoteTasks = response.tasks.filter(
(task) => !dismissedTaskIds.has(task.taskId),
);
const remoteTaskIds = new Set(remoteTasks.map((task) => task.taskId));
const remoteTaskById = new Map(remoteTasks.map((task) => [task.taskId, task]));
const isCompleteSnapshot = response.tasks.length === response.pending + response.processing;
const canPruneLocalTasks = isCompleteSnapshot && activeTaskLocalRevision === localRevisionAtRequest;
const currentTasks = get().activeTasks;
const nextTasks = currentTasks
.filter((task) => !dismissedTaskIds.has(task.taskId))
.filter((task) => !canPruneLocalTasks || remoteTaskIds.has(task.taskId))
.map((task) => remoteTaskById.get(task.taskId) ?? task);
const localTaskIds = new Set(nextTasks.map((task) => task.taskId));
for (const task of remoteTasks) {
if (!localTaskIds.has(task.taskId)) {
nextTasks.push(task);
}
}
const hasActiveTaskChanges = nextTasks.length !== currentTasks.length
|| nextTasks.some((task, index) => task !== currentTasks[index]);
if (hasActiveTaskChanges) {
activeTaskLocalRevision += 1;
set({ activeTasks: nextTasks });
}
} catch {
// Keep the current task panel when reconciliation cannot reach the API.
}
},
removeTask: (taskId) => {
dismissedTaskIds.add(taskId);
set({ activeTasks: get().activeTasks.filter((task) => task.taskId !== taskId) });
const currentTasks = get().activeTasks;
const nextTasks = currentTasks.filter((task) => task.taskId !== taskId);
if (nextTasks.length !== currentTasks.length) {
activeTaskLocalRevision += 1;
}
set({ activeTasks: nextTasks });
},
resetDashboardState: () => {
historyRequestSeq += 1;
reportRequestSeq = 0;
analyzeRequestSeq = 0;
activeTaskRequestSeq += 1;
activeTaskLocalRevision += 1;
dismissedTaskIds.clear();
set({ ...initialState });
},

View File

@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [文档] 新增 AnalysisContextPack P0 上下文盘点,明确字段质量状态、现有状态映射和首版 pack 边界。
- [新功能] 新增 AnalysisContextPack P1 内部契约与脱敏序列化测试。
- [修复] 恢复 Agent/历史兼容快照中的关联板块与板块联动字段提取,修复新版首页报告缺少“板块联动”的回归问题。
- [修复] 修复问股会话切换和首页任务重连后可能残留 Agent/分析任务进行中状态的问题。
- [新功能] 问股新增默认关闭的可见对话上下文压缩,支持 Web 开关、Agent 高级 preset、滚动摘要和最近轮次原文保护降低长会话 token 消耗。
- [改进] P2-minLLM Prompt 注入市场阶段上下文。