feat(server): realtime handle & migration (#15487)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Retry failed Copilot transcription tasks in real time.
  * Retried tasks resume processing and report updated status.
* Managed Copilot provider models can be omitted to use provider
defaults.

* **Bug Fixes**
* Improved handling of incomplete BYOK profiles, including safe
replacement of legacy records.
  * Duplicate profile creation now returns a clear validation error.
* Improved transcript processing reliability by preventing duplicate or
stale dispatches.

* **Migration**
* Consolidated legacy managed-provider settings while preserving
existing profiles and defaults.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-08-16 08:04:19 +08:00
committed by GitHub
parent ff1e3d9c94
commit 06b3d020fa
27 changed files with 1118 additions and 175 deletions
@@ -1,5 +1,4 @@
import {
retryTranscriptTaskMutation,
settleTranscriptTaskMutation,
submitTranscriptTaskMutation,
} from '@affine/graphql';
@@ -22,7 +21,10 @@ function createStore(
gql: ReturnType<typeof vi.fn>,
getAudioTranscriptionInput: () => Promise<AudioTranscriptionInput> = async () => ({
files: [],
})
}),
realtimeRequest: ReturnType<typeof vi.fn> = vi
.fn()
.mockResolvedValue({ task: { id: 'task-2' } })
) {
const framework = new Framework();
const server = {
@@ -31,7 +33,7 @@ function createStore(
},
};
const realtime = {
request: vi.fn().mockResolvedValue({ task: { id: 'task-2' } }),
request: realtimeRequest,
subscribe: vi.fn(),
};
framework
@@ -67,9 +69,16 @@ describe('AudioTranscriptionJobStore transcript task API', () => {
const gql = vi
.fn()
.mockResolvedValueOnce({ submitTranscriptTask: { id: 'task-1' } })
.mockResolvedValueOnce({ retryTranscriptTask: { id: 'task-2' } })
.mockResolvedValueOnce({ settleTranscriptTask: { id: 'task-2' } });
const store = createStore(gql, async () => ({ files: [file] }));
const realtimeRequest = vi
.fn()
.mockResolvedValueOnce({ task: { id: 'task-2', status: 'running' } })
.mockResolvedValueOnce({ task: { id: 'task-2' } });
const store = createStore(
gql,
async () => ({ files: [file] }),
realtimeRequest
);
await store.submitTranscriptTask();
await store.retryTranscriptTask('task-1');
@@ -90,16 +99,6 @@ describe('AudioTranscriptionJobStore transcript task API', () => {
);
expect(gql).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
query: retryTranscriptTaskMutation,
variables: {
workspaceId: 'workspace-1',
taskId: 'task-1',
},
})
);
expect(gql).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
query: settleTranscriptTaskMutation,
variables: {
@@ -108,5 +107,23 @@ describe('AudioTranscriptionJobStore transcript task API', () => {
},
})
);
expect(realtimeRequest).toHaveBeenNthCalledWith(
1,
'copilot.transcript.task.retry',
{
workspaceId: 'workspace-1',
taskId: 'task-1',
}
);
expect(realtimeRequest).toHaveBeenNthCalledWith(
2,
'copilot.transcript.task.get',
{
workspaceId: 'workspace-1',
taskId: 'task-2',
blobId: 'blob-1',
},
{ timeoutMs: 10000 }
);
});
});
@@ -1,5 +1,4 @@
import {
retryTranscriptTaskMutation,
settleTranscriptTaskMutation,
submitTranscriptTaskMutation,
type TranscriptionResultType,
@@ -64,21 +63,14 @@ export class AudioTranscriptionJobStore extends Entity<{
};
retryTranscriptTask = async (taskId: string) => {
const graphqlService = this.graphqlService;
if (!graphqlService) {
throw new Error('No graphql service available');
}
const response = await graphqlService.gql({
query: retryTranscriptTaskMutation,
variables: {
taskId,
workspaceId: this.currentWorkspaceId,
},
});
if (!response.retryTranscriptTask) {
const response = await this.nbstoreService.realtime.request(
'copilot.transcript.task.retry',
{ taskId, workspaceId: this.currentWorkspaceId }
);
if (!response.task) {
throw new Error('Failed to retry audio transcription');
}
return response.retryTranscriptTask;
return response.task as TranscriptionResultType;
};
getTranscriptTask = async (