@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,205 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as repo from "../db/repos.js";
|
|
3
|
+
import { HttpError, parse } from "./helpers.js";
|
|
4
|
+
import { documentToMarkdown, documentToHtml } from "../lib/render.js";
|
|
5
|
+
import { nowIso } from "../db/index.js";
|
|
6
|
+
const DOC_TYPES = ['prd', 'feature-spec', 'ia', 'user-flow', 'design-system'];
|
|
7
|
+
export async function documentRoutes(app) {
|
|
8
|
+
// create a document under a project
|
|
9
|
+
app.post('/api/projects/:pid/documents', async (req) => {
|
|
10
|
+
const { pid } = req.params;
|
|
11
|
+
if (!repo.getProject(pid))
|
|
12
|
+
throw new HttpError(404, 'project not found');
|
|
13
|
+
const body = parse(z.object({
|
|
14
|
+
type: z.enum(DOC_TYPES),
|
|
15
|
+
title: z.string().min(1),
|
|
16
|
+
parentDocumentId: z.string().nullable().optional(),
|
|
17
|
+
}), req.body);
|
|
18
|
+
if (body.parentDocumentId && !repo.getDocument(body.parentDocumentId)) {
|
|
19
|
+
throw new HttpError(400, 'parent document not found');
|
|
20
|
+
}
|
|
21
|
+
return repo.createDocument({
|
|
22
|
+
projectId: pid,
|
|
23
|
+
type: body.type,
|
|
24
|
+
title: body.title,
|
|
25
|
+
parentDocumentId: body.parentDocumentId ?? null,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
// full document view: doc + sections + session + context state
|
|
29
|
+
app.get('/api/documents/:id', async (req) => {
|
|
30
|
+
const { id } = req.params;
|
|
31
|
+
const doc = repo.getDocument(id);
|
|
32
|
+
if (!doc)
|
|
33
|
+
throw new HttpError(404, 'document not found');
|
|
34
|
+
return {
|
|
35
|
+
document: doc,
|
|
36
|
+
sections: repo.listSections(id),
|
|
37
|
+
session: repo.getSessionByDocument(id),
|
|
38
|
+
parentContextAvailable: repo.getParentContext(id) !== null,
|
|
39
|
+
openSuggestions: repo.countOpenSuggestions(id),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
app.patch('/api/documents/:id', async (req) => {
|
|
43
|
+
const { id } = req.params;
|
|
44
|
+
const body = parse(z.object({ title: z.string().min(1) }), req.body);
|
|
45
|
+
const updated = repo.updateDocumentTitle(id, body.title);
|
|
46
|
+
if (!updated)
|
|
47
|
+
throw new HttpError(404, 'document not found');
|
|
48
|
+
return updated;
|
|
49
|
+
});
|
|
50
|
+
app.delete('/api/documents/:id', async (req) => {
|
|
51
|
+
const { id } = req.params;
|
|
52
|
+
if (!repo.getDocument(id))
|
|
53
|
+
throw new HttpError(404, 'document not found');
|
|
54
|
+
repo.deleteDocument(id);
|
|
55
|
+
return { ok: true };
|
|
56
|
+
});
|
|
57
|
+
// ── sections ──────────────────────────────────────────────────────────────
|
|
58
|
+
app.get('/api/documents/:id/sections', async (req) => {
|
|
59
|
+
const { id } = req.params;
|
|
60
|
+
if (!repo.getDocument(id))
|
|
61
|
+
throw new HttpError(404, 'document not found');
|
|
62
|
+
return repo.listSections(id);
|
|
63
|
+
});
|
|
64
|
+
app.post('/api/documents/:id/sections', async (req) => {
|
|
65
|
+
const { id } = req.params;
|
|
66
|
+
if (!repo.getDocument(id))
|
|
67
|
+
throw new HttpError(404, 'document not found');
|
|
68
|
+
const body = parse(z.object({ heading: z.string(), body: z.string().optional() }), req.body);
|
|
69
|
+
const section = repo.createSection(id, body.heading, body.body ?? '');
|
|
70
|
+
repo.snapshotDocument(id, 'save', { reason: 'add_section' });
|
|
71
|
+
return section;
|
|
72
|
+
});
|
|
73
|
+
app.patch('/api/sections/:sid', async (req) => {
|
|
74
|
+
const { sid } = req.params;
|
|
75
|
+
const section = repo.getSection(sid);
|
|
76
|
+
if (!section)
|
|
77
|
+
throw new HttpError(404, 'section not found');
|
|
78
|
+
const body = parse(z.object({ heading: z.string().optional(), body: z.string().optional() }), req.body);
|
|
79
|
+
const updated = repo.updateSection(sid, body);
|
|
80
|
+
// A manual edit is a structural change -> version bump + child staleness.
|
|
81
|
+
repo.snapshotDocument(section.document_id, 'save', { reason: 'edit_section', sectionId: sid });
|
|
82
|
+
return updated;
|
|
83
|
+
});
|
|
84
|
+
app.delete('/api/sections/:sid', async (req) => {
|
|
85
|
+
const { sid } = req.params;
|
|
86
|
+
const section = repo.getSection(sid);
|
|
87
|
+
if (!section)
|
|
88
|
+
throw new HttpError(404, 'section not found');
|
|
89
|
+
repo.deleteSection(sid);
|
|
90
|
+
repo.snapshotDocument(section.document_id, 'save', { reason: 'delete_section' });
|
|
91
|
+
return { ok: true };
|
|
92
|
+
});
|
|
93
|
+
app.post('/api/documents/:id/sections/reorder', async (req) => {
|
|
94
|
+
const { id } = req.params;
|
|
95
|
+
if (!repo.getDocument(id))
|
|
96
|
+
throw new HttpError(404, 'document not found');
|
|
97
|
+
const body = parse(z.object({ orderedIds: z.array(z.string()) }), req.body);
|
|
98
|
+
const sections = repo.reorderSections(id, body.orderedIds);
|
|
99
|
+
repo.snapshotDocument(id, 'save', { reason: 'reorder' });
|
|
100
|
+
return sections;
|
|
101
|
+
});
|
|
102
|
+
// ── context chain (P-01) ────────────────────────────────────────────────────
|
|
103
|
+
app.get('/api/documents/:id/context/parent', async (req) => {
|
|
104
|
+
const { id } = req.params;
|
|
105
|
+
if (!repo.getDocument(id))
|
|
106
|
+
throw new HttpError(404, 'document not found');
|
|
107
|
+
const ctx = repo.getParentContext(id);
|
|
108
|
+
if (!ctx)
|
|
109
|
+
return { available: false };
|
|
110
|
+
return { available: true, ...ctx };
|
|
111
|
+
});
|
|
112
|
+
app.post('/api/documents/:id/context/refresh', async (req) => {
|
|
113
|
+
const { id } = req.params;
|
|
114
|
+
if (!repo.getDocument(id))
|
|
115
|
+
throw new HttpError(404, 'document not found');
|
|
116
|
+
// Only 'context-only' is supported here. Section regeneration (flow B) is
|
|
117
|
+
// driven per-section by the client via the regenerate endpoint (SPEC-07).
|
|
118
|
+
const body = parse(z.object({ mode: z.literal('context-only').default('context-only') }), req.body ?? {});
|
|
119
|
+
void body;
|
|
120
|
+
const updated = repo.refreshContext(id);
|
|
121
|
+
return updated;
|
|
122
|
+
});
|
|
123
|
+
// ── versions (SPEC-12) ──────────────────────────────────────────────────────
|
|
124
|
+
app.get('/api/documents/:id/versions', async (req) => {
|
|
125
|
+
const { id } = req.params;
|
|
126
|
+
if (!repo.getDocument(id))
|
|
127
|
+
throw new HttpError(404, 'document not found');
|
|
128
|
+
return repo.listVersions(id).map((v) => ({ ...v, meta: JSON.parse(v.meta) }));
|
|
129
|
+
});
|
|
130
|
+
app.post('/api/documents/:id/versions/:vid/restore', async (req) => {
|
|
131
|
+
const { id, vid } = req.params;
|
|
132
|
+
const restored = repo.restoreVersion(id, vid);
|
|
133
|
+
if (!restored)
|
|
134
|
+
throw new HttpError(404, 'version not found');
|
|
135
|
+
return {
|
|
136
|
+
document: restored,
|
|
137
|
+
sections: repo.listSections(id),
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
// 되돌리기(undo) — 직전 스냅샷으로 1단계 복원. 더 깊은 복원은 버전 기록 사용.
|
|
141
|
+
app.post('/api/documents/:id/undo', async (req) => {
|
|
142
|
+
const { id } = req.params;
|
|
143
|
+
if (!repo.getDocument(id))
|
|
144
|
+
throw new HttpError(404, 'document not found');
|
|
145
|
+
const versions = repo.listVersions(id); // 최신순
|
|
146
|
+
if (versions.length < 2)
|
|
147
|
+
throw new HttpError(400, '되돌릴 변경이 없습니다');
|
|
148
|
+
const restored = repo.restoreVersion(id, versions[1].id);
|
|
149
|
+
if (!restored)
|
|
150
|
+
throw new HttpError(404, 'version not found');
|
|
151
|
+
return { document: restored, sections: repo.listSections(id) };
|
|
152
|
+
});
|
|
153
|
+
// ── export (SPEC-13/14) ─────────────────────────────────────────────────────
|
|
154
|
+
app.get('/api/documents/:id/export.md', async (req, reply) => {
|
|
155
|
+
const { id } = req.params;
|
|
156
|
+
if (!repo.getDocument(id))
|
|
157
|
+
throw new HttpError(404, 'document not found');
|
|
158
|
+
reply
|
|
159
|
+
.header('Content-Type', 'text/markdown; charset=utf-8')
|
|
160
|
+
.header('Content-Disposition', `attachment; filename="${id}.md"`);
|
|
161
|
+
return documentToMarkdown(id);
|
|
162
|
+
});
|
|
163
|
+
app.get('/api/documents/:id/export.html', async (req, reply) => {
|
|
164
|
+
const { id } = req.params;
|
|
165
|
+
if (!repo.getDocument(id))
|
|
166
|
+
throw new HttpError(404, 'document not found');
|
|
167
|
+
// ?print=1 → 로드 시 인쇄 대화상자 자동 실행(브라우저 'PDF로 저장'). 의존성 없는 PDF 경로.
|
|
168
|
+
const print = req.query?.print === '1';
|
|
169
|
+
reply.header('Content-Type', 'text/html; charset=utf-8');
|
|
170
|
+
return documentToHtml(id, { print });
|
|
171
|
+
});
|
|
172
|
+
// ── share links (SPEC-14) ───────────────────────────────────────────────────
|
|
173
|
+
app.post('/api/documents/:id/shares', async (req) => {
|
|
174
|
+
const { id } = req.params;
|
|
175
|
+
if (!repo.getDocument(id))
|
|
176
|
+
throw new HttpError(404, 'document not found');
|
|
177
|
+
const body = parse(z.object({ expiresInHours: z.number().positive().nullable().optional() }), req.body ?? {});
|
|
178
|
+
let expiresAt = null;
|
|
179
|
+
if (body.expiresInHours) {
|
|
180
|
+
expiresAt = new Date(Date.now() + body.expiresInHours * 3600_000).toISOString();
|
|
181
|
+
}
|
|
182
|
+
const link = repo.createShareLink(id, expiresAt);
|
|
183
|
+
return { ...link, url: `/s/${link.token}` };
|
|
184
|
+
});
|
|
185
|
+
app.get('/api/documents/:id/shares', async (req) => {
|
|
186
|
+
const { id } = req.params;
|
|
187
|
+
if (!repo.getDocument(id))
|
|
188
|
+
throw new HttpError(404, 'document not found');
|
|
189
|
+
return repo.listShareLinks(id).map((l) => ({
|
|
190
|
+
...l,
|
|
191
|
+
url: `/s/${l.token}`,
|
|
192
|
+
expired: isExpired(l.expires_at),
|
|
193
|
+
}));
|
|
194
|
+
});
|
|
195
|
+
app.post('/api/shares/:sid/revoke', async (req) => {
|
|
196
|
+
const { sid } = req.params;
|
|
197
|
+
repo.revokeShareLink(sid);
|
|
198
|
+
return { ok: true, at: nowIso() };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
export function isExpired(expiresAt) {
|
|
202
|
+
if (!expiresAt)
|
|
203
|
+
return false;
|
|
204
|
+
return new Date(expiresAt).getTime() < Date.now();
|
|
205
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export class HttpError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(status, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function parse(schema, body) {
|
|
9
|
+
const result = schema.safeParse(body);
|
|
10
|
+
if (!result.success) {
|
|
11
|
+
const msg = result.error.issues
|
|
12
|
+
.map((i) => `${i.path.join('.') || '(body)'}: ${i.message}`)
|
|
13
|
+
.join('; ');
|
|
14
|
+
throw new HttpError(400, msg);
|
|
15
|
+
}
|
|
16
|
+
return result.data;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Set up a Server-Sent Events stream on the raw response and return a writer.
|
|
20
|
+
* Wires an AbortController to the client disconnect so upstream AI calls stop.
|
|
21
|
+
*/
|
|
22
|
+
export function sseStream(request, reply) {
|
|
23
|
+
// take over the socket so Fastify does not also try to send a reply
|
|
24
|
+
reply.hijack();
|
|
25
|
+
reply.raw.writeHead(200, {
|
|
26
|
+
'Content-Type': 'text/event-stream',
|
|
27
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
28
|
+
Connection: 'keep-alive',
|
|
29
|
+
'X-Accel-Buffering': 'no',
|
|
30
|
+
});
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
request.raw.on('close', () => controller.abort());
|
|
33
|
+
return {
|
|
34
|
+
signal: controller.signal,
|
|
35
|
+
send(event, data) {
|
|
36
|
+
reply.raw.write(`event: ${event}\n`);
|
|
37
|
+
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
38
|
+
},
|
|
39
|
+
end() {
|
|
40
|
+
reply.raw.end();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as repo from "../db/repos.js";
|
|
3
|
+
import { HttpError, parse, sseStream } from "./helpers.js";
|
|
4
|
+
import { listTemplates, getTemplate, getTemplateForType, templateSource, saveCustomTemplate, deleteCustomTemplate, } from "../lib/templates.js";
|
|
5
|
+
import { streamDocumentDraft, streamSectionRegeneration } from "../lib/ai.js";
|
|
6
|
+
export async function interviewRoutes(app) {
|
|
7
|
+
// ── templates (SPEC-01/02) + 라이브러리 편집 ─────────────────────────────────
|
|
8
|
+
app.get('/api/templates', async () => listTemplates().map((t) => ({ ...t, source: templateSource(t.id) })));
|
|
9
|
+
app.get('/api/templates/:id', async (req) => {
|
|
10
|
+
const { id } = req.params;
|
|
11
|
+
const t = getTemplate(id);
|
|
12
|
+
if (!t)
|
|
13
|
+
throw new HttpError(404, 'template not found');
|
|
14
|
+
return { ...t, source: templateSource(id) };
|
|
15
|
+
});
|
|
16
|
+
// 커스텀 템플릿 생성/수정 — 파일 템플릿 위에 DB 로 오버레이(파일 미수정, G-06 유지).
|
|
17
|
+
app.put('/api/templates/:id', async (req) => {
|
|
18
|
+
const { id } = req.params;
|
|
19
|
+
const body = parse(TEMPLATE_SCHEMA, req.body);
|
|
20
|
+
if (body.id !== id)
|
|
21
|
+
throw new HttpError(400, 'id 가 경로와 일치해야 합니다');
|
|
22
|
+
saveCustomTemplate(body);
|
|
23
|
+
return { ...getTemplate(id), source: templateSource(id) };
|
|
24
|
+
});
|
|
25
|
+
// 커스텀 삭제 — 오버라이드면 파일 버전으로 복귀, 순수 커스텀이면 제거.
|
|
26
|
+
app.delete('/api/templates/:id', async (req) => {
|
|
27
|
+
const { id } = req.params;
|
|
28
|
+
const removed = deleteCustomTemplate(id);
|
|
29
|
+
if (!removed)
|
|
30
|
+
throw new HttpError(404, '커스텀 템플릿이 아닙니다(파일 템플릿은 삭제 불가)');
|
|
31
|
+
const reverted = getTemplate(id);
|
|
32
|
+
return { ok: true, reverted: reverted ? { ...reverted, source: templateSource(id) } : null };
|
|
33
|
+
});
|
|
34
|
+
// ── interview session (SPEC-03 autosave/resume) ─────────────────────────────
|
|
35
|
+
app.get('/api/documents/:id/interview', async (req) => {
|
|
36
|
+
const { id } = req.params;
|
|
37
|
+
const doc = repo.getDocument(id);
|
|
38
|
+
if (!doc)
|
|
39
|
+
throw new HttpError(404, 'document not found');
|
|
40
|
+
const session = repo.getSessionByDocument(id);
|
|
41
|
+
const template = getTemplateForType(doc.type);
|
|
42
|
+
return { session, template };
|
|
43
|
+
});
|
|
44
|
+
// create (or return existing) session for a document
|
|
45
|
+
app.post('/api/documents/:id/interview', async (req) => {
|
|
46
|
+
const { id } = req.params;
|
|
47
|
+
const doc = repo.getDocument(id);
|
|
48
|
+
if (!doc)
|
|
49
|
+
throw new HttpError(404, 'document not found');
|
|
50
|
+
const template = getTemplateForType(doc.type);
|
|
51
|
+
if (!template)
|
|
52
|
+
throw new HttpError(400, `no interview template for type ${doc.type}`);
|
|
53
|
+
let session = repo.getSessionByDocument(id);
|
|
54
|
+
if (!session)
|
|
55
|
+
session = repo.createSession(id, template.id);
|
|
56
|
+
return { session, template };
|
|
57
|
+
});
|
|
58
|
+
// autosave a single answer + progress index (SPEC-03)
|
|
59
|
+
app.post('/api/interview/:sid/answer', async (req) => {
|
|
60
|
+
const { sid } = req.params;
|
|
61
|
+
const session = repo.getSession(sid);
|
|
62
|
+
if (!session)
|
|
63
|
+
throw new HttpError(404, 'session not found');
|
|
64
|
+
const body = parse(z.object({
|
|
65
|
+
questionId: z.string(),
|
|
66
|
+
question: z.string(),
|
|
67
|
+
answer: z.string(),
|
|
68
|
+
currentIndex: z.number().int().nonnegative().optional(),
|
|
69
|
+
}), req.body);
|
|
70
|
+
const answers = [...session.answers];
|
|
71
|
+
const existing = answers.findIndex((a) => a.questionId === body.questionId);
|
|
72
|
+
const entry = { questionId: body.questionId, question: body.question, answer: body.answer };
|
|
73
|
+
if (existing >= 0)
|
|
74
|
+
answers[existing] = entry;
|
|
75
|
+
else
|
|
76
|
+
answers.push(entry);
|
|
77
|
+
return repo.updateSession(sid, {
|
|
78
|
+
answers,
|
|
79
|
+
current_index: body.currentIndex ?? session.current_index,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
app.post('/api/interview/:sid/complete', async (req) => {
|
|
83
|
+
const { sid } = req.params;
|
|
84
|
+
const session = repo.getSession(sid);
|
|
85
|
+
if (!session)
|
|
86
|
+
throw new HttpError(404, 'session not found');
|
|
87
|
+
return repo.updateSession(sid, { status: 'complete' });
|
|
88
|
+
});
|
|
89
|
+
// ── draft streaming (SPEC-06, SSE) ──────────────────────────────────────────
|
|
90
|
+
// GET so it works with EventSource. Streams the whole document draft.
|
|
91
|
+
app.get('/api/documents/:id/draft/stream', async (req, reply) => {
|
|
92
|
+
const { id } = req.params;
|
|
93
|
+
if (!repo.getDocument(id))
|
|
94
|
+
throw new HttpError(404, 'document not found');
|
|
95
|
+
await pipeDraft((signal) => streamDocumentDraft(id, signal), req, reply);
|
|
96
|
+
});
|
|
97
|
+
// regenerate a single section (SPEC-07, SSE)
|
|
98
|
+
app.get('/api/sections/:sid/regenerate/stream', async (req, reply) => {
|
|
99
|
+
const { sid } = req.params;
|
|
100
|
+
if (!repo.getSection(sid))
|
|
101
|
+
throw new HttpError(404, 'section not found');
|
|
102
|
+
await pipeDraft((signal) => streamSectionRegeneration(sid, signal), req, reply);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async function pipeDraft(makeGen, req, reply) {
|
|
106
|
+
const sse = sseStream(req, reply);
|
|
107
|
+
// 클라이언트가 '중지'로 연결을 끊으면 signal 이 abort 되어 provider 호출까지 멈춘다(토큰 절약).
|
|
108
|
+
const gen = makeGen(sse.signal);
|
|
109
|
+
try {
|
|
110
|
+
for await (const evt of gen) {
|
|
111
|
+
const { type, ...rest } = evt;
|
|
112
|
+
sse.send(type, rest);
|
|
113
|
+
if (evt.type === 'done' || evt.type === 'error')
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
sse.send('error', { message: e.message });
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
sse.end();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const DOC_TYPES = ['prd', 'feature-spec', 'ia', 'user-flow', 'design-system', 'handoff'];
|
|
125
|
+
const TEMPLATE_SCHEMA = z.object({
|
|
126
|
+
id: z.string().min(1).max(60).regex(/^[a-z0-9-]+$/, 'id 는 소문자·숫자·하이픈만'),
|
|
127
|
+
docType: z.enum(DOC_TYPES),
|
|
128
|
+
name: z.string().min(1).max(120),
|
|
129
|
+
description: z.string().max(400).default(''),
|
|
130
|
+
questions: z
|
|
131
|
+
.array(z.object({
|
|
132
|
+
id: z.string().min(1).max(40),
|
|
133
|
+
prompt: z.string().min(1).max(600),
|
|
134
|
+
hint: z.string().max(300).optional(),
|
|
135
|
+
example: z.string().max(400).optional(),
|
|
136
|
+
}))
|
|
137
|
+
.min(1, '질문이 최소 1개 필요합니다'),
|
|
138
|
+
sections: z.array(z.string().min(1).max(80)).min(1, '섹션이 최소 1개 필요합니다'),
|
|
139
|
+
draftGuidance: z.string().min(1).max(2000),
|
|
140
|
+
itemSchema: z.unknown().optional(),
|
|
141
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as repo from "../db/repos.js";
|
|
3
|
+
import { HttpError, parse } from "./helpers.js";
|
|
4
|
+
import { resolveProvider } from "../providers/index.js";
|
|
5
|
+
import { getModelConfig } from "../lib/ai.js";
|
|
6
|
+
import { probeGateway } from "../lib/gateway.js";
|
|
7
|
+
import { config } from "../lib/config.js";
|
|
8
|
+
const PROVIDERS = ['anthropic', 'openai', 'openrouter'];
|
|
9
|
+
export async function keyRoutes(app) {
|
|
10
|
+
// masked list — never returns key material (G-01)
|
|
11
|
+
app.get('/api/keys', async () => {
|
|
12
|
+
const configured = repo.listKeyMeta();
|
|
13
|
+
return PROVIDERS.map((p) => {
|
|
14
|
+
const meta = configured.find((k) => k.provider === p);
|
|
15
|
+
return {
|
|
16
|
+
provider: p,
|
|
17
|
+
configured: !!meta,
|
|
18
|
+
label: meta?.label ?? '',
|
|
19
|
+
last4: meta?.last4 ?? '',
|
|
20
|
+
updatedAt: meta?.updated_at ?? null,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
app.put('/api/keys/:provider', async (req) => {
|
|
25
|
+
const provider = validProvider(req.params);
|
|
26
|
+
const body = parse(z.object({ key: z.string().min(8), label: z.string().optional() }), req.body);
|
|
27
|
+
const meta = repo.upsertApiKey(provider, body.key.trim(), body.label ?? '');
|
|
28
|
+
return { provider: meta.provider, configured: true, last4: meta.last4 };
|
|
29
|
+
});
|
|
30
|
+
app.delete('/api/keys/:provider', async (req) => {
|
|
31
|
+
const provider = validProvider(req.params);
|
|
32
|
+
repo.deleteApiKey(provider);
|
|
33
|
+
return { ok: true };
|
|
34
|
+
});
|
|
35
|
+
// test call (SPEC-18) — uses stored key (or stub/managed per env)
|
|
36
|
+
app.post('/api/keys/:provider/test', async (req) => {
|
|
37
|
+
const provider = validProvider(req.params);
|
|
38
|
+
const body = parse(z.object({ model: z.string().optional() }), req.body ?? {});
|
|
39
|
+
let ai;
|
|
40
|
+
try {
|
|
41
|
+
ai = resolveProvider(provider, { forceByok: true }); // 키 테스트는 항상 BYOK 경로
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
throw new HttpError(400, e.message);
|
|
45
|
+
}
|
|
46
|
+
// 테스트 모델 선정: 명시값 > (openai 게이트웨이면) 게이트웨이가 실제 제공하는 모델 > 설정 기본값.
|
|
47
|
+
// 게이트웨이는 gpt-4o-mini 등 기본 모델 권한이 없어 401 이 나므로, 실 모델로 테스트한다.
|
|
48
|
+
let model = body.model;
|
|
49
|
+
if (!model && (provider === 'openai' || provider === 'openrouter')) {
|
|
50
|
+
const base = repo.getSetting('openai_base_url') || config.openaiBaseUrl || '';
|
|
51
|
+
if (base) {
|
|
52
|
+
const key = repo.getDecryptedKey(provider);
|
|
53
|
+
const headers = repo.getSetting('openai_headers') ?? {};
|
|
54
|
+
const probe = key ? await probeGateway(base, key, headers) : null;
|
|
55
|
+
const configured = getModelConfig('prd').model;
|
|
56
|
+
model = probe?.models?.includes(configured) ? configured : probe?.models?.[0];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
model = model ?? getModelConfig('prd').model;
|
|
60
|
+
const result = await ai.testConnection(model);
|
|
61
|
+
return { provider, model, ...result };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function validProvider(params) {
|
|
65
|
+
const { provider } = params;
|
|
66
|
+
if (!PROVIDERS.includes(provider)) {
|
|
67
|
+
throw new HttpError(400, `unknown provider: ${provider}`);
|
|
68
|
+
}
|
|
69
|
+
return provider;
|
|
70
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as repo from "../db/repos.js";
|
|
3
|
+
import { HttpError, parse } from "./helpers.js";
|
|
4
|
+
export async function projectRoutes(app) {
|
|
5
|
+
app.get('/api/projects', async () => {
|
|
6
|
+
return repo.listProjects().map((p) => ({
|
|
7
|
+
...p,
|
|
8
|
+
documentCount: repo.listDocuments(p.id).length,
|
|
9
|
+
}));
|
|
10
|
+
});
|
|
11
|
+
app.post('/api/projects', async (req) => {
|
|
12
|
+
const body = parse(z.object({ name: z.string().min(1), description: z.string().optional() }), req.body);
|
|
13
|
+
return repo.createProject(body.name, body.description ?? '');
|
|
14
|
+
});
|
|
15
|
+
app.get('/api/projects/:id', async (req) => {
|
|
16
|
+
const { id } = req.params;
|
|
17
|
+
const project = repo.getProject(id);
|
|
18
|
+
if (!project)
|
|
19
|
+
throw new HttpError(404, 'project not found');
|
|
20
|
+
// open_suggestions drives the tree green dot (SYSTEM.md §2).
|
|
21
|
+
const documents = repo.listDocuments(id).map((d) => ({
|
|
22
|
+
...d,
|
|
23
|
+
open_suggestions: repo.countOpenSuggestions(d.id),
|
|
24
|
+
}));
|
|
25
|
+
return { ...project, documents };
|
|
26
|
+
});
|
|
27
|
+
app.patch('/api/projects/:id', async (req) => {
|
|
28
|
+
const { id } = req.params;
|
|
29
|
+
const body = parse(z.object({ name: z.string().min(1).optional(), description: z.string().optional() }), req.body);
|
|
30
|
+
const updated = repo.updateProject(id, body);
|
|
31
|
+
if (!updated)
|
|
32
|
+
throw new HttpError(404, 'project not found');
|
|
33
|
+
return updated;
|
|
34
|
+
});
|
|
35
|
+
app.delete('/api/projects/:id', async (req) => {
|
|
36
|
+
const { id } = req.params;
|
|
37
|
+
if (!repo.getProject(id))
|
|
38
|
+
throw new HttpError(404, 'project not found');
|
|
39
|
+
repo.deleteProject(id);
|
|
40
|
+
return { ok: true };
|
|
41
|
+
});
|
|
42
|
+
// Dependency graph across the project's documents (SPEC-05)
|
|
43
|
+
app.get('/api/projects/:id/graph', async (req) => {
|
|
44
|
+
const { id } = req.params;
|
|
45
|
+
if (!repo.getProject(id))
|
|
46
|
+
throw new HttpError(404, 'project not found');
|
|
47
|
+
const docs = repo.listDocuments(id);
|
|
48
|
+
return {
|
|
49
|
+
nodes: docs.map((d) => ({
|
|
50
|
+
id: d.id,
|
|
51
|
+
type: d.type,
|
|
52
|
+
title: d.title,
|
|
53
|
+
status: d.status,
|
|
54
|
+
contextStale: d.context_stale === 1,
|
|
55
|
+
})),
|
|
56
|
+
edges: docs
|
|
57
|
+
.filter((d) => d.parent_document_id)
|
|
58
|
+
.map((d) => ({ from: d.parent_document_id, to: d.id })),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
// Example plan for first-run onboarding: a sample chain with open suggestions
|
|
62
|
+
// so the accept/reject grammar can be practiced on real UI (SYSTEM.md §4).
|
|
63
|
+
app.post('/api/sample', async () => {
|
|
64
|
+
const existing = repo.listProjects().find((p) => p.name === SAMPLE_NAME);
|
|
65
|
+
if (existing) {
|
|
66
|
+
const docs = repo.listDocuments(existing.id);
|
|
67
|
+
return { project: existing, documentId: docs[0]?.id ?? null, created: false };
|
|
68
|
+
}
|
|
69
|
+
const project = repo.createProject(SAMPLE_NAME, '팀 지표를 매주 월요일 슬랙으로 요약');
|
|
70
|
+
const prd = repo.createDocument({ projectId: project.id, type: 'prd', title: '제품 요구사항' });
|
|
71
|
+
repo.createSection(prd.id, '문제 정의', '팀 지표가 여러 대시보드에 흩어져 있어 주간 회의 준비에 매번 30분 이상 쓴다. 리더가 손으로 숫자를 모으는 동안 해석은 뒷전이 된다.');
|
|
72
|
+
repo.createSection(prd.id, '목표', '매주 월요일 09:00, 지난주 핵심 지표 요약이 슬랙 채널에 자동 게시된다. 준비 시간 30분을 0분으로.');
|
|
73
|
+
const scope = repo.createSection(prd.id, '범위', '1차 범위는 슬랙 게시 봇 하나. 지표 소스는 스프레드시트 1개로 시작한다.');
|
|
74
|
+
repo.createSection(prd.id, '지표 이상 감지', '전주 대비 20% 이상 변동한 지표는 요약 상단에 따로 표시한다.', undefined, 'proposed');
|
|
75
|
+
repo.createSuggestion({
|
|
76
|
+
documentId: prd.id,
|
|
77
|
+
kind: 'add',
|
|
78
|
+
title: '이상 감지 섹션 추가',
|
|
79
|
+
body: '인터뷰에서 "숫자보다 변화를 놓치는 게 무섭다"고 답했습니다. 급변 지표를 상단에 올리는 규칙을 제안합니다.',
|
|
80
|
+
quoteAfter: '전주 대비 20% 이상 변동한 지표는 요약 상단에 따로 표시한다.',
|
|
81
|
+
source: 'Q4',
|
|
82
|
+
});
|
|
83
|
+
repo.createSuggestion({
|
|
84
|
+
documentId: prd.id,
|
|
85
|
+
sectionId: scope.id,
|
|
86
|
+
kind: 'revise',
|
|
87
|
+
title: '범위에 제외 항목 명시',
|
|
88
|
+
body: '범위 섹션에 "하지 않는 것"이 없으면 스코프가 새기 쉽습니다. 대시보드 UI는 만들지 않는다는 한 줄을 제안합니다.',
|
|
89
|
+
quoteBefore: '1차 범위는 슬랙 게시 봇 하나. 지표 소스는 스프레드시트 1개로 시작한다.',
|
|
90
|
+
quoteAfter: '1차 범위는 슬랙 게시 봇 하나. 지표 소스는 스프레드시트 1개로 시작하며, 별도 대시보드 UI는 만들지 않는다.',
|
|
91
|
+
source: 'PRD §1',
|
|
92
|
+
});
|
|
93
|
+
repo.createSuggestion({
|
|
94
|
+
documentId: prd.id,
|
|
95
|
+
kind: 'question',
|
|
96
|
+
title: '누락 확인',
|
|
97
|
+
body: '요약을 받는 채널이 팀 공개 채널인가요, 리더 전용인가요? 답에 따라 지표 민감도 처리가 갈립니다.',
|
|
98
|
+
source: 'PRD §2',
|
|
99
|
+
});
|
|
100
|
+
return { project, documentId: prd.id, created: true };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const SAMPLE_NAME = '예시: 주간 리포트 봇';
|