| 1 | import RastrilloApple |
| 2 | import SwiftUI |
| 3 | |
| 4 | @MainActor public struct MemoriaWorkspace: View { |
| 5 | let client: NativeHTTP |
| 6 | let signOut: () -> Void |
| 7 | private let accounts: (() -> Void)? |
| 8 | @StateObject private var model: MemoriaModel |
| 9 | @State private var selection: Int64? |
| 10 | @State private var adding = false |
| 11 | @State private var column: NavigationSplitViewColumn = .sidebar |
| 12 | public init(client: NativeHTTP, model: MemoriaModel? = nil, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void) { self.client = client; self.signOut = signOut; self.accounts = accounts; _model = StateObject(wrappedValue: model ?? MemoriaModel()) } |
| 13 | public var body: some View { |
| 14 | NavigationSplitView(preferredCompactColumn: $column) { |
| 15 | VStack(spacing: 0) { |
| 16 | if model.loading { ProgressView("Searching your team’s memory…").padding() } |
| 17 | List(selection: $selection) { |
| 18 | if !model.error.isEmpty { Label(model.error, systemImage: "exclamationmark.circle").foregroundStyle(.red) } |
| 19 | ForEach(model.notices, id: \.self) { Label($0, systemImage: "info.circle") } |
| 20 | if model.searched && !model.passages.isEmpty { |
| 21 | Section { |
| 22 | Button("Answer", systemImage: "sparkles") { selection = nil; column = .detail } |
| 23 | } |
| 24 | } |
| 25 | if !model.searched { |
| 26 | ContentUnavailableView("Find what your team knows", systemImage: "books.vertical", |
| 27 | description: Text("Search notes, documents and connected sources.")) |
| 28 | } else if model.passages.isEmpty && !model.loading && model.error.isEmpty { |
| 29 | ContentUnavailableView.search(text: model.query) |
| 30 | } |
| 31 | Section(model.passages.isEmpty ? "" : "\(model.passages.count) results") { |
| 32 | ForEach(Array(model.passages.enumerated()), id: \.element.id) { index, passage in |
| 33 | VStack(alignment: .leading, spacing: 6) { |
| 34 | Text(passage.title).font(.headline) |
| 35 | Label(passage.source, systemImage: "doc.text").font(.subheadline).foregroundStyle(.secondary) |
| 36 | Text(passage.text).font(.callout).foregroundStyle(.secondary).lineLimit(3) |
| 37 | Text("Source \(index + 1)").font(.caption).foregroundStyle(.secondary) |
| 38 | }.padding(.vertical, 6).tag(passage.id) |
| 39 | .accessibilityElement(children: .combine) |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | #if os(macOS) |
| 45 | .frame(minWidth: 280, idealWidth: 340) |
| 46 | #endif |
| 47 | .navigationTitle("Memoria").navigationSplitViewColumnWidth(min: 280, ideal: 340, max: 440) |
| 48 | .searchable(text: $model.query, prompt: "Search your team’s memory") |
| 49 | .onSubmit(of: .search) { |
| 50 | selection = nil |
| 51 | Task { await model.search(client) } |
| 52 | } |
| 53 | .toolbar { |
| 54 | if let accounts { |
| 55 | ToolbarItem(placement: .navigation) { |
| 56 | Button("Accounts", systemImage: "chevron.backward", action: accounts) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | ToolbarItem(placement: .primaryAction) { Button("Add note", systemImage: "plus") { adding = true } } |
| 61 | ToolbarItem { |
| 62 | Menu("Account", systemImage: "person.crop.circle") { |
| 63 | Text(client.origin.host ?? "Server") |
| 64 | Button("Sign out", action: signOut) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | } detail: { |
| 69 | ScrollView { |
| 70 | VStack(alignment: .leading, spacing: 24) { |
| 71 | if !model.error.isEmpty { Label(model.error, systemImage: "exclamationmark.circle").foregroundStyle(.red) } |
| 72 | ForEach(model.notices, id: \.self) { Label($0, systemImage: "info.circle").foregroundStyle(.secondary) } |
| 73 | if let passage = model.passages.first(where: { $0.id == selection }) { |
| 74 | Text(passage.title).font(.largeTitle.bold()) |
| 75 | Label(passage.source, systemImage: "doc.text").foregroundStyle(.secondary) |
| 76 | if !passage.path.isEmpty { Text(passage.path).font(.callout).foregroundStyle(.secondary) } |
| 77 | if !passage.heading.isEmpty { Text(passage.heading).font(.title2.bold()) } |
| 78 | Text(passage.text).textSelection(.enabled).accessibilityIdentifier("sourcePassage") |
| 79 | } else { |
| 80 | if !model.searched && !model.loading { |
| 81 | ContentUnavailableView("Your team’s memory", systemImage: "books.vertical", |
| 82 | description: Text("Search your notes and connected sources, then open a result to read it here.")) |
| 83 | } else { |
| 84 | Text(model.query).font(.largeTitle.bold()).textSelection(.enabled) |
| 85 | Label("Answers grounded in your sources", systemImage: "sparkles").foregroundStyle(.secondary) |
| 86 | } |
| 87 | if model.answering { ProgressView("Thinking on this device…") } |
| 88 | if model.searched && !model.passages.isEmpty { |
| 89 | if model.localModelAvailable { |
| 90 | Button(model.answering ? "Thinking on this device…" : "Answer on this device", systemImage: "sparkles") { |
| 91 | Task { await model.makeAnswer(client) } |
| 92 | }.buttonStyle(.borderedProminent).disabled(model.answering || model.loading) |
| 93 | Text("Search uses your team’s server. The answer is generated on this device.") |
| 94 | .font(.footnote).foregroundStyle(.secondary) |
| 95 | } else { |
| 96 | Label("On-device answers require an available Apple Intelligence model. Search works on this device.", systemImage: "desktopcomputer") |
| 97 | .font(.callout).foregroundStyle(.secondary) |
| 98 | } |
| 99 | } |
| 100 | if !model.answer.isEmpty { |
| 101 | Text("On-device answer · AI draft").font(.headline) |
| 102 | Text(model.answer).textSelection(.enabled) |
| 103 | Divider() |
| 104 | Text("Sources").font(.headline) |
| 105 | ForEach(Array(model.passages.prefix(5).enumerated()), id: \.element.id) { index, passage in |
| 106 | Button { selection = passage.id } label: { |
| 107 | Label("[\(index + 1)] \(passage.title)", systemImage: "doc.text") |
| 108 | .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) |
| 109 | }.buttonStyle(.borderless) |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | }.frame(maxWidth: 720, alignment: .leading).padding(28).frame(maxWidth: .infinity, alignment: .leading) |
| 114 | }.navigationTitle(selection == nil ? "Answer" : "Source") |
| 115 | .toolbar { ToolbarItem { if selection != nil { Button("Answer", systemImage: "sparkles") { selection = nil } } } } |
| 116 | } |
| 117 | .sheet(isPresented: $adding) { AddNoteView(model: model, client: client) } |
| 118 | .onChange(of: selection) { _, value in if value != nil { column = .detail } } |
| 119 | .onChange(of: model.query) { _, _ in selection = nil } |
| 120 | } |
| 121 | } |
| 122 | |