모바일 앱
conduit는 iOS·Android용 AI 채팅 앱입니다. Open WebUI에 로그인해 서버 기능을 이용하거나, Direct 연결로 모델 API에 바로 요청하거나, Hermes 서버의 에이전트와 대화할 수 있습니다. 이 저장소에서 배울 핵심은 서로 다른 AI 서비스를 하나의 모바일 경험으로 묶는 방법입니다.
모델 서버가 방송국이라면 conduit는 수신기입니다. 방송국을 바꾸어도 화면과 조작은 익숙하게 유지됩니다. 다만 방송국마다 제공하는 기능과 신호 형식이 달라, 내부에는 이를 해석하는 연결 부품이 필요합니다.
수신기에 최근 청취 목록이 남는다고 방송국까지 수신기 안에 들어 있는 것은 아닙니다. 마찬가지로 대화를 로컬에 저장하는 일과 모델을 로컬에서 실행하는 일은 별개입니다.
| 연결 | 실제 상대 | 여기서 공부할 것 |
|---|---|---|
| Open WebUI | 사용자가 운영하거나 접근 권한을 가진 서버 | 인증, 서버 권한, 대화 동기화, HTTP·소켓 전환 |
| Direct | OpenAI 호환 API, Ollama, OpenRouter 등 | 공통 인터페이스와 제공자별 프로토콜 변환 |
| Hermes | 사용자가 연결한 에이전트 서버 | 실행 이벤트, 도구 승인, 일정과 서버 기능 확인 |
README에는 Apple On-Device와 Apple Private Cloud Compute(PCC)도 Direct 제공자로 소개되어 있습니다. 일반 iOS 설치 조건과 별도로 OS·기기·권한 조건이 붙습니다. 따라서 모든 iPhone에서 오프라인 모델이나 PCC를 쓸 수 있다고 읽으면 안 됩니다.
이 순서는 이해를 위한 요약입니다. 구현에는 임시 대화, 여러 모델 비교, 중단·재개, 첨부 업로드와 계정 전환 같은 분기가 더 있습니다.
처음부터 모든 분기를 읽기보다 DirectProviderAdapter처럼 작은 경계부터 시작하는 편이 좋습니다.
2026-10-23 GitHub Trending Dart Monthly #10에 선정되었고, 같은 날 스타는 2,361개입니다. 순위만으로 유입 원인을 확정할 수는 없습니다. 아래는 README와 코드에서 드러나는 관심을 끌 만한 제품적 이유에 대한 해석입니다.
데스크톱에서 잘 쓰던 AI 서버라도 모바일에서는 키보드, 사진 첨부, 공유 메뉴, 화면 잠금, 프록시 인증이 문제가 됩니다.
README는 이러한 모바일 사용의 빈틈을 출발점으로 설명합니다. 실제로 lib/platform/에는 공유 수신, 홈 위젯,
앱 생명주기, 백그라운드 실행을 담당하는 구현이 따로 있습니다.
모델을 더 똑똑하게 만드는 대신, 이미 있는 모델을 매일 쓰기 편하게 만드는 데 집중한 프로젝트입니다.
Direct의 OpenAiCompatibleAdapter와 OllamaAdapter는 같은 인터페이스를 구현합니다.
Open WebUI 계정 없이도 Direct 연결을 구성할 수 있다는 README의 설명과 맞닿아 있습니다.
다만 노트·채널·터미널 같은 서버 기능까지 모든 연결에 똑같이 생기는 것은 아닙니다.
| 접근 방식 | 강점 | conduit가 더하는 것 / 선택의 대가 |
|---|---|---|
| 브라우저의 Open WebUI | 서버의 웹 UI를 바로 사용 | conduit는 공유·위젯·플랫폼 UI를 앱에 연결함. 대신 서버 API 변화도 따라가야 함 |
| 특정 서비스 전용 앱 | 그 서비스에 맞춘 일관된 기능 | 여러 서버·제공자를 고를 수 있음. 대신 제공자별 기능 차이를 이해해야 함 |
| 직접 만든 API 데모 | 요청과 응답 구조를 빨리 실험 | 저장, 재시도, 계정 분리, 렌더링까지 실제 앱의 주변 문제를 배울 수 있음 |
이 표는 설계 방향의 비교입니다. 응답 속도·배터리·장애율의 측정 결과나 개별 경쟁 앱의 기능 순위는 아닙니다.
packages/conduit_core/lib/database/에는 대화·메시지·노트·동기화 작업을 저장하는 테이블이 있습니다.
packages/conduit_core/lib/sync/outbox_drainer.dart는 보류한 작업을 다시 처리합니다.
네트워크 요청 함수 하나를 넘어, 이동 중 연결이 불안정해지는 상황을 데이터 모델에 반영한 점이 좋은 학습 재료입니다.
인증, 스트리밍, DB, UI가 각각 존재한다는 사실보다, 이들 사이에 어떤 값을 넘기고 누가 수명을 책임지는지에 주목하세요. 작은 채팅 데모가 제품으로 커질 때 필요한 사고가 그 경계에 드러납니다.
루트 pubspec.yaml의 앱 버전은 4.2.1+152입니다.
Dart SDK 조건은 >=3.13.0 <4.0.0, Flutter 조건은 >=3.47.0입니다.
아래의 “선언”은 pubspec.yaml, “잠금”은 pubspec.lock 기준입니다.
^5.9.0은 호환 범위의 업데이트를 허용하는 선언입니다.
실제로 선택된 버전은 잠금 파일에 기록됩니다. 이 저장소의 Dio는 선언이 ^5.9.0이고 잠금은 5.11.1입니다.
“무엇을 허용했는가”와 “무엇으로 묶였는가”를 나누어 봅니다.| 역할 | 선언 → 잠금 | 코드에서 만나는 곳 |
|---|---|---|
| 상태 관리 | flutter_riverpod ^3.4.3 → 3.4.3riverpod ^3.4.3 → 3.4.3 | 화면과 순수 Dart 로직이 같은 제공자 그래프를 사용 |
| 화면 이동 | go_router ^18.0.1 → 18.0.1 | lib/core/router/app_router.dart |
| Markdown | markdown ^7.3.0 → 7.3.1 | 공통 파서와 Flutter 렌더러를 분리 |
| 문서·미디어 | pdfrx ^2.4.7, flutter_tex ^5.2.6 — 선언값 | PDF·수식 등을 채팅 내용에 표시 |
| 플랫폼 저장소 | flutter_secure_storage ^11.1.1 → 11.1.1 | 키·토큰 저장 기능을 플랫폼 어댑터로 제공 |
material_ui ^1.0.0, cupertino_ui ^1.0.0도 선언되어 있습니다.
Material·Cupertino와 네이티브 브리지를 사용하는 앱이지, 웹사이트 전체를 WebView 하나에 넣은 구조는 아닙니다.
다만 Chart.js 등 일부 콘텐츠에는 WebView가 사용됩니다.
packages/conduit_core는 Flutter에 의존하지 않는 Dart 패키지입니다.
인증, API, 채팅 상태, DB, 동기화, Direct 연결을 담당합니다.
이름에 core가 붙었다고 서버가 되는 것은 아닙니다. 화면과 분리된 로직도 휴대전화 안에서 실행되는 클라이언트 코드일 수 있습니다.
| 역할 | 선언 → 잠금 | 실제 책임 |
|---|---|---|
| HTTP | dio ^5.9.0 → 5.11.1 | 요청, 인증 인터셉터, 스트림 응답, 취소 |
| 모델 API | openai_dart ^8.1.0 → 8.1.0ollama_dart ^2.4.0 → 2.6.1 | 제공자 모델·형식 지원. 어댑터에는 직접 처리하는 전송·파싱도 존재 |
| 소켓 | socket_io_client ^3.1.2 → 3.1.6 | Open WebUI 이벤트 수신 |
| 도구 연결 | mcp_dart ^2.4.0 → 2.4.2 | Direct 모델의 MCP 도구 연결 관련 구현 |
| 관계형 저장 | drift ^2.34.2 → 2.35.0drift_flutter ^0.3.1 → 0.3.1 | SQLite 스키마·질의와 Flutter의 파일 열기 분리 |
| 설정·이전 저장층 | hive_ce ^2.14.0 → 2.20.0shared_preferences ^2.3.2 → 2.5.5 | 설정과 기존 데이터 이전 관련 경로. 대화 저장 전체를 Hive라고 설명하면 부정확 |
| 패키지 | 역할 | 넘지 않아야 할 경계 |
|---|---|---|
| conduit_core | 인증·전송·DB·동기화·채팅 상태 | Flutter 플러그인을 직접 붙이지 않고 포트를 이용 |
| conduit_markdown | 전처리, 인용·추론·도구 호출 파싱 | 브라우저용 JS로도 컴파일 가능한 순수 Dart |
| conduit_theme | 팔레트와 테마 레지스트리 | 화면 프레임워크와 색상 데이터를 분리 |
| conduit_ddgs | 공개 검색엔진에 대한 기기 측 검색 | 검색이 로컬에서 시작되어도 외부 엔진에 질의가 전송됨 |
네 패키지의 선언 버전은 모두 0.1.0이고 publish_to: 'none',
resolution: workspace를 사용합니다. 루트의 workspace: 목록과 하나의 잠금 파일로 의존성을 함께 해석합니다.
| 항목 | 소스의 설정값 | 학습 포인트 |
|---|---|---|
| Android | AGP 9.2.1 · Kotlin 플러그인 2.4.0 · Gradle 9.4.1 · compileSdk 37 | android/settings.gradle.kts, Gradle wrapper, 앱 빌드 설정을 함께 읽기 |
| Android 네이티브 | Java 17 · NDK 29.0.14206865 · ARM ABI | 기본 비분할 빌드는 armeabi-v7a·arm64-v8a로 제한 |
| iOS | 배포 대상 16.0 · Swift Package Manager 활성화 | Xcode 프로젝트 설정과 Flutter 설정을 함께 확인 |
| 코드 생성 | build_runner ^2.15.1 → 2.16.1freezed ^4.0.1 → 4.0.1 | 모델·직렬화·DB·제공자 코드를 생성 |
| 제공자 생성 | riverpod_generator ^4.0.9 → 4.0.9 | 선언을 실제 제공자 코드로 연결 |
| 호환성 예외 | record: 7.1.1, objective_c: 9.6.0 강제 지정 | 루트의 dependency_overrides와 사유 주석을 함께 읽기 |
.github/workflows/ci.yml은 Flutter stable을 사용하며 SDK 버전을 고정하지 않습니다.
모바일 분석·테스트, Android 네이티브 테스트, 패키지 검사, 웹 안전성, 번역 검사가 나뉩니다.
모바일 분석은 --no-fatal-warnings --no-fatal-infos이고 공통 패키지는 --fatal-infos입니다.
“CI가 있다”에서 멈추지 말고 어떤 진단이 실제 실패 조건인지 읽어야 합니다.
.github/workflows/release.yml은 v* 태그에서 릴리스 노트를 검증하고 Android APK·AAB와 iOS 빌드를 처리합니다.
모델을 올리는 Docker 배포가 이 저장소의 주된 배포 단위는 아닙니다.
docs/BUILDING.md에는 AGP 9.1.0, Gradle 9.3.1, Android SDK 36이 적혀 있습니다.
커밋 3f95de1의 실제 설정은 각각 9.2.1, 9.4.1, compileSdk 37입니다.
또한 안내의 디렉토리 설명 중 lib/core/database 등은 공통 패키지로 이동한 현재 위치와 다릅니다.
도식의 연결은 선택 가능한 경로입니다. 모든 메시지가 Open WebUI를 거쳐 Direct 제공자로 가는 구조가 아닙니다. 또 추론 요청의 목적지와 대화 기록의 동기화 목적지는 서로 다를 수 있습니다.
packages/conduit_core/lib/ports/에는 플랫폼 기능의 계약이 있습니다.
예를 들어 DatabaseOpenerPort는 DB 실행기를 여는 역할을 맡고,
SecureKeyValueStore는 키·값 읽기와 쓰기 역할을 정의합니다.
Flutter 구현은 lib/platform/flutter_database_opener.dart와 lib/platform/flutter_secure_key_value_store.dart에 놓입니다.
lib/main.dart의 main()은 ProviderContainer를 만들 때 실제 구현을 주입합니다.
다음은 그중 DB와 갱신 스케줄러 부분의 발췌입니다.
// lib/main.dart의 ProviderContainer 설정 중 발췌
final providerContainer = ProviderContainer(
overrides: [
databaseOpenerProvider.overrideWithValue(
const FlutterDatabaseOpener(),
),
flushSchedulerProvider.overrideWithValue(
const FlutterFlushScheduler(),
),
],
);
로직은 “DB를 열 수 있는 대상”을 요구할 뿐, 앱 문서 디렉토리를 얻는 Flutter 플러그인을 직접 알 필요가 없습니다.
실제 FlutterDatabaseOpener.open()이 driftDatabase()와
getApplicationSupportDirectory를 연결합니다. 테스트에서는 이 계약을 가짜 구현으로 바꿀 수 있습니다.
전자제품이 콘센트 규격만 알면 되는 것과 같습니다. 발전소의 종류까지 제품 안에 박아 넣지 않습니다. 포트는 콘센트 규격이고, 플랫폼 어댑터는 실제 전원을 연결하는 부품입니다.
패키지 주석에는 conduitd나 데스크톱 호스트의 재사용 의도도 등장합니다.
그러나 이 커밋의 루트에는 해당 앱 디렉토리가 없습니다. 확장을 위한 계약이 있다는 사실과 완성된 데스크톱 제품이 있다는 주장은 구분해야 합니다.
packages/conduit_core/lib/features/direct_connections/services/direct_provider_adapter.dart의 계약은 작습니다.
아래 코드는 메서드 선언을 그대로 옮기고 주석을 줄인 것입니다.
abstract interface class DirectProviderAdapter {
String get key;
Future<DirectConnectionProbe> probe(
DirectConnectionProfile profile,
);
Future<List<DirectRemoteModel>> listModels(
DirectConnectionProfile profile,
);
DirectCompletionRun startCompletion(
DirectConnectionProfile profile,
DirectCompletionRequest request,
);
}
OpenAiCompatibleAdapter와 OllamaAdapter는 이 계약을 구현합니다.
DirectProviderAdapterRegistry.register()는 문자열 키로 구현을 등록하며,
앞뒤 공백을 제거한 뒤 빈 키·내부 공백이 든 키·기본적으로 중복인 키를 거절합니다. require()는 등록되지 않은 키에 오류를 냅니다.
프로필은 구현 클래스 이름 대신 문자열 키를 저장합니다. 새 어댑터를 등록하는 확장은 프로필 스키마 변경을 줄여 줍니다. 여기서 전략 패턴은 “어떤 방식으로 모델과 통신할지 갈아 끼우는 것”, 레지스트리는 “그 구현을 키로 찾는 명부”입니다.
| 호출 | 중요한 계약 | 초보자가 놓치기 쉬운 점 |
|---|---|---|
| probe() | 실제 연결 가능 여부를 제공자 방식으로 검증 | 모델 ID를 수동 입력했어도 살아 있는 연결인지 확인해야 함 |
| listModels() | 선택 가능한 모델 목록 제공 | directManualModels()는 수동 목록이 있으면 탐색 네트워크를 생략 |
| startCompletion() | 공통 요청을 제공자 형식으로 바꾸고 실행 반환 | 공통 계약이 모든 모델의 이미지·도구 지원을 보장하지는 않음 |
packages/conduit_core/lib/features/direct_connections/models/direct_completion.dart의 DirectCompletionRequest는 모델 ID, 메시지,
생성 매개변수를 모읍니다. DirectTextPart·DirectImagePart·DirectFilePart처럼
콘텐츠 종류를 타입으로 나누므로, 화면이 공급자마다 다른 JSON을 직접 만들 필요를 줄일 수 있습니다.
먼저 packages/conduit_core/lib/services/chat_completion_transport.dart의
ChatCompletionTransport는 httpStream, taskSocket,
jsonCompletion을 구분합니다. ChatCompletionSession은 바이트 스트림,
작업 ID, JSON 결과, 중단 함수를 전송 모드에 맞게 담습니다.
이는 README의 “WebSocket 스트리밍”이라는 소개보다 넓은 구현입니다. Open WebUI 응답에 따라 HTTP 스트림이나 JSON 경로도 있으며 Direct에는 별도의 제공자 스트림이 있습니다. 따라서 conduit의 모든 답변이 WebSocket으로 온다고 설명하면 틀립니다.
packages/conduit_core/lib/services/sse_frame_scanner.dart의 SseFrameScanner.addChunk()는
문자열 조각을 받아 빈 줄로 끝난 SSE 프레임을 내보냅니다. 여러 data: 줄은 줄바꿈으로 합치며,
조각 사이에서 끊긴 CRLF도 처리합니다. 네트워크 조각 하나와 JSON 하나가 일치한다고 가정하지 않습니다.
// 학습용 예시: 두 번 도착한 조각이 이벤트 하나가 된다
final scanner = SseFrameScanner();
final first = scanner.addChunk('data: {"x":').toList();
final second = scanner.addChunk('1}\n\n').toList();
// first.length == 0
// second.single.data == '{"x":1}'
// 실제 바이트의 UTF-8 해독은 이 스캐너에 들어오기 전의 책임
기본 한도는 한 줄과 한 프레임의 데이터 각각 4 * 1024 * 1024 UTF-16 코드 단위입니다.
바이트 한도라고 읽지 마세요. close()는 빈 줄로 끝나지 않은 마지막 이벤트를 버립니다.
잘린 JSON을 완성된 데이터처럼 넘기지 않기 위한 경계입니다.
packages/conduit_core/lib/features/chat/providers/chat_messages_notifier.dart의
_scheduleStreamingContentFrame()은 이미 프레임이 예약되었는지 확인합니다.
_flushStreamingContentUpdate()는 버퍼 버전과 마지막으로 반영한 버전을 비교한 뒤
streamingContentProvider를 갱신합니다.
FlutterFlushScheduler.scheduleFlush()는
SchedulerBinding.instance.scheduleFrameCallback()을 씁니다.
프레임 끝의 콜백이 아니라 프레임 시작에 갱신을 붙여 같은 프레임에서 화면 변경을 처리하려는 선택입니다.
백그라운드에서는 보이는 프레임을 기다리지 않고 반영하는 분기도 있습니다.
데이터를 빠짐없이 받는 일과 화면을 매번 갱신하는 일은 서로 다른 문제로 설계해야 합니다.
packages/conduit_core/lib/database/tables/outbox.dart의 OutboxOps는 단순 실패 로그가 아닙니다.
작업 종류, 대상 대화, payload, 상태, 시도 횟수, 다음 시각, 마지막 오류를 가진 앱 재시작 후에도 남는 작업 목록입니다.
상태 제약은 pending·inFlight·failed, 시도 횟수 제약은 attempts >= 0입니다.
packages/conduit_core/lib/database/daos/chats_dao.dart의 insertLocalChatWithCreateOp()와
appendMessagesWithUpdateOp()는 데이터 쓰기와 필요한 큐 기록을 같은 트랜잭션에 묶습니다.
둘 중 하나만 남으면 “화면에는 있는데 보낼 일이 없음” 또는 “보낼 일은 있는데 데이터가 없음”이 생기기 때문입니다.
packages/conduit_core/lib/sync/outbox_drainer.dart의 OutboxDrainer는 작업자 풀 크기 2를 사용합니다.
requestCompletion은 실패 횟수가 5에 도달하면 실패 상태로 보류해 수동 재시도 대상으로 만듭니다.
오프라인이나 일시 보류(OutboxDeferralException)는 이 실패 횟수를 늘리지 않습니다.
순수 동기화 작업은 같은 횟수 제한을 일괄 적용하지 않습니다.
// packages/conduit_core/lib/sync/backoff.dart의 기본 설정과 재시도 창
baseMs = 2000
capMs = 300000
window = min(capMs, baseMs * 2^attempt)
delay = floor(window * jitter)
// attempt 0: 0 이상 2초 미만
// attempt 1: 0 이상 4초 미만
// 상한에 도달하면: 0 이상 5분 미만
위 식은 Dart 코드가 아닌 계산 설명입니다. Backoff.delayMsForAttempt()는 무작위 함수를 주입받아 테스트 가능하게 합니다.
실제 drainer는 지연을 초로 변환하고 최소 1초 뒤로 예약합니다. 지연이 0인 작업을 같은 처리 순환에서 계속 잡는 일을 피합니다.
packages/conduit_core/lib/database/chat_database_repository.dart의 persistNewDirectChat()와
persistDirectMessages()도 읽어 볼 만합니다. Direct 기록을 Open WebUI에 동기화하더라도
requestCompletion을 다시 넣지 않습니다. 이미 모델 제공자가 수행한 생성을 서버에서 중복 실행하지 않기 위해서입니다.
포트는 플랫폼 책임을, 어댑터는 프로토콜 책임을, 버퍼는 갱신 시점을, Outbox는 재시도할 일을 분리합니다. 이 네 경계를 이해하면 거대한 채팅 파일을 한 줄씩 처음부터 읽지 않아도 전체 설계에 접근할 수 있습니다.
lib/features/와 packages/conduit_core/lib/features/에 비슷한 기능 이름이 반복됩니다.
중복 기능이라고 바로 판단하지 마세요. 앞쪽은 Flutter 표현을, 뒤쪽은 플랫폼과 분리된 상태·서비스를 찾는 출발점입니다.
| 궁금한 것 | 먼저 열 파일 | 찾아볼 식별자 |
|---|---|---|
| 연결 종류 확장 | packages/conduit_core/lib/features/direct_connections/services/direct_provider_adapter.dart | DirectProviderAdapterRegistry |
| 대화 저장 위치 | packages/conduit_core/lib/database/chat_database_repository.dart | ChatStorageKind, DirectChatSyncPreference |
| 응답 조각 처리 | packages/conduit_core/lib/services/sse_frame_scanner.dart | addChunk(), close() |
| 화면 갱신 시점 | lib/platform/flutter_flush_scheduler.dart | scheduleFlush() |
| DB 업그레이드 | packages/conduit_core/lib/database/app_database.dart | schemaVersion, migration |
| 범위 | Dart 파일 / 물리적 줄 수 | 읽는 방법 |
|---|---|---|
| 앱 lib/ | 447개 / 187,853줄 | 화면·플랫폼 구현 중심 |
| packages/conduit_core/lib/ | 547개 / 197,701줄 | 핵심 상태·전송·저장 중심 |
| 공통 작은 패키지 | Markdown 3,239줄 · 테마 610줄 · 검색 1,014줄 | 한 주제를 작게 끝내며 읽기 좋음 |
| 전체 Dart | 1,705개 / 722,962줄 | 도구·테스트·입력 계약 등 포함 |
| 이름이 _test.dart인 파일 | 653개 / 324,963줄 | 성공 경로보다 경계 조건을 먼저 찾기 |
커밋 3f95de1에서 Git이 추적하는 실제 파일의 물리적 행 수입니다. 마지막 개행이 없는 행도 한 줄로 셉니다. 빈 줄·주석을 포함하며 테스트도 포함합니다. 서브모듈 내부와 생성 시 생기는 파일은 제외합니다. Swift는 32개·29,655줄, Kotlin은 21개·12,060줄입니다. 줄 수가 곧 기능 수나 품질 점수는 아닙니다.
*.g.dart·*.freezed.dart는 생성 단계가 필요합니다..gitmodules와 docs/BUILDING.md를 함께 보면 역할 차이가 분명해집니다.
Mermaid·KaTeX는 빌드에 필요한 자산이고, openwebui-src·hermes-src는 API 참조용입니다.
참조 저장소를 포함한다고 그 서버가 앱과 함께 실행되거나 배포되는 것은 아닙니다.
DirectCompletionRequest는 메시지 목록과 매개변수를 수정 불가능한 컬렉션으로 보관하고,
빈 모델 ID를 거절합니다. “누구나 어떤 값이나 넣을 수 있는 Map”보다 잘못된 상태를 줄이는 모델링입니다.
Future<T>는 나중에 한 번 얻을 결과, Stream<T>은 여러 번 들어오는 값의 흐름입니다.
모델 목록은 전자에, 연속 답변 조각은 후자에 어울립니다. async*·yield·await for를
packages/conduit_core/lib/features/direct_connections/services/ollama_stream_parser.dart에서 함께 찾아보세요.
sealed class DirectContentPart는 텍스트·이미지 등 허용된 하위 타입을 한곳에서 정리합니다.
“이 데이터는 어떤 경우가 가능한가”를 코드로 드러내는 방식입니다.작은 연습: 텍스트와 이미지만 받는 메시지 모델을 설계해 보세요.
이미지 URL을 일반 문자열에 섞는 설계와 DirectImagePart로 분리한 설계를 비교하면 검증 위치가 달라집니다.
ref.watch()는 의존하는 값의 변화를 구독하고, ref.read()는 해당 시점의 값을 읽는 데 사용됩니다.
conduit에서는 화면 상태뿐 아니라 DB 열기·워커·생명주기 같은 기능도 제공자로 연결합니다.
관련 개념은 Riverpod 공식 시작 문서에서 확인할 수 있습니다.
lib/main.dart에서 “무엇을 어떤 구현으로 바꾸는가”를 표로 적어 보세요.
databaseOpenerProvider와 flushSchedulerProvider 두 개만 따라가도 앱 시작과 테스트 대체의 관계가 보입니다.
작은 연습: 실제 프레임 대신 콜백 목록을 저장하는 가짜 FlushScheduler를 구상하세요.
콜백 실행 전에는 상태가 유지되고, 실행 뒤에만 갱신되는지 설명할 수 있으면 의존성 주입의 이점을 이해한 것입니다.
SSE는 빈 줄로 프레임을 나누고 Ollama의 전용 스트림은 줄 단위 JSON을 처리합니다.
두 방식 모두 네트워크의 수신 조각과 논리적 데이터의 경계가 다릅니다.
SseFrameScanner와 parseOllamaNdjson()을 비교해 경계가 어디서 확정되는지 찾아보세요.
packages/conduit_core/lib/services/streaming_response_controller.dart의 StreamingResponseController.cancel()은
중복 취소를 막고 구독을 해제합니다. 반면 서버 작업 중단은
packages/conduit_core/lib/features/chat/services/chat_transport_dispatch.dart의 stopActiveTransport()가
전송 메타데이터를 보고 HTTP 취소와 작업 중단 경로를 선택합니다.
작은 연습: “화면이 더 이상 구독하지 않음”과 “서버가 생성을 멈춤”을 각각 화살표로 그려 보세요. 이 둘을 한 동작으로 가정하면 화면을 닫아도 서버 비용이 계속 생기는 상황을 놓칠 수 있습니다.
AppDatabase.schemaVersion은 10입니다. 같은 파일의 설명 주석 일부에는 버전 8이 남아 있습니다.
실제 마이그레이션 분기는 9·10까지 이어지므로, 현재 동작을 읽을 때는 실행되는 getter와 분기를 우선 확인합니다.
packages/conduit_core/lib/database/fts/fts_query.dart의 toFtsMatchQuery()는 사용자의 검색어를 공백 단위로 나누고
따옴표로 감싼 접두 검색어로 바꿉니다. 검색 문자열을 SQL 매개변수로 넘기는 일과 FTS 검색 문법을 안전하게 구성하는 일은 별개입니다.
// toFtsMatchQuery()의 결과를 이해하는 예시
입력: flutter stream
출력: "flutter"* "stream"*
입력: AND
출력: "AND"*
// 빈 입력은 빈 문자열. 호출자는 검색을 생략해야 한다.
이것은 전용 한국어 형태소 분석기라는 뜻이 아닙니다.
packages/conduit_core/lib/database/fts/fts_ddl.dart의 토크나이저는 unicode61 remove_diacritics 2입니다.
한글의 조사·띄어쓰기까지 기대한 대로 찾는지는 별도의 검색 사례로 검토해야 합니다.
작은 연습: 빈 검색어, 따옴표가 든 검색어, AND만 있는 검색어의 예상 결과를 적어 보세요.
관련 DB 개념은 Drift 공식 문서의 테이블·트랜잭션·마이그레이션 순서로 읽으면 좋습니다.
packages/conduit_core/lib/auth/api_auth_interceptor.dart의 ApiAuthInterceptor.onRequest()는
대상이 선택한 서버와 같은 origin이거나 같은 호스트의 HTTP:80 → HTTPS:443 전환인지 확인하고,
이 조건을 벗어나면 서버 자격 증명을 제거합니다.
보안 저장소에 넣었다는 사실만으로 다른 서버에 잘못 전송하는 문제까지 해결되지는 않습니다.
DirectConnectionProfile.secureUpdate()는 origin이 바뀌고 새 비밀 값의 확인이 없으면
withoutSecrets()로 비밀 값을 지웁니다.
DirectConnectionProfileStore.upsert()는 변경을 직렬화하며, expectedPrevious를 전달한 경우 저장된 값과 비교해 동시 편집 충돌을 감지합니다.
열쇠를 금고에 보관하는 일과 올바른 문에만 사용하는 일은 다릅니다. 보안 저장소는 보관 문제를, origin 검사와 프로필 갱신 규칙은 사용 대상 문제를 다룹니다.
lib/shared/widgets/markdown/markdown_compile_service.dart에는 동기 컴파일 임계값 384,
전처리 임계값 768(둘 다 문자열 길이의 UTF-16 코드 단위), 워커 유휴 정리 시간 30초가 있습니다.
MarkdownCompileService.handleMemoryPressure()는 캐시를 비우고 유휴 워커 정리를 요청합니다.
이 수치를 모든 앱의 정답으로 외우기보다 작은 입력과 긴 입력을 다르게 다루는 이유를 배웁니다.
l10n.yaml은 영어 ARB를 템플릿으로 두고 한국어 ko를 포함한 14개 로케일을 나열합니다.
lib/l10n/app_ko.arb는 번역 입력입니다. 생성된 Dart를 손으로 수정하는 대신 ARB와 자리표시자 계약을 관리합니다.
좋은 학습 단위는 파일 전체가 아니라 입력·출력·실패 조건을 함께 설명할 수 있는 작은 계약입니다.
작은 연습: packages/conduit_core/test/services/sse_frame_scanner_test.dart를 읽고
각 테스트를 “상황 → 기대 결과” 한 줄로 번역해 보세요. 테스트 개수나 줄 수만으로 커버리지가 충분하다고 단정하지는 않습니다.
| 대상 | 확인되는 조건 | 해석 |
|---|---|---|
| iOS 기본 앱 | Xcode 프로젝트의 배포 대상 16.0 | Apple 모델 기능의 요구 OS와는 별도 |
| Android 기본 앱 | 빌드 안내는 Android 7.0+ / API 24 | 실제 minSdk는 flutter.minSdkVersion에 위임. 최종 병합 설정은 SDK·플러그인 영향도 받음 |
| CPU·RAM | 일반 앱의 최소 코어 수·RAM 수치가 명시되어 있지 않음 | 긴 대화·문서·차트·음성 사용량에 따라 메모리 부담이 달라짐 |
| 모델 연결 | 접근 가능한 Open WebUI·API·Ollama·Hermes 중 선택 | 원격 추론일 때 휴대전화에 별도 추론용 GPU는 필요하지 않음 |
| 기기 권한 | 마이크·카메라·사진·위치 등 기능별 권한 | 권한을 거절하면 해당 기능의 대체 동작도 확인해야 함 |
README와 docs/BUILDING.md 기준으로 On-Device는 iOS 26과 Apple Intelligence 지원 기기가 필요하며,
개발에는 Xcode 26을 요구합니다. 로컬 모델 문맥 창은 4K로 설명되어 있고 이미지·추론 제어·도구 호출은 지원하지 않는다고 적혀 있습니다.
같은 문서에서 PCC는 iOS 27, Xcode 27, Apple Intelligence와 Apple의 관리형 PCC entitlement를 추가 조건으로 제시합니다. 오래된 SDK에서는 PCC를 컴파일에서 제외하면서 기본 배포 대상은 iOS 16으로 유지한다고 설명합니다. 이 조건은 저장소의 기능 안내이며 기기별 실제 사용 가능 여부나 권한 발급을 보장하지 않습니다.
| 환경 | 필요한 것 | 초보자에게 중요한 점 |
|---|---|---|
| 공통 | Flutter 3.47.0 이상 · Dart 3.13.0 이상 | 서브모듈과 생성 코드가 빠지면 소스가 있어도 바로 빌드되지 않음 |
| Android 개발 | Java 17 · compileSdk 37 · Gradle/AGP 도구 모음 | 기본 ABI가 ARM이므로 안내도 ARM 에뮬레이터나 실기기를 권장 |
| iOS 개발 | macOS · Xcode · 서명 설정 | 안내의 시뮬레이터 설정은 Apple Silicon 대상으로 x86_64를 제외 |
| 저장 공간 | SDK·빌드 캐시·시뮬레이터·의존성 공간 | 저장소의 소스 용량과 전체 개발 환경 용량은 다름. 공식 최소 GB 수치는 제시되지 않음 |
docs/BUILDING.md는 build_runner를 필수 단계로 설명합니다.
Pigeon 생성기는 분석기 버전 범위가 달라 tool/pigeon_codegen/에 별도 도구 의존성을 둡니다.
누락된 생성 파일 오류를 실제 구현 버그로 오해하지 않는 것도 빌드 학습의 일부입니다.
Direct Ollama 연결에서 모델은 보통 Ollama가 실행 중인 기기에서 돌아갑니다. 그 기기의 RAM·VRAM은 모델 크기, 양자화, 문맥 길이, 동시 요청 수에 좌우됩니다. conduit 자체의 요구사항으로 “GPU 몇 GB”를 일괄 지정할 근거는 없습니다.
휴대전화에서 localhost를 입력하면 개발 PC가 아니라 그 휴대전화 자신을 가리킵니다. 실기기에서 PC의 모델 서버를 사용하려면 접근 가능한 주소, 서버의 수신 설정, 방화벽과 네트워크 경로를 함께 확인해야 합니다.
Open WebUI 스트리밍 문제에 대해 빌드 안내는 서버의 ENABLE_WEBSOCKET_SUPPORT="true" 설정을 확인하도록 안내합니다.
프록시도 인증 쿠키·헤더와 지속 연결을 전달해야 합니다. 앱이 여러 전송 방식을 처리한다는 사실이 서버 설정 오류까지 없애 주지는 않습니다.
아래 과제는 실제 계정이나 유료 모델 없이도 설계하고 검증할 수 있습니다. 원본 코드와 테스트를 읽은 뒤, 네트워크 입력은 가짜 데이터로 대체해 보세요. 각 과제의 완료 기준을 충족한 다음 범위를 넓히면 좋습니다.
출발점: packages/conduit_core/lib/utils/server_version_compat.dart의
ServerVersionCompat.isSupported()와 대응 테스트.
할 일: 0.12.3, v0.12.3, 0.12.4,
0.12.3-rc1, 빈 문자열, null을 표에 넣고 결과와 이유를 적으세요.
완료 기준: 앞의 두 값과 rc1은 지원, 0.12.4는 경고 대상, 빈 값과 null은 경고를 띄우지 않는 이유를 설명합니다. 모르는 버전에 관대한 정책과 실제 호환성 보장은 다릅니다.
출발점: packages/conduit_core/lib/services/sse_frame_scanner.dart와
packages/conduit_core/test/services/sse_frame_scanner_test.dart.
할 일: 정상 프레임, 두 조각으로 나뉜 프레임, CRLF 경계, 여러 data 줄, 빈 data 줄, 데이터 없는 event, 끝나지 않은 EOF의 기대 결과를 적으세요.
완료 기준: 명시적인 빈 data 이벤트는 하나의 프레임이지만, 데이터 없는 event와 빈 줄 없이 끝난 미완성 이벤트는 내보내지 않는 차이를 설명합니다.
// 과제 2의 판별 입력. 역슬래시 표기는 실제 제어 문자를 뜻한다.
A: 'data: hello\n\n' // 1개, hello
B: 'event: ping\n\n' // 0개
C: 'data:\n\n' // 1개, 빈 문자열
D: 'data: {"unfinished":' + EOF // 0개
출발점: DirectProviderAdapter, DirectCompletionRun,
DirectProviderAdapterRegistry.
할 일: 고정 모델 하나를 반환하고, “안녕”·“하세요”를 순서대로 내보내는 가짜 어댑터를 독립적으로 구현하세요. 먼저 실제 실행 타입의 이벤트·취소 계약을 읽고 필요한 부분만 재현합니다.
완료 기준: 등록·조회가 가능하고 중복 키 등록은 실패하며 취소 뒤에는 새 이벤트가 나오지 않습니다.
probe()와 listModels()의 역할을 각각 설명합니다.
출발점: packages/conduit_core/lib/sync/backoff.dart와
packages/conduit_core/test/sync/backoff_test.dart.
할 일: jitter: () => 0.5로 고정하고 시도 0, 1, 2, 8의 지연을 계산하세요.
실제 현재 시각 대신 조절 가능한 가짜 시각을 사용해 다음 실행 시각도 표시합니다.
완료 기준: 지연은 1,000·2,000·4,000·150,000ms입니다. jitter가 0일 때 계산값은 0이지만 drainer 예약은 최소 1초 뒤라는 계층 차이까지 설명합니다.
출발점: ChatDatabaseRepository.persistDirectMessages(),
ChatsDao.insertLocalChatWithCreateOp(), OutboxDrainer와 관련 테스트.
할 일: 기기 전용 Direct 대화와 Open WebUI에 기록을 동기화하는 Direct 대화를 나눕니다. 저장 직전, 큐 기록 직전, 전송 직후에 앱이 종료되는 상황을 상태도로 그리세요.
완료 기준: 기기 전용 대화는 Open WebUI 큐를 만들지 않고, 기록 동기화는 모델 생성을 재요청하지 않아야 합니다. 서버 성공 뒤 앱 종료로 생기는 재전송 가능성과 로컬 ID가 서버 ID로 바뀌는 문제를 별도로 표시합니다.
실습의 목표는 기능을 많이 붙이는 것이 아니라 실패해도 지켜야 할 조건을 말과 코드로 표현하는 것입니다.
아래는 기초 문법을 조금 아는 독자를 위한 6주 순서입니다. 시간은 개인차가 크므로 주차보다 완료 기준을 우선하세요. 첫 주부터 앱 전체를 빌드하는 것보다 순수 Dart 함수의 계약을 읽는 편이 범위를 잡기 쉽습니다.
| 주차 | 배울 주제 | 이 저장소에서 읽을 것 | 남길 결과물 |
|---|---|---|---|
| 1주차 | Dart null 안전성·컬렉션·타입 | packages/conduit_core/lib/utils/server_version_compat.dart, packages/conduit_core/lib/features/direct_connections/models/direct_completion.dart | 버전 판단표와 메시지 타입 관계도 |
| 2주차 | Future·Stream·취소·프레이밍 | packages/conduit_core/lib/services/sse_frame_scanner.dart, packages/conduit_core/lib/features/direct_connections/services/ollama_stream_parser.dart | 입력 조각별 이벤트 결과표 |
| 3주차 | Flutter 위젯·Riverpod·의존성 주입 | lib/main.dart, packages/conduit_core/lib/ports/flush_scheduler.dart | 가짜 스케줄러로 설명하는 상태 갱신 흐름 |
| 4주차 | SQLite·Drift·트랜잭션·이전 | packages/conduit_core/lib/database/app_database.dart, packages/conduit_core/lib/database/tables/outbox.dart, packages/conduit_core/lib/database/daos/chats_dao.dart | 대화·메시지·작업 큐의 관계도 |
| 5주차 | 재시도·소유권·계정 경계 | packages/conduit_core/lib/sync/backoff.dart, packages/conduit_core/lib/sync/outbox_drainer.dart, packages/conduit_core/lib/auth/api_auth_interceptor.dart | 실패·계정 전환 시나리오표 |
| 6주차 | 렌더링·국제화·CI | lib/shared/widgets/markdown/markdown_compile_service.dart, l10n.yaml, .github/workflows/ci.yml | 긴 답변·번역·검사 경계를 정리한 설계 메모 |
ProviderContainer의 overrides를 추적합니다.포트·어댑터·Outbox라는 이름을 외우는 것보다 “이 경계를 없애면 어떤 오류가 생기는가”를 설명해 보세요. 답이 구체적이면 같은 문제를 작은 메모 앱이나 다운로드 앱에 옮겨 볼 준비가 된 것입니다.
| 용어 | 쉬운 설명 |
|---|---|
| Flutter | Dart로 화면과 앱을 만드는 도구 모음. conduit의 iOS·Android 화면을 담당합니다. |
| 위젯 | 화면을 구성하는 부품. 버튼·입력창부터 채팅 화면까지 작은 부품을 조합합니다. |
| Riverpod 제공자 | 상태나 서비스를 필요로 하는 곳에 연결하는 통로. conduit에서는 플랫폼 기능도 연결합니다. |
| 의존성 주입 | 객체가 필요한 도구를 직접 고정해서 만들지 않고 외부에서 받게 하는 방식입니다. |
| 포트 | 로직이 필요로 하는 기능의 계약. 예를 들어 DB를 여는 기능입니다. |
| 어댑터 | 계약을 실제 환경에 맞게 구현하는 부품. Flutter 플러그인이나 모델 API의 차이를 흡수합니다. |
| 전략 패턴 | 같은 목표를 수행하는 여러 방법을 공통 인터페이스로 교체하는 설계입니다. |
| 레지스트리 | 이름으로 구현을 찾는 명부. Direct 연결 키로 제공자 어댑터를 찾습니다. |
| Future / Stream | 나중에 한 번 얻는 값 / 시간에 따라 여러 번 들어오는 값의 흐름입니다. |
| SSE | 서버가 HTTP 응답으로 이벤트를 이어 보내는 형식. 빈 줄이 이벤트 경계를 만듭니다. |
| NDJSON | 한 줄에 JSON 하나를 두는 형식. Ollama 스트림 파서가 줄 경계를 처리합니다. |
| WebSocket / Socket.IO | 지속 연결 방식 / 그 위에서 이벤트·재연결 등을 다루는 통신 계층. 같은 이름의 프로토콜은 아닙니다. |
| 버퍼·일괄 반영 | 들어온 조각을 잠시 모으고 한 번에 상태에 반영하는 것. 화면 갱신 부담을 줄입니다. |
| isolate | Dart에서 메모리를 분리해 계산을 수행하는 실행 단위. 긴 Markdown 처리의 워커에 사용됩니다. |
| Drift / DAO | Dart의 관계형 저장 라이브러리 / 특정 데이터의 읽기·쓰기 동작을 모은 객체입니다. |
| 트랜잭션 | 여러 DB 변경이 함께 성공하거나 함께 취소되게 묶는 단위입니다. |
| Outbox | 나중에 서버로 보낼 일을 DB에 남긴 목록. 앱이 종료되어도 처리할 일을 복구할 수 있습니다. |
| 백오프·지터 | 재시도 간격을 늘리는 정책 / 요청이 동시에 몰리지 않도록 간격에 넣는 무작위성입니다. |
| 로컬 우선 | 로컬 저장을 사용 경험의 기반으로 삼는 방향. 모든 추론·동기화가 오프라인이라는 뜻은 아닙니다. |
| origin | 주소의 scheme·host·port 조합. 자격 증명을 어디까지 보낼지 판단하는 기준입니다. |
| 보안 저장소 | 키·토큰 등 민감한 값을 OS 기능으로 보관하는 곳. 대화 DB 전체의 암호화와는 구분합니다. |
| FTS5 | SQLite의 전문 검색 기능. 검색어 문법과 토크나이저가 결과에 영향을 줍니다. |
| 마이그레이션 | 앱 업데이트로 DB 구조가 바뀔 때 기존 데이터를 새 구조로 옮기는 절차입니다. |
| ARB / 로케일 | 번역 문자열·설명을 담는 입력 형식 / 언어·지역 설정입니다. |
| 서브모듈 | 다른 Git 저장소의 특정 커밋을 가리키는 연결. 일반 파일 복사와 다릅니다. |
| MCP | 모델 애플리케이션이 외부 도구·자원에 연결할 때 쓰는 규약. 연결했다고 모든 실행이 자동 허용되는 것은 아닙니다. |
| 항목 | 상태 |
|---|---|
| 선정 | GitHub Trending Dart Monthly #10 |
| 스타 / 포크 | 2,361 / 261 — 2026-10-23 GitHub 기준 |
| 주 언어 | Dart |
| 생성일 | 2025-08-09 |
| 최신 릴리스 | v4.2.1 — 2026-10-16 |
| 앱 선언 버전 | 4.2.1+152 — pubspec.yaml |
| 최근 push | 2026-10-18 — GitHub 기준. 개별 커밋 날짜와는 다른 시각 |
| 분석 기준 커밋 | 3f95de1 — 2026-10-17Open WebUI 0.12.3 서버에서 버전 경고를 표시하지 않도록 지원한 변경 (#850) |
| 버전 경고 기준 | ServerVersionCompat.maxSupportedVersion = '0.12.3'이를 넘는 해석 가능한 버전에 경고. 알 수 없는 버전은 경고하지 않음 |
| 코드 규모 | Dart 722,962줄 / 1,705파일 · Swift 29,655줄 · Kotlin 12,060줄 |
| 테스트 규모 | *_test.dart 653파일 / 324,963줄 — 전체 Dart 줄 수에 포함 |
| 규모 집계 범위 | Git 추적 파일의 물리적 줄 수. 빈 줄·주석 포함, 서브모듈 내부·미생성 파일 제외 |
| 라이선스 | GPL-3.0 — 배포·수정·재사용 조건은 LICENSE 원문 확인 |
| 한국어 지원 | lib/l10n/app_ko.arb와 ko 로케일 설정 존재. 전체 번역 품질을 보증하는 수치는 아님 |
① 모든 연결의 기능이 같지 않습니다. Open WebUI의 노트·채널·터미널과 Direct 모델의 채팅 기능은 다릅니다. Hermes도 서버가 보고하는 기능에 좌우됩니다. Apple 모델은 별도 OS·기기 조건이 있고 PCC에는 추가 entitlement가 필요합니다.
② 로컬 저장은 외부 전송이 없다는 뜻이 아닙니다. 원격 추론에 쓰는 질문은 선택한 서버나 모델 제공자에게 갑니다. Direct 기록은 설정에 따라 Open WebUI에 동기화될 수 있으며, 기기 측 웹 검색도 질의를 검색엔진에 보냅니다. 기존 대화와 새 대화에 적용되는 기록 위치 정책도 구분해야 합니다.
③ 보안 저장소와 대화 DB는 다릅니다. 키·토큰의 보안 저장 경로는 존재하지만,
FlutterDatabaseOpener의 일반 SQLite 연결을 대화 전체의 기본 암호화로 해석하면 안 됩니다.
AppDatabase는 SQLCipher를 나중에 도입할 수 있는 경계라고 설명합니다.
④ 문서와 실행 설정이 함께 움직이지 않을 수 있습니다. README의 Open WebUI 0.11 표기와 달리 이 커밋의 경고 상한은 0.12.3입니다. Android 도구 버전과 DB 설명 주석에도 차이가 있습니다. 코드상의 경고 정책은 모든 구버전 서버·프록시 조합의 동작을 보장하지 않습니다.
⑤ 이 글의 근거는 소스와 설정입니다. 실제 기기에서 측정한 배터리·메모리·응답 속도나 장애 복구 성공률을 제시하는 성능 검증 자료는 아닙니다. 스토어 배포 바이너리와 분석 커밋도 구분해서 보세요.