create-gentiq-app 0.18.0 → 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -168
- package/index.js +11 -4
- package/package.json +1 -1
- package/templates/starter/README.md +111 -67
- package/templates/starter/backend/.env.example +9 -5
- package/templates/starter/backend/README.md +17 -17
- package/templates/starter/backend/main.py +20 -9
- package/templates/starter/backend/pyproject.toml +1 -0
- package/templates/starter/frontend/README.md +10 -8
- package/templates/starter/frontend/package.json +3 -5
- package/templates/starter/frontend/src/App.tsx +10 -149
- package/templates/starter/frontend/src/index.css +18 -6
- package/templates/starter/frontend/src/options.ts +86 -0
- package/templates/starter/frontend/src/theme.css +25 -0
package/README.md
CHANGED
|
@@ -1,90 +1,91 @@
|
|
|
1
|
-
# create-gentiq-app
|
|
1
|
+
# create-gentiq-app
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The CLI that scaffolds a new **Gentiq** application: a FastAPI + Pydantic AI backend and a Vite + React frontend, copied from Gentiq's starter template.
|
|
4
4
|
|
|
5
|
-
## Quick
|
|
6
|
-
|
|
7
|
-
The fastest way to get started with Gentiq is to run the following command in your terminal:
|
|
5
|
+
## Quick start
|
|
8
6
|
|
|
9
7
|
```bash
|
|
10
|
-
npx create-gentiq-app@latest my-
|
|
8
|
+
npx create-gentiq-app@latest my-app
|
|
11
9
|
```
|
|
12
10
|
|
|
13
11
|
Or with **Yarn**:
|
|
14
12
|
|
|
15
13
|
```bash
|
|
16
|
-
yarn create gentiq-app my-
|
|
14
|
+
yarn create gentiq-app my-app
|
|
17
15
|
```
|
|
18
16
|
|
|
19
|
-
Or with **
|
|
17
|
+
Or with **pnpm**:
|
|
20
18
|
|
|
21
19
|
```bash
|
|
22
|
-
pnpm create gentiq-app my-
|
|
20
|
+
pnpm create gentiq-app my-app
|
|
23
21
|
```
|
|
24
22
|
|
|
25
23
|
---
|
|
26
24
|
|
|
27
|
-
## What
|
|
25
|
+
## What it does
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
1. Asks where to create the project (default `./my-gentiq-app`) unless you pass a directory. If the directory is not empty, it asks before writing into it, and cancels without asking when it can't prompt.
|
|
28
|
+
2. Copies the starter template, without build output, virtualenvs, lockfiles, `.env` files or SQLite databases.
|
|
29
|
+
3. Pins the Gentiq dependencies to the CLI's own version: `^<version>` for `@gentiq/ui`, `@gentiq/react` and `@gentiq/admin` on npm, and the matching `>=<version>,<next minor>` range for `gentiq[engines]` on PyPI. The CLI and both SDKs release in lockstep.
|
|
30
|
+
4. Writes `backend/.env` from `backend/.env.example`, with freshly generated `JWT_SECRET_KEY`, `BACKEND_API_KEY` and `ADMIN_SETUP_TOKEN` values and, if you gave one, your `OPENAI_API_KEY`. It leaves an existing `backend/.env` alone.
|
|
31
|
+
5. Unless told not to, runs `uv sync` in `backend/` and your package manager's `install` in `frontend/`.
|
|
32
|
+
6. Prints the commands left to run.
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
- ⚡ **AI Streaming**: Real-time agent responses via PydanticAI and Vercel AI SDK.
|
|
33
|
-
- 🛠️ **Admin Dashboard**: Built-in analytics, history logs, and user management.
|
|
34
|
-
- 💰 **Atomic Balance**: Native tracking and enforcement of per-user token and request limits.
|
|
35
|
-
- 📂 **Flexible Persistence**: SQLite + local filesystem out of the box; MongoDB + MinIO/S3 for production.
|
|
36
|
-
- 🔒 **Enterprise-Grade Security**: Full JWT authentication suite and encrypted sessions.
|
|
34
|
+
When it can prompt, it asks whether to install dependencies, which package manager the frontend should use, and for an OpenAI API key (optional; press Enter to skip).
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
## What it scaffolds
|
|
39
37
|
|
|
40
|
-
|
|
38
|
+
- **Streaming chat**: Pydantic AI on the backend, the Vercel AI SDK UI message stream on the wire.
|
|
39
|
+
- **Admin panel** at `/admin`: users, plans, chat history, analytics and maintenance jobs.
|
|
40
|
+
- **Usage limits**: per-user plans or credits, or no limit at all.
|
|
41
|
+
- **Persistence**: SQLite and the local filesystem by default; MongoDB and MinIO/S3 when you switch engines.
|
|
42
|
+
- **Auth**: username/password sign-up and login with JWTs, and permission-based admin accounts.
|
|
43
|
+
|
|
44
|
+
---
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
## Project structure
|
|
43
47
|
|
|
44
48
|
```text
|
|
45
|
-
my-
|
|
46
|
-
├──
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
my-app/
|
|
50
|
+
├── .gitignore
|
|
51
|
+
├── README.md # How to run and customise the project
|
|
52
|
+
├── backend/ # FastAPI + Pydantic AI application
|
|
53
|
+
│ ├── main.py # Agent, tools and the GentiqApp that serves them
|
|
54
|
+
│ ├── pyproject.toml # Python dependencies (uv)
|
|
49
55
|
│ ├── .env.example # Environment variable template
|
|
50
|
-
│ └── .env # Generated, with real secrets
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
│ └── .env # Generated, with real secrets; gitignored
|
|
57
|
+
└── frontend/ # Vite + React application
|
|
58
|
+
├── src/App.tsx # GentiqChatApp, pointed at /api
|
|
59
|
+
├── src/options.ts # Its options: name, theme, texts, sidebar, settings
|
|
60
|
+
├── vite.config.ts # Proxies /api to the backend on port 8000
|
|
61
|
+
└── package.json
|
|
55
62
|
```
|
|
56
63
|
|
|
57
64
|
---
|
|
58
65
|
|
|
59
|
-
##
|
|
66
|
+
## Running the project
|
|
60
67
|
|
|
61
|
-
Once
|
|
68
|
+
Once the CLI has installed the dependencies and written `backend/.env`, the project runs.
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
installs the frontend dependencies with your package manager, and writes `backend/.env`
|
|
65
|
-
with freshly generated `JWT_SECRET_KEY` and `BACKEND_API_KEY` values. So once it
|
|
66
|
-
finishes, the project runs.
|
|
67
|
-
|
|
68
|
-
### 1. Launch Backend
|
|
70
|
+
### 1. Backend
|
|
69
71
|
|
|
70
72
|
```bash
|
|
71
73
|
cd backend
|
|
72
74
|
uv run uvicorn main:app.api --reload --port 8000
|
|
73
75
|
```
|
|
74
76
|
|
|
75
|
-
The
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
The one value the CLI can't provide is the AI provider key. If you didn't enter one at the prompt, set `OPENAI_API_KEY` in `backend/.env` before starting: the agent definition in `main.py` builds its provider client on import, so a missing key stops the server from starting. To use another provider, change the model string in `backend/main.py` and set that provider's key instead.
|
|
78
|
+
|
|
79
|
+
### 2. Frontend
|
|
80
|
+
|
|
81
|
+
In a second terminal:
|
|
80
82
|
|
|
81
|
-
### 2. Launch Frontend
|
|
82
83
|
```bash
|
|
83
84
|
cd frontend
|
|
84
|
-
npm run dev
|
|
85
|
+
npm run dev # or `pnpm dev`, `yarn dev`, `bun dev`
|
|
85
86
|
```
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
Then open `http://localhost:5173`.
|
|
88
89
|
|
|
89
90
|
---
|
|
90
91
|
|
|
@@ -96,142 +97,32 @@ npx create-gentiq-app@latest [directory] [options]
|
|
|
96
97
|
|
|
97
98
|
| Flag | Effect |
|
|
98
99
|
| --- | --- |
|
|
99
|
-
| `--pm <name>` | Package manager for the frontend install: `pnpm`, `npm`, `yarn` or `bun`. Defaults to the one that
|
|
100
|
-
| `--
|
|
101
|
-
| `--
|
|
102
|
-
|
|
|
100
|
+
| `--pm <name>` | Package manager for the frontend install: `pnpm`, `npm`, `yarn` or `bun`. Defaults to the one that ran the CLI, then `npm`. `--package-manager` and `--pm=<name>` also work. |
|
|
101
|
+
| `--install` | Install the backend and frontend dependencies without asking. This is the default. |
|
|
102
|
+
| `--no-install` | Scaffold only; skip `uv sync` and the frontend install. `--skip-install` also works. |
|
|
103
|
+
| `--openai-key <key>` | Write this `OPENAI_API_KEY` into `backend/.env` instead of asking. |
|
|
104
|
+
| `-y`, `--yes` | Accept every default and never prompt. The CLI also skips prompts when stdin or stdout is not a TTY, so it works in CI. |
|
|
103
105
|
| `-h`, `--help` | Show usage. |
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
an install link, and a package manager that is not on `PATH` falls back to one that is.
|
|
107
|
-
The scaffold itself is complete either way, and the printed next steps name whatever is
|
|
108
|
-
left to run by hand.
|
|
107
|
+
A missing tool doesn't stop the scaffold. Without `uv`, the backend install is skipped with a link to install it. If the chosen package manager isn't on `PATH`, the CLI uses the first of `pnpm`, `npm`, `yarn` and `bun` that is. The printed next steps list whatever is left to run by hand.
|
|
109
108
|
|
|
110
109
|
---
|
|
111
110
|
|
|
112
111
|
## Using Tailwind in a Gentiq app
|
|
113
112
|
|
|
114
|
-
The scaffold ships **no Tailwind
|
|
115
|
-
and admin UIs are fully styled by it. You only need Tailwind if you are writing your own
|
|
116
|
-
components (a custom tool card, say). If you add it, read this section first: two Tailwind
|
|
117
|
-
builds in one document need one extra line to coexist, and without it they break each
|
|
118
|
-
other silently.
|
|
113
|
+
The scaffold ships **no Tailwind**. `@gentiq/admin/style.css` is already compiled, and it styles the chat and admin UIs completely. You only need Tailwind for components of your own, such as a custom tool card, and you can add it the usual way, `@import 'tailwindcss'` included.
|
|
119
114
|
|
|
120
|
-
|
|
115
|
+
Gentiq's stylesheet applies only inside a Gentiq root: the `.gentiq` element that `Chat`, `ChatLayout`, the admin panel and `GentiqRoot` render. Its preflight and utilities sit in `@scope (.gentiq)` and its tokens are `--gentiq-*` on that element, so your Tailwind and Gentiq's don't reach each other's elements, and your `--primary` or `--radius` never meets Gentiq's.
|
|
121
116
|
|
|
122
|
-
|
|
123
|
-
it is meant to override — that is the whole reason `flex-col md:flex-row` works. The
|
|
124
|
-
guarantee holds **per build**. Gentiq ships precompiled CSS, so adding Tailwind to your app
|
|
125
|
-
creates a second, independently sorted set of utilities, and concatenating them destroys
|
|
126
|
-
the ordering: whichever set lands later wins every tie, regardless of which stylesheet
|
|
127
|
-
"owns" the class.
|
|
117
|
+
Keep `import '@gentiq/admin/style.css'` ahead of your own stylesheet, as the scaffold does. Gentiq's cascade layers sit between Tailwind's standard ones (`theme, base, gentiq-*, components, utilities, gentiq-utilities`): inside a root, your preflight then can't strip Gentiq's buttons, and Gentiq's preflight can't strip the classes your own components carry. A stylesheet loaded before Gentiq's declares your layers first, and Gentiq's then all follow them.
|
|
128
118
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
| Your utilities last | Gentiq's utilities last |
|
|
132
|
-
| --- | --- |
|
|
133
|
-
| Your `.flex-col` beats gentiq's `.md:flex-row` — the admin header collapses into a vertical stack at every width | Gentiq's `.grid-cols-2` beats your `.sm:grid-cols-3` — your own grids stop responding |
|
|
134
|
-
|
|
135
|
-
Reordering does not fix it, because the problem is not the order. It is that there are two
|
|
136
|
-
sorts. The fix is to collapse them back to one.
|
|
137
|
-
|
|
138
|
-
### The setup
|
|
139
|
-
|
|
140
|
-
Put this at the top of `src/index.css`, and delete `import 'gentiq/style.css'` from
|
|
141
|
-
`src/App.tsx` (this file has to load first, so it must own that import):
|
|
119
|
+
Your components inside a root, such as a custom tool card, can use Gentiq's tokens with your Tailwind by registering them:
|
|
142
120
|
|
|
143
121
|
```css
|
|
144
|
-
/* Layer order for the document: gentiq's layers first, yours last. */
|
|
145
|
-
@layer properties, gentiq-theme, gentiq-base, gentiq-components, gentiq-utilities,
|
|
146
|
-
theme, base, components, utilities;
|
|
147
|
-
|
|
148
|
-
@import 'gentiq/style.css';
|
|
149
|
-
|
|
150
|
-
/* Theme + utilities only — NOT `@import 'tailwindcss'`. That bundles preflight,
|
|
151
|
-
and gentiq's stylesheet already ships one; a second copy lands after gentiq's
|
|
152
|
-
rules and its `border: 0 solid` resets border-color to currentColor, which
|
|
153
|
-
paints every unstyled border in the app the foreground colour. */
|
|
154
|
-
@import 'tailwindcss/theme.css' layer(theme);
|
|
155
|
-
@import 'tailwindcss/utilities.css' layer(utilities);
|
|
156
|
-
|
|
157
|
-
/* The line that actually makes the two coexist: scan gentiq's bundles so YOUR
|
|
158
|
-
build emits gentiq's classes too. One build, one sort, every variant back
|
|
159
|
-
after its base utility. Gentiq's precompiled layer is then shadowed for every
|
|
160
|
-
class you regenerate, and still supplies its custom `@utility` rules (`glass`,
|
|
161
|
-
the loader animations), whose names contend with nothing you emit. */
|
|
162
|
-
@source '../node_modules/gentiq/dist/gentiq-index.es.js';
|
|
163
|
-
@source '../node_modules/gentiq/dist/gentiq-admin.es.js';
|
|
164
|
-
|
|
165
|
-
/* Gentiq's design tokens, registered with YOUR Tailwind instance. Gentiq declares
|
|
166
|
-
the same map in its own `@theme inline`, but that lives in its compiled
|
|
167
|
-
stylesheet, which your compiler never sees — so without this block utilities
|
|
168
|
-
like `hover:border-primary/40` silently generate nothing. `inline` is what makes
|
|
169
|
-
them resolve at runtime, so ThemeProvider can keep rewriting accent and theme. */
|
|
170
122
|
@theme inline {
|
|
171
|
-
--color-
|
|
172
|
-
--color-
|
|
173
|
-
--color-card: var(--card);
|
|
174
|
-
--color-card-foreground: var(--card-foreground);
|
|
175
|
-
--color-popover: var(--popover);
|
|
176
|
-
--color-popover-foreground: var(--popover-foreground);
|
|
177
|
-
--color-primary: var(--primary);
|
|
178
|
-
--color-primary-foreground: var(--primary-foreground);
|
|
179
|
-
--color-secondary: var(--secondary);
|
|
180
|
-
--color-secondary-foreground: var(--secondary-foreground);
|
|
181
|
-
--color-muted: var(--muted);
|
|
182
|
-
--color-muted-foreground: var(--muted-foreground);
|
|
183
|
-
--color-accent: var(--accent);
|
|
184
|
-
--color-accent-foreground: var(--accent-foreground);
|
|
185
|
-
--color-destructive: var(--destructive);
|
|
186
|
-
--color-border: var(--border);
|
|
187
|
-
--color-input: var(--input);
|
|
188
|
-
--color-ring: var(--ring);
|
|
189
|
-
|
|
190
|
-
--radius-sm: calc(var(--radius) - 4px);
|
|
191
|
-
--radius-md: calc(var(--radius) - 2px);
|
|
192
|
-
--radius-lg: var(--radius);
|
|
193
|
-
--radius-xl: calc(var(--radius) + 4px);
|
|
123
|
+
--color-gentiq-primary: var(--gentiq-primary);
|
|
124
|
+
--color-gentiq-muted: var(--gentiq-muted);
|
|
194
125
|
}
|
|
195
126
|
```
|
|
196
127
|
|
|
197
|
-
|
|
198
|
-
configured face with `var(--font-script)` and writes it inline to `<html>` and `<body>`;
|
|
199
|
-
an `!important` rule beats that inline style and strips the per-script fallback, leaving
|
|
200
|
-
Persian and Arabic text in a serif default.
|
|
201
|
-
|
|
202
|
-
The `@source` lines cost roughly 10–13 kB gzipped, because your build re-emits the
|
|
203
|
-
utilities gentiq already shipped. That is the price of a correct cascade; the duplicate
|
|
204
|
-
rules are identical and fully shadowed.
|
|
205
|
-
|
|
206
|
-
### Layers
|
|
207
|
-
|
|
208
|
-
Gentiq publishes into `gentiq-theme`, `gentiq-base`, `gentiq-components` and
|
|
209
|
-
`gentiq-utilities` rather than the bare `theme` / `base` / `components` / `utilities`
|
|
210
|
-
(versions up to and including `0.15.0-beta.1` used the bare names). Those generic names belong to your document, and gentiq
|
|
211
|
-
claiming them is what let the two builds merge by accident in the first place. Gentiq
|
|
212
|
-
deliberately does **not** pre-register your layer names, so your stylesheet keeps the
|
|
213
|
-
position it has always had; the `@layer` statement above is where you decide the order.
|
|
214
|
-
|
|
215
|
-
The setup above works against older versions too — the `gentiq-*` layers just stay empty
|
|
216
|
-
and gentiq's stylesheet lands in `theme` / `base` / `utilities` instead, still ahead of
|
|
217
|
-
your regenerated superset.
|
|
218
|
-
|
|
219
|
-
### Checking it
|
|
220
|
-
|
|
221
|
-
The admin dashboard header is the fastest canary — it is the layout in the product that
|
|
222
|
-
leans hardest on `md:` overrides. Open `/admin` above 768px: the logo and the navigation
|
|
223
|
-
tabs sit on one row. If they are stacked vertically, your utilities are beating gentiq's
|
|
224
|
-
variants and `@source` is missing or pointing at the wrong path.
|
|
225
|
-
|
|
226
|
-
---
|
|
227
|
-
|
|
228
|
-
## Why Gentiq?
|
|
229
|
-
|
|
230
|
-
Unlike generic boilerplates, Gentiq is built with **developer velocity** and **production scalability** in mind. It provides the "boring" parts of AI development—authentication, rate limiting, and persistence—so you can focus on building your agents.
|
|
231
|
-
|
|
232
|
-
- **Type-Safe**: End-to-end TypeScript and Python (Pydantic) types.
|
|
233
|
-
- **Customizable**: Every part of the UI and Backend is modular and extensible.
|
|
234
|
-
- **Modern Stack**: React 19, FastAPI, PydanticAI, and TailwindCSS.
|
|
235
|
-
|
|
236
|
-
---
|
|
237
|
-
|
|
128
|
+
To restyle Gentiq itself, set its tokens on `.gentiq` in your stylesheet, for example `.gentiq { --gentiq-radius: 0.5rem; }`. The accent, radius and font also come from the `theme` option (in `src/options.ts`), which the root sets inline.
|
package/index.js
CHANGED
|
@@ -73,7 +73,7 @@ function detectPackageManager() {
|
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
74
|
// Version pinning
|
|
75
75
|
//
|
|
76
|
-
// The
|
|
76
|
+
// The published packages (`gentiq` on PyPI, `gentiq` and `@gentiq/*` on npm, and this CLI)
|
|
77
77
|
// release in lockstep, so the CLI's own version names exactly the runtime this
|
|
78
78
|
// template was written against. Pinning to it is what keeps a scaffold produced
|
|
79
79
|
// by an older cached CLI from installing a newer, incompatible framework.
|
|
@@ -94,6 +94,8 @@ function versionRanges(version) {
|
|
|
94
94
|
? `${major}.${minor}.${patch}${kind === 'alpha' ? 'a' : kind === 'beta' ? 'b' : 'rc'}${num ?? 0}`
|
|
95
95
|
: `${major}.${minor}.${patch}`;
|
|
96
96
|
|
|
97
|
+
// An alpha or beta may break the one before it, so a scaffold pins its pre-release exactly.
|
|
98
|
+
if (kind) return { npm: version, pypi: `==${pep440}` };
|
|
97
99
|
return { npm: `^${version}`, pypi: `>=${pep440},<${upper}` };
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -151,6 +153,7 @@ async function writeEnvFile(backendDir, openaiKey) {
|
|
|
151
153
|
let content = await fs.readFile(examplePath, 'utf8');
|
|
152
154
|
content = setEnvValue(content, 'JWT_SECRET_KEY', secret());
|
|
153
155
|
content = setEnvValue(content, 'BACKEND_API_KEY', secret());
|
|
156
|
+
content = setEnvValue(content, 'ADMIN_SETUP_TOKEN', secret());
|
|
154
157
|
if (openaiKey) content = setEnvValue(content, 'OPENAI_API_KEY', openaiKey);
|
|
155
158
|
|
|
156
159
|
await fs.writeFile(envPath, content, 'utf8');
|
|
@@ -162,8 +165,11 @@ async function pinDependencies(projectPath, ranges) {
|
|
|
162
165
|
const frontendPkgPath = path.join(projectPath, 'frontend', 'package.json');
|
|
163
166
|
if (fs.existsSync(frontendPkgPath)) {
|
|
164
167
|
const pkg = await fs.readJson(frontendPkgPath);
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
// `gentiq` and the `@gentiq/*` packages release in lockstep with the CLI.
|
|
169
|
+
for (const name of Object.keys(pkg.dependencies ?? {})) {
|
|
170
|
+
if (name === 'gentiq' || name.startsWith('@gentiq/')) {
|
|
171
|
+
pkg.dependencies[name] = ranges?.npm ?? 'latest';
|
|
172
|
+
}
|
|
167
173
|
}
|
|
168
174
|
await fs.writeJson(frontendPkgPath, pkg, { spaces: 2 });
|
|
169
175
|
}
|
|
@@ -431,7 +437,8 @@ async function main() {
|
|
|
431
437
|
if (envCreated) {
|
|
432
438
|
p.note(
|
|
433
439
|
`${color.cyan('backend/.env')} was created from ${color.cyan('.env.example')} with freshly generated\n` +
|
|
434
|
-
`${color.bold('JWT_SECRET_KEY')} and ${color.bold('
|
|
440
|
+
`${color.bold('JWT_SECRET_KEY')}, ${color.bold('BACKEND_API_KEY')} and ${color.bold('ADMIN_SETUP_TOKEN')} values. It is gitignored — keep it that way.\n` +
|
|
441
|
+
`The admin panel asks for ${color.bold('ADMIN_SETUP_TOKEN')} once, to create the first admin.`,
|
|
435
442
|
'Secrets',
|
|
436
443
|
);
|
|
437
444
|
}
|
package/package.json
CHANGED
|
@@ -1,49 +1,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Gentiq App
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A conversational AI application built with [Gentiq](https://github.com/arxyzan/gentiq): a FastAPI + Pydantic AI backend and a React frontend, with authentication, streaming, chat history, an admin panel and per-user usage limits wired together.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What you get
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
7
|
+
- **Streaming chat**: Pydantic AI on the backend, the Vercel AI SDK UI message stream on the wire.
|
|
8
|
+
- **Admin panel** at `/admin`: users and their plans, chat history, analytics, and maintenance jobs.
|
|
9
|
+
- **Usage and cost tracking**: per-user plans or credits, or no limit at all. Each turn is priced per model from the `genai-prices` rate data that ships with Pydantic AI.
|
|
10
|
+
- **Persistence**: SQLite and the local filesystem by default; MongoDB and MinIO/S3 when you switch engines.
|
|
11
|
+
- **Auth**: sign-up and login with JWTs, argon2id password hashing, and permission-based admin accounts.
|
|
12
|
+
- **i18n and RTL**: Gentiq's UI ships in 13 languages, including the right-to-left Arabic, Persian and Urdu. `i18n.resources` in `src/options.ts` holds this app's own strings, in English and Persian.
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Getting started
|
|
17
17
|
|
|
18
18
|
### 1. Prerequisites
|
|
19
19
|
|
|
20
|
-
- **Python 3.13+**
|
|
21
|
-
- **Node.js 20+**
|
|
20
|
+
- **Python 3.13+** and [uv](https://docs.astral.sh/uv/)
|
|
21
|
+
- **Node.js 20.19+ or 22.12+** (Vite 7's minimum), with npm, pnpm, yarn or bun
|
|
22
22
|
|
|
23
|
-
Nothing else. The defaults (`
|
|
23
|
+
Nothing else. The defaults (`StorageConfig(db="sqlite", files="filesystem")`) write a SQLite database and a `.storage/` folder into the directory the backend runs from, so you need no Docker, database or object store locally.
|
|
24
24
|
|
|
25
|
-
> Scaffolded with `create-gentiq-app`?
|
|
26
|
-
>
|
|
27
|
-
> below
|
|
25
|
+
> Scaffolded with `create-gentiq-app`? It has already installed both halves and written
|
|
26
|
+
> `backend/.env` with generated secrets, so you only need the `uv run` and `npm run dev`
|
|
27
|
+
> lines below, plus a provider key if you didn't enter one at the prompt.
|
|
28
28
|
|
|
29
29
|
### 2. Backend
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
32
|
cd backend
|
|
33
|
-
cp .env.example .env # only if you
|
|
33
|
+
cp .env.example .env # only if you don't have one yet; see below
|
|
34
34
|
uv sync
|
|
35
35
|
uv run uvicorn main:app.api --reload --port 8000
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
`main.py` loads `backend/.env` with `python-dotenv` before it defines the agent; Gentiq itself reads no `.env` file. At minimum set:
|
|
39
39
|
|
|
40
40
|
| Variable | Why |
|
|
41
41
|
| --- | --- |
|
|
42
|
-
| `OPENAI_API_KEY` | Or the key for whichever provider
|
|
43
|
-
| `JWT_SECRET_KEY` | Signs user tokens. Generate one with `python -c "import secrets; print(secrets.token_hex(32))"`. |
|
|
44
|
-
| `BACKEND_API_KEY` | Guards the server-to-server auth
|
|
42
|
+
| `OPENAI_API_KEY` | Or the key for whichever provider the model string in `main.py` names. `main.py` builds the provider client as it defines the agent, so the server doesn't start without it. |
|
|
43
|
+
| `JWT_SECRET_KEY` | Signs user tokens. Generate one with `python -c "import secrets; print(secrets.token_hex(32))"`. The server won't start without it. |
|
|
44
|
+
| `BACKEND_API_KEY` | Guards the server-to-server endpoints, such as `POST /api/auth/user`. They reject every request while it is unset. |
|
|
45
|
+
| `ADMIN_SETUP_TOKEN` | The admin panel asks for it once, to create the first admin at `/admin`. Generate it like `JWT_SECRET_KEY`. |
|
|
45
46
|
|
|
46
|
-
The API docs are
|
|
47
|
+
The API docs are at `http://localhost:8000/docs`.
|
|
47
48
|
|
|
48
49
|
### 3. Frontend
|
|
49
50
|
|
|
@@ -55,90 +56,133 @@ npm install
|
|
|
55
56
|
npm run dev
|
|
56
57
|
```
|
|
57
58
|
|
|
58
|
-
Open `http://localhost:5173`, create an account, and start chatting. The Vite dev server proxies `/api` to port 8000 (see `frontend/vite.config.ts`), so
|
|
59
|
+
Open `http://localhost:5173`, create an account, and start chatting. The Vite dev server proxies `/api` to port 8000 (see `frontend/vite.config.ts`), so the browser talks to one origin in development.
|
|
59
60
|
|
|
60
|
-
> **
|
|
61
|
+
> **Packages.** The app depends on `@gentiq/ui` (the chat), `@gentiq/react` (its hooks, for your
|
|
62
|
+
> own components) and `@gentiq/admin` (the admin panel). `gentiq` on npm re-exports all of them
|
|
63
|
+
> from one package, if you prefer that. They declare React, React DOM, `@tanstack/react-query`, `i18next`,
|
|
64
|
+
> `sonner` and (optionally) React Router, which the admin panel uses, and `katex` as peer
|
|
65
|
+
> dependencies, so the app holds the only copy of each. They are all listed in
|
|
66
|
+
> `frontend/package.json`, so a plain install works with any package manager.
|
|
61
67
|
|
|
62
68
|
### 4. Admin panel
|
|
63
69
|
|
|
64
|
-
Visit `http://localhost:5173/admin`. On a fresh database the panel shows a one-time setup screen that creates the first admin (username `admin`) and signs you in. That first account
|
|
70
|
+
Visit `http://localhost:5173/admin`. On a fresh database the panel shows a one-time setup screen that asks for `ADMIN_SETUP_TOKEN` from `backend/.env`, creates the first admin (username `admin`) and signs you in. That first account gets every permission, including the dangerous ones, and can create more admins with narrower permissions. The setup screen is gone once an admin exists.
|
|
65
71
|
|
|
66
72
|
---
|
|
67
73
|
|
|
68
|
-
##
|
|
74
|
+
## Project structure
|
|
69
75
|
|
|
70
76
|
```text
|
|
71
77
|
├── backend/
|
|
72
78
|
│ ├── main.py # Agent, tools, and the GentiqApp that serves them
|
|
73
|
-
│ ├── .env # Your secrets and provider keys
|
|
79
|
+
│ ├── .env # Your secrets and provider keys; gitignored
|
|
74
80
|
│ ├── .env.example # Template for the above
|
|
75
|
-
│ └── pyproject.toml # Python dependencies (uv
|
|
81
|
+
│ └── pyproject.toml # Python dependencies (uv)
|
|
76
82
|
├── frontend/
|
|
77
|
-
│ ├── src/App.tsx #
|
|
78
|
-
│ ├── src/
|
|
83
|
+
│ ├── src/App.tsx # The app: GentiqChatApp, pointed at /api, with the admin panel
|
|
84
|
+
│ ├── src/options.ts # How it looks and behaves: name, theme, texts, sidebar, settings
|
|
85
|
+
│ ├── src/theme.css # Gentiq's colors, radius and type, as --gentiq-* tokens
|
|
86
|
+
│ ├── src/index.css # The page's own styles
|
|
87
|
+
│ ├── vite.config.ts # Dev server, proxying /api to the backend
|
|
79
88
|
│ └── package.json
|
|
80
89
|
└── README.md
|
|
81
90
|
```
|
|
82
91
|
|
|
83
92
|
---
|
|
84
93
|
|
|
85
|
-
##
|
|
94
|
+
## Where to make changes
|
|
86
95
|
|
|
87
|
-
**Add a tool
|
|
96
|
+
**Add a tool**: `backend/main.py`. Tools receive `RunContext[AgentDeps[AppContext]]`, so `ctx.deps` gives you the authenticated user, your own `AppContext`, the user's own threads (`ctx.deps.threads`), and `ctx.deps.stream(...)` for pushing progress events to the UI mid-call. `list_my_chats` is the worked example: it reads a limit from `AppContext`, lists the signed-in user's threads with `ctx.deps.threads.list(...)`, and sends progress events before and after.
|
|
88
97
|
|
|
89
|
-
Return only the fields the model needs. Store methods are written for the API layer and some, `list_user_threads` among them,
|
|
98
|
+
Return only the fields the model needs. Store methods are written for the API layer, and some, `list_user_threads` among them, include the owner's contact details and metadata, none of which belongs in a prompt.
|
|
90
99
|
|
|
91
|
-
**Render a tool's result yourself
|
|
100
|
+
**Render a tool's result yourself**: `components.parts`, an option of `GentiqChatApp` (add it to `src/options.ts`), maps `tool-<name>` to a React component, which renders in place of Gentiq's generic tool card. Tools without an entry keep the generic card.
|
|
92
101
|
|
|
93
102
|
```tsx
|
|
94
|
-
import {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const output = asLoosePart(part).output
|
|
100
|
-
if (!output) return null // nothing to show until the call resolves
|
|
101
|
-
return <ul>{(output as { chats: { title: string }[] }).chats.map(...)}</ul>
|
|
103
|
+
import { defineToolUI } from '@gentiq/react'
|
|
104
|
+
import { GentiqChatApp, type GentiqComponents } from '@gentiq/ui'
|
|
105
|
+
|
|
106
|
+
interface ChatListOutput {
|
|
107
|
+
chats: { title: string | null }[]
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
// The types describe what `list_my_chats` returns; nothing checks them at runtime.
|
|
111
|
+
const ChatList = defineToolUI<Record<string, never>, ChatListOutput>(({ part }) => {
|
|
112
|
+
if (part.state !== 'output-available') return null
|
|
113
|
+
return (
|
|
114
|
+
<ul>
|
|
115
|
+
{part.output.chats.map((chat, i) => (
|
|
116
|
+
<li key={i}>{chat.title}</li>
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const components: GentiqComponents = { parts: { 'tool-list_my_chats': ChatList } }
|
|
123
|
+
|
|
124
|
+
export function App() {
|
|
125
|
+
return <GentiqChatApp url="/api" components={components} />
|
|
126
|
+
}
|
|
106
127
|
```
|
|
107
128
|
|
|
108
|
-
Gentiq wraps custom
|
|
129
|
+
Gentiq wraps custom part components in an error boundary, so let render errors propagate; a `try`/`catch` around JSX wouldn't catch them anyway.
|
|
109
130
|
|
|
110
|
-
**Extend the user profile
|
|
131
|
+
**Extend the user profile**: `app.userMetadataFields` declares extra profile fields, each persisted on `user.metadata`. A field appears on the signup form with `showInSignup: true` and in profile settings with `showInProfile: true`. Listing it under `settings.sections.profile.fields` sets how it is displayed there (`'editable'`, `'faded'` or `'hidden'`). Labels are i18n keys, so define them in `i18n.resources`.
|
|
111
132
|
|
|
112
133
|
```tsx
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
import { GentiqChatApp } from '@gentiq/ui'
|
|
135
|
+
|
|
136
|
+
export function App() {
|
|
137
|
+
return (
|
|
138
|
+
<GentiqChatApp
|
|
139
|
+
url="/api"
|
|
140
|
+
app={{
|
|
141
|
+
userMetadataFields: [
|
|
142
|
+
{
|
|
143
|
+
key: 'job_position',
|
|
144
|
+
label: 'settings:profile.job_position',
|
|
145
|
+
type: 'select',
|
|
146
|
+
showInSignup: true,
|
|
147
|
+
showInProfile: true,
|
|
148
|
+
options: [
|
|
149
|
+
{ label: 'settings:profile.job_position_options.developer', value: 'developer' },
|
|
150
|
+
{ label: 'settings:profile.job_position_options.other', value: 'other' },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
}}
|
|
155
|
+
i18n={{
|
|
156
|
+
resources: {
|
|
157
|
+
en: {
|
|
158
|
+
settings: {
|
|
159
|
+
profile: {
|
|
160
|
+
job_position: 'Job position',
|
|
161
|
+
job_position_options: { developer: 'Developer', other: 'Other' },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
}}
|
|
167
|
+
settings={{ sections: { profile: { fields: { job_position: 'editable' } } } }}
|
|
168
|
+
/>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
127
171
|
```
|
|
128
172
|
|
|
129
|
-
A field can also
|
|
173
|
+
A field can also depend on another field's value (`condition`); the `dev` template in the Gentiq repository shows that and more of the configuration.
|
|
130
174
|
|
|
131
|
-
**Add your own API routes
|
|
175
|
+
**Add your own API routes**: `app.add_router(...)` in `main.py`. Depend on `get_current_user` to put a route behind the same JWT auth the chat endpoints use.
|
|
132
176
|
|
|
133
|
-
**Go to production
|
|
177
|
+
**Go to production**: switch `db_engine` to `"mongodb"` and `storage_engine` to `"minio"`, fill in the matching variables in `.env`, and set real secrets. Gentiq installs no CORS middleware unless you pass `cors=CORSConfig(...)`. The template passes `CORSConfig(allow_origins=["http://localhost:5173"])`; replace that with the origins your frontend is served from if it is not same-origin with the API.
|
|
134
178
|
|
|
135
179
|
---
|
|
136
180
|
|
|
137
|
-
##
|
|
181
|
+
## Learn more
|
|
138
182
|
|
|
139
|
-
- [Gentiq](https://github.com/arxyzan/gentiq)
|
|
140
|
-
- [
|
|
141
|
-
- [Vercel AI SDK](https://sdk.
|
|
183
|
+
- [Gentiq](https://github.com/arxyzan/gentiq): framework docs and the `dev` template, which uses more of the configuration
|
|
184
|
+
- [Pydantic AI](https://ai.pydantic.dev/): building agents
|
|
185
|
+
- [Vercel AI SDK](https://ai-sdk.dev/): the streaming protocol the UI speaks
|
|
142
186
|
|
|
143
187
|
---
|
|
144
188
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Backend configuration.
|
|
2
2
|
#
|
|
3
|
-
# Copy this file to `backend/.env`
|
|
4
|
-
# working directory, which is `backend/` when you run
|
|
5
|
-
#
|
|
3
|
+
# Copy this file to `backend/.env`. `main.py` loads it with python-dotenv,
|
|
4
|
+
# relative to the process's working directory, which is `backend/` when you run
|
|
5
|
+
# uvicorn as documented. GentiqApp reads these variables when it is constructed.
|
|
6
6
|
|
|
7
7
|
# --- AI providers -----------------------------------------------------------
|
|
8
8
|
# Whichever provider your agent's model string names in main.py. Required to
|
|
@@ -16,10 +16,14 @@ OPENAI_API_KEY=
|
|
|
16
16
|
# Signs user JWTs. `create-gentiq-app` writes a random value here; if you copied
|
|
17
17
|
# this file by hand, generate one with:
|
|
18
18
|
# python -c "import secrets; print(secrets.token_hex(32))"
|
|
19
|
-
|
|
19
|
+
# The app refuses to start without one, or with one shorter than 32 characters.
|
|
20
|
+
JWT_SECRET_KEY=
|
|
20
21
|
# Guards server-to-server endpoints such as POST /api/auth/user. There is no
|
|
21
22
|
# default: if this is unset those routes reject every request.
|
|
22
23
|
BACKEND_API_KEY=change_me_in_production
|
|
24
|
+
# Entered once in the admin panel to create the first admin. Without it nobody
|
|
25
|
+
# can. Generated like JWT_SECRET_KEY, and at least 16 characters.
|
|
26
|
+
ADMIN_SETUP_TOKEN=
|
|
23
27
|
|
|
24
28
|
# --- Token lifetimes (hours) ------------------------------------------------
|
|
25
29
|
JWT_EXPIRATION_HOURS=24
|
|
@@ -33,7 +37,7 @@ ADMIN_JWT_EXPIRATION_HOURS=8
|
|
|
33
37
|
# no token Logfire stays local, so leave this commented out until you have one.
|
|
34
38
|
# LOGFIRE_TOKEN=pylf_...
|
|
35
39
|
# Prometheus metrics at /metrics, for Grafana (see Gentiq's monitoring/ folder).
|
|
36
|
-
#
|
|
40
|
+
# Read by the Ops plugin; Ops(metrics=...) in main.py wins over this.
|
|
37
41
|
# GENTIQ_METRICS_ENABLED=true
|
|
38
42
|
# Bearer token a scraper must send to /metrics. Unset leaves the endpoint open.
|
|
39
43
|
# METRICS_TOKEN=
|
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
# Backend
|
|
2
2
|
|
|
3
|
-
The FastAPI + [
|
|
3
|
+
The FastAPI + [Pydantic AI](https://ai.pydantic.dev/) half of the application. `main.py` defines an agent, its tools and its context; `GentiqApp` serves them as an API with auth, chat streaming, history, attachments, plans and usage limits, and the admin endpoints.
|
|
4
4
|
|
|
5
5
|
## Run it
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
cp .env.example .env #
|
|
8
|
+
cp .env.example .env # then set OPENAI_API_KEY, JWT_SECRET_KEY and BACKEND_API_KEY
|
|
9
9
|
uv sync
|
|
10
10
|
uv run uvicorn main:app.api --reload --port 8000
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
`
|
|
13
|
+
`app.api` is the `FastAPI` instance `GentiqApp` builds, so anything you would do with a FastAPI app (middleware, extra routers, another ASGI server) works on it. Interactive docs: `http://localhost:8000/docs`.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
`main.py` loads `.env` from the working directory with `python-dotenv`, so keep it in this folder; Gentiq itself reads no `.env` file.
|
|
16
16
|
|
|
17
17
|
## `main.py` at a glance
|
|
18
18
|
|
|
19
19
|
| Step | What it does |
|
|
20
20
|
| --- | --- |
|
|
21
|
-
| `AppContext` | Your own
|
|
22
|
-
| `Agent[AgentDeps[AppContext]]` | A normal
|
|
23
|
-
| `@agent.tool` | Tools. `ctx.deps` carries the authenticated user, your context, the
|
|
24
|
-
| `@agent.instructions` | Instructions computed per run
|
|
25
|
-
| `GentiqApp(...)` |
|
|
26
|
-
| `app.add_router(...)` | Your own routes, sharing Gentiq's auth
|
|
21
|
+
| `AppContext` | Your own state, passed as `context=` to `GentiqApp` and reachable in every tool as `ctx.deps.context`. Any dataclass or Pydantic model. |
|
|
22
|
+
| `Agent[AgentDeps[AppContext]]` | A normal Pydantic AI agent, typed so Gentiq can inject its dependencies. |
|
|
23
|
+
| `@agent.tool` | Tools. `ctx.deps` carries the authenticated user, your context, the user's own threads (`ctx.deps.threads`), and `ctx.deps.stream(...)` for pushing `ProgressUpdateEvent`s to the UI while the tool runs. |
|
|
24
|
+
| `@agent.instructions` | Instructions computed per run. The template uses it to tell the model the user's name. |
|
|
25
|
+
| `GentiqApp(...)` | Sets up persistence, file storage, JWT auth, the admin API, thread titles and usage pricing. |
|
|
26
|
+
| `app.add_router(...)` | Your own routes, sharing Gentiq's auth through `Depends(get_current_user)`. |
|
|
27
27
|
|
|
28
28
|
## Configuration worth knowing
|
|
29
29
|
|
|
30
|
-
- **`
|
|
31
|
-
- **`cors
|
|
32
|
-
- **`title_agent
|
|
33
|
-
- **`login_fields`**
|
|
34
|
-
- **`
|
|
35
|
-
-
|
|
30
|
+
- **`storage=StorageConfig(db=..., files=...)`**: `"sqlite"` and `"filesystem"` by default, which need no services. Switch to `"mongodb"` and `"minio"` for production and fill in the matching `.env` variables. Both also accept a `DBEngine` / `StorageEngine` instance.
|
|
31
|
+
- **`cors`**: no CORS middleware is installed unless you pass `CORSConfig(allow_origins=[...])`. In development the Vite dev server proxies `/api`, so the browser talks to one origin; you need this only when the frontend is served from another origin. The template passes `http://localhost:5173`.
|
|
32
|
+
- **`chat=ChatConfig(title_agent=...)`**: the model that names threads. The template passes `create_title_agent(model_name="gpt-5-nano")`; leave it out and threads get no generated titles.
|
|
33
|
+
- **`auth=AuthConfig(login_fields=...)`** (not set in the template): which of `username`, `email` and `phone` can be used to sign in. Defaults to the `LOGIN_FIELDS` env var, then `["username"]`. Enabled fields are unique; the rest are ordinary profile data.
|
|
34
|
+
- **`ChatConfig(ask_user=...)`** (not set in the template): gives the agent Gentiq's `ask_user` tool, a multiple-choice question mid-conversation rendered as buttons; the turn carries on with the answer. Off by default. `True` uses Gentiq's default policy on when to ask; a string is your own policy instead.
|
|
35
|
+
- **`plugins`**: optional subsystems, none by default. `Voice(transcriber)` from `gentiq.voice` adds voice input to the composer; `Jobs()` from `gentiq.jobs` adds maintenance jobs that admins run from the admin panel's Operations tab, registered with `@jobs.job(...)`.
|
|
36
36
|
|
|
37
|
-
Usage is priced
|
|
37
|
+
Usage is priced per model from the `genai-prices` rate data bundled with Pydantic AI. Pass `ChatConfig(usage_pricing=UsagePricing(...))` only to override specific rates.
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
from typing import Annotated
|
|
3
3
|
|
|
4
|
+
from dotenv import load_dotenv
|
|
4
5
|
from fastapi import APIRouter, Depends
|
|
5
6
|
from pydantic_ai import Agent, RunContext
|
|
6
7
|
|
|
7
8
|
from gentiq import (
|
|
8
9
|
AgentDeps,
|
|
10
|
+
ChatConfig,
|
|
9
11
|
CORSConfig,
|
|
10
12
|
GentiqApp,
|
|
11
13
|
ProgressUpdateEvent,
|
|
14
|
+
StorageConfig,
|
|
12
15
|
User,
|
|
13
16
|
create_title_agent,
|
|
14
17
|
get_current_user,
|
|
15
18
|
)
|
|
19
|
+
from gentiq.admin import AdminAPI
|
|
20
|
+
from gentiq.ops import Ops
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Gentiq reads no .env file of its own. Load backend/.env before the agent is defined:
|
|
24
|
+
# pydantic-ai reads the provider key (OPENAI_API_KEY) then, and GentiqApp reads its
|
|
25
|
+
# settings (JWT_SECRET_KEY, ...) from the environment when it is constructed.
|
|
26
|
+
load_dotenv()
|
|
16
27
|
|
|
17
28
|
|
|
18
29
|
# 1. Define custom context (any Pydantic model or dataclass)
|
|
@@ -33,7 +44,7 @@ agent = Agent[AgentDeps[AppContext]](name="agent", instructions=instructions, mo
|
|
|
33
44
|
|
|
34
45
|
|
|
35
46
|
# 3. Add custom tools. `ctx.deps` carries your context, the authenticated user
|
|
36
|
-
# and
|
|
47
|
+
# and their own threads; `ctx.deps.stream` pushes progress to the UI mid-call.
|
|
37
48
|
@agent.tool
|
|
38
49
|
async def list_my_chats(ctx: RunContext[AgentDeps[AppContext]]) -> dict:
|
|
39
50
|
"""List the titles of the user's recent chat threads."""
|
|
@@ -49,7 +60,7 @@ async def list_my_chats(ctx: RunContext[AgentDeps[AppContext]]) -> dict:
|
|
|
49
60
|
)
|
|
50
61
|
)
|
|
51
62
|
|
|
52
|
-
threads = ctx.deps.
|
|
63
|
+
threads = await ctx.deps.threads.list(limit=ctx.deps.context.history_limit)
|
|
53
64
|
|
|
54
65
|
await ctx.deps.stream(
|
|
55
66
|
ProgressUpdateEvent(
|
|
@@ -59,11 +70,9 @@ async def list_my_chats(ctx: RunContext[AgentDeps[AppContext]]) -> dict:
|
|
|
59
70
|
)
|
|
60
71
|
)
|
|
61
72
|
|
|
62
|
-
# Return only what the model needs. `list_user_threads` also embeds the
|
|
63
|
-
# owner's contact details and metadata, which have no business in a prompt.
|
|
64
73
|
return {
|
|
65
74
|
"count": len(threads),
|
|
66
|
-
"chats": [{"title": thread.
|
|
75
|
+
"chats": [{"title": thread.title, "created_at": thread.created_at} for thread in threads],
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
|
|
@@ -81,14 +90,16 @@ app = GentiqApp[AppContext](
|
|
|
81
90
|
agent,
|
|
82
91
|
app_name="My Gentiq App",
|
|
83
92
|
context=AppContext(),
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
send_to_logfire=True,
|
|
93
|
+
storage=StorageConfig(db="sqlite", files="filesystem"), # or mongodb and minio for production
|
|
94
|
+
chat=ChatConfig(title_agent=create_title_agent(model_name="gpt-5-nano")),
|
|
95
|
+
logfire=True,
|
|
88
96
|
# Cross-origin policy. Omit (or pass cors=False) and no CORS middleware is
|
|
89
97
|
# installed at all — correct when the frontend is same-origin or proxied, as
|
|
90
98
|
# the Vite dev server does. List every browser origin that calls this API directly.
|
|
91
99
|
cors=CORSConfig(allow_origins=["http://localhost:5173"]),
|
|
100
|
+
# The admin panel's API under /api/admin, and maintenance mode with the watchdog that can
|
|
101
|
+
# switch it on (Admin > Operations > Status). Leave them out and the app serves chat only.
|
|
102
|
+
plugins=[AdminAPI(), Ops()],
|
|
92
103
|
)
|
|
93
104
|
|
|
94
105
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Frontend
|
|
2
2
|
|
|
3
|
-
A [Vite](https://vite.dev/) + React app built on the `gentiq` React SDK. `src/App.tsx`
|
|
3
|
+
A [Vite](https://vite.dev/) + React app built on the `gentiq` React SDK. `src/App.tsx` holds the whole configuration: `GentiqProvider` with app, theme and i18n config, and `GentiqUI` inside it with history, welcome, composer, disclaimer and settings config, wrapping the routes that render `ChatLayout` (behind `RequireAuth`, with its thread in the URL through `useRouterThread` from `gentiq/react-router`), `AdminPanel` (from `gentiq/admin`), `UserLoginPage` and `SharedChatView`.
|
|
4
4
|
|
|
5
5
|
## Run it
|
|
6
6
|
|
|
@@ -9,20 +9,22 @@ npm install
|
|
|
9
9
|
npm run dev
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
The dev server proxies `/api` to `http://localhost:8000` (see `vite.config.ts`), so start the backend
|
|
12
|
+
The dev server proxies `/api` to `http://localhost:8000` (see `vite.config.ts`), so start the backend too. Build with `npm run build`, which runs `tsc -b` before Vite, so type errors fail the build.
|
|
13
13
|
|
|
14
14
|
## Customising
|
|
15
15
|
|
|
16
|
-
**Theme
|
|
16
|
+
**Theme**: `theme={{ accent, radius, typography }}` on the provider. Gentiq sets these as `--gentiq-primary`, `--gentiq-radius` and `--gentiq-font-family` on its root element, the `.gentiq` element its screens render in; to restyle further, set `--gentiq-*` tokens on `.gentiq` in `src/index.css`. Gentiq styles nothing outside its roots, so the page itself (the body's margin, a full-height `#root`) is styled in `src/index.css`, and `<DocumentSync />` in `src/App.tsx` keeps the page's language, direction, theme class, title and favicon in step with the chat.
|
|
17
17
|
|
|
18
|
-
**Tool rendering
|
|
18
|
+
**Tool rendering**: `components.parts` maps `tool-<name>` to a React component (typed with `defineToolUI`), so a tool can render as whatever you like instead of Gentiq's generic card. Tools without an entry keep the generic card. The same map takes `text`, `file`, `data-<name>` for custom parts and other part types, and `components` also replaces the welcome screen, the composer and other pieces; the project README has a worked example.
|
|
19
19
|
|
|
20
|
-
**Copy and languages
|
|
20
|
+
**Copy and languages**: every user-visible string is an i18n key. `i18n.resources` in `App.tsx` adds to and overrides Gentiq's own translations, which cover 13 languages. Right-to-left languages such as Persian and Arabic switch the layout direction automatically.
|
|
21
21
|
|
|
22
|
-
**Auth
|
|
22
|
+
**Auth**: with no `api.authAdapter`, Gentiq keeps the JWT in `localStorage`, under a key namespaced by `app.cacheNamespace`. Pass `api={{ authAdapter }}` to authenticate another way: a token from your identity provider (`getToken` may return a promise), or a session cookie your backend reads (an adapter with no token, and `credentials: 'include'` for another origin). See the `gentiq` README's Authentication section.
|
|
23
23
|
|
|
24
|
-
**Math
|
|
24
|
+
**Math**: the SDK loads KaTeX and its stylesheet the first time a message contains `$$` math, so nothing needs importing. `katex` is in `package.json` for that; remove it if your agent never writes math.
|
|
25
|
+
|
|
26
|
+
**Admin panel**: `App.tsx` loads `AdminPanel` with `lazy()`, so its code is a separate file that only `/admin` downloads.
|
|
25
27
|
|
|
26
28
|
## Package manager notes
|
|
27
29
|
|
|
28
|
-
`gentiq` declares React, `ai`, `@ai-sdk/react`, `@tanstack/react-query`, `i18next`, `react-i18next`, `
|
|
30
|
+
`gentiq` declares React, React DOM, React Router, `ai`, `@ai-sdk/react`, `@tanstack/react-query`, `i18next`, `react-i18next`, `sonner` and (optionally) `katex` as peer dependencies, because a second copy of any of them would break a shared React context or module singleton. They are all in `package.json`, so npm, pnpm, yarn and bun all install correctly.
|
|
@@ -10,17 +10,15 @@
|
|
|
10
10
|
"preview": "vite preview"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@
|
|
13
|
+
"@gentiq/admin": "workspace:*",
|
|
14
|
+
"@gentiq/react": "workspace:*",
|
|
15
|
+
"@gentiq/ui": "workspace:*",
|
|
14
16
|
"@tanstack/react-query": "^5.90.19",
|
|
15
|
-
"ai": "^7.0.85",
|
|
16
|
-
"gentiq": "workspace:*",
|
|
17
17
|
"i18next": "^25.8.10",
|
|
18
18
|
"katex": "^0.16.28",
|
|
19
19
|
"lucide-react": "^0.542.0",
|
|
20
|
-
"next-themes": "^0.4.6",
|
|
21
20
|
"react": "^19.2.0",
|
|
22
21
|
"react-dom": "^19.2.0",
|
|
23
|
-
"react-i18next": "^16.5.4",
|
|
24
22
|
"react-router-dom": "^7.13.1",
|
|
25
23
|
"sonner": "^2.0.7"
|
|
26
24
|
},
|
|
@@ -1,153 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import 'katex/dist/katex.min.css'
|
|
1
|
+
import { lazy } from 'react'
|
|
2
|
+
import { GentiqChatApp } from '@gentiq/ui'
|
|
3
|
+
import { options } from './options'
|
|
4
|
+
// The admin panel's stylesheet is the chat's plus the panel's own classes.
|
|
5
|
+
import '@gentiq/admin/style.css'
|
|
6
|
+
import './theme.css'
|
|
8
7
|
import './index.css'
|
|
9
8
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// The admin panel is a separate download, fetched only when someone opens /admin.
|
|
10
|
+
const AdminPanel = lazy(() => import('@gentiq/admin').then((m) => ({ default: m.AdminPanel })))
|
|
11
|
+
|
|
13
12
|
export default function App() {
|
|
14
|
-
return
|
|
15
|
-
<GentiqProvider
|
|
16
|
-
app={{
|
|
17
|
-
name: "My Gentiq App",
|
|
18
|
-
logo: "/favicon.svg",
|
|
19
|
-
favicon: "/favicon.svg",
|
|
20
|
-
basePath: "/chat",
|
|
21
|
-
cacheNamespace: "my_app_cache",
|
|
22
|
-
showToolDetails: false,
|
|
23
|
-
showSettings: true,
|
|
24
|
-
}}
|
|
25
|
-
// No `api.authAdapter` is passed, so Gentiq uses its built-in localStorage
|
|
26
|
-
// adapter, which namespaces the token under `app.cacheNamespace` above.
|
|
27
|
-
// Supply your own (`getToken`/`setToken`/`clearToken`, optional
|
|
28
|
-
// `getHeaders`) only if the token lives somewhere else — a cookie, an
|
|
29
|
-
// external identity provider, your host app's session store.
|
|
30
|
-
history={{
|
|
31
|
-
enabled: true,
|
|
32
|
-
showDelete: true,
|
|
33
|
-
showRename: true,
|
|
34
|
-
showPin: true,
|
|
35
|
-
showShare: true
|
|
36
|
-
}}
|
|
37
|
-
welcome={{
|
|
38
|
-
// A function receives the authenticated user, so the greeting can be
|
|
39
|
-
// personalised; a plain i18n key works too.
|
|
40
|
-
greeting: ({ firstName, t }) => t(
|
|
41
|
-
firstName ? 'chat:welcome.greeting_named' : 'chat:welcome.greeting',
|
|
42
|
-
{ firstName }
|
|
43
|
-
),
|
|
44
|
-
// Rotating subtitle typed out on the empty state. Replaces `subtitle`.
|
|
45
|
-
typingPrompts: ({ t }) => [
|
|
46
|
-
t('chat:welcome.prompts.features'),
|
|
47
|
-
t('chat:welcome.prompts.tools'),
|
|
48
|
-
t('chat:welcome.prompts.history'),
|
|
49
|
-
],
|
|
50
|
-
}}
|
|
51
|
-
threadActions={{
|
|
52
|
-
feedback: true,
|
|
53
|
-
retry: true,
|
|
54
|
-
}}
|
|
55
|
-
composer={{
|
|
56
|
-
placeholder: "chat:input_placeholder",
|
|
57
|
-
attachments: {
|
|
58
|
-
enabled: true,
|
|
59
|
-
maxSize: 10 * 1024 * 1024, // 10MB
|
|
60
|
-
types: ["image/*", ".pdf", ".txt", ".md", ".docx"]
|
|
61
|
-
}
|
|
62
|
-
}}
|
|
63
|
-
theme={{
|
|
64
|
-
typography: {
|
|
65
|
-
fontFamily: {
|
|
66
|
-
en: 'Lato',
|
|
67
|
-
fa: 'Vazirmatn'
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
accent: '#3c85f1',
|
|
71
|
-
}}
|
|
72
|
-
disclaimer="chat:disclaimer"
|
|
73
|
-
i18n={{
|
|
74
|
-
resources: {
|
|
75
|
-
en: {
|
|
76
|
-
chat: {
|
|
77
|
-
input_placeholder: 'Type your query to the AI...',
|
|
78
|
-
disclaimer: "AI can make mistakes! Check important information.",
|
|
79
|
-
welcome: {
|
|
80
|
-
greeting: "How can I help you today?",
|
|
81
|
-
greeting_named: "Hi {{firstName}}, how can I help you today?",
|
|
82
|
-
prompts: {
|
|
83
|
-
features: "What things can you do?",
|
|
84
|
-
tools: "Tell me an unbelievable fact!",
|
|
85
|
-
history: "What have we talked about recently?"
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
fa: {
|
|
91
|
-
chat: {
|
|
92
|
-
input_placeholder: 'هر چی میخوای ازم بپرس',
|
|
93
|
-
disclaimer: "هوش مصنوعی میتواند اشتباه کند! اطلاعات مهم را بررسی کنید.",
|
|
94
|
-
welcome: {
|
|
95
|
-
greeting: "چطور میتونم امروز بهت کمک کنم؟",
|
|
96
|
-
greeting_named: "سلام {{firstName}}، چه کمکی ازم بر میاد؟",
|
|
97
|
-
prompts: {
|
|
98
|
-
features: "چه کارهایی میتونی انجام بدی؟",
|
|
99
|
-
tools: "یه حقیقت باورنکردنی بگو!",
|
|
100
|
-
history: "اخیراً درباره چی صحبت کردیم؟"
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
}
|
|
106
|
-
}}
|
|
107
|
-
settings={{
|
|
108
|
-
sections: {
|
|
109
|
-
profile: {
|
|
110
|
-
enabled: true,
|
|
111
|
-
fields: {
|
|
112
|
-
name: 'editable',
|
|
113
|
-
surname: 'editable',
|
|
114
|
-
phone: 'editable',
|
|
115
|
-
password: 'editable',
|
|
116
|
-
}
|
|
117
|
-
},
|
|
118
|
-
account: {
|
|
119
|
-
enabled: true,
|
|
120
|
-
fields: {
|
|
121
|
-
usage: 'editable',
|
|
122
|
-
logout: 'editable',
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}}
|
|
127
|
-
>
|
|
128
|
-
<BrowserRouter>
|
|
129
|
-
<Routes>
|
|
130
|
-
<Route path="/login" element={<UserLoginPage />} />
|
|
131
|
-
<Route path="/admin/*" element={<AdminPanel />} />
|
|
132
|
-
<Route path="/shared/:shareId" element={<SharedChatView />} />
|
|
133
|
-
<Route
|
|
134
|
-
path="/chat/*"
|
|
135
|
-
element={
|
|
136
|
-
<RequireAuth>
|
|
137
|
-
<ChatUI />
|
|
138
|
-
</RequireAuth>
|
|
139
|
-
}
|
|
140
|
-
/>
|
|
141
|
-
<Route
|
|
142
|
-
path="/"
|
|
143
|
-
element={
|
|
144
|
-
<RequireAuth>
|
|
145
|
-
<ChatUI />
|
|
146
|
-
</RequireAuth>
|
|
147
|
-
}
|
|
148
|
-
/>
|
|
149
|
-
</Routes>
|
|
150
|
-
</BrowserRouter>
|
|
151
|
-
</GentiqProvider>
|
|
152
|
-
)
|
|
13
|
+
return <GentiqChatApp url="/api" admin={<AdminPanel />} {...options} />
|
|
153
14
|
}
|
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
/*
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/* The page's own styles. Gentiq styles only its own screens (everything inside a `.gentiq`
|
|
2
|
+
root) and leaves the document to the app, so the page-level rules live here. Gentiq's look is
|
|
3
|
+
set in theme.css. */
|
|
4
|
+
|
|
5
|
+
/* The chat fills the window, edge to edge, without the page bouncing past it. */
|
|
6
|
+
html,
|
|
7
|
+
body,
|
|
8
|
+
#root {
|
|
9
|
+
height: 100%;
|
|
10
|
+
height: 100dvh;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
html {
|
|
14
|
+
overscroll-behavior: none;
|
|
15
|
+
}
|
|
7
16
|
|
|
8
17
|
body {
|
|
18
|
+
margin: 0;
|
|
19
|
+
padding-left: env(safe-area-inset-left);
|
|
20
|
+
padding-right: env(safe-area-inset-right);
|
|
9
21
|
-webkit-font-smoothing: antialiased;
|
|
10
22
|
-moz-osx-font-smoothing: grayscale;
|
|
11
23
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { GentiqChatAppProps } from '@gentiq/ui'
|
|
2
|
+
|
|
3
|
+
// How the app looks and behaves. Every field is optional: `<GentiqChatApp url="/api" />` alone is
|
|
4
|
+
// a working chat. The admin panel's settings, saved on the backend, override what is set here.
|
|
5
|
+
export const options = {
|
|
6
|
+
app: {
|
|
7
|
+
name: 'My Gentiq App',
|
|
8
|
+
logo: '/favicon.svg',
|
|
9
|
+
favicon: '/favicon.svg',
|
|
10
|
+
cacheNamespace: 'my_app_cache',
|
|
11
|
+
showToolDetails: false,
|
|
12
|
+
showSettings: true,
|
|
13
|
+
},
|
|
14
|
+
// No `api.authAdapter` is passed, so Gentiq uses its built-in localStorage adapter, which
|
|
15
|
+
// namespaces the token under `app.cacheNamespace` above. Supply your own only if the session
|
|
16
|
+
// lives somewhere else: a token from your identity provider (`getToken` may return a
|
|
17
|
+
// promise), or a session cookie your backend reads (no token; `credentials: 'include'` for an
|
|
18
|
+
// API on another origin).
|
|
19
|
+
theme: {
|
|
20
|
+
typography: { fontFamily: { en: 'Lato', fa: 'Vazirmatn' } },
|
|
21
|
+
},
|
|
22
|
+
i18n: {
|
|
23
|
+
resources: {
|
|
24
|
+
en: {
|
|
25
|
+
chat: {
|
|
26
|
+
input_placeholder: 'Type your query to the AI...',
|
|
27
|
+
disclaimer: 'AI can make mistakes! Check important information.',
|
|
28
|
+
welcome: {
|
|
29
|
+
greeting: 'How can I help you today?',
|
|
30
|
+
greeting_named: 'Hi {{firstName}}, how can I help you today?',
|
|
31
|
+
prompts: {
|
|
32
|
+
features: 'What things can you do?',
|
|
33
|
+
tools: 'Tell me an unbelievable fact!',
|
|
34
|
+
history: 'What have we talked about recently?',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
fa: {
|
|
40
|
+
chat: {
|
|
41
|
+
input_placeholder: 'هر چی میخوای ازم بپرس',
|
|
42
|
+
disclaimer: 'هوش مصنوعی میتواند اشتباه کند! اطلاعات مهم را بررسی کنید.',
|
|
43
|
+
welcome: {
|
|
44
|
+
greeting: 'چطور میتونم امروز بهت کمک کنم؟',
|
|
45
|
+
greeting_named: 'سلام {{firstName}}، چه کمکی ازم بر میاد؟',
|
|
46
|
+
prompts: {
|
|
47
|
+
features: 'چه کارهایی میتونی انجام بدی؟',
|
|
48
|
+
tools: 'یه حقیقت باورنکردنی بگو!',
|
|
49
|
+
history: 'اخیراً درباره چی صحبت کردیم؟',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
history: { enabled: true, showDelete: true, showRename: true, showPin: true, showShare: true },
|
|
57
|
+
welcome: {
|
|
58
|
+
// A function receives the authenticated user, so the greeting can be personalised; a plain
|
|
59
|
+
// i18n key works too.
|
|
60
|
+
greeting: ({ firstName, t }) =>
|
|
61
|
+
t(firstName ? 'chat:welcome.greeting_named' : 'chat:welcome.greeting', { firstName }),
|
|
62
|
+
// Prompts typed out in turn as the new-chat composer's placeholder.
|
|
63
|
+
typingPrompts: ({ t }) => [
|
|
64
|
+
t('chat:welcome.prompts.features'),
|
|
65
|
+
t('chat:welcome.prompts.tools'),
|
|
66
|
+
t('chat:welcome.prompts.history'),
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
threadActions: { feedback: true, retry: true },
|
|
70
|
+
composer: {
|
|
71
|
+
placeholder: 'chat:input_placeholder',
|
|
72
|
+
// The types and the size limit are the backend's: GentiqApp(attachment_types=...) and
|
|
73
|
+
// MAX_ATTACHMENT_SIZE.
|
|
74
|
+
attachments: { enabled: true },
|
|
75
|
+
},
|
|
76
|
+
disclaimer: 'chat:disclaimer',
|
|
77
|
+
settings: {
|
|
78
|
+
sections: {
|
|
79
|
+
profile: {
|
|
80
|
+
enabled: true,
|
|
81
|
+
fields: { name: 'editable', surname: 'editable', phone: 'editable', password: 'editable' },
|
|
82
|
+
},
|
|
83
|
+
account: { enabled: true, fields: { usage: 'editable', logout: 'editable' } },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
} satisfies Omit<GentiqChatAppProps, 'url'>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/* Your theme. Every color, the radius and the type of Gentiq's screens, the admin panel's
|
|
2
|
+
included, come from the `--gentiq-*` tokens set on the Gentiq root. A rule here, loaded after
|
|
3
|
+
`gentiq/style.css`, wins over Gentiq's defaults. The full list is in Gentiq's README under
|
|
4
|
+
"Theming tokens". Uncomment and edit what you want to change; an empty file keeps the default
|
|
5
|
+
look. The `theme` option in options.ts sets the accent, radius and font, and the admin panel
|
|
6
|
+
can change those three too. */
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
.gentiq {
|
|
10
|
+
--gentiq-primary: oklch(0.55 0.15 160);
|
|
11
|
+
--gentiq-primary-foreground: oklch(0.985 0 0);
|
|
12
|
+
--gentiq-background: oklch(0.99 0.005 90);
|
|
13
|
+
--gentiq-foreground: oklch(0.2 0.01 90);
|
|
14
|
+
--gentiq-bubble: oklch(0.94 0.03 160);
|
|
15
|
+
--gentiq-bubble-foreground: oklch(0.25 0.04 160);
|
|
16
|
+
--gentiq-radius: 0.75rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.gentiq.dark {
|
|
20
|
+
--gentiq-background: oklch(0.18 0.01 160);
|
|
21
|
+
--gentiq-foreground: oklch(0.95 0 0);
|
|
22
|
+
--gentiq-bubble: oklch(0.3 0.04 160);
|
|
23
|
+
--gentiq-bubble-foreground: oklch(0.95 0 0);
|
|
24
|
+
}
|
|
25
|
+
*/
|