mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02:00
* feat(chat): LLM-generated chat session titles with silent fallback (MUL-4295) Generate a concise, language-matched title for a chat session after the first user message, replacing the raw first-message-derived title. The work is best-effort and fully non-blocking: - Triggered on the first user message in SendChatMessage (detected via ChatSessionHasUserMessage before insert), run in a detached goroutine so it never delays the send or first response. - Reuses pkg/llm GenerateText on the configured default model (MULTICA_LLM_DEFAULT_MODEL, else gpt-4o-mini); no model from the client. - Self-hosted with no LLM key (h.LLM.Enabled()==false): silent no-op, the original title stands. Same on timeout / upstream error. - CAS write (UpdateChatSessionTitleIfCurrent) so a manual rename during generation is never clobbered and titling runs at most once. - Pushes chat:session_updated so the frontend refreshes in place. - sanitizeChatTitle strips quotes/brackets, 'Title:'/'标题:' prefixes, trailing punctuation, and caps at chatSessionTitleMaxLen. Tests cover all six cases: configured→semantic title, disabled→fallback, upstream error→fallback, manual rename→no clobber, empty output→fallback, idempotent second run, plus sanitize rules and the realtime push. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): panic-contain title goroutine + loop sanitizer to a fixed point (MUL-4295) Address PR #5141 review (张大彪 / multica-eve, Phase B): 1. The detached title-generation goroutine now has a defer recover() at the top of its body. It runs outside chi's Recoverer, so an unhandled panic in GenerateText / sanitize / the DB write / publish would crash the server process. Best-effort path: log and keep the original title. 2. sanitizeChatTitle now alternates prefix-stripping and wrapper-stripping in a loop until the string is stable, so a forbidden label hidden inside a wrapper ("Title: Fix login", 「标题:修复登录问题」) is fully cleaned regardless of nesting order. Added both cases to the sanitize test table. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): fold trailing-punctuation trim into sanitizer fixed-point loop (MUL-4295) Address PR #5141 follow-up review: the trailing-punctuation trim ran once AFTER the prefix/wrapper loop, so a trailing '.' / '。' left the closing wrapper unrecognized and the forbidden prefix untouched for inputs like "Title: Fix login". and 「标题:修复登录问题」。. Trailing trim now runs inside the same loop, so removing the trailing punctuation re-exposes the wrapper (and the prefix it hid) on the next pass. Added both cases to TestSanitizeChatTitle. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
290 lines
12 KiB
SQL
290 lines
12 KiB
SQL
-- name: CreateChatSession :one
|
|
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, runtime_id, is_agent_intro)
|
|
VALUES ($1, $2, $3, $4, (SELECT runtime_id FROM agent WHERE id = $2), $5)
|
|
RETURNING *;
|
|
|
|
-- name: GetChatSession :one
|
|
SELECT * FROM chat_session
|
|
WHERE id = $1;
|
|
|
|
-- name: GetChatSessionInWorkspace :one
|
|
SELECT * FROM chat_session
|
|
WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: ListChatSessionsByCreator :many
|
|
-- IM-style list: each active session with its unread *count* (assistant
|
|
-- messages after the read cursor), a preview of the latest message, and
|
|
-- ordered by most-recent activity so a new reply bumps a session to the top.
|
|
SELECT cs.*,
|
|
(SELECT count(*) FROM chat_message m
|
|
WHERE m.chat_session_id = cs.id
|
|
AND m.role = 'assistant'
|
|
AND m.created_at > cs.last_read_at)::int AS unread_count,
|
|
COALESCE(lm.content, '') AS last_message_content,
|
|
COALESCE(lm.role, '') AS last_message_role,
|
|
lm.created_at AS last_message_at,
|
|
lm.failure_reason AS last_message_failure_reason
|
|
FROM chat_session cs
|
|
LEFT JOIN LATERAL (
|
|
SELECT content, role, created_at, failure_reason
|
|
FROM chat_message m
|
|
WHERE m.chat_session_id = cs.id
|
|
ORDER BY m.created_at DESC
|
|
LIMIT 1
|
|
) lm ON true
|
|
WHERE cs.workspace_id = $1 AND cs.creator_id = $2 AND cs.status = 'active'
|
|
ORDER BY (cs.pinned_at IS NOT NULL) DESC, cs.pinned_at DESC, COALESCE(lm.created_at, cs.updated_at) DESC;
|
|
|
|
-- name: ListAllChatSessionsByCreator :many
|
|
SELECT cs.*,
|
|
(SELECT count(*) FROM chat_message m
|
|
WHERE m.chat_session_id = cs.id
|
|
AND m.role = 'assistant'
|
|
AND m.created_at > cs.last_read_at)::int AS unread_count,
|
|
COALESCE(lm.content, '') AS last_message_content,
|
|
COALESCE(lm.role, '') AS last_message_role,
|
|
lm.created_at AS last_message_at,
|
|
lm.failure_reason AS last_message_failure_reason
|
|
FROM chat_session cs
|
|
LEFT JOIN LATERAL (
|
|
SELECT content, role, created_at, failure_reason
|
|
FROM chat_message m
|
|
WHERE m.chat_session_id = cs.id
|
|
ORDER BY m.created_at DESC
|
|
LIMIT 1
|
|
) lm ON true
|
|
WHERE cs.workspace_id = $1 AND cs.creator_id = $2
|
|
ORDER BY (cs.pinned_at IS NOT NULL) DESC, cs.pinned_at DESC, COALESCE(lm.created_at, cs.updated_at) DESC;
|
|
|
|
-- name: UpdateChatSessionTitle :one
|
|
UPDATE chat_session SET title = $2, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateChatSessionTitleIfCurrent :one
|
|
-- Compare-and-swap the title: only overwrite it when it still equals the
|
|
-- value the caller observed (@expected_title). This is the idempotency /
|
|
-- no-clobber guard behind LLM auto-titling (MUL-4295): the async generator
|
|
-- captures the session's current (default/original) title before calling the
|
|
-- model, and this write lands only if a manual rename or a competing writer
|
|
-- has not changed the title in the meantime. A mismatch returns pgx.ErrNoRows
|
|
-- (zero rows updated), which the caller treats as "someone renamed it — leave
|
|
-- it alone", NOT as an error.
|
|
UPDATE chat_session SET title = @new_title, updated_at = now()
|
|
WHERE id = @id AND title = @expected_title
|
|
RETURNING *;
|
|
|
|
-- name: SetChatSessionPinned :one
|
|
-- Pin/unpin a chat. Deliberately does NOT touch updated_at: pinning is a
|
|
-- list-ordering preference, not activity, so it must not bump the session's
|
|
-- last-activity sort key (which would make an unpinned chat jump the list).
|
|
-- pinned = true stamps pinned_at only when it was NULL, so re-pinning keeps
|
|
-- the original pin order; pinned = false clears it.
|
|
UPDATE chat_session
|
|
SET pinned_at = CASE WHEN @pinned::bool THEN COALESCE(pinned_at, now()) ELSE NULL END
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: SetChatSessionArchived :one
|
|
-- Archive/unarchive a chat session by flipping status between 'active' and
|
|
-- 'archived'. Bumps updated_at so the row re-sorts on the receiving list. The
|
|
-- send-message path refuses archived sessions (see SendChatMessage), so the
|
|
-- conversation is effectively read-only until it is unarchived.
|
|
UPDATE chat_session
|
|
SET status = CASE WHEN @archived::bool THEN 'archived' ELSE 'active' END,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateChatSessionSession :exec
|
|
-- Updates the resume pointer for a chat session. Empty/NULL inputs are
|
|
-- ignored via COALESCE so a task that completes without a session_id (e.g.
|
|
-- the agent crashed before establishing one) cannot wipe out a previously
|
|
-- recorded resume pointer. This makes the chat memory robust against
|
|
-- intermittent agent failures.
|
|
UPDATE chat_session
|
|
SET session_id = COALESCE(sqlc.narg('session_id'), session_id),
|
|
work_dir = COALESCE(sqlc.narg('work_dir'), work_dir),
|
|
runtime_id = COALESCE(sqlc.narg('runtime_id'), runtime_id),
|
|
updated_at = now()
|
|
WHERE id = sqlc.arg('id');
|
|
|
|
-- name: LockChatSessionForDelete :one
|
|
-- Acquires an exclusive (FOR UPDATE) row lock on chat_session(id). Used by
|
|
-- the delete path so that a concurrent SendChatMessage cannot enqueue a new
|
|
-- agent_task_queue row referencing this session between our cancel and
|
|
-- delete steps. The FK from agent_task_queue.chat_session_id takes a
|
|
-- KEY SHARE lock on the parent row during INSERT validation, which
|
|
-- conflicts with FOR UPDATE — concurrent inserts block here and then fail
|
|
-- their FK check after we commit the delete.
|
|
SELECT id FROM chat_session
|
|
WHERE id = $1
|
|
FOR UPDATE;
|
|
|
|
-- name: DeleteChatSession :exec
|
|
-- Hard delete. chat_message rows cascade via FK ON DELETE CASCADE; the
|
|
-- chat_session_id on agent_task_queue is set NULL by FK so completed/failed
|
|
-- task history survives the session being removed. Callers MUST run inside
|
|
-- the same transaction that holds LockChatSessionForDelete and that has
|
|
-- already cancelled any in-flight tasks (see CancelAgentTasksByChatSession)
|
|
-- so the daemon does not keep running work whose result has nowhere to
|
|
-- land. workspace_id in the WHERE clause is a SQL-layer tenant guard; see
|
|
-- DeleteIssue.
|
|
DELETE FROM chat_session WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: TouchChatSession :exec
|
|
UPDATE chat_session SET updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: CreateChatMessage :one
|
|
INSERT INTO chat_message (chat_session_id, role, content, task_id, failure_reason, elapsed_ms)
|
|
VALUES ($1, $2, $3, sqlc.narg(task_id), sqlc.narg(failure_reason), sqlc.narg(elapsed_ms))
|
|
RETURNING *;
|
|
|
|
-- name: LinkChatMessageToTask :exec
|
|
UPDATE chat_message
|
|
SET task_id = $2
|
|
WHERE id = $1 AND role = 'user';
|
|
|
|
-- name: DeleteUserChatMessageByTask :one
|
|
DELETE FROM chat_message
|
|
WHERE task_id = $1 AND role = 'user'
|
|
RETURNING *;
|
|
|
|
-- name: ListChatMessages :many
|
|
SELECT * FROM chat_message
|
|
WHERE chat_session_id = $1
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListChatMessagesPage :many
|
|
SELECT * FROM chat_message
|
|
WHERE chat_session_id = $1
|
|
AND (
|
|
sqlc.narg('before_created_at')::timestamptz IS NULL
|
|
OR (created_at, id) < (sqlc.narg('before_created_at')::timestamptz, sqlc.narg('before_id')::uuid)
|
|
)
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT $2;
|
|
|
|
-- name: GetChatMessage :one
|
|
SELECT * FROM chat_message
|
|
WHERE id = $1;
|
|
|
|
-- name: CreateChatTask :one
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, status, priority, chat_session_id,
|
|
initiator_user_id, originator_user_id, force_fresh_session, runtime_mcp_overlay,
|
|
runtime_connected_apps
|
|
)
|
|
VALUES (
|
|
$1, $2, NULL, 'queued', $3, $4, $5,
|
|
sqlc.narg(originator_user_id),
|
|
COALESCE(sqlc.narg('force_fresh_session')::boolean, FALSE),
|
|
sqlc.narg(runtime_mcp_overlay),
|
|
sqlc.narg(runtime_connected_apps)
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: GetLastChatTaskSession :one
|
|
-- Returns the most recent task in this chat session that managed to record a
|
|
-- session_id. Includes both completed and failed tasks: even a failed task
|
|
-- may have established a real agent session before failing, and we'd rather
|
|
-- resume there than start over and lose conversation memory. Used as a
|
|
-- fallback when chat_session.session_id is NULL. Resume-unsafe failures are
|
|
-- excluded because replaying those sessions deterministically reproduces the
|
|
-- same terminal state.
|
|
SELECT session_id, work_dir, runtime_id FROM agent_task_queue
|
|
WHERE chat_session_id = $1
|
|
AND (
|
|
status = 'completed'
|
|
OR (
|
|
status = 'failed'
|
|
AND COALESCE(failure_reason, '') NOT IN ('iteration_limit', 'agent_fallback_message', 'api_invalid_request', 'codex_semantic_inactivity')
|
|
AND NOT (COALESCE(error, '') ILIKE '%400%' AND COALESCE(error, '') ILIKE '%invalid_request_error%')
|
|
)
|
|
)
|
|
AND session_id IS NOT NULL
|
|
ORDER BY completed_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: GetPendingChatTask :one
|
|
-- Returns the most recent in-flight task for a chat session, if any.
|
|
-- Used by the frontend to recover pending state after refresh / reopen.
|
|
-- created_at is the anchor for the chat StatusPill timer (it computes
|
|
-- elapsed = now - task.created_at), so the pill survives refresh / reopen
|
|
-- without "resetting to 0s".
|
|
SELECT id, status, created_at FROM agent_task_queue
|
|
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
|
ORDER BY created_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: ListPendingChatTasksByCreator :many
|
|
-- Aggregate view of all in-flight chat tasks owned by a given creator in a
|
|
-- workspace. Drives the FAB's "running" indicator when the chat window is
|
|
-- closed and no single session's query is active.
|
|
--
|
|
-- Returns cs.agent_id so the handler can filter tasks belonging to private
|
|
-- agents the caller has lost access to using the already-loaded `allowed`
|
|
-- set — no second ListAllChatSessionsByCreator scan on the hot path.
|
|
--
|
|
-- atq.chat_session_id IS NOT NULL is redundant given the JOIN, but stated
|
|
-- explicitly so the planner can prove the query predicate is a subset of the
|
|
-- idx_agent_task_queue_chat_pending_v2 partial-index predicate and use it.
|
|
SELECT atq.id AS task_id, atq.status, atq.chat_session_id, cs.agent_id
|
|
FROM agent_task_queue atq
|
|
JOIN chat_session cs ON cs.id = atq.chat_session_id
|
|
WHERE atq.chat_session_id IS NOT NULL
|
|
AND atq.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
|
AND cs.workspace_id = $1
|
|
AND cs.creator_id = $2
|
|
ORDER BY atq.created_at DESC;
|
|
|
|
-- name: HasPendingChatTasksByCreator :one
|
|
-- Boolean fast-path for the FAB's "running" indicator. Returns a single
|
|
-- EXISTS row instead of the full task list, so the planner can stop at the
|
|
-- first matching in-flight task (LIMIT 1 semantics via EXISTS).
|
|
--
|
|
-- Permission filtering is baked into the query: agent_id = ANY($3) restricts
|
|
-- the result to the agents the caller may currently see, so a member who lost
|
|
-- access to a private agent never gets a true from a task they can no longer
|
|
-- reach. The handler must pass its resolved accessible-agent id set as $3;
|
|
-- an empty array yields false.
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM agent_task_queue atq
|
|
JOIN chat_session cs ON cs.id = atq.chat_session_id
|
|
WHERE atq.chat_session_id IS NOT NULL
|
|
AND atq.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
|
AND cs.workspace_id = sqlc.arg(workspace_id)
|
|
AND cs.creator_id = sqlc.arg(creator_id)
|
|
AND cs.agent_id = ANY(sqlc.arg(agent_ids)::uuid[])
|
|
) AS has_pending;
|
|
|
|
-- name: MarkChatSessionRead :exec
|
|
-- Advances the read cursor to now, dropping the session's unread_count to 0.
|
|
UPDATE chat_session SET last_read_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: GetMostRecentUserChatMessage :one
|
|
-- Returns the most recent role='user' message in a session. Used by the
|
|
-- Lark `/issue` command parser: when the user types `/issue` with no
|
|
-- title, the spec falls back to "use the previous user message as the
|
|
-- title". Bot replies (role='assistant') are excluded — only human
|
|
-- input qualifies as a fallback title source.
|
|
SELECT * FROM chat_message
|
|
WHERE chat_session_id = $1 AND role = 'user'
|
|
ORDER BY created_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: ChatSessionHasUserMessage :one
|
|
-- Reports whether a session has any human (role='user') message yet. Used to
|
|
-- scope the is_agent_intro self-introduction prompt to the very first,
|
|
-- server-driven turn: an intro session starts with zero user messages, so the
|
|
-- opening run gets the "introduce yourself" prompt. Once the creator replies,
|
|
-- later turns in the same session must fall back to the normal reply prompt
|
|
-- instead of repeating the introduction every turn (MUL-4259).
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM chat_message
|
|
WHERE chat_session_id = $1 AND role = 'user'
|
|
) AS has_user_message;
|