feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
433
apps/mobile-rn/src/features/commands/command-service.ts
Normal file
433
apps/mobile-rn/src/features/commands/command-service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue