feat: implement full context-scoped AI chat UI in AIChatPanel
- Added AIChatPanel component with context header, user and AI message handling. - Integrated streaming responses via IPC and error handling for chat mutations. - Enhanced user experience with input handling and auto-scrolling features. - Updated AppShell to derive AI chat context from the current route. - Introduced ScrollArea component for better scrolling behavior in various dialogs. - Added support for Tailwind typography and improved global styles. - Updated project and task dialogs to utilize ScrollArea for better UX.
This commit is contained in:
@@ -3,31 +3,43 @@
|
||||
*
|
||||
* Wraps the CopilotClient's session API so it can be used as a drop-in
|
||||
* BaseChatModel within LangGraph, making the orchestrator provider-agnostic.
|
||||
*
|
||||
* Accepts a client-getter function to avoid module duplication issues when
|
||||
* this file is code-split into a separate chunk by Vite.
|
||||
*/
|
||||
import { SimpleChatModel, type BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessageChunk } from '@langchain/core/messages';
|
||||
import { ChatGenerationChunk } from '@langchain/core/outputs';
|
||||
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
||||
import { getCopilotClient } from './copilot';
|
||||
|
||||
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
|
||||
|
||||
const COPILOT_TIMEOUT = 60_000;
|
||||
|
||||
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
||||
constructor() {
|
||||
private getClient: () => CopilotClientType | null;
|
||||
|
||||
constructor(getClient: () => CopilotClientType | null) {
|
||||
super({});
|
||||
this.getClient = getClient;
|
||||
}
|
||||
|
||||
_llmType(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
private requireClient(): CopilotClientType {
|
||||
const client = this.getClient();
|
||||
if (!client) {
|
||||
throw new Error('CopilotClient not initialized. Please check that Copilot CLI is authenticated (copilot auth login).');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
|
||||
const client = getCopilotClient();
|
||||
if (!client) {
|
||||
throw new Error('CopilotClient not initialized. Please add your GitHub token in Settings.');
|
||||
}
|
||||
const client = this.requireClient();
|
||||
|
||||
// Extract system message and user prompt from LangChain messages
|
||||
const systemContent = messages
|
||||
@@ -61,10 +73,7 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
||||
_options: this['ParsedCallOptions'],
|
||||
_runManager?: CallbackManagerForLLMRun,
|
||||
): AsyncGenerator<ChatGenerationChunk> {
|
||||
const client = getCopilotClient();
|
||||
if (!client) {
|
||||
throw new Error('CopilotClient not initialized. Please add your GitHub token in Settings.');
|
||||
}
|
||||
const client = this.requireClient();
|
||||
|
||||
const systemContent = messages
|
||||
.filter((m) => m._getType() === 'system')
|
||||
|
||||
@@ -5,14 +5,14 @@ import { registerProvider, type AIProvider } from './provider';
|
||||
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
|
||||
|
||||
let client: CopilotClientType | null = null;
|
||||
let token: string | null = null;
|
||||
let isReady = false;
|
||||
|
||||
const copilotProvider: AIProvider = {
|
||||
name: 'copilot',
|
||||
displayName: 'GitHub Copilot',
|
||||
usesExternalAuth: true,
|
||||
|
||||
async initialize(t: string): Promise<boolean> {
|
||||
token = t;
|
||||
async initialize(): Promise<boolean> {
|
||||
try {
|
||||
// Stop existing client if re-initializing
|
||||
if (client) {
|
||||
@@ -22,32 +22,30 @@ const copilotProvider: AIProvider = {
|
||||
}
|
||||
|
||||
const { CopilotClient } = await import('@github/copilot-sdk');
|
||||
// No githubToken — uses stored OAuth credentials from Copilot CLI
|
||||
// (authenticate first with `copilot auth login`)
|
||||
client = new CopilotClient({
|
||||
githubToken: t,
|
||||
autoStart: true,
|
||||
autoRestart: true,
|
||||
logLevel: 'warning',
|
||||
});
|
||||
await client.start();
|
||||
console.log('[AI] CopilotClient started successfully');
|
||||
isReady = true;
|
||||
console.log('[AI] CopilotClient started (using CLI OAuth credentials)');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[AI] Failed to start CopilotClient:', err);
|
||||
client = null;
|
||||
isReady = false;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
isReady(): boolean {
|
||||
return client !== null && token !== null;
|
||||
return isReady && client !== null;
|
||||
},
|
||||
};
|
||||
|
||||
/** Get the raw Copilot token (used by future chat/completion calls). */
|
||||
export function getCopilotToken(): string | null {
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Get the CopilotClient instance (null if not initialized). */
|
||||
export function getCopilotClient(): CopilotClientType | null {
|
||||
return client;
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* the only place that knows how to create provider-specific LLM instances.
|
||||
*/
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { getActiveProviderName } from './provider';
|
||||
import { getActiveProviderName, getActiveProvider } from './provider';
|
||||
import { getToken } from './token';
|
||||
import { getCopilotClient } from './copilot';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider-specific factory functions (lazy-loaded)
|
||||
@@ -36,8 +37,10 @@ async function createAnthropicModel(token: string): Promise<BaseChatModel> {
|
||||
async function createCopilotModel(_token: string): Promise<BaseChatModel> {
|
||||
// GitHub Copilot uses the Copilot SDK subprocess for auth and API access.
|
||||
// We wrap it in a LangChain-compatible adapter.
|
||||
// Pass getCopilotClient from this chunk (same as copilot.ts) to avoid
|
||||
// module duplication when chat-copilot.ts is code-split by Vite.
|
||||
const { ChatCopilot } = await import('./chat-copilot');
|
||||
return new ChatCopilot();
|
||||
return new ChatCopilot(getCopilotClient);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -62,14 +65,15 @@ export async function getLLM(): Promise<BaseChatModel | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = await getToken(providerName);
|
||||
if (!token) {
|
||||
const provider = getActiveProvider();
|
||||
const token = provider?.usesExternalAuth ? '' : await getToken(providerName);
|
||||
if (!provider?.usesExternalAuth && !token) {
|
||||
console.log(`[AI] No token available for provider "${providerName}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await factory(token);
|
||||
return await factory(token ?? '');
|
||||
} catch (err) {
|
||||
console.error(`[AI] Failed to create LLM for "${providerName}":`, err);
|
||||
return null;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
|
||||
import { SystemMessage, HumanMessage, type BaseMessage } from '@langchain/core/messages';
|
||||
import { z } from 'zod';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { projects, tasks, checkpoints, notes, clients } from '../db/schema';
|
||||
@@ -178,12 +177,6 @@ If the user asks about specific note contents that aren't included here, let the
|
||||
// LangGraph State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RouteSchema = z.object({
|
||||
route: z.enum(['project', 'knowledge', 'general']).describe(
|
||||
'Which specialist agent should handle this request',
|
||||
),
|
||||
});
|
||||
|
||||
const OrchestratorState = Annotation.Root({
|
||||
/** The user's original message */
|
||||
userMessage: Annotation<string>(),
|
||||
@@ -207,25 +200,29 @@ type State = typeof OrchestratorState.State;
|
||||
// Graph nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Node 1: Classify intent using structured output */
|
||||
/** Node 1: Classify intent using plain-text extraction (works with all providers) */
|
||||
async function classifyIntent(state: State): Promise<Partial<State>> {
|
||||
const llm = await getLLM();
|
||||
if (!llm) throw new Error('AI provider not configured. Please add your token in Settings.');
|
||||
|
||||
const routerLLM = llm.withStructuredOutput(RouteSchema);
|
||||
|
||||
const result = await routerLLM.invoke([
|
||||
const response = await llm.invoke([
|
||||
new SystemMessage(
|
||||
`You are a routing classifier for Adiuva, a project management workspace.
|
||||
Classify the user's message into one of these categories:
|
||||
- "project": Question about a specific project (tasks, notes, checkpoints, progress, summaries)
|
||||
- "knowledge": Cross-project or historical question (e.g., "what did we decide about X?", "find notes about Y")
|
||||
- "general": Everything else (general help, scheduling, task overviews, workspace summaries)`,
|
||||
Classify the user's message into exactly one category. Reply with ONLY the category name, nothing else.
|
||||
|
||||
Categories:
|
||||
- project: Question about a specific project (tasks, notes, checkpoints, progress, summaries)
|
||||
- knowledge: Cross-project or historical question (e.g., "what did we decide about X?", "find notes about Y")
|
||||
- general: Everything else (general help, scheduling, task overviews, workspace summaries)`,
|
||||
),
|
||||
new HumanMessage(state.userMessage),
|
||||
]);
|
||||
|
||||
return { route: result.route };
|
||||
const text = (typeof response.content === 'string' ? response.content : '').trim().toLowerCase();
|
||||
const validRoutes = ['project', 'knowledge', 'general'] as const;
|
||||
const route = validRoutes.find((r) => text.includes(r)) ?? 'general';
|
||||
|
||||
return { route };
|
||||
}
|
||||
|
||||
/** Node 2a: Project agent — answer project-scoped questions */
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AIProvider {
|
||||
initialize(token: string): Promise<boolean>;
|
||||
/** Whether the provider is initialized and ready to handle requests. */
|
||||
isReady(): boolean;
|
||||
/** If true, this provider uses external auth (e.g. CLI OAuth) and doesn't need a stored token. */
|
||||
usesExternalAuth?: boolean;
|
||||
}
|
||||
|
||||
const providers = new Map<string, AIProvider>();
|
||||
@@ -49,9 +51,12 @@ export async function saveTokenAndInit(token: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether the active provider has a stored token. */
|
||||
/** Check whether the active provider has credentials (stored token or external auth). */
|
||||
export async function hasActiveToken(): Promise<boolean> {
|
||||
const name = getActiveProviderName();
|
||||
const provider = providers.get(name);
|
||||
// Providers with external auth (e.g. Copilot CLI OAuth) don't need a stored token
|
||||
if (provider?.usesExternalAuth) return true;
|
||||
const token = await getToken(name);
|
||||
return token !== null && token.length > 0;
|
||||
}
|
||||
@@ -69,6 +74,14 @@ export async function initAI(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Providers with external auth (e.g. Copilot CLI OAuth) initialize without a stored token
|
||||
if (provider.usesExternalAuth) {
|
||||
const ready = await provider.initialize('');
|
||||
activeProvider = provider;
|
||||
console.log(`[AI] Provider "${provider.displayName}" initialized (external auth): ready=${ready}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken(name);
|
||||
if (token) {
|
||||
const ready = await provider.initialize(token);
|
||||
|
||||
Reference in New Issue
Block a user