Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ OWNER_ID=opendots-owner
INTELLIGENCE_API_KEY=
# INTELLIGENCE_API_URL=
# INTELLIGENCE_WS_URL=
# Option A: Connect an eligible ChatGPT Plus / Pro plan from Settings.
# The credential file defaults beside DATABASE_PATH; never commit it.
# CHATGPT_AUTH_FILE=data/chatgpt-auth.json
# CHATGPT_CALLBACK_PORT=0

# Option B: existing OpenAI-compatible provider. This stays available as an explicit alternative.
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ RESEARCH.md
.DS_Store

data/
chatgpt-auth.json
chatgpt-auth.json.*.tmp
chatgpt-auth.json.corrupt-*
*.sqlite
*.sqlite-shm
*.sqlite-wal
Expand Down
4 changes: 4 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ services:
target: app
ports:
- '127.0.0.1:4310:4310'
- '127.0.0.1:1455:1455'
environment:
OWNER_TOKEN: ${OWNER_TOKEN:?Set a 24+ character OWNER_TOKEN in .env}
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:4310}
Expand All @@ -22,6 +23,9 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
OPENAI_MODEL: ${OPENAI_MODEL:-}
CHATGPT_AUTH_FILE: ${CHATGPT_AUTH_FILE:-/data/chatgpt-auth.json}
CHATGPT_CALLBACK_HOST: 0.0.0.0
CHATGPT_CALLBACK_PORT: 1455
BROWSER_URL: http://browser:4311
BROWSER_SECRET: ${BROWSER_SECRET:?Set a 24+ character BROWSER_SECRET in .env}
volumes:
Expand Down
37 changes: 27 additions & 10 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,36 @@ Open http://127.0.0.1:4310. Keep the server running for background work.

Edit `.env` on the server and restart after changes:

| Variable | Purpose |
| --------------------------------------------- | --------------------------------------------------------- |
| `INTELLIGENCE_API_KEY` | Project credential for conversation persistence |
| `INTELLIGENCE_API_URL`, `INTELLIGENCE_WS_URL` | Endpoint overrides for your Intelligence deployment |
| `OPENAI_API_KEY`, `OPENAI_MODEL` | Model credential and model identifier |
| `OPENAI_BASE_URL` | Compatible model API endpoint |
| `OWNER_ID` | Stable identity used for this deployment's conversations |
| `DATABASE_PATH` | SQLite file containing pages, workspace and work metadata |
| `OWNER_TOKEN` | Application access token; required for external bindings |
| `APP_ORIGIN` | Exact browser origin when using a proxy or custom domain |
| Variable | Purpose |
| --------------------------------------------- | ---------------------------------------------------------- |
| `INTELLIGENCE_API_KEY` | Project credential for conversation persistence |
| `INTELLIGENCE_API_URL`, `INTELLIGENCE_WS_URL` | Endpoint overrides for your Intelligence deployment |
| `OPENAI_API_KEY`, `OPENAI_MODEL` | Optional OpenAI-compatible model credential and identifier |
| `OPENAI_BASE_URL` | Compatible model API endpoint |
| `CHATGPT_AUTH_FILE` | Optional protected ChatGPT credential file override |
| `OWNER_ID` | Stable identity used for this deployment's conversations |
| `DATABASE_PATH` | SQLite file containing pages, workspace and work metadata |
| `OWNER_TOKEN` | Application access token; required for external bindings |
| `APP_ORIGIN` | Exact browser origin when using a proxy or custom domain |

The model environment variable names follow the configured provider adapter. Provider credentials belong in `.env`, not client-side variables or source code. Conversation history lives in the configured Intelligence project; copying the SQLite file alone does not back up that history.

## Text models and ChatGPT plans

OpenDots supports two explicitly selected text providers. Configure `INTELLIGENCE_API_KEY` for conversation persistence in either case.

**ChatGPT plan:** Open **Settings & setup → Continue with ChatGPT**. Sign in to an eligible ChatGPT Plus or Pro account, approve plan usage, then choose one of the models listed for that account. The OAuth flow requests only identity and plan-inference permissions. Eligible model requests use the user's applicable ChatGPT plan limits and credits; Plus usage limits can be shared with other apps, and this is not unlimited usage. Use **Manage usage** in Settings to review or change access.

Tokens stay on the server in `CHATGPT_AUTH_FILE`, which defaults to `chatgpt-auth.json` beside `DATABASE_PATH` (`/data/chatgpt-auth.json` in Compose). The file is written atomically with owner-only file permissions, limited to 1 MB, and rejected when the credential path or its directory is a symbolic link. It is ignored by Git. Never commit or copy it into an image. Compose preserves it in the existing `opendots-data` volume. Refresh tokens rotate and are refreshed server-side for page chat, scheduled turns, Slack, and delegated text compute.

The credential file is **not encrypted at rest** and this implementation does not yet coordinate multiple OpenDots processes through an interprocess lock. Anyone who can read the server account's files can read these tokens. Use OS full-disk encryption, keep the credential directory private, and run only one OpenDots server process against a given credential file. OpenDots has not integrated OpenAI's DevKit because its repository uses a noncommercial license incompatible with this project's MIT license.

When ChatGPT plan is selected, failed, expired, revoked, or rate-limited plan requests do not fall back to an API key. Reconnect ChatGPT or explicitly switch to the configured OpenAI-compatible provider in Settings. The alternative remains `OPENAI_API_KEY`, `OPENAI_MODEL`, and optional `OPENAI_BASE_URL`; custom compatible providers keep their existing Chat Completions behavior. Signing into ChatGPT selects it as the text provider. Disconnect attempts OpenAI session revocation and removes local tokens; if the server cannot confirm revocation, disconnect OpenDots separately from ChatGPT Settings.

The loopback sign-in callback uses `http://127.0.0.1:<port>/auth/callback`, and validates the callback Host against that URI. Compose binds inside the container for host forwarding but publishes port 1455 only on host loopback (`127.0.0.1:1455`); LAN clients cannot reach the published listener. A remote VM cannot receive a callback to the browser computer's `127.0.0.1`. OpenAI documents a protected credential transfer workflow for VMs, but OpenDots does not yet provide VM credential import or destination host-ID management; remote VM ChatGPT sign-in is not supported by this UI. Do not copy the local auth file to a VM without a deliberate secure transfer and host-ID plan. Do not send it through a browser or commit it. Use the API-key provider for remote deployments until that workflow is implemented.

Realtime voice remains separate and still requires `VOICE_API_KEY` and `VOICE_MODEL`. ChatGPT-plan access applies to text inference, not the audio transport.

## Pages and page conversations

Select a Space to open its page library. Search for a document, switch between grid and list views, or create a new page. The visual editor supports formatting, headings, lists, checklists, tables, and slash commands. Use `/` to insert a block and Cmd/Ctrl+S to save immediately. Pages autosave after editing pauses; the save status tells you whether changes reached the server.
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@tiptap/starter-kit": "3.31.3",
"@tiptap/suggestion": "3.31.3",
"hono": "^4.13.11",
"jose": "^6.2.12",
"lucide-react": "^1.48.0",
"playwright": "^1.63.0",
"react": "^19.3.0",
Expand Down
238 changes: 237 additions & 1 deletion src/client/WorkspaceDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { api } from './api';
import type { Dot, Memory, State, WorkspaceState } from '../shared/types';
export type Dialog =
| { type: 'space' }
Expand Down Expand Up @@ -55,7 +56,43 @@ export function WorkspaceDialog({
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [provider, setProvider] = useState(
workspace.setup.modelProvider ?? 'openai-compatible',
);
const [chatgptModels, setChatgptModels] = useState<
{ slug: string; displayName: string }[]
>([]);
const [modelBusy, setModelBusy] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const container = useRef<HTMLElement>(null);
useEffect(() => {
setProvider(workspace.setup.modelProvider ?? 'openai-compatible');
if (workspace.setup.chatgpt?.connected) setSigningIn(false);
if (
dialog.type !== 'settings' ||
!workspace.setup.chatgpt?.connected ||
!workspace.setup.chatgpt?.sharing
)
return;
let active = true;
void api<{ models: { slug: string; displayName: string }[] }>(
'/chatgpt/models',
)
.then((result) => {
if (active) setChatgptModels(result.models);
})
.catch(() => {
if (active) setChatgptModels([]);
});
return () => {
active = false;
};
}, [
dialog.type,
workspace.setup.modelProvider,
workspace.setup.chatgpt?.connected,
workspace.setup.chatgpt?.sharing,
]);
useEffect(() => {
const previous =
document.activeElement instanceof HTMLElement
Expand Down Expand Up @@ -360,10 +397,209 @@ export function WorkspaceDialog({
)}
{dialog.type === 'settings' && (
<div className="config-note">
<strong>Text model</strong>
{workspace.setup.chatgpt?.connected ? (
<>
<p>
ChatGPT account · Connected ✓
{workspace.setup.chatgpt?.sharing
? ' · Plan usage enabled'
: ' · Plan usage needs permission'}
{provider === 'chatgpt-plan'
? ' · Selected'
: ' · Not selected'}
</p>
<p>{workspace.setup.chatgpt.email ?? 'ChatGPT account'}</p>
{workspace.setup.chatgpt?.sharing && (
<>
<label className="field-label" htmlFor="chatgpt-model">
Model
</label>
<select
id="chatgpt-model"
value={workspace.setup.chatgpt.model ?? ''}
disabled={modelBusy || !chatgptModels.length}
onChange={async (event) => {
setModelBusy(true);
const ok = await mutate('/chatgpt/model', 'PUT', {
model: event.target.value,
});
setModelBusy(false);
if (!ok)
setError('Could not select that ChatGPT model.');
}}
>
{!chatgptModels.length && (
<option value="">Loading available models…</option>
)}
{chatgptModels.map((model) => (
<option key={model.slug} value={model.slug}>
{model.displayName}
</option>
))}
</select>
</>
)}
<div className="button-row">
<a
href="https://chatgpt.com/settings/usage"
target="_blank"
rel="noreferrer"
>
Manage usage ↗
</a>
<button
type="button"
disabled={modelBusy}
onClick={async () => {
setModelBusy(true);
const result = await api<{ revoked: boolean }>(
'/chatgpt/auth',
'DELETE',
);
setProvider('openai-compatible');
setModelBusy(false);
if (!result.revoked)
setError(
'Disconnected locally. OpenAI could not confirm remote revocation; you can disconnect OpenDots from ChatGPT Settings.',
);
else setError('ChatGPT disconnected.');
}}
>
Disconnect
</button>
</div>
<p className="muted">
Eligible requests use your ChatGPT plan limits. There is no
automatic API-key fallback.
</p>
{workspace.setup.chatgpt?.needsReconsent && !signingIn && (
<button
type="button"
disabled={modelBusy}
onClick={async () => {
setSigningIn(true);
const popup = window.open('about:blank', '_blank');
try {
const result = await api<{
authorizationUrl: string;
}>('/chatgpt/auth/start', 'POST', {});
if (popup)
popup.location.href = result.authorizationUrl;
else window.location.href = result.authorizationUrl;
} catch {
popup?.close();
setSigningIn(false);
setError(
'Could not request ChatGPT plan permission.',
);
}
}}
>
Enable ChatGPT plan usage
</button>
)}
{workspace.setup.chatgpt?.usable &&
workspace.setup.modelProvider !== 'chatgpt-plan' && (
<button
type="button"
disabled={modelBusy}
onClick={async () => {
setModelBusy(true);
const ok = await mutate('/model-provider', 'PUT', {
provider: 'chatgpt-plan',
});
setModelBusy(false);
if (ok) setProvider('chatgpt-plan');
}}
>
Use ChatGPT plan
</button>
)}
</>
) : (
<>
<p>Use your ChatGPT plan</p>
{signingIn ? (
<>
<p>
Opening ChatGPT sign-in… Complete authorization in your
browser.
</p>
<button
type="button"
onClick={() => {
void api('/chatgpt/auth/cancel', 'POST', {});
setSigningIn(false);
}}
>
Cancel
</button>
</>
) : (
<button
type="button"
disabled={modelBusy}
onClick={async () => {
setSigningIn(true);
const popup = window.open('about:blank', '_blank');
try {
const result = await api<{
authorizationUrl: string;
}>('/chatgpt/auth/start', 'POST', {});
if (popup)
popup.location.href = result.authorizationUrl;
else window.location.href = result.authorizationUrl;
} catch {
popup?.close();
setSigningIn(false);
setError('Could not start ChatGPT sign-in.');
}
}}
>
{workspace.setup.chatgpt?.needsReconsent
? 'Enable ChatGPT plan usage'
: 'Continue with ChatGPT'}
</button>
)}
<p className="muted">
Use your eligible Plus / Pro allowance. No OpenAI API key
required.
</p>
</>
)}
<hr />
<strong>
OpenAI-compatible API
{provider === 'openai-compatible' ? ' · Selected' : ''}
</strong>
<p>
{workspace.setup.apiProviderAvailable
? 'Configured from the server environment.'
: 'Configure OPENAI_API_KEY and OPENAI_MODEL in the server environment.'}
</p>
{workspace.setup.chatgpt?.connected &&
workspace.setup.modelProvider !== 'openai-compatible' &&
workspace.setup.apiProviderAvailable && (
<button
type="button"
onClick={async () => {
const ok = await mutate('/model-provider', 'PUT', {
provider: 'openai-compatible',
});
if (ok) setProvider('openai-compatible');
}}
>
Switch to API-key provider
</button>
)}
<p className="muted">
Realtime voice still requires its separate VOICE_API_KEY.
</p>
<strong>Service setup</strong>
<p>
{workspace.setup.missing.length
? `Add ${workspace.setup.missing.join(', ')} to the server environment, then restart.`
? `Still needed: ${workspace.setup.missing.map((item) => (item === 'ChatGPT plan connection/model' ? 'ChatGPT plan connection (use Continue with ChatGPT above)' : `${item} in the server environment`)).join(', ')}. Restart after changing environment settings.`
: 'Text configuration is present. A successful conversation confirms connectivity.'}
</p>
<p>
Expand Down
Loading