From 7d7d0e4b51d5f678214aeb6d27dfdd73928e9cc1 Mon Sep 17 00:00:00 2001 From: Acbox Liu Date: Sat, 21 Mar 2026 15:57:22 +0800 Subject: [PATCH] refactor: introduce multi-session chat support (#session) (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: introduce multi-session chat support (#session) Replace the single-context-per-bot model with multiple chat sessions. Database: - Add bot_sessions table (route_id, channel_type, title, metadata, soft delete) - Migrate bot_history_messages from (route_id, channel_type) to session_id - Add active_session_id to bot_channel_routes - Migration 0036 handles data migration from existing messages Backend: - New internal/session service for session CRUD - Update message service/types to use session_id instead of route_id - Update conversation flow (resolver, history, store) for session context - Channel inbound auto-creates/retrieves active session via SessionEnsurer - New REST endpoints: /bots/:bot_id/sessions (CRUD) - WebSocket and message handlers accept optional session_id - Wire session service into FX dependency graph (agent + memoh) Frontend: - Refactor chat store: sessions replaces chats, sessionId replaces chatId - Session-aware message loading, sending, and pagination - WebSocket sends include session_id - New session sidebar component with select/delete - Chat area header shows active session title + new session button - API layer updated: fetchSessions, createSession, deleteSession - i18n strings for session management (en + zh) SDK: - Regenerated TypeScript SDK and Swagger docs with session endpoints * fix: update tests for session refactoring (RouteID → SessionID) Remove references to removed RouteID and Platform fields from PersistInput/Message in channel_test.go and service_integration_test.go. * fix: restore accidentally deleted SDK files and guard migration 0032 - Restore packages/sdk/src/container-stream.ts and extra/index.ts that were accidentally removed during SDK regeneration - Wrap migration 0032 route_id index creation in a column existence check to avoid failure on fresh databases where 0001_init.up.sql no longer has route_id * fix: guard migration 0036 data steps for fresh databases Wrap steps 3-7 (which reference route_id/channel_type on bot_history_messages) in a column existence check so the migration is safe on fresh databases where 0001_init.up.sql already reflects the final schema without those columns. * feat: add title model setting and auto-generate session titles on user input - Add title_model_id to bots table (migration 0037) and bot settings API - Implement async title generation triggered at user message time (not after assistant response) for faster title availability - Publish session_title_updated events via SSE event hub for real-time frontend updates without page refresh - Fix SSE message event parsing: use direct JSON.parse instead of normalizeStreamEvent which silently dropped non-chat-stream event types - Add title model selector in bot settings UI with i18n support * fix: session-scoped message filtering and URL-based chat routing - Filter realtime SSE messages by session_id to prevent cross-session message leakage after page refresh - Add /chat/:sessionId? route with bidirectional URL ↔ store sync - Visiting /chat shows a clean state with no bot or session pre-selected - Visiting /chat/:sessionId loads the specific session directly - Session switches from sidebar automatically update the URL - Fix stale RouteID field in dedupe test (removed during session refactor) * fix: skip cross-channel stream events to prevent session leakage The bot-level web stream pushes events from all channels (Telegram, Discord, etc.) without session_id context. Previously these were rendered inline in the current chat view regardless of session. Now cross-channel events are ignored in handleLocalStreamEvent; persisted messages arrive via the SSE message events stream with proper session_id filtering through appendRealtimeMessage. * feat: show IM avatars and platform badges on session sidebar - Add sender_avatar_url to route metadata from identity resolution - Resolve group avatar and handle via directory adapter for group chats - JOIN bot_channel_routes in ListSessionsByBot to return route metadata - Display avatar with ChannelBadge on IM session items (group avatar for groups, sender avatar for private chats) - Show @groupname or @username as session sub-label * fix: clean up RunConfig unused fields, fix skill system and copy bug - Remove unused RunConfig fields: Tools, Channels, CurrentChannel, ActiveContextTime - Remove unused SessionContext fields: DisplayName, ConversationType - Fix EnabledSkillNames copy bug: make([]string, 0, n) + copy copies zero elements; changed to make([]string, n) - Fix prepareRunConfig dead code: remove no-op loop over CurrentPlatform runes; compute supportsImageInput from model's InputModalities - Fix EnabledSkills always nil in system prompt: resolve enabled skill entries from EnabledSkillNames + Skills - Fix use_skill tool returning empty response: now returns full skill content (description + instructions) so LLM gets it in the same turn - Skip use_skill tool registration when no skills are available - Conditionally render Skills section in system prompt (hidden when no skills exist) * feat: add session type field and bind sessions to heartbeat/schedule executions - Add `type` column to `bot_sessions` (chat | heartbeat | schedule) - Add `session_id` to `bot_heartbeat_logs` for per-execution session tracking - Create `schedule_logs` table binding schedule_id + session_id - Heartbeat and schedule runs now create independent sessions and persist agent messages via storeRound, enabling full conversation replay - Add schedule logs API endpoints (list by bot, list by schedule, delete) - Update Triggerer interfaces to return TriggerResult with status/usage/model * refactor: modular system prompts per session type (chat/heartbeat/schedule) Split the monolithic system.md into three type-specific system prompts with shared fragments via {{include:_xxx}} syntax, so each session type gets a focused prompt without irrelevant instructions. * fix: prevent message duplication after task completion message_created events from Persist() had an empty platform field because toMessageFromCreate() didn't extract it from the session. This caused appendRealtimeMessage to fail the platform === 'web' guard, and hasMessageWithId to fail because local IDs differ from server UUIDs, resulting in all messages being appended as duplicates. - Extract platform from metadata in toMessageFromCreate so published events carry the correct value - Pass channel_type: 'web' when creating sessions from the web frontend so List queries return the correct platform via the session JOIN * fix: use per-message usage from SDK instead of misaligned step-level usages Previously, token usage was stored via a separate per-step usages array that didn't align with messages (off-by-one from prepending user message, step count != message count). This caused: - User messages incorrectly receiving usage data - Usage values shifted across messages in multi-step rounds - Last assistant message getting the accumulated total instead of its own step usage - InputTokenDetails/OutputTokenDetails lost during manual accumulation Now each sdk.Message carries its own per-step Usage (set by the SDK in buildStepMessages), which is extracted in sdkMessagesToModelMessages and stored directly via ModelMessage.Usage. The storeRound/storeMessages path no longer needs external usage/usages parameters. Also fixes the totalUsage accumulation in runStream to include all detail fields (InputTokenDetails, OutputTokenDetails). * feat: add /new slash command to create a new active session from IM channels Users in Telegram/Discord/Feishu can now send /new to start a fresh conversation, resetting the session context for the current chat thread. The command resolves the channel route, creates a new session, sets it as the active session on the route, and replies with a confirmation message. * feat: distinguish heartbeat and schedule sessions with dedicated icons in sidebar Heartbeat sessions show a heart-pulse icon (rose), schedule sessions show a clock icon (amber), and both display a type label beneath the session title. * refactor: remove enabledSkills system prompt injection, keep sorted skill listing use_skill now returns skill content directly as tool output, so there is no need to inject enabled skill body text into the system prompt. Remove the entire enabledSkills tracking chain (RunConfig.EnabledSkillNames, StreamEvent.Skills, GenerateResult.Skills, ChatRequest/Response.Skills, enableSkill closures in runStream/runGenerate, prepareRunConfig matching). Keep a lightweight skills listing (name + description only) in the system prompt so the model knows which skills are available. Sort entries by name to guarantee deterministic ordering and maximize KV cache reuse. * refactor: remove inbox system, persist passive messages directly to history Replace the bot_inbox table and service with direct writes to bot_history_messages for group conversations where the bot is not @mentioned. Trigger-path messages continue to be persisted after the agent responds (unchanged). - Drop bot_inbox table and max_inbox_items column (migration 0039) - Delete internal/inbox/, handlers/inbox.go, command/inbox.go, agent/tools/inbox.go and the MCP message provider - Add persistPassiveMessage() in channel inbound to write user messages into the active session immediately - Rewrite ListObservedConversationsByChannelIdentity to query bot_history_messages + bot_sessions instead of bot_inbox - Extract shared send/react logic into internal/messaging/executor.go; agent/tools/message.go is now a thin SDK adapter - Clean up all inbox references from agent prompts, flow resolver, email trigger, settings, commands, DI wiring, and frontend - Regenerate sqlc, swagger, and SDK * feat: add list_sessions and search_messages agent tools Provide agents with the ability to query session metadata and search message history across all sessions. search_messages supports filtering by time range, keyword (JSONB-aware ILIKE), session, contact, and role, with a default 7-day lookback when no start_time is given. * feat: inject last_heartbeat time and improve heartbeat search guidance Query the previous heartbeat's started_at timestamp and pass it through TriggerPayload into the heartbeat prompt template. Update system prompt and HEARTBEAT.md checklist to guide agents to use search_messages with start_time=last_heartbeat for efficient cross-session message review. * fix: pass BridgeProvider to FSClient and store full heartbeat prompt FSClient was always created with nil provider, causing all container file reads (IDENTITY.md, SOUL.md, MEMORY.md, HEARTBEAT.md, etc.) to silently return empty strings. Expose Agent.BridgeProvider() and wire it into Resolver. Also fix heartbeat trigger to store the full prompt template as the user message instead of the literal "heartbeat" string. * feat: add line numbers to container file read output Move line-number formatting from the bridge gRPC server to the agent tool layer so that the raw content stored and transmitted via gRPC remains clean, while the read_file tool output includes numbered lines for easier reference by the agent. * chore(deps): update twilight-ai to v0.3.2 * fix: lint, test --- .../web/src/components/add-provider/index.vue | 5 +- .../web/src/components/create-model/index.vue | 12 +- .../components/form-dialog-shell/index.vue | 2 +- .../src/composables/api/useChat.chat-api.ts | 49 +- .../composables/api/useChat.message-api.ts | 19 +- apps/web/src/composables/api/useChat.types.ts | 19 +- apps/web/src/composables/api/useChat.ws.ts | 1 + .../src/composables/api/useContainerStream.ts | 23 +- apps/web/src/composables/useBotStatusMeta.ts | 2 +- apps/web/src/i18n/locales/en.json | 16 +- apps/web/src/i18n/locales/zh.json | 16 +- .../pages/bots/components/bot-container.vue | 2 +- .../src/pages/bots/components/bot-email.vue | 16 +- .../pages/bots/components/bot-settings.vue | 18 + .../components/add-browser-context.vue | 3 +- .../components/context-setting.vue | 8 +- .../src/pages/chat/components/bot-sidebar.vue | 4 - .../src/pages/chat/components/chat-area.vue | 35 +- .../pages/chat/components/session-sidebar.vue | 242 +++++ .../pages/chat/components/tool-call-block.vue | 5 - .../pages/chat/components/tool-call-inbox.vue | 118 --- apps/web/src/pages/chat/index.vue | 60 +- .../components/add-email-provider.vue | 3 +- .../components/provider-setting.vue | 22 +- .../components/add-memory-provider.vue | 3 +- apps/web/src/pages/memory-providers/index.vue | 3 +- .../pages/models/components/provider-form.vue | 1 - apps/web/src/pages/models/index.vue | 4 +- apps/web/src/pages/models/model-setting.vue | 4 +- .../components/add-search-provider.vue | 3 +- .../components/add-tts-provider.vue | 3 +- .../components/model-config-editor.vue | 27 +- .../components/provider-setting.vue | 22 +- apps/web/src/router.ts | 2 +- apps/web/src/store/chat-list.ts | 271 ++---- cmd/agent/main.go | 99 +- cmd/bridge/server.go | 9 +- cmd/bridge/template/HEARTBEAT.md | 18 +- cmd/memoh/serve.go | 97 +- db/migrations/0001_init.up.sql | 76 +- .../0032_source_aware_acl_scope.up.sql | 12 +- db/migrations/0036_chat_sessions.down.sql | 38 + db/migrations/0036_chat_sessions.up.sql | 112 +++ db/migrations/0037_title_model.down.sql | 5 + db/migrations/0037_title_model.up.sql | 5 + .../0038_session_type_and_logs.down.sql | 11 + .../0038_session_type_and_logs.up.sql | 39 + db/migrations/0039_drop_inbox.down.sql | 19 + db/migrations/0039_drop_inbox.up.sql | 6 + db/queries/bots.sql | 10 +- db/queries/channel_routes.sql | 9 + db/queries/conversations.sql | 4 + db/queries/heartbeat_logs.sql | 10 +- db/queries/inbox.sql | 61 -- db/queries/messages.sql | 231 ++++- db/queries/schedule_logs.sql | 37 + db/queries/sessions.sql | 72 ++ db/queries/settings.sql | 12 +- eslint.config.mjs | 10 + go.mod | 2 +- go.sum | 4 +- internal/acl/service.go | 6 +- internal/acl/service_test.go | 19 +- internal/agent/agent.go | 73 +- internal/agent/prompt.go | 150 +-- internal/agent/prompts/_contacts.md | 17 + internal/agent/prompts/_memory.md | 34 + internal/agent/prompts/_schedule_task.md | 5 + internal/agent/prompts/_subagent.md | 4 + internal/agent/prompts/_tools.md | 2 + internal/agent/prompts/heartbeat.md | 1 + internal/agent/prompts/system.md | 209 ----- internal/agent/prompts/system_chat.md | 91 ++ internal/agent/prompts/system_heartbeat.md | 63 ++ internal/agent/prompts/system_schedule.md | 33 + internal/agent/stream.go | 2 - internal/agent/tools/container.go | 16 +- internal/agent/tools/history.go | 335 +++++++ internal/agent/tools/inbox.go | 114 --- internal/agent/tools/message.go | 338 +------ internal/agent/tools/skill.go | 25 +- internal/agent/tools/types.go | 7 + internal/agent/types.go | 19 +- internal/bots/service.go | 10 +- internal/bots/service_test.go | 31 +- internal/channel/inbound/channel.go | 302 ++++-- internal/channel/inbound/channel_test.go | 35 +- internal/command/commands.go | 1 - internal/command/handler.go | 22 +- internal/command/handler_test.go | 10 +- internal/command/inbox.go | 58 -- internal/command/registry.go | 3 +- internal/command/settings.go | 1 - internal/conversation/flow/resolver.go | 77 +- .../conversation/flow/resolver_dedupe_test.go | 1 - .../conversation/flow/resolver_history.go | 19 +- .../conversation/flow/resolver_messages.go | 5 + .../conversation/flow/resolver_settings.go | 9 - internal/conversation/flow/resolver_store.go | 29 +- internal/conversation/flow/resolver_stream.go | 15 +- internal/conversation/flow/resolver_title.go | 159 ++++ .../conversation/flow/resolver_trigger.go | 63 +- internal/conversation/flow/resolver_util.go | 24 - .../conversation/flow/schedule_gateway.go | 4 +- internal/conversation/flow/types.go | 2 +- internal/conversation/flow/user_header.go | 9 +- .../conversation/service_integration_test.go | 1 - internal/conversation/types.go | 4 +- internal/db/sqlc/bots.sql.go | 20 +- internal/db/sqlc/channel_routes.sql.go | 28 + internal/db/sqlc/conversations.sql.go | 6 +- internal/db/sqlc/heartbeat_logs.sql.go | 24 +- internal/db/sqlc/inbox.sql.go | 301 ------ internal/db/sqlc/messages.sql.go | 652 +++++++++++-- internal/db/sqlc/models.go | 42 +- internal/db/sqlc/schedule_logs.sql.go | 238 +++++ internal/db/sqlc/sessions.sql.go | 305 ++++++ internal/db/sqlc/settings.sql.go | 40 +- internal/email/trigger.go | 33 +- internal/handlers/file_embed.go | 3 +- internal/handlers/inbox.go | 268 ------ internal/handlers/local_channel.go | 2 + internal/handlers/message.go | 69 +- internal/handlers/schedule.go | 117 +++ internal/handlers/session.go | 243 +++++ internal/heartbeat/service.go | 71 +- internal/heartbeat/trigger.go | 9 +- internal/heartbeat/types.go | 1 + internal/inbox/service.go | 309 ------ internal/mcp/providers/message/provider.go | 555 ----------- internal/message/event/hub.go | 2 + internal/message/service.go | 315 ++++++- internal/message/types.go | 11 +- internal/messaging/executor.go | 420 +++++++++ internal/schedule/service.go | 236 ++++- internal/schedule/service_integration_test.go | 6 +- internal/schedule/trigger.go | 11 +- internal/schedule/types.go | 17 + internal/session/service.go | 326 +++++++ internal/settings/service.go | 33 +- internal/settings/types.go | 5 +- packages/cli/src/cli/bot.ts | 6 +- packages/cli/src/cli/index.ts | 3 +- packages/sdk/package.json | 3 +- packages/sdk/src/@pinia/colada.gen.ts | 239 ++--- packages/sdk/src/extra/index.ts | 10 - packages/sdk/src/index.ts | 4 +- packages/sdk/src/sdk.gen.ts | 118 +-- packages/sdk/src/types.gen.ts | 649 +++++++------ spec/docs.go | 877 ++++++++++-------- spec/swagger.json | 877 ++++++++++-------- spec/swagger.yaml | 584 +++++++----- 152 files changed, 7674 insertions(+), 4922 deletions(-) rename packages/sdk/src/container-stream.ts => apps/web/src/composables/api/useContainerStream.ts (83%) create mode 100644 apps/web/src/pages/chat/components/session-sidebar.vue delete mode 100644 apps/web/src/pages/chat/components/tool-call-inbox.vue create mode 100644 db/migrations/0036_chat_sessions.down.sql create mode 100644 db/migrations/0036_chat_sessions.up.sql create mode 100644 db/migrations/0037_title_model.down.sql create mode 100644 db/migrations/0037_title_model.up.sql create mode 100644 db/migrations/0038_session_type_and_logs.down.sql create mode 100644 db/migrations/0038_session_type_and_logs.up.sql create mode 100644 db/migrations/0039_drop_inbox.down.sql create mode 100644 db/migrations/0039_drop_inbox.up.sql delete mode 100644 db/queries/inbox.sql create mode 100644 db/queries/schedule_logs.sql create mode 100644 db/queries/sessions.sql create mode 100644 internal/agent/prompts/_contacts.md create mode 100644 internal/agent/prompts/_memory.md create mode 100644 internal/agent/prompts/_schedule_task.md create mode 100644 internal/agent/prompts/_subagent.md create mode 100644 internal/agent/prompts/_tools.md delete mode 100644 internal/agent/prompts/system.md create mode 100644 internal/agent/prompts/system_chat.md create mode 100644 internal/agent/prompts/system_heartbeat.md create mode 100644 internal/agent/prompts/system_schedule.md create mode 100644 internal/agent/tools/history.go delete mode 100644 internal/agent/tools/inbox.go delete mode 100644 internal/command/inbox.go create mode 100644 internal/conversation/flow/resolver_title.go delete mode 100644 internal/db/sqlc/inbox.sql.go create mode 100644 internal/db/sqlc/schedule_logs.sql.go create mode 100644 internal/db/sqlc/sessions.sql.go delete mode 100644 internal/handlers/inbox.go create mode 100644 internal/handlers/session.go delete mode 100644 internal/inbox/service.go delete mode 100644 internal/mcp/providers/message/provider.go create mode 100644 internal/messaging/executor.go create mode 100644 internal/session/service.go delete mode 100644 packages/sdk/src/extra/index.ts diff --git a/apps/web/src/components/add-provider/index.vue b/apps/web/src/components/add-provider/index.vue index f1985d29..e8ddf90a 100644 --- a/apps/web/src/components/add-provider/index.vue +++ b/apps/web/src/components/add-provider/index.vue @@ -155,9 +155,10 @@ import { } from '@memoh/ui' import { toTypedSchema } from '@vee-validate/zod' import z from 'zod' -import { useForm,Form,Field } from 'vee-validate' +import { useForm } from 'vee-validate' import { useMutation, useQueryCache } from '@pinia/colada' import { postProviders, postProvidersByIdImportModels } from '@memoh/sdk' +import type { ProvidersCreateRequest } from '@memoh/sdk' import { useI18n } from 'vue-i18n' import FormDialogShell from '@/components/form-dialog-shell/index.vue' import { useDialogMutation } from '@/composables/useDialogMutation' @@ -176,7 +177,7 @@ const { mutateAsync: createProviderMutation, isLoading } = useMutation({ ...data, metadata: { additionalProp1: {} }, } - const { data: result } = await postProviders({ body: payload as any, throwOnError: true }) + const { data: result } = await postProviders({ body: payload as ProvidersCreateRequest, throwOnError: true }) if (data.auto_import && result?.id) { try { const { data: importResult } = await postProvidersByIdImportModels({ diff --git a/apps/web/src/components/create-model/index.vue b/apps/web/src/components/create-model/index.vue index 27d7a383..10e01631 100644 --- a/apps/web/src/components/create-model/index.vue +++ b/apps/web/src/components/create-model/index.vue @@ -202,7 +202,7 @@ import { toTypedSchema } from '@vee-validate/zod' import z from 'zod' import { useMutation, useQueryCache } from '@pinia/colada' import { postModels, putModelsById, putModelsModelByModelId } from '@memoh/sdk' -import type { ModelsGetResponse } from '@memoh/sdk' +import type { ModelsGetResponse, ModelsAddRequest, ModelsUpdateRequest } from '@memoh/sdk' import { useI18n } from 'vue-i18n' import { CLIENT_TYPE_LIST, CLIENT_TYPE_META } from '@/constants/client-types' import FormDialogShell from '@/components/form-dialog-shell/index.vue' @@ -288,7 +288,7 @@ const { id } = defineProps<{ id: string }>() const queryCache = useQueryCache() const { mutateAsync: createModel, isLoading: createLoading } = useMutation({ mutation: async (data: Record) => { - const { data: result } = await postModels({ body: data as any, throwOnError: true }) + const { data: result } = await postModels({ body: data as ModelsAddRequest, throwOnError: true }) return result }, onSettled: () => queryCache.invalidateQueries({ key: ['provider-models'] }), @@ -297,7 +297,7 @@ const { mutateAsync: updateModel, isLoading: updateLoading } = useMutation({ mutation: async ({ id, data }: { id: string; data: Record }) => { const { data: result } = await putModelsById({ path: { id }, - body: data as any, + body: data as ModelsUpdateRequest, throwOnError: true, }) return result @@ -308,7 +308,7 @@ const { mutateAsync: updateModelByLegacyModelID, isLoading: updateLegacyLoading mutation: async ({ modelId, data }: { modelId: string; data: Record }) => { const { data: result } = await putModelsModelByModelId({ path: { modelId }, - body: data as any, + body: data as ModelsUpdateRequest, throwOnError: true, }) return result @@ -361,9 +361,9 @@ async function addModel() { if (isEdit) { const modelUUID = fallback?.id if (modelUUID) { - return updateModel({ id: modelUUID, data: payload as any }) + return updateModel({ id: modelUUID, data: payload as ModelsUpdateRequest }) } - return updateModelByLegacyModelID({ modelId: fallback!.model_id, data: payload as any }) + return updateModelByLegacyModelID({ modelId: fallback!.model_id, data: payload as ModelsUpdateRequest }) } return createModel(payload) }, diff --git a/apps/web/src/components/form-dialog-shell/index.vue b/apps/web/src/components/form-dialog-shell/index.vue index b684644e..3e213dd6 100644 --- a/apps/web/src/components/form-dialog-shell/index.vue +++ b/apps/web/src/components/form-dialog-shell/index.vue @@ -55,7 +55,7 @@ import { DialogTrigger, Spinner } from '@memoh/ui' -import { Form, Field } from 'vee-validate' + withDefaults(defineProps<{ title: string diff --git a/apps/web/src/composables/api/useChat.chat-api.ts b/apps/web/src/composables/api/useChat.chat-api.ts index 5fb3886a..54569817 100644 --- a/apps/web/src/composables/api/useChat.chat-api.ts +++ b/apps/web/src/composables/api/useChat.chat-api.ts @@ -1,27 +1,56 @@ -import { getBots, deleteBotsByBotIdMessages } from '@memoh/sdk' -import type { Bot, ChatSummary } from './useChat.types' +import { + getBots, + deleteBotsByBotIdMessages, + getBotsByBotIdSessions, + postBotsByBotIdSessions, + deleteBotsByBotIdSessionsBySessionId, + patchBotsByBotIdSessionsBySessionId, +} from '@memoh/sdk' +import type { Bot, SessionSummary } from './useChat.types' export async function fetchBots(): Promise { const { data } = await getBots({ throwOnError: true }) return data?.items ?? [] } -export async function fetchChats(botId: string): Promise { +export async function fetchSessions(botId: string): Promise { const id = botId.trim() if (!id) return [] - return [{ id, bot_id: id, kind: 'bot' }] + const { data } = await getBotsByBotIdSessions({ + path: { bot_id: id }, + throwOnError: true, + }) + return (data as Record)?.items as SessionSummary[] ?? [] } -export async function createChat(botId: string): Promise { +export async function createSession(botId: string, title?: string): Promise { const id = botId.trim() if (!id) throw new Error('bot id is required') - return { id, bot_id: id, kind: 'bot' } + const { data } = await postBotsByBotIdSessions({ + path: { bot_id: id }, + body: { title: title ?? '', channel_type: 'web' }, + throwOnError: true, + }) + return data as SessionSummary } -export async function deleteChat(botId: string, chatId: string): Promise { - if (botId.trim() !== chatId.trim()) { - throw new Error('chat id must match bot id') - } +export async function updateSessionTitle(botId: string, sessionId: string, title: string): Promise { + const { data } = await patchBotsByBotIdSessionsBySessionId({ + path: { bot_id: botId.trim(), session_id: sessionId.trim() }, + body: { title }, + throwOnError: true, + }) + return data as SessionSummary +} + +export async function deleteSession(botId: string, sessionId: string): Promise { + await deleteBotsByBotIdSessionsBySessionId({ + path: { bot_id: botId.trim(), session_id: sessionId.trim() }, + throwOnError: true, + }) +} + +export async function deleteAllMessages(botId: string): Promise { await deleteBotsByBotIdMessages({ path: { bot_id: botId }, throwOnError: true, diff --git a/apps/web/src/composables/api/useChat.message-api.ts b/apps/web/src/composables/api/useChat.message-api.ts index 2887ada3..16f51c23 100644 --- a/apps/web/src/composables/api/useChat.message-api.ts +++ b/apps/web/src/composables/api/useChat.message-api.ts @@ -12,18 +12,15 @@ import { parseStreamPayload, readSSEStream } from './useChat.sse' export async function fetchMessages( botId: string, - chatId: string, + sessionId?: string, options?: FetchMessagesOptions, ): Promise { - if (botId.trim() !== chatId.trim()) { - throw new Error('chat id must match bot id') - } - const { data } = await getBotsByBotIdMessages({ path: { bot_id: botId }, query: { limit: options?.limit ?? 30, ...(options?.before?.trim() ? { before: options.before.trim() } : {}), + ...(sessionId?.trim() ? { session_id: sessionId.trim() } : {}), }, throwOnError: true, }) @@ -106,9 +103,13 @@ export async function streamMessageEvents( if (!body) throw new Error('No response body') await readSSEStream(body, (payload) => { - const parsed = parseStreamPayload(payload) - if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) return - if (typeof parsed.type !== 'string' || !parsed.type.trim()) return - onEvent(parsed as MessageStreamEvent) + try { + const parsed = JSON.parse(payload) + if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) return + if (typeof parsed.type !== 'string' || !parsed.type.trim()) return + onEvent(parsed as MessageStreamEvent) + } catch { + // Ignore unparsable payloads + } }) } diff --git a/apps/web/src/composables/api/useChat.types.ts b/apps/web/src/composables/api/useChat.types.ts index 162a046a..f7c32e75 100644 --- a/apps/web/src/composables/api/useChat.types.ts +++ b/apps/web/src/composables/api/useChat.types.ts @@ -2,16 +2,18 @@ import type { BotsBot } from '@memoh/sdk' export type Bot = BotsBot -export interface ChatSummary { +export interface SessionSummary { id: string bot_id: string - kind: string - title?: string + route_id?: string + channel_type?: string + type?: string + title: string + metadata?: Record created_at?: string updated_at?: string - access_mode?: 'participant' | 'channel_identity_observed' - participant_role?: string - last_observed_at?: string + route_metadata?: Record + route_conversation_type?: string } export interface MessageAsset { @@ -28,7 +30,7 @@ export interface MessageAsset { export interface Message { id: string bot_id: string - route_id?: string + session_id?: string sender_channel_identity_id?: string sender_user_id?: string sender_display_name?: string @@ -69,11 +71,14 @@ export interface MessageStreamEvent { type: string bot_id?: string message?: Message + session_id?: string + title?: string } export interface FetchMessagesOptions { limit?: number before?: string + session_id?: string } export interface ChatAttachment { diff --git a/apps/web/src/composables/api/useChat.ws.ts b/apps/web/src/composables/api/useChat.ws.ts index 0cfdc725..5a458737 100644 --- a/apps/web/src/composables/api/useChat.ws.ts +++ b/apps/web/src/composables/api/useChat.ws.ts @@ -4,6 +4,7 @@ import type { StreamEvent, MessageStreamEvent, ChatAttachment, StreamEventHandle export interface WSClientMessage { type: 'message' | 'abort' text?: string + session_id?: string attachments?: ChatAttachment[] } diff --git a/packages/sdk/src/container-stream.ts b/apps/web/src/composables/api/useContainerStream.ts similarity index 83% rename from packages/sdk/src/container-stream.ts rename to apps/web/src/composables/api/useContainerStream.ts index 70aae99a..95045915 100644 --- a/packages/sdk/src/container-stream.ts +++ b/apps/web/src/composables/api/useContainerStream.ts @@ -1,14 +1,9 @@ -import { mergeHeaders } from './client' -import { client } from './client.gen' -import type { Options } from './sdk.gen' +import { client } from '@memoh/sdk/client' +import type { Options } from '@memoh/sdk' import type { HandlersCreateContainerResponse, PostBotsByBotIdContainerData, -} from './types.gen' - -// Handwritten SDK supplement for container-create SSE. -// Re-export this module via @memoh/sdk/extra instead of the generated root entry, -// because packages/sdk/src/index.ts is regenerated from OpenAPI. +} from '@memoh/sdk' export type ContainerCreateLayerStatus = { ref: string @@ -65,18 +60,20 @@ function toError(error: unknown): Error { return new Error('Container create stream failed') } -export async function postBotsByBotIdContainerStream( - options: Options, +export async function postBotsByBotIdContainerStream( + options: Options, ): Promise { let streamError: unknown + const { throwOnError: _throwOnError, ...rest } = options const result = await client.sse.post({ url: '/bots/{bot_id}/container', - ...options, - headers: mergeHeaders(options.headers, { + ...rest, + headers: { + ...options.headers as Record, Accept: 'text/event-stream', 'Content-Type': 'application/json', - }), + }, onSseError: (error) => { streamError = error }, diff --git a/apps/web/src/composables/useBotStatusMeta.ts b/apps/web/src/composables/useBotStatusMeta.ts index 7d0f31fb..bde16f89 100644 --- a/apps/web/src/composables/useBotStatusMeta.ts +++ b/apps/web/src/composables/useBotStatusMeta.ts @@ -9,7 +9,7 @@ interface BotStatusSource { export function useBotStatusMeta( bot: Ref, - t: (...args: any[]) => string, + t: (key: string, named?: Record) => string, ) { const isCreating = computed(() => bot.value?.status === 'creating') const isDeleting = computed(() => bot.value?.status === 'deleting') diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 5439e3af..b30a3de1 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -147,14 +147,23 @@ "toolExecError": "Error", "toolScheduleItems": "{count} items", "toolMemoryResults": "{count} memories", - "toolInboxResults": "{count} messages", "toolWebFetchPreview": "Preview", "toolContactsCount": "{count} contacts", "toolEmailCount": "{count} emails", "toolEmailAccounts": "{count} accounts", "toolSubagentCount": "{count} subagents", "unknownUser": "{platform} User", - "files": "Files" + "files": "Files", + "sessions": "Sessions", + "newSession": "New Session", + "deleteSession": "Delete Session", + "deleteSessionConfirm": "Are you sure you want to delete this session?", + "renameSession": "Rename Session", + "sessionTitle": "Session", + "untitledSession": "Untitled Session", + "noSessions": "No sessions yet", + "sessionTypeHeartbeat": "Heartbeat", + "sessionTypeSchedule": "Scheduled Task" }, "models": { "title": "Models", @@ -723,6 +732,9 @@ }, "settings": { "chatModel": "Chat Model", + "titleModel": "Title Model", + "titleModelDescription": "Select a small model to auto-generate session titles. Leave empty to disable.", + "titleModelPlaceholder": "Disabled (no auto title)", "searchProvider": "Search Provider", "searchProviderPlaceholder": "Select search provider", "memoryProvider": "Memory Provider", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index a4b95216..c49b92a3 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -143,14 +143,23 @@ "toolExecError": "错误", "toolScheduleItems": "{count} 条", "toolMemoryResults": "{count} 条记忆", - "toolInboxResults": "{count} 条消息", "toolWebFetchPreview": "预览", "toolContactsCount": "{count} 个联系人", "toolEmailCount": "{count} 封邮件", "toolEmailAccounts": "{count} 个账户", "toolSubagentCount": "{count} 个子代理", "unknownUser": "{platform}用户", - "files": "文件管理" + "files": "文件管理", + "sessions": "会话", + "newSession": "新建会话", + "deleteSession": "删除会话", + "deleteSessionConfirm": "确定要删除此会话吗?", + "renameSession": "重命名会话", + "sessionTitle": "会话", + "untitledSession": "未命名会话", + "noSessions": "暂无会话", + "sessionTypeHeartbeat": "心跳", + "sessionTypeSchedule": "定时任务" }, "models": { "title": "模型", @@ -719,6 +728,9 @@ }, "settings": { "chatModel": "对话模型", + "titleModel": "标题模型", + "titleModelDescription": "选择一个小模型来自动生成会话标题。留空则禁用自动标题生成。", + "titleModelPlaceholder": "未启用(不自动生成标题)", "searchProvider": "搜索提供方", "searchProviderPlaceholder": "选择搜索提供方", "memoryProvider": "记忆提供方", diff --git a/apps/web/src/pages/bots/components/bot-container.vue b/apps/web/src/pages/bots/components/bot-container.vue index e0bcee37..1b99ca17 100644 --- a/apps/web/src/pages/bots/components/bot-container.vue +++ b/apps/web/src/pages/bots/components/bot-container.vue @@ -24,7 +24,7 @@ import { postBotsByBotIdContainerStream, type ContainerCreateLayerStatus, type ContainerCreateStreamEvent, -} from '@memoh/sdk/extra' +} from '@/composables/api/useContainerStream' import { Button, Input, Label, Separator, Spinner, Switch } from '@memoh/ui' import ConfirmPopover from '@/components/confirm-popover/index.vue' import ContainerCreateProgress from './container-create-progress.vue' diff --git a/apps/web/src/pages/bots/components/bot-email.vue b/apps/web/src/pages/bots/components/bot-email.vue index 6c3bf3cd..7e693498 100644 --- a/apps/web/src/pages/bots/components/bot-email.vue +++ b/apps/web/src/pages/bots/components/bot-email.vue @@ -273,7 +273,7 @@ async function loadOutbox() { query: { limit: 50, offset: 0 }, throwOnError: true, }) - outboxItems.value = (data as any)?.items ?? [] + outboxItems.value = (data as Record)?.items as typeof outboxItems.value ?? [] } finally { outboxLoading.value = false } @@ -282,7 +282,7 @@ async function loadOutbox() { async function handleAddBinding(provider: EmailProviderResponse) { addingBinding.value = true addingProviderId.value = provider.id! - const emailAddr = (provider.config as any)?.username || provider.name || '' + const emailAddr = (provider.config as Record)?.username as string || provider.name || '' try { await postBotsByBotIdEmailBindings({ path: { bot_id: props.botId }, @@ -297,8 +297,8 @@ async function handleAddBinding(provider: EmailProviderResponse) { }) await loadBindings() toast.success(t('bots.email.bindSuccess')) - } catch (e: any) { - toast.error(e?.message || t('common.saveFailed')) + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : t('common.saveFailed')) } finally { addingBinding.value = false addingProviderId.value = '' @@ -313,8 +313,8 @@ async function handleTogglePerm(binding: EmailBindingResponse, field: string, va throwOnError: true, }) await loadBindings() - } catch (e: any) { - toast.error(e?.message || t('common.saveFailed')) + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : t('common.saveFailed')) } } @@ -327,8 +327,8 @@ async function handleDeleteBinding(id: string) { }) await loadBindings() toast.success(t('bots.email.unbindSuccess')) - } catch (e: any) { - toast.error(e?.message || t('common.saveFailed')) + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : t('common.saveFailed')) } finally { deletingId.value = '' } diff --git a/apps/web/src/pages/bots/components/bot-settings.vue b/apps/web/src/pages/bots/components/bot-settings.vue index 480ea25e..a9a1c66e 100644 --- a/apps/web/src/pages/bots/components/bot-settings.vue +++ b/apps/web/src/pages/bots/components/bot-settings.vue @@ -12,6 +12,21 @@ /> + +
+ +

+ {{ $t('bots.settings.titleModelDescription') }} +

+ +
+
@@ -450,6 +465,7 @@ const browserContexts = computed(() => browserContextData.value ?? []) // ---- Form ---- const form = reactive({ chat_model_id: '', + title_model_id: '', search_provider_id: '', memory_provider_id: '', tts_model_id: '', @@ -560,6 +576,7 @@ const encoderHealthLabel = computed(() => watch(settings, (val) => { if (val) { form.chat_model_id = val.chat_model_id ?? '' + form.title_model_id = val.title_model_id ?? '' form.search_provider_id = val.search_provider_id ?? '' form.memory_provider_id = val.memory_provider_id ?? '' form.tts_model_id = val.tts_model_id ?? '' @@ -577,6 +594,7 @@ const hasChanges = computed(() => { const s = settings.value let changed = form.chat_model_id !== (s.chat_model_id ?? '') + || form.title_model_id !== (s.title_model_id ?? '') || form.search_provider_id !== (s.search_provider_id ?? '') || form.memory_provider_id !== (s.memory_provider_id ?? '') || form.tts_model_id !== (s.tts_model_id ?? '') diff --git a/apps/web/src/pages/browser-contexts/components/add-browser-context.vue b/apps/web/src/pages/browser-contexts/components/add-browser-context.vue index 8247e785..4e37a3bc 100644 --- a/apps/web/src/pages/browser-contexts/components/add-browser-context.vue +++ b/apps/web/src/pages/browser-contexts/components/add-browser-context.vue @@ -60,6 +60,7 @@ import z from 'zod' import { useForm } from 'vee-validate' import { useMutation, useQueryCache } from '@pinia/colada' import { postBrowserContexts } from '@memoh/sdk' +import type { BrowsercontextsCreateRequest } from '@memoh/sdk' import { useI18n } from 'vue-i18n' import FormDialogShell from '@/components/form-dialog-shell/index.vue' import { useDialogMutation } from '@/composables/useDialogMutation' @@ -72,7 +73,7 @@ const queryCache = useQueryCache() const { mutateAsync: createMutation, isLoading } = useMutation({ mutation: async (data: { name: string }) => { const { data: result } = await postBrowserContexts({ - body: { name: data.name, config: {} } as any, + body: { name: data.name } as BrowsercontextsCreateRequest, throwOnError: true, }) return result diff --git a/apps/web/src/pages/browser-contexts/components/context-setting.vue b/apps/web/src/pages/browser-contexts/components/context-setting.vue index d628bd55..008109e1 100644 --- a/apps/web/src/pages/browser-contexts/components/context-setting.vue +++ b/apps/web/src/pages/browser-contexts/components/context-setting.vue @@ -240,7 +240,7 @@ import { useForm } from 'vee-validate' import { useMutation, useQuery, useQueryCache } from '@pinia/colada' import { putBrowserContextsById, deleteBrowserContextsById } from '@memoh/sdk' import { getBrowserContextsCoresQuery } from '@memoh/sdk/colada' -import type { BrowsercontextsBrowserContext } from '@memoh/sdk' +import type { BrowsercontextsBrowserContext, BrowsercontextsUpdateRequest } from '@memoh/sdk' import { inject, watch, computed, type Ref } from 'vue' import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' @@ -314,10 +314,10 @@ watch(() => curContext?.value, (ctx) => { }, { immediate: true }) const { mutateAsync: updateMutation, isLoading: isSaving } = useMutation({ - mutation: async (data: { id: string; name: string; config: any }) => { + mutation: async (data: { id: string; name: string; config: Record }) => { const { data: result } = await putBrowserContextsById({ path: { id: data.id }, - body: { name: data.name, config: data.config } as any, + body: { name: data.name } as BrowsercontextsUpdateRequest, throwOnError: true, }) return result @@ -339,7 +339,7 @@ const handleSave = form.handleSubmit(async (values) => { const id = curContext?.value?.id if (!id) return - const config: Record = { + const config: Record = { core: values.core ?? 'chromium', } if (values.viewportWidth || values.viewportHeight) { diff --git a/apps/web/src/pages/chat/components/bot-sidebar.vue b/apps/web/src/pages/chat/components/bot-sidebar.vue index 68bb024c..b11044be 100644 --- a/apps/web/src/pages/chat/components/bot-sidebar.vue +++ b/apps/web/src/pages/chat/components/bot-sidebar.vue @@ -54,7 +54,6 @@ diff --git a/apps/web/src/pages/chat/components/tool-call-block.vue b/apps/web/src/pages/chat/components/tool-call-block.vue index 98f32d59..13ffca23 100644 --- a/apps/web/src/pages/chat/components/tool-call-block.vue +++ b/apps/web/src/pages/chat/components/tool-call-block.vue @@ -31,10 +31,6 @@ v-else-if="block.toolName === 'search_memory'" :block="block" /> - -
-
- - - - {{ query }} - - - search_inbox - - - {{ $t('chat.toolInboxResults', { count: results.length }) }} - - - {{ $t('chat.toolDone') }} - - - {{ $t('chat.toolRunning') }} - -
- - - - - {{ $t('chat.toolSearchResultsLabel') }} - - -
-
-
- - {{ item.header }} - - - {{ item.created_at }} - -
- - {{ item.content }} - -
-
-
-
-
- - - diff --git a/apps/web/src/pages/chat/index.vue b/apps/web/src/pages/chat/index.vue index 3b5fa989..9fae8da4 100644 --- a/apps/web/src/pages/chat/index.vue +++ b/apps/web/src/pages/chat/index.vue @@ -8,8 +8,27 @@

-