Files
inkling/src/main/services/SettingsService.ts

166 lines
5.8 KiB
TypeScript

import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { z } from 'zod';
const OllamaSettingsSchema = z.object({
endpoint: z.string().url(),
model: z.string().min(1)
}).strict();
const SettingsSchema = z.object({
ollama: OllamaSettingsSchema.optional(),
// v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가
// true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 —
// load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동.
ai_enabled: z.boolean().optional(),
onboarding_completed: z.boolean().optional(),
// v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성.
sync_repo_url: z.string().nullable().optional(),
sync_auto_enabled: z.boolean().optional(),
sync_interval_min: z.number().int().min(5).optional(),
// v0.3.1 Cut F
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;
export class SettingsService {
private filePath: string;
private cache: Settings | null = null;
constructor(profileDir: string) {
this.filePath = join(profileDir, 'settings.json');
}
async load(): Promise<Settings> {
if (this.cache !== null) return this.cache;
try {
const raw = await readFile(this.filePath, 'utf8');
const parsed = JSON.parse(raw);
this.cache = SettingsSchema.parse(parsed);
} catch {
this.cache = {};
}
return this.cache;
}
/**
* v0.2.9 Cut B Task 12 — settings:get IPC 핸들러용 read-only accessor.
* 첫 launch onboarding 분기에서 onboarding_completed 키 확인.
*/
async getAll(): Promise<Settings> {
return this.load();
}
async setOllama(value: OllamaSettings): Promise<void> {
const validated = OllamaSettingsSchema.parse(value);
const current = await this.load();
const next: Settings = { ...current, ollama: validated };
await this.persist(next);
}
/**
* v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을
* 가진 사용자 (ai_enabled 키 부재) 도 무영향.
*/
async isAiEnabled(): Promise<boolean> {
const s = await this.load();
return s.ai_enabled ?? true;
}
async setAiEnabled(value: boolean): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, ai_enabled: value };
await this.persist(next);
}
/** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */
async isOnboardingCompleted(): Promise<boolean> {
const s = await this.load();
return s.onboarding_completed ?? false;
}
async setOnboardingCompleted(value: boolean): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, onboarding_completed: value };
await this.persist(next);
}
/**
* v0.3.0 Cut E — sync 저장소 URL. null/빈 문자열 = sync 비활성. 본 메서드는 값만 저장,
* git init/remote add 는 별도 호출자 (settings:configure-sync IPC) 가 담당.
*/
async getSyncRepoUrl(): Promise<string | null> {
const s = await this.load();
return s.sync_repo_url ?? null;
}
async setSyncRepoUrl(value: string | null): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, sync_repo_url: value };
await this.persist(next);
}
/** v0.3.0 Cut E — 자동 주기 sync 활성. configured 일 때만 의미 있음. 기본 true. */
async isAutoSyncEnabled(): Promise<boolean> {
const s = await this.load();
return s.sync_auto_enabled ?? true;
}
async setAutoSyncEnabled(value: boolean): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, sync_auto_enabled: value };
await this.persist(next);
}
/** v0.3.0 Cut E — 자동 주기 sync interval (분). 기본 30, min 5. */
async getSyncIntervalMin(): Promise<number> {
const s = await this.load();
return s.sync_interval_min ?? 30;
}
async setSyncIntervalMin(value: number): Promise<void> {
if (!Number.isInteger(value) || value < 5) {
throw new Error(`sync_interval_min must be an integer >= 5 (got ${value})`);
}
const current = await this.load();
const next: Settings = { ...current, sync_interval_min: value };
await this.persist(next);
}
/** v0.3.1 Cut F — 선택된 vision model. null = 미선택. */
async getVisionModel(): Promise<string | null> {
const s = await this.load();
return s.vision_model ?? null;
}
async setVisionModel(value: string | null): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_model: value };
await this.persist(next);
}
/** v0.3.1 Cut F — /api/tags 조회 결과 캐시. 기본 빈 배열 + null timestamp. */
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
const s = await this.load();
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
}
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
await this.persist(next);
}
private async persist(next: Settings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');
await rename(tmpPath, this.filePath);
this.cache = next;
}
}