feat(server): refactor copilot (#14892)

#### PR Dependency Tree


* **PR #14892** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-05-04 00:36:47 +08:00
committed by GitHub
parent fa8f1a096c
commit d64f368623
239 changed files with 35859 additions and 16777 deletions
-4
View File
@@ -313,10 +313,6 @@
"type": "Boolean",
"desc": "Whether to enable the copilot plugin. <br> Document: <a href=\"https://docs.affine.pro/self-host-affine/administer/ai\" target=\"_blank\">https://docs.affine.pro/self-host-affine/administer/ai</a>"
},
"scenarios": {
"type": "Object",
"desc": "Use custom models in scenarios and override default settings."
},
"providers.profiles": {
"type": "Array",
"desc": "The profile list for copilot providers."
@@ -1,263 +0,0 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class ClaimAudioTranscriptionMutation: GraphQLMutation {
public static let operationName: String = "claimAudioTranscription"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation claimAudioTranscription($jobId: String!) { claimAudioTranscription(jobId: $jobId) { __typename id status title summary actions sourceAudio { __typename blobId mimeType durationMs sampleRate channels } quality { __typename degraded overflowCount } sliceManifest { __typename index fileName mimeType startSec durationSec byteSize } normalizedSegments { __typename speaker startSec endSec start end text } normalizedTranscript summaryJson { __typename title durationMinutes attendees keyPoints actionItems { __typename description owner deadline } decisions openQuestions blockers } transcription { __typename speaker start end transcription } } }"#
))
public var jobId: String
public init(jobId: String) {
self.jobId = jobId
}
public var __variables: Variables? { ["jobId": jobId] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("claimAudioTranscription", ClaimAudioTranscription?.self, arguments: ["jobId": .variable("jobId")]),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ClaimAudioTranscriptionMutation.Data.self
] }
public var claimAudioTranscription: ClaimAudioTranscription? { __data["claimAudioTranscription"] }
/// ClaimAudioTranscription
///
/// Parent Type: `TranscriptionResultType`
public struct ClaimAudioTranscription: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionResultType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID.self),
.field("status", GraphQLEnum<AffineGraphQL.AiJobStatus>.self),
.field("title", String?.self),
.field("summary", String?.self),
.field("actions", String?.self),
.field("sourceAudio", SourceAudio?.self),
.field("quality", Quality?.self),
.field("sliceManifest", [SliceManifest]?.self),
.field("normalizedSegments", [NormalizedSegment]?.self),
.field("normalizedTranscript", String?.self),
.field("summaryJson", SummaryJson?.self),
.field("transcription", [Transcription]?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ClaimAudioTranscriptionMutation.Data.ClaimAudioTranscription.self
] }
public var id: AffineGraphQL.ID { __data["id"] }
public var status: GraphQLEnum<AffineGraphQL.AiJobStatus> { __data["status"] }
public var title: String? { __data["title"] }
public var summary: String? { __data["summary"] }
public var actions: String? { __data["actions"] }
public var sourceAudio: SourceAudio? { __data["sourceAudio"] }
public var quality: Quality? { __data["quality"] }
public var sliceManifest: [SliceManifest]? { __data["sliceManifest"] }
public var normalizedSegments: [NormalizedSegment]? { __data["normalizedSegments"] }
public var normalizedTranscript: String? { __data["normalizedTranscript"] }
public var summaryJson: SummaryJson? { __data["summaryJson"] }
public var transcription: [Transcription]? { __data["transcription"] }
/// ClaimAudioTranscription.SourceAudio
///
/// Parent Type: `TranscriptionSourceAudioType`
public struct SourceAudio: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionSourceAudioType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("blobId", String?.self),
.field("mimeType", String?.self),
.field("durationMs", Int?.self),
.field("sampleRate", Int?.self),
.field("channels", Int?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ClaimAudioTranscriptionMutation.Data.ClaimAudioTranscription.SourceAudio.self
] }
public var blobId: String? { __data["blobId"] }
public var mimeType: String? { __data["mimeType"] }
public var durationMs: Int? { __data["durationMs"] }
public var sampleRate: Int? { __data["sampleRate"] }
public var channels: Int? { __data["channels"] }
}
/// ClaimAudioTranscription.Quality
///
/// Parent Type: `TranscriptionQualityType`
public struct Quality: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionQualityType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("degraded", Bool?.self),
.field("overflowCount", Int?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ClaimAudioTranscriptionMutation.Data.ClaimAudioTranscription.Quality.self
] }
public var degraded: Bool? { __data["degraded"] }
public var overflowCount: Int? { __data["overflowCount"] }
}
/// ClaimAudioTranscription.SliceManifest
///
/// Parent Type: `AudioSliceManifestItemType`
public struct SliceManifest: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AudioSliceManifestItemType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("index", Int.self),
.field("fileName", String.self),
.field("mimeType", String.self),
.field("startSec", Double.self),
.field("durationSec", Double.self),
.field("byteSize", Int?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ClaimAudioTranscriptionMutation.Data.ClaimAudioTranscription.SliceManifest.self
] }
public var index: Int { __data["index"] }
public var fileName: String { __data["fileName"] }
public var mimeType: String { __data["mimeType"] }
public var startSec: Double { __data["startSec"] }
public var durationSec: Double { __data["durationSec"] }
public var byteSize: Int? { __data["byteSize"] }
}
/// ClaimAudioTranscription.NormalizedSegment
///
/// Parent Type: `NormalizedTranscriptSegmentType`
public struct NormalizedSegment: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.NormalizedTranscriptSegmentType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("speaker", String.self),
.field("startSec", Double.self),
.field("endSec", Double.self),
.field("start", String.self),
.field("end", String.self),
.field("text", String.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ClaimAudioTranscriptionMutation.Data.ClaimAudioTranscription.NormalizedSegment.self
] }
public var speaker: String { __data["speaker"] }
public var startSec: Double { __data["startSec"] }
public var endSec: Double { __data["endSec"] }
public var start: String { __data["start"] }
public var end: String { __data["end"] }
public var text: String { __data["text"] }
}
/// ClaimAudioTranscription.SummaryJson
///
/// Parent Type: `MeetingSummaryV2Type`
public struct SummaryJson: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.MeetingSummaryV2Type }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("title", String.self),
.field("durationMinutes", Double.self),
.field("attendees", [String].self),
.field("keyPoints", [String].self),
.field("actionItems", [ActionItem].self),
.field("decisions", [String].self),
.field("openQuestions", [String].self),
.field("blockers", [String].self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ClaimAudioTranscriptionMutation.Data.ClaimAudioTranscription.SummaryJson.self
] }
public var title: String { __data["title"] }
public var durationMinutes: Double { __data["durationMinutes"] }
public var attendees: [String] { __data["attendees"] }
public var keyPoints: [String] { __data["keyPoints"] }
public var actionItems: [ActionItem] { __data["actionItems"] }
public var decisions: [String] { __data["decisions"] }
public var openQuestions: [String] { __data["openQuestions"] }
public var blockers: [String] { __data["blockers"] }
/// ClaimAudioTranscription.SummaryJson.ActionItem
///
/// Parent Type: `MeetingActionItemType`
public struct ActionItem: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.MeetingActionItemType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("description", String.self),
.field("owner", String?.self),
.field("deadline", String?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ClaimAudioTranscriptionMutation.Data.ClaimAudioTranscription.SummaryJson.ActionItem.self
] }
public var description: String { __data["description"] }
public var owner: String? { __data["owner"] }
public var deadline: String? { __data["deadline"] }
}
}
/// ClaimAudioTranscription.Transcription
///
/// Parent Type: `TranscriptionItemType`
public struct Transcription: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionItemType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("speaker", String.self),
.field("start", String.self),
.field("end", String.self),
.field("transcription", String.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ClaimAudioTranscriptionMutation.Data.ClaimAudioTranscription.Transcription.self
] }
public var speaker: String { __data["speaker"] }
public var start: String { __data["start"] }
public var end: String { __data["end"] }
public var transcription: String { __data["transcription"] }
}
}
}
}
@@ -1,67 +0,0 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class RetryAudioTranscriptionMutation: GraphQLMutation {
public static let operationName: String = "retryAudioTranscription"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation retryAudioTranscription($workspaceId: String!, $jobId: String!) { retryAudioTranscription(workspaceId: $workspaceId, jobId: $jobId) { __typename id status } }"#
))
public var workspaceId: String
public var jobId: String
public init(
workspaceId: String,
jobId: String
) {
self.workspaceId = workspaceId
self.jobId = jobId
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"jobId": jobId
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("retryAudioTranscription", RetryAudioTranscription?.self, arguments: [
"workspaceId": .variable("workspaceId"),
"jobId": .variable("jobId")
]),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
RetryAudioTranscriptionMutation.Data.self
] }
public var retryAudioTranscription: RetryAudioTranscription? { __data["retryAudioTranscription"] }
/// RetryAudioTranscription
///
/// Parent Type: `TranscriptionResultType`
public struct RetryAudioTranscription: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionResultType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID.self),
.field("status", GraphQLEnum<AffineGraphQL.AiJobStatus>.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
RetryAudioTranscriptionMutation.Data.RetryAudioTranscription.self
] }
public var id: AffineGraphQL.ID { __data["id"] }
public var status: GraphQLEnum<AffineGraphQL.AiJobStatus> { __data["status"] }
}
}
}
@@ -1,82 +0,0 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class SubmitAudioTranscriptionMutation: GraphQLMutation {
public static let operationName: String = "submitAudioTranscription"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation submitAudioTranscription($workspaceId: String!, $blobId: String!, $blob: Upload, $blobs: [Upload!], $input: SubmitAudioTranscriptionInput) { submitAudioTranscription( blob: $blob blobs: $blobs blobId: $blobId workspaceId: $workspaceId input: $input ) { __typename id status } }"#
))
public var workspaceId: String
public var blobId: String
public var blob: GraphQLNullable<Upload>
public var blobs: GraphQLNullable<[Upload]>
public var input: GraphQLNullable<SubmitAudioTranscriptionInput>
public init(
workspaceId: String,
blobId: String,
blob: GraphQLNullable<Upload>,
blobs: GraphQLNullable<[Upload]>,
input: GraphQLNullable<SubmitAudioTranscriptionInput>
) {
self.workspaceId = workspaceId
self.blobId = blobId
self.blob = blob
self.blobs = blobs
self.input = input
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"blobId": blobId,
"blob": blob,
"blobs": blobs,
"input": input
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("submitAudioTranscription", SubmitAudioTranscription?.self, arguments: [
"blob": .variable("blob"),
"blobs": .variable("blobs"),
"blobId": .variable("blobId"),
"workspaceId": .variable("workspaceId"),
"input": .variable("input")
]),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
SubmitAudioTranscriptionMutation.Data.self
] }
public var submitAudioTranscription: SubmitAudioTranscription? { __data["submitAudioTranscription"] }
/// SubmitAudioTranscription
///
/// Parent Type: `TranscriptionResultType`
public struct SubmitAudioTranscription: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionResultType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID.self),
.field("status", GraphQLEnum<AffineGraphQL.AiJobStatus>.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
SubmitAudioTranscriptionMutation.Data.SubmitAudioTranscription.self
] }
public var id: AffineGraphQL.ID { __data["id"] }
public var status: GraphQLEnum<AffineGraphQL.AiJobStatus> { __data["status"] }
}
}
}
@@ -1,315 +0,0 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class GetAudioTranscriptionQuery: GraphQLQuery {
public static let operationName: String = "getAudioTranscription"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getAudioTranscription($workspaceId: String!, $jobId: String, $blobId: String) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename audioTranscription(jobId: $jobId, blobId: $blobId) { __typename id status title summary sourceAudio { __typename blobId mimeType durationMs sampleRate channels } quality { __typename degraded overflowCount } sliceManifest { __typename index fileName mimeType startSec durationSec byteSize } normalizedSegments { __typename speaker startSec endSec start end text } normalizedTranscript summaryJson { __typename title durationMinutes attendees keyPoints actionItems { __typename description owner deadline } decisions openQuestions blockers } transcription { __typename speaker start end transcription } } } } }"#
))
public var workspaceId: String
public var jobId: GraphQLNullable<String>
public var blobId: GraphQLNullable<String>
public init(
workspaceId: String,
jobId: GraphQLNullable<String>,
blobId: GraphQLNullable<String>
) {
self.workspaceId = workspaceId
self.jobId = jobId
self.blobId = blobId
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"jobId": jobId,
"blobId": blobId
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("currentUser", CurrentUser?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.self
] }
/// Get current user
public var currentUser: CurrentUser? { __data["currentUser"] }
/// CurrentUser
///
/// Parent Type: `UserType`
public struct CurrentUser: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("copilot", Copilot.self, arguments: ["workspaceId": .variable("workspaceId")]),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.self
] }
public var copilot: Copilot { __data["copilot"] }
/// CurrentUser.Copilot
///
/// Parent Type: `Copilot`
public struct Copilot: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("audioTranscription", AudioTranscription?.self, arguments: [
"jobId": .variable("jobId"),
"blobId": .variable("blobId")
]),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.Copilot.self
] }
public var audioTranscription: AudioTranscription? { __data["audioTranscription"] }
/// CurrentUser.Copilot.AudioTranscription
///
/// Parent Type: `TranscriptionResultType`
public struct AudioTranscription: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionResultType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID.self),
.field("status", GraphQLEnum<AffineGraphQL.AiJobStatus>.self),
.field("title", String?.self),
.field("summary", String?.self),
.field("sourceAudio", SourceAudio?.self),
.field("quality", Quality?.self),
.field("sliceManifest", [SliceManifest]?.self),
.field("normalizedSegments", [NormalizedSegment]?.self),
.field("normalizedTranscript", String?.self),
.field("summaryJson", SummaryJson?.self),
.field("transcription", [Transcription]?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.Copilot.AudioTranscription.self
] }
public var id: AffineGraphQL.ID { __data["id"] }
public var status: GraphQLEnum<AffineGraphQL.AiJobStatus> { __data["status"] }
public var title: String? { __data["title"] }
public var summary: String? { __data["summary"] }
public var sourceAudio: SourceAudio? { __data["sourceAudio"] }
public var quality: Quality? { __data["quality"] }
public var sliceManifest: [SliceManifest]? { __data["sliceManifest"] }
public var normalizedSegments: [NormalizedSegment]? { __data["normalizedSegments"] }
public var normalizedTranscript: String? { __data["normalizedTranscript"] }
public var summaryJson: SummaryJson? { __data["summaryJson"] }
public var transcription: [Transcription]? { __data["transcription"] }
/// CurrentUser.Copilot.AudioTranscription.SourceAudio
///
/// Parent Type: `TranscriptionSourceAudioType`
public struct SourceAudio: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionSourceAudioType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("blobId", String?.self),
.field("mimeType", String?.self),
.field("durationMs", Int?.self),
.field("sampleRate", Int?.self),
.field("channels", Int?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.Copilot.AudioTranscription.SourceAudio.self
] }
public var blobId: String? { __data["blobId"] }
public var mimeType: String? { __data["mimeType"] }
public var durationMs: Int? { __data["durationMs"] }
public var sampleRate: Int? { __data["sampleRate"] }
public var channels: Int? { __data["channels"] }
}
/// CurrentUser.Copilot.AudioTranscription.Quality
///
/// Parent Type: `TranscriptionQualityType`
public struct Quality: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionQualityType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("degraded", Bool?.self),
.field("overflowCount", Int?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.Copilot.AudioTranscription.Quality.self
] }
public var degraded: Bool? { __data["degraded"] }
public var overflowCount: Int? { __data["overflowCount"] }
}
/// CurrentUser.Copilot.AudioTranscription.SliceManifest
///
/// Parent Type: `AudioSliceManifestItemType`
public struct SliceManifest: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AudioSliceManifestItemType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("index", Int.self),
.field("fileName", String.self),
.field("mimeType", String.self),
.field("startSec", Double.self),
.field("durationSec", Double.self),
.field("byteSize", Int?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.Copilot.AudioTranscription.SliceManifest.self
] }
public var index: Int { __data["index"] }
public var fileName: String { __data["fileName"] }
public var mimeType: String { __data["mimeType"] }
public var startSec: Double { __data["startSec"] }
public var durationSec: Double { __data["durationSec"] }
public var byteSize: Int? { __data["byteSize"] }
}
/// CurrentUser.Copilot.AudioTranscription.NormalizedSegment
///
/// Parent Type: `NormalizedTranscriptSegmentType`
public struct NormalizedSegment: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.NormalizedTranscriptSegmentType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("speaker", String.self),
.field("startSec", Double.self),
.field("endSec", Double.self),
.field("start", String.self),
.field("end", String.self),
.field("text", String.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.Copilot.AudioTranscription.NormalizedSegment.self
] }
public var speaker: String { __data["speaker"] }
public var startSec: Double { __data["startSec"] }
public var endSec: Double { __data["endSec"] }
public var start: String { __data["start"] }
public var end: String { __data["end"] }
public var text: String { __data["text"] }
}
/// CurrentUser.Copilot.AudioTranscription.SummaryJson
///
/// Parent Type: `MeetingSummaryV2Type`
public struct SummaryJson: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.MeetingSummaryV2Type }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("title", String.self),
.field("durationMinutes", Double.self),
.field("attendees", [String].self),
.field("keyPoints", [String].self),
.field("actionItems", [ActionItem].self),
.field("decisions", [String].self),
.field("openQuestions", [String].self),
.field("blockers", [String].self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.Copilot.AudioTranscription.SummaryJson.self
] }
public var title: String { __data["title"] }
public var durationMinutes: Double { __data["durationMinutes"] }
public var attendees: [String] { __data["attendees"] }
public var keyPoints: [String] { __data["keyPoints"] }
public var actionItems: [ActionItem] { __data["actionItems"] }
public var decisions: [String] { __data["decisions"] }
public var openQuestions: [String] { __data["openQuestions"] }
public var blockers: [String] { __data["blockers"] }
/// CurrentUser.Copilot.AudioTranscription.SummaryJson.ActionItem
///
/// Parent Type: `MeetingActionItemType`
public struct ActionItem: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.MeetingActionItemType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("description", String.self),
.field("owner", String?.self),
.field("deadline", String?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.Copilot.AudioTranscription.SummaryJson.ActionItem.self
] }
public var description: String { __data["description"] }
public var owner: String? { __data["owner"] }
public var deadline: String? { __data["deadline"] }
}
}
/// CurrentUser.Copilot.AudioTranscription.Transcription
///
/// Parent Type: `TranscriptionItemType`
public struct Transcription: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.TranscriptionItemType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("speaker", String.self),
.field("start", String.self),
.field("end", String.self),
.field("transcription", String.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
GetAudioTranscriptionQuery.Data.CurrentUser.Copilot.AudioTranscription.Transcription.self
] }
public var speaker: String { __data["speaker"] }
public var start: String { __data["start"] }
public var end: String { __data["end"] }
public var transcription: String { __data["transcription"] }
}
}
}
}
}
}
@@ -37,7 +37,8 @@ public enum SchemaMetadata: ApolloAPI.SchemaMetadata {
"BlobUploadPart": AffineGraphQL.Objects.BlobUploadPart,
"BlobUploadedPart": AffineGraphQL.Objects.BlobUploadedPart,
"CalendarAccountObjectType": AffineGraphQL.Objects.CalendarAccountObjectType,
"CalendarCalDAVProviderPresetObjectType": AffineGraphQL.Objects.CalendarCalDAVProviderPresetObjectType,
"CalendarCalDAVProviderPresetObjectType": AffineGraphQL.Objects
.CalendarCalDAVProviderPresetObjectType,
"CalendarEventObjectType": AffineGraphQL.Objects.CalendarEventObjectType,
"CalendarSubscriptionObjectType": AffineGraphQL.Objects.CalendarSubscriptionObjectType,
"ChatMessage": AffineGraphQL.Objects.ChatMessage,
@@ -139,7 +140,7 @@ public enum SchemaMetadata: ApolloAPI.SchemaMetadata {
"WorkspaceRolePermissions": AffineGraphQL.Objects.WorkspaceRolePermissions,
"WorkspaceType": AffineGraphQL.Objects.WorkspaceType,
"WorkspaceUserType": AffineGraphQL.Objects.WorkspaceUserType,
"tokenType": AffineGraphQL.Objects.TokenType
"tokenType": AffineGraphQL.Objects.TokenType,
]
public static func objectType(forTypename typename: String) -> ApolloAPI.Object? {
@@ -152,7 +152,6 @@ declare global {
selectedMarkdown?: string;
html?: string;
};
postfix?: (text: string) => string;
}
interface TranslateOptions extends AITextActionOptions {
@@ -0,0 +1,34 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, test, vi } from 'vitest';
import { CopilotClient, Endpoint } from './copilot-client';
describe('CopilotClient action streams', () => {
test('routes action endpoint outside the deprecated workflow path', () => {
const eventSource = vi.fn(
() =>
({
close: vi.fn(),
}) as unknown as EventSource
);
const client = new CopilotClient(vi.fn(), eventSource);
client.chatTextStream(
{
sessionId: 'session-1',
messageId: 'message-1',
actionId: 'mindmap.generate',
actionVersion: 'v1',
retry: true,
runId: 'run-1',
},
Endpoint.Action
);
expect(eventSource).toHaveBeenCalledWith(
'/api/copilot/actions/session-1/stream?messageId=message-1&actionId=mindmap.generate&actionVersion=v1&runId=run-1&retry=true'
);
});
});
@@ -42,8 +42,8 @@ import {
} from './error';
export enum Endpoint {
Action = 'action',
StreamObject = 'stream-object',
Workflow = 'workflow',
Images = 'images',
}
@@ -469,21 +469,36 @@ export class CopilotClient {
reasoning,
modelId,
toolsConfig,
actionId,
actionVersion,
runId,
retry,
}: {
sessionId: string;
messageId?: string;
reasoning?: boolean;
modelId?: string;
toolsConfig?: AIToolsConfig;
actionId?: string;
actionVersion?: string;
runId?: string;
retry?: boolean;
},
endpoint = Endpoint.StreamObject
) {
let url = `/api/copilot/chat/${sessionId}/${endpoint}`;
let url =
endpoint === Endpoint.Action
? `/api/copilot/actions/${sessionId}/stream`
: `/api/copilot/chat/${sessionId}/${endpoint}`;
const queryString = this.paramsToQueryString({
messageId,
reasoning,
modelId,
toolsConfig,
actionId,
actionVersion,
runId,
retry,
});
if (queryString) {
url += `?${queryString}`;
@@ -39,9 +39,13 @@ export const promptKeys = [
'Convert to sticker',
'Upscale image',
'Remove background',
// workflows
'workflow:presentation',
'workflow:brainstorm',
// actions
'mindmap.generate',
'slides.outline',
'image.filter.sketch',
'image.filter.clay',
'image.filter.anime',
'image.filter.pixel',
] as const;
export type PromptKey = (typeof promptKeys)[number];
@@ -18,8 +18,10 @@ export type TextToTextOptions = {
signal?: AbortSignal;
retry?: boolean;
endpoint?: Endpoint;
actionId?: string;
actionVersion?: string;
runId?: string;
isRootSession?: boolean;
postfix?: (text: string) => string;
reasoning?: boolean;
modelId?: string;
toolsConfig?: AIToolsConfig;
@@ -120,7 +122,9 @@ export function textToText({
timeout = TIMEOUT,
retry = false,
endpoint = Endpoint.StreamObject,
postfix,
actionId,
actionVersion,
runId,
reasoning,
modelId,
toolsConfig,
@@ -148,6 +152,10 @@ export function textToText({
reasoning,
modelId,
toolsConfig,
actionId,
actionVersion,
runId,
retry,
},
endpoint
);
@@ -166,25 +174,12 @@ export function textToText({
signal.addEventListener('abort', onAbort, { once: true });
}
if (postfix) {
const messages: string[] = [];
for await (const event of toTextStream(eventSource, {
timeout,
signal,
})) {
if (event.type === 'message') {
messages.push(event.data);
}
}
yield postfix(messages.join(''));
} else {
for await (const event of toTextStream(eventSource, {
timeout,
signal,
})) {
if (event.type === 'message') {
yield event.data;
}
for await (const event of toTextStream(eventSource, {
timeout,
signal,
})) {
if (event.type === 'message') {
yield event.data;
}
}
} finally {
@@ -215,6 +210,10 @@ export function textToText({
reasoning,
modelId,
toolsConfig,
actionId,
actionVersion,
runId,
retry,
},
endpoint
);
@@ -244,7 +243,7 @@ export function textToText({
}
const result = messages.join('');
return postfix ? postfix(result) : result;
return result;
} finally {
eventSource.close();
if (signal && onAbort) {
@@ -266,6 +265,9 @@ export function toImage({
timeout = TIMEOUT,
retry = false,
endpoint,
actionId,
actionVersion,
runId,
client,
}: ToImageOptions) {
let messageId: string | undefined;
@@ -282,12 +284,20 @@ export function toImage({
signal,
});
}
const eventSource = client.imagesStream(
sessionId,
messageId,
seed,
endpoint
);
const eventSource =
endpoint === Endpoint.Action
? client.chatTextStream(
{
sessionId,
messageId,
actionId,
actionVersion,
runId,
retry,
},
Endpoint.Action
)
: client.imagesStream(sessionId, messageId, seed, endpoint);
AIProvider.LAST_ACTION_SESSIONID = sessionId;
for await (const event of toTextStream(eventSource, {
@@ -0,0 +1,120 @@
/**
* @vitest-environment happy-dom
*/
import { BehaviorSubject } from 'rxjs';
import { describe, expect, test, vi } from 'vitest';
import { AIProvider } from './ai-provider';
import { CopilotClient, Endpoint } from './copilot-client';
import { setupAIProvider } from './setup-provider';
Object.defineProperty(globalThis, 'EventSource', {
configurable: true,
value: {
CLOSED: 2,
},
});
type SetupAIProviderArgs = Parameters<typeof setupAIProvider>;
type ActionInput<T extends keyof BlockSuitePresets.AIActions> = Parameters<
NonNullable<BlockSuitePresets.AIActions[T]>
>[0];
async function drain(stream: AsyncIterable<unknown>) {
for await (const chunk of stream) {
void chunk;
}
}
async function drainActionResult(
stream: string | AsyncIterable<unknown> | undefined
) {
expect(stream).toBeDefined();
expect(typeof stream).not.toBe('string');
await drain(stream as AsyncIterable<unknown>);
}
function createClosedEventSource(): EventSource {
return {
readyState: EventSource.CLOSED,
addEventListener: vi.fn(),
close: vi.fn(),
} as unknown as EventSource;
}
describe('setupAIProvider action migrations', () => {
test('routes mindmap, slides and image filter through action API', async () => {
const createdSessions: unknown[] = [];
const textStreams: unknown[] = [];
const client = new CopilotClient(
vi.fn(),
vi.fn(() => createClosedEventSource())
);
vi.spyOn(client, 'createSession').mockImplementation(async options => {
createdSessions.push(options);
return `session:${options.promptName}`;
});
vi.spyOn(client, 'createMessage').mockResolvedValue('message-1');
vi.spyOn(client, 'chatTextStream').mockImplementation(
(options, endpoint) => {
textStreams.push({ options, endpoint });
return createClosedEventSource();
}
);
vi.spyOn(client, 'imagesStream').mockReturnValue(createClosedEventSource());
setupAIProvider(
client,
{ open: vi.fn() } as unknown as SetupAIProviderArgs[1],
{
session: {
account$: new BehaviorSubject(null),
},
} as unknown as SetupAIProviderArgs[2]
);
await drainActionResult(
await AIProvider.actions.brainstormMindmap?.({
workspaceId: 'workspace-1',
input: 'make a map',
stream: true,
} satisfies ActionInput<'brainstormMindmap'>)
);
await drainActionResult(
await AIProvider.actions.createSlides?.({
workspaceId: 'workspace-1',
input: 'make slides',
stream: true,
} satisfies ActionInput<'createSlides'>)
);
await drainActionResult(
await AIProvider.actions.filterImage?.({
workspaceId: 'workspace-1',
input: 'convert',
attachments: ['blob-1'],
style: 'Sketch style',
} satisfies ActionInput<'filterImage'>)
);
expect(createdSessions).toEqual([
expect.objectContaining({ promptName: 'mindmap.generate' }),
expect.objectContaining({ promptName: 'slides.outline' }),
expect.objectContaining({ promptName: 'image.filter.sketch' }),
]);
expect(textStreams).toEqual([
expect.objectContaining({
endpoint: Endpoint.Action,
options: expect.objectContaining({ actionId: 'mindmap.generate' }),
}),
expect.objectContaining({
endpoint: Endpoint.Action,
options: expect.objectContaining({ actionId: 'slides.outline' }),
}),
expect.objectContaining({
endpoint: Endpoint.Action,
options: expect.objectContaining({ actionId: 'image.filter.sketch' }),
}),
]);
expect(client.imagesStream).not.toHaveBeenCalled();
});
});
@@ -10,7 +10,6 @@ import {
type RequestOptions,
type UpdateChatSessionInput,
} from '@affine/graphql';
import { z } from 'zod';
import { AIProvider } from './ai-provider';
import { type CopilotClient, Endpoint } from './copilot-client';
@@ -30,10 +29,10 @@ function toAIUserInfo(account: AuthAccountInfo | null) {
const filterStyleToPromptName = new Map<string, PromptKey>(
Object.entries({
'Clay style': 'Convert to Clay style',
'Pixel style': 'Convert to Pixel style',
'Sketch style': 'Convert to Sketch style',
'Anime style': 'Convert to Anime style',
'Clay style': 'image.filter.clay',
'Pixel style': 'image.filter.pixel',
'Sketch style': 'image.filter.sketch',
'Anime style': 'image.filter.anime',
})
);
@@ -350,7 +349,7 @@ export function setupAIProvider(
AIProvider.provide('brainstormMindmap', async options => {
const sessionId = await createSession({
promptName: 'workflow:brainstorm',
promptName: 'mindmap.generate',
...options,
});
return textToText({
@@ -360,7 +359,9 @@ export function setupAIProvider(
content: options.input,
// 3 minutes
timeout: 180000,
endpoint: Endpoint.Workflow,
endpoint: Endpoint.Action,
actionId: 'mindmap.generate',
actionVersion: 'v1',
});
});
@@ -439,44 +440,8 @@ Could you make a new website based on these notes and send back just the html fi
});
AIProvider.provide('createSlides', async options => {
const SlideSchema = z.object({
page: z.number(),
type: z.enum(['name', 'title', 'content']),
content: z.string(),
});
type Slide = z.infer<typeof SlideSchema>;
const parseJson = (json: string) => {
try {
return SlideSchema.parse(JSON.parse(json));
} catch {
return null;
}
};
// TODO(@darkskygit): move this to backend's workflow after workflow support custom code action
const postfix = (text: string): string => {
const slides = text
.split('\n')
.map(parseJson)
.filter((v): v is Slide => !!v);
return slides
.map(slide => {
if (slide.type === 'name') {
return `- ${slide.content}`;
} else if (slide.type === 'title') {
return ` - ${slide.content}`;
} else if (slide.content.includes('\n')) {
return slide.content
.split('\n')
.map(c => ` - ${c}`)
.join('\n');
} else {
return ` - ${slide.content}`;
}
})
.join('\n');
};
const sessionId = await createSession({
promptName: 'workflow:presentation',
promptName: 'slides.outline',
...options,
});
return textToText({
@@ -486,8 +451,9 @@ Could you make a new website based on these notes and send back just the html fi
content: options.input,
// 3 minutes
timeout: 180000,
endpoint: Endpoint.Workflow,
postfix,
endpoint: Endpoint.Action,
actionId: 'slides.outline',
actionVersion: 'v1',
});
});
@@ -521,14 +487,15 @@ Could you make a new website based on these notes and send back just the html fi
promptName,
...options,
});
const isWorkflow = !!promptName?.startsWith('workflow:');
return toImage({
...options,
client,
sessionId,
content: options.input,
timeout: 180000,
endpoint: isWorkflow ? Endpoint.Workflow : Endpoint.Images,
endpoint: Endpoint.Action,
actionId: promptName,
actionVersion: 'v1',
});
});
@@ -5,7 +5,6 @@ import {
import { insertFromMarkdown } from '@affine/core/blocksuite/utils';
import { preprocessAudioBlobForTranscription } from '@affine/core/utils/opus-encoding';
import { DebugLogger } from '@affine/debug';
import { AiJobStatus } from '@affine/graphql';
import track from '@affine/track';
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
import type { AffineTextAttributes } from '@blocksuite/affine/shared/types';
@@ -175,7 +174,7 @@ export class AudioAttachmentBlock extends Entity<AttachmentBlockModel> {
return;
}
const status = await this.transcriptionJob.start();
if (status.status === AiJobStatus.claimed) {
if (status.status === 'settled') {
await this.fillTranscriptionResult(status.result);
}
} catch (error) {
@@ -0,0 +1,125 @@
import {
getTranscriptTaskQuery,
retryTranscriptTaskMutation,
settleTranscriptTaskMutation,
submitTranscriptTaskMutation,
} from '@affine/graphql';
import { Framework } from '@toeverything/infra';
import { describe, expect, test, vi } from 'vitest';
import { DefaultServerService } from '../../cloud/services/default-server';
import { GraphQLService } from '../../cloud/services/graphql';
import { WorkspaceServerService } from '../../cloud/services/workspace-server';
import { WorkspaceService } from '../../workspace';
import { AudioTranscriptionJobStore } from './audio-transcription-job-store';
type AudioTranscriptionInput = {
files: File[];
input?: Record<string, unknown>;
};
function createStore(
gql: ReturnType<typeof vi.fn>,
getAudioTranscriptionInput: () => Promise<AudioTranscriptionInput> = async () => ({
files: [],
})
) {
const framework = new Framework();
const server = {
scope: {
get: (key: unknown) => (key === GraphQLService ? { gql } : null),
},
};
framework
.service(WorkspaceService, {
workspace: { id: 'workspace-1' },
} as WorkspaceService)
.service(WorkspaceServerService, {
server: {
scope: server.scope,
},
} as WorkspaceServerService)
.service(DefaultServerService, {
server: null,
} as unknown as DefaultServerService)
.entity(AudioTranscriptionJobStore, [
WorkspaceService,
WorkspaceServerService,
DefaultServerService,
]);
return framework.provider().createEntity(AudioTranscriptionJobStore, {
blobId: 'blob-1',
getAudioTranscriptionInput,
});
}
describe('AudioTranscriptionJobStore transcript task API', () => {
test('uses new transcript task mutations and query', async () => {
const file = new File(['audio'], 'audio.webm', { type: 'audio/webm' });
const gql = vi
.fn()
.mockResolvedValueOnce({ submitTranscriptTask: { id: 'task-1' } })
.mockResolvedValueOnce({ retryTranscriptTask: { id: 'task-2' } })
.mockResolvedValueOnce({
currentUser: {
copilot: {
transcriptTask: { id: 'task-2' },
},
},
})
.mockResolvedValueOnce({ settleTranscriptTask: { id: 'task-2' } });
const store = createStore(gql, async () => ({
files: [file],
input: { strategy: 'gemini' },
}));
await store.submitTranscriptTask();
await store.retryTranscriptTask('task-1');
await store.getTranscriptTask('blob-1', 'task-2');
await store.settleTranscriptTask('task-2');
expect(gql).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
query: submitTranscriptTaskMutation,
variables: {
workspaceId: 'workspace-1',
blobId: 'blob-1',
blobs: [file],
input: { strategy: 'gemini' },
},
})
);
expect(gql).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
query: retryTranscriptTaskMutation,
variables: {
workspaceId: 'workspace-1',
taskId: 'task-1',
},
})
);
expect(gql).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
query: getTranscriptTaskQuery,
variables: {
workspaceId: 'workspace-1',
taskId: 'task-2',
blobId: 'blob-1',
},
})
);
expect(gql).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
query: settleTranscriptTaskMutation,
variables: {
workspaceId: 'workspace-1',
taskId: 'task-2',
},
})
);
});
});
@@ -1,8 +1,8 @@
import {
claimAudioTranscriptionMutation,
getAudioTranscriptionQuery,
retryAudioTranscriptionMutation,
submitAudioTranscriptionMutation,
getTranscriptTaskQuery,
retryTranscriptTaskMutation,
settleTranscriptTaskMutation,
submitTranscriptTaskMutation,
} from '@affine/graphql';
import { Entity } from '@toeverything/infra';
@@ -39,7 +39,7 @@ export class AudioTranscriptionJobStore extends Entity<{
return this.workspaceService.workspace.id;
}
submitAudioTranscription = async () => {
submitTranscriptTask = async () => {
const graphqlService = this.graphqlService;
if (!graphqlService) {
throw new Error('No graphql service available');
@@ -47,7 +47,7 @@ export class AudioTranscriptionJobStore extends Entity<{
const { files, input } = await this.props.getAudioTranscriptionInput();
const response = await graphqlService.gql({
timeout: 0, // default 15s is too short for audio transcription
query: submitAudioTranscriptionMutation,
query: submitTranscriptTaskMutation,
variables: {
workspaceId: this.currentWorkspaceId,
blobId: this.props.blobId,
@@ -55,31 +55,31 @@ export class AudioTranscriptionJobStore extends Entity<{
input,
},
});
if (!response.submitAudioTranscription?.id) {
if (!response.submitTranscriptTask?.id) {
throw new Error('Failed to submit audio transcription');
}
return response.submitAudioTranscription;
return response.submitTranscriptTask;
};
retryAudioTranscription = async (jobId: string) => {
retryTranscriptTask = async (taskId: string) => {
const graphqlService = this.graphqlService;
if (!graphqlService) {
throw new Error('No graphql service available');
}
const response = await graphqlService.gql({
query: retryAudioTranscriptionMutation,
query: retryTranscriptTaskMutation,
variables: {
jobId,
taskId,
workspaceId: this.currentWorkspaceId,
},
});
if (!response.retryAudioTranscription) {
if (!response.retryTranscriptTask) {
throw new Error('Failed to retry audio transcription');
}
return response.retryAudioTranscription;
return response.retryTranscriptTask;
};
getAudioTranscription = async (blobId: string, jobId?: string) => {
getTranscriptTask = async (blobId: string, taskId?: string) => {
const graphqlService = this.graphqlService;
if (!graphqlService) {
throw new Error('No graphql service available');
@@ -89,32 +89,33 @@ export class AudioTranscriptionJobStore extends Entity<{
throw new Error('No current workspace id');
}
const response = await graphqlService.gql({
query: getAudioTranscriptionQuery,
query: getTranscriptTaskQuery,
variables: {
workspaceId: currentWorkspaceId,
jobId,
taskId,
blobId,
},
});
if (!response.currentUser?.copilot?.audioTranscription) {
if (!response.currentUser?.copilot?.transcriptTask) {
return null;
}
return response.currentUser.copilot.audioTranscription;
return response.currentUser.copilot.transcriptTask;
};
claimAudioTranscription = async (jobId: string) => {
settleTranscriptTask = async (taskId: string) => {
const graphqlService = this.graphqlService;
if (!graphqlService) {
throw new Error('No graphql service available');
}
const response = await graphqlService.gql({
query: claimAudioTranscriptionMutation,
query: settleTranscriptTaskMutation,
variables: {
jobId,
taskId,
workspaceId: this.currentWorkspaceId,
},
});
if (!response.claimAudioTranscription) {
throw new Error('Failed to claim transcription result');
if (!response.settleTranscriptTask) {
throw new Error('Failed to settle transcription result');
}
return response.claimAudioTranscription;
return response.settleTranscriptTask;
};
}
@@ -13,37 +13,37 @@ import type { TranscriptionResult } from './types';
// The UI status of the transcription job
export type TranscriptionStatus =
| {
status: 'waiting-for-job';
}
| {
status: 'started';
}
| {
status: AiJobStatus.pending;
}
| {
status: AiJobStatus.running;
}
| { status: 'waiting-for-job' }
| { status: 'started' }
| { status: AiJobStatus.pending }
| { status: AiJobStatus.running }
| {
status: AiJobStatus.failed;
error: UserFriendlyError; // <<- this is not visible on UI yet
}
| {
status: AiJobStatus.finished; // ready to be claimed, but may be rejected because of insufficient credits
}
| {
status: AiJobStatus.claimed;
result: TranscriptionResult;
};
| { status: AiJobStatus.finished }
| { status: 'settled'; result: TranscriptionResult };
const logger = new DebugLogger('audio-transcription-job');
function hasSettledTranscriptResult(
job: {
status: AiJobStatus;
normalizedTranscript?: string | null;
transcription?: unknown[] | null;
} | null
) {
return (
job?.status === AiJobStatus.finished &&
(!!job.normalizedTranscript || !!job.transcription?.length)
);
}
// facts on transcription job ownership
// 1. jobid + blobid is unique for a given user
// 2. only the creator can claim the job
// 3. all users can query the claimed job result
// 4. claim a job requires AI credits
// 2. only the creator can settle/unlock the task result
// 3. all users can query the settled result
// 4. settlement requires AI credits
export class AudioTranscriptionJob extends Entity<{
readonly blockProps: TranscriptionBlockProps;
readonly blobId: string;
@@ -97,12 +97,12 @@ export class AudioTranscriptionJob extends Entity<{
readonly preflightCheck = async () => {
// if the job id is given, check if the job exists
if (this.props.blockProps.jobId) {
const existingJob = await this.store.getAudioTranscription(
const existingJob = await this.store.getTranscriptTask(
this.props.blobId,
this.props.blockProps.jobId
);
if (existingJob?.status === AiJobStatus.claimed) {
if (hasSettledTranscriptResult(existingJob)) {
// if job exists, anyone can query it
return;
}
@@ -142,25 +142,28 @@ export class AudioTranscriptionJob extends Entity<{
let job: {
id: string;
status: AiJobStatus;
} | null = await this.store.getAudioTranscription(
} | null = await this.store.getTranscriptTask(
this.props.blobId,
this.props.blockProps.jobId
);
if (!job) {
logger.debug('No existing job found, submitting new transcription job');
job = await this.store.submitAudioTranscription();
job = await this.store.submitTranscriptTask();
} else if (job.status === AiJobStatus.failed) {
logger.debug('Found existing failed job, retrying', {
jobId: job.id,
});
job = await this.store.retryAudioTranscription(job.id);
job = await this.store.retryTranscriptTask(job.id);
} else {
logger.debug('Found existing job', {
jobId: job.id,
status: job.status,
});
}
if (!job) {
throw UserFriendlyError.fromAny('failed to submit transcription');
}
this.props.blockProps.jobId = job.id;
this.props.blockProps.createdBy = this.currentUserId;
@@ -174,8 +177,8 @@ export class AudioTranscriptionJob extends Entity<{
throw UserFriendlyError.fromAny('failed to submit transcription');
}
await this.untilJobFinishedOrClaimed();
await this.claim();
await this.untilTaskReadyOrSettled();
await this.settle();
} catch (err) {
logger.debug('Error during job submission', { error: err });
this._status$.value = {
@@ -186,7 +189,7 @@ export class AudioTranscriptionJob extends Entity<{
return this.status$.value;
}
private async untilJobFinishedOrClaimed() {
private async untilTaskReadyOrSettled() {
while (
!this.disposed &&
this.props.blockProps.jobId &&
@@ -195,7 +198,7 @@ export class AudioTranscriptionJob extends Entity<{
logger.debug('Polling job status', {
jobId: this.props.blockProps.jobId,
});
const job = await this.store.getAudioTranscription(
const job = await this.store.getTranscriptTask(
this.props.blobId,
this.props.blockProps.jobId
);
@@ -207,8 +210,8 @@ export class AudioTranscriptionJob extends Entity<{
throw UserFriendlyError.fromAny('Transcription job failed');
}
if (job?.status === 'finished' || job?.status === 'claimed') {
logger.debug('Job finished, ready to claim', {
if (job?.status === AiJobStatus.finished) {
logger.debug('Transcript task is ready to settle', {
jobId: this.props.blockProps.jobId,
});
this._status$.value = {
@@ -222,37 +225,37 @@ export class AudioTranscriptionJob extends Entity<{
}
}
async claim() {
async settle() {
if (this.disposed) {
logger.debug('Job already disposed, cannot claim');
logger.debug('Job already disposed, cannot settle');
throw new Error('Job already disposed');
}
logger.debug('Attempting to claim job', {
logger.debug('Attempting to settle transcript task', {
jobId: this.props.blockProps.jobId,
});
if (!this.props.blockProps.jobId) {
logger.debug('No job id found, cannot claim');
logger.debug('No job id found, cannot settle');
throw new Error('No job id found');
}
const claimedJob = await this.store.claimAudioTranscription(
const settledTask = await this.store.settleTranscriptTask(
this.props.blockProps.jobId
);
if (claimedJob) {
logger.debug('Successfully claimed job', {
if (settledTask) {
logger.debug('Successfully settled transcript task', {
jobId: this.props.blockProps.jobId,
});
const result: TranscriptionResult = buildTranscriptionResult(claimedJob);
const result: TranscriptionResult = buildTranscriptionResult(settledTask);
this._status$.value = {
status: AiJobStatus.claimed,
status: 'settled',
result,
};
} else {
throw new Error('Failed to claim transcription result');
throw new Error('Failed to settle transcription result');
}
}