튜토리얼: 첫 번째 앱 만들기
이 튜토리얼에서는 활성 채팅 기록을 읽고 역할별(사용자 vs 어시스턴트)로 단어 수를 세어 통계를 표시하는 단어 카운터 미니 프로그램을 만듭니다. 이 튜토리얼을 마치면 미니 프로그램을 생성, 테스트, 패키징, 게시하는 방법을 알게 됩니다.
배울 내용:
manifest.json만들기window.aisSDK로 앱 HTML 작성ais.chat.getHistory()로 메시지 읽기ais.storage로 세션 간 데이터 유지- 사이드로딩으로 로컬 테스트
.ais번들로 패키징- 커뮤니티 레지스트리에 게시
소요 시간: 약 15분.
1단계: 매니페스트 만들기
모든 미니 프로그램은 앱, 권한, 진입점을 설명하는 manifest.json이 필요합니다. 새 디렉토리를 만들고 이 파일을 추가하세요:
mkdir word-counter
cd word-counter
manifest.json 생성:
{
"name": "word-counter",
"version": "1.0.0",
"abi": 1,
"type": "mini-program",
"title": "Word Counter",
"description": "Count words in your chat history by role",
"author": { "name": "Your Name" },
"entry": "index.html",
"base_url": "https://localhost:8080/",
"permissions": ["storage", "chat:read", "ui:toast"],
"keywords": ["statistics", "utility"]
}
주요 필드:
| 필드 | 값 | 이유 |
|---|---|---|
name | word-counter | 고유 식별자 (소문자, 하이픈만) |
abi | 1 | 필수 -- 현재 플랫폼 ABI와 일치 |
type | mini-program | 플랫폼에 이것이 샌드박스 iframe 앱임을 알림 |
entry | index.html | 로드할 HTML 파일 |
base_url | https://localhost:8080/ | 에셋이 호스팅되는 위치 (프로덕션용으로 업데이트) |
permissions | ["storage", "chat:read", "ui:toast"] | 채팅 읽기와 통계 저장 필요 |
storage 권한은 항상 자동으로 부여되지만, 사용자가 앱이 데이터를 저장한다는 것을 알 수 있도록 명시적으로 나열하는 것이 좋습니다.
2단계: HTML 만들기
같은 디렉토리에 index.html을 만듭니다. 이것이 전체 앱입니다 -- HTML, CSS, JavaScript가 한 파일에 있습니다.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
padding: 20px;
color: #e0e0e0;
background: #1a1a2e;
min-height: 100vh;
}
h1 {
font-size: 22px;
margin-bottom: 16px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #58a6ff;
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 14px;
color: #888;
margin-top: 4px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
min-height: 48px;
padding: 10px 20px;
font-size: 16px;
border: 1px solid #444;
border-radius: 6px;
background: #2a2a4e;
color: #e0e0e0;
cursor: pointer;
}
button:hover {
background: #3a3a5e;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #1a73e8;
border-color: #1a73e8;
}
.btn-primary:hover {
background: #1557b0;
}
.btn-close {
background: none;
border-color: #666;
color: #888;
}
#last-updated {
margin-top: 16px;
font-size: 14px;
color: #666;
}
</style>
</head>
<body>
<h1>단어 카운터</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="total-words">--</div>
<div class="stat-label">총 단어</div>
</div>
<div class="stat-card">
<div class="stat-value" id="user-words">--</div>
<div class="stat-label">내 단어</div>
</div>
<div class="stat-card">
<div class="stat-value" id="ai-words">--</div>
<div class="stat-label">AI 단어</div>
</div>
<div class="stat-card">
<div class="stat-value" id="msg-count">--</div>
<div class="stat-label">메시지</div>
</div>
</div>
<div class="actions">
<button class="btn-primary" id="analyze">채팅 분석</button>
<button class="btn-close" id="close">닫기</button>
</div>
<div id="last-updated"></div>
<script>
// 헬퍼: 문자열의 단어 수 세기
function countWords(text) {
if (!text || typeof text !== "string") return 0;
return text.trim().split(/\s+/).filter(Boolean).length;
}
// 숫자를 쉼표와 함께 포맷 (1234 -> "1,234")
function fmt(n) {
return n.toLocaleString();
}
ais.ready(async function () {
// 패널 제목 설정
ais.ui.setTitle("단어 카운터");
// 마지막으로 저장된 통계 복원 시도
var saved = await ais.storage.get("last-stats");
if (saved) {
showStats(saved);
}
// 분석 버튼
document
.getElementById("analyze")
.addEventListener("click", async function () {
this.disabled = true;
this.textContent = "분석 중...";
try {
// 채팅 기록에서 최대 500개 메시지 가져오기
var messages = await ais.chat.getHistory(500);
var userWords = 0;
var aiWords = 0;
var msgCount = messages.length;
for (var i = 0; i < messages.length; i++) {
var msg = messages[i];
var words = countWords(msg.content);
if (msg.role === "user") {
userWords += words;
} else if (msg.role === "assistant") {
aiWords += words;
}
}
var stats = {
total: userWords + aiWords,
user: userWords,
ai: aiWords,
messages: msgCount,
timestamp: Date.now(),
};
showStats(stats);
// 다음을 위해 통계 저장
await ais.storage.set("last-stats", stats);
ais.ui.toast(msgCount + "개 메시지 분석 완료!");
} catch (err) {
ais.ui.toast("에러: " + err.message);
}
this.disabled = false;
this.textContent = "채팅 분석";
});
// 닫기 버튼
document.getElementById("close").addEventListener("click", function () {
ais.close();
});
});
function showStats(stats) {
document.getElementById("total-words").textContent = fmt(stats.total);
document.getElementById("user-words").textContent = fmt(stats.user);
document.getElementById("ai-words").textContent = fmt(stats.ai);
document.getElementById("msg-count").textContent = fmt(stats.messages);
if (stats.timestamp) {
var date = new Date(stats.timestamp);
document.getElementById("last-updated").textContent =
"마지막 분석: " + date.toLocaleString();
}
}
</script>
</body>
</html>
이 코드가 하는 일
ais.ready()-- SDK 브리지가 연결될 때까지 기다린 후 로직 실행.ais.storage.get('last-stats')-- 이전에 저장된 통계를 복원하여 사용자가 실행 시 즉시 데이터를 볼 수 있음.ais.chat.getHistory(500)-- 활성 대화에서 최대 500개 메시지 가져오기.- 단어 수 세기 -- 메시지를 반복하며 콘텐츠를 공백으로 분할하고 역할별로 집계.
ais.storage.set('last-stats', stats)-- 다음을 위해 결과 유지.ais.ui.toast()-- 분석 완료 시 알림 표시.ais.close()-- 사용자가 닫기를 클릭하면 채팅 화면으로 돌아감.
3단계: 로컬 테스트
매니페스트와 HTML 파일을 제공할 로컬 HTTP 서버가 필요합니다. 선호하는 도구를 사용하세요:
# Python 3
cd word-counter
python3 -m http.server 8080
# 또는 Node.js
npx serve -p 8080
# 또는 PHP
php -S localhost:8080
이제 플랫폼에 앱을 설치합니다:
- aiscouncil.net 열고 로그인
- 왼쪽 사이드바의 앱 아이콘 클릭
- 사이드로드 섹션에서 붙여넣기:
http://localhost:8080/manifest.json - 설치 클릭
- 권한 검토 (storage, chat:read, ui:toast)하고 허용 클릭
- 설치된 앱 카드에서 열기 클릭
가장 빠른 개발 루프를 위해 URL 사이드로딩 대신 HTML 업로드를 사용하세요. index.html을 직접 업로드하면 서버가 필요 없습니다. 플랫폼이 자동으로 합성 매니페스트를 생성합니다. 변경할 때마다 제거하고 다시 업로드할 수 있습니다.
문제 해결
| 문제 | 해결책 |
|---|---|
| "매니페스트 가져오기 실패" | 로컬 서버가 실행 중이고 CORS 헤더를 제공하는지 확인. python3 -m http.server 8080은 CORS-safe하게 제공합니다. |
| 앱이 빈 흰 페이지 표시 | 브라우저 콘솔에서 에러 확인. 가장 일반적인 문제는 ais.ready() 전에 ais.* 메서드를 호출하는 것입니다. |
| "PermissionDenied: chat:read" | 매니페스트에 chat:read가 권한 배열에 없습니다. 매니페스트를 업데이트하고 재설치하세요. |
| 코드 변경 후 앱이 업데이트되지 않음 | 먼저 앱을 제거(카드의 X 버튼)한 다음 재설치. 진입 HTML은 설치 시점에 캐시됩니다. |
4단계: 다듬기 추가
실시간 단어 수 세기 기능을 추가해 봅시다. 새 메시지가 도착하면 실시간으로 업데이트됩니다.
닫기 버튼 핸들러 뒤, ais.ready() 콜백 안에 이 코드를 추가하세요:
// 실시간 카운팅을 위해 새 메시지 구독
ais.chat.onMessage(function (msg) {
// 저장된 통계를 다시 읽고 새 메시지의 단어 추가
ais.storage.get("last-stats").then(function (stats) {
if (!stats) return;
var words = countWords(msg.content);
if (msg.role === "user") stats.user += words;
else if (msg.role === "assistant") stats.ai += words;
stats.total = stats.user + stats.ai;
stats.messages++;
stats.timestamp = Date.now();
showStats(stats);
ais.storage.set("last-stats", stats);
});
});
이제 사용자가 채팅할 때 "분석"을 다시 클릭할 필요 없이 카운터가 실시간으로 업데이트됩니다.
5단계: .ais 번들로 패키징
.ais 번들은 매니페스트와 모든 앱 파일이 포함된 ZIP 아카이브입니다. 플랫폼은 ZIP을 추출하고 매니페스트를 읽고 모든 에셋(CSS, JS, 이미지)을 진입 HTML에 인라인합니다.
우리 같은 단일 파일 앱의 경우 번들은 간단합니다:
cd word-counter
zip -r ../word-counter.ais manifest.json index.html
상위 디렉토리에 word-counter.ais가 생성됩니다.
번들 테스트
- 플랫폼에서 앱으로 이동하여 앱 업로드 클릭
word-counter.ais선택- 권한 검토 및 승인
- 모든 에셋이 인라인된 상태로 번들에서 앱이 설치됨
번들은 독립적입니다. 사용자는 원래 base_url에 대한 네트워크 접근이 필요 없습니다. 모든 것이 설치 시점에 인라인됩니다. 이것이 번들을 오프라인 배포와 공유에 이상적으로 만듭니다.
다중 파일 번들
앱에 별도의 CSS, JavaScript, 이미지 파일이 있는 경우 모두 ZIP에 포함하세요:
zip -r ../word-counter.ais manifest.json index.html style.css app.js icon.png
플랫폼은 자동으로 인라인합니다:
<link rel="stylesheet" href="style.css">→<style>...</style><script src="app.js"></script>→<script>...</script><img src="icon.png">→<img src="data:image/png;base64,...">
6단계: 레지스트리에 게시
앱이 다른 사람들이 사용할 준비가 되면 커뮤니티 레지스트리에 게시합니다.
1. 파일 호스팅
매니페스트와 진입 HTML을 공개 CDN에 업로드합니다. GitHub Pages는 무료이고 쉽습니다:
# GitHub 저장소에서 (예: github.com/yourname/word-counter)
# manifest.json과 index.html을 main 브랜치에 푸시
# 저장소 설정에서 GitHub Pages 활성화 (소스: main 브랜치, 루트)
파일은 다음에서 사용할 수 있습니다:
https://yourname.github.io/word-counter/manifest.jsonhttps://yourname.github.io/word-counter/index.html
매니페스트의 base_url을 업데이트하세요:
"base_url": "https://yourname.github.io/word-counter/"
2. aiscouncil 저장소 포크
github.com/nicholasgasior/bcz로 이동하여 Fork를 클릭합니다.
3. 패키지 항목 추가
registry/packages.json을 편집하고 packages 배열에 항목을 추가합니다:
{
"name": "word-counter",
"type": "mini-program",
"version": "1.0.0",
"manifest": "https://yourname.github.io/word-counter/manifest.json",
"tier": "community",
"category": "utilities",
"description": "Count words in your chat history by role",
"icon": "https://yourname.github.io/word-counter/icon.png",
"added": "2026-02-19",
"price": 0,
"currency": "USD",
"seller": null
}
4. 검증
검증 스크립트를 실행하여 항목을 확인합니다:
python3 registry/validate.py packages
검증이 통과하면 제출할 준비가 된 것입니다.
5. 풀 리퀘스트 제출
포크에 변경사항을 푸시하고 메인 저장소에 대한 PR을 생성합니다. 자동화된 검증이 통과하면 PR이 병합될 수 있으며 앱이 플랫폼의 앱 스토어 섹션에 나타납니다.
가격 책정, 판매자 설정, 검증 등급에 대한 자세한 내용은 레지스트리에 게시를 참조하세요.
팁과 모범 사례
디자인
- 다크 테마 기본 -- 대부분의 플랫폼 사용자는 다크 모드를 사용합니다. 어두운 배경(
#1a1a2e또는 유사)과 밝은 텍스트(#e0e0e0)로 디자인하세요. - 48px 최소 터치 타겟 -- 버튼과 인터랙티브 요소는 접근성과 VLM 친화적 상호작용을 위해 최소 48px 높이여야 합니다.
- 14px 최소 폰트 크기 -- 모든 텍스트는 가독성을 위해 최소 14px여야 합니다.
- 반응형 레이아웃 -- 앱 패널 너비는 다양합니다. 적응하기 위해
auto-fit과 함께 CSS Grid 또는 Flexbox를 사용하세요.
성능
- 저장소에 결과 캐시 -- 계산된 결과를 저장하기 위해
ais.storage를 사용하세요. 사용자가 실행 시 즉시 데이터를 볼 수 있도록 복원합니다. - 채팅 기록 요청 제한 --
ais.chat.getHistory(500)이면 보통 충분합니다. 무제한 기록 요청을 피하세요. - 폴링 금지 --
getHistory를 반복 호출하는 대신 실시간 업데이트를 위해ais.chat.onMessage()를 사용하세요.
보안
- 최소 권한 요청 -- 앱이 실제로 사용하는 권한만 나열하세요. 권한이 적을수록 더 많은 사용자가 앱을 신뢰하고 설치합니다.
- 모든 입력 검증 --
ais.chat.getHistory()의 데이터는 사용자 생성 콘텐츠를 포함합니다. DOM에 삽입하기 전에 새니타이즈하세요. - 민감한 데이터 저장 금지 --
ais.storage는 암호화되지 않습니다. 비밀번호, 토큰, API 키를 저장하지 마세요 (앱에secrets:sync가 있고 명시적으로 자격 증명 전송을 처리하지 않는 한).
호환성
ais.platform.abi확인 -- 앱이 특정 SDK 기능에 의존하는 경우 ABI 버전을 확인하고 플랫폼이 오래된 경우 도움이 되는 메시지를 표시하세요.- SDK 호출을 try/catch로 감싸기 -- 권한 에러와 플랫폼 버전 차이로 거부가 발생할 수 있습니다. 우아하게 처리하세요.
- hello-world 예시로 테스트 -- 플랫폼은 참조로 사용할 수 있는 예시 미니 프로그램과 함께 제공됩니다.