import RastrilloApple import SwiftUI @MainActor public struct MemoriaWorkspace: View { let client: NativeHTTP let signOut: () -> Void private let accounts: (() -> Void)? @StateObject private var model: MemoriaModel @State private var selection: Int64? @State private var adding = false @State private var column: NavigationSplitViewColumn = .sidebar 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()) } public var body: some View { NavigationSplitView(preferredCompactColumn: $column) { VStack(spacing: 0) { if model.loading { ProgressView("Searching your team’s memory…").padding() } List(selection: $selection) { if !model.error.isEmpty { Label(model.error, systemImage: "exclamationmark.circle").foregroundStyle(.red) } ForEach(model.notices, id: \.self) { Label($0, systemImage: "info.circle") } if model.searched && !model.passages.isEmpty { Section { Button("Answer", systemImage: "sparkles") { selection = nil; column = .detail } } } if !model.searched { ContentUnavailableView("Find what your team knows", systemImage: "books.vertical", description: Text("Search notes, documents and connected sources.")) } else if model.passages.isEmpty && !model.loading && model.error.isEmpty { ContentUnavailableView.search(text: model.query) } Section(model.passages.isEmpty ? "" : "\(model.passages.count) results") { ForEach(Array(model.passages.enumerated()), id: \.element.id) { index, passage in VStack(alignment: .leading, spacing: 6) { Text(passage.title).font(.headline) Label(passage.source, systemImage: "doc.text").font(.subheadline).foregroundStyle(.secondary) Text(passage.text).font(.callout).foregroundStyle(.secondary).lineLimit(3) Text("Source \(index + 1)").font(.caption).foregroundStyle(.secondary) }.padding(.vertical, 6).tag(passage.id) .accessibilityElement(children: .combine) } } } } #if os(macOS) .frame(minWidth: 280, idealWidth: 340) #endif .navigationTitle("Memoria").navigationSplitViewColumnWidth(min: 280, ideal: 340, max: 440) .searchable(text: $model.query, prompt: "Search your team’s memory") .onSubmit(of: .search) { selection = nil Task { await model.search(client) } } .toolbar { if let accounts { ToolbarItem(placement: .navigation) { Button("Accounts", systemImage: "chevron.backward", action: accounts) } } ToolbarItem(placement: .primaryAction) { Button("Add note", systemImage: "plus") { adding = true } } ToolbarItem { Menu("Account", systemImage: "person.crop.circle") { Text(client.origin.host ?? "Server") Button("Sign out", action: signOut) } } } } detail: { ScrollView { VStack(alignment: .leading, spacing: 24) { if !model.error.isEmpty { Label(model.error, systemImage: "exclamationmark.circle").foregroundStyle(.red) } ForEach(model.notices, id: \.self) { Label($0, systemImage: "info.circle").foregroundStyle(.secondary) } if let passage = model.passages.first(where: { $0.id == selection }) { Text(passage.title).font(.largeTitle.bold()) Label(passage.source, systemImage: "doc.text").foregroundStyle(.secondary) if !passage.path.isEmpty { Text(passage.path).font(.callout).foregroundStyle(.secondary) } if !passage.heading.isEmpty { Text(passage.heading).font(.title2.bold()) } Text(passage.text).textSelection(.enabled).accessibilityIdentifier("sourcePassage") } else { if !model.searched && !model.loading { ContentUnavailableView("Your team’s memory", systemImage: "books.vertical", description: Text("Search your notes and connected sources, then open a result to read it here.")) } else { Text(model.query).font(.largeTitle.bold()).textSelection(.enabled) Label("Answers grounded in your sources", systemImage: "sparkles").foregroundStyle(.secondary) } if model.answering { ProgressView("Thinking on this device…") } if model.searched && !model.passages.isEmpty { if model.localModelAvailable { Button(model.answering ? "Thinking on this device…" : "Answer on this device", systemImage: "sparkles") { Task { await model.makeAnswer(client) } }.buttonStyle(.borderedProminent).disabled(model.answering || model.loading) Text("Search uses your team’s server. The answer is generated on this device.") .font(.footnote).foregroundStyle(.secondary) } else { Label("On-device answers require an available Apple Intelligence model. Search works on this device.", systemImage: "desktopcomputer") .font(.callout).foregroundStyle(.secondary) } } if !model.answer.isEmpty { Text("On-device answer · AI draft").font(.headline) Text(model.answer).textSelection(.enabled) Divider() Text("Sources").font(.headline) ForEach(Array(model.passages.prefix(5).enumerated()), id: \.element.id) { index, passage in Button { selection = passage.id } label: { Label("[\(index + 1)] \(passage.title)", systemImage: "doc.text") .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) }.buttonStyle(.borderless) } } } }.frame(maxWidth: 720, alignment: .leading).padding(28).frame(maxWidth: .infinity, alignment: .leading) }.navigationTitle(selection == nil ? "Answer" : "Source") .toolbar { ToolbarItem { if selection != nil { Button("Answer", systemImage: "sparkles") { selection = nil } } } } } .sheet(isPresented: $adding) { AddNoteView(model: model, client: client) } .onChange(of: selection) { _, value in if value != nil { column = .detail } } .onChange(of: model.query) { _, _ in selection = nil } } }