Skip to content
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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Sources/InkletMac/InkletMacApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }
Expand All @@ -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…") }
Expand Down
7 changes: 7 additions & 0 deletions Sources/InkletMac/Models/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -108,6 +114,7 @@ final class AppModel {
runTasks.values.forEach { $0.cancel() }
runTasks = [:]
runs = []
ask.reset()
loadError = nil
isLoading = true
}
Expand Down
229 changes: 229 additions & 0 deletions Sources/InkletMac/Models/AskModel.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?
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
}
}
40 changes: 40 additions & 0 deletions Sources/InkletMac/Services/InkletAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading