diff --git a/dist-electron/main.js b/dist-electron/main.js index 0a11466..a717c0e 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -3,9 +3,9 @@ import "node:fs"; import "node:url"; import "electron"; import "module"; -import { M as i, R as _, V as D } from "./main-D2zg5g4b.js"; +import { M, R, V } from "./main-CzLorSzc.js"; export { - i as MAIN_DIST, - _ as RENDERER_DIST, - D as VITE_DEV_SERVER_URL + M as MAIN_DIST, + R as RENDERER_DIST, + V as VITE_DEV_SERVER_URL }; diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index b05ec55..0c7f825 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -1 +1,31 @@ -"use strict";const n=require("electron");n.contextBridge.exposeInMainWorld("ipcRenderer",{on(...e){const[r,i]=e;return n.ipcRenderer.on(r,(o,...t)=>i(o,...t))},off(...e){const[r,...i]=e;return n.ipcRenderer.off(r,...i)},send(...e){const[r,...i]=e;return n.ipcRenderer.send(r,...i)},invoke(...e){const[r,...i]=e;return n.ipcRenderer.invoke(r,...i)}});n.contextBridge.exposeInMainWorld("api",{generate:e=>n.ipcRenderer.invoke("generate",e),hanSpell:e=>n.ipcRenderer.invoke("hanSpell-check",e),onNavigate:e=>n.ipcRenderer.invoke("navigate",e),openSetting:()=>n.ipcRenderer.invoke("setting-open"),getAppVersion:()=>n.ipcRenderer.invoke("get-app-version")});n.contextBridge.exposeInMainWorld("theme",{changeTheme:e=>n.ipcRenderer.invoke("theme-mode",e),getTheme:()=>n.ipcRenderer.invoke("get-theme")}); +"use strict"; +const electron = require("electron"); +electron.contextBridge.exposeInMainWorld("ipcRenderer", { + on(...args) { + const [channel, listener] = args; + return electron.ipcRenderer.on(channel, (event, ...args2) => listener(event, ...args2)); + }, + off(...args) { + const [channel, ...omit] = args; + return electron.ipcRenderer.off(channel, ...omit); + }, + send(...args) { + const [channel, ...omit] = args; + return electron.ipcRenderer.send(channel, ...omit); + }, + invoke(...args) { + const [channel, ...omit] = args; + return electron.ipcRenderer.invoke(channel, ...omit); + } +}); +electron.contextBridge.exposeInMainWorld("api", { + generate: (opts) => electron.ipcRenderer.invoke("generate", opts), + hanSpell: (opts) => electron.ipcRenderer.invoke("hanSpell-check", opts), + onNavigate: (path, payload) => electron.ipcRenderer.invoke("navigate", path, payload), + openSetting: () => electron.ipcRenderer.invoke("setting-open"), + getAppVersion: () => electron.ipcRenderer.invoke("get-app-version") +}); +electron.contextBridge.exposeInMainWorld("theme", { + changeTheme: (mode) => electron.ipcRenderer.invoke("theme-mode", mode), + getTheme: () => electron.ipcRenderer.invoke("get-theme") +}); diff --git a/electron/controller/navigate.ts b/electron/controller/navigate.ts index 1de4d1d..59e5d69 100644 --- a/electron/controller/navigate.ts +++ b/electron/controller/navigate.ts @@ -4,18 +4,24 @@ import { quickWin } from '../windows/quickWindow'; import { RENDERER_DIST, VITE_DEV_SERVER_URL } from '../main'; import { PRELOAD_PATH } from '../paths'; -export const handleNavigate = (_event: unknown, path: string) => { +export const handleNavigate = (_event: unknown, path: string, payload?: string) => { quickWin?.destroy(); if (mainWin && !mainWin.isDestroyed()) { mainWin.focus(); mainWin.webContents.send('navigate-to', path); + if (payload) { + mainWin.webContents.send('set-spell-from-quick-window', payload); + } return { ok: true }; } createMainWindow(RENDERER_DIST, VITE_DEV_SERVER_URL, PRELOAD_PATH); mainWin?.webContents.once('did-finish-load', () => { mainWin?.webContents.send('navigate-to', path); + if (payload) { + mainWin?.webContents.send('set-spell-from-quick-window', payload); + } }); mainWin?.show(); mainWin?.focus(); diff --git a/electron/preload.ts b/electron/preload.ts index 89a1ae5..0b6f755 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -6,7 +6,7 @@ declare global { api: { generate: (opts: { sentence: string }) => Promise; hanSpell: (opts: { sentence: string; weakOpt?: number }) => Promise; - onNavigate: (path: string) => Promise; + onNavigate: (path: string, payload?: string) => Promise; openSetting: () => Promise; getAppVersion: () => Promise; }; @@ -40,7 +40,7 @@ contextBridge.exposeInMainWorld('api', { generate: (opts: { sentence: string }) => ipcRenderer.invoke('generate', opts), hanSpell: (opts: { sentence: string; weakOpt?: number }) => ipcRenderer.invoke('hanSpell-check', opts), - onNavigate: (path: string) => ipcRenderer.invoke('navigate', path), + onNavigate: (path: string, payload?: string) => ipcRenderer.invoke('navigate', path, payload), openSetting: () => ipcRenderer.invoke('setting-open'), getAppVersion: () => ipcRenderer.invoke('get-app-version'), }); diff --git a/electron/services/gemini/gemini.ts b/electron/services/gemini/gemini.ts index 0d4c212..1fcf0ab 100644 --- a/electron/services/gemini/gemini.ts +++ b/electron/services/gemini/gemini.ts @@ -5,6 +5,9 @@ import { SpellCheckerApiResponse } from '../schema'; let ai: ReturnType | null = null; function createAi() { + if (!process.env.GEMINI_API_KEY) { + throw new Error('GEMINI_API_KEY is not set in environment variables.'); + } return new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); } @@ -13,43 +16,72 @@ function getAi() { return ai; } -export async function geminiGenerate(contents: string) { +const GEN_MODELS = [ + 'gemini-2.5-flash-lite', + 'gemini-2.5-flash', + 'gemini-2.0-flash-lite', + 'gemini-2.0-flash', +]; + +async function generateWithSingleModel( + modelName: string, + contents: string +): Promise { const response = await getAi().models.generateContent({ - model: 'gemini-2.5-flash-lite', + model: modelName, contents, }); - if (!response) return ''; + if (!response) return null; if (typeof response === 'string') return response; const maybeText = (response as { text?: unknown }).text; if (typeof maybeText === 'string') return maybeText; try { return JSON.stringify(response); - } catch (err) { - return String(response); + } catch { + return null; } } +export async function geminiGenerate(contents: string): Promise { + let lastError: unknown = null; + + for (const modelName of GEN_MODELS) { + try { + const result = await generateWithSingleModel(modelName, contents); + + if (result) return result; + } catch (error) { + console.warn(`Model [${modelName}] failed. Reason:`, error); + lastError = error; + } + } + throw lastError || new Error('All models failed silently.'); +} + export async function checkSpelling(text: string): Promise { const prompt = getSpellCheckPrompt(text); try { const raw = await geminiGenerate(prompt); - if (!raw) return null; const firstBrace = raw.indexOf('{'); const lastBrace = raw.lastIndexOf('}'); - const candidate = - firstBrace !== -1 && lastBrace !== -1 ? raw.slice(firstBrace, lastBrace + 1) : raw; + if (firstBrace === -1 || lastBrace === -1) { + return null; + } + + const candidate = raw.slice(firstBrace, lastBrace + 1); const resultObject = JSON.parse(candidate); - if (!resultObject) { - console.warn('LLM 반환값이 예상 스키마를 따르지 않습니다.', resultObject); + + if (!resultObject || typeof resultObject !== 'object') { return null; } + return resultObject as unknown as SpellCheckerApiResponse; } catch (error) { - console.error('LLM 응답 파싱 오류:', error); - throw new Error('LLM 응답 파싱 오류'); + console.error('Spelling check process failed:', error); + throw error; } } diff --git a/package.json b/package.json index a31eefc..e8e0bd7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "matmal", "private": true, - "version": "0.0.6", + "version": "0.0.7", "productName": "matmal", "description": "간편한 한국어 맞춤법 검사 데스크탑 앱", "author": "zzzryt ", diff --git a/src/App.tsx b/src/App.tsx index 8726af6..804d9c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,9 +6,19 @@ import { ToastContainer } from 'react-toastify'; import { useSpellCheck } from './shared/stores/spell'; function App() { + const { setSpell } = useSpellCheck(); + useEffect(() => { - useSpellCheck.getState().setSpell(''); - }, []); + const handleSetSpell = (_event: unknown, text: string) => { + setSpell(text); + }; + + window.ipcRenderer.on('set-spell-from-quick-window', handleSetSpell); + + return () => { + window.ipcRenderer.off('set-spell-from-quick-window', handleSetSpell); + }; + }, [setSpell]); return ( <> diff --git a/src/apps/UserInput/index.tsx b/src/apps/UserInput/index.tsx index 65d1375..9dd7b8c 100644 --- a/src/apps/UserInput/index.tsx +++ b/src/apps/UserInput/index.tsx @@ -3,30 +3,49 @@ import Button from '../../shared/components/ui/Button'; import { useStore } from 'zustand'; import { useSpellCheck } from '../../shared/stores/spell'; import { toast } from 'react-toastify'; +import clsx from 'clsx'; + +const TEXT_LIMIT = 500; function UserInput() { const navigate = useNavigate(); const { spell, setSpell } = useStore(useSpellCheck); - const handleSpellingStart = () => { - if (spell.length <= 0) { - toast.error('검사할 항목을 입력해 주세요.'); - return; + const isValidationText = (text: string) => { + if (text.length > TEXT_LIMIT) { + toast.error(`텍스트는 ${TEXT_LIMIT}자 이내로 입력해 주세요.`); + return false; } + if (text.trim().length === 0) { + toast.error('검사할 텍스트를 입력해 주세요.'); + return false; + } + return true; + }; + + const handleSpellingStart = () => { + if (!isValidationText(spell)) return; setSpell(spell); navigate('/result'); }; + const overTextLimit = clsx({ + 'text-red-600': spell.length > TEXT_LIMIT, + }); + return (
- + + + 글자수 : {spell.length}/{TEXT_LIMIT} +
- + ); } diff --git a/src/shared/components/layout/Footer.tsx b/src/shared/components/layout/Footer.tsx index 867a8a7..5a3e276 100644 --- a/src/shared/components/layout/Footer.tsx +++ b/src/shared/components/layout/Footer.tsx @@ -6,13 +6,15 @@ function Footer() { return ( <>
-
+
+ AI 맞춤법 검사는 틀릴 수 있습니다. +
@@ -22,10 +24,10 @@ function Footer() { onClick={() => setShowHelp(false)} >
e.stopPropagation()} > -
+

도움말

diff --git a/src/shared/components/ui/Loading.tsx b/src/shared/components/ui/Loading.tsx index e1e4d83..30f3a37 100644 --- a/src/shared/components/ui/Loading.tsx +++ b/src/shared/components/ui/Loading.tsx @@ -12,16 +12,16 @@ export function LoadingSpinner() {