JSX 제작 매뉴얼

🧩 JSX 제작 매뉴얼

JSX 는 채팅 답변 안에 그려지는 작은 화면입니다. 호감도 막대, 선택지 버튼, 상태 패널처럼 제작자가 HTML·JS·React 로 만들고, 캐릭터가 답변 끝에 한 줄로 불러 씁니다.

기능과 함수 하나하나를 개념 → 사용법 → 예시 → 제한 순서로 적었습니다. 왼쪽 검색 칸에 함수 이름이나 하고 싶은 일(예: setValue, 확인창, 높이)을 넣으면 그 항목만 남습니다.

맞는 항목이 없어요. 다른 말로 찾아보세요. 예: 보내기, 저장값, 높이, React, 차단

JSX 란 — 개념과 동작 흐름

JSX 는 에셋의 한 종류입니다. 제작자가 코드를 등록해 두면, 캐릭터가 답변 안에 호출 태그 한 줄을 쓰고, 화면이 그 자리에 격리된 작은 창을 그립니다. 창 안의 코드는 LT 라는 다리를 통해서만 채팅과 이야기합니다.

용어
에셋제작자가 등록하는 코드 묶음. 고유 ID 로 불립니다. 유형은 그림(SVG)·JSX·Worker 셋입니다.
호출 태그답변 안에 쓰는 <lunatalk_asset id="…" … /> 한 줄. 어떤 에셋을 어떤 값으로 그릴지 정합니다.
격리 창태그 자리에 그려지는 iframe. 채팅 화면과 출처가 달라 쿠키·저장소·화면에 손댈 수 없습니다.
다리 LT격리 창 안에 준비된 전역 객체. 메시지 보내기·재생성·문맥 읽기·값 저장·높이 맞춤을 이 함수들로만 합니다.
방 저장값채팅방 하나마다 따로 있는 작은 저장소. JSX 가 LT.setValue() 로 쓰고, 다음 턴부터 {{{키}}} 가 그 값으로 바뀝니다.
숨은 데이터모델이 답변에 쓰지만 화면에는 안 보이는 {{hidden:키=값}}. JSX 가 읽어 저장값으로 옮기는 통로입니다.
한 턴의 흐름
  1. 제작자가 에셋 편집기에서 JSX 를 등록하고 고유 ID 를 정합니다. (예: panel)
  2. 캐릭터 프롬프트에 "답변 끝에 이 줄을 쓴다"고 적어 둡니다: <lunatalk_asset id="panel" af="{{{AF}}}" />
  3. 캐릭터가 답변 끝에 그 줄을 씁니다. 답변이 다 끝나면 화면이 그 자리에 격리 창을 그립니다. 태그에 적힌 값은 창 안에서 LT.vars.af 로 읽힙니다.
  4. 창 안의 버튼을 누르면 LT.sendToAI() 가 이용자 메시지를 보냅니다. 확인창이 뜨고, 확인하면 한 턴이 나갑니다.
  5. 창이 LT.setValue("AF", 72) 로 값을 저장하면, 다음 턴부터 프롬프트와 태그의 {{{AF}}} 가 72 로 바뀝니다.
종전 SVG 에셋과 무엇이 다른가
구분그림(SVG) 에셋JSX
내용SVG 그림(+스크립트)HTML·SVG·JS 화면. SVG 그림도 그대로 됨
React없음React 18 포함. <Panel /> 같은 JSX 문법 자동 변환
호출 태그<lunatalk_asset …/>같음
채팅에 작용없음(보여주기만)메시지 보내기·재생성·값 저장·직전 메시지 읽기
외부 접속그림 허용통신 전부 차단. 그림·소리·글꼴만 허용 목록 호스트
크기태그의 w/h 또는 비율같음 + 창이 스스로 높이를 맞출 수 있음

이미 등록한 SVG 에셋은 그대로 동작합니다. 편집기에서 열면 JSX 로 보이고, 저장하면 JSX 가 됩니다.

관련
5분 시작호출 태그LT 함수 한눈에방 저장값

누가 쓸 수 있나 — 권한·승인

JSX 는 단계적으로 열립니다. 지금 상태와, 등록한 에셋이 남에게 보이기까지의 절차입니다.

개념
편집기 입장로그인한 회원 누구나 에셋 편집기를 열 수 있어요. 제작자 인증은 필요 없습니다.
JSX 유형 저장로그인한 회원 누구나 JSX 유형으로 저장됩니다.
JSX 실행모든 회원의 채팅에서 그려집니다.
지금 단계로그인한 회원 전체에 열려 있습니다.
승인 절차
  • 등록하면 자동 안전 검사가 돌고, 통과하면 바로 승인돼요. 문구: "에셋이 등록되었습니다. 안전 검사를 통과해 바로 사용할 수 있습니다."
  • 사칭 문구(로그인·비밀번호 등)·0.3초 미만 깜빡임·난독화·eval 같은 항목이 걸리면 승인 대기로 들어가 운영진이 본 뒤 승인해요. 문구: "에셋이 등록되었습니다. 관리자 승인 후 공개됩니다." Worker 유형은 외부 통신을 하므로 항상 운영진 확인을 거칩니다.
  • 대기 중인 에셋은 본인에게만 그려져요. 다른 회원의 채팅에서는 빈 자리로 남습니다.
  • 제작자가 공개로 둔 에셋은 고유 ID 로 누구나 부를 수 있어요. 비공개 에셋(새 에셋의 기본)은 그 제작자의 캐릭터 방에서만 작동합니다.
  • 승인된 에셋을 고쳐도 검사를 통과하면 승인이 유지돼요("수정되었습니다. 안전 검사를 통과해 바로 반영됩니다."). 걸리면 승인 대기로 돌아갑니다("수정되었습니다. 재심사가 필요합니다."). 반려된 에셋은 고친 뒤 [재심사 요청]을 눌러야 해요.
  • 로그인하지 않은 상태에서는 어떤 에셋도 그려지지 않아요.
관련
등록하기안전 검사문제 해결

등록하기 — 에셋 편집기 입력 칸 전부

에셋 편집기에서 타입 JSX · 그림·상호작용을 고르고 코드를 넣어 등록합니다. 칸마다 규칙이 있습니다.

위치마이페이지 › 에셋 › 만들기필요로그인비용없음
입력 칸
칸규칙
에셋 이름 *1~100자. 목록에 보이는 이름.
고유 ID *3~50자, 영문 소문자·숫자·-·_ 만. 숫자만으로는 불가(번호와 충돌). 예약어 불가: id w h width height src style class _t idx key. [중복 확인] 버튼으로 미리 확인. 호출 태그의 id 에 이 값을 씁니다.
짧은 설명255자 이내. 카드·목록에 표시.
상세 설명20,000자까지. 상세 페이지에 표시.
사용 예시 (변수 조합)최대 10줄. 호출 태그 예를 적어 두면 다른 제작자가 보고 씁니다.
공개 범위새 에셋은 비공개로 시작합니다. 공개는 갤러리에 올라가고 누구든 자기 캐릭터에서 부를 수 있습니다. 비공개 · 내 캐릭터 전용은 갤러리·상세에 보이지 않고, 내가 만든 캐릭터의 채팅방에서만 작동합니다. 다른 제작자의 캐릭터가 부르면 "제작자 전용 에셋입니다" 안내만 나가고, 캐릭터를 복제해 가도 복제본에서는 작동하지 않습니다. 안전 검사와 승인은 공개와 같습니다.
코드HTML·SVG·<script> 그대로. 순수 JS 도, React 코드도 됩니다. 500KB 이하.
오른쪽 도구
  • 실시간 미리보기 — 코드를 격리 창에 그려 보여요. 크게 보기, 미리보기 갱신 버튼이 있어요.
  • 🧪 변수 테스트 — 태그 속성값을 넣어 LT.vars 가 채워진 상태를 봅니다.
  • 🛡 안전 검사 — 저장 전에 검사 결과(차단·확인·안내)를 봅니다. 저장할 때도 자동으로 다시 돕니다.
미리보기의 한계

미리보기에서는 LT 가 가짜로 들어갑니다. 화면은 그려지지만 sendToAI·reroll·setValue 는 {ok:false, error:"preview"} 를 돌려주고, getContext 는 빈 값, resize 는 아무것도 하지 않습니다. 실제 동작은 채팅방에서 확인하세요.

저장할 때
  • 자동 안전 검사가 돕니다. 차단 항목이 하나라도 있으면 저장되지 않아요. 문구: "안전 검사에서 막힌 항목이 있어 저장할 수 없습니다." + 항목 목록.
  • 확인 항목 중 사칭 문구·깜빡임·난독화·eval 이 있으면 승인 대기로 들어가 운영진이 본 뒤 승인합니다. 그 밖의 확인 항목(스크립트 있음 등)만 있으면 바로 승인됩니다.
  • 승인된 것을 고쳐도 같은 규칙입니다. 깨끗하면 승인 유지, 걸리면 승인 대기.
⚠ 캐릭터에 이미 쓰고 있는 JSX 를 고칠 때는 태그 속성 이름이 바뀌지 않게 하세요. 이미 쓰인 답변의 태그는 그대로 남아 있어, 이름이 바뀌면 옛 답변의 창이 빈 값으로 그려집니다.
관련
5분 시작안전 검사 항목 전부호출 태그

5분 시작 — 첫 JSX 를 채팅에 띄우기

가장 작은 JSX 를 등록하고, 프롬프트 한 줄로 불러, 채팅방에서 확인하는 순서입니다.

순서
  1. 편집기에서 타입 JSX 선택, 고유 ID 에 hello, 코드에 아래를 붙여 넣고 등록합니다.
  2. 캐릭터 편집 › 프롬프트 어딘가에 한 줄을 적습니다: 매 답변 맨 끝에 아래 한 줄을 그대로 쓴다: <lunatalk_asset id="hello" name="유나" />
  3. 그 캐릭터와 채팅을 시작합니다. 답변이 끝나면 말풍선 끝에 "안녕, 유나!" 상자가 그려집니다.
  4. 그려지지 않으면 문제 해결의 첫 줄(고유 ID 철자·승인 상태·따옴표)을 보세요.
예시
코드 — 고유 ID hello
<div id="box" style="font:13px/1.5 sans-serif;color:#222;background:#fff;border:1px solid #ddd;border-radius:10px;padding:10px 12px;">
  안녕, <b id="who"></b>!
</div>
<script>
document.getElementById('who').textContent = LT.vars.name || '이름 없음';
LT.resize(document.getElementById('box').offsetHeight + 4);
</script>
호출 <lunatalk_asset id="hello" name="유나" /> → 상자에 안녕, 유나! 가 그려지고, 높이는 내용에 맞춰집니다.
다음 단계
버튼으로 메시지 보내기값 저장해서 다음 턴에 쓰기프롬프트에 호출 지시 쓰기샘플 4개

<lunatalk_asset> — 호출 태그 사양

답변 안에 쓰는 한 줄입니다. 어떤 에셋을, 어떤 값으로, 어떤 크기로 그릴지 정합니다.

<lunatalk_asset id="고유ID" 이름1="값" 이름2="값" [w="320" h="200" | ratio="16/9"] />
규칙
항목규칙
id필수. 에셋의 고유 ID 또는 번호(idx). 영문·숫자·-·_ 1~50자, 대소문자 구분 없음(소문자로 맞춰집니다).
속성 이름영문자나 _ 로 시작, 그 뒤 영문·숫자·_·-. 대소문자는 보존되지만 LT.vars 조회는 대소문자 무관.
속성 값큰따옴표나 작은따옴표 안. 500자에서 잘립니다. 값 안에 같은 종류의 따옴표는 쓸 수 없어요. 여러 항목은 | 같은 구분자를 정해 한 값에 넣습니다.
예약 속성id w h width height ratio — 크기·식별에 쓰이고 LT.vars 로는 넘어가지 않아요. 서버는 idx key _t _r 도 뺍니다.
고정 크기w·h(또는 width·height) 둘 다 적으면 px 고정. 각각 16~1,200 으로 잘립니다.
비율 크기크기를 안 적으면 폭 100% 에 ratio 비율로 시작합니다. ratio="16:9" 또는 "16/9", 기본 1/1. 창이 열리면 내용 높이에 맞춰 자동으로 바뀝니다. 에셋이 스스로 비율을 알려 오면(비율 알리기) 그 비율로 잡힙니다.
치환값 안의 {{{키}}} 는 화면이 그릴 때 방 저장값으로 바뀝니다. 저장값이 없으면 빈 글자. (치환)
개수·자리한 답변에 여러 개 가능, 각각 따로 그려집니다. 답변 어디에나 쓸 수 있지만 보통 맨 끝. 첫 메시지(인사말)에도 됩니다.
스트리밍답변이 생성되는 동안에는 자리만 잡히고, 답변이 끝난 뒤 그려집니다.
다시 그리기말풍선이 다시 그려져도 창은 새로 불러오지 않습니다(화면 바깥에 두고 자리만 연결). 턴이 바뀌면 새 창입니다.
예시
값 세 개를 넘기는 상태창 + 고정 크기 지도
<lunatalk_asset id="panel" af="{{{AF}}}" mood="차분" items="말을 건다|조용히 본다|자리를 뜬다" />
<lunatalk_asset id="map" x="12" y="40" w="320" h="200" />
첫 줄 → LT.vars.af = 저장값 AF(없으면 ""), LT.vars.mood = "차분", LT.vars.items = "말을 건다|조용히 본다|자리를 뜬다". 둘째 줄 → 320×200 고정, LT.vars.x = "12".
제한·주의
⚠ 값에 JSON 을 넣고 싶으면 저장값을 거치세요. values="{{{AF}}}" 처럼 쓰고 AF 에 JSON 문자열을 저장해 두면, 치환은 태그를 해석한 뒤에 일어나므로 따옴표가 태그를 깨지 않습니다. 모델에게 JSON 을 태그 값에 직접 쓰게 하면 따옴표 때문에 자주 깨집니다.
  • 보이는 조건: 승인된 에셋이거나, 본인이 보는 자기 승인 대기 에셋. 그 밖은 빈 자리.
  • 모델이 속성 이름을 바꾸거나 따옴표를 빼면 그려지지 않아요. 프롬프트에 "그대로 쓴다"고 명시하세요(호출 지시).
관련
LT.vars{{{키}}} 치환LT.resize크기·화면 가이드

LT 함수 한눈에 — 결과 형식과 오류 코드

격리 창 안에는 전역 객체 LT 가 준비되어 있습니다. 값을 돌려주는 함수는 전부 Promise 라서 .then() 으로 받습니다.

함수 목록
함수하는 일돌려주는 것
LT.vars호출 태그 속성값 읽기객체 (Promise 아님)
LT.sendToAI(text)이용자 메시지로 한 턴 보내기 (확인창){ok}
LT.reroll(guidance)마지막 답변 재생성 (확인창){ok}
LT.getContext()이 답변·직전 메시지·저장값 전체 읽기{ok, current, prevUser, prevChar, values}
LT.getCurrentMessage()이 답변 원문만문자열
LT.getPrevUserMessage()직전 이용자 메시지만문자열
LT.getPrevCharMessage()하나 전 캐릭터 답변만문자열
LT.getValues()방 저장값 전체만객체
LT.setValue(key, value)방 저장값 쓰기{ok}
LT.resize(height)창 높이 직접 지정 — 보통 필요 없음(자동)없음
LT.autosize(on)자동 높이 켜기/끄기 (기본 켜짐)없음
LT.hidden(text)글 속 {{hidden:…}} 를 객체로객체 (Promise 아님)
결과 형식
{ ok: true, …결과 }            // 성공
{ ok: false, error: "코드" }   // 실패 — 코드는 아래 표
error뜻대처
cancelled이용자가 확인창에서 취소함조용히 넘기기
busy다른 확인창이 떠 있거나, 답변 생성 중이거나, 직전 요청 뒤 3초가 안 지남"잠시 뒤 다시" 안내
empty보낼 글이 비어 있음글을 채워서 다시
no_target재생성할 마지막 답변을 못 찾음드묾. 새로 고침 뒤 다시
bad_key / too_long저장 키 형식 오류 / 값 2,000자 초과키·값 규칙(방 저장값) 확인
(서버 거절 문구) "방 하나의 저장값 합계는 10,000자까지입니다."이 방의 값을 다 합쳐 10,000자를 넘김안 쓰는 키를 비우거나 값을 줄이세요. 합계 규칙은 방 저장값
network / fail저장 요청이 서버에 닿지 못함 / 서버가 거절함다시 시도. 서버 거절 문구는 error 에 그대로 옴
timeout부모 창이 60초(보내기·재생성은 120초) 안에 답하지 않음새로 고침
no_parent채팅방 밖(직접 연 창 등)에서 실행됨채팅방에서 확인
preview편집기 미리보기의 가짜 다리채팅방에서 확인
공통 규칙
  • 요청은 채팅 화면이 만든 격리 창에서 온 것만 받습니다. 주소창에 에셋 주소를 직접 열면 보기 페이지로 옮겨지고 LT 는 동작하지 않아요.
  • 창은 답변마다 새로 만들어지므로 창 안의 변수는 턴을 넘겨 남지 않아요. 남길 값은 LT.setValue() 로 저장합니다.
  • 같은 답변 안에 같은 JSX 가 여러 번 있으면 각각 따로 돕니다. 서로 직접 통신할 수 없고 저장값으로만 주고받습니다.

LT.vars — 호출 태그 속성값 읽기

LT.vars.이름 → string | undefined (Promise 아님)
개념

호출 태그에 적힌 속성값이 들어 있는 객체입니다. 전부 문자열이라 숫자는 parseInt, JSON 은 JSON.parse 로 바꿔 씁니다. 이름은 대소문자 무관(LT.vars.af 와 LT.vars.AF 는 같음). 없는 이름은 undefined.

사용법
var af = parseInt(LT.vars.af, 10); if (isNaN(af)) af = 0;          // 숫자
var items = String(LT.vars.items || '').split('|');                 // 목록
var data = {}; try { data = JSON.parse(LT.vars.values || '{}'); } catch (e) {}   // JSON (저장값을 거쳐 받은 경우)
예시
태그 → 값
<lunatalk_asset id="panel" af="72" Mood="차분" items="a|b" w="320" h="200" />
LT.vars.af = "72" · LT.vars.mood = "차분" (대소문자 무관) · LT.vars.items = "a|b" · LT.vars.w = undefined (예약 속성)
제한
  • 값은 500자에서 잘려 들어옵니다. 긴 자료는 저장값(setValue)에 두고 키 이름만 넘기세요.
  • 첫 턴처럼 저장값이 아직 없으면 {{{키}}} 자리는 빈 글자("")가 옵니다. 빈 값일 때의 기본값을 꼭 두세요.
  • 같은 객체를 window.ASSET_VARS 로도 읽을 수 있고, 대소문자를 보존한 원본은 window.__ASSET_VARS_RAW 입니다.
관련
호출 태그 사양{{{키}}} 치환LT.setValue

LT.sendToAI(text) — 이용자 메시지로 한 턴 보내기 한 턴 요금

LT.sendToAI(text: string) → Promise<{ok:true} | {ok:false, error}>
개념

text 를 이용자가 직접 쓴 메시지로 보냅니다. 부모 창이 확인창을 띄우고(JSX 가 끌 수 없음), 확인하면 입력창에 글이 들어가고 전송됩니다. 그 뒤는 보통 턴과 똑같이 처리되고 요금도 같습니다.

확인창 문구: JSX가 아래 메시지를 보내려고 합니다. 보내면 한 턴으로 처리됩니다. + 보낼 글.

사용법
btn.onclick = function () {
  LT.sendToAI('말을 건다').then(function (r) {
    if (r.ok) note.textContent = '보냈습니다.';
    else if (r.error === 'cancelled') note.textContent = '취소했습니다.';
    else if (r.error === 'busy') note.textContent = '지금은 답변 중입니다. 잠시 뒤 다시.';
    else note.textContent = '보내지 못했습니다: ' + r.error;
  });
};
예시
선택지 버튼 3개 → 누른 글이 그대로 메시지로
['도망친다','맞선다','말을 건다'].forEach(function (label) {
  var b = document.createElement('button'); b.textContent = label;
  b.onclick = function () { LT.sendToAI(label); };
  list.appendChild(b);
});
"맞선다"를 누르면 확인창 → 확인 → 이용자 말풍선에 맞선다 가 올라가고 캐릭터가 답합니다. 답변 안에서 다시 태그가 호출되면 새 창이 뜹니다.
제한
  • 글은 앞뒤 공백을 지우고 2,000자에서 잘립니다. 비어 있으면 empty.
  • 다른 확인창이 떠 있거나, 답변 생성 중이거나, 직전 보내기·재생성 뒤 3초가 안 지났으면 busy. 한 번에 하나만 보낼 수 있어요.
  • ok:true 는 "전송을 시작했다"는 뜻입니다. 답변 내용은 돌려주지 않아요. 답변은 새 말풍선에 나옵니다.
  • 120초 안에 확인창 응답이 없으면 timeout.
  • 이용자가 버튼을 눌렀을 때만 부르세요. 창이 열리자마자 자동으로 부르면 확인창이 뜨는데 이용자는 영문을 모릅니다.
관련
LT.reroll오류 코드 표샘플: 선택지 버튼

LT.reroll(guidance) — 마지막 답변 다시 만들기 재생성 요금

LT.reroll(guidance?: string) → Promise<{ok:true} | {ok:false, error}>
개념

채팅의 맨 마지막 캐릭터 답변을 다시 만듭니다. guidance 를 주면 "이렇게 다시 써라"는 지시로 함께 전달됩니다. 확인창이 뜨고, 확인하면 보통 재생성과 같은 경로·요금으로 처리됩니다.

확인창 문구: JSX가 마지막 답변을 다시 만들려고 합니다. 재생성 한 턴으로 처리됩니다. 지시가 있으면 그 아래 지시: … 가 붙습니다.

사용법
LT.reroll('더 짧게, 대사 위주로').then(function (r) {
  if (!r.ok && r.error !== 'cancelled') note.textContent = '다시 만들지 못했습니다: ' + r.error;
});
예시
"분위기 바꾸기" 버튼
document.getElementById('lighter').onclick = function () { LT.reroll('같은 장면을 더 밝은 분위기로'); };
확인 → 마지막 답변(보통 이 창이 들어 있는 답변)이 지시대로 다시 생성되고, 옛 창은 새 답변의 창으로 바뀝니다.
제한
  • 대상은 항상 화면의 마지막 캐릭터 답변입니다. 이 창이 더 오래된 답변 안에 있어도 마지막 답변이 재생성됩니다. 못 찾으면 no_target.
  • 지시문은 2,000자에서 잘립니다. 그 밖의 제한(busy, 3초 간격, 120초 확인 대기)은 sendToAI 와 같습니다.
  • 재생성되면 그 답변 안의 창은 사라지고 새 답변의 창이 그려집니다. 창 안 변수는 남지 않아요.
관련
LT.sendToAI오류 코드 표

LT.getContext() — 이 답변·직전 메시지·저장값 읽기

LT.getContext() → Promise<{ok:true, current, prevUser, prevChar, values}> LT.getCurrentMessage() → Promise<string> LT.getPrevUserMessage() → Promise<string> LT.getPrevCharMessage() → Promise<string> LT.getValues() → Promise<object>
개념
current이 창이 들어 있는 답변의 원문 전체. 화면 표시용 가공 전이라 {{hidden:…}} 가 들어 있습니다.
prevUser그 답변 바로 앞의 이용자 메시지.
prevChar그 앞의 캐릭터 답변(이 답변 말고 하나 전).
values이 방의 저장값 전체 {키: 값}.

줄임 함수 네 개는 각각 한 필드만 돌려줍니다.

사용법
LT.getContext().then(function (c) {
  if (!c.ok) return;
  var h = LT.hidden(c.current);          // 이 답변의 숨은 데이터
  var said = c.prevUser;                 // 이용자가 방금 한 말
  var saved = c.values.AF;               // 저장값
});
예시
이용자가 방금 한 말에 "고마워"가 있으면 하트 표시
LT.getPrevUserMessage().then(function (t) {
  if (/고마워|감사/.test(t || '')) document.getElementById('heart').style.display = 'block';
});
직전 이용자 말풍선이 "고마워, 정말." 이면 → 하트가 보입니다. 화면에 그 말풍선이 없으면(아직 안 불러온 옛 대화) → 빈 글자라 보이지 않습니다.
제한
  • 화면에 없는 오래된 메시지는 빈 글자가 됩니다. 이전 대화를 더 불러온 뒤에는 읽힙니다.
  • values 는 페이지를 열 때 받은 값에 이 탭에서 setValue 한 것을 더한 것입니다. 다른 탭·기기에서 바꾼 값은 새로 고침해야 보입니다.
  • 읽기만 합니다. 메시지를 고치는 함수는 없어요.
관련
LT.hidden숨은 데이터 규격방 저장값

LT.setValue(key, value) · LT.getValues() — 방 저장값 쓰기·읽기

LT.setValue(key: string, value: string | object) → Promise<{ok:true} | {ok:false, error}> LT.getValues() → Promise<{키: 값}>
개념

이 방의 저장값에 씁니다. 문자열이 아닌 값(숫자·객체)은 JSON.stringify 로 문자열이 됩니다. 저장되면 다음 턴부터 프롬프트와 태그의 {{{key}}} 가 이 값으로 치환됩니다. 저장소 규칙은 방 저장값.

사용법
LT.setValue('AF', 72);                       // "72"
LT.setValue('STATS', { hp: 80, mp: 20 });   // '{"hp":80,"mp":20}'
LT.setValue('AF', next).then(function (r) {
  note.textContent = r.ok ? '저장됨' : '저장 실패: ' + r.error;
});
예시
+5 버튼 → 저장 → 다음 턴
plus.onclick = function () {
  af = Math.min(100, af + 5); show(af);
  LT.setValue('AF', String(af));
};
저장 직후 이 탭의 values.AF 가 바뀌고, 다음 턴에 서버가 프롬프트의 {{{AF}}} 를 새 값으로 바꿔 모델에게 보냅니다. 이미 그려진 답변의 태그 값은 바뀌지 않아요.
제한
  • 키: 영문·숫자·_ 1~40자, 대소문자 구분. 아니면 bad_key.
  • 값: 2,000자까지. 넘으면 too_long. 방 하나에 키 50개까지(서버 거절 문구: "방 하나에 키는 50개까지입니다."). 방 하나의 값을 다 합쳐 10,000자까지 — 넘으면 서버가 거절하고 error 에 "방 하나의 저장값 합계는 10,000자까지입니다." 문구가 옵니다. 프롬프트의 {{{키}}} 치환도 한 턴에 합계 10,000자까지만 꽂힙니다.
  • 서버에 닿지 못하면 network. JSX 가 열리지 않은 계정이면 서버가 거절하고 error 에 안내 문구가 옵니다.
  • 모델은 직접 저장할 수 없습니다. 모델은 숨은 데이터로 값을 넘기고 JSX 가 저장합니다.
관련
방 저장값 규칙{{{키}}} 치환LT.hidden

LT.resize(height) · LT.autosize(on) — 창 높이 (자동)

LT.resize(height: number) → void LT.autosize(on: boolean) → void (응답 없음)
개념

창 높이는 자동입니다. 창 안의 내용 크기가 바뀔 때마다(목록이 늘거나, React 상태가 바뀌거나, 그림·글꼴이 늦게 로드되거나) 본문 높이를 다시 재서 창을 맞춥니다. 16~1,200px, 1px 차이는 무시, 0.06초 단위로 묶어 보냅니다. 폭은 말풍선 폭을 따릅니다.

LT.resize(h) 는 직접 높이를 지정하고 싶을 때만 씁니다. 자동이 켜져 있으면 내용이 바뀔 때 다시 측정값으로 돌아오므로, 고정하려면 먼저 LT.autosize(false) 로 자동을 끄세요.

사용법
// 보통은 아무것도 안 해도 됩니다.
// 높이를 직접 고정하고 싶을 때:
LT.autosize(false);
LT.resize(240);
// 다시 자동으로(멈춘 상태도 풀림):
LT.autosize(true);
예시
목록을 펼쳤다 접는 버튼
toggle.onclick = function () { more.hidden = !more.hidden; };
펼치면 창이 커지고 접으면 작아집니다. 따로 부를 함수가 없습니다.
비율 알리기 — 가로세로가 정해진 그림(폰 화면 목업 등)

내용 높이를 재는 자동 맞춤 대신 비율을 직접 알릴 수 있습니다. 아래 한 줄을 보내면 창이 폭 100% 에 그 비율로 잡히고, 자동 높이는 이미 맞은 높이를 건드리지 않습니다. 편집기 미리보기·갤러리·채팅 모두 같은 메시지를 받습니다. 태그에 w·h 를 둘 다 적은 창에서는 무시됩니다.

parent.postMessage({ type: 'lt_asset_size', w: 380, h: 760 }, '*');

그림이 그려진 뒤(load)에 한 번 더 보내면 안전합니다. 비율만 정하는 것이라 크기(px)는 말풍선 폭에 따라 달라집니다.

제한
  • 높이가 창 높이에 비례해 커지는 코드(예: 최상위 요소에 height:100vh + 여백)는 끝없이 자랄 수 있어, 같은 폭으로 짧은 시간에 3번 연속 커지거나 두 값을 번갈아 오가면 자동 맞춤이 멈춥니다. 최상위 요소 높이는 내용에 맡기세요. LT.autosize(true) 를 부르면 다시 켜집니다.
  • 1,200 보다 크게는 안 됩니다. 긴 목록은 안쪽 요소에 max-height; overflow:auto 를 주세요.
  • 태그에 w·h 둘 다 적은 고정 크기 창도 높이는 자동으로 바뀝니다. 폭은 못 바꿉니다.
  • 편집기 미리보기도 같은 규칙으로 늘고 줄어, 채팅과 같은 모양으로 보입니다.
관련
크기·화면 가이드호출 태그의 크기 속성

LT.hidden(text) — 글 속 숨은 데이터를 객체로

LT.hidden(text: string) → {키: 값} (Promise 아님)
개념

글 안의 {{hidden:키=값;키2=값2}} 를 전부 찾아 하나의 객체로 돌려줍니다. 보통 getContext().current 와 함께 씁니다. 규격은 숨은 데이터.

사용법
LT.getContext().then(function (c) {
  var h = LT.hidden(c.current);   // 예: { AF: "72", MOOD: "불안" }
  if (h.AF) LT.setValue('AF', h.AF);
});
예시
입력 → 결과
LT.hidden('그녀가 웃었다. {{hidden:AF=72;MOOD=기쁨}} … {{hidden:FLAG=met}}')
→ { AF: "72", MOOD: "기쁨", FLAG: "met" }. 같은 키가 두 번 나오면 뒤의 값이 남습니다.
제한
  • 값은 전부 문자열입니다. 숫자는 parseInt 로 바꾸세요.
  • 값 안의 ; 와 }} 는 구분자로 읽혀 끊깁니다.
관련
숨은 데이터 규격LT.getContextLT.setValue

방 저장값 — 턴을 넘겨 남는 작은 저장소

채팅방 하나, 회원 하나마다 따로 있는 {키: 값} 저장소입니다. JSX 가 쓰고, 서버와 화면이 {{{키}}} 자리에 읽어 넣습니다.

규칙
범위방 단위. 같은 캐릭터라도 방이 다르면 값이 다르고, 같은 방에서는 턴이 지나도 이어집니다.
키영문·숫자·_, 1~40자. 대소문자 구분. (예: AF, stats_v2)
값문자열 2,000자까지. 객체는 JSON 문자열로.
개수방 하나에 키 50개까지.
합계방 하나의 값을 다 합쳐 10,000자까지. 프롬프트 치환도 한 턴에 같은 합계까지만(저장값은 상태·수치용이지 긴 글을 넣는 통로가 아닙니다).
기본값없음. 저장 전에는 {{{키}}} 가 빈 글자. 프롬프트에 "비어 있으면 50으로 본다"처럼 적거나, JSX 에서 빈 값을 기본값으로 처리하세요.
새 방비어 있음. 방을 복사해도 값은 따라가지 않습니다.
쓰기JSX 의 LT.setValue() 만. 모델은 직접 못 쓰고 숨은 데이터로 JSX 에 넘깁니다.
읽기 시점채팅 화면을 열 때 한 번 받고, 이후엔 이 탭에서 저장한 것만 더해집니다. 다른 탭·기기에서 바꾼 값은 새로 고침해야 보입니다.
예시
한 방의 저장값 예
{ "AF": "72", "MOOD": "기쁨", "STATS": "{\"hp\":80,\"mp\":20}", "FLAG_met": "1" }
프롬프트의 {{{AF}}} → 72, {{{STATS}}} → {"hp":80,"mp":20}, 없는 {{{GOLD}}} → (빈 글자)
관련
LT.setValue{{{키}}} 치환숨은 데이터

{{{키}}} 치환 — 저장값이 들어가는 두 자리

세 겹 중괄호 {{{키}}} 는 방 저장값으로 바뀝니다. 바뀌는 자리는 딱 두 곳입니다.

개념
자리누가 언제효과
캐릭터 프롬프트 (설명·세계관·시작 대사 등 글 칸 전부)서버가 턴을 보내기 직전모델이 "지금 호감도: 72" 를 보고 답합니다.
답변 속 호출 태그의 속성값화면이 창을 그릴 때모델은 {{{AF}}} 를 그대로 쓰기만 하면 됩니다.
  • 키 모양: 영문·숫자·_ 1~40자. 중괄호 안 공백은 무시됩니다.
  • 없는 키는 빈 글자가 됩니다.
  • 치환된 값 안에 또 {{{ 가 있어도 다시 치환되지 않습니다(안전 처리).
예시
값 갱신의 기본 흐름
턴 N    모델: … 그녀가 살짝 웃었다. {{hidden:AF=72}} <lunatalk_asset id="panel" af="{{{AF}}}" />
        화면: {{hidden:…}} 은 지우고, 태그 속성의 {{{AF}}} 는 저장값(아직 없으면 빈 글자)으로 바꿔 JSX 창을 그림
        JSX : LT.getContext() → LT.hidden(current).AF = "72" → LT.setValue('AF', '72')
턴 N+1  서버: 캐릭터 프롬프트의 {{{AF}}} → 72  (모델이 "지금 호감도 72" 를 보고 답함)
        화면: 새 답변 태그의 {{{AF}}} → 72
제한
⚠ 답변 본문의 {{{키}}} 는 화면에서 바뀌지 않습니다(태그 속성값만). 본문에 값을 보이게 하려면 프롬프트 쪽에서 치환된 값을 모델이 읽고 쓰게 하세요.
관련
방 저장값프롬프트에 호출 지시숨은 데이터

{{hidden:…}} — 모델이 JSX 에 값을 넘기는 통로

모델이 답변에 쓰지만 화면에는 보이지 않고 기록에만 남는 데이터입니다. JSX 가 LT.hidden() 으로 읽어 저장값으로 옮깁니다.

{{hidden:키=값}} / {{hidden:키=값;키2=값2}} / 여러 개 따로 써도 됨
규격
  • 키·값은 = 로, 여러 쌍은 ; 또는 줄바꿈으로 나눕니다. 앞뒤 공백은 지워집니다.
  • 값에 ; 나 }} 가 들어가면 끊깁니다. JSON 처럼 복잡한 값은 ; 가 없도록 만들거나 키를 나누세요.
  • 화면에서는 지워지지만 대화 기록·프롬프트에는 남아 모델이 다음 턴에 다시 봅니다. 길게 쓰면 그만큼 대화가 길어지니 짧게.
  • 이용자가 메시지를 수정하거나 턴을 지우면 그 턴의 숨은 데이터도 함께 사라집니다. 중요한 값은 JSX 가 읽는 즉시 LT.setValue() 로 저장해 두세요.
예시
모델이 쓴 것 → JSX 가 읽는 것
// 모델이 쓴 답변 끝
… 그녀가 살짝 웃었다. {{hidden:AF=72;MOOD=기쁨}} <lunatalk_asset id="panel" af="{{{AF}}}" />
// JSX 에서
LT.getContext().then(function (c) {
  var h = LT.hidden(c.current);          // { AF: "72", MOOD: "기쁨" }
  if (h.AF) LT.setValue('AF', h.AF);
});
화면의 말풍선에는 "… 그녀가 살짝 웃었다." 만 보이고, 그 아래 창이 그려집니다.
관련
LT.hiddenLT.setValue프롬프트에 호출 지시

격리 창 — 할 수 있는 것과 없는 것

JSX 는 출처가 없는 iframe(sandbox="allow-scripts") 안에서 돕니다. 채팅 화면의 쿠키·화면·저장소에 손댈 수 없고, 바깥과는 LT 로만 이야기합니다. 통신 정책(CSP)이 아래처럼 걸려 있습니다.

표
할 수 있음할 수 없음
HTML·SVG·CSS·JavaScript 전부. <style>, 인라인 스타일, 애니메이션, 입력칸, 버튼외부 통신 전부 — fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon
React 18 (React·ReactDOM 전역) 과 JSX 문법바깥 스크립트 불러오기(우리 서버 주소 제외). 다른 라이브러리는 500KB 상한 안에서 코드에 직접 포함
그림·소리 — 우리 서버와 허용 호스트(itimg.kr, *.itimg.kr)의 주소, data:·blob: URL그 밖의 외부 주소 그림·소리 (표시되지 않음)
글꼴 — Google Fonts(스타일시트·글꼴 파일), data: 글꼴그 밖의 외부 스타일시트·글꼴
LT.* 함수alert·confirm·prompt (격리 창에서는 뜨지 않음. 화면 안에 글로 보여줄 것)
부모 창 접근, 쿠키, localStorage, 새 창 열기, 주소 이동, 폼 전송, import/export

그림은 itimg.kr 에 올려 쓰면 됩니다. LUNATALK 공식 이미지 호스팅이라 올린 그림의 주소가 그대로 허용됩니다.

기본 CSS
html, body { margin:0; padding:0; width:100%; background:transparent !important; }
body { overflow:hidden; }   * { box-sizing:border-box; }   svg { display:block; max-width:100%; }
  • 배경이 투명이라 채팅 화면(다크 모드일 수 있음)이 비칩니다. 배경색과 글자색을 둘 다 직접 지정하세요.
  • body 가 스크롤되지 않습니다. 긴 내용은 안쪽 요소에 max-height; overflow:auto.
그 밖
  • 에셋 주소를 주소창에 직접 열면 보기 페이지로 옮겨집니다. 채팅 화면 안에서만 창으로 돕니다.
  • 글자는 UTF-8, 한국어 그대로 됩니다.
  • 그림을 다른 곳에서 가져오고 싶으면 허용 호스트에 올려 두거나 data: URL 로 코드에 넣으세요(500KB 상한 주의).
관련
안전 검사React 와 자동 변환크기·화면 가이드

React 와 JSX 문법 — 자동 변환 규칙

React 18 이 들어 있고, <Panel /> 같은 JSX 문법은 창이 열릴 때 자동으로 변환됩니다. 아무 설정도 켤 필요가 없습니다.

규칙
React 읽기코드에 React 또는 ReactDOM 글자가 보일 때만 React 18 을 불러옵니다. 순수 JS 만 쓰면 아무것도 안 읽어 가볍습니다.
변환 대상<script type="text/babel"> 또는 type="text/jsx" 는 무조건 변환. 일반 <script> 는 태그 모양(<Abc)이 있고 그대로 실행하면 문법 오류가 나는 경우에만 변환합니다.
변환 결과JSX 문법 → React.createElement 호출(클래식 방식). 그래서 React 전역이 꼭 있어야 합니다.
안 되는 것import·export. React 와 ReactDOM 은 전역으로 바로 씁니다.
실행 순서창이 다 불러온 뒤 변환 → 변환된 스크립트를 순서대로 실행 → LT.resize 를 아직 안 불렀으면 50ms 뒤 자동 높이 맞춤.
예시
가장 짧은 React 카드
<div id="root"></div>
<script type="text/babel">
function Card() {
  const [n, setN] = React.useState(0);
  return <button onClick={() => setN(n + 1)}>눌린 횟수 {n}</button>;
}
ReactDOM.createRoot(document.getElementById("root")).render(<Card />);
</script>
창이 열리면 버튼 하나가 그려지고 누를 때마다 숫자가 오릅니다. 턴이 바뀌면 새 창이라 0 부터 시작합니다(남기려면 setValue).
오류 표시

변환·실행에 실패하면 창 안에 빨간 상자로 원인이 뜹니다(코드 앞 300자 포함). 브라우저 콘솔에는 [LT JSX] 로 남습니다.

JSX 변환 오류 (스크립트 N)JSX 문법 자체가 틀림. 닫히지 않은 태그, 속성 따옴표 누락이 흔함.
실행 오류 (스크립트 N)변환은 됐지만 실행 중 예외. 없는 id, undefined 접근 등.
라이브러리 로드 실패React 나 변환기를 못 불러옴. 새로 고침.
불러오지 못함: (주소)외부 스크립트 주소가 막힘. 코드에 직접 포함할 것.
제한
  • 일반 <script> 에 JSX 문법을 써도 변환되지만, 그 경우 콘솔에 문법 오류 한 줄이 먼저 찍힙니다(동작엔 지장 없음). type="text/babel" 을 권합니다.
  • React 외 라이브러리는 들어 있지 않습니다.
관련
샘플: React 카드격리 창

안전 검사 — 차단·확인·안내 항목 전부

저장할 때(그리고 [🛡 안전 검사] 버튼으로 미리) 코드를 검사합니다. 항목마다 등급이 있고, 최종 등급은 차단이 하나라도 있으면 차단, 아니면 확인이 있으면 확인, 둘 다 없으면 통과입니다.

등급
⛔ 차단저장되지 않습니다. 패널 문구: "⛔ 차단 항목 N — 아래를 고쳐야 저장할 수 있습니다."
⚠ 확인저장은 됩니다. 사칭 문구·깜빡임·난독화·eval 이면 운영진이 본 뒤 승인하고, 그 밖(스크립트 있음·이벤트 속성·외부 주소 등)은 바로 승인됩니다. 패널 문구는 자동 승인 대상이면 "⚠ 확인 항목 N — 자동 승인 대상입니다. 저장하면 바로 사용할 수 있습니다.", 운영진 확인이 필요하면 "⚠ 확인 필요 N — 저장은 되지만 관리자가 아래 항목을 확인한 뒤 승인합니다."
ℹ 안내등급에 영향 없음. 격리 때문에 어차피 동작하지 않는 코드를 알려 줍니다.
통과"🛡 안전 검사 통과 — 막히는 항목이 없습니다."
차단 항목
  • 코드 500KB 초과 — "코드가 500KB 를 넘습니다."
  • 요소(<) 5,000개 초과 — "요소가 너무 많습니다 (…개, 상한 5,000)."
  • SVG 애니메이션 요소(animate·set) 300개 초과, 필터 요소(<fe…>) 60개 초과
  • 창을 다른 주소로 옮기는 코드 — location(window·document·top·parent 포함)에 대입, .href=, assign(, replace(, reload(. 같은 이름의 변수를 직접 선언해 쓰는 경우는 제외.
  • <meta http-equiv="refresh">
  • 바깥으로 나가는 링크 — # 이나 javascript: 가 아닌 곳으로 가는 <a href>
확인 항목
  • <script> 가 있음 — JSX 는 사실상 항상 해당. "스크립트가 들어 있습니다 — 관리자가 내용을 확인합니다."
  • onclick= 같은 이벤트 속성, javascript: 주소
  • eval(, new Function(, 문자열을 넘기는 setTimeout/setInterval
  • 난독화 흔적 — \x/\u 이스케이프 20개 이상, data: 없이 한 줄 3,000자 초과, atob/fromCharCode/unescape
  • 로그인·비밀번호·카드번호·인증 같은 사칭 문구
  • 0.3초 미만으로 무한 반복하는 애니메이션(SVG dur, CSS animation)
  • 허용 목록 밖 외부 주소 — 검사는 통과해도 실제 창에서는 그림·소리가 우리 서버와 허용 호스트 것만 표시됩니다.
안내 항목
  • 쿠키·localStorage·sessionStorage·IndexedDB 접근
  • fetch·XHR·sendBeacon·WebSocket·EventSource — "SVG 에셋에서는 외부 통신(fetch·XMLHttpRequest 등)이 동작하지 않습니다. 필요하면 Worker 형식으로 만드세요."
  • import(, importScripts, new Worker, window.open
  • iframe·object·embed·base·foreignObject, form·input
예시
검사 결과 읽기
⚠ 확인 필요 1 — 저장은 되지만 관리자가 아래 항목을 확인한 뒤 승인합니다.
  ⚠ 스크립트가 들어 있습니다 — 관리자가 내용을 확인합니다. (12행)
  ℹ SVG 에셋에서는 외부 통신(fetch·XMLHttpRequest 등)이 동작하지 않습니다. … (30행)
저장은 됩니다. 30행의 fetch 는 실행돼도 막히니 지우는 편이 깔끔합니다.
관련
격리 창등록하기문제 해결

크기·화면 가이드 — 폰과 다크 모드에서 깨지지 않게

규칙
  • 창 폭은 말풍선 폭을 따라갑니다(폰에서 300px 안팎). 최상위 요소에 width:100%; box-sizing:border-box; 를 두고 고정 폭은 피하세요.
  • 높이는 자동입니다. 내용이 바뀌면 따라갑니다. 최상위 요소에 height:100vh 같은 창 비례 높이만 피하세요.
  • 창 배경은 투명입니다. 배경색과 글자색을 둘 다 직접 지정하세요. 지정하지 않으면 다크 모드에서 흰 글자가 흰 배경에 보이지 않는 식의 문제가 납니다.
  • 글꼴은 -apple-system, 'Malgun Gothic', sans-serif 정도면 폰·PC 모두 무난합니다.
  • 버튼은 손가락 크기(높이 36px 이상)로, 긴 글은 overflow:hidden; text-overflow:ellipsis 로 보호하세요.
  • 창은 스크롤되지 않습니다. 긴 목록은 안쪽 요소에 max-height; overflow:auto 를 주세요.
예시
기본 틀 — 이 상자로 시작하면 폰·다크 모드 문제가 거의 없습니다
<div id="box" style="width:100%;box-sizing:border-box;font:13px/1.5 -apple-system,'Malgun Gothic',sans-serif;color:#1f2937;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:12px 14px;">
  …
</div>
관련
LT.resize태그의 w/h/ratio

캐릭터 프롬프트에 호출 지시 쓰기

서비스는 JSX 설명을 프롬프트에 끼워 넣지 않습니다. 언제, 어떤 줄을 쓸지를 제작자가 프롬프트에 적어야 모델이 따릅니다. 잘 되는 틀은 "조건 + 그대로 쓸 한 줄" 입니다.

상태창형 — 매 턴
[화면 JSX]
- 매 답변 맨 끝에 아래 한 줄을 그대로 쓴다. af 는 지금 호감도(0~100), mood 는 지금 기분 한 단어, items 는 이용자가 고를 행동 2~3개를 | 로 구분한다:
<lunatalk_asset id="panel" af="{{{AF}}}" mood="차분" items="말을 건다|조용히 본다|자리를 뜬다" />
- 호감도가 달라진 턴에는 그 줄 바로 앞에 {{hidden:AF=새 숫자}} 를 쓴다.
- 지금 호감도: {{{AF}}} (비어 있으면 50으로 본다)
선택지형 — 장면이 끝날 때만
[화면 JSX]
- 이용자가 고를 행동이 뚜렷한 장면이 끝나면 답변 맨 끝에 선택지를 쓴다(2~4개). 매 턴 쓰지 않는다:
<lunatalk_asset id="choice" items="도망친다|맞선다|말을 건다" />
요령
  • 태그 한 줄은 글자 그대로 적고 "그대로 쓴다"고 명시하세요. 모델이 속성 이름을 바꾸거나 따옴표를 빼면 그려지지 않습니다.
  • 속성값은 짧은 단어·숫자·| 목록으로 제한하세요. 긴 문장이나 JSON 을 모델이 태그 안에 쓰게 하면 깨지기 쉽습니다.
  • 값이 턴마다 바뀌는 것은 숨은 데이터로 넘기고 JSX 가 저장하게 하세요. 태그는 {{{키}}} 로 받기만 하면 됩니다.
  • 첫 메시지(인사말)에 태그를 넣어 두면 첫 화면부터 창이 보이고, 모델이 그 모양을 따라 쓰는 데도 도움이 됩니다.
  • 엔진에 따라 태그를 빼먹는 턴이 생깁니다. "답변 끝에 반드시" 보다는 "매 답변 맨 끝에 아래 한 줄을 그대로 쓴다"가 더 잘 지켜집니다.
  • 프롬프트의 {{{키}}} 는 서버가 저장값으로 바꿔 모델에게 보냅니다. "비어 있으면 50으로 본다"처럼 빈 값 규칙도 함께 적으세요.
관련
호출 태그 사양숨은 데이터{{{키}}} 치환

샘플 코드 — 그대로 붙여 넣어 쓰는 4개

편집기에 그대로 붙여 넣고 고유 ID 만 맞추면 됩니다. 각 샘플은 아래에 따로 항목이 있습니다.

샘플고유 ID보여 주는 것쓰는 함수
① 호감도 패널affinityJSON 저장값을 막대로vars, sendToAI, resize
② 선택지 버튼choice버튼 누르면 메시지 전송vars, sendToAI, resize
③ 상태 패널panel숨은 데이터 읽어 저장까지vars, getContext, hidden, setValue, sendToAI, resize
④ React 카드rcardReact 18 + JSX 문법React.useState, setValue, sendToAI, resize

샘플 ① 호감도 패널 — 고유 ID affinity

저장값 AF 에 {"이름":수치,…} JSON 이 들어 있을 때 이름별 막대로 그립니다. 호출: <lunatalk_asset id="affinity" values="{{{AF}}}" />

코드
<div id="box" style="font:13px/1.5 sans-serif;color:#222;background:#fff;border:1px solid #ddd;border-radius:10px;padding:10px 12px;">
  <div style="font-weight:700;margin-bottom:6px;">호감도</div>
  <div id="rows"></div>
  <div style="display:flex;gap:6px;margin-top:8px;">
    <button type="button" id="talk" style="flex:1;padding:8px;border:1px solid #cbd5e1;border-radius:8px;background:#f8fafc;cursor:pointer;">말 걸기</button>
  </div>
</div>
<script>
// 태그에서 받은 값: <lunatalk_asset id="affinity" values="{{{AF}}}" /> → LT.vars.values 가 저장된 AF 값(JSON 문자열)
var data = {};
try { data = JSON.parse(LT.vars.values || "{}"); } catch (e) { data = {}; }
var rows = document.getElementById("rows");
Object.keys(data).forEach(function (name) {
  var v = Math.max(0, Math.min(100, parseInt(data[name], 10) || 0));
  var r = document.createElement("div"); r.style.cssText = "display:flex;align-items:center;gap:8px;margin:3px 0;";
  r.innerHTML = "<span style='width:64px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'></span><div style='flex:1;height:8px;background:#e5e7eb;border-radius:4px;overflow:hidden;'><div style='height:100%;width:" + v + "%;background:#f472b6;'></div></div><b style='width:32px;text-align:right;'>" + v + "</b>";
  r.firstChild.textContent = name;
  rows.appendChild(r);
});
// 버튼: 이용자가 눌렀을 때만 한 턴을 보낸다(부모 창이 확인창을 띄운다)
document.getElementById("talk").onclick = function () {
  LT.sendToAI("잠깐 이야기 좀 하자.").then(function (r) { if (!r.ok) console.log("보내지 않음:", r.error); });
};
LT.resize(document.getElementById("box").offsetHeight + 4);
</script>
동작

LT.vars.values 를 JSON 으로 풀어 막대를 만들고, [말 걸기]를 누르면 확인창 뒤 메시지가 나갑니다. AF 가 비어 있으면 막대가 없고 버튼만 보입니다.

관련
LT.varsLT.sendToAIJSON 은 저장값을 거쳐

샘플 ② 선택지 버튼 — 고유 ID choice

items 를 | 로 나눠 버튼으로 만들고, 누른 글을 이용자 메시지로 보냅니다. 호출: <lunatalk_asset id="choice" items="도망친다|맞선다|말을 건다" />

코드
<div id="box" style="font:13px/1.5 sans-serif;color:#222;background:#fff;border:1px solid #ddd;border-radius:10px;padding:10px 12px;">
  <div style="font-weight:700;margin-bottom:8px;">어떻게 할까?</div>
  <div id="list" style="display:flex;flex-direction:column;gap:6px;"></div>
  <div id="note" style="font-size:11px;color:#888;margin-top:6px;"></div>
</div>
<script>
var items = String(LT.vars.items || '').split('|').map(function (s) { return s.trim(); }).filter(Boolean).slice(0, 4);
var list = document.getElementById('list'), note = document.getElementById('note');
if (!items.length) note.textContent = '선택지가 없습니다 (items="가|나|다")';
items.forEach(function (label) {
  var b = document.createElement('button');
  b.type = 'button'; b.textContent = label;
  b.style.cssText = 'padding:9px 10px;border:1px solid #cbd5e1;border-radius:8px;background:#f8fafc;color:#111;text-align:left;cursor:pointer;';
  b.onclick = function () {
    note.textContent = '보내는 중…';
    LT.sendToAI(label).then(function (r) {
      note.textContent = r.ok ? '보냈습니다.' : (r.error === 'cancelled' ? '취소했습니다.' : (r.error === 'busy' ? '지금은 답변 중입니다. 잠시 뒤 다시 눌러 주세요.' : '보내지 못했습니다: ' + r.error));
    });
  };
  list.appendChild(b);
});
LT.resize(document.getElementById('box').offsetHeight + 4);
</script>
동작

최대 4개까지 버튼을 만듭니다. 결과(ok·cancelled·busy)를 아래 글로 보여 주므로 이용자가 영문을 압니다. items 가 비면 안내 문구가 뜹니다.

관련
LT.sendToAI선택지형 프롬프트

샘플 ③ 상태 패널 — 고유 ID panel

호감도 막대 + 기분 + 선택지. 이 답변에 {{hidden:AF=숫자}} 가 있으면 그 값을 쓰고 저장까지 합니다. 호출: <lunatalk_asset id="panel" af="{{{AF}}}" mood="차분" items="말을 건다|조용히 본다|자리를 뜬다" />

코드
<div id="box" style="font:13px/1.5 -apple-system,'Malgun Gothic',sans-serif;color:#222;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:12px 14px;">
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
    <b>상태</b>
    <span id="mood" style="font-size:12px;color:#6b7280;"></span>
  </div>
  <div style="display:flex;align-items:center;gap:8px;">
    <span style="width:44px;font-size:12px;color:#6b7280;">호감도</span>
    <div style="flex:1;height:10px;background:#f3f4f6;border-radius:5px;overflow:hidden;">
      <div id="bar" style="height:100%;width:0%;background:linear-gradient(90deg,#f9a8d4,#ec4899);transition:width .4s;"></div>
    </div>
    <b id="num" style="width:36px;text-align:right;">0</b>
  </div>
  <div id="list" style="display:flex;flex-direction:column;gap:6px;margin-top:10px;"></div>
  <div id="note" style="font-size:11px;color:#9ca3af;margin-top:6px;min-height:14px;"></div>
</div>
<script>
(function () {
  var $ = function (id) { return document.getElementById(id); };
  var note = $('note');
  function clamp(n) { n = parseInt(n, 10); return isNaN(n) ? null : Math.max(0, Math.min(100, n)); }
  function show(af) { $('bar').style.width = af + '%'; $('num').textContent = af; }
  function fit() { LT.resize($('box').offsetHeight + 4); }

  // 1) 태그 값
  var af = clamp(LT.vars.af);
  $('mood').textContent = LT.vars.mood ? '기분: ' + LT.vars.mood : '';
  show(af === null ? 0 : af);

  // 2) 선택지 버튼 → 이용자 메시지로 보내기 (부모 창이 확인창을 띄움, 한 턴)
  var items = String(LT.vars.items || '').split('|').map(function (s) { return s.trim(); }).filter(Boolean).slice(0, 4);
  items.forEach(function (label) {
    var b = document.createElement('button');
    b.type = 'button'; b.textContent = label;
    b.style.cssText = 'padding:9px 10px;border:1px solid #d1d5db;border-radius:8px;background:#f9fafb;color:#111;text-align:left;cursor:pointer;font:inherit;';
    b.onclick = function () {
      note.textContent = '보내는 중…';
      LT.sendToAI(label).then(function (r) {
        note.textContent = r.ok ? '보냈습니다.' : (r.error === 'cancelled' ? '취소했습니다.' : (r.error === 'busy' ? '지금은 답변 중입니다. 잠시 뒤 다시 눌러 주세요.' : '보내지 못했습니다: ' + r.error));
      });
    };
    $('list').appendChild(b);
  });
  fit();

  // 3) 이 답변의 숨은 데이터 {{hidden:AF=72}} 가 있으면 그 값을 쓰고 방 저장값으로 남긴다 → 다음 턴의 {{{AF}}} 가 이 값이 된다
  LT.getContext().then(function (c) {
    if (!c || !c.ok) return;
    var h = LT.hidden(c.current || '');
    var fromHidden = clamp(h.AF);
    if (fromHidden === null) return;
    show(fromHidden);
    if (String(c.values && c.values.AF) !== String(fromHidden)) {
      LT.setValue('AF', String(fromHidden)).then(function (r) { note.textContent = r.ok ? '호감도 ' + fromHidden + ' 저장됨' : '저장 실패: ' + r.error; fit(); });
    }
  });
})();
</script>
동작
  1. 태그의 af·mood·items 로 먼저 그립니다.
  2. getContext() 로 원문을 받아 hidden().AF 가 있으면 막대를 그 값으로 바꾸고, 저장값과 다르면 setValue('AF', …) 합니다.
  3. 다음 턴부터 프롬프트와 태그의 {{{AF}}} 가 새 값이 됩니다.
관련
숨은 데이터LT.setValue상태창형 프롬프트

샘플 ④ React 카드 — 고유 ID rcard

React 18 + JSX 문법 그대로. <script type="text/babel"> 안에 쓰면 자동 변환됩니다. 호출: <lunatalk_asset id="rcard" name="유나" af="{{{AF}}}" mood="차분" />

코드
<div id="root"></div>
<script type="text/babel">
function Bar({ value }) {
  const v = Math.max(0, Math.min(100, value));
  return (
    <div style={{ flex: 1, height: 10, background: '#f3f4f6', borderRadius: 5, overflow: 'hidden' }}>
      <div style={{ width: v + '%', height: '100%', background: 'linear-gradient(90deg,#f9a8d4,#ec4899)', transition: 'width .4s' }} />
    </div>
  );
}

function Card() {
  const initial = parseInt(LT.vars.af, 10);
  const [af, setAf] = React.useState(isNaN(initial) ? 0 : initial);
  const [note, setNote] = React.useState('');
  const boxRef = React.useRef(null);

  // 높이 맞추기 — 내용이 바뀔 때마다
  React.useEffect(() => { if (boxRef.current) LT.resize(boxRef.current.offsetHeight + 4); });

  const bump = (d) => {
    const next = Math.max(0, Math.min(100, af + d));
    setAf(next);
    LT.setValue('AF', String(next)).then((r) => setNote(r.ok ? '호감도 ' + next + ' 저장됨' : '저장 실패: ' + r.error));
  };
  const talk = () => {
    setNote('보내는 중…');
    LT.sendToAI('잠깐 이야기 좀 하자.').then((r) => setNote(r.ok ? '보냈습니다.' : (r.error === 'cancelled' ? '취소했습니다.' : '보내지 못했습니다: ' + r.error)));
  };
  const btn = { padding: '8px 10px', border: '1px solid #d1d5db', borderRadius: 8, background: '#f9fafb', color: '#111', cursor: 'pointer', font: 'inherit' };

  return (
    <div ref={boxRef} style={{ font: "13px/1.5 -apple-system,'Malgun Gothic',sans-serif", color: '#1f2937', background: '#fff', border: '1px solid #e5e7eb', borderRadius: 12, padding: '12px 14px' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
        <b>{LT.vars.name || '이름 없음'}</b>
        <span style={{ fontSize: 12, color: '#6b7280' }}>{LT.vars.mood ? '기분: ' + LT.vars.mood : ''}</span>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span style={{ width: 44, fontSize: 12, color: '#6b7280' }}>호감도</span>
        <Bar value={af} />
        <b style={{ width: 36, textAlign: 'right' }}>{af}</b>
      </div>
      <div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
        <button type="button" style={btn} onClick={() => bump(-5)}>−5</button>
        <button type="button" style={btn} onClick={() => bump(5)}>+5</button>
        <button type="button" style={{ ...btn, flex: 1, background: '#4f46e5', borderColor: '#4f46e5', color: '#fff' }} onClick={talk}>말 걸기</button>
      </div>
      <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 6, minHeight: 14 }}>{note}</div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<Card />);
</script>
동작

−5/+5 버튼이 상태를 바꾸고 setValue 로 저장합니다. useEffect 가 그릴 때마다 LT.resize 를 불러 높이를 맞춥니다. [말 걸기]는 메시지를 보냅니다.

관련
React 와 자동 변환LT.resize

올리기 전 체크리스트

  • 최상위 요소에 폭 100%, 배경색·글자색 지정. 높이는 자동(창 비례 높이 금지)
  • LT.vars 값이 비었을 때(첫 턴)도 깨지지 않는지
  • 보내기·재생성은 버튼 클릭 안에서만 부르는지
  • 결과의 error 를 화면에 글로 보여 주는지(alert 는 안 뜸)
  • fetch·외부 그림·외부 링크를 쓰지 않았는지
  • 프롬프트의 태그 한 줄과 JSX 가 읽는 속성 이름이 같은지
  • 폰 폭(300px)과 다크 모드에서 글이 잘리거나 안 보이지 않는지
  • [🛡 안전 검사] 결과에 차단 항목이 없는지

문제 해결 — 증상별 원인과 대처

증상원인과 대처
JSX 자리가 비어 있음고유 ID 철자, 에셋 상태(본인 것 또는 승인됨), 태그 따옴표 확인. 답변 생성 중에는 자리만 잡히고 끝난 뒤 그려짐. 로그아웃 상태면 안 그려짐.
"JSX 기능이 아직 열리지 않았습니다"보는 계정에 JSX 가 열려 있지 않음.
창 안에 빨간 상자 "JSX 변환 오류"JSX 문법 오류. 닫히지 않은 태그·따옴표 확인. "실행 오류"면 변환은 됐고 코드 실행 중 예외.
버튼을 눌러도 아무 일 없음결과의 error 를 화면에 보여 주세요. busy 면 답변 중이거나 3초 안 연타, cancelled 면 확인창 취소.
값이 다음 턴에 안 바뀜LT.setValue() 결과가 ok 였는지, 프롬프트의 키 철자가 같은지(대소문자 구분). 저장은 되는데 모델이 못 읽으면 프롬프트에 {{{키}}} 가 실제로 적혀 있는지.
이미 그려진 창의 값이 안 바뀜정상. 치환은 그릴 때 한 번. 새 턴의 창부터 새 값.
높이가 잘림자동 맞춤은 내용이 바뀌면 따라갑니다. LT.autosize(false) 를 불렀거나, 최상위 요소 높이가 창에 비례(100vh 등)해 자동이 멈춘 경우. 내용 높이로 바꾸고 LT.autosize(true).
다크 모드에서 글이 안 보임배경색·글자색을 직접 지정.
그림이 안 나옴우리 서버·허용 호스트·data: 만 됨. 다른 주소는 CSP 로 막힘.
저장 시 "차단"검사 메시지의 항목을 지우기. 창 이동·외부 링크·500KB 초과가 흔한 원인.
미리보기에서 보내기·저장이 안 됨정상. 미리보기의 LT 는 가짜(error: "preview"). 채팅방에서 확인.

자주 묻는 질문

Q. 값을 캐릭터 전체나 계정 전체에 공유할 수 있나요?
지금은 방 단위만 됩니다.

Q. React 를 쓸 수 있나요?
됩니다. React 18 이 항상 들어 있어서 React.useState 와 JSX 문법을 그대로 쓰면 되고, 아무것도 켤 필요가 없습니다. 다른 라이브러리는 들어 있지 않습니다.

Q. JSX 가 보낸 메시지도 요금이 드나요?
이용자가 직접 쓴 메시지와 똑같이 한 턴으로 처리됩니다. 그래서 확인창이 반드시 뜹니다. 재생성도 보통 재생성과 같습니다.

Q. 다른 제작자의 JSX 를 내 캐릭터에서 쓸 수 있나요?
제작자가 공개로 둔 에셋만 됩니다. 갤러리에 보이는 에셋이 그것이고, 고유 ID 로 부르면 됩니다. 비공개 에셋은 그 제작자의 캐릭터에서만 작동합니다.

Q. 한 답변에 JSX 를 두 개 넣으면?
각각 따로 그려지고 따로 돕니다. 둘이 서로 이야기할 수는 없고, 저장값을 통해 간접으로 주고받습니다.

Q. 모델이 저장값을 직접 바꿀 수 있나요?
아니요. 모델은 숨은 데이터로 값을 적고, JSX 가 읽어 setValue 로 저장합니다. 그래서 상태 패널 샘플처럼 "읽고 저장"하는 코드가 필요합니다.

Q. 외부 자료(날씨·시세)를 가져올 수 있나요?
JSX 에서는 외부 통신이 전부 막힙니다. 그런 용도는 에셋 유형 중 Worker 를 쓰세요. Worker 의 외부 호출은 우리 서버 중계를 거치며 https 만 되고, 받는 호스트는 *.r2.dev, *.workers.dev, *.pages.dev, *.cloudflare.com, raw.githubusercontent.com, cdn.jsdelivr.net, *.itimg.kr, itimg.kr, *.lunatalk.chat, lunatalk.chat 입니다. 응답 크기는 5MB 까지입니다. Worker 는 자동 승인 없이 운영진이 코드를 본 뒤 승인합니다.

직접 만들어 볼 준비가 되셨나요?

JSX 만들기 내 에셋 보기