├── .gitattributes ├── env.d.ts ├── public ├── favicon.ico ├── service-worker.js └── manifest.json ├── src ├── assets │ └── main.css ├── features │ ├── TextInput.vue │ ├── sync │ │ ├── SyncLayout.vue │ │ ├── JsonBin.ts │ │ └── useSync.ts │ ├── ModalWindow.vue │ ├── ExportData.vue │ ├── cards │ │ ├── WordCard.vue │ │ ├── ToolKit.vue │ │ ├── WordInfo.vue │ │ ├── AddWord.vue │ │ └── CardsList.vue │ ├── ImportData.vue │ ├── header │ │ ├── HeaderLayout.vue │ │ └── NewLanguage.vue │ ├── SettingPanel.vue │ └── llm │ │ └── useGenerateWord.ts ├── main.ts ├── App.vue ├── stores │ ├── token.ts │ ├── languages.ts │ ├── consts.ts │ └── words.ts ├── config │ └── index.ts └── utils │ └── errorHandler.ts ├── .prettierrc.json ├── .vscode └── extensions.json ├── tailwind.config.ts ├── tsconfig.json ├── .editorconfig ├── tsconfig.app.json ├── index.html ├── .gitignore ├── tsconfig.node.json ├── README.md ├── eslint.config.ts ├── vite.config.ts └── package.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/sozdik/main/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | button { 4 | @apply cursor-pointer text-sm; 5 | } 6 | -------------------------------------------------------------------------------- /src/features/TextInput.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "EditorConfig.EditorConfig", 6 | "esbenp.prettier-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{html,vue}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | 8 | end_of_line = lf 9 | -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('install', (event) => { 2 | console.log('Service Worker установлен') 3 | }) 4 | 5 | self.addEventListener('fetch', (event) => { 6 | console.log('Service Worker перехватывает запрос:', event.request.url) 7 | }) 8 | -------------------------------------------------------------------------------- /src/features/sync/SyncLayout.vue: -------------------------------------------------------------------------------- 1 | 10 | 13 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/main.css' 2 | 3 | import { createApp } from 'vue' 4 | import { createPinia } from 'pinia' 5 | import App from './App.vue' 6 | import { autoAnimatePlugin } from '@formkit/auto-animate/vue' 7 | 8 | const app = createApp(App) 9 | 10 | app.use(createPinia()) 11 | app.use(autoAnimatePlugin) 12 | 13 | app.mount('#app') 14 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 7 | 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Sozdik | AI Flash Cards 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | *.tsbuildinfo 31 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sozdik", 3 | "short_name": "szdk", 4 | "description": "Learning words with AI", 5 | "theme_color": "#ffffff", 6 | "background_color": "#ffffff", 7 | "display": "standalone", 8 | "icons": [ 9 | { 10 | "src": "/pwa-192x192.png", 11 | "sizes": "192x192", 12 | "type": "image/png" 13 | }, 14 | { 15 | "src": "/pwa-512x512.png", 16 | "sizes": "512x512", 17 | "type": "image/png" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*", 9 | "eslint.config.*" 10 | ], 11 | "compilerOptions": { 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sozdik - Learn words effectively with OpenAI 2 | 3 | ## Local-first app 4 | 5 | Sozdik or szdk is a local-first app, which means: 6 | - no registration 7 | - no paid mode 8 | - no ads 9 | 10 | which also means all user data will be saved in local storage. 11 | In next versions I am planning to add some kinda sync engine to sync several user devices 12 | 13 | ## Run locally 14 | 15 | ```bash 16 | # install 17 | pnpm install 18 | 19 | # dev 20 | pnpm run dev 21 | 22 | # build 23 | 24 | pnpm run build 25 | ``` 26 | -------------------------------------------------------------------------------- /src/features/sync/JsonBin.ts: -------------------------------------------------------------------------------- 1 | export class Npoint { 2 | private id 3 | private root = 'https://api.npoint.io/' 4 | constructor(id: string) { 5 | this.id = id 6 | } 7 | 8 | public async getData() { 9 | const res = await fetch(this.root + this.id) 10 | return await res.json() 11 | } 12 | 13 | public async setData(data: string) { 14 | const res = await fetch(this.root + this.id, { 15 | method: 'POST', 16 | body: data, 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | }, 20 | }) 21 | return await res.json() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/stores/token.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { onMounted, ref, watch } from 'vue' 3 | 4 | export const useTokenStore = defineStore('token', () => { 5 | const token = ref('') 6 | const show = ref(false) 7 | function load() { 8 | token.value = localStorage.getItem('OPENAI_TOKEN') || '' 9 | } 10 | onMounted(() => { 11 | load() 12 | }) 13 | watch(token, (t) => { 14 | localStorage.setItem('OPENAI_TOKEN', t || '') 15 | }) 16 | 17 | function toggleShow() { 18 | show.value = !show.value 19 | } 20 | 21 | return { token, show, toggleShow } 22 | }) 23 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const ConfigSchema = z.object({ 4 | openai: z.object({ 5 | model: z.string(), 6 | temperature: z.number(), 7 | }), 8 | sync: z.object({ 9 | endpoint: z.string().url(), 10 | retryAttempts: z.number(), 11 | }), 12 | }) 13 | 14 | type Config = z.infer 15 | 16 | export const config: Config = { 17 | openai: { 18 | model: 'gpt-4o-2024-11-20', 19 | temperature: 0.7, 20 | }, 21 | sync: { 22 | endpoint: 'https://api.npoint.io/', 23 | retryAttempts: 3, 24 | }, 25 | } 26 | 27 | // Validate config at runtime 28 | ConfigSchema.parse(config) 29 | -------------------------------------------------------------------------------- /src/features/ModalWindow.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 28 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import pluginVue from 'eslint-plugin-vue' 2 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' 3 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' 4 | 5 | // To allow more languages other than `ts` in `.vue` files, uncomment the following lines: 6 | // import { configureVueProject } from '@vue/eslint-config-typescript' 7 | // configureVueProject({ scriptLangs: ['ts', 'tsx'] }) 8 | // More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup 9 | 10 | export default defineConfigWithVueTs( 11 | { 12 | name: 'app/files-to-lint', 13 | files: ['**/*.{ts,mts,tsx,vue}'], 14 | }, 15 | 16 | { 17 | name: 'app/files-to-ignore', 18 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'], 19 | }, 20 | 21 | pluginVue.configs['flat/essential'], 22 | vueTsConfigs.recommended, 23 | skipFormatting, 24 | ) 25 | -------------------------------------------------------------------------------- /src/utils/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai' 2 | 3 | export class AppError extends Error { 4 | constructor( 5 | message: string, 6 | public code: string, 7 | public details?: unknown 8 | ) { 9 | super(message) 10 | this.name = 'AppError' 11 | } 12 | } 13 | 14 | export const handleLLMError = (error: unknown) => { 15 | if (error instanceof OpenAI.APIError) { 16 | throw new AppError( 17 | 'AI Service Unavailable', 18 | 'LLM_ERROR', 19 | error.message 20 | ) 21 | } 22 | if (error instanceof Error) { 23 | throw new AppError( 24 | 'LLM Service Error', 25 | 'UNKNOWN_LLM_ERROR', 26 | error.message 27 | ) 28 | } 29 | throw error 30 | } 31 | 32 | export const handleSyncError = (error: unknown) => { 33 | if (error instanceof Error) { 34 | throw new AppError( 35 | 'Sync Failed', 36 | 'SYNC_ERROR', 37 | error.message 38 | ) 39 | } 40 | throw error 41 | } -------------------------------------------------------------------------------- /src/features/ExportData.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 32 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import vueDevTools from 'vite-plugin-vue-devtools' 6 | import tailwindcss from '@tailwindcss/vite' 7 | import { VitePWA } from 'vite-plugin-pwa' 8 | 9 | // https://vite.dev/config/ 10 | export default defineConfig({ 11 | plugins: [ 12 | vue(), 13 | vueDevTools(), 14 | tailwindcss(), 15 | VitePWA({ 16 | registerType: 'autoUpdate', 17 | manifest: { 18 | name: 'Sozdik', 19 | short_name: 'szdk', 20 | description: 'Learning words with AI', 21 | theme_color: '#ffffff', 22 | background_color: '#ffffff', 23 | display: 'standalone', 24 | icons: [ 25 | { 26 | src: 'pwa-192x192.png', 27 | sizes: '192x192', 28 | type: 'image/png', 29 | }, 30 | { 31 | src: 'pwa-512x512.png', 32 | sizes: '512x512', 33 | type: 'image/png', 34 | }, 35 | ], 36 | }, 37 | }), 38 | ], 39 | resolve: { 40 | alias: { 41 | '@': fileURLToPath(new URL('./src', import.meta.url)), 42 | }, 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /src/features/cards/WordCard.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sozdik", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "run-p type-check \"build-only {@}\" --", 9 | "preview": "vite preview", 10 | "build-only": "vite build", 11 | "type-check": "vue-tsc --build", 12 | "lint": "eslint . --fix", 13 | "format": "prettier --write src/" 14 | }, 15 | "dependencies": { 16 | "@formkit/auto-animate": "^0.8.2", 17 | "@google/generative-ai": "^0.22.0", 18 | "@phosphor-icons/vue": "^2.2.1", 19 | "@tailwindcss/vite": "^4.0.9", 20 | "marked": "^15.0.7", 21 | "openai": "^4.85.4", 22 | "pinia": "^3.0.1", 23 | "tailwindcss": "^4.0.9", 24 | "uuid": "^11.1.0", 25 | "vue": "^3.5.13", 26 | "zod": "^3.24.2" 27 | }, 28 | "devDependencies": { 29 | "@tsconfig/node22": "^22.0.0", 30 | "@types/node": "^22.13.4", 31 | "@vitejs/plugin-vue": "^5.2.1", 32 | "@vue/eslint-config-prettier": "^10.2.0", 33 | "@vue/eslint-config-typescript": "^14.4.0", 34 | "@vue/tsconfig": "^0.7.0", 35 | "eslint": "^9.20.1", 36 | "eslint-plugin-vue": "^9.32.0", 37 | "jiti": "^2.4.2", 38 | "npm-run-all2": "^7.0.2", 39 | "prettier": "^3.5.1", 40 | "typescript": "~5.7.3", 41 | "vite": "^6.1.0", 42 | "vite-plugin-pwa": "^0.21.1", 43 | "vite-plugin-vue-devtools": "^7.7.2", 44 | "vue-tsc": "^2.2.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/features/ImportData.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 50 | -------------------------------------------------------------------------------- /src/features/cards/ToolKit.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 43 | -------------------------------------------------------------------------------- /src/features/header/HeaderLayout.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 41 | -------------------------------------------------------------------------------- /src/stores/languages.ts: -------------------------------------------------------------------------------- 1 | import { ref, onMounted } from 'vue' 2 | import { defineStore } from 'pinia' 3 | import type { addedLanguages } from './consts' 4 | import type { SubType } from './words' 5 | 6 | export type Language = (typeof addedLanguages)[number] 7 | 8 | export function capitalize(str: string): string { 9 | if (str.length === 0) return str 10 | return str.charAt(0).toUpperCase() + str.slice(1) 11 | } 12 | 13 | export type LanguageType = { 14 | target: Language 15 | user: Language 16 | } 17 | 18 | export const useLanguagesStore = defineStore('language', () => { 19 | const languages = ref([]) 20 | const selectedIndex = ref(0) 21 | const subs = ref([]) 22 | 23 | const addNewLanguage = (lang: LanguageType) => { 24 | languages.value = [...languages.value, lang] 25 | 26 | localStorage.setItem('language', JSON.stringify(languages.value)) 27 | triggerSubs() 28 | } 29 | 30 | const deleteLanguage = (lang: string) => { 31 | languages.value = languages.value.filter((l) => l.target !== lang) 32 | localStorage.setItem('language', JSON.stringify(languages.value)) 33 | triggerSubs() 34 | } 35 | 36 | onMounted(() => { 37 | languages.value = JSON.parse(localStorage.getItem('language') || '[]') 38 | }) 39 | 40 | function triggerSubs() { 41 | for (const s of subs.value) { 42 | s() 43 | } 44 | } 45 | 46 | function addSub(s: SubType) { 47 | subs.value = [...subs.value, s] 48 | } 49 | 50 | return { addSub, triggerSubs, languages, addNewLanguage, selectedIndex, deleteLanguage } 51 | }) 52 | -------------------------------------------------------------------------------- /src/features/cards/WordInfo.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 41 | 42 | 48 | -------------------------------------------------------------------------------- /src/features/header/NewLanguage.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 51 | -------------------------------------------------------------------------------- /src/stores/consts.ts: -------------------------------------------------------------------------------- 1 | export const languageCodes = [ 2 | { language: 'english', code: 'en-US' }, 3 | { language: 'spanish', code: 'es-ES' }, 4 | { language: 'french', code: 'fr-FR' }, 5 | { language: 'german', code: 'de-DE' }, 6 | { language: 'chinese', code: 'zh-CN' }, 7 | { language: 'japanese', code: 'ja-JP' }, 8 | { language: 'portuguese', code: 'pt-PT' }, 9 | { language: 'italian', code: 'it-IT' }, 10 | { language: 'russian', code: 'ru-RU' }, 11 | { language: 'arabic', code: 'ar-SA' }, 12 | { language: 'korean', code: 'ko-KR' }, 13 | { language: 'dutch', code: 'nl-NL' }, 14 | { language: 'swedish', code: 'sv-SE' }, 15 | { language: 'turkish', code: 'tr-TR' }, 16 | { language: 'greek', code: 'el-GR' }, 17 | { language: 'hebrew', code: 'he-IL' }, 18 | { language: 'polish', code: 'pl-PL' }, 19 | { language: 'finnish', code: 'fi-FI' }, 20 | { language: 'indonesian', code: 'id-ID' }, 21 | { language: 'vietnamese', code: 'vi-VN' }, 22 | { language: 'thai', code: 'th-TH' }, 23 | { language: 'swahili', code: 'sw-KE' }, 24 | { language: 'danish', code: 'da-DK' }, 25 | { language: 'norwegian', code: 'no-NO' }, 26 | { language: 'hungarian', code: 'hu-HU' }, 27 | { language: 'czech', code: 'cs-CZ' }, 28 | { language: 'urdu', code: 'ur-PK' }, 29 | { language: 'malay', code: 'ms-MY' }, 30 | ] as const 31 | 32 | export const addedLanguages = [ 33 | 'english', 34 | 'spanish', 35 | 'french', 36 | 'german', 37 | 'chinese', 38 | 'japanese', 39 | 'portuguese', 40 | 'italian', 41 | 'russian', 42 | 'arabic', 43 | 'korean', 44 | 'dutch', 45 | 'swedish', 46 | 'turkish', 47 | 'greek', 48 | 'hebrew', 49 | 'polish', 50 | 'finnish', 51 | 'indonesian', 52 | 'vietnamese', 53 | 'thai', 54 | 'swahili', 55 | 'danish', 56 | 'norwegian', 57 | 'hungarian', 58 | 'czech', 59 | 'korean', 60 | 'urdu', 61 | 'malay', 62 | ] as const 63 | 64 | export const pos = [ 65 | 'noun', 66 | 'verb', 67 | 'adjective', 68 | 'adverb', 69 | 'pronoun', 70 | 'preposition', 71 | 'postposition', 72 | 'conjunction', 73 | 'interjection', 74 | 'numeral', 75 | 'determiner', 76 | ] as const 77 | 78 | export const levels = ['a1', 'a2', 'b1', 'b2', 'c1', 'c2'] as const 79 | -------------------------------------------------------------------------------- /src/features/sync/useSync.ts: -------------------------------------------------------------------------------- 1 | import { useLanguagesStore, type LanguageType } from '@/stores/languages' 2 | import { useWordsStore, type WordType } from '@/stores/words' 3 | import { defineStore } from 'pinia' 4 | import { ref, watch } from 'vue' 5 | import { Npoint } from './JsonBin' 6 | 7 | export const useSyncStore = defineStore('sync', () => { 8 | const id = ref('') 9 | const words = useWordsStore() 10 | const langs = useLanguagesStore() 11 | const show = ref(false) 12 | function toggleShow() { 13 | show.value = !show.value 14 | } 15 | 16 | async function sync() { 17 | if (id.value === '') { 18 | console.log('there is no id for npoint to sync') 19 | return 20 | } 21 | const npoint = new Npoint(id.value) 22 | const data = await npoint.getData() 23 | if ('setup' in data && data.setup) { 24 | const syncWords: WordType[] = data.words 25 | for (const w of syncWords) { 26 | if (words.allWords.find((aw) => aw.id === w.id) === undefined) { 27 | words.allWords = [...words.allWords, w] 28 | } 29 | } 30 | const syncLangs: LanguageType[] = data.languages 31 | for (const sl of syncLangs) { 32 | if (langs.languages.find((l) => l.target === sl.target) === undefined) { 33 | langs.languages = [...langs.languages, sl] 34 | } 35 | } 36 | } else { 37 | await update() 38 | } 39 | } 40 | 41 | async function update() { 42 | if (id.value === '') { 43 | console.log('there is no id for npoint to update') 44 | return 45 | } 46 | const npoint = new Npoint(id.value) 47 | await npoint.setData( 48 | JSON.stringify({ 49 | setup: true, 50 | words: words.allWords, 51 | languages: langs.languages, 52 | }), 53 | ) 54 | } 55 | 56 | async function load() { 57 | const ls = localStorage.getItem('npoint_id') 58 | id.value = ls || '' 59 | if (ls !== null) { 60 | sync() 61 | } 62 | } 63 | 64 | async function watcher() { 65 | console.log('Running watcher') 66 | try { 67 | await sync() 68 | await update() 69 | } catch (e) { 70 | console.log(e) 71 | } 72 | } 73 | 74 | watch(id, sync) 75 | watch(id, (newID) => { 76 | localStorage.setItem('npoint_id', newID) 77 | }) 78 | words.addSub(update) 79 | langs.addSub(update) 80 | 81 | return { id, load, sync, update, show, toggleShow } 82 | }) 83 | -------------------------------------------------------------------------------- /src/features/SettingPanel.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 73 | -------------------------------------------------------------------------------- /src/features/cards/AddWord.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 90 | -------------------------------------------------------------------------------- /src/features/cards/CardsList.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 113 | -------------------------------------------------------------------------------- /src/features/llm/useGenerateWord.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import type { Language } from '@/stores/languages' 3 | import { levels, pos } from '@/stores/consts' 4 | import { useTokenStore } from '@/stores/token' 5 | import { AppError } from '@/utils/errorHandler' 6 | import OpenAI from 'openai' 7 | import { zodResponseFormat } from 'openai/helpers/zod' 8 | import { config } from '@/config' 9 | 10 | export const WordSchema = z.object({ 11 | in_target: z.string(), 12 | in_user: z.string(), 13 | transcription: z.string(), 14 | pos: z.enum(pos).optional(), 15 | level: z.enum(levels), 16 | }) 17 | 18 | export const useGenerateWord = () => { 19 | const tokenStore = useTokenStore() 20 | 21 | const validateToken = () => { 22 | if (!tokenStore.token || tokenStore.token.trim() === '') { 23 | throw new AppError( 24 | 'OpenAI API key is not set', 25 | 'TOKEN_MISSING', 26 | 'Please set your OpenAI API key in settings', 27 | ) 28 | } 29 | } 30 | 31 | const createOpenAIClient = () => { 32 | return new OpenAI({ 33 | dangerouslyAllowBrowser: true, 34 | apiKey: tokenStore.token, 35 | }) 36 | } 37 | 38 | const handleLLMError = (error: unknown) => { 39 | if (error instanceof OpenAI.APIError) { 40 | throw new AppError('AI Service Unavailable', 'LLM_ERROR', error.message) 41 | } 42 | if (error instanceof Error) { 43 | throw new AppError( 44 | 'LLM Service Error', 45 | 'UNKNOWN_LLM_ERROR', 46 | error.message, 47 | ) 48 | } 49 | throw error 50 | } 51 | 52 | const getWordInfo = async ( 53 | word: string, 54 | target: Language, 55 | user: Language, 56 | ) => { 57 | try { 58 | validateToken() 59 | const openai = createOpenAIClient() 60 | 61 | const completion = await openai.chat.completions.create({ 62 | model: config.openai.model, 63 | messages: [ 64 | { 65 | role: 'system', 66 | content: 67 | 'You are AI language learning assistant. Respond with ONLY valid JSON, no markdown, no code blocks. The JSON should contain: in_target (string), in_user (string), transcription (string), pos (string, optional), level (string). Example: {"in_target": "hello", "in_user": "привет", "transcription": "həˈləʊ", "pos": "interjection", "level": "a1"}', 68 | }, 69 | { 70 | role: 'user', 71 | content: ` 72 | target language: ${target}, 73 | user language: ${user}, 74 | word or phrase: ${word} 75 | `, 76 | }, 77 | ], 78 | response_format: zodResponseFormat(WordSchema, target), 79 | temperature: config.openai.temperature, 80 | }) 81 | 82 | const content = completion.choices[0].message.content || '{}' 83 | // Remove any markdown code block syntax and clean the response 84 | const cleanJson = content 85 | .replace(/```json\n?/g, '') 86 | .replace(/```\n?/g, '') 87 | .trim() 88 | 89 | return cleanJson 90 | } catch (error) { 91 | handleLLMError(error) 92 | } 93 | } 94 | 95 | const generateDescription = async ( 96 | word: string, 97 | target: Language, 98 | user: Language, 99 | callback: (chunk: string) => void, 100 | ) => { 101 | try { 102 | validateToken() 103 | const openai = createOpenAIClient() 104 | 105 | const stream = await openai.chat.completions.create({ 106 | model: config.openai.model, 107 | messages: [ 108 | { 109 | role: 'developer', 110 | content: 111 | 'You are AI language learning assistant. User will send you a word or phrase, language that use learning (target) and users native language (user). You have to explain the word/phrase in user language. If the word/phrase is not in target language, then translate it to target language. You have to be short but answer should contain all importants things. You have to create 3 examples with translation in both languages. Give answer without markdown', 112 | }, 113 | { 114 | role: 'user', 115 | content: ` 116 | target language: ${target}, 117 | user language: ${user}, 118 | word or phrase: ${word} 119 | `, 120 | }, 121 | ], 122 | stream: true, 123 | temperature: config.openai.temperature, 124 | }) 125 | 126 | for await (const chunk of stream) { 127 | callback(chunk.choices[0]?.delta?.content || '') 128 | } 129 | } catch (error) { 130 | handleLLMError(error) 131 | } 132 | } 133 | 134 | return { getWordInfo, generateDescription } 135 | } 136 | -------------------------------------------------------------------------------- /src/stores/words.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { useLanguagesStore, type Language } from './languages' 3 | import { computed, onMounted, ref } from 'vue' 4 | import { useGenerateWord, WordSchema } from '@/features/llm/useGenerateWord' 5 | import type { levels, pos } from './consts' 6 | import { v4 as uuidv4 } from 'uuid' 7 | 8 | export type WordType = { 9 | id: string 10 | target_lang: Language 11 | user_lang: Language 12 | in_target: string 13 | in_user: string 14 | pos: (typeof pos)[number] | null 15 | level: (typeof levels)[number] 16 | transcription: string 17 | description: string 18 | createdAt: Date 19 | } 20 | 21 | export type SubType = () => void 22 | 23 | export const useWordsStore = defineStore('words', () => { 24 | const allWords = ref([]) 25 | const subs = ref([]) 26 | function importData(data: string) { 27 | const newWords: WordType[] = JSON.parse(data) 28 | const oldWords = allWords.value 29 | let wordsToAdd: WordType[] = [] 30 | 31 | for (const w of newWords) { 32 | if (oldWords.find((ow) => w.id === ow.id) === undefined) { 33 | wordsToAdd = [...wordsToAdd, w] 34 | } 35 | } 36 | 37 | allWords.value = [...allWords.value, ...wordsToAdd] 38 | localStorage.setItem('words', JSON.stringify(allWords.value)) 39 | } 40 | const langs = useLanguagesStore() 41 | const currentWords = computed(() => { 42 | const currTarget = langs.languages[langs.selectedIndex] 43 | return allWords.value.filter((w) => currTarget.target === w.target_lang) 44 | }) 45 | 46 | // create new word 47 | const newWord = ref(null) 48 | const inputToWord = ref('') 49 | const bothLoading = ref<{ info: boolean; desc: boolean }>({ 50 | info: false, 51 | desc: false, 52 | }) 53 | const isLoading = computed(() => { 54 | return bothLoading.value.info || bothLoading.value.desc 55 | }) 56 | 57 | const llm = useGenerateWord() 58 | function load() { 59 | const data = localStorage.getItem('words') 60 | allWords.value = data 61 | ? JSON.parse(data).map((word: WordType) => ({ 62 | ...word, 63 | createdAt: new Date(word.createdAt), 64 | })) 65 | : [] 66 | 67 | allWords.value.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) 68 | } 69 | onMounted(() => { 70 | load() 71 | }) 72 | async function addNewWord() { 73 | bothLoading.value = { info: true, desc: true } 74 | const currTarget = langs.languages[langs.selectedIndex] 75 | llm 76 | .getWordInfo(inputToWord.value, currTarget.target, currTarget.user) 77 | .then((info) => { 78 | const newWordData = WordSchema.parse(JSON.parse(info || '{}')) 79 | if (newWord.value !== null) { 80 | newWord.value = { 81 | ...newWordData, 82 | id: uuidv4(), 83 | target_lang: currTarget.target, 84 | user_lang: currTarget.user, 85 | pos: newWordData.pos || null, 86 | description: newWord.value.description || '', 87 | createdAt: new Date(), 88 | } 89 | } else { 90 | newWord.value = { 91 | ...newWordData, 92 | id: uuidv4(), 93 | target_lang: currTarget.target, 94 | user_lang: currTarget.user, 95 | pos: newWordData.pos || null, 96 | description: '', 97 | createdAt: new Date(), 98 | } 99 | } 100 | bothLoading.value = { ...bothLoading.value, info: false } 101 | }) 102 | llm.generateDescription( 103 | inputToWord.value, 104 | currTarget.target, 105 | currTarget.user, 106 | (ch) => { 107 | if (newWord.value !== null) { 108 | newWord.value = { 109 | ...newWord.value, 110 | description: newWord.value.description + ch, 111 | } 112 | } else { 113 | newWord.value = { 114 | id: uuidv4(), 115 | in_target: '', 116 | in_user: '', 117 | level: 'a1', 118 | transcription: '', 119 | target_lang: currTarget.target, 120 | user_lang: currTarget.user, 121 | pos: null, 122 | description: ch, 123 | createdAt: new Date(), 124 | } 125 | } 126 | bothLoading.value = { ...bothLoading.value, desc: false } 127 | }, 128 | ) 129 | } 130 | 131 | function triggerSubs() { 132 | for (const s of subs.value) { 133 | s() 134 | } 135 | } 136 | 137 | function addSub(s: SubType) { 138 | subs.value = [...subs.value, s] 139 | } 140 | 141 | function handleSave() { 142 | if (newWord.value !== null) { 143 | allWords.value = [newWord.value, ...allWords.value] 144 | localStorage.setItem('words', JSON.stringify(allWords.value)) 145 | newWord.value = null 146 | inputToWord.value = '' 147 | } 148 | triggerSubs() 149 | } 150 | 151 | function handleDelete(id: string) { 152 | allWords.value = allWords.value.filter((w) => w.id !== id) 153 | localStorage.setItem('words', JSON.stringify(allWords.value)) 154 | triggerSubs() 155 | } 156 | 157 | function setAllWords(words: WordType[]) { 158 | allWords.value = words 159 | triggerSubs() 160 | } 161 | 162 | const searchQuery = ref('') 163 | const isSearchActive = ref(false) 164 | 165 | const filteredWords = computed(() => { 166 | if (!isSearchActive.value || !searchQuery.value) { 167 | return currentWords.value 168 | } 169 | 170 | return currentWords.value.filter( 171 | (word) => 172 | word.in_target 173 | .toLowerCase() 174 | .includes(searchQuery.value.toLowerCase()) || 175 | word.in_user.toLowerCase().includes(searchQuery.value.toLowerCase()), 176 | ) 177 | }) 178 | 179 | function toggleSearch() { 180 | isSearchActive.value = !isSearchActive.value 181 | if (!isSearchActive.value) { 182 | searchQuery.value = '' 183 | } 184 | } 185 | return { 186 | addSub, 187 | setAllWords, 188 | allWords, 189 | currentWords, 190 | addNewWord, 191 | load, 192 | newWord, 193 | inputToWord, 194 | isLoading, 195 | handleSave, 196 | handleDelete, 197 | importData, 198 | filteredWords, 199 | searchQuery, 200 | isSearchActive, 201 | toggleSearch, 202 | } 203 | }) 204 | --------------------------------------------------------------------------------