Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IA Assistant #408

Merged
merged 4 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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