mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
fix: prevent malformed generated session titles
Session titles need to appear immediately, but the async AI title pass can return fenced or escaped JSON fragments. Keep the first-message title path fast, preserve optimistic client titles during background refresh, and only accept structured title responses that parse cleanly. Constraint: Desktop sessions should stop showing Untitled/New Session after the first user turn. Rejected: Accept short raw model output as a fallback | malformed JSON fragments are short enough to leak into the UI. Confidence: high Scope-risk: moderate Directive: Do not loosen generated title parsing without regression coverage for fenced, escaped, and truncated JSON responses. Tested: bun test src/server/__tests__/title-service.test.ts Tested: bun run check:server Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/stores/sessionStore.test.ts src/pages/EmptySession.test.tsx Tested: agent-browser smoke on http://127.0.0.1:1420/ confirmed sidebar and tab titles update without JSON fragments.
This commit is contained in:
parent
a340e41046
commit
7fbce35be6
21
desktop/src/lib/sessionTitle.ts
Normal file
21
desktop/src/lib/sessionTitle.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const TITLE_MAX_LEN = 50
|
||||||
|
|
||||||
|
const PLACEHOLDER_TITLES = new Set([
|
||||||
|
'',
|
||||||
|
'New Session',
|
||||||
|
'Untitled Session',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function deriveSessionTitle(raw: string): string | null {
|
||||||
|
const clean = raw.replace(/<[^>]+>[^<]*<\/[^>]+>/g, '').trim()
|
||||||
|
const firstSentence = /^(.*?[.!?\u3002\uff01\uff1f])\s/.exec(clean)?.[1] ?? clean
|
||||||
|
const flat = firstSentence.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!flat) return null
|
||||||
|
return flat.length > TITLE_MAX_LEN
|
||||||
|
? `${flat.slice(0, TITLE_MAX_LEN - 1)}\u2026`
|
||||||
|
: flat
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlaceholderSessionTitle(title: string | null | undefined): boolean {
|
||||||
|
return PLACEHOLDER_TITLES.has((title ?? '').trim())
|
||||||
|
}
|
||||||
@ -180,7 +180,7 @@ describe('EmptySession', () => {
|
|||||||
|
|
||||||
expect(useTabStore.getState().activeTabId).toBe('draft-session')
|
expect(useTabStore.getState().activeTabId).toBe('draft-session')
|
||||||
expect(useTabStore.getState().tabs).toEqual([
|
expect(useTabStore.getState().tabs).toEqual([
|
||||||
{ sessionId: 'draft-session', title: 'New Session', type: 'session', status: 'idle' },
|
{ sessionId: 'draft-session', title: 'draft question', type: 'session', status: 'idle' },
|
||||||
])
|
])
|
||||||
expect(useSessionStore.getState().sessions[0]).toMatchObject({
|
expect(useSessionStore.getState().sessions[0]).toMatchObject({
|
||||||
id: 'draft-session',
|
id: 'draft-session',
|
||||||
|
|||||||
@ -16,6 +16,10 @@ const {
|
|||||||
resetCompletedTasksMock,
|
resetCompletedTasksMock,
|
||||||
refreshTasksMock,
|
refreshTasksMock,
|
||||||
notifyDesktopMock,
|
notifyDesktopMock,
|
||||||
|
updateTabTitleMock,
|
||||||
|
updateTabStatusMock,
|
||||||
|
updateSessionTitleMock,
|
||||||
|
sessionStoreSnapshot,
|
||||||
cliTaskStoreSnapshot,
|
cliTaskStoreSnapshot,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
sendMock: vi.fn(),
|
sendMock: vi.fn(),
|
||||||
@ -31,6 +35,21 @@ const {
|
|||||||
resetCompletedTasksMock: vi.fn(async () => {}),
|
resetCompletedTasksMock: vi.fn(async () => {}),
|
||||||
refreshTasksMock: vi.fn(),
|
refreshTasksMock: vi.fn(),
|
||||||
notifyDesktopMock: vi.fn(),
|
notifyDesktopMock: vi.fn(),
|
||||||
|
updateTabTitleMock: vi.fn(),
|
||||||
|
updateTabStatusMock: vi.fn(),
|
||||||
|
updateSessionTitleMock: vi.fn(),
|
||||||
|
sessionStoreSnapshot: {
|
||||||
|
sessions: [] as Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: string
|
||||||
|
modifiedAt: string
|
||||||
|
messageCount: number
|
||||||
|
projectPath: string
|
||||||
|
workDir: string | null
|
||||||
|
workDirExists: boolean
|
||||||
|
}>,
|
||||||
|
},
|
||||||
cliTaskStoreSnapshot: {
|
cliTaskStoreSnapshot: {
|
||||||
tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>,
|
tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>,
|
||||||
sessionId: null as string | null,
|
sessionId: null as string | null,
|
||||||
@ -73,8 +92,8 @@ vi.mock('./teamStore', () => ({
|
|||||||
vi.mock('./tabStore', () => ({
|
vi.mock('./tabStore', () => ({
|
||||||
useTabStore: {
|
useTabStore: {
|
||||||
getState: () => ({
|
getState: () => ({
|
||||||
updateTabStatus: vi.fn(),
|
updateTabStatus: updateTabStatusMock,
|
||||||
updateTabTitle: vi.fn(),
|
updateTabTitle: updateTabTitleMock,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@ -82,7 +101,8 @@ vi.mock('./tabStore', () => ({
|
|||||||
vi.mock('./sessionStore', () => ({
|
vi.mock('./sessionStore', () => ({
|
||||||
useSessionStore: {
|
useSessionStore: {
|
||||||
getState: () => ({
|
getState: () => ({
|
||||||
updateSessionTitle: vi.fn(),
|
sessions: sessionStoreSnapshot.sessions,
|
||||||
|
updateSessionTitle: updateSessionTitleMock,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@ -120,6 +140,10 @@ describe('chatStore history mapping', () => {
|
|||||||
resetCompletedTasksMock.mockReset()
|
resetCompletedTasksMock.mockReset()
|
||||||
refreshTasksMock.mockReset()
|
refreshTasksMock.mockReset()
|
||||||
notifyDesktopMock.mockReset()
|
notifyDesktopMock.mockReset()
|
||||||
|
updateTabTitleMock.mockReset()
|
||||||
|
updateTabStatusMock.mockReset()
|
||||||
|
updateSessionTitleMock.mockReset()
|
||||||
|
sessionStoreSnapshot.sessions = []
|
||||||
cliTaskStoreSnapshot.tasks = []
|
cliTaskStoreSnapshot.tasks = []
|
||||||
cliTaskStoreSnapshot.sessionId = null
|
cliTaskStoreSnapshot.sessionId = null
|
||||||
useSessionRuntimeStore.setState({ selections: {} })
|
useSessionRuntimeStore.setState({ selections: {} })
|
||||||
@ -1407,4 +1431,27 @@ describe('chatStore history mapping', () => {
|
|||||||
|
|
||||||
expect(fetchSessionTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID)
|
expect(fetchSessionTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('optimistically titles a new placeholder session from the first user message', () => {
|
||||||
|
sessionStoreSnapshot.sessions = [{
|
||||||
|
id: TEST_SESSION_ID,
|
||||||
|
title: 'New Session',
|
||||||
|
createdAt: '2026-05-07T00:00:00.000Z',
|
||||||
|
modifiedAt: '2026-05-07T00:00:00.000Z',
|
||||||
|
messageCount: 0,
|
||||||
|
projectPath: '',
|
||||||
|
workDir: '/workspace/project',
|
||||||
|
workDirExists: true,
|
||||||
|
}]
|
||||||
|
|
||||||
|
useChatStore.getState().sendMessage(TEST_SESSION_ID, '开始优化UI')
|
||||||
|
|
||||||
|
expect(updateSessionTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI')
|
||||||
|
expect(updateTabTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI')
|
||||||
|
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
|
||||||
|
type: 'user_message',
|
||||||
|
content: '开始优化UI',
|
||||||
|
attachments: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
|||||||
import { useTabStore } from './tabStore'
|
import { useTabStore } from './tabStore'
|
||||||
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
||||||
import { notifyDesktop } from '../lib/desktopNotifications'
|
import { notifyDesktop } from '../lib/desktopNotifications'
|
||||||
|
import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
||||||
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
|
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
|
||||||
import type { MessageEntry } from '../types/session'
|
import type { MessageEntry } from '../types/session'
|
||||||
import type { PermissionMode } from '../types/settings'
|
import type { PermissionMode } from '../types/settings'
|
||||||
@ -325,6 +326,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
void taskStore.resetCompletedTasks()
|
void taskStore.resetCompletedTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isMemberSession) {
|
||||||
|
updateOptimisticSessionTitle(sessionId, userFacingContent)
|
||||||
|
}
|
||||||
|
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const session = s.sessions[sessionId] ?? createDefaultSessionState()
|
const session = s.sessions[sessionId] ?? createDefaultSessionState()
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
@ -924,6 +929,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
function updateOptimisticSessionTitle(sessionId: string, content: string): void {
|
||||||
|
const title = deriveSessionTitle(content)
|
||||||
|
if (!title) return
|
||||||
|
|
||||||
|
const session = useSessionStore.getState().sessions.find((item) => item.id === sessionId)
|
||||||
|
if (!session || session.messageCount > 0 || !isPlaceholderSessionTitle(session.title)) return
|
||||||
|
|
||||||
|
useSessionStore.getState().updateSessionTitle(sessionId, title)
|
||||||
|
useTabStore.getState().updateTabTitle(sessionId, title)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── History mapping helpers (unchanged from original) ─────────
|
// ─── History mapping helpers (unchanged from original) ─────────
|
||||||
|
|
||||||
type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
|
type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
|
||||||
|
|||||||
@ -22,6 +22,14 @@ function delay(ms: number) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((res) => {
|
||||||
|
resolve = res
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
describe('sessionStore', () => {
|
describe('sessionStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createMock.mockReset()
|
createMock.mockReset()
|
||||||
@ -64,6 +72,45 @@ describe('sessionStore', () => {
|
|||||||
expect(listMock).toHaveBeenCalledOnce()
|
expect(listMock).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps an optimistic local title when a background refresh still returns a placeholder', async () => {
|
||||||
|
const refresh = createDeferred<{
|
||||||
|
sessions: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: string
|
||||||
|
modifiedAt: string
|
||||||
|
messageCount: number
|
||||||
|
projectPath: string
|
||||||
|
workDir: string | null
|
||||||
|
workDirExists: boolean
|
||||||
|
}>
|
||||||
|
total: number
|
||||||
|
}>()
|
||||||
|
createMock.mockResolvedValue({ sessionId: 'session-title-1', workDir: '/workspace/project' })
|
||||||
|
listMock.mockReturnValue(refresh.promise)
|
||||||
|
|
||||||
|
await useSessionStore.getState().createSession('/workspace/project')
|
||||||
|
useSessionStore.getState().updateSessionTitle('session-title-1', '开始优化UI')
|
||||||
|
|
||||||
|
refresh.resolve({
|
||||||
|
sessions: [{
|
||||||
|
id: 'session-title-1',
|
||||||
|
title: 'Untitled Session',
|
||||||
|
createdAt: '2026-05-07T00:00:00.000Z',
|
||||||
|
modifiedAt: '2026-05-07T00:00:01.000Z',
|
||||||
|
messageCount: 0,
|
||||||
|
projectPath: '',
|
||||||
|
workDir: '/workspace/project',
|
||||||
|
workDirExists: true,
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
await refresh.promise
|
||||||
|
await delay(0)
|
||||||
|
|
||||||
|
expect(useSessionStore.getState().sessions[0]?.title).toBe('开始优化UI')
|
||||||
|
})
|
||||||
|
|
||||||
it('forwards direct branch switch repository options when creating a session', async () => {
|
it('forwards direct branch switch repository options when creating a session', async () => {
|
||||||
createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' })
|
createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' })
|
||||||
listMock.mockResolvedValue({ sessions: [], total: 0 })
|
listMock.mockResolvedValue({ sessions: [], total: 0 })
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { create } from 'zustand'
|
|||||||
import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions'
|
import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions'
|
||||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||||
import type { SessionListItem } from '../types/session'
|
import type { SessionListItem } from '../types/session'
|
||||||
|
import { isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
||||||
|
|
||||||
type CreateSessionOptions = {
|
type CreateSessionOptions = {
|
||||||
repository?: CreateSessionRepositoryOptions
|
repository?: CreateSessionRepositoryOptions
|
||||||
@ -36,17 +37,22 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const { sessions: raw } = await sessionsApi.list({ project, limit: 100 })
|
const { sessions: raw } = await sessionsApi.list({ project, limit: 100 })
|
||||||
// Deduplicate by session ID — keep the most recently modified entry
|
set((state) => {
|
||||||
|
const currentById = new Map(state.sessions.map((session) => [session.id, session]))
|
||||||
|
// Deduplicate by session ID - keep the most recently modified entry.
|
||||||
const byId = new Map<string, SessionListItem>()
|
const byId = new Map<string, SessionListItem>()
|
||||||
for (const s of raw) {
|
for (const s of raw) {
|
||||||
|
const current = currentById.get(s.id)
|
||||||
|
const candidate = preserveLocalTitle(current, s)
|
||||||
const existing = byId.get(s.id)
|
const existing = byId.get(s.id)
|
||||||
if (!existing || new Date(s.modifiedAt) > new Date(existing.modifiedAt)) {
|
if (!existing || new Date(candidate.modifiedAt) > new Date(existing.modifiedAt)) {
|
||||||
byId.set(s.id, s)
|
byId.set(s.id, candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sessions = [...byId.values()]
|
const sessions = [...byId.values()]
|
||||||
const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort()
|
const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort()
|
||||||
set({ sessions, availableProjects, isLoading: false })
|
return { sessions, availableProjects, isLoading: false }
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: (err as Error).message, isLoading: false })
|
set({ error: (err as Error).message, isLoading: false })
|
||||||
}
|
}
|
||||||
@ -109,3 +115,14 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||||
setSelectedProjects: (projects) => set({ selectedProjects: projects }),
|
setSelectedProjects: (projects) => set({ selectedProjects: projects }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
function preserveLocalTitle(
|
||||||
|
current: SessionListItem | undefined,
|
||||||
|
incoming: SessionListItem,
|
||||||
|
): SessionListItem {
|
||||||
|
if (!current) return incoming
|
||||||
|
if (isPlaceholderSessionTitle(incoming.title) && !isPlaceholderSessionTitle(current.title)) {
|
||||||
|
return { ...incoming, title: current.title }
|
||||||
|
}
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
|||||||
@ -817,6 +817,47 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
expect(statusMsgs[0].state).toBe('thinking')
|
expect(statusMsgs[0].state).toBe('thinking')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits the derived session title before the first response completes', async () => {
|
||||||
|
const sessionId = `title-fast-${crypto.randomUUID()}`
|
||||||
|
const messages: any[] = []
|
||||||
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close()
|
||||||
|
reject(new Error('Timed out waiting for derived session title'))
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data as string)
|
||||||
|
messages.push(msg)
|
||||||
|
if (msg.type === 'connected') {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'user_message',
|
||||||
|
content: '开始优化UI',
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.type === 'message_complete') {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
ws.close()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(new Error(`WebSocket error for title session ${sessionId}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleIndex = messages.findIndex((msg) => msg.type === 'session_title_updated')
|
||||||
|
const completionIndex = messages.findIndex((msg) => msg.type === 'message_complete')
|
||||||
|
expect(titleIndex).toBeGreaterThan(-1)
|
||||||
|
expect(completionIndex).toBeGreaterThan(-1)
|
||||||
|
expect(messages[titleIndex].title).toBe('开始优化UI')
|
||||||
|
expect(titleIndex).toBeLessThan(completionIndex)
|
||||||
|
})
|
||||||
|
|
||||||
it('should start desktop sessions with disabled thinking when configured', async () => {
|
it('should start desktop sessions with disabled thinking when configured', async () => {
|
||||||
const sessionId = `chat-thinking-disabled-${crypto.randomUUID()}`
|
const sessionId = `chat-thinking-disabled-${crypto.randomUUID()}`
|
||||||
const originalStartSession = conversationService.startSession.bind(conversationService)
|
const originalStartSession = conversationService.startSession.bind(conversationService)
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { generateTitle, saveAiTitle } from '../services/titleService.js'
|
import { generateTitle, parseGeneratedTitleText, saveAiTitle } from '../services/titleService.js'
|
||||||
import { sessionService } from '../services/sessionService.js'
|
import { sessionService } from '../services/sessionService.js'
|
||||||
|
|
||||||
describe('titleService', () => {
|
describe('titleService', () => {
|
||||||
@ -70,6 +70,20 @@ describe('titleService', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('parses JSON title responses wrapped in markdown fences', () => {
|
||||||
|
expect(parseGeneratedTitleText('```json\n{"title":"Write bash script"}\n```'))
|
||||||
|
.toBe('Write bash script')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses escaped JSON title responses', () => {
|
||||||
|
expect(parseGeneratedTitleText('```json\n{\\"title\\":\\"Write bash script\\"}\n```'))
|
||||||
|
.toBe('Write bash script')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects incomplete JSON title fragments instead of using them as titles', () => {
|
||||||
|
expect(parseGeneratedTitleText('```json\n{\\"title\\":')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test('does not persist automatic titles over a user custom title', async () => {
|
test('does not persist automatic titles over a user custom title', async () => {
|
||||||
const { sessionId } = await sessionService.createSession(os.tmpdir())
|
const { sessionId } = await sessionService.createSession(os.tmpdir())
|
||||||
await sessionService.renameSession(sessionId, 'My fixed name')
|
await sessionService.renameSession(sessionId, 'My fixed name')
|
||||||
|
|||||||
@ -99,20 +99,78 @@ export async function generateTitle(
|
|||||||
const text = body.content?.find((b) => b.type === 'text')?.text
|
const text = body.content?.find((b) => b.type === 'text')?.text
|
||||||
if (!text) return null
|
if (!text) return null
|
||||||
|
|
||||||
// Parse JSON response
|
return parseGeneratedTitleText(text)
|
||||||
const match = text.match(/\{[^}]*"title"\s*:\s*"([^"]+)"[^}]*\}/)
|
|
||||||
if (match?.[1]) return match[1].trim()
|
|
||||||
|
|
||||||
// Fallback: if model returned plain text instead of JSON
|
|
||||||
const plain = text.trim()
|
|
||||||
if (plain.length > 0 && plain.length <= 60) return plain
|
|
||||||
|
|
||||||
return null
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseGeneratedTitleText(text: string): string | null {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
const parsed = parseTitleFromStructuredText(trimmed)
|
||||||
|
if (parsed) return normalizeTitle(parsed)
|
||||||
|
|
||||||
|
if (looksLikeStructuredTitleFragment(trimmed)) return null
|
||||||
|
|
||||||
|
return normalizeTitle(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTitleFromStructuredText(text: string): string | null {
|
||||||
|
const candidates = new Set<string>([text])
|
||||||
|
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim()
|
||||||
|
if (fenced) candidates.add(fenced)
|
||||||
|
|
||||||
|
const firstBrace = text.indexOf('{')
|
||||||
|
const lastBrace = text.lastIndexOf('}')
|
||||||
|
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||||
|
candidates.add(text.slice(firstBrace, lastBrace + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of [...candidates]) {
|
||||||
|
const unescaped = candidate.replace(/\\"/g, '"').replace(/\\n/g, '\n')
|
||||||
|
if (unescaped !== candidate) candidates.add(unescaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const title = parseTitleJson(candidate)
|
||||||
|
if (title) return title
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTitleJson(candidate: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(candidate)
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
return parseTitleFromStructuredText(parsed)
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === 'object' && typeof (parsed as { title?: unknown }).title === 'string') {
|
||||||
|
return (parsed as { title: string }).title
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(title: string): string | null {
|
||||||
|
const clean = title.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!clean || clean.length > 60 || looksLikeStructuredTitleFragment(clean)) return null
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeStructuredTitleFragment(text: string): boolean {
|
||||||
|
return (
|
||||||
|
text.includes('```') ||
|
||||||
|
text.includes('{') ||
|
||||||
|
text.includes('}') ||
|
||||||
|
/\\?"title\\?"\s*:/.test(text)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function shouldDisableThinkingForTitle(presetId: string): Promise<boolean> {
|
async function shouldDisableThinkingForTitle(presetId: string): Promise<boolean> {
|
||||||
const settings = await new SettingsService().getUserSettings()
|
const settings = await new SettingsService().getUserSettings()
|
||||||
if (settings.alwaysThinkingEnabled !== false) return false
|
if (settings.alwaysThinkingEnabled !== false) return false
|
||||||
|
|||||||
@ -53,6 +53,7 @@ const sessionTitleState = new Map<string, {
|
|||||||
hasCustomTitle: boolean
|
hasCustomTitle: boolean
|
||||||
firstUserMessage: string
|
firstUserMessage: string
|
||||||
allUserMessages: string[]
|
allUserMessages: string[]
|
||||||
|
startedGenerationCounts: Set<number>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const runtimeOverrides = new Map<string, {
|
const runtimeOverrides = new Map<string, {
|
||||||
@ -271,6 +272,25 @@ async function handleUserMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track and emit the first placeholder title before CLI startup/streaming.
|
||||||
|
let titleState = sessionTitleState.get(sessionId)
|
||||||
|
if (!titleState) {
|
||||||
|
titleState = {
|
||||||
|
userMessageCount: 0,
|
||||||
|
hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)),
|
||||||
|
firstUserMessage: '',
|
||||||
|
allUserMessages: [],
|
||||||
|
startedGenerationCounts: new Set<number>(),
|
||||||
|
}
|
||||||
|
sessionTitleState.set(sessionId, titleState)
|
||||||
|
}
|
||||||
|
titleState.userMessageCount++
|
||||||
|
titleState.allUserMessages.push(message.content)
|
||||||
|
if (titleState.userMessageCount === 1) {
|
||||||
|
titleState.firstUserMessage = message.content
|
||||||
|
}
|
||||||
|
triggerTitleGeneration(ws, sessionId)
|
||||||
|
|
||||||
// 启动 CLI 子进程(如果还没有)
|
// 启动 CLI 子进程(如果还没有)
|
||||||
try {
|
try {
|
||||||
await ensureCliSessionStarted(ws, sessionId, 'user_message')
|
await ensureCliSessionStarted(ws, sessionId, 'user_message')
|
||||||
@ -290,23 +310,6 @@ async function handleUserMessage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track user message for title generation
|
|
||||||
let titleState = sessionTitleState.get(sessionId)
|
|
||||||
if (!titleState) {
|
|
||||||
titleState = {
|
|
||||||
userMessageCount: 0,
|
|
||||||
hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)),
|
|
||||||
firstUserMessage: '',
|
|
||||||
allUserMessages: [],
|
|
||||||
}
|
|
||||||
sessionTitleState.set(sessionId, titleState)
|
|
||||||
}
|
|
||||||
titleState.userMessageCount++
|
|
||||||
titleState.allUserMessages.push(message.content)
|
|
||||||
if (titleState.userMessageCount === 1) {
|
|
||||||
titleState.firstUserMessage = message.content
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the callback before sending the turn so startup errors are not lost.
|
// Register the callback before sending the turn so startup errors are not lost.
|
||||||
// Keep output muted until the current user turn is enqueued to avoid forwarding
|
// Keep output muted until the current user turn is enqueued to avoid forwarding
|
||||||
// any pre-turn SDK chatter as fresh chat history.
|
// any pre-turn SDK chatter as fresh chat history.
|
||||||
@ -627,6 +630,8 @@ function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, sessionId: s
|
|||||||
|
|
||||||
// Generate on count 1 (first response) and count 3 (with more context)
|
// Generate on count 1 (first response) and count 3 (with more context)
|
||||||
if (count !== 1 && count !== 3) return
|
if (count !== 1 && count !== 3) return
|
||||||
|
if (state.startedGenerationCounts.has(count)) return
|
||||||
|
state.startedGenerationCounts.add(count)
|
||||||
|
|
||||||
const text = count === 1
|
const text = count === 1
|
||||||
? state.firstUserMessage
|
? state.firstUserMessage
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user