Compare commits
2 Commits
1f6e60d4a9
...
35d7c3e710
| Author | SHA1 | Date | |
|---|---|---|---|
| 35d7c3e710 | |||
| 89df7e48ad |
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user