├── .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 |
4 |
5 |
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 |
11 |
12 |
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 |
8 |
9 |
10 |
11 |
12 |
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 |
11 |
16 |
20 |
21 |
{{ props.title }}
22 |
23 |
24 |
25 |
26 |
27 |
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 |
24 |
31 |
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 |
13 |
19 | {{ word.in_target }}
21 | {{ word.in_user }}
23 |
24 |
25 |
26 |
27 |
38 |
39 |
40 |
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 |
34 |
41 |
45 |
Import
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/features/cards/ToolKit.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
27 |
28 |
29 | {{ words.currentWords.length }} words
30 |
31 |
No words yet
32 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/features/header/HeaderLayout.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
40 |
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 |
23 |
24 |
{{
25 | props.word.in_user
26 | }}
27 |
{{
28 | props.word.level
29 | }}
30 |
{{
31 | props.word.pos || 'no pos'
32 | }}
33 |
{{ props.word.transcription }}
38 |
39 |
40 |
41 |
42 |
48 |
--------------------------------------------------------------------------------
/src/features/header/NewLanguage.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 | Add first language
27 |
28 | Which language do you wanna learn?
29 |
32 | Your native language?
33 |
36 |
37 |
48 |
49 |
50 |
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 |
16 |
17 |
18 |
Languages
19 |
{{ capitalize(l.target) }}
24 |
27 |
28 |
29 |
OpenAI Token
30 |
31 |
46 |
47 |
48 |
Sync data
49 |
50 |
51 |
52 |
53 |
Npoint token
54 |
55 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/src/features/cards/AddWord.vue:
--------------------------------------------------------------------------------
1 |
50 |
51 |
52 |
59 |
60 |
61 |
62 |
69 |
82 |
83 |
84 | {{ error }}
85 |
86 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/src/features/cards/CardsList.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
23 |
24 |
25 |
Welcome to Sozdik
26 |
To get started:
27 |
28 |
29 |
49 |
50 | Add your OpenAI Token. Click to settings on the top right corner
51 |
52 |
53 |
54 |
74 |
75 | Select your the languages you want to learn
76 |
77 |
78 |
79 |
99 |
100 | Create your first word with AI. Click on the "New word" button
101 |
102 |
103 |
104 |
105 |
110 |
111 |
112 |
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 |
--------------------------------------------------------------------------------