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)