@hanmariyang/drafting 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/api/dist/db/index.js +107 -0
- package/api/dist/db/repos.js +670 -0
- package/api/dist/index.js +89 -0
- package/api/dist/lib/ai.js +314 -0
- package/api/dist/lib/config.js +57 -0
- package/api/dist/lib/crypto.js +71 -0
- package/api/dist/lib/design-system-gen.js +332 -0
- package/api/dist/lib/fixtures.js +150 -0
- package/api/dist/lib/gateway.js +55 -0
- package/api/dist/lib/handoff.js +283 -0
- package/api/dist/lib/items-gen.js +211 -0
- package/api/dist/lib/lint-service.js +118 -0
- package/api/dist/lib/lint.js +141 -0
- package/api/dist/lib/mockup-gen.js +136 -0
- package/api/dist/lib/numbering.js +75 -0
- package/api/dist/lib/provider-errors.js +31 -0
- package/api/dist/lib/render.js +154 -0
- package/api/dist/lib/style-guide.js +47 -0
- package/api/dist/lib/templates.js +85 -0
- package/api/dist/lib/types.js +1 -0
- package/api/dist/lib/wireframes.js +137 -0
- package/api/dist/providers/byok/anthropic.js +75 -0
- package/api/dist/providers/byok/openai-compat.js +95 -0
- package/api/dist/providers/cli.js +391 -0
- package/api/dist/providers/index.js +68 -0
- package/api/dist/providers/managed.js +22 -0
- package/api/dist/providers/sse.js +37 -0
- package/api/dist/providers/stub.js +55 -0
- package/api/dist/providers/types.js +1 -0
- package/api/dist/routes/backup.js +24 -0
- package/api/dist/routes/deliverables.js +588 -0
- package/api/dist/routes/documents.js +205 -0
- package/api/dist/routes/helpers.js +43 -0
- package/api/dist/routes/interview.js +141 -0
- package/api/dist/routes/keys.js +70 -0
- package/api/dist/routes/projects.js +103 -0
- package/api/dist/routes/settings.js +134 -0
- package/api/dist/routes/share.js +39 -0
- package/api/dist/routes/suggestions.js +144 -0
- package/api/templates/design-system.json +17 -0
- package/api/templates/feature-spec.json +73 -0
- package/api/templates/ia.json +52 -0
- package/api/templates/prd.json +60 -0
- package/api/templates/user-flow.json +61 -0
- package/bin/drafting.mjs +79 -0
- package/db/schema.sql +154 -0
- package/package.json +62 -0
- package/web/dist/assets/index-CS06cWP3.js +125 -0
- package/web/dist/assets/index-DWoYeaZU.css +1 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Consistency compiler (§4) — deterministic, AI-free. Pure function over plan
|
|
2
|
+
// items so it is exhaustively unit-testable. Only ACCEPTED items are checked.
|
|
3
|
+
function meta(item) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(item.meta || '{}');
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function links(item) {
|
|
12
|
+
return meta(item).links ?? {};
|
|
13
|
+
}
|
|
14
|
+
function allLinkRefs(l) {
|
|
15
|
+
return [...(l.reqs ?? []), ...(l.pages ?? []), ...(l.flows ?? []), ...(l.features ?? [])];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Run rule v1 (6 rules) over a project's plan items. `reqIds` = the valid
|
|
19
|
+
* REQ-nn ids derived from the PRD's accepted sections (§1.2).
|
|
20
|
+
*/
|
|
21
|
+
export function lintProject(items, reqIds) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const reqSet = new Set(reqIds);
|
|
24
|
+
const byRef = new Map();
|
|
25
|
+
for (const it of items)
|
|
26
|
+
byRef.set(it.ref_id, it);
|
|
27
|
+
const accepted = items.filter((i) => i.status === 'accepted');
|
|
28
|
+
// ── E-DUP-REF: duplicate ref_id within the same document ──────────────────
|
|
29
|
+
const seen = new Map(); // `${doc}:${ref}` -> ref
|
|
30
|
+
const dupped = new Set();
|
|
31
|
+
for (const it of accepted) {
|
|
32
|
+
const key = `${it.document_id}:${it.ref_id}`;
|
|
33
|
+
if (seen.has(key))
|
|
34
|
+
dupped.add(it.ref_id);
|
|
35
|
+
else
|
|
36
|
+
seen.set(key, it.ref_id);
|
|
37
|
+
}
|
|
38
|
+
for (const ref of dupped) {
|
|
39
|
+
out.push({
|
|
40
|
+
code: 'E-DUP-REF',
|
|
41
|
+
message: `중복 ref_id "${ref}" — 같은 문서에 같은 번호가 둘 이상 있습니다.`,
|
|
42
|
+
refs: [ref],
|
|
43
|
+
severity: 'E',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// ── E-BROKEN-REF: a link points at a ref that doesn't exist or is rejected ─
|
|
47
|
+
for (const it of accepted) {
|
|
48
|
+
for (const ref of allLinkRefs(links(it))) {
|
|
49
|
+
let broken = false;
|
|
50
|
+
if (/^REQ-/.test(ref)) {
|
|
51
|
+
broken = !reqSet.has(ref);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const target = byRef.get(ref);
|
|
55
|
+
broken = !target || target.status === 'rejected';
|
|
56
|
+
}
|
|
57
|
+
if (broken) {
|
|
58
|
+
out.push({
|
|
59
|
+
code: 'E-BROKEN-REF',
|
|
60
|
+
message: `${it.ref_id} 이(가) 존재하지 않거나 거절된 ${ref} 를 참조합니다.`,
|
|
61
|
+
refs: [it.ref_id, ref],
|
|
62
|
+
severity: 'E',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ── W-ORPHAN-SPEC: accepted feature with zero reqs links ──────────────────
|
|
68
|
+
for (const it of accepted) {
|
|
69
|
+
if (it.kind !== 'feature')
|
|
70
|
+
continue;
|
|
71
|
+
if ((links(it).reqs ?? []).length === 0) {
|
|
72
|
+
out.push({
|
|
73
|
+
code: 'W-ORPHAN-SPEC',
|
|
74
|
+
message: `${it.ref_id} "${it.title}" 이(가) 어느 요구(REQ)에도 연결되지 않았습니다.`,
|
|
75
|
+
refs: [it.ref_id],
|
|
76
|
+
severity: 'W',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// ── W-UNREACHED-PAGE: accepted page reached by no accepted step ───────────
|
|
81
|
+
const reachedPages = new Set();
|
|
82
|
+
for (const it of accepted) {
|
|
83
|
+
if (it.kind !== 'step')
|
|
84
|
+
continue;
|
|
85
|
+
const pg = meta(it).page;
|
|
86
|
+
if (pg)
|
|
87
|
+
reachedPages.add(pg);
|
|
88
|
+
}
|
|
89
|
+
for (const it of accepted) {
|
|
90
|
+
if (it.kind !== 'page')
|
|
91
|
+
continue;
|
|
92
|
+
if (!reachedPages.has(it.ref_id)) {
|
|
93
|
+
out.push({
|
|
94
|
+
code: 'W-UNREACHED-PAGE',
|
|
95
|
+
message: `${it.ref_id} "${it.title}" 에 도달하는 플로우 스텝이 없습니다.`,
|
|
96
|
+
refs: [it.ref_id],
|
|
97
|
+
severity: 'W',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// ── W-EMPTY-PAGE: accepted page with zero features links ──────────────────
|
|
102
|
+
for (const it of accepted) {
|
|
103
|
+
if (it.kind !== 'page')
|
|
104
|
+
continue;
|
|
105
|
+
if ((links(it).features ?? []).length === 0) {
|
|
106
|
+
out.push({
|
|
107
|
+
code: 'W-EMPTY-PAGE',
|
|
108
|
+
message: `${it.ref_id} "${it.title}" 에 연결된 기능(F)이 없습니다.`,
|
|
109
|
+
refs: [it.ref_id],
|
|
110
|
+
severity: 'W',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ── W-NO-FLOW: P0 accepted feature in no flow's links.features ────────────
|
|
115
|
+
const featuresInFlows = new Set();
|
|
116
|
+
for (const it of accepted) {
|
|
117
|
+
if (it.kind !== 'flow')
|
|
118
|
+
continue;
|
|
119
|
+
for (const f of links(it).features ?? [])
|
|
120
|
+
featuresInFlows.add(f);
|
|
121
|
+
}
|
|
122
|
+
for (const it of accepted) {
|
|
123
|
+
if (it.kind !== 'feature')
|
|
124
|
+
continue;
|
|
125
|
+
if (meta(it).priority !== 'P0')
|
|
126
|
+
continue;
|
|
127
|
+
if (!featuresInFlows.has(it.ref_id)) {
|
|
128
|
+
out.push({
|
|
129
|
+
code: 'W-NO-FLOW',
|
|
130
|
+
message: `P0 기능 ${it.ref_id} "${it.title}" 이(가) 어느 플로우에도 등장하지 않습니다.`,
|
|
131
|
+
refs: [it.ref_id],
|
|
132
|
+
severity: 'W',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
/** Stable de-dupe key for a violation (code + sorted refs) — used for waive/regression. */
|
|
139
|
+
export function violationKey(v) {
|
|
140
|
+
return `${v.code}::${[...v.refs].sort().join(',')}`;
|
|
141
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// AI 고해상도 시안 생성 — 페이지(IA) 1개 + StyleGuide → 자기완결 HTML 화면.
|
|
2
|
+
// AI_STUB 이면 테마 반영 결정적 HTML(테스트·오프라인). 아니면 provider 로 생성.
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
import { resolveProvider } from "../providers/index.js";
|
|
5
|
+
import { getModelConfig } from "./ai.js";
|
|
6
|
+
import { getStyleGuide, guidePromptText } from "./style-guide.js";
|
|
7
|
+
import * as repo from "../db/repos.js";
|
|
8
|
+
function meta(i) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(i.meta || '{}');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function bodyLines(i) {
|
|
17
|
+
return i.body.split('\n').map((l) => l.replace(/^[·\-*]\s*/, '').trim()).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
/** ```html 펜스·잡텍스트 제거 후 <html…>/<!doctype…>~</html> 만 뽑는다. */
|
|
20
|
+
export function extractHtml(text) {
|
|
21
|
+
let t = text.trim().replace(/^```[a-zA-Z]*\s*/, '').replace(/\s*```$/, '').trim();
|
|
22
|
+
let lower = t.toLowerCase();
|
|
23
|
+
// 서두 텍스트 제거 — doctype/html 중 '가장 이른' 마커부터. (max 아님: doctype 를 버리면 안 됨)
|
|
24
|
+
const starts = [lower.indexOf('<!doctype'), lower.indexOf('<html')].filter((i) => i >= 0);
|
|
25
|
+
const start = starts.length ? Math.min(...starts) : -1;
|
|
26
|
+
if (start > 0) {
|
|
27
|
+
t = t.slice(start);
|
|
28
|
+
lower = t.toLowerCase(); // slice 후 인덱스 재계산 (스테일 방지)
|
|
29
|
+
}
|
|
30
|
+
const end = lower.lastIndexOf('</html>');
|
|
31
|
+
if (end >= 0)
|
|
32
|
+
t = t.slice(0, end + 7);
|
|
33
|
+
return t;
|
|
34
|
+
}
|
|
35
|
+
function buildMessages(page, features, g) {
|
|
36
|
+
const type = (meta(page).page_type ?? 'GENERIC');
|
|
37
|
+
const featureBlock = features.length
|
|
38
|
+
? features.map((f) => `- ${f.title}${f.body ? `: ${bodyLines(f).slice(0, 4).join(' / ')}` : ''}`).join('\n')
|
|
39
|
+
: '- (연결된 기능 없음 — 화면 목적에 맞는 합리적 기본 요소)';
|
|
40
|
+
const system = `너는 시니어 프로덕트 디자이너다. 아래 화면 1개에 대한 **고해상도 시안**을 만든다.\n` +
|
|
41
|
+
`출력은 오직 자기완결 HTML 문서 하나 — 인라인 <style> 만 사용, 외부 리소스·이미지 URL·JS 금지. ` +
|
|
42
|
+
`코드펜스·설명 문장 없이 <!doctype html> 로 시작한다.\n` +
|
|
43
|
+
`모바일 앱 화면(폭 390px 기준, body 는 화면 배경, 중앙에 390px 프레임). 한국어 실제 콘텐츠로 채운다(로렘 금지).\n` +
|
|
44
|
+
`디자인 토큰(반드시 준수): ${guidePromptText(g)}\n` +
|
|
45
|
+
`화면 성격 = ${type}. 상단 앱바 + 성격에 맞는 본문(목록/상세/폼/대시보드/설정 등) + 주요 액션 버튼(accent).`;
|
|
46
|
+
const user = `화면: "${page.title}" (${type})\n담아야 할 기능/요소:\n${featureBlock}\n\n위 토큰과 성격대로 시안 HTML 을 출력하라.`;
|
|
47
|
+
return [
|
|
48
|
+
{ role: 'system', content: system },
|
|
49
|
+
{ role: 'user', content: user },
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
export async function generateMockupHtml(projectId, page) {
|
|
53
|
+
const g = getStyleGuide(projectId);
|
|
54
|
+
const featureRefs = meta(page).links?.features ?? [];
|
|
55
|
+
const items = repo.listProjectItems(projectId);
|
|
56
|
+
const byRef = new Map(items.map((i) => [i.ref_id, i]));
|
|
57
|
+
const features = featureRefs.map((r) => byRef.get(r)).filter((x) => !!x);
|
|
58
|
+
if (config.aiStub || config.managedTier)
|
|
59
|
+
return stubMockupHtml(page, features, g);
|
|
60
|
+
const cfg = getModelConfig('ia');
|
|
61
|
+
const provider = resolveProvider(cfg.provider);
|
|
62
|
+
const messages = buildMessages(page, features, g);
|
|
63
|
+
// 무거운 화면(기능 많음)은 시안 HTML 이 길다 → 넉넉한 토큰(잘림 방지).
|
|
64
|
+
const maxTokens = Math.max(cfg.maxTokens, 16000);
|
|
65
|
+
const run = async () => {
|
|
66
|
+
let text = '';
|
|
67
|
+
for await (const delta of provider.streamChat({ model: cfg.model, maxTokens, messages }))
|
|
68
|
+
text += delta;
|
|
69
|
+
return extractHtml(text);
|
|
70
|
+
};
|
|
71
|
+
let html = await run();
|
|
72
|
+
// 미완성(닫는 </html> 없음·너무 짧음) = 토큰 소진/스톨로 잘린 것 → 1회 재시도.
|
|
73
|
+
if (!isCompleteHtml(html))
|
|
74
|
+
html = await run();
|
|
75
|
+
if (!html.toLowerCase().includes('<html') && !html.toLowerCase().includes('<!doctype')) {
|
|
76
|
+
// 모델이 조각만 줬으면 최소 래핑
|
|
77
|
+
return `<!doctype html><html><head><meta charset="utf-8"></head><body>${html}</body></html>`;
|
|
78
|
+
}
|
|
79
|
+
return html;
|
|
80
|
+
}
|
|
81
|
+
/** 시안 HTML 이 온전한가 — 닫는 </html> 가 있고 최소 길이 이상. */
|
|
82
|
+
export function isCompleteHtml(html) {
|
|
83
|
+
const lc = html.toLowerCase();
|
|
84
|
+
return lc.includes('</html>') && html.trim().length > 400;
|
|
85
|
+
}
|
|
86
|
+
/** 결정적 테마 시안 — stub/offline. 실제 시안의 느낌을 토큰으로 재현. */
|
|
87
|
+
export function stubMockupHtml(page, features, g) {
|
|
88
|
+
const type = (meta(page).page_type ?? 'GENERIC');
|
|
89
|
+
const labels = features.map((f) => f.title);
|
|
90
|
+
const lines = features.flatMap((f) => bodyLines(f));
|
|
91
|
+
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
|
92
|
+
const gapPx = g.density === 'compact' ? 10 : g.density === 'spacious' ? 20 : 14;
|
|
93
|
+
const font = g.font === 'serif' ? 'Georgia, serif' : g.font === 'mono' ? 'ui-monospace, Menlo, monospace' : "-apple-system, system-ui, sans-serif";
|
|
94
|
+
const body = (() => {
|
|
95
|
+
switch (type) {
|
|
96
|
+
case 'LIST':
|
|
97
|
+
return `<div class="input">🔍 ${esc(page.title)} 검색</div>` +
|
|
98
|
+
(labels.length ? labels : [page.title]).slice(0, 4).map((t, i) => `<div class="card row"><div class="av">${i + 1}</div><div class="rt"><b>${esc(t)}</b><span>${esc(lines[i] ?? '항목 설명')}</span></div><span class="chev">›</span></div>`).join('');
|
|
99
|
+
case 'DETAIL':
|
|
100
|
+
return `<div class="hero"></div><h2>${esc(labels[0] ?? page.title)}</h2>` +
|
|
101
|
+
`<div class="chips">${['개요', '상세', '리뷰'].map((c, i) => `<span class="chip${i === 0 ? ' on' : ''}">${c}</span>`).join('')}</div>` +
|
|
102
|
+
lines.slice(0, 3).map((b) => `<p>${esc(b)}</p>`).join('') +
|
|
103
|
+
`<button class="cta">다음 단계로</button>`;
|
|
104
|
+
case 'FORM':
|
|
105
|
+
return (lines.length ? lines : labels).slice(0, 4).map((f) => `<label class="field"><span>${esc(f)}</span><div class="input"> </div></label>`).join('') +
|
|
106
|
+
`<button class="cta">제출</button>`;
|
|
107
|
+
case 'DASH':
|
|
108
|
+
return `<div class="stats">${(labels.length ? labels : ['활성 사용자', '전환율', '매출']).slice(0, 3).map((l, i) => `<div class="stat"><b>${(i + 4) * 13}%</b><span>${esc(l)}</span></div>`).join('')}</div>` +
|
|
109
|
+
`<div class="card"><div class="chart">${[42, 66, 80, 54, 92, 70].map((h) => `<i style="height:${h}%"></i>`).join('')}</div></div>`;
|
|
110
|
+
case 'SETTINGS':
|
|
111
|
+
return (lines.length ? lines : labels).slice(0, 4).map((t, i) => `<div class="card tgl"><span>${esc(t)}</span><span class="sw${i < 2 ? ' on' : ''}"></span></div>`).join('');
|
|
112
|
+
default:
|
|
113
|
+
return (lines.length ? lines : labels.length ? labels : [page.title]).map((b) => `<div class="card"><p>${esc(b)}</p></div>`).join('') +
|
|
114
|
+
`<button class="cta">열기</button>`;
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
|
118
|
+
:root{--ac:${g.accent};--bg:${g.bg};--sf:${g.surface};--ink:${g.ink};--sub:${g.sub};--ln:${g.line};--r:${g.radius}px;--gap:${gapPx}px}
|
|
119
|
+
*{box-sizing:border-box;margin:0}body{background:var(--bg);font-family:${font};color:var(--ink);display:flex;justify-content:center;padding:16px}
|
|
120
|
+
.frame{width:390px;background:var(--bg);display:flex;flex-direction:column;gap:var(--gap)}
|
|
121
|
+
.bar{display:flex;align-items:center;gap:10px;padding:14px 4px 4px;font-weight:700;font-size:17px}
|
|
122
|
+
.bar .sp{flex:1}.bar .dot{width:26px;height:26px;border-radius:50%;background:var(--ln)}
|
|
123
|
+
.card{background:var(--sf);border:1px solid var(--ln);border-radius:var(--r);padding:14px}
|
|
124
|
+
.row{display:flex;align-items:center;gap:12px}.av{width:38px;height:38px;border-radius:calc(var(--r) - 2px);background:color-mix(in srgb,var(--ac) 16%,var(--sf));color:var(--ac);display:flex;align-items:center;justify-content:center;font-weight:800;flex-shrink:0}
|
|
125
|
+
.rt{display:flex;flex-direction:column;min-width:0}.rt b{font-size:14px}.rt span{font-size:12px;color:var(--sub)}.chev{margin-left:auto;color:var(--sub);font-size:20px}
|
|
126
|
+
.input{background:var(--sf);border:1px solid var(--ln);border-radius:var(--r);padding:11px 13px;color:var(--sub);font-size:13px;min-height:20px}
|
|
127
|
+
.hero{height:150px;background:linear-gradient(135deg,color-mix(in srgb,var(--ac) 22%,var(--sf)),var(--sf));border-radius:var(--r);border:1px solid var(--ln)}
|
|
128
|
+
h2{font-size:20px;margin:2px 0}.chips{display:flex;gap:8px}.chip{font-size:12px;color:var(--sub);border:1px solid var(--ln);border-radius:99px;padding:4px 12px}.chip.on{background:var(--ac);color:#fff;border-color:var(--ac)}
|
|
129
|
+
p{font-size:13px;line-height:1.6;color:var(--sub)}
|
|
130
|
+
.field{display:flex;flex-direction:column;gap:6px}.field span{font-size:12px;color:var(--sub);font-weight:600}.field .input{min-height:42px}
|
|
131
|
+
.cta{background:var(--ac);color:#fff;border:0;border-radius:var(--r);padding:14px;font-size:15px;font-weight:700;margin-top:4px}
|
|
132
|
+
.stats{display:flex;gap:var(--gap)}.stat{flex:1;background:var(--sf);border:1px solid var(--ln);border-radius:var(--r);padding:14px}.stat b{font-size:22px;color:var(--ac)}.stat span{display:block;font-size:11px;color:var(--sub);margin-top:3px}
|
|
133
|
+
.chart{display:flex;align-items:flex-end;gap:8px;height:120px}.chart i{flex:1;background:var(--ac);opacity:.85;border-radius:4px 4px 0 0}
|
|
134
|
+
.tgl{display:flex;align-items:center}.tgl span:first-child{font-size:14px}.sw{margin-left:auto;width:42px;height:24px;border-radius:99px;background:var(--ln);position:relative}.sw.on{background:var(--ac)}.sw::after{content:'';position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:.2s}.sw.on::after{left:21px}
|
|
135
|
+
</style></head><body><div class="frame"><div class="bar">${type === 'DETAIL' || type === 'FORM' || type === 'SETTINGS' ? '<span>‹</span>' : ''}<span>${esc(page.title)}</span><span class="sp"></span><span class="dot"></span></div>${body}</div></body></html>`;
|
|
136
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Server-side ref_id assignment (§1.2). The LLM NEVER produces ref_ids — this
|
|
2
|
+
// module is the single source of truth. Rule: max existing number of that kind
|
|
3
|
+
// (or of that parent's children) + 1. Deleted numbers are NOT reused because we
|
|
4
|
+
// take max, never count. Pure functions — fully unit-tested.
|
|
5
|
+
function pad2(n) {
|
|
6
|
+
return String(n).padStart(2, '0');
|
|
7
|
+
}
|
|
8
|
+
/** Highest F-/PG-/FLOW- top-level number among rows of `kind`. 0 if none. */
|
|
9
|
+
function maxTopNumber(rows, kind, prefix) {
|
|
10
|
+
let max = 0;
|
|
11
|
+
for (const r of rows) {
|
|
12
|
+
if (r.kind !== kind)
|
|
13
|
+
continue;
|
|
14
|
+
const m = r.ref_id.match(prefix);
|
|
15
|
+
if (m)
|
|
16
|
+
max = Math.max(max, Number(m[1]));
|
|
17
|
+
}
|
|
18
|
+
return max;
|
|
19
|
+
}
|
|
20
|
+
/** Highest child suffix under a given parent ref (feature/step). 0 if none. */
|
|
21
|
+
function maxChildNumber(rows, parentRef, sep) {
|
|
22
|
+
// Escape regex metacharacters in the parent ref (e.g. the '.' in FLOW-01).
|
|
23
|
+
const esc = parentRef.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
|
+
const re = new RegExp(`^${esc}${sep === '.' ? '\\.' : '-'}(\\d+)$`);
|
|
25
|
+
let max = 0;
|
|
26
|
+
for (const r of rows) {
|
|
27
|
+
const m = r.ref_id.match(re);
|
|
28
|
+
if (m)
|
|
29
|
+
max = Math.max(max, Number(m[1]));
|
|
30
|
+
}
|
|
31
|
+
return max;
|
|
32
|
+
}
|
|
33
|
+
export function nextGroupRef(rows) {
|
|
34
|
+
return `F-${pad2(maxTopNumber(rows, 'feature-group', /^F-(\d+)$/) + 1)}`;
|
|
35
|
+
}
|
|
36
|
+
export function nextFeatureRef(rows, groupRef) {
|
|
37
|
+
return `${groupRef}-${maxChildNumber(rows, groupRef, '-') + 1}`;
|
|
38
|
+
}
|
|
39
|
+
export function nextPageRef(rows) {
|
|
40
|
+
return `PG-${pad2(maxTopNumber(rows, 'page', /^PG-(\d+)$/) + 1)}`;
|
|
41
|
+
}
|
|
42
|
+
export function nextFlowRef(rows) {
|
|
43
|
+
return `FLOW-${pad2(maxTopNumber(rows, 'flow', /^FLOW-(\d+)$/) + 1)}`;
|
|
44
|
+
}
|
|
45
|
+
export function nextStepRef(rows, flowRef) {
|
|
46
|
+
return `${flowRef}.${maxChildNumber(rows, flowRef, '.') + 1}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Assign the next ref_id for a new item of `kind`. `parentRef` is required for
|
|
50
|
+
* feature (its group's ref) and step (its flow's ref).
|
|
51
|
+
*/
|
|
52
|
+
export function nextRefId(rows, kind, parentRef) {
|
|
53
|
+
switch (kind) {
|
|
54
|
+
case 'feature-group':
|
|
55
|
+
return nextGroupRef(rows);
|
|
56
|
+
case 'feature':
|
|
57
|
+
if (!parentRef)
|
|
58
|
+
throw new Error('feature requires a parent group ref');
|
|
59
|
+
return nextFeatureRef(rows, parentRef);
|
|
60
|
+
case 'page':
|
|
61
|
+
return nextPageRef(rows);
|
|
62
|
+
case 'flow':
|
|
63
|
+
return nextFlowRef(rows);
|
|
64
|
+
case 'step':
|
|
65
|
+
if (!parentRef)
|
|
66
|
+
throw new Error('step requires a parent flow ref');
|
|
67
|
+
return nextStepRef(rows, parentRef);
|
|
68
|
+
default:
|
|
69
|
+
throw new Error(`unknown plan item kind: ${kind}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Narrow a PlanItem[] to the RefRow shape the numbering functions consume. */
|
|
73
|
+
export function toRefRows(items) {
|
|
74
|
+
return items.map((i) => ({ kind: i.kind, ref_id: i.ref_id, parent_id: i.parent_id }));
|
|
75
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider(직접·게이트웨이) 에러를 사용자 언어 + 해결 힌트로 표준화한다.
|
|
3
|
+
* 원문은 detail 로 보존한다. UI 배너·카드가 그대로 읽어 도움말을 준다.
|
|
4
|
+
*/
|
|
5
|
+
export function humanizeProviderError(status, rawBody) {
|
|
6
|
+
const body = (rawBody || '').slice(0, 400);
|
|
7
|
+
const lc = body.toLowerCase();
|
|
8
|
+
// 모델 접근 권한 없음 (게이트웨이 모델 그룹 제한 등) — 401 로도 오므로 auth 분기보다 먼저.
|
|
9
|
+
if (/not allowed to access model|model.*not found|does not exist|no endpoints found/i.test(lc)) {
|
|
10
|
+
return ('이 키로 접근할 수 없는 모델입니다. 설정의 모델 칸을 게이트웨이가 제공하는 모델로 바꾸세요' +
|
|
11
|
+
'(드롭다운). · ' + body);
|
|
12
|
+
}
|
|
13
|
+
// 인증 헤더 없음 / 잘못된 키 — base URL 이 표준 provider 를 가리키는데 게이트웨이 키를 넣은 전형
|
|
14
|
+
if (status === 401 || /missing authentication|unauthorized|invalid api key|no auth/i.test(lc)) {
|
|
15
|
+
return ('인증 실패(401). 키가 이 엔드포인트와 맞는지 확인하세요. ' +
|
|
16
|
+
'게이트웨이(LiteLLM 등)를 쓴다면 설정의 "OpenAI 호환 게이트웨이" base URL 을 넣고, ' +
|
|
17
|
+
'키는 그 게이트웨이 키여야 합니다. · ' + body);
|
|
18
|
+
}
|
|
19
|
+
// 모델별 게이트(예: 18+ 확인)
|
|
20
|
+
if (status === 403 || /age.?confirm|attestation|requires you to complete|forbidden/i.test(lc)) {
|
|
21
|
+
return ('이 모델은 계정에서 추가 확인(예: 연령 확인)이 필요하거나 접근이 막혀 있습니다. ' +
|
|
22
|
+
'다른 모델을 쓰거나 provider 설정에서 확인을 마치세요. · ' + body);
|
|
23
|
+
}
|
|
24
|
+
if (status === 429 || /rate limit|too many requests|quota/i.test(lc)) {
|
|
25
|
+
return '요청이 많거나 한도를 초과했습니다(429). 잠시 후 다시 시도하세요. · ' + body;
|
|
26
|
+
}
|
|
27
|
+
if (status >= 500) {
|
|
28
|
+
return `provider 서버 오류(${status}). 잠시 후 다시 시도하세요. · ` + body;
|
|
29
|
+
}
|
|
30
|
+
return `${status}: ${body}`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
import * as repo from "../db/repos.js";
|
|
3
|
+
marked.setOptions({ gfm: true, breaks: false });
|
|
4
|
+
export function documentToMarkdown(documentId) {
|
|
5
|
+
const doc = repo.getDocument(documentId);
|
|
6
|
+
if (!doc)
|
|
7
|
+
throw new Error('document not found');
|
|
8
|
+
// Only ACCEPTED sections are the document (SYSTEM.md §0.2).
|
|
9
|
+
const sections = repo.listAcceptedSections(documentId);
|
|
10
|
+
const parts = [`# ${doc.title}`, ''];
|
|
11
|
+
for (const s of sections) {
|
|
12
|
+
parts.push(`## ${s.heading}`, '', s.body.trim(), '');
|
|
13
|
+
}
|
|
14
|
+
// 구조 문서(기능명세·IA·유저플로우)는 내용이 sections 가 아니라 plan_items 에 있다.
|
|
15
|
+
// sections 만 렌더하면 제목만 나오므로, 수락된 항목을 마크다운으로 렌더한다.
|
|
16
|
+
parts.push(structureItemsToMarkdown(documentId));
|
|
17
|
+
const excluded = repo.countExcludedSections(documentId);
|
|
18
|
+
if (excluded > 0) {
|
|
19
|
+
parts.push('---', '', `> 검토 대기 ${excluded}개 제외`, '');
|
|
20
|
+
}
|
|
21
|
+
return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
22
|
+
}
|
|
23
|
+
/** 수락된 plan_items 를 트리(그룹/부모 → 자식)로 마크다운 렌더. 항목이 없으면 빈 문자열. */
|
|
24
|
+
export function structureItemsToMarkdown(documentId) {
|
|
25
|
+
const items = repo.listItems(documentId).filter((i) => i.status === 'accepted');
|
|
26
|
+
if (items.length === 0)
|
|
27
|
+
return '';
|
|
28
|
+
const byParent = new Map();
|
|
29
|
+
for (const it of items) {
|
|
30
|
+
const key = it.parent_id ?? null;
|
|
31
|
+
(byParent.get(key) ?? byParent.set(key, []).get(key)).push(it);
|
|
32
|
+
}
|
|
33
|
+
const roots = (byParent.get(null) ?? []).sort((a, b) => a.position - b.position);
|
|
34
|
+
const lines = [];
|
|
35
|
+
const linkLine = (it) => {
|
|
36
|
+
const m = repo.parsePlanItemMeta(it);
|
|
37
|
+
const refs = [
|
|
38
|
+
...(m.links?.reqs ?? []),
|
|
39
|
+
...(m.links?.features ?? []),
|
|
40
|
+
...(m.links?.pages ?? []),
|
|
41
|
+
...(m.links?.flows ?? []),
|
|
42
|
+
];
|
|
43
|
+
return refs.length ? `연결: ${refs.join(' · ')}` : null;
|
|
44
|
+
};
|
|
45
|
+
for (const root of roots) {
|
|
46
|
+
const rm = repo.parsePlanItemMeta(root);
|
|
47
|
+
const pri = rm.priority ? ` — ${rm.priority}` : '';
|
|
48
|
+
lines.push('', `## ${root.ref_id} ${root.title}${pri}`, '');
|
|
49
|
+
if (root.body.trim())
|
|
50
|
+
lines.push(root.body.trim(), '');
|
|
51
|
+
const rl = linkLine(root);
|
|
52
|
+
if (rl)
|
|
53
|
+
lines.push(rl, '');
|
|
54
|
+
const kids = (byParent.get(root.id) ?? []).sort((a, b) => a.position - b.position);
|
|
55
|
+
kids.forEach((kid, idx) => {
|
|
56
|
+
const km = repo.parsePlanItemMeta(kid);
|
|
57
|
+
const kpri = km.priority ? ` — ${km.priority}` : '';
|
|
58
|
+
// 스텝(플로우 자식)은 번호 목록, 그 외(기능 등)는 소제목으로
|
|
59
|
+
if (kid.kind === 'step') {
|
|
60
|
+
const page = km.page ? `[${km.page}] ` : '';
|
|
61
|
+
const branch = km.branch?.label ? ` _(분기: ${km.branch.label})_` : '';
|
|
62
|
+
lines.push(`${idx + 1}. ${page}${kid.title}${branch}`);
|
|
63
|
+
if (km.note)
|
|
64
|
+
lines.push(` ${km.note}`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
lines.push(`### ${kid.ref_id} ${kid.title}${kpri}`, '');
|
|
68
|
+
if (kid.body.trim())
|
|
69
|
+
lines.push(kid.body.trim(), '');
|
|
70
|
+
const kl = linkLine(kid);
|
|
71
|
+
if (kl)
|
|
72
|
+
lines.push(kl, '');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
lines.push('');
|
|
76
|
+
}
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
/** Minimal sanitization: strip <script>/<iframe> and inline event handlers. */
|
|
80
|
+
function sanitize(html) {
|
|
81
|
+
return html
|
|
82
|
+
.replace(/<\s*(script|iframe)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
|
83
|
+
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
84
|
+
.replace(/javascript:/gi, '');
|
|
85
|
+
}
|
|
86
|
+
export function documentToHtml(documentId, opts) {
|
|
87
|
+
const doc = repo.getDocument(documentId);
|
|
88
|
+
if (!doc)
|
|
89
|
+
throw new Error('document not found');
|
|
90
|
+
// Only ACCEPTED sections are the document (SYSTEM.md §0.2).
|
|
91
|
+
const sections = repo.listAcceptedSections(documentId);
|
|
92
|
+
let body = sections
|
|
93
|
+
.map((s) => `<section><h2>${escapeHtml(s.heading)}</h2>${sanitize(marked.parse(s.body))}</section>`)
|
|
94
|
+
.join('\n');
|
|
95
|
+
// 구조 문서 항목(plan_items)을 렌더 — sections 만 있으면 제목만 나오는 문제 방지
|
|
96
|
+
const itemsMd = structureItemsToMarkdown(documentId);
|
|
97
|
+
if (itemsMd.trim()) {
|
|
98
|
+
body += `\n<section>${sanitize(marked.parse(itemsMd))}</section>`;
|
|
99
|
+
}
|
|
100
|
+
const excluded = repo.countExcludedSections(documentId);
|
|
101
|
+
const footnote = excluded > 0
|
|
102
|
+
? `<footer class="excluded">검토 대기 ${excluded}개 제외</footer>`
|
|
103
|
+
: '';
|
|
104
|
+
const badge = opts?.readOnly ? '<div class="ro">읽기 전용 공유</div>' : '';
|
|
105
|
+
return `<!doctype html>
|
|
106
|
+
<html lang="ko">
|
|
107
|
+
<head>
|
|
108
|
+
<meta charset="utf-8" />
|
|
109
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
110
|
+
<title>${escapeHtml(doc.title)}</title>
|
|
111
|
+
<style>
|
|
112
|
+
:root { color-scheme: light dark; }
|
|
113
|
+
body { max-width: 820px; margin: 0 auto; padding: 3rem 1.25rem 6rem;
|
|
114
|
+
font: 16px/1.7 -apple-system, "Pretendard", "Segoe UI", system-ui, sans-serif;
|
|
115
|
+
color: #17181c; background: #ffffff; }
|
|
116
|
+
@media (prefers-color-scheme: dark) { body { color: #e7e7ea; background: #131315; } }
|
|
117
|
+
h1 { font-size: 2rem; letter-spacing: -0.02em; margin-bottom: 0.25rem; }
|
|
118
|
+
h2 { font-size: 1.25rem; margin-top: 2.4rem; padding-bottom: 0.3rem;
|
|
119
|
+
border-bottom: 1px solid rgba(128,128,128,0.25); }
|
|
120
|
+
section p { margin: 0.6rem 0; }
|
|
121
|
+
code { background: rgba(128,128,128,0.15); padding: 0.1em 0.35em; border-radius: 4px; }
|
|
122
|
+
blockquote { border-left: 3px solid #E03A2B; margin: 1rem 0; padding: 0.2rem 1rem; opacity: 0.85; }
|
|
123
|
+
.ro { position: fixed; top: 0; left: 0; right: 0; text-align: center; font-size: 0.75rem;
|
|
124
|
+
padding: 0.35rem; background: #E03A2B; color: #fff; letter-spacing: 0.05em; }
|
|
125
|
+
.meta { color: #8a8a90; font-size: 0.85rem; margin-bottom: 2rem; }
|
|
126
|
+
.excluded { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid rgba(128,128,128,0.25);
|
|
127
|
+
color: #8a8a90; font-size: 0.8rem; }
|
|
128
|
+
/* PDF(인쇄) 최적화 — 흰 배경 고정, 섹션 페이지 넘김 존중, 공유 배지 숨김 */
|
|
129
|
+
@media print {
|
|
130
|
+
body { color: #17181c; background: #fff; max-width: none; padding: 0; font-size: 12pt; }
|
|
131
|
+
.ro { display: none; }
|
|
132
|
+
section { break-inside: avoid; }
|
|
133
|
+
h2 { break-after: avoid; }
|
|
134
|
+
@page { margin: 18mm 16mm; }
|
|
135
|
+
}
|
|
136
|
+
</style>
|
|
137
|
+
</head>
|
|
138
|
+
<body>
|
|
139
|
+
${badge}
|
|
140
|
+
<h1>${escapeHtml(doc.title)}</h1>
|
|
141
|
+
<div class="meta">${escapeHtml(doc.type)} · v${doc.version}</div>
|
|
142
|
+
${body}
|
|
143
|
+
${footnote}
|
|
144
|
+
${opts?.print ? '<script>window.addEventListener("load", () => setTimeout(() => window.print(), 250));</script>' : ''}
|
|
145
|
+
</body>
|
|
146
|
+
</html>`;
|
|
147
|
+
}
|
|
148
|
+
function escapeHtml(s) {
|
|
149
|
+
return s
|
|
150
|
+
.replace(/&/g, '&')
|
|
151
|
+
.replace(/</g, '<')
|
|
152
|
+
.replace(/>/g, '>')
|
|
153
|
+
.replace(/"/g, '"');
|
|
154
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// 프로젝트 StyleGuide(테마) — C/B/A 세 레이어의 공용 스타일 소스.
|
|
2
|
+
// 저장은 프로젝트별 settings 키(style_guide:<pid>), 스키마 변경 없음.
|
|
3
|
+
import * as repo from "../db/repos.js";
|
|
4
|
+
// 뚜렷이 구분되는 프리셋 — C 의 스타일 선택지.
|
|
5
|
+
export const PRESETS = {
|
|
6
|
+
clean: { preset: 'clean', accent: '#4f46e5', bg: '#f6f7f9', surface: '#ffffff', ink: '#1b1c1e', sub: '#6f7076', line: '#e6e7ea', radius: 10, density: 'cozy', font: 'sans', mode: 'light' },
|
|
7
|
+
warm: { preset: 'warm', accent: '#c2603f', bg: '#faf6f0', surface: '#fffdfa', ink: '#2a231c', sub: '#8a7d6d', line: '#ece3d6', radius: 8, density: 'spacious', font: 'serif', mode: 'light' },
|
|
8
|
+
mono: { preset: 'mono', accent: '#1b1c1e', bg: '#fafafa', surface: '#ffffff', ink: '#111214', sub: '#70727a', line: '#e4e4e6', radius: 4, density: 'compact', font: 'mono', mode: 'light' },
|
|
9
|
+
vivid: { preset: 'vivid', accent: '#7c3aed', bg: '#f6f4ff', surface: '#ffffff', ink: '#211a34', sub: '#7b7391', line: '#e8e2f7', radius: 16, density: 'cozy', font: 'rounded', mode: 'light' },
|
|
10
|
+
dark: { preset: 'dark', accent: '#22d3ee', bg: '#0f1115', surface: '#171a21', ink: '#e7e9ee', sub: '#9aa0aa', line: '#272b34', radius: 10, density: 'cozy', font: 'sans', mode: 'dark' },
|
|
11
|
+
};
|
|
12
|
+
export const DEFAULT_GUIDE = PRESETS.clean;
|
|
13
|
+
const FONT_STACK = {
|
|
14
|
+
sans: "'Pretendard', -apple-system, system-ui, sans-serif",
|
|
15
|
+
serif: "'Iowan Old Style', 'Apple SD Gothic Neo', Georgia, serif",
|
|
16
|
+
rounded: "'SF Pro Rounded', 'Pretendard', system-ui, sans-serif",
|
|
17
|
+
mono: "ui-monospace, 'SF Mono', Menlo, monospace",
|
|
18
|
+
};
|
|
19
|
+
const DENSITY_GAP = { compact: 8, cozy: 12, spacious: 18 };
|
|
20
|
+
const key = (pid) => `style_guide:${pid}`;
|
|
21
|
+
export function getStyleGuide(projectId) {
|
|
22
|
+
const saved = repo.getSetting(key(projectId));
|
|
23
|
+
if (!saved)
|
|
24
|
+
return DEFAULT_GUIDE;
|
|
25
|
+
const base = PRESETS[saved.preset ?? 'clean'] ?? DEFAULT_GUIDE;
|
|
26
|
+
return { ...base, ...saved };
|
|
27
|
+
}
|
|
28
|
+
export function saveStyleGuide(projectId, patch) {
|
|
29
|
+
const current = getStyleGuide(projectId);
|
|
30
|
+
// 프리셋 전환이면 그 프리셋을 베이스로(값 리셋), 아니면 현재 값 유지 위에 부분 덮어쓰기.
|
|
31
|
+
const base = patch.preset && patch.preset !== current.preset ? PRESETS[patch.preset] ?? current : current;
|
|
32
|
+
const next = { ...base, ...patch };
|
|
33
|
+
repo.setSetting(key(projectId), next);
|
|
34
|
+
return next;
|
|
35
|
+
}
|
|
36
|
+
/** 프론트 렌더용 파생값(폰트 스택·간격) — CSS 변수로 내려보낸다. */
|
|
37
|
+
export function guideRender(g) {
|
|
38
|
+
return { fontStack: FONT_STACK[g.font], gap: DENSITY_GAP[g.density] };
|
|
39
|
+
}
|
|
40
|
+
/** AI 시안 프롬프트에 넣을 압축 스타일 설명 — 생성물이 테마와 일관되게. */
|
|
41
|
+
export function guidePromptText(g) {
|
|
42
|
+
return [
|
|
43
|
+
`배경 ${g.bg}, 표면(카드) ${g.surface}, 본문 텍스트 ${g.ink}, 보조 텍스트 ${g.sub}, 경계선 ${g.line}, 강조(accent) ${g.accent}.`,
|
|
44
|
+
`모서리 반경 ${g.radius}px, 밀도 ${g.density}(간격 ${DENSITY_GAP[g.density]}px 기준), 서체 계열 ${g.font}, 모드 ${g.mode}.`,
|
|
45
|
+
`강조색은 주요 버튼·활성 상태에만 절제해서 쓰고, 나머지는 표면·경계선·텍스트로 차분하게.`,
|
|
46
|
+
].join(' ');
|
|
47
|
+
}
|