Add demo app with mock, Ollama, OpenAI, and Anthropic provider support
Example iOS app that seeds a task management SQLite database and renders SwiftDBAI's DataChatView. Ships with a mock LLM for offline use and an OllamaWithSystemPrompt wrapper that fixes AnyLanguageModel's Ollama adapter dropping system instructions. Tested successfully with: - DemoLanguageModel (mock, pattern-matched SQL) - Ollama qwen3-coder-30b (local, real SQL generation) - OpenAI gpt-4o-mini (JOINs, GROUP BY, multi-turn) - Anthropic claude-haiku-4.5 (complex aggregations) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
141
Example/SwiftDBAIDemo/SwiftDBAIDemo/DatabaseSeeder.swift
Normal file
141
Example/SwiftDBAIDemo/SwiftDBAIDemo/DatabaseSeeder.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
// DatabaseSeeder.swift
|
||||
// SwiftDBAIDemo
|
||||
//
|
||||
// Creates and seeds a sample SQLite database on first launch.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
enum DatabaseSeeder {
|
||||
|
||||
/// Returns the path to the seeded database, creating it if it does not exist.
|
||||
static func seedIfNeeded() throws -> String {
|
||||
let url = URL.documentsDirectory.appending(path: "demo.sqlite")
|
||||
let path = url.path(percentEncoded: false)
|
||||
|
||||
// If the database already exists, just return the path.
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
return path
|
||||
}
|
||||
|
||||
let db = try DatabaseQueue(path: path)
|
||||
try db.write { db in
|
||||
// -- Schema ----------------------------------------------------------
|
||||
|
||||
try db.execute(sql: """
|
||||
CREATE TABLE projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
|
||||
try db.execute(sql: """
|
||||
CREATE TABLE tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'todo',
|
||||
priority INTEGER NOT NULL DEFAULT 2,
|
||||
project_id INTEGER REFERENCES projects(id),
|
||||
due_date TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
|
||||
try db.execute(sql: """
|
||||
CREATE TABLE tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
""")
|
||||
|
||||
try db.execute(sql: """
|
||||
CREATE TABLE task_tags (
|
||||
task_id INTEGER NOT NULL REFERENCES tasks(id),
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id),
|
||||
PRIMARY KEY (task_id, tag_id)
|
||||
);
|
||||
""")
|
||||
|
||||
// -- Seed data -------------------------------------------------------
|
||||
|
||||
// Projects
|
||||
try db.execute(sql: """
|
||||
INSERT INTO projects (id, name, description, created_at) VALUES
|
||||
(1, 'Mobile App Redesign', 'Complete UI/UX overhaul of the iOS app', '2025-01-10 09:00:00'),
|
||||
(2, 'Backend Migration', 'Migrate from REST to GraphQL', '2025-02-01 10:30:00'),
|
||||
(3, 'Q2 Marketing Site', 'New landing pages for Q2 campaign', '2025-03-01 08:00:00');
|
||||
""")
|
||||
|
||||
// Tags
|
||||
try db.execute(sql: """
|
||||
INSERT INTO tags (id, name) VALUES
|
||||
(1, 'bug'),
|
||||
(2, 'feature'),
|
||||
(3, 'urgent'),
|
||||
(4, 'design'),
|
||||
(5, 'backend'),
|
||||
(6, 'frontend'),
|
||||
(7, 'documentation');
|
||||
""")
|
||||
|
||||
// Tasks (20 tasks across the 3 projects)
|
||||
try db.execute(sql: """
|
||||
INSERT INTO tasks (id, title, status, priority, project_id, due_date, created_at) VALUES
|
||||
-- Mobile App Redesign
|
||||
(1, 'Design new onboarding flow', 'done', 3, 1, '2025-02-15', '2025-01-12 10:00:00'),
|
||||
(2, 'Implement tab bar redesign', 'done', 3, 1, '2025-02-20', '2025-01-15 11:00:00'),
|
||||
(3, 'Fix navigation stack crash', 'in_progress', 3, 1, '2025-03-01', '2025-02-10 09:30:00'),
|
||||
(4, 'Add dark mode support', 'in_progress', 2, 1, '2025-03-15', '2025-02-15 14:00:00'),
|
||||
(5, 'Write accessibility audit report', 'todo', 2, 1, '2025-04-01', '2025-03-01 10:00:00'),
|
||||
(6, 'Optimize image loading', 'todo', 1, 1, '2025-04-10', '2025-03-05 11:00:00'),
|
||||
(7, 'Update app store screenshots', 'todo', 1, 1, NULL, '2025-03-10 09:00:00'),
|
||||
|
||||
-- Backend Migration
|
||||
(8, 'Define GraphQL schema', 'done', 3, 2, '2025-02-28', '2025-02-03 10:00:00'),
|
||||
(9, 'Implement user resolver', 'done', 2, 2, '2025-03-05', '2025-02-10 11:00:00'),
|
||||
(10, 'Implement order resolver', 'in_progress', 2, 2, '2025-03-15', '2025-02-20 10:00:00'),
|
||||
(11, 'Set up DataLoader batching', 'in_progress', 3, 2, '2025-03-10', '2025-02-25 09:00:00'),
|
||||
(12, 'Write integration tests', 'todo', 2, 2, '2025-03-20', '2025-03-01 10:00:00'),
|
||||
(13, 'Deprecate REST endpoints', 'todo', 1, 2, '2025-04-15', '2025-03-05 11:00:00'),
|
||||
(14, 'Update API documentation', 'todo', 1, 2, NULL, '2025-03-10 10:00:00'),
|
||||
|
||||
-- Q2 Marketing Site
|
||||
(15, 'Create wireframes', 'done', 3, 3, '2025-03-10', '2025-03-02 09:00:00'),
|
||||
(16, 'Build hero section component', 'in_progress', 3, 3, '2025-03-20', '2025-03-08 10:00:00'),
|
||||
(17, 'Implement pricing page', 'todo', 2, 3, '2025-03-25', '2025-03-10 11:00:00'),
|
||||
(18, 'Add contact form', 'todo', 2, 3, '2025-03-28', '2025-03-12 09:00:00'),
|
||||
(19, 'SEO optimization pass', 'todo', 1, 3, '2025-04-05', '2025-03-15 10:00:00'),
|
||||
(20, 'Cross-browser testing', 'todo', 1, 3, '2025-04-10', '2025-03-18 11:00:00');
|
||||
""")
|
||||
|
||||
// Task-tag associations
|
||||
try db.execute(sql: """
|
||||
INSERT INTO task_tags (task_id, tag_id) VALUES
|
||||
(1, 4), (1, 2),
|
||||
(2, 6), (2, 2),
|
||||
(3, 1), (3, 3),
|
||||
(4, 6), (4, 2),
|
||||
(5, 7),
|
||||
(6, 6),
|
||||
(7, 4),
|
||||
(8, 5), (8, 2),
|
||||
(9, 5),
|
||||
(10, 5),
|
||||
(11, 5), (11, 3),
|
||||
(12, 5), (12, 7),
|
||||
(13, 5), (13, 7),
|
||||
(14, 7),
|
||||
(15, 4), (15, 2),
|
||||
(16, 6), (16, 2),
|
||||
(17, 6),
|
||||
(18, 6),
|
||||
(19, 6),
|
||||
(20, 6);
|
||||
""")
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
120
Example/SwiftDBAIDemo/SwiftDBAIDemo/DemoLanguageModel.swift
Normal file
120
Example/SwiftDBAIDemo/SwiftDBAIDemo/DemoLanguageModel.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
// DemoLanguageModel.swift
|
||||
// SwiftDBAIDemo
|
||||
//
|
||||
// A mock LanguageModel that returns canned SQL for common queries
|
||||
// and simple summaries for result summarization.
|
||||
// No real LLM API key is needed.
|
||||
|
||||
import AnyLanguageModel
|
||||
import Foundation
|
||||
|
||||
/// A mock language model that pattern-matches the user's question to return
|
||||
/// appropriate SQL, and produces simple text summaries for query results.
|
||||
///
|
||||
/// The ChatEngine calls the model twice per user message:
|
||||
/// 1. SQL generation (system prompt contains "Respond with ONLY the SQL query")
|
||||
/// 2. Result summarization (prompt contains the raw query result)
|
||||
///
|
||||
/// This model detects which phase it is in based on the prompt content.
|
||||
struct DemoLanguageModel: LanguageModel {
|
||||
typealias UnavailableReason = Never
|
||||
|
||||
func respond<Content>(
|
||||
within session: LanguageModelSession,
|
||||
to prompt: Prompt,
|
||||
generating type: Content.Type,
|
||||
includeSchemaInPrompt: Bool,
|
||||
options: GenerationOptions
|
||||
) async throws -> LanguageModelSession.Response<Content> where Content: Generable {
|
||||
let promptText = prompt.description.lowercased()
|
||||
let responseText: String
|
||||
|
||||
// Detect if this is a summarization call (contains result data or "summarize")
|
||||
// vs. a SQL generation call.
|
||||
if promptText.contains("row") && (promptText.contains("column") || promptText.contains("|")) {
|
||||
// Summarization phase -- return a human-readable summary
|
||||
responseText = deriveSummary(from: prompt.description)
|
||||
} else {
|
||||
// SQL generation phase -- return SQL based on the question
|
||||
responseText = deriveSQL(from: promptText)
|
||||
}
|
||||
|
||||
let rawContent = GeneratedContent(kind: .string(responseText))
|
||||
let content = try Content(rawContent)
|
||||
return LanguageModelSession.Response(
|
||||
content: content,
|
||||
rawContent: rawContent,
|
||||
transcriptEntries: [][...]
|
||||
)
|
||||
}
|
||||
|
||||
func streamResponse<Content>(
|
||||
within session: LanguageModelSession,
|
||||
to prompt: Prompt,
|
||||
generating type: Content.Type,
|
||||
includeSchemaInPrompt: Bool,
|
||||
options: GenerationOptions
|
||||
) -> sending LanguageModelSession.ResponseStream<Content> where Content: Generable {
|
||||
let rawContent = GeneratedContent(kind: .string("SELECT * FROM tasks"))
|
||||
let content = try! Content(rawContent)
|
||||
return LanguageModelSession.ResponseStream(content: content, rawContent: rawContent)
|
||||
}
|
||||
|
||||
// MARK: - SQL Pattern Matching
|
||||
|
||||
private func deriveSQL(from prompt: String) -> String {
|
||||
// Pattern-match common questions to SQL
|
||||
if prompt.contains("how many task") || prompt.contains("count") && prompt.contains("task") {
|
||||
return "SELECT COUNT(*) AS task_count FROM tasks"
|
||||
}
|
||||
if prompt.contains("overdue") {
|
||||
return "SELECT * FROM tasks WHERE due_date < date('now') AND status != 'done'"
|
||||
}
|
||||
if prompt.contains("high priority") || prompt.contains("priority") && prompt.contains("high") {
|
||||
return "SELECT * FROM tasks WHERE priority = 3"
|
||||
}
|
||||
if prompt.contains("in progress") || prompt.contains("in_progress") {
|
||||
return "SELECT * FROM tasks WHERE status = 'in_progress'"
|
||||
}
|
||||
if prompt.contains("done") || prompt.contains("completed") {
|
||||
return "SELECT * FROM tasks WHERE status = 'done'"
|
||||
}
|
||||
if prompt.contains("todo") || prompt.contains("to do") || prompt.contains("pending") {
|
||||
return "SELECT * FROM tasks WHERE status = 'todo'"
|
||||
}
|
||||
if prompt.contains("project") && !prompt.contains("task") {
|
||||
return "SELECT * FROM projects"
|
||||
}
|
||||
if prompt.contains("tag") {
|
||||
return "SELECT * FROM tags"
|
||||
}
|
||||
if prompt.contains("show all task") || prompt.contains("list all task") || prompt.contains("all task") {
|
||||
return "SELECT * FROM tasks"
|
||||
}
|
||||
if prompt.contains("show") && prompt.contains("task") {
|
||||
return "SELECT * FROM tasks"
|
||||
}
|
||||
// Default fallback
|
||||
return "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 10"
|
||||
}
|
||||
|
||||
// MARK: - Summary Generation
|
||||
|
||||
private func deriveSummary(from rawPrompt: String) -> String {
|
||||
// Count approximate row references to generate a reasonable summary
|
||||
let lines = rawPrompt.components(separatedBy: "\n")
|
||||
let dataLines = lines.filter { $0.contains("|") || $0.contains(",") }
|
||||
let rowCount = max(dataLines.count - 1, 0) // subtract header
|
||||
|
||||
if rawPrompt.lowercased().contains("count") {
|
||||
return "The query returned a count result."
|
||||
}
|
||||
if rowCount == 0 {
|
||||
return "The query returned no results."
|
||||
}
|
||||
if rowCount == 1 {
|
||||
return "Found 1 result."
|
||||
}
|
||||
return "Found \(rowCount) results."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// OllamaWithSystemPrompt.swift
|
||||
// SwiftDBAIDemo
|
||||
//
|
||||
// Wraps OllamaLanguageModel to prepend session instructions into the user
|
||||
// prompt, working around AnyLanguageModel's Ollama adapter not forwarding
|
||||
// system messages.
|
||||
|
||||
import AnyLanguageModel
|
||||
import Foundation
|
||||
|
||||
/// Wrapper that injects session instructions into every Ollama request.
|
||||
struct OllamaWithSystemPrompt: LanguageModel {
|
||||
typealias UnavailableReason = Never
|
||||
|
||||
private let inner: OllamaLanguageModel
|
||||
|
||||
init(baseURL: URL = OllamaLanguageModel.defaultBaseURL, model: String) {
|
||||
self.inner = OllamaLanguageModel(baseURL: baseURL, model: model)
|
||||
}
|
||||
|
||||
func respond<Content>(
|
||||
within session: LanguageModelSession,
|
||||
to prompt: Prompt,
|
||||
generating type: Content.Type,
|
||||
includeSchemaInPrompt: Bool,
|
||||
options: GenerationOptions
|
||||
) async throws -> LanguageModelSession.Response<Content> where Content: Generable {
|
||||
let userText = prompt.description
|
||||
let instructionText = session.instructions?.description ?? ""
|
||||
|
||||
let combinedText: String
|
||||
if instructionText.isEmpty {
|
||||
combinedText = userText
|
||||
} else {
|
||||
combinedText = """
|
||||
[System Instructions]
|
||||
\(instructionText)
|
||||
|
||||
[User Message]
|
||||
\(userText)
|
||||
"""
|
||||
}
|
||||
|
||||
let plainSession = LanguageModelSession(model: inner)
|
||||
let combinedPrompt = Prompt(combinedText)
|
||||
return try await inner.respond(
|
||||
within: plainSession,
|
||||
to: combinedPrompt,
|
||||
generating: type,
|
||||
includeSchemaInPrompt: includeSchemaInPrompt,
|
||||
options: options
|
||||
)
|
||||
}
|
||||
|
||||
func streamResponse<Content>(
|
||||
within session: LanguageModelSession,
|
||||
to prompt: Prompt,
|
||||
generating type: Content.Type,
|
||||
includeSchemaInPrompt: Bool,
|
||||
options: GenerationOptions
|
||||
) -> sending LanguageModelSession.ResponseStream<Content> where Content: Generable {
|
||||
inner.streamResponse(
|
||||
within: session,
|
||||
to: prompt,
|
||||
generating: type,
|
||||
includeSchemaInPrompt: includeSchemaInPrompt,
|
||||
options: options
|
||||
)
|
||||
}
|
||||
}
|
||||
48
Example/SwiftDBAIDemo/SwiftDBAIDemo/SwiftDBAIDemoApp.swift
Normal file
48
Example/SwiftDBAIDemo/SwiftDBAIDemo/SwiftDBAIDemoApp.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
// SwiftDBAIDemoApp.swift
|
||||
// SwiftDBAIDemo
|
||||
//
|
||||
// A minimal demo app showing DataChatView with a seeded SQLite database.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftDBAI
|
||||
|
||||
@main
|
||||
struct SwiftDBAIDemoApp: App {
|
||||
@State private var databasePath: String?
|
||||
@State private var setupError: String?
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if let path = databasePath {
|
||||
DataChatView(
|
||||
databasePath: path,
|
||||
model: DemoLanguageModel(),
|
||||
allowlist: .readOnly,
|
||||
additionalContext: """
|
||||
This is a task management database with projects, tasks, tags, \
|
||||
and a task_tags junction table. Tasks have priority (1=low, 2=medium, \
|
||||
3=high) and status ('todo', 'in_progress', 'done').
|
||||
"""
|
||||
)
|
||||
} else if let error = setupError {
|
||||
ContentUnavailableView(
|
||||
"Database Setup Failed",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(error)
|
||||
)
|
||||
} else {
|
||||
ProgressView("Setting up database...")
|
||||
}
|
||||
}
|
||||
.task {
|
||||
do {
|
||||
let path = try DatabaseSeeder.seedIfNeeded()
|
||||
databasePath = path
|
||||
} catch {
|
||||
setupError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Example/SwiftDBAIDemo/project.yml
Normal file
26
Example/SwiftDBAIDemo/project.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: SwiftDBAIDemo
|
||||
options:
|
||||
bundleIdPrefix: com.swiftdbai.demo
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
createIntermediateGroups: true
|
||||
packages:
|
||||
SwiftDBAI:
|
||||
path: ../..
|
||||
targets:
|
||||
SwiftDBAIDemo:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- SwiftDBAIDemo
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.swiftdbai.demo
|
||||
MARKETING_VERSION: "1.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
SWIFT_VERSION: "6.0"
|
||||
INFOPLIST_GENERATION: true
|
||||
GENERATE_INFOPLIST_FILE: true
|
||||
dependencies:
|
||||
- package: SwiftDBAI
|
||||
Reference in New Issue
Block a user