From ff33abbfe788d86a406c670f4b6a574d9f8f13b7 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 19 May 2026 16:11:00 +0600 Subject: [PATCH] 2026-05-19 reflect-skip dev-setup: Android build, Skip Fuse pitfalls, feature status --- .../projects/psychologist-app/dev-setup.md | 431 ++++++++++-------- 1 file changed, 248 insertions(+), 183 deletions(-) diff --git a/personal/projects/psychologist-app/dev-setup.md b/personal/projects/psychologist-app/dev-setup.md index 46e2da57..c810ab85 100644 --- a/personal/projects/psychologist-app/dev-setup.md +++ b/personal/projects/psychologist-app/dev-setup.md @@ -1,124 +1,282 @@ --- created: '2026-05-16' -updated: '2026-05-16' +updated: '2026-05-19' tags: - project - dev - ios - swift + - android + - skip --- # Reflect App — Dev Setup & Architecture -> Путь: `~/Developer/reflect-app/` -> Статус: BUILD SUCCEEDED, 30/30 тестов +> **iOS (reflect-app):** `~/Desktop/reflect-app/` — нативный SwiftUI, не трогать +> **Cross-platform (reflect-skip):** `~/Developer/reflect-skip/` — Skip Fuse, iOS + Android +> Gitea: `git.mallexxx.duckdns.org/git_admin/reflect-app` --- -## Структура проекта +## Статус (2026-05-19) -``` -reflect-app/ -├── project.yml ← XcodeGen spec (генерирует .xcodeproj) -├── proxy_server.py ← Local AI proxy server (Mac → iPhone) -├── ReflectApp/ -│ ├── Models/ -│ │ ├── CrisisKeywords.swift -│ │ ├── Message.swift -│ │ ├── Session.swift -│ │ └── UserProfile.swift -│ ├── Services/ -│ │ ├── ClaudeService.swift ← AI бэкенд (cloud + local proxy) -│ │ ├── AnalystBot.swift ← Бот 2 (Sonnet, CBT вопросы) -│ │ ├── NarratorBot.swift ← Бот 1 (Haiku, 3rd-person reframe) -│ │ ├── CrisisDetector.swift ← Hardcoded SI/SH детекция (RU+EN) -│ │ ├── ProfileManager.swift ← Obsidian-like on-device vault -│ │ └── SessionScheduler.swift ← Напоминания (UNUserNotificationCenter) -│ ├── Views/ -│ │ ├── OnboardingView.swift ← 3 слайда + 5 вопросов + инсайт -│ │ ├── HomeView.swift -│ │ ├── SessionView.swift -│ │ ├── CrisisView.swift -│ │ ├── ProfileView.swift ← Obsidian-like markdown viewer -│ │ ├── SettingsView.swift ← Выбор бэкенда + API key + server URL -│ │ ├── CardView.swift -│ │ └── OrbCharacter.swift ← SwiftUI анимированный орб -│ └── ViewModels/ -│ ├── HomeViewModel.swift -│ └── SessionViewModel.swift -└── ReflectAppTests/ - └── ReflectAppTests.swift ← 30 тестов -``` +### Что реализовано + +| Компонент | Статус | +|-----------|--------| +| iOS build (reflect-app) | ✅ Build succeeded, 30/30 тестов | +| Android build (reflect-skip) | ✅ APK собирается, запускается на эмуляторе | +| Экран 0: Privacy intro | ✅ Личный текст, GDPR opt-out, дисклеймер | +| Онбординг | ✅ 3 info-слайда + 5 вопросов + AI инсайт | +| Сессия: dynamic intro | ✅ AI-генерированное вступление + max 2 крючка из истории | +| Сессия: one-by-one вопросы | ✅ Вопросы по одному, батч-подбивка агенту | +| Сессия: closing phrase | ✅ Тёплая фраза от Narrator'а | +| Сессия: one last thing | ✅ Маленький input + неакцентная кнопка Завершить | +| Dual-bot pipeline | ✅ Narrator (Haiku) → Analyst (Sonnet) | +| Crisis detection | ✅ Hardcoded RU+EN, CrisisView | +| Profile vault | ✅ profile.md, sessions/, insights.md | +| App Lock (Face ID) | ✅ BiometricAuthentication | +| Settings: API key, proxy | ✅ Keychain + LocalProxy | +| Settings: Сбросить данные | ✅ Алерт + полный сброс + онбординг заново | +| Session scheduler | ✅ UNCalendarNotification | + +### Что не реализовано (план) + +| Компонент | Приоритет | +|-----------|-----------| +| ProfileView: история сессий | 🟠 Средний | +| Session recovery (незавершённые) | 🟠 Средний | +| Полноценный sessions/YYYY-MM-DD.md | 🟡 Низкий | +| RevenueCat paywall | 🔴 Нужен до публикации | +| Голосовой ввод (iOS) | 🟡 Phase 2 | +| Push-уведомления opt-in | 🟡 Phase 2 | --- -## Генерация проекта +## Android / Skip Fuse — сборка + +### Требования +- Xcode 16+, `xcodebuild` +- Java 21: `/opt/homebrew/opt/openjdk@21` +- Android SDK: `~/Library/Android/sdk` +- Android Emulator: `reflect-android` (Pixel 6, API 35) +- Skip CLI: `skip` (из Homebrew или из Xcode DerivedData) + +### Полная сборка (первый раз или после новых Swift файлов) ```bash -# Установить XcodeGen (один раз) -brew install xcodegen +cd ~/Developer/reflect-skip -# Регенерировать .xcodeproj после изменения project.yml -cd ~/Developer/reflect-app -xcodegen generate +export ANDROID_HOME=~/Library/Android/sdk +export JAVA_HOME=/opt/homebrew/opt/openjdk@21 +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:/opt/homebrew/bin:$PATH" + +# Шаг 1: Xcode генерирует Kotlin стабы (skipstone) + собирает Android +xcodebuild \ + -workspace Project.xcworkspace \ + -scheme "ReflectApp App" \ + -destination "generic/platform=iOS Simulator" \ + -configuration Debug \ + -skipPackagePluginValidation \ + SKIP_ACTION=build + +# Шаг 2: Линкуем symlinks субмодулей (один раз после очистки) +# Делается скриптом или вручную — см. раздел ниже + +# Шаг 3: Gradle собирает APK +cd Darwin +skip gradle -p ../Android assembleDebug +``` + +### Быстрая пересборка (инкрементальная) + +```bash +cd ~/Developer/reflect-skip/Darwin +export ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=/opt/homebrew/opt/openjdk@21 +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:/opt/homebrew/bin:$PATH" + +skip gradle -p ../Android assembleDebug +``` + +### Деплой на эмулятор + +```bash +export ANDROID_HOME=~/Library/Android/sdk +export PATH="$ANDROID_HOME/platform-tools:$PATH" + +# Запустить эмулятор если не запущен +$ANDROID_HOME/emulator/emulator -avd reflect-android & + +APK=~/Developer/reflect-skip/.build/Android/app/outputs/apk/debug/app-debug.apk +adb -s emulator-5554 install -r "$APK" +adb -s emulator-5554 shell am start -n com.reflect.app/reflect.app.MainActivity ``` --- -## Сборка и тесты +## ⚠️ Критические особенности Skip Fuse + +### 1. Новые Swift View файлы требуют ручного Kotlin стаба + +При добавлении нового `*.swift` файла с View в `Sources/ReflectApp/Views/`: +skipstone **не всегда** автоматически генерирует `.kt` стаб. Нужно создать вручную. + +**Шаблон** (копируем `OnboardingView.kt`, меняем класс): + +```kotlin +package reflect.app +import skip.lib.* +import skip.ui.* +import skip.foundation.* +import skip.model.* + +@androidx.annotation.Keep +internal class MyNewView: skip.ui.View, skip.bridge.SwiftPeerBridged, skip.lib.SwiftProjecting { + var Swift_peer: skip.bridge.SwiftObjectPointer = skip.bridge.SwiftObjectNil + + constructor(Swift_peer: skip.bridge.SwiftObjectPointer, marker: skip.bridge.SwiftPeerMarker?) { + this.Swift_peer = Swift_peer + } + fun finalize() { Swift_release(Swift_peer); Swift_peer = skip.bridge.SwiftObjectNil } + private external fun Swift_release(Swift_peer: skip.bridge.SwiftObjectPointer) + override fun Swift_peer(): skip.bridge.SwiftObjectPointer = Swift_peer + override fun equals(other: Any?): Boolean { + if (other !is skip.bridge.SwiftPeerBridged) return false + return Swift_peer == other.Swift_peer() + } + override fun hashCode(): Int = Swift_peer.hashCode() + + override fun body(): skip.ui.View { + return skip.ui.ComposeBuilder { ctx -> Swift_composableBody(Swift_peer)?.Compose(ctx) ?: skip.ui.ComposeResult.ok } + } + private external fun Swift_composableBody(Swift_peer: skip.bridge.SwiftObjectPointer): skip.ui.View? + + constructor(/* params */) { Swift_peer = Swift_constructor_0(/* params */) } + private external fun Swift_constructor_0(/* params */): skip.bridge.SwiftObjectPointer + + override fun Swift_projection(options: Int): () -> Any = Swift_projectionImpl(options) + private external fun Swift_projectionImpl(options: Int): () -> Any + + @androidx.annotation.Keep companion object {} +} +``` + +**Файл кладём в оба места:** +``` +~/Library/Developer/Xcode/DerivedData/Project-bpfpf.../Build/Intermediates.noindex/BuildToolPluginIntermediates/reflect-skip.output/ReflectApp/skipstone/ReflectApp/src/main/kotlin/reflect/app/ +~/Developer/reflect-skip/.build/plugins/outputs/reflect-skip/ReflectApp/destination/skipstone/ReflectApp/src/main/kotlin/reflect/app/ +``` + +### 2. Kotlin субмодули — symlinks + +После очистки кеша/DerivedData Kotlin исходники субмодулей (SkipLib, SkipBridge, etc.) перестают быть видны Gradle. Нужно перелинковать: ```bash -# Узнать ID симулятора -xcrun simctl list devices | grep "iPhone 16 Pro" +DERIVED="$HOME/Library/Developer/Xcode/DerivedData/Project-bpfpf..." +SKIPSTONE="$HOME/Developer/reflect-skip/.build/plugins/outputs/reflect-skip/ReflectApp/destination/skipstone" -# Сборка -xcodebuild -project ReflectApp.xcodeproj -scheme ReflectApp \ - -destination "id=9FF2D6A8-E0DF-4157-9FA5-463E45BB5FAD" build +for MODULE in SkipLib SkipBridge SkipFoundation SkipUI SkipAndroidBridge SkipModel SkipSwiftUI SkipUnit; do + case "$MODULE" in + SkipLib) PKG="skip-lib" ;; + SkipBridge) PKG="skip-bridge" ;; + SkipFoundation) PKG="skip-foundation" ;; + SkipUI) PKG="skip-ui" ;; + SkipAndroidBridge) PKG="skip-android-bridge" ;; + SkipModel) PKG="skip-model" ;; + SkipSwiftUI) PKG="skip-fuse-ui" ;; + SkipUnit) PKG="skip-unit" ;; + esac + REAL_KT="$DERIVED/Build/Intermediates.noindex/BuildToolPluginIntermediates/${PKG}.output/${MODULE}/skipstone/${MODULE}/src/main/kotlin" + mv "$SKIPSTONE/$MODULE/src/main/kotlin" "$SKIPSTONE/$MODULE/src/main/kotlin.bak" 2>/dev/null || true + ln -sfn "$REAL_KT" "$SKIPSTONE/$MODULE/src/main/kotlin" +done +``` -# Тесты (30 тестов, ~0.1 сек) -xcodebuild test -project ReflectApp.xcodeproj -scheme ReflectApp \ - -destination "id=9FF2D6A8-E0DF-4157-9FA5-463E45BB5FAD" +### 3. AndroidBridge.initBridge — обязательно до первого JNI вызова + +В `Android/app/src/main/kotlin/Main.kt` порядок в `onCreate`: +```kotlin +ProcessInfo.launch(applicationContext) +AndroidBridge.initBridge("ReflectApp") // ← ДО AppDelegate.shared.onInit() +AppDelegate.shared.onInit() ``` --- -## Локальный AI сервер (режим без ключа) +## Структура проекта (reflect-skip) -### Запуск +``` +reflect-skip/ +├── Package.swift ← SPM, SkipFuseUI зависимость +├── Skip.env ← ANDROID_PACKAGE_NAME=reflect.app +├── Project.xcworkspace/ ← Workspace для xcodebuild +├── Darwin/ ← Xcode проект (iOS таргет + Skip build phases) +│ ├── ReflectApp.xcodeproj/ +│ └── Sources/Main.swift +├── Android/ ← Gradle Android проект +│ ├── app/src/main/ +│ │ ├── AndroidManifest.xml +│ │ └── kotlin/Main.kt ← Entry point, AndroidBridge.initBridge +│ └── settings.gradle.kts +└── Sources/ReflectApp/ + ├── ReflectAppMain.swift ← App root + PrivacyIntroView → Onboarding → Home + ├── Models/ + ├── Services/ + │ ├── ClaudeService.swift ← Claude API (cloud + local proxy) + │ ├── AnalystBot.swift ← Бот 2 (Sonnet, CBT вопросы + tools) + │ ├── NarratorBot.swift ← Бот 1 (Haiku, 3rd-person reframe) + │ ├── CrisisDetector.swift ← Hardcoded SI/SH + │ ├── ProfileManager.swift ← On-device vault (profile.md, sessions/) + │ ├── SessionManager.swift ← Оркестратор: intro/closing/pipeline + │ └── ScheduleNegotiationService.swift + └── Views/ + ├── PrivacyIntroView.swift ← Экран 0: личный текст + GDPR + ├── OnboardingView.swift ← Онбординг + ├── HomeView.swift + ├── SessionView.swift ← Фазы: loading/intro/dialogue/closing/oneLastThing + ├── CardView.swift + ├── SettingsView.swift ← AI backend + Сбросить данные + └── OrbCharacter.swift +``` + +--- + +## Архитектура флоу онбординга + +``` +Первый запуск + → PrivacyIntroView (privacy_accepted) + → OnboardingView (5 вопросов + AI инсайт → profile.md) + → HomeView + → SessionView (loading → intro → dialogue → closing → oneLastThing → done) +``` + +--- + +## On-device хранилище + +``` +Documents/UserProfile/ +├── profile.md ← паттерны, наблюдения, счётчик сессий +├── insights.md ← кристаллизованные инсайты +└── sessions/ + ├── index.md ← таблица дат/тем для быстрого поиска + └── 2026-05-19.md +``` + +**Сброс:** Settings → Данные → Сбросить все данные → удаляет `UserProfile/`, сбрасывает `privacy_accepted`, `onboarding_completed`, API key. + +--- + +## Локальный AI прокси ```bash -# claude-code — OAuth через Hermes (нет API ключа, читает CLAUDE_CODE_OAUTH_TOKEN из ~/.hermes/.env) -cd ~/Developer/reflect-app -python3 proxy_server.py --backend claude-code - -# Ollama — локальная модель +cd ~/Developer/reflect-app # iOS проект +python3 proxy_server.py --backend claude-code # OAuth через Hermes +# или python3 proxy_server.py --backend ollama --ollama-model qwen2.5:7b - -# Claude API key -export ANTHROPIC_API_KEY=sk-ant-... -python3 proxy_server.py --backend claude ``` -**claude-code бэкенд** — приоритет источников токена: -1. `CLAUDE_CODE_OAUTH_TOKEN` из `~/.hermes/.env` (Hermes-managed, наиболее надёжный) -2. macOS Keychain (`Claude Code-credentials`) -3. `~/.claude/.credentials.json` (с авто-обновлением если есть refreshToken) - -### Настройка на iPhone - -1. Открыть приложение → шестерёнка (Settings) -2. AI Backend → "Локальный сервер (Mac)" -3. URL сервера: скопировать из вывода `proxy_server.py` -4. Нажать "Проверить" — должно показать "Доступен · qwen2.5:7b" - -### Архитектура proxy - -``` -iPhone (ClaudeService) - → POST /v1/messages → proxy_server.py (8765) - → Anthropic API format → Ollama (11434) - ← response ← Ollama response (конвертируется в Anthropic формат) - ← response ← proxy_server.py -``` +В Settings приложения: AI Backend → Локальный сервер → URL сервера. --- @@ -131,106 +289,13 @@ CrisisDetector.detect() — hardcoded, <5ms ↓ (если .critical → CrisisView) NarratorBot (Haiku) — переформулировка в 3-е лицо ↓ -AnalystBot (Sonnet) — читает profile.md + вопросы +AnalystBot (Sonnet) — читает profile.md + инструменты (search_sessions, read_profile...) ↓ -CardView с вопросами +1 вопрос из батча → ответ → следующий вопрос → нумерованная подбивка → AnalystBot ``` ---- - -## On-device хранилище (Obsidian-like) - -``` -Documents/UserProfile/ -├── profile.md ← паттерны, наблюдения, прогресс -├── insights.md ← кристаллизованные инсайты -└── sessions/ - ├── 2026-05-16.md - └── ... -``` - ---- - -## App Lock (Face ID / Touch ID / Passcode) - -**Файлы:** -- `Services/AppLockService.swift` — `@MainActor ObservableObject`, `LAContext`, состояние -- `Views/LockView.swift` — экран блокировки, автоматически запускает биометрию при появлении -- `ReflectApp.swift` — `checkOnLaunch()` при старте, `scenePhase.background → lock()` -- `SettingsView.swift` — секция "Безопасность" с Toggle - -**Поведение:** -- Включение/выключение требует подтверждения биометрией -- При уходе в фон (`scenePhase == .background`) — блокируется -- При возврате — LockView показывается поверх всего, сразу запрашивает биометрию -- Симулятор: биометрия эмулируется через Features → Face ID → Enrolled - ---- - -## Голосовой ввод - -**`Services/VoiceInputService.swift`** — `@MainActor ObservableObject`: -- `AVAudioEngine` + `SFSpeechRecognizer` (locale: ru-RU с fallback на системный) -- Push-to-talk: `startRecording()` / `stopRecording()` -- `onTranscript: ((String) -> Void)?` — коллбэк с финальным текстом -- `audioLevel: Float` — уровень звука в реальном времени (→ орб анимация) -- `requestPermissions()` — запрашивает mic + speech recognition - ---- - -## Адаптивное расписание (ScheduleNegotiationService) - -**`Services/ScheduleNegotiationService.swift`**: -- `nextSessionDate` — следующая запланированная сессия (UserDefaults) -- `buildSuggestion()` → предлагает время на основе паттерна прошлых встреч -- `acceptSchedule(date)` → сохраняет и ставит разовое `UNCalendarNotificationTrigger` -- `postpone(by: PostponeOption)` → переносит на +1ч / +3ч / завтра / skip -- Уведомление содержит action-кнопки прямо из шторы (без открытия приложения) - -**Флоу в конце сессии:** -1. Агент завершает сессию → `SessionManager.endSession()` → `clearSchedule()` -2. `SessionViewModel.endSession()` → показывает `CardData` с предложением времени -3. Пользователь: "Да, подходит" / "Другое время" / "Без расписания" -4. При выборе времени → `ScheduleNegotiationService.acceptSchedule(date)` → уведомление - -**HomeView:** -- Если есть расписание → показывает badge "Следующая: пятница 20:00" + кнопка "Перенести" -- Если нет расписания → "Назначить следующую встречу" (кнопка открывает `PostponeSheetView`) - ---- - -## Тулинг агента (AnalystBot) - -Аналитик использует tool-use loop (`ClaudeService.completeWithTools`). - -**4 инструмента:** -| Tool | Описание | -|------|----------| -| `get_index` | Индекс сессий — таблица дат/тем/тегов, < 1K токенов | -| `search_sessions` | Полнотекстовый поиск по архиву | -| `read_session` | Прочитать полную сессию по дате | -| `read_profile` | Прочитать profile.md | - -Агент сам решает когда обращаться к хранилищу — когда видит паттерн или нужен контекст прошлых встреч. - ---- - -## Session Index - -**`ProfileManager.updateSessionIndex()`** обновляет `sessions/index.md`: -``` -| Дата | Темы | Теги | Содержание | -|------------|------------------------|-------------------|-------------------| -| 2026-05-16 | работа, усталость | #сессия #инсайт | Говорит что устал | -``` -Заполняется автоматически при `endSession()`. Агент читает через `get_index` — без засорения контекста полными файлами. - ---- - -## Тест-результаты (2026-05-16) - -- ClaudeServiceTests: 4/4 -- CrisisDetectorTests: 17/17 (включая RU+EN, нормализация) -- ProfileManagerTests: 6/6 (включая onboarding init) -- SessionSchedulerTests: 3/3 -- **ИТОГО: 30/30, 0 failures** +**Закрытие сессии:** +1. `endSession()` → AnalystBot генерирует наблюдение (3-е лицо) +2. NarratorBot генерирует тёплую closing phrase +3. One last thing: маленький input +4. Если нет расписания → предложить дату следующей сессии