Skip to content

Commit ef8a25b

Browse files
committed
feat: support user-managed model configuration
1 parent b04e272 commit ef8a25b

73 files changed

Lines changed: 3047 additions & 888 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

App.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import ChatInput from './components/ChatInput';
99
import Sidebar from './components/Sidebar';
1010
import ChatArea from './components/ChatArea';
1111

12+
const CHAT_INPUT_MAX_WIDTH_CLASS = 'max-w-[40.32rem]';
13+
1214
const App = () => {
1315
const {
1416
sessions,
@@ -52,7 +54,7 @@ const App = () => {
5254

5355
return (
5456
<ErrorBoundary>
55-
<div className="relative flex h-screen overflow-hidden bg-[var(--theme-bg-secondary)] font-sans text-[var(--theme-text-primary)]">
57+
<div className="theme-transition-colors relative flex h-full overflow-hidden bg-[var(--theme-bg-secondary)] font-sans text-[var(--theme-text-primary)]">
5658
<SettingsModal
5759
isOpen={isSettingsOpen}
5860
onClose={() => setIsSettingsOpen(false)}
@@ -76,7 +78,7 @@ const App = () => {
7678
onDeleteSession={handleDeleteSession}
7779
/>
7880

79-
<main className="relative flex min-w-0 flex-1 flex-col bg-[var(--theme-bg-primary)]">
81+
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-[var(--theme-bg-primary)]">
8082
<Header
8183
selectedModel={selectedModel}
8284
setSelectedModel={setSelectedModel}
@@ -106,8 +108,10 @@ const App = () => {
106108
onForkMessage={handleForkMessage}
107109
/>
108110

109-
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-20 flex justify-center bg-[linear-gradient(to_top,var(--theme-bg-primary)_0%,color-mix(in_srgb,var(--theme-bg-primary)_82%,transparent)_68%,transparent_100%)] p-4 pb-6">
110-
<div className="pointer-events-auto w-full max-w-[40.32rem]">
111+
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-30">
112+
<div
113+
className={`pointer-events-auto mx-auto w-full ${CHAT_INPUT_MAX_WIDTH_CLASS} px-2 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)] sm:px-3`}
114+
>
111115
<ChatInput
112116
query={query}
113117
setQuery={setQuery}

Dockerfile

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,20 @@ COPY package.json package-lock.json ./
66
RUN npm ci
77

88
COPY . .
9+
ENV VITE_API_PROXY_MODE=local
910
RUN npm run build
1011

11-
FROM nginx:1.27-alpine AS runtime
12+
FROM node:22-alpine AS runtime
1213

13-
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
14-
COPY --from=build /app/dist /usr/share/nginx/html
14+
WORKDIR /app
15+
16+
ENV NODE_ENV=production
17+
ENV PORT=80
18+
ENV PRISMA_STATIC_DIR=/app/dist
19+
20+
COPY --from=build /app/dist /app/dist
21+
COPY docker/server.mjs /app/server.mjs
1522

1623
EXPOSE 80
1724

18-
CMD ["nginx", "-g", "daemon off;"]
25+
CMD ["node", "server.mjs"]

README.en.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,24 @@
44
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a>
55
</p>
66

7-
A Gemini-powered visual multi-agent deep reasoning engine with dynamic planning, reasoning visualization, and multi-session management.
7+
A visual multi-agent deep reasoning engine for user-configured Gemini API and OpenAI-compatible API models.
88

99
## Overview
1010

11-
A Gemini-powered visual multi-agent deep reasoning engine with dynamic planning, reasoning visualization, and multi-session management.
11+
A visual multi-agent deep reasoning engine with dynamic planning, reasoning visualization, and multi-session management. Prisma does not ship model presets; create the Gemini or OpenAI-compatible models you want to use in settings.
1212

1313
## Features
1414

1515
- Multi-agent collaborative reasoning.
1616
- Visual task planning and reasoning traces.
17-
- Supports Gemini API and OpenAI-compatible endpoints.
17+
- Supports user-created Gemini API and OpenAI-compatible model configurations.
1818
- Modern React 19 + TypeScript + Vite project.
1919

2020
## Quick Start
2121

2222
- Run `npm install`.
23-
- Copy and configure environment variables.
2423
- Run `npm run dev`.
24+
- Open Settings -> Model Management and add at least one Gemini API or OpenAI-compatible API model.
2525

2626
## Docker Deployment
2727

@@ -38,13 +38,21 @@ docker build -t prisma .
3838
docker run --rm -p 8081:80 prisma
3939
```
4040

41-
The container builds the static `dist/` bundle and serves it with Nginx, while Cloudflare Pages can keep using the existing `npm run build` flow.
41+
The Docker image builds the static `dist/` bundle and serves it with a small Node runtime. Docker builds enable the local API proxy: the browser calls same-origin `/custom-api`, and the container makes the actual Gemini/OpenAI-compatible API request to avoid browser-side CORS limits.
42+
43+
Common model API hosts are allowed by default. To use a custom gateway or local model service, add allowed hosts:
44+
45+
```bash
46+
PRISMA_PROXY_ALLOWED_HOSTS=api.example.com,host.docker.internal docker compose up --build
47+
```
48+
49+
The proxy switch is injected only by the Dockerfile. Cloudflare Pages can keep using the existing pure-static `npm run build` flow, with direct browser API requests and no `/custom-api` runtime service.
4250

4351
Cloudflare Pages reads the root `.node-version`; this repository pins Node.js 22 so Pages, GitHub Actions, and Docker builds use the same major runtime.
4452

4553
## Configuration
4654

47-
- Configure API keys and model settings for Gemini or OpenAI-compatible services.
55+
- Create and manage model-specific API keys, base URLs, and providers for Gemini or OpenAI-compatible services.
4856

4957
## Tech Stack
5058

README.md

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
- **思考预算 (Thinking Budget)**
7272
- 支持为 **规划****执行****综合** 三个阶段分别设置思考深度(Minimal, Low, Medium, High)。
7373
- 这决定了模型在生成 Token 时分配给 "Thinking" 字段的配额。
74-
- **模型切换**内置 `Gemini 3 Flash``Gemini 3.1 Pro`,也支持接入 OpenAI 兼容自定义模型
74+
- **模型管理**不预置任何模型配置,启动后由用户自行添加 Gemini API 或 OpenAI 兼容 API 模型
7575

7676
### 🛠️ 现代化工程体验
7777

@@ -101,25 +101,15 @@ cd Prisma
101101
npm install
102102
```
103103

104-
### 3. 配置环境
105-
106-
在项目根目录创建 `.env.local` 文件并填入你的 API Key:
107-
108-
```env
109-
VITE_API_KEY=your_api_key_here
110-
```
111-
112-
兼容旧配置名:`GEMINI_API_KEY` 仍可继续使用。
113-
114-
### 4. 启动开发服务器
104+
### 3. 启动开发服务器
115105

116106
```bash
117107
npm run dev
118108
```
119109

120-
访问 `http://localhost:3000` 即可开始推理
110+
访问 `http://localhost:3000` 后,先在「设置 -> 模型管理」中添加至少一个 Gemini API 或 OpenAI 兼容 API 模型
121111

122-
### 5. 运行校验
112+
### 4. 运行校验
123113

124114
```bash
125115
npm test
@@ -128,7 +118,7 @@ npm run lint
128118
npm run build
129119
```
130120

131-
### 6. 使用 Docker 部署
121+
### 5. 使用 Docker 部署
132122

133123
```bash
134124
docker compose up --build
@@ -143,7 +133,13 @@ docker build -t prisma .
143133
docker run --rm -p 8081:80 prisma
144134
```
145135

146-
Docker 镜像会在构建阶段生成静态 `dist/` 并用 Nginx 提供服务,Cloudflare Pages 仍然可以继续沿用现有的 `npm run build` 流程。
136+
Docker 镜像会在构建阶段生成静态 `dist/`,并由一个轻量 Node 运行时提供页面和本地 API 代理:浏览器只请求同源的 `/custom-api`,真正的 Gemini/OpenAI 兼容 API 请求由容器内的 Node 服务发出,用来避开浏览器侧 CORS 限制。默认允许常见模型 API 域名;如果要接入自定义网关或本地模型服务,可以追加允许域名:
137+
138+
```bash
139+
PRISMA_PROXY_ALLOWED_HOSTS=api.example.com,host.docker.internal docker compose up --build
140+
```
141+
142+
这个代理开关只在 Dockerfile 中注入。Cloudflare Pages 仍然走普通 `npm run build` 的纯前端直连模式,不需要 `/custom-api` 服务。
147143

148144
Cloudflare Pages 会读取仓库根目录的 `.node-version`,当前固定为 Node.js 22,以便和 GitHub Actions、Docker 构建环境保持一致。
149145

@@ -243,6 +239,7 @@ Prisma/
243239
## 📄 许可证
244240

245241
MIT License
242+
246243
---
247244

248245
## 友链

api.ts

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
import { GoogleGenAI } from '@google/genai';
22
import OpenAI from 'openai';
3-
import { ApiProvider, CustomModel, AIClient, GoogleGenAIClient, OpenAIClient } from './types';
3+
import type {
4+
ApiProvider,
5+
AppConfig,
6+
CustomModel,
7+
AIClient,
8+
GoogleGenAIClient,
9+
ModelOption,
10+
OpenAIClient,
11+
} from './types';
412

513
// --- Configuration & Types ---
614

7-
type AIProviderConfig = {
15+
export type AIProviderConfig = {
816
provider?: ApiProvider;
917
apiKey?: string;
1018
baseUrl?: string;
19+
proxyMode?: ApiProxyMode;
1120
};
1221

1322
type ApiEnv = {
1423
VITE_API_KEY?: string;
1524
GEMINI_API_KEY?: string;
25+
VITE_API_PROXY_MODE?: string;
1626
};
1727

28+
type ApiProxyMode = 'direct' | 'local';
29+
1830
// --- Provider Detection ---
1931

2032
export const isGoogleProvider = (ai: AIClient | unknown): ai is GoogleGenAIClient => {
@@ -33,14 +45,48 @@ export const isGoogleProvider = (ai: AIClient | unknown): ai is GoogleGenAIClien
3345
* Only handles API version prefix deduplication as a safety net.
3446
* URL routing is handled by SDK-level options (httpOptions.baseUrl / baseURL).
3547
*/
36-
const createCustomFetch = (baseUrl: string | null): typeof globalThis.fetch => {
37-
if (!baseUrl) return window.fetch.bind(window);
38-
48+
const createCustomFetch = (
49+
baseUrl: string | null,
50+
proxyMode: ApiProxyMode,
51+
): typeof globalThis.fetch => {
3952
const nativeFetch = window.fetch.bind(window);
40-
const cleanBaseUrl = baseUrl.replace(/\/+$/, '');
53+
const cleanBaseUrl = baseUrl?.replace(/\/+$/, '') ?? null;
54+
55+
const createProxyInit = (
56+
input: RequestInfo | URL,
57+
init: RequestInit | undefined,
58+
targetOrigin: string,
59+
): RequestInit => {
60+
const request = input instanceof Request ? input : null;
61+
const method = init?.method ?? request?.method;
62+
const headers = new Headers(request?.headers);
63+
64+
new Headers(init?.headers).forEach((value, key) => {
65+
headers.set(key, value);
66+
});
67+
headers.set('X-Target-URL', targetOrigin);
68+
69+
return {
70+
...init,
71+
method,
72+
headers,
73+
body:
74+
init?.body ??
75+
(request && !['GET', 'HEAD'].includes(method ?? request.method) ? request.body : undefined),
76+
cache: init?.cache ?? request?.cache,
77+
credentials: init?.credentials ?? request?.credentials,
78+
integrity: init?.integrity ?? request?.integrity,
79+
keepalive: init?.keepalive ?? request?.keepalive,
80+
mode: init?.mode ?? request?.mode,
81+
redirect: init?.redirect ?? request?.redirect,
82+
referrer: init?.referrer ?? request?.referrer,
83+
signal: init?.signal ?? request?.signal,
84+
};
85+
};
4186

4287
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
4388
let urlString: string;
89+
let requestInput: RequestInfo | URL = input;
4490
if (typeof input === 'string') {
4591
urlString = input;
4692
} else if (input instanceof URL) {
@@ -49,8 +95,8 @@ const createCustomFetch = (baseUrl: string | null): typeof globalThis.fetch => {
4995
urlString = input.url;
5096
}
5197

52-
// Safety: deduplicate API version prefix (e.g., /v1beta/v1beta /v1beta)
53-
// Some Google SDK versions may double-append version prefixes when httpOptions.baseUrl includes one
98+
// Safety: deduplicate API version prefix (e.g., /v1beta/v1beta -> /v1beta).
99+
// Some Google SDK versions may double-append version prefixes when httpOptions.baseUrl includes one.
54100
if (cleanBaseUrl) {
55101
try {
56102
const baseHost = new URL(cleanBaseUrl).host;
@@ -60,15 +106,31 @@ const createCustomFetch = (baseUrl: string | null): typeof globalThis.fetch => {
60106
const versionPrefix = basePath.match(/\/v\d+(beta|alpha)?$/)?.[0];
61107
if (versionPrefix && url.pathname.includes(versionPrefix + versionPrefix)) {
62108
url.pathname = url.pathname.replace(versionPrefix + versionPrefix, versionPrefix);
63-
return nativeFetch(url.toString(), init);
109+
urlString = url.toString();
110+
requestInput = urlString;
64111
}
65112
}
66113
} catch {
67114
/* ignore URL parse errors */
68115
}
69116
}
70117

71-
return nativeFetch(input, init);
118+
if (proxyMode === 'local') {
119+
try {
120+
const url = new URL(urlString, window.location.href);
121+
const isHttpApiRequest = url.protocol === 'https:' || url.protocol === 'http:';
122+
const isExternalRequest = url.origin !== window.location.origin;
123+
124+
if (isHttpApiRequest && isExternalRequest) {
125+
const proxyUrl = `/custom-api${url.pathname}${url.search}`;
126+
return nativeFetch(proxyUrl, createProxyInit(input, init, url.origin));
127+
}
128+
} catch {
129+
/* fall through to the native request */
130+
}
131+
}
132+
133+
return nativeFetch(requestInput, init);
72134
};
73135
};
74136

@@ -81,11 +143,22 @@ export const findCustomModel = (
81143
return customModels?.find((m) => m.name === modelName);
82144
};
83145

146+
export const findPresetOverride = (
147+
modelName: string,
148+
presetOverrides?: CustomModel[],
149+
): CustomModel | undefined => {
150+
return presetOverrides?.find((model) => model.name === modelName);
151+
};
152+
84153
export const resolveApiKey = (
85154
explicitApiKey?: string,
86-
env: ApiEnv = import.meta.env,
155+
_env: ApiEnv = import.meta.env,
87156
): string | undefined => {
88-
return explicitApiKey || env.VITE_API_KEY || env.GEMINI_API_KEY;
157+
return explicitApiKey;
158+
};
159+
160+
export const resolveApiProxyMode = (env: ApiEnv = import.meta.env): ApiProxyMode => {
161+
return env.VITE_API_PROXY_MODE === 'local' ? 'local' : 'direct';
89162
};
90163

91164
/**
@@ -112,13 +185,28 @@ export const getAIProvider = (model: string): ApiProvider => {
112185
return 'google';
113186
};
114187

188+
export const resolveModelApiConfig = (
189+
model: ModelOption,
190+
config: Pick<AppConfig, 'customModels' | 'presetOverrides'>,
191+
): AIProviderConfig => {
192+
const customModelConfig = findCustomModel(model, config.customModels);
193+
const provider = customModelConfig?.provider || getAIProvider(model);
194+
195+
return {
196+
provider,
197+
...(customModelConfig?.apiKey ? { apiKey: customModelConfig.apiKey } : {}),
198+
...(customModelConfig?.baseUrl ? { baseUrl: customModelConfig.baseUrl } : {}),
199+
};
200+
};
201+
115202
// --- API Client Factory ---
116203

117204
export const getAI = (config?: AIProviderConfig): AIClient => {
118205
const provider = config?.provider || 'google';
119206
const apiKey = resolveApiKey(config?.apiKey);
120207
const baseUrl = config?.baseUrl || null;
121-
const customFetch = createCustomFetch(baseUrl);
208+
const proxyMode = config?.proxyMode || resolveApiProxyMode();
209+
const customFetch = createCustomFetch(baseUrl, proxyMode);
122210

123211
// Handle OpenAI-compatible providers
124212
if (provider === 'openai') {

appVersion.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const resolveAppVersion = (version?: string) => version || '0.0.0-dev';
2+
3+
export const APP_VERSION = resolveAppVersion(import.meta.env.VITE_APP_VERSION);

0 commit comments

Comments
 (0)