diff --git a/README.md b/README.md index 650c2f3..58a005d 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ The native SwiftUI client for inklet. It signs in with an inklet account (password, Google, or Apple), composes Content from text, links, images, and files, sends it to inklet -Displays and to Dot. Quote/0 panels connected through their cloud, manages -account-owned Virtual Displays, and ships a WidgetKit extension that shows -those Displays on the desktop. +Displays and to Dot. Quote/0 panels connected through their cloud, answers +questions about what you have saved (Ask, ⌘⇧A), manages account-owned Virtual +Displays, and ships a WidgetKit extension that shows those Displays on the +desktop. It runs on macOS 15 (Sequoia) and later and builds as one universal app for Apple silicon and Intel Macs. The product name is **inklet Portal**, matching @@ -165,6 +166,10 @@ overrides it at build time) with an inklet user access token: Analysis event stream. The contract lives in the `inklet-sdk` repository (`ANALYSIS_CONTRACT.md`) and `inklet-backend/docs/api/sdk-v1.md`. +- `/api/app/v1/conversations`: Ask inklet. Every message starts a `mode = chat` + Analysis; the reply is read from that Analysis's event stream + (`assistant.delta`) and then from the conversation once it settles. Pro + only (`ai_chat`). The contract is `inklet-sdk/CONVERSATION_CONTRACT.md`. - `/api/devices`: legacy Display reads, rename, unbind, queue advance, and history, shared with the Portal and iOS. `POST /api/devices/quote0` connects a Dot. Quote/0 with an API key and serial number; the backend seals the key diff --git a/Sources/InkletMac/InkletMacApp.swift b/Sources/InkletMac/InkletMacApp.swift index e628fce..10993fd 100644 --- a/Sources/InkletMac/InkletMacApp.swift +++ b/Sources/InkletMac/InkletMacApp.swift @@ -48,6 +48,10 @@ struct InkletMacApp: App { Button("Create Presentation…") { model.startComposing() } .keyboardShortcut("i", modifiers: [.command, .shift]) .disabled(session.user == nil) + + Button("Ask inklet…") { openWindow(id: Self.mainWindowID); NSApp.activate(); model.openAsk() } + .keyboardShortcut("a", modifiers: [.command, .shift]) + .disabled(session.user == nil) } CommandGroup(after: .toolbar) { Button("Refresh") { Task { await model.refresh() } } @@ -64,6 +68,8 @@ struct InkletMacApp: App { Button("Open inklet Portal") { openWindow(id: Self.mainWindowID); NSApp.activate() } Button("Create Presentation…") { model.startComposing() } .disabled(session.user == nil) + Button("Ask inklet…") { openWindow(id: Self.mainWindowID); NSApp.activate(); model.openAsk() } + .disabled(session.user == nil) Divider() CheckForUpdatesCommand() SettingsLink { Text("Settings…") } diff --git a/Sources/InkletMac/Models/AppModel.swift b/Sources/InkletMac/Models/AppModel.swift index 15f1ce2..3f6ede9 100644 --- a/Sources/InkletMac/Models/AppModel.swift +++ b/Sources/InkletMac/Models/AppModel.swift @@ -11,6 +11,9 @@ final class AppModel { /// The History page's runs and timelines. Its own object: the list pages /// and polls on its own clock, and nothing else on this model reads it. let history = HistoryModel() + /// Ask inklet: conversations with the knowledge base. Owned here so + /// sign-out clears it with everything else. + let ask = AskModel() init() { virtualDisplays = VirtualDisplayController { path, method, body, headers in try await InkletAPI.shared.virtualDisplayRequest(path, method: method, body: body, headers: headers) @@ -34,6 +37,8 @@ final class AppModel { var requestedSidebarItem: SidebarItem? /// Opens a run's timeline on the History page, from anywhere. + func openAsk() { requestedSidebarItem = .ask } + func openRun(_ analysisID: String) { history.requestOpen(analysisID) requestedSidebarItem = .history @@ -68,6 +73,7 @@ final class AppModel { self.session = session accountGeneration = UUID() history.reset() + ask.reset() if let user = session.user { virtualDisplays.activate(accountID: user.id) account = Account(dto: user) @@ -108,6 +114,7 @@ final class AppModel { runTasks.values.forEach { $0.cancel() } runTasks = [:] runs = [] + ask.reset() loadError = nil isLoading = true } diff --git a/Sources/InkletMac/Models/AskModel.swift b/Sources/InkletMac/Models/AskModel.swift new file mode 100644 index 0000000..2a602f3 --- /dev/null +++ b/Sources/InkletMac/Models/AskModel.swift @@ -0,0 +1,229 @@ +import Foundation +import Observation +import InkletPresentationKit + +/// Ask inklet: a conversation with the knowledge base. +/// +/// Every message the user sends starts one round. The backend answers with an +/// assistant message whose text arrives on the round's Analysis event stream +/// (`assistant.delta`), so the reply is drawn as it is written; once the round +/// ends the conversation is re-read and the settled message — text, the notes +/// it drew on, what the agent did — replaces what was streamed. +@MainActor +@Observable +final class AskModel { + /// What the page knows about the round in flight, on top of the messages. + struct LiveReply: Equatable { + let messageID: String + let analysisID: String + var text = "" + /// The agent's latest step, for the line under the growing text. + var activity: String? + var citations: [MessageCitationDTO] = [] + var actions: [MessageActionDTO] = [] + } + + var conversations: [ConversationDTO] = [] + var isLoadingList = false + private(set) var selectedID: String? + var messages: [ConversationMessageDTO] = [] + var isLoadingThread = false + var draft = "" + var isSending = false + var live: LiveReply? + var error: String? + + private var followTask: Task? + private var generation = UUID() + + /// One `Idempotency-Key` per attempt to send the current draft, so a retry + /// after a dropped connection does not start a second round. + private var pendingRequestID = UUID() + + var isReplying: Bool { live != nil } + + func reset() { + generation = UUID() + followTask?.cancel() + followTask = nil + conversations = [] + selectedID = nil + messages = [] + draft = "" + live = nil + error = nil + isLoadingList = false + isLoadingThread = false + isSending = false + } + + func load() async { + let expected = generation + isLoadingList = true + defer { if generation == expected { isLoadingList = false } } + do { + let page = try await InkletAPI.shared.conversations(limit: 50) + guard generation == expected else { return } + conversations = page.items + error = nil + } catch is CancellationError { + } catch { + guard generation == expected else { return } + self.error = describe(error) + } + } + + /// Opens a conversation; `nil` is the empty "new conversation" state. A + /// round that was still running when it was opened is picked up where it is. + func select(_ id: String?) async { + followTask?.cancel() + followTask = nil + live = nil + selectedID = id + messages = [] + guard let id else { return } + let expected = generation + isLoadingThread = true + defer { if generation == expected { isLoadingThread = false } } + do { + let detail = try await InkletAPI.shared.conversation(id: id) + guard generation == expected, selectedID == id else { return } + messages = detail.messages + error = nil + if let open = detail.messages.last(where: \.isOpen), let analysisID = open.analysisId { + follow(conversationID: id, replyID: open.id, analysisID: analysisID) + } + } catch is CancellationError { + } catch { + guard generation == expected else { return } + self.error = describe(error) + } + } + + func send() async { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isSending, live == nil else { return } + guard text.count <= 4000 else { error = "Keep it under 4000 characters."; return } + let expected = generation + isSending = true + defer { if generation == expected { isSending = false } } + do { + var id = selectedID + if id == nil { + let created = try await InkletAPI.shared.createConversation() + guard generation == expected else { return } + conversations.insert(created, at: 0) + selectedID = created.id + id = created.id + } + guard let id else { return } + let sent = try await InkletAPI.shared.sendMessage(conversationID: id, text: text, requestID: pendingRequestID) + guard generation == expected, selectedID == id else { return } + pendingRequestID = UUID() + draft = "" + error = nil + messages.append(sent.message) + messages.append(ConversationMessageDTO( + id: sent.reply.id, conversationId: id, role: "assistant", state: sent.reply.state, text: "", + analysisId: sent.reply.analysisId, citations: [], actions: [], failure: nil, + createdAt: sent.message.createdAt, completedAt: nil)) + follow(conversationID: id, replyID: sent.reply.id, analysisID: sent.reply.analysisId) + } catch is CancellationError { + } catch { + guard generation == expected else { return } + self.error = describe(error) + } + } + + func delete(_ id: String) async { + let expected = generation + do { + try await InkletAPI.shared.deleteConversation(id: id) + guard generation == expected else { return } + conversations.removeAll { $0.id == id } + if selectedID == id { await select(nil) } + } catch { + guard generation == expected else { return } + self.error = describe(error) + } + } + + /// Polls the round's public events until it settles, growing the reply as + /// deltas arrive, then re-reads the conversation for the settled message. + private func follow(conversationID: String, replyID: String, analysisID: String) { + followTask?.cancel() + live = LiveReply(messageID: replyID, analysisID: analysisID) + let expected = generation + followTask = Task { @MainActor [weak self] in + var after = 0 + var settled = false + for _ in 0..<900 { + guard !Task.isCancelled, let self, self.generation == expected else { return } + do { + var hasMore = true + var state: String? + while hasMore { + let page = try await InkletAPI.shared.analysisEvents(id: analysisID, after: after) + guard !Task.isCancelled, self.generation == expected else { return } + for event in page.items { + self.apply(event) + after = max(after, event.seq) + } + if let next = page.nextAfter { after = max(after, next) } + hasMore = (page.hasMore ?? false) && !page.items.isEmpty + state = page.state ?? state + } + if state == "completed" || state == "failed" { settled = true; break } + } catch is CancellationError { + return + } catch { + // A dropped poll is not the end of the round; the next one resumes from `after`. + } + try? await Task.sleep(for: .seconds(1)) + } + guard !Task.isCancelled, let self, self.generation == expected else { return } + if settled, self.selectedID == conversationID, + let detail = try? await InkletAPI.shared.conversation(id: conversationID), + self.generation == expected, self.selectedID == conversationID { + self.messages = detail.messages + } + self.live = nil + await self.load() + } + } + + private func apply(_ event: AnalysisEventDTO) { + guard var current = live else { return } + switch event.type { + case "assistant.delta": + current.text += event.data?["text"]?.stringValue ?? "" + case "agent.activity": + current.activity = event.displayText + case "assistant.citation": + if let contentID = event.data?["contentId"]?.stringValue, !current.citations.contains(where: { $0.contentId == contentID }) { + current.citations.append(MessageCitationDTO(contentId: contentID, title: event.data?["title"]?.stringValue ?? "")) + } + case "action.card_created": + current.actions.append(MessageActionDTO(kind: "card_created", analysisId: event.data?["analysisId"]?.stringValue, + displayId: event.data?["displayId"]?.stringValue, presentationId: nil)) + case "action.display_switched": + current.actions.append(MessageActionDTO(kind: "display_switched", analysisId: nil, + displayId: event.data?["displayId"]?.stringValue, + presentationId: event.data?["presentationId"]?.stringValue)) + default: + break + } + live = current + } + + private func describe(_ error: Error) -> String { + if let http = error as? PresentationHTTPError { + switch http.code { + case "reply_in_progress": return "inklet is still answering the last question." + case "plan_upgrade_required": return "Ask inklet is part of Pro. Upgrade from the web Portal to use it." + default: return http.message + } + } + return (error as? APIError)?.errorDescription ?? error.localizedDescription + } +} diff --git a/Sources/InkletMac/Services/InkletAPI.swift b/Sources/InkletMac/Services/InkletAPI.swift index 4caf9fd..e5285b3 100644 --- a/Sources/InkletMac/Services/InkletAPI.swift +++ b/Sources/InkletMac/Services/InkletAPI.swift @@ -297,6 +297,46 @@ actor InkletAPI { return try await authed(path) } + // MARK: - Conversations (Ask inklet, `/api/app/v1/conversations`) + + func conversations(cursor: String? = nil, limit: Int = 50) async throws -> ConversationPageDTO { + var path = "api/app/v1/conversations?limit=\(min(max(limit, 1), 50))" + if let cursor, let encoded = cursor.addingPercentEncoding(withAllowedCharacters: .alphanumerics) { + path += "&cursor=\(encoded)" + } + return try await authed(path) + } + + func createConversation(title: String? = nil) async throws -> ConversationDTO { + try await authed("api/app/v1/conversations", method: "POST", jsonObject: ["title": title ?? NSNull()]) + } + + /// A conversation with its most recent 50 messages, oldest first. + func conversation(id: String) async throws -> ConversationDetailDTO { + try await authed("api/app/v1/conversations/\(id)") + } + + /// Messages strictly before `before`, oldest first. + func conversationMessages(id: String, before: String? = nil, limit: Int = 50) async throws -> MessagePageDTO { + var path = "api/app/v1/conversations/\(id)/messages?limit=\(min(max(limit, 1), 50))" + if let before { path += "&before=\(before)" } + return try await authed(path) + } + + func deleteConversation(id: String) async throws { + try await authedVoid("api/app/v1/conversations/\(id)", method: "DELETE") + } + + /// One user message, one round. Errors keep the backend's `code` + /// (`reply_in_progress`, `plan_upgrade_required`) as a `PresentationHTTPError`. + func sendMessage(conversationID: String, text: String, requestID: UUID) async throws -> SendMessageResultDTO { + let body = try JSONSerialization.data(withJSONObject: ["text": text]) + let data = try await virtualDisplayRequest("api/app/v1/conversations/\(conversationID)/messages", method: "POST", + body: body, headers: ["Idempotency-Key": "mac-\(requestID.uuidString.lowercased())"]) + guard let decoded = try? JSONDecoder().decode(SendMessageResultDTO.self, from: data) else { throw APIError.decoding } + return decoded + } + // MARK: - Analyses /// One page of the account's runs, newest first — what History shows. diff --git a/Sources/InkletMac/Views/AskView.swift b/Sources/InkletMac/Views/AskView.swift new file mode 100644 index 0000000..21151bb --- /dev/null +++ b/Sources/InkletMac/Views/AskView.swift @@ -0,0 +1,266 @@ +import SwiftUI +import InkletPresentationKit + +/// Ask inklet: a conversation with the knowledge base. Conversations on the +/// left, the thread on the right, a composer at the bottom. The reply is drawn +/// as it is written; the agent's current step sits under it until it is done. +struct AskView: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var ask = model.ask + + HStack(spacing: 0) { + conversationList + .frame(width: 220) + Divider() + thread + } + .background(Ink.bg) + .navigationTitle("Ask") + .task { await model.ask.load() } + .alert("Ask inklet", isPresented: Binding(get: { ask.error != nil }, set: { if !$0 { ask.error = nil } })) { + Button("OK") { ask.error = nil } + } message: { + Text(ask.error ?? "") + } + } + + // MARK: - Conversations + + private var conversationList: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + SectionLabel("Conversations") + Spacer() + Button { + Task { await model.ask.select(nil) } + } label: { + Image(systemName: "square.and.pencil").font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(Ink.secondary) + .help("New conversation") + } + .padding(.horizontal, 16) + .padding(.top, 22) + .padding(.bottom, 8) + + if model.ask.isLoadingList && model.ask.conversations.isEmpty { + Text("Loading…").font(.system(size: 12)).foregroundStyle(Ink.muted).padding(.horizontal, 16) + } else if model.ask.conversations.isEmpty { + Text("Nothing asked yet.").font(.system(size: 12)).foregroundStyle(Ink.muted).padding(.horizontal, 16) + } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(model.ask.conversations) { conversation in + conversationRow(conversation) + } + } + .padding(.horizontal, 8) + } + Spacer(minLength: 0) + } + .background(Ink.sidebar) + } + + private func conversationRow(_ conversation: ConversationDTO) -> some View { + let selected = model.ask.selectedID == conversation.id + return Button { + Task { await model.ask.select(conversation.id) } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(conversation.title ?? "New conversation") + .font(.system(size: 13, weight: selected ? .medium : .regular)) + .foregroundStyle(Ink.text) + .lineLimit(1) + Text(Self.relative(conversation.lastMessageAt ?? conversation.createdAt)) + .font(.system(size: 11)) + .foregroundStyle(Ink.muted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(selected ? Ink.card : .clear, in: .rect(cornerRadius: Ink.controlCorner)) + .contentShape(.rect) + } + .buttonStyle(.plain) + .contextMenu { + Button("Delete", role: .destructive) { Task { await model.ask.delete(conversation.id) } } + } + } + + // MARK: - Thread + + private var thread: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 6) { + SectionLabel("Ask your second brain") + Text("Ask") + .font(.brand(34)) + .foregroundStyle(Ink.text) + } + Spacer(minLength: 20) + } + .padding(.horizontal, 28) + .padding(.top, 26) + .padding(.bottom, 12) + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + if model.ask.isLoadingThread { + HStack(spacing: 8) { ProgressView().controlSize(.small); Text("Opening…").font(.system(size: 12)).foregroundStyle(Ink.muted) } + } else if model.ask.messages.isEmpty { + emptyThread + } + ForEach(model.ask.messages) { message in + MessageRow(message: message, live: model.ask.live) + .id(message.id) + } + Color.clear.frame(height: 1).id("end") + } + .padding(.horizontal, 28) + .padding(.vertical, 8) + } + .onChange(of: model.ask.messages.count) { _, _ in proxy.scrollTo("end", anchor: .bottom) } + .onChange(of: model.ask.live?.text) { _, _ in proxy.scrollTo("end", anchor: .bottom) } + } + + composer + } + } + + private var emptyThread: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Ask about anything you have saved.") + .font(.system(size: 14)) + .foregroundStyle(Ink.text) + Text("inklet searches and reads your notes, answers, and can put a card on a display when you say so. Try “When is my next dentist appointment?” or “Put yesterday’s shopping list back on the kitchen display.”") + .font(.system(size: 13)) + .foregroundStyle(Ink.secondary) + } + .padding(.top, 24) + } + + private var composer: some View { + @Bindable var ask = model.ask + return VStack(spacing: 0) { + Divider() + HStack(alignment: .bottom, spacing: 10) { + TextField("Ask about your notes…", text: $ask.draft, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...6) + .font(.system(size: 14)) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(Ink.input, in: .rect(cornerRadius: Ink.controlCorner)) + .onSubmit { Task { await model.ask.send() } } + .disabled(model.ask.isSending) + Button { + Task { await model.ask.send() } + } label: { + if model.ask.isSending { + ProgressView().controlSize(.small).frame(width: 44, height: 20) + } else { + Text("Ask").font(.system(size: 13, weight: .medium)).frame(width: 44, height: 20) + } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: .command) + .disabled(model.ask.isSending || model.ask.isReplying || model.ask.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.horizontal, 28) + .padding(.vertical, 14) + } + } + + static func relative(_ value: String) -> String { + let plain = ISO8601DateFormatter() + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = plain.date(from: value) ?? fractional.date(from: value) else { return "" } + let minutes = Int(Date.now.timeIntervalSince(date) / 60) + if minutes < 1 { return "just now" } + if minutes < 60 { return "\(minutes) min ago" } + if minutes < 24 * 60 { return "\(minutes / 60) h ago" } + return date.formatted(.dateTime.month(.abbreviated).day()) + } +} + +/// One message. A user message is a filled bubble on the right; an assistant +/// message is plain text with the notes it drew on and what it did underneath. +private struct MessageRow: View { + let message: ConversationMessageDTO + let live: AskModel.LiveReply? + + private var streaming: AskModel.LiveReply? { live?.messageID == message.id ? live : nil } + + var body: some View { + if message.isAssistant { + assistant + } else { + HStack { + Spacer(minLength: 80) + Text(message.text) + .font(.system(size: 14)) + .foregroundStyle(Ink.bg) + .textSelection(.enabled) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(Ink.text, in: .rect(cornerRadius: 14)) + } + } + } + + private var assistant: some View { + let text = streaming?.text ?? message.text + let citations = streaming?.citations ?? message.citations ?? [] + let actions = streaming?.actions ?? message.actions ?? [] + let working = streaming != nil || message.isOpen + return VStack(alignment: .leading, spacing: 6) { + if message.state == "failed" { + Text(message.failure?.message ?? "inklet could not answer this one. Try asking again.") + .font(.system(size: 14)) + .foregroundStyle(Ink.danger) + } else { + if !text.isEmpty { + Text(text) + .font(.system(size: 14)) + .foregroundStyle(Ink.text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + if working { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(streaming?.activity ?? (text.isEmpty ? "Thinking…" : "Writing…")) + .font(.system(size: 12)) + .foregroundStyle(Ink.muted) + .contentTransition(.opacity) + } + } + } + if !citations.isEmpty { + HStack(spacing: 6) { + ForEach(citations) { citation in + Text(citation.title.isEmpty ? "a note" : citation.title) + .font(.system(size: 11)) + .foregroundStyle(Ink.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .overlay { Capsule().strokeBorder(Ink.border) } + } + } + } + ForEach(Array(actions.enumerated()), id: \.offset) { _, action in + Text(action.displayText) + .font(.system(size: 12)) + .foregroundStyle(Ink.secondary) + } + } + .padding(.trailing, 80) + } +} diff --git a/Sources/InkletMac/Views/RootView.swift b/Sources/InkletMac/Views/RootView.swift index 8392e0d..b2ecdcc 100644 --- a/Sources/InkletMac/Views/RootView.swift +++ b/Sources/InkletMac/Views/RootView.swift @@ -4,6 +4,7 @@ import InkletPresentationKit enum SidebarItem: Hashable { case home case knowledge + case ask case history case newDisplay case virtualDisplayDetail(UUID) @@ -95,6 +96,8 @@ struct RootView: View { HomeView(selection: $selection) case .knowledge: KnowledgeView() + case .ask: + AskView() case .history: HistoryView() case .virtualDisplayDetail(let id): @@ -135,6 +138,7 @@ private struct Sidebar: View { GroupLabel("Workspace", topPadding: 4) SidebarRow(icon: "house", title: "Home", item: .home, selection: $selection) SidebarRow(icon: "books.vertical", title: "Knowledge", item: .knowledge, selection: $selection) + SidebarRow(icon: "bubble.left.and.text.bubble.right", title: "Ask", item: .ask, selection: $selection) SidebarRow(icon: "clock.arrow.circlepath", title: "History", item: .history, selection: $selection) GroupLabel("Displays", topPadding: 18) diff --git a/Sources/InkletPresentationKit/AnalysisModels.swift b/Sources/InkletPresentationKit/AnalysisModels.swift index bc6ac21..b77c041 100644 --- a/Sources/InkletPresentationKit/AnalysisModels.swift +++ b/Sources/InkletPresentationKit/AnalysisModels.swift @@ -131,6 +131,13 @@ nonisolated public struct AnalysisEventDTO: Codable, Sendable, Equatable, Identi case "checking_display": verb = state == "done" ? "Checked the display" : "Checking the display" case "choosing_layout": verb = state == "done" ? "Chose a layout" : "Looking at layouts" case "planning": verb = state == "done" ? "Submitted the plan" : "Checking the plan" + case "searching_notes": + let matches = data["stats"]?.objectValue?["matches"]?.numberValue.map { Int($0) } + if state == "done", let matches { verb = "Found \(matches) \(matches == 1 ? "note" : "notes")" } + else { verb = state == "done" ? "Searched your notes" : "Searching your notes" } + case "reading_note": verb = state == "done" ? "Read a note" : "Reading a note" + case "creating_card": verb = state == "done" ? "Started a card" : "Starting a card" + case "switching_display": verb = state == "done" ? "Switched the display" : "Switching the display" default: verb = state == "done" ? "Worked through the steps" : "Working" } return state == "failed" ? "\(verb) — failed" : verb @@ -167,3 +174,114 @@ extension JSONValue { return nil } } + +// MARK: - Conversations (`/api/app/v1/conversations`, Ask inklet) + +nonisolated public struct ConversationDTO: Codable, Sendable, Equatable, Identifiable { + public var id: String + /// Set from the first message when absent. + public var title: String? + public var lastMessageAt: String? + public var createdAt: String + public var updatedAt: String? +} + +nonisolated public struct ConversationPageDTO: Codable, Sendable { + public var items: [ConversationDTO] + public var nextCursor: String? + public var hasMore: Bool? +} + +/// A note the reply drew on: one per note the agent actually read. +nonisolated public struct MessageCitationDTO: Codable, Sendable, Equatable, Identifiable { + public var contentId: String + public var title: String + public var id: String { contentId } + + public init(contentId: String, title: String) { + self.contentId = contentId + self.title = title + } +} + +/// Something the agent did for the user during a round. +nonisolated public struct MessageActionDTO: Codable, Sendable, Equatable { + public var kind: String + public var analysisId: String? + public var displayId: String? + public var presentationId: String? + + public init(kind: String, analysisId: String? = nil, displayId: String? = nil, presentationId: String? = nil) { + self.kind = kind + self.analysisId = analysisId + self.displayId = displayId + self.presentationId = presentationId + } + + public var displayText: String { + switch kind { + case "card_created": "Started a card for you — it will show up on the display when it is ready." + case "display_switched": "Put an earlier picture back on the display." + default: kind + } + } +} + +/// One message. A user message is `completed` at once; an assistant message +/// follows its Analysis (`queued → running → completed / failed`) and its +/// `text` is final only once `completed`. +nonisolated public struct ConversationMessageDTO: Codable, Sendable, Equatable, Identifiable { + public var id: String + public var conversationId: String + public var role: String + public var state: String + public var text: String + public var analysisId: String? + public var citations: [MessageCitationDTO]? + public var actions: [MessageActionDTO]? + public var failure: PresentationProblemDTO? + public var createdAt: String + public var completedAt: String? + + public init(id: String, conversationId: String, role: String, state: String, text: String, analysisId: String?, + citations: [MessageCitationDTO]?, actions: [MessageActionDTO]?, failure: PresentationProblemDTO?, + createdAt: String, completedAt: String?) { + self.id = id + self.conversationId = conversationId + self.role = role + self.state = state + self.text = text + self.analysisId = analysisId + self.citations = citations + self.actions = actions + self.failure = failure + self.createdAt = createdAt + self.completedAt = completedAt + } + + public var isAssistant: Bool { role == "assistant" } + public var isOpen: Bool { isAssistant && (state == "queued" || state == "running") } +} + +nonisolated public struct ConversationDetailDTO: Codable, Sendable { + public var conversation: ConversationDTO + public var messages: [ConversationMessageDTO] + public var hasMore: Bool? +} + +nonisolated public struct MessagePageDTO: Codable, Sendable { + public var items: [ConversationMessageDTO] + public var hasMore: Bool? +} + +/// What `POST …/messages` answers: the user message and the reply's handle. +nonisolated public struct SendMessageResultDTO: Codable, Sendable { + public var message: ConversationMessageDTO + public var reply: ReplyHandleDTO +} + +nonisolated public struct ReplyHandleDTO: Codable, Sendable, Equatable { + public var id: String + public var state: String + public var analysisId: String +} diff --git a/Tests/InkletPresentationKitTests/ConversationModelsTests.swift b/Tests/InkletPresentationKitTests/ConversationModelsTests.swift new file mode 100644 index 0000000..8ba9268 --- /dev/null +++ b/Tests/InkletPresentationKitTests/ConversationModelsTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import InkletPresentationKit + +// The Ask inklet wire shapes (CONVERSATION_CONTRACT §4.4) and the status lines +// the chat kernel's activities turn into. + +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(type, from: Data(json.utf8)) +} + +@Test func assistantMessageDecodesWithCitationsAndActions() throws { + let message = try decode(ConversationMessageDTO.self, """ + {"id":"m1","conversationId":"cv1","role":"assistant","state":"completed", + "text":"Next Tuesday at three.","analysisId":"an1", + "citations":[{"contentId":"c1","title":"Dentist"}], + "actions":[{"kind":"card_created","analysisId":"an2","displayId":"d1"},{"kind":"display_switched","displayId":"d1","presentationId":"p1"}], + "failure":null,"createdAt":"2026-09-24T01:00:01Z","completedAt":"2026-09-24T01:00:09Z"} + """) + #expect(message.isAssistant) + #expect(!message.isOpen) + #expect(message.citations?.first?.title == "Dentist") + #expect(message.actions?.count == 2) + #expect(message.actions?[0].displayText.hasPrefix("Started a card") == true) + #expect(message.actions?[1].displayText.hasPrefix("Put an earlier picture") == true) +} + +@Test func queuedReplyIsOpenAndAUserMessageIsNot() throws { + let reply = try decode(ConversationMessageDTO.self, """ + {"id":"m2","conversationId":"cv1","role":"assistant","state":"queued","text":"","analysisId":"an1", + "citations":[],"actions":[],"failure":null,"createdAt":"2026-09-24T01:00:01Z","completedAt":null} + """) + #expect(reply.isOpen) + let user = try decode(ConversationMessageDTO.self, """ + {"id":"m0","conversationId":"cv1","role":"user","state":"completed","text":"When?","analysisId":null, + "citations":[],"actions":[],"failure":null,"createdAt":"2026-09-24T01:00:00Z","completedAt":"2026-09-24T01:00:00Z"} + """) + #expect(!user.isAssistant) + #expect(!user.isOpen) +} + +@Test func sendResultCarriesTheReplyHandle() throws { + let sent = try decode(SendMessageResultDTO.self, """ + {"message":{"id":"m0","conversationId":"cv1","role":"user","state":"completed","text":"When?","analysisId":null, + "citations":[],"actions":[],"failure":null,"createdAt":"2026-09-24T01:00:00Z","completedAt":"2026-09-24T01:00:00Z"}, + "reply":{"id":"m1","state":"queued","analysisId":"an1"}} + """) + #expect(sent.reply == ReplyHandleDTO(id: "m1", state: "queued", analysisId: "an1")) +} + +@Test func chatActivitiesHaveStatusLines() throws { + let searching = try decode(AnalysisEventDTO.self, """ + {"seq":2,"at":"now","type":"agent.activity","data":{"activityId":"c1","kind":"searching_notes","state":"done","steps":1,"stats":{"matches":3}}} + """) + #expect(searching.displayText == "Found 3 notes") + let reading = try decode(AnalysisEventDTO.self, """ + {"seq":3,"at":"now","type":"agent.activity","data":{"activityId":"c2","kind":"reading_note","state":"active","steps":1,"stats":{}}} + """) + #expect(reading.displayText == "Reading a note") + let card = try decode(AnalysisEventDTO.self, """ + {"seq":4,"at":"now","type":"agent.activity","data":{"activityId":"c3","kind":"creating_card","state":"failed","steps":1,"stats":{"failedSteps":1}}} + """) + #expect(card.displayText == "Starting a card — failed") +}