Rokid AIUI로 AI 안경용 자기소개 연습 앱 개발·출시기
한 개발자가 Rokid AIUI를 활용해 사용자 상황에 맞춰 질문과 문장 보완을 제공하는 자기소개 연습 앱 '설명명'을 만들어 상架했다고 공유했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
나는 Rokid AIUI로 자기소개 연습 도우미를 만들었는데, 이름은 “说清楚”다. 사용자가 먼저 자신의 경험을 말하면 모델이 맥락에 따라 되묻고, 막힐 때는 문장 틀 채우기로 바꾸고, 시간이 촉박하면 연습을 줄이며, 끝난 뒤에는 별도로 전체 회차 요약과 소개 완성본을 생성한다.
내가 해결하려는 것은 말문을 연 뒤 다음 문장이 이어지지 않는 문제다. 예를 들어 자신이 AI를 쓸 줄 안다고 말했다면, 다음에 어떤 사례를 이야기해야 할까? 그 사례가 눈앞의 청중에게 어떤 도움을 줄 수 있을까? 그래서 나는 정체성, 경험, 도움을 세 가지 내용 목표로 설정하고, 구체적인 되묻기는 모델에 맡겼다.
이번에는 AIUI Studio로 개발했는데, 실물 안경이 없어도 시뮬레이터에서 실제 모델 호출과 페이지 흐름을 검증할 수 있다.
1. 플랫폼에서 프로젝트 만들기
먼저 워크벤치를 열고 왼쪽의 “新建智能体”를 클릭한 뒤 “对话创建”를 선택해 AIUI CODING에 들어간다. 나는 먼저 AIUI 0.18.0 빠른 시작을 읽고, 플랫폼에서 공식 aiui-dev Skill을 활성화해 프레임워크 규범에 따라 프로젝트를 생성하도록 했다.
생성 요구에는 장면, 페이지, 검수 동작을 동시에 명시해야 한다. 이 프로젝트의 요구를 정리하면 이렇게 설명할 수 있다.
중국어 자기소개 연습 도우미 “说清楚”를 생성한다. 정체성, 구체적 경험, 제공할 수 있는 도움에 따라 연습을 구성한다. 화면마다 질문 하나, 힌트 하나, 소량의 조작을 배치한다. 음성 답변, 안경 다리 선택, 일시정지, 다시 말하기, 난이도 조정을 지원한다. LanguageModel과 맥락을 결합해 되묻고, 마지막에 독립적으로 요약을 생성한다. 실제로 프로젝트 파일을 생성하고 시뮬레이터에서 한 회차를 끝까지 실행해 달라.
실제 생성은 한 번에 끝나지 않았다. 첫 회차에 플랫폼은 자료를 반복해서 읽고 계획을 작성했지만 코드 영역은 여전히 비어 있었다. 그래서 나는 목표를 먼저 네 개의 파일을 생성하고 환영 페이지와 기본 이동을 실행하는 것으로 줄인 뒤, 음성, 모델, 예외 처리를 항목별로 보충했다. 이렇게 하면 매번 수정할 때마다 점검할 수 있는 페이지가 생긴다.
상단의 “代码”로 전환해 먼저 파일 트리를 확인한 뒤 구체적인 파일을 열어 편집한다. 디렉터리를 생성한 뒤에는 pages/index를 펼쳐 페이지가 실제로 존재하는지 확인해야 하고, app.json 안의 경로도 그것과 일치해야 한다. 애플리케이션 진입점만 있고 페이지 파일이 없으면 여전히 환영 페이지가 나오지 않는다. 최종적으로 이 프로젝트의 주요 파일은 다음과 같다.
AGENTS.md 정체성, 장면, 행동 경계 app.json 페이지 진입점, 권한, 전역 설정 app.js 애플리케이션 생명주기 pages/index/index.ink 페이지 로직, 구조, 스타일
플랫폼이 최소 프로젝트를 생성한 뒤, 나는 코드를 같은 AIUI 프로젝트에 다시 작성했다. 이후의 결과는 줄곧 그 시뮬레이터에서 확인했다.
기능을 보충할 때는 매번 점검 가능한 목표를 하나만 제시했다. 먼저 환영 페이지에서 첫 번째 문제로 들어갈 수 있게 하고, 다음으로 답변이 모델 요청을 발생시키게 하고, 마지막으로 다시 말하기와 전체 회차 요약을 추가했다. 모든 요구를 한꺼번에 넣으면 문제가 생겼을 때 페이지가 생성되지 않은 것인지, 이벤트가 발생하지 않은 것인지, 모델이 응답하지 않은 것인지 구분하기 어렵다.
아래 코드는 현재 프로젝트의 핵심 분기를 정리한 것으로, 일부 정리, 로그, 오류 처리는 글로 설명한다. 함수들 사이에 페이지 상태를 공유하므로, 그대로 구현할 때는 같은 페이지 객체 안에 다시 넣어야 한다.
2. 진입점과 권한 설정
app.json에서 먼저 페이지 경로와 녹음 권한을 선언한다. 다음은 현재 설정의 핵심 부분이다.
{ "name" : "说清楚" , "version" : "1.2.1" , "permissions" : [ "RECORD_AUDIO" ] , "pages" : [ "pages/index/index" ] , "window" : { "navigationBarTitleText" : "说清楚" } }
가장 처음 생성된 권한에는 microphone, network를 썼지만, 나중에 문서에 따라 RECORD_AUDIO로 바꿨다. 이 이름은 플랫폼이 지원하는 값을 사용해야 한다. 프로젝트는 카메라, 위치, 주소록을 신청하지 않았고 외부 모델 키도 구성하지 않았다.
AGENTS.md에는 책임을 명확히 적었다. 내용과 구조만 연습하고, 발음, 말 속도, 인격을 평가하지 않으며, 경험, 숫자, 결과를 지어내지 않는다. 이 요구는 페이지가 실제로 모델에 보내는 시스템 프롬프트에도 넣어야 하며, 설명 파일을 다 썼다고 해서 모든 요청에 제약이 걸린다고 여겨서는 안 된다.
AIUI CODING은 개발 시 프로젝트 생성과 수정을 담당하고, LanguageModel은 제품 실행 시의 되묻기와 요약을 담당한다. 두 프롬프트는 분리되어 있다. 전자는 어떤 페이지를 만들어야 하는지 설명하고, 후자는 현재 답변을 어떻게 분석할지 설명한다. 실행 시마다 세션을 생성할 때 initialPrompts를 통해 시스템 요구를 명확히 전달한다.
index.ink는 페이지 설정, JavaScript, 인터페이스 구조, 스타일을 한데 담는다. 로직은 export default를 사용하고, 페이지 상태는 this.setData로 갱신하며, 구조에서 해당 필드를 읽는다. 예를 들어 현재 질문과 조작 버튼은 각각 question과 actions에 바인딩되고, 모델이 반환된 뒤 함께 갱신된다.
구체적으로 편집할 때는 import와 페이지 메서드를 script setup에, 질문과 버튼을 표시하는 구조를 page에, 글자 크기, 테두리, 레이아웃을 style에 둔다. data에 먼저 표시 필드의 초깃값을 주고, 이벤트가 상태를 바꾸게 한다. 이전에 필드 미정의 실행 오류를 겪었기 때문에, 나는 템플릿 참조가 data에서 초기화되었는지 먼저 확인한다.
페이지는 또 두 가지 상태를 구분한다. stage는 정체성, 경험, 도움 중 어디까지 연습했는지를, phase는 환영, 답변, 모델 대기, 피드백 확인, 요약 읽기 중 무엇인지를 나타낸다. 음성 수음은 별도로 voicePanel로 제어한다. 일반 답변을 처리한 뒤 페이지는 먼저 review로 들어가 사용자 확인을 받고, 그다음 다음 질문을 정식 질문으로 바꾼다. 이는 “이 문장 다시 말하기”를 위한 자리도 마련해 준다.
질문과 조작의 바인딩은 page에서 바로 볼 수 있으며, 아래에는 답변 영역과 버튼 반복을 남겨 둔다.
< page > < view ink:if = "{{!paused && phase === 'answer'}}" > < text > {{goalTitle}} </ text > < text > {{question}} </ text > </ view > < view ink:for = "{{actions}}" ink:key = "key" data-key = "{{item.key}}" bindtap = "onAction" > < text > {{item.label}} </ text > </ view > </ page >
버튼은 자신의 key를 onAction에 넘기고, 다시 _act로 전달한다. 페이지 상태를 전환할 때 _refresh가 actions를 다시 생성하므로, 음성 수음 중에는 “说完了”, 피드백 중에는 “说下一句”, 요약 중에는 “下一页”가 표시된다. 인터페이스 문구와 버튼 선택이 같은 데이터를 사용해 양쪽 조작이 어긋나는 문제를 줄인다.
3. 음성과 버튼 연결
홈페이지에서는 먼저 청중과 용도를 말할 수 있고, 바로 시작할 수도 있다. 연습 페이지에는 “语音回答”, “给点提示” 등의 조작만 남긴다. 안경 다리를 밀면 선택지가 전환되고, 한 번 클릭하면 확인, 두 번 클릭하면 일시정지 또는 계속이다.
음성 인식은 플랫폼이 제공하는 SpeechRecognition을 사용한다. 인식 인스턴스를 만든 뒤 중국어, 단일 인식, 중간 결과를 설정하고, 중간 텍스트는 표시만 하며 최종 결과만 연습에 들어간다. 아래는 결과 처리의 주요 분기를 추린 것으로, active와 commit이 각각 현재 세션 검사, 제출 및 정리를 맡는다:
import { SpeechRecognition } from 'speech' ; const recognition = new SpeechRecognition (); recognition. lang = 'zh-CN' ; recognition. continuous = false ; recognition. interimResults = true ; recognition. onresult = e => { if (! active ()) return ; let finalText = '' , interim = '' ; for ( let i = 0 ; i < (e. results || []). length ; i++) { const r = e. results [i], value = r && r [ 0 ] && r [ 0 ]. transcript ; if (!value) continue ; if (r. isFinal ) finalText += value; else interim += value; } this . _voiceFinal = finalText; this . _voiceInterim = interim; this . setData ({ speechText : cut (finalText + interim). slice (- 52 ) }); if (finalText && !interim) commit (finalText); }; recognition. start ();
commit은 먼저 인식 인스턴스를 해제한 뒤 _route를 호출해 텍스트를 처리한다. onend로도 종료 통지가 올 수 있으므로 현재 인스턴스가 여전히 유효한지 확인해 같은 문장이 두 번 제출되는 것을 막아야 한다. 페이지를 떠날 때도 마찬가지로 콜백을 해제하고 세션을 반납한다.
이 부분에서 한 번 삽질했다. 능동적으로 수신을 취소해도 '음성 일시 사용 불가'가 표시되고, 이전 세션의 오류 콜백이 새 상태를 덮어썼다. 고칠 때는 먼저 이전 인스턴스의 콜백을 비우고 나서 abort했으며, 각 콜백은 자신이 여전히 현재 인스턴스인지 검사한다. 능동 취소는 정상적으로 반환되고, 권한, 마이크, 네트워크 오류는 각각 안내한다.
인식된 텍스트가 _route로 들어오면 먼저 '일시정지', '계속', '다시 말하기' 등의 명령을 판별하고, 그다음 연습 방식 조정과 일반 답변을 구분한다. 버튼 진입점도 최종적으로 같은 조작 세트를 호출해 음성과 안경 다리(템플) 쪽에 두 벌의 연습 로직이 유지되는 것을 피한다.
수신 화면은 voicePanel로 따로 전환하고, voiceState는 켜짐, 듣는 중, 최종 확인 대기, 재시도를 구분한다. 사용자가 '말 다 했어요'를 클릭하면 stop을 호출해 계속 최종 텍스트를 기다리고, 취소하면 콜백을 해제하고 abort한다.
안경 다리 이벤트는 Enter, ArrowUp, ArrowDown, Backspace로 매핑된다. onKeyDown은 기본 동작을 막고, onKeyUp에서 선택이나 확인을 실행한다. 같은 키가 180밀리초 안에 반복 도달하면 무시된다. 현재 선택 항목은 인덱스만 저장하고, 확인 시 actions에서 key를 읽어 _act로 넘겨 분배한다.
4. 컨텍스트를 직접 관리하기
매 요청마다 모델 세션을 만들고, 이번 턴의 기억은 페이지에서 전달한다. 대화 기록은 _turns에 저장하고, 각 답변에는 당시의 목표와 질문을 붙인다. 유효 사실과 이력은 각각 조립한다:
_facts ( ) { return this . _turns . filter ( t => t. role === 'user' && t. kind === 'answer' && !t. superseded ) . map ( t => ({ turnId : t. turnId , stage : t. stage , question : t. question , answer : t. text })); }, _context ( intent, text ) { return { intent, userInput : cut (text), currentStage : this . data . stage , currentQuestion : this . data . question , currentMode : this . data . mode , audience : this . data . audience , focus : this . data . focus , followups : this . _followups . slice (), validUserFacts : this . _facts (), history : this . _turns . filter ( t => !t . superseded ). slice (- 24 ) }; }
여기서는 모든 유효 답변을 보존하고, 이력은 최근 24개의 비작폐 대화를 취한다. cut은 프로젝트 안의 텍스트 정리 함수로, 문자열로 변환하고 앞뒤 공백을 제거하며 길이를 제한하는 역할을 한다. 질문과 답변을 쌍으로 저장해야 나중에 '이 표'가 대체 어떤 질문에 응답한 것인지 알 수 있다.
두 재료는 각기 용도가 있다. validUserFacts는 초안 작성에 쓸 수 있는 사용자 원문을 주고, history는 모델이 문답 경과와 연습 방식 변화를 이해하도록 돕는다. 시스템 프롬프트는 이력과 사용자 텍스트를 분석 대상 자료로 명확히 규정해, 그 안에서 경험을 꾸며내거나 규칙을 바꾸라는 지시를 실행할 수 없게 한다. 새 답변이 들어오면 _ask가 먼저 이를 기록에 쓰고 나서 컨텍스트를 조립하기 때문에, 지금 이 문장도 이번 요청에 포함된다.
'너무 어려워요'는 adjust로 기록하고, 어시스턴트의 시범은 assistant로 기록하며, 둘 다 validUserFacts에 들어가지 않는다. 사용자가 다시 말하면 원래 답변과 그에 대응하는 피드백을 superseded로 표시하고 다른 답변은 보존한다. 처음부터 다시 시작하면 이번 턴을 비운다. 이렇게 하면 연습 방식을 조정할 수 있을 뿐 아니라 요약이 어떤 자료를 사용했는지도 확인할 수 있다.
작폐에는 표시를 쓰지 원래 기록을 곧바로 삭제하지 않는다. 대응하는 어시스턴트 피드백은 parentTurnId로 답변에 연결되고, 다시 말할 때 함께 제외된다. 디버깅 시에는 원래 무엇을 말했는지 여전히 추적할 수 있고, 정상 생성 시에는 작폐되지 않은 항목만 읽는다.
커뮤니티 도서 코너로 인물 샤오저우를 테스트해 검증했다. 먼저 대출·반납 등록과 신입 안내를 맡는다고 설명하자, 모델이 이어서 신입을 안내한 경험을 물었다.
그다음 “신입들이 반납한 책을 자주 잘못된 위치에 놓아서, 내가 분류 위치 대조표를 하나 만들었다”고 덧붙이자, 다음 질문이 이렇게 바뀌었다. “신입들이 이 표를 쓴 뒤 반납 도서의 정확도나 효율에 구체적으로 어떤 변화가 있었나요?”
5. 모델을 조정한 뒤 다시 검증
호출은 내장 LanguageModel을 사용한다. 시스템 프롬프트는 매번 정보 격차를 하나만 찾고, question, feedback, action, stage, mode, hint 등의 필드를 포함한 JSON을 출력하도록 요구한다. 구체적인 질문은 모델이 생성하고, 프로그램이 다시 적용 가능한지 검사한다.
요청은 의도에 따라 구분한다. start는 오프닝을 생성하고, answer는 답변을 처리하며, adjust는 힌트, 어려움, 시간 변화를 처리한다. 똑같은 “예를 하나 들어 보세요”라는 말도 서로 다른 경험 뒤에서는 서로 다른 세부 사항으로 귀결된다. 요청에는 현재 목표를 담고, 시스템 프롬프트는 모델이 연습법 조정 이유를 설명하도록 요구하며, 페이지는 modeReason으로 이번에 왜 연습법을 바꿨는지 보여 준다.
연습 호출의 핵심은 아래 한 단락으로 요약할 수 있다. 전체 구현에는 타임아웃, 요청 번호, 로그도 포함된다:
import { LanguageModel } from 'language-model' ; if ( await LanguageModel . availability () !== 'available' ) { throw new Error ( '모델 현재 사용 불가' ); } const session = await LanguageModel . create ({ initialPrompts : [{ role : 'system' , content : COACH_SYSTEM }] }); try { const raw = await session. prompt ( JSON . stringify(job. payload )); const result = coachResult (raw, job. payload ); this . _applyCoach (result, job); this . _refresh (); } finally { session . destroy (); }
coachResult는 먼저 JSON을 파싱한 뒤 동작, 목표 범위, 질문, 연습 모드를 검사한다. followup은 현재 목표에 머물고, advance는 인접 목표로만 진입할 수 있으며, finish는 이미 마지막 목표에 도달했고 유효한 답변이 최소 세 개 있어야 한다. 통과하면 _applyCoach가 피드백과 다음 질문을 페이지에 기록한다.
question이 비어 있거나, mode가 허용 범위에 없거나, 난이도를 낮추면서 frame이 없으면 역시 무효로 판정된다. 인터페이스는 검증된 결과만 받아들인다. 일반 답변은 먼저 피드백과 다음 질문을 표시하고, start나 adjust는 현재 질문을 바로 갱신한다. 여기서 JSON은 페이지와 모델 사이의 약속 역할을 하며, 구체적 내용은 여전히 이번 생성에서 나온다.
이런 제한은 “너무 어려워요”와 “2분밖에 없어요”에도 적용된다. 난이도 낮추기는 현재 목표를 유지해야 하고, 간소화는 인접 목표로 진입할 수 있다. 실제 검증에서의 이 부분은 다음과 같다:
const stay = x. action === 'followup' && stage === payload. currentStage ; const shorten = x. mode === 'compact' && x. action === 'advance' && stage === payload. currentStage + 1 ; if (!stay && !shorten) { throw new Error ( '힌트 또는 난이도 낮추기는 원래 목표에 머물러야 하며, 간소화 모드는 인접 목표로 진입할 수 있습니다' ); }
나는 처음에 adjust를 전부 원래 목표에 제한했더니 합리적인 간소화 배치까지 막혔다. 판단을 분리한 뒤 “너무 어려워요”는 대조표를 중심으로 한 문장 보완 패턴을 반환했다. “이 표가 있으면, 신입은 ______(반응/말), ______(상태 변화).”
다시 “2분밖에 없어요”라고 하자 모델은 compact로 전환해 새 자원봉사자에게 가장 큰 도움이 되는 것이 무엇인지 한 문장으로 요약하라고 요구했다. 이는 연습을 단축하라는 요구를 표현한 것이지, 카운트다운으로 처리한 것이 아니다.
문장 패턴에 나온 피드백은 힌트로만 쓸 수 있다. 시스템은 모델이 사용자가 제공한 사실에만 근거하도록 요구하며, 통계를 내지 않은 정확도, 효율, 백분율은 완성된 결과물에 보태서는 안 된다.
6. 요약을 별도로 생성
사용자가 답변을 완료하거나 주도적으로 요약을 요청하면, _finish가 독립적인 작업을 만들어 다시 같은 실행 함수에 전달한다:
const job = { kind : 'summary' , payload : this . _context ( 'summary' , '이번 라운드의 유효한 답변을 종합해 요약과 연습 가능한 자기소개를 생성하세요.' ) }; this . _lastJob = job; return this . _execute (job);
_execute는 kind에 따라 SUMMARY_SYSTEM을 선택하고 summaryResult로 반환값을 검증한다. 요약은 overview, strengths, improvements, introduction, nextPractice가 모두 갖춰져야 하며, 장점에는 사실적 근거가 있어야 하고, 개선점에는 실행 가능한 제안이 있어야 한다.
이번 라운드에 아직 유효한 답변이 없으면 _finish가 먼저 막는다. 실제로 요약을 보낼 때 입력은 여전히 라운드 전체 맥락이고, 시스템 요구는 라운드 전체 정리 작업으로 바뀐다. 장점 배열의 point와 evidence에는 각각 결론과 근거가 저장되고, 개선 배열의 point와 suggestion에는 각각 문제와 제안이 저장되며, 페이지는 필드에 따라 읽기 순서를 구성할 수 있다.
자원봉사자는 마지막에 “대조표 덕분에 신입이 스스로 반납 위치를 찾을 수 있어서, 나에게 거듭 묻지 않아도 된다. 나는 모두와 함께 첫 번째 당직을 완수하고 싶다”고 덧붙였다. 생성된 소개는 책임, 방식, 도움을 이어 붙였고, 끝에 “나의 목표는”을 남겨 두었으며, 바람을 이미 이루어 낸 성과로 쓰지 않았다.
인터페이스는 전체 판단, 소개 완성본, 다음 연습법을 그룹으로 나누고, 다시 글자 길이에 따라 페이지를 나눈다. 이 묶음은 마침 세 페이지였고, 다른 라운드에서는 달라진다. 한때 인터페이스에는 이미 결과가 있는데 페이지에는 제목만 있는 상황을 겪었고, 마지막에 목록 컨테이너写法를 수정하고 다시 페이지별로 내용을 점검했다.
구조화 출력도 실패할 수 있다. 프로그램은 최대 두 번 시도한다. 형식이나 필드가 요구에 맞지 않으면 오류와 이전 원본 출력을 원래 맥락과 함께 모델에 넘겨 한 번 수정한다. 그래도 실패하면 답변과 재시도 진입점을 남긴다. 연습 대기 상한은 35초, 요약은 45초로, 인터페이스가 처리 중 상태에 무한히 머물지 않게 한다.
두 번째 요청에는 outputRepair가 추가되며, 구체적인 오류와 잘라낸 지난 출력을 첨부하고 여전히 동일한 실제 컨텍스트를 사용하도록 요구한다. 모델을 사용할 수 없거나 대기 시간이 초과되면 이번 시도는 곧바로 종료된다. 대기는 Promise.race로 모델 반환과 타임아웃 작업을 비교하고, finally에서 타이머를 지우고 세션을 destroy하며, 성공과 실패 모두 정리 절차를 거친다. 이 분기들은 모두 기록을 남기며, 재시도할 때는 원래 작업을 계속 사용하고 사용자에게 경험 전체를 다시 말하도록 요구하지 않는다.
7. 테스트와 출시
코드를 저장한 후 '시뮬레이션 디버깅'에 들어가 한 라운드를 완주한다. 질문에 답하고, 난이도를 낮추고, 연습을 짧게 줄이고, 마지막으로 요약을 생성한다. 다시 다른 경험 세트로 바꿔서 되묻기와 완성본이 내용에 따라 달라지는지 확인하고, 안경 다리 클릭, 슬라이드, 일시정지가 정상인지 점검한다.
시뮬레이터에서 정상 작동한 후, 먼저 '실기기 디버깅' 페이지에서 기본 정보를 입력하고 AIX로 패키징한 다음, '심사 제출' 페이지에서 소개를 입력하고 스크린샷과 시연 영상을 업로드해 심사를 제출한다. 현재 AIUI Studio에서 내 '설명하기' 에이전트의 상태는 이미 '출시됨'으로 표시되어 있다.
이번에는 플랫폼에서 프로젝트를 생성하고, 음성을 연결하고, 컨텍스트를 구성하고, 모델의 되묻기와 독립적인 요약까지, 한 라운드 전체를 연습할 수 있는 자기소개 코치를 완성했다. 사용자가 말한 흩어진 경험은 되묻기를 통해 보충되고, 마지막에는 계속 연습할 수 있는 하나의 소개로 정리된다. 이것이 내가 만들어 내고 싶었던 결과다.
여러분의 사용을 환영합니다.
努力的小雨
공식 계정 |【小雨AI验货局】
335
글
358k
읽음
656
팬