├── api ├── .eslintignore ├── src │ ├── util │ │ ├── prisma.util.ts │ │ ├── sleep.util.ts │ │ ├── is.util.ts │ │ ├── size.util.ts │ │ ├── storage.util.ts │ │ ├── emitter.util.ts │ │ ├── time.util.ts │ │ ├── state.util.ts │ │ ├── redact.util.ts │ │ ├── socket.util.ts │ │ ├── logger.util.ts │ │ └── cron.util.ts │ ├── config │ │ ├── system.ts │ │ ├── default.ts │ │ └── index.ts │ ├── constants │ │ └── http-status.ts │ ├── events │ │ ├── summary.event.ts │ │ ├── transcript.event.ts │ │ └── image.event.ts │ ├── controllers │ │ ├── state.controller.ts │ │ ├── logs.controller.ts │ │ ├── audio.controller.ts │ │ ├── summary.controller.ts │ │ ├── storage.controller.ts │ │ ├── transcript.controller.ts │ │ ├── config.controller.ts │ │ ├── image.controller.ts │ │ └── gallery.controller.ts │ ├── ai │ │ ├── index.ts │ │ ├── util │ │ │ ├── index.ts │ │ │ └── dream-styles.util.ts │ │ ├── deepai.ts │ │ ├── stabilityai.ts │ │ ├── openai.ts │ │ ├── leonardoai.ts │ │ ├── dream.ts │ │ ├── midjourney.ts │ │ └── ai.ts │ ├── app.ts │ └── routes │ │ └── index.ts ├── .prettierrc.js ├── prisma │ ├── migrations │ │ ├── migration_lock.toml │ │ └── 20230522045304_init │ │ │ └── migration.sql │ └── schema.prisma ├── scripts │ ├── dream-styles.ts │ └── splash.ts ├── .eslintrc.js ├── package.json └── server.ts ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.md │ └── bug-report.md └── workflows │ ├── release.yml │ └── build-push.yml ├── .gitattributes ├── .develop └── build ├── .dockerignore ├── frontend ├── src │ ├── assets │ │ ├── scss │ │ │ ├── _variables.scss │ │ │ ├── _functions.scss │ │ │ └── _mixins.scss │ │ ├── font-awesome │ │ │ └── webfonts │ │ │ │ ├── fa-solid-900.ttf │ │ │ │ ├── fa-brands-400.ttf │ │ │ │ ├── fa-regular-400.ttf │ │ │ │ ├── fa-solid-900.woff2 │ │ │ │ ├── fa-brands-400.woff2 │ │ │ │ ├── fa-regular-400.woff2 │ │ │ │ ├── fa-v4compatibility.ttf │ │ │ │ └── fa-v4compatibility.woff2 │ │ ├── fonts │ │ │ ├── roboto-v20-latin-ext_latin-500.woff │ │ │ ├── roboto-v20-latin-ext_latin-500.woff2 │ │ │ ├── roboto-v20-latin-ext_latin-700.woff │ │ │ ├── roboto-v20-latin-ext_latin-700.woff2 │ │ │ ├── roboto-v20-latin-ext_latin-regular.woff │ │ │ ├── roboto-v20-latin-ext_latin-regular.woff2 │ │ │ └── fonts.css │ │ ├── logo.svg │ │ ├── base.scss │ │ └── monaco-themes │ │ │ └── phrame.json │ ├── services │ │ ├── emitter.service.js │ │ ├── plausible.ts │ │ ├── api.service.ts │ │ └── api.class.ts │ ├── utils │ │ ├── sleep.ts │ │ ├── constants.ts │ │ ├── time.ts │ │ ├── socket.ts │ │ ├── functions.ts │ │ └── web-speech-api.ts │ ├── components │ │ ├── icons │ │ │ ├── IconSupport.vue │ │ │ ├── IconTooling.vue │ │ │ ├── IconCommunity.vue │ │ │ ├── IconDocumentation.vue │ │ │ └── IconEcosystem.vue │ │ ├── ControllerTranscript.vue │ │ ├── GalleryRow.vue │ │ ├── GalleryImage.vue │ │ ├── ToolBar.vue │ │ └── FramePhoto.vue │ ├── main.ts │ ├── router │ │ └── index.ts │ ├── views │ │ ├── LogsView.vue │ │ └── ConfigView.vue │ └── App.vue ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── apple-touch-icon.png │ ├── splash │ │ ├── 375x812@3x.png │ │ ├── 390x844@3x.png │ │ ├── 393x852@3x.png │ │ ├── 414x896@2x.png │ │ ├── 414x896@3x.png │ │ ├── 428x926@3x.png │ │ └── 430x932@3x.png │ ├── manifest.json │ ├── favicon.svg │ └── js │ │ ├── plausible.min.js │ │ └── worklets │ │ └── volume-meter-processor.js ├── types │ ├── env.d.ts │ ├── v-lazy-image.d.ts │ └── global.d.ts ├── .editorconfig ├── tsconfig.vite-config.json ├── tsconfig.json ├── lint-staged.config.js ├── .gitignore ├── .prettierrc.js ├── .prettierignore ├── .stylelintignore ├── .eslintignore ├── .vscode │ ├── extensions.json │ └── settings.json ├── stylelint.config.js ├── index.html ├── vite.config.ts ├── package.json └── .eslintrc.js ├── .build ├── entrypoint.sh └── Dockerfile ├── .husky └── commit-msg ├── .prettierrc.js ├── .gitignore ├── docker-compose.yml ├── .vscode └── settings.json ├── .commitlintrc.js ├── LICENSE ├── package.json ├── release.config.js └── CHANGELOG.md /api/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jakowenko 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | frontend/public/** linguist-vendored -------------------------------------------------------------------------------- /.develop/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker build -f ./.build/Dockerfile -t phrame . -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !package.json 4 | !.build/entrypoint.sh 5 | !frontend/* 6 | !api/* -------------------------------------------------------------------------------- /frontend/src/assets/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // Place SCSS variables here 2 | $my-red: red; 3 | -------------------------------------------------------------------------------- /.build/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | node -e 'require("./api/src/config").default()' 3 | npm run phrame -------------------------------------------------------------------------------- /frontend/src/services/emitter.service.js: -------------------------------------------------------------------------------- 1 | import mitt from 'mitt'; 2 | 3 | export default mitt(); 4 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/types/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /api/src/util/prisma.util.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | export default new PrismaClient(); 4 | -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/splash/375x812@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/public/splash/375x812@3x.png -------------------------------------------------------------------------------- /frontend/public/splash/390x844@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/public/splash/390x844@3x.png -------------------------------------------------------------------------------- /frontend/public/splash/393x852@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/public/splash/393x852@3x.png -------------------------------------------------------------------------------- /frontend/public/splash/414x896@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/public/splash/414x896@2x.png -------------------------------------------------------------------------------- /frontend/public/splash/414x896@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/public/splash/414x896@3x.png -------------------------------------------------------------------------------- /frontend/public/splash/428x926@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/public/splash/428x926@3x.png -------------------------------------------------------------------------------- /frontend/public/splash/430x932@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/public/splash/430x932@3x.png -------------------------------------------------------------------------------- /frontend/types/v-lazy-image.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'v-lazy-image' { 2 | const VLazyImage: any; 3 | export default VLazyImage; 4 | } 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | [ -n "$CI" ] && exit 0 5 | npx --no -- commitlint --edit "$1" -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | printWidth: 100, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | }; 7 | -------------------------------------------------------------------------------- /api/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | printWidth: 100, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Phrame", 3 | "short_name": "Phrame", 4 | "start_url": "/", 5 | "display": "standalone" 6 | } 7 | -------------------------------------------------------------------------------- /api/src/util/sleep.util.ts: -------------------------------------------------------------------------------- 1 | export default (sec: number) => { 2 | sec *= 1000; 3 | return new Promise((resolve) => setTimeout(resolve, sec)); 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # frontend 5 | frontend/dist 6 | 7 | # api 8 | .storage 9 | api/dist 10 | 11 | # local env files 12 | .env -------------------------------------------------------------------------------- /frontend/src/assets/font-awesome/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/font-awesome/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /api/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /frontend/src/assets/font-awesome/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/font-awesome/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /frontend/src/assets/font-awesome/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/font-awesome/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /frontend/src/assets/font-awesome/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/font-awesome/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/font-awesome/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/font-awesome/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/font-awesome/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/font-awesome/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto-v20-latin-ext_latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/fonts/roboto-v20-latin-ext_latin-500.woff -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto-v20-latin-ext_latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/fonts/roboto-v20-latin-ext_latin-500.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto-v20-latin-ext_latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/fonts/roboto-v20-latin-ext_latin-700.woff -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto-v20-latin-ext_latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/fonts/roboto-v20-latin-ext_latin-700.woff2 -------------------------------------------------------------------------------- /frontend/src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-promise-executor-return 2 | export default (time: number) => new Promise((resolve) => setTimeout(resolve, time)); 3 | -------------------------------------------------------------------------------- /frontend/types/global.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | interface Window { 5 | SpeechRecognition: any; 6 | webkitSpeechRecognition: any; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/assets/font-awesome/webfonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/font-awesome/webfonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto-v20-latin-ext_latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/fonts/roboto-v20-latin-ext_latin-regular.woff -------------------------------------------------------------------------------- /frontend/src/assets/font-awesome/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/font-awesome/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto-v20-latin-ext_latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakowenko/phrame/HEAD/frontend/src/assets/fonts/roboto-v20-latin-ext_latin-regular.woff2 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Discord 4 | url: https://discord.gg/EeVrVrCkWZ 5 | about: Get help from other users on Discord 6 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 120 8 | -------------------------------------------------------------------------------- /api/src/config/system.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | port: 3000, 3 | storage: { 4 | path: './.storage', 5 | config: { path: './.storage/config' }, 6 | image: { path: './.storage/image' }, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /api/src/constants/http-status.ts: -------------------------------------------------------------------------------- 1 | export const OK = 200; 2 | export const NO_CONTENT = 204; 3 | export const SERVER_ERROR = 500; 4 | export const BAD_REQUEST = 400; 5 | export const UNAUTHORIZED = 401; 6 | export const NOT_FOUND = 404; 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | phrame: 5 | container_name: phrame 6 | image: jakowenko/phrame 7 | restart: unless-stopped 8 | volumes: 9 | - ./.storage:/.storage 10 | ports: 11 | - 3000:3000 12 | -------------------------------------------------------------------------------- /frontend/tsconfig.vite-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node"], 7 | "allowJs": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /api/src/util/is.util.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | json: (value: any) => { 3 | try { 4 | JSON.parse(value); 5 | } catch (e) { 6 | return false; 7 | } 8 | return true; 9 | }, 10 | object: (value: any) => value && typeof value === 'object' && value instanceof Object, 11 | }; 12 | -------------------------------------------------------------------------------- /api/src/util/size.util.ts: -------------------------------------------------------------------------------- 1 | export const bytesToSize = (bytes: number) => { 2 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; 3 | if (bytes === 0) return '0 Bytes'; 4 | const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString(), 10); 5 | return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`; 6 | }; 7 | -------------------------------------------------------------------------------- /api/src/events/summary.event.ts: -------------------------------------------------------------------------------- 1 | import prisma from '../util/prisma.util'; 2 | import { emitter } from '../util/emitter.util'; 3 | 4 | emitter.on('summary.create', async (summary: string) => { 5 | if (!summary) return; 6 | const data = await prisma.summary.create({ data: { summary } }); 7 | emitter.emit('image.create', data); 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/src/components/icons/IconSupport.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /api/src/util/storage.util.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import config from '../config'; 4 | 5 | const { 6 | SYSTEM: { STORAGE }, 7 | } = config(); 8 | 9 | export default { 10 | setup: () => { 11 | const folders = [STORAGE.IMAGE.PATH]; 12 | folders.forEach((folder) => { 13 | if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true }); 14 | }); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/assets/scss/_functions.scss: -------------------------------------------------------------------------------- 1 | // Source: https://css-tricks.com/snippets/sass/px-to-em-functions/ 2 | @function rem($pixels, $context: 16) { 3 | @return (math.div($pixels, $context)) * 1rem; 4 | } 5 | 6 | @function em($pixels, $context) { 7 | @return (math.div($pixels, $context)) * 1em; 8 | } 9 | 10 | @function letter-spacing($pxAV, $fontSize) { 11 | @return em($pxAV, $fontSize); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | api: `${ 3 | process.env.NODE_ENV === 'production' 4 | ? window.location.origin 5 | : `${window.location.origin.replace(':8080', ':3000')}` 6 | }/api`, 7 | socket: `${ 8 | process.env.NODE_ENV === 'production' 9 | ? window.location.origin 10 | : `${window.location.origin.replace(':8080', ':3000')}` 11 | }`, 12 | }); 13 | -------------------------------------------------------------------------------- /api/src/controllers/state.controller.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { OK } from '../constants/http-status'; 4 | import state from '../util/state.util'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get('/', async (req, res) => { 9 | res.send(state.get()); 10 | }); 11 | 12 | router.patch('/', async (req, res) => { 13 | state.patch(req.body); 14 | res.send(OK); 15 | }); 16 | 17 | export default router; 18 | -------------------------------------------------------------------------------- /frontend/src/services/plausible.ts: -------------------------------------------------------------------------------- 1 | // if (import.meta.env.VITE_DEPLOY_ENV === 'prod' && import.meta.env.VITE_FRONTEND_URL) { 2 | // const script = document.createElement('script'); 3 | // script.setAttribute('data-domain', import.meta.env.VITE_FRONTEND_URL.replace(/(^\w+:|^)\/\//, '')); // Remove protocol from URL 4 | // script.setAttribute('src', 'https://analytics-url.com'); 5 | // document.head.appendChild(script); 6 | // } 7 | 8 | export {}; 9 | -------------------------------------------------------------------------------- /api/scripts/dream-styles.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | type Style = { 4 | id: number; 5 | name: string; 6 | }; 7 | 8 | (async () => { 9 | const { data } = await axios({ 10 | method: 'get', 11 | url: 'https://api.luan.tools/api/styles', 12 | }); 13 | const styles = data 14 | .map(({ id, name }: Style) => ({ id, style: name })) 15 | .sort((a: Style, b: Style) => a.id - b.id); 16 | console.log(JSON.stringify(styles, null, 4)); 17 | })(); 18 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.json", 3 | "include": ["types/*.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": [".built/**/*", "**/node_modules"], 5 | "compilerOptions": { 6 | "baseUrl": ".", 7 | "paths": { 8 | "@/*": ["./src/*"] 9 | }, 10 | "outDir": "./.built", 11 | "allowJs": true 12 | }, 13 | 14 | "references": [ 15 | { 16 | "path": "./tsconfig.vite-config.json" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /frontend/lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{ts,tsx,js,jsx}': ['npm run lint:eslint --', 'npm run lint:prettier --'], 3 | '{!(package)*.json,*.code-snippets,.!(browserslist|npm)*rc}': ['npm run lint:prettier -- --parser json'], 4 | 'package.json': ['npm run lint:prettier --'], 5 | '*.vue': ['npm run lint:eslint --', 'npm run lint:stylelint -- --aei', 'npm run lint:prettier --'], 6 | '*.{css,scss}': ['npm run lint:stylelint -- --aei', 'npm run lint:prettier --'], 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for Phrame 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /frontend/src/assets/base.scss: -------------------------------------------------------------------------------- 1 | @import './fonts/fonts.css'; 2 | 3 | *, 4 | *::before, 5 | *::after { 6 | position: relative; 7 | box-sizing: border-box; 8 | margin: 0; 9 | 10 | } 11 | 12 | body { 13 | font-family: Roboto, Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Fira Sans', 14 | 'Droid Sans', 'Helvetica Neue', sans-serif; 15 | font-size: 15px; 16 | text-rendering: optimizelegibility; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | } 20 | 21 | strong { font-weight: 600; } 22 | -------------------------------------------------------------------------------- /frontend/.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 | !.vscode/settings.json 24 | .idea 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | 31 | # Vault-managed Env Files 32 | .env.* 33 | 34 | # .terraform directories 35 | **/.terraform/ 36 | -------------------------------------------------------------------------------- /api/src/ai/index.ts: -------------------------------------------------------------------------------- 1 | const services: any = async (ai: string) => import(`./${ai}`); 2 | 3 | export default { 4 | transcription: async (ai: string, files: string[]) => 5 | new (await services(ai)).default().transcription(files), 6 | summary: async (ai: string, meta: { transcripts: string[] }) => 7 | new (await services(ai)).default(meta).summary(), 8 | image: async (ai: string, meta: { summary: { id: number; summary: string } }) => 9 | new (await services(ai)).default(meta).image(), 10 | test: async (ai: string) => new (await services(ai)).default().test(), 11 | }; 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { "mode": "auto" } 4 | ], 5 | "cSpell.words": [ 6 | "jakowenko", 7 | "phrame", 8 | "openai", 9 | "midjourney", 10 | "stabilityai", 11 | "deepai", 12 | "leonardoai", 13 | "autogen", 14 | "mousemove", 15 | "pillarbox", 16 | "customizer", 17 | "ydotool", 18 | "ccpa", 19 | "pecr", 20 | "subpage", 21 | "stylelint", 22 | "loglevel", 23 | "vueuse", 24 | "luxon", 25 | "primeflex", 26 | "primeicons", 27 | "primevue", 28 | "rushstack" 29 | ] 30 | } -------------------------------------------------------------------------------- /frontend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: true, 4 | endOfLine: 'lf', 5 | htmlWhitespaceSensitivity: 'css', 6 | bracketSameLine: false, 7 | jsxSingleQuote: true, 8 | printWidth: 120, 9 | proseWrap: 'never', 10 | quoteProps: 'as-needed', 11 | semi: true, 12 | singleQuote: true, 13 | tabWidth: 2, 14 | trailingComma: 'all', 15 | useTabs: false, 16 | vueIndentScriptAndStyle: false, 17 | overrides: [ 18 | { 19 | files: '*.html', 20 | options: { 21 | parser: 'html', 22 | }, 23 | }, 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'wip'], 8 | ], 9 | 'subject-case': [ 10 | 2, 11 | 'always', 12 | [ 13 | 'lower-case', 14 | 'upper-case', 15 | 'camel-case', 16 | 'kebab-case', 17 | 'pascal-case', 18 | 'sentence-case', 19 | 'snake-case', 20 | 'start-case', 21 | ], 22 | ], 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /api/src/util/emitter.util.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import socket from './socket.util'; 4 | import state from './state.util'; 5 | 6 | const setup = async (): Promise => { 7 | await import('../events/transcript.event'); 8 | await import('../events/summary.event'); 9 | await import('../events/image.event'); 10 | }; 11 | 12 | export const emitter = new EventEmitter(); 13 | 14 | emitter.on('to', ({ to, ...obj }) => { 15 | socket.emit('to', { to, ...obj }); 16 | }); 17 | 18 | emitter.on('state:get', ({ to }) => { 19 | socket.emit('state:get', { to, ...state.get() }); 20 | }); 21 | 22 | export default { setup, emitter }; 23 | -------------------------------------------------------------------------------- /frontend/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | export default { 4 | format: (ISO: string, format: string) => DateTime.fromISO(ISO).toFormat(format), 5 | ago: (ISO: string) => { 6 | const units = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'] as const; 7 | const dateTime = DateTime.fromISO(ISO); 8 | const diff = dateTime.diffNow().shiftTo(...units); 9 | const unit = units.find((u) => diff.get(u) !== 0) || 'seconds'; 10 | const relativeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); 11 | return relativeFormatter.format(Math.trunc(diff.as(unit)), unit); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a bug report to improve Phrame 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **Version of Phrame** 13 | `X.X.X-SHA7` 14 | 15 | **Expected behavior** 16 | A clear and concise description of what you expected to happen. 17 | 18 | **Screenshots** 19 | If applicable, add screenshots to help explain your problem. 20 | 21 | **Hardware** 22 | 23 | - OS: [e.g. Ubuntu, macOS, Windows] 24 | - Browser (if applicable) [e.g. Chrome, Safari] 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | # Extends the .gitignore 2 | 3 | # Workspace files 4 | /.github/** 5 | /.husky/** 6 | /scripts/** 7 | .npmrc 8 | 9 | # Following are from .gitignore 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | pnpm-debug.log* 17 | lerna-debug.log* 18 | 19 | node_modules 20 | .DS_Store 21 | dist 22 | dist-ssr 23 | coverage 24 | *.local 25 | 26 | /cypress/videos/ 27 | /cypress/screenshots/ 28 | 29 | # Editor directories and files 30 | .vscode/* 31 | !.vscode/extensions.json 32 | .idea 33 | *.suo 34 | *.ntvs* 35 | *.njsproj 36 | *.sln 37 | *.sw? 38 | 39 | # Vault-managed Env Files 40 | .env.* 41 | 42 | # .terraform directories 43 | **/.terraform/ 44 | -------------------------------------------------------------------------------- /frontend/.stylelintignore: -------------------------------------------------------------------------------- 1 | # Extends the .gitignore 2 | 3 | # Workspace files 4 | /.github/** 5 | /.husky/** 6 | /scripts/** 7 | .npmrc 8 | 9 | # Following are from .gitignore 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | pnpm-debug.log* 17 | lerna-debug.log* 18 | 19 | node_modules 20 | .DS_Store 21 | dist 22 | dist-ssr 23 | coverage 24 | *.local 25 | 26 | /cypress/videos/ 27 | /cypress/screenshots/ 28 | 29 | # Editor directories and files 30 | .vscode/* 31 | !.vscode/extensions.json 32 | .idea 33 | *.suo 34 | *.ntvs* 35 | *.njsproj 36 | *.sln 37 | *.sw? 38 | 39 | # Vault-managed Env Files 40 | .env.* 41 | 42 | # .terraform directories 43 | **/.terraform/ 44 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import PrimeVue from 'primevue/config'; 3 | import ToastService from 'primevue/toastservice'; 4 | import ConfirmationService from 'primevue/confirmationservice'; 5 | import Tooltip from 'primevue/tooltip'; 6 | 7 | import App from './App.vue'; 8 | import router from './router'; 9 | import emitter from './services/emitter.service'; 10 | 11 | // Create the Vue app instance 12 | const app = createApp(App); 13 | 14 | app.use(PrimeVue); 15 | app.use(ToastService); 16 | app.use(ConfirmationService); 17 | app.directive('tooltip', Tooltip); 18 | 19 | // Use Vue Router 20 | app.use(router); 21 | app.config.globalProperties.emitter = emitter; 22 | app.mount('#app'); 23 | -------------------------------------------------------------------------------- /frontend/src/services/api.service.ts: -------------------------------------------------------------------------------- 1 | // Disable prefer-default-export to keep a consistent import structure when one vs multiple services are used. 2 | /* eslint-disable import/prefer-default-export */ 3 | import constants from '@/utils/constants'; 4 | /** 5 | * Default ApiService. 6 | * This uses the default environment variable VUE_APP_API_URL. 7 | * If you need another API instance, append another export as seen in the example below. 8 | * 9 | * Usage: 10 | * import { ApiService, S3ApiService } from '@/services/api.service'; 11 | */ 12 | import ApiInstance from './api.class'; 13 | 14 | // Create a new Api passing in an Axios configuration object. 15 | export const ApiService = new ApiInstance({ 16 | baseURL: constants().api, 17 | }); 18 | -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | # Extends the .gitignore 2 | 3 | # Workspace files 4 | /.github/** 5 | /.husky/** 6 | /scripts/** 7 | .npmrc 8 | 9 | # Typescript build files 10 | /.built/** 11 | 12 | # Following are from .gitignore 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | pnpm-debug.log* 20 | lerna-debug.log* 21 | 22 | node_modules 23 | .DS_Store 24 | dist 25 | dist-ssr 26 | coverage 27 | *.local 28 | 29 | /cypress/videos/ 30 | /cypress/screenshots/ 31 | 32 | # Editor directories and files 33 | .vscode/* 34 | !.vscode/extensions.json 35 | .idea 36 | *.suo 37 | *.ntvs* 38 | *.njsproj 39 | *.sln 40 | *.sw? 41 | 42 | # Vault-managed Env Files 43 | .env.* 44 | 45 | # .terraform directories 46 | **/.terraform/ 47 | -------------------------------------------------------------------------------- /api/src/controllers/logs.controller.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import express from 'express'; 3 | 4 | import config from '../config'; 5 | import readLastLines from 'read-last-lines'; 6 | import { bytesToSize } from '../util/size.util'; 7 | 8 | const router = express.Router(); 9 | 10 | const { 11 | SYSTEM: { STORAGE }, 12 | } = config(); 13 | 14 | router.get('/', async (req, res) => { 15 | const { size } = fs.statSync(`${STORAGE.PATH}/messages.log`); 16 | const logs = await readLastLines.read(`${STORAGE.PATH}/messages.log`, 500); 17 | res.send({ size: bytesToSize(size), logs }); 18 | }); 19 | 20 | router.delete('/', async (req, res) => { 21 | fs.writeFileSync(`${STORAGE.PATH}/messages.log`, ''); 22 | res.send(); 23 | }); 24 | 25 | export default router; 26 | -------------------------------------------------------------------------------- /api/src/controllers/audio.controller.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import express from 'express'; 3 | import multer from 'multer'; 4 | 5 | import config from '../config'; 6 | import Log from '../util/logger.util'; 7 | 8 | const router = express.Router(); 9 | 10 | const { 11 | SYSTEM: { STORAGE }, 12 | } = config(); 13 | 14 | router.post( 15 | '/audio', 16 | multer({ storage: multer.memoryStorage() }).single('file'), 17 | async (req, res) => { 18 | const { log } = new Log('audio'); 19 | if (req?.file?.buffer && req?.file?.size) { 20 | const file = `${STORAGE.PATH}/audio/${Date.now()}.${req.file?.mimetype.split('/').pop()}`; 21 | fs.writeFileSync(file, req.file.buffer); 22 | log.info(`saved: ${file}`); 23 | } 24 | res.send('OK'); 25 | } 26 | ); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /api/src/controllers/summary.controller.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import config from '../config'; 4 | import { BAD_REQUEST } from '../constants/http-status'; 5 | import { emitter } from '../util/emitter.util'; 6 | 7 | const router = express.Router(); 8 | 9 | router.post('/', async (req, res) => { 10 | const { summary } = req.body; 11 | emitter.emit('summary.create', summary); 12 | res.send('OK'); 13 | }); 14 | 15 | router.get('/random', async (req, res) => { 16 | if (!config.ai().some((obj) => obj.ai === 'openai')) 17 | return res.status(BAD_REQUEST).send({ error: 'OpenAI not configured' }); 18 | 19 | const openai = (await import('../ai/openai')).default; 20 | const summary = await new openai().random({ context: req.query?.summary?.toString() }); 21 | res.send(summary); 22 | }); 23 | 24 | export default router; 25 | -------------------------------------------------------------------------------- /api/src/util/time.util.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | enum TimeUnit { 4 | YEAR = 'year', 5 | MONTH = 'month', 6 | WEEK = 'week', 7 | DAY = 'day', 8 | HOUR = 'hour', 9 | MINUTE = 'minute', 10 | SECOND = 'second', 11 | } 12 | 13 | const enumToArray = (enumObj: T): T[keyof T][] => { 14 | return Object.values(enumObj as { [key: string]: T[keyof T] }); 15 | }; 16 | 17 | export default { 18 | ago: (date: any) => { 19 | const dateTime = DateTime.fromISO(date.toISOString()); 20 | const units = enumToArray(TimeUnit); 21 | const diff = dateTime.diffNow().shiftTo(...units); 22 | const unit = units.find((u) => diff.get(u) !== 0) || 'second'; 23 | 24 | const relativeFormatter = new Intl.RelativeTimeFormat('en', { 25 | numeric: 'auto', 26 | }); 27 | return relativeFormatter.format(Math.trunc(diff.as(unit)), unit); 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | // Language Support plugin built specifically for Vue 3 4 | // https://github.com/johnsoncodehk/volar 5 | "Vue.volar", 6 | "Vue.vscode-typescript-vue-plugin", 7 | 8 | // Lint-on-save with ESLint 9 | // https://github.com/microsoft/vscode-eslint 10 | "dbaeumer.vscode-eslint", 11 | 12 | // Lint-on-save with Stylelint 13 | // https://github.com/stylelint/vscode-stylelint 14 | "stylelint.vscode-stylelint", 15 | 16 | // Format-on-save with Prettier 17 | // https://github.com/prettier/prettier-vscode 18 | "esbenp.prettier-vscode", 19 | 20 | // SCSS intellisense 21 | // https://github.com/mrmlnc/vscode-scss 22 | "mrmlnc.vscode-scss", 23 | 24 | // Vue Code Snippets 25 | // https://github.com/sdras/vue-vscode-snippets 26 | "sdras.vue-vscode-snippets" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/assets/scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | // Mobile-first responsive breakpoints 2 | $breakpoints: ( 3 | sm: 576px, 4 | md: 768px, 5 | lg: 992px, 6 | xl: 1200px, 7 | xxl: 1600px, 8 | ) !default; 9 | 10 | @mixin bp-sm-phone-landscape { 11 | @media (min-width: map-get($breakpoints, 'sm')) { 12 | @content; 13 | } 14 | } 15 | 16 | @mixin bp-md-tablet { 17 | @media (min-width: map-get($breakpoints, 'md')) { 18 | @content; 19 | } 20 | } 21 | 22 | @mixin bp-lg-laptop { 23 | @media (min-width: map-get($breakpoints, 'lg')) { 24 | @content; 25 | } 26 | } 27 | 28 | @mixin bp-xl-desktop { 29 | @media (min-width: map-get($breakpoints, 'xl')) { 30 | @content; 31 | } 32 | } 33 | 34 | @mixin bp-xxl-desktop-large { 35 | @media (min-width: map-get($breakpoints, 'xxl')) { 36 | @content; 37 | } 38 | } 39 | 40 | // Custom sizes 41 | @mixin bp-custom-min($min-width) { 42 | @media (min-width: ($min-width * 1px)) { 43 | @content; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/utils/socket.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | import { io, Socket } from 'socket.io-client'; 3 | import constants from '@/utils/constants'; 4 | 5 | interface StateSocket extends Socket { 6 | state: { 7 | connected: boolean; 8 | }; 9 | } 10 | 11 | const socket = io(constants().socket) as StateSocket; 12 | socket.state = reactive({ 13 | connected: false, 14 | }); 15 | 16 | socket.on('connect', () => { 17 | console.log('connected'); 18 | socket.state.connected = true; 19 | socket.emit('register.url', window.location.href); 20 | }); 21 | 22 | socket.on('disconnect', () => { 23 | console.log('disconnect'); 24 | socket.state.connected = false; 25 | }); 26 | 27 | socket.on('connect_error', () => { 28 | console.log('connect_error'); 29 | socket.state.connected = false; 30 | }); 31 | 32 | socket.on('reconnect_error', () => { 33 | console.log('reconnect'); 34 | socket.state.connected = false; 35 | }); 36 | 37 | export default socket; 38 | -------------------------------------------------------------------------------- /frontend/stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | // Use the Standard config as the base 4 | // https://github.com/stylelint-scss/stylelint-config-standard-scss 5 | 'stylelint-config-standard-scss', 6 | // Configure rules for Vue 7 | // https://www.npmjs.com/package/stylelint-config-recommended-vue 8 | 'stylelint-config-standard-vue/scss', 9 | // Enforce a standard order for CSS properties 10 | // https://github.com/stormwarning/stylelint-config-recess-order 11 | 'stylelint-config-recess-order', 12 | ], 13 | // Rule lists: 14 | // - https://stylelint.io/user-guide/rules/ 15 | // - https://github.com/kristerkari/stylelint-scss#list-of-rules 16 | rules: { 17 | // Disallow allow global element/type selectors in scoped modules 18 | 'selector-max-type': [0, { ignore: ['child', 'descendant', 'compounded'] }], 19 | 'selector-class-pattern': null, 20 | // 'no-descending-specificity': null, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | amd: true, 5 | }, 6 | extends: ['airbnb-base', 'plugin:prettier/recommended'], 7 | plugins: ['prettier'], 8 | parserOptions: { 9 | ecmaVersion: 2020, 10 | }, 11 | rules: { 12 | 'no-unused-vars': ['error', { argsIgnorePattern: 'next' }], 13 | 'linebreak-style': 0, 14 | 'no-console': 0, 15 | 'no-plusplus': 0, 16 | 'max-len': 0, 17 | 'no-return-assign': 0, 18 | 'no-await-in-loop': 0, 19 | indent: 0, // Allowing prettier to handle this 20 | 'consistent-return': 0, 21 | 'comma-dangle': 0, 22 | 'operator-linebreak': 0, 23 | 'implicit-arrow-linebreak': 0, 24 | 'function-paren-newline': 0, 25 | 'object-curly-newline': 0, 26 | 'newline-per-chained-call': 0, 27 | 'prettier/prettier': 'error', 28 | 'no-param-reassign': 0, 29 | 'no-restricted-syntax': 0, 30 | 'no-nested-ternary': 0, 31 | 'global-require': 0, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /api/src/util/state.util.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import _ from 'lodash'; 3 | 4 | import is from '../util/is.util'; 5 | import config from '../config'; 6 | 7 | const { 8 | SYSTEM: { STORAGE }, 9 | } = config(); 10 | 11 | let STATE: { [name: string]: any } = { 12 | processing: false, 13 | cron: true, 14 | autogen: false, 15 | image: { 16 | index: 0, 17 | summary: true, 18 | cycle: true, 19 | }, 20 | microphone: { 21 | enabled: null, 22 | }, 23 | }; 24 | 25 | if (fs.existsSync(`${STORAGE.PATH}/state.json`)) { 26 | let tempState = fs.readFileSync(`${STORAGE.PATH}/state.json`, 'utf-8'); 27 | if (is.json(tempState)) { 28 | STATE = JSON.parse(tempState); 29 | STATE.processing = false; 30 | } 31 | } 32 | 33 | export default { 34 | get: () => STATE, 35 | patch: (obj: { [name: string]: any }) => { 36 | STATE = _.mergeWith(STATE, obj); 37 | fs.writeFileSync(`${STORAGE.PATH}/state.json`, JSON.stringify(STATE)); 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /frontend/src/assets/monaco-themes/phrame.json: -------------------------------------------------------------------------------- 1 | { 2 | "base": "vs-dark", 3 | "inherit": true, 4 | "rules": [ 5 | { "token": "type", "foreground": "EAEAEA", "fontStyle": "bold" }, 6 | { "token": "string", "foreground": "D1D1D1" }, 7 | { "token": "comment", "foreground": "6B6B6B" }, 8 | { "token": "number", "foreground": "B19DF7" }, 9 | { "token": "keyword", "foreground": "B19DF7" } 10 | ], 11 | "colors": { 12 | "editor.foreground": "#D1D1D1", 13 | "editor.background": "#24252E", 14 | "editor.selectionBackground": "#44475A", 15 | "editor.lineHighlightBackground": "#44475A", 16 | "editorCursor.foreground": "#EAEAEA", 17 | "editorWhitespace.foreground": "#44475A", 18 | "editorIndentGuide.background": "#404040", 19 | "editorIndentGuide.activeBackground": "#404040", 20 | "scrollbarSlider.background": "#8E8F93", 21 | "scrollbarSlider.hoverBackground": "#8E8F93", 22 | "scrollbarSlider.activeBackground": "#8E8F93" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /api/src/controllers/storage.controller.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import express from 'express'; 3 | import sharp from 'sharp'; 4 | 5 | import { BAD_REQUEST } from '../constants/http-status'; 6 | import config from '../config'; 7 | 8 | const router = express.Router(); 9 | 10 | const { 11 | SYSTEM: { STORAGE }, 12 | } = config(); 13 | 14 | router.get('/image/:filename', async (req, res) => { 15 | const isThumb = req.query.thumb === ''; 16 | const { filename } = req.params; 17 | const source = `${STORAGE.IMAGE.PATH}/${filename}`; 18 | 19 | if (!fs.existsSync(source)) { 20 | return res.status(BAD_REQUEST).send(`${source} does not exist`); 21 | } 22 | 23 | const buffer = isThumb 24 | ? await sharp(source, { failOnError: false }) 25 | .jpeg({ quality: 80 }) 26 | .resize(350) 27 | .withMetadata() 28 | .toBuffer() 29 | : fs.readFileSync(source); 30 | res.set('Content-Type', 'image/png'); 31 | res.end(buffer); 32 | }); 33 | 34 | export default router; 35 | -------------------------------------------------------------------------------- /frontend/src/components/icons/IconTooling.vue: -------------------------------------------------------------------------------- 1 | 2 | 20 | -------------------------------------------------------------------------------- /api/src/util/redact.util.ts: -------------------------------------------------------------------------------- 1 | import config from '../config'; 2 | 3 | const { 4 | LOGS: { LEVEL }, 5 | } = config(); 6 | 7 | const KEYS = [ 8 | // generic 9 | /passw(or)?d/i, 10 | /key/, 11 | /^pw$/, 12 | /^pass$/i, 13 | /secret/i, 14 | /token/i, 15 | /api[-._]?key/i, 16 | /session[-._]?id/i, 17 | /^connect\.sid$/, 18 | ]; 19 | 20 | const key = (str: any) => KEYS.some((regex) => regex.test(str)); 21 | 22 | const traverse = (obj: { [name: string]: any }, value: any) => { 23 | const o = obj; 24 | Object.keys(o).forEach((k) => { 25 | if (o[k] !== null && typeof o[k] === 'object') { 26 | traverse(o[k], value); 27 | return; 28 | } 29 | if (typeof o[k] === 'string') { 30 | if (key(k)) o[k] = value; 31 | } 32 | }); 33 | return o; 34 | }; 35 | 36 | export default (obj: {}, value = '*** REDACTED IN LOGS ***') => { 37 | try { 38 | return LEVEL === 'silly' ? obj : traverse(JSON.parse(JSON.stringify(obj)), value); 39 | } catch (error) { 40 | return obj; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - beta 8 | jobs: 9 | release: 10 | name: Release 11 | if: github.repository == 'jakowenko/phrame' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | persist-credentials: false 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 18 23 | - name: Install dependencies 24 | run: npm i --no-save semantic-release @semantic-release/changelog @semantic-release/exec @semantic-release/git 25 | - name: Release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_PAT }} 28 | GIT_AUTHOR_NAME: David Jakowenko 29 | GIT_AUTHOR_EMAIL: ${{ secrets.SEMANTIC_RELEASE_EMAIL }} 30 | GIT_COMMITTER_NAME: David Jakowenko 31 | GIT_COMMITTER_EMAIL: ${{ secrets.SEMANTIC_RELEASE_EMAIL }} 32 | run: npx semantic-release 33 | -------------------------------------------------------------------------------- /api/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = "file:../../.storage/db.db" 8 | } 9 | 10 | model Transcript { 11 | id Int @id @default(autoincrement()) 12 | transcript String 13 | createdAt DateTime @default(now()) 14 | } 15 | 16 | model Summary { 17 | id Int @id @default(autoincrement()) 18 | summary String 19 | createdAt DateTime @default(now()) 20 | image Image[] 21 | } 22 | 23 | model Image { 24 | id Int @id @default(autoincrement()) 25 | summary Summary @relation(fields: [summaryId], references: [id]) 26 | summaryId Int 27 | filename String 28 | favorite Boolean @default(false) 29 | createdAt DateTime @default(now()) 30 | meta Meta[] 31 | } 32 | 33 | model Meta { 34 | id Int @id @default(autoincrement()) 35 | image Image @relation(fields: [imageId], references: [id]) 36 | imageId Int 37 | key String 38 | value String 39 | createdAt DateTime @default(now()) 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/components/icons/IconCommunity.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /api/src/events/transcript.event.ts: -------------------------------------------------------------------------------- 1 | import prisma from '../util/prisma.util'; 2 | import socket from '../util/socket.util'; 3 | import { emitter } from '../util/emitter.util'; 4 | import ai from '../ai'; 5 | 6 | emitter.on( 7 | 'transcript.create', 8 | async (transcript: string, { socket = false }: { socket: boolean }) => { 9 | const data = await prisma.transcript.create({ 10 | data: { 11 | transcript, 12 | }, 13 | }); 14 | if (socket) emitter.emit('to', { to: 'controller', transcript: data }); 15 | } 16 | ); 17 | 18 | emitter.on('transcript.process', async (ids: number[]) => { 19 | const transcripts: string[] = ( 20 | await prisma.transcript.findMany({ 21 | where: { id: { in: ids } }, 22 | }) 23 | ).map(({ transcript }: { transcript: string }) => transcript); 24 | await prisma.transcript.deleteMany({ 25 | where: { id: { in: ids } }, 26 | }); 27 | socket.emit('to', { to: 'controller', reloadTranscript: true }); 28 | 29 | const summary = await ai.summary('openai', { transcripts }); 30 | emitter.emit('summary.create', summary); 31 | }); 32 | -------------------------------------------------------------------------------- /api/src/ai/util/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import fs from 'fs'; 3 | 4 | import config from '../../config'; 5 | 6 | const { 7 | SYSTEM: { STORAGE }, 8 | } = config(); 9 | 10 | type DownloadImageFromURL = { 11 | ai: string; 12 | url?: string; 13 | style?: string; 14 | }; 15 | 16 | type SaveImageFromBuffer = { 17 | ai: string; 18 | base64?: string; 19 | style?: string; 20 | }; 21 | 22 | export const downloadImageFromURL = async ({ url, ai, style }: DownloadImageFromURL) => { 23 | const { data } = await axios({ 24 | method: 'get', 25 | url: url, 26 | responseType: 'arraybuffer', 27 | timeout: 15000, 28 | }); 29 | const filename = `${Date.now()}-${ai}-${style}.png`; 30 | fs.writeFileSync(`${STORAGE.IMAGE.PATH}/${filename}`, data); 31 | return { filename }; 32 | }; 33 | 34 | export const saveImageFromBuffer = async ({ base64, ai, style }: SaveImageFromBuffer) => { 35 | const filename = `${Date.now()}-${ai}-${style}.png`; 36 | if (base64) fs.writeFileSync(`${STORAGE.IMAGE.PATH}/${filename}`, Buffer.from(base64, 'base64')); 37 | return { filename }; 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/src/assets/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Roboto"; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local("Roboto"), local("Roboto-Regular"), url("./roboto-v20-latin-ext_latin-regular.woff2") format("woff2"), url("./roboto-v20-latin-ext_latin-regular.woff") format("woff"); 6 | /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 7 | } 8 | /* roboto-500 - latin-ext_latin */ 9 | @font-face { 10 | font-family: "Roboto"; 11 | font-style: normal; 12 | font-weight: 500; 13 | src: local("Roboto Medium"), local("Roboto-Medium"), url("./roboto-v20-latin-ext_latin-500.woff2") format("woff2"), url("./roboto-v20-latin-ext_latin-500.woff") format("woff"); 14 | /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 15 | } 16 | /* roboto-700 - latin-ext_latin */ 17 | @font-face { 18 | font-family: "Roboto"; 19 | font-style: normal; 20 | font-weight: 700; 21 | src: local("Roboto Bold"), local("Roboto-Bold"), url("./roboto-v20-latin-ext_latin-700.woff2") format("woff2"), url("./roboto-v20-latin-ext_latin-700.woff") format("woff"); 22 | /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 David Jakowenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/public/js/plausible.min.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | !function(){"use strict";var a=window.location,r=window.document,o=r.currentScript,s=o.getAttribute("data-api")||new URL(o.src).origin+"/api/event";function t(t,e){try{if("true"===window.localStorage.plausible_ignore)return void console.warn("Ignoring Event: localStorage flag")}catch(t){}var i={};i.n=t,i.u=a.href,i.d=o.getAttribute("data-domain"),i.r=r.referrer||null,i.w=window.innerWidth,e&&e.meta&&(i.m=JSON.stringify(e.meta)),e&&e.props&&(i.p=e.props);var n=new XMLHttpRequest;n.open("POST",s,!0),n.setRequestHeader("Content-Type","text/plain"),n.send(JSON.stringify(i)),n.onreadystatechange=function(){4===n.readyState&&e&&e.callback&&e.callback()}}var e=window.plausible&&window.plausible.q||[];window.plausible=t;for(var i,n=0;n { 32 | const html = fs.readFileSync( 33 | `${process.cwd()}/frontend/${process.env.NODE_ENV === 'production' ? '' : 'dist/'}index.html`, 34 | 'utf8' 35 | ); 36 | res.send(html); 37 | }); 38 | 39 | app.use((error: any, req: Request, res: Response, next: NextFunction) => { 40 | log.error(error); 41 | res.status(500).send({ error: error.message }); 42 | }); 43 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phrame-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@prisma/client": "^4.16.0", 14 | "axios": "^1.4.0", 15 | "canvas": "^2.11.2", 16 | "cors": "^2.8.5", 17 | "cron": "^2.3.1", 18 | "express": "^4.18.2", 19 | "express-async-errors": "^3.1.1", 20 | "form-data": "^4.0.0", 21 | "js-yaml": "^4.1.0", 22 | "lodash": "^4.17.21", 23 | "luxon": "^3.3.0", 24 | "midjourney": "^3.1.88", 25 | "multer": "^1.4.5-lts.1", 26 | "openai": "^3.3.0", 27 | "read-last-lines": "^1.8.0", 28 | "sharp": "^0.32.1", 29 | "socket.io": "^4.6.2", 30 | "winston": "^3.9.0", 31 | "zod": "^3.21.4", 32 | "zod-to-json-schema": "^3.21.2" 33 | }, 34 | "devDependencies": { 35 | "@types/cron": "^2.0.1", 36 | "@types/express": "^4.17.17", 37 | "@types/js-yaml": "^4.0.5", 38 | "@types/lodash": "^4.14.195", 39 | "@types/multer": "^1.4.7", 40 | "@types/node": "^20.3.1", 41 | "prisma": "^4.16.0", 42 | "typescript": "^5.1.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/components/icons/IconDocumentation.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /api/prisma/migrations/20230522045304_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Transcript" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "transcript" TEXT NOT NULL, 5 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 6 | ); 7 | 8 | -- CreateTable 9 | CREATE TABLE "Summary" ( 10 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 11 | "summary" TEXT NOT NULL, 12 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "Image" ( 17 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 18 | "summaryId" INTEGER NOT NULL, 19 | "filename" TEXT NOT NULL, 20 | "favorite" BOOLEAN NOT NULL DEFAULT false, 21 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | CONSTRAINT "Image_summaryId_fkey" FOREIGN KEY ("summaryId") REFERENCES "Summary" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 23 | ); 24 | 25 | -- CreateTable 26 | CREATE TABLE "Meta" ( 27 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 28 | "imageId" INTEGER NOT NULL, 29 | "key" TEXT NOT NULL, 30 | "value" TEXT NOT NULL, 31 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 32 | CONSTRAINT "Meta_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 33 | ); 34 | -------------------------------------------------------------------------------- /api/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { NOT_FOUND } from '../constants/http-status'; 4 | import transcript from '../controllers/transcript.controller'; 5 | import summary from '../controllers/summary.controller'; 6 | import gallery from '../controllers/gallery.controller'; 7 | import image from '../controllers/image.controller'; 8 | import audio from '../controllers/audio.controller'; 9 | import storage from '../controllers/storage.controller'; 10 | import state from '../controllers/state.controller'; 11 | import logs from '../controllers/logs.controller'; 12 | import config from '../controllers/config.controller'; 13 | import prisma from '../util/prisma.util'; 14 | 15 | const router = express.Router(); 16 | 17 | router.use('/*', (req, res, next) => { 18 | req.prisma = prisma; 19 | next(); 20 | }); 21 | router.use('/transcript', transcript); 22 | router.use('/summary', summary); 23 | router.use('/gallery', gallery); 24 | router.use('/image', image); 25 | router.use('/audio', audio); 26 | router.use('/storage', storage); 27 | router.use('/state', state); 28 | router.use('/logs', logs); 29 | router.use('/config', config); 30 | router.all('*', (req, res) => 31 | res.status(NOT_FOUND).send({ error: `${req.method} ${req.originalUrl} not found` }) 32 | ); 33 | 34 | export default router; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phrame", 3 | "version": "1.1.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "local:api": "nodemon -e yml,js,ts --watch ./.storage/config --watch ./api -q api/server.ts", 7 | "local:frontend": "cd frontend && npm run serve", 8 | "phrame": "npm run prod:migrate && npm run prod:generate && npm run prod:api", 9 | "prod:migrate": "cd api && npx prisma migrate deploy && cd ..", 10 | "prod:generate": "cd api && npx prisma generate > /dev/null && cd ..", 11 | "prod:api": "nodemon -e yml,yaml --watch ./.storage/config -q api/server.js", 12 | "version-bump": "npm version $VERSION --no-git-tag-version --allow-same-version && cd ./api && npm version $VERSION --no-git-tag-version --allow-same-version && cd ../frontend && npm version $VERSION --no-git-tag-version --allow-same-version", 13 | "test": "echo \"Error: no test specified\" && exit 1", 14 | "prepare": "husky install" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://jakowenko@github.com/jakowenko/phrame.git" 19 | }, 20 | "keywords": [], 21 | "author": "", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/jakowenko/phrame/issues" 25 | }, 26 | "homepage": "https://github.com/jakowenko/phrame#readme", 27 | "description": "", 28 | "devDependencies": { 29 | "@commitlint/cli": "^17.6.5", 30 | "@commitlint/config-conventional": "^17.6.5", 31 | "husky": "^8.0.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /api/src/ai/deepai.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import AI, { LogError } from './ai'; 4 | import config from '../config'; 5 | 6 | const { 7 | DEEPAI: { KEY, IMAGE }, 8 | } = config(); 9 | 10 | class DeepAI extends AI { 11 | constructor(meta?: any) { 12 | super('deepai', meta); 13 | this.styles = IMAGE.STYLE; 14 | } 15 | 16 | async generateImage() { 17 | let formData = new FormData(); 18 | formData.append('text', `${this.meta.summary.summary}`); 19 | formData.append('grid_size', IMAGE.GRID_SIZE); 20 | formData.append('width', IMAGE.WIDTH); 21 | formData.append('height', IMAGE.HEIGHT); 22 | if (IMAGE.NEGATIVE_PROMPT) formData.append('negative_prompt', IMAGE.NEGATIVE_PROMPT); 23 | const { data } = await axios({ 24 | method: 'post', 25 | url: `https://api.deepai.org/api/${this.style}`, 26 | timeout: IMAGE.TIMEOUT * 1000, 27 | headers: { 'api-key': KEY }, 28 | data: formData, 29 | }); 30 | this.downloadImage([{ url: data.output_url }]); 31 | } 32 | 33 | async test() { 34 | const { data } = await axios({ 35 | method: 'post', 36 | url: 'https://api.deepai.org/api/text2img', 37 | timeout: 10000, 38 | validateStatus: () => true, 39 | }); 40 | 41 | return data?.status?.toLowerCase().includes('valid api-key') || data; 42 | } 43 | 44 | logError({ type, error }: LogError) { 45 | this.log.error( 46 | `${type}: ${error?.response?.data?.status || error?.response?.data?.err || error}` 47 | ); 48 | } 49 | } 50 | 51 | export default DeepAI; 52 | -------------------------------------------------------------------------------- /api/src/events/image.event.ts: -------------------------------------------------------------------------------- 1 | import ai from '../ai'; 2 | import socket from '../util/socket.util'; 3 | import prisma from '../util/prisma.util'; 4 | import { emitter } from '../util/emitter.util'; 5 | import Log from '../util/logger.util'; 6 | import config from '../config'; 7 | 8 | emitter.on('image.create', async (summary: { id: number; summary: string }) => { 9 | const { log } = new Log('image.create'); 10 | const promises: any[] = []; 11 | const services = config 12 | .ai() 13 | .filter((obj) => obj.services.includes('image')) 14 | .map((obj) => obj.ai); 15 | if (!services.length) { 16 | log.warn('no active ai image services'); 17 | return; 18 | } 19 | services.forEach((service) => { 20 | promises.push(ai.image(service, { summary })); 21 | }); 22 | await Promise.all(promises); 23 | }); 24 | 25 | emitter.on( 26 | 'image.ready', 27 | async ({ 28 | summaryId, 29 | images = [], 30 | }: { 31 | summaryId: number; 32 | images: [{ filename: string }] | []; 33 | }) => { 34 | for (let i = 0; i < images.length; i++) { 35 | const { filename, ...meta }: { filename: string; [key: string]: any } = images[i]; 36 | const { id } = await prisma.image.create({ 37 | data: { summaryId, filename }, 38 | }); 39 | 40 | for (const [key, value] of Object.entries(meta)) { 41 | await prisma.meta.create({ 42 | data: { imageId: id, key, value }, 43 | }); 44 | } 45 | 46 | socket.emit('to', { to: ['frame', 'gallery'], action: 'new-image', summaryId }); 47 | } 48 | } 49 | ); 50 | -------------------------------------------------------------------------------- /api/src/controllers/transcript.controller.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { emitter } from '../util/emitter.util'; 4 | import Log from '../util/logger.util'; 5 | 6 | const { log } = new Log('transcript'); 7 | 8 | const router = express.Router(); 9 | 10 | router.get('/', async (req, res) => { 11 | const { beforeId } = req.query; 12 | const transcriptCount = await req.prisma.transcript.count(); 13 | const transcripts = await req.prisma.transcript.findMany({ 14 | take: 20, 15 | where: { ...(beforeId && { id: { lt: Number(beforeId) } }) }, 16 | orderBy: { createdAt: 'desc' }, 17 | }); 18 | res.send({ count: transcriptCount, transcripts }); 19 | }); 20 | 21 | router.post('/', (req, res) => { 22 | const { transcript } = req.body; 23 | const words = transcript.split(' '); 24 | if (words.length >= 3) { 25 | emitter.emit('transcript.create', transcript, { socket: true }); 26 | } 27 | res.send('OK'); 28 | }); 29 | 30 | router.delete('/', async (req, res) => { 31 | const ids = req.body.ids; 32 | const transcripts = await req.prisma.transcript.deleteMany( 33 | ids 34 | ? { 35 | where: { id: { in: ids } }, 36 | } 37 | : undefined 38 | ); 39 | log.info(`deleted ${transcripts.count} transcript(s)`); 40 | res.send('OK'); 41 | }); 42 | 43 | router.post('/process', (req, res) => { 44 | emitter.emit('transcript.process'); 45 | res.send('OK'); 46 | }); 47 | 48 | router.post('/manual', (req, res) => { 49 | const { transcript } = req.body; 50 | emitter.emit('summary.create', transcript); 51 | res.send('OK'); 52 | }); 53 | 54 | export default router; 55 | -------------------------------------------------------------------------------- /api/src/util/socket.util.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from 'socket.io'; 2 | import http from 'http'; 3 | 4 | import state from '../util/state.util'; 5 | import { emitter } from '../util/emitter.util'; 6 | 7 | let io: Server; 8 | let socketUrlMap = new Map(); 9 | 10 | type EmitMessage = 11 | | string 12 | | { 13 | [key: string]: any; 14 | }; 15 | 16 | const connect = (server: http.Server): void => { 17 | io = new Server(server, { 18 | cors: { origin: true }, 19 | }); 20 | 21 | io.on('connection', (socket: Socket) => { 22 | socket.on('register.url', (url) => { 23 | socketUrlMap.set(socket.id, url); 24 | }); 25 | 26 | socket.on('state:patch', (data: { to: string; [key: string]: any }) => { 27 | const { to, ...obj } = data; 28 | state.patch(obj); 29 | if (to) emit('state:get', { to, ...state.get() }); 30 | }); 31 | 32 | socket.on('to', (obj: EmitMessage) => { 33 | emit('to', obj); 34 | }); 35 | 36 | socket.on('realtime', (obj: EmitMessage) => { 37 | emit('realtime', obj); 38 | }); 39 | 40 | socket.on('disconnect', () => { 41 | const url = socketUrlMap.get(socket.id) || 'unknown url'; 42 | socketUrlMap.delete(socket.id); 43 | if (url.includes('?mic')) { 44 | state.patch({ microphone: { enabled: null } }); 45 | emitter.emit('state:get', { to: 'controller' }); 46 | } 47 | }); 48 | }); 49 | }; 50 | 51 | const emit = (event: string, message?: EmitMessage): boolean => { 52 | if (io) { 53 | io.emit(event, message); 54 | return true; 55 | } 56 | return false; 57 | }; 58 | 59 | export default { connect, emit }; 60 | -------------------------------------------------------------------------------- /frontend/src/components/ControllerTranscript.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 39 | 40 | 61 | -------------------------------------------------------------------------------- /.build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 as build 2 | ARG DEBIAN_FRONTEND=noninteractive 3 | 4 | RUN apt-get update && apt-get install -y curl bash 5 | RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - 6 | RUN apt-get install -y nodejs gcc g++ make libpixman-1-dev libcairo2-dev libpango1.0-dev libjpeg8-dev libgif-dev 7 | 8 | WORKDIR /app/api 9 | COPY /api/package.json . 10 | COPY /api/tsconfig.json . 11 | RUN npm install 12 | 13 | WORKDIR /app/frontend 14 | COPY /frontend/index.html . 15 | COPY /frontend/package.json . 16 | COPY /frontend/tsconfig.json . 17 | COPY /frontend/tsconfig.vite-config.json . 18 | COPY /frontend/vite.config.ts . 19 | COPY /frontend/.eslintrc.js . 20 | COPY /frontend/.prettierrc.js . 21 | COPY /frontend/.eslintignore . 22 | COPY /frontend/.prettierignore . 23 | COPY /frontend/types ./types 24 | RUN npm install 25 | 26 | WORKDIR /app/api 27 | COPY /api/server.ts . 28 | COPY /api/src ./src 29 | COPY /api/prisma ./prisma 30 | 31 | RUN npx prisma generate 32 | RUN npx tsc 33 | RUN mv node_modules /tmp/node_modules && mv dist /tmp/dist-api && rm -r * && mv /tmp/dist-api/* . && mv /tmp/node_modules node_modules 34 | COPY /api/prisma . 35 | 36 | WORKDIR /app/frontend 37 | COPY /frontend/src ./src 38 | COPY /frontend/public ./public 39 | 40 | RUN npm run build 41 | RUN mv dist /tmp/dist-frontend && rm -r * && mv /tmp/dist-frontend/* . 42 | 43 | WORKDIR / 44 | RUN mkdir /.storage 45 | RUN ln -s /.storage /app/.storage 46 | 47 | WORKDIR /app 48 | RUN npm install nodemon -g 49 | COPY /.build/entrypoint.sh . 50 | COPY /package.json . 51 | 52 | FROM ubuntu:22.04 53 | COPY --from=build . . 54 | ENV NODE_ENV=production 55 | WORKDIR /app 56 | ENTRYPOINT ["/bin/bash", "./entrypoint.sh"] -------------------------------------------------------------------------------- /frontend/public/js/worklets/volume-meter-processor.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /* eslint-disable @typescript-eslint/lines-between-class-members */ 3 | /* eslint-disable no-plusplus */ 4 | 5 | registerProcessor( 6 | 'volume-meter-processor', 7 | class extends AudioWorkletProcessor { 8 | constructor() { 9 | super(); 10 | this.volumeSum = 0; 11 | this.volumeCount = 0; 12 | this.lastUpdateTime = currentTime; 13 | this.updateInterval = 0.25; 14 | } 15 | 16 | process(inputs /* , outputs, parameters */) { 17 | const input = inputs[0]; 18 | // const output = outputs[0]; 19 | 20 | let sum = 0; 21 | let totalSamples = 0; 22 | 23 | for (let channel = 0; channel < input.length; ++channel) { 24 | const samples = input[channel]; 25 | for (let i = 0; i < samples.length; ++i) { 26 | sum += Math.abs(samples[i]); 27 | totalSamples++; 28 | } 29 | } 30 | 31 | const average = sum / totalSamples; 32 | 33 | this.volumeSum += average; 34 | this.volumeCount++; 35 | 36 | if (currentTime - this.lastUpdateTime >= this.updateInterval) { 37 | const volumeDB = average === 0 ? -Infinity : 20 * Math.log10(average); 38 | const minDB = -60; 39 | const maxDB = 0; 40 | const clampedDB = Math.max(minDB, Math.min(maxDB, volumeDB)); 41 | const volume0To100 = ((clampedDB - minDB) / (maxDB - minDB)) * 100; 42 | 43 | this.port.postMessage({ volume: volume0To100 }); 44 | 45 | this.volumeSum = 0; 46 | this.volumeCount = 0; 47 | this.lastUpdateTime = currentTime; 48 | } 49 | 50 | return true; 51 | } 52 | }, 53 | ); 54 | -------------------------------------------------------------------------------- /frontend/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router'; 2 | 3 | const router = createRouter({ 4 | history: createWebHistory(import.meta.env.BASE_URL), 5 | scrollBehavior(to, from, savedPosition) { 6 | if (savedPosition) { 7 | return savedPosition; 8 | } 9 | if (to.hash) { 10 | return { 11 | el: to.hash, 12 | behavior: 'smooth', 13 | }; 14 | } 15 | return { top: 0 }; 16 | }, 17 | routes: [ 18 | { 19 | path: '/', 20 | name: 'controller', 21 | component: () => import('../views/ControllerView.vue'), 22 | meta: { 23 | title: 'Controller', 24 | }, 25 | }, 26 | { 27 | path: '/phrame', 28 | name: 'phrame', 29 | component: () => import('../views/FrameView.vue'), 30 | }, 31 | { 32 | path: '/gallery', 33 | name: 'gallery', 34 | component: () => import('../views/GalleryView.vue'), 35 | meta: { 36 | title: 'Gallery', 37 | }, 38 | }, 39 | { 40 | path: '/config', 41 | name: 'config', 42 | component: () => import('../views/ConfigView.vue'), 43 | meta: { 44 | title: 'Config', 45 | }, 46 | }, 47 | { 48 | path: '/logs', 49 | name: 'logs', 50 | component: () => import('../views/LogsView.vue'), 51 | meta: { 52 | title: 'Logs', 53 | }, 54 | }, 55 | { 56 | path: '/:pathMatch(.*)*', 57 | name: 'not-found', 58 | redirect: '/', 59 | }, 60 | ], 61 | }); 62 | 63 | router.beforeEach((to, from, next) => { 64 | document.title = to.meta.title ? `${to.meta.title} | Phrame` : 'Phrame'; 65 | next(); 66 | }); 67 | 68 | export default router; 69 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ['master', { name: 'beta', prerelease: true }], 3 | plugins: [ 4 | [ 5 | '@semantic-release/commit-analyzer', 6 | { 7 | preset: 'conventionalcommits', 8 | releaseRules: [ 9 | { 10 | breaking: true, 11 | release: 'major', 12 | }, 13 | { 14 | revert: true, 15 | release: 'patch', 16 | }, 17 | { 18 | type: 'feat', 19 | release: 'minor', 20 | }, 21 | { 22 | type: 'fix', 23 | release: 'patch', 24 | }, 25 | { 26 | type: 'build', 27 | scope: 'deps', 28 | release: 'patch', 29 | }, 30 | ], 31 | }, 32 | ], 33 | [ 34 | '@semantic-release/release-notes-generator', 35 | { 36 | preset: 'conventionalcommits', 37 | presetConfig: { 38 | types: [ 39 | { 40 | type: 'feat', 41 | section: 'Features', 42 | }, 43 | { 44 | type: 'fix', 45 | section: 'Bug Fixes', 46 | }, 47 | { 48 | type: 'build', 49 | section: 'Build', 50 | }, 51 | ], 52 | }, 53 | }, 54 | ], 55 | '@semantic-release/changelog', 56 | [ 57 | '@semantic-release/npm', 58 | { 59 | npmPublish: false, 60 | }, 61 | ], 62 | [ 63 | '@semantic-release/exec', 64 | { 65 | prepareCmd: 'VERSION=${nextRelease.version} npm run version-bump', 66 | }, 67 | ], 68 | [ 69 | '@semantic-release/git', 70 | { 71 | assets: ['*'], 72 | }, 73 | ], 74 | '@semantic-release/github', 75 | ], 76 | }; 77 | -------------------------------------------------------------------------------- /api/src/controllers/config.controller.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import express from 'express'; 3 | import yaml from 'js-yaml'; 4 | import { zodToJsonSchema } from 'zod-to-json-schema'; 5 | 6 | import ai from '../ai'; 7 | import config from '../config'; 8 | import validate, { schema } from '../schemas'; 9 | import { BAD_REQUEST } from '../constants/http-status'; 10 | 11 | const router = express.Router(); 12 | 13 | const { 14 | SYSTEM: { STORAGE }, 15 | } = config(); 16 | 17 | router.get('/', async (req, res) => { 18 | const { format } = req.query; 19 | let output = {}; 20 | if (format === 'yaml') output = fs.readFileSync(`${STORAGE.CONFIG.PATH}/config.yml`, 'utf8'); 21 | else if (format === 'yaml-with-defaults') output = yaml.dump(config()); 22 | else output = config.lowercase(); 23 | const errors = validate(true); 24 | res.send({ config: output, errors }); 25 | }); 26 | 27 | router.get('/schema.json', async (req, res) => { 28 | res.send(zodToJsonSchema(schema, 'phrame')); 29 | }); 30 | 31 | router.patch('/', async (req, res) => { 32 | try { 33 | const { code } = req.body; 34 | yaml.load(code); 35 | fs.writeFileSync(`${STORAGE.CONFIG.PATH}/config.yml`, code); 36 | res.send(); 37 | } catch (error: any) { 38 | if (error.name === 'YAMLException') 39 | return res.status(BAD_REQUEST).send({ error: error.message }); 40 | res.send(error); 41 | } 42 | }); 43 | 44 | router.get('/service/status', async (req, res) => { 45 | const configuredAIs = config.ai(); 46 | const promises: any[] = []; 47 | configuredAIs.forEach((configuredAI) => { 48 | promises.push(ai.test(configuredAI.ai)); 49 | }); 50 | const data = (await Promise.all(promises)).map((result, i) => ({ 51 | ...configuredAIs[i], 52 | status: result instanceof Error ? result.message : result, 53 | })); 54 | res.send(data); 55 | }); 56 | 57 | export default router; 58 | -------------------------------------------------------------------------------- /api/server.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import http from 'http'; 3 | 4 | import Log from './src/util/logger.util'; 5 | import { version } from './package.json'; 6 | import config from './src/config'; 7 | import socket from './src/util/socket.util'; 8 | import storage from './src/util/storage.util'; 9 | import cron from './src/util/cron.util'; 10 | import emitter from './src/util/emitter.util'; 11 | import validate from './src/schemas'; 12 | 13 | const { log } = new Log('server'); 14 | 15 | const { 16 | SYSTEM: { PORT }, 17 | } = config(); 18 | 19 | const getLocalIPs = (): string[] => { 20 | const networkInterfaces = os.networkInterfaces(); 21 | const localIPs: string[] = []; 22 | 23 | for (const interfaceName in networkInterfaces) { 24 | const addresses = networkInterfaces[interfaceName]; 25 | if (!addresses) continue; 26 | 27 | for (const addressInfo of addresses) { 28 | // Skip internal (loopback) and non-IPv4 addresses 29 | if (addressInfo.internal || addressInfo.family !== 'IPv4') continue; 30 | localIPs.push(addressInfo.address); 31 | } 32 | } 33 | 34 | return localIPs; 35 | }; 36 | 37 | const start = async () => { 38 | const server = http.createServer((await import('./src/app')).app).listen(PORT, async () => { 39 | const message = ['localhost', ...getLocalIPs()] 40 | .map((ip) => `${ip === 'localhost' ? 'Local' : 'Network'}: http://${ip}:${PORT}`) 41 | .join('\n'); 42 | log.info( 43 | `Phrame v${version}\n-------------------------------------------------------\n${message}\n-------------------------------------------------------` 44 | ); 45 | validate(); 46 | storage.setup(); 47 | emitter.setup(); 48 | socket.connect(server); 49 | cron.transcript(); 50 | cron.autogen(); 51 | cron.heartbeat(); 52 | }); 53 | }; 54 | 55 | try { 56 | start().catch((error) => log.error(error)); 57 | } catch (error) { 58 | log.error(error); 59 | } 60 | -------------------------------------------------------------------------------- /api/src/ai/stabilityai.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import AI, { LogError } from './ai'; 4 | import config from '../config'; 5 | 6 | const { 7 | STABILITYAI: { KEY, IMAGE }, 8 | } = config(); 9 | 10 | class StabilityAI extends AI { 11 | constructor(meta?: any) { 12 | super('stabilityai', meta); 13 | this.styles = IMAGE.STYLE; 14 | } 15 | 16 | async generateImage() { 17 | const { data } = await axios({ 18 | method: 'post', 19 | url: `https://api.stability.ai/v1/generation/${IMAGE.ENGINE_ID}/text-to-image`, 20 | timeout: IMAGE.TIMEOUT * 1000, 21 | headers: { 22 | Authorization: `Bearer ${KEY}`, 23 | 'Content-Type': 'application/json', 24 | Accept: 'application/json', 25 | }, 26 | data: JSON.stringify({ 27 | width: IMAGE.WIDTH, 28 | height: IMAGE.HEIGHT, 29 | cfg_scale: IMAGE.CFG_SCALE, 30 | samples: IMAGE.SAMPLES, 31 | steps: IMAGE.STEPS, 32 | style_preset: this.style, 33 | text_prompts: [ 34 | { 35 | text: this.meta.summary.summary, 36 | }, 37 | ], 38 | }), 39 | }); 40 | const images: { base64: string }[] = []; 41 | data.artifacts.forEach((artifact: { base64: string }) => { 42 | images.push({ base64: artifact.base64 }); 43 | }); 44 | this.downloadImage(images); 45 | } 46 | 47 | async test() { 48 | try { 49 | await axios({ 50 | method: 'get', 51 | url: `https://api.stability.ai/v1/user/balance`, 52 | headers: { Authorization: `Bearer ${KEY}` }, 53 | }); 54 | return true; 55 | } catch (error: any) { 56 | this.log.error(error); 57 | return error?.response?.data?.message || error; 58 | } 59 | } 60 | 61 | logError({ type, error }: LogError) { 62 | this.log.error(`${type}: ${error?.response?.data?.message || error}`); 63 | } 64 | } 65 | 66 | export default StabilityAI; 67 | -------------------------------------------------------------------------------- /frontend/src/components/icons/IconEcosystem.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /frontend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // === 3 | // Spacing 4 | // === 5 | 6 | "editor.insertSpaces": true, 7 | "editor.tabSize": 2, 8 | "editor.trimAutoWhitespace": true, 9 | "files.trimTrailingWhitespace": true, 10 | "files.eol": "\n", 11 | "files.insertFinalNewline": true, 12 | "files.trimFinalNewlines": true, 13 | 14 | // === 15 | // Event Triggers 16 | // === 17 | 18 | "editor.formatOnSave": true, 19 | "editor.defaultFormatter": "esbenp.prettier-vscode", 20 | "editor.codeActionsOnSave": { 21 | "source.fixAll.eslint": true, 22 | "source.fixAll.stylelint": true 23 | }, 24 | "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact", "vue", "vue-html"], 25 | "stylelint.validate": ["css", "less", "postcss", "scss", "vue", "sass"], 26 | "editor.tabCompletion": "onlySnippets", 27 | 28 | // === 29 | // HTML 30 | // === 31 | 32 | "html.format.enable": false, 33 | "emmet.triggerExpansionOnTab": true, 34 | "emmet.includeLanguages": { 35 | "vue-html": "html" 36 | }, 37 | 38 | // === 39 | // JS(ON) 40 | // === 41 | 42 | "javascript.format.enable": false, 43 | "json.format.enable": true, 44 | 45 | // === 46 | // CSS 47 | // === 48 | 49 | "stylelint.enable": true, 50 | "css.validate": false, 51 | "scss.validate": false, 52 | 53 | // set i18n folder 54 | "i18n-ally.localesPaths": "../src/lang", 55 | // i18n json file style: nested | flat | auto 56 | "i18n-ally.keystyle": "auto", 57 | // i18n default language 58 | "i18n-ally.displayLanguage": "en", 59 | 60 | // Specifies the folder path to the typescript 61 | "typescript.tsdk": "node_modules/typescript/lib", 62 | "cSpell.words": [ 63 | "Axios", 64 | "browserslist", 65 | "commitlint", 66 | "datadoghq", 67 | "grimoire", 68 | "huskyrc", 69 | "loglevel", 70 | "longform", 71 | "npmrc", 72 | "oidc", 73 | "persistedstate", 74 | "pinia", 75 | "prebuild", 76 | "preinit", 77 | "rootdir", 78 | "rushstack", 79 | "stylelint", 80 | "twentyfourg", 81 | "unspaced", 82 | "vite", 83 | "vitejs", 84 | "vuejs", 85 | "vueuse" 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 22 | 27 | 32 | 37 | 42 | 47 | 48 | 49 | 50 | 51 |
52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /api/scripts/splash.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp'; 2 | // import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | const dimensions = [ 6 | { width: 300, height: 300, ratio: 1 }, 7 | { width: 375, height: 812, ratio: 3 }, 8 | { width: 414, height: 896, ratio: 2 }, 9 | { width: 414, height: 896, ratio: 3 }, 10 | { width: 390, height: 844, ratio: 3 }, 11 | { width: 428, height: 926, ratio: 3 }, 12 | { width: 393, height: 852, ratio: 3 }, 13 | { width: 430, height: 932, ratio: 3 }, 14 | ]; 15 | 16 | // Path to your source image file on disk 17 | const sourceImagePath = path.resolve('./splash/favicon.png'); 18 | 19 | const backgroundColor = { r: 27, g: 28, b: 35 }; 20 | 21 | // Specify the ratio of the image's width to the canvas's width 22 | const imageWidthRatio = 0.65; 23 | 24 | // Loop over the dimensions array and create an image for each dimension 25 | dimensions.forEach(async ({ width, height, ratio }, index) => { 26 | try { 27 | // Calculate the width of the image based on the width of the canvas and the desired ratio 28 | const imageWidth = Math.floor(width * imageWidthRatio); 29 | 30 | // Read the image, resize it to the calculated width while maintaining aspect ratio, and convert it to PNG 31 | const image = sharp(sourceImagePath).resize(imageWidth).png(); 32 | 33 | // Create a new sharp instance for the background, resize it to the desired dimensions, set the background color, and convert it to PNG 34 | const finalImage = sharp({ 35 | create: { 36 | width: width * ratio, 37 | height: height * ratio, 38 | channels: 3, 39 | background: backgroundColor, 40 | }, 41 | }) 42 | .composite([ 43 | { 44 | input: await image.toBuffer(), 45 | gravity: 'center', 46 | }, 47 | ]) 48 | .png(); 49 | 50 | const filename = `${width}x${height}@${ratio}x.png`; 51 | const outputImagePath = path.resolve(`./splash/${filename}`); 52 | await finalImage.toFile(outputImagePath); 53 | console.log( 54 | `` 55 | ); 56 | } catch (err) { 57 | console.error(err); 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /frontend/src/components/GalleryRow.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 54 | 55 | 78 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | // Disable no-extraneous-dependencies because vite is not set up in airbnb's eslint config 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | import { fileURLToPath, URL } from 'url'; 4 | 5 | import { defineConfig } from 'vite'; 6 | import vue from '@vitejs/plugin-vue'; 7 | import eslintPlugin from 'vite-plugin-eslint'; 8 | import svgLoader from 'vite-svg-loader'; 9 | import monacoEditorPlugin from 'vite-plugin-monaco-editor'; 10 | 11 | let svgPrefixCounter = 0; 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig({ 15 | plugins: [ 16 | vue(), 17 | eslintPlugin(), 18 | monacoEditorPlugin({ 19 | customWorkers: [{ label: 'yaml', entry: 'monaco-yaml/yaml.worker' }], 20 | }), 21 | svgLoader({ 22 | svgoConfig: { 23 | plugins: [ 24 | { 25 | name: 'preset-default', 26 | params: { 27 | overrides: { 28 | // viewBox is required to resize SVGs with CSS. 29 | removeViewBox: false, 30 | }, 31 | }, 32 | }, 33 | { name: 'removeDimensions' }, 34 | // Prefix IDs in the SVG to avoid conflicts 35 | { 36 | name: 'prefixIds', 37 | params: { 38 | prefix: ({ name }: { name: string }) => { 39 | if (name === 'svg') svgPrefixCounter += 1; 40 | return `svg-id-${svgPrefixCounter}`; 41 | }, 42 | prefixClassNames: false, 43 | }, 44 | }, 45 | ], 46 | }, 47 | }), 48 | ], 49 | resolve: { 50 | alias: { 51 | '@': fileURLToPath(new URL('./src', import.meta.url)), 52 | }, 53 | }, 54 | server: { 55 | port: 8080, 56 | host: true, 57 | }, 58 | build: { 59 | sourcemap: true, 60 | }, 61 | css: { 62 | devSourcemap: true, 63 | preprocessorOptions: { 64 | scss: { 65 | additionalData: ` 66 | @use 'sass:math'; 67 | @use 'sass:color'; 68 | @use 'sass:map'; 69 | @import './src/assets/scss/_variables.scss'; 70 | @import './src/assets/scss/_mixins.scss'; 71 | @import './src/assets/scss/_functions.scss'; 72 | `, 73 | }, 74 | }, 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /frontend/src/utils/functions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns true if the string passed into it is a valid email 3 | * @param {string} email 4 | * @returns {boolean} True if the provided email is syntactically valid, false if not 5 | */ 6 | export const isValidEmail = (email: string) => 7 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( 8 | email, 9 | ); 10 | 11 | /** 12 | * Truncates a string and appends an ellipsis if the string is longer than the specified maxStringLength 13 | * 14 | * Note: When possible, CSS text-overflow: ellipsis; should be prioritized over this 15 | * 16 | * @param {string} string 17 | * @param {number} maxStringLength 18 | * @returns {string} A truncated version of the provided string 19 | */ 20 | export const truncateString = (string: string, maxStringLength: number) => 21 | string.length <= maxStringLength ? string : `${string.substring(0, maxStringLength)}...`; 22 | 23 | /** 24 | * Converts an array of strings into one comma separated string 25 | * @param {array} array 26 | * @param {string} [conjunction='and'] 27 | * @param {boolean} [oxfordComma=true] 28 | * @returns {string} A stringified version of the provided array separated by commas 29 | */ 30 | export const arrayToCommaSeparatedSentence = (array: [string], conjunction = 'and', oxfordComma = true) => { 31 | let commaSeparatedSentence = ''; 32 | if (array.length) { 33 | commaSeparatedSentence += `${array[0]}`; 34 | } 35 | for (let i = 1; i < array.length; i += 1) { 36 | if (conjunction && i === array.length - 1) { 37 | if (oxfordComma) { 38 | commaSeparatedSentence += `, ${conjunction} ${array[i]}`; 39 | } else { 40 | commaSeparatedSentence += ` ${conjunction} ${array[i]}`; 41 | } 42 | } else { 43 | commaSeparatedSentence += `, ${array[i]}`; 44 | } 45 | } 46 | return commaSeparatedSentence; 47 | }; 48 | 49 | export const aiToTitleCase = (str: string): string => { 50 | if (str === 'openai') return 'OpenAI'; 51 | if (str === 'stabilityai') return 'Stability AI'; 52 | if (str === 'deepai') return 'DeepAI'; 53 | if (str === 'leonardoai') return 'Leonardo.Ai'; 54 | return str.replace(/(\w)(\w*)/g, (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase()); 55 | }; 56 | -------------------------------------------------------------------------------- /api/src/ai/openai.ts: -------------------------------------------------------------------------------- 1 | import { Configuration, OpenAIApi, ChatCompletionRequestMessageRoleEnum } from 'openai'; 2 | 3 | import AI, { Random, LogError } from './ai'; 4 | import config from '../config'; 5 | 6 | const { 7 | OPENAI: { KEY, SUMMARY, IMAGE }, 8 | } = config(); 9 | 10 | const openai = new OpenAIApi( 11 | new Configuration({ 12 | apiKey: KEY, 13 | }) 14 | ); 15 | 16 | class OpenAI extends AI { 17 | constructor(meta?: any) { 18 | super('openai', meta); 19 | this.styles = IMAGE.STYLE; 20 | } 21 | 22 | async generateRandom({ prompt = SUMMARY.RANDOM, context }: Random) { 23 | const { 24 | data: { choices }, 25 | } = await openai.createChatCompletion( 26 | { 27 | model: SUMMARY.MODEL, 28 | messages: [ 29 | { role: 'system', content: prompt }, 30 | ...(context 31 | ? [{ role: 'user' as ChatCompletionRequestMessageRoleEnum, content: context }] 32 | : []), 33 | ], 34 | }, 35 | { timeout: 15000 } 36 | ); 37 | 38 | if (!choices[0]?.message?.content) throw new Error('no content returned'); 39 | return choices[0].message.content.replace(/(\r\n|\n|\r)/gm, ''); 40 | } 41 | 42 | async generateSummary() { 43 | const { 44 | data: { choices, usage }, 45 | } = await openai.createChatCompletion( 46 | { 47 | model: SUMMARY.MODEL, 48 | messages: [ 49 | { 50 | role: 'system', 51 | content: SUMMARY.PROMPT, 52 | }, 53 | { 54 | role: 'user', 55 | content: this.meta.transcripts.join(' '), 56 | }, 57 | ], 58 | }, 59 | { timeout: 15000 } 60 | ); 61 | if (!choices[0]?.message?.content) throw new Error('no content returned'); 62 | return choices[0].message.content.replace(/(\r\n|\n|\r)/gm, ''); 63 | } 64 | 65 | async generateImage() { 66 | const { 67 | data: { data }, 68 | } = await openai.createImage({ 69 | prompt: `${this.meta.summary.summary}, ${this.style}`, 70 | n: IMAGE.N, 71 | size: IMAGE.SIZE, 72 | }); 73 | const images: { url: string }[] = data 74 | .filter((item): item is { url: string } => 'url' in item) 75 | .map(({ url }) => ({ url })); 76 | this.downloadImage(images); 77 | } 78 | 79 | async test() { 80 | try { 81 | await openai.listModels(); 82 | return true; 83 | } catch (error) { 84 | this.log.error(error); 85 | return error; 86 | } 87 | } 88 | 89 | logError({ type, error }: LogError) { 90 | this.log.error(`${type}: ${error?.response?.data?.error?.message || error}`); 91 | } 92 | } 93 | 94 | export default OpenAI; 95 | -------------------------------------------------------------------------------- /api/src/config/default.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | telemetry: true, 3 | logs: { level: 'verbose' }, 4 | time: { timezone: 'UTC', format: null }, 5 | autogen: { 6 | cron: '15,45 * * * *', 7 | prompt: 8 | 'Provide a random short description to describe a picture. It should be no more than one or two sentences. If keywords are provided select a couple at random to help guide the description.', 9 | keywords: [], 10 | }, 11 | image: { 12 | interval: 60, 13 | order: 'recent', 14 | }, 15 | transcript: { 16 | cron: '*/30 * * * *', 17 | minutes: 30, 18 | minimum: 5, 19 | }, 20 | openai: { 21 | summary: { 22 | model: 'gpt-3.5-turbo', 23 | prompt: 24 | 'You will be given a string of random conversations and need to pull out a few keywords and topics that were talked about. You will then turn this into a short description to describe a picture. It should be no more than two or three sentences.', 25 | random: 26 | 'Provide a random short description to describe a picture. It should be no more than two or three sentences.', 27 | }, 28 | image: { 29 | enable: true, 30 | trim: false, 31 | size: '512x512', 32 | n: 1, 33 | style: ['cinematic'], 34 | }, 35 | }, 36 | stabilityai: { 37 | image: { 38 | enable: true, 39 | trim: false, 40 | timeout: 30, 41 | engine_id: 'stable-diffusion-512-v2-1', 42 | width: 512, 43 | height: 512, 44 | cfg_scale: 7, 45 | samples: 1, 46 | steps: 50, 47 | style: ['cinematic'], 48 | }, 49 | }, 50 | deepai: { 51 | image: { 52 | enable: true, 53 | trim: false, 54 | timeout: 30, 55 | grid_size: 1, 56 | width: 512, 57 | height: 512, 58 | negative_prompt: null, 59 | style: ['text2img'], 60 | }, 61 | }, 62 | dream: { 63 | image: { 64 | enable: true, 65 | trim: false, 66 | timeout: 30, 67 | width: 512, 68 | height: 512, 69 | style: ['buliojourney v2'], 70 | }, 71 | }, 72 | midjourney: { 73 | image: { 74 | enable: true, 75 | trim: false, 76 | parameters: '--chaos 80 --no text', 77 | upscale: 'random', 78 | style: ['cinematic'], 79 | }, 80 | }, 81 | leonardoai: { 82 | image: { 83 | enable: true, 84 | trim: false, 85 | negative_prompt: null, 86 | model_id: '6bef9f1b-29cb-40c7-b9df-32b51c1f67d3', 87 | sd_version: 'v2', 88 | num_images: 1, 89 | width: 512, 90 | height: 512, 91 | num_inference_steps: null, 92 | guidance_scale: 7, 93 | scheduler: null, 94 | preset_style: 'LEONARDO', 95 | tiling: null, 96 | public: null, 97 | prompt_magic: null, 98 | timeout: 30, 99 | style: ['cinematic'], 100 | }, 101 | }, 102 | }; 103 | -------------------------------------------------------------------------------- /.github/workflows/build-push.yml: -------------------------------------------------------------------------------- 1 | name: Build & Push 2 | 3 | on: 4 | release: 5 | types: [released, prereleased] 6 | branches: 7 | - master 8 | - beta 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build-push: 13 | name: Build & Push 14 | if: github.repository == 'jakowenko/phrame' 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Get VERSION 20 | id: version 21 | uses: notiz-dev/github-action-json-property@release 22 | with: 23 | path: ./package.json 24 | prop_path: version 25 | - name: Set ENV to beta 26 | if: ${{ github.event.release.target_commitish == 'beta' || (github.event_name == 'workflow_dispatch' && github.ref_name == 'beta') }} 27 | run: | 28 | echo "ARCH=linux/amd64" >> $GITHUB_ENV 29 | echo "TAGS=jakowenko/phrame:beta" >> $GITHUB_ENV 30 | - name: Set ENV to latest 31 | if: ${{ github.event.release.target_commitish == 'master' || (github.event_name == 'workflow_dispatch' && github.ref_name == 'master') }} 32 | run: | 33 | echo "ARCH=linux/amd64,linux/arm64" >> $GITHUB_ENV 34 | echo "TAGS=jakowenko/phrame:latest,jakowenko/phrame:${{steps.version.outputs.prop}}" >> $GITHUB_ENV 35 | - name: Version with SHA-7 36 | run: | 37 | npm version ${{steps.version.outputs.prop}}-$(echo ${GITHUB_SHA} | cut -c1-7) --no-git-tag-version 38 | cd ./api && npm version ${{steps.version.outputs.prop}}-$(echo ${GITHUB_SHA} | cut -c1-7) --no-git-tag-version 39 | cd ../frontend && npm version ${{steps.version.outputs.prop}}-$(echo ${GITHUB_SHA} | cut -c1-7) --no-git-tag-version 40 | - name: Set up QEMU 41 | uses: docker/setup-qemu-action@v2 42 | - name: Set up Docker Buildx 43 | uses: docker/setup-buildx-action@v2 44 | - name: Cache Docker layers 45 | uses: actions/cache@v2 46 | with: 47 | path: /tmp/.buildx-cache 48 | key: ${{ runner.os }}-buildx-${{ github.sha }} 49 | restore-keys: | 50 | ${{ runner.os }}-buildx- 51 | - name: Docker Login 52 | uses: docker/login-action@v2 53 | with: 54 | username: ${{ secrets.DOCKER_USERNAME }} 55 | password: ${{ secrets.DOCKER_PASSWORD }} 56 | - name: Docker Buildx (push) 57 | uses: docker/build-push-action@v4 58 | with: 59 | context: . 60 | file: ./.build/Dockerfile 61 | platforms: ${{env.ARCH}} 62 | push: true 63 | tags: ${{env.TAGS}} 64 | cache-from: type=local,src=/tmp/.buildx-cache 65 | cache-to: type=local,dest=/tmp/.buildx-cache-new 66 | - # Temp fix 67 | # https://github.com/docker/build-push-action/issues/252 68 | # https://github.com/moby/buildkit/issues/1896 69 | name: Move cache 70 | run: | 71 | rm -rf /tmp/.buildx-cache 72 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 73 | -------------------------------------------------------------------------------- /api/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import yaml from 'js-yaml'; 3 | import _ from 'lodash'; 4 | 5 | import SYSTEM from './system'; 6 | import DEFAULT from './default'; 7 | 8 | const supportedAIs = ['openai', 'stabilityai', 'deepai', 'dream', 'midjourney', 'leonardoai']; 9 | 10 | const objectKeysToCase = ( 11 | casing: 'toLowerCase' | 'toUpperCase', 12 | input: { [name: string]: any } 13 | ): { [name: string]: any } => { 14 | const self = objectKeysToCase; 15 | if (input === null) return input; 16 | if (typeof input !== 'object') return input; 17 | if (Array.isArray(input)) return input.map((value) => self(casing, value)); 18 | return Object.keys(input).reduce((newObj: { [name: string]: any }, key) => { 19 | const val = input[key]; 20 | const newVal = typeof val === 'object' ? self(casing, val) : val; 21 | if (casing === 'toLowerCase') newObj[key.toLowerCase()] = newVal; 22 | else newObj[key.toUpperCase()] = newVal; 23 | return newObj; 24 | }, {}); 25 | }; 26 | 27 | const customizer = (objValue: any, srcValue: any) => (_.isNull(srcValue) ? objValue : undefined); 28 | 29 | const createFile = (filename: string, path: string, data: any) => { 30 | if (!fs.existsSync(path)) fs.mkdirSync(path, { recursive: true }); 31 | if (!fs.existsSync(`${path}/${filename}`)) fs.writeFileSync(`${path}/${filename}`, data); 32 | }; 33 | 34 | const loadYaml = (file: string) => { 35 | try { 36 | return yaml.load(fs.readFileSync(file, 'utf8')); 37 | } catch (error) { 38 | return error; 39 | } 40 | }; 41 | 42 | let loaded: any; 43 | let userOptions: any; 44 | 45 | const config = Object.assign( 46 | () => { 47 | return objectKeysToCase('toUpperCase', config.lowercase()); 48 | }, 49 | { 50 | lowercase: () => { 51 | if (loaded) return loaded; 52 | createFile( 53 | 'config.yml', 54 | './.storage/config', 55 | '# Phrame\n# Default values are already applied and do not need to be added\n# Learn more at https://github.com/jakowenko/phrame/#configuration' 56 | ); 57 | userOptions = loadYaml('./.storage/config/config.yml'); 58 | loaded = _.isEmpty(userOptions) ? DEFAULT : _.mergeWith(DEFAULT, userOptions, customizer); 59 | loaded = _.mergeWith(loaded, { SYSTEM: SYSTEM }); 60 | loaded = objectKeysToCase('toLowerCase', loaded); 61 | 62 | supportedAIs.forEach((supportedAI) => { 63 | if (!_.get(userOptions, `${supportedAI}.${supportedAI === 'midjourney' ? 'token' : 'key'}`)) 64 | _.unset(loaded, supportedAI); 65 | }); 66 | 67 | return loaded; 68 | }, 69 | ai: () => { 70 | const configuredAIs: any[] = []; 71 | 72 | supportedAIs.forEach((supportedAI) => { 73 | const base: { ai: string; services: string[] } = { ai: supportedAI, services: [] }; 74 | if (_.get(loaded, `${supportedAI}.image.enable`)) base.services.push('image'); 75 | if (_.get(loaded, `${supportedAI}.key`) || _.get(loaded, `${supportedAI}.token`)) 76 | configuredAIs.push(base); 77 | }); 78 | 79 | return configuredAIs; 80 | }, 81 | } 82 | ); 83 | export default config; 84 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phrame-frontend", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build-dev": "run-p type-check build-only-dev", 7 | "build-qa": "run-p type-check build-only-qa", 8 | "build": "run-p type-check build-only", 9 | "build-only-dev": "vite build --mode development", 10 | "build-only-qa": "vite build --mode qa", 11 | "build-only": "vite build", 12 | "type-check": "vue-tsc --noEmit", 13 | "start-dev": "npm run build-dev && npm run preview", 14 | "start-qa": "npm run build-qa && npm run preview", 15 | "start": "npm run build && npm run preview", 16 | "serve": "npm run dev", 17 | "local": "npm run dev", 18 | "preview": "vite preview", 19 | "lint": "npm run lint:all:eslint && npm run lint:all:stylelint && npm run lint:all:prettier", 20 | "lint:all:eslint": "npm run lint:eslint -- --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts .", 21 | "lint:all:prettier": "npm run lint:prettier -- \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,json,css,scss,vue,html}\"", 22 | "lint:all:stylelint": "npm run lint:stylelint \"src/**/*.{vue,html,css,scss}\"", 23 | "lint:eslint": "eslint --fix", 24 | "lint:prettier": "prettier --write --loglevel warn", 25 | "lint:stylelint": "stylelint --fix" 26 | }, 27 | "dependencies": { 28 | "@vueuse/core": "^10.2.0", 29 | "audio-recorder-polyfill": "^0.4.1", 30 | "axios": "^1.4.0", 31 | "inflection": "^2.0.1", 32 | "lodash": "^4.17.21", 33 | "luxon": "^3.3.0", 34 | "mitt": "^3.0.0", 35 | "monaco-editor": "^0.39.0", 36 | "monaco-yaml": "^4.0.4", 37 | "primeflex": "^3.3.1", 38 | "primeicons": "^6.0.1", 39 | "primevue": "^3.29.2", 40 | "socket.io-client": "^4.6.2", 41 | "v-lazy-image": "^2.1.1", 42 | "vite-plugin-monaco-editor": "^1.1.0", 43 | "vue": "^3.3.4", 44 | "vue-router": "^4.2.2" 45 | }, 46 | "devDependencies": { 47 | "@rushstack/eslint-patch": "^1.3.2", 48 | "@tsconfig/node18": "^2.0.1", 49 | "@types/lodash": "^4.14.195", 50 | "@types/luxon": "^3.3.0", 51 | "@types/node": "^20.3.1", 52 | "@vitejs/plugin-vue": "^4.2.3", 53 | "@vue/eslint-config-prettier": "^7.1.0", 54 | "@vue/eslint-config-typescript": "^11.0.3", 55 | "@vue/tsconfig": "^0.3.2", 56 | "autoprefixer": "^10.4.14", 57 | "cross-env": "^7.0.3", 58 | "dotenv": "^16.3.1", 59 | "eslint": "^8.43.0", 60 | "eslint-config-airbnb-base": "^15.0.0", 61 | "eslint-config-airbnb-typescript": "^17.0.0", 62 | "eslint-import-resolver-typescript": "^3.5.5", 63 | "eslint-plugin-import": "^2.26.0", 64 | "eslint-plugin-vue": "^9.15.0", 65 | "lint-staged": "^13.2.2", 66 | "npm-run-all": "^4.1.5", 67 | "postcss-html": "^1.5.0", 68 | "prettier": "^2.8.8", 69 | "sass": "^1.63.6", 70 | "stylelint": "^15.8.0", 71 | "stylelint-config-recess-order": "^4.2.0", 72 | "stylelint-config-standard-scss": "^9.0.0", 73 | "stylelint-config-standard-vue": "^1.0.0", 74 | "typescript": "^5.1.3", 75 | "vite": "^4.3.9", 76 | "vite-plugin-eslint": "^1.6.1", 77 | "vite-svg-loader": "^4.0.0", 78 | "vue-tsc": "^1.8.1" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /api/src/util/logger.util.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports, Logger } from 'winston'; 2 | import util from 'util'; 3 | import { DateTime } from 'luxon'; 4 | 5 | import redact from '../util/redact.util'; 6 | import config from '../config'; 7 | 8 | const { 9 | SYSTEM: { STORAGE }, 10 | LOGS: { LEVEL }, 11 | TIME, 12 | } = config(); 13 | 14 | declare global { 15 | interface Console { 16 | verbose(...data: any[]): Logger; 17 | http(...data: any[]): Logger; 18 | silly(...data: any[]): Logger; 19 | } 20 | } 21 | 22 | class Log { 23 | label?: string; 24 | log: Logger; 25 | logClone: Logger | null; 26 | constructor(label?: string) { 27 | this.label = label; 28 | this.logClone = null; 29 | this.log = this.create(); 30 | } 31 | 32 | create() { 33 | const log = createLogger({ 34 | silent: LEVEL === 'silent', 35 | level: LEVEL, 36 | defaultMeta: { label: this.label }, 37 | transports: [ 38 | new transports.Console({ 39 | format: format.combine( 40 | format.colorize(), 41 | Log.format, 42 | format.printf((info) => Log.formatMessage(info)) 43 | ), 44 | }), 45 | 46 | new transports.File({ 47 | filename: `${STORAGE.PATH}/messages.log`, 48 | format: format.combine( 49 | Log.format, 50 | format.printf((info) => Log.formatMessage(info)) 51 | ), 52 | }), 53 | ], 54 | }); 55 | 56 | const { error } = log; 57 | this.logClone = log.child({}); 58 | this.logClone.error = error; 59 | // @ts-ignore 60 | log.error = (...args) => this.patchError(args); 61 | 62 | return log; 63 | } 64 | 65 | patchError(args: any) { 66 | const isError = args[0] instanceof Error; 67 | const [error] = args; 68 | const message = isError 69 | ? { message: error.stack || error.message, errorUID: error.errorUID } 70 | : args.map((arg: any) => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '); 71 | 72 | if (this.logClone) { 73 | if (isError) this.logClone.error(message); 74 | // @ts-ignore 75 | else this.logClone.error(...args); 76 | } 77 | } 78 | 79 | static formatMessage = (info: { [key: string]: any }) => { 80 | return `${Log.convertTime(info.timestamp)}: ${info.label ? `[${info.label}]` : ''} ${ 81 | info.level 82 | }: ${info.message}`; 83 | }; 84 | 85 | static convertTime = (time: string) => { 86 | return TIME.FORMAT 87 | ? DateTime.fromISO(time).setZone(TIME.TIMEZONE.toUpperCase()).toFormat(TIME.FORMAT) 88 | : DateTime.now().setZone(TIME.TIMEZONE.toUpperCase()).toString(); 89 | }; 90 | 91 | static combineMessageAndSplat = () => { 92 | return { 93 | transform: (info: any /* , opts */) => { 94 | info.message = util.format( 95 | redact(info.message), 96 | // @ts-ignore 97 | ...redact(info[Symbol.for('splat')] || []) 98 | ); 99 | return info; 100 | }, 101 | }; 102 | }; 103 | 104 | static format = format.combine(Log.combineMessageAndSplat(), format.simple(), format.timestamp()); 105 | } 106 | 107 | export default Log; 108 | -------------------------------------------------------------------------------- /frontend/src/views/LogsView.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 86 | 87 | 113 | -------------------------------------------------------------------------------- /api/src/ai/leonardoai.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import AI, { LogError } from './ai'; 4 | import config from '../config'; 5 | 6 | const { 7 | LEONARDOAI: { KEY, IMAGE }, 8 | } = config(); 9 | 10 | class DeepAI extends AI { 11 | generationId?: string; 12 | constructor(meta?: any) { 13 | super('leonardoai', meta); 14 | this.generationId = undefined; 15 | this.styles = IMAGE.STYLE; 16 | } 17 | 18 | async generateImageId() { 19 | const { data } = await axios({ 20 | method: 'post', 21 | url: 'https://cloud.leonardo.ai/api/rest/v1/generations', 22 | headers: { 23 | Authorization: `Bearer ${KEY}`, 24 | }, 25 | data: { 26 | prompt: `${this.meta.summary.summary}, ${this.style}`, 27 | negative_prompt: IMAGE.NEGATIVE_PROMPT, 28 | modelId: IMAGE.MODEL_ID, 29 | sd_version: IMAGE.SD_VERSION, 30 | num_images: IMAGE.NUM_IMAGES, 31 | width: IMAGE.WIDTH, 32 | height: IMAGE.HEIGHT, 33 | num_inference_steps: IMAGE.NUM_INFERENCE_STEPS, 34 | guidance_scale: IMAGE.GUIDANCE_SCALE, 35 | scheduler: IMAGE.SCHEDULER, 36 | presetStyle: IMAGE.PRESET_STYLE, 37 | tiling: IMAGE.TILING, 38 | public: IMAGE.PUBLIC, 39 | promptMagic: IMAGE.PROMPT_MAGIC, 40 | }, 41 | }); 42 | 43 | if (data?.sdGenerationJob?.generationId) this.generationId = data.sdGenerationJob.generationId; 44 | } 45 | 46 | async generateImage() { 47 | await this.createAttemptHandler('create generation', this.generateImageId.bind(this))(); 48 | if (!this.generationId) { 49 | this.log.error('generation id not found'); 50 | return; 51 | } 52 | const images = await this.waitForImage(); 53 | if (images?.length) await this.downloadImage(images); 54 | } 55 | 56 | async waitForImage() { 57 | try { 58 | this.log.info('wait for image'); 59 | for (let i = 0; i < IMAGE.TIMEOUT; i++) { 60 | const { data } = await axios({ 61 | method: 'get', 62 | url: `https://cloud.leonardo.ai/api/rest/v1/generations/${this.generationId}`, 63 | headers: { 64 | Authorization: `Bearer ${KEY}`, 65 | }, 66 | }); 67 | 68 | if (i === IMAGE.TIMEOUT / 2) { 69 | this.log.info('still waiting'); 70 | } 71 | 72 | const { status, generated_images } = data.generations_by_pk; 73 | if (status == 'FAILED') { 74 | this.log.error('failed to generate image'); 75 | break; 76 | } else if (status == 'COMPLETE') { 77 | return generated_images.map(({ url }: { url: string }) => ({ 78 | url, 79 | })); 80 | } 81 | await this.sleep(1); 82 | } 83 | this.log.warn(`image was not generated after ${IMAGE.TIMEOUT} seconds`); 84 | } catch (error: any) { 85 | this.logError({ type: 'wait for image', error }); 86 | throw new Error(error); 87 | } 88 | } 89 | 90 | async test() { 91 | try { 92 | const { data } = await axios({ 93 | method: 'get', 94 | url: 'https://cloud.leonardo.ai/api/rest/v1/me', 95 | headers: { Authorization: `Bearer ${KEY}` }, 96 | timeout: 10000, 97 | }); 98 | return true; 99 | } catch (error: any) { 100 | return error?.response?.data || error; 101 | } 102 | } 103 | 104 | logError({ type, error }: LogError) { 105 | this.log.error(`${type}: ${error?.response?.data.error || error}`); 106 | } 107 | } 108 | 109 | export default DeepAI; 110 | -------------------------------------------------------------------------------- /api/src/util/cron.util.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import axios from 'axios'; 3 | import { CronJob } from 'cron'; 4 | import { DateTime } from 'luxon'; 5 | 6 | import config from '../config'; 7 | import { emitter } from '../util/emitter.util'; 8 | import state from '../util/state.util'; 9 | import prisma from '../util/prisma.util'; 10 | import Log from '../util/logger.util'; 11 | import { version } from '../../package.json'; 12 | 13 | const { 14 | TELEMETRY, 15 | TRANSCRIPT: { CRON, MINUTES, MINIMUM }, 16 | AUTOGEN, 17 | } = config(); 18 | 19 | export default { 20 | heartbeat: async () => { 21 | if (process.env.NODE_ENV !== 'production' || !TELEMETRY) return; 22 | const track = async () => 23 | axios({ 24 | method: 'post', 25 | url: 'https://api.phrame.ai/v1/telemetry', 26 | timeout: 5 * 1000, 27 | data: { 28 | version, 29 | arch: os.arch(), 30 | }, 31 | validateStatus: () => true, 32 | }).catch((/* error */) => {}); 33 | 34 | await track(); 35 | new CronJob(`${DateTime.now().toFormat('m')} * * * *`, track).start(); 36 | }, 37 | transcript: async () => { 38 | const { log } = new Log('cron'); 39 | try { 40 | new CronJob(CRON, async () => { 41 | const { cron } = state.get(); 42 | if (!cron) { 43 | log.verbose('paused'); 44 | return; 45 | } 46 | 47 | const recent = await prisma.image.findFirst({ 48 | where: { 49 | createdAt: { 50 | gte: new Date(Date.now() - 5 * 60 * 1000), 51 | }, 52 | }, 53 | orderBy: { 54 | createdAt: 'desc', 55 | }, 56 | }); 57 | 58 | if (recent) { 59 | log.verbose('skipped (image created within last 5 minutes)'); 60 | return; 61 | } 62 | 63 | await prisma.transcript.deleteMany({ 64 | where: { 65 | createdAt: { 66 | lt: new Date(Date.now() - MINUTES * 60 * 1000), 67 | }, 68 | }, 69 | }); 70 | 71 | const transcriptIds = ( 72 | await prisma.transcript.findMany({ 73 | select: { 74 | id: true, 75 | }, 76 | where: { 77 | createdAt: { 78 | gte: new Date(Date.now() - MINUTES * 60 * 1000), 79 | }, 80 | }, 81 | }) 82 | ).map(({ id }: { id: number }) => id); 83 | 84 | if (transcriptIds.length < MINIMUM) { 85 | log.info( 86 | `${MINIMUM} transcript(s) needed within last ${MINUTES} minutes, found ${transcriptIds.length}` 87 | ); 88 | return; 89 | } 90 | 91 | emitter.emit('transcript.process', transcriptIds); 92 | }).start(); 93 | } catch (error: any) { 94 | log.error(error.message); 95 | } 96 | }, 97 | autogen: async () => { 98 | const { log } = new Log('autogen'); 99 | try { 100 | new CronJob(AUTOGEN.CRON, async () => { 101 | const { autogen } = state.get(); 102 | if (!autogen) { 103 | log.verbose('paused'); 104 | return; 105 | } 106 | const openai = (await import('../ai/openai')).default; 107 | const summary = await new openai().random({ 108 | prompt: AUTOGEN.PROMPT, 109 | context: AUTOGEN.KEYWORDS.join(', '), 110 | }); 111 | emitter.emit('summary.create', summary); 112 | }).start(); 113 | } catch (error: any) { 114 | log.error(error.message); 115 | } 116 | }, 117 | }; 118 | -------------------------------------------------------------------------------- /api/src/ai/dream.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import AI, { LogError } from './ai'; 4 | import config from '../config'; 5 | import dreamStyles from './util/dream-styles.util'; 6 | 7 | const { 8 | DREAM: { KEY, IMAGE }, 9 | } = config(); 10 | 11 | const styleToId = (style: string) => 12 | dreamStyles.find((s) => s.name.toLowerCase() === style.toLowerCase())?.id; 13 | 14 | class Dream extends AI { 15 | taskId?: number; 16 | constructor(meta?: any) { 17 | super('dream', meta); 18 | this.styles = IMAGE.STYLE; 19 | this.taskId = undefined; 20 | } 21 | 22 | async generateTask() { 23 | const { data } = await axios({ 24 | method: 'post', 25 | url: 'https://api.luan.tools/api/tasks', 26 | headers: { 27 | Authorization: `Bearer ${KEY}`, 28 | 'Content-Type': 'application/json', 29 | }, 30 | data: { use_target_image: false }, 31 | }); 32 | this.taskId = data.id; 33 | } 34 | 35 | async generateImage() { 36 | await this.createAttemptHandler('create task', this.generateTask.bind(this))(); 37 | if (!this.taskId) { 38 | this.log.error('task id not found'); 39 | return; 40 | } 41 | await this.createAttemptHandler('set task', this.startTask.bind(this))(); 42 | const image = await this.waitForImage(); 43 | if (image?.url) await this.downloadImage([{ url: image.url }]); 44 | } 45 | 46 | async startTask() { 47 | await axios({ 48 | method: 'put', 49 | url: `https://api.luan.tools/api/tasks/${this.taskId}`, 50 | headers: { 51 | Authorization: `Bearer ${KEY}`, 52 | 'Content-Type': 'application/json', 53 | }, 54 | data: JSON.stringify({ 55 | input_spec: { 56 | style: styleToId(this.style), 57 | prompt: this.meta.summary.summary, 58 | width: 512, 59 | height: 512, 60 | }, 61 | }), 62 | }); 63 | } 64 | 65 | async waitForImage() { 66 | try { 67 | this.log.info('wait for image'); 68 | for (let i = 0; i < IMAGE.TIMEOUT; i++) { 69 | const { data } = await axios({ 70 | method: 'get', 71 | url: `https://api.luan.tools/api/tasks/${this.taskId}`, 72 | headers: { 73 | Authorization: `Bearer ${KEY}`, 74 | 'Content-Type': 'application/json', 75 | }, 76 | }); 77 | 78 | if (i === IMAGE.TIMEOUT / 2) { 79 | this.log.info('still waiting'); 80 | } 81 | 82 | const { state } = data; 83 | if (state == 'failed') { 84 | this.log.error('failed to generate image'); 85 | break; 86 | } else if (state == 'completed') { 87 | return { url: data.result }; 88 | } 89 | await this.sleep(1); 90 | } 91 | this.log.warn(`image was not generated after ${IMAGE.TIMEOUT} seconds`); 92 | } catch (error: any) { 93 | this.logError({ type: 'wait for image', error }); 94 | throw new Error(error); 95 | } 96 | } 97 | 98 | async test() { 99 | const uuid = () => 100 | 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 101 | let r = (Math.random() * 16) | 0, 102 | v = c == 'x' ? r : (r & 0x3) | 0x8; 103 | return v.toString(16); 104 | }); 105 | 106 | const taskId = uuid(); 107 | const { data } = await axios({ 108 | method: 'get', 109 | url: `https://api.luan.tools/api/tasks/${taskId}`, 110 | headers: { 111 | Authorization: `Bearer ${KEY}`, 112 | }, 113 | validateStatus: () => true, 114 | }); 115 | 116 | return data?.detail?.toLowerCase().includes(`${taskId} not found`) || data; 117 | } 118 | 119 | logError({ type, error }: LogError): any { 120 | this.log.error(`${type}: ${error?.response?.data?.detail || error}`); 121 | } 122 | } 123 | 124 | export default Dream; 125 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // Disable no-extraneous-dependencies because vite is not set up in airbnb's eslint config 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | /* eslint-env node */ 4 | require('@rushstack/eslint-patch/modern-module-resolution'); 5 | 6 | module.exports = { 7 | root: true, 8 | extends: [ 9 | 'plugin:vue/vue3-essential', 10 | 'eslint:recommended', 11 | 'airbnb-base', 12 | 'airbnb-typescript/base', 13 | '@vue/eslint-config-typescript/recommended', 14 | '@vue/eslint-config-prettier', 15 | ], 16 | parserOptions: { 17 | ecmaVersion: 'latest', 18 | // Not needed, but keeping in case we need to revisit this. 19 | // project: './tsconfig.eslint.json', 20 | }, 21 | env: { 22 | 'vue/setup-compiler-macros': true, 23 | }, 24 | settings: { 25 | 'import/resolver': { 26 | typescript: {}, // this loads /tsconfig.json to eslint 27 | }, 28 | }, 29 | rules: { 30 | // Modify some Typescript Linting rules because they require parserOptions.project to be defined, which noticeably slows down lint-on-save. 31 | '@typescript-eslint/dot-notation': 0, 32 | '@typescript-eslint/no-implied-eval': 0, 33 | '@typescript-eslint/no-throw-literal': 0, 34 | '@typescript-eslint/return-await': 0, 35 | '@typescript-eslint/naming-convention': 0, 36 | 37 | // Turn on the base eslint rules that Typescript turned off above. 38 | 'dot-notation': [ 39 | 'error', 40 | { 41 | allowKeywords: true, 42 | allowPattern: '', 43 | }, 44 | ], 45 | 'no-implied-eval': ['error'], 46 | 'no-throw-literal': ['error'], 47 | 'no-return-await': ['error'], 48 | 49 | // Only allow debugger in development 50 | 'no-debugger': process.env.PRE_COMMIT ? 'error' : 'warn', 51 | // Only allow `console.log` in development 52 | 'no-console': process.env.PRE_COMMIT 53 | ? ['error', { allow: ['warn', 'error'] }] 54 | : ['warn', { allow: ['warn', 'error'] }], 55 | // 'max-len': 0, 56 | // Allow object properties to be reassigned. 57 | 'no-param-reassign': ['error', { props: false }], 58 | // Disable global-require to allow for dynamic image imports 59 | 'global-require': 'off', 60 | // Disable underscore dangle restriction 61 | 'no-underscore-dangle': 'off', 62 | // Disable prefer-destructuring for arrays only 63 | 'prefer-destructuring': ['error', { object: true, array: false }], 64 | // Allow for-of statements. Only way to do this is to change the default Airbnb rules, 65 | 'no-restricted-syntax': [ 66 | 'error', 67 | { 68 | selector: 'ForInStatement', 69 | message: 70 | 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', 71 | }, 72 | { 73 | selector: 'LabeledStatement', 74 | message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', 75 | }, 76 | { 77 | selector: 'WithStatement', 78 | message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', 79 | }, 80 | ], 81 | // Vue rules (mostly to enforce airbnb in