build: 打通 FastAPI 与 Vue 最小工程、依赖与数据库连接

后端入口、配置、Redis 与 Alembic 迁移就位;前端 Vite 工程可安装可构建。
This commit is contained in:
WangShen007 committed 2026-09-22 20:15:00 +08:00
1 parent fb0ddca4de
commit 2aee6286d6
167 files changed
+26082

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
+33
View File
@@ -0,0 +1,33 @@
# API URLs
VITE_API_URL=http://localhost:8000/api/v1
VITE_WS_URL=ws://localhost:8000
VITE_WIDGET_URL=http://localhost:8000
# Firebase Configuration
VITE_FIREBASE_API_KEY=your_firebase_api_key_here
VITE_FIREBASE_AUTH_DOMAIN=your_firebase_auth_domain_here
VITE_FIREBASE_PROJECT_ID=your_firebase_project_id_here
VITE_FIREBASE_MESSAGING_SENDER_ID=680125690355
VITE_FIREBASE_APP_ID=your_firebase_app_id_here
VITE_FIREBASE_STORAGE_BUCKET=your_firebase_storage_bucket_here
VITE_FIREBASE_MEASUREMENT_ID=your_firebase_measurement_id_here
VITE_FIREBASE_VAPID_KEY=your_firebase_vapid_key_here
# Google Fonts API — OPTIONAL. Only powers the font picker in agent
# customization; leave blank and a built-in font list is offered instead.
# Must be your own key: Google API keys are HTTP-referrer restricted, so a key
# issued for another domain returns 403 on yours. Get one at
# https://console.cloud.google.com/apis/credentials (enable "Web Fonts Developer
# API"), then restrict it to your app domain.
# Leave BLANK rather than a placeholder — a non-empty value is treated as a real
# key and the request goes out and fails.
VITE_GOOGLE_FONTS_API_KEY=
# Shopify API Key (for embedded apps) — optional, blank unless you ship one.
VITE_SHOPIFY_API_KEY=
# Node Environment
NODE_ENV=development
HOST=0.0.0.0
+38
View File
@@ -0,0 +1,38 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
test-results/
playwright-report/
# Module specific ignores
src/modules/**/node_modules/
src/modules/**/*.log
src/modules/**/.DS_Store
+9
View File
@@ -0,0 +1,9 @@
registry=https://registry.npmjs.org/
strict-ssl=false
legacy-peer-deps=true
audit=false
fund=false
progress=false
save-exact=true
always-auth=false
access=public
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+64
View File
@@ -0,0 +1,64 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Run End-to-End Tests with [Playwright](https://playwright.dev)
```sh
# Install browsers for the first run
npx playwright install
# When testing on CI, must build the project first
npm run build
# Runs the end-to-end tests
npm run test:e2e
# Runs the tests only on Chromium
npm run test:e2e -- --project=chromium
# Runs the tests of a specific file
npm run test:e2e -- tests/example.spec.ts
# Runs the tests in debug mode
npm run test:e2e -- --debug
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
+30
View File
@@ -0,0 +1,30 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
// Global type declarations
declare global {
interface Window {
APP_CONFIG?: {
API_URL?: string
WS_URL?: string
WIDGET_URL?: string
FIREBASE_API_KEY?: string
FIREBASE_AUTH_DOMAIN?: string
FIREBASE_PROJECT_ID?: string
FIREBASE_MESSAGING_SENDER_ID?: string
FIREBASE_APP_ID?: string
FIREBASE_STORAGE_BUCKET?: string
FIREBASE_MEASUREMENT_ID?: string
FIREBASE_VAPID_KEY?: string
RAZORPAY_KEY_ID?: string
GOOGLE_FONTS_API_KEY?: string
NODE_ENV?: string
HOST?: string
DEMO_WIDGET_ID?: string
VITE_SHOPIFY_API_KEY?: string
GTM_ID?: string
}
}
}
export {}
+73
View File
@@ -0,0 +1,73 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
import pluginVitest from '@vitest/eslint-plugin'
import pluginPlaywright from 'eslint-plugin-playwright'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
// The enterprise Playwright suite has its own lint config (e2e/eslint.config.js rules).
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', 'src/modules/enterprise/e2e/**'],
},
...pluginVue.configs['flat/essential'],
// Spread, not wrapped in an object: vueTsEslintConfig() returns an ARRAY of
// flat configs. Spreading it into `{}` produced numeric keys ("0", "1", …)
// and ESLint refused the whole config with `Unexpected key "0" found`, so
// linting has been failing outright rather than reporting anything.
...vueTsEslintConfig(),
{
name: 'app/ts-rule-overrides',
rules: {
'@typescript-eslint/no-explicit-any': 'off',
// Replaces the old `ban-types` entry here, which typescript-eslint v8
// split into these successor rules. It was configured to allow bare
// `Object`/`Function`; these two are the rules that would flag them.
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-wrapper-object-types': 'off',
}
},
{
// Published images are built once in CI and configured per deployment at
// container start, so import.meta.env.VITE_* is undefined at runtime for
// every self-hosted install. Config must come from window.APP_CONFIG via
// the resolvers in src/config/api.ts (or webclient/widget-env.ts, which
// keeps its own fallbacks). Only those resolvers may read the env directly.
name: 'app/no-build-time-env',
files: ['src/**/*.{ts,mts,tsx,vue}'],
ignores: ['src/config/api.ts', 'src/webclient/widget-env.ts', 'src/sw.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
// VITE_* only. Vite's own built-ins (BASE_URL, MODE, DEV, PROD) are
// build-time by definition and stay allowed.
selector:
'MemberExpression[object.object.type="MetaProperty"][object.property.name="env"][property.name=/^VITE_/]',
message:
'Do not read import.meta.env.VITE_* directly — it is undefined in the published image. Use the runtime resolvers in @/config/api (getApiUrl, resolveUploadUrl, getGoogleFontsApiKey, …).',
},
],
},
},
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
skipFormatting,
]
+12970
View File
File diff suppressed because it is too large. Load diff
+104
View File
@@ -0,0 +1,104 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"build:dev": "run-p type-check \"build-only:dev {@}\" --",
"prebuild:dev": "tsx scripts/generate-dev-env.ts",
"build-only:dev": "vite build --mode dev",
"build:prod": "run-p type-check \"build-only:prod {@}\" --",
"prebuild:prod": "tsx scripts/generate-prod-env.ts",
"build-only:prod": "vite build --mode prod",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"build:webclient": "npm run build:webclient:prod",
"build:webclient:dev": "tsx scripts/build-webclient-dev.ts",
"build:webclient:prod": "tsx scripts/build-webclient-prod.ts",
"build:widget": "tsx scripts/build-widget.ts",
"build:widget:dev": "tsx scripts/build-widget-dev.ts",
"build:widget:prod": "tsx scripts/build-widget-prod.ts",
"build:widgetdocker": "tsx scripts/build-widget-docker.ts"
},
"dependencies": {
"@fortawesome/fontawesome-free": "7.0.0",
"@fortawesome/fontawesome-svg-core": "7.0.0",
"@fortawesome/free-brands-svg-icons": "7.0.0",
"@fortawesome/free-regular-svg-icons": "7.0.0",
"@fortawesome/free-solid-svg-icons": "7.0.0",
"@fortawesome/vue-fontawesome": "3.1.1",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@primevue/themes": "^4.2.5",
"@shopify/app-bridge": "3.7.10",
"@shopify/app-bridge-utils": "3.5.1",
"@vue-flow/background": "1.3.2",
"@vue-flow/controls": "1.1.2",
"@vue-flow/core": "1.45.0",
"@vue-flow/minimap": "1.5.3",
"apexcharts": "4.4.0",
"axios": "1.20.0",
"chart.js": "4.4.7",
"date-fns": "^4.1.0",
"dompurify": "3.4.15",
"firebase": "^11.2.0",
"floating-vue": "^5.2.2",
"marked": "^15.0.6",
"mitt": "^3.0.1",
"pinia": "^2.3.0",
"primevue": "^4.2.5",
"socket.io-client": "4.8.3",
"timezone-select-js": "^2.0.1",
"vue": "^3.5.13",
"vue-advanced-cropper": "^2.8.9",
"vue-router": "^4.5.0",
"vue-sonner": "^1.3.0",
"vue3-apexcharts": "1.8.0",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@tsconfig/node22": "^22.0.0",
"@types/dotenv": "^8.2.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.10.2",
"@types/webfontloader": "^1.6.38",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/eslint-plugin": "1.1.20",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/eslint-config-typescript": "^14.1.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
"dotenv": "^16.4.5",
"eslint": "^9.14.0",
"eslint-plugin-playwright": "^2.1.0",
"eslint-plugin-vue": "^9.30.0",
"jsdom": "^25.0.1",
"npm-run-all2": "^7.0.2",
"prettier": "^3.3.3",
"tsx": "^4.7.1",
"typescript": "~5.6.3",
"vite": "6.4.3",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-vue-devtools": "^7.6.8",
"vitest": "3.2.7",
"vue-tsc": "^2.1.10",
"workbox-core": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1"
},
"overrides": {
"editorconfig": {
"minimatch": "^9.0.7"
}
}
}
+76
View File
@@ -0,0 +1,76 @@
import { build } from 'esbuild'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import * as dotenv from 'dotenv'
import * as fs from 'fs'
import * as path from 'path'
// Load development environment variables
dotenv.config({ path: '.env.dev' })
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
async function buildWebClientDev() {
try {
console.log('Building webclient with development configuration...')
// For development, we use the existing config.js which is already set for development
// No need to copy files like in production build
console.log('Using existing config.js for development build')
// Set NODE_ENV to development for the build
process.env.NODE_ENV = 'development'
// Baked DEFAULT base URL only; getBaseUrl() still resolves
// window.chattermateBaseUrl / window.APP_CONFIG at runtime, so no rebuild is
// needed when a deployment's env changes.
const apiUrl = process.env.VITE_API_URL || 'http://localhost:8000/api/v1'
console.log('Using API URL:', apiUrl)
const sourceFile = resolve(dirname(__dirname), 'src/webclient/chattermate.js')
await build({
entryPoints: [sourceFile],
bundle: true,
minify: false, // Don't minify for development
outfile: resolve(dirname(__dirname), 'public/webclient/chattermate.min.js'),
format: 'iife',
target: ['es2015'],
loader: {
'.svg': 'text',
},
define: {
'process.env.NODE_ENV': '"development"',
'__CHATTERMATE_API_URL__': JSON.stringify(apiUrl),
},
sourcemap: true, // Add sourcemap for development
})
// Copy output to dist/webclient directory
const publicWebclientPath = resolve(dirname(__dirname), 'public/webclient/chattermate.min.js')
const publicSourcemapPath = resolve(dirname(__dirname), 'public/webclient/chattermate.min.js.map')
const distWebclientDir = resolve(dirname(__dirname), 'dist/webclient')
const distWebclientPath = resolve(distWebclientDir, 'chattermate.min.js')
const distSourcemapPath = resolve(distWebclientDir, 'chattermate.min.js.map')
// Ensure dist/webclient directory exists
if (!fs.existsSync(distWebclientDir)) {
fs.mkdirSync(distWebclientDir, { recursive: true })
}
// Copy the built file and sourcemap to dist/webclient
fs.copyFileSync(publicWebclientPath, distWebclientPath)
if (fs.existsSync(publicSourcemapPath)) {
fs.copyFileSync(publicSourcemapPath, distSourcemapPath)
}
console.log('Development web client copied to dist/webclient/')
console.log('Development web client built successfully!')
} catch (error) {
console.error('Development build failed:', error)
process.exit(1)
}
}
buildWebClientDev()
+69
View File
@@ -0,0 +1,69 @@
import { build } from 'esbuild'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import * as dotenv from 'dotenv'
import * as fs from 'fs'
import * as path from 'path'
// Load production environment variables
dotenv.config({ path: '.env.prod' })
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
async function buildWebClientProd() {
try {
console.log('Building webclient with production configuration...')
// Config is now generated by the prebuild step (generate-prod-env.ts)
// No need to copy config files here
// Set NODE_ENV to production for the build
process.env.NODE_ENV = 'production'
// Baked DEFAULT base URL. This only sets the fallback; getBaseUrl() still
// resolves window.chattermateBaseUrl / window.APP_CONFIG at runtime, so an
// env change on a deployment needs no rebuild of this artifact.
const apiUrl = process.env.VITE_API_URL || 'https://api.chattermate.chat/api/v1'
console.log('Using API URL:', apiUrl)
const sourceFile = resolve(dirname(__dirname), 'src/webclient/chattermate.js')
await build({
entryPoints: [sourceFile],
bundle: true,
minify: true,
outfile: resolve(dirname(__dirname), 'public/webclient/chattermate.min.js'),
format: 'iife',
target: ['es2015'],
loader: {
'.svg': 'text',
},
define: {
'process.env.NODE_ENV': '"production"',
'__CHATTERMATE_API_URL__': JSON.stringify(apiUrl),
},
})
// Copy output to dist/webclient directory
const publicWebclientPath = resolve(dirname(__dirname), 'public/webclient/chattermate.min.js')
const distWebclientDir = resolve(dirname(__dirname), 'dist/webclient')
const distWebclientPath = resolve(distWebclientDir, 'chattermate.min.js')
// Ensure dist/webclient directory exists
if (!fs.existsSync(distWebclientDir)) {
fs.mkdirSync(distWebclientDir, { recursive: true })
}
// Copy the built file to dist/webclient
fs.copyFileSync(publicWebclientPath, distWebclientPath)
console.log('Production web client copied to dist/webclient/')
console.log('Production web client built successfully!')
} catch (error) {
console.error('Production build failed:', error)
process.exit(1)
}
}
buildWebClientProd()
+67
View File
@@ -0,0 +1,67 @@
import { build } from 'vite'
import { resolve } from 'path'
import * as fs from 'fs'
import * as path from 'path'
import * as dotenv from 'dotenv'
// Load development environment variables
dotenv.config({ path: '.env.dev' })
const config = resolve(process.cwd(), 'vite.widget.config.ts')
const assetsDir = resolve(process.cwd(), '../backend/assets')
const filesToKeep = ['widget.js', 'widget.css']
async function buildWidgetDev() {
try {
console.log('Building widget with development configuration...')
// Set NODE_ENV to development for the build
process.env.NODE_ENV = 'development'
// Run the Vite build
await build({ configFile: config })
console.log('Vite build completed')
// Copy CSS file from unused directory to the root assets directory
const unusedDir = path.join(assetsDir, 'unused')
if (fs.existsSync(unusedDir)) {
const files = fs.readdirSync(unusedDir)
const cssFile = files.find(file => file.endsWith('.css'))
if (cssFile) {
const sourcePath = path.join(unusedDir, cssFile)
const destPath = path.join(assetsDir, 'widget.css')
fs.copyFileSync(sourcePath, destPath)
console.log(`Copied CSS file to ${destPath}`)
}
// Clean up unused directory
fs.rmSync(unusedDir, { recursive: true, force: true })
console.log('Cleaned up unused directory')
}
// Clean up the assets directory - keep only widget.js and widget.css
const entries = fs.readdirSync(assetsDir)
for (const entry of entries) {
if (!filesToKeep.includes(entry)) {
const entryPath = path.join(assetsDir, entry)
if (fs.statSync(entryPath).isDirectory()) {
fs.rmSync(entryPath, { recursive: true, force: true })
} else {
fs.unlinkSync(entryPath)
}
}
}
console.log(`Cleaned up assets directory, keeping only: ${filesToKeep.join(', ')}`)
console.log('Development widget built successfully!')
} catch (error) {
console.error('Development build failed:', error)
process.exit(1)
}
}
buildWidgetDev()
+67
View File
@@ -0,0 +1,67 @@
import { build } from 'vite'
import { resolve } from 'path'
import * as fs from 'fs'
import * as path from 'path'
const config = resolve(process.cwd(), 'vite.widget.docker.config.ts')
// For Docker, we need to copy to a shared volume or mounted directory
// This assumes the backend assets are mounted/shared between containers
const assetsDir = resolve(process.cwd(), '/app/backend-assets')
const filesToKeep = ['widget.js', 'widget.css']
async function buildWidgetDocker() {
try {
// Ensure the backend assets directory exists
if (!fs.existsSync(assetsDir)) {
fs.mkdirSync(assetsDir, { recursive: true })
console.log(`Created backend assets directory: ${assetsDir}`)
}
// Run the Vite build
await build({ configFile: config })
console.log('Vite build completed')
// Copy CSS file from unused directory to the root assets directory
const unusedDir = path.join(assetsDir, 'unused')
if (fs.existsSync(unusedDir)) {
const files = fs.readdirSync(unusedDir)
const cssFile = files.find(file => file.endsWith('.css'))
if (cssFile) {
const sourcePath = path.join(unusedDir, cssFile)
const destPath = path.join(assetsDir, 'widget.css')
fs.copyFileSync(sourcePath, destPath)
console.log(`Copied CSS file to ${destPath}`)
}
// Clean up unused directory
fs.rmSync(unusedDir, { recursive: true, force: true })
console.log('Cleaned up unused directory')
}
// Clean up the assets directory - keep only widget.js and widget.css
const entries = fs.readdirSync(assetsDir)
for (const entry of entries) {
if (!filesToKeep.includes(entry)) {
const entryPath = path.join(assetsDir, entry)
if (fs.statSync(entryPath).isDirectory()) {
fs.rmSync(entryPath, { recursive: true, force: true })
} else {
fs.unlinkSync(entryPath)
}
}
}
console.log(`Cleaned up assets directory, keeping only: ${filesToKeep.join(', ')}`)
console.log('Widget built successfully for Docker!')
console.log(`Files available at: ${assetsDir}`)
} catch (error) {
console.error('Docker widget build failed:', error)
process.exit(1)
}
}
buildWidgetDocker()
+67
View File
@@ -0,0 +1,67 @@
import { build } from 'vite'
import { resolve } from 'path'
import * as fs from 'fs'
import * as path from 'path'
import dotenv from 'dotenv'
// Load production environment variables
dotenv.config({ path: '.env.prod' })
const config = resolve(process.cwd(), 'vite.widget.config.ts')
const assetsDir = resolve(process.cwd(), '../backend/assets')
const filesToKeep = ['widget.js', 'widget.css']
async function buildWidgetProd() {
try {
console.log('Building widget with production configuration...')
// Set NODE_ENV to production for the build
process.env.NODE_ENV = 'production'
// Run the Vite build
await build({ configFile: config })
console.log('Vite build completed')
// Copy CSS file from unused directory to the root assets directory
const unusedDir = path.join(assetsDir, 'unused')
if (fs.existsSync(unusedDir)) {
const files = fs.readdirSync(unusedDir)
const cssFile = files.find(file => file.endsWith('.css'))
if (cssFile) {
const sourcePath = path.join(unusedDir, cssFile)
const destPath = path.join(assetsDir, 'widget.css')
fs.copyFileSync(sourcePath, destPath)
console.log(`Copied CSS file to ${destPath}`)
}
// Clean up unused directory
fs.rmSync(unusedDir, { recursive: true, force: true })
console.log('Cleaned up unused directory')
}
// Clean up the assets directory - keep only widget.js and widget.css
const entries = fs.readdirSync(assetsDir)
for (const entry of entries) {
if (!filesToKeep.includes(entry)) {
const entryPath = path.join(assetsDir, entry)
if (fs.statSync(entryPath).isDirectory()) {
fs.rmSync(entryPath, { recursive: true, force: true })
} else {
fs.unlinkSync(entryPath)
}
}
}
console.log(`Cleaned up assets directory, keeping only: ${filesToKeep.join(', ')}`)
console.log('Production widget built successfully!')
} catch (error) {
console.error('Production build failed:', error)
process.exit(1)
}
}
buildWidgetProd()
+63
View File
@@ -0,0 +1,63 @@
import { build } from 'vite'
import { resolve } from 'path'
import * as fs from 'fs'
import * as path from 'path'
import dotenv from 'dotenv'
// Load the local .env so a plain `build:widget` bakes the local API URL
// (VITE_API_URL) instead of falling through to the widget-env default.
dotenv.config({ path: '.env' })
const config = resolve(process.cwd(), 'vite.widget.config.ts')
const assetsDir = resolve(process.cwd(), '../backend/assets')
const filesToKeep = ['widget.js', 'widget.css']
async function buildWidget() {
try {
// Run the Vite build
await build({ configFile: config })
console.log('Vite build completed')
// Copy CSS file from unused directory to the root assets directory
const unusedDir = path.join(assetsDir, 'unused')
if (fs.existsSync(unusedDir)) {
const files = fs.readdirSync(unusedDir)
const cssFile = files.find(file => file.endsWith('.css'))
if (cssFile) {
const sourcePath = path.join(unusedDir, cssFile)
const destPath = path.join(assetsDir, 'widget.css')
fs.copyFileSync(sourcePath, destPath)
console.log(`Copied CSS file to ${destPath}`)
}
// Clean up unused directory
fs.rmSync(unusedDir, { recursive: true, force: true })
console.log('Cleaned up unused directory')
}
// Clean up the assets directory - keep only widget.js and widget.css
const entries = fs.readdirSync(assetsDir)
for (const entry of entries) {
if (!filesToKeep.includes(entry)) {
const entryPath = path.join(assetsDir, entry)
if (fs.statSync(entryPath).isDirectory()) {
fs.rmSync(entryPath, { recursive: true, force: true })
} else {
fs.unlinkSync(entryPath)
}
}
}
console.log(`Cleaned up assets directory, keeping only: ${filesToKeep.join(', ')}`)
console.log('Widget built successfully!')
} catch (error) {
console.error('Build failed:', error)
process.exit(1)
}
}
buildWidget()
+56
View File
@@ -0,0 +1,56 @@
import * as fs from 'fs'
import * as path from 'path'
import * as dotenv from 'dotenv'
// Load development environment variables
dotenv.config({ path: '.env.dev' })
// (swenv.js is gone — the app service worker src/sw.ts inlines its Firebase
// config at build time via import.meta.env)
// Create config.js content for development using environment variables
const configContent = `// Runtime configuration - this file gets replaced at container startup
// Assigned to self, not window, so the service worker can importScripts()
// it too; in a page self === window, so window.APP_CONFIG still works.
self.APP_CONFIG = {
// API URLs
API_URL: "${process.env.VITE_API_URL || 'http://localhost:8000/api/v1'}",
WS_URL: "${process.env.VITE_WS_URL || 'ws://localhost:8000'}",
WIDGET_URL: "${process.env.VITE_WIDGET_URL || 'http://localhost:8000'}",
// Firebase Configuration
FIREBASE_API_KEY: "${process.env.VITE_FIREBASE_API_KEY || 'your_firebase_api_key_here'}",
FIREBASE_AUTH_DOMAIN: "${process.env.VITE_FIREBASE_AUTH_DOMAIN || 'your_firebase_auth_domain_here'}",
FIREBASE_PROJECT_ID: "${process.env.VITE_FIREBASE_PROJECT_ID || 'your_firebase_project_id_here'}",
FIREBASE_MESSAGING_SENDER_ID: "${process.env.VITE_FIREBASE_MESSAGING_SENDER_ID || '680125690355'}",
FIREBASE_APP_ID: "${process.env.VITE_FIREBASE_APP_ID || 'your_firebase_app_id_here'}",
FIREBASE_STORAGE_BUCKET: "${process.env.VITE_FIREBASE_STORAGE_BUCKET || 'your_firebase_storage_bucket_here'}",
FIREBASE_MEASUREMENT_ID: "${process.env.VITE_FIREBASE_MEASUREMENT_ID || 'your_firebase_measurement_id_here'}",
FIREBASE_VAPID_KEY: "${process.env.VITE_FIREBASE_VAPID_KEY || 'your_firebase_vapid_key_here'}",
// Google Fonts API
GOOGLE_FONTS_API_KEY: "${process.env.VITE_GOOGLE_FONTS_API_KEY || 'your_google_fonts_api_key_here'}",
// Demo Widget ID
DEMO_WIDGET_ID: "${process.env.VITE_DEMO_WIDGET_ID || 'your_demo_widget_id_here'}",
// Node Environment
NODE_ENV: "development",
HOST: "${process.env.VITE_HOST || '0.0.0.0'}"
};
`
// Write config to both public and dist directories
const publicConfigPath = './public/config.js'
const distConfigPath = './dist/config.js'
// Write to public directory
fs.writeFileSync(publicConfigPath, configContent)
console.log('Development config file generated to public/config.js')
// Ensure dist directory exists and write to dist directory
if (!fs.existsSync('./dist')) {
fs.mkdirSync('./dist', { recursive: true })
}
fs.writeFileSync(distConfigPath, configContent)
console.log('Development config file generated to dist/config.js')
+57
View File
@@ -0,0 +1,57 @@
import * as fs from 'fs'
import * as path from 'path'
import * as dotenv from 'dotenv'
// Load production environment variables
dotenv.config({ path: '.env.prod' })
// (swenv.js is gone — the app service worker src/sw.ts inlines its Firebase
// config at build time via import.meta.env)
// Create config.js content for production using environment variables
const configContent = `// Runtime configuration - this file gets replaced at container startup
// Assigned to self, not window, so the service worker can importScripts()
// it too; in a page self === window, so window.APP_CONFIG still works.
self.APP_CONFIG = {
// API URLs
API_URL: "${process.env.VITE_API_URL || 'https://api.chattermate.chat/api/v1'}",
WS_URL: "${process.env.VITE_WS_URL || 'wss://api.chattermate.chat'}",
WIDGET_URL: "${process.env.VITE_WIDGET_URL || 'https://api.chattermate.chat'}",
// Firebase Configuration
FIREBASE_API_KEY: "${process.env.VITE_FIREBASE_API_KEY || ''}",
FIREBASE_AUTH_DOMAIN: "${process.env.VITE_FIREBASE_AUTH_DOMAIN || ''}",
FIREBASE_PROJECT_ID: "${process.env.VITE_FIREBASE_PROJECT_ID || ''}",
FIREBASE_MESSAGING_SENDER_ID: "${process.env.VITE_FIREBASE_MESSAGING_SENDER_ID || ''}",
FIREBASE_APP_ID: "${process.env.VITE_FIREBASE_APP_ID || ''}",
FIREBASE_STORAGE_BUCKET: "${process.env.VITE_FIREBASE_STORAGE_BUCKET || ''}",
FIREBASE_MEASUREMENT_ID: "${process.env.VITE_FIREBASE_MEASUREMENT_ID || ''}",
FIREBASE_VAPID_KEY: "${process.env.VITE_FIREBASE_VAPID_KEY || ''}",
// Google Fonts API
GOOGLE_FONTS_API_KEY: "${process.env.VITE_GOOGLE_FONTS_API_KEY || ''}",
// Demo Widget ID
DEMO_WIDGET_ID: "${process.env.VITE_DEMO_WIDGET_ID || ''}",
// Node Environment
NODE_ENV: "production",
HOST: "${process.env.VITE_HOST || '0.0.0.0'}",
VITE_SHOPIFY_API_KEY: "${process.env.VITE_SHOPIFY_API_KEY || ''}",
};
`
// Write config to both public and dist directories
const publicConfigPath = './public/config.js'
const distConfigPath = './dist/config.js'
// Write to public directory
fs.writeFileSync(publicConfigPath, configContent)
console.log('Production config file generated to public/config.js')
// Ensure dist directory exists and write to dist directory
if (!fs.existsSync('./dist')) {
fs.mkdirSync('./dist', { recursive: true })
}
fs.writeFileSync(distConfigPath, configContent)
console.log('Production config file generated to dist/config.js')
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*", "src/modules/enterprise/e2e/**"],
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.app.json",
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"lib": [],
"types": ["node", "jsdom"]
}
}
+153
View File
@@ -0,0 +1,153 @@
import { fileURLToPath, URL } from 'node:url'
import * as fs from 'fs'
import * as path from 'path'
import * as dotenv from 'dotenv'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import { VitePWA } from 'vite-plugin-pwa'
import { THEME_COLORS } from './src/config/themeColors'
// https://vite.dev/config/
export default defineConfig(({ command, mode }) => {
// Load env file based on `mode` in the current working directory.
// When mode is 'prod' or 'dev', load .env.prod or .env.dev respectively
let envDir = process.cwd()
let envFile = '.env'
if (mode === 'prod' || mode === 'production') {
// Manually load .env.prod for production builds. Plain `vite build`
// (mode 'production', used by Dockerfile.frontend.prod) must see the
// VITE_FIREBASE_* values too: src/sw.ts inlines them at build time, and
// without them the service worker ships with push silently disabled.
const envPath = path.resolve(envDir, '.env.prod')
if (fs.existsSync(envPath)) {
const envConfig = dotenv.parse(fs.readFileSync(envPath))
// Merge with process.env
for (const key in envConfig) {
if (!process.env[key]) {
process.env[key] = envConfig[key]
}
}
}
} else if (mode === 'dev') {
// Manually load .env.dev for development builds
const envPath = path.resolve(envDir, '.env.dev')
if (fs.existsSync(envPath)) {
const envConfig = dotenv.parse(fs.readFileSync(envPath))
// Merge with process.env
for (const key in envConfig) {
if (!process.env[key]) {
process.env[key] = envConfig[key]
}
}
}
}
// Also load standard Vite env files
const env = loadEnv(mode, process.cwd(), '')
if (command === 'build' && !(process.env.VITE_FIREBASE_API_KEY || env.VITE_FIREBASE_API_KEY)) {
console.warn(
'\n[pwa] VITE_FIREBASE_API_KEY is not set for this build — the service worker ' +
'will ship WITHOUT background push support (foreground notifications still work ' +
'via runtime config.js). Provide .env/.env.prod with VITE_FIREBASE_* to enable it.\n'
)
}
return {
plugins: [
vue({
template: {
compilerOptions: {
// Treat all tags starting with 's-' as custom elements (Polaris web components)
isCustomElement: (tag) => tag.startsWith('s-')
}
}
}),
vueJsx(),
vueDevTools(),
// Plugin to inject environment variables into HTML
{
name: 'html-transform',
transformIndexHtml(html) {
return html.replace(
/<%= VITE_SHOPIFY_API_KEY %>/g,
process.env.VITE_SHOPIFY_API_KEY || env.VITE_SHOPIFY_API_KEY || ''
)
}
},
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
// Registration is manual (src/pwa/register.ts) so the Shopify App Bridge
// script stays the first script tag and embedded contexts never register.
injectRegister: false,
manifest: {
name: 'ChatterMate',
short_name: 'ChatterMate',
description: 'Agent console for ChatterMate — handle customer chats anywhere.',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: THEME_COLORS.dark,
theme_color: THEME_COLORS.dark,
icons: [
{ src: '/pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/pwa-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: '/pwa-maskable-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
injectManifest: {
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
// config.js is runtime-substituted per environment (docker-entrypoint)
// and must never be precached; webclient isn't the agent app.
globIgnores: ['config.js', 'config.dev.js', 'webclient/**'],
},
devOptions: {
enabled: false,
},
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
headers: {
'Service-Worker-Allowed': '/',
},
// Allow ngrok and other tunnel services for Shopify embedded app development
allowedHosts: [
'localhost',
'.ngrok.app',
'.ngrok.io',
'frontendchat.ngrok.app',
'.chattermate.chat',
],
},
publicDir: 'public',
build: {
outDir: 'dist',
assetsDir: 'assets',
chunkSizeWarningLimit: 600,
rollupOptions: {
input: {
main: './index.html',
},
output: {
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-ui': ['vue3-apexcharts'],
'vendor-utils': ['axios', 'marked'],
'vendor-firebase': ['firebase/app', 'firebase/messaging', 'firebase/auth'],
},
},
},
},
}
})
+44
View File
@@ -0,0 +1,44 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Treat all tags starting with 's-' as custom elements (Polaris web components)
isCustomElement: (tag) => tag.startsWith('s-')
}
}
})
],
build: {
lib: {
entry: resolve(__dirname, 'src/webclient/widget.ts'),
name: 'ChatterMateWidget',
fileName: 'widget',
formats: ['es'],
},
outDir: '../backend/assets',
emptyOutDir: false,
rollupOptions: {
output: {
format: 'es',
entryFileNames: 'widget.js',
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') {
return 'widget.css'
}
// Place other assets in a subdirectory that we can ignore or clean up later
return 'unused/[name]-[hash][extname]'
},
chunkFileNames: 'unused/[name]-[hash].js',
manualChunks: undefined, // Disable code splitting
},
},
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
},
})
+44
View File
@@ -0,0 +1,44 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Treat all tags starting with 's-' as custom elements (Polaris web components)
isCustomElement: (tag) => tag.startsWith('s-')
}
}
})
],
build: {
lib: {
entry: resolve(__dirname, 'src/webclient/widget.ts'),
name: 'ChatterMateWidget',
fileName: 'widget',
formats: ['es'],
},
outDir: '/app/backend-assets',
emptyOutDir: false,
rollupOptions: {
output: {
format: 'es',
entryFileNames: 'widget.js',
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') {
return 'widget.css'
}
// Place other assets in a subdirectory that we can ignore or clean up later
return 'unused/[name]-[hash][extname]'
},
chunkFileNames: 'unused/[name]-[hash].js',
manualChunks: undefined, // Disable code splitting
},
},
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
},
})
+23
View File
@@ -0,0 +1,23 @@
import { fileURLToPath } from 'node:url'
import { defineConfig, configDefaults } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
export default defineConfig({
plugins: [vue(), vueJsx()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
// vite-plugin-pwa virtual module doesn't exist under vitest
'virtual:pwa-register': fileURLToPath(
new URL('./src/__tests__/stubs/pwa-register.ts', import.meta.url)
),
},
},
test: {
environment: 'jsdom',
// Playwright specs (the enterprise suite lives under its module), not vitest.
exclude: [...configDefaults.exclude, 'e2e/**', 'src/modules/enterprise/e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
})