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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@ NEXT_PUBLIC_GISCUS_REPO_ID=
NEXT_PUBLIC_GISCUS_CATEGORY=
NEXT_PUBLIC_GISCUS_CATEGORY_ID=

# AI (Anthropic)
ANTHROPIC_API_KEY=

# Google Tag Manager
NEXT_PUBLIC_GTM_ID=
28 changes: 28 additions & 0 deletions app/api/ai/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { anthropic } from '@ai-sdk/anthropic';
import { streamText } from 'ai';
import { type AIContext, buildSystemPrompt } from '@/lib/ai/system-prompt';

export const maxDuration = 30;

export async function POST(req: Request) {
const body = await req.json();
const { prompt, context } = body as { prompt: string; context?: AIContext };

if (!prompt || typeof prompt !== 'string') {
return new Response('Missing prompt', { status: 400 });
}

if (prompt.length > 500) {
return new Response('Prompt too long (max 500 chars)', { status: 400 });
}

const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
system: buildSystemPrompt(context),
prompt,
maxOutputTokens: 300,
temperature: 0.7,
});

return result.toTextStreamResponse();
}
2 changes: 1 addition & 1 deletion app/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@
"purpose": "any maskable"
}
]
}
}
4 changes: 3 additions & 1 deletion components/mate/terminal/command-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type DataSources = {

export interface TerminalTools {
clearLines: () => void;
appendStreamingText: (chunk: string) => void;
finalizeStream: () => void;
}

export interface CommandContextType {
Expand All @@ -42,7 +44,7 @@ const CommandContext = createContext<CommandContextType>({
spotify: { data: null },
github: { data: null },
} as DataSources,
tools: { clearLines: () => {} },
tools: { clearLines: () => {}, appendStreamingText: () => {}, finalizeStream: () => {} },
});

export const useCommandContext = () => useContext(CommandContext);
Expand Down
140 changes: 140 additions & 0 deletions components/mate/terminal/commands/ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { AIContext } from '@/lib/ai/system-prompt';
import type { DataSources } from '../command-context';
import type { Command, CommandContext } from '../types/commands';

function buildContextFromDataSources(dataSources: DataSources): AIContext {
const context: AIContext = {};

const spotifyData = dataSources.spotify.data;
if (spotifyData) {
context.spotify = {
currentlyPlaying: spotifyData.currentlyPlaying
? {
title: spotifyData.currentlyPlaying.title,
artist: spotifyData.currentlyPlaying.artist,
album: spotifyData.currentlyPlaying.album,
}
: null,
recentTracks: spotifyData.recentlyPlayed?.slice(0, 5).map((t) => ({ title: t.title, artist: t.artist })),
topTracks: spotifyData.topTracks?.slice(0, 5).map((t) => ({ title: t.title, artist: t.artist })),
topArtists: spotifyData.topArtists?.slice(0, 5).map((a) => ({ name: a.name, genres: a.genres })),
};
}

const githubData = dataSources.github.data;
if (githubData) {
context.github = {
login: githubData.profile?.login,
followers: githubData.profile?.followers?.length,
activities: githubData.activities?.activities?.slice(0, 5).map((a) => ({
type: a.type,
title: a.title,
repo: a.repo.fullName,
date: a.date,
})),
};
}

return context;
}

async function streamAIResponse(
prompt: string,
{ dataSources, tools }: Pick<CommandContext, 'dataSources' | 'tools'>,
): Promise<void> {
const context = buildContextFromDataSources(dataSources);

const response = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, context }),
});

if (!response.ok) {
const errorText = await response.text();
tools.appendStreamingText(`Error: ${errorText || response.statusText}`);
tools.finalizeStream();
return;
}

const reader = response.body?.getReader();
if (!reader) {
tools.appendStreamingText('Error: No response stream.');
tools.finalizeStream();
return;
}

const decoder = new TextDecoder();

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
tools.appendStreamingText(chunk);
}
} finally {
tools.finalizeStream();
}
}

export const askCommand: Command = {
name: 'ask',
description: 'Ask AIt anything',
usage: 'ask <question>',
aliases: ['ai', 'chat', 'q'],
handler: async ({ args, dataSources, tools }) => {
if (!args) return 'Usage: ask <your question>\nExample: ask what tech stack do you use?';

tools.appendStreamingText('');
await streamAIResponse(args, { dataSources, tools });
return { type: 'streamed' };
},
};

export const explainCommand: Command = {
name: 'explain',
description: "Explain a topic from Mateo's perspective",
usage: 'explain <topic>',
aliases: ['ex'],
handler: async ({ args, dataSources, tools }) => {
if (!args) return 'Usage: explain <topic>\nExample: explain your approach to testing';

const prompt = `Explain the following from your perspective and experience as a senior engineer: ${args}`;
tools.appendStreamingText('');
await streamAIResponse(prompt, { dataSources, tools });
return { type: 'streamed' };
},
};

export const summarizeCommand: Command = {
name: 'summarize',
description: 'Summarize what Mateo is about',
usage: 'summarize [topic]',
aliases: ['sum'],
handler: async ({ args, dataSources, tools }) => {
const prompt = args
? `Give a focused summary about: ${args}. Draw from your experience and projects.`
: "Give a concise summary of who you are, what you do, and what you're working on right now.";

tools.appendStreamingText('');
await streamAIResponse(prompt, { dataSources, tools });
return { type: 'streamed' };
},
};

export const tldrCommand: Command = {
name: 'tldr',
description: 'Quick take on any topic',
usage: 'tldr <topic>',
handler: async ({ args, dataSources, tools }) => {
if (!args) return 'Usage: tldr <topic>\nExample: tldr your open source work';

const prompt = `Give me a very brief, 2-3 line tldr about: ${args}`;
tools.appendStreamingText('');
await streamAIResponse(prompt, { dataSources, tools });
return { type: 'streamed' };
},
};

export const aiCommands: Command[] = [askCommand, explainCommand, summarizeCommand, tldrCommand];
2 changes: 2 additions & 0 deletions components/mate/terminal/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { registry } from '../command-registry';
import { aiCommands } from './ai';
import { aitCommand } from './ait';
import { linksCommand, siteCommand } from './navigation';
import { personalCommands } from './personal';
Expand All @@ -9,6 +10,7 @@ registry.registerGroup('System', systemCommands);
registry.registerGroup('Personal', personalCommands);
registry.registerGroup('Social', socialCommands);
registry.registerGroup('Navigation', [linksCommand, siteCommand]);
registry.registerGroup('AI', aiCommands);
registry.registerGroup('Fun', [aitCommand]);

export { registry as commandRegistry };
14 changes: 9 additions & 5 deletions components/mate/terminal/hooks/use-command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ export function useCommandExecutor({ dataSources, tools, actions }: CommandExecu
const commandName = input.trim().split(' ')[0];
trackTerminal.commandExecuted(commandName, result.success !== false);

const newLines = [
{ text: input, showPrompt: true },
...result.output.split('\n').map((text) => ({ text, showPrompt: false })),
];
actions.addCompletedLines(newLines);
if (result.streamed) {
actions.addCompletedLines([{ text: input, showPrompt: true }]);
} else {
const newLines = [
{ text: input, showPrompt: true },
...result.output.split('\n').map((text) => ({ text, showPrompt: false })),
];
actions.addCompletedLines(newLines);
}
actions.addToHistory(input);
},
[runCommand, actions],
Expand Down
21 changes: 16 additions & 5 deletions components/mate/terminal/hooks/use-command-runner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { useCallback, useMemo } from 'react';
import type { DataSources, TerminalTools } from '../command-context';
import { commandRegistry } from '../commands/index';

type CommandResult = {
export type CommandResult = {
success: boolean;
output: string;
streamed?: boolean;
};

interface CommandRunnerOptions {
Expand All @@ -17,8 +18,13 @@ export function useCommandRunner({ dataSources, tools }: CommandRunnerOptions) {

const runCommand = useCallback(
async (input: string): Promise<CommandResult> => {
const trimmedInput = input.trim().toLowerCase();
const command = commandMap.get(trimmedInput);
const trimmedInput = input.trim();
const spaceIndex = trimmedInput.indexOf(' ');
const commandName =
spaceIndex === -1 ? trimmedInput.toLowerCase() : trimmedInput.slice(0, spaceIndex).toLowerCase();
const args = spaceIndex === -1 ? undefined : trimmedInput.slice(spaceIndex + 1).trim() || undefined;

const command = commandMap.get(commandName);

if (!command) {
return {
Expand All @@ -28,8 +34,13 @@ export function useCommandRunner({ dataSources, tools }: CommandRunnerOptions) {
}

try {
const output = await command.handler({ dataSources, tools });
return { success: true, output };
const result = await command.handler({ dataSources, tools, args });

if (typeof result === 'object' && result.type === 'streamed') {
return { success: true, output: '', streamed: true };
}

return { success: true, output: result as string };
} catch (error) {
console.error('Error executing command:', error);
return {
Expand Down
6 changes: 4 additions & 2 deletions components/mate/terminal/hooks/use-terminal-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useCallback } from 'react';
import type { TerminalState, TerminalStateActions } from './use-terminal-state';

interface TerminalInputOptions {
state: Pick<TerminalState, 'userInput' | 'commandHistory' | 'historyIndex'>;
state: Pick<TerminalState, 'userInput' | 'commandHistory' | 'historyIndex' | 'streamingText'>;
actions: Pick<TerminalStateActions, 'setUserInput' | 'updateHistoryIndex' | 'addCompletedLines'>;
executeCommand: (input: string) => Promise<void>;
getMatchingCommands: (input: string) => string[];
Expand All @@ -12,7 +12,9 @@ interface TerminalInputOptions {
export function useTerminalInput({ state, actions, executeCommand, getMatchingCommands }: TerminalInputOptions) {
const handleUserInput = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
const { userInput, commandHistory, historyIndex } = state;
const { userInput, commandHistory, historyIndex, streamingText } = state;

if (streamingText !== null) return;

switch (e.key) {
case 'Enter':
Expand Down
11 changes: 11 additions & 0 deletions components/mate/terminal/hooks/use-terminal-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface TerminalState {
userInput: string;
commandHistory: string[];
historyIndex: number;
streamingText: string | null;
}

export interface TerminalStateActions {
Expand All @@ -22,6 +23,8 @@ export interface TerminalStateActions {
setUserInput: (input: string) => void;
addToHistory: (command: string) => void;
updateHistoryIndex: (index: number) => void;
setStreamingText: (text: string | null) => void;
appendStreamingText: (chunk: string) => void;
}

export function useTerminalState(_initialMessages: string[]): [TerminalState, TerminalStateActions] {
Expand All @@ -32,6 +35,7 @@ export function useTerminalState(_initialMessages: string[]): [TerminalState, Te
const [userInput, setUserInput] = useState<string>('');
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const [streamingText, setStreamingText] = useState<string | null>(null);

const addCompletedLine = useCallback((line: TerminalLine) => {
setCompletedLines((prev) => [...prev, line].slice(-100));
Expand All @@ -50,6 +54,10 @@ export function useTerminalState(_initialMessages: string[]): [TerminalState, Te
setHistoryIndex(-1);
}, []);

const appendStreamingText = useCallback((chunk: string) => {
setStreamingText((prev) => (prev === null ? chunk : prev + chunk));
}, []);

return [
{
currentLine,
Expand All @@ -59,6 +67,7 @@ export function useTerminalState(_initialMessages: string[]): [TerminalState, Te
userInput,
commandHistory,
historyIndex,
streamingText,
},
{
setCurrentLine,
Expand All @@ -70,6 +79,8 @@ export function useTerminalState(_initialMessages: string[]): [TerminalState, Te
setUserInput,
addToHistory,
updateHistoryIndex: setHistoryIndex,
setStreamingText,
appendStreamingText,
},
];
}
Loading
Loading