Skip to content

Commit 86da8b5

Browse files
committed
Merge branch 'feature/shortcut-input-chat-plugin' into develop
2 parents 84723cc + 2a46d5e commit 86da8b5

8 files changed

Lines changed: 173 additions & 58 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import ChatPlugin
2+
import Environment
3+
import Foundation
4+
import OpenAIService
5+
import Parsing
6+
import Terminal
7+
8+
public actor ShortcutInputChatPlugin: ChatPlugin {
9+
public static var command: String { "shortcutInput" }
10+
public nonisolated var name: String { "Shortcut Input" }
11+
12+
let chatGPTService: any ChatGPTServiceType
13+
var terminal: TerminalType = Terminal()
14+
var isCancelled = false
15+
weak var delegate: ChatPluginDelegate?
16+
17+
public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) {
18+
self.chatGPTService = chatGPTService
19+
self.delegate = delegate
20+
}
21+
22+
public func send(content: String, originalMessage: String) async {
23+
delegate?.pluginDidStart(self)
24+
delegate?.pluginDidStartResponding(self)
25+
26+
defer {
27+
delegate?.pluginDidEndResponding(self)
28+
delegate?.pluginDidEnd(self)
29+
}
30+
31+
let id = "\(Self.command)-\(UUID().uuidString)"
32+
33+
var content = content[...]
34+
let firstParenthesisParser = PrefixThrough("(")
35+
let shortcutNameParser = PrefixUpTo(")")
36+
37+
_ = try? firstParenthesisParser.parse(&content)
38+
let shortcutName = try? shortcutNameParser.parse(&content)
39+
_ = try? PrefixThrough(")").parse(&content)
40+
41+
guard let shortcutName, !shortcutName.isEmpty else {
42+
let id = "\(Self.command)-\(UUID().uuidString)"
43+
let reply = ChatMessage(
44+
id: id,
45+
role: .assistant,
46+
content: "Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`."
47+
)
48+
await chatGPTService.mutateHistory { history in
49+
history.append(reply)
50+
}
51+
return
52+
}
53+
54+
var input = String(content).trimmingCharacters(in: .whitespacesAndNewlines)
55+
if input.isEmpty {
56+
// if no input detected, use the previous message as input
57+
input = await chatGPTService.history.last?.content ?? ""
58+
}
59+
60+
do {
61+
if isCancelled { throw CancellationError() }
62+
63+
let env = ProcessInfo.processInfo.environment
64+
let shell = env["SHELL"] ?? "/bin/bash"
65+
let temporaryURL = FileManager.default.temporaryDirectory
66+
let temporaryInputFileURL = temporaryURL
67+
.appendingPathComponent("\(id)-input.txt")
68+
let temporaryOutputFileURL = temporaryURL
69+
.appendingPathComponent("\(id)-output")
70+
71+
try input.write(to: temporaryInputFileURL, atomically: true, encoding: .utf8)
72+
73+
let command = """
74+
shortcuts run "\(shortcutName)" \
75+
-i "\(temporaryInputFileURL.path)" \
76+
-o "\(temporaryOutputFileURL.path)"
77+
"""
78+
79+
_ = try await terminal.runCommand(
80+
shell,
81+
arguments: ["-i", "-l", "-c", command],
82+
currentDirectoryPath: "/",
83+
environment: [:]
84+
)
85+
86+
await Task.yield()
87+
88+
if FileManager.default.fileExists(atPath: temporaryOutputFileURL.path) {
89+
let data = try Data(contentsOf: temporaryOutputFileURL)
90+
if let text = String(data: data, encoding: .utf8) {
91+
if text.isEmpty { return }
92+
let stream = try await chatGPTService.send(content: text, summary: nil)
93+
for try await _ in stream {}
94+
} else {
95+
let text = """
96+
[View File](\(temporaryOutputFileURL))
97+
"""
98+
let stream = try await chatGPTService.send(content: text, summary: nil)
99+
for try await _ in stream {}
100+
}
101+
102+
return
103+
}
104+
} catch {
105+
let id = "\(Self.command)-\(UUID().uuidString)"
106+
let reply = ChatMessage(
107+
id: id,
108+
role: .assistant,
109+
content: error.localizedDescription
110+
)
111+
await chatGPTService.mutateHistory { history in
112+
history.append(reply)
113+
}
114+
}
115+
}
116+
117+
public func cancel() async {
118+
isCancelled = true
119+
await terminal.terminate()
120+
}
121+
122+
public func stopResponding() async {
123+
isCancelled = true
124+
await terminal.terminate()
125+
}
126+
}
127+

Core/Sources/ChatService/AllPlugins.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ let allPlugins: [ChatPlugin.Type] = [
99
MathChatPlugin.self,
1010
SearchChatPlugin.self,
1111
ShortcutChatPlugin.self,
12+
ShortcutInputChatPlugin.self,
1213
]
1314

Core/Sources/ChatService/ChatPluginController.swift

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ final class ChatPluginController {
77
let chatGPTService: any ChatGPTServiceType
88
let plugins: [String: ChatPlugin.Type]
99
var runningPlugin: ChatPlugin?
10+
weak var chatService: ChatService?
1011

1112
init(chatGPTService: any ChatGPTServiceType, plugins: [ChatPlugin.Type]) {
1213
self.chatGPTService = chatGPTService
@@ -107,15 +108,11 @@ final class ChatPluginController {
107108

108109
extension ChatPluginController: ChatPluginDelegate {
109110
public func pluginDidStartResponding(_: ChatPlugin) {
110-
Task {
111-
await chatGPTService.markReceivingMessage(true)
112-
}
111+
chatService?.isReceivingMessage = true
113112
}
114113

115114
public func pluginDidEndResponding(_: ChatPlugin) {
116-
Task {
117-
await chatGPTService.markReceivingMessage(false)
118-
}
115+
chatService?.isReceivingMessage = false
119116
}
120117

121118
public func pluginDidStart(_ plugin: ChatPlugin) {

Core/Sources/ChatService/ChatService.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import OpenAIService
66

77
public final class ChatService: ObservableObject {
88
public let chatGPTService: any ChatGPTServiceType
9+
public var allPluginCommands: [String] { allPlugins.map { $0.command } }
910
let pluginController: ChatPluginController
1011
let contextController: DynamicContextController
1112
var cancellable = Set<AnyCancellable>()
13+
@Published public internal(set) var isReceivingMessage = false
1214
@Published public internal(set) var systemPrompt = UserDefaults.shared
1315
.value(for: \.defaultChatSystemPrompt)
1416
@Published public internal(set) var extraSystemPrompt = ""
@@ -21,30 +23,37 @@ public final class ChatService: ObservableObject {
2123
contextCollectors: ActiveDocumentChatContextCollector()
2224
)
2325

26+
pluginController.chatService = self
2427
chatGPTService.objectWillChange.sink { [weak self] _ in
2528
self?.objectWillChange.send()
2629
}.store(in: &cancellable)
2730
}
2831

2932
public func send(content: String) async throws {
33+
guard !isReceivingMessage else { throw CancellationError() }
3034
let handledInPlugin = try await pluginController.handleContent(content)
3135
if handledInPlugin { return }
3236
try await contextController.updatePromptToMatchContent(systemPrompt: """
3337
\(systemPrompt)
3438
\(extraSystemPrompt)
3539
""", content: content)
3640

37-
_ = try await chatGPTService.send(content: content, summary: nil)
41+
let stream = try await chatGPTService.send(content: content, summary: nil)
42+
isReceivingMessage = true
43+
for try await _ in stream {}
44+
isReceivingMessage = false
3845
}
3946

4047
public func stopReceivingMessage() async {
4148
await pluginController.stopResponding()
4249
await chatGPTService.stopReceivingMessage()
50+
isReceivingMessage = false
4351
}
4452

4553
public func clearHistory() async {
4654
await pluginController.cancel()
4755
await chatGPTService.clearHistory()
56+
isReceivingMessage = false
4857
}
4958

5059
public func resetPrompt() async {

Core/Sources/Service/GUI/ChatProvider+Service.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ extension ChatProvider {
1111
onCloseChat: @escaping () -> Void,
1212
onSwitchContext: @escaping () -> Void
1313
) {
14-
self.init()
14+
self.init(pluginIdentifiers: service.allPluginCommands)
1515

1616
let cancellable = service.objectWillChange.sink { [weak self] in
1717
guard let self else { return }
@@ -23,7 +23,7 @@ extension ChatProvider {
2323
text: message.summary ?? message.content
2424
)
2525
}
26-
self.isReceivingMessage = await service.chatGPTService.isReceivingMessage
26+
self.isReceivingMessage = service.isReceivingMessage
2727
self.systemPrompt = service.systemPrompt
2828
self.extraSystemPrompt = service.extraSystemPrompt
2929
}

Core/Sources/SuggestionWidget/ChatProvider.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public final class ChatProvider: ObservableObject {
77
let id = UUID()
88
@Published public var history: [ChatMessage] = []
99
@Published public var isReceivingMessage = false
10+
public var pluginIdentifiers: [String] = []
1011
public var systemPrompt = ""
1112
public var extraSystemPrompt = ""
1213
public var onMessageSend: (String) -> Void
@@ -23,6 +24,7 @@ public final class ChatProvider: ObservableObject {
2324
public init(
2425
history: [ChatMessage] = [],
2526
isReceivingMessage: Bool = false,
27+
pluginIdentifiers: [String] = [],
2628
onMessageSend: @escaping (String) -> Void = { _ in },
2729
onStop: @escaping () -> Void = {},
2830
onClear: @escaping () -> Void = {},
@@ -36,6 +38,7 @@ public final class ChatProvider: ObservableObject {
3638
) {
3739
self.history = history
3840
self.isReceivingMessage = isReceivingMessage
41+
self.pluginIdentifiers = pluginIdentifiers
3942
self.onMessageSend = onMessageSend
4043
self.onStop = onStop
4144
self.onClear = onClear
@@ -59,6 +62,7 @@ public final class ChatProvider: ObservableObject {
5962
public func triggerCustomCommand(_ command: CustomCommand) {
6063
onRunCustomCommand(command)
6164
}
65+
6266
public func setAsExtraPrompt(id: MessageID) { onSetAsExtraPrompt(id) }
6367
}
6468

Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -355,29 +355,7 @@ struct ChatPanelInputArea: View {
355355
text: $typedMessage,
356356
font: .systemFont(ofSize: 14),
357357
onSubmit: { submitText() },
358-
completions: { text, _, range in
359-
if text.isEmpty { return [] }
360-
let availableFeatures = [
361-
"/run",
362-
"/airun",
363-
"/math",
364-
"/search",
365-
"/shortcut",
366-
"/exit",
367-
"@selection",
368-
"@file",
369-
]
370-
return availableFeatures
371-
.filter { $0.hasPrefix(text) && $0 != text }
372-
.compactMap {
373-
guard let index = $0.index(
374-
$0.startIndex,
375-
offsetBy: range.location,
376-
limitedBy: $0.endIndex
377-
) else { return nil }
378-
return String($0[index...])
379-
}
380-
}
358+
completions: chatAutoCompletion
381359
)
382360
.padding(.top, 1)
383361
.padding(.bottom, -1)
@@ -420,6 +398,28 @@ struct ChatPanelInputArea: View {
420398
chat.send(typedMessage)
421399
typedMessage = ""
422400
}
401+
402+
func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] {
403+
guard text.count == 1 else { return [] }
404+
let plugins = chat.pluginIdentifiers.map { "/\($0)" }
405+
let availableFeatures = plugins + [
406+
"/exit",
407+
"@selection",
408+
"@file",
409+
]
410+
411+
let result: [String] = availableFeatures
412+
.filter { $0.hasPrefix(text) && $0 != text }
413+
.compactMap {
414+
guard let index = $0.index(
415+
$0.startIndex,
416+
offsetBy: range.location,
417+
limitedBy: $0.endIndex
418+
) else { return nil }
419+
return String($0[index...])
420+
}
421+
return result
422+
}
423423
}
424424

425425
struct ChatContextMenu: View {
@@ -700,3 +700,6 @@ struct ChatPanel_Light_Preview: PreviewProvider {
700700
}
701701
}
702702

703+
704+
705+

0 commit comments

Comments
 (0)