From 7bb38f81b64d69b647b7478b97276fa5ce7d3406 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Mon, 18 May 2026 22:24:39 +0800 Subject: [PATCH] feat(session): persist last request state for UI restoration Sessions now record the input-bar state used for the most recent QA request (agent, model, KB scope, web search). The chat UI hydrates those settings on session reopen so users see the same configuration they used last time, instead of the global default. The state is stored in the existing sessions.agent_config JSONB column to avoid a new migration. Frontend snapshots the user's global defaults on session enter and restores them on session leave, so opening an old session does not pollute new-chat defaults. --- frontend/src/stores/settings.ts | 75 +++++++++++++++++++++- frontend/src/views/chat/index.vue | 46 +++++++++---- internal/application/repository/session.go | 27 ++++++++ internal/application/service/session.go | 27 ++++++++ internal/handler/session/qa.go | 55 ++++++++++++++-- internal/handler/session/types.go | 8 +-- internal/types/interfaces/session.go | 8 +++ internal/types/session.go | 56 ++++++++++++++++ 8 files changed, 282 insertions(+), 20 deletions(-) diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index ba57fd07..460c788b 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -104,7 +104,10 @@ const defaultSettings: Settings = { export const useSettingsStore = defineStore("settings", { state: () => ({ // 从本地存储加载设置,如果没有则使用默认设置 - settings: JSON.parse(localStorage.getItem("WeKnora_settings") || JSON.stringify(defaultSettings)), + settings: JSON.parse(localStorage.getItem("WeKnora_settings") || JSON.stringify(defaultSettings)) as Settings, + // 进入会话时拍下"全局默认"的快照;离开会话时还原。非持久化字段: + // 刷新页面相当于重新走"进入会话"流程,自然会重新拍快照。 + _defaultsSnapshot: null as Settings | null, }), getters: { @@ -416,6 +419,76 @@ export const useSettingsStore = defineStore("settings", { getSelectedAgentId(): string { return this.settings.selectedAgentId || BUILTIN_QUICK_ANSWER_ID; }, + + // —— 会话级输入态恢复 —— // + // + // 输入栏的 agent / 模型 / KB / 联网 / MCP 等选择由本 store 持有,跨会话共享。 + // 但用户的诉求是:点开旧会话时,能看到当时发起请求的那一套状态。 + // 实现策略:进入会话时把"当前的全局默认"暂存到一个非持久化的 `_defaultsSnapshot` + // 字段里,然后用 session.last_request_state 覆盖 store;离开会话时从快照还原。 + // 快照不写 localStorage,因为它只在「正处于某个旧会话」这段路由期内有意义; + // 刷新页面相当于"重新进入会话" → 重新拍快照 + 覆盖,不会丢失用户的全局默认。 + + // 拍下当前 settings 作为"离开会话后要还原回去的默认"。 + // 已存在快照时不覆盖,避免会话间切换(B→B')把已恢复的 store 错当成默认。 + snapshotAsDefaultsIfNeeded() { + if (this._defaultsSnapshot) return; + this._defaultsSnapshot = JSON.parse(JSON.stringify(this.settings)); + }, + + // 还原默认(如果有快照),用于离开会话或跨会话切换时。 + restoreDefaultsIfSnapshotted() { + if (!this._defaultsSnapshot) return; + this.settings = this._defaultsSnapshot; + this._defaultsSnapshot = null; + // 不写 localStorage:默认值在快照之前已经写过 localStorage,这里恢复 + // 的就是 localStorage 中既有的值,再写一次只会增加无意义的 IO。 + }, + + // 根据 session.last_request_state 覆盖输入栏相关字段。 + // 只触碰本次记录的字段,**不**清空 store 中其它无关字段(如模型列表)。 + // 任何字段缺失则保留 store 现值,做"尽力恢复"。 + applyLastRequestState(state: SessionLastRequestStatePayload | null | undefined) { + if (!state) return; + if (typeof state.agent_enabled === "boolean") { + this.settings.isAgentEnabled = state.agent_enabled; + } + if (typeof state.agent_id === "string" && state.agent_id) { + this.settings.selectedAgentId = state.agent_id; + // 上次记录是自有 agent 还是共享 agent,目前服务端不区分回传 sourceTenantId。 + // 与 selectAgent() 不同,这里**不**重置 KB/文件选择 —— 因为我们紧接着 + // 就要用 state 里的 KB/文件覆盖,不需要先清空再写。 + } + if (state.model_id !== undefined) { + const current = this.settings.conversationModels || defaultSettings.conversationModels; + this.settings.conversationModels = { ...current, selectedChatModelId: state.model_id || "" }; + } + if (Array.isArray(state.knowledge_base_ids)) { + this.settings.selectedKnowledgeBases = [...state.knowledge_base_ids]; + } + if (Array.isArray(state.knowledge_ids)) { + this.settings.selectedFiles = [...state.knowledge_ids]; + // selectedFileKbMap 此时无法重建(state 里没存 KB 归属),交给前端按 + // 需要 lazy 拉取。保留 store 现值,避免误删用户刚加进来的文件映射。 + } + if (typeof state.web_search_enabled === "boolean") { + this.settings.webSearchEnabled = state.web_search_enabled; + } + // 注意:故意不写 localStorage —— 旧会话的状态不应污染"用户默认"。 + // 离开会话时 restoreDefaultsIfSnapshotted 会把 localStorage 里那份完整 + // 的默认值再次同步回 this.settings。 + }, }, }); + +// 后端 sessions.last_request_state JSON 形状(与 SessionLastRequestState 对齐)。 +// 字段全部可选——历史会话或新建会话首发前的请求没有这条记录。 +export interface SessionLastRequestStatePayload { + agent_id?: string; + agent_enabled?: boolean; + model_id?: string; + knowledge_base_ids?: string[]; + knowledge_ids?: string[]; + web_search_enabled?: boolean; +} \ No newline at end of file diff --git a/frontend/src/views/chat/index.vue b/frontend/src/views/chat/index.vue index 15e64d09..fcc5d64e 100644 --- a/frontend/src/views/chat/index.vue +++ b/frontend/src/views/chat/index.vue @@ -126,6 +126,28 @@ const route = useRoute(); const router = useRouter(); const session_id = ref(props.session_id || route.params.chatid); const sessionData = ref(null); + +// 拉 session 详情,并按其 last_request_state 把输入栏状态恢复到当时的发起态。 +// 嵌入式(embeddedMode)由宿主页面注入 agent/KB,所以跳过整套恢复逻辑, +// 避免污染宿主的 settings store。 +const loadSessionAndHydrate = async (sid) => { + if (!sid || props.embeddedMode) return; + try { + const sessionRes = await getSession(sid); + if (sessionRes?.data) { + sessionData.value = sessionRes.data; + const lastState = sessionRes.data.last_request_state; + if (lastState) { + // 先把当前的"全局默认"快照下来,再用 session 状态覆盖; + // 离开会话时会从快照还原,避免本会话的状态污染新建对话。 + useSettingsStoreInstance.snapshotAsDefaultsIfNeeded(); + useSettingsStoreInstance.applyLastRequestState(lastState); + } + } + } catch (error) { + console.error('Failed to load session data:', error); + } +}; const inputFieldRef = ref(); const created_at = ref(''); const limit = ref(20); @@ -252,8 +274,13 @@ watch([() => route.params], (newvalue) => { isReplying.value = false; currentAssistantMessageId.value = ''; userHasScrolledUp.value = false; - + + // 跨会话切换:先把旧会话覆盖前的全局默认还原,再让新会话重新拍快照 + // 并应用自己的 last_request_state(在 loadSessionAndHydrate 内部完成)。 + useSettingsStoreInstance.restoreDefaultsIfSnapshotted(); + checkmenuTitle(session_id.value) + loadSessionAndHydrate(session_id.value); let data = { session_id: session_id.value, created_at: '', @@ -1229,16 +1256,9 @@ onMounted(async () => { loading.value = false; isReplying.value = false; - // Load session data to get agent_config - try { - const sessionRes = await getSession(session_id.value); - if (sessionRes?.data) { - sessionData.value = sessionRes.data; - } - } catch (error) { - console.error('Failed to load session data:', error); - } - + // 拉会话详情;若服务端记录了 last_request_state,则按其恢复输入栏状态。 + await loadSessionAndHydrate(session_id.value); + checkmenuTitle(session_id.value) if (firstQuery.value) { scrollLock.value = true; @@ -1270,10 +1290,14 @@ onUnmounted(() => { }); onBeforeRouteLeave((to, from, next) => { clearData() + // 离开聊天会话 → 还原"用户全局默认",避免旧会话的请求态泄漏到新建对话。 + useSettingsStoreInstance.restoreDefaultsIfSnapshotted(); next() }) onBeforeRouteUpdate((to, from, next) => { clearData() + // 仅"会话 → 会话"会落到这里;跨会话覆盖的还原放到 route.params 的 watch 里, + // 因为新会话的 getSession 也在那边触发,便于保证 restore→snapshot→apply 顺序。 next() }) diff --git a/internal/application/repository/session.go b/internal/application/repository/session.go index 1dec4ccc..178d3ae1 100644 --- a/internal/application/repository/session.go +++ b/internal/application/repository/session.go @@ -239,6 +239,33 @@ func (r *sessionRepository) Update(ctx context.Context, session *types.Session, return res.RowsAffected, res.Error } +// UpdateLastRequestState writes only the agent_config column (used here to +// store SessionLastRequestState) and bumps updated_at. We deliberately bypass +// the regular Update path so the call doesn't perturb title/description and +// stays cheap (single-row UPDATE by PK). +func (r *sessionRepository) UpdateLastRequestState( + ctx context.Context, tenantID uint64, userID string, sessionID string, + state *types.SessionLastRequestState, +) (int64, error) { + now := time.Now() + var stateValue interface{} + if state != nil { + v, err := state.Value() + if err != nil { + return 0, err + } + stateValue = v + } + res := applySessionUserScope(r.db.WithContext(ctx). + Model(&types.Session{}). + Where("tenant_id = ? AND id = ?", tenantID, sessionID), userID). + Updates(map[string]interface{}{ + "agent_config": stateValue, + "updated_at": now, + }) + return res.RowsAffected, res.Error +} + // Delete deletes a session func (r *sessionRepository) Delete(ctx context.Context, tenantID uint64, userID string, id string) (int64, error) { res := applySessionUserScope( diff --git a/internal/application/service/session.go b/internal/application/service/session.go index fd6eee25..6ecd8f51 100644 --- a/internal/application/service/session.go +++ b/internal/application/service/session.go @@ -247,6 +247,33 @@ func (s *sessionService) UpdateSession(ctx context.Context, session *types.Sessi return nil } +// UpdateSessionLastRequestState persists the input-bar state used by the most +// recent QA request on this session. Called from the QA handler after a +// request is accepted so the UI can rehydrate the same settings on reopen. +// Best-effort: scope mismatches are logged and swallowed — failing to record +// the UI memo should never fail the user's chat request. +func (s *sessionService) UpdateSessionLastRequestState( + ctx context.Context, sessionID string, state *types.SessionLastRequestState, +) error { + if sessionID == "" { + return stderrors.New("session id is required") + } + tenantID := types.MustTenantIDFromContext(ctx) + userID := sessionUserIDFromContext(ctx) + affected, err := s.sessionRepo.UpdateLastRequestState(ctx, tenantID, userID, sessionID, state) + if err != nil { + logger.ErrorWithFields(ctx, err, map[string]interface{}{ + "session_id": sessionID, + "tenant_id": tenantID, + }) + return err + } + if affected == 0 { + logger.Warnf(ctx, "UpdateSessionLastRequestState: no rows affected for session %s", sessionID) + } + return nil +} + // DeleteSession removes a session by its ID func (s *sessionService) DeleteSession(ctx context.Context, id string) error { // Validate session ID diff --git a/internal/handler/session/qa.go b/internal/handler/session/qa.go index 6e84c132..ef3ac15c 100644 --- a/internal/handler/session/qa.go +++ b/internal/handler/session/qa.go @@ -35,11 +35,17 @@ type qaRequestContext struct { webSearchEnabled bool enableMemory bool // Whether memory feature is enabled mentionedItems types.MentionedItems - effectiveTenantID uint64 // when using shared agent, tenant ID for model/KB/MCP resolution; 0 = use context tenant - images []ImageAttachment // Uploaded images with analysis text - userMessageID string // Created user message ID (populated after createUserMessage) - channel string // Source channel: "web", "api", "im", etc. + effectiveTenantID uint64 // when using shared agent, tenant ID for model/KB/MCP resolution; 0 = use context tenant + images []ImageAttachment // Uploaded images with analysis text + userMessageID string // Created user message ID (populated after createUserMessage) + channel string // Source channel: "web", "api", "im", etc. attachments types.MessageAttachments // Processed file attachments + + // Snapshot of the request fields needed to persist the input-bar state + // for session restoration. Kept verbatim from the request so we record + // what the user had selected on the UI (not server-side resolutions). + reqAgentEnabled bool + reqAgentID string } // buildQARequest converts the qaRequestContext into a types.QARequest for service invocation. @@ -241,6 +247,8 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo images: request.Images, channel: request.Channel, attachments: processedAttachments, + reqAgentEnabled: request.AgentEnabled, + reqAgentID: request.AgentID, } return reqCtx, &request, nil @@ -608,6 +616,13 @@ func (h *Handler) executeQA(reqCtx *qaRequestContext, mode qaMode, generateTitle ctx := reqCtx.ctx sessionID := reqCtx.sessionID + // Persist the input-bar state used for this request so reopening the + // session can rehydrate agent / model / KB / web-search / MCP selections. + // This is a pure UI memo (no behavioural effect) and runs in a goroutine + // to avoid adding a DB round-trip to TTFB. Use WithoutCancel so a fast + // client disconnect doesn't drop the write. + go h.persistLastRequestState(ctx, reqCtx, mode) + // Agent mode: emit agent query event before message creation if mode == qaModeAgent { if err := event.Emit(ctx, event.Event{ @@ -817,6 +832,38 @@ func (h *Handler) runVLMAnalysisIfNeeded(streamCtx *sseStreamContext, reqCtx *qa }) } +// persistLastRequestState records the input-bar state the user just sent so +// that reopening this session restores agent/model/KB/web-search/MCP picks. +// Pure UI memo — failures are logged but never bubble up; the caller runs +// this in a goroutine and is safe to discard the returned context. +func (h *Handler) persistLastRequestState(parentCtx context.Context, reqCtx *qaRequestContext, mode qaMode) { + // Detach from the HTTP request lifetime: this write must survive both + // SSE disconnects and the parent gin context being released after the + // handler returns. + ctx := logger.CloneContext(context.WithoutCancel(parentCtx)) + + agentEnabled := reqCtx.reqAgentEnabled + // Mirror the resolution rule used in AgentQA: a resolved custom agent's + // agent_mode wins over the request flag. For KnowledgeQA the request + // itself carries agent_enabled=false, so this collapses correctly. + if mode == qaModeAgent && reqCtx.customAgent != nil { + agentEnabled = reqCtx.customAgent.IsAgentMode() + } + + state := &types.SessionLastRequestState{ + AgentID: reqCtx.reqAgentID, + AgentEnabled: agentEnabled, + ModelID: reqCtx.summaryModelID, + KnowledgeBaseIDs: reqCtx.knowledgeBaseIDs, + KnowledgeIDs: reqCtx.knowledgeIDs, + WebSearchEnabled: reqCtx.webSearchEnabled, + } + + if err := h.sessionService.UpdateSessionLastRequestState(ctx, reqCtx.sessionID, state); err != nil { + logger.Warnf(ctx, "persist last_request_state failed for session %s: %v", reqCtx.sessionID, err) + } +} + // completeAssistantMessage marks an assistant message as complete, updates it, // and asynchronously indexes the Q&A pair into the chat history knowledge base. func (h *Handler) completeAssistantMessage(ctx context.Context, assistantMessage *types.Message, userQuery string) { diff --git a/internal/handler/session/types.go b/internal/handler/session/types.go index 21e6f018..170058ae 100644 --- a/internal/handler/session/types.go +++ b/internal/handler/session/types.go @@ -58,10 +58,10 @@ type CreateKnowledgeQARequest struct { // user's personal memory setting doesn't leak into a widget // context; older clients that still send a literal bool also // land here (back-compat). - EnableMemory *bool `json:"enable_memory,omitempty"` - Images []ImageAttachment `json:"images"` // Attached images for multimodal chat - AttachmentUploads []AttachmentUpload `json:"attachment_uploads,omitempty"` // Attached files (documents, audio, etc.) - Channel string `json:"channel"` // Source channel: "web", "api", "im", etc. + EnableMemory *bool `json:"enable_memory,omitempty"` + Images []ImageAttachment `json:"images"` // Attached images for multimodal chat + AttachmentUploads []AttachmentUpload `json:"attachment_uploads,omitempty"` // Attached files (documents, audio, etc.) + Channel string `json:"channel"` // Source channel: "web", "api", "im", etc. } // AttachmentUpload represents a file attachment upload from the client diff --git a/internal/types/interfaces/session.go b/internal/types/interfaces/session.go index e047ded7..a0f58637 100644 --- a/internal/types/interfaces/session.go +++ b/internal/types/interfaces/session.go @@ -19,6 +19,10 @@ type SessionService interface { GetPagedSessionsByTenant(ctx context.Context, page *types.Pagination) (*types.PageResult, error) // UpdateSession updates a session UpdateSession(ctx context.Context, session *types.Session) error + // UpdateSessionLastRequestState records the input-bar state used for the + // most recent QA request on this session. Best-effort: callers should log + // but not surface failures to the user. + UpdateSessionLastRequestState(ctx context.Context, sessionID string, state *types.SessionLastRequestState) error // DeleteSession deletes a session DeleteSession(ctx context.Context, id string) error // BatchDeleteSessions deletes multiple sessions by IDs @@ -65,6 +69,10 @@ type SessionRepository interface { QueryPaged(ctx context.Context, q *types.SessionListQuery) ([]*types.SessionListItem, int64, error) // Update updates a session visible to the tenant/user scope. Update(ctx context.Context, session *types.Session, userID string) (int64, error) + // UpdateLastRequestState persists the most recent input-bar state for a + // session (agent, model, KB scope, etc.) so the chat UI can restore it + // when the session is reopened. Scope rules match Update. + UpdateLastRequestState(ctx context.Context, tenantID uint64, userID string, sessionID string, state *types.SessionLastRequestState) (int64, error) // SetPinned pins or unpins a session row scoped by tenant. // userID, when non-empty, is enforced so users cannot pin sessions they don't own. // Returns the number of rows affected; 0 means the session doesn't exist or is diff --git a/internal/types/session.go b/internal/types/session.go index 7b96ba63..33a396ab 100644 --- a/internal/types/session.go +++ b/internal/types/session.go @@ -89,6 +89,14 @@ type Session struct { // PinnedAt records when the session was pinned; nil when not pinned. PinnedAt *time.Time `json:"pinned_at,omitempty"` + // LastRequestState records the input-bar state used the last time this + // session sent a question (agent, model, KB scope, web search, MCPs). + // Persisted on every successful POST to /knowledge-chat or /agent-chat so + // that reopening the session can restore the original request context to + // the chat UI. Stored in the legacy sessions.agent_config JSONB column to + // avoid a new migration; the shape used today is `SessionLastRequestState`. + LastRequestState *SessionLastRequestState `json:"last_request_state,omitempty" gorm:"column:agent_config;type:jsonb"` + // // Strategy configuration // KnowledgeBaseID string `json:"knowledge_base_id"` // 关联的知识库ID // MaxRounds int `json:"max_rounds"` // 多轮保持轮数 @@ -184,6 +192,54 @@ func (c *SummaryConfig) Scan(value interface{}) error { return json.Unmarshal(b, c) } +// SessionLastRequestState captures the user-facing input-bar state at the +// time of the most recent QA request on a session. It is purely a UI memory +// aid — none of the fields here drive backend behaviour. They are echoed back +// to the frontend by GetSession so the chat input can restore the same agent, +// model, KB scope, etc. the user had selected last time. +type SessionLastRequestState struct { + AgentID string `json:"agent_id,omitempty"` + AgentEnabled bool `json:"agent_enabled"` + ModelID string `json:"model_id,omitempty"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"` + KnowledgeIDs []string `json:"knowledge_ids,omitempty"` + WebSearchEnabled bool `json:"web_search_enabled"` +} + +// Value implements driver.Valuer for SessionLastRequestState (JSONB). +func (s *SessionLastRequestState) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + return json.Marshal(s) +} + +// Scan implements sql.Scanner for SessionLastRequestState (JSONB). +// Tolerates legacy values that may not match the current schema by silently +// ignoring unmarshal errors — the stored row predates this struct. +func (s *SessionLastRequestState) Scan(value interface{}) error { + if value == nil { + return nil + } + var b []byte + switch v := value.(type) { + case []byte: + b = v + case string: + b = []byte(v) + default: + return nil + } + if len(b) == 0 { + return nil + } + if err := json.Unmarshal(b, s); err != nil { + // Tolerate legacy shapes from before this column was repurposed. + return nil + } + return nil +} + // Value implements the driver.Valuer interface, used to convert ContextConfig to database value func (c *ContextConfig) Value() (driver.Value, error) { return json.Marshal(c)