Skip to content

Commit

Permalink
IA Assistant (#408)
Browse files Browse the repository at this point in the history
* Stash

* Prepare chat assistant

* Rebase

* Update server + txt + format
  • Loading branch information
bourvill authored Jan 23, 2024
1 parent 7c3e438 commit 4931806
Show file tree
Hide file tree
Showing 40 changed files with 1,050 additions and 138 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,5 @@ fastlane/metadata/trade_representative_contact_information

vendor/
.bundle/

Config.xcconfig
2 changes: 1 addition & 1 deletion .swift-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.8
5.9
33 changes: 0 additions & 33 deletions App/Configuration.storekit

This file was deleted.

1 change: 1 addition & 0 deletions App/Entity/Entry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import CoreData
import CoreSpotlight
import Foundation
import SharedLib

// import MobileCoreServices
import WallabagKit

Expand Down
56 changes: 56 additions & 0 deletions App/Features/AI/ChatAssistant.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation
import HTTPTypes
import OpenAPIRuntime
import OpenAPIURLSession

protocol ChatAssistantProtocol {
func generateSynthesis(content: String) async throws -> String
func generateTags(content: String) async throws -> [String]
}

struct ChatAssistant: ChatAssistantProtocol {
var client: Client {
get throws {
try Client(
serverURL: Servers.server2(),
transport: URLSessionTransport(),
middlewares: [AuthenticationMiddleware()]
)
}
}

var locale = Locale.current.identifier

func generateSynthesis(content: String) async throws -> String {
let response = try await client.wallabagSynthesis(body: .json(.init(body: content, language: locale)))

return try response.ok.body.json.content ?? ""
}

func generateTags(content: String) async throws -> [String] {
let response = try await client.wallabagTags(body: .json(.init(body: content, language: locale)))

return try response.ok.body.json.tags ?? []
}
}

private struct AuthenticationMiddleware: ClientMiddleware {
@BundleKey("GPTBACK_KEY")
private var gptBackKey

func intercept(
_ request: HTTPTypes.HTTPRequest,
body: OpenAPIRuntime.HTTPBody?,
baseURL: URL,
operationID _: String,
next: @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, URL) async throws -> (
HTTPTypes.HTTPResponse,
OpenAPIRuntime.HTTPBody?
)
) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) {
var request = request
request.headerFields[.authorization] = "Bearer \(gptBackKey)"

return try await next(request, body, baseURL)
}
}
39 changes: 39 additions & 0 deletions App/Features/AI/Synthesis/SynthesisEntryView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// SynthesisEntryView.swift
// wallabag
//
// Created by maxime marinel on 11/12/2023.
//

import Factory
import SwiftUI

struct SynthesisEntryView: View {
@StateObject private var viewModel = SynthesisEntryViewModel()
let entry: Entry

var body: some View {
ScrollView {
if viewModel.isLoading {
Text("Your assistant is working")
ProgressView()
} else {
Text(viewModel.synthesis)
.padding()
.fontDesign(.serif)
}
}
.navigationTitle("Synthesis")
.task {
do {
try await viewModel.generateSynthesis(from: entry)
} catch {
print(error)
}
}
}
}

// #Preview {
// SynthesisEntryView(entry: .)
// }
27 changes: 27 additions & 0 deletions App/Features/AI/Synthesis/SynthesisEntryViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// SynthesisEntryViewModel.swift
// wallabag
//
// Created by maxime marinel on 22/01/2024.
//

import Factory
import Foundation

final class SynthesisEntryViewModel: ObservableObject {
@Injected(\.chatAssistant) private var chatAssistant
@Published var synthesis = ""
@Published var isLoading = false

@MainActor
func generateSynthesis(from entry: Entry) async throws {
defer {
isLoading = false
}
isLoading = true

guard let content = entry.content?.withoutHTML else { return }

synthesis = try await chatAssistant.generateSynthesis(content: content)
}
}
71 changes: 71 additions & 0 deletions App/Features/AI/Tag/TagSuggestionView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Factory
import SwiftUI

struct TagSuggestionView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = TagSuggestionViewModel()

let entry: Entry

var body: some View {
VStack {
if viewModel.isLoading {
Text("Your assistant is working")
ProgressView()
} else {
List {
ForEach(viewModel.suggestions, id: \.self) { suggestion in
Button(action: {
if viewModel.tagSelections.contains(suggestion) {
viewModel.tagSelections.remove(suggestion)
} else {
viewModel.tagSelections.insert(suggestion)
}
}, label: {
HStack {
Text(suggestion)
.padding()
.fontDesign(.serif)
Spacer()
if viewModel.tagSelections.contains(suggestion) {
Image(systemName: "checkmark.circle.fill")
} else {
Image(systemName: "circle")
}
}
})
}
}
.listStyle(.plain)
if !viewModel.tagSelections.isEmpty {
Button(action: {
Task {
try? await viewModel.addTags(to: entry)
dismiss()
}
}, label: {
if viewModel.addingTags {
ProgressView()
} else {
Text("Add \(viewModel.tagSelections.count.formatted()) tags")
}
})
.buttonStyle(.borderedProminent)
.disabled(viewModel.addingTags)
}
}
}
.navigationTitle("Tag suggestion")
.task {
do {
try await viewModel.generateTags(from: entry)
} catch {
print(error)
}
}
}
}

// #Preview {
// SynthesisEntryView(entry: .)
// }
41 changes: 41 additions & 0 deletions App/Features/AI/Tag/TagSuggestionViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// TagSuggestionViewModel.swift
// wallabag
//
// Created by maxime marinel on 22/01/2024.
//

import Factory
import Foundation

final class TagSuggestionViewModel: ObservableObject {
@Injected(\.wallabagSession) private var wallabagSession
@Injected(\.chatAssistant) private var chatAssistant
@Published var suggestions: [String] = []
@Published var isLoading = false
@Published var addingTags = false
@Published var tagSelections = Set<String>()

@MainActor
func generateTags(from entry: Entry) async throws {
defer {
isLoading = false
}
isLoading = true

guard let content = entry.content?.withoutHTML else { return }

suggestions = try await chatAssistant.generateTags(content: content)
}

@MainActor
func addTags(to entry: Entry) async throws {
defer {
addingTags = false
}
addingTags = true
for tag in tagSelections {
wallabagSession.add(tag: tag, for: entry)
}
}
}
4 changes: 4 additions & 0 deletions App/Features/Entry/EntriesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ struct EntriesView: View {
Label("Don", systemImage: "heart")
})
Divider()
NavigationLink(value: RoutePath.wallabagPlus) {
Label("wallabag Plus", systemImage: "hands.and.sparkles")
}
Divider()
Button(action: {
router.path.append(RoutePath.setting)
}, label: {
Expand Down
14 changes: 14 additions & 0 deletions App/Features/Entry/EntryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ struct EntryView: View {
FontSizeSelectorView()
.buttonStyle(.plain)
}
ToolbarItem(placement: toolbarPlacement) {
Menu(content: {
NavigationLink(value: RoutePath.synthesis(entry), label: {
Text("Synthesis")
})
NavigationLink(value: RoutePath.tags(entry), label: {
Text("Suggest tag")
})
}, label: {
Label("Help assistant", systemImage: "hands.and.sparkles")
.foregroundColor(.primary)
.labelStyle(.iconOnly)
})
}
}
.actionSheet(isPresented: $showDeleteConfirm) {
ActionSheet(
Expand Down
4 changes: 2 additions & 2 deletions App/Features/Entry/Picture/ImageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ import Foundation
let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
do {
let files = try FileManager.default.contentsOfDirectory(atPath: url.path)
try files.forEach {
try FileManager.default.removeItem(atPath: url.appendingPathComponent($0).path)
for file in files {
try FileManager.default.removeItem(atPath: url.appendingPathComponent(file).path)
}
} catch {
print("Error in cache purge")
Expand Down
3 changes: 3 additions & 0 deletions App/Features/Router/Route.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ enum RoutePath: Hashable {
case registration
case addEntry
case entry(Entry)
case synthesis(Entry)
case tags(Entry)
case tips
case about
case setting
case wallabagPlus
}
12 changes: 10 additions & 2 deletions App/Features/Router/RouteSwiftUIExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,22 @@ extension View {
AddEntryView()
case let .entry(entry):
EntryView(entry: entry)
case let .synthesis(entry):
SynthesisEntryView(entry: entry)
.wallabagPlusProtected()
case let .tags(entry):
TagSuggestionView(entry: entry)
.wallabagPlusProtected()
case .about:
AboutView()
case .tips:
TipView()
case .setting:
SettingView()
default:
Text("test")
case .wallabagPlus:
WallabagPlusView()
case .registration:
RegistrationView()
}
}
}
Expand Down
Loading

0 comments on commit 4931806

Please sign in to comment.