oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1import RastrilloApple
2import 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