Files
AriWeb-Suite/modules/webmaker/lib/prompt.js

228 lines
21 KiB
JavaScript

'use strict';
// 사용자 입력과 첨부 파일 정보를 Claude 에 전달할 프롬프트로 정리한다.
const { humanSize } = require('./uploads');
const fs = require('fs');
const path = require('path');
const WIDGET_TPL = fs.readFileSync(path.join(__dirname, '..', 'templates', 'contact-widget.html'), 'utf8');
const sitemsg = require('./sitemsg');
/**
* 표준 문의 위젯 코드 (사이트별 엔드포인트 삽입).
* v1.3.16: 화면 글자는 코드에 두지 않고 시스템 문구 카탈로그(lib/sitemsg)에서 사이트 언어(lang)로 채운다.
* @param {string} [lang] 사이트 본문(기본) 언어 — 다국어 사이트는 기본 언어, 다른 언어 페이지는 번역 단계에서 바뀐다
*/
function contactWidget(site, kind = 'contact', endpointBase, lang = 'ko') {
const endpoint = `${String(endpointBase || '').replace(/\/$/, '')}/api/forms/${site.id}/contact`;
const t = sitemsg.forLang(lang);
return sitemsg.renderTemplate(WIDGET_TPL.replace(/{{ENDPOINT}}/g, endpoint).replace(/{{SITE_ID}}/g, site.id).replace(/{{KIND}}/g, kind).replace(/{{MESSAGE_LABEL}}/g, t(kind === 'booking' ? 'contact.bookingMessageLabel' : 'contact.messageLabel')).replace(/{{BUTTON_LABEL}}/g, t(kind === 'booking' ? 'contact.bookingButton' : 'inquiry.button')), lang);
}
function widgetSection(site, endpointBase, opts = {}) {
if (!site || !endpointBase) return '';
return `[문의·예약 접수 표준 위젯 — 반드시 이 코드 그대로 사용]
문의, 상담 신청, 예약 접수 등 사용자가 내용을 보내는 폼은 mailto: 링크나 임의의 <form action> 을 만들지 말고 아래 표준 위젯 코드를 그대로 붙여 넣습니다(사이트 안에서 메일이 발송됩니다). data-* 속성과 <script>, 기본 입력 항목(name, email, phone, subject, message, website, agree)은 한 글자도 바꾸지 말고, 모양은 .wm-contact, .wm-row, .wm-submit 등 클래스에 CSS 만 추가해 꾸밉니다. 기존 폼에 회사명·업종·문의 유형 같은 항목이 더 있었다면 폼 안에 <label>항목명<input name="항목명"></label> 또는 <select name="항목명"> 으로 추가합니다 — name 이 있는 항목은 모두 접수 메일에 함께 포함되므로 기존 항목을 빠뜨리지 않습니다(파일 첨부 항목만 지원하지 않으므로 "이메일로 보내 주세요" 안내 문구로 대체). 예약용은 data-kind="booking" 인 두 번째 코드를 씁니다. 전화·이메일 주소를 안내 문구로 표시하는 것은 되지만 링크는 mailto: 대신 일반 텍스트로 둡니다.
--- 문의 위젯 ---
${contactWidget(site, 'contact', endpointBase, opts.lang)}
${opts.booking ? `
--- 예약 위젯 (data-kind="booking") ---
${contactWidget(site, 'booking', endpointBase, opts.lang)}
` : ''}`;
}
const SYSTEM_PROMPT = `당신은 정적 웹사이트를 처음부터 완성하는 웹 개발 에이전트입니다.
규칙:
- 모든 파일은 현재 작업 디렉터리(사이트 루트) 안에만 작성합니다. 그 밖의 경로에는 절대 쓰지 않습니다.
- 메인 페이지 파일명은 반드시 index.html 입니다. 추가 페이지는 같은 폴더에 *.html 로 만들고, 링크는 상대 경로(예: about.html, ./css/style.css)만 사용합니다. 절대 경로(/로 시작)는 사용하지 않습니다.
- 빌드 도구나 서버 사이드 코드 없이 브라우저에서 바로 열리는 HTML/CSS/JS 로 작성합니다. 필요하면 CDN(Google Fonts, cdnjs 등)은 사용해도 됩니다.
- 반응형(모바일 포함)으로 만들고, 기본 언어는 사용자가 입력한 언어를 따릅니다.
- 사용자가 첨부한 파일은 사이트 루트의 uploads/ 폴더에 있습니다. 첨부 이미지는 <img src="uploads/파일명"> 처럼 상대 경로로 페이지에 사용하고, 첨부 문서(txt, md, pdf, csv 등)는 Read 도구로 내용을 읽어 본문/구성에 반영합니다. 첨부 이미지는 Read 도구로 직접 보고 내용에 맞게 배치·설명합니다. uploads/ 안의 파일은 이동·삭제·덮어쓰기하지 않습니다.
- 문의/예약 접수 폼은 사용자 프롬프트에 주어지는 표준 위젯 코드를 그대로 사용하고, mailto: 링크는 사용하지 않습니다.
- 첨부 이미지가 없는 자리에는 외부 URL 을 임의로 넣지 말고 CSS 그라데이션, 인라인 SVG, 이모지, 플레이스홀더 블록으로 대체합니다.
- 질문하지 말고 합리적으로 가정하여 즉시 완성합니다. 사용자는 대화할 수 없습니다.
- 파일명이 '.' 으로 시작하는 파일은 만들지 않습니다.
- 작업이 끝나면 한국어로 5줄 이내의 짧은 요약(만든/수정한 파일과 핵심 내용)만 답합니다. 코드 전체를 답변에 포함하지 않습니다.`;
/** 언어 코드 → 표시 이름 (주석 언어 안내용). ko 또는 알 수 없으면 한국어 */
function langName(lang) { try { return lang && lang !== 'ko' ? new Intl.DisplayNames([lang], { type: 'language' }).of(lang) : '한국어'; } catch (_) { return '한국어'; } }
/**
* 코드 주석 규칙 (v1.2.0). 생성·수정 프롬프트 "앞"에 붙는다.
* 1차 테스트에서 규칙을 프롬프트 끝(긴 위젯 코드 뒤)에 두었더니 기본 등급 모델이 무시해 주석이 하나도 달리지 않았다.
* 그래서 앞쪽에 "필수 요구"로 두고, 끝에는 commentReminder 로 한 번 더 확인시킨다.
* @param {string} lang 빌더 화면 언어 (site.uiLang). 주석을 이 언어로 쓴다
* @param {boolean} existing 가져온·복사한 사이트면 true — 원래 코드는 건드리지 않고 고친·추가한 부분에만 주석
*/
function commentRule(lang, existing) {
const name = langName(lang); const code = lang && lang !== 'ko' ? ` (${lang})` : '';
const scope = existing
? `원래 파일의 구조와 기존 주석은 그대로 두고, 이번에 수정하거나 추가한 코드마다 바로 앞에 무엇을 왜 바꿨는지 ${name} 주석을 답니다 (HTML <!-- -->, CSS /* */, JS //). 새로 만든 파일은 아래 전체 규칙을 따릅니다.`
: `만들거나 고치는 모든 HTML·CSS·JS 파일에 ${name} 주석을 답니다.`;
return `[필수 요구 — 코드 주석 (주석 언어: ${name}${code})]
${scope}
사이트 주인이 "소스 보기"로 코드를 열었을 때 구조와 동작을 이해할 수 있도록 친절하고 구체적으로 씁니다. 주석이 빠진 결과는 미완성입니다.
- 각 파일 맨 위: 이 파일의 역할, 담고 있는 페이지/섹션, 다른 파일(CSS·JS)과의 관계
- HTML: 주요 구역(헤더·내비게이션·각 섹션·푸터) 시작마다 무엇을 보여 주는 구역인지
- CSS: 색상·글꼴 같은 공통 변수의 의미, 구역별 스타일 묶음, 반응형(@media) 기준과 이유
- JS: 함수마다 목적·매개변수·반환값, 주요 변수의 의미, 이벤트 흐름과 눈에 띄지 않는 로직의 이유
- 표준 위젯·모듈 코드(data-wm-* 가 있는 블록)는 내용을 바꾸지 말고 바깥에 한 줄 설명만 붙입니다.
주석은 설명이지 장식이 아닙니다. 코드와 맞지 않는 주석, 의미 없는 반복 주석은 쓰지 않습니다.
`;
}
/** 프롬프트 끝에 붙이는 한 줄 확인 (commentRule 참고) */
function commentReminder(lang, existing) { return `[마지막 확인] 작업을 끝내기 전에 ${existing ? '이번에 수정·추가한 코드' : '만들거나 고친 모든 파일'}에 ${langName(lang)} 주석을 달았는지 확인하세요.`; }
function field(label, v) {
v = (v || '').trim();
return v ? `[${label}]\n${v}\n` : '';
}
function describe(a) {
const kind = a.kind === 'image' ? '이미지' : '문서';
let s = `- ${a.rel} (${kind}, ${humanSize(a.size)})`;
if (a.kind === 'image') s += ' → Read 로 확인 후 페이지에 <img src="' + a.rel + '"> 로 사용';
else if (a.textSidecar) s += ` → 텍스트 추출본 ${a.textSidecar} 를 Read 로 읽어 내용 반영`;
else s += ' → Read 로 읽어 내용 반영';
return s;
}
/** 첨부 파일 섹션. newOnes: 이번 명령과 함께 올라온 파일, existing: 이전부터 있던 파일 */
function attachmentsSection(newOnes, existing) {
if (!newOnes.length && !existing.length) return '';
const lines = ['[첨부 파일]', '사용자가 업로드한 파일입니다. 아래 안내대로 반드시 활용하세요.'];
if (newOnes.length) { lines.push('이번에 새로 첨부된 파일:'); newOnes.forEach((a) => lines.push(describe(a))); }
if (existing.length) { lines.push('이전에 첨부된 파일(필요 시 계속 사용):'); existing.forEach((a) => lines.push(describe(a))); }
return lines.join('\n') + '\n';
}
function buildCreatePrompt(f, attachments = [], widget = '') {
return [
'다음 요구사항에 맞는 웹사이트를 현재 디렉터리에 새로 작성하세요.\n',
field('페이지 제목', f.title),
field('운영 주체(회사명/이름)', f.ownerName),
field('목적', f.purpose),
field('주요 컨텐츠', f.content),
field('디자인 방향', f.design),
field('추가 요구사항', f.extra),
attachmentsSection(attachments, []),
widget,
'\n메인 페이지는 index.html 이어야 하며, 지금 바로 파일을 작성하세요.',
].join('\n');
}
function basicsSection(site, changed) {
if (!site) return '';
const lines = [
field('페이지 제목', site.title), field('운영 주체(회사명/이름)', site.ownerName), field('목적', site.purpose),
field('주요 컨텐츠', site.content), field('디자인 방향', site.design), field('추가 요구사항', site.extra),
].filter(Boolean).join('\n');
const head = changed && changed.length
? `[홈페이지 기본 사항 — 최근 변경됨: ${changed.join(', ')}]\n사용자가 아래 기본 사항을 수정했습니다. 변경된 항목이 페이지 전반에 반영되도록 함께 수정하세요.`
: '[홈페이지 기본 사항 (참고)]';
return `${head}\n${lines}\n`;
}
/**
* 작업 맥락 섹션 (v1.1.0). 매 작업마다 모델을 바꿀 수 있으므로 세션 기억에 의존하지 않고
* 사이트 기본 정보·설정과 최근 지시 이력을 프롬프트에 다시 넣어 맥락이 이어지게 한다.
* - 명시적으로 바꾸라고 한 것 외에는 이전 결정(디자인·구조·문구·설정)을 유지하라고 지시한다.
*/
function historySection(site, limit = 8) {
if (!site) return '';
// v1.4.14 (요구 7): 중단·캐시 대기로 끝난 작업도 이력에 넣는다 — 그 전까지의 변경은 파일에 남아 있어 모델이 알아야 한다
const cmds = (site.commands || []).filter((c) => ['create', 'update'].includes(c.kind) && ['done', 'paused-credit', 'cancelled'].includes(c.status)).slice(-limit);
const keep = [`사이트 제목: ${site.title || ''}`, site.contactEmail ? `문의 수신 이메일: ${site.contactEmail}` : '', (site.domains || []).length ? `연결 도메인: ${site.domains.join(', ')}` : '', site.plan && site.plan.brief ? `업종: ${site.plan.brief.business.industry.major || ''}${site.plan.brief.business.industry.minor ? '/' + site.plan.brief.business.industry.minor : ''} · 톤: ${site.plan.brief.design.tone || ''} · 언어: ${site.plan.brief.copy.language || 'ko'}` : ''].filter(Boolean);
const hist = cmds.map((c, i) => `${i + 1}. [${c.kind === 'create' ? '생성' : '수정'}${c.status !== 'done' ? ' · ' + (c.status === 'cancelled' ? '중단됨' : '캐시 대기') : ''}] ${String(c.instruction || '').replace(/\s+/g, ' ').slice(0, 160)}${c.resultText ? ` → 결과: ${stripBrief(c.resultText).slice(0, 220)}` : ''}${c.git && c.git.files && c.git.files.length ? ` (바뀐 파일 ${c.git.files.length}개)` : ''}`);
return `[작업 맥락 — 반드시 유지]
이 사이트는 아래 정보와 이전 지시들을 바탕으로 만들어졌습니다. 이번 지시에서 명시적으로 바꾸라고 한 것만 바꾸고, 그 외의 디자인(색·폰트·레이아웃)·페이지 구성·문구·설정은 그대로 유지합니다. 작업 전에 현재 파일(Read/Glob)을 확인해 실제 상태를 기준으로 수정합니다.
${keep.map((k) => '- ' + k).join('\n')}
${hist.length ? '[이전 지시 이력(최근 ' + hist.length + '건)]\n' + hist.join('\n') : '[이전 지시 이력] 없음(첫 수정)'}
`;
}
/** AI 응답에서 <brief> 블록을 빼고 한 줄로 */
function stripBrief(t) { return String(t || '').replace(/<brief>[\s\S]*?<\/brief>/g, '').replace(/\s+/g, ' ').trim(); }
/** 모델 참조 표시용 (anthropic:claude-opus-5-5 → claude-opus-5-5, openai:gpt-6-astra → openai/gpt-6-astra) */
function shortModel(ref) { const r = String(ref || ''); return r.startsWith('anthropic:') ? r.slice(10) : r.replace(':', '/'); }
/**
* 엔진·모델 변경 때 맥락 이어 주기 (v1.4.14, 요구 7).
*
* 작업마다 모델 등급을 바꿀 수 있고, 등급에 묶인 모델은 제공자가 다를 수 있다(Claude ↔ OpenAI/codex).
* - Claude 는 사이트별 세션(--resume)을 이어 가지만 다른 엔진이 한 수정은 그 세션 기억에 없다.
* - OpenAI(codex·API) 엔진은 실행마다 새로 시작해 세션 기억이 없다 — 작업 맥락은 historySection 과 이 섹션으로만 전달된다.
* 그래서 "이번 모델이 이 사이트를 마지막으로 다룬 뒤" 다른 모델이 한 작업(지시·결과 요약·바뀐 파일)을 정리해 넣는다.
* 같은 모델이 연속이면 빈 문자열(이력 섹션과 세션 기억으로 충분).
* @param {object} site
* @param {string} modelRef 이번 작업의 모델 참조 (provider:model)
* @param {number} [limit] 넣을 최대 작업 수 (프롬프트가 지나치게 길어지지 않게)
*/
function engineSwitchSection(site, modelRef, limit = 6) {
const cmds = (site && site.commands || []).filter((c) => ['create', 'update'].includes(c.kind) && ['done', 'paused-credit', 'cancelled'].includes(c.status) && c.model);
if (!cmds.length || !modelRef) return '';
if (cmds[cmds.length - 1].model === modelRef) return '';
let idx = -1; for (let i = cmds.length - 1; i >= 0; i--) if (cmds[i].model === modelRef) { idx = i; break; }
const since = cmds.slice(idx + 1).slice(-limit); if (!since.length) return '';
const others = [...new Set(since.map((c) => shortModel(c.model)))];
const lines = since.map((c) => {
const files = ((c.git && c.git.files) || []); const fl = files.slice(0, 12).map((f) => `${{ A: '추가', M: '수정', D: '삭제', R: '이름변경' }[f.status] || f.status} ${f.path}`).join(', ');
return `- [${c.kind === 'create' ? '생성' : '수정'} #${c.no} · ${shortModel(c.model)}${c.status !== 'done' ? ' · ' + (c.status === 'cancelled' ? '중단됨(일부 반영)' : '캐시 대기(일부 반영)') : ''}]\n 지시: ${String(c.instruction || '').replace(/\s+/g, ' ').slice(0, 300)}${c.resultText ? `\n 결과: ${stripBrief(c.resultText).slice(0, 400)}` : ''}${fl ? `\n 바뀐 파일: ${fl}${files.length > 12 ? ` 외 ${files.length - 12}개` : ''}` : ''}`;
});
return `[엔진 변경 안내 — 맥락 유지]
이번 작업은 ${shortModel(modelRef)} 로 진행합니다. ${idx >= 0 ? '이 모델이 이 사이트를 마지막으로 다룬 뒤' : '지금까지'} 다른 AI 모델(${others.join(', ')})이 아래 작업을 했고, 그 결과는 이미 현재 파일에 반영되어 있습니다.
기억에 없는 변경이라도 되돌리거나 다시 만들지 말고, 반드시 현재 파일(Read/Glob)을 기준으로 아래 작업 위에 이어서 수정합니다. 디자인·구조·문구는 이전 작업들의 결정을 그대로 존중합니다.
${lines.join('\n')}
`;
}
function importSection(site) {
if (!site || (site.mode !== 'import' && site.mode !== 'copy')) return '';
const src = site.source || { url: site.copyFrom };
const dyn = site.importInfo && site.importInfo.dynamic;
return `[운영 중인 기존 사이트]
이 디렉터리는 실제로 운영 중인 사이트(${src.url || src.host || ''})의 파일을 ${site.mode === 'copy' ? '웹에서 복사해 온 것입니다(서버 코드는 없고 화면 결과만 있음). 수정 결과는 발행 시 빌더의 배포 서버로 올라갑니다.' : '그대로 가져온 것입니다. 수정 결과는 원본 서버에 다시 반영됩니다.'}
- 기존 구조·디자인·파일 구성과 링크 경로를 최대한 유지하고, 지시된 부분만 최소한으로 고칩니다. 지시와 무관한 파일은 건드리지 않습니다.
- 기존 파일을 새 파일로 통째로 바꾸지 말고, 해당 부분만 Edit 로 수정합니다. 파일 이름·경로를 바꾸거나 삭제하지 않습니다(요청된 경우 제외).
- 인코딩·줄바꿈·들여쓰기 등 원본 스타일을 따르고, 원본에 있는 분석 코드·외부 스크립트·메타 태그는 유지합니다.${dyn ? `
- 서버 스크립트(PHP 등)가 있는 사이트입니다. 템플릿 안의 HTML/CSS/JS 만 수정하고 서버 로직은 요청이 없으면 바꾸지 않습니다. 새 정적 파일을 만들 때도 기존 방식과 어울리게 합니다.` : ''}
`;
}
function buildUpdatePrompt(instruction, newOnes = [], existing = [], site = null, basicsChanged = [], widget = '') {
return [
(site && (site.mode === 'import' || site.mode === 'copy')) ? '현재 디렉터리의 웹사이트(운영 중인 기존 사이트를 가져오거나 복사한 것)에 다음 수정 지시를 반영하세요. 필요한 파일만 직접 수정하세요.\n' : '현재 디렉터리의 웹사이트에 다음 수정 지시를 반영하세요. 기존 파일을 직접 수정하거나 필요한 파일을 추가하세요.\n',
importSection(site),
historySection(site),
`[수정 지시]\n${instruction.trim()}\n`,
basicsSection(site, basicsChanged),
attachmentsSection(newOnes, existing),
widget,
].join('\n');
}
/** Brief 기반 생성 프롬프트 (AI에게 맡기기 / 내가 고르기 공통) */
function buildBriefPrompt(brief, attachments = [], widget = '') {
const files = brief.pages.map((p, i) => `${i === 0 ? 'index.html' : p.id + '.html'} ← ${p.title} [${p.sections.map((s) => s.id).join(', ')}]`).join('\n');
// v1.0.3: 사용자가 입력한 값만 쓰고, 값이 없는 항목(removed/missing)은 페이지에서 항목 자체를 뺀다 ("[입력 필요]" 자리도 두지 않음)
const facts = Object.entries(brief.facts || {}).map(([k, v]) => `- ${k}: ${v.status === 'provided' && v.value ? String(v.value) : '(항목 삭제 — 이 정보는 페이지 어디에도 표시하지 않고 관련 문구·칸·메뉴도 두지 않음)'}`).join('\n');
const fontSet = brief.design.fonts || {};
return `아래 Site Brief 만을 근거로 정적 웹사이트를 현재 디렉터리에 작성하세요. Brief 에 없는 내용을 지어내지 않습니다.
[Site Brief]
${JSON.stringify(brief, null, 1)}
[페이지 → 파일] (이 파일들을 모두 만들고, 공통 내비게이션으로 서로 연결)
${files}
[사실정보 사용 규칙]
${facts}
사실정보는 위 값만 그대로 사용합니다. 값이 없는(삭제된) 항목은 아예 표시하지 않고 관련 섹션·문구도 넣지 않습니다. 절대 예시 번호·주소·가격·후기를 만들지 않습니다. 후기 섹션은 실제 후기가 있을 때만 만들고, 없으면 후기 섹션 자체를 두지 않습니다.
${brief.regulated && brief.regulated.flag ? `[규제 제약] ${brief.regulated.constraints.join(' / ')}\n` : ''}
[작성 순서] 1) 골격: 모든 페이지 파일 + 공통 헤더/내비/푸터 + 섹션 뼈대 2) 콘텐츠: 섹션별 문구(헤드라인·본문은 Brief 어조(${brief.copy.voice}, ${brief.copy.length}, 언어 ${brief.copy.language})로 창작 가능) 3) 스타일: css/style.css 하나로 톤 "${brief.design.tone}", 팔레트 ${JSON.stringify((brief.design.palette || {}).colors || [])} (순서: 본문 텍스트, 보조, 배경, 포인트), 폰트 제목 "${fontSet.heading || 'Noto Sans KR'}" 본문 "${fontSet.body || 'Noto Sans KR'}"${fontSet.google ? ` (Google Fonts: ${fontSet.google})` : ''}, 밀도 ${brief.design.density}, 이미지 유형 ${brief.design.imagery}.
[이미지] 첨부 로고/사진(${(brief.assets && brief.assets.logo) || '없음'}; ${((brief.assets && brief.assets.photos) || []).join(', ') || '사진 없음'})만 <img> 로 사용. 그 외 자리는 ${brief.assets && brief.assets.useStock ? '스톡 이미지 대신 ' : ''}CSS 그라데이션·인라인 SVG·플레이스홀더 블록으로 채웁니다.
[CTA] 목표(${brief.goals.join(', ')})에 맞는 CTA 버튼을 히어로와 페이지 하단에 배치합니다.
${attachments}
${widget}
완료 후 한국어 5줄 이내 요약만 답합니다.`;
}
module.exports = { commentRule, commentReminder, SYSTEM_PROMPT, buildCreatePrompt, buildUpdatePrompt, buildBriefPrompt, attachmentsSection, contactWidget, widgetSection, historySection, engineSwitchSection, shortModel };