fix: route obsidian plugin through grammar proxy

This commit is contained in:
hyungyunlim 2026-05-20 18:17:08 +09:00
parent 537a1e2acf
commit 0d1ff5d1bc
12 changed files with 204 additions and 47 deletions

View file

@ -349,8 +349,8 @@ export class TokenEstimator {
### Bareun.ai API 설정
```typescript
const DEFAULT_SETTINGS: PluginSettings = {
apiKey: 'koba-UFZFDYA-KWREC5Q-QE44PDQ-HANYC2A',
apiHost: 'bareun-api.junlim.org',
apiKey: '',
apiHost: 'appstore.junlim.org',
apiPort: 443,
ignoredWords: []
};

View file

@ -27,13 +27,13 @@ The plugin does not include telemetry, analytics, tracking, or a remote plugin a
### Requirements
1. A Bareun.ai API key is required for the primary Korean spelling and grammar analysis.
1. The default grammar check uses the app proxy at `appstore.junlim.org`, so a Bareun.ai API key is not required unless you configure a direct Bareun.ai server.
2. An AI provider key or local Ollama endpoint is optional and only needed for AI-assisted suggestions.
3. Obsidian 1.4.0 or newer is required.
### Installation
When installed from the Obsidian Community Plugins directory, enable the plugin from Settings, then open the Korean Grammar Assistant settings tab and enter your Bareun.ai API key. Optional AI provider settings can be configured in the same settings tab.
When installed from the Obsidian Community Plugins directory, enable the plugin from Settings. Optional AI provider settings and direct Bareun.ai server settings can be configured in the Korean Grammar Assistant settings tab.
For manual installation, download `main.js`, `manifest.json`, and `styles.css` from the latest GitHub release and place them in:
@ -125,12 +125,12 @@ Bareun.ai와 다양한 AI 제공자를 활용한 Obsidian용 고급 한국어
## 🚀 빠른 시작
### 사전 요구사항
1. **Bareun.ai 계정**: 기본 문법 검사를 위해 [https://bareun.ai/](https://bareun.ai/)에서 가입
2. **API 키**: Bareun.ai 대시보드에서 개인 API 키 획득
1. **기본 문법 검사**: 기본값은 `appstore.junlim.org` 프록시를 사용하므로 별도 Bareun.ai API 키가 필요하지 않습니다.
2. **직접 Bareun.ai 서버 사용 시**: [https://bareun.ai/](https://bareun.ai/)에서 가입 후 개인 API 키를 설정합니다.
3. **(선택사항) AI 제공자 계정**: AI 기능을 위해 OpenAI, Anthropic, Google의 API 키 또는 실행 중인 Ollama 인스턴스 필요
## 🔐 네트워크 및 개인정보 공개
- **Bareun.ai API**: 맞춤법 분석을 위해 선택한 텍스트를 Bareun.ai 서버로 전송합니다. 응답 데이터는 로컬에서만 사용되며 저장되지 않습니다.
- **문법 검사 API**: 맞춤법 분석을 위해 선택한 텍스트를 기본 앱 서버 프록시 또는 사용자가 설정한 직접 Bareun.ai 서버로 전송합니다. 응답 데이터는 로컬에서만 사용되며 저장되지 않습니다.
- **AI 제공자 (선택)**: OpenAI, Anthropic, Google Gemini, Ollama 중 선택한 서비스로 오류 문맥을 전송하여 제안을 받습니다. 어떤 제공자를 사용할지는 설정에서 직접 제어할 수 있습니다.
- **추가 데이터 수집 없음**: 플러그인은 텔레메트리를 전송하지 않으며, 사용자의 노트나 자격 증명은 Obsidian 볼트 밖으로 저장하지 않습니다.
- **로컬 제어**: 모든 API 키는 사용자의 장치에만 보관되며, 네트워크 사용은 설정에서 언제든지 비활성화할 수 있습니다.
@ -152,7 +152,7 @@ Bareun.ai와 다양한 AI 제공자를 활용한 Obsidian용 고급 한국어
1. **플러그인 활성화**: `설정``커뮤니티 플러그인``Korean Grammar Assistant``활성화`로 이동
2. **API 설정 구성**: `설정``Korean Grammar Assistant`로 이동
- **Bareun.ai API 키 (필수)**: 개인 Bareun.ai API 키
- **Bareun.ai API 키 (선택)**: 직접 Bareun.ai 서버를 사용할 때만 입력
- **AI 제공자 (선택사항)**: 선호하는 AI 제공자를 선택하고 해당 API 키 또는 엔드포인트 입력
## 📱 사용법

View file

@ -1,6 +1,6 @@
{
"apiKey": "여기에-본인의-API-키를-입력하세요",
"apiHost": "bareun-api.junlim.org",
"apiKey": "",
"apiHost": "appstore.junlim.org",
"apiPort": 443,
"ignoredWords": []
}
}

View file

@ -1,7 +1,7 @@
{
"id": "korean-grammar-assistant",
"name": "Korean Grammar Assistant",
"version": "0.3.8",
"version": "0.3.9",
"minAppVersion": "1.4.0",
"description": "Korean grammar and spelling checker with real-time inline editing (BETA) and AI-powered suggestions. Features modular architecture and mobile optimization.",
"author": "hyungyunlim",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "korean-grammar-assistant",
"version": "0.3.8",
"version": "0.3.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "korean-grammar-assistant",
"version": "0.3.8",
"version": "0.3.9",
"license": "MIT",
"devDependencies": {
"@codemirror/state": "^6.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "korean-grammar-assistant",
"version": "0.3.8",
"version": "0.3.9",
"description": "Korean grammar and spelling checker for Obsidian with AI-powered suggestions. Features interactive corrections, performance optimization, and comprehensive settings management.",
"main": "main.js",
"scripts": {

View file

@ -2,7 +2,7 @@ import { App, Editor, EditorPosition, Notice, MarkdownView, TFile } from 'obsidi
import { PluginSettings, SpellCheckResult, Correction, MorphemeInfo, AIAnalysisResult } from './types/interfaces';
import { OptimizedSpellCheckService } from './services/optimizedApiService';
import type { MorphemeResponse } from './services/api';
import { SettingsService } from './services/settings';
import { DEFAULT_API_HOST, DEFAULT_API_PORT, SettingsService } from './services/settings';
import { IgnoredWordsService } from './services/ignoredWords';
import { CorrectionPopup } from './ui/correctionPopup';
import { AIAnalysisService } from './services/aiAnalysisService';
@ -578,8 +578,8 @@ export class WorkflowOrchestrator {
private getDefaultSettings(): PluginSettings {
return {
apiKey: '',
apiHost: 'bareun-api.junlim.org',
apiPort: 443,
apiHost: DEFAULT_API_HOST,
apiPort: DEFAULT_API_PORT,
ignoredWords: [],
ai: {
enabled: false,
@ -605,4 +605,4 @@ export class WorkflowOrchestrator {
}
};
}
}
}

View file

@ -1,6 +1,6 @@
import { Platform } from 'obsidian';
import { PluginSettings } from '../types/interfaces';
import { DEFAULT_INLINE_MODE_SETTINGS } from './settings';
import { DEFAULT_API_HOST, DEFAULT_API_PORT, DEFAULT_INLINE_MODE_SETTINGS, SettingsService } from './settings';
import { Logger } from '../utils/logger';
/**
@ -80,10 +80,10 @@ export class AdvancedSettingsService {
};
// API 키 검증
if (!settings.apiKey || settings.apiKey.trim() === '') {
if (!SettingsService.usesGrammarProxy(settings) && (!settings.apiKey || settings.apiKey.trim() === '')) {
result.errors.push('API 키가 설정되지 않았습니다');
result.isValid = false;
} else if (settings.apiKey.length < 10) {
} else if (settings.apiKey && settings.apiKey.length < 10) {
result.warnings.push('API 키가 너무 짧습니다. 올바른 키인지 확인해주세요');
}
@ -93,7 +93,8 @@ export class AdvancedSettingsService {
result.isValid = false;
} else {
try {
new URL(`https://${settings.apiHost}`);
const host = settings.apiHost.trim();
new URL(/^https?:\/\//i.test(host) ? host : `https://${host}`);
} catch {
result.errors.push('API 호스트 형식이 올바르지 않습니다');
result.isValid = false;
@ -356,8 +357,8 @@ export class AdvancedSettingsService {
// 기본 설정 반환 (실제 기본값들)
const defaultSettings: PluginSettings = {
apiKey: '',
apiHost: 'bareun-api.junlim.org',
apiPort: 443,
apiHost: DEFAULT_API_HOST,
apiPort: DEFAULT_API_PORT,
ignoredWords: [],
ai: {
enabled: false,
@ -544,4 +545,4 @@ export class AdvancedSettingsService {
if (minutes > 0) return `${minutes}분 전`;
return `${seconds}초 전`;
}
}
}

View file

@ -2,6 +2,7 @@ import { requestUrl } from 'obsidian';
import { PluginSettings, Correction, SpellCheckResult } from '../types/interfaces';
import { Logger } from '../utils/logger';
import { ErrorHandlerService } from './errorHandler';
import { SettingsService } from './settings';
/**
* Bareun.ai API
@ -27,6 +28,19 @@ interface BareunResponse {
}>;
}
interface GrammarProxyResponse {
success: boolean;
errors?: Array<{
orgStr: string;
candWord?: string[] | string;
message?: string;
help?: string;
}>;
original?: string;
corrected?: string;
error?: string;
}
/**
* ( API )
*/
@ -108,9 +122,12 @@ export class SpellCheckApiService {
* API .
*/
private async executeMorphemeRequest(text: string, settings: PluginSettings): Promise<MorphemeResponse> {
const protocol = settings.apiPort === 443 ? 'https' : 'http';
const port = (settings.apiPort === 443 || settings.apiPort === 80) ? '' : `:${settings.apiPort}`;
const apiUrl = `${protocol}://${settings.apiHost}${port}/bareun/api/v1/analyze`;
if (SettingsService.usesGrammarProxy(settings)) {
Logger.debug('형태소 분석 생략: 기본 프록시 API는 analyze 엔드포인트를 노출하지 않습니다.');
return { sentences: [], language: 'ko-KR' };
}
const apiUrl = SettingsService.buildBareunApiUrl(settings, 'analyze');
// REST API 문서에 따른 올바른 요청 형식
const requestBody = {
@ -222,14 +239,16 @@ export class SpellCheckApiService {
* @returns
*/
async checkSpelling(text: string, settings: PluginSettings): Promise<SpellCheckResult> {
if (SettingsService.usesGrammarProxy(settings)) {
return await this.checkSpellingViaProxy(text, settings);
}
// API 키 유효성 검사
if (!settings.apiKey || settings.apiKey.trim() === '') {
throw new Error("API 키가 설정되지 않았습니다. 플러그인 설정에서 Bareun.ai API 키를 입력해주세요.");
}
const protocol = settings.apiPort === 443 ? 'https' : 'http';
const port = (settings.apiPort === 443 || settings.apiPort === 80) ? '' : `:${settings.apiPort}`;
const apiUrl = `${protocol}://${settings.apiHost}${port}/bareun/api/v1/correct-error`;
const apiUrl = SettingsService.buildBareunApiUrl(settings, 'correct-error');
const requestBody = {
document: {
@ -267,6 +286,37 @@ export class SpellCheckApiService {
return this.parseBareunResults(data, text, settings);
}
private async checkSpellingViaProxy(text: string, settings: PluginSettings): Promise<SpellCheckResult> {
const apiUrl = SettingsService.buildApiUrl(settings);
const response = await this.requestWithTimeout(
requestUrl({
url: apiUrl,
method: 'POST',
body: JSON.stringify({ text }),
contentType: 'application/json',
throw: false
}),
15000,
'맞춤법 검사 요청 타임아웃 (15초)'
);
if (response.status < 200 || response.status >= 300) {
Logger.error('맞춤법 검사 프록시 API 오류:', {
status: response.status,
errorBody: response.text
});
throw new Error(`API 요청 실패: ${response.status}`);
}
const data: GrammarProxyResponse = (response.json as GrammarProxyResponse | undefined) ?? (JSON.parse(response.text || '{}') as GrammarProxyResponse);
if (!data.success) {
throw new Error(data.error || '맞춤법 검사 API 요청 실패');
}
return this.parseGrammarProxyResults(data, text, settings);
}
/**
* requestUrl
*/
@ -546,6 +596,54 @@ export class SpellCheckApiService {
return { resultOutput, corrections };
}
private parseGrammarProxyResults(data: GrammarProxyResponse, originalText: string, settings: PluginSettings): SpellCheckResult {
const resultOutput = data.corrected || data.original || originalText;
const correctionMap = new Map<string, Correction>();
(data.errors || []).forEach((error) => {
const original = error.orgStr;
if (!original || original.trim().length === 0) {
return;
}
const rawSuggestions = Array.isArray(error.candWord)
? error.candWord
: error.candWord
? [error.candWord]
: [];
const uniqueSuggestions = [...new Set(rawSuggestions)]
.filter((suggestion) => suggestion && suggestion !== original && !suggestion.includes('\uFFFD'));
const filteredSuggestions = this.applySingleCharFilter(
original,
uniqueSuggestions,
settings.filterSingleCharErrors
);
if (filteredSuggestions.length === 0) {
return;
}
const existing = correctionMap.get(original);
if (existing) {
existing.corrected = [...new Set([...existing.corrected, ...filteredSuggestions])];
return;
}
correctionMap.set(original, {
original,
corrected: filteredSuggestions,
help: error.help || error.message || '맞춤법 교정'
});
});
return {
resultOutput,
corrections: Array.from(correctionMap.values())
};
}
/**
* .
* @param text

View file

@ -1,6 +1,10 @@
import { PluginSettings, InlineModeSettings } from '../types/interfaces';
import { DEFAULT_AI_SETTINGS } from '../constants/aiModels';
export const DEFAULT_API_HOST = 'appstore.junlim.org';
export const LEGACY_PUBLIC_API_HOST = 'bareun-api.junlim.org';
export const DEFAULT_API_PORT = 443;
/**
*
*/
@ -21,9 +25,9 @@ export const DEFAULT_INLINE_MODE_SETTINGS: InlineModeSettings = {
*
*/
export const DEFAULT_SETTINGS: PluginSettings = {
apiKey: '', // 사용자가 직접 입력해야 함
apiHost: 'bareun-api.junlim.org',
apiPort: 443,
apiKey: '',
apiHost: DEFAULT_API_HOST,
apiPort: DEFAULT_API_PORT,
ignoredWords: [],
ai: DEFAULT_AI_SETTINGS,
filterSingleCharErrors: true, // 기본적으로 한 글자 오류 필터링 활성화
@ -42,7 +46,7 @@ export class SettingsService {
static validateSettings(settings: PluginSettings): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
if (!settings.apiKey || settings.apiKey.trim() === '') {
if (!this.usesGrammarProxy(settings) && (!settings.apiKey || settings.apiKey.trim() === '')) {
errors.push('API 키가 설정되지 않았습니다.');
}
@ -87,6 +91,11 @@ export class SettingsService {
if (userSettings.filterSingleCharErrors === undefined) {
mergedSettings.filterSingleCharErrors = DEFAULT_SETTINGS.filterSingleCharErrors;
}
if (this.isLegacyPublicApiHost(mergedSettings.apiHost)) {
mergedSettings.apiHost = DEFAULT_API_HOST;
mergedSettings.apiPort = DEFAULT_API_PORT;
}
return mergedSettings;
}
@ -97,8 +106,53 @@ export class SettingsService {
* @returns API URL
*/
static buildApiUrl(settings: PluginSettings): string {
const protocol = settings.apiPort === 443 ? 'https' : 'http';
const port = (settings.apiPort === 443 || settings.apiPort === 80) ? '' : `:${settings.apiPort}`;
return `${protocol}://${settings.apiHost}${port}/bareun/api/v1/correct-error`;
if (this.usesGrammarProxy(settings)) {
return `${this.buildBaseUrl(settings)}/api/grammar/check`;
}
return this.buildBareunApiUrl(settings, 'correct-error');
}
}
/**
* Bareun REST API URL을 .
*/
static buildBareunApiUrl(settings: PluginSettings, endpoint: string): string {
return `${this.buildBaseUrl(settings)}/bareun/api/v1/${endpoint}`;
}
/**
* API .
*/
static usesGrammarProxy(settings: PluginSettings): boolean {
return this.normalizeApiHost(settings.apiHost) === DEFAULT_API_HOST;
}
/**
* Bareun .
*/
static isLegacyPublicApiHost(apiHost: string): boolean {
return this.normalizeApiHost(apiHost) === LEGACY_PUBLIC_API_HOST;
}
private static buildBaseUrl(settings: PluginSettings): string {
const rawHost = (settings.apiHost || '').trim().replace(/\/+$/, '');
if (/^https?:\/\//i.test(rawHost)) {
return rawHost;
}
const protocol = settings.apiPort === 443 ? 'https' : 'http';
const hasPort = rawHost.includes(':');
const port = (settings.apiPort === 443 || settings.apiPort === 80 || hasPort) ? '' : `:${settings.apiPort}`;
return `${protocol}://${rawHost}${port}`;
}
private static normalizeApiHost(apiHost: string): string {
const withoutProtocol = (apiHost || '')
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/+$/, '');
return withoutProtocol.split('/')[0].split(':')[0].toLowerCase();
}
}

View file

@ -6,6 +6,7 @@ import { AIProvider } from '../types/interfaces';
import { createMetricsDisplay, clearElement } from '../utils/domUtils';
import { AdvancedSettingsService } from '../services/advancedSettingsService';
import { ErrorHandlerService, ErrorType } from '../services/errorHandler';
import { DEFAULT_API_HOST, SettingsService } from '../services/settings';
/**
* (AdvancedSettingsService.getBackups )
@ -324,10 +325,10 @@ export class ModernSettingsTab extends PluginSettingTab {
// API 키 설정
new Setting(settingsGroup)
.setName("Bareun.ai API 키")
.setDesc("맞춤법 검사를 위한 Bareun.ai API 키를 입력하세요.")
.setDesc("직접 Bareun.ai 서버를 사용할 때 필요한 API 키입니다. 기본 프록시 서버 사용 시 비워둘 수 있습니다.")
.addText((text) => {
text
.setPlaceholder("API 키를 입력하세요")
.setPlaceholder("직접 Bareun 서버 사용 시 입력")
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.apiKey = value;
@ -338,10 +339,10 @@ export class ModernSettingsTab extends PluginSettingTab {
// API 호스트 설정
new Setting(settingsGroup)
.setName("API 호스트")
.setDesc("Bareun.ai API 서버 호스트 주소입니다.")
.setDesc("기본값은 앱 서버 프록시입니다. 직접 Bareun.ai 서버를 사용할 때만 변경하세요.")
.addText((text) => {
text
.setPlaceholder("bareun-api.junlim.org")
.setPlaceholder(DEFAULT_API_HOST)
.setValue(this.plugin.settings.apiHost)
.onChange(async (value) => {
this.plugin.settings.apiHost = value;
@ -1543,9 +1544,11 @@ export class ModernSettingsTab extends PluginSettingTab {
// API 상태
const apiStatus = summaryGrid.createDiv({ cls: 'ksc-summary-item' });
apiStatus.createSpan({ text: 'API', cls: 'ksc-summary-label' });
const usesProxy = SettingsService.usesGrammarProxy(this.plugin.settings);
const apiReady = usesProxy || !!this.plugin.settings.apiKey;
apiStatus.createSpan({
text: this.plugin.settings.apiKey ? '✓ 설정됨' : '✗ 미설정',
cls: this.plugin.settings.apiKey ? 'ksc-status-ok' : 'ksc-status-error'
text: usesProxy ? '✓ 기본 프록시' : (this.plugin.settings.apiKey ? '✓ 설정됨' : '✗ 미설정'),
cls: apiReady ? 'ksc-status-ok' : 'ksc-status-error'
});
// AI 상태

View file

@ -19,5 +19,6 @@
"0.3.5": "1.4.0",
"0.3.6": "1.4.0",
"0.3.7": "1.4.0",
"0.3.8": "1.4.0"
"0.3.8": "1.4.0",
"0.3.9": "1.4.0"
}