Skip to content

Commit 250c54b

Browse files
committed
Add GitHubCopilotChatService
1 parent ae0f411 commit 250c54b

File tree

4 files changed

+180
-68
lines changed

4 files changed

+180
-68
lines changed

Tool/Sources/BuiltinExtension/BuiltinExtension.swift

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import ChatContextCollector
1+
import ChatBasic
22
import ChatTab
33
import CopilotForXcodeKit
44
import Foundation
@@ -28,25 +28,37 @@ public extension BuiltinExtension {
2828

2929
/// A temporary protocol for ChatServiceType. Migrate it to CopilotForXcodeKit when finished.
3030
public protocol BuiltinExtensionChatServiceType: ChatServiceType {
31-
typealias Message = ChatServiceMessage
32-
typealias RetrievedContent = ChatContext.RetrievedContent
31+
typealias Message = ChatMessage
32+
33+
func sendMessage(
34+
_ message: String,
35+
history: [Message],
36+
references: [RetrievedContent],
37+
workspace: WorkspaceInfo
38+
) async -> AsyncThrowingStream<String, Error>
3339
}
3440

35-
public struct ChatServiceMessage: Codable {
36-
public enum Role: Codable, Equatable {
37-
case system
38-
case user
39-
case assistant
40-
case tool
41-
case other(String)
41+
public struct RetrievedContent {
42+
public var document: ChatMessage.Reference
43+
public var priority: Int
44+
45+
public init(document: ChatMessage.Reference, priority: Int) {
46+
self.document = document
47+
self.priority = priority
4248
}
49+
}
4350

44-
public var role: Role
45-
public var text: String
51+
public enum ChatServiceMemoryMutation: Codable {
52+
public typealias Message = ChatMessage
4653

47-
public init(role: Role, text: String) {
48-
self.role = role
49-
self.text = text
50-
}
54+
/// Add a new message to the end of memory.
55+
/// If an id is not provided, a new id will be generated.
56+
/// If an id is provided, and a message with the same id exists the message with the same
57+
/// id will be updated.
58+
case appendMessage(id: String?, role: Message.Role, text: String)
59+
/// Update the message with the given id.
60+
case updateMessage(id: String, role: Message.Role, text: String)
61+
/// Stream the content into a message with the given id.
62+
case streamIntoMessage(id: String, role: Message.Role?, text: String?)
5163
}
5264

Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ public final class GitHubCopilotExtension: BuiltinExtension {
1111

1212
public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot }
1313

14-
public let suggestionService: GitHubCopilotSuggestionService?
15-
public let chatService: GitHubCopilotChatService?
14+
public let suggestionService: GitHubCopilotSuggestionService
15+
public let chatService: GitHubCopilotChatService
1616

1717
private var extensionUsage = ExtensionUsage(
1818
isSuggestionServiceInUse: false,
@@ -141,6 +141,7 @@ protocol ServiceLocatorType {
141141
class ServiceLocator: ServiceLocatorType {
142142
let workspacePool: WorkspacePool
143143

144+
144145
init(workspacePool: WorkspacePool) {
145146
self.workspacePool = workspacePool
146147
}

Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server {
105105
set { wrappedServer?.requestHandler = newValue }
106106
}
107107

108+
@available(*, deprecated, message: "Use `ServerNotificationHandler` instead")
108109
public var notificationHandler: NotificationHandler? {
109110
get { wrappedServer?.notificationHandler }
110111
set { wrappedServer?.notificationHandler = newValue }
Lines changed: 148 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import BuiltinExtension
2+
import ChatBasic
23
import CopilotForXcodeKit
34
import Foundation
5+
import LanguageServerProtocol
46
import XcodeInspector
57

68
public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType {
@@ -22,90 +24,131 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType {
2224
else { return .finished(throwing: CancellationError()) }
2325
let id = UUID().uuidString
2426
let editorContent = await XcodeInspector.shared.getFocusedEditorContent()
25-
do {
26-
let createResponse = try await service.server
27-
.sendRequest(GitHubCopilotRequest.ConversationCreate(requestBody: .init(
28-
workDoneToken: "",
29-
turns: convertHistory(history: history),
30-
capabilities: [
31-
.init(allSkills: true, skills: []),
32-
],
33-
doc: .init(
34-
source: editorContent?.editorContent?.content ?? "",
35-
tabSize: 1,
36-
indentSize: 4,
37-
insertSpaces: true,
38-
path: editorContent?.documentURL.path ?? "",
39-
uri: editorContent?.documentURL.path ?? "",
40-
relativePath: editorContent?.relativePath ?? "",
41-
languageId: editorContent?.language.rawValue ?? "plaintext",
42-
position: .zero
43-
),
44-
source: .panel,
45-
workspaceFolder: workspace.projectURL.path
46-
)))
47-
48-
let stream = AsyncThrowingStream<String, Error>.init { continuation in
49-
service.registerNotificationHandler(id: id) { notification in
50-
if notification.method.rawValue == "" {
51-
return true
52-
}
27+
let workDoneToken = UUID().uuidString
28+
let turns = convertHistory(history: history, message: message)
29+
let request = GitHubCopilotRequest.ConversationCreate(requestBody: .init(
30+
workDoneToken: workDoneToken,
31+
turns: turns,
32+
capabilities: .init(allSkills: true, skills: []),
33+
doc: .init(
34+
source: editorContent?.editorContent?.content ?? "",
35+
tabSize: 1,
36+
indentSize: 4,
37+
insertSpaces: true,
38+
path: editorContent?.documentURL.path ?? "",
39+
uri: editorContent?.documentURL.path ?? "",
40+
relativePath: editorContent?.relativePath ?? "",
41+
languageId: editorContent?.language ?? .plaintext,
42+
position: editorContent?.editorContent?.cursorPosition ?? .zero
43+
),
44+
source: .panel,
45+
workspaceFolder: workspace.projectURL.path
46+
))
47+
48+
var cont: AsyncThrowingStream<String, Error>.Continuation? = nil
49+
let stream = AsyncThrowingStream<String, Error> { continuation in
50+
cont = continuation
51+
let startTimestamp = Date()
5352

53+
service.registerNotificationHandler(id: id) { notification, data in
54+
// just incase the conversation is stuck, we will cancel it after timeout
55+
if Date().timeIntervalSince(startTimestamp) > 60 * 30 {
56+
continuation.finish(throwing: CancellationError())
5457
return false
5558
}
5659

57-
continuation.onTermination = { _ in
58-
Task {
59-
try await service.server.sendRequest(
60-
GitHubCopilotRequest.ConversationDestroy(requestBody: .init(
61-
conversationId: createResponse.conversationId
62-
))
60+
switch notification.method {
61+
case "$/progress":
62+
do {
63+
let progress = try JSONDecoder().decode(
64+
JSONRPC<StreamProgressParams>.self,
65+
from: data
66+
).params
67+
guard progress.token == workDoneToken else { return false }
68+
if let reply = progress.value.reply, progress.value.kind == "report" {
69+
continuation.yield(reply)
70+
} else if progress.value.kind == "end" {
71+
if let error = progress.value.error,
72+
progress.value.cancellationReason == nil
73+
{
74+
continuation.finish(throwing: ServerError.serverError(
75+
code: 0,
76+
message: error,
77+
data: nil
78+
))
79+
} else {
80+
continuation.finish()
81+
}
82+
}
83+
return true
84+
} catch {
85+
return false
86+
}
87+
case "conversation/context":
88+
do {
89+
_ = try JSONDecoder().decode(
90+
JSONRPC<ConversationContextParams>.self,
91+
from: data
6392
)
93+
throw ServerError.clientDataUnavailable(CancellationError())
94+
} catch {
95+
return false
6496
}
97+
98+
default:
99+
return false
65100
}
66101
}
102+
}
67103

68-
_ = try await service.server
69-
.sendRequest(GitHubCopilotRequest.ConversationTurn(requestBody: .init(
70-
workDoneToken: "",
71-
conversationId: createResponse.conversationId,
72-
message: message
73-
)))
74-
75-
return stream
104+
do {
105+
let createResponse = try await service.server.sendRequest(request)
106+
cont?.onTermination = { _ in
107+
Task {
108+
service.unregisterNotificationHandler(id: id)
109+
_ = try await service.server.sendRequest(
110+
GitHubCopilotRequest.ConversationDestroy(requestBody: .init(
111+
conversationId: createResponse.conversationId
112+
))
113+
)
114+
}
115+
}
76116
} catch {
77-
return .finished(throwing: error)
117+
cont?.finish(throwing: error)
78118
}
119+
120+
return stream
79121
}
80122
}
81123

82124
extension GitHubCopilotChatService {
83125
typealias Turn = GitHubCopilotRequest.ConversationCreate.RequestBody.Turn
84-
func convertHistory(history: [Message]) -> [Turn] {
126+
func convertHistory(history: [Message], message: String) -> [Turn] {
85127
guard let firstIndexOfUserMessage = history.firstIndex(where: { $0.role == .user })
86-
else { return [] }
128+
else { return [.init(request: message, response: nil)] }
87129

88130
var currentTurn = Turn(request: "", response: nil)
89131
var turns: [Turn] = []
90132
for i in firstIndexOfUserMessage..<history.endIndex {
91133
let message = history[i]
134+
let text = message.content ?? ""
92135
switch message.role {
93136
case .user:
94137
if currentTurn.response == nil {
95138
if currentTurn.request.isEmpty {
96-
currentTurn.request = message.text
139+
currentTurn.request = text
97140
} else {
98-
currentTurn.request += "\n\n\(message.text)"
141+
currentTurn.request += "\n\n\(text)"
99142
}
100143
} else { // a valid turn is created
101144
turns.append(currentTurn)
102-
currentTurn = Turn(request: message.text, response: nil)
145+
currentTurn = Turn(request: text, response: nil)
103146
}
104147
case .assistant:
105148
if let response = currentTurn.response {
106-
currentTurn.response = "\(response)\n\n\(message.text)"
149+
currentTurn.response = "\(response)\n\n\(text)"
107150
} else {
108-
currentTurn.response = message.text
151+
currentTurn.response = text
109152
}
110153
default:
111154
break
@@ -117,12 +160,67 @@ extension GitHubCopilotChatService {
117160
}
118161

119162
turns.append(currentTurn)
163+
turns.append(.init(request: message, response: nil))
120164

121165
return turns
122166
}
123167

124168
func createNewMessage(references: [RetrievedContent], message: String) -> String {
125169
return message
126170
}
171+
172+
struct JSONRPC<Params: Decodable>: Decodable {
173+
var jsonrpc: String
174+
var method: String
175+
var params: Params
176+
}
177+
178+
struct StreamProgressParams: Decodable {
179+
struct Value: Decodable {
180+
struct Step: Decodable {
181+
var id: String
182+
var title: String
183+
var status: String
184+
}
185+
186+
struct FollowUp: Decodable {
187+
var id: String
188+
var type: String
189+
var message: String
190+
}
191+
192+
var kind: String
193+
var title: String?
194+
var conversationId: String
195+
var turnId: String
196+
var steps: [Step]?
197+
var followUp: FollowUp?
198+
var suggestedTitle: String?
199+
var reply: String?
200+
var annotations: [String]?
201+
var hideText: Bool?
202+
var cancellationReason: String?
203+
var error: String?
204+
}
205+
206+
var token: String
207+
var value: Value
208+
}
209+
210+
struct ConversationContextParams: Decodable {
211+
enum SkillID: String, Decodable {
212+
case currentEditor = "current-editor"
213+
case projectLabels = "project-labels"
214+
case recentFiles = "recent-files"
215+
case references
216+
case problemsInActiveDocument = "problems-in-active-document"
217+
}
218+
219+
var conversationId: String
220+
var turnId: String
221+
var skillId: String
222+
}
223+
224+
struct ConversationContextResponseBody: Encodable {}
127225
}
128226

0 commit comments

Comments
 (0)