@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/api/dist/db/index.js +107 -0
  4. package/api/dist/db/repos.js +670 -0
  5. package/api/dist/index.js +89 -0
  6. package/api/dist/lib/ai.js +314 -0
  7. package/api/dist/lib/config.js +57 -0
  8. package/api/dist/lib/crypto.js +71 -0
  9. package/api/dist/lib/design-system-gen.js +332 -0
  10. package/api/dist/lib/fixtures.js +150 -0
  11. package/api/dist/lib/gateway.js +55 -0
  12. package/api/dist/lib/handoff.js +283 -0
  13. package/api/dist/lib/items-gen.js +211 -0
  14. package/api/dist/lib/lint-service.js +118 -0
  15. package/api/dist/lib/lint.js +141 -0
  16. package/api/dist/lib/mockup-gen.js +136 -0
  17. package/api/dist/lib/numbering.js +75 -0
  18. package/api/dist/lib/provider-errors.js +31 -0
  19. package/api/dist/lib/render.js +154 -0
  20. package/api/dist/lib/style-guide.js +47 -0
  21. package/api/dist/lib/templates.js +85 -0
  22. package/api/dist/lib/types.js +1 -0
  23. package/api/dist/lib/wireframes.js +137 -0
  24. package/api/dist/providers/byok/anthropic.js +75 -0
  25. package/api/dist/providers/byok/openai-compat.js +95 -0
  26. package/api/dist/providers/cli.js +391 -0
  27. package/api/dist/providers/index.js +68 -0
  28. package/api/dist/providers/managed.js +22 -0
  29. package/api/dist/providers/sse.js +37 -0
  30. package/api/dist/providers/stub.js +55 -0
  31. package/api/dist/providers/types.js +1 -0
  32. package/api/dist/routes/backup.js +24 -0
  33. package/api/dist/routes/deliverables.js +588 -0
  34. package/api/dist/routes/documents.js +205 -0
  35. package/api/dist/routes/helpers.js +43 -0
  36. package/api/dist/routes/interview.js +141 -0
  37. package/api/dist/routes/keys.js +70 -0
  38. package/api/dist/routes/projects.js +103 -0
  39. package/api/dist/routes/settings.js +134 -0
  40. package/api/dist/routes/share.js +39 -0
  41. package/api/dist/routes/suggestions.js +144 -0
  42. package/api/templates/design-system.json +17 -0
  43. package/api/templates/feature-spec.json +73 -0
  44. package/api/templates/ia.json +52 -0
  45. package/api/templates/prd.json +60 -0
  46. package/api/templates/user-flow.json +61 -0
  47. package/bin/drafting.mjs +79 -0
  48. package/db/schema.sql +154 -0
  49. package/package.json +62 -0
  50. package/web/dist/assets/index-CS06cWP3.js +125 -0
  51. package/web/dist/assets/index-DWoYeaZU.css +1 -0
  52. package/web/dist/index.html +14 -0
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { config } from "./config.js";
4
+ import { getSetting, setSetting } from "../db/repos.js";
5
+ // Interview templates live as external JSON files (G-06: NOT hardcoded), so the
6
+ // community can add/edit document types without touching source. Loaded once at
7
+ // boot. Custom templates (edited in-app) are stored in the DB and overlaid on
8
+ // top of the file templates by id — so users can customize without file edits.
9
+ let fileCache = null;
10
+ function loadFileTemplates() {
11
+ if (fileCache)
12
+ return fileCache;
13
+ return reloadTemplates();
14
+ }
15
+ export function reloadTemplates() {
16
+ const map = new Map();
17
+ const dir = config.templatesDir;
18
+ if (fs.existsSync(dir)) {
19
+ for (const file of fs.readdirSync(dir)) {
20
+ if (!file.endsWith('.json'))
21
+ continue;
22
+ const raw = fs.readFileSync(path.join(dir, file), 'utf8');
23
+ try {
24
+ const tpl = JSON.parse(raw);
25
+ if (tpl.id && tpl.docType && Array.isArray(tpl.questions)) {
26
+ map.set(tpl.id, tpl);
27
+ }
28
+ }
29
+ catch {
30
+ // skip malformed template file
31
+ }
32
+ }
33
+ }
34
+ fileCache = map;
35
+ return map;
36
+ }
37
+ function customStore() {
38
+ return getSetting('custom_templates') ?? {};
39
+ }
40
+ /** File templates with DB custom templates overlaid (custom wins by id). */
41
+ function effectiveTemplates() {
42
+ const map = new Map(loadFileTemplates());
43
+ for (const [id, t] of Object.entries(customStore()))
44
+ map.set(id, t);
45
+ return map;
46
+ }
47
+ /** source of an id in the effective set — for the UI. */
48
+ export function templateSource(id) {
49
+ const isCustom = id in customStore();
50
+ const isFile = loadFileTemplates().has(id);
51
+ if (isCustom && isFile)
52
+ return 'override';
53
+ return isCustom ? 'custom' : 'file';
54
+ }
55
+ export function loadTemplates() {
56
+ return effectiveTemplates();
57
+ }
58
+ export function listTemplates() {
59
+ return [...effectiveTemplates().values()].sort((a, b) => a.docType.localeCompare(b.docType));
60
+ }
61
+ export function getTemplate(id) {
62
+ return effectiveTemplates().get(id) ?? null;
63
+ }
64
+ export function getTemplateForType(docType) {
65
+ for (const t of effectiveTemplates().values()) {
66
+ if (t.docType === docType)
67
+ return t;
68
+ }
69
+ return null;
70
+ }
71
+ // ── custom template mutations (in-app editor) ────────────────────────────────
72
+ export function saveCustomTemplate(t) {
73
+ const store = customStore();
74
+ store[t.id] = t;
75
+ setSetting('custom_templates', store);
76
+ }
77
+ /** Delete a custom template. For an override, this reverts to the file version. */
78
+ export function deleteCustomTemplate(id) {
79
+ const store = customStore();
80
+ if (!(id in store))
81
+ return false;
82
+ delete store[id];
83
+ setSetting('custom_templates', store);
84
+ return true;
85
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,137 @@
1
+ // Derived wireframe render data (§5.1) — deterministic, AI-free. Input: accepted
2
+ // (and proposed, flagged) IA pages + their linked feature content + flow steps.
3
+ // Content is SEEDED from feature titles/bodies; hotspots (→ PG-nn) come from the
4
+ // flow. Same input → same output. The React <WireframeGrid> paints this shape.
5
+ import * as repo from "../db/repos.js";
6
+ import { lintProject } from "./lint.js";
7
+ function meta(i) {
8
+ try {
9
+ return JSON.parse(i.meta || '{}');
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ function bodyLines(i) {
16
+ return i.body
17
+ .split('\n')
18
+ .map((l) => l.replace(/^[·\-*]\s*/, '').trim())
19
+ .filter(Boolean);
20
+ }
21
+ /**
22
+ * Build a flat, wireframe-ready list for a project. Pages that are 'rejected' are
23
+ * excluded; accepted + proposed pages are both included (proposed flagged so the
24
+ * UI can dash them — mirrors the IA sitemap proposal).
25
+ */
26
+ export function deriveWireframes(projectId) {
27
+ const items = repo.listProjectItems(projectId);
28
+ const byRef = new Map();
29
+ for (const it of items)
30
+ byRef.set(it.ref_id, it);
31
+ // page ref -> outgoing hotspot (from consecutive page-bearing MAIN steps)
32
+ const outgoing = new Map();
33
+ const flowsForPage = new Map();
34
+ const flowItems = items.filter((i) => i.kind === 'flow');
35
+ for (const flow of flowItems) {
36
+ const steps = items
37
+ .filter((i) => i.kind === 'step' && i.parent_id === flow.id)
38
+ .sort((a, b) => a.position - b.position);
39
+ let prevPage = null;
40
+ for (const st of steps) {
41
+ const m = meta(st);
42
+ if (m.branch)
43
+ continue; // branch steps don't drive the main hotspot chain
44
+ if (!m.page)
45
+ continue;
46
+ if (!flowsForPage.has(m.page))
47
+ flowsForPage.set(m.page, new Set());
48
+ flowsForPage.get(m.page).add(flow.ref_id);
49
+ if (prevPage && prevPage !== m.page && !outgoing.has(prevPage)) {
50
+ outgoing.set(prevPage, m.page);
51
+ }
52
+ prevPage = m.page;
53
+ }
54
+ }
55
+ const reqIds = repo.reqIdsForProject(projectId).map((r) => r.id);
56
+ const violations = lintProject(items, reqIds);
57
+ const pageWarning = new Map();
58
+ for (const v of violations) {
59
+ if (v.code === 'W-UNREACHED-PAGE')
60
+ pageWarning.set(v.refs[0], '이 화면에 도달하는 플로우 없음');
61
+ else if (v.code === 'W-EMPTY-PAGE' && !pageWarning.has(v.refs[0]))
62
+ pageWarning.set(v.refs[0], '연결된 기능 없음');
63
+ }
64
+ const pages = items
65
+ .filter((i) => i.kind === 'page' && i.status !== 'rejected')
66
+ .sort((a, b) => a.position - b.position);
67
+ return pages.map((page) => {
68
+ const m = meta(page);
69
+ const featureRefs = m.links?.features ?? [];
70
+ const features = featureRefs.map((r) => byRef.get(r)).filter((x) => !!x);
71
+ const toPage = outgoing.get(page.ref_id) ?? null;
72
+ const hotspot = toPage ? { toPage, label: `→ ${toPage}` } : null;
73
+ return {
74
+ ref: page.ref_id,
75
+ itemId: page.id,
76
+ title: page.title,
77
+ pageType: (m.page_type ?? 'GENERIC'),
78
+ status: page.status === 'accepted' ? 'accepted' : 'proposed',
79
+ featureRefs,
80
+ flowRefs: [...(flowsForPage.get(page.ref_id) ?? [])],
81
+ hotspot,
82
+ lintWarning: pageWarning.get(page.ref_id) ?? null,
83
+ seed: seedFor((m.page_type ?? 'GENERIC'), page, features, hotspot),
84
+ };
85
+ });
86
+ }
87
+ function seedFor(type, page, features, hotspot) {
88
+ const labels = features.map((f) => f.title);
89
+ const lines = features.flatMap((f) => bodyLines(f));
90
+ const cta = hotspot ? '다음 단계로' : '확인';
91
+ switch (type) {
92
+ case 'LIST':
93
+ return {
94
+ search: `${page.title} 검색`,
95
+ rows: (labels.length ? labels : [page.title]).slice(0, 3).map((t, i) => ({
96
+ title: t,
97
+ meta: `${i + 1}번`,
98
+ action: i === 0 && hotspot ? '열기' : '보기',
99
+ hot: i === 0 && hotspot ? hotspot.label : undefined,
100
+ })),
101
+ };
102
+ case 'DETAIL':
103
+ return {
104
+ detailTitle: labels[0] ?? page.title,
105
+ slots: ['옵션 A', '옵션 B', '옵션 C'].map((label, i) => ({
106
+ label,
107
+ state: i === 1 ? 'on' : i === 2 ? 'dis' : 'idle',
108
+ })),
109
+ blocks: lines.slice(0, 2),
110
+ cta: hotspot ? `${cta}` : page.title,
111
+ };
112
+ case 'FORM':
113
+ return {
114
+ fields: (lines.length ? lines : labels).slice(0, 4).map((label, i) => ({
115
+ label,
116
+ value: i === 0 ? page.title : '입력값',
117
+ })),
118
+ cta: hotspot ? '제출' : '저장',
119
+ };
120
+ case 'DASH':
121
+ return {
122
+ stats: (labels.length ? labels : ['지표 A', '지표 B', '지표 C'])
123
+ .slice(0, 3)
124
+ .map((label, i) => ({ value: `${(i + 5) * 12}%`, label })),
125
+ bars: [40, 65, 80, 55, 90],
126
+ };
127
+ case 'SETTINGS':
128
+ return {
129
+ toggles: (lines.length ? lines : labels).slice(0, 3).map((label, i) => ({
130
+ label,
131
+ on: i < 2,
132
+ })),
133
+ };
134
+ default:
135
+ return { blocks: lines.length ? lines : labels.length ? labels : [page.title] };
136
+ }
137
+ }
@@ -0,0 +1,75 @@
1
+ import { iterateSse } from "../sse.js";
2
+ const BASE = 'https://api.anthropic.com/v1';
3
+ export class AnthropicProvider {
4
+ id = 'anthropic';
5
+ apiKey;
6
+ constructor(apiKey) {
7
+ this.apiKey = apiKey;
8
+ }
9
+ headers() {
10
+ return {
11
+ 'x-api-key': this.apiKey,
12
+ 'anthropic-version': '2023-06-01',
13
+ 'content-type': 'application/json',
14
+ };
15
+ }
16
+ async *streamChat(params) {
17
+ const system = params.messages
18
+ .filter((m) => m.role === 'system')
19
+ .map((m) => m.content)
20
+ .join('\n\n');
21
+ const messages = params.messages
22
+ .filter((m) => m.role !== 'system')
23
+ .map((m) => ({ role: m.role, content: m.content }));
24
+ const res = await fetch(`${BASE}/messages`, {
25
+ method: 'POST',
26
+ headers: this.headers(),
27
+ signal: params.signal,
28
+ body: JSON.stringify({
29
+ model: params.model,
30
+ max_tokens: params.maxTokens ?? 4096,
31
+ system: system || undefined,
32
+ messages,
33
+ stream: true,
34
+ }),
35
+ });
36
+ if (!res.ok) {
37
+ throw new Error(`Anthropic ${res.status}: ${await res.text()}`);
38
+ }
39
+ for await (const data of iterateSse(res)) {
40
+ if (data === '[DONE]')
41
+ break;
42
+ try {
43
+ const evt = JSON.parse(data);
44
+ if (evt.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
45
+ yield evt.delta.text;
46
+ }
47
+ else if (evt.type === 'error') {
48
+ throw new Error(evt.error?.message ?? 'anthropic stream error');
49
+ }
50
+ }
51
+ catch {
52
+ // non-JSON keep-alive line; ignore
53
+ }
54
+ }
55
+ }
56
+ async testConnection(model) {
57
+ try {
58
+ const res = await fetch(`${BASE}/messages`, {
59
+ method: 'POST',
60
+ headers: this.headers(),
61
+ body: JSON.stringify({
62
+ model,
63
+ max_tokens: 1,
64
+ messages: [{ role: 'user', content: 'ping' }],
65
+ }),
66
+ });
67
+ if (res.ok)
68
+ return { ok: true };
69
+ return { ok: false, detail: `${res.status}: ${(await res.text()).slice(0, 200)}` };
70
+ }
71
+ catch (e) {
72
+ return { ok: false, detail: e.message };
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,95 @@
1
+ import { iterateSse } from "../sse.js";
2
+ import { humanizeProviderError } from "../../lib/provider-errors.js";
3
+ /**
4
+ * Shared implementation for OpenAI-compatible chat/completions endpoints.
5
+ * OpenAI and OpenRouter differ only in base URL + optional extra headers.
6
+ */
7
+ export class OpenAICompatProvider {
8
+ id;
9
+ apiKey;
10
+ base;
11
+ extraHeaders;
12
+ constructor(id, apiKey, base, extraHeaders = {}) {
13
+ this.id = id;
14
+ this.apiKey = apiKey;
15
+ this.base = base;
16
+ this.extraHeaders = extraHeaders;
17
+ }
18
+ headers() {
19
+ return {
20
+ authorization: `Bearer ${this.apiKey}`,
21
+ 'content-type': 'application/json',
22
+ ...this.extraHeaders,
23
+ };
24
+ }
25
+ async *streamChat(params) {
26
+ const res = await fetch(`${this.base}/chat/completions`, {
27
+ method: 'POST',
28
+ headers: this.headers(),
29
+ signal: params.signal,
30
+ body: JSON.stringify({
31
+ model: params.model,
32
+ max_tokens: params.maxTokens ?? 4096,
33
+ messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
34
+ stream: true,
35
+ }),
36
+ });
37
+ if (!res.ok) {
38
+ throw new Error(humanizeProviderError(res.status, await res.text()));
39
+ }
40
+ for await (const data of iterateSse(res)) {
41
+ if (data === '[DONE]')
42
+ break;
43
+ try {
44
+ const evt = JSON.parse(data);
45
+ const delta = evt.choices?.[0]?.delta?.content;
46
+ if (typeof delta === 'string' && delta.length)
47
+ yield delta;
48
+ }
49
+ catch {
50
+ // keep-alive / non-JSON line
51
+ }
52
+ }
53
+ }
54
+ async testConnection(model) {
55
+ try {
56
+ const res = await fetch(`${this.base}/chat/completions`, {
57
+ method: 'POST',
58
+ headers: this.headers(),
59
+ body: JSON.stringify({
60
+ model,
61
+ max_tokens: 1,
62
+ messages: [{ role: 'user', content: 'ping' }],
63
+ }),
64
+ });
65
+ if (res.ok)
66
+ return { ok: true };
67
+ return { ok: false, detail: `${res.status}: ${(await res.text()).slice(0, 200)}` };
68
+ }
69
+ catch (e) {
70
+ return { ok: false, detail: e.message };
71
+ }
72
+ }
73
+ }
74
+ export class OpenAIProvider extends OpenAICompatProvider {
75
+ // base 를 넘기면 OpenAI 호환 게이트웨이(LiteLLM·Azure·사내 프록시)를 그대로 사용한다.
76
+ // 게이트웨이가 표준 Bearer 외 다른 헤더를 요구하면 extraHeaders 로 주입한다.
77
+ constructor(apiKey, base, extraHeaders = {}) {
78
+ super('openai', apiKey, normalizeBase(base) ?? 'https://api.openai.com/v1', extraHeaders);
79
+ }
80
+ }
81
+ /** 사용자가 붙인 base 를 정규화 — 끝 슬래시 제거. 빈 값이면 null(기본값 사용). */
82
+ function normalizeBase(base) {
83
+ const b = (base ?? '').trim().replace(/\/+$/, '');
84
+ return b || null;
85
+ }
86
+ export class OpenRouterProvider extends OpenAICompatProvider {
87
+ // base 를 넘기면 OpenAI 호환 게이트웨이(LiteLLM 등)로 라우팅. 안 넘기면 openrouter.ai.
88
+ constructor(apiKey, base, extraHeaders = {}) {
89
+ super('openrouter', apiKey, normalizeBase(base) ?? 'https://openrouter.ai/api/v1', {
90
+ 'HTTP-Referer': 'https://github.com/hanmariyang/drafting',
91
+ 'X-Title': 'Drafting',
92
+ ...extraHeaders,
93
+ });
94
+ }
95
+ }