feat: add independent conversation branching
Local conversations can now continue in a new branch after stable content is persisted. Each branch uses new conversation and message IDs, keeps its source visible, and remains independent without copying Tasks, queue items, runs, memories, or artifact ownership. Conversation Task rows are also denser, align status markers at the right edge, and use an accessible checked success marker without redundant completion text. Release note: 本地会话现在可从“在新会话中继续”创建独立分支并显示来源;Task 子项布局更紧凑,状态统一显示在右侧。
This commit is contained in:
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
builtInDefaultProjectSeedName,
|
||||
conversationSnapshotSchema,
|
||||
isUntouchedBuiltInDefaultProject
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
@@ -161,7 +162,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 25', async () => {
|
||||
it('migrates existing databases to schema version 26', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -190,7 +191,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(25)
|
||||
).toBe(26)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -213,6 +214,18 @@ describe('AssistantDatabase', () => {
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(
|
||||
current
|
||||
.prepare('PRAGMA table_info(conversations)')
|
||||
.all()
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'branch_source_conversation_id'
|
||||
}),
|
||||
expect.objectContaining({ name: 'branch_source_title' })
|
||||
])
|
||||
)
|
||||
const foreignKeys = current
|
||||
.prepare('PRAGMA foreign_key_list(model_usage_calls)')
|
||||
.all() as Array<{
|
||||
@@ -275,6 +288,15 @@ describe('AssistantDatabase', () => {
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'conversation_queue_items' })
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
AND name = 'conversations_branch_source_idx'`
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'conversations_branch_source_idx' })
|
||||
current.close()
|
||||
})
|
||||
|
||||
@@ -476,7 +498,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(25)
|
||||
).toBe(26)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -614,7 +636,7 @@ describe('AssistantDatabase', () => {
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
inspected.prepare('PRAGMA user_version').get()
|
||||
).toEqual({ user_version: 25 })
|
||||
).toEqual({ user_version: 26 })
|
||||
expect(
|
||||
inspected
|
||||
.prepare(
|
||||
@@ -2503,6 +2525,341 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('atomically branches a stable local conversation without cloning owned work', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-branched-conversation-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize('C:\\Workspace')
|
||||
const project = database.listProjects()[0]!
|
||||
const sourceConversationId =
|
||||
'00000000-0000-4000-8000-000000000541'
|
||||
const sourceMessageIds = [
|
||||
'00000000-0000-4000-8000-000000000542',
|
||||
'00000000-0000-4000-8000-000000000543'
|
||||
]
|
||||
database.replaceConversations([
|
||||
{
|
||||
id: sourceConversationId,
|
||||
projectId: project.id,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: channelDefaultProfileId
|
||||
},
|
||||
knowledgeRetrievalMode: 'always',
|
||||
contextMetrics: {
|
||||
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
|
||||
contextTokens: 4_096,
|
||||
source: 'estimated',
|
||||
basis: 'conversation'
|
||||
},
|
||||
contextCompressionState: {
|
||||
coveredHistoryDigest: 'b'.repeat(64),
|
||||
coveredMessageCount: 2,
|
||||
coveredFromMessageId: sourceMessageIds[0],
|
||||
coveredThroughMessageId: sourceMessageIds[1],
|
||||
summary: '来源会话摘要'
|
||||
},
|
||||
title: '发布方案讨论',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: sourceMessageIds[0]!,
|
||||
role: 'user',
|
||||
content: '比较两个发布方案',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete',
|
||||
attachments: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000544',
|
||||
name: '发布清单.md',
|
||||
size: 128,
|
||||
preview: '发布步骤',
|
||||
kind: 'text'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: sourceMessageIds[1]!,
|
||||
role: 'assistant',
|
||||
content: '可以从风险和成本两个方向比较。',
|
||||
createdAt: 1_775_000_001_000,
|
||||
state: 'complete',
|
||||
task: {
|
||||
id: '00000000-0000-4000-8000-000000000545',
|
||||
title: '来源任务'
|
||||
},
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000546'
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
const raw = new DatabaseSync(databasePath)
|
||||
raw
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET request_id = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
'00000000-0000-4000-8000-000000000547',
|
||||
sourceMessageIds[1]!
|
||||
)
|
||||
const sourceMetadataRow = raw
|
||||
.prepare(
|
||||
`SELECT metadata_json
|
||||
FROM messages
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(sourceMessageIds[1]!) as { metadata_json: string }
|
||||
raw
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET metadata_json = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
JSON.stringify({
|
||||
...(JSON.parse(sourceMetadataRow.metadata_json) as object),
|
||||
subagents: [
|
||||
{
|
||||
childTaskId:
|
||||
'00000000-0000-4000-8000-000000000549',
|
||||
expertId:
|
||||
'00000000-0000-4000-8000-000000000550',
|
||||
expertName: '来源专家',
|
||||
routingMode: 'manual',
|
||||
state: 'completed'
|
||||
}
|
||||
]
|
||||
}),
|
||||
sourceMessageIds[1]!
|
||||
)
|
||||
raw.close()
|
||||
|
||||
const sourceBefore = database.getConversation(sourceConversationId)
|
||||
const branch = database.branchLocalConversation({
|
||||
sourceConversationId,
|
||||
title: '发布方案讨论 · 分支'
|
||||
})
|
||||
|
||||
expect(conversationSnapshotSchema.safeParse(branch).success).toBe(true)
|
||||
expect(branch).toMatchObject({
|
||||
projectId: project.id,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: channelDefaultProfileId
|
||||
},
|
||||
knowledgeRetrievalMode: 'always',
|
||||
branch: {
|
||||
sourceConversationId,
|
||||
sourceTitle: '发布方案讨论'
|
||||
},
|
||||
title: '发布方案讨论 · 分支'
|
||||
})
|
||||
expect(branch).not.toHaveProperty('contextMetrics')
|
||||
expect(branch).not.toHaveProperty('contextCompressionState')
|
||||
expect(branch.id).not.toBe(sourceConversationId)
|
||||
expect(branch.messages.map((message) => message.content)).toEqual(
|
||||
sourceBefore.messages.map((message) => message.content)
|
||||
)
|
||||
expect(
|
||||
branch.messages.every(
|
||||
(message) => !sourceMessageIds.includes(message.id)
|
||||
)
|
||||
).toBe(true)
|
||||
expect(branch.messages[0]?.attachments).toEqual(
|
||||
sourceBefore.messages[0]?.attachments
|
||||
)
|
||||
expect(branch.messages[1]?.task).toBeUndefined()
|
||||
expect(branch.messages[1]?.artifactIds).toBeUndefined()
|
||||
expect(database.getConversation(sourceConversationId)).toEqual(
|
||||
sourceBefore
|
||||
)
|
||||
|
||||
const durable = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
durable
|
||||
.prepare(
|
||||
`SELECT sequence, request_id
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY sequence`
|
||||
)
|
||||
.all(branch.id)
|
||||
).toEqual([
|
||||
{ sequence: 0, request_id: null },
|
||||
{ sequence: 1, request_id: null }
|
||||
])
|
||||
const branchMetadataRow = durable
|
||||
.prepare(
|
||||
`SELECT metadata_json
|
||||
FROM messages
|
||||
WHERE conversation_id = ? AND sequence = 1`
|
||||
)
|
||||
.get(branch.id) as { metadata_json: string }
|
||||
const branchMetadata = JSON.parse(
|
||||
branchMetadataRow.metadata_json
|
||||
) as Record<string, unknown>
|
||||
expect(branchMetadata).not.toHaveProperty('artifactIds')
|
||||
expect(branchMetadata).not.toHaveProperty('subagents')
|
||||
expect(branchMetadata).not.toHaveProperty('task')
|
||||
durable.close()
|
||||
|
||||
database.saveLocalConversations([
|
||||
{
|
||||
header: {
|
||||
id: branch.id,
|
||||
projectId: branch.projectId,
|
||||
runtimeSelection: branch.runtimeSelection,
|
||||
knowledgeRetrievalMode: branch.knowledgeRetrievalMode,
|
||||
branch: branch.branch,
|
||||
title: '发布方案讨论 · 分支(独立更新)',
|
||||
updatedAt: 1_775_000_003_000
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000548',
|
||||
role: 'user',
|
||||
content: '只在分支中继续',
|
||||
createdAt: 1_775_000_003_000,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
expect(database.getConversation(sourceConversationId)).toEqual(
|
||||
sourceBefore
|
||||
)
|
||||
expect(database.getConversation(branch.id)).toMatchObject({
|
||||
title: '发布方案讨论 · 分支(独立更新)',
|
||||
messages: [
|
||||
{ content: '比较两个发布方案' },
|
||||
{ content: '可以从风险和成本两个方向比较。' },
|
||||
{ content: '只在分支中继续' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(database.deleteLocalConversation(sourceConversationId)).toBe(
|
||||
true
|
||||
)
|
||||
expect(database.getConversation(branch.id)).toMatchObject({
|
||||
branch: {
|
||||
sourceConversationId,
|
||||
sourceTitle: '发布方案讨论'
|
||||
},
|
||||
messages: [
|
||||
{ content: '比较两个发布方案' },
|
||||
{ content: '可以从风险和成本两个方向比较。' },
|
||||
{ content: '只在分支中继续' }
|
||||
]
|
||||
})
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('rejects unstable or remote branch sources and rolls back copy failures', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-conversation-branch-guards-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize('C:\\Workspace')
|
||||
const sourceConversationId =
|
||||
'00000000-0000-4000-8000-000000000551'
|
||||
const sourceMessageId =
|
||||
'00000000-0000-4000-8000-000000000552'
|
||||
database.replaceConversations([
|
||||
{
|
||||
id: sourceConversationId,
|
||||
title: '必须稳定后分支',
|
||||
updatedAt: 1,
|
||||
messages: [
|
||||
{
|
||||
id: sourceMessageId,
|
||||
role: 'user',
|
||||
content: '稳定内容',
|
||||
createdAt: 1,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
const queued = database.enqueueConversationUserInput({
|
||||
conversationId: sourceConversationId,
|
||||
label: '待发送内容',
|
||||
payloadJson: '{}'
|
||||
})
|
||||
expect(() =>
|
||||
database.branchLocalConversation({
|
||||
sourceConversationId,
|
||||
title: '不应创建的队列分支'
|
||||
})
|
||||
).toThrow('仍有待发送内容')
|
||||
database.removeConversationUserQueueItem(queued.id)
|
||||
|
||||
const taskId = '00000000-0000-4000-8000-000000000553'
|
||||
database.createTask({
|
||||
id: taskId,
|
||||
conversationId: sourceConversationId,
|
||||
title: '运行中的任务',
|
||||
instructions: '保持运行',
|
||||
workMode: 'ask'
|
||||
})
|
||||
database.updateTaskStatus(taskId, 'running')
|
||||
expect(() =>
|
||||
database.branchLocalConversation({
|
||||
sourceConversationId,
|
||||
title: '不应创建的运行分支'
|
||||
})
|
||||
).toThrow('仍有正在运行的任务')
|
||||
database.updateTaskStatus(taskId, 'completed')
|
||||
|
||||
const channelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
channelDefaultProfileId
|
||||
)[0]!
|
||||
const remote = database.getOrCreateRemoteConversation({
|
||||
projectId: channelProject.id,
|
||||
channel: 'weixin',
|
||||
accountId: 'default',
|
||||
externalConversationId: 'branch-protected',
|
||||
conversationType: 'direct',
|
||||
title: '远程来源',
|
||||
accountDisplay: '发送者 ****0551'
|
||||
})
|
||||
expect(() =>
|
||||
database.branchLocalConversation({
|
||||
sourceConversationId: remote.id,
|
||||
title: '不应创建的远程分支'
|
||||
})
|
||||
).toThrow('只能从现有的本地会话创建分支')
|
||||
|
||||
const countBeforeFailure = database.listConversations().length
|
||||
const raw = new DatabaseSync(databasePath)
|
||||
raw.exec(`
|
||||
CREATE TRIGGER reject_branched_message
|
||||
BEFORE INSERT ON messages
|
||||
WHEN NEW.conversation_id != '${sourceConversationId}'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced branch copy failure');
|
||||
END;
|
||||
`)
|
||||
raw.close()
|
||||
expect(() =>
|
||||
database.branchLocalConversation({
|
||||
sourceConversationId,
|
||||
title: '必须完整回滚'
|
||||
})
|
||||
).toThrow('forced branch copy failure')
|
||||
expect(database.listConversations()).toHaveLength(countBeforeFailure)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('keeps incremental local conversation storage bounded to 500 messages', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-bounded-local-conversation-')
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
ConversationQueueItem,
|
||||
ConversationBranchInput,
|
||||
ConversationMessage,
|
||||
ConversationSnapshot,
|
||||
ExpertCreateInput,
|
||||
@@ -122,6 +123,8 @@ type ConversationRow = {
|
||||
external_conversation_id: string | null
|
||||
conversation_type: 'direct' | 'group' | null
|
||||
account_display: string | null
|
||||
branch_source_conversation_id: string | null
|
||||
branch_source_title: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -892,6 +895,16 @@ function toConversationSnapshot(
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(conversation.branch_source_conversation_id &&
|
||||
conversation.branch_source_title
|
||||
? {
|
||||
branch: {
|
||||
sourceConversationId:
|
||||
conversation.branch_source_conversation_id,
|
||||
sourceTitle: conversation.branch_source_title
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
title: conversation.title,
|
||||
updatedAt: Date.parse(conversation.updated_at),
|
||||
messages: messages.map((message) => {
|
||||
@@ -1525,7 +1538,9 @@ export class AssistantDatabase {
|
||||
`SELECT id, project_id, runtime_selection_json,
|
||||
knowledge_retrieval_mode, context_state_json, title, channel,
|
||||
external_account_id, external_conversation_id,
|
||||
conversation_type, account_display, updated_at
|
||||
conversation_type, account_display,
|
||||
branch_source_conversation_id, branch_source_title,
|
||||
updated_at
|
||||
FROM conversations
|
||||
WHERE status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
@@ -1560,7 +1575,9 @@ export class AssistantDatabase {
|
||||
`SELECT id, project_id, runtime_selection_json,
|
||||
knowledge_retrieval_mode, context_state_json, title, channel,
|
||||
external_account_id, external_conversation_id,
|
||||
conversation_type, account_display, updated_at
|
||||
conversation_type, account_display,
|
||||
branch_source_conversation_id, branch_source_title,
|
||||
updated_at
|
||||
FROM conversations
|
||||
WHERE id = ? AND status = 'active'`
|
||||
)
|
||||
@@ -1686,9 +1703,10 @@ export class AssistantDatabase {
|
||||
const insertConversation = database.prepare(
|
||||
`INSERT INTO conversations
|
||||
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||
context_state_json, work_mode, title, status, created_at,
|
||||
updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
context_state_json, work_mode, title,
|
||||
branch_source_conversation_id, branch_source_title, status,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?)`
|
||||
)
|
||||
const insertMessage = database.prepare(
|
||||
`INSERT INTO messages
|
||||
@@ -1710,6 +1728,8 @@ export class AssistantDatabase {
|
||||
conversation.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(conversation),
|
||||
conversation.title,
|
||||
conversation.branch?.sourceConversationId ?? null,
|
||||
conversation.branch?.sourceTitle ?? null,
|
||||
updatedAt,
|
||||
updatedAt
|
||||
)
|
||||
@@ -1743,15 +1763,17 @@ export class AssistantDatabase {
|
||||
const insertConversation = database.prepare(
|
||||
`INSERT INTO conversations
|
||||
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||
context_state_json, work_mode, title, status, created_at,
|
||||
updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
context_state_json, work_mode, title,
|
||||
branch_source_conversation_id, branch_source_title, status,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?)`
|
||||
)
|
||||
const updateConversation = database.prepare(
|
||||
`UPDATE conversations
|
||||
SET project_id = ?, runtime_selection_json = ?,
|
||||
knowledge_retrieval_mode = ?, context_state_json = ?,
|
||||
title = ?, status = 'active',
|
||||
title = ?, branch_source_conversation_id = ?,
|
||||
branch_source_title = ?, status = 'active',
|
||||
updated_at = MAX(updated_at, ?)
|
||||
WHERE id = ? AND channel IS NULL`
|
||||
)
|
||||
@@ -1807,6 +1829,8 @@ export class AssistantDatabase {
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
header.title,
|
||||
header.branch?.sourceConversationId ?? null,
|
||||
header.branch?.sourceTitle ?? null,
|
||||
updatedAt,
|
||||
header.id
|
||||
)
|
||||
@@ -1823,6 +1847,8 @@ export class AssistantDatabase {
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
header.title,
|
||||
header.branch?.sourceConversationId ?? null,
|
||||
header.branch?.sourceTitle ?? null,
|
||||
updatedAt,
|
||||
updatedAt
|
||||
)
|
||||
@@ -1878,6 +1904,128 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
branchLocalConversation(
|
||||
input: ConversationBranchInput
|
||||
): ConversationSnapshot {
|
||||
const database = this.requireDatabase()
|
||||
const destinationConversationId = randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const source = database
|
||||
.prepare(
|
||||
`SELECT id, project_id, runtime_selection_json,
|
||||
knowledge_retrieval_mode, title
|
||||
FROM conversations
|
||||
WHERE id = ? AND status = 'active' AND channel IS NULL`
|
||||
)
|
||||
.get(input.sourceConversationId) as
|
||||
| {
|
||||
id: string
|
||||
project_id: string | null
|
||||
runtime_selection_json: string | null
|
||||
knowledge_retrieval_mode: 'auto' | 'always' | null
|
||||
title: string
|
||||
}
|
||||
| undefined
|
||||
if (!source) {
|
||||
throw new Error('只能从现有的本地会话创建分支')
|
||||
}
|
||||
const activeTask = database
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM tasks
|
||||
WHERE conversation_id = ?
|
||||
AND status IN ('running', 'waiting_approval')
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(source.id)
|
||||
if (activeTask) {
|
||||
throw new Error('当前会话仍有正在运行的任务,请等待完成后再创建分支')
|
||||
}
|
||||
const queuedItem = database
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM conversation_queue_items
|
||||
WHERE conversation_id = ?
|
||||
AND status IN ('pending', 'dispatching')
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(source.id)
|
||||
if (queuedItem) {
|
||||
throw new Error('当前会话仍有待发送内容,请处理后再创建分支')
|
||||
}
|
||||
const messageStates = database
|
||||
.prepare(
|
||||
`SELECT state
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY sequence ASC`
|
||||
)
|
||||
.all(source.id) as Array<{ state: MessageRow['state'] }>
|
||||
if (
|
||||
messageStates.some((message) => message.state === 'streaming')
|
||||
) {
|
||||
throw new Error('当前会话仍在生成内容,请等待完成后再创建分支')
|
||||
}
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO conversations
|
||||
(id, project_id, runtime_selection_json,
|
||||
knowledge_retrieval_mode, context_state_json, work_mode,
|
||||
title, branch_source_conversation_id, branch_source_title,
|
||||
status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, NULL, 'ask', ?, ?, ?, 'active', ?, ?)`
|
||||
)
|
||||
.run(
|
||||
destinationConversationId,
|
||||
source.project_id,
|
||||
source.runtime_selection_json,
|
||||
source.knowledge_retrieval_mode,
|
||||
input.title,
|
||||
source.id,
|
||||
source.title,
|
||||
now,
|
||||
now
|
||||
)
|
||||
const copiedMessages = database
|
||||
.prepare(
|
||||
`INSERT INTO messages
|
||||
(id, conversation_id, request_id, role, content, state,
|
||||
sequence, metadata_json, created_at)
|
||||
SELECT lower(
|
||||
hex(randomblob(4)) || '-' ||
|
||||
hex(randomblob(2)) || '-4' ||
|
||||
substr(hex(randomblob(2)), 2) || '-' ||
|
||||
substr('89ab', (random() & 3) + 1, 1) ||
|
||||
substr(hex(randomblob(2)), 2) || '-' ||
|
||||
hex(randomblob(6))
|
||||
),
|
||||
?, NULL, role, content, state,
|
||||
ROW_NUMBER() OVER (ORDER BY sequence) - 1,
|
||||
json_remove(
|
||||
metadata_json,
|
||||
'$.artifactIds',
|
||||
'$.subagents',
|
||||
'$.task'
|
||||
),
|
||||
created_at
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY sequence`
|
||||
)
|
||||
.run(destinationConversationId, source.id)
|
||||
if (copiedMessages.changes !== messageStates.length) {
|
||||
throw new Error('分支消息复制不完整')
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return this.getConversation(destinationConversationId)
|
||||
}
|
||||
|
||||
deleteLocalConversation(conversationId: string): boolean {
|
||||
const database = this.requireDatabase()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
@@ -5799,12 +5947,12 @@ export class AssistantDatabase {
|
||||
const version = database
|
||||
.prepare('PRAGMA user_version')
|
||||
.get() as { user_version: number }
|
||||
if (version.user_version > 25) {
|
||||
if (version.user_version > 26) {
|
||||
throw new Error(
|
||||
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
||||
)
|
||||
}
|
||||
if (version.user_version === 25) {
|
||||
if (version.user_version === 26) {
|
||||
return
|
||||
}
|
||||
if (version.user_version < 1) {
|
||||
@@ -5837,6 +5985,8 @@ export class AssistantDatabase {
|
||||
work_mode TEXT NOT NULL DEFAULT 'ask'
|
||||
CHECK(work_mode IN ('ask', 'execute')),
|
||||
title TEXT NOT NULL,
|
||||
branch_source_conversation_id TEXT,
|
||||
branch_source_title TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active', 'archived')),
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -5855,6 +6005,9 @@ export class AssistantDatabase {
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(conversation_id, sequence)
|
||||
);
|
||||
CREATE INDEX conversations_branch_source_idx
|
||||
ON conversations(branch_source_conversation_id)
|
||||
WHERE branch_source_conversation_id IS NOT NULL;
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
||||
@@ -7177,6 +7330,40 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (version.user_version < 26) {
|
||||
const conversationColumns = new Set(
|
||||
(
|
||||
database.prepare('PRAGMA table_info(conversations)').all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
).map((column) => column.name)
|
||||
)
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (!conversationColumns.has('branch_source_conversation_id')) {
|
||||
database.exec(`
|
||||
ALTER TABLE conversations
|
||||
ADD COLUMN branch_source_conversation_id TEXT;
|
||||
`)
|
||||
}
|
||||
if (!conversationColumns.has('branch_source_title')) {
|
||||
database.exec(`
|
||||
ALTER TABLE conversations
|
||||
ADD COLUMN branch_source_title TEXT;
|
||||
`)
|
||||
}
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS conversations_branch_source_idx
|
||||
ON conversations(branch_source_conversation_id)
|
||||
WHERE branch_source_conversation_id IS NOT NULL;
|
||||
PRAGMA user_version = 26;
|
||||
COMMIT;
|
||||
`)
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
).count
|
||||
check.close()
|
||||
migrated.close()
|
||||
expect(version).toBe(25)
|
||||
expect(version).toBe(26)
|
||||
expect(heartbeatTableCount).toBe(4)
|
||||
})
|
||||
|
||||
|
||||
+57
-1
@@ -2734,7 +2734,7 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('validates and forwards incremental saves and explicit deletions', async () => {
|
||||
it('validates and forwards incremental saves, branches, and explicit deletions', async () => {
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000301'
|
||||
const queuedAttachmentId =
|
||||
@@ -2774,6 +2774,16 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
})
|
||||
),
|
||||
saveLocalConversations: vi.fn(),
|
||||
branchLocalConversation: vi.fn((input) => ({
|
||||
id: '00000000-0000-4000-8000-000000000305',
|
||||
branch: {
|
||||
sourceConversationId: input.sourceConversationId,
|
||||
sourceTitle: '增量会话'
|
||||
},
|
||||
title: input.title,
|
||||
updatedAt: 2,
|
||||
messages: []
|
||||
})),
|
||||
deleteLocalConversation: vi.fn(() => true)
|
||||
}
|
||||
const contextManager = {
|
||||
@@ -2838,6 +2848,21 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
expect(
|
||||
assistantDatabase.saveLocalConversations
|
||||
).toHaveBeenCalledWith(batch)
|
||||
const branchInput = {
|
||||
sourceConversationId: conversationId,
|
||||
title: '增量会话 · 分支'
|
||||
}
|
||||
expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.conversationsBranchLocal
|
||||
)?.(event, branchInput)
|
||||
).toMatchObject({
|
||||
branch: { sourceConversationId: conversationId },
|
||||
title: branchInput.title
|
||||
})
|
||||
expect(
|
||||
assistantDatabase.branchLocalConversation
|
||||
).toHaveBeenCalledWith(branchInput)
|
||||
expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.conversationsDeleteLocal
|
||||
@@ -2867,6 +2892,14 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
}
|
||||
])
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.conversationsBranchLocal
|
||||
)?.(event, {
|
||||
sourceConversationId: 'not-a-uuid',
|
||||
title: '无效分支'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.conversationsDeleteLocal
|
||||
@@ -3348,6 +3381,17 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
queueScheduleNow: vi.fn(),
|
||||
completeScheduleRun: vi.fn(),
|
||||
branchLocalConversation: vi.fn(() => ({
|
||||
id: '00000000-0000-4000-8000-000000000403',
|
||||
branch: {
|
||||
sourceConversationId:
|
||||
'00000000-0000-4000-8000-000000000402',
|
||||
sourceTitle: '来源会话'
|
||||
},
|
||||
title: '来源会话 · 分支',
|
||||
updatedAt: Date.now(),
|
||||
messages: []
|
||||
})),
|
||||
listSchedules: vi.fn(() => []),
|
||||
createSchedule: vi.fn(),
|
||||
setScheduleEnabled: vi.fn(),
|
||||
@@ -3471,6 +3515,9 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
handler: electronMocks.handlers.get(ipcChannels.agentRun),
|
||||
statusHandler: electronMocks.handlers.get(ipcChannels.agentStatus),
|
||||
cancelHandler: electronMocks.handlers.get(ipcChannels.agentCancel),
|
||||
branchHandler: electronMocks.handlers.get(
|
||||
ipcChannels.conversationsBranchLocal
|
||||
),
|
||||
knowledgeSearchHandler: electronMocks.handlers.get(
|
||||
ipcChannels.knowledgeSearch
|
||||
),
|
||||
@@ -4016,6 +4063,15 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
})
|
||||
).rejects.toThrow('当前对话已有执行中的请求')
|
||||
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
||||
expect(() =>
|
||||
harness.branchHandler?.(trustedEvent(harness.webContents), {
|
||||
sourceConversationId: conversationId,
|
||||
title: '等待 Runtime · 分支'
|
||||
})
|
||||
).toThrow('当前会话仍有正在执行的请求')
|
||||
expect(
|
||||
harness.assistantDatabase.branchLocalConversation
|
||||
).not.toHaveBeenCalled()
|
||||
|
||||
resolveRuntime?.(runtime)
|
||||
await firstRun
|
||||
|
||||
@@ -152,6 +152,7 @@ import {
|
||||
} from '../shared/magic-notes-contracts'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
conversationBranchInputSchema,
|
||||
conversationSnapshotsSchema,
|
||||
localConversationSaveBatchSchema,
|
||||
memoryCreateSchema,
|
||||
@@ -4623,6 +4624,20 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.conversationsBranchLocal,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const parsed = conversationBranchInputSchema.parse(input)
|
||||
if (isConversationExecuting(parsed.sourceConversationId)) {
|
||||
throw new Error('当前会话仍有正在执行的请求,请等待完成后再创建分支')
|
||||
}
|
||||
return assistantDatabase.branchLocalConversation(
|
||||
parsed
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.conversationsDeleteLocal,
|
||||
(event, input: unknown) => {
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
AssistantExpert,
|
||||
AssistantTask,
|
||||
TokenUsageSummary,
|
||||
ConversationBranchInput,
|
||||
ConversationSnapshot,
|
||||
LocalConversationSaveBatch,
|
||||
WorkspaceChanges,
|
||||
@@ -629,6 +630,11 @@ const desktopApi: DesktopApi = {
|
||||
batch
|
||||
)
|
||||
},
|
||||
branchLocal: (input: ConversationBranchInput) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.conversationsBranchLocal,
|
||||
input
|
||||
) as Promise<ConversationSnapshot>,
|
||||
deleteLocal: (conversationId: string) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.conversationsDeleteLocal,
|
||||
|
||||
@@ -129,4 +129,15 @@ describe('sandboxed preload', () => {
|
||||
expect(source).toContain('ipcChannels.conversationQueueDispatch')
|
||||
expect(source).toContain('listener(conversationId)')
|
||||
})
|
||||
|
||||
it('exposes a narrow local conversation branch operation', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
||||
'utf8'
|
||||
)
|
||||
expect(source).toContain(
|
||||
'branchLocal: (input: ConversationBranchInput)'
|
||||
)
|
||||
expect(source).toContain('ipcChannels.conversationsBranchLocal')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -377,6 +377,16 @@ const api: DesktopApi = {
|
||||
list: vi.fn(async () => []),
|
||||
replace: vi.fn(async () => {}),
|
||||
saveLocal: vi.fn(async () => {}),
|
||||
branchLocal: vi.fn(async (input) => ({
|
||||
id: crypto.randomUUID(),
|
||||
branch: {
|
||||
sourceConversationId: input.sourceConversationId,
|
||||
sourceTitle: '新对话'
|
||||
},
|
||||
title: input.title,
|
||||
updatedAt: Date.now(),
|
||||
messages: []
|
||||
})),
|
||||
deleteLocal: vi.fn(async () => true),
|
||||
onChanged: vi.fn(() => () => undefined)
|
||||
},
|
||||
@@ -867,6 +877,18 @@ describe('App', () => {
|
||||
vi.mocked(api.conversations.saveLocal)
|
||||
.mockReset()
|
||||
.mockResolvedValue()
|
||||
vi.mocked(api.conversations.branchLocal)
|
||||
.mockReset()
|
||||
.mockImplementation(async (input) => ({
|
||||
id: crypto.randomUUID(),
|
||||
branch: {
|
||||
sourceConversationId: input.sourceConversationId,
|
||||
sourceTitle: '新对话'
|
||||
},
|
||||
title: input.title,
|
||||
updatedAt: Date.now(),
|
||||
messages: []
|
||||
}))
|
||||
vi.mocked(api.conversations.deleteLocal)
|
||||
.mockReset()
|
||||
.mockResolvedValue(true)
|
||||
@@ -1404,9 +1426,11 @@ describe('App', () => {
|
||||
expect(within(taskRegion).getByText('Execute')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses shared status dots for Conversation Task children', async () => {
|
||||
it('keeps completed Conversation Task metadata compact and accessible', async () => {
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000824'
|
||||
const scheduleId =
|
||||
'00000000-0000-4000-8000-000000000828'
|
||||
const conversation: ConversationSnapshot = {
|
||||
id: conversationId,
|
||||
projectId,
|
||||
@@ -1419,7 +1443,8 @@ describe('App', () => {
|
||||
id: '00000000-0000-4000-8000-000000000825',
|
||||
projectId,
|
||||
conversationId,
|
||||
title: '已完成任务',
|
||||
scheduleId,
|
||||
title: '归档任务',
|
||||
instructions: '检查完成状态',
|
||||
origin: 'schedule',
|
||||
status: 'completed',
|
||||
@@ -1446,10 +1471,25 @@ describe('App', () => {
|
||||
createdAt: '2026-08-19T01:00:00.000Z'
|
||||
}
|
||||
]
|
||||
const schedule: AssistantSchedule = {
|
||||
id: scheduleId,
|
||||
projectId,
|
||||
taskId: tasks[0]!.id,
|
||||
conversationId,
|
||||
title: tasks[0]!.title,
|
||||
prompt: tasks[0]!.instructions,
|
||||
workMode: 'ask',
|
||||
recurrence: 'once',
|
||||
nextRunAt: '2026-08-19T03:00:00.000Z',
|
||||
enabled: false,
|
||||
createdAt: tasks[0]!.createdAt,
|
||||
updatedAt: tasks[0]!.createdAt
|
||||
}
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([
|
||||
conversation
|
||||
])
|
||||
vi.mocked(api.tasks.list).mockResolvedValue(tasks)
|
||||
vi.mocked(api.schedules.list).mockResolvedValue([schedule])
|
||||
|
||||
render(<App />)
|
||||
|
||||
@@ -1458,8 +1498,32 @@ describe('App', () => {
|
||||
'展开或折叠“任务状态会话”中的 3 个任务'
|
||||
)
|
||||
)
|
||||
const completedTitle = await screen.findByText('归档任务', {
|
||||
selector: '.conversation-task-child__title'
|
||||
})
|
||||
const completedTask = completedTitle.closest('button')
|
||||
const completedStatus = completedTask?.querySelector(
|
||||
'.conversation-task-child__completed-status'
|
||||
)
|
||||
expect(completedStatus).toHaveClass(
|
||||
'task-status-dot',
|
||||
'task-status-dot--completed'
|
||||
)
|
||||
expect(completedStatus).toHaveAttribute('title', '已完成')
|
||||
expect(
|
||||
completedStatus?.querySelector('.lucide-check')
|
||||
).toBeInTheDocument()
|
||||
expect(completedTask?.firstElementChild).toBe(completedTitle)
|
||||
expect(completedTask?.lastElementChild).toBe(completedStatus)
|
||||
expect(completedTask).toHaveAccessibleName(/已完成/u)
|
||||
expect(
|
||||
completedTask?.querySelector('.conversation-task-child__meta')
|
||||
).toHaveTextContent('Ask · 仅一次')
|
||||
expect(
|
||||
completedTask?.querySelector('.conversation-task-child__meta')
|
||||
).not.toHaveTextContent('已完成')
|
||||
|
||||
for (const [title, status, label] of [
|
||||
['已完成任务', 'completed', '已完成'],
|
||||
['运行中任务', 'running', '运行中'],
|
||||
['失败任务', 'failed', '失败']
|
||||
] as const) {
|
||||
@@ -1470,6 +1534,10 @@ describe('App', () => {
|
||||
expect(
|
||||
taskChild?.querySelector('.task-status-dot')
|
||||
).toHaveClass(`task-status-dot--${status}`)
|
||||
expect(taskChild?.firstElementChild).toBe(taskTitle)
|
||||
expect(taskChild?.lastElementChild).toBe(
|
||||
taskChild?.querySelector('.task-status-dot')
|
||||
)
|
||||
expect(
|
||||
taskChild?.querySelector('.conversation-task-child__meta')
|
||||
).toHaveTextContent(label)
|
||||
@@ -2172,6 +2240,242 @@ describe('App', () => {
|
||||
expect(await screen.findByRole('status')).toBeVisible()
|
||||
})
|
||||
|
||||
it('flushes and opens an independent conversation branch with provenance badges', async () => {
|
||||
const sourceConversationId =
|
||||
'00000000-0000-4000-8000-000000000421'
|
||||
const branchConversationId =
|
||||
'00000000-0000-4000-8000-000000000422'
|
||||
const sourceConversation: ConversationSnapshot = {
|
||||
id: sourceConversationId,
|
||||
projectId,
|
||||
title: '方案讨论',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000423',
|
||||
role: 'user',
|
||||
content: '探索不同发布方案',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete'
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000424',
|
||||
role: 'assistant',
|
||||
content: '可以比较风险、成本和回滚路径。',
|
||||
createdAt: 1_775_000_001_000,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
sourceConversation
|
||||
])
|
||||
vi.mocked(api.conversations.branchLocal).mockImplementationOnce(
|
||||
async (input) => ({
|
||||
...sourceConversation,
|
||||
id: branchConversationId,
|
||||
branch: {
|
||||
sourceConversationId,
|
||||
sourceTitle: '方案讨论更新'
|
||||
},
|
||||
contextMetrics: undefined,
|
||||
contextCompressionState: undefined,
|
||||
title: input.title,
|
||||
updatedAt: 1_775_000_002_000,
|
||||
messages: sourceConversation.messages.map((message, index) => ({
|
||||
...message,
|
||||
id: `00000000-0000-4000-8000-${String(
|
||||
430 + index
|
||||
).padStart(12, '0')}`
|
||||
}))
|
||||
})
|
||||
)
|
||||
const { container } = render(<App />)
|
||||
expect(
|
||||
await screen.findByText('探索不同发布方案')
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByLabelText('更多会话操作 方案讨论')
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '重命名会话' })
|
||||
)
|
||||
const renameInput = screen.getByLabelText('重命名会话 方案讨论')
|
||||
fireEvent.change(renameInput, {
|
||||
target: { value: '方案讨论更新' }
|
||||
})
|
||||
fireEvent.submit(renameInput.closest('form')!)
|
||||
expect(
|
||||
await screen.findAllByText('方案讨论更新')
|
||||
).toHaveLength(2)
|
||||
vi.mocked(api.conversations.saveLocal).mockClear()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByLabelText('更多会话操作 方案讨论更新')
|
||||
)
|
||||
const branchButton = screen.getByRole('button', {
|
||||
name: '在新会话中继续'
|
||||
})
|
||||
await waitFor(() => expect(branchButton).toBeEnabled())
|
||||
fireEvent.click(branchButton)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
header: expect.objectContaining({
|
||||
id: sourceConversationId,
|
||||
title: '方案讨论更新'
|
||||
})
|
||||
})
|
||||
])
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.branchLocal).toHaveBeenCalledWith({
|
||||
sourceConversationId,
|
||||
title: '方案讨论更新 · 分支'
|
||||
})
|
||||
)
|
||||
expect(
|
||||
vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder.at(-1)
|
||||
).toBeLessThan(
|
||||
vi.mocked(api.conversations.branchLocal).mock.invocationCallOrder[0]!
|
||||
)
|
||||
expect(
|
||||
await screen.findAllByLabelText(
|
||||
'分支会话,来源:方案讨论更新'
|
||||
)
|
||||
).toHaveLength(2)
|
||||
const sidebar = container.querySelector('.conversation-list')
|
||||
const badge = within(sidebar as HTMLElement).getByLabelText(
|
||||
'分支会话,来源:方案讨论更新'
|
||||
)
|
||||
expect(badge).toHaveClass('conversation-branch-badge')
|
||||
expect(badge.querySelector('.lucide-git-fork')).toBeInTheDocument()
|
||||
expect(
|
||||
container.querySelector('.conversation-title')
|
||||
).toHaveTextContent('方案讨论更新 · 分支')
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveFocus()
|
||||
)
|
||||
expect(
|
||||
screen.getAllByText('可以比较风险、成本和回滚路径。').length
|
||||
).toBeGreaterThan(0)
|
||||
expect(
|
||||
await screen.findByText('已创建并打开分支会话')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the source conversation open when branch persistence fails', async () => {
|
||||
const sourceConversationId =
|
||||
'00000000-0000-4000-8000-000000000441'
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: sourceConversationId,
|
||||
projectId,
|
||||
title: '保留的来源会话',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000442',
|
||||
role: 'user',
|
||||
content: '不能丢失的来源内容',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
vi.mocked(api.conversations.branchLocal).mockRejectedValueOnce(
|
||||
new Error('分支数据写入失败')
|
||||
)
|
||||
const { container } = render(<App />)
|
||||
expect(
|
||||
await screen.findByText('不能丢失的来源内容')
|
||||
).toBeInTheDocument()
|
||||
|
||||
const actionTrigger = await screen.findByLabelText(
|
||||
'更多会话操作 保留的来源会话'
|
||||
)
|
||||
fireEvent.click(actionTrigger)
|
||||
const branchButton = screen.getByRole('button', {
|
||||
name: '在新会话中继续'
|
||||
})
|
||||
await waitFor(() => expect(branchButton).toBeEnabled())
|
||||
fireEvent.click(branchButton)
|
||||
|
||||
expect(
|
||||
await screen.findByText('分支数据写入失败')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
container.querySelector('.conversation-title')
|
||||
).toHaveTextContent('保留的来源会话')
|
||||
expect(
|
||||
screen.getByText('不能丢失的来源内容')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText(/^分支会话,来源:/u)
|
||||
).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(actionTrigger).toHaveFocus())
|
||||
})
|
||||
|
||||
it('explains why a queued conversation cannot be branched', async () => {
|
||||
const sourceConversationId =
|
||||
'00000000-0000-4000-8000-000000000451'
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: sourceConversationId,
|
||||
projectId,
|
||||
title: '等待队列的会话',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000452',
|
||||
role: 'user',
|
||||
content: '还有待发送内容',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
vi.mocked(api.conversationQueue.list).mockResolvedValue([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000453',
|
||||
conversationId: sourceConversationId,
|
||||
source: 'user',
|
||||
label: '待发送',
|
||||
createdAt: '2026-08-20T08:00:00.000Z'
|
||||
}
|
||||
])
|
||||
render(<App />)
|
||||
expect(
|
||||
await screen.findByText('还有待发送内容')
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByLabelText('更多会话操作 等待队列的会话')
|
||||
)
|
||||
const branchButton = screen.getByRole('button', {
|
||||
name: '在新会话中继续'
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(branchButton).toHaveAttribute('aria-disabled', 'true')
|
||||
)
|
||||
expect(branchButton).toHaveAccessibleDescription(
|
||||
'会话运行或待发送内容处理完后才能创建分支'
|
||||
)
|
||||
fireEvent.click(branchButton)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'会话运行或待发送内容处理完后才能创建分支'
|
||||
)
|
||||
).toHaveLength(2)
|
||||
)
|
||||
expect(api.conversations.branchLocal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers a floating control when more messages remain below', async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
+243
-13
@@ -11,6 +11,7 @@ import {
|
||||
Download,
|
||||
Edit3,
|
||||
FileText,
|
||||
GitFork,
|
||||
HeartPulse,
|
||||
Info,
|
||||
Library,
|
||||
@@ -119,6 +120,7 @@ import type {
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
conversationAttachmentSchema,
|
||||
conversationBranchSchema,
|
||||
conversationContextCompressionMarkerSchema,
|
||||
conversationContextMetricsSchema,
|
||||
conversationMessageBlocksSchema,
|
||||
@@ -679,6 +681,39 @@ function displayErrorMessage(reason: unknown, fallback: string): string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function createConversationBranchTitle(
|
||||
sourceTitle: string,
|
||||
suffix: string
|
||||
): string {
|
||||
const trailing = ` · ${suffix}`
|
||||
if (trailing.length >= 200) {
|
||||
return suffix.slice(0, 200)
|
||||
}
|
||||
return `${sourceTitle
|
||||
.slice(0, 200 - trailing.length)
|
||||
.trimEnd()}${trailing}`
|
||||
}
|
||||
|
||||
function ConversationBranchBadge({
|
||||
sourceTitle
|
||||
}: {
|
||||
sourceTitle: string
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const label = t('conversation.branch.badge', {
|
||||
title: sourceTitle
|
||||
})
|
||||
return (
|
||||
<span
|
||||
aria-label={label}
|
||||
className="conversation-branch-badge"
|
||||
title={label}
|
||||
>
|
||||
<GitFork aria-hidden="true" size={13} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function isUnusedConversation(conversation: Conversation): boolean {
|
||||
return (
|
||||
conversation.title === '新对话' &&
|
||||
@@ -1143,6 +1178,8 @@ function isConversation(value: unknown): value is Conversation {
|
||||
(item.knowledgeRetrievalMode === undefined ||
|
||||
item.knowledgeRetrievalMode === 'auto' ||
|
||||
item.knowledgeRetrievalMode === 'always') &&
|
||||
(item.branch === undefined ||
|
||||
conversationBranchSchema.safeParse(item.branch).success) &&
|
||||
(item.remote === undefined ||
|
||||
(typeof item.remote === 'object' &&
|
||||
item.remote !== null &&
|
||||
@@ -1228,6 +1265,7 @@ function toConversationSnapshots(
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||
title: conversation.title,
|
||||
updatedAt: conversation.updatedAt,
|
||||
messages: conversation.messages
|
||||
@@ -1269,6 +1307,7 @@ function toLocalConversationHeader(
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||
title: conversation.title,
|
||||
updatedAt: conversation.updatedAt
|
||||
}
|
||||
@@ -2284,6 +2323,8 @@ function App(): React.JSX.Element {
|
||||
useState('')
|
||||
const [deletingConversationId, setDeletingConversationId] =
|
||||
useState('')
|
||||
const [branchingConversationId, setBranchingConversationId] =
|
||||
useState('')
|
||||
const [renamingConversationId, setRenamingConversationId] = useState('')
|
||||
const [notifications, notify] = useReducer(
|
||||
appNotificationReducer,
|
||||
@@ -3268,6 +3309,13 @@ function App(): React.JSX.Element {
|
||||
const activeProjectDisplayName = activeProject
|
||||
? getProjectDisplayText(activeProject, tWorkspace).name
|
||||
: undefined
|
||||
const queuedConversationIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
conversationQueueItems.map((item) => item.conversationId)
|
||||
),
|
||||
[conversationQueueItems]
|
||||
)
|
||||
const filteredConversations = useMemo(() => {
|
||||
const query = deferredSearchQuery.trim().toLocaleLowerCase()
|
||||
const candidates = query
|
||||
@@ -5757,6 +5805,80 @@ function App(): React.JSX.Element {
|
||||
setActiveId(replacement.id)
|
||||
}
|
||||
|
||||
const branchConversation = async (
|
||||
sourceConversation: Conversation
|
||||
): Promise<void> => {
|
||||
if (
|
||||
branchingConversationId ||
|
||||
sourceConversation.remote ||
|
||||
!conversationStoreReady ||
|
||||
activeConversationIds.has(sourceConversation.id) ||
|
||||
queuedConversationIds.has(sourceConversation.id)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setBranchingConversationId(sourceConversation.id)
|
||||
try {
|
||||
persistLocalConversationChanges()
|
||||
await conversationPersistenceQueueRef.current
|
||||
if (
|
||||
persistedLocalConversationsRef.current.get(
|
||||
sourceConversation.id
|
||||
) !== sourceConversation ||
|
||||
conversationsRef.current.find(
|
||||
(conversation) =>
|
||||
conversation.id === sourceConversation.id
|
||||
) !== sourceConversation
|
||||
) {
|
||||
throw new Error(t('notices.conversationPersistenceFailed'))
|
||||
}
|
||||
const sourceTitle = getConversationDisplayTitle(
|
||||
sourceConversation,
|
||||
t('conversation.defaultTitle')
|
||||
)
|
||||
const branch = await window.goodbuddy.conversations.branchLocal({
|
||||
sourceConversationId: sourceConversation.id,
|
||||
title: createConversationBranchTitle(
|
||||
sourceTitle,
|
||||
t('conversation.branch.suffix')
|
||||
)
|
||||
})
|
||||
const nextBranch: Conversation = branch
|
||||
persistedLocalConversationsRef.current.set(
|
||||
nextBranch.id,
|
||||
nextBranch
|
||||
)
|
||||
setConversations((current) => [
|
||||
nextBranch,
|
||||
...current.filter(
|
||||
(conversation) => conversation.id !== nextBranch.id
|
||||
)
|
||||
])
|
||||
setSelectedAssistantTaskId(undefined)
|
||||
setActiveId(nextBranch.id)
|
||||
setView('chat')
|
||||
if (narrowWindow) {
|
||||
closeNarrowSidebar()
|
||||
}
|
||||
notify({
|
||||
tone: 'success',
|
||||
message: t('notices.conversationBranched')
|
||||
})
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
} catch (error) {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: displayErrorMessage(
|
||||
error,
|
||||
t('notices.conversationBranchFailed')
|
||||
)
|
||||
})
|
||||
focusConversationActions(sourceConversation.id)
|
||||
} finally {
|
||||
setBranchingConversationId('')
|
||||
}
|
||||
}
|
||||
|
||||
const focusConversationActions = (conversationId: string): void => {
|
||||
requestAnimationFrame(() =>
|
||||
conversationActionTriggerRefs.current.get(conversationId)?.focus()
|
||||
@@ -7445,6 +7567,25 @@ function App(): React.JSX.Element {
|
||||
conversation,
|
||||
t('conversation.defaultTitle')
|
||||
)
|
||||
const branchSourceTitle = conversation.branch
|
||||
? conversationTitles.get(
|
||||
conversation.branch.sourceConversationId
|
||||
) ?? conversation.branch.sourceTitle
|
||||
: undefined
|
||||
const branchUnavailable =
|
||||
activeConversationIds.has(conversation.id) ||
|
||||
queuedConversationIds.has(conversation.id)
|
||||
const branchDisabledReason = !conversationStoreReady
|
||||
? t('conversation.branch.storageUnavailable')
|
||||
: branchingConversationId
|
||||
? branchingConversationId === conversation.id
|
||||
? t('conversation.branch.creating')
|
||||
: t('conversation.branch.anotherCreating')
|
||||
: branchUnavailable
|
||||
? t('conversation.branch.unavailable')
|
||||
: undefined
|
||||
const branchDisabledReasonId =
|
||||
`conversation-branch-disabled-${conversation.id}`
|
||||
return (
|
||||
<div className="conversation-entry" key={conversation.id}>
|
||||
<div
|
||||
@@ -7508,6 +7649,11 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
<span className="conversation-item__primary">
|
||||
{branchSourceTitle && (
|
||||
<ConversationBranchBadge
|
||||
sourceTitle={branchSourceTitle}
|
||||
/>
|
||||
)}
|
||||
{conversation.remote && (
|
||||
<b className="conversation-source-badge">
|
||||
{
|
||||
@@ -7595,6 +7741,50 @@ function App(): React.JSX.Element {
|
||||
className="conversation-actions"
|
||||
id={`conversation-actions-${conversation.id}`}
|
||||
>
|
||||
{!conversation.remote && (
|
||||
<>
|
||||
<button
|
||||
aria-describedby={
|
||||
branchDisabledReason
|
||||
? branchDisabledReasonId
|
||||
: undefined
|
||||
}
|
||||
aria-disabled={
|
||||
branchDisabledReason ? true : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (branchDisabledReason) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
message: branchDisabledReason
|
||||
})
|
||||
return
|
||||
}
|
||||
setConversationActionsId('')
|
||||
void branchConversation(conversation)
|
||||
}}
|
||||
title={branchDisabledReason}
|
||||
type="button"
|
||||
>
|
||||
<GitFork
|
||||
aria-hidden="true"
|
||||
className="conversation-branch-icon"
|
||||
size={14}
|
||||
/>
|
||||
{branchingConversationId === conversation.id
|
||||
? t('conversation.branch.creating')
|
||||
: t('conversation.actions.branch')}
|
||||
</button>
|
||||
{branchDisabledReason && (
|
||||
<span
|
||||
className="sr-only"
|
||||
id={branchDisabledReasonId}
|
||||
>
|
||||
{branchDisabledReason}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!conversation.remote && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -7722,6 +7912,24 @@ function App(): React.JSX.Element {
|
||||
task,
|
||||
assistantSchedules
|
||||
)
|
||||
const statusLabel = tWorkspace(
|
||||
`task.status.${task.status}`
|
||||
)
|
||||
const metadata = schedule
|
||||
? [
|
||||
tWorkspace(`task.mode.${schedule.workMode}`),
|
||||
tWorkspace(
|
||||
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`
|
||||
),
|
||||
task.status === 'completed'
|
||||
? undefined
|
||||
: statusLabel
|
||||
]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(' · ')
|
||||
: task.status === 'completed'
|
||||
? ''
|
||||
: statusLabel
|
||||
return (
|
||||
<li key={task.id}>
|
||||
<button
|
||||
@@ -7738,21 +7946,34 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`task-status-dot task-status-dot--${task.status}`}
|
||||
/>
|
||||
<span className="conversation-task-child__title">
|
||||
{task.title}
|
||||
</span>
|
||||
<small className="conversation-task-child__meta">
|
||||
{schedule
|
||||
? `${tWorkspace(`task.mode.${schedule.workMode}`)} · ${tWorkspace(
|
||||
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`
|
||||
)} · `
|
||||
: ''}
|
||||
{tWorkspace(`task.status.${task.status}`)}
|
||||
</small>
|
||||
{metadata && (
|
||||
<small className="conversation-task-child__meta">
|
||||
{metadata}
|
||||
</small>
|
||||
)}
|
||||
{task.status === 'completed' ? (
|
||||
<span
|
||||
className="task-status-dot task-status-dot--completed conversation-task-child__completed-status"
|
||||
title={statusLabel}
|
||||
>
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
size={7}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{statusLabel}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`task-status-dot task-status-dot--${task.status}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
@@ -7847,7 +8068,7 @@ function App(): React.JSX.Element {
|
||||
className="conversation-title"
|
||||
title={activeConversation?.title}
|
||||
>
|
||||
<span>
|
||||
<span className="conversation-title__text">
|
||||
{activeConversation
|
||||
? getConversationDisplayTitle(
|
||||
activeConversation,
|
||||
@@ -7858,6 +8079,15 @@ function App(): React.JSX.Element {
|
||||
? t('conversation.remoteTitle')
|
||||
: t('conversation.defaultTitle'))}
|
||||
</span>
|
||||
{activeConversation?.branch && (
|
||||
<ConversationBranchBadge
|
||||
sourceTitle={
|
||||
conversationTitles.get(
|
||||
activeConversation.branch.sourceConversationId
|
||||
) ?? activeConversation.branch.sourceTitle
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeConversation?.remote && (
|
||||
<b className="conversation-source-badge">
|
||||
{
|
||||
|
||||
@@ -178,6 +178,9 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(forcedColors).toMatch(
|
||||
/\.task-status-dot--failed,[\s\S]*?forced-color-adjust:\s*none;/u
|
||||
)
|
||||
expect(forcedColors).toMatch(
|
||||
/:root \.conversation-task-child__completed-status\s*\{[^}]*color:\s*Canvas;/u
|
||||
)
|
||||
expect(forcedColors).toMatch(
|
||||
/\.heartbeat-center__meter\s*\{[^}]*forced-color-adjust:\s*none;/u
|
||||
)
|
||||
@@ -210,6 +213,12 @@ describe('WorkspacePrimitives', () => {
|
||||
})
|
||||
|
||||
it('keeps the conversation action close to the sidebar edge', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.conversation-row:has\(\.conversation-task-toggle\) \.conversation-item\s*\{[^}]*padding-left:\s*34px;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.conversation-task-toggle\s*\{[^}]*left:\s*3px;[^}]*width:\s*28px;[^}]*height:\s*28px;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.conversation-row \.conversation-item\s*\{[^}]*padding-right:\s*36px;/u
|
||||
)
|
||||
@@ -225,6 +234,15 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(stylesheet).not.toContain('.conversation-delete')
|
||||
})
|
||||
|
||||
it('shows conversation provenance with a horizontal fork badge', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.conversation-branch-badge\s*\{[^}]*width:\s*20px;[^}]*height:\s*18px;[^}]*background:\s*var\(--accent-subtle\);[^}]*color:\s*var\(--accent\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.conversation-branch-badge svg,\s*\.conversation-branch-icon\s*\{[^}]*transform:\s*rotate\(90deg\);/u
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the shared control and menu surfaces for the rich project picker', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.project-switcher__trigger\s*\{[^}]*height:\s*var\(--control-height\);[^}]*border-radius:\s*var\(--radius-control\);[^}]*background:\s*var\(--surface-raised\);/u
|
||||
|
||||
@@ -86,9 +86,20 @@ export const app = {
|
||||
saveName: 'Save conversation name',
|
||||
cancelRename: 'Cancel rename',
|
||||
exportFallbackName: 'GoodBuddy conversation',
|
||||
branch: {
|
||||
badge: 'Branched conversation, source: {{title}}',
|
||||
suffix: 'Branch',
|
||||
creating: 'Creating branch',
|
||||
anotherCreating: 'Another branch is being created. Please wait.',
|
||||
storageUnavailable:
|
||||
'Conversation storage is not ready yet. Please wait.',
|
||||
unavailable:
|
||||
'Finish the active run or pending items before creating a branch'
|
||||
},
|
||||
actions: {
|
||||
more: 'More conversation actions for {{title}}',
|
||||
region: 'Conversation actions for {{title}}',
|
||||
branch: 'Continue in a new conversation',
|
||||
rename: 'Rename conversation',
|
||||
copy: 'Copy full conversation',
|
||||
export: 'Export Markdown'
|
||||
@@ -572,6 +583,9 @@ export const app = {
|
||||
clipboardUnavailable:
|
||||
'Cannot access the clipboard. Check your system permissions.',
|
||||
conversationExported: 'Conversation exported',
|
||||
conversationBranched: 'Branch created and opened',
|
||||
conversationBranchFailed:
|
||||
'Failed to create a conversation branch. Try again.',
|
||||
imageUnavailable: 'Image content is unavailable',
|
||||
imageDownloadStarted: 'Image download started',
|
||||
remoteConversationReadOnly:
|
||||
|
||||
@@ -83,9 +83,18 @@ export const app = {
|
||||
saveName: '保存会话名称',
|
||||
cancelRename: '取消重命名',
|
||||
exportFallbackName: 'GoodBuddy 对话',
|
||||
branch: {
|
||||
badge: '分支会话,来源:{{title}}',
|
||||
suffix: '分支',
|
||||
creating: '正在创建分支',
|
||||
anotherCreating: '另一条分支正在创建,请稍候',
|
||||
storageUnavailable: '会话存储尚未就绪,请稍候',
|
||||
unavailable: '会话运行或待发送内容处理完后才能创建分支'
|
||||
},
|
||||
actions: {
|
||||
more: '更多会话操作 {{title}}',
|
||||
region: '{{title}} 的会话操作',
|
||||
branch: '在新会话中继续',
|
||||
rename: '重命名会话',
|
||||
copy: '复制完整会话',
|
||||
export: '导出 Markdown'
|
||||
@@ -537,6 +546,8 @@ export const app = {
|
||||
conversationCopied: '对话已复制到剪贴板',
|
||||
clipboardUnavailable: '无法访问剪贴板,请检查系统权限',
|
||||
conversationExported: '对话已导出',
|
||||
conversationBranched: '已创建并打开分支会话',
|
||||
conversationBranchFailed: '创建分支会话失败,请重试',
|
||||
imageUnavailable: '图片内容不可用',
|
||||
imageDownloadStarted: '图片下载已开始',
|
||||
remoteConversationReadOnly: '远程通道会话只能从对应消息应用继续发起',
|
||||
|
||||
@@ -95,16 +95,16 @@
|
||||
}
|
||||
|
||||
.conversation-row:has(.conversation-task-toggle) .conversation-item {
|
||||
padding-left: 42px;
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
.conversation-task-toggle {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 7px;
|
||||
left: 3px;
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
@@ -180,6 +180,14 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-task-child__completed-status {
|
||||
display: grid;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
color: var(--text-on-accent);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.conversation-task-view-all {
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
@@ -2792,6 +2800,7 @@ textarea:focus-visible {
|
||||
|
||||
.conversation-item__primary
|
||||
> :where(
|
||||
.conversation-branch-badge,
|
||||
.conversation-source-badge,
|
||||
.conversation-unread
|
||||
) {
|
||||
@@ -2841,6 +2850,25 @@ textarea:focus-visible {
|
||||
transition-timing-function: linear;
|
||||
}
|
||||
|
||||
.conversation-branch-badge {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 5px;
|
||||
margin-right: var(--space-1);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.conversation-branch-badge svg,
|
||||
.conversation-branch-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.conversation-source-badge {
|
||||
display: inline-flex;
|
||||
margin-right: var(--space-1);
|
||||
@@ -3926,12 +3954,17 @@ button > svg {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation-title span {
|
||||
.conversation-title__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-title .conversation-branch-badge {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.topbar__actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -8910,6 +8943,13 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.conversation-actions button[aria-disabled='true'] {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.conversation-rename {
|
||||
display: grid;
|
||||
padding: var(--space-1);
|
||||
@@ -13253,6 +13293,10 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
|
||||
:root .conversation-task-child__completed-status {
|
||||
color: Canvas;
|
||||
}
|
||||
|
||||
:root :is(
|
||||
.task-status-dot--failed,
|
||||
.task-status-dot--interrupted,
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { AssistantProject } from './assistant-contracts'
|
||||
import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
builtInDefaultProjectSeedName,
|
||||
conversationBranchInputSchema,
|
||||
conversationSnapshotSchema,
|
||||
isUntouchedBuiltInDefaultProject,
|
||||
projectCreateSchema,
|
||||
projectUpdateSchema
|
||||
@@ -72,3 +74,38 @@ describe('isUntouchedBuiltInDefaultProject', () => {
|
||||
expect(projectUpdateSchema.safeParse(input).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation branch contracts', () => {
|
||||
it('accepts bounded branch provenance and rejects renderer extras', () => {
|
||||
const sourceConversationId =
|
||||
'00000000-0000-4000-8000-000000000201'
|
||||
expect(
|
||||
conversationBranchInputSchema.parse({
|
||||
sourceConversationId,
|
||||
title: '方案讨论 · 分支'
|
||||
})
|
||||
).toEqual({
|
||||
sourceConversationId,
|
||||
title: '方案讨论 · 分支'
|
||||
})
|
||||
expect(
|
||||
conversationBranchInputSchema.safeParse({
|
||||
sourceConversationId,
|
||||
title: '方案讨论 · 分支',
|
||||
copyTasks: true
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
conversationSnapshotSchema.safeParse({
|
||||
id: '00000000-0000-4000-8000-000000000202',
|
||||
branch: {
|
||||
sourceConversationId,
|
||||
sourceTitle: '方案讨论'
|
||||
},
|
||||
title: '方案讨论 · 分支',
|
||||
updatedAt: 1,
|
||||
messages: []
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -346,6 +346,24 @@ export type ConversationContextCompressionState = z.infer<
|
||||
typeof conversationContextCompressionStateSchema
|
||||
>
|
||||
|
||||
export const conversationBranchInputSchema = z
|
||||
.object({
|
||||
sourceConversationId: assistantIdSchema,
|
||||
title: z.string().trim().min(1).max(200)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ConversationBranchInput = z.infer<
|
||||
typeof conversationBranchInputSchema
|
||||
>
|
||||
|
||||
export const conversationBranchSchema = z
|
||||
.object({
|
||||
sourceConversationId: assistantIdSchema,
|
||||
sourceTitle: z.string().trim().min(1).max(200)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const conversationSnapshotSchema = z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
@@ -363,6 +381,7 @@ export const conversationSnapshotSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
branch: conversationBranchSchema.optional(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
updatedAt: z.number().int().nonnegative(),
|
||||
messages: z
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type TokenUsageSummary,
|
||||
type ConversationSnapshot,
|
||||
type ConversationAttachment,
|
||||
type ConversationBranchInput,
|
||||
type ConversationQueueItem,
|
||||
type ConversationContextCompressionState,
|
||||
type LocalConversationSaveBatch,
|
||||
@@ -1497,6 +1498,9 @@ export type DesktopApi = {
|
||||
list: () => Promise<ConversationSnapshot[]>
|
||||
replace: (conversations: ConversationSnapshot[]) => Promise<void>
|
||||
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
|
||||
branchLocal: (
|
||||
input: ConversationBranchInput
|
||||
) => Promise<ConversationSnapshot>
|
||||
deleteLocal: (conversationId: string) => Promise<boolean>
|
||||
onChanged: (
|
||||
listener: (conversationId?: string) => void
|
||||
|
||||
@@ -95,6 +95,7 @@ export const ipcChannels = {
|
||||
conversationsList: 'conversations:list',
|
||||
conversationsReplace: 'conversations:replace',
|
||||
conversationsSaveLocal: 'conversations:save-local',
|
||||
conversationsBranchLocal: 'conversations:branch-local',
|
||||
conversationsDeleteLocal: 'conversations:delete-local',
|
||||
conversationsChanged: 'conversations:changed',
|
||||
conversationQueueList: 'conversation-queue:list',
|
||||
|
||||
Reference in New Issue
Block a user