Compare commits
4 Commits
c4ecb5862b
...
2eed4e492c
| Author | SHA1 | Date |
|---|---|---|
|
|
2eed4e492c | |
|
|
ce2e38e566 | |
|
|
6743bf0054 | |
|
|
ebf333d287 |
|
|
@ -5,7 +5,7 @@ GEMINI_API_KEY=
|
||||||
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
|
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
|
||||||
ADMIN_API_TOKEN=
|
ADMIN_API_TOKEN=
|
||||||
|
|
||||||
# Shared demo invite DEMO-BUNSIN on signup UI (multiple testers). Set 0 in production.
|
# Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 in production.
|
||||||
ALLOW_DEMO_INVITE=1
|
ALLOW_DEMO_INVITE=1
|
||||||
|
|
||||||
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
|
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
|
||||||
|
|
|
||||||
31
AGENTS.md
31
AGENTS.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Project Instructions — hikikomori / 분신 (가칭)
|
# Project Instructions — hikikomori / 와카뷰 (가칭)
|
||||||
|
|
||||||
This repository is an AI-twin messenger ("나를 대신해 남과 대화하는 AI 분신") with
|
This repository is an AI-twin messenger ("나를 대신해 남과 대화하는 AI 와카뷰") with
|
||||||
Phase 1 app code (`core-backend/`, `ai-service/`, `mobile/`) plus planning docs.
|
Phase 1 app code (`core-backend/`, `ai-service/`, `mobile/`) plus planning docs.
|
||||||
|
|
||||||
Canonical product decisions live under `docs/`. Prefer linking to them over
|
Canonical product decisions live under `docs/`. Prefer linking to them over
|
||||||
|
|
@ -29,8 +29,8 @@ Notes:
|
||||||
|
|
||||||
## Product identity (v1 working assumptions)
|
## Product identity (v1 working assumptions)
|
||||||
|
|
||||||
- Name (working): **분신**
|
- Name (working): **와카뷰**
|
||||||
- One-liner: ChatGPT talks *with* me; 분신 talks *as* me *to others*
|
- One-liner: ChatGPT talks *with* me; 와카뷰 talks *as* me *to others*
|
||||||
- Target: consumer individuals first (not B2B)
|
- Target: consumer individuals first (not B2B)
|
||||||
- Delivery: self-owned messenger, closed beta
|
- Delivery: self-owned messenger, closed beta
|
||||||
- Autonomy for v1: **L0~L2 only**
|
- Autonomy for v1: **L0~L2 only**
|
||||||
|
|
@ -57,7 +57,7 @@ These apply at every autonomy level and must not be weakened for convenience:
|
||||||
- Money, appointment confirmation, and emotional/sensitive topics always
|
- Money, appointment confirmation, and emotional/sensitive topics always
|
||||||
escalate to the human. When uncertain, escalate (fail-safe).
|
escalate to the human. When uncertain, escalate (fail-safe).
|
||||||
- Twin-authored messages must be visually distinct (badge / `sender_mode`).
|
- Twin-authored messages must be visually distinct (badge / `sender_mode`).
|
||||||
- Honest identity answers: if asked “본인이야 분신이야?”, answer as twin.
|
- Honest identity answers: if asked “본인이야 와카뷰가야?”, answer as twin.
|
||||||
- Peer veto: if the other person rejects the twin, disable auto-reply for that
|
- Peer veto: if the other person rejects the twin, disable auto-reply for that
|
||||||
conversation immediately.
|
conversation immediately.
|
||||||
- Every automatic action needs post-hoc notification + one-tap undo.
|
- Every automatic action needs post-hoc notification + one-tap undo.
|
||||||
|
|
@ -90,6 +90,9 @@ changes it.
|
||||||
|
|
||||||
- Before starting any Phase 1 app-build task, check `docs/roadmap.md`'s
|
- Before starting any Phase 1 app-build task, check `docs/roadmap.md`'s
|
||||||
"Phase 1 상세 작업 분해" checklist for what's already done and what's next.
|
"Phase 1 상세 작업 분해" checklist for what's already done and what's next.
|
||||||
|
- After Phase 1 A~C, use [`docs/deploy-checklist.md`](docs/deploy-checklist.md)
|
||||||
|
(N1→N5) for smoke, Docker deploy, stabilize, FCM/Android QA, then human PoC.
|
||||||
|
Keep that file and `roadmap.md` in sync when status changes.
|
||||||
- Follow the "권장 착수 순서" there — don't skip ahead in the numbered order
|
- Follow the "권장 착수 순서" there — don't skip ahead in the numbered order
|
||||||
without a reason, and note the reason in the checklist if you do.
|
without a reason, and note the reason in the checklist if you do.
|
||||||
- When a task is finished, check it off in that same checklist. When you
|
- When a task is finished, check it off in that same checklist. When you
|
||||||
|
|
@ -113,6 +116,24 @@ Do not invent frameworks, folder layouts, or CI conventions beyond what
|
||||||
in sync.
|
in sync.
|
||||||
- Prefer updating existing docs over creating parallel overlapping docs.
|
- Prefer updating existing docs over creating parallel overlapping docs.
|
||||||
|
|
||||||
|
## Git remotes & branch policy (Master)
|
||||||
|
|
||||||
|
Master policy for this repo — follow even when a cloud agent default suggests
|
||||||
|
feature branches or a non-`main` base:
|
||||||
|
|
||||||
|
1. **Work on `main`.** Commit and land changes on `main` (fast-forward or merge
|
||||||
|
into `main`). Do not leave finished work only on long-lived side branches
|
||||||
|
unless Master explicitly asks for a temporary branch.
|
||||||
|
2. **Dual remote sync after every `main` update.**
|
||||||
|
- `origin` = GitHub `o0kuma/hikikomori`
|
||||||
|
- `gitea` = Gitea `gitea.iykyka.com/oh/iykyka` (iykyka)
|
||||||
|
- Push both: `./scripts/push-both.sh main` (or `git push origin main` then
|
||||||
|
`git push gitea main`).
|
||||||
|
3. Never commit secrets (tokens, API keys). Gitea/GitHub credentials stay in
|
||||||
|
local/env only.
|
||||||
|
4. If a PR was opened for tooling reasons, merge it into `main` and dual-push;
|
||||||
|
then close the PR. Keep `gitea/main` even with `origin/main`.
|
||||||
|
|
||||||
## Communication with agents
|
## Communication with agents
|
||||||
|
|
||||||
- Read the relevant docs before proposing product/tech changes.
|
- Read the relevant docs before proposing product/tech changes.
|
||||||
|
|
|
||||||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -1,16 +1,21 @@
|
||||||
# Claude Code — hikikomori / 분신
|
# Claude Code — hikikomori / 와카뷰
|
||||||
|
|
||||||
Follow the project instructions in [@AGENTS.md](./AGENTS.md).
|
Follow the project instructions in [@AGENTS.md](./AGENTS.md).
|
||||||
|
|
||||||
Quick context:
|
Quick context:
|
||||||
|
|
||||||
- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is
|
- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`).
|
||||||
**D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults.
|
**Next execution track:** N1 smoke → N2 Docker (`msn.iykyka.com`) → N3 stabilize →
|
||||||
- Working product name: **분신** (가칭 확정).
|
N4 FCM/Android QA → **N5 / D human PoC last** — see
|
||||||
|
[`docs/deploy-checklist.md`](./docs/deploy-checklist.md). Do not start human PoC
|
||||||
|
early or invent Phase 1 §3 defaults.
|
||||||
|
- Working product name: **와카뷰** (가칭 확정).
|
||||||
- Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open).
|
- Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open).
|
||||||
- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first.
|
- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first.
|
||||||
- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B.
|
- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B.
|
||||||
- Never weaken escalation, twin badge, peer veto, or undo.
|
- Never weaken escalation, twin badge, peer veto, or undo.
|
||||||
|
- **Git:** work and land on `main`; after each push to GitHub `origin`, also push
|
||||||
|
`main` to Gitea `gitea` (`scripts/push-both.sh`). See `AGENTS.md`.
|
||||||
|
|
||||||
Before changing product or technical direction, read `AGENTS.md` and the
|
Before changing product or technical direction, read `AGENTS.md` and the
|
||||||
relevant files under `docs/`.
|
relevant files under `docs/`.
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# hikikomori / 분신 (가칭)
|
# hikikomori / 와카뷰 (가칭)
|
||||||
|
|
||||||
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 분신".
|
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰".
|
||||||
|
|
||||||
## 구조
|
## 구조
|
||||||
|
|
||||||
|
|
@ -18,17 +18,21 @@
|
||||||
- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**)
|
- 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**)
|
||||||
- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md)
|
- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md)
|
||||||
- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md)
|
- 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md)
|
||||||
|
- **배포·잔여 실행 트랙:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) (N1 스모크 → N2 Docker → … → N5 사람 PoC)
|
||||||
- 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md)
|
- 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md)
|
||||||
- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
|
- PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
|
||||||
|
|
||||||
## AI 에이전트 규칙
|
## AI 에이전트 규칙
|
||||||
|
|
||||||
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
|
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
|
||||||
|
- **브랜치:** 작업·머지는 항상 `main`
|
||||||
|
- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh`
|
||||||
|
|
||||||
## 현재 단계
|
## 현재 단계
|
||||||
|
|
||||||
- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로)
|
- Phase 1 **A~C**까지 반영됨 (서버·Flutter·베타 직전 문서/배포 경로)
|
||||||
- 다음: **D — 사람 PoC #1/#3·Q3 인터뷰** (`docs/roadmap.md` §3, 맨 마지막)
|
- **다음:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) — N1 스모크 → N2 `msn.iykyka.com` Docker → N3 안정화 → N4 FCM/Android QA
|
||||||
|
- **맨 마지막:** N5 / D — 사람 PoC #1/#3·Q3 인터뷰 (`docs/roadmap.md` §3). §3 기본값 추측 금지
|
||||||
- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
|
- 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
|
||||||
|
|
||||||
## 로컬 실행 (요약)
|
## 로컬 실행 (요약)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# 분신 AI service (Python)
|
# 와카뷰 AI service (Python)
|
||||||
|
|
||||||
Phase 1 AI 서비스 (`docs/roadmap.md` Phase 1 §2.2). `poc/tone-corpus/`의 세 스크립트
|
Phase 1 AI 서비스 (`docs/roadmap.md` Phase 1 §2.2). `poc/tone-corpus/`의 세 스크립트
|
||||||
(`generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`)를 그대로 승격한 내부 API —
|
(`generate_draft.py`·`escalation_filter.py`·`retrieve_style.py`)를 그대로 승격한 내부 API —
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ def load_dotenv_if_present():
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
|
SYSTEM_PROMPT = """너는 어떤 사람의 '와카뷰'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
|
||||||
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
|
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
|
||||||
|
|
||||||
지켜야 할 것:
|
지켜야 할 것:
|
||||||
|
|
@ -40,8 +40,8 @@ SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화
|
||||||
- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 이 문장만 출력한다: \
|
- 금전, 약속 시간 확정, 감정적으로 무거운 주제라고 판단되면 초안 대신 정확히 이 문장만 출력한다: \
|
||||||
[ESCALATE] 이 내용은 본인 확인이 필요합니다.
|
[ESCALATE] 이 내용은 본인 확인이 필요합니다.
|
||||||
- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.
|
- 예시에 없는 존댓말/반말을 새로 만들지 말고, 예시의 격식 수준을 그대로 유지한다.
|
||||||
- 상대가 "본인이야/분신이야?"처럼 정체를 물으면 분신임을 정직하게 밝힌다 \
|
- 상대가 "본인이야/와카뷰야?"처럼 정체를 물으면 와카뷰임을 정직하게 밝힌다 \
|
||||||
(서버가 고정 문구로 먼저 처리하지만, 여기까지 온 경우에도 분신이라고 답한다).
|
(서버가 고정 문구로 먼저 처리하지만, 여기까지 온 경우에도 와카뷰라고 답한다).
|
||||||
|
|
||||||
(이 지침은 2차 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진
|
(이 지침은 2차 방어선이다 -- 1차는 escalation_filter.py의 규칙 기반 하드 게이트로, 이미 걸러진
|
||||||
내용은 여기까지 오지 않는다. 이 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""
|
내용은 여기까지 오지 않는다. 이 지침이 남아있는 이유는 규칙이 놓친 케이스를 위한 것이다.)"""
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,17 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
# Fixed product copy — tune only after PoC #3; do not invent alternate defaults.
|
# Fixed product copy — tune only after PoC #3; do not invent alternate defaults.
|
||||||
IDENTITY_REPLY = (
|
IDENTITY_REPLY = (
|
||||||
"지금은 분신이 답하고 있어. 본인이랑 바로 이야기하고 싶으면 그렇게 말해줘."
|
"지금은 와카뷰가 답하고 있어. 본인이랑 바로 이야기하고 싶으면 그렇게 말해줘."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Incoming peer questions that should trigger the fixed identity reply.
|
# Incoming peer questions that should trigger the fixed identity reply.
|
||||||
_IDENTITY_QUESTION = re.compile(
|
_IDENTITY_QUESTION = re.compile(
|
||||||
r"("
|
r"("
|
||||||
r"본인\s*(이야|인가요|이니|임\??|맞아|맞음)"
|
r"본인\s*(이야|인가요|이니|임\??|맞아|맞음)"
|
||||||
r"|분신\s*(이야|인가요|이니|임\??|맞아|맞음)"
|
r"|와카뷰\s*(이야|야\??|인가요|이니|임\??|맞아|맞음)"
|
||||||
r"|지금\s*(본인|분신)"
|
r"|지금\s*(본인|와카뷰)"
|
||||||
r"|진짜\s*(야|임|인가요)"
|
r"|진짜\s*(야|임|인가요)"
|
||||||
r"|너\s*(사람|본인|분신)"
|
r"|너\s*(사람|본인|와카뷰)"
|
||||||
r")",
|
r")",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ async def lifespan(app: FastAPI):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="분신 AI service", lifespan=lifespan)
|
app = FastAPI(title="와카뷰 AI service", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ from app.generation import draft_reply
|
||||||
|
|
||||||
def test_identity_phrases_match():
|
def test_identity_phrases_match():
|
||||||
for q in [
|
for q in [
|
||||||
"지금 본인이야 분신이야?",
|
"지금 본인이야 와카뷰야?",
|
||||||
"분신이야?",
|
"와카뷰야?",
|
||||||
|
"와카뷰이야?",
|
||||||
"너 본인 맞아?",
|
"너 본인 맞아?",
|
||||||
"진짜야?",
|
"진짜야?",
|
||||||
]:
|
]:
|
||||||
|
|
@ -23,7 +24,7 @@ def test_draft_returns_fixed_identity_without_llm(monkeypatch):
|
||||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||||
status, text = draft_reply(
|
status, text = draft_reply(
|
||||||
["ㅇㅇ 알겠음"],
|
["ㅇㅇ 알겠음"],
|
||||||
["상대: 지금 본인이야 분신이야?"],
|
["상대: 지금 본인이야 와카뷰야?"],
|
||||||
)
|
)
|
||||||
assert status == "ok"
|
assert status == "ok"
|
||||||
assert text == IDENTITY_REPLY
|
assert text == IDENTITY_REPLY
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# 분신 backend (Python 프로토타입 — 참고용, Go로 포팅 예정)
|
# 와카뷰 backend (Python 프로토타입 — 참고용, Go로 포팅 예정)
|
||||||
|
|
||||||
**스택이 바뀌었다**: `docs/tech-design.md` §8에서 코어 백엔드를 Go로 재확정했다
|
**스택이 바뀌었다**: `docs/tech-design.md` §8에서 코어 백엔드를 Go로 재확정했다
|
||||||
(성능/동시성, 향후 스케일 대비). 이 디렉토리는 그 결정 전에 만든 Python/FastAPI 프로토타입으로,
|
(성능/동시성, 향후 스케일 대비). 이 디렉토리는 그 결정 전에 만든 Python/FastAPI 프로토타입으로,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
||||||
from . import models
|
from . import models
|
||||||
from .db import Base, engine, get_db
|
from .db import Base, engine, get_db
|
||||||
|
|
||||||
app = FastAPI(title="분신 backend")
|
app = FastAPI(title="와카뷰 backend")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# 분신 core-backend (Go)
|
# 와카뷰 core-backend (Go)
|
||||||
|
|
||||||
Phase 1 코어 백엔드 (`docs/roadmap.md` Phase 1 §2.1). 스택 결정은 `docs/tech-design.md` §8 참고 —
|
Phase 1 코어 백엔드 (`docs/roadmap.md` Phase 1 §2.1). 스택 결정은 `docs/tech-design.md` §8 참고 —
|
||||||
Go(Gin + gorilla/websocket + GORM), PostgreSQL(프로덕션)/SQLite(로컬 개발). `../backend/`(Python
|
Go(Gin + gorilla/websocket + GORM), PostgreSQL(프로덕션)/SQLite(로컬 개발). `../backend/`(Python
|
||||||
|
|
@ -127,7 +127,7 @@ go test ./... -v
|
||||||
- `GET /admin/metrics` — `users_total`·`messages_human_total`·`messages_twin_total`·
|
- `GET /admin/metrics` — `users_total`·`messages_human_total`·`messages_twin_total`·
|
||||||
`escalations_total`·`escalations_by_reason`·`conversations_total`·`conversations_vetoed`·
|
`escalations_total`·`escalations_by_reason`·`conversations_total`·`conversations_vetoed`·
|
||||||
`peer_veto_rate`·`invites_minted`·`invites_used`. 지금 스키마로 정직하게 계산 가능한 것만 —
|
`peer_veto_rate`·`invites_minted`·`invites_used`. 지금 스키마로 정직하게 계산 가능한 것만 —
|
||||||
`peer_veto_rate`는 vision.md "분신 거부율" 지표의 1차 근사치(대화방 단위)이지 확정 정의는 아님.
|
`peer_veto_rate`는 vision.md "와카뷰 거부율" 지표의 1차 근사치(대화방 단위)이지 확정 정의는 아님.
|
||||||
생성 지연시간·AI 서비스 오류율은 별도 계측/로깅 계층이 없어서 넣지 않음 (아래 "아직 없는 것")
|
생성 지연시간·AI 서비스 오류율은 별도 계측/로깅 계층이 없어서 넣지 않음 (아래 "아직 없는 것")
|
||||||
|
|
||||||
## 아직 없는 것 (다음 워크스트림)
|
## 아직 없는 것 (다음 워크스트림)
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ func registerBRoutes(r *gin.Engine, db *gorm.DB) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Title == "" {
|
if req.Title == "" {
|
||||||
req.Title = "분신 테스트"
|
req.Title = "와카뷰 테스트"
|
||||||
}
|
}
|
||||||
if req.Body == "" {
|
if req.Body == "" {
|
||||||
req.Body = "push test"
|
req.Body = "push test"
|
||||||
|
|
@ -189,7 +189,7 @@ const adminDashboardHTML = `<!doctype html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>분신 · admin metrics</title>
|
<title>와카뷰 · admin metrics</title>
|
||||||
<style>
|
<style>
|
||||||
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #14231e; background: #f3f7f5; }
|
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #14231e; background: #f3f7f5; }
|
||||||
body { margin: 0; padding: 24px; }
|
body { margin: 0; padding: 24px; }
|
||||||
|
|
@ -204,7 +204,7 @@ const adminDashboardHTML = `<!doctype html>
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>분신 운영 지표</h1>
|
<h1>와카뷰 운영 지표</h1>
|
||||||
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 를 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
|
<p>Bearer ADMIN_API_TOKEN 으로 /admin/metrics 를 불러옵니다. 생성 지연·오류율은 프로세스 메모리 샘플입니다.</p>
|
||||||
<button onclick="load()">새로고침</button>
|
<button onclick="load()">새로고침</button>
|
||||||
<div class="grid" id="cards"></div>
|
<div class="grid" id="cards"></div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
|
|
||||||
// Shared closed-beta demo invite shown on the Flutter signup screen.
|
// Shared closed-beta demo invite shown on the Flutter signup screen.
|
||||||
// Multiple testers can use the same code when ALLOW_DEMO_INVITE is enabled.
|
// Multiple testers can use the same code when ALLOW_DEMO_INVITE is enabled.
|
||||||
const demoInviteCode = "DEMO-BUNSIN"
|
const demoInviteCode = "DEMO-YKAVU"
|
||||||
|
|
||||||
func demoInviteEnabled() bool {
|
func demoInviteEnabled() bool {
|
||||||
v := strings.TrimSpace(os.Getenv("ALLOW_DEMO_INVITE"))
|
v := strings.TrimSpace(os.Getenv("ALLOW_DEMO_INVITE"))
|
||||||
|
|
@ -84,7 +84,7 @@ func demoSignupInviteCode(db *gorm.DB) (InviteCode, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// uniqueDemoUserInvite stores a per-user value on users.invite_code (unique)
|
// uniqueDemoUserInvite stores a per-user value on users.invite_code (unique)
|
||||||
// while still accepting the shared DEMO-BUNSIN input.
|
// while still accepting the shared DEMO-YKAVU input.
|
||||||
func uniqueDemoUserInvite() (string, error) {
|
func uniqueDemoUserInvite() (string, error) {
|
||||||
suffix, err := generateInviteCode()
|
suffix, err := generateInviteCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
"escalations_by_reason": escalationsByReason,
|
"escalations_by_reason": escalationsByReason,
|
||||||
"conversations_total": conversationsTotal,
|
"conversations_total": conversationsTotal,
|
||||||
"conversations_vetoed": conversationsVetoed,
|
"conversations_vetoed": conversationsVetoed,
|
||||||
// Approximates vision.md's "분신 거부율" metric at conversation
|
// Approximates vision.md's "와카뷰 거부율" metric at conversation
|
||||||
// granularity (vetoed conversations / all conversations) --
|
// granularity (vetoed conversations / all conversations) --
|
||||||
// vision.md doesn't pin down the exact denominator, so treat
|
// vision.md doesn't pin down the exact denominator, so treat
|
||||||
// this as a first approximation, not the final definition.
|
// this as a first approximation, not the final definition.
|
||||||
|
|
@ -164,7 +164,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
// 코드가 있어야만 된다 -- 아무 문자열이나 처음 쓰면 통과되던 이전
|
// 코드가 있어야만 된다 -- 아무 문자열이나 처음 쓰면 통과되던 이전
|
||||||
// 방식은 "초대 기반"이 아니었음.
|
// 방식은 "초대 기반"이 아니었음.
|
||||||
//
|
//
|
||||||
// Shared demo: DEMO-BUNSIN (ALLOW_DEMO_INVITE, default on) can be reused
|
// Shared demo: DEMO-YKAVU (ALLOW_DEMO_INVITE, default on) can be reused
|
||||||
// by multiple testers; User.InviteCode still gets a unique stored value.
|
// by multiple testers; User.InviteCode still gets a unique stored value.
|
||||||
demo := demoInviteEnabled() && isDemoInviteCode(req.InviteCode)
|
demo := demoInviteEnabled() && isDemoInviteCode(req.InviteCode)
|
||||||
var invite InviteCode
|
var invite InviteCode
|
||||||
|
|
@ -258,7 +258,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
if req.SenderMode == SenderTwin {
|
if req.SenderMode == SenderTwin {
|
||||||
if conversation.TwinDisabledByPeer {
|
if conversation.TwinDisabledByPeer {
|
||||||
runtimeMetrics.recordTwinBlocked()
|
runtimeMetrics.recordTwinBlocked()
|
||||||
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 분신을 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
|
c.JSON(http.StatusForbidden, gin.H{"detail": "상대방이 와카뷰를 거부해서 이 대화방에서는 자동 발송이 꺼져 있습니다"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,7 +278,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
MessageSnippet: req.Text,
|
MessageSnippet: req.Text,
|
||||||
})
|
})
|
||||||
// Best-effort push (no-op without FCM_SERVER_KEY / real tokens).
|
// Best-effort push (no-op without FCM_SERVER_KEY / real tokens).
|
||||||
_, _, _ = notifyUser(db, req.SenderID, "분신 확인 필요", result.Reason, map[string]string{
|
_, _, _ = notifyUser(db, req.SenderID, "와카뷰 확인 필요", result.Reason, map[string]string{
|
||||||
"type": "escalation",
|
"type": "escalation",
|
||||||
"conversation_id": strconv.FormatUint(uint64(convID), 10),
|
"conversation_id": strconv.FormatUint(uint64(convID), 10),
|
||||||
"reason": result.Reason,
|
"reason": result.Reason,
|
||||||
|
|
@ -298,7 +298,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
switch level {
|
switch level {
|
||||||
case AutonomyL0:
|
case AutonomyL0:
|
||||||
runtimeMetrics.recordTwinBlocked()
|
runtimeMetrics.recordTwinBlocked()
|
||||||
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 분신 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
|
c.JSON(http.StatusForbidden, gin.H{"detail": "L0(비서 모드)에서는 와카뷰 자동 발송이 허용되지 않습니다 -- 초안만 생성하고 사람이 직접 보내세요"})
|
||||||
return
|
return
|
||||||
case AutonomyL1:
|
case AutonomyL1:
|
||||||
if !req.Approved {
|
if !req.Approved {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# 프로젝트 기획 접근 방법 — AI 분신 메신저
|
# 프로젝트 기획 접근 방법 — AI 와카뷰 메신저
|
||||||
|
|
||||||
원본 회의 자료: [`idea-meeting-2026-06-29.html`](./idea-meeting-2026-06-29.html)
|
원본 회의 자료: [`idea-meeting-2026-06-29.html`](./idea-meeting-2026-06-29.html)
|
||||||
|
|
||||||
## 0. 지금 어디까지 왔나
|
## 0. 지금 어디까지 왔나
|
||||||
|
|
||||||
회의 자료는 "발산 → 컨셉 탐색 → AI 분신으로 좁혀 설계 심화"까지 진행됐다.
|
회의 자료는 "발산 → 컨셉 탐색 → AI 와카뷰로 좁혀 설계 심화"까지 진행됐다.
|
||||||
즉 **아이디어 단계는 끝났고, 다음은 아이디어를 실행 가능한 스펙으로 좁히는 단계**다.
|
즉 **아이디어 단계는 끝났고, 다음은 아이디어를 실행 가능한 스펙으로 좁히는 단계**다.
|
||||||
이 단계에서 실패하는 가장 흔한 패턴은 두 가지다.
|
이 단계에서 실패하는 가장 흔한 패턴은 두 가지다.
|
||||||
|
|
||||||
|
|
@ -29,18 +29,18 @@
|
||||||
## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7)
|
## 2. 먼저 확정해야 할 결정 (회의 Q1~Q7)
|
||||||
|
|
||||||
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
기능 명세를 쓰기 전에 아래 표를 채운다. 답이 안 나온 항목은 "보류 사유"를 적어두고 다음 회의 안건으로 남긴다.
|
||||||
현재 작업용 답은 [`decision-log.md`](./decision-log.md)에 있으며, 상태는 모두 **제안(잠정)** 이다.
|
현재 답은 [`decision-log.md`](./decision-log.md)에 있으며, **Q1~Q7은 Phase 1 C에서 확정**(2026-07-30).
|
||||||
회의에서 정식 확정되기 전까지는 decision-log를 단일 기준으로 따른다.
|
PoC 의존 하위 질문(자율성 기본값 등)만 열려 있다 — decision-log를 단일 기준으로 따른다.
|
||||||
|
|
||||||
| # | 질문 | 잠정 결정 | 상태 |
|
| # | 질문 | 결정 | 상태 |
|
||||||
|---|------|------|------|
|
|---|------|------|------|
|
||||||
| Q1 | AI 분신으로 확정? | 예 | 제안 — 근거는 `decision-log.md` |
|
| Q1 | AI 와카뷰로 확정? | 예 | **확정** — `decision-log.md` |
|
||||||
| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | 제안 |
|
| Q2 | 타깃: 대중 vs 회사? | 대중 우선 (B2B는 이후) | **확정** |
|
||||||
| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 | 제안 |
|
| Q3 | 자율성 몇 단계까지 출시? | L0~L2만 (시작 기본값은 PoC 후) | **확정** (하위 기본값은 열림) |
|
||||||
| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | 제안 |
|
| Q4 | 사칭 우려 대응 충분한가? | 1차 설계는 충분, 실사용 검증 필요 | **확정** (실사용은 PoC) |
|
||||||
| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | 제안 |
|
| Q5 | MVP 데모 시나리오 1개는? | 읽씹 종결 + 단톡 따라잡기 (묶음) | **확정** |
|
||||||
| Q6 | 서비스 이름 | "분신" (가칭) | 제안 (가칭) |
|
| Q6 | 서비스 이름 | "와카뷰 (Ykavu)" | **확정** (2026-07-31 명칭 변경) |
|
||||||
| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | 제안 |
|
| Q7 | 자체 앱 vs OS 레이어 시작점 | 자체 앱 클로즈드 베타 먼저 → OS 레이어는 이후 | **확정** |
|
||||||
|
|
||||||
## 3. MVP 시나리오 좁히기
|
## 3. MVP 시나리오 좁히기
|
||||||
|
|
||||||
|
|
@ -51,10 +51,10 @@
|
||||||
| 🌙 읽씹 종결 | L0~L2 | 높음 (전 국민 공감) | 낮음 (컨텍스트 응답 1건) | **1순위 추천** — 가장 좁고 명확 |
|
| 🌙 읽씹 종결 | L0~L2 | 높음 (전 국민 공감) | 낮음 (컨텍스트 응답 1건) | **1순위 추천** — 가장 좁고 명확 |
|
||||||
| 📚 단톡 따라잡기 | L0 | 중간 | 낮음 (요약만) | 발송 권한 불필요, 안전 |
|
| 📚 단톡 따라잡기 | L0 | 중간 | 낮음 (요약만) | 발송 권한 불필요, 안전 |
|
||||||
| 🚫 비호감 대화 방어 | L2 | 중간 | 중간 (의도 판별 필요) | 오탐 리스크 존재 |
|
| 🚫 비호감 대화 방어 | L2 | 중간 | 중간 (의도 판별 필요) | 오탐 리스크 존재 |
|
||||||
| 📅 약속 자동 조율(L4) | L4 | 높음 | 높음 (분신 간 프로토콜, 양쪽 앱 필요) | 초기엔 데모용으로만, 정식 범위 아님 |
|
| 📅 약속 자동 조율(L4) | L4 | 높음 | 높음 (와카뷰 간 프로토콜, 양쪽 앱 필요) | 초기엔 데모용으로만, 정식 범위 아님 |
|
||||||
|
|
||||||
**권장:** "읽씹 종결"과 "단톡 따라잡기"는 둘 다 L0~L2로 커버되고 같은 파이프라인(수신 메시지 요약 →
|
**권장:** "읽씹 종결"과 "단톡 따라잡기"는 둘 다 L0~L2로 커버되고 같은 파이프라인(수신 메시지 요약 →
|
||||||
맥락 기반 응답 초안)을 공유하므로, 이 둘을 묶어 MVP로 잡고 L3(자리비움 응대)·L4(분신 협상)는
|
맥락 기반 응답 초안)을 공유하므로, 이 둘을 묶어 MVP로 잡고 L3(자리비움 응대)·L4(와카뷰 협상)는
|
||||||
로드맵의 다음 단계로 명시적으로 미룬다.
|
로드맵의 다음 단계로 명시적으로 미룬다.
|
||||||
|
|
||||||
## 4. 기술 검증(PoC) 우선순위
|
## 4. 기술 검증(PoC) 우선순위
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
OS/앱 버전에 따라 얼마나 자주 깨지는지 확인 (OS 레이어 트랙을 갈 경우 필수)
|
OS/앱 버전에 따라 얼마나 자주 깨지는지 확인 (OS 레이어 트랙을 갈 경우 필수)
|
||||||
3. **자동응대가 "이상하다"고 느껴지는 임계점은 어디인가** — 실제 대화 로그로 사용자 테스트, 뱃지·거부권
|
3. **자동응대가 "이상하다"고 느껴지는 임계점은 어디인가** — 실제 대화 로그로 사용자 테스트, 뱃지·거부권
|
||||||
같은 투명성 장치가 실제로 신뢰를 회복시키는지 확인
|
같은 투명성 장치가 실제로 신뢰를 회복시키는지 확인
|
||||||
4. **분신 간 협상(L4) 프로토콜의 최소 형태** — MVP 범위는 아니지만, 자체 메신저 트랙의 차별화 지점이므로
|
4. **와카뷰 간 협상(L4) 프로토콜의 최소 형태** — MVP 범위는 아니지만, 자체 메신저 트랙의 차별화 지점이므로
|
||||||
설계 문서 수준에서만 먼저 검증 (턴 수 제한, 타임아웃, 결렬 시 에스컬레이션 규칙)
|
설계 문서 수준에서만 먼저 검증 (턴 수 제한, 타임아웃, 결렬 시 에스컬레이션 규칙)
|
||||||
|
|
||||||
## 5. 범위 관리 원칙 — "켜는 만큼만 만든다"
|
## 5. 범위 관리 원칙 — "켜는 만큼만 만든다"
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
|
|
||||||
- **MVP**: L0(비서 모드) + L1(제안 모드) + L2(부분 위임, 좁은 화이트리스트 주제만)
|
- **MVP**: L0(비서 모드) + L1(제안 모드) + L2(부분 위임, 좁은 화이트리스트 주제만)
|
||||||
- **v2**: L3(자리비움 응대) — 사용자 신뢰가 쌓인 뒤
|
- **v2**: L3(자리비움 응대) — 사용자 신뢰가 쌓인 뒤
|
||||||
- **v3+**: L4(분신 협상) — 네트워크 효과가 필요한 단계이므로 사용자 기반이 어느 정도 생긴 뒤
|
- **v3+**: L4(와카뷰 협상) — 네트워크 효과가 필요한 단계이므로 사용자 기반이 어느 정도 생긴 뒤
|
||||||
|
|
||||||
절대 안전선(금전·약속 확정·민감 내용은 항상 사람에게 에스컬레이션)은 L0부터 L4까지 전 단계에서
|
절대 안전선(금전·약속 확정·민감 내용은 항상 사람에게 에스컬레이션)은 L0부터 L4까지 전 단계에서
|
||||||
예외 없이 지킨다 — 이건 범위를 줄여도 타협하지 않는 항목이다.
|
예외 없이 지킨다 — 이건 범위를 줄여도 타협하지 않는 항목이다.
|
||||||
|
|
@ -85,9 +85,9 @@
|
||||||
두 트랙은 경쟁 관계가 아니라 순서 문제다.
|
두 트랙은 경쟁 관계가 아니라 순서 문제다.
|
||||||
|
|
||||||
- **OS 레이어(알림 관통형)**: 채택 장벽이 낮다(앱 설치+권한 허용만). 대신 발송 자동화는 각 앱의
|
- **OS 레이어(알림 관통형)**: 채택 장벽이 낮다(앱 설치+권한 허용만). 대신 발송 자동화는 각 앱의
|
||||||
API/자동입력 제약을 받아 범위가 좁고, 검증 안 된 핵심 가설(분신의 자연스러움·신뢰)과 제3자
|
API/자동입력 제약을 받아 범위가 좁고, 검증 안 된 핵심 가설(와카뷰의 자연스러움·신뢰)과 제3자
|
||||||
플랫폼 제약(카카오톡 등의 자동화 정책)이 동시에 섞여 실패 원인을 구분하기 어렵다.
|
플랫폼 제약(카카오톡 등의 자동화 정책)이 동시에 섞여 실패 원인을 구분하기 어렵다.
|
||||||
- **자체 메신저(클로즈드 베타)**: 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 구현해
|
- **자체 메신저(클로즈드 베타)**: 와카뷰 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히 구현해
|
||||||
통제된 소규모 그룹으로 핵심 가설만 순수하게 검증할 수 있다. "메신저를 옮겨야 한다"는 채택
|
통제된 소규모 그룹으로 핵심 가설만 순수하게 검증할 수 있다. "메신저를 옮겨야 한다"는 채택
|
||||||
장벽은 있지만, 베타 단계에서는 어차피 소규모 초대 기반이라 큰 문제가 아니다.
|
장벽은 있지만, 베타 단계에서는 어차피 소규모 초대 기반이라 큰 문제가 아니다.
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록
|
6. [`decision-log.md`](./decision-log.md) — Q1~Q7 결정과 근거, 계속 누적 기록
|
||||||
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
|
7. [`poc-plan.md`](./poc-plan.md) — PoC #1(말투 학습)·#3(사칭/신뢰 수용성) 실행 계획과 Go/No-Go 기준
|
||||||
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
|
8. [`poc-materials.md`](./poc-materials.md) — 모집 문구, 동의 안내, 역할극 스크립트, 인터뷰 질문지 초안
|
||||||
9. [`user-interview-guide.md`](./user-interview-guide.md) — Q3 자율성 수용성 인터뷰 (분신 사용자 관점)
|
9. [`user-interview-guide.md`](./user-interview-guide.md) — Q3 자율성 수용성 인터뷰 (와카뷰 사용자 관점)
|
||||||
10. [`meeting-review-summary.md`](./meeting-review-summary.md) — 회의에서 Q1~Q7을 확정할 때 쓰는 1페이지 요약
|
10. [`meeting-review-summary.md`](./meeting-review-summary.md) — 회의에서 Q1~Q7을 확정할 때 쓰는 1페이지 요약
|
||||||
|
|
||||||
## 8. 다음 액션 체크리스트
|
## 8. 다음 액션 체크리스트
|
||||||
|
|
@ -143,14 +143,17 @@
|
||||||
검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md`
|
검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md`
|
||||||
"말투 검색기" 참고)
|
"말투 검색기" 참고)
|
||||||
- [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정
|
- [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정
|
||||||
4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`bunsin-prototype`).
|
4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`ykavu-prototype`).
|
||||||
PoC #3 역할극 자극재로 사용
|
PoC #3 역할극 자극재로 사용
|
||||||
- [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md`
|
- [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md`
|
||||||
(질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장)
|
(질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장)
|
||||||
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영)
|
- [ ] 위 인터뷰 실제 진행 (참가자 5~10명, 스크리닝 → 본 인터뷰 → 결과 반영) — **맨 마지막(N5/D)**
|
||||||
- [x] 회의 리뷰용 1페이지 요약 자료 작성 — `meeting-review-summary.md`
|
- [x] 회의 리뷰용 1페이지 요약 자료 작성 — `meeting-review-summary.md`
|
||||||
- [ ] 실제 회의에서 위 요약 자료로 문서 세트 전체를 리뷰하고 Q1~Q7을 정식 확정
|
- [x] Q1~Q7 정식 확정 — `decision-log.md` (Phase 1 C, 2026-07-30). PoC 의존 하위만 열림
|
||||||
- [x] Phase 1(자체 앱 빌드) 상세 작업 분해 — `roadmap.md` "Phase 1 상세 작업 분해". PoC 결과
|
- [x] Phase 1(자체 앱 빌드) 상세 작업 분해 — `roadmap.md` "Phase 1 상세 작업 분해". PoC 결과
|
||||||
무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠
|
무관 기반 작업(백엔드/클라이언트 뼈대)과 PoC 결과 필요 항목을 구분해둠
|
||||||
- [ ] 기술 스택 결정 (클라이언트/백엔드/DB/메시지 릴레이/온디바이스 저장소) — `roadmap.md`
|
- [x] 기술 스택 결정 — `tech-design.md` §8 / `roadmap.md` Phase 1 §1
|
||||||
Phase 1 §1, 회의 필요
|
(Flutter/Dart, Go core + Python AI, WebSocket, drift+SQLCipher; 배포 DB는
|
||||||
|
[`deploy-checklist.md`](./deploy-checklist.md) N2-A에서 SQLite 1차 등 운영 결정)
|
||||||
|
- [ ] A~C 이후 실행 트랙 — [`deploy-checklist.md`](./deploy-checklist.md)
|
||||||
|
(N1 스모크 → N2 Docker → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC)
|
||||||
|
|
|
||||||
30
docs/PRD.md
30
docs/PRD.md
|
|
@ -1,34 +1,34 @@
|
||||||
# PRD — 분신 v1 (클로즈드 베타)
|
# PRD — 와카뷰 v1 (클로즈드 베타)
|
||||||
|
|
||||||
범위: `vision.md`의 가치 제안을 자체 앱 클로즈드 베타로 구현한다. 자율성 L0~L2, 시나리오는
|
범위: `vision.md`의 가치 제안을 자체 앱 클로즈드 베타로 구현한다. 자율성 L0~L2, 시나리오는
|
||||||
"읽씹 종결"과 "단톡 따라잡기" 두 가지로 한정한다. (근거: `decision-log.md` Q3, Q5)
|
"읽씹 종결"과 "단톡 따라잡기" 두 가지로 한정한다. (근거: `decision-log.md` Q3, Q5)
|
||||||
|
|
||||||
## 1. 페르소나
|
## 1. 페르소나
|
||||||
|
|
||||||
- **주 사용자(분신을 쓰는 나)** — 메신저 응답 압박을 자주 느끼는 개인. 분신을 켜고, 응답을
|
- **주 사용자(와카뷰를 쓰는 나)** — 메신저 응답 압박을 자주 느끼는 개인. 와카뷰를 켜고, 응답을
|
||||||
검토·승인하거나 위임 범위를 조정한다.
|
검토·승인하거나 위임 범위를 조정한다.
|
||||||
- **상대방** — 분신 사용자와 대화하는 사람. 앱을 설치하지 않았어도 분신의 응답을 받을 수 있어야
|
- **상대방** — 와카뷰 사용자와 대화하는 사람. 앱을 설치하지 않았어도 와카뷰의 응답을 받을 수 있어야
|
||||||
하며(v1은 자체 앱 내부이므로 상대방도 사실상 앱 사용자), 분신 여부를 언제든 확인·거부할 수 있다.
|
하며(v1은 자체 앱 내부이므로 상대방도 사실상 앱 사용자), 와카뷰 여부를 언제든 확인·거부할 수 있다.
|
||||||
|
|
||||||
## 2. 유저 플로우
|
## 2. 유저 플로우
|
||||||
|
|
||||||
### 2.1 온보딩 (말투 학습)
|
### 2.1 온보딩 (말투 학습)
|
||||||
1. 가입 시 기존 대화 일부 임포트 또는 짧은 질문 응답으로 말투 초기 세팅 (목표: 5분 이내)
|
1. 가입 시 기존 대화 일부 임포트 또는 짧은 질문 응답으로 말투 초기 세팅 (목표: 5분 이내)
|
||||||
2. 관계별 페르소나 초기값 설정 — 최소 "가까운 사이 / 공식적인 사이" 2종만 v1에서 지원
|
2. 관계별 페르소나 초기값 설정 — 최소 "가까운 사이 / 공식적인 사이" 2종만 v1에서 지원
|
||||||
3. 분신 자율성 기본값은 **L0(비서 모드)** — 사용자가 명시적으로 올려야 L1, L2로 이동
|
3. 와카뷰 자율성 기본값은 **L0(비서 모드)** — 사용자가 명시적으로 올려야 L1, L2로 이동
|
||||||
|
|
||||||
### 2.2 읽씹 종결 시나리오 (핵심)
|
### 2.2 읽씹 종결 시나리오 (핵심)
|
||||||
1. 사용자가 "방해금지"(수면 중 등) 상태를 켜거나, 앱이 비활성 상태 감지 시 자동 제안
|
1. 사용자가 "방해금지"(수면 중 등) 상태를 켜거나, 앱이 비활성 상태 감지 시 자동 제안
|
||||||
2. 상대가 메시지를 보냄 → 분신이 맥락(상태, 최근 활동 패턴)을 보고 응답 초안 생성
|
2. 상대가 메시지를 보냄 → 와카뷰가 맥락(상태, 최근 활동 패턴)을 보고 응답 초안 생성
|
||||||
3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송 (기본값)
|
3. **L1**: 사용자에게 "지금 자동 응답 보낼까요?" 알림 → 승인 시 발송 (기본값)
|
||||||
4. **L2**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해
|
4. **L2**: 사용자가 화이트리스트에 등록한 상대·주제(예: "가벼운 안부, 시간 문의")에 한해
|
||||||
즉시 자동 발송, 사후 알림
|
즉시 자동 발송, 사후 알림
|
||||||
5. 상대에게는 분신 뱃지가 붙은 말풍선으로 표시됨 (3.1 참고)
|
5. 상대에게는 와카뷰 뱃지가 붙은 말풍선으로 표시됨 (3.1 참고)
|
||||||
6. 사용자가 복귀하면 미응답/보류 항목 요약 제공, 필요 시 후속 메시지 작성
|
6. 사용자가 복귀하면 미응답/보류 항목 요약 제공, 필요 시 후속 메시지 작성
|
||||||
|
|
||||||
### 2.3 단톡 따라잡기 시나리오
|
### 2.3 단톡 따라잡기 시나리오
|
||||||
1. 사용자가 오랜만에 단톡방 진입 또는 "안 본 동안 요약" 요청
|
1. 사용자가 오랜만에 단톡방 진입 또는 "안 본 동안 요약" 요청
|
||||||
2. 분신이 **나에게 멘션된 것 / 결정된 사항** 위주로 3~5줄 요약
|
2. 와카뷰가 **나에게 멘션된 것 / 결정된 사항** 위주로 3~5줄 요약
|
||||||
3. 답장이 필요한 항목에는 초안 버튼 제공 (발송은 항상 사용자 승인 — 이 시나리오는 L0 고정,
|
3. 답장이 필요한 항목에는 초안 버튼 제공 (발송은 항상 사용자 승인 — 이 시나리오는 L0 고정,
|
||||||
자동 발송 없음)
|
자동 발송 없음)
|
||||||
|
|
||||||
|
|
@ -38,14 +38,14 @@
|
||||||
|
|
||||||
| 기능 | 설명 |
|
| 기능 | 설명 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 분신 뱃지 | 분신이 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
|
| 와카뷰 뱃지 | 와카뷰가 작성한 말풍선은 사람 말풍선과 시각적으로 구분(점선 테두리 + 뱃지 라벨) |
|
||||||
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
|
| 자율성 설정(L0~L2) | 전역 기본값 + 상대별 예외 설정 |
|
||||||
| 응답 승인 UI | L1 초안을 한 탭으로 검토·수정·발송 |
|
| 응답 승인 UI | L1 초안을 한 탭으로 검토·수정·발송 |
|
||||||
| 화이트리스트 주제 | L2에서 자동 발송을 허용할 주제/상대를 사용자가 직접 등록 |
|
| 화이트리스트 주제 | L2에서 자동 발송을 허용할 주제/상대를 사용자가 직접 등록 |
|
||||||
| 에스컬레이션 규칙 | 금전, 약속 확정, 감정적/민감 내용 감지 시 자동 처리 금지, 무조건 사용자에게 넘김 |
|
| 에스컬레이션 규칙 | 금전, 약속 확정, 감정적/민감 내용 감지 시 자동 처리 금지, 무조건 사용자에게 넘김 |
|
||||||
| 실시간 본인 확인 | 상대가 "지금 본인이야 분신이야?" 물으면 분신이 스스로 정직하게 답함 |
|
| 실시간 본인 확인 | 상대가 "지금 본인이야 와카뷰가야?" 물으면 와카뷰가 스스로 정직하게 답함 |
|
||||||
| 거부권 | 상대가 "분신 말고 본인이랑만" 요청 시 해당 대화에서 자동응대 즉시 중단 |
|
| 거부권 | 상대가 "와카뷰 말고 본인이랑만" 요청 시 해당 대화에서 자동응대 즉시 중단 |
|
||||||
| 사후 알림 + 되돌리기 | 분신이 취한 모든 자동 행동은 알림 로그에 남고 원클릭 취소 가능 |
|
| 사후 알림 + 되돌리기 | 와카뷰가 취한 모든 자동 행동은 알림 로그에 남고 원클릭 취소 가능 |
|
||||||
| 단톡 요약(멘션 기준) | 안 본 동안 나 언급/결정 사항만 골라 요약 |
|
| 단톡 요약(멘션 기준) | 안 본 동안 나 언급/결정 사항만 골라 요약 |
|
||||||
| 관계별 페르소나(최소 2종) | 가까운 사이 / 공식적인 사이 톤 구분 |
|
| 관계별 페르소나(최소 2종) | 가까운 사이 / 공식적인 사이 톤 구분 |
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
|
|
||||||
### 3.3 P2 / 명시적 범위 밖 (v2+)
|
### 3.3 P2 / 명시적 범위 밖 (v2+)
|
||||||
|
|
||||||
- L3 자리비움 전면 응대, L4 분신 협상 — `roadmap.md` 참고
|
- L3 자리비움 전면 응대, L4 와카뷰 협상 — `roadmap.md` 참고
|
||||||
- OS 레이어(타 앱 알림 관통) — `roadmap.md` 참고
|
- OS 레이어(타 앱 알림 관통) — `roadmap.md` 참고
|
||||||
- 다중 페르소나, 구독 결제 — 수익 모델은 베타 이후
|
- 다중 페르소나, 구독 결제 — 수익 모델은 베타 이후
|
||||||
|
|
||||||
|
|
@ -65,8 +65,8 @@
|
||||||
|
|
||||||
| 상황 | 처리 |
|
| 상황 | 처리 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 분신이 잘못된 맥락으로 응답 초안을 만듦 | L1은 발송 전 사용자 검토이므로 그 자리에서 수정. L2는 사후 알림 + 되돌리기 |
|
| 와카뷰가 잘못된 맥락으로 응답 초안을 만듦 | L1은 발송 전 사용자 검토이므로 그 자리에서 수정. L2는 사후 알림 + 되돌리기 |
|
||||||
| 상대가 분신에게 확정을 요구(예: "그럼 3시 확정이지?") | 분신은 확정 불가 — "본인 확인 필요"로 응답하고 사용자에게 에스컬레이션 |
|
| 상대가 와카뷰에게 확정을 요구(예: "그럼 3시 확정이지?") | 와카뷰는 확정 불가 — "본인 확인 필요"로 응답하고 사용자에게 에스컬레이션 |
|
||||||
| 상대가 짧은 시간에 메시지 도배 | 스팸 감지 임계치 초과 시 응대 중단, 사용자에게 보고 (P1이지만 안전 관련이라 v1 최소 버전 필요) |
|
| 상대가 짧은 시간에 메시지 도배 | 스팸 감지 임계치 초과 시 응대 중단, 사용자에게 보고 (P1이지만 안전 관련이라 v1 최소 버전 필요) |
|
||||||
| 사용자가 여러 상대에게 다른 자율성 레벨을 원함 | 상대별 예외 설정으로 지원 (전역 기본값 + 오버라이드) |
|
| 사용자가 여러 상대에게 다른 자율성 레벨을 원함 | 상대별 예외 설정으로 지원 (전역 기본값 + 오버라이드) |
|
||||||
| 온보딩 시 임포트할 기존 대화가 없음 | 질문 기반 초기 세팅 경로로 폴백 |
|
| 온보딩 시 임포트할 기존 대화가 없음 | 질문 기반 초기 세팅 경로로 폴백 |
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ v1은 **Android만** 대상 (`tech-design.md` §8). Play 스토어 공개가 아
|
||||||
2. Master 머신에서 한 번 생성:
|
2. Master 머신에서 한 번 생성:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias bunsin
|
keytool -genkey -v -keystore ~/ykavu-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias ykavu
|
||||||
```
|
```
|
||||||
|
|
||||||
3. `mobile/android/key.properties.example`을 복사해 `mobile/android/key.properties` 작성:
|
3. `mobile/android/key.properties.example`을 복사해 `mobile/android/key.properties` 작성:
|
||||||
|
|
@ -24,8 +24,8 @@ keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -val
|
||||||
```
|
```
|
||||||
storePassword=...
|
storePassword=...
|
||||||
keyPassword=...
|
keyPassword=...
|
||||||
keyAlias=bunsin
|
keyAlias=ykavu
|
||||||
storeFile=/absolute/path/to/bunsin-release.jks
|
storeFile=/absolute/path/to/ykavu-release.jks
|
||||||
```
|
```
|
||||||
|
|
||||||
4. `key.properties`가 없으면 release 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다
|
4. `key.properties`가 없으면 release 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@
|
||||||
|
|
||||||
| # | 질문 | 결정 | 근거 | 상태 |
|
| # | 질문 | 결정 | 근거 | 상태 |
|
||||||
|---|------|------|------|------|
|
|---|------|------|------|------|
|
||||||
| Q1 | AI 분신으로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 분신은 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 확정 |
|
| Q1 | AI 와카뷰로 확정? | **예** | "기록/관계 피로/프라이버시" 축은 기능 묶음이라 한 문장 정체성이 약함. 와카뷰는 "나를 대신해 남과 대화하는 나"로 명확히 정의됨 | 확정 |
|
||||||
| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 확정 |
|
| Q2 | 타깃: 대중 vs 회사? | **대중 우선** | 회사용은 워크스페이스 도입 결정권자를 설득해야 해 진입 장벽이 큼. 대중은 개인 대 개인으로 바로 가치 체감 가능(읽씹 종결). B2B는 v3 확장 항목으로 남김 | 확정 |
|
||||||
| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(분신 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용. *기본 자율성 레벨(L1 vs L2 시작점)과 화이트리스트 기본 주제는 사람 PoC/실사용 후 확정* | 확정 |
|
| Q3 | 자율성 몇 단계까지 출시? | **L0~L2만** | L3(자리비움 응대)·L4(와카뷰 협상)는 신뢰가 쌓이기 전엔 리스크 대비 이득이 낮음. "켜는 만큼만 만든다" 원칙 적용. *기본 자율성 레벨(L1 vs L2 시작점)과 화이트리스트 기본 주제는 사람 PoC/실사용 후 확정* | 확정 |
|
||||||
| Q4 | 사칭 우려 대응 충분한가? | **1차 설계로 베타 진입, PoC #3로 검증** | 분신 뱃지·거부권·확정 불가·본인확인 고정 문구는 설계상 필수. 상대 신뢰 여부는 D(사람 PoC #3)에서 측정 | 확정 |
|
| Q4 | 사칭 우려 대응 충분한가? | **1차 설계로 베타 진입, PoC #3로 검증** | 와카뷰 뱃지·거부권·확정 불가·본인확인 고정 문구는 설계상 필수. 상대 신뢰 여부는 D(사람 PoC #3)에서 측정 | 확정 |
|
||||||
| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능 | 확정 |
|
| Q5 | MVP 데모 시나리오 1개는? | **읽씹 종결 + 단톡 따라잡기 (묶음)** | 둘 다 L0~L2, 같은 파이프라인(수신 요약 → 맥락 응답 초안) 공유. 발송 자동화 없이도(단톡 따라잡기) 가치 증명 가능 | 확정 |
|
||||||
| Q6 | 서비스 이름 | **"분신" (가칭)** | 한국어로 직관적. 최종 브랜딩/상표는 MVP·베타 반응 이후 | 확정 (가칭 유지) |
|
| Q6 | 서비스 이름 | **"와카뷰 (Ykavu)"** | 기존 가칭 "분신"에서 변경(2026-07-31). "분신"(分身)은 메신저 브랜드로 부르기 무겁고, 焚身(분신자살)과 동음이의 리스크도 있었음. "와카뷰(Ykavu)"는 Master가 최종 선택한 브랜드명 — 메신저 이름으로 짧고 부르기 쉬움. 상표 등록 여부는 여전히 베타 반응 이후 재검토 | 확정 (2026-07-31 명칭 변경) |
|
||||||
| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2** | 아래 "Q7 상세 근거" | 확정 |
|
| Q7 | 자체 앱 vs OS 레이어 시작점 | **자체 앱(클로즈드 베타) 먼저 → OS 레이어는 v2** | 아래 "Q7 상세 근거" | 확정 |
|
||||||
|
|
||||||
확정일: 2026-07-30 (Phase 1 C — Master 승인 하에 제안 → 확정).
|
확정일: 2026-07-30 (Phase 1 C — Master 승인 하에 제안 → 확정).
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
- 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은
|
- 카카오톡·인스타그램은 자동화/크롤링을 정책상 제한하는 경우가 많아, OS 레이어의 "발송" 쪽은
|
||||||
처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설을 제3자 플랫폼 제약과 동시에
|
처음부터 제3자 API 제약을 받는다. 아직 검증되지 않은 핵심 가설을 제3자 플랫폼 제약과 동시에
|
||||||
검증하면 실패 원인을 구분할 수 없다.
|
검증하면 실패 원인을 구분할 수 없다.
|
||||||
- 자체 앱(초대 기반 클로즈드 베타)에서는 분신 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히
|
- 자체 앱(초대 기반 클로즈드 베타)에서는 와카뷰 로직·UX(뱃지, 거부권, 에스컬레이션)를 온전히
|
||||||
구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다.
|
구현해 통제된 소규모 그룹으로 "이게 정말 쓸만한가"만 순수하게 검증할 수 있다.
|
||||||
- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능.
|
- 이 단계에서 만든 요약/응답 초안 생성 로직은 이후 OS 레이어(문자·이메일 우선)로 그대로 재사용 가능.
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
|
|
||||||
## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후)
|
## 아직 열려 있는 하위 질문 (PoC/실사용 의존 — D 이후)
|
||||||
|
|
||||||
- 분신 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터)
|
- 와카뷰 응답 임계점·화이트리스트 기본 주제 목록 (실사용 데이터)
|
||||||
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
|
- 자율성 기본 시작 레벨 L1 vs L2 (Q3 인터뷰)
|
||||||
- 상표/네이밍 최종안 (Q6은 가칭 확정)
|
- 상표 정식 등록 여부 (Q6은 "와카뷰"로 명칭 확정, 등록 절차는 베타 반응 이후)
|
||||||
- 베타 참가자 모집 규모와 방식
|
- 베타 참가자 모집 규모와 방식
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
# 배포·잔여 작업 체크리스트 (와카뷰)
|
||||||
|
|
||||||
|
Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
|
||||||
|
권위 문서: [`roadmap.md`](./roadmap.md) · [`decision-log.md`](./decision-log.md) · [`AGENTS.md`](../AGENTS.md).
|
||||||
|
|
||||||
|
**사람 PoC(D)와 Phase 1 §3 기본값은 맨 마지막.** 추측으로 채우지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 통합 현황 (Claude + Cursor)
|
||||||
|
|
||||||
|
### DONE — Claude (`claude/project-planning-approach-ukdz31` 계열)
|
||||||
|
|
||||||
|
- [x] 기획 문서 세트 (`PLANNING`, vision/PRD/tech-design/risk/roadmap, decision-log 초안)
|
||||||
|
- [x] PoC #1/#3 계획·모집/역할극 자료·Q3 인터뷰 가이드·프로토타입 앵커
|
||||||
|
- [x] `poc/tone-corpus/` 파이프라인 (전처리·draft·escalation·retrieve·blind_eval)
|
||||||
|
- [x] 스택 확정: Flutter + Go core + Python AI
|
||||||
|
- [x] `core-backend/` · `ai-service/` · 하드게이트·거부권·초대·되돌리기·L0~L2 QA
|
||||||
|
- [x] Flutter UI 테마 폴리시 (`app_theme` + 화면별 시각 개선)
|
||||||
|
|
||||||
|
### DONE — Cursor 후속
|
||||||
|
|
||||||
|
- [x] A1/A2 API · A3 Flutter 메신저 연결 · API E2E (`scripts/e2e_a3.py`)
|
||||||
|
- [x] Phase 1 B (drift/SQLCipher, FCM 골격, sessions, metrics, data-flow, identity)
|
||||||
|
- [x] Phase 1 C (Q1~Q7 **확정**, invite-ops, Android release 경로)
|
||||||
|
- [x] Flutter Web SQLCipher stub · Twin Shadow UI · CORS · `DEMO-BUNSIN`
|
||||||
|
- [x] GitHub + Gitea 듀얼 리모트 (`scripts/push-both.sh`)
|
||||||
|
|
||||||
|
### NOW
|
||||||
|
|
||||||
|
- 앱 코드는 클로즈드 베타 직전 수준
|
||||||
|
- **프로덕션 Docker / `msn.iykyka.com` 배포는 미착수**
|
||||||
|
- 실 FCM · Android UI 수동 QA · 사람 PoC 실행은 남음
|
||||||
|
|
||||||
|
### NEXT 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화
|
||||||
|
→ N4 FCM·Android QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
|
||||||
|
```
|
||||||
|
|
||||||
|
### LOCKED
|
||||||
|
|
||||||
|
- [ ] Phase 2+ (L3 / OS 레이어 / L4 / B2B) — Phase 1 게이트 전 구현 금지
|
||||||
|
- [ ] PoC §3 기본값(자율성 시작 레벨, 화이트리스트 기본 주제, 신뢰 UX 최종 카피) 추측 금지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 항목 템플릿
|
||||||
|
|
||||||
|
각 ID를 처리할 때 아래로 상태를 갱신한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Status: todo | doing | done | blocked
|
||||||
|
Depends on:
|
||||||
|
Acceptance: (체크리스트)
|
||||||
|
Notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N1 — 배포 전 스모크
|
||||||
|
|
||||||
|
| ID | 작업 | Status | 완료 조건 |
|
||||||
|
|----|------|--------|-----------|
|
||||||
|
| **N1-1** | 서비스 기동 | todo | `8080` core-backend, `8001` ai-service, (옵션) `5555` Flutter web 헬스 OK |
|
||||||
|
| **N1-2** | API E2E | todo | `ADMIN_API_TOKEN` 설정 후 `python3 scripts/e2e_a3.py` 16/16 |
|
||||||
|
| **N1-3** | Web 가입 스모크 | todo | `DEMO-BUNSIN` → 표시명 → 가입 → 세션/다음 화면 |
|
||||||
|
| **N1-4** | 말투 온보딩 스모크 | todo | 샘플 저장 또는 스킵 후 대화 목록 진입 |
|
||||||
|
| **N1-5** | 핵심 메신저 스모크 | todo | 연락처·대화·메시지 또는 초안/L1 중 최소 1경로 UI 왕복 |
|
||||||
|
| **N1-6** | 프로덕션 CORS/API 메모 | todo | `msn.iykyka.com`용 origins / `CORE_API_BASE` 변경 목록 작성 |
|
||||||
|
|
||||||
|
로컬 포트 참고: 앱 **5555**, 코어 **8080**, AI **8001**. Dart VM Service 고포트(예: 39369)는 디버그용 — 무시 가능.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N2 — Docker 배포 (`https://msn.iykyka.com`) — Plan A
|
||||||
|
|
||||||
|
### N2-A. 착수 전 결정 (Master 확인)
|
||||||
|
|
||||||
|
| ID | 결정 | 제안 기본값 | Status |
|
||||||
|
|----|------|-------------|--------|
|
||||||
|
| **N2-A1** | 구 Node MSN 교체 | 교체(Plan A) | todo |
|
||||||
|
| **N2-A2** | DB | 1차 SQLite 파일 볼륨 (PostgreSQL은 이후) | todo |
|
||||||
|
| **N2-A3** | AI 서비스 노출 | 내부망만 (외부 포트 미공개) | todo |
|
||||||
|
| **N2-A4** | 클라이언트 제공 | Web 컨테이너 ± 내부 APK (둘 다/웹만 결정) | todo |
|
||||||
|
| **N2-A5** | 시크릿 관리 | Portainer/호스트 env — **git 금지** | todo |
|
||||||
|
| **N2-A6** | 데모 초대 | 프로덕션 `ALLOW_DEMO_INVITE` on/off | todo |
|
||||||
|
|
||||||
|
### N2-B. 이미지·compose
|
||||||
|
|
||||||
|
| ID | 작업 | Status | 완료 조건 |
|
||||||
|
|----|------|--------|-----------|
|
||||||
|
| **N2-B1** | `core-backend` Dockerfile | todo | `docker build` 성공, migrate/기동 |
|
||||||
|
| **N2-B2** | `ai-service` Dockerfile | todo | `docker build` 성공 |
|
||||||
|
| **N2-B3** | Flutter web 빌드/서빙 | todo | `flutter build web` + nginx(또는 Caddy)로 `/` 로딩 |
|
||||||
|
| **N2-B4** | `docker-compose.yml` | todo | `up` 후 서비스 healthy |
|
||||||
|
| **N2-B5** | env 템플릿 | todo | `.env.example`에 키만: `GEMINI_API_KEY`, `ADMIN_API_TOKEN`, `AI_SERVICE_URL`, DB, `ALLOW_DEMO_INVITE`, CORS/origins, `FCM_*` |
|
||||||
|
| **N2-B6** | 데이터 볼륨 | todo | 재시작 후 SQLite(및 필요 경로) 유지 |
|
||||||
|
| **N2-B7** | 내부 DNS | todo | Go → `http://ai-service:…` draft/escalate 동작 |
|
||||||
|
| **N2-B8** | CORS + API base | todo | `https://msn.iykyka.com`에서 브라우저 가입 성공 |
|
||||||
|
| **N2-B9** | 리버스 프록시 | todo | HTTPS로 도메인 접속 |
|
||||||
|
| **N2-B10** | Portainer 스택 | todo | 스택 Up, 절차를 Notes에 기록 |
|
||||||
|
| **N2-B11** | 구 MSN 컷오버 | todo | 새 스택이 도메인 응답 + 롤백 메모 |
|
||||||
|
| **N2-B12** | 배포 스모크 | todo | N1-3~N1-5를 프로덕션 URL로 재실행 |
|
||||||
|
| **N2-B13** | 운영 runbook | todo | 로그·재시작·SQLite 백업·초대 발급 1페이지 (`docs/` 또는 본 파일 Notes) |
|
||||||
|
|
||||||
|
관련: Portainer `https://portainer.iykyka.com/`, 호스트 SSH는 인프라 메모 참고(시크릿은 커밋 금지).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N3 — 배포 직후 안정화
|
||||||
|
|
||||||
|
| ID | 작업 | Status | 완료 조건 |
|
||||||
|
|----|------|--------|-----------|
|
||||||
|
| **N3-1** | 헬스/로그 | todo | OOM·CORS·502 없음 |
|
||||||
|
| **N3-2** | 초대 발급 리허설 | todo | `docs/invite-ops.md` 절차 1회 |
|
||||||
|
| **N3-3** | admin metrics | todo | `/admin/metrics`·`/admin/dashboard` 토큰 조회 |
|
||||||
|
| **N3-4** | Gemini | todo | draft 1회 실호출 또는 mock 정책 명시 |
|
||||||
|
| **N3-5** | 백업 리허설 | todo | SQLite 볼륨 복사/복구 1회 |
|
||||||
|
| **N3-6** | 테스터 안내 | todo | URL + 초대(`DEMO-BUNSIN` 또는 개인 코드) + 주의사항 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N4 — 베타 품질 잔여
|
||||||
|
|
||||||
|
### FCM
|
||||||
|
|
||||||
|
| ID | 작업 | Status | 완료 조건 |
|
||||||
|
|----|------|--------|-----------|
|
||||||
|
| **N4-1** | Firebase + `google-services.json` | todo | Android 앱 연결 |
|
||||||
|
| **N4-2** | 실 FCM registration token | todo | install-id 플레이스홀더 제거 |
|
||||||
|
| **N4-3** | 서버 FCM 자격증명 | todo | env만 (`FCM_SERVER_KEY` 또는 HTTP v1) |
|
||||||
|
| **N4-4** | 푸시 수신 | todo | `/admin/push-test` + 에스컬레이션 수신 |
|
||||||
|
|
||||||
|
### Android UI 탭 (`mobile/README.md`)
|
||||||
|
|
||||||
|
| ID | 작업 | Status |
|
||||||
|
|----|------|--------|
|
||||||
|
| **N4-5** | 가입 → 말투 저장 | todo |
|
||||||
|
| **N4-6** | 연락처 → 대화 → 메시지·히스토리 | todo |
|
||||||
|
| **N4-7** | L1 초안 수정/버리기/승인·뱃지 | todo |
|
||||||
|
| **N4-8** | 에스컬레이션 → 사후알림 함 | todo |
|
||||||
|
| **N4-9** | 되돌리기·거부권 | todo |
|
||||||
|
| **N4-10** | 자율성 L0~L2 + 화이트리스트 | todo |
|
||||||
|
|
||||||
|
### 후순위
|
||||||
|
|
||||||
|
| ID | 작업 | Status | 비고 |
|
||||||
|
|----|------|--------|------|
|
||||||
|
| **N4-11** | 오프라인 메시지 큐 | todo | 멀티디바이스 고도화 |
|
||||||
|
| **N4-12** | 자연스러움 피드백 UI | todo | vision 지표 |
|
||||||
|
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
|
||||||
|
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
|
||||||
|
| **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N5 — 사람 PoC (D) — **지금 열지 않음**
|
||||||
|
|
||||||
|
N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` Phase 1 §3과 동일.
|
||||||
|
|
||||||
|
| ID | 작업 | Status |
|
||||||
|
|----|------|--------|
|
||||||
|
| **N5-1** | PoC #1/#3 참가자 모집·실행 | locked |
|
||||||
|
| **N5-2** | Q3 인터뷰 → 자율성 기본값 | locked |
|
||||||
|
| **N5-3** | 화이트리스트 기본 주제 | locked |
|
||||||
|
| **N5-4** | 신뢰 UX 문구/위치 확정 | locked |
|
||||||
|
| **N5-5** | vision 게이트 → 실제 클로즈드 베타 오픈 | locked |
|
||||||
|
|
||||||
|
자료: [`poc-plan.md`](./poc-plan.md) · [`poc-materials.md`](./poc-materials.md) · [`user-interview-guide.md`](./user-interview-guide.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 바로 다음 5개 (권장)
|
||||||
|
|
||||||
|
1. **N2-A1~A6** 결정 체크
|
||||||
|
2. **N1-1~N1-5** 스모크
|
||||||
|
3. **N2-B1~B4** Dockerfile + compose
|
||||||
|
4. **N2-B8~B12** 도메인·Portainer·컷오버·스모크
|
||||||
|
5. **N3-6** 테스터 안내
|
||||||
|
|
||||||
|
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
| `risk-log.md` | 리스크 + 완화 상태 | 결정 반영 완료 |
|
| `risk-log.md` | 리스크 + 완화 상태 | 결정 반영 완료 |
|
||||||
| `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 |
|
| `roadmap.md` | Phase 0~4 게이트 기반 로드맵 | 결정 반영 완료 |
|
||||||
| `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** |
|
| `poc-plan.md` / `poc-materials.md` | PoC #1·#3 방법론 + 바로 쓸 모집문구·스크립트 | 계획 완료, **실행 전** |
|
||||||
| `user-interview-guide.md` | Q3 위임 의향 인터뷰 (분신 사용자 관점) | 계획 완료, **실행 전** |
|
| `user-interview-guide.md` | Q3 위임 의향 인터뷰 (와카뷰 사용자 관점) | 계획 완료, **실행 전** |
|
||||||
| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, 앵커 [`prototype.md`](./prototype.md) |
|
| 클릭 프로토타입 | 읽씹종결·본인확인·거부권·에스컬레이션·자율성설정 4장면 | 완료, 앵커 [`prototype.md`](./prototype.md) |
|
||||||
|
|
||||||
**아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행,
|
**아직 안 된 것 (사람이 직접 해야 함):** 실제 참가자 모집, 대화 샘플 수집, 역할극/인터뷰 진행,
|
||||||
|
|
@ -25,12 +25,12 @@
|
||||||
|
|
||||||
각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다.
|
각 항목을 "확정" 또는 "재검토"로 표시하고, 재검토라면 이유와 다음 액션을 적는다.
|
||||||
|
|
||||||
- [x] **Q1** AI 분신 컨셉 확정? → 확정: 예 (`decision-log.md`)
|
- [x] **Q1** AI 와카뷰 컨셉 확정? → 확정: 예 (`decision-log.md`)
|
||||||
- [x] **Q2** 타깃 대중 우선? → 확정: 예 (B2B는 이후)
|
- [x] **Q2** 타깃 대중 우선? → 확정: 예 (B2B는 이후)
|
||||||
- [x] **Q3** 자율성 L0~L2만 출시? → 확정: 예 (기본 레벨·화이트리스트는 PoC/실사용 후)
|
- [x] **Q3** 자율성 L0~L2만 출시? → 확정: 예 (기본 레벨·화이트리스트는 PoC/실사용 후)
|
||||||
- [x] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 확정: 1차 설계로 베타, PoC#3로 검증
|
- [x] **Q4** 사칭 우려 대응(뱃지·거부권) 충분? → 확정: 1차 설계로 베타, PoC#3로 검증
|
||||||
- [x] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 확정: 예
|
- [x] **Q5** MVP 시나리오 = 읽씹종결 + 단톡따라잡기? → 확정: 예
|
||||||
- [x] **Q6** 서비스 이름 "분신"(가칭)? → 확정: 가칭 유지
|
- [x] **Q6** 서비스 이름 "와카뷰"(가칭)? → 확정: 가칭 유지
|
||||||
- [x] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 확정: 예
|
- [x] **Q7** 자체 앱 클로즈드 베타 먼저, OS 레이어는 이후? → 확정: 예
|
||||||
|
|
||||||
## 회의에서 정할 것 (Q1~Q7 확정 외)
|
## 회의에서 정할 것 (Q1~Q7 확정 외)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
## 1. PoC #1 참가자 모집 문구
|
## 1. PoC #1 참가자 모집 문구
|
||||||
|
|
||||||
> 안녕하세요! 요즘 만들고 있는 "분신"이라는 AI 메신저 프로젝트에서, AI가 제 말투를 얼마나
|
> 안녕하세요! 요즘 만들고 있는 "와카뷰"이라는 AI 메신저 프로젝트에서, AI가 제 말투를 얼마나
|
||||||
> 잘 따라 하는지 테스트해보려고 해요. 참여 방법은 이렇습니다.
|
> 잘 따라 하는지 테스트해보려고 해요. 참여 방법은 이렇습니다.
|
||||||
>
|
>
|
||||||
> 1. 최근 카톡 대화 내보내기(50~100개 정도, 민감한 내용은 미리 빼셔도 돼요)를 공유해주세요
|
> 1. 최근 카톡 대화 내보내기(50~100개 정도, 민감한 내용은 미리 빼셔도 돼요)를 공유해주세요
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
|
|
||||||
## 2. PoC #3 역할극 스크립트 (프로토타입 보완용)
|
## 2. PoC #3 역할극 스크립트 (프로토타입 보완용)
|
||||||
|
|
||||||
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
|
`ykavu-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
|
||||||
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
|
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
|
||||||
공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).
|
공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).
|
||||||
|
|
||||||
|
|
@ -40,29 +40,29 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
상대: 오늘 좀 힘든 일이 있었어... 얘기해도 될까
|
상대: 오늘 좀 힘든 일이 있었어... 얘기해도 될까
|
||||||
분신: (감정적으로 무거운 주제로 판단, 보류)
|
와카뷰: (감정적으로 무거운 주제로 판단, 보류)
|
||||||
죄송해요, 이런 얘기는 제가 대신 답하기 어려워서 지우님께 바로 알려드렸어요.
|
죄송해요, 이런 얘기는 제가 대신 답하기 어려워서 지우님께 바로 알려드렸어요.
|
||||||
조금만 기다려주시면 지우님이 직접 답장하실 거예요.
|
조금만 기다려주시면 지우님이 직접 답장하실 거예요.
|
||||||
(본인에게 알림: "은채님이 힘든 일이 있다고 하셨어요 — 직접 답장이 필요해요")
|
(본인에게 알림: "은채님이 힘든 일이 있다고 하셨어요 — 직접 답장이 필요해요")
|
||||||
```
|
```
|
||||||
|
|
||||||
**관찰 포인트**: 분신이 "대신 답하기 어렵다"고 보류하는 것에 대해 상대가 서운해하는지,
|
**관찰 포인트**: 와카뷰가 "대신 답하기 어렵다"고 보류하는 것에 대해 상대가 서운해하는지,
|
||||||
아니면 오히려 더 신뢰하는지 — 감정 주제에서의 반응은 업무/약속 주제와 다를 수 있음.
|
아니면 오히려 더 신뢰하는지 — 감정 주제에서의 반응은 업무/약속 주제와 다를 수 있음.
|
||||||
|
|
||||||
### 역할극 진행 스크립트 (진행자용)
|
### 역할극 진행 스크립트 (진행자용)
|
||||||
|
|
||||||
1. (도입, 1분) "지금부터 짧은 대화를 몇 번 나눠볼 거예요. 상대방이 되어 편하게 대화해주시면 됩니다."
|
1. (도입, 1분) "지금부터 짧은 대화를 몇 번 나눠볼 거예요. 상대방이 되어 편하게 대화해주시면 됩니다."
|
||||||
2. (프로토타입 시연, 3분) 4개 탭을 순서대로 같이 보며 진행 — 매 장면 뒤 "지금 느낌이 어땠어요?" 짧게 질문
|
2. (프로토타입 시연, 3분) 4개 탭을 순서대로 같이 보며 진행 — 매 장면 뒤 "지금 느낌이 어땠어요?" 짧게 질문
|
||||||
3. (시나리오 E 역할극, 3분) 진행자가 분신 역할, 참가자가 상대방 역할로 실제 대화
|
3. (시나리오 E 역할극, 3분) 진행자가 와카뷰 역할, 참가자가 상대방 역할로 실제 대화
|
||||||
4. (사후 인터뷰, 5분) 아래 §3 질문지 사용
|
4. (사후 인터뷰, 5분) 아래 §3 질문지 사용
|
||||||
|
|
||||||
## 3. 사후 인터뷰 질문지 (PoC #3 공용)
|
## 3. 사후 인터뷰 질문지 (PoC #3 공용)
|
||||||
|
|
||||||
1. 방금 대화가 불편하게 느껴진 순간이 있었나요? 있다면 어떤 부분이었나요?
|
1. 방금 대화가 불편하게 느껴진 순간이 있었나요? 있다면 어떤 부분이었나요?
|
||||||
2. "본인이야 분신이야?" 같은 질문을 실제로 하고 싶어졌나요? 안 했다면 왜인가요?
|
2. "본인이야 와카뷰가야?" 같은 질문을 실제로 하고 싶어졌나요? 안 했다면 왜인가요?
|
||||||
3. 분신이 답을 미루거나 보류했을 때(에스컬레이션), 안심됐나요 아니면 답답했나요?
|
3. 와카뷰가 답을 미루거나 보류했을 때(에스컬레이션), 안심됐나요 아니면 답답했나요?
|
||||||
4. 거부권("본인이랑만 얘기하고 싶다")을 쓴다면, 언제 쓰고 싶을 것 같나요?
|
4. 거부권("본인이랑만 얘기하고 싶다")을 쓴다면, 언제 쓰고 싶을 것 같나요?
|
||||||
5. 이런 분신과 대화하는 친구가 있다면, 계속 편하게 대화할 수 있을 것 같나요? 왜 그런가요?
|
5. 이런 와카뷰와 대화하는 친구가 있다면, 계속 편하게 대화할 수 있을 것 같나요? 왜 그런가요?
|
||||||
|
|
||||||
## 참가자 확보 규모 (재확인)
|
## 참가자 확보 규모 (재확인)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,17 @@
|
||||||
### 방법
|
### 방법
|
||||||
1. **샘플 수집**: 참가자 3~5명당 최근 대화 50~100개(카카오톡 내보내기 등) 확보. 민감한 대화는
|
1. **샘플 수집**: 참가자 3~5명당 최근 대화 50~100개(카카오톡 내보내기 등) 확보. 민감한 대화는
|
||||||
참가자가 직접 제외하도록 안내 (연구 목적 최소 수집 원칙, `tech-design.md` §5와 동일한 원칙 적용)
|
참가자가 직접 제외하도록 안내 (연구 목적 최소 수집 원칙, `tech-design.md` §5와 동일한 원칙 적용)
|
||||||
2. **분리**: 대화의 80%는 "학습용"(말투 특징 추출), 20%는 "평가용"(실제 있었던 대화지만 분신
|
2. **분리**: 대화의 80%는 "학습용"(말투 특징 추출), 20%는 "평가용"(실제 있었던 대화지만 와카뷰
|
||||||
응답과 비교하지 않고 감춰둔 답)으로 나눈다
|
응답과 비교하지 않고 감춰둔 답)으로 나눈다
|
||||||
3. **응답 생성**: 평가용 대화의 상대방 메시지에 대해 분신이 응답 초안을 생성
|
3. **응답 생성**: 평가용 대화의 상대방 메시지에 대해 와카뷰가 응답 초안을 생성
|
||||||
4. **블라인드 평가**: 참가자 본인과, 참가자를 잘 아는 지인 1~2명에게 "실제 답장 vs 분신 초안"을
|
4. **블라인드 평가**: 참가자 본인과, 참가자를 잘 아는 지인 1~2명에게 "실제 답장 vs 와카뷰 초안"을
|
||||||
섞어서 보여주고 구분 가능한지 + "내(그의) 말투 같다"를 5점 척도로 평가
|
섞어서 보여주고 구분 가능한지 + "내(그의) 말투 같다"를 5점 척도로 평가
|
||||||
|
|
||||||
### 평가 기준 (Go/No-Go)
|
### 평가 기준 (Go/No-Go)
|
||||||
| 지표 | 기준 |
|
| 지표 | 기준 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| "말투 같다" 평균 점수 | 4/5 이상 |
|
| "말투 같다" 평균 점수 | 4/5 이상 |
|
||||||
| 실제 답장과 분신 초안을 구분 못하는 비율 | 40% 이상 (완전히 구분 안 되길 기대하지 않음 — 어색하지만 않으면 됨) |
|
| 실제 답장과 와카뷰 초안을 구분 못하는 비율 | 40% 이상 (완전히 구분 안 되길 기대하지 않음 — 어색하지만 않으면 됨) |
|
||||||
| 명백히 "이상하다"는 반응 비율 | 10% 미만 |
|
| 명백히 "이상하다"는 반응 비율 | 10% 미만 |
|
||||||
|
|
||||||
기준 미달 시: `vision.md`의 가치제안 자체를 재검토하거나, 온디바이스 학습 방식(특징 추출 방식,
|
기준 미달 시: `vision.md`의 가치제안 자체를 재검토하거나, 온디바이스 학습 방식(특징 추출 방식,
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
주입하는 방식 — `tech-design.md` §2-1에 구체화. PoC #1에서 수집한 참가자 샘플이 바로 이 메커니즘의
|
주입하는 방식 — `tech-design.md` §2-1에 구체화. PoC #1에서 수집한 참가자 샘플이 바로 이 메커니즘의
|
||||||
"말투 예시"가 된다.
|
"말투 예시"가 된다.
|
||||||
|
|
||||||
## PoC #3 — 사칭/신뢰 수용성 (분신 뱃지·거부권 UX)
|
## PoC #3 — 사칭/신뢰 수용성 (와카뷰 뱃지·거부권 UX)
|
||||||
|
|
||||||
### 목적
|
### 목적
|
||||||
`decision-log.md` Q4("사칭 우려 대응 충분한가?")는 아직 가설이다. 뱃지·거부권·확정 불가 원칙이
|
`decision-log.md` Q4("사칭 우려 대응 충분한가?")는 아직 가설이다. 뱃지·거부권·확정 불가 원칙이
|
||||||
|
|
@ -56,15 +56,15 @@
|
||||||
프로토타입을 이미 제작해둠 (`PLANNING.md` §8 참고). 역할극에서 이 프로토타입을 그대로 보여주며
|
프로토타입을 이미 제작해둠 (`PLANNING.md` §8 참고). 역할극에서 이 프로토타입을 그대로 보여주며
|
||||||
"이런 상황이면 어떨 것 같은지" 반응을 끌어내는 자극재로 사용한다. 추가로 필요하면 감정적인
|
"이런 상황이면 어떨 것 같은지" 반응을 끌어내는 자극재로 사용한다. 추가로 필요하면 감정적인
|
||||||
대화 등 프로토타입에 없는 케이스만 별도 스크립트로 보완
|
대화 등 프로토타입에 없는 케이스만 별도 스크립트로 보완
|
||||||
2. **참가자 역할**: "상대방" 역할 참가자 5~10명에게 위 프로토타입/시나리오로 분신과 대화하는
|
2. **참가자 역할**: "상대방" 역할 참가자 5~10명에게 위 프로토타입/시나리오로 와카뷰와 대화하는
|
||||||
경험을 시켜봄 (Wizard-of-Oz 방식도 가능 — 실제 자동 응답 시스템 없이 사람이 분신 역할을 대신
|
경험을 시켜봄 (Wizard-of-Oz 방식도 가능 — 실제 자동 응답 시스템 없이 사람이 와카뷰 역할을 대신
|
||||||
수행해도 됨, 이 단계에서는 UX/신뢰 반응만 측정)
|
수행해도 됨, 이 단계에서는 UX/신뢰 반응만 측정)
|
||||||
3. **관찰 포인트**:
|
3. **관찰 포인트**:
|
||||||
- 뱃지를 보고 "이건 AI구나"를 즉시 인지하는가
|
- 뱃지를 보고 "이건 AI구나"를 즉시 인지하는가
|
||||||
- "본인이야 분신이야?" 질문을 자연스럽게 던지는가, 던졌을 때 답변에 만족하는가
|
- "본인이야 와카뷰가야?" 질문을 자연스럽게 던지는가, 던졌을 때 답변에 만족하는가
|
||||||
- 거부권("본인이랑만 얘기하고 싶다")을 실제로 쓰고 싶어지는 순간이 있는가, 그 요청이 잘 반영됐다고 느끼는가
|
- 거부권("본인이랑만 얘기하고 싶다")을 실제로 쓰고 싶어지는 순간이 있는가, 그 요청이 잘 반영됐다고 느끼는가
|
||||||
- 약속/금전 얘기가 나왔을 때 분신이 보류하는 것에 대한 반응 (안심 vs 답답함)
|
- 약속/금전 얘기가 나왔을 때 와카뷰가 보류하는 것에 대한 반응 (안심 vs 답답함)
|
||||||
4. **사후 인터뷰**: "이 대화가 불편했는가", "다음에도 이 사람의 분신과 대화하고 싶은가" 개방형 질문
|
4. **사후 인터뷰**: "이 대화가 불편했는가", "다음에도 이 사람의 와카뷰와 대화하고 싶은가" 개방형 질문
|
||||||
|
|
||||||
### 평가 기준 (Go/No-Go)
|
### 평가 기준 (Go/No-Go)
|
||||||
| 지표 | 기준 |
|
| 지표 | 기준 |
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ Phase 1 C: PoC #3 역할극에서 쓰는 **클릭 프로토타입**의 공식
|
||||||
|
|
||||||
| 항목 | 값 |
|
| 항목 | 값 |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| **식별자** | `bunsin-prototype` (`poc-materials.md` §2와 동일) |
|
| **식별자** | `ykavu-prototype` (`poc-materials.md` §2와 동일) |
|
||||||
| **공유 URL** | **TBD — Master 기입** (Claude Artifacts 공개 permalink 또는 정적 호스팅 URL) |
|
| **공유 URL** | **TBD — Master 기입** (Claude Artifacts 공개 permalink 또는 정적 호스팅 URL) |
|
||||||
| **docs 앵커** | 이 파일. 다른 문서는 프로토타입을 말할 때 여기로 링크한다 |
|
| **docs 앵커** | 이 파일. 다른 문서는 프로토타입을 말할 때 여기로 링크한다 |
|
||||||
|
|
||||||
|
|
@ -33,4 +33,4 @@ SHARE_URL=
|
||||||
|
|
||||||
1. 프로토타입 장면을 바꾸면 이 파일에 변경일·요약 한 줄을 추가한다.
|
1. 프로토타입 장면을 바꾸면 이 파일에 변경일·요약 한 줄을 추가한다.
|
||||||
2. `SHARE_URL`이 비어 있거나 404면 PoC #3 / 외부 공유 전에 반드시 채운다.
|
2. `SHARE_URL`이 비어 있거나 404면 PoC #3 / 외부 공유 전에 반드시 채운다.
|
||||||
3. 호스팅을 옮겨도 식별자 `bunsin-prototype`과 이 앵커 문서는 유지한다.
|
3. 호스팅을 옮겨도 식별자 `ykavu-prototype`과 이 앵커 문서는 유지한다.
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
|
|
||||||
| 리스크 | 영향 | 완화 방안 | 상태 |
|
| 리스크 | 영향 | 완화 방안 | 상태 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 분신이 틀린 응답/약속을 자동 발송 | 사용자 신뢰 상실, 관계 손상 | 사후 알림 + 원클릭 되돌리기, 확정성 있는 내용은 항상 보류 | 설계 반영 — 실사용 검증 필요 |
|
| 와카뷰가 틀린 응답/약속을 자동 발송 | 사용자 신뢰 상실, 관계 손상 | 사후 알림 + 원클릭 되돌리기, 확정성 있는 내용은 항상 보류 | 설계 반영 — 실사용 검증 필요 |
|
||||||
| 상대가 분신을 사칭/신뢰 문제로 인식 | 확산 저해, 컨셉 자체 붕괴 | 분신 뱃지, 실시간 본인 확인, 거부권 | 설계 반영 — Q4 핵심 리스크, PoC #3로 검증 예정 |
|
| 상대가 와카뷰를 사칭/신뢰 문제로 인식 | 확산 저해, 컨셉 자체 붕괴 | 와카뷰 뱃지, 실시간 본인 확인, 거부권 | 설계 반영 — Q4 핵심 리스크, PoC #3로 검증 예정 |
|
||||||
| 자동응대가 스팸에 악용됨 | 무한 응답, 사용자 피해 | 스팸/도배 감지 시 응대 중단 (P1, v1 최소 버전 필요) | 부분 반영 — v1 최소 버전 필요, `PRD.md` §4 |
|
| 자동응대가 스팸에 악용됨 | 무한 응답, 사용자 피해 | 스팸/도배 감지 시 응대 중단 (P1, v1 최소 버전 필요) | 부분 반영 — v1 최소 버전 필요, `PRD.md` §4 |
|
||||||
| 에스컬레이션 판정기 오탐/누락 | 안전선 위반(금전/약속 자동 확정) 가능성 | 규칙 기반 + 애매하면 항상 에스컬레이션(fail-safe) | 1차 구현 완료 — `poc/tone-corpus/escalation_filter.py`, 자체 테스트 10/10, 검증셋 트리거율 0.93%. 실사용 오탐/누락률은 PoC 로그로 계속 튜닝 |
|
| 에스컬레이션 판정기 오탐/누락 | 안전선 위반(금전/약속 자동 확정) 가능성 | 규칙 기반 + 애매하면 항상 에스컬레이션(fail-safe) | 1차 구현 완료 — `poc/tone-corpus/escalation_filter.py`, 자체 테스트 10/10, 검증셋 트리거율 0.93%. 실사용 오탐/누락률은 PoC 로그로 계속 튜닝 |
|
||||||
| 온디바이스 말투 학습 품질 미흡 | "나답지 않다"는 인상, 핵심 가치 제안 실패 | PoC #1(§4)로 사전 검증, 교정 학습으로 지속 개선 | 미검증 — 최우선 PoC |
|
| 온디바이스 말투 학습 품질 미흡 | "나답지 않다"는 인상, 핵심 가치 제안 실패 | PoC #1(§4)로 사전 검증, 교정 학습으로 지속 개선 | 미검증 — 최우선 PoC |
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
- 온디바이스 말투 학습 품질 검증 (`PLANNING.md` §4 PoC #1)
|
- 온디바이스 말투 학습 품질 검증 (`PLANNING.md` §4 PoC #1)
|
||||||
- 에스컬레이션 판정기(규칙 기반) 최소 프로토타입으로 오탐/누락 감 잡기
|
- 에스컬레이션 판정기(규칙 기반) 최소 프로토타입으로 오탐/누락 감 잡기
|
||||||
- 목표: "분신이 나답게 느껴지는가"에 대해 Go/No-Go 판단 근거 확보
|
- 목표: "와카뷰가 나답게 느껴지는가"에 대해 Go/No-Go 판단 근거 확보
|
||||||
|
|
||||||
## Phase 1 — v1 클로즈드 베타 (자체 앱, `PRD.md` 범위)
|
## Phase 1 — v1 클로즈드 베타 (자체 앱, `PRD.md` 범위)
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
|
WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
|
||||||
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
|
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
|
||||||
기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
|
기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
|
||||||
- [x] 분신 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
|
- [x] 와카뷰 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
|
||||||
서버/AI 프롬프트 측과 이어서 다듬을 것
|
서버/AI 프롬프트 측과 이어서 다듬을 것
|
||||||
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
|
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
|
||||||
서버 contact-scoped 매칭과 함께 화이트리스트 CRUD
|
서버 contact-scoped 매칭과 함께 화이트리스트 CRUD
|
||||||
|
|
@ -212,10 +212,19 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
||||||
|
|
||||||
##### D. 맨 마지막 — 사람 PoC (지금 안 함)
|
##### D. 맨 마지막 — 사람 PoC (지금 안 함)
|
||||||
- §3 항목과 동일. A~C 완료 후에만 착수.
|
- §3 항목과 동일. A~C 완료 후에만 착수.
|
||||||
|
**단, 프로덕션 Docker 배포·스모크(N1~N3)는 D보다 앞** — [`deploy-checklist.md`](./deploy-checklist.md).
|
||||||
|
|
||||||
##### E. 베타 이후 (지금은 설계만, 구현 금지)
|
##### E. 베타 이후 (지금은 설계만, 구현 금지)
|
||||||
- Phase 2 L3 / Phase 3 OS 레이어 / Phase 4 L4·B2B
|
- Phase 2 L3 / Phase 3 OS 레이어 / Phase 4 L4·B2B
|
||||||
|
|
||||||
|
#### 6. 배포·잔여 작업 (A~C 이후 실행 트랙)
|
||||||
|
|
||||||
|
단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**.
|
||||||
|
|
||||||
|
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC(D)**.
|
||||||
|
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5)는 해당 문서를 본다. 완료 시 그 문서와
|
||||||
|
본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
|
||||||
|
|
||||||
|
|
||||||
## Phase 2 — L3 확장 + 베타 확대
|
## Phase 2 — L3 확장 + 베타 확대
|
||||||
|
|
||||||
|
|
@ -231,10 +240,10 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
|
||||||
- 확산 후 완전한 기능(L3·L4 등)이 필요하면 자체 앱으로 유도 — 시작 순서를 뒤집는 뜻이 아님
|
- 확산 후 완전한 기능(L3·L4 등)이 필요하면 자체 앱으로 유도 — 시작 순서를 뒤집는 뜻이 아님
|
||||||
- 안드로이드 우선, iOS는 이 단계 반응을 본 뒤 자체 앱 전환 유도 전략으로 대응
|
- 안드로이드 우선, iOS는 이 단계 반응을 본 뒤 자체 앱 전환 유도 전략으로 대응
|
||||||
|
|
||||||
## Phase 4 — L4 분신 협상 + B2B 확장
|
## Phase 4 — L4 와카뷰 협상 + B2B 확장
|
||||||
|
|
||||||
- 사용자 기반이 어느 정도 쌓여 네트워크 효과가 의미 있을 때 착수
|
- 사용자 기반이 어느 정도 쌓여 네트워크 효과가 의미 있을 때 착수
|
||||||
- 기업용 고객 응대 분신(B2B)은 이 시점 이후 별도 트랙으로 검토
|
- 기업용 고객 응대 와카뷰(B2B)은 이 시점 이후 별도 트랙으로 검토
|
||||||
|
|
||||||
## 명시적으로 지금 계획하지 않는 것
|
## 명시적으로 지금 계획하지 않는 것
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# 기술 설계서 — 분신 v1
|
# 기술 설계서 — 와카뷰 v1
|
||||||
|
|
||||||
`PRD.md` 범위(자체 앱, L0~L2, 읽씹 종결 + 단톡 따라잡기)를 구현하기 위한 아키텍처 개요.
|
`PRD.md` 범위(자체 앱, L0~L2, 읽씹 종결 + 단톡 따라잡기)를 구현하기 위한 아키텍처 개요.
|
||||||
상세 API 스펙이 아니라 경계와 원칙을 정의하는 문서다 — 실제 구현 시 세부 설계는 별도로 좁혀나간다.
|
상세 API 스펙이 아니라 경계와 원칙을 정의하는 문서다 — 실제 구현 시 세부 설계는 별도로 좁혀나간다.
|
||||||
|
|
@ -76,10 +76,10 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
|
||||||
|
|
||||||
## 4. 투명성 구현
|
## 4. 투명성 구현
|
||||||
|
|
||||||
- **분신 뱃지**: 메시지 객체에 `sender_mode: human | twin` 필드, 클라이언트가 이를 렌더링에만 사용
|
- **와카뷰 뱃지**: 메시지 객체에 `sender_mode: human | twin` 필드, 클라이언트가 이를 렌더링에만 사용
|
||||||
(서버가 신뢰의 원천 — 클라이언트 임의 조작 방지를 위해 서명 포함)
|
(서버가 신뢰의 원천 — 클라이언트 임의 조작 방지를 위해 서명 포함)
|
||||||
- **실시간 본인 확인**: "본인/분신" 질문은 별도 API 없이 자율성 엔진이 인식하는 고정 인텐트로 처리,
|
- **실시간 본인 확인**: "본인/와카뷰" 질문은 별도 API 없이 자율성 엔진이 인식하는 고정 인텐트로 처리,
|
||||||
분신은 항상 정직하게 "저는 분신입니다"로 응답 (프롬프트 레벨에서 이 사실을 숨기지 않도록 고정)
|
와카뷰는 항상 정직하게 "저는 와카뷰입니다"로 응답 (프롬프트 레벨에서 이 사실을 숨기지 않도록 고정)
|
||||||
- **거부권**: 대화방 단위 플래그(`twin_disabled_by_peer`) — 상대방의 거부 요청이 감지되면 즉시
|
- **거부권**: 대화방 단위 플래그(`twin_disabled_by_peer`) — 상대방의 거부 요청이 감지되면 즉시
|
||||||
해당 대화방에서 L1/L2 자동 동작을 끄고 L0으로 강등
|
해당 대화방에서 L1/L2 자동 동작을 끄고 L0으로 강등
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
|
||||||
|
|
||||||
- OS 레이어(알림 파싱 기반 타 앱 관통) — v2. 지금 만들면 카카오톡 등 UI 변경에 따라 계속 깨지는
|
- OS 레이어(알림 파싱 기반 타 앱 관통) — v2. 지금 만들면 카카오톡 등 UI 변경에 따라 계속 깨지는
|
||||||
파싱 로직을 유지보수해야 해서, 아직 검증 안 된 v1 핵심 가설과 리스크가 섞인다.
|
파싱 로직을 유지보수해야 해서, 아직 검증 안 된 v1 핵심 가설과 리스크가 섞인다.
|
||||||
- 분신 간 프로토콜(L4) — 네트워크 효과가 필요해 사용자 기반이 있어야 의미 있음.
|
- 와카뷰 간 프로토콜(L4) — 네트워크 효과가 필요해 사용자 기반이 있어야 의미 있음.
|
||||||
- 서버 측 전체 대화 분석/추천 — 온디바이스 우선 원칙과 상충.
|
- 서버 측 전체 대화 분석/추천 — 온디바이스 우선 원칙과 상충.
|
||||||
|
|
||||||
## 8. 기술 스택 결정 (Phase 1)
|
## 8. 기술 스택 결정 (Phase 1)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# 유저 인터뷰 가이드 — Q3 자율성 수용성 (분신 사용자 관점)
|
# 유저 인터뷰 가이드 — Q3 자율성 수용성 (와카뷰 사용자 관점)
|
||||||
|
|
||||||
`poc-materials.md`의 역할극/인터뷰는 "상대방(받는 사람)"이 분신을 얼마나 신뢰하는지를 본다.
|
`poc-materials.md`의 역할극/인터뷰는 "상대방(받는 사람)"이 와카뷰를 얼마나 신뢰하는지를 본다.
|
||||||
이 문서는 반대쪽 — **분신을 직접 켜서 쓰는 본인**이 실제로 어디까지 위임하고 싶어하는지를 본다.
|
이 문서는 반대쪽 — **와카뷰를 직접 켜서 쓰는 본인**이 실제로 어디까지 위임하고 싶어하는지를 본다.
|
||||||
`decision-log.md` Q3("자율성 몇 단계까지 출시?")를 "L0~L2만"으로 잠정 결정했는데, 이게 실제
|
`decision-log.md` Q3("자율성 몇 단계까지 출시?")를 "L0~L2만"으로 잠정 결정했는데, 이게 실제
|
||||||
사용자들의 위임 의향과 맞는지 확인하는 것이 목적이다. 두 인터뷰는 서로 대체하지 않는다 —
|
사용자들의 위임 의향과 맞는지 확인하는 것이 목적이다. 두 인터뷰는 서로 대체하지 않는다 —
|
||||||
분신이 자연스러워도(PoC#1), 신뢰를 줘도(PoC#3), 정작 본인이 위임하길 원치 않으면 v1 설계가
|
와카뷰가 자연스러워도(PoC#1), 신뢰를 줘도(PoC#3), 정작 본인이 위임하길 원치 않으면 v1 설계가
|
||||||
틀린 것이다.
|
틀린 것이다.
|
||||||
|
|
||||||
## 대상 및 규모
|
## 대상 및 규모
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Vision Doc — 분신 (가칭)
|
# Vision Doc — 와카뷰 (가칭)
|
||||||
|
|
||||||
## 문제
|
## 문제
|
||||||
|
|
||||||
|
|
@ -14,28 +14,28 @@ v1 이후 확장 대상으로 명시적으로 제외한다. (근거: `decision-l
|
||||||
|
|
||||||
## 가치 제안 (한 문장)
|
## 가치 제안 (한 문장)
|
||||||
|
|
||||||
> 분신은 내가 답할 수 없는 순간에도, 나 대신 나답게 응답해 읽씹 압박과 응답 피로를 없애주는
|
> 와카뷰는 내가 답할 수 없는 순간에도, 나 대신 나답게 응답해 읽씹 압박과 응답 피로를 없애주는
|
||||||
> 메신저다.
|
> 메신저다.
|
||||||
|
|
||||||
## 이번 v1에서 하지 않는 것 (Non-goals)
|
## 이번 v1에서 하지 않는 것 (Non-goals)
|
||||||
|
|
||||||
- 분신 간 협상(L4, 약속 자동 조율) — 네트워크 효과가 필요한 단계라 사용자 기반이 있어야 의미가 있음
|
- 와카뷰 간 협상(L4, 약속 자동 조율) — 네트워크 효과가 필요한 단계라 사용자 기반이 있어야 의미가 있음
|
||||||
- 자리비움 전면 자동응대(L3) — 신뢰가 쌓이기 전에는 리스크가 큼
|
- 자리비움 전면 자동응대(L3) — 신뢰가 쌓이기 전에는 리스크가 큼
|
||||||
- 카카오톡/인스타그램 등 타 채널 위 OS 레이어 — v2 성장 전략으로 미룸
|
- 카카오톡/인스타그램 등 타 채널 위 OS 레이어 — v2 성장 전략으로 미룸
|
||||||
- 기업용(B2B) 고객 응대 분신
|
- 기업용(B2B) 고객 응대 와카뷰
|
||||||
|
|
||||||
## 성공 지표 (v1 클로즈드 베타 기준)
|
## 성공 지표 (v1 클로즈드 베타 기준)
|
||||||
|
|
||||||
| 지표 | 목표 | 측정 방법 |
|
| 지표 | 목표 | 측정 방법 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 분신 응답 자연스러움 | 베타 참가자의 70% 이상이 "내 말투 같다"고 평가 | 온보딩 후 설문 |
|
| 와카뷰 응답 자연스러움 | 베타 참가자의 70% 이상이 "내 말투 같다"고 평가 | 온보딩 후 설문 |
|
||||||
| 신뢰 유지 | 상대방의 분신 거부율 10% 미만 | 거부권 사용 로그 |
|
| 신뢰 유지 | 상대방의 와카뷰 거부율 10% 미만 | 거부권 사용 로그 |
|
||||||
| 핵심 시나리오 도달률 | 베타 참가자 50% 이상이 "읽씹 종결" 시나리오를 1주 내 실사용 | 이벤트 로그 |
|
| 핵심 시나리오 도달률 | 베타 참가자 50% 이상이 "읽씹 종결" 시나리오를 1주 내 실사용 | 이벤트 로그 |
|
||||||
| 리텐션 | 2주차 재사용률 40% 이상 | DAU/재방문 로그 |
|
| 리텐션 | 2주차 재사용률 40% 이상 | DAU/재방문 로그 |
|
||||||
| 안전선 위반 0건 | 금전/약속 확정을 분신이 자동 처리한 사례 0건 | 에스컬레이션 로그 감사 |
|
| 안전선 위반 0건 | 금전/약속 확정을 와카뷰가 자동 처리한 사례 0건 | 에스컬레이션 로그 감사 |
|
||||||
|
|
||||||
## 핵심 축 요약
|
## 핵심 축 요약
|
||||||
|
|
||||||
- **정체성 한 줄:** 챗GPT가 나와 대화하는 AI라면, 분신은 나를 대신해 남과 대화하는 나.
|
- **정체성 한 줄:** 챗GPT가 나와 대화하는 AI라면, 와카뷰는 나를 대신해 남과 대화하는 나.
|
||||||
- **원칙:** 통제권은 항상 사용자에게 — 켜는 만큼만 일한다. 금전·약속 확정·민감 내용은
|
- **원칙:** 통제권은 항상 사용자에게 — 켜는 만큼만 일한다. 금전·약속 확정·민감 내용은
|
||||||
어떤 단계에서도 예외 없이 사람에게 에스컬레이션한다.
|
어떤 단계에서도 예외 없이 사람에게 에스컬레이션한다.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# 분신 mobile (Flutter)
|
# 와카뷰 mobile (Flutter)
|
||||||
|
|
||||||
Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다
|
Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다
|
||||||
(`docs/tech-design.md` §8).
|
(`docs/tech-design.md` §8).
|
||||||
|
|
@ -10,8 +10,8 @@ Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드
|
||||||
- 대화방 목록/생성 (`GET`/`POST /conversations`) — 수동 ID 입력 제거
|
- 대화방 목록/생성 (`GET`/`POST /conversations`) — 수동 ID 입력 제거
|
||||||
- 연락처 CRUD + 대화 시작 (`/users/:id/contacts`)
|
- 연락처 CRUD + 대화 시작 (`/users/:id/contacts`)
|
||||||
- 채팅 히스토리 로드 + WebSocket 수신
|
- 채팅 히스토리 로드 + WebSocket 수신
|
||||||
- 분신 초안 / L1 승인(수정·버리기·재초안·승인 발송)
|
- 와카뷰 초안 / L1 승인(수정·버리기·재초안·승인 발송)
|
||||||
- 분신 뱃지 · 거부권 · 되돌리기
|
- 와카뷰 뱃지 · 거부권 · 되돌리기
|
||||||
- 사후 알림 함 (`GET /users/:id/escalation-logs`)
|
- 사후 알림 함 (`GET /users/:id/escalation-logs`)
|
||||||
- 자율성 L0~L2 + 화이트리스트 CRUD UI
|
- 자율성 L0~L2 + 화이트리스트 CRUD UI
|
||||||
|
|
||||||
|
|
@ -47,9 +47,9 @@ HTTP로 검증한다.
|
||||||
1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에)
|
1. 초대 코드로 가입 → 말투 샘플 저장(또는 나중에)
|
||||||
2. 연락처에 상대 사용자 ID 등록 → 대화 시작
|
2. 연락처에 상대 사용자 ID 등록 → 대화 시작
|
||||||
3. 메시지 전송 · 새로고침 후 히스토리 유지
|
3. 메시지 전송 · 새로고침 후 히스토리 유지
|
||||||
4. 초안 요청 → L1 패널에서 수정/버리기/승인 발송 → 분신 뱃지 확인
|
4. 초안 요청 → L1 패널에서 수정/버리기/승인 발송 → 와카뷰 뱃지 확인
|
||||||
5. 민감 문장으로 에스컬레이션 → 사후 알림 함 진입
|
5. 민감 문장으로 에스컬레이션 → 사후 알림 함 진입
|
||||||
6. 분신 메시지 되돌리기 · 거부권
|
6. 와카뷰 메시지 되돌리기 · 거부권
|
||||||
7. 자율성 L0~L2 + 화이트리스트 추가/삭제
|
7. 자율성 L0~L2 + 화이트리스트 추가/삭제
|
||||||
|
|
||||||
## 로컬 DB (drift + SQLCipher)
|
## 로컬 DB (drift + SQLCipher)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ if (hasReleaseKeystore) {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.bunsin.bunsin_mobile"
|
namespace = "com.ykavu.ykavu_mobile"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
|
@ -30,7 +30,7 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.bunsin.bunsin_mobile"
|
applicationId = "com.ykavu.ykavu_mobile"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
android:label="분신"
|
android:label="와카뷰"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.bunsin.bunsin_mobile
|
package com.ykavu.ykavu_mobile
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
storePassword=replace-me
|
storePassword=replace-me
|
||||||
keyPassword=replace-me
|
keyPassword=replace-me
|
||||||
keyAlias=bunsin
|
keyAlias=ykavu
|
||||||
storeFile=/absolute/path/to/bunsin-release.jks
|
storeFile=/absolute/path/to/ykavu-release.jks
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class AppConfig {
|
||||||
|
|
||||||
/// Shared demo invite (must match core-backend `demoInviteCode`).
|
/// Shared demo invite (must match core-backend `demoInviteCode`).
|
||||||
/// Shown on the signup screen so other testers can join without admin mint.
|
/// Shown on the signup screen so other testers can join without admin mint.
|
||||||
static const demoInviteCode = 'DEMO-BUNSIN';
|
static const demoInviteCode = 'DEMO-YKAVU';
|
||||||
static const demoDisplayName = '테스터';
|
static const demoDisplayName = '테스터';
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ const _kDbPassphrase = 'db_passphrase_v1';
|
||||||
QueryExecutor _openEncryptedExecutor() {
|
QueryExecutor _openEncryptedExecutor() {
|
||||||
return LazyDatabase(() async {
|
return LazyDatabase(() async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final file = File(p.join(dir.path, 'bunsin_encrypted.db'));
|
final file = File(p.join(dir.path, 'ykavu_encrypted.db'));
|
||||||
final passphrase = await _loadOrCreatePassphrase();
|
final passphrase = await _loadOrCreatePassphrase();
|
||||||
|
|
||||||
// Background isolate does not inherit open.overrideFor — re-apply there.
|
// Background isolate does not inherit open.overrideFor — re-apply there.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
@ -7,10 +5,12 @@ import 'package:provider/provider.dart';
|
||||||
import 'screens/conversation_list_screen.dart';
|
import 'screens/conversation_list_screen.dart';
|
||||||
import 'screens/onboarding_tone_screen.dart';
|
import 'screens/onboarding_tone_screen.dart';
|
||||||
import 'screens/signup_screen.dart';
|
import 'screens/signup_screen.dart';
|
||||||
|
import 'screens/splash_screen.dart';
|
||||||
import 'state/session_state.dart';
|
import 'state/session_state.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'widgets/gradient_backdrop.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
FlutterError.onError = (details) {
|
FlutterError.onError = (details) {
|
||||||
|
|
@ -23,30 +23,83 @@ Future<void> main() async {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
runApp(const _Bootstrap());
|
||||||
final session = SessionState();
|
}
|
||||||
await session.restore().timeout(const Duration(seconds: 8));
|
|
||||||
runApp(BunsinApp(session: session));
|
/// Shows [SplashScreen] while [SessionState.restore] is in flight, then
|
||||||
} catch (e, st) {
|
/// swaps to the real app. Runs `runApp` immediately (rather than awaiting
|
||||||
debugPrint('BOOT FAIL: $e\n$st');
|
/// restore() first) so the branded splash actually paints instead of
|
||||||
runApp(
|
/// leaving a blank frame during the async gap. Falls back to a readable
|
||||||
MaterialApp(
|
/// error screen if restore fails or hangs, rather than crashing silently.
|
||||||
|
class _Bootstrap extends StatefulWidget {
|
||||||
|
const _Bootstrap();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Bootstrap> createState() => _BootstrapState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BootstrapState extends State<_Bootstrap> {
|
||||||
|
SessionState? _session;
|
||||||
|
Object? _error;
|
||||||
|
StackTrace? _stackTrace;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
try {
|
||||||
|
final session = SessionState();
|
||||||
|
await session.restore().timeout(const Duration(seconds: 8));
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _session = session);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('BOOT FAIL: $e\n$st');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e;
|
||||||
|
_stackTrace = st;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final error = _error;
|
||||||
|
if (error != null) {
|
||||||
|
return MaterialApp(
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: SelectableText('앱 시작 실패\n\n$e\n\n$st'),
|
child: SelectableText('앱 시작 실패\n\n$error\n\n${_stackTrace ?? ''}'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: '와카뷰',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
darkTheme: AppTheme.dark(),
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
builder: (context, child) => GradientBackdrop(child: child),
|
||||||
|
home: const SplashScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return YkavuApp(session: session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class BunsinApp extends StatelessWidget {
|
class YkavuApp extends StatelessWidget {
|
||||||
const BunsinApp({super.key, required this.session});
|
const YkavuApp({super.key, required this.session});
|
||||||
|
|
||||||
final SessionState session;
|
final SessionState session;
|
||||||
|
|
||||||
|
|
@ -55,11 +108,12 @@ class BunsinApp extends StatelessWidget {
|
||||||
return ChangeNotifierProvider.value(
|
return ChangeNotifierProvider.value(
|
||||||
value: session,
|
value: session,
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: '분신',
|
title: '와카뷰',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
// Twin Shadow phase-1: light only (dark kept in AppTheme for later).
|
darkTheme: AppTheme.dark(),
|
||||||
themeMode: ThemeMode.light,
|
themeMode: ThemeMode.system,
|
||||||
|
builder: (context, child) => GradientBackdrop(child: child),
|
||||||
home: Consumer<SessionState>(
|
home: Consumer<SessionState>(
|
||||||
builder: (context, s, _) {
|
builder: (context, s, _) {
|
||||||
if (s.user == null) return const SignupScreen();
|
if (s.user == null) return const SignupScreen();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
import 'data_flow_screen.dart';
|
import 'data_flow_screen.dart';
|
||||||
import 'sessions_screen.dart';
|
import 'sessions_screen.dart';
|
||||||
|
|
||||||
|
|
@ -50,8 +51,8 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
static const _levelDescriptions = {
|
static const _levelDescriptions = {
|
||||||
AutonomyLevel.L0: '분신이 초안만 만들고, 발송은 항상 직접 합니다.',
|
AutonomyLevel.L0: '와카뷰가 초안만 만들고, 발송은 항상 직접 합니다.',
|
||||||
AutonomyLevel.L1: '분신이 초안을 만들면 검토·수정 후 승인해야 보내집니다.',
|
AutonomyLevel.L1: '와카뷰가 초안을 만들면 검토·수정 후 승인해야 보내집니다.',
|
||||||
AutonomyLevel.L2: '아래 화이트리스트 주제는 승인 없이 자동으로 보내집니다.',
|
AutonomyLevel.L2: '아래 화이트리스트 주제는 승인 없이 자동으로 보내집니다.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -106,8 +107,9 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
color: AppTheme.glassFill(theme.brightness),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../models/models.dart';
|
||||||
import '../services/api_client.dart';
|
import '../services/api_client.dart';
|
||||||
import '../services/ws_client.dart';
|
import '../services/ws_client.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/message_bubble.dart';
|
import '../widgets/message_bubble.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
|
|
@ -141,7 +142,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
try {
|
try {
|
||||||
final contextLines = _messages
|
final contextLines = _messages
|
||||||
.where((m) => !m.retracted)
|
.where((m) => !m.retracted)
|
||||||
.map((m) => '${m.isTwin ? "분신" : "상대"}: ${m.text}')
|
.map((m) => '${m.isTwin ? "와카뷰" : "상대"}: ${m.text}')
|
||||||
.toList();
|
.toList();
|
||||||
if (_input.text.trim().isNotEmpty) {
|
if (_input.text.trim().isNotEmpty) {
|
||||||
contextLines.add('상대: ${_input.text.trim()}');
|
contextLines.add('상대: ${_input.text.trim()}');
|
||||||
|
|
@ -190,7 +191,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
});
|
});
|
||||||
_scrollToEnd();
|
_scrollToEnd();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}');
|
setState(() => _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}');
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _busy = false);
|
setState(() => _busy = false);
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +210,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('거부권을 쓸까요?'),
|
title: const Text('거부권을 쓸까요?'),
|
||||||
content: const Text('이 대화방에서 분신 자동응대가 즉시 중단됩니다. 이후에는 다시 켤 수 없습니다.'),
|
content: const Text('이 대화방에서 와카뷰 자동응대가 즉시 중단됩니다. 이후에는 다시 켤 수 없습니다.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
||||||
FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('거부권 사용')),
|
FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('거부권 사용')),
|
||||||
|
|
@ -220,7 +221,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
final session = context.read<SessionState>();
|
final session = context.read<SessionState>();
|
||||||
try {
|
try {
|
||||||
await session.api.vetoConversation(widget.conversationId);
|
await session.api.vetoConversation(widget.conversationId);
|
||||||
setState(() => _banner = '거부권 적용: 이 대화방에서 분신 자동응대가 중단됩니다.');
|
setState(() => _banner = '거부권 적용: 이 대화방에서 와카뷰 자동응대가 중단됩니다.');
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
setState(() => _banner = '거부권 실패 (${e.statusCode})');
|
setState(() => _banner = '거부권 실패 (${e.statusCode})');
|
||||||
}
|
}
|
||||||
|
|
@ -245,8 +246,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.errorContainer,
|
color: theme.colorScheme.errorContainer.withOpacity(0.85),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -263,7 +265,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text,
|
draft.text.isEmpty ? '민감·확정성 내용으로 와카뷰 발송이 보류되었습니다.' : draft.text,
|
||||||
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
@ -280,8 +282,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.secondaryContainer,
|
color: theme.colorScheme.secondaryContainer.withOpacity(0.85),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -336,7 +339,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '거부권 (분신 자동응대 중단)',
|
tooltip: '거부권 (와카뷰 자동응대 중단)',
|
||||||
onPressed: _busy ? null : _veto,
|
onPressed: _busy ? null : _veto,
|
||||||
icon: const Icon(Icons.block),
|
icon: const Icon(Icons.block),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -103,13 +103,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: const Text('와카뷰'),
|
||||||
'분신',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '사후 알림',
|
tooltip: '사후 알림',
|
||||||
|
|
@ -167,13 +161,10 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
session.user == null ? '' : '${session.user!.displayName}의 대화',
|
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
|
|
@ -222,7 +213,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
|
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'상대가 분신을 거부함',
|
'상대가 와카뷰를 거부함',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ class DataFlowScreen extends StatelessWidget {
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
color: AppTheme.glassFill(theme.brightness),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppTheme.glassBorder(theme.brightness)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -66,7 +67,7 @@ class DataFlowScreen extends StatelessWidget {
|
||||||
_section(
|
_section(
|
||||||
context,
|
context,
|
||||||
icon: Icons.lock_outline,
|
icon: Icons.lock_outline,
|
||||||
tint: TwinTokens.forest,
|
tint: Colors.green.shade600,
|
||||||
title: '기기 안에만 둡니다',
|
title: '기기 안에만 둡니다',
|
||||||
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
|
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
|
||||||
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
|
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
|
||||||
|
|
@ -90,7 +91,7 @@ class DataFlowScreen extends StatelessWidget {
|
||||||
_section(
|
_section(
|
||||||
context,
|
context,
|
||||||
icon: Icons.cloud_upload_outlined,
|
icon: Icons.cloud_upload_outlined,
|
||||||
tint: TwinTokens.twinMark,
|
tint: Colors.amber.shade800,
|
||||||
title: '서버로 보낼 수 있는 것',
|
title: '서버로 보낼 수 있는 것',
|
||||||
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
|
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
|
||||||
'채팅 릴레이: 보낸 메시지 본문\n'
|
'채팅 릴레이: 보낸 메시지 본문\n'
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ class _InboxScreenState extends State<InboxScreen> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
|
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
'분신이 보류·차단한 내용과 에스컬레이션 기록입니다. '
|
'와카뷰가 보류·차단한 내용과 에스컬레이션 기록입니다. '
|
||||||
'이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
|
'이미 보낸 와카뷰 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../theme/app_theme.dart';
|
|
||||||
import '../widgets/twin_hero_backdrop.dart';
|
|
||||||
|
|
||||||
/// Phase 1 onboarding: capture a few style samples locally.
|
/// Phase 1 onboarding skeleton: capture a few style samples locally.
|
||||||
/// Fine copy / import UX waits for human PoC — do not invent §3 defaults here.
|
/// Fine copy / import UX waits for human PoC (#1) — do not invent §3 defaults here.
|
||||||
class OnboardingToneScreen extends StatefulWidget {
|
class OnboardingToneScreen extends StatefulWidget {
|
||||||
const OnboardingToneScreen({super.key});
|
const OnboardingToneScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -49,104 +47,68 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final scheme = theme.colorScheme;
|
final scheme = theme.colorScheme;
|
||||||
final fromMenu = Navigator.of(context).canPop();
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: TwinHeroBackdrop(
|
appBar: AppBar(
|
||||||
child: SafeArea(
|
title: const Text('말투 샘플'),
|
||||||
child: Column(
|
actions: [
|
||||||
children: [
|
TextButton(
|
||||||
Padding(
|
onPressed: () => context.read<SessionState>().skipToneOnboarding(),
|
||||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
child: const Text('나중에'),
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (fromMenu)
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.of(context).maybePop(),
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const SizedBox(width: 48),
|
|
||||||
const Spacer(),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => context.read<SessionState>().skipToneOnboarding(),
|
|
||||||
child: const Text('나중에'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(28, 8, 28, 24),
|
|
||||||
children: [
|
|
||||||
TwinFadeUp(
|
|
||||||
child: Text(
|
|
||||||
'말투',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: TwinTokens.forest,
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TwinFadeUp(
|
|
||||||
delay: const Duration(milliseconds: 80),
|
|
||||||
child: Text(
|
|
||||||
'분신이 따라 쓸 말투',
|
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: TwinTokens.ink,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
TwinFadeUp(
|
|
||||||
delay: const Duration(milliseconds: 140),
|
|
||||||
child: Text(
|
|
||||||
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다.',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
for (var i = 0; i < _samples.length; i++) ...[
|
|
||||||
TwinFadeUp(
|
|
||||||
delay: Duration(milliseconds: 180 + i * 50),
|
|
||||||
child: TextField(
|
|
||||||
controller: _samples[i],
|
|
||||||
maxLines: 2,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: '샘플 ${i + 1}',
|
|
||||||
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
|
|
||||||
prefixIcon: Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 12, right: 4),
|
|
||||||
child: Align(
|
|
||||||
widthFactor: 1,
|
|
||||||
child: Text(
|
|
||||||
'${i + 1}',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
color: TwinTokens.forest,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(28, 0, 28, 20),
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () => _save(markDone: true),
|
|
||||||
child: const Text('이 말투로 시작'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(color: scheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimaryContainer),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('와카뷰가 따라 쓸 말투', style: theme.textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
|
||||||
|
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
for (var i = 0; i < _samples.length; i++) ...[
|
||||||
|
TextField(
|
||||||
|
controller: _samples[i],
|
||||||
|
maxLines: 2,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '샘플 ${i + 1}',
|
||||||
|
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
|
||||||
|
prefixIcon: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, right: 4, top: 4),
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 12,
|
||||||
|
backgroundColor: scheme.secondaryContainer,
|
||||||
|
child: Text(
|
||||||
|
'${i + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: scheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => _save(markDone: true),
|
||||||
|
child: const Text('이 말투로 시작'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import 'package:provider/provider.dart';
|
||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/twin_hero_backdrop.dart';
|
|
||||||
|
|
||||||
class SignupScreen extends StatefulWidget {
|
class SignupScreen extends StatefulWidget {
|
||||||
const SignupScreen({super.key});
|
const SignupScreen({super.key});
|
||||||
|
|
@ -36,118 +35,91 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final scheme = theme.colorScheme;
|
final scheme = theme.colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: TwinHeroBackdrop(
|
body: SafeArea(
|
||||||
child: SafeArea(
|
child: Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
children: [
|
const Spacer(flex: 3),
|
||||||
const Spacer(flex: 2),
|
Center(
|
||||||
TwinFadeUp(
|
child: Container(
|
||||||
child: Text(
|
width: 72,
|
||||||
'분신',
|
height: 72,
|
||||||
textAlign: TextAlign.center,
|
decoration: BoxDecoration(
|
||||||
style: theme.textTheme.displayLarge?.copyWith(
|
color: scheme.primaryContainer,
|
||||||
fontSize: 56,
|
borderRadius: BorderRadius.circular(22),
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: TwinTokens.ink,
|
|
||||||
letterSpacing: -1.6,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: Icon(Icons.auto_awesome, size: 34, color: scheme.onPrimaryContainer),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'와카뷰',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'나를 대신해 답하는, 나만의 와카뷰',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const Spacer(flex: 2),
|
||||||
|
Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_DemoTestPanel(
|
||||||
|
onFill: _fillDemoCredentials,
|
||||||
|
onCopy: () async {
|
||||||
|
await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _invite,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '초대 코드',
|
||||||
|
prefixIcon: Icon(Icons.vpn_key),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _name,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '표시 이름',
|
||||||
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (!session.loading) {
|
||||||
|
session.signup(_invite.text.trim(), _name.text.trim());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (session.error != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TwinFadeUp(
|
Text(session.error!, style: TextStyle(color: scheme.error)),
|
||||||
delay: const Duration(milliseconds: 90),
|
|
||||||
child: Text(
|
|
||||||
'나를 대신해 답하는, 나만의 그림자',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
color: TwinTokens.forestDeep,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
height: 1.35,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
TwinFadeUp(
|
|
||||||
delay: const Duration(milliseconds: 160),
|
|
||||||
child: Text(
|
|
||||||
'초대 코드로 클로즈드 베타에 참여합니다',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(flex: 2),
|
|
||||||
TwinFadeUp(
|
|
||||||
delay: const Duration(milliseconds: 200),
|
|
||||||
child: _DemoTestPanel(
|
|
||||||
onFill: _fillDemoCredentials,
|
|
||||||
onCopy: () async {
|
|
||||||
await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode));
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TwinFadeUp(
|
|
||||||
delay: const Duration(milliseconds: 240),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
TextField(
|
|
||||||
controller: _invite,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '초대 코드',
|
|
||||||
prefixIcon: Icon(Icons.vpn_key_outlined),
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
textCapitalization: TextCapitalization.characters,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: _name,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '표시 이름',
|
|
||||||
prefixIcon: Icon(Icons.person_outline),
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.done,
|
|
||||||
onSubmitted: (_) {
|
|
||||||
if (!session.loading) {
|
|
||||||
session.signup(_invite.text.trim(), _name.text.trim());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (session.error != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(session.error!, style: TextStyle(color: scheme.error)),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_PressScale(
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: session.loading
|
|
||||||
? null
|
|
||||||
: () => session.signup(_invite.text.trim(), _name.text.trim()),
|
|
||||||
child: session.loading
|
|
||||||
? const SizedBox(
|
|
||||||
height: 22,
|
|
||||||
width: 22,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
|
||||||
)
|
|
||||||
: const Text('시작하기'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
],
|
],
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
|
_PressScale(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: session.loading
|
||||||
|
? null
|
||||||
|
: () => session.signup(_invite.text.trim(), _name.text.trim()),
|
||||||
|
child: session.loading
|
||||||
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Text('시작하기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -156,7 +128,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Visible test credentials so other people can try the closed beta without
|
/// Visible test credentials so other people can try the closed beta without
|
||||||
/// asking Master for a one-off invite mint.
|
/// asking for a one-off invite mint.
|
||||||
class _DemoTestPanel extends StatelessWidget {
|
class _DemoTestPanel extends StatelessWidget {
|
||||||
const _DemoTestPanel({required this.onFill, required this.onCopy});
|
const _DemoTestPanel({required this.onFill, required this.onCopy});
|
||||||
|
|
||||||
|
|
@ -166,21 +138,26 @@ class _DemoTestPanel extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final brightness = theme.brightness;
|
||||||
return Material(
|
return Material(
|
||||||
color: TwinTokens.mist.withValues(alpha: 0.85),
|
color: AppTheme.glassFill(brightness),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onFill,
|
onTap: onFill,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Padding(
|
child: Container(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppTheme.glassBorder(brightness)),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'테스트용 (누구나)',
|
'테스트용 (누구나)',
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
color: TwinTokens.forest,
|
color: theme.colorScheme.primary,
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -191,7 +168,6 @@ class _DemoTestPanel extends StatelessWidget {
|
||||||
child: Text(
|
child: Text(
|
||||||
'초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}',
|
'초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}',
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
color: TwinTokens.ink,
|
|
||||||
height: 1.45,
|
height: 1.45,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
|
|
@ -207,7 +183,7 @@ class _DemoTestPanel extends StatelessWidget {
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
|
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: TwinTokens.ink.withValues(alpha: 0.55)),
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Shown while `SessionState.restore()` runs, before the real app (signup /
|
||||||
|
/// onboarding / conversation list) is ready to pick. Purely presentational —
|
||||||
|
/// no navigation logic lives here, `main.dart` swaps it out once restore()
|
||||||
|
/// resolves.
|
||||||
|
class SplashScreen extends StatelessWidget {
|
||||||
|
const SplashScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.primary.withOpacity(0.15),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [scheme.primary, scheme.tertiary],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.auto_awesome, color: Colors.white, size: 30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'와카뷰',
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'나를 대신해 답하는, 나만의 와카뷰',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5, color: scheme.primary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,99 +1,105 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
|
|
||||||
/// Twin Shadow design tokens for 분신.
|
|
||||||
///
|
|
||||||
/// Brand-first: forest ink on mist/paper atmosphere. `twinAccent` stays a
|
|
||||||
/// separate warm mark so AI-authored bubbles never read as human-typed
|
|
||||||
/// (PRD §3.1 분신 뱃지).
|
|
||||||
class TwinTokens {
|
|
||||||
TwinTokens._();
|
|
||||||
|
|
||||||
static const ink = Color(0xFF0E1A16);
|
|
||||||
static const forest = Color(0xFF1F6F5B);
|
|
||||||
static const forestDeep = Color(0xFF163F34);
|
|
||||||
static const mist = Color(0xFFE7F0EC);
|
|
||||||
static const paper = Color(0xFFF5F7F6);
|
|
||||||
static const glow = Color(0xFFC8E6D9);
|
|
||||||
static const twinMark = Color(0xFFB8860B); // muted gold — AI authorship only
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Central design tokens for 와카뷰 (Ykavu) — soft-gradient + glassmorphism
|
||||||
|
/// direction. `backgroundGradient` paints behind every screen (wired into
|
||||||
|
/// `MaterialApp.builder`); cards/inputs/app bars are semi-transparent
|
||||||
|
/// "glass" surfaces that let the gradient read through. `twinAccent` stays a
|
||||||
|
/// separate warm accent so a twin-written bubble never reads as something
|
||||||
|
/// the human actually typed (PRD §3.1 와카뷰 뱃지).
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
|
static const _seed = Color(0xFF7C6FF0);
|
||||||
|
|
||||||
static Color twinAccent(Brightness brightness) =>
|
static Color twinAccent(Brightness brightness) =>
|
||||||
brightness == Brightness.dark ? const Color(0xFFE0C36A) : TwinTokens.twinMark;
|
brightness == Brightness.dark ? Colors.amber.shade300 : Colors.amber.shade800;
|
||||||
|
|
||||||
|
/// Soft diagonal gradient painted behind every screen. Light: pastel
|
||||||
|
/// lavender → sky → pink. Dark: desaturated indigo → navy → plum.
|
||||||
|
static LinearGradient backgroundGradient(Brightness brightness) {
|
||||||
|
if (brightness == Brightness.dark) {
|
||||||
|
return const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF1B1730), Color(0xFF161C34), Color(0xFF2A1830)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFEDE9FE), Color(0xFFE0F2FE), Color(0xFFFCE7F3)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Glass" fill for cards/panels — semi-transparent so the gradient
|
||||||
|
/// behind still reads through, with a faint light border for the classic
|
||||||
|
/// glassmorphism edge highlight.
|
||||||
|
static Color glassFill(Brightness brightness) => brightness == Brightness.dark
|
||||||
|
? Colors.white.withOpacity(0.06)
|
||||||
|
: Colors.white.withOpacity(0.55);
|
||||||
|
|
||||||
|
static Color glassBorder(Brightness brightness) => brightness == Brightness.dark
|
||||||
|
? Colors.white.withOpacity(0.10)
|
||||||
|
: Colors.white.withOpacity(0.65);
|
||||||
|
|
||||||
/// Phase-1 default: light Twin Shadow only.
|
|
||||||
static ThemeData light() => _build(Brightness.light);
|
static ThemeData light() => _build(Brightness.light);
|
||||||
|
|
||||||
/// Kept for system/dark opt-in later; not used while ThemeMode.light.
|
|
||||||
static ThemeData dark() => _build(Brightness.dark);
|
static ThemeData dark() => _build(Brightness.dark);
|
||||||
|
|
||||||
static ThemeData _build(Brightness brightness) {
|
static ThemeData _build(Brightness brightness) {
|
||||||
final isLight = brightness == Brightness.light;
|
final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness);
|
||||||
final baseScheme = ColorScheme.fromSeed(
|
final glass = glassFill(brightness);
|
||||||
seedColor: TwinTokens.forest,
|
final glassEdge = glassBorder(brightness);
|
||||||
brightness: brightness,
|
final glassShape = RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
side: BorderSide(color: glassEdge, width: 1),
|
||||||
);
|
);
|
||||||
final scheme = baseScheme.copyWith(
|
|
||||||
primary: isLight ? TwinTokens.forest : TwinTokens.glow,
|
|
||||||
onPrimary: isLight ? Colors.white : TwinTokens.ink,
|
|
||||||
primaryContainer: isLight ? TwinTokens.mist : TwinTokens.forestDeep,
|
|
||||||
onPrimaryContainer: isLight ? TwinTokens.forestDeep : TwinTokens.mist,
|
|
||||||
surface: isLight ? TwinTokens.paper : const Color(0xFF0B1210),
|
|
||||||
onSurface: isLight ? TwinTokens.ink : TwinTokens.mist,
|
|
||||||
onSurfaceVariant: isLight ? TwinTokens.ink.withValues(alpha: 0.62) : TwinTokens.mist.withValues(alpha: 0.72),
|
|
||||||
surfaceContainerHighest: isLight ? TwinTokens.mist : const Color(0xFF15201C),
|
|
||||||
surfaceContainerHigh: isLight ? const Color(0xFFEEF4F1) : const Color(0xFF121A17),
|
|
||||||
outlineVariant: isLight ? TwinTokens.glow : TwinTokens.forestDeep,
|
|
||||||
);
|
|
||||||
|
|
||||||
final textTheme = _textTheme(scheme);
|
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: brightness,
|
brightness: brightness,
|
||||||
colorScheme: scheme,
|
colorScheme: scheme,
|
||||||
scaffoldBackgroundColor: scheme.surface,
|
// Transparent so the gradient painted by MaterialApp.builder shows
|
||||||
visualDensity: VisualDensity.standard,
|
// through behind every Scaffold.
|
||||||
textTheme: textTheme,
|
scaffoldBackgroundColor: Colors.transparent,
|
||||||
primaryTextTheme: textTheme,
|
visualDensity: VisualDensity.comfortable,
|
||||||
|
textTheme: _textTheme(),
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: scheme.surface,
|
backgroundColor: Colors.transparent,
|
||||||
foregroundColor: scheme.onSurface,
|
foregroundColor: scheme.onSurface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 0.5,
|
scrolledUnderElevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
titleTextStyle: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
letterSpacing: -0.4,
|
color: scheme.onSurface,
|
||||||
|
letterSpacing: -0.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: scheme.surfaceContainerHigh,
|
color: glass,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: glassShape,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
listTileTheme: ListTileThemeData(
|
listTileTheme: ListTileThemeData(
|
||||||
iconColor: scheme.onSurfaceVariant,
|
iconColor: scheme.onSurfaceVariant,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 24, thickness: 1),
|
dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: isLight ? Colors.white.withValues(alpha: 0.72) : scheme.surfaceContainerHighest,
|
fillColor: glass,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
borderSide: BorderSide(color: scheme.outlineVariant),
|
borderSide: BorderSide(color: glassEdge, width: 1),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.8)),
|
borderSide: BorderSide(color: glassEdge, width: 1),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
|
@ -107,30 +113,23 @@ class AppTheme {
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
borderSide: BorderSide(color: scheme.error, width: 1.6),
|
borderSide: BorderSide(color: scheme.error, width: 1.6),
|
||||||
),
|
),
|
||||||
labelStyle: TextStyle(color: scheme.onSurfaceVariant),
|
hintStyle: TextStyle(color: scheme.onSurfaceVariant.withOpacity(0.7)),
|
||||||
hintStyle: TextStyle(color: scheme.onSurfaceVariant.withValues(alpha: 0.7)),
|
|
||||||
),
|
),
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: TwinTokens.forest,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
minimumSize: const Size.fromHeight(52),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
textStyle: GoogleFonts.manrope(fontWeight: FontWeight.w700, fontSize: 16),
|
textStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(48),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
side: BorderSide(color: scheme.outlineVariant),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
textButtonTheme: TextButtonThemeData(
|
textButtonTheme: TextButtonThemeData(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: TwinTokens.forest,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -139,57 +138,57 @@ class AppTheme {
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||||
|
style: SegmentedButton.styleFrom(
|
||||||
|
backgroundColor: glass,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: glassEdge, width: 1),
|
||||||
|
),
|
||||||
|
side: BorderSide.none,
|
||||||
|
backgroundColor: glass,
|
||||||
|
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
),
|
||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
backgroundColor: TwinTokens.forest,
|
backgroundColor: scheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: scheme.onPrimary,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||||
),
|
),
|
||||||
snackBarTheme: SnackBarThemeData(
|
snackBarTheme: SnackBarThemeData(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
|
bannerTheme: MaterialBannerThemeData(
|
||||||
|
backgroundColor: glass,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
),
|
||||||
dialogTheme: DialogThemeData(
|
dialogTheme: DialogThemeData(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
backgroundColor: brightness == Brightness.dark
|
||||||
|
? const Color(0xFF211D3D)
|
||||||
|
: Colors.white.withOpacity(0.92),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
),
|
),
|
||||||
progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary),
|
progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary),
|
||||||
chipTheme: ChipThemeData(
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
side: BorderSide.none,
|
|
||||||
backgroundColor: scheme.surfaceContainerHighest,
|
|
||||||
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static TextTheme _textTheme(ColorScheme scheme) {
|
static TextTheme _textTheme() {
|
||||||
final base = GoogleFonts.manropeTextTheme();
|
return const TextTheme(
|
||||||
return base.copyWith(
|
displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5),
|
||||||
displayLarge: base.displayLarge?.copyWith(
|
headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3),
|
||||||
fontWeight: FontWeight.w800,
|
titleLarge: TextStyle(fontWeight: FontWeight.w700),
|
||||||
letterSpacing: -1.2,
|
titleMedium: TextStyle(fontWeight: FontWeight.w700),
|
||||||
color: scheme.onSurface,
|
titleSmall: TextStyle(fontWeight: FontWeight.w700),
|
||||||
height: 1.05,
|
bodyLarge: TextStyle(height: 1.4),
|
||||||
),
|
bodyMedium: TextStyle(height: 1.4),
|
||||||
displaySmall: base.displaySmall?.copyWith(
|
labelLarge: TextStyle(fontWeight: FontWeight.w700),
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.8,
|
|
||||||
color: scheme.onSurface,
|
|
||||||
height: 1.1,
|
|
||||||
),
|
|
||||||
headlineSmall: base.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.4,
|
|
||||||
color: scheme.onSurface,
|
|
||||||
),
|
|
||||||
titleLarge: base.titleLarge?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface),
|
|
||||||
titleMedium: base.titleMedium?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface),
|
|
||||||
titleSmall: base.titleSmall?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface),
|
|
||||||
bodyLarge: base.bodyLarge?.copyWith(height: 1.45, color: scheme.onSurface),
|
|
||||||
bodyMedium: base.bodyMedium?.copyWith(height: 1.45, color: scheme.onSurface),
|
|
||||||
bodySmall: base.bodySmall?.copyWith(height: 1.4, color: scheme.onSurfaceVariant),
|
|
||||||
labelLarge: base.labelLarge?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Paints the app-wide soft gradient behind [child]. Wired into
|
||||||
|
/// `MaterialApp.builder` so every screen gets it automatically without each
|
||||||
|
/// Scaffold needing its own background — screens just need a transparent
|
||||||
|
/// `scaffoldBackgroundColor` (set globally in [AppTheme]).
|
||||||
|
class GradientBackdrop extends StatelessWidget {
|
||||||
|
const GradientBackdrop({super.key, required this.child});
|
||||||
|
|
||||||
|
final Widget? child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(gradient: AppTheme.backgroundGradient(Theme.of(context).brightness)),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
/// Twin messages get a dashed border + badge (PRD §3.1 분신 뱃지) so an
|
/// Twin messages get a dashed border + badge (PRD §3.1 와카뷰 뱃지) so an
|
||||||
/// auto-sent bubble never reads as something the human actually typed.
|
/// auto-sent bubble never reads as something the human actually typed.
|
||||||
class MessageBubble extends StatelessWidget {
|
class MessageBubble extends StatelessWidget {
|
||||||
const MessageBubble({
|
const MessageBubble({
|
||||||
|
|
@ -69,7 +69,7 @@ class MessageBubble extends StatelessWidget {
|
||||||
Icon(Icons.auto_awesome, size: 13, color: accent),
|
Icon(Icons.auto_awesome, size: 13, color: accent),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'분신',
|
'와카뷰',
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
color: accent,
|
color: accent,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../theme/app_theme.dart';
|
|
||||||
|
|
||||||
/// Full-bleed Twin Shadow atmosphere: mist→paper gradient + overlapping
|
|
||||||
/// silhouettes (the "second self"). No badges, chips, or promo overlays.
|
|
||||||
class TwinHeroBackdrop extends StatefulWidget {
|
|
||||||
const TwinHeroBackdrop({super.key, required this.child});
|
|
||||||
|
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<TwinHeroBackdrop> createState() => _TwinHeroBackdropState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TwinHeroBackdropState extends State<TwinHeroBackdrop> with SingleTickerProviderStateMixin {
|
|
||||||
late final AnimationController _drift;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_drift = AnimationController(vsync: this, duration: const Duration(seconds: 14))..repeat(reverse: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_drift.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _drift,
|
|
||||||
builder: (context, _) {
|
|
||||||
final t = Curves.easeInOut.transform(_drift.value);
|
|
||||||
return Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment(-0.2 + t * 0.15, -1),
|
|
||||||
end: Alignment(0.3 - t * 0.1, 1.1),
|
|
||||||
colors: const [
|
|
||||||
TwinTokens.glow,
|
|
||||||
TwinTokens.mist,
|
|
||||||
TwinTokens.paper,
|
|
||||||
],
|
|
||||||
stops: const [0.0, 0.42, 1.0],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
CustomPaint(
|
|
||||||
painter: _TwinSilhouettePainter(phase: t),
|
|
||||||
),
|
|
||||||
widget.child,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TwinSilhouettePainter extends CustomPainter {
|
|
||||||
_TwinSilhouettePainter({required this.phase});
|
|
||||||
|
|
||||||
final double phase;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final w = size.width;
|
|
||||||
final h = size.height;
|
|
||||||
final paintA = Paint()..color = TwinTokens.forest.withValues(alpha: 0.07 + phase * 0.02);
|
|
||||||
final paintB = Paint()..color = TwinTokens.ink.withValues(alpha: 0.05 + (1 - phase) * 0.02);
|
|
||||||
|
|
||||||
// Soft overlapping ovals — a person and their shadow-self.
|
|
||||||
final a = RRect.fromRectAndRadius(
|
|
||||||
Rect.fromCenter(
|
|
||||||
center: Offset(w * 0.72 + phase * 8, h * 0.22),
|
|
||||||
width: w * 0.55,
|
|
||||||
height: h * 0.42,
|
|
||||||
),
|
|
||||||
const Radius.circular(120),
|
|
||||||
);
|
|
||||||
final b = RRect.fromRectAndRadius(
|
|
||||||
Rect.fromCenter(
|
|
||||||
center: Offset(w * 0.78 + phase * 4, h * 0.26),
|
|
||||||
width: w * 0.48,
|
|
||||||
height: h * 0.38,
|
|
||||||
),
|
|
||||||
const Radius.circular(120),
|
|
||||||
);
|
|
||||||
canvas.drawRRect(a, paintA);
|
|
||||||
canvas.drawRRect(b, paintB);
|
|
||||||
|
|
||||||
// Thin crescent arc suggesting a second outline.
|
|
||||||
final arcPaint = Paint()
|
|
||||||
..color = TwinTokens.forest.withValues(alpha: 0.12)
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 1.4;
|
|
||||||
canvas.drawArc(
|
|
||||||
Rect.fromCenter(center: Offset(w * 0.78, h * 0.24), width: w * 0.42, height: h * 0.34),
|
|
||||||
-math.pi * 0.2,
|
|
||||||
math.pi * 1.1,
|
|
||||||
false,
|
|
||||||
arcPaint,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant _TwinSilhouettePainter oldDelegate) => oldDelegate.phase != phase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Brand wordmark entrance — fade + slight rise.
|
|
||||||
class TwinFadeUp extends StatefulWidget {
|
|
||||||
const TwinFadeUp({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.delay = Duration.zero,
|
|
||||||
this.duration = const Duration(milliseconds: 700),
|
|
||||||
});
|
|
||||||
|
|
||||||
final Widget child;
|
|
||||||
final Duration delay;
|
|
||||||
final Duration duration;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<TwinFadeUp> createState() => _TwinFadeUpState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TwinFadeUpState extends State<TwinFadeUp> with SingleTickerProviderStateMixin {
|
|
||||||
late final AnimationController _c;
|
|
||||||
late final Animation<double> _opacity;
|
|
||||||
late final Animation<Offset> _offset;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_c = AnimationController(vsync: this, duration: widget.duration);
|
|
||||||
_opacity = CurvedAnimation(parent: _c, curve: Curves.easeOutCubic);
|
|
||||||
_offset = Tween(begin: const Offset(0, 0.06), end: Offset.zero)
|
|
||||||
.animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic));
|
|
||||||
Future<void>.delayed(widget.delay, () {
|
|
||||||
if (mounted) _c.forward();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_c.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return FadeTransition(
|
|
||||||
opacity: _opacity,
|
|
||||||
child: SlideTransition(position: _offset, child: widget.child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -93,10 +93,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
charcode:
|
charcode:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -356,26 +356,26 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.9"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -396,26 +396,26 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -713,10 +713,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.11"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -729,10 +729,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -798,5 +798,5 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.1 <4.0.0"
|
dart: ">=3.10.0-0 <4.0.0"
|
||||||
flutter: ">=3.29.0"
|
flutter: ">=3.29.0"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
name: bunsin_mobile
|
name: ykavu_mobile
|
||||||
description: "A new Flutter project."
|
description: "A new Flutter project."
|
||||||
# The following line prevents the package from being accidentally published to
|
# The following line prevents the package from being accidentally published to
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:bunsin_mobile/db/app_database.dart';
|
import 'package:ykavu_mobile/db/app_database.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:bunsin_mobile/models/models.dart';
|
import 'package:ykavu_mobile/models/models.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import 'package:bunsin_mobile/main.dart';
|
import 'package:ykavu_mobile/main.dart';
|
||||||
import 'package:bunsin_mobile/state/session_state.dart';
|
import 'package:ykavu_mobile/state/session_state.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('shows signup when logged out', (tester) async {
|
testWidgets('shows signup when logged out', (tester) async {
|
||||||
final session = SessionState();
|
final session = SessionState();
|
||||||
await tester.pumpWidget(BunsinApp(session: session));
|
await tester.pumpWidget(YkavuApp(session: session));
|
||||||
expect(find.text('분신'), findsOneWidget);
|
expect(find.text('와카뷰'), findsOneWidget);
|
||||||
expect(find.text('시작하기'), findsOneWidget);
|
expect(find.text('시작하기'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,19 +23,16 @@
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
<meta name="apple-mobile-web-app-title" content="bunsin_mobile">
|
<meta name="apple-mobile-web-app-title" content="Ykavu">
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
<title>분신</title>
|
<title>와카뷰 (Ykavu)</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="bunsin-boot" style="font-family: Manrope, system-ui, sans-serif; padding: 24px; color: #1F6F5B;">
|
|
||||||
분신 로딩 중…
|
|
||||||
</div>
|
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "bunsin_mobile",
|
"name": "와카뷰 (Ykavu)",
|
||||||
"short_name": "bunsin_mobile",
|
"short_name": "와카뷰",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#7C6FF0",
|
||||||
"theme_color": "#0175C2",
|
"theme_color": "#7C6FF0",
|
||||||
"description": "A new Flutter project.",
|
"description": "나를 대신해 답하는, 나만의 와카뷰",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ def load_dotenv_if_present():
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
|
SYSTEM_PROMPT = """너는 어떤 사람의 '와카뷰'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
|
||||||
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
|
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
|
||||||
|
|
||||||
지켜야 할 것:
|
지켜야 할 것:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Push main to GitHub (origin) and Gitea (gitea) remotes.
|
# Push a branch to GitHub (origin) and Gitea iykyka (gitea) remotes.
|
||||||
|
# Master policy: land work on main, then run: ./scripts/push-both.sh main
|
||||||
|
# See AGENTS.md "Git remotes & branch policy".
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BRANCH="${1:-main}"
|
BRANCH="${1:-main}"
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue