Compare commits

...

2 Commits

Author SHA1 Message Date
35d7c3e710 step 1.6 complete: migrate embeddings to backend
- upsertNoteEmbedding() calls BackendClient.embedText() instead of local LangChain
- Offline graceful degradation: skip embedding with warning, retry on next save
- searchNotes() embeds queries via backend client
- Add searchNotesByVector() for pre-computed vector search (used by DrizzleExecutor)
- drizzle-executor: vector_search now uses searchNotesByVector with backend-provided vector
- Delete src/main/ai/embeddings.ts (LangChain OpenAIEmbeddings removed)
2026-03-05 00:27:49 +01:00
89df7e48ad step 1.5 complete: refactor orchestrator to delegate to backend
- Replace 996-line LangGraph orchestrator with ~190-line backend-delegation layer
- orchestrate() checks online/auth → builds ChatContext from SQLite → delegates to BackendClient.chatStream()
- Remove setToken, hasToken from aiRouter (replaced by auth.status)
- AIChatPanel: trpc.ai.hasToken → trpc.auth.status, update auth-gate messaging
- AppShell: remove Copilot token dialog, replace with auth-status placeholder
2026-03-05 00:23:46 +01:00
8 changed files with 188 additions and 1044 deletions

View File

@@ -157,7 +157,7 @@ Electron generates `id` (UUID v4) and `createdAt`/`updatedAt` (Unix ms) for inse
- **Outcome:** ~120 lines. Backend sends structured ops, Electron maps to Drizzle. No SQL building.
### Step 1.5 — Refactor orchestrator to delegate to backend
- [ ] Replace `src/main/ai/orchestrator.ts` entirely (996 lines → ~80 lines):
- [x] Replace `src/main/ai/orchestrator.ts` entirely (996 lines → ~190 lines):
- `orchestrate({ message, context, sender })`:
1. Check `BackendClient.isOnline()` — if offline, return `{ response: '', error: 'You are offline.' }`
2. Check `AuthManager.isAuthenticated()` — if not, return `{ response: '', error: 'Please log in.' }`
@@ -168,22 +168,26 @@ Electron generates `id` (UUID v4) and `createdAt`/`updatedAt` (Unix ms) for inse
- No PlanRunner, no action handling — writes happen mid-conversation via tool calls
- Keep `sendStreamChunk()` IPC helper
- Export `orchestrate()` and `dailyBrief()`
- [ ] Update `aiRouter` in `src/main/router/index.ts`:
- [x] Update `aiRouter` in `src/main/router/index.ts`:
- Remove `setToken` mutation and `hasToken` query (replaced by `auth.status`)
- Keep `chat` mutation (same interface) and `dailyBrief`
- [ ] Update `src/renderer/hooks/useAIChat.ts`:
- Replace `ChatContext` with `UIChatContext` (renderer-only type)
- [x] Update `src/renderer/components/ai/AIChatPanel.tsx`:
- Replace `trpc.ai.hasToken.useQuery()` with `trpc.auth.status.useQuery()`
- Update auth-gate condition and daily brief trigger to use `authStatusQuery.data?.authenticated`
- Replace `KeyRound` icon + provider-config messaging with `LogIn` icon + login messaging
- **Files:** `src/main/ai/orchestrator.ts`, `src/main/router/index.ts`, `src/renderer/hooks/useAIChat.ts`
- **Outcome:** ~916 lines removed. Chat works through backend. All tool execution is bidirectional.
### Step 1.6 — Migrate embeddings to backend
- [ ] Update `src/main/db/vectordb.ts`:
- [x] Update `src/main/db/vectordb.ts`:
- Add `upsertWithVector(noteId, projectId, content, vector)` — takes pre-computed vector, stores in LanceDB
- Update `upsertNoteEmbedding()` → calls `BackendClient.embedText(content)` → `upsertWithVector()`
- Keep `searchNotes()` and `migrateNotesIfNeeded()` (migration will call backend for embeddings)
- If offline: skip embedding (next edit will re-embed when online)
- [ ] Delete `src/main/ai/embeddings.ts`
- **Files:** `src/main/db/vectordb.ts`, `src/main/ai/embeddings.ts` (deleted)
- Add `searchNotesByVector(vector, limit)` for direct pre-computed-vector search
- [x] Update `src/main/api/drizzle-executor.ts`: use `searchNotesByVector` with pre-computed vector from tool call payload
- [x] Delete `src/main/ai/embeddings.ts`
- **Files:** `src/main/db/vectordb.ts`, `src/main/api/drizzle-executor.ts`, `src/main/ai/embeddings.ts` (deleted)
- **Outcome:** Embeddings generated by backend `/vectors/embed`. Local LanceDB for storage + search.
---

View File

@@ -1,73 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getToken } from './token';
interface CopilotConfig {
copilot_tokens?: Record<string, string>;
}
/**
* Read the GitHub Copilot OAuth token from the CLI config file.
* Stored at ~/.copilot/config.json under copilot_tokens["{host}:{login}"].
* Returns the first available token, or null if unavailable.
*/
function readCopilotToken(): string | null {
try {
const raw = fs.readFileSync(
path.join(os.homedir(), '.copilot', 'config.json'),
'utf-8',
);
const cfg = JSON.parse(raw) as CopilotConfig;
const vals = Object.values(cfg.copilot_tokens ?? {});
return vals[0] ?? null;
} catch {
return null;
}
}
/**
* Embed a single text string using the best available credentials.
*
* Priority:
* 1. GitHub Copilot CLI token → OpenAI-compatible embeddings endpoint at
* https://api.githubcopilot.com
* 2. Stored OpenAI token → standard OpenAI embeddings API
*
* Throws if no credentials are available or the API call fails.
* Callers must .catch() this and handle the error without rejecting
* the surrounding tRPC mutation.
*/
export async function embedText(text: string): Promise<number[]> {
const { OpenAIEmbeddings } = await import('@langchain/openai');
const copilotToken = readCopilotToken();
let embeddingsInstance;
if (copilotToken) {
embeddingsInstance = new OpenAIEmbeddings({
apiKey: copilotToken,
model: 'text-embedding-3-small',
configuration: { baseURL: 'https://api.githubcopilot.com' },
});
} else {
const openaiToken = await getToken('openai');
if (!openaiToken) {
throw new Error(
'[Embeddings] No credentials available. Authenticate with Copilot CLI or add an OpenAI token in Settings.',
);
}
embeddingsInstance = new OpenAIEmbeddings({
apiKey: openaiToken,
model: 'text-embedding-3-small',
});
}
// embedDocuments returns number[][] — cast explicitly to satisfy strict TS
const results = (await embeddingsInstance.embedDocuments([text])) as number[][];
const vector = results[0] as number[] | undefined;
if (!vector || vector.length === 0) {
throw new Error('[Embeddings] Empty vector returned from embedding API');
}
return vector;
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
import { eq, and, or, like, isNull, asc, desc, gte, lte, SQL } from 'drizzle-orm';
import { getDb } from '../db';
import { tasks, projects, clients, checkpoints, notes, taskComments } from '../db/schema';
import { searchNotes, upsertWithVector } from '../db/vectordb';
import { searchNotesByVector, upsertWithVector } from '../db/vectordb';
import type { WsToolCall } from '../../shared/api-types';
// ---------------------------------------------------------------------------
@@ -281,14 +281,9 @@ export class DrizzleExecutor {
throw new ExecutorError('vector_search requires a vector');
}
// searchNotes accepts a query string and embeds it — for pre-vectorised search
// we call LanceDB directly via the vectordb internals. For now, use the
// payload data.query string as a fallback. A full implementation will be
// wired up in Step 1.6 when embedText is migrated to the backend.
const data = (payload.data ?? {}) as Record<string, unknown>;
const query = (data['query'] as string | undefined) ?? '';
const rawResults = await searchNotes(query, limit);
// Use the pre-computed vector sent by the backend directly — no local
// embedding needed now that embedText is migrated to the backend (Step 1.6).
const rawResults = await searchNotesByVector(vec, limit);
const results = rawResults.map((r) => ({
id: r.id,
content: r.content,

View File

@@ -3,7 +3,7 @@ import { app } from 'electron';
import path from 'node:path';
import { getDb } from './index';
import { notes } from './schema';
import { embedText } from '../ai/embeddings';
import { BackendClient, OfflineError } from '../api/backend-client';
interface NoteRecord {
id: string;
@@ -55,7 +55,17 @@ export async function upsertNoteEmbedding(
content: string,
): Promise<void> {
const c = getConn();
const vector = await embedText(content);
let vector: number[];
try {
vector = await BackendClient.getInstance().embedText(content);
} catch (err) {
if (err instanceof OfflineError) {
console.warn('[VectorDB] Offline — skipping embedding for note', noteId);
return;
}
throw err;
}
const record: NoteRecord = {
id: noteId,
@@ -169,7 +179,7 @@ export async function searchNotes(query: string, limit = 5): Promise<SearchResul
return [];
}
const queryVector = await embedText(query);
const queryVector = await BackendClient.getInstance().embedText(query);
const table = await c.openTable('notes');
const results = await table.search(queryVector).limit(limit).execute();
@@ -180,3 +190,27 @@ export async function searchNotes(query: string, limit = 5): Promise<SearchResul
_distance: r._distance as number,
}));
}
/**
* Perform a similarity search using a **pre-computed** vector.
* Used by the DrizzleExecutor for `vector_search` tool calls where the
* backend already embedded the query server-side.
*/
export async function searchNotesByVector(vector: number[], limit = 5): Promise<SearchResult[]> {
const c = getConn();
const tableNames = await c.tableNames();
if (!tableNames.includes('notes')) {
return [];
}
const table = await c.openTable('notes');
const results = await table.search(vector).limit(limit).execute();
return results.map((r) => ({
id: r.id as string,
projectId: r.projectId as string,
content: r.content as string,
_distance: r._distance as number,
}));
}

View File

@@ -5,7 +5,6 @@ import { alias } from 'drizzle-orm/sqlite-core';
import { getDb } from '../db';
import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema';
import { getStore } from '../store';
import { saveTokenAndInit, hasActiveToken } from '../ai/provider';
import { orchestrate, dailyBrief } from '../ai/orchestrator';
import { upsertNoteEmbedding } from '../db/vectordb';
import { getAuthManager, AuthError } from '../auth/auth-manager';
@@ -573,12 +572,6 @@ const aiRouter = router({
return { response: '', error: msg };
}
}),
setToken: publicProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ input }) => {
await saveTokenAndInit(input.token);
return { success: true };
}),
dailyBrief: publicProcedure
.mutation(async ({ ctx }) => {
try {
@@ -588,9 +581,6 @@ const aiRouter = router({
return { response: '', error: msg };
}
}),
hasToken: publicProcedure.query(async () => {
return hasActiveToken();
}),
});
// ---------------------------------------------------------------------------

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import { Sparkles, LogIn, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { motion, AnimatePresence } from 'framer-motion';
@@ -51,7 +51,7 @@ export function AIChatPanel({
onOpenSettings,
isHomePage,
}: AIChatPanelProps) {
const hasTokenQuery = trpc.ai.hasToken.useQuery();
const authStatusQuery = trpc.auth.status.useQuery();
// Home-specific queries
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
@@ -123,7 +123,7 @@ export function AIChatPanel({
// Auto-fire daily brief on home page
useEffect(() => {
if (!isHomePage || hasFiredBrief.current || hasTokenQuery.data !== true) return;
if (!isHomePage || hasFiredBrief.current || !authStatusQuery.data?.authenticated) return;
hasFiredBrief.current = true;
setBriefLoading(true);
@@ -152,7 +152,7 @@ export function AIChatPanel({
setBriefLoading(false);
},
});
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
}, [isHomePage, authStatusQuery.data?.authenticated]); // briefMutation excluded — only fire once
const handleSend = useCallback(() => {
if (briefLoading) return;
@@ -296,11 +296,11 @@ export function AIChatPanel({
{/* Daily brief */}
<motion.div variants={fadeUp} className="max-w-3xl">
{hasTokenQuery.data === false ? (
{authStatusQuery.data?.authenticated === false ? (
<div className="flex flex-col items-start gap-3 py-2">
<KeyRound size={20} className="text-muted-foreground" />
<LogIn size={20} className="text-muted-foreground" />
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Configure your AI provider in Settings to enable the daily brief.
Log in to your account to enable AI features.
</p>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
Open Settings

View File

@@ -50,7 +50,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
@@ -98,20 +97,9 @@ function AppShellInner({ children }: AppShellProps) {
setSidebarCollapsedMutation.mutate({ collapsed: !value });
};
// AI token dialog state (shared between sidebar gear menu and AIChatPanel prompt)
// AI settings dialog state
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [saved, setSaved] = useState(false);
const hasTokenQuery = trpc.ai.hasToken.useQuery();
const utils = trpc.useUtils();
const setTokenMutation = trpc.ai.setToken.useMutation({
onSuccess: () => {
setSaved(true);
setTokenInput('');
void utils.ai.hasToken.invalidate();
setTimeout(() => setSaved(false), 2000);
},
});
const authStatusQuery = trpc.auth.status.useQuery();
const isHomePage = currentPath === '/';
@@ -142,45 +130,20 @@ function AppShellInner({ children }: AppShellProps) {
{/* Floating AI Chat — portal to document.body */}
<FloatingChatPortal />
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
setTokenDialogOpen(open);
if (!open) { setTokenInput(''); setSaved(false); }
}}>
{/* AI Settings Dialog — full login/auth UI added in Step 6.1 */}
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI Provider</DialogTitle>
<DialogTitle>AI Features</DialogTitle>
<DialogDescription>
Configure your AI provider credentials for chat, summaries, and suggestions.
{authStatusQuery.data?.authenticated
? 'You are signed in. AI features are available.'
: 'Sign in to your account to enable AI chat, daily briefs, and suggestions.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-sm font-medium">GitHub Copilot Token</label>
<Input
type="password"
placeholder="Paste your token here"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Your token is stored securely in the OS keychain.
{hasTokenQuery.data === true && (
<span className="text-green-600 dark:text-green-400 ml-1">A token is currently stored.</span>
)}
</p>
</div>
<DialogFooter>
{saved && (
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400 mr-auto">
<Check size={14} />
Saved
</span>
)}
<Button
disabled={!tokenInput.trim() || setTokenMutation.isPending}
onClick={() => setTokenMutation.mutate({ token: tokenInput.trim() })}
>
{setTokenMutation.isPending ? 'Saving...' : 'Save Token'}
<Button variant="outline" onClick={() => setTokenDialogOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>