taekepco commited on
Commit
48529c8
·
verified ·
1 Parent(s): eb9df86

Update static/index.html

Browse files
Files changed (1) hide show
  1. static/index.html +340 -0
static/index.html CHANGED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="Cache-Control" content="no-store">
7
+ <title>공문 생성기</title>
8
+ <style>
9
+ :root {
10
+ --bg: #f5f5f4;
11
+ --panel: #ffffff;
12
+ --border: #d6d3d1;
13
+ --text: #1c1917;
14
+ --muted: #78716c;
15
+ --accent: #0f172a;
16
+ --accent-hover: #334155;
17
+ --error: #b91c1c;
18
+ }
19
+ * { box-sizing: border-box; }
20
+ body {
21
+ margin: 0;
22
+ font-family: "Pretendard", "Malgun Gothic", "맑은 고딕", -apple-system, BlinkMacSystemFont, sans-serif;
23
+ background: var(--bg);
24
+ color: var(--text);
25
+ line-height: 1.6;
26
+ }
27
+ .container {
28
+ max-width: 1100px;
29
+ margin: 0 auto;
30
+ padding: 20px 16px 60px;
31
+ }
32
+ header h1 { margin: 0 0 4px; font-size: 22px; }
33
+ header p { margin: 0 0 20px; color: var(--muted); font-size: 13px; }
34
+ .grid {
35
+ display: grid;
36
+ grid-template-columns: 1fr 1fr;
37
+ gap: 16px;
38
+ }
39
+ @media (max-width: 800px) {
40
+ .grid { grid-template-columns: 1fr; }
41
+ }
42
+ .panel {
43
+ background: var(--panel);
44
+ border: 1px solid var(--border);
45
+ border-radius: 8px;
46
+ padding: 16px;
47
+ }
48
+ .panel h2 {
49
+ margin: 0 0 12px;
50
+ font-size: 13px;
51
+ color: var(--muted);
52
+ font-weight: 600;
53
+ letter-spacing: 0.5px;
54
+ text-transform: uppercase;
55
+ }
56
+ label {
57
+ display: block;
58
+ font-size: 12px;
59
+ color: var(--muted);
60
+ margin: 8px 0 4px;
61
+ }
62
+ input[type=text], select, textarea {
63
+ width: 100%;
64
+ padding: 8px 10px;
65
+ border: 1px solid var(--border);
66
+ border-radius: 6px;
67
+ font-family: inherit;
68
+ font-size: 14px;
69
+ background: #fff;
70
+ }
71
+ textarea { resize: vertical; min-height: 100px; }
72
+ .row { display: flex; gap: 8px; flex-wrap: wrap; }
73
+ .row > * { flex: 1; min-width: 0; }
74
+ button {
75
+ padding: 10px 14px;
76
+ background: var(--accent);
77
+ color: #fff;
78
+ border: 0;
79
+ border-radius: 6px;
80
+ font-size: 14px;
81
+ font-weight: 600;
82
+ cursor: pointer;
83
+ }
84
+ button:hover:not(:disabled) { background: var(--accent-hover); }
85
+ button:disabled { background: #a8a29e; cursor: not-allowed; }
86
+ button.secondary {
87
+ background: #fff;
88
+ color: var(--text);
89
+ border: 1px solid var(--border);
90
+ }
91
+ button.secondary:hover:not(:disabled) { background: #f5f5f4; }
92
+ .btnrow {
93
+ display: flex;
94
+ gap: 8px;
95
+ flex-wrap: wrap;
96
+ margin-top: 12px;
97
+ }
98
+ .btnrow > button:first-child { flex: 1; }
99
+ .status {
100
+ font-size: 12px;
101
+ color: var(--muted);
102
+ margin-top: 10px;
103
+ min-height: 18px;
104
+ }
105
+ .status.error { color: var(--error); }
106
+ .output {
107
+ white-space: pre-wrap;
108
+ font-family: "D2Coding", "Consolas", "Malgun Gothic", monospace;
109
+ font-size: 14px;
110
+ background: #fafaf9;
111
+ border: 1px solid var(--border);
112
+ border-radius: 6px;
113
+ padding: 14px;
114
+ min-height: 360px;
115
+ line-height: 1.8;
116
+ }
117
+ </style>
118
+ </head>
119
+ <body>
120
+ <div class="container">
121
+ <header>
122
+ <h1>공문 생성기</h1>
123
+ <p>EXAONE-3.5-2.4B-Instruct · Hugging Face Spaces (CPU)</p>
124
+ </header>
125
+
126
+ <div class="grid">
127
+ <section class="panel">
128
+ <h2>입력</h2>
129
+
130
+ <label for="doc-type">문서 유형</label>
131
+ <select id="doc-type">
132
+ <option value="협조요청">협조 요청</option>
133
+ <option value="공지/안내">공지/안내</option>
134
+ <option value="요청">요청</option>
135
+ <option value="회신">회신</option>
136
+ <option value="보고">보고</option>
137
+ </select>
138
+
139
+ <div class="row">
140
+ <div>
141
+ <label for="recipient">수신</label>
142
+ <input id="recipient" type="text" placeholder="예: 각 부서장">
143
+ </div>
144
+ <div>
145
+ <label for="sender">발신명의</label>
146
+ <input id="sender" type="text" placeholder="예: 총무팀장">
147
+ </div>
148
+ </div>
149
+
150
+ <label for="title">제목 (선택)</label>
151
+ <input id="title" type="text" placeholder="비워두면 내용으로부터 생성">
152
+
153
+ <label for="prompt">내용 요점</label>
154
+ <textarea id="prompt" placeholder="예: 5.13(수) 부사장 회의 참석 요청, 9~10시, 본사 회의실"></textarea>
155
+
156
+ <div class="btnrow">
157
+ <button id="generate" disabled>로딩 중...</button>
158
+ <button id="stop" class="secondary" disabled>중지</button>
159
+ </div>
160
+ <div class="status" id="status">서버 깨우는 중...</div>
161
+ </section>
162
+
163
+ <section class="panel">
164
+ <h2>생성 결과</h2>
165
+ <div class="output" id="output">여기에 결과가 표시됩니다.</div>
166
+ <div class="btnrow">
167
+ <button id="copy" class="secondary">복사</button>
168
+ <button id="download-txt" class="secondary">.txt 저장</button>
169
+ </div>
170
+ </section>
171
+ </div>
172
+ </div>
173
+
174
+ <script type="module">
175
+ const $ = (id) => document.getElementById(id);
176
+ const statusEl = $('status');
177
+ const outputEl = $('output');
178
+ const genBtn = $('generate');
179
+ const stopBtn = $('stop');
180
+
181
+ let abortCtrl = null;
182
+
183
+ const setStatus = (msg, isError = false) => {
184
+ statusEl.textContent = msg;
185
+ statusEl.classList.toggle('error', isError);
186
+ };
187
+
188
+ const SYSTEM_PROMPT = `당신은 한국 행정 공문 작성 전문가입니다. 사용자가 요청한 내용을 한국 표준 공문 양식에 맞춰 작성하세요.
189
+
190
+ [작성 규칙]
191
+ - 형식: 수신 / 제목 / 본문 / 붙임(있을 경우) / 발신명의
192
+ - 본문 첫 단락은 문서 목적을 요약하는 2줄 이내의 도입부
193
+ - "관련:" 같은 법령 인용 항목은 사용하지 않음
194
+ - 본문 항목 번호: "1. 2. 3. ..." 사용, 세부는 "가. 나. 다. ..."
195
+ - 문체: "~합니다", "~바랍니다" 공식체
196
+ - 날짜: "YYYY. M. D.(요일)"
197
+ - 본문 마지막 항목 뒤 "끝." 표시
198
+ - 마크다운 기호(**, *, #, -) 절대 사용 금지
199
+ - 군더더기 없이 핵심만, 추측 금지`;
200
+
201
+ function buildUserPrompt() {
202
+ const parts = [`[문서 유형] ${$('doc-type').value}`];
203
+ if ($('recipient').value.trim()) parts.push(`[수신] ${$('recipient').value.trim()}`);
204
+ if ($('sender').value.trim()) parts.push(`[발신명의] ${$('sender').value.trim()}`);
205
+ if ($('title').value.trim()) parts.push(`[제목] ${$('title').value.trim()}`);
206
+ parts.push('[내용 요점]');
207
+ parts.push($('prompt').value.trim());
208
+ return parts.join('\n');
209
+ }
210
+
211
+ function stripMarkdown(text) {
212
+ return text
213
+ .replace(/\*\*(.+?)\*\*/gs, '$1')
214
+ .replace(/(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)/gs, '$1')
215
+ .replace(/^#{1,6}\s+/gm, '')
216
+ .replace(/^(\s*)[-*]\s+/gm, '$1');
217
+ }
218
+
219
+ async function checkHealth() {
220
+ for (let i = 0; i < 60; i++) {
221
+ try {
222
+ const r = await fetch('/health');
223
+ if (r.ok) {
224
+ const j = await r.json();
225
+ if (j.ready) {
226
+ setStatus('준비 완료. 입력 후 [생성]을 누르세요.');
227
+ genBtn.disabled = false;
228
+ genBtn.textContent = '생성';
229
+ return;
230
+ }
231
+ setStatus(`로딩 중: ${j.status || '...'}`);
232
+ }
233
+ } catch (_) {
234
+ setStatus('서버 응답 대기 중...');
235
+ }
236
+ await new Promise(r => setTimeout(r, 3000));
237
+ }
238
+ setStatus('서버 준비 안 됨 (3분 타임아웃)', true);
239
+ }
240
+
241
+ async function generate() {
242
+ if (!$('prompt').value.trim()) {
243
+ setStatus('내용 요점을 입력하세요.', true);
244
+ return;
245
+ }
246
+ outputEl.textContent = '';
247
+ genBtn.disabled = true;
248
+ stopBtn.disabled = false;
249
+ setStatus('생성 중...');
250
+
251
+ abortCtrl = new AbortController();
252
+ let buffer = '';
253
+
254
+ try {
255
+ const r = await fetch('/generate', {
256
+ method: 'POST',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({
259
+ messages: [
260
+ { role: 'system', content: SYSTEM_PROMPT },
261
+ { role: 'user', content: buildUserPrompt() },
262
+ ],
263
+ max_new_tokens: 600,
264
+ temperature: 0.7,
265
+ top_p: 0.8,
266
+ top_k: 20,
267
+ }),
268
+ signal: abortCtrl.signal,
269
+ });
270
+ if (!r.ok) {
271
+ const t = await r.text();
272
+ throw new Error(`HTTP ${r.status}: ${t}`);
273
+ }
274
+
275
+ const reader = r.body.getReader();
276
+ const decoder = new TextDecoder();
277
+ let leftover = '';
278
+ while (true) {
279
+ const { value, done } = await reader.read();
280
+ if (done) break;
281
+ const chunk = leftover + decoder.decode(value, { stream: true });
282
+ const lines = chunk.split('\n');
283
+ leftover = lines.pop() || '';
284
+ for (const line of lines) {
285
+ if (!line.startsWith('data:')) continue;
286
+ try {
287
+ const obj = JSON.parse(line.slice(5).trim());
288
+ if (obj.error) throw new Error(obj.error);
289
+ if (obj.done) {
290
+ setStatus(`완료 (${obj.tokens} 토큰, ${obj.elapsed}초)`);
291
+ return;
292
+ }
293
+ if (obj.token) {
294
+ buffer += obj.token;
295
+ outputEl.textContent = stripMarkdown(buffer);
296
+ outputEl.scrollTop = outputEl.scrollHeight;
297
+ }
298
+ } catch (e) { /* skip parse errors */ }
299
+ }
300
+ }
301
+ setStatus('완료.');
302
+ } catch (e) {
303
+ if (e.name === 'AbortError') {
304
+ setStatus('중지됨.');
305
+ } else {
306
+ setStatus('오류: ' + e.message, true);
307
+ }
308
+ } finally {
309
+ genBtn.disabled = false;
310
+ stopBtn.disabled = true;
311
+ abortCtrl = null;
312
+ }
313
+ }
314
+
315
+ genBtn.addEventListener('click', generate);
316
+ stopBtn.addEventListener('click', () => abortCtrl?.abort());
317
+ $('copy').addEventListener('click', async () => {
318
+ await navigator.clipboard.writeText(outputEl.textContent);
319
+ setStatus('복사됨.');
320
+ });
321
+ $('download-txt').addEventListener('click', () => {
322
+ const text = outputEl.textContent;
323
+ if (!text.trim()) { setStatus('저장할 내용 없음.', true); return; }
324
+ const title = $('title').value.trim() || '공문';
325
+ const safeTitle = title.replace(/[\\/:*?"<>|]/g, '_').slice(0, 50);
326
+ // UTF-8 BOM 추가 (메모장/한글에서 깨짐 방지)
327
+ const blob = new Blob(['' + text], { type: 'text/plain;charset=utf-8' });
328
+ const url = URL.createObjectURL(blob);
329
+ const a = document.createElement('a');
330
+ a.href = url;
331
+ a.download = safeTitle + '.txt';
332
+ a.click();
333
+ URL.revokeObjectURL(url);
334
+ setStatus('저장됨.');
335
+ });
336
+
337
+ checkHealth();
338
+ </script>
339
+ </body>
340
+ </html>