Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions dist-electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
32 changes: 31 additions & 1 deletion dist-electron/preload.mjs
Original file line number Diff line number Diff line change
@@ -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")
});
8 changes: 7 additions & 1 deletion electron/controller/navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ declare global {
api: {
generate: (opts: { sentence: string }) => Promise<SpellCheckerApiResponse>;
hanSpell: (opts: { sentence: string; weakOpt?: number }) => Promise<SpellCheckerApiResponse>;
onNavigate: (path: string) => Promise<void>;
onNavigate: (path: string, payload?: string) => Promise<void>;
openSetting: () => Promise<void>;
getAppVersion: () => Promise<string>;
};
Expand Down Expand Up @@ -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'),
});
Expand Down
56 changes: 44 additions & 12 deletions electron/services/gemini/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { SpellCheckerApiResponse } from '../schema';
let ai: ReturnType<typeof createAi> | 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 });
}

Expand All @@ -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<string | null> {
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<string> {
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<SpellCheckerApiResponse | null> {
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;
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "matmal",
"private": true,
"version": "0.0.6",
"version": "0.0.7",
"productName": "matmal",
"description": "간편한 한국어 맞춤법 검사 데스크탑 앱",
"author": "zzzryt <jinjinstar3@gmail.com>",
Expand Down
14 changes: 12 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand Down
33 changes: 26 additions & 7 deletions src/apps/UserInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">검사할 텍스트</label>
<label className="block text-sm font-medium text-gray-500 mb-2">검사할 텍스트</label>
<textarea
className="w-full min-h-100 border rounded p-3 resize-y"
className="w-full min-h-100 border rounded p-3 resize-y relative"
value={spell}
onChange={(e) => setSpell(e.target.value)}
placeholder="여기에 텍스트를 입력하거나 붙여넣으세요..."
/>
></textarea>
<span className={`flex justify-end ${overTextLimit}`}>
글자수 : {spell.length}/{TEXT_LIMIT}
</span>
<div className="mt-2 flex gap-2">
<Button variant="primary" onClick={handleSpellingStart}>
검사 실행
Expand Down
5 changes: 2 additions & 3 deletions src/apps/quick/QuickSpell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useStore } from 'zustand';

import { SpellCheckerApiResponse } from '../../../electron/services/schema';

Expand All @@ -13,7 +12,7 @@ import Button from '../../shared/components/ui/Button';
type Status = 'loading' | 'success' | 'error';

function QuickSpell() {
const { spell, setSpell, clearSpell } = useStore(useSpellCheck);
const { spell, setSpell, clearSpell } = useSpellCheck();
const [resultData, setResultData] = useState<SpellCheckerApiResponse | null>(null);
const [status, setStatus] = useState<Status>('loading');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
Expand Down Expand Up @@ -72,7 +71,7 @@ function QuickSpell() {
};

const handleDetailInfo = () => {
window.api.onNavigate('/result');
window.api.onNavigate('/result', spell);
};

const renderContent = () => {
Expand Down
42 changes: 17 additions & 25 deletions src/apps/spellChecker/components/SpellChecker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useStore } from 'zustand';
import { useEffect, useState, useCallback } from 'react';

import { SpellCheckerApiResponse } from '../../../../electron/services/schema';

Expand All @@ -11,34 +10,27 @@ import Button from '../../../shared/components/ui/Button';
import { useSpellCheck } from '../../../shared/stores/spell';
import { LoadingSpinner } from '../../../shared/components/ui/Loading';

interface SpellChecker {
inputText: string;
}

function SpellChecker({ inputText }: SpellChecker) {
const { spell, setSpell, undo } = useStore(useSpellCheck);
function SpellChecker() {
const { spell, undo } = useSpellCheck();
const [resultData, setResultData] = useState<SpellCheckerApiResponse | null>(null);
const [error, setError] = useState<Error | null>(null);
const didMountRef = useRef(false);

const callGenerateSpell = useCallback(
async (sentence?: string) => {
try {
const res = await window.api.generate({ sentence: sentence ?? spell });
setResultData(res as SpellCheckerApiResponse);
setSpell(inputText);
} catch (err) {
setError(err as Error);
}
},
[spell, inputText, setSpell]
);
console.log('spell:', spell);

const callGenerateSpell = useCallback(async () => {
setResultData(null);
setError(null);
try {
const res = await window.api.generate({ sentence: spell });
setResultData(res as SpellCheckerApiResponse);
} catch (err) {
setError(err as Error);
}
}, [spell]);

useEffect(() => {
if (didMountRef.current) return;
didMountRef.current = true;
callGenerateSpell();
}, [callGenerateSpell]);
}, [spell, callGenerateSpell]);

if (error) {
throw error;
Expand Down Expand Up @@ -69,7 +61,7 @@ function SpellChecker({ inputText }: SpellChecker) {
if (!resultData) return <p className="text-gray-500">결과가 여기에 표시됩니다.</p>;
const raw = resultData.PnuErrorWordList?.PnuErrorWord;
if (raw.length === 0)
return <p className="text-gray-500">결과가 여기에 표시됩니다.</p>;
return <p className="text-gray-500">검사할 맞춤법이 존재하지 않습니다.</p>;
return raw.map((word, idx) => {
const currentWord = getCandWord(word);
if (currentWord !== word.OrgStr) {
Expand Down
5 changes: 1 addition & 4 deletions src/page/spell-checker/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import { useNavigate } from 'react-router-dom';
import { useStore } from 'zustand';
import { useSpellCheck } from '../../shared/stores/spell';

import Button from '../../shared/components/ui/Button';
import SpellChecker from '../../apps/spellChecker/components/SpellChecker';

function SpellCheckerPage() {
const navigate = useNavigate();
const { spell } = useStore(useSpellCheck);

return (
<>
<Button variant="primary" onClick={() => navigate('/')} className="mb-4">
텍스트 입력하러 가기
</Button>
<SpellChecker inputText={spell} />
<SpellChecker />
</>
);
}
Expand Down
Loading