├── .eslintrc.json ├── bun.lockb ├── image.png ├── src ├── app │ ├── favicon.ico │ ├── commit-mono.woff2 │ ├── loading.tsx │ ├── page.tsx │ ├── layout.tsx │ ├── globals.css │ ├── icon.svg │ └── home.tsx ├── components │ ├── home │ │ ├── spacer.tsx │ │ ├── entries-container.tsx │ │ ├── container.tsx │ │ ├── footer.tsx │ │ ├── ai-button.tsx │ │ ├── day-progress.tsx │ │ ├── entry.tsx │ │ ├── ai-chat.tsx │ │ ├── entry-dialog.tsx │ │ └── header.tsx │ ├── theme-provider.tsx │ ├── current-time.tsx │ └── ui │ │ ├── theme-toggle.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── select.tsx │ │ └── dropdown-menu.tsx ├── lib │ ├── typings.d.ts │ ├── index-db.ts │ ├── utils.ts │ ├── llm.ts │ └── langchain.ts └── hooks │ └── use-local-storage.ts ├── postcss.config.js ├── kirimase.config.json ├── components.json ├── .gitignore ├── next.config.mjs ├── tsconfig.json ├── package.json ├── README.md ├── public └── kisahari.svg └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xN1/kisahari/HEAD/bun.lockb -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xN1/kisahari/HEAD/image.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xN1/kisahari/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/commit-mono.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xN1/kisahari/HEAD/src/app/commit-mono.woff2 -------------------------------------------------------------------------------- /src/components/home/spacer.tsx: -------------------------------------------------------------------------------- 1 | export const Spacer = () =>
; 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/typings.d.ts: -------------------------------------------------------------------------------- 1 | type JournalEntry = { 2 | content: string; 3 | created: string; 4 | id: number; 5 | title: string; 6 | tldr: string; 7 | updated: string; 8 | }; 9 | -------------------------------------------------------------------------------- /kirimase.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "hasSrc": true, 3 | "packages": [ 4 | "shadcn-ui" 5 | ], 6 | "preferredPackageManager": "bun", 7 | "t3": false, 8 | "alias": "@", 9 | "rootPath": "src/", 10 | "componentLib": "shadcn-ui", 11 | "auth": null 12 | } -------------------------------------------------------------------------------- /src/lib/index-db.ts: -------------------------------------------------------------------------------- 1 | import Dexie from "dexie"; 2 | 3 | const db = new Dexie("kisahariDB"); 4 | db.version(1).stores({ 5 | entries: "++id, title , content, tldr, created, updated", 6 | }); 7 | 8 | const entries = db.table("entries"); 9 | 10 | export { db, entries }; 11 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import Container from "@/components/home/container"; 2 | 3 | export default function Loading() { 4 | return ( 5 | 6 | loading 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/home/entries-container.tsx: -------------------------------------------------------------------------------- 1 | const EntriesContainer = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
7 | {children} 8 |
9 | ); 10 | }; 11 | 12 | export default EntriesContainer; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/components/home/container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | const Container = ({ 4 | children, 5 | className, 6 | }: { 7 | children: React.ReactNode; 8 | className?: string; 9 | }) => { 10 | return ( 11 |
17 | {children} 18 |
19 | ); 20 | }; 21 | 22 | export default Container; 23 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import HomePage from "@/app/home"; 4 | import { useLiveQuery } from "dexie-react-hooks"; 5 | import { entries } from "@/lib/index-db"; 6 | 7 | export default function Home() { 8 | const allEntries = useLiveQuery( 9 | () => entries.toArray(), 10 | [entries] 11 | ) as JournalEntry[]; 12 | 13 | const sortedEntries = allEntries?.sort( 14 | (a, b) => new Date(b.created).getTime() - new Date(a.created).getTime() 15 | ); 16 | 17 | if (sortedEntries) { 18 | return ; 19 | } else { 20 | return ; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/current-time.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { formatDate } from "@/lib/utils"; 4 | import { useEffect, useState } from "react"; 5 | 6 | type Props = {}; 7 | 8 | const CurrentTime = (props: Props) => { 9 | const [currentTime, setCurrentTime] = useState( 10 | formatDate(new Date(), "time") 11 | ); 12 | useEffect(() => { 13 | const interval = setInterval(() => { 14 | setCurrentTime(formatDate(new Date(), "time")); 15 | }, 1000); 16 | return () => clearInterval(interval); 17 | }); 18 | return
{currentTime}
; 19 | }; 20 | 21 | export default CurrentTime; 22 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | webpack: (config) => { 4 | config.resolve.alias = { 5 | ...config.resolve.alias, 6 | sharp$: false, 7 | "onnxruntime-node$": false, 8 | mongodb$: false, 9 | fs: false, 10 | }; 11 | 12 | return config; 13 | }, 14 | 15 | experimental: { 16 | serverComponentsExternalPackages: [ 17 | // "llamaindex", 18 | "onnxruntime-node", 19 | "sharp", 20 | "mongodb", 21 | ], 22 | outputFileTracingIncludes: { 23 | "/*": ["./cache/**/*"], 24 | }, 25 | }, 26 | output: "export", 27 | }; 28 | 29 | export default nextConfig; 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/components/home/footer.tsx: -------------------------------------------------------------------------------- 1 | import EntryDialog from "@/components/home/entry-dialog"; 2 | 3 | const Footer = (data: { 4 | footer: { 5 | left_copy: string; 6 | right_copy: string; 7 | }; 8 | }) => { 9 | return ( 10 |
11 |
12 | {data.footer.left_copy} 13 |
14 | 15 |
16 | {data.footer.right_copy} {new Date().getFullYear()} 17 |
18 |
19 | ); 20 | }; 21 | 22 | export default Footer; 23 | -------------------------------------------------------------------------------- /src/components/home/ai-button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Atom } from "lucide-react"; 3 | 4 | const AIButton = ({ 5 | showAI, 6 | setShowAI, 7 | }: { 8 | showAI: boolean; 9 | setShowAI: (showAI: boolean) => void; 10 | }) => { 11 | return ( 12 |
13 | 30 |
31 | ); 32 | }; 33 | 34 | export default AIButton; 35 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export const formatDate = (date: Date, option?: "all" | "time" | "date") => { 9 | // format in 22:11 - Tue 5 feb 24 10 | const d = new Date(date); 11 | const day = d.toLocaleString("en-US", { weekday: "short" }); 12 | const month = d.toLocaleString("en-US", { month: "short" }); 13 | const year = d.toLocaleString("en-US", { year: "2-digit" }); 14 | 15 | const hour = d.getHours() < 10 ? `0${d.getHours()}` : d.getHours(); 16 | 17 | const minute = d.getMinutes() < 10 ? `0${d.getMinutes()}` : d.getMinutes(); 18 | const second = d.getSeconds() < 10 ? `0${d.getSeconds()}` : d.getSeconds(); 19 | 20 | if (option === "time") { 21 | return `${hour}:${minute}:${second}`; 22 | } 23 | 24 | if (option === "date") { 25 | return `${ 26 | d.getDate() < 10 ? "0" + d.getDate() : d.getDate() 27 | } ${month} ${year}`; 28 | } 29 | 30 | return `${hour}:${minute} - ${day} ${ 31 | d.getDate() < 10 ? "0" + d.getDate() : d.getDate() 32 | } ${month} ${year}`; 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/ui/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { MoonIcon, SunIcon } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kisahari", 3 | "version": "0.4.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@langchain/community": "^0.0.27", 13 | "@langchain/openai": "^0.0.14", 14 | "@radix-ui/react-avatar": "^1.0.4", 15 | "@radix-ui/react-dialog": "^1.0.5", 16 | "@radix-ui/react-dropdown-menu": "^2.0.6", 17 | "@radix-ui/react-label": "^2.0.2", 18 | "@radix-ui/react-select": "^2.0.0", 19 | "@radix-ui/react-slot": "^1.0.2", 20 | "@radix-ui/react-toast": "^1.1.5", 21 | "caniuse-lite": "^1.0.30001702", 22 | "class-variance-authority": "^0.7.0", 23 | "clsx": "^2.1.0", 24 | "dexie": "^3.2.5", 25 | "dexie-react-hooks": "^1.1.7", 26 | "langchain": "^0.1.17", 27 | "lucide-react": "^0.323.0", 28 | "next": "14.1.0", 29 | "next-themes": "^0.2.1", 30 | "ollama": "^0.4.6", 31 | "react": "^18", 32 | "react-dom": "^18", 33 | "tailwind-merge": "^2.2.1", 34 | "tailwindcss-animate": "^1.0.7", 35 | "usehooks-ts": "^2.14.0" 36 | }, 37 | "devDependencies": { 38 | "@tailwindcss/typography": "^0.5.10", 39 | "@types/node": "^20", 40 | "@types/react": "^18", 41 | "@types/react-dom": "^18", 42 | "autoprefixer": "^10.0.1", 43 | "eslint": "^8", 44 | "eslint-config-next": "14.1.0", 45 | "postcss": "^8", 46 | "tailwind-scrollbar": "^3.0.5", 47 | "tailwindcss": "^3.3.0", 48 | "typescript": "^5" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Alt text](image.png) 2 | 3 | Kisahari, a personal journaling app where you can chat with your entries locally. 4 | 5 | ## Motivation 6 | 7 | A personal journal app where we keep the data locally, while having the power to chat with it using local LLM. 8 | 9 | ## Features 10 | 11 | - Minimal techy look 12 | - NextJS + TailwindCSS 13 | - Keyboard shortcut 14 | - Local LLM support through Ollama 15 | - Langchain for RAG 16 | - Local embedding 17 | - In-memory Vector DB 18 | - Entries stored in IndexedDB 19 | - Model selection for Ollama & OpenAI 20 | 21 | ## Todo / Ideas 22 | 23 | - [x] Option to use OpenAI API 24 | - [x] Model selection for OpenAI 25 | - [x] Mobile responsive design 26 | - [ ] Allow ollama model pull from within the app 27 | - [ ] Allow theme customization 28 | - [ ] Support markdown 29 | - [ ] Turn into PWA 30 | - [ ] Turn into Tauri app (Desktop) 31 | 32 | ## Develop 33 | 34 | clone the repo 35 | 36 | ``` 37 | git clone https://github.com/0xn1/kisahari.git 38 | ``` 39 | 40 | install dependencies 41 | 42 | ``` 43 | cd kisahari && bun i 44 | ``` 45 | 46 | run development server 47 | 48 | ``` 49 | bun run dev 50 | ``` 51 | 52 | build the app 53 | 54 | ``` 55 | bun run build 56 | ``` 57 | 58 | ## Contribute 59 | 60 | Feel free to open issue and create PR :) 61 | 62 | ## Contributors 63 | 64 | [](https://github.com/0xn1) [](https://github.com/apikmeister) [](https://github.com/luqmanrom) 65 | -------------------------------------------------------------------------------- /src/components/home/day-progress.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { useEffect, useState } from "react"; 3 | 4 | const DayProgress = () => { 5 | const [progressWidth, setProgressWidth] = useState(0); 6 | 7 | const calcDayProgress = () => { 8 | const now = new Date(); 9 | const start = new Date(now.getFullYear(), 0, 0); 10 | const diff = now.getTime() - start.getTime(); 11 | const oneDay = 1000 * 60 * 60 * 24; 12 | const day = Math.floor(diff / oneDay); 13 | const year = now.getFullYear(); 14 | const daysInYear = year % 4 === 0 ? 366 : 365; 15 | return (day / daysInYear) * 100; 16 | }; 17 | 18 | useEffect(() => { 19 | setProgressWidth(calcDayProgress()); 20 | 21 | const interval = setInterval(() => { 22 | setProgressWidth(calcDayProgress()); 23 | }, 60000); 24 | 25 | return () => clearInterval(interval); 26 | }, []); 27 | 28 | return ( 29 |
30 |
31 |
35 |
36 | 92 ? "text-zinc-800" : "text-lime-400", 39 | "absolute right-2 sm:right-4 md:right-6 font-medium text-[0.9em]" 40 | )} 41 | > 42 | {progressWidth.toFixed(1)}% 43 | 44 |
45 | ); 46 | }; 47 | 48 | export default DayProgress; 49 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import { ThemeProvider } from "@/components/theme-provider"; 4 | import localFont from "next/font/local"; 5 | import { ReactNode } from "react"; 6 | 7 | const commitMono = localFont({ 8 | src: "./commit-mono.woff2", 9 | variable: "--font-commit-mono", 10 | display: "swap", 11 | }); 12 | 13 | export const metadata: Metadata = { 14 | title: "Kisahari", 15 | description: "A personal journaling app.", 16 | }; 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: Readonly<{ 21 | children: ReactNode; 22 | }>) { 23 | return ( 24 | 25 | 26 | 32 |
33 |
34 | 35 | {children} 36 |
37 |
38 |
39 | 40 | 41 | ); 42 | } 43 | 44 | const Background = () => { 45 | return ( 46 | <> 47 | {/*
*/} 48 |
49 |
50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/components/home/entry.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Spacer } from "@/components/home/spacer"; 4 | import { cn } from "@/lib/utils"; 5 | import { useState } from "react"; 6 | 7 | const Entry = ({ 8 | date, 9 | title, 10 | content, 11 | tldr, 12 | }: { 13 | date: string; 14 | title: string; 15 | content: string; 16 | tldr?: string; 17 | }) => { 18 | const [closed, setClosed] = useState(false); 19 | 20 | return ( 21 | <> 22 |
{ 24 | setClosed(!closed); 25 | }} 26 | className="flex flex-row items-center mt-2 sm:mt-0 gap-2 group cursor-pointer hover:text-lime-300 hover:animate-pulse transition-colors duration-200 ease-in-out" 27 | > 28 |
29 |
35 | {date} 36 |
37 |
38 | {!closed && ( 39 |
43 | 44 |

{title}

45 | {tldr && ( 46 |

47 | {tldr} 48 |

49 | )} 50 |
54 | 55 |
56 | )} 57 | 58 | ); 59 | }; 60 | 61 | export default Entry; 62 | -------------------------------------------------------------------------------- /src/hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const isServer = typeof window === "undefined"; 4 | 5 | const useLocalStorage = (key: string, initialValue: any) => { 6 | // State to store our value 7 | // Pass initial state function to useState so logic is only executed once 8 | const [storedValue, setStoredValue] = useState(() => initialValue); 9 | 10 | const initialize = () => { 11 | if (isServer) { 12 | return initialValue; 13 | } 14 | try { 15 | // Get from local storage by key 16 | const item = window.localStorage.getItem(key); 17 | // Parse stored json or if none return initialValue 18 | return item ? JSON.parse(item) : initialValue; 19 | } catch (error) { 20 | // If error also return initialValue 21 | console.log(error); 22 | return initialValue; 23 | } 24 | }; 25 | 26 | /* prevents hydration error so that state is only initialized after server is defined */ 27 | useEffect(() => { 28 | if (!isServer) { 29 | setStoredValue(initialize()); 30 | } 31 | // eslint-disable-next-line react-hooks/exhaustive-deps 32 | }, []); 33 | 34 | // Return a wrapped version of useState's setter function that ... 35 | // ... persists the new value to localStorage. 36 | const setValue = (value: any) => { 37 | try { 38 | // Allow value to be a function so we have same API as useState 39 | const valueToStore = 40 | value instanceof Function ? value(storedValue) : value; 41 | // Save state 42 | setStoredValue(valueToStore); 43 | // Save to local storage 44 | if (typeof window !== "undefined") { 45 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 46 | } 47 | } catch (error) { 48 | // A more advanced implementation would handle the error case 49 | console.log(error); 50 | } 51 | }; 52 | return [storedValue, setValue]; 53 | }; 54 | 55 | export default useLocalStorage; 56 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 0 0% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 0 0% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 0 0% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 0 0% 9%; 50 | 51 | --secondary: 0 0% 14.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 0 0% 14.9%; 55 | --muted-foreground: 0 0% 63.9%; 56 | 57 | --accent: 0 0% 14.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 0 0% 14.9%; 64 | --input: 0 0% 14.9%; 65 | --ring: 0 0% 83.1%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | /* Mobile responsive adjustments */ 79 | @media (max-width: 640px) { 80 | .prose { 81 | font-size: 0.875rem; 82 | } 83 | 84 | input, 85 | textarea, 86 | select, 87 | button { 88 | font-size: 0.875rem; 89 | } 90 | } 91 | 92 | /* Ensure content doesn't overflow on small screens */ 93 | html, 94 | body { 95 | max-width: 100vw; 96 | overflow-x: hidden; 97 | } 98 | -------------------------------------------------------------------------------- /src/lib/llm.ts: -------------------------------------------------------------------------------- 1 | import ollama from "ollama"; 2 | 3 | export const chatStream = async ( 4 | data: JournalEntry[], 5 | query: string, 6 | model?: string 7 | ) => { 8 | const q = query || "who are you?"; 9 | const selectedModel = model || "nous-hermes2:latest"; 10 | 11 | const cleanData = data.map((e) => { 12 | return { 13 | content: e.content, 14 | created: e.created, 15 | title: e.title, 16 | tldr: e.tldr, 17 | }; 18 | }); 19 | 20 | const flattenedData = cleanData.map((e) => { 21 | return `{content:${e.content},title:${e.title},tldr:${e.tldr},created:${e.created}}`; 22 | }); 23 | 24 | function compressString(inputStr: string) { 25 | const cleanInput = inputStr 26 | .replace(/( )+/g, " ") // Remove HTML entities 27 | .replace(/[.\/#!$%\^&\*;\[\]_\\]+/g, "") // Remove special characters 28 | .replace(/<[^>]*>/g, "") // Remove HTML tags 29 | .toLowerCase(); 30 | 31 | return cleanInput; 32 | } 33 | 34 | const cleanerData = compressString(flattenedData.join("|")); 35 | 36 | const response = await ollama.chat({ 37 | model: selectedModel, 38 | messages: [ 39 | { 40 | content: `your name is kisahari. you are an AI within a journaling app. 41 | Your purpose is to assist the user in reflecting on their thoughts through a thoughtful and empathetic approach. 42 | The user will not respond to your prompts. You need to be particularly mindful of time and date. 43 | Your goal is to provide fresh perspectives, encouragement, or even constructive debate, while maintaining concise yet meaningful responses. 44 | current time is ${new Date().toLocaleString()} gmt+8. answer the user based on the following journal entries: ${cleanerData}`, 45 | role: "system", 46 | }, 47 | // { content: "my journal entries\n" + cleanerData, role: "user" }, 48 | { 49 | content: q, 50 | role: "user", 51 | }, 52 | ], 53 | keep_alive: 60, 54 | stream: true, 55 | }); 56 | 57 | return response; 58 | }; 59 | 60 | export const getAllModels = async () => { 61 | try { 62 | const models = await ollama.list(); 63 | return models; 64 | } catch (e) { 65 | console.error(e); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /src/app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /public/kisahari.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require("tailwindcss/defaultTheme"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: ["class"], 6 | content: ["src/app/**/*.{ts,tsx}", "src/components/**/*.{ts,tsx}"], 7 | theme: { 8 | container: { 9 | center: true, 10 | padding: "2rem", 11 | screens: { 12 | "2xl": "1400px", 13 | }, 14 | }, 15 | extend: { 16 | fontFamily: { 17 | mono: ["var(--font-commit-mono)"], 18 | }, 19 | colors: { 20 | border: "hsl(var(--border))", 21 | input: "hsl(var(--input))", 22 | ring: "hsl(var(--ring))", 23 | background: "hsl(var(--background))", 24 | foreground: "hsl(var(--foreground))", 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | destructive: { 34 | DEFAULT: "hsl(var(--destructive))", 35 | foreground: "hsl(var(--destructive-foreground))", 36 | }, 37 | muted: { 38 | DEFAULT: "hsl(var(--muted))", 39 | foreground: "hsl(var(--muted-foreground))", 40 | }, 41 | accent: { 42 | DEFAULT: "hsl(var(--accent))", 43 | foreground: "hsl(var(--accent-foreground))", 44 | }, 45 | popover: { 46 | DEFAULT: "hsl(var(--popover))", 47 | foreground: "hsl(var(--popover-foreground))", 48 | }, 49 | card: { 50 | DEFAULT: "hsl(var(--card))", 51 | foreground: "hsl(var(--card-foreground))", 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: `var(--radius)`, 56 | md: `calc(var(--radius) - 2px)`, 57 | sm: "calc(var(--radius) - 4px)", 58 | }, 59 | 60 | keyframes: { 61 | "accordion-down": { 62 | from: { height: 0 }, 63 | to: { height: "var(--radix-accordion-content-height)" }, 64 | }, 65 | "accordion-up": { 66 | from: { height: "var(--radix-accordion-content-height)" }, 67 | to: { height: 0 }, 68 | }, 69 | }, 70 | animation: { 71 | "accordion-down": "accordion-down 0.2s ease-out", 72 | "accordion-up": "accordion-up 0.2s ease-out", 73 | }, 74 | }, 75 | }, 76 | plugins: [ 77 | require("@tailwindcss/typography"), 78 | require("tailwindcss-animate"), 79 | require("tailwind-scrollbar"), 80 | ], 81 | }; 82 | -------------------------------------------------------------------------------- /src/components/home/ai-chat.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2Icon, RotateCcwIcon, SparklesIcon } from "lucide-react"; 2 | 3 | const AIChat = ({ 4 | answer, 5 | time, 6 | loading, 7 | askLLM, 8 | setAnswer, 9 | setTime, 10 | setLoading, 11 | }: { 12 | answer: string; 13 | time: string; 14 | loading: boolean; 15 | askLLM: (formData: FormData) => void; 16 | setAnswer: (answer: string) => void; 17 | setTime: (time: string) => void; 18 | setLoading: (loading: boolean) => void; 19 | }) => { 20 | return ( 21 |
26 |
27 |
{ 30 | e.preventDefault(); 31 | askLLM(new FormData(e.target as HTMLFormElement)); 32 | }} 33 | > 34 |
35 | 44 | 53 | 67 |
68 |
69 | 70 | {answer.length > 1 && ( 71 |
72 | {answer} 73 |
74 | )} 75 | {loading && ( 76 |
77 |
78 | {answer.length < 1 ? "thinking" : "streaming answers"} 79 |
80 |
81 | )} 82 | {time && ( 83 |
84 |
{time}
85 |
86 | )} 87 |
88 |
89 | ); 90 | }; 91 | 92 | export default AIChat; 93 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/lib/langchain.ts: -------------------------------------------------------------------------------- 1 | import { Document } from "langchain/document"; 2 | import { OllamaEmbeddings } from "@langchain/community/embeddings/ollama"; 3 | import { MemoryVectorStore } from "langchain/vectorstores/memory"; 4 | import { RecursiveCharacterTextSplitter } from "langchain/text_splitter"; 5 | import { createRetrievalChain } from "langchain/chains/retrieval"; 6 | import { createStuffDocumentsChain } from "langchain/chains/combine_documents"; 7 | import { ChatPromptTemplate } from "@langchain/core/prompts"; 8 | import { ChatOllama } from "@langchain/community/chat_models/ollama"; 9 | import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai"; 10 | 11 | const askAI = async (data: JournalEntry[], llmModel: string, query: string) => { 12 | let docs: Document>[] = []; 13 | 14 | data.forEach((entry) => { 15 | docs.push({ 16 | pageContent: `{content:${entry.content}|tldr:${entry.tldr}|title:${entry.title}}`, 17 | metadata: { 18 | title: entry.title, 19 | created: entry.created, 20 | tldr: entry.tldr, 21 | content: entry.content, 22 | updated: entry.updated, 23 | id: entry.id, 24 | }, 25 | }); 26 | }); 27 | 28 | const splitter = new RecursiveCharacterTextSplitter({ 29 | chunkSize: 1000, 30 | chunkOverlap: 200, 31 | }); 32 | 33 | const splitDocs = await splitter.splitDocuments(docs); 34 | 35 | const embeddings = new OllamaEmbeddings({ 36 | model: llmModel || "nous-hermes2:latest", 37 | }); 38 | 39 | const vectorstore = await MemoryVectorStore.fromDocuments( 40 | splitDocs, 41 | embeddings 42 | ); 43 | 44 | const prompt = ChatPromptTemplate.fromTemplate(` 45 | your name is kisahari. you are an AI within a journaling app. 46 | Your purpose is to assist the user in reflecting on their thoughts through a thoughtful and empathetic approach. 47 | The user will not respond to your prompts. You need to be particularly mindful of time and date. 48 | Your goal is to provide fresh perspectives, encouragement, or even constructive debate, while maintaining concise yet meaningful responses. 49 | current time is ${new Date().toLocaleString()} gmt+8. 50 | Answer the following question based only on the provided context: 51 | 52 | {context} 53 | 54 | 55 | Question: {input}`); 56 | 57 | const model = new ChatOllama({ 58 | baseUrl: "http://localhost:11434", 59 | model: llmModel || "nous-hermes2:latest", 60 | }); 61 | 62 | const documentChain = await createStuffDocumentsChain({ 63 | llm: model, 64 | prompt, 65 | }); 66 | 67 | const retriever = vectorstore.asRetriever(); 68 | 69 | const retrievalChain = await createRetrievalChain({ 70 | combineDocsChain: documentChain, 71 | retriever, 72 | }); 73 | 74 | const result = await retrievalChain.stream({ 75 | input: query || "What are the journal entry for today?", 76 | }); 77 | 78 | return result; 79 | }; 80 | 81 | const askOpenAI = async ( 82 | data: JournalEntry[], 83 | query: string, 84 | openAIKey: string, 85 | modelName: string = "gpt-3.5-turbo" 86 | ) => { 87 | let docs: Document>[] = []; 88 | 89 | data.forEach((entry) => { 90 | docs.push({ 91 | pageContent: `{content:${entry.content}|tldr:${entry.tldr}|title:${entry.title}}`, 92 | metadata: { 93 | title: entry.title, 94 | created: entry.created, 95 | tldr: entry.tldr, 96 | content: entry.content, 97 | updated: entry.updated, 98 | id: entry.id, 99 | }, 100 | }); 101 | }); 102 | 103 | const splitter = new RecursiveCharacterTextSplitter({ 104 | chunkSize: 1000, 105 | chunkOverlap: 200, 106 | }); 107 | 108 | const splitDocs = await splitter.splitDocuments(docs); 109 | 110 | const embeddings = new OpenAIEmbeddings({ 111 | openAIApiKey: openAIKey, 112 | }); 113 | 114 | const vectorstore = await MemoryVectorStore.fromDocuments( 115 | splitDocs, 116 | embeddings 117 | ); 118 | 119 | const prompt = ChatPromptTemplate.fromTemplate(` 120 | your name is kisahari. you are an AI within a journaling app. 121 | Your purpose is to assist the user in reflecting on their thoughts through a thoughtful and empathetic approach. 122 | The user will not respond to your prompts. You need to be particularly mindful of time and date. 123 | Your goal is to provide fresh perspectives, encouragement, or even constructive debate, while maintaining concise yet meaningful responses. 124 | current time is ${new Date().toLocaleString()} gmt+8. 125 | Answer the following question based only on the provided context: 126 | 127 | {context} 128 | 129 | 130 | Question: {input}`); 131 | 132 | const model = new ChatOpenAI({ 133 | openAIApiKey: openAIKey, 134 | modelName, 135 | }); 136 | 137 | const documentChain = await createStuffDocumentsChain({ 138 | llm: model, 139 | prompt, 140 | }); 141 | 142 | const retriever = vectorstore.asRetriever(); 143 | 144 | const retrievalChain = await createRetrievalChain({ 145 | combineDocsChain: documentChain, 146 | retriever, 147 | }); 148 | 149 | const result = await retrievalChain.stream({ 150 | input: query || "What are the journal entry for today?", 151 | }); 152 | 153 | return result; 154 | }; 155 | 156 | export { askAI, askOpenAI }; 157 | -------------------------------------------------------------------------------- /src/app/home.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { formatDate } from "@/lib/utils"; 4 | import { useCallback, useEffect, useState } from "react"; 5 | import { getAllModels } from "@/lib/llm"; 6 | import type { ListResponse } from "ollama"; 7 | 8 | import AIChat from "@/components/home/ai-chat"; 9 | import Footer from "@/components/home/footer"; 10 | import Container from "@/components/home/container"; 11 | import EntriesContainer from "@/components/home/entries-container"; 12 | import Entry from "@/components/home/entry"; 13 | import Header from "@/components/home/header"; 14 | import AIButton from "@/components/home/ai-button"; 15 | import DayProgress from "@/components/home/day-progress"; 16 | import { askAI, askOpenAI } from "@/lib/langchain"; 17 | import useLocalStorage from "@/hooks/use-local-storage"; 18 | 19 | const data = { 20 | title: "KISAHARI", 21 | version: "v040", 22 | footer: { 23 | left_copy: "YOUR PERSONAL JOURNAL", 24 | right_copy: "KISAHARI © ", 25 | }, 26 | }; 27 | 28 | export default function HomePage({ entries }: { entries: JournalEntry[] }) { 29 | const [loading, setLoading] = useState(false); 30 | const [answer, setAnswer] = useState(""); 31 | const [time, setTime] = useState(""); 32 | const [showAI, setShowAI] = useState(false); 33 | const [ollamaModels, setOllamaModels] = useState(); 34 | const [selectedOllamaModel, setSelectedOllamaModel] = useState(""); 35 | 36 | const [openAiModels] = useState({ 37 | models: [ 38 | { 39 | name: "gpt-3.5-turbo", 40 | }, 41 | { 42 | name: "gpt-4", 43 | }, 44 | ], 45 | }); 46 | 47 | const [selectedOpenAiModel, setSelectedOpenAiModel] = 48 | useState("gpt-3.5-turbo"); 49 | const [modelType, setModelType] = useLocalStorage("modelType", "ollama"); 50 | const [openAIKey, setOpenAIKey] = useLocalStorage("openAIKey", null); 51 | const [isLoading, setIsLoading] = useState(true); 52 | 53 | // const openAIKey = useReadLocalStorage("openAIKey"); 54 | 55 | const askLLM = useCallback( 56 | async (formData: FormData) => { 57 | performance.mark("start"); 58 | const q = formData.get("q") as string; 59 | setLoading(true); 60 | 61 | try { 62 | const processStream = async (stream: any) => { 63 | let answer = []; 64 | try { 65 | for await (const chat of stream) { 66 | answer.push(chat.answer); 67 | const ans = answer.join(""); 68 | 69 | setAnswer(ans); 70 | } 71 | performance.mark("end"); 72 | setLoading(false); 73 | performance.measure("askAI", "start", "end"); 74 | setTime( 75 | `TIME:${( 76 | performance.getEntriesByName("askAI")[0].duration / 1000 77 | ).toFixed(1)}s` 78 | ); 79 | } catch (error) { 80 | setLoading(false); 81 | setAnswer("Error: " + error); 82 | } 83 | }; 84 | 85 | if (modelType === "openAI") { 86 | const stream = await askOpenAI( 87 | entries, 88 | q, 89 | openAIKey, 90 | selectedOpenAiModel 91 | ); 92 | processStream(stream); 93 | } 94 | 95 | if (modelType === "ollama") { 96 | const stream = await askAI(entries, selectedOllamaModel, q); 97 | processStream(stream); 98 | } 99 | } catch (error) { 100 | setLoading(false); 101 | setAnswer("Error: " + error); 102 | } 103 | }, 104 | [entries, selectedOllamaModel, selectedOpenAiModel, modelType, openAIKey] 105 | ); 106 | 107 | useEffect(() => { 108 | const getModels = async () => { 109 | const models = await getAllModels(); 110 | setOllamaModels(models); 111 | setIsLoading(false); 112 | }; 113 | getModels(); 114 | }, []); 115 | 116 | useEffect(() => { 117 | setSelectedOllamaModel(ollamaModels?.models[0]?.name || ""); 118 | }, [ollamaModels]); 119 | 120 | useEffect(() => { 121 | const handleKeyDown = (e: KeyboardEvent) => { 122 | if (e.metaKey && e.key === "k") { 123 | setShowAI(!showAI); 124 | } 125 | }; 126 | window.addEventListener("keydown", handleKeyDown); 127 | return () => { 128 | window.removeEventListener("keydown", handleKeyDown); 129 | }; 130 | }, [showAI]); 131 | 132 | return ( 133 | 134 |
150 | 151 | 152 | {entries.map((entry) => ( 153 | 160 | ))} 161 | 162 | 163 | 164 | 165 | {showAI && ( 166 | 177 | )} 178 | 179 | 180 | 181 |