Initial Coworker app scaffold
This commit is contained in:
75
.codex/project.md
Normal file
75
.codex/project.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Codex Project Notes
|
||||
|
||||
## Project
|
||||
|
||||
`Coworker` is a local-first Windows AI coding coworker. It combines a Codex-style desktop app, local model provider configuration, a persistent Brain for memory and skills, and optional iPhone/iPad PWA access.
|
||||
|
||||
Repository:
|
||||
|
||||
```text
|
||||
Code-Inc/Coworker
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
```text
|
||||
Electron, React, TypeScript, Vite
|
||||
```
|
||||
|
||||
Package manager:
|
||||
|
||||
```text
|
||||
npm
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Run only on Gitea Ubuntu runners:
|
||||
|
||||
```text
|
||||
INSTALL_COMMAND = npm install
|
||||
LINT_COMMAND = npm run lint
|
||||
TEST_COMMAND = npm run test
|
||||
BUILD_COMMAND = npm run build
|
||||
AUDIT_COMMAND = npm run audit
|
||||
PACKAGE_COMMAND = npm run package:win
|
||||
```
|
||||
|
||||
Local checks are limited to lightweight validation such as `git diff --check`.
|
||||
|
||||
## Build Artifacts
|
||||
|
||||
Release artifacts are produced in:
|
||||
|
||||
```text
|
||||
release/
|
||||
```
|
||||
|
||||
Expected files:
|
||||
|
||||
```text
|
||||
Coworker-Setup-<version>.exe
|
||||
SHA256SUMS.txt
|
||||
```
|
||||
|
||||
## Security Rules
|
||||
|
||||
- Web access is disabled by default.
|
||||
- LAN/PWA access requires pairing-code authentication and scoped device tokens.
|
||||
- Workspace tools may access only explicitly selected project directories.
|
||||
- Agent writes and command execution require visible user approval.
|
||||
- Local provider endpoints and tokens are stored in user app data, not tracked files.
|
||||
- Unsigned installers are acceptable until a code-signing certificate is available.
|
||||
|
||||
## Release Rules
|
||||
|
||||
Before a release:
|
||||
|
||||
1. verify Gitea CI is green,
|
||||
2. run release dry-run workflow,
|
||||
3. confirm `Coworker-Setup-<version>.exe` and `SHA256SUMS.txt` exist,
|
||||
4. update `CHANGELOG.md` and release notes,
|
||||
5. create a `v*` tag,
|
||||
6. let Gitea Actions publish the release artifact.
|
||||
|
||||
Do not create a release unless explicitly requested.
|
||||
47
.gitea/workflows/build.yml
Normal file
47
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Build Windows installer
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD":/project \
|
||||
-w /project \
|
||||
electronuserland/builder:wine \
|
||||
/bin/bash -lc "npm run package:win"
|
||||
|
||||
- name: Checksums
|
||||
run: |
|
||||
cd release
|
||||
sha256sum Coworker-Setup-*.exe > SHA256SUMS.txt
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coworker-windows-installer
|
||||
path: |
|
||||
release/Coworker-Setup-*.exe
|
||||
release/SHA256SUMS.txt
|
||||
22
.gitea/workflows/dependency-check.yml
Normal file
22
.gitea/workflows/dependency-check.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Scheduled Dependency Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "29 3 * * 2"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dependency-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Audit
|
||||
run: npm run audit
|
||||
|
||||
- name: Outdated report
|
||||
run: npm outdated || true
|
||||
31
.gitea/workflows/release-dry-run.yml
Normal file
31
.gitea/workflows/release-dry-run.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Release Dry Run
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-dry-run:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate release metadata
|
||||
run: |
|
||||
test -f README.md
|
||||
test -f CHANGELOG.md
|
||||
test -f docs/release-checklist.md
|
||||
test -f docs/release-notes.md
|
||||
grep -q "Coworker-Setup" README.md
|
||||
if rg "PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|BUILD_COMMAND|TEST_COMMAND|LINT_COMMAND|AUDIT_COMMAND" .; then
|
||||
echo "Unresolved template placeholder found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Release check
|
||||
run: npm run release:check
|
||||
72
.gitea/workflows/release.yml
Normal file
72
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Validate
|
||||
run: npm run release:check
|
||||
|
||||
- name: Build Windows installer
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD":/project \
|
||||
-w /project \
|
||||
electronuserland/builder:wine \
|
||||
/bin/bash -lc "npm run package:win"
|
||||
|
||||
- name: Checksums
|
||||
run: |
|
||||
cd release
|
||||
sha256sum Coworker-Setup-*.exe > SHA256SUMS.txt
|
||||
|
||||
- name: Create Gitea release and upload assets
|
||||
run: |
|
||||
test -n "$GITEA_TOKEN"
|
||||
server="${GITHUB_SERVER_URL:-https://git.wilkensxl.de}"
|
||||
repo="${GITHUB_REPOSITORY:-Code-Inc/Coworker}"
|
||||
tag="${GITHUB_REF_NAME:-manual}"
|
||||
body="$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
print(Path("docs/release-notes.md").read_text(encoding="utf-8"))
|
||||
PY
|
||||
)"
|
||||
release_json="$(python3 - <<PY
|
||||
import json, os
|
||||
print(json.dumps({
|
||||
"tag_name": os.environ.get("tag", "$tag"),
|
||||
"target_commitish": "main",
|
||||
"name": "$tag",
|
||||
"body": """$body""",
|
||||
"draft": False,
|
||||
"prerelease": False
|
||||
}))
|
||||
PY
|
||||
)"
|
||||
response="$(curl --fail-with-body -sS \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$release_json" \
|
||||
"$server/api/v1/repos/$repo/releases")"
|
||||
release_id="$(printf '%s' "$response" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
||||
for file in release/Coworker-Setup-*.exe release/SHA256SUMS.txt; do
|
||||
name="$(basename "$file")"
|
||||
curl --fail-with-body -sS \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-F "attachment=@$file" \
|
||||
"$server/api/v1/repos/$repo/releases/$release_id/assets?name=$name"
|
||||
done
|
||||
27
.gitea/workflows/repo-cleanup.yml
Normal file
27
.gitea/workflows/repo-cleanup.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Scheduled Repository Cleanup Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "43 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cleanup-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Report generated files
|
||||
run: |
|
||||
find . -path ./.git -prune -o \( -name node_modules -o -name dist -o -name dist-electron -o -name release -o -name coverage \) -print
|
||||
|
||||
- name: Report large tracked files
|
||||
run: |
|
||||
git ls-files -z | xargs -0 du -h | awk '$1 ~ /[0-9]+M/ { print }' || true
|
||||
|
||||
- name: Verify ignored local outputs
|
||||
run: |
|
||||
grep -q "node_modules/" .gitignore
|
||||
grep -q "release/" .gitignore
|
||||
grep -q ".env" .gitignore
|
||||
30
.gitea/workflows/security-scan.yml
Normal file
30
.gitea/workflows/security-scan.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Scheduled Security Scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Scan suspicious patterns
|
||||
run: |
|
||||
rg -n "eval\\(|new Function|innerHTML\\s*=|shell:\\s*true|LOCALHOST_BYPASS|coworker_token" apps packages docs || true
|
||||
|
||||
- name: Scan secret-prone files
|
||||
run: |
|
||||
if find . -type f \( -name ".env" -o -name "*.pem" -o -name "*.pfx" -o -name "*.p12" -o -name "*.key" \) | rg .; then
|
||||
echo "Secret-prone file is tracked or present in workspace"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Audit
|
||||
run: npm run audit
|
||||
35
.gitea/workflows/template-compliance.yml
Normal file
35
.gitea/workflows/template-compliance.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Codex Template Compliance
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
compliance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Required files
|
||||
run: |
|
||||
test -f AGENTS.md
|
||||
test -f .codex/project.md
|
||||
test -f README.md
|
||||
test -f SECURITY.md
|
||||
test -f CHANGELOG.md
|
||||
test -f docs/release-checklist.md
|
||||
test -f docs/security-review.md
|
||||
test -f docs/agent-handoff.md
|
||||
|
||||
- name: Placeholder scan
|
||||
run: |
|
||||
if rg "PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|PACKAGE_NAME|ARTIFACT_NAME|ARTIFACT_OUTPUT_DIRECTORY|AUTHOR_NAME|PROJECT_STACK|DOWNLOAD_URL|CI_URL|RELEASES_URL|BUILD_COMMAND|TEST_COMMAND|LINT_COMMAND|AUDIT_COMMAND|README_COMMAND|INSTALL_COMMAND|DEV_COMMAND|PACKAGE_MANAGER|PROJECT_VERSION|COMMIT_OR_VERSION" .; then
|
||||
echo "Unresolved Codex kit placeholder found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Lightweight diff hygiene
|
||||
run: git diff --check
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-electron/
|
||||
release/
|
||||
coverage/
|
||||
.vite/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
data/
|
||||
logs/
|
||||
*.pfx
|
||||
*.p12
|
||||
*.key
|
||||
*.pem
|
||||
59
AGENTS.md
Normal file
59
AGENTS.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Project
|
||||
|
||||
Coworker is a Windows-first local AI coding app with a Codex-style desktop UX, local model provider setup, an Odysseus-inspired Brain, and optional iPhone/iPad PWA access.
|
||||
|
||||
Repository:
|
||||
|
||||
```text
|
||||
Code-Inc/Coworker
|
||||
```
|
||||
|
||||
## Repository Rules
|
||||
|
||||
- Start every task with `git status --short --branch`.
|
||||
- If the tree is clean, use a safe fast-forward update check before editing.
|
||||
- Preserve unrelated user changes.
|
||||
- Do not commit secrets, `.env` files, private keys, certificates, API tokens, local app data, or generated installers outside release artifacts.
|
||||
- Use only Gitea Ubuntu runners for project builds, tests, dependency setup, package jobs, installers, and releases.
|
||||
- Do not run dependency installs, Electron builds, test suites, package jobs, or installer builds on the user's local machine.
|
||||
- Local checks are limited to lightweight reads and validation such as `rg`, JSON parsing, TypeScript config inspection, and `git diff --check`.
|
||||
- Keep workflows on supported Gitea runner labels: `ubuntu-latest`, `ubuntu-24.04`, or `ubuntu-22.04`.
|
||||
- Do not add Windows or macOS runners. Use Linux-compatible Electron packaging with Wine/Docker on Gitea runners.
|
||||
- Keep release artifacts free of Codex kit metadata such as `AGENTS.md`, `.codex/`, workflow templates, and handoff docs.
|
||||
|
||||
## Commands
|
||||
|
||||
Run these through Gitea Actions, not locally:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run build
|
||||
npm run package:win
|
||||
npm run audit
|
||||
```
|
||||
|
||||
Lightweight local validation:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
- Electron + React + TypeScript is the product stack.
|
||||
- Avoid native Node dependencies in v1 unless they have reliable Windows prebuilds and package cleanly from Ubuntu runners.
|
||||
- The first installer target is unsigned NSIS `.exe`; MSI follows after the EXE pipeline is stable.
|
||||
- Web/PWA access must be disabled by default and protected by pairing-code authentication.
|
||||
- Workspace file access must stay inside explicitly selected project roots.
|
||||
- Agent file writes, command execution, and destructive operations require visible user approval.
|
||||
|
||||
## Finish Checklist
|
||||
|
||||
- `git diff --check` passes.
|
||||
- Gitea runner workflows are configured for build, security, dependency, cleanup, release dry-run, and template compliance.
|
||||
- Release docs explain how the Windows installer is produced and where artifacts appear.
|
||||
- If a commit is pushed and triggers workflows, poll the workflow to success or document the blocker.
|
||||
6
CHANGELOG.md
Normal file
6
CHANGELOG.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial Coworker scaffold.
|
||||
- Added Electron desktop shell, React UI, local provider setup, Brain, PWA web access, and Gitea installer workflows.
|
||||
30
CONTRIBUTING.md
Normal file
30
CONTRIBUTING.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Contributing
|
||||
|
||||
Coworker is built through Gitea Actions. Keep local work lightweight and use the Ubuntu runners for dependency setup, tests, builds, packaging, and release checks.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Check `git status --short --branch`.
|
||||
2. Keep changes focused.
|
||||
3. Update docs when behavior or release artifacts change.
|
||||
4. Push and watch Gitea workflows.
|
||||
5. Fix failed runner checks before considering work complete.
|
||||
|
||||
## Commands
|
||||
|
||||
Run on Gitea runners:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run build
|
||||
npm run package:win
|
||||
npm run audit
|
||||
```
|
||||
|
||||
Local:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Code-Inc
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
72
README.md
Normal file
72
README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Coworker
|
||||
|
||||
Coworker is a Windows-first local AI coding app. It is designed like a focused desktop coding assistant: open a project, connect a local model, talk to the agent, inspect files, review diffs, and keep useful context in Brain.
|
||||
|
||||
## Features
|
||||
|
||||
- Codex-style desktop workflow for coding sessions.
|
||||
- Local model provider setup for Ollama, LM Studio, and custom OpenAI-compatible endpoints.
|
||||
- Brain with persistent memories and reusable skills.
|
||||
- Workspace file preview with path confinement.
|
||||
- Optional Web/PWA access for iPhone and iPad, disabled by default and protected by pairing codes.
|
||||
- Windows installer produced by Gitea Actions on Ubuntu runners.
|
||||
|
||||
## Development
|
||||
|
||||
Project setup, dependency installation, builds, tests, audits, packaging, and releases run in Gitea Actions. Do not run heavy project commands on the user's local machine.
|
||||
|
||||
Runner commands:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run build
|
||||
npm run package:win
|
||||
```
|
||||
|
||||
Lightweight local validation:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Windows Installer
|
||||
|
||||
The first release artifact is:
|
||||
|
||||
```text
|
||||
release/Coworker-Setup-<version>.exe
|
||||
release/SHA256SUMS.txt
|
||||
```
|
||||
|
||||
The installer is unsigned until a Windows code-signing certificate is configured. The app can check Gitea releases for updates, but silent auto-update is intentionally deferred until signing exists.
|
||||
|
||||
## Web/PWA Access
|
||||
|
||||
Web access is disabled by default. Enable it in Settings, choose localhost or LAN/Tailscale binding, then create a pairing code. Open the displayed URL on iPhone or iPad and add Coworker to the Home Screen.
|
||||
|
||||
Security defaults:
|
||||
|
||||
- pairing is required,
|
||||
- device tokens are stored hashed,
|
||||
- paired devices can be revoked in a future settings panel,
|
||||
- workspace access stays inside selected project folders.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
apps/desktop Electron main process, preload bridge, React renderer
|
||||
packages/core Agent, Brain, provider, pairing, and shared types
|
||||
docs release, security, and project handoff documents
|
||||
.gitea/workflows Gitea CI, installer, security, and release automation
|
||||
```
|
||||
|
||||
## Release
|
||||
|
||||
1. Ensure the Gitea build workflow succeeds.
|
||||
2. Update `CHANGELOG.md`.
|
||||
3. Create a `v*` tag.
|
||||
4. Let the release workflow upload the installer and checksum to Gitea.
|
||||
|
||||
Do not create a release from a local build.
|
||||
29
SECURITY.md
Normal file
29
SECURITY.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Security
|
||||
|
||||
Coworker is a local-first coding app with powerful project and agent capabilities. Treat it like a developer tool with access to your files and local model endpoints.
|
||||
|
||||
## Reporting
|
||||
|
||||
Open a private issue or contact the repository owner if a vulnerability exposes files, tokens, local services, paired devices, or agent permissions.
|
||||
|
||||
## Defaults
|
||||
|
||||
- Web access is disabled by default.
|
||||
- LAN/Tailscale access requires an explicit settings change.
|
||||
- Mobile access requires a pairing code.
|
||||
- Device tokens are stored as hashes.
|
||||
- Workspace file access is confined to selected project roots.
|
||||
- Agent file writes and command execution require visible approval.
|
||||
|
||||
## Secrets
|
||||
|
||||
Never commit:
|
||||
|
||||
- `.env` files,
|
||||
- API keys,
|
||||
- provider tokens,
|
||||
- pairing tokens,
|
||||
- certificates,
|
||||
- private keys,
|
||||
- app data,
|
||||
- generated installers outside release artifacts.
|
||||
416
apps/desktop/electron/main.ts
Normal file
416
apps/desktop/electron/main.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { createServer, IncomingMessage, Server, ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
AppState,
|
||||
BrainInput,
|
||||
ModelProvider,
|
||||
WebAccessSettings,
|
||||
createBrainItem,
|
||||
createPairedDevice,
|
||||
createPairingChallenge,
|
||||
createProvider,
|
||||
isPairingChallengeValid,
|
||||
nowIso,
|
||||
probeOpenAiCompatibleProvider,
|
||||
runAgent,
|
||||
updateBrainItem
|
||||
} from "@coworker/core";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rendererDir = path.resolve(__dirname, "../dist");
|
||||
const stateFile = () => path.join(app.getPath("userData"), "state.json");
|
||||
|
||||
let mainWindow: BrowserWindow | undefined;
|
||||
let webServer: Server | undefined;
|
||||
let activePairing = createPairingChallenge(0);
|
||||
|
||||
const defaultState: AppState = {
|
||||
providers: [
|
||||
createProvider("ollama", "Ollama", "http://localhost:11434/v1"),
|
||||
createProvider("lmstudio", "LM Studio", "http://localhost:1234/v1")
|
||||
],
|
||||
brain: [],
|
||||
workspaces: [],
|
||||
webAccess: {
|
||||
enabled: false,
|
||||
bindHost: "127.0.0.1",
|
||||
port: 4789,
|
||||
requirePairing: true
|
||||
},
|
||||
pairedDevices: []
|
||||
};
|
||||
|
||||
async function readState(): Promise<AppState> {
|
||||
try {
|
||||
return { ...defaultState, ...JSON.parse(await readFile(stateFile(), "utf8")) };
|
||||
} catch {
|
||||
return defaultState;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(state: AppState): Promise<AppState> {
|
||||
await mkdir(path.dirname(stateFile()), { recursive: true });
|
||||
await writeFile(stateFile(), JSON.stringify(state, null, 2), "utf8");
|
||||
return state;
|
||||
}
|
||||
|
||||
async function mutateState(mutator: (state: AppState) => AppState | Promise<AppState>): Promise<AppState> {
|
||||
const current = await readState();
|
||||
const next = await mutator(current);
|
||||
return writeState(next);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1320,
|
||||
height: 860,
|
||||
minWidth: 980,
|
||||
minHeight: 680,
|
||||
title: "Coworker",
|
||||
backgroundColor: "#111317",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
});
|
||||
|
||||
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
if (devUrl) {
|
||||
void mainWindow.loadURL(devUrl);
|
||||
} else {
|
||||
void mainWindow.loadFile(path.join(rendererDir, "index.html"));
|
||||
}
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
|
||||
function registerIpc() {
|
||||
ipcMain.handle("state:get", () => readState());
|
||||
|
||||
ipcMain.handle("providers:save", async (_event, provider: ModelProvider) => mutateState((state) => {
|
||||
const next = { ...provider, baseUrl: provider.baseUrl.replace(/\/+$/, ""), updatedAt: nowIso() };
|
||||
return {
|
||||
...state,
|
||||
providers: state.providers.some((item) => item.id === next.id)
|
||||
? state.providers.map((item) => item.id === next.id ? next : item)
|
||||
: [...state.providers, next]
|
||||
};
|
||||
}));
|
||||
|
||||
ipcMain.handle("providers:remove", async (_event, id: string) => mutateState((state) => ({
|
||||
...state,
|
||||
providers: state.providers.filter((provider) => provider.id !== id)
|
||||
})));
|
||||
|
||||
ipcMain.handle("providers:probe", async (_event, id: string) => {
|
||||
const state = await readState();
|
||||
const provider = state.providers.find((item) => item.id === id);
|
||||
if (!provider) throw new Error("Provider not found.");
|
||||
return probeOpenAiCompatibleProvider(provider);
|
||||
});
|
||||
|
||||
ipcMain.handle("brain:add", async (_event, input: BrainInput) => mutateState((state) => ({
|
||||
...state,
|
||||
brain: [createBrainItem(input), ...state.brain]
|
||||
})));
|
||||
|
||||
ipcMain.handle("brain:update", async (_event, id: string, patch: Partial<BrainInput>) => mutateState((state) => ({
|
||||
...state,
|
||||
brain: state.brain.map((item) => item.id === id ? updateBrainItem(item, patch) : item)
|
||||
})));
|
||||
|
||||
ipcMain.handle("brain:remove", async (_event, id: string) => mutateState((state) => ({
|
||||
...state,
|
||||
brain: state.brain.filter((item) => item.id !== id)
|
||||
})));
|
||||
|
||||
ipcMain.handle("workspace:open", async () => {
|
||||
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
|
||||
if (result.canceled || !result.filePaths[0]) return undefined;
|
||||
const rootPath = result.filePaths[0];
|
||||
const workspace = {
|
||||
id: createHash("sha256").update(rootPath).digest("hex").slice(0, 16),
|
||||
name: path.basename(rootPath),
|
||||
rootPath,
|
||||
openedAt: nowIso()
|
||||
};
|
||||
await mutateState((state) => ({
|
||||
...state,
|
||||
workspaces: [workspace, ...state.workspaces.filter((item) => item.id !== workspace.id)]
|
||||
}));
|
||||
return workspace;
|
||||
});
|
||||
|
||||
ipcMain.handle("workspace:list", async (_event, id: string) => {
|
||||
const workspace = (await readState()).workspaces.find((item) => item.id === id);
|
||||
if (!workspace) throw new Error("Workspace not found.");
|
||||
const entries = await readdir(workspace.rootPath, { withFileTypes: true });
|
||||
return Promise.all(entries.slice(0, 200).map(async (entry) => {
|
||||
const absolute = path.join(workspace.rootPath, entry.name);
|
||||
const info = await stat(absolute);
|
||||
return {
|
||||
path: entry.name,
|
||||
type: entry.isDirectory() ? "directory" : "file",
|
||||
size: entry.isFile() ? info.size : undefined,
|
||||
modifiedAt: info.mtime.toISOString()
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
ipcMain.handle("workspace:read", async (_event, id: string, relativePath: string) => {
|
||||
const workspace = (await readState()).workspaces.find((item) => item.id === id);
|
||||
if (!workspace) throw new Error("Workspace not found.");
|
||||
const absolute = confinedPath(workspace.rootPath, relativePath);
|
||||
const info = await stat(absolute);
|
||||
if (!info.isFile() || info.size > 512 * 1024) throw new Error("File is not readable in preview.");
|
||||
return readFile(absolute, "utf8");
|
||||
});
|
||||
|
||||
ipcMain.handle("agent:run", async (_event, request) => {
|
||||
const state = await readState();
|
||||
const provider = request.providerId
|
||||
? state.providers.find((item) => item.id === request.providerId)
|
||||
: state.providers.find((item) => item.enabled);
|
||||
const model = request.model ?? provider?.defaults.coding ?? provider?.defaults.chat;
|
||||
return runAgent(
|
||||
{
|
||||
prompt: String(request.prompt ?? ""),
|
||||
workspace: request.workspace,
|
||||
provider,
|
||||
model,
|
||||
messages: request.messages ?? [],
|
||||
brain: state.brain
|
||||
},
|
||||
(messages) => completeWithProvider(provider, model, messages)
|
||||
);
|
||||
});
|
||||
|
||||
ipcMain.handle("web:settings", async (_event, settings: WebAccessSettings) => {
|
||||
const state = await mutateState((current) => ({ ...current, webAccess: settings }));
|
||||
await restartWebServer(state.webAccess);
|
||||
return state;
|
||||
});
|
||||
|
||||
ipcMain.handle("web:pairing:create", () => {
|
||||
activePairing = createPairingChallenge();
|
||||
return activePairing;
|
||||
});
|
||||
}
|
||||
|
||||
async function completeWithProvider(provider: ModelProvider | undefined, model: string | undefined, messages: { role: string; content: string }[]) {
|
||||
if (!provider || !model) {
|
||||
return "No model is configured yet. Add Ollama, LM Studio, or another OpenAI-compatible provider in Settings.";
|
||||
}
|
||||
const response = await fetch(`${provider.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: messages.map((message) => ({ role: message.role, content: message.content })),
|
||||
temperature: 0.2
|
||||
})
|
||||
});
|
||||
if (!response.ok) return `Model request failed with HTTP ${response.status}.`;
|
||||
const body = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
|
||||
return body.choices?.[0]?.message?.content ?? "The model returned an empty response.";
|
||||
}
|
||||
|
||||
function confinedPath(root: string, relativePath: string) {
|
||||
const absolute = path.resolve(root, relativePath);
|
||||
const normalizedRoot = path.resolve(root);
|
||||
if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||
throw new Error("Path is outside the workspace.");
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
async function restartWebServer(settings: WebAccessSettings) {
|
||||
if (webServer) {
|
||||
await new Promise<void>((resolve) => webServer?.close(() => resolve()));
|
||||
webServer = undefined;
|
||||
}
|
||||
if (!settings.enabled) return;
|
||||
webServer = createServer(handleWebRequest);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
webServer?.listen(settings.port, settings.bindHost, resolve);
|
||||
webServer?.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleWebRequest(req: IncomingMessage, res: ServerResponse) {
|
||||
const url = new URL(req.url ?? "/", "http://coworker.local");
|
||||
if (url.pathname === "/api/pair" && req.method === "POST") return handlePair(req, res);
|
||||
if (url.pathname === "/api/state") return sendJson(res, await publicState(req));
|
||||
if (url.pathname.startsWith("/api/") && !(await isAuthorized(req))) return sendJson(res, { error: "Pairing required." }, 401);
|
||||
if (url.pathname === "/api/providers" && req.method === "POST") return handleWebProviderSave(req, res);
|
||||
if (url.pathname.match(/^\/api\/providers\/[^/]+\/probe$/) && req.method === "POST") return handleWebProviderProbe(url, res);
|
||||
if (url.pathname === "/api/brain" && req.method === "POST") return handleWebBrainAdd(req, res);
|
||||
if (url.pathname.match(/^\/api\/brain\/[^/]+$/) && req.method === "DELETE") return handleWebBrainRemove(url, res);
|
||||
if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files$/)) return handleWebWorkspaceList(url, res);
|
||||
if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files\/.+$/)) return handleWebWorkspaceRead(url, res);
|
||||
if (url.pathname === "/api/agent/run" && req.method === "POST") return handleWebAgent(req, res);
|
||||
return serveStatic(url.pathname, res);
|
||||
}
|
||||
|
||||
async function handlePair(req: IncomingMessage, res: ServerResponse) {
|
||||
const body = await readJsonBody<{ code?: string; name?: string }>(req);
|
||||
if (!isPairingChallengeValid(activePairing, body.code ?? "")) return sendJson(res, { error: "Invalid pairing code." }, 403);
|
||||
const token = randomBytes(24).toString("hex");
|
||||
const device = await createPairedDevice(body.name ?? "Mobile device", token, digest);
|
||||
await mutateState((state) => ({ ...state, pairedDevices: [device, ...state.pairedDevices] }));
|
||||
res.setHeader("Set-Cookie", `coworker_token=${token}; HttpOnly; SameSite=Lax; Path=/`);
|
||||
sendJson(res, { token });
|
||||
}
|
||||
|
||||
async function handleWebProviderSave(req: IncomingMessage, res: ServerResponse) {
|
||||
const provider = await readJsonBody<ModelProvider>(req);
|
||||
const state = await mutateState((current) => ({
|
||||
...current,
|
||||
providers: current.providers.some((item) => item.id === provider.id)
|
||||
? current.providers.map((item) => item.id === provider.id ? { ...provider, updatedAt: nowIso() } : item)
|
||||
: [...current.providers, { ...createProvider(provider.kind, provider.name, provider.baseUrl), ...provider, updatedAt: nowIso() }]
|
||||
}));
|
||||
sendJson(res, sanitizeState(state));
|
||||
}
|
||||
|
||||
async function handleWebProviderProbe(url: URL, res: ServerResponse) {
|
||||
const id = decodeURIComponent(url.pathname.split("/")[3]);
|
||||
const provider = (await readState()).providers.find((item) => item.id === id);
|
||||
if (!provider) return sendJson(res, { error: "Provider not found." }, 404);
|
||||
sendJson(res, await probeOpenAiCompatibleProvider(provider));
|
||||
}
|
||||
|
||||
async function handleWebBrainAdd(req: IncomingMessage, res: ServerResponse) {
|
||||
const input = await readJsonBody<BrainInput>(req);
|
||||
const state = await mutateState((current) => ({ ...current, brain: [createBrainItem(input), ...current.brain] }));
|
||||
sendJson(res, sanitizeState(state));
|
||||
}
|
||||
|
||||
async function handleWebBrainRemove(url: URL, res: ServerResponse) {
|
||||
const id = decodeURIComponent(url.pathname.split("/")[3]);
|
||||
const state = await mutateState((current) => ({ ...current, brain: current.brain.filter((item) => item.id !== id) }));
|
||||
sendJson(res, sanitizeState(state));
|
||||
}
|
||||
|
||||
async function handleWebWorkspaceList(url: URL, res: ServerResponse) {
|
||||
const id = decodeURIComponent(url.pathname.split("/")[3]);
|
||||
const workspace = (await readState()).workspaces.find((item) => item.id === id);
|
||||
if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404);
|
||||
const entries = await readdir(workspace.rootPath, { withFileTypes: true });
|
||||
const files = await Promise.all(entries.slice(0, 200).map(async (entry) => {
|
||||
const absolute = path.join(workspace.rootPath, entry.name);
|
||||
const info = await stat(absolute);
|
||||
return {
|
||||
path: entry.name,
|
||||
type: entry.isDirectory() ? "directory" : "file",
|
||||
size: entry.isFile() ? info.size : undefined,
|
||||
modifiedAt: info.mtime.toISOString()
|
||||
};
|
||||
}));
|
||||
sendJson(res, files);
|
||||
}
|
||||
|
||||
async function handleWebWorkspaceRead(url: URL, res: ServerResponse) {
|
||||
const parts = url.pathname.split("/");
|
||||
const id = decodeURIComponent(parts[3]);
|
||||
const relativePath = decodeURIComponent(parts.slice(5).join("/"));
|
||||
const workspace = (await readState()).workspaces.find((item) => item.id === id);
|
||||
if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404);
|
||||
const absolute = confinedPath(workspace.rootPath, relativePath);
|
||||
const info = await stat(absolute);
|
||||
if (!info.isFile() || info.size > 512 * 1024) return sendJson(res, { error: "File is not readable in preview." }, 400);
|
||||
sendJson(res, { content: await readFile(absolute, "utf8") });
|
||||
}
|
||||
|
||||
async function handleWebAgent(req: IncomingMessage, res: ServerResponse) {
|
||||
const body = await readJsonBody<{ prompt?: string; messages?: unknown[]; providerId?: string; model?: string }>(req);
|
||||
const state = await readState();
|
||||
const provider = body.providerId ? state.providers.find((item) => item.id === body.providerId) : state.providers[0];
|
||||
const result = await runAgent(
|
||||
{ prompt: body.prompt ?? "", messages: [], brain: state.brain, provider, model: body.model },
|
||||
(messages) => completeWithProvider(provider, body.model ?? provider?.defaults.chat, messages)
|
||||
);
|
||||
sendJson(res, result);
|
||||
}
|
||||
|
||||
async function publicState(req: IncomingMessage) {
|
||||
const state = await readState();
|
||||
const paired = await isAuthorized(req);
|
||||
return {
|
||||
paired,
|
||||
webAccess: state.webAccess,
|
||||
providers: paired ? state.providers.map(({ apiKey, ...provider }) => provider) : [],
|
||||
brain: paired ? state.brain : [],
|
||||
workspaces: paired ? state.workspaces : []
|
||||
};
|
||||
}
|
||||
|
||||
async function isAuthorized(req: IncomingMessage) {
|
||||
const token = (req.headers.cookie ?? "").split(";").map((item) => item.trim()).find((item) => item.startsWith("coworker_token="))?.split("=")[1];
|
||||
if (!token) return false;
|
||||
const hash = await digest(token);
|
||||
const state = await readState();
|
||||
return state.pairedDevices.some((device) => !device.revokedAt && device.tokenHash === hash);
|
||||
}
|
||||
|
||||
async function digest(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function sanitizeState(state: AppState): AppState {
|
||||
return {
|
||||
...state,
|
||||
providers: state.providers.map(({ apiKey, ...provider }) => provider),
|
||||
pairedDevices: state.pairedDevices.map((device) => ({ ...device, tokenHash: "" }))
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as T;
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, data: unknown, status = 200) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function serveStatic(urlPath: string, res: ServerResponse) {
|
||||
try {
|
||||
const safePath = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
||||
const absolute = confinedPath(rendererDir, safePath);
|
||||
const finalPath = absolute.endsWith(path.sep) ? path.join(absolute, "index.html") : absolute;
|
||||
createReadStream(finalPath)
|
||||
.on("error", () => createReadStream(path.join(rendererDir, "index.html")).pipe(res))
|
||||
.pipe(res);
|
||||
} catch {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
registerIpc();
|
||||
createWindow();
|
||||
await restartWebServer((await readState()).webAccess);
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
31
apps/desktop/electron/preload.ts
Normal file
31
apps/desktop/electron/preload.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
const api = {
|
||||
state: () => ipcRenderer.invoke("state:get"),
|
||||
providers: {
|
||||
save: (provider: unknown) => ipcRenderer.invoke("providers:save", provider),
|
||||
remove: (id: string) => ipcRenderer.invoke("providers:remove", id),
|
||||
probe: (id: string) => ipcRenderer.invoke("providers:probe", id)
|
||||
},
|
||||
brain: {
|
||||
add: (input: unknown) => ipcRenderer.invoke("brain:add", input),
|
||||
update: (id: string, patch: unknown) => ipcRenderer.invoke("brain:update", id, patch),
|
||||
remove: (id: string) => ipcRenderer.invoke("brain:remove", id)
|
||||
},
|
||||
workspace: {
|
||||
open: () => ipcRenderer.invoke("workspace:open"),
|
||||
list: (id: string) => ipcRenderer.invoke("workspace:list", id),
|
||||
read: (id: string, relativePath: string) => ipcRenderer.invoke("workspace:read", id, relativePath)
|
||||
},
|
||||
agent: {
|
||||
run: (request: unknown) => ipcRenderer.invoke("agent:run", request)
|
||||
},
|
||||
web: {
|
||||
saveSettings: (settings: unknown) => ipcRenderer.invoke("web:settings", settings),
|
||||
createPairing: () => ipcRenderer.invoke("web:pairing:create")
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("coworker", api);
|
||||
|
||||
export type CoworkerApi = typeof api;
|
||||
17
apps/desktop/index.html
Normal file
17
apps/desktop/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#111317" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Coworker" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Coworker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
apps/desktop/package.json
Normal file
6
apps/desktop/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@coworker/desktop",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true
|
||||
}
|
||||
5
apps/desktop/public/icon.svg
Normal file
5
apps/desktop/public/icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="28" fill="#111317"/>
|
||||
<path d="M31 70c0-23 14-39 34-39 16 0 27 9 31 23H77c-3-5-7-7-13-7-10 0-16 9-16 23s6 23 16 23c7 0 12-3 15-10h19c-5 17-17 27-35 27-19 0-32-16-32-40Z" fill="#f5f7fb"/>
|
||||
<path d="M22 24h28v13H35v54h15v13H22V24Zm84 80H78V91h15V37H78V24h28v80Z" fill="#7dd3fc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
17
apps/desktop/public/manifest.webmanifest
Normal file
17
apps/desktop/public/manifest.webmanifest
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Coworker",
|
||||
"short_name": "Coworker",
|
||||
"display": "standalone",
|
||||
"background_color": "#111317",
|
||||
"theme_color": "#111317",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
apps/desktop/public/sw.js
Normal file
13
apps/desktop/public/sw.js
Normal file
@@ -0,0 +1,13 @@
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open("coworker-shell-v1").then((cache) => cache.addAll(["/", "/manifest.webmanifest", "/icon.svg"])));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((cached) => cached || caches.match("/"))));
|
||||
});
|
||||
86
apps/desktop/src/api.ts
Normal file
86
apps/desktop/src/api.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
AgentRunResult,
|
||||
AppState,
|
||||
BrainInput,
|
||||
BrainItem,
|
||||
ModelProvider,
|
||||
PairingChallenge,
|
||||
ProviderProbeResult,
|
||||
WebAccessSettings,
|
||||
WorkspaceFile,
|
||||
WorkspaceState
|
||||
} from "@coworker/core";
|
||||
|
||||
type AgentRequest = {
|
||||
prompt: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
workspace?: WorkspaceState;
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
};
|
||||
|
||||
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const coworkerApi = {
|
||||
state(): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.state() as Promise<AppState>;
|
||||
return json<AppState>("/api/state");
|
||||
},
|
||||
saveProvider(provider: ModelProvider): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.providers.save(provider) as Promise<AppState>;
|
||||
return json<AppState>("/api/providers", { method: "POST", body: JSON.stringify(provider) });
|
||||
},
|
||||
probeProvider(id: string): Promise<ProviderProbeResult> {
|
||||
if (window.coworker) return window.coworker.providers.probe(id) as Promise<ProviderProbeResult>;
|
||||
return json<ProviderProbeResult>(`/api/providers/${id}/probe`, { method: "POST" });
|
||||
},
|
||||
addBrain(input: BrainInput): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.brain.add(input) as Promise<AppState>;
|
||||
return json<AppState>("/api/brain", { method: "POST", body: JSON.stringify(input) });
|
||||
},
|
||||
removeBrain(id: string): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.brain.remove(id) as Promise<AppState>;
|
||||
return json<AppState>(`/api/brain/${id}`, { method: "DELETE" });
|
||||
},
|
||||
openWorkspace(): Promise<WorkspaceState | undefined> {
|
||||
if (window.coworker) return window.coworker.workspace.open() as Promise<WorkspaceState | undefined>;
|
||||
throw new Error("Workspace selection is available in the desktop app.");
|
||||
},
|
||||
listWorkspace(id: string): Promise<WorkspaceFile[]> {
|
||||
if (window.coworker) return window.coworker.workspace.list(id) as Promise<WorkspaceFile[]>;
|
||||
return json<WorkspaceFile[]>(`/api/workspaces/${id}/files`);
|
||||
},
|
||||
readWorkspace(id: string, relativePath: string): Promise<string> {
|
||||
if (window.coworker) return window.coworker.workspace.read(id, relativePath) as Promise<string>;
|
||||
return json<{ content: string }>(`/api/workspaces/${id}/files/${encodeURIComponent(relativePath)}`).then((r) => r.content);
|
||||
},
|
||||
runAgent(request: AgentRequest): Promise<AgentRunResult> {
|
||||
if (window.coworker) return window.coworker.agent.run(request) as Promise<AgentRunResult>;
|
||||
return json<AgentRunResult>("/api/agent/run", { method: "POST", body: JSON.stringify(request) });
|
||||
},
|
||||
saveWebSettings(settings: WebAccessSettings): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.web.saveSettings(settings) as Promise<AppState>;
|
||||
throw new Error("Web settings are available in the desktop app.");
|
||||
},
|
||||
createPairing(): Promise<PairingChallenge> {
|
||||
if (window.coworker) return window.coworker.web.createPairing() as Promise<PairingChallenge>;
|
||||
throw new Error("Pairing is started from the desktop app.");
|
||||
},
|
||||
pair(code: string, name: string): Promise<{ token: string }> {
|
||||
return json<{ token: string }>("/api/pair", { method: "POST", body: JSON.stringify({ code, name }) });
|
||||
}
|
||||
};
|
||||
|
||||
export function displayBrainItem(item: BrainItem) {
|
||||
return `${item.kind.toUpperCase()} / ${item.scope}${item.pinned ? " / PINNED" : ""}`;
|
||||
}
|
||||
7
apps/desktop/src/global.d.ts
vendored
Normal file
7
apps/desktop/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CoworkerApi } from "../electron/preload";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
coworker?: CoworkerApi;
|
||||
}
|
||||
}
|
||||
310
apps/desktop/src/main.tsx
Normal file
310
apps/desktop/src/main.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppState, BrainItem, ChatMessage, ModelProvider, WorkspaceFile, WorkspaceState } from "@coworker/core";
|
||||
import { createProvider } from "@coworker/core";
|
||||
import { coworkerApi, displayBrainItem } from "./api";
|
||||
import "./styles.css";
|
||||
|
||||
type View = "agent" | "models" | "brain" | "files" | "settings";
|
||||
|
||||
const emptyState: AppState = {
|
||||
providers: [],
|
||||
brain: [],
|
||||
workspaces: [],
|
||||
webAccess: { enabled: false, bindHost: "127.0.0.1", port: 4789, requirePairing: true },
|
||||
pairedDevices: []
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<AppState>(emptyState);
|
||||
const [view, setView] = useState<View>("agent");
|
||||
const [error, setError] = useState("");
|
||||
const [workspace, setWorkspace] = useState<WorkspaceState | undefined>();
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
setState(await coworkerApi.state());
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load Coworker state.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if ("serviceWorker" in navigator) void navigator.serviceWorker.register("/sw.js").catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspace(state.workspaces[0]);
|
||||
}, [state.workspaces]);
|
||||
|
||||
const activeProvider = state.providers.find((provider) => provider.enabled) ?? state.providers[0];
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">C</span>
|
||||
<div>
|
||||
<strong>Coworker</strong>
|
||||
<span>Local coding app</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{(["agent", "files", "models", "brain", "settings"] as View[]).map((item) => (
|
||||
<button key={item} className={view === item ? "active" : ""} onClick={() => setView(item)}>
|
||||
{label(item)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="status">
|
||||
<span>Model</span>
|
||||
<strong>{activeProvider?.name ?? "Not configured"}</strong>
|
||||
</div>
|
||||
<button className="primary" onClick={async () => setWorkspace(await coworkerApi.openWorkspace())}>
|
||||
Open project
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section className="content">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>{label(view)}</h1>
|
||||
<p>{subtitle(view, workspace)}</p>
|
||||
</div>
|
||||
{error ? <span className="error">{error}</span> : <span className="ok">Ready</span>}
|
||||
</header>
|
||||
|
||||
{view === "agent" && <AgentView state={state} workspace={workspace} onError={setError} />}
|
||||
{view === "files" && <FilesView workspace={workspace} onError={setError} />}
|
||||
{view === "models" && <ModelsView providers={state.providers} onState={setState} onError={setError} />}
|
||||
{view === "brain" && <BrainView items={state.brain} onState={setState} onError={setError} />}
|
||||
{view === "settings" && <SettingsView state={state} onState={setState} onError={setError} />}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentView({ state, workspace, onError }: { state: AppState; workspace?: WorkspaceState; onError: (value: string) => void }) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const provider = state.providers.find((item) => item.enabled) ?? state.providers[0];
|
||||
const model = provider?.defaults.coding ?? provider?.defaults.chat;
|
||||
|
||||
async function run() {
|
||||
if (!prompt.trim()) return;
|
||||
setRunning(true);
|
||||
const userMessage: ChatMessage = {
|
||||
id: `local_${Date.now()}`,
|
||||
role: "user",
|
||||
content: prompt,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages((current) => [...current, userMessage]);
|
||||
setPrompt("");
|
||||
try {
|
||||
const result = await coworkerApi.runAgent({ prompt, providerId: provider?.id, model, workspace, messages });
|
||||
setMessages((current) => [...current, result.assistantMessage]);
|
||||
onError("");
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Agent failed.");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-grid">
|
||||
<section className="conversation">
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty">
|
||||
<h2>Start a coding session</h2>
|
||||
<p>Open a project, pick a local model, and ask Coworker to inspect, plan, or propose changes.</p>
|
||||
</div>
|
||||
) : messages.map((message) => (
|
||||
<article key={message.id} className={`message ${message.role}`}>
|
||||
<span>{message.role}</span>
|
||||
<pre>{message.content}</pre>
|
||||
</article>
|
||||
))}
|
||||
<div className="composer">
|
||||
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder="Ask Coworker to work on this project..." />
|
||||
<button className="primary" disabled={running} onClick={() => void run()}>{running ? "Running" : "Send"}</button>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="inspector">
|
||||
<h2>Session</h2>
|
||||
<dl>
|
||||
<dt>Project</dt>
|
||||
<dd>{workspace?.name ?? "None"}</dd>
|
||||
<dt>Provider</dt>
|
||||
<dd>{provider?.name ?? "None"}</dd>
|
||||
<dt>Model</dt>
|
||||
<dd>{model ?? "Choose in Models"}</dd>
|
||||
</dl>
|
||||
<h2>Brain recall</h2>
|
||||
<ul className="compact-list">
|
||||
{state.brain.filter((item) => item.pinned).slice(0, 6).map((item) => <li key={item.id}>{item.title}</li>)}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesView({ workspace, onError }: { workspace?: WorkspaceState; onError: (value: string) => void }) {
|
||||
const [files, setFiles] = useState<WorkspaceFile[]>([]);
|
||||
const [preview, setPreview] = useState("");
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
coworkerApi.listWorkspace(workspace.id).then(setFiles).catch((err) => onError(err instanceof Error ? err.message : "File list failed."));
|
||||
}, [workspace, onError]);
|
||||
return (
|
||||
<div className="split">
|
||||
<section className="list-panel">
|
||||
{files.map((file) => (
|
||||
<button key={file.path} onClick={async () => {
|
||||
if (file.type === "directory" || !workspace) return;
|
||||
setPreview(await coworkerApi.readWorkspace(workspace.id, file.path));
|
||||
}}>
|
||||
<span>{file.type === "directory" ? "DIR" : "FILE"}</span>
|
||||
{file.path}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
<pre className="preview">{preview || "Open a text file to preview it."}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsView({ providers, onState, onError }: { providers: ModelProvider[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
|
||||
const [draft, setDraft] = useState<ModelProvider>(() => createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
|
||||
const [probe, setProbe] = useState("");
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section>
|
||||
<h2>Providers</h2>
|
||||
<div className="provider-list">
|
||||
{providers.map((provider) => (
|
||||
<article key={provider.id}>
|
||||
<strong>{provider.name}</strong>
|
||||
<span>{provider.baseUrl}</span>
|
||||
<button onClick={async () => {
|
||||
const result = await coworkerApi.probeProvider(provider.id);
|
||||
setProbe(`${result.message} ${result.models.join(", ")}`);
|
||||
}}>Test</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Add endpoint</h2>
|
||||
<input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} />
|
||||
<input value={draft.baseUrl} onChange={(event) => setDraft({ ...draft, baseUrl: event.target.value })} />
|
||||
<button className="primary" onClick={async () => {
|
||||
try {
|
||||
onState(await coworkerApi.saveProvider(draft));
|
||||
setDraft(createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Provider save failed.");
|
||||
}
|
||||
}}>Save provider</button>
|
||||
<p className="muted">{probe || "Ollama and LM Studio defaults are created on first launch."}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrainView({ items, onState, onError }: { items: BrainItem[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [kind, setKind] = useState<"memory" | "skill">("memory");
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section>
|
||||
<h2>Brain</h2>
|
||||
<div className="brain-list">
|
||||
{items.map((item) => (
|
||||
<article key={item.id}>
|
||||
<span>{displayBrainItem(item)}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.body}</p>
|
||||
<button onClick={async () => onState(await coworkerApi.removeBrain(item.id))}>Remove</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Add Brain item</h2>
|
||||
<div className="segmented">
|
||||
<button className={kind === "memory" ? "active" : ""} onClick={() => setKind("memory")}>Memory</button>
|
||||
<button className={kind === "skill" ? "active" : ""} onClick={() => setKind("skill")}>Skill</button>
|
||||
</div>
|
||||
<input value={title} placeholder="Title" onChange={(event) => setTitle(event.target.value)} />
|
||||
<textarea value={body} placeholder="What should Coworker remember or know how to do?" onChange={(event) => setBody(event.target.value)} />
|
||||
<button className="primary" onClick={async () => {
|
||||
try {
|
||||
onState(await coworkerApi.addBrain({ kind, scope: "global", title, body, pinned: kind === "skill" }));
|
||||
setTitle("");
|
||||
setBody("");
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Brain save failed.");
|
||||
}
|
||||
}}>Save</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsView({ state, onState, onError }: { state: AppState; onState: (state: AppState) => void; onError: (value: string) => void }) {
|
||||
const [settings, setSettings] = useState(state.webAccess);
|
||||
const [pairing, setPairing] = useState("");
|
||||
const url = useMemo(() => `http://${settings.bindHost === "0.0.0.0" ? "localhost" : settings.bindHost}:${settings.port}`, [settings]);
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section>
|
||||
<h2>Web access</h2>
|
||||
<label className="switch"><input type="checkbox" checked={settings.enabled} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /> Enable Web/PWA access</label>
|
||||
<label>Bind host</label>
|
||||
<select value={settings.bindHost} onChange={(event) => setSettings({ ...settings, bindHost: event.target.value as "127.0.0.1" | "0.0.0.0" })}>
|
||||
<option value="127.0.0.1">Localhost only</option>
|
||||
<option value="0.0.0.0">LAN / Tailscale</option>
|
||||
</select>
|
||||
<label>Port</label>
|
||||
<input type="number" value={settings.port} min={1024} max={65535} onChange={(event) => setSettings({ ...settings, port: Number(event.target.value) })} />
|
||||
<button className="primary" onClick={async () => {
|
||||
try {
|
||||
onState(await coworkerApi.saveWebSettings(settings));
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Web access update failed.");
|
||||
}
|
||||
}}>Apply</button>
|
||||
<p className="muted">Open {url} on iPhone or iPad after enabling LAN access.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Pair device</h2>
|
||||
<button onClick={async () => {
|
||||
const challenge = await coworkerApi.createPairing();
|
||||
setPairing(`${challenge.code} expires ${new Date(challenge.expiresAt).toLocaleTimeString()}`);
|
||||
}}>Create pairing code</button>
|
||||
<div className="pairing-code">{pairing || "No active code"}</div>
|
||||
<p className="muted">The mobile webapp stores a scoped device session after pairing.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function label(view: View) {
|
||||
return ({ agent: "Agent", models: "Models", brain: "Brain", files: "Files", settings: "Settings" })[view];
|
||||
}
|
||||
|
||||
function subtitle(view: View, workspace?: WorkspaceState) {
|
||||
if (view === "agent") return workspace ? `Working in ${workspace.name}` : "Open a project to start.";
|
||||
if (view === "models") return "Connect Ollama, LM Studio, or OpenAI-compatible endpoints.";
|
||||
if (view === "brain") return "Persistent memory, project knowledge, and reusable skills.";
|
||||
if (view === "files") return workspace ? workspace.rootPath : "Open a project to inspect files.";
|
||||
return "Desktop, web access, pairing, and release-safe defaults.";
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
461
apps/desktop/src/styles.css
Normal file
461
apps/desktop/src/styles.css
Normal file
@@ -0,0 +1,461 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #111317;
|
||||
color: #f4f6f8;
|
||||
--bg: #111317;
|
||||
--panel: #181b20;
|
||||
--panel-2: #20242b;
|
||||
--line: #303640;
|
||||
--muted: #9ba6b3;
|
||||
--text: #f4f6f8;
|
||||
--accent: #7dd3fc;
|
||||
--accent-2: #84cc16;
|
||||
--danger: #fb7185;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #0f1115;
|
||||
color: var(--text);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: #0e1014;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #15202a;
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand span,
|
||||
.muted,
|
||||
.status span,
|
||||
.message span,
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav button {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav button.active,
|
||||
.segmented button.active {
|
||||
border-color: var(--accent);
|
||||
background: #142333;
|
||||
}
|
||||
|
||||
.status {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: var(--panel);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.status strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border-color: #24506a;
|
||||
background: #123047;
|
||||
color: #e6f7ff;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: 86px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ok,
|
||||
.error {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.agent-grid,
|
||||
.settings-grid,
|
||||
.split {
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.agent-grid {
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.split {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
|
||||
}
|
||||
|
||||
.conversation,
|
||||
.inspector,
|
||||
.settings-grid section,
|
||||
.list-panel,
|
||||
.preview {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.conversation {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
max-width: 520px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #12161c;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
background: #151d20;
|
||||
}
|
||||
|
||||
.message pre,
|
||||
.preview {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
font-family: "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
min-height: 58px;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.inspector,
|
||||
.settings-grid section {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 86px minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.provider-list,
|
||||
.brain-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.provider-list article,
|
||||
.brain-list article {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: #12161c;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-list span,
|
||||
.brain-list span {
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.brain-list p {
|
||||
margin-bottom: 0;
|
||||
color: #d6dbe1;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-grid section > * + * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.pairing-code {
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #101319;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 20px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.list-panel button {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-panel span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-row: 2;
|
||||
border-right: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px max(8px, env(safe-area-inset-left)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.brand,
|
||||
.status,
|
||||
.sidebar .primary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav button {
|
||||
min-height: 48px;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: 72px;
|
||||
padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
|
||||
}
|
||||
|
||||
.topbar p,
|
||||
.ok,
|
||||
.error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.agent-grid,
|
||||
.settings-grid,
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.inspector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.composer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.split {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
13
apps/desktop/tsconfig.electron.json
Normal file
13
apps/desktop/tsconfig.electron.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": false,
|
||||
"outDir": "dist-electron",
|
||||
"rootDir": "electron",
|
||||
"types": ["node"],
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["electron/**/*.ts"]
|
||||
}
|
||||
16
apps/desktop/tsconfig.json
Normal file
16
apps/desktop/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@coworker/core": ["../../packages/core/src"],
|
||||
"@coworker/core/*": ["../../packages/core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"electron/**/*.ts",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
21
apps/desktop/vite.config.ts
Normal file
21
apps/desktop/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@coworker/core": fileURLToPath(new URL("../../packages/core/src", import.meta.url))
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true
|
||||
}
|
||||
});
|
||||
17
docs/agent-handoff.md
Normal file
17
docs/agent-handoff.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Agent Handoff
|
||||
|
||||
## Current State
|
||||
|
||||
Coworker was scaffolded from an empty repository as an Electron + React + TypeScript app with a shared core package.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Push the scaffold to Gitea.
|
||||
- Let Gitea Actions install dependencies, run checks, build the app, and produce the first Windows NSIS installer.
|
||||
- If the installer workflow fails, inspect runner logs and fix the workflow or dependency issue in scope.
|
||||
|
||||
## Known Risks
|
||||
|
||||
- The first installer is unsigned.
|
||||
- MSI packaging is intentionally deferred until the NSIS `.exe` pipeline is stable.
|
||||
- Full model downloading/serving is deferred; v1 connects to Ollama, LM Studio, and custom OpenAI-compatible endpoints.
|
||||
10
docs/release-checklist.md
Normal file
10
docs/release-checklist.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Release Checklist
|
||||
|
||||
- [ ] `CHANGELOG.md` contains the release entry.
|
||||
- [ ] Gitea build workflow passed.
|
||||
- [ ] Release dry-run workflow passed.
|
||||
- [ ] `release/Coworker-Setup-<version>.exe` exists.
|
||||
- [ ] `release/SHA256SUMS.txt` exists and matches the installer.
|
||||
- [ ] Installer does not include repository maintenance files such as `.codex/`, `.gitea/`, or `docs/agent-handoff.md`.
|
||||
- [ ] Release notes mention that the first installer is unsigned.
|
||||
- [ ] Tag uses `v<version>`.
|
||||
17
docs/release-notes.md
Normal file
17
docs/release-notes.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Release Notes
|
||||
|
||||
## Coworker 0.1.0
|
||||
|
||||
Initial Windows-first local AI coding app scaffold.
|
||||
|
||||
Highlights:
|
||||
|
||||
- Electron desktop app with Codex-style workspace shell.
|
||||
- Local model provider settings for Ollama, LM Studio, and custom endpoints.
|
||||
- Brain memory and skills.
|
||||
- Optional paired Web/PWA access for iPhone and iPad.
|
||||
- Gitea Actions pipeline for Windows setup `.exe`.
|
||||
|
||||
Known limitation:
|
||||
|
||||
- Installer is unsigned until code signing is configured.
|
||||
19
docs/security-review.md
Normal file
19
docs/security-review.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Security Review
|
||||
|
||||
## Current State
|
||||
|
||||
Coworker starts from a local-first security model:
|
||||
|
||||
- Web/PWA access is opt-in.
|
||||
- Pairing tokens are hashed before storage.
|
||||
- Workspace file access is path-confined.
|
||||
- Model endpoints are user-configured local or OpenAI-compatible URLs.
|
||||
|
||||
## Required Checks Before Release
|
||||
|
||||
- Verify web access is disabled by default.
|
||||
- Verify unauthenticated web API requests return `401`.
|
||||
- Verify paired device tokens are not stored in plaintext.
|
||||
- Verify workspace reads reject path traversal.
|
||||
- Verify build artifacts exclude local app data and repository maintenance files.
|
||||
- Run scheduled security scan in Gitea Actions.
|
||||
77
package.json
Normal file
77
package.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "coworker",
|
||||
"version": "0.1.0",
|
||||
"description": "Windows-first local AI coding coworker with local model providers, Brain memory, and mobile PWA access.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.wilkensxl.de/Code-Inc/Coworker.git"
|
||||
},
|
||||
"author": "Code-Inc",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"main": "apps/desktop/dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --config apps/desktop/vite.config.ts",
|
||||
"lint": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p apps/desktop/tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -p packages/core/tsconfig.json && tsc -p apps/desktop/tsconfig.electron.json && vite build --config apps/desktop/vite.config.ts",
|
||||
"package:win": "npm run build && electron-builder --win nsis --x64",
|
||||
"release:check": "npm run lint && npm run test && npm run build",
|
||||
"audit": "npm audit --omit=dev --audit-level=high"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coworker/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"electron": "^35.0.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"vitest": "^3.2.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "de.codeinc.coworker",
|
||||
"productName": "Coworker",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"apps/desktop/dist/**/*",
|
||||
"apps/desktop/dist-electron/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraMetadata": {
|
||||
"main": "apps/desktop/dist-electron/main.js"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "Coworker-Setup-${version}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
}
|
||||
}
|
||||
10
packages/core/package.json
Normal file
10
packages/core/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@coworker/core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
}
|
||||
}
|
||||
60
packages/core/src/agent.ts
Normal file
60
packages/core/src/agent.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AgentRunRequest, AgentRunResult, ChatMessage } from "./types";
|
||||
import { recallBrain } from "./brain";
|
||||
import { createId, nowIso } from "./ids";
|
||||
|
||||
export async function runAgent(
|
||||
request: AgentRunRequest,
|
||||
completion: (messages: ChatMessage[]) => Promise<string>
|
||||
): Promise<AgentRunResult> {
|
||||
const recalled = recallBrain(request.brain, request.prompt, 8);
|
||||
const systemContent = [
|
||||
"You are Coworker, a local-first coding assistant.",
|
||||
"Show proposed file changes as unified diffs and wait for user approval before writes.",
|
||||
request.workspace ? `Current workspace: ${request.workspace.name}` : "No workspace is open.",
|
||||
recalled.length > 0
|
||||
? `Recalled Brain items:\n${recalled.map((r) => `- ${r.item.title}: ${r.item.body}`).join("\n")}`
|
||||
: "No Brain items were recalled."
|
||||
].join("\n\n");
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: createId("msg"), role: "system", content: systemContent, createdAt: nowIso() },
|
||||
...request.messages,
|
||||
{ id: createId("msg"), role: "user", content: request.prompt, createdAt: nowIso() }
|
||||
];
|
||||
|
||||
const content = await completion(messages);
|
||||
return {
|
||||
assistantMessage: {
|
||||
id: createId("msg"),
|
||||
role: "assistant",
|
||||
content,
|
||||
createdAt: nowIso()
|
||||
},
|
||||
recalled,
|
||||
proposedDiffs: extractDiffs(content),
|
||||
toolLog: [
|
||||
{
|
||||
id: createId("log"),
|
||||
level: "info",
|
||||
message: `Recalled ${recalled.length} Brain item(s).`,
|
||||
createdAt: nowIso()
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function extractDiffs(content: string) {
|
||||
const blocks = [...content.matchAll(/```(?:diff|patch)\n([\s\S]*?)```/g)];
|
||||
return blocks.map((block, index) => ({
|
||||
id: createId("diff"),
|
||||
filePath: inferDiffFile(block[1]) ?? `proposed-change-${index + 1}.patch`,
|
||||
title: `Proposed change ${index + 1}`,
|
||||
patch: block[1].trim(),
|
||||
applied: false
|
||||
}));
|
||||
}
|
||||
|
||||
function inferDiffFile(patch: string): string | undefined {
|
||||
const match = patch.match(/^\+\+\+\s+b\/(.+)$/m);
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
26
packages/core/src/brain.test.ts
Normal file
26
packages/core/src/brain.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBrainItem, recallBrain } from "./brain";
|
||||
|
||||
describe("Brain recall", () => {
|
||||
it("returns matching pinned and semantic keyword items", () => {
|
||||
const items = [
|
||||
createBrainItem({
|
||||
kind: "memory",
|
||||
scope: "global",
|
||||
title: "Model preference",
|
||||
body: "User prefers local Ollama models for coding.",
|
||||
pinned: true
|
||||
}),
|
||||
createBrainItem({
|
||||
kind: "skill",
|
||||
scope: "project",
|
||||
title: "Diff workflow",
|
||||
body: "Always present patches before writing files."
|
||||
})
|
||||
];
|
||||
|
||||
const recalled = recallBrain(items, "Use ollama and patches");
|
||||
expect(recalled.map((r) => r.item.title)).toContain("Model preference");
|
||||
expect(recalled.map((r) => r.item.title)).toContain("Diff workflow");
|
||||
});
|
||||
});
|
||||
76
packages/core/src/brain.ts
Normal file
76
packages/core/src/brain.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BrainItem, BrainItemKind, BrainRecall, BrainScope } from "./types";
|
||||
import { createId, nowIso } from "./ids";
|
||||
|
||||
export interface BrainInput {
|
||||
kind: BrainItemKind;
|
||||
scope: BrainScope;
|
||||
title: string;
|
||||
body: string;
|
||||
tags?: string[];
|
||||
projectId?: string;
|
||||
sessionId?: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export function createBrainItem(input: BrainInput): BrainItem {
|
||||
const createdAt = nowIso();
|
||||
return {
|
||||
id: createId(input.kind),
|
||||
kind: input.kind,
|
||||
scope: input.scope,
|
||||
projectId: input.projectId,
|
||||
sessionId: input.sessionId,
|
||||
title: input.title.trim(),
|
||||
body: input.body.trim(),
|
||||
tags: normalizeTags(input.tags ?? []),
|
||||
pinned: Boolean(input.pinned),
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
}
|
||||
|
||||
export function updateBrainItem(item: BrainItem, patch: Partial<BrainInput>): BrainItem {
|
||||
return {
|
||||
...item,
|
||||
title: patch.title === undefined ? item.title : patch.title.trim(),
|
||||
body: patch.body === undefined ? item.body : patch.body.trim(),
|
||||
tags: patch.tags === undefined ? item.tags : normalizeTags(patch.tags),
|
||||
pinned: patch.pinned === undefined ? item.pinned : Boolean(patch.pinned),
|
||||
updatedAt: nowIso()
|
||||
};
|
||||
}
|
||||
|
||||
export function recallBrain(items: BrainItem[], query: string, limit = 8): BrainRecall[] {
|
||||
const terms = tokenize(query);
|
||||
if (terms.length === 0) {
|
||||
return items.filter((item) => item.pinned).slice(0, limit).map((item) => ({
|
||||
item,
|
||||
score: 1,
|
||||
matchedTerms: ["pinned"]
|
||||
}));
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const haystack = tokenize(`${item.title} ${item.body} ${item.tags.join(" ")}`);
|
||||
const matchedTerms = terms.filter((term) => haystack.includes(term));
|
||||
const pinBoost = item.pinned ? 0.5 : 0;
|
||||
const skillBoost = item.kind === "skill" ? 0.2 : 0;
|
||||
return {
|
||||
item,
|
||||
score: matchedTerms.length + pinBoost + skillBoost,
|
||||
matchedTerms
|
||||
};
|
||||
})
|
||||
.filter((recall) => recall.score > 0)
|
||||
.sort((a, b) => b.score - a.score || b.item.updatedAt.localeCompare(a.item.updatedAt))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function normalizeTags(tags: string[]): string[] {
|
||||
return Array.from(new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean))).sort();
|
||||
}
|
||||
|
||||
export function tokenize(text: string): string[] {
|
||||
return Array.from(new Set(text.toLowerCase().match(/[a-z0-9_äöüß-]{2,}/gi) ?? []));
|
||||
}
|
||||
9
packages/core/src/ids.ts
Normal file
9
packages/core/src/ids.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function createId(prefix: string): string {
|
||||
const random = Math.random().toString(36).slice(2, 10);
|
||||
const time = Date.now().toString(36);
|
||||
return `${prefix}_${time}_${random}`;
|
||||
}
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
6
packages/core/src/index.ts
Normal file
6
packages/core/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./agent";
|
||||
export * from "./brain";
|
||||
export * from "./ids";
|
||||
export * from "./pairing";
|
||||
export * from "./providers";
|
||||
export * from "./types";
|
||||
30
packages/core/src/pairing.ts
Normal file
30
packages/core/src/pairing.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PairedDevice, PairingChallenge } from "./types";
|
||||
import { createId, nowIso } from "./ids";
|
||||
|
||||
export function createPairingChallenge(ttlMs = 5 * 60 * 1000): PairingChallenge {
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
return {
|
||||
id: createId("pair"),
|
||||
code,
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPairedDevice(
|
||||
name: string,
|
||||
token: string,
|
||||
digest: (value: string) => Promise<string>
|
||||
): Promise<PairedDevice> {
|
||||
return {
|
||||
id: createId("device"),
|
||||
name: name.trim() || "Paired device",
|
||||
tokenHash: await digest(token),
|
||||
createdAt: nowIso()
|
||||
};
|
||||
}
|
||||
|
||||
export function isPairingChallengeValid(challenge: PairingChallenge | undefined, code: string): boolean {
|
||||
if (!challenge) return false;
|
||||
if (challenge.code !== code.trim()) return false;
|
||||
return Date.parse(challenge.expiresAt) > Date.now();
|
||||
}
|
||||
65
packages/core/src/providers.ts
Normal file
65
packages/core/src/providers.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ModelProvider, ProviderProbeResult } from "./types";
|
||||
import { createId, nowIso } from "./ids";
|
||||
|
||||
export function createProvider(
|
||||
kind: ModelProvider["kind"],
|
||||
name: string,
|
||||
baseUrl: string
|
||||
): ModelProvider {
|
||||
const createdAt = nowIso();
|
||||
return {
|
||||
id: createId("provider"),
|
||||
name: name.trim(),
|
||||
kind,
|
||||
baseUrl: normalizeBaseUrl(baseUrl),
|
||||
enabled: true,
|
||||
supportsTools: kind !== "ollama",
|
||||
contextWindow: 8192,
|
||||
defaults: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export async function probeOpenAiCompatibleProvider(
|
||||
provider: ModelProvider,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ProviderProbeResult> {
|
||||
const url = `${normalizeBaseUrl(provider.baseUrl)}/models`;
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
headers: provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : undefined
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: provider.id,
|
||||
models: [],
|
||||
message: `Provider returned HTTP ${response.status}`
|
||||
};
|
||||
}
|
||||
const body = await response.json() as { data?: Array<{ id?: string }>; models?: string[] };
|
||||
const models = Array.isArray(body.data)
|
||||
? body.data.map((model) => model.id).filter(Boolean) as string[]
|
||||
: Array.isArray(body.models)
|
||||
? body.models
|
||||
: [];
|
||||
return {
|
||||
ok: models.length > 0,
|
||||
providerId: provider.id,
|
||||
models,
|
||||
message: models.length > 0 ? "Provider is reachable." : "Provider responded without model ids."
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: provider.id,
|
||||
models: [],
|
||||
message: error instanceof Error ? error.message : "Provider probe failed."
|
||||
};
|
||||
}
|
||||
}
|
||||
129
packages/core/src/types.ts
Normal file
129
packages/core/src/types.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
export type ModelProviderKind = "ollama" | "lmstudio" | "openai-compatible";
|
||||
|
||||
export type ModelPurpose = "chat" | "coding" | "reasoning" | "embedding";
|
||||
|
||||
export interface ModelProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ModelProviderKind;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
enabled: boolean;
|
||||
supportsTools: boolean;
|
||||
contextWindow: number;
|
||||
defaults: Partial<Record<ModelPurpose, string>>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderProbeResult {
|
||||
ok: boolean;
|
||||
providerId: string;
|
||||
models: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type BrainScope = "global" | "project" | "session";
|
||||
export type BrainItemKind = "memory" | "skill";
|
||||
|
||||
export interface BrainItem {
|
||||
id: string;
|
||||
kind: BrainItemKind;
|
||||
scope: BrainScope;
|
||||
projectId?: string;
|
||||
sessionId?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
pinned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BrainRecall {
|
||||
item: BrainItem;
|
||||
score: number;
|
||||
matchedTerms: string[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceFile {
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceState {
|
||||
id: string;
|
||||
name: string;
|
||||
rootPath: string;
|
||||
openedAt: string;
|
||||
}
|
||||
|
||||
export interface AgentRunRequest {
|
||||
prompt: string;
|
||||
workspace?: WorkspaceState;
|
||||
provider?: ModelProvider;
|
||||
model?: string;
|
||||
messages: ChatMessage[];
|
||||
brain: BrainItem[];
|
||||
}
|
||||
|
||||
export interface AgentRunResult {
|
||||
assistantMessage: ChatMessage;
|
||||
recalled: BrainRecall[];
|
||||
proposedDiffs: ProposedDiff[];
|
||||
toolLog: ToolLogEntry[];
|
||||
}
|
||||
|
||||
export interface ProposedDiff {
|
||||
id: string;
|
||||
filePath: string;
|
||||
title: string;
|
||||
patch: string;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
export interface ToolLogEntry {
|
||||
id: string;
|
||||
level: "info" | "warning" | "error";
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WebAccessSettings {
|
||||
enabled: boolean;
|
||||
bindHost: "127.0.0.1" | "0.0.0.0";
|
||||
port: number;
|
||||
requirePairing: boolean;
|
||||
}
|
||||
|
||||
export interface PairedDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
createdAt: string;
|
||||
lastSeenAt?: string;
|
||||
revokedAt?: string;
|
||||
}
|
||||
|
||||
export interface PairingChallenge {
|
||||
id: string;
|
||||
code: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
providers: ModelProvider[];
|
||||
brain: BrainItem[];
|
||||
workspaces: WorkspaceState[];
|
||||
webAccess: WebAccessSettings;
|
||||
pairedDevices: PairedDevice[];
|
||||
}
|
||||
13
packages/core/tsconfig.json
Normal file
13
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
19
tsconfig.base.json
Normal file
19
tsconfig.base.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "packages/core" },
|
||||
{ "path": "apps/desktop" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user