Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f78a7fe
docs: mark iOS as the primary Chat Buddy app
Luckycat133 Jul 18, 2026
63daa73
docs: add primary app status and roadmap in Chinese
Luckycat133 Jul 18, 2026
42b7fa3
ci: build and test the iOS app
Luckycat133 Jul 18, 2026
d273cdd
fix: restore iOS application compilation
Luckycat133 Jul 18, 2026
9e35f68
fix: restore iOS application compilation
Luckycat133 Jul 18, 2026
19263ef
fix: restore iOS application compilation
Luckycat133 Jul 18, 2026
58e9c21
ci: provision the simulator deterministically
Luckycat133 Jul 18, 2026
01e1121
fix: resolve Xcode 26.5 compile errors
Luckycat133 Jul 18, 2026
6209bfc
fix: resolve Xcode 26.5 compile errors
Luckycat133 Jul 18, 2026
f3eeea5
fix: resolve Xcode 26.5 compile errors
Luckycat133 Jul 18, 2026
a59591c
fix: share Keychain key derivation with data import
Luckycat133 Jul 18, 2026
f4ad1d3
fix: propagate Keychain import failures
Luckycat133 Jul 18, 2026
d6ab31d
fix: use the Xcode 26 AttributedString index API
Luckycat133 Jul 18, 2026
df2a264
fix: resolve chat view compilation
Luckycat133 Jul 18, 2026
8aa1869
fix: resolve chat view compilation
Luckycat133 Jul 18, 2026
8c864ab
fix: disambiguate poll status colors
Luckycat133 Jul 18, 2026
eb49f44
Disambiguate model preset iteration
Luckycat133 Jul 18, 2026
c997c41
Simplify learning report generation
Luckycat133 Jul 18, 2026
da94299
Fix RAG service argument labels
Luckycat133 Jul 18, 2026
4a854fb
Fix focused chat navigation initializer
Luckycat133 Jul 18, 2026
24f847a
Fix Xcode 26 accent style compatibility
Luckycat133 Jul 18, 2026
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
53 changes: 53 additions & 0 deletions .github/workflows/ios-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: iOS CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: ios-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-unit-test:
runs-on: macos-26
timeout-minutes: 45

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Select Xcode 26.5
run: sudo xcode-select --switch /Applications/Xcode_26.5.app/Contents/Developer

- name: Show toolchain
run: xcodebuild -version

- name: Prepare iOS simulator
run: |
set -euo pipefail
SIMULATOR_UDID=$(xcrun simctl list devices available | awk -F '[()]' '/iPhone 17 Pro/ {print $2; exit}')
if [ -z "$SIMULATOR_UDID" ]; then
SIMULATOR_UDID=$(xcrun simctl create \
'CI iPhone 17 Pro' \
'com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro' \
'com.apple.CoreSimulator.SimRuntime.iOS-26-5')
fi
echo "SIMULATOR_UDID=$SIMULATOR_UDID" >> "$GITHUB_ENV"
xcrun simctl boot "$SIMULATOR_UDID" || true
xcrun simctl bootstatus "$SIMULATOR_UDID" -b

- name: Build and run unit tests
run: |
set -o pipefail
xcodebuild test \
-project Chat_Buddy_iOS.xcodeproj \
-scheme Chat_Buddy_iOS \
-destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
-only-testing:Chat_Buddy_iOSTests \
CODE_SIGNING_ALLOWED=NO
12 changes: 9 additions & 3 deletions Chat_Buddy_iOS/Features/Chats/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ struct ChatView: View {

let sessionId: String
let persona: Persona
let initialFocusMessageId: String? = nil
let initialFocusMessageId: String?

init(sessionId: String, persona: Persona, initialFocusMessageId: String? = nil) {
self.sessionId = sessionId
self.persona = persona
self.initialFocusMessageId = initialFocusMessageId
}

@State private var viewModel = ChatViewModel()
@State private var showClearAlert = false
Expand Down Expand Up @@ -354,15 +360,15 @@ struct ChatView: View {
scrollToMessageId = initialFocusMessageId
}
// Proactive greeting
let lastDate = viewModel.messages.last?.timestamp
let lastDate = allMessages.last?.timestamp
let isZhUI = localization.uiLanguage.resolved == .zh
if let greeting = GreetingService.checkWindowOpenGreeting(
lastMessageDate: lastDate,
personaId: persona.id,
isZh: isZhUI
) {
let greetMsg = ChatMessage(role: .assistant, content: greeting)
viewModel.messages.append(greetMsg)
chatStore.appendMessage(greetMsg, to: sessionId)
}
}
.task(id: viewModel.inputText) {
Expand Down
2 changes: 1 addition & 1 deletion Chat_Buddy_iOS/Features/Chats/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ enum ChatViewModelError: LocalizedError {
isTyping = false
}

private func saveNewMemories(_ memories: [ExtractedMemory], for personaId: String, service: MemoryService?) {
private func saveNewMemories(_ memories: [AIPipeline.ExtractedMemory], for personaId: String, service: MemoryService?) {
for extracted in memories {
service?.addMemory(
personaId: personaId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ struct MarkdownContentView: View {
// Only highlight if it's a word boundary
let before = range.lowerBound == result.startIndex
let after = range.upperBound == result.endIndex
if (before || !String(result.characters[result.index(before: range.lowerBound)]).first!.isLetter)
if (before || !String(result.characters[result.index(beforeCharacter: range.lowerBound)]).first!.isLetter)
&& (after || !String(result.characters[range.upperBound]).first!.isLetter) {
result[range].foregroundColor = .purple
result[range].font = .system(size: 12, weight: .semibold, design: .monospaced)
Expand Down
4 changes: 2 additions & 2 deletions Chat_Buddy_iOS/Features/Chats/GroupDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,15 @@ struct GroupDetailsView: View {
.font(DSTypography.caption1)
.foregroundStyle(.secondary)
} else {
ForEach(session.polls) { poll in
ForEach(session.polls, id: \.id) { (poll: ChatPoll) in
VStack(alignment: .leading, spacing: 4) {
Text(poll.question)
.font(DSTypography.footnote.weight(.semibold))
Text(poll.isExpired
? (isZh ? "已结束" : "Closed")
: (isZh ? "进行中" : "Active"))
.font(DSTypography.caption2)
.foregroundStyle(poll.isExpired ? .secondary : .green)
.foregroundStyle(poll.isExpired ? Color.secondary : Color.green)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ struct KnowledgeBaseView: View {

if !data.docs.isEmpty {
Button(role: .destructive) {
for doc in data.docs { RAGService.removeDocumentFromIndex(id: doc.id) }
for doc in data.docs { RAGService.removeDocumentFromIndex(documentId: doc.id) }
data.docs.removeAll()
save()
} label: {
Expand Down Expand Up @@ -90,7 +90,7 @@ struct KnowledgeBaseView: View {
.padding(.vertical, 2)
.swipeActions {
Button(role: .destructive) {
RAGService.removeDocumentFromIndex(id: doc.id)
RAGService.removeDocumentFromIndex(documentId: doc.id)
data.docs.removeAll { $0.id == doc.id }
save()
} label: {
Expand Down Expand Up @@ -142,7 +142,7 @@ struct KnowledgeBaseView: View {
data.docs.insert(doc, at: 0)
save()
// Index document for RAG search
RAGService.addDocumentToIndex(id: doc.id, title: doc.name, content: doc.content)
RAGService.addDocumentToIndex(id: doc.id, name: doc.name, content: doc.content)
errorText = nil
} catch {
errorText = error.localizedDescription
Expand Down
26 changes: 12 additions & 14 deletions Chat_Buddy_iOS/Features/Settings/Advanced/LearningReportView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,18 @@ struct LearningReportView: View {
}

private var reportText: String {
[
isZh ? "学习报告" : "Learning Report",
DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short),
"",
(isZh ? "会话数" : "Sessions") + ": \(totalSessions)",
(isZh ? "消息数" : "Messages") + ": \(totalMessages)",
(isZh ? "积分" : "Points") + ": \(social.points)",
(isZh ? "签到连续" : "Streak") + ": \(social.streakDays)",
(isZh ? "今日任务完成率" : "Daily Task Completion") + ": \(Int(completionRate * 100))%",
"",
isZh ? "高频互动角色:" : "Most interacted personas:",
topPersonas.prefix(5).map { "- \($0.name): \($0.count)" }.joined(separator: "\n")
]
.joined(separator: "\n")
let title = isZh ? "学习报告" : "Learning Report"
let date = DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short)
let sessions = "\(isZh ? "会话数" : "Sessions"): \(totalSessions)"
let messages = "\(isZh ? "消息数" : "Messages"): \(totalMessages)"
let points = "\(isZh ? "积分" : "Points"): \(social.points)"
let streak = "\(isZh ? "签到连续" : "Streak"): \(social.streakDays)"
let completion = "\(isZh ? "今日任务完成率" : "Daily Task Completion"): \(Int(completionRate * 100))%"
let personasTitle = isZh ? "高频互动角色:" : "Most interacted personas:"
let personas = topPersonas.prefix(5).map { "- \($0.name): \($0.count)" }.joined(separator: "\n")

return [title, date, "", sessions, messages, points, streak, completion, "", personasTitle, personas]
.joined(separator: "\n")
}

var body: some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct ModelSwitcherView: View {
var body: some View {
List {
Section(isZh ? "模型选择" : "Model") {
ForEach(ModelPreset.all) { preset in
ForEach(ModelPreset.all, id: \.id) { (preset: ModelPreset) in
Button {
selectedModel = preset.id
} label: {
Expand All @@ -57,7 +57,7 @@ struct ModelSwitcherView: View {
Spacer()
if selectedModel == preset.id {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.accent)
.foregroundStyle(Color.accentColor)
}
}
}
Expand Down
25 changes: 24 additions & 1 deletion Chat_Buddy_iOS/Services/API/AIClient.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
import Foundation
import os.log

actor AIClient {
protocol AIClientProtocol: Sendable {
func sendChatCompletion(
messages: [ChatMessage],
model: String?,
temperature: Double?,
config: APIConfig
) async throws -> ChatCompletionResponse
}

extension AIClientProtocol {
func sendChatCompletion(
messages: [ChatMessage],
config: APIConfig
) async throws -> ChatCompletionResponse {
try await sendChatCompletion(
messages: messages,
model: nil,
temperature: nil,
config: config
)
}
}

actor AIClient: AIClientProtocol {
static let shared = AIClient()

private var apiClient: APIClient
Expand Down
6 changes: 3 additions & 3 deletions Chat_Buddy_iOS/Services/API/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,14 @@ actor APIClient {
Double(maxRetries - retriesLeft + 1) + Double.random(in: 0...0.5),
Self.maxRetryDelay
)
Self.logger.info("Rate limited (429), retrying in \(waitTime, format: .seconds(style: .floatingPoint))s...")
Self.logger.info("Rate limited (429), retrying in \(waitTime, privacy: .public)s...")
try await Task.sleep(for: .seconds(waitTime))
return try await fetchWithRetry(request, retriesLeft: retriesLeft - 1, attempt: attempt + 1)
}

if httpResponse.statusCode >= 500 && retriesLeft > 0 {
let waitTime = min(1.0 * Double(maxRetries - retriesLeft + 1), Self.maxRetryDelay)
Self.logger.info("Server error (\(httpResponse.statusCode)), retrying in \(waitTime, format: .seconds(style: .floatingPoint))s...")
Self.logger.info("Server error (\(httpResponse.statusCode)), retrying in \(waitTime, privacy: .public)s...")
try await Task.sleep(for: .seconds(waitTime))
return try await fetchWithRetry(request, retriesLeft: retriesLeft - 1, attempt: attempt + 1)
}
Expand All @@ -101,7 +101,7 @@ actor APIClient {
} catch {
if retriesLeft > 0 && !(error is CancellationError) {
let waitTime = Double(attempt + 1)
Self.logger.info("Network error, retrying in \(waitTime, format: .seconds(style: .floatingPoint))s...")
Self.logger.info("Network error, retrying in \(waitTime, privacy: .public)s...")
try await Task.sleep(for: .seconds(waitTime))
return try await fetchWithRetry(request, retriesLeft: retriesLeft - 1, attempt: attempt + 1)
}
Expand Down
4 changes: 2 additions & 2 deletions Chat_Buddy_iOS/Services/API/APIConfigStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ final class APIConfigStore {

private let storage = StorageService.shared

private static let activeKeyKey = "api-key-active"
private static func profileKeyKey(_ id: UUID) -> String { "api-key-profile-\(id.uuidString)" }
static let activeKeyKey = "api-key-active"
static func profileKeyKey(_ id: UUID) -> String { "api-key-profile-\(id.uuidString)" }

init() {
let (config, savedProfiles) = Self.loadFromStorage(storage: StorageService.shared)
Expand Down
9 changes: 3 additions & 6 deletions Chat_Buddy_iOS/Services/API/KeychainService.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import Security
import LocalAuthentication
import os

enum KeychainError: LocalizedError {
case encodingFailed
Expand Down Expand Up @@ -126,14 +127,10 @@ enum KeychainService {
logger.warning("Authentication failed for key: \(key)")
throw KeychainError.authenticationFailed

case errSecBiometryNotAvailable, errSecBiometryNotEnrolled:
logger.warning("Biometric not available for key: \(key)")
case errSecInteractionNotAllowed:
logger.warning("Keychain interaction is not available for key: \(key)")
throw KeychainError.biometricNotAvailable

case errSecBiometryLockout:
logger.warning("Biometric locked out for key: \(key)")
throw KeychainError.biometricFailed

default:
logger.error("Keychain error for key \(key): \(status)")
return nil
Expand Down
9 changes: 5 additions & 4 deletions Chat_Buddy_iOS/Services/Chat/AIPipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,11 @@ enum AIPipeline {
// RAG context injection
var ragBlock = ""
if ragEnabled, let query = userQuery ?? session.displayMessages.last(where: { $0.role == .user })?.content {
let ragResults = RAGService.searchDocuments(query: query, topK: 3)
if !ragResults.isEmpty {
ragBlock = RAGService.buildRAGContext(ragResults)
}
ragBlock = RAGService.buildRAGContext(
query: query,
indexedChunks: RAGService.loadIndex(),
topK: 3
) ?? ""
}

let systemMsg = ChatMessage(
Expand Down
4 changes: 2 additions & 2 deletions Chat_Buddy_iOS/Services/Storage/DataImporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ struct DataImporter {
configStore.activeConfig = config
// 如果备份中包含 API 密钥,也保存到 Keychain
if !config.apiKey.isEmpty {
KeychainService.set(APIConfigStore.activeKeyKey, value: config.apiKey)
try KeychainService.set(APIConfigStore.activeKeyKey, value: config.apiKey)
}
count += 1
}
Expand All @@ -70,7 +70,7 @@ struct DataImporter {
// 如果备份中包含 API 密钥,也保存到 Keychain
for profile in profiles {
if !profile.config.apiKey.isEmpty {
KeychainService.set(APIConfigStore.profileKeyKey(profile.id), value: profile.config.apiKey)
try KeychainService.set(APIConfigStore.profileKeyKey(profile.id), value: profile.config.apiKey)
}
}
count += profiles.count
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

# Chat Buddy iOS

> **Status: Active Development — primary development version of Chat Buddy.**
> The [web application](https://github.com/Luckycat133/Chat_Buddy) is in maintenance mode and serves as the migration source for existing user data.

![iOS 26+](https://img.shields.io/badge/iOS-26.0+-black?style=flat&logo=apple)
![SwiftUI](https://img.shields.io/badge/SwiftUI-100%25-blue?style=flat&logo=swift)
![License MIT](https://img.shields.io/badge/License-MIT-green.svg)
Expand All @@ -14,6 +17,15 @@
- ⏱️ **Moments Background Orchestration**: Deep integration with the iOS `BackgroundTasks` framework (`BGAppRefreshTask` & `BGProcessingTask`). Your AI companions will naturally and autonomously generate updates, celebrate birthdays, and post holiday greetings to their "Moments" feed even while the app is completely backgrounded.
- 🎨 **Premium Native UI/UX**: Built with a rigorous design system featuring glassmorphism elements, dynamic accent colors, smooth SwiftUI transitions, and robust bilingual (English/Chinese) localization support.

## 🗺 Roadmap

- **MVP stabilization (current)**: Keep the Web → iOS data schema compatible, expand core chat/storage tests, and validate background tasks on physical devices.
- **Internal beta**: Complete migration testing and collect crash/performance feedback from a small device matrix.
- **TestFlight**: Ship a signed beta with release notes, privacy disclosures, and a repeatable archive process.
- **App Store**: Complete review assets, support documentation, and production monitoring before public release.

Background refresh is opportunistic: iOS decides when `BackgroundTasks` run, so the app does not promise execution at an exact time. API keys are stored in Keychain rather than plain-text `UserDefaults`.

## 🛠 Tech Stack

- **UI Framework**: SwiftUI
Expand All @@ -30,6 +42,8 @@
3. The project uses standard iOS frameworks and requires no complex third-party setup.
4. Set your run destination and hit `Cmd + R`.

Pull requests are also built and unit-tested on GitHub Actions using the macOS 26 runner and Xcode 26.5.

### Terminal Build & Test

```bash
Expand Down
14 changes: 14 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

# Chat Buddy iOS

> **状态:积极开发中 —— Chat Buddy 的主要开发版本。**
> [Web 版本](https://github.com/Luckycat133/Chat_Buddy)现处于维护模式,并作为现有用户数据迁移到 iOS 的来源。

**Chat Buddy iOS** 是一款基于 SwiftUI 纯原生打造的高性能智能 AI 伴侣应用。该项目将广受欢迎的 Web 端“Chat Buddy”的核心功能深度迁移至 iOS 环境,提供了无缝衔接且纯粹的原生体验。

![iOS 26+](https://img.shields.io/badge/iOS-26.0+-black?style=flat&logo=apple)
Expand All @@ -16,6 +19,15 @@
- ⏱️ **Moments 后台编排 (朋友圈)**: 深度集成 iOS `BackgroundTasks` 机制 (`BGAppRefreshTask` & `BGProcessingTask`)。你的 AI 伴侣们能够自主、自然地在后台发文字/图片状态、记住你的生日、甚至在特定的节假日为你送出祝福,即便你并未打开应用。
- 🎨 **顶级原生 UI/UX**: 配备严谨的 Glassmorphism 毛玻璃设计系统。支持自定义提取主题强调色、流畅的 SwiftUI 过渡动画,并在整个应用内完美支持英文和简体中文的动态双语切换。

## 🗺 版本路线

- **MVP 稳定化(当前)**:保持 Web → iOS 数据 schema 兼容,扩充核心聊天和存储测试,并在真机上验证后台任务。
- **内部测试**:完成迁移测试,在小型设备矩阵上收集崩溃和性能反馈。
- **TestFlight**:发布已签名的测试版,补齐发布说明、隐私声明和可重复的归档流程。
- **App Store**:上线前完成审核素材、支持文档和生产监控。

后台刷新属于机会性调度:实际执行时间由 iOS 决定,应用不承诺在固定时刻执行。API Key 存储在 Keychain,不会以明文写入 `UserDefaults`。

## 🛠 技术栈

- **界面框架**: SwiftUI
Expand All @@ -32,6 +44,8 @@
3. 本项目完全使用标准的 iOS 原生框架,不需要繁杂的第三方依赖 (No CocoaPods / No SPM)。
4. 选择 iOS 模拟器或真实设备,按下 `Cmd + R` 即可直接编译运行!

Pull Request 会在 GitHub Actions 中使用 macOS 26 和 Xcode 26.5 自动构建并运行单元测试。

### 终端构建与测试

```bash
Expand Down
Loading