feat: configure supervisor model and story graph use per conversation
Add a supervisor text-model selector using existing connections and a per-conversation story graph switch above knowledge settings. Hide the switch when Supervisor is disabled and enforce conversation preferences at local and remote tool execution boundaries. Release note: Choose a dedicated supervisor model and control whether each conversation can query existing stories and experiences. Enabling graph access does not start a review or automatically insert context.
This commit is contained in:
@@ -5,7 +5,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from '../main/assistant/assistant-database'
|
||||
import type { KnowledgeService } from '../main/knowledge/knowledge-service'
|
||||
import { KnowledgeMcpGateway } from '../main/agent/knowledge-mcp-gateway'
|
||||
import { KnowledgeMcpGateway, type StoryGraphBinding } from '../main/agent/knowledge-mcp-gateway'
|
||||
import { ModelToolProvider } from '../main/agent/model-tool-provider'
|
||||
import { AgentImageToolMcp } from './image-tool-mcp'
|
||||
import { MainImageToolSession } from '../main/remote-agent/main-image-tool-session'
|
||||
@@ -19,18 +19,20 @@ function fixture() {
|
||||
const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd())
|
||||
cleanups.push(() => db.close())
|
||||
const projectId = db.listProjects()[0]!.id
|
||||
const conversationId = randomUUID()
|
||||
db.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Graph', updatedAt: Date.now() }, messages: [] }])
|
||||
db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } },
|
||||
evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'original', title: 'Evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A is revised to B, C remains only an option.' }],
|
||||
output: { summary: 'Decision history', changeDigest: '', openItems: ['Not yet executed'], entities: [{ id: 'decision', label: 'Decision', description: 'Decision A revised to B', sourceReferenceIds: ['quote'] }],
|
||||
events: [], relations: [], entityChanges: [] } })
|
||||
let enabled = true, assigned = true
|
||||
const read = vi.fn(db.readStoryGraph.bind(db))
|
||||
const available = vi.fn(async () => enabled && assigned)
|
||||
const available = vi.fn(async (binding: StoryGraphBinding) => enabled && assigned && db.isConversationStoryGraphEnabled(binding.conversationId!))
|
||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available, read } })
|
||||
cleanups.push(() => gateway.dispose())
|
||||
const wait = new AbortController()
|
||||
const token = gateway.grant('graph', [], wait.signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: 'model' })!
|
||||
return { db, projectId, gateway, token, wait, read, available,
|
||||
const token = gateway.grant('graph', [], wait.signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, conversationId, runtimeTarget: 'model' })!
|
||||
return { db, projectId, conversationId, gateway, token, wait, read, available,
|
||||
disable: () => { enabled = false }, unassign: () => { assigned = false } }
|
||||
}
|
||||
|
||||
@@ -41,7 +43,7 @@ async function client(url: string, token?: string) {
|
||||
return connection
|
||||
}
|
||||
|
||||
it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and revokes discovery and old calls when disabled', async transport => {
|
||||
it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and revokes discovery and old calls when conversation is disabled', async transport => {
|
||||
const f = fixture()
|
||||
let connection: Client
|
||||
if (transport === 'local') {
|
||||
@@ -84,11 +86,15 @@ it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and
|
||||
expect(unsupported.isError).toBe(true)
|
||||
expect(JSON.stringify(unsupported)).toContain('unsupported_mode')
|
||||
const before = f.read.mock.calls.length
|
||||
f.disable()
|
||||
f.db.setConversationStoryGraphEnabled(f.conversationId, false)
|
||||
expect((await connection.listTools()).tools).toEqual([])
|
||||
const blocked = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', cursor: result.page.next_cursor, page_size: 1 } })
|
||||
expect(blocked.isError).toBe(true)
|
||||
expect(f.read).toHaveBeenCalledTimes(before)
|
||||
f.db.setConversationStoryGraphEnabled(f.conversationId, true)
|
||||
expect((await connection.listTools()).tools.map(tool => tool.name)).toEqual(storyGraphToolNames)
|
||||
f.disable()
|
||||
expect((await connection.listTools()).tools).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and runtime assignment gate', async workMode => {
|
||||
@@ -99,6 +105,10 @@ it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and r
|
||||
expect((await provider.listTools(context, f.wait.signal)).filter(tool => tool.name.startsWith('story_graph_')).map(tool => tool.name)).toEqual(storyGraphToolNames)
|
||||
const result = await provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)
|
||||
expect(JSON.stringify(result)).toContain(f.projectId)
|
||||
f.db.setConversationStoryGraphEnabled(f.conversationId, false)
|
||||
expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false)
|
||||
await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable')
|
||||
f.db.setConversationStoryGraphEnabled(f.conversationId, true)
|
||||
f.unassign()
|
||||
expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false)
|
||||
await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable')
|
||||
@@ -110,6 +120,6 @@ it('rejects disabled in-flight delivery and cancellation before the synchronous
|
||||
await expect(f.gateway.callStoryGraphTool(f.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow()
|
||||
expect(f.read).not.toHaveBeenCalled()
|
||||
const other = fixture()
|
||||
other.read.mockImplementationOnce((...args) => { const result = other.db.readStoryGraph(...args); other.disable(); return result })
|
||||
other.read.mockImplementationOnce((...args) => { const result = other.db.readStoryGraph(...args); other.db.setConversationStoryGraphEnabled(other.conversationId, false); return result })
|
||||
await expect(other.gateway.callStoryGraphTool(other.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow('story_graph_unavailable')
|
||||
})
|
||||
|
||||
@@ -109,12 +109,14 @@ it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and E
|
||||
const db = new AssistantDatabase(':memory:'); db.initialize(root)
|
||||
let enabled = true
|
||||
const projectId = db.listProjects()[0]!.id
|
||||
const conversationId = crypto.randomUUID()
|
||||
db.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Graph', updatedAt: Date.now() }, messages: [] }])
|
||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: {
|
||||
available: async () => enabled, read: db.readStoryGraph.bind(db)
|
||||
available: async binding => db.isConversationStoryGraphEnabled(binding.conversationId!), read: db.readStoryGraph.bind(db)
|
||||
} })
|
||||
const signal = new AbortController().signal
|
||||
const token = gateway.grant('harness-graph', [], signal, 'none', undefined, undefined, undefined, undefined, undefined,
|
||||
{ projectId, runtimeTarget: 'deepseek-harness' })!
|
||||
{ projectId, conversationId, runtimeTarget: 'deepseek-harness' })!
|
||||
const inProcess = createInProcessLaunch(root, { stream(options) {
|
||||
const prompt = latestUserText(options)
|
||||
const tool = options.tools?.find(tool => tool.name === 'story_graph_search')
|
||||
@@ -131,7 +133,8 @@ it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and E
|
||||
try {
|
||||
for (const mode of ['ask', 'execute', 'disabled'] as const) {
|
||||
enabled = mode !== 'disabled'
|
||||
const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId: 'graph', prompt: mode,
|
||||
db.setConversationStoryGraphEnabled(conversationId, enabled)
|
||||
const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId, prompt: mode,
|
||||
workMode: mode === 'execute' ? 'execute' : 'ask', knowledgeCapabilityToken: token }, signal))
|
||||
expect(events.at(-1)?.type).toBe('done')
|
||||
expect(JSON.stringify(events)).toContain(enabled ? 'GRAPH_READ' : 'DISABLED')
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } f
|
||||
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
|
||||
import type { RuntimeTarget } from '../../shared/capability-contracts'
|
||||
|
||||
export type StoryGraphBinding = { projectId?: string; runtimeTarget: RuntimeTarget }
|
||||
export type StoryGraphBinding = { projectId?: string; conversationId?: string; runtimeTarget: RuntimeTarget }
|
||||
export type StoryGraphRemoteBinding = {
|
||||
available(): Promise<boolean>
|
||||
call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise<Record<string, unknown>>
|
||||
|
||||
@@ -14,9 +14,9 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
|
||||
const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' }
|
||||
const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile }
|
||||
const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask' }
|
||||
const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask', storyGraphEnabled: true }
|
||||
const project = { id: 'project', runtimeSelection: { provider: 'deepseek-harness', profileId: profile.id } }
|
||||
const application = { magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } }
|
||||
const application = { heartbeatEnabled: false, magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } }
|
||||
const gateway = { start: vi.fn(), dispose: vi.fn(), grant: vi.fn(() => 'builtin-token'),
|
||||
grantCustomMcp: vi.fn(() => 'custom-token'), getEndpoint: () => 'http://127.0.0.1:1234/mcp',
|
||||
getAvailableToolNames: vi.fn(() => ['knowledge_search', 'obsidian_read_note']) }
|
||||
@@ -30,11 +30,11 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
|
||||
const stop = vi.spyOn(NativeDshWebClientService.prototype, 'stop').mockImplementation(async id => { handles.delete(id) })
|
||||
const openExternal = vi.fn(async () => {})
|
||||
const coordinator = new NativeClientCoordinator({
|
||||
database: { getConversation: () => conversation, getProject: () => project },
|
||||
database: { getConversation: (id: string) => ({ ...conversation, id }), getProject: () => project },
|
||||
settingsStore: { getResolvedSettings: async () => settings },
|
||||
applicationSettingsStore: { get: async () => application },
|
||||
capabilities: { getRuntimeSkillContext: async () => ({ packages: [{ directory: '/skills/test', id: 'test', digest: 'one' }] }),
|
||||
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config'],
|
||||
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config', 'story-graph'],
|
||||
getObsidianSettings: async () => ({}) },
|
||||
executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) },
|
||||
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady },
|
||||
@@ -45,6 +45,17 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
|
||||
}
|
||||
|
||||
describe('native client coordinator', () => {
|
||||
it('binds story graph to its conversation and does not reuse another conversation or disabled configuration', async () => {
|
||||
const { coordinator, conversation, application, gateway } = fixture()
|
||||
application.heartbeatEnabled = true
|
||||
await coordinator.open(1, 'first')
|
||||
expect(gateway.grant.mock.calls.at(-1)?.at(-1)).toEqual({ projectId: 'project', conversationId: 'first', runtimeTarget: 'deepseek-harness' })
|
||||
expect(await coordinator.get(1, 'second')).toBeNull()
|
||||
conversation.storyGraphEnabled = false
|
||||
expect(await coordinator.get(1, 'first')).toBeNull()
|
||||
await coordinator.open(1, 'first')
|
||||
expect(gateway.grant.mock.calls.at(-1)?.at(-1)).toBeUndefined()
|
||||
})
|
||||
it('waits for the startup tool environment before launching a native client', async () => {
|
||||
let ready!: () => void
|
||||
const { coordinator, start } = fixture(() => new Promise<void>(resolve => { ready = resolve }))
|
||||
|
||||
@@ -99,7 +99,7 @@ export class NativeClientCoordinator {
|
||||
this.options.applicationSettingsStore.get(),
|
||||
this.options.capabilities.getObsidianSettings()
|
||||
])
|
||||
const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, space: space.cacheIdentity,
|
||||
const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, conversationId, storyGraphEnabled: conversation.storyGraphEnabled !== false, space: space.cacheIdentity,
|
||||
target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile
|
||||
: selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile,
|
||||
workMode, skills, mcpServers, builtin,
|
||||
@@ -168,7 +168,8 @@ export class NativeClientCoordinator {
|
||||
builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none',
|
||||
builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined,
|
||||
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined,
|
||||
builtin.includes('story-graph') && application.heartbeatEnabled ? { projectId: project.id, runtimeTarget: selected.target } : undefined)
|
||||
builtin.includes('story-graph') && application.heartbeatEnabled && conversation.storyGraphEnabled !== false
|
||||
? { projectId: project.id, conversationId: conversation.id, runtimeTarget: selected.target } : undefined)
|
||||
const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined
|
||||
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` },
|
||||
readOnlyTools: index === 0 && workMode === 'ask' ? gateway.getAvailableToolNames(value) : [] }] : [])
|
||||
|
||||
@@ -141,6 +141,23 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(store.update({ supervisionReview: { ...supervisionReview, ...patch } })).rejects.toThrow()
|
||||
}
|
||||
})
|
||||
it.each([11, 12])('persists the supervisor model reference and defaults old settings to app default in version %s', async (version) => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
|
||||
delete legacy.supervisorModelProfileId
|
||||
await writeFile(filePath, JSON.stringify(legacy))
|
||||
expect((await store.get()).supervisorModelProfileId).toBeNull()
|
||||
const profileId = '00000000-0000-4000-8000-000000000201'
|
||||
await store.update({ supervisorModelProfileId: profileId })
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBe(profileId)
|
||||
for (const value of ['', 'missing', 12, { apiKey: 'not-a-profile-reference' }]) {
|
||||
await expect(store.update({ supervisorModelProfileId: value })).rejects.toThrow()
|
||||
}
|
||||
await store.update({ supervisorModelProfileId: null })
|
||||
expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBeNull()
|
||||
})
|
||||
|
||||
it.each([11, 12])('persists supervision concurrency and defaults missing values in version %s', async (version) => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
|
||||
|
||||
@@ -265,6 +265,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
|
||||
heartbeatReportTimeoutSeconds: defaultSupervisionTimeoutSeconds,
|
||||
supervisorOrganizeTimeoutSeconds: defaultSupervisionTimeoutSeconds,
|
||||
supervisorModelConcurrency: defaultSupervisorModelConcurrency,
|
||||
supervisorModelProfileId: null,
|
||||
magicNotesShowIncompleteTodoCount: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
@@ -545,6 +546,7 @@ export class ApplicationSettingsStore {
|
||||
heartbeatReportTimeoutSeconds: stored.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||
supervisorOrganizeTimeoutSeconds: stored.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||
supervisorModelConcurrency: stored.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
|
||||
supervisorModelProfileId: stored.supervisorModelProfileId ?? null,
|
||||
supervisionReview: stored.supervisionReview,
|
||||
magicNotesShowIncompleteTodoCount:
|
||||
stored.magicNotesShowIncompleteTodoCount,
|
||||
@@ -593,6 +595,7 @@ export class ApplicationSettingsStore {
|
||||
heartbeatReportTimeoutSeconds: next.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||
supervisorOrganizeTimeoutSeconds: next.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||
supervisorModelConcurrency: next.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
|
||||
supervisorModelProfileId: next.supervisorModelProfileId ?? null,
|
||||
supervisionReview: next.supervisionReview,
|
||||
magicNotesShowIncompleteTodoCount:
|
||||
next.magicNotesShowIncompleteTodoCount,
|
||||
|
||||
@@ -101,6 +101,27 @@ it('roundtrips canvas assets and keeps todos stable across geometry edits and co
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists the story graph switch per conversation without schema migration or stale autosave overrides', async () => {
|
||||
const database = await createDatabase()
|
||||
const header = { id: randomUUID(), title: 'Graph preference', updatedAt: 1000, knowledgeRetrievalMode: 'always' as const }
|
||||
const other = { ...header, id: randomUUID() }
|
||||
try {
|
||||
database.saveLocalConversations([{ header, messages: [] }, { header: other, messages: [] }])
|
||||
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true)
|
||||
database.setConversationStoryGraphEnabled(header.id, false)
|
||||
database.saveLocalConversations([{ header, messages: [] }])
|
||||
database.close()
|
||||
database.initialize('C:\\Workspace')
|
||||
expect(database.getConversation(header.id)).toMatchObject({ storyGraphEnabled: false, knowledgeRetrievalMode: 'always' })
|
||||
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(false)
|
||||
expect(database.isConversationStoryGraphEnabled(other.id)).toBe(true)
|
||||
database.setConversationStoryGraphEnabled(header.id, true)
|
||||
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true)
|
||||
expect(database.isConversationStoryGraphEnabled(randomUUID())).toBe(false)
|
||||
expect(() => database.setConversationStoryGraphEnabled(randomUUID(), false)).toThrow('对话不存在')
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
|
||||
const database = await createDatabase()
|
||||
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
|
||||
|
||||
@@ -1234,6 +1234,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
|
||||
workMode: true,
|
||||
knowledgeLibraryIds: true,
|
||||
knowledgeRetrievalMode: true,
|
||||
storyGraphEnabled: true,
|
||||
contextMetrics: true,
|
||||
contextCompressionState: true
|
||||
})
|
||||
@@ -1242,7 +1243,7 @@ function parseConversationContextState(
|
||||
value: string | null
|
||||
): Pick<
|
||||
ConversationSnapshot,
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState'
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
> {
|
||||
if (!value) {
|
||||
return {}
|
||||
@@ -1260,18 +1261,20 @@ function parseConversationContextState(
|
||||
function serializeConversationContextState(
|
||||
conversation: Pick<
|
||||
ConversationSnapshot,
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState'
|
||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
>
|
||||
): string | null {
|
||||
return conversation.workMode !== undefined ||
|
||||
conversation.knowledgeLibraryIds !== undefined ||
|
||||
conversation.knowledgeRetrievalMode !== undefined ||
|
||||
conversation.storyGraphEnabled !== undefined ||
|
||||
conversation.contextMetrics ||
|
||||
conversation.contextCompressionState
|
||||
? JSON.stringify({
|
||||
workMode: conversation.workMode,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState
|
||||
})
|
||||
@@ -3194,6 +3197,22 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
isConversationStoryGraphEnabled(conversationId: string): boolean {
|
||||
const row = this.requireDatabase().prepare(
|
||||
"SELECT context_state_json FROM conversations WHERE id = ? AND status = 'active'"
|
||||
).get(conversationId) as { context_state_json: string | null } | undefined
|
||||
return Boolean(row && parseConversationContextState(row.context_state_json).storyGraphEnabled !== false)
|
||||
}
|
||||
|
||||
setConversationStoryGraphEnabled(conversationId: string, enabled: boolean): void {
|
||||
const result = this.requireDatabase().prepare(
|
||||
`UPDATE conversations
|
||||
SET context_state_json = json_set(COALESCE(context_state_json, '{}'), '$.storyGraphEnabled', json(?))
|
||||
WHERE id = ? AND status = 'active'`
|
||||
).run(JSON.stringify(enabled), conversationId)
|
||||
if (result.changes !== 1) throw new Error('对话不存在')
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears references to deleted model connections in every project and
|
||||
* conversation so they fall back to the next layer instead of failing.
|
||||
@@ -3324,7 +3343,7 @@ export class AssistantDatabase {
|
||||
saveLocalConversations(batch: LocalConversationSaveBatch): void {
|
||||
const database = this.requireDatabase()
|
||||
const findConversation = database.prepare(
|
||||
'SELECT channel FROM conversations WHERE id = ?'
|
||||
'SELECT channel, context_state_json FROM conversations WHERE id = ?'
|
||||
)
|
||||
const insertConversation = database.prepare(
|
||||
`INSERT INTO conversations
|
||||
@@ -3376,7 +3395,7 @@ export class AssistantDatabase {
|
||||
const { header } = save
|
||||
const existingConversation = findConversation.get(
|
||||
header.id
|
||||
) as { channel: ProjectChannel | null } | undefined
|
||||
) as { channel: ProjectChannel | null; context_state_json: string | null } | undefined
|
||||
const updatedAt = new Date(header.updatedAt).toISOString()
|
||||
if (existingConversation?.channel) {
|
||||
throw new Error('本地对话 ID 与远程对话冲突')
|
||||
@@ -3386,7 +3405,9 @@ export class AssistantDatabase {
|
||||
header.projectId ?? null,
|
||||
serializeRuntimeSelection(header.runtimeSelection),
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
// The explicit switch owns this preference; a queued older header must not undo it.
|
||||
serializeConversationContextState({ ...header,
|
||||
storyGraphEnabled: parseConversationContextState(existingConversation.context_state_json).storyGraphEnabled }),
|
||||
header.title,
|
||||
header.branch?.sourceConversationId ?? null,
|
||||
header.branch?.sourceTitle ?? null,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { AgentRuntime } from '../agent/runtime'
|
||||
import type { SupervisionEvidence, SupervisionRunRequest } from '../../shared/supervision-contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { createProductionSupervisorService } from './supervision-production'
|
||||
import { createProductionSupervisorService, createProductionSuggestionPhraser } from './supervision-production'
|
||||
import { SupervisionModelPool } from './supervision-model-pool'
|
||||
import { supervisionEntitySchema } from '../../shared/supervision-contracts'
|
||||
|
||||
@@ -12,6 +12,88 @@ const cleanups: Array<() => void> = []
|
||||
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup() })
|
||||
const empty = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
|
||||
it('keeps one model across extraction, navigation, stories and experiences, and reads the next selection on retry', async () => {
|
||||
const f = fixture('A'.repeat(1500))
|
||||
let profileId: string | null = 'first'
|
||||
const phases: string[] = []
|
||||
const dispose = vi.fn(async () => {})
|
||||
const resolve = vi.fn(async (selected?: string | null) => ({
|
||||
runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
|
||||
run: async function* (input) {
|
||||
expect(selected).toBe('first')
|
||||
profileId = 'next'
|
||||
let output: unknown
|
||||
if (input.prompt.includes('You organise the GoodBuddy')) {
|
||||
phases.push('stories')
|
||||
output = { stories: [{ key: 'new_1', level: 'feature', name: 'Work' }],
|
||||
assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_1' }] }
|
||||
} else if (input.prompt.includes('You distil reusable experience')) {
|
||||
phases.push('experiences')
|
||||
output = { experiences: [], applications: [] }
|
||||
} else if (input.prompt.includes('These inputs are navigation summaries')) {
|
||||
phases.push('navigation')
|
||||
output = { summary: 'Combined', changeDigest: '', openItems: [] }
|
||||
} else {
|
||||
phases.push('extraction')
|
||||
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as SupervisionEvidence[]
|
||||
output = { ...empty, events: [{ title: 'Decision', description: '', eventType: 'decision',
|
||||
occurredAt: f.request.timeRange.from, entityIds: [], sourceReferenceIds: [evidence[0]!.id] }] }
|
||||
}
|
||||
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(output) }
|
||||
yield { type: 'done', requestId: input.requestId }
|
||||
}
|
||||
} as AgentRuntime))
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanups.push(() => pool.dispose())
|
||||
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId,
|
||||
supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, experienceMinEvents: 2 } }), resolve, pool)
|
||||
const result = await service.run(f.request)
|
||||
expect(phases).toEqual(['extraction', 'extraction', 'navigation', 'stories', 'experiences'])
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith('first')
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
await service.organizeStoriesFor(result.runId!)
|
||||
expect(resolve).toHaveBeenLastCalledWith('next')
|
||||
expect(dispose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases a failed review runtime and uses the current model when continuing saved work', async () => {
|
||||
const f = fixture()
|
||||
let profileId: string | null = 'first'
|
||||
const dispose = vi.fn(async () => {})
|
||||
const resolve = vi.fn(async (selected?: string | null) => ({ runtimeId: 'model', capability: 'chat',
|
||||
requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
|
||||
run: async function* (input) {
|
||||
if (selected === 'first') throw new Error('Provider unavailable')
|
||||
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(empty) }
|
||||
yield { type: 'done', requestId: input.requestId }
|
||||
}
|
||||
} as AgentRuntime))
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanups.push(() => pool.dispose())
|
||||
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId }), resolve, pool)
|
||||
await expect(service.run(f.request)).rejects.toThrow('Provider unavailable')
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(service.execution().active).toBe(false)
|
||||
profileId = null
|
||||
await expect(service.resume(f.db.listSupervisionActivity()[0]!.id)).resolves.toMatchObject({ status: 'completed' })
|
||||
expect(resolve.mock.calls).toEqual([['first'], [null]])
|
||||
expect(dispose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses the supervisor selection for suggestions and disposes its runtime on failure', async () => {
|
||||
const f = fixture()
|
||||
const dispose = vi.fn(async () => {})
|
||||
const resolve = vi.fn(async () => ({ runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
|
||||
run: async function* (input) { yield { type: 'error', requestId: input.requestId, message: 'Provider unavailable' } }
|
||||
} as AgentRuntime))
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanups.push(() => pool.dispose())
|
||||
const phrase = createProductionSuggestionPhraser(f.db, async () => ({ supervisorModelProfileId: 'reviewer' }), resolve, pool)
|
||||
await expect(phrase({ systemInstruction: 'Suggest', outputContract: '{}', candidates: [] })).rejects.toThrow('Provider unavailable')
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith('reviewer')
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('production six-leaf merge failure resumes navigation-only JSON and publishes all retained facts', async () => {
|
||||
const f = fixture('A'.repeat(765))
|
||||
const now = Date.parse(f.request.timeRange.from) + 1
|
||||
|
||||
@@ -85,7 +85,7 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
|
||||
function dependencies(
|
||||
database: AssistantDatabase,
|
||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||
resolveRuntime: () => Promise<AgentRuntime>,
|
||||
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||
pool: SupervisionModelPool,
|
||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||
): SupervisionModelDependencies {
|
||||
@@ -98,11 +98,16 @@ function dependencies(
|
||||
export function createProductionSupervisorService(
|
||||
database: AssistantDatabase,
|
||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||
resolveRuntime: () => Promise<AgentRuntime>,
|
||||
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||
pool: SupervisionModelPool,
|
||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||
): SupervisorService {
|
||||
const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage)
|
||||
// The service admits only one execution at a time, including resume and story retry.
|
||||
let runtime: AgentRuntime | undefined
|
||||
const model = dependencies(database, getSettings, async () => {
|
||||
if (!runtime) throw new Error('Supervisor model execution is not active')
|
||||
return runtime
|
||||
}, pool, persistUsage)
|
||||
return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, {
|
||||
summarize: async request => runSupervisionModel(model, {
|
||||
title: '监督者回顾', instructions: '根据有界证据整理监督者回顾',
|
||||
@@ -122,7 +127,15 @@ export function createProductionSupervisorService(
|
||||
start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId),
|
||||
fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId),
|
||||
candidates: async request => database.listSupervisionCandidates(request), save: async result => database.saveSupervisionResult(result)
|
||||
}, { database: () => database.supervisionReviewStore(), configuration: async () => {
|
||||
}, { database: () => database.supervisionReviewStore(), withExecution: async operation => {
|
||||
runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId)
|
||||
try { return await operation() }
|
||||
finally {
|
||||
const completed = runtime
|
||||
runtime = undefined
|
||||
await completed.dispose?.()
|
||||
}
|
||||
}, configuration: async () => {
|
||||
const settings = await getSettings()
|
||||
return { ...supervisionReviewSettingsSchema.parse(settings?.supervisionReview ?? {}), version: 1,
|
||||
timeoutSeconds: settings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||
@@ -163,16 +176,21 @@ async function assignStoriesStep(model: SupervisionModelDependencies, request: S
|
||||
export function createProductionSuggestionPhraser(
|
||||
database: AssistantDatabase,
|
||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||
resolveRuntime: () => Promise<AgentRuntime>,
|
||||
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||
pool: SupervisionModelPool,
|
||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||
): SuggestionPhraser {
|
||||
const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage)
|
||||
return async request => runSupervisionModel(model, {
|
||||
title: '监督者建议', instructions: '根据已发布的图谱变化生成建议',
|
||||
timeoutMessage: seconds => `监督者建议生成超过 ${seconds} 秒,已停止;回顾结果已保留`,
|
||||
authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) },
|
||||
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
|
||||
'CANDIDATES:', JSON.stringify(request.candidates), 'Return only JSON.'].join('\n\n')
|
||||
})
|
||||
return async request => {
|
||||
const runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId)
|
||||
const model = dependencies(database, getSettings, async () => runtime, pool, persistUsage)
|
||||
try {
|
||||
return await runSupervisionModel(model, {
|
||||
title: '监督者建议', instructions: '根据已发布的图谱变化生成建议',
|
||||
timeoutMessage: seconds => `监督者建议生成超过 ${seconds} 秒,已停止;回顾结果已保留`,
|
||||
authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) },
|
||||
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
|
||||
'CANDIDATES:', JSON.stringify(request.candidates), 'Return only JSON.'].join('\n\n')
|
||||
})
|
||||
} finally { await runtime.dispose?.() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ export class SupervisorService {
|
||||
private readonly review?: {
|
||||
database: () => SupervisionReviewStore
|
||||
configuration: () => Promise<ReviewConfiguration>
|
||||
withExecution?: (operation: () => Promise<StoredSupervisionResult>) => Promise<StoredSupervisionResult>
|
||||
/** Story assignment over published, still unassigned events of the scope. Runs inside the review slot. */
|
||||
stories?: (request: SupervisionRunRequest, config: ReviewConfiguration, signal: AbortSignal) => Promise<NonNullable<SupervisionReviewProgress['stories']>>
|
||||
}
|
||||
@@ -234,7 +235,7 @@ export class SupervisorService {
|
||||
await this.active.settled.catch(() => undefined)
|
||||
}
|
||||
// Reserve before any asynchronous configuration, collection or resume work.
|
||||
const active = { settled: Promise.resolve().then(operation) }
|
||||
const active = { settled: Promise.resolve().then(() => this.review?.withExecution ? this.review.withExecution(operation) : operation()) }
|
||||
this.active = active
|
||||
try { return await active.settled }
|
||||
finally { if (this.active === active) this.active = undefined }
|
||||
|
||||
+4
-2
@@ -1057,7 +1057,8 @@ if (hasSingleInstanceLock) {
|
||||
{
|
||||
observeFailure: observeDesktopFailure,
|
||||
storyGraphService: {
|
||||
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
||||
available: async ({ runtimeTarget, conversationId }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
||||
(!conversationId || startupAssistantDatabase.isConversationStoryGraphEnabled(conversationId)) &&
|
||||
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
||||
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
|
||||
},
|
||||
@@ -1540,7 +1541,8 @@ if (hasSingleInstanceLock) {
|
||||
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
|
||||
observeFailure: observeDesktopFailure,
|
||||
storyGraphService: {
|
||||
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
||||
available: async ({ runtimeTarget, conversationId }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
||||
(!conversationId || startupAssistantDatabase.isConversationStoryGraphEnabled(conversationId)) &&
|
||||
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
||||
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
|
||||
},
|
||||
|
||||
+72
-2
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {} from '@testing-library/jest-dom/vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createServer } from 'node:http'
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
} from '../shared/assistant-contracts'
|
||||
import {
|
||||
browserTabIdSchema,
|
||||
defaultModelProfileId,
|
||||
defaultRuntimeSettings,
|
||||
runtimeSettingsInputSchema,
|
||||
type AgentEvent,
|
||||
@@ -4684,6 +4686,16 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
expect(database.getConversation(conversationId)).toEqual(before)
|
||||
|
||||
const setStoryGraph = electronMocks.handlers.get(ipcChannels.conversationsSetStoryGraph)!
|
||||
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(true)
|
||||
for (const enabled of [false, true]) {
|
||||
setStoryGraph(event, { conversationId, enabled })
|
||||
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(enabled)
|
||||
expect(database.getConversation(conversationId)).toEqual({ ...before, storyGraphEnabled: enabled })
|
||||
}
|
||||
expect(() => setStoryGraph(event, { conversationId, enabled: 'false' })).toThrow()
|
||||
expect(() => setStoryGraph({ ...event, sender: {} }, { conversationId, enabled: false })).toThrow()
|
||||
|
||||
expect(electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true)
|
||||
expect(collect).toHaveBeenCalledOnce()
|
||||
expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce()
|
||||
@@ -5509,6 +5521,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
runtimeSelection: { provider: 'opencode' },
|
||||
messages: []
|
||||
})),
|
||||
isConversationStoryGraphEnabled: vi.fn(() => true),
|
||||
setConversationStoryGraphEnabled: vi.fn(),
|
||||
getProject: vi.fn<
|
||||
(projectId: string) => Record<string, unknown>
|
||||
>((projectId) => ({
|
||||
@@ -5559,6 +5573,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
const getResolvedSettings = vi.fn(
|
||||
async (): Promise<Record<string, unknown>> => ({
|
||||
modelProfiles: [{ id: defaultModelProfileId, name: 'Default', modelName: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none' }],
|
||||
defaultModelProfileId,
|
||||
toolApproval,
|
||||
subagentSmartRoutingEnabled: smartRoutingEnabled
|
||||
})
|
||||
@@ -5634,6 +5650,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
args[45] = nativeClientCoordinator
|
||||
args[41] = nativeTerminalManager
|
||||
if (applicationSettingsStore) args[15] = applicationSettingsStore
|
||||
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(runtime)
|
||||
const dispose = registerIpcHandlers(...args)
|
||||
return {
|
||||
approvalBroker,
|
||||
@@ -6396,6 +6413,57 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
} finally { await harness.dispose(); database.close() }
|
||||
})
|
||||
|
||||
it('routes supervisor model selection through the real direct runtime to a saved connection', async () => {
|
||||
const calls: Array<{ model: string; tools?: unknown; temperature?: number }> = []
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += String(chunk)
|
||||
calls.push(JSON.parse(body))
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(`data: ${JSON.stringify({ choices: [{ delta: { content: JSON.stringify({ summary: 'Connection verified', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`)
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as { port: number }
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
const now = Date.now()
|
||||
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Review', updatedAt: now },
|
||||
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'A saved decision', createdAt: now }] }])
|
||||
const harness = createHarness({ runtimeId: 'opencode', capability: 'chat', run: vi.fn() }, undefined, 'always', undefined, false,
|
||||
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
||||
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
|
||||
runtimeFactoryMocks.createModelProfileRuntime.mockImplementation(actual.createModelProfileRuntime)
|
||||
const profile = { id: crypto.randomUUID(), name: 'Review connection',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`, modelName: 'review-selected', protocol: 'openai-chat-completions', authentication: 'none',
|
||||
requestHeaders: {}, requestBody: { temperature: 0.25 } }
|
||||
const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'app-default' }
|
||||
harness.getResolvedSettings.mockResolvedValue({ ...defaultRuntimeSettings, provider: 'opencode', modelProfiles: [profile, fallback], defaultModelProfileId: fallback.id })
|
||||
const handler = electronMocks.handlers.get(ipcChannels.supervisionRun)!
|
||||
const input = { trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange: {
|
||||
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
|
||||
} }
|
||||
try {
|
||||
for (const supervisorModelProfileId of [profile.id, null]) {
|
||||
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId })
|
||||
await expect(handler(trustedEvent(harness.webContents), input)).resolves.toMatchObject({ status: 'completed' })
|
||||
}
|
||||
expect(calls.map(call => call.model)).toEqual(['review-selected', 'app-default'])
|
||||
expect(calls.every(call => !call.tools && call.temperature === 0.25)).toBe(true)
|
||||
for (const invalid of [undefined, { ...profile, protocol: 'openai-images-generations' }, { ...profile, authentication: 'api-key', apiKey: undefined }]) {
|
||||
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId: profile.id })
|
||||
harness.getResolvedSettings.mockResolvedValue({ modelProfiles: invalid ? [invalid, fallback] : [fallback], defaultModelProfileId: fallback.id })
|
||||
await expect(handler(trustedEvent(harness.webContents), input)).rejects.toThrow(/不可用|API Key/)
|
||||
}
|
||||
expect(calls).toHaveLength(2)
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
database.close()
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
runtimeFactoryMocks.createModelProfileRuntime.mockReset()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z'))
|
||||
@@ -9549,16 +9617,18 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
})
|
||||
try {
|
||||
const projectId = '00000000-0000-4000-8000-000000000088'
|
||||
for (const enabled of [true, false]) {
|
||||
for (const [enabled, conversationEnabled] of [[true, true], [false, true], [true, false]]) {
|
||||
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: enabled })
|
||||
harness.assistantDatabase.isConversationStoryGraphEnabled.mockReturnValue(conversationEnabled!)
|
||||
const requestId = crypto.randomUUID()
|
||||
await harness.handler!(trustedEvent(harness.webContents), { requestId, projectId, conversationId: 'graph-grant', prompt: 'Continue work', workMode: 'ask', knowledgeLibraryIds: [] })
|
||||
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
|
||||
}
|
||||
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal), 'none',
|
||||
undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: runtimeId })
|
||||
undefined, undefined, undefined, undefined, undefined, { projectId, conversationId: 'graph-grant', runtimeTarget: runtimeId })
|
||||
expect(received[0]).toMatchObject({ knowledgeCapabilityToken: 'graph-capability', storyGraphBinding: binding })
|
||||
expect(received[1]).not.toHaveProperty('storyGraphBinding')
|
||||
expect(received[2]).not.toHaveProperty('storyGraphBinding')
|
||||
expect(gateway.bindRemoteStoryGraph).toHaveBeenCalledOnce()
|
||||
} finally { await harness.dispose() }
|
||||
})
|
||||
|
||||
+29
-6
@@ -137,6 +137,7 @@ import {
|
||||
conversationListRequestSchema,
|
||||
conversationSearchRequestSchema,
|
||||
conversationSetPinnedSchema,
|
||||
conversationSetStoryGraphSchema,
|
||||
type ConversationSnapshot,
|
||||
localConversationSaveBatchSchema,
|
||||
memoryCreateSchema,
|
||||
@@ -2228,8 +2229,9 @@ export function registerIpcHandlers(
|
||||
)
|
||||
: []
|
||||
const notesCapability = await grantScopedDataCapability({
|
||||
storyGraph: requestRuntimeTarget && (await applicationSettingsStore?.get())?.heartbeatEnabled
|
||||
? { runtimeTarget: requestRuntimeTarget, projectId: schedule.projectId ?? undefined } : undefined,
|
||||
storyGraph: requestRuntimeTarget && (await applicationSettingsStore?.get())?.heartbeatEnabled &&
|
||||
assistantDatabase.isConversationStoryGraphEnabled(runtimeConversationId)
|
||||
? { runtimeTarget: requestRuntimeTarget, projectId: schedule.projectId ?? undefined, conversationId: runtimeConversationId } : undefined,
|
||||
obsidian: enabledBuiltinMcpServers.includes('obsidian')
|
||||
? {
|
||||
settings: await capabilityService.getObsidianSettings(),
|
||||
@@ -3761,8 +3763,9 @@ export function registerIpcHandlers(
|
||||
const imageSaveAvailable = Boolean(imageToolBinding?.save && normalizedWorkMode === 'execute' &&
|
||||
await imageToolBinding.describeSave?.())
|
||||
const scopedCapability = await grantScopedDataCapability({
|
||||
storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled
|
||||
? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId } : undefined,
|
||||
storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled &&
|
||||
assistantDatabase.isConversationStoryGraphEnabled(enrichedRequest.conversationId)
|
||||
? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId, conversationId: enrichedRequest.conversationId } : undefined,
|
||||
obsidian: enabledBuiltinMcpServers.includes('obsidian')
|
||||
? {
|
||||
settings: await capabilityService.getObsidianSettings(),
|
||||
@@ -6383,11 +6386,31 @@ export function registerIpcHandlers(
|
||||
publishConversationChange()
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.conversationsSetStoryGraph,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { conversationId, enabled } = conversationSetStoryGraphSchema.parse(input)
|
||||
assistantDatabase.setConversationStoryGraphEnabled(conversationId, enabled)
|
||||
publishConversationChange()
|
||||
}
|
||||
)
|
||||
const resolveSupervisorRuntime = async (profileId?: string | null): Promise<AgentRuntime> => {
|
||||
const settings = await settingsStore.getResolvedSettings()
|
||||
const profile = settings.modelProfiles.find(candidate => candidate.id === (profileId ?? settings.defaultModelProfileId))
|
||||
if (!profile || !isAgentRuntimeModelProtocol(profile.protocol)) {
|
||||
throw new Error('监督者所选文本模型不存在或已不可用,请在监督者设置中重新选择模型。')
|
||||
}
|
||||
if (profile.authentication === 'api-key' && !profile.apiKey) {
|
||||
throw new Error(`监督者模型连接“${profile.name}”未配置 API Key`)
|
||||
}
|
||||
return createModelProfileRuntime(settings.workspacePath, settings, profile)
|
||||
}
|
||||
const supervisorService = createProductionSupervisorService(assistantDatabase,
|
||||
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool,
|
||||
async () => applicationSettingsStore?.get(), resolveSupervisorRuntime, supervisionModelPool,
|
||||
persistModelUsage)
|
||||
const suggestionPhraser = createProductionSuggestionPhraser(assistantDatabase,
|
||||
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool,
|
||||
async () => applicationSettingsStore?.get(), resolveSupervisorRuntime, supervisionModelPool,
|
||||
persistModelUsage)
|
||||
|
||||
registerHandler(
|
||||
|
||||
@@ -1135,6 +1135,9 @@ const desktopApi: DesktopApi = {
|
||||
setPinned: async (input) => {
|
||||
await ipcRenderer.invoke(ipcChannels.conversationsSetPinned, input)
|
||||
},
|
||||
setStoryGraph: async (input) => {
|
||||
await ipcRenderer.invoke(ipcChannels.conversationsSetStoryGraph, input)
|
||||
},
|
||||
branchLocal: (input: ConversationBranchInput) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.conversationsBranchLocal,
|
||||
|
||||
@@ -586,6 +586,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
replace: vi.fn(async () => {}),
|
||||
saveLocal: vi.fn(async () => {}),
|
||||
setPinned: vi.fn(async () => {}),
|
||||
setStoryGraph: vi.fn(async () => {}),
|
||||
branchLocal: vi.fn(async (input) => ({
|
||||
id: crypto.randomUUID(),
|
||||
branch: {
|
||||
@@ -8544,6 +8545,33 @@ describe("App", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves the story graph switch through the conversation API and restores it on reload", async () => {
|
||||
installRemoteProjectsSetting(false);
|
||||
const id = "00000000-0000-4000-8000-000000000311";
|
||||
const saved = { id, projectId: project.id, title: "Story preference", updatedAt: 1, messages: [] };
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([saved]);
|
||||
const pending = deferred<void>();
|
||||
vi.mocked(api.conversations.setStoryGraph).mockImplementationOnce(() => pending.promise);
|
||||
const view = render(<App />);
|
||||
await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
openComposerOptions();
|
||||
const toggle = await screen.findByRole("switch", { name: "使用故事图谱" });
|
||||
expect(toggle).toBeChecked();
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(api.conversations.setStoryGraph).toHaveBeenCalledWith({ conversationId: id, enabled: false }));
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle).toBeDisabled();
|
||||
await act(async () => pending.resolve());
|
||||
await waitFor(() => { expect(toggle).not.toBeChecked(); expect(toggle).toBeEnabled(); });
|
||||
view.unmount();
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([{ ...saved, storyGraphEnabled: false }]);
|
||||
render(<App />);
|
||||
await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
openComposerOptions();
|
||||
expect(await screen.findByRole("switch", { name: "使用故事图谱" })).not.toBeChecked();
|
||||
expect(api.agent.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("groups composer tools and exposes clear control descriptions", async () => {
|
||||
render(<App />);
|
||||
|
||||
|
||||
@@ -853,6 +853,7 @@ function toConversationSnapshots(
|
||||
workMode: conversation.workMode,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||
@@ -6666,6 +6667,16 @@ function App(): React.JSX.Element {
|
||||
openImageViewer,
|
||||
openModelSettings: openImageModelSettings,
|
||||
setEnabledKnowledgeLibraryIds,
|
||||
setStoryGraphEnabled: async (conversationId, enabled) => {
|
||||
persistLocalConversationChanges();
|
||||
await conversationPersistence.enqueue(async () => {
|
||||
await window.goodbuddy.conversations.setStoryGraph({ conversationId, enabled });
|
||||
setConversations((current) => current.map((conversation) =>
|
||||
conversation.id === conversationId
|
||||
? { ...conversation, storyGraphEnabled: enabled, updatedAt: Date.now() }
|
||||
: conversation));
|
||||
});
|
||||
},
|
||||
setKnowledgeRetrievalMode: (mode) =>
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
@@ -7463,6 +7474,7 @@ function App(): React.JSX.Element {
|
||||
selectedRuntimeAgent={selectedRuntimeAgent}
|
||||
selectedRuntimeCommand={selectedRuntimeCommand}
|
||||
selectingContextFiles={selectingContextFiles}
|
||||
supervisorEnabled={applicationSettings?.heartbeatEnabled === true}
|
||||
updateAttachmentBusy={updateAttachmentBusy}
|
||||
voiceListening={voiceListening}
|
||||
voiceRecording={voiceRecording}
|
||||
@@ -7569,6 +7581,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
>
|
||||
{isApplicationEnabled(applicationSettings, 'heartbeat') ? <HeartbeatCenter
|
||||
runtimeSettings={runtimeSettings}
|
||||
applicationSettings={applicationSettings}
|
||||
applicationSettingsPending={applicationSettingsPending}
|
||||
applicationSettingsLocked={applicationSettingsUnconfirmed}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createRef, Profiler } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Conversation } from "./chat-conversation";
|
||||
@@ -98,6 +98,37 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Composer render boundary", () => {
|
||||
it("hides the story graph switch with Supervisor off and keeps conversation state while saving or failing", async () => {
|
||||
const { props, rerender, store } = setup();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
||||
expect(screen.queryByRole("switch", { name: "Use story graph" })).toBeNull();
|
||||
let finish!: () => void;
|
||||
const save = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
|
||||
const notify = vi.fn();
|
||||
const next = { ...props, supervisorEnabled: true, actions: { ...actions, setStoryGraphEnabled: save, notify } };
|
||||
rerender(<Composer {...next} />);
|
||||
const toggle = screen.getByRole("switch", { name: "Use story graph" });
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle.closest("label")).toHaveClass("toggle-row");
|
||||
fireEvent.click(toggle);
|
||||
expect(save).toHaveBeenCalledWith("a", false);
|
||||
expect(toggle).toBeChecked();
|
||||
expect(toggle).toBeDisabled();
|
||||
await act(async () => {
|
||||
store.set(current => current.map(item => item.id === "a" ? { ...item, storyGraphEnabled: false } : item));
|
||||
finish();
|
||||
});
|
||||
await waitFor(() => { expect(toggle).not.toBeChecked(); expect(toggle).toBeEnabled(); });
|
||||
save.mockRejectedValueOnce(new Error("Save failed"));
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => { expect(toggle).toBeEnabled(); expect(toggle).not.toBeChecked(); expect(notify).toHaveBeenCalledWith({ tone: "error", message: "Save failed" }); });
|
||||
rerender(<Composer {...next} conversationId="b" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
||||
expect(screen.getByRole("switch", { name: "Use story graph" })).toBeChecked();
|
||||
rerender(<Composer {...next} supervisorEnabled={false} />);
|
||||
expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not re-render when another conversation changes", () => {
|
||||
const { renders, store } = setup();
|
||||
act(() => store.set((current) => current.map((item) =>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -127,6 +128,7 @@ export type ComposerProps = {
|
||||
selectedRuntimeAgent: string;
|
||||
selectedRuntimeCommand: string;
|
||||
selectingContextFiles: boolean;
|
||||
supervisorEnabled?: boolean;
|
||||
updateAttachmentBusy: (busy: boolean) => void;
|
||||
voiceListening: boolean;
|
||||
voiceRecording: boolean;
|
||||
@@ -194,6 +196,7 @@ export const Composer = memo(function Composer({
|
||||
selectedRuntimeAgent,
|
||||
selectedRuntimeCommand,
|
||||
selectingContextFiles,
|
||||
supervisorEnabled,
|
||||
updateAttachmentBusy,
|
||||
voiceListening,
|
||||
voiceRecording,
|
||||
@@ -202,6 +205,7 @@ export const Composer = memo(function Composer({
|
||||
}: ComposerProps): React.JSX.Element {
|
||||
const { t } = useTranslation("app");
|
||||
const view = useComposerConversationView(conversationStore, conversationId);
|
||||
const [storyGraphSaving, setStoryGraphSaving] = useState(false);
|
||||
const isRunning = view?.running ?? false;
|
||||
// Popups belong to one conversation, run state and view, like the former
|
||||
// reset in App: changing any of them shows every popup closed.
|
||||
@@ -329,7 +333,7 @@ export const Composer = memo(function Composer({
|
||||
}
|
||||
};
|
||||
const frame = requestAnimationFrame(() => {
|
||||
composerOptionsRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
|
||||
composerOptionsRef.current?.querySelector<HTMLElement>("input:not(:disabled), button:not(:disabled)")?.focus();
|
||||
});
|
||||
document.addEventListener("pointerdown", dismissOutside);
|
||||
document.addEventListener("focusin", dismissOutside);
|
||||
@@ -858,6 +862,23 @@ export const Composer = memo(function Composer({
|
||||
}}
|
||||
>
|
||||
<strong>{t("composer.settings")}</strong>
|
||||
{supervisorEnabled && (
|
||||
<label className="toggle-row">
|
||||
<span>{t("composer.storyGraph.label")}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={view?.storyGraphEnabled !== false}
|
||||
disabled={storyGraphSaving}
|
||||
onChange={(event) => {
|
||||
setStoryGraphSaving(true);
|
||||
void actions.setStoryGraphEnabled(conversationId, event.target.checked)
|
||||
.catch((error: unknown) => actions.notify({ tone: "error", message: error instanceof Error ? error.message : String(error) }))
|
||||
.finally(() => setStoryGraphSaving(false));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{knowledgeLibraries.length > 0 && (
|
||||
<div
|
||||
className="knowledge-scope"
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PageShell } from './WorkspacePrimitives'
|
||||
import i18n from './i18n'
|
||||
import { WorkspaceUnsavedChangesContext } from './workspace-unsaved-changes'
|
||||
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../shared/application-settings-contracts'
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -334,6 +335,40 @@ describe('HeartbeatCenter', () => {
|
||||
view.rerender(<HeartbeatCenter {...props} applicationSettings={{ ...applicationSettings, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 4 }} />)
|
||||
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
||||
})
|
||||
it.each(['zh-CN', 'en-US'])('saves an existing text model reference, retains failed drafts, and restores app default (%s)', async language => {
|
||||
await i18n.changeLanguage(language)
|
||||
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
|
||||
const profileId = '00000000-0000-4000-8000-000000000201'
|
||||
const applicationSettings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||
const runtimeSettings = { modelProfiles: [
|
||||
{ id: profileId, name: 'Reviewer', modelName: 'review-model', protocol: 'openai-chat-completions' },
|
||||
{ id: 'image', name: 'Image only', modelName: 'image-model', protocol: 'openai-images-generations' }
|
||||
] } as RuntimeSettings
|
||||
const onUpdateApplicationSettings = vi.fn(async () => false)
|
||||
const props = createProps({ applicationSettings, runtimeSettings, onUpdateApplicationSettings })
|
||||
const view = render(<HeartbeatCenter {...props} />, false)
|
||||
fireEvent.click(screen.getByRole('tab', { name: t('supervisor.settings') }))
|
||||
const selector = screen.getByRole('combobox', { name: t('timeouts.model') })
|
||||
expect(selector).toHaveValue('')
|
||||
expect(within(selector).queryByText(/Image only/)).not.toBeInTheDocument()
|
||||
fireEvent.change(selector, { target: { value: profileId } })
|
||||
fireEvent.click(screen.getByRole('button', { name: t('timeouts.save') }))
|
||||
await waitFor(() => expect(onUpdateApplicationSettings).toHaveBeenCalledExactlyOnceWith({
|
||||
supervisorModelProfileId: profileId, supervisorOrganizeTimeoutSeconds: 240, supervisorModelConcurrency: 1
|
||||
}))
|
||||
view.rerender(<HeartbeatCenter {...props} applicationSettingsError="Save failed" />)
|
||||
expect(selector).toHaveValue(profileId)
|
||||
view.rerender(<HeartbeatCenter {...props} applicationSettings={{ ...applicationSettings, supervisorModelProfileId: profileId }} />)
|
||||
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
||||
view.rerender(<HeartbeatCenter {...props} runtimeSettings={{ ...runtimeSettings, modelProfiles: [] }} applicationSettings={{ ...applicationSettings, supervisorModelProfileId: profileId }} />)
|
||||
expect(within(selector).getByRole('option', { name: t('timeouts.unavailableModel') })).toBeDisabled()
|
||||
fireEvent.change(selector, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: t('timeouts.save') }))
|
||||
await waitFor(() => expect(onUpdateApplicationSettings).toHaveBeenLastCalledWith({
|
||||
supervisorModelProfileId: null, supervisorOrganizeTimeoutSeconds: 240, supervisorModelConcurrency: 1
|
||||
}))
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'])('opens complete automatic supervision settings without creating defaults (%s)', async (language) => {
|
||||
await i18n.changeLanguage(language)
|
||||
const props = createProps({ configs: [], runs: [], entries: [] })
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
HeartbeatUpdateInput
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||
import { isAgentRuntimeModelProtocol, type RuntimeSettings } from '../../shared/contracts'
|
||||
import { defaultSupervisionTimeoutSeconds, supervisionTimeoutSecondsSchema, defaultSupervisorModelConcurrency, supervisorModelConcurrencySchema, type ApplicationSettings, type ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
|
||||
import { SupervisionReviewSettings, type SupervisionSettingsSection } from './SupervisionReviewSettings'
|
||||
import { SupervisorWorkspace, type SupervisionGraphNavigation } from './SupervisorWorkspace'
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
} from './WorkspacePrimitives'
|
||||
|
||||
export type HeartbeatCenterProps = {
|
||||
runtimeSettings?: RuntimeSettings
|
||||
applicationSettings?: ApplicationSettings
|
||||
applicationSettingsPending?: boolean
|
||||
applicationSettingsLocked?: boolean
|
||||
@@ -200,24 +202,37 @@ function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Elemen
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const supervisor = props.applicationSettings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
|
||||
const concurrency = props.applicationSettings?.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency
|
||||
const profileId = props.applicationSettings?.supervisorModelProfileId ?? ''
|
||||
const profiles = useMemo(() => props.runtimeSettings?.modelProfiles.filter(profile => isAgentRuntimeModelProtocol(profile.protocol)) ?? [], [props.runtimeSettings])
|
||||
const [model, setModel] = useState(profileId)
|
||||
const [parallel, setParallel] = useState(String(concurrency))
|
||||
const [organize, setOrganize] = useState(String(supervisor))
|
||||
const [saved, setSaved] = useState({ supervisor, concurrency })
|
||||
if (saved.supervisor !== supervisor || saved.concurrency !== concurrency) {
|
||||
setSaved({ supervisor, concurrency })
|
||||
const [saved, setSaved] = useState({ supervisor, concurrency, profileId })
|
||||
if (saved.supervisor !== supervisor || saved.concurrency !== concurrency || saved.profileId !== profileId) {
|
||||
setSaved({ supervisor, concurrency, profileId })
|
||||
setModel(profileId)
|
||||
setParallel(String(concurrency))
|
||||
setOrganize(String(supervisor))
|
||||
}
|
||||
useWorkspaceUnsavedChanges(organize !== String(supervisor) || parallel !== String(concurrency))
|
||||
useWorkspaceUnsavedChanges(organize !== String(supervisor) || parallel !== String(concurrency) || model !== profileId)
|
||||
const valid = supervisionTimeoutSecondsSchema.safeParse(Number(organize)).success &&
|
||||
supervisorModelConcurrencySchema.safeParse(Number(parallel)).success
|
||||
const disabled = props.applicationSettingsPending || props.applicationSettingsLocked || !props.applicationSettings || !props.onUpdateApplicationSettings
|
||||
return <form className="heartbeat-settings heartbeat-settings__editor" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!valid || disabled) return
|
||||
void props.onUpdateApplicationSettings?.({ supervisorOrganizeTimeoutSeconds: Number(organize), supervisorModelConcurrency: Number(parallel) })
|
||||
void props.onUpdateApplicationSettings?.({ supervisorOrganizeTimeoutSeconds: Number(organize), supervisorModelConcurrency: Number(parallel),
|
||||
...(model !== profileId ? { supervisorModelProfileId: model || null } : {}) })
|
||||
}}>
|
||||
<h2>{t('timeouts.title')}</h2>
|
||||
<label className="heartbeat-settings__field">{t('timeouts.model')}
|
||||
<select value={model} aria-describedby="supervisor-model-help" disabled={disabled || !props.runtimeSettings} onChange={event => setModel(event.target.value)}>
|
||||
<option value="">{t('timeouts.followDefault')}</option>
|
||||
{model && !profiles.some(profile => profile.id === model) && <option value={model} disabled>{t('timeouts.unavailableModel')}</option>}
|
||||
{profiles.map(profile => <option key={profile.id} value={profile.id}>{profile.name} ({profile.modelName})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p id="supervisor-model-help">{t('timeouts.modelHelp')}</p>
|
||||
<p>{t('timeouts.help')}</p>
|
||||
<label className="heartbeat-settings__field">{t('timeouts.organize')}
|
||||
<input type="number" min={30} max={600} step={1} value={organize} disabled={disabled}
|
||||
@@ -234,7 +249,7 @@ function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Elemen
|
||||
<p>{props.applicationSettingsError}</p>
|
||||
<button className="secondary-button" type="button" disabled={props.applicationSettingsPending} onClick={props.onRetryApplicationSettings}>{t('center.actions.retry')}</button>
|
||||
</div>}
|
||||
<button className="primary-button" type="submit" disabled={disabled || !valid || (Number(organize) === supervisor && Number(parallel) === concurrency)}>{t('timeouts.save')}</button>
|
||||
<button className="primary-button" type="submit" disabled={disabled || !valid || (Number(organize) === supervisor && Number(parallel) === concurrency && model === profileId)}>{t('timeouts.save')}</button>
|
||||
</form>
|
||||
}
|
||||
function HeartbeatSections({
|
||||
|
||||
@@ -61,6 +61,7 @@ export function toLocalConversationHeader(
|
||||
workMode: conversation.workMode,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||
|
||||
@@ -250,7 +250,7 @@ export function useActiveConversationView(
|
||||
*/
|
||||
export type ComposerConversationView = Pick<
|
||||
Conversation,
|
||||
| "id" | "runtimeSelection" | "knowledgeLibraryIds" | "knowledgeRetrievalMode"
|
||||
| "id" | "runtimeSelection" | "knowledgeLibraryIds" | "knowledgeRetrievalMode" | "storyGraphEnabled"
|
||||
| "remote" | "contextMetrics" | "contextCompressionState"
|
||||
> & { running: boolean };
|
||||
|
||||
@@ -263,6 +263,7 @@ export function selectComposerConversationView(
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
remote: conversation.remote,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
@@ -277,6 +278,7 @@ export function sameComposerConversationView(
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
return left.id === right.id && left.running === right.running &&
|
||||
left.storyGraphEnabled === right.storyGraphEnabled &&
|
||||
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
|
||||
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
|
||||
(left.knowledgeLibraryIds === undefined) === (right.knowledgeLibraryIds === undefined) &&
|
||||
|
||||
@@ -563,6 +563,7 @@ export const app = {
|
||||
},
|
||||
removeAttachment: 'Remove {{name}}',
|
||||
settings: 'Conversation settings',
|
||||
storyGraph: { label: 'Use story graph' },
|
||||
options: 'Options',
|
||||
expertLabel: 'Expert role',
|
||||
modeLabel: 'Work mode',
|
||||
|
||||
@@ -43,6 +43,10 @@ export const heartbeat = {
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
timeouts: {
|
||||
model: 'Model',
|
||||
followDefault: 'Follow app default',
|
||||
unavailableModel: 'Selected model is unavailable',
|
||||
modelHelp: 'Uses an existing text model for reviews, summaries, stories, experiences, and suggestions. Saving affects the next execution, including continue or retry; a review already running keeps its model.',
|
||||
title: 'Model timeouts and concurrency',
|
||||
report: 'Heartbeat report timeout (seconds)',
|
||||
organize: 'Supervisor organize timeout (seconds)',
|
||||
|
||||
@@ -543,6 +543,7 @@ export const app = {
|
||||
},
|
||||
removeAttachment: '移除 {{name}}',
|
||||
settings: '对话设置',
|
||||
storyGraph: { label: '使用故事图谱' },
|
||||
options: '选项',
|
||||
expertLabel: '专家角色',
|
||||
modeLabel: '工作模式',
|
||||
|
||||
@@ -40,6 +40,10 @@ export const heartbeat = {
|
||||
dismiss: '忽略'
|
||||
},
|
||||
timeouts: {
|
||||
model: '模型',
|
||||
followDefault: '跟随应用默认',
|
||||
unavailableModel: '所选模型已不可用',
|
||||
modelHelp: '复用已有文本模型,用于回顾、汇总、故事、经验和建议。保存后从下次执行生效,包括继续或重试;正在运行的回顾保持原模型。',
|
||||
title: '模型超时与并发',
|
||||
report: '心跳报告超时(秒)',
|
||||
organize: '监督者整理超时(秒)',
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ComposerActions = {
|
||||
openModelSettings: () => void;
|
||||
setEnabledKnowledgeLibraryIds: (update: SetStateAction<string[]>) => void;
|
||||
setKnowledgeRetrievalMode: (mode: "auto" | "always") => void;
|
||||
setStoryGraphEnabled: (conversationId: string, enabled: boolean) => Promise<void>;
|
||||
selectExpert: (expertId: string) => void;
|
||||
selectRuntimeAgent: (agentId: string) => void;
|
||||
selectContinuePreset: (presetId: string) => void;
|
||||
|
||||
@@ -73,6 +73,7 @@ const applicationPreferencesSchema = z
|
||||
heartbeatReportTimeoutSeconds: supervisionTimeoutSecondsSchema.optional(),
|
||||
supervisorOrganizeTimeoutSeconds: supervisionTimeoutSecondsSchema.optional(),
|
||||
supervisorModelConcurrency: supervisorModelConcurrencySchema.optional(),
|
||||
supervisorModelProfileId: z.string().uuid().nullable().optional(),
|
||||
supervisionReview: supervisionReviewSettingsSchema.optional(),
|
||||
magicNotesShowIncompleteTodoCount: z.boolean().default(true),
|
||||
magicNoteCommentMode: magicNoteCommentModeSchema.default('immediate'),
|
||||
|
||||
@@ -620,6 +620,7 @@ export const conversationSnapshotSchema = z
|
||||
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
|
||||
workMode: workModeSchema.optional(),
|
||||
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
||||
storyGraphEnabled: z.boolean().optional(),
|
||||
contextMetrics: conversationContextMetricsSchema.optional(),
|
||||
contextCompressionState:
|
||||
conversationContextCompressionStateSchema.optional(),
|
||||
@@ -671,6 +672,11 @@ export const conversationSetPinnedSchema = z.object({
|
||||
pinned: z.boolean()
|
||||
}).strict()
|
||||
|
||||
export const conversationSetStoryGraphSchema = z.object({
|
||||
conversationId: assistantIdSchema,
|
||||
enabled: z.boolean()
|
||||
}).strict()
|
||||
|
||||
export type ConversationSetPinnedInput = z.infer<
|
||||
typeof conversationSetPinnedSchema
|
||||
>
|
||||
|
||||
@@ -2100,6 +2100,7 @@ export type DesktopApi = {
|
||||
replace: (conversations: ConversationSnapshot[]) => Promise<void>
|
||||
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
|
||||
setPinned: (input: ConversationSetPinnedInput) => Promise<void>
|
||||
setStoryGraph: (input: { conversationId: string; enabled: boolean }) => Promise<void>
|
||||
branchLocal: (
|
||||
input: ConversationBranchInput
|
||||
) => Promise<ConversationSnapshot>
|
||||
|
||||
@@ -228,6 +228,7 @@ export const ipcChannels = {
|
||||
conversationsReplace: 'conversations:replace',
|
||||
conversationsSaveLocal: 'conversations:save-local',
|
||||
conversationsSetPinned: 'conversations:set-pinned',
|
||||
conversationsSetStoryGraph: 'conversations:set-story-graph',
|
||||
conversationsBranchLocal: 'conversations:branch-local',
|
||||
conversationsDeleteLocal: 'conversations:delete-local',
|
||||
conversationsChanged: 'conversations:changed',
|
||||
|
||||
Reference in New Issue
Block a user