feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,433 @@
import {
ChatServiceError,
sendReportableCommandMessage,
type ChatErrorCode,
type ReportableChatResult,
} from '../chat/chat-service'
import { supabase } from '../../lib/supabase'
export type BuiltinCommandId =
| 'builtin-translate-en'
| 'builtin-summarize'
| 'builtin-formal'
| 'builtin-explain-code'
export interface BuiltinCommand {
id: BuiltinCommandId
nameKey:
| 'mobile.commands.translate.name'
| 'mobile.commands.summarize.name'
| 'mobile.commands.formal.name'
| 'mobile.commands.explain.name'
descriptionKey:
| 'mobile.commands.translate.description'
| 'mobile.commands.summarize.description'
| 'mobile.commands.formal.description'
| 'mobile.commands.explain.description'
prompt: string
}
export const BUILTIN_COMMANDS: readonly BuiltinCommand[] = Object.freeze([
Object.freeze({
id: 'builtin-translate-en' as const,
nameKey: 'mobile.commands.translate.name' as const,
descriptionKey: 'mobile.commands.translate.description' as const,
prompt: 'Translate the following text into natural English. Return only the translation.\n\n{{text}}',
}),
Object.freeze({
id: 'builtin-summarize' as const,
nameKey: 'mobile.commands.summarize.name' as const,
descriptionKey: 'mobile.commands.summarize.description' as const,
prompt: '다음 텍스트의 핵심 내용을 3줄 이내로 요약하세요. 요약문만 출력하세요.\n\n{{text}}',
}),
Object.freeze({
id: 'builtin-formal' as const,
nameKey: 'mobile.commands.formal.name' as const,
descriptionKey: 'mobile.commands.formal.description' as const,
prompt: '다음 텍스트를 원래 의미를 유지한 격식 있는 비즈니스 문체로 다시 작성하세요. 결과만 출력하세요.\n\n{{text}}',
}),
Object.freeze({
id: 'builtin-explain-code' as const,
nameKey: 'mobile.commands.explain.name' as const,
descriptionKey: 'mobile.commands.explain.description' as const,
prompt: '다음 코드를 한국어로 간결하게 설명하세요. 각 부분의 역할과 주의점을 포함하세요.\n\n{{text}}',
}),
])
export type CommandExecutionErrorCode = ChatErrorCode | 'COMMAND_NOT_FOUND'
export class CommandExecutionError extends Error {
constructor(
public readonly code: CommandExecutionErrorCode,
public readonly retryable: boolean,
public readonly status: number | null = null,
) {
super(code)
this.name = 'CommandExecutionError'
}
}
export interface ExecuteBuiltinCommandOptions {
accessToken: string
model?: string | null
signal?: AbortSignal
timeoutMs?: number
}
export type CommandExecutionResult = ReportableChatResult
export const COMMAND_INPUT_MAX_CHARS = 7_500
export function getBuiltinCommand(commandId: string): BuiltinCommand | null {
return BUILTIN_COMMANDS.find((command) => command.id === commandId) ?? null
}
export function buildBuiltinCommandPrompt(
commandId: string,
input: string,
): string {
const command = getBuiltinCommand(commandId)
if (command === null) {
throw new CommandExecutionError('COMMAND_NOT_FOUND', false)
}
const normalizedInput = input.trim()
if (normalizedInput.length === 0 || normalizedInput.length > COMMAND_INPUT_MAX_CHARS) {
throw new CommandExecutionError('INVALID_REQUEST', false)
}
const prompt = command.prompt.replace('{{text}}', normalizedInput)
if (prompt.length > 8_000) {
throw new CommandExecutionError('INVALID_REQUEST', false)
}
return prompt
}
export async function executeBuiltinCommand(
commandId: string,
input: string,
options: ExecuteBuiltinCommandOptions,
): Promise<CommandExecutionResult> {
const prompt = buildBuiltinCommandPrompt(commandId, input)
try {
return await sendReportableCommandMessage(
[{ role: 'user', content: prompt }],
{
accessToken: options.accessToken,
model: options.model,
signal: options.signal,
timeoutMs: options.timeoutMs,
},
)
} catch (error) {
if (error instanceof ChatServiceError) {
throw new CommandExecutionError(error.code, error.retryable, error.status)
}
throw new CommandExecutionError('SERVER_ERROR', true)
}
}
export type CommandBuiltinKey =
| 'translate_en'
| 'summarize'
| 'formal'
| 'explain_code'
export interface SyncedCommand {
id: string
userId: string
builtinKey: CommandBuiltinKey | null
name: string
description: string
prompt: string
icon: string
sortOrder: number
revision: number
createdAt: string
updatedAt: string
}
export interface SyncedCommandCatalog {
commands: SyncedCommand[]
activeInstructionId: string | null
}
export type CommandSyncErrorCode =
| 'auth'
| 'validation'
| 'duplicate'
| 'conflict'
| 'not-found'
| 'server'
export class CommandSyncError extends Error {
constructor(readonly code: CommandSyncErrorCode) {
super(code)
this.name = 'CommandSyncError'
}
}
const COMMAND_COLUMNS = [
'id', 'user_id', 'builtin_key', 'name', 'description', 'prompt', 'icon',
'sort_order', 'revision', 'created_at', 'updated_at',
].join(',')
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const BUILTIN_KEYS = new Set<CommandBuiltinKey>([
'translate_en', 'summarize', 'formal', 'explain_code',
])
function record(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new CommandSyncError('server')
}
return value as Record<string, unknown>
}
function timestamp(value: unknown): string {
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
throw new CommandSyncError('server')
}
return new Date(value).toISOString()
}
export function normalizeSyncedCommand(value: unknown, expectedUserId?: string): SyncedCommand {
const row = record(value)
const builtinKey = row.builtin_key === null
? null
: typeof row.builtin_key === 'string' && BUILTIN_KEYS.has(row.builtin_key as CommandBuiltinKey)
? row.builtin_key as CommandBuiltinKey
: undefined
if (
typeof row.id !== 'string'
|| !UUID_PATTERN.test(row.id)
|| typeof row.user_id !== 'string'
|| !UUID_PATTERN.test(row.user_id)
|| (expectedUserId !== undefined && row.user_id !== expectedUserId)
|| builtinKey === undefined
|| typeof row.name !== 'string'
|| row.name.trim().length < 1
|| row.name.length > 80
|| typeof row.description !== 'string'
|| row.description.length > 240
|| typeof row.prompt !== 'string'
|| row.prompt.trim().length < 1
|| row.prompt.length > 4_000
|| typeof row.icon !== 'string'
|| row.icon.length < 1
|| row.icon.length > 32
|| typeof row.sort_order !== 'number'
|| !Number.isInteger(row.sort_order)
|| row.sort_order < 0
|| typeof row.revision !== 'number'
|| !Number.isSafeInteger(row.revision)
|| row.revision < 1
) throw new CommandSyncError('server')
return {
id: row.id,
userId: row.user_id,
builtinKey,
name: row.name.trim(),
description: row.description.trim(),
prompt: row.prompt.trim(),
icon: row.icon,
sortOrder: row.sort_order,
revision: row.revision,
createdAt: timestamp(row.created_at),
updatedAt: timestamp(row.updated_at),
}
}
function requireUserId(userId: string): void {
if (!UUID_PATTERN.test(userId)) throw new CommandSyncError('auth')
}
function validateCustomFields(input: {
name: string
description: string
prompt: string
}): { name: string; description: string; prompt: string } {
const name = input.name.trim()
const description = input.description.trim()
const prompt = input.prompt.trim()
if (
name.length < 1
|| name.length > 80
|| description.length > 240
|| prompt.length < 1
|| prompt.length > 4_000
) throw new CommandSyncError('validation')
return { name, description, prompt }
}
function mapWriteError(error: { code?: string } | null): CommandSyncError {
if (error?.code === '23505') return new CommandSyncError('duplicate')
if (error?.code === '42501') return new CommandSyncError('auth')
return new CommandSyncError('server')
}
export async function loadSyncedCommandCatalog(userId: string): Promise<SyncedCommandCatalog> {
requireUserId(userId)
const bootstrap = await supabase.rpc('bootstrap_custom_instructions')
if (bootstrap.error || !Array.isArray(bootstrap.data)) throw mapWriteError(bootstrap.error)
const commands = bootstrap.data
.map((value) => normalizeSyncedCommand(value, userId))
.sort((left, right) => left.sortOrder - right.sortOrder || left.createdAt.localeCompare(right.createdAt))
const settings = await supabase
.from('user_settings')
.select('active_instruction_id')
.eq('user_id', userId)
.maybeSingle()
if (settings.error) throw mapWriteError(settings.error)
const active = settings.data?.active_instruction_id
if (active !== null && active !== undefined && (typeof active !== 'string' || !UUID_PATTERN.test(active))) {
throw new CommandSyncError('server')
}
return { commands, activeInstructionId: active ?? null }
}
export async function createSyncedCommand(
userId: string,
input: { name: string; description: string; prompt: string; sortOrder: number },
): Promise<SyncedCommand> {
requireUserId(userId)
const fields = validateCustomFields(input)
if (!Number.isInteger(input.sortOrder) || input.sortOrder < 0) {
throw new CommandSyncError('validation')
}
const result = await supabase
.from('custom_instructions')
.insert({
user_id: userId,
...fields,
icon: 'sparkles',
sort_order: input.sortOrder,
})
.select(COMMAND_COLUMNS)
.single()
if (result.error || !result.data) throw mapWriteError(result.error)
return normalizeSyncedCommand(result.data, userId)
}
export async function updateSyncedCommand(
userId: string,
command: SyncedCommand,
input: { name: string; description: string; prompt: string; sortOrder?: number },
): Promise<SyncedCommand> {
requireUserId(userId)
if (command.userId !== userId || command.builtinKey !== null) {
throw new CommandSyncError('auth')
}
const fields = validateCustomFields(input)
if (input.sortOrder !== undefined && (!Number.isInteger(input.sortOrder) || input.sortOrder < 0)) {
throw new CommandSyncError('validation')
}
const result = await supabase
.from('custom_instructions')
.update({
...fields,
...(input.sortOrder === undefined ? {} : { sort_order: input.sortOrder }),
})
.eq('user_id', userId)
.eq('id', command.id)
.eq('revision', command.revision)
.is('builtin_key', null)
.select(COMMAND_COLUMNS)
.maybeSingle()
if (result.error) throw mapWriteError(result.error)
if (!result.data) throw new CommandSyncError('conflict')
return normalizeSyncedCommand(result.data, userId)
}
export async function deleteSyncedCommand(
userId: string,
command: SyncedCommand,
): Promise<void> {
requireUserId(userId)
if (command.userId !== userId || command.builtinKey !== null) {
throw new CommandSyncError('auth')
}
const result = await supabase
.from('custom_instructions')
.delete()
.eq('user_id', userId)
.eq('id', command.id)
.eq('revision', command.revision)
.is('builtin_key', null)
.select('id')
.maybeSingle()
if (result.error) throw mapWriteError(result.error)
if (!result.data) throw new CommandSyncError('conflict')
}
export async function setActiveSyncedCommand(
userId: string,
instructionId: string | null,
): Promise<string | null> {
requireUserId(userId)
if (instructionId !== null && !UUID_PATTERN.test(instructionId)) {
throw new CommandSyncError('validation')
}
const result = await supabase.rpc('set_active_custom_instruction', {
instruction_id: instructionId,
})
if (result.error || !result.data) throw mapWriteError(result.error)
const row = record(result.data)
if (row.user_id !== userId) throw new CommandSyncError('auth')
const active = row.active_instruction_id
if (active !== null && (typeof active !== 'string' || !UUID_PATTERN.test(active))) {
throw new CommandSyncError('server')
}
return active
}
export async function reorderSyncedCommand(
userId: string,
command: SyncedCommand,
direction: 'up' | 'down',
): Promise<SyncedCommand[]> {
requireUserId(userId)
if (command.userId !== userId || command.builtinKey !== null) {
throw new CommandSyncError('auth')
}
const result = await supabase.rpc('reorder_custom_instruction', {
instruction_id: command.id,
direction,
})
if (result.error || !Array.isArray(result.data)) throw mapWriteError(result.error)
return result.data.map((value) => normalizeSyncedCommand(value, userId))
}
export function buildSyncedCommandPrompt(command: SyncedCommand, input: string): string {
const normalizedInput = input.trim()
if (normalizedInput.length < 1 || normalizedInput.length > COMMAND_INPUT_MAX_CHARS) {
throw new CommandExecutionError('INVALID_REQUEST', false)
}
const prompt = command.prompt.includes('{{text}}')
? command.prompt.replace('{{text}}', normalizedInput)
: `${command.prompt}\n\n${normalizedInput}`
if (prompt.length > 8_000) throw new CommandExecutionError('INVALID_REQUEST', false)
return prompt
}
export async function executeSyncedCommand(
command: SyncedCommand,
input: string,
options: ExecuteBuiltinCommandOptions,
): Promise<CommandExecutionResult> {
const prompt = buildSyncedCommandPrompt(command, input)
try {
return await sendReportableCommandMessage(
[{ role: 'user', content: prompt }],
{
accessToken: options.accessToken,
model: options.model,
signal: options.signal,
timeoutMs: options.timeoutMs,
},
)
} catch (error) {
if (error instanceof ChatServiceError) {
throw new CommandExecutionError(error.code, error.retryable, error.status)
}
throw new CommandExecutionError('SERVER_ERROR', true)
}
}