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.
This commit is contained in:
wizardchen
2026-05-18 22:24:39 +08:00
committed by lyingbug
parent 8dd9b67df9
commit 7bb38f81b6
8 changed files with 282 additions and 20 deletions

View File

@@ -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;
}

View File

@@ -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()
})
</script>

View File

@@ -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(

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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)